轻松搞懂前端面试题系列(vue篇一)
众所周知,现在的面试越来越卷,面试造火箭,为了跟上形势,鸽了这么久,我也来学点新东西吧。
一、说说vue中的diff算法
讲一个东西之前,首先得先了解它是做什么的,我们知道,渲染真实DOM的开销是很大的,比如有时候我们修改了某个数据,如果直接渲染到真实dom上会引起整个dom树的重绘和重排,有没有可能我们只更新我们修改的那一小块dom而不要更新整个dom呢?diff算法就是用来实现这个目的,它其实是一种优化手段,将前后两个模块进行差异化对比,从而提高渲染效率,修补差异的过程叫做patch。
比较方式
diff整体策略为:深度优先,同层比较,其有两个特点:
- 比较只会在同层级进行, 不会跨层级比较

所以Diff算法是:深度优先算法。 时间复杂度:O(n)。 - 在
diff比较的过程中,循环从两边向中间比较

对比流程
当数据改变时,会触发setter,并且通过Dep.notify去通知所有订阅者Watcher,订阅者们就会调用patch方法,给真实DOM打补丁,更新相应的视图。

由上图,继续分析对比流程中的几个关键节点:
patch方法
这个方法作用就是,对比当前同层的虚拟节点是否为同一种类型的标签:
- 是:继续执行
patchVnode方法进行深层比对 - 否:没必要比对了,直接整个节点替换成新虚拟节点
来看看patch的核心原理代码
function patch(oldVnode, newVnode) {
// 比较是否为一个类型的节点
if (sameVnode(oldVnode, newVnode)) {
// 是:继续进行深层比较
patchVnode(oldVnode, newVnode)
} else {
// 否
const oldEl = oldVnode.el // 旧虚拟节点的真实DOM节点
const parentEle = api.parentNode(oldEl) // 获取父节点
createEle(newVnode) // 创建新虚拟节点对应的真实DOM节点
if (parentEle !== null) {
api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) // 将新元素添加进父元素
api.removeChild(parentEle, oldVnode.el) // 移除以前的旧元素节点
// 设置null,释放内存
oldVnode = null
}
}
return newVnode
}
sameVnode方法
patch关键的一步就是sameVnode方法判断是否为同一类型节点,咱们来看看sameVnode方法的核心原理代码,看同一类型节点的标准是什么
function sameVnode(oldVnode, newVnode) {
return (
oldVnode.key === newVnode.key && // key值是否一样
oldVnode.tagName === newVnode.tagName && // 标签名是否一样
oldVnode.isComment === newVnode.isComment && // 是否都为注释节点
isDef(oldVnode.data) === isDef(newVnode.data) && // 是否都定义了data
sameInputType(oldVnode, newVnode) // 当标签为input时,type必须是否相同
)
}
patchVnode方法
这个函数做了以下事情:
- 找到对应的真实
DOM,称为el - 判断
newVnode和oldVnode是否指向同一个对象,如果是,那么直接return - 如果他们都是文本节点并且不相等,那么将
el的文本节点设置为newVnode的文本节点。 - 如果
oldVnode有子节点而newVnode没有,则删除el的子节点 - 如果
oldVnode没有子节点而newVnode有,则将newVnode的子节点真实化之后添加到el - 如果两者都有子节点,则执行updateChildren函数比较子节点
function patchVnode(oldVnode, newVnode) {
const el = newVnode.el = oldVnode.el // 获取真实DOM对象
// 获取新旧虚拟节点的子节点数组
const oldCh = oldVnode.children, newCh = newVnode.children
// 如果新旧虚拟节点是同一个对象,则终止
if (oldVnode === newVnode) return
// 如果新旧虚拟节点是文本节点,且文本不一样
if (oldVnode.text !== null && newVnode.text !== null && oldVnode.text !== newVnode.text) {
// 则直接将真实DOM中文本更新为新虚拟节点的文本
api.setTextContent(el, newVnode.text)
} else {
// 否则
if (oldCh && newCh && oldCh !== newCh) {
// 新旧虚拟节点都有子节点,且子节点不一样
// 对比子节点,并更新
updateChildren(el, oldCh, newCh)
} else if (newCh) {
// 新虚拟节点有子节点,旧虚拟节点没有
// 创建新虚拟节点的子节点,并更新到真实DOM上去
createEle(newVnode)
} else if (oldCh) {
// 旧虚拟节点有子节点,新虚拟节点没有
//直接删除真实DOM里对应的子节点
api.removeChild(el)
}
}
}
updateChildren方法
这个方法的代码很多,主要是做了以下事情
- 设置新旧
VNode的头尾指针 - 新旧头尾指针进行比较,循环向中间靠拢,根据情况调用
patchVnode进行patch重复流程、调用createElem创建一个新节点,从哈希表寻找key一致的VNode节点再分情况操作
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
let oldStartIdx = 0 // 旧头索引
let newStartIdx = 0 // 新头索引
let oldEndIdx = oldCh.length - 1 // 旧尾索引
let newEndIdx = newCh.length - 1 // 新尾索引
let oldStartVnode = oldCh[0] // oldVnode的第一个child
let oldEndVnode = oldCh[oldEndIdx] // oldVnode的最后一个child
let newStartVnode = newCh[0] // newVnode的第一个child
let newEndVnode = newCh[newEndIdx] // newVnode的最后一个child
let oldKeyToIdx, idxInOld, vnodeToMove, refElm
// removeOnly is a special flag used only by <transition-group>
// to ensure removed elements stay in correct relative positions
// during leaving transitions
const canMove = !removeOnly
// 如果oldStartVnode和oldEndVnode重合,并且新的也都重合了,证明diff完了,循环结束
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 如果oldVnode的第一个child不存在
if (isUndef(oldStartVnode)) {
// oldStart索引右移
oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
// 如果oldVnode的最后一个child不存在
} else if (isUndef(oldEndVnode)) {
// oldEnd索引左移
oldEndVnode = oldCh[--oldEndIdx]
// oldStartVnode和newStartVnode是同一个节点
} else if (sameVnode(oldStartVnode, newStartVnode)) {
// patch oldStartVnode和newStartVnode, 索引左移,继续循环
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
// oldEndVnode和newEndVnode是同一个节点
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// patch oldEndVnode和newEndVnode,索引右移,继续循环
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
// oldStartVnode和newEndVnode是同一个节点
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
// patch oldStartVnode和newEndVnode
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
// 如果removeOnly是false,则将oldStartVnode.eml移动到oldEndVnode.elm之后
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
// oldStart索引右移,newEnd索引左移
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
// 如果oldEndVnode和newStartVnode是同一个节点
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
// patch oldEndVnode和newStartVnode
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
// 如果removeOnly是false,则将oldEndVnode.elm移动到oldStartVnode.elm之前
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
// oldEnd索引左移,newStart索引右移
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
// 如果都不匹配
} else {
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
// 尝试在oldChildren中寻找和newStartVnode的具有相同的key的Vnode
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
// 如果未找到,说明newStartVnode是一个新的节点
if (isUndef(idxInOld)) { // New element
// 创建一个新Vnode
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
// 如果找到了和newStartVnodej具有相同的key的Vnode,叫vnodeToMove
} else {
vnodeToMove = oldCh[idxInOld]
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && !vnodeToMove) {
warn(
'It seems there are duplicate keys that is causing an update error. ' +
'Make sure each v-for item has a unique key.'
)
}
// 比较两个具有相同的key的新节点是否是同一个节点
//不设key,newCh和oldCh只会进行头尾两端的相互比较,设key后,除了头尾两端的比较外,还会从用key生成的对象oldKeyToIdx中查找匹配的节点,所以为节点设置key可以更高效的利用dom。
if (sameVnode(vnodeToMove, newStartVnode)) {
// patch vnodeToMove和newStartVnode
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
// 清除
oldCh[idxInOld] = undefined
// 如果removeOnly是false,则将找到的和newStartVnodej具有相同的key的Vnode,叫vnodeToMove.elm
// 移动到oldStartVnode.elm之前
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
// 如果key相同,但是节点不相同,则创建一个新的节点
} else {
// same key but different element. treat as new element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
}
}
// 右移
newStartVnode = newCh[++newStartIdx]
}
}
while循环主要处理了以下五种情景:
- 当新老
VNode节点的start相同时,直接patchVnode,同时新老VNode节点的开始索引都加 1 - 当新老
VNode节点的end相同时,同样直接patchVnode,同时新老VNode节点的结束索引都减 1 - 当老
VNode节点的start和新VNode节点的end相同时,这时候在patchVnode后,还需要将当前真实dom节点移动到oldEndVnode的后面,同时老VNode节点开始索引加 1,新VNode节点的结束索引减 1 - 当老
VNode节点的end和新VNode节点的start相同时,这时候在patchVnode后,还需要将当前真实dom节点移动到oldStartVnode的前面,同时老VNode节点结束索引减 1,新VNode节点的开始索引加 1 - 如果都不满足以上四种情形,那说明没有相同的节点可以复用,则会分为以下两种情况:
- 从旧的
VNode为key值,对应index序列为value值的哈希表中找到与newStartVnode一致key的旧的VNode节点,再进行patchVnode,同时将这个真实dom移动到oldStartVnode对应的真实dom的前面 - 调用
createElm创建一个新的dom节点放到当前newStartIdx的位置
为了更好的理解,下面举个vue通过diff算法更新的例子:
新旧VNode节点如下图所示:

- 第一次循环后,发现旧节点D与新节点D相同,直接复用旧节点D作为
diff后的第一个真实节点,同时旧节点endIndex移动到C,新节点的startIndex移动到了 C

- 第二次循环后,同样是旧节点的末尾和新节点的开头(都是 C)相同,同理,
diff后创建了 C 的真实节点插入到第一次创建的 B 节点后面。同时旧节点的endIndex移动到了 B,新节点的startIndex移动到了 E

- 第三次循环中,发现E没有找到,这时候只能直接创建新的真实节点 E,插入到第二次创建的 C 节点之后。同时新节点的
startIndex移动到了 A。旧节点的startIndex和endIndex都保持不动

- 第四次循环中,发现了新旧节点的开头(都是 A)相同,于是 diff 后创建了 A 的真实节点,插入到前一次创建的 E 节点后面。同时旧节点的
startIndex移动到了 B,新节点的startIndex移动到了 B

- 第五次循环中,情形同第四次循环一样,因此
diff后创建了 B 真实节点 插入到前一次创建的 A 节点后面。同时旧节点的startIndex移动到了 C,新节点的startIndex移动到了 F

- 新节点的
startIndex已经大于endIndex了,需要创建newStartIdx和newEndIdx之间的所有节点,也就是节点F,直接创建 F 节点对应的真实节点放到 B 节点后面

为什么不建议用index作为key?
由前面我们已经明白了,在进行子节点的diff算法过程中,会复用相同的节点,而数组的顺序怎么颠倒,index都是0, 1, 2这样排列,进行旧首节点和新首节点的sameNode对比时,复用了错误的旧子节点,而原本的节点可能会被当成新增的节点,导致key值并没有起到任何作用,也就无法提高渲染效率。
二、Vue 模板是如何编译的
在日常开发中,.vue 这种单文件组件非常方便,我们也知道,Vue 底层是通过虚拟 DOM 来进行渲染的,那么 .vue 文件的模板到底是怎么转换成虚拟 DOM 的呢?
我们知道 <template></template> 这个是模板,不是真实的 HTML,浏览器是不认识模板的,所以我们需要把它编译成浏览器认识的原生的 HTML
这一块的主要流程就是
- 提取出模板中的原生
HTML和非原生HTML,比如绑定的属性、事件、指令等等 - 经过一些处理生成
render函数 render函数再将模板内容生成对应的vnode- 再经过
patch过程( Diff )得到要渲染到视图中的vnode - 最后根据
vnode创建真实的DOM节点,也就是原生HTML插入到视图中,完成渲染
上面的 1、2、3 条就是模板编译的过程了,我们先来了解一下render函数
render 函数
以一个vue实例为例,有el,有template,有render,有$mount,但是渲染只能是渲染一次,那么,这几个东西里谁有权力去渲染这一次呢,或者说,谁的权力最大呢?
// 此代码只是演示
let vue = new Vue({
el: '#app',
data() {
return {
a: 1,
b: [1]
}
},
render(h) {
return h('div', { id: 'hhh' }, 'hello')
},
template: `<div id='hhh' style="aa:1;bb:2"><a>{{xxx}}{{ccc}}</a></div>`
}).$mount('#app')
console.log(vue)
官网是这样描述的

通过上图,可以总结为以下几点:
- 渲染到哪个根节点上:判断有无
el属性,有的话直接获取el根节点,没有的话调用$mount去获取根节点 - 渲染哪个模板:
- 有
render:这时候优先执行render函数,render优先级 >template - 无
render:- 有
template:拿template去解析成render函数的所需的格式,并使用调用render函数渲染 - 无
template:拿el根节点的outerHTML去解析成render函数的所需的格式,并使用调用render函数渲染
- 有
- 渲染的方式:无论什么情况,最后都统一是要使用
render函数渲染
那么是如何编译,最终生成 render 函数的呢?
编译过程
我们看看 Vue 完整版的入口文件(src/platforms/web/entry-runtime-with-compiler.js)
// 省略了部分代码,只保留了关键部分
import { compileToFunctions } from './compiler/index'
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (el) {
const options = this.$options
// 如果没有 render 方法,则进行 template 编译
if (!options.render) {
let template = options.template
if (template) {
// 调用 compileToFunctions,编译 template,得到 render 方法
const { render, staticRenderFns } = compileToFunctions(template, {
shouldDecodeNewlines,
shouldDecodeNewlinesForHref,
delimiters: options.delimiters,
comments: options.comments
}, this)
// 这里的 render 方法就是生成生成虚拟 DOM 的方法
options.render = render
}
}
return mount.call(this, el, hydrating)
}
可以看到,render函数是compileToFunctions导出的,再看看 compileToFunctions 方法从何而来。
import { baseOptions } from './options'
import { createCompiler } from 'compiler/index'
// 通过 createCompiler 方法生成编译函数
const { compile, compileToFunctions } = createCompiler(baseOptions)
export { compile, compileToFunctions }
重点在createCompiler方法,我们看看它是如何实现的
export function createCompiler(baseOptions) {
const baseCompile = (template, options) => {
// 解析 html,转化为 ast
const ast = parse(template.trim(), options)
// 优化 ast,标记静态节点
optimize(ast, options)
// 将 ast 转化为可执行代码
const code = generate(ast, options)
return {
ast,
render: code.render,
staticRenderFns: code.staticRenderFns
}
}
const compile = (template, options) => {
const tips = []
const errors = []
// 收集编译过程中的错误信息
options.warn = (msg, tip) => {
(tip ? tips : errors).push(msg)
}
// 编译
const compiled = baseCompile(template, options)
compiled.errors = errors
compiled.tips = tips
return compiled
}
const createCompileToFunctionFn = () => {
// 编译缓存
const cache = Object.create(null)
return (template, options, vm) => {
// 已编译模板直接走缓存
if (cache[template]) {
return cache[template]
}
const compiled = compile(template, options)
return (cache[key] = compiled)
}
}
return {
compile,
compileToFunctions: createCompileToFunctionFn(compile)
}
}
可以看到主要的编译逻辑基本都在 baseCompile 方法内,从baseCompile 方法可以看出,编译的流程,主要有三步:
- 模板解析:通过正则等方式提取出 模板里的标签元素、属性、变量等信息,并解析成抽象语法树 AST
- 优化:遍历 AST 找出其中的静态节点和静态根节点,并添加标记
- 代码生成:根据 AST 生成渲染函数 render
这三步分别对应 parse、optimize、generate三个方法,这里面的具体实现代码有点多,感兴趣的可以自己去阅读。
三、说说 Vue 中 CSS scoped 的原理
在vue文件中的style标签上,有一个特殊的属性:scoped,当一个style标签拥有scoped属性时,它的CSS样式就只能作用于当前的组件
为什么需要CSS scoped
在前端工程化飞速发展的时候,作为非编程语言的CSS在融入模块化的浪潮时产生了很多问题:
- 无法做到样式模块化
组件化开发是前端模块化的核心,但是原生CSS的思想是样式的层叠,对于组件来说并不友好,会造成组件样式被覆盖等问题。
于是我们希望样式是存在作用域的,即在组件的作用域内,组件样式只对该组件生效。
- 命名混乱
在大型项目中,多人合作经常容易产生命名混乱的问题,直接后果就是代码风格不统一、样式冲突等。
- 高重复
组件开发也意味着有很多样式代码是重复的,在项目中显得十分冗余。
于是我们希望存在一种机制可以导入和导出CSS,做到样式的复用,解决CSS模块化的方案有很多种,在Vue项目中,Vue Loader支持的两种分别是CSS scoped 和CSS Modules。
CSS scoped原理
Vue Loader默认使用CSS后处理器PostCSS来实现CSS scoped,原理就是给声明了scoped的样式中选择器命中的元素添加一个自定义属性,再通过属性选择器实现作用域隔离样式的效果。
- 转化前
<style module>
.example {
color: red;
}
</style>
<template>
<div class="example">hi</div>
</template>
- 转化后
<!-- 用自定义属性把类名封装起来了 -->
<style>
.example[data-v-f3f3eg9] {
color: red;
}
</style>
<template>
<div class="example" data-v-f3f3eg9>hi</div>
</template>
CSS scoped规则
- 一个
Vue文件中可以同时存在global和scoped的样式,即允许声明两个style标签。
<style>
/* global styles */
</style>
<style scoped>
/* local styles */
</style>
- 使用
CSS scoped以后,因为样式具有了作用域,所以父组件的样式是不会影响到子组件的,即父组件和子组件的样式都具有自己的作用域,但是对于子组件的根元素来说,其样式还是可以受父组件控制的,使得父组件可以控制布局。
注意通过
v-html创建的DOM内容是不受CSS scoped控制的,如果希望修改其中的样式,可以通过深度作用选择器。
- 因为
CSS scoped是通过属性选择器实现的,所以最好不要和标签选择器混用,会产生性能问题。
深度作用选择器
在使用了scoped属性后,给当前组件的子组件创建的样式就会不生效,包括一些第三方的组件库,这时就需要用到深度作用选择器
原理
深度作用选择器使得父组件的样式可以渗透到子组件,其原理是使用后代选择器。
/* 转化前 */
<style scoped>
.a :deep(.b) {
/* ... */
}
</style>
/* 转化后 */
.a[data-v-f3f3eg9] .b {
/* ... */
}
注意: 深度作用选择器和声明为
global样式的区别,深度作用选择器只是为了能让父组件控制子组件样式,而global样式是全局起效的。
写法
- /deep/:已废弃
- >>>:在不使用
Sass预处理器时可以使用 - ::v-deep:使用
Sass预处理器时使用

浙公网安备 33010602011771号