初识Web Components


当前的前端时期(2020),是一个由Vue.js / React框架以及各种控件库所操控的时期

自从我2019年开始找前端相关的工作开始,无论刷什么招聘软件,只要一输入前端二字,所有的JD上一般都会写“至少熟悉、了解Vue.js / React / Angular三大框架之一;能够使用Element / Antd等UI框架进行开发……”等等诸如此类的要求。
自从有了这些框架,前端开发可能就和开发桌面程序一样,搭建空白脚手架,直接拖控件;控件样式不对,写一大堆额外代码重置。每次用vue init一类的命令建项目,总能感受到 npm i 的壮观。

Vue.js / React 框架为我做了什么?

(以下是笔者的看法)

在各种框架尚未流行之前(大概15年以前),倘若要在前端开发应用程序(或者制作包含复用控件的页面),如果一定指定用一个框架,那我应该会和大多数人一样,选择jQuery。据说,这时的开发一般都是用BootStrap或是jQueryUI来做控件库,页面上各个控件封装很令人头疼。

本人在公司项目中也接触过类似使用jQuery来制作的项目,感觉很不顺手,几乎各种操作都是同时操作数据的同时,手动根据数据更新DOM;UI和数据的操作基本上杂糅在一起的。

不过,Vue.js / React 一类的框架引入后,很多事情变得似乎轻松了一些。
由于本人Vue.js用得稍多一些,因此以此为例;

  1. UI不再由用户手动调用DOM API来操控,而是根据数据由框架内部对相关UI进行操作,例如双向绑定、模板渲染等等,使得UI可根据数据的更新而更新(数据驱动UI);
  2. 页面中各个部分可直接分成多个组件,各个组件具有不同的状态与不同的数据,复用方便;
  3. Vue.js与React提供的虚拟DOM及其Diff让网页上页面节点随数据的变化更加智能;
  4. 借助脚手架与构建工具,各种组件能够清晰、有条理的分散到各个小文件(.vue)中,最终由构建工具打包、处理为一个大文件,全局变量不会被过分污染,项目更好维护;甚至连CSS样式也能够实现组件化;这使得基于Vue.js的UI(组件)的封装变得更加容易。

Web Components 是做什么的?

首先,我们来探讨一下,浏览器DOM API中为何会出现类似querySelector、querySelectorAll这样的方法。早期浏览器中,当这些方法尚未出现的时候,我们几乎都使用jQuery来进行大部分DOM操作。终于,制定网页标准的人认为,jQuery的相关实现很漂亮,遂将它的选择DOM节点的功能收编到了标准中。
同样的,Vue.js / React的框架出现之后,制定网页标准的人又认为这操作很漂亮,于是Web Components就来了。

Web Component是浏览器自身包含的API,不必引入外部框架及其它依赖,开发者可以直接在页面中创建自定义的组件。引用方式类似Vue.js / React中对组件的引用。例如,某组件名为Hello World,假设该组件已经定义好,那么我们在HTML中的引用方式则是:

<div>
    <hello-world>
        <button slot="name">a slot</button>
    </hello-world>
</div>

Web Components 的相关技术

事实上,Web Components具体是由三种技术所组成的

  • HTML模板
  • 自定义元素
  • Shadow DOM

下文中的内容就体现了这些技术。

来试着定义一个Web Components

定义、编写模板

和Vue.js一样,Web Components也有模板这个概念。模板就是我们所写的编译后将会展现在用户眼前的东西(表现层)。
模板使用<template>这样一个元素来定义(似乎是一个新的HTML元素,但在Vue.js的单文件组件中经常可以见到),该元素可以写在<body>中。
定义的方式:

<template id="helloWorld">
    <p>
        HelloWorld~
    </p>
</template>

在浏览器中查看:
image
我们可以看到,浏览器窗口中没有任何显示,但开发者工具中,<template>元素下的内容被包裹在了一个document-fragment中。

在Console中获取到<template>,访问它的.content属性,即可看到这个document-fragment。字面上看,document-fragment的意思应该是文档片段,具体到DocumentFragment – Web APIs | MDN 上查了查,发现DocumentFragment实质上是相当于一个独立的Document,独立但依赖于当前页面Document。DocumentFragment并不会直接渲染到页面,而仅仅是保存了HTML节点(具体没查,哪天开新的issue看一看)。位于template中的内容其实就是DocumentFragment。

image

由此可见,template元素中的内容并不会直接渲染到页面上。

注册自定义元素

此处注册自定义元素有点类似React中定义组件。

  • 在React中
class HelloWorld extends React.Component {
    constructor(){ super() }
}

这使得HelloWorld继承自React.Component类。

  • 在Web Components中
class HelloWorld extends HTMLElement {
    constructor(){ super() }
}
customElements.define('hello-world', HelloWorld)

这能够让HelloWorld继承自HTMLElement类(接口)。

HTMLElement是网页中各个元素接口的基类。如此继承,让HelloWorld组件具有了HTMLElement相关的属性和方法,从而使其在网页中可作为元素使用。此处的HTMLElement实际上是所有HTML元素的基类,这里还可以是其他值,具体取决于HelloWorld到底有哪些特性,应当继承自哪些元素。如HTMLImageElement(<img>元素的接口)、HTMLButtonElement(<button>元素的接口)。但此处我们并不需要继承自任何其他元素。
最后调用customElements.define()方法,来将HelloWorld注册为<hello-world>这个自定义元素。

注意,为了与标准HTML元素进行区分,自定义元素的标签名中必须包含短横线。

将模板与自定义组件进行结合

此时页面上的组件已经成功注册。如果我们在页面中直接使用注册好的<hello-world>组件,可以看到此处什么也没有。
image
这是因为我们还没有将模板内容加入到组件中。此时需要在构造函数中进行一些处理:

class HelloWorld extends HTMLElement {
    constructor() {
        super();
        var shadow = this.attachShadow({ mode: 'open' });
        let templateEl = document.getElementById('helloWorld')
        let content = templateEl.content.cloneNode(true);
        shadow.append(content)
    }
}
customElements.define('hello-world', HelloWorld)

解释一下构造函数中super()以后发生了什么:

  1. 创建了一个Shadow DOM
  2. 获取到之前在HTML中写的template元素
  3. 克隆template元素中的节点
  4. 将这些克隆的节点附加到Shadow DOM中

经过此番操作,我们回到浏览器页面上看一看:
image

可以看到此时元素中已经出现了我们在之前 template 元素中所定义的相关模板。

在这里,我们新引入了一个概念,Shadow DOM


什么是Shadow DOM?

首先,打开浏览器开发者工具,启用”显示UA shadow DOM“功能
image

然后,打开一个网页,例如HTML5 Video Events and API,页面中包含一个视频。我们在开发者工具中选中视频。
image

可以发现它的下方有一个#shadow-root节点。也就是说,浏览器的一些内建组件(例如文本输入框、视频播放控件)实质上也是时使用Shadow DOM进行构建的。

Shadow DOM为浏览器提供了组件封装的能力,其样式以及结构能够与其外部相隔离。

Web Components 更多特性

上文对 Web Components 最简单的定义、使用方式进行了了解。显然,这东西事情不会这么简单。它还有一些十分有趣的特性~

CSS 作用域

CSS的作用范围一直很令人头疼;一般来说,CSS的作用域是全局的,它并没有模块化这种说法。

在Vue.js单文件组件中,我们可以为CSS指定作用域,使得当前组件中的CSS仅在当前组件中生效。例如:

<style lang="less" scoped>
#app {
  .el {
    position: absolute;
  }
}
</style>

编译到HTML以后,CSS代码变成了:

<style>
#app .el[data-v-7ba5bd90] {
    position: absolute;
}
</style>

这里在编译后自动增加了[data-v-7ba5bd90]这么一串标记,通过属性选择器选择对应元素,从而实现了样式的模块化。

但在 Web Components 中,CSS作用域的问题原生得到了解决。接着上面的代码继续。

我们在body下的style元素里定义一个样式,目的是让页面中所有的p元素字体颜色变为红色:

p{
    color: #f00
}

作为对比,我们在body下直接写一个p元素。

<p>AlohaWorld~</p>

回到浏览器,发现字体变为红色的只是body下的p元素,<hello-world>里的p元素并没有受到影响。
image

那如何修改<hello-world>组件中p元素的样式呢?
回到编辑器,找到之前我们写的template元素,在其下方写一个style标签:

<style>
    p{
         color: #00f
    }
</style>

回到浏览器,发现<hello-world>中的p元素字体都变为了蓝色,而body下的p元素并没有发生样式“后来居上”的情况(template中的style标签定义在文档后方,其中的样式覆盖掉前方文档head中style标签里的样式)。
image

为何没有如预想中发生样式覆盖呢?正如上文所说,

  1. template中的内容是独立于当前Document的一个DocumentFragment,在没有附加到文档之前,其中内容不会对文档产生影响。
  2. Shadow DOM具有隔离样式与文档的功能。在这里,我们将 template 内的 DocumentFragment 克隆插入到了shadow-root中,使得样式只在shadow-root中生效且不会被全局CSS所影响,从而使得CSS具有了作用域。

组件的生命周期

在这里,我们回顾一下Vue.js中有关组件生命周期的一些内容。

Vue.js组件生命周期包括以下这几个阶段:

  • beforeCreate
  • created
  • beforeMount
  • mounted
  • beforeUpdate
  • updated
  • beforeDestroy
  • destroyed

Web Components生命周期则包括:

  • connectedCallback
    当Web Components元素插入到页面的时候调用。
  • adoptedCallback
    当Web Components元素从当前Document移动到其它Document(假设页面里有个iframe,将iframe外所选的Web Components元素节点放入iframe中)时调用。
  • attributeChangedCallback
    当Web Components元素上的属性发生改变时调用(使用.setAttribute()方法改变节点上的属性);不过这个方法默认并不监听所有属性,需要在自定义元素定义中额外使用observedAttributes静态方法额外定义哪些属性需要监听。(详见下方代码)
  • disconnectedCallback
    当Web Components元素从页面中移除的时候调用。

笔者看来,Vue.js与Web Components声明周期函数大致可以十分粗糙地对应为:
| Vue | Web Component |
| – | – |
| created | constructor |
| mounted | adoptedCallback |
| updated | attributeChangedCallback |
| destroyed | disconnectedCallback |

(当然两者其实并不相同,不应拿来比较)

加入生命周期后,Web Components的类定义会有所改变:

class HelloWorld extends HTMLElement {
    constructor() {
        super();
        var shadow = this.attachShadow({ mode: 'open' });
        let templateEl = document.getElementById('helloWorld')
        let content = templateEl.content.cloneNode(true);
        shadow.append(content)
    }
    connectedCallback() {
        console.log(`connectedCallback`)
    }
    disconnectedCallback() {
        console.log(`disconnectedCallback`)
    }
    adoptedCallback() {
        console.log(`adoptedCallback`)
    }
    attributeChangedCallback( attrName, oldVal, newVal ) {
        console.log(`attributeChangedCallback`)
    }
    // 仅当`observedAttributes`存在时,`attributeChangedCallback`才可以生效
    static get observedAttributes() {
        return ['value'];
    }
}

组件的插槽

插槽是什么?如果你有用过Vue.js,那你一定听说过这个概念。有时,我们需要在一个组件中插入一些额外的内容,例如假设我们上面定义的HelloWorld组件是一个对话框组件,显然我们希望它能够按照如下方式接收我们要提示的信息:

<hello-world>
    我是对话框里要提示的信息。
</hello-world>

嗯,这时候我们就会用到插槽了。
和Vue.js类似,Web Components插槽也是类似<slot>这样的东西,我们把<slot>加入到<template>中要插入相关内容的地方,把要插入的内容放到中间。

<hello-world>
    我是对话框里要提示的信息。
</hello-world>
<template id="helloWorld">
    <style>
        p {
            color: #00f
        }
    </style>
    <p>
        HelloWorld~
        <slot></slot>
    </p>
</template>
image

由此我们就实现了其他内容在组件中的插入。

如果我们细看Vue.js文档,可以看到插槽分为两种,一种叫匿名插槽,另一种叫具名插槽。上面我们所演示的便是匿名插槽,这里的<slot>元素并没有命名,而我是对话框里要提示的信息。这句话也就默认放入了这个插槽中。那Web Components有没有具名插槽呢?emmmmm,当然是有的。

我们继续修改上方的代码,加入更多插槽:

<hello-world>
    <button>我是匿名插槽里的信息</button>
    <span slot="info">我是对话框里要提示的信息。</span>
    “游离的被插入匿名插槽的内容1”
    <p slot="none">没地方插入的内容</p>
    “游离的被插入匿名插槽的内容2”
</hello-world>
<template id="helloWorld">
    <style>
        p {
            color: #00f
        }
    </style>
    <p>
        HelloWorld~
        <slot></slot>
        <slot name="info"></slot>
        <slot></slot>
    </p>
</template>

此处代码,在我们引用自定义组件的地方,分别写了两个会被插入到名为info的具名插槽中的内容以及三个会被插入到匿名插槽中的内容;template则包含了两个匿名插槽,以及一个名为info的具名插槽。
最终效果如下:
image
可以发现,两个匿名插槽中,后一个没有插入内容,似乎被无视了,而所有未指定插槽名称的内容都被插入了前一个匿名插槽中;对于名为info的具名插槽,指定了插槽名称为info的内容都被插入到了该插槽中;还有一个内容指定了一个模板中不存在的插槽名称,由于没有地方插入,直接被忽略。

示例App

为了实际测试一下Web Components好不好用,我试着编写了一个示例App:使用Web Components实现的看天气的App

同样,最近我在学习React,也发现React和Web Components定义组件的方式有些类似,因此使用React重构了一个:使用React实现的看天气的App

据说Vue.js 3.0也快来了,待有时间的时候也用Vue.js 3.0重构一个吧🥴

Web Components 的一些局限性

在制作示例App的过程中,我发现Web Components的局限性还是十分明显的。

  1. 没有Diff算法

左侧是Web Components实现的App,右侧是React实现的App。
image

两者都定义了在标签上发生属性(prop)更新后,更新子组件的方法。

在React的实现中,组件prop更新的方式是调用组件的.render()方法,.render()方法中返回了一个JSX DOM。

在Web Components的实现中,为了保持和React示例一致,笔者定义了一个自己写的.render()方法,.render()方法中直接对DOM元素的 .innerHTML 进行赋值。

很显然,原生的.innerHTML并不够聪明,无论原先节点是否存在以及是否发生变化,都会全部替换为新的DOM节点;然而在React(或是Vue.js等框架中),节点渲染前会执行一次差异对比(大概就是Diff),最小程度更新需要发生改变的地方(打补丁),而非整体更新。

此处在Web Components的实现中,直接为innerHTML赋值会使得原先节点全部丢失,若这些节点中包含自定义组件,将会触发自定义组件的disconnectedCallback回调函数;而在React的实现中,经过diff,只会对需要变动的地方进行更改。

  1. 标签属性(prop)只可以接受字符串

Web Components是HTML的一部分,若通过 .setAttribute() 方法为标签上的节点赋值为一个对象,最终标签上呈现的只能是"[object Object]"。然而在React(或是Vue.js)中,编写模板时,标签可以接受对象。

结语

Web Component相关的内容真的很多,此处仅仅是一个入门。它其实是三种技术的一个合集:HTML模板、自定义元素、Shadow DOM。HTML模板定义了组件中模板片段的内容,自定义元素扩展了基本的HTML元素的功能,Shadow DOM实现了CSS作用域的隔离以及组件的封装。

笔者看来,若要开发一个单页面网页应用,使用现有框架 —— React或是Vue.js会是更好的选择,它们经过长久发展,很多功能也趋于完善,最终可以编译为兼容IE9或更高版本浏览器的基本的HTML;而对于Web Components,如果是要对原生HTML进行扩展,例如编写一个UI组件,那么它的确是一个很好地选择,而如果要用它来编写数据操作或是层级结构很复杂的组件,或是需要兼容IE以及其他过时的浏览器,那么它可就有些“力不从心”了。


评论