阅读视图

发现新文章,点击刷新页面。

每日一题-转换字符串的最小成本 I🟡

给你两个下标从 0 开始的字符串 sourcetarget ,它们的长度均为 n 并且由 小写 英文字母组成。

另给你两个下标从 0 开始的字符数组 originalchanged ,以及一个整数数组 cost ,其中 cost[i] 代表将字符 original[i] 更改为字符 changed[i] 的成本。

你从字符串 source 开始。在一次操作中,如果 存在 任意 下标 j 满足 cost[j] == z  、original[j] == x 以及 changed[j] == y 。你就可以选择字符串中的一个字符 x 并以 z 的成本将其更改为字符 y

返回将字符串 source 转换为字符串 target 所需的 最小 成本。如果不可能完成转换,则返回 -1

注意,可能存在下标 ij 使得 original[j] == original[i]changed[j] == changed[i]

 

示例 1:

输入:source = "abcd", target = "acbe", original = ["a","b","c","c","e","d"], changed = ["b","c","b","e","b","e"], cost = [2,5,5,1,2,20]
输出:28
解释:将字符串 "abcd" 转换为字符串 "acbe" :
- 更改下标 1 处的值 'b' 为 'c' ,成本为 5 。
- 更改下标 2 处的值 'c' 为 'e' ,成本为 1 。
- 更改下标 2 处的值 'e' 为 'b' ,成本为 2 。
- 更改下标 3 处的值 'd' 为 'e' ,成本为 20 。
产生的总成本是 5 + 1 + 2 + 20 = 28 。
可以证明这是可能的最小成本。

示例 2:

输入:source = "aaaa", target = "bbbb", original = ["a","c"], changed = ["c","b"], cost = [1,2]
输出:12
解释:要将字符 'a' 更改为 'b':
- 将字符 'a' 更改为 'c',成本为 1 
- 将字符 'c' 更改为 'b',成本为 2 
产生的总成本是 1 + 2 = 3。
将所有 'a' 更改为 'b',产生的总成本是 3 * 4 = 12 。

示例 3:

输入:source = "abcd", target = "abce", original = ["a"], changed = ["e"], cost = [10000]
输出:-1
解释:无法将 source 字符串转换为 target 字符串,因为下标 3 处的值无法从 'd' 更改为 'e' 。

 

提示:

  • 1 <= source.length == target.length <= 105
  • sourcetarget 均由小写英文字母组成
  • 1 <= cost.length== original.length == changed.length <= 2000
  • original[i]changed[i] 是小写英文字母
  • 1 <= cost[i] <= 106
  • original[i] != changed[i]

🔥别再用递归了!WeakMap 的影子索引“让树不再是树!”

一、前言

大家好,我是微澜。今天来分享一个基于 WeakMap 实现的快速对树形结构数据进行增删改查操作useTree hook函数,它是基于JavaScriptWeakMap 特性,在不改动原始数据的前提下,实现了一套 O(1) 查找的影子索引结构,这个影子其实就是对象的引用地址,让树形数据操作像操作数组一样简单!

二、为什么选择 WeakMap?

1. 非侵入性 (Non-invasive)

通过 WeakMap 在内存中构建了一套 Node -> Parent 的映射。原始数据对象保持纯净,没有任何多余属性,完美支持序列化。

2. 内存安全 (Memory Safety)

这是最关键的一点。WeakMap 对键(对象)是弱引用的。

  • 如果你删除了原始树中的某个节点,且没有其他地方引用它,垃圾回收器(GC)会自动清理索引表中的对应项。

  • 这种特性非常适合处理大型动态树,完全不用担心手动维护索引带来的内存泄漏。

3、不同实现方案对比

在开始之前,我们先看看为什么选择 WeakMap。我们可以通过一个综合对比来看看操作树形数据不同方案的差异:

维度 / 方案 传统递归遍历 节点注入 parent Map 缓存方案 WeakMap 优化方案 (本项目)
初始化复杂度 O(N2)O(N^2) (双层循环) O(N)O(N) O(N)O(N) O(N)O(N) (单次递归)
溯源时间复杂度 O(N)O(N) O(1)O(1) O(D)O(D) O(D)O(D) (D为深度,极快)
数据污染 高(直接修改对象) 无(外部映射,保持纯净)
内存管理 一般 一般 (强引用,需手动清理) 优秀(弱引用自动 GC)
序列化友好度 友好 极差(循环引用) 友好 友好
适用场景 极小数据量 中等数据量 中等数据量 大规模数据、高频转换

三、核心实现原理

1. 整体架构图

我们通过一个外部的 WeakMap 来存储节点与其父级的对应关系。这种设计最大的好处是:“不改变原始树结构,不产生循环引用,且能实现 O(1) 的溯源。”

graph TD
    A[原始树形数据] --> B{useTree Hook}
    B -- "1. 递归扫描" --> C[WeakMap 存储库]

    subgraph "WeakMap 存储策略"
    C -- "Key: 根节点" --> D["Value: 整个 Tree 数组 (方便删除)"]
    C -- "Key: 子节点" --> E["Value: 直接父节点对象 (实现溯源)"]
    end

    F[业务请求: 查找/删除] --> C
    C -- "返回父级引用" --> G[完成操作]

2. 数据映射图解 (核心逻辑)

为了让大家更直观地看到 WeakMap 里到底存了什么,我们看下面这个例子:

示例数据:

const list = [
  {
    id: 1, name: '掘金',
    children: [ { id: 11, name: '前端组' } ]
  },
  {
    id: 2, name: '社区',
    children: []
  }
];

WeakMap 内部映射关系:

节点 (Key) 映射值 (Value) Value类型 存储逻辑说明
{ id: 11, name: '前端组' } { id: 1, name: '掘金', children: [{ id: 11, name: '前端组' }] } Object (父节点) 非根节点:指向它的直接父对象
{ id: 1, name: '掘金', children: [{ id: 11, name: '前端组' }] } [{ id: 1, name: '掘金', children: [{ id: 11, name: '前端组' }] }, { id: 2, name: '社区', children: [] }] Array (根数组) 根节点:指向它所属的整棵树数组
{ id: 2, name: '社区', children: [] } [{ id: 1, name: '掘金', children: [{ id: 11, name: '前端组' }] }, { id: 2, name: '社区', children: [] }] Array (根数组) 根节点:指向它所属的整棵树数组

💡 核心秘诀: 以上设计了一套巧妙的判断规则:如果节点是根节点,其映射值就是数组;如果节点是子节点,其映射值就是父对象。 这样在执行 removeChild 时,我们只需要判断 Array.isArray(parent),就能自动切换“从数组中删除”根节点或“从 children 属性中删除”子节点的逻辑,极其优雅!


四、useTree()实现方法逐一拆解

接下来,我们看看 useTree 内部每个方法的具体实现原理。

1. initTree - 初始化索引

功能:递归遍历整棵树,建立 WeakMap 映射。

// 数据索引对象
const _treeWeakMap = new WeakMap<TreeNode, TreeNode | TreeNode[]>();

const useTree = (
  props: Props = {
    // 外部数据对象字段映射
    treeNodeProp: {
      value: 'value',
      label: 'label',
      children: 'children',
    }
  }
){
    // 初始化树结构索引
    function initTree(list, parent){...}
}
  // 初始化树结构索引
  function initTree(list: TreeNode[], parent?: TreeNode) {
    list.forEach((item) => {
      // 根节点的父级为完整的树数据,在删除根节点时需要通过完整的数组删除
      _treeWeakMap.set(item, parent || list);
      if (item[treeNodeProp.children]) {
        // 删除子节点只需要通过对应子节点的children数组删除
        initTree(item[treeNodeProp.children], item);
      }
    });
  }

原理:在组件挂载或数据更新时调用。通过这种“差异化映射”,我们让每个节点都拥有了一个指向其“归属集合”的指针,也就是指向了父级的引用地址

以上文的表格列出的示例数据为例,调用initTree方法初始化后,可以得到以下对应关系:

graph LR
    subgraph Keys [节点对象 - Key]
        K11("前端组 (id:11)")
        K1("掘金 (id:1)")
        K2("社区 (id:2)")
    end

    subgraph Values [映射值 - Value]
        V_Obj1["父节点对象 (id:1)"]
        V_Arr["根数据数组 [Tree]"]
    end

    K11 -- "指向直接父级" --> V_Obj1
    K1 -- "指向所属根数组" --> V_Arr
    K2 -- "指向所属根数组" --> V_Arr

    style K11 fill:#f9f,stroke:#333
    style K1 fill:#bbf,stroke:#333
    style K2 fill:#bbf,stroke:#333
    style V_Arr fill:#dfd,stroke:#333
    style V_Obj1 fill:#fff,stroke:#333

2. _setParent - 节点索引设置

功能:建立节点与其所属容器(父节点对象或根数组)的映射关系。

  // 设置节点的父节点映射。为防止用户错误使用,不向外暴露,内部使用
  function _setParent(node: TreeNode, parent: TreeNode | TreeNode[]) {
    _treeWeakMap.set(node, parent);
  }

原理:这是对 WeakMap.set 的一层简单封装。通过这种方式,我们将节点对象引用作为 Key,其父级引用作为 Value 存入索引库。通过_前缀标记为私有是为了防止外部直接篡改映射关系,保证索引的单一可靠性。

3. getParent - 获取父节点

功能:获取指定节点的直接父节点。

  // 获取节点的父节点
  function getParent(node: TreeNode) {
    return _treeWeakMap.get(node);
  }
  • 原理:利用 WeakMap.get 直接获取。时间复杂度为 O(1)

4. getParents - 获取全路径父节点

功能:递归向上检索父级,获取从当前节点到根部的所有父节点数组。

  // 获取节点的所有父节点
  function getParents(item: TreeNode, parentList: (TreeNode | string)[] = [], key?: string): (TreeNode | string)[] {
    const parent = _treeWeakMap.get(item);
    // 递归到根节点数组时停止
    if (parent && !_isRootNode(parent)) {
      parentList.push(key ? parent[key] : parent);
      getParents(parent, parentList, key);
    }
    return parentList;
  }
  
  // 判断是否根节点,下文都用这个方法判断是否根节点
  function _isRootNode(node: TreeNode | TreeNode[]) {
    return Array.isArray(node);
  }

原理:通过节点自身的引用地址在WeakMap中查找父级。相比于DFS(深度优先遍历)全树,这种垂直爬升的方式效率极高。 什么是垂直爬升

类似于:

show code is me.parent.parent. ......

就这样,连祖宗十八代都能给你扒出来!

5. addChild - 动态添加节点

功能:向指定节点添加子节点,并自动更新索引。

  // 添加子节点或根节点,节点不存在.children时,代表添加到根节点数组
  function addChild(node: TreeNode, child: TreeNode) {
    if(_isRootNode(node)) {
      // 根节点数组直接添加
      const i = node.push(child) - 1;
      /**
       * 为新增加的子节点构建一个WeakMap索引,指向父节点
       * 注意:注册为key的引用地址是经过proxy处理后的节点
       */
      node[i] && _setParent(node[i], node);
    } else {
      // 非根节点,添加到children数组
      if (!node[treeNodeProp.children]) {
        node[treeNodeProp.children] = [];
      }
      const i = node[treeNodeProp.children].push(child) - 1;
      // 和上面设置根节点同理
      node.children[i] && _setParent(node.children[i], node);
    }
  }

原理:在操作原始数据的同时,同步更新 WeakMap索引。也就是为新增的对象建立父级索引。

注意:这里不能把新添加的child对象当做WeakMapKey,因为child只是一个外部传入的一个临时变量,还没有和整棵树建立联系,需要在添加到树当中后,获取树当中的对象地址作为Key建立索引。

6. removeChild - 删除指定节点

功能:无痛删除任意节点,无需手动遍历查找。

  // 删除指定子节点
  function removeChild(node: TreeNode) {
    // 找到父节点,通过父节点删除
    const parent = getParent(node);
    if(!parent) {
      // 没有找到父级节点,抛出错误
      throw new Error('没有找到父级节点!');
    }
    if(_isRootNode(parent)) {
      // 删除根节点
      const index = parent.findIndex((item: TreeNode) => item === node);
      if(index >= 0) {
        parent.splice(index, 1);
      } else {
        // 没有找到要删除的根节点,抛出错误
        throw new Error('没有找到要删除的根节点!');
      }
    } else {
      // 通过找到父级删除自己
      parent.children = parent.children.filter((item: TreeNode) => item !== node);
    }
  }

原理:这是该方案最精妙的地方!通过 initTree 建立的差异化映射,我们只需要简单的 Array.isArray 判断,就能准确找到节点所在的“容器”,实现秒删。


五、完整代码实现

代码不多,却能快速~实现对树形数据的增删改查操作。

// useTree.ts

type Props = {
  // 树形数据字段名映射类型
  treeNodeProp: TreeNodeProp
}

// 树节点属性映射类型
type TreeNodeProp = {
  value: string;
  label: string;
  children: string;
}

// 数节点
type TreeNode = any

/**
 * 树结构索引数据
 * key为节点本身,value为父节点或根节点数组(即整棵树)
 * 如果value为Array,代表根节点数组
 * 如果value为Object,代表子节点
 */
const _treeWeakMap = new WeakMap<TreeNode, TreeNode | TreeNode[]>();

// 使用weakmap构建树形数据数据索引
export const useTree = (
  props: Props = {
    // 外部数据对象字段映射
    treeNodeProp: {
      value: 'value',
      label: 'label',
      children: 'children',
    }
  }
) => {
  const { treeNodeProp } = props;

  // 设置节点的父节点映射。为防止用户错误使用,不向外暴露,内部使用
  function _setParent(node: TreeNode, parent: TreeNode | TreeNode[]) {
    _treeWeakMap.set(node, parent);
  }

  // 判断是否根节点
  function _isRootNode(node: TreeNode | TreeNode[]) {
    return Array.isArray(node);
  }

  // 初始化树结构索引
  function initTree(list: TreeNode[], parent?: TreeNode) {
    list.forEach((item) => {
      // 根节点的父级为完整的树数据,在删除根节点时需要通过完整的数组删除
      _treeWeakMap.set(item, parent || list);
      if (item[treeNodeProp.children]) {
        // 删除子节点只需要通过对应子节点的children数组删除
        initTree(item[treeNodeProp.children], item);
      }
    });
  }

  // 添加子节点或根节点,节点不存在.children时,代表添加到根节点数组
  function addChild(node: TreeNode, child: TreeNode) {
    if(_isRootNode(node)) {
      // 根节点数组直接添加
      const i = node.push(child) - 1;
      /**
       * 为新增加的子节点构建一个WeakMap索引,指向父节点
       * 注意:注册为key的引用地址是经过proxy处理后的节点
       */
      node[i] && _setParent(node[i], node);
    } else {
      // 非根节点,添加到children数组
      if (!node[treeNodeProp.children]) {
        node[treeNodeProp.children] = [];
      }
      const i = node[treeNodeProp.children].push(child) - 1;
      // 和上面设置根节点同理
      node.children[i] && _setParent(node.children[i], node);
    }
  }

  // 删除指定子节点
  function removeChild(node: TreeNode) {
    // 找到父节点,通过父节点删除
    const parent = getParent(node);
    if(!parent) {
      // 没有找到父级节点,抛出错误
      throw new Error('没有找到父级节点!');
    }
    if(_isRootNode(parent)) {
      // 删除根节点
      const index = parent.findIndex((item: TreeNode) => item === node);
      if(index >= 0) {
        parent.splice(index, 1);
      } else {
        // 没有找到要删除的根节点,抛出错误
        throw new Error('没有找到要删除的根节点!');
      }
    } else {
      // 通过找到父级删除自己
      parent.children = parent.children.filter((item: TreeNode) => item !== node);
    }
  }

  // 获取节点的父节点
  function getParent(node: TreeNode) {
    return _treeWeakMap.get(node);
  }

  // 获取节点的所有父节点
  function getParents(item: TreeNode, parentList: (TreeNode | string)[] = [], key?: string): (TreeNode | string)[] {
    const parent = _treeWeakMap.get(item);
    // 递归到根节点数组时停止
    if (parent && !_isRootNode(parent)) {
      parentList.push(key ? parent[key] : parent);
      getParents(parent, parentList, key);
    }
    return parentList;
  }

  // 获取节点的所有父节点label数组
  function getParentLabels(item: TreeNode, labelList: string[] = []): string[] {
    return getParents(item, labelList, treeNodeProp.label) as string[];
  }

  // 获取节点的所有父节点value数组
  function getParentValues(item: TreeNode, valueList: string[] = []): string[] {
    return getParents(item, valueList, treeNodeProp.value) as string[];
  }

  return {
    getParents,
    getParentLabels,
    getParentValues,
    getParent,
    initTree,
    addChild,
    removeChild,
  };
};

六、总结

通过 WeakMap 实现的 useTree,核心优势在于解耦。它将“业务数据”与“层级关系”分离开来,既保证了数据的纯净,又获得了极高的查找性能。

WeakMap 作为一个容易被忽视的原生特性,在处理这类关联关系映射时有着天然的优势。

如果你也在为复杂树结构的管理发愁,不妨尝试下这种“影子索引”的思路。

如有不足或可优化的地方,欢迎在评论区交流讨论,如果觉得有用,点个👍也挺好的!👏

如果你在处理大型树形表格或复杂的组织架构,这个 Hook 绝对是你的提效神器!

七、预览和源码地址

多场景演示 (Demo)

项目源码地址

github:https://github.com/java6688/Tree_By_WeakMap

构建无障碍组件之Link Pattern

Link Pattern 详解:构建无障碍链接组件

链接是 Web 页面中最核心的导航元素,它将用户从一个资源引导到另一个资源。根据 W3C WAI-ARIA Link Pattern 规范,正确实现的链接组件不仅要提供清晰的导航功能,更要确保所有用户都能顺利访问,包括依赖屏幕阅读器等辅助技术的用户。本文将深入探讨 Link Pattern 的核心概念、实现要点以及最佳实践。

一、链接的定义与核心功能

链接是一个允许用户导航到另一个页面、页面位置或其他资源的界面组件。链接的本质是超文本引用,它告诉用户这里有你可能感兴趣的另一个资源。与按钮执行动作不同,链接的作用是导航,这是两者最本质的区别。

在实际开发中,浏览器为原生 HTML 链接提供了丰富的功能支持,例如在新窗口中打开目标页面、将目标 URL 复制到系统剪贴板等。因此,应尽可能使用 HTML a 元素创建链接

二、何时需要自定义链接实现

在某些情况下,需要使用非 a 元素实现链接功能,例如:

  • 图片作为导航入口
  • 使用 CSS 伪元素创建的可视化链接
  • 复杂的 UI 组件中需要链接行为的元素

根据 WAI-ARIA 规范,当必须使用非 a 元素时,需要手动添加必要的 ARIA 属性和键盘支持。

三、键盘交互规范

键盘可访问性是 Web 无障碍设计的核心要素之一。链接组件必须支持完整的键盘交互,确保无法使用鼠标的用户也能顺利操作。根据 Link Pattern 规范:

回车键是激活链接的主要方式。当用户按下回车键时,链接被触发执行导航操作。

上下文菜单(可选):按 Shift + F10 键可以打开链接的上下文菜单,提供复制链接地址、在新窗口中打开等选项。

操作系统 打开上下文菜单
Windows Shift + F10Menu
macOS Control + 点击

四、WAI-ARIA 角色、状态和属性

正确使用 WAI-ARIA 属性是构建无障碍链接组件的技术基础。

角色声明是基础要求。非 a 元素的链接需要将 role 属性设置为 link,向辅助技术表明这是一个链接组件。

示例:使用 span 元素模拟链接:

<span
  tabindex="0"
  role="link"
  onclick="goToLink(event, 'https://example.com/')"
  onkeydown="goToLink(event, 'https://example.com/')">
  示例网站
</span>

可访问名称是链接最重要的可访问性特征之一。链接必须有可访问的名称,可以通过元素文本内容、aria-label 或 alt 属性提供。

示例 1:使用 img 元素作为链接时,通过 alt 属性提供可访问名称:

<img
  tabindex="0"
  role="link"
  onclick="goToLink(event, 'https://example.com/')"
  onkeydown="goToLink(event, 'https://example.com/')"
  src="logo.png"
  alt="示例网站" />

示例 2:使用 aria-label 为链接提供可访问名称:

<span
  tabindex="0"
  role="link"
  class="text-link"
  onclick="goToLink(event, 'https://example.com/')"
  onkeydown="goToLink(event, 'https://example.com/')"
  aria-label="访问示例网站"
  >🔗</span
>

焦点管理需要使用 tabindex="0",将链接元素包含在页面 Tab 序列中,使其可通过键盘聚焦。

五、完整示例

以下是使用不同元素实现链接的完整示例:

<!-- 示例 1:span 元素作为链接 -->
<span
  tabindex="0"
  role="link"
  onclick="goToLink(event, 'https://w3.org/')"
  onkeydown="goToLink(event, 'https://w3.org/')">
  W3C 网站
</span>

<!-- 示例 2:img 元素作为链接 -->
<img
  tabindex="0"
  role="link"
  onclick="goToLink(event, 'https://w3.org/')"
  onkeydown="goToLink(event, 'https://w3.org/')"
  src="logo.svg"
  alt="W3C 网站" />

<!-- 示例 3:使用 aria-label 的链接 -->
<span
  tabindex="0"
  role="link"
  class="link-styled"
  onclick="goToLink(event, 'https://w3.org/')"
  onkeydown="goToLink(event, 'https://w3.org/')"
  aria-label="W3C 网站"
  >🔗</span
>

<script>
  function goToLink(event, url) {
    if (event.type === 'keydown' && event.key !== 'Enter') {
      return;
    }
    window.open(url, '_blank');
  }
</script>

<style>
  .link-styled {
    color: blue;
    text-decoration: underline;
    cursor: pointer;
  }
  .link-styled:focus {
    outline: 2px solid blue;
    outline-offset: 2px;
  }
</style>

六、最佳实践

6.1 优先使用原生元素

尽可能使用原生 HTML a 元素创建链接。浏览器为原生链接提供了丰富的功能和更好的兼容性,无需额外添加 ARIA 属性。

<!-- 推荐做法 -->
<a
  href="https://example.com/"
  target="_blank"
  >访问示例</a
>

<!-- 不推荐做法 -->
<span
  role="link"
  tabindex="0"
  >访问示例</span
>

6.2 正确处理键盘事件

自定义链接需要同时处理 onclick 和 onkeydown 事件,确保用户可以通过回车键激活链接。

element.addEventListener('keydown', function (e) {
  if (e.key === 'Enter') {
    e.preventDefault();
    // 执行导航操作
    window.location.href = this.dataset.url;
  }
});

6.3 提供视觉反馈

链接应该有明确的视觉样式,让用户能够识别这是一个可交互的元素。同时,应该提供键盘焦点样式。

a,
[role='link'] {
  color: #0066cc;
  text-decoration: underline;
  cursor: pointer;
}

/* 焦点状态:仅对键盘 Tab 导航显示焦点框,鼠标点击时不显示 */
a:focus-visible,
[role='link']:focus-visible {
  outline: 2px solid #0066cc;
  outline-offset: 2px;
  border-radius: 2px;
}

/* 悬停状态:加深颜色并加粗下划线,提供鼠标交互反馈 */
a:hover,
[role='link']:hover {
  color: #004499;
  text-decoration-thickness: 2px;
}

/* 已访问状态:使用紫色标识用户已访问的链接 */
a:visited,
[role='link']:visited {
  color: #551a8b;
}

/* 激活状态:点击瞬间的颜色变化 */
a:active,
[role='link']:active {
  color: #ff0000;
}

6.4 避免过度使用 ARIA

WAI-ARIA 有一条重要原则:没有 ARIA 比糟糕的 ARIA 更好。在某些情况下,错误使用 ARIA 可能会导致比不使用更糟糕的可访问性体验。只有在确实需要时才使用自定义链接实现。

七、链接与按钮的区别

在 Web 开发中,正确区分按钮和链接至关重要。

特性 链接 按钮
功能 导航到其他资源 触发动作
HTML 元素 <a> <button><input type="button">
键盘激活 Enter Space、Enter
role 属性 link button
典型用途 页面跳转、锚点导航 提交表单、打开对话框

八、总结

构建无障碍的链接组件需要关注多个层面的细节。从语义化角度,应优先使用原生 HTML a 元素;从键盘交互角度,必须支持回车键激活;从 ARIA 属性角度,需要正确使用 role="link" 和可访问名称。

WAI-ARIA Link Pattern 为我们提供了清晰的指导方针,遵循这些规范能够帮助我们创建更加包容和易用的 Web 应用。每一个正确实现的链接组件,都是构建无障碍网络环境的重要一步。

type-challenges(ts类型体操): 7 - 对象属性只读

7 - 对象属性只读

by Anthony Fu (@antfu) #简单 #built-in #readonly #object-keys

题目

不要使用内置的Readonly<T>,自己实现一个。

泛型 Readonly<T> 会接收一个 泛型参数,并返回一个完全一样的类型,只是所有属性都会是只读 (readonly) 的。

也就是不可以再对该对象的属性赋值。

例如:

interface Todo {
  title: string
  description: string
}

const todo: MyReadonly<Todo> = {
  title: "Hey",
  description: "foobar"
}

todo.title = "Hello" // Error: cannot reassign a readonly property
todo.description = "barFoo" // Error: cannot reassign a readonly property

在 Github 上查看:tsch.js.org/7/zh-CN

代码

/* _____________ 你的代码 _____________ */

type MyReadonly<T> = {
  readonly [P in keyof T]: T[P]
}

关键解释:

  • readonly 关键字用于将属性设置为只读,即不能对其进行赋值操作;
  • [P in keyof T] 用于遍历泛型 T 的所有属性键;
  • T[P] 用于获取属性 P 的类型。

相关知识点

readonly

  • 核心作用:标记后,目标(属性 / 数组 / 元组)只能在初始化阶段赋值(比如接口实例化、类构造函数、变量声明时),后续任何修改操作都会被 TS 编译器拦截报错;
  • 运行时特性:readonly 仅做编译时检查,不会生成任何额外 JS 代码,也无法真正阻止运行时的修改(比如通过类型断言绕开的话,运行时仍能改);
  • const 的区别:const 是变量层面的不可重新赋值(但变量指向的对象 / 数组内部属性仍可改),readonly 是属性 / 类型层面的不可修改(变量本身可重新赋值,除非变量也用 const)。

常用使用场景:

  1. 作用于接口 / 类型别名的属性(最基础)
// 定义带只读属性的接口
interface User {
  readonly id: number; // 只读属性:只能初始化赋值,后续不可改
  name: string; // 普通属性:可修改
}

// 初始化时赋值(合法)
const user: User = { id: 1, name: "张三" };

// 尝试修改只读属性(报错)
user.id = 2; // ❌ 报错:无法分配到 "id",因为它是只读属性
// 修改普通属性(合法)
user.name = "李四"; // ✅ 合法
  1. 作用于类的属性: 类中使用 readonly 标记属性,只能在声明时构造函数中赋值,后续无法修改
class Person {
  readonly id: number; // 只读属性
  name: string;

  // 构造函数中给 readonly 属性赋值(唯一合法的后续赋值方式)
  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  updateInfo() {
    this.id = 100; // ❌ 报错:id 是只读属性
    this.name = "王五"; // ✅ 合法
  }
}

const person = new Person(1, "赵六");
person.id = 2; // ❌ 报错:只读属性不可修改
  1. 作用于数组 / 元组(只读数组): readonly 可标记数组为 “只读数组”,禁止修改数组元素、调用 push/pop 等修改方法
// 方式1:使用 readonly 修饰数组类型
const arr1: readonly number[] = [1, 2, 3];
arr1.push(4); // ❌ 报错:readonly 数组不存在 push 方法
arr1[0] = 10; // ❌ 报错:无法修改只读数组的元素

// 方式2:使用 ReadonlyArray<T> 类型(等价于 readonly T[])
const arr2: ReadonlyArray<string> = ["a", "b"];
arr2.pop(); // ❌ 报错

// 作用于元组(只读元组)
type Point = readonly [number, number];
const point: Point = [10, 20];
point[0] = 30; // ❌ 报错:只读元组元素不可修改
  1. 结合 keyof + in 批量创建只读类型(映射类型)
interface Product {
  name: string;
  price: number;
  stock: number;
}

// 批量创建只读版本的 Product(TS 内置的 Readonly<T> 就是这么实现的)
type ReadonlyProduct = {
  readonly [K in keyof Product]: Product[K];
};

const product: ReadonlyProduct = { name: "手机", price: 2999, stock: 100 };
product.price = 3999; // ❌ 报错:price 是只读属性

// TS 内置了 Readonly<T>,可直接使用(无需手动写映射类型)
const product2: Readonly<Product> = { name: "电脑", price: 5999, stock: 50 };
product2.stock = 60; // ❌ 报错
  1. 只读索引签名:如果类型使用索引签名,也可以标记为 readonly,禁止通过索引修改属性
// 只读索引签名:只能读取,不能修改
type ReadonlyDict = {
  readonly [key: string]: number;
};

const dict: ReadonlyDict = { a: 1, b: 2 };
dict["a"] = 3; // ❌ 报错:索引签名是只读的
console.log(dict["b"]); // ✅ 合法:仅读取

keyof

keyof 操作符用于获取对象类型的所有属性名(包括索引签名),并将其转换为联合类型。

例如:

interface Todo {
  title: string
  description: string
  completed: boolean
}

type TodoKeys = keyof Todo // "title" | "description" | "completed"

in

in 操作符用于遍历联合类型中的每个成员。

例如:

interface Todo {
  title: string
  description: string
  completed: boolean
}

type TodoKeys = 'title' | 'description' | 'completed'

type TodoPreview = {
  [P in TodoKeys]: Todo[P]
}
// TodoPreview 类型为:
// {
//   title: string
//   description: string
//   completed: boolean
// }

测试用例

/* _____________ 测试用例 _____________ */
import type { Equal, Expect } from '@type-challenges/utils'

type cases = [
  Expect<Equal<MyReadonly<Todo1>, Readonly<Todo1>>>,
]

interface Todo1 {
  title: string
  description: string
  completed: boolean
  meta: {
    author: string
  }
}

相关链接

分享你的解答:tsch.js.org/7/answer/zh… 查看解答:tsch.js.org/7/solutions 更多题目:tsch.js.org/zh-CN

下面是我的公众号,欢迎关注。关注后有新的功能点会及时收到推送。

实战为王!专注于汇总各种功能点,致力于打造一系列能够帮助工程师实现各种功能的想法思路的优质文章。

前端功能点

python dijkstra

Problem: 100156. 转换字符串的最小成本 I

[TOC]

思路

python dijkstra

解题方法

python dijkstra

Code

###Python3

class Solution:
    def minimumCost(self, source: str, target: str, original: List[str], changed: List[str], cost: List[int]) -> int:
        ans=0
        g=[[] for _ in range(26)]
        for i,j,c in zip(original,changed,cost):
            heappush(g[ord(i)-ord('a')],[c,ord(j)-ord('a')])
        d=defaultdict(int)
        def dijkstra(x,y):#dijkstra
            distant=0
            vis=set()
            q=[]
            q.append([0,x])
            while q:
                c,temp=heappop(q)
                if temp==y:
                    return c
                if temp in vis:
                    continue
                vis.add(temp)
                for cc,xx in g[temp]:
                    heappush(q,[c+cc,xx])
            return -1
        for i,j in zip(source,target):
            if i!=j:
                if (ord(i)-ord('a'),ord(j)-ord('a')) in d:
                    ans+=d[ord(i)-ord('a'),ord(j)-ord('a')]
                else :
                    res=dijkstra(ord(i)-ord('a'),ord(j)-ord('a'))
                    if res==-1:
                        return -1
                    ans+=res
                    d[ord(i)-ord('a'),ord(j)-ord('a')]=res
        return ans

Floyd 算法(Python/Java/C++/Go)

建图,从 $\textit{original}[i]$ 向 $\textit{changed}[i]$ 连边,边权为 $\textit{cost}[i]$。

然后用 Floyd 算法求图中任意两点最短路,得到 $\textit{dis}$ 矩阵,原理请看 带你发明 Floyd 算法!包含为什么循环顺序是 $kij$ 的讲解。

这里得到的 $\textit{dis}[i][j]$ 表示字母 $i$ 通过若干次替换操作变成字母 $j$ 的最小成本。

最后累加所有 $\textit{dis}[\textit{original}[i]][\textit{changed}[i]]$,即为答案。如果答案为无穷大,返回 $-1$。

本题视频讲解

###py

class Solution:
    def minimumCost(self, source: str, target: str, original: List[str], changed: List[str], cost: List[int]) -> int:
        dis = [[inf] * 26 for _ in range(26)]
        for i in range(26):
            dis[i][i] = 0

        for x, y, c in zip(original, changed, cost):
            x = ord(x) - ord('a')
            y = ord(y) - ord('a')
            dis[x][y] = min(dis[x][y], c)

        for k in range(26):
            for i in range(26):
                if dis[i][k] == inf:
                    continue  # 巨大优化!
                for j in range(26):
                    dis[i][j] = min(dis[i][j], dis[i][k] + dis[k][j])

        ans = sum(dis[ord(x) - ord('a')][ord(y) - ord('a')] for x, y in zip(source, target))
        return ans if ans < inf else -1

###java

class Solution {
    public long minimumCost(String source, String target, char[] original, char[] changed, int[] cost) {
        final int INF = Integer.MAX_VALUE / 2;
        int[][] dis = new int[26][26];
        for (int i = 0; i < 26; i++) {
            Arrays.fill(dis[i], INF);
            dis[i][i] = 0;
        }
        for (int i = 0; i < cost.length; i++) {
            int x = original[i] - 'a';
            int y = changed[i] - 'a';
            dis[x][y] = Math.min(dis[x][y], cost[i]);
        }
        for (int k = 0; k < 26; k++) {
            for (int i = 0; i < 26; i++) {
                if (dis[i][k] == INF) {
                    continue; // 巨大优化!
                }
                for (int j = 0; j < 26; j++) {
                    dis[i][j] = Math.min(dis[i][j], dis[i][k] + dis[k][j]);
                }
            }
        }

        long ans = 0;
        for (int i = 0; i < source.length(); i++) {
            int d = dis[source.charAt(i) - 'a'][target.charAt(i) - 'a'];
            if (d == INF) {
                return -1;
            }
            ans += d;
        }
        return ans;
    }
}

###cpp

class Solution {
public:
    long long minimumCost(string source, string target, vector<char>& original, vector<char>& changed, vector<int>& cost) {
        const int INF = 0x3f3f3f3f;
        int dis[26][26];
        memset(dis, 0x3f, sizeof(dis));
        for (int i = 0; i < 26; i++) {
            dis[i][i] = 0;
        }
        for (int i = 0; i < cost.size(); i++) {
            int x = original[i] - 'a';
            int y = changed[i] - 'a';
            dis[x][y] = min(dis[x][y], cost[i]);
        }
        for (int k = 0; k < 26; k++) {
            for (int i = 0; i < 26; i++) {
                if (dis[i][k] == INF) {
                    continue; // 巨大优化!
                }
                for (int j = 0; j < 26; j++) {
                    dis[i][j] = min(dis[i][j], dis[i][k] + dis[k][j]);
                }
            }
        }

        long long ans = 0;
        for (int i = 0; i < source.length(); i++) {
            int d = dis[source[i] - 'a'][target[i] - 'a'];
            if (d == INF) {
                return -1;
            }
            ans += d;
        }
        return ans;
    }
};

###go

func minimumCost(source, target string, original, changed []byte, cost []int) (ans int64) {
const inf = math.MaxInt / 2
dis := [26][26]int{}
for i := range dis {
for j := range dis[i] {
if j != i {
dis[i][j] = inf
}
}
}
for i, c := range cost {
x := original[i] - 'a'
y := changed[i] - 'a'
dis[x][y] = min(dis[x][y], c)
}
for k := range dis {
for i := range dis {
if dis[i][k] == inf {
continue // 巨大优化!
}
for j := range dis {
dis[i][j] = min(dis[i][j], dis[i][k]+dis[k][j])
}
}
}

for i, b := range source {
d := dis[b-'a'][target[i]-'a']
if d == inf {
return -1
}
ans += int64(d)
}
return
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n+m+|\Sigma|^3)$,其中 $n$ 为 $\textit{source}$ 的长度,$m$ 为 $\textit{cost}$ 的长度,$|\Sigma|$ 为字符集合的大小,本题中字符均为小写字母,所以 $|\Sigma|=26$。
  • 空间复杂度:$\mathcal{O}(|\Sigma|^2)$。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/最短路/最小生成树/二分图/基环树/欧拉路径)
  7. 动态规划(入门/背包/状态机/划分/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

Floyd 求最短路

解法:Floyd 求最短路

本题使用到了 Floyd 求最短路算法。关于这个算法,我给力扣专栏写过一篇文章进行了详细的介绍,欢迎有兴趣的读者阅读:https://zhuanlan.zhihu.com/p/623757829

由于本题只能将单个字符改为其它字符,所以不同位置之间的修改互不影响,那么答案就是把 source[i] 改成 target[i] 的代价加起来,即 $\sum\limits_{i = 1}^n g(s_i, t_i)$,其中 $g(s_i, t_i)$ 是把字符 $s_i$ 改成 $t_i$ 的最小代价。

我们把每个字母看成一个点,如果能把字母 $x$ 改成字母 $y$,就从 $x$ 到 $y$ 连一条长度为 cost[i] 的有向边。这样对于任意两个字母 $x$ 和 $y$,把 $x$ 改成 $y$ 的最小代价就是从 $x$ 到 $y$ 的最短路。

由于我们需要知道任意两个点之间的最短路,所以可以使用 Floyd 算法。

复杂度 $\mathcal{O}(n + m + \Sigma^3)$,其中 $n$ 是 source 的长度,$m$ 是 original 的长度,$\Sigma$ 是字符集的大小,即 $26$。

参考代码(c++)

###c++

class Solution {
public:
    long long minimumCost(string source, string target, vector<char>& original, vector<char>& changed, vector<int>& cost) {
        const int INF = 1e9;
        // 建有向图
        long long g[26][26];
        for (int i = 0; i < 26; i++) for (int j = 0; j < 26; j++) g[i][j] = INF;
        for (int i = 0; i < 26; i++) g[i][i] = 0;
        for (int i = 0; i < original.size(); i++) {
            int x = original[i] - 'a', y = changed[i] - 'a';
            g[x][y] = min(g[x][y], 1LL * cost[i]);
        }

        // floyd 求最短路
        for (int k = 0; k < 26; k++) for (int i = 0; i < 26; i++) for (int j = 0; j < 26; j++)
            g[i][j] = min(g[i][j], g[i][k] + g[k][j]);

        long long ans = 0;
        // 把每个位置的修改代价加起来
        for (int i = 0; i < source.size(); i++) {
            int x = source[i] - 'a', y = target[i] - 'a';
            if (x != y) {
                // x 不能变成 y,无解
                if (g[x][y] >= INF) return -1;
                // 否则答案增加把 x 改成 y 的最小代价
                ans += g[x][y];
            }
        }
        return ans;
    }
};

React Native新架构之iOS端初始化源码分析

React Native新架构之iOS端初始化源码分析

前言

注意,本文是基于React Native 0.83版本源码进行分析。有了前面几篇Android端分析文章打基础,再来理解iOS端就易如反掌了。总的来说,iOS端的实现要比Android端简单太多了,这是因为Android端的Java/Kotlin 都是运行在Java的虚拟机环境中,其与C++通信要经过JNI,并不如OC与C++互调那么简单直接。此外,RN基于Android的Gradle构建机制,也使问题更加复杂。

初始化流程

React Native的iOS端相比于安卓端要简单很多。现在我们基于Swift入口来分析一下初始化流程。首先找到RN源码工程中的测试工程,打开源码react-native/private/helloworld/ios/HelloWorld/AppDelegate.swift

import React                          // React Native 核心模块
import ReactAppDependencyProvider     // Codegen 生成的依赖提供者
import React_RCTAppDelegate           // AppDelegate 相关类
import UIKit

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
  var window: UIWindow?

  var reactNativeDelegate: ReactNativeDelegate?
  var reactNativeFactory: RCTReactNativeFactory?

  func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
  ) -> Bool {
    // 1: 创建 ReactNativeDelegate
    let delegate = ReactNativeDelegate()
    // 2: 创建 RCTReactNativeFactory
    let factory = RCTReactNativeFactory(delegate: delegate)
    // 3: 配置依赖提供者(Codegen 生成的模块)
    delegate.dependencyProvider = RCTAppDependencyProvider()

    // 保持强引用
    reactNativeDelegate = delegate
    reactNativeFactory = factory

    // 4: 配置开发菜单(仅 DEBUG 模式)
    #if DEBUG
    let devMenuConfiguration = RCTDevMenuConfiguration(
      devMenuEnabled: true,
      shakeGestureEnabled: true,
      keyboardShortcutsEnabled: true
    )
    reactNativeFactory?.devMenuConfiguration = devMenuConfiguration
    #endif

    // 5: 创建主窗口
    window = UIWindow(frame: UIScreen.main.bounds)

    // 6: 启动 React Native
    factory.startReactNative(
      withModuleName: "HelloWorld",
      in: window,
      launchOptions: launchOptions
    )

    return true
  }
}

class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
  // 旧架构兼容(新架构中会调用 bundleURL)
  override func sourceURL(for bridge: RCTBridge) -> URL? {
    self.bundleURL()
  }

  // 提供 JS Bundle 的 URL
  override func bundleURL() -> URL? {
    #if DEBUG
    // 开发模式:从 Metro 开发服务器加载
    RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
    #else
    // 生产模式:从 App Bundle 加载
    Bundle.main.url(forResource: "main", withExtension: "jsbundle")
    #endif
  }
}

Swift 新架构使用 @main 注解作为应用入口,无需 main.mmain.swift 文件。ReactNativeDelegate 继承自 RCTDefaultReactNativeFactoryDelegate,负责提供 Bundle URL 和自定义配置。

这里RCTAppDependencyProvider是Objective-C++代码,这是为了方便与C++互调。源码tcode/react-native/packages/react-native/Libraries/AppDelegate/RCTReactNativeFactory.mm

@interface RCTReactNativeFactory () <
    RCTComponentViewFactoryComponentProvider, // Fabric 组件提供者
    RCTHostDelegate,                          // RCTHost 代理
    RCTJSRuntimeConfiguratorProtocol,         // JS Runtime 配置
    RCTTurboModuleManagerDelegate>            // TurboModule 管理器代理
@end

@implementation RCTReactNativeFactory

- (instancetype)initWithDelegate:(id<RCTReactNativeFactoryDelegate>)delegate
{
  return [self initWithDelegate:delegate releaseLevel:Stable];
}

- (instancetype)initWithDelegate:(id<RCTReactNativeFactoryDelegate>)delegate releaseLevel:(RCTReleaseLevel)releaseLevel
{
  if (self = [super init]) {
    self.delegate = delegate;
    [self _setUpFeatureFlags:releaseLevel];
    // 设置默认颜色空间
    [RCTColorSpaceUtils applyDefaultColorSpace:[self defaultColorSpace]];
    RCTEnableTurboModule(YES);

    // 创建 RCTRootViewFactory
    self.rootViewFactory = [self createRCTRootViewFactory];
    // 设置第三方 Fabric 组件提供者
    [RCTComponentViewFactory currentComponentViewFactory].thirdPartyFabricComponentsProvider = self;
  }

  return self;
}

可以看到RCTReactNativeFactory的构造逻辑非常简单,但它实现了四个关键协议,这使得它可以作为多个子系统的代理。

先重点看一下createRCTRootViewFactory方法的实现,这是一个关键方法,同时设置了大量的回调 Block 来桥接代理模式:

- (RCTRootViewFactory *)createRCTRootViewFactory
{
  __weak __typeof(self) weakSelf = self;
  // 1. 创建 Bundle URL 提供者 Block
  RCTBundleURLBlock bundleUrlBlock = ^{
    auto *strongSelf = weakSelf;
    return strongSelf.bundleURL;
  };

  // 2. 创建配置对象(新架构默认全部启用)
  RCTRootViewFactoryConfiguration *configuration =
      [[RCTRootViewFactoryConfiguration alloc] initWithBundleURLBlock:bundleUrlBlock
                                                       newArchEnabled:YES
                                                   turboModuleEnabled:YES
                                                    bridgelessEnabled:YES];

  // 省略旧架构代码......

  // 3.配置根视图自定义回调
  configuration.customizeRootView = ^(UIView *_Nonnull rootView) {
    [weakSelf.delegate customizeRootView:(RCTRootView *)rootView];
  };

  // 4.配置 Bundle URL 获取回调
  configuration.sourceURLForBridge = ^NSURL *_Nullable(RCTBridge *_Nonnull bridge)
  {
    // 新架构:直接使用 bundleURL,不再依赖 bridge
    return [weakSelf.delegate bundleURL];
  };

  // 5.配置 Bundle 加载回调(支持进度)
  if ([self.delegate respondsToSelector:@selector(loadSourceForBridge:onProgress:onComplete:)]) {
    configuration.loadSourceForBridgeWithProgress =
        ^(RCTBridge *_Nonnull bridge,
          RCTSourceLoadProgressBlock _Nonnull onProgress,
          RCTSourceLoadBlock _Nonnull loadCallback) {
          // 新架构:使用 loadBundleAtURL 替代旧的 loadSourceForBridge
          [weakSelf.delegate loadBundleAtURL:self.bundleURL onProgress:onProgress onComplete:loadCallback];
        };
  }

  // 6.配置无进度的 Bundle 加载回调
  if ([self.delegate respondsToSelector:@selector(loadSourceForBridge:withBlock:)]) {
    configuration.loadSourceForBridge = ^(RCTBridge *_Nonnull bridge, RCTSourceLoadBlock _Nonnull loadCallback) {
      // 新架构:使用 loadBundleAtURL,进度回调为空
      [weakSelf.delegate loadBundleAtURL:self.bundleURL
                              onProgress:^(RCTLoadingProgress *progressData) {
                              }
                              onComplete:loadCallback];
    };
  }

  // 7.设置 JS Runtime 代理
  configuration.jsRuntimeConfiguratorDelegate = self;
  // 8.创建并返回 RCTRootViewFactory
  return [[RCTRootViewFactory alloc] initWithTurboModuleDelegate:self hostDelegate:self configuration:configuration];
}

这里的大概流程是非常清楚的,主要就是设置了四个协议的代理。我们将流程绘制成一个关系图:

┌─────────────────────────────────────────────────────────────────────────┐
│                        RCTReactNativeFactory                             │
│  ┌───────────────────────────────────────────────────────────────────┐  │
│  │ 实现协议:                                                          │  │
│  │  • RCTTurboModuleManagerDelegate    → TurboModule 类/实例查找      │  │
│  │  • RCTHostDelegate                  → Host 生命周期 + Bundle 加载  │  │
│  │  • RCTJSRuntimeConfiguratorProtocol → JS Runtime 工厂创建         │  │
│  │  • RCTComponentViewFactoryComponentProvider → Fabric 组件注册     │  │
│  └───────────────────────────────────────────────────────────────────┘  │
│                                    │                                     │
│                                    ▼                                     │
│  ┌───────────────────────────────────────────────────────────────────┐  │
│  │ createRCTRootViewFactory() 中设置:                                 │  │
│  │  • initWithTurboModuleDelegate: self  ← RCTTurboModuleManagerDelegate│
│  │  • hostDelegate: self                 ← RCTHostDelegate              │
│  │  • jsRuntimeConfiguratorDelegate: self ← RCTJSRuntimeConfiguratorProtocol│
│  └───────────────────────────────────────────────────────────────────┘  │
│                                    │                                     │
│                                    ▼                                     │
│  ┌───────────────────────────────────────────────────────────────────┐  │
│  │ 初始化时设置:                                                       │  │
│  │  • thirdPartyFabricComponentsProvider = self                       │  │
│  │    ← RCTComponentViewFactoryComponentProvider                      │  │
│  └───────────────────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────────────────┘

RCTTurboModuleManagerDelegate

主要负责 TurboModule 的类查找和实例创建,源码react-native/packages/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModuleManager.h

@class RCTBridgeProxy;
@class RCTTurboModuleManager;
@class RCTDevMenuConfigurationDecorator;

@protocol RCTTurboModuleManagerDelegate <NSObject>

/**
 * 给定模块名称,返回其实际类。如果返回 nil,则执行基本的 ObjC 类查找
 */
- (Class)getModuleClassFromName:(const char *)name;

/**
 * 给定一个模块类,为其提供一个实例。如果返回值为 nil,则使用默认初始化器
 */
- (id<RCTTurboModule>)getModuleInstanceFromClass:(Class)moduleClass;

@optional

/**
 * 此方法用于获取一个工厂对象,该对象可以创建 `facebook::react::TurboModule` 实例。
 * 实现 `RCTTurboModuleProvider` 接口的类必须是 Objective-C 类,以便我们可以使用代码生成工具对其进行动态初始化。
 */
- (id<RCTModuleProvider>)getModuleProvider:(const char *)name;

/**
 * 创建一个 TurboModule 实例,而无需依赖任何 ObjC++ 模块实例
 */
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const std::string &)name
                                                      jsInvoker:
                                                          (std::shared_ptr<facebook::react::CallInvoker>)jsInvoker;

- (void)installJSBindings:(facebook::jsi::Runtime &)runtime;

- (void)invalidate;

@end

再来看看RCTReactNativeFactory 中的实现:

#pragma mark - RCTTurboModuleManagerDelegate

- (Class)getModuleClassFromName:(const char *)name
{
#if RN_DISABLE_OSS_PLUGIN_HEADER
  return RCTTurboModulePluginClassProvider(name);
#else
  // 先尝试从 delegate 获取
  if ([_delegate respondsToSelector:@selector(getModuleClassFromName:)]) {
    Class moduleClass = [_delegate getModuleClassFromName:name];
    if (moduleClass != nil) {
      return moduleClass;
    }
  }
  return RCTCoreModulesClassProvider(name);
#endif
}

- (nullable id<RCTModuleProvider>)getModuleProvider:(const char *)name
{
  if ([_delegate respondsToSelector:@selector(getModuleProvider:)]) {
    return [_delegate getModuleProvider:name];
  }
  return nil;
}

// 创建纯 C++ TurboModule
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const std::string &)name
                                                      jsInvoker:(std::shared_ptr<facebook::react::CallInvoker>)jsInvoker
{
  if ([_delegate respondsToSelector:@selector(getTurboModule:jsInvoker:)]) {
    return [_delegate getTurboModule:name jsInvoker:jsInvoker];
  }

  return facebook::react::DefaultTurboModules::getTurboModule(name, jsInvoker);
}

- (id<RCTTurboModule>)getModuleInstanceFromClass:(Class)moduleClass
{
#if USE_OSS_CODEGEN
  if (self.delegate.dependencyProvider == nil) {
    [NSException raise:@"ReactNativeFactoryDelegate dependencyProvider is nil"
                format:@"Delegate must provide a valid dependencyProvider"];
  }
#endif
  if ([_delegate respondsToSelector:@selector(getModuleInstanceFromClass:)]) {
    id<RCTTurboModule> moduleInstance = [_delegate getModuleInstanceFromClass:moduleClass];
    if (moduleInstance != nil) {
      return moduleInstance;
    }
  }
  // 使用默认方式创建(通过 dependencyProvider)
  return RCTAppSetupDefaultModuleFromClass(moduleClass, self.delegate.dependencyProvider);
}

RCTHostDelegate

主要负责 RCTHost 的生命周期和 Bundle 加载。源码react-native/packages/react-native/ReactCommon/react/runtime/platform/ios/ReactCommon/RCTHost.h

NS_ASSUME_NONNULL_BEGIN

@class RCTFabricSurface;
@class RCTHost;
@class RCTModuleRegistry;
@class RCTDevMenuConfiguration;

@protocol RCTTurboModuleManagerDelegate;

typedef NSURL *_Nullable (^RCTHostBundleURLProvider)(void);

// Runtime API

@protocol RCTHostDelegate <NSObject>
// Host 启动完成通知
- (void)hostDidStart:(RCTHost *)host;

@optional
// 需要在主线程初始化的模块列表
- (NSArray<NSString *> *)unstableModulesRequiringMainQueueSetup;

// 加载 Bundle(支持进度回调)
- (void)loadBundleAtURL:(NSURL *)sourceURL
             onProgress:(RCTSourceLoadProgressBlock)onProgress
             onComplete:(RCTSourceLoadBlock)loadCallback;

@end


NS_ASSUME_NONNULL_END

查看RCTReactNativeFactory 中的实现:

#pragma mark - RCTHostDelegate

- (void)hostDidStart:(RCTHost *)host
{
  if ([_delegate respondsToSelector:@selector(hostDidStart:)]) {
    [_delegate hostDidStart:host];
  }
}

- (NSArray<NSString *> *)unstableModulesRequiringMainQueueSetup
{
#if RN_DISABLE_OSS_PLUGIN_HEADER
  return RCTTurboModulePluginUnstableModulesRequiringMainQueueSetup();
#else
  return self.delegate.dependencyProvider
      ? RCTAppSetupUnstableModulesRequiringMainQueueSetup(self.delegate.dependencyProvider)
      : @[];
#endif
}

RCTJSRuntimeConfiguratorProtocol

主要负责创建 JS Runtime 工厂,源码react-native/packages/react-native/Libraries/AppDelegate/RCTJSRuntimeConfiguratorProtocol.h

NS_ASSUME_NONNULL_BEGIN

// Forward declarations for umbrella headers.
// In implementations, import `<react/runtime/JSRuntimeFactoryCAPI.h>` to obtain the actual type.
typedef void *JSRuntimeFactoryRef;

@protocol RCTJSRuntimeConfiguratorProtocol

// 创建 JS Runtime 工厂(通常返回 Hermes)
- (JSRuntimeFactoryRef)createJSRuntimeFactory;

@end

NS_ASSUME_NONNULL_END

查看RCTReactNativeFactory 中的实现:

#pragma mark - RCTJSRuntimeConfiguratorProtocol

- (JSRuntimeFactoryRef)createJSRuntimeFactory
{
  // 委托给用户的 delegate
  return [_delegate createJSRuntimeFactory];
}

RCTComponentViewFactoryComponentProvider

主要负责提供第三方 Fabric 组件,源码react-native/packages/react-native/React/Fabric/Mounting/RCTComponentViewFactory.h

/**
 * 可用于向 Fabric 提供第三方组件的协议。
 * Fabric 将检查此映射表,以确定是否存在需要注册的组件。
 */
@protocol RCTComponentViewFactoryComponentProvider <NSObject>

/**
 * 返回一个第三方组件字典,其中 `key` 是组件处理程序,`value` 是一个符合 `RCTComponentViewProtocol` 协议的类
 */
- (NSDictionary<NSString *, Class<RCTComponentViewProtocol>> *)thirdPartyFabricComponents;

@end

查看RCTReactNativeFactory 中的实现:

#pragma mark - RCTComponentViewFactoryComponentProvider

- (NSDictionary<NSString *, Class<RCTComponentViewProtocol>> *)thirdPartyFabricComponents
{
  // 先尝试从 delegate 获取
  if ([_delegate respondsToSelector:@selector(thirdPartyFabricComponents)]) {
    return _delegate.thirdPartyFabricComponents;
  }
  // 回退到 dependencyProvider(Codegen 生成)
  return self.delegate.dependencyProvider ? self.delegate.dependencyProvider.thirdPartyFabricComponents : @{};
}

RCTDefaultReactNativeFactoryDelegate

以上协的很多方法的实现,其实也是代理给RCTReactNativeFactory 中的delegate对象。此delegate也就是我们最开始自定义的ReactNativeDelegate实例。其也是继承自RCTDefaultReactNativeFactoryDelegate,现在分析一下此类的实现,源码react-native/packages/react-native/Libraries/AppDelegate/RCTDefaultReactNativeFactoryDelegate.mm

@implementation RCTDefaultReactNativeFactoryDelegate

@synthesize dependencyProvider;
// 抽象方法:子类必须实现
- (NSURL *_Nullable)sourceURLForBridge:(nonnull RCTBridge *)bridge
{
  [NSException raise:@"RCTBridgeDelegate::sourceURLForBridge not implemented"
              format:@"Subclasses must implement a valid sourceURLForBridge method"];
  return nil;
}

- (UIViewController *)createRootViewController
{
  return [UIViewController new];
}

- (void)setRootView:(UIView *)rootView toRootViewController:(UIViewController *)rootViewController
{
  rootViewController.view = rootView;
}

- (JSRuntimeFactoryRef)createJSRuntimeFactory
{
#if USE_THIRD_PARTY_JSC != 1
  return jsrt_create_hermes_factory();
#endif
}

- (void)customizeRootView:(RCTRootView *)rootView
{
  // Override point for customization after application launch.
}

- (RCTColorSpace)defaultColorSpace
{
  return RCTColorSpaceSRGB;
}

- (NSURL *_Nullable)bundleURL
{
  [NSException raise:@"RCTAppDelegate::bundleURL not implemented"
              format:@"Subclasses must implement a valid getBundleURL method"];
  return nullptr;
}

- (NSDictionary<NSString *, Class<RCTComponentViewProtocol>> *)thirdPartyFabricComponents
{
  return (self.dependencyProvider != nullptr) ? self.dependencyProvider.thirdPartyFabricComponents : @{};
}

- (void)hostDidStart:(RCTHost *)host
{
}

- (NSArray<NSString *> *)unstableModulesRequiringMainQueueSetup
{
  return (self.dependencyProvider != nullptr)
      ? RCTAppSetupUnstableModulesRequiringMainQueueSetup(self.dependencyProvider)
      : @[];
}

- (nullable id<RCTModuleProvider>)getModuleProvider:(const char *)name
{
  NSString *providerName = [NSString stringWithCString:name encoding:NSUTF8StringEncoding];
  return (self.dependencyProvider != nullptr) ? self.dependencyProvider.moduleProviders[providerName] : nullptr;
}

- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const std::string &)name
                                                      jsInvoker:(std::shared_ptr<facebook::react::CallInvoker>)jsInvoker
{
  return facebook::react::DefaultTurboModules::getTurboModule(name, jsInvoker);
}

#pragma mark - RCTArchConfiguratorProtocol

- (BOOL)newArchEnabled
{
  return YES;
}

- (BOOL)bridgelessEnabled
{
  return YES;
}

- (BOOL)fabricEnabled
{
  return YES;
}

- (BOOL)turboModuleEnabled
{
  return YES;
}

- (Class)getModuleClassFromName:(const char *)name
{
  return nullptr;
}

- (id<RCTTurboModule>)getModuleInstanceFromClass:(Class)moduleClass
{
  return nullptr;
}

- (void)loadSourceForBridge:(RCTBridge *)bridge
                 onProgress:(RCTSourceLoadProgressBlock)onProgress
                 onComplete:(RCTSourceLoadBlock)loadCallback
{
  [RCTJavaScriptLoader loadBundleAtURL:[self sourceURLForBridge:bridge] onProgress:onProgress onComplete:loadCallback];
}

@end

React Native 启动

对于以上四个协议以及代理,我们已经有了大概认识,现在应该把视线拉回AppDelegate中的初始化流程,继续分析startReactNative方法的实现,它对应的OC方法名应该是startReactNativeWithModuleName

- (void)startReactNativeWithModuleName:(NSString *)moduleName
                              inWindow:(UIWindow *_Nullable)window
                         launchOptions:(NSDictionary *_Nullable)launchOptions
{
  [self startReactNativeWithModuleName:moduleName inWindow:window initialProperties:nil launchOptions:launchOptions];
}

- (void)startReactNativeWithModuleName:(NSString *)moduleName
                              inWindow:(UIWindow *_Nullable)window
                     initialProperties:(NSDictionary *_Nullable)initialProperties
                         launchOptions:(NSDictionary *_Nullable)launchOptions
{
  // 1. 通过 RootViewFactory 创建根视图
  UIView *rootView = [self.rootViewFactory viewWithModuleName:moduleName
                                            initialProperties:initialProperties
                                                launchOptions:launchOptions
                                         devMenuConfiguration:self.devMenuConfiguration];
  // 2. 创建 RootViewController
  UIViewController *rootViewController = [_delegate createRootViewController];
  // 3. 设置根视图
  [_delegate setRootView:rootView toRootViewController:rootViewController];
  // 4. 配置窗口并显示
  window.rootViewController = rootViewController;
  [window makeKeyAndVisible];
}

可以看到,流程十分清楚,我们继续跟踪viewWithModuleName方法实现。

RCTHost (ReactHost)初始化

查看源码react-native/packages/react-native/Libraries/AppDelegate/RCTRootViewFactory.mm

- (UIView *)viewWithModuleName:(NSString *)moduleName
             initialProperties:(NSDictionary *)initProps
                 launchOptions:(NSDictionary *)launchOptions
          devMenuConfiguration:(RCTDevMenuConfiguration *)devMenuConfiguration
{
  // 1. 初始化 ReactHost
  [self initializeReactHostWithLaunchOptions:launchOptions devMenuConfiguration:devMenuConfiguration];

  // 2. 创建 Fabric Surface
  RCTFabricSurface *surface = [self.reactHost createSurfaceWithModuleName:moduleName
                                                        initialProperties:initProps ? initProps : @{}];
   // 3. 创建 SurfaceHostingProxyRootView
  RCTSurfaceHostingProxyRootView *surfaceHostingProxyRootView =
      [[RCTSurfaceHostingProxyRootView alloc] initWithSurface:surface];

  surfaceHostingProxyRootView.backgroundColor = [UIColor systemBackgroundColor];
  if (_configuration.customizeRootView != nil) {
    // 4. 自定义根视图
    _configuration.customizeRootView(surfaceHostingProxyRootView);
  }
  return surfaceHostingProxyRootView;
}


- (void)initializeReactHostWithLaunchOptions:(NSDictionary *)launchOptions
                        devMenuConfiguration:(RCTDevMenuConfiguration *)devMenuConfiguration
{
  // Enable TurboModule interop by default in Bridgeless mode
  RCTEnableTurboModuleInterop(YES);
  RCTEnableTurboModuleInteropBridgeProxy(YES);

  [self createReactHostIfNeeded:launchOptions devMenuConfiguration:devMenuConfiguration];
  return;
}

- (void)createReactHostIfNeeded:(NSDictionary *)launchOptions
           devMenuConfiguration:(RCTDevMenuConfiguration *)devMenuConfiguration
{
  if (self.reactHost) {
    return;
  }
  self.reactHost = [self createReactHost:launchOptions devMenuConfiguration:devMenuConfiguration];
}


- (RCTHost *)createReactHost:(NSDictionary *)launchOptions
        devMenuConfiguration:(RCTDevMenuConfiguration *)devMenuConfiguration
{
  __weak __typeof(self) weakSelf = self;
  RCTHost *reactHost =
      [[RCTHost alloc] initWithBundleURLProvider:self->_configuration.bundleURLBlock
                                    hostDelegate:_hostDelegate
                      turboModuleManagerDelegate:_turboModuleManagerDelegate
                                jsEngineProvider:^std::shared_ptr<facebook::react::JSRuntimeFactory>() {
                                  return [weakSelf createJSRuntimeFactory];
                                }
                                   launchOptions:launchOptions
                            devMenuConfiguration:devMenuConfiguration];
  // 设置 Bundle URL 提供者
  [reactHost setBundleURLProvider:^NSURL *() {
    return [weakSelf bundleURL];
  }];
  // 启动 ReactHost
  [reactHost start];
  return reactHost;
}
RCTInstance 初始化

继续跟踪RCTHoststart方法。源码react-native/packages/react-native/ReactCommon/react/runtime/platform/ios/ReactCommon/RCTHost.mm

- (void)start
{
  // 1. 设置 Bundle URL
  if (_bundleURLProvider) {
    [self _setBundleURL:_bundleURLProvider()];
  }

  // 2. 设置 Inspector Target(用于调试)
  auto &inspectorFlags = jsinspector_modern::InspectorFlags::getInstance();
  if (inspectorFlags.getFuseboxEnabled() && !_inspectorPageId.has_value()) {
    _inspectorTarget =
        facebook::react::jsinspector_modern::HostTarget::create(*_inspectorHostDelegate, [](auto callback) {
          RCTExecuteOnMainQueue(^{
            callback();
          });
        });
    __weak RCTHost *weakSelf = self;
    _inspectorPageId = facebook::react::jsinspector_modern::getInspectorInstance().addPage(
        "React Native Bridgeless",
        /* vm */ "",
        [weakSelf](std::unique_ptr<facebook::react::jsinspector_modern::IRemoteConnection> remote)
            -> std::unique_ptr<facebook::react::jsinspector_modern::ILocalConnection> {
          RCTHost *strongSelf = weakSelf;
          if (!strongSelf) {
            // This can happen if we're about to be dealloc'd. Reject the connection.
            return nullptr;
          }
          return strongSelf->_inspectorTarget->connect(std::move(remote));
        },
        {.nativePageReloads = true, .prefersFuseboxFrontend = true});
  }
  if (_instance) {
    RCTLogWarn(
        @"RCTHost should not be creating a new instance if one already exists. This implies there is a bug with how/when this method is being called.");
    [_instance invalidate];
  }

  // 3. 创建 RCTInstance
  _instance = [[RCTInstance alloc] initWithDelegate:self
                                   jsRuntimeFactory:[self _provideJSEngine]
                                      bundleManager:_bundleManager
                         turboModuleManagerDelegate:_turboModuleManagerDelegate
                                     moduleRegistry:_moduleRegistry
                              parentInspectorTarget:_inspectorTarget.get()
                                      launchOptions:_launchOptions
                               devMenuConfiguration:_devMenuConfiguration];
  // 4. 通知代理 Host 已启动
  [_hostDelegate hostDidStart:self];
}

继续查看RCTInstance的构造实现,源码react-native/packages/react-native/ReactCommon/react/runtime/platform/ios/ReactCommon/RCTInstance.mm

- (instancetype)initWithDelegate:(id<RCTInstanceDelegate>)delegate
                jsRuntimeFactory:(std::shared_ptr<facebook::react::JSRuntimeFactory>)jsRuntimeFactory
                   bundleManager:(RCTBundleManager *)bundleManager
      turboModuleManagerDelegate:(id<RCTTurboModuleManagerDelegate>)tmmDelegate
                  moduleRegistry:(RCTModuleRegistry *)moduleRegistry
           parentInspectorTarget:(jsinspector_modern::HostTarget *)parentInspectorTarget
                   launchOptions:(nullable NSDictionary *)launchOptions
            devMenuConfiguration:(RCTDevMenuConfiguration *)devMenuConfiguration
{
  if (self = [super init]) {
    // 性能监控初始化
    _performanceLogger = [RCTPerformanceLogger new];
    registerPerformanceLoggerHooks(_performanceLogger);
    [_performanceLogger markStartForTag:RCTPLReactInstanceInit];

    // 保存外部传入的关键依赖
    _delegate = delegate;
    _jsRuntimeFactory = jsRuntimeFactory;
    _appTMMDelegate = tmmDelegate;
    _jsThreadManager = [RCTJSThreadManager new];

    // 开发菜单配置
    _devMenuConfigurationDecorator =
#if RCT_DEV_MENU
        [[RCTDevMenuConfigurationDecorator alloc] initWithDevMenuConfiguration:devMenuConfiguration];
#else
        nil;
#endif

    _parentInspectorTarget = parentInspectorTarget;

    // JS 模块调用器配置(设置 Bridgeless 模式下的 JS 模块方法调用器)
    {
      __weak __typeof(self) weakSelf = self;
      [_bridgeModuleDecorator.callableJSModules
          setBridgelessJSModuleMethodInvoker:^(
              NSString *moduleName, NSString *methodName, NSArray *args, dispatch_block_t onComplete) {
            [weakSelf callFunctionOnJSModule:moduleName method:methodName args:args];
            if (onComplete) {
              [weakSelf
                  callFunctionOnBufferedRuntimeExecutor:[onComplete](facebook::jsi::Runtime &_) { onComplete(); }];
            }
          }];
    }
    _launchOptions = launchOptions;

    // 内存警告监听
    [[NSNotificationCenter defaultCenter] addObserver:self
                                             selector:@selector(_handleMemoryWarning)
                                                 name:UIApplicationDidReceiveMemoryWarningNotification
                                               object:nil];
    // 启动核心初始化
    [self _start];
  }
  return self;
}

接下来,我们来看整个初始化过程的核心方法_start实现:

- (void)_start
{
  // 1.设置定时器系统
  auto objCTimerRegistry = std::make_unique<ObjCTimerRegistry>();
  auto timing = objCTimerRegistry->timing;
  auto *objCTimerRegistryRawPtr = objCTimerRegistry.get();

  auto timerManager = std::make_shared<TimerManager>(std::move(objCTimerRegistry));
  objCTimerRegistryRawPtr->setTimerManager(timerManager);

  __weak __typeof(self) weakSelf = self;
  auto onJsError = [=](jsi::Runtime &runtime, const JsErrorHandler::ProcessedError &error) {
    [weakSelf _handleJSError:error withRuntime:runtime];
  };

  // 2.创建 ReactInstance (C++ 层)
  _reactInstance = std::make_unique<ReactInstance>(
      _jsRuntimeFactory->createJSRuntime(_jsThreadManager.jsMessageThread),
      _jsThreadManager.jsMessageThread,
      timerManager,
      onJsError,
      _parentInspectorTarget);
  _valid = true;

  // 3.设置 RuntimeExecutor
  RuntimeExecutor bufferedRuntimeExecutor = _reactInstance->getBufferedRuntimeExecutor();
  timerManager->setRuntimeExecutor(bufferedRuntimeExecutor);

  auto jsCallInvoker = make_shared<RuntimeSchedulerCallInvoker>(_reactInstance->getRuntimeScheduler());
  // 省略旧架构......

  // 4.创建 TurboModuleManager
  _turboModuleManager = [[RCTTurboModuleManager alloc] initWithBridgeProxy:bridgeProxy
                                                     bridgeModuleDecorator:_bridgeModuleDecorator
                                                                  delegate:self
                                                                 jsInvoker:jsCallInvoker
                                             devMenuConfigurationDecorator:_devMenuConfigurationDecorator];

#if RCT_DEV
  /**
    * 实例化 DevMenu 会产生副作用,即通过 RCTDevMenu 注册
    * CMD + d、CMD + i 和 CMD + n 的快捷键。 
    * 因此,启用 TurboModules 时,我们必须手动创建此NativeModule。
   */
  [_turboModuleManager moduleForName:"RCTDevMenu"];
#endif // end RCT_DEV

  // 初始化 RCTModuleRegistry,以便 TurboModules 可以引用其他 TurboModules
  [_bridgeModuleDecorator.moduleRegistry setTurboModuleRegistry:_turboModuleManager];

  if (ReactNativeFeatureFlags::enableEagerMainQueueModulesOnIOS()) {
    /**
     * 某些原生模块需要在主线程上捕获 UIKit 对象。
     * 因此,请在此处的主队列上开始初始化这些模块。JavaScript 线程将等待这些模块初始化完成后,才会执行 JavaScript 代码包。
     */
    NSArray<NSString *> *modulesRequiringMainQueueSetup = [_delegate unstableModulesRequiringMainQueueSetup];

    std::shared_ptr<std::mutex> mutex = std::make_shared<std::mutex>();
    std::shared_ptr<std::condition_variable> cv = std::make_shared<std::condition_variable>();
    std::shared_ptr<bool> isReady = std::make_shared<bool>(false);

    _waitUntilModuleSetupComplete = ^{
      std::unique_lock<std::mutex> lock(*mutex);
      cv->wait(lock, [isReady] { return *isReady; });
    };

    // TODO(T218039767): 将性能日志记录功能集成到主队列模块初始化过程中
    RCTExecuteOnMainQueue(^{
      for (NSString *moduleName in modulesRequiringMainQueueSetup) {
        [self->_bridgeModuleDecorator.moduleRegistry moduleForName:[moduleName UTF8String]];
      }

      RCTScreenSize();
      RCTScreenScale();
      RCTSwitchSize();

      std::lock_guard<std::mutex> lock(*mutex);
      *isReady = true;
      cv->notify_all();
    });
  }

  RCTLogSetBridgelessModuleRegistry(_bridgeModuleDecorator.moduleRegistry);
  RCTLogSetBridgelessCallableJSModules(_bridgeModuleDecorator.callableJSModules);

  // 5.创建 ContextContainer
  auto contextContainer = std::make_shared<ContextContainer>();
  [_delegate didCreateContextContainer:contextContainer];
  // 插入核心模块
  contextContainer->insert(
      "RCTImageLoader", facebook::react::wrapManagedObject([_turboModuleManager moduleForName:"RCTImageLoader"]));
  contextContainer->insert(
      "RCTEventDispatcher",
      facebook::react::wrapManagedObject([_turboModuleManager moduleForName:"RCTEventDispatcher"]));
  contextContainer->insert("RCTBridgeModuleDecorator", facebook::react::wrapManagedObject(_bridgeModuleDecorator));
  contextContainer->insert(RuntimeSchedulerKey, std::weak_ptr<RuntimeScheduler>(_reactInstance->getRuntimeScheduler()));
  contextContainer->insert("RCTBridgeProxy", facebook::react::wrapManagedObject(bridgeProxy));

  // 6.创建 SurfacePresenter
  _surfacePresenter = [[RCTSurfacePresenter alloc]
        initWithContextContainer:contextContainer
                 runtimeExecutor:bufferedRuntimeExecutor
      bridgelessBindingsExecutor:std::optional(_reactInstance->getUnbufferedRuntimeExecutor())];

  // 这使得模块中的 RCTViewRegistry 能够通过其 viewForReactTag 方法返回 UIView 对象
  __weak RCTSurfacePresenter *weakSurfacePresenter = _surfacePresenter;
  [_bridgeModuleDecorator.viewRegistry_DEPRECATED setBridgelessComponentViewProvider:^UIView *(NSNumber *reactTag) {
    RCTSurfacePresenter *strongSurfacePresenter = weakSurfacePresenter;
    if (strongSurfacePresenter == nil) {
      return nil;
    }

    return [strongSurfacePresenter findComponentViewWithTag_DO_NOT_USE_DEPRECATED:reactTag.integerValue];
  }];

  // 7.创建DisplayLink (用于调用计时器回调函数)
  _displayLink = [RCTDisplayLink new];

  auto &inspectorFlags = jsinspector_modern::InspectorFlags::getInstance();

  ReactInstance::JSRuntimeFlags options = {
      .isProfiling = inspectorFlags.getIsProfilingBuild(),
      .runtimeDiagnosticFlags = [RCTInstanceRuntimeDiagnosticFlags() UTF8String]};

  // 8.初始化 JS Runtime 并加载 Bundle
  _reactInstance->initializeRuntime(options, [=](jsi::Runtime &runtime) {
    __strong __typeof(self) strongSelf = weakSelf;
    if (!strongSelf) {
      return;
    }

    // 安装 TurboModule JS 绑定
    [strongSelf->_turboModuleManager installJSBindings:runtime];
    // 绑定 Native Logger
    facebook::react::bindNativeLogger(runtime, [](const std::string &message, unsigned int logLevel) {
      _RCTLogJavaScriptInternal(static_cast<RCTLogLevel>(logLevel), [NSString stringWithUTF8String:message.c_str()]);
    });

    // 安装 Native Component Registry 绑定
    RCTInstallNativeComponentRegistryBinding(runtime);

    // 通知代理 Runtime 已初始化
    [strongSelf->_delegate instance:strongSelf didInitializeRuntime:runtime];

    // 设置 DisplayLink
    id<RCTDisplayLinkModuleHolder> moduleHolder = [[RCTBridgelessDisplayLinkModuleHolder alloc] initWithModule:timing];
    [strongSelf->_displayLink registerModuleForFrameUpdates:timing withModuleHolder:moduleHolder];
    [strongSelf->_displayLink addToRunLoop:[NSRunLoop currentRunLoop]];

    // 尝试同步加载bundle包,如果失败则回退到异步加载。
    [strongSelf->_performanceLogger markStartForTag:RCTPLScriptDownload];
    [strongSelf _loadJSBundle:[strongSelf->_bridgeModuleDecorator.bundleManager bundleURL]];
  });

  [_performanceLogger markStopForTag:RCTPLReactInstanceInit];
}
JS Bundle 加载

继续查看_loadJSBundle方法的实现:

- (void)_loadJSBundle:(NSURL *)sourceURL
{
#if RCT_DEV_MENU && __has_include(<React/RCTDevLoadingViewProtocol.h>)
  {
    // 显示加载视图
    id<RCTDevLoadingViewProtocol> loadingView =
        (id<RCTDevLoadingViewProtocol>)[_turboModuleManager moduleForName:"DevLoadingView"];
    [loadingView showWithURL:sourceURL];
  }
#endif

  __weak __typeof(self) weakSelf = self;
  // 通过代理加载 Bundle
  [_delegate loadBundleAtURL:sourceURL
      onProgress:^(RCTLoadingProgress *progressData) {
        __typeof(self) strongSelf = weakSelf;
        if (!strongSelf) {
          return;
        }

#if RCT_DEV_MENU && __has_include(<React/RCTDevLoadingViewProtocol.h>)
        id<RCTDevLoadingViewProtocol> loadingView =
            (id<RCTDevLoadingViewProtocol>)[strongSelf->_turboModuleManager moduleForName:"DevLoadingView"];
        [loadingView updateProgress:progressData];
#endif
      }
      onComplete:^(NSError *error, RCTSource *source) {
        __typeof(self) strongSelf = weakSelf;
        if (!strongSelf) {
          return;
        }

        if (error) {
          [strongSelf handleBundleLoadingError:error];
          return;
        }
        // _loadScriptFromSource 函数的回调函数需要 DevSettings 模块,因此需要提前进行初始化。
        RCTDevSettings *const devSettings =
            (RCTDevSettings *)[strongSelf->_turboModuleManager moduleForName:"DevSettings"];

        // 加载脚本
        [strongSelf _loadScriptFromSource:source];
        // 仅在开发环境中启用热模块重载功能。
        [strongSelf->_performanceLogger markStopForTag:RCTPLScriptDownload];
        [devSettings setupHMRClientWithBundleURL:sourceURL];
#if RCT_DEV
        [strongSelf _logOldArchitectureWarnings];
#endif
      }];
}


- (void)_loadScriptFromSource:(RCTSource *)source
{
  std::lock_guard<std::mutex> lock(_invalidationMutex);
  if (!_valid) {
    return;
  }

  auto script = std::make_unique<NSDataBigString>(source.data);
  const auto *url = deriveSourceURL(source.url).UTF8String;

  auto beforeLoad = [waitUntilModuleSetupComplete = self->_waitUntilModuleSetupComplete](jsi::Runtime &_) {
    if (waitUntilModuleSetupComplete) {
      waitUntilModuleSetupComplete();
    }
  };
  auto afterLoad = [](jsi::Runtime &_) {
    [[NSNotificationCenter defaultCenter] postNotificationName:@"RCTInstanceDidLoadBundle" object:nil];
  };
  // 在 ReactInstance 中执行脚本
  _reactInstance->loadScript(std::move(script), url, beforeLoad, afterLoad);
}
Surface 创建与启动

现在,我们把视线拉回viewWithModuleName方法中,继续分析后面的createSurfaceWithModuleName方法的实现。源码RCTHost.mm

- (RCTFabricSurface *)createSurfaceWithModuleName:(NSString *)moduleName initialProperties:(NSDictionary *)properties
{
  return [self createSurfaceWithModuleName:moduleName mode:DisplayMode::Visible initialProperties:properties];
}

- (RCTFabricSurface *)createSurfaceWithModuleName:(NSString *)moduleName
                                             mode:(DisplayMode)displayMode
                                initialProperties:(NSDictionary *)properties
{
  // 1. 创建 FabricSurface
  RCTFabricSurface *surface = [[RCTFabricSurface alloc] initWithSurfacePresenter:self.surfacePresenter
                                                                      moduleName:moduleName
                                                               initialProperties:properties];
  // 2. 设置显示模式
  surface.surfaceHandler.setDisplayMode(displayMode);

  // 3. 附加 Surface
  [self _attachSurface:surface];

  __weak RCTFabricSurface *weakSurface = surface;
  // 在 JS Bundle 执行完成后,使用BufferedRuntimeExecutor启动Surface
  [_instance callFunctionOnBufferedRuntimeExecutor:[weakSurface](facebook::jsi::Runtime &_) { [weakSurface start]; }];
  return surface;
}

总结

初始化的大概流程:

┌─────────────────────────────────────────────────────────────────┐
                        Swift 应用层                              
  ┌─────────────────────────────────────────────────────────────┐│
    AppDelegate                                                 ││
      ├── reactNativeDelegate: ReactNativeDelegate             ││
      └── reactNativeFactory: RCTReactNativeFactory             ││
  └─────────────────────────────────────────────────────────────┘│
  ┌─────────────────────────────────────────────────────────────┐│
    ReactNativeDelegate : RCTDefaultReactNativeFactoryDelegate  ││
      ├── bundleURL()  URL?                                    ││
      └── dependencyProvider: RCTAppDependencyProvider          ││
  └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘
                               
                               
┌─────────────────────────────────────────────────────────────────┐
                        Factory                                 
  ┌─────────────────────────────────────────────────────────────┐│
    RCTReactNativeFactory                                       ││
      ├── delegate: RCTReactNativeFactoryDelegate               ││
      ├── rootViewFactory: RCTRootViewFactory                   ││
      └── startReactNative(withModuleName:in:)                  ││
  └─────────────────────────────────────────────────────────────┘│
  ┌─────────────────────────────────────────────────────────────┐│
    RCTRootViewFactory                                          ││
      ├── reactHost: RCTHost                                    ││
      ├── view(withModuleName:)  UIView                        ││
      └── createReactHost()  RCTHost                           ││
  └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘
                               
                               
┌─────────────────────────────────────────────────────────────────┐
                         Host                                   
  ┌─────────────────────────────────────────────────────────────┐│
    RCTHost                                                     ││
      ├── instance: RCTInstance                                 ││
      ├── bundleManager: RCTBundleManager                       ││
      ├── start()                                               ││
      └── createSurface(withModuleName:)  RCTFabricSurface     ││
  └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘
                               
                               
┌─────────────────────────────────────────────────────────────────┐
                       Instance                                 
  ┌─────────────────────────────────────────────────────────────┐│
    RCTInstance                                                 ││
      ├── reactInstance: ReactInstance (C++)                    ││
      ├── turboModuleManager: RCTTurboModuleManager             ││
      ├── surfacePresenter: RCTSurfacePresenter                 ││
      ├── displayLink: RCTDisplayLink                           ││
      └── _start()                                              ││
  └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘
                               
                               
┌─────────────────────────────────────────────────────────────────┐
                        C++ Runtime                             
  ┌─────────────────────────────────────────────────────────────┐│
    ReactInstance (C++)                                         ││
      ├── jsRuntime: jsi::Runtime (Hermes)                      ││
      ├── runtimeScheduler: RuntimeScheduler                    ││
      ├── timerManager: TimerManager                            ││
      ├── initializeRuntime()                                   ││
      └── loadScript()                                          ││
  └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘

mri@1.2.0源码阅读

mri(全称 Minimalist CLI Argument Parser)的核心作用是解析命令行传入的参数,把用户在终端输入的命令行参数(比如 --port 3000-v)转换成易于在代码中使用的 JavaScript 对象。

有什么特点?

  1. 超轻量:体积只有几百 KB,无依赖,适合对包体积敏感的 Node.js/ 前端工具开发;
  2. 易用性:API 极简,几行代码就能完成参数解析;
  3. 常用场景:前端工程化工具(如自定义构建脚本、本地开发服务器、CLI 工具)中解析命令行参数。

index文件

mri-1.2.0/src/index.js

export default function (args, opts) {
  args = args || []; // 命令行参数数组(比如 process.argv.slice(2))
  opts = opts || {}; // 解析配置(别名、默认值、类型等)

  var k, arr, arg, name, val, out={ _:[] }; // out 解析结果对象
  var i=0, j=0, idx=0, len=args.length;

  // 标记是否配置了别名、严格模式、默认值
  const alibi = opts.alias !== void 0;
  const strict = opts.unknown !== void 0;
  const defaults = opts.default !== void 0;

  opts.alias = opts.alias || {};
  opts.string = toArr(opts.string); // 字符串类型的参数名数组
  opts.boolean = toArr(opts.boolean); // 布尔类型的参数名数组

  // 别名双向绑定
  if (alibi) {
    for (k in opts.alias) {
      arr = opts.alias[k] = toArr(opts.alias[k]);
      for (i=0; i < arr.length; i++) {
        (opts.alias[arr[i]] = arr.concat(k)).splice(i, 1);
      }
    }
  }
  // 布尔 / 字符串类型补全
  // 布尔类型:把别名也加入布尔列表
  for (i=opts.boolean.length; i-- > 0;) {
    arr = opts.alias[opts.boolean[i]] || [];
    for (j=arr.length; j-- > 0;) opts.boolean.push(arr[j]);
  }

  // 字符串类型:同理,把别名也加入字符串列表
  for (i=opts.string.length; i-- > 0;) {
    arr = opts.alias[opts.string[i]] || [];
    for (j=arr.length; j-- > 0;) opts.string.push(arr[j]);
  }

  // 默认值关联类型
  if (defaults) {
    for (k in opts.default) {
      name = typeof opts.default[k];
      arr = opts.alias[k] = opts.alias[k] || [];
      if (opts[name] !== void 0) {
        opts[name].push(k);
        for (i=0; i < arr.length; i++) {
          opts[name].push(arr[i]);
        }
      }
    }
  }

  // 遍历解析命令行参数
  const keys = strict ? Object.keys(opts.alias) : [];

  for (i=0; i < len; i++) {
    arg = args[i]; // 当前处理的参数(比如 '--port'、'-p=3000')

    // 1. 遇到 "--" 终止解析,后续参数全部放入 out._
    if (arg === '--') {
      out._ = out._.concat(args.slice(++i));
      break;
    }

    // 2. 计算参数前的 "-" 数量(比如 --port 是 2 个,-p 是 1 个)
    for (j=0; j < arg.length; j++) {
      if (arg.charCodeAt(j) !== 45) break; // "-"
    }

    // 3. 无 "-":非选项参数,放入 out._
    if (j === 0) {
      out._.push(arg);

      // 4. 以 "no-" 开头(比如 --no-open):布尔值取反
    } else if (arg.substring(j, j + 3) === 'no-') {
      name = arg.substring(j + 3);
      if (strict && !~keys.indexOf(name)) {
        return opts.unknown(arg);
      }
      out[name] = false;

      // 5. 正常选项参数(比如 --port=3000、-p3000、-p 3000)
    } else {
      // 找到 "=" 的位置(分割参数名和值)
      for (idx=j+1; idx < arg.length; idx++) {
        if (arg.charCodeAt(idx) === 61) break; // "="
      }

      // 取值:有=则取=后的值;无=则取下一个参数(如果下一个不是-开头)
      name = arg.substring(j, idx);
      val = arg.substring(++idx) || (i+1 === len || (''+args[i+1]).charCodeAt(0) === 45 || args[++i]);
      arr = (j === 2 ? [name] : name);

      // 遍历解析(比如 -pv 会拆成 p 和 v 分别处理)
      for (idx=0; idx < arr.length; idx++) {
        name = arr[idx];
        if (strict && !~keys.indexOf(name)) return opts.unknown('-'.repeat(j) + name);
        // 把解析后的值存入 out(toVal 是核心工具函数)
        toVal(out, name, (idx + 1 < arr.length) || val, opts);
      }
    }
  }

  // 1. 补全默认值:如果参数未解析到,用默认值填充
  if (defaults) {
    for (k in opts.default) {
      if (out[k] === void 0) {
        out[k] = opts.default[k];
      }
    }
  }

  // 2. 同步别名:比如解析到 -p=3000,自动给 out.port 也赋值 3000
  if (alibi) {
    for (k in out) {
      arr = opts.alias[k] || [];
      while (arr.length > 0) {
        out[arr.shift()] = out[k];
      }
    }
  }

  return out;
}

toArr

function toArr(any) {
return any == null ? [] : Array.isArray(any) ? any : [any];
}

toVal

function toVal(out, key, val, opts) {
  var x,
    old = out[key],
    nxt = !!~opts.string.indexOf(key)
      ? val == null || val === true
        ? ''
        : String(val)
      : typeof val === 'boolean'
        ? val
        : !!~opts.boolean.indexOf(key)
          ? val === 'false'
            ? false
            : val === 'true' ||
              (out._.push(((x = +val), x * 0 === 0) ? x : val), !!val)
          : ((x = +val), x * 0 === 0)
            ? x
            : val;
  out[key] =
    old == null ? nxt : Array.isArray(old) ? old.concat(nxt) : [old, nxt];
}

示例


const mri = require('mri');
const args = mri(process.argv.slice(2));
console.log(args);

image.png

vue3自定义指令合集-单例v-tooltip

背景

在实际项目中,Tooltip 往往会遇到这些问题:

  • 大多数使用组件形式实现(有时候项目中对于tooltip没有过多要求,使用UI组件的话,需要包一层组件,有点臃肿)
  • 每个元素一个 Tooltip,实例过多,性能差
  • 页面滚动 / 容器滚动后,Tooltip 位置不更新
  • Tooltip 抖动、闪烁、频繁销毁重建
  • 指令解绑不彻底,事件和监听器泄漏

目标

  • 全局只创建 一个 Tooltip 实例
  • 使用指令 v-tooltip 即插即用
  • 支持 top / right / bottom / left
  • 自动跟随目标元素位置变化
  • 无闪烁、无内存泄漏
  • TypeScript 类型安全

造个轮子,代码放在仓库了,各位大佬自取:gitee.com/Gitfubobo/v…


使用方式

<button v-tooltip="'删除后无法恢复'">删除</button>
<button v-tooltip.right="'更多操作'">操作</button>

image.png


整体设计思路

Tooltip 用单例统一管理,指令只负责绑定 DOM,这里的单例是指DOM单例,页面上只存在一个DOM

核心拆分

  • TooltipSingleton

    • 负责 Tooltip 的创建、更新、销毁
  import tooltipV from './tooltip.vue';
  
  
  /**
     * @Author: fubobo
     * @Description: 单例
     * @return {TooltipSingleton}
     */
    public static getInstance(): TooltipSingleton {
        if (!TooltipSingleton.instance) {
            TooltipSingleton.instance = new TooltipSingleton();
        }
        return TooltipSingleton.instance;
    }

    // 创建dom挂载tooltip组件
    private createInstance() {
        if (!this.tooltipInstance) {
            this.rootEl = document.createElement('div');
            document.body.appendChild(this.rootEl);
            this.app = createApp(tooltipV);
            this.tooltipInstance = this.app.mount(this.rootEl);
        }
    }
  • v-tooltip 指令

    • 负责 DOM 事件绑定与生命周期
 // 指令对象
 const tooltipDirective: Directive<HTMLElement, string> = {
    mounted(el, binding) {
        TooltipSingleton.getInstance().mount(el, binding);
    },
    updated(el, binding) {
        TooltipSingleton.getInstance().updated(el, binding);
    },
    unmounted(el) {
        TooltipSingleton.getInstance().unmount(el);
    },
};
  • 位置监听工具

    • 负责监听滚动 / resize / DOM 变化
  /**
     * @Author: fubobo
     * @Description: 根据目标元素和tooltip进行位置计算
     * @return 坐标
     * @param {HTMLElement} targetEl
     * @param {Placement} placement
     */
    private calculatePosition(targetEl: HTMLElement, placement: Placement) {
        ....
        return { x, y };
    }

/**
 * 监听元素距离窗口顶部的距离变化
 * @param element 目标元素
 * @param callback 距离变化回调
 * @param options 配置项
 * @returns 停止监听的函数
 */
const observeElementViewportTop = (
    element: HTMLElement,
    callback: PositionCallback,
    options?: {
        throttle?: number; // 节流时间(默认 100ms)
        observeAncestors?: boolean; // 是否监听祖先元素尺寸变化
    }
) => {
    ....
    return () => {
        window.removeEventListener('scroll', handleWindowEvent);
        window.removeEventListener('resize', handleWindowEvent);
        mutationObserver.disconnect();
        resizeObserver.disconnect();
    };
};

为什么一定要用「单例 Tooltip」

如果每个 v-tooltip 都创建一个组件:

  • DOM 数量指数级增长
  • 每个 Tooltip 都监听 scroll / resize
  • 事件难以统一管理

带来的好处

  • 整个应用 只存在一个 Tooltip
  • hover 不同元素 → 只是更新内容和位置
  • 性能稳定、结构清晰

Tooltip 实例的创建与销毁

创建(只会执行一次)

private createInstance() {
  this.rootEl = document.createElement('div');
  document.body.appendChild(this.rootEl);
  this.app = createApp(tooltipV);
  this.tooltipInstance = this.app.mount(this.rootEl);
}

销毁(最后一个指令卸载时)

private destroyInstance() {
  this.app.unmount();
  document.body.removeChild(this.rootEl);
}

Tooltip 定位计算逻辑

核心思路:

使用目标元素位置 + Tooltip 自身尺寸计算最终坐标

const tooltipRect = tooltip.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
switch (placement) {
  case 'top':
    x = targetRect.x + targetRect.width / 2 - tooltipRect.width / 2;
    y = targetRect.y - tooltipRect.height - 10;
}

监听「元素位置变化」

仅靠 mouseenter 是完全不够的:

  • 页面滚动
  • 容器滚动
  • DOM transform
  • 父级尺寸变化

解决方案:多维度监听

observeElementViewportTop(el, callback, {
  observeAncestors: true,
});

监听来源包括:

监听方式 解决问题
scroll / resize 页面和窗口变化
MutationObserver class / style 改变
ResizeObserver 元素或父级尺寸变化

📌 Tooltip 始终贴着目标元素,不会错位

关键点

  • Tooltip 自身维护 hover 状态
  • 延迟隐藏(100ms)
  • 体验更自然

使用 WeakMap 优化性能

  • 不阻止 GC
  • DOM 销毁后自动释放
  • 从根源避免内存泄漏
class TooltipSingleton {
    ...
    private eventHandlers = new WeakMap<
        HTMLElement,
        {
            enter: EventListener;
            leave: EventListener;
        }
    >(); // 存储元素的事件处理器(弱引用防止内存泄漏)
    private directiveInfo = new WeakMap<
        HTMLElement,
        {
            text: string;
            placement: Placement;
        }
    >(); // 存储元素关联的指令信息(弱引用)
}

解决了哪些问题

问题 是否解决
Tooltip 重复创建
滚动后位置错乱
hover 闪烁
内存泄漏
TypeScript 类型不安全
指令更新无效
支持DOM传值

代码仓库

框框造个轮子,代码放在仓库了,各位大佬自取:gitee.com/Gitfubobo/v…

JS-面试必考:手动实现一个标准的 Ajax 请求(XMLHttpRequest)

前言

虽然 fetch API 在现代开发中非常流行,但 XMLHttpRequest (XHR) 依然是理解 Web 网络编程的基石。无论是底层的 Ajax 封装,还是需要细粒度监控上传/下载进度的场景,XHR 依然不可替代。

一、 什么是 Ajax?

Ajax(Asynchronous JavaScript And XML) 并不是一种单一的编程语言,而是一套技术的组合。其核心是在不重新加载整个网页的情况下,通过与服务器进行异步数据交换,实现页面的局部刷新

  • 核心优势:提升用户体验,减少网络带宽占用。
  • 实现基础:JavaScript 和浏览器原生的 XMLHttpRequest 对象。

二、 XHR 操作的“五步法”逻辑

1. 创建对象

let xhr = new XMLHttpRequest();

2. 注册回调函数 (事件驱动)

XHR 是基于事件驱动的,我们需要在请求发送前定义好如何处理各种状态。

  • onreadystatechange:监控请求的生命周期(状态 0-4)。
  • onerror:处理网络层面的错误(如断网或 DNS 解析失败)。
  • ontimeout:处理响应超时,需配合 xhr.timeout 使用。
  • onprogress:数据传输进度监控。

3. 配置请求参数

通过 open() 方法初始化请求:

  • method:请求方法(GET, POST, PUT 等)。
  • url:请求地址。
  • async:是否异步(默认 true)。注意: 设为 false 会阻塞浏览器主线程,导致页面假死。

4. 设置属性与请求头

  • responseType:设置期望的返回格式(如 json, blob)。
  • setRequestHeader():手动添加自定义请求头(必须在 open 之后,send 之前调用)。

5. 发送请求

  • send(data):正式发起请求。如果是 GET 请求,传 null;如果是 POST,传具体数据。

三、 详解 readyState:五种状态的演变

readyState 是排查 Ajax 问题的关键,它记录了请求的每一步进展:

取值 状态名称 描述
0 UNSENT 初始化状态,对象已创建,尚未调用 open()
1 OPENED 已调用 open(),尚未调用 send(),可设置 Header
2 HEADERS_RECEIVED 已调用 send(),接收到了响应头和状态码
3 LOADING 正在下载响应体,responseText 已包含部分数据
4 DONE 数据接收完成,请求生命周期结束

四、 进度监控:如何实现进度条?

XHR 相比 fetch 的一大优势就是对进度的原生支持。通过 onprogress 事件,我们可以计算出下载百分比:

  • lengthComputable:布尔值,表示服务器是否返回了 Content-Length
  • loaded:已接收的字节数。
  • total:总字节数。

五、 代码实现:封装一个标准的 Ajax 函数

/**
 * 基于 XHR 封装的数据获取函数
 * @param {string} URL 请求地址
 */
function GetWebData(URL) {
  const xhr = new XMLHttpRequest();

  // 1. 状态监控:只在 DONE 阶段处理逻辑
  xhr.onreadystatechange = function () {
    if (xhr.readyState === 4) { 
      // 兼容性判断:2xx 范围和 304 缓存均认为成功
      if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
        console.log("请求成功:", xhr.response);
      } else {
        console.error("请求失败, 状态码:", xhr.status);
      }
    }
  };

  // 2. 进度监控
  xhr.onprogress = function (event) {
    if (event.lengthComputable) {
      let percent = (event.loaded / event.total) * 100;
      console.log(`下载进度: ${percent.toFixed(2)}%`);
    }
  };

  // 3. 异常与超时处理
  xhr.timeout = 5000; 
  xhr.ontimeout = () => console.error("请求超时!");
  xhr.onerror = () => console.error("网络异常或跨域错误");

  // 4. 配置与发送
  xhr.open("GET", URL, true);
  xhr.responseType = "json"; // 自动解析 JSON
  xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");

  xhr.send(null);
}

Three.js 变形动画-打造花瓣绽放

概述

本文将详细介绍如何使用 Three.js 实现变形动画效果。我们将学习如何利用 Morph Targets(形态目标)技术,让 3D 模型在不同形状之间平滑过渡,创造出花瓣绽放等生动的动画效果。

screenshot_2026-01-28_17-56-51.gif

准备工作

首先,我们需要引入必要的 Three.js 库和相关工具:

import * as THREE from "three";
// 导入轨道控制器
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
// 导入动画库
import gsap from "gsap";
// 导入dat.gui
import * as dat from "dat.gui";
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";
import { RGBELoader } from "three/examples/jsm/loaders/RGBELoader";

场景初始化

首先,我们需要创建一个基本的 Three.js 场景:

const gui = new dat.GUI();
// 1、创建场景
const scene = new THREE.Scene();

// 2、创建相机
const camera = new THREE.PerspectiveCamera(
  75,
  window.innerWidth / window.innerHeight,
  0.1,
  1000
);
camera.aspect = window.innerWidth / window.innerHeight;
// 更新摄像机的投影矩阵
camera.updateProjectionMatrix();

// 设置相机位置
camera.position.set(0, 0, 20);
scene.add(camera);

环境设置

添加 HDR 环境纹理,提升场景的真实感:

// 添加hdr环境纹理
const loader = new RGBELoader();
loader.load("./textures/038.hdr", function (texture) {
  texture.mapping = THREE.EquirectangularReflectionMapping;
  scene.environment = texture;
});

DRACO 压缩模型加载

使用 DRACO 压缩技术加载 GLB 模型:

// 加载压缩的glb模型
const gltfLoader = new GLTFLoader();
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath("./draco/gltf/");
dracoLoader.setDecoderConfig({ type: "js" });
dracoLoader.preload();
gltfLoader.setDRACOLoader(dracoLoader);

变形动画核心实现

这是变形动画的关键部分,通过 Morph Targets 技术实现模型变形:

let params = {
  value: 0,
  value1: 0,
};

let mixer;
let stem, petal, stem1, petal1, stem2, petal2;

// 加载第一个模型(初始状态)
gltfLoader.load("./model/f4.glb", function (gltf1) {
  console.log(gltf1);
  stem = gltf1.scene.children[0];
  petal = gltf1.scene.children[1];
  gltf1.scene.rotation.x = Math.PI;

  // 遍历场景中的对象并处理材质
  gltf1.scene.traverse((item) => {
    if (item.material && item.material.name == "Water") {
      item.material = new THREE.MeshStandardMaterial({
        color: "skyblue",
        depthWrite: false,
        transparent: true,
        depthTest: false,
        opacity: 0.5,
      });
    }
    if (item.material && item.material.name == "Stem") {
      stem = item;
    }
    if (item.material && item.material.name == "Petal") {
      petal = item;
    }
  });

  // 加载第二个模型(中间状态)
  gltfLoader.load("./model/f2.glb", function (gltf2) {
    gltf2.scene.traverse((item) => {
      if (item.material && item.material.name == "Stem") {
        stem1 = item;
        // 将第二个模型的几何体作为第一个形态目标添加到基础模型
        stem.geometry.morphAttributes.position = [
          stem1.geometry.attributes.position,
        ];
        stem.updateMorphTargets();
      }
      if (item.material && item.material.name == "Petal") {
        petal1 = item;
        // 将第二个模型的几何体作为第一个形态目标添加到基础模型
        petal.geometry.morphAttributes.position = [
          petal1.geometry.attributes.position,
        ];
        petal.updateMorphTargets();
        console.log(petal.morphTargetInfluences);
      }

      // 加载第三个模型(最终状态)
      gltfLoader.load("./model/f1.glb", function (gltf2) {
        gltf2.scene.traverse((item) => {
          if (item.material && item.material.name == "Stem") {
            stem2 = item;
            // 将第三个模型的几何体作为第二个形态目标添加到基础模型
            stem.geometry.morphAttributes.position.push(
              stem2.geometry.attributes.position
            );
            stem.updateMorphTargets();
          }
          if (item.material && item.material.name == "Petal") {
            petal2 = item;
            // 将第三个模型的几何体作为第二个形态目标添加到基础模型
            petal.geometry.morphAttributes.position.push(
              petal2.geometry.attributes.position
            );
            petal.updateMorphTargets();
            console.log(petal.morphTargetInfluences);
          }
        });
      });
    });
  });

  // 使用 GSAP 创建变形动画
  gsap.to(params, {
    value: 1,
    duration: 4,
    onUpdate: function () {
      // 控制第一个形态目标的影响程度
      stem.morphTargetInfluences[0] = params.value;
      petal.morphTargetInfluences[0] = params.value;
    },
    onComplete: function () {
      // 在第一个动画完成后,开始第二个变形动画
      gsap.to(params, {
        value1: 1,
        duration: 4,
        onUpdate: function () {
          // 控制第二个形态目标的影响程度
          stem.morphTargetInfluences[1] = params.value1;
          petal.morphTargetInfluences[1] = params.value1;
        },
      });
    },
  });

  scene.add(gltf1.scene);
});

渲染器设置

配置 WebGL 渲染器:

// 初始化渲染器
const renderer = new THREE.WebGLRenderer({
  logarithmicDepthBuffer: true,
  antialias: true,
});
// 设置渲染的尺寸大小
renderer.setSize(window.innerWidth, window.innerHeight);
// 开启场景中的阴影贴图
renderer.shadowMap.enabled = true;
renderer.physicallyCorrectLights = true;
renderer.setClearColor(0xcccccc, 1);
renderer.autoClear = false;
// 设置电影渲染模式
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.outputEncoding = THREE.sRGBEncoding;
renderer.sortObjects = true;
renderer.logarithmicDepthBuffer = true;

// 将webgl渲染的canvas内容添加到body
document.body.appendChild(renderer.domElement);

控制器和动画循环

设置轨道控制器和渲染循环:

// 创建轨道控制器
const controls = new OrbitControls(camera, renderer.domElement);
// 设置控制器阻尼,让控制器更有真实效果,必须在动画循环里调用.update()。
controls.enableDamping = true;

// 添加坐标轴辅助器
const axesHelper = new THREE.AxesHelper(5);
scene.add(axesHelper);

// 设置时钟
const clock = new THREE.Clock();
function render() {
  let time = clock.getDelta();
  if (mixer) {
    mixer.update(time);
  }
  controls.update();

  renderer.render(scene, camera);
  // 渲染下一帧的时候就会调用render函数
  requestAnimationFrame(render);
}

render();

变形动画原理详解

Morph Targets(形态目标)是一种在计算机图形学中用于实现网格变形的技术。其基本原理是:

  1. 基础几何体: 定义一个基础的网格几何体
  2. 目标几何体: 定义一个或多个"目标"几何体,它们与基础几何体有相同的拓扑结构(相同的顶点数量和连接关系),但顶点位置不同
  3. 权重控制: 通过权重值(0到1之间)来控制目标几何体对基础几何体的影响程度

在代码中,我们使用 morphTargetInfluences 数组来控制每个形态目标的影响程度:

  • morphTargetInfluences[0] = 0 时,模型呈现初始状态
  • morphTargetInfluences[0] = 1 时,模型完全变成第一个目标状态
  • morphTargetInfluences[0] = 0.5 时,模型是初始状态和目标状态的中间形态

应用场景

变形动画在 3D 应用中有广泛的应用:

  1. 角色面部表情: 实现人物的表情变化
  2. 物体形态变化: 如花朵绽放、物体变形等
  3. 动画过渡: 在不同模型状态之间平滑过渡
  4. 程序化生成: 根据参数动态改变模型形状

性能优化建议

  1. 合理使用: Morph Targets 会增加内存消耗,只在必要时使用
  2. 减少目标数量: 尽量减少形态目标的数量以提高性能
  3. 压缩模型: 使用 DRACO 等压缩技术减少模型文件大小
  4. 优化动画: 使用高效的动画库如 GSAP 来控制变形过程

总结

通过这个项目,我们学习了如何使用 Three.js 的 Morph Targets 技术:

  1. 如何加载多个具有相同拓扑结构的模型
  2. 如何将目标模型的几何体作为形态目标添加到基础模型
  3. 如何通过控制权重来实现平滑的变形动画
  4. 如何使用 GSAP 等动画库来管理复杂的动画序列

变形动画是一个强大而灵活的技术,能够为你的 3D 应用增添生动有趣的视觉效果,特别是在创建有机形态变化(如植物生长、花朵绽放等)方面特别有效。

React Fiber 架构全方位梳理

背景

作为 react 高频考点,React Fiber反复出现,为啥会成为高频考点,我觉得,很大程度上是因为 React Fiber架构真的解决了问题,并且巧妙的思想或许在未来可以给我们一些性能优化的启发,所以我们今天一起来走进 React Fiber,感受它的

一、先理解:为什么需要 Fiber 架构?

React 15 及之前使用的是 Stack Reconciler(栈协调器),它采用递归方式遍历虚拟 DOM 树,一旦开始渲染就无法中断。JS 线程是单线程的,如果渲染任务(比如复杂组件树的更新)耗时超过 16.6ms(浏览器 60fps 下每帧的时长),就会阻塞浏览器的渲染、布局、用户交互(如点击/输入),导致页面卡顿。

Fiber 架构的核心目标就是将同步的、不可中断的递归渲染,改成异步的、可中断的迭代渲染,彻底解决渲染阻塞问题。

一、先明确:Stack Reconciler 是什么?

Stack Reconciler(栈协调器)是 React 15 及之前版本的核心渲染引擎,负责两大核心工作:

  1. Reconciliation(协调):对比新旧虚拟 DOM 树(Virtual DOM),找出需要更新的节点(即「差异(diff)」);
  2. Commit(提交):将找到的差异同步应用到真实 DOM 上。

它的核心特征是:基于 JavaScript 调用栈的同步递归遍历 —— 这也是「Stack(栈)」这个名字的由来(完全依赖 JS 函数调用栈完成遍历)。

二、Stack Reconciler 的递归执行流程

虚拟 DOM 是树形结构,递归是最直观的遍历方式,但也埋下了「无法中断」的隐患。我们先通过一个简化的示例,还原它的执行逻辑:

1. 模拟 Stack Reconciler 的递归遍历代码
// 模拟 React 15 的虚拟 DOM 节点
class VNode {
  constructor(tag, children = []) {
    this.tag = tag; // 组件/标签名
    this.children = children; // 子节点
  }
}

// 模拟 Stack Reconciler 的核心递归遍历方法
function reconcile(oldVNode, newVNode) {
  // 1. 对比当前节点:如果标签不同,直接标记为替换
  if (oldVNode.tag !== newVNode.tag) {
    console.log(`标记替换节点: ${oldVNode.tag}${newVNode.tag}`);
    return;
  }

  // 2. 递归遍历子节点(核心:深度优先递归)
  const oldChildren = oldVNode.children;
  const newChildren = newVNode.children;
  const maxLen = Math.max(oldChildren.length, newChildren.length);

  for (let i = 0; i < maxLen; i++) {
    const oldChild = oldChildren[i];
    const newChild = newChildren[i];
    
    // 递归处理每个子节点 —— 每递归一次,就向 JS 调用栈压入一层
    if (oldChild && newChild) {
      reconcile(oldChild, newChild); // 子节点存在,继续递归
    } else if (newChild) {
      console.log(`标记新增节点: ${newChild.tag}`); // 新节点,标记新增
    } else if (oldChild) {
      console.log(`标记删除节点: ${oldChild.tag}`); // 旧节点,标记删除
    }
  }
}

// 模拟一个复杂的组件树(多层嵌套)
const oldTree = new VNode('App', [
  new VNode('Header', [new VNode('Logo'), new VNode('Nav')]),
  new VNode('Content', [
    new VNode('Article', [new VNode('Title'), new VNode('Text')]),
    new VNode('Sidebar', [new VNode('Menu'), new VNode('Ad')])
  ]),
  new VNode('Footer')
]);

const newTree = new VNode('App', [
  new VNode('Header', [new VNode('Logo'), new VNode('Nav')]),
  new VNode('Content', [
    new VNode('Article', [new VNode('Title'), new VNode('Text', [new VNode('Span')])]),
    new VNode('Sidebar', [new VNode('Menu')])
  ]),
  new VNode('Footer')
]);

// 触发渲染:一旦调用,必须执行完整个递归流程
reconcile(oldTree, newTree);
2. 递归执行的关键特点

从代码中能看到,reconcile 函数的执行完全依赖 JS 调用栈:

  • 每递归处理一个子节点,就会向调用栈压入一层 reconcile 函数;
  • 只有当某个节点的所有子节点都处理完毕(递归返回),该层函数才会从调用栈弹出
  • 整个遍历过程必须「一气呵成」—— 从根节点到最深层节点,再逐层返回,中间没有任何暂停的可能。

三、为什么递归遍历「无法中断」?

核心原因是 JavaScript 单线程 + 调用栈的同步特性

  1. JS 单线程模型:浏览器中 JS 线程和渲染线程(布局、绘制)是互斥的 —— 只要 JS 线程在执行代码,渲染线程就会被阻塞;
  2. 调用栈不可中断JS 引擎的调用栈是「同步执行」的,一旦函数开始执行,必须等到函数体完全执行完毕、调用栈清空,才会释放主线程。

举个实际场景:如果你的组件树有 1000 个节点,reconcile 递归遍历需要 50ms 才能完成。这 50ms 内:

  • JS 线程被完全占用,浏览器无法处理任何用户交互(点击、输入、滚动);
  • 渲染线程被阻塞,页面无法更新,用户会明显感觉到「卡顿」(60fps 下每帧只有 16.6ms,50ms 会丢失 3 帧);
  • 即使中途用户触发了更紧急的操作(比如点击按钮),也必须等这 50ms 的递归完成后才能响应 —— 因为调用栈没有「暂停」「插队」的机制。

四、Stack Reconciler 的核心问题

除了「无法中断」,递归遍历还带来两个关键问题:

  1. 无优先级调度:所有更新任务(比如用户输入的高优先级更新、数据请求的低优先级更新)都被同等对待,同步排队执行 —— 比如用户输入框打字(需要即时响应),却要等一个耗时的列表渲染完成,体验极差;
  2. 调用栈溢出风险:如果组件树嵌套过深(比如 1000 层),递归遍历会导致调用栈层数过多,直接触发 Maximum call stack size exceeded 错误(栈溢出)。

总结(核心关键点)

  1. Stack Reconciler 依赖 JS 调用栈的同步递归 遍历虚拟 DOM 树,递归的特性决定了渲染过程「一旦开始就必须执行到底」;
  2. 无法中断的根源是 JS 单线程 + 调用栈不可中断 —— 递归过程中主线程被完全占用,阻塞渲染和用户交互;
  3. 直接后果是 长任务导致页面卡顿,且无法区分任务优先级,无法优先响应用户交互等关键操作(这也是 Fiber 架构要解决的核心痛点)。

简单来说,React 15 的 Stack Reconciler 就像「一口气跑完马拉松」,不管中途有没有突发情况,都不能停;而 Fiber 架构则是「分段跑马拉松」,跑一段歇一下,还能优先处理紧急的事(比如喝水、接电话),这也是两者最核心的区别。

二、核心原理拆解

1. Fiber 解决渲染阻塞的核心逻辑

Fiber 解决阻塞的核心是 “任务拆分 + 时间切片 + 优先级调度”,核心逻辑可总结为 3 点:

  • 任务拆分:将原本一次性完成的“整棵组件树的渲染更新”拆成无数个小任务(每个小任务只处理一个 Fiber 节点);
  • 时间切片(Time Slicing):每次只执行一个小任务,且执行时长不超过浏览器预留的空闲时间,执行完就把控制权还给浏览器;
  • 中断与恢复:如果当前帧的空闲时间用完,就暂停渲染任务,记录当前执行到的 Fiber 节点,等浏览器下一次空闲时再从该节点继续执行。

支撑这个逻辑的两个关键基础:

  • Fiber 数据结构:每个 Fiber 节点对应一个组件,除了保存组件的 props/state 外,还增加了 return(父节点)、child(子节点)、sibling(兄弟节点)等指针,形成链表结构——这是“中断后能恢复”的关键(递归栈无法记录中断点,链表可以);
  • Scheduler 调度器:React 自研的调度模块,负责给任务分配优先级、计算空闲时间、控制任务的执行/暂停。

一、拆分的核心基础:Fiber 节点是「可独立处理的最小单元」

要拆分任务,首先得有「可拆分的最小单元」—— Fiber 节点就是这个单元。 React 把每一个组件(无论是类组件、函数组件还是原生标签)都对应成一个 Fiber 节点,并且给每个节点设计了链表指针child/sibling/return),替代了原来的递归树结构。

每个 Fiber 节点都包含了「处理该节点所需的全部信息」,比如:

  • 组件的 props/state
  • 对应的真实 DOM 节点;
  • 要执行的操作(比如「更新」「新增」「删除」);
  • 链表指针(找到下一个要处理的节点)。

这意味着:处理单个 Fiber 节点不需要依赖其他节点的执行结果,可以单独作为一个「小任务」来执行——这是任务能被拆分的核心前提。

二、任务拆分的核心逻辑:从「递归整树处理」→「迭代逐个节点处理」

Stack Reconciler 是「一次性递归遍历整棵树」,而 Fiber 架构则是把「整树处理」拆成「处理单个节点」的小任务,通过迭代 + 链表遍历的方式逐个执行,具体拆分逻辑如下:

1. 拆分原则
  • 大任务:整棵组件树的渲染更新(Reconciliation 阶段);
  • 小任务:处理一个 Fiber 节点(包括:对比新旧节点、标记副作用、确定下一个要处理的节点);
  • 拆分方式:按「深度优先 + 链表遍历」的顺序,把整树的处理拆成一个个独立的「单个节点处理任务」。
2. 链表遍历的顺序(决定小任务的执行顺序)

Fiber 链表的遍历遵循「深度优先」,但通过指针实现迭代(而非递归),顺序是:

  1. 先处理当前节点(小任务 1);
  2. 如果有子节点(child),下一个处理子节点(小任务 2);
  3. 如果没有子节点,但有兄弟节点(sibling),下一个处理兄弟节点(小任务 3);
  4. 如果既没有子节点也没有兄弟节点,回到父节点(return),再找父节点的兄弟节点(小任务 4);
  5. 直到回到根节点,所有小任务执行完毕。

这个过程就像「走迷宫」:先往深走(子节点),走到底就往旁边走(兄弟节点),旁边也走完就退回去(父节点),再往旁边走——每走一步,就是执行一个小任务。

三、代码模拟:直观看到任务是怎么拆分的

下面用简化代码模拟 Fiber 的任务拆分和执行过程,你能清晰看到「整树任务」是如何被拆成「单个节点任务」的:

// 1. 定义 Fiber 节点(最小任务单元)
class FiberNode {
  constructor(tag, props) {
    this.tag = tag; // 组件/标签名(如 App、Div)
    this.props = props; // 组件 props
    this.child = null; // 第一个子节点
    this.sibling = null; // 下一个兄弟节点
    this.return = null; // 父节点
    this.effectTag = null; // 要执行的操作(新增/更新/删除)
  }
}

// 2. 构建 Fiber 链表(模拟组件树)
// 结构:App → Header → Logo → Nav → Content → Article → Footer
const App = new FiberNode('App', {});
const Header = new FiberNode('Header', {});
const Logo = new FiberNode('Logo', {});
const Nav = new FiberNode('Nav', {});
const Content = new FiberNode('Content', {});
const Article = new FiberNode('Article', {});
const Footer = new FiberNode('Footer', {});

// 设置链表指针(构建依赖关系)
App.child = Header;
Header.return = App;
Header.child = Logo;
Logo.return = Header;
Logo.sibling = Nav;
Nav.return = Header;
Header.sibling = Content;
Content.return = App;
Content.child = Article;
Article.return = Content;
Content.sibling = Footer;
Footer.return = App;

总结(核心关键点)

  1. 拆分基础:Fiber 节点是「可独立处理的最小单元」,链表指针(child/sibling/return)让节点能被逐个遍历,无需依赖递归栈;
  2. 拆分方式:把「整棵组件树的渲染更新」拆成「处理单个 Fiber 节点」的原子小任务,按「深度优先 + 链表遍历」的顺序逐个执行;
  3. 可中断核心:工作循环每次只执行一个小任务,执行前检查时间,不足则中断并记录断点,恢复时从断点继续——这也是拆分的最终目的:让长任务变可中断。

简单来说,Fiber 的任务拆分就像「把一本厚书拆成一页页来读」:原来的 Stack Reconciler 是「一口气读完整本书」,而 Fiber 是「读一页,看看有没有时间,有就继续读下一页,没有就合上书记下页码,下次从这页继续读」。

为什么 Stack Reconciler 的「一次性递归遍历整棵树」做不到像 Fiber 那样把节点处理拆成独立小任务

一、核心差异:递归遍历中「节点处理不具备独立性」

Fiber 能拆分任务的关键是「单个节点的处理不依赖其他节点的执行上下文」,但 Stack Reconciler 的递归遍历中,处理子节点是父节点处理逻辑的「嵌套部分」,完全依赖父节点的调用上下文,无法单独拆分

1. 先看 Stack Reconciler 的递归代码(核心片段)
// Stack Reconciler 处理父节点的逻辑
function reconcile(oldVNode, newVNode) {
  // 1. 处理当前父节点的逻辑
  if (oldVNode.tag !== newVNode.tag) { /* 标记替换 */ }

  // 2. 遍历子节点 —— 这是父节点处理逻辑的「一部分」
  for (let i = 0; i < maxLen; i++) {
    const oldChild = oldVNode.children[i];
    const newChild = newVNode.children[i];
    
    // 3. 递归处理子节点 —— 这个调用「嵌套在父节点的函数体中」
    if (oldChild && newChild) {
      reconcile(oldChild, newChild); // 子节点处理依赖父节点的循环上下文
    }
  }
}
2. 递归模式下的「依赖陷阱」

从代码能清晰看到:

  • 「处理子节点」不是一个「独立任务」,而是父节点 reconcile 函数执行过程中的嵌套步骤
  • 要处理子节点,必须先进入父节点的 reconcile 函数,且父节点的 for 循环必须执行到对应子节点的索引;
  • 子节点的递归调用「依附于父节点的调用栈」—— 父节点的函数没执行完,子节点的调用就无法独立存在;反过来,子节点的递归没返回,父节点的函数也无法结束。

举个通俗例子:

  • Fiber 模式:每个节点是「独立的快递包裹」,处理一个包裹只需要包裹本身的信息,不用管其他包裹,能拆成一个个独立任务;
  • Stack 递归模式:所有节点是「一串连在一起的鞭炮」,要炸到第 5 个鞭炮,必须先点第 1 个,且中间不能停 —— 第 5 个的爆炸依赖前 4 个的传导,无法单独拆出第 5 个来炸。

二、底层限制:JS 调用栈的「同步不可中断性」

Stack Reconciler 依赖 JS 函数调用栈实现递归,而调用栈的特性决定了「一旦开始递归,必须执行到底」:

Fiber 架构(迭代+链表) Stack Reconciler(递归+调用栈)
用「变量记录当前节点」(workInProgress),中断时只需保存这个变量 用「调用栈记录执行位置」,栈的层级和节点嵌套深度绑定
中断时:保存当前节点变量,直接退出循环,调用栈清空 中断时:无法退出——调用栈必须等嵌套的递归函数全部返回才能清空
恢复时:从保存的节点变量继续迭代,无需恢复调用栈 恢复时:无法实现——调用栈一旦弹出,无法还原之前的嵌套状态
关键举例:递归调用栈的「不可回退性」

假设组件树是 App → Header → Logo,递归处理时调用栈的变化是:

  1. 调用 reconcile(App) → 栈:[reconcile(App)]
  2. 遍历 App 的子节点,调用 reconcile(Header) → 栈:[reconcile(App), reconcile(Header)]
  3. 遍历 Header 的子节点,调用 reconcile(Logo) → 栈:[reconcile(App), reconcile(Header), reconcile(Logo)]

此时如果想「中断处理 Logo,先处理其他任务」,不可能:

  • 要中断,必须让 reconcile(Logo) 执行完并返回 → 栈变成 [reconcile(App), reconcile(Header)]
  • 再让 reconcile(Header) 执行完并返回 → 栈变成 [reconcile(App)]
  • 最后让 reconcile(App) 执行完 → 栈清空。

整个过程中,你无法「暂停在 Logo 节点」,因为调用栈的层级和节点处理深度是强绑定的 —— 栈的状态就是执行位置,而栈无法被「冻结」或「保存」,只能按顺序弹出。

而 Fiber 架构中,执行位置是靠「变量(workInProgress)」记录的,不是调用栈:

  • 处理到 Logo 节点时,workInProgress = Logo
  • 想中断?直接退出循环,保存 workInProgress = Logo 即可,调用栈完全清空;
  • 恢复时,把 workInProgress 设为 Logo,重新启动循环就能继续处理,无需依赖任何栈状态。

三、处理单元的本质:「整树遍历」vs「单个节点处理」

Stack Reconciler 的「最小处理单元」是「整棵树的遍历」,而 Fiber 的「最小处理单元」是「单个节点的处理」—— 这是能否拆分的根本:

  1. Stack Reconciler

    • 开发者调用 setState 后,React 启动的是「整棵树的 reconcile 任务」,这个任务是「不可分割的整体」;
    • 即使你只想更新一个 Logo 节点,也必须从 App 根节点开始,递归遍历到 Logo,再逐层返回 —— 整个过程是一个「大任务」,没有更小的可执行单元。
  2. Fiber 架构

    • 启动的是「遍历链表的工作循环」,这个循环的「最小执行单元」是「处理单个 Fiber 节点」;
    • 想更新 Logo 节点?工作循环可以只执行「处理 Logo 节点」这个小任务,时间不够就暂停,完全不影响其他节点的处理状态。

总结(核心关键点)

  1. 独立性差异:Stack 递归中,子节点处理是父节点逻辑的嵌套部分,依赖父节点的调用上下文,无法拆成独立任务;Fiber 节点包含全部处理信息,单个节点处理无需依赖其他节点;
  2. 执行载体差异:Stack 依赖 JS 调用栈,栈的同步特性决定了递归必须执行到底,无法中断/保存状态;Fiber 依赖变量记录当前节点,迭代执行,可随时中断并保存执行位置;
  3. 最小单元差异:Stack 的最小处理单元是「整树遍历」,Fiber 的最小处理单元是「单个节点处理」—— 这是能否拆分的核心。

简单来说:Stack Reconciler 就像「用一根绳子串起所有珠子,要拿最后一颗必须把整串拉到底」;而 Fiber 是「每个珠子都有独立的标签和位置信息,想拿哪颗就拿哪颗,拿一半放下也能记住位置」。这就是递归遍历无法拆分,而 Fiber 能拆分的根本原因。

「中断后能恢复」的关键是能否精准记住「下一个该处理的节点」

本质上,「中断后能恢复」的关键是 能否精准记住「下一个该处理的节点」 —— 递归栈的执行逻辑天然做不到这一点,而 Fiber 链表通过「显式的节点引用 + 独立变量记录」完美解决了这个问题。下面我用「通俗例子 + 逻辑拆解 + 对比」的方式讲透。

一、先明确:中断点需要记录什么?

不管是递归还是 Fiber,想中断后恢复,必须记住一个核心信息:

中断时,下一个该处理的节点是谁? 比如处理到 App → Header → Logo 后中断,需要记住「接下来该处理 Logo 的兄弟节点 Nav」,恢复时直接从 Nav 开始,而不是从头重新处理 App。

这是恢复的核心前提 —— 递归栈做不到记录这个信息,而链表可以。

二、递归栈为什么「无法记录中断点」?

递归栈的执行位置,是靠 **JS 调用栈的「层级」和「函数执行上下文」** 来隐式记录的,而非显式的节点引用 —— 这意味着一旦中断,这些上下文会全部丢失,无法还原。

1. 递归栈的「执行位置」绑定调用栈层级

举个具体例子:组件树是 App → Header → Logo(子)/ Nav(兄弟),递归处理时的调用栈变化:

// 步骤1:调用 reconcile(App) → 栈:[reconcile(App)]
// 步骤2:遍历 App 子节点,调用 reconcile(Header) → 栈:[reconcile(App), reconcile(Header)]
// 步骤3:遍历 Header 子节点,调用 reconcile(Logo) → 栈:[reconcile(App), reconcile(Header), reconcile(Logo)]

此时想中断(比如时间不足),要处理的下一个节点是 Nav(Logo 的兄弟),但递归栈的问题来了:

  • 「下一个该处理 Nav」这个信息,只存在于 reconcile(Header) 函数的执行上下文中(比如循环变量 i=1,对应 Header 子节点的第二个元素);
  • 调用栈的层级只记录「当前正在执行 reconcile(Logo)」,但无法直接记录「下一个节点是 Nav」
  • 如果强行中断(比如退出函数),reconcile(Logo) 会返回,reconcile(Header) 的执行上下文(循环变量 i、Header 节点的引用)会被销毁,调用栈回到 [reconcile(App)]
  • 等恢复时,你只知道「之前执行到过 Logo」,但完全不知道「下一个该处理 Nav」—— 只能从头重新调用 reconcile(App),相当于白执行了前面的步骤。
2. 递归栈的「不可恢复性」核心
递归栈的特性 导致的问题
执行位置靠「栈层级 + 函数上下文」隐式记录 中断时函数上下文销毁,无法记住「下一个节点」
调用栈只能「先进后出」,无法暂停/保存 要么执行完当前递归,要么全部退出,没有中间状态
没有独立变量记录「待处理节点」 恢复时只能从头开始,无法从中断点继续

通俗比喻:递归栈就像「在纸上写作业,没写完就被擦掉」—— 你只知道写到了第3题,但不知道第3题写完后该写第4题,只能重新从第1题开始写。

三、Fiber 链表为什么「能记录中断点」?

Fiber 架构放弃了递归栈,改用 「独立变量 + 链表指针」显式记录中断点 —— 中断点的核心信息(下一个待处理节点)被保存在一个独立变量(workInProgress)中,和调用栈无关,就算中断也不会丢失。

1. 链表的「执行位置」绑定「节点引用」

还是同一个例子:组件树 App → Header → Logo / Nav,Fiber 处理时的逻辑:

// 初始化:待处理节点 = App
let workInProgress = App;

// 步骤1:处理 App → 找到下一个节点(App.child = Header)→ workInProgress = Header
// 步骤2:处理 Header → 找到下一个节点(Header.child = Logo)→ workInProgress = Logo
// 步骤3:处理 Logo → 找到下一个节点(Logo.sibling = Nav)→ workInProgress = Nav

此时想中断:

  • 只需要保存 workInProgress = Nav 这个变量(记录「下一个该处理 Nav」),然后直接退出循环即可;
  • 调用栈会被清空(因为是迭代而非递归),但 workInProgress 变量不会消失;
  • 恢复时,直接把 workInProgress 设为 Nav,重新启动循环:处理 Nav → 找到下一个节点(Nav.return = Header → Header.sibling = Content)→ workInProgress = Content,完美继续。
2. 链表指针让「中断点可追溯」

Fiber 节点的 child/sibling/return 指针,让每个节点都能「找到自己的下一个节点」,无需依赖调用栈:

  • 就算中断时只记住了 Nav,通过 Nav.return 能找到 Header,通过 Header.sibling 能找到 Content,永远不会「迷路」;
  • 而递归栈一旦丢失上下文,节点之间的关系就无从追溯。
3. 链表的「可恢复性」核心
Fiber 链表的特性 解决的问题
执行位置靠「workInProgress 变量」显式记录 中断时只需保存这个变量,上下文不丢失
节点关系靠指针(child/sibling/return)显式关联 从任意节点都能找到下一个待处理节点
迭代执行,调用栈随时可清空 中断时不依赖栈层级,恢复时直接用变量继续

通俗比喻:Fiber 链表就像「在作业本上贴便利贴,写不完就贴在第4题上」—— 就算合上书,下次打开看到便利贴,直接从第4题开始写就行,不用重新写。

四、直观对比:递归栈 vs Fiber 链表(中断恢复)

场景 递归栈 Fiber 链表
处理到 Logo 后中断 调用栈层级丢失,循环变量销毁,不知道下一个是 Nav 保存 workInProgress = Nav,变量不丢失
恢复执行 只能重新从 App 开始递归,重复处理 App/Header/Logo 直接从 workInProgress = Nav 开始,处理 Nav/Content/...
核心记录方式 隐式(栈层级 + 函数上下文) 显式(独立变量 + 节点引用)
能否恢复 ❌ 不能,只能重跑 ✅ 能,精准继续

总结(核心关键点)

  1. 中断点的核心是记录「下一个待处理节点」递归栈靠「调用栈层级 + 函数上下文」隐式记录,中断后上下文销毁,无法还原;Fiber 靠 workInProgress 变量显式记录节点引用,中断后变量不丢失。
  2. 链表指针是恢复的基础child/sibling/return 让每个节点都能找到下一个节点,无需依赖调用栈,从任意中断点都能继续遍历。
  3. 递归栈的本质是「依赖栈状态」,链表的本质是「依赖节点引用」:栈状态不可保存,节点引用可保存 —— 这是「能否中断恢复」的根本区别。

简单来说:递归栈是「靠记忆记位置」,中断后忘了就只能重来了;Fiber 链表是「靠笔记记位置」,中断后看笔记就能精准继续。这就是链表能记录中断点、递归栈做不到的核心原因。

2. Fiber 分片/中断的执行机制 -- Scheduler

Fiber 的执行分为两个核心阶段,只有第一个阶段可中断:

阶段 名称 核心操作 是否可中断
阶段1 Reconciliation(协调/渲染) 对比新旧 Fiber 树、标记 DOM 变更(副作用) ✅ 可中断、可暂停、可重启
阶段2 Commit(提交) 将标记的副作用应用到真实 DOM ❌ 不可中断(DOM 操作必须一次性完成,避免页面不完整)

分片/中断的具体执行流程

  1. 任务调度:当组件触发更新(如 setState),Scheduler 会根据任务优先级(比如用户输入 > 数据请求)将任务加入调度队列;
  2. 启动迭代遍历:不再递归遍历组件树,而是基于 Fiber 链表做迭代式深度优先遍历:
    • 先处理当前 Fiber 节点(比如计算 props、对比子节点);
    • 处理完后,检查“剩余空闲时间”:如果还有时间,就取下一个 Fiber 节点(child → sibling → return)继续处理;如果没有时间,就暂停遍历;
  3. 中断逻辑:暂停时,React 会记录当前“工作中的 Fiber 节点”(workInProgress),然后调用 requestIdleCallback(或 React 模拟的空闲回调),把控制权还给浏览器,让浏览器执行布局、绘制等操作;
  4. 恢复逻辑:当浏览器进入下一次空闲期,Scheduler 会唤醒暂停的任务,从之前记录的 workInProgress 节点继续遍历,直到所有 Fiber 节点处理完毕;
  5. 提交阶段:协调阶段完成后,React 会一次性执行所有标记的 DOM 变更,这个过程不可中断,保证 DOM 状态的一致性。

Scheduler 调度器的具体实现原理

Scheduler 本质是一个「智能任务调度中心」,核心目标是让高优先级任务优先执行,低优先级任务利用空闲时间执行,且所有任务都能被中断/恢复。下面我会从「优先级设计」「空闲时间计算」「任务执行/暂停控制」三个核心维度,拆解它的实现逻辑,还会附上简化代码模拟核心流程。

一、核心前置认知

Scheduler 是独立于 React 核心的模块(甚至可以单独使用),它的实现不依赖 Fiber 架构,但为 Fiber 提供了「可中断渲染」的基础能力。其核心设计思路是:

  • 用「过期时间」量化任务优先级,而非单纯的“高/中/低”标签;
  • 用「requestAnimationFrame + postMessage」模拟高精度空闲回调,替代原生 requestIdleCallback
  • 用「工作循环 + 时间检查」实现任务的执行、中断与恢复。

二、模块1:任务优先级的实现

Scheduler 不直接用“优先级等级”,而是用过期时间(expirationTime) 来定义优先级——优先级越高,任务的过期时间越短(越早需要执行)。

1. 优先级与过期时间的映射

React 定义了 5 类核心优先级(对应不同的过期时间阈值),本质是「任务必须在多久内执行,否则用户会感知到卡顿」:

优先级名称 过期时间(当前时间 + X) 适用场景 核心特点
ImmediatePriority 0ms(立即过期) 同步执行的紧急任务(如报错) 阻塞渲染,必须立即执行
UserBlockingPriority 250ms 用户交互(点击/输入/滚动) 250ms内执行,否则感知卡顿
NormalPriority 5000ms 普通任务(如网络请求回调) 不紧急,可延迟
LowPriority 10000ms 低优先级任务(如非关键数据加载) 可大幅延迟
IdlePriority Infinity(永不过期) 空闲任务(如日志/统计) 只有浏览器空闲时才执行
2. 优先级队列的实现:最小堆(小顶堆)

Scheduler 用最小堆管理所有待执行任务,堆的排序依据是「过期时间」——堆顶永远是「过期时间最早(优先级最高)」的任务,保证高优先级任务先执行。

  • 为什么用最小堆?堆的「取顶(O(1))」「插入(O(logn))」效率远高于普通数组排序,适合频繁新增/取出任务的场景;
  • 核心操作:
    • 新增任务:插入堆中,堆自动调整,保持堆顶是最高优先级任务;
    • 执行任务:每次从堆顶取出任务执行,执行完后重新调整堆。

三、模块2:空闲时间计算的实现

原生 requestIdleCallback 有两个致命问题:① 兼容性差;② 触发时机不稳定(帧率低于 60fps 时可能不触发)。因此 Scheduler 用「requestAnimationFrame (rAF) + postMessage」模拟高精度的空闲时间计算。

1. 核心原理

浏览器每帧(16.6ms)的执行顺序是:

事件处理 → 宏任务 → 微任务 → rAF → 布局 → 绘制 → 空闲时间

Scheduler 利用 rAF 精准获取每帧的开始时间,再用 postMessage 触发宏任务,在宏任务中计算「当前帧剩余的空闲时间」。

2. 空闲时间计算的步骤(代码级逻辑)
// 步骤1:用 rAF 获取每帧的开始时间
let frameStartTime = 0;
requestAnimationFrame((timestamp) => {
  frameStartTime = timestamp; // 记录当前帧的开始时间(ms)
});

// 步骤2:计算当前帧的剩余空闲时间
function calculateRemainingTime() {
  // 16.6ms = 1000ms / 60fps(每帧总时长)
  const frameDuration = 16.6;
  const elapsedTime = Date.now() - frameStartTime; // 已用时间
  const remainingTime = frameDuration - elapsedTime; // 剩余空闲时间
  // 预留 5ms 安全阈值,避免占用渲染时间
  return Math.max(0, remainingTime - 5);
}

// 步骤3:用 postMessage 触发空闲任务执行
// 原因:postMessage 的宏任务会在「绘制完成后、下一次 rAF 前」执行,正好对应空闲时间
const channel = new MessageChannel();
channel.port2.onmessage = () => {
  const remainingTime = calculateRemainingTime();
  if (remainingTime > 0) {
    // 有空闲时间,执行调度任务
    workLoop(remainingTime);
  } else {
    // 无空闲时间,等待下一次 rAF
    requestAnimationFrame(channel.port1.postMessage.bind(channel.port1));
  }
};
3. 5ms 阈值的作用

计算剩余时间时,会强制减去 5ms——这是为了预留足够时间给浏览器完成「布局/绘制」,避免任务执行超时导致丢帧。

四、模块3:任务执行/暂停的实现

Scheduler 的核心是「工作循环(workLoop)」,它会逐次取出高优先级任务,执行“一小段”后检查剩余时间,不足则暂停,空闲时恢复。

1. 核心流程(简化代码模拟)
// 模拟:最小堆任务队列(简化版,仅示意)
const taskQueue = [];

// 1. 新增任务(带优先级)
function scheduleTask(callback, priorityLevel) {
  // 步骤1:计算过期时间(当前时间 + 优先级对应的阈值)
  const expirationTime = Date.now() + getExpirationTimeByPriority(priorityLevel);
  // 步骤2:插入最小堆队列
  taskQueue.push({ callback, expirationTime, isPending: true });
  taskQueue.sort((a, b) => a.expirationTime - b.expirationTime); // 简化堆排序
  // 步骤3:触发调度(启动工作循环)
  startWorkLoop();
}

// 2. 工作循环(核心:执行/中断/恢复)
function workLoop(remainingTime) {
  // 取出堆顶任务(最高优先级)
  let currentTask = taskQueue[0];
  
  while (currentTask && remainingTime > 0) {
    // 检查任务是否过期/取消
    if (currentTask.expirationTime < Date.now() || !currentTask.isPending) {
      taskQueue.shift(); // 移除过期/取消的任务
      currentTask = taskQueue[0];
      continue;
    }

    // 执行任务的「一小段」(而非一次性执行完)
    // 关键点:callback 返回是否需要继续执行(true=未完成,false=完成)
    const needContinue = currentTask.callback(remainingTime);
    
    if (needContinue) {
      // 任务未完成,计算剩余时间(模拟执行消耗)
      remainingTime -= 1; // 假设执行一小段消耗 1ms
      // 剩余时间不足 5ms,中断任务
      if (remainingTime < 5) {
        console.log('⚠️ 时间不足,暂停任务');
        break; // 退出循环,任务留在队列中
      }
    } else {
      // 任务完成,移除队列
      taskQueue.shift();
      currentTask = taskQueue[0];
    }
  }

  // 任务未执行完,下次空闲时恢复
  if (currentTask) {
    // 重新触发 postMessage,等待下一次空闲
    channel.port1.postMessage('continue');
  }
}

// 3. 启动工作循环
function startWorkLoop() {
  requestAnimationFrame((timestamp) => {
    frameStartTime = timestamp;
    channel.port1.postMessage('start'); // 触发 postMessage 回调
  });
}

// 辅助函数:根据优先级获取过期时间
function getExpirationTimeByPriority(priority) {
  const priorities = {
    'immediate': 0,
    'user-blocking': 250,
    'normal': 5000,
    'low': 10000,
    'idle': Infinity
  };
  return priorities[priority] || 5000;
}

// 测试:新增一个用户交互任务(高优先级)
scheduleTask((remainingTime) => {
  console.log(`执行任务,剩余时间:${remainingTime}ms`);
  // 模拟任务需要多次执行(返回 true 表示未完成)
  return remainingTime > 10; // 剩余时间>10ms 才继续,否则暂停
}, 'user-blocking');
2. 关键逻辑拆解
  • 任务可中断:任务的回调函数不是一次性执行完,而是返回「是否需要继续执行」——如果剩余时间不足,工作循环会退出,任务留在队列中;
  • 任务可恢复:中断后,Scheduler 会再次触发 postMessage,下一次空闲时重新启动工作循环,从队列中取出未完成的任务继续执行;
  • 任务取消:每个任务都有 isPending 标记,外部可通过修改该标记取消任务,工作循环会跳过已取消的任务。

五、Scheduler 与 Fiber 的联动

Scheduler 并不直接操作 Fiber 节点,而是给 Fiber 的「工作循环」提供“时间切片”能力:

  1. Fiber 把「处理单个节点」作为小任务,传给 Scheduler;
  2. Scheduler 按优先级调度这个小任务,执行前检查空闲时间;
  3. 时间不足时,Scheduler 暂停执行,通知 Fiber 记录当前 workInProgress 节点;
  4. 下一次空闲时,Scheduler 恢复执行,Fiber 从 workInProgress 继续处理节点。

总结(核心关键点)

  1. 优先级实现:用「过期时间」量化优先级,结合「最小堆」保证高优先级任务先执行;
  2. 空闲时间计算:基于 rAF + postMessage 模拟高精度空闲回调,替代原生 requestIdleCallback,并预留 5ms 安全阈值;
  3. 执行/暂停控制:工作循环逐次执行任务的“小单元”,执行中持续检查剩余时间,不足则中断,下次空闲时从断点恢复。

简单来说,Scheduler 就像「交通调度员」:给紧急车辆(高优先级任务)开绿灯,普通车辆(低优先级任务)错峰通行,且所有车辆都能临时靠边(中断),等道路空闲后再继续行驶(恢复)。

调度逻辑的核心底气:单个 Fiber 执行足够短

React 这套调度逻辑的核心底气:从设计上把「单个 Fiber 执行足够短」做成「必然结果」,而非「偶然运气」,因此哪怕多执行一个,带来的耗时增量也只是微秒级,完全在浏览器的帧时间安全范围内,不会有任何“大碍”。

更准确地说,这个「坚信」不是凭空的设计自信,而是三层硬约束下的必然结论,让「单个 Fiber 执行短」成为不可打破的规则,这也是整个 Scheduler 敢放弃“精准预估”、采用“实时校验+容忍多执行一个”的底层前提:

1. 处理逻辑的硬约束:只做「纯内存原子操作」

单个 Fiber 节点的处理被严格限定在无阻塞、无复杂计算、无IO的内存操作内: props浅对比、副作用标记、链表指针读取,这些操作的耗时由 JS 引擎的内存访问速度决定,天然稳定在 0.1ms 以内,不会出现任何耗时突变。 不存在“一个 Fiber 处理突然花了 1ms”的可能,因为这类操作从根源上被排除在单个 Fiber 的处理逻辑之外。

2. 组件拆分的工程要求:复杂逻辑必须拆分为多个 Fiber - 对开发者,非强制

React 的 Fiber 树构建规则强制要求:任何包含复杂计算/逻辑的组件,必须拆分为子组件,进而对应多个 Fiber 节点。 比如一个渲染时要做大量数据计算的组件,React 会要求开发者拆成「计算子组件+展示子组件」,让每个子组件对应一个 Fiber,单个 Fiber 只承担一部分简单逻辑,从拆分层面保证单个处理的轻量化。

3. 阶段隔离的硬约束:耗时操作被彻底排除在 Reconciliation 阶段

React 把所有耗时操作(DOM 操作、样式计算、动画执行)都隔离到「Commit 阶段」,而 Reconciliation 阶段(Fiber 节点处理的阶段)只做纯内存的对比和标记。 Commit 阶段本身是不可中断的,但它的耗时操作是批量一次性执行的,且基于 Fiber 阶段的标记精准执行,不会有冗余操作,而 Fiber 阶段的轻量化则完全不受这些耗时操作的影响。

再补一层:“多执行一个也无碍”的实际体感

假设剩余时间是 1.6ms,单个 Fiber 执行耗时 0.1ms,就算因为实时校验的时机问题,多执行了3个,总耗时也只是增加 0.3ms,达到 1.9ms,远低于浏览器 16.6ms 的帧时长,更低于 5ms 的安全阈值。 这个增量在浏览器层面属于「微秒级误差」,不会导致帧丢失,用户完全感知不到任何卡顿——这就是 React 敢“容忍多执行一个”的核心原因:增量耗时在人类感知和浏览器渲染的安全范围内

最终结论

你的判断切中了核心:React 所有的调度策略(执行前预估、执行后实时校验、可中断恢复),都是建立在「单个 Fiber 执行足够短」这个不可动摇的基础上的。 这个基础不是“坚信”出来的,而是通过「逻辑限定、组件拆分、阶段隔离」三层硬约束做出来的,正是因为这个基础的存在,Scheduler 才不用纠结于“精准预估耗时”,不用害怕“多执行一个”,只用做简单的实时时间校验,就能实现高效、不阻塞的调度。

简单说:先把单个任务的耗时压到极致,再谈调度的容错性——这是 React Fiber 架构最底层的设计哲学。

剩余空闲时间 的具体计算方法

简单来说,剩余时间的计算核心是:以浏览器每帧的总时长(≈16.6ms)为基准,减去当前帧已消耗的时间,再预留 5ms 安全阈值,最终得到可用于执行 React 任务的空闲时间。下面我用「原理 + 代码 + 例子」的方式把计算逻辑拆透。

一、计算的核心前提

浏览器要保证页面流畅(60fps),每帧的总时长必须控制在 1000ms ÷ 60 ≈ 16.666ms(以下简称 16.6ms)。每帧内浏览器需要依次完成:

事件处理 → 宏任务 → 微任务 → requestAnimationFrame(rAF) → 布局(Layout) → 绘制(Paint) → 空闲时间

Scheduler 计算的「剩余时间」,就是当前帧总时长 - 已消耗时长,代表浏览器完成核心工作(布局、绘制)后,还剩多少时间可以执行 React 的低优先级任务。

二、剩余时间的完整计算步骤(带代码模拟)

Scheduler 对剩余时间的计算分为 4 步,核心依赖 requestAnimationFrame (rAF) 的高精度时间戳(而非 Date.now()),保证计算准确性:

步骤1:获取当前帧的「开始时间」(核心基准)

rAF 的回调函数会接收一个高精度时间戳(timestamp),这个时间戳是浏览器给出的「当前帧开始执行的时间」,是计算的唯一基准(比 Date.now() 更准,避免系统时间偏差)。

// 存储当前帧的开始时间(全局变量)
let frameStartTime = 0;

// 注册 rAF,在每帧开始时记录帧起始时间
function registerFrameStartTime() {
  requestAnimationFrame((timestamp) => {
    // timestamp:浏览器提供的高精度时间戳(单位:ms),代表当前帧的开始时间
    frameStartTime = timestamp;
    // 每帧都要重新注册,保证帧开始时间实时更新
    registerFrameStartTime();
  });
}

// 启动帧时间监听
registerFrameStartTime();
步骤2:计算「当前帧已消耗的时间」

从帧开始到「当前时刻」,已经用掉的时间,公式为:

已消耗时间 = 当前高精度时间 - 帧开始时间

代码实现:

// 获取当前高精度时间(兼容浏览器)
function getCurrentTime() {
  // performance.now() 是高精度时间(微秒级),比 Date.now() 更适合计算短时间间隔
  return performance.now();
}

// 计算已消耗时间
function getElapsedTime() {
  return getCurrentTime() - frameStartTime;
}
步骤3:计算「原始剩余时间」

原始剩余时间 = 每帧总时长(16.6ms) - 已消耗时间,代表当前帧理论上还剩的全部时间:

// 每帧总时长(60fps 下的标准值)
const FRAME_DURATION = 1000 / 60; // ≈16.666ms

// 计算原始剩余时间
function getRawRemainingTime() {
  const elapsedTime = getElapsedTime();
  return FRAME_DURATION - elapsedTime;
}
步骤4:应用「安全阈值」并处理边界情况

为了避免 React 任务占用过多时间导致浏览器来不及完成「布局/绘制」,Scheduler 会预留 5ms 安全阈值,并保证剩余时间不会为负数(负数代表当前帧已超时):

// 安全阈值:预留 5ms 给浏览器完成布局/绘制
const SAFE_THRESHOLD = 5;

// 最终可用的剩余时间(Scheduler 实际使用的)
function getRemainingTime() {
  const rawRemaining = getRawRemainingTime();
  // 1. 减去安全阈值;2. 保证结果 ≥ 0(避免负数)
  const remaining = rawRemaining - SAFE_THRESHOLD;
  return Math.max(0, remaining);
}

三、关键细节解释(为什么要这么算?)

1. 为什么用 performance.now() 而非 Date.now()
  • Date.now() 是「系统时间」,精度低(毫秒级),且可能被用户手动修改、时区同步等影响;
  • performance.now() 是「相对时间」(从页面加载开始计时),精度高达微秒级(1ms = 1000μs),完全不受系统时间影响——适合计算「帧内短时间间隔」。
2. 为什么要减 5ms 安全阈值?

16.6ms 的帧时长中,浏览器需要至少 10ms 左右完成「布局 + 绘制」(这是实践验证的经验值)。如果 React 任务把剩余时间用完(比如剩 5ms 全占用),浏览器会来不及完成渲染,导致帧丢失、页面卡顿。 减 5ms 后,即使 React 用满剩余时间,浏览器仍有足够时间完成核心渲染工作。

3. 剩余时间为 0 意味着什么?

如果 getRemainingTime() 返回 0,说明:

  • 当前帧已消耗的时间 ≥ 11.6ms(16.6 - 5);
  • 没有空闲时间执行 React 任务,Scheduler 会立即暂停任务,等待下一次帧的空闲时间。

四、实际场景举例(直观理解)

假设当前帧的执行情况如下:

  • 帧开始时间(frameStartTime):1000.0ms;
  • 当前时间(performance.now()):1010.0ms;

计算过程:

  1. 已消耗时间 = 1010.0 - 1000.0 = 10ms;
  2. 原始剩余时间 = 16.6 - 10 = 6.6ms;
  3. 最终剩余时间 = 6.6 - 5 = 1.6ms(取正值);

结论:Scheduler 只会给 React 任务分配 1.6ms 的执行时间,超时就中断。

再举一个超时场景:

  • 已消耗时间 = 15ms;
  • 原始剩余时间 = 16.6 - 15 = 1.6ms;
  • 最终剩余时间 = 1.6 - 5 = -3.4ms → 取 0;

结论:无空闲时间,暂停任务。

总结(核心关键点)

  1. 计算基准:以 60fps 下每帧 16.6ms 为总时长,剩余时间 = 总时长 - 已消耗时间 - 5ms 安全阈值;
  2. 时间精度:依赖 rAF 的高精度时间戳和 performance.now(),避免系统时间偏差;
  3. 边界处理:剩余时间最小为 0,保证不会出现负数(超时则暂停任务);
  4. 核心目的:预留足够时间给浏览器完成渲染,避免 React 任务阻塞页面流畅性。

简单来说,剩余时间的计算逻辑就是:先算当前帧还剩多少时间,再扣掉浏览器必须的渲染时间,剩下的才给 React 用——这是时间切片能「不阻塞渲染」的根本保障。

rAF 和 postMessage 的分工

Scheduler 并不是「在 requestAnimationFrame (rAF) 里开启任务」,而是用 rAF 获取「帧开始时间」(作为计算剩余时间的基准),真正执行任务的时机是在 postMessage 的宏任务回调中;时间不足时暂停任务后,也不是直接由 postMessage 触发处理剩余任务,而是下一次帧周期重新走「rAF 记时间 → postMessage 执行任务」的流程

下面我用「精准流程拆解 + 通俗比喻」把这个逻辑讲透,你就能清楚 rAF 和 postMessage 的分工,以及任务暂停/恢复的完整链路:

一、先纠正核心偏差:rAF 和 postMessage 的核心分工

角色 requestAnimationFrame (rAF) postMessage 宏任务
执行时机 每帧「布局/绘制前」(帧内核心工作前) 每帧「绘制完成后」(帧内空闲时间)
核心作用 记录「帧开始时间」(计算剩余时间的基准) 执行 React 任务(Fiber 处理)+ 检查剩余时间
是否直接执行任务 ❌ 不执行,只记时间 ✅ 是,真正的任务执行入口

二、Scheduler 完整执行流程(纠正后的准确版本)

用步骤拆解,你能清晰看到「记时间 → 算剩余 → 执行任务 → 暂停/恢复」的全链路:

步骤1:rAF 标记「帧开始时间」(准备工作)

浏览器每帧启动时,先触发 rAF 回调:

requestAnimationFrame((timestamp) => {
  // 记录当前帧的开始时间(高精度时间戳),作为后续计算剩余时间的基准
  frameStartTime = timestamp;
});

👉 这一步不执行任何 React 任务,只做「时间基准标记」,为后续计算“这一帧还剩多少空闲时间”铺路。

步骤2:postMessage 触发「任务执行」(核心)

rAF 执行后,浏览器会完成「布局/绘制」等核心工作,之后触发 postMessage 宏任务(这才是空闲时间):

const channel = new MessageChannel();
channel.port2.onmessage = () => {
  // 1. 先计算当前帧的剩余空闲时间(基于 rAF 记录的 frameStartTime)
  const remainingTime = getRemainingTime(); // 比如算出 1.6ms

  if (remainingTime > 0) {
    // 2. 有空闲时间 → 执行 React 任务(处理 Fiber 节点)
    const hasMoreWork = workLoop(remainingTime); // 执行任务,返回是否有剩余任务
    
    if (hasMoreWork) {
      // 3. 任务没做完(时间不足暂停)→ 等待下一次帧周期
      // 不是直接触发 postMessage,而是等下一次 rAF 后再走流程
      requestAnimationFrame(() => channel.port1.postMessage(''));
    }
  } else {
    // 4. 无空闲时间 → 直接等下一次帧周期
    requestAnimationFrame(() => channel.port1.postMessage(''));
  }
};
步骤3:workLoop 执行任务 + 时间不足则暂停

workLoop 里就是处理 Fiber 节点的核心逻辑:

  • 每处理一个 Fiber 节点,就检查「已用时间是否 ≥ 剩余时间」;
  • 时间不足时,workLoop 返回 true(表示有剩余任务),Scheduler 就停止执行;
  • 剩余任务不会立刻由 postMessage 触发,而是等下一个帧周期:先由 rAF 重新记录帧开始时间,再由 postMessage 回调执行剩余任务。

三、通俗比喻:把「帧周期」比作「一节课」

  • rAF = 上课铃:打铃时记录“上课开始时间”(帧开始时间),只记时间,不讲课;
  • 浏览器的布局/绘制 = 老师讲核心知识点:必须优先完成,不能打断;
  • postMessage = 课间休息:核心知识点讲完后,才有休息时间,此时才可以做“刷题(处理 Fiber 任务)”;
  • 剩余时间不足 = 课间休息快结束了:刷不完题就暂停,把剩下的题留到「下一节课的课间休息」(下一次帧的 postMessage)再做;
  • 下一次 rAF = 下一节课的上课铃:先记新的上课时间,再等新的课间休息(postMessage)刷题。

总结(核心关键点)

  1. rAF 只记时间,不执行任务它的作用是给剩余时间计算提供基准,不是任务执行入口;
  2. postMessage 才是任务执行的核心时机只有在绘制完成后的空闲时间(postMessage 回调),才会处理 Fiber 任务;
  3. 暂停后不是立即触发 postMessage剩余任务会等「下一次帧周期」,重新走「rAF 记时间 → postMessage 执行任务」的流程,保证始终在空闲时间执行任务,不阻塞渲染。

简单来说:Scheduler 是「先靠 rAF 定时间基准,再靠 postMessage 找空闲时间执行任务,时间不够就等下一轮空闲时间继续」—— 而非“rAF 开任务,postMessage 收尾”。

requestIdleCallback:想法很好,落地很拉垮

一、先明确:requestIdleCallback 到底是干啥的?

requestIdleCallback浏览器原生提供的「空闲时间调度 API」,核心设计目标和 React Scheduler 一致——让开发者在浏览器“没活儿干”的时候执行低优先级任务,避免占用核心渲染时间导致卡顿。

1. 核心工作逻辑

浏览器每帧(16.6ms)的执行顺序是:

事件处理 → 宏任务 → 微任务 → requestAnimationFrame(rAF) → 布局 → 绘制 → 【空闲时间】→ requestIdleCallback 回调

只有当「布局+绘制」完成后,当前帧还有剩余时间(比如只用了 10ms,剩 6.6ms),浏览器才会触发 requestIdleCallback 的回调函数。

2. 核心能力

回调函数会接收一个 IdleDeadline 对象,能拿到两个关键信息:

  • timeRemaining():返回当前帧还剩多少空闲时间(比如 6.6ms);
  • didTimeout:标记任务是否过期(若浏览器长期无空闲,任务会强制触发)。
3. 简单示例(原生用法)
// 注册一个空闲任务
requestIdleCallback((deadline) => {
  // 只要还有空闲时间,就执行任务
  while (deadline.timeRemaining() > 0) {
    doLowPriorityWork(); // 比如日志收集、非紧急数据处理
  }
});

简单说:requestIdleCallback 就是浏览器给开发者的「空闲时间接口」,让你把不紧急的任务塞到帧的空闲期执行。

二、为什么 React 不用它?(核心还是那两个致命问题,展开讲透)

虽然 requestIdleCallback 的设计初衷和 React Scheduler 一致,但它的两个硬伤让 React 完全无法用——咱们之前提过「兼容性差、触发时机不稳定」,这里结合实际场景展开:

问题 具体表现 对 React 的致命影响
兼容性极差 - IE 全版本不支持
- iOS Safari 仅 14.5+ 支持
- 安卓 WebView 大量不支持
React 要适配多端/多浏览器,依赖它会导致旧设备用户直接失去「可中断渲染」能力,退回卡顿状态
触发时机极不稳定 - 帧率 < 60fps 时(页面卡顿),浏览器判定「无空闲时间」,回调完全不触发
- 浏览器为了“保核心渲染”,会刻意压缩触发机会
React 的低优先级任务(比如列表渲染)会无限积压,甚至永远不执行;中断后的任务也无法恢复
无优先级调度能力 所有注册的任务都是“同等优先级”,无法让用户交互(高优先级)插队 用户点击按钮后,低优先级任务还在占用空闲时间,导致交互响应卡顿

三、关键补充:React 自研 Scheduler(rAF + postMessage)和 requestIdleCallback 的核心差异

React 不是“不用空闲时间调度”,而是「抛弃原生的 requestIdleCallback,自己实现了一个更靠谱的版本」:

特性 原生 requestIdleCallback React Scheduler(rAF + postMessage)
兼容性 差(仅新版浏览器支持) 好(rAF + postMessage 是基础 API,全浏览器兼容)
触发稳定性 帧率低时完全不触发 不管帧率多少,每帧都会尝试执行(剩余时间为0就等下一帧,不会积压)
优先级调度 支持5级优先级,高优先级任务(比如点击)可插队中断低优先级任务
时间控制精度 依赖浏览器内置计算,无法自定义 5ms 安全阈值 自研剩余时间计算,可精准预留 5ms 给浏览器渲染

总结(核心关键点)

  1. requestIdleCallback 是浏览器原生的「空闲时间调度 API」,作用是在帧的空闲期执行低优先级任务;
  2. React 不用它的核心原因:兼容性差(覆盖不全)、触发不稳定(帧率低时不执行)、无优先级调度,无法满足 Fiber 架构“可中断、高优先级优先”的核心需求;
  3. React 转而用 rAF + postMessage 自研 Scheduler,本质是「重新实现了一个更稳定、更可控、带优先级的 requestIdleCallback」。

简单来说:requestIdleCallback 是个“想法很好但落地拉胯”的原生 API,React 嫌它不好用,就自己造了个更强大的版本——这就是 Scheduler 的本质。

前端5分钟技术转全栈!orpc + cloudflare 项目实践

前言

标题党了一下哈哈,全栈还是有很多知识要学习的,这里只是简单介绍一套我觉得还不错的技术栈,希望对大家有帮助。

大前提: 请先注册一个 cloudflare 账户。5块钱买个域名绑定到 cloudflare 上,可以参考我之前的文章。

快速创建项目

我在 GitHub 上偶然发现了一个很有意思的项目,想推荐给大家: www.better-t-stack.dev/new

它提供了一个可视化页面,可以自由选择自己需要的技术栈,并自动生成初始化命令,一行命令就能创建完整项目。即使对技术栈不太熟悉,也可以直接使用社区中使用人数较多的预设配置,例如左下角提供的 T3 Stack、PERN Stack 等方案。同时还支持在线预览项目的文件结构,整体体验做得相当成熟。

这次我选择的是前后端统一部署到 Cloudflare,运行时使用 Cloudflare Workers,前后端之间通过 oRPC 通信。对于技术栈中一些不太熟悉的部分,我会在后文单独说明。通过复制生成的命令行即可完成项目初始化,最终得到的是一个前后端共存的 monorepo 项目结构,使用 Turbo 统一管理前后端的运行、调试和构建流程。

前后端代码全部采用 TypeScript 编写,对前端同学非常友好,上手成本也很低。(题外话: 我觉得写 JS / TS真幸福,应了那句话: 能用 JS 写的最后都用 JS 写😂)

pnpm create better-t-stack@latest cf-todo --frontend tanstack-router --backend hono --runtime workers --api orpc --auth none --payments none --database sqlite --orm drizzle --db-setup d1 --package-manager pnpm --git --web-deploy cloudflare --server-deploy cloudflare --install --addons biome turborepo --examples todo

运行项目

我们先把项目运行起来再看看代码。根据提示先运行一些Database的命令。pnpm run db:generate

然后启动本地看看效果 pnpm run dev (注: 如果项目运行不起来,尝试升级一下pnpm版本,这个monorepo借助了pnpm-workspace的特性,一些 catalog 可能需要 pnpm 高版本才支持。)

可以看到项目运行成功分别在本地 3001、3002端口,作者贴心的展示了一个TODO list示例。

了解代码

文件结构

很清爽的文件结构,apps下有前后端,然后packages下是一些共用的内容,根目录是格式化的biome与pnpm与turbo的配置。

Pnpm-workspace

www.pnpm.cn/catalogs 使用了catalogs 方便在各个子包中共享同一个npm包避免重复安装。

然后在 /apps/web/package.json/apps/server/package.json 我们就可以看到使用根目录的catalogs了。

www.pnpm.cn/workspaces workspaces 方便引用同一工作区的其他子模块的文件。

Hono

Hono 是一个轻量级的 Web 框架,主要面向 JavaScript 和 TypeScript 生态。它的设计目标是高性能、低开销,以及在不同运行时环境中的一致体验

官网: hono.dev/ ,常用与于 cloudflare 想结合出现。nodejs backend 框架也是多种多样的,选择自己喜欢的即可。

ORPC

orpc 简单讲就是 trpc 的进化版。

trpc可能有的同学也不是很了解,引用自官网的介绍 快速迭代,无忧无虑。轻松构建端到端类型安全的 API

说人话就是前后端共用一套TS,然后前端调用接口就可以直接以函数调用的方式访问后端接口,前端可以直接获得后端暴露的 API 类型定义。不用传统的前后端联调,后端给openapi文档,前端生成对应的TS接口响应入参与出参了,直接一套前端要调用接口直接点出来。

orpc 就是在 trpc 的基础上进行改造,官网: orpc.dev/docs/gettin… oRPC(开放 API 远程过程调用)结合了 RPC(远程过程调用)与 OpenAPI,允许您通过类型安全的 API 定义和调用远程(或本地)过程,同时遵循 OpenAPI 规范。 可以在享受trpc的同时生成openapi规范的文档。

访问我们这个示例项目的 http://localhost:3000/api-reference 就可以看到一个美观的openapi规范的在线接口示例

我们来看orpc有多方便。

packages\api\src\routers\todo.ts 接口的定义

apps\web\src\routes\todos.tsx 前端界面直接调用点出来要使用的函数。然后用 @tanstack/react-query 相关的hook进行处理,

orpc也可以很好的与tanstack-query相结合。这样子直接开发个人项目的时候就免去了接口联调的麻烦,写完后端前端直接调用。

drizzle

drizzle orm 就是方便你来增删改查数据的,在没有这些ORM的时候需要直接写SQL语句执行,有了这些ORM他们封装了一些方法让你更轻松的掌控数据。然后实在复杂的SQL也可以自定义。

node的ORM选择有蛮多的 选自己喜欢的或者大家推荐比较多的即可。

Alchemy

这个东西我也是第一次见到,我直接在bing搜还搜不到 需要加一些关键词,但感觉做的还不错,这里是官网: alchemy.run/what-is-alc… 简单讲就是帮你管理一些部署用的基础设施信息,我这里是部署到 cloudflare,然后有一个 alchemy.run.ts 文件就是管理全部部署到 cloudflare相关的事情。

如果你尝试 pnpm run deploy 部署这个项目的时候会发现运行不成功,因为你还没有给本地的 alchemy 授权你的 cloudflare 账户。

npm i -g alchemy安装完成后 alchemy configure 会打开一个授权页授权到本地即可。如果打不开或授权失败,请尝试打开vpn的 TUN 模式试下。

Turborepo

Turborepo 是一个用于 JavaScript 和 TypeScript 代码库的高性能构建系统。它专为扩展单体仓库而设计,也能加速单包工作区中的工作流。

vercel开源的一个项目,常用于处理 monorepo 类型仓库的构建。

Cloudflare

我们这个项目前后端都部署到 cf 的 woker上,有很多的免费额度,cf真是大善人。然后数据存储的数据库使用的也是 cf 的D1数据库,个人MVP的项目初期应该够用了,升级了也不贵,不愧是赛博佛祖。

项目部署

授权完成后,我们运行 pnpm run deploy 试一下。可以看到给出了前后端两个地址。

访问前端后发现,我靠这么请求的是localhost:3000, 原来我们前后端的 .env 文件都没进行修改。

在后端的根目录创建一个.env.dev文件,CORS_ORIGIN 填写前端访问的地址。

CORS_ORIGIN=[填写前端地址]

前端创建一个 .env.dev文件

VITE_SERVER_URL=[填写后端地址]

然后添加一个 pnpm run deploy:dev 命令在根目录的 package.json 文件,表示我们要对 env.dev 环境变量的内容进行打包构建。复制上一行的 deploy 命令基础上再添加了一个 ALCHEMY_ENV=dev 的标识。

"deploy:dev": "cross-env ALCHEMY_ENV=dev turbo -F @cf-todo/infra deploy",

再修改 alchemy.run.ts 文件,根据环境变量读取对应的env文件

import alchemy from "alchemy";
import { D1Database, Vite, Worker } from "alchemy/cloudflare";
import { config } from "dotenv";

const mode = process.env.ALCHEMY_ENV ?? process.env.NODE_ENV ?? "development";
const loadEnv = (path: string, override = false) => {
  config({ path, override });
};

loadEnv("./.env");
loadEnv(`./.env.${mode}`, true);
loadEnv("../../apps/web/.env");
loadEnv(`../../apps/web/.env.${mode}`, true);
loadEnv("../../apps/server/.env");
loadEnv(`../../apps/server/.env.${mode}`, true);

const app = await alchemy("cf-todo");

const db = await D1Database("database", {
  migrationsDir: "../../packages/db/src/migrations",
});

export const web = await Vite("web", {
  cwd: "../../apps/web",
  assets: "dist",
  bindings: {
    VITE_SERVER_URL: alchemy.env.VITE_SERVER_URL!,
  },
});

export const server = await Worker("server", {
  cwd: "../../apps/server",
  entrypoint: "src/index.ts",
  compatibility: "node",
  bindings: {
    DB: db,
    CORS_ORIGIN: alchemy.env.CORS_ORIGIN!,
  },
  dev: {
    port: 3000,
  },
});

console.log(`Web    -> ${web.url}`);
console.log(`Server -> ${server.url}`);

await app.finalize();

最后还需要在turbo.json 文件添加下构建传递的这个标识变量。

"deploy": {
  "cache": false,
  "env": ["ALCHEMY_ENV"]
},

重新部署

先运行 pnpm run destory 销毁之前的资源。再执行 pnpm run deploy:dev。nice 可以使用了,美滋滋~

结语

现在,一个简单完整的前后端项目已经准备好,并且可以零成本完成部署,已经交到你手里了。接下来就可以充分发挥想象力和创造力,去打磨一个让人眼前一亮的产品。你也可以选择继续深入研究这个项目,从中学习和理解当前主流的工程化实践与技术选型思路。希望对大家有帮助!

改造部署文件参考: github.com/LLmoskk/orp…

最后,感恩Cloudflare!感恩 better-t-stack.dev/new 项目!

根治监管报送“对不准”:从列级血缘到算子级血缘的数据治理新范式

本文首发于 Aloudata 官方技术博客:《列级血缘为何在 EAST 报送中“对不准”?算子级解析的降维打击》转载请注明出处。

摘要:在金融监管报送(如 EAST)场景中,传统列级血缘因 SQL 解析精度低(<80%)、无法处理复杂逻辑,导致指标口径追溯不全、人工盘点耗时数月。本文深入剖析了列级血缘的技术局限,并介绍了以算子级血缘为核心的新范式。通过 AST 深度解析、行级裁剪和白盒化口径提取等技术,算子级血缘将解析准确率提升至 >99%,实现监管指标“一键溯源”与自动化盘点,为数据治理和 DataOps 流程提供精准的溯源基座。

在金融监管报送(如 EAST、1104)领域,数据血缘的准确性直接关系到合规风险与运营效率。传统列级血缘技术因解析精度不足,已成为指标口径“对不准”、人工盘点“盘不动”的症结所在。本文将对比分析列级血缘的固有缺陷,并深入解读以算子级血缘(Operator-level Lineage) 为核心的技术新范式,如何通过 >99% 的解析准确率与行级裁剪能力,为监管报送构建可靠的自动化数据溯源基座。

一、核心痛点:EAST 报送中的数据溯源困局

金融监管指标背后是跨越数仓多层(ODS、明细层、汇总层、报表层)的复杂加工链路,涉及大量 SQL 转换、存储过程及临时表处理。传统数据血缘(表级/列级)在此场景下普遍失效,具体表现为:

  1. 盘点效率低下:面对成千上万的监管指标,数据团队需投入数周至数月进行人工“扒代码”和访谈,成本高昂。
  2. 追溯结果不可靠:行业反馈显示,开源列级血缘工具对 Hive SQL 的解析准确率通常低于 70%,近三分之一的依赖关系错误或缺失,为合规埋下隐患。
  3. 变更风险失控:无法精准评估上游字段或逻辑变更对下游报送指标的影响,导致“牵一发而动全身”,易引发数据错误或报送延误。

血缘对比.png

二、技术剖析:列级血缘为何“力不从心”?

列级血缘的局限源于其技术原理,它通常基于正则匹配或浅层语法分析,只能识别“A 表的 X 列出现在 B 表 Y 列的 SELECT 语句中”,但无法理解其间的计算逻辑。这导致三大硬伤:

  • 解析精度天花板低:对包含 CASE WHEN、窗口函数、多层嵌套子查询的复杂 SQL 解析能力弱,准确率普遍低于 80%。
  • 无法穿透黑盒逻辑:对 DB2、Oracle 的 PL/SQL 存储过程、动态 SQL、临时表加工等场景几乎无法解析,造成血缘链路断点。
  • 影响分析过度泛化:缺乏对 WHEREJOIN ON 等过滤条件的识别。例如,一个仅影响特定分行的源数据变更,会触发所有相关下游任务的告警,噪音率可超过 80%。
对比维度 传统列级血缘 算子级血缘 (如 Aloudata BIG)
解析粒度 列级,仅知“从哪列到哪列” 算子级,可知“经过怎样的计算(过滤、连接、聚合)从哪列到哪列”
解析准确率 通常 < 80%,复杂 SQL 下更低 > 99%,基于 AST 深度解析
复杂场景支持 弱,难以处理存储过程、动态 SQL、临时表 强,深度支持 DB2、GaussDB 等 PL/SQL,穿透临时表
影响分析精度 粗粒度,易泛化,噪音大 行级裁剪,精准识别过滤条件,聚焦真实影响范围
口径提取 需人工拼接多层代码 白盒化口径提取,自动生成可读、可验证的最终加工逻辑

三、新范式:算子级血缘的核心原理与“降维打击”

算子级血缘实现了技术范式的跃迁。它深入 SQL 内部,将数据加工过程解析为最细粒度的算子(Operator)序列,如 Filter(过滤)、Join(连接)、Aggregation(聚合)等。结合以下核心技术,实现对传统方法的“降维打击”:

  1. 行级裁剪 (Row-level Pruning):精准识别 SQL 中的过滤条件(WHEREJOIN ON)。当上游数据变更时,系统能自动判断变更是否落入下游任务所关心的数据子集内,从而剔除无关的上游分支,使影响评估范围平均降低 80% 以上,实现精准风险预警。
  2. 复杂场景全覆盖:基于对多 SQL 方言(Hive, Spark, Oracle, DB2 等)及 PL/SQL 的深度解析能力,可穿透存储过程、动态 SQL、临时表等传统黑盒,构建端到端的完整血缘链路。
  3. 白盒化口径提取:针对跨多层加工的监管指标,系统能自动将沿途的所有 SELECTCASE WHEN、函数调用等逻辑,“压缩”成一段从最终指标反向追溯到源字段的、可读性极高的“加工口径”,直接替代人工“扒代码”。

四、实践验证:算子级血缘在金融场景的落地成效

该技术已在多家金融机构的 EAST 报送场景中得到验证:

浙江农商联合银行:通过部署具备算子级血缘能力的 Aloudata BIG 平台,实现了监管指标溯源人效提升 20 倍,全量指标口径盘点从数月缩短至 8 小时;对核心 DB2 存储过程的解析准确率达到 99%,攻克技术难关;自动生成符合监管要求的指标加工口径报告。

共性价值:算子级血缘实现的“一键溯源”能力,不仅大幅提升合规效率,更将管理动作从事后补救转向事前防控与事中协同,精准管控上游变更对下游报送指标的影响。

五、实施路径:构建 EAST 报送的数据溯源基座

企业可遵循以下三步,系统性构建高可靠的数据溯源能力:

1、基座先行:优先接入核心数仓(Hive, Oracle)、ETL/ELT 平台(DataStage, Kettle)及 BI 系统,快速构建覆盖“入仓->加工->服务”全链路的算子级血缘图谱。

2、场景驱动:选择 EAST、1104 等具体监管报表作为首场景,利用“一键溯源”快速验证价值,赢得业务与合规部门支持。

3、流程嵌入:将血缘能力深度嵌入 DataOps 与合规流程:

  • 研发侧:代码提交前自动进行变更影响分析,识别波及的报送指标。
  • 运维侧:发生数据异常时,利用血缘图谱快速定位根因。
  • 合规侧:建立基于血缘的自动化口径报告与审计机制。

六、常见问题(FAQ)

Q1: 列级血缘和算子级血缘的核心区别是什么?

最本质的区别是解析粒度。列级血缘仅知道字段的流向,而算子级血缘能还原完整的计算逻辑,例如“A.X 列经过 WHERE 过滤后,与 C 表 Z 列 LEFT JOIN,再 GROUP BY 生成 B.Y 列”,实现加工过程的白盒化。

Q2: 对复杂的存储过程和嵌套查询,算子级血缘解析效果如何?

这是算子级血缘的核心优势。它针对 DB2、Oracle 等 PL/SQL 存储过程、动态 SQL 及多层嵌套查询进行了深度优化,解析准确率可超过 99%,能有效穿透这些传统血缘工具的解析盲区。

Q3: 引入算子级血缘对 EAST 报送的具体价值是什么?

主要体现在三方面:效率提升(盘点从数月缩短到几小时)、准确性保障(>99% 解析准确率确保口径完整正确)、风险防控(精准评估上游变更影响,实现主动预警)。

核心要点

  1. 精度是核心:传统列级血缘低解析精度(<80%)是 EAST 报送“对不准”的根源。
  2. 算子级是解药:算子级血缘通过 AST 深度解析 Filter、Join 等算子,实现 >99% 的解析准确率。
  3. 行级裁剪提效:行级裁剪技术能精准识别数据子集,将变更影响分析范围平均降低 80% 以上。
  4. 案例验证价值:在标杆案例中,算子级血缘已将监管指标盘点从数月缩短至 8 小时,人效提升 20 倍。
  5. 构建溯源基座:企业应优先建设全链路算子级血缘,并以此驱动 DataOps 与自动化合规流程。

再次提醒:本文更详细的图表与案例细节,请访问Aloudata官方技术博客阅读原文:ai.noetl.cn/knowledge-b…

Three.js 曲线应用详解

概述

本文将详细介绍如何使用 Three.js 中的曲线功能。我们将学习如何创建 CatmullRomCurve3 样条曲线,并利用曲线来控制物体的运动轨迹,以及如何将曲线可视化地显示在场景中。

screenshot_2026-01-28_17-49-20.gif

准备工作

首先,我们需要引入必要的 Three.js 库:

import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";

场景初始化

首先,我们需要创建一个基本的 Three.js 场景:

let camera, scene, renderer;
const clock = new THREE.Clock();
const textureLoader = new THREE.TextureLoader();

let moon;
let earth;
let curve;
const raycaster = new THREE.Raycaster();

function init() {
  const EARTH_RADIUS = 1;
  const MOON_RADIUS = 0.27;

  // 创建透视相机
  camera = new THREE.PerspectiveCamera(
    45,
    window.innerWidth / window.innerHeight,
    1,
    200
  );
  camera.position.set(0, 5, -10);

  // 初始化场景
  scene = new THREE.Scene();
}

光照设置

添加适当的光照使场景更真实:

// 方向光
const dirLight = new THREE.DirectionalLight(0xffffff);
dirLight.position.set(0, 0, -1);
scene.add(dirLight);

// 环境光
const light = new THREE.AmbientLight(0xffffff, 0.5); // soft white light
scene.add(light);

创建地球和月球模型

使用纹理贴图创建地球和月球模型:

// 创建地球
const earthGeometry = new THREE.SphereGeometry(EARTH_RADIUS, 16, 16);
const earthMaterial = new THREE.MeshPhongMaterial({
  specular: 0x333333,
  shininess: 5,
  map: textureLoader.load("textures/planets/earth_atmos_2048.jpg"),
  specularMap: textureLoader.load("textures/planets/earth_specular_2048.jpg"),
  normalMap: textureLoader.load("textures/planets/earth_normal_2048.jpg"),
  normalScale: new THREE.Vector2(0.85, 0.85),
});

earth = new THREE.Mesh(earthGeometry, earthMaterial);
scene.add(earth);

// 创建月球
const moonGeometry = new THREE.SphereGeometry(MOON_RADIUS, 16, 16);
const moonMaterial = new THREE.MeshPhongMaterial({
  shininess: 5,
  map: textureLoader.load("textures/planets/moon_1024.jpg"),
});
moon = new THREE.Mesh(moonGeometry, moonMaterial);
scene.add(moon);

创建 CatmullRomCurve3 曲线

这是关键部分,我们使用一系列点来创建平滑的 CatmullRomCurve3 样条曲线:

// 根据这一系列的点创建闭合曲线
curve = new THREE.CatmullRomCurve3(
  [
    new THREE.Vector3(-10, 0, 10),  // 起始点
    new THREE.Vector3(-5, 5, 5),    // 控制点1
    new THREE.Vector3(0, 0, 5),     // 控制点2
    new THREE.Vector3(5, -5, 5),    // 控制点3
    new THREE.Vector3(10, 0, 10),   // 结束点
  ],
  true  // true 表示创建闭合曲线,false 表示开放曲线
);

CatmullRomCurve3 曲线是一种样条曲线,它会经过所有的控制点,形成平滑的路径。参数 true 表示创建闭合曲线,即曲线的终点会连接到起点。

可视化曲线

将曲线可视化地显示在场景中:

// 在曲线里,getPoints获取501个点(500个分段+1)
const points = curve.getPoints(500);
console.log(points);
const geometry = new THREE.BufferGeometry().setFromPoints(points);

const material = new THREE.LineBasicMaterial({ color: 0xff0000 });

// 创建线条对象并添加到场景
const curveObject = new THREE.Line(geometry, material);
scene.add(curveObject);

getPoints(divisions) 方法会沿着曲线均匀地采样指定数量的点。在这里,我们使用 500 个分段,因此会得到 501 个点(包括起始点和结束点)。这样可以确保曲线看起来平滑流畅。

渲染器和控制器设置

设置渲染器和控制器:

renderer = new THREE.WebGLRenderer();
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

const controls = new OrbitControls(camera, renderer.domElement);
controls.minDistance = 5;
controls.maxDistance = 100;

动画循环与曲线运动

在动画循环中,我们让月球沿着曲线运动,同时相机也跟随曲线:

function animate() {
  requestAnimationFrame(animate);

  const elapsed = clock.getElapsedTime();
  // 计算归一化的时间参数(0到1之间)
  const time = elapsed/10 % 1;
  
  // 根据时间参数获取曲线上对应的点
  const point = curve.getPoint(time);
  
  // 将月球位置设置为曲线上当前点的位置
  moon.position.copy(point);
  
  // 相机也跟随曲线运动
  camera.position.copy(point);
  
  // 相机始终看向地球位置
  camera.lookAt(earth.position)
  
  // 渲染场景
  renderer.render(scene, camera);
}

getPoint(t) 方法用于获取曲线上特定参数位置的点,其中 t 是一个介于 0 和 1 之间的值。当 t=0 时,返回曲线的起始点;当 t=1 时,返回曲线的结束点(在闭合曲线的情况下,这与起始点相同)。

曲线类型详解

Three.js 提供了多种曲线类型:

  1. CatmullRomCurve3: 经过所有控制点的三次样条曲线
  2. CubicBezierCurve3: 三次贝塞尔曲线
  3. QuadraticBezierCurve3: 二次贝塞尔曲线
  4. LineCurve3: 直线段

CatmullRomCurve3 曲线参数

CatmullRomCurve3 曲线构造函数接受以下参数:

new THREE.CatmullRomCurve3(points, closed, curveType, tension)
  • points: Vector3 类型的数组,定义曲线经过的点
  • closed: Boolean 类型,是否闭合曲线
  • curveType: String 类型,默认为 "centripetal",还有 "chordal" 和 "catmullrom"
  • tension: Number 类型,张力参数,默认为 0.5,值越大曲线越紧绷

应用场景

曲线在 Three.js 中有多种应用场景:

  1. 路径动画: 让物体沿着预设路径运动
  2. 摄像机动画: 让摄像机沿着路径移动,创建电影般的镜头效果
  3. 地形生成: 使用曲线生成平滑的地形轮廓
  4. 粒子系统: 控制粒子的运动轨迹
  5. UI 动画: 创建复杂的 UI 动画效果

总结

通过这个项目,我们学习了如何使用 Three.js 的曲线功能:

  1. 如何创建 CatmullRomCurve3 样条曲线
  2. 如何将曲线可视化显示在场景中
  3. 如何让物体沿着曲线运动
  4. 如何控制动画的时间参数
  5. 曲线在 3D 应用中的多种用途

曲线是 Three.js 中一个非常强大的功能,它可以让我们创建流畅的动画和复杂的路径效果,为我们的 3D 应用增添更多的动态美感。

90% 的人读不懂 Vue 源码,是因为没做这些准备

前言

相信很多人阅读源码都是从响应式开始看起的,clone vue源码之后,查看reactivity下的reactive.ts模块,一行行阅读;十分钟后,合上编辑器,心里只剩下一个问题:“是我太菜,还是源码本来就看不懂?”

事实是——绝大多数人不是看不懂源码,而是用错了方式。

源码不是文档,它不是按“教学顺序”写的,而是按工程、性能和长期维护来组织的。如果你在不了解项目结构、不知道入口、不具备调试手段的情况下直接硬读,很容易陷入“每一行都认识,但整体完全不懂”的状态。

这也是为什么:
90% 的人读不懂 Vue 源码,并不是能力问题,而是没做阅读前的准备。

为什么要读源码

在阅读源码前,我们一定要问自己:为什么阅读源码? 自己希望从源码中得知什么?

如今源码阅读已成潮流,其中不乏培训机构的过度鼓吹,制造 “不懂源码就找不到工作” 的焦虑,让很多人盲目卷入内卷。但回归现实,多数面试并不会深究源码细节,核心还是考察 Vue 的设计思想与核心原理。如果单纯为了应试,针对性梳理高频面试题、核心知识点,远比通读源码简单高效、性价比更高。

而如果是出于对技术的兴趣,想要揭开 Vue 内部的运行原理,学习框架中精妙的设计思路、架构模式与工程化实践,或是为了解决开发中难以定位的疑难问题、完成框架的二次封装与定制开发,深入研读源码就非常有必要。这能帮我们跳出 “只会使用框架” 的层面,真正理解底层逻辑,大幅提升架构思维与问题解决能力。

源码不是让你“一行行啃”,而是让你“顺着主线走”

很多人一提到“读源码”,脑海里就会自动等同成:
从第一行看到最后一行,一行都不能漏。

但这恰恰是读源码最容易走偏的地方。

以 Vue 为例,源码中充满了大量的:

  • 边界判断(if (__DEV__)if (!isObject)
  • 兼容处理
  • 性能优化分支
  • 为极端场景准备的兜底逻辑

这些代码本身没有错,但如果一开始就死磕每一个分支,只会让你迅速迷失在细节里。

  • 真正高效的源码阅读方式:抓住「核心主线」

读源码时,更重要的不是“这一行在干嘛”,
而是先回答清楚这三个问题:

  1. 这个模块的核心职责是什么?
  2. 主流程是如何从入口一路走到出口的?
  3. 中途哪些是核心逻辑,哪些是边界保护?

比如在 Vue 响应式系统中:

  • 主线只有一条

    访问数据 → 依赖收集 → 数据变化 → 触发更新

只要你能顺着这条主线走通:

  • track 什么时候执行
  • trigger 如何通知订阅者
  • effect / computed 是如何被调度的

那些零散的判断条件,其实都是围绕主线展开的“护栏”。

  • 边界判断不是现在的重点

这并不意味着边界判断不重要,而是:

它们更适合在你第二次、第三次读源码时再深入。

第一次读源码,你的目标应该是:

  • 建立整体执行流程的心智模型
  • 知道“这一坨代码大概负责什么”
  • 能把关键函数串成一条完整链路

当你已经清楚主线之后,再回头看这些判断:

  • 你会知道为什么要加

  • 也能理解为什么要写得这么绕

  • 甚至能体会到框架作者在做取舍时的无奈

  • 用调试而不是“意念”来跟主线

抓主线还有一个非常重要的前提:
不要靠猜,而是让代码跑起来。

通过 SourceMap + 断点:

  • 从入口函数开始打断点
  • 顺着调用栈一路向下
  • 只跟踪你当前关心的那条路径

你会发现:

  • 80% 的代码你暂时根本不用看
  • 真正影响理解的,往往只有那 20% 的核心逻辑

开启sourceMap

接下来让我们揭开Vue的神秘面纱 Vue3源码地址, 推荐大家fork项目到自己仓库阅读

Vue 的源码并不是一堆散乱的文件,而是一个 Monorepo 工程,核心逻辑被清晰地拆分在 packages 目录,我们在vue源码的文档中,可以看到详细的介绍github.com/vuejs/core/…

image.png

运行pnpm install安装相关依赖

vue源码项目采用的是rollup进行打包的,我们可以查看package.json打包命令, "build": "build": "node scripts/build.js", 运行pnpm build生成 dist 打包文件

我们在packages/vue/examples下创建自己的demo文件夹,比如说之后我想看响应式相关源码,可以创建类似的demo文件 packages/vue/examples/demo/reactive/index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Vue Effect Test</title>

    <!-- 引入你本地 clone 的 vue.global.js -->
    <script src="../../../../vue/dist/vue.global.js"></script>
</head>

<body>
    <div id="text"></div>
    <div id="text1"></div>
    <button id="btn">count1++</button>
    <button id="btn2">count2++</button> 

    <script>
        const { reactive, ref, effect } = Vue;

        // 创建响应式数据
        const state = reactive({
            count1: 1,
            count2: 10
        });

        // 绑定 effect:依赖收集 + 自动更新
        effect(() => {
            document.getElementById('text').innerText =
                `当前 count 值:${state.count1 + state.count2}`;
        });

        effect(() => {
            document.getElementById('text1').innerText =
                `当前 count 值:${state.count1 + state.count2 + 100}`;
        });


        // 事件:修改数据触发 effect 自动执行
        document.getElementById('btn').onclick = () => {
            state.count1++;
            state.count2++;
        };
        document.getElementById('btn2').onclick = () => {
            state.count1 = state.count1 + 5;
            state.count2 = state.count2 + 5
        };
    </script>
</body>
</html>

然后通过liveServer打开index.html(需要安装live server插件) image.png 但是我们会发现在source中查看源代码,发现并没有开启 sourceMap

image.png

接下来我们开始vuesourceMap, 我们可以看到pnpm build命令质上还是执行的script文件夹下的build.js打包脚本

image.png 而这个sourceMap开始是在指令中通过-s开启的

image.png

rollup.config.js会根据sourceMap指令生成sourceMap文件

image.png

接下来我们修改在package.json中的build脚本,增加-s标识("build": "node scripts/build.js -s",), 然后重新执行 pnpm build, 这样打包出来的文件就有sourceMap文件

image.png

源码debuger技巧

比如说,我们想看响应式相关的逻辑,可以找到reactive函数处,设置断点,然后按步骤阅读 image.png

总结

本节主要介绍阅读源码前的准备,后续将逐步带大家阅读vue源码, 如果对你有帮助,帮忙点个关注、赞~

SpringBoot+Uniapp实战开发全新仿抖音短视频App【2022升级版】

一、 项目架构与技术选型

在开始之前,我们要明确这个项目的核心逻辑:移动端负责展示与交互,后端负责内容分发与业务逻辑学习地址:pan.baidu.com/s/1WwerIZ_elz_FyPKqXAiZCA?pwd=waug

  • 后端:SpringBoot + MyBatis-Plus + Redis + MinIO(对象存储)

    • 理由:SpringBoot 生态成熟,处理高并发请求(如视频流、点赞)稳定;Redis 用于缓存热门视频和做分布式锁;MinIO 自建私有云存储,比 OSS 成本更低,适合副业项目起步。
  • 前端:Uniapp (Vue3) + TypeScript

    • 理由:一套代码,多端发布(iOS、Android、H5、小程序),性价比极高。

二、 后端核心实现

1. 数据库设计

我们需要核心的几张表:视频表、用户表、点赞表。

sql

复制

CREATE TABLE `tb_video` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `user_id` bigint(20) DEFAULT NULL COMMENT '发布者ID',
  `video_url` varchar(255) NOT NULL COMMENT '视频地址',
  `cover_url` varchar(255) DEFAULT NULL COMMENT '封面地址',
  `title` varchar(50) DEFAULT NULL COMMENT '视频标题',
  `like_count` int(11) DEFAULT '0' COMMENT '点赞数',
  `is_deleted` tinyint(1) DEFAULT '0',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

2. 视频流接口(核心 Feed 流)

仿抖音的核心是“滑动刷新”,我们需要一个高效的分页查询接口。这里使用 MyBatis-Plus 简化开发。

Entity 实体类 (Video.java):

java

复制

@Data
@TableName("tb_video")
public class Video {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Long userId;
    private String videoUrl;
    private String coverUrl;
    private String title;
    private Integer likeCount;
    // getter/setter 省略,使用 Lombok @Data
}

Service 业务层 (VideoService.java):
这里演示基础的分页查询。在生产环境中,通常结合 Redis 做推荐算法(如基于权重的 Feed 流)。

java

复制

@Service
public class VideoService {

    @Autowired
    private VideoMapper videoMapper;

    /**
     * 分页获取视频列表(仿抖音首页Feed流)
     */
    public IPage<Video> getVideoFeed(long current, long size) {
        Page<Video> page = new Page<>(current, size);
        // 按照创建时间倒序排列
        QueryWrapper<Video> queryWrapper = new QueryWrapper<>();
        queryWrapper.orderByDesc("create_time");
        return videoMapper.selectPage(page, queryWrapper);
    }
}

Controller 控制层 (VideoController.java):

java

复制

@RestController
@RequestMapping("/api/video")
public class VideoController {

    @Autowired
    private VideoService videoService;

    @GetMapping("/feed")
    public Result getFeed(@RequestParam(defaultValue = "1") long current,
                          @RequestParam(defaultValue = "5") long size) {
        IPage<Video> videoPage = videoService.getVideoFeed(current, size);
        return Result.success(videoPage);
    }
}

3. 文件上传接口 (MinIO)

视频上传是短视频应用最消耗资源的部分。

java

复制

@PostMapping("/upload")
public Result uploadVideo(MultipartFile file) {
    try {
        // 1. 生成唯一文件名
        String fileName = UUID.randomUUID().toString() + ".mp4";
        // 2. 调用 MinIO 工具类上传(需自行配置 MinIOClient)
        String url = MinIOUtil.upload(file.getInputStream(), fileName);
        // 3. 返回访问地址
        return Result.success(url);
    } catch (Exception e) {
        e.printStackTrace();
        return Result.error("上传失败");
    }
}

三、 前端核心实现

1. 视频滑页组件

这是仿抖音的灵魂,使用 swiper 组件实现垂直滑动。

四、 避坑指南与上线优化

结合项目经验,这里有几个关键点需要注意:

  1. 视频加载优化:不要一次性加载所有视频,使用“预加载”机制。在用户滑动到 index+1 时,在后台静默加载下一个视频资源。
  2. 视频格式转码:用户上传的视频格式五花八门,后端必须使用 FFmpeg 进行统一转码(转为 H.264 编码的 MP4),否则在 Android 和 iOS 上会出现兼容性问题。
  3. CDN 加速:如果你想真正做到“可上线”,视频资源必须走 CDN。MinIO 只是存储,必须结合 CDN 回源配置,否则用户播放会卡顿。
  4. Uniapp 原生插件:对于视频的高级特效(滤镜、贴纸),Uniapp 默认的 <video> 标签能力有限,可能需要购买或引入原生插件(如 DCloud 市场的短视频 SDK)。

这套方案跑通后,你不仅拥有了一个可演示的 App,更掌握了一条完整的“短视频后端+跨端前端”技术链。对于做副业来说,这既可以作为外包项目的案例,也可以作为你自己打造独立产品的起点。

我把 Claude Code 搬进了 Slack,从此蹲坑也能 Vibe Coding

我把 Claude Code 搬进了 Slack,从此蹲坑也能 Vibe Coding

周末两天肝出一个工具,让 AI 帮我干活,我在 Slack 里指挥就行

🤔 起因:一次深刻的厕所沉思

那是一个普通的下午,我正在电脑前和 Claude Code 愉快地 vibe coding。需求聊着聊着,代码写着写着,突然,肚子一阵翻涌 —— 该去解决人生大事了。

坐在马桶上,我习惯性地掏出手机刷了会儿。刷着刷着突然想起来:卧槽,刚才让 Claude 改的那个函数,我还没确认呢,它现在在干嘛?

更要命的是,我还想继续和它聊,让它把剩下的逻辑也写了。但是... 我在厕所啊!

我盯着手机屏幕陷入了沉思:

  • 回去继续?—— 但这坨还没解决完
  • 用手机 SSH?—— 上次试过,vim 在手机上简直是酷刑
  • 干等着?—— 这也太浪费时间了

就在这个充满哲学意味的时刻,一个想法击中了我:

为什么我不能在手机上继续 vibe coding?

现在 AI 编程这么火,Claude Code 那么好用,凭什么非得坐在电脑前?我就不能蹲着坑,发条消息,让 Claude 继续帮我干活?

想到这里,我感觉这坨💩都拉得更顺畅了。

说干就干。

💡 想法:让 Claude Code 成为我的远程员工

思路其实很简单:

我 (Slack) --> 消息 --> 服务器 --> Claude Code --> 代码修改 --> 结果返回 --> Slack

把 Claude Code CLI 包装成一个服务,跑在我的开发服务器上,然后通过 Slack 这类 IM 工具来和它对话。这样我就可以:

  • 🚽 蹲坑的时候继续 vibe coding
  • 📱 躺床上用手机改代码
  • 🚇 地铁上处理紧急 bug
  • 🍜 吃饭的时候让 AI 跑着任务

我给它起名叫 Heimerdinger —— 英雄联盟里那个小矮子发明家。因为这工具就像有个小机器人帮你干活一样。

🏗️ 架构设计:踩过的第一个坑

最初的设想

一开始我想得很简单:

  1. 监听 Slack 消息
  2. 调用 Claude CLI
  3. 把结果发回去

三步走,完事儿。

现实的暴击

实际做的时候才发现,事情没那么简单:

问题一:Claude Code 的输出是流式的

Claude 不是一下子吐出所有结果,而是一个字一个字往外蹦的。如果我等它全部输出完再发 Slack,用户体验会很差 —— 要等好久才能看到结果。

问题二:一个 Slack workspace 可能有多个项目

不同的频道可能在聊不同的项目,我需要记住"这个频道正在操作哪个项目"。

问题三:会话连续性

Claude Code 有会话概念,同一个会话里它能记住上下文。如果每次都开新会话,那用户说"把刚才那个函数改一下",Claude 根本不知道"刚才"是啥。

最终架构

flowchart TB
    subgraph daemon["hmdg daemon"]
        subgraph adapters["IM Adapters"]
            slack["Slack Adapter"]
            feishu["Feishu Adapter"]
            discord["Discord...<br/>(Future)"]
        end

        subgraph processor["Message Processor"]
            state["Session State<br/>Project State"]
        end

        claude["Claude Code CLI<br/>(streaming JSON)"]

        slack --> processor
        feishu --> processor
        discord --> processor
        processor --> claude
    end

    user["📱 你 (手机/电脑)"] <--> slack
    user <--> feishu

我设计了一个适配器模式,把不同 IM 平台的差异封装起来。这样以后想支持飞书、Discord 什么的,只需要写个新 Adapter 就行。

🔧 技术实现:细节里全是魔鬼

1. 流式输出的处理

Claude Code 支持 --output-format stream-json 参数,会输出 JSONL 格式的流式数据:

{"type":"assistant","message":{"content":[{"type":"text","text":"让我来"}]}}
{"type":"assistant","message":{"content":[{"type":"text","text":"让我来看看"}]}}
{"type":"assistant","message":{"content":[{"type":"text","text":"让我来看看这个bug"}]}}

我需要实时解析这些 JSON,然后更新 Slack 消息。

但这里有个坑:Slack 有 API 频率限制

如果每收到一个 JSON 就更新一次消息,很快就会被限流。所以我做了个节流处理:

// 至少间隔 1 秒才更新一次消息
const MIN_UPDATE_INTERVAL = 1000;
let lastUpdateTime = 0;

function throttledUpdate(content: string) {
  const now = Date.now();
  if (now - lastUpdateTime >= MIN_UPDATE_INTERVAL) {
    updateMessage(content);
    lastUpdateTime = now;
  }
}

2. 会话状态管理

这是整个项目最复杂的部分。我需要维护三层状态:

// 每个频道当前选择的项目
const userStates = new Map<string, ChannelState>();

// 每个项目最后使用的会话 ID
const projectSessions = new Map<string, string>();

// 正在执行的任务(用于支持 /stop 命令)
const activeExecutions = new Map<string, ExecutionInfo>();

而且这些状态要持久化,不然服务重启就全丢了:

// 状态持久化到 ~/.heimerdinger/sessions-state.json
function saveState() {
  fs.writeFileSync(STATE_FILE, JSON.stringify({
    userStates: Object.fromEntries(userStates),
    projectSessions: Object.fromEntries(projectSessions)
  }));
}

3. 项目发现机制

Claude Code 会把项目信息存在 ~/.claude/projects/ 目录下,目录名是项目路径的编码形式:

~/.claude/projects/
├── home-dev-project-a/
├── home-dev-my-project/
└── Users-test-my-app/

编码规则很简单:把 / 替换成 -。但解码就头疼了。

比如 home-dev-my-project,它可能是:

  • /home/dev/my/project —— 4 层目录
  • /home/dev/my-project —— 3 层目录,最后一层本身带连字符

没法直接区分哪个 - 是原来的 /,哪个是路径本身就有的。

一开始我想简单处理:

// 简单粗暴,但是错的
function decodePath(encoded: string): string {
  return '/' + encoded.replace(/-/g, '/');
}
// home-dev-my-project -> /home/dev/my/project ❌ 错!

后来想到一个办法:穷举所有可能的组合,看哪个路径在文件系统里真实存在

function decodeProjectPath(encodedPath: string): string {
  const parts = encodedPath.split('-');  // ['home', 'dev', 'my', 'project']
  const result = findValidPath('', parts, 0);
  return result || `/${encodedPath.replace(/-/g, '/')}`;  // fallback
}

// 递归 + 回溯,尝试所有可能的路径组合
function findValidPath(current: string, parts: string[], index: number): string | null {
  if (index >= parts.length) {
    return existsSync(current) ? current : null;
  }

  // 从 index 开始,尝试把连续的 parts 拼成一个目录名
  for (let i = index; i < parts.length; i++) {
    const segment = parts.slice(index, i + 1).join('-');  // 'my' 或 'my-project'
    const newPath = `${current}/${segment}`;

    if (i === parts.length - 1) {
      // 最后一段了,检查路径是否存在
      if (existsSync(newPath)) return newPath;
    } else {
      // 继续递归
      const result = findValidPath(newPath, parts, i + 1);
      if (result) return result;
    }
  }
  return null;
}

举个例子,对于 home-dev-my-project

尝试 /home/dev/my/project  → existsSync() = false ❌
尝试 /home/dev/my-project  → existsSync() = true  ✅ 找到了!

本质就是暴力搜索,但因为目录层级不会太深,性能完全可以接受。

有时候最笨的办法就是最好的办法

4. 权限系统

Claude Code 有权限控制,执行某些操作需要用户确认。在 CLI 里是交互式的,但在 Slack 里怎么办?

我做了个交互式卡片

// 当 Claude 需要权限时,发送一个带按钮的卡片
async function sendPermissionCard(channel: string, tool: string, input: any) {
  await slack.chat.postMessage({
    channel,
    blocks: [
      {
        type: 'section',
        text: { type: 'mrkdwn', text: `🔐 *需要权限确认*\n工具: ${tool}` }
      },
      {
        type: 'actions',
        elements: [
          { type: 'button', text: { type: 'plain_text', text: '✅ 允许' }, action_id: 'approve' },
          { type: 'button', text: { type: 'plain_text', text: '❌ 拒绝' }, action_id: 'deny' }
        ]
      }
    ]
  });
}

用户点击按钮后,我再用更高权限重新执行请求。

5. Slack 斜杠命令

光发消息还不够,我还想要一些快捷操作。Slack 的斜杠命令(Slash Commands)正好派上用场:

  • /project —— 切换项目
  • /stop —— 停止当前正在执行的任务
  • /clear —— 清除会话,重新开始

实现分两层:

第一层:Slack Adapter 注册命令

// 注册 /project 命令
this.app.command('/project', async ({ command, ack }) => {
  await ack();  // 必须在 3 秒内响应,否则 Slack 会报错

  const context = {
    channelId: command.channel_id,
    userId: command.user_id,
  };

  // 触发交互处理
  for (const handler of this.interactionHandlers) {
    await handler('show_project_selector', '', context);
  }
});

第二层:Message Processor 处理交互

async handleInteraction(action: string, value: string, context: MessageContext) {
  if (action === 'show_project_selector') {
    // 展示项目选择卡片
    await this.showProjectSelector(adapter, context);
  } else if (action === 'stop_execution') {
    // 停止当前任务
    await this.handleStopExecution(adapter, context);
  } else if (action === 'clear_session') {
    // 清除会话
    await this.handleClearSession(adapter, context);
  }
}

/stop 的实现有点意思 —— 需要能够中断正在运行的 Claude 进程:

private async handleStopExecution(adapter: IMAdapter, context: MessageContext) {
  const execution = this.activeExecutions.get(context.channelId);

  if (!execution) {
    await adapter.sendMessage(context.channelId, '没有正在运行的任务。');
    return;
  }

  // 标记为已中止,防止后续消息更新
  execution.aborted = true;
  // 触发 AbortController
  execution.abort();

  await adapter.updateMessage(context.channelId, execution.messageTs, '🛑 已停止');
}

这里用了 AbortController,它是 Node.js 原生支持的中止信号机制。在启动 Claude 进程时传入 abortSignal,调用 abort() 就能优雅地终止进程。

一个小细节:Slack 要求斜杠命令必须在 3 秒内响应(ack()),否则会显示错误。所以我先 ack(),再异步处理实际逻辑。这样用户体验会好很多。

😱 踩坑实录:那些让我头秃的问题

坑 1:Bun + Slack WebSocket = 💥

我一开始用 Bun 来开发,build 也用 Bun。结果发现一个诡异的问题:Slack 的 Socket Mode 在 Bun 运行时下经常断连。

排查了半天,发现是 Bun 的 WebSocket 实现和 @slack/bolt 不太兼容。

解决方案:开发时用 tsx(Node.js 运行时),生产构建用 Bun 打包但改成 Node.js 的 shebang:

bun build ./src/cli.ts --outdir ./dist --target node && \
sed -i '1s|#!/usr/bin/env bun|#!/usr/bin/env node|' ./dist/cli.js

是的,有点丑陋,但它能用。

坑 2:Slack 消息长度限制

Slack 单条消息最大 40KB(实际建议 38KB 以内)。但 Claude 有时候会输出很长的内容,特别是它在解释代码的时候。

直接截断?不行,可能截到一半把 markdown 格式搞坏了。

解决方案:按字节长度截断,并确保截断点不在多字节字符中间:

function truncateToByteLength(str: string, maxBytes: number): string {
  const encoder = new TextEncoder();
  const encoded = encoder.encode(str);

  if (encoded.length <= maxBytes) return str;

  // 找到合适的截断点
  let truncated = encoded.slice(0, maxBytes);
  // 确保不截断 UTF-8 多字节字符
  while (truncated.length > 0 && (truncated[truncated.length - 1] & 0xc0) === 0x80) {
    truncated = truncated.slice(0, -1);
  }

  return new TextDecoder().decode(truncated) + '\n\n... (内容过长,已截断)';
}

坑 3:Markdown 格式转换

Claude 输出的是标准 Markdown,但 Slack 用的是自己的 mrkdwn 格式。两者语法不一样:

Markdown Slack mrkdwn
**bold** *bold*
*italic* _italic_
[text](url) <url|text>
`code` `code`

我写了个转换函数,处理这些差异。但最坑的是代码块 —— Slack 对代码块的渲染很奇怪,超过一定长度就会出问题。

最终方案:长代码不放在消息里,而是上传成代码片段(Snippet):

if (codeBlock.length > 2000) {
  await slack.files.uploadV2({
    channel_id: channel,
    content: codeBlock,
    filename: 'code.txt',
    title: 'Code Output'
  });
}

坑 4:语音消息支持

既然是在手机上用,那支持语音消息岂不是更方便?说一句话就能让 Claude 干活。

我集成了 whisper.cpp 做语音转文字:

async function transcribe(audioPath: string): Promise<string> {
  // 先用 ffmpeg 转换成 whisper 需要的格式
  await exec(`ffmpeg -i ${audioPath} -ar 16000 -ac 1 -f wav ${wavPath}`);

  // 调用 whisper
  const { stdout } = await exec(`whisper-cli -m ${modelPath} -f ${wavPath}`);
  return stdout.trim();
}

但这里又有坑:Slack 发来的语音是 .mp4 格式,需要先下载再转换。而且 whisper 模型挺大的,第一次运行要下载...

坑 5:进程管理

作为一个后台服务,进程管理是必须的:

  • 如何优雅地启动/停止?
  • 如何检测服务是否在运行?
  • 如何处理僵尸进程?

我用 PID 文件 + 信号量来管理:

async function stop() {
  const pid = readPidFile();
  if (!pid) return;

  // 先尝试优雅退出
  process.kill(pid, 'SIGTERM');

  // 等待 5 秒
  await sleep(5000);

  // 如果还在运行,强制杀掉
  if (isRunning(pid)) {
    process.kill(pid, 'SIGKILL');
  }
}

✨ 最终效果

折腾了两天,终于能用了:

# 安装
npm install -g chat-heimerdinger

# 初始化配置
hmdg init

# 启动服务
hmdg start

然后在 Slack 里:

2db91133029cfc157e7dbbcea5630576.jpg

或者直接发语音

cae84620508677ae00533e3fc5d68573.jpg

蹲在马桶上,动动手指,代码就改好了。Vibe coding,永不断档!

如果不确定Claude code的改动是否正确,每次对话的thread里还有本次修改的diff

image.png

写在最后

工具的价值在于节省时间。 虽然开发这个工具花了我两个整天,但以后每次蹲坑时继续 vibe coding 省下的时间,迟早会赚回来的(大概)。

slack目前支持已经基本完善,但飞书我是盲调的(公司内网有防火墙,连不到飞书的socket,所以飞书的不保证可用,后续我还会继续调整)。后续我还会考虑支持上钉钉、企微、微信,让更多的IM工具都能实现马桶vibe coding


项目地址:GitHub - chat-heimerdinger

如果觉得有用,欢迎 Star ⭐

有问题欢迎评论区交流,我会尽量回复(如果我不是在厕所里指挥 Claude 干活的话)。

❌