小伙伴们在 React 开发过程中,常常会遇到组件重绘的问题,这个问题在老版本 class 写法中,通过 shouldComponentUpdate 进行优化,属于性能优化操作的一部分
但是在 hooks 写法中,没有相对应的 api,于是很多小伙伴只能盯着组件重绘干瞪眼,对此毫无办法
其实,只要将思想转变过来,你就能意识到,React hooks ——
完全不需要优化,没有性能优化这个步骤
是的,在 react-hooks 中,如果你理解了 react 的调度机制,优化从来都不是问题,组件总是会朝着你预想的调度方式进行调度
但是,有两个误区需要优先纠正过来
hooks component 不是 render!!!
是的,hooks component 不是 render function,直接返回 jsx?不存在的,这不是偷懒,这是调度逻辑已经有了问题:
在 hooks component 写法中,凡是没有被 hooks api 包裹的,调度都会跟随整个组件
比如:
const a = {a:''} // 手写数据频繁初始化
const [state,setState] = useState({a:''}) // 未设置惰性初始化的参数,频繁初始化
const cb = ()=>{} // 直接写的回调,频繁初始化
复制代码
既然这些都会频繁初始化(也就是组件函数会在变更时被调用),那么,你觉得你能够平静地直接返回 jsx 么?
function someCompo(){
return <div>
{/** 视图依然会被频繁初始化,即 vm 重绘 **/}
</div>
}
复制代码
注意,vm 重绘要和 dom 重绘区分开,这是两个完全不同的过程,因为 vm 的重绘并不一定会走到 patch 阶段
且不说重绘问题,随着组件 props + context + state 刷新(props,context 还有个浅层比较的问题),是你的业务逻辑么?不是的话,逻辑都被改了,会出什么问题能够预料?
那么,该怎么解决这个问题呢?
这就是思想需要转变的地方,即 ——
useMemo 就是视图
没错,我们将 [state,setState] = useState('')
中的 setState
看做变更发起的地方,useEffect 是数据处理的地方,那么,useMemo 这个被动根据调度得出新的 memorized state 的 api,就是视图处理的地方(比如 useCallback 即为视图回调,因为是 useMemo 返回函数)
回忆一下上文提到 MVI 模型,Intent 交给 回调,转换成数据变化(setState),再经由 useEffect 进行数据驱动主动调度,最后,由 useMemo 进行视图输出(即 DOM sink,沉降,过滤掉不必要的变更,限制调度进行)
那么,你就不应该直接返回 jsx,因为 return <div>/* ... */</div>
隐含了下面的代码:
function someCompo(props){
const ctx = useContext(xxx)
return useMemo(()=><div>
{/** 视图依然会被频繁初始化,即 vm 重绘 **/}
</div>,[props,ctx, ...])
}
复制代码
即,视图会在 props 变化,context 变化的时候,也会刷新
这就是本身逻辑有问题了,视图不应该是这个逻辑
也就是说,如果直接返回 jsx,你相当于 ——
告诉 react 无论如何,只要有变更,就刷新视图
props 和 context 都会进行对象浅层比较,子孙组件更是会直接被刷新,因此,调度已经可以说完全失控
这个问题比 class 中的主动优化调度更加危险,因为这个刷新不止会触发视图vm重绘,还会调用空 effect,刷新 useCallback 等
所以,问题出在 只要有变更(state,props,context) 这个需求上,不应该出现这个需求
比如一个带 callback 的 高消耗组件:
const cb = useCallback(()=>{
// 依赖 a,b
},[a,b])
return <div>
{/* 本来 cb 应该只在 a,b 改变时改变,结果是只要 props,context 刷新,组件依然会刷新 */}
<ExpensiveComponent cb={cb}/>
</div>
复制代码
所以,熟悉 react 觉得很自然的事情,不一定显得那么自然:
const cb = useCallback(()=>{
// 依赖 a,b
},[a,b])
return <div>
{/* ExpensiveComponent 只依赖 cb 进行刷新 */}
{useMemo(()=>(<ExpensiveComponent cb={cb}/>),[cb])}
</div>
复制代码
- 其他只是纯粹消费的 element 节点,优化作用并不大:
因为只要保证层层组件 useMemo, 视图节点总是能保证正确的刷新调度
当然,严谨地说,这个不是优化,这个只是正确的逻辑
React 中,依赖数组承担了调度的功能,是逻辑的一部分
不过,需要单独提出这个,是因为 react 的插件 eslint-plugin-react-hooks 管不到 return 的部分
进阶 react 调度写法 —— hooks 组件
并不是为了解决 重绘问题而诞生,但是对重绘问题解决起来最为方便的一种写法,能够利用好 eslint 对代码健壮度的提升
这种写法就是 —— hooks 组件
// useModal
function useModal(title:string,content:JSX.Element, visible=true, submitCb=()=>{}){
return useMemo(()=>(<Modal title={title} visible={visible} onOk={submitCb}>
{content}
</Modal>),[title,content,visible,submitCb])
}
// Compo
function Compo(){
const modal = useModal(
'test title',
useMemo(()=><div>test content</div>,[]),
true,
useCallback(()=>{
console.log('submited')
},[])
)
return <>
{modal}
{otherCompo}
</>
}
复制代码
是的,component 只起到组装视图的功能,所有视图都强制写上了依赖
这个写法 React 程序员可能不太熟悉,但是 Angular 程序员熟悉啊,这个叫做 module declaration 组件,而 hooks 写得组件,对应 component + service
那么,就上面的 useModal 写法,还是有不尽如人意的地方,比如那个 visible
数据驱动写法,事件调度交给 react 调度是最稳妥的,既然请求没有 run 方法,这里的 visible 也显得不伦不类
function useModal(title:string,content:JSX.Element, submitCb=()=>{}){
const [visible,setVisible] = useState(false)
const started = useRef(false)
useEffect(()=>{
if(!started.current){
started.current = true
return
}
setVisible(true)
},[title,content,submitCb])
return useMemo(()=>(<Modal title={title} visible={visible} onCancel={()=>{
setVisible(false)
}} onOk={()=>{
setVisible(false)
submitCb()
}}>
{content}
</Modal>),[title,content,visible,submitCb])
}
复制代码
使用时,只有 title, content, submitCb
三个参数
怎么控制打开呢?
title,content,submitCb 三者有一个变化,就会打开 modal
为何要如此设计?因为,写好数据驱动代码的话,需要往前多想一步,即,为啥在这里出现了个奇怪的事件?还是用户端打开 modal?
modal 是提示,即,你需要有提示的内容,才会打开 modal,内容不变,会打开相同的 modal 么?
不会,即便显示相同,隐含的内容也不同,比如 用户点击太多次,列表数据项不同 等,背后都是数据变化
依赖加到 submitCb 中,自然会自动打开弹框
// 列表中的选中项
const [selected,setSelected] = useState(null)
// 某个点击的 action
const [action,setAction] = useState(()=>()=>{})
// 这样,就能保证弹框在你需要的时候 **数据驱动**地弹出
const submitCb = useCallback(()=>{},[selected, action])
复制代码
这样能规避 React 事件代理补全,带来的一大堆奇怪问题,而且更加稳定,没有多余重绘也会把 react 的性能发挥到极致
这也是第二个需要纠正的误区 ——
React 适合数据驱动,useCallback 只用来采集事件
所谓采集事件的事件,是指事件循环的事件,即所有异步(本质上,进程线程通讯,这个和 golang 做法有异曲同工之妙),将其转化为数据变化,接下来,应该一个 useCallback 都用不到(useCallback 组合 useEffect 的做法也会有误导,不如直接用 useEffect 进行组合)
为何说 React 只适合数据驱动?因为前文提到,react 除了合成事件部分,你无法直接调用 useCallback (即 jsx 处绑定),调度需要你自行绑定到 react
这导致 react 没有办法实现 zone 办到的完全事件代理,逻辑总是会汇聚到 react 的调度中,导致 同步,raf,webWorker 调度无法进行
因此,数据驱动才能规避这些问题
同样,逻辑也需要从数据驱动角度构建,通过数据限制你的组件绘制,才能从优化问题,转化成逻辑问题
同时,React 官方提出的 useReducer 方案给到 不变 dispatch,说实话,真的丑陋(不想吐槽第三遍了)
只要你摸清了React的脾气,这些问题都不是问题