[TOC]

教程一

大纲

第一周(从零手写Vue2部分)https://www.bilibili.com/video/BV1mR4y1w7cU

  • Vue2响应式原理,模板编译原理,虚拟Dom原理,Vue初渲染流程
  • Vue2中生命周期原理,mixin原理,依赖收集WatcherDep原理
  • 手写computedwatch原理,异步更新原理
  • 手写Vue2中组件渲染原理、Vue.extend原理,Vue2diff算法

目标:掌握Vue2核心源码及核心设计思想

第二周(从0手写VueRouterVuex

  • 掌握HashHistoryBrowserHistory及路由钩子实现原理,及RouterViewRouterLink组件实现
  • 从0实现Vuex,彻底掌握Vuex设计思想

目标:掌握前端路由实现原理及状态管理实现原理

第三周

  • 剖析Vue2源码,调试Vue2核心源码
  • Vue2常见面试题解析

目标:掌握如何阅读框架源码,掌握Vue相关面试题

第四周TS详解、掌握TS核心应用)

  • TS环境搭建、基础类型、类型推导、类
  • 接口、泛型、TS兼容性
  • 类型保护、高级类型、模块命名空间等

目标:掌握TS的使用为学习Vue3做准备

第五周Vue3核心讲解)

  • 掌握Vue3核心语法及组件化开发,Vue3新特性和新增API
  • Vue3 + Vite掌握VueRouter4Vuex4应用,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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
npm init
# 一路回车

npm i rollup rollup-plugin-babel @babel/core @babel/preset-env -D

# 安装rollup
npm i rollup

# 安装babel,将高级语法转换成低级语法
npm i rollup-plugin-babel

# 安装babel的核心模块
npm i @babel/core

# 安装预设(比如说怎么把let、const转换成var)
npm i @babel/preset-env

实操注意点:

提示rollup-plugin-babel不更新维护了

1
2
[root@VM-4-12-centos VUE2_STAGE]# npm i rollup rollup-plugin-babel @babel/core @babel/preset-env -D
npm WARN deprecated rollup-plugin-babel@4.4.0: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-babel.

解决办法:

卸载rollup-plugin-babel

1
npm uninstall rollup-plugin-babel

安装推荐的包

1
npm i @rollup/plugin-babel -D

安装完毕后的包信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"name": "vue2_stage",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"devDependencies": {
"@babel/core": "^7.18.6",
"@babel/preset-env": "^7.18.6",
"@rollup/plugin-babel": "^5.3.1",
"rollup": "^2.75.7"
}
}

根目录VUE2-STAGE新建rollup配置文件rollup.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// rollup 默认可以导出一个对象,作为打包的配置文件

import babel from '@rollup/plugin-babel'
export default {
input: './src/index.js', // 入口
output: {
file: './dist/vue.js', // 出口
name: 'Vue', // 在global全局上,增添一个Vue对象,我们就可以new Vue了(global.Vue)
format: 'umd', // options: 1.esm es6模块,相当于没有打包了 2.commonjs node中使用 3.iife 自执行函数 4.umd 兼容amd和commonjs
sourcemap: true // 可以调试源代码
},
plugins: [
// 需要新建babel的配置文件,既可以是js文件,也可以是.rc文件,
// 这里和视频的保持一致
babel({
exclude: 'node_modules/**', // 排除第三方模块 ,**表示任意文件夹
babelHelpers: 'bundled' // https://www.npmjs.com/package/@rollup/plugin-babel 搜索babelHelpers
}) // 所有的插件都是函数
]
}

根目录新建.babelrc文件

1
2
3
4
5
{
"presets": [
"@babel/preset-env"
]
}

配置较少的话,也可以直接写在rollup.config.js

package.json中添加npm run dev脚本

-c:指定默认的配置文件

-w:监视文件变化

1
2
3
4
5
// ...
"scripts": {
"dev": "rollup -cw"
},
// ...

根目录新建打包入口文件src/index.js

1
2
3
4
export const a = 100
export default {
a: 1
}

测试能否打包

1
npm run dev

成功显示如下信息

1
2
3
4
5
6
7
8
9
[root@VM-4-12-centos VUE2_STAGE]# npm run dev

> vue2_stage@1.0.0 dev
> rollup -cw
rollup v2.75.7
bundles ./src/index.js → dist/vue.js...
created dist/vue.js in 304ms

[2022-06-30 17:36:35] waiting for changes...

可能会提示你修改package.json

新增字段:

1
"type": "module"

根目录下会生成之前配置的目录及文件夹

1
2
3
4
[root@VM-4-12-centos VUE2_STAGE]# tree ./dist/
./dist/
|-- vue.js
`-- vue.js.map

index.js对应的打包文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.Vue = {}));
})(this, (function (exports) { 'use strict';

var a = 100;
var index = {
a: 1
};

exports.a = a;
exports["default"] = index;

Object.defineProperty(exports, '__esModule', { value: true });

}));
//# sourceMappingURL=vue.js.map

可以新建index.html并引入该打包文件

1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<script src="vue.js"></script>
<script>
console.log(Vue)
</script>
</body>
</html>

全局上多了一个Vue的对象,身上的属性就是我们导出的,效果如下:

image-20220630185136069

index.js中也可以设置断点,进行调试

1
2
3
4
5
export const a = 100
debuger
export default {
a: 1
}

已完成:

  • 1.使用Rollup搭建开发环境

初始化数据

响应式数据的核心?数据变化了,我可以监控到

监控的是什么呢?是数据的取值更改值

监控到然后干嘛呢?更新视图

满足上面要求的数据,就是响应式数据。简单点来说,响应式数据变化可以更新视图

不考虑工程化开发,当初在html中我们是这么写Vue代码的

把所有需要的数据,都放在配置对象里

1
2
3
4
5
6
7
8
9
<script src='vue.js'></script>
<script>
const vm = new Vue({
data: {
name: 'sai',
age: 18
}
})
</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
2
3
4
5
6
7
8
9
10
11
function Vue(options) { // options就是用户的选项
// 拿到用户的options,做一下初始化
this._init(options)
}

// 用于初始化操作
Vue.prototype._init = function(options) {

}

export default Vue

但是不能都写在index.js里,不然代码一多就完犊子了

新建src/init.js

1
2
3
4
// 用于初始化操作
Vue.prototype._init = function(options) {

}

问题来了,这个时候Vue就丢失了,咋整?

可以把初始化操作,封装成函数并导出,这个函数接收一个形参

init.js

1
2
3
4
5
6
7
// 就是给Vue增加_init方法的
export function initMixin(Vue) {
// 用于初始化操作
Vue.prototype._init = function(options) {

}
}

index.js就可以传入实参Vue使用了

1
2
3
4
5
6
7
8
9
function Vue(options) { // options就是用户的选项
// 拿到用户的options,做一下初始化
this._init(options)
}

// 给Vue增加init方法
initMixin(Vue) // 扩展了init方法

export default Vue

initMixin相当于扩展了_init方法,后续再有逻辑,可以再initXXX(xxx),就可以原型方法,扩展成一个个函数,通过函数的形式在原型上扩展功能

靠谱!!!

需要把options扩展到vm实例上,为什么不直接用呢?

  • 或许扩展的第二个方法,怎么拿到options呢,只有通过实例来传递

看下面的代码:

init.js

1
2
3
4
5
6
7
8
9
export function initMixin(Vue) {
Vue.prototype._init = function(options) {
this.$options = options // Vue中采取$作为自己的变量,如果传入了形如$name这样的以$开头的变量,是不会被Vue实例管理的(Vue给自己画了个界限,所有以$开头的,都认为是自己的属性)
debugger
}
// Vue.prototype.xxx = function() {
// 扩展的第二个方法,怎么拿到options呢,只有通过实例来传递
// }
}

可以看到,此时Vue上多了$options属性

image-20220702210913280

写法优化

1
2
3
4
5
6
7
8
export function initMixin(Vue) {
Vue.prototype._init = function(options) {
const vm = this // 保留下this,不然后面一直写this,写法上有点恶心
vm.$options = options // 将用户的选项挂载到实例上
debugger

}
}

thisvm都指向Vue实例

image-20220702211212033

挂载完options之后,干嘛呢?

我们不是传入了data这些配置项嘛,要进行初始化状态,Vue中的状态有很多,如props/data/computed/watch等等配置项,这些都是要初始化的

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
29
30
31
32
33
export function initMixin(Vue) {
Vue.prototype._init = function(options) {
const vm = this
vm.$options = options

// 初始化状态 data/computed/watch等等配置项
initState(vm)
// 初始化状态之后,还要去编译模板、创建虚拟dom等
}
}

function initState(vm) {
const opts = vm.$options // 获取所有的选项
// 拿到所有的选项之后,我们先处理`data`
// 一项项的来处理
// 在`data`之前处理`props`,但目前我们先只处理data
//if(opts.props) {
// initProps()
//}

// 处理`data`
if(opts.data) {
initData(vm)
}
}

function initData(vm) {
// 代理数据
let data = vm.$options.data // data有两种情况,对象或函数
console.log(this) // 这里的this是undefined
data = typeof data === 'function' ? data.call(this) : data // 对data类型进行判断后,拿到data
debugger
}

image-20220704063245372

initStateinitData这两个初始化数据的方法,单独抽出来,放到state.js

1
2
3
4
5
6
7
8
9
10
11
12
export function initState(vm) {
const opts = vm.$options
if(opts.data) {
initData(vm)
}
}

function initData(vm) {
let data = vm.$options.data // data可能是函数或对象
data = typeof data === 'function' ? data.call(vm) : data
debugger
}

至此,状态初始化中的数据初始化,已经完成了第一步,拿到用户自定义配置

本小节技能点需完善的地方

  • 浏览器控制台调试大全
  • jsthis的系列问题
  • js中的call方法

下一小节,实现对象的响应式原理

Vue响应式原理实现

对象属性劫持

对数据进行劫持

vue2中采用了defineProperty

我们定义一个方法obeserve观测数据,这是一个核心模块,我们单独新建observe文件夹进行处理

state.js

1
2
3
4
5
6
7
8
9
import {observe} from "./observe/index"
// ...
function initData(vm) {
let data = vm.$options.data
data = typeof data === 'function' ? data.call(vm) : data
// 对数据进行劫持,vue2中采用了defineProperty
// 定义一个方法obeserve观测数据,这是一个核心模块,我们单独新建observe文件夹进行处理
observe(data)
}

新建src/observe/index.js

1
2
3
4
export function observe(data) {
console.log(data)
debugger
}

observe中可以拿到data数据

image-20220710111903316

实现对象属性劫持

observe方法中对对象类型的数据进行劫持

  • 先对传入的数据类型进行判断,只对对象进行劫持
  • 如果一个对象被劫持过了,那就不需要再被劫持了
    • 要判断一个对象是否被劫持过
      • 可以添加一个实例,用实例来判断是否被劫持过。
      • observe函数中新建Observer类,这个类是专门用来观测数据的,如果数据被观测过,那么它的实例就是这个类??
        • 这里看不明白,可以直接先往下看,到具体代码那儿就清楚了

observe/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Observer {
constructor(data) {

}
}

export function observe(data) {
console.log(data)
// 对data类型进行判断
if(typeof data !== 'object' || data == null) {
return // 只对对象进行劫持
}

// 如要考虑到一个对象已经被劫持的情况
// 如果一个对象已经被劫持过了,那么就不需要再被劫持
// 可以添加一个实例,用实例来判断是否被劫持过(应该是用实例身上的属性)
return new Observer(data)
}

问题:Object.defineProperty只能劫持已经存在的数据,后增的或者删除的属性,是劫持不到的(为此Vue2单独写了一些api,比如说$set$delete

  • Observer类型中,要遍历对象
    • 可以专门写个walk方法来干这件事,循环对象,对属性依此劫持
    • 拿到所有的key后遍历,“重新”定义属性(重新定义的话,相当于每个key都要遍历,这也是Vue2性能较差的原因)
      • 定义defineReactive方法,实现将某个对象数据定义成响应式
      • 该方法需要后面可以单独使用,所以写在Observer同级,并导出

observer/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Observer {
constructor(data) {
this.walk(data)
}

walk(data) { // 循环对象,对属性依此劫持
// 重新定义属性 性能差
Object.keys(data).forEach(key => defineReactive(data, key, data[key]))
}
}

// 向外导出该方法,供单独使用
export function defineReactive(target, key, value) { // 闭包
Object.defineProperty(target, key, {
get() { // 取值的时候,会执行get
return value
},
set(newValue) { // 修改值的时候,会执行set
if(value == newValue) return
value = newValue
}
})
}
// ...

我们在使用的时候,打印下vm实例

index.html

1
2
3
4
5
6
7
8
9
10
11
12
<script src="./vue.js"></script>
<script>
const vm = new Vue({
data() {
return {
name: 'sai',
age: 11
}
}
})
console.log(vm)
</script>

image-20220711065304265

虽然定义了响应式,但此时vm实例上直接是拿不到data

我们可以在state.jsinitData中,在vm身上增加_data属性,将data赋值给vm._data(在观测属性之前)

state.js

1
2
3
4
5
6
7
8
9
// ...
function initData(vm) {
let data = vm.$options.data
data = typeof data === 'function' ? data.call(vm) : data

// 观测之前,把data放一份到vm._data身上
vm._data = data
observe(data)
}

此时vm身上就有了_data,存着data的响应式数据

image-20220711065849648

思考:vm._data=data是在做观测数据之前存的,为啥_data也变成了响应式的呢?

  • 直接赋值是浅拷贝,_datadata变量中存的是对象值在堆内存中的引用地址
  • 原对象的值变了,但_data中存的引用地址没有变
  • 所以即使是后做的响应式,vm._data自然也是响应式的了
  • 观测数据之后,进行赋值也不是不可以
用户用法简化

现在还有个小问题,就是取数的时候,要写成vm._data.name,每次都要加个_data,有点恶心

我们能不能直接vm.name去取值呢

  • 当用户在vm.name上取值时,我们就代理到vm._data.name
  • vm._datavm来代理
    • 依旧是做一个循环来处理
    • 自定义proxy方法:proxy(vm, '_data'),代理vm身上的_data

state.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ...
function proxy(vm, target, key) {
Object.defineProperty(vm, key, { // vm.name
get() {
return vm[target][key] // vm.name实际上是去vm._data.name上去取值了
}
})
}

function initData(vm) {
let data = vm.$options.data
data = typeof data === 'function' ? data.call(vm) : data

vm._data = data

observe(data)


// 将vm._data用vm来代理
for (let key in data) {
proxy(vm, '_data', key)
}
}

image-20220711144219711

我们先写get方法,可以看到vm代理了vm._data,身上有了nameage属性

同样,在设置值时也要加个代理

state.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ...

function proxy(vm, target, key) {
Object.defineProperty(vm, key, { // vm.name
get() {
return vm[target][key] // vm.name实际上是去vm._data.name上去取值了
},
set(newValue) {
vm[target][key] = newValue // 这性能的确不太好,每一个`key`都加了一层
}
})
}

// ...

image-20220711145232926

此时写法上就可以更便捷的取值及修改值了

嵌套对象属性劫持

还有个问题:上面的写法只会监测到对象的第一层,一旦传入的data是嵌套的,里面的属性并没有被监测到

如下所示,address内部属性并没有被劫持

index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script src="./vue.js"></script>
<script>
const vm = new Vue({
data() {
return {
name: 'sai',
age: 11,
address: {
street: 'RoadA',
room: 123
}
}
}
})
console.log(vm.name)
</script>

image-20220711145822997

当初的defineReactive函数,入参的value可能是个对象

  • 再次调用observe方法,如果value是个对象,会再次创建Observer实例,再次调用walk方法,劫持每个属性
  • 这样就实现了对所有的对象,都进行了属性劫持
  • 是个递归,性能消耗也是可以的

observe/index.js

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
29
30
31
class Observer {
constructor(data) {
this.walk(data)
}

walk(data) {
Object.keys(data).forEach(key => defineReactive(data, key, data[key]))
}
}

export function defineReactive(target, key, value) { // value可能是个对象
observe(value) // observe内部对value进行判断了,是个对象,会再次创建Observer实例,再次调用walk方法,劫持每个属性
Object.defineProperty(target, key, {
get() {
return value
},
set(newValue) {
if(value == newValue) return
value = newValue
}
})
}

export function observe(data) {

if(typeof data !== 'object' || data == null) {
return
}

return new Observer(data)
}

此时不管传入的是几层,对象属性都是被劫持过了的

image-20220711151136480

至此,对象属性劫持的define核心逻辑就完成了

  • 循环对象,给对象用defineReactive方法,把属性重新定义
    • 如果值还是对象的话,需要对这个对象进行递归操作
    • 这个用户在取值和修改值时,就可以监控到
  • 对象被劫持完之后,为了方便用户获取,把data放在了vm._data
    • 再用vm代理vm._data,这样用户在取值和修改值时,只要写成vm.name即可

已完成:

  • 1.使用Rollup搭建开发环境
  • 2.Vue响应式原理实现,对象属性劫持,深度属性劫持

数组的方法劫持

如果data里面还有数组呢

index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<script src="./vue.js"></script>
<script>
const vm = new Vue({
data() {
return {
name: 'sai',
age: 11,
address: {
street: 'RoadA',
room: 123
},
hobby: [ // data中含有数组
'eat',
'drink'
]
}
}
})
console.log(vm.name)
</script>

我们看一下打印结果

image-20220711161920103

defineProperty把数组里的每个属性,都增加了getset,虽然通过vm.hobby[0]取值时,的确会被监测到,但是一旦数据量大了,就很消耗内存了

并且通过下标的方式来修改值,如修改第888个数组的值,vm.hobby[888]=123,一般也不会有这种操作

修改数组,很少用索引来修改数组,并且内部劫持数组,会浪费性能

用户一般都是都过方法来修改数组:pushshift等等

observe/index.js里的Observer类的构造函数中

  • 对数组类型进行判断
    • 如果是数组
      • 重写数组的方法,7个变异方法(可以修改数组本身的方法)
      • 如果数组内部,还嵌套有对象,如hobby:['eat','drink',{a:1}]也应该对对象属性进行劫持
        • 定义observeArray方法,实现该功能
          • 循环传入的data,递归调用observe方法
    • 如果不是数组
      • 继续之前的逻辑,添加代理
数组中的对象属性劫持

定义observeArray方法,对数组中的对象进行属性劫持

observe/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Observer {
constructor(data) {
if(Array.isArray(data)) {
// 这里我们可以重写数组的7个变异方法(可以修改数组本身)
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))
}
}

// ...

打印结果如下:

image-20220711164236905

我们在defineReactive的函数的get中添加打印语句:console.log('key', key)

然后在样例中取数vm.hobby[2].a

打印结果如下:

image-20220711165311701

表示先取了hobby,再取了a,说明数组中的对象,是可以被监控到的

数组的方法劫持

如果是vm.hobby.push['1'],会打印了hobby,说明目前只能监控到get,无法监控到修改

所以就需要重写数组的方法

  • 在传入的data对应的__proto__属性中,重写各个数组方法

    • 给当前数组的的原型链,重新指向新的原型(也会覆盖掉forEach方法,可以先注释掉observeArray方法的调用)

      observe/index.js

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      class 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))
      }
      }
      // ...

      打印如下:

      image-20220712054236080

当然我们不可能直接这样重写__proto__,我们需要保留数组原有的特性,并且可以重写部分方法,observe文件夹下新建array.js

  • 存一份原来的Array.prototype,该对象上定义着各种方法

    1
    let oldArrayProto = Array.prototype

    image-20220714110255721

  • oldArrayProto为原型定义新的变量

    1
    let newArrayProto = Object.create(oldArrayProto)

    目前newArrayProto是一个空对象,:

    image-20220714110927390

  • 定义所有的变异方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    let 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
    17
    let 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

    image-20220714112458922

  • 很明显,在返回之前调用一下原来的方法就可以了

    • 这里要注意要,如果不改变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
      29
      let 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身上了,因为方法本身就是定义在它身上的

      image-20220715091536149

    • 需要注意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
      29
      let 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方法指定了正确的上下文:

      image-20220715091732694

    • 本例如下:

      1
      2
      3
      4
      5
      6
      7
      methods.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
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
// 我们希望重写数组中的部分方法

let oldArrayProto = Array.prototype

// newArraryProto.__proto = oldArrayProto
export let newArrayProto = Object.create(oldArrayProto) // 以oldArryarProto对象为原型对象,新建一个newArraryProto

// 找到所有的变异方法
let methods = [
'push',
'pop',
'shift',
'unshift',
'reverse',
'sort',
'splice'
] // concact slice都不会改变原数组

methods.forEach(method => {
// arr.push(1,2,3)
newArrayProto[method] = function (...args) { // 这里重写了数组方法
const result = oldArrayProto[method].call(this, ...args) // 内部调用原来的方法,一般称为函数的劫持(切片编程(切面编程):自己写个功能,把以前的功能塞进去,外面可以做一些自己的事,aop)
console.log('method', method) // 供使用时打印
return result
}
})

observe.index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { newArrayProto } from './array'
class Observer {
constructor(data) {
if(Array.isArray(data)) {
// 这里我们可以重写数组的7个变异方法(可以修改数组本身)
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))
}
}

// ...

index.html测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<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.push('1')
</script>

测试下,用到的是什么方法,就会打印什么方法

但是,如果追加的是一个对象,还会有问题

1
2
3
4
5
<script src="./vue.js"></script>
<script>
// ...
vm.hobby.unshift({a:1})
</script>

ps:记得取消之前的注释

1
2
3
4
5
6
7
8
// ...
if(Array.isArray(data)) {
data.__proto__ = newArrayProto
this.observeArray(data) // 取消这里的注释
} else {
this.walk(data)
}
// ...

可以看到,虽然hobby里面的对象被劫持了,但是数组中新增的对象,并没有被劫持

因为目前我们只是拦截了变异方法,并没有对新增的属性做处理,即要对rest参数args做处理

image-20220714140840410

我们劫持了函数之后,也要对新增的数据再次进行劫持

  • 拿到rest参数(是个数组),根据调用的方法做不同的处理

    • pushunshift
      • 直接可以用过rest参数获取到
    • splice
      • 如果是删除操作,splice方法是没有第三个参数的,args是为
  • 根据不同的方法,拿到新增的内容

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    methods.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__属性,指向thisdata.__ob__ = this,这里的this指向的是Observer类的实例

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        class 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
        23
        methods.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.js

        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
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        42
        43
        44
        45
        46
        47
        48
        49
        50
        import { 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)

        }
      • 但这样写行不行呢?我们再来测试下,看下页面

        image-20220714151621487

        完犊子了,内存爆了!

        咋回事,我们是为了解决data是数组的情况,给data添加了__ob__自定义属性

        但是,如果data是对象,它会先加一个自定义属性__ob__,这是合理的,相当于增加一个标识,这一步没问题,但到了下一步,走walk方法,会被data身上的__ob__属性,也是对象,然后在加一个,再走walk,就死循环了

        data.__ob__ = this之前打个断点,执行到walk时,我们进入内部,看下data

        image-20220714190149824

        再继续往下走到observe,添加条件断点:key === __ob__,不断点下一步,观察右边的调用栈,一直在增加

        image-20220714191311887

        那么怎么处理呢?我们希望在遍历对象的时候,不能遍历到__ob__这个属性,让其变成不可枚举的即可。改写原来的写法:

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        class 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.html

    1
    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>

    数组里有四项,通过数组方法新增的对象,也有了getset

    image-20220714192439428

至此,数组的劫持,全部搞定

  • 数组劫持核心,就是重写数组的方法,并且去观测数组中的每一项
    • 如果是数组的话,需要对每一项新增的属性,做一下判断,并且把数组的每一项,再进行观测

接下来就要和视图挂钩了

模板编译原理

当初我们需要写一个div并指定id,在里面写小胡子语法

index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<body>
<!-- 模板 -->
<div id="app">
<div>
{{name}}
</div>
<span>{{age}}</span>
</div>

<script src="./vue.js"></script>
<script>
const vm = new Vue({
data() {
return {
name: 'sai',
age: 11
}
}
})
</script>
</body>

我们要对这个模板进行编译,需要给配置对象,传一个el属性,将数据解析到el元素上,将{{name}}{{age}}进行一个数据的替换

  • 方案一:模板引擎
    • 每次把模板拿到,用数据来替换
    • 性能很差,需要正则匹配替换(vue1.0的时候,没有引入虚拟dom
  • 方案二:采用虚拟dom
    • 数据变化后,比较虚拟dom的差异,最后更新需要更新的地方
    • 核心就是,把模板变成js语法,可以通过js语法生成虚拟dom
      • 从一个东西,变成另一个东西(语法之间的转换),这是一个很典型的语法转译问题,如es6 => es5,需要先变成语法树,再重新组装代码,成为新的语法

现在我们要拿到模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<body>
<div id="app">
<div>
{{name}}
</div>
<span>{{age}}</span>
</div>

<script src="./vue.js"></script>
<script>
const vm = new Vue({
data() {
return {
name: 'sai',
age: 11
}
},
el: "#app" // 将数据解析到el元素上
})
</script>
</body>

模板除了可以写在el上,也可以写在template

1
2
3
4
5
6
<script src="./vue.js"></script>
<script>
const vm = new Vue({
template: ``
})
</script>

或者可以用一个方法来替代:render()

1
2
3
4
5
6
7
8
<script src="./vue.js"></script>
<script>
const vm = new Vue({
render() {
return h('div', {})
}
})
</script>

而我们最终的目标就是,把template语法变成render()函数

将模板转换成ast语法树

状态初试完完了,就去看用户,有没有给el属性

  • 先看配置项,有没有el属性

    • 如果有,vm原型对象上定义$mount函数,将options.el传入$mount方法,并实现数据的挂载
      • 有了$mount方法后,我们也可以不写el配置项,直接调用vm.$mount("#app")实现手动挂载
  • $mount的功能

    • 先找到对应的元素:

      1
      2
      3
      4
      Vue.prototype.$mount = function (el) {
      const vm = this
      el = document.querySelector(el)
      }

      返回el对应的dom元素

    • 根据配置项的进行进行不同的处理

      • 判断是否有render函数
        • 如果没有
          • 判断是否有template配置项
            • 如果没有template配置项,但是有el配置项
              • 使用el.outerHTML拿到模板
            • 如果有template配置项,就用template配置项

      init.js

      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
      29
      30
      31
      32
      33
      import {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
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
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<div>
<div id="app">
{{name}}
{{age}}
</div>
</div>
<script src="./vue.js"></script>
<script>
const vm = new Vue({
el: '#app',
data() {
return {
name: 'sai',
age: 11,
address: {
street: 'RoadA',
room: 123
},
hobby: [
'eat',
'drink',
{
a: 1
}
]
}
}
})
</script>
</body>
</html>

结果:

image-20221122072027640

这里可以多试试不同的情况

整体逻辑是:先找render函数,没写的话就找template配置项,再没写的话,就用外部的html

最终获取到template后,将template传入到自定义函数compileToFunction中进行渲染

init.js

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
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配置项
template = el.outerHTML // 就用el的配置项,outHTML返回的是匹配到自身的dom元素
} else { // // 如果既有template,又有el,就用template配置项作为模板
if(el) {
template = ops.template
}
}
// 其他情况的分支考虑
// console.log(template)
// 最终如果获取到模板
if(template) {
// 这里需要对模板进行编译
const render = compileToFunction(template)
// 将编译后的结果给render函数
ops.render = render
}
}
ops.render // 有render就直接调用render
}

实际开发中可以写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
2
3
4
5
6
7
8
9
// 对模板进行编译处理
export function compileToFunction(template) {
// 1.将template转化成ast抽象语法树

// 2.生成render方法(返回的结果,就是虚拟dom)
console.log(template)
// 有了虚拟dom之后,再渲染成真实dom

}

init.js中导入

1
2
3
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
2
3
4
5
6
7
8
9
10
import { nodeResolve } from '@rollup/plugin-node-resolve';

export default {
input: 'src/index.js',
output: {
dir: 'output',
format: 'cjs'
},
plugins: [nodeResolve()]
};

rollup.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// rollup 默认可以导出一个对象,作为打包的配置文件
import { nodeResolve } from '@rollup/plugin-node-resolve';
import babel from '@rollup/plugin-babel'

export default {
input: './src/index.js', // 入口
output: {
file: './dist/vue.js', // 出口
name: 'Vue', // 在global全局上,增添一个Vue对象,我们就可以new Vue了(global.Vue)
format: 'umd', // options: 1.esm es6模块,相当于没有打包了 2.commonjs node中使用 3.iife 自执行函数 4.umd 兼容amd和commonjs
sourcemap: true // 可以调试源代码
},
plugins: [
// 需要新建babel的配置文件,既可以是js文件,也可以是.rc文件,
// 这里和视频的保持一致
babel({
exclude: 'node_modules/**', // 排除第三方模块 ,**表示任意文件夹
babelHelpers: 'bundled' // https://www.npmjs.com/package/@rollup/plugin-babel 搜索babelHelpers,不加这一行会有报错
}), // 所有的插件都是函数
nodeResolve()
]
}

代码生成,实现虚拟DOM

上一节回顾:

  • 把模板转换成ast语法树,再将ast语法树转换成render函数

对于标签而言,内容有标签名、表达式、文本、属性

1
2
3
4
5
6
7
8
<div id='app'>
<div style='color:red' class='container'>
{{ name }} hello world
</div>
<span>
{{ age }}
</span>
</div>

我们拿到这样的字符串文本后,需要开始解析

怎么解析呢?

要有能够匹配标签、表达式、文本、属性的能力

先来看一些正则:

compiler/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*` // 匹配标签名
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^'])'+|([^\s"'=<>`]+)))?/
const startTagClose = /^\s*(\/?)>/
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g

console.log(startTagOpen)
// 对模板进行编译处理
export function compileToFunction(template) {
// 1.将template转化成ast抽象语法树

// 2.生成render方法(返回的结果,就是虚拟dom)
console.log(template)

}

先打印下startTagOpen

1
/^<((?:[a-zA-Z_][\-\.0-9_a-zA-Z]*\:)?[a-zA-Z_][\-\.0-9_a-zA-Z]*)/

用可视化工具看一下,Regexper

image-20221122205447489

1
2
3
4
<div>
<div:xxx> 带命名空间的标签
<_div> 自定义标签

startTagOpen匹配到的分组,是一个开始标签名

看下endTag

1
/^<\/((?:[a-zA-Z_][\-\.0-9_a-zA-Z]*\:)?[a-zA-Z_][\-\.0-9_a-zA-Z]*)[^>]*>/

image-20221122210031249

Oneof表示任一一个Noneof表示除了这些,箭头表示可跳过

endTag匹配到的分组,是结束标签名

看下属性

1
/^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/

image-20221122210514120

1
2
3
4
color   =  'a'
color = "a"
color=a
disabled

属性中的key是第一个分组,value是第三或者第四或者第五个分组

看下startTagClose

1
/^\s*(\/?)>/

image-20221122211350057

表示闭合标签

看下defaultTagRE

1
/\{\{((?:.|\r?\n)+?)\}\}/g

image-20221122211528510

表示小胡子语法对应的表达式变量

备注:vue3中的这一步不是用的正则,是一个一个字符来判读,如是不是/,是不是<之类来解析的,其实效果也是一样的

那么接下来怎么去解析模板呢

  • 每解析一个标签,就把它从字符串中删除掉

index.js

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
function parsetHTML(html) {// 每解析一个标签,就把它从字符串中删除掉
function parseStartTag() {
const start = html.match(startTagOpen) // 是一个数组
console.log(start)
}

while(html) {
// vue2中,html最开始一定是一个<
// 如果textEnd为0,说明是一个开始标签或者结束标签
// 如果textEnd>0,说明就是文本的结束位置
let textEnd = html.indexOf('<') // 如果索引为0,则说明是个标签,开始标签取完了,再去取尖角号,取到的是文本结束的位置
if(textEnd == 0) {
parseStartTag()
break
}
}
}
// 对模板进行编译处理
export function compileToFunction(template) {
// 1.将template转化成ast抽象语法树
let ast = parsetHTML(template)
// 2.生成render方法(返回的结果,就是虚拟dom)
console.log(template)

}

start打印结果:

image-20221122214247328

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
29
30
31
32
33
34
35
36
37
38
39
function parsetHTML(html) {// 每解析一个标签,就把它从字符串中删除掉
function advance(n) { // 截取
html = html.substring(n)
}

function parseStartTag() {
const start = html.match(startTagOpen) // 结果是一个数组
console.log(start)
if(start) {
// 匹配到了,把结果(数组)组成一个对象
const match = {
tagName: start[1], // 标签名
attrs: []
}
advance(start[0].length) // 根据匹配到的字符的长度,进行删除
console.log(match)
console.log(html)
}
return false // 不是开始标签,返回false
}

while(html) {
// vue2中,html最开始一定是一个<
// 如果textEnd为0,说明是一个开始标签或者结束标签
// 如果textEnd>0,说明就是文本的结束位置
let textEnd = html.indexOf('<') // 如果索引为0,则说明是个标签,开始标签取完了,再去取尖角号,取到的是文本结束的位置
if(textEnd == 0) {
parseStartTag()
break
}
}
}
// 对模板进行编译处理
export function compileToFunction(template) {
// 1.将template转化成ast抽象语法树
let ast = parsetHTML(template)
// 2.生成render方法(返回的结果,就是虚拟dom)
console.log(template)
}

image-20221122215009131

开始标签匹配完了,接下来开始匹配属性

匹配过程中,只要它不是开始标签的结束,就一直匹配

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
29
30
31
32
33
34
35
36
37
38
function parsetHTML(html) {// 每解析一个标签,就把它从字符串中删除掉
function advance(n) { // 截取
html = html.substring(n)
}

function parseStartTag() {
const start = html.match(startTagOpen) // 结果是一个数组
console.log(start)
if(start) {
// 匹配到了,把结果(数组)组成一个对象
const match = {
tagName: start[1], // 标签名
attrs: []
}
advance(start[0].length) // 根据匹配到的字符的长度,进行删除
// console.log(match)
// console.log(html)
}
let attr
while(!html.match(startTagClose) && (attr = html.match(attribute))) { // 如果不是开始标签的结束,就一直匹配,并且每次匹配都把匹配到的结果存起来
advance(attr[0].length) // 每次匹配完,再把匹配过的字符去掉

}
console.log(html)
return false // 不是开始标签,返回false
}

while(html) {
// vue2中,html最开始一定是一个<
// 如果textEnd为0,说明是一个开始标签或者结束标签
// 如果textEnd>0,说明就是文本的结束位置
let textEnd = html.indexOf('<') // 如果索引为0,则说明是个标签,开始标签取完了,再去取尖角号,取到的是文本结束的位置
if(textEnd == 0) {
parseStartTag()
break
}
}
}

匹配处理属性的结果:

image-20221122215755848

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

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
function parseStartTag() {
const start = html.match(startTagOpen) // 结果是一个数组
console.log(start)
if(start) {
// 匹配到了,把结果(数组)组成一个对象
const match = {
tagName: start[1], // 标签名
attrs: []
}
advance(start[0].length) // 根据匹配到的字符的长度,进行删除
// console.log(match)
// console.log(html)
let attr, end
while(!(end = html.match(startTagClose)) && (attr = html.match(attribute))) { // 如果不是开始标签的结束,就一直匹配,并且每次匹配都把匹配到的结果存起来
advance(attr[0].length) // 每次匹配完,再把匹配过的字符去掉
}
if(end) {
// 如果匹配到了end,也应该删除
advance(end[0].length)
}
}

console.log(html)
return false // 不是开始标签,返回false
}

>尖角号也处理完了

image-20221122220021712

我们需要开始标签中的属性,解析出来,并放在attrs数组中

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
if(start) {
// 匹配到了,把结果(数组)组成一个对象
const match = {
tagName: start[1], // 标签名
attrs: []
}
advance(start[0].length) // 根据匹配到的字符的长度,进行删除
// console.log(match)
// console.log(html)
let attr, end
while(!(end = html.match(startTagClose)) && (attr = html.match(attribute))) { // 如果不是开始标签的结束,就一直匹配,并且每次匹配都把匹配到的结果存起来
advance(attr[0].length) // 每次匹配完,再把匹配过的字符去掉
match.attrs.push(
{
name:attr[1],
value: attr[3] || attr[4] || attr[5] || true
}
)
}
console.log(match)

if(end) {
// 如果匹配到了end,也应该删除
advance(end[0].length)
}
return match
}

image-20181111100049281

第一个开始标签解析完成,继续向下解析文本内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
while(html) {
// vue2中,html最开始一定是一个<
// 如果textEnd为0,说明是一个开始标签或者结束标签
// 如果textEnd>0,说明就是文本的结束位置
let textEnd = html.indexOf('<') // 如果索引为0,则说明是个标签,开始标签取完了,再去取尖角号,取到的是文本结束的位置
if(textEnd == 0) {
const startTagMatch = parseStartTag() // 开始标签的匹配结果
if(startTagMatch) { // 解析到的开始标签
continue
console.log(html) // 截取完之后,可能还是开始标签
}
// break
}
if(textEnd >= 0) { // 解析到的文本
let text = html.substring(0, textEnd) // 文本内容
if(text) {
advance(text.length)
console.log(html)
}
break
}
}

结果:

image-20221123085745451

该标签前面的空格内容被截取掉了

继续解析结束标签

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
while(html) {
// vue2中,html最开始一定是一个<
// 如果textEnd为0,说明是一个开始标签或者结束标签
// 如果textEnd>0,说明就是文本的结束位置
let textEnd = html.indexOf('<') // 如果索引为0,则说明是个标签,开始标签取完了,再去取尖角号,取到的是文本结束的位置
if(textEnd == 0) {
const startTagMatch = parseStartTag() // 开始标签的匹配结果
if(startTagMatch) { // 解析到的开始标签
continue
console.log(html) // 截取完之后,可能还是开始标签
}
//如果不是开始标签,那么就是结束标签
let endTagMatch = html.match(endTag)
if(endTagMatch) {
advance(endTagMatch[0].length)
continue
}
}
if(textEnd >= 0) { // 解析到的文本
let text = html.substring(0, textEnd) // 文本内容
if(text) {
advance(text.length)
}
}
}
console.log(html)

如果打印为空,说明while循环写的没问题

我们在while内部打个断点,看看整个流程

着重看每次循环的html变量以及进入的是哪个分支

如果是类似<br />这样的自闭合标签呢?

image-20221123162047816

这里先留个坑,处理的时候直接就去掉了

我们在调用解析方法前,打印下获取到的template

image-20221123162236809

获取到的template中的自闭合标签已经没有了

上面的处理,只是把字符串删除了,并没有替换文本

我们希望把文本稍作处理,需要写几个方法把各自匹配的内容,暴露出去,让外面来处理

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
function parsetHTML(html) {
function start(tag, attrs) {
console.log('开始标签', tag, attrs)
}

function chars(text) {
console.log('文本', text)
}

function end(tag) {
console.log('结束标签', tag)
}

// ...
while(html) {

let textEnd = html.indexOf('<')
if(textEnd == 0) {
const startTagMatch = parseStartTag()
if(startTagMatch) {
start(startTagMatch.tagName, startTagMatch.attrs) // 把匹配到的开始标签的内容,传出去
continue
}

let endTagMatch = html.match(endTag)
if(endTagMatch) {
advance(endTagMatch[0].length)
end(endTagMatch[1]) // 把匹配到的结束标签,传出去

continue
}
}
if(textEnd >= 0) {
let text = html.substring(0, textEnd)
if(text) {
char(text) // 把匹配到的文本,传出去
advance(text.length)
}
}
}

}

通过虚拟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
2
3
4
5
{
"scripts": {
"dev": "rollup -w -c scripts/config.js --sourcemap --environment TARGET:web-full-dev"
}
}

概念扫盲

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
2
3
4
5
6
7
8
module.exports = {
// ...
resolve: {
alias: {
'vue$': 'vue/dist/vue.esm.js'
}
}
}

Rollup

1
2
3
4
5
6
7
8
9
10
const alias = require('rollup-plugin-alias')

rollup({
// ...
plugins: [
alias({
'vue': 'vue/dist/vue.esm.js'
})
]
})

Browserify

Add to your project’s package.json:

1
2
3
4
5
6
{
// ...
"browser": {
"vue": "vue/dist/vue.common.js"
}
}

目录结构

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
├── benchmarks                  性能、基准测试
├── dist 构建打包的输出目录
├── examples 案例目录
├── flow flow 语法的类型声明
├── packages 一些额外的包,比如:负责服务端渲染的包 vue-server-renderer、配合 vue-loader 使用的的 vue-template-compiler,还有 weex 相关的
│ ├── vue-server-renderer
│ ├── vue-template-compiler
│ ├── weex-template-compiler
│ └── weex-vue-framework
├── scripts 所有的配置文件的存放位置,比如 rollup 的配置文件
├── src vue 源码目录
│ ├── compiler 编译器
│ ├── core 运行时的核心包
│ │ ├── components 全局组件,比如 keep-alive
│ │ ├── config.js 一些默认配置项
│ │ ├── global-api 全局 API,比如熟悉的:Vue.use()、Vue.component() 等
│ │ ├── instance Vue 实例相关的,比如 Vue 构造函数就在这个目录下
│ │ ├── observer 响应式原理
│ │ ├── util 工具方法
│ │ └── vdom 虚拟 DOM 相关,比如熟悉的 patch 算法就在这儿
│ ├── platforms 平台相关的编译器代码
│ │ ├── web
│ │ └── weex
│ ├── server 服务端渲染相关
├── test 测试目录
├── types TS 类型声明

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
    <!DOCTYPE html>
    <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 构造函数所在的文件