(一)手写 Vue2.0 源码 —— 模板编译

241 阅读8分钟

一、使用Rollup搭建开发环境

1、什么是Rollup?

Rollup 是一个 JavaScript 模块打包器,可以将小块代码编译成大块复杂的代码,rollup.js 更专注于 JavaScript 类库打包(开发应用时使用 webpack,开发库使用 Rollup)

2、安装rollup

首先,新建空文件夹,npm 初始化并下载插件:

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

然后我们手动创建一个rollup.config.js配置文件:

// rollup 默认可以捣出一个对象,作为打包的配置文件
import babel from 'rollup-plugin-babel';
import resolve from '@rollup/plugin-node-resolve';

export default {
	input: `./src/index.js`, // 入口
	output: {  // 出口
		file: `./dist/vue.js`,
		name: 'Vue', // 给 global 添加一个 Vue 对象
		format: 'umd', // es6 esm cjs umd(兼容amd+cmd) iife自执行函数 umd全局挂在vue的变量,打包后的结果是umd模块规范
		sourcemap: true // 表示可以调试源代码
	},
	plugins: [
		babel({
			exclude: 'ndoe_modules/**' //glob的写法,不打包 ndoe_modules 中的文件
		}),
		resolve()
	]
}

image.png 在src目录下手动创建入口文件:

image.png 在rollup.config.js的plugins里面是可以配置很多插件的,这里我们需要配置babel,但一般我们会单独创建一个.babelrc文件。

{
	"presets": [
		"@babel/preset-env"
	]
}

最后我们需要在package.json里面配置运行脚本。-c表示指定配置文件没跟具体名字就表示默认配置文件,-w表示监视,并设置 type 为 module。

  "type": "module",
  "scripts": {
    "dev": "rollup -cw"
  },

image.png 运行一下

npm run dev

可以看到./dist/vue.js生成下面的代码。实际上就是一个立即执行函数。最重要的地方就是预留了一个函数,这个函数就是程序的启动点。

image.png 我们往./src/index.js里面添加一些代码

const a=10
console.log(a);

再次编译,可以看到,const被转化为了es5的var。

image.png

我们把const a导出。

const a=10
console.log(a);
export default { a }

这次生成的代码很不一样。最值得注意的是 global.Vue = factory()这行,生成了全局的Vue对象。  其中,module.exports 是commonjs写法,define.amd 是 amd 写法,其他代码现在看个大概就行。

(function (global, factory) {
	typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
	typeof define === 'function' && define.amd ? define(factory) :
	(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Vue = factory());
})(this, (function () { 'use strict';

	var a = 10;
	console.log(a);
	var index = {
	  a: a
	};

	return index;

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

新建一个./dist/index.html

<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">
	<title>Document</title>
</head>
<body>
	<script src="./vue.js"></script>
	<script>
		console.log(Vue);
	</script>
</body>
</html>

image.png

创建 Vue 的入口,这里使用构造函数,而不是 ES6 的 class,因为 ES6 的class 要求所有的扩展都在类的内部来进行扩展 image.png

image.png

image.png

初始化Vue

用构造函数扩展方法,可以写在 Vue 的原型上

// 自己编写的Vue入口
function Vue (options) {
	this._init(options);
}

Vue.prototype._init = function (options) {
	console.log(options);
}
export default Vue;

但是当 Vue 原型拓展方法越来越多时,就容易变得冗长

// 自己编写的Vue入口
function Vue (options) {
	this._init(options);
}

Vue.prototype._init = function (options) {
	console.log(options);
}
Vue.prototype._init = function (options) {
	console.log(options);
}
Vue.prototype._init = function (options) {
	console.log(options);
}
Vue.prototype._init = function (options) {
	console.log(options);
}
Vue.prototype._init = function (options) {
	console.log(options);
}
export default Vue;

这时候给 Vue 添加原型方法可以通过文件的方式来添加,防止所有的功能都在一个文件中处理; 但是把功能拆分到一个个文件中,文件每次都要引用很麻烦,可以巧妙的利用函数来解决这个问题

init.js文件中
// 初始化的拓展函数initMixin
export default function initMixin (Vue) {
	Vue.prototype._init = function (options) {
		debugger
	}
}

后续再拓展都可以采用这种方式 image.png

vm.$options 可以通过实例的 $options 来获取实例上传递的参数

image.png

二、处理实例的数据源,状态初始化 (initState函数)

props, data, methods, computed, watch

这里的 $ options 的 $ 符号,是 Vue 特有的,Vue 会判断如果是 $ 开头的属性不会被变成响应式数据

如果 data 是函数就拿到函数的返回值,否则直接采用 data 作为数据源

image.png

image.png 属性劫持,采用 DefineProperty 将所有的属性进行劫持

image.png

三、实现对象的深度观测

观察者 observe
  • 如果不是对象类型,不做任何处理
  • 如果是对象类型,要区分开,如果对象已经被观测过,就不要再次观测了
  • __ob__标识是否有被观测过
  • 写一个类专门用来观测数据 new Observer()
实现响应式的核心

observer 中,可拓展的代码放在外面,本身功能写在里面,符合高内聚写法

  • 先调用 this.walk() 遍历对象,因为 数据源 data 不论是函数还是对象,都是对象,而不是数组;这里循环对象使用的是 Object.keysforEach,而不是 for infor in 会遍历原型链);
  • walk() 中调用 defineReactive(),传入 data,key, value
  • defineReactive() 中,使用 Object.defineProperty() 重写属性,利用 getset 进行依赖收集和派发更新
  • value 这里产生了闭包, 这里不是 data[key]
// 写一个类专门用来观测数据 new Observer()
class Observer {
	constructor(data) {
		this.walk(data);
	}
	walk (data) { //循环对象 尽量不用 for in (会遍历原型链)
		let keys = Object.keys(data);
		keys.forEach(key => {
			defineReactive(data, key, data[key]);
		})
	}
}

function defineReactive (data, key, value) { // value 这里产生了闭包, 这里不是 data[key]
	Object.defineProperty(data, key, {
        // 属性会全部被重写增加了 get 和 set
		get () { // vm.xxx
			return value;
		},
		set (newValue) { // vm.xxx = 123
			if (newValue !== value) {
				value = newValue;
			}
		}
	})
}

export function observe (data) {
// 	如果不是对象类型,不做任何处理
	if (typeof data !== 'object' || data == null) {
		return
	};
 
/* 
	如果是对象类型,要区分开,如果对象已经被观测过,就不要再次观测了
	__ob__标识是否有被观测过
 */
	return new Observer(data);
	
}

闭包:

函数嵌套函数,内部函数就是闭包,只有函数内部的子函数才能读取内部变量。

特性:

  • 内部函数没有执行完成,外部函数变量不会销毁。
  • 形成一个不销毁的私有作用域,除了保护私有变量不受干扰以外,还可以存储一些内容。

写到这里,因为没有做观测区分,所以性能很差,因为所有的属性都被重新定义了一遍 但是可以看到每个属性都多了 get 和 set

image.png

image.png

这时候外面是拿不到处理好的 data 的,可以在 vm 上挂载 _data就能访问了

image.png 这里也可以访问到

image.png 当 data 数据源是多层嵌套对象时,需要递归代理属性,set 的 newValue 也需要观察, 但是一上来就深度代理递归属性,性能差

image.png 到这里,可以 debugger 调试过一下流程

image.png

- step over next function call 跳过下一个函数调用
  • step into next function call 进入下一个函数调用

image.png 整理一下 set 代码格式

image.png 如果是数组的话,会发现索引被添加了 get 和 set

image.png 显然,这不是我们想要的

image.png 如果是数组的话也是用 defineProperty 会浪费很多性能,很少用户会arr[223] = 12 这样操作数组,所以 Vue2 的 defineProperty是不代理数组的, 但是 Vue3 中是直接使用 polyfill 给数组做代理了

四、实现数组的劫持及处理

数组响应式的实现

是通过改写数组的 7 种方法,如果用户调用了可以改写数组方法的 api ,那么我去劫持这个方法

变异方法:

push,pop, shift, unshift, reverse, sort, splice

  • 修改数组长度和索引是无法更新视图的

image.png 注意不能在原型上直接改写,这样的话所有的数组方法就都被重写了

image.png 实现思路:

先把老的原型拷贝一份,再通过新原型的 __ proto__ 指向老的 prototype

  • 根据 arrayPrototype.___proto __ = Array.prototype;
  • 当用户调用这 7 个方法时,使用的是我们重写的方法;
  • 当用户调用 7 个方法以外的方法比如 concat 时,也可以通过刚刚拷贝的原型链关系,向上查找,也可以调用到我们重写的方法。
  • 总结,用户调用 push 方法会先经历我们重写的方法,之后调用数组原来的方法

image.png

image.png

image.png

image.png 可以 debugger 看一下过程

image.png 现在往数组里添加对象,比如 arr.push({a: 1}),它并不是响应式的

所以,如果数组里面放的是对象类型,我期望他也会被变成响应式的

image.png

arr.splice(1, 1, xxx)

splice(起始索引index, 删除个数,在index前新增元素1,新增元素2)

此时,对新增的内容再次观测需要在 array.js 中,拿到 index.js 的 observeArray方法

observeArray方法无法 import,所以

可以给 Observer 添加属性 __ob__this 传递过去,让 method 中可以拿到 observeArray方法

属性 __ob__ 必须是不可枚举的

image.png

image.png

小结:
  • Vue2 对象的响应式原理,就是给每个属性增加 getset,而且是递归操作,写代码的时候尽量不要把所有的属性都放在 data 中,层次尽可能不要太深; 赋值一个新对象也会被变成响应式的;
  • 数组的响应式没有采用 defineProperty,采用的是函数劫持创造了一个新的原型重写了这个原型的 7 个方法,调用的时候采用的是这 7 个方法;增加了逻辑如果是新增的数据会再次劫持,最终调用的是数组原有的方法(注意数组的索引和长度没有被监控);数组中的对象类型会被响应式处理。

image.png

  • 每个类都有一个 prototype 指向了一个公共的空间;
  • 每个实例都可以通过 __proto__ 找到所属类的 prototype 对应的内容

我希望可以直接通过 vm.xxx 取值,也可以 vm._data.xxx

所以可以再代理一次

image.png 写到这里,忘记重新 npm run dev 了,找了一个小时

以为是自己写的代理有问题。。。。真是蠢钝如猪了

代理成功

image.png

以上,初始化完毕。

页面挂载

状态初始化完毕后,需要进行页面挂载

el 属性和直接调用 $mount 是一样的

image.png

创建 render 函数 -> 虚拟 dom -> 渲染真实 dom

五、识别模板准备编译

diff 算法,主要是两个虚拟节点的比对,我们需要根据模板渲染出一个 render 函数,render 函数可以返回一个虚拟节点;数据更新了重新调用 render 函数,可以再返回一个虚拟节点

六、将模板转换成 ast 语法树 (template → render 函数 )

查看样式:

搜索关键词: vue2 template explorer

v2.template-explorer.vuejs.org/

image.png 引入 compileToFunctions 函数

image.png

image.png

image.png

AST 是用来描述语言本身的

Vdom 是描述 dom 元素的

将匹配的正则复制一下

  • ncname: 匹配标签名 形如 abc-123
  • qnameCapture: 匹配特殊标签 形如 abc:234 前面的abc:可有可无
  • startTagOpen: 匹配标签开始 形如 <abc-123 捕获里面的标签名
  • startTagClose: 匹配标签结束 >
  • endTag: 匹配标签结尾 如 捕获里面的标签名
  • attribute: 匹配属性 形如 id="app" 分组1是属性名,分组3, 4, 5 拿到的是 key 对应的值
  • defaultTagRE: 匹配双花括号
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`;
const qnameCapture = `((?:${ncname}\\:)?${ncname})`;
const startTagOpen = new RegExp(`^<${qnameCapture}`);
const startTagClose = /^\s*(\/?)>/; 
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`);
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/; 
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g;

正则表达式意义查看:regexper.com/

开始标签:

image.png

image.png

attribute 的正则图如下: 分组3 分组4 分组5 的区别:

标签的属性有可能是: a = 1 , a = '1' , a="1"

image.png

原理就是每解析完成一个就删除一个,直到全部解析完

let a = 'rcinn.cn'
a.substring(3)
console.log(a) // 输出结果为'nn.cn'
a.substring(2,4)
console.log(a) // 输出结果为'inn.'

image.png 匹配到了标签 image.png 开始写 ast 结构

image.png <div 被删掉了

image.png 继续解析开始标签的属性

只要没有匹配到开始标签的结束 > 位置,就一直匹配

image.png 解析一个属性删除一个

删除尖角号 >

image.png

image.png

属性没有值就默认 value 是 true

image.png

image.png

image.png 匹配开始标签和文本内容

image.png 匹配结束标签

image.png 打断点看是否能跑通

image.png

image.png 小结:一个一个解析将结果抛出去。

npmjs 官网 可以查看 htmlparser2 www.npmjs.com/package/htm…

import * as htmlparser2 from "htmlparser2";

const parser = new htmlparser2.Parser({
    onopentag(name, attributes) {
        /*
         * This fires when a new tag is opened.
         *
         * If you don't need an aggregated `attributes` object,
         * have a look at the `onopentagname` and `onattribute` events.
         */
        if (name === "script" && attributes.type === "text/javascript") {
            console.log("JS! Hooray!");
        }
    },
    ontext(text) {
        /*
         * Fires whenever a section of text was processed.
         *
         * Note that this can fire at any point within text and you might
         * have to stitch together multiple pieces.
         */
        console.log("-->", text);
    },
    onclosetag(tagname) {
        /*
         * Fires when a tag is closed.
         *
         * You can rely on this event only firing when you have received an
         * equivalent opening tag before. Closing tags without corresponding
         * opening tags will be ignored.
         */
        if (tagname === "script") {
            console.log("That's it?!");
        }
    },
});
parser.write(
    "Xyz <script type='text/javascript'>const foo = '<<bar>>';</script>"
);
parser.end();

接下来开始构建 ast 树,root 树根;

树的操作,需要根据开始标签和结束标签产生一个树

如何构建树的父子关系: 建立一个栈,把标签放进去,比如

<div><span><i></i></span></div>

创建 AST 元素,如果 树根为空则表示是根元素,就让 root = element element push 到栈中 当传 span 时,拿到栈的最后一个就是它的父元素 type: 1标签 3文本

image.png

image.png 看一下效果

image.png

image.png

  • AST 描述的是语法本身,语法中没有的不会被描述出来;
  • 虚拟 dom 是描述真实 dom ,可以自己添加属性;

七、通过 ast 语法树转换成 render 函数

遍历 ast 树,拼接成可以直接执行的JavaScript字符串

genCode 生成代码

// React.createElement(标签名,类名,孩子)
React.createElement('div', {className: 'xxx'}, createTextVnode('hello world'))

简化为:

_c('div', {className: 'xxx'}, _v('hello world'),_v('hello world'))

孩子可以放在一个数组里也可以分开,只要可以解析就行

属性的格式单独拿出来处理,比如 style 要拼接成键值对的形式

style="color: blue,;background: red"

image.png

  • RegExp 对象 定义和用法 test() 方法用于检测一个字符串是否匹配某个模式. 如果字符串中有匹配的值返回 true ,否则返回 false。

  • trim() 字符串中移除前导空格、尾随空格和行终止符,该方法不修改原字符串。

  • 处理儿子的内容格式,分纯文本 和 变量

  • exec 遇到全局匹配会有 lastIndex 问题,每次匹配前需要将 lastIndex 重置为 0

image.png

image.png

image.png

接下来将模板变成 render 函数,通过 with + new Function 的方式让字符串变成 JS 语法来执行

我们知道,把对象放到 with 函数中,可以直接打印其属性

let oobj = {
	name: 'lisi',
	age: 13
}

with(oobj) {
	console.log(name, age);
}

在 render 函数中 加 this 改造,效果是一样的

let oobj = {
	name: 'lisi',
	age: 13
}

function render () {
	with(this) {
		console.log(name, age);
	}
}

render.call(oobj)

image.png new Function 把字符串转换成可执行的函数了

render 一定存在了

image.png

八、将虚拟节点渲染成真实节点

实现页面挂载的流程

先调用生成的 render 函数获取到虚拟节点,再生成真实的 dom

image.png

image.png

image.png

image.png

image.png

新建 vdom 文件夹

image.png 接下来将虚拟节点变成真实节点

先将 el 挂载到实例上

再将 el 替换成 VNode, 即 创建 vm.$el 替换掉原来的 el

image.png

image.png

patch() 可以初始化渲染,后续更新也走这个 patch 方法
  • 每次更新页面的话 dom 是不会变的,我调用 render 方法时,数据变化了会根据数据渲染成新的虚拟节点,用新的虚拟节点渲染 dom。
  • 需要获取被替换的元素的父节点,将其下一个元素作为参照物,将新节点插入后,删除老节点
  • 把 el 插在老节点的下一个元素的前面

Node.insertBefore(), 方法在参考节点之前插入一个拥有指定父节点的子节点。

var insertedNode = parentNode.insertBefore(newNode, referenceNode);
  • insertedNode 被插入节点 (newNode)
  • parentNode 新插入节点的父节点
  • newNode 用于插入的节点
  • referenceNode newNode 将要插在这个节点之前

如果 referenceNode 为 null 则 newNode 将被插入到子节点的末尾*。*

Node.nextSibling 是一个只读属性,返回其父节点的 childNodes 列表中紧跟在其后面的节点,如果指定的节点为最后一个节点,则返回 null

image.png

image.png

双引号条件写一下

image.png 页面可看到了

image.png

九、解析vnode的data属性 映射到真实dom上

src/patch.js

image.png

image.png

十、Vue 和 React 区别

  • Vue 是MVVM 框架(基于 MVC 升级的,弱化了 controller 这一层),Vue 没有完全遵循 MVVM,因为传统的 MVVM 框架不能手动的操作数据。(ref 可以操作组件数据)。
  • React 是一个 V 框架,只是将数据转化成视图,并没有绑定操作,更新数据也是手动调用 setState(实现组件化)。
  • 响应式数据原理: Vue2 采用的是 DefineProperty ,目的是监控数据的变化,只要用户给数据赋值,就会触发试图更新)。