Web Components 的未来

Web Components 的未来

Web Components 是为了方便 Web 应用中的视图隔离和简化内容分发流程而提出的一系列技术,目前包括:

  • Custom Elements API:用于定义浏览器可识别的自定义元素,扩展 HTML 行为;
  • Shadow DOM:用于隔离局部样式,节点访问以及事件传播,构造独立管理的视图内容;
  • HTML Template:用于定义视图模版,自身不进行渲染或产生副作用;

本文将基于现有的提案出发,简单总结 Web Components 在可预测的未来中可能出现的形态,部分内容会涉及到 Web Components 之外的技术。

万物即模块

Web Components 的一个重要目的就是简化内容分发的成本,早期的 Web Components 概念中曾经包含 HTML Imports 提案,用于建立 HTML 文件间的依赖关系。不过该提案已从 Web Components 概念中移除,目前 Safari 和 Edge 都并未实现过该特性,即便已经提供了实现的 Chrome 也在 73 版本中停止了对这一特性的支持

虽然 HTML Imports 被认为是一个糟糕的方案,但是直接分发 HTML 内容仍然是 Web Components 实践中的切实需求。

新的 HTML Modules 提案有效解决了这一问题,能够直接将 HTML 文件*作为 ECMAScript Module 引入:

import { libDom, libHelper } from './my-lib.html'

* 决定是否为 HTML 文件的是 MIME type,并非文件后缀。

而在 HTML Module 文件类似于一个普通的局部 HTML:

<div id="blogPost">
  <p> Some Amazing Content </p>
</div>
<script>
  let blogPost = import.meta.document.querySelector("#blogPost")
  export { blogPost }
</script>

当然也有一些细节需要注意:

  • HTML 自身被解析为 DocumentFragment,并作为 default export(可覆盖);
  • 文件中所有的 Inline Script 都是 Module*(不论是否指定 type),而非 Script;
  • Inline Script 可通过 import.meta.document 访问当前的 DocumentFragment;

* Module 和 Script 是 ES 中的称呼方式,而在 HTML 中分别叫做 Module Script 和 Classic Script。

通过这些约束,可以很方便的定义一个 Web Component,例如:

<template id="myCustomElementTemplate">
  <div class="myDiv">
    <div class="devText">Here is some amazing text</div>
  </div>
</template>

<script type="module">
let importDoc = import.meta.document

class myCustomElement extends HTMLElement {
  constructor() {
    super()
    let shadowRoot = this.attachShadow({ mode: 'open' })
    let template = importDoc.getElementById('myCustomElementTemplate')
    shadowRoot.appendChild(template.content.clone(true))
  }
}

customElements.define('myCustomElement', myCustomElement)
</script>

由于直接作为 HTML 文件使用,因此不需要再通过字符串字面量的方式来内嵌模版内容,或者通过复杂的预处理步骤进行嵌入。

HTML Modules 提案极大地降低了对构建工具的需求,开发人员可以能够便捷高效地发布源代码,提升开发体验。

与之类似的提案还包括 CSS Modules 以及 JSON Modules,相比于 HTML Modules 而言,两者的处理内容要简单得多,并且相应的语义早已在构建工具中广泛使用。

除此之外*,另一个将能够被用作 ES Module 使用的内容是 Web Assembly,相应的 PR 在:

* 这部分内容与 Web Components 没有直接关联。

之后,我们将能够直接在 JavaScript 中引入 Web Assembly 文件:

import { superFastAlgorithm } from './lib.wasm'

export function bridge(input) {
  return superFastAlgorithm(input)
}

可以遇见,之后会有越来越多的内容能够被作为 ES Module 使用,Web App 对构建工具的硬性需求也会逐渐减少。

Web 基础库

由于 Web 应用的需求逐渐复杂,一般的项目中很难做到全部功能都自行实现,往往需求引入第三方类库来简化开发过程。

从另一个方面来考虑,如果一些高级功能在大量应用中都会用到,那么我们是否能将其作为 Web 规范,直接通过浏览器引入呢?或者说,定义 Web 中的 JavaScript 标准库。

ES Module 为此提供了极大便利,由于 ES 中 Module Resolution 是未定义行为,在 Web App 中如何获取模块内容是由 HTML 规范定义的行为,因此提供内置模块并不会破坏 JavaScript 的语义。

为了解决这一问题,Layered APIs 提案应用而生,为 Web App 提供了高层次的类库模块功能。其 API 基于 ES Module,使用方式类似于*:

import { storage } from '@std/async-local-storage'

* Async Local Storage 是基于 Layered APIs 之上的独立提案,并不是 Layered APIs 本身的内容。

不过在 Web 中提供内置功能会遇到的一个常见问题是,由于 Web 规范会不断演进,因此特定浏览器内可能不具备当前规范的全部功能,为此需要支持 Polyfill 的方案。

于是这里涉及到了另一个 Import Maps 提案,允许对 import 的标识符进行路径映射,提供若干个候选项,从而在内置模块不可用时自动切换到 Polyfill 的实现:

<script type="importmap">
{
  "imports": {
    "@std/async-local-storage": [
      "@std/async-local-storage",
      "/node_modules/als-polyfill/index.mjs"
    ]
  }
}
</script>

由于应用的范围并不仅限于内置模块,从而也可以用于第三方库的映射*:

<script type="importmap">
{
  "imports": {
    "lodash": "/lodash.mjs",
    "moment": "/moment.mjs"
  }
}
</script>

* 需要注意现阶段大部分类库都并未提供 Web 兼容的 ES Module 发布版本。

而后就能直接在代码中通过标识符的方式引入第三方依赖:

import moment from 'moment'

console.log(moment())

该提案的本质是引入了新的 URL Scheme "import",自动应用于全体通过 ES Module Import 以及 ES Dynamic Import 导入的内容,因此上面的代码产生的模块 URL 为:

import:moment

所有该 Scheme 的 URL 均受到 Import Map 的控制,依次尝试可能的路径。

因为是基于 URL Scheme 的处理,所以也同样能够应用于资源文件:

<img src="import:my-avatar">

不过用于资源文件的意义并不是太大,资源文件大多都是本地内容,且很少需要重复引入,因此在不依赖构建工具的情况下依靠相对 URL 即可满足大部分场景。

原生模版引擎

Template Tag 虽然设定为模版,但仅仅提供了 Parser 级别的特殊语义(其内容不作为 innerHTML 渲染到页面),并未提供使用上的便利性。因此为了使用其内容,需要首先获取其 DocumentFragment 内容,然后通过 document.importNode() 克隆节点(其它克隆方案也可行),最后通过 DOM API 手动替换节点中的已有内容,类似于:

// <template id="hello">
//   Hello, <span class="name"></span>!
// </template>

const template = document.querySelector('#hello')
const instance = document.importNode(template.content, true)
const name = instance.querySelector('.name')
name.textContent = 'World'

$element.appendChild(instance)

繁琐的步骤极大地限制了 Template Tag 的使用,大部分情况下我们期望模版引擎能够自动分析占位符,并根据提供的 ViewModel 对象自动替换占位符的内容,例如借助于 Polymer 模版语法*,可以简化为:

// <template>
//   Hello, {{name}}!
// </template>

const item = { name: 'World' }

* Polymer 中自身存在模版机制,普通的内容插值并不需要用到 <template>,通常为结合 dom-ifdom-repeat 等 Control Flow 元素使用。

为了真正发挥 Template Tag 的价值,需要能够直接实例化模版并更新其内容,因此 Template Installation 提案被提出。该提案为 HTMLTemplateElement 增加了 createInstance() 方法,能够自动复制节点并替换占位符:

// <template id="hello">
//   Hello, {{name}}!
// </template>

const template = document.querySelector('#hello')
const instance = template.createInstance({ name: 'World' })
$element.appendChild(instance)

此外,也可能对 ViewModel 进行后续修改:

instance.update({ name: 'Web' })

至此,我们以及具备了原生的数据绑定能力。甚至更进一步,还能够对模版实例化的过程进行拦截。假设我们有以下模版:

<template>
  Hello, {{uppercase(name)}}!
</template>

这里我们执行了一个函数,并且将返回值绑定到内容中,至少我们看起来是这样。但是,结果真的如此么?和一般模版引擎不同的是,Template Tag 并未自行定义一套模版语法(JavaScript 的子集 + 扩展),也不会将该内容作为全局 JavaScript 执行(失去了模版的意义),因此只会得到 vm['uppercase(name)']。大部分情况下这并非我们需要的结果。

为了能够支持这类用例,可以通过指定 type 属性(Attribute)为该模版定义拦截过程:

<template type="allow-func">
  Hello, {{uppercase(name)}}!
</template>

并实现对应的 callback:

document.defineTemplateType('allow-func', {
  processCallback: (instance, parts, state) => {
    for (const part of parts) {
      if (/* matches function call */) {
        const [func, ...args] = extractFuncText(part.expression)
        part.value = state[func](...args.map(arg => state[arg]))
      }
    }
  }
})

由于是自定义的拦截过程,因此也可以选择其它形式的语法,例如 Pipe:

<template type="allow-pipe">
  Hello, {{name|uppercase}}!
</template>

基于这个拦截过程,还能够进一步实现更强大的功能,例如循环内容。现在考虑以下模版:

<template type="with-for-each">
  <ul>
    {{foreach items}}
      <li>{{label}}</li>
    {{/foreach}}
  </ul>
</template>

如果有后端模版经验,我们可能会认为 foreach 是一个模版语法,用于定义循环内容。不过这里并不是这样,{{foreach items}}{{/foreach}} 都是普通的插值,其表达式内容分别为 "foreach items" "/foreach"。同样的,得到 vm['foreach items']vm['/foreach'] 并非我们期望的结果,不过通过对模版实例化过程的拦截,我们确确实实能够模拟出模版语法的效果*。

* 实践上来说,使用类 Polymer 的方案将 Control Flow 的内容作为子模版往往更易实现。

虽然这里的预处理过程非常强大,实际应用中往往并不希望出现差异化的处理方式,因此可以将所有需要用到的 DSL 集中到同一个 TemplateTypeInit,或者提取为专门的类库:

<template type="awesome-template">
  <ul>
    {{foreach items}}
      <li>{{label}}</li>
    {{/foreach}}
  </ul>
  <p>Hello, {{name|uppercase}}!</p>
</template>

这样,就能仅通过 DOM API 以极小的成本达到模版引擎级别的能力。

写在最后

当然,并不是所有愿景都一定能够在可预见的未来内实现,即便能够实现,我们仍然需要优先处理当下的问题。

虽然 Web Components 现阶段还不具备实用性,但是作为 Web 的原生特性,真正*具备着达到 0KB Runtime 的能力。随着个别老旧浏览器的淘汰,并且现代浏览器具备快速更新的能力,未来的 Web App 必将更加偏向于 Evergreen Browser,从而充分发挥 Web 功能。

* 某自称 0KB Runtime 的框架仅仅是把成本分担到每个组件中,应用达到一定规模后的总体成本比 Shared Runtime 反而更高。

发布于 2018-11-12

文章被以下专栏收录