介绍 Preact Signals

集群狂
• 阅读 2148

1. 什么是 Signals?

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

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

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

2. 为什么是 Signals?

2.1 状态管理的困境

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

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

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

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

介绍 Preact Signals

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

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

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

介绍 Preact Signals

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 属性的组件。

介绍 Preact Signals

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

介绍 Preact 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} 这个文本节点。
介绍 Preact Signals

💡 提示: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 状态管理对比和原理实现
点赞
收藏
评论区
推荐文章
blmius blmius
3年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
Wesley13 Wesley13
3年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
美凌格栋栋酱 美凌格栋栋酱
6个月前
Oracle 分组与拼接字符串同时使用
SELECTT.,ROWNUMIDFROM(SELECTT.EMPLID,T.NAME,T.BU,T.REALDEPART,T.FORMATDATE,SUM(T.S0)S0,MAX(UPDATETIME)CREATETIME,LISTAGG(TOCHAR(
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
Peter20 Peter20
4年前
mysql中like用法
like的通配符有两种%(百分号):代表零个、一个或者多个字符。\(下划线):代表一个数字或者字符。1\.name以"李"开头wherenamelike'李%'2\.name中包含"云",“云”可以在任何位置wherenamelike'%云%'3\.第二个和第三个字符是0的值wheresalarylike'\00%'4\
Wesley13 Wesley13
3年前
FLV文件格式
1.        FLV文件对齐方式FLV文件以大端对齐方式存放多字节整型。如存放数字无符号16位的数字300(0x012C),那么在FLV文件中存放的顺序是:|0x01|0x2C|。如果是无符号32位数字300(0x0000012C),那么在FLV文件中的存放顺序是:|0x00|0x00|0x00|0x01|0x2C。2.  
Wesley13 Wesley13
3年前
mysql设置时区
mysql设置时区mysql\_query("SETtime\_zone'8:00'")ordie('时区设置失败,请联系管理员!');中国在东8区所以加8方法二:selectcount(user\_id)asdevice,CONVERT\_TZ(FROM\_UNIXTIME(reg\_time),'08:00','0
Wesley13 Wesley13
3年前
PHP创建多级树型结构
<!lang:php<?php$areaarray(array('id'1,'pid'0,'name''中国'),array('id'5,'pid'0,'name''美国'),array('id'2,'pid'1,'name''吉林'),array('id'4,'pid'2,'n
Wesley13 Wesley13
3年前
Java日期时间API系列36
  十二时辰,古代劳动人民把一昼夜划分成十二个时段,每一个时段叫一个时辰。二十四小时和十二时辰对照表:时辰时间24时制子时深夜11:00凌晨01:0023:0001:00丑时上午01:00上午03:0001:0003:00寅时上午03:00上午0
Stella981 Stella981
3年前
Gstreamer1.16.2与Glib2 signals关键字冲突解决
报错:GDBusSignalInfosignalserror:expectedunqualifiedidbefore'public'.原因:signals为QT关键字.但是在glib2里把signals当做变量来使用,两者需要做兼容处理.解决方案:1.在xxx.pro文件加入
Python进阶者 Python进阶者
1年前
Excel中这日期老是出来00:00:00,怎么用Pandas把这个去除
大家好,我是皮皮。一、前言前几天在Python白银交流群【上海新年人】问了一个Pandas数据筛选的问题。问题如下:这日期老是出来00:00:00,怎么把这个去除。二、实现过程后来【论草莓如何成为冻干莓】给了一个思路和代码如下:pd.toexcel之前把这