学习源码的目的
很多时候我们总是会纠结要不要学源码,会不会被卷之类的问题,但是要我看,学不学还是在于自己,无论你是出于什么样的目的来学,总之学到了就是你自己的。从功利的角度来说,学习源码就是为了面试,为了获得更好薪资待遇。不过作为一个技术人,是否会有一种深究的欲望呢,总是想要了解其中神奇的功能到底是如何实现,那么就让我们带着一些“目的”来学习吧。
一些问题
先从最简单的生命周期的问题来学习源码,学习前,带着一些问题去看,有助于解决一些平时可能想不通的问题,甚至是面试中遇到的面试题,下面是自己开始学习前的一些疑问:
- React 的挂载流程是什么样的?class 组件和 function 组件有什么不同呢?
- React 的生命周期方法执行顺序。
- 为什么带 will 的生命周期都是不安全的呢,比如 componentWillMount componentWillUpdate componentWillReceiveProps 都会加上了前缀 UNSAFE。
- useEffect 与 useLayout 的区别。
React 的架构
先从 React 整体架构来看 React 的生命周期过程,React 的初次渲染到页面上的过程可以分为两个阶段:
- render/reconcile阶段
- commit阶段
在 render 阶段会形成 Fiber 树,commit 阶段则是遍历 Fiber 树上的 effectList 链表来建立对应的 DOM 节点,并将其渲染到页面上,这也就是 React 初次挂载的流程。
本篇也是围绕着两个流程,尝试来解决开头提出的问题。源码的版本为17.0.2。
先来祭出一张官方的生命周期图。
从这张图可以很清楚的看到 React 生命周期分为了三个阶段,分别是挂载阶段、更新阶段以及卸载阶段。
我们来一一分析。
挂载阶段
从上图可以看到 React 在挂载阶段主要包括了四个生命周期,按顺序分别为:
- constructor
- getDerivedStateFromProps
- render
- componentDidMount
先利用vite简单的搭建一个 React 的示例,然后在构造函数中断点调试一下:
class App extends React.Component {
constructor(props) {
super(props);
debugger;
console.log('father constructor', this);
this.state = { count: 0 };
}
setCount = () => {
this.setState({ count: this.state.count + 1 });
};
render() {
console.log('father render');
return (
<div className="App">
<img src={logo} className="App-logo" alt="logo" />
<div>
<button onClick={this.setCount}>
count is: {this.state.count}
</button>
</div>
</div>
)
}
}
复制代码
从入口 render 函数到构造函数 App 中,经过了这么多的调用栈。先不去关心这个中间发生了什么,而是将关注点聚焦到构造函数中来。
React 文档中说了 constructor 一般是用来初始化 state 或者是做一些函数绑定工作,是不建议在 constructor 中去 setState的。那么我们就偏偏在 constructor 中 setState 一下。改造一下上面的构造函数。
constructor(props) {
super(props);
console.log('father constructor', this);
this.state = { count: 0 };
this.setState({
count: 0
});
}
复制代码
然后就发现控制台上多了一个 warning
顺着这个 warning,发现就是因为执行了 setState,很奇怪,setState变成了这样的
Component.prototype.setState = function (partialState, callback) {
if (!(typeof partialState === 'object' || typeof partialState === 'function' || partialState == null)) {
{
throw Error( "setState(...): takes an object of state variables to update or a function which returns an object of state variables." );
}
}
this.updater.enqueueSetState(this, partialState, callback, 'setState');
};
var ReactNoopUpdateQueue = {
enqueueSetState: function (publicInstance, partialState, callback, callerName) {
warnNoop(publicInstance, 'setState');
}
};
复制代码
setState 为什么直接执行了打印警告的方法呢,这不符合我们的期望啊,于是再次把断点打在了点击事件中,结果发现了另一个 setState
// setState的原型方法是一模一样的
var classComponentUpdater = {
enqueueSetState: function (inst, payload, callback) {
var fiber = get(inst);
var eventTime = requestEventTime();
var lane = requestUpdateLane(fiber);
var update = createUpdate(eventTime, lane);
update.payload = payload;
if (callback !== undefined && callback !== null) {
{
warnOnInvalidCallback(callback, 'setState');
}
update.callback = callback;
}
enqueueUpdate(fiber, update);
scheduleUpdateOnFiber(fiber, lane, eventTime);
}
};
复制代码
这才符合我们对 setState 的期望嘛,如果是这样的话,那就解释了为什么在 constructor 中执行 setState 不起作用的原因了。
不过为什么两者执行的方法不一样呢。继续下去才发现关键就在上面的 constructClassInstance 方法中,这个方法执行除了执行了构造方法外,还执行了一些其他的东西
// 这里省略了一些暂时不需要关注的代码
var isLegacyContextConsumer = false;
var unmaskedContext = emptyContextObject;
var context = emptyContextObject;
var contextType = ctor.contextType;
// 如果使用严格模式,也就是StrictMode,则会进入这里
// 这里react说是实例化两次以帮助检测副作用,暂时没有深入了解
if (
debugRenderPhaseSideEffectsForStrictMode &&
workInProgress.mode & StrictMode
) {
disableLogs();
try {
new ctor(props, context);
} finally {
reenableLogs();
}
}
// 执行构造函数的地方
var instance = new ctor(props, context);
// 获取fiber树的state
var state = workInProgress.memoizedState = instance.state !== null && instance.state !== undefined ? instance.state : null;
// 这里就是区分setState两种形态的方法之一
adoptClassInstance(workInProgress, instance);
return instance;
复制代码
function adoptClassInstance(workInProgress: Fiber, instance: any): void {
// 这里就是把setState的方法赋给了实例的updater
instance.updater = classComponentUpdater;
// fiber树关联实例
workInProgress.stateNode = instance;
// 实例需要访问光纤,以便调度更新
// 实际就是instance._reactInternals = workInProgress
setInstance(instance, workInProgress);
}
复制代码
到这里就很清晰了,只有执行了 constructor 方法以后,才能将真正的 setState 的方法放到实例上去。
好了,看完了构造函数,继续走到下一个方法 getDerivedStateFromProps 中。这个方法是在哪里调用的呢。回到上面执行 constructClassInstance 方法的地方,也就是 updateClassComponent 方法中,来看看这里是怎么做的
function updateClassComponent(
current, // 当前显示在页面上的fiber树,初次挂载的时候,current为null
workInProgress, // 内存中构建的fiber树
Component, // 这里就是我们的根应用,也就是App
nextProps, // props属性,根应用没有设置props,为空
renderLanes, // 调用优先级相关,暂不考虑
) {
// 这里去掉了一些关于context的逻辑
// 这个instance为null
// 通过上面我们知道,只有执行了构造函数以后,才会把实例赋值给stateNode
const instance = workInProgress.stateNode;
let shouldUpdate;
if (instance === null) {
// 猜测是并发模式下调用的,未找到此项入口
if (current !== null) {
current.alternate = null;
workInProgress.alternate = null;
workInProgress.flags |= Placement;
}
// 在最初的过程中,需要构造实例
constructClassInstance(workInProgress, Component, nextProps);
// 这里就是getDerivedStateFromProps的入口方法了
mountClassInstance(workInProgress, Component, nextProps, renderLanes);
shouldUpdate = true;
} else if (current === null) {
// In a resume, we'll already have an instance we can reuse.
shouldUpdate = resumeMountClassInstance(
workInProgress,
Component,
nextProps,
renderLanes,
);
} else {
// setState更新的时候走这里
shouldUpdate = updateClassInstance(
current,
workInProgress,
Component,
nextProps,
renderLanes,
);
}
// 完成当前fiber节点,返回下一个工作单元
const nextUnitOfWork = finishClassComponent(
current,
workInProgress,
Component,
shouldUpdate,
hasContext,
renderLanes,
);
return nextUnitOfWork;
}
复制代码
走到这里的时候,就需要将前面调试的方法串联起来去看,不然可能不会明白为什么 React 要这么去做,下面画了一个简单的流程图:
我们来以一段简单易懂的话来试图描述这个阶段所做的工作:
首先 React 使用了双缓存机制,显示在页面上的节点指针为 current,在内存中构建的节点为 workingInProgress 指针。初次挂载时,current 指针是为空的,所以 React 所做的工作就是根据 JSX 创建的节点树进行深度优先遍历,创建Fiber树,对比 current 上的旧的Fiber树,但首次创建 current 是空的,因此直接将属性添加到新的 Fiber 树上即可。构建完整颗 Fiber 树以后,则 render 阶段结束,开始进入 commit 阶段。
这里只是简单的描述了 render 阶段执行的流程,里面还有很多细节,不过不是今天关注的重点,有兴趣的可以去看看这篇文章。
这里的流程是 React 的 legacy 模式,而经常提到的可中断更新实际是 React 还正式发布的 concurrent 模式,关于 React 模式,文档中也提到了相关的概念,这里就直接拿过来作为参考:
- legacy 模式: ReactDOM.render(<App />, rootNode)。这是当前 React app 使用的方式。当前没有计划删除本模式,但是这个模式可能不支持这些新功能。
- blocking 模式: ReactDOM.createBlockingRoot(rootNode).render(<App />)。目前正在实验中。作为迁移到 concurrent 模式的第一个步骤。
- concurrent 模式: ReactDOM.createRoot(rootNode).render(<App />)。目前在实验中,未来稳定之后,打算作为 React 的默认开发模式。这个模式开启了所有的新功能。
好了,回到要讲的生命周期上来。这里已经找到了 getDerivedStateFromProps 的入口函数,接着来看看这个入口函数做了什么:
function mountClassInstance(
workInProgress,
ctor,
newProps,
renderLanes,
): void {
// 这里检查class类是否定义了render方法,getInitialState、getDefaultProps、propTypes、contextType格式以及一些生命周期方法的检查工作
checkClassInstance(workInProgress, ctor, newProps);
const instance = workInProgress.stateNode;
instance.props = newProps;
instance.state = workInProgress.memoizedState;
instance.refs = emptyRefsObject;
// 初始化了updateQueue,这是一个链表,所有的更新的内容都会放在这里
initializeUpdateQueue(workInProgress);
// 这里做了一个警告,就是不要直接把props赋值给state
if (instance.state === newProps) {
const componentName = getComponentName(ctor) || 'Component';
if (!didWarnAboutDirectlyAssigningPropsToState.has(componentName)) {
didWarnAboutDirectlyAssigningPropsToState.add(componentName);
console.error(
'%s: It is not recommended to assign props directly to state ' +
"because updates to props won't be reflected in state. " +
'In most cases, it is better to use props directly.',
componentName,
);
}
}
// 这里做了一些不安全的生命周期和context的警告,不是重点省略了
processUpdateQueue(workInProgress, newProps, instance, renderLanes);
instance.state = workInProgress.memoizedState;
const getDerivedStateFromProps = ctor.getDerivedStateFromProps;
// 这里执行了getDerivedStateFromProps生命周期函数
if (typeof getDerivedStateFromProps === 'function') {
applyDerivedStateFromProps(
workInProgress,
ctor,
getDerivedStateFromProps,
newProps,
);
instance.state = workInProgress.memoizedState;
}
// 这里如果有新的生命周期方法,也就是getDerivedStateFromProps和getSnapshotBeforeUpdate,
// 那么componentWillMount就不会调用,否则的话,就会被调用
if (
typeof ctor.getDerivedStateFromProps !== 'function' &&
typeof instance.getSnapshotBeforeUpdate !== 'function' &&
(typeof instance.UNSAFE_componentWillMount === 'function' ||
typeof instance.componentWillMount === 'function')
) {
callComponentWillMount(workInProgress, instance);
// If we had additional state updates during this life-cycle, let's
// process them now.
processUpdateQueue(workInProgress, newProps, instance, renderLanes);
instance.state = workInProgress.memoizedState;
}
}
复制代码
最后这里可以看到,当存在新的生命周期 API 时,那些带 will 的生命周期都不会被调用,稍后再来看这里的 will 生命周期,先来看看调用 getDerivedStateFromProps 的地方:
function applyDerivedStateFromProps(workInProgress, ctor, getDerivedStateFromProps, nextProps) {
var prevState = workInProgress.memoizedState;
var partialState = getDerivedStateFromProps(nextProps, prevState);
{
// 如果getDerivedStateFromProps没有返回值则会警告
warnOnUndefinedDerivedState(ctor, partialState);
}
// 这里将getDerivedStateFromProps返回的对象和上次的state进行了合并
var memoizedState = partialState === null || partialState === undefined ? prevState : _assign({}, prevState, partialState);
workInProgress.memoizedState = memoizedState;
}
复制代码
这个逻辑很好理解,就是执行了 getDerivedStateFromProps 的生命周期方法后,将其返回值和上次的 state 进行了合并。注意这个 applyDerivedStateFromProps 方法会在更新阶段再次被调用。而 getDerivedStateFromProps 的作用官方文档也给出了其唯一的作用:就是在 props 改变时更新 state。这里会在更新阶段再次来分析。
通过上面的代码,可以知道 getDerivedStateFromProps 和 componentWillMount的方法是不会同时调用的。注释掉 getDerivedStateFromProps 的方法,添加 componentWillMount 方法再来看看其执行流程:
function callComponentWillMount(workInProgress, instance) {
var oldState = instance.state;
// 调用willMount的生命周期方法
if (typeof instance.componentWillMount === 'function') {
instance.componentWillMount();
}
if (typeof instance.UNSAFE_componentWillMount === 'function') {
instance.UNSAFE_componentWillMount();
}
// 这里如果前后state不等,说明在willMount中给state重新赋值了,导致state引用改变了
if (oldState !== instance.state) {
{
error('%s.componentWillMount(): Assigning directly to this.state is ' + "deprecated (except inside a component's " + 'constructor). Use setState instead.', getComponentName(workInProgress.type) || 'Component');
}
// 直接赋值的话,react会把赋值的语句变成了setState调用
classComponentUpdater.enqueueReplaceState(instance, instance.state, null);
}
}
复制代码
官方文档上面有这么一句话
componentWillMount 在 render() 之前调用,因此在此方法中同步调用 setState() 不会触发额外渲染。
意思是在 render 阶段的更新是不会触发实例的 render,这是因为进入 setState 更新方法的时候,会去判断当前的树和内存中的树是一个引用,说明当前阶段是 render 阶段,因此不会去执行 render 方法。
执行完了 getDerivedStateFromProps 或者是 componentWillMount 之后,就到了 render 方法的调用了。这里需要回到上面的 updateClassComponent 方法中有一个 finishClassComponent 方法,就是调用 render 方法的地方。来看看这个方法:
function finishClassComponent(current, workInProgress, Component, shouldUpdate, hasContext, renderLanes) {
// 更新ref
markRef(current, workInProgress);
var didCaptureError = (workInProgress.flags & DidCapture) !== NoFlags;
// 这里shouldComponentUpdate如果返回的是false的话,则不会执行render,而是复用上次的fiber
if (!shouldUpdate && !didCaptureError) {
// Context providers should defer to sCU for rendering
if (hasContext) {
invalidateContextProvider(workInProgress, Component, false);
}
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
var instance = workInProgress.stateNode; // Rerender
ReactCurrentOwner$1.current = workInProgress;
var nextChildren;
if (didCaptureError && typeof Component.getDerivedStateFromError !== 'function') {
// 如果捕获到错误,但未定义getDerivedStateFromError,则卸载所有的子元素。
// componentDidCatch将安排更新以重新呈现回退。这是暂时的,直到我们将迁移到新的API。
// TODO: Warn in a future release.
nextChildren = null;
{
stopProfilerTimerIfRunning();
}
} else {
{
setIsRendering(true);
nextChildren = instance.render();
// 其实是调用了两次render,react解释是为了侦测副作用,而且这里执行的render的log是不会被打印出来的
if ( workInProgress.mode & StrictMode) {
disableLogs();
try {
instance.render();
} finally {
reenableLogs();
}
}
setIsRendering(false);
}
}
if (current !== null && didCaptureError) {
// 如果正在从错误中恢复,可以在不重用任何现有子元素的情况下进行协调。
// 从概念上讲,普通子元素和在错误中显示的子元素是两个不同的集合,
// 因此即使它们的标识匹配,也不应该重用普通子元素。
forceUnmountCurrentAndReconcile(current, workInProgress, nextChildren, renderLanes);
} else {
// 将render返回的react元素进行协调
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
}
// fiber记住实例上的state的值,作为状态缓存
workInProgress.memoizedState = instance.state; // The context might have changed so we need to recalculate it.
// 返回当前fiber的子元素
return workInProgress.child;
}
复制代码
通过上面的流程图可以知道,React 挂载的过程实际就是一个自顶向下不断遍历的过程,在 legacy 模式下这种递归更新是无法打断的,在 render 方法中做一些计算工作会导致更新的速度变慢,浏览器的卡顿的情况更加明显。因此才说要保持 render 方法的纯净和简洁。
执行完了 render 方法以后,一直到 commitRoot 方法,则开始进入了 commit 阶段。
总结一下 commit 阶段所做的事,首先就是 beforeMutation
阶段,这个阶段主要做了一些变量赋值的工作,其内部是调用了 commitBeforeMutationLifeCyles 方法,就是执行了 getSnapshotBeforeUpdate 的生命周期,不过需要注意的是,只有 state 或者 props 发生了变化的时候才会调用该方法,而首次挂载时,状态初始化,因此并不会调用该方法。
与此同时,尝试将根 App 组件改成了函数组件,并添加了一个子组件 Child
// 根组件
function App() {
const [count, setCount] = useState(0);
const [name, setName] = useState('qiugu');
const dom = useRef(null);
const clickHandler = () => {
setCount(count+1);
setName(name => name + count);
}
return (
<div className="App">
<img src={logo} className="App-logo" alt="logo" />
<Child name={name}/>
<div>
<button onClick={clickHandler} ref={dom}>
count is: {count}
</button>
</div>
</div>
)
}
// 子组件
class Child extends React.Component<{name: string}, {}> {
constructor(props: {name: string}) {
super(props);
console.log('child constructor');
this.state = { age: 20 };
}
getSnapshotBeforeUpdate() {
console.log('child getSnapshotBeforeUpdate');
return {
name: 'qiugu2'
}
}
componentDidUpdate(prevProps, prevState, snapshot) {
console.log(prevProps, prevState, snapshot, 'child componentDidUpdate');
console.log('child componentDidUpdate');
}
render() {
console.log('child render');
return <div>我是一个子组件姓名:{this.props.name}年龄:{this.state.age}</div>
}
}
复制代码
结果发现首次子组件就会调用了 getSnapshotBeforeUpdate 方法,但是如果根组件是 class 组件,首次则不会调用该方法!
紧接着则是 mutation
阶段,这个阶段主要是把fiber上携带的副作用标签进行对应的处理,这里可以不去先不去关心,有兴趣可以深入去了解如何插入节点的。但是可以知道的是,在mutation
阶段,DOM 元素将会被插入页面,所以这个时候如果有 ref 之类的 DOM 引用是可以访问到了。
function commitMutationEffects(root, renderPriorityLevel) {
while (nextEffect !== null) {
setCurrentFiber(nextEffect);
var flags = nextEffect.flags;
if (flags & ContentReset) {
commitResetTextContent(nextEffect);
}
if (flags & Ref) {
var current = nextEffect.alternate;
if (current !== null) {
// 移除ref引用
commitDetachRef(current);
}
}
var primaryFlags = flags & (Placement | Update | Deletion | Hydrating);
switch (primaryFlags) {
case Placement:
commitPlacement(nextEffect);
nextEffect.flags &= ~Placement;
break;
case PlacementAndUpdate:
commitPlacement(nextEffect);
nextEffect.flags &= ~Placement;
var _current = nextEffect.alternate;
commitWork(_current, nextEffect);
break;
case Hydrating:
nextEffect.flags &= ~Hydrating;
break;
case HydratingAndUpdate:
nextEffect.flags &= ~Hydrating;
var _current2 = nextEffect.alternate;
commitWork(_current2, nextEffect);
break;
case Update:
var _current3 = nextEffect.alternate;
commitWork(_current3, nextEffect);
break;
case Deletion:
commitDeletion(root, nextEffect);
break;
}
resetCurrentFiber();
nextEffect = nextEffect.nextEffect;
}
}
复制代码
最后则是 layout
阶段,这个阶段是在挂载好DOM元素后,同步执行了诸如 componentDidUpdate 和 useLayoutEffect 方法,并且调度 useEffect 方法。
function commitLayoutEffects(root, committedLanes) {
while (nextEffect !== null) {
setCurrentFiber(nextEffect);
var flags = nextEffect.flags;
if (flags & (Update | Callback)) {
var current = nextEffect.alternate;
// 执行生命周期方法,包括函数式组件的useEffect和useLayoutEffect
commitLifeCycles(root, current, nextEffect);
}
if (flags & Ref) {
// 更新DOM元素和ref的引用
commitAttachRef(nextEffect);
}
resetCurrentFiber();
nextEffect = nextEffect.nextEffect;
}
}
复制代码
这里为什么说调度 useEffect 方法呢,进入 commitLifeCycles 来看看:
function commitLifeCycles(finishedRoot, current, finishedWork, committedLanes) {
switch (finishedWork.tag) {
case FunctionComponent:
case ForwardRef:
case SimpleMemoComponent:
case Block:
{
// 函数组件进入这里
// 这里是useLayoutEffect的回调函数执行的入口
commitHookEffectListMount(Layout | HasEffect, finishedWork);
// 而这里则是调度useEffect的回调函数的入口
schedulePassiveEffects(finishedWork);
return;
}
case ClassComponent:
{
var instance = finishedWork.stateNode;
if (finishedWork.flags & Update) {
// 根据current是否存在区分当前是挂载还是更新
if (current === null) {
// 执行componentDidMount生命周期方法
instance.componentDidMount();
} else {
// 执行componentDidUpdate方法
var prevProps = finishedWork.elementType === finishedWork.type ? current.memoizedProps : resolveDefaultProps(finishedWork.type, current.memoizedProps);
var prevState = current.memoizedState;
instance.componentDidUpdate(prevProps, prevState, instance.__reactInternalSnapshotBeforeUpdate);
}
}
var updateQueue = finishedWork.updateQueue;
if (updateQueue !== null) {
commitUpdateQueue(finishedWork, updateQueue, instance);
}
return;
}
// 其他的fiber类型暂且不去考虑
case HostRoot:
return;
case HostComponent:
return;
case HostText:
return;
case HostPortal:
return;
case Profiler:
return;
case SuspenseComponent:
return;
case SuspenseListComponent:
case IncompleteClassComponent:
case FundamentalComponent:
case ScopeComponent:
case OffscreenComponent:
case LegacyHiddenComponent:
return;
}
}
复制代码
function schedulePassiveEffects(finishedWork) {
var updateQueue = finishedWork.updateQueue;
var lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
var firstEffect = lastEffect.next;
var effect = firstEffect;
do {
var _effect = effect,
next = _effect.next,
tag = _effect.tag;
if ((tag & Passive$1) !== NoFlags$1 && (tag & HasEffect) !== NoFlags$1) {
// 将useEffect回调方法和卸载方法加入了队列中,并没有直接执行
enqueuePendingPassiveHookEffectUnmount(finishedWork, effect);
enqueuePendingPassiveHookEffectMount(finishedWork, effect);
}
effect = next;
} while (effect !== firstEffect);
}
}
复制代码
这么一看其实就很明确了,在进入layout
阶段之后,根据组件类型是函数组件还是 class 组件,分别执行 useLayoutEffect 方法 或者是 componentDidMount
方法。如果函数组件中还存在了 useEffect 回调方法,则会将其加入队列中,会由 React 在合适的时机执行该方法。因此可以很明白的看到,对于 useLayoutEffect 和 useEffect 而言,一个是同步调用,另一个则是异步调用,所以如果在同步方法中执行了耗费时间的操作,则会影响页面的渲染速度,是不推荐这么做的。反之,放在 useEffect 方法中,则会有这样的问题。
至此整个 commit 阶段也就结束了。一些如何创建 Fiber,如何深度优先遍历整颗树的细节等都忽略了,不过总的来说,希望读者看完后,能对 React 的生命周期有一个新的认识,能够解答上面提出的部分问题。
未完待续
不知不觉,写的东西有点多,还有更新和卸载的阶段没有说到。本篇也是利用每天下班时间不断的调试源码,查看资料一点一点积累,无论是自己还是读者,都需要时间来消化这些内容,所以将更新和卸载放到下篇继续分析。