yinguangyao / blog

关于 JavaScript 前端开发、工作经验的一点点总结。
262 stars 12 forks source link

介绍 Preact Signals #80

Open yinguangyao opened 2 years ago

yinguangyao commented 2 years ago

1. 什么是 Signals?

Signals 是用来处理状态的一种方式,它参考自 SolidJS,吸收了其大部分的优点。无论应用多么复杂,它都能保证快速响应。

Signals 的独特之处在于状态更改会以最有效的方式来自动更新组件和 UI。

Signals 基于自动状态绑定和依赖跟踪提供了出色的工效,并具有针对虚拟 DOM 优化的独特实现。

2. 为什么是 Signals?

2.1 状态管理的困境

随着应用越来越复杂,项目中的组件也会越来越多,需要管理的状态也越来越多。

为了实现组件状态共享,一般需要将状态提升到组件的共同的祖先组件里面,通过 props 往下传递,带来的问题就是更新时会导致所有子组件跟着更新,需要配合 memouseMemo 来优化性能。

虽然这听起来还挺合理,但随着项目代码的增加,我们很难确定这些优化应该放到哪里。

即使添加了 memoization,也常常因为依赖值不稳定变得无效,由于 Hooks 没有可以用于分析的显式依赖关系树,所以也没法使用工具来找到原因。

另一种解决方案就是放到 Context 上面,子组件作为消费者自行通过 useContext 来获取需要的状态。

但是有一个问题,只有传给 Provider 的值才能被更新,而且只能作为一个整体来更新,无法做到细粒度的更新。

为了处理这个问题,只能将 Context 进行拆分,业务逻辑又不可避免地会依赖多个 Context,这样就会出现 Context 套娃现象。

2.2 通向未来的 Signals

看到这里你一定感觉似曾相识,没错,通往未来的解决方案一定是我 —— Recoil,不对,这次的主角是 Signals。

signal 的核心是一个通过 value 属性 来保存值的对象。它有一个重要特征,那就是 signal 对象的值可以改变,但 signal 本身始终保持不变。

import { signal } from "@preact/signals";

const count = signal(0);

// Read a signal’s value by accessing .value:
console.log(count.value);   // 0

// Update a signal’s value:
count.value += 1;

// The signal's value has changed:
console.log(count.value);  // 1

在 Preact 中,当 signal 作为 props 或 context 向下传递时,传递的是对 signal 的引用。这样就可以在不重新渲染组件的情况下更新 signal,因为传给组件的是 signal 对象而不是它的值。

这让我们可以跳过所有昂贵的渲染工作,立即跳到任意访问 signal .value 属性的组件。

这里有 VDOM 和 Signals 在 Chrome 里面更新时的火焰图对比,可以发现 Signals 非常快。相比组件树更新,Signals 渲染会更快一些,这是因为更新状态图所需的工作要少得多。

Signals 具有第二个重要特征,即它们会跟踪其值何时被访问以及何时被更新。在 Preact 中,当 signal 的值发生变化时,从组件内访问 signal 的属性会自动重新渲染组件。

2.3 栗子

我们可以用一个例子来理解 Signals 的独特之处:

import { signal } from "@preact/signals";

const count = signal(0);

const App = () => {
  return (
    <Fragment>
      <h1 onClick={() => count.value++;}>
        +
        {console.log("++")}
      </h1>
      <span>{count}</span>
    </Fragment>
  );
};

当我们点击10次加号之后,count会从0变成10,那么"++"是否会被打印10次呢?

从我们平时写 React 组件的经验来说,肯定会被打印10次,但在 Signals 里面不是这样。

从这个 Gif 可以看到,"++"一次都没被打印出来,这就是 Signals 的独特之处,整个组件没有被重新渲染。

不仅 h1 没有重新渲染,甚至连 span 节点都没有重新渲染,唯一更新的地方就只有 {count} 这个文本节点。

💡 提示:Signal 只有在设置新的值才会更新。如果设置的值没有发生变化,就不会触发更新。

除了文本节点,Signals 还能做到对 DOM 属性的细粒度更新。当点击加号的时候,只有 data-id 被更新了,甚至连 span 里面的 random 都没有被执行。

const count = signal(0);

const App = () => {
  return (
    <Fragment>
      <h1 onClick={() => count.value++;}>
        +
        {console.log("++");}
      </h1>
      <span data-id={count}>{Math.random()}</span>
    </Fragment>
  );
};

3. 安装

可以通过将 @preact/signals 包添加到项目中来安装 Signals:

npm install @preact/signals

4. 用法

我们接下来将会写一个 TodoList 的 Demo 来学习 Signals。

4.1 创建状态

首先需要一个包含待办事项列表的 signal,可以用数组来表示:

import { signal } from "@preact/signals";

const todos = signal([
  { text: "Buy groceries" },
  { text: "Walk the dog" },
]);

接着,需要允许用户编辑输入框、创建新的 Todo 事项,所以还要创建输入值的 signal,然后直接设置 .value 来实现修改。

// We'll use this for our input later
const text = signal("");

function addTodo() {
  todos.value = [...todos.value, { text: text.value }];
  text.value = ""; // Clear input value on add
}

我们要添加的最后一个功能是从列表中删除待办事项。为此,我们将添加一个从 todos 数组中删除给定 todo 项的函数:

function removeTodo(todo) {
  todos.value = todos.value.filter(t => t !== todo);
}

4.2 构建用户界面

现在我们创建了所有的状态,接下来需要编写用户界面,这里使用了 Preact。

function TodoList() {
  const onInput = event => (text.value = event.target.value);

  return (
    <>
      <input value={text.value} onInput={onInput} />
      <button onClick={addTodo}>Add</button>
      <ul>
        {todos.value.map(todo => (
          <li>
            {todo.text}{' '}
            <button onClick={() => removeTodo(todo)}>❌</button>
          </li>
        ))}
      </ul>
    </>
  );
}

到这里,一个完整的 TodoList 就已经完成了,你可以在这里体验完整的功能。

4.3 衍生状态

在 TodoList 里面有一个常见的场景,那就是展示已完成事项数量,这个要怎么去设计状态呢?

相信你的第一反应肯定是 Mobx 或者 Vue 的衍生状态,刚好在 Signals 里面也有。

import { signal, computed } from "@preact/signals";

const todos = signal([
  { text: "Buy groceries", completed: true },
  { text: "Walk the dog", completed: false },
]);

// 基于其他 signals 创建衍生 signal
const completed = computed(() => {
  // 当 todos 变化,这里会自动重新计算
  return todos.value.filter(todo => todo.completed).length;
});

console.log(completed.value); // 1

4.4 管理全局状态

到目前为止,我们都是在组件树之外创建了 signal,对于小型应用来说没什么问题,但对于大型复杂应用来说,测试会比较困难。

因此,我们可以将 signal 提升至最外层组件里面,通过 Context 进行传递。

import { createContext } from "preact";
import { useContext } from "preact/hooks";

// 创建 App 状态
function createAppState() {
  const todos = signal([]);

  const completed = computed(() => {
    return todos.value.filter(todo => todo.completed).length
  });

  return { todos, completed }
}

const AppState = createContext();

// 通过 Context 传递给子组件
render(
  <AppState.Provider value={createAppState()}>
    <App />
  </AppState.Provider>
);

// 子组件接收后使用
function App() {
  const state = useContext(AppState);
  return <p>{state.completed}</p>;
}

4.5 管理局部状态

除了直接通过 signals 来创建状态,我们也可以使用提供的 hooks 来创建组件内部状态。

import { useSignal, useComputed } from "@preact/signals";

function Counter() {
  const count = useSignal(0);
  const double = useComputed(() => count.value * 2);

  return (
    <div>
      <p>{count} x 2 = {double}</p>
      <button onClick={() => count.value++}>click me</button>
    </div>
  );
}

useSignal 的实现是基于 signal 的,原理比较简单,利用了 useMemo 来对 signal 进行缓存,避免更新时重新创建了新的 signal

function useSignal(value) {
    return useMemo(() => signal(value), []);
}

4.6 订阅变化

从前面的例子里面可以注意到,在组件外访问 signal 的时候,都是直接读取它的值,并不涉及到响应值的变化。

在 Mobx 里面提供了 autoRun 来订阅值的变化,signal 里面提供了 effect 方法来订阅。

effect 接收一个回调函数作为参数,当回调函数中依赖的 signal 值发生了变化,这个回调函数也会被重新执行

import { signal, computed, effect } from "@preact/signals-core";

const name = signal("Jane");
const surname = signal("Doe");
const fullName = computed(() => `${name.value} ${surname.value}`);

// 每次名字变化的时候就打印出来
effect(() => console.log(fullName.value)); // 打印: "Jane Doe"

// 更新 name 的值
name.value = "John";
// 触发自动打印: "John Doe"

effect 执行后会返回一个新的函数,用于取消订阅。


const name = signal("Jane");
const surname = signal("Doe");
const fullName = computed(() => name.value + " " + surname.value);

const dispose = effect(() => console.log(fullName.value));

// 取消订阅
dispose();

// 更新 name,会触发 fullName 的更新,但不会触发 effect 回调执行了
name.value = "John";

在极少情况下,你可能需要在 effect(fn) 里面更新 signal,但又不希望在 signal 更新时重新运行,所以可以使用 .peek() 来获取 signal 但不订阅。

const delta = signal(0);
const count = signal(0);

effect(() => {
  // 更新 count 但不订阅变化
  count.value = count.peek() + delta.value;
});

delta.value = 1;

// 不会触发 effect 回调函数重新执行
count.value = 10;

4.7 批量更新

有时候我们可能会同时有多个更新,但又不希望触发多次更新,所以需要像 React 的 setState 一样合并更新。

Signals 提供了 batch 方法允许我们对 signal 进行批量更新。

以我们创建待办事项、清空输入框为例:

effect(() => console.log(todos.length, text.value););

function addTodo() {
  batch(() => {
    // effect 里面只会执行一次
    todos.value = [...todos.value, { text: text.value }];
    text.value = "";
  });
}

5. 总结

Signals 是 Preact 最近新出的特性,目前还不稳定,不建议在生产环境使用,如果想尝试,可以考虑在小型项目中使用。

下一篇文章将会从介绍 Signals 的实现原理,也会带领大家从零开始实现一个 Signals。

推荐阅读

  1. Introducing Signals
  2. Signals
  3. 使用 Solid
  4. 各流派 React 状态管理对比和原理实现