使用 Webpack ? 构建 Shadow DOM 组件

前言

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.jstemplate.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:

截屏2021-06-21 上午12.26.38.png
但这仅仅是创建了静态结构,对于一些动态行为,依然需要编写代码进行控制:

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:

截屏2021-06-21 上午12.30.18.png

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:

截屏2021-06-21 上午12.32.44.png
<style>元素已添加上,并且只在 Shadow DOM 内部生效。至此,便完成了一个 Shadow DOM 组件的编写。

思考

上述流程的基本思想是将结构和表现分离,再组装起来,在一定程度上提高了组件的可维护性。但是对自定义元素属性的处理以及为 Shadow DOM 添加事件监听器依然需要在构造器中进行。并且由于分离了 DOM 结构和 CSS 样式,关注点也随之分离,开发过程中需要在不同文件来回切换。参考 Vue SFC,一个更为合理的组件结构应该如下所示:

<template>
...
</template>

<script>
...
<script>

<style>
...
</style>
复制代码

以这样的结构编写 Shadow DOM 组件,便需要实现一个能够导出 Shadow DOM 组件的 Webpack Loader,以满足需求。等有精力的时候再研究下。??‍?

参考文献

[1] Using shadow DOM – Web Components | MDN

[2] Web Components with Shadow DOM and Sass.

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享