实现一个靠谱好用的全屏组件,顺手入门 Headless 组件

本文为稀土掘金技术社区首发签约文章,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,解决这种不一致问题。

全屏状态不一致.gif

全屏/退出全屏的触发方式比较多,可能有通过按键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等方法的。

image.png

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

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

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

.ts文件中,通过declare global可以扩展全局类型,再依靠interface的 Merge 能力,我们就能对Document接口进行扩展,补充一些运行时特有的属性或方法。此时,我们可以观察到类型错误信息已经不存在。

image.png

另一种做法是定义一个子类型(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类型也是合理的。

image.png

进入/退出全屏

对于进入全屏而言,触发的目标元素可能是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

image.png

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

image.png

其中有一个webkitExitFullscreenwebkitCancelFullScreen让我迷惑了一会,最后从 WebKit JS 的文档中了解到已经不建议使用webkitCancelFullScreen了。

image.png

为了避免写太多as类型断言,这里通过一个变量doc接收了document的值,同时将doc的类型声明为EnhancedDocument

image.png

从类型兼容的角度看,EnhancedDocumentDocument的子类型,一个父类型的值(document)赋给一个子类型的变量(doc)看起来似乎不是类型安全的,但是实际赋值过程中并没有报类型错误,这似乎有违我之前的认知。

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

仔细观察后,我发现这是因为EnhancedDocument扩展的属性都是可选的,这种时候,TypeScript 会认为EnhancedDocumentDocument是互相兼容的。从类型的使用上来看也是安全的,如果你要用到可选属性,必然少不了要用到类型守卫。

一旦我们给EnhancedDocument增加一个必选属性,这种赋值就违背类型兼容了。

image.png

获取全屏元素

获取全屏元素也只能通过document上的fullscreenElement属性取得,这在标准中也有定义。

image.png

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

image.png

判断全屏状态

标准中没有告诉我们怎么判断全屏状态,但是我们可以在【获取全屏元素】的基础上得到启发。如果通过getFullscreenElement函数得到的结果不是null,就可以认为当前是全屏状态。

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

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

监听/取消监听全屏事件

事件监听也不复杂,主要是将参数的支持做好,并且把浏览器兼容性考虑在内。

image.png

image.png

全屏状态一致性问题

前面介绍了好几个应用层面的 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。

全屏top-layer叠加.gif

如果你想要了解组件的具体实现,可以前往源码查看。

Headless 组件

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

引用 TanStack Table 给出的介绍:

Headless UI is a term for libraries and utilities that provide the logic, state, processing and API for UI elements and interactions, but do 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 则是针对各个前端框架的适配层。

image.png

  • React Hook Form也是一种 Headless 的实现,其在 Hook 内部把表单的核心逻辑都实现了,对外提供了状态,方法等,你只要拿着暴露出来的状态和 API,与视图做交互即可,这样一来,你可以在表单 UI 的实现上发挥充分的想象力,而不是局限于修改 css 或者拿着几个有限的 Render Props 做定制。

image.png

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

image.png

TanStack Table 在底层用纯 JS 实现了通用的 core 核心,并在上层提供了各大前端框架的 Adapter,当然你也可以选择直接用它的核心模块createTable

image.png

以 Vue 为例,对外提供的useVueTable就是将createTable核心与 Vue 的各个 API 做了 binding。

image.png

你可能会认为这跟 Hooks 之类的没有区别,这无可厚非,它们确实很相似。不过换个角度看,你可以认为 Hooks 之类的技术底座,是实现 Headless 组件的一种方式或者途径,但是它们并不是严格意义上的一回事。

以我们目前实现的这个全屏组件而言,其实它最适合的 Headless 形式是 Hook。首先,我做的这个组件库是面向 Vue 框架的,并不需要像 Semi Design 或者 TanStack Table 这类方案一般提供 JS 层面的抽象。因此,我们借助 Vue Composition API,就能很快抽象出一个可复用的 Headless 组件,这样一来,业务方基于此就能很快定制出自己想要的 UI 效果。

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

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

不管 UI 怎么变,其实只关注两个事情:

  • 当前是否为全屏状态
  • 切换全屏状态的 API

所以,我们可以把逻辑抽象成这样,对外只暴露isTargetFullscreentoggleFullscreen即可:

image.png

这样一来,我们封装的全屏组件就能以这个 Hook 为基础简化:

image.png

同时,外部也可以直接使用@vue-pro-components/headless提供的useFullscreen能力,实现 UI 自主可控(比如用一个开关组件承载全屏能力)。

image.png

useFullscreen.gif

结语

本文和前2篇文章都聚焦于怎么实现基础的复杂度不高的业务组件,看起来可能有点枯燥乏味,但也是为了打个基础,引导部分还不太熟悉组件开发的读者慢慢进入状态,掌握组件开发的一些基本思想。实际上,开发组件发布可用的组件之间还隔着一条鸿沟,这就是从开发环境到生产环境必经的路,也是组件库研发过程中最复杂的部分。要越过这条鸿沟,就必须掌握一些工程化能力。如果您对我的专栏感兴趣,欢迎您订阅关注本专栏,接下来可以一同探讨和交流组件库开发过程中遇到的问题。

© 版权声明
THE END
喜欢就支持一下吧
点赞5 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容