vue2技术揭秘笔记
本文是vue 技术揭秘的笔记 (以下统一《揭秘》指代), 除非特指,vue 都是指的 2.x 版本。 跟着大佬的脚步过了一边 vue2 底层,分析得很详细,站在巨人的肩膀上果然能够看的更高,有时间精力能力的时候必定自己读一番源码。
揭秘主要分为了数据驱动、组件化、响应式原理、编译拓展这几个部分,也分析了 vue 生态 vue router 和 vuex。
前置准备
《揭秘》最开头,介绍了 vue 的整体情况,vue2 是使用 FlowJS 做的静态类型检查,没有使用过这个库,但看介绍跟 TS 挺像的。 vue 的源码分为以下部分
src
├── compiler # 编译相关
├── core # 核心代码
├── platforms # 不同平台的支持(web和weex)
├── server # 服务端渲染
├── sfc # .vue 文件解析
├── shared # 共享代码(指浏览器端和服务端的通用代码)
Weex简单说就是一个用 web 技术开发原生应用的框架
vue 源码是基于 Rollup 构建的,从构建脚本可以看出产物有 3 种
{
"script": {
"build": "node scripts/build.js",
"build:ssr": "npm run build -- web-runtime-cjs,web-server-renderer",
"build:weex": "npm run build -- weex"
}
}
关于 node 环境下的
process.argv
参数,数组前两个为 node 和执行文件的路径,第三个开始依次为命令中空格相间的字符 所以web-runtime-cjs,web-server-renderer
和weex
这些参数是通过process.argv[2]
获取的
scripts/build.js
的构建过程简单来说就是:判断命令参数->过滤相应的预设构建配置->构建不同用途的 vue.js,
详细的构建过程这里不再继续展开,构建配置简单来说就是以下内容的组合:
- 模块系统,cjs 还是 esm
- 环境,如开发模式的包会包含各种日志打印,生成模式则 shake 掉了相关代码
- 运行时和编译器
- web 还是 weex
- 浏览器和服务端
- webpack 插件
vue 同时还有运行时、运行时+编译器的版本,这两者的区别简单理解就是render
函数是在什么时候生成的。
vue 的最终渲染都是
render
函数,该函数返回的是虚拟 dom,编译指的是template
转换成render
函数的过程
如果是在代码运行的时候去做编译这一过程,那么需要用的是运行时+编译器的版本,
如果在代码运行前就做好了编译工作,则只需要纯运行时版本(vue-loader
就是用来体检做编译工作的)
观察运行时+编译器的产物src/platforms/web/entry-runtime-with-compiler.js
,作为入口文件,做的事情十分简洁明了:
- 从
./runtime/index
引入 Vue,还有编译器,以及其他的依赖 - 重写运行时 Vue 原型上的$mount 方法,主要是做了些前置工作:
- 检查挂载的根元素,不能是
html
或者body
- 检查渲染函数,没有渲染函数时检查
template
并转换为渲染函数,没有template
则取挂载元素的outerHTML
作为template
- 继续调用原本的$mount
- 检查挂载的根元素,不能是
- 将编译器挂载到拓展后的 Vue 的
compile
属性 - 导出拓展的 Vue
接下来继续看 vue 运行时src/platforms/web/runtime/index.js
,主要做了以下工作:
- 从
core/index
引入 Vue - 拓展 Vue 原型,设置
__patch__
和$mount
属性,还有杂七杂八的
再进到src/core/index.js
:
- 从
./instance/index
引入Vue
对象,引入initGlobalAPI
和其他工具 - 调用
initGlobalAPI
初始化全局 Vue API - 拓展
Vue.prototype
,定义了$isServer
和$ssrContext
- 拓展
Vue
,定义了FunctionalRenderContext
- 标记版本并导出
Vue
进到src/core/instance/index.js
,可以看到Vue
是一个函数类,需要用new
来实例化
(并且内部做了判断,Vue
只能作为构造方法使用),在这个类导出之前,有一系列的xxxMixin
方法对这个类的原型进行一系列的拓展,
这也是Vue
没有使用 ES6 的 Class 实现的原因:方便维护和管理。
function Vue(options) {
if (process.env.NODE_ENV !== "production" && !(this instanceof Vue)) {
warn("Vue is a constructor and should be called with the `new` keyword");
}
this._init(options);
}
initMixin(Vue);
stateMixin(Vue);
eventsMixin(Vue);
lifecycleMixin(Vue);
renderMixin(Vue);
说回initGlobalAPI
,它作用是Vue.prototype
进行了一系列的方法拓展后,对Vue
对象本身拓展全局静态方法,
先挂载了以下属性到Vue
上:
- util
- set
- delete
- nextTick
- options
然后是下列的初始化操作:
initUse(Vue);
initMixin(Vue);
initExtend(Vue);
initAssetRegisters(Vue);
这里需要注意下流程中对原型的拓展和对原对象的静态拓展,在原型上拓展的内容,是 vue 实例才能访问到的, 而在
Vue
上拓展的静态全局 API,则需要通过Vue
对下岗来访问
大致过完了框架的大流程,接下来就是核心的分析了
数据驱动
数据驱动作为 vue 的核心已经老八股文了,面试经常会问,而我也是经常那几句话:
- 数据劫持,
defineProperty
- 依赖收集,发布订阅
- 啊吧啊吧…
实例化
哈哈,光知道这些肯定不够的,知其然知其所以然,《揭秘》首先带我们继续深扒 vue 的实例化过程,
回到Vue
构造函数,可以看到在判断完是否作为构造方法调用后,紧接着调用了this._init
,
而这个初始化方法正是initMixin
在Vue.prototype
上拓展的,主要做了以下工作:
- 合并配置项
- 初始化生命周期(给 vm 实例加上各种生命周期的标记)
- 初始化事件中心(根据 parent 更新自己的 listener)
- 初始化渲染
- 初始化 data、props、computed、watcher 等
代码中可以清晰的看到beaforeCreate
和created
的两钩子前后发生了什么:
//...
initLifecycle(vm);
initEvents(vm);
initRender(vm); // 声明了vm.$createElement等
callHook(vm, "beforeCreate");
initInjections(vm); // resolve injections before data/props
initState(vm);
initProvide(vm); // resolve provide after data/props
callHook(vm, "created");
//...
挂载
相关的细节并没有继续深入,接下来是vm
的挂载过程,上文也提到运行时版本的入口platform/web/runtime/index.js
,
对Vue.prototype
上的$mount
进行了拓展,$mount
内部则是在查询到元素后,最终调用了lifecycle
的mountComponent
方法,
并同时传入vm
实例,然后:
- 检查
vm
的选项式 api 是否有render
,没有的话调用createEmptyVNode
- 触发
beforeMount
钩子 - 实例化
Watcher
,其回调函数调用vm._render
生成虚拟 node,然后用vm._update
更新 DOM - 标记
vm._isMounted
,触发mounted
钩子
render
通过对挂载过程的分析可以看出较为关键的是vm._render
和vm._update
,
这两个都是实例私有方法,是在准备工作中提到的renderMixin
和lifecycleMixin
对Vue.prototype
拓展的,
Vue.prototype._render
的关键操作:
- 挂载父节点到
vm.$vnode
- 调用实例上的
render
方法(即options
上的,可能是经过template
转换而来)
createElement
源码中可以注意到在调用实例的 render 方法是这种姿势
vnode = render.call(vm._renderProxy, vm.$createElement);
而这个vm.$createElement
是由vm._init
->initRender
声明在vm
上的,最终调用的是
src/core/vdom/create-element.js
略去一些细节(有点复杂了,不再这里展开),它做的主要工作就是:
- 规范化 children
- 创建 VNode
update
从挂载的源码可知vm._update
调用的时机为首次渲染和数据更新时,它是在lifecycleMixin
中拓展的,
它做的事情就是经典的差异比对vm.__patch__
(平台相关的一个方法,所以是在platform/web/runtime/
中定义的),
platform/web/runtime/platch.js
又表明这个方法是src/core/vdom/patch.js
返回的,
patch 很复杂 😭😭,记一些关键点:
- 支持的 nodeOps 是因平台而异的
- 关于子节点的处理是深度优先的,created 钩子深自底向上触发,插入顺序也是如此
组件化
在调用createElement
的过程中,会判断是否组件,调用createComponent
:
- 构造子类构造函数(通过原型继承的方法,组件导出的对象继承 Vue 的一个子类,并缓存)
- 安装组件钩子函数
- 实例化
vnode
TODO 组件patch的差异
组件的注册方式分为全局注册和局部注册,实际上globalApi
中初始化了['component','directive','filter']
三个全局函数
异步组件支持:
- 通过普通的工厂函数
- promise
- 高级异步组件,通过 promise 定义需要加载的组件,并可定义加载中和加载错误的组件,和等待时间 delay、超时时间 timeout 等,
本质上是两次渲染(delay 为 0 第一次直接渲染 loading 组件,否则第一次渲染一个注释节点,异步组件后获取成功后,
通过
forceRender
强制重新渲染
响应式原理
响应式对象创建过程
记录一下关键点:
- 实例化->
vm._init
->initState
->初始化props
、methods
、data
、computed
、watcher
等 - 初始化
props
:遍历props
- 调用
defineReactive
把每个prop
对应的值转换为响应式,使得vm._props.xxx
能访问到对应属性 - 调用
proxy
把访问vm._props.xxx
的访问代理到vm.xxx
上
- 调用
- 初始化
data
:遍历data
函数返回的对象- 调用
proxy
把每一个值vm._data.xxx
都代理到vm.xxx
上 - 调用
observe
方法观测整个data
的变化,将data
变成响应式
- 调用
-
proxy
方法:通过defineProperty
把target[[sourceKey][key]]
的读写变成target[key]
的读写, 使得vm.xxx
能够访问到vm._props.xxx
和vm._data.xxx
-
observe
方法:- 传入值不是对象或是一个 VNode 对象则 return 掉
- 声明一个
Observer
- 检查对象是否添加了
Obsever
,如果添加了,取这个Observer
- 没有添加
Observer
的话,在满足一定条件下,实例化一个Observer
- 返回这个
Observer
-
Observer
类,用来给对象的属性添加 getter 和 setter,即传说中的依赖收集和派发更新:- 构造函数先实例化一个
Dep
对象 - 执行
def
函数把自身实例添加到入参对象value
的__ob__
属性上 - 判断
value
是否数组,是则调用observeArray
方法- 遍历数组再次调用
observe
方法
- 遍历数组再次调用
- 否则调用
walk
方法- 遍历
value
对象的 key 调用defineReactive
方法
- 遍历
- 构造函数先实例化一个
-
defineReactive
的功能是定义一个响应式对象,入参是对象和属性名等,给对象动态添加 getter 和 setter:- 初始化一个
Dep
对象实例, - 获取入参
obj
的属性描述符,对子对象地柜调用observe
方法 - 定义 getter 和 setter
- 初始化一个
依赖收集
上面的提到的Dep
对象其实就是依赖收集的核心,它在源码中是个 class,类上有个全局静态属性target
,
是一个全局唯一的Watcher
,Dep
实际上是对Watcher
的一种管理
Watcher
也是一个 Class,这个类有很多属性,和Dep
相关的有:
this.deps = []; // 当前Watcher实例持有的Dep实例数组
this.newDeps = []; // 新添加的依赖数组
this.depIds = new Set();
this.newDepIds = new Set();
回想 Vue 的 mount 过程,在调用mountComponent
函数时,实例化了一个Watcher
,大致发生了这些事:
- 进入
Watcher
构造函数逻辑this.get()
pushTarget(this)
- 将
Watcher
实例赋值Dep.target
并放入栈中 - 触发
Wacher
的第二个入参函数updateComponent
- 执行
vm._update
,触发vm._render()
- 生成 VNode 的过程触发
vm
上的数据访问 - 触发数据对象的
getter
- 执行
- 调用数据对象的
dep.depend()
即Dep.target.addDep(当前dep实例)
即watcher.addDep()
- 按照条件更新
watcher
的newDeps
和newDepsIds
,并执行dep.addSub
- 调用
traverse
递归访问,触发子项getter
- 调用
popTarget
和清空依赖
- 按照条件更新
//TODO 此处应该有张图 😭😭😭😭😭
依赖收集这个过程是一个非常经典的观察者模式,这里回顾一下观察者模式和发布订阅模式的区别: 观察者模式通常是观察者和发布者直接通向,而发布订阅模式在两者之间多了一个主题/时间通道, 发布者向通道发布主题或者事件,订阅者向通道进行订阅,由通道触发事件与订阅者通信, 其目的是避免发布者和订阅者产生依赖关系。
派发更新
记录下大致过程:
- 修改响应数据,触发 setter 逻辑
- 更新值
- 调用
dep.notify()
- 遍历
subs
数组里的watcher
实例 - 调用
watcher
的update
方法 - 区分
computed
、sync
以及其他执行不同逻辑,此时会走到最后的queueWatcher
- 把这些
watcher
添加到一个队列,nextTick
后执行flushSchedulerQueue
- 自顶向下排列
watcher
- 遍历
watcher
执行watcher.run()
-
this.get()
获取当前值,触发组件渲染patch
过程 - 判断是否满足新旧值不相等、新值是对象类型、deep 模式开启任何一个条件
- 执行
watcher
回调
-
- 恢复状态,清空队列
- 自顶向下排列
nextTick
回顾事件循环:
- 所有同步任务都在主线程上执行,形成一个执行栈
- 主线程之外,还存在一个”任务队列”。只要异步任务有了运行结果,就在”任务队列”之中放置一个事件。
- 一旦”执行栈”中的所有同步任务执行完毕,系统就会读取”任务队列”,看看里面有哪些事件。之前那些 往”任务队列”放事件的异步任务,会结束等待状态,进入执行栈,开始执行。
- 主线程不断重复上面的第三步骤。
src/core/util/next-tick.js
单独维护了nextTick
,关于timmerFunc
的取值遵循以下顺序
- Promise
- MutationObserver
- setImmediate
- setTimeout
特殊情况
这也是一些 vue2 的”特色”
-
Object.defineProperty
实现的响应式对象,新增属性无法触发 setter,vue 为此专门提供了set
方法 - 无法监测数组的索引操作、length 操作,并且重写了数组的一些方法使其实现响应式
[ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse' ]
, 并且获取了能增加数组长度的方法的插入值,将其转换为响应式对象,最后在方法内部主动调用ob.dep.notify()
计算和侦听
计算属性本质上是 computed watcher
,而侦听属性本质上是 user watcher
。
就应用场景而言,计算属性适合用在模板渲染中,某个值是依赖了其它的响应式对象甚至是计算属性计算而来;
而侦听属性适用于观测某个值的变化去完成一段复杂的业务逻辑。
组件更新
// TODO Diff 算法
编译
简单说就是 AST 语法树转换->标记静态节点->可执行代码
拓展
这一章节都只拖到后面看了下总结…
Vue Router
vue 插件注册原理
vue 提供了Vue.use
全局 API 来注册插件,它是在src/core/global-api/use.js
中维护的:
-
Vue.use
接受一个plugin
参数,并在内部维护一个_installedPlugins
数组 - 判断
plugin
有没有定义install
方法,有的话会调用这个方法,并在所有入参前加一个Vue
参数 - 把插件储存到数组中
vue-router 的 install
当用户执行Vue.use(VueRouter)
的时候,实际上是在执行VueRouter
的install
函数:
-
install
函数在自身维护了一个install.installed
标记来确保插件只被安装一次, 并缓存了Vue
入参 - 使用
Vue.mixin
把 beforeCreate和
destroyed`钩子函数注入到每一个组件 - 一些初始化工作,定义
vm.$route
和vm.$router
- 通过
Vue.component
方法定义全局<router-lint>
和<router-view>
组件
VueRouter 对象
VueRouter
是个 ES6 class,以下是一些关键属性
this.app = null; // Vue实例
this.apps = []; // 持有$options.router属性的Vue实例
this.options = options; // 传入的路由配置
this.beforeHooks = []; // 一些钩子
this.resolveHooks = [];
this.afterHooks = [];
this.matcher = createMatcher(options.routes || [], this); // 路由匹配器
let mode = options.mode || "hash";
// 判断是否回退到hash模式
this.fallback =
mode === "history" && !supportsPushState && options.fallback !== false;
if (this.fallback) {
mode = "hash";
}
if (!inBrowser) {
mode = "abstract";
}
this.mode = mode; // 路由模式
// history的不同实现分支
switch (mode) {
case "history":
this.history = new HTML5History(this, options.base);
break;
case "hash":
this.history = new HashHistory(this, options.base, this.fallback);
break;
case "abstract":
this.history = new AbstractHistory(this, options.base);
break;
default:
if (process.env.NODE_ENV !== "production") {
assert(false, `invalid mode: ${mode}`);
}
}
// TODO matcher history.transitionTo
Vuex
最后
非常浅的过了一遍大佬的《vue 技术揭秘》,说实话没太吃透,暂时只在脑海中建立了 vue 的总体框架, 不太建议一上来就读这个,还是得先对 vue 有一定的理解。