前言
Shadow DOM
(影子DOM)是 Web Components
主要技术组成之一,可以将一个隐藏的、独立的 DOM 附加到一个元素上[1],结合 Custom elements(自定义元素),可以创建一个与外界隔离的 Web 组件(以下简称为 Shadow DOM 组件),不用担心与页面的其他部分发生冲突,可复用性高。其基本概念与基本使用方式可以从 MDN 文档上获知,在此不再赘述。
本文主要探讨开发一个复杂 Shadow DOM 组件过程中的痛点,以及提供一种思路,用以降低编写 Shadow DOM 组件的复杂度。
开发痛点
沿用MDN上的 popup-info-box?,可以看出手撸一个 Shadow DOM 复杂度主要体现在以下几个方面:
1. 创建 Shadow DOM 结构
Shadow DOM 的创建是通过Element.attachShadow()
来挂载一个 Shadow DOM,并返回对 ShadowRoot 的引用。之后像操作普通 DOM 一样为 shadow root 节点添加子节点、设置属性等。创建 Shadow DOM 结构只需对该 ShadowRoot 对象进行 appendChild 操作:
// 创建 shadow root
var shadow = this.attachShadow({mode: 'open'});
// 创建 span
var wrapper = document.createElement('span');
wrapper.setAttribute('class','wrapper');
var icon = document.createElement('span');
icon.setAttribute('class','icon');
icon.setAttribute('tabindex', 0);
var info = document.createElement('span');
info.setAttribute('class','info');
// 获取属性的内容并将内容添加到 info 元素内
var text = this.getAttribute('text');
info.textContent = text;
// 插入 icon
var imgUrl;
if(this.hasAttribute('img')) {
imgUrl = this.getAttribute('img');
} else {
imgUrl = 'img/default.png';
}
var img = document.createElement('img');
img.src = imgUrl;
icon.appendChild(img);
// 将所创建的元素添加到 Shadow DOM 上
shadow.appendChild(wrapper);
wrapper.appendChild(icon);
wrapper.appendChild(info);
复制代码
像这样命令式地批量 appendChild 需编写大量代码,如果仅是静态 HTML 结构,另一种更佳的方式是创建一个根结点,只执行一次 appendChild 操作:
const template = document.createElement('template');
template.innerHTML = `
<span class="wrapper"></span>
<span class="icon" tabindex="0"></span>
<span class="info"></span>
`;
shadow.appendChild(template.content.cloneNode(true));
复制代码
借助 innerHTML 和模板字符串,可以声明式地描述 Shadow DOM 的结构,相较前者可阅读性更好。但由于其仅是字符串,编辑器不会为其语法高亮,当 DOM 结构变得复杂的时候,可维护性与可阅读性将会变差。
2. 为Shadow DOM添加样式
为 Shadow DOM 添加样式,可以创建<style>
元素,并加入一些样式:
// 为 shadow DOM 添加一些 CSS 样式
var style = document.createElement('style');
style.textContent = `
.wrapper {
position: relative;
}
.info {
font-size: 0.8rem;
width: 200px;
display: inline-block;
border: 1px solid black;
padding: 10px;
background: white;
border-radius: 10px;
opacity: 0;
transition: 0.6s all;
position: absolute;
bottom: 20px;
left: 10px;
z-index: 3;
}
img {
width: 1.2rem;
}
.icon:hover + .info, .icon:focus + .info {
opacity: 1;
}`;
复制代码
然后,依然是通过 appendChild 操作添加到 Shadow root 上:
shadow.appendChild(style);
复制代码
这与前面提到的创建 DOM 结构的方式几乎一模一样,在可维护性与可阅读性上得不到保证,并且不支持 CSS 预处理器,开发效率上大打折扣。
除了行内<style>
元素为 Shadow DOM 添加样式,也可以通过<link>
标签引用外部样式表来替代行内样式:
// 将外部引用的样式添加到 Shadow DOM 上
const linkElem = document.createElement('link');
linkElem.setAttribute('rel', 'stylesheet');
linkElem.setAttribute('href', 'style.css');
// 将所创建的元素添加到 Shadow DOM 上
shadow.appendChild(linkElem);
复制代码
但因为<link>
元素不会打断 shadow root 的绘制, 因此在加载样式表时可能会出现未添加样式内容(FOUC),导致闪烁[1]。引用外部样式在一定程度上也会导致 Shadow DOM 的表现依赖于外部,从而失去封装的意义,独立性也得不到体现。因此还是推荐使用行内<style>
样式。
解决方法
为了解决以上痛点,笔者决定采用 Webpack
来帮助构建Shadow DOM组件。总体思路是:采用传统的 Web 开发思维,将结构(HTML
)和表现(CSS
)分离,然后将其组装成我们需要的Shadow DOM组件。在正式改造前,先创建一个Webpack项目:
npm init
npm install --save-dev webpack
npm install --save-dev webpack-cli
复制代码
1. 使用html-loader分离Shadow DOM结构
html-loader是一个 Webpack 的Loader,可以将 HTML 导出为字符串,在入口文件中将其引入,赋值给 innerHTML 以此组成 Shadow DOM 结构。
首先,安装依赖:
npm install --save-dev html-loader
复制代码
接着在 Webpack 配置文件中配置该插件。因为 innerHTML 可以设置 HTML 语法表示的元素的后代,我们不需要为此创建完整的HTML结构,甚至是否有根结点也不是硬性要求。Webpack 配置如下:
// wepback.config.js
module.exports = {
mode: 'production',
entry: './src/index.js',
output: {
filename: 'popup-info.js'
},
module: {
rules: [
{
test: /\.html$/i,
loader: 'html-loader'
},
],
},
};
复制代码
指定 mode 为 production,html-loader 会在生产模式下对 HTML 进行压缩。在 src 目录下新建 index.js
和 template.html
文件,分别作为入口文件与 Shadow DOM 结构描述文件:
<!-- template.html -->
<span class="wrapper">
<span class="icon" tabindex="0"></span>
<span class="info"></span>
</span>
复制代码
// index.js
import html from './template.html';
const template = document.createElement('template');
template.innerHTML = html
class PopUpInfo extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(template.content.cloneNode(true));
}
}
customElements.define('popup-info', PopUpInfo);
复制代码
这样,就将结构分离了出来,并获得了编辑器语法高亮加持。在 package.json
中的 scripts
中添加打包脚本:
"build": "webpack --config webpack.config.js"
复制代码
在项目根目录下执行 npm run build
获得打包后的 popup-info.js
文件,在页面中引入:
<popup-info></popup-info>
<script src="./dist/popup-info.js"></script>
复制代码
检查 Element:
但这仅仅是创建了静态结构,对于一些动态行为,依然需要编写代码进行控制:
class PopUpInfo extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({ mode: 'open' });
// 获取属性的内容并将内容添加到 info 元素内
shadowRoot.appendChild(template.content.cloneNode(true));
const text = this.getAttribute('text');
shadowRoot.querySelector('.info').textContent = text;
// 插入 icon
let imgUrl;
if (this.hasAttribute('img')) {
imgUrl = this.getAttribute('img');
} else {
imgUrl = 'img/default.png';
}
const img = document.createElement('img');
img.src = imgUrl;
shadowRoot.querySelector('.icon').appendChild(img);
}
}
复制代码
修改 <popup-info>
元素:
<popup-info img="img/alt.png" text="Your card validation code (CVC) is an extra security feature — it is the last 3 or 4 numbers on the back of your card."></popup-info>
复制代码
再次检查 Element:
2. 分离样式,使用CSS预处理器
处理完结构,再看样式如何分离。从前所述,为 Shadow DOM 添加样式,需要创建 style 元素,并使用普通 CSS 文本填充,在这种模式下无法借助 CSS 预处理器,并且编辑器无法为其语法高亮。参考了文章:Web Components with Shadow DOM and Sass.[2],下面使用 Sass 来编写 Shadow DOM 组件样式。
首先安装 raw-loader 和 sass-loader:
npm install --save-dev raw-loader
npm install --save-dev sass-loader
npm install --save-dev node-sass
复制代码
修改 webpack.config.js,添加 rule:
// webpack.config.js
const path = require('path');
module.exports = {
mode: 'production',
entry: './src/index.js',
output: {
filename: 'popup-info.js',
},
module: {
rules: [{
test: /\.html$/i,
loader: 'html-loader',
options: {
minimize: true,
sources: false
}
},
{
test: /\.scss$/,
use: [
'raw-loader',
{
loader: 'sass-loader',
options: {
sassOptions: {
includePaths: [path.resolve(__dirname, 'node_modules')]
}
}
}
]
}
],
},
};
复制代码
接着就可以创建单独的样式文件 popup-info.scss
:
$img-width: 1.2rem;
.wrapper {
position: relative;
}
.info {
font-size: 0.8rem;
width: 200px;
display: inline-block;
border: 1px solid black;
padding: 10px;
background: white;
border-radius: 10px;
opacity: 0;
transition: 0.6s all;
position: absolute;
bottom: 20px;
left: 10px;
z-index: 3;
}
img {
width: $img-width;
}
.icon:hover+.info,
.icon:focus+.info {
opacity: 1;
}
复制代码
在入口文件中引入:
// index.js
import styleText from './popup-info.scss';
const style = document.createElement('style');
style.appendChild(document.createTextNode(styleText));
class PopUpInfo extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({ mode: 'open' });
shadowRoot.appendChild(style);
// ...
}
}
复制代码
再次检查 Element:
<style>
元素已添加上,并且只在 Shadow DOM 内部生效。至此,便完成了一个 Shadow DOM 组件的编写。
思考
上述流程的基本思想是将结构和表现分离,再组装起来,在一定程度上提高了组件的可维护性。但是对自定义元素属性的处理以及为 Shadow DOM 添加事件监听器依然需要在构造器中进行。并且由于分离了 DOM 结构和 CSS 样式,关注点也随之分离,开发过程中需要在不同文件来回切换。参考 Vue SFC,一个更为合理的组件结构应该如下所示:
<template>
...
</template>
<script>复制代码
以这样的结构编写 Shadow DOM 组件,便需要实现一个能够导出 Shadow DOM 组件的 Webpack Loader,以满足需求。等有精力的时候再研究下。???