Coding Style
业务架构
从业务架构的角度看,更推荐使用“逻辑分离”的方式编写组件:组件负责"交互+ui"(展示组件), Hooks则相当于"逻辑+数据",尽量做到状态 和 UI 分离,“有状态的组件没有渲染,有渲染的组件没有状态”。 通俗来说,有UI的组件文件没有useState概念方向,具体问题具体分析)。
Custom Hooks 格式
文件统一放到页面目录或公共组件根目录的hooks文件夹下,文件命名风格统一以函数名命名
-
camelcase or kebabcase?
个人感觉直接用camelcase可读性会比较好,但是项目要求都用,后者也可以。
-
所有useState前置(虽然顺序可能不会影响到使用和执行,但便于阅读)
-
必须写 deps
-
返回格式,先说结果 ,采用{...state,...actions }的格式
优点:可以类型推断,no-break change(后续改动对之前使用 hooks 的地方无影响)
-
其他格式:
-
[...state, ...actions], 个人比较喜欢的方式,React 官方的方式
优点:简单,容易别名
缺点:Break change
-
[{...state}, {...action}], reduck like 的方式
优点:No-break change
缺点:
1. 需要重写一遍类型,不能自动推断 2. 需要别名
-
对于业务来说, 能不产生 break-change 可能对于维护代码和迭代需求有减少代码修改量的效果。而可以自动推断类型,可以减少很多重复的类型书写,最后采用{ ...state, ...actions }的格式
尽可能的减少返回值的数量,内敛,降低耦合度
尽可能的减少传入值数量,能自己管理的参数,让自己管理
举个例子,一个列表数据的 hooks,可以将 loading,limit,total 等数据收到内部
const useSomeItems = () => {
...
return { items, hasMore,loadMore, refresh };
}
关于 Hooks 进一步学习和思考
概览
代码入口:react/packages/react-reconciler/src/ReactFiberHooks#346:renderWithHooks
首先 Hooks是通过闭包实现的。通过在当前FiberNode下存储一些变量,统一控制当前作用域下的component 不断刷新执行。大致如下图:
执行的时候,每个hook函数会生成一个Hook对象,记录这个值的值和变化。整体是一个链表结构,每次执行都会获取当前hook游标的下一个节点,从中获取存储的值和更新,回调函数等。所以必须要每一次执行和函数和顺序一致(不允许变化和判断)。大致执行后存储格式如下:
再看一下 Hook 结构体的类型。可以看到 hook 会记录当次渲染内,所有数据变更的记录,并且存储的是变化,并不是结果。具体执行更新的结果,会在下一次函数执行的 useHook 中逐个遍历更新,执行变化。这里除了等待当次函数渲染的其他事件执行完成外,还需要等待调度器根据算法优先级判断,最后再执行新一轮的渲染。所以数据的变化是异步的。
type Update<S, A> = {|
expirationTime: ExpirationTime,
suspenseConfig: null | SuspenseConfig,
action: A, // 更新行为,可以为直接赋值,或者相对于上一次值的函数
eagerReducer: ((S, A) => S) | null,
eagerState: S | null, // 预测可能变化的值,如果和当前值一致,则停止申请渲染调度(scheduleUpdateOnFiber)
next: Update<S, A>, // 链表结构,指向下一个更新,单次执行可能有多个更新
priority?: ReactPriorityLevel, // Fiber 算法优先级标志(暂时没看。。)
};
type UpdateQueue<S, A> = {|
pending: Update<S, A> | null, // 缓存等待执行的更新,由 dispatchAction 执行添加(即 setState)
dispatch: (A => mixed) | null, // 将当前值的更新函数,绑定
lastRenderedReducer: ((S, A) => S) | null,
lastRenderedState: S | null, // 记录上次执行更新的结果值
};
export type Hook = {|
memoizedState: any, // 当前的最新值
baseState: any, // 这一次渲染执行的初始值
baseQueue: Update<any, any> | null, // 上一次执行流程中,当前节点的更新记录。每次重新执行时,从 queue 中同步
queue: UpdateQueue<any, any> | null, // 本次执行流程中,当前节点的更新记录
next: Hook | null, // 链表结构,指向下一个节点
};
具体执行流程示意图:
了解一下这些“上层居民”。
// 当前执行域对应的 Fiber
let currentlyRenderingFiber: Fiber = (null: any);
// 当前渲染的 hooks 的存储链式结构的节点游标,是下一个需要使用的节点
// 每次执行前, 赋值上一次 fiber 的 memorizeSate,即上一次的数据链表的表头
let currentHook: Hook | null = null;
// 下一次渲染需要的 hooks 的存储链式结构的节点游标,是下一个需要使用的节点
let workInProgressHook: Hook | null = null;
// 是否有数据更新
let didScheduleRenderPhaseUpdate: boolean = false;
// 在当次渲染进行的时候,是否还有更新
let didScheduleRenderPhaseUpdateDuringThisPass: boolean = false;
// 可以从绘制的次数,只做 dev 模式下提示,并不约束
// 只代表组件执行次数,并不是真是渲染次数
// 如下列方式可以轻松获得报错提示
// {count < 50 && setCount(e => e + 1)}
const RE_RENDER_LIMIT = 25;
EffectState
副作用的执行不止会在hook链表中作为值存储,还会在currentlyRenderingFiber.updateQueue 中保存一个副作用链表,以及effectTag,标记当前Fiber拥有的副作用的状态。tag是一个二进制的并集的形式存储(有点不好理解。。。)判断 deps ,给currentlyRenderingFiber和effect根据deps情况绑定对应的tag,当双方tag同时符合当前life cycle hook的tag时执行副作用。
所有副作用都是在functional函数执行完成之后再执行的, 副作用执行按照代码的顺序(链表顺序)执行。每个触发点(如mount,update,unmount等)都会遍历一次所有effect,判断tag是否执行。所以尽量写deps和内部判断,减少无用执行。空deps并没有跟mount和unmount绑定,只是第一次执行所有和最后一次回收所有,中间的更新因为 deps不变,所以一直没有执行。
Deps
Deps 是一般 hooks 的第二个参数,用来比对是否需要更新。需要注意函数的值(除了 hooks 内置的 setState 或者 useCallback 等)每次都是新的,写进 deps 就相当于每一次都会执行。同时因为是浅比较(object.is), 数组和对象无法监听到变化,可以用对应的值或者一些逻辑来比对,比如 array.length,a.b === c。
FAQ
Q: 为什么 hooks要在函数最底层写,不能加判断?
A: 因为每个 hooks 在函数中都是链式结构的一个节点,每次渲染,执行函数的时候按顺序读取和写入对应的 hook 节点。
Q: 什么时候应该使用 useMemo,useCallback?
A: 首先开发第一时间,先考虑不使用这些 hooks。因为这是属于性能优化的部分,加了只会增加报错的心智负担,并不会减少已有的 bug 。。而且两者使用本身有一定性能成本
useMemo
当一个页面有多个组件的时候,更新部分数据不需要某个子组件重新执行,或者某个值更新,可以用 useMemo 一般是减少组件重复执行渲染,除非数据有一定运算成本,否则不需要存储 储存值之前,考虑能不能用 useState 代替,甚至直接 const/let Hooks 默认每次都执行一遍,所以每次执行的值和函数都是新的
useCallback
可以保证 useMemo 使用的函数是需要时才更新而不是每次都更新的,函数对于 deps diff ( Object.is ) 来说每次都是新的
这里需要考虑一点,是否可以只监听数据变化。组件的书写,能否符合事件与数据绑定的概念
如果不能,是否应该在上一层绑定 useMemo。因为只有当 数据 和 事件不同步更新的 时候 才需要 用到 useCallback,一般来说不需要用到,只需要监听数据变化即可。如果必须要用到,可以先思考是否必要,是不是可以设计成不需要
Memo
上面两者除了单独使用外,还有一个使用场景就是配合 memo,可以让纯展示组件在父组件更新的时候不会重新执行。
Q:如何调试 react 源码?
A:
- 配置 webpack
const { DefinePlugin } = require('webpack')
...
resolve: {
alias: {
react: '/path/to/react/packages/react',
'react-dom': '/path/to/react/packages/react-dom',
shared: '/path/to/Packages/react/packages/shared',
'react-reconciler': '/path/to/Packages/react/packages/react-reconciler',
'react-events': '/path/to/Packages/react/packages/events',
'legacy-events': '/path/to/Packages/react/packages/legacy-events',
scheduler: '/path/to/Packages/react/packages/scheduler',
},
},
...
plugins: [
new DefinePlugin({
__DEV__: true,
__PROFILE__: true,
__UMD__: true,
__EXPERIMENTAL__: true,
}),
],
...
module: {
rules: [
{
test: /\.js?$/,
loader: 'babel-loader',
exclude: /node_modules/,
options: {
presets: ['@babel/preset-env', '@babel/preset-flow', '@babel/preset-react'],
},
},
]
}
- 修改源码
有一些部分需要修改后才能正常执行
packages/react-reconciler/src/ReactFiberHostConfig.js
// import invariant from 'shared/invariant';
// invariant(false, 'This module must be shimmed by a specific renderer.');
export * from './forks/ReactFiberHostConfig.dom';
packages/scheduler/src/SchedulerHostConfig.js
// throw new Error('This module must be shimmed by a specific build.');
export * from './forks/SchedulerHostConfig.default.js';
packages/shared/ReactSharedInternals.js
// const ReactSharedInternals =
// React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;
import ReactSharedInternals from '../react/src/ReactSharedInternals';
packages/shared/invariant.js
export default function invariant(condition, format, a, b, c, d, e, f) {
if (condition) return;
console.error(format);
}
- 使用 React
// 这里我试了下加 @babel/preset-react 没有用,所以还是这么用了
import * as React from 'react'