六、「深入React源码」--- 手写实现组件生命周期

100 阅读12分钟

本文将循序渐进的从了解生命周期到实现生命周期。个别钩子会穿插上DOM-DIFF,在整个源码范围内本章节内容属于较难部分,因此花费篇幅比较大。如对生命周期已有了解,可以直接跳到第三节开始观看。

一、基本生命周期

image.png

  • 阶段1:初始化

    initialization,初始化状态和属性,即constructor

  • 阶段2:组件挂载

    组件将要挂载componentWillMount --> 渲染render --> 组件挂载完成componentDidMount

  • 阶段3:组件更新

    (属性更新componentWillReceiveProps) --> 状态更新 --> 更新前,将要更新componentWillUpdate --> 更新render --> 更新后componentDidUpdate

  • 阶段4:组件卸载

    componentWillUnupdate

二、子组件生命周期

image.png Counter组件生成实例CounterInstance,render渲染出div,div子节点p、button; div渲染后渲染ChildCounter,ChildCounter组件生成实例ChildCounterInstance,render渲染后产生div

1. 生命周期执行过程

我们通过一个简单的demo来观察各个生命周期钩子的触发,随后将根据我们观察到的结果去一一实现这些钩子。

demo:

父组件Counter,内部数据number初始值为0,渲染number、button,button绑定点击事件:每次点击number+1;
当number === 4时,渲染子组件ChildCounter并传递number进去,子组件ChildCounter渲染这个number

src/index.js

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

class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      number: 0,
    };
    console.log("Counter 1.constructor");
  }

  componentWillMount() {
    console.log("Counter 2.componentWillMount");
  }

  componentDidMount() {
    console.log("Counter 4.componentDidMount");
  }

  handleClick = () => {
    this.setState({
      number: this.state.number + 1,
    });
  };

  shouldComponentUpdate(nextProps, nextState) {
    console.log("Counter 5.shouldComponentUpdate");
    // state.number都会改变,只是添加了页面刷新的判断
    return nextState.number % 2 === 0;
  }

  componentWillUpdate() {
    console.log("Counter 6.componentWillUpdate");
  }

  componentDidUpdate() {
    console.log("Counter 7.componentDidUpdate");
  }

  render() {
    console.log("Counter 3.render");
    return (
      <div>
        <p>{this.state.number}</p>
        {this.state.number === 4 ? null : (
          <ChildCounter count={this.state.number} />
        )}
        <button onClick={this.handleClick}>+</button>
      </div>
    );
  }
}

class ChildCounter extends React.Component {
  componentWillReceiveProps(newProps) {
    console.log("ChildCounter 4.componentWillReceiveProps");
  }

  componentWillMount() {
    console.log("ChildCounter 1.componentWillMount");
  }

  componentDidMount() {
    console.log("ChildCounter 3.componentDidMount");
  }

  shouldComponentUpdate(nextProps, nextState) {
    console.log("ChildCounter 5.shouldComponentUpdate");
    return nextProps.count % 3 === 0;
  }

  componentWillUnmount() {
    console.log(console.log("ChildCounter 6.componentWillUnmount"));
  }

  render() {
    console.log("ChildCounter 2.render");
    return <div>{this.props.count}</div>;
  }
}

ReactDOM.render(<Counter />, document.getElementById("root"));

2. 执行结果

2-1. 初次渲染

Counter 1.constructor
Counter 2.componentWillMount
Counter 3.render
ChildCounter 1.componentWillMount
ChildCounter 2.render
ChildCounter 3.componentDidMount
Counter 4.componentDidMount

父组件初始化 --> 父组件将要挂载 --> 父组件渲染 --> 子组件将要挂载 --> 子组件渲染 --> 自组件挂载完成 --> 父组件挂载完成

2-2. 第一次点击

Counter 5.shouldComponentUpdate

第一次点击:Counter.state.number = 1。因为添加了%2判断,所以本次的number虽然已经从0改为1,但是更新的生命周期shouldComponentUpdate返回false,因此父组件Counter不会更新,则子组件ChildCounter不更新,页面就不刷新。

2-3. 第二次点击

Counter 5.shouldComponentUpdate
Counter 6.componentWillUpdate
Counter 3.render
ChildCounter 4.componentWillReceiveProps
ChildCounter 5.shouldComponentUpdate
Counter 7.componentDidUpdate

第二次点击:Counter.state.number = 2。父组件Counter更新的生命周期shouldComponentUpdate % 2返回true,开始更新。子组件ChildCouter接收到新的属性,但是子组件更新的生命周期shouldComponentUpdate添加了%3判断,因此子组件不更新。

2-4. 第三次点击

Counter 5.shouldComponentUpdate

第三次点击:Counter.state.number = 3。父组件Counter更新逻辑% 2 === false,因此父组件Counter不更新,子组件ChildCouter也不更新,页面不刷新。

2-5. 第四次点击

Counter 5.shouldComponentUpdate
Counter 6.componentWillUpdate
Counter 3.render
ChildCounter 6.componentWillUnmount
Counter 7.componentDidUpdate

第四次点击: Counter.state.number = 4。父组件Counter的更新生命周期shouldComponentUpdate % 2返回true,开始更新、将要挂载、渲染。但是,此时子组件ChildCouter渲染条件为 === 4时,就渲染null,因此子组件ChildCouter被卸载。父组件Counter完成更新。

2-6. 第五次点击

Counter 5.shouldComponentUpdate

第五次点击:Counter.state.number = 5。道理同第三次点击。

2-7. 第六次点击

Counter 5.shouldComponentUpdate
Counter 6.componentWillUpdate
Counter 3.render
ChildCounter 1.componentWillMount
ChildCounter 2.render
ChildCounter 3.componentDidMount
Counter 7.componentDidUpdate

第六次点击:Counter.state.number = 6。父组件Counter的更新生命周期shouldComponentUpdate % 2返回true开始更新,挂载componentWillUpdate、渲染render。因为上次子组件被卸载,所以这次 要先经过即将挂载componentWillMount阶段。然后渲染render、挂载完成componentDidUpdate。随即父组件也挂载完成。

注意:此时重新挂载的子组件ChildCounter是新的组件,之前被销毁得组件就已经不存在了。

三、实现思路

生命周期函数:
挂载- constructor、componentWillMount、render、componentDidMount
更新- componentWillReciveProps、shouldComponentUpdate、componentWillUpdate、componentDidUpdate
卸载- componentWillUnmount

1. 挂载阶段

挂载阶段钩子的执行顺序?constructor --> componentWillMount --> render --> componentDidUpdate
经过前面几节内容,已经实现了constructor、render,所以接下来我们来实现componentWillMount和componentDidUpdate

mountClassComponent挂载类组件的方法中,添加componentWillMount钩子。

根据生命周期执行顺序来看,一定是先执行componentWillMount再执行render,因此我们需要再render之前判断,如果实例上有componentWillMount方法,就执行此方法。render执行,其实只是生成了对应的真实dom,并没有进行挂载。

但此时,我们可以做的是,把componentDidMount的this指向绑定给实例,作为componentDidMount属性绑在实例身上。那么在mount阶段,appendChild真正挂载节点之后,就直接实例.componentDidMount调用即可。

2. 更新阶段

更新阶段钩子的执行顺序?componentWillReciveProps --> shouldComponentUpdate --> componentWillUpdate --> componentDidUpdate
componentWillReciveProps需要依赖组件的父子关系,稍后处理

走到shouldUpdate方法,说明一定有状态/属性发生了改变就会触发shouldComponentUpdate钩子去询问组件是否需要更新。所以接下来我们只需要判断组件是否更新。之前shouldUpdate方法中,写死了要强制触发更新。现在重写逻辑。

定义变量willUpdate默认为true更新,来标识组件是否更新。即:1.组件不更新 2.组件更新。现在来判断以上两种情况。

  1. 组件不需要更新。在shouldComponentUpdate的前提下,shouldComponentUpdate方法执行结果为false,说明不需要更新,就更改标识变量为false。后面只处理状态/属性的更新就好了。

  2. 另一种情况,组件需要更新,即willUpdate的值为true。因此需要加上判断有componentWillUpdate就执行此方法。

我们了解不管组件是否更新,状态/属性肯定是要更新的,所以更新状态/属性的逻辑,不管是哪种情况都要执行,就会触发forceUpdateforceUpdate内部已经重新计算了新的状态和新的属性,因此我们需要在函数结束前判断,如果实例上面有componentDidUpdate方法的话,就执行。

3. componentWillReciveProps、componentWillUnmount

父子组件关系一旦形成,组件渲染就会发生联动关系。所以此时要稍微处理一下DOM-DIFF逻辑。

image.png

之前的逻辑是直接替换掉整个节点,太粗暴,我们在这里细腻处理一下。

我们把接收到的老dom和新dom进行比较:

  1. 老-无,新-无。不需要处理
  2. 老-无,新-有。挂载新组件:根据新的虚拟DOM创建新的真实DOM,然后appendChild添加到父节点,挂载阶段如果有componentDidMount也要执行。为了避免不必要的多次更换,需要判断是否有下一个节点,如果有的话就insertBefore到父节点,如果没有的话再appendChild
  3. 老-有,新-无。unMountVdom卸载老节点。先获取这个虚拟DOM对应的真实DOM。
    • ref.current的值置为null
    • 取消监听函数(存在store当中)-
    • 递归删除子节点。 如果是类组件,就执行componentWillUnmount进行卸载。最后删掉自己的节点。
  4. 老-有,新-有,且老新节点的类型不同。删掉老的,增加新的。
  5. 老-有,新-有,且老新节点的类型相同。复用老节点,只需要更新元素updateElement,进行深度对比子节点。根据节点类型不同做不同处理:
    • 文本节点:如果内容相同,就不做处理;如果内容不同,替换文本内容
    • 原生节点:复用老节点。获取老的真实DOM准备复用,然后执行updateProps用新的属性更新老的DOM节点。递归深度对比儿子updateChildren入参为父节点、老儿子、新儿子:老儿子和新儿子都转为数组,取最大长度进行遍历,内部把儿子一一进行深度对比compareToVdom
    • 函数-类组件:isReactComponent。把老DOM的实例赋值给新DOM的实例,然后进行类组件的更新updateClassComponent:同步类的实例,和渲染的虚拟DOM,父组件更新后,子组件接收到新的属性也需要更新,就会触发componentWillReceiveProps。执行componentWillReceiveProps后调用emitUpdate开始更新子组件。
    • 函数-函数组件:进行函数组件的更新updateFunctionComponent。拿到函数组件虚拟DOM,再拿到父节点。然后重新执行函数,返回新的DOM。把老的DOM和新的DOM进行深度对比compareToVdom,最后把新DOM的oldRenderVdom重新置为新DOM。

四、代码实现

完整生命周期代码实现-码云仓库

src/react.dom.js

import { REACT_TEXT, REACT_FORWARD_REF } from "./constants";
import { addEvent } from "./event";

/**
 *把虚拟DOM变成真实DOM插入容器
 * @param {*} vdom 虚拟DOM/React元素
 * @param {*} container 真实DOM容器
 */
function render(vdom, container) {
  mount(vdom, container);
}

/** 页面挂载真实DOM */
function mount(vdom, parentDOM) {
  //把虚拟DOM变成真实DOM
  let newDOM = createDOM(vdom);
  //把真实DOM追加到容器上
  parentDOM.appendChild(newDOM);
> > >   if (newDOM.componentDidMount) newDOM.componentDidMount();
}

/**
 * 把虚拟DOM变成真实DOM
 * @param {*} vdom 虚拟DOM
 * @return 真实DOM
 */
function createDOM(vdom) {
  if (!vdom) return null; // null/und也是合法的dom

  let { type, props, ref } = vdom;
  let dom; //真实DOM
  if (type?.$$typeof === REACT_FORWARD_REF) {
    return mountForwardComponent(vdom);
  } else if (type === REACT_TEXT) {
    // 如果元素为文本,创建文本节点
    dom = document.createTextNode(props.content);
  } else if (typeof type === "function") {
    if (type.isReactComponent) {
      // 说明这是一个类组件
      return mountClassComponent(vdom);
    } else {
      // 函数组件
      return mountFunctionComponent(vdom);
    }
  } else if (typeof type === "string") {
    //创建DOM节点 span div p
    dom = document.createElement(type);
  }

  // 处理属性
  if (props) {
    //更新DOM的属性 后面我们会实现组件和页面的更新。
    updateProps(dom, {}, props);
    let children = props.children;
    //如果说children是一个React元素,也就是说也是个虚拟DOM
    if (typeof children === "object" && children.type) {
      //把这个儿子这个虚拟DOM挂载到父节点DOM上
      mount(children, dom);
    } else if (Array.isArray(children)) {
      reconcileChildren(children, dom);
    }
  }
  vdom.dom = dom; // 给虚拟dom添加dom属性指向这个虚拟DOM对应的真实DOM
  if (ref) ref.current = dom;
  return dom;
}

/** 挂载类组件 */
function mountClassComponent(vdom) {
  let { type: ClassComponent, props, ref } = vdom;
  // 把类组件的属性传递给类组件的构造函数,
  // 创建类组件的实例,返回组件实例对象,以便在组件卸载时可以直接执行实例的方法
  let classInstance = new ClassComponent(props);
  // 在虚拟DOM上挂载classInstance,指向类的实例
> > >   vdom.classInstance = classInstance;
  // 如果有ref,就把实例赋值给current属性
  if (ref) ref.current = classInstance;
  if (classInstance.componentWillMount) classInstance.componentWillMount();
  //可能是原生组件的虚拟DOM,也可能是类组件的的虚拟DOM,也可能是函数组件的虚拟DOM
  let renderVdom = classInstance.render();
  //在第一次挂载类组件的时候让类实例上添加一个oldRenderVdom=renderVdom
  // 类组件的虚拟dom的oldRenderVdom属性,指向renderVdom
> > >   vdom.oldRenderVdom = classInstance.oldRenderVdom = renderVdom;
> > >   let dom = createDOM(renderVdom);
> > >   if (classInstance.componentDidMount) {
> > >     dom.componentDidMount = classInstance.componentDidMount.bind(classInstance);
> > >   }
  return dom;
}

/** 挂载函数组件 */
function mountFunctionComponent(vdom) {
  let { type: functionComponent, props } = vdom;
  //获取组件将要渲染的虚拟DOM
  let renderVdom = functionComponent(props);
  // 函数组件的oldRenderVdom属性,指向渲染的虚拟DOM--renderVdom
  vdom.oldRenderVdom = renderVdom;
  return createDOM(renderVdom);
}

/** 挂载经过转发的ref的函数组件 */
function mountForwardComponent(vdom) {
  let { type, props, ref } = vdom;
  let renderVdom = type.render(props, ref);
  return createDOM(renderVdom);
}

/** 如果子元素为数组,遍历挂载到容器 */
function reconcileChildren(children, parentDOM) {
  children.forEach((childVdom) => mount(childVdom, parentDOM));
}

/**
 * 把新的属性更新到真实DOM上
 * @param {*} dom 真实DOM
 * @param {*} oldProps 旧的属性对象
 * @param {*} newProps 新的属性对象
 */
function updateProps(dom, oldProps, newProps) {
  for (let key in newProps) {
    if (key === "children") {
      // 子节点另外处理
      continue;
    } else if (key === "style") {
      let styleObj = newProps[key];
      for (let attr in styleObj) {
        dom.style[attr] = styleObj[attr];
      }
    } else if (/^on[A-Z].*/.test(key)) {
      // 绑定事件 ==> dom.onclick = 事件函数
      // dom[key.toLowerCase()] = newProps[key];
      // 之后不再把事件函数绑定在对应的DOM上,而是事件委托到文档对象
      addEvent(dom, key.toLowerCase(), newProps[key]);
    } else {
      dom[key] = newProps[key];
    }
  }

  for (let key in oldProps) {
    //如果说一个属性老的属性对象里有,新的属性没有,就需要删除
    if (!newProps.hasOwnProperty(key)) {
      dom[key] = null;
    }
  }
}

/**
 * DOM-DIFF:递归比较老的虚拟DOM和新的虚拟DOM,找出两者的差异,把这些差异最小化的同步到真实DOM上
 * @param {*} parentDOM 父真实DOM
 * @param {*} oldVdom 老的虚拟DOM
 * @param {*} newVdom 新的虚拟DOM
 * @param {*} nextDOM 新的虚拟DOM
 *
 */
export function compareToVdom(parentDOM, oldVdom, newVdom, nextDOM) {
  /**
    // 之前写得:
    // 拿到老的真实DOM,创建新的真实DOM,新的替换掉老的
    // 性能很差,应完善,去做深度的dom-diff 
    // 获取oldRenderVdom对应的真实DOM
    let oldDOM = findDOM(oldVdom);
    // 根据新的虚拟DOM得到新的真实DOM
    let newDOM = createDOM(newVdom);
    // 把老的真实DOM替换为新的真实DOM
    parentDOM.replaceChild(newDOM, oldDOM);
  */

> > >   // 1.老-无 新-无:啥也不干
> > >   if (!oldVdom && !newVdom) return;
> > >   // 2.老-有 新-无:直接删除老节点
> > >   if (oldVdom && !newVdom) {
> > >     unMountVdom(oldVdom);
> > >   }
> > >   // 3.老-无 新-有:插入节点
> > >   if (!oldVdom && newVdom) {
> > >     mountVdom(parentDOM, newVdom, nextDOM);
> > >   }
> > >   // 4-1.老-有 新-有:判断类型不一样,删除老的,添加新的
> > >   if (oldVdom && newVdom && oldVdom.type !== newVdom.type) {
> > >     unMountVdom(oldVdom);
> > >     mountVdom(parentDOM, newVdom, nextDOM);
> > >   }
> > >   // 4-2.老-有 新-有:判断类型一样,进行DOM-DIFF,并且节点可复用
> > >   if (oldVdom && newVdom && oldVdom.type == newVdom.type) {
> > >     updateElement(oldVdom, newVdom);
> > >   }
}

/**
 * 新老DOM类型一样的更新----DOM-DIFF精髓之处
 * 如果新老DOM的类型一样,那么节点就可以复用
 */
> > > function updateElement(oldVdom, newVdom) {
> > >   // 新老节点都是文本节点:复用老的节点,替换内容
> > >   if (oldVdom.type === REACT_TEXT) {
> > >     // 老的真实DOM给新的DOM的dom属性,把内容改掉
> > >     let currentDOM = (newVdom.dom = findDOM(oldVdom));
> > >     currentDOM.textContent = newVdom.props.content;
> > >     // 原生节点
> > >   } else if (typeof oldVdom.type === "string") {
> > >     let currentDOM = (newVdom.dom = findDOM(oldVdom));
> > >     // 更新属性
> > >     updateProps(currentDOM, oldVdom.props, newVdom.props);
> > >     // 递归比较儿子
> > >     updateChildren(currentDOM, oldVdom.props.children, newVdom.props.children);
> > >     // 类组件或函数组件
> > >   } else if (typeof oldVdom.type === "function") {
> > >     // 类组件
> > >     if (oldVdom.type.isReactComponent) {
> > >       // 先同步实例
> > >       newVdom.classInstance = oldVdom.classInstance;
> > >       updateClassComponent(oldVdom, newVdom);
> > >       // 函数组件
> > >     } else {
> > >       updateFunctionComponent(oldVdom, newVdom);
> > >     }
> > >   }
> > > }

/**
 * 更新类组件
 * @param {*} oldVdom
 * @param {*} newVdom
 */
> > > function updateClassComponent(oldVdom, newVdom) {
> > >   // 复用老的类组件实例
> > >   let classInstance = (newVdom.classInstance = oldVdom.classInstance);
> > >   if (classInstance.componentWillReceiveProps) {
> > >     classInstance.componentWillReceiveProps(newVdom.props);
> > >   }
> > >   classInstance.updater.emitUpdate(newVdom.props);
> > > }

/**
 * 更新函数组件
 * @param {*} oldVdom
 * @param {*} newVdom
 */
function updateFunctionComponent(oldVdom, newVdom) {
  // 获取老的真实DOM的父节点
  let parentDOM = findDOM(oldVdom).parentNode;
  let { type, props } = newVdom;
  let newRenderVdom = type(props);
  // 函数组件更新每次都要重新执行函数,拿到新的虚拟DOM
  compareToVdom(parentDOM, oldVdom.oldRenderVdom, newRenderVdom);
  newVdom.newRenderVdom = newRenderVdom;
}

/**
 * 递归比较子节点
 * @param {*} parentDOM
 * @param {*} oldVChildren
 * @param {*} newVChildren
 */
> > > function updateChildren(parentDOM, oldVChildren, newVChildren) {
> > >   // 为方便后续进行DOM-DIFF,以数组形式保存
> > >   oldVChildren = Array.isArray(oldVChildren) ? oldVChildren : [oldVChildren];
> > >   newVChildren = Array.isArray(newVChildren) ? newVChildren : [newVChildren];
> > >   // 最大长度
> > >   let maxLength = Math.max(oldVChildren.length, newVChildren.length);
> > >   // 每一个都进行深度对比
> > >   for (let i = 0; i < maxLength; i++) {
> > >     // 在老的虚拟DOM查找,有老节点并且老节点真的对应一个真实DOM节点,并且这个索引要比我大(目的是找到本身的下一个节点)
> > >     let nextVdom = oldVChildren.find(
> > >       (item, index) => index > i && item && findDOM(item)
> > >     );
> > >     compareToVdom(
> > >       parentDOM,
> > >       oldVChildren[i],
> > >       newVChildren[i],
> > >       nextVdom && findDOM(nextVdom)
> > >     );
> > >   }
> > > }

/**
 * 插入新的真实DOM
 * @param {}} parentDOM
 * @param {*} vdom
 * @param {*} nextDOM
 */
> > > function mountVdom(parentDOM, newVdom, nextDOM) {
> > >   let newDOM = createDOM(newVdom);
> > >   if (nextDOM) {
> > >     parentDOM.insertBefore(newDOM, nextDOM);
> > >   } else {
> > >     parentDOM.appendChild(newDOM);
> > >   }
> > >   if (newDOM.componentDidMount) {
> > >     newDOM.componentDidMount();
> > >   }
> > > }

/**
 * 删除老的真实DOM
 * @param {*} vdom 老的虚拟DOM
 */
> > > function unMountVdom(vdom) {
> > >   let { type, props, ref } = vdom;
> > >   // 获取老的真实DOM
> > >   let currentDOM = findDOM(vdom);
> > >   // 如果这个子节点是类组件,还要执行它的卸载的生命周期函数
> > >   if (vdom.classInstance && vdom.classInstance.componentWillUnmount) {
> > >     vdom.classInstance.componentWillUnmount();
> > >   }
> > >   // 如果有ref,删除ref对应的真实DOM
> > >   if (ref) ref.current = null;
> > >   // 取消监听函数
> > >   Object.keys(props).forEach((propName) => {
> > >     if (propName.slice(0, 2) === "on") {
> > >       // 事件在真实dom就这样做
> > >       //   const eventName = propName.slice(2).toLowerCase()
> > >       //   currentDOM.removeEventListener(eventName, props[propName])
> > >       //但是我们先处理了合成事件,事件注册再store上
> > >       delete currentDOM.store;
> > >     }
> > >   });
> > >   // 如果有子节点,递归删除所有子节点
> > >   if (props.children) {
> > >     let children = Array.isArray(props.children)
> > >       ? props.children
> > >       : [props.children];
> > >     children.forEach(unMountVdom);
> > >   }
> > >   // 从父节点中把自己删除
> > >   if (currentDOM) currentDOM.parentNode.removeChild(currentDOM);
> > > }

/** 虚拟DOM返回的真实DOM */
export function findDOM(vdom) {
  if (!vdom) return null;
  // 如果有dom属性,说明这个vdom是原生组件的虚拟DOM,会有dom属性指向真实dom
  if (vdom.dom) {
    return vdom.dom;
  } else {
    return findDOM(vdom.oldRenderVdom);
  }
}

const ReactDOM = {
  render,
};
export default ReactDOM;