第 61 本《深入浅出 Vue.js》
简介
本书从源码层面分析了 Vue.js。首先,简要介绍了 Vue.js;然后详细讲解了其内部核心技术“变化侦测”,这里带领大家从 0 到 1 实现一个 简单的“变化侦测”系统;接着详细介绍了虚拟 DOM 技术,其中包括虚拟 DOM 的原理及其 patching 算法;紧接着详细讨论了模板编译技术, 其中包括模板解析器的实现原理、优化器的原理以及代码生成器的原理;最后详细介绍了其整体架构以及提供给我们使用的各种 API 的内部原理, 同时还介绍了生命周期、错误处理、指令系统与模板过滤器等功能的原理
阅读笔记
《深入浅出Vue.js》
刘博文 208个笔记
点评
◆ 2024/10/09 认为好看
这是一本五年前的技术书,对于技术迭代来说算是很老旧了,但丝毫不影响内容的时效性,这本书可以也许成为经典的原因吧
读这本书的有一个原因是因为作者的励志经历影响了我。一个中专毕业生,转行进入了我这个行业,靠着自己的勤奋努力后面进入了国内大厂顶级技术团队,之后一路打怪升级成为阿里巴巴最年轻的技术专家,在阿里的工作成就也是大放异彩。写这本书的时候他才工作六七年,虽然跟我现在工作年限差不多,但比我强太多了。很难想象,当今学历如此重要的社会,他却是那个个例,完成了人生的反转
因为这本书出版时间的关系,我以为内容可能会有些过时,但我发现万变不离其宗,从源码解读上,学习到的技术思想和代码设计能力是永远不会过时。简单来说这本书的内容,我还是比较满意的。对讲解的源码取其精华,深入浅出,对于我这个多年框架开发经验的人来说,也是可以查漏补缺,温故而知新的。技术点基本覆盖了框架的方方面面和日常开发会遇到的问题、最佳实战等,最好可以实际结合源码一起研究分析和日常工作中发现的一些问题,如何通过源码层面发现问题到解决问题,可能收获会更大吧
所以这不仅是一本写的通俗易懂的框架源码解析的书,也是一本构建升级你技术视野和解决问题能力的书籍。随着这个行业的人越来越多,如何区分人与人之间的分水岭,保留自己的竞争力呢?也许,学会前沿框架的设计和实现,掌握底层原理,就是不错的选择吧,早一天学到,早一天受益
序一
◆ 元编程(metaprogramming)
◆ 所谓元编程,简单来说,是指框架的作者使用一种编程语言固有的语言特性,创造出相对新的语言特性,使得最终使用者能够以新的语法和语义来构建他们的应用程序,从而在某些领域开发中获得更好的开发体验。
序二
◆ 2023/12/17发表想法
讲解抽象代码逻辑做法确实应该这样,让读者以一个低姿态来理解高阶东西,期待能学到很多东西
原文:再辅以明白浅显的文字和配图,原本隐晦、抽象、艰深的代码逻辑,瞬间变得明白易懂,让人不时有“原来如此”之叹,继而“拍手称快”!
◆ 2023/12/17发表想法
大部分人的学习和工作应用都处在框架表面上,如何更深层次的学习理解框架底层原理设计与实现,对成为高级人才的成长大有裨益。可能很多人会觉得学习这些太难或者根本用不到,浪费时间之类的理由,这也许会限制你突破自己成长或者让你的能力很难有上升一个台阶吧
原文:随着越来越多“聪明又勤奋”的人加入前端行列,能否洞悉前沿框架的设计和实现将会成为高级人才与普通人才的“分水岭”。
◆ 随着越来越多“聪明又勤奋”的人加入前端行列,能否洞悉前沿框架的设计和实现将会成为高级人才与普通人才的“分水岭”。
◆ 2023/12/17发表想法
我们是学习到底层的东西,以不变应万变,万变不离其宗。期待我们学习后都能更上一层楼😎
原文:“欲穷千里目,更上一层楼。”我衷心希望博文这本用心之作,能够帮助千千万万的Vue.js用户从“知其然”跃进到“知其所以然”的境界。最后想说一句,有心购买本书的读者大可不必纠结于Vue.js的版本问题。因为优秀源代码背后的思想是永恒的、普适的,跟版本没有任何关系。早一天读到,早一天受益,仅此而已。
李松峰
◆ “欲穷千里目,更上一层楼。”我衷心希望博文这本用心之作,能够帮助千千万万的Vue.js用户从“知其然”跃进到“知其所以然”的境界。最后想说一句,有心购买本书的读者大可不必纠结于Vue.js的版本问题。因为优秀源代码背后的思想是永恒的、普适的,跟版本没有任何关系。早一天读到,早一天受益,仅此而已。
李松峰
前言
◆ 这就造成了一个很普遍的现象,大部分前端工程师对框架以及第三方周边插件的关注程度越来越高,甚至把自己全部的关注点都放在了框架上
◆ 2023/12/17发表想法
技术解决方案确实有很多种,也很难想到或找到最好的,受限于个人的技术水平能力、技术视野、业务场景适配度、技术追求等等,但我们应该有所调研,深究之后的权衡取舍,而不是一味的处于完成,做完解决就好的状态,一开始确实很难,难道就不做了吗
原文:所有技术解决方案的终极目标都是在解决问题,都是先有问题,然后有解决方案。解决方案可能并不完美,也可能有很多种。
◆ 所有技术解决方案的终极目标都是在解决问题,都是先有问题,然后有解决方案。解决方案可能并不完美,也可能有很多种。
◆ Vue.js也是如此,它解决了什么问题?如何解决的?解决问题的同时都做了哪些权衡和取舍?
第 1 章 Vue.js简介
◆ 2013年7月28日
◆ 2015年10月26日这天,Vue.js终于迎来了1.0.0版本的发布。
◆ “The fate of destruction is also the joy of rebirth.” 翻译成中文是: 毁灭的命运,也是重生的喜悦。
◆ Your effort to remain what you are is what limits you.” 翻译成中文是: 保持本色的努力,也在限制你的发展
◆ 所谓渐进式框架,就是把框架分层。 最核心的部分是视图层渲染,然后往外是组件机制,在这个基础上再加入路由机制,再加入状态管理,最外层是构建工具,如图1-1所示。 
◆ 2023/12/17发表想法
突然有点后悔读这本书晚了,原来渐进式框架是这个意思,之前理解的太表面了
原文:最核心的部分是视图层渲染,然后往外是组件机制,在这个基础上再加入路由机制,再加入状态管理,最外层是构建工具,如图1-1所示
◆ 所谓分层,就是说你既可以只用最核心的视图层渲染功能来快速开发一些需求,也可以使用一整套全家桶来开发大型应用。Vue.js有足够的灵活性来适应不同的需求,所以你可以根据自己的需求选择不同的层级
◆ Vue.js 2.0与Vue.js 1.0之间内部变化非常大,整个渲染层都重写了,但API层面的变化却很小。
◆ Vue.js引入虚拟DOM是有原因的。事实上,并不是引入虚拟DOM后,渲染速度变快了。准确地说,应该是80% 的场景下变得更快了,而剩下的20% 反而变慢了。
◆ 2023/12/18发表想法
看看预测到了😏
原文:可能你在读这行文字的时候,Vue.js已经挤进前三了。
◆ Nuxt、Quasar Framework、Element、iView、Muse-UI、Vux、Vuetify、Vue Material
第一篇 变化侦测
◆ Vue.js最独特的特性之一是看起来并不显眼的响应式系统
◆ 从状态生成DOM,再输出到用户界面显示的一整套流程叫作渲染
第 2 章 Object的变化侦测
◆ Object和Array的变化侦测采用不同的处理方式
◆ Vue.js会自动通过状态生成DOM,并将其输出到页面上显示出来,这个过程叫渲染
◆ Vue.js的渲染过程是声明式的,我们通过模板来描述状态与DOM之间的映射关系。
◆ 从Vue.js 2.0开始,它引入了虚拟DOM,将粒度调整为中等粒度,即一个状态所绑定的依赖不再是具体的DOM节点,而是一个组件。这样状态变化后,会通知到组件,组件内部再使用虚拟DOM进行比对。这可以大大降低依赖数量,从而降低依赖追踪所消耗的内存。
◆ 有两种方法可以侦测到变化:使用Object.defineProperty
和ES6的Proxy。
◆ 我们之所以要观察数据,其目的是当数据的属性发生变化时,可以通知那些曾经使用了该数据的地方。
◆ 在getter中收集依赖,在setter中触发依赖。
◆ 我们要通知用到数据的地方,而使用这个数据的地方有很多,而且类型还不一样,既有可能是模板,也有可能是用户写的一个watch,这时需要抽象出一个能集中处理这些情况的类。然后,我们在依赖收集阶段只收集这个封装好的类的实例进来,通知也只通知它一个。接着,它再负责通知其他地方。
◆ Watcher是一个中介的角色,数据发生变化时通知它,然后它再通知其他地方。
◆ Vue.js通过Object.defineProperty
来将对象的key转换成getter/setter的形式来追踪变化,但getter/setter只能追踪一个数据是否被修改,无法追踪新增属性和删除属性,所以才会导致上面例子中提到的问题。
第 3 章 Array的变化侦测
◆ 正因为我们可以通过Array原型上的方法来改变数组的内容,所以Object那种通过getter/setter的实现方式就行不通了。
◆ 有了拦截器之后,想要让它生效,就需要使用它去覆盖Array.prototype
。但是我们又不能直接覆盖,因为这样会污染全局的Array,这并不是我们希望看到的结果。我们希望拦截操作只针对那些被侦测了变化的数据生效,也就是说希望拦截器只覆盖那些响应式数组的原型。
◆ 将一个数据转换成响应式的,需要通过Observer,所以我们只需要在Observer中使用拦截器覆盖那些即将被转换成响应式Array类型数据的原型
◆ __proto__
其实是Object.getPrototypeOf
和Object.setPrototypeOf
的早期实现
◆ Vue的做法非常粗暴,如果不能使用 __proto__
,就直接将arrayMethods身上的这些方法设置到被侦测的数组上
◆ 当用户使用这些方法时,其实执行的并不是浏览器原生提供的Array.prototype
上的方法,而是拦截器中提供的方法。
◆ 因为当访问一个对象的方法时,只有其自身不存在这个方法,才会去它的原型上找这个方法。
◆ 因为我们之所以创建拦截器,本质上是为了得到一种能力,一种当数组的内容发生变化时得到通知的能力。
◆ Array在getter中收集依赖,在拦截器中触发依赖。
◆ 我们之所以将依赖保存在Observer实例上,是因为在getter中可以访问到Observer实例,同时在Array拦截器中也可以访问到Observer实例。
◆ Array拦截器是对原型的一种封装,所以可以在拦截器中访问到this(当前正在被操作的数组)
◆ 数组数据的 __ob__
属性拿到Observer实例,然后就可以拿到 __ob__
上的dep
◆ 所有被侦测了变化的数据身上都会有一个 __ob__
属性来表示它们是响应式的
◆ 只要能获取新增的元素并使用Observer来侦测它们就行。
◆ Array追踪变化的方式和Object不一样。因为它是通过方法来改变内容的,所以我们通过创建拦截器去覆盖数组原型的方式来追踪变化。
◆ 为了不污染全局Array.prototype
,我们在Observer中只针对那些需要侦测变化的数组使用 __proto__
来覆盖原型方法,但 __proto__
在ES6之前并不是标准属性,不是所有浏览器都支持它。因此,针对不支持 proto 属性的浏览器,我们直接循环拦截器,把拦截器中的方法直接设置到数组身上来拦截Array.prototype上的原生方法。
第 4 章 变化侦测相关的API实现原理
◆ 用于观察一个表达式或computed函数在Vue.js实例上的变化
◆ vm.$watch其实是对Watcher的一种封装
◆ 一定要在window.target = undefined
之前去触发子值的收集依赖逻辑,这样才能保证子集收集的依赖是当前这个Watcher
◆ 而 _traverse函数其实是一个递归操作,所以这个value的子值也会触发同样的逻辑,这样就可以实现通过deep参数来监听所有子值的变化。
第 5 章 虚拟DOM简介
◆ 三大主流框架Vue.js、Angular和React都是声明式操作DOM。我们通过描述状态和DOM之间的映射关系是怎样的,就可以将状态渲染成视图
◆ 说明事实上,任何应用都有状态,并不是只有使用了现代比较流行的框架之后才有状态。只不过现代框架揭露了一个事实,那就是我们的关注点应该聚焦在状态维护上,而DOM操作其实是可以省略掉的,所以才会给我们营造一种错觉,好像只有使用了框架之后的应用才会有状态。
◆ 在Angular中就是脏检查的流程,React中使用虚拟DOM,Vue.js 1.0通过细粒度的绑定
◆ 虚拟DOM的解决方式是通过状态生成一个虚拟节点树,然后使用虚拟节点树进行渲染。在渲染之前,会使用新生成的虚拟节点树和上一次生成的虚拟节点树进行对比,只渲染不同的部分。
◆ 虚拟节点树其实是由组件树建立起来的整个虚拟节点(Virtual Node,也经常简写为vnode)树
◆ 在Vue.js中,当状态发生变化时,它在一定程度上知道哪些节点使用了这个状态,从而对这些节点进行更新操作,根本不需要比对。事实上,在Vue.js 1.0的时候就是这样实现的
◆ 因此,Vue.js 2.0开始选择了一个中等粒度的解决方案,那就是引入了虚拟DOM。组件级别是一个watcher实例,就是说即便一个组件内有10个节点使用了某个状态,但其实也只有一个watcher在观察这个状态的变化。所以当这个状态发生变化时,只能通知到组件,然后组件内部通过虚拟DOM去进行比对与渲染。这是一个比较折中的方案。
◆ Vue.js通过编译将模板转换成渲染函数(render),执行渲染函数就可以得到一个虚拟节点树,使用这个虚拟节点树就可以渲染页面
◆ 虚拟DOM是将状态映射成视图的众多解决方案中的一种,它的运作原理是使用状态生成虚拟节点,然后使用虚拟节点渲染视图
◆ 之所以需要先使用状态生成虚拟节点,是因为如果直接用状态生成真实DOM,会有一定程度的性能浪费。而先创建虚拟节点再渲染视图,就可以将虚拟节点缓存,然后使用新创建的虚拟节点和上一次渲染时缓存的虚拟节点进行对比,然后根据对比结果只更新需要更新的真实DOM节点,从而避免不必要的DOM操作,节省一定的性能开销
第 6 章 VNode
◆ 在Vue.js中存在一个VNode类,使用它可以实例化不同类型的vnode实例,而不同类型的vnode实例各自表示不同类型的DOM元素
◆ 例如,DOM元素有元素节点、文本节点和注释节点等,vnode实例也会对应着有元素节点、文本节点和注释节点等
◆ vnode只是一个名字,本质上其实是JavaScript中一个普通的对象,是从VNode类实例化的对象。我们用这个JavaScript对象来描述一个真实DOM元素的话,那么该DOM元素上的所有属性在VNode这个对象上都存在对应的属性
◆ vnode可以理解成节点描述对象,它描述了应该怎样去创建真实的DOM节点
◆ vnode和视图是一一对应的。我们可以把vnode理解成JavaScript对象版本的DOM元素
◆ 由于每次渲染视图时都是先创建vnode,然后使用它创建真实DOM插入到页面中,所以可以将上一次渲染视图时所创建的vnode缓存起来,之后每当需要重新渲染视图时,将新创建的vnode和上一次缓存的vnode进行对比,查看它们之间有哪些不一样的地方,找出这些不一样的地方并基于此去修改真实的DOM
◆ ● 注释节点 ● 文本节点 ● 元素节点 ● 组件节点 ● 函数式组件 ● 克隆节点
◆ 它的作用是优化静态节点和插槽节点(slot node)。
◆ 以静态节点为例,当组件内的某个状态发生变化后,当前组件会通过虚拟DOM重新渲染视图,静态节点因为它的内容不会改变,所以除了首次渲染需要执行渲染函数获取vnode之外,后续更新不需要执行渲染函数重新生成vnode。因此,这时就会使用创建克隆节点的方法将vnode克隆一份,使用克隆节点进行渲染。这样就不需要重新执行渲染函数生成新的静态节点的vnode,从而提升一定程度的性能
◆ 克隆节点和被克隆节点之间的唯一区别是isCloned属性,克隆节点的isCloned为true,被克隆的原始节点的isCloned为false
第 7 章 patch
◆ patch也可以叫作patching算法,通过它渲染真实DOM时,并不是暴力覆盖原有DOM,而是比对新旧两个vnode之间有哪些不同,然后根据对比结果找出需要更新的节点进行更新。这一点从名字就可以看出,patch本身就有补丁、修补等意思,其实际作用是在现有DOM上进行修改来实现更新视图的目的
◆ DOM操作的执行速度远不如JavaScript的运算速度快。因此,把大量的DOM操作搬运到JavaScript中,使用patching算法来计算出真正需要更新的节点,最大限度地减少DOM操作,从而显著提升性能
◆ 这本质上其实是使用JavaScript的运算成本来替换DOM操作的执行成本,而JavaScript的运算速度要比DOM快很多,这样做很划算,所以才会有虚拟DOM。
◆ patch的目的其实是修改DOM节点,也可以理解为渲染视图
◆ 之所以需要通过算法来比对两个节点之间的差异,并针对不同的节点进行更新,主要是为了性能考虑。
◆ 当oldVnode不存在而vnode存在时,就需要使用vnode生成真实的DOM元素并将其插入到视图当中去
◆ 当vnode和oldVnode完全不是同一个节点时,需要使用vnode生成真实的DOM元素并将其插入到视图当中。
◆ 替换过程是将新创建的DOM节点插入到旧节点的旁边,然后再将旧节点删除,从而完成替换过程。
◆ 当新旧两个节点是相同的节点时,我们需要对这两个节点进行比较细致的比对,然后对oldVnode在视图中所对应的真实节点进行更新。
◆ 只有三种类型的节点会被创建并插入到DOM中:元素节点、注释节点和文本节点。
◆ 而跨平台渲染的本质是在设计框架的时候,要让框架的渲染机制和DOM解耦。只要把框架更新DOM时的节点操作进行封装,就可以实现跨平台渲染,在不同平台下调用节点的操作。
◆ 我们把这些平台下节点操作的封装看成渲染引擎,那么将这些渲染引擎所提供的节点操作的API和框架的运行时对接一下,就可以实现将框架中的代码进行原生渲染的目的
◆ 在更新节点时,首先需要判断新旧两个虚拟节点是否是静态节点,如果是,就不需要进行更新操作,可以直接跳过更新节点的过程。
◆ 静态节点指的是那些一旦渲染到界面上之后,无论日后状态如何变化,都不会发生任何变化的节点。
◆ 当新旧两个虚拟节点(vnode和oldVnode)不是静态节点,并且有不同的属性时,要以新虚拟节点(vnode)为准来更新视图。根据新节点(vnode)是否有text属性,更新节点可以分为两种不同的情况。
◆ 简单来说,就是当新虚拟节点有文本属性,并且和旧虚拟节点的文本属性不一样时,我们可以直接把视图中的真实DOM节点的内容改成新虚拟节点的文本。
◆ 如果新创建的虚拟节点没有text属性,那么它就是一个元素节点。元素节点通常会有子节点,也就是children属性,但也有可能没有子节点,所以存在两种不同的情况
◆ 当newChildren中的所有节点都被循环了一遍后,也就是循环结束后,如果oldChildren中还有剩余的没有被处理的节点,那么这些节点就是被废弃、需要删除的节点。
◆ 只需要尝试使用相同位置的两个节点来比对是否是同一个节点:如果恰巧是同一个节点,直接就可以进入更新节点的操作;如果尝试失败了,再用循环的方式来查找节点。
◆ 由于前面我们的优化策略,节点是有可能会从后面对比的,对比成功就会进行更新处理,也就是说,我们的循环体内的逻辑由于优化策略,不再是只处理所有未处理过的节点的第一个,而是有可能会处理最后一个,这种情况下就不能从前向后循环,而应该是从两边向中间循环
◆ 因为循环的目的是找出差异,针对差异来做对应的操作,但现在直接就可以判断出差异,所以就不需要再循环对比差异了
◆ 有一部分逻辑是建立key与index索引的对应关系。这部分内容前面并没有提到。在Vue.js的模板中,渲染列表时可以为节点设置一个属性key,这个属性可以标示一个节点的唯一ID。Vue.js官方非常推荐在渲染列表时使用这个属性,我也非常推荐使用它,为什么呢?前面提到过,在更新子节点时,需要在oldChildren中循环去找一个节点。但是如果我们在模板中渲染列表时,为子节点设置了属性key,那么在图7-26中建立key与index索引的对应关系时,就生成了一个key对应着一个节点下标这样一个对象。也就是说,如果在节点上设置了属性key,那么在oldChildren中找相同节点时,可以直接通过key拿到下标,从而获取节点。这样,我们根本不需要通过循环来查找节点
第三篇 模板编译原理
◆ 渲染函数是创建HTML最原始的方法。模板最终会通过编译转换成渲染函数,渲染函数执行后,会得到一份vnode用于虚拟DOM渲染。所以模板编译其实是配合虚拟DOM进行渲染,这也是本书先介绍虚拟DOM后介绍模板编译的原因
第 8 章 模板编译
◆ 详细介绍了虚拟DOM,其中介绍的大部分知识都是关于虚拟DOM拿到vnode后所做的事,而模板编译所介绍的内容是如何让虚拟DOM拿到vnode
◆ Vue.js
提供了模板语法,允许我们声明式地描述状态和DOM之间的绑定关系,然后通过模板来生成真实DOM并将其呈现在用户界面上
◆ 在底层实现上,Vue.js
会将模板编译成虚拟DOM渲染函数。当应用内部的状态发生变化时,Vue.js
可以结合响应式系统,聪明地找出最小数量的组件进行重新渲染以及最少量地进行DOM操作
◆ 模板编译的主要目标就是生成渲染函数,如图8-2所示。而渲染函数的作用是每次执行它,它就会使用当前最新的状态生成一份新的vnode,然后使用这个vnode进行渲染
◆ 将模板编译成渲染函数可以分两个步骤,先将模板解析成AST(Abstract Syntax Tree,抽象语法树),然后再使用AST生成渲染函数
◆ 但是由于静态节点不需要总是重新渲染,所以在生成AST之后、生成渲染函数之前这个阶段,需要做一个操作,那就是遍历一遍AST,给所有静态节点做一个标记,这样在虚拟DOM中更新节点时,如果发现节点有这个标记,就不会重新渲染它
◆ 在解析器内部,分成了很多小解析器,其中包括过滤器解析器、文本解析器和HTML解析器。然后通过一条主线将这些解析器组装在一起
◆ 过滤器解析器的作用就是用来解析过滤器的
◆ 文本解析器的主要作用是用来解析带变量的文本,什么是带变量的文本?下面这段代码中的name就是变量,而这样的文本叫作带变量的文本
◆ 最后也是最重要的是HTML解析器,它是解析器中最核心的模块,它的作用就是解析模板,每当解析到HTML标签的开始位置、结束位置、文本或者注释时,都会触发钩子函数,然后将相关信息通过参数传递出来
◆ 这个AST其实和vnode有点类似,都是使用JavaScript中的对象来表示节点
◆ 当HTML解析器把所有模板都解析完毕后,AST也就生成好了
◆ 当AST中的静态子树被打上标记后,每次重新渲染时,就不需要为打上标记的静态节点创建新的虚拟节点,而是直接克隆已存在的虚拟节点。在虚拟DOM的更新操作中,如果发现两个节点是同一个节点,正常情况下会对这两个节点进行更新,但是如果这两个节点是静态节点,则可以直接跳过更新节点的流程
◆ 优化器的主要作用是避免一些无用功来提升性能。因为静态节点除了首次渲染,后续不需要任何重新渲染操作
◆ 这样一个代码字符串最终导出到外界使用时,会将代码字符串放到函数里,这个函数叫作渲染函数
◆ 渲染函数的作用是创建vnode。渲染函数之所以可以生成vnode,是因为代码字符串中会有很多函数调用(例如,上面生成的代码字符串中有两个函数调用 _c和 _v),这些函数是虚拟DOM提供的创建vnode的方法。vnode有很多种类型,不同的类型对应不同的创建方法,所以代码字符串中的 _c和 _v其实都是创建vnode的方法,只是创建的vnode的类型不同。例如,_c可以创建元素类型的vnode,而 _v可以创建文本类型的vnode
第 9 章 解析器
◆ 其实AST并不是什么很神奇的东西,不要被它的名字吓倒。它只是用JavaScript中的对象来描述一个节点,一个对象表示一个节点,对象中的属性用来保存节点所需的各种数据
◆ 解析器内部也分了好几个子解析器,比如HTML解析器、文本解析器以及过滤器解析器,其中最主要的是HTML解析器。顾名思义,HTML解析器的作用是解析HTML,它在解析HTML的过程中会不断触发各种钩子函数。这些钩子函数包括开始标签钩子函数、结束标签钩子函数、文本钩子函数以及注释钩子函数
◆ 当HTML解析器不再触发钩子函数时,就说明所有模板都解析完毕,所有类型的节点都在钩子函数中构建完成,即AST构建完成
◆ 构建AST层级关系其实非常简单,我们只需要维护一个栈(stack)即可,用栈来记录层级关系,这个层级关系也可以理解为DOM的深度
◆ 解析HTML模板的过程就是循环的过程,简单来说就是用HTML模板字符串来循环,每轮循环都从HTML模板中截取一小段字符串,然后重复以上过程,直到HTML模板被截成一个空字符串时结束循环,解析完毕
◆ 就是每解析到开始标签,就向栈中推进去一个;每解析到标签结束,就弹出来一个
◆ HTML解析器中的栈还有另一个作用,它可以检测出HTML标签是否正确闭合
◆ 文本其实分两种类型,一种是纯文本,另一种是带变量的文本
◆ 第一步要做的事情就是使用正则表达式来判断文本是否为带变量的文本,也就是检查文本中是否包含 这样的语法。如果是纯文本,则直接返回undefined;如果是带变量的文本,再进行二次加工
◆ 使用正则表达式匹配出文本中的变量,先把变量左边的文本添加到数组中,然后把变量改成 _s(x)这样的形式也添加到数组中。如果变量后面还有变量,则重复以上动作,直到所有变量都添加到数组中。如果最后一个变量的后面有文本,就将它添加到数组中
第 10 章 优化器
◆ 解析器的作用是将HTML模板解析成AST,而优化器的作用是在AST中找出静态子树并打上标记。静态子树指的是那些在AST中永远都不会发生变化的节点。例如,一个纯文本节点就是静态子树,而带变量的文本节点就不是静态子树,因为它会随着变量的变化而变化
◆ 标记静态子树有两点好处:● 每次重新渲染时,不需要为静态子树创建新节点;● 在虚拟DOM中打补丁(patching)的过程可以跳过。
◆ 优化器的内部实现主要分为两个步骤:(1) 在AST中找出所有静态节点并打上标记;(2) 在AST中找出所有静态根节点并打上标记。
◆ 找出所有静态子节点并不难,我们只需要从根节点开始,先判断根节点是不是静态根节点,再用相同的方式处理子节点,接着用同样的方式去处理子节点的子节点,直到所有节点都被处理之后程序结束,这个过程叫作递归
◆ 但有两种情况比较特殊:一种是如果一个静态根节点的子节点只有一个文本节点,那么不会将它标记成静态根节点,即便它也属于静态根节点;另一种是如果找到的静态根节点是一个没有子节点的静态节点,那么也不会将它标记为静态根节点。因为这两种情况下,优化成本大于收益
第 11 章 代码生成器
◆ 代码字符串可以被包装在函数中执行,这个函数就是我们通常所说的渲染函数
第四篇 整体流程
◆ 2024/10/07发表想法
这段总结非常到位,它强调了深入研究框架源码的重要性。对于开发者而言,仅仅满足于使用层面是远远不够的,那样只会让我们停留在“知其然,不知其所以然”的阶段。更重要的是,我们应该深入了解和探索框架的底层原理,这不仅能够加深我们对框架的理解和运用,还能让我们清楚地认识到框架的优势和局限。 此外,这种深入的探索还能显著提升我们解决复杂问题的能力,并最大限度地发挥技术在业务中的作用。这正是技术开发者应当秉持的工匠精神——不断追求卓越,精益求精。
原文:如果熟悉所使用功能的内部实现,那么当业务功能出现bug时,我们就可以快速、精准地定位问题所在,知道问题是由Vue.js的某些特性导致的,还是代码逻辑有问题。并且在开发复杂功能时,我们可以清楚地知道Vue.js能提供的能力的边界在哪里,这样就可以最大限度地发挥它的价值
◆ 如果熟悉所使用功能的内部实现,那么当业务功能出现bug时,我们就可以快速、精准地定位问题所在,知道问题是由Vue.js的某些特性导致的,还是代码逻辑有问题。并且在开发复杂功能时,我们可以清楚地知道Vue.js能提供的能力的边界在哪里,这样就可以最大限度地发挥它的价值
第 12 章 架构设计与项目结构
◆ 完整版:构建后的文件同时包含编译器和运行时
◆ 编译器:负责将模板字符串编译成JavaScript渲染函数
◆ 运行时:负责创建Vue.js
实例,渲染视图和使用虚拟DOM实现重新渲染,基本上包含除编译器外的所有部分
◆ UMD:UMD版本的文件可以通过 <script>
标签直接在浏览器中使用
◆ CommonJS:CommonJS版本用来配合较旧的打包工具,比如Browserify或webpack 1,这些打包工具的默认文件(pkg.main)只包含运行时的CommonJS版本(vue.runtime.common.js
◆ ES Module:ES Module版本用来配合现代打包工具,比如webpack 2或Rollup,这些打包工具的默认文件(pkg.module)只包含运行时的ES Module版本(vue.runtime.esm.js
◆ 当使用vue-loader或vueify的时候,*.vue文件内部的模板会在构建时预编译成JavaScript。所以,最终打包完成的文件实际上是不需要编译器的,只需要引入运行时版本即可
◆ 对于UMD版本来说,开发环境和生产环境二者的模式是硬编码的:开发环境下使用未压缩的代码,生产环境下使用压缩后的代码
第 13 章 实例方法与全局API的实现原理
◆ 由于vm.$off
的第一个参数event支持数组,所以接下来需要处理event参数为数组的情况,其处理逻辑很简单,只需要将数组遍历一遍,然后数组中的每一项依次调用vm.$off
即可
◆ 接下来处理最后一种情况:如果同时提供了事件与回调,那么只移除这个回调的监听器。实现这个功能并不复杂,只需要使用参数中提供的事件名从vm._events上取出事件列表,然后从列表中找到与参数中提供的回调函数相同的那个函数,并将它从列表中移除
◆ 在vm.$once
中调用vm.$on
来实现监听自定义事件的功能,当自定义事件触发后会执行拦截器,将监听器从事件列表中移除
◆ 但是要注意on.fn = fn
这行代码。前面我们介绍vm.$off
时提到,在移除监听器时,需要将用户提供的监听器函数与列表中的监听器函数进行对比,相同部分会被移除,这导致当我们使用拦截器代替监听器注入到事件列表中时,拦截器和用户提供的函数是不相同的,此时用户使用vm.$off
来移除事件监听器,移除操作会失效。这个问题的解决方案是将用户提供的原始监听器保存到拦截器的fn属性中,当vm.$off方法遍历事件监听器列表时,同时会检查监听器和监听器的fn属性是否与用户提供的监听器函数相同,只要有一个相同,就说明需要被移除的监听器被找到了,将被找到的拦截器从事件监听器列表中移除即可
◆ vm.$forceUpdate()
的作用是迫使Vue.js实例重新渲染。注意它仅仅影响实例本身以及插入插槽内容的子组件,而不是所有子组件
◆ 在Vue.js中,当状态发生变化时,watcher会得到通知,然后触发虚拟DOM的渲染流程。而watcher触发渲染这个操作并不是同步的,而是异步的。Vue.js中有一个队列,每当需要渲染时,会将watcher推送到这个队列中,在下一次事件循环中再让watcher触发渲染的流程
◆ 如果在同一轮事件循环中有两个数据发生了变化,那么组件的watcher会收到两份通知,从而进行两次渲染。事实上,并不需要渲染两次,虚拟DOM会对整个组件进行渲染,所以只需要等所有状态都修改完毕后,一次性将整个组件的DOM渲染到最新即可
◆ Vue.js的实现方式是将收到通知的watcher实例添加到队列中缓存起来,并且在添加到队列之前检查其中是否已经存在相同的watcher,只有不存在时,才将watcher实例添加到队列中。然后在下一次事件循环(event loop)中,Vue.js会让队列中的watcher触发渲染流程并清空队列。这样就可以保证即便在同一事件循环中有两个状态发生改变,watcher最后也只执行一次渲染流程
◆ 异步任务有两种类型:微任务(microtask)和宏任务(macrotask)。不同类型的任务会被分配到不同的任务队列中。当执行栈中的所有任务都执行完毕后,会去检查微任务队列中是否有事件存在,如果存在,则会依次执行微任务队列中事件对应的回调,直到为空。然后去宏任务队列中取出一个事件,把对应的回调加入当前执行栈,当执行栈中的所有任务都执行完毕后,检查微任务队列中是否有事件存在。无限重复此过程,就形成了一个无限循环,这个循环就叫作事件循环
◆ 属于微任务的事件包括但不限于以下几种:● Promise.then● MutationObserver● Object.observe● process.nextTick
属于宏任务的事件包括但不限于以下几种:● setTimeout● setInterval● setImmediate● MessageChannel● requestAnimationFrame● I/O● UI交互事件
◆ 当我们执行一个方法时,JavaScript会生成一个与这个方法对应的执行环境(context),又叫执行上下文。这个执行环境中有这个方法的私有作用域、上层作用域的指向、方法的参数、私有作用域中定义的变量以及this对象。这个执行环境会被添加到一个栈中,这个栈就是执行栈
◆ ,“下次DOM更新周期”的意思其实是下次微任务执行时更新DOM。而vm.$nextTick其实是将回调添加到微任务中。只有在特殊情况下才会降级成宏任务,默认会添加到微任务中
◆ 注意 事实上,更新DOM的回调也是使用vm.$nextTick来注册到微任务中的
◆ 状态通过Observer转换成响应式之后,每当触发getter时,会从全局的某个属性中获取watcher实例并将它添加到数据的依赖列表中。watcher在读取数据之前,会先将自己设置到全局的某个属性中。而数据被读取会触发getter,所以会将watcher收集到依赖列表中。收集好依赖后,当数据发生变化时,会向依赖列表中的watcher发送通知。由于Watcher的第二个参数支持函数,所以当watcher执行函数时,函数中所读取的数据都将会触发getter去全局找到watcher并将其收集到函数的依赖列表中。也就是说,函数中读取的所有数据都将被watcher观察。这些数据中的任何一个发生变化时,watcher都将得到通知
◆ 全局API和实例方法不同,后者是在Vue的原型上挂载方法,也就是在Vue.prototype
上挂载方法,而前者是直接在Vue上挂载方法
◆ Vue.js
的实例方法和全局API的实现原理。它们的区别在于:实例方法是Vue.prototype
上的方法,而全局API是Vue.js
上的方法
第 14 章 生命周期
◆ Vue.js
实例的生命周期,可以分为4个阶段:初始化阶段、模板编译阶段、挂载阶段、卸载阶段
◆ 初始化阶段。这个阶段的主要目的是在Vue.js
实例上初始化一些属性、事件以及响应式数据,如props、methods、data、computed、watch、provide和inject
等。
◆ 模板编译阶段。这个阶段的主要目的是将模板编译为渲染函数,只存在于完整版中
◆ 在这个阶段,Vue.js
会将其实例挂载到DOM元素上,通俗地讲,就是将模板渲染到指定的DOM元素中。在挂载的过程中,Vue.js
会开启Watcher
来持续追踪依赖的变化
◆ 在已挂载状态下,Vue.js
仍会持续追踪状态的变化。当数据(状态)发生变化时,Watcher
会通知虚拟DOM重新渲染视图,并且会在渲染视图前触发beforeUpdate
钩子函数,渲染完毕后触发updated
钩子函数
◆ 应用调用vm.$destroy
方法后,Vue.js
的生命周期会进入卸载阶段。在这个阶段,Vue.js
会将自身从父组件中删除,取消实例上所有依赖的追踪并且移除所有的事件监听器
◆ new Vue()
被调用时发生了什么,我们需要知道在Vue构造函数中实现了哪些逻辑。前面介绍过,当new Vue()
被调用时,会首先进行一些初始化操作,然后进入模板编译阶段,最后进入挂载阶段
◆ 图14-2给出了 _init方法的内部流程图,我们会在后面的章节中依次介绍每一项初始化的详细实现原理。[插图]
◆ 所有生命周期钩子的函数名:● beforeCreate● created● beforeMount● mounted● beforeUpdate● updated● beforeDestroy● destroyed● activated● deactivated● errorCaptured
◆ errorCaptured
钩子函数的作用是捕获来自子孙组件的错误,此钩子函数会收到三个参数:错误对象、发生错误的组件实例以及一个包含错误来源信息的字符串。然后此钩子函数可以返回false,阻止该错误继续向上传播
◆ 注意以 $
开头的属性是提供给用户使用的外部属性,以 _ 开头的属性是提供给内部使用的内部属性
◆ 说明provide和inject主要为高阶插件/组件库提供用例,并不推荐直接用于程序代码中
◆ inject和provide选项需要一起使用,它们允许祖先组件向其所有子孙后代注入依赖,并在其上下游关系成立的时间里始终生效(不论组件层次有多深
◆ 说明可用的注入内容指的是祖先组件通过provide注入了内容,子孙组件可以通过inject获取祖先组件注入的内容
◆ 读出用户在当前组件中设置的inject的key,然后循环key,将每一个key从当前组件起,不断向父组件查找是否有值,找到了就停止循环,最终将所有key对应的值一起返回即可
◆ 注意当使用provide注入内容时,其实是将内容注入到当前组件实例的 _provide中,所以inject可以从父组件实例的 _provide中获取注入的内容
◆ 初始化状态可以分为5个子项,分别是初始化props、初始化methods、初始化data、初始化computed
和初始化watch
◆ props的实现原理大体上是这样的:父组件提供数据,子组件通过props字段选择自己需要哪些内容,Vue.js
内部通过子组件的props选项将需要的数据筛选出来之后添加到子组件的上下文中
◆ 说明props可以通过数组指定需要哪些属性。但在Vue.js
内部,数组格式的props将被规格化成对象格式
◆ vm:Vue.js
实例上下文,this的别名
◆ 简单来说,computed
是定义在vm上的一个特殊的getter方法。之所以说特殊,是因为在vm上定义getter方法时,get并不是用户提供的函数,而是Vue.js内部的一个代理函数。在代理函数中可以结合Watcher
实现缓存与收集依赖等功能
◆ 这个getter方法被触发时会做两件事。● 计算当前计算属性的值,此时会使用Watcher
去观察计算属性中用到的所有其他数据的变化。同时将计算属性的Watcher
的dirty属性设置为false,这样再次读取计算属性时将不再重新计算,除非计算属性所依赖的数据发生了变化。● 当计算属性中用到的数据发生变化时,将得到通知从而进行重新渲染操作
◆ 说明计算属性的一个特点是有缓存。计算属性函数所依赖的数据在没有发生变化的情况下,会反复读取计算属性,而计算属性函数并不会反复执行
◆ 说明Object.create(null)
创建出来的对象没有原型,它不存在 __proto__
属性
◆ new Vue()
被执行时Vue.js
的背后发生了什么
第 16 章 过滤器的奥秘
◆ 过滤器的原理是:在编译阶段将过滤器编译成函数调用,串联的过滤器编译后是一个嵌套的函数调用,前一个过滤器函数的执行结果是后一个过滤器函数的参数
第 17 章 最佳实践
◆ 在项目中使用统一的风格规范,可以在绝大多数工程中改善代码的可读性和工作者的开发体验,同时可以回避一些常见的错误和小纠结,避免一些反模式
◆ vue-router
提供了导航守卫beforeRouteUpdate
,该守卫在当前路由改变且组件被复用时调用,所以可以在组件内定义路由导航守卫来解决这个问题
◆ 这种做法非常取巧,非常“暴力”,但非常有效。它本质上是利用虚拟DOM在渲染时通过key来对比两个节点是否相同的原理。通过给router-view
组件设置key,可以使每次切换路由时的key都不一样,让虚拟DOM认为router-view组件是一个新节点,从而先销毁组件,然后再重新创建新组件。即使是相同的组件,但是如果url变了,key就变了,Vue.js
就会重新创建这个组件
◆ 这种方式的坏处很明显,每次切换路由组件时都会被销毁并且重新创建,非常浪费性能。其优点更明显,简单粗暴,改动小。为router-view
组件设置了key之后,立刻就可以看到问题被解决了
◆ 通常,在项目开发中,业务组件会使用Vuex维护状态,使用不同组件统一操作Vuex中的状态。这样不论是父子组件间的通信还是兄弟组件间的通信,都很容易。对于通用组件,我会使用props以及事件进行父子组件间的通信(通用组件不需要兄弟组件间的通信)。这样做是因为通用组件会拿到各个业务组件中使用,它要与业务解耦,所以需要使用props获取状态
◆ 在Vue.js
中,可以通过scoped
特性或CSS Modules
(一个基于class的类似BEM的策略)来设置组件样式作用域
◆ 在scoped
样式中,类选择器比元素选择器更好,因为大量使用元素选择器是很慢的
◆ 问题在于,大量的元素和特性组合的选择器(比如 button[data-v-f3f3eg9]
)会比类和特性组合的选择器慢,所以应该尽可能选用类选择器
◆ 单文件组件的命名虽然不会影响代码的正常运转,但是一个良好的命名规范能够在绝大多数工程中改善可读性和开发体验
◆ 单词首字母大写对于代码编辑器的自动补全最为友好,因为这会使JS(X)和模板中引用组件的方式尽可能一致。然而,混用文件的命名方式有时候会导致文件系统对大小写不敏感的问题,这也是横线连接命名可取的原因
◆ 只拥有单个活跃实例的组件以The前缀命名,以示其唯一性。但这并不意味着组件只可用于一个单页面,而是每个页面只使用一次。这些组件永远不接受任何prop,因为它们是为你的应用定制的,而不是应用中的上下文。如果你发现有必要添加prop,就表明这实际上是一个可复用的组件,只是目前在每个页面里只使用了一次
◆ 但是我们并不推荐这种方式,因为这会导致:● 许多文件的名字相同,这使得在编辑器中快速切换文件变得困难;● 过多嵌套的子目录增加了在编辑器侧边栏中浏览组件所花的时间
◆ 你可能想换成多级目录的方式,把所有的搜索组件放到search目录,把所有的设置组件放到settings目录。Vue.js
官方推荐只有在非常大型(如有100+ 个组件)的应用下才考虑这么做,原因有以下几点。● 在多级目录间找来找去比在单个components目录下滚动查找花费更多的精力。● 存在组件重名的时候(比如存在多个ButtonDelete组件),在编辑器里更难快速定位。● 让重构变得更难,因为为一个移动了的组件更新相关引用时,查找或替换通常并不高效
◆ 组件名应该倾向于完整单词而不是缩写。编辑器中的自动补全已经让书写长命名的代价非常低了,而它带来的明确性却是非常宝贵的。尤其应该避免不常用的缩写
◆ 组件名应该始终由多个单词组成,但是根组件App除外。这样做可以避免与现有的以及未来的HTML元素相冲突,因为所有的HTML元素名称都是单个单词的
◆ 单词首字母大写比横线连接有如下优势。● 编辑器可以在模板里自动补全组件名,因为单词首字母大写同样适用于JavaScript。● 在视觉上,<MyComponent>
比 <my-component>
更能够和单个单词的HTML元素区别开来,因为前者有两个大写字母,后者只有一个横线。● 如果你在模板中使用任何非Vue.js的自定义元素,比如一个Web Component,单词首字母大写确保了你的Vue.js组件在视觉上仍然是易识别的
◆ 在JavaScript中,单词首字母大写是类和构造函数(本质上是任何可以产生多份不同实例的东西)的命名约定。Vue.js
组件也有多份实例,所以同样使用单词首字母大写是有意义的。额外的好处是,在JSX(和模板)里使用单词首字母大写能够让读者更容易分辨Vue.js组件和HTML元素
◆ 然而,对于只通过Vue.component
定义全局组件的应用来说,我们推荐使用横线连接的方式,原因有两点。● 全局组件很少被JavaScript引用,所以遵守JavaScript的命名约定意义不大。● 这些应用往往包含许多DOM内的模板,这种情况下必须使用横线连接的方式
◆ 在声明prop的时候,其命名应该始终使用驼峰式命名规则,而在模板和JSX中应该始终使用横线连接的方式
◆ 多个特性的元素应该分多行撰写,每个特性一行。在JavaScript中,用多行分隔对象的多个属性是很常见的最佳实践,因为这更易读。模板和JSX值得我们做相同的考虑
◆ 组件模板应该只包含简单的表达式,复杂的表达式则应该重构为计算属性或方法
◆ ● 易于测试:当每个计算属性都包含一个非常简单且很少依赖的表达式时,撰写测试以确保其正确工作会更加容易。● 易于阅读:简化计算属性要求你为每一个值都起一个描述性的名称,即便它不可复用。这使得开发者更容易专注在代码上并搞清楚发生了什么。● 更好地“拥抱变化”:任何能够命名的值都可能用在视图上。举个例子,我们可能打算展示一个信息,告诉用户他们存了多少钱;也可能打算计算税费,但是可能会分开展现,而不是作为总价的一部分
◆ 代码顺序指的是组件/实例的选项的顺序、元素特性的顺序以及单文件组件的顶级元素的顺序
◆ 下面是Vue.js官方推荐的组件选项默认顺序,它们被划分为几大类,从中能知道从插件里添加的新属性应该放到哪里。● 副作用(触发组件外的影响)● el● 全局感知(要求组件以外的知识)● name● parent● 组件类型(更改组件的类型)● functional● 模板修改器(改变模板的编译方式)● delimiters● comments● 模板依赖(模板内使用的资源)● components● directives● filters● 组合(向选项里合并属性)● extends● mixins● 接口(组件的接口)● inheritAttrs● model● props/propsData● 本地状态(本地的响应式属性)● data● computed● 事件(通过响应式事件触发的回调)● watch● 生命周期钩子(按照它们被调用的顺序)● beforeCreate●created● beforeMount● mounted● beforeUpdate● updated● activated● deactivated● beforeDestroy● destroyed●
非响应式的属性(不依赖响应系
◆ 下面是Vue.js官方为元素特性推荐的默认顺序,它们被划分为几大类,从中也能知道新添加的自定义特性和指令应该放到哪里。● 定义(提供组件的选项)● is● 列表渲染(创建多个变化的相同元素)● v-for●
条件渲染(元素是否渲染/显示)
◆ ● v-if● v-else-if● v-else● v-show● v-cloak●
渲染方式(改变元素的渲染方式)● v-pre● v-once●
全局感知(需要超越组件的知识)● id● 唯一的特性(需要唯一值的特性)● ref● key● slot●
双向绑定(把绑定和事件结合起来)● v-model●
其他特性(所有普通的绑定或未绑定的特性)● 事件(组件事件监听器)● v-on●
内容(覆写元素的内容)● v-html● v-text
◆ 单文件组件应该总是让 <script>、<template>
和 <style>
标签的顺序保持一致,且<style>
要放在最后,因为另外两个标签至少要有一个
-- 来自微信读书