2

Hooks 数据流浅析

 2 years ago
source link: https://zhuanlan.zhihu.com/p/380761708
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Hooks 数据流浅析

一棵需要定期修剪的树

语雀链接:Hooks 数据流 · 语雀

今天结合我的实践经验和大家探讨一下基于 Hooks 的几个数据流方案。

原始版本的数据流方案一般是 useReduceruseStateuseContext 的组合。一般用 useReducer 比较多,因为它自带了 Redux 的 reducer 功能,从维护性上来说比 useState 更好。

// reducer
const reducer = (action: Action, state: State) => {
  switch (action.type) {
    case 'increment':
      return {
        ...state,
        count: state.count + 1,
      };
    case 'decrement':
      return {
        ...state,
        count: state.count - 1,
      };
    case 'updateData':
      return {
        ...state,
        data: action.data,
      };
    default:
      return state;
  }
};


// 初始化
const DispatchContext = React.createContext(null);
const StoreContext = React.createContext(null);

const Parent = () => {
  const [store, dispatch] = useReducer(reducer, { count: 0, dataList: [] });
  
  return (
    <DispatchContext.Provider value={dispatch}>
      <StoreContext.Provider value={store}>
        <Child />
      </StoreContext.Provider>
    </DispatchContext.Provider>
  );
};

原始版完全没有上手成本可言,如果做一个轻量应用或者一个简单的局部数据流,原始版基本上就满足了基本需求了。这个使用方法和 Class Component 的 Redux 基本上没有任何区别,你会发现它并没有发挥出 Hooks 的优势 —— 抽象能力。

我希望把 count 和 count 相关的方法放一个模块,dataList 放在另一个模块。那么我可以用 custom hook 来实现:

const useCount = () => {
  const [count, setCount] = React.useState(0);

  const increment = React.useCallback(() => {
    setCount(c => c + 1);
  }, []);

  const decrement = React.useCallback(() => {
    setCount(c => c - 1);
  }, []);

  return { count, increment, decrement };
};

同样的 dataList 也是:

const useDataList = () => {
  const [dataList, setDataList] = useState([]);

  return {
    dataList,
    setDataList,
  };
};

然后用一个 hook 做收口:

const useStore = () => {
  const count = useCount();
  const dataList = useDataList();

  return {
    ...count,
    ...dataList,
  };
};


// 使用
const StoreContext = createContext(null);

const Parent = () => {
  const store = useStore();
  return (
    <StoreContext.Provider value={store}>
      <Child />
    </StoreContext.Provider>
  );
};

是不是感觉整体逻辑清晰了很多?当然它的代价就是 dispatch 和 state 放一起了,所以有些只用到 dispatch 的组件也会受到数据流更新的影响而导致重渲染。但是这种情况毕竟非常少数,实际上一般开销比较大的组件恰恰就是依赖了 state 的组件。无论是使用原始版还是进阶版都会有这个问题,下面说说可以用什么方式解决这种问题。

降低重渲染次数

1. useMemo / useEffect 细粒度依赖

比如解构语法用起来很爽,但是它会导致自动依赖收集不准确。对于开销比较大的 hook 可以注意一下是否有类似的问题。

// ❌ 这会导致组件频繁渲染
const store = useContext(StoreContext);

useEffect(() => {
  const { count } = store;
  // ... 使用 count
}, [store]);


// ⭕️ 尽量只依赖最小所需部分
const store = useContext(StoreContext);
const { count } = store;

useEffect(() => {
  // ... 使用 count
}, [count]);

这个问题看起来不值一提,其实看一下代码就知道,这是最容易犯下的小错误。

2. useRef 代替 useCallback

useCallback 依赖 store 的话,可能会导致当前组件频繁更新,这是一个很抓狂的问题,这时候唯一的解决方法就是把 useCallback 换成 useRef,一般不会有什么问题,虽然在 concurrent 模式下会有状态同步问题,但是 concurrent 模式还远着呢。不过它更容易引起一个非常常见 bug,下面举一个简单的例子:

// 封装 ref
const useCurrentValue = (value) => {
  const valueRef = useRef(value);
  
  useEffect(() => {
    valueRef.current = value;
  }, value);
  
  return valueRef;
}


const Child = () => {
  const {count, setCount} = useContext(StoreContext);
  
  // useRef 代替 useCallback
  // 真正的生产代码里,该方法的逻辑会非常复杂,这里做了极致简化
  const increment = useCurrentValue(() => {
    setCount(count + 1);
  });
  
  
  return (
    <button
      onClick={increment.current}
    >
      {count}
    </button>
  );
};

看得出上面的代码有什么问题吗?试一下就会发现,点击按钮的时候,得到的值永远是 1。

v2-b04845d67139ca43dfa868d6bb5530ff_b.jpg

这是因为 dom 没有重新挂载,所以 onClick 方法并没有随着 ref 更新而更新导致的。所以我们需要一直传递 ref 而不是传递 current:

const Child = () => {
  //...
  
  return (
    <button
      onClick={() => {
        increment.current();
      }}
    >
      {count}
    </button>
  );
};

可见这种做法一定程度上损失了一点代码的简洁性。

降低重渲染耗时

1. key 到底应该用 index 还是 id

操作频繁的长列表,如果以 index 为 key,删除了第 1 个元素,剩下的 dom 都得更新,因为 key 变化了。这种情况下应该用 id 作为 key,由于 key 不变,其他不受影响的 item dom 都不会更新。

2. 拆分 useMemo / 拆分组件

用 useMemo 把组件返回的 dom 包起来出发点是好的,但是依赖了 store 的话,useMemo 可能会名存实亡。
要做的就是每次 store 变化,更新的 dom 尽量少一点。那就把依赖 store 不同属性的子组件拆分开。拆分的前提是:子组件的层级尽量打平
比如:

// 因为 Tooltip 的重渲染开销比较大,尽量别让它重渲染
<Tooltip>
  <dom1 />
  <dom2 />
  <span>{timestamp}</span> // 依赖了频繁变化的 state
</Tooltip>

// 换成:
<Tooltip>
  <dom1 />
  <dom2 />
</Tooltip>
<span>{frequentlyUpdatedState}</span> // 依赖了频繁变化的 state

甚至把 dom3 拆成一个单独的组件:

// 父组件
<Tooltip>
  <dom1 />
  <dom2 />
  <Child />
</Tooltip>


const Child = () => {
  const { frequentlyUpdatedState } = useContext(StoreContext);
  
  return <span>{frequentlyUpdatedState}</span>
}

其实说的还是尽量让组件的功能单一化

最小的状态管理解决方案 unstated-next 在进阶版的基础上又封装了一层,可以减少使用数据流的胶水代码,当然了因为使用 Hooks 构建数据流的成本本来就很小,所以在我个人看来这个改善并不是特别的重要。另外一方面它也会遇到我所说的数据流频繁更新的问题,因此它的 readme 中也会有类似于上面的解决方法。

ps:写代码的时候时刻关注这些问题其实很烦,所以只推荐在比较简单的项目里用 unstated-next,我更喜欢的方式是更新 state 整个组件树直接重渲染 ,纯数据驱动,组件无需关心数据流的变化,真正的 pure render。

按需重渲染

当你发现你写代码的时候总是需要分出精力去为性能做妥协的时候, mobx 可能更适合你。

我自己是没有在正经的项目中使用过 mobx 的,因为团队的技术栈一直以来就是 Redux,不过最近因为比较闲,有意在调研 mobx。

之前做过一个复杂模块的性能优化,该模块使用的就是 context + useReducer 当局部数据流,因为两年前拿来练手 hooks 的项目,所以这个模块非常的天然无雕琢:hooks 直接依赖 store、一个组件几千行代码……该踩的雷区基本都踩了,导致性能问题非常严重。因为时间成本,当时没有更换数据流方案,只是做了一些优化。效果挺明显的,但还是不达自己的预期,因为使用 hooks 的时候很多时候总是需要为性能做出一些妥协,把一段本来挺优雅的逻辑搞的面目全非的,这里 ref 那里 ref……

最近调研 mobx 的时候,发现它简直太适合这种容易出现性能问题的小型应用了。

不用担心 mobx 跟 hooks 的集成问题, mobx 和 hooks 配合的非常好,贴一个官网的例子:

import { useObserver, useLocalStore } from 'mobx-react' // 6.x or [email protected]

function Person() {
  const person = useLocalStore(() => ({ name: 'John' }))
  return useObserver(() => (
    <div>
      {person.name}
      <button onClick={() => (person.name = 'Mike')}>No! I am Mike</button>
    </div>
  ))
}

可以看出使用起来和普通的 hook 没啥区别,而且因为内置的 hooks 比如 useRef、useCallback 都是拿来解决 immutable 的问题的,所以使用 mobx 的时候,基本上可以弃用这些内置的 hooks 了(本人觉得这是最大的爽点),既能一定程度上保持代码的简洁性,又能充分发挥 hooks 的逻辑复用能力。

ps:我认为学 hooks 的要点不在于学会这些内置 api 的用法,而在于其组合大于继承的编程方式。

再说一下可维护性的问题,mobx 最受人诟病的一点就是它太自由了,在多人协同的场景下很容易变得群魔乱舞,有时候要启用全局搜索大法才能知道触发源在哪,这种 debug 方式非常的低效,当然这是 mutable 的通病,没有一个 mutable 框架可以避免。所以我并不推荐大型复杂应用使用 mobx,因为这对开发者的能力要求非常非常高。

不过,虽然这个问题无法完全解决,但是可以缓解,mobx 的自由度可以通过一定的规范来约束。比如 store 中数据的修改全部通过内部的方法来操作,不要直接操作 store 的属性:

// ❌ 别酱
const store = useLocalStore(() => ({
  count: 0
})
                            
return <button onClick={store.count++}> + </button>



// ⭕️ 用 store 自己的方法修改状态值
const store = useLocalStore(() => ({
  count: 0,
  increment: () => store.count++,
  decrement: () => store.count--;
})
                            
return <button onClick={store.increment}> + </button>

在复杂的场景下,也可以增加业务自定义的打点来降低未来的维护成本,这是基于所有对 store 的操作都通过内置方法的前提,这里举一个比较简单的例子:

const callbackStack = [];

// 做一个简单的打点
function record(cbName, cb) {
  return (...args) => {
    callbackStack.push({
      cbName, 
      args,
    });
    return cb(...args);
  }
}

const store = useLocalStore(() => ({
  count: 0,
  increment: record('increment', () => store.count++),
  decrement: record('decrement', () => store.count--),
})
                            
return <button onClick={store.increment}> + </button>

因为 callback 的参数还是比较自由,更进一步可以抽象成 action 来进一步约束对 store 的操作,然后在结合 console.log(就是这么朴素)可以做一个非常低成本的 debug 工具了,如果有兴趣还可以自己搞一个 chrome 插件集成一个 debug 工具,补齐这方面的短板。

总体来说 mobx 是一个非常适合中型应用的数据流管理方案,对于非常复杂的应用有可能会失控,后续的可维护性是一个需要重点考虑的地方。

Redux

Redux 是一个比较适合大型应用的数据流方案,应该说一开始他的核心理念就是让状态变化可追溯、可维护,为了达到这么对于大多数中小型应用来说就是杀鸡用牛刀,这也是它收到很多诟病的原因。

不过 Hooks 出来之后,Redux 的使用体验已经比以前好很多了,middleware 的功能已经被 hooks 完美覆盖,所以也不会像以前 Class Component 时期冒出一大堆二次封装库了。

Redux 自备按需重渲染能力和强大的数据追溯能力(核心竞争力),特别适合强依赖数据流的大规模应用。毕竟对于这种应用,维护成本大于开发成本,调试的时候 80% 的时候都在和数据流打交道。Redux 的调试面板对于普通的应用来说可能只是锦上添花,但是对于这种大规模应用来说甚至是团队稳定性基础啊。

Redux 最核心的 API 是 useSelector,useSelector 让你从数据流中 pick 出你需要的数据,默认会跟你上一次 pick 的数据做一次浅比较,不过你也可以自己自定义比较的方式,下面使用 shallowEqual 对数组的每一个元素做浅比较(shallowEqual)。

const Child = memo(() => {
  const data = useSelector(state => state.data.filter(each => each.value < 60), shallowEqual)
  
  return ...
})

useSelector 实现按需渲染的原理其实很朴素,保持 store 的引用不变,绕开 context 的更新机制,在每次 store 更新的时候所有 useSelector 都重新执行,执行 useSelector 第一个参数 pick 出新的数据,然后用第二个参数判断前后两次数据是否相等,如果相等就 forceUpdate 当前的组件。

可以发现 useSelector 也是会消耗性能的,所以又出现了一些方案来降低 useSelector 的执行损耗,比如 reselect 等,这也许是 immutable 永远绕不开的话题吧。如果是 callback 里需要拿到数据流,推荐用 store.getSnapshot 来拿快照值。

再说一下对数据流的封装,Redux 通过 useSelector 获取数据流,通过 useDispatch 操作数据流,但是我们希望对组件隐藏数据流细节,这时候 Hooks 的优势就体现出来了。首先对 dispatch 做个封装:

function useMethods() {
  const store = useStore(); // 这里可以拿到 store 的引用,引用是不会变的
  const dispatch = useDispatch();
  
  const increment = useCallback(() => {
    if (store.count < 100) {
      dispatch({type: 'increment'});
    } else {
      // throw Error('count 已达到上限值')
    }
  }, [store])
  
  const decrement = useCallback(() => {
    dispatch({type: 'decrement'});
  }, [store])
  
  return { increment, decrement }
}

因为 store 的引用不变,所以 useMethods 并不会引起更新,可以用朴素的 useContext 做静态上下文。因为用到了 store,所以必须得放在 Redux Provider 的作用域下。

const MethodContext = createContext();
const store = createStore({ count: 0 })

const AppInner = () => {
  const methods = useMethods();
  
  return 
    <MethodContext.Provider value={methods}>
      // ... children
    </MethodContext.Provider>
}

const App = () => {
  
  return 
    <Provider value={store}>
      <AppInner />
    </Provider>
}

对于组件要隐藏数据流细节,可以用一个 custom hook 封装 useSelector 和 methods:

function useTotalState(selector, equalFn) {
  const methods = useContext(MethodContext);
  const state = useSelector(selector, equalFn);
  
  return {
    ...state,
    ...methods,
  }
}


const Child = memo(() => {
  const { count, increment } = useTotalState();
  
  return <button onClick={increment}>{ count }</button>
})

组件就可以通过一个统一的 hook 按需获取数据流和拿到所有的操作方法了。少了很多胶水代码,用起来相当的爽。

最后安利一下 Redux 的调试面板,随时查看当前 state 和时光旅行只是基础功能而已,我比较喜欢的的两个功能是 Diff 和 Trace,Diff 可以看到某一个 action 之后 state 的前后变化:

这可以帮我们快速定位到是哪个 action 引起的 bug,而 Trace 可以看到该 action 的调用栈:

立刻定位到发生 action 的具体代码,咔咔打个断点,分分钟解决问题,拥有它让你 6 点准时下班。

比起 mobx,Redux 的强项无疑是数据流的可预测性和可维护性,当然这也导致了它在使用过程中心智负担要比 mobx 更重。

可以看到每个数据流方案都是在使用爽感、性能、可维护性上的权衡,建议按照自己的喜好和手头项目的特性选择对应的数据流,合适的才是最好的。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK