@ivliu/react-signal
Signal(信号)是一种存储应用状态的形式,类似于 React 中的 useState()。但是,有一些关键性差异使 Signal 更具优势。Vue、Preact、Solid 和 Qwik 等流行 JavaScript 框架都支持 Signal。
那么react结合signal能产生什么样的火花,能解决什么问题呢?
Signal 是什么?
Signal 和 State 之间的主要区别在于 Signal 返回一个 getter 和一个 setter,而非响应式系统返回一个值和一个 setter。
useState() = value + setter
useSignal() = getter + setter
注意:有些响应式系统同时返回一个 getter/setter,有些则返回两个单独的引用,但思想是一样的。
我们拿solidjs举个例子,因为react-signal的api设计和solidjs保持一致
const Counter = () => {
const [count, setCount] = createSignal(0);
return (
<button onClick={() => setCount(count() + 1)}>{count}</button>
)
}
安装
using pnpm
pnpm add @ivliu/react-signal
using yarn
yarn add @ivliu/react-signal
using npm
npm install @ivliu/react-signal --save
用法
import { useSignal, useEffect, untrack } from '@ivliu/react-signal';
const App = () => {
// ? [getter, setter]
const [count, setCount] = useSignal(60);
// ? untrack count();
useEffect(() => {
setInterval(() => {
setCount(untrack(() => count()) - 1);
}, 1000);
});
// ? auto track count();
useEffect(() => {
console.log('effect', count());
return () => console.log('destroy', count());
});
// ? useEffect with undefined deps
useEffect(() => {
console.log('update');
}, null);
return <div>{count()}</div>;
};
调试
# 安装依赖
pnpm install
# 运行
npm start
# 进入example
cd example
# 安装依赖
pnpm install # or yarn
# 运行
npm start
打开http://localhost:1234,即可查看,也可更改example/index.tsx来体验
react hooks的问题
提起react hooks,我们作为开发者可以说是又爱又恨,爱的是它可以让函数组件拥有类组件的功能,从而更方便地管理组件状态,同时在逻辑复用上相较于HOC或者render props更简单更轻量。恨的是它带来了一些心智负担,尤其是闭包和显式依赖问题。
react-signal在一定程度上可以解决这些问题
API
react-signal使用useSignal代替useState,返回了getter和setter。
为了实现依赖自动追踪,我们重写了useEffect、useLayoutEffect、useInsertionEffect、useMemo、useCallback,且命名与react保持一致。
另外我们还提供了一些高级api,createSignal、untrack、destroy。
下面将会详细介绍每一个api。
useSignal
useSignal用于替换useState,它返回一个getter和setter。
import { useSignal, useEffect } from '@ivliu/react-signal';
function App() {
const [count, setCount] = useSignal(0);
useEffect(() => {
const handle = setTimeout(() => {
// 输出最新值10,而非初次访问的闭包值
console.log(count())
}, 1000);
return () => clearTimeout(handle);
})
// useEffect都不需要写依赖了
useEffect(() => {
setCount(10);
})
// 取值改为getter方式
return <div>{count()}</div>
}
如果signal初值初始化成本较高,那么你可以通过函数指定。
// new person仅会初始化一次
useSignal(() => new Person())
另外还可以用createSignal创建初始值,但是注意createSignal需要声明在组件外部。
import { createSignal, useSignal, useEffect } from '@ivliu/react-signal';
const externalSignal = createSignal(0);
function App() {
const [count, setCount] = useSignal(externalSignal);
useEffect(() => {
const handle = setTimeout(() => {
// 输出最新值10,而非初次访问的闭包值
console.log(count())
}, 1000);
return () => clearTimeout(handle);
})
// useEffect都不需要写依赖了
useEffect(() => {
setCount(10);
})
// 取值改为getter方式
return <div>{count()}</div>
}
useReducer
import { useReducer, useEffect } from '@ivliu/react-signal';
function App() {
const [count, dispatch] = useReducer((prevValue) => prevValue + 1, 0);
// dispatch引用是稳定的,当需要对子组件缓存时很有效果
return <div onClick={dispatch}>{count()}</div>
}
useEffect
useEffect用于替换native useEffect,默认不需要填写依赖。执行时机和react effect一致
useEffect(() => {
/** count()会自动跟踪,count()发生变化时,effect函数会重新执行 */
console.log(count())
})
如果想实现等效native Effect不传依赖,即useEffect回调每次渲染都重新执行的效果的话,则依赖项需要显式传入null。
useEffect(() => {
console.log(count())
}, null)
useLayoutEffect、useInsertionEffect同理。
useCallback
const onClick = useCallback(() => {
console.log(count());
})
如果函数仅仅依赖signal的话,那么想实现一个引用稳定的函数将轻而易举,这是个附加的feature。
useMemo
function App() {
const [count, setCount] = useSignal(0);
const doubleCount = useMemo(() => {
return count() * 2;
});
return <div onClick={() => setCount(count() + 1)}>{doubleCount()}</div>
}
createSignal
createSignal是脱离react组件创建signal的方式,本意是为了和useSyncExternalStore更好的结合使用。
结合useSyncExternalStore
import { useSyncExternalStore } from 'react';
import { createSignal, useCallback } from '@ivliu/react-signal';
const store = createSignal({ theme: 'light' });
function App() {
const { theme } = useSyncExternalStore(
store.subscribe,
useCallback(() => store.value),
);
return <div onClick={() => store.value = { theme: 'dark' } }>{theme}</div>
}
结合useSignal
import { createSignal, useSignal, useEffect } from '@ivliu/react-signal';
const externalSignal = createSignal(0);
externalSignal.subscribe((value) => console.log(value));
function App() {
const [count, setCount] = useSignal(externalSignal);
useEffect(() => {
const handle = setTimeout(() => {
// 输出最新值10,而非初次访问的闭包值
console.log(count())
}, 1000);
return () => clearTimeout(handle);
})
// useEffect都不需要写依赖了
useEffect(() => {
setCount(10);
})
// 取值改为getter方式
return <div>{count()}</div>
}
同时我们可以用它做一些状态保持,比如最常见的页码保持。 我们有一个列表页,然后在某页进入详情,然后返回,我们肯定希望保持在对应页,利用createSignal就可以轻松实现,因为组件销毁的时候,状态仍然保持在内存里,组件再次挂载时访问的是缓存状态。
注意不要一个external signal供多个useSignal使用。
untrack
我们实现了effect依赖的自动追踪,那么我们不想追踪某些变量的话,我们可以用untrack包裹
useEffect(() => {
// 此时count()不会追踪,setInterval仅会设置一次
const handle = setInterval(() => {
setCount(untrack(() => count()) - 1);
}, 1000);
return () => clearInterval(handle);
});
destroy
先看个问题
function App() {
const [count, setCount] = useState(0);
const [person, setPerson] = useState({ name: '' });
const countRef = useRef(count);
countRef.current = count;
useEffect(() => {
// ? person.name每次更新,两次输出的值是否一致
console.log(countRef.current);
return () => console.log(countRef.current);
}, [person.name]);
return <input value={person.name} onChange={(e) => {
setPerson({ name: e.target.name });
}} />
}
揭晓答案,不一致。因为effect destroy函数是在下一次渲染执行的。
因为我们提供了destroy api,它用在native useEffect内部访问signal的情况。
// ! native useEffect
useEffect(() => {
// ? person.name每次更新,两次输出的值保持一致
console.log(count());
return destroy(() => console.log(count()))
}, [person.name]);
渐进接入
react-signal并非脱离react创造新概念,且和细粒度更新没什么关系,它仅仅提供了signal形式的api。 因为我们可以非常低成本的接入,且支持和native api混用。
import { useState, useEffect } from 'react';
import { useSignal, useEffect as useEffect2 } from '@ivliu/react-signal';
function App(props: { count3: number }) {
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useSignal(0);
useEffect(() => {
console.log(count1, count2(), props.count3);
}, [count1, count2, props.count3]);
useEffect2(() => {
console.log(count1, count2(), props.count3);
// state和props值无法自动追踪,需要显式声明依赖
}, [count1, props.count3]);
return <div onClick={() => {
setCount1(count1 + 1);
setCount2(count2() + 1);
}}>{count1 + count2() + props.count3}</div>
}
与useState的不同
在使用useSignal的时候需要注意和useState的不同
function App1() {
const [count, setCount] = useState(0);
return (
<p onClick={() => {
// 点击一次,count值加1
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
}}>{count}</p>
)
}
function App2() {
const [count, setCount] = useSignal(0);
return (
<p onClick={() => {
// 点击一次,count值加3,因为signal是稳定且可变的
setCount(count() + 1);
setCount(count() + 1);
setCount(count() + 1);
// 如果你想保持行为一致,你需要
// const current = count();
// setCount(current + 1);
// setCount(current + 1);
// setCount(current + 1);
}}>{count}</p>
)
}
todo
在native effect中我们可以自由控制监听的粒度,比如
// native effect
useEffect(() => { console.log(person) }, [person.name]);
但目前react-signal只能做到signal粒度的自动追踪,我们正在努力实现该feature。 如果你想实现类似效果,你可以暂时这样做。
useEffect(() => { console.log(untrack(() => person())) }, [person().name]);
贡献
请随时提交任何问题或请求请求。我将在最快的时间回复你。
License
MIT