速看,Web Components可以实现多框架组件的共存?
本文带你了解Web component如何实现多框架代码在一个应用里共存的,不是建议你这么去维护代码,只是一种思路帮你解决业务中因为框架限制而不能处理的问题。同时也可以进一步学习Web component。
最近我们看到有很多关于Web Components的文章,有许多人开始关注新兴的HTML Web Components模式,该模式避开了Shadow Dom转而支持渐进式增强现有模版。关于将Web Components完全替代JS框架也有很多讨论,包括本文。
不过,这并不是唯一的选择。
你可以将Web Components与现有的JS框架结合使用,基于这个思路,我想表达我之前没有过多关注的一个关键收益点:Web Components可以在很大程度上放宽JS框架之间的耦合性
。
为了证明上述的观点,我们将会做一些很疯狂的事情:构建一个应用,其中的每个组件都用一个不同的框架来实现。
不言而喻,现实情况中你不需要这样去构建一个应用,但是我们有充分的理由来说明混合框架存在的原因:
- 也许你正在逐渐地从React转向Vue
- 也许你的应用是用Solid来构建,但是你需要使用一个只有Angular框架实现的三方库
- 也许你想在一个静态网站中使用Svelte来实现一些“互动岛”
下面就是我们将要实现的:一个基于多框架的简单的TODO应用,
随着我们构建的过程,我们会看到Web Components如何封装JS框架,允许我们在不对应用程序的其余部分增加更多限制的情况下使用它们。
那什么是Web Component?
防止你还不太熟悉Web Component,这里将会关于它如何工作做一个简单的介绍。如果你已经熟悉了,可以跳过这个部分。
首先,在JS中声明HTMLElement
的一个子类,将它命名为MyComponent
:
class MyComponent extends HTMLElement {
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.shadow.innerHTML = `
<p>Hello from a web component!</p>
<style>
p {
color: pink;
font-weight: bold;
padding: 1rem;
border: 4px solid pink;
}
</style>
`;
}
}
构造函数中的attachShadow
将会使我们的组件拥有shadow DOM的能力,它在组件内部封装了模版和样式。connectedCallback
则会在web component结合到DOM树的时候被调用,渲染HTML的内容到组件的“影子根节点”。
这也就预示着我们是如何让框架与web components工作,我们简单地将框架挂载到一个DOM元素,然后让框架接管剩下的子孙元素。因为web components,我们可以将框架挂载到影子节点,这将保证了它只能访问组件的“影子树”。
接下来,我们给MyComponent
类定义一个自定义元素名:
customElements.define('my-component', MyComponent)
当页面中出现了自定义元素名的标签,那么这个相对应的DOM节点实际上是MyComponent
的一个实例!
<my-component></my-component>
<script>
const myComponent = document.querySelector("my-component")
console.log(myComponent instanceof MyComponent) // true
</script>
看下渲染效果:
关于Web components还有很多内容,不过目前的内容足够你理解本文的剩下内容了。
整体布局
我们应用的入口是一个React组件,它长这样:
// TodoApp.jsx
export default function TodoApp() {
return <></>
}
紧接着,我们就可以添加一些元素进而导出基础的DOM结构,但是这里我另外写一个组件,以此来表现我们如何像在框架里面嵌套组件一样来嵌套web components。
很多的框架支持像HTML那样的嵌套排版样式,从外层看起来是这样的:
<Card>
<Avatar />
</Card>
在其内部,不同的框架有不同的处理方式。例如,React和Solid通过children
属性来访问子节点:
function Card(props) {
return <div class="card">{props.children}</div>
}
对于使用影子DOM的web components,我们通过<slot>
元素来做同样事情。当浏览器遇到一个<slot>
标签,将会把web component的内容替换掉这个标签。
<slot>
实际上比React或Solid的children
更好用,如果我们给每一个slot一个name
属性,一个web component可以有很多个<slot>
,进而我们可以依据slot的name
属性匹配决定每一个嵌套元素的执行。
让我们看看实际练习中代码,我们通过Solid来实现我们的layout组件:
// 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);
}
}
);
Solid web component有两个部分:web component包装器在上面,实际的solid组件在下面。
Solid component中最值得注意的是:我们在使用命名的<slot>
代替children
属性,因此children
将被Solid接管,同时可以嵌套其他Solid组件,<slot>
被浏览器本身接管,进而可以嵌套任何Html节点——包括用其他框架实现的web components。
web component包装器跟上述的例子很相似,它会在构造函数中创建一个影子节点,然后在connectedCallback
方法中将Solid组件渲染进这个节点中。
注意,这不是web component包装器的完整实现,至少,我们还需要定义一个attributeChangedCallback
方法,进而我们可以在属性变化的时候对solid组件进行重新渲染。如果你在生产环境中使用这个包装器,你需要使用Solid提供的一个Solid Element
的包来帮你处理这些事情。
回到React应用,我们可以使用TodoLayout
组件:
export default function TodoApp() {
return (
<todo-layout>
<h1 slot="title">Todos</h1>
</todo-layout>
)
}
注意,我们在上面的文件里面不需要引入任何内容——我们只是使用了我们自定义元素,让我们看看结果:
这就是React组件通过嵌套React元素作为子节点渲染了一个Solid组件。
添加Todos
对于Todo这个部分,我们尝试不用框架来实现:
// 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="What needs to be done?" /> </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();
});
}
}
在web component案例和我们的Solid layout之间,你可能观察到一种模式:挂载一个影子节点,然后在里面渲染HTML。无论我们是手写HTML还是用框架生成它,这个流程大致都是相似的。
这个例子中,我们使用自定义事件来跟父组件通信,一旦表单提交,我们发布一个含有input内容add
事件。
一个软件系统的事件队列通常在组件间使用解耦通信,浏览器是很依赖事件的,特别是自定义事件在web component工具包中更为重要——自定义事件使用了可以在web compoent外部都可以访问的Event bus。
在继续往里添加组件之前,我们需要弄清楚如何进行状态管理。现在,我们从TodoApp
的React组件开始,尽管最终我们并不会用useState
这一种的管理状态工具,但是从这里开始是很好的。
每一个todo有三个属性:id,text为描述信息和一个布尔值类型的done来标记它是否已完成。
// 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 }]);
}
const inputRef = useCallback(ref => {
if (!ref) return;
ref.addEventListener("add", evt => addTodo(evt.detail));
}, []);
return (
<todo-layout>
<h1 slot="title">Todos</h1>
<todo-input slot="input" ref={inputRef}></todo-input>
</todo-layout>
);
}
我们定义了一个todos的数组作为状态值,添加一个todo,数组长度就会+1。
这里面有一个令人比较尴尬的点是inputRef
函数,我们的<todo-input>
在表单提交的时候触发一个自定义的add
事件,通常在React中,我们通过props绑定一个onClick事件即可,但这通常只会在React环境中使用,我们需要直接监听add
事件。
在React环境中,我们直接通过refs
跟DOM交互,常规的使用方式也就是通过useRef
这个hook,但不是唯一的方式。ref
属性本质上是DOM节点调用的一个函数,相较于传递一个从useRef
返回的ref作为prop,我们可以直接传递一个绑定了DOM节点的监听事件函数。
你可以能会疑惑为什么必须要在函数外面包裹一层useCallback
,这里可以解释一下(掌握的同学可以跳过):
如果ref的回调定义为一个行内方法,那么在更新的时候会执行两次,第一次为
null
,第二次为DOM节点。这是因为每次渲染都会触发一个新实例的创建,React需要清除掉旧的ref进而使用新的来替换。你可以通过定义ref的回调作为类的绑定方法来避免这种情况,但是大多数情况下都没有作用。
在这个案例中,它是起作用的,因为我们不想在每次渲染过程中都绑定监听事件。因此通过包裹一层useCallback
来保证每次渲染的时候都传递的是同一个实例。
Todo列表
到目前为止,我们可以添加todos,但是还看不到。下一步就是写一个组件来展示todo,那么,接下来我们将通过Svelte来写这个组件。
Svelte支持开箱即用的自定义元素,相较于不断地通过模版包裹相同的组件来展示列表,我们只使用该功能,代码如下:
<!-- TodoItem.svelte -->
<svelte:options customElement="todo-item" />
<script>
import { createEventDispatcher } from "svelte";
export let id;
export let text;
export let done;
const dispatch = createEventDispatcher();
$: dispatch("check", { id, done });
</script>
<div>
<input id="todo-{id}" type="checkbox" bind:checked={done} />
<label for="todo-{id}">{text}</label>
<button aria-label="delete {text}" on:click={() => dispatch("delete", { id })}>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12">
<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,1,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中,渲染DOM的不是Script标签里面的内容,而是在组件运行实例化的时候。该组件含有三个props:id
、text
和done
,同时还定义个了一个事件触发器,进而可以触发自定义事件。
$:
语法为声明一个响应式模块,其含义为当id
或者done
的值发生变化的时候,将会用最新的值触发check
事件。id
基本上不会发生变化,那么这就意味着在实际运行中只会在todo中进行选中或取消的时候触发check
。
再回到react组件中,我们循环todos并使用新的<todo-item>
组件。我们也需要一些实用的方法来删除和选中todos,另一个ref callback用来在每个<todo-item>
上面添加事件监听。
代码如下:
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)));
}
const inputRef = useCallback(ref => {
if (!ref) return;
ref.addEventListener("add", evt => addTodo(evt.detail));
}, []);
const todoRef = useCallback(ref => { if (!ref) return;
ref.addEventListener("check", evt => checkTodo(evt.detail.id, evt.detail.done));
ref.addEventListener("delete", evt => removeTodo(evt.detail.id));
}, []);
return (
<todo-layout>
<h1 slot="title">Todos</h1>
<ul> {todos.map(todo => (
<li key={todo.id}>
<todo-item ref={todoRef} {...todo} />
</li>
))}
</ul>
<todo-input slot="input" ref={inputRef}></todo-input>
</todo-layout> ); }
现在这个TODO list的功能基本完成,当我们添加一个新的todo,列表中会显示出来。
过滤Todos
最后一项功能是添加一个针对todos的过滤器。在添加这个功能之前,我们需要做一点改造。
我想要展示另一种在web components之间通信的方式——使用一个共享store。很多我们在使用的框架都有自己实现的store,但是现在我们需要一个所有框架都能使用的store,基于这个诉求,我们使用一个叫Nano Stores的库。
首先,我们创建一个叫store.js
的新文件,在这里我们使用Nano Stores重新todos的状态:
// store.js
import { atom, computed } from "nanostores";
let id = 0;
export const $todos = atom([]);
export const $done = computed($todos, todos => todos.filter(todo => todo.done));
export const $left = computed($todos, todos => todos.filter(todo => !todo.done));
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");
核心逻辑是一样的,最大的变化就是将useState
的api转化成了Nano Stores的API,我们添加了两个计算stores:$done
和$left
,这俩从$todos
中拆解出来并相应地返回已完成和未完成的todos。另外还有一个新的store——$filter
,用来存储当前实时的过滤值。
接下来,我们使用Vue来实现这个过滤组件:
<!-- 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>
语法上跟Svelte很相似:在顶部的<script>
会在组件实例化的时候运行,<component>
标签包含着组件的渲染内容。
不过,Vue不像Svelte那样可以简单地将一个组件编译成一个自定义元素,我们需要创建另一个文件,引入一个Vue组件并调用defineCustomElement
方法:
// TodoFilters.js
import { defineCustomElement } from "vue";
import TodoFilters from "./TodoFilters.ce.vue";
customElements.define("todo-filters", defineCustomElement(TodoFilters));
再回到React组件中,我们将useState
api替换成Nano api,同时引入<todo-filter>
组件:
// 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() {
const filter = useStore($filter);
const todos = useStore($todos);
const done = useStore($done);
const left = useStore($left);
const visible = filter === "todo" ? left : filter === "done" ? done : todos;
const todoRef = useCallback(ref => {
if (!ref) return;
ref.addEventListener("check", evt => checkTodo(evt.detail.id, evt.detail.done));
ref.addEventListener("delete", evt => removeTodo(evt.detail.id));
}, []);
const inputRef = useCallback(ref => {
if (ref)
ref.addEventListener("add", evt => addTodo(evt.detail)); },
[]);
return (
<todo-layout>
<h1 slot="title">Todos</h1>
<todo-filters slot="filters" />
<div slot="todos"> {
visible.map(todo => (
<todo-item key={todo.id} ref={todoRef} {...todo} />
))
}</div>
<todo-input ref={inputRef} slot="input" />
</todo-layout> );
}
全部实现,我们现在拥有了一个功能完整的todo应用,使用了四种框架来实现——React/Solid/Svelte/Vue,再加上一个使用原生js写的组件。
进一步探讨
本文的重点不是建议你按照这种方式实现app,只是为了表明构建一个app的过程中不仅仅只有单一框架实现这一条路,而web component在实现这种方式中让一切变得简单。
你可以渐进式的增强静态HTML,你可以构建一个重交互的js“岛”,并很好地和像HTMX的超媒体库进行交互。你可以将一个框架组件包裹在web component中,然后用在其他框架中
。
Web component通过提供所有框架都能使用的通用接口进而显著地实现了多框架的结合。从用户角度来看,web component像是HTML标签——至于底层实现并不那么重要。
如果你想尝试体验这种多框架实现的app,你可以点击这里体验。
转载自:https://juejin.cn/post/7326544016056680474