本文中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 元素,例如 HTMLImageElement 或 HTMLParagraphElement。它们的实现定义了标准元素的行为。
- 独立自定义元素(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 前,确保你已经处理好版本名称,避免与其他库冲突。
- 外部依赖: 确保在打包时不会将外部依赖(如
react
或vue
)打包到库中,而是让使用者自行安装。 - 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.0
或 vue-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;
}
}
}