一、setState
1. state 和 props 的区别
- props 是一个从外部传给组件的参数,用于父组件向子组件传递数据,不能被修改。
- state 是组件内部私有的,可以通过
setState
修改。
为什么不能直接修改 state?
react的 immutable 的理念,在监听数据的变化是通过比较对象引用的方式,直接修改 state 会让 react 无法监听到变化。而 setState 会重新生成新对象。
2. setState 介绍
setState
有 2 个参数:
setState(updater [, callback])
- 第一个参数可以是一个对象或函数。
this.setState({quantity: 2}); // 传入对象
this.setState((state, prop) => { // 传入函数
return {counter: state.counter + props.step};
});
setState
第一个参数是对象或函数的区别是?
如果后续state
的计算依赖于当前state
,那么应该传递函数,因为在函数内部可以拿到最新的state
。(但state
的渲染更新过程仍是异步的。)
1. 参数是对象,当React重新渲染该组件时,this.state.count 会变为 1
add() {
this.setState({count: this.state.count + 1});
}
handleAdd() {
// 假设 this.state.count 从 0 开始
this.add();
this.add();
}
2. 参数是函数,当React重新渲染该组件时,this.state.count 会变为 2
add() {
this.setState((state) => {
return {count: state.count + 1})
});
}
- 第二个参数是可选的回调函数,它将在
setState
完成合并而且重新渲染组件后执行,因此可以获取更新后的 state。通常,建议用componentDidUpdate()
代替。
3. 异步 or 同步?如何获取更新后的 state?
注意,这里的同步是指 state 能否立即更新。
- React18会对所有更新自动批处理,所以
setState
表现为异步。 - React18 之前:
- 同步更新:原生事件
click
、setTimeout
等异步。 - 异步更新:合成事件
onclick
、生命周期函数、(hooks
)等。
- 同步更新:原生事件
常见问题:
-
如何获取更新后的 state?
在
componentDidUpdate
或setState
的回调中获取。在应用更新后,这两种方式都会被触发。 -
为什么 setTimeout 或原生事件里是同步的?
- 原生事件:因为它们不会触发 react 的批处理机制。
- 异步函数:js 的事件循环机制会将异步代码放到任务队列中,等同步代码执行完后再执行。也就是说 react 的批处理完成后再执行异步函数,所以函数里拿到的是最新 state,表现为同步。
-
为什么要合并更新?
如果不合并,那么每次调用 setState 都会执行一次更新的生命周期,进行
diff
比较、更新dom
树,比较浪费时间。而将多个setState
合并更新,只会产生一次 re-render 、避免了不必要的渲染开销,能够提升性能。
4. setState 和生命周期
constructor
不能调用 setState,是无意义的,应该用this.state
初始化。componentDidMount
中调用 setState 就相当于在初始化阶段执行了两次render(),也就是挂载阶段和更新阶段,会影响性能。- 更新阶段的生命周期都禁止调用 setState,无限次的触发更新、陷入死循环,导致内存崩溃。
- setState 会触发更新阶段的 5 个生命周期。
是否可以执行setState | 生命周期 |
---|---|
尽量避免 | componentDidMount |
禁止 | 凡是更新阶段的生命周期都禁止:shouldComponentUpdate、render、componentDidUpdate |
无意义 | constructor、componentWillUnmount |
5. v18 的setState 批量更新机制:了解
-
调用 setState 之后内部会执行一个函数,用来将参数对象推入到队列中。
-
在函数执行前会判断队列是否为空,如果为空就会设置一个微任务
flush
用于清空队列。 -
然后继续执行所有同步代码(其中可能包括多个 setState,需要循环第 1、2 步)。
-
当同步代码都执行完成后,会执行
flush
微任务。flush
函数会合并队列中所有的 state,然后更新对应的组件。
二、虚拟dom
1. 虚拟dom介绍
虚拟 dom 是一个描述真实 DOM 结构和属性的 JavaScript 对象,通过比较虚拟 dom 来最小化地更新真实 DOM,有利于性能提升。
和真实 DOM 有什么区别?
- 当真实 DOM 频繁被修改时会重排重绘整棵DOM树,并且执行多次,性能消耗很高。
- 而虚拟 DOM 频繁被修改时,通过 diff 算法和批量更新机制最后只需要对局部的真实DOM节点重排重绘即可,并且只需要执行一次重排重绘,性能消耗很低。
2. 虚拟dom的优缺点
-
优点
- 减少重排重绘以提升性能:见上。
- 跨平台兼容:虚拟 dom 就是 js 对象,不像 DOM 与平台是强相关的,虚拟 dom 可以更方便地跨平台操作(微信小程序、原生应用、浏览器、Node)。
- 跨浏览器兼容:React 基于虚拟 dom 实现了一套自己的事件机制,抹平了浏览器的事件兼容性差异。
-
缺点:
- 在首次渲染时,react 需要更多的计算,所以速度比 js 原生操作 DOM 慢。
- 针对一些性能要求比较高的应用,虚拟 dom 无法做到性能的极致优化。
3. 虚拟dom转为真实dom:render
jsx -> 真实 dom:
- jsx 会被 babel 转换成
React.createElement
进而生成虚拟 dom。 - 然后虚拟 dom 通过 render 函数调用
document.createElement
生成真实 dom 节点,最后挂载到目标 dom 上。
render 的第一个参数实际上是React.createElement
函数的返回对象;第二个参数是要挂载的目标 DOM。
ReactDOM.render(
<h1>Hello, world!</h1>,
document.getElementById('root')
);
1. babel转换后
ReactDOM.render(
React.createElement( 'h1', null, 'Hello, world!' ),
document.getElementById('root')
);
2. render函数内部的过程
function render(vnode, container) {
1. 如果是vnode是文本节点,直接挂载即可。
const textNode = document.createTextNode(vnode);
return container.appendChild(textNode);
2. 根据vnode的标签名,创建dom节点
const dom = document.createElement(vnode.tag)
3. 将vnode.attrs遍历添加到dom节点上,然后递归render它的子节点
4. 最后将完整的dom节点挂载到真正的目标dom上
return container.appendChild(dom);
}
4. diff 算法:深度优先遍历
构建 WIP 树:diff 算法通过对比最新的 vdom 和之前的 current fiber,然后根据比较的结果生成 WIP fiber,进而生成 WIP 树。
Diff 算法是比较最新的 vdom 和之前的 current fiber,然后决定怎么产生新的 fiber,对可复用的节点打上更新的 tag,剩余的旧节点打上删除 tag,新节点打上新增 tag。通过 Diff 来最小化 DOM 的更新,有利于性能优化。
具体操作其实就是reconciliation
的阶段:React18 的 Diff
- 第一次遍历时会一一对比 vDom 和老的 Fiber,如果可以复用就处理下一个节点,否则就结束遍历。(对比是会比较类型和key值是否都相同)
- 如果所有新的 vDom 都处理完了,那就把剩下的老 Fiber 节点删掉。如果还有新的 vDom 没处理,那就进行第二次遍历。
- 第二次遍历时将剩下的老 Fiber 放到 map 中(
key
就是节点的key
),然后遍历剩余的新的 vDom,看看 map 中是否有可复用的节点,有就移动过来、打上更新的 tag。 - 第二次遍历结束后,将剩余的老的 Fiber 删除,剩余的新的 vDom 新增。
常见问题:
-
key 的作用?
key 可以帮助识别该节点是否存在于旧集合,尽可能帮我们重用同一层级的节点,执行移动操作。不需要删除、新建节点,减少了不必要的渲染
-
节点复用的条件?
只有该节点的类型和 key 都相同,才能复用节点。
-
index 能作为 key 吗?
不能,如果删除某节点那么后面的元素都会前移,顺序会变化,导致dom节点的不必要的重建,降低性能。
-
diff 还能改进吗?
在 element 层级中会根据 key 在新旧集合中的大小比较进行移动,假设旧集合
a b c d
,新集合d a b c
,那么 diff 算法的移动规则是会移动a b c
,实际可以只移动 d。let num1= “react diff 操作节点的频率”,let num2=“相反的移动规则所需的操作节点的频率”,取 Min 移动
三、数据驱动视图原理
React 数据驱动视图是通过setState
实现的。
假设我们触发了onClick
事件,然后事件处理函数会调用setState
进行处理:
- v18 中会对 setState 自动批处理,将多个 state 的更新合并为一次。
- 当组件开始更新时,会执行更新阶段的五个生命周期,其中执行到
render
时,会- 生成虚拟DOM树
- 执行 Diff 算法找出变化的组件
- 然后将变化的组件渲染到页面上
(了解) 虚拟DOM实现原理:jsx 转为虚拟dom,借助createElement
function x() {
return (
<div className="box">
<span>
<em>title</em>
</span>
<div>1111</div>
</div>
)
}
// babel转译
"use strict";
function x() {
return React.createElement(
"div",
{ className: "box"},
React.createElement(
"span",
null,
React.createElement("em", null, "title”)
),
React.createElement("div", null, "1111")
);
}
- jsx 语法会被 babel 转换成
React.createElement(tag, attrs, ...children)
; - React.createElement 方法接收 元素类型、属性、子节点 这三个参数,返回一个
ReactElement(type, key, ...ReactCurrentOwner.current)
,其中ReactCurrentOwner.current
是 Fiber节点。 - ReactElement 函数只在原参数的基础上多包装了一个
$$type: REACT_ELEMENT_TYPE
属性,用来识别这个元素是 React 的元素。
虚拟 dom 就是一个 dom 树结构的 js 对象,它的转换过程是:react 中的 jsx 语法 {只转换dom节点<div>
} 会被 babel 转换成React.createElement(tag, attrs, ...children)
代码。
- tag:dom 节点的标签名。
- attrs:是一个对象,里面包含了所有的属性。
- 第 3 个参数往后,就是它的子节点。
const t = <h1 color="blue" className="title">Click Me</h1>;
1. babel编译为
React.createElement(
h1,
{color:'blue', className:'title'},
'Click Me'
)
2. createElement函数的内部过程:返回一个对象来保存它的信息,这个对象就是虚拟dom!!!
function createElement(tag, attrs, ...children) {
2.1 初始化一个props,用作返回值
props={}; key=null;ref=null;self=null;source=null;
2.2 将attrs的属性、子节点都填充到props中(判断组件属性上是否有特殊属性`ref属性或key属性`,无则为null)
(1)if(hasValidKey(attrs)) ref=attrs.ref; key=''+attrs.key(用于diff的key)
(2)遍历attrs,向props中添加属性
2.3 返回一个对象
return {
// 只是多了一个typeof属性,唯一识别这个元素是ReactElement的标识
$$typeof:REACT_ELEMENT_TYPE,
tag: tag,
attrs: attrs:
children:children,
ref: ref
key: key
};
}
babel: es6 -> es5,jsx -> js。
v17开始,jsx 转换时会引入 jsx-runtime 处理。
import { jsx as _jsx } from "react/jsx-runtime";
import { jsxs as _jsxs } from "react/jsx-runtime";
function x() {
return _jsxs("div", {
className: "box",
children: [_jsx("span", {
children: _jsx("em", {
children: "title"
})
}), _jsx("div", {
children: "1111"
})]
});
}
帮助理解 Diff
react 分别对 tree、component、element 三个层级的 diff 进行优化,时间复杂度为O(n)
,只需一次遍历就能完成整个 DOM 树的比较,优化策略为:
-
tree 层级:对树进行分层比较,会忽略跨层级的操作。如果发现节点已经不存在,则会删除该节点及其子节点(只有创建、删除操作)。
-
component 层级 (react 假设相同类型生成相似树形结构的策略)
- 如果是同一类型的组件(div、p等)则继续比较。
- 如果不是,则就删除该组件和它所有子节点,重新创建。
-
element 层级 :针对同一层级的节点,通过 key 来识别哪些元素是移动的。
- 插入、删除:如果该节点只在旧集合存在或只在新集合中存在。
- 移动:当新旧集合中存在相同节点时,比较节点在新旧集合中的下标,当
新下标 > 旧下标
时就会移动,否则不移动。
过时内容:setState 更新机制
该部分针对v16,和v18会有出入。
1. 初始渲染
- 假如我们触发了
click
事件去setState
,首先在dispatchEvent
方法中调用batchedUpdates
批量更新方法; - 批量更新方法会将批量更新标识设置为
true
,然后执行更新事务的perform
方法;
return transaction.perform(callback, null, a, b, c, d, e);
1. callback 就是事务中的目标方法,也就是click的监听函数
2. a/b/c...就是
- 更新事务中有两个
wrapper
方法:
// wrapper1:
FLUSH_BATCHED_UPDATES = {
// initialize:空函数,什么都不做
initialize: emptyFunction,
// close:调用flushBatchedUpdates,该函数作用是批量更新组件
close: ReactUpdates.flushBatchedUpdates.bind(ReactUpdates),
}
// wrapper2:
RESET_BATCHED_UPDATES = {
// initialize:空函数,什么都不做
initialize: emptyFunction,
// close:将 全局 React批量更新标识(ReactDefaultBatchingStrategy.isBatchingUpdates)重置为false
close: function () { ReactDefaultBatchingStrategy.isBatchingUpdates = false; },
}
- 第 1 个是
FLUSH_BATCHED_UPDATES
,负责批量更新组件,即虚拟dom
到真实dom
的过程。 - 第 2 个是
RESET_BATCHED_UPDATES
,负责将React
的批量更新标识isBatchingUpdate
重置为false
。
- 更新事务的执行流程为:
transaction.perform(callback, null, a, b, c, d, e)
- 事务
initialize
:啥也不干; - 事务
perform
:执行setState
传入的callback
方法; - 事务
close
:执行两个wrapper
的close
方法,- 首先组件批量更新;
- 然后将批量更新标识
isBatchingUpdate
设置为false
。
2. 再次渲染
if (alreadyBatchingUpdates) {
return callback(a, b, c, d, e);
} else {
return transaction.perform(callback, null, a, b, c, d, e);
}
(参考《数据驱动视图》)
判断当前是否处理批量更新状态,
- 是,就将待更新的组件放到
dirtyComponents
中; - 否,开启批量更新事务,批量更新组件,然后将批量更新标识重置为
false
。
参考