umijs/hox

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 解决,但是又会导致包体积较大。
---- 这里不是准备分出几个平台的包,来按需引入吗?

  1. 无法和 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 了

PR 已提 #90,欢迎大家来 review~

加入 Context 后,hox@v2 多了对局部共享 store 的支持,自由划定共享数据的界限,很 nice

确实会产生 Provider 嵌套问题,对此目前建议的方案为 HoxRoot,使用体验类似 v1

不过这个模式相当于强制全局共享了,也丢失了对局部状态共享的支持

另外,Provider 的嵌套衍生的问题是,共享模块之间会有依赖顺序关系的限制

<AProvider>
  <BProvider>
      只能 B 依赖 A,无法反向依赖
  </BProvider>
</AProvider>

这个依赖顺序限制与 v1 的顺序无关有所不同,虽然新的优势为可以避免依赖产生的问题,但一定程度上也降低自由度

循环依赖的问题个人理解留给用户考虑更好一些,hox 可以做一些循环更新检测的告警和阻断,但机制上允许循环依赖

结合以上和目前的 v2 文档,个人感觉有下边的这些问题

  1. HoxRoot + createGlobalStore 丢失了对局部状态共享的支持
  2. 局部 store 的嵌套问题衍生了依赖顺序问题,行为与 HoxRoot 中的 store 不一致
  3. 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>
)

以上设计有如下收益

  1. 解决了局部 store 的上下文嵌套问题
  2. 局部 store 间依赖顺序无关,保持与 HoxRoot 一致
  3. 特定的 hoxContext 可以作为 HoxRoot 的底层实现
  4. 版本使用方式变更不大,除了对 createModel 的重命名,仅新增了 HoxRoot, createHoxContext 与 .Provider 设计

@CJY0208 我来回复一下~

HoxRoot + createGlobalStore 丢失了对局部状态共享的支持

可能是我文档没写清楚,其实局部状态和全局状态并不是二选一的,用户可以同时 createGlobalStorecreateStore,当然,会有一点限制:局部状态可以依赖全局状态,但是全局状态不能依赖局部状态。

globalStore 和局部 store 的 create 方法不一致,且方法返回不一致,有认知成本

这一点我觉得有利有弊,创建 store 存在两个函数,的确有认知成本,但是带来的好处是更加明确,用户也能很清楚的分辨出哪些是全局状态哪些是局部状态,甚至在做代码搜索的时候,直接搜索 createGlobalStore 就能搜索到全部的全局状态,而如果只是通过一个参数来控制,那么就不太方便做检索了。此外,createGlobalStoreoptions 参数和 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,createGlobalStorecreateStore 区分的意图理解了,不过 createStore 的返回值和 createGlobalStore 不同还是不太理解

赞成 <BatchStoreProvider > 做法,相对预先声明 context,这个设计的意图更清晰

这种做法应该可以实现依赖顺序无关,之前我做过类似的事情

createStorecreateGlobalStore 返回值不同确实有点容易让人迷惑,我在写的时候也感觉到这个问题了,不过毕竟 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 已发