likes
comments
collection
share

(一)手写 Vue 源码 —— 模板编译

作者站长头像
站长
· 阅读数 18

一、使用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()
	]
}

(一)手写 Vue 源码 —— 模板编译 在src目录下手动创建入口文件:

(一)手写 Vue 源码 —— 模板编译 在rollup.config.js的plugins里面是可以配置很多插件的,这里我们需要配置babel,但一般我们会单独创建一个.babelrc文件。

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

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

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

(一)手写 Vue 源码 —— 模板编译 运行一下

npm run dev

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

(一)手写 Vue 源码 —— 模板编译 我们往./src/index.js里面添加一些代码

const a=10
console.log(a);

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

(一)手写 Vue 源码 —— 模板编译

我们把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>

(一)手写 Vue 源码 —— 模板编译

创建 Vue 的入口,这里使用构造函数,而不是 ES6 的 class,因为 ES6 的class 要求所有的扩展都在类的内部来进行扩展 (一)手写 Vue 源码 —— 模板编译

(一)手写 Vue 源码 —— 模板编译

(一)手写 Vue 源码 —— 模板编译

初始化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
	}
}

后续再拓展都可以采用这种方式 (一)手写 Vue 源码 —— 模板编译

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

(一)手写 Vue 源码 —— 模板编译

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

props, data, methods, computed, watch

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

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

(一)手写 Vue 源码 —— 模板编译

(一)手写 Vue 源码 —— 模板编译 属性劫持,采用 DefineProperty 将所有的属性进行劫持

(一)手写 Vue 源码 —— 模板编译

三、实现对象的深度观测

观察者 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

(一)手写 Vue 源码 —— 模板编译

(一)手写 Vue 源码 —— 模板编译

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

(一)手写 Vue 源码 —— 模板编译 这里也可以访问到

(一)手写 Vue 源码 —— 模板编译 当 data 数据源是多层嵌套对象时,需要递归代理属性,set 的 newValue 也需要观察, 但是一上来就深度代理递归属性,性能差

(一)手写 Vue 源码 —— 模板编译 到这里,可以 debugger 调试过一下流程

(一)手写 Vue 源码 —— 模板编译

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

(一)手写 Vue 源码 —— 模板编译 整理一下 set 代码格式

(一)手写 Vue 源码 —— 模板编译 如果是数组的话,会发现索引被添加了 get 和 set

(一)手写 Vue 源码 —— 模板编译 显然,这不是我们想要的

(一)手写 Vue 源码 —— 模板编译 如果是数组的话也是用 defineProperty 会浪费很多性能,很少用户会arr[223] = 12 这样操作数组,所以 Vue2 的 defineProperty是不代理数组的, 但是 Vue3 中是直接使用 polyfill 给数组做代理了

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

数组响应式的实现

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

变异方法:

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

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

(一)手写 Vue 源码 —— 模板编译 注意不能在原型上直接改写,这样的话所有的数组方法就都被重写了

(一)手写 Vue 源码 —— 模板编译 实现思路:

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

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

(一)手写 Vue 源码 —— 模板编译

(一)手写 Vue 源码 —— 模板编译

(一)手写 Vue 源码 —— 模板编译

(一)手写 Vue 源码 —— 模板编译 可以 debugger 看一下过程

(一)手写 Vue 源码 —— 模板编译 现在往数组里添加对象,比如 arr.push({a: 1}),它并不是响应式的

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

(一)手写 Vue 源码 —— 模板编译

arr.splice(1, 1, xxx)

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

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

observeArray方法无法 import,所以

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

属性 __ob__ 必须是不可枚举的

(一)手写 Vue 源码 —— 模板编译

(一)手写 Vue 源码 —— 模板编译

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

(一)手写 Vue 源码 —— 模板编译

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

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

所以可以再代理一次

(一)手写 Vue 源码 —— 模板编译 写到这里,忘记重新 npm run dev 了,找了一个小时

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

代理成功

(一)手写 Vue 源码 —— 模板编译

以上,初始化完毕。

页面挂载

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

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

(一)手写 Vue 源码 —— 模板编译

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

五、识别模板准备编译

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

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

查看样式:

搜索关键词: vue2 template explorer

v2.template-explorer.vuejs.org/

(一)手写 Vue 源码 —— 模板编译 引入 compileToFunctions 函数

(一)手写 Vue 源码 —— 模板编译

(一)手写 Vue 源码 —— 模板编译

(一)手写 Vue 源码 —— 模板编译

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/

开始标签:

(一)手写 Vue 源码 —— 模板编译

(一)手写 Vue 源码 —— 模板编译

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

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

(一)手写 Vue 源码 —— 模板编译

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

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

(一)手写 Vue 源码 —— 模板编译 匹配到了标签 (一)手写 Vue 源码 —— 模板编译 开始写 ast 结构

(一)手写 Vue 源码 —— 模板编译 <div 被删掉了

(一)手写 Vue 源码 —— 模板编译 继续解析开始标签的属性

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

(一)手写 Vue 源码 —— 模板编译 解析一个属性删除一个

删除尖角号 >

(一)手写 Vue 源码 —— 模板编译

(一)手写 Vue 源码 —— 模板编译

属性没有值就默认 value 是 true

(一)手写 Vue 源码 —— 模板编译

(一)手写 Vue 源码 —— 模板编译

(一)手写 Vue 源码 —— 模板编译 匹配开始标签和文本内容

(一)手写 Vue 源码 —— 模板编译 匹配结束标签

(一)手写 Vue 源码 —— 模板编译 打断点看是否能跑通

(一)手写 Vue 源码 —— 模板编译

(一)手写 Vue 源码 —— 模板编译 小结:一个一个解析将结果抛出去。

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文本

(一)手写 Vue 源码 —— 模板编译

(一)手写 Vue 源码 —— 模板编译 看一下效果

(一)手写 Vue 源码 —— 模板编译

(一)手写 Vue 源码 —— 模板编译

  • 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"

(一)手写 Vue 源码 —— 模板编译

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

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

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

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

(一)手写 Vue 源码 —— 模板编译

(一)手写 Vue 源码 —— 模板编译

(一)手写 Vue 源码 —— 模板编译

接下来将模板变成 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)

(一)手写 Vue 源码 —— 模板编译 new Function 把字符串转换成可执行的函数了

render 一定存在了

(一)手写 Vue 源码 —— 模板编译

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

实现页面挂载的流程

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

(一)手写 Vue 源码 —— 模板编译

(一)手写 Vue 源码 —— 模板编译

(一)手写 Vue 源码 —— 模板编译

(一)手写 Vue 源码 —— 模板编译

(一)手写 Vue 源码 —— 模板编译

新建 vdom 文件夹

(一)手写 Vue 源码 —— 模板编译 接下来将虚拟节点变成真实节点

先将 el 挂载到实例上

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

(一)手写 Vue 源码 —— 模板编译

(一)手写 Vue 源码 —— 模板编译

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

(一)手写 Vue 源码 —— 模板编译

(一)手写 Vue 源码 —— 模板编译

双引号条件写一下

(一)手写 Vue 源码 —— 模板编译 页面可看到了

(一)手写 Vue 源码 —— 模板编译

九、Vue 和 React 区别

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