Web Component 打包优化:动态拆包策略与实践
Web Component 打包优化:动态拆包策略与实践
现代前端工程化中提升 Web Component 自定义组件首屏加载速度的动态拆包策略
前言
我是大山哥。
上周帮客户做组件库优化时,架构师老王问我:"大山哥,Web Component 虽然跨框架,但加载太慢了,怎么优化?"
我分析了一下打包产物,发现每个 Web Component 都包含了大量重复的 polyfill 和运行时,导致首屏加载时间超过 3 秒。
兄弟,Web Component 也要按需加载!
今天,我就来分享如何通过动态拆包策略优化 Web Component 首屏加载速度。
一、 问题分析
1.1 当前架构问题
graph TD A[首屏加载] --> B[加载所有组件] B --> C[button-component.js] B --> D[card-component.js] B --> E[dialog-component.js] B --> F[input-component.js] B --> G[polyfill.js] B --> H[runtime.js] note over C,D,E,F,G,H: 每个组件都包含重复的 polyfill 和 runtime1.2 问题量化
| 组件 | 大小(含重复) | 纯业务代码 | 重复代码 |
|---|---|---|---|
| button-component | 15KB | 8KB | 7KB |
| card-component | 20KB | 12KB | 8KB |
| dialog-component | 25KB | 15KB | 10KB |
| input-component | 18KB | 10KB | 8KB |
| 合计 | 78KB | 45KB | 33KB (42%重复) |
二、 动态拆包策略
2.1 优化后架构
graph TD A[首屏加载] --> B[核心 runtime] B --> C[polyfill.js] B --> D[runtime.js] E[首次使用组件] --> F[按需加载组件代码] F --> G[button-component.js] F --> H[card-component.js] F --> I[dialog-component.js] F --> J[input-component.js] note over G,H,I,J: 组件只包含业务代码,共享 runtime2.2 核心实现:组件加载器
interface ComponentRegistry { [key: string]: { loader: () => Promise<CustomElementConstructor>; loaded: boolean; element: CustomElementConstructor | null; }; } class WebComponentLoader { private registry: ComponentRegistry = {}; private runtimeLoaded = false; async loadRuntime(): Promise<void> { if (this.runtimeLoaded) return; // 动态加载 polyfill(如果需要) if (!this.supportsCustomElements()) { await import('./polyfills/custom-elements'); } // 加载共享 runtime await import('./runtime/core'); this.runtimeLoaded = true; } registerComponent(name: string, loader: () => Promise<CustomElementConstructor>): void { this.registry[name] = { loader, loaded: false, element: null, }; } async loadComponent(name: string): Promise<CustomElementConstructor> { const entry = this.registry[name]; if (!entry) { throw new Error(`Component "${name}" not registered`); } // 确保 runtime 已加载 await this.loadRuntime(); if (entry.loaded && entry.element) { return entry.element; } try { const CustomElement = await entry.loader(); entry.element = CustomElement; entry.loaded = true; // 注册自定义元素 if (!customElements.get(name)) { customElements.define(name, CustomElement); } return CustomElement; } catch (error) { console.error(`Failed to load component "${name}":`, error); throw error; } } supportsCustomElements(): boolean { return 'customElements' in window; } preloadComponent(name: string): Promise<void> { return this.loadComponent(name).then(() => {}); } } // 全局实例 export const componentLoader = new WebComponentLoader();2.3 组件定义方式
// components/button-component.ts import { BaseElement } from '../runtime/core'; export class ButtonComponent extends BaseElement { static get observedAttributes() { return ['disabled', 'variant', 'size']; } constructor() { super(); this.attachShadow({ mode: 'open' }); } connectedCallback() { this.render(); } attributeChangedCallback() { this.render(); } private render() { const disabled = this.hasAttribute('disabled'); const variant = this.getAttribute('variant') || 'primary'; const size = this.getAttribute('size') || 'medium'; this.shadowRoot!.innerHTML = ` <style> :host { display: inline-flex; align-items: center; justify-content: center; padding: ${this.getPadding(size)}; border: none; border-radius: 8px; cursor: ${disabled ? 'not-allowed' : 'pointer'}; background: ${this.getBackground(variant)}; color: white; font-size: 14px; } :host([disabled]) { opacity: 0.6; } </style> <slot></slot> `; } private getPadding(size: string): string { const paddings = { small: '6px 12px', medium: '8px 16px', large: '12px 24px', }; return paddings[size as keyof typeof paddings] || paddings.medium; } private getBackground(variant: string): string { const backgrounds = { primary: '#3b82f6', secondary: '#6b7280', danger: '#ef4444', success: '#22c55e', }; return backgrounds[variant as keyof typeof backgrounds] || backgrounds.primary; } }三、 使用方式
3.1 注册组件
// registry.ts import { componentLoader } from './loader'; // 注册组件,使用动态导入 componentLoader.registerComponent('ui-button', async () => { const { ButtonComponent } = await import('./components/button-component'); return ButtonComponent; }); componentLoader.registerComponent('ui-card', async () => { const { CardComponent } = await import('./components/card-component'); return CardComponent; }); componentLoader.registerComponent('ui-dialog', async () => { const { DialogComponent } = await import('./components/dialog-component'); return DialogComponent; }); componentLoader.registerComponent('ui-input', async () => { const { InputComponent } = await import('./components/input-component'); return InputComponent; });3.2 React 集成
import { useEffect, useState } from 'react'; import { componentLoader } from './loader'; interface WebComponentProps { tagName: string; onLoad?: () => void; onError?: (error: Error) => void; [key: string]: unknown; } export function WebComponent({ tagName, onLoad, onError, ...props }: WebComponentProps) { const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState<Error | null>(null); useEffect(() => { let mounted = true; componentLoader.loadComponent(tagName) .then(() => { if (mounted) { setIsLoading(false); onLoad?.(); } }) .catch((err) => { if (mounted) { setError(err); onError?.(err); } }); return () => { mounted = false; }; }, [tagName, onLoad, onError]); if (isLoading) { return <div className="component-loading">加载中...</div>; } if (error) { return <div className="component-error">加载失败: {error.message}</div>; } const elementProps = { ...props }; delete elementProps.children; return React.createElement(tagName, elementProps, props.children); } // 使用示例 function App() { return ( <div> <WebComponent tagName="ui-button"> 点击按钮 </WebComponent> <WebComponent tagName="ui-card" title="卡片标题"> <p>卡片内容</p> </WebComponent> </div> ); }四、 预加载策略
4.1 智能预加载
class PreloadManager { private loader: WebComponentLoader; private preloaded = new Set<string>(); constructor(loader: WebComponentLoader) { this.loader = loader; } preloadVisibleComponents(): void { // 预加载当前可见区域的组件 const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { const element = entry.target as HTMLElement; const tagName = element.tagName.toLowerCase(); if (!this.preloaded.has(tagName)) { this.preload(tagName); } } }); }, { rootMargin: '100px' } ); // 观察所有自定义元素 document.querySelectorAll('[is-custom-element]').forEach((el) => { observer.observe(el); }); } preload(tagName: string): void { if (this.preloaded.has(tagName)) return; this.preloaded.add(tagName); this.loader.preloadComponent(tagName).catch((err) => { console.error(`Failed to preload ${tagName}:`, err); this.preloaded.delete(tagName); }); } preloadAll(): void { // 预加载所有注册的组件(谨慎使用) Object.keys(this.loader['registry']).forEach((tagName) => { this.preload(tagName); }); } } // 使用示例 const preloadManager = new PreloadManager(componentLoader); // 在应用初始化时预加载可见组件 document.addEventListener('DOMContentLoaded', () => { preloadManager.preloadVisibleComponents(); });4.2 基于路由的预加载
interface RouteConfig { path: string; components: string[]; } class RoutePreloader { private routes: RouteConfig[]; private loader: WebComponentLoader; constructor(routes: RouteConfig[], loader: WebComponentLoader) { this.routes = routes; this.loader = loader; } preloadForRoute(path: string): void { const route = this.routes.find((r) => r.path === path); if (route) { route.components.forEach((component) => { this.loader.preloadComponent(component); }); } } preloadCurrentRoute(): void { const currentPath = window.location.pathname; this.preloadForRoute(currentPath); } setupNavigationListener(): void { // 监听导航变化 window.addEventListener('popstate', () => { this.preloadCurrentRoute(); }); // 拦截链接点击 document.addEventListener('click', (e) => { const link = e.target as HTMLElement; if (link.tagName === 'A' && link.getAttribute('data-preload')) { const href = link.getAttribute('href'); if (href) { this.preloadForRoute(href); } } }); } } // 使用示例 const routes: RouteConfig[] = [ { path: '/', components: ['ui-button', 'ui-card'] }, { path: '/profile', components: ['ui-input', 'ui-card', 'ui-dialog'] }, { path: '/checkout', components: ['ui-button', 'ui-input', 'ui-dialog'] }, ]; const routePreloader = new RoutePreloader(routes, componentLoader); routePreloader.setupNavigationListener(); routePreloader.preloadCurrentRoute();五、 打包优化
5.1 Rollup 配置
// rollup.config.js import { defineConfig } from 'rollup'; import typescript from '[用户名]/plugin-typescript'; import resolve from '[用户名]/plugin-node-resolve'; import commonjs from '[用户名]/plugin-commonjs'; export default defineConfig({ input: { main: 'src/index.ts', runtime: 'src/runtime/core.ts', 'button-component': 'src/components/button-component.ts', 'card-component': 'src/components/card-component.ts', 'dialog-component': 'src/components/dialog-component.ts', 'input-component': 'src/components/input-component.ts', }, output: { dir: 'dist', format: 'es', entryFileNames: '[name].js', chunkFileNames: 'chunks/[name]-[hash].js', }, plugins: [ typescript(), resolve(), commonjs(), ], external: [], });5.2 优化效果
const optimizationResults = { before: { totalSize: 78, // KB initialLoad: 78, // KB (所有组件) loadTime: 3200, // ms }, after: { totalSize: 55, // KB (去重后) initialLoad: 12, // KB (仅 runtime) loadTime: 800, // ms }, improvement: { totalReduction: '29%', initialReduction: '85%', loadTimeReduction: '75%', }, };六、 性能监控
class ComponentPerformanceMonitor { private metrics = new Map<string, { loadTime: number; instances: number; firstRender: number; }>(); trackLoad(componentName: string, loadTime: number): void { const existing = this.metrics.get(componentName); if (existing) { existing.instances += 1; existing.loadTime = Math.min(existing.loadTime, loadTime); } else { this.metrics.set(componentName, { loadTime, instances: 1, firstRender: performance.now(), }); } } getReport(): Record<string, { avgLoadTime: number; totalInstances: number }> { const report: Record<string, { avgLoadTime: number; totalInstances: number }> = {}; this.metrics.forEach((data, name) => { report[name] = { avgLoadTime: data.loadTime, totalInstances: data.instances, }; }); return report; } } // 使用示例 const monitor = new ComponentPerformanceMonitor(); // 在组件加载时记录性能 componentLoader.loadComponent('ui-button').then(() => { const loadTime = performance.now() - startLoadTime; monitor.trackLoad('ui-button', loadTime); });