前端前端Web Components
JunsWeb Components
完善中~~~
前言
在写 vue 还有 react 的时候,有时候也会好奇,原生三件套怎么实现组件化呢?因为觉得组件化是一个很重要的东西,可以把一个页面拆分成一个个小的模块,方便维护和复用,组件代码也提倡不要过长过耦合。对于原生三件套来说,组件化不解决的话,确实开发上是很大的问题。
查阅资料,以及结合之前微前端所了解的知识,发现有 Web Components 这个东西,他是一种标准,相当于是提供了原生的组件化能力,下面简要了解看看。
是什么
直接翻看官方文档:
Web Component 是一套不同的技术,允许你创建可重用的定制元素(它们的功能封装在你的代码之外)并且在你的 web 应用中使用它们。
和理解的一样,就是提供一个组件化能力,不过具体怎么用还得接着看文档。
如何实现组件化
文档后面介绍了他的三个主要技术,基于这几个技术实现组件功能。
- Custom element(自定义元素):一组 JavaScript API,允许你定义 custom elements 及其行为,然后可以在你的用户界面中按照需要使用它们。
- Shadow DOM(影子 DOM):一组 JavaScript API,用于将封装的“影子”DOM 树附加到元素(与主文档 DOM 分开呈现)并控制其关联的功能。通过这种方式,你可以保持元素的功能私有,这样它们就可以被脚本化和样式化,而不用担心与文档的其他部分发生冲突。
- HTML template(HTML 模板):
<template>
和 <slot>
元素使你可以编写不在呈现页面中显示的标记模板。然后它们可以作为自定义元素结构的基础被多次重用。
Shadow DOM 可以保证这个组件不受外部影响,Custom element 可以自定义组件,HTML template 的话就是用来辅助写 html 的。
怎么用
基本使用
我们可以基于 Custom element 这个特性,实现自定义的标签,比如<my-button/>
这样的非原生标签,然后在 js 中定义这个标签的行为。
<script> class MyButton extends HTMLElement { constructor() { super()
const shadow = this.attachShadow({ mode: 'open' }) const button = document.createElement('button') button.innerText = '我是自定义按钮' shadow.appendChild(button) } }
window.customElements.define('my-button', MyButton) </script>
<h1>Temp</h1> <my-button></my-button>
|
上面是一个简易的示例代码,不太规范,不过能够体现功能,我们只需要在其他地方使用<my-button/>
这个标签,就可以看到一个自定义的组件了,可以拆分成多个文件出来,然后单独调用组件。
使用模板抽离成单 js 文件
除此之外,我们还可以使用 js 的模板字符串结合 template 标签,实现封装到一个 js 文件中:
const template = document.createElement('template') template.innerHTML = ` <style> button { background-color: #007bff; color: white; padding: 10px 20px; border-radius: 5px; border: none; cursor: pointer; } </style>
<button>我是自定义按钮</button> `
class MyButton extends HTMLElement { constructor() { super()
const content = template.content.cloneNode(true) const shadow = this.attachShadow({ mode: 'open' }) shadow.appendChild(content) } }
window.customElements.define('my-button', MyButton)
|
<script src="./index.js"></script>
<h1>Temp</h1> <my-button></my-button>
|
添加 attributes
组件是需要支持一些静态属性的,下面根据文档给我们自定义的 button 添加一些静态属性支持
const template = document.createElement('template') template.innerHTML = ` <style> button { background-color: #007bff; color: white; padding: 10px 20px; border-radius: 5px; border: none; cursor: pointer; } </style>
<button>我是自定义按钮</button> `
class MyButton extends HTMLElement { constructor() { super()
const content = template.content.cloneNode(true) const shadow = this.attachShadow({ mode: 'open' })
const buttonText = this.getAttribute('text') || '我是自定义按钮' content.querySelector('button').innerHTML = buttonText
shadow.appendChild(content) } }
setTimeout(() => { window.customElements.define('my-button', MyButton) }, 100)
|
<script src="./index.js"></script>
<my-button text="自定义内容"></my-button> <my-button text="自定义"></my-button>
|
这样我们就实现了静态属性的支持,可以根据不同的属性,自己判断显示内容。不过这个还是有待完善的,当我们在外部改变 text 属性的时候,里面的内容没有更新,因为我们只在初始化的时候获取了一次属性,所以我们需要添加一个监听属性变化的方法。
const template = document.createElement('template') template.innerHTML = ` <style> button { background-color: #007bff; color: white; padding: 10px 20px; border-radius: 5px; border: none; cursor: pointer; } </style>
<button>我是自定义按钮</button> `
class MyButton extends HTMLElement { static observedAttributes = ['text']
constructor() { super()
const content = template.content.cloneNode(true) const shadow = this.attachShadow({ mode: 'open' }) shadow.appendChild(content) this.renderText() } renderText() { const buttonText = this.getAttribute('text') || '我是自定义按钮' this.shadowRoot.querySelector('button').innerHTML = buttonText } attributeChangedCallback(name, oldValue, newValue) { this.renderText() } }
setTimeout(() => { window.customElements.define('my-button', MyButton) }, 100)
|
我们可以使用上面的 api 来实现监听属性变化的功能,这样当我们在外部改变 text 属性的时候,里面的内容也会随之改变。
生命周期
简单分析代码可以知道,我们使用一个类来表示一个组件,这就有点像 react 的类组件了,那么理论上也是有一些生命周期的概念的。
自定义元素生命周期回调包括:
- connectedCallback():每当元素添加到文档中时调用。规范建议开发人员尽可能在此回调中实现自定义元素的设定,而不是在构造函数中实现。
- disconnectedCallback():每当元素从文档中移除时调用。
- adoptedCallback():每当元素被移动到新文档中时调用。
- attributeChangedCallback():在属性更改、添加、移除或替换时调用。有关此回调的更多详细信息,请参见响应属性变化。
由此可见,我们上面其实不太规范,我们把渲染操作放到了 constructor 中,规范应该是放到 connectedCallback 中,这样可以保证在元素添加到文档中时才会渲染。
除此之外,我们也可以利用生命周期方法,做一些其他的操作。
实践
下面尝试根据上面的知识,实现一个简单的按钮组件,并在 solid 组件中使用它。
代码仓库地址: https://github.com/Juns-g/solid_web_component
const COLOR_MAP = { primary: '#007bff', danger: 'red', }
const template = document.createElement('template') template.innerHTML = ` <style> button { color: white; background-color: var(--button-color,${COLOR_MAP.primary}); padding: 10px 20px; border-radius: 5px; border: none; cursor: pointer; } </style>
<button>默认文案</button> `
class MyButton extends HTMLElement { static observedAttributes = ['text', 'color']
constructor() { super()
const content = template.content.cloneNode(true) const shadow = this.attachShadow({ mode: 'open' }) shadow.appendChild(content) }
connectedCallback() { console.log('挂载') this.render() }
render() { const btn = this.shadowRoot!.querySelector('button') as HTMLButtonElement
const buttonText = this.getAttribute('text') || '我是自定义按钮' btn!.innerHTML = buttonText
const color = (this.getAttribute('color') || 'primary') as keyof typeof COLOR_MAP btn.style.setProperty('--button-color', COLOR_MAP[color])
btn.onclick = () => { this.dispatchEvent(new Event('handleClick')) } }
attributeChangedCallback(name: string, oldValue: string, newValue: string) { console.log('attribute change', { name, oldValue, newValue }) this.render() } }
setTimeout(() => { window.customElements.define('my-button', MyButton) }, 100)
|
import { createEffect, createSignal } from 'solid-js'
export const Buttons = () => { let buttonsRef: HTMLDivElement | undefined const [btnText, setBtnText] = createSignal('我是按钮') createEffect(() => { const btn = buttonsRef!.querySelector('my-button') as HTMLElement btn.setAttribute('text', btnText()) }) return ( <div class='flex items-center justify-center gap-3' ref={buttonsRef} > <my-button on:handleClick={(event: MouseEvent) => { console.log('点击了按钮', event) setBtnText('我被点击了') }} /> <my-button text='danger按钮' color='danger' /> </div> ) }
|
这只是一个十分简单的实践,具体情况还可以优化很多地方。
拓展
Lit
Lit是 Google 开发的一个用于构建快速,轻量级 Web 组件的简单库。他在原生的基础上提供了一些状态相关的逻辑,以及更好的模板语法支持,下面是他官网的一个例子。
import { LitElement, html, css } from 'lit' import { customElement, property, state } from 'lit/decorators.js'
@customElement('my-timer') export class MyTimer extends LitElement { static styles = css`...`
@property() duration = 60 @state() private end: number | null = null @state() private remaining = 0
render() { const { remaining, running } = this const min = Math.floor(remaining / 60000) const sec = pad(min, Math.floor((remaining / 1000) % 60)) const hun = pad(true, Math.floor((remaining % 1000) / 10)) return html` ${min ? `${min}:${sec}` : `${sec}.${hun}`} <footer> ${remaining === 0 ? '' : running ? html`<span @click=${this.pause}>${pause}</span>` : html`<span @click=${this.start}>${play}</span>`} <span @click=${this.reset}>${replay}</span> </footer> ` }
start() { this.end = Date.now() + this.remaining this.tick() }
pause() { this.end = null }
reset() { const running = this.running this.remaining = this.duration * 1000 this.end = running ? Date.now() + this.remaining : null }
tick() { if (this.running) { this.remaining = Math.max(0, this.end! - Date.now()) requestAnimationFrame(() => this.tick()) } }
get running() { return this.end && this.remaining }
connectedCallback() { super.connectedCallback() this.reset() } }
function pad(pad: unknown, val: number) { return pad ? String(val).padStart(2, '0') : val }
|
Omi
官网地址: https://github.com/Tencent/omi/blob/master/README.CN.md
- 📶 信号 Signal 驱动的响应式编程,reactive-signal 强力驱动
- 🧱 TDesign Web 组件
- ⚡ 微小的尺寸,极速的性能
- 💗 目标 100+ 模板 & OMI 模板源码
- 🐲 OMI Form & OMI Form 游乐场 & Lucide Omi 图标
- 🌐 你要的一切都有: Web Components, JSX, Function Components, Router, Suspense, Directive, > - Tailwindcss…
- 💒 使用 Constructable Stylesheets 轻松管理和共享样式
示例代码:
import { render, signal, tag, Component, h } from 'omi'
const count = signal(0)
function add() { count.value++ }
function sub() { count.value-- }
@tag('counter-demo') export class CounterDemo extends Component { static css = 'span { color: red; }'
render() { return ( <> <button onClick={sub}>-</button> <span>{count.value}</span> <button onClick={add}>+</button> </> ) } }
|