react + signal = react-signal

2,028 阅读5分钟

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 '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# 进入examplecd 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>
}

#### 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更好的结合使用。

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>
}

todo

在native effect中我们可以自由控制监听的粒度,比如

```typescript

// native effectuseEffect(() => { console.log(person) }, [person.name]);

```

但目前react-signal只能做到signal粒度的自动追踪,我们正在努力实现该feature。

如果你想实现类似效果,你可以暂时这样做。

useEffect(() => { 
    console.log(untrack(() => person())) 
}, [person().name]);

github

github.com/IVLIU/react…

npm

www.npmjs.com/package/@iv…