Web前端离线化,用了都说好

还记得 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(https://github.com/webcomponents/webcomponentsjs)用来兼容老旧的浏览器,可以让 Web Components 与 IE11 兼容。

这意味着你现在可以在任何浏览器,包括移动设备中使用 Web Components。

你可以创建自定义的 HTML 标签,这些标签继承了它们扩展的 HTML 元素的所有属性,只需导入脚本即可在任何支持的浏览器中使用。组件内定义的所有 HTML、CSS 和 JavaScript 都完全限定在组件内部。

该组件将在浏览器的开发工具中显示为单个 HTML 标签,其样式和行为完全封装妥当,无需额外的处理、框架或转换。

我们来看看 Web Components 的主要功能。

自定义元素

自定义元素其实就是用户定义的 HTML 元素。它们是使用 CustomElementRegistry 定义的。要注册一个新元素时,需要通过 window.customElements 获取注册表实例并调用其 define 方法:

window.customElement.define('my-element', MyElement);

define 方法的第一个参数是我们新创建元素的标签名称。我们加上下面一行就能使用它了:

<my-element> 

名称中的短划线( - )是必需的,以避免与任何原生 HTML 元素发生命名冲突。

不幸的是 MyElement 构造函数必须是一个 ES6 类,考虑到 Javascript 类(还)和传统的 OOP 类不太一样,这就容易让人头晕了。此外,如果允许使用对象,则还可以使用代理,从而为自定义元素启用简单数据绑定。但是,需要此限制才能启用原生 HTML 元素的扩展,并确保你的元素继承了整个 DOM API。

下面我们为自定义元素编写类:

class MyElement extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
// here the element has been inserted into the DOM
}
}

我们自定义元素的类只是一个常规的 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() 来实例化元素了:

customElements.define('my-element', class extends HTMLElement {...}); 

...
const el = customElements.get('my-element');
const myElement = new el(); // same as document.createElement('my-element');
document.body.appendChild(myElement);

connectedCallback 对应的是 disconnectedCallback,当从 DOM 中删除元素时调用后者。该方法可以用来执行任何必要的清理工作,但请记住,当用户关闭浏览器或浏览器选项卡时不会调用此方法。

当通过调用 document.adoptNode(element) 来将元素引入文档时还会调用 adoptCallback。到目前为止,我从未遇到过这个回调的用例。

还有一个很有用的生命周期方法是 attributeChangedCallback。每当属性更改已添加到 observedAttributes 数组时都会调用此方法。可以使用属性的名称、旧值和新值来调用它:

class MyElement extends HTMLElement {
static get observedAttributes() {
return ['foo', 'bar'];
}
attributeChangedCallback(attr, oldVal, newVal) {
switch(attr) {
case 'foo':
// do something with 'foo' attribute
case 'bar':
// do something with 'bar' attribute
}
}
}

此回调仅对 observeAttributes 数组中存在的属性调用,在本例中为 foo 和 bar。这个回调不会对其它变动过的属性调用。

属性主要用于声明元素的初始配置 / 状态。理论上讲,可以通过序列化将复杂值传递给属性,但这可能会降低性能表现;因为你可以访问组件的方法,所以不需要这样做。如果你想通过 React 和 Angular 等框架提供的属性进行数据绑定,你可以查看 Polymer:

https://polymer-library.polymer-project.org/

生命周期方法的执行顺序

生命周期方法的执行顺序是:

constructor -> attributeChangedCallback -> connectedCallback

为什么在 connectedCallback之前执行 attributeChangedCallback?

回想一下,Web Components 上属性的主要用途是初始配置。这意味着当组件插入 DOM 时,此配置需要处于可用状态,因此需要在 connectedCallback 之前调用 attributeChangedCallback。

这意味着如果你需要根据某些属性的值配置 Shadow DOM 中的任何节点时,需要引用位于构造函数 constructor 中的节点,而不是在 connectedCallback 中引用它们。

例如,如果组件中有一个 id=“container”的元素,并且每当观察到的属性禁用更改时你都需要将此元素设置为灰色背景,请在 constructor 中引用此元素,以便它在 attributeChangedCallback 中可用:

constructor() {
this.container = this.shadowRoot.querySelector('#container');
}
attributeChangedCallback(attr, oldVal, newVal) {
if(attr === 'disabled') {
if(this.hasAttribute('disabled') {
this.container.style.background = '#808080';
}
else {
this.container.style.background = '#ffffff';

}
}
}

如果你等到 connectedCallback 创建了 this.container 之后才引用,那么第一次调用 attributeChangedCallback 时它就不可用了。因此,尽管你应该尽可能地将组件的设置延迟到 connectedCallback,但在这里这是做不到的。

你也要明白你可以在使用 customElements.define() 注册 之前 就可以使用 Web 组件。当元素存在于 DOM 中或插入其中并且尚未被注册时,它将是一个 HTMLUnknownElement 的实例。浏览器会用这种方式处理陌生的 HTML 元素,你可以照常与它交互,但它不会有任何方法或默认的样式。

当它通过 customElements.define() 注册时,会通过类定义得到增强。此过程被称为 升级。使用 customElements.whenDefined 升级元素时可以调用回调,前者在元素升级时会解析返回 Promise 对象:

customElements.whenDefined('my-element')
.then(() => {
// my-element is now defined
})

Web 组件的公共 API

除了这些生命周期方法之外,你还可以在元素上定义可以从外部调用的方法,目前在使用 React 或 Angular 等框架定义元素时是不可能做到这一点的。例如,你可以定义一个名为 doSomething 的方法:

class MyElement extends HTMLElement {
...
doSomething() {
// do something in this method
}
}

并从组件外部调用它,如下所示:

const element = document.querySelector('my-element');
element.doSomething();

你在元素上定义的任何方法都将成为其公共 JavaScript API 的一部分。这样一来,你就可以通过为元素的属性提供 setter 来实现数据绑定,这样它就可以在元素的 HTML 中呈现属性值,诸如此类。由于除了字符串之外不能为属性赋予任何其他值,因此像对象这样的复杂值应作为属性传递给自定义元素。

除了声明一个 Web 组件的初始状态之外,attribute 属性还能用来映射相关 property 属性的值,以便将元素的 JavaScript 状态映射到其 DOM 表达中。一个例子是 input 元素的 disabled 属性:


const input = document.querySelector('input');
input.disabled = true;

将输入的属性 disabled property 设置为 true 后,此更改将映射到相关的 disabled attribute 属性上:

 

使用 setter 就能将一个 property 映射到一个 attribute 属性上:

class MyElement extends HTMLElement {
...
set disabled(isDisabled) {
if(isDisabled) {
this.setAttribute('disabled', '');
}
else {
this.removeAttribute('disabled');
}
}
get disabled() {
return this.hasAttribute('disabled');
}
}

如果需要在属性更改时执行某些操作,请将其添加到 observedAttributes 数组中。为提升性能,这里只会观察此处列出的属性以进行更改。一旦属性的值发生变动,就将使用属性的名称、其当前值及其新值调用 attributeChangedCallback:

class MyElement extends HTMLElement { 
static get observedAttributes() {
return ['disabled'];
}
constructor() {
const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `


`;
this.container = this.shadowRoot('#container');
}
attributeChangedCallback(attr, oldVal, newVal) {
if(attr === 'disabled') {
if(this.disabled) {
this.container.classList.add('disabled');
}
else {
this.container.classList.remove('disabled')

}
}
}
}

现在,只要 disabled 属性发生更改,就会在 this.container 上切换“disabled”类,这是元素 Shadow DOM 中的 div 元素。

下面我们进一步来看。

Shadow DOM

使用 Shadow DOM 时,自定义元素的 HTML 和 CSS 会完全封装在组件内部。这意味着该元素将在文档的 DOM 树中显示为单个 HTML 标签,其内部 HTML 结构则放在一个 #shadow-root 中。

其实 Shadow DOM 也用在几个原生 HTML 元素上。例如当你的网页中有<video>元素时,它会显示为单个标签;但它也会显示视频的播放控件,这个控件是不会显示在浏览器开发工具中的<video>元素上的。/<video>/<video>

这些控件实际上是<video>元素的 Shadow DOM 的一部分,因此默认情况下是隐藏的。要在 Chrome 中显示 Shadow DOM,请转到开发工具设置中的“首选项”,然后选中“显示用户代理 Shadow DOM”复选框。当你在开发工具中再次检查视频元素时就能看到并检查元素的 Shadow DOM 了。/<video>

Shadow DOM 还提供真正的作用域 CSS。组件内定义的所有 CSS 仅适用于组件本身。该元素仅从组件外部定义的 CSS 继承最少量的属性,甚至可以将这些属性配置为不从周围的 CSS 继承任何值。但你也可以公开 CSS 属性以允许使用者为组件设置样式。这解决了许多当下存在的 CSS 问题,同时仍然可以使用组件的自定义样式。

要定义一个影子根(Shadow root):

const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `

Hello world

`;

这里定义了一个带有 mode:’open’的影子根,这意味着它可以在开发工具中检查,并通过查询、配置任何公开的 CSS 属性或监听它抛出的事件来交互。也可以用 mode:’closed’定义影子根,但这里不推荐这样做,因为它不允许组件的使用者以任何方式与它交互;你甚至无法监听到它抛出的事件。

要将 HTM 添加到影子根,你可以为其 innerHTML 属性分配 HTML 字符串或使用<template>元素。HTML 模板基本上是一个惰性 HTML 片段,你可以定义它以便以后使用。在实际插入 DOM 树之前,它将不会被显示或解析,这意味着在其中定义的任何外部资源都不会被提取,并且在将其插入 DOM 之前不会解析任何 CSS 和 JavaScript。当组件的 HTML 根据其状态更改时,你可以定义多个<template>元素,从而根据组件的状态插入这些元素,诸如此类。这样你就可以轻松更改组件的大部分 HTML 内容,而无需摆弄单个 DOM 节点。/<template>/<template>

创建影子根后,你可以对它使用以往在 document 对象上使用的所有 DOM 方法,例如使用 this.shadowRoot.querySelector 来查找元素。组件的所有 CSS 都在


分享到:


相關文章: