7556 字
38 分钟
React 虚拟 dom
2022-04-19

通过本文你将学习以下几点:

  • 虚拟DOM究竟是什么?
  • 虚拟DOM的优势是什么?解决了什么问题?
  • 虚拟DOM的性能比操作原生DOM要快吗?
  • react中的虚拟DOM是如何生成的?
  • react是如何将虚拟DOM转变成真实dom的?

什么是虚拟DOM?#

本文将默认你有 react 或者 vue 的开发经历,当然本文出发点还是以react为主。

熟悉 react 的同学对于 React.createElement 方法一定不会陌生,它用于创建reactNode,语法如下:

/*
* component 组件名,一个标签也可以理解成一个最基础的组件
* props 当前组件的属性,比如class,或者其它属性
* children 组件的子组件,就像标签套标签
*/
React.createElement(component, props, ...children)

比如我们定一个最简单的html片段:

<span className='span'>hello echo</span>

React.createElement表示如下:

React.createElement('div', {className:'span'}, 'hello echo');

这样看好像也没什么大问题,但是假定我们dom存在嵌套关系:

<div className='span'>
  <span>
    hello echo
  </span>
</div>

React.createElement表示就相对比较麻烦了,你需要在createElement中不断嵌套:

React.createElement('span', {className:'span'}, React.createElement("span", null, "hello echo"));

这还仅仅是两层嵌套,实际开发中dom结构往往要复杂的多,因此react中我们常常推荐直接使用jsx文件定义业务逻辑以及html片段。

我们可以将jsx中定义的html模板理解成React.createElement的语法糖,它方便了开发者以html的习惯去定义reactNode片段,而在编译之后,这些reactNode本质上还是会被转变成React.createElement所创建的对象,这个过程可以理解为:

alt text

为方便理解,我们可以将React.createElement创建对象结构抽象为:

const VirtualDom = {
  type: 'span',
  props: {
    className: 'span'
  },
  children: [{
    type: 'span',
    props: {},
    children: 'hello echo'
  }]
}

说到底,这个就是传递给React.createElement的结构,而React.createElement接收后生成的数据,其实才是真正意义上的虚拟dom。我们可以简单定一个react组件,来查看虚拟dom真正的结构:

class C extends React.PureComponent {
  render() {
    console.log(this.props.children);
    return <div>{this.props.children}</div>;
  }
}

class P extends Component {
  render() {
    return (
      <C>
        <span className="span">
          <span>hello echo</span>
        </span>
      </C>
    );
  }
}

alt text

那么到这里,我们搞清楚了虚拟DOM究竟是什么,所谓虚拟DOM其实只是一个包含了标签类型type,属性props以及它包含子元素children的对象。

虚拟DOM的优势是什么?#

销毁重建与局部更新#

在提及虚拟DOM的优势之前,我们可以先抛开什么虚拟DOM以及什么MVC思想,回想下在纯 js 或者 jq 开发角度,我们是如何连接UI和数据层的。在之前的开发中,UI和数据处理都是强耦合,比如我们页面渲染完成,使用onload进行监听,然后发起ajax请求,并在回调中加工数据,以及在此生成DOM片段,并将其替换到需要更新的地方。

打个比方,后端返回了一个用户列表userList

const userList = [
  'echo',
  '听风是风',
  '时间跳跃'
]

前端在请求完成,于是在ajax回调中进行dom片段生成以及替换工作,比如:

<ul id='userList'></ul>
const ulDom = document.querySelector('#userList');
// 生成代码片段
const fragment = document.createDocumentFragment();

for (let i = 0; i < userList.length; i++) {
  const liDom = document.createElement("li");
  liDom.innerHTML = userList[i];
  // 依次生成li,并加入到代码片段
  fragment.appendChild(liDom);
}

// 最终将代码片段塞入到ul
ulDom.appendChild(fragment);

所以不管是页面初始化,还是之后用户通过事件发起请求更新了用户数据,到头来还是都是调用上面生成li的这段逻辑。

所以你会发现,在原生js的角度,根本没有所谓的dom对比,都是重新创建,因为在写代码之前,我们已经明确知道了哪部分是静态页面,哪部分需要结合数据进行动态展示。那么只需要将需要动态生成的dom的逻辑提前封装成方法,然后在不同时期去调用,这在当年已经是非常不错的复用了(组件的前生)。

那么问题来了,假定现在我们有一个类似form表单的展示功能,点击不同用户,表单就会展示用户名,年龄等一系列信息:

alt text

js写怎么做?还是一样的,点击不同用户,肯定会得到一个用户信息对象,我们根据这个对象动态生成多个信息展示的input等相关dom,然后塞入到form表单中,所以每次点击,这个form其实都等同于完全重建了。

假定现在我们不希望完整重建这个结构,而是希望做前后dom节点对比,比如inputvalue前后不一样,某个style颜色不同,我们单点更新这个属性,比较笨拙的想法肯定还是得生成一份新dom片段,然后递归对比两个结构,且属性一一对比,只有不同的部分我们才需要更新。但仅仅通过下面这段代码,你就能预想到这个做法的性能有多糟糕了:

// 一个li节点自带的属性就有307个
const liDom = document.createElement("li");
let num = 0;
for (let key in liDom) {
  num += 1;
}
console.log(num); // 307

我们生成了一个最基本的li节点,并通过遍历依次访问节点的属性,经过统计发现li单属性就307个,而这仅仅是一个节点。

对于react而言,props是可变的,child是可变的,state也是可变的,而这些属性恰好都在虚拟dom中均有呈现。

所以到这里,我们解释了虚拟dom的第一个优势,站在对比更新的角度,虚拟dom能聚焦于需要对比什么,相对原生dom它提供更高效的对比可行性。

更佳的兼容性#

我们在上文提到,reactbabeljsx转成了js对象(虚拟dom),之后又通过render生成dom,那为啥还要转成js而不是直接生成dom呢,因为在这个中间react还需要做diff对比,兼容处理,以及跨平台的考虑,我们先说兼容处理。

准确来说,虚拟dom只是react中的一部分,要真正体现虚拟dom的价值,肯定得结合react中的其它设计来一起讲,其中一点就是结合合成事件所体现的强大的兼容性。

之前 jq 在操作dom的便捷,以及各类api兼容性上的贡献,而react中使用了虚拟dom也做了大量的兼容。

打个比方,原生的inputchange事件,普通的div总没有onchange事件吧?不管你有没有留意,其实dom和事件在底层已经做了强关联,不同的dom能触发的事件,浏览器在一开始就已经定义好了,而且你根本改不了。

但是虚拟dom就不同了,虚拟dom一方面模仿了原生dom的行为,其次在事件方面也做了合成事件与原生事件的映射关系,比如:

{
  onClick: ['click'],
  onChange: ['blur', 'change', 'click', 'focus', 'input', 'keydown', 'keyup', 'selectionchange']
}

react暴露给我们的合成事件,其实在底层会关联到多个原生事件,通过这种做法抹平了不同浏览器之间的api差异,也带来了更强大的事件系统。

渲染优化#

我们知道react遵循UI = Render(state),只要state发生了改变,那么render就会重新触发,以达到更新ui层的效果。而更改state依赖了setState,大家都知道setState对于state更新的行为其实是异步的,假设我们在一次事件中更改了多次state,你会发现页面也仅会渲染一次。

而假定我们是直接操作dom,那还有哪门子的异步和渲染等待,当你append完一个子节点,页面早渲染完了。所以虚拟dom的对比提前,以及setState的异步处理,本质上也是在像尽可能少的操作dom靠近。

跨平台能力#

同理,之所以加入虚拟dom这个中间层,除了解决部分性能问题,加强兼容性之外,还有个目的是将dom的更新抽离成一个公共层,别忘了react除了做页面引用外,react还支持使用React Native做原生app。所以针对同一套虚拟dom体系,react只是在最终将体现在了不同的平台上而已。

虚拟DOM比原生快吗?#

那么问题来了,聊了这么久的虚拟dom,虚拟dom性能真的比操作原生dom要更快吗?很遗憾的说,并不是,或者说不应该这样粗暴的去对比。

我们在前面虽然对比了虚拟dom属性以及原生dom的属性量级,但事实上我们并不会对原生dom属性进行递归对比,而是直接操作dom。而且站在react角度,即便经历了diff算法以及一系列的优化,react到头来还是要操作原生dom,只是对于研发来讲不用关注这一步罢了。

所以我们可以想象一下,现在要替换p标签的内容,用原生就是直接修改innerHTML属性,对于react而言它需要先生成虚拟dom,然后新旧diff找出变化的部分,最后才修改原生dom,单论这个例子,一定是原生快。

但我们既然说虚拟dom,就一定得结合react的使命来解释,虚拟dom的核心目的是模拟了原生dom大部分特性,让研发高效无痛写html的同时,还达到了单点刷新而不是整个替换(前面表单替换的例子),最重要的,它也将研发从繁琐的dom操作中解放了出来。

总结来说,单论修改一个dom节点的性能,不管react还是vue亦或是angular,一定是原生最快,但虚拟dom有原生dom比不了的价值,起码react这些框架能让研发更专注业务以及数据处理,而不是陷入繁琐的dom增删改查中。

虚拟DOM的实现原理#

我们先解释虚拟dom的创建过程,要聊这个那必然逃不开React.createElement方法,github 源码,具体代码如下(我删除了dev环境特有的逻辑):

/**
 * 创建并返回给定类型的新ReactElement。
 * See https://reactjs.org/docs/react-api.html#createelement
 */
function createElement(type, config, children) {
  let propName;

  // 创建一个全新的props对象
  const props = {};

  let key = null;
  let ref = null;
  let self = null;
  let source = null;

  // 有传递自定义属性进来吗?有的话就尝试获取ref与key
  if (config != null) {
    if (hasValidRef(config)) {
      ref = config.ref;
    }
    if (hasValidKey(config)) {
      key = '' + config.key;
    }

    // 保存self和source
    self = config.__self === undefined ? null : config.__self;
    source = config.__source === undefined ? null : config.__source;

    // 剩下的属性都添加到一个新的props属性中。注意是config自身的属性
    for (propName in config) {
      if (
        hasOwnProperty.call(config, propName) &&
        !RESERVED_PROPS.hasOwnProperty(propName)
      ) {
        props[propName] = config[propName];
      }
    }
  }

  // 处理子元素,默认参数第二个之后都是子元素
  const childrenLength = arguments.length - 2;
  // 如果子元素只有一个,直接赋值
  if (childrenLength === 1) {
    props.children = children;
  } else if (childrenLength > 1) {
    // 如果是多个,转成数组再赋予给props
    const childArray = Array(childrenLength);
    for (let i = 0; i < childrenLength; i++) {
      childArray[i] = arguments[i + 2];
    }
    props.children = childArray;
  }

  // 处理默认props,不一定有,有才会遍历赋值
  if (type && type.defaultProps) {
    const defaultProps = type.defaultProps;
    for (propName in defaultProps) {
      // 默认值只处理值不是undefined的属性
      if (props[propName] === undefined) {
        props[propName] = defaultProps[propName];
      }
    }
  }

  // 调用真正的React元素创建方法
  return ReactElement(type, key, ref, self, source, ReactCurrentOwner.current, props);
}

代码看着好像有点多,但其实一共就只做了两件事:

  • 根据createElement所接收参数config做数据加工与赋值。
  • 加工完数据后调用真正的虚拟dom创建API ReactElement

而数据加工部分可分为三步,大家可以对应上面代码理解,其实注释写的也很清晰了:

  • 第一步,判断config有没有传,不为null就做处理,步骤分为
    • 判断refkey__self__source 这些是否存在或者有效,满足条件就分别赋值给前面新建的变量。
    • 遍历config,并将config自身的属性依次赋值给前面新建props。
  • 第二步,处理子元素。默认从第三个参数开始都是子元素。
    • 如果子元素只有一个,直接赋值给props.children
    • 如果子元素有多个,转成数组后再赋值给props.children
  • 第三步,处理默认属性defaultProps,一个纯粹的标签也可以理解成一个最最最基础的组件,而组件支持 defaultProps,所以这一步判断有没有defaultProps,如果有同样遍历,并将值不为undefined的部分都拷贝到props对象上。

至此,第一大步全部做完,紧接着调用ReactElement,我们接着看这一块的源码,同样我删掉dev部分的逻辑,然后你会发现就这么一点代码,github 源码

const ReactElement = function (type, key, ref, self, source, owner, props) {
  const element = {
    // 这个标签允许我们将其标识为唯一的React Element
    $$typeof: REACT_ELEMENT_TYPE,
    // 元素的内置属性
    type: type,
    key: key,
    ref: ref,
    props: props,
    // 记录负责创建此元素的组件。
    _owner: owner,
  };
  return element;
};

这个方法啥也没干,单纯接受我们在上个方法加工后的数据,并将其组装成了一个element对象,也就是我们前文所说的虚拟dom

不过针对这个虚拟dom,我们可以把$$typeof: REACT_ELEMENT_TYPE拧出来单独讲讲。我们可以看看它的具体实现:

// The Symbol used to tag the ReactElement-like types.
export const REACT_ELEMENT_TYPE = Symbol.for('react.element');

大家在查看虚拟dom时应该都有发现它的$$typeof定义为Symbol(react.element),而Symbol一大特性就是标识唯一性,即便两个看着一模一样的Symbol,它们也不会相等。而react之所以这样做,本质也是为了防止xss攻击,防止外部伪造虚拟dom结构。

其次,如果大家有在开发中留意,虚拟dom的不允许修改,哪怕你为这个对象新增属性也不可以,这是因为在ReactElement方法省略的dev代码中,react使用Object.freeze冻结了虚拟dom使其无法修改。但实际上我们确实有为虚拟dom添加属性的场景,解决这个问题时我们可以借用顶层React.cloneElement()方法,它会以你传递的虚拟dom为模板克隆并返回一个新的虚拟dom对象,同时这个过程中你可以为其添加新的config,具体用法可见React.cloneElement

其次,如果当前环境不支持Symbol时,REACT_ELEMENT_TYPE的值为0xeac7

var REACT_ELEMENT_TYPE = 0xeac7;

为什么是0xeac7呢?官方答复是,因为它看起来像React…好了,那么到这里,关于如何生成虚拟dom的源码分析结束。

react中虚拟dom是如何转变成真实dom的#

终于,我们来到了本文的最后一个问题,要想搞清这个问题,我们的关注点自然是ReactDOM.render方法了,这个部分比较麻烦,大家跟着我的思路走就行。(有兴趣可以直接把react脚手架项目跑起来,写一个最基本的组件,然后去react-dom.development.js文件断点也可以)。

// 我为了方便断点,定义了一个class组件P
class P extends Component {
  state = {
    name: 1,
  };
  handleClick = () => {};
  render() {
    return <span onClick={this.handleClick}>111</span>;
  }
}
ReactDOM.render(<P />, document.getElementById("root"));

首先我们来到render方法,代码如下:

function render(element, container, callback) {
	// 我删除了对于container是否合法的效验逻辑
  return legacyRenderSubtreeIntoContainer(null, element, container, false, callback);
}

render做的事情其实很简单,验证container是否合法,如果不是一个有效的dom就会抛错,核心逻辑看样子都在legacyRenderSubtreeIntoContainer中,根据命名可以推测是将组件子树都渲染到容器元素中。

// 同样,我删除了部分对主逻辑理解没啥影响的代码
function legacyRenderSubtreeIntoContainer(parentComponent, children, container, forceHydrate, callback) {
  var root = container._reactRootContainer;
  var fiberRoot;
	// 有fiber的root节点吗?没有就新建
  if (!root) {
    root = container._reactRootContainer = legacyCreateRootFromDOMContainer(container, forceHydrate);
    fiberRoot = root._internalRoot;
    unbatchedUpdates(function () {
      // 核心关注这里
      updateContainer(children, fiberRoot, parentComponent, callback);
    });
  } else {
    fiberRoot = root._internalRoot;

    updateContainer(children, fiberRoot, parentComponent, callback);
  }
  return getPublicRootInstance(fiberRoot);
}

因为 react 16引入了fiber的概念,所以后续其实很多代码就是在创建fiber节点,legacyRenderSubtreeIntoContainer一样,它一开始判断有没有root节点(一个fiber对象),很显然我们初次渲染走了新建逻辑,但不管是不是新建,最终都会调用updateContainer方法。但此方法没有太多我们需要关注的逻辑,一直往下走,我们会遇到一个很重要的beginWork(开始干正事)方法,代码如下:

function beginWork(current, workInProgress, renderLanes) {
	// 删除部分无影响的代码
  workInProgress.lanes = NoLanes;

  switch (workInProgress.tag) {
    // 模糊定义的组件
    case IndeterminateComponent:
      {
        return mountIndeterminateComponent(current, workInProgress, workInProgress.type, renderLanes);
      }
		// 函数组件
    case FunctionComponent:
      {
        var _Component = workInProgress.type;
        var unresolvedProps = workInProgress.pendingProps;
        var resolvedProps = workInProgress.elementType === _Component ? unresolvedProps : resolveDefaultProps(_Component, unresolvedProps);
        return updateFunctionComponent(current, workInProgress, _Component, resolvedProps, renderLanes);
      }
		// class组件
    case ClassComponent:
      {
        var _Component2 = workInProgress.type;
        var _unresolvedProps = workInProgress.pendingProps;

        var _resolvedProps = workInProgress.elementType === _Component2 ? _unresolvedProps : resolveDefaultProps(_Component2, _unresolvedProps);

        return updateClassComponent(current, workInProgress, _Component2, _resolvedProps, renderLanes);
      }
    case HostRoot:
      return updateHostRoot(current, workInProgress, renderLanes);
  }
}

beginWork方法做了很重要的一件事,那就是根据你render接收的组件类型,来执行不同的组件更新的方法,毕竟我们可能给render传递一个普通标签,也可能是函数组件或者Class组件,亦或是hooksmemo组件等等。

比如我此时定义的Pclass组件,于是走了ClassComponent路线,紧接着调用updateClassComponent更新组件。

function updateClassComponent(current, workInProgress, Component, nextProps, renderLanes) {
  // 删除了添加context部分的逻辑
	// 获取组件实例
  var instance = workInProgress.stateNode;
  var shouldUpdate;
	// 如果没有实例,那就得创建实例
  if (instance === null) {
    if (current !== null) {
      current.alternate = null;
      workInProgress.alternate = null;

      workInProgress.flags |= Placement;
    }
    // 全体目光向我看齐,看我看我,这里new Class创建组件实例
    constructClassInstance(workInProgress, Component, nextProps);
    // 挂载组件实例
    mountClassInstance(workInProgress, Component, nextProps, renderLanes);
    shouldUpdate = true;
  } else if (current === null) {
    shouldUpdate = resumeMountClassInstance(workInProgress, Component, nextProps, renderLanes);
  } else {
    shouldUpdate = updateClassInstance(current, workInProgress, Component, nextProps, renderLanes);
  }
  // Class组件的收尾工作
  var nextUnitOfWork = finishClassComponent(current, workInProgress, Component, shouldUpdate, hasContext, renderLanes);
}

在看这段代码前,我们自己也可以提前想象下这个过程,比如Class组件你一定是得new才能得到一个实例,只有拿到实例后才能调用其render方法,拿到其虚拟dom结构,之后再根据结构创建真实dom,添加属性,最后加入到页面。

所以在updateClassComponent中,首先会对组件做context相关的处理,这部分代码我删掉了,其余,判断当前组件是否有实例,如果有就去更新实例,如果没有那就创建实例,所以我们聚焦到constructClassInstancemountClassInstancefinishClassComponent三个方法,看命名就能猜到,前者一定是创造实例,后者是应该是挂载实例前的一些处理,先看第一个方法:

function constructClassInstance(workInProgress, ctor, props) {
	// 删除了对组件context进一步加工的逻辑
	// ....
  
  // 看我看我,我宣布个事,这里创建了组件实例
  // 验证了前面的推测,这里new了我们的组件,并且传递了当前组件的props以及前面代码加工的context
  var instance = new ctor(props, context);
  var state = workInProgress.memoizedState = instance.state !== null && instance.state !== undefined ? instance.state : null;
  adoptClassInstance(workInProgress, instance);

  // 删除了对于组件生命周期钩子函数的处理,比如很多即将被废弃的钩子,在这里都会被添加 UNSAFE_ 前缀
  //.....

  return instance;
}

alt text

constructClassInstance正如我们推测的一样,这里通过new ctor(props, context)创建了组件实例,除此之外,react后续版本已将部分声明周期钩子标记为不安全,对于钩子命名的加工也在此方法中。

紧接着,我们得到了一个组件实例,接着看mountClassInstance方法:

function mountClassInstance(workInProgress, ctor, newProps, renderLanes) {
	// 此方法主要是对constructClassInstance创建的实例进行数据组装,为其赋予props,state等一系列属性
  var instance = workInProgress.stateNode;
  instance.props = newProps;
  instance.state = workInProgress.memoizedState;
  instance.refs = emptyRefsObject;
  initializeUpdateQueue(workInProgress);
  
  // 删除了部分特殊情况下,对于instance的特殊处理逻辑
}

虽然命名是挂载,但其实离真正的挂载还远得很,本方法其实是为constructClassInstance创建的组件实例做数据加工,为其赋予props state等一系列属性。

在上文代码中,其实还有个finishClassComponent方法,此方法在组件自身都准备完善后调用,我们期待已久的render方法处理就在里面

function finishClassComponent(current, workInProgress, Component, shouldUpdate, hasContext, renderLanes) {
  var instance = workInProgress.stateNode;
  ReactCurrentOwner$1.current = workInProgress;
  var nextChildren;
  if (didCaptureError && typeof Component.getDerivedStateFromError !== 'function') {
			// ...
  } else {
    {
      setIsRendering(true);
      // 关注点在这,通过调用组件实例的render方法,得到内部的元素
      nextChildren = instance.render();
      setIsRendering(false);
    }
  } 
  workInProgress.memoizedState = instance.state;
  return workInProgress.child;
}

在此方法内部,我们通过获取之前创建的组件实例,然后调用了它的render方法,于是成功执行了我们组件Prender方法:

render() {
  return <span onClick={this.handleClick}>111</span>;
}

需要注意的是,render返回的其实是一个jsx的模板语法,在真正return之前,react还会再次调用生成虚拟dom的逻辑也就是ReactElement方法,将span这一段转变成虚拟dom

而对于react而言,很明显虚拟domspan也可能理解成一个最最最基础的组件,所以它会重走beginWork这条路线,只是到了组件分类时,这一次会走HostComponent路线,然后触发updateHostComponent方法,我们直接跳过相同的流程,之后就会走到completeWork方法。

到这里,我们可以理解例子P组件虚拟dom都准备完毕,现在要做的是对于虚拟dom这种最基础的组件做转成真实dom的操作,见如下代码:

function completeWork(current, workInProgress, renderLanes) {
  var newProps = workInProgress.pendingProps;
	// 根据tag类型做不同的处理
  switch (workInProgress.tag) {
    // 标签类的基础组件走这条路
    case HostComponent:
      {
        popHostContext(workInProgress);
        var rootContainerInstance = getRootHostContainer();
        var type = workInProgress.type;

        if (current !== null && workInProgress.stateNode != null) {
          // ...
        } else {
          // ...
          } else {
            // 关注点1:创建虚拟dom的实例
            var instance = createInstance(type, newProps, rootContainerInstance, currentHostContext, workInProgress);
            appendAllChildren(instance, workInProgress, false, false);
            workInProgress.stateNode = instance; // Certain renderers require commit-time effects for initial mount.
            // 关注点2:初始化实例的子元素
            if (finalizeInitialChildren(instance, type, newProps, rootContainerInstance)) {
              markUpdate(workInProgress);
            }
          }
        }
      }
  }
}

可以猜到,虽然同样还是调用createInstance生成实例,但目前咱们的组件是个虚拟dom对象啊,一个普通的span标签,所以接下来一定会创建最基本的span节点,代码如下:

function createInstance(type, props, rootContainerInstance, hostContext, internalInstanceHandle) {
	// 根据span创建节点,调用createElement方法
  var domElement = createElement(type, props, rootContainerInstance, parentNamespace);
  precacheFiberNode(internalInstanceHandle, domElement);
  // 将虚拟dom span的属性添加到span节点上
  updateFiberProps(domElement, props);
  return domElement;
}

// createElement具体实现
function createElement(type, props, rootContainerElement, parentNamespace) {
  var isCustomComponentTag; 
  var ownerDocument = getOwnerDocumentFromRootContainer(rootContainerElement);
  var domElement;
  var namespaceURI = parentNamespace;

  if (namespaceURI === HTML_NAMESPACE$1) {
    if (type === 'script') {
      var div = ownerDocument.createElement('div');
      div.innerHTML = '<script><' + '/script>';
      var firstChild = div.firstChild;
      domElement = div.removeChild(firstChild);
    } else if (typeof props.is === 'string') {
      domElement = ownerDocument.createElement(type, {
        is: props.is
      });
    } else {
      // 在这里,真实dom span节点创建完毕
      domElement = ownerDocument.createElement(type); 
      if (type === 'select') {
        var node = domElement;

        if (props.multiple) {
          node.multiple = true;
        } else if (props.size) {
          node.size = props.size;
        }
      }
    }
  } else {
    domElement = ownerDocument.createElementNS(namespaceURI, type);
  }
  return domElement;
}

createElement方法中,react会根据你的标签类型来决定怎么创建dom,比如如果你是script,那就创建一个div用于包裹一个script标签。而我们的span很显然就是通过ownerDocument.createElement(type)创建,如下图:

alt text

创建完成后,此时的span节点还是一个啥都没有的空span,所以通过updateFiberProps将还未加工的span的子节点以及其它属性强行赋予给span,在之后会进一步加工,之后返回我们的span

alt text

然后来到finalizeInitialChildren方法,这里开始对创建的span节点的子元素进一步加工,其实就是文本111

function finalizeInitialChildren(domElement, type, props, rootContainerInstance, hostContext) {
  // 实际触发的其实是这个
  setInitialProperties(domElement, type, props, rootContainerInstance);
  return shouldAutoFocusHostComponent(type, props);
}

// 跳过对于部分,接着看 setInitialDOMProperties
function setInitialProperties(domElement, tag, rawProps, rootContainerElement) {
  var props;

  switch (tag) {
		// ...
    default:
      props = rawProps;
  }
	// 验证props合法性
  assertValidProps(tag, props);
  // 正式设置props
  setInitialDOMProperties(tag, domElement, rootContainerElement, props, isCustomComponentTag);
  }
}

又是一系列的跳转,为dom设置属性的逻辑现在又聚焦在了setInitialDOMProperties中,我们直接看代码:

function setInitialDOMProperties(tag, domElement, rootContainerElement, nextProps, isCustomComponentTag) {
  for (var propKey in nextProps) {
    // 遍历所有属性,只要这个属性不是原型属性,那就开始正式处理
    if (!nextProps.hasOwnProperty(propKey)) {
      continue;
    }

    var nextProp = nextProps[propKey];
		// 如果属性是样式,那就通过setValueForStyles为dom设置样式
    if (propKey === STYLE) {
      {
        if (nextProp) {
          Object.freeze(nextProp);
        }
      }
      setValueForStyles(domElement, nextProp);
    } else if (propKey === DANGEROUSLY_SET_INNER_HTML) {

    } else if (propKey === CHILDREN) {
      if (typeof nextProp === 'string') {
        var canSetTextContent = tag !== 'textarea' || nextProp !== '';
        if (canSetTextContent) {
          // 设置文本属性
          setTextContent(domElement, nextProp);
        }
      } else if (typeof nextProp === 'number') {
        setTextContent(domElement, '' + nextProp);
      }
    } else if (propKey === SUPPRESS_CONTENT_EDITABLE_WARNING || propKey === SUPPRESS_HYDRATION_WARNING) ; else if (propKey === AUTOFOCUS) ; else if (registrationNameDependencies.hasOwnProperty(propKey)) {
      if (nextProp != null) {
        if ( typeof nextProp !== 'function') {
          warnForInvalidEventListener(propKey, nextProp);
        }

        if (propKey === 'onScroll') {
          listenToNonDelegatedEvent('scroll', domElement);
        }
      }
    } else if (nextProp != null) {
      setValueForProperty(domElement, propKey, nextProp, isCustomComponentTag);
    }
  }
}

这段代码看着有点长,其实做的事情非常的清晰,遍历span目前的props,如果propskeystyle,那就通过setValueForStyles为当前真实dom一一设置样式,如果keychildren,很明显我们虚拟dom111是放在children属性中的,外加上如果这个children类型还是string,那就通过setTextContentdom添加文本信息。

这里给大家展示为真实dom设置style以及设置innerHTML的源码:

// 为真实dom添加样式的逻辑
function setValueForStyles(node, styles) {
  // 获取真是dom的style对象,后面就遍历styles对象,依次覆盖
  var style = node.style;
  for (var styleName in styles) {
    if (!styles.hasOwnProperty(styleName)) {
      continue;
    }
    var isCustomProperty = styleName.indexOf('--') === 0;
    {
      if (!isCustomProperty) {
        warnValidStyle$1(styleName, styles[styleName]);
      }
    }
    // 获取样式的值
    var styleValue = dangerousStyleValue(styleName, styles[styleName], isCustomProperty);
    if (styleName === 'float') {
      styleName = 'cssFloat';
    }
		// 最终覆盖node节点原本的值
    if (isCustomProperty) {
      style.setProperty(styleName, styleValue);
    } else {
      style[styleName] = styleValue;
    }
  }
}

// 为真实dom添加innerHTML的逻辑
var setTextContent = function (node, text) {
  if (text) {
    var firstChild = node.firstChild;

    if (firstChild && firstChild === node.lastChild && firstChild.nodeType === TEXT_NODE) {
      firstChild.nodeValue = text;
      return;
    }
  }
  // 为真实dom设置文本信息
  node.textContent = text;
};

那么到这里,其实我们的组件P已经准备完毕,包括真实dom也都创建好了,就等插入到页面了,那这些dom什么时候插入到页面的呢?后面我又跟了下调用栈,根据我页面啥时候绘制的111一步步断点缩小范围,最终定位到了insertOrAppendPlacementNodeIntoContainer方法,直译过来就是将节点插入或者追加到容器节点中:

function insertOrAppendPlacementNodeIntoContainer(node, before, parent) {
  var tag = node.tag;
  var isHost = tag === HostComponent || tag === HostText;
  if (isHost || enableFundamentalAPI ) {
    var stateNode = isHost ? node.stateNode : node.stateNode.instance;
    if (before) {
      // 在容器节点前插入
      insertInContainerBefore(parent, stateNode, before);
    } else {
      // 在容器节点后追加
      appendChildToContainer(parent, stateNode);
    }
  } else if (tag === HostPortal) ; else {
    var child = node.child;
		// 只要子节点不为null,继续递归调用
    if (child !== null) {
      insertOrAppendPlacementNodeIntoContainer(child, before, parent);
      var sibling = child.sibling;
			// 只要兄弟节点不为null,继续递归调用
      while (sibling !== null) {
        insertOrAppendPlacementNodeIntoContainer(sibling, before, parent);
        sibling = sibling.sibling;
      }
    }
  }
}

insertOrAppendPlacementNodeIntoContainer中,react会根据当前节点是否有子节点,或者兄弟节点进行递归调用,然后分别根据insertInContainerBefore与appendChildToContainer做最终的节点插入页面操作,这里我们看看appendChildToContainer的实现:

function appendChildToContainer(container, child) {
  var parentNode;

  if (container.nodeType === COMMENT_NODE) {
    parentNode = container.parentNode;
    parentNode.insertBefore(child, container);
  } else {
    parentNode = container;
    // 将子节点插入到父节点中
    parentNode.appendChild(child);
  var reactRootContainer = container._reactRootContainer;

  if ((reactRootContainer === null || reactRootContainer === undefined) && parentNode.onclick === null) {
    // TODO: This cast may not be sound for SVG, MathML or custom elements.
    trapClickOnNonInteractiveElement(parentNode);
  }
}

由于我们定义的组件非常简单,P组件只有一个span标签,所以这里的parentNode其实就是容器根节点,当执行完parentNode.appendChild(child),可以看到页面就出现了111了。

alt text

至此,组件的虚拟dom生成,真实dom的创建,加工以及渲染全部执行完毕。

可能大家对于这个过程还是比较迷糊,我大致画个图描述下这个过程:

alt text

react是怎么知道谁是谁的子节点,谁是谁的父节点,这个就需要了解fiber对象了,其实我们在创建完真实dom后,它还是会被加工成一个fiber节点,而此节点中通过child可以访问到自己的子节点,通过sibling获取自己的兄弟节点,最后通过return属性获取自己的父节点,通过这些属性为构建dom树提供了支撑,当然fiber我会另开一篇文章来解释,这里不急。

前文,我们验证了Class组件是通过new得到组件实例,然后开展后续操作,那对于函数组件,是不是直接调用拿到子组件呢?这里我简单跟了下源码,发现了如下代码:

alt text

function renderWithHooks(current, workInProgress, Component, props, secondArg, nextRenderLanes) {
  // ....
  var children = Component(props, secondArg);
}

可以发现确实如此,拿到子节点,然后后续还是跟之前一样,将虚拟dom转变成真实dom,以及后续的一系列操作。

不过有点意外的是,我以为我定义的函数组件在判断组件类型时,会走case FunctionComponent分支路线,结果它走的case IndeterminateComponent,也就是模糊定义的组件,不过影响不大,还是符合我们的推测。

好了,到这里,我已经写了一万字,关于虚拟dom如何转变成真实dom也介绍完毕了。

React 虚拟 dom
https://alexdev.top/posts/react-virtual-dom/post/
作者
凡百一新
发布于
2022-04-19
许可协议
CC BY-NC-SA 4.0