Atag - Web Components 最佳实践
引子
上一次社区中谈论起 Web Components 已经可以追溯到三四年前了,彼时 Web Components 仍处于不稳定的草案阶段,Polymer 的出世使大家似乎看到了新一代的前端技术,但直到今天,在今年五月 Google I/O 发布 Polymer 3 之后, Web Components 的规模化应用才看似成为了可能。
过去一段时间,我一直在使用 Web Components 构建淘宝小程序的 基础组件 Atag。MDN 上对 Web Components 这个名词的解释是
Web Components是一套不同的技术,允许您创建可重用的定制元素(它们的功能封装在您的代码之外)并且在您的web应用中使用它们。
我们从中提取几个关键字:可重用
定制元素
封装
这些特性刚好能满足可复用组件的需求,更重要的是,这是由 W3C 标准提供的,面向标准编程不需要再考虑我使用的技术在未来几年内会不会过时。目前社区中的框架大都具有传染性,什么是传染性?如果你希望使用一个 React 组件,大概率你的整个用户界面都会使用 React 来开发。而 Custom Elements 不是这样,因为它替代的是 div
,能使用 div
的地方就能使用它,它即插即用:引入一个 js
文件就可以了,直接操作 DOM 使它的性能更高,它并不跟社区主流的框架相克,这样看来它更适合用来开发底层的基础组件。
回到正题,这篇文章的目的,是希望总结在 Atag 开发阶段中使用 Web Components 的经验,避免大家踩坑。
基础设施
webcomponentsjs
这是一系列 Web Components 规范的 polyfill 集合,如果你的目标用户不是最新的现代浏览器,强烈建议引入这个库。
引用方式 :
建议在 html 中使用 script 标签引入它,而不是通过 npm 引入,这样浏览器可以使用缓存帮助你减少二次加载的消耗。
(以下引入方式二选一)
使用 bundle 整包引入,这样会引入整个库中包含的所有 polyfills。
如果你需要按需引入 bundle,这个库的 bundles 目录下有一系列预打包好的 bundle 文件,用后缀标明了它包含的功能:ce 表示 custom-elements,sd 表示 shadow-dom,pf 表示环境的 polyfills。
[推荐] 使用 loader 按需引入,引入
webcomponents-loader.js
后,它会根据浏览器中的特征按需加载。
这里有必要对一些名词做一些解释:
ShadyDOM 这是 Shadow DOM 的 polyfill 的官方名称,它通过劫持 HTMLElement 的原型方法来实现一些 Shadow DOM 节点拥有的功能,实际上它的原理是把节点添加到了真实(light) DOM 节点之上。
ShadyCSS 跟上面一样,这也是 polyfill 的名称,它提供了一些 Shadow DOM 节点内样式的封装,使得可以在真实 DOM 中模拟 scoped style 的效果。它的原理是通过解析和重写 style 节点内部的样式规则来实现的。
需要注意的是,在引入 polyfill 的同时,有一些功能是无法被模拟的,需要我们在使用的时候避开,在下文中会介绍到。
NOTE: 这个库的 2.1.x 版本对 Symbol 的 polyfill 有一些问题,在官方修复之前建议使用 2.0.4 的稳定版本。
这里提供一份 alicdn 的 bundle URL:
https://g.alicdn.com/code/npm/@webcomponents/webcomponentsjs/2.0.4/bundles/webcomponents-sd-ce-pf.js
polymer 3
在 Web Components 的实践中,Polymer 不是必须的,但有了它会让我们轻松很多。强烈建议使用最新版本的 Polymer 3,在这个版本不再使用 html 定义和引入组件,官方推荐使用 JS 模块的方式进行文件组织,同时抛弃了 bower 迎接 npm,这使得很多现代的前端工具能派上用场,比如使用 webpack 和 babel 进行打包,使用 ESLint 对代码进行规则校验,使用 prettier 对代码进行美化等。
安装 polymer 3:
1 | npm i @polymer/polymer --save |
官方推荐的 polymer-cli 工具比较鸡肋,可以不用。
在使用之前强烈建议对 polymer 的文档 或者本文进行一番了解,避免踩坑。
构建配置
为什么要把构建配置单独拿出来讲呢,当然是因为有坑?。开发组件过程中 ES 678 已经是标配,运行在低版本浏览器还得依赖 Babel。Web Components 中每一个 Component,对应一个类 (class)。Babel 的转换逻辑如下:
1 | class MyTag extends HTMLElement {} |
—>
1 | var MyTag = function (_HTMLElement) { |
这样执行的话浏览器会显示如下报错 Uncaught TypeError: Failed to construct 'HTMLElement': Please use the 'new' operator, this DOM object constructor cannot be called as a function
,大意就是被继承的类 HTMLElement 必须使用 new 来初始化,不能使用函数调用 + apply 的使用方式。
针对这个问题 webcomponentsjs 额外提供了一份 custom-elements-es5-adapter-index.js
的 polyfill 来解决,这个文件的具体代码见此。引入这个文件可以通过在组件库的 webpack 配置中添加或者额外在使用的 html 文件中通过 script 标签引入,只要在组件被注册之前执行这段脚本就可以避免报错。
兼容性
特性 | Import | ShadowDOM | CustomElement | Template |
---|---|---|---|---|
Chrome 最新版 (66) | Y | Y | Y | Y |
Firefox 最新版 | N | N | N | Y |
iOS 最新版 (12) | N | Y | Y | Y |
Android (UC 11.6.0.960) | Y | Y | Y | Y |
其中 import 特性在整个体系下比较鸡肋,可以通过 webpack 打包的方式来替代。
结论是在移动端下,99% 以上的用户可以通过 polyfill 的方式来获得比较好的 Web Components 特性支持。
性能
(单位 ms) | 注册 1W 个组件 | 渲染 1W 个组件 |
---|---|---|
pure web-components | 55.5ms 48.6ms 47.2ms | 934.3ms 889.0ms 915.1ms |
polymer | 184.9ms 191.9ms 197.8ms | 768.0ms 858.4ms 785.0ms |
React 16.2.0 | 38.9ms 38.0ms 40.60ms | 1834.8ms 1754.8ms 1869.5ms |
Rax 使用 View 和 Text 组件 | 86.4ms 73.9ms 82.4ms | 11587.4ms 11238.0ms 11289.6ms |
Rax使用 append={‘tree’} 模式 | 82.5ms 67.3ms 81.4ms | 798.0ms 823.5ms 878.0ms |
可以看到,由于 React JSX 的 VDOM 在构建时解析的加持,React 的注册时间是最短的,但是放大到 1W 个组件的渲染时,原生 DOM 的性能就发挥出来了,web-components 获得了比较优秀的表现。对原生 web-components 和 polymer,后者只是在注册的时候由于需要在运行时解析模板字符串,牺牲了一些性能,但是如果组件数量有限,这个性能差距可以忽略不计,加上 polymer 本身提供的一些组件化开发的便利,整体来看使用 polymer 获得的收益还是比较高的。
基准环境:
Chrome 66
macOS 10.13.1
Macbook Pro 15’ late 2015
组件化
个人认为,Web Components 在整个前端的语境下更偏向于提供符合 DOM 标准的规范,而 Polymer 则是在这种规范之上的一种框架封装,使用 Polymer 可以带来更便利的组件化开发体验。因此这里就不多介绍如何使用标准的 custom element 来创建自定义标签,下文都使用 Polymer 来封装自定义标签。文章中的组件、自定义标签、自定义组件其实描述的是同一个东西。
推荐使用以下模式创建一个自定义组件。
1 | import { PolymerElement, html } from '@polymer/polymer'; |
使用以下方式注册自定义组件。
1 | customElements.define(CustomElement.is, CustomElement); |
使用以下或任意方式使用自定义组件。
1 | const el = document.createElement('custom-element'); |
生命周期
PolymerElement 继承了 HTMLElement,所以它拥有和 HTMLElement 一致的生命周期。
constructor:组件被 create 的时候会被调用,整个生命周期中最早触发也只会触发一次,通常可以在这里做一些初始化私有变量、记录数据的一些操作;但是出于性能和职责分离的考虑,不建议在这里做一些 DOM 相关的事情。
connectedCallback:组件被 连接
到 DOM Tree 的时候会触发,这个时机包括节点被插入节点树、节点被从节点树中移动,所以它可能会被触发多次。你可以在这里监听 DOM 事件或者对 DOM 节点做一些修改。
disconnectedCallback:组件被从 DOM Tree 中移除的时候触发,这个生命周期也可能被触发多次。如果你在 connectedCallback 中监听了事件,一定要记得在这里移除,否则事件监听回调可能会一直引用导致内存泄露和一些奇怪的问题。
adoptedCallback:不常用,不多介绍。
attributeChangedCallback:当组件的 attribute 发生变化的时候触发,它的三个形参分别是 name, oldValue, newValue
,记得别把顺序搞反了。如果你声明了 properties 对象,对 attribute 的相应值变化也会触发这个回调。需要注意的是,如果你覆盖了组件的 observedAttributes
静态方法,properties 对象中声明的值不会触发,它会按照你覆盖的 observedAttributes
静态方法的返回值为准。
除此之外,polymer 还额外添加了一些生命周期
ready:由于 HTMLElement 的生命周期中没有一个可以操作 DOM,又只触发一次的周期,Polymer 人为地添加了 ready 这个时机,它在整个生命周期中只会触发一次,也就是第一次节点插入到 DOM 树的时刻。
记得调用 super.ready()
来触发 PolymerElement
的初始化阶段。在初始化阶段,Polymer 会做以下几件事情:
attache 组件实例的 Shadow DOM,所以在这个阶段之后才可以访问
this.shadowRoot
初始化 properties,并赋初始值
如果 properties 有声明 observer 或者 computed,会执行它们
通常可以在 ready 函数中给组件实例添加一个 this._isReady = true;
的状态以标明组件已经 ready。
首屏性能优化技巧
我们知道页面的首屏渲染直接影响了这个页面的性能,组件也是一样。Polymer 提供了 render-status 的 afterNextRender 方法来帮助你在首次渲染之后执行一些不必要的 DOM 操作,比如添加事件绑定。
1 |
|
属性 (property)和属性 (attribute)
在 DOM 中,property 和 attribute 这两个概念是严格区分的,虽然有时它们会产生类似双向绑定的联动,例如 id、input 的 checked 属性等。
在 Polymer 中,使用 properties 静态属性来声明组件的 property:
1 | class XCustom extends PolymerElement { |
在大多数情况下,如果你的属性需要暴露为公有 (public) API,你需要把这个属性声明到 properties 对象中。
关于 properties 对象的声明,可以参考文档
属性的默认值
通过 property 的 value 可以设置属性的默认值,这里强烈建议所有的属性都显式声明一个默认值,这样更清晰于阅读,也避免了默认为 undefined 值处理的坑。
如果声明的 value 是一个函数,Polymer 会在初始化这个组件的时候取函数的返回值为默认属性;如果 value 是一个对象或数组,Polymer 会在初始化这个组件的时候对 value 做一次浅拷贝,所以不用担心会在不同组件实例中会共享同一个对象。
1 | class CustomEl extends PolymerElement { |
attribute 映射为 property
在 properties 对象中声明的属性,polymer 会自动创建从 attribute 到 perperty 的映射解析规则,解析的规则是:
大写的 attribute 名称会被转换为小写的 property:
firstName
映射为firstname
带中划线
-
的 attribute 名会被转换为小驼峰 (camelCased) 的 property:first-name
映射为firstName
值的解析会使用声明的类型去判断和解析,对于 JS 基本类型的值,会直接转换类型映射;对于 Object 和 Array 类型,会通过 JSON.parse
来解析。
1 | <my-element id="foo" book='{ "title": "Persuasion", "author": "Austen" }'> </my-element> |
由于 JSON 的序列化和反序列化会比较消耗性能,而且我们从数据源获取数据后一般已经是 JS Object,这个时候通过 property 赋值的方式来使用 Object/Array 类型的数据可以避免额外的性能消耗。
1 | var el = document.querySelector('#foo'); |
关于布尔值,我们在 React 中习惯于显式传递布尔值 <Checkbox checked={false} />
,但在 DOM 中,attribute 判断布尔类型的方式是判断 attribute value 字符串的长度。<a-checkbox checked /> <a-checkbox checked="false">
前面这些都表示 checked 为 true,而只有 <a-checkbox />
才表示 checked 为 false。
所以对于布尔值,这里建议在使用时跟对象一样,使用 property 赋值的方式去修改,避免使用 setAttribute 修改布尔类型的属性。如果你希望修改 checked attribute 的语义,在为字符串 “false” 的时候表示真正的 false 概念,你也可以声明一个 getter 来帮助你在内部做判断,但是其实我不建议这么做。
1 | class X extend PolymerElement { |
property 映射为 attribute
双向绑定不是必须的。
通过开启 reflectToAttribute: true
选项可以自动把 property 映射为 attribute 值,序列化的方式跟上边正好相反。由于涉及到 setAttribute 这一 DOM 操作,开启这个功能是比较消耗性能的,仅在必要的时候开启。
使用的例子如 radio
、checkbox
组件的 checked
属性,image
组件的 src
属性等。
计算属性
跟 Vue 的 computed 很像 (其实是 Vue 参考了这里),计算属性允许你用一个方法来计算 property 的值。
观察属性
跟 Vue 的 watch 很像,当你的属性发生变化时,polymer 会通知相应的函数。
1 | class XCustom extends PolymerElement { |
观察 children 的变化
有时候我们需要观察 children 节点的添加或者删除,比如在 swiper
组件中,它需要动态地监听子节点中 swiper-item
节点的增删改,来变化其内部的 indicator-dots
数量等。
Polymer 提供了 FlattenedNodesObserver 工具集合来追踪子孙节点的变化。
1 | import { FlattenedNodesObserver } from '@polymer/polymer/lib/utils/flattened-nodes-observer'; |
添加监听器,每当子节点发生变化的时候会触发对应的回调函数,回调函数的第一个参数包含了一些增加或者删除节点的信息可以使用:
1 | this._childrenObserver = new FlattenedNodesObserver(this, this._handleChildrenChanged); |
当然不要忘记移除监听器:
1 | this._childrenObserver.disconnect(); |
获取子孙节点的方法:
1 | FlattenedNodesObserver.getFlattenedNodes(this); |
模板语法
Polymer 使用 html
方法来把模板字符串转换成一个 DOM fragment,并可以自由绑定组件实例上下文中的属性。
创建 Shadow DOM
1 | import { PolymerElement, html } from '@polymer/polymer'; |
slot
表示该组件子节点的引用,跟 Vue 的 slot 师出同门,对应 React 的 this.props.children
的概念。
需要注意的是反引号语法在这里其实等价于 html(templateString)
,但是不要试图在模板字符串中使用插值,至于原因你可以参考阮老师的 ES6 参考。
创建 Light DOM
有时候由于 Shadow DOM 的一些限制,它并不能满足所有组件的需求,我们可能需要创建一个拥有真实节点的自定义标签组件。
跟普通的 DOM 操作一样,如果你需要创建真实节点(light dom),直接在 constructor 生命周期中使用 DOM 方法创建并添加到节点的 children 中即可。
1 | import { PolymerElement, html } from '@polymer/polymer'; |
详细参考 https://www.polymer-project.org/3.0/docs/devguide/dom-template
公私有方法和属性的命名约定
这是一个建议,但是 W3C 标准中的方法大多遵守这一约定。除生命周期方法外,组件的实例属性和实例方法会被视作外部接口,可被外部直接访问到。所以如果是私有的属性和方法,需要加上 _
作为前缀以作区分。如
1 | class MyVideo extends PolymerElement { |
Polymer 官方建议使用
_
前缀表示私有变量,__
前缀表示受保护的变量,个人以为这种区分太过复杂,建议用一个_
来替代就可以了。
事件
这里的事件指的都是 DOM 事件,你可以通过模板事件监听或者 DOM 事件监听两种方式来绑定事件。前者是声明式的而且不需要处理解除绑定,所以更加推荐使用模板事件监听器的方式来绑定事件。
模板事件监听器
1 | static get template(){ |
这里的 handleClick 指的是实例方法,其它就跟直接绑定 onclick
事件没什么差别。
DOM 事件监听器
1 | connectedCallback() { |
不要忘记在 disconnectedCallback 的时候解除绑定
1 | disconnectedCallback() { |
有一种情况下你必须使用 DOM 事件监听器:你希望监听的事件节点不在 ShadowDOM 中,例如使用 window 或者 document 对象代理监听事件等。
关于冒泡
slot 子节点的冒泡,在 ShadowDOM 中,slot 子节点是出现在影子节点树内部的,所以它冒泡事件传递顺序会包含 slot 节点,在 polyfill 的实现中并不是这样,所以不要依赖这一项来实现一些功能。
触发自定义事件
有时候组件可能希望在事件中带入一些额外的 detail
信息。如果你的事件名称与 DOM 事件不同名,直接使用 Custom Event 的接口声明和派发即可。
1 | const myEvent = new CustomEvent('myevent', { |
如果你的事件名称与 DOM 事件同名,原生 DOM 事件可不支持修改,但是你可以在它冒泡的时候阻止它并派发一个自己的同名事件出来。
在 window
上捕获你需要拦截的事件,并在事件处理回调中停止冒泡原生事件和派发一个自定义的同名事件:
1 | connectedCallback() { |
手势
在移动端中手势的处理可以说是比较常用的功能,我们可以通过监听 touchstart
touchmove
touchend
touchacancel
事件并合成处理几乎所有的复杂交互。Polymer 为组件开发者提供了 Gesture
工具库,你可以按需地引入它:
1 | import * as Gestures from '@polymer/polymer/lib/utils/gestures'; |
它提供了很多合成事件的封装,比如在 ATAG 中,swiper 轮播器组件就使用到了 track 合成事件。
在 ready 的时刻添加 track 事件的监听,
1 | ready() { |
track 使你追踪手指滑动的过程和方向。
有时候你需要考虑横向滚动和纵向滚动之间的关系,比如在横向滚动 swiper 轮播器的时候,通常并不希望同时触发纵向的页面滚动,这个时候就需要在开始内部滚动的时候为组件添加一个拖动的状态,在拖动状态中的 touchmove
事件需要被执行 preventDefault
1 | _handleSwiperTouchMove = event => { |
另外在 Polymer 监听 track 事件的时候,组件本身的 touch-action
CSS 属性会被置为 none
,这是一个 Chrome 下已经支持的属性,当为 none
时组件不向上传递触摸事件和阻止默认事件,跟 preventDefault
的效果一致。所以如果你需要响应滚动事件,可以参考以下方法自定义 touch-action
的值:
1 | /** |
NOTE:
样式
使用 Polymer 的 template 方法创建的 customElement 默认是 Shadow DOM,Shadow DOM 内的样式是局部作用域的,也就是说内部的样式不会影响全局。
1 | static get template() { |
避免动态修改 style 标签来应用样式
对于 Shadow DOM 内部的样式,ShadyCSS 会解析和重写以正确地作用到真实节点上,所以如果你的组件内部有动态创建或者写 style 标签的 innerHTML 属性,这些都不会被 ShadyCSS 作用到,应当避免。
如果实在无法避免,我在使用的过程中使用了一种 HACK 的手段:
给每一个组件添加一个 uid 的标识来区分组件实例,并带到组件的 data-id attribute 上面
在写 CSS 的时候通过 [data-id=xxx] 选择符来区分
这种方式你需要同时写 :host {} 和 my-element[data-id=xxx]{} 两个 CSS 选择符来保证同时非 polyfill 情况下和 polyfill 情况下都能工作
由于对 ShadyCSS 的源码研究还不够,可能还有更好的办法,如果你有的话也请在下边评论指教~~
测试
一个健壮的基础应用必须有响应的测试机制来保障,一般一款软件的稳定性跟它的测试代码/应用代码的比例正相关。DOM 相关的 UI 测试在前几年一直有着不错的发展,比如截图对比测试,Driver 服务化等,但是真正被大规模使用的寥寥。本着实用至上的原则,Atag 的测试分为自动化的冒烟测试和手动的 UI 测试。
冒烟测试
顾名思义,就是渲染一个组件,看看有没有报错或者渲染异常,进行一些简单的 DOM 渲染判断。这里我们用到了 Karma + puppteer 的搭配,如果你想验证浏览器兼容性的差异也可以加入更多的 karma-driver。
karma 配置文件:
1 | const webpackConfig = require('./tests/webpack.test.js'); |
建议继续学习:
扫一扫订阅我的微信号:IT技术博客大学习
- 作者:Taobao FED 来源: Taobao FED | 淘宝前端团队
- 标签: atag components
- 发布时间:2019-03-26 22:18:33
- [68] Go Reflect 性能
- [68] 如何拿下简短的域名
- [67] Oracle MTS模式下 进程地址与会话信
- [62] IOS安全–浅谈关于IOS加固的几种方法
- [61] 图书馆的世界纪录
- [60] 【社会化设计】自我(self)部分――欢迎区
- [58] android 开发入门
- [56] 视觉调整-设计师 vs. 逻辑
- [49] 给自己的字体课(一)——英文字体基础
- [48] 读书笔记-壹百度:百度十年千倍的29条法则