跳到主要内容

组件基础

1.React 事件机制

<div onClick={this.handleClick.bind(this)}>点我</div>

React 并不是将 click 事件绑定到了 div 的真实 DOM 上,而是在 document 处监听了所有的事件,当事件发生并且冒泡到 document 处的时候,React 将事件内容封装并交由真正的处理函数运行。这样的方式不仅仅减少了内存的消耗,还能在组件挂载销毁时统一订阅和移除事件

除此之外,冒泡到 document 上的事件也不是原生的浏览器事件,而是由 react 自己实现的合成事件(SyntheticEvent)。因此如果不想要是事件冒泡的话应该调用 event.preventDefault()方法,而不是调用 event.stopProppagation()方法。

image-20220701085916823

实现合成事件的目的如下:

  • 合成事件首先抹平了浏览器之间的兼容问题,另外这是一个跨浏览器原生事件包装器,赋予了跨浏览器开发的能力;
  • 对于原生浏览器事件来说,浏览器会给监听器创建一个事件对象。如果你有很多的事件监听,那么就需要分配很多的事件对象,造成高额的内存分配问题。但是对于合成事件来说,有一个事件池专门来管理它们的创建和销毁,当事件需要被使用时,就会从池子中复用对象,事件回调结束后,就会销毁事件对象上的属性,从而便于下次复用事件对象。

2.React 的事件和普通的 HTML 事件有什么不同?

区别:

  • 对于事件名称命名方式,原生事件为全小写,react 事件采用小驼峰;
  • 对于事件函数处理语法,原生事件为字符串,react 事件为函数;
  • react 事件不能采用 return false 的方式来阻止浏览器的默认行为,而必须要地明确地调用preventDefault()来阻止默认行为。

合成事件是 react 模拟原生 DOM 事件所有能力的一个事件对象,其优点如下:

  • 兼容所有浏览器,更好的跨平台;
  • 将事件统一存放在一个数组,避免频繁的新增与删除(垃圾回收)。
  • 方便 react 统一管理和事务机制。

事件的执行顺序为原生事件先执行,合成事件后执行,合成事件会冒泡绑定到 document 上,所以尽量避免原生事件与合成事件混用,如果原生事件阻止冒泡,可能会导致合成事件不执行,因为需要冒泡到 document 上合成事件才会执行。

3.React.Component 和 React.PureComponent 的区别

PureComponent 表示一个纯组件,可以用来优化 React 程序,减少 render 函数执行的次数,从而提高组件的性能

在 React 中,当 prop 或者 state 发生变化时,可以通过在shouldComponentUpdate生命周期函数中执行 return false来阻止页面的更新,从而减少不必要的 render 执行。React.PureComponent 会自动执行 shouldComponentUpdate。

不过,pureComponent 中的 shouldComponentUpdate() 进行的是浅比较,也就是说如果是引用数据类型的数据,只会比较不是同一个地址而不会比较这个地址里面的数据是否一致。浅比较会忽略属性和或状态突变情况,其实也就是数据引用指针没有变化,而数据发生改变的时候 render 是不会执行的。如果需要重新渲染那么就需要重新开辟空间引用数,PureComponent 一般会用在一些纯展示组件上

使用 pureComponent 的好处:当组件更新时,如果组件的 props 或者 state 都没有改变,render 函数就不会触发。省去虚拟 DOM 的生成和对比过程,达到提升性能的目的。这是因为 react 自动做了一层浅比较。

4.React 高阶组件(HOC)是什么,和普通组件有什么区别,适用什么场景

官方解释 ∶

高阶组件(HOC)是 React 中用于复用组件逻辑的一种高级技巧。HOC 自身不是 React API 的一部分,它是一种基于 React 的组合特性而形成的设计模式。

高阶组件(HOC)就是一个函数,且该函数接受一个组件作为参数,并返回一个新的组件,它只是一种组件的设计模式,这种设计模式是由 react 自身的组合性质必然产生的。我们将它们称为纯组件,因为它们可以接受任何动态提供的子组件,但它们不会修改或复制其输入组件中的任何行为。

// hoc的定义
function withSubscription(WrappedComponent, selectData) {
return class extends React.Component {
constructor(props) {
super(props);
this.state = {
data: selectData(DataSource, props)
};
}
// 一些通用的逻辑处理
render() {
// ... 并使用新数据渲染被包装的组件!
return <WrappedComponent data={this.state.data} {...this.props} />;
}
};

// 使用
const BlogPostWithSubscription = withSubscription(BlogPost,
(DataSource, props) => DataSource.getBlogPost(props.id));

1)HOC 的优缺点

  • 优点 ∶ 逻辑复用、不影响被包裹组件的内部逻辑。
  • 缺点 ∶hoc 传递给被包裹组件的 props 容易和被包裹后的组件重名,进而被覆盖

2)适用场景

  • 代码复用,逻辑抽象
  • 渲染劫持
  • State 抽象和更改
  • Props 更改

3)具体应用例子

  • 权限控制: 利用高阶组件的 条件渲染 特性可以对页面进行权限控制,权限控制一般分为两个维度:页面级别和 页面元素级别
// HOC.js
function withAdminAuth(WrappedComponent) {
return class extends React.Component {
state = {
isAdmin: false,
}
async UNSAFE_componentWillMount() {
const currentRole = await getCurrentUserRole();
this.setState({
isAdmin: currentRole === 'Admin',
});
}
render() {
if (this.state.isAdmin) {
return <WrappedComponent {...this.props} />;
} else {
return (<div>您没有权限查看该页面,请联系管理员!</div>);
}
}
};
}

// pages/page-a.js
class PageA extends React.Component {
constructor(props) {
super(props);
// something here...
}
UNSAFE_componentWillMount() {
// fetching data
}
render() {
// render page with data
}
}
export default withAdminAuth(PageA);


// pages/page-b.js
class PageB extends React.Component {
constructor(props) {
super(props);
// something here...
}
UNSAFE_componentWillMount() {
// fetching data
}
render() {
// render page with data
}
}
export default withAdminAuth(PageB);
  • 组件渲染性能追踪: 借助父组件子组件生命周期规则捕获子组件的生命周期,可以方便的对某个组件的渲染时间进行记录 ∶
class Home extends React.Component {
render() {
return <h1>Hello World.</h1>;
}
}
function withTiming(WrappedComponent) {
return class extends WrappedComponent {
constructor(props) {
super(props);
this.start = 0;
this.end = 0;
}
UNSAFE_componentWillMount() {
super.componentWillMount && super.componentWillMount();
this.start = Date.now();
}
componentDidMount() {
super.componentDidMount && super.componentDidMount();
this.end = Date.now();
console.log(
`${WrappedComponent.name} 组件渲染时间为 ${this.end - this.start} ms`
);
}
render() {
return super.render();
}
};
}

export default withTiming(Home);

注意:withTiming 是利用 反向继承 实现的一个高阶组件,功能是计算被包裹组件(这里是 Home 组件)的渲染时间。

  • 页面复用
const withFetching = fetching => WrappedComponent => {
return class extends React.Component {
state = {
data: [],
}
async UNSAFE_componentWillMount() {
const data = await fetching();
this.setState({
data,
});
}
render() {
return <WrappedComponent data={this.state.data} {...this.props} />;
}
}
}

// pages/page-a.js
export default withFetching(fetching('science-fiction'))(MovieList);
// pages/page-b.js
export default withFetching(fetching('action'))(MovieList);
// pages/page-other.js
export default withFetching(fetching('some-other-type'))(MovieList);

5.对 componentWillReceiveProps 的理解

该方法当props发生变化时执行,初始化render时不执行,在这个回调函数里面,你可以根据属性的变化,通过调用this.setState()来更新你的组件状态,旧的属性还是可以通过this.props来获取,这里调用更新状态是安全的,并不会触发额外的render调用。

使用好处: 在这个生命周期中,可以在子组件的 render 函数执行前获取新的 props,从而更新子组件自己的 state。 可以将数据请求放在这里进行执行,需要传的参数则从 componentWillReceiveProps(nextProps)中获取。而不必将所有的请求都放在父组件中。于是该请求只会在该组件渲染时才会发出,从而减轻请求负担

componentWillReceiveProps 在初始化 render 的时候不会执行,它会在 Component接受到新的状态(Props)时被触发,一般用于父组件状态更新时子组件的重新渲染

6.哪些方法会触发 React 重新渲染?重新渲染 render 会做些什么?

哪些方法会触发 react 重新渲染?

  • setState()方法被调用

setState 是 React 中最常用的命令,通常情况下,执行 setState 会触发 render。但是这里有个点值得关注,执行 setState 的时候不一定会重新渲染。当 setState 传入 null 时,并不会触发 render。

class App extends React.Component {
state = {
a: 1
};

render() {
console.log("render");
return (
<React.Fragement>
<p>{this.state.a}</p>
<button
onClick={() => {
this.setState({ a: 1 }); // 这里并没有改变 a 的值
}}
>
Click me
</button>
<button onClick={() => this.setState(null)}>setState null</button>
<Child />
</React.Fragement>
);
}
}
  • 父组件重新渲染

只要父组件重新渲染了,即使传入子组件的 props 未发生变化,那么子组件也会重新渲染,进而触发 render

父组件数据变化,子组件数据更新方法:

利用 componentWillReceiveProps 方法

componentWillReceiveProps(nextProps){
this.setState({
isLogin: nextProps.isLogin,
userInfo: nextProps.userInfo,
});
}

子组件数据变化,通知父组件

// 父组件:
<FromCom demo={this.demo} />;
//子组件:利用setState的回调函数
this.setState({}, () => {
this.props.demo(userInfo);
});

重新渲染 render 会做些什么?

  • 会对新旧 VNode 进行对比,也就是我们所说的 Diff 算法。
  • 对新旧两棵树进行一个深度优先遍历,这样每一个节点都会一个标记,在到深度遍历的时候,每遍历到一和个节点,就把该节点和新的节点树进行对比,如果有差异就放到一个对象里面
  • 遍历差异对象,根据差异的类型,根据对应对规则更新 VNode

React 的处理 render 的基本思维模式是每次一有变动就会去重新渲染整个应用。在 Virtual DOM 没有出现之前,最简单的方法就是直接调用 innerHTML。Virtual DOM 厉害的地方并不是说它比直接操作 DOM 快,而是说不管数据怎么变,都会尽量以最小的代价去更新 DOM。React 将 render 函数返回的虚拟 DOM 树与老的进行比较,从而确定 DOM 要不要更新、怎么更新。当 DOM 树很大时,遍历两棵树进行各种比对还是相当耗性能的,特别是在顶层 setState 一个微小的修改,默认会去遍历整棵树。尽管 React 使用高度优化的 Diff 算法,但是这个过程仍然会损耗性能.

判断什么时候重新渲染组件

组件状态的改变可以因为props的改变,或者直接通过setState方法改变。组件获得新的状态,然后 React 决定是否应该重新渲染组件。只要组件的 state 发生变化,React 就会对组件进行重新渲染。这是因为 React 中的shouldComponentUpdate方法默认返回true,这就是导致每次更新都重新渲染的原因。

当 React 将要渲染组件时会执行shouldComponentUpdate方法来看它是否返回true(组件应该更新,也就是重新渲染)。所以需要重写shouldComponentUpdate方法让它根据情况返回true或者false来告诉 React 什么时候重新渲染什么时候跳过重新渲染。

7.React 如何跳过子组件更新,减少不必要的 render

什么是 shouldComponent?

  1. React 中的一个生命周期,
  2. 运行时机:在 getDerivedStateFromProps 之后,render 之前执行
  3. 触发条件: a. props 更新 b. setState

forceUpdate 不会导致 shouldComponentUpdate 的触发

  1. 作用,如果返回 true,那组件就继续 render;如果返回 false,组件就不更新渲染

img

什么是 pureComponent

  1. React 的一种组件类;
  2. 与 React.Component 很相似。两者的区别在于 React.Component 中,并没有实现 shouldComponentUpdate,需要继承类自己实现。而 React.PureComponent 中,会浅层对比 prop 和 state,如果内容相同,那么组件将会跳过此次的 render 更新;
  3. React.PureComponent 中的 shouldComponentUpdate() 将跳过所有子组件树的 prop 更新。因此,请确保所有子组件也都是“纯”的组件。

纯组件的含义,就是传入相同的 props 对象,总会有相同的渲染内容。

类似于纯函数的定义

4.判断步骤: 如果 PureComponent 里有 shouldComponentUpdate 函数的话,直接使用 shouldComponentUpdate 的结果作为是否更新的依据。

没有 shouldComponentUpdate 函数的话,才会去判断是不是 PureComponent ,是的话再去做 shallowEqual 浅比较。

const instance = workInProgress.stateNode;
// 如果实利实现了shouldComponentUpdate则返回调用它的结果
if (typeof instance.shouldComponentUpdate === "function") {
const shouldUpdate = instance.shouldComponentUpdate(
newProps,
newState,
nextContext
);
return shouldUpdate;
}

// PureReactComponent的时候进行浅对比
if (ctor.prototype && ctor.prototype.isPureReactComponent) {
return !shallowEqual(oldProps, newProps) || !shallowEqual(oldState, newState);
}

从上面的代码就可以看出,pureComponent 不是实现了 shouldComponentUpdate 的Component,而是在对比的时候就返回浅对比的结果。

PureComponent 不可滥用,他使用在 class 组件内,只有那些状态和属性不经常的更新的组件我们用来做优化,对于经常更新的,这样处理后反而浪费性能,因为每一次浅比较也是要消耗时间的

什么是 shallowEqual 浅比较

浅谈 React 中的浅比较是如何工作的

  • 浅比较的对象,是新旧两个 props、新旧两个 state
// PureReactComponent的时候进行浅对比
if (ctor.prototype && ctor.prototype.isPureReactComponent) {
return !shallowEqual(oldProps, newProps) || !shallowEqual(oldState, newState);
}
  • 先判断两个对象是否地址相同。如果地址相同,就直接返回 true;如果地址不相同,就继续判断

网上很多文章把第一步的意义判定为,基本类型的相等性判断

  • 再判断有没有不是对象的值,或者等于 null 的值。如果有,直接返回 false;如果没有,就继续判断

只有这一步通过了,下面的判断才有了意义

如果把第一步判定为,基本类型的判断,那第二步又如何解释呢?

话又说回来了,传进来的 props 或者 state 一定是对像啊。如果传进来的是非对象,又是怎么做到的呢?

  • 再判断两个 props 的 key 数量,是否相同,如果相同就继续下一步的判断;如果不相同,就直接返回 false
  • 最后一步,分别判断每个 key 对应的 value 值,是否相同。判断 value 是否相同,使用的是 object.is()
shallowEqual 的源码
// shallowEqual.js
function shallowEqual(objA: mixed, objB: mixed): boolean {
// 一样的对象返回true
if (Object.is(objA, objB)) {
return true;
}

// 不是对象或者为null返回false
if (
typeof objA !== "object" ||
objA === null ||
typeof objB !== "object" ||
objB === null
) {
return false;
}

const keysA = Object.keys(objA);
const keysB = Object.keys(objB);

// key数量不同返回false
if (keysA.length !== keysB.length) {
return false;
}

// 对应key的值不相同返回false
for (let i = 0; i < keysA.length; i++) {
if (
!hasOwnProperty.call(objB, keysA[i]) ||
!Object.is(objA[keysA[i]], objB[keysA[i]])
) {
return false;
}
}

return true;
}

什么是 React.memo

const MyComponent = React.memo(function MyComponent(props) {
/* 使用 props 渲染 */
}, areEqual);
  1. React.memo 是高阶组件,
  2. 包裹其中的组件,并返回新的组件。该组件在 props 没有变更的时候,就会返回相同的渲染结果,也就是直接跳过渲染阶段。该阶段及其之后的阶段的生命周期函数就不会得到调用。当然,进行的也是浅比较
  3. 用法:
    1. 第一个参数,是函数组件( React.FunctionComponent)
    2. 第二个参数,回调函数。如果我们觉得浅比较不行,我们就填入第二个参数,React 会把第二个参数的返回值,当作是否跳过更新的标准
function areEqual(prevProps, nextProps) {
/*
如果把 nextProps 传入 render 方法的返回结果与
将 prevProps 传入 render 方法的返回结果一致则返回 true,
否则返回 false
*/
}
  1. 与 class 组件中 shouldComponentUpdate() 方法不同的是,如果 props 相等,areEqual 会返回 true;如果 props 不相等,则返回 false。这与 shouldComponentUpdate 方法的返回值相反

总结

React 基于虚拟 DOM 和高效 Diff 算法的完美配合,实现了对 DOM 最小粒度的更新。大多数情况下,React 对 DOM 的渲染效率足以业务日常。但在个别复杂业务场景下,性能问题依然会困扰我们。此时需要采取一些措施来提升运行性能,其很重要的一个方向,就是避免不必要的渲染(Render)。这里提下优化的点:

  • shouldComponentUpdate 和 PureComponent

在 React 类组件中,可以利用 shouldComponentUpdate 或者 PureComponent 来减少因父组件更新而触发子组件的 render,从而达到目的。shouldComponentUpdate 来决定是否组件是否重新渲染,如果不希望组件重新渲染,返回 false 即可。

  • 利用高阶组件

在函数组件中,并没有 shouldComponentUpdate 这个生命周期,可以利用高阶组件,封装一个类似 PureComponet 的功能

  • 使用 React.memo

React.memo 是 React 16.6 新的一个 API,用来缓存组件的渲染,避免不必要的更新,其实也是一个高阶组件,与 PureComponent 十分类似,但不同的是, React.memo 只能用于函数组件

8.React 声明组件有哪几种方法,有什么不同

React 声明组件的三种方式:

  • 函数式定义的无状态组件
  • ES5 原生方式React.createClass定义的组件
  • ES6 形式的extends React.Component定义的组件

(1)无状态函数式组件

它是为了创建纯展示组件,这种组件只负责根据传入的 props 来展示,不涉及到 state 状态的操作 组件不会被实例化,整体渲染性能得到提升,不能访问 this 对象,不能访问生命周期的方法

(2)ES5 原生方式 React.createClass // RFC

React.createClass 会自绑定函数方法,导致不必要的性能开销,增加代码过时的可能性。

(3)E6 继承形式 React.Component // RCC

目前极为推荐的创建有状态组件的方式,最终会取代 React.createClass 形式;相对于 React.createClass 可以更好实现代码复用。

无状态组件相对于于后者的区别: 与无状态组件相比,React.createClass 和 React.Component 都是创建有状态的组件,这些组件是要被实例化的,并且可以访问组件的生命周期方法。

React.createClass 与 React.Component 区别:

① 函数 this 自绑定

  • React.createClass 创建的组件,其每一个成员函数的 this 都有 React 自动绑定,函数中的 this 会被正确设置。
  • React.Component 创建的组件,其成员函数不会自动绑定 this,需要开发者手动绑定,否则 this 不能获取当前组件实例对象。

② 组件属性类型 propTypes 及其默认 props 属性 defaultProps 配置不同

  • React.createClass 在创建组件时,有关组件 props 的属性类型及组件默认的属性会作为组件实例的属性来配置,其中 defaultProps 是使用 getDefaultProps 的方法来获取默认组件属性的
  • React.Component 在创建组件时配置这两个对应信息时,他们是作为组件类的属性,不是组件实例的属性,也就是所谓的类的静态属性来配置的。

③ 组件初始状态 state 的配置不同

  • React.createClass 创建的组件,其状态 state 是通过 getInitialState 方法来配置组件相关的状态;
  • React.Component 创建的组件,其状态 state 是在 constructor 中像初始化组件属性一样声明的。

9.有状态组件和无状态组件的理解及使用场景

(1)有状态组件

特点:

  • 是类组件
  • 有继承
  • 可以使用 this
  • 可以使用 react 的生命周期
  • 使用较多,容易频繁触发生命周期钩子函数,影响性能
  • 内部使用 state,维护自身状态的变化,有状态组件根据外部组件传入的 props 和自身的 state 进行渲染。

使用场景:

  • 需要使用到状态的。
  • 需要使用状态操作组件的(无状态组件的也可以实现新版本 react hooks 也可实现)

总结: 类组件可以维护自身的状态变量,即组件的 state ,类组件还有不同的生命周期方法,可以让开发者能够在组件的不同阶段(挂载、更新、卸载),对组件做更多的控制。类组件则既可以充当无状态组件,也可以充当有状态组件。当一个类组件不需要管理自身状态时,也可称为无状态组件。

2)无状态组件 特点:

  • 不依赖自身的状态 state
  • 可以是类组件或者函数组件。
  • 可以完全避免使用 this 关键字。(由于使用的是箭头函数事件无需绑定)
  • 有更高的性能。当不需要使用生命周期钩子时,应该首先使用无状态函数组件
  • 组件内部不维护 state ,只根据外部组件传入的 props 进行渲染的组件,当 props 改变时,组件重新渲染。

使用场景:

  • 组件不需要管理 state,纯展示

优点:

  • 简化代码、专注于 render
  • 组件不需要被实例化,无生命周期,提升性能。 输出(渲染)只取决于输入(属性),无副作用
  • 视图和数据的解耦分离

缺点:

  • 无法使用 ref
  • 无生命周期方法
  • 无法控制组件的重渲染,因为无法使用 shouldComponentUpdate 方法,当组件接受到新的属性时则会重渲染

总结: 组件内部状态且与外部无关的组件,可以考虑用状态组件,这样状态树就不会过于复杂,易于理解和管理。当一个组件不需要管理自身状态时,也就是无状态组件,应该优先设计为函数组件。比如自定义的 <Button/><Input /> 等组件。

10.React 中什么是受控组件和非控组件?

(1)受控组件 在使用表单来收集用户输入时,例如<input><select><textearea>等元素都要绑定一个 change 事件,当表单的状态发生变化,就会触发 onChange 事件更新组件的 state。这种组件在 React 中被称为受控组件,在受控组件中,组件渲染出的状态与它的 value 或 checked 属性相对应,react 通过这种方式消除了组件的局部状态,使整个状态可控。react 官方推荐使用受控表单组件。

受控组件更新 state 的流程:

  • 可以通过初始 state 中设置表单的默认值
  • 每当表单的值发生变化时,调用 onChange 事件处理器
  • 事件处理器通过事件对象 e 拿到改变后的状态,并更新组件的 state
  • 一旦通过 setState 方法更新 state,就会触发视图的重新渲染,完成表单组件的更新

受控组件缺陷: 表单元素的值都是由 React 组件进行管理,当有多个输入框,或者多个这种组件时,如果想同时获取到全部的值就必须每个都要编写事件处理函数,这会让代码看着很臃肿,所以为了解决这种情况,出现了非受控组件。

(2)非受控组件 如果一个表单组件没有 value props(单选和复选按钮对应的是 checked props)时,就可以称为非受控组件。在非受控组件中,可以使用一个 ref 来从 DOM 获得表单值。而不是为每个状态更新编写一个事件处理程序

React 官方的解释:

要编写一个非受控组件,而不是为每个状态更新都编写数据处理函数,你可以使用 ref 来从 DOM 节点中获取表单数据。 因为非受控组件将真实数据储存在 DOM 节点中,所以在使用非受控组件时,有时候反而更容易同时集成 React 和非 React 代码。如果你不介意代码美观性,并且希望快速编写代码,使用非受控组件往往可以减少你的代码量。否则,你应该使用受控组件。

例如,下面的代码在非受控组件中接收单个属性:

class NameForm extends React.Component {
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleSubmit(event) {
alert("A name was submitted: " + this.input.value);
event.preventDefault();
}
render() {
return (
<form onSubmit={this.handleSubmit}>
<label>
Name:
<input type="text" ref={(input) => (this.input = input)} />
</label>
<input type="submit" value="Submit" />
</form>
);
}
}

总结: 页面中所有输入类的 DOM 如果是现用现取的称为非受控组件,而通过 setState 将输入的值维护到了 state 中,需要时再从 state 中取出,这里的数据就受到了 state 的控制,称为受控组件。

11.React context 的使用和理解

React Context 用法整理(附完整代码)

在 React 中,数据传递一般使用 props 传递数据,维持单向数据流,这样可以让组件之间的关系变得简单且可预测,但是单项数据流在某些场景中并不适用。单纯一对的父子组件传递并无问题,但要是组件之间层层依赖深入,props 就需要层层传递显然,这样做太繁琐了

Context 提供了一种在组件之间共享此类值的方式,而不必显式地通过组件树的逐层传递 props

可以把 context 当做是特定一个组件树内共享的 store,用来做数据传递简单说就是,当你不想在组件树中通过逐层传递 props 或者 state 的方式来传递数据时,可以使用 Context 来实现跨层级的组件数据传递。

JS 的代码块在执行期间,会创建一个相应的作用域链,这个作用域链记录着运行时 JS 代码块执行期间所能访问的活动对象,包括变量和函数,JS 程序通过作用域链访问到代码块内部或者外部的变量和函数。

假如以 JS 的作用域链作为类比,React 组件提供的 Context 对象其实就好比一个提供给子组件访问的作用域,而 Context 对象的属性可以看成作用域上的活动对象。由于组件 的 Context 由其父节点链上所有组件通 过 getChildContext()返回的 Context 对象组合而成,所以,组件通过 Context 是可以访问到其父组件链上所有节点组件提供的 Context 的属性。

React 并不推荐优先考虑使用 Context?

  • Context 目前还处于实验阶段,可能会在后面的发行版本中有很大的变化,事实上这种情况已经发生了,所以为了避免给今后升级带来大的影响和麻烦,不建议在 app 中使用 context。
  • 尽管不建议在 app 中使用 context,但是独有组件而言,由于影响范围小于 app,如果可以做到高内聚,不破坏组件树之间的依赖关系,可以考虑使用 context
  • 对于组件之间的数据通信或者状态管理,有效使用 props 或者 state 解决,然后再考虑使用第三方的成熟库进行解决,以上的方法都不是最佳的方案的时候,在考虑 context。
  • context 的更新需要通过 setState()触发,但是这并不是很可靠的,Context 支持跨组件的访问,但是如果中间的子组件通过一些方法不影响更新,比如 shouldComponentUpdate() 返回 false 那么不能保证 Context 的更新一定可以使用 Context 的子组件,因此,Context 的可靠性需要关注

使用场景:

  • 父组件使用Provider生产数据,子组件使用Consumer消费数据
  • 子组件使用ContextType接收数据
  • 动态和静态 Context(父组件更新 Context,被 Provider 包裹的子组件刷新数据,没被 Provider 包裹的子组件使用 Context 默认值)
  • 在嵌套组件中更新 Context(子组件通过 Context 传递的函数更新数据)
  • 消费多个 Context

Context 什么时候用?

Context 设计目的是为了共享那些对于一个组件树而言是“全局”的数据,例如当前认证的用户、主题或首选语言。举个例子,在下面的代码中,我们通过一个 “theme” 属性手动调整一个按钮组件的样式

class App extends React.Component {
render() {
return <Toolbar theme="dark" />;
}
}

function Toolbar(props) {
// Toolbar 组件接受一个额外的“theme”属性,然后传递给 ThemedButton 组件。
// 如果应用中每一个单独的按钮都需要知道 theme 的值,这会是件很麻烦的事,
// 因为必须将这个值层层传递所有组件。
return (
<div>
<ThemedButton theme={props.theme} />
</div>
);
}

class ThemedButton extends React.Component {
render() {
return <Button theme={this.props.theme} />;
}
}

// 通过props传递:App -> Toolbar -> ThemedButton
// 如果嵌套很深,那么需要逐层传递props,即使中间不需要该props,显得很繁琐

使用 context, 我们可以避免通过中间元素传递 props

// Context 可以让我们无须明确地传遍每一个组件,就能将值深入传递进组件树。
// 为当前的 theme 创建一个 context("light"为默认值)。
const ThemeContext = React.createContext("light");
class App extends React.Component {
render() {
// 使用一个 Provider 来将当前的 theme 传递给以下的组件树。
// 无论多深,任何组件都能读取这个值。
// 在这个例子中,我们将 “dark” 作为当前的值传递下去。
return (
<ThemeContext.Provider value="dark">
<Toolbar />
</ThemeContext.Provider>
);
}
}

// 中间的组件再也不必指明往下传递 theme 了。
function Toolbar() {
return (
<div>
<ThemedButton />
</div>
);
}

class ThemedButton extends React.Component {
// 指定 contextType 读取当前的 theme context。
// React 会往上找到最近的 theme Provider,然后使用它的值。
// 在这个例子中,当前的 theme 值为 “dark”。
static contextType = ThemeContext;
render() {
return <Button theme={this.context} />;
}
}
// 也可以使用 ThemedButto.contextType = ThemeContext;

API 介绍

React.createContext
const MyContext = React.createContext(defaultValue);

创建一个 Context 对象。当 React 渲染一个订阅了这个 Context 对象的组件,这个组件会从组件树中离自身最近的那个匹配的 Provider 中读取到当前的 context 值。

只有当组件所处的树中没有匹配到 Provider 时,其 defaultValue 参数才会生效。这有助于在不使用 Provider 包装组件的情况下对组件进行测试。注意:将 undefined 传递给 Provider 的 value 时,消费组件的 defaultValue 不会生效。

Context.Provider
<MyContext.Provider value={/* 某个值 */}>

每个 Context 对象都会返回一个 Provider React 组件,它允许消费组件订阅 context 的变化。

Provider 接收一个 value 属性,传递给消费组件。一个 Provider 可以和多个消费组件有对应关系。多个 Provider 也可以嵌套使用,里层的会覆盖外层的数据。

当 Provider 的 value 值发生变化时,它内部的所有消费组件都会重新渲染。Provider 及其内部 consumer 组件都不受制于 shouldComponentUpdate 函数,因此当 consumer 组件在其祖先组件退出更新的情况下也能更新。

Class.contextType

挂载在 class 上的 contextType 属性会被重赋值为一个由 React.createContext() 创建的 Context 对象。这能让你使用 this.context 来消费最近 Context 上的那个值。你可以在任何生命周期中访问到它,包括 render 函数中

import MyContext from "./MyContext";

class MyClass extends React.Component {
componentDidMount() {
let value = this.context;
/* 在组件挂载完成后,使用 MyContext 组件的值来执行一些有副作用的操作 */
}
componentDidUpdate() {
let value = this.context;
/* ... */
}
componentWillUnmount() {
let value = this.context;
/* ... */
}
render() {
let value = this.context;
/* 基于 MyContext 组件的值进行渲染 */
}
// 或者如上边例子一样使用 static contextType = MyContext;
}
MyClass.contextType = MyContext;
Context.Consumer
import MyContext from './MyContext';

function ToolList() {
return (
<MyContext.Consumer
{value => /* 基于 context 值进行渲染*/}
</MyContext.Consumer>
)
}

这里,React 组件也可以订阅到 context 变更。这能让你在函数式组件中完成订阅 context。

这需要函数作为子元素(function as a child)这种做法。这个函数接收当前的 context 值,返回一个 React 节点。传递给函数的 value 值等同于往上组件树离这个 context 最近的 Provider 提供的 value 值。如果没有对应的 Provider,value 参数等同于传递给 createContext()defaultValue

Context.displayName

context 对象接受一个名为 displayName 的 property,类型为字符串。React DevTools 使用该字符串来确定 context 要显示的内容。

如下述组件在 DevTools 中将显示为 MyDisplayName

const MyContext = React.createContext(/* some value */);
MyContext.displayName = 'MyDisplayName';

<MyContext.Provider> // "MyDisplayName.Provider" 在 DevTools 中
<MyContext.Consumer> // "MyDisplayName.Consumer" 在 DevTools 中

示例

动态 Context

对于上面的 theme 例子,使用动态值(dynamic values)后更复杂的用法

theme-context.js

export const themes = {
light: {
foreground: "#000000",
background: "#eeeeee",
},
dark: {
foreground: "#ffffff",
background: "#222222",
},
};

export const ThemeContext = React.createContext(themes.dark); // 该处为默认值

themed-button.js

import { ThemeContext } from "./theme-context";

class ThemedButton extends React.Component {
render() {
let props = this.props;
// 获取到ThemeContext中的默认值
let theme = this.context;
return <button {...props} style={{ backgroundColor: theme.background }} />;
}
// static contextType = ThemeContext;
}
ThemedButton.contextType = ThemeContext;

export default ThemedButton;

app.js

import { ThemeContext, themes } from "./theme-context";
import ThemedButton from "./themed-button";

// 一个使用 ThemedButton 的中间组件
function Toolbar(props) {
return <ThemedButton onClick={props.changeTheme}>Change Theme</ThemedButton>;
}

class App extends React.Component {
constructor(props) {
super(props);
this.state = {
theme: themes.light,
};

this.toggleTheme = () => {
this.setState((state) => ({
theme: state.theme === themes.dark ? themes.light : themes.dark,
}));
};
}

render() {
// 在 ThemeProvider 内部的 ThemedButton 按钮组件使用 state 中的 theme 值,
// 而外部的组件使用默认的 theme 值
return (
<Page>
<ThemeContext.Provider value={this.state.theme}>
<Toolbar changeTheme={this.toggleTheme} />
</ThemeContext.Provider>
<Section>
<ThemedButton />
</Section>
</Page>
);
}
}

ReactDOM.render(<App />, document.root);

// 使用ThemeContext.Provider包裹的组件,可以消费到ThemeContext中的value
// 即Toolbar、ThemedButton中都可以使用this.context来获取到value
// 注意观察,更新state的方法是通过props向下传递,由子孙组件触发更新,下面会讲到通过context的方式传递更新函数
在嵌套组件中更新 Context

在上面的例子中,我们通过 props 的方式向下传递一个更新函数,从而改变 App 中 themes 的值。我们知道,从一个在组件树中嵌套很深的组件中更新 context 是很有必要的。在这种场景下,你可以通过 context 传递一个函数,使得 consumers 组件更新 context

theme-context.js

// 确保传递给 createContext 的默认值数据结构是调用的组件(consumers)所能匹配的!
export const ThemeContext = React.createContext({
theme: themes.dark,
toggleTheme: () => {}, // 定义更新主题的方法,向下传递
});

theme-toggler-button.js

import { ThemeContext } from "./theme-context";

function ThemeTogglerButton() {
// Theme Toggler 按钮不仅仅只获取 theme 值,它也从 context 中获取到一个 toggleTheme 函数(下面app.js部分)
return (
<ThemeContext.Consumer>
{({ theme, toggleTheme }) => (
<button
onClick={toggleTheme}
style={{ backgroundColor: theme.background }}
>
Toggle Theme
</button>
)}
</ThemeContext.Consumer>
);
}

export default ThemeTogglerButton;

app.js

import { ThemeContext, themes } from "./theme-context";
import ThemeTogglerButton from "./theme-toggler-button";

class App extends React.Component {
constructor(props) {
super(props);

this.toggleTheme = () => {
this.setState((state) => ({
theme: state.theme === themes.dark ? themes.light : themes.dark,
}));
};

// State 也包含了更新函数,因此它会被传递进 context provider。
this.state = {
theme: themes.light,
toggleTheme: this.toggleTheme, // 定义更新函数,通过context方式向下传递
};
}

render() {
// 整个 state 都被传递进 provider
return (
<ThemeContext.Provider value={this.state}>
<Content />
</ThemeContext.Provider>
);
}
}

function Content() {
return (
<div>
<ThemeTogglerButton />
</div>
);
}

ReactDOM.render(<App />, document.root);
消费多个 Context

为了确保 context 快速进行重渲染,React 需要使每一个 consumers 组件的 context 在组件树中成为一个单独的节点

// Theme context,默认的 theme 是 "light" 值
const ThemeContext = React.createContext("light");

// 用户登录 context
const UserContext = React.createContext({
name: "Guest",
});

class App extends React.Component {
render() {
const { signedInUser, theme } = this.props;

// 提供初始 context 值的 App 组件
return (
<ThemeContext.Provider value={theme}>
<UserContext.Provider value={signedInUser}>
<Layout />
</UserContext.Provider>
</ThemeContext.Provider>
);
}
}

function Layout() {
return (
<div>
<Sidebar />
<Content />
</div>
);
}

// 一个组件可能会消费多个 context
function Content() {
return (
<ThemeContext.Consumer>
{(theme) => (
<UserContext.Consumer>
{(user) => <ProfilePage user={user} theme={theme} />}
</UserContext.Consumer>
)}
</ThemeContext.Consumer>
);
}

如果两个或者更多的 context 值经常被一起使用,那你可能要考虑一下另外创建你自己的渲染组件,以提供这些值。

注意事项

因为 context 会使用参考标识(reference identity)来决定何时进行渲染,这里可能会有一些陷阱,当 provider 的父组件进行重渲染时,可能会在 consumers 组件中触发意外的渲染。举个例子,当每一次 Provider 重渲染时,以下的代码会重渲染所有下面的 consumers 组件,因为 value 属性总是被赋值为新的对象

class App extends React.Component {
render() {
return (
<MyContext.Provider value={{ something: "something" }}>
<Toolbar />
</MyContext.Provider>
);
}
}

为了防止这种情况,将 value 状态提升到父节点的 state 里

class App extends React.Component {
constructor(props) {
super(props);
// 多次渲染,state 会被保留,当value不变时,下面的 consumers 组件不会重新渲染
this.state = {
value: { something: "something" },
};
}

render() {
return (
<Provider value={this.state.value}>
<Toolbar />
</Provider>
);
}
}

12.React 的插槽(Portals)的理解,如何使用,有哪些使用场景

React 官方对 Portals 的定义:

Portal 提供了一种将子节点渲染到存在于父组件以外的 DOM 节点的优秀的方案

Portals 是 React 16 提供的官方解决方案,使得组件可以脱离父组件层级挂载在 DOM 树的任何位置。通俗来讲,就是我们 render 一个组件,但这个组件的 DOM 结构并不在本组件内

Portals 语法如下:

ReactDOM.createPortal(child, container);
  • 第一个参数 child 是可渲染的 React 子项,比如元素,字符串或者片段等;
  • 第二个参数 container 是一个 DOM 元素。

一般情况下,组件的 render 函数返回的元素会被挂载在它的父级组件上:

import DemoComponent from './DemoComponent';
render() {
// DemoComponent元素会被挂载在id为parent的div的元素上
return (
<div id="parent">
<DemoComponent />
</div>
);
}

然而,有些元素需要被挂载在更高层级的位置。最典型的应用场景:当父组件具有overflow: hidden或者z-index的样式设置时,组件有可能被其他元素遮挡,这时就可以考虑要不要使用 Portal 使组件的挂载脱离父组件。例如:对话框,模态窗。

import DemoComponent from './DemoComponent';
render() {
// DemoComponent元素会被挂载在id为parent的div的元素上
return (
<div id="parent">
<DemoComponent />
</div>
);
}

13.React 中 Fragment 的理解,它的使用场景是什么?

在 React 中,组件返回的元素只能有一个根元素。为了不添加多余的 DOM 节点,我们可以使用 Fragment 标签来包裹所有的元素,Fragment 标签不会渲染出任何元素。React 官方对 Fragment 的解释:

React 中的一个常见模式是一个组件返回多个元素。Fragments 允许你将子列表分组,而无需向 DOM 添加额外节点。

import React, { Component, Fragment } from 'react'

// 一般形式
render() {
return (
<React.Fragment>
<ChildA />
<ChildB />
<ChildC />
</React.Fragment>
);
}
// 也可以写成以下形式
render() {
return (
<>
<ChildA />
<ChildB />
<ChildC />
</>
);
}

14.React 中 refs 的作用是什么?有哪些应用场景?

Refs 提供了一种方式,用于访问在 render 方法中创建的 React 元素或 DOM 节点。Refs 应该谨慎使用,如下场景使用 Refs 比较适合:

  • 处理焦点、文本选择或者媒体的控制
  • 触发必要的动画
  • 集成第三方 DOM 库

不可以在 render 访问 refs,render 阶段 DOM 还没有生成,无法获取 DOM。DOM 的获取需要在 pre-commit 阶段和 commit 阶段

Refs 是使用 React.createRef() 方法创建的,他通过 ref 属性附加到 React 元素上。要在整个组件中使用 Refs,需要将 ref构造函数中分配给其实例属性

class MyComponent extends React.Component {
constructor(props) {
super(props);
this.myRef = React.createRef();
}
render() {
return <div ref={this.myRef} />;
}
}

由于函数组件没有实例,因此不能在函数组件上直接使用 ref

function MyFunctionalComponent() {
return <input />;
}
class Parent extends React.Component {
constructor(props) {
super(props);
this.textInput = React.createRef();
}
render() {
// 这将不会工作!
return <MyFunctionalComponent ref={this.textInput} />;
}
}

但可以通过闭合的帮助在函数组件内部进行使用 Refs:

function CustomTextInput(props) {
// 这里必须声明 textInput,这样 ref 回调才可以引用它
let textInput = null;
function handleClick() {
textInput.focus();
}
return (
<div>
<input
type="text"
ref={(input) => {
textInput = input;
}}
/>
<input type="button" value="Focus the text input" onClick={handleClick} />
</div>
);
}

注意:

  • 不应该过度的使用 Refs
  • ref 的返回值取决于节点的类型:
    • ref 属性被用于一个普通的 HTML 元素时,React.createRef() 将接收底层 DOM 元素作为他的 current 属性以创建 ref
    • ref 属性被用于一个自定义的类组件时,ref 对象将接收该组件已挂载的实例作为他的 current
  • 当在父组件中需要访问子组件中的 ref 时可使用传递 Refs 或回调 Refs。

15.类组件与函数组件有什么异同?

相同点: 组件是 React 可复用的最小代码片段,它们会返回要在页面中渲染的 React 元素。也正因为组件是 React 的最小编码单位,所以无论是函数组件还是类组件,在使用方式和最终呈现效果上都是完全一致的。

我们甚至可以将一个类组件改写成函数组件,或者把函数组件改写成一个类组件(虽然并不推荐这种重构行为)。从使用者的角度而言,很难从使用体验上区分两者,而且在现代浏览器中,闭包和类的性能只在极端场景下才会有明显的差别。所以,基本可认为两者作为组件是完全一致的。

不同点:

  • 它们在开发时的心智模型上却存在巨大的差异。类组件是基于面向对象编程的,它主打的是继承、生命周期等核心概念;而函数组件内核是函数式编程,主打的是 immutable、没有副作用、引用透明等特点。
  • 之前,在使用场景上,如果存在需要使用生命周期的组件,那么主推类组件;设计模式上,如果需要使用继承,那么主推类组件。但现在由于 React Hooks 的推出,生命周期概念的淡出,函数组件可以完全取代类组件。其次继承并不是组件最佳的设计模式,官方更推崇“组合优于继承”的设计概念,所以类组件在这方面的优势也在淡出。
  • 性能优化上,类组件主要依靠 shouldComponentUpdate  阻断渲染来提升性能,而函数组件依靠 React.memo 缓存渲染结果来提升性能
  • 从上手程度而言,类组件更容易上手,从未来趋势上看,由于 React Hooks 的推出,函数组件成了社区未来主推的方案。
  • 类组件在未来时间切片与并发模式中,由于生命周期带来的复杂度,并不易于优化。而函数组件本身轻量简单,且在 Hooks 的基础上提供了比原先更细粒度的逻辑组织与复用,更能适应 React 的未来发展。

16.React 组件的构造函数有什么作用?它是必须的吗?

构造函数主要用于两个目的:

  • 通过将对象分配给 this.state 来初始化本地状态
  • 将事件处理程序方法绑定到实例上

所以,当在 React class 中需要设置 state 的初始值或者绑定事件时,需要加上构造函数,官方 Demo:

class LikeButton extends React.Component {
constructor() {
super();
this.state = {
liked: false,
};
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
this.setState({ liked: !this.state.liked });
}
render() {
const text = this.state.liked ? "liked" : "haven't liked";
return (
<div onClick={this.handleClick}>You {text} this. Click to toggle.</div>
);
}
}
ReactDOM.render(<LikeButton />, document.getElementById("example"));

构造函数用来新建父类的 this 对象;子类必须在 constructor 方法中调用 super 方法;否则新建实例时会报错;因为子类没有自己的 this 对象,而是继承父类的 this 对象,然后对其进行加工。如果不调用 super 方法;子类就得不到 this 对象。

注意:

  • constructor () 必须配上 super(), 如果要在 constructor 内部使用 this.props 就要 传入 props , 否则不用
  • JavaScript 中的 bind 每次都会返回一个新的函数, 为了性能等考虑, 尽量在 constructor 中绑定事件

17.React.forwardRef 的作用?

React.forwardRef 会创建一个 React 组件,这个组件能够将其接受的 ref 属性转发到其组件树下的另一个组件中。这种技术并不常见,但在以下两种场景中特别有用:

  • 转发 refs 到 DOM 组件
  • 在高阶组件中转发 refs

18.HOC 相比 mixins 有什么优点?

HOC 和 Vue 中的 mixins 作用是一致的,并且在早期 React 也是使用 mixins 的方式。但是在使用 class 的方式创建组件以后,mixins 的方式就不能使用了,并且其实 mixins 也是存在一些问题的,比如:

  • 隐含了一些依赖,比如我在组件中写了某个 state 并且在 mixin 中使用了,就这存在了一个依赖关系。万一下次别人要移除它,就得去 mixin 中查找依赖
  • 多个 mixin 中可能存在相同命名的函数,同时代码组件中也不能出现相同命名的函数,否则就是重写了,其实我一直觉得命名真的是一件麻烦事。。
  • 雪球效应,虽然我一个组件还是使用着同一个 mixin,但是一个 mixin 会被多个组件使用,可能会存在需求使得 mixin 修改原本的函数或者新增更多的函数,这样可能就会产生一个维护成本

HOC 解决了这些问题,并且它们达成的效果也是一致的,同时也更加函数式了。

19.React 中的高阶组件运用了什么设计模式?

使用了装饰模式,高阶组件的运用:

function withWindowWidth(BaseComponent) {
class DerivedClass extends React.Component {
state = {
windowWidth: window.innerWidth,
};
onResize = () => {
this.setState({
windowWidth: window.innerWidth,
});
};
componentDidMount() {
window.addEventListener("resize", this.onResize);
}
componentWillUnmount() {
window.removeEventListener("resize", this.onResize);
}
render() {
return <BaseComponent {...this.props} {...this.state} />;
}
}
return DerivedClass;
}
const MyComponent = (props) => {
return <div>Window width is: {props.windowWidth}</div>;
};
export default withWindowWidth(MyComponent);

装饰模式的特点是不需要改变 被装饰对象 本身,而只是在外面套一个外壳接口。JavaScript 目前已经有了原生装饰器的提案,其用法如下:

@testable
class MyTestableClass {}

20.React 渲染流程

image-20220712105045979

image-20220712111025690

协调

协调,在 React 官方博客的原文中是 Reconciler,它的本意是“和解者,调解员”

Reconciler 是协助 React 确认状态变化时要更新哪些 DOM 元素的 diff 算法

在 React 源码中还有一个叫作 reconcilers 的模块,它通过抽离公共函数与 diff 算法使声明式渲染、自定义组件、state、生命周期方法和 refs 等特性实现跨平台工作

Reconciler 模块以 React 16 为分界线分为两个版本。

  • Stack Reconciler是 React 15 及以前版本的渲染方案,其核心是以递归的方式逐级调度栈中子节点到父节点的渲染。
  • Fiber Reconciler是 React 16 及以后版本的渲染方案,它的核心设计是增量渲染(incremental rendering),也就是将渲染工作分割为多个区块,并将其分散到多个帧中去执行。它的设计初衷是提高 React 在动画、画布及手势等场景下的性能表现。

渲染

为了更好地理解两者之间的差异,我们需要先梳理一遍 Stack Reconciler。

Stack Reconciler

Stack Reconciler 没有单独的包,并没有像 Fiber Reconclier 一样抽取为独立的React-Reconciler 模块。但这并不妨碍它成为一个经典的设计。在 React 的官方文档中,是通过伪代码的形式介绍其实现方案的。与官方文档略有不同,下面我会介绍一些真实代码的信息。

挂载

这里的挂载与生命周期一讲中的挂载不同,它是将整个 React 挂载到 ReactDOM.render 之上,就像以下代码中的 App 组件挂载到 root 节点上一样。

class App extends React.Component {
render() {
return <div>Hello World</div>;
}
}
ReactDOM.render(<App />, document.getElementById("root"));

JSX 会被 Babel 编译成 React.creatElemnt 的形式:

ReactDOM.render(React.creatElement(App), document.getElementById("root"));

但一定要记住,这项工作发生在本地的 Node 进程中,而不是通过浏览器中的 React 完成的。以为 JSX 是通过 React 完成编译,这是完全不正确的。

ReactDOM.render 调用之后,实际上是透传参数给 ReactMount.render

  • ReactDOM 是对外暴露的模块接口;
  • ReactMount 是实际执行者,完成初始化 React 组件的整个过程。

初始化第一步就是通过 React.creatElement 创建 React Element。不同的组件类型会被构建为不同的 Element:

  • App 组件会被标记为 type function,作为用户自定义的组件,被 ReactComponentsiteComponent 包裹一次,生成一个对象实例;
  • div 标签作为 React 内部的已知 DOM 类型,会实例化为 ReactDOMComponent;
  • "Hello World" 会被直接判断是否为字符串,实例化为 ReactDOMComponent。

image-20220712111330887

这段逻辑在 React 源码中大致是这样的,其中 isInternalComponentType 就是判断当前的组件是否为内部已知类型。

if (typeof element.type === "string") {
instance = ReactHostComponent.createInternalComponent(element);
} else if (isInternalComponentType(element.type)) {
instance = new element.type(element);
} else {
instance = new ReactCompositeComponentWrapper();
}

到这里仅仅完成了实例化,我们还需要与 React 产生一些联动,比如改变状态、更新界面等。在 setState 一讲中,我们提到在状态变更后,涉及一个变更收集再批量处理的过程。在这里 ReactUpdates 模块就专门用于批量处理,而批量处理的前后操作,是由 React 通过建立事务的概念来处理的。

React 事务都是基于 Transaction 类继承拓展。每个 Transaction 实例都是一个封闭空间,保持不可变的任务常量,并提供对应的事务处理接口 。一段事务在 React 源码中大致是这样的:

mountComponentIntoNode: function(rootID, container) {
var transaction = ReactComponent.ReactReconcileTransaction.getPooled();
transaction.perform(
this._mountComponentIntoNode,
this,
rootID,
container,
transaction
);
ReactComponent.ReactReconcileTransaction.release(transaction);
}

如果有操作数据库经验的同学,应该看到过相似的例子。React 团队将其从后端领域借鉴到前端是因为事务的设计有以下优势。

  • 原子性: 事务作为一个整体被执行,要么全部被执行,要么都不执行。
  • 隔离性: 多个事务并发执行时,一个事务的执行不应影响其他事务的执行。
  • 一致性: 相同的输入,确定能得到同样的执行结果。

上面提到的事务会调用 ReactCompositeComponent.mountComponent 函数进入 React 组件生命周期,它的源码大致是这样的。

if (inst.componentWillMount) {
inst.componentWillMount();
if (this._pendingStateQueue) {
inst.state = this._processPendingState(inst.props, inst.context);
}
}

首先会判断是否有 componentWillMount,然后初始化 state 状态。当 state 计算完毕后,就会调用在 App 组件中声明的 render 函数。接着 render 返回的结果,会处理为新的 React Element,再走一遍上面提到的流程,不停地往下解析,逐步递归,直到开始处理 HTML 元素。到这里我们的 App 组件就完成了首次渲染。

更新

接下来我们用同样的方式解析下当调用 setState 时会发生什么。setState 时会调用 Component 类中的 enqueueSetState 函数。

this.updater.enqueueSetState(this, partialState);

在执行 enqueueSetState 后,会调用 ReactCompositeComponent 实例中的_pendingStateQueue,将新的状态变更加入实例的等待更新状态队列中,再调用 ReactUpdates 模块中的 enqueueUpdate 函数执行更新。这个过程会检查更新是否已经在进行中:

  • 如果是,则把组件加入 dirtyComponents 中;
  • 如果不是,先初始化更新事务,然后把组件加入 dirtyComponents 列表。

这里的初始化更新事务,就是 setState 一讲中提到的 batchingstrategy.isBatchingUpdates 开关。接下来就会在更新事务中处理所有记录的 dirtyComponents。

卸载

对于自定义组件,也就是对 ReactCompositeComponent 而言,卸载过程需要递归地调用生命周期函数。

class CompositeComponent{
unmount(){
var publicInstance = this.publicInstance
if(publicInstance){
if(publicInstance.componentWillUnmount){
publicInstance.componentWillUnmount()
}
}
var renderedComponent = this.renderedComponent
renderedComponent.unmount()
}
}

而对于 ReactDOMComponent 而言,卸载子元素需要清除事件监听器并清理一些缓存。

class DOMComponent{
unmount(){
var renderedChildren = this.renderedChildren
renderedChildren.forEach(child => child.unmount())
}
}

那么到这里,卸载的过程就算完成了

在挂载阶段, ReactMount 模块已经不存在了,是直接构造 Fiber 树。而更新流程大致一样,依然通过 IsBatchingUpdates 控制。那么 Fiber Reconciler 最大的不同有两点:

  • 协作式多任务模式;
  • 基于循环遍历计算 diff

总结

React 的渲染过程大致一致,但协调并不相同,以 React 16 为分界线,分为 Stack Reconciler 和 Fiber Reconciler。这里的协调从狭义上来讲,特指 React 的 diff 算法,广义上来讲,有时候也指 React 的 reconciler 模块,它通常包含了 diff 算法和一些公共逻辑。

回到 Stack Reconciler 中,Stack Reconciler 的核心调度方式是递归。调度的基本处理单位是事务,它的事务基类是 Transaction,这里的事务是 React 团队从后端开发中加入的概念。在 React 16 以前,挂载主要通过 ReactMount 模块完成,更新通过 ReactUpdate 模块完成,模块之间相互分离,落脚执行点也是事务。

在 React 16 及以后,协调改为了 Fiber Reconciler。它的调度方式主要有两个特点,第一个是协作式多任务模式,在这个模式下,线程会定时放弃自己的运行权利,交还给主线程,通过 requestIdleCallback 实现。第二个特点是策略优先级,调度任务通过标记 tag 的方式分优先级执行,比如动画,或者标记为 high 的任务可以优先执行。Fiber Reconciler 的基本单位是 Fiber,Fiber 基于过去的 React Element 提供了二次封装,提供了指向父、子、兄弟节点的引用,为 diff 工作的双链表实现提供了基础。

在新的架构下,整个生命周期被划分为 Render 和 Commit 两个阶段。Render 阶段的执行特点是可中断、可停止、无副作用,主要是通过构造 workInProgress 树计算出 diff。以 current 树为基础,将每个 Fiber 作为一个基本单位,自下而上逐个节点检查并构造 workInProgress 树。这个过程不再是递归,而是基于循环来完成。

在执行上通过 requestIdleCallback 来调度执行每组任务,每组中的每个计算任务被称为 work,每个 work 完成后确认是否有优先级更高的 work 需要插入,如果有就让位,没有就继续。优先级通常是标记为动画或者 high 的会先处理。每完成一组后,将调度权交回主线程,直到下一次 requestIdleCallback 调用,再继续构建 workInProgress 树。

在 commit 阶段需要处理 effect 列表,这里的 effect 列表包含了根据 diff 更新 DOM 树、回调生命周期、响应 ref 等。

但一定要注意,这个阶段是同步执行的,不可中断暂停,所以不要在 componentDidMount、componentDidUpdate、componentWiilUnmount 中去执行重度消耗算力的任务。

如果只是一般的应用场景,比如管理后台、H5 展示页等,两者性能差距并不大,但在动画、画布及手势等场景下,Stack Reconciler 的设计会占用占主线程,造成卡顿,而 fiber reconciler 的设计则能带来高性能的表现。

image-20220712111542109