曾经在 React、Vue、Angular 这些框架还没有大火的时候,页面上要想实现一些组件的复用是相当的麻烦,可以通过加载一个自己实现的 function createComponent ,又或者直接 Copy 组件代码。但实现的方法可能在操作 Dom 的时候造成很多性能问题,而 Copy 代码也会导致维护起来异常困难。
那个时候的 React 等框架横空出世,除开性能等优化问题外,也在开发和维护上变得更加方便,同时也让开发者的目光相对远离了 web component,这个浏览器本身就支持的 api。
Main Technologies
Web Components 允许您创建可重用的定制元素,它由三项主要技术组成
- Custom elements(自定义元素)
- Shadow DOM(影子 DOM)
- HTML templates(HTML 模板)
主要目的是使用来创建封装功能的定制元素,可以在需要的任何地方重用,而不必担心代码或样式冲突。
Custom elements
允许定义 custom elements 及其行为的 JavaScript API,具有 Lifecycle
- connectedCallback:当自定义元素第一次被连接到文档 DOM 时被调用。
- disconnectedCallback:当自定义元素与文档 DOM 断开连接时被调用。
- adoptedCallback:当自定义元素被移动到新文档时被调用。
- attributeChangedCallback:当自定义元素的一个属性被增加、移除或更改时被调用。
- formAssociatedCallback:元素与表单元素相关联或取消元素与表单元素的关联时调用。
- formDisabledCallback:在元素状态发生 disabled 变化后调用。
- formResetCallback:重置表单后调用。
- formStateRestoreCallback:浏览器恢复元素的状态时或输入辅助功能(例如表单自动填充)设置一个值时调用
以上的 Api 并非全部都需要用到,与 form 有关的 api,只在与表单元素结合后使用,这里就不详细说明了,具体可以看 more-capable-form-controls,可以看出这些 Api 和目前流行框架的生命周期方法都比较相似,如 React 的 didMount、unMount。
而整个 Web Component 的 Reaction Queue,也如下图所示
Shadow DOM
将元素与页面上的其他元素隔离开,保证 web components 和其他的 DOM 元素的结构、样式和行为不会混在一起。
shadow dom 主要是为隔离,它可以和操作常规 DOM 一样——例如 appendChild、setAttribute、添加自己的 style。但是它不会去影响其他的元素。
就像 Video 等标签,开发者可以直接使用或者设置一些属性,但是不需要去处理 Video 标签内部结构,而且也无法访问内部结构。
HTML templates
templates 主要是方便重复使用网页上相同的标记结构,<template>
及其内容不会在 DOM 中呈现,但可使用 JavaScript 去引用它。
严格来说 Templates 并不是属于 web components,而是 web components 加上 templates 后会变得更强大,
<template>
还可以结合 <slot>
,有用过 vue 的话应该会更容易去理解,slot 由 name 属性标识,允许在模板中定义占位符,当在标记中使用该元素时,slot 可以填充所需的 HTML 结构。
用 React 来做个不恰当的比喻,Custom elements 就是 Lifecycle,Shadow DOM 就像 CreateRoot,HTML templates 就是 Render 里面的 HTML,当然 Web Components 的内容也远不止这些
Use Web Components Lib
直接使用最原始的 Web Components Api 在开发的效率上难以应对团队的需求,就像以往操作 Dom,直接使用 Browser Api 远不如 Jquery 的效率高,而且维护成本也更高,因此在基于 WC 的 Stencil、Lit、Omi、X-Tag 等框架中,最终我选择了 Lit来开发一个 Admin 。
Lit.dev
Lit 是一个用于构建快速、轻量级 Web 组件的库,它提供反应状态、作用域样式和一个小巧、快速且富有表现力的声明性模板系统。
- Simple: 在 WC 标准的基础上,添加了反应性、声明性模板等一些功能,以减少样板文件
- Bundle Size:较小的体积约为 5 KB (minified and compressed)
- Performance:更新时只触及 UI 的动态部分——无需重建虚拟树并将其与 DOM 进行比较
- Maintainable:Lit 组件都是原生 Web 组件,可以在使用 HTML 的地方使用,无论是否使用任何框架
- Community:使用人数较多和相对完善的社区,可以容易找到解决方案
比起 React、Vue 等框架还不算非常成熟,但相对其他 WC 框架来说算是不错的选择,如果有打算尝试或者使用 WC 框架的,个人比较推荐 Lit,个人 Demo
感受
在使用 Lit 完成项目的开发和搭建过程中,遇到了一些问题,虽然最后都 fix 掉,但是在思考的过程中也有了一些对于 WC 和 WC Lib 的感受。
Class Components
目前流行框架基本都是 function components,hooks 的规范,class Components 已经相对较少,并不是 FC 就比 Class C 好,而是用法感觉像回到 React 15 / Vue2 。
有一些 WC 的框架本身就是 JSX 和使用 FC,但 Web Components 是自定义元素。每个 HTML 元素,无论是自定义的还是内置的,都是对象图 (DOM) 的一部分。换句话说,DOM 中的每个节点都是一个对象,它是 HTMLElement 直接(在自定义元素的情况下)或某些其他元素类(如 HTMLParagraphElement、HTMLHeadingElement 等)的子类。
所以使用 Class 来构建也非常贴切,如果想使用 function/hooks 等构建 WC。可以通过第三方 API 启用,但在幕后所做的只是将这些概念映射到底层对象和 DOM API 使用的 OOP(面向对象编程)范式。
Encapsulation
Shadow Dom 带来了 Scoped CSS 这个特性,虽然避免样式污染,但是如果是一个业务复杂的项目上,复用也同样变得麻烦了点,Css Variable是一个解决办法。
Isolated DOM,获取 Dom 节点原先可以直接通过 document.queryselector
来获取,而 Shadow DOM 有点不一样,需要将 mode: "open"
才可以用 JS 方法来获取 Shadow DOM,将 mode 设置为 closed,就不能从外部获取 Shadow DOM。
用 CustomElementRegistry.define
注册一个 Web 组件需要一个唯一的 name
,组件的颗粒化导致在创建组件时的命名也变得麻烦,而不像 React 直接 export
。
而元素间的数据或者状态通信可以使用以下的方式进行处理,但多层级或者全局的数据也不好维护,但如果使用 Lit 这类 Lib,也会有配套的 State 和 Context 等功能可以用。
- Using attributes
- Using properties(getter/setter)
- Using events
- Using an event bus
Community
虽然可以使用 WC 或基于它的框架构建几乎任何类型的 Web UI,但目前并不算非常流行,因此整个生态来说比较弱,容易遇到很多需要花时间的问题。
- 没有完善的 Cli,@lit-labs/cli 目前也在开发中,Lit 给的
Starter Kits
和 Open Web Components 的并不一致 - develop 和 Testing,Open Web Components推荐的是 Web Dev Server 和 Web Test Runner,但是目前绝大多数都是使用 Vite 或者 Webpack 的 Server,而 Web Test Runner 与 Web Dev Server 更像是 配套使用
- Router、Context、Analyzer 等 Labs 不够完善,无论是基于原生 WC 还是基于 Lit,Lit 的 Labs 都还在开发中,虽然部分 Labs 能用但是限制较多
- UI Components Lib,目前并没有一个非常出名的 WC Components Lib,material-web 还在 Beta 阶段,shoelace 是我选择的 Lib,主要是基于 Lit 并且在 Lit 社区中也有推荐
- SSR,Web 组件依赖于服务器上不可用的特定于浏览器的 DOM API,但是可以通过服务器上预渲染 Web 组件并使用声明式 Shadow DOM 来实现 SSR 或者使用 Puppeteer 来控制 Chrome/Chromium,Lit 正开发 @lit-lab/ssr,目前还在 Beta,而且 limitations 比较多
除上面提到的问题,目前维护力度和文档感觉是不够的,在使用 Apollo 的过程中,遇到文档上没找到用法而去看源码的情况,作为 Google 出品而且在使用人数较多和社区比较知名的情况下,还是有许多的缺失导致无法满足业务场景。因此感觉要有一个比较好的 Cli,包含项目需要的大部分 Lib 才能发展生态,develop、testing、build 的 Tools、UI、Lib 等的不统一加大了新使用者的上手难度,增加解决问题的时间成本。
Build
Build 在工具上没有特别的地方,可以用 Webpack 或者 Rollup,更加推荐 Rollup , 它使用 JavaScript ES6 中代码模块的新标准化格式,可以优化 ES modules 以在现代浏览器中更快地进行加载,或者输出允许 ES 模块工作流程的 legacy module 格式。
Vite 的 Build 部份也是使用 Rollup
最主要的是 Build 的产物,虽然可以用 Rollup 打包出 legacy module,但 ES modules 是值得关注的地方,以往的 bundle 产物,使用了 Polyfill、Babel、Plugin 等来兼容各种浏览器,在配置和优化上都变得更加麻烦。而使用 ES modules 可以带来这些好处
- 借助 Es Module 的静态导入导出的优势,实现 tree shaking
- Es Module 可以使用 import() 懒加载方式实现代码分割
- 构建依赖图,可以更快查找导入,CJS 会生成一个 module 实例对象,CJS 需要访问一个对象进行动态属性查找
- 可以更方便的支持其他语言 静态的可以进行编译
- 每个 .js 都是独立的作用域(scope) 和 自行 import 自己的依赖,沒有顺序相依问题,也不用担心数据污染
Future
Web Components 一直都在发展中,以下是感觉比较重要和有意思的点,但并不绝对,除此之外 Web Components 还有非常多的功能和特性也在开发当中
Lazy upgrade
Custom Elements,在 HTML 的解析中,都是在浏览器在解析后立即 upgrade,而 Custom Elements 本身经过扩展后就更加复杂,立刻执行容易导致出现性能问题。
为了减少首屏加载时 Custom Element 带来 CPU 和内存使用量,需要实现一个 Lazy 特性。
去看了一下源代码,upgrade 的流程根据自己的理解大概意思是
- 检查当前 root 是否在 CustomElementData 中,CustomElementData 只在 custom element 上创建,或 upgrade candidate(待升级元素),CustomElementData 是
uncustomized
(非定制)的 - 查看 State 是否为 undefined,如果不是 undefined 就根本没有必要 upgrade
- 获取 root 的 CustomElementDefinition
- 设置 State 为 failed
- 如果 ObservedAttributes.IsEmpty() = true 就跳过,!= true 就遍历 Attribute 并且把
IsInObservedAttributeList(attrName) = true
的属性将它的attributeChangedCallback
加到EnqueueLifecycleCallback
,在后续改变 attr 的时候可以执行 Lifecycle 的attributeChangedCallback
- 如果已经 Connected,
connectedCallback
加到EnqueueLifecycleCallback
- 设置 construction stack
- DoUpgrade, upgrade 的时候会把 State 改为 precustomized,如果有 error 抛出,例如
constructor() { throw new Error() }
则,清空 ReactionQueue、CustomElementDefinition 等之前的设置 - 如果是 IsFormAssociated,也就是在 define 的时候设置
formAssociated = true
用来做表单的元素,会执行UpdateFormOwner
,然后设置 FormOwner、FieldSet、DisabledState、FromConstraintValidation,主要是设置 4 个 form 有关的 Lifecycle - CustomElementData 的 state 设置为 custom,执行 SetDefined(true)
目前建议的解决方案是 lazy upgrade 未显示在屏幕上或可能很快出现在屏幕上的自定义元素,或因使用 CSS 使它们在不更改 DOM 状态下不可见。
不可显示元素的示例:
- 具有 display:none style 或 hidden 属性的元素。
- 删除空间时具有 visibility:collapse 的元素(tables 和 flex children)。
- shadow root 的 未分配的轻量级 DOM child。
- 非活跃(content-visibility:auto|hidden)子树中的元素。
- element 未附加到 document。
因此需要手动和自动 lazy 两种方式,自动的可以以上面的条件和是否在屏幕内来决定是否进行 upgrade,有点像 lazyload 的思路。而手动的则是
- 在 create Element 的时候增加
static deferredUpgrade(element: Element): boolean
,将该 element 加到 pending-upgrade 元素队列 - 在相关样式更改时检查 pending-upgrade 元素队列,如果变为可显示,则 upgrade,当然在浏览器空闲时即使是不可显示元素也可以进行 upgrade
目前方案在提案中,所以后续有可能改变,如命名deferredUpgrade
可能换成lazyupgrade
等,而功能方面,像 Lifecycle 是否要对应增加 upgradeCallback
,在customElements.upgrade
改造还是新增一个customElements.lazyupgrade
,如何定义声明式属性在 HTML 标签中使用<my-element upgrade="eager|auto">
,可能还有其他受影响的场景。
主要思路比较确定,覆盖所有场景和规范命名应该不是大问题,而且这策略还可以放到 define 中,实现一个lazyDefine
用来做Dynamic imports,如 customElements.lazyDefine('my-element', () => import('./my-element.js')));
,这样也有 code-split 的好处。
Tips:个人觉得和 React 有点像,从 class 的同步到 hooks 的异步,有 lazy,也需要一个 Scheduler 去 upgrade,不知道是不是可以借鉴一下,也许以后有原生 hooks 的 web components
Declarative Shadow DOM
声明式 Shadow DOM,使用 Shadow DOM 的方法是使用 JavaScript 构建 Shadow Root
const host = document.getElementById('host')
const shadowRoot = host.attachShadow({ mode: 'open' })
shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>'
像命令式 API 适用于客户端渲染,在 SSR 的时候,因为 Server 端并没有 Dom Api,虽然像 Node 可以使用 Puppeteer 这类库来处理,但在性能、使用成本都会更难以接受。
将 Shadow Roots 附加到没有它们的的 DOM 元素时,会导致布局重排对性能产生影响,可能导致页面 UI 发生变化或在加载 Shadow Root 的样式表时闪烁(FOUC)。
<!-- 不支持 Declarative Shadow DOM 的浏览器会保留 <template>,可以使用以下代码来防止 FOUC --> <style> x-foo:not(:defined) > template[shadowroot] ~ * { display: none; } </style>
Declarative Shadow DOM 消除了这些限制,让 HTML 解析器检测 shadowroot 去生成 Dom 树。
<host-element>
<template shadowroot="open">
<slot></slot>
</template>
<h2>Light content</h2>
</host-element>
<!-- Parse -->
<host-element>
#shadow-root (open)
<slot>
↳
<h2>Light content</h2>
</slot>
</host-element>
目前的 Declarative Shadow DOM 兼容性不是非常好,可能还是需要 polyfill ,但是 polyfill 的的思路也很简单,扫描 DOM 找到所有 <template shadowroot>
元素,然后将它们转换为附加在其父元素上的 Shadow Roots.
;(function attachShadowRoots(root) {
root.querySelectorAll('template[shadowroot]').forEach(template => {
const mode = template.getAttribute('shadowroot')
const shadowRoot = template.parentNode.attachShadow({ mode })
shadowRoot.appendChild(template.content)
template.remove()
attachShadowRoots(shadowRoot)
})
})(document)
随着优化(FOUC、serialize constructable stylesheets)、浏览器兼容以及 SSR 需求的增加,虽然目前还在讨论中,但已经越来越多的提到 DSD,相信以后是会成为标准而且性能会更好。 性能对比
Declarative Shadow DOM 可能会导致 XSS,Sanitizer Lib 不一定包含了这个新的特性
Modules
Modules,模块在前端已经应用广泛,无论是node_modules
还是ES modules
,或多或少我们都有用到,这里主要介绍Modules
与 Web Components 的联系
- HTML Modules
- CSS Modules
- ES Modules
HTML Modules
HTML Modules,主要是为了以组件化和可重用性的方式从 script 中打包和访问声明性内容,并集成到现有的 ES Modules 基础架构中
<!-- module.html -->
<div id="blogPost">
<p>Content...</p>
</div>
<script type="module">
// import.meta.document 将其限制为内联模块脚本,避免引用的 document 不明确,因为可以从多个上下文导入和运行非内联模块
let blogPost = import.meta.document.querySelector('#blogPost')
export { blogPost }
</script>
<!-- blog.html -->
<script type="module">
import { blogPost } from 'module.html'
document.body.appendChild(blogPost)
</script>
import
导入的 HTML 被视为 DocumentFragment,因为 Script Modules 访问声明式内容都是比较多限制,例如模块中打包custom element definition
,应该如何创建element's shadow tree
的 HTML?目前是document.createElement
、innerHTML
、template literals
,HTML Module 提案可以提高便利性和节省 parser、build 的时间。
CSS Modules
与 HTML Mdoules 相似,CSS Modules 默认情况下,所有class names
和animation names
都在本地范围内
CSS loaders 将 <style>
append 到 document 通常具有全局作用,不能很好与 Shadow DOM 的 style scoping 一起使用,而 CSS Modules 可以解决这些问题
/* style.css */
.className { color: green; } 从 JS 模块导入CSS
// 从 JS 模块导入 CSS 模块时,它会导出一个对象,其中包含从局部名称到全局名称的所有映射。
import styles from "./style.css";
// import { className } from "./style.css";
element.innerHTML = '<div class="' + styles.className + '">';
CSS Modules 可以带来模块化和可复用性,它没有全局作用域,具有明确的依赖关系,会减少很多的样式冲突,也方便进行优化。
ES Modules
ES Modules,将 JavaScript 拆分为可按需导入的单独模块的机制,Web Componets 中除了大家熟知的三项主要技术外 ES Module 也算是其中之一。
ES Modules 使 web components 能以模块化方式开发,这与其他 JavaScript 应用程序开发保持一致。可以在含在type="module"
属性的 JS 文件中定义 Custom Element 的接口。
ES Module 的 files 可以在客户端合并成一个文件,或者可以提前打包到单个 package 中。
<script type="module" src="awesome-explosion.js"></script>
...
<script type="module">
import 'awesome-explosion.js';
...
import {awesomeExplosion} from '@awesome-things/awesome-explosion';
</script>
这里只简单说明 WC 和 Es Modules 的关系,Es Modules 是一个复杂也值得研究的问题
除了上面三个 Modules 之外,还有JSON Modules、WebAssembly Modules,可以看出现在模块化在前端方面是在不停的进化,可能未来新的一些文件类型也会具有模块化。
Conclusion
Web Componets 发展到现在已经很长时间,也有不少公司或者项目有使用,例如 github、caniuse、tiktok,所以在应用上是没有问题,主要看业务上的需求
优点
- 浏览器本身支持,没有太多第三方依赖,bundle 的体积会更小
- Shodow Dom 的隔离环境,无论是 Style 还是 Dom
- 性能优势明显,不需要 JSX 以及 VDom
- 贴近浏览器本身,任何框架都可以迁移使用
缺点
- 功能不够完善,如 Declarative Shadow DOM 对于 SSR,和一些 Modules 没有开发完或兼容性不够,需要 polyfill
- 社区不够活跃,无论是浏览器标准还是 Lib 的社区,可能导致各种 UI、Tools 的 Lib 缺失或者没人维护
- 隔离带来的开发成本,如果在大型项目,例如 style 的复用,还有 define 的时候的 name 等问题
目前的 Web Componets 或者基于它的 Lib,想在大型项目中和 React/Vue 一样用是比较困难的,它社区不够活跃,还有写法导致大型项目上更难标准化。 虽然这些都可以自己解决,但也增加了开发成本。可能在不久的将来随着各项标准和 Api 的完善会更好用。
它的隔离性质,目前来说更适合开发一些组件,可以直接在多框架的地方使用,或者用做 micro-frontend,用来解决框架迁移、框架升级、页面多框架等场景。
Reference
- https://developer.mozilla.org/zh-CN/docs/Web/Web_Components
- https://itnext.io/handling-data-with-web-components-9e7e4a452e6e
- https://html.spec.whatwg.org/multipage/custom-elements.html
- https://web.dev/declarative-shadow-dom/
- https://github.com/WICG/webcomponents/blob/gh-pages/proposals/Template-Instantiation.md
- https://web.dev/constructable-stylesheets/
- https://github.com/css-modules/css-modules