acfasj/blog

use-shared-state 源码阅读

acfasj opened this issue · 6 comments

背景

看了知乎「如何优雅地处理使用 React Context 导致的不必要渲染问题?」 的一个回答, 对里面提到的

一句话,react context是给你注入服务的,不是让你注入数据的,如果要注入具有数据的服务那你就注入个类似EventEmitter的东西,例如rxjs observable

感觉有被打到, 所以想看看答主是如何实践这一点的 (其实是我看回答里的例子还get不到, 只好看代码了orz)

版本是 v1.2.0, commit 是 7711f3

用法

首先看 github 里的 README

1. Share state between components

这个还比较好理解, 直接看 CodeSandbox 的这个实例会直接一点

2. Notify event between components

这里我就看不太懂了, 大致能理解是观察者模式, 但是又分什么 ChangeNotifier, ValueNotifier 是几个意思?

3. Create 'Controller' for controlling a component

没用过 flutter, 但是我可以理解为用 class 组织业务逻辑代码, 然后用 hooks 在组件里消费? 毕竟 hooks直接堆业务逻辑太难看了

看源码

好了, 带着上面的疑问开始看代码吧

首先拉代码下来, 果然只有 100 行的代码, 太开心了

先看index.ts:

import React, { useState, useRef, useCallback } from 'react';
import { ValueNotifier, useListen } from './listenable';
export * from './listenable';

export class SharedState<T> extends ValueNotifier<T> {}

export function useSharedState<T>(
}

导出了: SharedState, useSharedState, 以及 listenable 里的全部东西

useSharedState

image

先看input, 作者已经说得很清楚了:

  1. sharedState, 要共享的状态, 是 class SharedState 是实例
  2. shouldUpdate, 类似于 shouldComponentUpdate

然后看return, 返回的是一个 tuple : [sharedState.getValue(), setSharedState]

  1. sharedState.getValue(), 先不理, 反正就是共享的状态值

  2. setSharedState

    const setSharedState = useCallback(
      (v: React.SetStateAction<T>) => {
        sharedState.setValue(v);
      },
      [sharedState],
    );

    好家伙, 就是包装了一下 sharedState.setValue

  3. 同时我们注意到中间的一句 useListen(sharedState, listener); 比较显眼

一顿分析猛如虎之后, 我们可以得出两个关键:

  1. SharedState 也就是 ValueNotifier 到底是干嘛的 (因为代码里 SharedState 只是单纯地继承了 ValueNotifier 而已)
  2. useListen 是什么鬼

ValueNotifier

进到 listenable.ts

image

好吧, ValueNotifier 又是继承了 Listenable, 那就先看看 Listenable

image

很简单, 就是一个类里面有个成员变量是数组

  • hasListener, 数组长度是否大于0
  • addListener, 数组push一个元素, 显然这里的元素就是listener
  • removeListener, 数组删除一个元素

那就可以来看 ValueNotifier 的代码了:

image

首先可以看到, Listenable的范型参数被限定为了 ValueListener, 是一个函数, 这个函数的两个参数分别是current 和 prev;

成员变量只有一个构造函数传进来的value, 这个value 的类型就是 ValueNotifier 的范型

接下来看方法:

  • getValue, 返回 value
  • setValue, 做了两个事情, 一个是改变this.value, 另外是调用this.notifyListeners
  • notifyListeners, 接受value前后两个值, 通知所有订阅者, 也就是 Listenable 里数组里的所有函数

emmm, 看完了, 代码我都看得懂啊, 好像还是没get到什么, 接着看吧

useListen

image

这个一眼就看明白了啊, 就是 useEffect 里 addListener 和 cleanup 的时候 removeListener 而已啊

回过头看useSharedState

image

有了之前的阅读理解,同时结合一下 useSharedState 的实际用法 https://codesandbox.io/s/mystifying-cray-x2gcp?fontsize=14&hidenavigation=1&theme=dark&file=/src/App.tsx

可以发现, useSharedState 所做的事情, 其实大部分就是ValueNotifier做的事情: 在setValue的时候通知 listener. 同时它加了一点料, 让我们setValue的时候可以更新 React 组件:

image

emmm, 好像通了! 🧐

解答用法疑问

Notify event between components

先看看 ChangeNotifier 是什么鬼

image

ok, 就是 ValueNotifier 的精简版

所以本质上REAME的那个示例, 就是个类似于 element.dispatchEvent(new Event('change')) 或者 eventEmitter.emit('change') 之类的东西而已, 没什么好纠结的 🤣

Create 'Controller' for controlling a component

这里我觉得README的例子好像不太好, 结合作者的回答, 应该是想把Controller的实例当成 Context.Provider 的value, 这样就可以跨组件共享Controller实例

然后我尝试把 redux 的 todos 的 example 改用这里的库实现了一下, 应该会让 Controller 的用法清晰一点 https://codesandbox.io/s/friendly-sid-f2ifw?file=/src/use-todos-service.js

好了, 写完这些我觉得我已经能get到答主讲的大部分内容了 👏

单元测试

既然都看了源码, 那么单测也不能放过啊

useSharedState

源文件位于 src/__tests__/index.test.tsx

image

这里用的测试框架是jest, 辅以官方的 ReactTestUtils 来操作dom

可以看到, 在beforeEachafterEach做了container的清理和销毁, 因为每一个测试都会用 ReactDOM.render 把组件渲染在 container 上

这里作者测试了3个case, 一一看吧:

normal using

代码比较长就不贴了, 这里和demo其实很像:

  1. 有一个数字的sharedState

  2. 在三个组件里useSharedState: A组件永远更新; B组件用不更新; C组件只有前后的值加起来等于3的时候才更新

  3. 依次用js触发这三个按钮的点击时间, 断言渲染的值是否符合预期

值得注意的是, 触发button的点击事件, 都放在了 ReactTestUtils.act

参考 https://reactjs.org/docs/testing-recipes.html#act 这里面的定义, 可以理解为, 我们把触发事件、重新渲染组件等操作放在act 里的话, ReactTestUtils 能保证在后面代码的执行里, 这些操作的变更都已经应用在DOM上, 方便我们进行断言

on shouldUpdate change

主要是动态改变useState的第二个参数, 然后断言看它是否有用组件重新渲染

回想一下我们之前看 useSharedState的代码

  shouldUpdateRef.current = shouldUpdate;

我们可是在每一次render里, 都把这个 shouldUpdate 存进 shouldUpdateRef.current 里的哈哈

updating in useEffect

这个用例不太明白为什么这么设计, 是要检测一下 setState 的引用有没有变, 会不会无限循环触发 useEffect 吗?

ChangeNotifier

源文件位于 src/__tests__/listenable.test.tsx

notify listeners

利用了一个全局变量flag(相对), 通过notifyListeners修改他的值来进行断言

ValueNotifier

源文件位于 src/__tests__/listenable.test.tsx

get & set value

断言 getValue 和 setValue 是否正常

listeners

利用了一个全局变量(相对), 通过断言它的值, 来判断 addEventListener 和 removeEventListener 是否正常

useListen

源文件位于 src/__tests__/listenable.test.tsx

normal using

这里的设计, 比较像完全没封装 useSharedState, 手动地调用他们来完成断言的测试

end

自己本身没有太多单测经验, 更多还是学习, 看看这些用例是怎么设计的.而且单元测试用例本身也是文档的一部分, 看清楚也会比较明白作者到底想用这个库做一些什么事情. 以后看别人代码应该也会花时间去看单测

因为我是站在有 Flutter 经验的角度去写的,所以可能会忽略了很多细节哈哈。controller 其实只是个存放 SharedState/Notifier 的 object 而已,方便把某个组件的所有共享状态打包起来,controller 里面甚至也可以包含其他 controller(例如用来控制本组件下的子组件)。另一个好处就像你说的,可以把 controller 的实例用 context 共享出去,这样就不用共享一堆 SharedState 了。

最后,感谢你的分析和对 README 的批评,我准备重新计划下怎么完善 README 和例子了🤔
(另外,你分析得出的结论都是正确的~

@nekocode 感谢回复, 这个库有帮助到我打开思路~ (最后一句话激励作用很大哈哈哈

「是要检测一下 setState 的引用有没有变, 会不会无限循环触发 useEffect 吗?」
是的,我们要确保我们 hook 返回的 setState 引用不变,不然使用者在其他 hook 里去用 setState 可能回出问题。这也是为什么 setState 在返回前要 useCallback 包一遍的原因。

@nekocode 请教下作者
useSharedState里面的 onListen 为何要比较值,我看注释 If the state changed before the listener is added, notify the listener没想明白什么场景会发生这种情况?

@hardmanhong
具体可以看这个测试单元 https://github.com/nekocode/use-shared-state/blob/master/src/__tests__/index.test.tsx#L233

上图三个箭头处都用了 useEffect,这三个箭头的执行顺序刚好是一二三。当第二个箭头改变 shared state 后,第三个箭头才开始监听 shared state,所以此刻需要触发 Component2 重新渲染来获取最新的值。

@hardmanhong
具体可以看这个测试单元 https://github.com/nekocode/use-shared-state/blob/master/src/__tests__/index.test.tsx#L233

上图三个箭头处都用了 useEffect,这三个箭头的执行顺序刚好是一二三。当第二个箭头改变 shared state 后,第三个箭头才开始监听 shared state,所以此刻需要触发 Component2 重新渲染来获取最新的值。

懂了,谢谢