Vue 2.6 源码剖析-响应式原理学习 - 1.起手

学习目标

  1. Vue.js 的静态成员和实例成员初始化过程
    1. 静态成员如:Vue.use、Vue.set、Vue.nextTick
    2. 实例成员如:vm. e l 、 v m . el、vm. elvm.set、vm.$mount
  2. 首次渲染的过程
  3. 数据响应式原理

准备工作

获取源码

  • 项目地址:https://github.com/vuejs/vue
  • Fork 一份到自己的仓库,克隆到本地,可以自己写注释提交到 github
    • 注意提交的时候要按照规范写提交日志,否则提交失败
      • 例如:docs: 乱写一通
  • 版本选择:Vue 2.6
    • 目前为止 (2020-07) Vue 3.0 还没有发布正式版
    • 新版本是兼容2.6的,发布后,现有项目不会升级到 3.0,2.x 还有很长一段过渡期
    • 3.0 项目地址:https://github.com/vuejs/vue-next

目录结构

  • dist - 打包的结果
    • Vue 打包后会生成很多不同的版本,这些版本在 dist/README.md 中都有相应的介绍
  • examples - 示例
    • 快速体验Vue的基本使用方式
  • src - 源码。根据不同的功能,拆分到不同的文件夹
    • compiler - 编译器,把模板(template)转换成render函数,render函数创建虚拟DOM
    • core - Vue 的核心
      • components 定义了 keep-alive 组件
      • global-api 定义了Vue的静态方法
      • instance 创建Vue实例,定义了Vue的构造函数、初始化、生命周期等相应函数
      • observer 实现响应式机制
      • util 公共成员
      • vdom 虚拟DOM
        • 重写了 Snabbdom,增加了组件的机制
    • platforms - 和平台相关的代码
      • web - web 平台下相关代码
        • entry-*.js 打包时候的入口
      • weex - 基于Vue的移动端开发框架 weex 平台下的相关代码
    • server - 服务端渲染的相关代码(Vue2.0后支持服务端渲染)
    • sfc - 单文件组件 Single Function Component
      • sfc中的代码会将单文件组件转化成JS对象
    • shared - 公共的代码

了解 Flow

Vue 2.x 是使用 Flow 开发的。

Vue 3.0 已经使用 TypeScript 开发,所以没有必要深入学习Flow。

  • Flow 和 TypeScript 都是 静态类型检查器。TypeScript更强大。
  • 它们都是基于 JavaScript 的,是JS的超集。
  • 最终都会编译成 JavaScript。
  • JavaScript 本身是动态类型检查,代码在执行过程中检查代码是否正确。
  • 静态类型检查:代码在编译(执行前)的时候检查代码是否正确。
  • 一般在大型项目中,使用静态类型检查,来确保代码的可维护性和可读性。

Flow 使用:

  • Flow 的 静态类型检查错误 是通过 静态类型推断 实现的。
  • 文件开头 通过 // @flow 或者 /* @flow */ 声明该文件需要 Flow 进行静态类型检查
  • 代码写完后,需要结合Flow官方提供的命令行工具,或vscode的插件,来检查类型是否错误。

在方法中的 形参 后通过冒号指明 参数 所使用的类型,在括号后使用冒号指明 方法返回值 的类型:

/* @flow */
function square(n: number): number {
  return n * n;
}
square("2"); // Error

调试

了解如何对Vue的源码进行打包和调试。

看源码的过程中,可以通过调试,来验证自己的一些想法。

打包

打包工具

Vue 源码中使用的打包工具是 Rollup。

  • Rollup 和 Webpack 都是打包工具,Rollup 比 Webpack 更轻量
  • Webpack 把所有文件当作模块,Rollup 只处理 js 文件,更适合在 Vue.js 这样的库中使用。
  • Rollup 打包不会生成冗余的代码。
    • Webpack 会生成一些支持浏览器端的代码。
  • 开发库的时候适合使用Rollup,开发项目适合使用 Webpack。
打包过程
  • 安装依赖 npm i
  • 设置 SourceMap
    • package.json 文件中的 dev 脚本中添加参数 --sourcemap
    • "dev": "rollup -w -c scripts/config.js --sourcemap --environment TARGET:web-full-dev",
  • 执行 dev npm run dev,打包使用的是Rollup
    • -w 监听文件变化,文件变化自动重载,更新结果
    • -c 指定配置文件
    • --environment 设置环境变量
      • 通过设置环境变量TARGET不同的值,来打包生成不同版本的 Vue

注意:

  • dev 脚本会生成单个版本的打包文件到dist目录。
  • build 脚本会生成所有版本的打包文件到dist目录。

为了便于区分,可以将dist清空,或拷贝后置空,来查看dev打包后的结果。

dev 和 build 都不会清空 dist 目录,也不会生成 README.md 文件(可以提前备份)。

开始调试

选择 examples 中的一个示例,如 grid。

将引入的 vue.min.js 改为 vue.js(dev生成的打包文件)。

浏览器打开示例的 index.html

F12 打开开发人员工具,在source面板中找到grid.js代码中创建vue实例的位置,添加断点,刷新页面。

页面到断点停止,F11 跳转到 src 下具体的文件,接下来就可以调试了。

Vue 的不同构建版本

源码(dist/README.md)中的解释:

打包文件说明
UMDCommonJSES Module
开发版本(未压缩版本)Full(完整版)vue.jsvue.common.jsvue.esm.js
开发版本(未压缩版本)Runtime-only(运行时版)vue.runtime.jsvue.runtime.common.jsvue.runtime.esm.js
生产版本(压缩版本)Full (production)vue.min.js
生产版本(压缩版本)Runtime-only (production)vue.runtime.min.js

术语

  • Full(完整版): 同时包含 编译器运行时 的版本。
  • Compiler(编译器): 用于将模板字符串(template)编译成 JavaScript 渲染函数(render函数)的代码,体积大(3000行代码),效率低。
  • Runtime(运行时): 用于创建 Vue 实例、渲染并处理虚拟 DOM 等的代码,基本上就是除去编译器的代码。体积小(相对少了3000行代码)、效率高。
  • UMD: UMD版本的通用模块化版本
    • UMD规范
      • 支持多种模块化方式,CommonJS、CMD、AMD 以及 挂载到window对象上使用
      • 可以通过<script>标签引入,在任意规范的项目中运行。
      • 内部其实是通过检测当前使用环境和模块的定义方式(判断defineexports等),将各种模块化定义方式转化为同样的一种写法。
    • 默认文件 vue.js 就是 运行时 + 编译器 的UMD版本
  • CommonJS(cjs): CommonJS 版本用来配合老的打包工具,如 Browserify 或 webpack 1
  • ES Module(esm): ES Module 版本用于配合现代打包工具,如 webpack2 或 Rollup。
    • ESM 是未来主流模块化方式,重点了解。
    • ESM 格式被设计为可以被静态分析(在编译时处理解析,而不是在运行时),所以打包工具可以利用这一点来进行 Tree Sharking,将用不到的代码排除出最终的包。

使用Vue CLI创建的项目,默认使用的 ESM 运行时版本,即 vue.runtime.esm.js。

开发版本 vs 生产版本

  • UMD 构建的开发/生产版本是硬编码的。
    • 未压缩的用于开发
    • 压缩的用于生产
  • CommonJS 和 ESM 构建的是用于配合 打包工具的。
    • 所以压缩任务交给打包工具,Vue不为它们提供压缩版本。

完整版 vs 运行时

<div id="app">
  Hello World
</div>

<!-- <script src="../../dist/vue.js"></script>
<script>
// compiler
// 需要编译器,把 template 转换成 render 函数
const vm = new Vue({
el: '#app',
template: '<h1>{{ msg }}</h1>',
data: {
msg: 'Hello Vue'
}
})
</script> -->

<script src="../../dist/vue.runtime.js"></script>
<script>
  // runtime
  // 运行时版本,不支持编译 tempalte,需要直接编写 render 函数
  const vm = new Vue({
    el: '#app',
    render(h) {
      return h('h1', this.msg)
    },
    data: {
      msg: 'Hello Vue'
    }
  })
</script>

运行时版本相比完整版体积小(轻30%),效率高,推荐使用。

Vue CLI 默认版本(运行时)

在Vue CLI创建的项目根目录执行命令vue inspect > output.js 查看webpack的配置(不是有效的webpack配置文件)。

在文件中找到resolve选项,查看别名alias配置中,vue模块的地址指向 vue/dist/vue.runtime.esm.js,即 ESM的运行时版本。

所以import Vue from 'vue'引入的就是上面这个文件。

SFC 单文件组件

浏览器环境不支持 SFC 类型的文件(.vue),所以打包过程中,打包工具会将 SFC 转换成 JS 对象(如 vue-loader)。

在转换过程中,就会把 tempalte 模板转换成 render 函数,所以 SFC 在运行时不需要编译器。

所以 runtime 版本中可以使用 SFC。

入口文件

寻找入口文件

在开始看源码之前,先找到入口文件。

通过查看 dist/vue.js 的构建过程,找到入口文件。

dev 脚本用于打包生成 dist/vue.js 文件。

  • -c scripts/config.js 指定配置文件
  • --environment TARGET:web-full-dev 指定环境变量 TARGET
    • TARGET - 指定构建版本
      • web - 浏览器环境
      • full - 完整版
      • runtime - 运行时版本
      • cjs - CommonJS版本
      • esm - ES Module 版本
      • dev - 开发版本

查看 dev 脚本中指定的配置文件 scripts/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)
}

查看 genConfig 方法:

function genConfig (name) {
  const opts = builds[name]
  const config = {
    input: opts.entry, // rollup入口文件
    external: opts.external,
    plugins: [
      flow(),
      alias(Object.assign({}, aliases, opts.alias))
    ].concat(opts.plugins || []),
    output: {
      file: opts.dest, // 输出路径
      format: opts.format,
      banner: opts.banner,
      name: opts.moduleName || 'Vue'
    },
    onwarn: (msg, warn) => {
      if (!/Circular/.test(msg)) {
        warn(msg)
      }
    }
  }
  // ...
  return config
}

查看 builds ,它存储了每个环境变量的值对应的一些配置信息,找到web-full-dev

const builds = {
  // ...
  // Runtime+compiler development build (Browser)
  'web-full-dev': {
    // 入口文件
    entry: resolve('web/entry-runtime-with-compiler.js'),
    // 打包后的目标文件
    dest: resolve('dist/vue.js'),
    // 模块化方式
    format: 'umd',
    // 打包方式
    env: 'development',
    // 别名
    alias: { he: './entity-decoder' },
    // 文件头:包含vue版本等信息
    banner
  },
  //...
}

项目目录中没有入口文件路径中的web目录,查看resolve方法,它将解析传入的路径:

const aliases = require('./alias')
const resolve = p => {
  // 根据路径中的前半部分去 alias 中找别名
  const base = p.split('/')[0]
  if (aliases[base]) {
    return path.resolve(aliases[base], p.slice(base.length + 1))
  } else {
    return path.resolve(__dirname, '../', p)
  }
}

查看 alias 模块:

const path = require('path')

const resolve = p => path.resolve(__dirname, '../', p)

module.exports = {
  vue: resolve('src/platforms/web/entry-runtime-with-compiler'),
  compiler: resolve('src/compiler'),
  core: resolve('src/core'),
  shared: resolve('src/shared'),
  // web 对应的路径
  web: resolve('src/platforms/web'),
  weex: resolve('src/platforms/weex'),
  server: resolve('src/server'),
  sfc: resolve('src/sfc')
}

所以入口文件就是:src/platforms/web/entry-runtime-with-compiler.js

是一个 runtime + compiler 的配置。

从入口开始调试

从入口开始调试,查看以下场景:

// 当同时定义了template和render会渲染什么?
const vm = new Vue({
  el: '#app',
  tempalte: '<h3>Hello tempalte</h3>',
  render(h) {
    return h('h4', 'Hello render')
  }
})

查看入口文件中的$mount方法:

// 获取 $mount 初始定义
const mount = Vue.prototype.$mount
// 重写 $mount 方法,增加功能
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  // 获取 el 对象
  el = el && query(el)

  // el 不能是 body 或 html
  if (el === document.body || el === document.documentElement) {
    process.env.NODE_ENV !== 'production' && warn(
      `Do not mount Vue to <html> or <body> - mount to normal elements instead.`
    )
    return this
  }

  const options = this.$options
  // 判断是否定义了 render 选项
  if (!options.render) {
    // 如果没有定义 render 函数,获取tempalte选项,进行处理
    let template = options.template
    // ...
    if (template) {
      // ...
      options.render = render
      // ...
      }
    }
  }
  // 调用 mount 方法,渲染DOM
  return mount.call(this, el, hydrating)
}
// src\platforms\web\util\index.js
// query 方法
export function query (el: string | Element): Element {
  // 判断是字符串还是dom对象
  if (typeof el === 'string') {
    // 如果是字符串,就认为是选择器
    const selected = document.querySelector(el)
    if (!selected) {
      process.env.NODE_ENV !== 'production' && warn(
        'Cannot find element: ' + el
      )
      return document.createElement('div')
    }
    return selected
  } else {
    return el
  }
}
// src\platforms\web\runtime\index.js
// $mount 方法初始定义:渲染DOM
// public mount method
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}

运行 dev 生成vue.js,开始调试。

在入口文件的$mount方法内第一行打断点,刷新页面,查看Source面板的Call Stack。

Call Stack 记录调用栈,从下到上是依次执行的方法。

依次点击调用栈,可以跳转到调用方法的位置。

可以追踪到 Vue.$mount 的调用来源。

在这里插入图片描述

继续调试,由于定义了render,$mount 内部直接调用并返回 mount 方法。

最终页面渲染的是 render 定义的内容。

总结

  • 阅读源码记录
    • el 不能是 body 或 html 标签
    • 如果没有 render ,把template 转换成 render 函数
    • 如果有render,直接调用 mount 挂载 DOM
  • 调试代码的方法
    • 断点
    • 调用堆栈 - 查看方法之间调用关系
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值