本文为稀土技能社区首发签约文章,14天内制止转载,14天后未获授权制止转载,侵权必究!

专栏上篇文章传送门:衍生需求:按钮集成图标组件 & 图标选择器

本节涉及的内容源码可在vue-pro-components c5 分支找到,欢迎 star 支撑!

前语

本文是 基于Vite+AntDesignVue打造事务组件库 专栏第 6 篇文章【完成一个靠谱好用的全屏组件,随手入门 Headless 组件】,聊聊一个运用频率还挺高的组件——全屏组件,趁便了解下什么是 Headless 组件,并尝试动手将一个普通组件改形成 Headless 组件。

全屏组件

咱们在项目中或多或少会用到进出全屏的功用,这样能够最大化利用可视区域,可是,完成一个完善的全屏功用并不简略。

首要,各浏览器内核关于全屏 API 的完成不一样,你或许会看到诸如requestFullscreen, webkitRequestFullScreen, mozRequestFullScreen, msRequestFullscreen之类的进入全屏的办法,退出全屏的办法也不例外。

其次,各浏览器中能用来判别全屏状况的特点和办法也不尽相同,比方document.fullscreenElement, document.webkitFullscreenElement等等,甚至有的状况下用document.fullscreenElement也无法准确反映全屏的状况,比方你在 Chrome, Edge, Firefox 等浏览器中经过 F11 按键进入全屏后,document.fullscreenElement的值会是null,而且fullscreenchange事情也不会触发;而经过调用requestFullscreen() API 进入全屏后,document.fullscreenElement的值便是正确的。

关于做项目的开发者们来说,这种不一致就让人很恼火,由于咱们仅靠document.fullscreenElement并不能确保在界面上能够反馈正确的状况,此时咱们需求寻觅一种办法 hack,处理这种不一致问题。

完成一个靠谱好用的全屏组件,随手入门 Headless 组件

全屏/退出全屏的触发方法比较多,或许有经过按键F11, ESC等触发,也有或许经过监听某个界面元素的交互事情并结合全屏 API 触发,这会让全屏的状况判别变得更杂乱。

为了处理这些问题,咱们有必要把这些繁琐和不确定性会集处理掉,对外供给洁净、简洁、一致的 API。那么咱们要做哪些事情呢?我想大概有以下几点:

  • 检测当时环境是否允许/支撑全屏才能(对应fullscreenEnabled)。
  • 供给进入/退出全屏的 API(姓名可所以enterFullscreen, exitFullscreen)。
  • 供给统一的判别全屏状况的办法(姓名可所以isFullscreen)。
  • 供给获取全屏元素的办法(姓名可所以getFullscreenElement)。
  • 供给监听/撤销监听全屏事情的才能(姓名可所以subscribeFullscreenChange, unsubscribeFullscreenChange

检测当时环境是否允许/支撑全屏才能

由于浏览器厂商的详细完成差异,或许会出现部分浏览器不支撑全屏 API的状况,或许有供给某种配置或开关,能够做到启用/禁用全屏特性。因而最保险的做法是:在咱们运用全屏 API 之前,做一次全屏特性支撑度检测。

检测的逻辑并不杂乱,只需将规范的fullscreenEnabled用上,一起将浏览器前缀考虑在内即可。

/**
 * @description 判别浏览器当时状况是否允许启用全屏特性
 */
export function isFullscreenEnabled(): boolean {
    return !!(document.fullscreenEnabled || document.webkitFullScreenEnabled || document.mozFullScreenEnabled || document.msFullScreenEnabled);
}

TypeScript 类型扩展

可是咱们能够发现,在运用 TypeScript 编写这部分代码时,IDE 会在类型上给咱们抛出错误信息,这是由于规范的lib.dom.d.ts中没有声明带有各个浏览器前缀的 API,所所以不能直接用webkitFullScreenEnabled, mozFullScreenEnabled等办法的。

完成一个靠谱好用的全屏组件,随手入门 Headless 组件

而为了照料各种浏览器,咱们又不得不写这些兼容代码。因而,咱们需求对interface Document做一些扩展,使得扩展出来的类型能够支撑调用webkitFullScreenEnabled等办法。

考虑到document对象是浏览器运行时的大局特点,第一种做法是直接在global上扩展Document接口。

declare global {
    interface Document {
        webkitFullScreenEnabled?: boolean
        mozFullScreenEnabled?: boolean
        msFullScreenEnabled?: boolean
    }
}

.ts文件中,经过declare global能够扩展大局类型,再依靠interface的 Merge 才能,咱们就能对Document接口进行扩展,弥补一些运行时特有的特点或办法。此时,咱们能够观察到类型错误信息现已不存在。

完成一个靠谱好用的全屏组件,随手入门 Headless 组件

另一种做法是界说一个子类型(SubType)继承Document接口,咱们把这个子类型命名为EnhancedDocument,再对这个子类型做扩展,接着用类型断语将document对象断语为EnhancedDocument类型。

interface EnhancedDocument extends Document {
    webkitFullScreenEnabled?: boolean
    mozFullScreenEnabled?: boolean
    msFullScreenEnabled?: boolean
}

Sometimes you will have information about the type of a value that TypeScript can’t know about.

类型断语是一个从抽象到更详细的做法,有时分咱们能知道一些 TypeScript 无法感知的类型信息。在 TypeScript 层面,它以为 document 便是 Document 类型,这是由于 TypeScript 无法确定详细的运行时环境是什么样的。而作为开发者,咱们很清楚,当代码在浏览器执行时,它或许会有webkitFullScreenEnabledmozFullScreenEnabled等可选特点(取决于浏览器完成),所以断语为EnhancedDocument类型也是合理的。

完成一个靠谱好用的全屏组件,随手入门 Headless 组件

进入/退出全屏

关于进入全屏而言,触发的方针元素或许是document.body,也或许是详细的某一个页面元素。考虑到调用requestFullscreen会回来一个 Promise,咱们能够将enterFullscreen封装为一个异步函数。

/**
 * 进入全屏
 * https://developer.mozilla.org/zh-CN/docs/Web/API/Element/requestFullScreen
 * @param {EnhancedHTMLElement} [element=document.body] - 全屏方针元素,默许是 body
 * @param {FullscreenOptions} options - 全屏选项
 */
export async function enterFullscreen(element: EnhancedHTMLElement = document.body, options?: FullscreenOptions) {
    try {
        if (element.requestFullscreen) {
            await element.requestFullscreen(options)
        } else if (element.webkitRequestFullScreen) {
            await element.webkitRequestFullScreen()
        } else if (element.mozRequestFullScreen) {
            await element.mozRequestFullScreen()
        } else if (element.msRequestFullscreen) {
            await element.msRequestFullscreen()
        } else {
            throw new Error('该浏览器不支撑全屏API')
        }
    } catch (err) {
        console.error(err)
    }
}

退出全屏有一点不一样,由于退出全屏的 API 只在 Document 接口中有界说,这一点能够参考Fullscreen API Standard。

完成一个靠谱好用的全屏组件,随手入门 Headless 组件

退出全屏的代码封装如下:

完成一个靠谱好用的全屏组件,随手入门 Headless 组件

其中有一个webkitExitFullscreenwebkitCancelFullScreen让我迷惑了一会,最后从 WebKit JS 的文档中了解到现已不主张运用webkitCancelFullScreen了。

完成一个靠谱好用的全屏组件,随手入门 Headless 组件

为了避免写太多as类型断语,这儿经过一个变量doc接收了document的值,一起将doc的类型声明为EnhancedDocument

完成一个靠谱好用的全屏组件,随手入门 Headless 组件

从类型兼容的视点看,EnhancedDocumentDocument的子类型,一个父类型的值(document)赋给一个子类型的变量(doc)看起来好像不是类型安全的,可是实践赋值过程中并没有报类型错误,这好像有违我之前的认知。

你能够把狗赋值给动物类型,可是不能把动物赋值给狗类型。这就符合类型安全。

仔细观察后,我发现这是由于EnhancedDocument扩展的特点都是可选的,这种时分,TypeScript 会以为EnhancedDocumentDocument是互相兼容的。从类型的运用上来看也是安全的,假如你要用到可选特点,必然少不了要用到类型护卫。

一旦咱们给EnhancedDocument添加一个必选特点,这种赋值就违背类型兼容了。

完成一个靠谱好用的全屏组件,随手入门 Headless 组件

获取全屏元素

获取全屏元素也只能经过document上的fullscreenElement特点获得,这在规范中也有界说。

完成一个靠谱好用的全屏组件,随手入门 Headless 组件

代码相对简略,封装如下:

完成一个靠谱好用的全屏组件,随手入门 Headless 组件

判别全屏状况

规范中没有告知咱们怎样判别全屏状况,可是咱们能够在【获取全屏元素】的根底上得到启发。假如经过getFullscreenElement函数得到的结果不是null,就能够以为当时是全屏状况。

/**
 * @description 判别当时是否是全屏状况
 */
export function isFullscreen(): boolean {
    return !!getFullscreenElement() || window.innerHeight === window.screen.height
}

为了确保准确性,我还加了一个或的逻辑(判别视口高度和屏幕高度是否一致)。

监听/撤销监听全屏事情

事情监听也不杂乱,主要是将参数的支撑做好,而且把浏览器兼容性考虑在内。

完成一个靠谱好用的全屏组件,随手入门 Headless 组件

完成一个靠谱好用的全屏组件,随手入门 Headless 组件

全屏状况一致性问题

前面介绍了好几个使用层面的 API,可是咱们还遗漏了一个重要问题,便是在上文中说到的 F11 按键和调用 API 的不一致问题,这会导致咱们在获取全屏元素和判别全屏状况时都有或许出错。

我的做法是:既然 F11 的行为与预期不一致,那我就将 F11 按键逻辑优化一下,制止其默许行为(进入全屏),并依据当时是否是全屏状况调用enterFullscreen()或许exitFullscreen()。这样一来,就能确保进入全屏的进口都是经过 API 触发的,从而确保全屏状况的一致性。

/**
 * 阻止F11按键的默许行为,并依据当时的全屏状况调用进入/退出全屏,
 * 处理经过F11按键和API两种方法进入全屏时出现的状况不一致问题。
 */
export function patchF11DefaultAction(): void {
    window.addEventListener('keydown', (e) => {
        // https://w3c.github.io/uievents-code/
        if (e.code === 'F11') {
            e.preventDefault()
            if (isFullscreen()) {
                exitFullscreen()
            } else {
                enterFullscreen()
            }
        }
    })
}

假如您想了解全屏API更细致的内容,能够查阅Fullscreen API Standard。

封装为 Vue 组件

对根底的全屏API做了封装后,咱们就能够在此根底上封装一个全屏事务组件了。

中心逻辑不杂乱,主要是:

  • 依据当时是否是全屏状况,在 UI 上供给进入/退出全屏的才能。
  • 在适当的时机检查全屏状况,比方挂载/全屏事情触发后。
  • 供给函数类型的特点getElement,让调用者能够自由选择进入全屏的方针元素。之所以供给函数类型的getElement,是为了兼容 dom 异步挂载的状况。

由于不同的项目或许对全屏这块的 UI 完成有不同的要求,这儿就不细说了,唯一要注意的是全屏态的叠加问题,假如你期望操控 top layer 的叠加问题,就需求在逻辑中操控好进出全屏的顺序问题(比方先退出,再进入,确保只有一个全屏 layer)。注意看 body 和 div 标签右侧的 top-layer。

假如你想要了解组件的详细完成,能够前往源码查看。

Headless 组件

Headless 组件的概念能够类比于 Headless 浏览器,其中心是一种重逻辑、轻 UI 的思想。

引证 TanStack Table 给出的介绍:

Headless UIis a term for libraries and utilities that provide the logic, state, processing and API for UI elements and interactions, butdo not provide markup, styles, or pre-built implementations.

虽然各大盛行组件库都供给了较为通用的款式,而且也能经过覆盖款式支撑必定程度上的定制。可是,这种 UI 范式也很难满意杂乱的定制需求,咱们或许会有这样的困惑:

  • 分明逻辑很相似,我却无法复用这个组件,需求改源码或许重新开发一个新组件。
  • 这个组件很符合我的需求,需求做到一半时发现:就差一个 div 不能定制了,其他的都满意需求……
  • 本来 2 人天的需求,由于某个 UI 组件不可控,直接导致人天翻倍。

关于事务开发者来说,咱们或许会提出这样的诉求:组件库能不能在供给一套 UI 完成的一起,把组件的所有状况和 API 都敞开出来,让咱们有自行完成 UI 烘托的或许性呢?这在某种程度上和 Headless 组件的理念不谋而合。

我对 Headless 的了解

介绍 Headless 组件的文章也有不少了,这儿简略谈谈我对 Headless 组件的一点浅显的了解和观点。

在我看来,Headless 组件适合的场景是:

  • 组件逻辑相对简略,可是 UI 通用性不强,常常需求依据事务需求定制 UI 的场景。
  • 组件逻辑很杂乱,需求经过抽象来完成复用,可是服务的上层一般不是详细的事务项目,大概率是组件库。
  • 跨结构复用,状况和逻辑用纯 js 办理,上层使用再针对结构去做适配层。

举实践的比如阐明下:

场景1:我要完成一个全屏组件,可是有的事务项目期望全屏组件对应的 UI 是一个按钮,有的事务项目期望是一个图标,有的期望是图标 + 文字,甚至有更多或许性……虽然在 UI 方面有多样性的需求,可是底层逻辑都是一样或相似的,无非便是操控进出全屏、监听全屏的状况等。这种时分,供给一个可复用的 Hook 或许 Headless 组件是值得考虑的。

场景2;我地点的公司是字节挑逗(瞎编的),公司有两个子品牌,一个是 dy,一个是 tt,两个团队都有一套组件库,都完成了比较杂乱的 Table, Form 等组件,而且都服务了很多个上层事务,或许从直观上看,两套组件库主要是 UI 长得有点不一样,可是底层逻辑差不多。此时,我期望两个品牌方团队能共用一套逻辑完成组件库,将关键逻辑下沉。那么 Headless 组件或许是一个处理计划。

场景3:我地点的公司是字节挑逗,公司的前端结构既有 Vue,也有 React,在这两套结构之上,都完成了对应的组件库,此时我想把逻辑下沉完成更大程度复用,状况和逻辑不再依靠任何结构(纯 js 撸一套,或许再用个类封装下),而在详细的结构之上再做适配工作(将底层封装好的状况和逻辑结构中的状况/特点/事情等概念结合起来)。当然,这也适用于跨渠道的场景

Headless 是直接服务事务方,仍是服务特定结构下的 UI 组件库,亦或是对接结构或渠道的适配层,都是有或许的,这需求结合实践场景来考虑。Headless 不是全能和普适的,但的确给咱们供给了一个新的值得探索的思路。

开发一个 Headless 组件

虽然 Headless 组件也火了一段时间了,可是现在在社区中还没有形成对 Headless 的共识,没有一个咱们公以为最佳实践的做法。咱们的第一个问题或许是:我开发的 Headless 组件要对外输出什么内容?是一个组件,仍是一段逻辑?

从 Headless 组件的中心思想——逻辑层与表现层解耦(详细表现为:内部封装状况和逻辑,对外支撑 UI 的高度定制化)来看,这好像与 Render Props / 效果域插槽 / Hooks 等概念有必定的相似性。假如要跨结构或许跨渠道,Headless 组件或许也是纯 JS 的。这就决定了 Headless 组件并不是拘泥于某一种特定的形式,从现在社区中有的一些产品中,咱们也能看出一些端倪。

  • 比方 Semi Design 就将一个组件分为了 Foundation 和 Adapter 部分,Foundation 负责完成组件通用的 JS 逻辑,Adapter 则是针对各个前端结构的适配层。

完成一个靠谱好用的全屏组件,随手入门 Headless 组件

  • React Hook Form也是一种 Headless 的完成,其在 Hook 内部把表单的中心逻辑都完成了,对外供给了状况,办法等,你只需拿着露出出来的状况和 API,与视图做交互即可,这样一来,你能够在表单 UI 的完成上发挥充沛的想象力,而不是局限于修正 css 或许拿着几个有限的 Render Props 做定制。

完成一个靠谱好用的全屏组件,随手入门 Headless 组件

  • 还有直接挂上 Headless 招牌的 TanStack Table。

完成一个靠谱好用的全屏组件,随手入门 Headless 组件

TanStack Table 在底层用纯 JS 完成了通用的 core 中心,并在上层供给了各大前端结构的 Adapter,当然你也能够选择直接用它的中心模块createTable

完成一个靠谱好用的全屏组件,随手入门 Headless 组件

以 Vue 为例,对外供给的useVueTable便是将createTable中心与 Vue 的各个 API 做了 binding。

完成一个靠谱好用的全屏组件,随手入门 Headless 组件

你或许会以为这跟 Hooks 之类的没有区别,这无可厚非,它们的确很相似。不过换个视点看,你能够以为 Hooks 之类的技能底座,是完成 Headless 组件的一种方法或许途径,可是它们并不是严格意义上的一回事。

以咱们现在完成的这个全屏组件而言,其实它最适合的 Headless 形式是 Hook。首要,我做的这个组件库是面向 Vue 结构的,并不需求像 Semi Design 或许 TanStack Table 这类计划一般供给 JS 层面的抽象。因而,咱们借助 Vue Composition API,就能很快抽象出一个可复用的 Headless 组件,这样一来,事务方基于此就能很快定制出自己想要的 UI 效果。

说了一圈,好像又堕入僵局了。额,Headless 可所以 Hook,也能够不是,不要纠结。

那么咱们就以这个全屏组件为例说说,怎样做一个 Headless 组件。

不论 UI 怎样变,其实只重视两个事情:

  • 当时是否为全屏状况
  • 切换全屏状况的 API

所以,咱们能够把逻辑抽象成这样,对外只露出isTargetFullscreentoggleFullscreen即可:

完成一个靠谱好用的全屏组件,随手入门 Headless 组件

这样一来,咱们封装的全屏组件就能以这个 Hook 为根底简化:

完成一个靠谱好用的全屏组件,随手入门 Headless 组件

一起,外部也能够直接运用@vue-pro-components/headless供给的useFullscreen才能,完成 UI 自主可控(比方用一个开关组件承载全屏才能)。

完成一个靠谱好用的全屏组件,随手入门 Headless 组件

完成一个靠谱好用的全屏组件,随手入门 Headless 组件

结语

本文和前2篇文章都聚焦于怎样完成根底的杂乱度不高的事务组件,看起来或许有点枯燥乏味,但也是为了打个根底,引导部分还不太熟悉组件开发的读者渐渐进入状况,把握组件开发的一些基本思想。实践上,开发组件发布可用的组件之间还隔着一条距离,这便是从开发环境到出产环境必经的路,也是组件库研制过程中最杂乱的部分。要跳过这条距离,就必须把握一些工程化才能。假如您对我的专栏感兴趣,欢迎您订阅重视本专栏,接下来能够一同讨论和沟通组件库开发过程中遇到的问题。