👉 个人博客主页 👈
📝 一个努力学习的程序猿
更多文章/专栏推荐:
HTML和CSS
JavaScript
Vue
Vue3
React
TypeScript
个人经历+面经+学习路线【内含免费下载初级前端面试题】
前端面试分享
前端学习+方案分享(VitePress、html2canvas+jspdf、vuedraggable、videojs)
前端踩坑日记(ElementUI)
【一文读懂】对闭包的理解及其衍生问题
【一文读懂】对原型链、事件循环、异步操作(Promise、async/await)、递归栈溢出的理解及其衍生问题
前言
在正式进入 Vue 系列内容之前,先简单描述下与 Vue 相关的基础原理。在面试过程中,鲜有面试官让我们聊 Vue 的底层原理。太复杂的源码逻辑,博主确实也不甚了解(下文中出现的代码,仅为展示,不代表 100% 为官方源码)。但是为了"应试",或许本文的内容,对各位有所帮助。
1、vue
1.1 vue 前言-简述
我们先回顾一下,在 JS 中使用 Vue:
<div id="app">
<input type="text" v-model="msg">
<p>hello {{msg}}</p>
</div>
<script type="text/javascript">
// 创建Vue示例
const vm = new Vue({
el: '#app',
data: {
msg: 'hello'
}
})
</script>
看到这个示例,如果看过我上一篇文章,就能发现,这不是妥妥的构造函数嘛!
https://blog.youkuaiyun.com/qq_45613931/article/details/145586439(构造函数 + 原型链)
那我们就可以先给一个简单到不能再简单的构造函数,用于下文的说明:
// 简化版的 Vue 构造函数
function Vue(options) {
// 数据初始化
this._init(options)
}
// Vue 在原型上定义的特殊属性(以 $ 开头,避免用户定义的数据属性与 Vue 内部属性命名冲突)
// 可以在 constructor 内初始化相关数据
Vue.prototype.$options = {
el: '#app',
data: function() {},
methods: {},
computed: {},
watch: {},
components: {},
directives: {},
filters: {},
// 生命周期钩子
created: function() {},
mounted: function() {},
// ... 其他选项
}
Vue.prototype.$data = {} // 数据
Vue.prototype.$props = {} // 属性
Vue.prototype.$el = {} // DOM 元素
// ... 其他属性
Vue.prototype._init = function(options) {
const vm = this
// mergeOptions 内,将用户选项覆盖到 Vue.prototype.$options中
vm.$options = mergeOptions(
vm.$options, // 默认选项
options || {}, // 用户传入的选项
vm // 实例自身
)
}
// ….
1.2 Vue 处理模版和指令
有了上述的概念后,接下来说下 Vue 如何处理模版和指令(内部太复杂,以下为简述)。
(1)首先,在 init 内拿到 el,获取到对应 DOM 元素后,内部要做的事情,就是把这些 Vue 自己定义的语法,转成浏览器可以理解的方式。
所以第一步就是解析 => 将模版字符串转换为 AST(抽象语法树)。主要就是识别 Vue 的语法(v-if、v-for 等),并确保模版语法的正确性。
比如我有一个这样的结构:<input type="text" v-model="msg">
,简易转化结果为:
// AST 结构示例(简易版)
{
type: 1,
tag: 'input',
attrs: [
{
name: 'type',
value: 'text'
},
{
name: 'v-model',
value: 'msg'
}
],
parent: null,
children: []
}
(2)第二步:通过 AST 生成 render 渲染函数。调用它之后,生成虚拟DOM。比如(简易版):
// 假设模板为:
// <div class="container">
// <p>{{ msg }}</p>
// <input type="text" v-model="msg">
// <button @click="handleClick">Click</button>
// </div>
// Vue 2.x 生成的 render 函数(简易版)
function render() {
const h = this._createVNode;
const toString = this._toString;
const model = this.msg;
return h('div',
{
staticClass: 'container'
},
[
// p 元素
h('p', [toString(model)]),
// input 元素
h('input', {
// 指令信息
directives: [{
name: "model",
rawName: "v-model",
value: model,
expression: "msg"
}],
attrs: { type: 'text' },
domProps: { value: model },
on: {
input: ($event) => {
if ($event.target.composing) return;
this.msg = $event.target.value;
}
}
}),
// button 元素
h('button', {
on: {
click: this.handleClick
}
}, ['Click'])
]
);
}
// Vue 3.x 生成的 render 函数(简易版)
export function render(_ctx, _cache) {
return _createVNode("div", {
class: "container"
}, [
// p 元素
_createVNode("p", null, [
_ctx.msg
], 1 /* TEXT */),
// input 元素
_createVNode("input", {
type: "text",
"onUpdate:modelValue": $event => ((_ctx.msg) = $event),
modelValue: _ctx.msg
}, null, 8 /* PROPS */, ["modelValue", "onUpdate:modelValue"]),
// button 元素
_createVNode("button", {
onClick: _cache[0] || (_cache[0] = (...args) => (_ctx.handleClick(...args)))
}, "Click")
])
}
createVNode
用来创建虚拟节点(内部逻辑较复杂)
toString
用来转化为文本
至此,就可以通过抽象的 AST 转化为可以用来生成虚拟 DOM 的 render 函数。
(为什么需要虚拟 DOM,详见 1.3 节)
(3)第三步:Vue 内部根据虚拟 DOM 去生成真实 DOM。最后的效果类似于:
<!-- 原始模板 -->
<input type="text" v-model="msg">
<!-- 相当于被转换为 -->
<input type="text"
:value="msg"
@input="msg = $event.target.value">
在 vue 项目中,vue-loader 就会做这样的事情,将 .vue 文件的每个部分(template、script、style)提取出来,并最后转化为浏览器能理解的方式。
(msg 双向绑定请看第 1.4 节)
Vue3.0 相对 Vue2.0 的优化点,可以参考我的文章(需要从文章内至少了解到,数据更新时,内部用到了diff算法):
https://blog.youkuaiyun.com/qq_45613931/article/details/109470718
1.3 虚拟 DOM
回答上文的问题:为什么需要虚拟 DOM?直接生成真实 DOM,挂载到页面不可以吗?
首先,浏览器渲染引擎的大致工作流程为:
第一步,用HTML分析器,分析HTML元素,构建一颗DOM树。
第二步,用CSS分析器,分析CSS文件和元素上的样式,生成页面的样式表。
第三步,将DOM树和样式表,关联起来,构建一颗Render树。
第四步,有了Render树,浏览器开始布局,为每个Render树上的节点确定一个在显示屏上出现的精确坐标。
第五步,Render树和节点显示坐标都有了,就调用每个节点paint方法,把它们绘制出来。
最后通过浏览器复杂的渲染过程得到最终效果。
其中,当我们改变一个元素的尺寸位置属性时,会重新进行样式计算、布局、绘制以及后面的所有流程,这种行为被称为重排;
当我们改变某个元素的颜色属性时,不会重新触发布局,但还是会触发样式计算和绘制,这种行为被称为重绘。
因为我们拿到 AST 之后,最终还是要把它们转成正常的标签。如果我们直接根据 AST 生成真实 DOM,则浏览器会从构建 DOM 树开始,从头到尾执行一遍流程。假设在一次操作中,需要更新 10 个 DOM 节点,浏览器收到第一个 DOM 请求后并不知道还有 9 次更新操作,因此会马上执行流程,最终执行 10 次。极端情况下,第一次计算完,紧接着下一个更新请求还是操作这个 DOM 节点,就会浪费性能。
因此,虚拟 DOM 的好处就是:若一次操作中有多次生成 / 更新 DOM 的动作,虚拟 DOM 不会立即操作DOM。此时会将多次更新的内容保存起来(批量处理),最终将处理结果一次性挂到 DOM 树上,再进行后续操作,避免大量无谓的计算量。
1.4 Vue 数据的双向绑定
我们在 1.2 节通过解析 el 模版,得到了真实 DOM。从表现来看,input 已经实现了自给自足,似乎不需要双向绑定?还是看这个例子:
<!-- 原始模板 -->
<input type="text" v-model="msg">
<!-- 相当于被转换为 -->
<input type="text"
:value="msg"
@input="msg = $event.target.value">
<!-- 原始模板 -->
<p>hello {{msg}}</p>
<!-- 相当于被转换为 -->
<p>hello <span>msg的值</span></p>
在原始模版的情况下,{{ msg }}
转化为真实 DOM,是上面的结果。此时如果想在 input 的 msg 变化的情况下,调整 {{ msg }}
,能想到的方法只能是这样做:
input.addEventListener('input', e => {
msg = e.target.value
// ❌ 问题:其他使用 msg 的地方不会自动更新
// 需要手动更新所有相关的 DOM
updateAllRelatedElements()
// document.querySelector('.xxx').textContent = msg
})
所以双向数据绑定不仅仅是处理输入和更新,更重要的是:自动依赖追踪。
首先,在 Vue.init (参考第 1.1 节构造函数)的时候,给每个 data 增加监听 Object.defineProperty(Vue 2.x):
// 简化的响应式系统
class Dep {
static target = null // 静态属性,全局唯一
constructor() {
this.subs = [] // 存储依赖的 Watcher
}
depend() {
if (Dep.target) {
this.subs.push(Dep.target)
}
}
notify() {
this.subs.forEach(watcher => watcher.update())
}
}
// 渲染 Watcher
class Watcher {
constructor(vm, updateFn) {
this.vm = vm
this.getter = updateFn
this.get() // 首次渲染
}
get() {
// 设置当前 Watcher 为全局的活动 Watcher
Dep.target = this
// 执行更新函数,这会触发数据的 getter
this.getter()
// 清除当前 Watcher
Dep.target = null
}
update() {
// 数据变化时更新视图
this.get()
}
}
// 简化版的响应式实现
function defineReactive(obj, key, val) {
// 依赖收集容器
const dep = new Dep()
Object.defineProperty(obj, key, {
get() {
// 收集依赖
dep.depend()
return val
},
set(newVal) {
if (val === newVal) return
val = newVal
// 通知所有依赖更新
dep.notify()
}
})
}
// 组件渲染时
function mountComponent(vm) {
// 创建渲染 Watcher
new Watcher(vm, () => {
// 渲染函数执行,这里会访问响应式数据,触发 getter
vm._update(vm._render())
})
}
从上述简例,总结就是:
(1)在 vue.init 初始化执行到 $mount 阶段(详见第 1.6 节),在 render 每个组件时,调用 mountComponent
,去创建 watcher
。此时全局 Dep.target (指针)指向当前组件。在 Watcher 执行 this.getter()
期间,render 函数会访问对应响应式数据,从而触发 Object.defineProperty
,收集到这个依赖。执行结束后 Dep.target = null
。
另外:defineReactive
内的 const dep = new Dep()
形成了闭包,这就让后续每次数据更新,都能拿到初始化时的依赖结果。
闭包的内容可以参考我之前的文章:https://blog.youkuaiyun.com/qq_45613931/article/details/144483787
(2)随后,在 input 值发生变化时,触发 Object.defineProperty – set
,通过 dep.notify
再次 render 所有依赖的组件,重新生成虚拟 DOM。
(3)接下来就是所谓的 vue-diff
算法出场的时间了。因为我们初始化已经生成了虚拟 DOM 树(并由此生成真实 DOM 树),我们需要只让页面更新有变化的 DOM 部分,所以需要找出有更新的节点,这就是 diff 算法做的事情。
综上,双向数据绑定流程结束。这就是 MVVM 模式:
数据绑定的流向:View -> Model 、Model -> View
1.5 Vue3.x 为什么改用 proxy
延续第 1.4 节,其实在使用 Object.defineProperty
时,会有以下局限性:
(1)无法监听新增或删除的属性。例如:
data() {
return {
a: '1'
}
},
methods: {
addProperty() {
// ❌ 直接赋值不会触发响应式
this.b = 2
// ✅ 使用 Vue.set 手动添加响应式属性
this.$set(this, 'b', 2)
}
}
虽然 Vue 确实提供了一个可以更新的方法,为新增的属性定义 getter 和 setter,从而让其变成响应式。但个人理解不建议这么做。
(2)无法监听数组的索引和长度变化。例如:
const arr = [1, 2, 3]
arr[1] = 99 // 无法触发响应式更新
arr.length = 1 // 无法触发响应式更新
这也就是为什么之前经常会遇到:明明数组的数据更新,但页面不渲染的问题。从而转为使用 this.$set(this.arr, 1, 99)
或者 this.arr.splice(1)
等方式去触发数组更新。
(3)性能问题。对象的每个属性都需要通过 Object.defineProperty 单独定义才能使用。对于嵌套深层的对象,Vue2 会递归遍历每一层属性,初始化响应式,这在大数据量场景下性能较差。
(4)代码复杂性。上述也已经提到,为了解决上述问题,Vue2 引入了 Vue.set 等其他用法(Vue.delete)。在特定情况下的页面不渲染,让使用者也陷入了困惑。
综上,Vue3.x 改用 ES6 的 Proxy 后,问题迎刃而解:Proxy 会代理整个对象,不用单独劫持每个属性(性能提升),几乎能拦截所有操作(新增、删除、数组索引修改等)。
const obj = reactive({})
obj.newKey = 'value' // 自动响应式
delete obj.newKey // 自动响应式
const arr = reactive([1, 2, 3])
arr[1] = 99 // 自动响应式
arr.length = 1 // 自动响应式
function defineReactive(obj) {
// 依赖收集容器
const dep = new Dep()
// 创建 Proxy
return new Proxy(obj, {
get(target, key) {
// 收集依赖
dep.depend()
return target[key]
},
set(target, key, newVal) {
if (target[key] === newVal) return true
target[key] = newVal
// 通知所有依赖更新
dep.notify()
return true
}
})
}
1.6 Vue 如何得知生命周期
在第 1.5 节,提到了 mountComponent
。那么 Vue 是如何判断什么时间,去触发不同的生命周期函数呢?我们来补全初始的构造函数。
function Vue(options) {
this._init(options)
}
class Dep {
static target = null
constructor() {
this.subs = [] // 存储依赖的 Watcher
}
depend() {
if (Dep.target) {
this.subs.push(Dep.target)
}
}
notify() {
this.subs.forEach(watcher => watcher.update())
}
}
function defineReactive(obj, key, val) {
// 依赖收集容器
const dep = new Dep()
Object.defineProperty(obj, key, {
get() {
// 收集依赖
dep.depend()
return val
},
set(newVal) {
if (val === newVal) return
val = newVal
// 通知所有依赖更新
dep.notify()
}
})
}
class Watcher {
constructor(vm, updateFn) {
this.vm = vm
this.getter = updateFn
this.get() // 初次渲染
}
get() {
Dep.target = this // 当前 Watcher
this.getter() // 执行渲染函数
Dep.target = null
}
update() {
// 数据变化时触发
queueWatcher(this) // 异步更新队列
}
run() {
this.get() // 重新渲染
}
}
function queueWatcher(watcher) {
// 将 Watcher 放入队列
nextTick(() => {
callHook(vm, 'beforeUpdate ')
watcher.run() // 执行更新
callHook(vm, 'updated ')
})
}
Vue.prototype._init = function(options) {
const vm = this
vm.$options = options
// beforeCreate: 实例初始化之后,数据观测之前
callHook(vm, 'beforeCreate')
// 初始化 data、props、methods 等 => vue2.x 内部遍历 data,触发defineReactive
initState(vm)
// created: 数据观测完成,还未挂载
callHook(vm, 'created')
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
Vue.prototype.$mount = function(el) {
const vm = this
// beforeMount: 挂载之前
callHook(vm, 'beforeMount')
mountComponent(vm)
// 注意:实际实现比这复杂,需要考虑子组件
// mounted: DOM 挂载完成
nextTick(() => {
callHook(vm, 'mounted')
})
}
// 组件渲染
function mountComponent(vm) {
// 创建渲染 Watcher
new Watcher(vm, () => {
// 渲染函数执行,这里会访问响应式数据,触发 getter
vm._update(vm._render())
})
}
Vue.prototype.$destroy = function() {
const vm = this
// beforeDestroy: 实例销毁之前
callHook(vm, 'beforeDestroy')
// 清理依赖、事件监听器等
vm._cleanup()
// destroyed: 实例销毁完成
callHook(vm, 'destroyed')
}
// 钩子函数调用器
function callHook(vm, hook) {
const handlers = vm.$options[hook]
if (handlers) {
for (let i = 0; i < handlers.length; i++) {
handlers[i].call(vm)
}
}
}
通过该简例,相信各位可以有个大概的理解,实际的逻辑处理比这些复杂的多。其实就是在特定的时机调用对应的钩子函数,并不是依赖 JS 提供的功能(实际上,也没有这样的 API):
- created: 数据初始化后
- mounted: DOM 挂载后(通过 nextTick)
- updated: 数据更新后(通过 Watcher)
- destroyed: 手动调用 $destroy 时
通过简例,这里还有两个问题没有得到答案。
(1)nextTick 是如何实现的,为何此时调用 mounted 一定是 DOM 挂载完成。这将在第 1.7 节表述。
(2)调用 $destroy 的时机。
关于(2)调用 $destroy
的时机,举个例子(我们不去考虑显示调用 vm.$destroy()
的情况):
new Vue({
el: "#app",
template: `
<div>
<child-component v-if="show"></child-component>
<button @click="show = false">Destroy Child</button>
</div>
`,
data: {
show: true,
},
});
在第 1.2 节我们知道,Vue 内部一定会把模版转化成 key-value 的形式,所以当 show 变为 false 时,虚拟 DOM 重新生成。通过 diff 算法检测到组件被销毁时,触发 vm.$destroy() 即可。Vue 项目内的 Vue Router 也是同理,都是能检测到的。
1.7 nextTick 简述
第 1.6 节遗留的问题:nextTick 是如何实现的,为何此时调用 mounted 一定是 DOM 挂载完成。
nextTick 相信各位并不陌生,我们经常在 Vue2.x 里用它来处理一些要在 DOM 更新完成后执行的回调任务。在 Vue 内部,道理也一样。
首先我们已经得知 render 函数内,做的是从虚拟 DOM 到真实 DOM 的过程。因为内部处理是同步的,且通过我之前的文章提到的事件循环(https://blog.youkuaiyun.com/qq_45613931/article/details/145586439),我们知道:
JS 是单线程执行的,执行时一定是:主线程代码先执行,执行过后,到事件队列中执行。在事件队列中,先去微任务队列中执行,没有微任务再去执行宏任务。
因此 nextTick 要做的事情,就是把回调任务放到宏任务或微任务队列中,此时前序已经同步的从虚拟 DOM 生成真实 DOM。所以在调用 mounted 的时候,DOM 已经挂载完成。
那 nextTick 内部的实现原理简单来看就是:
function nextTick(cb) {
if (typeof Promise !== 'undefined') {
// 优先使用 Promise
return Promise.resolve().then(cb)
} else if (typeof MutationObserver !== 'undefined') {
// 次优先使用 MutationObserver
let counter = 1
const observer = new MutationObserver(cb)
const textNode = document.createTextNode(counter)
observer.observe(textNode, { characterData: true })
textNode.data = counter = 2
} else {
setTimeout(cb, 0)
}
}
1.8 Vue 项目 data 为什么是函数?
最后,还有这样一个常见问题。我们已知,在 JS 里,我们可以这样用:
new Vue({
el: '#app',
data: {
count: 0
}
});
为什么这里的 data 可以直接是一个对象:因为根实例通常只有一个,不存在复用的问题。在项目中,我们看一下:
<template>
<MyComponent />
<MyComponent />
</template>
当我们定义一个 Vue 组件时,Vue 会将组件的选项(如 data、methods 等)存储起来,作为组件“模版”。当在使用 <MyComponent />
时,Vue 会根据这个模板创建组件实例。也就是说,如果 data 是一个对象:
const MyComponent = {
data: {
count: 0
}
};
// Vue 内部创建组件实例时:
const instance1 = Object.create(MyComponent);
const instance2 = Object.create(MyComponent);
// 两个实例的 `data` 都指向同一个对象:
instance1.data === instance2.data; // true
但如果 data 是一个函数:
const MyComponent = {
data() {
return {
count: 0
};
}
};
// Vue 内部创建组件实例时:
const instance1 = Object.create(MyComponent);
instance1.data = MyComponent.data(); // 调用 data 函数,生成新的对象
const instance2 = Object.create(MyComponent);
instance2.data = MyComponent.data(); // 再次调用 data 函数,生成新的对象
// 两个实例的 `data` 是独立的:
instance1.data === instance2.data; // false
此时,每次调用函数都会返回一个新的对象,从而避免了引用共享的问题。