本文共 9893 字,大约阅读时间需要 32 分钟。
纯原生的组件化、模块化的一次小小的尝试,用到了如下几个新特性:
shadown-DOM
对HTML
标签结构的一个封装,真正意义上的组件,能保证shadow-DOM
中的DOM
元素不会被外界影响,内部也不会影响到外部的行为,变成了一个独立的模块。custom-elements
可以在浏览器中注册自定义的标签来使用,类似这样的效果<my-tag></my-tag>
,标签内容基于两种形式:1. 普通子元素 2.shadow-DOM
custom-events
使用各种自定义事件辅助完成组件之间的通讯ES-module
为浏览器原生支持的模块化的一种方案,直接在浏览器里使用import
和export
这类语法,以 module 的方式来引入 js 文件。 几个算是比较新的事物,聚集在一起确实可以做点儿好玩的东西出来。
想象有这样的一个场景,类似资料卡的东东,需要在页面中展示头像和用户的名称。
头像在左,宽高100px
,圆形; 姓名在右,字号16px
,垂直居中。 这算是一段很简单的CSS
了,实现起来大概是这样的:
复制代码Jarvis
此时,我们完成了需求,一切都没有什么不对的,但是一个很现实的问题。
不会有这么简单的页面存在的,就算简洁如 Google 首页,也用到了400
左右的DOM
元素。 很难保证其他资源文件中的CSS
、JS
会不会对上边的DOM
产生影响。 就比如如果有一个main.css
文件中写了一行:p { color: red;}
,那么这条CSS
就会对我们上边所写的.info-name
元素产生影响,导致文本颜色变为红色。 这种问题经常会出现在一些需要用到第三方插件的页面中,很可能对方提供的CSS
会影响到你的DOM
元素,也很有可能你的CSS
会对插件中的DOM
造成影响。
解决这个问题有一种简单的办法,那就是All with !important
,使用shadow-DOM
。
目前浏览器中就有一些shadow-DOM
的例子:
<video>
<audio>
<input>
这些元素在 Chrome 上的构建都是采用了shadow-DOM
的方式,但是默认情况下在开发者工具中是看不到这些元素的。
开启
shadow-DOM
的流程: Chrome DevTools -> Settings -> 默认 Preferences 面板中找到 Elements -> 点击勾选 Show user agent shadow DOM 即可
这时候就可以通过开发者工具查看到shadow-DOM
的实际结构了。
shadow-DOM
的一个特点,shadow 里边所有的DOM
元素不会被外界的代码所影响,这也就是为什么video
和audio
的 UI 难以自定义的原因了-.-。
shadow-DOM
的创建必须要使用JavaScript
才能完成,我们需要在文档中有一个用于挂在shadow-DOM
的真实元素,也被称为host
。
DOM
树那样的增删改子元素了。 let $tag = document.querySelector('XXX') // 用于挂载的真实元素let shadow = $tag.attachShadow({ mode: 'open' }) // 挂载shadow-DOM元素,并获取其根元素复制代码
attachShadow
中的mode
参数有两个有效的取值,open
和closed
,用来指定一个 shadow-DOM 结构的封装模式。
当值为open
时,则我们可以通过挂载时使用的真实元素获取到shadow-DOM
。
$tag.shadowRoot; // shadow-DOM的root元素复制代码
当值为closed
时,则表示外层无法获取shadow-DOM
。
$tag.shadowRoot; // null复制代码
后续的操作就与普通的DOM
操作一致了,各种append
、remove
、innerHTML
都可以了。
let $shadow = $tag.attachShadow({ mode: 'open' })let $img = document.createElement('img')$shadow.appendChild($img) // 添加一个img标签到shadow-DOM中$shadow.removeChild($img) // 将img标签从shadow-DOM中移除$img.addEventListener('click', _ => console.log('click on img'))$shadow.innerHTML = ``复制代码Some Text
需要注意的一点是,shadow-DOM
本身并不是一个实际的标签,不具备定义CSS
的能力。
$shadow.appendChild('') // 假装add了一个标签$shadow.appendChild('') // 假装add了一个标签// 最后得到的结构就是// <外层容器> // // // 外层容器>// 没有class相关的属性$shadow.classList // undefined$shadow.className // undefined$shadow.style // undefined// 绑定事件是没问题的$shadow.addEventListener('click', console.log)复制代码
shadow-DOM也会有CSS的属性继承,而不是完全的忽略所有外层CSS
#shadow复制代码Text
Text
#shadow
所以说,对于shadow-DOM
,CSS只是屏蔽了直接命中了内部元素的那一部分规则。
* { color: red; }
,这个规则肯定会生效的,因为*
代表了全部,实际上shadow-DOM
是从外层host
元素继承过来的color: red
,而不直接是命中自己的这条规则。 我们使用shadow-DOM
来修改上边的资料卡。
复制代码
P.S. 在 shadow-DOM 内部的 css,不会对外界所产生影响,所以使用 shadow-DOM 就可以肆意的对 class 进行命名而不用担心冲突了。
如果现在在一个页面中要展示多个用户的头像+姓名,我们可以将上边的代码进行封装,将 className
,appendChild
之类的操作放到一个函数中去,类似这样的结构:
function initShadow($host, { isOpen, avatar, name }) { let $shadow = $host.attachShadow({ mode: isOpen ? 'open' : 'closed' }); // ...省略各种操作 $avatar.src = avatar $name.innerHTML = name}initShadow(document.querySelector('#info1'), { avatar: 'https://avatars1.githubusercontent.com/u/9568094?v=4', name: 'Jarvis'});initShadow(document.querySelector('#info2'), { isOpen: true, avatar: 'https://avatars1.githubusercontent.com/u/9568094?v=4', name: 'Jarvis' })复制代码
这样就实现了一个简单的组件,可以在需要用到的地方,直接传入一个挂载的DOM
即可。
就像上边的shadow-DOM
,已经在文档树中看不到组件的细节了,任何代码也都不会影响到它的结构(open
模式下的获取root
操作除外)。
shadow-DOM
的根元素,这个根元素依然是一个普通的HTML
标签。 如果是一个大型页面中,存在了N多类似的组件,搜索一下,全是<div></div>
,这个体验其实是很痛苦的,基本是毫无语义化。 而且我们想要使用这个组件时,还必须额外的调用JavaScript
来获取DOM
元素生成对应的shadow-DOM
。 所以,我们可以尝试用custom-elements
来注册自己独有的标签。 简单的通过<my-tag>
的方式来调用自定义组件。 custom-elements支持同时支持普通标签的封装以及shadow-DOM的封装,但两者不能共存。
首先我们需要有一个继承了HTMLElement
的类。
class Info extends HTMLElement {}customElements.define( 'cus-info', // 标签名 Info // 标签对应的构造函数)复制代码
在调用define
时还有一个可选的第三个参数,用来设置自定义标签继承自某个原生标签。 两者在后续的标签使用上稍微有些区别:
复制代码
P.S. 自定义的标签的注册名至少要包含一个-
extends
,个人不建议使用,因为看起来会舒服一些 如果是针对普通的一组标签进行封装,就是解决了一些相同功能的组件需要在页面中粘来粘去的问题。
复制代码 native text
实现类似这样的效果:
P.S. 当一个元素激活了shadow-DOM以后,里边的普通子元素都会变得不可见,但是使用DOM API依然可以获取到
复制代码 native text
自定义标签并不只是一个让你多了一个标签可以用。
注册的自定义标签是有一些生命周期函数可以设置的,目前有效的事件为:connectedCallback
标签被添加到文档流中触发disconnectedCallback
标签被从文档流中移除时触发adoptedCallback
标签被移动时触发,现有的API貌似没有一个可以触发这个事件的,因为像appendChild
或者insertBefore
这一类的,对于已经存在的DOM元素都是先移除后新增的,所以不存在有直接移动的行为 attributeChangedCallback
增删改元素属性时会触发 需要提前设置observedAttributes,才能监听对应的属性变化 一个触发各种事件的简单示例:
复制代码
P.S. 如果需要处理DOM结构以及绑定事件,推荐在connectedCallback回调中执行 想要attributeChangedCallback
生效,必须设置observedAttributes
来返回该标签需要监听哪些属性的改变
接下来就是使用custome-elements
结合着shadow-DOM
来完成资料卡的一个简单封装。
shadow-DOM
版本的组件相对更独立一些,所以这里采用的是shadow-DOM
的方式进行封装。 大致代码如下: 复制代码
针对上边的initShadow
调用也只是更换了avatar
和name
字段的来源罢了。
HTML
中写对应的标签代码即可 因为是采用了注册html
标签的方式,其实这个是对采用Server
端模版渲染特别友好的一件事儿。
router.get('/', ctx => { ctx.body = ``})复制代码
在使用了custom-elements
以后,Server
端的记忆成本也会降低很多。
Server
端只需要表明这里有一个表单元素就够了,具体渲染成什么样,还是交由前端来决定。 router.get('/', ctx => { ctx.body = ``})复制代码
如果在页面中使用很多的自定义组件,必然会遇到组件之间的通讯问题的。
比如我一个按钮点击了以后如何触发其他组件的行为。 因为是纯原生的版本,所以天然的支持addEventListener
,我们可以直接使用custom-events
来完成组件之间的通讯。 使用自定义事件与原生DOM事件唯一的区别就在于需要自己构建Event
实例并触发事件:
document.body.addEventListener('ping', _ => console.log('pong')) // 设置事件监听document.body.dispatchEvent(new Event('ping')) // 触发事件复制代码
现在页面中有两个组件,一个容器,容器中包含一个文本框和数个按钮,点击按钮以后会将按钮对应的文字输出到文本框中:
复制代码
上边是在List中循环了自己的子节点,然后依次绑定事件,这种处理是低效的,而且是不灵活的。
如果有新增的子元素,则无法触发对应的事件。 所以,我们可以开启事件的冒泡来简化上边的代码:class CusList extends HTMLElement { connectedCallback() { let $output = this.querySelector('#output') this.addEventListener('check', event => { // 注册自定义事件的监听 $output.value = event.target.innerText // 效果一样,因为event.target就是触发dispatchEvent的那个DOM对象 }) }}class CusBtn extends HTMLElement { connectedCallback() { let { text } = this.dataset let $text = document.createElement('p') $text.innerHTML = text $text.addEventListener('click', _ => { this.dispatchEvent(new Event('check'), { bubbles: true // 启用事件冒泡 }) // 触发自定义事件 }) this.appendChild($text) }}复制代码
ES-module
是原生模块化的一种实现,使用ES-module
可以让我们上边组件的调用变得更方便。
ES-module
的文章: 所以,不再赘述一些module相关的基础,直接将封装好的组件代码挪到一个js文件中,然后在页面中引用对应的js文件完成调用。 module.js
export default class InfoCard extends HTMLElement { }customElements.define('info-card', InfoCard)复制代码
index.html
复制代码
第一眼看上去,这样做好像与普通的js脚本引入并没有什么区别。
确实单纯的写这一个组件的话,是没有什么区别的。但是一个现实中的页面,不会只有这么一个组件的,假设有这样的一个页面,其中包含了三个组件:
复制代码
我们在使用list
时要保证card
已经加载完成,在使用tab
时要保证list
已经加载完成。
webpack
打包就是这么做的。 但是,这样做带来的后果就是,明明list
和card
加载完毕后就可以处理自己的逻辑,注册自定义标签了,却还是要等外层的tab
加载完毕后再执行代码。 这个在使用webpack
打包的React
和Vue
这类框架上边就是很明显的问题,如果打包完的js文件过大,几百k,甚至数兆。 需要等到这个文件全部下载完毕后才会开始运行代码,构建页面。 我们完全可以利用下载其他组件时的空白期来执行当前组件的一些逻辑,而使用webpack
这类打包工具却不能做到,这很显然是一个时间上的浪费,而ES-module
已经帮忙处理了这件事儿,module
代码的执行是建立在所有的依赖全部加载完毕的基础上的。
当card
和list
加载完毕后,list
就会开始执行代码。而此时的tab
可能还在加载过程中,等到tab
加载完毕开始执行时,list
已经注册到了document上,就等着被调用了,从某种程度上打散了代码执行过于集中的问题。
举一个现实中的例子:
你开了一家饭店,雇佣了三个厨师,一个做番茄炒蛋、一个做皮蛋豆腐、还有一个做拍黄瓜,因为场地有限,所以三个厨师共用一套炊具。(单线程) 今天第一天开业,这时候来了客人点了这三样菜,但是菜还在路上。 webpack:「西红柿、鸡蛋、皮蛋、豆腐、黄瓜」全放到一块给你送过来,送到了以后,三个厨师轮着做,然后给客人端过去。 ES-module:分拨送,什么菜先送过来就先做哪个,哪个先做完给客人端哪个。
cus-elements-info-list.js
import InfoCard from './cus-elements-info-card.js'export default class InfoList extends HTMLElement { connectedCallback() { // load data let data = [ { avatar: 'https://avatars1.githubusercontent.com/u/9568094?v=4', name: 'Jarvis' }, { avatar: 'https://avatars1.githubusercontent.com/u/9568094?v=4', name: 'Jarvis' }, { avatar: 'https://avatars1.githubusercontent.com/u/9568094?v=4', name: 'Jarvis' } ] // laod data end initShadow(this, { data }) }}function initShadow($host, { data, isOpen }) { let $shadow = $host.attachShadow({ mode: isOpen ? 'open' : 'closed' }) let $style = document.createElement('style') let $wrap = document.createElement('div') $style.textContent = ` .list { display: flex; flex-direction: column; } ` $wrap.className = 'list' // loop create data.forEach(item => { let $item = new InfoCard() $item.setAttribute('avatar', item.avatar) $item.setAttribute('name', item.name) $wrap.appendChild($item) }) $shadow.appendChild($style) $shadow.appendChild($wrap)}customElements.define('info-list', InfoList)复制代码
复制代码
new Component与document.createElement效果一样,用于在不知道组件的注册名的情况下使用
shadow-DOM
无法与普通的子元素共存,设置attachShadow
以后会导致普通子元素在页面不可见,但是DOM依然保留custom-elements
的注册名必须要包含一个-
custom-elements
的constructor
函数触发时不能保证DOM
已经正确渲染完毕,对DOM进行的操作应该放到connectedCallback
中custom-elements
组件的属性变化监听需要提前配置observedAttributes
,没有通配符之类的操作ES-module
相关的操作只能在type="module"
中进行ES-module
的引用是共享的,即使十个文件都import
了同一个JS文件,他们拿到的都是同一个对象,不用担心浪费网络资源一个简单的TODO-LIST的实现:
浏览器原生支持的功能越来越丰富,ES-module
、custom-elements
、shadow-DOM
以及各种新鲜的玩意儿;
qsa
、fetch
,而不用考虑是否需要引入jQuery来帮助做兼容一样(大部分情况下)。