轻松搞懂前端面试题系列(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
预处理器时使用