«

react源码解密4-实现组件

时间:2026-4-4 14:04     作者:john     分类: 技术实现


elements和Component概念

上一节我们主入口render的时候,你会发现我们render的本质是React.createElement:

// ReactDOM.render(<div class='page-wrap'>hello myreact render to dom</div>)
React.createElement("div", {
    class: "page-wrap",
    __self: this,
    __source: {
        fileName: _jsxFileName,
        lineNumber: 12,
        columnNumber: 17
        })

这个React.createElement函数返回的其实是vnode,他就可以叫做elements。他是明确的不可变的一个结果。

但实际上我们在编写react的时候,不止是会render一个div,即我们经常render的并不是elements,而是render一个“返回createElements结果”的函数,例如:

import MyApp from './App.jsx'

reactDOM.render(<MyApp />)

上述代码本质上babel编译后是:

所以我们发现其实jsx编译后的createElement创建虚拟dom,他不仅仅是可以依靠“传入type为div”来生成,他的type字段还可以传入一个具有更复杂生成虚拟dom能力的“工厂函数”来生成虚拟dom elements。这个工厂函数本质上是对“如何产生类似于<div></div>这样的jsx的更复杂的包装”,于是这个工厂函数就叫做React组件。

当React.createElement发现传入的不是单纯的type是div,而是一个工厂函数的话,那createElement他应当去执行这个工厂函数来获得的vnode就是长这样的:

于是,我们发现了一种奇特的vnode类型,其type为一个function类型而不是字符串类型。

为什么要有组件

因为我们不止是生成一次elements就完事了。我们有时候确实要基于一些条件、逻辑、事件等来让elements发生变化。从表现来说就是我们要基于一定的条件、逻辑、事件等来改变我们页面中某些区域的内容。于是就产生了带有一定逻辑的这种element工厂函数。

渲染react组件

于是,我们来在jsx里面用一个这种特殊的标签类型,从而让虚拟dom树结构里面出现type为function类型的vnode。

ReactDOM.render(<div>
  <MyApp />
</div>, document.querySelector('#root'))

构建后结果:

此时我们发现reactDOM的render函数无法渲染了。本质在于我们之前逻辑中无法给这种function类型的vnode去创建出真实dom出来。于是我得改造reactDOM的render函数。
只需要在其判断vnode的地方看一下“若是函数类型则执行一下函数拿到vnode即可”:

// 这里涉及到深度优先遍历的算法
function doMount(vnode, parentRealDOM) {
  const { type, $$typeof, props } = vnode
  if (type && (typeof type === 'function')) {
    // 函数式组件,得先执行函数获取出来vnode再继续. 所谓继续就是拿着组件函数返回的vnode来继续去doMount。
    const functionVnodeRes = _getFunctionVnode(vnode, parentRealDOM)
    doMount(functionVnodeRes, parentRealDOM)
  }
  else if (type && $$typeof === REACT_ELEMENT_TYPE) {
    console.log('333')
    console.log('检查type', type, $$typeof, vnode)
    const realNode = _createRealDom(vnode)
    console.log(realNode)
    if (props?.children && Array.isArray(props.children)) {
      props.children.forEach(childVnode => doMount(childVnode, realNode))
    }
    parentRealDOM.appendChild(realNode)
    _setRealElementProps(vnode.props, realNode)
  }
  else if (typeof vnode === 'string') {
    console.log('444')
    const realNode = document.createTextNode(vnode)
    parentRealDOM.appendChild(realNode)
  }
  else {
    console.log('555')
  }
}

类组件

啥叫类组件,就是用class形式实现上述所谓“生成虚拟elements”的工厂函数。类怎么实现上述效果呢?其实无非就是能接受props且能返回vnode就行呗。

演示我们实际操练的类组件:

import React from '../myreact/react'

export default class MyApp extends React.Component {
  render() {
    return <div>hello <span>{this.props.msg}</span><span>child2</span></div>
  }
}
// 对应的我们react源码就得实现 React.Component基类:
export default class Component {
  static REACT_CLASS = true
  constructor(props) {
    this.props = props
  }
}

接下来,我们让我们的页面源码中包含上述类组件:

import MyClassApp from './ClassApp'

ReactDOM.render(<div>
  <MyClassApp msg="child1" />
</div>, document.querySelector('#root'))

接下来,让我们的reactDOM.render能支持渲染上述类组件。

// 这里涉及到深度优先遍历的算法
function doMount(vnode, parentRealDOM) {
  if (!vnode) return
  const { type, $$typeof, props } = vnode
  // 类组件的核心就在这里。其实也没啥,就是调一下类的render函数而已。
  if (type && typeof type === 'function' && type.REACT_CLASS === true) {
    const comp = new type(props)
    const vnode = comp.render()
    doMount(vnode, parentRealDOM)
  }
  else if (type && (typeof type === 'function')) {
    // 函数式组件,得先执行函数获取出来vnode再继续. 所谓继续就是拿着组件函数返回的vnode来继续去doMount。
    const functionVnodeRes = _getFunctionVnode(vnode, parentRealDOM)
    doMount(functionVnodeRes, parentRealDOM)
  }
  else if (type && $$typeof === REACT_ELEMENT_TYPE) {
    const realNode = _createRealDom(vnode)
    console.log(realNode)
    if (props?.children && Array.isArray(props.children)) {
      props.children.forEach(childVnode => doMount(childVnode, realNode))
    }
    parentRealDOM.appendChild(realNode)
    _setRealElementProps(vnode.props, realNode)
  }
  else if (typeof vnode === 'string') {
    console.log('444')
    const realNode = document.createTextNode(vnode)
    parentRealDOM.appendChild(realNode)
  }
  else {
    console.log('555')
  }
}

渲染成功:

组件setState及其更新渲染

以class组件为例思考一下:

  1. 要想setState后触发组件更新,我们要分几步:class组件必须得先有state,同时还得有基类给你提供一个setState来更新组件实例里的state。
  2. setState执行的时候除了设置组件实例的state,额外要做的就是想办法触发本组件所对应的那坨管控的vnode的重新渲染(也就是重新执行一次本组件实例的render函数)。
  3. 光render完还不行,那仅仅是虚拟dom。还得研究把这个组件所管控的vnode所对应的当时的真实dom给改成最新的该组件return 的最新vnode-----即完成一次像根组件一样的vnode mount挂载,只是挂载的对象是当前组件所对应的旧dom所在位置。

于是,我们看看使用方怎么用先:

import React from '../myreact/react'

export default class MyApp extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      a: '111'
    }
    setTimeout(() => {
      this.setState({
        a: '333'
      })
      this.setState({
        a: '444'
      })
    }, 3000)
  }
  render() {
    return <div onClick={() => {
      console.log('点击了外层div')
    }}>hello <span>{this.state.a}</span><span>child2</span></div>
  }
}

这个组件里jsx里使用了this.state.a。 然后这个组件实例化的时候,构造函数里会定时器3秒后setState。理论上按照react效果,3秒后页面中要把MyApp组件对应位置dom节点渲染成444这个数字。

我们先看setState应该做啥:

  1. 他要把组件实例里的state给改成新的state。这当然得做。
  2. 他要触发组件的render函数让他重新render一次基于最新的state产生新vnode,且基于新vnode更新真实dom。
export default class Component {
  static REACT_CLASS = true
  constructor(props) {
    this.state = {}
    this.props = props
    this.updater = new Updater(this)
  }
  setState(newState) {
    this.updater.addState(newState)
  }
}

其实理论上我们就在setState里面设置一下this.state,然后调用this.update就好了。为啥还要额外弄一个专门的Updater类来做这件事呢?
第一是为了让更新逻辑单独抽取出去管理,避免影响Component类的观感。另一方面很重要:我们不单单是单纯的set到this.state,我们要考虑在一个事件(onClick)发生的时候,可能事件会从底冒泡到顶上,有n个事件处理器要改变state;即使是1个组件的事件处理器里面可能也会连续多次调用setState,为了避免每次调用setState都触发一次update(update除了涉及到render还涉及到vnode转真实dom挂载因此是比较耗性能的),于是单独弄一个updater稍微加了一些额外的缓冲队列逻辑:

  1. 先把新state放到数据队列里等待
  2. 如果发现批量开关isBatch开了,就意味着可能事件处理正在冒泡处理过程中,于是就先别触发update,先放队列里,等外部控制者去flush触发即可。
  3. 如果发现批量开关是关闭的,说明没有外部控制者在做批量动作,于是就可以直接触发update就好了。

如此来看,我们平常写代码的时候,如果连续写了好几个setState,他还真的可能并没有修改掉state(仅仅是把新state放到了一个待处理队列里),而是要等到真的下次达到渲染条件开始“重渲染”组件的时候,才真正执行setState。
以下是updater的实现:

export const updateQueue = {
  updaters: new Set(),
  isBatch: false
}

export function flushUpdaterQueue() {
  console.log('刷新待更新队列')
  updateQueue.isBatch = false
  for (let updater of updateQueue.updaters) {
    updater.launchUpdate()
  }
  updateQueue.updaters.clear()
}

class Updater {
  constructor(ClassComponentInstance) {
    this.classComponentInstance = ClassComponentInstance
    this.pendingSetStates = []
  }
  addState(partialState) {
    this.pendingSetStates.push(partialState)
    this.preHandleForUpdate()
  }
  preHandleForUpdate() {
    // 不知道为啥要这么判断。哦知道了:
    // https://gemini.google.com/app/c517366cc0b031de
    // 如果开发者连续两次setState,例如不在事件回调里写而是在setTimeout里面写。在 React 18 以前,由于这些异步代码脱离了 React 的事件包装环境,isBatch 确实是 false,会导致两次渲染。
    // React 18 的改进: 现在的 React 引入了 Automatic Batching(自动批处理),无论你在哪里调用 setState,它都能自动通过优先级调度来完成这种“关闸门”的操作。---例如最经典的:我发现setTimeout(()=>{},1000)和setTimeout(()=>{}, 1001)如果只相差1毫秒的话,react19是能做到只触发一次更新的(函数式组件只执行了一次)。
    if (updateQueue.isBatch) {
      updateQueue.updaters.add(this)
    }
    else {
      this.launchUpdate()
    }
  }
  launchUpdate() {
    const { classComponentInstance, pendingSetStates } = this
    if (pendingSetStates.length === 0) return
    classComponentInstance.state = pendingSetStates.reduce((pre, cur) => {
      return {
        ...pre,
        ...cur
      }
    }, classComponentInstance.state)
    this.pendingSetStates.length = 0
    classComponentInstance.update()
  }
}

最后我们看update的实现(在上方Component类里面写过了):

  update() {
    console.log('调用组件render,更新组件')
    console.log('vnode更新')
    const oldVNode = this.oldVNode
    const newVNode = this.render()
    console.log('oldVNode', oldVNode, newVNode)
    const oldDom = findDomByVNode(oldVNode)
    updateDomTree(oldDom, newVNode)
    this.oldVNode = newVNode
  }

他思路主要是用最新state再调一次组件的render拿到最新vnode,然后旧的vnode则用于找到对应真实dom位置。
于是我们确定了真实dom位置,以及确定了新的vnode后,就可以拿着vnode去生成真实dom元素然后挂载到真实dom位置。

其实就是局部做了一次咱们入口文件里所做的reactDOM.render。只是此时的父组件是当前组件老vnode所对应的那个真实dom的位置。

export function findDomByVNode(vnode) {
  if (!vnode) return null
  return vnode.dom
}

export function updateDomTree(oldDom, newVNode) {
  const parent = oldDom.parentNode
  if (parent) {
    parent.removeChild(oldDom)
    doMount(newVNode, parent)
  }
}

我认为一定要着重理解上面的组件的update函数。因为正常来说一个组件(无论是类组件还是函数组件),他在reactDOM.render那一刻就被递归实例化之后就变成了vnode的树,然后vnode的树递归创建出来了真实dom树。此时其实这两个树就已经跟class组件实例或函数组件断开了联系。

那为啥我们class组件或函数组件里的setTimeout定时器改变状态(或者dom事件改变状态)能够重新找到vnode树或真实dom树?其核心秘密就在于vnode创建真实dom的时候顺便把真实dom挂到了vnode,同时vnode又趁机挂到了class组件实例或函数组件里,最终组件实例里面又发生的闭包行为才能重新基于这个oldVNode线索去找回真实dom位置。

组件里的事件(合成事件)

react里面的事件他都是自己js合成的,你监听dom事件拿到的event并不是真的原生dom的event。

  1. 他是在document顶层做了事件委托,收到事件后,从触发本次事件的真实dom位置对应的那个真实dom开始往上自己主动循环遍历去冒泡找到其他的自定义react组件里的事件handler。
  2. 为啥自己js通过真实dom往上遍历真实dom,还能找到react的类组件或函数组件里定义的事件处理器? 核心秘密依然在于mount渲染挂载那一刻,就是vnode生成真实dom并创建真实dom属性的时候,此时把vnode上props里的事件监听器挂到了真实dom上的attach属性里,于是你真实dom遍历的时候就能基于该字段线索找到最初class组件实例里的事件处理器方法。
    (至于事件处理器的this指向问题,我感觉在这个过程中还是建议组件内部通过组件里你自己用箭头函数搞定吧,因为vnode创建真实dom并设置属性的时候并没有处理vnode是来自哪个组件实例的问题,因此事件处理器都丢失了this,但是事件处理器的词法作用域所在位置本来就是原组件里的闭包,所以你完全可以靠箭头函数方式搞定)。

下面,我们先看事件的绑定:

function _setRealElementProps(props, realNode) {
  if (!props) return
  Object.keys(props).forEach(k => {
    if (k === 'children') {
      return
    }
    else if (k === 'className') {
      realNode.setAttribute('class', props[k])
    }
    else if (/^on[A-Z]\w*/.test(k)) {
      // 事件处理
      addEvent(realNode, k.toLowerCase(), props[k])
    }
    else if (k === 'style' && typeof props[k] === 'object') {
      const styleObj = props[k]
      Object.keys(styleObj).forEach(s => {
        realNode.style[s] = styleObj[s]
      })
    }
    else {
      realNode.setAttribute(k, props[k])
    }
  })
}

主要就是挂载虚拟dom Vnode的时候,创建真实dom属性的时候,把事件设置到真实dom上。

addEvent的实现是这样的,其实就是把事件处理器挂到真实dom上等着被调用。同时真正的去创建一个真实dom的该事件类型的监听,但是是委托给了document一次即可:


export function addEvent(realDom, eventName, bindFunction) {
  // 事件委托
  realDom.attach = realDom.attach ?? {}
  realDom.attach[eventName] = bindFunction
  if (document.eventName) return
  document[eventName] = dispatchDelegateEvent
}

接下来看看事件触发后是如何分发的:

function dispatchDelegateEvent(event) {
  updateQueue.isBatch = true
  const eventTarget = event.target
  const eventType = event.type
  let currentTarget = eventTarget
  // 之所以要循环向上遍历来自己实现冒泡,是因为我们真实dom上其实并没有真的绑定事件,我们仅仅只有document上面绑定的顶层委托。因此真实用户点击了某个元素后,只会触发一次顶层委托click,我们要自己去冒泡来触达到所有监听者的handler。
  while (currentTarget) {
    const syntheticEvent = createSyntheticEvent(event)
    syntheticEvent.currentTarget = currentTarget
    const attachEvent = currentTarget.attach?.[`on${eventType}`]
    if (attachEvent) {
      attachEvent(syntheticEvent)
    }
    if (syntheticEvent.isPropagationStopped) {
      break;
    }
    currentTarget = currentTarget.parentElement
  }
  flushUpdaterQueue()
}

// 创建合成事件对象(即react统一封装后的虚拟的event对象)
function createSyntheticEvent(nativeEvent) {
  const syntheticEvent = {}
  for (let k in nativeEvent) {
    syntheticEvent[k] = typeof nativeEvent[k] === 'function' ? nativeEvent[k].bind(nativeEvent) : nativeEvent[k]
  }
  Object.assign(syntheticEvent, {
    nativeEvent, // 原生事件对象
    isDefaultPrevented: false,
    isPropagationStopped: false,
    preventDefault() {
      this.isDefaultPrevented = true
      // 兼容不同浏览器实现
      if (this.nativeEvent.preventDefault) {
        this.nativeEvent.preventDefault()
      }
      else {
        this.nativeEvent.returnValue = false
      }
    },
    stopPropagation() {
      this.isPropagationStopped = true
      if (this.nativeEvent.stopPropagation) {
        this.nativeEvent.stopPropagation()
      }
      else {
        this.nativeEvent.cancelBubble = true
      }
    }
  })
  return syntheticEvent
}

整体思路就是document上监听到之前被绑定过的某些事件后,例如监听到一个click,于是他就从触发该click 的那个目标dom开始看。
若dom上attach里有监听器,他就创建一个合成event事件对象然后主动触发这个监听器。
之后依次向上遍历冒泡所有dom。他也通过isPropagationStopped自己实现了冒泡的阻止。

ref和forwardRef

forwardRef的机制是:"告诉React,我这个组件愿意接收ref,并且把它作为第二个参数传递给我"。
为啥不通过props传递ref(例如)? 答:props里不可能传递ref啊,人家ref是关键字,createElement创建vnode的时候会把props里的ref挪到vnode顶层你不记得了吗??所以你去子组件里读取props.ref是读不到的!所以才出现了forwardRef,这样也顺便能让你明确这个组件是支持接受ref的。

那为啥class组件经常看到这样写<MyApp ref={myRef}></MyApp>。这是因为class组件确实支持这个语法,这个ref是会绑定到对应的class实例上从而让你可以调用他实例方法(而函数式组件没有实例概念,所以根本不存在这个用法)。然而class这个写法一方面容易跟props混淆,另一方面在“想把ref传递到子组件内部的场景下”确实做不到。
要想class组件也把外层ref能传到子组件内部,只能也类似下面这样借助“props+forwardRef”。对class组件来说其中forwardRef其实并没有什么黑科技,他只是一个包装范式,让你的子组件变成多一个参数可以接收ref,例如如下这样:

// Class组件也可以用forwardRef!
const ChildWithForwardRef = React.forwardRef((props, ref) => (
<ChildClass {...props} innerRef={ref} />
));
其实如果你想让ref绑定到子组件的某个div上,你无论class还是函数式组件,你都得靠forwardRef来实现。除非你用props里面搞别的乱七八糟的xxxRef名字来绕过。

但函数式组件的React.forwardRef感觉应该是让函数式组件从“不能接收ref”变成“能接受ref”吧? 其实现原理如下:

todo: