(一)手写 Vue 源码 —— 模板编译
一、使用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()
]
}
在src目录下手动创建入口文件:
在rollup.config.js的plugins里面是可以配置很多插件的,这里我们需要配置babel,但一般我们会单独创建一个.babelrc文件。
{
"presets": [
"@babel/preset-env"
]
}
最后我们需要在package.json里面配置运行脚本。-c表示指定配置文件没跟具体名字就表示默认配置文件,-w表示监视,并设置 type 为 module。
"type": "module",
"scripts": {
"dev": "rollup -cw"
},
运行一下
npm run dev
可以看到./dist/vue.js生成下面的代码。实际上就是一个立即执行函数。最重要的地方就是预留了一个函数,这个函数就是程序的启动点。
我们往./src/index.js里面添加一些代码
const a=10
console.log(a);
再次编译,可以看到,const被转化为了es5的var。
我们把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 的入口,这里使用构造函数,而不是 ES6 的 class,因为 ES6 的class 要求所有的扩展都在类的内部来进行扩展
初始化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
}
}
后续再拓展都可以采用这种方式
vm.$options 可以通过实例的 $options 来获取实例上传递的参数
二、处理实例的数据源,状态初始化 (initState函数)
props, data, methods, computed, watch
这里的 $ options 的 $ 符号,是 Vue 特有的,Vue 会判断如果是 $ 开头的属性不会被变成响应式数据
如果 data 是函数就拿到函数的返回值,否则直接采用 data 作为数据源
属性劫持,采用 DefineProperty 将所有的属性进行劫持
三、实现对象的深度观测
观察者 observe
- 如果不是对象类型,不做任何处理
- 如果是对象类型,要区分开,如果对象已经被观测过,就不要再次观测了
__ob__
标识是否有被观测过- 写一个类专门用来观测数据
new Observer()
实现响应式的核心
observer
中,可拓展的代码放在外面,本身功能写在里面,符合高内聚写法
- 先调用
this.walk()
遍历对象,因为 数据源data
不论是函数还是对象,都是对象,而不是数组;这里循环对象使用的是Object.keys
和forEach
,而不是for in
(for in
会遍历原型链); - 在
walk()
中调用defineReactive()
,传入data,key, value
- 在
defineReactive()
中,使用Object.defineProperty()
重写属性,利用get
和set
进行依赖收集和派发更新 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
这时候外面是拿不到处理好的 data 的,可以在 vm 上挂载 _data就能访问了
这里也可以访问到
当 data 数据源是多层嵌套对象时,需要递归代理属性,set 的 newValue 也需要观察, 但是一上来就深度代理递归属性,性能差
到这里,可以 debugger 调试过一下流程
- step over next function call 跳过下一个函数调用
- step into next function call 进入下一个函数调用
整理一下 set 代码格式
如果是数组的话,会发现索引被添加了 get 和 set
显然,这不是我们想要的
如果是数组的话也是用
defineProperty
会浪费很多性能,很少用户会arr[223] = 12 这样操作数组,所以 Vue2 的 defineProperty
是不代理数组的, 但是 Vue3 中是直接使用 polyfill 给数组做代理了
四、实现数组的劫持及处理
数组响应式的实现
是通过改写数组的 7 种方法,如果用户调用了可以改写数组方法的 api ,那么我去劫持这个方法
变异方法:
push,pop, shift, unshift, reverse, sort, splice
- 修改数组长度和索引是无法更新视图的
注意不能在原型上直接改写,这样的话所有的数组方法就都被重写了
实现思路:
先把老的原型拷贝一份,再通过新原型的 __ proto__ 指向老的 prototype
- 根据
arrayPrototype.___proto __ = Array.prototype;
- 当用户调用这 7 个方法时,使用的是我们重写的方法;
- 当用户调用 7 个方法以外的方法比如
concat
时,也可以通过刚刚拷贝的原型链关系,向上查找,也可以调用到我们重写的方法。 总结,用户调用 push 方法会先经历我们重写的方法,之后调用数组原来的方法
可以 debugger 看一下过程
现在往数组里添加对象,比如
arr.push({a: 1})
,它并不是响应式的
所以,如果数组里面放的是对象类型,我期望他也会被变成响应式的
arr.splice(1, 1, xxx)
splice(起始索引index, 删除个数,在index前新增元素1,新增元素2)
此时,对新增的内容再次观测需要在 array.js 中,拿到 index.js 的 observeArray
方法
observeArray
方法无法 import,所以
可以给 Observer 添加属性 __ob__
把 this
传递过去,让 method 中可以拿到 observeArray
方法
属性 __ob__
必须是不可枚举的
小结:
- Vue2
对象
的响应式原理,就是给每个属性增加get
和set
,而且是递归
操作,写代码的时候尽量不要把所有的属性都放在 data 中,层次尽可能不要太深; 赋值一个新对象也会被变成响应式的; 数组
的响应式没有采用 defineProperty,采用的是函数劫持创造了一个新的原型重写了这个原型的 7 个方法,调用的时候采用的是这 7 个方法;增加了逻辑如果是新增的数据会再次劫持,最终调用的是数组原有的方法(注意数组的索引和长度没有被监控);数组中的对象类型会被响应式处理。
- 每个类都有一个
prototype
指向了一个公共的空间; - 每个实例都可以通过
__proto__
找到所属类的prototype
对应的内容
我希望可以直接通过 vm.xxx
取值,也可以 vm._data.xxx
所以可以再代理一次
写到这里,忘记重新
npm run dev
了,找了一个小时
以为是自己写的代理有问题。。。。真是蠢钝如猪了
代理成功
以上,初始化完毕。
页面挂载
状态初始化完毕后,需要进行页面挂载
el 属性和直接调用 $mount 是一样的
创建 render 函数 -> 虚拟 dom -> 渲染真实 dom
五、识别模板准备编译
diff 算法,主要是两个虚拟节点的比对,我们需要根据模板渲染出一个 render 函数,render 函数可以返回一个虚拟节点;数据更新了重新调用 render 函数,可以再返回一个虚拟节点
六、将模板转换成 ast 语法树 (template → render 函数 )
查看样式:
搜索关键词: vue2 template explorer
v2.template-explorer.vuejs.org/
引入 compileToFunctions 函数
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/
开始标签:
attribute 的正则图如下: 分组3 分组4 分组5 的区别:
标签的属性有可能是: a = 1 , a = '1' , a="1"
原理就是每解析完成一个就删除一个,直到全部解析完
let a = 'rcinn.cn'
a.substring(3)
console.log(a) // 输出结果为'nn.cn'
a.substring(2,4)
console.log(a) // 输出结果为'inn.'
匹配到了标签
开始写 ast 结构
<div
被删掉了
继续解析开始标签的属性
只要没有匹配到开始标签的结束 > 位置,就一直匹配
解析一个属性删除一个
删除尖角号 >
属性没有值就默认 value 是 true
匹配开始标签和文本内容
匹配结束标签
打断点看是否能跑通
小结:一个一个解析将结果抛出去。
在 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文本
看一下效果
- 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"
-
RegExp 对象 定义和用法 test() 方法用于检测一个字符串是否匹配某个模式. 如果字符串中有匹配的值返回 true ,否则返回 false。
-
trim() 字符串中移除前导空格、尾随空格和行终止符,该方法不修改原字符串。
-
处理儿子的内容格式,分纯文本 和 变量
-
exec 遇到全局匹配会有 lastIndex 问题,每次匹配前需要将 lastIndex 重置为 0
接下来将模板变成 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)
new Function 把字符串转换成可执行的函数了
render 一定存在了
八、将虚拟节点渲染成真实节点
实现页面挂载的流程
先调用生成的 render 函数获取到虚拟节点,再生成真实的 dom
新建 vdom 文件夹
接下来将虚拟节点变成真实节点
先将 el 挂载到实例上
再将 el 替换成 VNode,
即 创建 vm.$el
替换掉原来的 el
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 和 React 区别
- Vue 是MVVM 框架(基于 MVC 升级的,弱化了 controller 这一层),Vue 没有完全遵循 MVVM,因为传统的 MVVM 框架不能手动的操作数据。(ref 可以操作组件数据)。
- React 是一个 V 框架,只是将数据转化成视图,并没有绑定操作,更新数据也是手动调用 setState(实现组件化)。
- 响应式数据原理: Vue2 采用的是 DefineProperty ,目的是监控数据的变化,只要用户给数据赋值,就会触发试图更新)。
转载自:https://juejin.cn/post/7267858684667035704