风趣科普:Web 组件让 JavaScript 框架自由恋爱
摘要
各位看官,今天我们要聊的是Web组件------这个能打破JavaScript框架之间的隔离,让它们"自由恋爱"的神奇小东西。为了证明这一点,我们决定做个大胆的实验:建一个应用,里面的每个组件都是不同框架的"孩子"。让我们一起见证这个奇迹吧!
什么是Web组件?
如果你是Web组件的小白,别急,我来给你科普一下。首先,我们在JavaScript的世界里宣布:"我,MyComponent
,是HTMLElement
的亲儿子!"然后你就创建了一个Web组件。这不,代码看起来就像是这样:
js
class MyComponent extends HTMLElement {
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.shadow.innerHTML = `
<p>大家好,我是Web组件,不是网红,但我也能火!</p>
<style>
p {
color: pink;
font-weight: bold;
padding: 1rem;
border: 4px solid pink;
}
</style>
`;
}
}
这段代码就像是在说:"看好了,我要变身了!"然后它就用了Shadow DOM,把自己的标记和样式藏起来,不让外面的世界看到。这就像是你的朋友圈三天可见,只给你的"影子"看。
这个Shadow DOM就像是你的私人空间,你可以在里面尽情地装扮自己,外面的世界却看不到。而connectedCallback
就像是你的"登场秀",当你被添加到DOM的大舞台上时,你就会展示你的魅力。
接下来,我们定义了一个自定义元素名字给我们的MyComponent
类:
js
customElements.define("my-component", MyComponent);
每当页面上出现带有这个自定义元素名字的标签时,对应的DOM节点实际上就是MyComponent
的一个实例!就像是你的艺名,一提到它,大家都知道是你。
html
<my-component></my-component>
<script>
const myComponent = document.querySelector("my-component");
console.log(myComponent instanceof MyComponent); // true
</script>
看到了吗?这就是Web组件的魅力,它就像是你的个人秀,你可以在里面自由发挥,而外面的世界只能远远地欣赏。这就是Web组件的魔法,让每个组件都能成为舞台上的明星。
搭建布局
说到搭建布局,这可不是搭积木那么简单,这是在JavaScript的世界里盖房子!我们的主角是一个React组件,就像是那个总是穿着牛仔裤、手里拿着咖啡的建筑工人。
jsx
// TodoApp.jsx
export default function TodoApp() {
return <></>;
}
这个组件现在看起来可能有点空,就像是刚刚拿到钥匙的新房子,里面啥也没有。但别急,我们很快就会往里面搬东西,布置得温馨又实用。
我们本来可以在这里开始添加元素,搭建出基本的DOM结构,但我决定先写另一个组件,来展示我们是如何像搭乐高一样,把Web组件一块一块地嵌套起来的。
大多数框架都支持通过嵌套来组合组件,就像是在做汉堡包,一层一层叠加起来。从外面看,它可能看起来像这样:
jsx
<Card>
<Avatar />
</Card>
但在内部,框架们处理这种组合的方式各有千秋。比如React和Solid,它们会给你一个特殊的children
属性,让你可以访问这些子组件:
jsx
function Card(props) {
return <div class="card">{props.children}</div>;
}
但是,当我们用Shadow DOM的Web组件时,我们可以用<slot>
元素来做同样的事情。当浏览器遇到<slot>
时,它会用Web组件的子元素来替换它。
<slot>
元素其实比React或Solid的children
属性还要强大。如果我们给每个<slot>
一个name
属性,一个Web组件就可以有多个<slot>
,我们可以通过给嵌套元素一个匹配<slot>
的name
属性的slot
属性来决定每个元素应该放在哪里。
让我们来看一个实际操作的例子。我们将使用Solid来编写我们的布局组件:
jsx
// TodoLayout.jsx
import { render } from "solid-js/web";
function TodoLayout() {
return (
<div class="wrapper">
<header class="header">
<slot name="title" />
<slot name="filters" />
</header>
<div>
<slot name="todos" />
</div>
<footer>
<slot name="input" />
</footer>
</div>
);
}
customElements.define(
"todo-layout",
class extends HTMLElement {
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
render(() => <TodoLayout />, this.shadow);
}
}
);
这里我们有两部分:上面是Web组件的包装器,下面是真正的Solid组件。
最重要的是要注意,Solid组件使用了命名的<slot>
而不是children
属性。children
是由Solid处理的,只能让我们嵌套其他Solid组件,而<slot>
是由浏览器自己处理的,它允许我们嵌套任何HTML元素------包括用其他框架编写的Web组件!
Web组件包装器和上面的例子很像。它在构造函数中创建了一个影子根,然后在connectedCallback
方法中将Solid组件渲染进去。
注意,这不是Web组件包装器的完整实现!至少,我们可能想要定义一个attributeChangedCallback
方法,这样当属性变化时,我们可以重新渲染Solid组件。如果你在生产环境中使用这个,你应该使用Solid提供的Solid Element包,它为你处理了所有这些。
回到我们的React应用中,我们现在可以使用我们的TodoLayout
组件了:
jsx
// TodoApp.jsx
export default function TodoApp() {
return (
<todo-layout>
<h1 slot="title">Todos</h1>
</todo-layout>
);
}
注意,我们不需要从TodoLayout.jsx
导入任何东西------我们只需要使用我们定义的自定义元素标签。
就这样,一个React组件渲染了一个Solid组件,它又拿了一个嵌套的React元素作为孩子。这就像是一场跨框架的"联姻",不同家族的成员和谐地生活在一起。
添加 Todos
说到添加 Todos,这可不是在超市里买东西,随便拿几个就行。我们要做的是让每个 Todo 都有自己的身份证------一个独一无二的 id
。这就像是在《乘风破浪的姐姐》里,每个姐姐都有自己的粉丝团,我们要让每个 Todo 都有自己的"粉丝"------也就是我们这些用户。
js
// TodoInput.js
customElements.define("todo-input", TodoInput);
class TodoInput extends HTMLElement {
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" }); // 开启影子模式,神秘感爆棚!
}
connectedCallback() {
this.shadow.innerHTML = `
<form>
<input name="text" type="text" placeholder="需要做什么?" />
</form>
`;
// 这里我们监听表单提交,就像是在等待观众的掌声。
this.shadow.querySelector("form").addEventListener("submit", evt => {
evt.preventDefault(); // 别急着走,我们还有事呢!
const data = new FormData(evt.target);
// 收集观众的热情,也就是他们的输入。
this.dispatchEvent(new CustomEvent("add", { detail: data.get("text") })); // 发射信号,让大家都知道有新动态了!
evt.target.reset(); // 清空舞台,准备下一场表演。
});
}
}
在这个代码里,我们就像是在做一个互动节目,观众通过输入框发送信息,我们通过自定义事件 add
来接收这些信息。这就像是在直播中,观众发弹幕,主播读弹幕一样,互动感十足!
我们在这里使用了 customEvent
来和父组件通信。当表单提交时,我们发出一个 add
事件,带着输入的文字。这就像是在说:"嘿,新来的 Todo,你被选中了,快来加入我们的大家庭吧!"
事件队列在这里就像是快递小哥,把信息从一个地方送到另一个地方。浏览器大量使用事件,特别是自定义事件,这是 Web 组件工具箱中的重要工具------尤其是因为自定义元素本身就像个自然的事件总线,可以从外部访问。
在我们继续添加组件之前,我们需要先解决状态管理的问题。目前,我们将状态保持在 React 的 TodoApp
组件中。虽然我们最终可能会超越 useState
,但现在这是一个很好的起点。
每个 Todo 都有三个属性:一个 id
,一个描述它的 text
字符串,以及一个表示它是否已完成的 done
布尔值。
jsx
// TodoApp.jsx
import { useCallback, useState } from "react";
let id = 0;
export default function TodoApp() {
const [todos, setTodos] = useState([]); // 这里就是我们的 Todo 仓库。
export function addTodo(text) {
// 每当有新的 Todo 加入,我们就像欢迎新成员一样热情。
setTodos(todos => [...todos, { id: id++, text, done: false }]);
}
// ...其他的状态管理和事件监听的代码
}
我们在这里保持一个 Todo 的数组在 React 状态中。每当我们添加一个新的 Todo,我们就会把它加到数组里。
这个 inputRef
函数看起来有点尴尬。我们的 todo-input
发出自定义的 add
事件当表单提交时。通常在 React 中,我们会用 onClick
这样的 prop 来处理事件,但那只能处理 React 已经知道的事件。我们需要直接监听 add
事件。
在 React 的世界里,我们用 refs 来直接和 DOM 交互。我们通常用 useRef
钩子来使用它们,但这并不是唯一的方式!ref
prop 其实就是一个函数,它会在得到一个 DOM 节点时被调用。我们不是把从 useRef
钩子返回的 ref 传给那个 prop,而是可以传一个直接在 DOM 节点上添加事件监听器的函数。
你可能在想,为什么我们要用 useCallback
来包装这个函数。答案在于 旧版 React 文档关于 refs 的说明(据我所知,这部分内容并没有更新到新版文档中):
如果
ref
回调被定义为内联函数,它会在更新期间被调用两次,第一次传入null
,然后再传入 DOM 元素。这是因为每次渲染都会创建一个新的函数实例,所以 React 需要清除旧的 ref 并设置新的一个。你可以通过将ref
回调定义为类的绑定方法来避免这个问题,但请注意,在大多数情况下,这并不重要。
在这种情况下,它确实很重要,因为我们不想在每次渲染时都重新添加事件监听器。所以我们用 useCallback
来确保每次都传递相同的函数实例。
现在,我们的列表终于可以显示所有的 Todos 了!每当我们添加一个新的 Todo,它就会出现在列表中!
Todo项目展示
咱们的Todo应用现在已经能添加事项了,但问题来了,这些事项就像是藏在深闺的大家闺秀,谁也见不到啊!接下来,我们要让这些事项亮相,让全世界都知道我们的待办事项有多"高大上"。
我们将用Svelte来编写一个展示每个Todo项目的组件。Svelte支持自定义元素,这不是开玩笑的,它是认真的!
html
<!-- TodoItem.svelte -->
<script>
import { createEventDispatcher } from "svelte";
export let id; // 每个Todo的身份证号
export let text; // Todo的自我介绍
export let done; // Todo的心情:完成还是未完成
const dispatch = createEventDispatcher(); // 事件分发器,就像是Todo的经纪人
$: dispatch("check", { id, done }); // 每当Todo的状态改变,就发个通告
</script>
<div>
<input id="todo-{id}" type="checkbox" bind:checked={done} /> <!-- 一个简单的复选框,决定了Todo的命运 -->
<label for="todo-{id}">{text}</label> <!-- Todo的名字,响亮登场 -->
<button aria-label="delete {text}" on:click={() => dispatch("delete", { id })}>
<!-- 一个删除按钮,轻轻一点,Todo就消失不见 -->
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12">
<!-- 一个简单的删除图标,就像是Todo的"注销"按钮 -->
<path d="M10.707,1.293a1,1,0,0,0-1.414,0L6,4.586,2.707,1.293A1,1,0,0,0,1.293,2.707L4.586,6,1.293,9.293a1,1,0,0,0,1.414,1.414L6,7.414l3.293,3.293a1,1,0,0,0,1.414-1.414L7.414,6l3.293-3.293A1,1,0,0,0,10.707,1.293Z" fill="currentColor" />
</svg>
</button>
</div>
在Svelte的世界里,<script>
标签里的内容并不是真的渲染到DOM上,而是在组件实例化的时候运行。我们的Svelte组件接收三个props:id
、text
和done
。它还创建了一个自定义事件分发器,可以在整个自定义元素上分发事件。
$:
语法声明了一个响应式块。这意味着,每当id
或done
的值改变时,它都会分发一个check
事件,带着新的值。id
可能不会变,所以实际上这意味着,每当我们勾选或取消勾选一个Todo时,它都会分发一个check
事件。
回到我们的React组件,我们遍历所有的Todos,使用我们新创建的<todo-item>
组件。我们还需要一些额外的工具函数来删除和勾选Todos,以及另一个ref回调来给每个<todo-item>
添加事件监听器。
jsx
// TodoApp.jsx
import { useCallback, useState } from "react";
let id = 0;
export default function TodoApp() {
const [todos, setTodos] = useState([]);
export function addTodo(text) {
setTodos(todos => [...todos, { id: id++, text, done: false }]);
}
export function removeTodo(id) {
setTodos(todos => todos.filter(todo => todo.id !== id));
}
export function checkTodo(id, done) {
setTodos(todos => todos.map(todo => (todo.id === id ? { ...todo, done } : todo)));
}
// ...其他的事件处理和ref回调的代码
return (
<todo-layout>
{/* 组件插槽和事件监听的代码 */}
</todo-layout>
);
}
现在,我们的列表终于可以显示所有的Todos了!每当我们添加一个新的Todo,它就会出现在列表中,就像是登上了《青春有你》的舞台,闪闪发光。
过滤 Todos
到了这个阶段,我们的Todo应用已经能添加事项,也能展示了,但是,如果Todo太多,找起来就像是在《青春有你》的海选现场找人一样困难。所以,我们现在要加个过滤功能,让我们的Todo们排排队,站好位置,一目了然。
首先,我们得有个"大脑"来存储所有的todo信息,这就是我们的store.js
文件,用Nano Stores来管理状态。这就像是给Todo们建立了一个"档案馆",每个Todo都有自己的档案。
js
// store.js
import { atom, computed } from "nanostores";
let id = 0;
export const $todos = atom([]); // 所有的Todos都在这里
export const $done = computed($todos, todos => todos.filter(todo => todo.done)); // 完成的Todos
export const $left = computed($todos, todos => todos.filter(todo => !todo.done)); // 未完成的Todos
// 添加、勾选、删除Todo的方法
export function addTodo(text) {
$todos.set([...$todos.get(), { id: id++, text }]);
}
export function checkTodo(id, done) {
$todos.set($todos.get().map(todo => (todo.id === id ? { ...todo, done } : todo)));
}
export function removeTodo(id) {
$todos.set($todos.get().filter(todo => todo.id !== id));
}
// 过滤状态
export const $filter = atom("all"); // "all", "todo", "done"三种状态
接下来,我们用Vue来写一个过滤器组件。这不是普通的Vue组件,而是个自定义元素,就像是Vue界的"变形金刚"。
html
<!-- TodoFilters.ce.vue -->
<script setup>
import { useStore, useVModel } from "@nanostores/vue";
import { $todos, $done, $left, $filter } from "./store.js";
const filter = useVModel($filter);
const todos = useStore($todos);
const done = useStore($done);
const left = useStore($left);
</script>
<template>
<div>
<label>
<input type="radio" name="filter" value="all" v-model="filter" />
<span> All ({{ todos.length }})</span>
</label>
<label>
<input type="radio" name="filter" value="todo" v-model="filter" />
<span> Todo ({{ left.length }})</span>
</label>
<label>
<input type="radio" name="filter" value="done" v-model="filter" />
<span> Done ({{ done.length }})</span>
</label>
</div>
</template>
Vue的语法和Svelte有点像,但是把组件转换成自定义元素的过程可就没那么简单了。我们需要另外写一个文件,导入Vue组件,然后用defineCustomElement
来给它"施个魔法"。
js
// TodoFilters.js
import { defineCustomElement } from "vue";
import TodoFilters from "./TodoFilters.ce.vue";
customElements.define("todo-filters", defineCustomElement(TodoFilters));
回到我们的React大本营,我们要重构一下组件,用Nano Stores来替代useState
,并且把<todo-filters>
组件也加进来。
jsx
// TodoApp.jsx
import { useStore } from "@nanostores/react";
import { useCallback } from "react";
import { $todos, $done, $left, $filter, addTodo, removeTodo, checkTodo } from "./store.js";
export default function App() {
// ...状态管理和事件处理的代码
return (
<todo-layout>
{/* 组件插槽和事件监听的代码 */}
</todo-layout>
);
}
就这样,我们用四种不同的框架------React、Solid、Svelte和Vue------还有一个纯JavaScript写的组件,搭建了一个功能完备的todo应用。这不仅仅是技术上的胜利,更是和平共处的典范啊!
向前看
各位观众,我们的Todo应用现在已经功能齐全,就像是《奔跑吧》里的超级战队,各个身怀绝技。但这并不是说我们要停止创新的脚步,就像是网红们永远不会停止追求下一个热门挑战一样。
这篇文章的目的,并不是要说服你Web组件就是最好的解决方案,就像是《奇葩说》里辩手们不是为了争辩而争辩,而是为了展示多元的观点。我们只是想要展示,除了和某个框架"绑定"之外,你还有其他的选择。Web组件就像是那个"分手大师",让你的JavaScript世界不再被束缚。
你可以选择渐进式增强静态HTML,就像是在《中国新说唱》里,选手们一步步展示自己的才华。你也可以构建丰富的交互式JavaScript"岛屿",它们自然地与像HTMX这样的超媒体库通信。你甚至可以把一个Web组件包裹在一个框架组件外面,然后用它与任何其他框架一起使用。
Web组件通过提供一个所有框架都能使用的通用接口,极大地减少了JavaScript框架之间的耦合。对于消费者来说,Web组件只是HTML标签------它们"背后"发生了什么并不重要。
如果你想要自己动手试试,我在一个叫做CodeSandbox的"沙盒"里放了一个我们示例todo应用的代码。就像是在《青春有你》的训练营里,大家都有机会展示自己的才华。