hox v2 RFC
awmleer opened this issue · 24 comments
背景
在 hox v1 中,我们的实现方案是在应用的 React 组件树之外,额外创建一个组件树,以存储 hox 中的 model。然而,这种方案渐渐显露出较多的弊端:
- 对渲染环境的要求比较苛刻,强依赖 react-dom,SSR、RN、小程序都无法使用(#10 #11 #13)。即便可以通过 custom renderer 解决,但是又会导致包体积较大(#26)。
- 无法和 Context 配合使用,在某些场景下瓶颈非常明显(#20 #36)
- 生命周期不够完善,无法控制何时装载何时卸载(#12)
- 强制将数据存储到全局,无法支持局部状态
为了解决上述问题,在此 RFC 中,尝试将底层实现改为基于 Context。不过基于 Context 虽然可以解决上述全部问题,但也会存在一些新的弊端:
- API 较为复杂,特别是需要用户手动在组件树中添加 Provider
API
创建 model
import {createModel} from 'hox'
function useCounter() {
// ...
}
export const CounterModel = createModel(useCounter)
// 或
export default createModel(useCounter)
提供 model
import {CounterModel} from './counter.model.ts'
function App() {
return (
<CounterModel.Provider>
<div>
{/* ... */}
</div>
</CounterModel.Provider>
)
}
获取/订阅 model
import {useModel} from 'hox'
import {CounterModel} from './counter.model.ts'
function Foo() {
const counterModel = useModel(CounterModel)
return (
...
)
}
只读(对应 v1 API 中的 useXxxModel.data
)
<CounterModel.Provider ref={yourModelRef}> // 通过 ref 的方式获取
</CounterModel.Provider>
传参
<CounterModel.Provider startFrom={123}> // 通过 ref 的方式获取
</CounterModel.Provider>
interface Props {
startFrom: number
}
const CounterModel = createModel<Props>((props) => {
const [count, setCount] = useState(props.startFrom)
// ...
})
由于存在参数传递,需要给 createModel
增加一个 options.memo
参数来控制何时触发重渲染:
const CounterModel = createModel<Props>((props) => {
const [count, setCount] = useState(props.startFrom)
// ...
}, { // <- options
memo: true // 开启 memo 后,Provider 的重渲染逻辑和普通 React 组件被 memo 后的逻辑类似
})
如果语法较为复杂的话,可以考虑把 memo
的默认值设置为 true
,因为绝大部分场景下都是需要 memo 的。
其他
是叫 model 好还是叫 store 好?
有点没明白这个 ref 的使用方法,不知道是不是我理解的这样:
const Foo = () => {
const counterModelRef = useRef(null)
return (
<CounterModel.Provider ref={counterModelRef}> // 通过 ref 的方式获取
{counterModelRef.current.xxx}
</CounterModel.Provider>
)
}
如果是这样的话这个命名上会不会和 props.ref
冲突呢?
是不是可以搞一个 useReadonlyModel
来访问只读数据?感觉这样会精简一点
有点没明白这个 ref 的使用方法,不知道是不是我理解的这样:
const Foo = () => { const counterModelRef = useRef(null) return ( <CounterModel.Provider ref={counterModelRef}> // 通过 ref 的方式获取 {counterModelRef.current.xxx} </CounterModel.Provider> ) }
如果是这样的话这个命名上会不会和
props.ref
冲突呢?是不是可以搞一个
useReadonlyModel
来访问只读数据?感觉这样会精简一点
本来就是 forwardRef 的,何谈命名冲突呢。。
useReadonlyModel
这样的 API 可以加,但是未必有什么价值,因为相比于 ref,它毕竟是个 Hook,会受到各种限制
改成这样的api和constate这些用context的库有什么区别呢
1。即便可以通过 custom renderer 解决,但是又会导致包体积较大。
---- 这里不是准备分出几个平台的包,来按需引入吗?
- 无法和 Context 配合使用,在某些场景下瓶颈非常明显
---- 是否可以提供一个createModelWithContext的方法,把context也注入到额外的组件树中;个人感觉useRequest这样的副作用不应该放在model中,model提供状态和方法就可以了
手动添加Provider?那对于数量不确认的叶子节点呢?
v2 啥时候出,不能配合 context 很恼火,react-router 里的 hooks 都不能用
这样的改动违背了最初的设计理念,不叫modal了确实叫store了,称不上是下一代的状态管理库了,和其它context的库有什么区别呢?
从V2 整体 更新点 和 解决的问题来看
基于context 加上 provider 有点 unstate-next化了。
保持原样,要解决这些问题,又有点向 recoil靠拢了..
喜欢Hox的地方在于 直接使用Hook 处理传递数据,类似订阅的方式,同步状态..
不管v2怎么样,我觉得保持API简洁,初心不丢最主要...要不 和其他的两个相比,要差异化竞争就比较困难了...
看了下 unstated-next API,感觉我们就是多了一个 memo,其它的几乎一样。
我给个极简思路,我们只在 v1 基础上提供一个 Provider,如果用户使用了 Provider,我们就把 model 挂在这个 Provider 上。如果没有提供,则保持 v1 的逻辑。
看了下 unstated-next API,感觉我们就是多了一个 memo,其它的几乎一样。
我给个极简思路,我们只在 v1 基础上提供一个 Provider,如果用户使用了 Provider,我们就把 model 挂在这个 Provider 上。如果没有提供,则保持 v1 的逻辑。
保持v1的逻辑,context #20 #36 的问题 怎么解决? unstat-next的 provider后 API使用比较不雅,如果和recoil 类似,只在root根上加个provider, 其余仍保持 v1的 api 是可以接受的。
仔细和recoil使用场景对比了下,recoil能解决的问题,hox基本能解决,recoil和react原生配合的更默契些,比如和suspense的使用,API较复杂,学习成本较高。hox在逻辑复用上要更方便,API简单,学习成本较低。
对比后主要担心以下一点:
v2 不知道能不能平稳支持 Concurrent Mode 或者是 即将到来的17版的一些其他特性。
Context方式实现的化,Provider好像必不可少啊,添加 Provider的写法很啰嗦,再想想看还有没有别的办法吧。
既然基于Context,为什么我不直接用Context,兜兜转转又回来了?
v2 看起来真的还不如 unstated-next 来的实在,还简单快捷;
可以考虑 zustand 的方式,不过 zustand 没有默认值传递,也不能和其它 hooks (比如 useRequest )一起用;
希望能参考一下其优点;
相比起redux-like的解决方案,我现在反而更喜欢jotai/recoil这种
如果应用的最外层加一个 <HoxRoot>...</HoxRoot>
,就可以支持创建全局 store 了
加入 Context 后,hox@v2 多了对局部共享 store 的支持,自由划定共享数据的界限,很 nice
确实会产生 Provider 嵌套问题,对此目前建议的方案为 HoxRoot,使用体验类似 v1
不过这个模式相当于强制全局共享了,也丢失了对局部状态共享的支持
另外,Provider 的嵌套衍生的问题是,共享模块之间会有依赖顺序关系的限制
<AProvider>
<BProvider>
只能 B 依赖 A,无法反向依赖
</BProvider>
</AProvider>
这个依赖顺序限制与 v1 的顺序无关有所不同,虽然新的优势为可以避免依赖产生的问题,但一定程度上也降低自由度
循环依赖的问题个人理解留给用户考虑更好一些,hox 可以做一些循环更新检测的告警和阻断,但机制上允许循环依赖
结合以上和目前的 v2 文档,个人感觉有下边的这些问题
- HoxRoot + createGlobalStore 丢失了对局部状态共享的支持
- 局部 store 的嵌套问题衍生了依赖顺序问题,行为与 HoxRoot 中的 store 不一致
- globalStore 和局部 store 的 create 方法不一致,且方法返回不一致,有认知成本
针对这些问题,我理想中的 hox 大概是这样
import { createStore, HoxRoot, createHoxContext } from 'hox'
// 全局 store:依赖 <HoxRoot> 的创建,且有 .data 功能
const useGlobal = createStore(() => {...}) // 默认是全局
const jsx_global = (
<HoxRoot>
/* 内部可访问 useGlobal */
</HoxRoot>
)
// 局部 store - 定义方式 1:依赖 <useStore.Provider> 的创建,无 .data 功能
const useScoped_1 = createStore(() => { ... }, {
context: true // 声明为局部 store,内部自动创建上下文
})
const jsx_scoped_1 = (
<useStore.Provider>
/* 内部可访问 useScoped_1 */
</useStore.Provider>
)
// 局部 store - 定义方式 2:依赖 <customContext.Provider> 的创建,无 .data 功能
const customContext = createHoxContext()
const useScoped_2 = createStore(() => { ... }, {
context: customContext // 声明为局部 store 且指定上下文
})
const useScoped_3 = createStore(() => { ... }, {
context: customContext // 声明为局部 store 且指定上下文
})
const jsx_scoped_2 = (
<customContext.Provider>
/* 内部可访问 useScoped_2、useScoped_3,且两者可以相互依赖 */
</customContext.Provider>
)
以上设计有如下收益
- 解决了局部 store 的上下文嵌套问题
- 局部 store 间依赖顺序无关,保持与 HoxRoot 一致
- 特定的 hoxContext 可以作为 HoxRoot 的底层实现
- 版本使用方式变更不大,除了对 createModel 的重命名,仅新增了 HoxRoot, createHoxContext 与 .Provider 设计
@CJY0208 我来回复一下~
HoxRoot + createGlobalStore 丢失了对局部状态共享的支持
可能是我文档没写清楚,其实局部状态和全局状态并不是二选一的,用户可以同时 createGlobalStore
和 createStore
,当然,会有一点限制:局部状态可以依赖全局状态,但是全局状态不能依赖局部状态。
globalStore 和局部 store 的 create 方法不一致,且方法返回不一致,有认知成本
这一点我觉得有利有弊,创建 store 存在两个函数,的确有认知成本,但是带来的好处是更加明确,用户也能很清楚的分辨出哪些是全局状态哪些是局部状态,甚至在做代码搜索的时候,直接搜索 createGlobalStore
就能搜索到全部的全局状态,而如果只是通过一个参数来控制,那么就不太方便做检索了。此外,createGlobalStore
的 options
参数和 createStore
是不同的,例如 createGlobalStore
不支持手动选择是否 memo
、后面可能额外支持配置 lazy
是否懒加载,这些参数差异后面可能会比较大,如果把这两个函数合并成一个,感觉未必认知成本很低。
你提到了通过手动指明 context 的方式来实现,感觉是想解决局部状态一次性声明一批的这种情况?例如下面这种:
<AStore.Provider>
<BStore.Provider>
<CStore.Provider>
...
</CStore.Provider>
</BStore.Provider>
</AStore.Provider>
如果用 createHoxContext
的话,就可以简写成:
const fooContext = createHoxContext()
<fooContext.Provider>
...
</fooContext.Provider>
对于一些复杂情况(比如复杂页面中一连串声明五六个 store,我就遇到过这种情况),这样写应该会更简单,但是如果强制每次都得先创建一个 hox context,再创建 store,可能就有些繁琐了,而且,不利于 store 的细粒度组合,举个例子:
<AStore.Provider>
<BStore.Provider>
...
</BStore.Provider>
</AStore.Provider>
<AStore.Provider>
<CStore.Provider>
...
</CStore.Provider>
</AStore.Provider>
在页面 1 中,我希望把 A 和 B 组合起来使用,在页面 2 中,我希望把 A 和 C 组合起来使用,这种情况下,AStore 的 createStore
就很难写了,因为只能指明一个 context。
我在想 StoreProvider 嵌套地狱的这种情况肯定会存在的,如果要解决的话,倒是有另外一种思路,提供一个批量 Store 的语法糖:
<BatchStoreProvider members={[AStoreProvider, BStoreProvider]}>
...
</BatchStoreProvider>
和预先声明 context 的思路不同,BatchStoreProvider
是在使用 Provider 的时候再做组合的,这样就提供了更好的灵活性。
不过,就像你说的,相较于预先声明 context,自然这里就没有办法自动处理 Provider 之间的依赖顺序了:
局部 store 的嵌套问题衍生了依赖顺序问题,行为与 HoxRoot 中的 store 不一致
需要让用户手动按先后顺序排放好。或者也许有办法实现自动判断依赖顺序,但是比较难实现……?
其实我觉得依赖顺序倒还好,我在实际使用过程中(因为我之前大量使用了 reto),几乎没有因为依赖顺序而花费过心思,显示的声明 Store 的顺序也让我能够更清晰的看到整个状态树的脉络,反倒挺好的。
OK,createGlobalStore
与 createStore
区分的意图理解了,不过 createStore
的返回值和 createGlobalStore
不同还是不太理解
赞成 <BatchStoreProvider >
做法,相对预先声明 context,这个设计的意图更清晰
这种做法应该可以实现依赖顺序无关,之前我做过类似的事情
createStore
和 createGlobalStore
返回值不同确实有点容易让人迷惑,我在写的时候也感觉到这个问题了,不过毕竟 createGlobalStore
目前是只有一个返回值的,如果也强行包一层数组的话会不会有点奇怪……?
const [useFooStore] = createGlobalStore(...)
const [useBarStore, BarStoreProvider] = createStore(...)
这样设计 API 的话,倒是有个额外的好处,如果后面的版本 createGlobalStore
想增加一些额外的返回内容,就可以很方便的扩充了 🤔 这样来看的话,感觉也未尝不可
依赖了数组的解构,后续拓展可能也会受数组顺序限制😂
2-3 个的话,用数组解构还好,多了的话就比较难受了
也许可以把 .data
属性改成独立的 getFooStore
函数,例如这样:
const useFooStore = createGlobalStore(...)
useFooStore.data
// ⬇️
const [useFooStore, getFooStore] = createGlobalStore(...)
这样有些好处:
- 可以手动控制要不要暴露只读函数(其实我觉得意义也不太大?)
- 方便查找引用
- 更明确,不然有些 useXxxStore 可以
.data
,有些不能,可能会容易让人困惑
但也有坏处:
- 语法稍微繁琐一点
npm 包 v2.0.0-alpha.0
已发