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

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

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

作者:jake lazaroff

摘要

各位看官,今天我们要聊的是Web组件——这个能打破JavaScript框架之间的隔离,让它们“自由恋爱”的神奇小东西。为了证明这一点,我们决定做个大胆的实验:建一个应用,里面的每个组件都是不同框架的“孩子”。让我们一起见证这个奇迹吧!


什么是Web组件?

如果你是Web组件的小白,别急,我来给你科普一下。首先,我们在JavaScript的世界里宣布:“我,MyComponent,是HTMLElement的亲儿子!”然后你就创建了一个Web组件。这不,代码看起来就像是这样:

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类:

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组件的魅力,它就像是你的个人秀,你可以在里面自由发挥,而外面的世界只能远远地欣赏。这就是Web组件的魔法,让每个组件都能成为舞台上的明星。

搭建布局

说到搭建布局,这可不是搭积木那么简单,这是在JavaScript的世界里盖房子!我们的主角是一个React组件,就像是那个总是穿着牛仔裤、手里拿着咖啡的建筑工人。

// TodoApp.jsx
export default function TodoApp() {
  return <></>;
}

这个组件现在看起来可能有点空,就像是刚刚拿到钥匙的新房子,里面啥也没有。但别急,我们很快就会往里面搬东西,布置得温馨又实用。

我们本来可以在这里开始添加元素,搭建出基本的DOM结构,但我决定先写另一个组件,来展示我们是如何像搭乐高一样,把Web组件一块一块地嵌套起来的。

大多数框架都支持通过嵌套来组合组件,就像是在做汉堡包,一层一层叠加起来。从外面看,它可能看起来像这样:

<Card>
  <Avatar />
</Card>

但在内部,框架们处理这种组合的方式各有千秋。比如React和Solid,它们会给你一个特殊的children属性,让你可以访问这些子组件:

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来编写我们的布局组件:

// 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组件了:

// 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 都有自己的“粉丝”——也就是我们这些用户。

// 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 布尔值。

// 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支持自定义元素,这不是开玩笑的,它是认真的!

<!-- 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>添加事件监听器。

// 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都有自己的档案。

// 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界的“变形金刚”。

<!-- 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来给它“施个魔法”。

// TodoFilters.js
import { defineCustomElement } from "vue";

import TodoFilters from "./TodoFilters.ce.vue";

customElements.define("todo-filters", defineCustomElement(TodoFilters));

回到我们的React大本营,我们要重构一下组件,用Nano Stores来替代useState,并且把<todo-filters>组件也加进来。

// 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应用的代码。就像是在《青春有你》的训练营里,大家都有机会展示自己的才华。

原文链接:https://juejin.cn/post/7349028634827440191 作者:rainstop_3

(0)
上一篇 2024年3月23日 上午10:21
下一篇 2024年3月23日 下午4:00

相关推荐

发表回复

登录后才能评论