【译文】风趣科普:Web 组件让 JavaScript 框架自由恋爱

风趣科普:Web 组件让 JavaScript 框架自由恋爱

原文地址:jakelazaroff.com/words/web-c...

作者:jake lazaroff

摘要

各位看官,今天我们要聊的是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:idtextdone。它还创建了一个自定义事件分发器,可以在整个自定义元素上分发事件。

$: 语法声明了一个响应式块。这意味着,每当iddone的值改变时,它都会分发一个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应用的代码。就像是在《青春有你》的训练营里,大家都有机会展示自己的才华。

相关推荐
mldong7 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排8 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60618 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角10 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!10 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰10 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi11 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen12 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马13 小时前
AI源码分析:拆解模型底层实现逻辑
前端
BillKu13 小时前
全局样式变量:CSS 自定义属性(--border-color)和SCSS 变量($border-color)的说明
javascript·css·scss