Vue.js ⼀个核⼼思想是数据驱动。所谓数据驱动,是指视图是由数据驱动⽣成的,我们对视图的修改, 不会直接操作 DOM,⽽是通过修改数据。它相⽐我们传统的前端开发,如使⽤ jQuery 等前端库直接 修改 DOM,⼤⼤简化了代码量。特别是当交互复杂的时候,只关⼼数据的修改会让代码的逻辑变的⾮ 常清晰,因为 DOM 变成了数据的映射,我们所有的逻辑都是对数据的修改,⽽不⽤碰触 DOM,这样 的代码⾮常利于维护。 在 Vue.js 中我们可以采⽤简洁的模板语法来声明式的将数据渲染为 DOM:
最终它会在⻚⾯上渲染出 Hello Vue 。接下来,我们会从源码⾓度来分析 Vue 是如何实现的,分析 过程会以主线代码为主,重要的分⽀逻辑会放在之后单独分析。数据驱动还有⼀部分是数据更新驱动 视图变化,这⼀块内容我们也会在之后的章节分析,这⼀章我们的⽬标是弄清楚模板和数据如何渲染 成最终的 DOM。
new Vue 发⽣了什么
从⼊⼝代码开始分析,我们先来分析 new Vue 背后发⽣了哪些事情。我们都知道, new 关键字在 Javascript 语⾔中代表实例化是⼀个对象,⽽ Vue 实际上是⼀个类,类在 Javascript 中是⽤ Function 来实现的,来看⼀下源码,在 src/core/instance/index.js 中。
可以看到 Vue 只能通过 new 关键字初始化,然后会调⽤ this._init ⽅法, 该⽅法在 src/core/instance/init.js 中定义。
Vue 初始化主要就⼲了⼏件事情,合并配置,初始化⽣命周期,初始化事件中⼼,初始化渲染,初始 化 data、props、computed、watcher 等等。
总结
Vue 的初始化逻辑写的⾮常清楚,把不同的功能逻辑拆成⼀些单独的函数执⾏,让主线逻辑⼀⽬了 然,这样的编程思想是⾮常值得借鉴和学习的。 由于我们这⼀章的⽬标是弄清楚模板和数据如何渲染成最终的 DOM,所以各种初始化逻辑我们先不 看。在初始化的最后,检测到如果有 el 属性,则调⽤ vm.$mount ⽅法挂载 vm ,挂载的⽬标就 是把模板渲染成最终的 DOM,那么接下来我们来分析 Vue 的挂载过程。
Vue 实例挂载的实现
Vue 中我们是通过 $mount 实例⽅法去挂载 vm 的, $mount ⽅法在多个⽂件中都有定义,如 src/platform/web/entry-runtime-withcompiler.js 、 src/platform/web/runtime/index.js 、 src/platform/weex/runtime/index.js 。因为 $mount 这个⽅法的实现是和平台、构建⽅式都相关的。接下来我们重点分析带 compiler 版本的 $monut 实现,因为抛开 webpack 的 vue-loader,我们在纯前端浏览器环境分析 Vue 的⼯作原 理,有助于我们对原理理解的深⼊。 compiler 版本的 $monut 实现⾮常有意思,先来看⼀下 src/platform/web/entry-runtimewith-compiler.js ⽂件中定义:
这段代码⾸先缓存了原型上的 $mount ⽅法,再重新定义该⽅法,我们先来分析这段代码。⾸先,它 对 el 做了限制,Vue 不能挂载在 body 、 html 这样的根节点上。接下来的是很关键的逻辑 —— 如果没有定义 render ⽅法,则会把 el 或者 template 字符串转换成 render ⽅法。这⾥我们 要牢记,在 Vue 2.0 版本中,所有 Vue 的组件的渲染最终都需要 render ⽅法,⽆论我们是⽤单⽂件 .vue ⽅式开发组件,还是写了 el 或者 template 属性,最终都会转换成 render ⽅法,那么这个 过程是 Vue 的⼀个“在线编译”的过程,它是调⽤ compileToFunctions ⽅法实现的,编译过程我们之 后会介绍。最后,调⽤原先原型上的 $mount ⽅法挂载。 原先原型上的 $mount ⽅法在 src/platform/web/runtime/index.js 中定义,之所以这么设计完 全是为了复⽤,因为它是可以被 runtime only 版本的 Vue 直接使⽤的。
Vue.prototype.$mount = function ( el?: string | Element, hydrating?: boolean ): Component { el = el && inBrowser ? query(el) : undefined return mountComponent(this, el, hydrating)
$mount ⽅法⽀持传⼊ 2 个参数,第⼀个是 el ,它表⽰挂载的元素,可以是字符串,也可以是 DOM 对象,如果是字符串在浏览器环境下会调⽤ query ⽅法转换成 DOM 对象的。第⼆个参数是和 服务端渲染相关,在浏览器环境下我们不需要传第⼆个参数。 $mount ⽅法实际上会去调⽤ mountComponent ⽅法,这个⽅法定义在 src/core/instance/lifecycle.js ⽂件中:
从上⾯的代码可以看到, mountComponent 核⼼就是先调⽤ vm._render ⽅法先⽣成虚拟 Node,再 实例化⼀个渲染 Watcher ,在它的回调函数中会调⽤ updateComponent ⽅法,最终调⽤ vm._update 更新 DOM。 Watcher 在这⾥起到两个作⽤,⼀个是初始化的时候会执⾏回调函数,另⼀个是当 vm 实例中的监测 的数据发⽣变化的时候执⾏回调函数,这块⼉我们会在之后的章节中介绍。 函数最后判断为根节点的时候设置 vm._isMounted 为 true , 表⽰这个实例已经挂载了,同时执⾏ mounted 钩⼦函数。 这⾥注意 vm.$vnode 表⽰ Vue 实例的⽗虚拟 Node,所以它为 Null 则表⽰ 当前是根 Vue 的实例。
总结 mountComponent ⽅法的逻辑也是⾮常清晰的,它会完成整个渲染⼯作,接下来我们要重点分析其中 的细节,也就是最核⼼的 2 个⽅法: vm._render 和 vm._update 。
render
Vue 的 _render ⽅法是实例的⼀个私有⽅法,它⽤来把实例渲染成⼀个虚拟 Node。它的定义在 src/core/instance/render.js ⽂件中:
这段代码最关键的是 render ⽅法的调⽤,我们在平时的开发⼯作中⼿写 render ⽅法的场景⽐较 少,⽽写的⽐较多的是 template 模板,在之前的 mounted ⽅法的实现中,会把 template 编译 成 render ⽅法,但这个编译过程是⾮常复杂的,我们不打算在这⾥展开讲,之后会专门花⼀个章节 来分析 Vue 的编译过程。 在 Vue 的官⽅⽂档中介绍了 render 函数的第⼀个参数是 createElement ,那么结合之前的例⼦:
<div id="app"> {{ message }} </div>
相当于我们编写如下 render 函数:
Virtual DOM
Virtual DOM 这个概念相信⼤部分⼈都不会陌⽣,它产⽣的前提是浏览器中的 DOM 是很“昂贵"的,为 了更直观的感受,我们可以简单的把⼀个简单的 div 元素的属性都打印出来,如图所⽰:
可以看到 Vue.js 中的 Virtual DOM 的定义还是略微复杂⼀些的,因为它这⾥包含了很多 Vue.js 的特 性。这⾥千万不要被这些茫茫多的属性吓到,实际上 Vue.js 中 Virtual DOM 是借鉴了⼀个开源库 snabbdom 的实现,然后加⼊了⼀些 Vue.js 特⾊的东⻄。我建议⼤家如果想深⼊了解 Vue.js 的 Virtual DOM 前不妨先阅读这个库的源码,因为它更加简单和纯粹。
总结
其实 VNode 是对真实 DOM 的⼀种抽象描述,它的核⼼定义⽆⾮就⼏个关键属性,标签名、数据、⼦ 节点、键值等,其它属性都是都是⽤来扩展 VNode 的灵活性以及实现⼀些特殊 feature 的。由于 VNode 只是⽤来映射到真实 DOM 的渲染,不需要包含操作 DOM 的⽅法,因此它是⾮常轻量和简单的。 Virtual DOM 除了它的数据结构的定义,映射到真实的 DOM 实际上要经历 VNode 的 create、diff、 patch 等过程。那么在 Vue.js 中,VNode 的 create 是通过之前提到的 createElement ⽅法创建的,我 们接下来分析这部分的实现。
createElement
children 的规范化
由于 Virtual DOM 实际上是⼀个树状结构,每⼀个 VNode 可能会有若⼲个⼦节点,这些⼦节点应该也 是 VNode 的类型。 _createElement 接收的第 4 个参数 children 是任意类型的,因此我们需要把它们 规范成 VNode 类型。 这⾥根据 normalizationType 的不同,调⽤了 normalizeChildren(children) 和 simpleNormalizeChildren(children) ⽅法,它们的定义都在 src/core/vdom/helpers/normalzie-children.js 中:
simpleNormalizeChildren ⽅法调⽤场景是 render 函数当函数是编译⽣成的。理论上编译⽣成的 children 都已经是 VNode 类型的,但这⾥有⼀个例外,就是 functional component 函数式组件 返回的是⼀个数组⽽不是⼀个根节点,所以会通过 Array.prototype.concat ⽅法把整个 children 数组打平,让它的深度只有⼀层。 normalizeChildren ⽅法的调⽤场景有 2 种,⼀个场景是 render 函数是⽤户⼿写的,当 children 只有⼀个节点的时候,Vue.js 从接⼝层⾯允许⽤户把 children 写成基础类型⽤来创建单 个简单的⽂本节点,这种情况会调⽤ createTextVNode 创建⼀个⽂本节点的 VNode;另⼀个场景是 当编译 slot 、 v-for 的时候会产⽣嵌套数组的情况,会调⽤ normalizeArrayChildren ⽅法, 接下来看⼀下它的实现:
normalizeArrayChildren 接收 2 个参数, children 表⽰要规范的⼦节点, nestedIndex 表⽰ 嵌套的索引,因为单个 child 可能是⼀个数组类型。 normalizeArrayChildren 主要的逻辑就是 遍历 children ,获得单个节点 c ,然后对 c 的类型判断,如果是⼀个数组类型,则递归调⽤ normalizeArrayChildren ; 如果是基础类型,则通过 createTextVNode ⽅法转换成 VNode 类型; 否则就已经是 VNode 类型了,如果 children 是⼀个列表并且列表还存在嵌套的情况,则根据 nestedIndex 去更新它的 key。这⾥需要注意⼀点,在遍历的过程中,对这 3 种情况都做了如下处 理:如果存在两个连续的 text 节点,会把它们合并成⼀个 text 节点。 经过对 children 的规范化, children 变成了⼀个类型为 VNode 的 Array。
VNode 的创建
总结
那么⾄此,我们⼤致了解了 createElement 创建 VNode 的过程,每个 VNode 有 children , children 每个元素也是⼀个 VNode,这样就形成了⼀个 VNode Tree,它很好的描述 了我们的 DOM Tree。 回到 mountComponent 函数的过程,我们已经知道 vm._render 是如何创建了⼀个 VNode,接下来 就是要把这个 VNode 渲染成⼀个真实的 DOM 并渲染出来,这个过程是通过 vm._update 完成的, 接下来分析⼀下这个过程。
update
createPatchFunction 内部定义了⼀系列的辅助⽅法,最终返回了⼀个 patch ⽅法,这个⽅法就 赋值给了 vm._update 函数⾥调⽤的 vm.__patch__ 。 在介绍 patch 的⽅法实现之前,我们可以思考⼀下为何 Vue.js 源码绕了这么⼀⼤圈,把相关代码分 散到各个⽬录。因为前⾯介绍过, patch 是平台相关的,在 Web 和 Weex 环境,它们把虚拟 DOM 映 射到 “平台 DOM” 的⽅法是不同的,并且对 “DOM” 包括的属性模块创建和更新也不尽相同。因此每个
update
47
平台都有各⾃的 nodeOps 和 modules ,它们的代码需要托管在 src/platforms 这个⼤⽬录下。 ⽽不同平台的 patch 的主要逻辑部分是相同的,所以这部分公共的部分托管在 core 这个⼤⽬录 下。差异化部分只需要通过参数来区别,这⾥⽤到了⼀个函数柯⾥化的技巧,通过 createPatchFunction 把差异化参数提前固化,这样不⽤每次调⽤ patch 的时候都传递 nodeOps 和 modules 了,这种编程技巧也⾮常值得学习。 在这⾥, nodeOps 表⽰对 “平台 DOM” 的⼀些操作⽅法, modules 表⽰平台的⼀些模块,它们会在 整个 patch 过程的不同阶段执⾏相应的钩⼦函数。这些代码的具体实现会在之后的章节介绍。 回到 patch ⽅法本⾝,它接收 4个参数, oldVnode 表⽰旧的 VNode 节点,它也可以不存在或者是 ⼀个 DOM 对象; vnode 表⽰执⾏ _render 后返回的 VNode 的节点; hydrating 表⽰是否是服 务端渲染; removeOnly 是给 transition-group ⽤的,之后会介绍。 patch 的逻辑看上去相对复杂,因为它有着⾮常多的分⽀逻辑,为了⽅便理解,我们并不会在这⾥ 介绍所有的逻辑,仅会针对我们之前的例⼦分析它的执⾏逻辑。之后我们对其它场景做源码分析的时 候会再次回顾 patch ⽅法。 先来回顾我们的例⼦:
createComponent
构造⼦类构造函数
安装组件钩⼦函数
实例化 VNode
patch
createComponent
createComponent 函数中,⾸先对 vnode.data 做了⼀些判断: