Signal 是一种应用的状态管理方式,跟 React 的 useState
有点相似。
与 state 的不同,useState
返回了 value 和一个 setter,useSignal
返回 getter/setter 。
Signal 是响应式(Reactivity)的。这意味着我们可以对状态进行订阅,如果状态发生变化那么就通知订阅者。
而调用 getter 就会创建订阅,那么 Signal 就知道什么位置想要订阅状态了。
它是一个在访问时跟踪依赖、在变更时触发副作用的值容器。这种基于响应性基础类型的范式在前端领域并不是一个特别新的概念:它可以追溯到十多年前的 Knockout observables 和 Meteor Tracker 等实现。
响应式编程是一种可以使我们声明式地处理变化的编程范式。
这里我们举一个算术的例子
let a = 1;
let b = 2;
let sum = a + b;
console.log(sum); // 3
a = 2;
console.log(sum); // 仍然是 3
在正常的 JS 代码中,更新 a
并不会让 sum
重新计算。现在我们包装一个 update
函数,可以通过这个 update
函数来更新。
let sum;
function update() {
sum = a + b;
}
这里有一些术语定义:
update
函数会产生副作用(Side Effect)或者叫作用(Effect),它会更新程序中的值
a
和 b
被认为是这个作用的依赖(dependencies),这写值被用来执行这个作用。
这两个依赖可以被订阅,被这个作用订阅,那么我们说这个作用是这两个依赖的订阅者(subscriber)
现在我们需要有这么一个魔法函数 createEffect(update)
,它可以在 a
b
两个依赖发生改变时,自动的调用副作用 update
,那就可以完美实现我们这个响应式了。
我们换一个写法更加符合我们(在 react 中)的认知。
我们期望能够实现一个 createEffect
函数,帮助我们完成响应式。
const state = {};
state.a = 1;
state.b = 2;
createEffect(() => {
state.sum = state.a + state.b;
});
我们普通对象肯定无法完成这个“订阅”动作,我们使用 Proxy
当值进行了 set
就可以进行发布更新了。
我们的 Proxy 实现了正常的取值,设置值外,还增加了两个勾子函数。
const state = new Proxy(
{},
{
get(obj, prop) {
onGet(prop);
return obj[prop];
},
set(obj, prop, value) {
obj[prop] = value;
onSet(prop, value);
return true;
},
}
);
我们看看 createEffect
应该做些啥。我们将 effect 设置在了全局的 currentEffect
,随后立即执行 effect
let currentEffect = null;
function createEffect(effect) {
currentEffect = effect;
effect();
currentEffect = null;
}
这里我们定义了一个 propsToEffects
,用来存储每个属性的作用,a
有 a
的作用,b
有 b
的作用。
获取值的时候,我们就会把各自的副作用 push 进去。
const propsToEffects = {};
function onGet(prop) {
if (currentEffect) {
const effects = propsToEffects[prop] ?? (propsToEffects[prop] = []);
effects.push(currentEffect);
}
}
因为我们一开始就调用了 createEffect
,里面立刻调用了 effect()
,里面分别调用了 a
和 b
的 getter。
所以在示例代码执行完后,propsToEffects
的值为:
{
a: [effect];
b: [effect];
}
这里其实就是使用了 getter ,让 effect 成为了订阅者。
这时候我们想要 state.a = 2
,来触发 setter,并调用 effect ,怎么实现呢,就很简单了
我们看看怎么实现 onSet,其实已经很明显了,上面说了 propsToEffects
存储了对应的 effect,那就直接执行就好了。
现实开发中,我们可能多次调用 setter,为了避免频繁的数据更新,这里使用了微任务,在微任务被调用时才去触发刷新 flush
。使用微任务可以让我们“合并”更新,避免频繁的更新。
let queued = false;
const dirtyEffects = [];
function onSet(prop, value) {
if (propsToEffects[prop]) {
if (!queued) {
queued = true;
dirtyEffects.push(...propsToEffects[prop]);
queueMicrotask(() => {
queued = false;
flush();
});
}
}
}
function flush() {
while (dirtyEffects.length) {
dirtyEffects.shift()();
}
}
const state = new Proxy(
{},
{
get(obj, prop) {
onGet(prop);
return obj[prop];
},
set(obj, prop, value) {
obj[prop] = value;
onSet(prop, value);
return true;
},
}
);
const propsToEffects = {};
const dirtyEffects = [];
let currentEffect = null;
let queued = false;
function createEffect(effect) {
currentEffect = effect;
effect();
currentEffect = null;
}
function onGet(prop) {
if (currentEffect) {
const effects = propsToEffects[prop] ?? (propsToEffects[prop] = []);
effects.push(currentEffect);
}
}
function onSet(prop, value) {
if (propsToEffects[prop]) {
if (!queued) {
queued = true;
dirtyEffects.push(...propsToEffects[prop]);
queueMicrotask(() => {
queued = false;
flush();
});
}
}
}
function flush() {
while (dirtyEffects.length) {
dirtyEffects.shift()();
}
}
state.a = 1;
state.b = 2;
createEffect(() => {
state.sum = state.a + state.b;
});
console.log(11, { ...state });
state.a = 3;
console.log(22, { ...state });
Promise.resolve().then(() => {
console.log(33, { ...state });
});
上面的例子中,effect
还仅仅只是数据更新,要把这个 Signal 应用在 dom 更新中,或者说是 vdom 更新中,就需要加额外的内容。
下面是一个简单的示例,怎么更新 dom。
const container = document.getElementById('container');
createEffect(() => {
const dom = render(state);
if (container.firstElementChild) {
container.firstElementChild.replaceWith(dom);
} else {
container.appendChild(dom);
}
});
import { createSignal } from 'solid-js';
function Counter() {
const [count, setCount] = createSignal(1);
const increment = () => setCount(count() + 1);
createEffect(() => {
console.log('The count is now', count());
});
return (
<button type="button" onClick={increment}>
{count()}
</button>
);
}
createSignal()
分配一个 StateStorage
并将其初始化为 1。count()
是一个 getter ,获取到值setCount()
是一个 setter,修改值Solid 的 createSignal()
API 设计强调了读/写隔离。信号通过一个只读的 getter 和另一个单独的 setter 暴露。当我们不暴露 setter 那么意味着状态不可变。
import { component$, useSignal } from '@builder.io/qwik';
export default component$(() => {
const count = useSignal(0);
return <button onClick$={() => count.value++}>Increment {count.value}</button>;
});
虽然和 solid 的语法不一样,但是内部工作原理是一样的。
import { component$, useSignal } from '@builder.io/qwik';
export const Display = component$(({ count }: { count: number }) => {
console.log(`Display ${count}`);
return <div>{count}!</div>;
});
export function Counter() {
console.log('Counter');
const countA = useSignal(0);
const countB = useSignal(0);
return (
<div>
<button onClick$={() => countA.value++}>A</button>
<button onClick$={() => countB.value++}>B</button>
<Display count={countA.value} />
<Display count={countB.value} />
</div>
);
}
初始化的时候会打印
Counter
Display 0
Display 0
当你点击按钮 A 或这 按钮 B 时,不会有任何打印
(blank)
Qwik 的 API 设计跟 Preact、Vue 非常相似了,区别在于,Qwik 的 useSignal 只能用在组件内部,无法外部共享。
import { signal, computed } from '@preact/signals';
const count = signal(0);
const double = computed(() => count.value * 2);
function Counter() {
return (
<button onClick={() => count.value++}>
{count} x 2 = {double}
</button>
);
}
在 Preact 中,signal 在组件内部或者外部都可以使用。
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
React 中 useState
返回的是一个值,当你调用 setCount 的时候,React 并不知道页面的哪个部分被修改了,所以它必须整个组件重新 rerender。过程会显得比较昂贵。
function Counter () {
const count = useRef(0);
return (
<button onClick={() => count.current++1}>
{count.current}
</button>
)
}
React 中 useRef
用法跟 useSignal 很像了,但是它不会订阅跟踪和通知,也不会 rerender。
React 中的 useMemo 确实可以减少一些渲染,但相比于上述的 Qwik 章节中的 Counter 案例,会进行更多的 rerender。
初始化:
Counter
Display 0
Display 0
按钮点击(使用了 memo)
Counter
Display 1