当前的前端时期(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用得稍多一些,因此以此为例;
- UI不再由用户手动调用DOM API来操控,而是根据数据由框架内部对相关UI进行操作,例如双向绑定、模板渲染等等,使得UI可根据数据的更新而更新(数据驱动UI);
- 页面中各个部分可直接分成多个组件,各个组件具有不同的状态与不同的数据,复用方便;
- Vue.js与React提供的虚拟DOM及其Diff让网页上页面节点随数据的变化更加智能;
- 借助脚手架与构建工具,各种组件能够清晰、有条理的分散到各个小文件(.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>
在浏览器中查看:
我们可以看到,浏览器窗口中没有任何显示,但开发者工具中,<template>
元素下的内容被包裹在了一个document-fragment
中。
在Console中获取到<template>
,访问它的.content
属性,即可看到这个document-fragment
。字面上看,document-fragment
的意思应该是文档片段,具体到DocumentFragment – Web APIs | MDN 上查了查,发现DocumentFragment实质上是相当于一个独立的Document,独立但依赖于当前页面Document。DocumentFragment并不会直接渲染到页面,而仅仅是保存了HTML节点(具体没查,哪天开新的issue看一看)。位于template中的内容其实就是DocumentFragment。

由此可见,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>
组件,可以看到此处什么也没有。
这是因为我们还没有将模板内容加入到组件中。此时需要在构造函数中进行一些处理:
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()
以后发生了什么:
- 创建了一个Shadow DOM
- 获取到之前在HTML中写的template元素
- 克隆template元素中的节点
- 将这些克隆的节点附加到Shadow DOM中
经过此番操作,我们回到浏览器页面上看一看:
可以看到此时元素中已经出现了我们在之前 template 元素中所定义的相关模板。
在这里,我们新引入了一个概念,Shadow DOM。
什么是Shadow DOM?
首先,打开浏览器开发者工具,启用”显示UA shadow DOM“功能
然后,打开一个网页,例如HTML5 Video Events and API,页面中包含一个视频。我们在开发者工具中选中视频。
可以发现它的下方有一个#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元素并没有受到影响。
那如何修改<hello-world>
组件中p元素的样式呢?
回到编辑器,找到之前我们写的template元素,在其下方写一个style标签:
<style>
p{
color: #00f
}
</style>
回到浏览器,发现<hello-world>
中的p元素字体都变为了蓝色,而body下的p元素并没有发生样式“后来居上”的情况(template中的style标签定义在文档后方,其中的样式覆盖掉前方文档head中style标签里的样式)。
为何没有如预想中发生样式覆盖呢?正如上文所说,
- template中的内容是独立于当前Document的一个DocumentFragment,在没有附加到文档之前,其中内容不会对文档产生影响。
- 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>

由此我们就实现了其他内容在组件中的插入。
如果我们细看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的具名插槽。
最终效果如下:
可以发现,两个匿名插槽中,后一个没有插入内容,似乎被无视了,而所有未指定插槽名称的内容都被插入了前一个匿名插槽中;对于名为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的局限性还是十分明显的。
- 没有Diff算法
左侧是Web Components实现的App,右侧是React实现的App。
两者都定义了在标签上发生属性(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,只会对需要变动的地方进行更改。
- 标签属性(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以及其他过时的浏览器,那么它可就有些“力不从心”了。
评论