? 优质资源分享 ?
| 学习路线指引(点击解锁) | 知识定位 | 人群定位 |
|---|---|---|
| ? Python实战微信订餐小程序 ? | 进阶级 | 本课程是python flask+微信小程序的完美结合,从项目搭建到腾讯云部署上线,打造一个全栈订餐系统。 |
| ?Python量化交易实战? | 入门级 | 手把手带你打造一个易扩展、更安全、效率更高的量化交易系统 |
![[react] 什么是虚拟dom?虚拟dom比操作原生dom要快吗?虚拟dom是如何转变成真实dom并渲染到页面的? 61f73173044dabb4db6b624430c05bc8 - [react] 什么是虚拟dom?虚拟dom比操作原生dom要快吗?虚拟dom是如何转变成真实dom并渲染到页面的?](https://img-blog.csdnimg.cn/img_convert/61f73173044dabb4db6b624430c05bc8.png)
壹 ❀ 引
虚拟DOM(Virtual DOM)在前端领域也算是老生常谈的话题了,若你了解过vue或者react一定避不开这个话题,因此虚拟DOM也算是面试中常问的一个点,那么通过本文,你将了解到如下几点:
- 虚拟
DOM究竟是什么? - 虚拟
DOM的优势是什么?解决了什么问题? - 虚拟
DOM的性能比操作原生DOM要快吗? react中的虚拟DOM是如何生成的?react是如何将虚拟DOM转变成真实dom的?
阅读前建议与提醒:
- 本篇文章可能比较长,建议挑一个空闲的时间段阅读,还请保持耐心,我将以通俗易懂的口吻带你了解这些问题。
- 本文源码分析部分
react版本为17.0.2,无须担心低版本源码分析对你之后面试帮助不大的问题。 - 如果可以,泡上一杯性温的茶或者咖啡,保持一个舒服的姿势会让你阅读更加愉快。
那么本文开始。
贰 ❀ 在虚拟dom之前
在聊虚拟DOM之前,我还是想先聊聊在没有虚拟DOM概念的时候,我们是如何更新页面的,所以在这里我将先引出前端框架(库)的发展史,通过这个变迁过程也便于大家理解虚拟dom的出现到底解决了什么问题。
贰 ❀ 壹 石器时代jqery
其实在15年以及更早之前,前端面试涉及到性能优化问题,往往都会提到尽可能少的操作DOM这一点。为什么呢?因为在原生JS的年代,前端项目文件都明确分为html、js与css三种,我们在js中获取DOM,并为其绑定事件,通过事件监听感知用户在UI层的操作,并随之更新DOM,从而达到页面交互的目的:
![[react] 什么是虚拟dom?虚拟dom比操作原生dom要快吗?虚拟dom是如何转变成真实dom并渲染到页面的? a81499ae99eeaa137b1af80948bc6e27 - [react] 什么是虚拟dom?虚拟dom比操作原生dom要快吗?虚拟dom是如何转变成真实dom并渲染到页面的?](https://img-blog.csdnimg.cn/img_convert/a81499ae99eeaa137b1af80948bc6e27.png)
而在后面,jqery的出现极大简化了开发者操作DOM的成本,抹平了当时不同浏览器操作DOM的API差异,为当时苦于ie以及不同浏览器自研API的开发者解决了不少兼容性问题,当然JQ也并未改变开发者在JS层直接操作DOM这一现状。
那么我们为什么说要尽可能少的操作DOM呢,这里就涉及到重绘与回流两个概念,比如单纯修改颜色就会引发重绘,删除或新增一个DOM节点就会引发回流和重绘,用户虽然无法感知这个过程,但对于浏览器而言也存在消耗性能。所以针对于回流,在此之后又提出了DocumentFragment文档对象以优化多次操作DOM的方案。简单理解就是,假如我要依次替换五个li节点,那么我们可以创建一个DocumentFragment对象保存这五个节点,然后一次性替换。
关于节流与重绘,若有兴趣可读读博主页面优化,谈谈重绘(repaint)和回流(reflow)一文。
关于DocumentFragment可读读博主页面优化,DocumentFragment对象详解一文。
这些都是时代的眼泪,现在应该很少会有人提及,这里就不再赘述了。
贰 ❀ 贰 青铜时代angularjs
在JQ之后,angularjs(这里指angularjs1而非angular)横空出世,一招双向绑定在当时更是惊为天人,除此之外,angularjs的模板语法也格外惊艳,我们将所有与数据挂钩的节点通过{{}}包裹(vue在早期设计上大量借鉴了angularjs),比如:
<span>{{vm.name}}span>
之后 view 视图层就自动与 Model 数据层进行挂钩(MVC那一套),只要 Model 层数据发生变化,view 层便自动更新。angularjs 的这种做法,彻底将开发者从操作 DOM 上解放了出来(为jq没落埋下伏笔),自此之后开发者只用专注 Model 层的数据加工以及业务处理,至于页面如何渲染全权交给 angularjs 底层处理即好了。
但需要注意的是,angularjs 在当时并没有虚拟dom的概念,那它是怎么做感知数据层变化以及更新视图层的呢?angularjs有一套脏检测机制$digest,html中凡是使用了模板语法{{}}或者ng-bind指令的部分,都会被加入到脏检测的warchers列表中,它是一个数组,之后只要用户通过ng-click(与传统click不同,内置绑定了触发脏检测的机制)等方法改变了Model的数据,angularjs就会从顶层rootScope向下递归,依次访问每个子scope中的warchers列表,并对其中监听的部分做新旧对比,如果不同则进行数据替换,以及DOM层的更新。
但是你要想想,一个应用那么大的结构,只要某一个数据变化了就得从顶层向下对比N个子 scope 中 warchers 下的所有监听对象,全量对比的性能有多差可想而知,angularjs 自身也意识到了这点,所以之后直接放弃了 angularjs 的维护转而新开了 angular 项目。
对于 angularjs 脏检测感兴趣可以读读博主深入了解angularjs中的??????与apply方法,从区别聊到使用优化一文,同样是时代的眼泪了。
贰 ❀ 叁 铁器时代react与vue
如果从 angularjs 转到 vue ,你会发现早期vue的模板语法、指令,双向绑定等很多灵感其实都借鉴了angularjs,但在更新机制上,vue 并不是一个改动牵动全身,而是组件均独立更新。react 与 vue 一样相对 angularjs 也是局部更新,只是 react 中的局部是以当前组件为根以及之下的所有子组件。
打个比方,如果组件 A 状态发生变化,那么 A 的所有子组件默认都会触发更新,即使子组件的props未发生改变,所以对于react我们需要使用 PureComponent、shouldComponentUpdate 以及 memo 来避免这种场景下的多余渲染。而在更新体系中,react 与 vue 都引入了虚拟 DOM 的概念,当然这也是本文需要探讨的重点。
我们先总结下上述的观点:
js 和 jq:研发在专注业务的同时,还要亲自操作 dom。
angularjs版本1:将研发从操作 dom 中解脱了出来,更新 dom 交由 angularjs 底层实现,这一套机制由脏检测机制所支撑。
react/vue:同样由底层更新 dom,只是在此之前多了虚拟dom的对比,先对比再更新,以此达到最小更新目的。
所以相对传统更新 dom 的策略,虚拟dom的更新如下:
![[react] 什么是虚拟dom?虚拟dom比操作原生dom要快吗?虚拟dom是如何转变成真实dom并渲染到页面的? dc427954e7b7d63edc0cf9aa4da8e5b4 - [react] 什么是虚拟dom?虚拟dom比操作原生dom要快吗?虚拟dom是如何转变成真实dom并渲染到页面的?](https://img-blog.csdnimg.cn/img_convert/dc427954e7b7d63edc0cf9aa4da8e5b4.png)
到这里,我们站在宏观的角度解释了前端框架的变迁,以及有虚拟dom前后我们如何更新dom,也许到这里你的脑中隐约对于虚拟dom有了一丝感悟,但又不是很清晰,虚拟dom到底解决了什么问题,别着急,接下来才是虚拟dom的正餐,我们接着聊。
叁 ❀ 什么是虚拟DOM?
本文将默认你有 react 或者 vue 的开发经历,当然本文出发点还是以react为主。
熟悉 react 的同学对于 React.createElement 方法一定不会陌生,它用于创建reactNode,语法如下:
/*
* component 组件名,一个标签也可以理解成一个最基础的组件
* props 当前组件的属性,比如class,或者其它属性
* children 组件的子组件,就像标签套标签
*/
React.createElement(component, props, ...children)
比如我们定一个最简单的html片段:
<span className='span'>hello echospan>
用React.createElement表示如下:
React.createElement('div', {className:'span'}, 'hello echo');
这样看好像也没什么大问题,但是假定我们dom存在嵌套关系:
<span className='span'>
<span>
hello echo
span>
span>
用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] 什么是虚拟dom?虚拟dom比操作原生dom要快吗?虚拟dom是如何转变成真实dom并渲染到页面的? 644fd4e16009fc91e3e1f578b67fda25 - [react] 什么是虚拟dom?虚拟dom比操作原生dom要快吗?虚拟dom是如何转变成真实dom并渲染到页面的?](https://img-blog.csdnimg.cn/img_convert/644fd4e16009fc91e3e1f578b67fda25.png)
为方便理解,我们可以将React.createElement创建对象结构抽象为:
const VitrualDom = {
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>
);
}
}
![[react] 什么是虚拟dom?虚拟dom比操作原生dom要快吗?虚拟dom是如何转变成真实dom并渲染到页面的? 23813424eb0afef8fb22042d364e7964 - [react] 什么是虚拟dom?虚拟dom比操作原生dom要快吗?虚拟dom是如何转变成真实dom并渲染到页面的?](https://img-blog.csdnimg.cn/img_convert/23813424eb0afef8fb22042d364e7964.png)
那么到这里,我们搞清楚了虚拟DOM究竟是什么,所谓虚拟DOM其实只是一个包含了标签类型type,属性props以及它包含子元素children的对象。
肆 ❀ 虚拟DOM的优势是什么?
肆 ❀ 壹 销毁重建与局部更新
在提及虚拟DOM的优势之前,我们可以先抛开什么虚拟DOM以及什么MVC思想,回想下在纯 js 或者 jq 开发角度,我们是如何连接UI和数据层的。其实在16年之前,博主所经历的项目开发中,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的这段逻辑。在当时能想着把这段逻辑复用成一个方法,再考虑用上createDocumentFragment减少操作dom的次数,能做到这些,这在当时都是能小吹一波的了....
![[react] 什么是虚拟dom?虚拟dom比操作原生dom要快吗?虚拟dom是如何转变成真实dom并渲染到页面的? 14af85cadaad110c72fda232586b68d1 - [react] 什么是虚拟dom?虚拟dom比操作原生dom要快吗?虚拟dom是如何转变成真实dom并渲染到页面的?](https://img-blog.csdnimg.cn/img_convert/14af85cadaad110c72fda232586b68d1.png)
所以你会发现,在原生js的角度,根本没有所谓的dom对比,都是重新创建,因为在写代码之前,我们已经明确知道了哪部分是静态页面,哪部分需要结合数据进行动态展示。那么只需要将需要动态生成的dom的逻辑提前封装成方法,然后在不同时期去调用,这在当年已经是非常不错的复用了(组件的前生)。
那么问题来了,假定现在我们有一个类似form表单的展示功能,点击不同用户,表单就会展示用户名,年龄等一系列信息:
![[react] 什么是虚拟dom?虚拟dom比操作原生dom要快吗?虚拟dom是如何转变成真实dom并渲染到页面的? 1c0d640222ad0abf7115408c178e8a64 - [react] 什么是虚拟dom?虚拟dom比操作原生dom要快吗?虚拟dom是如何转变成真实dom并渲染到页面的?](https://img-blog.csdnimg.cn/img_convert/1c0d640222ad0abf7115408c178e8a64.png)
用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个,而这仅仅是一个节点。
在前面我们也提到过,不管是jq封装,还是react vue的模板语法,它的前提一定是研发自己提前知道了哪部分内容未来是可变的,所以我们才要动态封装,才需要使用{}进行包裹,那既然如此,我们就对比未来可能会变的部分不是更好吗?
而回到上文我们对于虚拟结构的抽象,对于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合成事件底层原理,原生事件中阻止冒泡是否会阻塞合成事件?一文。
肆 ❀ 叁 渲染优化
我们知道react遵循UI = Render(state),只要state发生了改变,那么render就会重新触发,以达到更新ui层的效果。而更改state依赖了setState,大家都知道setState对于state更新的行为其实是异步的,假设我们在一次事件中更改了多次state,你会发现页面也仅会渲染一次。
而假定我们是直接操作dom,那还有哪门子的异步和渲染等待,当你append完一个子节点,页面早渲染完了。所以虚拟dom的对比提前,以及setState的异步处理,本质上也是在像尽可能少的操作dom靠近。
若对于setState想有更深入的了解,可以阅读博主这两篇文章:
react中的setState是同步还是异步?react为什么要将其设计成异步?
react 聊聊setState异步背后的原理,react如何感知setState下的同步与异步?
肆 ❀ 肆 跨平台能力
同理,之所以加入虚拟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的实现原理
文章开头的五个问题到这里已经解释了三个,还剩两个问题均与源码有一定关系,虽然略显枯燥但我会精简给大家阐述这个过程,另外,为了让知识量不会显得格外庞大,本文将不会阐述diff算法与fiber部分,这两个知识点我会另起文章单独介绍,敬请期待。
除此之外,接下来两个问题的源码,我将均以react17.0.2源码为准,所以大家也不用担心版本差异,会不会有理解了用不上的问题,而且目前用react 18的公司也不会很多。
我们先解释虚拟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;
}
![[react] 什么是虚拟dom?虚拟dom比操作原生dom要快吗?虚拟dom是如何转变成真实dom并渲染到页面的? 74b8f4646b781396450f5080f2a09040 - [react] 什么是虚拟dom?虚拟dom比操作原生dom要快吗?虚拟dom是如何转变成真实dom并渲染到页面的?](https://img-blog.csdnimg.cn/img_convert/74b8f4646b781396450f5080f2a09040.png)
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 = '
转载请注明:xuhss » [react] 什么是虚拟dom?虚拟dom比操作原生dom要快吗?虚拟dom是如何转变成真实dom并渲染到页面的?