Vue2源码解析
[TOC]
教程一
大纲
第一周(从零手写Vue2部分)https://www.bilibili.com/video/BV1mR4y1w7cU
Vue2响应式原理,模板编译原理,虚拟Dom原理,Vue初渲染流程Vue2中生命周期原理,mixin原理,依赖收集Watcher、Dep原理- 手写
computed及watch原理,异步更新原理 - 手写
Vue2中组件渲染原理、Vue.extend原理,Vue2diff算法
目标:掌握
Vue2核心源码及核心设计思想
第二周(从0手写VueRouter及Vuex)
- 掌握
HashHistory、BrowserHistory及路由钩子实现原理,及RouterView、RouterLink组件实现 - 从0实现
Vuex,彻底掌握Vuex设计思想
目标:掌握前端路由实现原理及状态管理实现原理
第三周
- 剖析
Vue2源码,调试Vue2核心源码 Vue2常见面试题解析
目标:掌握如何阅读框架源码,掌握
Vue相关面试题
第四周(TS详解、掌握TS核心应用)
TS环境搭建、基础类型、类型推导、类- 接口、泛型、
TS兼容性 - 类型保护、高级类型、模块命名空间等
目标:掌握
TS的使用为学习Vue3做准备
第五周(Vue3核心讲解)
- 掌握
Vue3核心语法及组件化开发,Vue3新特性和新增API Vue3 + Vite掌握VueRouter4及Vuex4应用,element-plus组件库使用Vue3 + TS后台管理系统项目实战(一)
目标:快速上手
Vue3,利用Vue3 + TS开发项目
第六周(Vue3项目实战)
Vue3 + TS后台管理系统项目实战(二)Vue3 + TS后台管理系统项目实战(三)- 从零实现
Vite,掌握Vite原理 Vue3源码剖析,从零实现Vue3源码- 从零搭建
Vue3组件库
第一周(从零手写Vue2部分)
使用Rollup搭建开发环境
不是重点,只会搭建最简单的环境方便编写vue代码
一般类库的打包,会用rollup,打包的体积相较webpack会更小,因为rollup更专注一些,主要用来打包js
新建文件夹VUE2-STAGE
1 | npm init |
实操注意点:
提示rollup-plugin-babel不更新维护了
1 | [root@VM-4-12-centos VUE2_STAGE]# npm i rollup rollup-plugin-babel @babel/core @babel/preset-env -D |
解决办法:
卸载rollup-plugin-babel:
1 | npm uninstall rollup-plugin-babel |
安装推荐的包
1 | npm i @rollup/plugin-babel -D |
安装完毕后的包信息:
1 | { |
根目录VUE2-STAGE新建rollup配置文件rollup.config.js
1 | // rollup 默认可以导出一个对象,作为打包的配置文件 |
根目录新建.babelrc文件
1 | { |
配置较少的话,也可以直接写在rollup.config.js中
在package.json中添加npm run dev脚本
-c:指定默认的配置文件
-w:监视文件变化
1 | // ... |
根目录新建打包入口文件src/index.js
1 | export const a = 100 |
测试能否打包
1 | npm run dev |
成功显示如下信息
1 | [root@VM-4-12-centos VUE2_STAGE]# npm run dev |
可能会提示你修改package.json
新增字段:
1 | "type": "module" |
根目录下会生成之前配置的目录及文件夹
1 | [root@VM-4-12-centos VUE2_STAGE]# tree ./dist/ |
index.js对应的打包文件
1 | (function (global, factory) { |
可以新建index.html并引入该打包文件
1 |
|
全局上多了一个Vue的对象,身上的属性就是我们导出的,效果如下:

index.js中也可以设置断点,进行调试
1 | export const a = 100 |
已完成:
- 1.使用
Rollup搭建开发环境
初始化数据
响应式数据的核心?数据变化了,我可以监控到
监控的是什么呢?是数据的取值和更改值
监控到然后干嘛呢?更新视图
满足上面要求的数据,就是响应式数据。简单点来说,响应式数据变化可以更新视图
不考虑工程化开发,当初在html中我们是这么写Vue代码的
把所有需要的数据,都放在配置对象里
1 | <script src='vue.js'></script> |
Tips:
Vue2中没有用类的写法,因为类的方法如果有很多,就都耦合在一起了,函数的写法直接Vue.prototype就可以了(虽然类也可以Vue.prototype这样写,但一般不会这样搞)
1
2
3
4
5
6
7
8
9 function Vue() {
}
Vue.prototype.a = function(){}
Vue.prototype.b = function(){}
Vue.prototype.c = function(){}
export default Vue并且可以将扩展的功能单独放在一个文件中,方便管理
那么现在要干嘛呢?
模拟实现Vue的功能
index.js
options就是用户传入的选项,拿到options后,肯定要处理下的
我们给原型对象上添加个_init方法,专门用来做初始化
1 | function Vue(options) { // options就是用户的选项 |
但是不能都写在index.js里,不然代码一多就完犊子了
新建src/init.js
1 | // 用于初始化操作 |
问题来了,这个时候Vue就丢失了,咋整?
可以把初始化操作,封装成函数并导出,这个函数接收一个形参
init.js
1 | // 就是给Vue增加_init方法的 |
在index.js就可以传入实参Vue使用了
1 | function Vue(options) { // options就是用户的选项 |
initMixin相当于扩展了_init方法,后续再有逻辑,可以再initXXX(xxx),就可以原型方法,扩展成一个个函数,通过函数的形式在原型上扩展功能
靠谱!!!
需要把options扩展到vm实例上,为什么不直接用呢?
- 或许扩展的第二个方法,怎么拿到
options呢,只有通过实例来传递
看下面的代码:
init.js
1 | export function initMixin(Vue) { |
可以看到,此时Vue上多了$options属性

写法优化
1 | export function initMixin(Vue) { |
this和vm都指向Vue实例

挂载完options之后,干嘛呢?
我们不是传入了data这些配置项嘛,要进行初始化状态,Vue中的状态有很多,如props/data/computed/watch等等配置项,这些都是要初始化的
1 | export function initMixin(Vue) { |

将initState和initData这两个初始化数据的方法,单独抽出来,放到state.js中
1 | export function initState(vm) { |
至此,状态初始化中的数据初始化,已经完成了第一步,拿到用户自定义配置
本小节技能点需完善的地方
- 浏览器控制台调试大全
js中this的系列问题js中的call方法
下一小节,实现对象的响应式原理
Vue响应式原理实现
对象属性劫持
对数据进行劫持
vue2中采用了defineProperty
我们定义一个方法obeserve观测数据,这是一个核心模块,我们单独新建observe文件夹进行处理
state.js
1 | import {observe} from "./observe/index" |
新建src/observe/index.js
1 | export function observe(data) { |
observe中可以拿到data数据

实现对象属性劫持
在observe方法中对对象类型的数据进行劫持
- 先对传入的数据类型进行判断,只对对象进行劫持
- 如果一个对象被劫持过了,那就不需要再被劫持了
- 要判断一个对象是否被劫持过
- 可以添加一个实例,用实例来判断是否被劫持过。
- 在
observe函数中新建Observer类,这个类是专门用来观测数据的,如果数据被观测过,那么它的实例就是这个类??- 这里看不明白,可以直接先往下看,到具体代码那儿就清楚了
- 要判断一个对象是否被劫持过
observe/index.js
1 | class Observer { |
问题:Object.defineProperty只能劫持已经存在的数据,后增的或者删除的属性,是劫持不到的(为此Vue2单独写了一些api,比如说$set、$delete)
Observer类型中,要遍历对象- 可以专门写个
walk方法来干这件事,循环对象,对属性依此劫持 - 拿到所有的
key后遍历,“重新”定义属性(重新定义的话,相当于每个key都要遍历,这也是Vue2性能较差的原因)- 定义
defineReactive方法,实现将某个对象数据定义成响应式 - 该方法需要后面可以单独使用,所以写在
Observer同级,并导出
- 定义
- 可以专门写个
observer/index.js
1 | class Observer { |
我们在使用的时候,打印下vm实例
index.html
1 | <script src="./vue.js"></script> |

虽然定义了响应式,但此时vm实例上直接是拿不到data的
我们可以在state.js的initData中,在vm身上增加_data属性,将data赋值给vm._data(在观测属性之前)
state.js
1 | // ... |
此时vm身上就有了_data,存着data的响应式数据

思考:vm._data=data是在做观测数据之前存的,为啥_data也变成了响应式的呢?
- 直接赋值是浅拷贝,
_data和data变量中存的是对象值在堆内存中的引用地址 - 原对象的值变了,但
_data中存的引用地址没有变 - 所以即使是后做的响应式,
vm._data自然也是响应式的了 - 观测数据之后,进行赋值也不是不可以
用户用法简化
现在还有个小问题,就是取数的时候,要写成vm._data.name,每次都要加个_data,有点恶心
我们能不能直接vm.name去取值呢
- 当用户在
vm.name上取值时,我们就代理到vm._data.name上 - 将
vm._data用vm来代理- 依旧是做一个循环来处理
- 自定义
proxy方法:proxy(vm, '_data'),代理vm身上的_data
state.js
1 | // ... |

我们先写get方法,可以看到vm代理了vm._data,身上有了name和age属性
同样,在设置值时也要加个代理
state.js
1 | // ... |

此时写法上就可以更便捷的取值及修改值了
嵌套对象属性劫持
还有个问题:上面的写法只会监测到对象的第一层,一旦传入的data是嵌套的,里面的属性并没有被监测到
如下所示,address内部属性并没有被劫持
index.html
1 | <script src="./vue.js"></script> |

当初的defineReactive函数,入参的value可能是个对象
- 再次调用
observe方法,如果value是个对象,会再次创建Observer实例,再次调用walk方法,劫持每个属性 - 这样就实现了对所有的对象,都进行了属性劫持
- 是个递归,性能消耗也是可以的
observe/index.js
1 | class Observer { |
此时不管传入的是几层,对象属性都是被劫持过了的

至此,对象属性劫持的define核心逻辑就完成了
- 循环对象,给对象用
defineReactive方法,把属性重新定义- 如果值还是对象的话,需要对这个对象进行递归操作
- 这个用户在取值和修改值时,就可以监控到
- 对象被劫持完之后,为了方便用户获取,把
data放在了vm._data上- 再用
vm代理vm._data,这样用户在取值和修改值时,只要写成vm.name即可
- 再用
已完成:
- 1.使用
Rollup搭建开发环境 - 2.
Vue响应式原理实现,对象属性劫持,深度属性劫持
数组的方法劫持
如果data里面还有数组呢
index.html
1 | <script src="./vue.js"></script> |
我们看一下打印结果

defineProperty把数组里的每个属性,都增加了get、set,虽然通过vm.hobby[0]取值时,的确会被监测到,但是一旦数据量大了,就很消耗内存了
并且通过下标的方式来修改值,如修改第888个数组的值,vm.hobby[888]=123,一般也不会有这种操作
修改数组,很少用索引来修改数组,并且内部劫持数组,会浪费性能
用户一般都是都过方法来修改数组:push、shift等等
在observe/index.js里的Observer类的构造函数中
- 对数组类型进行判断
- 如果是数组
- 重写数组的方法,7个变异方法(可以修改数组本身的方法)
- 如果数组内部,还嵌套有对象,如
hobby:['eat','drink',{a:1}]也应该对对象属性进行劫持- 定义
observeArray方法,实现该功能- 循环传入的
data,递归调用observe方法
- 循环传入的
- 定义
- 如果不是数组
- 继续之前的逻辑,添加代理
- 如果是数组
数组中的对象属性劫持
定义observeArray方法,对数组中的对象进行属性劫持
observe/index.js
1 | class Observer { |
打印结果如下:

我们在defineReactive的函数的get中添加打印语句:console.log('key', key)
然后在样例中取数vm.hobby[2].a
打印结果如下:

表示先取了hobby,再取了a,说明数组中的对象,是可以被监控到的
数组的方法劫持
如果是vm.hobby.push['1'],会打印了hobby,说明目前只能监控到get,无法监控到修改
所以就需要重写数组的方法
在传入的
data对应的__proto__属性中,重写各个数组方法给当前数组的的原型链,重新指向新的原型(也会覆盖掉
forEach方法,可以先注释掉observeArray方法的调用)observe/index.js1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23class Observer {
constructor(data) {
if(Array.isArray(data)) {
// 这里我们可以重写数组的7个变异方法(可以修改数组本身)
data.__proto__ = {
push() {
console.log('重写的push')
}
}
// this.observeArray(data) // 递归处理数组中的对象
} else {
this.walk(data)
}
}
walk(data) {
Object.keys(data).forEach(key => defineReactive(data, key, data[key]))
}
observeArray(data) {
data.forEach(item => observe(item))
}
}
// ...打印如下:

当然我们不可能直接这样重写__proto__,我们需要保留数组原有的特性,并且可以重写部分方法,observe文件夹下新建array.js
存一份原来的
Array.prototype,该对象上定义着各种方法1
let oldArrayProto = Array.prototype

以
oldArrayProto为原型定义新的变量1
let newArrayProto = Object.create(oldArrayProto)
目前
newArrayProto是一个空对象,:
定义所有的变异方法:
1
2
3
4
5
6
7
8
9let methods = [
'push',
'pop',
'shift',
'unshift',
'reverse',
'sort',
'splice'
]遍历
methods数组,给newArrayProto循环添加属性,这一步就是在重写1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17let oldArrayProto = Array.prototype
let newArrayProto = Object.create(oldArrayProto)
let methods = [
'push',
'pop',
'shift',
'unshift',
'reverse',
'sort',
'splice'
]
methods.forEach(method => {
newArrayProto[method] = function (...args) { // 这里重写了数组方法
return 'a'
}
})我们现在调用
newArrayProto身上的方法,返回的都是自定义的a
很明显,在返回之前调用一下原来的方法就可以了
这里要注意要,如果不改变
this的指向,比如调用push方法的时候,实际上就是oldArrayProto这个原型对象调用了push方法,属性会被加到原型对象上面,样例:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29let oldArrayProto = Array.prototype
let newArrayProto = Object.create(oldArrayProto)
// 找到所有的变异方法
let methods = [
'push',
'pop',
'shift',
'unshift',
'reverse',
'sort',
'splice'
]
methods.forEach(method => {
newArrayProto[method] = function (...args) {
// const result = oldArrayProto[method].call(this, ...args)
const result = oldArrayProto[method](...args)
return result
}
})
let data1 = ['a']
data1.__proto__ = newArrayProto
data1.push('b', 'c')
console.log(data1)可以看到,
push方法全都作用在了oldArrayProto身上了,因为方法本身就是定义在它身上的
需要注意
this指向问题,谁调的this应该就指向谁,所以要使用call方法,将push方法执行时的上下文,改为data本身1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29let oldArrayProto = Array.prototype
let newArrayProto = Object.create(oldArrayProto)
// 找到所有的变异方法
let methods = [
'push',
'pop',
'shift',
'unshift',
'reverse',
'sort',
'splice'
]
methods.forEach(method => {
newArrayProto[method] = function (...args) {
const result = oldArrayProto[method].call(this, ...args)
// const result = oldArrayProto[method](...args)
return result
}
})
let data1 = ['a']
data1.__proto__ = newArrayProto
data1.push('b', 'c')
console.log(data1)push方法指定了正确的上下文:
本例如下:
1
2
3
4
5
6
7methods.forEach(method => {
// arr.push(1,2,3)
newArrayProto[method] = function (...args) { // 这里重写了数组方法
const result = oldArrayProto[method].call(this, ...args) // 内部调用原来的方法,一般称为函数的劫持(切片编程(切面编程):自己写个功能,把以前的功能塞进去,外面可以做一些自己的事,aop)
return result
}
})
最后导出
newArrayProto对象,在observe/index.js中导入,并将data的隐式原型属性指向newArrayProto对象
完整示例:
observe/array.js
1 | // 我们希望重写数组中的部分方法 |
observe.index.js
1 | import { newArrayProto } from './array' |
index.html测试
1 | <script src="./vue.js"></script> |
测试下,用到的是什么方法,就会打印什么方法
但是,如果追加的是一个对象,还会有问题
1 | <script src="./vue.js"></script> |
ps:记得取消之前的注释
1 | // ... |
可以看到,虽然hobby里面的对象被劫持了,但是数组中新增的对象,并没有被劫持
因为目前我们只是拦截了变异方法,并没有对新增的属性做处理,即要对rest参数args做处理

我们劫持了函数之后,也要对新增的数据再次进行劫持
拿到
rest参数(是个数组),根据调用的方法做不同的处理push、unshift- 直接可以用过
rest参数获取到
- 直接可以用过
splice- 如果是删除操作,
splice方法是没有第三个参数的,args是为
- 如果是删除操作,
根据不同的方法,拿到新增的内容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21methods.forEach(method => {
newArrayProto[method] = function (...args) {
// ...
// 我们需要对新增的数据,再次进行劫持
let inserted
switch (method) {
case 'push':
case 'unshift': // arr.unshift(1,2,3)
inserted = args
break
case 'splice': // arr.splice(0, 1, {a:1}, {b:2}) 只要第三个参数有值,即是新增了属性
inserted = args.slice(2) // [{a:1}, {b:2}]
break
default:
break
}
console.log('新增的内容', inserted)
// inserted是一个数组
return result
}
})对新增的内容,再次进行观测
inserted是一个数组,要对数组进行观测,需要拿到在Observer类中定义的observeArray方法,但不好直接拿到该方法在
forEach中,我们只能拿到this,指向的是上下文(指向的是调用push方法的那个对象),可以打印下this,指向的就是data,在index.html中,调用的形式是vm.hobby.push(1,2,3),也表明了是data调用的push在
Observer类的构造函数中,在data上,自定义__ob__属性,指向this:data.__ob__ = this,这里的this指向的是Observer类的实例1
2
3
4
5
6
7
8
9
10
11
12class Observer {
constructor(data) {
data.__ob__ = this // `data`属性上再自定义`__ob__`属性,指向`Ovserver`的实例
if(Array.isArray(data)) {
data.__proto__ = newArrayProto
this.observeArray(data)
} else {
this.walk(data)
}
}
// ...
}那么在
forEach中,由于this指向的就是data,可以通过this.__ob__拿到Observer的实例,然后调用observeArray方法- 在循环内部,就可以通过
this.__ob__.observeArray对新增内容进行观测了 - 这并不是一种设计上的巧妙,是没办法解决了,只能写成这样
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23methods.forEach(method => {
newArrayProto[method] = function (...args) {
// ...
let inserted
let ob = this.__ob__ // 指向的是Observer类的实例
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
break
default:
break
}
// inserted是一个数组
if(inserted) {
ob.observeArray(inserted) // 调用监测数组的方法
}
return result
}
})- 在循环内部,就可以通过
同时,另外一个好处是,也给
data加了一个标识,如果data上有__ob__,则说明这个属性被观测过,可以借助此完善observe函数的判断observe/index.js1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50import { newArrayProto } from './array'
class Observer {
constructor(data) {
data.__ob__ = this
if(Array.isArray(data)) {
data.__proto__ = newArrayProto
this.observeArray(data)
} else {
this.walk(data)
}
}
walk(data) {
Object.keys(data).forEach(key => defineReactive(data, key, data[key]))
}
observeArray(data) {
data.forEach(item => observe(item))
}
}
export function defineReactive(target, key, value) {
observe(value)
Object.defineProperty(target, key, {
get() {
console.log('key', key)
return value
},
set(newValue) {
if(value == newValue) return
value = newValue
}
})
}
export function observe(data) {
// 对data类型进行判断
if(typeof data !== 'object' || data == null) {
return // 只对对象进行劫持
}
// 如要考虑到一个对象已经被劫持的情况
// 如果一个对象已经被劫持过了,那么就不需要再被劫持
// 可以添加一个实例,用实例来判断是否被劫持过(应该是用实例身上的属性)
if(data.__ob__ instanceof Observer) {
return data.__ob__ // 如果被代理过了,直接返回它的实例
}
return new Observer(data)
}但这样写行不行呢?我们再来测试下,看下页面

完犊子了,内存爆了!
咋回事,我们是为了解决
data是数组的情况,给data添加了__ob__自定义属性但是,如果
data是对象,它会先加一个自定义属性__ob__,这是合理的,相当于增加一个标识,这一步没问题,但到了下一步,走walk方法,会被data身上的__ob__属性,也是对象,然后在加一个,再走walk,就死循环了在
data.__ob__ = this之前打个断点,执行到walk时,我们进入内部,看下data
再继续往下走到
observe,添加条件断点:key === __ob__,不断点下一步,观察右边的调用栈,一直在增加
那么怎么处理呢?我们希望在遍历对象的时候,不能遍历到
__ob__这个属性,让其变成不可枚举的即可。改写原来的写法:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18class Observer {
constructor(data) {
// data.__ob__ = this
// 在data上添加属性的同时,让其变成不可枚举的
// 并且这种写法,也没有影响到data位数组的情况
Object.defineProperty(data, '__ob__', {
value: this,
enumerable: false // 将__ob__变成不可枚举(循环的时候无法获取到)
})
if(Array.isArray(data)) {
data.__proto__ = newArrayProto
this.observeArray(data)
} else {
this.walk(data)
}
}
// ...
}
此时我们再测试一下
index.html1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24<script src="./vue.js"></script>
<script>
const vm = new Vue({
data() {
return {
name: 'sai',
age: 11,
address: {
street: 'RoadA',
room: 123
},
hobby: [
'eat',
'drink',
{
a: 1
}
]
}
}
})
vm.hobby.unshift({a:1})
console.log(vm.hobby)
</script>数组里有四项,通过数组方法新增的对象,也有了
get和set
至此,数组的劫持,全部搞定
- 数组劫持核心,就是重写数组的方法,并且去观测数组中的每一项
- 如果是数组的话,需要对每一项新增的属性,做一下判断,并且把数组的每一项,再进行观测
接下来就要和视图挂钩了
模板编译原理
当初我们需要写一个div并指定id,在里面写小胡子语法
index.html
1 | <body> |
我们要对这个模板进行编译,需要给配置对象,传一个el属性,将数据解析到el元素上,将{{name}}和{{age}}进行一个数据的替换
- 方案一:模板引擎
- 每次把模板拿到,用数据来替换
- 性能很差,需要正则匹配替换(
vue1.0的时候,没有引入虚拟dom)
- 方案二:采用虚拟
dom- 数据变化后,比较虚拟
dom的差异,最后更新需要更新的地方 - 核心就是,把模板变成
js语法,可以通过js语法生成虚拟dom- 从一个东西,变成另一个东西(语法之间的转换),这是一个很典型的语法转译问题,如
es6 => es5,需要先变成语法树,再重新组装代码,成为新的语法
- 从一个东西,变成另一个东西(语法之间的转换),这是一个很典型的语法转译问题,如
- 数据变化后,比较虚拟
现在我们要拿到模板
1 | <body> |
模板除了可以写在el上,也可以写在template上
1 | <script src="./vue.js"></script> |
或者可以用一个方法来替代:render()
1 | <script src="./vue.js"></script> |
而我们最终的目标就是,把template语法变成render()函数
将模板转换成ast语法树
状态初试完完了,就去看用户,有没有给el属性
先看配置项,有没有
el属性- 如果有,
vm原型对象上定义$mount函数,将options.el传入$mount方法,并实现数据的挂载- 有了
$mount方法后,我们也可以不写el配置项,直接调用vm.$mount("#app")实现手动挂载
- 有了
- 如果有,
$mount的功能先找到对应的元素:
1
2
3
4Vue.prototype.$mount = function (el) {
const vm = this
el = document.querySelector(el)
}返回
el对应的dom元素根据配置项的进行进行不同的处理
- 判断是否有
render函数- 如果没有
- 判断是否有
template配置项- 如果没有
template配置项,但是有el配置项- 使用
el.outerHTML拿到模板
- 使用
- 如果有
template配置项,就用template配置项
- 如果没有
- 判断是否有
- 如果没有
init.js1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33import {initState} from "./state";
export function initMixin(Vue) {
Vue.prototype._init = function(options) {
const vm = this
vm.$options = $options
initState(vm)
if(options.el) { // 看是否有el配置项
vm.$mount(options.el) // 实现数据的挂载
}
}
Vue.prototype.$mount = function (el) {
const vm = this
el = document.querySelector(el)
let ops = vm.$options
if(!ops.render) { // 先查找render函数
let template
if(!ops.template && el) { // 没有template配置项,但是有el配置项
let template = el.outerHTML // 就用el的配置项,outHTML返回的是匹配到自身的dom元素
} else { // 如果既有template,又有el,就用template配置项作为模板
if(el) {
template = ops.template
}
}
// 其他情况的分支考虑
console.log(template)
}
}
}- 判断是否有
这里其他分支的细节代码就不写了,如都没有的情况应该怎么处理等
挂载el配置项:
index.html
1 |
|
结果:

这里可以多试试不同的情况
整体逻辑是:先找render函数,没写的话就找template配置项,再没写的话,就用外部的html
最终获取到template后,将template传入到自定义函数compileToFunction中进行渲染
init.js
1 | Vue.prototype.$mount = function (el) { |
实际开发中可以写jsx,它是依赖babel做了编译(vue中有对应的jsx-plugin插件),如果写jsx相当于多了一层:将jsx转换成render函数返回的h方法
script标签引用的vue.global.js,这个编译过程是在浏览器运行的runtime是不包含模板编译的,整个编译打包的时候,是通过loader来转义.vue文件中的<template></template>- 只有
runtime是不能写template配置项的
- 只有
新建src/compiler/index.js,表示编译模板
index.js
1 | // 对模板进行编译处理 |
init.js中导入
1 | import {compileToFunction} from "./compiler" // 使用@rollup/plugin-node-resolve插件,可省略index.js |
如果想自动导入index文件,可以安装插件,rollup环境下安装:npm i @rollup/plugin-node-resolve
详细使用方式:@rollup/plugin-node-resolve - npm (npmjs.com)
样例:
1 | import { nodeResolve } from '@rollup/plugin-node-resolve'; |
rollup.config.js
1 | // rollup 默认可以导出一个对象,作为打包的配置文件 |
代码生成,实现虚拟DOM
上一节回顾:
- 把模板转换成
ast语法树,再将ast语法树转换成render函数
对于标签而言,内容有标签名、表达式、文本、属性
1 | <div id='app'> |
我们拿到这样的字符串文本后,需要开始解析
怎么解析呢?
要有能够匹配标签、表达式、文本、属性的能力
先来看一些正则:
compiler/index.js
1 | const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*` // 匹配标签名 |
先打印下startTagOpen
1 | /^<((?:[a-zA-Z_][\-\.0-9_a-zA-Z]*\:)?[a-zA-Z_][\-\.0-9_a-zA-Z]*)/ |
用可视化工具看一下,Regexper

1 | <div> |
startTagOpen匹配到的分组,是一个开始标签名
看下endTag
1 | /^<\/((?:[a-zA-Z_][\-\.0-9_a-zA-Z]*\:)?[a-zA-Z_][\-\.0-9_a-zA-Z]*)[^>]*>/ |

Oneof表示任一一个,Noneof表示除了这些,箭头表示可跳过
endTag匹配到的分组,是结束标签名
看下属性
1 | /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/ |

1 | color = 'a' |
属性中的key是第一个分组,value是第三或者第四或者第五个分组
看下startTagClose
1 | /^\s*(\/?)>/ |

表示闭合标签
看下defaultTagRE
1 | /\{\{((?:.|\r?\n)+?)\}\}/g |

表示小胡子语法对应的表达式变量
备注:vue3中的这一步不是用的正则,是一个一个字符来判读,如是不是/,是不是<之类来解析的,其实效果也是一样的
那么接下来怎么去解析模板呢
- 每解析一个标签,就把它从字符串中删除掉
index.js
1 | function parsetHTML(html) {// 每解析一个标签,就把它从字符串中删除掉 |
start打印结果:

1 | function parsetHTML(html) {// 每解析一个标签,就把它从字符串中删除掉 |

开始标签匹配完了,接下来开始匹配属性
匹配过程中,只要它不是开始标签的结束,就一直匹配
1 | function parsetHTML(html) {// 每解析一个标签,就把它从字符串中删除掉 |
匹配处理属性的结果:

还剩一个>尖角号,也需要处理(把处理方法的逻辑,放在条件判断里更合理些)
1 | function parseStartTag() { |
>尖角号也处理完了

我们需要开始标签中的属性,解析出来,并放在attrs数组中
1 | if(start) { |

第一个开始标签解析完成,继续向下解析文本内容
1 | while(html) { |
结果:

该标签前面的空格内容被截取掉了
继续解析结束标签
1 | while(html) { |
如果打印为空,说明while循环写的没问题
我们在while内部打个断点,看看整个流程
着重看每次循环的html变量以及进入的是哪个分支
如果是类似<br />这样的自闭合标签呢?

这里先留个坑,处理的时候直接就去掉了
我们在调用解析方法前,打印下获取到的template

获取到的template中的自闭合标签已经没有了
上面的处理,只是把字符串删除了,并没有替换文本
我们希望把文本稍作处理,需要写几个方法把各自匹配的内容,暴露出去,让外面来处理
1 | function parsetHTML(html) { |
通过虚拟DOM生成真实DOM
教程二
地址:[blog/精通 Vue 技术栈的源码原理 at main · liyongning/blog (github.com)](https://github.com/liyongning/blog/tree/main/精通 Vue 技术栈的源码原理)
搭建vue开发环境
下载及安装配置
下载链接:https://github.com/vuejs/vue
下载后,执行npm i安装依赖
修改package.json如下依赖,scripts 中的 dev 命令中添加 --sourcemap,这样就可以在浏览器中调试源代码时,查看当前代码在源代码中的位置:
1 | { |
概念扫盲
npm run build后的打包文件:
| UMD | CommonJS | ES Module | |
|---|---|---|---|
| Full | vue.js | vue.common.js | vue.esm.js |
| Runtime-only | vue.runtime.js | vue.runtime.common.js | vue.runtime.esm.js |
| Full (production) | vue.min.js | vue.common.prod.js | |
| Runtime-only (production) | vue.runtime.min.js | vue.runtime.common.prod.js |
- Full:这是一个全量的包,包含编译器(
compiler)和运行时(runtime)。 - Compiler:编译器,负责将模版字符串(即你编写的类 html 语法的模版代码)编译为 JavaScript 语法的 render 函数。
- Runtime:负责创建 Vue 实例、渲染函数、patch 虚拟 DOM 等代码,基本上除了编译器之外的代码都属于运行时代码。
- UMD:兼容 CommonJS 和 AMD 规范,通过 CDN 引入的 vue.js 就是 UMD 规范的代码,包含编译器和运行时。
- CommonJS:典型的应用比如 nodeJS,CommonsJS 规范的包是为了给 browserify 和 webpack 1 这样旧的打包器使用的。他们默认的入口文件为
vue.runtime.common.js。 - ES Module:现代 JavaScript 规范,ES Module 规范的包是给像 webpack 2 和 rollup 这样的现代打包器使用的。这些打包器默认使用仅包含运行时的
vue.runtime.esm.js文件。
运行时(Runtime)+ 编译器(Compiler) vs. 只包含运行时(Runtime-only)
如果你需要动态编译模版(比如:将字符串模版传递给 template 选项,或者通过提供一个挂载元素的方式编写 html 模版),你将需要编译器,因此需要一个完整的构建包。
当你使用 vue-loader 或者 vueify 时,*.vue 文件中的模版在构建时会被编译为 JavaScript 的渲染函数。因此你不需要包含编译器的全量包,只需使用只包含运行时的包即可。
只包含运行时的包体积要比全量包的体积小 30%。因此尽量使用只包含运行时的包,如果你需要使用全量包,那么你需要进行如下配置:
webpack
1 | module.exports = { |
Rollup
1 | const alias = require('rollup-plugin-alias') |
Browserify
Add to your project’s package.json:
1 | { |
目录结构
1 | ├── benchmarks 性能、基准测试 |
Vue初始化过程
目标
深入理解 Vue 的初始化过程,再也不怕 面试官 的那道面试题:new Vue(options) 发生了什么?
找入口
想知道 new Vue(options) 都做了什么,就得先找到 Vue 的构造函数是在哪声明的,有两个办法:
- 从 rollup 配置文件中找到编译的入口,然后一步步找到 Vue 构造函数,这种方式 费劲
- 通过编写示例代码,然后打断点的方式,一步到位,简单
我们就采用第二种方式,写示例,打断点,一步到位。
在
/examples目录下增加一个示例文件 ——test.html,在文件中添加如下内容:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Vue 源码解读</title>
</head>
<body>
<div id="app">
{{ msg }}
</div>
<script src="../dist/vue.js"></script>
<script>
debugger
new Vue({
el: '#app',
data: {
msg: 'hello vue'
}
})
</script>
</body>
</html>在浏览器中打开控制台,然后打开
test.html,则会进入断点调试,然后找到 Vue 构造函数所在的文件



