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

什么是WebComponent

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

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

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

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

html 复制代码
 <h1>WebComponent组件化</h1>
 <!-- 自定义组件 -->
 <custom-component></custom-component>

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

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

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

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

Custom element

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

html 复制代码
 <!-- 自定义组件 -->
 <custom-component></custom-component>
css 复制代码
 /* 组件样式 */
 .custom-component {
   display: inline-block;
   padding: 15px;
   border: 1px solid red;
   border-radius: 5px;
   color: blue;
 }
js 复制代码
 // 创建自定义组件
 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);

效果如下:

首先可以看出,自定义组件需要有个类的概念,并且必须继承自内置的 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() 定义的自定义元素。
css 复制代码
 :defined {
   font-weight: bold;
   font-size: 30px;
 }

调用方式

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

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

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

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

HTML template

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

html 复制代码
 <custom-component></custom-component>
 ​
 <template id="custom-id">
   <div class="custom-component">
     <p>这是一个自定义组件</p>
   </div>
 </template>
js 复制代码
 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 节点。并且该模板还要留给其他实例使用,所以不能直接移动其子元素。

props传递

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

html 复制代码
 <custom-component></custom-component>
 <custom-component text="显示这个文本"></custom-component>

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

js 复制代码
 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);
   }
 }

插槽

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

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

html 复制代码
 <!-- 使用插槽 -->
 <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>
js 复制代码
 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);
   }
 }

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

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

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

html 复制代码
 <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>

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

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

事件

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

js 复制代码
 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);
   }
 }

Shadow DOM

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

把本来 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";

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

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

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

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

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

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

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

html 复制代码
 <input type="text">123</input>

Exparser框架原理

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

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

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

相关推荐
xiao-xiang4 分钟前
jenkins-通过api获取所有job及最新build信息
前端·servlet·jenkins
C语言魔术师20 分钟前
【小游戏篇】三子棋游戏
前端·算法·游戏
匹马夕阳2 小时前
Vue 3中导航守卫(Navigation Guard)结合Axios实现token认证机制
前端·javascript·vue.js
你熬夜了吗?2 小时前
日历热力图,月度数据可视化图表(日活跃图、格子图)vue组件
前端·vue.js·信息可视化
桂月二二8 小时前
探索前端开发中的 Web Vitals —— 提升用户体验的关键技术
前端·ux
hunter2062069 小时前
ubuntu向一个pc主机通过web发送数据,pc端通过工具直接查看收到的数据
linux·前端·ubuntu
qzhqbb9 小时前
web服务器 网站部署的架构
服务器·前端·架构
刻刻帝的海角9 小时前
CSS 颜色
前端·css
九酒9 小时前
从UI稿到代码优化,看Trae AI 编辑器如何帮助开发者提效
前端·trae
浪浪山小白兔10 小时前
HTML5 新表单属性详解
前端·html·html5