web components的跨框架(React&Vue)实践

144 阅读8分钟

本文中React和Vue框架对应的环境:

vue:vue+vite

react:react+vite

1 Web components介绍

Web components是一组 Web 平台 API,可让开发者创建新的自定义、可重复使用、封装的 HTML 标签,以用于网页和 Web 应用。Web components可在现代浏览器上运行,并可与任何支持 HTML 的 JavaScript 库或框架一起使用。相比第三方框架,原生组件简单直接,符合直觉,不用加载任何外部模块,代码量小。目前,它还在不断发展,但已经可用于生产环境。

Web components由三项主要技术组成,它们可以一起使用来创建封装功能的定制元素,可你可以在任意地方复用,不必担心代码冲突。

1.1Custom Elements(自定义元素)

一组 JavaScript API,允许你定义 custom elements 及其行为,然后可以在你的用户界面中按照需要使用它们。

有两种类型的自定义元素:

  • 自定义内置元素(Customized built-in element)继承自标准的 HTML 元素,例如 HTMLImageElementHTMLParagraphElement。它们的实现定义了标准元素的行为。
  • 独立自定义元素(Autonomous custom element)继承自 HTML 元素基类 HTMLElement。你必须从头开始实现它们的行为。

我们开发的一般是独立自定义元素,必须继承父类HTMLElement

class CustomInfo extends HTMLElement {
  constructor() {
    super();
  }
  ...
}

组件定义后,需要使用浏览器原生的customElements.define()方法注册组件才可使用。自定义元素的名称必须包含连词线,用与区别原生的 HTML 元素

customElements.define("custom-info", CustomInfo);

使用:

<custom-info></custom-info>

示例:

class CustomInfo extends HTMLElement {
  constructor() {
    super();

    var container = document.createElement("div");
    container.classList.add("container");

    var name = document.createElement("h1");
    name.classList.add("title");
    name.innerText = "web components";

    var email = document.createElement("p");
    email.classList.add("info");
    email.innerText =
      "Web components是一组 Web 平台 API,可让您创建新的自定义、可重复使用、封装的 HTML 标签,以用于网页和 Web 应用";

    var button = document.createElement("button");
    button.classList.add("button");
    button.innerText = "click me";

    container.append(name, email, button);
    this.append(container);
  }
}

customElements.define("custom-info", CustomInfo);
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script type="module" src="./test.js"></script>
</head>

<body>
  <custom-info></custom-info>
</body>

</html>

1.2Shadow DOM

介绍

一组 JavaScript API,用于将封装的shadow DOM 树附加到元素(与主文档 DOM 分开呈现)并控制其关联的功能。通过这种方式,你可以保持元素的功能私有,这样它们就可以被脚本化和样式化,而不用担心与文档的其他部分发生冲突。

影子 DOM 允许将隐藏的 DOM 树附加到常规 DOM 树中的元素上——这个影子 DOM 始于一个影子根,在其之下你可以用与普通 DOM 相同的方式附加任何元素。

有一些影子 DOM 术语需要注意:

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

<body>
  <div id="host"></div>
  <span>I'm not in the shadow DOM</span>
  <br />

  <button id="upper" type="button">将 span 元素转换为大写</button>
  <button id="reload" type="button">重新加载</button>
  <script>
  const host = document.querySelector("#host");
const shadow = host.attachShadow({ mode: "open" }); //{mode: "open"} 参数为页面提供一种破坏影子 DOM 封装的方法
const span = document.createElement("span");
span.textContent = "I'm in the shadow DOM";
shadow.appendChild(span);

const upper = document.querySelector("button#upper");
upper.addEventListener("click", () => {
  const spans = Array.from(document.querySelectorAll("span"));
  for (const span of spans) {
    span.textContent = span.textContent.toUpperCase();
  }
});

const reload = document.querySelector("#reload");
reload.addEventListener("click", () => document.location.reload());

</script>
  <style>
  span {
  color: blue;
  border: 1px solid black;
}
</style>
  </body>

在示例中可以看到document.querySelectorAll无法获取影子DOM中的元素,样式表也无法影响到影子DOM,影子DOM与我们的DOM树是隔绝开的。

当然,web components也提供了访问影子DOM 的方法。

使用 {mode: "open"} 参数为页面提供一种破坏影子 DOM 封装的方法。如果你不希望给页面这个能力,传递 {mode: "closed"} 作为替代,此时 shadowRoot 返回 null

示例(点击按钮操作影子DOM中的内容):

const shadow = host.attachShadow({ mode: "open" }); //{mode: "open"} 参数为页面提供一种破坏影子 DOM 封装的方法
    const span = document.createElement("span");
    span.textContent = "I'm in the shadow DOM";
    shadow.appendChild(span);

    const upper = document.querySelector("button#upper");
    upper.addEventListener("click", () => {
      const spans = Array.from(host.shadowRoot.querySelectorAll("span")); // 通过 host.shadowRoot 获取 虚拟DOM
      for (const span of spans) {
        span.textContent = span.textContent.toUpperCase();
      }
    });

在上面的例子中,我们将一个参数 { mode: "open" } 传入 attachShadow()。当 mode 设置为 "open" 时,页面中的 JavaScript 可以通过影子宿主的 shadowRoot 属性访问影子 DOM 的内部。

在影子 DOM 内应用样式

在这个部分,我们将看到两种不同的方法来在影子 DOM 树中应用样式:

  • 编程式,通过构建一个 CSSStyleSheet 对象并将其附加到影子根。
  • 声明式,通过在一个<template> 元素的声明中添加一个 <style>元素。
构造样式表

const sheet = new CSSStyleSheet();
sheet.replaceSync("span { color: red; border: 2px dotted black;}");

const host = document.querySelector("#host");

const shadow = host.attachShadow({ mode: "open" });
shadow.adoptedStyleSheets = [sheet];

const span = document.createElement("span");
span.textContent = "I'm in the shadow DOM";
shadow.appendChild(span);
<template>声明中添加<style>元素
<template id="my-element">
  <style>
    span {
      color: red;
      border: 2px dotted black;
    }
  </style>
  <span>I'm in the shadow DOM</span>
</template>

<div id="host"></div>
<span>I'm not in the shadow DOM</span>


const host = document.querySelector("#host");
const shadow = host.attachShadow({ mode: "open" });
const template = document.getElementById("my-element");

shadow.appendChild(template.content);

1.3HTML Template

Web Components API 提供了 <template> <slot> 定义一个 HTML 模板,它们可以作为自定义元素结构的基础被多次重用。

<template>的方式减少了繁琐的js创建元素的代码,也可以直接在<template>标签中写样式,代码更加简洁。

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>

<body>
  <hi-wc></hi-wc>
  <template id="hi-wc">
    <style>
      h1 {
        color: red;
      }
    </style>
    <h1>Hi, I am a web component</h1>
  </template>
  <script>
    class HiWc extends HTMLElement {
      constructor() {
        super();

        var templateElem = document.getElementById('hi-wc');
        var content = templateElem.content.cloneNode(true);
        this.appendChild(content);
      }
    }
    window.customElements.define('hi-wc', HiWc);    
  </script>
</body>

</html>

2 将react组件构建成web component

2.1 改造方式

可以使用三方插件:react-to-webcomponent

通过影子dom方式创建web components

创建

class XSearch extends HTMLElement {
  connectedCallback() {
    const mountPoint = document.createElement('span');
    this.attachShadow({ mode: 'open' }).appendChild(mountPoint);

    const name = this.getAttribute('name');
    const url = 'https://www.google.com/search?q=' + encodeURIComponent(name);
    const root = ReactDOM.createRoot(mountPoint);
    root.render(<a href={url}>{name}</a>);
  }
}
customElements.define('x-search', XSearch);

使用

class HelloMessage extends React.Component {
  render() {
    return <div>Hello <x-search name={this.props.name}></x-search>!</div>;
  }
}

自定义元素创建web components

示例:

1、新建一个react组件

const WCTest = () => {
  const handleClick = () => {
    console.log('click')
  }
  return <>
    <div>WC Test</div>
    <button onClick={handleClick}>按钮一个</button>
  </>
}
export default WCTest;

2、将构建成web component

主要思路:

  • 自定义元素继承自HTMLElement
  • 在connectedCallback钩子中渲染元素
  • 通过ReactDOM.render将组件渲染到当前的自定义元素
  • 通过window.customElements.define注册自定义元素
import React from "react";
import ReactDOM from "react-dom";
import WCTest from "./WCTest";

class VosElement extends HTMLElement {
  constructor() {
    super();
    // 可以在这里初始化一些默认值
    this.option = "";
  }

  // 定义属性和其变化的观察器
  static get observedAttributes() {
    return ["option"];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    console.log({ name, oldValue, newValue });
    if (name === "option") {
      this.option = newValue;
      this.render();
    }
  }

  connectedCallback() {
    this.render();
  }

  render() {
    let option;
    try {
      option = JSON.parse(this.option);
    } catch (error) {
      console.error("Error parsing option:", error);
      option = null;
    }
    console.log({ option });

    ReactDOM.render(
      <React.StrictMode>
        <WCTest />
      </React.StrictMode>,
      this // render to the current custom element
    );
  }
}

const tagName = "wc-test";

if (!window.customElements.get(tagName)) {
  window.customElements.define(tagName, VosElement);
}

2.2 使用方式

在当前项目中使用web component

import './webcomponent'; //引入注册web components的代码,多个地方使用的可以在主文件中全局引入

const HelloMessage = (props: { name: string }) => {
  return <>
    <wc-test></wc-test>
  </>
}
export default HelloMessage;

2.3 打包为SDK

使用 Vite 将 React 组件打包成 SDK 库文件,以便在其他环境中使用,是一种很好的方式来创建可复用的组件库。

vite插件vite-plugin-css-injected-by-js把组件的css打包到js中

// npm
npm i vite-plugin-css-injected-by-js -D
// pnpm
pnpm add vite-plugin-css-injected-by-js -D

配置vite.config.ts

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react(),
             cssInjectedByJsPlugin(),
           ],
  define: { "process.env.NODE_ENV": '"production"' }, 
  build: {
    lib: {
      entry: "src/webcomponent.tsx", // 入口文件
      name: "MyWebcomponent", // 库的名字
      fileName: (format) => `my-webcomponent.${format}.js`, // 输出文件名
    },
    rollupOptions: {
      // 确保外部化那些你不想打包进库的依赖
      // external: ["react", "react-dom"],
      output: {
        globals: {
          react: "React",
          "react-dom": "ReactDOM",
        },
      },
    },
  },
});

打包

npm run build

默认的打包后的文件有两个,他们分别是ESM格式和UMD格式,不同的打包方式会影响如何引入和使用你的库。

你可以指定 format 选项来生成不同的输出格式,比如 UMD、ESM、CJS。如果你希望提供多个格式,可以在 rollupOptions.output 中进行设置。

例如,你可以这样配置生成多个格式的输出:

rollupOptions: {  
    output: [  
        {  
            format: 'es',  
            file: 'dist/my-component.esm.js',  
            globals: {  
                react: 'React',  
                'react-dom': 'ReactDOM',  
            },  
        },  
        {  
            format: 'umd',  
            file: 'dist/my-component.umd.js',  
            name: 'MyComponent',  
            globals: {  
                react: 'React',  
                'react-dom': 'ReactDOM',  
            },  
        },  
    ],  
}

测试一下打包后的sdk

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script type="module" src="./my-webcomponent.es.js"></script>
</head>

<body>
  <wc-test></wc-test>
</body>

</html>

2.4 发布

(TODO: rollup打包,发布npm或者其他更优雅的公开组件管理...)

将打包后的 SDK 文件发布到 npm 等地方,以便其他项目使用。

注意事项

  • 版本管理: 在发布 SDK 前,确保你已经处理好版本名称,避免与其他库冲突。
  • 外部依赖: 确保在打包时不会将外部依赖(如 reactvue)打包到库中,而是让使用者自行安装。
  • Documentation: 为你的 SDK 编写详细的文档,包括如何安装、使用和示例代码等。

3 将Vue组件构建成web component

本示例中是vite相关的解决方案,其他方案可参考vue官方文档vue 与 web components

3.1跳过组件解析

默认情况下,Vue 会将任何非原生的 HTML 标签优先当作 Vue 组件处理,而将“渲染一个自定义元素”作为后备选项。这会在开发时导致 Vue 抛出一个“解析组件失败”的警告。要让 Vue 知晓特定元素应该被视为自定义元素并跳过组件解析,我们可以指定 compilerOptions.isCustomElement这个选项

vite示例配置:

// vite.config.js
import vue from '@vitejs/plugin-vue'

export default {
  plugins: [
    vue({
      template: {
        compilerOptions: {
          // 将所有带短横线的标签名都视为自定义元素
          isCustomElement: (tag) => tag.includes('-')
        }
      }
    })
  ]
}

3.2改造方式

将vue组件改造为web component更简单,Vue 提供了一个和定义一般 Vue 组件几乎完全一致的 defineCustomElement 方法来支持创建自定义元素。这个方法接收的参数和 defineComponent 完全相同。但它会返回一个继承自 HTMLElement 的自定义元素构造器。

使用 Vue 构建自定义元素

<my-vue-element></my-vue-element>
import { defineCustomElement } from 'vue'

const MyVueElement = defineCustomElement({
  // 这里是同平常一样的 Vue 组件选项
  props: {},
  emits: {},
  template: `...`,

  // defineCustomElement 特有的:注入进 shadow root 的 CSS
  styles: [`/* inlined css */`]
})

// 注册自定义元素
// 注册之后,所有此页面中的 `<my-vue-element>` 标签
// 都会被升级
customElements.define('my-vue-element', MyVueElement)

// 你也可以编程式地实例化元素:
// (必须在注册之后)
document.body.appendChild(
  new MyVueElement({
    // 初始化 props(可选)
  })
)

生命周期

  • 当该元素的 connectedCallback 初次调用时,一个 Vue 自定义元素会在内部挂载一个 Vue 组件实例到它的 shadow root 上。
  • 当此元素的 disconnectedCallback 被调用时,Vue 会在一个微任务后检查元素是否还留在文档中。
    • 如果元素仍然在文档中,那么说明它是一次移动操作,组件实例将被保留;
    • 如果该元素不再存在于文档中,那么说明这是一次移除操作,组件实例将被销毁。

将单文件组件编译为自定义元素

defineCustomElement 也可以搭配 Vue 单文件组件 (SFC) 使用。官方的单文件组件工具链支持以“自定义元素模式”导入单文件组件 (需要 @vitejs/plugin-vue@^1.4.0vue-loader@^16.5.0)。

要开启这个模式,只需要将你的组件文件以 .ce.vue 结尾即可:

import { defineCustomElement } from 'vue'
import Example from './Example.ce.vue'

console.log(Example.styles) // ["/* 内联 css */"]

// 转换为自定义元素构造器
const ExampleElement = defineCustomElement(Example)

// 注册
customElements.define('my-example', ExampleElement)

3.3 使用

在当前项目中使用

<script setup lang="ts">
import './test'
</script>

<template>
  <my-example></my-example>
</template>

建议按元素分别导出构造函数,以便用户可以灵活地按需导入它们,并使用期望的标签名称注册它们。

import { defineCustomElement } from 'vue'
import Foo from './MyFoo.ce.vue'
import Bar from './MyBar.ce.vue'

const MyFoo = defineCustomElement(Foo)
const MyBar = defineCustomElement(Bar)

// 分别导出元素
export { MyFoo, MyBar }

export function register() {
  customElements.define('my-foo', MyFoo)
  customElements.define('my-bar', MyBar)
}

3.4 打包为SDK

使用 Vite 将 vue 组件打包成 SDK 库文件,以便在其他环境中使用,是一种很好的方式来创建可复用的组件库。

vite插件vite-plugin-css-injected-by-js把组件的css打包到js中

// npm
npm i vite-plugin-css-injected-by-js -D
// pnpm
pnpm add vite-plugin-css-injected-by-js -D

配置vite.config.ts

import { defineConfig } from "vite";
import vue from '@vitejs/plugin-vue'
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue(),
           cssInjectedByJsPlugin()
           ],
  define: { "process.env.NODE_ENV": '"production"' }, 
  build: {
    lib: {
      entry: "src/webcomponent.tsx", // 入口文件
      name: "MyWebcomponent", // 库的名字
      fileName: (format) => `my-webcomponent.${format}.js`, // 输出文件名
    },
    rollupOptions: {
      // external: ['vue'], // 确保外部化 Vue  
      output: {  
          globals: {  
              vue: 'Vue',  
          },  
      },
    },
  },
});

打包

npm run build

打包后文件:

测试一下打包后的文件:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script type="module" src="./my-example.es.js"></script>
</head>

<body>
  <my-example></my-example>
</body>

</html>

4 注意事项

1、环境变量

打包后的js文件没有环境变量process.env.NODE_ENV,在使用时可能会报错找不到环境变量process.env.NODE_ENV

解决方法:打包前先定义一个环境变量

define: { "process.env.NODE_ENV": '"production"' }

2、选择是否打包依赖库

在使用打包后的SDK,如果使用的环境中包含依赖库react和react-dom(或者是vue),rollup打包可以配置 external,不打包依赖库,这里打包出来的文件体积要小很多。

但是如果想要在任意环境中使用这个SDK,打包时需要把所有依赖库都打包到SDK中。

 rollupOptions: {  
            // 确保外部化那些你不想打包进库的依赖  
            external: ['react', 'react-dom'],  
            output: {  
                globals: {  
                    react: 'React',  
                    'react-dom': 'ReactDOM',  
                },  
            },  
        },                                    

3、项目打包和组件SDK打包脚本分离

我们的项目需要打包后发布,直接修改vite.config.ts,默认的build脚本就会执行组件SDK的打包,而不是项目的打包。

为了满足我们的打包需求,可以再新加一个web component组件的打包脚本和其他对应的vite配置

1、新增一个vite.webcomponent.config.ts

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  define: { "process.env.NODE_ENV": '"production"' },
  build: {
    lib: {
      entry: "src/webcomponent.tsx", // 入口文件
      name: "MyWebcomponent", // 库的名字
      fileName: (format) => `my-webcomponent.${format}.js`, // 输出文件名
    },
    rollupOptions: {
      // 确保外部化那些你不想打包进库的依赖
      // external: ["react", "react-dom"],
      output: {
        globals: {
          react: "React",
          "react-dom": "ReactDOM",
        },
      },
    },
  },
});

2、修改打包脚本

  "scripts": {
    ...
    "build:webcomponent": "tsc  && vite build --config vite.webcomponent.config.ts",
    ...
  },

4、Web Components 和 TypeScript

Vue

自定义元素是使用原生 API 全局注册的,所以默认情况下,当在 Vue 模板中使用时,它们不会有类型推断。为了给注册为自定义元素的 Vue 组件提供类型支持,我们可以通过 Vue 模板和/或 JSX 中的 GlobalComponents接口来注册全局组件的类型:

vue添加全局声明

import { defineCustomElement } from 'vue'

// vue 单文件组件
import CounterSFC from './src/components/counter.ce.vue'

// 将组件转换为 web components
export const Counter = defineCustomElement(CounterSFC)

// 注册全局类型
declare module 'vue' {
  export interface GlobalComponents {
    Counter: typeof Counter
  }
}

Reat

react添加全局声明

declare global {
  namespace JSX {
    interface IntrinsicElements {
      'wc-test': any;
    }
  }
}