ReactJS 随手记(持续)

  • start date: 2018-05-27 22:00:45

React 是一个轻量库,它只关注 MVC 的试图部分,它遵循从上到下的单向数据流。

1 setState()运作机制及优化

ReactJS 中数据到 UI 的映射就是靠state。React 通过管理状态实现对组件的管理,通过 this.state()方法更新 state。当 this.setState()被调用的时候,React 会重新调用 render 方法来重新渲染 UI。

1.1 setState 用法

语法:

1
setState(updater, [callback])

它能接收两个参数,其中第一个参数 updater 可以为对象或者为函数 ((prevState, props) => stateChange),第二个参数为回调函数

1.2 setState 异步更新

setState 通过一个队列机制实现 state 的更新。当执行 setState 时,会把需要更新的 state 合并后放入状态队列,而不会立刻更新 this.state,利用这个队列机制可以高效的批量的更新 state。

p-setstate1.png

对 setState 的认知:

  • 1.setState 不会立刻改变 React 组件中 state 的值.
  • 2.setState 通过触发一次组件的更新来引发重绘.
  • 3.多次 setState 函数调用产生的效果会合并。

重绘指的就是引起 React 的更新生命周期函数 4 个函数:

  • shouldComponentUpdate(被调用时 this.state 没有更新;如果返回了 false,生命周期被中断,虽然不调用之后的函数了,但是 state 仍然会被更新)
  • componentWillUpdate(被调用时 this.state 没有更新)
  • render(被调用时 this.state 得到更新),此过程执行最耗性能
  • componentDidUpdate

目前 React 会将 setState 的效果放在队列中,积攒着一次引发更新过程,为的就是把 Virtual DOM 和 DOM 树操作降到最小,用于提高性能。

在 React 中,如果是由 React 引发的事件处理(比如通过 onClick 引发的事件处理),调用 setState 不会同步更新 this.state,除此之外(addEventListener 直接添加的事件处理函数,还有通过 setTimeout/setInterval)的 setState 调用会同步执行 this.state。

看一道题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Example extends React.Component {
constructor() {
super();
this.state = {
val: 0,
};
}

componentDidMount() {
this.setState({ val: this.state.val + 1 });
console.log(this.state.val); // 第 1 次 log

this.setState({ val: this.state.val + 1 });
console.log(this.state.val); // 第 2 次 log

setTimeout(() => {
this.setState({ val: this.state.val + 1 });
console.log(this.state.val); // 第 3 次 log

this.setState({ val: this.state.val + 1 });
console.log(this.state.val); // 第 4 次 log
}, 0);
}

render() {
return null;
}
}

结果是:
0、0、2、3。原因:接下来就可以理解了,因为在 componentDidMount 中调用 setState 时,batchingStrategy 的 isBatchingUpdates 已经被设置为 true 了,所以两次 setState 的结果并没有立即生效,而是被放进了 dirtyComponents 中。这也解释了两次打印 this.state.val 都是 0 的原因,因为新的 state 还没被应用到组件中。

在看 setTimeOut 中的两次 setState,因为没有前置的 batchedUpdate 调用,所以 batchingStrategy 的 isBatchingUpdates 标志位是 false,也就导致了新的 state 马上生效,没有走到 dirtyComponents 分支。也就是说,setTimeOut 中的第一次执行,setState 时,this.state.val 为 1,而 setState 完成后打印时 this.state.val 变成了 2。第二次的 setState 同理。

优化技巧

2.diff 算法

diff 算法用于计算出两个 virtual dom 的差异,是 react 中开销最大的地方

传统 diff 算法通过循环递归对比差异,算法复杂度为O(n3)
react diff 算法制定了三条策略,将算法复杂度从 O(n3)降低到O(n)

  • WebUI 中 DOM 节点跨节点的操作特别少,可以忽略不计。
  • 拥有相同类的组件会拥有相似的 DOM 结构。拥有不同类的组件会生成不同的 DOM 结构。
  • 同一层级的子节点,可以根据唯一的 ID(key)来区分。

针对这三个策略,react diff 实施的具体策略是:

  • diff 对树进行分层比较,只对比两棵树同级别的节点。跨层级移动节点,将会导致节点删除,重新插入,无法复用。
  • diff 对组件进行类比较,类相同的递归 diff 子节点,不同的直接销毁重建。diff 对同一层级的子节点进行处理时,会根据 key 进行简要的复用。两棵树中存在相同 key 的节点时,只会移动节点。

优化

  • 减少 diff 算法触发次数,即减少 update 流程的次数。正常进入 update 流程有三种方式:
    • setState:常见的业务场景即处理接口回调时,无论数据处理多么复杂,保证最后只调用一次 setState。
    • 父组件 render:父组件的 render 必然会触发子组件进入 update 阶段,此时最常用的优化方案即为 shouldComponentUpdate 方法。最常见的方式为进行 this.props 和 this.state 的浅比较来判断组件是否需要更新。或者直接使用 PureComponent。
    • forceUpdate:只能弃用
  • 正确使用 diff 算法:
    • 不使用跨层级移动节点的操作。
    • 对于条件渲染多个节点时,尽量采用隐藏等方式切换节点,而不是替换节点。
    • 尽量避免将后面的子节点移动到前面的操作,当节点数量较多时,会产生一定的性能问题。

3.PureComponent

React.PureComponent 与 React.Component 很相似。两者的区别在于 React.Component 并未实现 shouldComponentUpdate(),而 React.PureComponent 中以浅层对比 prop 和 state 的方式来实现了该函数。

若是数组和对象等引用类型,则要引用不同,才会渲染。但是如果 prop 和 state 每次都会变,那么 PureComponent 的效率还不如 Component,因为你知道的,进行浅比较也是需要时间

如果赋予 React 组件相同的 props 和 state,render() 函数会渲染相同的内容,那么在某些情况下使用 React.PureComponent 可提高性能。

PureComponent 不仅会影响本身,而且会影响子组件,所以 PureComponent 最佳情况是展示组件

不要在 PureComponent 中使用 shouldComponentUpdate,因为根本没有必要,也会触发警告Warning: ListOfWords has a method called shouldComponentUpdate(). shouldComponentUpdate should not be used when extending React.PureComponent. Please extend React.Component if shouldComponentUpdate is used.

继承 PureComponent 时,进行的是浅比较,也就是说,如果是引用类型的数据,只会比较是不是同一个地址,而不会比较具体这个地址存的数据是否完全一致

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 无论你怎么点击按钮,ListOfWords渲染的结果始终没变化,原因就是WordAdder的word的引用地址始终是同一个。
class ListOfWords extends React.PureComponent {
render() {
return <div>{this.props.words.join(',')}</div>;
}
}
class WordAdder extends React.Component {
constructor(props) {
super(props);
this.state = {
words: ['marklar'],
};
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
// This section is bad style and causes a bug
const words = this.state.words;
words.push('marklar');
this.setState({ words: words });
}
render() {
return (
<div>
<button onClick={this.handleClick}>click</button>
<ListOfWords words={this.state.words} />
</div>
);
}
}

3.1 React.memo

React.memo 为高阶组件。它与 React.PureComponent 非常相似,但只适用于函数组件,而不适用 class 组件。它和 PureComponent 在数据对比上唯一的区别就在于 ×× 只进行了 props 的浅比较 ××。

如果你的函数组件在给定相同 props 的情况下渲染相同的结果,那么你可以通过将其包装在 React.memo 中调用,以此通过记忆组件渲染结果的方式来提高组件的性能表现。这意味着在这种情况下,React 将跳过渲染组件的操作并直接复用最近一次渲染的结果。

React.memo 仅检查 props 变更。如果函数组件被 React.memo 包裹,且其实现中拥有 useState 或 useContext 的 Hook,当 context 发生变化时,它仍会重新渲染。

默认情况下其只会对复杂对象做浅层对比,如果你想要控制对比过程,那么请将自定义的比较函数通过第二个参数传入来实现。

如:

1
2
3
4
5
6
7
8
9
10
11
function MyComponent(props) {
/* 使用 props 渲染 */
}
function areEqual(prevProps, nextProps) {
/*
如果把 nextProps 传入 render 方法的返回结果与
将 prevProps 传入 render 方法的返回结果一致则返回 true,
否则返回 false
*/
}
export default React.memo(MyComponent, areEqual);

4.生命周期

p-lifecircle.png

5.super()

5.1 super()的作用

ES6 语法中,super 指代父类的构造函数,在你调用 super() 之前,你无法在构造函数中使用 this,JS 不允许这么做。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Person {
constructor(name) {
this.name = name;
}
}

class PolitePerson extends Person {
constructor(name) {
this.greetColleagues(); //这是不允许的
super(name);
}

greetColleagues() {
alert('Good morning folks!');
alert('My name is ' + this.name + ', nice to meet you!');
}
}

如果允许的话,在 super() 之前执行了一个 greetColleagues 函数,greetColleagues 函数里用到了 this.name,这时还没执行 super(name),greetColleagues 函数里就获取不到 super 传入的 name 了,此时的 this.name 是 undefined。

5.2 super() 里为什么要传 props?

执行 super(props) 可以使基类 React.Component 初始化 this.props。

1
2
3
4
5
6
7
// React 内部
class Component {
constructor(props) {
this.props = props;
// ...
}
}

有时候我们不传 props,只执行 super(),或者没有设置 constructor 的情况下,依然可以在组件内使用 this.props,为什么呢?

其实 React 在组件实例化的时候,马上又给实例设置了一遍 props:

1
2
3
// React 内部
const instance = new YourComponent(props);
instance.props = props;

那是否意味着我们可以只写 super() 而不用 super(props) 呢?

不是的。虽然 React 会在组件实例化的时候设置一遍 props,但在 super 调用一直到构造函数结束之前,this.props 依然是未定义的。

1
2
3
4
5
6
7
8
class Button extends React.Component {
constructor(props) {
super(); // 😬 我们忘了传入 props
console.log(props); // ✅ {}
console.log(this.props); // 😬 undefined
}
// ...
}

如果这时在构造函数中调用了函数,函数中有 this.props.xxx 这种写法,直接就报错了。

而用 super(props),则不会报错。

1
2
3
4
5
6
7
8
class Button extends React.Component {
constructor(props) {
super(props); // ✅ 我们传了 props
console.log(props); // ✅ {}
console.log(this.props); // ✅ {}
}
// ...
}

6 受控组件和非受控组件

6.1 受控组件

受控组件是在 React 中处理输入表单的一种技术。表单元素通常维护它们自己的状态,而 react 则在组件的状态属性中维护状态。我们可以将两者结合起来控制输入表单,这称为受控组件。因此,在受控组件表单中,数据由 React 组件处理。

如当用户在 todo 项中输入名称时,调用一个 js 函数 handleChange 捕获每一个输入的数据并将其放入状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// import ...

export class ToDoForm extends React.Component {
constructor(props) {
super(props);
this.state = {
value: '',
};

this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}

handleChange(event) {
this.setState({
value: event.target.value,
});
}

handleSubmit() {
console.log(this.state.value);
}

render() {
return (
<div>
<input type="text" onChange={this.handleChange} />
<button onClick={this.handleSubmit}>submit</button>
</div>
);
}
}

6.2 非受控组件

在大多数情况下都建议使用受控组件,有一种称为非受控组件的方法可以通过使用Ref来处理表单数据。在非受控组件中,Ref用于直接从 DOM 访问表单值,而不是事件处理程序。

如,我们使用React.createRef()定义Ref并传递该输入表单并直接从handleSubmit方法中的 DOM 访问表单值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// import ...

export class ToDoForm extends React.Component {
constructor(props) {
super(props);
this.input = React.createRef();

this.handleSubmit = this.handleSubmit.bind(this);
}

handleSubmit() {
console.log(this.input.current.value); // 一个对节点的引用可以通过ref的current属性得到;
}

render() {
return (
<div>
<input type="text" ref={this.input} />
<button onClick={this.handleSubmit}>submit</button>
</div>
);
}
}

7. 展示(UI)组件和容器组件

为了解决 React 只有 V 层的这个问题,更好地区分我们的代码逻辑,展示组件与容器组件这一对概念就被引入了。

说明/组件 展示组件 容器组件
作用 描述如何展现(骨架、样式) 描述如何运行(数据获取、状态更新)
直接使用 store
数据来源 props 监听 store state
数据修改 从 props 调用回调函数 向 store 派发 actions

7.1 展示组件(persentational components)

  • 负责展示 UI,也就是组件如何渲染,具有很强的内聚性。
  • 只关心得到数据后(props)如何渲染
  • *大多数情况可以通过函数定义组件声明

7.2 容器组件(container components)

  • 负责应用逻辑处理,如
    • 拥有自身的 state,发送网络请求,处理返回数据,将处理过的数据传递给展示组件,或与 redux 等其他数据处理模块协作
    • 也提供修改数据源的方法,通过展示组件的 props 传递给展示组件
    • 当展示组件的状态变更引起源数据变化时,展示组件通过调用容器组件提供的方法同步这些变化

7.3 优点和注意事项

优点:

  • 解耦了界面和数据的逻辑
  • 更好的可复用性,比如同一个回复列表展示组件可以套用不同数据源的容器组件
    利于团队协作,一个人负责界面结构,一个人负责数据交互

注意

  • 展示组件和容器组件是根据组件的意图划分组件,展示组件通常通过无状态组件实现,容器组件通过有状态组件实现
  • 无状态和有状态组件时根据组件内部是否使用 state 划分组件

8.Fragments

在 React 中,我们需要有一个父元素,同时从组件返回 React 元素,有时在 DOM 中添加额外的节点会很烦人。这时候就可以使用 Fragments,我们不需要再 DOM 中添加额外的节点,只需要用React.Fragment或直接简写为<>来包裹内容就行。

如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// origin
return (
<div>
<Component A />
<Component B />
<Component C />
</div>
);

// use fragments
return (
<React.Fragment>
<Component A />
<Component B />
<Component C />
</React.Fragment>
);

// or
return (
<>
<Component A />
<Component B />
<Component C />
</>
);

如何添加 key 属性

1
2
3
4
5
6
7
8
let list = [1, 2, 3, 4, 5, 6];
list.map(item => (
<React.Fragment key={item}>
<Component A />
<Component B />
<Component C />
</React.Fragment>
));

9.传送门Portals

默认情况下,所有子组件都在 UI 上呈现,具体取决于组件层次结构。Portal 提供了一种将子节点渲染到存在于父组件以外的 DOM 节点的优秀的方案。

如,有个父组件 parent 在 DOM 层次结构中有子组件 children,我们可以将子组件 children 移出 parent 组件并将其附加 id 为其他的 DOM 节点下,如 id 为 someid 的节点。
首先,获取 id 为 someid,我们在 constructor 中创建一个元素 div,将 child 附加到 componentDidMount 中的 someRoot。
然后我们在React.createPortal(this.props.children, domnode)方法的帮助下将子节点传递给该特定 DOM 节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const someRoot = document.querySelector('#someid');

class Modal extends React.Component {
constructor(props) {
super(props);
this.el = document.createElement('div');
}

componentDidMount() {
someRoot.appendChild(this.el);
}

componentWillUnmount() {
someRoot.removeChild(this.el);
}

render() {
return ReactDOM.createPortal(this.props.children, this.el);
}
}

10.React16 的兼容

React 16 依赖集合类型 Map 和 Set 。如果你要支持无法原生提供这些能力(例如 IE < 11)或实现不规范(例如 IE 11)的旧浏览器与设备,考虑在你的应用库中包含一个全局的 polyfill ,例如 core-js 或 babel-polyfill 。
如:

1
2
3
4
5
6
7
import 'core-js/es/map';
import 'core-js/es/set';

import React from 'react';
import ReactDOM from 'react-dom';

ReactDOM.render(<h1>Hello, world!</h1>, document.getElementById('root'));

11.拆分组件的原则

满足高内聚(High Cohesion)和低耦合(Low Coupling)的原则。

  • 高内聚:把逻辑紧密相关的内容放在一个组件中。如 jsx 将 js 和 html 甚至 css 聚合在一起,天生具有高内聚的特点;
  • 低耦合:不同组件之间的依赖关系要尽量弱化,每个组件要尽量独立以保持整个系统的低耦合度。

12.生命周期

12.1 * getInitialState 和 getDefaultProps

getInitialState()函数的返回值会用来初始化组件的 this.state,getDefaultProps()函数的返回值可以作为 props 的初始值。这两个函数只在 React.createClass 方法创造的组件类才会用到(ES6 定义的 React 组件中根本用不到)。

如以下两个定义结果相同

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// getInitialState和getDefaultProps
const Sample = React.createClass({
getInitialState: function () {
return {
foo: 'bar',
};
},

getDefaultProps: function () {
return {
sampleProp: 0,
};
},
});
1
2
3
4
5
6
7
8
9
10
11
12
// es6
class Sample extends React.Component {
constructor(props) {
super(props);
this.state = {
foo: 'bar',
};
}
}
Sample.defaultProps = {
sampleProp: 0,
};

*当然,包括官方也不推荐使用 React.createClass,所以基本不会用这两个函数

12.2 componentWillMount 和 componentDidMount

componentWillMount 可能将在 17 版本移除。原因有两点:1.服务端渲染: 在服务端渲染的情景下, componentWillMount 执行完立马执行 render 会导致 componentWillMount 里面执行的方法(获取数据, 订阅事件) 并不一定执行完;2.Concurrent Render: 在 fiber 架构下, render 前的钩子会被多次调用, 在 componentWillMount 里执行订阅事件就会产生内存泄漏。这样的话我们需要迁移思路, 将以前写在 componentWillMount 的获取数据、时间订阅的方法写进 componentDidMount 中;

众所周知,componentWillMount 会在调用 render 函数之前被调用,componentDidMount 会在调用 render 之后调用。

通常我们不用定义 componentWillMount 函数,这个时候没有任何渲染出来的结构,即使调用 this.setState 修改状态也不会引发重新绘制。所有可以在 componentWillMount 中做的事情都可以提前到 constructor 中间去做。

render 函数被调用完之后,componentDidMount 函数并不会被立即调用,componentDidMount 被调用的时候,render 函数返回的东西已经引发了渲染,组件已经被“装载”到了 DOM 树上。需要注意的是,render 函数本身并不往 DOM 树上渲染或者装载内容,它只是返回一个 JSX 表示的对象,然后由 React 库来根据返回对象决定如何渲染。而 React 肯定要把所有组件返回的结果综合起来,才能知道该如何产生对应的 DOM 修改。所以多个组件渲染的情况下,React 只有调用多个组件的 render 函数之后才会依次调用各个组件的 componentDidMount 函数作为装载过程的收尾。这点与 componentWillMount 不一样。

componentWillMount 和 componentDidMount 这对兄弟节点还有个区别:componentWillMount 可以在服务端被调用,也可以在浏览器中被调用;而 componentDidMount 只能在浏览器中被调用,服务端不会被调用。因为服务端渲染并不会产生 DOM 树,通过 React 组件产生的只是一个纯粹的字符串而已。也因如此,componentDidMount 给了开发者一个很好的位置去做只有浏览器端才能做的逻辑,比如调用 ajax。

12.3 componentWillReceiveProps(nextProps)

本以为这个函数只有当组件的 props 发生改变时才会被调用,其实是不正确的。只要父组件的 render 函数被调用,在 render 函数里面被渲染的子组件就会经历更新过程,不管父组件传给子组件的 props 有没有改变都会触发该函数。

12.4 shouldComponentUpdate

render 和 shouldComponentUpdate 是 React 生命周期函数中唯二两个要求有返回结果的函数。render 函数的返回结果将用于构造 DOM 对象,而 shouldComponentUpdate 函数返回一个布尔值,告诉 React 库这个组件在这次更新过程中是否要继续,这个作用可见上文。

13 Hooks(16.7)

在 React 16.7 之前, React 有两种形式的组件, 有状态组件(类)和无状态组件(函数)。Hooks 的意义就是赋能先前的无状态组件, 让之变为有状态。这样一来更加契合了 React 所推崇的函数式编程,React 团队希望组件不要变成复杂的容器,最好只是数据流的管道,开发者根据需要组合管道即可。

类组件的缺点

  • 明显的就是代码会很重,编程模式复杂
  • 大型组件很难拆分和重构,也很难测试
  • 业务逻辑分散在组件的各个方法之中,导致重复逻辑或关联逻辑

Hook 的含义

钩子,React Hooks 的意思是,组件尽量写成纯函数,如果需要外部功能和副作用,就用钩子把外部代码“钩”进来。

钩子使用

React 约定钩子一律使用 use 前缀命名,即你要使用 xxx 功能,钩子就命名为 usexxx。Hooks 涉及到最核心的 2 个 api, useStateuseEffect

useState:状态钩子。

返回状态和一个更新状态的函数。
如:

1
2
3
4
5
6
7
8
9
10
function App() {
const [count, setCount] = useState(0);

return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
);
}

原理:闭包+单向循环链表。

useEffect(fn, relyon):副作用钩子。

在每次 render 后都会执行这个钩子。可以将它当成是 componentDidMount、componentDidUpdate、componentWillUnmount 的合集。因此使用 useEffect 比之前优越的地方在于: - 可以避免在 componentDidMount、componentDidUpdate 书写重复的代码; - 可以将关联逻辑写进一个 useEffect(在以前得写进不同生命周期里);

如:

1
2
3
4
5
6
7
8
9
function App() {
const [val, setVal] = useState(0);

useEffect(() => {
console.log(val);
}, [val]);

return <>{val}</>;
}

useLayoutEffect(fn, relyon)

其函数签名与 useEffect 相同,但它会在所有的 DOM 变更之后同步调用 effect。可以使用它来读取 DOM 布局并同步触发重渲染。在浏览器执行绘制之前,useLayoutEffect 内部的更新计划将被同步刷新。

简单来说 useLayoutEffect 会更快执行,因此仅在某些特殊情况下(比如 DOM 操作)用 useLayoutEffect, 其他像异步请求的用 useEffect。

如:

1
2
3
4
5
6
7
8
9
function App() {
useLayoutEffect(() => {
document.title = `You clicked ${count} times`;
return () => {
document.title += '!!!';
};
}, []);
return <>hello</>;
}

useRef

1
const refContainer = useRef(initialValue);

useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数(initialValue)。返回的 ref 对象在组件的整个生命周期内保持不变

本质上,useRef 就像是可以在其 .current 属性中保存一个可变值的“盒子”。

保存 DOM

1
2
3
4
5
6
7
8
9
function Test() {
const divRef = useRef(null);

useEffect(() => {
handleDiv(divRef.current); // div
});

return <div ref={divRef}> ... </div>;
}

保存事件程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Test() {
const eventRef = useRef(null);
function handleClick() {
eventRef.current = setTimeout(() => handleEvent(1), 2000);
}
function handleClear() {
clearTimeout(eventRef.current);
}

return (
<>
<button onClick={handleClick}>start</button>
<button onClick={handleClear}>clear</button>
</>
);
}

存储以前的值

useState()/useEffect()经常遇到因闭包导致的取值问题,这时候就需要通过 useRef()来解决。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Test() {
const t = useRef(null);
const [name, setName] = useState('ajanuw');
useEffect(() => {
t.current = name;
});
const prevName = t.current;
return (
<div>
<input value={name} onChange={e => setName(e.target.value)} />
<h2>{name}</h2>
<p>{prevName}</p>
</div>
);
}

useImperativeHandle(ref, createHandle, [deps])

useImperativeHandle 可以让你在使用 ref 时自定义暴露给父组件的实例值。在大多数情况下,应当避免使用 ref 这样的命令式代码。useImperativeHandle 应当与 forwardRef 一起使用:

1
2
3
4
5
6
7
8
9
10
function FancyInput(props, ref) {
const inputRef = useRef();
useImperativeHandle(ref, () => ({
focus: () => {
inputRef.current.focus();
}
}));
return <input ref={inputRef} ... />;
}
FancyInput = forwardRef(FancyInput);

useCallback()

1
2
3
const memoizedCallback = useCallback(() => {
doSomething(a, b);
}, [a, b]);

返回一个 memoized 回调函数

把内联回调函数及依赖项数组作为参数传入 useCallback,它将返回该回调函数的 memoized 版本,该回调函数仅在某个依赖项改变时才会更新。当你把回调函数传递给经过优化的并使用引用相等性去避免非必要渲染(例如 shouldComponentUpdate)的子组件时,它将非常有用。

useCallback(fn, deps) 相当于 useMemo(() => fn, deps)

useMemo()

1
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

返回一个 memoized

把“创建”函数和依赖项数组作为参数传入 useMemo,它仅会在某个依赖项改变时才重新计算 memoized 值。这种优化有助于避免在每次渲染时都进行高开销的计算。

记住,传入 useMemo 的函数会在渲染期间执行。请不要在这个函数内部执行与渲染无关的操作,诸如副作用这类的操作属于 useEffect 的适用范畴,而不是 useMemo。

如果没有提供依赖项数组,useMemo 在每次渲染时都会计算新的值。

你可以把 useMemo 作为性能优化的手段,但不要把它当成语义上的保证。将来,React 可能会选择“遗忘”以前的一些 memoized 值,并在下次渲染时重新计算它们,比如为离屏组件释放内存。先编写在没有 useMemo 的情况下也可以执行的代码 —— 之后再在你的代码中添加 useMemo,以达到优化性能的目的。

除此之外还有两个不常用的钩子:

  • useContext:共享状态钩子。在组件之间共享状态时使用。
  • useReducer:action 钩子。Redux 等数据 action。

hooks 的优缺点

知乎——《谈谈 react hooks 的优缺点》

优点

  • 1.更容易复用代码:可以通过自定义 hooks 来复用状态,从而解决了类组件有时候难以复用逻辑的问题
  • 2.清爽的代码风格:函数式编程风格,函数式组件、状态保存在运行环境、每个功能都包裹在函数中,整体风格更清爽优雅。
  • 3.代码量更少:不像 class 组件需要通过 this,props 更简洁。

缺点

  • 1.状态不同步:函数独立运行导致的独立作用域,函数的变量也是保存在运行时的作用域里面,当我们有异步操作的时候,经常会碰到异步回调的变量引用是之前的;如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import React, { useState } from 'react';
const Counter = () => {
const [count, setCount] = useState(0);
const onAlertButtonClick = () => {
setTimeout(() => {
alert('count: ' + count);
}, 1000);
};

return (
<>
<p>{count}</p>
<p>
<button onClick={onAlertButtonClick}>click</button>
</p>
</>
);
};
  • 2.响应式的 useEffect:改变写法习惯,需要掌握 useEffect 的触发时机,对比 componentDidmount 和 componentDidUpdate,useEffect 带来的心智负担更大;

避免常见问题

  • 1.不要再 useEffect 里写太多依赖项,划分这些依赖项成多个单一功能的 useEffect,“单一职责模式”;
  • 2.注意闭包和作用域可能存在的风险。如遇到状态不同步的问题,可以考虑使用 useRef 或手动传递参数到函数。如
1
2
3
4
5
6
7
8
9
10
11
// showCount的count值来自父级作用域
const [count, setCount] = useState(xxx);
function showCount() {
console.log(count);
}

// showCount的count来自参数
const [count, setCount] = useState(xxx);
function showCount(c) {
console.log(c);
}

14 单元测试

单测引导:testing-library>>

测试原则

架构层级 测试内容 测试策略 解释
action(creator)层 是否正确创建 action 对象 一般不需要测试,视信心而定 这个层级架构上非常简单,设施搭好后一般不会出错
reducer 层 是否正确完成计算 有逻辑的 reducer,要求 100%覆盖率 这个层级输入输出明确,又包含业务计算,非常适合单元测试
selector 层 是否正确完成计算 有逻辑的 selector,要求 100%覆盖率 这个层级输入输出明确,又包含业务计算,非常适合单元测试
saga 层 是否获取了正确的参数 这 5 个业务点建议 100%覆盖 这个层级主要包含前述 5 大方面的业务逻辑,进行测试很有重构价值
是否正确地调用了 API
是否使用了正确的返回值存取回 redux 中
业务分支逻辑
异常逻辑
component 层 组件分支渲染逻辑 要求 100%覆盖率 这个层级最为复杂,以“代价最低,收益最高”的指导原则进行
交互事件是否以正确的参数被调用 要求 100%覆盖率
redux connect 过的组件 不用测
UI 层 组件是否渲染了正确的样式 可以不测或快照 这个层级测试成本较高、难度较大
utils 层 各种帮助函数 没有副作用的必须 100%覆盖率

action 测试

这一层获益于架构的简单性,甚至都可以不用测试。当然,如果有些经常出错的 action,可以针对性地对这些 action creator 补充测试。其测试方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// demo action
export const saveUserComments = comments => ({
type: 'saveUserComments',
payload: {
comments,
},
});

// demo use action
import * as actions from './actions';

test('should dispatch saveUserComments action with fetched user comments', () => {
const comments = [];
const expected = {
type: 'saveUserComments',
payload: {
comments: [],
},
};
const result = actions.saveUserComments(comments);
expect(result).toEqual(expected);
});

reducer 测试

reducer 大概有两种:

  • 一种比较简单,仅一一保存对应的数据切片;
  • 一种复杂一些,里面具有一些计算逻辑。
    对于第一种 reducer,写起来非常简单,简单到甚至可以不需要用测试去覆盖,其正确性基本由简单的架构和逻辑去保证。下面是对一个简单 reducer 做测试的例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// demo reducer
import Immutable from 'seamless-immutable';

const initialState = Immutable.from({
isLoadingProducts: false,
});

export default createReducer(on => {
on(actions.isLoadingProducts, (state, action) => {
return state.merge({
isLoadingProducts: action.payload.isLoadingProducts,
});
});
}, initialState);

// demo use reducer
import reducers from './reducers';
import actions from './actions';

test('should save loading start indicator when action isLoadingProducts is dispatched given isLoadingProducts is true', () => {
const state = { isLoadingProducts: false };
const expected = { isLoadingProducts: true };
const result = reducers(state, actions.isLoadingProducts(true));

expect(result).toEqual(expected);
});

reducer 作为纯函数,非常适合做单元测试,加之一般在 reducer 中做重逻辑处理,此处做单元测试保护的价值很大。请留意,上面所说的单元测试,是不是符合我们描述的单元测试基本原则:

  • 只关注输入输出,不关注内部实现:在输入不变时,仅可能因为“合并去重”的业务操作不符预期时才会挂测试
  • 表达力极强:测试描述已经写得清楚“当使用新获取到的留言数据分发 action saveUserComments 时,应该与已有留言合并并去除重复的部分”;此外,测试数据只准备了足够体现“合并”这个操作的两条 id 的数据,而没有放很多的数据,形成杂音;
  • 不包含逻辑:测试代码不包含准备数据、调用、断言外的任何逻辑
  • 运行速度快:没有任何依赖

selector 测试

selector 同样是重逻辑的地方,可以认为是 reducer 到组件的延伸。它也是一个纯函数,测起来与 reducer 一样方便、价值不菲,也是应该重点照顾的部分。况且,稍微大型一点的项目,应该说必然会用到 selector。原因我讲在这里。下面看一个 selector 的测试用例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// demo selector
import { createSelector } from 'reselect';

// for performant access/filtering in React component

export const labelArrayToObjectSelector = createSelector(
[(store, ownProps) => store.products[ownProps.id].labels],
labels => {
return labels.reduce(
(result, { code, active }) => ({
...result,
[code]: active,
}),
{}
);
}
);

// demo use selector
import { labelArrayToObjectSelector } from './selector';

test('should transform label array to object', () => {
const store = {
products: {
10085: {
labels: [
{ code: 'canvas', name: '帆布鞋', active: false },
{ code: 'casual', name: '休闲鞋', active: false },
{ code: 'oxford', name: '牛津鞋', active: false },
{ code: 'bullock', name: '布洛克', active: true },
{ code: 'ankle', name: '高帮鞋', active: true },
],
},
},
};
const expectedActiveness = {
canvas: false,
casual: false,
oxford: false,
bullock: true,
ankle: false,
};
const productLabels = labelArrayToObjectSelector(store, { id: 10085 });
expect(productLabels).toEqual(expectedActiveness);
});

saga 测试

saga 是负责调用 API、处理副作用的一层。在实际的项目上副作用还有其他的中间层进行处理,比如 redux-thunk、redux-promise 等,本质是一样的,只不过 saga 在测试性上要好一些。这一层副作用怎么测试呢?首先为了保证单元测试的速度和稳定性,像 API 调用这种不确定性的依赖我们一定是要 mock 掉的。经过仔细总结,我认为这一层主要的测试内容有五点:

  • 是否使用正确的参数(通常是从 action payload 或 redux 中来),调用了正确的 API
  • 对于 mock 的 API 返回,是否保存了正确的数据(通常是通过 action 保存到 redux 中去)
  • 主要的业务逻辑(比如仅当用户满足某些权限时才调用 API 等分支逻辑)
  • 异常逻辑(比如找不到用户等异常逻辑)
  • 其他副作用是否发生(比如有时有需要 Emit 的事件、需要保存到 IndexDB 中去的数据等)

我们认为真正能够保障质量、重构和开发者体验的 saga 测试应该是这样:

  • 不依赖实现次序;
  • 允许仅对真正关心的、有价值的业务进行测试;
  • 支持不改动业务行为的重构;

官方提供了这么一个跑测试的工具,刚好可以用来完美满足我们的需求:runSaga。我们可以用它将 saga 全部执行一遍,搜集所有发布出去的 action,由开发者自由断言其感兴趣的 action!基于这个发现,我们推出了我们的第二版 saga 测试方案:runSaga + 自定义拓展 jest 的 expect 断言。最终,使用这个工具写出来的 saga 测试,几近完美:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { put, call } from 'saga-effects';
import { Api } from 'src/utils/axios';
import { testSaga } from '../../../testing-utils';
import { onEnterProductDetailPage } from './saga';

const product = productId => ({ productId });
test(`
should only save the three recommended products and show ads
when user enters the product detail page
given the user is not a VIP
`, async () => {
const action = { payload: { userId: 233 } };
const store = { credentials: { vipList: [2333] } };
const recommendedProducts = [product(1), product(2), product(3), product(4)];
const firstThreeRecommendations = [product(1), product(2), product(3)];

Api.get = jest.fn().mockImplementations(() => recommendedProducts);
await testSaga(onEnterProductDetailPage, action, store);
expect(Api.get).toHaveBeenCalledWith('products/recommended');
expect(actions.importantActionToSaveRecommendedProducts).toHaveBeenDispatchedWith(
firstThreeRecommendations
);
expect(actions.importantActionToFetchAds).toHaveBeenDispatched();
});

component 测试

组件测试其实是实践最多、测试实践看法和分歧也最多的地方。React 组件是一个高度自治的单元,从分类上来看,它大概有这么几类:

  • 展示型业务组件
  • 容器型业务组件
  • 通用 UI 组件
  • 功能型组件

先把这个分类放在这里,待会回过头来谈。对于 React 组件测什么不测什么,我有一些思考,也有一些判断标准:除去功能型组件,其他类型的组件一般是以渲染出一个语法树为终点的,它描述了页面的 UI 内容、结构、样式和一些逻辑 component(props) => UI。内容、结构和样式,比起测试,直接在页面上调试反馈效果更好。测也不是不行,但都难免有不稳定的成本在;逻辑这块,有一测的价值,但需要控制好依赖。综合上面提到的测试原则进行考虑,建议是:两测两不测。

  • 组件分支渲染逻辑必须测
  • 事件调用和参数传递一般要测
  • 连接 redux 的高阶组件不测
  • 渲染出来的 UI 不在单元测试层级测

组件类型/测试内容 | 分支渲染逻辑 | 事件调用 | @connect | 纯 UI
展示型组件 | yes | yes | - | no
容器型组件 | yes | yes | no | no
通用 UI 组件 | yes | yes | - | no
功能型组件 | yes | yes | no | no

业务型组件——分支渲染

1
2
3
4
5
6
7
8
9
10
export const CommentsSection = ({ comments }) => (
<div>
{comments.length > 0 && (
<h2>Comments</h2>
)}
{comments.map((comment) => (
<Comment content={comment} key={comment.id} />
)}
</div>
)

对应的测试如下,测试的是不同的分支渲染逻辑:没有评论时,则不渲染 Comments header。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { CommentsSection } from './index';
import { Comment } from './Comment';

test('should not render a header and any comment sections when there is no comments', () => {
const component = shallow(<CommentsSection comments={[]} />);
const header = component.find('h2');
const comments = component.find(Comment);
expect(header).toHaveLength(0);
expect(comments).toHaveLength(0);
});

test('should render a comments section and a header when there are comments', () => {
const contents = [
{ id: 1, author: '男***8', comment: '价廉物美,相信奥康旗舰店' },
{ id: 2, author: '雨***成', comment: '所以一双合脚的鞋子...' },
];
const component = shallow(<CommentsSection comments={contents} />);
const header = component.find('h2');
const comments = component.find(Comment);
expect(header.html()).toBe('Comments');
expect(comments).toHaveLength(2);
});

业务型组件 – 事件调用

测试事件的一个场景如下:当某条产品被点击时,应该将产品相关的信息发送给埋点系统进行埋点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
export const ProductItem = ({ id, productName, introduction, trackPressEvent }) => (
<TouchableWithoutFeedback onPress={() => trackPressEvent(id, productName)}>
<View>
<Title name={productName} />
<Introduction introduction={introduction} />
</View>
</TouchableWithoutFeedback>
);

import { ProductItem } from './index';
test(`
should send product id and name to analytics system
when user press the product item
`, () => {
const trackPressEvent = jest.fn();
const component = shallow(
<productitem
id={100832}
introduction="iMac Pro - Power to the pro."
trackPressEvent={trackPressEvent}
></productitem>
);
component.find(TouchableWithoutFeedback).simulate('press');
expect(trackPressEvent).toHaveBeenCalledWith(100832, 'iMac Pro - Power to the pro.');
});

功能型组件 – children 型高阶组件

功能型组件,指的是跟业务无关的另一类组件:它是功能型的,更像是底层支撑着业务组件运作的基础组件,比如路由组件、分页组件等。这些组件一般偏重逻辑多一点,关心 UI 少一些。其本质测法跟业务组件是一致的:不关心 UI 具体渲染,只测分支渲染和事件调用。但由于它偏功能型的特性,使得它在设计上常会出现一些业务型组件不常出现的设计模式,如高阶组件、以函数为子组件等。下面分别针对这几种进行分述。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
export const FeatureToggle = ({ features, featureName, children }) => {
if (!features[featureName]) {
return null;
}
return children;
};

export default connect(store => ({ features: store.global.features }))(FeatureToggle);

import React from 'react';
import { shallow } from 'enzyme';
import { View } from 'react-native';
import FeatureToggles from './featureToggleStatus';
import { FeatureToggle } from './index';
const DummyComponent = () => <View />;
test('should not render children component when remote toggle does not exist', () => {
const component = shallow(
<FeatureToggle features={{}} featureName="promotion618">
<DummyComponent />
</FeatureToggle>
);
expect(component.find(DummyComponent)).toHaveLength(0);
});
test('should render children component when remote toggle is present and is on', () => {
const features = {
promotion618: FeatureToggles.on,
};
const component = shallow(
<FeatureToggle features={features} featureName="promotion618">
<DummyComponent />
</FeatureToggle>
);
expect(component.find(DummyComponent)).toHaveLength(1);
});
test('should not render children component when remote toggle is present but is off', () => {
const features = {
promotion618: FeatureToggles.off,
};
const component = shallow(
<FeatureToggle features={features} featureName="promotion618">
<DummyComponent />
</FeatureToggle>
);
expect(component.find(DummyComponent)).toHaveLength(0);
});

utils 测试

每个项目都会有 utils。一般来说,我们期望 util 都是纯函数,即是不依赖外部状态、不改变参数值、不维护内部状态的函数。这样的函数测试效率也非常高。测试原则跟前面所说的也并没什么不同,不再赘述。不过值得一提的是,因为 util 函数多是数据驱动,一个输入对应一个输出,并且不需要准备任何依赖,这使得它多了一种测试的选择,也即是参数化测试的方式。参数化测试可以提升数据准备效率,同时依然能保持详细的用例信息、错误提示等优点。jest 从 23 后就内置了对参数化测试的支持,如下:

1
2
3
4
5
6
7
8
9
10
11
12
test.each([
[['0', '99'], 0.99, '(整数部分为0时也应返回)'],
[['5', '00'], 5, '(小数部分不足时应该补0)'],
[['5', '10'], 5.1, '(小数部分不足时应该补0)'],
[['4', '38'], 4.38, '(小数部分不足时应该补0)'],
[['4', '99'], 4.994, '(超过默认2位的小数的直接截断,不四舍五入)'],
[['4', '99'], 4.995, '(超过默认2位的小数的直接截断,不四舍五入)'],
[['4', '99'], 4.996, '(超过默认2位的小数的直接截断,不四舍五入)'],
[['-0', '50'], -0.5, '(整数部分为负数时应该保留负号)'],
])('should return %s when number is %s (%s)', (expected, input, description) => {
expect(truncateAndPadTrailingZeros(input)).toEqual(expected);
});