轻松搞懂前端面试题系列(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
  • 判断newVnodeoldVnode是否指向同一个对象,如果是,那么直接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循环主要处理了以下五种情景:

  1. 当新老VNode节点的start相同时,直接patchVnode,同时新老VNode节点的开始索引都加 1
  2. 当新老VNode节点的end相同时,同样直接 patchVnode ,同时新老 VNode 节点的结束索引都减 1
  3. 当老 VNode 节点的 start 和新 VNode 节点的 end 相同时,这时候在 patchVnode 后,还需要将当前真实 dom 节点移动到 oldEndVnode 的后面,同时老 VNode 节点开始索引加 1,新 VNode 节点的结束索引减 1
  4. 当老 VNode 节点的 end 和新 VNode 节点的 start 相同时,这时候在 patchVnode 后,还需要将当前真实 dom 节点移动到 oldStartVnode 的前面,同时老 VNode 节点结束索引减 1,新 VNode 节点的开始索引加 1
  5. 如果都不满足以上四种情形,那说明没有相同的节点可以复用,则会分为以下两种情况:
  • 从旧的 VNodekey 值,对应 index 序列为 value 值的哈希表中找到与 newStartVnode 一致 key 的旧的 VNode 节点,再进行patchVnode,同时将这个真实 dom 移动到 oldStartVnode 对应的真实 dom 的前面
  • 调用 createElm 创建一个新的 dom 节点放到当前 newStartIdx 的位置

为了更好的理解,下面举个vue通过diff算法更新的例子:

新旧VNode节点如下图所示:

  1. 第一次循环后,发现旧节点D与新节点D相同,直接复用旧节点D作为diff后的第一个真实节点,同时旧节点endIndex移动到C,新节点的 startIndex 移动到了 C
  2. 第二次循环后,同样是旧节点的末尾和新节点的开头(都是 C)相同,同理,diff 后创建了 C 的真实节点插入到第一次创建的 B 节点后面。同时旧节点的 endIndex 移动到了 B,新节点的 startIndex 移动到了 E
  3. 第三次循环中,发现E没有找到,这时候只能直接创建新的真实节点 E,插入到第二次创建的 C 节点之后。同时新节点的 startIndex 移动到了 A。旧节点的 startIndexendIndex 都保持不动
  4. 第四次循环中,发现了新旧节点的开头(都是 A)相同,于是 diff 后创建了 A 的真实节点,插入到前一次创建的 E 节点后面。同时旧节点的 startIndex 移动到了 B,新节点的 startIndex 移动到了 B
  5. 第五次循环中,情形同第四次循环一样,因此 diff 后创建了 B 真实节点 插入到前一次创建的 A 节点后面。同时旧节点的 startIndex 移动到了 C,新节点的 startIndex 移动到了 F
  6. 新节点的 startIndex 已经大于 endIndex 了,需要创建 newStartIdxnewEndIdx 之间的所有节点,也就是节点F,直接创建 F 节点对应的真实节点放到 B 节点后面

为什么不建议用index作为key?

由前面我们已经明白了,在进行子节点的diff算法过程中,会复用相同的节点,而数组的顺序怎么颠倒,index都是0, 1, 2这样排列,进行旧首节点和新首节点的sameNode对比时,复用了错误的旧子节点,而原本的节点可能会被当成新增的节点,导致key值并没有起到任何作用,也就无法提高渲染效率。

二、Vue 模板是如何编译的

在日常开发中,.vue 这种单文件组件非常方便,我们也知道,Vue 底层是通过虚拟 DOM 来进行渲染的,那么 .vue 文件的模板到底是怎么转换成虚拟 DOM 的呢?
我们知道 <template></template> 这个是模板,不是真实的 HTML,浏览器是不认识模板的,所以我们需要把它编译成浏览器认识的原生的 HTML

这一块的主要流程就是

  1. 提取出模板中的原生 HTML 和非原生 HTML,比如绑定的属性、事件、指令等等
  2. 经过一些处理生成 render 函数
  3. render 函数再将模板内容生成对应的 vnode
  4. 再经过 patch 过程( Diff )得到要渲染到视图中的 vnode
  5. 最后根据 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)

官网是这样描述的


通过上图,可以总结为以下几点:

  1. 渲染到哪个根节点上:判断有无el属性,有的话直接获取el根节点,没有的话调用$mount去获取根节点
  2. 渲染哪个模板:
  • render:这时候优先执行render函数,render优先级 > template
  • render
    • template:拿template去解析成render函数的所需的格式,并使用调用render函数渲染
    • template:拿el根节点的outerHTML去解析成render函数的所需的格式,并使用调用render函数渲染
  1. 渲染的方式:无论什么情况,最后都统一是要使用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 方法可以看出,编译的流程,主要有三步:

  1. 模板解析:通过正则等方式提取出 模板里的标签元素、属性、变量等信息,并解析成抽象语法树 AST
  2. 优化:遍历 AST 找出其中的静态节点和静态根节点,并添加标记
  3. 代码生成:根据 AST 生成渲染函数 render

这三步分别对应 parseoptimizegenerate三个方法,这里面的具体实现代码有点多,感兴趣的可以自己去阅读。

三、说说 Vue 中 CSS scoped 的原理

vue文件中的style标签上,有一个特殊的属性:scoped,当一个style标签拥有scoped属性时,它的CSS样式就只能作用于当前的组件

为什么需要CSS scoped

在前端工程化飞速发展的时候,作为非编程语言的CSS在融入模块化的浪潮时产生了很多问题:

  1. 无法做到样式模块化

组件化开发是前端模块化的核心,但是原生CSS的思想是样式的层叠,对于组件来说并不友好,会造成组件样式被覆盖等问题。
于是我们希望样式是存在作用域的,即在组件的作用域内,组件样式只对该组件生效。

  1. 命名混乱

在大型项目中,多人合作经常容易产生命名混乱的问题,直接后果就是代码风格不统一、样式冲突等。

  1. 高重复

组件开发也意味着有很多样式代码是重复的,在项目中显得十分冗余。

于是我们希望存在一种机制可以导入和导出CSS,做到样式的复用,解决CSS模块化的方案有很多种,在Vue项目中,Vue Loader支持的两种分别是CSS scopedCSS 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规则

  1. 一个Vue文件中可以同时存在globalscoped的样式,即允许声明两个style标签。
<style>
/* global styles */
</style>
​
<style scoped>
/* local styles */
</style>
  1. 使用CSS scoped以后,因为样式具有了作用域,所以父组件的样式是不会影响到子组件的,即父组件和子组件的样式都具有自己的作用域,但是对于子组件的根元素来说,其样式还是可以受父组件控制的,使得父组件可以控制布局。

注意通过v-html创建的DOM内容是不受CSS scoped控制的,如果希望修改其中的样式,可以通过深度作用选择器。

  1. 因为CSS scoped是通过属性选择器实现的,所以最好不要和标签选择器混用,会产生性能问题。

深度作用选择器

在使用了scoped属性后,给当前组件的子组件创建的样式就会不生效,包括一些第三方的组件库,这时就需要用到深度作用选择器

原理

深度作用选择器使得父组件的样式可以渗透到子组件,其原理是使用后代选择器。

/* 转化前 */
<style scoped>
.a :deep(.b) {
  /* ... */
}
</style>
​
/* 转化后 */
.a[data-v-f3f3eg9] .b {
  /* ... */
}

注意: 深度作用选择器和声明为global样式的区别,深度作用选择器只是为了能让父组件控制子组件样式,而global样式是全局起效的。

写法

  • /deep/:已废弃
  • >>>:在不使用Sass预处理器时可以使用
  • ::v-deep:使用Sass预处理器时使用
posted @ 2022-08-01 18:11  来亦何哀  阅读(86)  评论(1编辑  收藏  举报