Web Components学习(2)-语法
一、Web Components 对 Vue 的影响
尤雨溪在创建 Vue 的时候大量参考了 Web Components 的语法,下面写个简单示例。
首先写个 Vue 组件 my-span.vue:
<!-- my-span.vue -->
<template><span>my-span</span>
</template><script>
export default {}
</script><style>
span {color: purple;
}
</style>
这是很标准的 Vue 组件,不过非常遗憾的是 HTML 文件并不能有效的利用这个 vue 文件,如果想让它能够正确运行,还需要下载 node、webpack、vue-loader 将其打包,而且它只能在 Vue 的项目中使用,也就是必须依赖 Vue 的安装包。如果在 React、Angular 甚至 jQuery 项目中,这个组件就不能用了。
但是以前只需要将它稍稍修改一下,它就会变成 Web Components 文件,能够直接在浏览器中运行。
只需要修改 <script>
中的 JS 代码和文件后缀:
<!-- my-span.html -->
<template><span>my-span</span>
</template><script>
// 获取 DOM 元素
const dom = document.currentScript.ownerDocument.querySelector('template').content// 有点像 React 定义组件的写法
class MySpan extends HTMLElement {constructor() {super()this.attachShadow({ mode: 'open' }).appendChild(dom)}
}// 注册组件
customElements.define('my-span', MySpan)
</script><style>
span {color: purple;
}
</style>
使用 HTML Imports 在 HTML 页面中引入组件:
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Document</title><!-- HTML Imports --><link rel="import" href="my-span.html">
</head>
<body><my-span></my-span>
</body>
</html>
但是现在 HTML Imports 已废弃(被 ES Modules 的代替),所以不能使用这种方式了。
如果还想要以独立模块的方式引入,那么就要通过 JS 生成 HTML 和 CSS:
// my-span.js
class MySpan extends HTMLElement {constructor() {super()this.render()}// 生成 HTML 和 CSSrender() {const shadow = this.attachShadow({ mode: 'open' })const dom = document.createElement('span')const style = document.createElement('style')dom.textContent = 'my-span'style.textContent = `span {color: purple;}`shadow.appendChild(style)shadow.appendChild(dom)}
}// 注册组件
customElements.define('my-span', MySpan)
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Document</title><!-- HTML Imports(已废弃) --><!-- <link rel="import" href="my-span.html"> --><!-- ES Modules --><script type="module" src="my-span.js"></script>
</head>
<body><my-span></my-span>
</body>
</html>
注意:使用 ES Modules 和 HTML Imports 一样,都要开启一个 Web 服务,直接打开 HTML 文件,会以文件协议(file://)的方式打开,控制台会报跨域错误。可以使用 vscode 的 Live Server 插件打开 HTML。或者不使用 ES Modules:
<script src="my-span.js"></script>
也可以提前在 HTML 页面中通过
<template>
原生标签写好 DOM 结构,然后在组件中通过 DOM API 获取模板内容,不过这样并没有将组件作为独立的模块分离出来。
如果想使用插槽,只需要将 <slot />
添加进去:
const dom = document.createElement('span')
const style = document.createElement('style')
// dom.textContent = 'my-span'
dom.innerHTML = '<slot>默认内容</slot>'
style.textContent = `span {color: purple;}
`
使用上也和 Vue 一样:
<!-- 默认内容 -->
<my-span></my-span>
<!-- 自定义内容 -->
<my-span>自定义内容</my-span>
如果想使用具名插槽(多个插槽):
const dom = document.createElement('span')
const style = document.createElement('style')
// dom.textContent = 'my-span'
dom.innerHTML = `<slot>默认内容</slot><slot name="content">默认内容</slot>
`
style.textContent = `span {color: purple;}
`
使用:
<my-span><h1>默认插槽</h1><h2 slot="content">另一个插槽</h2>
</my-span>
Vue 新语法已经建议使用 v-slot
,而 Web Components 还是 slot
。
二、HTML Imports
Web Components 的历史
Web Components 不是一门单一的技术,而是四门技术的组合,这四门技术分别是:
- HTML Imports
- HTML templates
- Custom Elements
- Shadow DOM
HTML Imports
HTML Imports 就是上面示例中的 用来引入另一个 HTML 文件。
可惜 HTML Imports 已经被废弃,如果想正常使用 HTML Imports 代码查看效果,可以安装低版本浏览器,例如 Chrome 79:
通过上面的示例可以看到 HTML Imports 很好用,为什么会被废弃?这就要讲讲 Web Components 的前世今生了。
很多人都认为 Google 是一个比 IE 还“遵纪守法”的好公民,因为它一直遵守 W3C、ECMA 的标准,才可以得以干掉 IE 成为浏览器市场的占有率之王。
其实 Google 可不老实,它经常会倒逼标准的形成。比方说 Google 自己实现了一个 CSS 属性,那时候 W3C 并没有发布标准,于是它就在自己自创的属性前加个前缀 -webkit-
,这代表只是它自己浏览器的实验性属性,并没有破坏标准私自发布属性。
可是开发者觉得这个属性真的很好用,可以实现很酷炫的效果,其他浏览器的厂商有的是觉得这个属性确实很不错,而有的是感觉到了压力,总而言之,所有浏览器厂商最终都实现了这个属性,为了表示自己也没有破坏标准私自发布属性,大家都默默地在这个属性前面加上自己浏览器内核的前缀,像 -moz-
、-ms-
、-o-
。
虽然这个属性没有在 W3C 等机构成为标准,但它已经成为了**“事实标准”**,世界各地的开发者们也都已经用这个属性实现出来成千上万个网站了,最终也不得不把它标为标准。于是 Google 最终自己研究出来的属性就这样成为了标准。大家再也不用写那么烦人的前缀了。Web Components 也正是基于这样的一种情况下诞生的。
话说在 2011 年的时候 Google 就推出了 Web Components 的概念,当时前端还没有**“模块化”的概念,甚至都没有“前端”**的概念,这个时期 Google 就已经敏锐的察觉到前端需要组件化,但最开始他们也只是提出了这个概念,并没有开发出真正能用的前端组件化。
2015 年 Web Components 终于能用了,所以网上开始有人介绍 Web Components,这也是为什么网上大部分 Web Components 文章都是 2015 - 2016 写的(内容还包括 HTML Imports)。
那么为什么这么多年过去了,Web Components 还没有火起来呢?
因为 Google 的做法引起了其他浏览器厂商的不满,凭什么这么重要的新功能就你一家说了算,平时你实现的 CSS3 属性啥的,我们睁一只眼闭一只眼也就算了,可 Web Components 是非常重大的一项功能,API 长什么样子都是你自己定,我们不同意。于是 Web Components 的第一版,也就是 V0,就只有 Google 自己实现了。
Google 也意识到,虽然自己目前市场占有率全球称霸,但只要其它浏览器不支持还是不会有人用,毕竟大家都要考虑兼容性的问题,不可能只考虑用 Google 内核的用户上网才能够看到效果,其它浏览器就不管。
所以 Google 决定,和其它主流浏览器厂商一起讨论一下,在讨论中大家就产生了激烈的分歧,比如苹果系统的浏览器 Safari 觉得 Shadow DOM 应该始终保持封闭以保证独立性,而 Google 则认为要始终保持开放,让用户能够访问到,不然 Web Components 组件库在用户的眼中始终是一个无法窥视内部构造的黑盒;还有火狐浏览器觉得马上要出 ES6 了,HTML Imports 不用实现,先看看 ES6 的模块化怎么样,感觉它也能代替 HTML Imports 的功能。
于是根据各个浏览器厂商的不满,又修订了第二个版本:V1,这正是目前使用的版本。就在各大浏览器厂商不断扯皮的过程中,三大框架崛起了:Angular、React、Vue,它们都有组件化的功能,于是其它浏览器厂商实现 Web Components 的动力就有点不足了,本来实现起来就挺复杂的,现在更不想实现了。
但是几年过后,大家发现浏览器真的需要一个原生的组件化技术,开发者们也一直都在询问到底为什么就是不实现 Web Components。
基于种种压力之下,Safari 在 2017 年实现了 Web Components,当然只实现了一部分,因为他们至今都不是很认同 Google 的 is 属性(类似 Vue 的 is 属性),所以他们就是不实现,反正 Web Components 还没有成为标准(2017 年),这也不算不遵守规范。
而火狐则是在 2018 年实现的 Web Components。
微软的 Edge 浏览器现在改用 Google 的内核了,IE 就不要提了。
Opera 也早就改用 Google 内核了。
至此所有浏览器都实现了 Web Components,不过它终究还是来的太晚了点,三大框架早已瓜分了市场,形成了三足鼎立的局面。但之后随着时间的推移,三大框架有可能会用 Web Components 去实现自己底层的组件化系统。
而 Vue CLI 早就实现了能将 Vue 组件编译成 Web Components 的功能。
而且一些组件库为了能够跨框架运行,也是采用了 Web Components 来实现,比如 Taro 3 为了能够让写不同框架的的人都能用上组件,特意采用了 Web Components 来实现的基础组件。
既然 HTML Imports 已经废弃,这里也不再学习它的具体用法。但可以介绍一下它的下一代技术 HTML Modules。
下面是一个无法运行的示例:
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Document</title>
</head>
<body><script type="module">import A from './component.html'</script>
</body>
</html>
<!-- component.html -->
<template></template><script type="module"></script>
目前还没有任何浏览器实现了这一提案,所以上例代码无法演示,而且这种写法目前来说争议非常大,因为在<script>
标签里引入了一个 HTML 文件,之所以要这么做,是因为 <template>
通常就是用来引入到 JS 文件里使用的。
下面介绍 Web Components 中最重要的一项技术,同时也是所有浏览器都没有提出反对意见,一致通过的一项技术 —— Custom Elements(自定义元素)。
Shadow DOM 和 HTML templates( and slots) 目前主流浏览器也同样支持,通常都会应用于 Custom Elements,前者是用于封装独立于主文档的 DOM,后者类似 Vue 的 Slot,本文不作详解。
Custom Elements 自定义元素
MDN:使用 custom elements
基础使用
window 全局对象上有一个 customElements 提供自定义元素支持,它包含四个 API:
- define:注册/定义自定义元素
- get:获取自定义元素的构造函数
- whenDefined:
- upgrade:
- define 示例
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8" /><title>Custom Elements</title></head><body><!-- 使用元素 --><long-time-no-see></long-time-no-see><!-- 不能写成自闭和标签,浏览器会当作起始标签包裹后面的全部内容 --><!-- <long-time-no-see /> --><script>// 注册组件(自定义元素)window.customElements.define(// 参数1:元素名,必须包含一个短横线,以区分原生元素'long-time-no-see',// 参数2:用于定义元素行为的类(类似 React 中的类组件),必须继承自 HTMLElementclass extends HTMLElement {constructor() {super()// custome elements 类中的 this 指向组件本身console.log(this)this.innerHTML = '<h1>好久不见</h1>'this.onclick = () => alert('你还好吗')}})// 多次注册相同名称的组件会报错:// the name "long-time-no-see" has already been used with this registry// window.customElements.define('long-time-no-see', class extends HTMLElement {})// 获取自定义元素的构造函数console.log(customElements.get('long-time-no-see'))// 如果获取的是一个并没有被定义的元素,则返回 undefined// 使用场景1:用于判断组件是否已被注册过// 使用场景2:扩展第三方组件console.log(customElements.get('long-time-no-see1'))</script></body>
</html>
get 示例
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8" /><title>Custom Elements</title></head><body><!-- 扩展第三方组件 --><my-bubbles click>我扩展的 bubbles</my-bubbles><script type="module">import { FcBubbles } from 'https://unpkg.com/fancy-components'// 注册组件new FcBubbles()// 获取第三方组件的构造函数,用于继承扩展const FcBubblesConstructor = customElements.get('fc-bubbles')customElements.define('my-bubbles',class extends FcBubblesConstructor {constructor() {super()this.onclick = () => console.log('自定义点击事件')}})</script></body>
</html>
whenDefined 示例
通常都会将
当渲染引擎读取到自定义元素的时候,并不知道它是什么元素(此时注册脚本还没执行),一般来说当渲染引擎碰到一个不认识的元素的时候,会认为这是一个无效的元素。
不过自定义元素的命名规则要求必须包含短横杠 -,是为了和原生元素区分开,所有当渲染引擎看到一个不认识的元素,但是名称中带有横杠连字符,会将它认为是一个未定义的自定义元素,不会当作一个无效元素。当执行到注册自定义元素的代码时,就会将之前未定义的元素标记为定义的元素。
定义的元素对应的伪类选择器就是 :defined,未定义的元素对应的伪类选择器就是 :not(:defined)
通过这个伪类选择器,可以在定义元素之前的空白时间内,设置自定义元素的加载样式。
whenDefine 是元素定义后触发的回调,通常用于异步注册组件的时候:
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8" /><title>Custom Elements</title><style>:not(:defined) {width: 120px;height: 60px;background: gray linear-gradient(-60deg, transparent, transparent 20%, white 40%, transparent 60%) 0 / 300%;border-radius: 15px;animation: loading 2s infinite;display: grid;place-items: center;}@keyframes loading {to {background-position: 300% 0;}}</style></head><body><long-time-no-see>Loading</long-time-no-see><script>// 模拟 JS 代码执行延迟setTimeout(() => {customElements.define('long-time-no-see',class extends HTMLElement {})}, 3000)// 返回一个 PromisecustomElements.whenDefined('long-time-no-see').then(() => {document.querySelector('long-time-no-see').innerHTML = '好久不见'}).catch(err => console.log(err))</script></body>
</html>
upgrade 示例
upgrade 是升级的意思,如果在定义元素之前先使用 JS 创建了元素,则元素实例并不是继承的定义元素行为的类,可以使用 upgrade 将其升级为期望的样子:
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8" /><title>Custom Elements</title></head><body><script>// 先使用 JS 创建自定义元素const el = document.createElement('vue-react')// 再注册自定义元素class VueReact extends HTMLElement {}customElements.define('vue-react', VueReact)// 返回 falseconsole.log(el instanceof VueReact)// 升级元素customElements.upgrade(el)// 返回 trueconsole.log(el instanceof VueReact)</script></body>
</html>
生命周期
我们经常会在组件的初始阶段设置监听器,在组件的挂载阶段获取 DOM 元素,在组件的更新阶段发送一些 ajax 请求,在组件的卸载阶段做一些清理操作(例如移除定时器)。
Web Components 的生命周期比 Vue 和 React 的都要少。
下面通过 Vue 的生命周期来对比 Web Components 的生命周期:
connectedCallback
vs mounted
Vue 和 React 都是靠一个根元素(Root)来实现的,默认 Vue 里是一个 id 为 app 的元素,React 中是一个 id 为 root 的根元素:
<div id="app"></div>
一开始 DOM 都是空的,是靠 JavaScript 动态生成的 DOM,然后再往里填充:
const component = document.createElement('h1')
它需要挂载到 HTML 页面上才能显示:
const root = document.getElementById('app')
root.append(component)
所以这个过程叫挂载,而对应的生命周期命名为 mounted。
而自定义元素组件通常是先在 HTML 中编写组件,浏览器会先解析到它:
<life-cycle></life-cycle>
然后浏览器继续解析到定义它的 JS 代码时,就会将其与 JS 定义的元素(构造函数中的 this)进行连接:
customElements.define('life-cycle', class extends HTMLElement {})
这个连接的过程和 Vue、React 挂载的过程有很大的区别,所以它叫 connectedCallback
。
adoptedCallback
示例
adopt 是收养的意思,DOM API document.adoptNode可以剪切文档(包括另一个文档)中的节点,可以通过它将其它文档上的节点剪切到当前文档中使用,这个过程可以成为“收养(adopt)”,例如:
<!-- iframe.html -->
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>iframe</title>
</head>
<body><h1>我来自 iframe</h1>
</body>
</html>
<!-- index.html -->
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8" /></head><body><iframe src="./iframe.html"></iframe><script>const iframe = document.querySelector('iframe')iframe.onload = () => {const dom = iframe.contentDocument.querySelector('h1')// 剪切元素const adoptDom = document.adoptNode(dom)// 添加到当前文档document.body.append(adoptDom)}</script></body>
</html>
注意:要开启一个 web 服务访问页面,否则获取不到 iframe 的内容。并且要访问 iframe 的内容还要符合同源要求。
而 Web Components 的 adoptedCallback
生命周期回调指的是元素被移动(剪切)到新的文档时被调用,正符合这个场景:
<!-- iframe.html -->
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8" /><title>iframe</title></head><body><life-cycle>我来自 iframe</life-cycle><script>customElements.define('life-cycle',class extends HTMLElement {constructor() {super()// 相当于 Vue3 的 setupconsole.log('constructor')}connectedCallback() {// 相当于 Vue 的 mountedconsole.log('connected')}disconnectedCallback() {// 相当于 Vue 的 unmountedconsole.log('disconnected')}adoptedCallback() {console.log('adopted')}})</script></body>
</html>
<!-- index.html -->
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8" /></head><body><iframe src="./iframe.html"></iframe><script>const iframe = document.querySelector('iframe')iframe.onload = () => {const dom = iframe.contentDocument.querySelector('life-cycle')// 剪切元素const adoptDom = document.adoptNode(dom)// 添加到当前文档document.body.append(adoptDom)}</script></body>
</html>
该示例可以查看不同生命周期函数触发的时机。