写在前面

本篇是「源码级答复」大厂高频Vue面试系列的第二篇,本篇也是选择了面试中经常会问到的一些经典面试题,从源码视点去分析。

想从第一篇开端看的,地址在这儿

话不多说,干就完了!

简述 Vue 中 diff 算法原理

diff 简介

diff 算法是一种经过同层的树节点进行比较的高效算法,避免了对树进行逐层搜索遍历,所以时刻复杂度只有 O(n)diff 算法的在许多场景下都有运用,例, 2 3 5 H * u 0 R如在 Vue 虚拟 dom 烘托成实在 dom 的新旧 VNode 节点比较更新时,就用到了该算法。diff 算法有两个比较显著的6 % 6 n C D特色:

  • 比较只会3 x ; r在同层级进行, 不会跨层级比较。
  • 在 diff 比较的过程中,循环从两头向中心收拢。

upG $ # : bdateChildren

咱们知道,在对 model 进行操作时,会触发对应 Dep 中的 Watcher 目标。Watcher 目标会调用对应的 update 来修改视图。最终是将新产生/ | m E *VNodeP ( 5 |点与老 VNode 进行一个 patch 的过程,比对得出「差异」,最终将这些0 C E I「差异」更新到视图上。

diff 算法又是patch 的中心内容,咱们用 diff 算法能够比对出两颗树的「差异」,假设咱们现在有如下两颗树,它们分别是新老 VNode 节点,这时分到了 patch 的过程,咱们需求将他们进行比对:
「源码级回答」大厂高频Vue面试题(中)

diff 算法是经过同层的树节点进行比较而非对树进行逐层搜索遍历的方法,所以时刻复杂度只有 O(n O k a I Sn),是一种适当高效的4 N z G y N _ B算法,如下图。
「源码级回答」大厂高频Vue面试题(中)

图中的相同色彩的方块中的节点会进行比对,比对得到「差异」后将这些「差异」更新到视图上。因为只进行同层级的比对,所以非常高效。

a ` B 0 Q X _ `

patch 的过 m N 3 G ?程比较复杂,咱们这儿主要说一下「oldChch 都存在且不相一起,运用 updateChildren 函数来更新子节点」这种状况。

来看下updateChildren函数

为了便利了解,我在对应代码中添加了注释

funcu # 0 Htion updateChildreO { o on(
parentElm,
oldCh,
newChJ g ~ } X o y,
insertedVnodeQueue,
removek S w : QOnly
) {
let oldStartIdx = 0; // oldVnode开端下标
let newStartIdx = 0; // newVnode开端下标
let oldEndIdx = oldCh.length - 1; // oldVnode完毕下标
let newEndIdx = newCh.length - 1s C { T ~; // nee U | } Q ; h HwVnode完毕下标
let o] : L k XldStartVnode = oldCh[0]; // oldVno# @ 0 { 4  $de开端节点
let newStartVnode = newCh[0]; // newVnode开端节点
let oldEndVnode = oldChA . Q - = z 3 G h[oldEndIdx]; // o; E } C }ldVnode完5 r _ ] z I ) e毕节点
let newEndVnode = newCh[newEndIdx]; // newVnode完毕节点

let oldKeyToIdx, idxInOld, vnv D 2 t 9odeToMove, refElm;

// ...
}

首先界说了 oldStartIdxnewStartIdxoldEndIdx 以及 newEndIdx 分别是新老两个 VNode 的开端/完毕的下标,一起 oldStartVnodenew, $ = a [ W s 5StartVnodeoldEndVnode 以及 newEndVnode 分别指向这几个u } _ , :索引对应的 VNode 节点。
「源码级回答」大厂高频Vue面试题(中)
接下来是7 # r m j &一个 while 循环,在这过程中,oldStartIdxnewStaA x * 3rtIdxoldEndIdx 以及 newEndIdx 会逐步向中心挨近[ , @ @ X x Y m 5

while (oldStartIdx &A d ] 7 b D }lt;= oldEndIdx && newStartIdx3 7 _ T e r B <= newEndIdx)Q ~ W H L b {
// ...
}

「源码级回答」大厂高频Vue面试题(中)
首先当 oldStartVnode 或者 oldEndVnod6 0 %e 不存在的时分,oldStartIdxoldEndIdx 继续向中心挨– ) 4 _近,并更新对应的 oldStartVnodeoldEndVnode 的指向。

if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx];
} else if (isUndef(oldEn7 u adVnode)) {
oldEndVnode = oldCh[--oldEndIdx];
}

接下来这一块,是将 oldStartIdxnewStaU N ^rtIdxol, r z v u } | ddEndIdx 以及 newEndIdx 两两比对的过程,一共会呈( l 3 e b现 2*2=4 种状况。

首先是 oldStartVnodenewStartVnode 契合 sameVnode 时,阐明老 VNode 节点的头部与新 VNodg m l } # ` W z xe 节点的头部N 4 – F是相同的 VNode 节点,直接进行 patchVnode,一起 oldStartIdxnewStartIdx 向后移动一位。

if (sameVnode(oldStartVnode, newSh X * . WtaB y y E G YrtVnode)) {
// 首先是 oldStaO C 6 : 7 %rtVnode 与 newStartVnode 契合 sameVnode 时,
// 阐明老 VNode 节点的头部与新 VNode 节点的头部是相同的 VNode 节点,直接进行 patch1 Z ,Vno1 } ; ,  jde,一起 oldStartIdx 与 newStartIdx 向后移动一位
patchVnode(
oldStartVnode,
newStartVnode,
insertedVnodeQueue,
newCh,
newStartIdx
);
oldStartVnod_ o H 9 ] ( $ d We = oldCh[++olz E 4 [ k $ H } pdStartIdx];
newA 4 z } f 6StartVnode = newt _ N 5Ch[++newStartIdx];
}
「源码级回答」大厂高频Vue面试题(中)

其次是Q m } s R D K Z o d % } s 6ldEndVnodenewEndVnoX | U = W - 7de 契合 sameVnode,也便是两A F BVNode 的完毕是相同的 VNode` T S },相同进行 patchVnode 操作并将 oldEnH * c @ @dVnodenewEndVnode 向前移动一位。

if (sameVnode(oldEndVnode,m ^ r | ?  d 3 newEndVnode)) {
// 其次是 oldEndVnode 与 newEndVnode 契合 sameVnode,
// 也便是两个 VNode 的完毕是相同 z N } Z 8 @ z的 VNode,相同进行 pA y a X  ^atchVnode 操作并将 oldEndVnode 与 newEnh w [ Q pdVnode_ f U 向前移动一位。
patchVnode(
oldEndVnode,
newEndVnode,
insertedVnodeQueue= m r W X , i n [,
newCh,
newEndIdx
);
oldEndVnode = oldCh[--oldEe ( K x O mndIdx];
newEndVnode = newCh[--newEnd r T KIdx];
}
「源码级回答」大厂高频Vue面试题(中)

接下来是oldStartVnodenewEndVnodx O % l ] 1 R X ue 契合 sameVnode 的时分,也便是老 VNode 节点的头部与新 VNode 节点的尾部是同R d 5一节点的时分,将 oldStartVnode.elm 这个节点直接移动到 oldEndVnode.elm 这个节点的后 S N边即可。然后 oldStartIdx 向后移动一位,newEndIdx 向前移动一位。

if (sameVnode(oldStartVnode, newEndVnode)) {
// oldStartVnode 与 newEndVnode 契合 sameVnode 的时分,
// 也便是老 VNode 节点的头部与新 VNz 9 V 8 Yo* ; p + ^ !de 节点的尾部是同一节点的时分,
// 将 oldStartVnode.elm 这个节点直接移动到 oldEndVnodeZ ^ a ?.elm 这个节点的后边即} y h n |可。然后 oldStartIdx 向后移动一位,nE K FewEnH ! =dIdx 向前移动一位。
patchVnode(
oldSd f { [ ? vtartVnode,
newEndVnode,
insertedVnodeQueue,
newCh,
newEndIdx
);
canMove &&
nodeOps.insertBefore(
parentElm,
oldStartVnode.elm,
nodeOps.nextSibling(oldEndVA n ] I f o tnode.elm)
);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdxz # J [ V];
}
「源码级回答」大厂高频Vue面试题(中)

最后是oll 1 a D f f / i )dEndVnodenewStartVnode 契合 sameVnode 时,也便是老 VNode 节点的尾部与新 VNode 节点的头部是同一节点的时分m p ! v @ M,将 oldEndVnode.elm 刺进g d i ; $ / X ol@ [ ydStartVnode.k L [ q e # ~ [elm 前面。相同的,6 j t c = ^ t q 7oldEndIdx 向前移动一位,newStartIdx 向后移动一位。

if (sameVnode(oldEndVnode, newStartVnode)) {
// old, D P Q 3 L ,EndVnode 与 newStartVw 1 : Cnode 契合 sameVnode 时,
// 也便是老 VNode 节点的尾部与新 VNode 节点的头部是同一节点的时分,
// 将 oldEndVnode.elm 刺进到 oldStartVnode.elm 前面。相同的,oldEndIdx 向前移动一位,newStartIdx 向后移动一位。
patchVnode(
oldEndVnode,
newStartVnode,
insertedVnodeQueue,
newCh,
newSta) j [ o S :rtIdx
);
canMove &&
nodeOps.insertBefore(parentElm, oldEndVnode.elm, o7 J TldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];q x q
nZ : s C % [ ? tewStartVnode = newCh[? E i n V++newStartIdx];
}
「源码级回答」大厂高频Vue面试题(中)

假如都不满意以上四种景象,那阐明没有相同的节点能够复用。所以则经过查找事先建立好的以T B S ( t b .旧的 VNodeke= Y 1 My 值,对应 indexvalue 值的哈希表。

从这个哈希表中找到与 newStartVnode 一致 key 的旧的 VNode 节点,假如两者满意 sameVnode 的条件,在进行 patchVnode 的一起会将这个实在 dom 移动到 oldStartVnode 对应的实在 dom 的前面;假如没有找到,则阐F H E Q %明当时索引下的新的 VNode5 % Z 节点在旧的 VNode 队列; E Z ^ s Y y C P中不存在,无法进行节点的复用,那么就只能调用 createElm 创立一个新的 dom 节点放到当时 newStartIdx 的位置。

最后还有一段代码:

// while 循环完毕
if (oldStartIdx > oldEndIdx) {
// 假如 oldStartIdx > oldEndIdx,阐明老节点比对完了,可是新节点还有多的,需求将新节点刺进到实在 DO$ | } P  e R M 中去a h t M a Y } g Y,调用 addVnodes 将这些节点刺进即( N * c s 3可。
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;
adde z  W 3 s & 1Vnodes(
parentElm,
refElm,
newCh,
n@ B 8 o Q ) x m UewStartIdx,
newEndIdx,
insertedVnodeQueue
);
} else if (newStartIdx > newEndIdx) {
// 假如满意 nX = W % iewStartIdx >x / | newEndIdx 条件,阐明新节点比对完了,老节点还有多,将这些无用的老节点经过 removeVnodesq 1 t 批量删除即可。
removeVnodes(oldCh, oldStartIdx, oldEndIdx);
}

while 循环完毕今后,假如 oldStartIdx > oldEndIdx,阐明老节点I % B F 5 N比对完了,可是新节点还有多的,需求将9 C I k { *新节p C J $ R 点刺进到实在 DOM 中去,调用 addVnodes 将这些节点刺进即可。

假如满5 / f S 7 }newSS ] ) q 4 c u *tartIdx > newEndIdx 条件J t ~ n ` ` 4 j,阐明新节点E & k ~比对# ~ 8 m R 2 完了,老节点还有多,6 p I将这些无用的老节点经过 removeVnodes 批量删除即可。

Vue 组件中的 data 为什么是8 ] I N 9 e个函数?

其实这个问题还有下半句:而 new Vue 实例里,data 能够直接是一个目标?

先来看下平时在组件和new Vue时运用data的场景:

// 组件
data() {
return {
msg: "hello 森林",
}
}

// new Vue
new Vue({
data: {
msg: 'hello jack-cool'
},
el: '#app',
router,
template: '<App/>',
cp  r ) j W p ]omponents: {
App
}
})

咱们知道,Vue组件其实便是一个Vue实例。

JSX . 8 (的实例是经过结构函数来创立的,每个结构函数能够new出许多个实例,那么每个实例都会承继原型上的办法或特点。

Vuedata数据其实是Vue原型上的特点,数据存在于内存傍边

Vue为了确保每个实例上的data数据的独立性,规定了有必要运用函数,而不是目标。

因为运用目标的话,每个实例(组件)上运用a P wdata数据是相互影响的,这当然就不是咱们想要的了。目标是对于内存地址的引证,直接界说个目标的话组件之间都会运用这个目标,这样会造成组件之间数据相互影响。

咱们来看个示例:

// 创立一个简Z T L O B [略的构建函数
var MyComponentE I t ? = function() {
// ...
}
// 原型链目标上设置data数据,data设为Object
MyH 7 0Component.protot= 0 T W U .ype.data = {
name: '森林x { X Y x J Y',
age: 20,
}
// 创立两个实例:春娇,志明
var chunjiao = new MyComponent/ E I y B()
var zhiming = new MyComponent()
// 默认状态下春娇和志明的年纪相同
console.log(chunjiao.data.age === zhiming.data.age) // true
// 改动春娇的年+ } T x C 4 ! J A
chunjiao.data.age = 25;
// 打印志明的年纪,发现因为改动了春娇的年纪,成果造成志明的年纪也变了
console.log(chunjiao.data.age)// 25
console.log(zhimi^ / W ` t 2 ] cng.data.age) // 25

运用函数后,运用( ) : S的是data()函数,data()1 j b J L函数中的this指向的是当时实例自身,就不会相互影响了。

总结一下,G R i l V {便是:

组件中的data是一个函数的原因在于:同一个组件被复用多次,会创立多个实例。这些实例用的是同一个结构函数,假如 data 是一个W 6 j * h i W H目标的话。那么一切组件都同享了同一个目标。为了确保组件的数据独立性要@ H + 5 m Z求每个组件有必要z M * w d x t经过 data 函数返回一个目标作为组件的状态。

new Vue 的实例,是不会被复用的,{ Q ) W因而不存在引证目标的问题。

谈谈你对 Vue 生命周期的了解?

答复这个问题,咱们先要概括的答复一{ 2 ?Vue生命周期是什么:

Vue 实例有一个完好的生命周期,也– E C q @ u q ` N便是从开端 n C 创立、初S S B ! s始化数据、编译模版、挂载 Dom -> 烘托、更新 -> 烘? 8 M i u托、卸载等一系列过程– P f c,咱们称这是 Vue 的生命周期。

下面的表格展@ x K K J 3现了每个生命周期分别在什么时分被调用:

生命周期 描述
beforeCreate 在实例初始化之后,数据观测(data observer) 之前被调用。
created5 i ` E | ` { & 实例现已创立完成之后被调用。在这一步,实例已完成以下的配置:数据观测(data observer),特点和办法的运算, watch/event 事情回调。L M & c #t 9 } # T C W实在 dom 还没有生成,$el 还不可用
beforeMount 在挂载开端之前被调用,相关的 r] + ? ` cender# 6 F Z 函数初次被调用。
mounted el 被新创立的 vm.$el 替换,并挂载到实例上去之后调/ z T 0 V用该钩子q } G 5
beforeUpdate 数据更新时调用,发生在虚拟 DOM 从头烘托和打补丁之前。
updatep z % P E M Y yd 因为数据$ e 8 N 5 A更改导B f : U R致的虚拟 DOM 从头烘托和d R X打补丁,在这之后5 9 f G 9会调用该钩子~ A o x
activited keep-alive 专属,组件被激活时调用
deactivated keep-alive 专属,组件被销毁时调用
beforeDestory 实例销毁之前调用。在这一# | f 2步,实例仍然彻底可用。
destoryed Vue 实例销毁后调用。

这儿放上官网的生命周期流程图:
「源码级回答」大厂高频Vue面试题(中)

我这儿用一张图梳理了源码中关于周期的全流程(长图预警):
「源码级回答」大厂高频Vue面试题(中)

  • Vue本质上是一个结构函数,界说在src/core/instance/index.js中:
// src/core/instance/index.js
function Vue(options) {
if (process.env.NODE_ENV !== "production" && !(this insM ! y ) w G Itanceof V) L P ; Cue2  K B H)) {
warn(Q V s G X [ _ f"Vue is a constructor and should be called with the `nu ) e y = 8 f @ei ? # ^ d w q ? fw` keyword");
}
this._init(options);
}
  • 结构函数的中Q S [ G l ( P ` t心是调用了_ p J !init办法,_init界说在src/core/instance/init.js中:
// src/core/instance/init.js
Vue.prototype._init = function(options?: Object) {
conJ F F ist vm: Component = this;
// a uid
vm._uidu S R $ c = uid++;
[1];
let startTag, endTag;
/* istanbul ignore if ** F L ) I &/
if (process.env.NODE_ENV !== "production" && config.performance && mark) {
startTag = `v} ; f xue-perf-start:${vm._uid}`;
endTag = `! W 9 *vue-perf-e[ C ;nd:${vm._uid}`;
mark(stau c I / O J ] ( ?rtTag);
}

// a fli 8 ; JagF ; / ! L x I d to avoid this being observed
vm._ik 5 V 7 & r 2 usVue = true;
// merge options
i] f a n m U rf (options && options._isComponent) {
// optimize internal component instantiation
// since dynaj * * S { 2 e X ,mic options merging is pretty slow, and none of the
// internal component options needs special treatment.
initInternalComponent(vm, options);
} else {
vm.$optioR 1 O + z ; L hns = mergeOptions(
resolveConstructorOptionsu l O 2(vm.constructor),
options || {},
vm
);
}
/* istanbul ignore else */
if (process.env.NODE_ENV !== "production") {
initProxy(vm);
} else {
vm._renderProxy = vm;
}
// expose real self
vm._self = vm;
initLifecycle(vm);
initEvents(vm);
initRender(vm);
callHook(vm, "beforeCreate");
initInjections(vm); // resolve injections before data/props
initState(vm);
initProvide(vm); // resolve provide after data/props
callHook(vm, "created")[2Z e T g _ p X];
/* istanbul ignore if */
if (procesa ? r , , Z Qs.env.NODE_ENV !== "productiW ~ b N mon" && config.performance && mark) {
vm._name = formatT E N AComponentName(vm, false);
mark(endTag);
measure(`vue ${vm._name} init`, startTag, endTag);
}

if (vm.$options.el) {
vm.$mount(vm.$options.el);
}
};

_init内调用了许多初始化函数,从函数名称能够e A K f A &看出分别是履行初始化生命周期(initLifecycle)、初始化事情中心(initEvents)、初始化烘托(initRender)、履行beforeCreate钩子(callHook(vm, 'beforeCreate'))、解析 inject(initInjections)、初j J ] n a | ^ t始化k s 7状态(i2 l c p Y v E P 2nitState)、解析 prh W m $ J jovide(initProvS Q ^ x E s .ide)、履行created钩子(callHook(vm, 'created'))。

  • _init函数的最后有判断假如有el就履行$mount办法。界说在# F nsrc/platforms/` [ R y r j ? Eweb/entry-runtime-wit2 U 3 }h-compiler.js中:
/4 S r ; 9 + C/ src+ n X - S 9/platforms/web/entry-runtime-with-ce T a }ompiler.js

// ...

const mount1 8 = & J n K = Vue.prototype.$mount;
Vue.prototype.$+ G j 7 4 H x Ymount = function(
el?: string | Element,
hydrating?: boolean
): ComponW 1 &ent {
el = el && query(el);

/* istanbul ignore if */
if (el === document.body || el === document.documen3 / 8 6 ! = ptElement) {
process.env.NODE_ENV !== "pr} e o ! ! Q w _oduction" &&
warn(
`Do not mount Vue to <html> or <body> - mount to norma` j v 0 l hl elements instead.`
);
return this;
}

const options = this.$options;
// resolve template/el and convert to res j F 7 Tnder functn ^ @ Q Xion
if (!options.render) {
let template = options.template;
if (template: & ^ j S  .) {
if (typeofw + . Y ? h template === "st. Z } : cring") {
// ...
} else if (template.nodeType) {
template = template.innerHTML;
} else {
// ...
return this;a m Q ( D h 9 1
}
} else if (el) {
template = getOuterHTML(el);
}
if (template) {
// ...
}
}
return mount.call(this, el, hydrating);
};
// ...

export defau# $ L N U t L * rlt Vue;

这儿面主要做了两件事:

1、f $ r ] 6 s . Z l 重写了Vue函数的原0 D J = A型上的$mount函数

2、 判断是否有模板,而且将模板转化成render函数

最后调用了runtimez N T hmount办法,用来挂载组件,也便是mountComponent办法。

  • mountComponent内首先调用了beforeMount办法,然后在初次烘托和更新后会履行vm._update(vm._render(), hydrating)办法e | – n e ,。最后烘托完成后调用* s : /mounted钩子。
  • be) 9 yforeUpdateup$ u R E 5dated钩子是在页面发生变化,触发更新后,被调用的,# P . h ] % e对应是在src/core/obser^ d lver/scheduler.js@ V S Q VflushSchedulerQueue函数中。
  • beforeDestroydestroyed 都在履行 $destroy 函数时被调用。$destroy 函数是界说在 Vue.prototype 上的一个办法,对应在 src/core/insts F M - .ance/lifecycle.js 文件中:
// src/core/instance/lifecycle.js

Vz R T r ~ ^ k Wue.prototype.$destroy = function() {
const vm: Component = this;
if (vm._isBeingDestroyed) {
return;
}
cal5 3 ]lHook(vm, "beforeDestroy")B O b;
vm._isBeingDestroyed = true;
// remove selC P [ 2f from parent
consti D c l r W U @ parent = vm.$parent;
if (paro I # [ 7 . n 3 Bent && !parent._isBeinl 7 R _  )gDestroyed && !vm.$options.abstract) {
rm L C j q 7 O ]emove(parent.$children, vm);
}
// teardown watchers
if (vm._watcher) {
vm._watcher.teardown();` 2 ( v | M t z L
}
let i = vm._watchers.length;
while (i--) {
vm._watcM r , B r f m jhers[i].teardown();
}
// remove reZ b _ 0 t A S ] rference from data ob
// frozen object mb g xay not have observer.
if (v! p : ;m._data.__ob__) {
vm._dae * % / Pta.__ob__.vmCount--;
}
// call the last hook.{ G !..
vm._is: x } D ADestroyed = true;
// invoke des % c x L / 5 1 dtroy hooks on current rend` 6 :ered tree
vm.__patch__(vm._vnode, null);
// fire destroyed hook
callHook(vm, "destroyedA _ s 2 D o");
//3 V 1 H K tur| / 6 n Rn off all instance listeners* Z E : /.
vm.$off();
// remove __vue__ reference
if (vmo p / %.$el) {
vm.$el.__vue__ = null;
}
// release circular refery 4 7 qence (#6759)
if (vm.$vnode) {
vV N 0 P H I $ :m.$vnode.parent = nu~ v ) X ull;
}
};

Vue 中常见的Q 6 U 7功能优化方法

编码优化

  • 尽量不要将一切的数据都放在data中,Z 2 T qdata中的数据都会增加gettersetterc b a – +会搜集对应的 watcher
  • vuev-for 时给每项元素绑定事情尽量用事情署理
  • 拆分组件( 进步复用H | D P m S i 6性、增加代码的可维护性,减少不必要的烘托 )
  • v-if 当值为false时内部指令不会履行,具有阻断功能,许多状况下运用v-if替代v-show
  • 合理运用路由懒加z . ! – L 7 ! I T载、异步组件
  • Objecl U L . ?t.freeze 冻住数据

用户体验

  • app-skeleton 骨架屏
  • pwa serviceworker

加载功能优化

  • 第三方模块按需导入 ( babel-plugin-component )
  • 滚动到可视区域动态加载 ( https://tangbc.github.io/vue-virtual-scrollz S Q U q-list )
  • 图片懒c o z ; $ N ]加载 (https://github.com/hilongjw/vue-lazyload.git)

SEO 优化

  • 预烘托插件 prerender-spa-z : .plugX h ~in
  • 服务端烘托 ssr

打包优化

  • 运用 cdn 的方法加载第三方模块
  • 多线程} 3 j P打包 happypackparallel-webpack
  • 控制包文件大小(tree shaking / spF q N q litQ ; ~ W jChunksPluginN w k
  • 运用x U U % &DllPlugin进步打包速度

缓存/压缩

  • 客户端缓存/服务端缓存
  • 服务端gzip压缩
「源码级回答」大厂高频Vue面试题(中)