近期的工作重点为项目的技术栈转型,在编码的过程中碰到大大小小的疑惑,主要聚焦在 React 中使用 TypeScript、Redux 细节、hooks 相关的困惑以及一些小的知识点,周末磨磨刀吧。
Hooks
背景
Hooks 出现之前,如果要复用组件代码,常用的解决方式有
- HOC:高阶组件
- 属性代理:函数返回一个我们自己定义的组件,然后在 render 中返回要包裹的组件,这样我们就可以代理所有传入的 props,并且决定如何渲染
- 反向继承:返回一个组件,继承原组件,在 render 中调用原组件的 render。由于继承了原组件,能通过 this 访问到原组件的生命周期、props、state、render 等,相比属性代理它能操作更多的属性。
- Render Props:属性是一个函数,这个函数接受一个对象并返回一个子组件,会将这个函数参数中的对象作为 props 传入给新生成的组件。
HOC 与容器组件模式之间有相似之处。容器组件担任分离高层和低层关注的责任,由容器管理订阅和状态,并将 prop 传递给处理渲染 UI。HOC 使用容器作为其实现的一部分,你可以将 HOC 视为参数化容器组件。
HOC 注意事项
- 不要在 render 方法中使用 HOC
- 性能问题,导致 diff 失败,导致组件卸载,状态丢失
- 在极少数情况下,你需要动态调用 HOC。你可以在组件的生命周期方法或其构造函数中进行调用。
- 务必复制静态方法:可以使用
hoist-non-react-statics
自动拷贝所有非 React 静态方法 - Refs 不会被传递
HOC 和 Render Props 相同点
- 两者都能很好的帮助我们重用组件逻辑
- 造成组件嵌套层数过多
HOC 和 Render Props 不同点
- HOC 父组件有相同属性名属性传递过来,会造成属性丢失
- Render Props 你只需要实例化一个中间类,而 HOC 你每次调用的地方都需要额外实例化一个中间类
HOC 和 Render Props 总结
- HOC 有点像把通用代码抽离处理,然后插拔式的附加在特定组件上
- Render Props 像是把一个个组件抽离出来,如果该组件需要某组件提供的功能,则作为 render 函数返回值传入即可,render 参数用来传递数据
- Render Props 可以实现大多数高阶组件 (HOC)。
Render Props 有以下几个优点
- 不用担心 props 的命名问题
- 可以溯源,子组件的 props 一定是来自于直接父组件
哪为啥造一个 Hooks 呢
- 在组件之间复用状态逻辑很难:目前的复用方式 Render Props 和 HOC,无论是哪一种方法都会造成组件数量增多,组件树结构的修改。使用 Hook 从组件中提取状态逻辑,使得这些逻辑可以单独测试并复用。Hook 使你在无需修改组件结构的情况下复用状态逻辑
- 生命周期钩子函数里的逻辑混乱
- 通常希望一个函数只做一件事情,而生命周期钩子将完全不相关的代码却在同一个方法中组合在一起
- 一些清除逻辑可能还会在另一个钩子中出现,相互关联且需要对照修改的代码被进行了拆分
- 难以理解的 class:主要表现在 this 指向问题和无状态组件到有状态组件的改造
Hooks 优点
- useState 和 useEffect 解决了函数式组件没有状态和生命周期的问题
- 状态相关逻辑代码的细粒度拆分,在 hooks 之前,你想拆只能基于组件,而现在可以通过自定义 hooks 将复用的逻辑抽离出来,变成一个个可以随意插拔的“插销”,将相关 useState 和 useEffect 抽离出来到一个函数中(常取名为use*),返回 stateName 即可,在组件中就可以直接引用这个函数。
Hooks 心智模型
- 关注变化,函数可能会过期,导致闭包问题,从而访问到旧的数据
- 代码组织能力:一个类中写再多函数也感觉没什么,但在函数组件中,写一大堆内联函数会很别扭。所以要好好抽象自己的业务逻辑。该抽到 Hooks 的就老老实实抽象出来
基础 Hooks
重申一下一些基础 Hooks 用法和注意事项
useState
- 参数可以是初始值或者函数形式(惰性初始 state),后续重新渲染中,React 会确保 state 和 setState 的标识是稳定的,因此依赖列表中可以省略 setState
- 把所有 state 都放在同一个 useState 调用中,或是每一个字段都对应一个 useState 调用,这两方式都能跑通。当你在这两个极端之间找到平衡,然后把相关 state 组合到几个独立的 state 变量时,组件就会更加的可读。如果 state 的逻辑开始变得复杂,我们推荐用 reducer 来管理它,或使用自定义 Hook
- 如果一个操作,
需要运行多个 setXXX,说明这些 state 的相关的,你可能会考虑放进一个自定义 hook 中,很多时候还可以考虑使用 useReducer hook
- 类似 class 组件的 this.setState,但是它不会把新的 state 和旧的 state 进行合并,通常支持函数形式,用来获取
prevState
React 怎么知道 useState 对应的是哪个组件,因为我们并没有传递 this 给 React。
- React 保持对当先渲染中的组件的追踪。
- 与此同时,多亏了 Hook 规范,每个组件内部都有一个「记忆单元格」列表。它们只不过是我们用来存储一些数据的 JavaScript 对象。当你用 useState() 调用一个 Hook 的时候,它会读取当前的单元格(或在首次渲染时将其初始化),然后把指针移动到下一个。
分离独立 state 变量的建议:这是个哲学问题,一个通用的思考方向就是,如果一个操作会触发多个 setState 操作,就可以考虑它们是否可以归为一个组
useReducer
- 通过这种方式可以对多个状态同时进行控制
- 惰性初始化:将 init 函数作为 useReducer 的第三个参数传入
useEffect
- componentDidMount、componentDidUpdate 和 componentWillUnmount 具有相同的用途,只不过被合并成了一个 API
- 在函数组件主体内改变 DOM、添加订阅、设置定时器、记录日志以及执行其他包含副作用的操作都是不被允许的,因此需要使用 useEffect 完成副作用操作。
- 用于处理各种状态变化造成的副作用,也就是说只有在特定的时刻,才会执行的逻辑。
- 回调函数中,我们可以返回一个常用用于清理工作,这是 effect 可选的清除机制,比如事件监听、取消订阅、计时器、中断 Ajax
- 可以使用多个 Effect 实现关注点分离,解决面向生命周期编程的问题
- 传递给 useEffect 的函数在每次渲染中都会有所不同,这是刻意为之的。事实上这正是我们可以在 effect 中获取最新的值,而不用担心其过期的原因。每次我们重新渲染,都会生成新的 effect,替换掉之前的。
- 按照 effect 声明的顺序依次调用组件中的每一个 effect。
useCallback
- 返回一个 memoized 函数,该回调函数仅在某个依赖项改变时才会更新。
- 当你把回调函数传递给经过优化的并使用引用相等性去避免非必要渲染的子组件时,它将非常有用。因为函数引用未发生改变。
useMemo
- 返回一个 memoized 值。当依赖的状态发生改变时,才会触发计算函数的执行。
- 有助于避免在每次渲染时都进行高开销的计算。
- 当你把引用对象传递给经过优化的并使用引用相等性去避免非必要渲染的子组件时,它将非常有用。因为对象引用未发生改变。
useContext
- 读取 context 的值以及订阅 context 的变化
- context 是在外部 create,内部 use 的 state,它和全局变量的区别在于,数据的改变会触发依赖该数据组件的重新渲染。而 useContext hooks 可以帮助我们简化 context 的使用。
useRef
- useRef 返回一个可变的 ref 对象,其
.current
属性初始化为传递的参数(initialValue)。返回的对象将持续整个组件的生命周期。 - useRef 和 DOM refs 有点类似,但 useRef 是一个更通用的概念,它就是一个你可以放置一些东西的盒子。它可以很方便地保存任何可变值,其类似于在 class 中使用实例字段的方式。
- useRef 和自建一个 {current: ...} 对象的唯一区别是,useRef 会在每次渲染时返回同一个 ref 对象。
useImperativeHandle
- 在使用 ref 时自定义暴露给父组件的实例值,弥补不能引用函数组件的缺陷。
- 需要配合
forwardRef
使用
useLayoutEffect
- 函数签名与 useEffect 相同,但它会在所有的 DOM 变更之后同步调用 effect。可以使用它来读取 DOM 布局并同步触发重渲染。
- 尽可能使用标准的 useEffect 以避免阻塞视觉更新。
自定义 hook
- 只需要定义一个函数,并且把相应需要的状态和 effect 封装进去
- Hook 之间也是可以相互引用的。使用 use 开头命名自定义 Hook,这样可以方便 eslint 进行检查。
useEffect 注意事项
- 请确保数组中包含了所有外部作用域中会随时间变化并且在 effect 中使用的变量,否则你的代码会引用到先前渲染中的旧变量。
- 如果想执行只运行一次的 effect(仅在组件挂载和卸载时执行),可以传递一个
空数组
作为第二个参数。这就告诉 React 你的 effect 不依赖于 props 或 state 中的任何值,所以它永远都不需要重复执行。如果你传入了一个空数组,effect 内部的 props 和 state 就会一直拥有其初始值。 - 推荐启用 eslint-plugin-react-hooks 中的
exhaustive-deps
规则。此规则会在添加错误依赖时发出警告并给出修复建议。
Hook 规则
- 只在组件最顶层使用 Hook,不要在循环,条件或嵌套函数中调用
- 只在函数组件和自定义 Hook 中使用
hooks 目前暂时还没有对应不常用的 getSnapshotBeforeUpdate 和 componentDidCatch 生命周期的 Hook 等价写法
Q & A
函数中看到陈旧的 props 和 state
- 组件内部的任何函数,都是从它被创建的那次渲染中看到的,典型的闭包问题。如果你刻意地想要从某些异步回调中读取最新的 state,你可以用 一个 ref 来保存它,修改它,并从中读取。
- 使用了「依赖数组」优化但没有正确地指定所有的依赖
依赖列表中使用函数是否安全
- 不安全:要记住 effect 外部的函数使用了哪些 props 和 state 很难。这也是为什么通常你会想要在 effect 内部去声明它所需要的函数。这同时也允许你通过 effect 内部的局部变量来处理无序的响应。
- 如果出于某些原因你 无法把一个函数移动到 effect 内部
- 把函数移动到你的组件之外,这样一来,这个函数肯定不会依赖任何 props 和 state,通过传参即可知道其依赖那些状态
- 如果你所调用的方法是一个纯计算,并且可以在渲染时调用,你可以转而在 effect 之外调用它, 并让 effect 依赖于它的返回值。
- 万不得已的情况下,你可以把函数加入 effect 的依赖但 把它的定义包裹进 useCallback Hook。这就确保了它不随渲染而改变,除非它自身的依赖发生了改变
如何从 useCallback 读取一个经常变化的值
- 某些场景中,你需要使用 useCallback 记住一个回调,但由于闭包的原因,读取外部变化的值会读取到旧值
- 解决方案:使用 ref 当做实例变量,并手动保存该经常变化的值
Hooks 性能相关
在 React 中一切皆组件,因此优化也是面向组件优化,在 React 中引发组件重新渲染的时机很简单,就如下三种情况,第三种我们无需考虑
- props 更改
- state 更改
- forceUpdate
UI 响应延迟低于 100ms 对于用户而言是无感知的,当延迟 100~300ms 时用户就会有所察觉
那么 render 的时候做了什么事情呢
- diff 比较
- DOM 卸载与挂载
简单而言,优化方向就是
- 使用 PureComponent 或 React.memo 减少不必要的更新
- 使用更好的 props
- 尽量使用原始类型
- 不要直接传递箭头函数
- 使用 shouldComponentUpdate 控制更新
- 解决尽量传递原始类型很难做到的问题
- 对于函数式组件,可以传递一个比较函数作为 React.memo 的第二参数
- 需要注意深比较本身的性能问题
既然 PureComponent 或 React.memo 可以减少更新,为什么不作为默认选项呢
- Problem with nested objects
- Problem with function props
- 原因就在于针对对象和函数 props,比较通常会返回 false,依旧会触发重新渲染,而比较操作本身也是需要时间的
渲染中昂贵的计算,函数式组件不同于类组件,由于没有实例,函数运行完现场就销毁了,因此如果函数运行中有一些昂贵的计算,该如何处理呢
- 如果值需要在某些场景重新计算,则使用 useMemo,这里的值也可以是组件,因此可以用来优化子节点的重新渲染
- 如果初始化常见 state 很昂贵时,使用 setState 的函数形式,只会在首次渲染时调用这个函数
- 如果值从不被重新计算,可以惰性初始化一个 ref,ref 不会像 useState 可以接受一个特殊的函数重载,你需要编写自己的函数来创建并设置为惰性的
在 React 中使用内联函数对性能的影响,与每次渲染都传递新的回调会如何破坏子组件的 shouldComponentUpdate 优化有关。Hook 从三个方面解决了这个问题。
- useCallback Hook 允许你在重新渲染之间保持对相同的回调引用以使得 shouldComponentUpdate 继续工作
- useMemo Hook 使控制具体子节点何时更新变得更容易
- useReducer Hook 减少了对深层传递回调的需要,通过 context 用 useReducer 往下传一个 dispatch 函数,
dispatch 永远是稳定的
什么时候使用 memo 或 PureComponent 呢
- Pure functional component
- Renders often
- Re-renders with the same props:一个常见的导致组件使用相同 props 重新渲染的场景就是:父组件重新渲染导致子组件重新渲染
- Medium too big size:包含了太多其他组件
什么时候避免使用 memo 或 PureComponent 呢
- 易变组件:如果组件的更新总是不同的 props,那么比较总会失败,这就是没意义的比较,应该避免使用 memo
- 原则:如果无法量化性能收益,就不要使用 memo
Hook 困扰
自己使用 Hooks 的过程中一直有很多困扰,在知乎看到一篇文章,几乎说到我的很多点上,一起看看吧React Hooks 带来的困扰与思考。
要保证依赖数据的正确性,但同时我又只关心部分 props 或 state 变量的变动,从而重新执行副作用函数,其它 props 或 state 变量只取决于当时的状态。处理方式总结如下
- 从副作用函数入手,使用 ref 存储副作用函数
- 从数据变量入手,使用 ref 存储之前的值(usePrevious),从而比较是否变更
场景1:只在组件 mount 之后执行的方法,但是会用到组件的状态值,该如何处理呢,封装 useMount
函数
function useMount(mountedFn) {
const mountedFnRef = useRef(null);
mountedFnRef.current = mountedFn;
useEffect(() => {
mountedFnRef.current();
}, [mountedFnRef]);
}
场景2:只关心部分值变动
const usePrevious = (value) => {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
};
希望依赖数组中对象类型的比较是浅比较或深比较,还是得需要 ref 帮忙,例子如下
function useCompare(value, compare) {
const ref = useRef(null);
if (!compare(value, ref.current)) {
ref.current = value;
}
return ref.current;
}
function Child({ list }) {
const listArr = useCompare(list, isEqual);
useEffect(() => {
console.log(listArr);
}, [listArr]);
}
你耗费心思去保持函数的引用稳定,但是组件树上层一个不小心可能就将之前的努力白费。比如如下例子
function Button({
child,
disabled,
onClick
}) {
const handleBtnClick = useCallback(() => {
if (!disabled && onClick) {
onClick();
}
}, [disabled, onClick]);
return (
<button onClick={handleBtnClick}>{child}</button>
);
}
function App() {
const onBtnClick = () => {};
return (
<Button onClick={onBtnClick} />
);
}
由于组件树上层传递的 onClick 函数是不稳定的,因此从而导致 handleBtnClick
其实也是不稳定。这样在子组件使用 useCallback 反而带来了不必要的比较。那么该如何做呢,可以使用 useCallback + useRef
方式
function Button({
child,
disabled,
onClick
}) {
const handleBtnClickRef = useRef();
handleBtnClickRef.current = () => {
if (!disabled && onClick) {
onClick();
}
};
const handleBtnClick = useCallback(() => {
handleBtnClickRef.current();
}, [handleBtnClickRef]);
return (
<button onClick={handleBtnClick}>{child}</button>
);
}
再来看一个例子
function App() {
const handleResize = useCallback(() => {
console.log(count);
}, [count]);
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [handleResize]);
}
在例子中,每次 count 变量的变动都会引起 handleResize 变量的变动,进而引起下方 useEffect 中副作用函数的执行,即执行事件解绑 + 事件绑定。而实际上,我们只希望在组件挂载时绑定 resize 事件,在组件销毁时解绑。如果要实现这样的功能,又需要借助 useRef。
function App() {
const handleResizeRef = useRef();
handleResizeRef.current = () => {
console.log(count);
};
useEffect(() => {
const handleResize = () => {
handleResizeRef.current();
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [handleResize]);
}
usePrevious 误解
一开始使用 usePrevious 时,以为其存储的总是某值的之前值。这其实错误。当值改变时,确实会存储之前值,但组件由于其他原因重新渲染时,此时 usePrevious 拿到的值就和当前值一致了。
Typescript
在 React 接入 Typescript 时,一定会碰到很多类型声明的错误,这里总结下常用的内置类型以及辅助函数
常用类型与函数
- React.ReactElement:拥有 type 和 props 属性的对象
- JSX.Element:React.createElement 的返回值,是特殊的 ReactElement,props 和 type 均为 any,更加通用
- React.ReactNode:组件的返回值,比 JSX.Element 更广泛,覆盖组件可能返回的所有可能值如下
- ReactElement
- ReactFragment
- string
- number
- ReactNodes
- null/undefined/boolean
- React.CSSProperties
- HTMLElement/HTMLDivElement/……
- React.isValidElement:验证对象是一个 React 元素
- React.createElement:使用 JSX 通常不会手动调用
- React.cloneElement
- React.Children.forEach/map/count/only/toArray
- ReactDOM.createPortal(child, container):将孩子呈现到父组件的 DOM 层次结构之外的 DOM 节点中
- ReactDOM.findDOMNode
虽然有了 JSX 后,通常不会手动调用 createElement 函数,但简单了解下具体做了什么吧
React.cloneElement(
element,
[props],
[...children]
)
// 相当于
<element.type {...element.props} {...props}>{children}</element.type>
这里提供一个基于 React.Children 抽象的 toArray 工具,可以帮组我们减少很多的类型判断
function toArray(children: React.ReactNode) {
const res = []
React.Children.forEach(children, item => {
res.push(item)
})
return ret
}
Redux
Redux 介绍,本身十分简单
- 你的应用程序的状态被描述为一个普通的对象。
- 这个对象就像一个“模型”,除了没有 setter。这是因为代码的不同部分不能任意改变状态,导致难以重现的错误。
- 要改变这个状态,你需要发送一个动作。一个动作是一个普通的 JavaScript 对象。强制将每一项变更都描述为一项行动,让我们清楚了解应用中发生的情况。如果有什么改变,我们知道它为什么改变。
- 为了将状态和动作绑定在一起,我们编写了一个名为 reducer 的函数。它只是一个以状态和动作为参数的函数,并返回应用程序的下一个状态。
- 为大型应用程序编写这样的功能将很难,因此我们编写管理部分状态的较小函数。
Redux 生命周期
- 通过 dispatch 派发动作
- 调用 reducer 纯函数计算下一个状态。注意 reducer 应该是完全可预测的:多次调用相同的输入应该产生相同的输出。不应该执行任何副作用。
- 根 reducer 将多个 reducer 的输出组合成单个状态树。combineReducers 辅助函数用于将根简化器“分解”为单独的函数,每个函数都管理状态树的一个分支。
- redux 存储保存由根 reducer 返回的完整状态树
reducer 主要注意的点
- 绝不会直接写入 state 或其字段,而是返回新的对象
- 不完全变异:比如想更新数组中的某个特定项目而不使用突变,所以我们必须创建一个新数组,其中除了索引处的项目外,其他项目都是相同的。这种代码的编写
immer
等第三方库就可以派上用处了 - Redux 中 connect 检查是否 mapStateToProps 已更改从函数返回的值以确定组件是否需要更新。为了提高性能,connect 需要一些依赖状态不可变的快捷方式,并使用浅层引用相等检查来检测更改。这意味着不会检测到通过直接变异对对象和数组所做的更改,并且组件不会重新呈现。
reducer 重构
如果 reducer 发展的很大,我们可以如何重构呢
- 提取实用工具函数:比如 updateObject 与 updateItemInArray
- 提取 case reducer:把每个 case 的处理逻辑封装成单独的函数在 reducer 中调用,而不是直接写在 reducer 中
- 按域分隔数据处理:主要提高内聚,减少耦合,将相关性比较强的 case 整合成一个个单独的 reducer,最终使用 combineReducers 整合
- 减少模板代码,比如不使用 switch
简单的 updateObject 与 updateItemInArray 工具函数代码如下
function updateObject(oldObject, newValues) {
// 用空对象作为第一个参数传递给 Object.assign,以确保是复制数据,而不是去改变原来的数据
return Object.assign({}, oldObject, newValues);
}
function updateItemInArray(array, itemId, updateItemCallback) {
const updatedItems = array.map(item => {
if(item.id !== itemId) {
// 因为我们只想更新一个项目,所以保留所有的其他项目
return item;
}
// 使用提供的回调来创建新的项目
const updatedItem = updateItemCallback(item);
return updatedItem;
});
return updatedItems;
}
不使用 switch 可以怎么做呢,提取一个 createReducer 函数如下
function createReducer(initialState, handlers) {
return function reducer(state = initialState, action) {
if (handlers.hasOwnProperty(action.type)) {
return handlers[action.type](state, action)
} else {
return state
}
}
}
组件与数据分类
什么组件可以使用 redux 中数据呢,大致将组件分为展示组件和容器组件,他们的区别如下
展示组件
- 目标:How things look
- aware of Redux:No
- 读取数据:props
- 改变数据:执行 props 回调函数
容器组件
- 目标:How things work
- aware of Redux:Yes
- 读取数据:redux state or local state
- 改变数据:dispatch or setState
大多数应用程序处理多种类型的数据,大致可以分为三类:
- 域数据:应用程序需要显示,使用或修改的数据(例如“从服务器检索到的所有 ToDos”)
- 应用程序状态:特定于应用程序行为的数据(例如“当前选择了待办事项#5”或“请求正在处理获取待办事项”)
- 用户界面状态:表示用户界面当前如何显示的数据(如“EditTodo模式对话框当前处于打开状态”)
计算派生数据
计算派生数据:基于 redux state 的数据计算新的数据
如果计算派生数据的开销很大,则可以考虑使用 reselect 提升性能
在多个组件上共享选择器,则需要使用到 connect 的高阶用法:如果 mapStateToProps 返回一个函数而不是一个对象,它将用于为容器的每个实例 mapStateToProps 创建一个单独的函数 connect mapStateToProps
不可变性
Redux 为什么要求不变性,主要有两方面原因,分别来自 Redux 和 React-Redux
- Redux 的 combineReducers 浅显地检查由它调用的 reducer 引起的引用更改。
- React-Redux 的 connect 方法生成的组件会浅显地检查对根状态的引用更改,并从 mapStateToProps 函数返回值以查看被包装的组件是否实际需要重新呈现。这种浅层检查需要不变性才能正常工作。
combineReducers 如何使用浅层平等检查?
- combineReducers 将使用从每个 reducer 返回的状态片构造一个新的状态对象。这个新的状态对象可能与当前状态对象相同,也可能不同。combineReducers 使用浅层平等检查来确定状态是否已经改变。
- combineReducers 对当前状态片和从还原器返回的状态片执行浅层次的相等检查。如果 reducer 返回一个新对象,浅层相等性检查将失败,并将标志combineReducers 设置 hasChanged 为 true
- 迭代完成后,combineReducers 将检查 hasChanged 标志的状态。如果是,则返回新构造的状态对象。如果它为假,则返回当前状态对象。
React-Redux 如何使用浅层平等检查:保持对根状态对象的引用以及对从 mapStateToProps 函数返回的 props 对象中每个值的引用来检测更改。
React-Redux 如何使用浅层平等检查来确定组件是否需要重新渲染
- 每次 connect 调用 React-Redux 函数时,它都会对其存储的对根状态对象的引用以及从存储区传递给它的当前根状态对象执行浅层次的相等检查。如果检查通过,则根状态对象尚未更新,因此不需要重新呈现组件,甚至不需要调用 mapStateToProps
- 如果检查失败,connect 将调用 mapStateToProps,查看是否有包装的组件道具已被更新。
- 通过对对象内的每个值分别执行浅的相等检查来执行此操作,并且只有在其中一个检查失败时才会触发重新呈现。
数据存放
确定应将什么类型的数据放入 Redux 的一些常用经验法则是:
- 应用程序的其他部分是否关心这些数据?
- 你需要能够根据这些原始数据创建更多派生数据吗?
- 是否使用相同的数据来驱动多个组件?
- 能够将这种状态恢复到某个特定时间点(例如,时间旅行调试)对您来说是否有价值?
- 你想缓存数据吗(例如:如果它已经存在,而不是重新请求它,使用什么状态)?
中间件机制
中间件是 redux 核心,你可以假设你要实现一个 log 机制,会如何一步步尝试呢。只是重点哈
- 手动记录:代码冗余、耦合
- 封装 dispatchAndLog 函数:不利于扩展
- 猴子补丁:重写 dispatch 实现
- 隐藏猴子补丁:返回新的 dispatch 函数,有点像中间件了哈
- 不使用猴子补丁
- 使用猴子补丁的目的:通过 store 可以拿到修改后 dispatch,如果不这么做呢
- 通过高阶函数,将修改后的 dispatch,也就是 next 作为参数传入
- 需要升级一下 applyMiddleware 函数
Redux-Saga
Redux-Saga 深入 effect 函数功能
- call(fn, ...args):阻塞当前 saga 的执行,直到被调用函数 fn 返回结果,才会执行下一步代码
- put(action):相当于在 saga 中调用 store.dispatch(action),put 也是阻塞 effect
- take(pattern):阻塞当前 saga,直到接收到指定的 action,代码才会继续往下执行
- fork(fn, ...args):类似于 call effect,不会阻塞当前 saga,会立即返回一个 task 对象
- cancel:cancel:针对 fork 方法返回的 task ,可以进行取消关闭
- spawn(fn, ...args):类似 fork,区别在于 spawn 返回 isolate task,不存在父子关系,即使 saga2 挂了,rootSaga 也不受影响,saga1 和 saga3 自然更不会受影响,依然可以正常工作。
- select(selector, ...args):获取 store 上的 state 数据
更多辅助函数
- takeEvery:允许多个 fetchData 实例同时启动,尽管之前还有一个或多个 fetchData 尚未结束
- takeLatest:只允许执行一个 fetchData 任务,并且这个任务是最后被启动的那个,如果之前已经有一个任务在执行,那之前的这个任务会自动被取消
Context
Context 基础
- 当 React 渲染一个订阅了这个 Context 对象的组件,这个组件会从组件树中离自身最近的那个匹配的 Provider 中读取到当前的 React 值。
- 只有当组件所处的树中没有匹配到 Provider 时,其 defaultValue 参数才会生效。这有助于在不使用 Provider 包装组件的情况下对组件进行测试。
- 调用了 useContext 的组件总会在 context 值变化时重新渲染。如果重渲染组件的开销较大,你可以通过使用 memoization 来优化
Context 注意事项:如果 Provider 父组件进行重渲染时,会导致的 value 属性被赋值为新对象,则会导致 consumers 组件中触发意外的渲染。
useContext 不同于 react-redux
提供的 useSelector
具有选择部分值功能,因此只要 context value 发生的改变,所有 consumer 都会触发重新渲染,大致解决办法如下,来源issue
- 将不会同时变化的值拆分成多个 context
- 将组件拆分成多个,将有复杂逻辑的组件使用 memo
- 组件内部使用 useMemo
使用 hooks 后,Context 的使用也会有所不同,这里单独比较一下两者,下面是 Context API 的使用例子,仔细观看本质是一个 Render Props 案例
// 1. 使用 createContext 创建上下文
const UserContext = new createContext();
// 2. 创建 Provider
const UserProvider = props => {
let [username, handleChangeUsername] = useState('');
return (
<UserContext.Provider value={{ username, handleChangeUsername }}>
{props.children}
</UserContext.Provider>
);
};
// 3. 创建 Consumer
const UserConsumer = UserContext.Consumer;
// 4. 使用 Consumer 包裹组件
const Panel = () => (
<UserConsumer>
{({ username, handleChangeUsername }) => (
<div>
<div>user: {username}</div>
<input onChange={e => handleChangeUsername(e.target.value)} />
</div>
)}
</UserConsumer>
);
使用 useContext hooks
// 1. 使用 createContext 创建上下文
const UserContext = new createContext();
// 2. 创建 Provider
const UserProvider = props => {
let [username, handleChangeUsername] = useState('');
return (
<UserContext.Provider value={{ username, handleChangeUsername }}>
{props.children}
</UserContext.Provider>
);
};
const Panel = () => {
const { username, handleChangeUsername } = useContext(UserContext); // 3. 使用 Context
return (
<div>
<div>user: {username}</div>
<input onChange={e => handleChangeUsername(e.target.value)} />
</div>
);
};
ref
refs 能获取到 Component 或 DOM Element 实例,应该尽量避免使用它,因为它会导致代码可读性变差,而且打破了 top-to-bottom 数据流。如果有更好的方式去解决问题,就不要轻易使用它,比如提升状态或函数至父组件。
切记:避免将 ref 用于任何可以通过声明完成的事情。
对于 refs 有几个很好的用例:
- 管理焦点,文本选择或媒体播放。
- 触发命令式动画。
- 与第三方 DOM 库集成。
React 支持可以附加到任何组件的特殊属性。该 ref 属性采用回调函数,并且在组件挂载或卸载后立即执行回调。
- 当在 ref 在 HTML 元素上使用该属性时,该 ref 回调接收基础 DOM 元素作为其参数
- 当在 ref 声明为类的自定义组件上使用该属性时,ref 回调接收组件的已装入实例作为其参数
- 可通过传递 props 函数,将 DOM Refs 公开给父组件,
具体使用 API,注意之前的字符串方式已被淘汰,改为采用回调函数的方式
- 函数组件使用 useRef hook
- 类组件使用 React.createRef()
自定义组件暴露内部元素给父组件:在子组件上设置一个特殊的 props 回调来对父组件暴露 DOM refs,来把父组件的 ref 传向子节点的 DOM 节点,也就转发 ref,为了规范化,React 提供了 forwardRef 来做这件事情。
准确理解 forwardRef:并不是啥特殊的功能,就是 ref 通过 props 转发,其只是 React 封装的一个简单 api
记住 ref 不是 prop 属性。就像 key 一样,其被 React 进行了特殊处理
callback ref 与 ref
- 使用
[]
作为useCallback
的依赖列表。这确保了ref callback
不会在再次渲染时改变,因此 React 不会在非必要的时候调用它。 - 不使用 useRef 的原因:当 ref 是一个对象时它并不会把当前 ref 的值的变化通知到我们。如果使用 useEffect 监听
current
会有问题
这里来分析一下监听 ref.current
为什么会存在问题
首先理解 dependencies 注意事项
- dependencies 通常指包括参与 React top-down 数据流的数据,比如 props,state 或其他通过他们计算而来的数据
- 在 dependencies 中监听 mutable field 会存在问题,因为他们不能保证在渲染之前改变或者改变它会触发一次渲染。ref 就是一个例子,他在渲染完成之后改变,这会导致滞后,导致运行异常。
具体分析监听 ref.current 存在的问题
- 一开始 current 初始值为 undefined,组件初次渲染完成后,current 被设值为 dom element,但此时 React 无法知道更改成功通知
- 由于某些因素导致组件再次渲染时候,effect 会比较前后两次的值,此时 React 还是把 current 当初 undefined 对待,比较结果为 false,再次触发 effect,虽然其实值并没有发生变化
有趣的 ref 回调
- 不要在 ref 中使用内联函数,这会导致每次重新渲染 ref 都是新的回调函数
- 这会导致每次更新,都会触发两次回调,一次 null 值和一次 dom 值(如果你设计两个函数,会发现传 null 调用旧函数,传 dom 调用新函数)
- 这是 react 做的
clean up
工作(清理旧的 ref,设置新的 ref),为避免内存泄露。针对的场景就是,previous callback 和 next callback 是两个完全不同的函数。清理工作是必须的,用于闲置引用
OOP
OOP 的优势
- 业务模型复杂时,OOP 把数据结构和处理数据结构的方法组织在一起,比起 FP 的散乱陈列的方法更为清晰
- OOP 有丰富的、成熟的、好用的设计模式
- OOP 相比 FP,更容易发挥 TypeScript 的优势
toJSON fromJSON
性能工具
推荐两个可用工具
- react-addons-perf:测量重新渲染花费的时间
- why-did-you-update:发现应用里是否存在不该重新渲染的节点工具
测试
TODO
项目思考
项目也开发一段时间了,总结下项目中需要改善的地方吧
其他
Code Split
代码分割
- 动态 import
- React.lazy:目前只支持默认导出(default exports)
- 基于路由的代码分割
React.lazy 和 Suspense 技术还不支持服务端渲染。如果你想要在使用服务端渲染的应用中使用,我们推荐 Loadable Components 这个库。
错误边界
错误边界是一种 React 组件,这种组件可以捕获并打印发生在其子组件树任何位置的 JavaScript 错误,并且,它会渲染出备用 UI,而不是渲染那些崩溃了的子组件树。错误边界在渲染期间、生命周期方法和整个组件树的构造函数中捕获错误。
错误边界无法捕获以下场景中产生的错误:
- 事件处理
- 异步代码
- 服务端渲染
- 它自身抛出来的错误(并非它的子组件)
如果一个 class 组件中定义了 static getDerivedStateFromError() 或 componentDidCatch() 这两个生命周期方法中的任意一个(或两个)时,那么它就变成一个错误边界。当抛出错误后,请使用 static getDerivedStateFromError() 渲染备用 UI ,使用 componentDidCatch() 打印错误信息。
只有 class 组件才可以成为错误边界组件。大多数情况下, 你只需要声明一次错误边界组件, 并在整个应用中使用它。
key 属性
React.Fragment 片段可以具有 key,key 也是目前唯一可以传递给 Fragment 的属性。
JSX
JSX 深入
- 运行时选择类型,由于你不能将通用表达式作为 React 元素类型。如果你想通过通用表达式来(动态)决定元素类型,你需要首先将它赋值给大写字母开头的变量。
- Props 默认值为
true
- 布尔类型、Null 以及 Undefined 将会忽略:助于依据特定条件来渲染其他的 React 元素
虚拟长列表
可以参考
- react-window
- react-virtualized
Portals
Portals
- 将子节点渲染到存在于父组件以外的 DOM 节点的优秀的方案。
- 通过 Portal 进行事件冒泡:其行为和普通的 React 子节点行为一致。
文件结构
项目文件结构
- 按功能或路由组织
- 按文件类型组织
- 避免多层嵌套:考虑将单个项目中的目录嵌套控制在最多三到四个层级内。
- 不要过度思考:通常,将经常一起变化的文件组织在一起是个好主意。这个原则被称为 “colocation”。