作为现代前端组件化的基石技术,Web Components 提供了一整套浏览器原生支持的组件化方案。本章将深入剖析其四大核心技术,并通过完整的企业级案例展现其工程化实践。
// 自主元素(Autonomous Custom Elements)
class ScientificCalculator extends HTMLElement {
constructor() {
super();
// 元素初始化逻辑
}
}
customElements.define('sci-calculator', ScientificCalculator);
// 自定义内置元素(Customized Built-in Elements)
class FancyButton extends HTMLButtonElement {
constructor() {
super();
this.style.backgroundColor = 'gold';
}
}
customElements.define('fancy-button', FancyButton, { extends: 'button' });
class LifecycleDemo extends HTMLElement {
// 元素首次被创建时调用(非插入文档时)
constructor() {
super();
console.log('constructor executed');
}
// 元素首次被插入DOM时
connectedCallback() {
console.log('Component mounted');
this.render();
}
// 元素从DOM移除时
disconnectedCallback() {
console.log('Component unmounted');
this.cleanup();
}
// 观察的属性列表
static get observedAttributes() {
return ['data-config'];
}
// 被观察属性发生变化时
attributeChangedCallback(name, oldVal, newVal) {
console.log(`Attribute ${name} changed from ${oldVal} to ${newVal}`);
this.applyConfig(newVal);
}
// 元素被移动到新文档时(极少使用)
adoptedCallback() {
console.log('Component moved to new document');
}
}
class ShadowDemo extends HTMLElement {
constructor() {
super();
// Open模式:允许外部访问Shadow Root
const openShadow = this.attachShadow({ mode: 'open' });
openShadow.innerHTML = `Open Shadow Content
`;
console.log(openShadow === this.shadowRoot); // true
// Closed模式:完全封闭的Shadow DOM
const closedShadow = this.attachShadow({ mode: 'closed' });
closedShadow.innerHTML = `Closed Shadow Content
`;
console.log(this.shadowRoot); // null
}
}
<style>
p { color: red; } /* 外部样式不影响Shadow DOM */
style>
<shadow-demo>shadow-demo>
<script>
class ShadowStyle extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
Internal Styled Text
`;
}
}
customElements.define('shadow-style', ShadowStyle);
script>
class TabGroup extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
`;
}
}
// 使用示例
document.body.innerHTML = `
Content 1
Content 2
`;
<template id="user-card">
<style>
.card {
border: 1px solid #ddd;
padding: 16px;
border-radius: 8px;
}
style>
<div class="card">
<h2><slot name="name">Default Nameslot>h2>
<p>Email: <slot name="email">slot>p>
<div class="avatar">div>
div>
template>
<script>
class UserCard extends HTMLElement {
constructor() {
super();
const template = document.getElementById('user-card');
const content = template.content.cloneNode(true);
// 动态插入内容
const avatar = content.querySelector('.avatar');
avatar.style.backgroundImage = `url(${this.getAttribute('avatar')})`;
this.attachShadow({ mode: 'open' }).appendChild(content);
}
}
customElements.define('user-card', UserCard);
script>
class DataTable extends HTMLElement {
get dataSource() {
return this._data;
}
set dataSource(value) {
this._data = value;
this.renderTable();
}
static get observedAttributes() {
return ['data-source'];
}
attributeChangedCallback(name, oldVal, newVal) {
if (name === 'data-source') {
this.dataSource = JSON.parse(newVal);
}
}
}
// 使用方式
const table = document.querySelector('data-table');
table.dataSource = [...]; // JS属性设置
table.setAttribute('data-source', JSON.stringify([...])); // 特性设置
class Autocomplete extends HTMLElement {
constructor() {
super();
// ...初始化逻辑...
}
// 自定义事件派发
dispatchSearchEvent(query) {
this.dispatchEvent(new CustomEvent('search', {
detail: {
query,
timestamp: Date.now()
},
bubbles: true,
composed: true // 允许穿透Shadow DOM
}));
}
}
// 事件监听
document.querySelector('app-root')
.addEventListener('search', e => {
console.log('Search query:', e.detail.query);
});
<script src="https://unpkg.com/@webcomponents/[email protected]/webcomponents-bundle.js">script>
<script>
if (!window.customElements) {
document.write('
{{ name }}
// 双向绑定适配器
const withModel = (Component, propName = 'modelValue') => ({
extends: Component,
emits: ['update:modelValue'],
methods: {
emitUpdate(value) {
this.$emit('update:modelValue', value);
}
},
mounted() {
this.$el.addEventListener('change', e => {
this.emitUpdate(e.detail.value);
});
},
watch: {
[propName](newVal) {
if (this.$el[propName] !== newVal) {
this.$el[propName] = newVal;
}
}
}
});
// 使用示例
const CustomInput = defineCustomElement(
withModel(InputComponent)
);
// slot-proxy.js
export const SlotProxy = {
mounted() {
const slots = this.$slots.default?.();
if (slots) {
this.$el.innerHTML = '';
slots.forEach(node => {
this.$el.appendChild(node.el || document.createTextNode(node.text));
});
}
},
updated() {
this.mounted();
}
};
// 组件使用
export default {
mixins: [SlotProxy],
// 其他组件配置...
}
// store.js
import { createStore } from 'vuex';
const store = createStore({
state() {
return { count: 0 }
},
mutations: {
increment(state) {
state.count++
}
}
});
// web-component-store.js
export const withStore = (Component, store) => ({
extends: Component,
created() {
this.$store = store;
}
});
// 组件使用
export default defineCustomElement(
withStore(CounterComponent, store)
);
class RouterLink extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `${this.getAttribute('to')}">
`;
this.shadowRoot.querySelector('a').addEventListener('click', e => {
e.preventDefault();
window.dispatchEvent(new CustomEvent('router-navigate', {
detail: { path: this.getAttribute('to') }
}));
});
}
}
// Vue路由监听
window.addEventListener('router-navigate', e => {
router.push(e.detail.path);
});
// dynamic-loader.js
export async function loadComponent(name) {
const { default: Component } = await import(`./components/${name}.ce.vue`);
const CustomElement = defineCustomElement(Component);
customElements.define(name, CustomElement);
return CustomElement;
}
// 使用示例
document.querySelectorAll('[data-load-component]').forEach(el => {
loadComponent(el.dataset.loadComponent);
});
// rollup.config.js
export default {
// ...
treeshake: {
moduleSideEffects: false,
propertyReadSideEffects: false,
tryCatchDeoptimization: false,
preset: 'smallest',
annotations: true
},
output: {
compact: true,
preserveModules: false,
experimentalMinChunkSize: 10000
}
};
// error-boundary.js
export const withErrorBoundary = (Component) => ({
extends: Component,
data: () => ({
error: null
}),
errorCaptured(err) {
this.error = err;
return false;
},
render() {
return this.error
? this.$slots.error?.({ error: this.error }) || 'Component Error'
: this.$slots.default();
}
});
// performance-monitor.js
export const withPerfMonitor = (Component) => ({
extends: Component,
mounted() {
const timerName = `ComponentRender-${this.$options.name}`;
performance.mark(`${timerName}-start`);
},
updated() {
const timerName = `ComponentRender-${this.$options.name}`;
performance.mark(`${timerName}-end`);
performance.measure(timerName,
`${timerName}-start`,
`${timerName}-end`
);
}
});
// react-wrapper.jsx
import { useEffect, useRef } from 'react';
export function VueWebComponentWrapper({ component, props, onEvent }) {
const ref = useRef(null);
useEffect(() => {
const el = document.createElement(component);
Object.entries(props).forEach(([key, value]) => {
el[key] = value;
});
const handleEvent = e => onEvent?.(e.type, e.detail);
el.addEventListener('any-event', handleEvent);
ref.current.replaceChildren(el);
return () => {
el.removeEventListener('any-event', handleEvent);
};
}, [component, props, onEvent]);
return <div ref={ref} />;
}
// angular-adapter.directive.ts
@Directive({ selector: '[vueComponent]' })
export class VueComponentDirective implements OnChanges {
@Input() component: string;
@Input() props: Record<string, any>;
@Output() event = new EventEmitter<any>();
constructor(private el: ElementRef) {}
ngOnChanges(changes: SimpleChanges) {
if (changes.component) {
this.initComponent();
}
this.updateProps();
}
private initComponent() {
const element = document.createElement(this.component);
this.el.nativeElement.replaceChildren(element);
element.addEventListener('any-event', e => {
this.event.emit(e.detail);
});
}
private updateProps() {
const element = this.el.nativeElement.firstElementChild;
Object.entries(this.props).forEach(([key, value]) => {
element[key] = value;
});
}
}
// package.json
{
"scripts": {
"build:esm": "vite build --config vite.esm.config.js",
"build:umd": "vite build --config vite.umd.config.js",
"build:cdn": "vite build --config vite.cdn.config.js",
"build": "npm run build:esm && npm run build:umd && npm run build:cdn"
},
"files": [
"dist/esm/*",
"dist/umd/*",
"dist/cdn/*"
]
}
<script src="https://cdn.example.com/vue-components/v1.2.3/vue-components.min.js">script>
<script src="https://cdn.example.com/vue-components/v1.2.3/vue-components.js">script>
<script type="module">
import { registerAll } from 'https://cdn.example.com/vue-components/v1.2.3/esm/vue-components.js';
registerAll();
script>
// Vue 3 配置方案(vite.config.js)
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
isCustomElement: tag => tag.startsWith('wc-')
}
}
})
]
})
// Vue 2 兼容方案
Vue.config.ignoredElements = [
/^wc-/ // 使用正则匹配自定义元素
]
// global.d.ts
declare namespace JSX {
interface IntrinsicElements {
'wc-calendar': {
locale?: string
startDate?: Date
onDateSelect?: (event: CustomEvent<Date>) => void
} & HTMLAttributes
}
}
// 自定义指令实现双向绑定
Vue.directive('wc-model', {
bind(el, binding, vnode) {
const prop = binding.arg || 'value'
// Web Component -> Vue
el.addEventListener('change', e => {
vnode.context.$set(vnode.context, binding.expression, e.detail[prop])
})
// Vue -> Web Component
new MutationObserver(() => {
el[prop] = vnode.context[binding.expression]
}).observe(el, {
attributes: true,
attributeFilter: [prop]
})
}
})
// 使用示例
<wc-input v-wc-model:value="username"></wc-input>
{{ title }}
// 注册富文本编辑器
async function loadEditor() {
await import('@thirdparty/rich-text-editor')
customElements.define('rich-editor', ThirdpartyEditor)
}
// Vue组件封装
export default {
mounted() {
loadEditor().then(() => {
this.$refs.editor.addEventListener('content-change', this.handleChange)
})
},
methods: {
handleChange(e) {
this.$emit('update:modelValue', e.detail.html)
}
},
render() {
return this.$createElement('rich-editor', {
ref: 'editor',
props: {
content: this.value
}
})
}
}
// store.js
export const store = new Vuex.Store({
state: {
theme: 'light'
},
mutations: {
setTheme(state, theme) {
state.theme = theme
}
}
})
// 状态同步中间件
document.addEventListener('theme-change', e => {
store.commit('setTheme', e.detail)
})
// Web组件中响应状态
const ThemeProvider = {
computed: {
currentTheme() {
return store.state.theme
}
},
watch: {
currentTheme(newVal) {
document.dispatchEvent(new CustomEvent('theme-update', {
detail: newVal
}))
}
}
}
// 上下文提供者
export const WCContext = Symbol('wc-context')
export default {
provide() {
return {
[WCContext]: {
theme: computed(() => this.theme),
locale: computed(() => this.locale),
notify: this.sendNotification
}
}
}
}
// 组件消费
customElements.define('wc-button', class extends HTMLElement {
connectedCallback() {
const context = Vue.inject(WCContext)
this.addEventListener('click', () => {
context.notify('Button clicked!')
})
}
})
// 按需加载注册组件
const componentLoader = {
components: new Set(),
async load(name) {
if (this.components.has(name)) return
await import(`./wc-components/${name}.js`)
this.components.add(name)
}
}
// 路由级加载控制
router.beforeEach((to, from, next) => {
const requiredComponents = to.meta.wcComponents || []
Promise.all(requiredComponents.map(componentLoader.load))
.then(next)
.catch(next)
})
// 虚拟滚动容器
export default {
data: () => ({
visibleItems: []
}),
mounted() {
this.$refs.scroller.addEventListener('scroll', this.handleScroll)
},
methods: {
handleScroll() {
const { scrollTop, clientHeight } = this.$refs.scroller
const startIdx = Math.floor(scrollTop / 50)
const endIdx = startIdx + Math.ceil(clientHeight / 50) + 5
this.visibleItems = this.allItems.slice(startIdx, endIdx)
}
},
render() {
return this.$createElement('wc-virtual-list', {
props: {
items: this.visibleItems,
itemHeight: 50,
totalHeight: this.allItems.length * 50
}
})
}
}
// 调试Shadow DOM内容
const debugWC = (selector) => {
const el = document.querySelector(selector)
return {
element: el,
shadow: el.shadowRoot,
styles: [...el.shadowRoot.querySelectorAll('style')]
}
}
// 快速访问示例
const calendar = debugWC('wc-calendar')
calendar.styles[0].innerHTML += '\n/* Debug style */'
// 错误捕获高阶组件
export const withErrorBoundary = (component) => ({
data: () => ({ error: null }),
errorCaptured(err) {
this.error = err
return false
},
render(h) {
return this.error
? h('wc-error-display', { props: { error: this.error } })
: h(component, this.$attrs)
}
})
// 主应用通信总线
const eventBus = new Vue()
// 子应用封装
export const registerWebComponent = (name, component) => {
customElements.define(name, class extends HTMLElement {
connectedCallback() {
const vueInstance = new Vue({
parent: eventBus,
render: h => h(component)
}).$mount()
this.appendChild(vueInstance.$el)
}
})
}
/* 主题变量注入 */
wc-button {
--primary-color: var(--system-primary, #2196F3);
--text-color: var(--system-text, #333);
}
/* Vue主题控制 */
document.documentElement.style.setProperty('--system-primary', themeColor)
// 测试工具配置
import { mount } from '@vue/test-utils'
import '@testing-library/jest-dom'
test('integration with web component', async () => {
const wrapper = mount(Component)
await wrapper.find('wc-input').trigger('change')
expect(wrapper.emitted('update')).toBeTruthy()
})
// Cypress测试用例
describe('Web Component Integration', () => {
it('should update value on input', () => {
cy.get('wc-input')
.type('test')
.then(el => {
expect(el[0].value).to.equal('test')
expect(Cypress.vue.$data.value).to.equal('test')
})
})
})
class PropertyProxy {
constructor(element) {
this.element = element;
this.observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.type === 'attributes') {
this.handleAttributeChange(mutation.attributeName);
}
});
});
}
start() {
this.observer.observe(this.element, {
attributes: true,
attributeFilter: Object.keys(this.schema)
});
}
handleAttributeChange(attrName) {
const propName = this.schema[attrName];
const value = this.element.getAttribute(attrName);
this.onChange(propName, this.parseValue(value));
}
parseValue(value) {
try {
return JSON.parse(value);
} catch {
return value;
}
}
}
// 使用示例
const proxy = new PropertyProxy(wcElement, {
'data-config': 'config',
'theme-mode': 'theme'
});
proxy.start();
interface Schema {
[attribute: string]: {
prop: string;
type: 'number' | 'boolean' | 'object' | 'string';
default?: any;
};
}
class TypeSafeProxy extends PropertyProxy {
constructor(element: HTMLElement, private schema: Schema) {
super(element);
}
protected parseValue(value: string, type: Schema[string]['type']) {
switch (type) {
case 'number':
return Number(value);
case 'boolean':
return value === 'true' || value === '';
case 'object':
try { return JSON.parse(value); }
catch { return null; }
default:
return value;
}
}
}
class EventBus {
constructor() {
this.layers = {
component: new EventTarget(),
module: new EventTarget(),
global: window
};
}
on(event, handler, layer = 'component') {
this.layers[layer].addEventListener(event, handler);
}
emit(event, detail, layer = 'component') {
const customEvent = new CustomEvent(event, { detail });
this.layers[layer].dispatchEvent(customEvent);
}
}
// 跨层级通信示例
bus.emit('data-update', payload, 'global');
bus.on('config-change', handler, 'module');
const debouncedEvents = new WeakMap();
function optimizeEvent(element, eventName, handler, delay = 100) {
if (!debouncedEvents.has(element)) {
debouncedEvents.set(element, new Map());
}
const eventMap = debouncedEvents.get(element);
if (!eventMap.has(eventName)) {
const debounced = debounce(handler, delay);
eventMap.set(eventName, debounced);
element.addEventListener(eventName, debounced);
}
return () => {
element.removeEventListener(eventName, eventMap.get(eventName));
eventMap.delete(eventName);
};
}
class ThemeManager {
constructor() {
this.variables = new Map();
this.styleElement = document.createElement('style');
document.head.appendChild(this.styleElement);
}
setVariable(name, value) {
this.variables.set(name, value);
this.updateStyles();
}
updateStyles() {
const rules = Array.from(this.variables)
.map(([name, value]) => ` --${name}: ${value};`)
.join('\n');
this.styleElement.textContent = `
:root {
${rules}
}
`;
}
}
// 组件内应用
const theme = new ThemeManager();
theme.setVariable('primary-color', '#2196f3');
class ResponsiveStyle {
constructor(element) {
this.element = element;
this.mediaQueries = new Map();
this.observer = new ResizeObserver(this.handleResize.bind(this));
this.observer.observe(element);
}
addRule(breakpoint, style) {
this.mediaQueries.set(breakpoint, style);
}
handleResize(entries) {
const width = entries[0].contentRect.width;
for (const [breakpoint, style] of this.mediaQueries) {
if (width >= breakpoint) {
this.applyStyle(style);
break;
}
}
}
applyStyle(style) {
Object.entries(style).forEach(([prop, value]) => {
this.element.style.setProperty(prop, value);
});
}
}
class StateBridge {
constructor() {
this.states = new Map();
this.subscribers = new Map();
}
register(key, initialValue) {
this.states.set(key, initialValue);
}
get(key) {
return this.states.get(key);
}
set(key, value) {
const oldValue = this.states.get(key);
this.states.set(key, value);
this.notify(key, value, oldValue);
}
subscribe(key, callback) {
if (!this.subscribers.has(key)) {
this.subscribers.set(key, new Set());
}
this.subscribers.get(key).add(callback);
}
notify(key, newValue, oldValue) {
const callbacks = this.subscribers.get(key) || [];
callbacks.forEach(cb => cb(newValue, oldValue));
}
}
// 跨框架使用
const bridge = new StateBridge();
bridge.register('auth.token', null);
// Vue组件
watch(() => bridge.get('auth.token'), newVal => {
store.commit('setToken', newVal);
});
// Web Component
bridge.subscribe('auth.token', (token) => {
document.querySelector('app-shell').setAttribute('auth-token', token);
});
class ComponentLoader {
constructor() {
this.registry = new Map();
this.loading = new Set();
this.pending = new Map();
}
register(name, loader) {
this.registry.set(name, loader);
}
async load(name) {
if (this.registry.has(name)) {
if (!customElements.get(name)) {
if (!this.loading.has(name)) {
this.loading.add(name);
try {
await this.registry.get(name)();
this.loading.delete(name);
this.resolvePending(name);
} catch (error) {
this.rejectPending(name, error);
}
}
return new Promise((resolve, reject) => {
this.pending.set(name, { resolve, reject });
});
}
return Promise.resolve();
}
return Promise.reject(new Error(`Component ${name} not registered`));
}
resolvePending(name) {
const pending = this.pending.get(name);
if (pending) {
pending.resolve();
this.pending.delete(name);
}
}
}
const dependencyGraph = {
'data-grid': ['virtual-scroll', 'sort-controller'],
'chart': ['data-adapter']
};
class DependencyResolver {
constructor(loader) {
this.loader = loader;
}
async loadComponent(name) {
const dependencies = dependencyGraph[name] || [];
for (const dep of dependencies) {
await this.loadComponent(dep);
}
return this.loader.load(name);
}
}
class Sanitizer {
static sanitizeHTML(html) {
const template = document.createElement('template');
template.innerHTML = html;
const scripts = template.content.querySelectorAll('script');
scripts.forEach(script => script.remove());
const events = template.content.querySelectorAll('*');
events.forEach(el => {
[...el.attributes].forEach(attr => {
if (attr.name.startsWith('on')) {
el.removeAttribute(attr.name);
}
});
});
return template.innerHTML;
}
}
// 安全插槽渲染
function safeSlot(content) {
return Sanitizer.sanitizeHTML(content);
}
class CSPCompatibility {
static check() {
const csp = document.querySelector('meta[http-equiv="Content-Security-Policy"]');
if (csp) {
const policy = csp.getAttribute('content');
if (!policy.includes('unsafe-inline')) {
console.warn('CSP restricts inline styles, using fallback strategy');
this.applyFallbackStyles();
}
}
}
static applyFallbackStyles() {
document.querySelectorAll('[style]').forEach(el => {
const styles = el.getAttribute('style');
const className = `csp-style-${hash(styles)}`;
if (!document.querySelector(`.${className}`)) {
const style = document.createElement('style');
style.className = className;
style.textContent = `.${className} { ${styles} }`;
document.head.appendChild(style);
}
el.classList.add(className);
el.removeAttribute('style');
});
}
}
class BatchRenderer {
constructor() {
this.queue = new Map();
this.frameId = null;
}
scheduleUpdate(element, updater) {
this.queue.set(element, updater);
if (!this.frameId) {
this.frameId = requestAnimationFrame(() => this.flush());
}
}
flush() {
this.queue.forEach((updater, element) => {
updater();
this.queue.delete(element);
});
this.frameId = null;
}
}
// 使用示例
const renderer = new BatchRenderer();
function updateElement(element, data) {
renderer.scheduleUpdate(element, () => {
element.data = data;
});
}
class ComponentGC {
constructor() {
this.registry = new WeakMap();
this.observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
mutation.removedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE && this.registry.has(node)) {
this.cleanup(node);
}
});
});
});
}
register(element, cleanup) {
this.registry.set(element, cleanup);
this.observer.observe(element.parentElement, {
childList: true
});
}
cleanup(element) {
const fn = this.registry.get(element);
if (typeof fn === 'function') {
fn();
}
this.registry.delete(element);
}
}
class ComponentInspector {
constructor() {
this.panel = document.createElement('div');
Object.assign(this.panel.style, {
position: 'fixed',
right: '0',
top: '0',
background: 'white',
padding: '20px',
zIndex: '9999'
});
document.body.appendChild(this.panel);
}
inspect(element) {
const props = element.getAttributeNames().reduce((acc, name) => {
acc[name] = element.getAttribute(name);
return acc;
}, {});
const state = element._state || {};
this.panel.innerHTML = `
Component Inspector
${JSON.stringify({ props, state }, null, 2)}
`;
}
}
// 浏览器控制台快捷访问
window.__INSPECTOR__ = new ComponentInspector();
class PerformanceTracker {
constructor() {
this.marks = new Map();
}
start(markName) {
performance.mark(`${markName}-start`);
this.marks.set(markName, true);
}
end(markName) {
if (this.marks.has(markName)) {
performance.mark(`${markName}-end`);
performance.measure(
markName,
`${markName}-start`,
`${markName}-end`
);
this.marks.delete(markName);
}
}
report() {
return performance.getEntriesByType('measure');
}
}
// 组件生命周期追踪
const tracker = new PerformanceTracker();
customElements.define('tracked-element', class extends HTMLElement {
constructor() {
super();
tracker.start('element-constructor');
}
connectedCallback() {
tracker.end('element-constructor');
tracker.start('element-connected');
}
});