阅读 266

如何避免useContext重渲染

背景

hook中可以使用useContext进行状态管理,具体代码如下:
使用createContext创建一个Context对象
在子组件中使用useContext进行消费

export const AppContext = React.createContext();
复制代码

父组件中使用createContext创建的Context.Provider。这个组件允许消费组件订阅Context的变化。当 Provider 的 value 值发生变化时,它内部的所有消费组件都会重新渲染

// parent.jsx
const [position, setPosition] = useState({
    left: 0,
    top: 0,
});
const change = () => {
    setPosition({
        left: position.left++,
        top: 0
    })
}
return (
    <AppContext.Provider value={position}>
        <button onClick={change}>change<button>
        <Child />
    </AppContext.Provider>
)
复制代码

在子组件中消费Context值的变化

// child.jsx
const store = useContext(AppContext);
return <span>top: {store.top}; random: {Math.random()}</span>
复制代码

在上面代码中child就能够消费在parent中定义的值。
但是上面这么做存在一个问题,在child组件中我们依赖了store.top的值,但是在父组件更改left值的时候,random值也跟着更改,表示这个组件被多次渲染。这显然是不合理的,那有没有一种办法可以实现这个子组件依赖Context中某个值,当这个值发生改变时组件才重新渲染,其他值发生改变,并不会导致子组件的重新渲染。 大概思路如下:

const store = useStore(['left']);
return <span>child</span>
复制代码

在上面我们只需要storeleft的值,当left值发生改变时,组件才开始重新渲染。storetop的更改不会导致组件的重渲染

使用React.memo

React.memo可用于props的变更检查,当props没有发生改变时,那么该组件就不会渲染,那么Child组件可拆分为两个组件

const InnerChild = React.memo(({ top }) => (
  <span>
    top: {top}; random: {Math.random()}
  </span>
));
function Child() {
  const store = useContext(AppContext);
  return <InnerChild top={store.top} />;
}
复制代码

使用React.memo包裹组件,因为父组件只会更改left的值,所以top始终不会发生更改,那么当前组件也就不会重新渲染

使用useMemo进行缓存组件

把创建函数和依赖项数组作为参数传入useMemo,它仅会在某个依赖项改变时才重新计算memoized值。那么就可以把上面的子组件改为以下:

// child
 const store = useContext(AppContext);
 return useMemo(() => <span>random: {Math.random()}</span>, [store.top]);
复制代码

这样只有当store.top发生改变时,useMemo返回值才会发生改变。
但是这种方式也有一个弊端,当子组件需要维护大量的状态的时候,useMemo依赖项就需要写很多,就可能导致漏掉而导致状态更新了,DOM树没有更新。

拆分Context

这种思路借鉴于发布订阅者模式,发布者发布数据后,只会对其依赖数据的组件进行更新。
下面是具体的使用方式

const { Provider, useModal } = createModel((initState) => {
    const [count, setCount] = React.useState(0);
    const [value, setValue] = React.useState('value');
    const inc = function () {
        setCount(count + 1);
    };
    const dec = function () {
        setCount(count - 1);
    };
    return { count, inc, dec, value, setValue };
});
复制代码

然后在父组件中使用Provider进行提供值

function Parent() {
    return (
    <Provider>
        <Child />
        <Count />
    </Provider>)
}
复制代码

Provider中提供两个了两个组件,这两个组件分别依赖了Provider中不同的值,具体如下:

const Child = () => {
  const { count, inc, dec } = useModel(['count']);
  return (
    <div>
      {Math.random()}
      <Button onClick={dec}>-</Button>
      <span>{count}</span>
      <Button onClick={inc}>+</Button>
    </div>
  );
};
const Counter = () => {
  const { value, setValue } = useModel(['value']);
  return (
    <div>
      {Math.random()}
      <input value={value} onChange={e => setValue(e.target.value)} />
    </div>
  );
};
复制代码

在上面代码中Count子组件只希望value值发生改变时,组件重新渲染,而Child组件只期望count值发生改变时,组件重新渲染。
先讲一下这种方式的思路,简单来说就是发布订阅者模式,Provider就是发布者,ChildCount就是订阅者。当Provider值发生改变时,需要通知所以订阅者进行更新。订阅者收到更新通知后,根据对比之前的值判断是否需要更新。

实现发布订阅者模式

首先需要实现一个简单的发布订阅者模式

class Subs {
    constructor(state) {
        this.state = state;
        this.observers = [];
    }
    add(observer) {
        this.observers.push(observer)
    }
    notify() {
        this.observers.forEach(observer => observer())
    }
    delete(observer) {
        const index = this.observers.findIndex(item => item === observer);
        if (index > -1) {
            this.observers.splice(index, 1);
        }
    }
}
复制代码

add方法用于往订阅列表中添加订阅者,notify就用通知所有订阅者进行更新

Provider

需要包装Provider,当提供的值发生更改时,需要通知所有的订阅者触发更新

function createModel(model) {
    const Context1 = createContext(null);
    const Context2 = createContext(null);
    const Provider = ({ initState, children }) => {
        const containerRef = useRef();
        if (!containerRef.current) {
            containerRef.current = new Subs(initState);
        }
        const sealedInitState = useMemo(() => initState, []);
        const state = model(sealedInitState);
        
        useEffect(() => {
            containerRef.current.notify();
        })
        return (
            <Context1 value={state}>
                <Context2 value={containerRef.current}>
                    {children}
                </Context2>
            </Context1>
        )
    }
    return {
        Provider
    }
}
复制代码

代码还是比较简单的,创建了两个Context,子组件如果需要向useModel(['count'])这么使用那么实际消费的是Context2提供的值,如果直接useModel()就直接消费Context1的值。

useModel

这个函数所需要实现的功能就是在子组件创建的时候把更新函数pushProvider的订阅列表中,具体代码如下:

const useModel = (deps = []) => {
    const sealedDeps = useMemo(() => deps, []);

    if (sealedDeps.length === 0) {
      return useContext(Context1);
    }
    
    const container = useContext(Context2);
    const [state, setState] = useState(container.state);
    const prevDepsRef = useRef([]);
    useEffect(() => {
        const observer = () => {
            const prev = prevDepsRef.current;
            const curr = getAttr(container.state, sealedDeps);
            if (!isEuqal(prev, curr)) {
                setState(container.state)
            }
            prev.current = curr;
        }
        container.add(observer)
        return () => {
            container.delete(observer)
        }
    }, [])
    return state;
}
复制代码

简单来说在调用的useModel的时候,发布者收集该依赖,然后当值发生更新触发observer函数,这个函数需要比对更改前和更改后的值是否发生更改,如果更改就重新设置state的值。最后返回这个state的值

这样按需加载更新组件就实现了

react-tracked

使用react-tracked也能实现按需更新组件,具体如下代码可参考react-tracked, 大概思路也是参考发布订阅者模式进行按需更新

欢迎各位小伙伴关注我的github,多多点赞ღ( ´・ᴗ・` )比心