likes
comments
collection
share

聊一聊WebComponent|原生JS自定义组件

作者站长头像
站长
· 阅读数 3

什么是WebComponent

首先 WebComponent 直译过来第一感觉是 web 组件的意思,实际上它是 HTML5 推出的一个新特征,在这个依赖前端框架的时代,更推崇组件化开发的思想来编写页面。

例如 Vue、React 等框架都基于组件化开发的形式,但它们的组件生态并不互通。要是脱离了 Vue、React 这样的前端框架,原生 JavaScript 就不能自定义组件吗?

WebComponent 就是为了解决这个问题的,WebComponent 是一套规则、一套 API。可以通过这些 API 创建自定义的组件,并且组件是可以重复使用的,封装好的组件可以在网页和 Web 应用程序中进行使用。

并非所有业务场景都需要 Vue、React 这样的框架进行开发,也并非都是工程化,很多业务场景也是依赖原生 JavaScript 和 HTML 的,那么 WebComponent 实现的组件也可以和原生 HTML 一起使用

 <h1>WebComponent组件化</h1>
 <!-- 自定义组件 -->
 <custom-component></custom-component>

上面看到 <h1> 标签是原生的 HTML 标签,但是 <custom-component> 标签就是自定义组件的标签了,它不属于 HTML 语义化标签中的任何一个, 最后自定义组件展示的结果如下:

聊一聊WebComponent|原生JS自定义组件

要了解 WebComponent,首先要从它的三个主要技术切入,其旨在解决创建封装功能的定制元素的问题,可以在任何地方重用,不必担心代码冲突。

  • Custom element(自定义元素)
  • Shadow DOM(影子 DOM)
  • HTML template(HTML 模板)

MDN文档: developer.mozilla.org/zh-CN/docs/…

Custom element

所谓自定义元素,即当内置元素无法为问题提供解决方案时,需要自己动手来创建一个自定义元素来解决,上方的 <custom-component> 就是手动创建的自定义元素。

 <!-- 自定义组件 -->
 <custom-component></custom-component>
 /* 组件样式 */
 .custom-component {
   display: inline-block;
   padding: 15px;
   border: 1px solid red;
   border-radius: 5px;
   color: blue;
 }
 // 创建自定义组件
 class CustomComponent extends HTMLElement {
   constructor() {
     super();
 ​
     this.init();
   }
 ​
   init() {
     const box = document.createElement('div');// 创建一个div元素
     box.className = 'custom-component'; // class样式名称
 ​
     const text = document.createElement('p'); // 创建一个p元素
     text.innerHTML = '这是一个自定义组件';     // 文本内容
 ​
     box.appendChild(text);
     this.appendChild(box); // 挂载到这个自定义组件中
   }
 }
 ​
 window.customElements.define('custom-component', CustomComponent);

效果如下:

聊一聊WebComponent|原生JS自定义组件

首先可以看出,自定义组件需要有个类的概念,并且必须继承自内置的 HTMLElement 类,然后定义类一些标记模版,并且执行 this.appendchild,其中 this 指向了当前类实例。

最后通过 window.customElements.define 方法,将自定义组件挂载到 customElements 上,并且需要给自定义组件起一个名字,上面例子的命名为 custom-component

自定义元素的其他相关 API: developer.mozilla.org/zh-CN/docs/…

命名规则

自定义元素命名是有规则的,规则如下:

  • 自定义元素的名称必须包含短横线(-)。它确保 html 解析器能够区分常规元素和自定义元素,还能确保 html 标记的兼容性。
  • 自定义元素只能一次定义一个,一旦定义无法撤回。
  • 自定义元素不能单标记封闭。比如 <custom-component/>,必须是一对开闭标记,比如 <custom-component></custom-component>

元素状态

元素的状态是指定义该元素(或者叫做升级该元素)时元素状态的改变,升级过程是异步的。元素内部的状态有如下几种:

  • undefined未升级: 即自定义元素还未被 define。
  • failed升级失败: 即 define 过了也实例化了,但失败了,会自动按 HTMLUnknownElement 类来实例化。
  • uncustomized未定制化: 没有 define 过但却被实例化了,会自动按 HTMLUnknownElement 类来实例化。
  • custom升级成功: define 过并且实例化成功了。

到这里,是不是有种 Promise 的感觉了,元素的状态和 Promise 的三种状态及其相似。

css伪类

与自定义元素特别相关的伪类:

  • :defined 匹配任何已定义的元素,包括内置元素和使用 window.customElements.define() 定义的自定义元素。
 :defined {
   font-weight: bold;
   font-size: 30px;
 }

聊一聊WebComponent|原生JS自定义组件

调用方式

除了以标签的形式使用自定义元素,还可以通过 document 的 API 或者 new 构造函数进行调用

 // createElement形式
 const customComponent = document.createElement ('custom-component');
 document.body.appendChild(customComponent);
 // new 构造函数形式
 const customComponent = new customComponent()
 document.body.appendchild(customComponent)

思考问题: 如果 dom 结构很复杂的组件怎么办呢?

目前 DOM 结构比较简单,所以通过 document.createElementappendchild 等方法进行构建还不算复杂。如果 DOM 结果很复杂,一顿使用 createElement 也不是办法,这就要引入 <template> 标记了。

HTML template

WebComponent 的 API 提供了 <template> 标签,可以在它里面使用 HTML 定义 DOM 结构,现在对刚刚的例子进行改造,重新创建一下我们的自定义组件。

 <custom-component></custom-component>
 ​
 <template id="custom-id">
   <div class="custom-component">
     <p>这是一个自定义组件</p>
   </div>
 </template>
 class CustomComponent extends HTMLElement {
   constructor() {
     super();
     this.init();
   }
 ​
   init() {
     const template = document.getElementById('custom-id');
     const content = template.content.cloneNode(true); // 克隆一份
     this.appendChild(content);
   }
 }

改成 template 形式后,是不是更符合我们的开发模式了,这里有两个点需要注意:

  • 这里可以用脚本把 <template> 注入网页,这样 JavaScript 脚本跟 <template> 就能封装成一个 js 文件,成为独立的组件文件。网页只要加载这个脚本,就能使用 <custom-component> 组件。
  • <template> 标签内的节点必须通过 template.content.cloneNode 克隆返回的节点来操作。因为这里获取的 template 不是一个正常的 DOM 结构,template.content 得到的结果是 DocumentFragment 节点。并且该模板还要留给其他实例使用,所以不能直接移动其子元素。

聊一聊WebComponent|原生JS自定义组件

props传递

在 Vue 和 React 中使用组件时,经常会涉及到 props 的传递,那么自定义元素也可以接收 props 的传递:

 <custom-component></custom-component>
 <custom-component text="显示这个文本"></custom-component>

这里传入自定义的文本 text,如果有传入 text 内容就展示 text,没有则展示默认值

 class CustomComponent extends HTMLElement {
   constructor() {
     super();
     this.init();
   }
 ​
   init() {
     const template = document.getElementById('custom-id');
     const content = template.content.cloneNode(true);
     // 新增代码
     const textValue = this.getAttribute('text');
     if (textValue) {
       content.querySelector('.custom-component>p').innerHTML = textValue;
     }
     this.appendChild(content);
   }
 }

聊一聊WebComponent|原生JS自定义组件

插槽

我们平常使用组件的时候是可以嵌套的,所以不仅仅需要注意参数注入的形式,还需要兼容嵌套的 children 形式,继续修改自定义组件。

WebComponent 有一个 slot 概念 -- 插槽,它提供了一个"缺口"让给需要嵌套的 DOM 结构,其用法和 Vue 是比较相似的,例如:

 <!-- 使用插槽 -->
 <custom-component>
   <p slot="my-slot">这是插槽的内容</p>
 </custom-component>
 <!-- 不使用插槽 -->
 <custom-component text="显示这个文本"></custom-component>
 ​
 <template id="custom-id">
   <div class="custom-component">
     <p>这是一个自定义组件</p>
     <slot name="my-slot">插槽的默认内容</slot>
   </div>
 </template>
 class CustomComponent extends HTMLElement {
   constructor() {
     super();
     this.init();
   }
 ​
   init() {
 ​
     const template = document.getElementById('custom-id');
     const content = template.content.cloneNode(true);
     const textValue = this.getAttribute('text');
     if (textValue) {
       content.querySelector('.custom-component>p').innerHTML = textValue;
     }
     // 新增代码
     const shadow = this.attachShadow({ mode: 'closed' });
     // 将template的内容挂载到shadow中
     shadow.appendChild(content);
   }
 }

聊一聊WebComponent|原生JS自定义组件

此时插槽生效了,但是有没有注意到,css 样式却失效了,并且在自定义组件内部多了一个 #shadow-root(closed) 的内容,这点与后面讲到的 shadow DOM 有关。

这是因为使用了 shadow DOM 技术,这和 Vue 组件化开发的 css scoped 也是很相似的,组件外的样式无法影响组件内部样式,所以 css 样式失效了。

现在把 style 标签中样式写到 <template> 内部,样式只对自定义元素内的 DOM 结构生效,并且不会影响外部有同样 class 名称的标签。

 <p class="custom-component">对外部元素不生效</p>
 ​
 <template id="custom-id">
   <style>
     .custom-component {
       display: inline-block;
       padding: 15px;
       border: 1px solid red;
       border-radius: 5px;
       color: blue;
     }
     :defined {
       font-weight: bold;
       font-size: 30px;
     }
   </style>
   <div class="custom-component">
     <p>这是一个自定义组件</p>
     <slot name="my-slot">插槽的默认内容</slot>
   </div>
 </template>

聊一聊WebComponent|原生JS自定义组件

思考:如果没有使用shadow DOM,直接将内容挂载到 CustomComponent 中,样式会影响外部元素吗?

此时样式还是写在 <template> 标签内部的,但内容是直接使用 this 挂载,这样自定义元素内的样式是否会影响外部有同样 class 名称的标签呢?

聊一聊WebComponent|原生JS自定义组件

事件

有了参数之后就不能少了事件 Event,现在给 <p> 标签添加一个文本的点击事件,继续对自定义元素来改造升级。

 class CustomComponent extends HTMLElement {
   constructor() {
     super();
     this.init();
   }
 ​
   init() {
     const template = document.getElementById('custom-id');
     const content = template.content.cloneNode(true);
     const textValue = this.getAttribute('text');
     // 获取文本节点
     const textDom = content.querySelector('.custom-component>p');
     if (textValue) {
       textDom.innerHTML = textValue;
     }
     // 绑定事件
     textDom.addEventListener('click', function () {
       alert('Hello WebComponent');
     });
     const shadow = this.attachShadow({ mode: 'closed' });
     shadow.appendChild(content);
   }
 }

聊一聊WebComponent|原生JS自定义组件

Shadow DOM

Shadow DOM 允许将隐藏的 DOM 树附加到常规的 DOM 树中 ---- 它以 shadow root 节点为起始根节点,在这个根节点的下方,可以是任意元素,和普通的DOM元素一样。

聊一聊WebComponent|原生JS自定义组件

把本来 DOM 树中的一部分封装并隐藏起来,隐藏起来的树概念为 Shadow Tree。把它理解成 DOM 上一棵特殊的子树,称之为 shadow tree 或影子树。它是一种特殊的树,树里面也是 DOM,就像我们上面用document.createElement 创建的 DOM 一样。

  • 影子宿主(Shadow host) : 影子 DOM 附加到的常规 DOM 节点。
  • 影子树(Shadow tree) : 影子 DOM 内部的 DOM 树。
  • 影子边界(Shadow boundary) : 影子 DOM 终止,常规 DOM 开始的地方。
  • 影子根(Shadow root) : 影子树的根节点。

在目前的自定义元素中,里面的结构已经变成了 Shadow DOM,顺带说下 attachShadow 中的 mode 参数有两种值“open"、"closed";

聊一聊WebComponent|原生JS自定义组件

 <custom-component id="component-id"></custom-component>
  • open: 表示可以通过页面内的 JavaScript 方法来获取 Shadow DOM,如使用 Element.shadowRoot 属性;
 const myShadowDom = document.getElementById('component-id');
 const shadowRoot = myShadowDom.shadowRoot; // shadow root
  • closed: 不可以从外部获取 shadow DOM ,Element.shadowRoot 将会返回 null
 const myShadowDom = document.getElementById('component-id');
 const shadowRoot = myShadowDom.shadowRoot; // null

Shadow DOM 的概念在 HTML 中非常常见,例如 HTML 中内置的 <video> 标签,使用该标签渲染一个视频时,会发现页面中除了会呈现一个完整的播放器之外,里面还有播放视频的进度条、播放按钮、音量调节等。

明明只有一个标签,为什么内部有如此丰富的内容呢?

 <video
   src="http://maoyan.meituan.net/movie/videos/854x4804c109134879943f4b24387adc040504b.mp4"
   controls
   width="500"
 >
 </video>

聊一聊WebComponent|原生JS自定义组件

这时候需要打开控制台的【设置】,勾选上【显示用户代理Shadow DOM】

聊一聊WebComponent|原生JS自定义组件

然后就可以看到隐藏在 <video> 标签中的shadow root了,其播放控件正是隐藏在 shadow root 内部

聊一聊WebComponent|原生JS自定义组件

因此像 img、button、input、textarea、select、 radio、 checkbox,video 等等这些标签是不可以作为宿主的,因为它们本身内部就已经有shadow DOM了。也就是说上述的替换元素不可以作为根节点,即使强行往标签中插入dom 结构,它也会挂载到 body 中。

 <input type="text">123</input>

聊一聊WebComponent|原生JS自定义组件

Exparser框架原理

说到这里,开发过小程序的jym是不是觉得很熟悉,在小程序自定义一个组件或者使用某些内置组件时,结构是不是和使用 WebComponent 技术自定义的元素高度相似。

聊一聊WebComponent|原生JS自定义组件

没错,这正是微信小程序中的 Exparser 设计原理,Exparser 是微信小程序的组件组织框架,内置在小程序基础库中,为小程序提供各种各样的组件支撑。内置组件和自定义组件都有 Exparser 组织管理。

Exparser 参考: developers.weixin.qq.com/ebook?actio…