Skip to content

小程序虚拟 DOM 渲染原理与核心实现解析

一、整体架构设计

1.1 双线程模型

小程序采用独特的双线程架构:

逻辑层 (Service) <-> 桥接层 <-> 渲染层 (View) <-> 原生组件系统
  • 逻辑层:处理业务逻辑、数据更新、事件响应
  • 渲染层:执行虚拟 DOM 对比、生成渲染指令、操作原生组件

1.2 核心模块组成

typescript
class MothraRenderer {
  // 核心功能模块
  private baseRenderer: BaseRenderer; // 基础渲染逻辑
  private patchSystem: PatchSystem; // 差异对比算法
  private commandQueue: UpdateCommand[]; // 更新指令队列
  private bridge: BridgeService; // 跨线程通信模块

  // 关键数据结构
  private vTree: VNode; // 当前虚拟DOM树
  private componentMap: Map<string, ComponentMeta>; // 组件元信息
}

二、核心实现原理

2.1 diff 算法 - 双端对比算法

参考了 Vue2/Vue3 的双端对比算法实现。(React 是单端从左到右,通过 key 优化,diff 双端对比能更好的处理列表反转等场景。React 通过 fiber 架构和时间切片弥补了单向便利的不足。)

采用优化的平级比较策略:

typescript
function patchKeyedChildren(oldChildren, newChildren) {
  let i = 0;
  // 老节点的尾部索引
  let oldEnd = oldChildren.length - 1;
  // 新节点的尾部索引
  let newEnd = newChildren.length - 1;

  // 阶段1:头部比对 - 相同的节点直接复用
  // ⚠️firstPointer=0 从左到右
  while (i <= oldEnd && i <= newEnd) {
    const oldChild = oldChildren[i];
    const newChild = newChildren[i];
    if (sameVnode(oldChild, newChild)) {
      // 递归更新子节点
      patch(oldChild, newChild);
      i++;
    } else {
      break;
    }
  }

  // 阶段2:尾部比对 - 从尾部开始,相同的节点直接复用
  // ⚠️firstPointer=len-1 从右到左
  while (i <= oldEnd && i <= newEnd) {
    const oldChild = oldChildren[oldEnd];
    const newChild = newChildren[newEnd];
    if (sameVnode(oldChild, newChild)) {
      patch(oldChild, newChild);
      oldEnd--;
      newEnd--;
    } else {
      break;
    }
  }

  // 阶段3:处理剩余情况
  if (i > oldEnd && i <= newEnd) {
    // 情况1:旧节点已处理完,但新节点还有剩余,说明需要新增节点
    const anchorIndex = newEnd + 1;
    const anchor =
      anchorIndex < newChildren.length ? newChildren[anchorIndex].el : null;

    // 批量新增节点
    for (let j = i; j <= newEnd; j++) {
      mount(newChildren[j], parentEl, anchor);
    }
  } else if (i > newEnd) {
    // 情况2:新节点已处理完,但旧节点还有剩余,说明需要删除多余节点
    for (let j = i; j <= oldEnd; j++) {
      unmount(oldChildren[j]);
    }
  } else {
    // 情况3:新旧节点都有剩余,需要进行最复杂的对比
    const remainingNewChildrenCount = newEnd - i + 1;
    const oldChildrenMap = new Map();

    // 创建索引映射,优化查找速度
    for (let j = i; j <= oldEnd; j++) {
      const oldChild = oldChildren[j];
      if (oldChild.key != null) {
        oldChildrenMap.set(oldChild.key, j);
      }
    }

    // 记录需要移动的节点
    const moves = new Array(remainingNewChildrenCount);
    let shouldMove = false;
    let lastIndex = 0;

    // 处理每个新节点
    for (let newIndex = i; newIndex <= newEnd; newIndex++) {
      const newChild = newChildren[newIndex];
      const newIndexInRemaining = newIndex - i;

      let oldIndexToUse;
      if (newChild.key != null) {
        // 通过key直接查找对应旧节点
        oldIndexToUse = oldChildrenMap.get(newChild.key);
      } else {
        // 如果没有key,则遍历查找第一个可匹配的节点
        for (let j = i; j <= oldEnd; j++) {
          if (!moves.includes(j) && sameVnode(oldChildren[j], newChild)) {
            oldIndexToUse = j;
            break;
          }
        }
      }

      // 如果找到可复用的节点
      if (oldIndexToUse !== undefined) {
        patch(oldChildren[oldIndexToUse], newChild);
        moves[newIndexInRemaining] = oldIndexToUse;

        // 判断是否需要移动
        if (oldIndexToUse < lastIndex) {
          shouldMove = true;
        } else {
          lastIndex = oldIndexToUse;
        }
      } else {
        // 未找到可复用节点,创建新节点
        mount(newChild, parentEl, null);
      }
    }

    // 如果需要移动节点,使用最长递增子序列优化
    if (shouldMove) {
      const seq = getSequence(moves); // 获取最长递增子序列

      // j 指向最长递增子序列的最后一个元素
      let j = seq.length - 1;

      // 从后向前遍历,以便正确处理DOM移动
      for (
        let newIndex = remainingNewChildrenCount - 1;
        newIndex >= 0;
        newIndex--
      ) {
        const realNewIndex = newIndex + i;
        const newChild = newChildren[realNewIndex];
        const anchor =
          realNewIndex + 1 < newChildren.length
            ? newChildren[realNewIndex + 1].el
            : null;

        if (moves[newIndex] === undefined) {
          // 这是一个新创建的节点,已在前面处理过
          continue;
        }

        // 如果当前索引不在最长递增子序列中,需要移动
        if (j < 0 || newIndex !== seq[j]) {
          move(newChild, parentEl, anchor);
        } else {
          // 当前节点是稳定的,不需要移动
          j--;
        }
      }
    }

    // 删除未使用的旧节点
    for (let j = i; j <= oldEnd; j++) {
      if (
        !oldChildrenMap.has(
          newChildren.map((child) => child.key).includes(oldChildren[j].key)
        )
      ) {
        unmount(oldChildren[j]);
      }
    }
  }
}

2.1.1 算法解释

双端对比算法

这个算法将新旧两组子节点的头部和尾部进行对比,这样可以快速跳过头尾相同的节点,减少需要处理的节点数量:

  • 头部对比:从左到右比较新旧节点列表的头部,相同则复用并递增头指针。
  • 尾部对比:从右到左比较新旧节点列表的尾部,相同则复用并递减尾指针。

优化点分析

  1. Map 结构优化查找:
  • 使用 Map 存储旧节点的 key 到索引的映射,将查找时间从 O(n)降低到 O(1)。
  1. 最长递增子序列(LIS)算法:
  • 此算法用于最小化 DOM 移动操作。
  • 它找出保持相对位置不变的最长节点序列,只移动不在此序列中的节点。
  • 时间复杂度从 O(n²)优化到 O(n log n)。
  1. 批量处理相似操作:
  • 对新增、删除和移动操作进行分组处理,减少 DOM 操作次数。
  1. 从后向前遍历移动节点:
  • 移动 DOM 节点时从后向前遍历,确保移动操作不会影响后续节点的位置判断。

2.1.2 最长递增子序列(LIS)详解

最长递增子序列算法在此处的应用是虚拟 DOM diff 算法的关键优化点:

typescript
function getSequence(arr) {
  const len = arr.length;
  // dp[i]表示以arr[i]结尾的最长上升子序列的长度
  const dp = new Array(len).fill(1);
  // 用于回溯的数组
  const result = [0];

  for (let i = 1; i < len; i++) {
    // 处理undefined或null值
    if (arr[i] === undefined) continue;

    for (let j = 0; j < i; j++) {
      if (arr[j] !== undefined && arr[i] > arr[j]) {
        dp[i] = Math.max(dp[i], dp[j] + 1);
      }
    }

    // 更新result数组
    let resultLastIndex = result[result.length - 1];
    if (arr[i] > arr[resultLastIndex]) {
      result.push(i);
    } else {
      // 二分查找应插入的位置
      let left = 0;
      let right = result.length - 1;

      while (left < right) {
        const mid = Math.floor((left + right) / 2);
        if (arr[result[mid]] < arr[i]) {
          left = mid + 1;
        } else {
          right = mid;
        }
      }

      if (arr[i] < arr[result[left]]) {
        result[left] = i;
      }
    }
  }

  // 回溯构建最终序列
  let len2 = result.length;
  let idx = result[len2 - 1];
  for (let i = len2 - 2; i >= 0; i--) {
    if (idx > 0 && arr[idx] > arr[result[i]]) {
      result[i + 1] = idx;
      idx = result[i];
    }
  }

  return result;
}

相关面试题

  1. 为什么虚拟 DOM 需要使用 key?
  • 答案:key 是虚拟 DOM 的唯一标识,用于高效比对和复用 DOM 节点。没有 key 时,算法只能按索引比对,容易导致不必要的 DOM 操作,特别是在列表重排序时。合理使用 key 可使 diff 算法快速定位变化节点,显著提高渲染性能。
  1. 解释双端对比算法的优势?
  • 答案:双端对比算法相比简单的顺序对比有显著优势:
    • 同时从头部和尾部进行比较,快速处理头尾节点的移动情况
    • 适用于常见的节点插入和删除场景
    • 特别适合处理列表反转等复杂情况
    • 减少了 DOM 操作次数,提高了性能
  1. 最长递增子序列算法在虚拟 DOM diff 中的作用是什么?
  • 答案:最长递增子序列算法用于最小化 DOM 移动操作:

    • 它找出一个最长的节点序列,这些节点在新旧两个列表中相对位置不变
    • 只对不在这个序列中的节点进行移动,而非全部重新排序
    • 将移动操作的时间复杂度从 O(n²)优化到 O(n log n)
    • 大幅减少 DOM 操作次数,尤其是在大型列表重排序时性能提升明显
  1. Vue3 和 React 中的 diff 算法有哪些主要区别?
  • 答案:
    • Vue3 使用双端对比+最长递增子序列算法,React 主要使用单向遍历
    • Vue3 对静态节点和动态节点进行标记优化,React 引入了 Fiber 架构实现时间切片
    • Vue3 针对 Fragment 类型做了特殊处理,React 通过调度器管理优先级
    • Vue3 能够检测并跳过静态内容,React 通过 memo 等 API 手动优化渲染

2.2 属性更新机制

typescript
function patchProps(el, oldProps, newProps) {
  // 关键属性更新顺序优化
  const UPDATE_ORDER = ["hidden", "id", "class", "style", "animation"];

  UPDATE_ORDER.forEach((key) => {
    if (newProps[key] !== oldProps[key]) {
      applyPropUpdate(el, key, newProps[key]);
    }
  });

  // 其他属性对比
  for (const key in newProps) {
    if (!(key in UPDATE_ORDER) && newProps[key] !== oldProps[key]) {
      applyPropUpdate(el, key, newProps[key]);
    }
  }
}

2.3 跨线程通信优化

typescript
class BridgeService {
  private messageQueue: MessagePacket[] = [];

  // 批量更新发送机制
  sendBatch() {
    if (this.messageQueue.length === 0) return;

    const batchData = this.optimizeBatch(this.messageQueue);
    native.postMessage({
      type: "vdom-update",
      data: this.compressData(batchData), // 数据压缩
    });

    this.messageQueue = [];
  }

  private optimizeBatch(messages) {
    // 合并相同路径更新
    // 去除冗余指令
    // 优化传输结构
  }
}

三、与 React/Vue 的对比分析

3.1 架构差异对比

特性小程序ReactVue
线程模型双线程架构单线程单线程
更新触发方式setData 显式更新自动追踪依赖响应式系统自动触发
组件系统原生+自定义组件纯 JS 组件模板+JS 组件
DOM 操作方式跨线程指令传输直接 DOM 操作直接 DOM 操作
更新粒度组件级优化组件级细粒度依赖追踪

3.2 性能优化对比

小程序特有优化:

  • 跨线程通信压缩:二进制协议 + 增量更新
  • 渲染指令批处理:合并相邻 DOM 操作
  • 属性更新优先级控制:确保关键属性优先更新

与传统框架共有优化:

  • 虚拟 DOM 差异算法
  • 异步更新队列
  • 组件复用机制

四、技术选型原因分析

4.1 架构设计考量

  1. 安全隔离:通过双线程隔离 JS 执行环境,防止恶意操作 DOM
  2. 性能平衡
    • 逻辑层与渲染层分离避免 JS 执行阻塞渲染
    • 批量更新减少跨线程通信次数
  3. 跨平台一致性
    typescript
    // 平台抽象层示例
    interface PlatformAdapter {
      createElement(type: string): NativeElement;
      updateProp(el: NativeElement, prop: string, value: any): void;
      // ...其他平台相关操作
    }

4.2 更新机制设计

选择平级比较算法的原因:

  1. 小程序组件树结构相对扁平
  2. 减少深度递归带来的性能损耗
  3. 更适配小程序数据更新模式

更新指令序列化的必要性:

typescript
// 典型更新指令结构
interface UpdateCommand {
  path: string; // 节点路径:'root.children[3].props.style'
  action: "set" | "merge" | "remove";
  value: any; // 序列化后的值
  priority: number; // 更新优先级
}

4.3 性能优化选择

选择增量更新策略的原因:

  1. 减少跨线程数据传输量
  2. 更适应小程序高频更新场景
  3. 降低原生组件更新成本

典型优化场景对比:

场景小程序处理方式传统框架处理方式
列表更新仅发送移动/变更指令重新渲染整个列表
样式变更合并 style 对象变更直接修改 DOM style 属性
组件状态更新通过预编译确定更新范围运行时依赖追踪

4.4 复杂度分析

从算法和系统架构角度分析,小程序渲染的复杂度主要体现在以下几个方面:

4.4.1 时间复杂度

  1. 差异比较算法 (Diffing) 基于我分析的代码,Mothra Framework 采用了类似 Vue/React 的差异比较算法:
  • 最佳情况:O(n) - 当组件树结构相对稳定,只有少量属性变化时
  • 最坏情况:O(n³) - 理论上完全无序的列表比较,但通过优化算法降低至近似 O(n)
  • 平均情况:O(n) - 使用最长递增子序列算法和首尾快速比较优化后的实际性能
  1. 节点操作复杂度
  • 节点创建/删除:O(1)
  • 属性更新:O(k),其中 k 是变化的属性数量
  • 子节点列表更新:O(n·log(n)),其中 n 是子节点数量

4.4.2 空间复杂度

  • 虚拟 DOM 树:O(n),需要存储整个组件树结构
  • 差异队列:O(d),其中 d 是需要更新的 DOM 操作数量
  • 最长递增子序列算法:O(n) 的额外空间用于计算最优更新路径

4.4.3 架构复杂度

  1. 双线程模型带来的复杂性 Mothra Framework 采用双线程模型,增加了以下复杂性:
  • 线程间通信开销:逻辑层与渲染层之间的消息传递
  • 数据同步问题:确保两个线程中的虚拟 DOM 树保持一致
  • 事件处理延迟:用户交互需要跨线程传递到逻辑层处理
  1. 平台兼容带来的复杂性 多端渲染差异:需要处理不同端(iOS、Android、Web)的渲染行为差异 API 兼容层:需要抽象平台特定 API,增加了适配复杂度

4.4.4 性能关键指标

Mothra Framework 在优化渲染性能时主要考虑以下关键点:

  • 首屏渲染时间:通过渲染批处理和优先级调度减少
  • 线程间通信成本:尽量减少逻辑层和渲染层之间的通信次数和数据量
  • 重排重绘优化:通过属性更新优先级排序减少布局抖动

4.4.5 渲染优化方案

为了应对上述复杂度挑战,Mothra Framework 实现了以下优化:

  • 批量更新:将多次数据变更合并为一次渲染更新
  • 属性更新优先级:特定属性(如样式)按优化顺序更新,减少重排次数
  • DOM Ready 检测:通过微任务和 requestAnimationFrame 优化绘制时机
  • 增量渲染:只传输和更新发生变化的部分,而非整个视图树

综合来看,Mothra Framework 小程序渲染的整体复杂度实际上是 O(n),这在大多数实际场景中表现良好,但在极端情况下(如大量无序列表重新排序)可能会有性能瓶颈。系统架构的复杂性主要来自双线程模型和跨端兼容性要求,而非算法本身的计算复杂度。

五、优势与局限性

5.1 独特优势

  1. 跨线程安全:JS 执行与 DOM 操作完全隔离
  2. 平台一致性:统一抽象层支持多端渲染
  3. 性能可预测:批量更新+指令压缩保证稳定性能
  4. 内存优化:通过 uid 映射表管理节点引用

5.2 已知局限

  1. 通信开销:简单操作可能产生额外序列化成本
  2. 调试复杂度:需要跨线程调试工具支持
  3. 内存占用:需同时维护逻辑层和渲染层状态

六、总结

小程序虚拟 DOM 渲染系统通过以下创新设计实现高性能渲染:

  1. 双线程协同:平衡性能与安全的需求
  2. 增量更新策略:最小化跨线程通信开销
  3. 平台抽象层:实现多端一致渲染体验
  4. 智能批处理:优化高频更新场景性能

相比 React/Vue 等传统方案,小程序渲染系统更适合:

  • 需要严格安全隔离的场景
  • 多平台一致性要求高的项目
  • 频繁数据更新的复杂交互应用

这种设计在保证性能的同时,也带来了新的挑战,需要开发者理解其特殊更新机制和优化模式。随着小程序生态的发展,其渲染系统仍在持续演进,值得持续关注最新技术动态。

六、面试题

问题:讲一下小程序虚拟 DOM 渲染的原理,和 diff 算法,和 React 有什么区别?

小程序 Diff 算法

  • 回答:"小程序的 diff 算法也做了几个阶段的更新,第一阶段是使用的 React 的 diff 算法,从左到右,从上到下进行广度优先遍历节点并进行同级节点比较,子节点通过 key 值进行比对,这个算法是可以使用的,但是每次都要进行全树的完整 diff,而小程序又是完全的 Stack Reconciler,不能像 React 一样做 Fiber 分片更新节点,可能会造成线程卡顿。
  • 后面就更新成了 Vue2 用的双端对比算法,通过最长递增子序列优化,也是全树对比,只业务实践下来确实是双端对比算法更适合小程序,渲染耗时会小很多,大概 30% 的提升。
  • 因为小程序大部分是设备控制的应用,组件树会包含大量的静态内容,比如不变的列表、文本、canvas,这些内容在 diff 算法中可以被快速识别为不变,不需要重新渲染,所以双端对比会更快一点。"
  • 后续的优化是准备配合构建器做静态提升(把不变的 DOM 结构提取出来)和补丁标记(只变更跟数据变化相关的部分),完全对齐 Vue3.0 的渲染流程。因为小程序基础库渲染底层变动影响比较大,setData 性能并不是完全瓶颈,这个目前就还没上线。

详细解释(配合案例) "小程序 diff 算法的核心是双端对比和最长递增子序列优化:

  1. 双端对比算法:
  • 同时比较新旧节点列表的头部和尾部
  • 例如:旧节点[A,B,C,D],新节点[A,E,C,D]
  • 先比较头部:A 与 A 匹配,移动指针
  • 再比较尾部:D 与 D 匹配,C 与 C 匹配
  • 最后只需处理中间不同的 B 和 E
  1. 优势:
  • 能高效处理常见 DOM 操作模式,如列表首尾添加/删除
  • 特别擅长处理列表反转等场景
  • 减少 DOM 操作次数
  1. 最长递增子序列优化:
  • 用于处理节点位置变化
  • 找出保持相对位置不变的最长节点序列
  • 只移动其他节点,大幅减少 DOM 操作
  • 将时间复杂度从 O(n²)优化到 O(n log n)
  1. 对比 React 差异:
  • React 主要使用单向遍历+key 优化
  • 小程序/Vue 采用双端对比+最长递增子序列
  • React 通过 Fiber 架构实现时间切片弥补单向遍历不足"

代码示例与面试亮点 "如果需要具体说明算法实现,可以简要描述核心代码逻辑:

javascript
// 双端对比算法核心
while (i <= oldEnd && i <= newEnd) {
  // 头部比对
  if (sameVnode(oldChildren[i], newChildren[i])) {
    patch(oldChildren[i], newChildren[i]);
    i++;
  }
  // 尾部比对
  else if (sameVnode(oldChildren[oldEnd], newChildren[newEnd])) {
    patch(oldChildren[oldEnd], newChildren[newEnd]);
    oldEnd--;
    newEnd--;
  }
  // 处理交叉情况和其他复杂情况
  else {
    // 使用 key 快速定位节点
    // 应用最长递增子序列算法优化移动
  }
}

最后可以强调:'这种算法不仅提高了渲染性能,对于用户体验也有显著提升,特别是在长列表和复杂交互场景下。在实际项目中通过这些原理优化了列表渲染,将性能提升了约 30%。'"

基于 MIT 许可发布