Skip to content

Vue 原理篇

问题 1:什么是数据响应式?

数据变化后,依赖数据的函数重新运行。

问题 2:vue3 为什么要用 proxy 替换 Object.defineProperty?

Vue 3 在设计上选择使用 Proxy 替代 Object.defineProperty 主要是为了提供更好的响应性和性能。

Object.defineProperty 是在 ES5 中引入的属性定义方法,用于对对象的属性进行劫持和拦截。Vue 2.x 通过遍历对象的每个属性,使用 Object.defineProperty 来实现对数据的劫持,从而实现响应式数据的更新和依赖追踪。

  • Object.defineProperty 只能对已经存在的属性进行劫持,无法拦截新增的属性和删除的属性。这就意味着在 Vue 2.x 中,当你添加或删除属性时,需要使用特定的方法( Vue.setVue.delete )来通知 Vue 响应式系统进行更新。这种限制增加了开发的复杂性。
  • Object.defineProperty 的劫持是基于属性级别的,也就是说每个属性都需要被劫持。这对于大规模的对象或数组来说,会导致性能下降。因为每个属性都需要添加劫持逻辑,这会增加内存消耗和初始化时间。
  • 相比之下,Proxy 是 ES6 中引入的新特性,可以对整个对象进行拦截和代理。Proxy 提供了更强大和灵活的拦截能力,可以拦截对象的读取、赋值、删除等操作。Vue 3.x 利用 Proxy 的特性,可以更方便地实现响应式系统。
  • 使用 Proxy 可以解决 Object.defineProperty 的限制问题。它可以直接拦截对象的读取和赋值操作,无需在每个属性上进行劫持。这样就消除了属性级别的劫持开销,提高了初始化性能。另外,Proxy 还可以拦截新增属性和删除属性的操作,使得响应式系统更加完备和自动化。

问题 3:Vue 响应式核心 Observer、Dep、Watcher(如何实现)

Vue 响应式原理的核心就是 ObserverDepWatcher

Observer 中进行响应式的绑定,

在数据被读的时候,触发 get 方法,执行 Dep 来收集依赖,也就是收集 Watcher

在数据被改的时候,触发 set 方法,通过对应的所有依赖( Watcher ),去执行更新。

Observer、Dep、Watcher 详细解释

1. Observer

作用:

  • 数据劫持Observer 负责将数据对象转换为响应式对象。它通过递归地遍历对象的每个属性,并使用 Object.defineProperty(Vue 2)或 Proxy(Vue 3)来劫持这些属性的读取和写入操作。
  • 响应式绑定:当数据对象的属性被访问或修改时,Observer 会触发相应的 getset 方法。

工作原理:

  • vue2:
js
Object.defineProperty(data, key, {
  get() {
    // 收集依赖
    Dep.depend()
    return value
  },
  set(newValue) {
    if (newValue === value) return
    value = newValue
    // 通知依赖更新
    dep.notify()
  }
})
  • vue3:
js
const handler = {
  get(target, key, receiver) {
    // 收集依赖
    track(target, key)
    return Reflect.get(target, key, receiver)
  },
  set(target, key, value, receiver) {
    const result = Reflect.set(target, key, value, receiver)
    // 通知依赖更新
    trigger(target, key)
    return result
  }
}
const reactiveData = new Proxy(data, handler)

2. Dep

作用:

  • 依赖管理:Dep(Dependency)用于管理依赖(Watcher)的收集和通知。每个响应式属性都有一个对应的 Dep 实例。
  • 收集依赖:当响应式数据被读取时,Dep 会收集当前的 Watcher,建立数据和 Watcher 之间的依赖关系。
  • 通知更新:当响应式数据被修改时,Dep 会通知所有依赖它的 Watcher 进行更新。

工作原理:

  • 收集依赖:在 get 方法中,Dep 会调用 Dep.depend() 方法,将当前的 Watcher 添加到 Dep 的订阅者列表中。
  • 通知更新:在 set 方法中,Dep 会调用 Dep.notify() 方法,遍历订阅者列表并调用每个 Watcher 的 update 方法。

3. Watcher

作用:

  • 依赖追踪:Watcher 用于追踪数据的变化,并在数据变化时执行相应的更新操作。
  • 更新视图:当依赖的数据发生变化时,Watcher 会触发视图的重新渲染或组件的重新执行。

工作原理:

  • 初始化:在组件渲染过程中,会创建一个 Watcher 实例,并将其设置为当前的 Dep.target。
  • 收集依赖:当 get 方法被调用时,Dep 会将当前的 Watcher 添加到其订阅者列表中。
  • 更新:当 set 方法被调用时,Dep 会通知所有订阅的 Watcher 调用其 update 方法,从而触发视图的更新。

4. 总结

  • Observer:负责将数据对象转换为响应式对象,通过 get 和 set 方法劫持数据的读取和写入。
  • Dep:管理依赖关系,收集 Watcher 并在数据变化时通知它们。
  • Watcher:追踪数据的变化,并在数据变化时执行更新操作,确保视图与数据保持同步。

问题 4:$nextTick 原理及作用

Vue 的 nextTick 其本质是对 JavaScript 执行原理 EventLoop(事件循环) 的一种应用。nextTick 是将回调函数放到一个微任务队列中,保证在异步更新 DOM 的 watcher 后面,从而获取到更新后的 DOM。

因为在 created()钩子函数中,页面的 DOM 还未渲染,这时候也没办法操作 DOM,所以,此时如果想要操作 DOM,必须将操作的代码放在 nextTick()的回调函数中。

可以通过 PromiseMutationObserverqueueMicrotask 来创建微任务

1. Promise

Promise 是创建微任务最常见的方式之一。当一个 Promise 被解析或拒绝时,它的 .then()、.catch() 和 .finally() 回调会被添加到微任务队列中。

2. MutationObserver

MutationObserver 用于监视 DOM 树的变化。当观察到的变化被提交时,MutationObserver 的回调函数会被添加到微任务队列中。

js
console.log('Start')

const observer = new MutationObserver(() => {
  console.log('Microtask from MutationObserver')
})

observer.observe(document.body, { childList: true })

document.body.appendChild(document.createElement('div'))

console.log('End')

// 输入顺序
// Start
// End
// Microtask from MutationObserver

3. queueMicrotask

queueMicrotask() 是一个专门用于将回调函数添加到微任务队列的函数。它接受一个回调函数作为参数,并确保该回调会在当前同步代码执行完毕后立即执行。

js
onsole.log('Start')

queueMicrotask(() => console.log('Microtask from queueMicrotask'))

console.log('End')

// 输出顺序
// Start
// End
// Microtask from queueMicrotask

问题 5:什么是虚拟 DOM?为什么要虚拟 DOM?

什么是虚拟 DOM?

一种在前端开发中用于提高性能的概念,它最初由 React 引入,后来被其他一些前端框架如 Vue 所采用。虚拟 DOM 就是通过 JS 生成的 AST 节点树,主要目标是减少 DOM 操作的次数,从而提高页面渲染的效率

基本工作原理:

  1. 虚拟 DOM 树:当应用的状态发生变化时,框架会创建一个虚拟 DOM 树,一个轻量级的内存中的树形结构,与实际的 DOM 结构相对应。
  2. 对比和差异检测:框架会将前后两个虚拟 DOM 树进行比较,找出它们之间的差异。
  3. 差异更新:框架会计算出需要进行更新的最小 DOM 操作集合,然后将这些操作应用到实际的 DOM 中,以反映最新的状态。这一步骤通常包括插入、删除、更新 DOM 元素或属性,以及处理事件监听器等。

优点:

  • 性能提升:通过最小化实际 DOM 的操作,虚拟 DOM 可以显著提高页面渲染的性能。
  • 可跨平台:可以独立于底层平台,使前端框架能够在不同的浏览器和环境中工作,而不必担心平台差异。
  • 简化复杂性:开发者可以更轻松地编写应用逻辑,不必担心手动管理 DOM 更新,从而减少错误和复杂性。

为什么需要虚拟 DOM?

虚拟 DOM 并不一定是为了提升性能,例如svelte就没有采用虚拟 DOM。

采用虚拟 DOM 的主要原因是:

  1. 框架设计层面有关:因为 vue 组件会被编译成一个渲染函数,数据变化渲染函数就会重新运行,得到新的整个组件的虚拟 dom,这种情况下粒度就是组件级别,无法精准知道哪个地方发生变化,为了定位到组件中是哪个真实 DOM 发生变化,所以才使用了虚拟 DOM。通过新旧虚拟 DOM 树的对比,找到需要去更新的真实 DOM。

组件也就对应到虚拟 DOM,当我数据变化后,还是会生成新的虚拟 DOM,但是是虚拟 DOM,在内存中的,即使新生成,对效率的影响也不大。

  1. 跨平台有关:做到了运行时解耦(跟环境解耦),这样就可以移植到任何环境中。

问题 6:虚拟 DOM 就一定比真实 DOM 更快吗

虚拟 DOM 不一定比真实 DOM 更快,而是在特定情况下可以提供更好的性能,还有它的跨平台性。

在复杂情况下,虚拟 DOM 可以比真实 DOM 操作更快,因为它是在内存中维护一个虚拟的 DOM 树,将真实 DOM 操作转换为对虚拟 DOM 的操作,然后通过diff算法找出需要更新的部分,最后只变更这部分到真实 DOM 就可以。在频繁变更下,它可以批量处理这些变化从而减少对真实 DOM 的访问和操作,减少浏览器的回流重绘,提高页面渲染性能。

而在一下简单场景下,直接操作真实 DOM 可能会更快,当更新操作很少或者只是局部改变时,直接操作真实 DOM 比操作虚拟 DOM 更高效,省去了虚拟 DOM 的计算、对比开销。

问题 7:Vue 的 diff 算法(也称 patch)

diff的目的是找出差异,最小化的更新视图。 diff算法发生在视图更新阶段,当数据发生变化的时候,diff 会对新旧虚拟 DOM 进行对比,最小化操作真实 DOM。

1. 首先判断节点是否可复用

通过以下两个条件判断新旧节点是否为同一节点(可复用):

  • type 相同:如都是元素节点(div)、组件节点或文本节点
  • key 相同:若节点有 key 属性,则必须完全匹配(普通节点通常不设置 key,但组件节点可能会设置)

特殊情况:type 为 Text(文本节点),只需对比文本内容是否变化。

如果不满足以上条件,则直接销毁旧节点,并创建新节点替换它,不再进行深层对比。

2. 若可复用,则更新节点属性

当节点可复用时(type 和 key 匹配),Vue 3 会执行以下更新操作:

  • 更新元素属性:对比新旧节点的 propsattrsclassstyle 等,只更新变化的属性(如 class 新增 / 删除类名、style 修改值)
  • 更新事件监听:移除旧的事件监听,添加新的事件监听(若有变化)
  • 处理特殊属性:如 refdirectives(指令)等特殊属性的更新

3. 子节点处理阶段:递归对比并更新子节点(核心)

当前节点可复用后,需处理其子节点列表(oldChildren 和 newChildren)。这是 Diff 算法最复杂的部分,根据子节点类型不同,处理逻辑分为 3 类:

(1)子节点为单个节点(非列表)

  • newChildren 是单个节点:
    • oldChildren 也是单个节点 → 递归执行整个 Diff 流程(复用/更新该子节点)。
    • oldChildren 是多个节点 → 销毁所有旧子节点,创建并挂载新子节点。
  • newChildrennull(无子女):
    • oldChildren 存在 → 销毁所有旧子节点。

(2)子节点为多个固定节点(非 v-for 生成)

子节点顺序固定(如模板中写死的多个元素),按索引顺序逐个对比

  • 遍历新旧子节点列表,对每个索引 i

    • i 在新旧列表中均存在 → 递归对比 oldChildren[i]newChildren[i]
    • i 只在旧列表中存在 → 销毁 oldChildren[i]
    • i 只在新列表中存在 → 创建并挂载 newChildren[i]

示例:

html
<!-- 旧节点 -->
<div>
  <span>1</span>
  <p>2</p>
</div>

<!-- 新节点 -->
<div>
  <span>1</span>
  <p>3</p>
  <!-- 内容变化 -->
  <div>4</div>
  <!-- 新增节点 -->
</div>

按索引 0(span 复用)、1(p 更新内容)、2(新增 div)处理。

(3)子节点为列表(v-for 生成,带 key)

该步骤分为有key无key两种情况。

vue
<template>
  <div>
    <div
      v-for="item in Arr"
      :key="item"
    >
      {{ item }}
    </div>
  </div>
</template>

<script setup lang="ts">
const Arr: Array<string> = ['A', 'B', 'C', 'D']
Arr.splice(2, 0, 'DDD')
</script>
无 Key 情况

例如:在 v-for 循环中,不使用 key 进行遍历,vue 的 diff 算法处理过程为(3 步):

vue-diff算法-无key

由图可以看出,无 key 的 diff 算法会直接替换掉整个节点(全量替换),多了新增,少了删除。

无 key 的 diff 算法
ts
const patchUnkeyedChildren = (
  c1: VNode[], // 旧的
  c2: VNodeArrayChildren, // 新的
  container: RendererElement,
  anchor: RendererNode | null,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  namespace: ElementNamespace,
  slotScopeIds: string[] | null,
  optimized: boolean
) => {
  c1 = c1 || EMPTY_ARR
  c2 = c2 || EMPTY_ARR
  const oldLength = c1.length
  const newLength = c2.length
  const commonLength = Math.min(oldLength, newLength)
  let i
  for (i = 0; i < commonLength; i++) {
    const nextChild = (c2[i] = optimized
      ? cloneIfMounted(c2[i] as VNode)
      : normalizeVNode(c2[i]))
    // 节点替换
    patch(
      c1[i],
      nextChild,
      container,
      null,
      parentComponent,
      parentSuspense,
      namespace,
      slotScopeIds,
      optimized
    )
  }
  if (oldLength > newLength) {
    // 旧节点 比 新节点 多,删除
    unmountChildren(
      c1,
      parentComponent,
      parentSuspense,
      true,
      false,
      commonLength
    )
  } else {
    // 旧节点 比 新节点 少,新增
    mountChildren(
      c2,
      container,
      anchor,
      parentComponent,
      parentSuspense,
      namespace,
      slotScopeIds,
      optimized,
      commonLength
    )
  }
}
有 Key 情况

vue-diff算法-有key

步骤:

  1. 前序对比算法:只对比最前面的,当遇到不一样的时候,停止对比(patch 打补丁更新)
  2. 尾序对比算法:从尾部开始对比,当遇到不一样的时候,停止对比
  3. 仅处理新增:新节点如果比旧节点多,就是挂载新增
  4. 仅处理卸载:新节点如果比旧节点少,就是卸载删除
  5. 处理 新增、卸载、移动的复杂情况(需要移动时,才需要计算最长递增子序列)

在第五点判断复杂情况时的流程:

  1. 先找出新旧变化区间

  2. n3 节点卸载、n8 节点挂载

  3. n4、n5 节点不动,n6 移动到 n5 后面

diff-最长递增子序列
有 key 的 diff 算法
ts
// 有 key 的 diff 算法
const patchKeyedChildren = (
  c1: VNode[],
  c2: VNodeArrayChildren,
  container: RendererElement,
  parentAnchor: RendererNode | null,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  namespace: ElementNamespace,
  slotScopeIds: string[] | null,
  optimized: boolean
) => {
  let i = 0
  const l2 = c2.length
  let e1 = c1.length - 1 // prev ending index
  let e2 = l2 - 1 // next ending index

  // 1. 前序对比算法(只对比最前面的,当遇到不一样的时候,停止对比)
  // (a b) c
  // (a b) d e
  while (i <= e1 && i <= e2) {
    const n1 = c1[i]
    const n2 = (c2[i] = optimized
      ? cloneIfMounted(c2[i] as VNode)
      : normalizeVNode(c2[i]))
    /**
     * isSameVNodeType: 判断 type 和 key 是否一致,一致才复用
     */
    if (isSameVNodeType(n1, n2)) {
      patch(
        n1,
        n2,
        container,
        null,
        parentComponent,
        parentSuspense,
        namespace,
        slotScopeIds,
        optimized
      )
    } else {
      // 不一致,break 结束循环
      break
    }
    i++
  }

  // 2. 尾序对比算法(从尾部开始对比,当遇到不一样的时候,停止对比)
  // a (b c)
  // d e (b c)
  while (i <= e1 && i <= e2) {
    const n1 = c1[e1]
    const n2 = (c2[e2] = optimized
      ? cloneIfMounted(c2[e2] as VNode)
      : normalizeVNode(c2[e2]))
    if (isSameVNodeType(n1, n2)) {
      patch(
        n1,
        n2,
        container,
        null,
        parentComponent,
        parentSuspense,
        namespace,
        slotScopeIds,
        optimized
      )
    } else {
      break
    }
    e1--
    e2--
  }

  // 3. 新增
  // (a b)
  // (a b) c
  // i = 2, e1 = 1, e2 = 2
  // (a b)
  // c (a b)
  // i = 0, e1 = -1, e2 = 0
  if (i > e1) {
    if (i <= e2) {
      const nextPos = e2 + 1
      const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
      while (i <= e2) {
        patch(
          null,
          (c2[i] = optimized
            ? cloneIfMounted(c2[i] as VNode)
            : normalizeVNode(c2[i])),
          container,
          anchor,
          parentComponent,
          parentSuspense,
          namespace,
          slotScopeIds,
          optimized
        )
        i++
      }
    }
  }

  // 4. 删除
  // (a b) c
  // (a b)
  // i = 2, e1 = 2, e2 = 1
  // a (b c)
  // (b c)
  // i = 0, e1 = 0, e2 = -1
  else if (i > e2) {
    while (i <= e1) {
      unmount(c1[i], parentComponent, parentSuspense, true)
      i++
    }
  }

  // 5. 特殊情况:乱序
  // [i ... e1 + 1]: a b [c d e] f g
  // [i ... e2 + 1]: a b [e d c h] f g
  // i = 2, e1 = 4, e2 = 5
  else {
    const s1 = i // prev starting index
    const s2 = i // next starting index

    // 5.1 构建映射关系
    // key1 -> data1...
    const keyToNewIndexMap: Map<PropertyKey, number> = new Map()
    for (i = s2; i <= e2; i++) {
      const nextChild = (c2[i] = optimized
        ? cloneIfMounted(c2[i] as VNode)
        : normalizeVNode(c2[i]))
      if (nextChild.key != null) {
        if (__DEV__ && keyToNewIndexMap.has(nextChild.key)) {
          warn(
            `Duplicate keys found during update:`,
            JSON.stringify(nextChild.key),
            `Make sure keys are unique.`
          )
        }
        keyToNewIndexMap.set(nextChild.key, i)
      }
    }

    // 5.2 loop through old children left to be patched and try to patch
    // matching nodes & remove nodes that are no longer present
    let j
    let patched = 0
    const toBePatched = e2 - s2 + 1
    let moved = false
    // used to track whether any node has moved
    let maxNewIndexSoFar = 0
    // works as Map<newIndex, oldIndex>
    // Note that oldIndex is offset by +1
    // and oldIndex = 0 is a special value indicating the new node has
    // no corresponding old node.
    // used for determining longest stable subsequence
    const newIndexToOldIndexMap = new Array(toBePatched)
    for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0

    for (i = s1; i <= e1; i++) {
      const prevChild = c1[i]
      if (patched >= toBePatched) {
        // all new children have been patched so this can only be a removal
        unmount(prevChild, parentComponent, parentSuspense, true)
        continue
      }
      let newIndex
      if (prevChild.key != null) {
        newIndex = keyToNewIndexMap.get(prevChild.key)
      } else {
        // key-less node, try to locate a key-less node of the same type
        for (j = s2; j <= e2; j++) {
          if (
            newIndexToOldIndexMap[j - s2] === 0 &&
            isSameVNodeType(prevChild, c2[j] as VNode)
          ) {
            newIndex = j
            break
          }
        }
      }
      if (newIndex === undefined) {
        unmount(prevChild, parentComponent, parentSuspense, true)
      } else {
        newIndexToOldIndexMap[newIndex - s2] = i + 1
        if (newIndex >= maxNewIndexSoFar) {
          maxNewIndexSoFar = newIndex
        } else {
          moved = true
        }
        patch(
          prevChild,
          c2[newIndex] as VNode,
          container,
          null,
          parentComponent,
          parentSuspense,
          namespace,
          slotScopeIds,
          optimized
        )
        patched++
      }
    }

    // 5.3 最长递增子序列算法
    const increasingNewIndexSequence = moved
      ? getSequence(newIndexToOldIndexMap) // 贪心 + 二分查找
      : EMPTY_ARR
    j = increasingNewIndexSequence.length - 1
    // looping backwards so that we can use last patched node as anchor
    for (i = toBePatched - 1; i >= 0; i--) {
      const nextIndex = s2 + i
      const nextChild = c2[nextIndex] as VNode
      const anchor =
        nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
      if (newIndexToOldIndexMap[i] === 0) {
        // mount new
        patch(
          null,
          nextChild,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          namespace,
          slotScopeIds,
          optimized
        )
      } else if (moved) {
        // move if:
        // There is no stable subsequence (e.g. a reverse)
        // OR current node is not among the stable sequence
        if (j < 0 || i !== increasingNewIndexSequence[j]) {
          move(nextChild, container, anchor, MoveType.REORDER)
        } else {
          j--
        }
      }
    }
  }
}

不同节点类型的流程差异总结

节点类型核心差异点(子节点处理阶段)其他阶段(复用判断/属性更新)
单个普通节点直接递归对比单个子节点完全相同
多个固定节点按索引顺序逐个对比子节点完全相同
列表节点(v-for)用双向指针、key 映射表、LIS 优化子节点对比与移动完全相同

问题 8:vue3 渲染流程

Vue 3 的渲染流程主要包含创建应用实例、编译模板、创建虚拟 DOM、挂载应用、更新虚拟 DOM 等步骤,以下是详细介绍:

1. 创建应用实例

使用 createApp 函数创建一个 Vue 应用实例,这是整个渲染流程的起点。

javascript
import { createApp } from 'vue'
import App from './App.vue'

// 创建应用实例
const app = createApp(App)

// 挂载应用
app.mount('#app')

2. 编译模板

Vue 3 有两种模板来源:

  • 单文件组件(SFC):在 .vue 文件中,<template> 标签内的内容就是模板。在构建过程中,Vue CLI 或 Vite 等构建工具会使用 @vue/compiler-sfc 对单文件组件进行编译。
  • 运行时模板:如果在 JavaScript 中直接使用 template 选项定义模板,Vue 会在运行时使用 @vue/compiler-dom 进行编译。

示例:运行时模板编译

javascript
import { createApp } from 'vue'

const app = createApp({
  template: '<div>{{ message }}</div>',
  data() {
    return {
      message: 'Hello, Vue 3!'
    }
  }
})

app.mount('#app')

3. 创建虚拟 DOM

编译后的模板会被转换为渲染函数,渲染函数会返回虚拟 DOM(Virtual DOM)树。虚拟 DOM 是一种轻量级的 JavaScript 对象,它是真实 DOM 的抽象表示。

渲染函数示例

javascript
import { h } from 'vue'

const MyComponent = {
  render() {
    return h('div', {}, 'Hello, Vue 3!')
  }
}

⚠️ 注意:h 函数用于创建虚拟 DOM 节点。

4. 挂载应用

调用 app.mount 方法将应用挂载到 DOM 上。在挂载过程中,Vue 会执行以下操作:

  • 创建根组件实例。
  • 调用渲染函数生成虚拟 DOM 树。
  • 根据虚拟 DOM 树创建真实 DOM 节点,并将其插入到指定的 DOM 容器中。

5. 响应式系统

Vue 3 使用 Proxy 对象实现响应式系统。当组件的响应式数据发生变化时,Vue 会自动追踪这些变化,并触发相应的更新操作。

示例:响应式数据

javascript
import { createApp, ref } from 'vue'

const app = createApp({
  setup() {
    const count = ref(0)

    const increment = () => {
      count.value++
    }

    return {
      count,
      increment
    }
  },
  template: '<div><button @click="increment">{{ count }}</button></div>'
})

app.mount('#app')

在这个示例中,当点击按钮时,count 的值会发生变化,Vue 会自动更新 UI。

6. 更新虚拟 DOM

当响应式数据发生变化时,Vue 会重新调用渲染函数生成新的虚拟 DOM 树。然后,Vue 会使用虚拟 DOM 对比算法(Diff 算法)比较新旧虚拟 DOM 树的差异。

7. 更新真实 DOM

根据 Diff 算法的结果,Vue 会只更新真实 DOM 中发生变化的部分,从而最小化 DOM 操作,提高性能。

总结

Vue 3 的渲染流程可以概括为:创建应用实例 -> 编译模板 -> 创建虚拟 DOM -> 挂载应用 -> 响应式系统追踪数据变化 -> 更新虚拟 DOM -> 更新真实 DOM。通过这种方式,Vue 3 实现了高效的视图更新和良好的性能表现。

问题 9:vue 组件通信中,provide 原理

主要是通过 vue 的依赖注入实现的。

  1. 初始化阶段

当一个组件实例被创建时,Vue 会处理组件选项中的 provide。如果 provide 是一个对象,Vue 会直接将这个对象作为提供的数据;如果 provide 是一个函数,Vue 会调用这个函数并使用其返回值作为提供的数据。这些数据会被存储在组件实例的 _provided 属性中。

  1. 组件树遍历

当一个组件实例创建完成后,Vue 会遍历其所有子孙组件。对于每个子孙组件,Vue 会检查其 inject 选项。如果 inject 选项存在,Vue 会从当前组件的父组件开始,沿着组件树向上查找,直到找到第一个具有 _provided 属性且包含所需注入数据的组件。

  1. 注入数据

一旦找到包含所需注入数据的组件,Vue 会将这些数据注入到子孙组件的实例中。注入的数据会成为子孙组件实例的属性,子孙组件可以直接访问这些属性。

问题 10:proxy 比 Object.defineProperty 好在哪?

Object.defineProperty

遍历对象的每一个属性,然后为每个属性设置 getter 和 setter。

缺点:

  1. 需要遍历所有属性,深度遍历会造成效率问题
  2. 如果对象没有这个属性(属性新增),就监听不到,只能监听到已有的属性
js
const obj = {
  a: 1,
  b: 2,
  c: {
    a: 1,
    b: 2
  }
}

/**
 * 原理:
 *  let v = obj.a
    Object.defineProperty(obj, 'a', {
      get() {
        // 读取属性的时候,返回这个值
        console.log('读取', 'a')
        return v
      },
      set(newValue) {
        // 设置属性的时候,设置这个值
        console.log('设置', 'a')
        v = newValue
      }
    })
 */

function _isObject(v) {
  return v !== null && typeof v === 'object'
}

// 递归遍历obj,将每个属性被Object.defineProperty劫持
function observe(obj) {
  for (let key in obj) {
    let v = obj[key]
    if (_isObject(v)) {
      observe(v)
    }
    Object.defineProperty(obj, key, {
      get() {
        console.log('读取', key)
        return v
      },
      set(newValue) {
        console.log('设置', key)
        v = newValue
      }
    })
  }
}

observe(obj)

Proxy

Proxy 的监听是针对整个对象的,会返回一个代理对象,后续的操作都是针对代理对象的。

优点:(解决的问题)

  1. 监听整个对象,不用遍历,效率高
  2. 对象嵌套时,不需要深度遍历,当读取到的时候,才进行代理
  3. 属性的新增,也能被监听到
js
const obj = {
  a: 1,
  b: 2,
  c: {
    a: 1,
    b: 2
  }
}

function _isObject(v) {
  return v !== null && typeof v === 'object'
}

function reactive(obj) {
  const proxy = new Proxy(obj, {
    get(target, key) {
      console.log('get', key)
      // 只有当他读取的时候,才去递归处理
      if (_isObject(target[key])) {
        return reactive(target[key])
      }
      return target[key]
    },
    set(target, key, value) {
      if (target[key] === value) {
        return
      }
      console.log('set', key)
      target[key] = value
    }
  })
  return proxy
}

问题 11:keep-alive 底层原理

<keep-alive> 是 Vue 提供的一个内置组件,用于缓存不活动的组件实例,而不是销毁它们。keep-alive 本身是一个抽象组件,它不会渲染出自己的 DOM 元素,也不会出现在组件的父组件链中。

当组件被 <keep-alive> 缓存时,会新增两个生命周期钩子:

  • activated():组件被激活时调用(显示),可以在这里执行初始化或恢复操作
  • deactivated():组件被缓存时调用(隐藏),可以在这里清理资源、取消定时器等

底层实现原理

  1. 缓存机制
    • 它维护了一个缓存对象/Map(cache),用于存储被缓存组件的实例
    • 同时维护了一个键名数组/Set(keys),用于控制缓存组件的数量
md
- 当组件首次渲染时,将其加入 cache。
- 当组件被卸载时,不真正销毁,而是从 DOM 移除并保留在 cache 中。
- 当组件再次出现时,直接从 cache 中取出并插入 DOM。
  1. LRU 缓存策略:可以通过 max 属性限制最大缓存数量,超出时按 LRU(最近最少使用)算法 清除最久未使用的组件
简单实现
js
// 简化版 keep-alive 实现逻辑
export default {
  name: 'keep-alive',
  abstract: true, // 抽象组件

  props: {
    include: [String, RegExp, Array], // 只有名称匹配的组件会被缓存
    exclude: [String, RegExp, Array], // 任何名称匹配的组件都不会被缓存
    max: [String, Number] // 最多可以缓存多少组件实例
  },

  created() {
    this.cache = Object.create(null) // 缓存组件实例
    this.keys = [] // 缓存的组件 key 列表
  },

  destroyed() {
    // 销毁所有缓存的组件实例
    for (const key in this.cache) {
      pruneCacheEntry(this.cache, key, this.keys)
    }
  },

  mounted() {
    // 监听 include 和 exclude 的变化,更新缓存
    this.$watch('include', (val) => {
      pruneCache(this, (name) => matches(val, name))
    })
    this.$watch('exclude', (val) => {
      pruneCache(this, (name) => !matches(val, name))
    })
  },

  render() {
    // 获取默认插槽中的第一个组件
    const slot = this.$slots.default
    const vnode = getFirstComponentChild(slot)
    const componentOptions = vnode && vnode.componentOptions

    if (componentOptions) {
      // 获取组件名称
      const name = getComponentName(componentOptions)
      const { include, exclude } = this

      // 检查是否需要缓存
      if (
        (include && (!name || !matches(include, name))) ||
        (exclude && name && matches(exclude, name))
      ) {
        // 不需要缓存,直接返回 vnode
        return vnode
      }

      const { cache, keys } = this
      // 生成缓存 key
      const key =
        vnode.key == null
          ? componentOptions.Ctor.cid +
            (componentOptions.tag ? `::${componentOptions.tag}` : '')
          : vnode.key

      // 如果已经缓存过,则直接使用缓存的组件实例
      if (cache[key]) {
        vnode.componentInstance = cache[key].componentInstance
        // 调整 key 的位置,标记为最近使用
        remove(keys, key)
        keys.push(key)
      } else {
        // 没有缓存过,则缓存组件
        cache[key] = vnode
        keys.push(key)
        // 如果超过 max,则移除最久未使用的组件
        if (this.max && keys.length > parseInt(this.max)) {
          pruneCacheEntry(cache, keys[0], keys, this._vnode)
        }
      }

      // 标记为被 keep-alive 缓存的组件
      vnode.data.keepAlive = true
    }

    return vnode || (slot && slot[0])
  }
}

---难---

最长递增子序列算法

dp 变量在最长递增子序列(LIS)问题中的作用是关键。dp 数组在动态规划算法中用于存储子问题的解,具体到 LIS 问题中,dp[i] 表示以第 i 个元素结尾的最长递增子序列的长度。以下是详细的解释:

  • 定义dp[i] 表示以 nums[i] 结尾的最长递增子序列的长度。
  • 初始值:每个元素自身可以构成一个长度为 1 的递增子序列,因此 dp[i] 的初始值为 1。
js
;[10, 9, 2, 5, 3, 7, 101, 18]

解析:

js
[10, 9, 2, 5, 3, 7, 101, 18]
/**
 * dp
 */
// 开始全部默认为1:
  1  1  1  1  1  1   1    1
// 从 10 开始比较:10左边没有比他大的,那么 10 为 1
  1
// 从 9 开始比较:9 比 10 小,那么 9 为 1
  1  1
// 从 2 开始比较:2 比 10、9 小,那么 2 为 1
  1  1  1
// 从 5 开始比较:5 比 10、9 小,但比 2 大,所以 5 = 2的下标+1(1 + 1 = 2)
  1  1  1  2
// 从 3 开始比较:3 比 10、9、5 小,但比 2 大,所以 3 = 2的下标+1(1 + 1 = 2)
  1  1  1  2  2
// 从 7 开始比较:7 比 10、9 小,但比 2、5、3 大,所以 7 = 5的下标+1(2 + 1 = 3) 这时候就是在大的下标中任意一个+1
  1  1  1  2  2  3
// ...最后dp结果为:
  1  1  1  2  2  3   4   4

例:以下是使用动态规划解决最长递增子序列问题的 JavaScript 代码实现

js
function lengthOfLIS(nums) {
  if (nums.length === 0) return 0

  const dp = new Array(nums.length).fill(1)
  let maxLength = 1

  for (let i = 1; i < nums.length; i++) {
    for (let j = 0; j < i; j++) {
      if (nums[i] > nums[j]) {
        dp[i] = Math.max(dp[i], dp[j] + 1)
      }
    }
    maxLength = Math.max(maxLength, dp[i])
  }

  return maxLength
}

// 示例
const nums = [10, 9, 2, 5, 3, 7, 101, 18]
console.log(lengthOfLIS(nums)) // 输出: 4

vue3 的最长递增子序列算法

dp 数组 和 最后的结果

例如:对于给定的数组 arr = [10, 30, 200, 300, 40, 50, 60],dp 数组 [1, 2, 3, 4, 3, 4, 5]

1、dp 数组的含义:

对于给定的数组 arr = [10, 30, 200, 300, 40, 50, 60],dp 数组 [1, 2, 3, 4, 3, 4, 5] 中每个元素 dp[i] 表示以 arr[i] 结尾的最长递增子序列的长度。例如:

  • dp[0] = 1,表示以 arr[0] = 10 结尾的最长递增子序列长度为 1(即子序列只有 10 本身)。
  • dp[1] = 2,表示以 arr[1] = 30 结尾的最长递增子序列长度为 2(子序列为 [10, 30] )。
  • dp[2] = 3,表示以 arr[2] = 200 结尾的最长递增子序列长度为 3(如子序列 [10, 30, 200] )

2、从 dp 数组确定最长递增子序列的长度

通过遍历 dp 数组找到其中的最大值,这里 dp 数组中的最大值是 5,也就意味着整个数组的最长递增子序列的长度是 5。

3、回溯过程

找到 dp 数组中值为 5 的元素对应的索引(这里是 dp[6] 对应 arr[6] = 60 )。然后从这个位置开始向前回溯:

  • 从 dp[6] 开始,往前找满足 dp[i] == dp[6] - 1 且 arr[i] < arr[6] 的元素。发现 dp[5] = 4 且 arr[5] = 50 < arr[6] = 60,所以 50 是递增子序列的一部分。
  • 继续往前找,找到 dp[4] 不满足条件,而 dp[3] 不满足 arr[3] < arr[5],dp[2] 也不满足,找到 dp[1] = 2 且 arr[1] = 30 < arr[5] = 50,所以 30 是递增子序列的一部分。
  • 再往前找到 dp[0] = 1 且 arr[0] = 10 < arr[1] = 30,所以 10 是递增子序列的一部分。

最终得到的最长递增子序列就是 [10, 30, 40, 50, 60]。

js
// 给定一个数组,求他的最长递增子序列

// const arr = [10, 30, 200, 300, 40, 50, 60];
// dp:1 2 3 4 3 4 5
// 输出: [10, 30, 40, 50, 60]

// const arr = [10, 30, 200, 300, 400, 50, 60];
// dp:1 2 3 4 5 3 4
// 输出: [10, 30, 200, 300, 400]

// 思路: 贪心算法 + 二分查找 + 反向链表
const getSequence = (arr) => {
  const p = arr.slice() // 复制原数组, 用于构建反向链表
  const result = [0] // 定义结果序列, 用于返回最终结果
  let i, j, u, v, c
  const len = arr.length
  for (i = 0; i < len; i++) {
    // 遍历原数组
    const arrI = arr[i]
    if (arrI === 0) {
      j = result[result.length - 1] // 获取结果序列最后一位索引值
      if (arr[j] < arrI) {
        // 判断 当前值 大于 结果序列最后一位
        p[i] = j // 记录反向链表, 指向 结果序列最后一位
        result.push(i) // 把 i 添加到结果序列末尾
        continue
      }
      u = 0
      v = result.length - 1
      while (u < v) {
        // 二分查找
        c = (u + v) >> 1
        if (arr[result[c]] < arrI) {
          u = c + 1
        } else {
          v = c
        }
      }
      if (arrI < arr[result[u]]) {
        // 找到第一位比当前值大的值
        if (u > 0) {
          p[i] = result[u - 1] // 记录反向链表, 指向 结果序列前一位
        }
        result[u] = i // 用当前索引值 i, 替换原来的值
      }
    }
  }
  u = result.length
  v = result[u - 1]
  while (u-- > 0) {
    // 从后往前遍历, 回溯修正 结果序列
    result[u] = v
    v = p[v]
  }
  return result // 返回结果序列
}