四、「深入React源码」--- 手写实现合成事件和批量更新

593 阅读10分钟

一、准备

  1. 合成事件和DOM原生事件的区别
  • DOM原生事件事件流:冒泡(button->body->document->window)、目标(目标元素捕获事件,开始像根节点冒泡)、捕获(window->body->document->button)
  • React合成事件:事件没有在目标对象上绑定,而是在document上监听所支持的所有事件,当事件发生并冒泡至document时,react将事件内容封装并交由真正的处理函数运行。
  1. 合成事件的原理是通过事件委托实现的
  • React17之前:事件都委托在document上
  • React17之后:事件都委托在外层容器上。目的是可以在一个页面中存在多个不同的React应用

作用:

  • 可以做浏览器的兼容。不同浏览器的api不一样,可以把不同的事件对象做成一个标准化的事件对象,提供标准的api访问供用户使用。
  • 如果react事件绑定在了真实DOM节点上,一个节点同时有多个事件时,页面的响应和内存的占用会受到很大的影响。因此SyntheticEvent作为中间层出现,把事件处理函数委托在外部容器上,性能上得到了大的提升。
  1. React的更新,可能是同步,可能是异步
  • 异步:React能够管理范围内,比如:事件函数、生命周期函数里,都是异步的
  • 同步:除以上情况外,比如:setTimeout等原生的事件,都是同步的

二、批量更新

事件函数处理之前,开启批量更新模式,事件函数处理结束之前,关闭批量更新模式,然后遍历更新状态队列实现组件更新。

1. 实现思路

image-20210923181748563.png

1-1. 更新队列updateQueue

定义变量对象:更新队列updateQueue,属性:记录同步/异步更新的标志isBatchingUpdate默认值为规定为false即同步更新、将要更新的数组updaters更新器的数组、批量更新的方法batachUpdatesetState之前置为true,开始进行批量更新,更新之后置为false

1-2. 设置批量更新模式

每次状态更新,一定会执行emitUpdate触发更新函数,进入updateComponent方法来更新组件,那么①我们需要在此判断,如果updateQueue.isBatchingUpdate值为真,那么就开启批量更新模式,把要更新的状态存入更新队列中。②否则就是同步更新直接调用updateComponent更新组件

2. 代码实现

2-1. src/index.js

import React from "./react";
import ReactDOM from "./react-dom";
// import { updateQueue } from "./component";

class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = { number: 0, title: "计数器" };
  }

  handleClick = (event) => {
    console.log("event", event);
    // 更改状态前把标识置为true,进行批量更新
    // updateQueue.isBatchingUpdate = true;
    // debugger;
    this.setState({ number: this.state.number + 1 });
    console.log(this.state);
    this.setState({ number: this.state.number + 1 });
    console.log(this.state);
    setTimeout(() => {
      this.setState({ number: this.state.number + 1 });
      console.log(this.state);
      this.setState({ number: this.state.number + 1 });
      console.log(this.state);
    });
    // 更改后重置为同步、并拿到所有updater调用更新
    // updateQueue.isBatchingUpdate = false;
    // updateQueue.batchUpdate();
  };

  handleParentClick() {
    console.log("handleParentClick");
  }

  render() {
    return (
      <div onClick={this.handleParentClick}>
        <p>{this.state.number}</p>
        <button onClick={this.handleClick}>+</button>
      </div>
    );
  }
}
ReactDOM.render(<Counter />, document.getElementById("root"));

2-2. src/component.js

import { compareToVdom } from "./react-dom";
> > > export let updateQueue = {
> > >   // 控制同步更新和异步更新
> > >   isBatchingUpdate: false,
> > >   // 记录异步更新的
> > >   updaters: [],
> > >   // 批量更新
> > >   batchUpdate() {
> > >     updateQueue.updaters.forEach((updater) => updater.updateComponent());
> > >     updateQueue.isBatchingUpdate = false;
> > >     updateQueue.updaters.length = 0;
> > >   },
> > > };

/** 更新器 */
class Updater {
  constructor(classInstance) {
    // 保存实例
    this.classInstance = classInstance;
    // 等待更新的状态数组
    this.pendingStates = [];
  }

  addState(partialState) {
    this.pendingStates.push(partialState);
    // 触发更新
    this.emitUpdate();
  }

  emitUpdate() {
    // 说明当前是批量更新模式
> > >     if (updateQueue.isBatchingUpdate) {
> > >       // 批量更新为异步,先存入数组
> > >       updateQueue.updaters.push(this);
> > >     } else {
> > >       this.updateComponent();
> > >     }
  }

  updateComponent() {
    // 解构出实例、等待更新的状态
    let { classInstance, pendingStates } = this;
    if (pendingStates.length > 0) {
      shouldUpdate(classInstance, this.getState());
    }
  }

  /** 基于老状态和pendingStates获取新状态 */
  getState() {
    let { classInstance, pendingStates } = this;
    let { state } = classInstance; //老状态
    // 每个分状态 ==> 合并属性
    pendingStates.forEach((nextState) => {
      state = { ...state, ...nextState };
    });
    // 清空等待更新的分状态数组
    pendingStates.length = 0;
    return state;
  }
}

function shouldUpdate(classInstance, nextState) {
  classInstance.state = nextState; // 先把新状态赋值给实例的state
  classInstance.forceUpdate(); // 让类的实例强行更新
}

class Component {
  // 当子类继承父类的时候,父类的静态属性也是可以继承的
  // 函数组件和类组件编译后,都会变成函数,因此加上isReactComponent属性来区分是函数组件还是类组件
  static isReactComponent = true;
  constructor(props) {
    this.props = props;
    this.state = {}; // 初始值
    this.updater = new Updater(this);
  }

  /** 更新分状态 */
  setState(partialState) {
    this.updater.addState(partialState);
  }

  /** 根据新的属性状态,计算新的要渲染的虚拟DOM */
  forceUpdate() {
    //获取老的虚拟DOM
    let oldRenderVdom = this.oldRenderVdom;
    //获取老的真实DOM
    let oldDOM = oldRenderVdom.dom;
    // 基于新的属性和状态,计算新的真实DOM
    let newRenderVdom = this.render();
    compareToVdom(oldDOM.parentNode, oldRenderVdom, newRenderVdom);
    // 使下次更新时以上次的新DOM为比较
    this.oldRenderVdom = newRenderVdom;
  }
}

export { Component };

三、合成事件

通过事件委托把dom的处理函数委托在外部容器上,模拟冒泡捕获目标元素,执行对应的事件处理函数。

1. 实现思路

1-1. 添加合成事件

image.png 添加工具函数addEvent,修改原先绑定事件方法为addEvent,入参:dom元素、事件/属性名、事件函数。这么做的目的是为了实现目标元素的事件委托。给dom添加store自定义属性,赋值为事件处理函数,初始值为{}。没有给dom自身绑定事件,但是事件处理函数是在每个dom的store属性上存放的,最后把所有事件处理函数委托给dispatchEvent进行统一处理

1-2. 实现dispatchEvent

在事件处理函数打印event:event以上并不是真正的原生的事件对象,是React合成事件对象,真正的原生的事件对象存放在nativeEvent属性里。 image.png

dispatchEvent:合成事件的统一代理处理函数,入参为事件对象event。从事件对象上可以拿到目标元素target事件类型type。把原生的事件对象传给createSyntheticEvent方法创建合成事件。此时,函数执行前开启批量更新。从目标元素target上可以拿到store属性,store属性上又存放着事件处理函数,可以直接调用。事件函数函数执行完毕后关闭批量更新模式,遍历更新队列的状态数组进行更新

1-3. 创建合成事件

createSyntheticEvent方法用来创建合成事件。把原生对象的属性拷贝到合成事件对象上一份。添加对应的属性存放需要兼容的方法,比如preventDefault阻止浏览器默认事件、stopPropagation阻止冒泡等

1-4. 实现React事件冒泡

image.png 实现React事件冒泡,本质模拟浏览器的冒泡。点击button触发onclick事件处理函数,冒泡到文档对象document后,原生浏览器的冒泡过程已经结束,并没有捕获阶段。而且,节点上的onclick属性是我们手动创建的自定义事件,浏览器不识别此属性,因此需要我们自己模拟冒泡查找到事件源开始执行事件函数(如果不模拟冒泡,父节点div上的onclick事件就不会执行)。

2. 代码实现

2-1. src/event.js

> > > import { updateQueue } from "./component";
> > > 
> > > /**
> > >  * 绑定事件处理函数
> > >  * @param {*} dom 要绑定事件的DOM元素 button
> > >  * @param {*} eventType 事件类型 onclick
> > >  * @param {*} handler 事件处理函数  handleClick
> > >  */
> > > export function addEvent(dom, eventType, handler) {
> > >   // 确保dom上始终有一个store属性,初始值为{}
> > >   let store = dom.store || (dom.store = {});
> > >   store[eventType] = handler; // dom.store['onclice'] = hanldeClick 这个事件处理函数委托给了根容器上
> > >   if (!document[eventType]) {
> > >     document[eventType] = dispatchEvent;
> > >   }
> > > }
> > > 
> > > /**
> > >  * 合成事件的统一代理处理函数
> > >  * @param {} event
> > >  */
> > > function dispatchEvent(event) {
> > >   let { target, type } = event;
> > >   let eventType = `on${type}`;
> > >   let syntheticEvent = createSyntheticEvent(event);
> > >   // 事件函数执行前,先设置为批量更新
> > >   updateQueue.isBatchingUpdate = true;
> > > 
> > >   // 模拟React事件冒泡
> > >   while (target) {
> > >     let { store } = target;
> > >     let handler = store && store[eventType];
> > >     handler && handler(syntheticEvent); // 这个event不是原生事件对象
> > >     target = target.parentNode; // 向上冒泡
> > >   }
> > >   // 执行对应的事件函数
> > >   // 执行结束后重置批量更新对象
> > >   updateQueue.isBatchingUpdate = false;
> > >   updateQueue.batchUpdate();
> > > }
> > > 
> > > /**
> > >  * 为什么React不把原生事件对象直接传给事件处理函数
> > >  * 1.为了兼容性 比如阻止默认行为、冒泡等
> > >  * @param {*} nativeEvent
> > >  * @returns
> > >  */
> > > function createSyntheticEvent(nativeEvent) {
> > >   let syntheticEvent = [];
> > >   for (let key in nativeEvent) {
> > >     // 把原生事件对象上的属性拷贝到合成事件对象上
> > >     syntheticEvent[key] = nativeEvent[key];
> > >     syntheticEvent.nativeEvent = nativeEvent;
> > >     syntheticEvent.preventDefault = preventDefault;
> > >     syntheticEvent.stopPropagation = stopPropagation;
> > >     return syntheticEvent;
> > >   }
> > > }
> > > 
> > > /**
> > >  * 阻止默认事件
> > >  * @param {} event 原生事件对象
> > >  */
> > > function preventDefault(event) {
> > >   // 兼容ie
> > >   if (!event) {
> > >     window.event.returnValur = false;
> > >   }
> > >   // 标准浏览器
> > >   if (event.preventDefault) {
> > >     event.preventDefault();
> > >   }
> > > }
> > > 
> > > /**
> > >  * 阻止冒泡
> > >  */
> > > function stopPropagation() {
> > >   const event = this.nativeEvent;
> > >   if (event.stopPropagation) {
> > >     event.stopPropagation();
> > >   } else {
> > >     // 兼容
> > >     event.cancelBubble = true;
> > >   }
> > >   this.isPropagationStopped = true;
> > > }

2-2. src/react-dom.js

import { REACT_TEXT } 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);
}

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

  let { type, props } = vdom;
  let dom; //真实DOM
  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
  return dom;
}

/** 挂载类组件 */
function mountClassComponent(vdom) {
  let { type: ClassComponent, props } = vdom;
  // 把类组件的属性传递给类组件的构造函数,
  // 创建类组件的实例,返回组件实例对象
  let classInstance = new ClassComponent(props);
  //可能是原生组件的虚拟DOM,也可能是类组件的的虚拟DOM,也可能是函数组件的虚拟DOM
  let renderVdom = classInstance.render();
  //在第一次挂载类组件的时候让类实例上添加一个oldRenderVdom=renderVdom
  classInstance.oldRenderVdom = renderVdom;
  return createDOM(renderVdom);
}

/** 挂载函数组件 */
function mountFunctionComponent(vdom) {
  let { type: functionComponent, props } = vdom;
  //获取组件将要渲染的虚拟DOM
  let renderVdom = functionComponent(props);
  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;
    }
  }
}

/**
 * @param {*} parentDOM 父真实DOM
 * @param {*} oldVdom 老的虚拟DOM
 * @param {*} newVdom 新的虚拟DOM
 */
export function compareToVdom(parentDOM, oldVdom, newVdom) {
  // 获取oldRenderVdom对应的真实DOM
  let oldDOM = oldVdom.dom;
  // 根据新的虚拟DOM得到新的真实DOM
  let newDOM = createDOM(newVdom);
  // 把老的真实DOM替换为新的真实DOM
  parentDOM.replaceChild(newDOM, oldDOM);
}

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

四、总结

绑定事件

第一次先绑定父节点事件handleParentClick即store.onclick=handleParentClick,绑定后进行委托; 第二次绑定子节点事件handleClick;因为第一次已经委托了onclick事件所以第二次不再委托,但子节点上store属性保存了自己的事件处理函数handleClick

过程

  • 点击button,事件处理函数经过冒泡后开始执行 -->
  • 先拿到事件源button和事件类型click,拼出onclick后开始创建合成事件(除了原生的属性外,多出我们自己添加的preventDefault等属性)-->
  • 开启批量更新模式,开始执行真正的事件处理函数 -->
  • 走到setState更新状态,进入到更新批量状态 -->
  • 因为开启了批量更新,所以在更新阶段是把新状态放入更新数组而没有立即更新因此打印 0、0(前两次setState都是如此)-->
  • 走完状态的更新后,执行模拟React事件冒泡冒泡到父节点,也有onclick事件,调用事件处理函数后,打印handleParentClick -->
  • 开始走批量更新的更新逻辑,更新数组队列中两个状态(更新逻辑与上一节内容一致,不做赘述) -->
  • 最后执行setTimeout,因为没有走合成事件逻辑,还是同步更新,因此会打印2、3。