Vue2源码起手式

967 阅读7分钟

原文地址:kkanll.wang/posts/2022-…

事前准备

Fork代码

Vue仓库地址 作用:

  1. 方便写自己注释
  2. 方便便携demo,调试程序

项目结构1

└── vue                  // project root
   ├─benchmarks          // 性能测试相关
   ├─dist                // 打包后的文件集合
   ├─flow                // flow类型声明
   ├─packages            // 与`vue`相关的一些其他的npm包, `vue-server-render`, `vue-template-compiler`等
   ├─scripts             // 构建相关的脚本
   ├─src                 // 源代码入口
   |  ├─shared           // 项目中用到的一些公共变量,方法等
   |  ├─sfc              // 用于处理单文件组件(.vue)解析的逻辑
   |  ├─server           // 服务端渲染相关的代码
   |  ├─platforms        // 不同平台之间的代码
   |  ├─core             // Vue的核心**运行时**代码
   |  |  ├─vdom          // 虚拟dom相关的代码
   |  |  ├─util          // Vue里用到的一些工具方法抽取
   |  |  ├─observer      // 实现响应式原理的代码
   |  |  ├─instance      // vue实例相关的核心逻辑
   |  |  ├─global-api    // 全局api Vue.extend, Vue.component等
   |  |  ├─components    // 内置的全局组件
   |  ├─compiler         // 与模板编译相关的代码
   ├─types               // Typescript类型声明
   ├─test                // 测试相关的代码

阅读源码前的一些Tips

"蔑视"源码

这里所说的蔑视不是说轻视源码的作用,而是在面对源码时不要过于害怕或者抵触。
一开始很多新手(没错,我也是其中一员)开始看源码前,都会被源码的庞大和复杂所震慑,不敢或者不愿意去看源码。
其实大可不必,源码阅读是需要一定前置知识和技巧的铺垫的,新手看不懂源码非常正常,不用为之苦恼,甚至觉得自己菜,自己不行啥的(别问我怎么知道的)。

不要迷失在源码中

Vue, React等开源库的源码通常都很庞杂,里面充斥着各种边界情况的处理,工具函数的运用,看着看着你可能就不知道自己跳转到哪里去了,回头一想,又不知道自己看到哪儿了,也忘记了这个函数/变量是干嘛的。
我个人(也是很多人推荐的)觉得,阅读源码,先要对他有一个整体的认识,了解他大概是由哪几部分组成的,入口文件在哪里,然后由入口文件去分析他各个模块的调用顺序(这个时候完全不用看具体实现);这样就能了解一个大致的结构了。
这时,我们再带着我们的目标--你想了解哪一部分的源码: 比如响应式系统,Set, nextTick等,再去对应的部分找具体的实现。
这样做有几个好处:

  1. 结构清晰,方便我们快速定位具体功能的代码实现
  2. 大脑负担小,当我们设立了目标,其他不是目标的代码我们就可以忽略暂时不看
  3. 这样做的一个过程,也是将一个复杂任务拆分成若干简单任务,让我们有看源码的动力和勇气

标记源码

在自己fork的源码中,在合适的地方打上log。这样一来代码的执行过程会比较清楚,二来在你复看源码时也会提供比较大的帮助。

关于调试

阅读源码的过程中,如果有任何不清楚的地方(比如某个变量是什么值,函数的调用位置等),可以通过chrome给我们提供的工具来帮助我们更好的理解。
在调试之前,我们需要先做几个准备工作:

  1. 构建带有sourceMap的,Vue编译&运行时文件
    scripts命令中加上--sourcemap选项
{
  "scripts": {
    "dev": "rollup -w -c scripts/config.js --environment TARGET:full-dev --sourcemap"
  }
}
  1. 准备一个最起初的Vue起始文件。(带有new Vue()render()函数,并且使用$mount执行渲染)。(随便渲染一个div即可)
  2. 这里我们以寻找Vue.prototype.$mount的调用位置为例:
    在chrome的源码标签页中,Vue.prototype.$mount的第一行代码上打上断点 vue-use-api

然后我们刷新当前页面,进入调试模式
在chrome调试工具右侧,可以找到调用栈的信息。大致长这样:
vue-use-api
从调用栈上,我们不难发现,Vue.$mount是在Vue._init方法中调用的,而Vue._init又是由Vue的构造函数调用的。(anonymous)表示匿名函数,点进去就能看到就是我们new Vue()时的代码。

寻找入口文件

在Vue源码中,它是由Rollup来进行打包的,npm run dev这条指令则指示了打包开发环境时所使用的js文件,通过查看这个文件,我们就能找到Vue源码的入口的文件。

{
  "scripts": {
    "dev": "rollup -w -c scripts/config.js --environment TARGET:full-dev"
  }
}

可以看到,Rollup运行的打包文件是位于script目录下的config.js文件。 同时还传入了TARGETfull-dev

config.js内,抛开定义各种变量,执行逻辑其实只有一个if...else...语句

//config.js

//判断环境变量是否有TARGET
//getConfig()生成rollup配置文件
if (process.env.TARGET) {
  module.exports = genConfig(process.env.TARGET)
} else {
  exports.getBuild = genConfig
  exports.getAllBuilds = () => Object.keys(builds).map(genConfig)
}

getConfig()函数内,则通过builds这个对象来找到full-dev的选项

//config.js

const builds = {
  /*-----代码省略-----*/
  // Runtime+compiler development build (Browser)
  'full-dev': {
    entry: resolve('web/entry-runtime-with-compiler.ts'),
    dest: resolve('dist/vue.js'),
    format: 'umd',
    env: 'development',
    alias: {he: './entity-decoder'},
    banner
  },
  /*-----代码省略-----*/
}

function genConfig(name) {
  const opts = builds[name]
  /*-----代码省略-----*/
}

full-dev对象就可以找到入口文件,entry-runtime-with-compiler.ts了。(其他版本入口文件同理)

Vue初始化过程

我们找到了编译的入口文件entry-runtime-with-compiler.ts,接下来就可以分析Vue初始化的大概流程了。
一图胜千言,我总结了下这个流程:
vue-use-api

打包的过程中,遇到import会先进入被引入的模块,执行里面的内容。
所以图中的引用关系,由最里层的instance/index.ts开始,逐步向外执行。打印信息也印证了这一点:
vue-use-api

new Vue()过程

Vue的初始化过程在真正创建Vue实例之前,做了一些准备、铺垫工作,当然包括Vue的构造函数。 在instance/index.ts定义了Vue的构造函数,我们在new Vue()时,就执行这个构造函数

function Vue(options) {
  if (__DEV__ && !(this instanceof Vue)) {
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  this._init(options)
}

使用函数来作为构造器是因为这样方便在Vue的原型上添加方法、属性。

在这个构造函数中又会执行_init() ,篇幅原因源码就不放在这里了,点击这里查看(附带中文注释)
_init是源码中非常重要的一个函数,他的执行过程如下图: _init()

其中选项合并、注册各种方法和属性的初始化方法我们先按下不表,先来看下$mount 的内部实现。源码在这。

runtime-with-compiler.ts中的$mount()运行逻辑如下图所示: mount

需要注意的是,在runtime-with-compiler.tsruntime/index.ts有两处地方都注册了$mount() 方法,原因是:Vue源码打包时会分别生成编译时+运行时和运行时等多个版本的代码。如果只在上述文件中一个地方注册$mount()那肯定是不行的,所以在运行时版本中会先定义一次$mount() 版本,在编译时+运行时版本中则会保存运行时版本中定义的$mount()方法,然后覆写$mount()方法,并在覆写后的$mount()的最后调用运行时注册的$mount()方法。

//runtime/index.ts
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  //这里再次获取el的原因是
  //如果我们使用的不是运行编译时版本
  //那mount时会直接执行这个$mount
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}
//runtime-with-compiler.ts

//保存 Vue.prototype.$mount 方法
const mount = Vue.prototype.$mount
//相比runtime/index.ts中的$mount新增将template编译成render函数的部分
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  // ...
  // ...
  return mount.call(this, el, hydrating)
}

所以,挂载的关键函数就是mountComponent

export function mountComponent(
  vm: Component,
  el: Element | null | undefined,
  hydrating?: boolean
): Component {
  vm.$el = el
  //没有传入render函数,会赋值createEmptyVNode方法
  if (!vm.$options.render) {
    vm.$options.render = createEmptyVNode
    //runtime版本下,如果传入了template,会警告⚠️
    //...
  }
  callHook(vm, 'beforeMount')
  
  let updateComponent //这里只是定义,执行在Wather中
  if (__DEV__ && config.performance && mark) {
    //性能检测代码,忽略
  } else {
    //_render 使用用户传入的render, 或者没传render时vue生成的render。最终返回虚拟dom
    //_update 将传入的虚拟dom转换成真实dom,渲染到界面上
    updateComponent = () => {
      vm._update(vm._render(), hydrating)
    }
  }
  new Watcher(
    vm,
    updateComponent,
    noop,
    watcherOptions,
    true
  )
  //...
}

mountComponent会先判断实例的$options是否存在render函数,如果不存在则会告警;之后调用生命周期钩子beforeMount;注册updateComponent 函数,在这个函数中,调用实例上的_update()函数,转换_render()生成的虚拟dom为真实dom,渲染到界面上。
这里定义了updateComponent,调用是在之后的watcher类中,之后在响应式系统的部分我们在来看watcher部分的代码,这里只要知道更新视图实在watcher中执行的就可以了。
最后会触发生命周期函数mounted,并返回vm实例。

参考文章

Footnotes

  1. Vue2.x 源码学习系列-目录结构介绍