通过本文你将学习以下几点:
- 虚拟
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
所创建的对象,这个过程可以理解为:
为方便理解,我们可以将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>
);
}
}
那么到这里,我们搞清楚了虚拟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
表单的展示功能,点击不同用户,表单就会展示用户名,年龄等一系列信息:
用js
写怎么做?还是一样的,点击不同用户,肯定会得到一个用户信息对象,我们根据这个对象动态生成多个信息展示的input
等相关dom
,然后塞入到form
表单中,所以每次点击,这个form
其实都等同于完全重建了。
假定现在我们不希望完整重建这个结构,而是希望做前后dom
节点对比,比如input
的value
前后不一样,某个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
它提供更高效的对比可行性。
更佳的兼容性
我们在上文提到,react
与babel
将jsx
转成了js
对象(虚拟dom
),之后又通过render
生成dom
,那为啥还要转成js
而不是直接生成dom
呢,因为在这个中间react
还需要做diff
对比,兼容处理,以及跨平台的考虑,我们先说兼容处理。
准确来说,虚拟dom
只是react
中的一部分,要真正体现虚拟dom
的价值,肯定得结合react
中的其它设计来一起讲,其中一点就是结合合成事件所体现的强大的兼容性。
之前 jq 在操作dom
的便捷,以及各类api
兼容性上的贡献,而react
中使用了虚拟dom
也做了大量的兼容。
打个比方,原生的input
有change
事件,普通的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
就做处理,步骤分为- 判断
ref
、key
,__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
组件,亦或是hooks
的memo
组件等等。
比如我此时定义的P
是class
组件,于是走了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
相关的处理,这部分代码我删掉了,其余,判断当前组件是否有实例,如果有就去更新实例,如果没有那就创建实例,所以我们聚焦到constructClassInstance
与mountClassInstance
、finishClassComponent
三个方法,看命名就能猜到,前者一定是创造实例,后者是应该是挂载实例前的一些处理,先看第一个方法:
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;
}
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
方法,于是成功执行了我们组件P
的render
方法:
render() {
return <span onClick={this.handleClick}>111</span>;
}
需要注意的是,render
返回的其实是一个jsx
的模板语法,在真正return
之前,react
还会再次调用生成虚拟dom
的逻辑也就是ReactElement
方法,将span
这一段转变成虚拟dom
。
而对于react
而言,很明显虚拟dom
的span
也可能理解成一个最最最基础的组件,所以它会重走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)
创建,如下图:
创建完成后,此时的span
节点还是一个啥都没有的空span
,所以通过updateFiberProps
将还未加工的span
的子节点以及其它属性强行赋予给span
,在之后会进一步加工,之后返回我们的span
:
然后来到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
,如果props
的key
是style
,那就通过setValueForStyles
为当前真实dom
一一设置样式,如果key
是children
,很明显我们虚拟dom
的111
是放在children
属性中的,外加上如果这个children
类型还是string
,那就通过setTextContent
为dom
添加文本信息。
这里给大家展示为真实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
了。
至此,组件的虚拟dom
生成,真实dom
的创建,加工以及渲染全部执行完毕。
可能大家对于这个过程还是比较迷糊,我大致画个图描述下这个过程:
而react
是怎么知道谁是谁的子节点,谁是谁的父节点,这个就需要了解fiber
对象了,其实我们在创建完真实dom
后,它还是会被加工成一个fiber
节点,而此节点中通过child
可以访问到自己的子节点,通过sibling
获取自己的兄弟节点,最后通过return
属性获取自己的父节点,通过这些属性为构建dom
树提供了支撑,当然fiber
我会另开一篇文章来解释,这里不急。
前文,我们验证了Class
组件是通过new
得到组件实例,然后开展后续操作,那对于函数组件,是不是直接调用拿到子组件呢?这里我简单跟了下源码,发现了如下代码:
function renderWithHooks(current, workInProgress, Component, props, secondArg, nextRenderLanes) {
// ....
var children = Component(props, secondArg);
}
可以发现确实如此,拿到子节点,然后后续还是跟之前一样,将虚拟dom
转变成真实dom
,以及后续的一系列操作。
不过有点意外的是,我以为我定义的函数组件在判断组件类型时,会走case FunctionComponent
分支路线,结果它走的case IndeterminateComponent
,也就是模糊定义的组件,不过影响不大,还是符合我们的推测。
好了,到这里,我已经写了一万字,关于虚拟dom
如何转变成真实dom
也介绍完毕了。