Appearance
介绍
Vue 是一套用于构建用户界面的渐进式框架。与其它大型框架不同的是,Vue 被设计为可以自底向上逐层应用。Vue 的核心库只关注视图层,不仅易于上手,还便于与第三方库或既有项目整合。
核心思想:
- 组件化:增加代码的复用性,可维护性,可测试性,提高开发效率,方便重复使用,体现了高内聚低耦合
- 数据驱动:视图的内容随着数据的改变而改变
工具
Vue CLI
Vue 提供了一个官方的 CLI,为单页面应用 (SPA) 快速搭建繁杂的脚手架。它为现代前端工作流提供了开箱即用的构建设置。只需要几分钟的时间就可以运行起来并带有热重载、保存时 lint 校验,以及生产环境可用的构建版本。更多详情可查阅 Vue CLI 的文档。
Vue Devtools
在使用 Vue 时,可以在浏览器上安装 Vue Devtools。它允许你在一个更友好的界面中审查和调试 Vue 应用。
版本
很多人使用 Vue 的时候,都是直接通过 vue-cli 生成的模板代码,并不知道 Vue 其实提供了两个构建版本:
vue.js: 完整版本,包含了模板编译的能力;vue.runtime.js: 运行时版本,不提供模板编译能力,需要通过 vue-loader 进行提前编译。
在 NPM 包的 dist/ 目录你将会找到很多不同的 Vue.js 构建版本。这里列出了它们之间的差别:
| UMD | CommonJS | ES Module (基于构建工具使用) | ES Module (直接用于浏览器) | |
|---|---|---|---|---|
| 完整版 | vue.js | vue.common.js | vue.esm.js | vue.esm.browser.js |
| 只包含运行时版 | vue.runtime.js | vue.runtime.common.js | vue.runtime.esm.js | - |
| 完整版 (生产环境) | vue.min.js | - | - | vue.esm.browser.min.js |
| 只包含运行时版 (生产环境) | vue.runtime.min.js | - | - | - |
运行时 + 编译器 vs. 只包含运行时:
如果你需要在客户端编译模板 (比如传入一个字符串给 template 选项,或挂载到一个元素上并以其 DOM 内部的 HTML 作为模板),就将需要加上编译器,即完整版:
js
// 需要编译器
new Vue({
template: '<div>{{ hi }}</div>'
});
// 不需要编译器
new Vue({
render (h) {
return h('div', this.hi)
}
});简单来说,就是如果用了 vue-loader,就可以使用 vue.runtime.min.js,将模板编译的过程交过 vue-loader,如果你是在浏览器中直接通过 script 标签引入 Vue,需要使用 vue.min.js,运行的时候编译模板。运行时版本相比完整版体积要小大约 30%,所以应该尽可能使用这个版本。
基本原理
当一个 Vue 实例创建时,Vue 会遍历 data 中的属性,用 Object.defineProperty (Vue3.0 使用 Proxy) 把它们转为 getter/setter,并且在内部追踪相关依赖,在属性被访问和修改时通知变化。每个组件实例都有相应的 watcher 实例,它会在组件渲染的过程中把相关属性记录为依赖,之后当依赖项的 setter 触发时,会通知 watcher 重新计算,从而使它关联的组件重新渲染。

nextTick
nextTick 作用是在下次 DOM 更新循环结束之后执行延迟回调:
js
// 修改数据
vm.msg = 'Hello';
// DOM 还没有更新
Vue.nextTick(function () {
// DOM 更新了
});
// 作为一个 Promise 使用 (2.1.0 起新增,详见接下来的提示)
Vue.nextTick()
.then(function () {
// DOM 更新了
});
// or
new Vue({
// ...
methods: {
// ...
example: function () {
// 修改数据
this.message = 'changed';
// DOM 还没有更新
this.$nextTick(function () {
// DOM 现在更新了
// `this` 绑定到当前实例
this.doSomethingElse();
});
}
}
});Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。然后,在下一个的事件循环「tick」中,Vue 刷新队列并执行实际 (已去重的) 工作。
Vue 在内部对异步队列尝试使用原生的 Promise.then、MutationObserver 和 setImmediate,如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替。
多数情况我们不需要关心这个过程,但是如果你想基于更新后的 DOM 状态来做点什么,那么就可以在数据变化之后立即使用 Vue.nextTick(callback),这样 callback 就会在在 DOM 更新完成后被调用。
模板编译原理
Vue 中的模板 template 无法被浏览器解析并渲染,因为这不属于浏览器的标准,不是正确的 HTML 语法,所以需要将 template 转化成一 个 JavaScript 函数,这样浏览器就可以执行这一函数并渲染出对应的 HTML 元素,让视图跑起来。这一转化的过程,就称为模板编译。模板编译又分三个阶段:解析 parse,优化 optimize,生成 generate,最终生成可执行函数 render。
- 解析阶段:使用大量的正则表达式对
template字符串进行解析,将标签、指令、属性等转化为抽象语法树 AST - 优化阶段:遍历 AST,找到其中的一些静态节点并进行标记,方便在页面重渲染进行 diff 比较时,直接跳过这一些静态节点,优化 runtime 的性能
- 生成阶段:将最终的 AST 转化为
render函数字符串
Virtual DOM
Vue 通过建立一个虚拟 DOM 来追踪自己要如何改变真实 DOM。
Virtual DOM 其实就是一棵以 JS 对象(VNode 节点)作为基础的树,用对象属性来描述节点,实际上它只是一层对真实 DOM 的抽象。最终可以通过一系列操作使这棵树映射到真实环境上。
简单实现:
js
class VNode {
constructor(tagName, props, children) {
this.tagName = tagName;
this.props = props;
this.children = children;
}
render() {
let el = document.createElement(this.tagName);
let props = this.props;
for (let propName in props) {
el.setAttribute(propName, props[propName]);
}
this.children.forEach(child => {
let childEle = child instanceof VNode
? child.render()
: document.createTextNode(child);
el.appendChild(childEle);
});
return el;
}
}
// Example:
let ulVnode = new VNode('ul', { class: 'list' }, [
new VNode('li', { class: 'item' }, ['item-1']),
new VNode('li', { class: 'item' }, ['item-2']),
new VNode('li', { class: 'item' }, ['item-3'])
]);
let ulEle = ulVnode.render();
document.appendChild(ulEle);Diff 算法的原理
所谓 Virtual DOM Diff 算法,就是比较两个对象树的差异,也正是 Virtual DOM 的核心。传统的比较两棵树差异的算法,时间复杂度是 O(n³),大量操作 DOM 的时候肯定是接受不了的。
Vue 中 Virtual Dom Diff 算法,仅在同级的 vnode 间做 diff,递归地进行同级 vnode 的 diff,最终实现整个 DOM 树的更新。因为跨层级操作是非常少的,忽略不计,这样时间复杂度就从 O(n³) 变成 O(n)。Diff 过程如下:
- 首先,对比节点本身,判断是否为同一节点,如果不为相同节点,则删除该节点,重新创建节点进行替换
- 如果为相同节点,进行 patchVnode,判断如何对该节点的子节点进行处理,先判断一方有子节点一方没有子节点的情况(如果新的 children 没有子节点,将旧的子节点移除)
- 比较如果都有子节点,则进行 updateChildren,判断如何对这些新老节点的子节点进行操作(diff 核心)
- 匹配时,找到相同的子节点,递归比较子节点
key 的作用
key 是 Vue 中 vnode 的唯一标记,通过 key,diff 操作可以更准确、更快速:
- 更准确:因为带
key就不是就地复用了,在sameNode函数a.key === b.key对比中可以避免就地复用的情况。所以会更加准确 - 更快速:利用
key的唯一性生成 map 对象来获取对应节点,比遍历方式更快
key 的使用可以区分为两种场景:
v-if中使用key:由于 Vue 会尽可能高效地渲染元素,通常会复用已有元素而不是从头开始渲染。因此当使用v-if来 实现元素切换的时候,如果切换前后含有相同类型的元素,那么这个元素就会被复用。如果是相同的input元素,那么切换前后用户的输入不会被清除掉,这样是不符合需求的。因此可以通过使用key来唯一的标识一个元素,这个情况下,使用key的元素不会被复用。这个时候key的作用是用来标识一个独立的元素。v-for中使用key:用v-for更新已渲染过的元素列表时,它默认使用「就地复用」的策略。如果数据项的顺序发生了改变,Vue 不会移动 DOM 元素来匹配数据项的顺序,而是简单复用此处的每个元素。因此通过为每个列表项提供一个key值,以便 Vue 跟踪元素的身份,从而高效的实现复用。这个时候key的作用是为了高效的更新渲染虚拟 DOM。
模拟实现
以下,实现一个简单的 MVVM 框架,模拟 Vue 的响应式原理。主要包括以下功能:
- 接收初始化的参数,这里只举几个简单的例子
el、data、options - 通过私有方法
_proxyData把data注册到 Vue 中 转成getter、setter - 使用
observer把data中的属性转为「响应式」添加到「自身身上」 - 使用
observer方法监听data的所有属性变化来通过观察者模式更新视图 - 使用
compiler编译元素节点上面「指令」和文本节点「差值表达式」

文件结构如下:
bash
.
├── compiler.js # Compiler 类,模板编译
├── dep.js # Dep 类,收集依赖、添加观察者、通知观察者
├── observer.js # Observer 类,响应式数据转换
├── vue.js # Vue 类
└── watcher.js # Watcher 类js
/**
* vue.js
* 1. 接收初始化参数
* 2. 利用 Object.defineProperty 把 data 中的属性添加到 vue 实例
* 3. Observer, Compiler 类实例化
*/
class Vue {
constructor(options) {
// 获取到传入的对象 没有默认为空对象
this.$options = options || {};
// 获取 el
this.$el = typeof options.el === 'string'
? document.querySelector(options.el)
: options.el;
// 获取 data
this.$data = options.data || {};
// 调用 _proxyData 处理 data 中的属性
this._proxyData(this.$data);
// 使用 Obsever 把 data 中的数据转为响应式
new Observer(this.$data);
// 编译模板
new Compiler(this);
}
// 把 data 中的属性注册到 Vue
_proxyData(data) {
Object.keys(data).forEach((key) => {
// 进行数据劫持
// 把每个 data 的属性添加到 Vue 转化为 getter, setter 方法
Object.defineProperty(this, key, {
// 设置可以枚举
enumerable: true,
// 设置可以配置
configurable: true,
// 获取数据
get() {
return data[key];
},
// 设置数据
set(newValue) {
// 判断新值和旧值是否相等
if (newValue === data[key]) return;
// 设置新值
data[key] = newValue;
},
});
});
}
}js
/**
* observer.js
* 1. 深度遍历 data,转换为 getter, setter
* 2. 在 getter 中收集依赖,在 setter 中通知更新
*/
class Observer {
constructor(data) {
this.walk(data);
}
// 遍历 data,转为响应式
walk(data) {
// 判断 data 是否为「空」和「对象」
if (!data || typeof data !== 'object') return;
// 遍历 data
Object.keys(data).forEach((key) => {
// 转为响应式
this.defineReactive(data, key, data[key]);
});
}
// 转为响应式
defineReactive(obj, key, value) {
// 如果是对象类型,调用 walk 变成响应式,非对象类型会在 walk 中 return
this.walk(value);
// 保存一下 this
const self = this;
// 创建 Dep 对象
let dep = new Dep();
Object.defineProperty(obj, key, {
// 设置可枚举
enumerable: true,
// 设置可配置
configurable: true,
// 获取值
get() {
// 添加观察者对象 Dep.target 表示观察者
Dep.target && dep.addSub(Dep.target);
return value;
},
// 设置值
set(newValue) {
// 判断旧值和新值是否相等
if (newValue === value) return;
// 设置新值
value = newValue;
// 赋值的话如果是 newValue 是对象,对象里面的属性也应该设置为响应式的
self.walk(newValue);
// 触发通知,更新视图
dep.notify();
},
})
}
}js
/**
* dep.js
* 1. 观察者收集
* 2. 通知更新
*/
class Dep {
constructor() {
// 存储观察者
this.subs = [];
}
// 添加观察者
addSub(sub) {
// 判断观察者是否存在 和 是否拥有update方法
if (sub && sub.update) {
this.subs.push(sub);
}
}
// 通知方法
notify() {
// 触发每个观察者的更新方法
this.subs.forEach((sub) => {
sub.update();
});
}
}js
/**
* compiler.js
* 1. 模板编译
* 2. 创建观察者
*/
class Compiler {
constructor(vm) {
// 拿到 vm (vm 为 Vue 实例)
this.vm = vm;
// 拿到 el
this.el = vm.$el;
// 编译模板
this.compile(this.el);
}
// 编译模板
compile(el) {
// 获取子节点,如果使用 forEach 遍历就把伪数组转为真的数组
let childNodes = [...el.childNodes];
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);
}
});
}
// 编译文本节点(简单的实现)
compileText(node) {
// 核心思想是利用把正则表达式把 {{}} 去掉找到里面的变量
// 再去 Vue 实力上找这个变量赋值给 node.textContent
let reg = /\{\{(.+?)\}\}/;
// 获取节点的文本内容
let val = node.textContent;
// 判断是否有 {{}}
if (reg.test(val)) {
// 获取分组一(也就是 {{}} 里面的内容),去除前后空格
let key = RegExp.$1.trim();
// 进行替换再赋值给 node
node.textContent = val.replace(reg, this.vm[key]);
// 创建观察者
new Watcher(this.vm, key, (newValue) => {
node.textContent = newValue
});
}
}
// 编译元素节点(这里只处理指令)
compileElement(node) {
// 获取到元素节点上面的所有属性进行遍历
![...node.attributes].forEach((attr) => {
// 获取属性名
let attrName = attr.name;
// 判断是否是 v- 开头的指令
if (this.isDirective(attrName)) {
// 除去 v- 方便操作
attrName = attrName.substr(2);
// 获取 指令的值就是 v-text = "msg" 中 msg
// msg 作为 key 去 Vue 实例上找这个变量
let key = attr.value;
// 指令操作,执行指令方法
// vue 指令很多,为了避免大量 if 判断这里就写个 update 方法
this.update(node, key, attrName);
}
});
}
// 添加指令方法 并且执行
update(node, key, attrName) {
// 比如添加 textUpdater 就是用来处理 v-text 方法
// 我们应该就内置一个 textUpdater 方法进行调用
// 加个后缀加什么无所谓但是要定义相应的方法
let updateFn = this[attrName + 'Updater'];
// 如果存在这个内置方法 就可以调用了
updateFn && updateFn.call(this, node, key, this.vm[key]);
}
// 提前写好相应的指定方法比如 v-text
// 使用的时候和 Vue 的一样
textUpdater(node, key, value) {
node.textContent = value;
// 创建观察者
new Watcher(this.vm, key, (newValue) => {
node.textContent = newValue;
});
}
// v-model
modelUpdater(node, key, value) {
node.value = value;
// 创建观察者
new Watcher(this.vm, key, (newValue) => {
node.value = newValue;
})
// 这里实现双向绑定
node.addEventListener('input', () => {
this.vm[key] = node.value;
});
}
// 判断元素的属性是否是 vue 指令
isDirective(attr) {
return attr.startsWith('v-');
}
// 判断是否是「元素节点」
isElementNode(node) {
return node.nodeType === 1;
}
// 判断是否是「文本节点」
isTextNode(node) {
return node.nodeType === 3;
}
}js
/**
* watcher.js
*/
class Watcher {
constructor(vm, key, cb) {
// vm 是 Vue 实例
this.vm = vm;
// key 是 data 中的属性
this.key = key;
// cb 回调函数 更新视图的具体方法
this.cb = cb;
// 把观察者暂存在 Dep.target
Dep.target = this;
// 旧数据 更新视图的时候要进行比较
// 还有一点就是 vm[key] 这个时候就触发了 get 方法
// 之前在 get 把 观察者 通过dep.addSub(Dep.target) 添加到了 dep.subs中
this.oldValue = vm[key];
// Dep.target 就不用存在了 因为上面的操作已经存好了
Dep.target = null;
}
// 观察者中的必备方法 用来更新视图
update() {
// 获取新值
let newValue = this.vm[this.key];
// 比较旧值和新值
if (newValue === this.oldValue) return;
// 调用具体的更新方法
this.cb(newValue);
}
}测试:
html
<div id="app">
{{msg}} <br />
{{age}} <br />
<div v-text="msg"></div>
<input v-model="msg" type="text" />
</div>
<script src="./js/dep.js"></script>
<script src="./js/watcher.js"></script>
<script src="./js/compiler.js"></script>
<script src="./js/observer.js"></script>
<script src="./js/vue.js"></script>
<script>
let vm = new Vue({
el: '#app',
data: {
msg: '123',
age: 21,
},
});
</script>