看了 ssh 大佬的一篇文章,觉得挺有意思,刚好最近也在研究全局数据流方案,因此根据大佬的思路自己也尝试了一下。
vue-next
是 Vue3
的源码仓库,Vue3
采用 lerna
做 package
的划分,而响应式能力 @vue/reactivity
被划分到了单独的一个 package
中。
这个包提供了几个核心 api :
effect
effect
是一个观察函数,它的作用是 收集依赖
。effect
接受一个函数,这个函数内部对于响应式数据的访问都可以收集依赖,在响应式数据更新之后,就会触发响应的更新事件。
reactive
响应式机制核心 api ,将传入的对象转换为 proxy
,劫持上面所有属性的 getter 、setter 等方法,从而在访问数据的时候收集依赖(也就是 effect
函数),在修改数据的时候触发更新。
ref
reactive
函数可以将对象转换为响应式,不能转换基本类型,而 ref
函数可以转换基本类型,原理就是将基本类型用对象包装了一下,ref(0)
相当于 reactive({value: 0})
。
computed
计算属性,依赖值更新以后,它的值也会随之自动更新。其实 computed
内部也是一个 effect
。
实现思路
从这几个核心 api 来看,只要 effect
能接入到 React 系统中,那么其他的 api 都没什么问题,因为它们只是去收集 effect
的依赖,去通知 effect
触发更新。
effect
接受的是一个函数,而且 effect
还支持通过传入 schedule
参数来自定义依赖更新的时候需要触发什么函数。
而 rxv 的核心 api: useStore
接受的也是一个函数 selector
,它会让用户自己选择在组件中需要访问的数据。
因此思路就显而易见了:
- 把
selector
包装在effect
中执行,去收集依赖; - 指定依赖发生更新时,需要调用的函数是当前正在使用
useStore
的这个组件的useUpdate
强制渲染函数;
store.ts
代码如下:
import * as React from 'react';
import { useEffection, useUpdate } from './shared';
// 全局 Context 对象
const StoreContext = React.createContext<any>(null);
export const useStore = <T, S>(selector: (store: T) => S): S => {
// 强制组件渲染函数
const forceUpdate = useUpdate();
// 在组件中获取到全局上下文
const store = React.useContext(StoreContext);
// 使用 selector 访问 store 中的数据进行依赖收集
// 修改数据的时候触发 scheduler 调用 forceUpdate 重新渲染组件
const effection = useEffection(() => selector(store), {
scheduler: job => {
if (job() === undefined) return;
forceUpdate();
},
lazy: true,
});
const value = effection();
return value;
};
export const Provider = StoreContext.Provider;
再来看一下 useUpdate
和 useEffection
的实现:
import { effect, ReactiveEffect, stop } from '@vue/reactivity';
import { useCallback, useEffect, useRef, useState } from 'react';
// 强制渲染函数
// 使用了 ahooks 的实现方案
export const useUpdate = () => {
const [, setState] = useState({});
return useCallback(() => setState({}), []);
};
// 对 effect 方法的封装
export const useEffection = (...effectArgs: Parameters<typeof effect>) => {
// 用一个ref存储effection
// effect函数只需要初始化执行一遍
const effectionRef = useRef<ReactiveEffect>();
if (!effectionRef.current) {
effectionRef.current = effect(...effectArgs);
}
// 卸载组件后取消effect
const stopEffect = () => {
stop(effectionRef.current!);
};
useEffect(() => stopEffect, []);
// 上面的写法相当于
// useEffect(() => {
// return () => {
// stop(effectionRef.current!);
// }
// }, [])
return effectionRef.current
};
使用方式
在 src
目录下建一个 store
目录,里面创建 index.ts
文件,内容如下:
import { computed, reactive } from '@vue/reactivity';
export type State = {
count: number;
name: string;
age: number;
users: string[];
plusOne: number;
fullName: string;
}
// 导出 state
export const state: State = reactive({
count: 0,
name: "dby",
age: 12,
users: ['dby', 'db', 'dm'],
plusOne: computed<number>(() => state.count + 1),
fullName: computed<string>(() => state.name + "dm")
});
// 导出 mutation
export const mutations = {
add: () => state.count += 1,
changeName: () => state.name = "dbydm",
changeAge: () => state.age += 1,
changeUser: () => state.users[1] = 'dbydm'
};
在 App.tsx
中,将 state
的内容传给 Provider
:
import * as React from 'react';
import BaseInfo from './components/BaseInfo';
import { Provider } from './rxv/store';
import { state } from './store';
function App() {
return (
<Provider value={state}>
<BaseInfo />
</Provider>
)
}
export default App;
然后在 BaseInfo
组件里面消费 state
的数据:
import * as React from 'react';
import { useStore } from '../rxv/store';
import { mutations, State } from '../store';
const BaseInfo: React.FC<{}> = () => {
const state = useStore(({ name, age, fullName }: State) => {
return { name, age, fullName };
})
return (
<>
<div>姓名:{state.name}</div>
<div>年龄:{state.age}</div>
<div>Name: {state.fullName}</div>
<div>
<button onClick={mutations.changeName}>Change Name</button>
<button onClick={mutations.changeAge}>Change Age</button>
</div>
</>
)
}
export default React.memo(BaseInfo);
优点
直接引入 @vue/reacivity
,完全使用 Vue3
的 reactivity
能力,拥有 computed
, effect
等各种能力,并且对于 Set
和 Map
也提供了响应式的能力。
之前也尝试过使用 useContext
和 useReducer
实现状态管理,但是 useReducer
是直接替换闭包中的值,导致一旦状态发生变更,Provider
下的所有组件都会重新渲染,哪怕没有消费 Context
下的任何信息。这种情况下,组件需要使用 React.memo
避免不必要的渲染(仅对消费了 Context
信息的组件进行渲染)。
而使用 rxv 可以实现组件级别的精确更新。例如 BaseInfo
和 Wrapper
是兄弟组件关系,都处于 Provider
的组件树下面,当 BaseInfo
消费的状态发生变化,如果 Wrapper
没有消费这些状态,即使没有用 React.memo
包裹也不会重新渲染。但如果 BaseInfo
和 Wrapper
是父子组件关系,BaseInfo
状态发生变化,Wrapper
不用 React.memo
包裹还是会重新渲染。
缺点
现在存在两个问题:
一个是 reactive
中定义 computed
之后,无法获取返回类型,只能手动声明。
另外一个是依赖收集全靠 useStore
,所以 selector
函数一定要精确的访问到你关心的数据,如果是数组类型,就必须访问数组的每一项进行依赖收集,否则使用数组变更方法或者通过下标修改数组不能触发组件重新渲染。
主要是第二个问题影响比较大,前后端交互的数据经常会涉及到嵌套结构,那么为了实现精确的依赖收集,就需要在 effect
函数里面把这些数据都访问一遍,例如 ssh 大佬给了一个方案:
function Logger() {
const logs = useStore((store: Store) => {
return store.state.logs.map((log, idx) => (
<p className="log" key={idx}>
{log}
</p>
));
});
return (
<Card hoverable>
<h1>控制台</h1>
<div className="logs">{logs}</div>
</Card>
);
}
原本列表渲染属于视图层逻辑,现在为了进行依赖收集,不得不放到 useStore
中进行了。个人认为其实也可以不用这种方式,只要在 effect
函数里面对嵌套的数据进行递归访问,也是可以实现依赖收集的。后期可以在这个基础上改进。