本文是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-rendererweex这些参数是通过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,作为入口文件,做的事情十分简洁明了:

  1. ./runtime/index引入 Vue,还有编译器,以及其他的依赖
  2. 重写运行时 Vue 原型上的$mount 方法,主要是做了些前置工作:
    1. 检查挂载的根元素,不能是html或者body
    2. 检查渲染函数,没有渲染函数时检查template并转换为渲染函数,没有template则取挂载元素的outerHTML作为template
    3. 继续调用原本的$mount
  3. 将编译器挂载到拓展后的 Vue 的compile属性
  4. 导出拓展的 Vue

接下来继续看 vue 运行时src/platforms/web/runtime/index.js,主要做了以下工作:

  1. core/index引入 Vue
  2. 拓展 Vue 原型,设置__patch__$mount属性,还有杂七杂八的

再进到src/core/index.js:

  1. ./instance/index引入Vue对象,引入initGlobalAPI和其他工具
  2. 调用initGlobalAPI初始化全局 Vue API
  3. 拓展Vue.prototype,定义了$isServer$ssrContext
  4. 拓展Vue,定义了FunctionalRenderContext
  5. 标记版本并导出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, 而这个初始化方法正是initMixinVue.prototype上拓展的,主要做了以下工作:

  • 合并配置项
  • 初始化生命周期(给 vm 实例加上各种生命周期的标记)
  • 初始化事件中心(根据 parent 更新自己的 listener)
  • 初始化渲染
  • 初始化 data、props、computed、watcher 等

代码中可以清晰的看到beaforeCreatecreated的两钩子前后发生了什么:

//...
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内部则是在查询到元素后,最终调用了lifecyclemountComponent方法, 并同时传入vm实例,然后:

  1. 检查vm的选项式 api 是否有render,没有的话调用createEmptyVNode
  2. 触发beforeMount钩子
  3. 实例化Watcher,其回调函数调用vm._render生成虚拟 node,然后用vm._update更新 DOM
  4. 标记vm._isMounted,触发mounted钩子

render

通过对挂载过程的分析可以看出较为关键的是vm._rendervm._update, 这两个都是实例私有方法,是在准备工作中提到的renderMixinlifecycleMixinVue.prototype拓展的,

Vue.prototype._render的关键操作:

  1. 挂载父节点到vm.$vnode
  2. 调用实例上的render方法(即options上的,可能是经过template转换而来)

createElement

源码中可以注意到在调用实例的 render 方法是这种姿势

vnode = render.call(vm._renderProxy, vm.$createElement);

而这个vm.$createElement是由vm._init->initRender声明在vm上的,最终调用的是 src/core/vdom/create-element.js

略去一些细节(有点复杂了,不再这里展开),它做的主要工作就是:

  1. 规范化 children
  2. 创建 VNode

update

从挂载的源码可知vm._update调用的时机为首次渲染和数据更新时,它是在lifecycleMixin中拓展的, 它做的事情就是经典的差异比对vm.__patch__(平台相关的一个方法,所以是在platform/web/runtime/中定义的),

platform/web/runtime/platch.js又表明这个方法是src/core/vdom/patch.js返回的, patch 很复杂 😭😭,记一些关键点:

  1. 支持的 nodeOps 是因平台而异的
  2. 关于子节点的处理是深度优先的,created 钩子深自底向上触发,插入顺序也是如此

组件化

在调用createElement的过程中,会判断是否组件,调用createComponent:

  1. 构造子类构造函数(通过原型继承的方法,组件导出的对象继承 Vue 的一个子类,并缓存)
  2. 安装组件钩子函数
  3. 实例化vnode

TODO 组件patch的差异

组件的注册方式分为全局注册和局部注册,实际上globalApi中初始化了['component','directive','filter'] 三个全局函数

异步组件支持:

  1. 通过普通的工厂函数
  2. promise
  3. 高级异步组件,通过 promise 定义需要加载的组件,并可定义加载中和加载错误的组件,和等待时间 delay、超时时间 timeout 等, 本质上是两次渲染(delay 为 0 第一次直接渲染 loading 组件,否则第一次渲染一个注释节点,异步组件后获取成功后, 通过forceRender强制重新渲染

响应式原理

vue2响应式原理

响应式对象创建过程

记录一下关键点:

  1. 实例化->vm._init->initState->初始化propsmethodsdatacomputedwatcher
  2. 初始化props:遍历props
    1. 调用defineReactive把每个prop对应的值转换为响应式,使得vm._props.xxx能访问到对应属性
    2. 调用proxy把访问vm._props.xxx的访问代理到vm.xxx
  3. 初始化data:遍历data函数返回的对象
    1. 调用proxy把每一个值vm._data.xxx都代理到vm.xxx
    2. 调用observe方法观测整个data的变化,将data变成响应式
  4. proxy方法:通过definePropertytarget[[sourceKey][key]]的读写变成target[key]的读写, 使得vm.xxx能够访问到vm._props.xxxvm._data.xxx
  5. observe方法:
    1. 传入值不是对象或是一个 VNode 对象则 return 掉
    2. 声明一个Observer
    3. 检查对象是否添加了Obsever,如果添加了,取这个Observer
    4. 没有添加Observer的话,在满足一定条件下,实例化一个Observer
    5. 返回这个Observer
  6. Observer类,用来给对象的属性添加 getter 和 setter,即传说中的依赖收集派发更新
    1. 构造函数先实例化一个Dep对象
    2. 执行def函数把自身实例添加到入参对象value__ob__属性上
    3. 判断value是否数组,是则调用observeArray方法
      1. 遍历数组再次调用observe方法
    4. 否则调用walk方法
      1. 遍历value对象的 key 调用defineReactive方法
  7. defineReactive的功能是定义一个响应式对象,入参是对象和属性名等,给对象动态添加 getter 和 setter:
    1. 初始化一个Dep对象实例,
    2. 获取入参obj的属性描述符,对子对象地柜调用observe方法
    3. 定义 getter 和 setter

依赖收集

上面的提到的Dep对象其实就是依赖收集的核心,它在源码中是个 class,类上有个全局静态属性target, 是一个全局唯一的WatcherDep实际上是对Watcher的一种管理

Watcher也是一个 Class,这个类有很多属性,和Dep相关的有:

this.deps = []; // 当前Watcher实例持有的Dep实例数组
this.newDeps = []; // 新添加的依赖数组
this.depIds = new Set();
this.newDepIds = new Set();

回想 Vue 的 mount 过程,在调用mountComponent函数时,实例化了一个Watcher,大致发生了这些事:

  1. 进入Watcher构造函数逻辑
    1. this.get()
    2. pushTarget(this)
  2. Watcher实例赋值Dep.target并放入栈中
  3. 触发Wacher的第二个入参函数updateComponent
    1. 执行vm._update,触发vm._render()
    2. 生成 VNode 的过程触发vm上的数据访问
    3. 触发数据对象的getter
  4. 调用数据对象的dep.depend()Dep.target.addDep(当前dep实例)watcher.addDep()
    1. 按照条件更新watchernewDepsnewDepsIds,并执行dep.addSub
    2. 调用traverse递归访问,触发子项getter
    3. 调用popTarget和清空依赖

//TODO 此处应该有张图 😭😭😭😭😭

依赖收集这个过程是一个非常经典的观察者模式,这里回顾一下观察者模式和发布订阅模式的区别: 观察者模式通常是观察者和发布者直接通向,而发布订阅模式在两者之间多了一个主题/时间通道, 发布者向通道发布主题或者事件,订阅者向通道进行订阅,由通道触发事件与订阅者通信, 其目的是避免发布者和订阅者产生依赖关系。

派发更新

记录下大致过程:

  1. 修改响应数据,触发 setter 逻辑
    1. 更新值
    2. 调用dep.notify()
    3. 遍历subs数组里的watcher实例
    4. 调用watcherupdate方法
    5. 区分computedsync以及其他执行不同逻辑,此时会走到最后的queueWatcher
  2. 把这些watcher添加到一个队列,nextTick后执行flushSchedulerQueue
    1. 自顶向下排列watcher
    2. 遍历watcher执行watcher.run()
      1. this.get()获取当前值,触发组件渲染patch过程
      2. 判断是否满足新旧值不相等、新值是对象类型、deep 模式开启任何一个条件
      3. 执行watcher回调
    3. 恢复状态,清空队列

nextTick

回顾事件循环:

  1. 所有同步任务都在主线程上执行,形成一个执行栈
  2. 主线程之外,还存在一个”任务队列”。只要异步任务有了运行结果,就在”任务队列”之中放置一个事件。
  3. 一旦”执行栈”中的所有同步任务执行完毕,系统就会读取”任务队列”,看看里面有哪些事件。之前那些 往”任务队列”放事件的异步任务,会结束等待状态,进入执行栈,开始执行。
  4. 主线程不断重复上面的第三步骤。

src/core/util/next-tick.js单独维护了nextTick,关于timmerFunc的取值遵循以下顺序

  1. Promise
  2. MutationObserver
  3. setImmediate
  4. setTimeout

特殊情况

这也是一些 vue2 的”特色”

  1. Object.defineProperty实现的响应式对象,新增属性无法触发 setter,vue 为此专门提供了set方法
  2. 无法监测数组的索引操作、length 操作,并且重写了数组的一些方法使其实现响应式 [ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse' ], 并且获取了能增加数组长度的方法的插入值,将其转换为响应式对象,最后在方法内部主动调用ob.dep.notify()

计算和侦听

计算属性本质上是 computed watcher,而侦听属性本质上是 user watcher。 就应用场景而言,计算属性适合用在模板渲染中,某个值是依赖了其它的响应式对象甚至是计算属性计算而来; 而侦听属性适用于观测某个值的变化去完成一段复杂的业务逻辑。

组件更新

// TODO Diff 算法

编译

简单说就是 AST 语法树转换->标记静态节点->可执行代码

parse流程

拓展

这一章节都只拖到后面看了下总结…

Vue Router

vue 插件注册原理

vue 提供了Vue.use全局 API 来注册插件,它是在src/core/global-api/use.js中维护的:

  1. Vue.use接受一个plugin参数,并在内部维护一个_installedPlugins数组
  2. 判断plugin有没有定义install方法,有的话会调用这个方法,并在所有入参前加一个Vue参数
  3. 把插件储存到数组中

vue-router 的 install

当用户执行Vue.use(VueRouter)的时候,实际上是在执行VueRouterinstall函数:

  1. install函数在自身维护了一个install.installed标记来确保插件只被安装一次, 并缓存了Vue入参
  2. 使用Vue.mixin把 beforeCreatedestroyed`钩子函数注入到每一个组件
  3. 一些初始化工作,定义vm.$routevm.$router
  4. 通过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

vuex原理

最后

非常浅的过了一遍大佬的《vue 技术揭秘》,说实话没太吃透,暂时只在脑海中建立了 vue 的总体框架, 不太建议一上来就读这个,还是得先对 vue 有一定的理解。