Giter Site home page Giter Site logo

blog's People

Contributors

hexiaokang avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar

Forkers

ymitsource

blog's Issues

剖析setState源码,揭秘到底是同步OR异步

问题:setState()到底是同步还是异步?

请看如下场景:

image

结论:

  • 在 >= v18.0.0 时,任何位置调用setState都是异步更新的
  • 在 < v18.0.0 时,视调用位置的不同分两种情况:
    1、所有在React自身的调度流程中调用时都是异步的,比如生命周期(componentDidMount\componentDidUpdate...)、合成事件(onClick\onMouseEnter...)等
    2、在JS原生事件、异步事件、DOM元素绑定的原生事件等都是同步更新的(比如:setTimeout、setInterval、async\await等等)

源码分析

首先,我们来看低于V18.0.0版本以下的。
1、调用setState()后会依次经历以下函数过程:
setState > enqueueSetState > scheduleUpdateOnFiber > flushSyncCallbackQueue
2、flushSyncCallbackQueue是具体执行同步更新的函数,控制是否调用同步的逻辑在scheduleUpdateOnFiber中,所以我们来重点看下scheduleUpdateOnFiber内部的逻辑

image

从上图圈出的重点内容可以看出,控制是否同步更新的是executionContext变量,那么executionContext变量是什么?何时为NoContent?
executionContext是一个全局变量,标识当前React 执行的阶段。当executionContext值为NoContent时,setState即会同步更新state。那么executionContext何时会修改值?搜索全局发现,进入任何一个React自身的调度事件中都会被赋予相应不同的状态。如下几种式例:

image

image

executionContext默认值为NoContent, 所以 ,在React自身调度流程中执行setState是异步更新,其他未进入的则是同步更新。

接下来我们看V18的scheduleUpdateOnFiber函数:

image

从圈中部分可以看出,要执行同步更新时新增了条件判断,其中一个条件是必须在非concurrent模式下才可进入,react版本不同采用的模式也不同,主要有Legacy、blocking、Concurrent模式。而V18版本以后已经全面采用Concurrent模式,因为功能最全面。所以V18版本以后调用setState都是异步的。 - 但需要理解,setState的异步行为并不是代表setState内代码执行是异步的,其实内部代码是同步的,只不过生命周期和合成事件的执行顺序都在更新之前,所以没法立即拿到修改后的state,需要更新后才能拿到,所以才导致异步效果。目的是为了“批量优化”!

useState中使用setState时有何区别

专访Kyle Simpson - JavaScript神学家

Kyle Simpson 简介

你可能没有听过Kyle Simpson,但是你一定听过《You Don't Know JS》这本书。Kyle就是作者,他是真正的JavaScript大师、学习大神。在他看来,或许很多人学过的JS还没有他忘记的多!
详细主页:https://me.getify.com/

Kyle Simpson 专访

Tip:本文由原文链接 - 需翻墙翻译而来。

image

你好 Kyle! 欢迎来到 JSNation 访谈。既然你写了这么多你不知道JS 的书籍,那么你认为你懂 JS 吗?

我认为没有人真正了解 JS,至少不是完全了解。我说这话是真心的,并不是自夸:我忘记的JS内容比大多数人学到的都要多。本书的主要观点是,以一种不是已掌握和完成学习的态度来面对 JavaScript,而是不断地尝试更深入的学习它。一旦你擅长让 JS 做你想让它做的事情,学习如何在你的代码中清晰地表达你的想法就进入了一个全新的阶段,而这是一个更深入、更困难的学习阶段。
image

在你看来,JavaScript中最具争议/矛盾的是什么?

我认为 JS 处理类型的方式可能是最容易被误解的(因此也是最有争议的),尽管它的“类”系统紧随其后。我发现大多数人提到“类型”就会想到他们在其他语言像 C++ 或 Java 中习惯的静态类型, 由于 JavaScript 中没有那么明显的 JS 类型系统,一个体现在语法上的假设是 JS 的类型系统是简单的(最好的),或不存在的(在最坏的情况)。在我看来,JS 的类型系统实际上非常强大,而且设计得很好。它不是完美的,但它还未被广泛地理解和充分利用。

我不认为我们需要在 JS 中“修复”类型(比如 TypeScript 或 Flow ),我认为我们只需要更好地理解它们,更清楚地用 JS 表达我们的类型。例如,与大多数常见的想法完全相反,在我们的程序中, == 操作符实际上比 === 更强大、更有用,实际上,应该优先使用==,而 === 应该是最后一个选项。但要理解我为什么这么说,你必须学习类型,以及它们转换的方式:强制。我真心希望开发人员不要像他们多年来听到的那样不断重复“JS类型是糟糕的”。它们非常棒,如果你不学习并充分利用它们的潜力,你的程序就会受到影响。
image

JavaScript 语言中最吸引你的是什么?

我可能最着迷于 JS 的“多范式”方法。当然, JS并不是唯一一个被贴上这个标签的语言,但我认为, JS 也许是所有语言中最具多范式特征的,这主要是因为这种语言在相反的方向上延伸得有多远。令我惊讶的是,一个仅10天创造的实验语言如何成长为世界上使用最广泛的语言,而与此同时,语言的大部分核心在第10天就已经存在了。我不知道除了将这种令人惊讶的无与伦比的成功归功于Brendan Eich的偶然灵感之外还能归功于什么。我们周围有很多“JS憎恶者”,他们中的大多数人哀叹 JS, 并希望其他一些“更好”的语言能够取而代之。

问题是,JS正是达到这个目的所需要的。其他任何一种语言都不会成功。我认为这一部分原因是其它语言中的任何一个都不像JS已经证明的那样灵活(也就是“多范式”)。我不必选择完全按照步骤编写程序,或者完全面向对象,或者完全使用FP模式。我可以选择这些范式中哪个最适合我的程序的每个部分,而且JS足够灵活,可以让我灵活组合使用它们。在我看来,没有任何语言曾经(或者将来也不会)像JS那样接近于通用语言。当它被称为“通用语言”时,其原因是它足够灵活,可以作为几乎所有编程语言任意组合之间的通用桥梁语言。这真的很吸引我。

“你不知道的JS”已经成为JavaScript开发人员的现代圣经。成为一个鲜活的JavaScript大师感觉如何?

首先,我不是JS专家,我绝对不认为我应该被称为“经典”。我非常感谢我有幸给JS社区带来巨大影响(而且是没有计划的!)。当我开始写YDKJS的时候,我告诉我的出版商我想能卖出2500本就很成功了。如今,该书已售出10万多册。我做梦也想不到会有这种事。

这仍然很难理解。我认为并希望能与这么多读者产生共鸣是因为我信任和尊重读者, 实际上我的程序员同事,社区同僚也足以确信他们应该知道某些事情是怎样运行的以及为什么,它们需要知道全部的事实, 而不是被告知按我说的做, 因为只有我知道我在说什么。我总是试着说话和写作,就好像我坐在他们旁边,和他们聊天,我想分享一些我学到的和充满激情的东西。我认为这就是为什么YDKJS能深入人心的根本原因——他们离开时会真心觉得有人关心他们,帮助他们更好地了解JS。

我总是告诉人们,我和他们之间的唯一区别是,我会提出更多的问题,并且直到找到答案才会停下来。然后我决定把我提出问题和寻找答案的过程以书的形式写下来,这样其他人就能更容易地找到答案。我欣喜若狂,几乎难以言表——这对一个作家来说是一件大事!因为它帮助了很多人。
image

你是怎么“爱上”JS的?你是否有某位老师或导师帮助你起步,或者你是一个自学成才的人?

我绝对属于自学族。我接受过正规的计算机科学教育,但那只教会我把编程当作一门工程学科来欣赏,而不是当作一种爱好或玩具。我参加的计算机科学项目看不起像JS这样的语言,所以它们与我对该语言的依恋没有直接关系。许多人说,他们了解JS越多,就越不喜欢它,但是对我来说,这是完全相反的。
起初我也并不喜欢它,但随着我对它的了解越来越多,我真的开始喜欢上它了。绝对不是一见钟情的那种情况! 我觉得我最喜欢的是,无论我想解决的问题有多复杂,JS总有办法让我去解决。这是我遇到过的最灵活的工具,它让我感到强大和有能力。我对用其他语言编写软件感到无能为力,好像我甚至不知道从哪里开始。但是随着JS在其他领域传播的越多,我就对能在这些地方使用JS越感到兴奋。
我可以为我的手机、我的手表、我的冰箱、我的电视、我的有线电视盒编写应用程序,甚至可以控制我家里的灯泡! 这是我童年梦想的实现,我可以让它做我想做的任何事! 谁会不喜欢呢?

你将自己描述为一个开源web布道者、JavaScript神学家、开源优先的开发人员和Symmathecist(共同学习家)。那你认为你到底是什么样的人?

我认为“开源优先”和“Symmathecist”最能体现我在社区中的身份。我之所以说“开源优先”,是因为我真的相信最好的软件是我们共同开发的软件,而不是我们分发给其他人的软件,这样他们就会对我们印象深刻。我总是假设我写的每一行代码都是该代码最糟糕的版本,而我改进它的唯一希望就是尽快把它发布出来,并请其他人来帮助我改进它。我认为这就是作为一个社区运动的“开源”精神最真实的道德准则,我试着去实践它,并为其他人树立榜样。

" Symmathesy "这个概念对我来说是一个较新的概念,因为我只有一个词来形容它,但它是我一直相信的,但我没有一个很好的方式来表达它。Jessica Kerr的一个精彩演讲 帮助我理解了这个**。Symmathesy的意思是“一起学习”。我对它的理解是“getify”这个词,这是多年前构思出来的,我用它来描述我的角色是“出去获取东西(想法、服务等),并将它们提供给人们使用”。当时我甚至没有意识到这将演变成我的教学。

但对我来说,教与学是同一努力的两个方面。所以当我教书的时候,我真的在和别人“一起学习”,我希望能帮助别人也这样做。因此,开源优先的symmathesy(共同学习)形成了这样一个理念: 如果我们一起做而不是分开做,我们都会变得更好,会学到更多。

你还有其他JavaScript方面的书在编写中吗?

我的下一本新书将是关于提高代码可读性的。事实上,在过去的几年里,我所写的、编码的和教授的几乎所有东西都是以这个主题为基础的,所以,如果你愿意的话,这就是本书的研究和准备工作。但在开始写那本书之前,我必须先处理一下早该发行的YDKJS第二版 和我的新书《轻量级函数式编程JavaScript》 的第二版。哦,我还想用我最近对FP的一些观察来编写FLJS(Functional-Light JavaScript)的续集,内容包括更有效地使用JS类型,在FP模式中使用React-style钩子,等等。有很多东西要写,却没有足够的时间去写!
image

在你的Kickstarter活动页面上,你提到你正在尝试一种混合出版模式,并得到了人们和出版社的支持。你认为这种模式有效吗?

我为这个实验感到非常高兴,也为O'Reilly愿意让我做这个实验(用他们的钱)感到高兴。我希望,现在仍然希望,如果有人能解开写作和出版的整个过程的谜团,它们就更有可能成为作家。当我告诉我的编辑们,我将要求他们注册Github账户,并将他们所有的反馈以问题和PR的形式发布时,他们都非常胆怯。但这是因为我想在写作中实践那种开源优先(open-first)的心态,就像我在编码和教学中所做的那样。我认为这对于任何对写作感兴趣的人来说都是一件好事。

多年来,有无数人利用了这些信息,向我提出更多探索性的问题,然后自己成了作家。这是非常可喜的。我仍然以这种开源优先的方式写作和(现在是自己)出版,而且我希望只要我是一名作家,我就会继续这样做。我无法想象用别的方法做这件事。我希望我能不断激励其他人也这样做。

你将在Amsterdam最美丽的教堂之一做主题演讲。考虑到你是一个JS神学家,又要去以前的教堂演讲,这难道不是一个征兆吗?

哈! 嗯,这可能是个征兆! 几年前有人给了我这个“头衔”,全都是因为我所有的公开想法和对JS的预测。我觉得很合身。如果JavaScript(以及它的代码可读性)是一种“宗教”,那么我一定会用我的一生系统地思考和推理如何将这种“宗教”应用到我的生活中,以及如何将这个词传播给其他人!

你打算在Amsterdam 的JSNation会议上谈些什么?

JavaScript多年来一直是一个很好的赌注。不管你有没有意识到,在很长一段时间里,有两种不同的JavaScript基本上是并行运行的。但市场力量正导致它们分化,JS的这两个方向现在正朝着完全不同的方向发展。我觉得我们应该探索和思考这些演进是否会继续对JS有利,并最终对我们和web有利。

个人感受

  • 相比《JavaScript高级程序设计》、《JavaScript权威指南》、《JavaScript语言精粹》等书籍,这本书对JavaScript编译原理、执行机制、闭包、this机制、对象原型、作用域&内存、LHS&RHS查询等知识点讲解的最为深刻、细致;

  • 阅读本书还会感觉一种格外的亲和力,犹如作者亲临一般正在细心的跟你讲解每一个技术点;

  • 其中很多细节都纠正了开发者的错误认知,比如==&===的区别,“javaScript万物皆为对象的不严谨言论”等;

VDOM 到底比原生快还是慢

什么是VDOM?

VDOM如何转换为真实DOM

原生操作DOM的两种方式

VDOM到底快还是慢

VDOM缺点、优势、

为何Vue、react等框架都采用VDOM?

前端行业领军人物

语言相关

名字 贡献 主页
Brendan Eich JavaScript发明人 https://brendaneich.com
https://www.linkedin.cn/incareer/in/brendaneich
Jeremy Ashkenas coffeeScript发明人,
backbone.js作者,
underscore.js作者
http://ashkenas.com/
https://github.com/jashkenas
Anders Hejlsberg TypeScript发明人,
c#之父
https://www.linkedin.com/in/ahejlsberg/
Nicholas C. Zakas 《JavaScript高级程序设计》作者,
《Understanding ECMAScript6》,
ESlint 作者
https://www.linkedin.com/in/nzakas
https://humanwhocodes.com
Kyle Simpson JavaScript神学家,
《you don't know Js》作者
https://me.getify.com/
https://me.getify.com/@githubsponsorship
Douglas Crockford 《JavaScript语言精粹》作者,
《How JavaScript works》作者,
JSlint工具发明人,
JSON发明人
https://www.crockford.com/
https://github.com/douglascrockford
https://howjavascriptworks.com
David Flanagan 《JavaScript权威指南》作者 https://davidflanagan.com/
https://github.com/davidflanagan
Michael Fogus 《Functional JavaScript》作者 https://www.linkedin.com/in/fogus/
Stoyan Stefanov 《JavaScript Patterns》作者,
《React Up and Running》作者,
《Object Oriented Programming》作者
https://www.phpied.com/
http://www.jspatterns.com
https://www.linkedin.com/in/stoyanstefanov
Loiane Groner 《JavaScript数据结构与算法》作者 https://loiane.com
https://github.com/loiane
Evan Burchard 《Refactoring JavaScript》作者,
《网页游戏开发秘籍》作者
https://github.com/EvanBurchard
https://evanburchard.com/
Tali Garsiel 《how browser work》作者 http://taligarsiel.com/
https://web.dev/howbrowserswork
https://www.linkedin.cn/incareer/in/tali-garsiel-3aa6192
Jeremy Keith 《JavaScript DOM编程艺术》作者 https://adactio.com/
Jesse James Garrett Ajax发明人 https://www.linkedin.com/in/jesse-james-garrett-1341/
Axel Rauschmayer 《Exploring ES6》作者 https://exploringjs.com/
https://2ality.com
http://dr-axel.de/
Narayan Prusty 《Learning ES 6》作者,
serverLess专家
https://www.linkedin.com/in/narayanprusty/
http://qnimate.com
Ryan Dahl node.js作者,
deno.js作者
https://tinyclouds.org
https://github.com/ry
Isaac Schlueter node.js维护者,
npm公司CEO
https://www.linkedin.com/in/isaacschlueter/
https://izs.me/resume.html
https://blog.izs.me/

框架、工具相关

名字 贡献 主页
尤雨溪 vue作者 https://evanyou.me/
https://github.com/yyx990803
https://www.linkedin.cn/incareer/in/evanyou/
Sebastien Chopin nuxt.js作者 https://atinux.com/
https://www.linkedin.com/in/atinux/
Jordan Walke react作者 https://github.com/jordwalke
https://twitter.com/jordwalke
Dan Abramov redux作者,
create-react-app作者,
俄罗斯人,21岁写出redux
https://github.com/gaearon
https://justjavascript.com
https://overreacted.io/
https://zhuanlan.zhihu.com/p/66098354
Sebastian Markbåge “React终结者”,
著作《React设计**》
http://blog.calyptus.eu/
https://github.com/reactjs/react-basic
https://www.linkedin.com/in/sebmarkbage/
Ryan Florence react -router 作者, react Training公司CEO https://reacttraining.com/
https://www.linkedin.cn/incareer/in/ryanflorence/
https://github.com/ryanflorence
https://remix.run/
Jing Chen Flux作者之一 https://www.linkedin.com/in/jing-chen-3534255/
Pete Hunt react鼻祖,
“Rethinking best pratice”
http://www.petehunt.net
https://github.com/petehunt
Tony Kovanen next.js作者之一,
Gatsby.js作者之一
https://www.linkedin.com/in/tony-kovanen-36991a74/
https://www.gatsbyjs.org
Naoyuki Kanezawa next.js作者之一 https://github.com/nkzawa
Rick Hanlon React core之一 https://github.com/rickhanlonii
https://rickhanlon.codes/
Jason Miller preact.js作者 https://github.com/developit
https://jasonformat.com/
托比亚斯(Tobias) webpack作者 https://www.techug.com/post/interview-with-webpack-founder-tobias-koppers.html
https://github.com/sokra
Eric Schoffstall gulp作者 https://github.com/Contrahttps://contra.io/
Kevin Dangoor commonJS作者 https://github.com/dangoor
https://www.kevindangoor.com/
https://www.commonjs.org/
Andy Chung RequireJs作者 https://andychung.me/
https://requirejs.org/
Henry Zhu babel作者 https://www.henryzoo.com/
https://github.com/hzoo
John-David Dalton lodash作者 https://github.com/jdalton
jasonsaayman axios维护者 https://github.com/jasonsaayman
https://creativeowl.xyz/
jonathantneal polyfill作者 https://jonneal.dev/
https://github.com/jonathantneal
Vlad Filippov grunt维护者 https://github.com/vladikoff
https://vf.io/
Devon Govett Parcel作者 https://github.com/devongovett
Lukas Taegert-Atkinson Rollup作者 https://github.com/lukastaegert
Simon Boudrias Yeoman作者 https://yeoman.io/
https://github.com/SBoudrias
http://simonboudrias.com/
Evan Wallace esbuild作者 https://github.com/evanw
https://madebyevan.com/
https://esbuild.github.io/
Fred K. Schott Snowpack作者 https://github.com/FredKSchott
http://fredkschott.com/
https://www.snowpack.dev/
John Resig jQuery作者 https://www.linkedin.com/in/jeresig/
https://johnresig.com
Jacob Thornton Bootstrap发明人 https://www.linkedin.com/in/jacob-thornton-13a6a5162/
Mr.doob three.js发明人 https://mrdoob.com/
Mike Bostock D3.js作者 https://d3js.org/https://github.com/mbostock
Alexis Sellier less作者 https://github.com/cloudhead
TJ Holowaychuk stylus作者 https://github.com/tj

国内技术大佬

名字 贡献 主页
玉伯 less作者 https://github.com/lifesinger
https://www.zhihu.com/people/lifesinger
阮一峰 ES大佬 http://www.ruanyifeng.com/blog/
https://github.com/ruanyf
朴灵 《深入浅出Node.js》作者 https://github.com/JacksonTian
周爱民 《javaScript语言精髓与编程实践》作者 https://github.com/aimingoo
廖雪峰 技术作家 https://github.com/michaelliao
勾三股四(赵锦江) 阿里大佬 https://github.com/jinjiang/
https://linktr.ee/jinjiang
wintercn(程劭非) 前阿里专家 https://github.com/wintercn
吴亮(月影) 资深web https://www.h5jun.com/
张鑫旭 《CSS世界》作者 https://www.zhangxinxu.com/
大漠 W3CPlus创始人 https://www.zhihu.com/people/w3cplus

Vue3 三大核心模块之【编译器】

Vue框架本质

无论是Vue还是React,抛开路由、状态管理、SSR等周边生态而言,Vue和React的核心库只关注视图层,正如官网所言 - for building user interface,就是为快速创建UI界面而诞生。整体来看,它们都是对用户暴露声明式API,另一端则封装了对浏览器的命令式代码。比如说Vue,你在使用Vue时,在template中写下<div>this is div ele</div>,Vue框架则会将其进行编译成命令式代码。最终通知浏览器的代码可以理解为如此:

document.createElement('div').innerText = 'this is div ele'

我们熟悉的jQuery就是经典的命令式框架。那么Vue为何采用声明式方案来创建UI界面呢?那其实是在性能和可维护性两者之间的综合考虑。jQuery这种命令式代码最大的问题就是无法解放生产力,开发者的心智负担太重,因为开发者需要关注过程中的每一个细节,元素的创建、属性添加、销毁等等。因此随之而来的另一个严重问题就是可维护性极差。试想,你要为某个元素添加一个属性,你是否应该一步步找到创建这个元素的位置,在后面添加属性,并且还需确保是否在其他位置会修改属性。但是jQuery也有一个最大的好处,那就是 - 性能。理论上来讲,命令式代码的效率是最优的。为何?因为它是直接通知浏览器该如何做,没有其他前置过程。Vue采用的声明式方案则会有一个编译和计算的过程。

那么Vue核心库具体包含哪些模块?我们来看:

tips:所有示例代码仅描述核心逻辑实现,具体可自行查阅源码。个人观点 - 最好的源码讲解方式不是对照源码进行逐行功能讲解,而是在理解核心**后自驱性学习源码,自己领悟的才是真正属于自己的!

编译器

编译器是什么?它有什么作用?几乎所有的编译器都可以理解为“将一种语言A(源码)翻译成另一种语言B(目标代码)”。结合Vue来看,我们在template中所写的代码就是源码A。经过编译器的一系列处理最终输出的JavaScript代码就是目标代码B。Vue编译器的工作流程主要有三大环节:

  • 解析阶段/parse - 解析器将模板DSL解析成模板AST。

  • 转换阶段/transform - 转换器将模板AST转换为JavaScript AST。

  • 生成阶段/generate - 生成器根据JavaScript AST 生成 JS代码,也就是render函数。

image

接下来,我们来看这三个环节具体是如何处理的。

解析器

解析器的作用是将模板DSL解析为模板AST。何为DSL?DSL是领域特定语言的简称,在Vue中template就是DSL,在React中jsx就是DSL。何为AST?AST是抽象语法树的简称,本质上理解,AST就是用数据结构来描述代码。

传统解析模式

传统的解析模式包含词法分析和语法分析两个过程。词法分析就是将接收的template字符串分析处理成一个个的词法token。语法分析则是将tokens再次进行处理,组建成一个描述模板代码的树状结构,即模板AST。如下为词法分析示例:

image

那么词法分析具体是如何实现的?

传统的词法分析方法基本上都是采用"有限状态自动机"技术。所谓有限状态自动机,其中有两个关键字眼 - 有限状态自动机。通俗理解就是,在有限个状态中自动切换不同状态的过程。结合JS,我们很自然的会想到最直接的实现方式就是开启一个while循环,然后不断的处理所接收的字符串,依据不同的字符串生成不同的标签。下面来看核心代码实现:

// 定义解析状态
const PARSE_STATUS = {
    INITIAL: 1,
    TAG_OPEN: 2,
    TAG_NAME: 3,
    TEXT: 4,
    TAG_END: 5,
    TAG_END_NAME: 6
}
// 判断是否字母
function isAlpha(str) {
    return str >= 'a' && str <= 'z' || str >= 'A' && str <= 'Z'
}
function tmpTokenize(tmpStr) {
    let currentState = PARSE_STATUS.INITIAL
    let chars = []
    const tokens = []
    function handleStr(str) {
        chars.push(str) // 拼接标签或文本
        tmpStr = tmpStr.slice(str.length) // 模板字符串中去除已处理的字符
    }
    while (tmpStr) {
        const char = tmpStr[0]
        switch (currentState) {
            case PARSE_STATUS.INITIAL:
                if (char === '<') {
                    currentState = PARSE_STATUS.TAG_OPEN
                    tmpStr = tmpStr.slice(1)
                } else if (isAlpha(char)) {
                    currentState = PARSE_STATUS.TEXT
                    handleStr(char)
                }
                break
            case PARSE_STATUS.TAG_OPEN:
                if (isAlpha(char)) {
                    currentState = PARSE_STATUS.TAG_NAME
                    handleStr(char)
                } else if (char === '/') {
                    currentState = PARSE_STATUS.TAG_END
                    tmpStr = tmpStr.slice(1)
                }
                break
            case PARSE_STATUS.TAG_NAME:
                if (isAlpha(char)) {
                    handleStr(char)
                } else if (char === '>') {
                    currentState = PARSE_STATUS.INITIAL
                    tokens.push({
                        type: 'tag',
                        name: chars.join('')
                    })
                    chars = []
                    tmpStr = tmpStr.slice(1)
                }
                break
            case PARSE_STATUS.TEXT:
                if (isAlpha(char)) {
                    handleStr(char)
                } else if (char === '<') {
                    currentState = PARSE_STATUS.TAG_OPEN
                    tokens.push({
                        type: 'text',
                        content: chars.join('')
                    })
                    chars = []
                    tmpStr = tmpStr.slice(1)
                }
                break
            case PARSE_STATUS.TAG_END:
                if (isAlpha(char)) {
                    currentState = PARSE_STATUS.TAG_END_NAME
                    handleStr(char)
                }
                break
            case PARSE_STATUS.TAG_END_NAME:
                if (isAlpha(char)) {
                    handleStr(char)
                } else if (char === '>') {
                    currentState = PARSE_STATUS.INITIAL
                    tokens.push({
                        type: 'tagEnd',
                        name: chars.join('')
                    })
                    chars = []
                    tmpStr = tmpStr.slice(1)
                }
                break
        }
    }
    return tokens
}

通过以上函数处理即可得到一个tokens数组。接下来就是将tokens处理成AST的过程,也就是语法分析。如下效果:

image

那么语法分析是如何实现的?语法分析本质上就是循环处理tokens中的每一个token,将其重组成一个树状结构。其中有一个重点 - 如何将子节点精准的归属在其父节点的children属性中呢?这其实很简单,只需要借助一个额外的stack容器存储即可。遍历tokens时,每当遇到一个开始标签,会将开始标签做为父节点推入栈顶,同时,在ast树中相应的节点下设置当前父节点。当遇到结束标签时,会将其对应的开始标签从栈中弹出,以此保证标签正确的开始结束以及归属在正确的父标签下面,如下为核心代码实现:

function parse(tmpStr) {
    const tokens = tmpTokenize(tmpStr)
    const root = {
        type: 'Root',
        children: []
    }
    const elementStack = [root] // 暂时存储父节点
    while (tokens.length) {
        const parent = elementStack[elementStack.length - 1]
        const token = tokens[0]
        switch (token.type) {
            case 'tag':
                const elementNode = {
                    type: 'Element',
                    tag: token.name,
                    children: []
                }
                parent.children.push(elementNode) // 设置节点位置
                elementStack.push(elementNode) // 父节点推入栈顶
                break
            case 'text':
                const textNode = {
                    type: 'Text',
                    content: token.content
                }
                parent.children.push(textNode)
                break
            case 'tagEnd':
                elementStack.pop()
                break
        }
        tokens.shift() // 移除已处理的token
    }
    return root
}

经过这两个过程就完成了解析,得到 模板AST。但是,我们可以发现这种解析方式其实有很大的优化空间。比如:词法分析和语法分析其实可以同时执行的,因为两者具有同构性。还有,这种有限状态机需要编写的代码量太多,而JS 的正则本质上是一个天然的有限状态机,所以可用正则来优化。接下来我们来看Vue3中是如何升级解析过程的。

解析升级-四种解析模式

由于标签编码实际情况错综复杂,解析器在遇到相同标签时也会有不一样的解析行为,比如<div><p></p></div>中的p标签可以正常解析,但遇到<textarea><p></p></textarea>时,p标签则会被当做普通文本来解析,因为我们知道textarea中是不能有其他标签的。因此我们有必要先定义解析过程中有哪些模式。如下图:

image

详细说明可查看WHATWG官方规范文档

解析升级- 递归下降算法

首先,我们来看递归下降解析算法的核心实现代码,如下:

const TextModes = {
    DATA: 'DATA',
    RCDATA: 'RCDATA',
    RAWTEXT: 'RAWTEXT',
    CDATA: 'CDATA'
}
function parse(tmpStr) {
    // 上下文对象
    const context = {
        source: tmpStr,
        mode: TextModes.DATA
    }
    // parseChildren用于解析子节点,第一个参数传入上下文对象
    // 第二个参数是一个数组,用于存储父节点,后续会有重要作用
    const nodes = parseChildren(context, [])
    return {
        // 设置最外层为根节点
        type: 'Root',
        children: nodes
    }
}

首先定义了前面提到的四种解析模式,因为不同模式下解析行为会不同。然后重写parse函数,函数中定义了一个context对象 - 解析过程中上下文对象。它的作用可以通俗理解为“全局对象”,因为在接下来的递归处理过程中,需要一个存储全局变量的对象来实现各种控制,比如全局变量source存储的是模板字符串,mode存储的是解析模式,后续还会加很多全局变量。然后调用parseChildren函数对模板进行解析,这里可以大胆的事先猜测一下,parseChildren函数肯定是整个解析算法的核心,后续会重点讲解,parseChildren返回的就是节点list。

传统解析模式中提到了“有限状态自动机”,那么递归下降这种新型解析算法是如何实现的呢?具体来讲,parseChildren函数有哪些状态,以及状态间如何迁移转变呢?可以理解为调用一遍parseChildren,就开启了一个状态机。如下图:

image

下面结合四种解析模式来看parseChildren具体是如何来实现的:

function parseChildren(context = {}, ancestors = []) {
    let nodes = []
    const { source, mode } = context
    while (!isEnd(context, ancestors)) {
        let node = null
        if(mode === TextModes.DATA || mode === TextModes.RCDATA) {
            if(mode === TextModes.DATA && source[0] === '<') {
                if(source[1] === '!') {
                    if(source.startWith('<!--')) {
                        // 解析注释
                        node = parseComment(context)
                    } else if(source.startsWith('<!DOCTYPE')) {
                        // 解析文档声明
                        node = parseBogusComment(context)
                    } else if(source.startsWith('<![CDATA[')) {
                        // CDATA
                        node = parseCDATA(context, ancestors)
                    } else {
                        node = parseBogusComment(context)
                    }
                } else if(source[1] === '/') {
                    // 抛出错误,因为</这种为结束标签,不可能先遇到</
                } else if(/[a-z]/i.test(source[1])) {
                    // 解析标签
                    node = parseElement(context, ancestors)
                }
            } else if(source.startsWith('{{')) {
                // 解析动态插值
                node = parseInterpolation(context)
            }
        }
        if(!node) {
            // 解析文本
            node = parseText(context)
        }
        nodes.push(node)
    }
    return nodes
}

从parseChildren函数可以发现如下重点:

  • 每次调用parseChildren返回的就是当前父节点的字节点数组
  • 解析过程中需要判断解析模式,只有DATA和RCDATA模式,才能解析动态插值。只有DATA模式时,才可以解析标签节点、注释节点、CDATA节点。
  • DATA和RCDATA之外的模式遇到任何字符时都会当做文本解析。即使再DATA和RCDATA模式下如果遇到一些匹配异常的情况,也会当做普通文本解析。

parseChildren中用到了几个重点函数需要说明下:

1、isEnd函数用于判断ancestors数组中是否包含当前模板字符串的起始字符,比如ancestors中有div这个起始标签,而此时模板字符串以</div开头,说明遇到了结束标签,当前状态机要停止。
2、parseElement函数,顾名思义,这个函数用于解析元素,核心逻辑如下:

function parseElement(context, ancestors = []) {
    // 解析开始标签
    const element = parseTag(context)
    if(element.isSelfClosing) {
        return element
    }
    // 依据不同标签设置不同解析模式
    if(['textarea', 'title'].includes(element.tag)) {
        context.mode = TextModes.RCDATA
    } else if (/style|xmp|iframe|noembed|noframes|noscript/.test(element.tag)) {
        context.mode = TextModes.RAWTEXT
    } else {
        context.mode = TextModes.DATA
    }
    // 将开始标签推入栈顶
    ancestors.push(element)
    // 递归调用parseChildren解析子节点
    element.children = parseChildren(context, ancestors)
    if(context.source.startsWith(`</${element.tag}`)) {
        // 解析结束标签
        parseTag(context, 'end')
    } else {
        // 抛出错误
        throw `${element.tag}缺少闭合标签`
    }
}

至此,我们实现了递归下降算法的核心,可以更加系统的描述算法原理。正如下图所示:
image

  1. 首先,初始调用parseChildren,产生状态机1,为Root节点解析子节点。
  2. 状态机1中发现div标签,因此会调用parseElement解析,parseElement中默认又会调用parseChildren解析自身子节点,产生状态机2。
  3. 状态机2中发现第一个p标签,又会调用parseElement解析,继续调用parseChildren解析p的子节点,产生状态机3。
  4. 状态机3中发现是Hello文本,所以解析文本即可。解析完文本之后会解析P结束标签。
  5. 第一个p和第一个p的文本Hello已经解析完毕,回到状态状态机2中继续解析,发现第二个P标签,继续调用parseElement解析,再调用parseChildren,产生状态机4。
  6. 状态机4的文本world和p标签解析完之后,意味着状态机2的子节点也全部解析完毕,因此会逐层返回,最终为root节点设置子节点children属性。

总结:每次调用parseElement解析元素时,会默认调用parseChildren解析子节点,这就是“递归”的由来;上层parseChildren调用是为了构造上层模板AST的节点,下层parseChildren调用是为了构造下层模板AST的节点,这就是“下降”的由来。

解析标签

从前面可以看出,在parseElement函数中调用了parseTag首先来解析标签节点,其实解析标签的核心就是用正则去匹配合法的标签,接下来看具体函数实现:

function parseTag(context, type = 'start') {
    const { advanceBy, advanceSpaces } = context
    const match = type === 'start' ? /^<([a-z][^\t\r\n\f />]*)/i.exec(context.source) : /^<\/([a-z][^\t\r\n\f />]*)/i.exec(context.source)
    const tag = match[1]
    // 在原模板字符串上移除已处理的字符,比如 <div
    advanceBy(match[0].length)
    // 移除标签中可能存在的空格 比如<div   >
    advanceSpaces()
    // 解析属性
    let props = parseAttributes(context, type)
    const isSelfClosing = context.source.startsWith('/>')
    advanceBy(isSelfClosing ? 2 : 1)
    // 返回标签节点
    return {
        type: 'Element',
        tag,
        props,
        children: [],
        isSelfClosing
    }
}

另外,有必要说明的是,parseTag中调用了advanceBy、advanceSpaces两个工具函数。其实从命名和调用时机上就可以知道这两个函数就是为了在模板字符串中去除已经处理的字符,实现方式与前面传统解析模式中提到的一样,调用slice处理原模板字符串。此处将其抽离成工具函数,放在上下文对象中更便于管理。

解析属性

从上面可以看到,parseTag中调用了parseAttributes函数来解析标签的属性。解析属性也是用正则来匹配,但需要考虑属性名称、属性值的正则差异,以及属性值还有无引号、单引号、双引号三种情况。下面来看具体实现:

function parseAttributes(context) {
    const { advanceBy, advanceSpaces } = context
    const props = []
    while (
        context.source.length > 0 &&
        !context.source.startsWith('>') &&
        !context.source.startsWith('/>')
    ) {
        // 匹配属性名
        const match = /^[^\t\r\n\f />][^\t\r\n\f />=]*/.exec(context.source)
        const name = match[0]
        advanceBy(name.length)
        advanceSpaces()
        advanceBy(1)
        advanceSpaces()
        let value = ''
        const quote = context.source[0]
        // 考虑属性值是单引号、双引号、无引号三种情况
        const isQuote = quote === "'" || quote === '"'
        if(isQuote) {
            advanceBy(1)
            const endQuoteEndIndex = context.source.indexOf(quote)
            if(endQuoteEndIndex > -1) {
                // 属性值获取
                value = context.source.slice(0, endQuoteEndIndex)
                advanceBy(value.length + 1)
            } else {
                // 抛出错误,缺少引号
            }
        } else {
            // 无引号包裹属性值,则一直到下一个空白字符位置都是属性值
            const match = /^[^\t\r\n\f >]+/.exec(context.source)
            value = match[0]
            advanceBy(value.length)
        }
        advanceSpaces()
        props.push({
            type: 'Attribute',
            name,
            value
        })
    }
    return props
}

解析{{}}动态内容

{{}}是Vue中用来绑定数据的最常用方式,从前面parseChildren状态切换图中可以发现,当状态机遇到{{定界符时就会parseInterpolation来解析动态插值。下面来看此函数的实现:

function parseInterpolation(context = {}) {
    context.advanceBy(2)
    const closeIndex = context.source.indexOf('}}')
    if(closeIndex === -1) {
        // 抛出错误,缺少闭合定界符'}}'
    }
    const content = context.source.splice(0, closeIndex)
    context.advanceBy(content.length + 2)
    return {
        type: 'Interpolation',
        content: {
            type: 'Expression',
            // content需要经过解码
            content: decodeHtml(content)
        }
    }
}

解析其他内容

解析文本、解析转义字符、解析注释这三种场景与解析标签、属性等核心模式是一致的,这里不再讲述。

转换器

转换器的作用是将模板AST转换为JavaScript AST,AST前面已经提了,那么JavaScript AST就是用数据结构来描述JavaScript代码。首先我们来看转换示例:

const tmpAst = {
  type: 'Root',
  children: [
    {
       type: 'Element',
       tag: 'div',
       children: [
         { type: 'Text', content: 'Hi' }
       ]
    }
  ]
}

转换为JavaScript AST后,效果如下:

const jsAst = {
    type: 'FunctionDecl',
    id: {
        type: 'Identifier',
        name: 'render'
    },
    params: [],
    body: [
        {
            type: 'ReturnStatement',
            return: {
                type: 'CallExpression',
                callee: { type: 'Identifier', name: 'h' }
            },
            arguments: [
                { type: 'StringLiteral', value: 'div' },
                { type: 'StringLiteral', value: 'Hi' }
            ]
        }
    ]
}

那么转换具体是如何实现的呢?编译器的最终结果是输出render函数,因此我们需要思考的是如何用AST来描述render函数。函数无外乎由三部分组成:id、params、body。id描述函数名称,params描述函数的参数,body是函数体。所以可定义如下结构描述函数:

const FunctionDeclNode = {
    type: 'FunctionDecl',
    id: { type: 'Identifier', name: 'render' },
    params: [],
    body: [{
         // body中可能含有多条语句,所以也是一个数组
        type: 'ResultStatement',
        return: null
    }]
}

接下来设计转换器的核心逻辑:

function transform(tmpAst) {
    const context = {
        currentNode: '',
        childIndex: '',
        parent: '',
        nodeTransforms: [
            transformElement,
            transformText
        ]
    }
    traverseNode(tmpAst, context);
}

在transform中,依然需要定义一个上下文对象,然后调用traverseNode,traverseNode采用深度优先的方式遍历模板AST,是整个转换器的核心。核心逻辑如下:

function traverseNode(node, context) {
    context.currentNode = node
    const { nodeTransforms } = context
    const exitFns = []
    for(let i = 0; i < nodeTransforms.length; i++) {
        // nodeTransforms中的转换函数可以返回一个回调函数,供处理父节点的退出阶段调用
        const onExit = nodeTransforms[i](node, context)
        if(onExit) {
            exitFns.push(onExit)
        }
    }
    const children = context.currentNode.children
    if(children && children.length > 0) {
        for(let i = 0; i < children.length; i++) {
            context.parent = context.currentNode
            context.childIndex = i
            traverseNode(children[i], context)
        }
    }
    let i = exitFns.length
    // 执行退出阶段的回调
    while (i--) {
        exitFns[i]()
    }
}

从以上代码可以发现,traverseNode中会循环调用traverseNode来处理子节点,具体的处理方法封装在transformElement中,做为入参传入。traverseNode中有两个重点务必需要理解清楚:
1、nodeTransform中存放的是具体的转换函数,如前面提到的转换标签节点用transformElement,转换文本用transformText函数。之所以采用这种模式是为了解耦转换逻辑与核心的遍历逻辑,也便于后续新增各种转换函数,这种模式为插件化模式。
2、为什么转换函数需要返回一个回调函数,并且在traverseNode的最末尾才执行呢?由上面的模板AST结构以及traverseNode的调用顺序我们知道,转换过程是先处理的父节点,因为遍历是由外至内的。但是往往我们需要确保子节点处理完毕之后才能真正去处理父节点,这样才更稳妥。所以就有了这种模式,每次调用traverseNode转换节点时,先转换子节点,再转换父节点。

下面来看具体的AST转换函数:

function transformElement(node) {
    return () => {
        // createCallExpression函数用来创建`h('div', [])`语句
        const callExp = createCallExpression('h', [
            createStringLiteral(node.tag)
        ])
        node.children.length === 1 ? callExp.arguments.push(node.children[0].codegenNode) :
            callExp.arguments.push(
                // 当前父节点存在多个子节点
                createArrayExpression(node.children.map(ch => ch.codegenNode))
            )
        // 模板AST 节点对应的JavaScript AST存放在codegenNode属性中
        node.codegenNode = callExp
    }
}

通俗理解,模板AST中的节点我们最终如何创建?在render函数中是通过调用内置函数h实现的,比如h('div', [h('p')])会创建一个div父节点,里面还有一个p子节点。所以模板AST转换的目的就是用JavaScript AST 来表示 h('div', [h('p')])。
transformElement中调用了createCallExpression、createArrayExpression、createStringLiteral函数,此处不全部讲解,源码中还有很多与之类似的辅助函数,这些函数的作用就是用来创建各种JavaScript AST单元,比如createCallExpression,返回一个type为CallExpression的对象:

function createCallExpression(callee, args) {
    return {
        type: 'CallExpression',
        callee: createIdentifier(callee),
        arguments: args
    }
}

最后,我们需要补全JavaScript AST,类似transformElement,再并列新增一个transformRoot函数:

function transformRoot(node) {
    return () => {
        if(node.type !== 'Root') { return }
        const codegenNode = node.children[0].codegenNode
        node.codegenNode = {
            type: 'FunctionDecl',
            id: { type: 'Identifier', name: 'render' },
            params: [],
            body: [
                { type: 'ReturnStatement', return: codegenNode }
            ]
        }
    }
}

通过以上函数转换,最终就能得到JavaScript AST。

生成器

代码生成是整个编译阶段的最后一步,也是最简单的一步。代码生成就是js字符串拼接的过程,访问JavaScript AST中的每一个节点,为每一种类型的节点生成相应的JS代码。核心逻辑如下:

function generate(node) {
    const context = {
        code: '', // 存储最终生成的JS代码
        push(code) {
            // 拼接代码
            context.code += code
        },
        currentIndent: 0, // 控制缩进
        newLine() {
            context.code += '\n' + ` `.repeat(context.currentIndent)
        },
        indent() {
            context.currentIndent++
            context.newLine()
        },
        deIndent() {
            context.currentIndent--
            context.newLine()
        }
    }
    // 递归调用,生成JS代码
    genNode(node, context)
    return context.code
}

与解析、转换一样,生成阶段也需要定义上下文对象。核心是利用genNode来生成JS代码。接下来看genNode的核心逻辑:

function genNode(node, context) {
    switch (node.type) {
        case 'FunctionDecl':
            genFunctionDecl(node, context)
            break
        case 'ReturnStatement':
            genReturnStatement(node, context)
            break
        case 'CallExpression':
            genCallExpression(node, context)
            break
        case 'StringLiteral':
            genStringLiteral(node, context)
            break
        case 'ArrayExpression':
            genArrayExpression(node, context)
            break
    }
}

genNode内的核心逻辑很简单,判断节点类型,调用相应的函数生成JS代码。genReturnStatement、genCallExpression等这些函数核心原理是一样的,此处不全部讲解,以genFunctionDecl为例:

function genFunctionDecl(node, context) {
    const { push, indent, deIndent } = context
    push(`function ${node.id.name}`)
    push('(')
    getNodeList(node.params, context)
    push(`) `)
    push('{')
    indent()
    node.body.forEach(n => genNode(n, context))
    deIndent()
    push('}')
}

以上就是Vue编译过程中的三大核心环节 - 解析、转换、生成。

Vue3 三大核心模块之【响应式系统】

响应式系统核心设计

vue2响应式

vue3响应式

副作用函数冗余问题

无限循环问题

副作用函数嵌套

watch侦听原理

computed计算原理

复杂数据类型响应式

object

array

map\set

简单数据类型响应式

Vue3 三大核心模块之【渲染器】

从编译器原理中我们知道,我们所编写的template内容在经过编译器处理后最终输出render函数,调用render函数则会输出VDOM(虚拟DOM)。所谓虚拟DOM,本质就是用JS对象形式来描述DOM结构。如下:

const vNode = {
    type: 'div',
    props: {},
    children: ['Hi']
}

渲染器的作用就是将VDOM渲染为真实的DOM。不过Vue3的渲染器在功能上有相应的拓展,所以它也可以渲染在其他支持JS的平台上,此处仅讲解针对浏览器平台的渲染。

整体设计

首先来看设计一个渲染器需要编写的核心代码:

function createRenderer(options = {}) {
    const { createElement, insert, setElementText, patchProps } = options
    // 渲染入口
    function render(vnode, container) {
        if(vnode) {
            // 挂载 或 更新
            patch(container._vnode, vnode, container)
        } else {
            // 卸载
            unmount(container._vnode)
        }
        // 绑定vnode
        container._vnode = vnode
    }
    // 比较新旧node, n1为旧node,n2为新node
    function patch(n1, n2, container) {
        if(n1 && n1.type !== n2.type) {
            // 优先比较节点类型是否一致,不同则卸载旧node
            unmount(n1)
            n1 = null
        }
        if(!n1) {
            // 挂载
            mountedElement(n2, container)
        } else {
            // 更新
            patchElement(n1, n2)
        }
    }
    // 挂载节点
    function mountedElement(vnode, container) {
        const el = vnode.el = createElement(vnode.type)
        if(vnode.props) {
            for(let key in vnode.props) {
                // 设置属性
                patchProps(el, key, null, vnode.props[key])
            }
        }
        if(typeof vnode.children === 'string') {
            setElementText(el, vnode.children)
        } else if (Array.isArray(vnode.children)) {
            vnode.children.forEach(c => patch(null, c, container))
        } else {
            // 其他节点情况
        }
        insert(el, container)
    }
    return {
        render
    }
}

其中有几个重点务必理解清楚:
1、createRenderer用来创建一个渲染器,具体的渲染函数render之所以采用内部创建再返回这种方式,就是因为考虑到渲染器的通用性。实际上createRenderer不仅返回render,还会返回hydrate方法,hydrate用于SSR。
2、渲染器中将依赖平台特定API的方法都抽离至渲染器外面,比如createElement、insert,作为参数传入,这样就能实现渲染器函数在不同平台运行,便于拓展。
3、patch函数是渲染器的核心,接收旧节点n1和新节点n2,当旧节点n1为空时,说明执行挂载,当新节点n2为空时,说明要执行卸载操作。
4、patch函数优先判断两个节点的节点类型是否一致,如果不同则直接卸载旧节点。

更新子节点

一个标签的子节点有三种情况:无子节点、文本子节点、子节点list。因此比较两个标签的子节点时共有九种情况,如下代码实现:

function patchChildren(n1, n2, container) {
    const { children } = n2
    if(typeof children === 'string') {
        if(Array.isArray(n1.children)) {
            n1.children.forEach(ch => unmount(ch))
        }
        setElementText(container, children)
    } else if (Array.isArray(children)) {
        if(Array.isArray(n1.children)) {
            n1.children.forEach(ch => unmount(ch))
            // 新旧子节点都是list,DIff算法优化
        } else {
            if(typeof n1.children === 'string') {
                setElementText(container, '')           
            }
            n2.children.forEach(ch => patch(null, ch, container))
        }
    } else {
        if(Array.isArray(n1.children)) {
            n1.children.forEach(ch => unmount(ch))
        } else if(typeof n1.children === 'string') {
            setElementText(container, '')
        }
    }
}

从比较函数中可发现:
1、当新节点的子节点为文本时,需要判断旧节点的子节点是否为list,如果是则逐个卸载即可,最后再设置文本。
2、当新节点的子节点为list时,判断旧节点的子节点是否也为list,如果是则先卸载原list,再挂载新list。但是如此处理会耗费性能,所以后续有Diff算法专门优化此环节。如果旧节点的子节点原来为文本,需要先设置文本为空。
3、当新节点的子节点为空时,说明需要卸载全部子节点。

渲染属性

为节点设置属性,我们很自然会想到用setAttribute API,但实际上并没有那么简单。首先我们有必要了解下元素属性有两种 - HTML attributes 和 DOM properties。直接在html元素上设置或者调用setAttribute就是设置的HTML attributes,采用el.xx这种方式设置就是DOM properties。有些属性不能用setAttribute设置,比如textContent,用el.setAttribute('textContent', 'test value')设置文本值时无法生效,还有disabled属性调用setAttribute设置时也一样会出现异常。另一种情况,有些属性值无法用el.xx设置,比如input元素的form属性,只能用setAttribute方式设置。因此有必要在patchProps中兼容这些常见的情形:

function patchProps(el, key, prevValue, nextValue) {
    function shouldSetAsProps(el, key, value) {
        if(key === 'form' && el.tagName === 'INPUT') return false
        return key in el
    }
    if(shouldSetAsProps(el, key, nextValue)) {
        const type = typeof el[key]
        if(key === 'class') {
            el.className = nextValue || ''
        } else if(type === 'boolean' && nextValue === '') {
            el[key] === true
        } else {
            el[key] = nextValue
        }
    } else {
        el.setAttribute(key, nextValue)
    }
}

从函数中,可以得出几个重点信息:
1、如果当前dom有对应属性的情况下,优先采用dom properties的方式设置属性值,但是当属性值为boolean值,且传入的新值为‘’空字符串时,需要手动设置true,这才是用户的本意。
2、设置class属性时需要特殊处理,因为设置class有多种方式,但是设置className这种方式性能是最佳的。

事件处理

事件无非是用addEventListener和removeEventListener来处理,但是考虑到性能以及绑定的事件会很多,且同个事件绑定的函数还可能多个等情况,需要设计一个合理的绑定机制,如下为核心代码:

function patchProps(el, key, prevValue, nextValue) {
    // 省略上面已讲解过的代码
    if(/^on/.test(key)) {
        const invokers = el._vei || (el._vei = {})
        let invoker = invokers[key]
        const name = key.slice(2).toLowerCase()
        if(nextValue) {
            if(!invoker) {
                invoker = el._vei[key] = (e) => {
                    // 判断当前函数执行时间必须大于绑定时间点
                    // 以防父元素事件在子元素事件冒泡阶段绑定而触发的异常情况
                    if(e.timeStamp < invoker.attached) return
                    // 一个事件可绑定多个函数,props中传入数组即可
                    if(Array.isArray(invoker.value)) {
                        invoker.value.forEach(fn => fn(e))
                    } else {
                        invoker.value(e)
                    }
                }
                invoker.value = nextValue
                invoker.attached = performance.now()
                el.addEventListener(name, invoker)
            } else {
                invoker.value = nextValue
            }
        } else {
            el.removeEventListener(name, invoker)
        }
    }
}

从上面逻辑中可以发现:
为el元素新增invokers对象,invokers中存储了为每一个事件创建的虚拟函数invoker,再为invoker函数设置value属性,value存储真实的绑定函数。最后将invoker绑定元素上。这样做的好处在于:为不同事件绑定不同函数,避免覆盖。同时,也是有利于性能提升,因为在事件更新时不用多次调用removeEventListener解绑函数,而是直接修改Invoker.value值。

至此,我们已经实现了渲染器的核心功能,可对节点、属性、事件执行常规的挂载、卸载、更新操作。但是还有一个重要的问题没有解决 - 子节点更新时的性能优化。在更新子节点时,上面采用的方式是暴力卸载+挂载,这种方式无疑是特别耗费性能,因为会多出成倍的DOM操作。因此Vue设计了DIff算法对其进行优化。

Diff算法

Diff算法只考虑新旧子节点都是list的情况,目的就是为了在更新子节点list时尽可能的减少性能开销。从Vue诞生初期到现在,diff算法经历多版优化,最初是采用简单diff算法,Vue2中是采用双端diff,最新的Vue3中是采用快速diff算法。
tips: 以下核心逻辑中简化了type值的判断

简单diff

更新子节点list时可能存在如下几种情况:

  • 新list中某个节点与旧list中某个节点key值相同,文本不同,这种节点则更新文本即可
  • 新list与旧list中,存在key和文本相同的节点,但是顺序不一致,这种节点移动位置即可
  • 新list中存在旧list中没有的节点,这种节点需要挂载
  • 旧list中存在新list中没有的节点,这种节点则需要被卸载

这里有必要重点讲下key机制
key值好比是VNode的“身份证号”,判断节点type和key值如果一致则可复用DOM节点,减少DOM操作的性能损耗。如果子节点list可能存在更新,避免使用index索引或random()随机数做key值。

下面来看diff的核心逻辑:

function patchChildren(n1, n2, container) {
    if(typeof n2.children === 'string') {
        // 处理文本节点
    } else if(Array.isArray(n2.children)) {
        const oldChildren = n1.children
        const newChildren = n2.children
       // 记录最大索引值
       // 原理: 遍历新旧列表时,如果节点在旧列表中对应的索引值是呈递增趋势,则位置不变,反之则需要移动位置来保证跟新列表顺序一致
        let lastIndex = 0
        for(let i = 0; i < newChildren.length; i++) {
            const newVNode = newChildren[i]
            let findSameKey = false
            for(let j = 0; j < oldChildren.length; j++) {
                const oldVNode = oldChildren[j]
                // 通过key判断是否有可复用的DOM节点,移动即可
                if(newVNode.key === oldVNode.key) {
                    findSameKey = true
                    patch(oldVNode, newVNode, container)
                    if(j < lastIndex) {
                        const prevVNode = newChildren[i - 1]
                        if(prevVNode) {
                            const anchor = prevVNode.el.nextSibling
                            // 移动节点
                            insert(newVNode.el, container, anchor)
                        }
                    } else {
                        lastIndex = j
                    }
                    break
                }
            }
            if(!findSameKey) {
                // 说明未找到可复用节点
                const prevVNode = newChildren[i - 1]
                let anchor = null
                if(prevVNode) {
                    anchor = prevVNode.el.nextSibling
                } else {
                    anchor = container.firstChild
                }
                // 挂载新节点
                patch(null, newVNode, container, anchor)
            }
        }
        for(let j = 0; j < oldChildren.length; j++) {
            const has = newChildren.find(n => n.key === oldChildren[j].key)
            // 判断旧节点list中是否有未处理的节点,执行卸载
            if(!has) {
                unmount(oldChildren[j])
            }
        }
    } else {
        // 其他情况
    }
}

image

从函数实现以及简单diff流程图可以清晰理解比较过程:
1、判断新节点中P2时,发现旧list中有相同节点,则复用P2节点,更新最大索引值为1
2、判断新节点中P0时,发现旧list中没有,则需要执行挂载,挂载在P2节点后面
3、判断P1节点时,发现旧list中有P1,但此时P1在旧list中的索引为0,比原最大值1要小,所以要进行位置移动,即插入至已创建的真实DOM最末尾处
4、最后新节点全部处理完毕,需要判断旧list中是否有未处理的节点,有则执行卸载,如P3

双端Diff

简单diff算法比暴力卸载+挂载有明显的性能提升。但是在有些场景下依然有优化空间,因为它的比较模式太过机械固定。因此Vue2中采用了双端diff算法对其进行优化。双端diff的核心在于它会同时比较新旧两组节点的端点是否相等。具体逻辑如下:

function updateChildren(n1, n2, container) {
    const newChildren = n2.children
    const oldChildren = n1.children
    let oldStartIndex = 0
    let oldEndIndex = oldChildren.length - 1
    let newStartIndex = 0
    let newEndIndex = newChildren.length - 1
    let oldStartVNode = oldChildren[0]
    let oldEndVNode = oldChildren[oldEndIndex]
    let newStartVNode = newChildren[0]
    let newEndVNode = newChildren[newEndIndex]
    while(newStartIndex <= newEndIndex && oldStartIndex <= oldEndIndex) {
        if(!oldStartVNode) {
            // 说明oldStartVNode对应的节点已被处理
            oldStartVNode = oldChildren[++oldStartIndex]
        } else if (!oldEndVNode) {
            // 说明oldEndVNode对应的节点已被处理
            oldEndVNode = oldChildren[--oldEndIndex]
        } else if(oldStartVNode.key === newStartVNode.key) {
            // 比较新旧子节点列表的第一个节点
            patch(oldStartVNode, newStartVNode, container)
            oldStartVNode = oldChildren[++oldStartIndex]
            newStartVNode = newChildren[++newStartIndex]
        } else if (oldEndVNode.key === newEndVNode.key) {
            // 比较新旧子节点列表的最后一个节点
            patch(oldEndVNode, newEndVNode, container)
            oldEndVNode = oldChildren[--oldEndIndex]
            newEndVNode = newChildren[--newEndIndex]
        } else if (oldStartVNode.key === newEndVNode.key) {
            // 比较旧节点列表的第一个与新节点列表的最后一个
            patch(oldStartVNode, newEndVNode, container)
            insert(oldStartVNode.el, container, oldEndVNode.el.nextSibling)
            oldStartVNode = oldChildren[++oldStartIndex]
            newEndVNode = newChildren[--newEndIndex]
        } else if (oldEndVNode.key === newStartVNode.key) {
            // 比较旧节点列表的最后一个与新节点列表的第一个
            patch(oldEndVNode, newStartVNode, container)
            insert(oldEndVNode.el, container, oldStartVNode.el)
            oldEndVNode = oldChildren[--oldEndIndex]
            newStartVNode = newChildren[++newStartIndex]
        } else {
            // 四个端点都未找到相同key值的节点
            // 则判断当前新列表的第一个节点是否在旧列表中出现过
            const indexInOld = oldChildren.find(
                node => node.key === newStartVNode.key
            )
            if(indexInOld => -1) {
                // 出现过则移动此节点
                const vNodeToMove = oldChildren[indexInOld]
                patch(vNodeToMove, newStartVNode, container)
                insert(vNodeToMove.el, container, oldStartVNode.el)
                // 在旧列表中原位置设置节点为空,后面循环时可直接跳过
                oldChildren[indexInOld] = undefined
            } else {
                // 未出现则新增此节点
                patch(null, newStartVNode, container, oldStartVNode.el)
            }
            newStartVNode = newChildren[++newStartIndex]
        }
    }
    // 双端diff处理完之后判断新旧列表是否有未处理的节点
    if(oldEndIndex < oldStartIndex && newStartIndex <= newEndIndex) {
        // 新列表中存在未处理的节点,需要挂载
        for(let i = newStartIndex; i <= newEndIndex; i++) {
            const anchor = newChildren[newEndIndex + 1] ? newChildren[newEndIndex + 1] : null
            patch(null, newChildren[i], container, anchor)
        }
    } else if (newEndIndex < newStartIndex && oldStartIndex <= oldEndIndex) {
        // 旧列表中存在未处理的节点,则需要卸载
        for(let i = oldStartIndex; i <= oldEndIndex; i++) {
            if(!oldChildren[i]) continue
            unmount(oldChildren[i])
        }
    }
}

从核心逻辑中可得出总结:
1、双端diff设置四个索引值(newStartIndex、newEndIndex、oldStartIndex、oldEndIndex)分别指向新旧节点列表的开始节点、结束节点。
2、满足循环条件下,每轮比较会判断新旧列表的开始节点、结束节点是否存在key值相等的情况,如果有则进行复用,节点相同但是位置不同则需要移动节点,同时,索引值也需要相应移动。比如当newStartIndex对应的节点与oldEndIndex对应节点相同时,则需要将oldEndIndex对应的节点进行移动。还需要修改索引值:newStartIndex递增,oldEndIndex递减。
3、如果新旧列表的开始节点、结束节点不存在相等的情况,则判断新列表当前的开始节点是否在旧列表中存在。不存在说明是新增节点,需要挂载;存在则将此节点对应的真实DOM进行移动。移动之后有一个重点细节 - 将旧列表此节点对应的位置设置为undefined,再次遍历取到此值时依据空值直接跳过。
4、最后,当双端diff循环处理结束后,需要通过四个索引值的关系判断新旧列表中是否还存在未处理的节点,如果新列表中有则需要依次将其挂载,如果旧列表中有则需要依次将其卸载。
5、双端diff算法相比简单diff算法的核心优势在于某些场景下,双端diff可以实现更少的DOM操作。比如:旧列表为p1、p2、p3,新列表更改为p3、p1、p2,如果用简单diff则需要两次DOM操作,分别移动p1、p2;如果用双端diff则只需要移动一次p3节点即可,节点越多,优化性能越明显。

image

从流程图中可以看出:

  1. 首先比较四个端点处节点是否一致,发现都不相等。
  2. 接着取出新列表的头部节点p5,判断旧列表中是否出现此节点,未出现,则挂载p5至真实DOM列表的第一个位置,然后递增newStartIndex为1。
  3. 此时newStartIndex为1,继续判断新旧列表的开始和结束节点是否存在相等的情况,还是未发现相等的情况,接着取新列表的开始节点p3判断是否在旧列表中出现过,发现存在p3,则需要将p3移动位置,移动至oldStartIndex对应位置的前面节点,刚好也是p5之后。同时,处理完p3节点之后,切记将旧列表中p3对应的位置设置为undefined。将newStartIndex继续递增为2。
  4. 此时newStartIndex为2,继续按以上模式比较,发现新列表的开始节点p4与旧列表的结束节点p4刚好一致,因此可复用,接着移动p4节点。之后递增newStartIndex为3,递减oldEndIndex为2。
  5. 此时新旧列表的节点是p1、p6和p1、p2、p3,继续比较,发现新旧列表的开始节点一致,因为位置相同,所以不用移动,patch比较即可。之后递增newStartIndex为4,递增oldStartIndex为1。
  6. 此时新旧列表的节点是p6和p2、p3,继续比较,未发现端点相等的情况,再取出新列表第一个节点p6也不在旧列表中,因此需要挂载。继续递增newStartIndex为5。
  7. 此时newStartIndex大于newEndIndex,不满足循环条件,退出循环。
  8. while循环结束之后,判断新旧列表中是否有未处理的元素,发现新列表中没有,旧列表中有两个,则需要执行卸载。注意:旧列表中如果有节点值为undefined,说明已经处理过,直接跳过即可。

快速DIff

Vue3采用的是快速diff,相比简单diff和双端diff,快速diff借鉴了文本预处理的思路,拥有更快的算法效率。何为文本预处理模式?通俗理解就是,先去掉两个比较对象相同的前置部分和后置部分,从而缩小比较范围。首先来看核心逻辑:

function patchKeyedChildren(n1, n2, container) {
    const newChildren = n2.children
    const oldChildren = n1.children
    let j = 0
    let newVNode = newChildren[j]
    let oldVNode = oldChildren[j]
    // 处理相同的前置节点
    while(oldVNode.key === newVNode.key) {
        patch(oldVNode, newVNode, container)
        j++
        newVNode = newChildren[j]
        oldVNode = oldChildren[j]
    }
    let oldEndIndex = oldChildren.length - 1
    let newEndIndex = newChildren.length - 1
    newVNode = newChildren[newEndIndex]
    oldVNode = oldChildren[oldEndIndex]
    // 处理相同的后置节点
    while(oldVNode.key === newVNode.key) {
        patch(oldVNode, newVNode, container)
        newVNode = newChildren[--newEndIndex]
        oldVNode = oldChildren[--oldEndIndex]
    }
    if(j > oldEndIndex && j <= newEndIndex) {
        // 新节点列表中存在未处理的节点
        const anchorIndex = newEndIndex + 1
        const anchor = anchorIndex < newChildren.length ? newChildren[anchorIndex].el : null
        while(j <= newEndIndex) {
            // 新节点挂载
            patch(null, newChildren[j++], container, anchor)
        }
    } else if (j > newEndIndex && j <= oldEndIndex) {
        // 旧节点列表中存在未处理的节点
        while(j <= oldEndIndex) {
            // 旧节点卸载
            unmount(oldChildren[j++])
        }
    } else {
        // 创建source数组,存储新节点在旧列表中所处索引位置,旧列表中无则存储-1
        const count = newEndIndex - j + 1
        const source = new Array(count)
        source.fill(-1)
        const newStartIndex = j
        const oldStartIndex = j
        const keyIndex = {}
        // 记录节点是否需要移动
        let moved = false
        // 最大索引值
        let maxNewIndexSoFar = 0
        let patched = 0
        for(let i = newStartIndex; i <= newEndIndex; i++) {
            keyIndex[newChildren[i].key] = i
        }
        for(let i = oldStartIndex; i <= oldEndIndex; i++) {
            oldVNode = oldChildren[i]
            // 已更新节点数量不可超过source总长度
            if (patched <= count) {
                const k = keyIndex[oldVNode.key]
                if(typeof k !== 'undefined') {
                    // 此节点在旧列表中存在
                    newVNode = newChildren[k]
                    patch(oldVNode, newVNode, container)
                    patched++
                    source[k - newStartIndex] = i
                    if(k < maxNewIndexSoFar) {
                        moved = true
                    } else {
                        maxNewIndexSoFar = k
                    }
                } else {
                    unmount(oldVNode)
                }
            } else {
                unmount(oldVNode)
            }
        }
        // 有节点需要移动,构建最长递增子序列
        if(moved) {
            // getSequence工具函数获取一个数组的最长递增子序列
            // [2,1,8,3,5] => [1,3,5]、[2,3,5]都是最长递增子序列
            // 注意: getSequence函数返回的是索引值,比如上面例子[1,3,5]对应的是[1,3,4]
            const seq = getSequence(source)
            // s指向最长递增子序列的末尾索引值
            let s = seq.length - 1
            // i指向新节点列表的末尾索引值
            let i = count - 1
            for(i; i >= 0; i--) {
                if(source[i] === -1) {
                    // 挂载新节点
                    const pos = i + newStartIndex
                    const newVNode = newChildren[pos]
                    const nextPos = pos + 1
                    const anchor = nextPos < newChildren.length ? newChildren[nextPos].el : null
                    patch(null, newVNode, container, anchor)
                } else if(seq[s] !== i) {
                    // 最长递增子序列末尾索引值与source数组末尾索引值不一致代表需移动节点
                    const pos = i + newStartIndex
                    const newVNode = newChildren[pos]
                    const nextPos = pos + 1
                    const anchor = nextPos < newChildren.length ? newChildren[nextPos].el : null
                    insert(newVNode.el, container, anchor)
                } else {
                    // 一致则代表无需移动节点,将s递减即可
                    s--
                }
            }
        }
    }
}

从函数中可以总结快速diff的核心:

  1. 首先利用文本预处理的思路,先比较新旧两组节点的前置节点和后置节点,判断是否有相同的节点。
  2. 接着判断新旧列表是否存在其中一组已处理完而另一组未处理完这种情况,如果有则需要将另一组进行挂载或者卸载即可。
  3. 前面两种方式最终未处理完新旧列表时,需进一步处理,以新节点列表剩余节点为基础,构建source数组,存储剩余新节点在旧列表中对应的索引值,在旧列表中无则存储-1。
  4. 创建source的核心流程为:先基于新节点列表创建索引表,接着遍历旧节点列表,判断当前旧节点的key值是否在索引表中,在则可复用节点。同时,需要记录遍历过程中节点在新列表中对应的最大索引值,如果当前节点的索引值小于最大索引值,则需要移动节点。
  5. 移动节点时,基于source数组获取其中的最长递增子序列。需要注意的是最长递增子序列返回的是索引值。从尾部开始遍历source数组,如果source尾部索引与最长递增子序列的尾部索引值一致,则说明位置一致,不用移动节点,反之则需要移动节点。

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.