前言
Vue 是我们常用的 JavaScript 框架,然而仅仅作为使用者总归是有些肤浅。于是,从今天开始,我要开始深入研究 Vue 原理,通过源码窥探其内部机制。
第一篇文章我们将从一个小型的 Vue 实现着手。注意,本文的主要实现及原理均学习自《2021 年,让我们手写一个 mini 版本的 vue2.x 和 vue3.x 框架》一文,本文目前以 Vue 2.x 为基准。
现在,我们将定义一个 miniVue
对象,并模拟如 Vue 一样的使用:
<div id="app">
<h3>{{ count }}</h3>
<button type="button" v-on:click="increase">+</button>
</div>
<script>
const app = new miniVue({
el: "#app",
data: {
count: 666
},
methods: {
increase() {
this.count += 1;
}
}
});
</script>
我们将期望通过实现一个 miniVue
达成以下内容:
- 页面的
count
绑定于miniVue
的data
中,即页面默认显示666
; - 点击
+
号按钮时,将触发miniVue
中的increase
方法; increase
方法调用后,页面的count
将同步更新显示为667
。
源码实现
miniVue 类
首先定义 miniVue
类,声明其构造方法,并在其中保存构造时传递的内容:
class miniVue {
constructor(options = {}) {
console.log('--- constructor ---');
// 属性:
// - 保存根元素,不考虑数组情况
this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el;
// - 保存方法、数据、配置
this.$methods = options.methods;
this.$data = options.data;
this.$options = options;
}
}
代理数据
在 miniVue
内部,我们只能通过 this.$data.count
的形式来访问 data
的数据。如何可以通过 this.count
直接访问呢?答案是代理。我们在 miniVue
类中定义以下方法:
proxy(data) {
// 遍历 data 中的所有 key
Object.keys(data).forEach(key => {
// Object.defineProperty(obj, prop, desc)
// 作用:在一个对象(obj)上定义一个新属性(prop),或者修改一个已存在属性,desc 进行更精准的属性控制
Object.defineProperty(
this, // this 对象,绑定 key 的属性
key, {
enumerable: true, // 可枚举(默认 false)
configurable: true, // 可删除(默认 false)
get: () => { // 存取器 - get 函数
console.log('proxy - get invoked');
return data[key];
},
set: (newValue) => { // 存取器 - set 函数
console.log('proxy - set invoked');
// 值未发生改变或新旧值为非 NaN 时,跳过
if (newValue === data[key] || __isNaN(newValue, data[key])) {
return;
}
data[key] = newValue;
}
})
})
}
并在 miniVue
的构造方法中添加代理:
constructor(options = {}) {
console.log('--- constructor ---');
// ...
// 代理
this.proxy(this.$data);
}
此时我们即可尝试下效果:
constructor(options = {}) {
// ...
// ===> undefined
console.log(this.count);
// 代理
this.proxy(this.$data);
// ===> proxy - get invoked
// ===> 666
console.log(this.count);
// ===> proxy - set invoked
this.count = 233;
// ===> proxy - get invoked
// ===> 233
console.log(this.count);
}
响应式观察者
首先,我们再定义一个 Observer
观察者类:
class Observer {
constructor(data) {
this.walk(data);
}
}
具体逻辑在 walk
中实现对数据的响应式定义:
walk(data) {
// 仅处理对象类型,不考虑数组
if (!data || typeof data != 'object') {
return
}
// 1. 遍历 data 所有 key
// 2. 定义响应式(所有数据,当前数据,当前数据对应的值)
Object.keys(data).forEach(key => this.defineReactive(data, key, data[key]))
}
walk
中仅跳过边界情况,并遍历 data
中的 key
,实现继续进入 defineReactive
方法:
defineReactive(data, key, value) {
// eg. data: { "count": 666 }, key: "count", value: 666
const vm = this; // 保存 this 指向
this.walk(value); // 递归调用,因为 value 可能为对象
let dep = new Dependency();
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: () => {
console.log('obs - get invoked');
// 收集依赖
// 注:目前无 target
if (Dependency.target) {
dep.add(Dependency.target);
}
// 返回 value
return value;
},
set: (newValue) => {
console.log('obs - set invoked');
if (newValue === value || __isNaN(value, newValue)) {
// 未更新或 NaN,返回
return;
}
value = newValue; // 设置 value
vm.walk(newValue); // 递归处理
dep.notify(); // 通知 Dependency 更新
}
})
}
以上代码中出现了新的 Dependency
类,负责存储依赖关系:
class Dependency {
constructor() {
// 这里使用 ES6 的集合数据结构(Vue 源码是队列,先进先出)
this.deps = new Set();
}
add(dep) {
// 添加依赖
// 注:目前无 update 方法
if (dep && dep.update) {
// 存储依赖
this.deps.add(dep);
}
}
notify() {
// 通知,更新每一个依赖
// 注:目前无 update 方法
this.deps.forEach(dep => dep.update());
}
}
miniVue
的构造方法中也需要初始化观察者:
class miniVue {
constructor(options = {}) {
// ...
// 实例化观察者
new Observer(this.$data);
// ===> proxy - get invoked
// ===> obs - get invoked
// ===> 666
console.log(this.count);
// ===> proxy - set invoked
// ===> obs - get invoked (proxy setter 中的判断通过 data[key] 触发了 2 次 obs get)
// ===> obs - get invoked
// ===> obs - set invoked
this.count = 233;
// ===> proxy - get invoked
// ===> obs - get invoked
// ===> 233
console.log(this.count);
}
}
最终,经过两次 Object.defineProperty
,当我们在 miniVue
中使用 data
中的变量,将通过以下链路:
this.count -> this.data.count -> dep.add
this.count += 1 -> this.data.count += 1 -> dep.notify
但以上的实现目前还不完整,更新和通知还不能正常使用,因此我们需要继续添加代码。
Watcher
由于此时还没有使用到 Watcher
类,我们先将实现放在这儿,其功能我们在后面再详细说明:
class Watcher {
constructor(vm, key, callback) {
this.vm = vm;
this.key = key;
this.callback = callback;
Dependency.target = this;
this.__old = vm[key];
Dependency.target = null;
}
update() {
// 获取新值
let newValue = this.vm[this.key];
// 值未发生改变或新旧值为非 NaN 时,跳过
if (newValue === this.__old || __isNaN(newValue, this.__old)) {
return;
}
// 回调新值
this.callback(newValue);
// 保存旧值
this.__old = newValue;
}
}
编译器
我们在 *.vue
中写的代码其实并不是真正的 HTML,因此 Vue 需要将其编译为浏览器可以识别的 HTML。
注意:此处实现的内容将与 Vue 源码无关,仅简化实现部分能力。
class Compiler {
constructor (vm) {
// - 保存根元素、方法,组件本身
this.el = vm.$el;
this.methods = vm.$methods;
this.vm = vm;
// 开始从根元素开始编译
this.compile(vm.$el);
}
}
class miniVue {
constructor(options = {}) {
// ...
// 编译器
new Compiler(this);
}
}
HTML 的组成元素是一个个节点(node),主要分为文本节点(nodeType === 3
)和元素节点(nodeType === 1
):
Q: HTML 中节点都有哪些类型?nodeType 分别是多少? A: HTML 中节点的类型包括元素节点、属性节点、文本节点、注释节点、文档节点以及文档片段节点。
它们的 nodeType 值分别为:
元素节点:1 属性节点:2 文本节点:3 注释节点:8 文档节点:9 文档片段节点:11
其中,元素节点和文本节点是最常用的两种节点类型,分别代表 HTML 中的标签和文本内容。
From ChatGPT
例如:
<p>Hello World</p>
中的Hello World
是文本节点,<p>
和</p>
是元素节点。
compile(el) {
// 获取子节点
let childNodes = el.childNodes;
// 遍历
Array.from(childNodes).forEach(node => {
if (this.isTextNode(node)) {
this.compileText(node);
} else if (this.isElementNode(node)) {
this.compileElement(node);
}
if (node.childNodes && node.childNodes.length) {
// 递归处理子节点的子节点
this.compile(node);
}
})
}
isTextNode(node) {
// 文本节点
return node.nodeType === 3;
}
isElementNode(node) {
// 元素节点
return node.nodeType === 1;
}
编译元素节点
元素节点中如果使用了 Vue 指令,则也需要特殊处理,当然这里仅处理 v-text
& v-model
& v-on:click
:
compileElement(node) {
// 节点所有属性
const attrs = node.attributes;
if (attrs.length) {
Array.from(attrs).forEach(attr => {
// FIX: 修复后续的判断问题
let key = attr.value;
attr = attr.name;
// 判断是否为 Vue 指令
if (this.isDirective(attr)) {
// 截取处理:
// v-on:click => click
// v-text / v-model => text / model
let attrName = attr.indexOf(':') > -1 ? attr.substr(5) : attr.substr(2);
this.update(node, attrName, key, this.vm[key]);
}
})
}
}
isDirective(dir) {
return dir.startsWith('v-');
}
update(node, attrName, key, value) {
if (attrName === 'text') {
// 类似文本节点
node.textContent = value;
new Watcher(this.vm, key, newValue => {
node.textContent = newValue;
});
} else if (attrName === 'model') {
// model 双向绑定
node.value = value;
// 代码 -> 页面
new Watcher(this.vm, key, newValue => {
node.value = newValue;
});
// 页面 -> 代码
node.addEventListener('input', (e) => {
this.vm[key] = node.value;
});
} else if (attrName === 'click') {
// 事件绑定
node.addEventListener(attrName, this.methods[key].bind(this.vm));
}
}
编译文本节点
在 demo 中,我们的第一个文本节点即 {{ count }}
:
compileText(node) {
// eg. 准备匹配 `{{ count }}`;非 {{ }} 格式的文本将略过,仍展示原内容
let reg = /\{\{(.+?)\}\}/g;
let value = node.textContent; // {{ count }}
if (reg.test(value)) {
let key = RegExp.$1.trim(); // count
// 更新节点的文本内容(页面将显示 666)
node.textContent = value.replace(reg, this.vm[key]); // "666"
// 绑定更新(newValue 和 textContent 之间)
new Watcher(this.vm, key, newValue => {
node.textContent = newValue;
});
}
}
编译时将把 HTML 中的 {{ count }}
文本通过提取的 count
作为 key
从 data
中取值,并绑定更新关系。这里我们再把 Watcher
类的源码拿出来:
class Watcher {
constructor(vm, key, callback) {
this.vm = vm;
this.key = key;
this.callback = callback;
// 设置依赖目标
Dependency.target = this;
// 存储旧值,触发 proxy get,再触发 obs get
this.__old = vm[key];
// 清空依赖目标
Dependency.target = null;
}
update() {
// 获取新值
let newValue = this.vm[this.key];
// 值未发生改变或新旧值为非 NaN 时,跳过
if (newValue === this.__old || __isNaN(newValue, this.__old)) {
return;
}
// 回调新值
this.callback(newValue);
// 保存旧值
this.__old = newValue;
}
}
页面渲染时,将进行以下步骤:
- 通过
Object.defineProperty
将数据内容分别直接绑定在this
上,省去通过this.$data
访问; - 通过
Object.defineProperty
将数据内容分别添加依赖更新机制,使得数据更新时可以通知所有依赖方回调; - 编译文本节点,普通文本直接展示,
{{ xxx }}
文本替换为数据再展示,并绑定更新; - 编译元素节点,无属性跳过,有属性则针对 Vue 指令进行特殊处理,比如
v-on:click
将该元素的点击事件绑定在click
方法中。
当用户点击 +
按钮时,将按照以下链路更新数据并最终显示在页面上:
- 根据编译时绑定好的
click
方法调用到increase
方法; increase
方法中通过事先绑定好的代理获取并更新miniVue
数据中的count
;- 更新过程会先调用代理的 setter,再更新观察着的 setter;
- 观察者的 setter 会通知依赖,依赖会调用所有保存的依赖的
update
方法,也就是Watcher
的update
; Watcher
的update
会对比新旧值,并在发生改变时调用callback
且更新旧值;callback
即更新新值到节点的textContent
属性,至此页面更新。
-> 点击此处即可查看完整源码 <-
<div id="app">
<h3>{{ count }}</h3>
<button type="button" v-on:click="increase">+</button>
</div>
<script>
class miniVue {
constructor(options = {}) {
console.log('--- constructor ---');
// 属性:
// - 保存根元素,不考虑数组情况
this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el;
// - 保存方法、数据、配置
this.$methods = options.methods;
this.$data = options.data;
this.$options = options;
// 代理
this.proxy(this.$data);
// 实例化观察者
new Observer(this.$data);
// 编译器
new Compiler(this);
}
proxy(data) {
// 遍历 data 中的所有 key
Object.keys(data).forEach(key => {
// Object.defineProperty(obj, prop, desc)
// 作用:在一个对象(obj)上定义一个新属性(prop),或者修改一个已存在属性,desc 进行更精准的属性控制
Object.defineProperty(
this, // this 对象,绑定 key 的属性
key, {
enumerable: true, // 可枚举(默认 false)
configurable: true, // 可删除(默认 false)
get: () => { // 存取器 - get 函数
console.log('proxy - get invoked');
return data[key];
},
set: (newValue) => { // 存取器 - set 函数
console.log('proxy - set invoked');
// 值未发生改变或新旧值为非 NaN 时,跳过
if (newValue === data[key] || __isNaN(newValue, data[key])) {
return;
}
data[key] = newValue;
}
})
})
}
}
class Observer {
constructor(data) {
this.walk(data);
}
walk(data) {
// 仅处理对象类型,不考虑数组
if (!data || typeof data != 'object') {
return
}
// 1. 遍历 data 所有 key
// 2. 定义响应式(所有数据,当前数据,当前数据对应的值)
Object.keys(data).forEach(key => this.defineReactive(data, key, data[key]))
}
defineReactive(data, key, value) {
// eg. data: { "count": 666 }, key: "count", value: 666
const vm = this; // 保存 this 指向
this.walk(value); // 递归调用,因为 value 可能为对象
let dep = new Dependency();
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: () => {
console.log('obs - get invoked');
// 收集依赖
// 注:目前无 target
if (Dependency.target) {
dep.add(Dependency.target);
}
// 返回 value
return value;
},
set: (newValue) => {
console.log('obs - set invoked');
if (newValue === value || __isNaN(value, newValue)) {
// 未更新或 NaN,返回
return;
}
value = newValue; // 设置 value
vm.walk(newValue); // 递归处理
dep.notify(); // 通知 Dependency 更新
}
})
}
}
class Dependency {
constructor() {
// 这里使用 ES6 的集合数据结构(Vue 源码是队列,先进先出)
this.deps = new Set();
}
add(dep) {
// 添加依赖
// 注:目前无 update 方法
if (dep && dep.update) {
// 存储依赖
this.deps.add(dep);
}
}
notify() {
// 通知,更新每一个依赖
// 注:目前无 update 方法
this.deps.forEach(dep => dep.update());
}
}
class Watcher {
constructor(vm, key, callback) {
this.vm = vm;
this.key = key;
this.callback = callback;
// 设置依赖目标
Dependency.target = this;
// 存储旧值,触发 proxy get,再触发 obs get
this.__old = vm[key];
// 清空依赖目标
Dependency.target = null;
}
update() {
// 获取新值
let newValue = this.vm[this.key];
// 值未发生改变或新旧值为非 NaN 时,跳过
if (newValue === this.__old || __isNaN(newValue, this.__old)) {
return;
}
// 回调新值
this.callback(newValue);
// 保存旧值
this.__old = newValue;
}
}
class Compiler {
constructor (vm) {
// - 保存根元素、方法,组件本身
this.el = vm.$el;
this.methods = vm.$methods;
this.vm = vm;
// 开始从根元素开始编译
this.compile(vm.$el);
}
compile(el) {
// 获取子节点
let childNodes = el.childNodes;
// 遍历
Array.from(childNodes).forEach(node => {
if (this.isTextNode(node)) {
this.compileText(node);
} else if (this.isElementNode(node)) {
this.compileElement(node);
}
if (node.childNodes && node.childNodes.length) {
// 递归处理子节点的子节点
this.compile(node);
}
})
}
isTextNode(node) {
// 文本节点
return node.nodeType === 3;
}
isElementNode(node) {
// 元素节点
return node.nodeType === 1;
}
compileElement(node) {
// 节点所有属性
const attrs = node.attributes;
if (attrs.length) {
Array.from(attrs).forEach(attr => {
// FIX: 修复后续的判断问题
let key = attr.value;
attr = attr.name;
// 判断是否为 Vue 指令
if (this.isDirective(attr)) {
// 截取处理:
// v-on:click => click
// v-text / v-model => text / model
let attrName = attr.indexOf(':') > -1 ? attr.substr(5) : attr.substr(2);
this.update(node, attrName, key, this.vm[key]);
}
})
}
}
isDirective(dir) {
return dir.startsWith('v-');
}
update(node, attrName, key, value) {
if (attrName === 'text') {
// 类似文本节点
node.textContent = value;
new Watcher(this.vm, key, newValue => {
node.textContent = newValue;
});
} else if (attrName === 'model') {
// model 双向绑定
node.value = value;
// 代码 -> 页面
new Watcher(this.vm, key, newValue => {
node.value = newValue;
});
// 页面 -> 代码
node.addEventListener('input', (e) => {
this.vm[key] = node.value;
});
} else if (attrName === 'click') {
// 事件绑定
node.addEventListener(attrName, this.methods[key].bind(this.vm));
}
}
compileText(node) {
// eg. 准备匹配 `{{ count }}`;非 {{ }} 格式的文本将略过,仍展示原内容
let reg = /\{\{(.+?)\}\}/g;
let value = node.textContent; // {{ count }}
if (reg.test(value)) {
let key = RegExp.$1.trim(); // count
// 更新节点的文本内容(页面将显示 666)
node.textContent = value.replace(reg, this.vm[key]); // "666"
// 绑定更新(newValue 和 textContent 之间)
new Watcher(this.vm, key, newValue => {
node.textContent = newValue;
});
}
}
}
function __isNaN(a, b) {
return Number.isNaN(a) && Number.isNaN(b);
}
const app = new miniVue({
el: "#app",
data: {
count: 666
},
methods: {
increase() {
this.count += 1;
}
}
});
</script>
Tips
$
符号
一些 JavaScript 框架(如 Vue.js)也使用 $
符号来表示框架的内部属性或方法,以与用户定义的属性和方法进行区分。例如,在 Vue.js 中,可以使用 $data
来引用组件的数据对象。
需要注意的是,使用 $
开头的变量名只是一种约定,而不是 JavaScript 语言的特定语法。
__isNaN
__isNaN
是一个判断参数是否为数字类型的工具方法,我们将其定义在在外层,和 miniVue
同级:
function __isNaN(a, b) {
return Number.isNaN(a) && Number.isNaN(b);
}