likes
comments
collection
share

React函数组件如何一步一步到真实DOM

作者站长头像
站长
· 阅读数 9

之所以会讨论这个话题,是因为最近在看react源码的时候,对组件更新时beginWork工作中的props校验还有一定的疑问,然后就开始查看组件Fiber节点上pendingProps的生成,然后一直向上追寻,到react元素对象,再到编译生成的react代码,最后到我们定义的组件,所以在探寻组件更新策略之前写下了这篇笔记。

1,定义的组件

首先使用vite搭建一个react项目,这里使用vite脚手架来搭建,是因为create-react-app搭建的react项目在生产构建后关于webpack的代码太多,影响我们对组件源码的观察,所以采用的vite

注意: 这两个脚手架编译react时都是采用相同的babel插件,所以更换脚手架对编译后的组件自身源码没有任何影响的。

准备一个案例:

// App.js
import { lazy } from 'react'
const MyFun = lazy(() => import('./views/MyFun.jsx'))
const MyClass = lazy(() => import('./views/MyClass.jsx'))

export default function App() {
  console.log('App Start')
  return (
    <div className="App">
      <div>react code</div>
      <MyFun name='MyFun'></MyFun>
      <MyClass name='MyClass'></MyClass>
    </div>
  );
}
// MyFun.js
import { useState, useRef } from 'react'

export default function MyFun(props) {
  console.log('MyFun Start')
  const [count, setCount] = useState(1)
  const ref = useRef()
  function handleClick() {
    setCount(2)
  }
  return (
    <div className="MyFun">
      <div ref={ref}>DOM Instance</div>
      <div>state: {count}</div>
      <div>name: {props.name}</div>
      <button onClick={handleClick}>Button</button>
    </div>
  )
}
// MyClass.js
import { Component, createRef } from 'react';

export default class MyClass extends Component {
  constructor(props) {
    super(props)
    console.log('MyClass Start')
    this.state = {
      count: 1
    }
    this.ref = createRef();
  }
  componentDidMount() {
    console.log('MyClass Mounted')
  }
  handleClick = () => {
    this.setState({ count: 2})
  }
  render() {
    return (
      <div className='MyClass'>
        <div ref={this.ref}>DOM Instance</div>
        <div>state: {this.state.count}</div>
        <div>name: {this.props.name}</div>
        <button onClick={this.handleClick}>Button</button>
      </div>
    );
  }
}

然后直接执行yarn build命令,构建编译生成生产环境的代码。

2,编译后的代码

首先我们查看函数组件MyFun编译后的代码:

React函数组件如何一步一步到真实DOM

将编译后的代码拿过来:

import{r as t,j as n}from"./index-e471acce.js";

function a(s){
  console.log("MyFun Start");
  const[e,o]=t.useState(1), c=t.useRef();
  function r(){
    o(2)
  }
  return n.jsxs("div",{
    className:"MyFun",
    children:[
      n.jsx("div",{ref:c,children:"DOM Instance"}),
      n.jsxs("div",{children:["state: ",e]}),
      n.jsxs("div",{children:["name: ",s.name]}),
      n.jsx("button",{onClick:r,children:"Button"})
    ]
    })
}
export{a as default};

这里的代码已经手动格式化过,方便我们观察对比。对于变量和函数重命名都是代码编译很常见的操作,不是我们的重点。这里我们主要关注的对jsx内容的处理,可以发现目前的react组件编译之后没有存在react.createElement方法了,

react.createElement('div', null, '...')

因为新版本的react采用的新的编译模式,这里的n.jsxn就是jsxRunTime导出的对象,所以目前创建react元素对象【react-element】是直接使用的jsx运行时中的方法,而不再使用react.createElement方法。既然编译后不再需要react,所以我们的组件中在不需要react时就可以不再引入react了,不像以前要必须引入。

这里我们可以查看jsx-runtime运行时的源码:

// packages\react\jsx-runtime.js
export {Fragment, jsx, jsxs} from './src/jsx/ReactJSX';

这里就是上面编译后的代码使用的jsxjsxs方法。

继续查看它的来源:

// packages\react\src\jsx\ReactJSX.js

import {jsx as jsxProd} from './ReactJSXElement';
const jsx = __DEV__ ? jsxWithValidationDynamic : jsxProd;
const jsxs = __DEV__ ? jsxWithValidationStatic : jsxProd;
const jsxDEV = __DEV__ ? jsxWithValidation : undefined;

export {REACT_FRAGMENT_TYPE as Fragment, jsx, jsxs, jsxDEV};

我们可以发现jsxjsxs方法在生产环境下都是引用的同一个方法jsxProd

ReactJSXElement文件中的jsx方法,如下图所示:

React函数组件如何一步一步到真实DOM

3,创建React元素对象

下面我们将打包后的代码部署到服务器中,查看jsx方法运行时如何创建的react元素对象。

React函数组件如何一步一步到真实DOM

这里我们首先查看第一个react元素的创建:

n.jsx("div",{ref:c,children:"DOM Instance"}),

React函数组件如何一步一步到真实DOM

在继续调试之前,我们还得先学习jsx方法源码,查看它的执行逻辑:

// packages\react\src\jsx\ReactJSXElement.js

export function jsx(type, config, maybeKey) {
  let propName;
  // 存储此节点的props
  const props = {};
  let key = null;
  let ref = null;

  if (maybeKey !== undefined) {
    key = '' + maybeKey;
  }
  // 组件key处理
  if (hasValidKey(config)) {
    key = '' + config.key;
  }
  // ref处理
  if (hasValidRef(config)) {
    ref = config.ref;
  }
  
  // props处理
  for (propName in config) {
    if (
      hasOwnProperty.call(config, propName) &&
      !RESERVED_PROPS.hasOwnProperty(propName)
    ) {
      props[propName] = config[propName];
    }
  }
 
  // props默认值处理
  if (type && type.defaultProps) {
    const defaultProps = type.defaultProps;
    for (propName in defaultProps) {
      if (props[propName] === undefined) {
        props[propName] = defaultProps[propName];
      }
    }
  }
 
  # 创建react元素对象
  return ReactElement(
    type,
    key,
    ref,
    undefined,
    undefined,
    ReactCurrentOwner.current,
    props,
  );
}

根据jsx方法源码可以看出,创建react元素对象的逻辑也比较简单,主要就是针对组件keyref,以及props的处理。

  • key的处理:其实给组件传递的key值,这个key会存储到创建的react元素对象上,之后会备份到根据react元素创建的Fiber节点之上,用于react组件更新diff时的优化条件。
  • ref的处理:就是给DOM绑定的ref对象,这里的hasValidRef校验就是判断ref不等于undefined,才会设置ref
  • props的处理:props的处理其实就是循环config对象,将此对象的所有属性内容拷贝到新的props对象中。

这里在新增props属性时,有两个判断条件:

  • 使用hasOwnProperty方法校验必须为config的自有属性,而非原型链上的属性。
  • RESERVED_PROPS对象拥有的属性,及非保留属性。
// 保留属性,新增的props不能是这几个属性
const RESERVED_PROPS = {
  key: true,
  ref: true,
  __self: true,
  __source: true,
};

只有同时满足这两个条件,才会将此属性新增到props对象。并且这里还有一个对props默认值的处理,如果定义了defaultProps,则会在此将默认值进行初始化的赋值。

最后调用ReactElement方法创建一个react元素对象,此方法内部就是创建一个新对象,然后直接返回。

const ReactElement = function(type, key, ref, self, source, owner, props) {
  const element = {
    $$typeof: REACT_ELEMENT_TYPE,
    type: type,
    key: key,
    ref: ref,
    props: props,
    _owner: owner,
  };

  return element;
};

到此,调用jsxRunTime.jsx方法创建react元素的逻辑就执行完成。

React函数组件如何一步一步到真实DOM

最后注意: 一个函数组件在加载时,它会递归的将本组件内所有react元素创建完成。

React函数组件如何一步一步到真实DOM

4,创建Fiber节点

在react元素对象创建完成之后,下一步就是根据此对象创建对应的Fiber节点【虚拟DOM】。

在react内部会调用createFiberFromElement方法来创建Fiber节点,此方法从名字上就可以看出它的作用:根据react-element对象创建Fiber节点。当然createFiberFromElement方法内还会有一些其他的逻辑和判断,具体的内容这里不会展开。

createFiberFromElement => createFiberFromTypeAndProps => createFiber

最终会来到createFiber方法中,这个方法的内容就是调用FiberNode构造函数,创建Fiber对象实例。

const createFiber = function(
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
): Fiber {

  // 创建Fiber节点
  return new FiberNode(tag, pendingProps, key, mode);
};

查看FiberNode构造函数:

function FiberNode(
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
) {
  // Instance

  this.tag = tag; // 节点类型,不同的值代表不同的节点对象
  this.key = key; // 组件key
  
  this.elementType = null; // 大部分情况同type,存储原始组件函数
  this.type = null; 
  // 存储FiberNode对象对应的dom元素【hostCompoent】,
  // 函数组件此属性无值,
  // 类组件此属性存储的是组件实例instance
  this.stateNode = null; 

  # FiberNode节点之间的链接
  this.return = null; // 指向父级节点对象FiberNode
  this.child = null; // 指向第一个子节点FiberNode
  this.sibling = null; // 指向下一个兄弟节点FiberNode
  this.index = 0;

  this.ref = null; // ref引用
  
  # hooks相关
  this.pendingProps = pendingProps; // 新的,等待处理的props
  this.memoizedProps = null; // 旧的,上一次存储的props
  this.updateQueue = null; // 存储update更新对象链表
  this.memoizedState = null; // 类组件:旧的,上一次存储的state; 函数组件:存储hook链表
  this.dependencies = null;

  this.mode = mode; // 模式,作用?

  // 各种effect副作用相关的执行标记
  this.flags = NoFlags;
  this.subtreeFlags = NoFlags; // 子孙节点的副作用标记,默认无副作用
  this.deletions = null; // 删除标记

  // 优先级调度,默认为0
  this.lanes = NoLanes;
  this.childLanes = NoLanes;

  # 这个属性指向另外一个缓冲区对应的FiberNode
  // current.alternate === workInProgress
  // workInProgress.alternate === current
  this.alternate = null;
  ...
  
}

这里创建Fiber节点时初始化的属性不多,重点是tag属性和pendingProps属性。

  • tag属性是标记不同的组件类型,比如函数组件,类组件,普通DOM节点组件hostCompoent
  • pendingProps属性就是存储的由react元素对象传递过来的props对象。

React函数组件如何一步一步到真实DOM

注意ref属性是在Fiber节点创建完成之后赋值的,不是直接传递给FiberNode构造函数的。

// packages\react-reconciler\src\ReactChildFiber.new.js

const fiber = createFiberFromElement(element, returnFiber.mode, lanes);
// 设置ref
fiber.ref = coerceRef(returnFiber, currentFirstChild, element);

React函数组件如何一步一步到真实DOM

5,创建DOM元素

Fiber节点创建完成之后,下一步就是执行该Fiber节点的工作流程,而每一个Fibe节点都有两个工作模块内容:

  • beginWork工作。
  • completeWork工作。

对于组件节点来说,它的重点在于beginWork工作,比如函数组件和类组件的加载逻辑会在这个流程中执行。

对于普通DOM节点来说,它的重点在于completeWork工作,在这里会根据Fiber节点中的type创建真实的DOM元素。

// 比如type: div
document.createElement('div')

在DOM元素创建完成之后,都会调用一个appendAllChildren方法,将子节点内容添加到自身元素上。

appendAllChildren(instance, workInProgress, false, false);

同时在这里还会处理新建DOM元素的事件绑定和样式内容。

最后在一个组件内所有DOM节点组件的工作执行完成后,在组件的根节点的stateNode属性上就会形成一个相对完整的DOM结构,而对于App根组件来说,它的根节点元素【div.App】对应的Fiber.stateNode属性上就会存在一个离屏的DOM树。

export default function MyFun(props) {
  console.log('MyFun Start')
  const [count, setCount] = useState(1)
  const ref = useRef()
  function handleClick() {
    setCount(2)
  }
  return (
    <div className="MyFun">
      <div ref={ref}>DOM Instance</div>
      <div>state: {count}</div>
      <div>name: {props.name}</div>
      <button onClick={handleClick}>Button</button>
    </div>
  )
}

比如MyFun组件的completeWork工作完成之后,它组件内的根dom元素对应的Fiber上就会存储组件内整个DOM结构。

// 根dom元素对应的fiber节点
<div className="MyFun">

React函数组件如何一步一步到真实DOM

此时它的stateNode属性存储的就是组件的DOM结构:

React函数组件如何一步一步到真实DOM

注意:这并不是最终的DOM结构。

6,构建DOM树

在DOM元素处理之后,最终会来到react渲染流程的最后一个阶段:commit阶段。

commit阶段的第二个子阶段:Muation阶段会进行真实的DOM树构建,因为在之前每个组件虽然已经形成了部分DOM结构,但是这个DOM结构并不是最终确定的,因为组件的状态变化会影响DOM树的结构,具体来说就是会给DOM节点对应的Fiber节点标记相应的副作用,比如DOM插入,移动和删除。在这些副作用都执行完成之后,才是最终确定的DOM树,最后会将这颗完整的DOM树添加到react应用的容器节点之中,到此页面的加载渲染就执行完成。

<div className="App"></div>

此时App根组件内它的根元素对应的Fiber节点的stateNode属性存储的就是一颗处理完成的完整DOM树。

React函数组件如何一步一步到真实DOM

React函数组件如何一步一步到真实DOM

最后将这个div添加到#root容器元素内,页面即加载显示完成。

// #root
container.appendChild('div');

React函数组件如何一步一步到真实DOM

最后我们再来看一下类组件编译后的代码:

class d extends r.Component{
  constructor(t){
    super(t);
    o(this,"handleClick",()=>{this.setState({count:2})});
    console.log("MyClass Start"),
    this.state={count:1},
    this.ref=r.createRef()
  }
  componentDidMount(){
    console.log("MyClass Mounted")
  }
  render(){
    return n.jsxs("div",{
      className:"MyClass",
      children:[
        n.jsx("div",{ref:this.ref,children:"DOM Instance"}),
        n.jsxs("div",{children:["state: ",this.state.count]}),
        n.jsxs("div",{children:["name: ",this.props.name]}),
        n.jsx("button",{onClick:this.handleClick,children:"Button"})
      ]
    })
  }
}
export{d as default};

可以看出类组件在jsx的转化和处理方面和函数组件是完全一样的,所以后面就不重复解释了。

结束语

以上就是从react组件到真实DOM渲染的全部内容了,觉得有用的可以点赞收藏!如果有问题或建议,欢迎留言讨论!