还记得 document.querySelector 开始获得主流浏览器支持,并逐渐结束 jQuery 统治的历史吗? 它终于让我们能够原生实现多年来使用 jQuery 做的事情,也就是轻松选择 DOM 元素。我相信类似的变革也会席卷像 Angular 和 React 这样的前端框架。
这些框架让我们得以实现过去难以达成的目标,亦即创建可复用的自治前端组件;但随之而来的代价是代码更加复杂、需要专用语法和更多的负载压力。
但这种情况即将改变。
现代 Web API 已发展到不再需要框架就能创建可复用前端组件的程度。只需要自定义元素和 Shadow DOM 就足够创建可在任何地方重复使用的自治组件了。
于 2011 年面世的 Web Components 是一套功能组件,让开发者可以使用 HTML、CSS 和 JavaScript 创建可复用的组件。这意味着你无需 React 或 Angular 等框架也能创建组件。不仅如此,这些组件还都可以无缝集成到这些框架中。
有史以来头一次,我们只要使用 HTML、CSS 和 JavaScript 就能创建可在任何现代浏览器中运行的可复用组件了。现在,桌面平台的 Chrome、Safari、Firefox 和 Opera,iOS 上的 Safari 和 Android 上的 Chrome 最新版本都支持 Web Components。
Edge 浏览器将在即将发布的 19 版中提供支持。还有一个polyfill用来兼容老旧的浏览器,可以让 Web Components 与 IE11 兼容。
这意味着你现在可以在任何浏览器,包括移动设备中使用 Web Components。
你可以创建自定义的 HTML 标签,这些标签继承了它们扩展的 HTML 元素的所有属性,只需导入脚本即可在任何支持的浏览器中使用。组件内定义的所有 HTML、CSS 和 JavaScript 都完全限定在组件内部。
该组件将在浏览器的开发工具中显示为单个 HTML 标签,其样式和行为完全封装妥当,无需额外的处理、框架或转换。
我们来看看 Web Components 的主要功能。
自定义元素
自定义元素其实就是用户定义的 HTML 元素。它们是使用 CustomElementRegistry 定义的。要注册一个新元素时,需要通过 window.customElements 获取注册表实例并调用其 define 方法:
define 方法的第一个参数是我们新创建元素的标签名称。我们加上下面一行就能使用它了:
名称中的短划线( - )是必需的,以避免与任何原生 HTML 元素发生命名冲突。
不幸的是 MyElement 构造函数必须是一个 ES6 类,考虑到 Javascript 类(还)和传统的 OOP 类不太一样,这就容易让人头晕了。此外,如果允许使用对象,则还可以使用代理,从而为自定义元素启用简单数据绑定。但是,需要此限制才能启用原生 HTML 元素的扩展,并确保你的元素继承了整个 DOM API。
下面我们为自定义元素编写类:
我们自定义元素的类只是一个常规的 JavaScript 类,它扩展了原生的 HTMLElement。除了它的构造函数之外,它还有一个名为 connectedCallback 的方法,当元素插入 DOM 树时调用该方法。你可以将其与 React 的 componentDidMount 方法做对比。
通常来说,设置组件应尽可能地延迟到 connectedCallback,因为只有这里你才能确保元素的所有属性和子元素都可用。一般而言,构造函数只能用来初始化状态和设置 Shadow DOM。
元素的构造函数 constructor 和 connectedCallback 之间的区别在于,在创建元素时调用构造函数(例如,通过调用 document.createElement),并在元素实际插入 DOM 时调用 connectedCallback,例如当文档声明它已被解析或已与 document.body.appendChild 一起添加时这样做。
你还可以通过调用 customElements.get(‘my-element’)获取对其构造函数的引用来构造元素,前提是它已经在 customElements.define()中注册。然后,你就可以使用 new element()代替 document.createElement()来实例化元素了:
connectedCallback 对应的是 disconnectedCallback,当从 DOM 中删除元素时调用后者。该方法可以用来执行任何必要的清理工作,但请记住,当用户关闭浏览器或浏览器选项卡时不会调用此方法。
当通过调用 document.adoptNode(element)来将元素引入文档时还会调用 adoptCallback。到目前为止,我从未遇到过这个回调的用例。
还有一个很有用的生命周期方法是 attributeChangedCallback。每当属性更改已添加到 observedAttributes 数组时都会调用此方法。可以使用属性的名称、旧值和新值来调用它:
此回调仅对 observeAttributes 数组中存在的属性调用,在本例中为 foo 和 bar。这个回调不会对其它变动过的属性调用。
属性主要用于声明元素的初始配置/状态。理论上讲,可以通过序列化将复杂值传递给属性,但这可能会降低性能表现;因为你可以访问组件的方法,所以不需要这样做。如果你想通过 React 和 Angular 等框架提供的属性进行数据绑定,你可以查看Polymer。
生命周期方法的执行顺序
生命周期方法的执行顺序是:
为什么在 connectedCallback 之前执行 attributeChangedCallback?
回想一下,Web Components 上属性的主要用途是初始配置。这意味着当组件插入 DOM 时,此配置需要处于可用状态,因此需要在 connectedCallback 之前调用 attributeChangedCallback。
这意味着如果你需要根据某些属性的值配置 Shadow DOM 中的任何节点时,需要引用位于构造函数 constructor 中的节点,而不是在 connectedCallback 中引用它们。
例如,如果组件中有一个 id=“container”的元素,并且每当观察到的属性禁用更改时你都需要将此元素设置为灰色背景,请在 constructor 中引用此元素,以便它在 attributeChangedCallback 中可用:
如果你等到 connectedCallback 创建了 this.container 之后才引用,那么第一次调用 attributeChangedCallback 时它就不可用了。因此,尽管你应该尽可能地将组件的设置延迟到 connectedCallback,但在这里这是做不到的。
你也要明白你可以在使用 customElements.define()注册之前就可以使用 Web 组件。当元素存在于 DOM 中或插入其中并且尚未被注册时,它将是一个 HTMLUnknownElement 的实例。浏览器会用这种方式处理陌生的 HTML 元素,你可以照常与它交互,但它不会有任何方法或默认的样式。
当它通过 customElements.define()注册时,会通过类定义得到增强。此过程被称为升级。使用 customElements.whenDefined 升级元素时可以调用回调,前者在元素升级时会解析返回 Promise 对象:
Web 组件的公共 API
除了这些生命周期方法之外,你还可以在元素上定义可以从外部调用的方法,目前在使用 React 或 Angular 等框架定义元素时是不可能做到这一点的。例如,你可以定义一个名为 doSomething 的方法:
并从组件外部调用它,如下所示:
你在元素上定义的任何方法都将成为其公共 JavaScript API 的一部分。这样一来,你就可以通过为元素的属性提供 setter 来实现数据绑定,这样它就可以在元素的 HTML 中呈现属性值,诸如此类。由于除了字符串之外不能为属性赋予任何其他值,因此像对象这样的复杂值应作为属性传递给自定义元素。
除了声明一个 Web 组件的初始状态之外,attribute 属性还能用来映射相关 property 属性的值,以便将元素的 JavaScript 状态映射到其 DOM 表达中。一个例子是 input 元素的 disabled 属性:
将输入的属性 disabled property 设置为 true 后,此更改将映射到相关的 disabled attribute 属性上:
使用 setter 就能将一个 property 映射到一个 attribute 属性上:
如果需要在属性更改时执行某些操作,请将其添加到 observedAttributes 数组中。为提升性能,这里只会观察此处列出的属性以进行更改。一旦属性的值发生变动,就将使用属性的名称、其当前值及其新值调用 attributeChangedCallback:
现在,只要 disabled 属性发生更改,就会在 this.container 上切换“disabled”类,这是元素 Shadow DOM 中的 div 元素。
下面我们进一步来看。
Shadow DOM
使用 Shadow DOM 时,自定义元素的 HTML 和 CSS 会完全封装在组件内部。这意味着该元素将在文档的 DOM 树中显示为单个 HTML 标签,其内部 HTML 结构则放在一个 #shadow-root 中。
其实 Shadow DOM 也用在几个原生 HTML 元素上。例如当你的网页中有<video>
元素时,它会显示为单个标签;但它也会显示视频的播放控件,这个控件是不会显示在浏览器开发工具中的<video>
元素上的。
这些控件实际上是<video>
元素的 Shadow DOM 的一部分,因此默认情况下是隐藏的。要在 Chrome 中显示 Shadow DOM,请转到开发工具设置中的“首选项”,然后选中“显示用户代理 Shadow DOM”复选框。当你在开发工具中再次检查视频元素时就能看到并检查元素的 Shadow DOM 了。
Shadow DOM 还提供真正的作用域 CSS。组件内定义的所有 CSS 仅适用于组件本身。该元素仅从组件外部定义的 CSS 继承最少量的属性,甚至可以将这些属性配置为不从周围的 CSS 继承任何值。但你也可以公开 CSS 属性以允许使用者为组件设置样式。这解决了许多当下存在的 CSS 问题,同时仍然可以使用组件的自定义样式。
要定义一个影子根(Shadow root):
这里定义了一个带有 mode:’open’的影子根,这意味着它可以在开发工具中检查,并通过查询、配置任何公开的 CSS 属性或监听它抛出的事件来交互。也可以用 mode:’closed’定义影子根,但这里不推荐这样做,因为它不允许组件的使用者以任何方式与它交互;你甚至无法监听到它抛出的事件。
要将 HTM 添加到影子根,你可以为其 innerHTML 属性分配 HTML 字符串或使用<template>
元素。HTML 模板基本上是一个惰性 HTML 片段,你可以定义它以便以后使用。在实际插入 DOM 树之前,它将不会被显示或解析,这意味着在其中定义的任何外部资源都不会被提取,并且在将其插入 DOM 之前不会解析任何 CSS 和 JavaScript。当组件的 HTML 根据其状态更改时,你可以定义多个<template>
元素,从而根据组件的状态插入这些元素,诸如此类。这样你就可以轻松更改组件的大部分 HTML 内容,而无需摆弄单个 DOM 节点。
创建影子根后,你可以对它使用以往在 document 对象上使用的所有 DOM 方法,例如使用 this.shadowRoot.querySelector 来查找元素。组件的所有 CSS 都在<style>
标签内定义,但如果你想使用常规的<link rel =“stylesheet”>
标签,也可以获取外部样式表。除常规 CSS 外,你还可以使用:host 选择器来设置组件本身的样式。例如,自定义元素默认使用 display:inline,以便将组件显示为可以使用的块元素:
这样你也能使用上下文样式了。例如,如果要在组件具有 disabled 属性定义时将其显示为灰色,可以使用:
默认情况下,自定义元素会从周围的 CSS 继承一些属性,例如 color 和 font 等。但是如果你希望以纯净状态开始并将所有 CSS 属性重置为组件内的默认值,请使用:
要注意的是,从外部对组件本身定义的样式优先于 Shadow DOM 中使用:host 定义的样式。所以如果你要定义:
它会覆盖:
无法从外部设置自定义元素内的任何节点的样式。但是如果你希望用户能够设置组件的(部分)样式,则可以暴露 CSS 变量来做到这一点。例如,如果你希望用户能够选择组件的背景颜色,则可以暴露一个名为–background-color 的 CSS 变量。
假设组件中 Shadow DOM 的根节点是<div id =“container”>
:
现在,组件的用户可以从外部设置其背景颜色:
如果用户未定义组件,则应在组件内为其设置默认值:
当然,你可以为 CSS 变量选择任何名称。 CSS 变量的唯一要求是它们要以“–”开头。
通过提供作用域 CSS 和 HTML,Shadow DOM 解决了 CSS 的全局特性所带来的特殊性问题,并且通常会产生巨大的仅添加样式表,其包含越来越多的特定选择器和覆盖。Shadow DOM 可以将标签和样式捆绑到独立的组件中,而无需任何工具或命名约定。你永远不必再担心新的类或 ID 是否会与现有的类冲突。
除了能够通过 CSS 变量设置 Web Components 的内部样式之外,还可以将 HTML 注入 Web Components。
通过 Slot 组合
组合(Composition)是将 Shadow DOM 树与用户提供的标记组合在一起的过程。这是通过<slot>
元素完成的,该元素本质上是 Shadow DOM 中的占位符,其中呈现用户提供的标记。用户提供的标记称为 Light DOM。组合会将 Light DOM 和 Shadow DOM 组成一个新的 DOM 树。
例如,你可以创建<image-gallery>
组件并提供标准的<img>
标签作为要呈现的组件的内容:
该组件现在将使用给定的两张图像并使用 Slot 在组件的 Shadow DOM 内呈现它们。注意图像上的 slot =“image”属性。它告诉组件应该在其 Shadow DOM 中的什么位置呈现它们。例如,它可能如下所示:
当 Light DOM 中的节点已经分布到元素的 Shadow DOM 中时,生成的 DOM 树将如下所示:
如你所见,任何具有 slot 属性的用户提供的元素都将在 slot 元素内呈现,该 slot 元素具有 name 属性,其值与 slot 属性的值相对应。
简单的<select>
元素的工作方式与你在 Chrome 开发工具中检查时的效果完全相同(当你选择了显示用户代理 Shadow DOM 时,参见上文):
它采用用户提供的<option>
元素并将它们呈现到下拉菜单中。
具有 name 属性的 Slot 元素称为 named slot,但这一属性并非必需的。它仅用于在特定位置呈现内容。当一个或多个 slot 没有 name 属性时,内容将按照用户提供的顺序在其中呈现。当用户提供的内容少于 slot 数量时,slot 甚至可以提供后备内容。
假设<image-gallery>
的 Shadow DOM 看起来像这样:
当再次给定同样的两张图像时,生成的 DOM 树将如下所示:
通过 slot 在 Shadow DOM 内部呈现的元素称为分布式节点。在组件的(分布式)Shadow DOM 中呈现之前就应用于这些节点的所有样式也将在分发后得到应用。在 Shadow DOM 中,分布式节点可以通过:: slotted()选择器获得额外的样式:
:: slotted()可以使用任何有效的 CSS 选择器,但它只能选择顶级节点。例如:: slotted(section img)就不适用于此内容:
使用 JavaScript 中的 slot
你可以通过检查已分配给某个 slot 的节点、已分配给某个元素的 slot 以及 slotchange 事件来通过 JavaScript 与 slot 交互。
要找出哪些元素已分配给某个 slot,可以调用 slot.assignedNodes()。如果你还想检索任何后备内容,可以调用 slot.assignedNodes({flatten:true})。
要找出一个元素已分配给哪个元素的哪个 slot,可以检查 element.assignedSlot。
只要 slot 中的节点发生更改(即添加或删除节点时),就会触发 slotchange 事件。注意事件仅针对 slot 节点本身触发,而不针对这些 slot 节点的子节点触发。
首次初始化元素时,Chrome 会触发 slotchange 事件,而 Safari 和 Firefox 则不会。
Shadow DOM 中的事件
来自鼠标和键盘事件等自定义元素的标准事件默认会从 Shadow DOM 中弹出来。每当一个事件从 Shadow DOM 中的一个节点出来时,它将被重新定位,使得该事件看起来似乎是来自自定义元素本身。如果要查找事件实际来自 Shadow DOM 中的哪个元素,可以调用 event.composedPath()来检索事件所经过的节点数组。但是,事件的 target 属性将始终指向自定义元素本身。
你可以使用 CustomEvent 从自定义元素中抛出所需的任何事件。
但是,当从 Shadow DOM 内的节点而不是自定义元素本身抛出一个事件时,除非它使用 composition:true 创建,否则它不会从 Shadow DOM 中弹出。
模板元素
除了使用 this.shadowRoot.innerHTML 将 HTML 添加到元素的影子根之外,你还可以使用<template>
元素来执行此操作。模板会包含 HTML 供以后使用。它不会被呈现,最初只会被解析以确保其内容是有效的。模板内的 JavaScript 不会被执行,也不会获取任何外部资源。默认情况下它是隐藏的。
当 Web 组件需要根据不同情况呈现完全不同的标记时,可以使用不同的模板来完成此任务:
这里使用 innerHTML 将两个模板放置在元素的影子根中。一开始两个模板都会被隐藏,只渲染容器。在 connectedCallback 中,我们使用 this.shadowRoot.querySelector(’#view1’)获取 #view1 中的内容。模板的 content 属性将模板的内容作为 DocumentFragment 返回,可以使用 appendChild 将其添加到另一个元素。由于 appendChild 将移动 DOM 中已经存在的元素,我们需要首先使用 cloneNode(true)克隆它。否则,模板的内容将被移动而不是附加,这意味着我们只能使用它一次。
模板可以方便地用来快速更改大部分 HTML 或复用标记。它们不仅限于 Web Components,还可以在 DOM 中的任何位置使用。
扩展原生元素
到目前为止,我们一直在扩展 HTMLElement 以创建一个全新的 HTML 元素。自定义元素还允许扩展原生内置元素,从而可以增强现有的 HTML 元素,例如图像和按钮。在撰写本文时,此功能仅被 Chrome 和 Firefox 支持。
扩展现有 HTML 元素的好处是继承了元素的所有属性和方法。这样就能逐步增强现有元素了,意味着即使元素在不支持自定义元素的浏览器中加载也仍然是可用的。此时它将简单地回退到其默认的内置行为,而如果它是一个全新的 HTML 标签就彻底不可用了。
举个例子,假设我们要增强 HTML <button>
元素:
我们的 Web 组件现在扩展了 HTMLButtonElement,而不是更通用的 HTMLElement。对 customElements.define 的调用现在还需要一个额外的参数{extends:‘button’}来表示我们的类扩展了<button>
元素。这似乎是多余的,因为我们已经指出我们想要扩展 HTMLButtonElement,但是由于存在共享相同 DOM 接口的元素,所以这是必要的。例如,<q>
和<blockquote>
都共享 HTMLQuoteElement 接口。
增强的按钮现在可以与 is 属性一起使用:
它现在将通过我们的 MyElement 类增强,如果它在不支持自定义元素的浏览器中加载,它将简单地回退到标准按钮,这就是所谓渐进式的增强!
注意,在扩展现有元素时不能使用 Shadow DOM。这只是通过继承所有现有属性、方法和事件并提供其他功能来扩展原生 HTML 元素的一种方法。当然可以从组件中修改元素的 DOM 和 CSS,但是尝试创建影子根时将引发错误。
扩展内置元素的另一个好处是,这些元素也可以用于对子元素有限制的地方。例如,<thead>
元素只允许将<tr>
元素作为其子元素,因此像<awesome-tr>
这样的元素将呈现无效标记。在这种情况下,我们可以扩展内置的<tr>
元素并像这样使用它:
这种创建 Web 组件的方式是一种很好的渐进式增强,但如上所述,目前只有 Chrome 和 Firefox 支持它。 Edge 也将提供支持,但至少目前没有。
测试 Web Components
与为 Angular 和 React 等框架编写测试相比,测试 Web Components 更加简单明了,坦率地说是轻而易举的。你不需要转换或复杂的设置,只需创建元素,将其附加到 DOM 并运行测试即可。
以下是使用 Mocha 测试的示例:
这里第一行导入 my-element.js 文件,该文件将我们的 Web Components 暴露为 ES6 模块。这意味着测试文件本身也需要作为 ES6 模块加载到浏览器中。这需要下面的 index.html 才能在浏览器中运行测试。除了 Mocha 之外,这个设置还加载了 WebcomponentsJS polyfill,Chai 用于测试,Sinon 用于 spy 和 mock:
在加载了所需的脚本之后,我们将 chai.assert 作为全局变量暴露,因此我们可以在测试中使用 assert()并设置 Mocha 来使用 BDD 接口。然后加载测试文件(在这个示例中只有一个文件),然后我们调用 mocha.run()来运行测试。
注意,使用 ES6 模块时还需要将 mocha.run()放在带有 type =“module”的脚本中。这是因为 ES6 模块默认是延迟的,如果 mocha.run()放在常规脚本标签内,它将在加载 my-element.test.js 之前就执行了。
在旧版浏览器中使用 polyfill
现在,桌面上的 Chrome、Firefox、Safari 和 Opera 的最新版本都支持自定义元素:https://caniuse.com/#feat=custom-elementsv1
即将推出的 Edge 19 也将提供支持,在 iOS 和 Android 上的 Safari、Chrome 和 Firefox 也支持它。
对于旧版浏览器,可以通过以下方式安装 WebcomponentsJS polyfill:
你可以加入 webcomponents-loader.js 文件,该文件将执行功能检测以仅加载必要的 polyfill。使用此 polyfill,你就可以使用自定义元素,而无需向源代码添加任何内容。但是,它不提供真正的作用域 CSS,这意味着如果你在不同的 Web Components 中具有相同的类名和 ID 并将它们加载到同一文档中就将发生冲突。此外,Shadow DOM CSS 选择器:host()和:slotted()可能无法正常工作。
为了使其正常工作,你需要使用 Shady CSS polyfill,这也意味着你必须(稍微)调整你的源代码才能使用它。我个人不喜欢这样,所以我创建了一个 webpack 加载器处理这个问题。你需要用它来做转换,但这样就不用改代码了。
Webpack 加载器做了三件事:它为你的 web 组件的 Shadow DOM 中所有不以:: host 或:: slotted 开头的 CSS 规则添加元素标签名称前缀,从而提供正确的范围;之后它会解析所有:: host 和:: slotted 规则,以确保它们也能正常工作。
示例 1:lazy-img
我创建了一个 Web 组件,一旦它在浏览器的可视端口中完全可见,就会平缓地加载一张图像。你可以在Github上找到它。
该组件的主要版本将本机<img>
标签包装在<lazy-img>
自定义元素中:
repo 还包含 extend-native 分支,其中包含使用 is 属性扩展原生<img>
标签的 lazy-img:
这是关于原生 Web Components 功能的一个很好的例子:只需导入 JavaScript 文件,添加 HTML 标签或使用 is 属性扩展本地标签就可以干活了!
示例 2:material-webcomponents
我使用自定义元素实现了 Google 的 Material Design,也放到了Github上。
这个库还展示了 CSS自定义属性的强大功能。
那么,我应该抛弃我的框架吗?
一如既往,这取决于你的具体情况。
当下的前端框架有着数据绑定、状态管理和相当标准化的代码库等功能提供的附加价值。问题是你的应用是否真的需要它们。
如果你需要问自己,你的应用程序是否真的需要像 Redux 这样的状态管理,你可能其实并不需要它。当你真的用到它的时候再考虑也不迟。
你可能会觉得数据绑定很好用,但对于非原始值(如数组和对象)来说,原生 Web Components 已允许你直接设置属性。可以在属性上设置原始值,并且可以通过 attributeChangedCallback 观察对这些属性的更改。
虽然这种方式很有用,但与在 React 和 Angular 中执行此操作的声明方式相比,它只是更新 DOM 的一小部分就很麻烦了。这些框架允许定义包含在更改时更新的表达式的视图。
虽然有一个提议要扩展<template>
元素以允许它实例化并使用数据更新,但原生 Web Components 仍未提供此类功能:
当下能提供有效 DOM 更新的库是lit-html。
前端框架的另一个经常被提到的好处是,它们提供了一个标准的代码库,团队中的每位新人从一开始就很熟悉它。虽然我认可这一点,但我也觉得这种好处非常有限。
我使用 Angular、React 和 Polymer 开发了各种项目,尽管它们的确存在相似性,但就算使用相同的框架,这些代码库仍然存在很大差异。明确定义的工作方式和样式指南更有助于你维持代码库的一致性,仅仅使用框架是不够的。框架也带来了额外的复杂性,应该问问自己这是否真的值得。
现在 Web Components 得到了广泛支持,你可能会得出这样的结论:原生代码可以为你带来与框架相同的功能,但性能更强、需要的代码更少,更加简洁。
原生 Web Components 的好处很明显:
原生,无需框架;
易于集成,无需转换;
真正的作用域 CSS;
标准化,只有 HTML、CSS 和 JavaScript。
jQuery 及其出色的遗产仍将存在一段时间,但现在有了更好的选择,所以新建设的项目很少会去用它了。我不认为现有的框架会很快消失,但是原生 Web Components 提供了更好的选项,并且正在快速扩张。我也希望这些前端框架去扮演新的角色,只要在原生 Web Components 周围充当一个简单的附加层就可以了。
英文原文:
https://www.dannymoerkerke.com/blog/web-components-will-replace-your-frontend-framework
更多内容,请关注前端之巅。
评论 1 条评论