转前端一年半了,平时接触最多的框架就是React
。在熟悉了其用法之后,避免不了想深入了解其实现原理,网上相关源码分析的文章挺多的,但是总感觉不如自己阅读理解来得深刻。于是话了几个周末去了解了一下常用的流程。也是通过这篇文章将自己的个人理解分享出来。
在具体的源码流程分析之前,根据个人理解,结合网上比较好的文章,先来分析一些概念性的东西。后续再分析具体的流程逻辑。
React 15
架构分层
React 15
版本(Fiber以前)整个更新渲染流程分为两个部分:
-
Reconciler
(协调器); 负责找出变化的组件
-
Renderer
(渲染器); 负责将变化的组件渲染到页面上
Reconciler
在React
中可以通过setState
、forceUpdate
、ReactDOM.render
来触发更新。每当有更新发生时,Reconciler
会做如下工作:
- 调用组件的
render
方法,将返回的JSX
转化为虚拟DOM
- 将虚拟
DOM
和上次更新时的虚拟DOM
对比
- 通过对比找出本次更新中变化的虚拟
DOM
- 通知
Renderer
将变化的虚拟DOM渲染到页面上
Renderer
在对某个更新节点执行玩Reconciler
之后,会通知Renderer
根据不同的"宿主环境"进行相应的节点渲染/更新。
React 15的缺陷
React 15
的diff
过程是 递归执行更新 的。由于是递归,一旦开始就"无法中断" 。当层级太深或者diff
逻辑(钩子函数里的逻辑)太复杂,导致递归更新的时间过长,Js
线程一直卡主,那么用户交互和渲染就会产生卡顿。看个例子: count-demo
<button> click <button>
<li>1<li> -> <li>2<li>
<li>2<li> -> <li>4<li>
<li>3<li> -> <li>6<li>
当点击button
后,列表从左边的1、2、3
变为右边的2、4、6
。每个节点的更新过程对用户来说基本是同步,但实际上他们是顺序遍历的。具体步骤如下:
- 点击
button
,触发更新
-
Reconciler
检测到<li1>
需要变更为<li2>
,则立刻通知Renderer
更新DOM
。列表变成2、2、3
-
Reconciler
检测到<li2>
需要变更为<li4>
,通知Renderer
更新DOM
。列表变成2、4、3
-
Reconciler
检测到<li3>
需要变更为<li6>
,则立刻通知Renderer
更新DOM
。列表变成2、4、6
从此可见 Reconciler
和Renderer
是交替工作 的,当第一个节点在页面上已经变化后,第二个节点再进入Reconciler
。由于整个过程都是同步的,所以在用户看来所有节点是同时更新的。如果中断更新,则会在页面上看见更新不完全的新的节点树!
假如当进行到第2步的时候,突然因为其他任务而中断当前任务,导致第3、4步无法进行那么用户就会看到:
<button> click <button>
<li>1<li> -> <li>2<li>
<li>2<li> -> <li>2<li>
<li>3<li> -> <li>3<li>
这种情况是React
绝对不希望出现的。但是这种应用场景又是十分必须的。想象一下,用户在某个时间点进行了输入事件,此时应该更新input
内的内容,但是因为一个不在当前可视区域的列表的更新导致用户的输入更新被滞后,那么给用户的体验就是卡顿的。因此React
团队需要寻找一个办法,来解决这个缺陷。
React 16
架构分层
React15架构不能支撑异步更新以至于需要重构,于是React16架构改成分为三层结构:
- Scheduler(调度器);调度任务的优先级,高优任务优先进入Reconciler
- Reconciler(协调器);负责找出变化的组件
- Renderer(渲染器);负责将变化的组件渲染到页面上
Scheduler
React 15
对React 16
提出的需求是Diff更新应为可中断的,那么此时又出现了两个新的两个问题:中断方式和判断标准;
React
团队采用的是 合作式调度,即主动中断和控制器出让。判断标准为超时检测。同时还需要一种机制来告知中断的任务在何时恢复/重新执行。 React
借鉴了浏览器的requestIdleCallback
接口,当浏览器有剩余时间时通知执行。
由于一些原因React
放弃使用rIdc
,而是自己实现了功能更完备的polyfill
,即Scheduler
。除了在空闲时触发回调的功能外,Scheduler
还提供了多种调度优先级供任务设置。
Reconciler
在React 15
中Reconciler
是递归处理Virtual DOM
的。而React16
使用了一种新的数据结构:Fiber
。Virtual DOM
树由之前的从上往下的树形结构,变化为基于多向链表的"图"。
更新流程从递归变成了可以中断的循环过程。每次循环都会调用shouldYield()
判断当前是否有剩余时间。源码地址。
function workLoopConcurrent() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
workInProgress = performUnitOfWork(workInProgress);
}
}
前面有分析到React 15
中断执行会导致页面更新不完全,原因是因为Reconciler
和Renderer
是交替工作的,因此在React 16
中,Reconciler
与Renderer
不再是交替工作。当Scheduler
将任务交给Reconciler
后,Reconciler
只是会为变化的Virtual DOM
打上代表增/删/更新的标记,而不会发生通知Renderer
去渲染。类似这样:
export const Placement = /* */ 0b0000000000010;
export const Update = /* */ 0b0000000000100;
export const PlacementAndUpdate = /* */ 0b0000000000110;
export const Deletion = /* */ 0b0000000001000;
只有当所有组件都完成Reconciler
的工作,才会统一交给Renderer
进行渲染更新。
Renderer(Commit)
Renderer
根据Reconciler
为Virtual DOM
打的标记,同步执行对应的渲染操作。
对于我们在上一节使用过的例子,在React 16
架构中整个更新流程为:
-
setState
产生一个更新,更新内容为:state.count
从1
变为2
- 更新被交给
Scheduler
,Scheduler
发现没有其他更高优先任务,就将该任务交给Reconciler
-
Reconciler
接到任务,开始遍历Virtual DOM
,判断哪些Virtual DOM
需要更新,为需要更新的Virtual DOM
打上标记
-
Reconciler
遍历完所有Virtual DOM
,通知Renderer
-
Renderer
根据Virtual DOM
的标记执行对应节点操作
其中步骤2、3、4随时可能由于如下原因被中断:
由于Scheduler
和Reconciler
的工作都在内存中进行,不会更新页面上的节点,所以用户不会看见更新不完全的页面。
Diff原则
React的Diff
是有一定的 前提假设 的,主要分为三点:
- DOM跨层级移动的情况少,对
Virtual DOM
树进行分层比较,两棵树只会对同一层次的节点进行比较。
- 不同类型的组件,树形结构不一样。相同类型的组件树形结构相似
- 同一层级的一组子节点操作无外乎 更新、移除、新增 ,可以通过 唯一
ID
区分节点
无论是JSX
格式还是React.createElement
创建的React组件最终都会转化为Virtual DOM
,最终会根据层级生成相应的Virtual DOM
树形结构。React 15
每次更新会成新的Virtual DOM
,然后通 递归 的方式对比新旧Virtual DOM
的差异,得到对比后的"更新补丁",最后映射到真实的DOM
上。React 16
的具体流程后续会分析到
源码分析
React源码非常多,而且16以后的源码一直在调整,目前Github上最新源码都是保留xxx.new.js
与xxx.old.js
两份代码。react源码 是采用Monorepo
结构来进行管理的,不同的功能分在不同的package
里,唯一的坏处可能就是方法地址索引起来不是很方便,如果不是对源码比较熟悉的话,某个功能点可能需要通过关键字全局查询然后去一个个排查。开始之前,可以先阅读下官方的这份阅读指南
相关参考视频讲解:进入学习
因为源码实在是太多太复杂了,所有我这里尽可能的最大到小,从面到点的一个个分析。大致的流程如下:
- 首先得知道通过
JSX
或者createElement
编码的代码到底会转成啥
- 然后分析应用的入口
ReactDOM.render
- 接着进一步分析
setState
更新的流程
- 最后再具体分析
Scheduler
、Reconciler
、Renderer
的大致流程
触发渲染更新的操作除了ReactDOM.render
、setState
外,还有forceUpdate
。但是其实是差不多的,最大差异在于forceUpdate
不会走shouldComponentUpdate
钩子函数。
数据结构
Fiber
开始正式流程分析之前,希望你对Fiber
有过一定的了解。如果没有,建议你先看看这则视频。然后,先来熟悉下ReactFiber
的大概结构。
export type Fiber = {
// 任务类型信息;
// 比如ClassComponent、FunctionComponent、ContextProvider
tag: WorkTag,
key: null | string,
// reactElement.type的值,用于reconciliation期间的保留标识。
elementType: any,
// fiber关联的function/class
type: any,
// any类型!! 一般是指Fiber所对应的真实DOM节点或对应组件的实例
stateNode: any,
// 父节点/父组件
return: Fiber | null,
// 第一个子节点
child: Fiber | null,
// 下一个兄弟节点
sibling: Fiber | null,
// 变更状态,比如删除,移动
effectTag: SideEffectTag,
// 用于链接新树和旧树;旧->新,新->旧
alternate: Fiber | null,
// 开发模式
mode: TypeOfMode,
// ...
};
FiberRoot
每一次通过ReactDom.render
渲染的一棵树或者一个应用都会初始化一个对应的FiberRoot
对象作为应用的起点。其数据结构如下ReactFiberRoot
。
type BaseFiberRootProperties = {
// The type of root (legacy, batched, concurrent, etc.)
tag: RootTag,
// root节点,ReactDOM.render()的第二个参数
containerInfo: any,
// 持久更新会用到。react-dom是整个应用更新,用不到这个
pendingChildren: any,
// 当前应用root节点对应的Fiber对象
current: Fiber,
// 当前更新对应的过期时间
finishedExpirationTime: ExpirationTime,
// 已经完成任务的FiberRoot对象,在commit(提交)阶段只会处理该值对应的任务
finishedWork: Fiber | null,
// 树中存在的最旧的未到期时间
firstPendingTime: ExpirationTime,
// 挂起任务中的下一个已知到期时间
nextKnownPendingLevel: ExpirationTime,
// 树中存在的最新的未到期时间
lastPingedTime: ExpirationTime,
// 最新的过期时间
lastExpiredTime: ExpirationTime,
// ...
};
Fiber 类型
export const FunctionComponent = 0;
export const ClassComponent = 1;
export const IndeterminateComponent = 2; // 不确定类型;可能是class或function
export const HostRoot = 3; // 树的根
export const HostPortal = 4; // 一颗子树
export const HostComponent = 5; // 原生节点;根据环境而定,浏览器环境就是div等
export const HostText = 6; // 纯文本节点
export const Fragment = 7;
模式
到React 16.13.1
版本为止,内置的开发模式有如下几种:
export type TypeOfMode = number;
// 普通模式|Legacy模式,同步渲染,React15-16的生产环境用
export const NoMode = 0b0000;
// 严格模式,用来检测是否存在废弃API(会多次调用渲染阶段生命周期),React16-17开发环境使用
export const StrictMode = 0b0001;
// ConcurrentMode 模式的过渡版本
export const BlockingMode = 0b0010;
// 并发模式,异步渲染,React17的生产环境用
export const ConcurrentMode = 0b0100;
// 性能测试模式,用来检测哪里存在性能问题,React16-17开发环境使用
export const ProfileMode = 0b1000;
本文只分析 ConcurrentMode 模式
JSX与React.createElement
先来看一个最简单的JSX
格式编码的组件,这里借助babel
进行代码转换,代码看这
// JSX
class App extends React.Component {
render() {
return <div />
}
}
// babel
var App = /*#__PURE__*/function (_React$Component) {
_inherits(App, _React$Component);
var _super = _createSuper(App);
function App() {
_classCallCheck(this, App);
return _super.apply(this, arguments);
}
_createClass(App, [{
key: "render",
value: function render() {
return /*#__PURE__*/React.createElement("div", null);
}
}]);
return App;
}(React.Component);
关键点在于render
方法实际上是调用了React.createElement
方法。那么接下来我们只需要分析createElement
做了啥即可。我们先看看ReactElement
的结构:
let REACT_ELEMENT_TYPE = 0xeac7;
if (typeof Symbol === 'function' && Symbol.for) {
REACT_ELEMENT_TYPE = Symbol.for('react.element');
}
const ReactElement = function (type, key, ref, props) {
const element = {
// 唯一地标识为React Element,防止XSS,JSON里不能存Symbol
?typeof: REACT_ELEMENT_TYPE,
type: type,
key: key,
ref: ref,
props: props,
}
return element;
}
很简单的一个数据结构,每个属性的作用都一目了然,就不一一解释了。然后分析React.createElement
源码。
防XSS攻击
如果你不清楚XSS攻击,建议先读这篇文章如何防止XSS攻击?。
首先我们编码的组件都会转化为ReactElement
的对象。DOM的操作和产生都是有Js
脚本产生的。从根本上杜绝了三种XSS
攻击(你思品)。
但是React
提供了dangerouslySetInnerHTML
来作为innerHTML
的替代方案。假如某种场景下,接口给了我JSON
格式的数据。我需要展示在一个div
中。如果被攻击者拦截到了,并将JSON
替换为一段ReactElement
格式的结构。那么会发生什么呢?
我这里写了一个demo,当去掉?typeof
会发现会报错。而Symbol
无法JSON
化的,因此外部也是无法利用dangerouslySetInnerHTML
进行攻击的。具体检测的源码看这里
const hasOwnProperty = Object.prototype.hasOwnProperty;
const RESERVED_PROPS = {
key: true,
ref: true,
__self: true,
__source: true,
};
function createElement(type, config, children) {
let propName;
// Reserved names are extracted
const props = {
};
let key = null;
let ref = null;
if (config !== null) {
if (hasValidRef(config)) {
ref = config.ref;
}
if (hasValidKey(config)) {
key = '' + config.key;
}
}
// 过滤React保留的关键字
for (propName in config) {
if (hasOwnProperty.call(config, propName) && !RESERVED_PROPS.hasOwnProperty(propName)) {
props[propName] = config[propName];
}
}
// 遍历children
const childrenLength = arguments.length - 2;
if (childrenLength === 1) {
props.children = children;
} else if (childrenLength > 1) {
const childArray = Array(childrenLength);
for (let i = 0; i < childrenLength; i++) {
childArray[i] = arguments[i + 2];
}
props.children = childArray;
}
// 设置默认props
if (type && type.defaultProps) {
const defaultProps = type.defaultProps;
for (propName in defaultProps) {
if (props[propName] === undefined) {
props[propName] = defaultProps[propName];
}
}
}
return ReactElement(type, key, ref, props);
}
注释应该已经够清楚了哈。总结下来就是根据参数来生成一个ReactElement
对象,并绑定对应的props
、key
、ref
等;
render流程
ReactDOM.render
使用参考这里
一般来说,使用React
编写应用,ReactDOM.render
是我们触发的第一个函数。那么我们先从ReactDOM.render
这个入口函数开始分析render
的整个流程。
源码中会频繁出现针对hydrate
的逻辑判断和处理。这个是跟SSR
结合客户端渲染相关,不会做过多分析。源码部分我都会进行省略
ReactDOM.render
实际上对ReactDOMLegacy
里的render
方法的引用,精简后的逻辑如下:
export function render(
// React.creatElement的产物
element: React$Element<any>, container: Container, callback: ?Function,
) {
return legacyRenderSubtreeIntoContainer(
null,
element,
container,
false,
callback,
);
}
实际上调用的是legacyRenderSubtreeIntoContainer
方法,再来看看这个咯
function legacyRenderSubtreeIntoContainer(
parentComponent: ?React$Component<any, any>, // 一般为null
children: ReactNodeList, container: Container, forceHydrate: boolean, callback: ?Function,
) {
let root: RootType = (container._reactRootContainer: any);
let fiberRoot;
if (!root) {
// [Q]: 初始化容器。清空容器内的节点,并创建FiberRoot
root = container._reactRootContainer = legacyCreateRootFromDOMContainer(
container,
forceHydrate,
);
// FiberRoot; 应用的起点
fiberRoot = root._internalRoot;
if (typeof callback === 'function') {
const originalCallback = callback;
callback = function () {
const instance = getPublicRootInstance(fiberRoot);
originalCallback.call(instance);
};
}
// [Q]: 初始化不能批量处理,即同步更新
unbatchedUpdates(() => {
updateContainer(children, fiberRoot, parentComponent, callback);
});
} else {
// 省略... 跟上面类似,差别是无需初始化容器和可批处理
// [Q]:咦? unbatchedUpdates 有啥奥秘呢
updateContainer(children, fiberRoot, parentComponent, callback);
}
return getPublicRootInstance(fiberRoot);
}
根据官网的使用文档可知,在这一步会先清空容器里现有的节点,如果有异步回调callback
会先保存起来,并绑定对应FiberRoot
引用关系,以用于后续传递正确的根节点。注释里我标注了两个[Q]
代表两个问题。我们先来仔细分析这两个问题
初始化
从命名上看,legacyCreateRootFromDOMContainer
是用来初始化根节点的。
将legacyCreateRootFromDOMContainer
的返回结果赋值给container._reactRootContainer
,而_reactRootContainer
从代码上看是作为是否已经初始化的依据,也验证了这一点。不信的话,打开你的React
应用,查看下容器元素的_reactRootContainer
属性
function legacyCreateRootFromDOMContainer(
container: Container, forceHydrate: boolean,
): RootType {
// 省略 hydrate ...
return createLegacyRoot(container, undefined);
}
export function createLegacyRoot(
container: Container, options?: RootOptions,
): RootType {
return new ReactDOMBlockingRoot(container, LegacyRoot, options);
}
function ReactDOMBlockingRoot(
container: Container, tag: RootTag, options: void | RootOptions,
) {
// !!! look here
this._internalRoot = createRootImpl(container, tag, options);
}
一连串的函数调用,其实就是还回了一个ReactDOMBlockingRoot实例。其中重点在于属性_internalRoot
是通过createRootImpl
创建的产物。
function createRootImpl(
container: Container, tag: RootTag, options: void | RootOptions,
) {
// 省略 hydrate ...
const root = createContainer(container, tag, hydrate, hydrationCallbacks);
// 省略 hydrate ...
return root;
}
export function createContainer(
containerInfo: Container, tag: RootTag, hydrate: boolean, hydrationCallbacks: null | SuspenseHydrationCallbacks,
): OpaqueRoot {
return createFiberRoot(containerInfo, tag, hydrate, hydrationCallbacks);
}
export function createFiberRoot(
containerInfo: any, tag: RootTag, hydrate: boolean, hydrationCallbacks: null | SuspenseHydrationCallbacks,
): FiberRoot {
// 生成 FiberRoot
const root: FiberRoot = (new FiberRootNode(containerInfo, tag, hydrate): any);
if (enableSuspenseCallback) {
root.hydrationCallbacks = hydrationCallbacks;
}
// 为Root生成Fiber对象
const uninitializedFiber = createHostRootFiber(tag);
// 绑定 FiberRoot 与 Fiber
root.current = uninitializedFiber;
uninitializedFiber.stateNode = root;