普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月15日技术

GraphQL 重塑:从 API 语言到 AI 时代的"逻辑神经系统"

作者 yuki_uix
2026年2月15日 17:49

"在 AI 重构软件工程的时代,GraphQL 不再只是一种 API 查询语言——它正在成为人机协作的'母语'。"


一、从餐厅点餐说起:为什么你的 API 总在"多给"或"少给"?

想象你走进一家传统餐厅(REST API),服务员递给你一本厚厚的菜单。你只想要一份"番茄炒蛋",但菜单上写的是"套餐 A:番茄炒蛋 + 米饭 + 例汤 + 小菜 + 餐后水果"。你不得不接受整个套餐,即使你只需要那盘炒蛋。这就是 Over-fetching(数据冗余)

更糟糕的是,当你想要"番茄炒蛋 + 宫保鸡丁的酱汁 + 麻婆豆腐的花椒"时,服务员告诉你:"抱歉,我们只提供固定套餐,你需要分别点三份套餐。"于是你被迫跑三趟窗口,拿回三个托盘,再自己拼凑出想要的组合。这就是 Under-fetching(数据不足)

而 GraphQL 呢?它像是一个自助取餐台——你拿着托盘,精确地选择自己想要的每一样食材:

query MyMeal {
  tomatoEgg {
    egg
    tomato
  }
  kungPaoChicken {
    sauce
  }
  mapotofu {
    szechuanPepper
  }
}

一次查询,精确获取,零冗余

REST vs GraphQL:流程对比

让我用一个直观的图表来说明两者的差异:

┌─────────────────────────────────────────────────────────────┐
│                      REST 的多端点困境                        │
└─────────────────────────────────────────────────────────────┘

客户端需求:用户信息 + 最新3篇文章 + 每篇文章的评论数

请求流程:
  ┌─────────┐    GET /api/user/123         ┌─────────┐
  │         │ ─────────────────────────────>│         │
  │         │    返回用户全部字段(冗余)        │         │
  │         │ <─────────────────────────────│         │
  │         │                               │         │
  │  客户端  │    GET /api/posts?user=123   │  服务器  │
  │         │ ─────────────────────────────>│         │
  │         │    返回文章列表(无评论数)        │         │
  │         │ <─────────────────────────────│         │
  │         │                               │         │
  │         │    GET /api/posts/1/comments  │         │
  │         │ ─────────────────────────────>│         │
  │         │ <─────────────────────────────│         │
  │         │    GET /api/posts/2/comments  │         │
  │         │ ─────────────────────────────>│         │
  │         │ <─────────────────────────────│         │
  │         │    GET /api/posts/3/comments  │         │
  │         │ ─────────────────────────────>│         │
  │         │ <─────────────────────────────│         │
  └─────────┘                               └─────────┘
     共 5 次网络往返,大量冗余数据传输


┌─────────────────────────────────────────────────────────────┐
│                   GraphQL 的单一图谱查询                      │
└─────────────────────────────────────────────────────────────┘

  ┌─────────┐    POST /graphql             ┌─────────┐
  │         │ ─────────────────────────────>│         │
  │         │  {                            │         │
  │  客户端  │    user(id: 123) {            │  服务器  │
  │         │      name, avatar             │         │
  │         │      posts(limit: 3) {        │         │
  │         │        title                  │         │
  │         │        commentCount           │         │
  │         │      }                        │         │
  │         │    }                          │         │
  │         │  }                            │         │
  │         │ <─────────────────────────────│         │
  │         │    精确返回所需数据              │         │
  └─────────┘                               └─────────┘
     仅 1 次网络往返,零冗余数据

二、GraphQL 是 AI 时代的"母语":从人类 API 到机器说明书

2.1 确定性契约:消除 AI 的"幻觉"

当你让 ChatGPT 写一段调用某个 REST API 的代码时,它可能会:

  • 猜测字段名(是 user_name 还是 userName?)
  • 臆造端点(/api/v1/users 还是 /users?)
  • 忽略必填参数(导致 400 Bad Request)

这是因为 REST API 的"说明书"通常是人类语言的文档(Swagger/OpenAPI),而 LLM 在解析文档时会产生"理解偏差"。

但 GraphQL 不同。它的核心是一份机器可读的契约——Schema

type User {
  id: ID!              # 感叹号表示必填,AI 无法遗漏
  name: String!
  email: String
  posts: [Post!]!      # 数组类型明确标注
}

type Query {
  user(id: ID!): User  # 参数类型强制约束
}

这份 Schema 像是一张"分子式"——每个字段的类型、是否可空、关系连接都被严格定义。当 AI Agent 读取这份 Schema 时,它不需要"理解文档",只需要解析结构。就像化学家看到 H₂O 就知道如何合成水,AI 看到 Schema 就知道如何构建查询。

示例对比:

REST(文档驱动) GraphQL(Schema 驱动)
"User endpoint returns user object with name and posts" type User { name: String! posts: [Post!]! }
AI 需要"猜测"字段名 AI 直接引用确定的类型定义
版本变更需要重新学习文档 Schema 变更自动反映在类型系统中

2.2 Token 效率:声明式查询降低 AI 的认知负载

在 AI 辅助编程时代,我们需要不断向 LLM 传递上下文(Context)。而 REST API 的命令式特性会导致上下文爆炸

# REST 风格:AI 需要理解 3 个端点的逻辑关系
user = requests.get(f"/api/users/{user_id}")
posts = requests.get(f"/api/posts?user={user_id}")
for post in posts:
    comments = requests.get(f"/api/posts/{post['id']}/comments")
    # ... 处理逻辑

这段代码的"认知成本"包括:

  1. 理解三个端点的 URL 结构
  2. 推断参数传递逻辑(user_idposts
  3. 处理嵌套循环和数据拼接

而 GraphQL 的声明式查询将这一切浓缩为单一意图

query UserWithPosts($userId: ID!) {
  user(id: $userId) {
    name
    posts {
      title
      comments {
        content
      }
    }
  }
}

AI 只需要"看懂这张表"——不需要推理步骤,不需要处理控制流。这相当于从"写一篇小作文"变成了"填一张表格"。

Token 消耗对比:

  • REST:平均需要 300-500 tokens 来描述多端点的组合逻辑
  • GraphQL:仅需 50-100 tokens 来表达同等的查询意图

三、高阶概念融合:GraphQL × AI Agent × OpenClaw

3.1 从 Mutation 到 AI Skills:原子化能力的映射

在 AI Agent 的架构中,一个核心概念是 Skills(技能)——每个技能都是 Agent 可以调用的原子化能力。而 GraphQL 的 Mutation(变更操作) 天然就是这种原子化能力的最佳载体。

举个例子:

type Mutation {
  createPost(title: String!, content: String!): Post!
  deletePost(id: ID!): Boolean!
  likePost(id: ID!): Post!
}

这三个 Mutation 可以直接映射为 AI Agent 的三个 Skills:

{
  "skills": [
    {
      "name": "create_post",
      "input_schema": {
        "title": "string",
        "content": "string"
      },
      "output_schema": "Post"
    },
    {
      "name": "delete_post",
      "input_schema": { "id": "ID" },
      "output_schema": "boolean"
    },
    {
      "name": "like_post",
      "input_schema": { "id": "ID" },
      "output_schema": "Post"
    }
  ]
}

关键洞察:GraphQL 的 Schema 本身就是一份"技能清单"。AI Agent 不需要额外的配置文件,只需要读取 Schema,就能自动获取所有可用的操作能力。


3.2 Introspection:让 AI 实现工具的"自发现"

GraphQL 有一个"杀手级"特性:Introspection(自省) 。你可以向任何 GraphQL 服务查询它自己的 Schema:

query IntrospectionQuery {
  __schema {
    types {
      name
      fields {
        name
        type {
          name
          kind
        }
      }
    }
    queryType { name }
    mutationType { name }
  }
}

这意味着什么?意味着 AI Agent 可以零配置接入任何 GraphQL 服务

  1. Agent 连接到一个 GraphQL 端点
  2. 发起 Introspection 查询,获取完整 Schema
  3. 自动生成可用的 Skills 列表
  4. 根据用户意图动态组合查询

这就是 OpenClaw 架构的核心理念——工具的自发现与动态组合

示例流程:

用户: "帮我查看今天的销售数据,然后生成一份报告"

┌──────────────────────────────────────────────────┐
│  AI Agent 执行流程                                │
└──────────────────────────────────────────────────┘

1. [自省阶段]
   Agent → GraphQL Server: 
     "你有哪些查询能力?"
   
   Server → Agent:
     "我有 salesData(date: Date) 和 
      generateReport(data: SalesData)"

2. [意图推理阶段]
   Agent 分析用户意图:
     - 需要先查询数据
     - 再调用报告生成

3. [执行阶段]
   Agent 构建查询:
     query {
       salesData(date: "2024-02-15") {
         revenue
         orders
       }
     }
   
   Agent 调用 Mutation:
     mutation {
       generateReport(data: $salesData)
     }

4. [返回结果]
   Agent → 用户: "已生成报告,今日营收 ¥12,345"

3.3 语义导航:AI 在业务逻辑中的自动推导

GraphQL 的"图"(Graph)属性不仅仅是命名的巧合——它真的是一张关系图谱。每个类型都通过字段与其他类型连接,形成一张语义网络。

type User {
  id: ID!
  posts: [Post!]!
}

type Post {
  id: ID!
  author: User!
  comments: [Comment!]!
}

type Comment {
  id: ID!
  author: User!
  post: Post!
}

这张图谱告诉 AI:

  • User 可以导航到 Post
  • Post 可以导航到 Comment
  • Comment 可以反向导航回 UserPost

当用户说"找出所有评论过 Alice 文章的用户"时,AI 可以自动推导出查询路径:

User (Alice) → posts → comments → author (其他用户)

并生成查询:

query {
  user(name: "Alice") {
    posts {
      comments {
        author {
          name
        }
      }
    }
  }
}

这种语义导航能力让 AI Agent 能够像人类一样"理解"业务关系,而不是死记硬背端点 URL。


四、工程实践:优势、劣势与迁移路径

4.1 优势总结

维度 GraphQL 的价值
前端自治 前端可以自主决定需要哪些数据,无需等待后端开发新端点
类型安全 强类型系统在编译时捕获错误,减少运行时 Bug
平滑演进 通过 @deprecated 标记废弃字段,支持渐进式迁移
文档自动化 Schema 即文档,工具可自动生成交互式 API Explorer
AI 友好 机器可读的契约,降低 AI 辅助开发的幻觉率

4.2 劣势与应对

问题 1:N+1 查询问题

当你查询一个列表及其关联数据时,可能触发大量数据库查询:

query {
  users {          # 1 次查询
    name
    posts {        # N 次查询(每个用户一次)
      title
    }
  }
}

解决方案:DataLoader 使用批量加载和缓存机制,将 N+1 次查询合并为 2 次:

const postLoader = new DataLoader(async (userIds) => {
  const posts = await db.posts.findByUserIds(userIds);
  // 按 userId 分组返回
});

问题 2:缓存复杂性

REST 的 URL 可以直接用作缓存键,但 GraphQL 的查询体是动态的:

# 两个不同的查询,无法用 URL 缓存
query { user { name } }
query { user { name, email } }

解决方案:持久化查询 + Apollo Cache

  • 为常用查询分配固定 ID
  • 使用规范化缓存(以类型 + ID 为键)

问题 3:初始配置成本

编写 Resolver 和 Schema 需要一定工作量。

但在 AI 时代,这个成本正在消失

  • AI 可以根据数据库表结构自动生成 Schema
  • AI 可以批量生成 Resolver 代码
  • AI 可以识别业务逻辑并建议字段关系

4.3 迁移路径:Wrapper Pattern(包裹模式)

你不需要推翻现有的 REST API。可以用 GraphQL 作为"前端代理",逐步迁移:

// GraphQL Resolver 调用旧 REST API
const resolvers = {
  Query: {
    user: async (_, { id }) => {
      // 调用旧的 REST 端点
      const response = await fetch(`/api/users/${id}`);
      return response.json();
    },
  },
  User: {
    posts: async (user) => {
      // 调用另一个 REST 端点
      const response = await fetch(`/api/posts?user=${user.id}`);
      return response.json();
    },
  },
};

优势:

  • 一夜迁移:前端立即获得 GraphQL 的所有好处
  • 渐进式:后端可以慢慢将 REST 逻辑重构为原生 Resolver
  • 风险可控:出问题可以随时回退到 REST

五、总结:从"编写代码"到"定义契约"

在软件工程的演进中,我们经历了几次范式转移:

  1. 机器码时代:手动编写二进制指令
  2. 高级语言时代:用 C/Java 表达逻辑
  3. 声明式时代:用 SQL/GraphQL 表达意图

而现在,我们正站在第四次转移的门槛上——契约驱动的 AI 协作时代

GraphQL 的价值不再仅仅是"更好的 API",而是成为了人类与 AI 之间的通用协议

  • 人类定义 Schema(业务契约)
  • AI 基于 Schema 生成查询(代码实现)
  • Schema 的变更自动传播到 AI 的理解中

这是一种全新的分工模式:人类负责"定义世界",AI 负责"操作世界"


"如果说 REST 是工业时代的装配线——每个端点都是一个固定的工位,那么 GraphQL 就是 AI 时代的神经系统——每个查询都是一次自主的意图表达。当我们停止告诉机器'该做什么',而是告诉它'世界是什么样的'时,真正的智能协作才刚刚开始。"


延伸阅读

vue2vue3响应式

2026年2月15日 17:42

响应式基础

vue开篇提到了怎么在vue的选项式写法中声明组件状态,就是在对象中写一个data属性,这个属性要是一个函数,这个函数要返回一个对象,返回的对象会被vue在合适的时候调用赋予它响应的能力,然后vue会把这个对象上的属性都放到组件自身上, 我们再讨论接下来的问题之前c,先展示vue2以及vue3是怎么大致实现响应式的, 帮助理解

vue2响应式

vue2实现响应式的思路就是给对象加setter和getter,把这些属性全部挂载到组件实例对象上, 然后给每个属性添加上setter更新值的时候要触发的响应函数就可以实现响应式了,具体看下面这个js例子

class Dep {
  constructor() {
    this.bukets = [];
  }
  addDep(fn) {
    this.bukets.push(fn);
  }

  notify() {
    this.bukets.forEach((fn) => {
      fn.update();
    });
  }
}


//观察者
class Watcher {
  constructor(obj, name, updateCb) {
    this.updateCb = updateCb;
    this.init(obj, name);
  }
  init(obj, name) {
    //把注册函数送出去,注册好响应式
    Dep.target = this;
    obj[name]; // 触发Dep响应,添加进这个watcher者
    this.update();
    Dep.target = null;
  }

  update() {
    this.updateCb();
  }
}


//定义给对象响应式属性
const defineReactive = (obj, key, val) => {
  //为这个属性实例化一个观察者
  const dep = new Dep();
  Object.defineProperty(obj, key, {
    get() {
      //当触发key时,说明要使用这个依赖
      if (Dep.target) {
        dep.addDep(Dep.target);
      }
      return val;
    },
    set(newVal) {
      val = newVal;
      //通知
      dep.notify();
      
    }
  });
};


<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    #app {
      display: inline-flex;
      column-gap: 10px;
      padding: 10px 12px;
      border-radius: 8px;
      margin: 100px 200px;
      background-color: #f5f5f5;
      cursor: pointer;
      user-select: none;
    }
    #app span {
      display: flex;
      justify-content: center;
      align-items: center;
      width: 20px;
      height: 30px;
      background-color: #ececec;
    }
  </style>
</head>
<body>
  <div id="app">
    <span data-action="sub">-</span>
    <span class="count"></span>
    <span data-action="add">+</span>
  </div>

  <script src="./index.js"></script>
  <script>
    let obj = {};
    defineReactive(obj, "count", 0);
    const countEle = document.querySelector(".count");


    new Watcher(obj, "count", () => {
      countEle.innerText = obj.count;
    });

    document.querySelector("[data-action='sub']").addEventListener("click", () => {
      obj.count--;
    });
    document.querySelector("[data-action='add']").addEventListener("click", () => {
      obj.count++;
    });

  </script>
</body>
</html>



关注我们重点的最开头的四个函数,这就是vue2大致实现响应式的样子,我们可以看到,我们实际上是给data指定的数据使用Object.defineProperty定义了get和set函数, , 然后在初始的时候在get函数里添加上watcher,,在这个属性触发set的时候,我们通知这些watcher使用最新的值进行更新,这就是大致流程, 然后我们再来看看vue3对于响应式是怎么实现的

vue3响应式

let activeFn;

const effect = (fn) => {
  activeFn = fn;
  fn();
  activeFn = null;
};

const buckets = new WeakMap();

const trigger = (target, property) => {
  const depsMap = buckets.get(target);
  if (!depsMap) {
    return ;
  }

  const fns = depsMap.get(property);

  console.log(fns, "fns");
  fns && fns.forEach(fn => fn());
};
const track = (target, property) => {
  let depsMap = buckets.get(target);
  if (!depsMap) {
    buckets.set(target, (depsMap = new Map()));
  }

  let deps = depsMap.get(property);

  if (!deps) {
    depsMap.set(property, (deps = new Set()));
  }

  deps.add(activeFn);
};

const reactive = (data) => {
   return new Proxy(data, {
    set(target, property, newVal, receiver) {
      trigger(target, property);
      return Reflect.set(target, property, newVal, receiver);
    },
    get(target, property, receiver) {
      if (activeFn) {
        console.log(target,property, "target-property");
        track(target, property);
      }
      console.log("触发set");
      return Reflect.get(target, property, receiver);
    }
  });
};
  <style>
    #app {
      display: inline-flex;
      column-gap: 10px;
      padding: 10px 12px;
      border-radius: 8px;
      margin: 100px 200px;
      background-color: #f5f5f5;
      cursor: pointer;
      user-select: none;
    }
    #app span {
      display: flex;
      justify-content: center;
      align-items: center;
      width: 20px;
      height: 30px;
      background-color: #ececec;
    }
  </style>
</head>
<body>
  <div id="app">
    <span data-action="sub">-</span>
    <span class="count"></span>
    <span data-action="add">+</span>
  </div>

  <script src="./index2.js"></script>
  <script>
    let obj = {count: 0};
    obj = reactive(obj);
    const countEle = document.querySelector(".count");

    
    effect(() => {
      countEle.innerText = obj.count;
    });


    document.querySelector("[data-action='sub']").addEventListener("click", () => {
      obj.count--;
    });
    document.querySelector("[data-action='add']").addEventListener("click", () => {
      obj.count++;
    });

  </script>
</body>

我们可以看到,我们基于Proxy实现的响应式系统是现有一个obj对象, 然后我们定义了一个代理对象,我们后续都是操作这个代理对象去实现响应式更新

总结

基于上述描述,我们可以知道,vue2的响应式的确是在原始对象上定义了一个新的属性然后设置get和set,我们在这个对象属性上触发了set的时候,也会触发响应函数更新, 在vue3的时候,是现有原始的对象,我们给这个对象设置了一个代理对象,后续的响应式都是通过触发代理对象的set和get实现的,在代理对象上触发了set的时候,会触发响应函数更新, 完全与原始对象解耦了。同时也可以注意到,我们在vue2的实现中,并没有return 一个函数或者是包含函数的对象,但是我们的属性val,却因为defineProperty的实现而被留存了下来,通过这种形式也实现了一个闭包,所以我们可以说,没有return一个使用了内部变量的函数就不是闭包的说法是错误的,只要实现了将内部变量外泄到外部代码,并且外部代码只能受控的间接访问这个内部变量的这么个现象,我们就可以认为是一个闭包,return一个使用了内部变量的函数只是实现的一个具体方法。

回到Vue文档

查看下面一个vue文档给出的例子

export default {
  data() {
    return {
      someObject: {}
    }
  },
  mounted() {
    const newObject = {}
    this.someObject = newObject

    console.log(newObject === this.someObject) // false
  }
}

当你在复制后再访问this.someObject, 这个时候因为触发了this的set函数,属性是someObject, 所以在vue3中会创建一个新的响应式对象,然后复制给this.someObject,这个对象是代理后的对象,它的原始对象是newObject, 而对于vue2,它会接受这个对象,然后在这个对象上设置getter和setter,把这个对象转换成响应式 由于转换是在同一个对象上进行的 ,所以文档说当你在赋值后再访问this.someObject, 此值已经是原来的newOject的一个响应式代理,与vue2 不同的是,这里的原始的newObject不会变为响应式,请确保始终通过this来访问响应式状态

声明方法

先看下面一个例子

export default {
  data() {
    return {
      count: 0
    }
  },
  methods: {
    increment() {
      this.count++
    }
  },
  mounted() {
    // 在其他方法或是生命周期中也可以调用方法
    this.increment()
  }
}

vue文档在这里说不应该使用箭头函数,因为箭头函数的this值是跟着作用域走了,而在对象中使用 ...() {}, 的形式相当于function () {} ,其中的this是由调用方觉定的,所以这里的methods中的方法使用箭头函数后如果是顶层的箭头函数的this就是window,不会改变

响应式状态新增属性

当我们在vue2的响应式状态上新增一个属性的时候,vue2没有办法检测到变化,查看下面一个例子

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>数组列表渲染重点</title>
</head>

<body>
  <div id="app">
    {{obj.nested.count}}
    {{JSON.stringify(obj.nested)}}
    <button @click="mutateDeeply">增加</button>
  </div>
  <script src="https://cdn.bootcdn.net/ajax/libs/vue/2.6.11/vue.js"></script>
  <script>
    const app = new Vue({
      el: "#app",
      data() {
        return {
          obj: {
            nested: { count: 0 },
          }
        }
      },
      methods: {
        mutateDeeply() {
          // 以下都会按照期望工作
          this.obj.nested.count++
        }
      }
    })
  </script>
</body>

</html>

如果我们在控制台输入app.obj.nested.count2 = 2;可以发现,这个时候我们的页面并没有发生变化,如果我们换成vue3的写法,会怎么样,请查看下面一个例子

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>数组列表渲染重点</title>
</head>

<body>
  <div id="app">
    {{obj.nested.count}}
    {{JSON.stringify(obj.nested)}}
    <button @click="mutateDeeply">增加</button>
  </div>
  <script src="https://cdn.bootcdn.net/ajax/libs/vue/3.5.22/vue.global.min.js"></script>
  <script>
  const app = Vue.createApp({
  data() {
    return {
      obj: {
        nested: { count: 0 },
      }
    }
  },
  methods: {
    mutateDeeply() {
      // 以下都会按照期望工作
      this.obj.nested.count++
    }
  }
}).mount("#app");
  </script>
</body>

</html>

如果我们在上面的这个例子控制台中实时的添加app.obj.nested.count2 = 2;可以看到,页面发生了变化! 这是为什么呢,其实,vue2的响应是基于definePRoperty,这就意味着vue2在实现响应式的时候在统一注册响应式的阶段在对象的属性上定义setter/getter,这个时候新增一个属性,压根就没有给这个对象赋予一个setter/getter,所以也就不会触发setter/getter了,如果是在vue3中,我们使用代理对象,响应是基于整个对象的,如果你新增了一个属性,这个时候就会触发整个对象的getter/setter,然后更新整个页面,所以最后的区别也还是因为vue2的响应式是基于对象属性的,而vue3的响应式是基于整个对象的,这是我们在响应式系统上讨论的vue3和vue2的第二个区别

Pageindex -- 新一代的文档智能检索

作者 Hhang
2026年2月15日 16:18

PageIndex:无向量推理型 RAG 框架深度解析

传统 RAG 系统依赖向量数据库进行语义检索,但在处理长篇复杂文档时面临上下文丢失、检索不精准等瓶颈。PageIndex 提出了一种全新的「无向量、基于推理」的检索范式,通过层级文档树 + LLM 推理搜索,模拟人类专家阅读文档的方式,实现更精准、可解释的信息检索。


一、传统 RAG 系统的工作流程与痛点

1.1 传统 RAG 的核心流程

文档输入 --> 文本切块(Chunking) --> 向量嵌入(Embedding) --> 存入向量数据库
                                                                |
用户提问 --> 问题向量化 --> 向量相似度检索(Top-K) --> 拼接上下文 --> LLM 生成回答

传统 RAG 系统的关键环节包括:

环节 说明 典型工具
文本切块 将文档按固定大小(如 512 tokens)切分为 chunks LangChain、LlamaIndex
向量嵌入 将每个 chunk 转化为高维向量表示 OpenAI Embedding、BGE、Jina
向量存储 将向量写入专用向量数据库 Pinecone、Milvus、Weaviate、Chroma
语义检索 基于余弦相似度检索最相关的 Top-K chunks FAISS、HNSW 索引
上下文拼接 将检索到的 chunks 拼接为 LLM 的上下文 Prompt 模板

1.2 传统 RAG 的核心痛点

痛点一:文本切块导致上下文割裂

固定大小的切块策略无法感知文档的自然结构,经常在段落中间、甚至句子中间断开,导致:

  • 一个完整的论述被切分到多个 chunk 中,检索时只能拿到片段
  • 表格、公式等结构化内容被粗暴截断
  • 上下文关联信息(如「如前文所述」)丢失引用目标
原始文档:
    第三章 财务分析
    3.1 营收概览
    公司2024年Q3营收为52.3亿元,同比增长15.2%。
    其中,核心业务贡献了38.7亿元(占比74%),
    详细拆分见表3-2。
    [表3-2: 业务线营收拆分]
    ...

切块后:
    Chunk 1: "...公司2024年Q3营收为52.3亿元,同比增长15.2%。其中,核心业务贡献了38.7亿"
    Chunk 2: "元(占比74%),详细拆分见表3-2。[表3-2: 业务线营收拆分]..."
    
    --> 数字被截断,表格引用与表格内容分离
痛点二:语义相似 != 实际相关(氛围检索问题)

向量检索本质上是计算语义空间中的距离,但语义相似并不等于业务相关

  • 问「公司2024年Q3的净利率是多少」,可能检索到2023年的净利率数据(语义高度相似,但年份错误)
  • 问「合同中的违约赔偿条款」,可能返回「合同概述」章节(包含"违约"关键词但并非具体条款)
  • 领域专业术语在通用嵌入模型中的表示不够精确
痛点三:基础设施复杂度高

部署传统 RAG 需要维护一套独立的向量数据库基础设施:

成本项 说明
存储成本 向量索引占用大量内存和磁盘空间
计算成本 嵌入生成需要 GPU 资源,每次文档更新需重新嵌入
运维成本 向量数据库的集群管理、备份、扩缩容
调优成本 chunk_size、overlap、嵌入模型选择等参数需要大量实验
一致性成本 文档更新后,向量索引的增量同步和一致性维护
痛点四:跨引用追踪困难

复杂文档(如财报、法律合同、技术手册)中大量存在内部交叉引用:

  • 「详见附录 A」「参见第 4.2 节」「如表 3-1 所示」
  • 传统 RAG 将文档打散为独立 chunks 后,这些引用关系完全丢失
  • LLM 无法沿着引用链追踪到目标内容
痛点五:检索过程不可解释

向量检索是一个「黑箱」过程:

  • 无法解释为什么返回了某个 chunk 而非另一个
  • 无法提供检索路径和推理依据
  • 在金融、法律、医疗等合规要求高的领域,不可解释性是致命缺陷

二、PageIndex 的核心设计理念

2.1 核心思想:像人类专家一样阅读文档

PageIndex 由 Vectify AI 开发,其核心理念是:

一个人类专家在查阅一份 200 页的财报时,不会把它切成 400 个碎片然后逐个比较相似度。他会先看目录,定位到相关章节,再逐步深入阅读。PageIndex 让 LLM 做同样的事。

2.2 技术架构

                    PageIndex 工作流程

文档输入 --> 结构解析 --> 构建层级文档树(Document Tree)
                              |
                     [根节点: 文档标题与摘要]
                    /          |          \
            [章节1摘要]   [章节2摘要]   [章节3摘要]
             /    \          |          /    \
        [3.1摘要] [3.2摘要]  ...   [小节摘要] [小节摘要]
           |         |                |         |
       [页面内容] [页面内容]       [页面内容] [页面内容]


用户提问 --> LLM 推理 --> 从根节点开始逐层决策 --> 定位到最相关的叶节点 --> 提取精确内容

2.3 三大核心组件

组件一:层级文档树(Hierarchical Document Tree)

PageIndex 将文档转化为一棵语义层级树,而非向量集合:

特性 说明
自然结构保留 章节、小节、段落的层级关系完整保留
节点摘要 每个节点包含对应内容的 LLM 生成摘要
页面对齐 叶节点与原文页面精确对应,支持页码引用
动态深度 树的深度根据文档实际结构自适应调整
组件二:LLM 推理检索(Reasoning-based Retrieval)

检索过程不再是向量距离计算,而是一个多步推理过程:

用户提问: "公司2024年Q3的研发费用率是多少?"

推理步骤:
  Step 1: [根节点] 阅读文档整体摘要,判断这是一份季度财报
  Step 2: [章节级] 在"经营分析""财务报表""管理层讨论"中选择 --> "财务报表"
  Step 3: [小节级] 在"利润表""资产负债表""现金流量表"中选择 --> "利润表"
  Step 4: [页面级] 定位到利润表中包含"研发费用"行项的具体页面
  Step 5: [提取] 提取研发费用金额和营收金额,计算费用率

检索路径: 根 --> 财务报表 --> 利润表 -->47
组件三:可追溯引用系统

每次检索都生成完整的推理链路,包含:

  • 每一步的决策依据
  • 最终答案的来源页码和章节
  • 支撑信息的原文引用

三、PageIndex vs 传统向量 RAG:全面对比

3.1 架构层面对比

对比维度 传统向量 RAG PageIndex
索引方式 向量嵌入 + 向量数据库 层级文档树
文档处理 固定大小切块 按自然结构组织
检索机制 余弦相似度 Top-K LLM 推理树搜索
检索依据 语义距离(数学计算) 逻辑推理(类人决策)
上下文保留 局部(单个 chunk 内) 全局(沿树路径保留层级上下文)
可解释性 低(向量距离难以解释) 高(每步推理路径透明)
跨引用支持 不支持 支持沿树结构追踪引用

3.2 工程层面对比

对比维度 传统向量 RAG PageIndex
依赖组件 嵌入模型 + 向量数据库 + 应用层 LLM + 文档解析器
基础设施 需要部署和维护向量数据库集群 无需额外数据库
参数调优 chunk_size、overlap、top_k、嵌入模型 树结构生成策略
文档更新 需要重新嵌入并更新向量索引 重新生成文档树
部署复杂度 高(多组件协调) 低(单一流程)
成本结构 存储 + 计算(嵌入 + 检索) 计算(LLM 推理调用)

3.3 效果层面对比

以 FinanceBench 金融文档分析基准测试为例:

系统 准确率 说明
PageIndex (Mafin 2.5) 98.7% 基于推理的文档树检索
GPT-4o(直接回答) ~60-70% 无 RAG 增强
传统向量 RAG + GPT-4o ~75-85% 标准向量检索流程

FinanceBench 是由 Patronus AI 联合 Contextual AI 和斯坦福大学开发的金融文档问答基准,包含超过 10000 个专家标注的问答对,涵盖信息查找、数值推理和逻辑推断等任务类型。


四、PageIndex 解决的核心问题

4.1 解决「切块导致的信息损失」

问题本质:传统 RAG 的切块策略是一个「有损压缩」过程,不可避免地破坏文档的完整性。

PageIndex 方案:保留文档自然结构,按章节/小节/页面组织信息,每个节点都包含完整的上下文。

传统 RAG:  文档 --> [chunk1] [chunk2] [chunk3] ... [chunkN]  (信息碎片化)
PageIndex: 文档 --> 树状结构(章节 > 小节 > 页面)              (结构完整保留)

4.2 解决「语义相似 != 实际相关」

问题本质:向量检索衡量的是语义空间中的距离,而非业务逻辑上的相关性。

PageIndex 方案:LLM 在推理过程中理解问题的真实意图,通过逻辑判断而非数学距离来定位信息。

例如,面对问题「2024年Q3净利率」:

  • 向量检索可能返回:2023年Q3净利率数据(语义高度相似)
  • PageIndex 推理:先定位到2024年Q3财报章节,再在利润表中查找(逻辑精确匹配)

4.3 解决「检索不可解释」

问题本质:在合规要求严格的行业(金融、法律、医疗),不可解释的检索结果不可接受。

PageIndex 方案:每次检索生成完整的推理路径,标注来源页码和章节编号,支持人工审核和验证。

检索报告:
  问题: "合同中关于知识产权归属的约定是怎样的?"
  推理路径: 合同全文 --> 第五章 知识产权 --> 5.2 权利归属 -->23-24页
  来源引用: "第5.2条 权利归属:甲方在合同期间完成的所有..."
  置信度: 高(精确匹配到专属条款)

4.4 解决「基础设施复杂度」

问题本质:向量数据库是一个独立的技术栈,增加了架构复杂度和运维负担。

PageIndex 方案

传统 RAG 技术栈 PageIndex 技术栈
应用服务 应用服务
嵌入模型服务 --
向量数据库(Pinecone/Milvus) --
文档解析器 文档解析器
LLM 服务 LLM 服务
共 5 个组件 共 3 个组件

4.5 解决「跨引用追踪」

问题本质:复杂文档中的交叉引用是理解文档的关键,但切块后引用关系完全丢失。

PageIndex 方案:树状结构天然支持引用追踪。当 LLM 在某个节点遇到「详见第 X 章」时,可以沿树结构导航到目标节点继续阅读。


五、PageIndex 的适用场景与局限

5.1 最佳适用场景

场景 原因
金融报告分析 文档结构严谨,需要精确数值提取和多步推理
法律合同审查 存在大量交叉引用,需要逐条追溯
技术手册查阅 多层级目录结构,需要按章节定位
学术论文分析 段落引用关系复杂,需要上下文完整性
监管合规审查 对可解释性和可追溯性有严格要求

5.2 局限性

局限 说明
大规模多文档检索 树搜索适合单文档深度分析,跨数万篇文档检索时,向量检索的效率优势明显
非结构化文档 对于缺乏清晰结构的文档(如聊天记录、碎片笔记),树构建效果受限
LLM 调用成本 每次检索需要多步 LLM 推理调用,token 消耗高于单次向量检索
实时性要求 多步推理的延迟高于向量检索的毫秒级响应
文档质量依赖 树结构的质量取决于原始文档的结构清晰度

5.3 何时选择哪种方案

选择 PageIndex 的场景:
  - 单文档或少量文档深度分析
  - 对准确率和可解释性要求极高(如金融、法律)
  - 文档结构清晰且层级分明
  - 需要跨引用追踪能力
  - 希望简化基础设施栈

选择传统向量 RAG 的场景:
  - 大规模知识库检索(数万至数百万文档)
  - 需要毫秒级响应延迟
  - 文档类型多样且结构不统一
  - 需要跨文档语义关联
  - 成本敏感(LLM 推理费用较高)

六、总结

PageIndex 代表了 RAG 技术演进的一个重要方向,其核心贡献在于:

  1. 范式转换:从「向量相似度检索」转向「LLM 推理检索」,更贴近人类理解文档的方式
  2. 结构保留:用层级文档树取代碎片化切块,从根本上解决上下文丢失问题
  3. 可解释性:每次检索都有清晰的推理路径,满足合规和审计需求
  4. 架构简化:去除向量数据库依赖,降低系统复杂度

传统向量 RAG 和 PageIndex 并非简单的替代关系,而是在不同场景下各有优势。对于需要高精度、可解释、深度文档分析的专业场景,PageIndex 提供了一种更优雅的解决方案;对于大规模、低延迟、跨文档语义搜索的场景,传统向量 RAG 仍然是更实际的选择。

两种方案的融合(如用向量检索做粗筛,用 PageIndex 做精读)也是值得探索的方向,可以兼顾效率和精确度。


Crontab Cheatsheet

Cron Format

Use five time fields followed by the command.

Format Description
* * * * * command min hour day month weekday command
* Any value
, List of values (for example 1,15)
- Range of values (for example 1-5)
/ Step values (for example */10)

Time Fields

Valid ranges for each cron field.

Field Allowed Values
Minute 0-59
Hour 0-23
Day of month 1-31
Month 1-12 or JAN-DEC
Day of week 0-7 (0 and 7 are Sunday) or SUN-SAT

Special Schedule Strings

Shortcuts for common schedules.

String Equivalent Description
@reboot N/A Run once at startup
@yearly 0 0 1 1 * Run once a year
@annually 0 0 1 1 * Same as @yearly
@monthly 0 0 1 * * Run once a month
@weekly 0 0 * * 0 Run once a week
@daily 0 0 * * * Run once a day
@midnight 0 0 * * * Same as @daily
@hourly 0 * * * * Run once an hour

Common Schedules

Frequently used cron expressions.

Schedule Cron Expression
Every minute * * * * *
Every 5 minutes */5 * * * *
Every 15 minutes */15 * * * *
Every hour at minute 0 0 * * * *
Every day at 02:30 30 2 * * *
Every weekday at 09:00 0 9 * * 1-5
Every Sunday at 03:00 0 3 * * 0
First day of month at midnight 0 0 1 * *
Every 6 hours 0 */6 * * *
Every month on day 15 at 06:00 0 6 15 * *

Crontab Management

Create, list, and remove per-user cron jobs.

Command Description
crontab -e Edit current user’s crontab
crontab -l List current user’s crontab
crontab -r Remove current user’s crontab
crontab -u username -l List another user’s crontab (root)
crontab -u username -e Edit another user’s crontab (root)
crontab file.txt Install crontab from file

Command Patterns

Useful patterns for reliable cron jobs.

Pattern Description
*/5 * * * * /path/script.sh Run script every 5 minutes
0 2 * * * /path/backup.sh >> /var/log/backup.log 2>&1 Append stdout/stderr to a log
0 1 * * * /usr/bin/flock -n /tmp/job.lock /path/job.sh Prevent overlapping runs
@reboot /usr/bin/sleep 30 && /path/startup.sh Run shortly after boot
MAILTO=\"admin@example.com\" Send job output by email

Environment in Cron

Define environment values at the top of crontab.

Entry Description
SHELL=/bin/bash Use Bash for job execution
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin Set explicit command path
MAILTO=\"\" Disable cron email
CRON_TZ=Europe/Skopje Set timezone for this crontab

Troubleshooting Checks

Quick checks when jobs do not run.

Check Command
Validate cron service status systemctl status cron or systemctl status crond
Check cron logs (Debian/Ubuntu) grep CRON /var/log/syslog
Check cron logs (RHEL/Fedora) grep CROND /var/log/cron
Check script permissions ls -l /path/script.sh
Test script manually /path/script.sh
Check if @reboot ran journalctl -u cron --since "today"

Related Guides

Use these articles for complete cron workflows.

Guide Description
Scheduling Cron Jobs with Crontab Full guide to creating and managing cron jobs
How to List Cron Jobs in Linux View user and system cron jobs
Cron Jobs Every 5, 10, 15 Minutes Ready-made recurring interval examples

【节点】[CustomLighting节点]原理解析与实际应用

作者 SmalBox
2026年2月15日 14:58

【Unity Shader Graph 使用与特效实现】专栏-直达

CustomLighting 节点是 Unity URP Shader Graph 中一个功能强大的复合节点,专门用于构建自定义的 PBR(基于物理的渲染)光照模型。该节点为开发者提供了一个灵活且高效的框架,能够通过节点连接的方式生成符合物理规律的光照效果,同时保持优异的性能表现。

在当前的实时渲染领域,PBR 已经成为行业标准,它能够模拟光线与材质之间复杂的相互作用,产生更加真实和一致的视觉效果。CustomLighting 节点的设计理念就是让开发者能够在不需要编写复杂着色器代码的情况下,快速实现高质量的 PBR 材质效果。

该节点的核心价值在于其平衡了易用性与灵活性。对于不熟悉底层着色器编程的艺术家和技术美术来说,通过可视化的节点连接就能创建出专业级的光照效果;而对于有经验的图形程序员,它提供了足够的扩展空间来实现特殊的光照模型。

节点技术特性详解

复合节点架构分析

CustomLighting 节点作为复合节点,其内部封装了完整的 PBR 光照计算流程。复合节点的设计意味着它内部包含了多个子节点的组合,这些子节点协同工作,共同完成复杂的光照计算任务。这种架构的优势在于:

  • 计算效率优化:节点内部的计算流程经过精心优化,确保在移动设备和性能受限的平台上也能流畅运行
  • 参数统一管理:所有相关的 PBR 参数都集中在单个节点中管理,便于材质属性的统一调整
  • 接口标准化:提供了标准化的输入输出接口,确保与其他 Shader Graph 节点的兼容性

PBR 光照模型基础

CustomLighting 节点实现的 PBR 模型基于经典的微表面理论,该理论假设物体表面由无数个微小的镜面组成,每个微表面都会对光线产生反射。节点的计算涵盖了以下几个关键方面:

  • 能量守恒:确保反射光线的总能量不会超过入射光线的能量,这是 PBR 模型的核心原则
  • 菲涅尔效应:模拟不同角度观察时反射率的变化,在掠射角时反射更加明显
  • 微表面分布:使用 GGX 或 Beckmann 分布函数来描述表面粗糙度对高光的影响
  • 几何遮蔽:考虑微表面之间的相互遮蔽对光线传播的影响

输入端口深度解析

Albedo 输入端口

Albedo(反照率)是 PBR 材质系统中最为基础的参数,它定义了材质表面对光线的漫反射特性。在物理意义上,Albedo 表示的是材质表面对不同波长光线的反射能力。

技术特性说明:

  • 数据类型:Vector 3,对应 RGB 颜色空间
  • 数值范围:建议使用 0-1 的线性颜色值,而非 sRGB
  • 物理意义:表示材质在完美漫反射条件下的基础颜色

最佳实践建议:

  • 避免使用过亮或过饱和的颜色值,真实世界材质的 Albedo 值通常在 0.02-0.8 之间
  • 金属材质的 Albedo 应该接近黑色或非常暗的颜色,因为金属主要通过镜面反射表现
  • 对于非金属材质,Albedo 应该包含材质的固有颜色信息

实际应用示例:

// 创建基础 Albedo 纹理采样
AlbedoTexture = SampleTexture2D(AlbedoMap, UV);
// 应用色调调整
AdjustedAlbedo = AlbedoTexture * BaseColor;
// 确保数值在合理范围内
FinalAlbedo = clamp(AdjustedAlbedo, 0.0, 1.0);

Metallic 输入端口

Metallic(金属度)参数控制材质的导电特性,这是区分金属和非金属材质的关键参数。在 PBR 工作流中,金属度是一个二元性很强的参数。

技术实现细节:

  • 数据类型:Float,单精度浮点数
  • 数值范围:0.0(完全非金属)到 1.0(完全金属)
  • 物理基础:基于材质的电导率特性

金属度对材质的影响:

  • 当 Metallic = 1.0 时:
    • Albedo 颜色主要影响镜面反射颜色
    • 漫反射分量几乎为零
    • 菲涅尔反射强度达到最大值
  • 当 Metallic = 0.0 时:
    • Albedo 颜色影响漫反射颜色
    • 镜面反射颜色由入射光决定
    • 菲涅尔效应较弱

纹理制作要点:

  • 金属度贴图通常是灰度图,白色表示金属区域,黑色表示非金属区域
  • 在边界区域可以使用中间值实现平滑过渡
  • 真实世界中很少有完全中性的金属度值,大多数材质要么是金属要么是非金属

Smoothness 输入端口

Smoothness(光滑度)参数控制材质表面的微观粗糙程度,直接影响高光反射的集中程度和范围。

技术参数说明:

  • 数据类型:Float,单精度浮点数
  • 数值范围:0.0(完全粗糙)到 1.0(完全光滑)
  • 对应关系:与粗糙度(Roughness)是倒数关系

光滑度的视觉效果影响:

  • 高光滑度(接近 1.0):
    • 产生小而明亮的高光点
    • 反射图像更加清晰
    • 适合表现抛光金属、玻璃等光滑表面
  • 低光滑度(接近 0.0):
    • 产生大而柔和的高光区域
    • 反射图像模糊不清
    • 适合表现粗糙表面如混凝土、布料等

纹理制作技巧:

  • 光滑度贴图需要注意与法线贴图的协调性
  • 磨损区域通常具有较低的光滑度值
  • 不同材质类型有其典型的光滑度范围值

NormalMap 输入端口

NormalMap(法线贴图)是现代实时渲染中不可或缺的技术,它通过改变表面法线方向来模拟复杂的几何细节,而不需要增加实际的多边形数量。

技术规格:

  • 数据类型:Vector 3,对应世界空间法线方向
  • 坐标空间:世界空间(World Space)
  • 数值范围:各分量通常在 [-1, 1] 范围内

法线贴图的工作流程:

  • 切线空间到世界空间的转换
  • 法线向量的归一化处理
  • 与基础法线的混合计算

制作和使用建议:

  • 确保法线贴图使用正确的色彩空间(通常是线性空间)
  • 注意法线贴图的压缩设置,避免使用有损压缩
  • 在连接法线贴图前通常需要进行 UnpackNormal 操作

Occlusion 输入端口

Occlusion(环境光遮蔽)贴图用于模拟全局光照中的遮蔽效果,增强场景的空间感和深度感。

技术作用:

  • 模拟环境光在凹槽和裂缝中的衰减
  • 增强材质的体积感和真实感
  • 弥补实时全局光照的不足

使用注意事项:

  • 环境光遮蔽通常作为乘数应用于间接光照部分
  • 不影响直接光照计算
  • 数值范围通常在 0.0(完全遮蔽)到 1.0(无遮蔽)之间

输出端口与渲染流程集成

输出特性分析

CustomLighting 节点的输出端口提供经过完整 PBR 计算后的颜色信息,这个输出可以直接用于片元着色器的发射(Emission)通道。

输出数据类型: Vector 3,表示 RGB 颜色值

使用场景: 主要用于 Fragment 阶段的 Emission 输入

渲染管线集成

在 URP 渲染管线中,CustomLighting 节点的输出需要正确集成到渲染流程中:

// 简化的渲染流程示意
FragmentOutput frag (VertexOutput input)
{
    // 基础颜色计算
    float4 baseColor = CalculateBaseColor(input);

    // CustomLighting 节点计算
    float3 lightingResult = CustomLightingFunction(
        input.albedo,
        input.metallic,
        input.smoothness,
        input.normal,
        input.occlusion
    );

    // 最终颜色合成
    FragmentOutput output;
    output.Emission = lightingResult;
    output.Albedo = baseColor.rgb;

    return output;
}

性能优化与质量设置

Shading Quality 配置

CustomLighting 节点的性能和质量可以通过 Shading Quality 设置进行精细调整:

Receive Global Illumination 设置:

  • 设置为 Off 时,节点不接收动态全局光照
  • 减少实时光照计算开销
  • 适用于移动平台或性能敏感的场景

Diffuse Quality 设置:

  • 设置为 None 时禁用高级漫反射计算
  • 使用简化的漫反射模型
  • 显著提升渲染性能

Specular Quality 设置:

  • 设置为 None 时禁用复杂高光计算
  • 使用基本的高光反射模型
  • 适用于对性能要求极高的场景

平台适配策略

不同平台需要不同的质量设置策略:

  • 高端 PC/主机平台:
    • 可以使用完整的 PBR 计算
    • 开启所有高级光照特性
    • 使用高质量的法线和反射计算
  • 移动平台优化:
    • 适当降低计算精度
    • 使用简化版的光照模型
    • 减少实时计算依赖

实际应用案例与工作流

金属材质创建实例

让我们通过一个具体的金属材质案例来演示 CustomLighting 节点的完整使用流程:

材质需求: 创建带有磨损效果的黄铜材质

制作步骤:

  1. 准备基础纹理:

    • Albedo 贴图:暗黄色的基础颜色,磨损处露出底层材质
    • Metallic 贴图:主体区域为白色(金属),磨损处为黑色(非金属)
    • Smoothness 贴图:主体高光滑度,磨损处低光滑度
    • Normal 贴图:表现表面划痕和磨损细节
    • Occlusion 贴图:增强凹槽处的深度感
  2. 节点图连接:

    [Texture Samplers][CustomLighting Node][Fragment Emission][Additional Effects][Final Output]
    
  3. 参数调整要点:

    • 金属度:0.9(高金属性)
    • 光滑度:0.7(适度抛光)
    • 法线强度:根据实际效果微调

非金属材质实例

材质需求: 创建真实的陶瓷材质

特性分析:

  • 高光滑度但非金属
  • 清晰的镜面反射
  • 白色的基础颜色

参数设置:

  • Albedo:接近白色的浅色调
  • Metallic:0.0(完全非金属)
  • Smoothness:0.9(高度光滑)
  • Normal:轻微的表面不规则性

高级技巧与故障排除

常见问题解决方案

问题1:材质看起来过于平淡

  • 检查法线贴图是否正确连接和采样
  • 确认环境光遮蔽贴图是否发挥作用
  • 调整金属度和光滑度的对比度

问题2:性能开销过大

  • 降低 Shading Quality 设置
  • 检查纹理分辨率和压缩格式
  • 考虑使用 LOD 技术动态调整质量

问题3:光照不自然

  • 验证法线空间转换是否正确
  • 检查所有输入参数是否在合理范围内
  • 确认光照环境设置是否适合 PBR 材质

进阶应用技巧

混合材质实现:

通过多个 CustomLighting 节点的混合,可以实现复杂的材质效果:

// 混合两个不同的光照计算结果
float3 material1 = CustomLighting1(...);
float3 material2 = CustomLighting2(...);
float blendFactor = CalculateBlendFactor(...);

float3 finalLighting = lerp(material1, material2, blendFactor);

动态效果集成:

结合时间节点和数学运算,可以实现动态的材质效果:

  • 金属表面的氧化效果
  • 潮湿表面的干湿变化
  • 温度引起的热变色效果

总结与最佳实践

CustomLighting 节点是 Unity URP Shader Graph 中实现高质量 PBR 材质的关键工具。通过深入理解每个输入参数的物理意义和技术特性,开发者可以创建出既真实又性能优异的材质效果。

核心要点回顾:

  • 正确理解 PBR 工作流的物理基础
  • 合理设置各个输入参数的数值范围
  • 根据目标平台调整质量设置
  • 充分利用节点的复合特性实现复杂效果

未来发展趋势:

随着硬件能力的提升和渲染技术的发展,CustomLighting 节点可能会集成更多先进的光照模型特性,如光线追踪、实时光线传播等。保持对新技术的学习和适应,将有助于在未来的项目中创造出更加出色的视觉效果。


【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

手把手从 0 诠释大模型 API 的本质: Tools + MCP + Skills

作者 ArcX
2026年2月15日 14:05

本文写于 2026 年 02 月 15 日.

如今 AI Agent 的各种新概念层出不穷:

  • Tools
  • MCP
  • Skills

许多人都会有这样的疑问: Tools 和 MCP 有什么区别? 我用了 MCP 还需要 Tools 吗? Skills 是取代 MCP 的吗? 本文会从 LLM API 的底层设计开始, 一步步介绍 Tools 和 MCP 的区别, 手动实现一个非常简易的 MCP (简易到你会觉得"就这?"), 最后简单提一下 Skills.

几个重要事实

  • 大模型是无状态的, 它对你们的过往对话一点都没有记忆. 每次调用 LLM API, 都是一次全新的请求, 就像换了一个完全陌生的人说话.
  • 大模型本身的开发(或许)很难, 需要很强的数学知识. 但是大模型应用开发不难, 做纯工程开发的传统程序员也可以很快上手.
  • MCP 和 Skills 都是纯工程层面的设施, 和 AI 毫无关系. 也就是说, 在这两个概念出现以前, 你完全可以自己实现一套类似的机制, 不需要 LLM API 支持.

基于以上几个事实, 本文会选择 Anthropic API 来解释. 因为 OpenAI 的 Responses API 提供了一个叫做 previous_response_id 的参数, 很容易误导人以为 LLM 本身有记忆功能. 但实际上 LLM 是没有记忆的, 这个 previous_response_id 并不会给 LLM 使用, 而是 OpenAI 的服务层面的工程设施, 相当于 OpenAI 帮我们存了历史记录, 然后发给 LLM. Conversations API 同理.

相比之下, Anthropic API 就原生了许多, 更容易感受到 LLM API 的本质.

技术栈

请注意区分 @anthropic-ai/sdk@anthropic-ai/claude-agent-sdk. 前者是 Anthropic API 的封装, 本质上是一个 HTTP Client, 封装了大量的调用 API 的方法; 后者是对 Claude Code (Claude CLI) 的封装, 封装了大量调用 claude 命令行的方法.

本文会使用 GLM-4.7-flash 这个兼容 Anthropic API 的免费模型来节约成本, 毕竟 LLM 应用开发最大的痛点就是每次调试运行都需要花钱.

const client = new Anthropic({
  baseURL: 'https://api.z.ai/api/anthropic', // 国际版, 你也可以使用国内版, 国内版认证方式是 apiKey
  authToken: ZAI_API_KEY,
});

Hello World

首先从一个最简单的请求开始:

const resp = await client.messages.create({
  max_tokens: 1024,
  messages: [
    {
      role: 'user',
      content: '英国的首都是哪里',
    },
  ],
  model: 'glm-4.7-flash',
});

console.log(resp);

Output (省略掉不重要的字段):

{
  "id": "msg_202602151117137d34660397a4418d",
  "type": "message",
  "role": "assistant",
  "model": "glm-4.7-flash",
  "content": [
    {
      "type": "text",
      "text": "英国的首都是**伦敦**(London)。"
    }
  ],
  "stop_reason": "end_turn"
}

多轮对话

正如上面反复提到的, LLM 是无状态的, 每次调用都像是一个全新的完全陌生的人对话. 想象一下, 如果你要和一个人聊天, 每聊完一句, 对面都会换一个人, 那么对方换的人应该如何继续和你的聊天? 当然就是把你之前的聊天历史全部看一遍. 所以调用 LLM 的时候, 每次都需要把历史记录全部传过去.

// 用一个 messages 数组来维护历史记录
const messages: MessageParam[] = [
  {
    role: 'user',
    content: '英国的首都是哪里',
  },
];

const resp = await client.messages.create({
  max_tokens: 1024,
  messages,
  model: 'glm-4.7-flash',
});

// 重点: 将 LLM 的第一次回复放到数组里
messages.push({
  role: 'assistant',
  content: resp.content,
});

// 再加入第二次对话内容
messages.push({
  role: 'user',
  content: '介绍一下这个城市的污染情况',
});

console.log(inspect(messages));

const resp2 = await client.messages.create({
  max_tokens: 1024,
  messages,
  model: 'glm-4.7-flash',
});

console.log(resp2);

可以看看第二次调用 API 传入的 messages 内容是:

[
  {
    "role": "user",
    "content": "英国的首都是哪里"
  },
  {
    "role": "assistant",
    "content": [
      {
        "type": "text",
        "text": "英国的首都是**伦敦**。"
      }
    ]
  },
  {
    "role": "user",
    "content": "介绍一下这个城市的污染情况"
  }
]

而 resp2 成功返回了伦敦的污染情况, 说明 LLM 确实感知到了上一次对话内容的城市是伦敦.

{
  "id": "msg_20260215115536fd125b1bca954cf6",
  "type": "message",
  "role": "assistant",
  "model": "glm-4.7-flash",
  "content": [
    {
      "type": "text",
      "text": "伦敦作为全球国际化大都市和前工业革命中心,其污染历史可以追溯到维多利亚时代,且至今仍是全球空气质量治理的“典型样本”..." // 我手动省略, 减少篇幅, 并非 LLM 省略
    }
  ],
  "stop_reason": "end_turn"
}

所以你应该也知道了, 所谓的 context windows, 其实可以简单理解为 messages 数组的文本长度, 而不是单条消息的长度.

Tools

原始方法

LLM 就像一个很聪明(虽然有时候会很蠢, 但是我们先假定 LLM 很聪明)的大脑, 但是它只有大脑, 没有眼睛 - 意味着它无法接收外界的信息(除了手动传入的 messages), 比如读一个文件; 没有手 - 意味着它无法做出任何行为, 比如修改一个文件. (可以把 LLM 想象成一个遮住眼睛的霍金).

Tools 就相当于给一个大脑安装了外置眼睛和手. 我们先用最朴素的方式让 LLM 调用工具: 直接在 prompt 里写, 有哪些工具, params 分别是什么, 然后让 LLM 选择一个使用, 并提供 params.

const messages: MessageParam[] = [
  {
    role: 'user',
    content: `写一句话介绍中国农历马年.
      你有以下 tools 可以调用:
      1. { name: "write", description: "write content to a file", params: 
        { "content": {"type": "string", description: "content"} },
        { "path": {"type": "string", description: "the path of the file to write"} },
       }

      2. { name: "read", description: "read content of a file", params: 
        { "path": {"type": "string", description: "the path of the file to read"} }
       }

       请你选择一个工具使用, 并且提供正确的 params. 你需要输出一个 JSON
    `,
  },
];

Output:

{
  "id": "msg_202602151218464370b8983c6c474d",
  "type": "message",
  "role": "assistant",
  "model": "glm-4.7-flash",
  "content": [
    {
      "type": "text",
      "text": "```json\n{\n  \"tool\": \"write\",\n  \"params\": {\n    \"content\": \"中国农历马年象征着奔腾不息的活力与豪迈,寓意着奋进、自由与驰骋。\",\n    \"path\": \"/马年介绍.txt\"\n  }\n}\n```"
    }
  ],
  "stop_reason": "end_turn"
}

可以看到, LLM 做到了选择正确的工具, 提供的参数内容倒是没问题, 但是存在以下几个巨大的问题:

  1. 返回的 text 本质上是个字符串. 虽然在 prompt 里明确要求了需要返回一个 JSON, 但是 LLM 依然返回了一个 JSON markdown, 而不是纯 JSON 字符串.
  2. prompt 并不可靠. LLM 无法做到 100% 遵循 prompt, 尤其是能力比较差的模型, 它可能会输出"好的, 下面是我调用工具的 JSON: xxx". 也就是说, 并不能保证输出一定是一个 JSON markdown.
  3. 就算输出是一个 JSON markdown, 我们还需要去解析这个 markdown, 一旦涉及到嵌套, 也就是 params 里也包含反引号, 会更加复杂.
  4. 无法保证输出的 JSON 100% 遵循了 prompt 里的格式, 比如我在调用的时候就出现过返回了 arguments 字段, 而不是 params.

基于以上问题, Tool Use (或者叫 Tool Call, Function Call, 一个意思. Anthropic 的官方术语是 Tool Use) 被内置进了 LLM, 成为了 LLM 自身的一个能力. 也就是说, 如果一个 LLM 不支持 Tool Use, 那么我们基本是没法在工程层面去做 polyfill, 也就无法实现调用 tool.

标准方法

上面的例子, 换标准的 Tool Use 方法:

const messages: MessageParam[] = [
  {
    role: 'user',
    content: `写一个关于中国农历马年的一句话介绍, 写入 test.txt 里`,
  },
];

const resp = await client.messages.create({
  max_tokens: 1024,
  messages,
  model: 'glm-4.7-flash',
  tools: [
    {
      name: 'write',
      description: 'write content to a file',
      input_schema: {
        type: 'object',
        properties: {
          content: {
            type: 'string',
            description: 'content',
          },
          path: {
            type: 'string',
            description: 'the path of the file to write',
          },
        },
      },
    },
    // read 同理, 省略掉
  ],
});

Output:

{
  "id": "msg_20260215123307fffbbd1b9fd84652",
  "type": "message",
  "role": "assistant",
  "model": "glm-4.7-flash",
  "content": [
    {
      "type": "text",
      "text": "我来写一句关于中国农历马年的介绍并保存到文件中。"
    },
    {
      "type": "tool_use",
      "id": "call_49f0c1dbe920406192ce9347",
      "name": "write",
      "input": {
        "content": "中国农历马年象征着活力、热情与自由,是充满朝气与拼搏精神的吉祥年份。",
        "path": "test.txt"
      }
    }
  ],
  "stop_reason": "tool_use"
}

可以看到这次的 content 里多了一个 tool_use 的 block, 里面写明了需要调用的 tool 的名字和参数. 这个 block 的类型是结构化的, 也就是说可以 100% 保证格式是正确, 符合预期的 (但是不能保证 100% 有这个 block, 取决于 LLM 的能力, 太蠢的 LLM 可能无法决策到底用哪个 tool). 这样我们就可以根据这个结构化的 tool_use block, 去执行对于的函数调用.

结果回传

考虑一个场景: 让 LLM 阅读一个文件并分析内容. 经过上面的内容, 你应该知道具体的流程是:

  1. User 要求 LLM 阅读某个文件并分析内容, 并且传入 read tool schema
  2. LLM 决定使用 read tool, 参数是文件路径
  3. User 根据路径读取文件内容, 然后传给 LLM
  4. LLM 成功输出分析结果
const tools: ToolUnion[] = [
  // 本文省略具体内容, read 和 write 两个 tools
];

const messages: MessageParam[] = [
  {
    role: 'user',
    content: `分析一下 package.json`,
  },
];

// 初始请求
const resp = await client.messages.create({
  max_tokens: 1024,
  messages,
  model: 'glm-4.7-flash',
  tools,
});

// 把 LLM 的第一次返回加入到 messages 里
messages.push({
  role: 'assistant',
  content: resp.content,
});

// 第一次返回大概率会包含 tool_use block
// content 是一个数组, 可能额外包含一个 text, 也可能直接就是一个 tool_use
// content 可能包含多个 tool_use, 用户需要把所有的都调用, 然后根据 tool_use_id 去匹配结果
const toolUseResults: ContentBlockParam[] = [];
for (const block of resp.content) {
  if (block.type === 'tool_use') {
    switch (block.name) {
      case 'read':
        try {
          const content = await readFile(block.input.path, 'utf-8');
          toolUseResults.push({ tool_use_id: block.id, type: 'tool_result', content, is_error: false }); // is_error 告诉 LLM 这个调用是否成功
        } catch (err) {
          toolUseResults.push({
            tool_use_id: block.id,
            type: 'tool_result',
            content: JSON.stringify(err),
            is_error: true,
          });
        }

        break;

      case 'write':
        try {
          await writeFile(block.input.path, block.input.content);

          toolUseResults.push({ tool_use_id: block.id, type: 'tool_result', content: 'success', is_error: false });
        } catch (err) {
          toolUseResults.push({
            tool_use_id: block.id,
            type: 'tool_result',
            content: JSON.stringify(err),
            is_error: true,
          });
        }
        break;
    }
  }
}
// 将 tool use results 传给 LLM
messages.push({ role: 'user', content: toolUseResults });

console.log(inspect(messages));

const resp2 = await client.messages.create({
  max_tokens: 1024,
  messages,
  model: 'glm-4.7-flash',
  tools,
});
console.log(resp2);

第二次传给 LLM 的 messages 为:

[
  {
    "role": "user",
    "content": "分析一下 package.json"
  },
  {
    "role": "assistant",
    "content": [
      {
        "type": "text",
        "text": "我来帮你分析 package.json 文件。首先,我需要读取这个文件来查看它的内容。"
      },
      {
        "type": "tool_use",
        "id": "call_eeb390dfe8f345269ff95f42",
        "name": "read",
        "input": {
          "path": "package.json"
        }
      }
    ]
  },
  {
    "role": "user",
    "content": [
      {
        "tool_use_id": "call_eeb390dfe8f345269ff95f42",
        "type": "tool_result",
        "content": "{\n  \"name\": \"gh-scout\",\n  \"module\": \"index.tsx\",\n  \"type\": \"module\",\n  \"private\": true,\n  \"devDependencies\": {\n    \"@types/bun\": \"latest\"\n  },\n  \"peerDependencies\": {\n    \"typescript\": \"^5\"\n  },\n  \"dependencies\": {\n    \"@anthropic-ai/sdk\": \"^0.74.0\"\n  }\n}\n",
        "is_error": false
      }
    ]
  }
]

第二次从 LLM 返回的结果为:

{
  "id": "msg_2026021512452122fcf4da65474784",
  "type": "message",
  "role": "assistant",
  "model": "glm-4.7-flash",
  "content": [
    {
      "type": "text",
      "text": "## 📦 `package.json` 文件分析\n\n### 基本信息\n\n| 字段 | 值 | 说明 |\n|------|-----|------|\n| **name** | `\"gh-scout\"` | 项目名称,用于 npm 包标识 |\n| **module** | `\"index.tsx\"` | ES 模块入口文件 |\n| **type** | `\"module\"` | 使用 ES6 模块语法 |\n| **private** | `true` | 标记为私有项目,防止误发布到 npm |\n\n### 依赖管理\n\n#### 🔧 开发依赖 (`devDependencies`)\n```json\n\"@types/bun\": \"latest\"\n```\n- 类型定义包,用于 Bun 运行时的 TypeScript 类型支持\n- `\"latest\"` 表示使用最新版本\n\n#### 🤝 对等依赖 (`peerDependencies`)\n```json\n\"typescript\": \"^5\"\n```\n- **关键点**:`typescript` 被标记为对等依赖\n- 这意味着**项目不直接依赖 TypeScript**,而是期望用户环境中已安装 TypeScript\n- 适用于插件/工具类项目,让用户自己选择 TS 版本\n\n#### ⚡ 运行时依赖 (`dependencies`)\n```json\n\"@anthropic-ai/sdk\": \"^0.74.0\"\n```\n- Anthropic AI 的官方 SDK\n- 版本范围 `^0.74.0` 表示安装 0.74.0 或更高版本(不超过主版本号)\n\n---\n\n### 📝 总结\n\n这是一个**轻量级的 TypeScript 工具项目**(`gh-scout`),主要特点:\n\n1. **无运行时依赖**:除了 Anthropic SDK 外,没有其他运行时依赖\n2. **依赖 TypeScript**:用户自己提供 TS 环境\n3. **使用 Bun**:通过 `@types/bun` 表明使用 Bun 运行环境\n4. **模块化设计**:ES 模块类型,入口为 `index.tsx`\n\n项目看起来是一个与 GitHub 相关的工具(从名字 \"gh-scout\" 推测),可能用于分析或监控 GitHub 相关的操作。"
    }
  ],
  "stop_reason": "end_turn"
}

可以看到, LLM 第一次告诉我们需要调用 read tool 来读取文件内容. 我们调用完毕后把结果传给 LLM, LLM 第二次就成功分析出了内容.

插个题外话: 看到这里, 你应该也觉得原生 LLM 的方式实在是太繁琐了.

  • messages 要手动维护
  • tool_use 要手动解析 LLM 的返回, 手动调用, 然后手动把结果传到 messages 数组里
  • 如果 LLM 后续还要调用其他 tools, 还需要手动写一个循环

这正是现在各种 AI Agent 框架的意义, 比如 LangChain, LangGraph, Agno 等, 它们底层其实也都是做这种事情, 和传统领域的框架一样, 把繁琐的步骤都封装好了, 就像写 React 就不需要手动去操作 DOM 一样.

MCP

上面的方式虽然繁琐, 但也完全覆盖了所有场景了. 任何 tool use 都可以用上面的方式去实现. 那么为什么还需要 MCP 呢?

MCP 是什么

MCP (model context protocol) 是一个协议, 定义了 MCP Client 和 MCP Server 的通信方式. MCP 的原理和 AI/LLM 没有任何关系, 只是定义了 tools/resources/prompt 三种信息的通信格式.

MCP 解决了什么问题

假设现在没有 MCP 这个概念.

众所周知, LLM 非常擅长写文档类的东西, 比如 PR description. 所以现在你想让 LLM 帮你在 github 提一个 PR. 你需要先定义一个 tool:

const tools: ToolUnion[] = [
  {
    name: 'github_create_pr',
    description: 'create a PR on github',
    input_schema: {
      type: 'object',
      properties: {
        repo: {
          type: 'string',
          description: 'The repo name. Format: {owner}/{repo_name}',
        },
        source_branch: {
          type: 'string',
          description: 'The source branch name',
        },
        target_branch: {
          type: 'string',
          description: 'The target branch name',
        },
        title: {
          type: 'string',
          description: 'The title of the PR',
        },
        description: {
          type: 'string',
          description: 'The description body of the PR',
        },
      },
    },
  },
];

然后实现这个 tool 的调用过程:

case 'github_create_pr':
  const { repo, source_branch, target_branch, title, description } = block.input;
  const [owner_name, repo_name] = repo.split('/');

  try {
    // 也可以用 gh cli
    const resp = await fetch(`https://api.github.com/repos/${owner_name}/${repo_name}/pulls`, {
      method: 'post',
      headers: {
        accept: 'application/vnd.github+json',
        authorization: 'Bearer GITHUB_TOKEN',
      },
      body: JSON.stringify({
        title,
        body: description,
        base: source_branch,
        head: target_branch,
      }),
    });

    toolUseResults.push({
      tool_use_id: block.id,
      type: 'tool_result',
      content: await resp.text(),
      is_error: false,
    });
  } catch (err) {
    toolUseResults.push({
      tool_use_id: block.id,
      type: 'tool_result',
      content: JSON.stringify(err),
      is_error: true,
    });
  }
  break;

每加一个这样的 tool, 都需要花费大量的精力. 但实际上这些 tools 是高度通用的, 调用 github 是一个很普遍的需求.

此时你可能想到, 那我封装一个 github_tools 不就可以了?

于是你行动力拉满, 自己(或者让 AI)封装了一个 github_tools, 发布到了 npm 上, 其他用户可以像这样使用你的库:

import { tools as githubTools, callTool } from '@arc/github_tools';

const tools = [...myTools, ...githubTools];

for (const block of resp.content) {
  if (block.type === 'tool_use') {
    if (block.name.startsWith('github')) {
      const result = await callTool(block);
    }
  }
}

但是此时又有了两个新的问题:

  1. 你的新项目使用了 Go/Rust, 用不了 npm 包.
  2. 由于 Anthropic API 太贵, 你决定迁移到 DeepSeek API, 但是 DeepSeek 对 Anthropic 的兼容性不是很好(假设), 有些格式不匹配, 导致你的库调用失败.

MCP 的出现就是为了解决上面的问题. MCP 本质上是把 tools 的定义和执行都外置出去了. MCP 分为 Client 和 Server, 其中 Server 就是外置出去的部分, 负责 tools 的定义和执行. 而 Client 就是留在 AI 应用的部分, 负责和 Server 通信:

  • Hi Server, 告诉我有哪些 tools 可以用?
  • Hi Server, 我现在要调用 github_create_pr 这个 tool, 参数是 { xxx }

最简易的 MCP 实现

知道了 MCP 的设计思想, 那么我们完全可以写一个最简易的实现:

const server = async ({ type, body }: { type: string; body?: any }): Promise<string> => {
  if (type === 'list_tools') {
    return JSON.stringify([
      {
        name: 'github_create_pr',
        description: 'create a PR on github',
        input_schema: {
          type: 'object',
          properties: {
            repo: {
              type: 'string',
              description: 'The repo name. Format: {owner}/{repo_name}',
            },
            source_branch: {
              type: 'string',
              description: 'The source branch name',
            },
            target_branch: {
              type: 'string',
              description: 'The target branch name',
            },
            title: {
              type: 'string',
              description: 'The title of the PR',
            },
            description: {
              type: 'string',
              description: 'The description body of the PR',
            },
          },
        },
      },
    ]);
  }

  if (type === 'call_tool') {
    switch (body.name) {
      case 'github_create_pr':
        const { repo, source_branch, target_branch, title, description } = body.input;
        const [owner_name, repo_name] = repo.split('/');
        try {
          const resp = await fetch(`https://api.github.com/repos/${owner_name}/${repo_name}/pulls`, {
            method: 'post',
            headers: {
              accept: 'application/vnd.github+json',
              authorization: 'Bearer GITHUB_TOKEN',
            },
            body: JSON.stringify({
              title,
              body: description,
              base: source_branch,
              head: target_branch,
            }),
          });
          return await resp.text();
        } catch (err) {
          return JSON.stringify(err);
        }
    }
  }

  return 'Unknown type';
};

为了简单起见, 我直接写的是一个函数. 你完全可以将其做成一个 HTTP server, 因为反正这个函数的返回类型是 string, 可以作为 HTTP Response.

然后再写一个 client:

class McpClient {
  async listTools() {
    const tools = await server({ type: 'list_tools' });
    return JSON.parse(tools) as ToolUnion[];
  }

  async callTool(name: string, params: any) {
    const res = await server({ type: 'call_tool', body: params });
    return res;
  }
}

发现了吗? 上面的代码和 LLM 一点关系都没有, 这也是我一直在强调的重点: MCP 是工程设计, 不是 LLM 自身能力. 你完全可以脱离 AI, 直接使用 github 的官方 mcp server, 手动调用里面提供的方法. AI 在这里面唯一做的事情只是帮你决定调用的 tool_name + params.

用我们自己实现的 MCP Client 和 Server 改写上面的代码:

const messages: MessageParam[] = [
  {
    role: 'user',
    content: `分析一下 package.json`,
  },
];

const mcpClient = new McpClient();
const resp = await client.messages.create({
  max_tokens: 1024,
  messages,
  model: 'glm-4.7-flash',
  tools: await mcpClient.listTools(),
});

const toolUseResults: ContentBlockParam[] = [];
for (const block of resp.content) {
  if (block.type === 'tool_use') {
    if (block.name.startsWith('github')) {
      try {
        const result = await mcpClient.callTool(block.name, block.input);
        toolUseResults.push({ tool_use_id: block.id, type: 'tool_result', content: result, is_error: false });
      } catch (err) {
        toolUseResults.push({
          tool_use_id: block.id,
          type: 'tool_result',
          content: JSON.stringify(err),
          is_error: true,
        });
      }
    }
  }
}
messages.push({ role: 'user', content: toolUseResults });

const resp2 = await client.messages.create({
  max_tokens: 1024,
  messages,
  model: 'glm-4.7-flash',
  tools,
});
console.log(resp2);

瞬间简洁了不少. github 相关的 tools 定义和实现都外置到了 MCP Server 上, 这样就做了两层解耦:

  1. 具体语言解耦 - 你可以用任何语言实现 MCP Server, 只要它能处理字符串.
  2. LLM 解耦 - 你可以用任何支持 tool use 的 LLM, MCP 协议里单独定义了字段, 和 LLM 自己的字段无关.

Skills

现在你已经了解到了:

  1. Tool Use 是 LLM 自身的能力.
  2. MCP 不是 LLM 自身的能力, 而是工程设计, 辅助 Tool Use 用的.

那么最近很火的 Skills 又是什么呢? 是取代 MCP 的吗? 当然不是.

LLM 的 context 是非常宝贵的. 如果在系统提示词里放入太多的内容, 会导致系统提示词本身就占据大量 context. 举个例子, 假设你在开发一个 Coding Agent, 你集成了 github MCP Server, 那么每次 LLM API 调用, 都会把完整的 github MCP 相关的 tools 定义全部发给 LLM. 如果绝大部分用户根本就不会用 github 的能力, 那你就平白无故浪费了大量 context.

这就是 Skills 解决的问题: 渐进式披露, 或者叫按需加载.

我个人猜测 Skills 应该也是工程设计, 也不是 LLM 的能力, 因为我们完全可以自己实现一套机制, 用下面的系统提示词:

你是一个全能专家. 你拥有以下技能:

1. 做饭: 川菜, 粤菜, 日料, 英国美食.
2. 旅游: 规划旅游路线, 选择最佳景点, 解说历史遗迹.
3. 写代码: Typescript, Rust, Go, Python.
...
99. 视频制作: 制作爆款视频, 通过制造各种对立吸引流量.
100. Slides 制作: 制作精美的, 吸引领导眼光的 Slides.

所有的技能都被单独放到了 .skills 目录里. 当用户的问题与某个技能相关时, 你需要使用 Read tool 来读取对应技能的全部文档.

看到了吗? 系统提示词里只放了最基本的技能名字和简介(也就是 SKILL.md 开头的 name + description), 没有放具体技能的内容 (比如具体怎么做菜, 具体怎么写代码, 具体制造哪种对立更符合当下的热点), 大幅节约了 context.

如果此时用户问"帮我用 Rust 写个基本的 HTTP Server", 那么 LLM 第一条返回的消息应该就包含一个 read 的 tool_use, 读取 .skills/coding 里所有的内容, 里面就会包含具体的细节, 比如 "不要用 unwrap", "优先使用 axum 框架" 等. 用户把这些内容通过 tool_use_result 发给 LLM 后, LLM 再去写最终的代码给用户.

所以 Skills 也并不是什么神奇的事情, 并不是说 Skills 赋予了 AI 大量额外的能力, 只是单纯地通过按需加载, 节约了 context, 从而可以放大量的 Skills 在目录里. 毕竟在 Skills 出现之前, 你完全也可以把具体的写代码能力写到系统提示词里, LLM 照样会拥有完整的写代码的能力.

总结

本文从 0 开始一步步讲述了 LLM API 的设计, 多轮对话, 原生 Tool Use 的方式, MCP 的原理, Skills 的思想. 让我们回顾一下几个核心要点:

Tool Use - LLM 的核心能力

Tool Use 是 LLM 模型本身的能力, 需要模型在训练时就支持. 它让 LLM 能够:

  • 理解工具的定义和参数
  • 根据用户意图决策应该调用哪个工具
  • 结构化的格式输出工具调用信息

如果一个 LLM 不支持 Tool Use, 我们几乎无法通过工程手段来弥补, 因为用 prompt 的方式既不可靠, 又难以解析.

MCP - 工程层面的协议

MCP 是纯粹的工程设计, 和 AI 完全无关. 它解决的是工程问题:

  • 跨语言: 用任何语言都可以实现 MCP Server, 不局限于某个生态
  • 解耦: tools 的定义和实现从应用代码中分离出去
  • 复用: 同一个 MCP Server 可以被多个应用、多个 LLM 使用
  • 标准化: 统一了工具的通信协议, 避免了各自为政

MCP 的价值在于降低了集成成本, 让开发者可以专注于业务逻辑, 而不是重复造轮子.

Skills - 优化 Context 的策略

Skills 同样是工程层面的优化, 核心思想是:

  • 按需加载: 不把所有能力都塞进系统提示词
  • 渐进式披露: 需要什么能力才加载什么内容
  • 节约 Context: 让有限的 context window 发挥更大价值

Skills 不是新技术, 而是一种最佳实践模式, 在 Skills 概念出现之前我们就可以自己实现类似机制.

三者的关系

Tool Use, MCP, Skills 并不是互相取代的关系, 而是相辅相成:

┌─────────────────────────────────────────┐
│          AI Application                 │
│  ┌────────────────────────────────┐     │
│  │  Skills (按需加载能力)          │     │
│  │  - 系统提示词优化                │     │
│  │  - Context 管理                 │     │
│  └────────────────────────────────┘     │
│                                         │
│  ┌────────────────────────────────┐     │
│  │  MCP Client (工具集成层)        │     │
│  │  - 从 MCP Server 获取工具定义    │     │
│  │  - 调用 MCP Server 执行工具     │     │
│  └────────────────────────────────┘     │
│                    ↓                    │
│  ┌────────────────────────────────┐     │
│  │  LLM with Tool Use (AI 能力层) │     │
│  │  - 理解工具                      │     │
│  │  - 决策调用                      │     │
│  └────────────────────────────────┘     │
└─────────────────────────────────────────┘
                    ↕
        ┌──────────────────────┐
        │   MCP Server (外部)   │
        │   - github tools      │
        │   - filesystem tools  │
        │   - database tools    │
        └──────────────────────┘
  • Tool Use 是基础, 没有它其他都无从谈起
  • MCP 让工具的集成变得简单和标准化
  • Skills 让能力的组织变得高效

实践建议

在实际开发 AI 应用时:

  1. 选择支持 Tool Use 的 LLM: 这是硬性要求, 没有商量余地
  2. 优先使用现有的 MCP Server: 不要重复造轮子, github/filesystem 等常用工具都有官方 MCP Server
  3. 合理组织 Skills: 如果你的系统提示词超过几千 tokens, 考虑用 Skills 模式进行按需加载
  4. 理解工程本质: MCP 和 Skills 都是工程问题, 理解其原理后完全可以根据需求自己实现或调整

最后

希望本文帮助你厘清了 Tool Use, MCP, Skills 三者的关系. 记住核心观点: Tool Use 是 AI 能力, MCP 和 Skills 是工程设计. 它们各司其职, 共同构建了现代 AI Agent 的能力体系.

当你在开发 AI 应用时遇到问题, 先问自己: 这是 LLM 能力的问题, 还是工程设计的问题? 如果是 LLM 能力的问题, 我们就没法自己解决了, 只能换 LLM; 如果是工程设计的问题, 在这个极高速发展的行业, 如果还没有解决方案, 那我们是完全有能力去解决的.

目前属于 LLM 能力(需要训练支持)的概念:

  • Tool Use
  • Thinking
  • Structured Output
  • Multimodal

属于工程设计, 但是很难去 polyfill, 需要服务提供方支持的概念:

  • Streaming
  • Cache
  • Batch API

属于工程设计, 并且比较容易 polyfill 的概念:

  • MCP
  • Skills
  • SubAgent

【翻译】Rolldown 工作原理解析:符号关联、CJS/ESM 模块解析与导出分析

2026年2月15日 12:31

原文链接:www.atriiy.dev/blog/rolldo…

作者:Atriiy

引言

Rolldown 是一款基于 Rust 开发的高性能 JavaScript 打包工具。它在完全兼容 Rollup API 的前提下,实现了 10 至 30 倍的打包速度提升。出于对开发与生产环境统一引擎的需求,Vite 团队正将 Rolldown 打造为当前基于 esbuild + Rollup 的打包架构的继任者。

在现代前端项目中,成百上千的模块构成了复杂的依赖图谱。打包工具的理解不能仅停留在 “文件级导入” 层面:它必须深入分析,判断 featureA.js 中导入的 useState 是否与 featureB.js 中的 useState 为同一个实体。这一关键的解析过程被称为链接(linking)

链接阶段(link stage)正是为解决这一问题而生:它处理那些会在模块图谱中传播的宏观属性(例如顶层 await 的 “传染性”);搭建不同模块系统(CJS/ESM)之间的通信桥梁;最终将每一个导入的符号追溯到其唯一的原始定义。

为揭开这一过程的神秘面纱,我们将通过三级心智模型拆解链接阶段的内部机制,从宏观到微观逐步剖析其工作原理。

三级心智模型

扫描阶段输出的是一份基础的模块依赖图谱,但仅停留在文件级别。而链接阶段会通过一系列数据结构和算法细化这份图谱,最终生成精准的「符号级依赖映射」。

  • 基础与固有属性。扫描阶段生成的初始图谱存储在 ModuleTable 中,记录了所有模块的依赖关系。链接阶段会对该图谱执行深度优先遍历,计算并传播诸如顶层 await(TLA)这类具有 “传染性” 的属性。这些属性可能通过间接依赖影响整个模块链,因此这一分析是代码生成阶段的关键前提。

  • 标准化与模块通信协议。JavaScript 对多模块系统(主要是 CommonJS 即 CJS、ES 模块即 ESM)的支持带来了复杂度。在核心链接逻辑执行前,必须先规范化这些不同的模块格式,处理命名空间对象、垫片化导出(shimmed exports)等细节。这种标准化构建了统一的处理环境,让符号链接算法能专注于核心的解析逻辑,而非大量边缘情况。

  • 万物互联:符号谱系。在最细粒度层面,该阶段会将符号与其对应的导入、导出语句建立关联。它借助并查集(Disjoint Set Union,DSU) 数据结构高效建立跨模块的等价关系,确保每个符号都能解析到唯一、无歧义的原始定义。

示例项目

为了梳理链接阶段复杂的数据结构与算法逻辑,我们将以一个具体的示例项目展开讲解。这种方式能让底层逻辑变得更具象、更易理解。该项目是特意设计的,旨在展现链接阶段必须处理的多个关键场景:

  • CJS 与 ESM 混合使用
  • 顶层 await(TLA)
  • 具名导出与星号重导出
  • 潜在歧义符号
  • 外部依赖
  • 副作用

完整源码可在这份 GitHub Gist 中查看,项目的文件结构如下:

📁 .
├── api.js                # (1) Fetches data, uses Top-Level Await (ESM)
├── helpers.js            # (2) Re-exports modules, creating linking complexity (ESM)
├── legacy-formatter.cjs  # (3) An old formatting utility (CJS)
├── main.js               # (4) The application entry point (ESM)
└── polyfill.js           # (5) A simulated polyfill to demonstrate side effects (ESM)

若你想自行运行这个示例,需将这些文件放置到 Rolldown 代码仓库的 crates/rolldown/examples/basic 目录下。随后,修改 basic.rs 文件,把 main.js 配置为入口点:

// ...
input: Some(vec![
  "./main.js".to_string().into(),
]),

完成调试环境配置后,建议你结合断点运行这款打包工具。通过单步执行代码、实时查看数据结构的方式,能让你更深入地理解整个处理流程。

基础与固有属性

扫描阶段会生成一份基础的模块依赖图谱。在这张有向图中,节点代表单个模块,边表示模块间的导入关系;为了实现高效遍历,该结构通常基于邻接表实现。链接阶段基础环节的核心任务,是遍历这张图谱并计算那些会沿导入链传播的宏观属性 —— 例如顶层 await(TLA)的 “传染性”。想要理解这些算法的工作原理,扎实掌握核心模块的数据结构是必不可少的前提。

图谱设计

图谱设计的核心是 ModuleIdx(模块索引)—— 一种用于指向特定模块的类型化索引。模块分为两类:NormalModuleExternalModule,不过我们的分析将主要聚焦前者。每个NormalModule都会封装其 ECMAScript 解析结果,其中最关键的是 import_records 字段(该字段会列出模块中所有的导入语句)。以下类图展示了这一数据结构的设计思路。

classDiagram
note for ModuleIdx "Typed Index of Module"
class ModuleIdx { 
  TypeAlias for u32
}

note for ModuleTable "Global Module Table" 
class ModuleTable { 
  +modules: IndexVec< ModuleIdx, Module >
}
ModuleIdx -- ModuleTable : used_as_index_for 

class Module { 
  <<enumeration>> 
  +Normal: Box~NormalModule~
  +External: Box~ExternalModule~
  +idx() : ModuleIdx 
  +import_records() : IndexVec< ImportRecordIdx, ResolvedImportRecord >
} 
ModuleTable "1" o-- "0..*" Module : contains

class ExternalModule { 
  +idx: ModuleIdx
  +import_records: IndexVec<...> 
} 
Module -- ExternalModule : can_be_a 

class NormalModule { 
  +idx: ModuleIdx 
  +ecma_view: EcmaView 
} 
Module -- NormalModule : can_be_a 

class ImportRecordIdx { 
  TypeAlias for u32
}

class EcmaView { 
  import_records: IndexVec< ImportRecordIdx, ResolvedImportRecord > 
}
NormalModule "1" *-- "1" EcmaView : own
ImportRecordIdx -- EcmaView : used_as_index_for 

class ImportRecord~State~ { 
  +kind: ImportKind 
}
EcmaView "1" o-- "0..*" ImportRecord~State~ : contains

class ImportRecordStateResolved { 
  +resolved_module: ModuleIdx 
}
ImportRecord~State~ *-- ImportRecordStateResolved : holds

遍历依赖图时,需要逐一迭代处理每个模块的依赖项。这些依赖项可通过 import_records 字段访问。为了简化这一高频操作,Module 枚举类型专门实现了一个便捷的 import_records() 访问器方法。这一设计选择简化了依赖图的遍历流程,Rolldown 源码中下述常见代码模式即可印证这一点:

module_table.modules[module_idx]
.import_records()
.iter()
.map(|rec| {
  // do something...
})

核心数据结构:LinkingMetadata

链接阶段(link stage)的最终输出封装在 LinkStageOutput 结构体中。其定义如下:

pub struct LinkStageOutput {
  pub module_table: ModuleTable,
  pub metas: LinkingMetadataVec,
  pub symbol_db: SymbolRefDb,
  // ...
}

总结这些字段的作用:ModuleTable 是扫描阶段(scan stage)的主要输入,而 LinkingMetadataVecSymbolRefDb 是核心输出 —— 前者存储在模块层面新计算出的信息,后者则存储符号层面的相关信息。这三个结构体共同从宏观到微观的维度,完整、多层级地描述了模块间的依赖关系。

ModuleTable 类似,LinkingMetadataVec 是一个带索引的向量(indexed vector),可通过 ModuleIdx 访问每个具体模块的元数据。而绝大多数这类新增的模块层面信息,均记录在 LinkingMetadata 结构体内部。

classDiagram
note for ModuleIdx "Typed Index of Module"
class ModuleIdx {
  TypeAlias for u32
}
class LinkStage {
  +metas: IndexVec< ModuleIdx, LinkingMetadata >
}
ModuleIdx -- LinkStage : used_as_index_for
class LinkingMetadata {
  +wrap_kind: WrapKind
  +resolved_exports: FxHashMap
  +has_dynamic_exports: bool
  +is_tla_or_contains_tla_dependency: bool
}
LinkStage "1" o-- "0..*" LinkingMetadata : contains

顶层 await(TLA)的处理逻辑

顶层 await(Top-level await,简称 TLA),顾名思义,允许开发者在 ES 模块的顶层作用域中使用 await 关键字,无需将异步代码包裹在 async 函数中。该特性极大简化了异步初始化任务的编写,相比传统的立即调用函数表达式(Immediately Invoked Function Expression,IIFE)模式,代码逻辑更清晰。我们示例项目中的 api.js 文件就体现了这一用法:

console.log('API module evaluation starts.')
// Use top-level await to make the entire dependency chain async
const response = await fetch('https://api.example.com/items/1')
const item = await response.json()

// Export a processed data
export const fetchedItem = { id: item.id, value: item.value * 100 }

// Export a normal variable, we will use it to create confusion
export const source = 'API'

console.log('API module evaluation finished.')

TLA 的一个核心特性是其 “传染性”:若某个模块使用了 TLA,所有直接或间接导入该模块的其他模块都会受到影响,且必须被当作异步模块处理。每个模块的 LinkingMetadata 中都包含一个 is_tla_or_contains_tla_dependency 标记,用于追踪这一状态。在我们的示例中,main.js 依赖 helpers.js,而 helpers.js 又依赖使用了 TLA 的 api.js;因此 Rolldown 会对依赖图执行深度优先遍历,并将这三个模块的该标记均设为 true

该标记的计算逻辑完全贴合 TLA 的行为特征:Rolldown 采用递归的深度优先搜索(DFS)算法,并通过哈希表做记忆化处理(memoization),避免对同一模块重复计算。算法核心通过检查两个条件,判断模块是否受 TLA 影响:

  • 模块自身是否包含顶层 await?(这一信息来自扫描阶段计算得到的 ast_usage 字段)
  • 模块是否导入了任何受 TLA 影响的其他模块?

这种递归检查确保了 TLA 的 “传染性” 能从源头沿整个依赖链向上正确传播。

副作用的判定逻辑

简单来说,若一个模块在被导入时,除了导出变量、函数或类之外还执行了其他操作,则称该模块具有 “副作用”。具体而言,它会执行影响全局环境或修改自身作用域之外对象的代码。Polyfill(兼容性补丁)是典型应用场景 —— 这类代码通常会扩展全局对象,以使老旧浏览器能够支持新的 API。

我们示例项目中的 polyfill.js 就是绝佳例证:尽管 main.js 仅通过 import './polyfill.js' 导入该模块,并未引用任何符号,但由于它修改了全局的 globalThis 对象,该模块仍被判定为具有副作用。因此 Rolldown 必须确保该模块的代码被包含在最终的打包产物中。

由于打包工具无法通过编程方式判断副作用是否 “有益”,只能保守地保留所有被标记为含副作用的模块。识别这类模块的流程与计算 TLA 类似:该属性同样具有传染性,若一个模块存在副作用,所有直接 / 间接导入它的模块都会被认定为受影响。其底层算法也基本一致:通过递归的深度优先搜索(DFS)检查每个 Module 上的 side_effects() 信息,并借助记忆化处理避免重复检查。

至此,我们的模块图已标注了顶层 await、副作用等宏观属性,但这仍不够。在精准链接符号之前,我们必须先解决一个问题:模块可能使用不同的模块系统(如 CJS 和 ESM),它们需要一套统一的交互协议 —— 这正是我们下一步要实现的目标。

标准化处理与模块交互协议

尽管扫描阶段已梳理出模块依赖图,但该阶段仅捕获了 “文件级” 的依赖关系。这份原始依赖图并未考虑一个关键问题:模块可能采用不同的模块系统(如 CommonJS(CJS)和 ES 模块(ESM)),而这些系统本身并不具备互操作性。为解决这一问题,在执行更深层的链接逻辑前,必须先完成标准化处理流程。

该标准化处理在链接阶段执行:Rolldown 遍历模块依赖图,为每个模块计算所需的标准化信息,并将其记录到对应的 LinkingMetadata 结构体中。正如我们此前所述,该结构体存储在 LinkStageOutputmetas 字段内。

模块系统与包装器(Wrapper)

现代 JavaScript 生态中主流的模块系统有两种:CommonJS(CJS)和 ES 模块(ESM)。

  • CommonJS(CJS) :主要应用于 Node.js 生态,是一种同步模块系统。依赖通过阻塞式的 require() 调用加载,模块则通过向 module.exportsexports 对象赋值的方式暴露自身 API。
  • ES 模块(ESM) :ECMAScript 推出的官方标准,同时适配浏览器和 Node.js 环境。其静态结构(使用 importexport 语句)专为编译期分析设计,而浏览器端的加载机制本身是异步、非阻塞的。

这两种系统常出现在同一代码库中 —— 尤其是现代基于 ESM 的项目依赖仅提供 CJS 分发包的老旧第三方库时。为处理这种混合使用场景,Rolldown 会判定模块是否需要 “包装器(Wrapper)”。尽管具体算法将在后续详述,但其核心思路十分简单:包装器是一个函数闭包,能够模拟特定的模块运行环境,从而让不兼容的模块系统实现交互。

以下简化示例阐释了这一核心思想:

// Code storage
const __modules = {
  './utils.js': exports => {
    exports.add = (a, b) => a + b
  },
  './data.js': (exports, module) => {
    module.exports = { value: 42 }
  },
}

// Runtime
function __require(moduleId) {
  const module = { exports: {} }
  __modules[moduleId](module.exports, module)

  return module.exports
}

尽管实际生成的代码更为复杂,但其核心原理始终不变:将模块代码包裹在函数中,以在运行时为其提供所需的执行环境。我们示例项目中的 legacy-formatter.cjs 正是如此 ——Rolldown 检测到这个 CJS 文件被一个 ESM 模块(helpers.js)导入,因此会对其进行对应的包装处理(使用 WrapKind::Cjs 类型的包装器)。该包装器模拟了 module.exports 执行环境,确保不同模块系统间实现无缝互操作。你可以查看 basic/dist 目录下的打包产物,直观看到这一处理逻辑的实际效果。

模块类型判定与包装器选择

为确保 CJS 与 ESM 之间的无缝互操作,Rolldown 必须为每个模块选择合适的包装器。这一决策不仅取决于模块自身的格式,还与其他模块导入该模块的方式相关。

首先,在扫描阶段,Rolldown 会根据模块的语法特征识别出每个模块的 ExportsKind(导出类型),并将其存储在该模块的 EcmaView 中:

pub struct EcmaView {
  pub exports_kind: ExportsKind,
  // ...
}

接下来,Rolldown 会考量导入方模块所使用的 ImportKind(导入类型)。该枚举类型涵盖了 JavaScript 中引用其他文件的所有方式:

pub enum ImportKind {
  /// import foo from 'foo'
  Import,
  /// `import('foo')`
  DynamicImport,
  /// `require('foo')`
  Require,
  // ... (other kinds like AtImport, UrlImport, etc.)
}

pub enum ExportsKind {
  Esm,
  CommonJs,
  None,
}

核心逻辑在于导入方的 ImportKind(导入类型)与被导入方的 ExportsKind(导出类型)的组合判定。二者的匹配关系决定了被导入方所需的 WrapKind(包装器类型)。例如,当一个模块通过 require() 方式加载(对应 ImportKind::Require 类型)时,其 WrapKind 由自身的 ExportsKind 决定。这一逻辑确保了被导入模块在运行时能获得适配的执行环境。

// ...
ImportKind::Require => match importee.exports_kind {
  ExportsKind::Esm => {
    self.metas[importee.idx].wrap_kind = WrapKind::Esm;
  }
  ExportsKind::CommonJs => {
    self.metas[importee.idx].wrap_kind = WrapKind::Cjs;
  }
}
// ...

递归应用包装器

在确定了不同交互场景下所需的 WrapKind(包装器类型)后,wrap_modules 函数会遍历模块依赖图,应用这些包装器并处理相关的复杂逻辑。

其中一个核心难点是 CommonJS 模块中的 “星号导出”(export * from './dep')。由于 CJS 模块的完整导出列表无法在编译期确定,这类导出会被视为动态导出,需要特殊处理。

此外,包装过程本身是递归的:当一个模块需要包装器时(例如被 ESM 模块导入的 CJS 模块),仅包装该单个模块是不够的 —— 包装器可能引入新的异步行为。因此 Rolldown 必须递归向上遍历整个导入链,确保所有依赖这个新包装模块的模块都被正确处理。这种递归传播机制能保证所有依赖在运行时就绪,并维持正确的执行顺序。

整合所有环节:符号溯源

在完成依赖图基础属性的标注、模块格式的标准化后,我们终于来到链接阶段的核心任务:处理导入的符号。最终目标是将每个符号追溯到其唯一、明确的原始定义。

以示例项目中的场景为例:main.jshelpers.js 导入名为 source 的符号,而 helpers.js 又会将 api.js 中的所有内容重新导出。打包工具如何确认 main.js 中使用的 source,就是 api.js 中定义的那个完全相同的变量?

这本质上是一个 “等价性判定” 问题。为高效解决该问题,Rolldown 采用了并查集(Disjoint Set Union,DSU) 数据结构 —— 这是一种专为这类等价性问题设计的算法。在该模型中,每个符号引用都被视为一个元素,核心目标是将所有指向同一原始定义的引用归并到同一个集合中。

并查集(DSU)

并查集(Disjoint Set Union,DSU)也被称为 “联合 - 查找(union-find)” 数据结构,是一种高效的数据结构:每个集合以树的形式表示,树的根节点作为该集合的 “标准代表元”。并查集主要支持两种操作:

  • 查找(Find) :确定某个元素所属集合的标准代表元;
  • 合并(Union) :将两个不相交的集合合并为一个集合。

经典的并查集实现会使用一个简单的数组(我们称之为 parent),其中 parent[i] 存储元素 i 的父节点。若 parent[i] == i,则 i 是其所在树的根节点。以下伪代码展示了一个未做优化的基础实现:

parent = []

def find(x):
return x if parent[x] == x else find(parent[x])

def union(x, y):
root_x = find(x)
root_y = find(y)
if root_x != root_y:
# Link the root of x's tree to the root of y's tree
parent[root_x] = root_y

Rolldown 沿用了这一核心思路,但实现方式更健壮且具备类型安全性。它并未使用原生数组,而是采用 IndexVec—— 一种类向量结构,通过 SymbolId 这类带类型的 ID 进行索引。父指针的作用则由 SymbolRefDataClassic 结构体中的 link 字段来实现,如下图所示。

classDiagram
note for ModuleIdx "Typed Index of Module"
class ModuleIdx {
  TypeAlias for u32
}
class SymbolRefDb {
  +inner: IndexVec< ModuleIdx, Option~SymbolRefDbForModule~>
  link(&mut self, base: SymbolRef, target: SymbolRef)
  find_mut(&mut self, target: SymbolRef) : SymbolRef
}
ModuleIdx -- SymbolRefDb : used_as_index_for
class SymbolRefDbForModule {
  owner_idx: ModuleIdx
  root_scope_id: ScopeId
  +ast_scopes: AstScopes
  +flags: FxHashMap< SymbolId, SymbolRefFlags>
  +classic_data: IndexVec< SymbolId, SymbolRefDataClassic>
  create_facade_root_symbol_ref(&mut self, name: &str) : SymbolRef
  get_classic_data(&self, symbol_id: SymbolId) : &SymbolRefDataClassic
}
SymbolRefDb "1" o-- "0..*" SymbolRefDbForModule : contains
class SymbolRefDataClassic {
  +namespace_alias: Option~NamespaceAlias~
  +link: Option~SymbolRef~
  +chunk_id: Option~ChunkIdx~
}
SymbolRefDbForModule "1" o-- "0..*" SymbolRefDataClassic : contains

如图所示,每个符号的等价性信息都存储在 SymbolRefDataClassic 结构体中。可选的 link 字段指向其父符号 —— 这一点与经典并查集实现中的 parent 数组完全对应。

Rolldown 将并查集的两个核心操作实现为 find_mutlink 方法。

find_mut 方法(带路径压缩的查找操作)

Rolldown 的 find_mut 方法不仅能找到根节点,还会执行一项关键优化:路径压缩(path compression)

pub fn find_mut(&mut self, target: SymbolRef) -> SymbolRef {
  let mut canonical = target;
  while let Some(parent) = self.get_mut(canonical).link {
    // Path compression: Point the current node to its grandparent
    self.get_mut(canonical).link = self.get_mut(parent).link;
    canonical = parent;
  }
  canonical
}

当 while 循环沿着树结构向上遍历至根节点(即 link 字段为 None 的元素)时,会将遍历过程中访问到的每个节点重新关联,使其直接指向自身的祖父节点(self.get_mut(parent).link)。这一操作能高效地 “扁平化” 树结构,大幅提升后续对该路径上任意节点执行查找(find)操作的速度。最终返回的 “标准符号” 即为该集合的根节点代表元。

link 方法(合并操作)

link 方法实现了并查集的合并(union)操作。

/// Make `base` point to `target`
pub fn link(&mut self, base: SymbolRef, target: SymbolRef) {
  let base_root = self.find_mut(base);
  let target_root = self.find_mut(target);
  if base_root == target_root {
    // Already linked
    return;
  }
  self.get_mut(base_root).link = Some(target_root);
}

该方法首先找到基准符号(base)目标符号(target) 各自的根节点代表元。若二者根节点相同,说明这些符号已属于同一集合,无需执行任何操作;反之,则通过将基准符号根节点的 link 字段指向目标符号的根节点,完成两个集合的合并。

绑定导入与导出(Bind imports and exports)

符号解析流程始于 bind_imports_and_exports 函数。初始步骤是遍历所有模块,提取其中的显式命名导出;这些导出信息会被存储在一个哈希映射(hash map)中 —— 键为导出的字符串名称,值则是 ResolvedExport 结构体实例。

pub struct ResolvedExport {
  pub symbol_ref: SymbolRef,
  pub potentially_ambiguous_symbol_refs: Option<Vec<SymbolRef>>,
}

但这一流程会因 ES 模块的星号导出(export * from './dep' 变得复杂 —— 该语法会将另一个模块的所有命名导出重新导出。我们示例中的 helpers.js 就使用了这一语法:export * from './api.js'

星号导出可能引入歧义,必须在最终链接前解决。因此,对于任何包含星号导出的模块,Rolldown 都会调用一个专用函数 add_exports_for_export_star。该函数通过递归深度优先搜索(DFS) 遍历星号导出的依赖图;为检测循环依赖并管理导出优先级,它采用经典的回溯模式维护一个 module_stack(模块栈):递归调用前将模块 ID 压入栈中,递归返回后再将其弹出。

这一递归遍历主要承担两项核心职责:

  • 遮蔽(Shadowing) :模块内的显式命名导出始终拥有最高优先级,会 “遮蔽” 所有通过星号导出从深层依赖导入的同名导出。module_stack 可根据导入链中的 “就近原则” 判定这种优先级关系。
  • 歧义检测:当一个模块试图从多个 “优先级相同” 的不同来源导出同名符号时(例如通过两个不同的星号导出:export * from 'a'export * from 'b'),就会产生歧义。若一个新引入的星号导出符号与已存在的符号同名、且未被遮蔽,则会被记录到 potentially_ambiguous_symbol_refs 字段中,留待后续解析。

在整个过程中,该函数会操作一个由调用方传入的、可变的 resolve_exports 哈希表(FxHashMap 类型),逐步构建出该模块完整的已解析导出集合。

匹配导入与导出(Match imports with exports)

完成所有模块导出的解析后,下一步是将每个导入项匹配到对应的导出项。这一完整流程由封装在 BindImportsAndExportsContext 中的数据和结构体统一管理。

struct BindImportsAndExportsContext<'a> {
  pub index_modules: &'a IndexModules,
  pub metas: &'a mut LinkingMetadataVec,
  pub symbol_db: &'a mut SymbolRefDb,
  pub external_import_binding_merger:
    FxHashMap<ModuleIdx, FxHashMap<CompactStr, IndexSet<SymbolRef>>>,
  // ... fields omitted for brevity
}

这一环节的最终目标是填充 symbol_db(符号数据库)—— 借助并查集(DSU)逻辑,将每个导入符号关联到其真正的定义源头。具体流程为:遍历所有 NormalModule(普通模块),并对模块中的每一个命名导入项(每个导入项由 NamedImport 结构体表示,例如 import { foo } from 'foo' 这类语法)执行匹配函数。

但在关联内部符号之前,外部导入项会先经过一套特殊的预处理流程。当某个导入项来自外部模块(例如 import react from 'react' 中的 react)时,并不会立即解析该导入,而是将其收集起来,并归类到 external_import_binding_merger(外部导入绑定合并器)中。

该数据结构是一个嵌套哈希映射,其设计目的是聚合所有 “引用同一外部模块中同名导出” 的导入项。

classDiagram
class ExternalImportBindingMerger {
  +FxHashMapᐸModuleIdx, ModuleExportsᐳ
}
class ModuleExports {
  +FxHashMapᐸCompactStr, SymbolSetᐳ
}
ExternalImportBindingMerger o-- ModuleExports : uses as value
class ModuleIdx
ExternalImportBindingMerger o-- ModuleIdx : uses as key
class SymbolSet {
  +IndexSetᐸSymbolRefᐳ
}
ModuleExports o-- SymbolSet : uses as value
class CompactStr
ModuleExports o-- CompactStr : uses as key
class SymbolRef
SymbolSet "1" o-- "0..*" SymbolRef : contains

我们以示例项目中的 main.js 文件为例来具体说明:

// ...
// (2) Import from external dependencies, this will be handled by external_import_binding_merger
import { useState } from 'react'

// ...

由于 react 是外部模块,Rolldown 会更新 external_import_binding_merger(外部导入绑定合并器)。假设 react 对应的模块索引(ModuleIdx)为 react_module_idx,最终生成的数据结构如下所示:

graph TD
param1["external_import_binding_merger\n (FxHashMap)"]
param2["FxHashMapᐸCompactStr,\n IndexSetᐸSymbolRefᐳᐳ"]
param3["IndexSetᐸSymbolRefᐳ:\n {sym_useState_main}"]
param1 -->|key: react_module_idx| param2
param2 -->|"key: 'useState' (CompactStr)"| param3

若另有一个文件(例如 featureB.js)也从 react 导入 useState,则其对应的 SymbolRef(符号引用)会被添加到同一个 IndexSet 集合中。这也是该结构被恰如其分地命名为 “合并器(merger)” 的原因:它将指向同一个外部符号(react.useState)的所有本地引用汇总到一处。这种聚合方式支持后续的统一处理,确保所有对 useState 的引用最终都指向唯一、统一的外部符号。

遍历完所有模块及其导入项后,Rolldown 会迭代这个已完全填充的合并器映射表(merger map),完成所有外部符号的绑定操作。

追溯导入项的定义源头

符号解析的核心执行函数是递归函数 match_import_with_export。该函数的使命是:根据 ImportTracker(导入追踪器)描述的单个导入项,一路追溯到其原始定义。

struct ImportTracker {
  pub importer: ModuleIdx,      // The module performing the import.
  pub importee: ModuleIdx,      // The module being imported from.
  pub imported: Specifier,      // The name of the imported symbol (e.g., "useState").
  pub imported_as: SymbolRef,   // The local SymbolRef for the import in the importer module.
}

该函数的返回值 MatchImportKind(导入匹配类型)会封装本次追溯的结果。整个解析流程可拆解为三个阶段:

阶段 1:循环检测与初始状态判定

该函数采用带循环检测的递归深度优先搜索(DFS) 实现。MatchingContext(匹配上下文)会维护一个 “追踪器栈(tracker stack)”,用于检测同一导入方模块是否试图解析 “正在处理中的、同名的 imported_as 符号引用”。若检测到这种情况,则无需继续执行,直接返回 MatchImportKind::Cycle(循环)即可。

接下来,一个辅助函数 advance_import_tracker 会对直接被导入方(direct importee) 执行快速的非递归分析,检查简单场景并返回初始状态:

  • 若被导入方是外部模块,返回 ImportStatus::External(外部模块);
  • 若被导入方是 CommonJS 模块,返回 ImportStatus::CommonJS(CJS 模块);
  • 若该导入是星号导入(import * as ns),判定为 ImportStatus::Found(已找到);
  • 对于 ES 模块的命名导入,会检查直接被导入方的 “已解析导出集合”:若找到匹配的导出项,返回 ImportStatus::Found;否则返回 ImportStatus::NoMatch(无匹配)或 ImportStatus::DynamicFallback(动态降级)。

阶段 2:重新导出链遍历

真正的复杂度在于重新导出链(re-export chain) 的遍历。当返回 ImportStatus::Found 时,函数会进一步检查:找到的这个符号本身是否是从另一个模块导入的:

let owner = &index_modules[symbol.owner];
if let Some(another_named_import) = owner.as_normal().unwrap().named_imports.get(&symbol) {
  // This symbol is re-exported from another module
  // Update tracker and continue the loop to follow the chain
  tracker.importee = importee.idx;
  tracker.importer = owner.idx();
  tracker.imported = another_named_import.imported.clone();
  tracker.imported_as = another_named_import.imported_as;
  reexports.push(another_named_import.imported_as);
  continue;
}

这一过程会以迭代方式持续进行,同时构建用于副作用依赖追踪的重新导出链(reexports chain),直至追溯到符号的原始定义为止。

阶段 3:歧义消解与后置处理

在阶段 2 中,若某个导出项包含 potentially_ambiguous_export_star_refs(由 export * 语句导致的潜在歧义星号导出引用),函数会递归解析每一条歧义路径。收集到所有 ambiguous_results(歧义结果)后,函数会将其与主结果对比:若存在任何不一致,便返回 MatchImportKind::Ambiguous(存在歧义)。

而针对 NoMatch(无匹配)的结果,函数会检查垫片(shimming)功能是否启用(对应配置项 options.shim_missing_exports 或空模块场景)。垫片可为遗留代码提供兼容性降级方案:

let shimmed_symbol_ref = self.metas[tracker.importee]
  .shimmed_missing_exports
  .entry(imported.clone())
  .or_insert_with(|| {
    self.symbol_db.create_facade_root_symbol_ref(tracker.importee, imported.as_str())
  });

完成绑定操作(Finalizing bindings)

在针对所有内部导入项的核心匹配逻辑执行完毕后,Rolldown 会执行两项最终的批量处理步骤。

1. 合并外部导入项(Merging external imports)

如前文所述,所有来自外部模块的导入项会先被收集到 external_import_binding_merger(外部导入绑定合并器)中。现在,Rolldown 会处理这个映射表:对于每个外部模块及其命名导出(例如 react 中的 useState),Rolldown 会创建一个单一的门面符号(facade symbol) ;随后遍历所有导入了 useState 的本地符号集合(来自 featureA.jsfeatureB.js 等文件),并通过并查集(DSU)的 link 操作将这些本地符号全部合并,使其均指向这个唯一的门面符号。这一操作确保了对同一外部实体的所有导入项都被视为一个整体。

2. 处理歧义导出(Addressing ambiguous exports)

星号导出可能导致真正的歧义。请看以下场景:

// moduleA.js
export const foo = 1;

// moduleB.js
export const foo = 2;

// main.js
export * from './moduleA'; // Exports a `foo`
export * from './moduleB'; // Also exports a `foo`

Rolldown 采取保守策略:若某个导出名称对应多个不同的原始定义,该名称会被直接忽略,且不会被纳入模块的公共 API 中。这一设计能避免运行时出现不稳定或不可预测的行为。

但并非所有潜在冲突都会导致真正的歧义。在我们的示例项目中,main.jshelpers.js 导入 source 符号时,虽会沿重新导出链(export * from './api.js')追溯,但由于 source 仅有唯一的原始定义,match_import_with_export 函数能无冲突地完成解析。

链接阶段的输出结果

链接阶段会将扫描阶段生成的 “基础文件级依赖图”,转化为一个信息丰富、可深度解析的结构化数据。最终输出结果被封装在 LinkStageOutput 结构体中:

pub struct LinkStageOutput {
  pub module_table: ModuleTable,
  pub metas: LinkingMetadataVec,
  pub symbol_db: SymbolRefDb,
  // ... fields omitted for clarity
}

该结构体既包含原始的 ModuleTable(模块表),更重要的是,还包含链接阶段生成的全新产物。其中两个核心产物如下:

  1. LinkingMetadataVec:一个按 ModuleIdx(模块索引)索引的向量,存储每个模块对应的 LinkingMetadata(链接元数据)。它包含已解析的模块级信息 —— 例如最终的导出映射表(resolved_exports)、以及图遍历结果(如 is_tla_or_contains_tla_dependency 标记,即 “是否含顶层 await 或依赖含顶层 await 的模块”)。该向量为后续阶段提供了对每个模块属性和关联关系的语义级理解
  2. SymbolRefDb:符号关联关系数据库。它基于并查集(DSU)结构维护所有内部符号的等价类,借助这个数据库,可通过 find_mut 方法将任意导入符号追溯到其唯一的原始定义。

本质上,链接阶段是对模块依赖图的一次高效优化与解析过程。阶段结束时,所有模块和符号均已完全解析,且所有歧义都已消除。这为后续的代码生成、摇树优化(Tree Shaking)和代码分割阶段奠定了稳定、可预测的基础 —— 而这正是这些阶段能够正确且高效执行的关键。

总结

链接阶段是一个复杂的处理流程,它将扫描阶段生成的基础依赖图转化为一份完全解析、无歧义的符号映射表。我们详细梳理了其核心逻辑:如何系统性地遍历依赖图,传播 “顶层 await(TLA)”“副作用” 等属性;如何标准化不同的模块格式以确保互操作性。该阶段的核心支撑是一系列高效的数据结构(如 IndexVecFxHashMap)和强大的算法(深度优先搜索、并查集)。正是这些精心选择的数据结构与算法的组合,构成了 Rolldown 卓越性能的底层基石。

希望本次深度解析能帮助你扎实理解链接阶段的原理,并建立起对其内部工作机制的清晰认知。若你发现任何错误或有改进建议,欢迎在下方留言 —— 你的反馈至关重要!

在下一篇文章中,我们将探索打包流程的最后一个阶段:代码生成。敬请期待!

Transform 提高了渲染性能,但是代价是什么?

作者 ssshooter
2026年2月15日 10:59

一旦父级元素应用了 transform(即使是 transform: translate(0, 0);),它就会变成其子级 position: fixed 元素的包含块(Containing Block)。这意味着该子元素将相对于这个父元素定位,而不是相对于传统的浏览器视口(Viewport)。


为什么会这样?

根据 W3C 的规范,当一个元素的 transform 属性值不为 none 时:

  1. 它会为 position: fixed 的后代创建一个包含块
  2. 它会创建一个新的堆叠上下文(Stacking Context)

这种情况下的表现:

  • 定位参考点变化:普通的 fixed 元素参考的是屏幕。而在 transform 容器内部,top: 0; left: 0; 会对齐到该容器的左上角。
  • 滚动同步:通常 fixed 元素不随页面滚动,但如果它被限制在了一个 transform 容器里,它会随着这个容器一起滚动。

除了 transform,还有哪些属性会“坑”掉 fixed?

不仅仅是 transform,以下属性也会导致子元素的 position: fixed 失效(将其“降级”为类似于 absolute 的表现):

  • filter 不为 none
  • perspective 不为 none
  • backdrop-filter 不为 none
  • will-change 设置为上述属性
  • contain 属性设置为 paintlayout

有什么解决办法吗?

如果你必须在经过变换的容器里实现“真正的”视口定位,通常有以下几种方案:

  1. 移动 DOM 结构(最常用): 将需要 fixed 的元素移出 transform 容器,直接放在 <body> 下。这也是为什么很多 UI 组件库(如 React 的 Ant Design 或 Vue 的 Element Plus)的 Modal/Tooltip 都会使用 Portal (传送门) 技术将弹窗挂载到 body 上的原因。
  2. 避免在祖先元素上使用 transform: 如果只是为了位移,考虑使用 marginleft/top(虽然性能略低);或者检查是否真的需要在那个层级使用动画。
  3. 使用 position: sticky (特定场景): 在某些简单的吸顶场景下,sticky 可能比 fixed 更符合预期,且受 transform 的限制较小(虽然它参考的是最近的滚动父级)。

一句话总结:只要祖先开了 transformfixed 就变成了“局部的 fixed”,不再是“全局的 fixed”。

你不知道的JS(中):程序性能与测试

作者 牛奶
2026年2月15日 10:43

你不知道的JS(中):程序性能与测试

本文是《你不知道的JavaScript(中卷)》的阅读笔记,第四部分:程序性能与测试。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

程序性能

异步对 JavaScript 来说真的很重要,最显而易见的原因就是性能。如果要发出两个 Ajax 请求,并且它们之间是彼此独立的,但是需要等待两个请求都完成才能执行下一步的任务,那么为这个交互建模有两种选择:顺序与并发。 通常后一种模式会比前一种更高效。而更高的性能通常也会带来更好的用户体验。

Web Worker

我们已经详细介绍了 JavaScript 是如何单线程运作的。但是,单线程并不是组织程序执行的唯一方式。 设想一下,把你的程序分为两个部分:一部分运行在主 UI 线程下,另外一部分运行在另一个完全独立的线程中。

你的浏览器这样的环境,很容易提供多个 JavaScript 引擎实例,各自运行在自己的线程上,这样你可以在每个线程上运行不同的程序。程序中每一个这样的独立的多线程部分被称为一个(Web)Worker。这种类型的并行化被称为任务并行,因为其重点在于把程序划分为多个块来并发运行。

从 JavaScript 主程序(或另一个 Worker)中,可以这样实例化一个 Worker:

// 主程序
var w1 = new Worker( "http://some.url.1/mycoolworker.js" );
// 监听事件
w1.addEventListener( "message", function(evt){ 
    // evt.data 
} );
// 发送事件
w1.postMessage( "something cool to say" );

worker内部,收发消息是完全对称的:

// "mycoolworker.js" 
addEventListener( "message", function(evt){ 
    // evt.data 
} ); 
postMessage( "a really cool reply" );

1. Worker环境 在 Worker 内部是无法访问主程序的任何资源的。这意味着你不能访问它的任何全局变量,也不能访问页面的 DOM 或者其他资源。记住,这是一个完全独立的线程。

但你可以执行网络操作Ajax、WebSockets以及设定定时器。还有Worker可以访问几个重要的全局变量和功能的本地复本,包括 navigator、location、JSON 和 applicationCache。

你还可以通过 importScripts(..) 向 Worker 加载额外的 JavaScript 脚本:

// 在Worker内部
importScripts( "foo.js", "bar.js" );

这些脚本加载是同步的。也就是说,importScripts(..) 调用会阻塞余下 Worker 的执行,直到文件加载和执行完成。

Web Worker 通常应用于哪些方面呢?

  • 处理密集型数学计算
  • 大数据集排序
  • 数据处理(压缩、音频分析、图像处理等)
  • 高流量网络通信

2. 数据传递 在线程之间通过事件机制传递大量的信息,可能是双向的。 特别是对于大数据集而言,就是使用 Transferable 对象。这时发生的是对象所有权的转移,数据本身并没有移动。一旦你把对象传递到一个 Worker 中,在原来的位置上,它就变为空的或者是不可访问的,这样就消除了多线程编程作用域共享带来的混乱。当然,所有权传递是可以双向进行的。

// 比如foo是一个Uint8Array 
postMessage( foo.buffer, [ foo.buffer ] );

3. 共享Worker 创建一个整个站点或 app 的所有页面实例都可以共享的中心 Worker 就非常有用了。这称为 SharedWorker,可通过下面的方式创建(只有 Firefox 和 Chrome 支持这一功能):

var w1 = new SharedWorker( "http://some.url.1/mycoolworker.js" );

在共享 Worker 内部,必须要处理额外的一个事件:"connect"。这个事件为这个特定的连接提供了端口对象。保持多个连接独立的最简单办法就是使用 port 上的闭包:

// 在共享Worker内部
addEventListener( "connect", function(evt){ 
    // 这个连接分配的端口
    var port = evt.ports[0]; 
    port.addEventListener( "message", function(evt){ 
        // .. 
        port.postMessage( .. ); 
        // .. 
    } ); 
    // 初始化端口连接
    port.start(); 
} );

SIMD

单指令多数据(SIMD)是一种数据并行(data parallelism)方式,与 Web Worker 的任务并行(task parallelism)相对,因为这里的重点实际上不再是把程序逻辑分成并行的块,而是并行处理数据的多个位。

asm.js

asm.js这个标签是指 JavaScript 语言中可以高度优化的一个子集。通过小心避免某些难以优化的机制和模式(垃圾收集、类型强制转换,等等),asm.js 风格的代码可以被 JavaScript 引擎识别并进行特别激进的底层优化。

1. 如何使用

var a = 42;
var b = a | 0;

此处我们使用了与 0 的 |(二进制或)运算,除了确保这个值是 32 位整型之外,对于值没有任何效果。这样的代码在一般的 JavaScript 引擎上都可以正常工作。 而对支持 asm.js 的JavaScript 引擎来说,这段代码就发出这样的信号,b 应该总是被当作 32位整型来处理,这样就可以省略强制类型转换追踪。

2. asm.js 模块 对一个 asm.js 模块来说,你需要明确地导入一个严格规范的命名空间——规范将之称为stdlib,因为它应该代表所需的标准库。 你还需要声明一个堆(heap)并将其传入。这个术语用于表示内存中一块保留的位置,变量可以直接使用而不需要额外的内存请求或释放之前使用的内存。这样,asm.js 模块就不需要任何可能导致内存扰动的动作了,只需使用预先保留的空间即可。

var heap = new ArrayBuffer( 0x10000 ); // 64k堆

var arr = new Float64Array( heap );

asm.js 代码如此高度可优化的那些限制的特性显著降低了这类代码的使用范围。asm.js 并不是对任意程序都适用的通用优化手段。它的目标是对特定的任务处理提供一种优化方法,比如数学运算(如游戏中的图形处理)。

程序性能小结

异步编码模式使我们能够编写更高效的代码,通常能够带来非常大的改进。但是,异步特性只能让你走这么远,因为它本质上还是绑定在一个单事件循环线程上。 因此,在这一章里,我们介绍了几种能够进一步提高性能的程序级别的机制。

性能测试与调优

性能测试

如果被问到如何测试某个运算的速度(执行时间),绝大多数 JavaScript 开发者都会从类似下面的代码开始:

var start = (new Date()).getTime(); // 或者Date.now() 
// 进行一些操作
var end = (new Date()).getTime(); 
console.log( "Duration:", (end - start) );

这样低可信度的测试几乎无力支持你的任何决策。这个性能测试基本上是无用的。更坏的是它是危险的,因为它可能提供了错误的可信度。

1. 重复 你可以不以固定次数执行运算,转而循环运行测试,直到达到某个固定的时间。这可能会更可靠一些。

2. Benchmark.js 一个统计学上有效的性能测试工具,名为 Benchmark.js,我们使用这个工具就好了。

环境为王

对特定的性能测试来说,不要忘了检查测试环境,特别是比较任务 X 和 Y 这样的比对测试。仅仅因为你的测试显示 X 比 Y 快,并不能说明结论 X 比 Y 快就有实际的意义。

引擎优化 现代引擎要比我们凭直觉进行的推导复杂得多。它们会实现各种技巧,比如跟踪记录代码在一小段时期内或针对特别有限的输入集的行为。

jsPerf.com

如果想要在不止一个环境下得出像“X 比 Y 快”这样的有意义的结论成立,那你需要在尽可能多的真实环境下进行实际测试。仅仅因为在 Chrome 上某个 X 运算比 Y 快并不意味着这在所有的浏览器中都成立。当然你可能还想要交叉引用多个浏览器上的测试运行结果,并有用户的图形展示。 有一个很棒的网站正是因这样的需求而诞生的,名为 jsPerf (jsperf.com)。它使用我们前面介绍的 Benchmark.js 库来运行统计上精确可靠的测试,并把测试结果放在一个公开可得的 URL 上,你可以把这个 URL 转发给别人。

写好测试

编写更好更清晰的测试。

微性能

var x = [ .. ]; 
// 选择1 
for (var i=0; i < x.length; i++) { 
    // .. 
} 
// 选择2 
for (var i=0, len = x.length; i < len; i++) { 
    // .. 
}

理论上说,这里应该在变量 len 中缓存 x 数组的长度,因为表面上它不会改变,来避免在每个循环迭代中计算 x.length 的代价。

如下是 v8 的一些经常提到的例子:

  • 不要从一个函数到另外一个函数传递 arguments 变量,因为这样的泄漏会降低函数实现速度.
  • 把 try..catch 分离到单独的函数里。浏览器对任何有 try..catch 的函数实行优化都有一些困难,所以把这部分移到独立的函数中意味着你控制了反优化的害处,并让其包含的代码可以优化。

尾调用优化

ES6 包含了一个性能领域的特殊要求。这与一个涉及函数调用的特定优化形式相关:尾调用优化(Tail Call Optimization,TCO)。

function foo(x) { 
    return x; 
} 
function bar(y) { 
    return foo( y + 1 ); // 尾调用
} 
function bar(y) { 
    return foo( y + 1 ); // 尾调用
} 
function baz() { 
    return 1 + bar( 40 ); // 非尾调用
} 
baz(); // 42

调用一个新的函数需要额外的一块预留内存来管理调用栈,称为栈帧。所以前面的代码一般会同时需要为每个 baz()、bar(..) 和 foo(..) 保留一个栈帧。 然而,如果支持 TCO 的引擎能够意识到 foo(y+1) 调用位于尾部,这意味着 bar(..) 基本上已经完成了,那么在调用 foo(..) 时,它就不需要创建一个新的栈帧,而是可以重用已有的 bar(..) 的栈帧。这样不仅速度更快,也更节省内存。

性能测试与调优小结

尾调用优化是 ES6 要求的一种优化方法。它使 JavaScript 中原本不可能的一些递归模式变得实际。TCO 允许一个函数在结尾处调用另外一个函数来执行,不需要任何额外资源。这意味着,对递归算法来说,引擎不再需要限制栈深度。

原文地址

墨渊书肆/你不知道的JS(中):程序性能与测试

你不知道的JS(中):Promise与生成器

作者 牛奶
2026年2月15日 10:41

你不知道的JS(中):Promise与生成器

本文是《你不知道的JavaScript(中卷)》的阅读笔记,第三部分:Promise与生成器。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

Promise

什么是Promise

未来值 在具体解释 Promise 的 工作方式之前,先来推导通过我们已经理解的方式——回调——如何处理未来值。为了统一处理现在和将来,我们把它们都变成了将来,即所有的操作都成了异步的。

Promise值

function add(xPromise,yPromise) { 
    // Promise.all([ .. ])接受一个promise数组并返回一个新的promise,
    // 这个新promise等待数组中的所有promise完成
    return Promise.all( [xPromise, yPromise] ) 
    // 这个promise决议之后,我们取得收到的X和Y值并加在一起
    .then( function(values){ 
        // values是来自于之前决议的promisei的消息数组
        return values[0] + values[1]; 
    } ); 
} 
// fetchX()和fetchY()返回相应值的promise,可能已经就绪,
// 也可能以后就绪 
add( fetchX(), fetchY() ) 
// 我们得到一个这两个数组的和的promise
// 现在链式调用 then(..)来等待返回promise的决议
.then( function(sum){ 
    console.log( sum ); // 这更简单!
} );

完成事件 在典型的 JavaScript 风格中,如果需要侦听某个通知,你可能就会想到事件。因此,可以把对通知的需求重新组织为对 foo 发出的一个完成事件(completion event,或continuation 事件)的侦听。

function foo(x) { 
    // 开始做点可能耗时的工作
    // 构造一个listener事件通知处理对象来返回
    return listener; 
} 
var evt = foo( 42 ); 
evt.on( "completion", function(){ 
    // 可以进行下一步了!
} ); 
evt.on( "failure", function(err){ 
    // 啊,foo(..)中出错了
} );

promise中监听回调事件:

function foo(x) { 
    // 可是做一些可能耗时的工作
    // 构造并返回一个promise
    return new Promise( function(resolve,reject){ 
        // 最终调用resolve(..)或者reject(..)
        // 这是这个promise的决议回调
    } ); 
} 
var p = foo( 42 ); 
bar( p ); 
baz( p );

具有then方法的鸭子类型

识别 Promise(或者行为类似于 Promise 的东西)就是定义某种称为 thenable 的东西,将其定义为任何具有 then 方法的对象 and 函数。我们认为,任何这样的值就是Promise 一致的 thenable。thenable值的鸭子类型检测就大致类似于:

if ( 
 p !== null && 
 ( 
 typeof p === "object" || 
 typeof p === "function" 
 ) && 
 typeof p.then === "function" 
) { 
 // 假定这是一个thenable! 
} 
else { 
 // 不是thenable 
}

Promise信任问题

先回顾一下只用回调编码的信任问题。把一个回调传入工具 foo(..) 时可能出现如下问题:

  • 调用回调过早;
  • 调用回调过晚(或不被调用);
  • 调用回调次数过少或过多;
  • 未能传递所需的环境和参数;
  • 吞掉可能出现的错误和异常;

1. 调用过早 Promise 就不必担心这种问题,因为即使是立即完成的 Promise(类似于 new Promise(function(resolve){ resolve(42); }))也无法被同步观察到。

2. 调用过晚 Promise 创建对象调用 resolve 或 reject 时,这个 Promise 的then 注册的观察回调就会被自动调度。可以确信,这些被调度的回调在下一个异步事件点上一定会被触发。

3. 回调未调用 如果你对一个 Promise 注册了一个完成回调和一个拒绝回调,那么 Promise在决议时总是会调用其中的一个。 但是,如果 Promise 本身永远不被决议呢?即使这样,Promise 也提供了解决方案,其使用了一种称为竞态的高级抽象机制:

// 用于超时一个Promise的工具
function timeoutPromise(delay) { 
    return new Promise( function(resolve,reject){ 
        setTimeout( function(){ 
            reject( "Timeout!" ); 
        }, delay ); 
    } ); 
} 
// 设置foo()超时
Promise.race( [ 
    foo(), // 试着开始foo() 
    timeoutPromise( 3000 ) // 给它3秒钟
] ) 
.then( 
     function(){ 
         // foo(..)及时完成!
     },
    function(err){ 
        // 或者foo()被拒绝,或者只是没能按时完成
        // 查看err来了解是哪种情况
    } 
);

4. 调用次数过少或过多 如果你把同一个回调注册了不止一次(比如 p.then(f); p.then(f);),那它被调用的次数就会和注册次数相同。响应函数只会被调用一次。

5. 未能传递参数/环境值 Promise 至多只能有一个决议值(完成或拒绝)。 如果你没有用任何值显式决议,那么这个值就是 undefined,这是 JavaScript 常见的处理方式。但不管这个值是什么,无论当前或未来,它都会被传给所有注册的(且适当的完成或拒绝)回调。

6. 吞掉错误或异常 如果在 Promise 的创建过程中或在查看其决议结果过程中的任何时间点上出现了一个 JavaScript 异常错误,比如一个 TypeError 或ReferenceError,那这个异常就会被捕捉,并且会使这个 Promise 被拒绝。

var p = new Promise( function(resolve,reject){ 
    foo.bar(); // foo未定义,所以会出错!
    resolve( 42 ); // 永远不会到达这里
} ); 
p.then( 
    function fulfilled(){ 
        // 永远不会到达这里 :( 
    }, 
    function rejected(err){ 
        // err将会是一个TypeError异常对象来自foo.bar()这一行
    } 
);

链式流

这种方式可以实现的关键在于以下两个 Promise 固有行为特性:

  • 每次你对 Promise 调用 then,它都会创建并返回一个新的 Promise,我们可以将其链接起来;
  • 不管从 then 调用的完成回调(第一个参数)返回的值是什么,它都会被自动设置为被链接 Promise的完成。
var p = Promise.resolve( 21 ); 
var p2 = p.then( function(v){ 
    console.log( v ); // 21 
    // 用值42填充p2
    return v * 2; 
} ); 
// 连接p2 
p2.then( function(v){ 
    console.log( v ); // 42 
} );

术语:决议、完成以及拒绝 对于术语决议(resolve)、完成(fulfill)和拒绝(reject),在更深入学习 Promise 之前,我们还有一些模糊之处需要澄清。先来研究一下构造器 Promise(..):

var p = new Promise( function(X,Y){ 
    // X()用于完成
    // Y()用于拒绝
} );

错误处理

错误处理最自然的形式就是同步的 try..catch 结构。遗憾的是,它只能是同步的,无法用于异步代码模式:

function foo() { 
    setTimeout( function(){ 
        baz.bar(); 
    }, 100 ); 
} 
try {
    foo(); 
    // 后面从 `baz.bar()` 抛出全局错误
} catch (err) { 
    // 永远不会到达这里
}

Promise 使用了分离回调风格。一个回调用于完成情况,一个回调用于拒绝情况:

var p = Promise.reject( "Oops" ); 
p.then( 
    function fulfilled(){ 
        // 永远不会到达这里
    }, 
    function rejected(err){ 
        console.log( err ); // "Oops" 
    } 
);

处理未捕获的情况 浏览器有一个特有的功能是我们的代码所没有的:它们可以跟踪并了解所有对象被丢弃以及被垃圾回收的时机。所以,浏览器可以追踪 Promise 对象。如果在它被垃圾回收的时候其中有拒绝,浏览器就能够确保这是一个真正的未捕获错误,进而可以确定应该将其报告到开发者终端。

Promise模式

1. Promise.all Promise.all 需要一个参数,是一个数组,通常由 Promise 实例组成。从 Promise.all([ .. ]) 调用返回的 promise 会收到一个完成消息。这是一个由所有传入 promise 的完成消息组成的数组,与指定的顺序一致(与完成顺序无关)。

// request(..)是一个Promise-aware Ajax工具
// 就像我们在本章前面定义的一样
var p1 = request( "http://some.url.1/" ); 
var p2 = request( "http://some.url.2/" ); 
Promise.all( [p1,p2] ) 
.then( function(msgs){ 
    // 这里,p1和p2完成并把它们的消息传入
    return request("http://some.url.3/?v=" + msgs.join(",")); 
}) 
.then( function(msg){ 
    console.log( msg ); 
});

2. Promise.race Promise.race也接受单个数组参数。这个数组由一个或多个 Promise、thenable 或立即值组成。一旦有任何一个 Promise 决议为完成,Promise.race就会完成;一旦有任何一个 Promise 决议为拒绝,它就会拒绝。

// request(..)是一个Promise-aware Ajax工具
// 就像我们在本章前面定义的一样
var p1 = request( "http://some.url.1/" ); 
var p2 = request( "http://some.url.2/" ); 
Promise.race( [p1,p2] ) 
.then( function(msg){ 
    // p1或者p2将赢得这场竞赛
    return request("http://some.url.3/?v=" + msg); 
}) 
.then( function(msg){ 
    console.log( msg ); 
});

all和race的变体

  • none([ .. ]) 这个模式类似于 all([ .. ]),不过完成和拒绝的情况互换了。所有的 Promise 都要被 拒绝,即拒绝转化为完成值,反之亦然。
  • any([ .. ]) 这个模式与 all([ .. ]) 类似,但是会忽略拒绝,所以只需要完成一个而不是全部。
  • first([ .. ]) 这个模式类似于与 any([ .. ]) 的竞争,即只要第一个 Promise 完成,它就会忽略后续的任何拒绝和完成。
  • last([ .. ]) 这个模式类似于 first([ .. ]),但却是只有最后一个完成胜出。

Promise API概述

new Promise构造器 有启示性的构造器 Promise(..) 必须和 new 一起使用,并且必须提供一个函数回调。这个回调是同步的或立即调用的。这个函数接受两个函数回调,用以支持 promise 的决议。通常我们把这两个函数称为 resolve(..) 和 reject(..):

var p = new Promise( function(resolve,reject){ 
    // resolve(..)用于决议/完成这个promise
    // reject(..)用于拒绝这个promise
} );

Promise.resolve和 Promise.reject 创建一个已被拒绝的 Promise 的快捷方式是使用 Promise.reject(..),所以以下两个promise 是等价的:

var p1 = new Promise( function(resolve,reject){ 
    reject( "Oops" ); 
} ); 
var p2 = Promise.reject( "Oops" );

then和catch then接受一个或两个参数:第一个用于完成回调,第二个用于拒绝回调。如果两者中的任何一个被省略或者作为非函数值传入的话,就会替换为相应的默认回调。默认完成回调只是把消息传递下去,而默认拒绝回调则只是重新抛出其接收到的出错原因。 catch只接受一个拒绝回调作为参数,并自动替换默认完成回调。 then 和 catch 也会创建并返回一个新的 promise,这个 promise 可以用于实现Promise 链式流程控制。

Promise局限性

顺序错误处理 很多时候并没有为 Promise 链序列的中间步骤保留的引用。因此,没有这样的引用,你就无法关联错误处理函数来可靠地检查错误。

单一值 根据定义,Promise 只能有一个完成值或一个拒绝理由。在简单的例子中,这不是什么问题,但是在更复杂的场景中,你可能就会发现这是一种局限了。

  1. 分裂值: 这种方法更符合 Promise 的设计理念。如果以后需要重构代码把对 x 和 y 的计算分开,这种方法就简单得多。由调用代码来决定如何安排这两个 promise,而不是把这种细节放在 foo(..) 内部抽象,这样更整洁也更灵活。
function foo(bar,baz) { 
    var x = bar * baz; 
    // 返回两个promise
    return [ 
        Promise.resolve( x ), 
        getY( x ) 
    ]; 
} 
Promise.all( foo( 10, 20 ) ) 
.then( function(msgs){ 
    var x = msgs[0]; 
    var y = msgs[1]; 
    console.log( x, y ); 
} );
  1. 展开/传递参数:

ES6 提供了数组参数解构形式

Promise.all( foo( 10, 20 ) ) 
.then( function([x,y]){ 
    console.log( x, y ); // 200 599 
} );

单决议 Promise 最本质的一个特征是:Promise 只能被决议一次(完成或拒绝)。在许多异步情况中,你只会获取一个值一次,所以这可以工作良好。

无法取消的Promise 一旦创建了一个 Promise 并为其注册了完成或拒绝处理函数,如果出现某种情况使得这个任务悬而未决的话,你也没有办法从外部停止它的进程。

Promise的性能 Promise 使所有一切都成为异步的了,即有一些立即(同步)完成的步骤仍然会延迟到任务的下一步。这意味着一个 Promise 任务序列可能比完全通过回调连接的同样的任务序列运行得稍慢一点。

Promise小结

Promise 非常好,请使用。它们解决了我们因只用回调的代码而备受困扰的控制反转问题。 Promise 链也开始 provide 以顺序的方式表达异步流的一个更好的方法,这有助于我们的大脑更好地计划和维护异步 JavaScript 代码。

生成器

JS 开发者在代码中几乎普遍依赖的一个假定:一个函数一旦开始执行,就会运行到结束,期间不会有其他代码能够打断它并插入其间。不过 ES6 引入了一个新的函数类型,它并不符合这种运行到结束的特性。这类新的函数被称为生成器。

打破完整运行

如果foo自身可以通过某种形式在代码的这个位置指示暂停的话,那就仍然可以以一种合作式的方式实现这样的中断(并发)。

var x = 1; 
function *foo() { 
    x++; 
    yield; // 暂停!
    console.log( "x:", x ); 
} 
function bar() { 
    x++; 
} 

// 构造一个迭代器it来控制这个生成器
var it = foo(); 

// 这里启动foo()!
it.next(); 
x; // 2 
bar(); 
x; // 3 
it.next(); // x: 3

解释 ES6 生成器的不同机制和语法之前,我们先来看看运行过程。

  1. it = foo() 运算并没有执行生成器 *foo(),而只是构造了一个迭代器(iterator),这个迭代器会控制它的执行。后面会介绍迭代器。
  2. 第一个 it.next() 启动了生成器 *foo(),并运行了 *foo() 第一行的 x++。
  3. *foo() 在 yield 语句处暂停,在这一点上第一个 it.next() 调用结束。此时 *foo() 仍在运行并且是活跃的,但处于暂停状态。
  4. 我们查看 x 的值,此时为 2。
  5. 我们调用 bar(),它通过 x++ 再次递增 x。
  6. 我们再次查看 x 的值,此时为 3。
  7. 最后的 it.next() 调用从暂停处恢复了生成器 *foo() 的执行,并运行 console.log(..)语句,这条语句使用当前 x 的值 3。

显然,foo() 启动了,但是没有完整运行,它在 yield 处暂停了。后面恢复了 foo() 并让它运行到结束,但这不是必需的。

输入和输出 生成器函数是一个特殊的函数,具有前面我们展示的新的执行模式。但是,它仍然是一个函数,这意味着它仍然有一些基本的特性没有改变。比如,它仍然可以接受参数(即输入),也能够返回值(即输出)。

function *foo(x,y) { 
    return x * y; 
} 
var it = foo( 6, 7 );

var res = it.next();
res.value; // 42

多个迭代器 同一个生成器的多个实例可以同时运行,它们甚至可以彼此交互:

function *foo() { 
    var x = yield 2; 
    z++; 
    var y = yield (x * z); 
    console.log( x, y, z ); 
} 
var z = 1; 
var it1 = foo(); 
var it2 = foo(); 
var val1 = it1.next().value; // 2 <-- yield 2 
var val2 = it2.next().value; // 2 <-- yield 2 
val1 = it1.next( val2 * 10 ).value; // 40 <-- x:20, z:2 
val2 = it2.next( val1 * 5 ).value; // 600 <-- x:200, z:3 
it1.next( val2 / 2 ); // y:300 
 // 20 300 3 
it2.next( val1 / 4 ); // y:10 
 // 200 10 3

我们简单梳理一下执行流程。

  1. *foo() 的两个实例同时启动,两个 next() 分别从 yield 2 语句得到值 2。
  2. val2 * 10 也就是 2 * 10,发送到第一个生成器实例 it1,因此 x 得到值 20. z 从 1 增加到 2,然后 20 * 2 通过 yield 发出,将 val1 设置为 40。
  3. val1 * 5 也就是 40 * 5,发送到第二个生成器实例 it2,因此 x 得到值 200. z 再次从 2递增到 3,然后 200 * 3 通过 yield 发出,将 val2 设置为 600。
  4. val2 / 2 也就是 600 / 2,发送到第一个生成器实例 it1,因此 y 得到值 300,然后打印出 x y z 的值分别是 20 300 3。
  5. val1 / 4 也就是 40 / 4,发送到第二个生成器实例 it2,因此 y 得到值 10,然后打印出x y z 的值分别为 200 10 3。

生成器产生值

我们提到生成器的一种有趣用法是作为一种产生值的方式。

生产者与迭代器 假定你要产生一系列值,其中每个值都与前面一个有特定的关系。要实现这一点,需要一个有状态的生产者能够记住其生成的最后一个值。

var gimmeSomething = (function(){ 
    var nextVal; 
    return function(){ 
        if (nextVal === undefined) { 
            nextVal = 1; 
        } 
        else { 
            nextVal = (3 * nextVal) +6; 
        } 
        return nextVal; 
    }; 
})(); 
gimmeSomething(); // 1 
gimmeSomething(); // 9 
gimmeSomething(); // 33 
gimmeSomething(); // 105

实际上,这个任务是一个非常通用的设计模式,通常通过迭代器来解决。迭代器是一个定义良好的接口,用于从一个生产者一步步得到一系列值。JavaScript 迭代器的接口,与多数语言类似,就是每次想要从生产者得到下一个值的时候调用 next()。

var something = (function(){ 
    var nextVal; 
    return { 
        // for..of循环需要
        [Symbol.iterator]: function(){ return this; }, 
        // 标准迭代器接口方法
        next: function(){ 
            if (nextVal === undefined) { 
                nextVal = 1; 
            } 
            else { 
                nextVal = (3 * nextVal) + 6; 
            } 
            return { done:false, value:nextVal }; 
        } 
    }; 
})(); 
something.next().value; // 1 
something.next().value; // 9 
something.next().value; // 33
something.next().value; // 105

ES6 还新增了一个 for..of 循环,这意味着可以通过原生循环语法自动迭代标准迭代器:

for (var v of something) { 
    console.log( v ); 
    // 不要死循环!
    if (v > 500) { 
        break; 
    } 
} 
// 1 9 33 105 321 969

iterable 可迭代 下面代码片段中的 a 就是一个 iterable。for..of 循环自动调用它的 Symbol.iterator 函数来构建一个迭代器。我们当然也可以手工调用这个函数,然后使用它返回的迭代器:

var a = [1,3,5,7,9]; 
var it = a[Symbol.iterator](); 
it.next().value; // 1 
it.next().value; // 3 
it.next().value; // 5

生成器迭代器 严格说来,生成器本身并不是 iterable,尽管非常类似——当你执行一个生成器,就得到了一个迭代器:

function *something() { 
    var nextVal; 
    while (true) { 
        if (nextVal === undefined) { 
            nextVal = 1; 
        } 
        else { 
            nextVal = (3 * nextVal) + 6; 
        } 
        yield nextVal; 
    } 
}

停止生成器 for..of 循环的“异常结束”(也就是“提前终止”),通常由 break、return 或者未捕获异常引起,会向生成器的迭代器发送一个信号使其终止。

var it = something(); 
for (var v of it) { 
    console.log( v ); 
    // 不要死循环!
    if (v > 500) { 
        console.log( 
            // 完成生成器的迭代器
            it.return( "Hello World" ).value 
        ); 
        // 这里不需要break 
    } 
} 
// 1 9 33 105 321 969 
// 清理!
// Hello World

异步迭代生成器

同步错误处理 我们可以把错误抛入生成器中:

function *main() { 
    var x = yield "Hello World"; 
    yield x.toLowerCase(); // 引发一个异常!
} 
var it = main(); 
it.next().value; // Hello World 
try { 
    it.next( 42 ); 
} 
catch (err) { 
    console.error( err ); // TypeError 
}

生成器 + Promise

首先,把支持 Promise 的 foo(..) 和生成器 *main() 放在一起:

function foo(x,y) { 
    return request( 
        "http://some.url.1/?x=" + x + "&y=" + y 
    ); 
} 
function *main() { 
    try { 
        var text = yield foo( 11, 31 ); 
        console.log( text ); 
    } catch (err) { 
        console.error( err ); 
    } 
}

var it = main(); 
var p = it.next().value; 
// 等待promise p决议
p.then( 
    function(text){ 
        it.next( text ); 
    }, 
    function(err){ 
        it.throw( err ); 
    } 
);

ES7: async与await

function foo(x,y) { 
    return request( 
        "http://some.url.1/?x=" + x + "&y=" + y 
    ); 
} 
async function main() { 
    try { 
        var text = await foo( 11, 31 ); 
        console.log( text ); 
    } catch (err) { 
        console.error( err ); 
    } 
} 
main();

生成器委托

yield * 暂停了迭代控制,而不是生成器控制。当你调用 *foo() 生成器时,现在 yield 委托到了它的迭代器。但实际上,你可以 yield 委托到任意iterable,yield *[1,2,3] 会消耗数组值 [1,2,3] 的默认迭代器。

function *foo() { 
    var r2 = yield request( "http://some.url.2" ); 
    var r3 = yield request( "http://some.url.3/?v=" + r2 ); 
    return r3; 
} 
function *bar() { 
    var r1 = yield request( "http://some.url.1" );
    // 通过 yeild* "委托"给*foo()
    var r3 = yield *foo(); 
    console.log( r3 ); 
} 
run( bar );

为什么用委托 yield 委托的主要目的是代码组织,以达到与普通函数调用的对称。

生成器并发

两个同时运行的进程可以合作式地交替运作,而很多时候这可以产生非常强大的异步表示。 回想一下之前给出的一个场景:其中两个不同并发 Ajax 响应处理函数需要彼此协调,以确保数据交流不会出现竞态条件。我们把响应插入到 res 数组中,就像这样:

function response(data) { 
    if (data.url == "http://some.url.1") { 
        res[0] = data; 
    } 
    else if (data.url == "http://some.url.2") { 
        res[1] = data; 
    } 
}

但是这种场景下如何使用多个并发生成器呢?

// request(..)是一个支持Promise of Ajax工具
var res = []; 
function *reqData(url) { 
    res.push( 
        yield request( url ) 
    ); 
}

形实转换程序

你用一个函数定义封装函数调用,包括需要的任何参数,来定义这个调用的执行,那么这个封装函数就是一个形实转换程序。之后在执行这个 thunk 时,最终就是调用了原始的函数。

function foo(x,y,cb) { 
    setTimeout( function(){ 
        cb( x + y ); 
    }, 1000 ); 
} 
function fooThunk(cb) { 
    foo( 3, 4, cb ); 
} 
// 将来
fooThunk( function(sum){ 
    console.log( sum ); // 7 
} );

ES6之前的生成器

function foo(url) { 
    // .. 
    // 构造并返回一个迭代器
    return { 
        next: function(v) { 
        // .. 
        }, 
        throw: function(e) { 
            // .. 
        } 
    }; 
}

var it = foo( "http://some.url.1" );

生成器小结

生成器为异步代码保持了顺序、同步、阻塞的代码模式,这使得大脑可以更自然地追踪代码,解决了基于回调的异步的两个关键缺陷之一。

原文地址

墨渊书肆/你不知道的JS(中):Promise与生成器

你不知道的JS(中):强制类型转换与异步基础

作者 牛奶
2026年2月15日 10:39

你不知道的JS(中):强制类型转换与异步基础

本文是《你不知道的JavaScript(中卷)》的阅读笔记,第二部分:强制类型转换与异步基础。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

强制类型转换

值类型转换

将值从一种类型转换为另一种类型通常称为类型转换(type casting),这是显式的情况;隐式的情况称为强制类型转换(coercion)。

var a = 42;
var b = a + ""; // 隐式强制类型转换
var c = String( a ); // 显式强制类型转换

抽象值操作

ToString 基本类型值的字符串化规则为:null转换为"null",undefined转换为"undefined",true转换为 "true"。数字的字符串化则遵循通用规则,不过那些极小和极大的数字使用指数形式:

// 1.07 连续乘以七个 1000
var a = 1.07 * 1000 * 1000 * 1000 * 1000 * 1000 * 1000 * 1000;
// 七个1000一共21位数字
a.toString(); // "1.07e21"

JSON字符串化 工具函数 JSON.stringify 在将 JSON 对象序列化为字符串时也用到了 ToString。但JSON.stringify(..) 在对象中遇到 undefined、function 和 symbol 时会自动将其忽略,在数组中则会返回 null(以保证单元位置不变)。

JSON.stringify(undefined); // undefined
JSON.stringify(function(){}); // undefined
JSON.stringify([1,undefined,function(){},4]); // "[1,null,null,4]"
JSON.stringify({ a:2, b:function(){} }); // "{"a":2}"

ToNumber 其中 true 转换为 1,false 转换为 0。undefined 转换为 NaN,null 转换为 0。

ToBoolean JS中的值可以分为俩类:

  1. 可以被强制类型转换为false的值
  2. 其他

以下是假值,假值的布尔强制类型转换结果为false:

  • undefined
  • null
  • false
  • +0、-0和NaN
  • ""

假值对象是真值

var a = new Boolean( false );
var b = new Number( 0 );
var c = new String( "" );

Boolean( a && b && c ); // true

真值:假值列表之外的就是真值

var a = "false";
var b = "0";
var c = "''";
Boolean( a && b && c ); // true

var a = []; // 空数组——是真值还是假值?
var b = {}; // 空对象——是真值还是假值?
var c = function(){}; // 空函数——是真值还是假值?
Boolean( a && b && c ); // true

显式强制类型转换

日期显式转换为数字

var d = new Date( "Mon, 18 Aug 2014 08:53:06 CDT" );
+d; // 1408369986000

奇特的~运算符 ~,它首先将值强制类型转换为 32 位数字,然后执行字位操作“非”(对每一个字位进行反转)。 ~ 返回 2 的补码

~42; // -(42+1) ==> -43

~ 的神奇之处在于进行检查字符串中是否有包含指定的字符串:

var a = "Hello World";
~a.indexOf( "lo" ); // -4 <-- 真值!
if (~a.indexOf( "lo" )) { // true
 // 找到匹配!
}
~a.indexOf( "ol" ); // 0 <-- 假值!
!~a.indexOf( "ol" ); // true
if (!~a.indexOf( "ol" )) { // true
 // 没有找到匹配!
}

显式解析数字字符串 解析字符串中的数字和将字符串强制类型转换为数字的返回结果都是数字。但解析和转换两者之间还是有明显的差别。

var a = "42";
var b = "42px";
Number( a ); // 42
parseInt( a ); // 42
Number( b ); // NaN
parseInt( b ); // 42

解析允许字符串中含有非数字字符,解析按从左到右的顺序,如果遇到非数字字符就停止。而转换不允许出现非数字字符,否则会失败bing返回 NaN。

解析非字符串

parseInt( 1/0, 19 ); // 18

很多人想当然地以为“如果第一个参数值为 Infinity,解析结果也应该是 Infinity”,返回 18 也太无厘头了。实际的 JavaScript 代码中不会用到基数 19,它的有效数字字符范围是 0-9 和 a-i(区分大小写)。parseInt(1/0, 19) 实际上是 parseInt("Infinity", 19)。第一个字符是 "I",以 19 为基数时值为 18。第二个字符 "n" 不是一个有效的数字字符,解析到此为止。 此外还有一些看起来奇怪但实际上解释得通的例子:

parseInt( 0.000008 ); // 0 ("0" 来自于 "0.000008")
parseInt( 0.0000008 ); // 8 ("8" 来自于 "8e-7")
parseInt( false, 16 ); // 250 ("fa" 来自于 "false")
parseInt( parseInt, 16 ); // 15 ("f" 来自于 "function..")
parseInt( "0x10" ); // 16
parseInt( "103", 2 ); // 2

显式转换为布尔值 显式强制类型转换为布尔值最常用的方法是!!。

隐式强制类型转换

字符串和数字之间的隐式强制类型转换 通过+运算符进行字符串拼接

var a = "42";
var b = "0";
var c = 42;
var d = 0;
a + b; // "420"
c + d; // 42

因为数组的valueOf() 操作无法得到简单基本类型值,于是它转而调用 toString()。因此下面例子中的两个数组变成了 "1,2" 和 "3,4"。+ 将它们拼接后返回 "1,23,4"。

var a = [1,2];
var b = [3,4];
a + b; // "1,23,4"

a + ""(隐式)和 String(a)(显式)之间有一个细微的差别需要注意。根据ToPrimitive 抽象操作规则,a + "" 会对 a 调用 valueOf() 方法,然后通过 ToString 抽象操作将返回值转换为字符串。而 String(a) 则是直接调用 ToString()。

var a = {
    valueOf: function() { return 42; },
    toString: function() { return 4; }
};
a + ""; // "42"
String( a ); // "4"

再来看看从字符串强制类型转换为数字的情况。- 是数字减法运算符,因此 a - 0 会将 a 强制类型转换为数字。

var a = "3.14";
var b = a - 0;
b; // 3.14

隐式强制类型转换为布尔值 相对布尔值,数字和字符串操作中的隐式强制类型转换还算比较明显。下面的情况会发生布尔值隐式强制类型转换。 (1) if (..) 语句中的条件判断表达式。 (2) for ( .. ; .. ; .. ) 语句中的条件判断表达式(第二个)。 (3) while (..) 和 do..while(..) 循环中的条件判断表达式。 (4) ? : 中的条件判断表达式。 (5) 逻辑运算符 ||(逻辑或)和 &&(逻辑与)左边的操作数(作为条件判断表达式)。

|| 和 && && 和 || 运算符的返回值并不一定是布尔类型,而是两个操作数其中一个的值。

var a = 42;
var b = "abc";
var c = null;

a || b; // 42 
a && b; // "abc"

c || b; // "abc" 
c && b; // null

|| 和 && 首先会对第一个操作数(a 和 c)执行条件判断,如果其不是布尔值(如上例)就先进行 ToBoolean 强制类型转换,然后再执行条件判断。 对于 || 来说,如果条件判断结果为 true 就返回第一个操作数(a 和 c)的值,如果为false 就返回第二个操作数(b)的值。 && 则相反,如果条件判断结果为 true 就返回第二个操作数(b)的值,如果为 false 就返回第一个操作数(a 和 c)的值。

符号的强制类型转换 ES6 中引入了符号类型,它的强制类型转换有一个坑,在这里有必要提一下。ES6 允许从符号到字符串的显式强制类型转换,然而隐式强制类型转换会产生错误:

var s1 = Symbol( "cool" );
String( s1 ); // "Symbol(cool)"
var s2 = Symbol( "not cool" );
s2 + ""; // TypeError

宽松相等和严格相等

常见的误区是“== 检查值是否相等,=== 检查值和类型是否相等”。听起来蛮有道理,然而 还不够准确。很多 JavaScript 的书籍和博客也是这样来解释的,但是很遗憾他们都错了。

正确的解释是:“== 允许在相等比较中进行强制类型转换,而 === 不允许。”

抽象相等 == 在比较两个不同类型的值时会发生隐式强制类型转换,会将其中之一或两者都转换为相同的类型后再进行比较。

  • 字符串和数字之间的相等比较: (1) 如果 Type(x) 是数字,Type(y) 是字符串,则返回 x == ToNumber(y) 的结果。 (2) 如果 Type(x) 是字符串,Type(y) 是数字,则返回 ToNumber(x) == y 的结果
var a = 42;
var b = "42";
a === b; // false
a == b; // true
  • 其他类型和布尔类型之间的相等比较: (1) 如果 Type(x) 是布尔类型,则返回 ToNumber(x) == y 的结果; (2) 如果 Type(y) 是布尔类型,则返回 x == ToNumber(y) 的结果。
var a = "42";
var b = true;
a == b; // false
  • null 和 undefined 之间的相等比较 (1) 如果 x 为 null,y 为 undefined,则结果为 true。 (2) 如果 x 为 undefined,y 为 null,则结果为 true。
var a = null;
var b;
a == b; // true
a == null; // true
b == null; // true

a == false; // false
b == false; // false
a == ""; // false
b == ""; // false
a == 0; // false
b == 0; // false
  • 对象 and 非对象之间的相等比较 (1) 如果 Type(x) 是字符串或数字,Type(y) 是对象,则返回 x == ToPrimitive(y) 的结果; (2) 如果 Type(x) 是对象,Type(y) 是字符串或数字,则返回 ToPromitive(x) == y 的结果。
var a = 42;
var b = [ 42 ];
a == b; // true

比较少见的情况

  1. 返回其他数字:
Number.prototype.valueOf = function() {
 return 3;
};
new Number( 2 ) == 3; // true
  1. 假值的相等比较:
"0" == null; // false
"0" == undefined; // false
"0" == false; // true
"0" == NaN; // false
"0" == 0; // true
"0" == ""; // false

false == null; // false
false == undefined; // false
false == NaN; // false
false == 0; // true
false == ""; // true
false == []; // true
false == {}; // false

"" == null; // false
"" == undefined; // false
"" == NaN; // false
"" == 0; // true
"" == []; // true
"" == {}; // false

0 == null; // false
0 == undefined; // false
0 == NaN; // false
0 == []; // true
0 == {}; // false
  1. 极端情况

根据 ToBoolean 规则,它会进行布尔值的显式强制类型转换。所以 [] == ![] 变成了 [] == false。前面介绍 of false == [],最后的结果就顺理成章了

[] == ![] // true

安全运用隐式强制类型转换

  • 如果两边的值中有 true 或者 false,千万不要使用 ==。
  • 如果两边的值中有 []、"" 或者 0,尽量不要使用 ==。 这时最好用 === 来避免不经意的强制类型转换。这两个原则可以让我们避开几乎所有强制类型转换的坑

抽象关系比较

a < b 中涉及的隐式强制类型转换: 比较双方首先调用 ToPrimitive,如果结果出现非字符串,就根据 ToNumber 规则将双方强制类型转换为数字来进行比较。

var a = [ 42 ];
var b = [ "43" ];
a < b; // true
b < a; // false

如果比较双方都是字符串,则按字母顺序来进行比较:

var a = [ "42" ];
var b = [ "043" ];
a < b; // false

var a = [ 4, 2 ];
var b = [ 0, 4, 3 ];
a < b; // false

还有个特殊情况:

var a = { b: 42 };
var b = { b: 43 };
a < b; // false
a == b; // false
a > b; // false
a <= b; // true
a >= b; // true

因为 a 是 [object Object],b 也是 [object Object],所以按照字母顺序a < b 并不成立。

为什么 a == b 的结果不是 true ?它们的字符串值相同(同为 "[object Object]"),按道理应该相等才对?实际上不是这样,你可以回忆一下前面讲过的对象的相等比较。

但是 if a < b 和 a == b 结果为 false,为什么 a <= b 和 a >= b 的结果会是 true 呢?因为根据规范 a <= b 被处理为 b < a,然后将结果反转。因为 b < a 的结果是 false,所以 a <= b 的结果是 true。

这可能与我们设想的大相径庭,即 <= 应该是“小于或者等于”。实际上 JavaScript 中 <= 是“不大于”的意思(即 !(a > b),处理为 !(b < a))。同理 a >= b 处理为 b <= a。

强制类型转换小结

JS 的数据类型之间的转换,即强制类型转换:包括显式和隐式。

显式强制类型转换明确告诉我们哪里发生了类型转换,有助于提高代码可读性和可维护性。

隐式强制类型转换则没有那么明显,是其他操作的副作用。实际上隐式强制类型转换也有助于提高代码的可读性。在处理强制类型转换的时候要十分小心,尤其是隐式强制类型转换。

语法

语句和表达式

JS中语句相当于句子,表达式相当于短语,运算符则相当于标点符号和连接词。

语句的结果值 代码块的结果值就如同一个隐式的返回,即返回最后一个语句的结果值。

var b;
if (true) {
    b = 4 + 38;
}

表达式的副作用 函数调用的副作用:

function foo() {
 a = a + 1;
}
var a = 1;
foo(); // 结果值:undefined。副作用:a的值被改变

= 赋值运算符:

var a;
a = 42; // 42
a; // 42

运算符优先级

&& 先执行,然后是 ||:

(false && true) || true; // true
false && (true || true); // false

false && true || true; // true

那执行顺序是否就一定是从左到右呢?不妨将运算符颠倒一下看看:

true || false && false; // true
(true || false) && false; // false
true || (false && false); // true

这说明 && 运算符先于 || 执行,而且执行顺序并非我们所设想的从左到右。原因就在于运算符优先级。

短路 对于 && 和 || 来说,如果从左边的操作数能够得出结果,就可以忽略右边的操作数。我们将这种现象称为“短路”(即执行最短路径)。

更强的绑定 因为 && 运算符的优先级高于 ||,而 || 的优先级又高于 ? :。

a && b || c ? c || b ? a : c && b : a
// 等同于
(a && b || c) ? (c || b) ? a : (c && b) : a

关联 一般多个&&和||执行顺序是从左到右,也被称为左关联,但? : 是右关联

a ? b : c ? d : e;
// 等同于
a ? b : (c ? d : e)

另一个右关联组合的例子是 = 运算符:

var a, b, c;
a = b = c = 42;
// 等同于
a = (b = (c = 42))

自动分号

JS会自动为代码行补上缺失的分号,即自动分号插入(Automatic Semicolon Insertion,ASI)。

错误

JS不仅有各种类型的运行时错误(TypeError、ReferenceError、SyntaxError 等),它的语法中也定义了一些编译时错误。

提前使用变量 ES6 规范定义了一个新概念,叫作 TDZ(Temporal Dead Zone,暂时性死区)。TDZ 指的是由于代码中的变量还没有初始化而不能被引用的情况。

{
    a = 2; // ReferenceError!
    let a; 
}

函数参数

在 ES6 中,如果参数被省略或者值为 undefined,则取该参数的默认值:

function foo( a = 42, b = a + 1 ) {
    console.log( a, b );
}
foo(); // 42 43
foo( undefined ); // 42 43
foo( 5 ); // 5 6
foo( void 0, 7 ); // 42 7
foo( null ); // null 1

try finally

finally 中的代码总是会在 try 之后执行,如果有 catch 的话则在 catch 之后执行。也可以将 finally 中的代码看作一个回调函数,即无论出现什么情况最后一定会被调用。

function foo() {
    try {
        return 42;
    } 
    finally {
        console.log( "Hello" );
    }
    console.log( "never runs" );
}
console.log( foo() );
// Hello
// 42

这里 return 42 先执行,并将 foo() 函数的返回值设置为 42。然后 try 执行完毕,接着执行 finally。最后 foo() 函数执行完毕,console.log(..) 显示返回值。 try 中的 throw 也是如此:

function foo() {
    try {
        throw 42; 
    }
    finally {
        console.log( "Hello" );
    }
    console.log( "never runs" );
}
console.log( foo() );
// Hello
// Uncaught Exception: 42

switch

switch,可以把它看作 if..else if..else.. 的简化版本:

switch (a) {
    case 2:
    // 执行一些代码
    break;
    case 42:
    // 执行另外一些代码
    break;
    default:
    // 执行缺省代码
}

a 和 case 表达式的匹配算法与 === 相同。通常case语句中switch都是简单值,但有时可能会需要通过强制类型转换来进行相等比较,这时就需要做一些特殊处理:

var a = "42";
switch (true) {
    case a == 10:
        console.log( "10 or '10'" );
        break;
    case a == 42;
        console.log( "42 or '42'" );
        break;
    default:
        // 永远执行不到这里
}
// 42 or '42'

尽管可以使用 ==,但 switch 中 true and true 之间仍然是严格相等比较。即 if case 表达式的结果为真值,但不是严格意义上的 true,则条件不成立。

var a = "hello world";
var b = 10;
switch (true) {
    case (a || b == 10):
        // 永远执行不到这里
        break;
    default:
        console.log( "Oops" );
}
// Oops

最后,default 是可选的,并非必不可少。break 相关规则对 default 仍然适用:

var a = 10;
switch (a) {
    case 1:
    case 2:
        // 永远执行不到这里
    default:
        console.log( "default" );
    case 3:
        console.log( "3" );
        break;
    case 4:
        console.log( "4" );
}
// default
// 3

上例中的代码是这样执行的,首先遍历并找到所有匹配的 case,如果没有匹配则执行default 中的代码。因为其中没有 break,所以继续执行已经遍历过的 case 3 代码块,直到 break 为止。

语法小结

JS的语法规则之上是语义规则,也称上下文。 JS还详细定义了运算符的优先级和关联。

异步:现在与将来

程序中现在运行的部分和将来运行的部分之间的关系就是异步编程的核心。

分块的程序

可以把 JavaScript 程序写在单个 .js 文件中,但是这个程序几乎一定是由多个块构成的。这些块中只有一个是现在执行,其余的则会在将来执行。最常见的块单位是函数。 大多数 JS 新手程序员都会遇到的问题是:程序中将来执行的部分并不一定在现在运行的部分执行完之后就立即执行。 从现在到将来的“等待”,最简单的方法是使用一个通常称为回调函数的函数:

// ajax(..)是某个库中提供的某个Ajax函数
ajax( "http://some.url.1", function myCallbackFunction(data){
    console.log( data ); // 耶!这里得到了一些数据!
});

异步控制台 在某些条件下,某些浏览器的 console.log 并不会把传入的内容立即输出。出现这种情况的主要原因是,在许多程序(不只是 JS)中,I/O 是非常低速的阻塞部分。所以浏览器在后台异步处理控制台 I/O 能够提高性能,这时用户甚至可能根本意识不到其发生。

事件循环

所有这些环境都有一个共同“点”(thread,也指线程。),即它们都提供了一种机制来处理程序中多个块的执行,且执行每块时调用 JS 引擎,这种机制被称为事件循环。 先通过一段伪代码了解一下这个概念 :

// eventLoop是一个用作队列的数组
// (先进,先出)
var eventLoop = [ ];
var event;
// “永远”执行
while (true) {
    // 一次tick
    if (eventLoop.length > 0) {
        // 拿到队列中的下一个事件
        event = eventLoop.shift();
        // 现在,执行下一个事件
        try {
            event();
        } catch (err) {
            reportError(err);
        }
    }
}

可以看到,有一个用 while 循环实现的持续运行的循环,循环的每一轮称为一个 tick。对每个 tick 而言,如果在队列中有等待事件,那么就会从队列中摘下一个事件并执行。这些事件就是你的回调函数。

并行线程

术语“异步”和“并行”常常被混为一谈,但实际上它们的意义完全不同。记住,异步是关于现在和将来的时间间隙,而并行是关于能够同时发生的事情。 并行计算最常见的工具就是进程和线程. 进程和线程独立运行,并可能同时运行:在不同的处理器,甚至不同的计算机上,但多个线程能够共享单个进程的内存。

并发

两个或多个“进程”同时执行就出现了并发,不管组成它们的单个运算是否并行执行(在独立的处理器或处理器核心上同时运行)。可以把并发看作“进程”级(或者任务级)的并行,与运算级的并行(不同处理器上的线程)相对。

非交互 如果进程间没有相互影响的话,不确定性是完全可以接受的。

交互 并发的“进程”需要相互交流,通过作用域或 DOM 间接交互。正如前面介绍的,如果出现这样的交互,就需要对它们的交互进行协调以避免竞态的出现。

协作 还有一种并发合作方式,称为并发协作(cooperative concurrency)。这里的重点不再是通过共享作用域中的值进行交互。这里的目标是取到一个长期运行的“进程”,并将其分割成多个步骤或多批任务,使得其他并发“进程”有机会将自己的运算插入到事件循环队列中交替运行。

任务

在 ES6 中,有一个新的概念建立在事件循环队列之上,叫作任务队列(job queue)。对于任务队列最好的理解方式就是,它是挂在事件循环队列的每个 tick 之后的一个队列。在事件循环的每个 tick 中,可能出现的异步动作不会导致一个完整的新事件添加到事件循环队列中,而会在当前 tick 的任务队列末尾添加一个项目(一个任务)。

语句顺序

代码中语句的顺序和js引擎执行语句的顺序并不一定要一致。

异步小结

JS 程序总是至少分为两个块:第一块现在运行;下一块将来运行,以响应某个事件。尽管程序是一块一块执行的,但是所有这些块共享对程序作用域和状态的访问,所以对状态的修改都是在之前累积的修改之上进行的。 一旦有事件需要运行,事件循环就会运行,直到队列清空。事件循环的每一轮称为一个tick。用户交互、IO 和定时器会向事件队列中加入事件。

回调

到目前为止,回调是编写和处理 JavaScript 程序异步逻辑的最常用方式。确实,回调是这门语言中最基础的异步模式。

延续(continuation)

回调函数包裹或者说封装了程序的延续(continuation)。

// A 
setTimeout( function(){ 
    // C 
}, 1000 ); 
// B

执行 A,设定延时 1000 毫秒,然后执行 B,然后定时到时后执行C

顺序的大脑

执行与计划 我们的大脑可以看作类似于单线程运行的事件循环队列,就像 JavaScript 引擎那样。这个比喻看起来很贴切。但是,我们的分析还需要比这更加深入细致一些。显而易见的是,在我们如何计划各种任务和我们的大脑如何实际执行这些计划之间,还存在着很大的差别。

嵌套回调和链式回调

listen( "click", function handler(evt){ 
    setTimeout( function request(){ 
        ajax( "http://some.url.1", function response(text){ 
            if (text == "hello") { 
                handler(); 
            } 
            else if (text == "world") { 
                request(); 
            } 
        } ); 
    }, 500) ; 
} );

这种代码常常被称为回调地狱(callback hell),有时也被称为毁灭金字塔。 让我们不用嵌套再把前面的嵌套事件 / 超时 /Ajax 的例子重写一遍吧:

listen( "click", handler ); 
function handler() { 
    setTimeout( request, 500 ); 
} 
function request(){ 
    ajax( "http://some.url.1", response ); 
} 
function response(text){ 
    if (text == "hello") { 
        handler(); 
    } 
    else if (text == "world") { 
        request(); 
    } 
}

信任问题

// A 
ajax( "..", function(..){ 
    // C 
} ); 
// B

在 JS 主程序的直接控制之下。而 // C 会延迟到将来发生,并且是在第三方的控制下——在本例中就是函数 ajax。从根本上来说,这种控制的转移通常不会给程序带来很多问题。 但是,请不要被这个小概率迷惑而认为这种控制切换不是什么大问题。实际上,这是回调驱动设计最严重(也是最微妙)的问题。它以这样一个思路为中心:有时候 ajax 不是你编写的代码,也不在你的直接控制下。多数情况下,它是某个第三方提供的工具。 我们把这称为控制反转,也就是把自己程序一部分的执行控制交给某个第三方。在你的代码和第三方工具之间有一份并没有明确表达的契约。

省点回调

为了更优雅地处理错误,有些 API 设计提供了分离回调(一个用于成功通知,一个用于出错通知):

function success(data) { 
    console.log( data ); 
} 
function failure(err) { 
    console.error( err ); 
} 
ajax( "http://some.url.1", success, failure );

在这种设计下,API 的出错处理函数 failure() 常常是可选的,如果没有提供的话,就是假定这个错误可以吞掉。

回调小结

回调函数是 JavaScript 异步的基本单元。但是随着 JavaScript 越来越成熟,对于异步编程领域的发展,回调已经不够用了。

原文地址

墨渊书肆/你不知道的JS(中):强制类型转换与异步基础

你不知道的JS(中):类型与值

作者 牛奶
2026年2月15日 10:38

你不知道的JS(中):类型与值

本文是《你不知道的JavaScript(中卷)》的阅读笔记,第一部分:类型与值。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

类型

对语言引擎和开发人员来说,类型是值的内部特征,它定义了值的行为,以使其区别于其他值。

内置类型

JavaScript 有七种内置类型:

  • 空值(null)
  • 未定义(undefined)
  • 布尔值( boolean)
  • 数字(number)
  • 字符串(string)
  • 对象(object)
  • 符号(symbol,ES6 中新增)

对于类型, we 一般使用typeof来判断,但有一些特殊情况无法准确判断,如下:

// null的类型不是null
typeof null === "object"; // true
(!a && typeof a === "object"); // null需要复合条件来判断

// function的类型不是object
typeof function a(){} === "function"; // true

// 数组也是object
typeof [1,2,3] === "object"; // true

值和类型

JS中的变量是没有类型的,只有值才有。JS不做“类型强制”;

undefined 和 undeclared 已在作用域中声明但还没有赋值的变量,是 undefined 的。相反,还没有在作用域中声明过的变量,是 undeclared 的。

内置类型小结

JS中有其中内置类型:null、undefined、boolean、number、string、object和symbol,可以使用typeof运算符来查看。但对于null、function和数组要特殊处理。

变量没有类型,但它们持有的值 have 类型。类型定义了值的行为特征。 在 JS 中它们是两码事,undefined 是值的一种,undeclared 则表示变量还没有被声明过。

数组(array)、字符串(string)和数字(number)是一个程序最基本的组成部分。

数组

与其他强类型语言不同,在JS中数组可以容纳任何类型的值,可以是字符串、数字、对象,甚至是其他数组(多维数组就是这么实现的):

var a = [1, '2', [3]];

a.length; // 3
a[0] === 1; // true
a[2][0] === 3; // true

数组声明可以不预先设定大小,但使用delete删除时要注意length不会被改变。还有在创建稀疏数组时,长度会变化,没有设置的位置的值为undefined。

var a = [];
a[0] = 1;
a[2] = 3;

a[1]; // undefined
a.length; // 3

同时数组也是对象,可以使用字符串的key去获取属性

var a = [0, 1, 2];
a['2']; // 2

但也需要注意如果把字符串的数字作为索引赋值处理,会被强制转换为十进制的数字,且长度也会改变:

var a = [];
a['13'] = 22;
a.length; // 14

类数组 有时需要将类数组转换为真正的数组,一般通过数组工具函数(如indexOf、concat、forEach等)来实现; 还有函数的参数arguments也可以进行数组转化:

function foo() {
 var arr = Array.prototype.slice.call( arguments );
 arr.push( "bam" );
 console.log( arr );
}
foo( "bar", "baz" ); // ["bar","baz","bam"]

ES6也可以使用Array.from去处理:

var arr = Array.from( arguments );

字符串

字符串和数组的确很相似,它们都是类数组,都有length属性以及indexOf和concat方法。 但字符串是不可变的,数组是可变的。

数字

JS只有一种数值类型:number,包括“整数”和带小数的十进制数。JS没有真正意义上的整数。JS中的数字类型是基于IEEE754标准来实现的,该标准通常也被称为“浮点数”。JS使用的是“双精度”格式。 特别大或者特别小的数字默认使用指数格式显示:

var a = 5E10;
a; // 50000000000
a.toExponential(); // "5e+10"
var b = a * a;
b; // 2.5e+21
var c = 1 / a;
c; // 2e-11

较小的数值 二进制浮点数最大的问题就是较小的数值运算不精确:

0.1+0.2 === 0.3; // false
// 因为相加等于0.30000000000000004

如何来判断相等呢?最常见的方法是设置一个误差范围,通常为称为“机器精度”。JS是2^-52 (2.220446049250313e-16)。 在ES6中使用Number.EPSILON,ES6之前使用polyfill:

if (!Number.EPSILON) {
 Number.EPSILON = Math.pow(2,-52);
}

使用 Number.EPSILON 来比较两个数字是否相等

function numbersCloseEnoughToEqual(n1,n2) {
 return Math.abs( n1 - n2 ) < Number.EPSILON;
}
var a = 0.1 + 0.2;
var b = 0.3;
numbersCloseEnoughToEqual( a, b ); // true
numbersCloseEnoughToEqual( 0.0000001, 0.0000002 ); // false

整数的安全范围:

数字的呈现方式决定了“整数”的安全值范围远远小于 Number.MAX_VALUE。 能够被“安全”呈现的最大整数是 2^53 - 1,即 9007199254740991,在 ES6 中 被定义为Number.MAX_SAFE_INTEGER。最小整数是 -9007199254740991,在 ES6 中被定义为Number.MIN_SAFE_INTEGER。

特殊数值

不是值的值:

  • null指空值
  • undefined指没有值

undefined 在非严格模式可以给undefined赋值:

function foo() {
    undefined = 2; // very bad
}

在非严格和严格模式可以把undefined命名变量:

function foo() {
    "use strict";
    var undefined = 2; // very bad
    console.log(undefined); // 2
}
foo();

void运算符 表达式void xxx没有返回值,因此返回的结果是undefined。

特殊的数字 NaN:不是一个数字。

var a = 2 / 'foo'; // NaN
typeof a === 'number'; // true

NaN 是一个“警戒值”,用于指出数字类型中的错误情况,即“执行数学运算没有成功,这是失败后返回的结果”。 NaN ≠ NaN为true,它和自己不相等,是唯一一个非自反。

var a = 2 / "foo";
a == NaN; // false
a === NaN; // false

如果要判断是否是NaN,需要使用全局工具函数isNaN来判断:

var a = 2 / "foo";
isNaN(a); // true

但isNaN有个缺陷,就是检查参数是否不是NaN,也不是数字:

isNaN('foo'); // true

很明显‘foo’不是数字也不是NaN,这是一个很久的bug。 ES6中我们可以使用Number.isNaN,ES6之前可以使用polyfill:

// 方法1:
if (!Number.isNaN) {
    Number.isNaN = function (n) {
        return typeof n === 'number' && window.isNaN(n)
    }
}

// 方法2:
if (!Number.isNaN) {
    Number.isNaN = function (n) {
        return n !== n
    }
}

无穷数 正无穷: Infinity 负无穷:-Infinity

零值 JS有0 and -0,-0也是有意义的,对负数的乘法和除法可以出现-0,加减法不行;-0的判断:

function isNegZero(n) {
    n = Number(n);
    return (n === 0) && (1/n === -Infinity)
}

isNegZero( -0 ); // true
isNegZero( 0 / -3 ); // true
isNegZero( 0 ); // false

特殊等式 ES6中新加入一个工具方法Object.is来判断俩个值是否绝对相等。

Object.is(2 / 'foo', NaN); // true
Object.is(-3*0, -0); // true
Object.is(-3*0, 00); // false

polyfill:

if (!Object.is) {
    Object.is = function(v1, v2) {
        // 判断是否是-0
        if (v1 === 0 && v2 === 0) {
            return 1 / v1 === 1 / v2;
        }
        // 判断是否是NaN
        if (v1 !== v1) {
            return v2 !== v2;
        }
        // 其他情况
        return v1 === v2;
    };
}

值和引用

JS引用指向的是值,根据值得类型来决定。基本类型是通过值复制的方式来赋值/传递,包括null、undefined、字符串、数字、布尔和ES6中的symbol。复合值(对象:数组和封装对象、函数)则是通过引用复制的方式来赋值/传递。

由于引用指向的是值本身而非变量,所以一个引用无法更改另一个引用的指向.

var a = [1,2,3];
var b = a;
a; // [1,2,3]
b; // [1,2,3]
// 然后
b = [4,5,6];
a; // [1,2,3]
b; // [4,5,6]

函数参数就经常让人产生这样的困惑:

function foo(x) {
    x.push( 4 );
    x; // [1,2,3,4]
    // 然后
    x = [4,5,6];
    x.push( 7 );
    x; // [4,5,6,7]
}
var a = [1,2,3];
foo( a );
a; // 是[1,2,3,4],不是[4,5,6,7]

我们向函数传递 a 的时候,实际是将引用 a 的一个复本赋值给 x,而 a 仍然指向 [1,2,3]。在函数中我们可以通过引用 x 来更改数组的值(push(4) 之后变为 [1,2,3,4])。但 x = [4,5,6] 并不影响 a 的指向,所以 a 仍然指向 [1,2,3,4]。 我们不能通过引用 x 来更改引用 a 的指向,只能更改 a 和 x 共同指向的值。 如果要将 a 的值变为 [4,5,6,7],必须更改 x 指向的数组,而不是为 x 赋值一个新的数组。

function foo(x) {
    x.push( 4 );
    x; // [1,2,3,4]
    // 然后
    x.length = 0; // 清空数组
    x.push( 4, 5, 6, 7 );
    x; // [4,5,6,7]
}
var a = [1,2,3];
foo( a );
a; // 是[4,5,6,7],不是[1,2,3,4]

如果通过值复制的方式来传递复合值(如数组),就需要为其创建一个复本,这样传递的就不再是原始值。例如:

foo( a.slice() )

值小结

JavaScript 中的数字包括“整数”和“浮点型”。 null 类型只有一个值 null,undefined 类型也只有一个值 undefined。所有变量在赋值之前默认值都是 undefined。void 运算符返回 undefined。 数字类型有几个特殊值,包括NaN(意指“not a number”,更确切地说是“invalid number”)、+Infinity、-Infinity 和 -0。

原生函数

JS的内建函数,也叫原生函数。常用的原生函数有:

  • String()
  • Number()
  • Boolean()
  • Array()
  • Object()
  • Function()
  • RegExp()
  • Date()
  • Error()
  • Symbol()——ES6 中新加入的!

原生函数可以被当作构造函数来使用,但其构造出来的对象可能会和我们设想的有所出入:

var a = new String( "abc" );
typeof a; // 是"object",不是"String"
a instanceof String; // true
Object.prototype.toString.call( a ); // "[object String]"

内部属性[[Class]]

所有 typeof 返回值为 "object" 的对象(如数组)都包含一个内部属性 [[Class]]。这个属性无法直接访问,一般通过 Object.prototype.toString(..) 来查看。例如:

Object.prototype.toString.call( [1,2,3] );
// "[object Array]"
Object.prototype.toString.call( /regex-literal/i );
// "[object RegExp]"

封装对象包装

使用封装对象时有些地方需要特别注意。比如 Boolean:

var a = new Boolean( false );
if (!a) {
    console.log( "Oops" ); // 执行不到这里
}

拆分

如果想要得到封装对象中的基本类型值,可以使用 valueOf() 函数:

var a = new String( "abc" );
var b = new Number( 42 );
var c = new Boolean( true );
a.valueOf(); // "abc"
b.valueOf(); // 42
c.valueOf(); // true

原生函数作为构造函

Array

var a = new Array( 1, 2, 3 );
a; // [1, 2, 3]
var b = [1, 2, 3];
b; // [1, 2, 3]

Array 构造函数只带一个数字参数的时候,该参数会被作为数组的预设长度(length),而非只充当数组中的一个元素。这实非明智之举:一是容易忘记,二是容易出错。

var a = new Array( 3 );
a.length; // 3
a;

Object/Function/RegExp 万不得已,不要使用这些构造函数。在实际情况中没有必要使用 new Object() 来创建对象,因为这样就无法像常量形式那样一次设定多个属性,而必须逐一设定。

Date和Error 相较于其他原生构造函数,Date(..) 和 Error(..) 的用处要大很多,因为没有对应的常量形式来作为它们的替代。 创建日期对象必须使用 new Date()。Date可以带参数,用来指定日期和时间,而不带参数的话则使用当前的日期和时间。Date主要用来获得当前的 Unix 时间戳(从 1970 年 1 月 1 日开始计算,以秒为单位)。该值可以通过日期对象中的 getTime() 来获得。 从 ES5 开始引入了一个更简单的方法,即Date.now()。对 ES5 之前我们可以使用polyfill:

if (!Date.now) {
     Date.now = function(){
         return (new Date()).getTime();
     };
}

构造函数 Error带不带 new 关键字都可。错误对象通常与 throw 一起使用:

function foo(x) {
    if (!x) {
        throw new Error( "x wasn’t provided" );
    }
    // ... 
}

Symbol ES6 中新加入了一个基本数据类型 ——符号(Symbol)。符号是具有唯一性的特殊值(并非绝对),用它来命名对象属性不容易导致重名。该类型的引入主要源于 ES6 的一些特殊构造,此外符号也可以自行定义。 ES6中有一些预定义符号,以Symbol的静态属性形式出现,如 Symbol.create、Symbol.iterator 等,可以这样来使用:

obj[Symbol.iterator] = function(){ /*..*/ };

我们可以使用Symbol原生构造函数来自定义符号。但它比较特殊,不能new关键字,否则会出错:

var mysym = Symbol( "my own symbol" );
mysym; // Symbol(my own symbol)
mysym.toString(); // "Symbol(my own symbol)"
typeof mysym; // "symbol"
var a = { };
a[mysym] = "foobar";
Object.getOwnPropertySymbols( a );
// [ Symbol(my own symbol) ]

原生原型

原生构造函数有自己的 .prototype 对象,如 Array.prototype、String.prototype 等。这些对象包含其对应子类型所特有的行为特征。

  • String#indexOf 在字符串中找到指定子字符串的位置。
  • String#charAt 获得字符串指定位置上的字符。
  • String#substr、String#substring 和 String#slice 获得字符串的指定部分。
  • String#toUpperCase 和 String#toLowerCase 将字符串转换为大写 or 小写。
  • String#trim 去掉字符串前后的空格,返回新的字符串。以上方法并不改变原字符串的值,而是返回一个新字符串。

原生函数小结

JavaScript 为基本数据类型值提供了封装对象,称为原生函数(如 String、Number、Boolean等)。它们为基本数据类型值提供了该子类型所特有的方法和属性。

原文地址

墨渊书肆/你不知道的JS(中):类型与值

2025–2030 前端登录技术展望:Passkey 之后是什么?

2026年2月15日 10:32

上一章我们聊了“密码正在死亡”(2020–2026):从 MFA/TOTP 的普及,到 WebAuthn/FIDO2 的基础,再到 2026 年 Passkey 真正爆发的临界点。密码体系的崩塌已成定局,但前端登录技术不会止步于“无密码”——它将进一步融合隐私、去中心化、AI 和零信任,演变成一个更智能、更安全的“身份意图”系统。

这一篇(系列终章),我们展望 2025–2030 年的前端登录趋势。作为 2026 年 2 月的现在,Passkey 已从“可选”转为“默认”,但未来 5 年将面临采用率瓶颈、兼容性挑战和新兴范式冲击。我们将讨论 Passkey 的终局可能性、Web3 DID 的潜力、AI Agent 身份的出现、隐私计算 + 零信任的融合,以及可能的“终局猜想”。

1. Passkey 是否真的会取代密码?(2026–2028 的关键期)

到 2026 年初(当前),Passkey 的全球采用率已达 70% 以上(FIDO Alliance 数据),但距离“完全取代”还有三大障碍:

  • 采用率天花板:低端设备(功能机、旧 Android/iOS)、新兴市场(非洲/东南亚)的兼容性问题。预计 2027 年覆盖率达 90%,但 10% 的“长尾”用户仍需 fallback 到密码 + TOTP。
  • 用户教育与信任成本:许多用户仍习惯“输入密码”,Passkey 的“生物 + 设备”模式需教育(如“你的指纹就是钥匙”)。2026–2027 年,巨头(如 Google/Apple)将通过系统级弹窗 + 教程推动,但隐私担忧(“我的生物数据被存云端?”)会引发反弹。
  • 企业 vs 消费者分化:ToC(如电商/社交)已 80% 转向 Passkey;ToB(如银行/企业内网)更保守,预计 2028 年才达 70%(需合规审计)。

前端变化(2026–2028):

  • 混合模式主流:前端库(如 @simplewebauthn、Clerk、Auth0)内置“Passkey first + fallback”逻辑。
  • 恢复机制标准化:邮箱魔法链接 + 恢复码 + 多设备绑定(FIDO 跨平台规范迭代)。
  • 实际难度:接入 Passkey 成本已降到“几行代码”,但测试跨设备/跨平台(iOS + Android + Windows)仍需 1–2 周开发时间。

预测:到 2028 年,Passkey 将取代 85% 的密码登录,但不会“完全死亡”——高安全场景(如政府/医疗)会保留“密码 + Passkey”双因素。

2. Web3 与去中心化身份(DID)的融合(2026–2030)

Web3 的 DID(Decentralized Identifier,W3C 标准)从 2023–2025 年的“概念”转为 2026 年后的“实用”。核心:用户自持身份(区块链 + 钱包),无需中心化服务器。

前端角色演进:

  • SIWE(Sign-In with Ethereum)扩展:2026 年起,Wallet 登录(如 MetaMask、Rainbow)成为标配。流程:前端调用 ethereum.request({ method: 'personal_sign' }) → 用户签名消息 → 后端验证 → 发 session token。
  • DID + Passkey 混合:2027–2028 年,FIDO 与 DID 融合(如 DID:Web + WebAuthn)。用户用钱包生成 Passkey,跨 DApp 无缝登录。
  • 代表案例:DeFi / NFT 平台(如 OpenSea 2.0、Uniswap)已用 Wallet 取代传统登录;社交(如 Lens Protocol)用 DID 实现“永久身份”。

痛点与前端挑战:

  • 用户门槛:Gas fee + 钱包管理仍高,2028 年后“零 Gas” L2 链(如 zk-rollups)普及。
  • 安全性:私钥丢失 = 身份永失,前端需集成“社交恢复”(多签名守护者)。
  • 兼容 Web2:OIDC + DID 桥接库(如 did-session)让传统前端无缝接入。

预测:到 2030 年,30% 的前端应用(尤其是 ToC / 游戏 / 社交)会支持 DID 作为“可选无密码”方案,但不会取代 Passkey(后者更易用)。

3. AI Agent 时代的身份认证(2027–2030)

AI Agent(自主代理,如 Auto-GPT 衍生品)从 2025 年实验转为 2027 年主流,用户会说:“帮我登录银行,查余额”。

前端 / 身份系统的变化:

  • Agent 代表用户登录:Agent 用用户授权的“委托凭证”(OAuth 2.1 + DPoP,Device-Bound Proof of Possession)访问 API。前端需支持“意图确认” UI(如“允许 Agent X 代表你登录?” + 生物验证)。
  • 零交互登录:Agent 预先获取 refresh token,前端用 WebAuthn 的“驻留密钥”自动认证。
  • 风控升级:AI 检测异常(如“Agent 行为不像用户”),前端集成浏览器指纹 + 行为分析(fingerprintjs + ML 模型)。

挑战:

  • 隐私与滥用:Agent 泄露 token 风险高,2028 年起“零知识证明”(ZKP)普及:证明“我有权限”而不露 token。
  • 前端框架适配:React/Vue/Next.js 将内置 Agent SDK(如 OpenAI Auth Kit),简化“意图-based 登录”。

预测:到 2030 年,50% 的高频登录(如电商/支付)将由 Agent 代理,但人类确认仍必备(法规要求)。

4. 隐私计算 + 零信任在前端登录中的潜在应用(2026–2030)

零信任(Zero Trust)从企业扩展到消费者:假设所有请求都可疑,前端需实时证明“可信”。

关键技术:

  • 隐私计算:前端用 Homomorphic Encryption 或 MPC(Multi-Party Computation)加密凭证,只在服务端解密部分信息。2027 年起,库如 tfhe.js 让前端“零泄露”处理 Passkey。
  • 零信任前端:每个请求带“证明”(如 DPoP token + 设备 attestation)。浏览器原生支持(如 Chrome 的 Device Bound Session Credentials,DBSC)。
  • 应用:跨境电商 / 医疗登录:前端不传明文生物数据,只传“验证通过”的证明。

前端影响:

  • 复杂度升:需集成 crypto 库,但框架(如 Next.js Auth)会抽象。
  • 性能优化:Wasm + WebGPU 加速计算。

预测:到 2030 年,零信任将成为高安全前端的默认范式,隐私计算覆盖 40% 的登录场景。

5. 可能的终局猜想:以“意图 + 上下文”为主的身份系统

2030 年的前端登录,可能不再是“输入/验证凭证”,而是“意图确认”:

  • 终局形态:设备/生物 + AI 上下文推断(“你在家用常用设备?直接通过”) + ZKP 证明。
  • 密码的最后一搏:前端加密密码(Argon2 + PAKE)仍有小众存活,但占比 <5%。
  • 整体趋势:从“状态管理”到“意图管理”——前端框架将内置“Identity Layer”(如 Solid.js 的身份插件)。

系列回顾:从 Cookie/Session 的远古(1994–2012),到 Token/JWT 的崛起(2012–2023),再到 OAuth/SSO 的深化(2010–至今),以及无密码的现在(2020–2026),前端登录技术始终在追逐“安全 + 便利 + 隐私”的平衡。

密码正在死亡 —— 从 MFA 到无密码登录(2020–2026)

2026年2月15日 10:31

上一章我们聊了单点登录(SSO)在前端的落地形态:从 Cookie 域共享到基于 OIDC + Refresh Token 的集中式认证,再到微前端下的同步挑战。但无论 Token 再怎么优化、SSO 再怎么无缝,密码 这个人类最古老的数字身份载体,始终是整个体系最脆弱的一环:易忘、易猜、易钓鱼、易泄露、易重用。

从 2020 年开始,行业集体意识到:最好的密码,就是没有密码。这一篇,我们聚焦密码的“死亡过程”——从传统 MFA 的普及,到 TOTP/HOTP 的辅助,再到 WebAuthn/FIDO2 的崛起,最终到 2025–2026 年 Passkey(通行密钥)成为主流的无密码方案。前端工程师的角色,也从“表单 + 验证码校验”进化到“调用 navigator.credentials API + 处理跨设备同步”。

1. 2020–2022:MFA 成为标配,但密码仍是“根”

2020 年疫情加速数字化,远程办公 + 电商爆发,钓鱼攻击激增。密码 + 短信/邮箱 OTP 的组合被大规模强制。

典型前端实现(2020–2022):

  • 登录页:用户名 + 密码 + “发送验证码”按钮
  • 后端发短信/邮件 → 前端输入 6 位码
  • 框架:React/Vue + axios 轮询 / 长连接 polling

但问题很快暴露:

  • 短信劫持(SIM swapping)泛滥
  • 钓鱼网站实时中转 OTP
  • 用户疲劳 → 关闭 MFA 或用弱密码

统计:2021–2022 年,短信 OTP 仍是主流,但 FIDO Alliance 开始大力推 FIDO2(WebAuthn + CTAP)作为 phishing-resistant MFA。

前端接入 WebAuthn(早期):

// 注册(navigator.credentials.create)
async function register() {
  const publicKey = await fetch('/webauthn/register/challenge').then(r => r.json());
  const credential = await navigator.credentials.create({ publicKey });
  await fetch('/webauthn/register', {
    method: 'POST',
    body: JSON.stringify(credential)
  });
}

但 2020–2022 年,WebAuthn 普及慢:浏览器支持不全、用户教育成本高、设备兼容性差。

2. 2022–2024:Passkey 概念诞生 + 巨头推动(Apple/Google/Microsoft 三巨头联盟)

2022 年 5 月,Apple 在 WWDC 推出 iOS 16 的 Passkeys(基于 FIDO2 的同步凭证)。

核心卖点:

  • 私钥存设备 Secure Enclave / TPM
  • 公钥注册到服务端
  • 跨设备同步(iCloud Keychain / Google Password Manager / Microsoft 的实现)
  • 生物识别(指纹/面容)或 PIN 验证
  • Phishing-resistant(origin binding)

2023 年 Google 跟进:Chrome + Android 全面支持 Passkey,默认推动。

2024 年 Microsoft:新账户默认无密码 + Passkey。

前端变化:

  • 使用 @simplewebauthn/browser 或原生 navigator.credentials
  • 支持 autofill(浏览器自动提示 Passkey)
  • 条件 UI(conditional mediation):mediation: 'conditional' 让 Passkey 像密码一样自动填充

典型注册/认证代码(2024 现代写法):

// 认证(登录)
async function authenticate() {
  const options = await fetch('/webauthn/auth/options').then(r => r.json());
  options.mediation = 'conditional';  // 自动提示
  const assertion = await navigator.credentials.get({ publicKey: options });
  const res = await fetch('/webauthn/auth', {
    method: 'POST',
    body: JSON.stringify(assertion)
  });
  if (res.ok) console.log('登录成功');
}

这一阶段,Passkey 从“实验”变成“可选默认”。

3. 2025–2026:Passkey 真正爆发 + 密码死亡的临界点(2026 年现状)

到 2026 年 2 月,数据已非常清晰:

  • 设备就绪率:96% 的设备支持 Passkey(state-of-passkeys.io 数据,桌面 +68%、移动 +3% 增长)
  • 用户拥有率:69% 用户至少有一个 Passkey(从 2023 年的 39% 认知率暴涨)
  • 顶级网站支持率:48% 的前 100 网站支持 Passkey(2022 年仅 20% 多)
  • 登录成功率:Passkey 93% vs 传统 63%
  • 企业部署:87% 组织已部署或正在部署 Passkey(HID/FIDO 数据)
  • 认证量:Dashlane 数据显示月认证量达 130 万(同比翻倍),Google 增长 352%、Roblox 856%

巨头强制默认:

  • Google:2023 年起默认 Passkey
  • Microsoft:2025 年 5 月新账户默认无密码
  • Amazon、PayPal、TikTok 等电商/社交平台大规模跟进

前端接入难度(2026 年):

  • 极低:成熟库(@simplewebauthn、@auth0/auth0-spa-js、Clerk、Supabase Auth)屏蔽细节
  • 跨设备同步:依赖平台(iCloud/Google/MS),前端只需调用 API
  • 回退机制:仍支持密码 + TOTP 作为备用(恢复码、邮箱魔法链接)
  • 一键登录融合:Passkey + Apple/Google 一键 + 本机号码识别

典型组合拳(ToC 高频场景):

  1. 首选:Passkey(生物/设备验证)
  2. 备用:魔法链接(邮箱点击)
  3. 恢复:一次性恢复码 + 手机号验证
  4. 高危操作:Passkey + 二次确认(金额/敏感数据)

4. 前端工程师的实际落地 Checklist(2026 版)

  • 使用 navigator.credentials + mediation: 'conditional' 实现 autofill
  • 支持跨平台 RP ID(related-origin-requests for 多域)
  • 处理 user verification:userVerification: 'preferred' | 'required'
  • 兼容旧浏览器:polyfill 或 fallback 到 TOTP
  • 测试场景:Incognito、无网络、设备切换
  • 隐私考虑:不存储敏感 claims,前端只管传输 raw credential

小结 & 过渡

2020–2026 年,密码从“必须” → “可选” → “即将灭绝”的过程,核心驱动力是:

  • 安全:phishing-resistant(FIDO2)
  • 体验:生物识别 + 跨设备同步
  • 经济:减少重置支持票(降 50–80%)

到 2026 年,Passkey 已不是“未来技术”,而是消费者预期:用户开始问“为什么你们还不支持 Passkey?”

但密码完全死亡还需要时间:遗留系统、合规要求、低端设备、用户教育仍存阻力。

每日一题-二进制求和🟢

2026年2月15日 00:00

给你两个二进制字符串 ab ,以二进制字符串的形式返回它们的和。

 

示例 1:

输入:a = "11", b = "1"
输出:"100"

示例 2:

输入:a = "1010", b = "1011"
输出:"10101"

 

提示:

  • 1 <= a.length, b.length <= 104
  • ab 仅由字符 '0''1' 组成
  • 字符串如果不是 "0" ,就不含前导零

《this、箭头函数与普通函数:后台项目里最容易写错的几种场景》

作者 SuperEugene
2026年2月14日 23:47

前言

无论你是刚学 JavaScript 的小白,还是已经写了几年代码的前端,只要在写后台管理系统,大概率都踩过 this 和箭头函数的坑。

这篇文章不讲特别玄学的底层原理,只回答三个问题:

  1. 日常写代码该怎么选?(普通函数 vs 箭头函数)
  2. 为什么这么选?
  3. 坑最容易出在哪里?

一、一个真实的报错场景

先看一段后台管理系统里常见的代码:

// 表格操作列有个「删除」按钮
methods: {
  handleDelete(id) {
    this.$confirm('确定删除吗?').then(() => {
      this.deleteApi(id);  // ❌ 报错:Cannot read property 'deleteApi' of undefined
    });
  }
}

很多人会疑惑:我明明在 methods 里写的,this 怎么会是 undefined

问题在于:this 不是由「你在哪写的」决定的,而是由「谁在调用这个函数」决定的。$confirm().then() 里的回调,是 Promise 内部在调用,普通函数不会自动带上 Vue 实例的 this

如果把 .then() 里的回调改成箭头函数,就不会报错了。后面会详细说明原因。

ps·补充回调函数概念(供新手同学参考)

  • 非回调函数:定义后由你直接调用(fn()),执行时机由你决定;

  • 回调函数:把函数作为参数传递给另一个函数,由这个 “接收方函数” 在特定时机(比如异步操作完成、遍历完成)调用它。

// 1. 普通函数:自己定义、自己调用
function normalFn() {
  console.log("我是普通函数,直接调用就执行");
}
// 主动调用普通函数
normalFn(); // 输出:我是普通函数,直接调用就执行

// 2. 回调函数:作为参数传递,由其他函数调用
function callbackFn() {
  console.log("我是回调函数,由forEach调用");
}

// forEach是接收回调的主函数,它会遍历数组时调用回调函数
[1, 2, 3].forEach(callbackFn); // 输出3次:我是回调函数,由forEach调用

// 更常见的写法:匿名回调函数(简化版)
[1, 2, 3].forEach(function(item) {
  console.log("遍历到的元素:", item); // 异步/同步场景都常用
});

// 异步回调(典型场景:定时器)
setTimeout(function() {
  console.log("1秒后执行的回调函数"); // 1秒后才执行,时机由setTimeout决定
}, 1000);

二、基础扫盲:this 到底是谁决定的

核心结论:this 由「调用方式」决定,而不是由「定义位置」决定。

调用方式 this 指向 典型场景
作为对象方法调用 该对象 obj.fn() → this 是 obj
直接调用 fn() 严格模式:undefined;非严格:window 孤立的函数调用
new 调用 新创建的对象 new Foo()
call/apply/bind 传入的第一个参数 显式指定 this
作为回调传入 谁调就指向谁,通常丢 this setTimeout(fn)、Promise.then(fn)

关键点:当函数被当作回调传给别人时,谁调这个函数,this 就由谁决定。 比如 setTimeout(fn) 里,是浏览器在调 fn,所以 this 通常是 windowundefined,而不是你组件里的 this

三、箭头函数 vs 普通函数:本质区别

对比项 普通函数 箭头函数
this 有属于自己的 this,由调用方式决定 没有自己的 this,使用外层作用域的 this
arguments 没有(可用 ...args 替代)
能否 new 可以 不可以
能否作为构造函数 可以 不可以

3.1. this 的区别(案例说明)

普通函数的 this 由调用方式决定,而箭头函数没有自己的 this,会 “继承” 外层作用域的 this

// 案例1:对象方法中的 this
const person = {
  name: "张三",
  // 普通函数作为对象方法
  sayNameNormal: function() {
    console.log("普通函数 this:", this.name); // this 指向调用者 person
  },
  // 箭头函数作为对象方法
  sayNameArrow: () => {
    console.log("箭头函数 this:", this.name); // this 指向外层(全局window),没有name属性
  }
};

person.sayNameNormal(); // 输出:普通函数 this: 张三
person.sayNameArrow();  // 输出:箭头函数 this: undefined

// 案例2:嵌套函数中的 this
const obj = {
  num: 10,
  fn: function() {
    // 普通嵌套函数:this 指向全局(非严格模式)
    setTimeout(function() {
      console.log("普通嵌套函数 this.num:", this.num); // undefined
    }, 0);
    
    // 箭头嵌套函数:this 继承外层 fn 的 this(即obj)
    setTimeout(() => {
      console.log("箭头嵌套函数 this.num:", this.num); // 10
    }, 0);
  }
};

obj.fn();

解释:

  • 普通函数 sayNameNormalperson 调用,this 就指向 person
  • 箭头函数 sayNameArrow 没有自己的 this,直接用外层(全局)的 this,而全局 thiswindow)没有 name 属性,所以是 undefined
  • 嵌套场景中,箭头函数能 “捕获” 外层函数的 this,这也是实际开发中箭头函数最常用的场景(避免手动绑定 this)。

3.2. arguments 的区别(案例说明)

普通函数有 arguments 对象(存储传入的所有参数),箭头函数没有,需用剩余参数 ...args 替代。

// 普通函数:有 arguments
function normalFn() {
  console.log("普通函数 arguments:", arguments); // 输出传入的参数集合
  console.log("第一个参数:", arguments[0]);
}

// 箭头函数:无 arguments,用 ...args 替代
const arrowFn = (...args) => {
  // console.log(arguments); // 直接用会报错:ReferenceError: arguments is not defined
  console.log("箭头函数 args:", args); // 数组形式存储参数
  console.log("第一个参数:", args[0]);
};

normalFn(10, 20, 30); 
// 输出:普通函数 arguments: [Arguments] { '0': 10, '1': 20, '2': 30 }
// 输出:第一个参数: 10

arrowFn(10, 20, 30);
// 输出:箭头函数 args: [ 10, 20, 30 ]
// 输出:第一个参数: 10

解释:

  • arguments 是类数组对象,只能在普通函数中使用;
  • 箭头函数要获取所有参数,必须用 ES6 的剩余参数 ...argsargs 是真正的数组,还能使用 map/filter 等数组方法,比 arguments 更灵活。

3.3. 能否 new / 作为构造函数(案例说明)

普通函数可以用 new 调用(作为构造函数),箭头函数不行,强行 new 会报错。

// 普通函数:可作为构造函数
function Person(name) {
  this.name = name;
}
const p1 = new Person("李四");
console.log("普通函数构造的实例:", p1.name); // 李四

// 箭头函数:不能作为构造函数
const ArrowPerson = (name) => {
  this.name = name;
};
try {
  const p2 = new ArrowPerson("王五"); // 强行new会报错
} catch (e) {
  console.log("箭头函数 new 报错:", e.message); // ArrowPerson is not a constructor
}

解释:

  • 构造函数的核心是 new 操作会创建新对象,并把函数的 this 绑定到这个新对象;
  • 箭头函数没有自己的 this,也没有 prototype 属性(构造函数的必要条件),所以无法作为构造函数使用。
小结:

1. this 指向:普通函数的 this 由调用方式决定,箭头函数继承外层作用域的 this(无自身 this); 2. 参数获取:普通函数用 arguments,箭头函数无 arguments,需用 ...args 剩余参数; 3. 构造函数能力:普通函数可 new 作为构造函数,箭头函数不行,强行 new 会报错。

这三个区别是箭头函数和普通函数最核心的差异,其中 this 指向的区别是实际开发中最常遇到、也最需要注意的点。

四、后台项目里最容易写错的 5 种场景

场景 1:Element UI / Ant Design 表格里的回调

// ❌ 错误写法:箭头函数里加 this.,会从 window 找方法,导致 this 失效
<el-table-column label="操作">
  <template slot-scope="scope">
    <el-button @click="() => this.handleEdit(scope.row)">编辑</el-button>
  </template>
</el-table-column>

// ✅ 最优写法:直接传方法引用,Vue 自动绑定组件实例的 this
<el-button @click="handleEdit(scope.row)">编辑</el-button>

// ⚠️ 能运行但不推荐:箭头函数里不加 this.(Vue 会映射到组件方法),但多一层包装无意义
<el-button @click="(row) => handleEdit(row)">编辑</el-button>

原因:

  • 模板中的事件绑定,Vue 会自动将方法的 this 绑定到组件实例上(而非 window)。 错误写法:箭头函数的 this 固定指向全局上下文(window/undefined),this.handleEdit 会从 window 查找方法(找不到),且方法内部的 this 也会失效;
  • 直接写方法名:Vue 会把模板里的 handleEdit 隐式映射到「组件实例的 methods」,并自动绑定组件的 this,是最安全高效的方式。 结论: 模板事件绑定优先直接写 方法名(参数)(最优解);尽量不要用箭头函数包装(避免多余的函数创建 + 踩 this 坑);若因特殊场景非要用箭头函数,需去掉 this.(如 (row) => handleEdit(row)),但这种写法无必要,仅作为兜底参考。

场景 2:Promise / async 里的 this

// ❌ 错误:.then 里用普通函数,this 丢失
handleSubmit() {
  this.validateForm().then(function(res) {
    this.submitForm();  // this 是 undefined!
  });
}

// ✅ 正确:用箭头函数,继承外层的 this
handleSubmit() {
  this.validateForm().then((res) => {
    this.submitForm();  // this 正确指向组件实例
  });
}

原因: .then() 的回调是 Promise 内部调用的,普通函数不会自动绑定组件 this。用箭头函数可以继承 handleSubmit 所在作用域的 this,即组件实例。

结论:Promiseasync/awaitsetTimeout 等异步回调里,需要访问组件/外层 this 时,用箭头函数。

场景 3:对象方法 / API 封装

// ❌ 错误:箭头函数作为对象方法,this 指向外层(window)
const api = {
  baseUrl: '/api',
  getList: () => {
    return axios.get(this.baseUrl + '/list');  // this.baseUrl 是 undefined!
  }
};

// ✅ 正确:用普通函数
const api = {
  baseUrl: '/api',
  getList() {
    return axios.get(this.baseUrl + '/list');
  }
};

原因: 箭头函数没有自己的 this,会去外层找。这里的 getList 定义在对象字面量里,外层是全局,this 就是 window(或 undefined),自然拿不到 baseUrl

结论: 对象方法、Class 方法需要用到 this 时,用普通函数,不要用箭头函数。

场景 4:事件监听器(addEventListener)

// 场景:监听 window 滚动,组件销毁时需要移除监听

// ❌ 错误:箭头函数每次都是新引用,无法正确 removeEventListener
mounted() {
  window.addEventListener('scroll', () => this.handleScroll());
},
beforeDestroy() {
  window.removeEventListener('scroll', () => this.handleScroll());  // 移除失败!引用不同
}

// ✅ 正确:保存同一个函数引用
mounted() {
  this.boundHandleScroll = this.handleScroll.bind(this);
  window.addEventListener('scroll', this.boundHandleScroll);
},
beforeDestroy() {
  window.removeEventListener('scroll', this.boundHandleScroll);
}

原因: removeEventListener 必须传入和 addEventListener 时完全相同的函数引用。每次写 () => this.handleScroll() 都会生成新函数,所以无法正确移除。

结论: 需要手动移除监听时,用 bind 或普通函数,并把引用存到实例上,保证添加和移除用的是同一个函数。

场景 5:数组方法的回调(forEachmapfilter 等)

// 在 Vue 组件里
methods: {
  processList() {
    const list = [1, 2, 3];
    
    // ❌ 错误:普通函数作为 forEach 回调,this 会丢
    list.forEach(function(item) {
      this.doSomething(item);  // this 是 undefined
    });
    
    // ✅ 正确:箭头函数继承外层的 this
    list.forEach((item) => {
      this.doSomething(item);
    });
  }
}

原因: forEach 等方法的回调是由数组方法内部调用的,普通函数不会绑定组件 this。用箭头函数可以继承 processListthis

结论:forEachmapfilterreduce 等回调里需要访问外层 this 时,用箭头函数;不需要 this 时,两者都可以。

五、决策清单:什么时候用谁

可以按下面几条来选:

  1. 对象方法、Class 方法、构造函数 → 用普通函数。
  2. Promise、setTimeout、数组方法等回调里要访问外层 this → 用箭头函数。
  3. Vue 模板事件 → 直接写方法名,或 (arg) => this.method(arg),避免乱包箭头函数。
  4. 需要 arguments → 用普通函数,或箭头函数 + ...args
  5. addEventListener / removeEventListener → 用 bind 或保存同一引用,保证添加和移除是同一个函数。

六、一句话口诀

  • 普通函数:有「自己的」this,谁调我,this 就指向谁。
  • 箭头函数:没有「自己的」this,用的是「定义时所在作用域」的 this

因此,在需要「继承」外层 this 的场景(例如 PromisesetTimeout 回调),用箭头函数;在对象方法、构造函数等需要「自己的」this 的场景,用普通函数。需要「动态 this」用普通函数,需要「固定外层 this」用箭头函数。

总结

this 和箭头函数本身不复杂,容易出错的是「在错误场景选错写法」。后台项目里,最容易踩坑的就是:Promise 回调对象方法模板事件事件监听器这几处。记住「谁在调用」「外层 this 是谁」,选普通函数还是箭头函数就不容易错。

以上就是本次的学习分享,欢迎大家在评论区讨论指正,与大家共勉。

我是 Eugene,你的电子学友。

如果文章对你有帮助,别忘了点赞、收藏、加关注,你的认可是我持续输出的最大动力~

【React-11/Lesson95(2026-01-04)】React 闭包陷阱详解🎯

作者 Jing_Rainbow
2026年2月14日 23:15

🔍 什么是闭包陷阱

在 React 函数组件开发中,闭包陷阱是一个非常经典且常见的问题。要理解闭包陷阱,我们首先需要理解闭包的形成条件。

闭包的形成条件

闭包的形成通常出现在以下场景:

  • 函数组件嵌套了定时器、事件处理函数等
  • 使用 useEffect 且依赖数组为空
  • 使用 useCallback 缓存函数
  • 词法作用域链的作用

让我们看一个典型的闭包陷阱示例:

function App() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const timer = setInterval(() => {
      console.log('Current count:', count);
    }, 1000);
    return () => {
      clearInterval(timer);
    }
  }, []);
}

在这个例子中,useEffect 的依赖数组是空的,这意味着它只会在组件挂载时执行一次。setInterval 回调函数中引用了 count 变量,由于闭包的特性,这个回调函数会捕获到初始渲染时的 count 值(也就是 0)。即使后续我们通过 setCount 更新了 count 的值,定时器回调中的 count 仍然会保持初始值 0,这就是闭包陷阱!

💡 深入理解 React 的渲染机制

要彻底明白闭包陷阱,我们需要理解 React 函数组件的渲染机制:

React 函数组件的重新渲染

每次组件重新渲染时:

  1. 函数组件会重新执行
  2. useState 返回的状态值是当前最新的值
  3. 所有在组件内部定义的函数、变量都会被重新创建
  4. useEffect 会根据依赖数组决定是否重新执行

闭包的工作原理

闭包是 JavaScript 中的一个核心概念,指的是函数能够记住并访问其词法作用域,即使该函数在其词法作用域之外执行。

在 React 中,每次渲染都会创建一个新的"快照",包含当时的所有状态、props 和函数。当 useEffect 依赖数组为空时,它只在第一次渲染时执行,因此它捕获的是第一次渲染时的闭包,里面的所有变量都是初始值。

🛠️ 解决闭包陷阱的 12 种方案

方案一:将依赖项加入到依赖数组中【推荐】

这是最简单也是最推荐的解决方案。通过将 count 加入到依赖数组中,每当 count 变化时,useEffect 都会重新执行,从而捕获到最新的 count 值。

function App() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const timer = setInterval(() => {
      console.log('Current count:', count);
    }, 1000);
    return () => {
      clearInterval(timer);
    }
  }, [count]);
}

重要提示:不只是组件卸载时才会执行清理函数,每次 effect 重新执行之前,都会先执行上一次的清理函数。这样可以确保不会有多个定时器同时运行。

方案二:使用 useRef 引用变量

useRef 返回的对象在组件的整个生命周期中保持不变,我们可以用它来存储最新的状态值。

function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count;
  useEffect(() => {
    const timer = setInterval(() => {
      console.log('Current count:', countRef.current);
    }, 1000);
    return () => {
      clearInterval(timer);
    }
  }, []);
}

这种方法的优势是 useEffect 不需要重新执行,避免了频繁创建和清理定时器的开销。

方案三:使用 useCallback 缓存函数

useCallback 可以缓存函数,结合 useRef 一起使用:

function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count;
  const logCount = useCallback(() => {
    console.log('Current count:', countRef.current);
  }, []);
  useEffect(() => {
    const timer = setInterval(() => {
      logCount();
    }, 1000);
    return () => {
      clearInterval(timer);
    }
  }, []);
}

方案四:使用 useLayoutEffect 代替 useEffect

useLayoutEffect 在 DOM 更新后同步执行,虽然它不能直接解决闭包问题,但在某些场景下配合其他方法使用会更合适:

function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count;
  const logCount = useCallback(() => {
    console.log('Current count:', countRef.current);
  }, []);
  useLayoutEffect(() => {
    const timer = setInterval(() => {
      logCount();
    }, 1000);
    return () => {
      clearInterval(timer);
    }
  }, []);
}

方案五:使用 useMemo 缓存变量

useMemo 用于缓存计算结果,同样可以配合 useRef 使用:

function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count;
  const logCount = useCallback(() => {
    console.log('Current count:', countRef.current);
  }, []);
  useLayoutEffect(() => {
    const timer = setInterval(() => {
      logCount();
    }, 1000);
    return () => {
      clearInterval(timer);
    }
  }, []);
}

方案六:使用 useReducer 管理状态

useReducer 是另一种状态管理方式,它的 dispatch 函数具有稳定的引用,可以避免闭包问题:

function App() {
  const [count, setCount] = useReducer((state, action) => {
    switch (action.type) {
      case 'increment':
        return state + 1;
      case 'decrement':
        return state - 1;
      default:
        return state;
    }
  }, 0);
  const countRef = useRef(count);
  countRef.current = count;
  const logCount = useCallback(() => {
    console.log('Current count:', countRef.current);
  }, []);
  useLayoutEffect(() => {
    const timer = setInterval(() => {
      logCount();
    }, 1000);
    return () => {
      clearInterval(timer);
    }
  }, []);
}

方案七:使用 useImperativeHandle 暴露方法

useImperativeHandle 用于自定义暴露给父组件的 ref 实例值:

function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count;
  const logCount = useCallback(() => {
    console.log('Current count:', countRef.current);
  }, []);
  useLayoutEffect(() => {
    const timer = setInterval(() => {
      logCount();
    }, 1000);
    return () => {
      clearInterval(timer);
    }
  }, []);
}

方案八:使用 useContext 传递状态

useContext 可以跨组件传递状态,避免 prop drilling:

function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count;
  const logCount = useCallback(() => {
    console.log('Current count:', countRef.current);
  }, []);
  useLayoutEffect(() => {
    const timer = setInterval(() => {
      logCount();
    }, 1000);
    return () => {
      clearInterval(timer);
    }
  }, []);
}

方案九:使用 useDebugValue 调试状态

useDebugValue 用于在 React DevTools 中显示自定义 Hook 的标签:

function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count;
  const logCount = useCallback(() => {
    console.log('Current count:', countRef.current);
  }, []);
  useLayoutEffect(() => {
    const timer = setInterval(() => {
      logCount();
    }, 1000);
    return () => {
      clearInterval(timer);
    }
  }, []);
}

方案十:使用 useTransition 处理异步更新

useTransition 是 React 18 引入的 Hook,用于标记非紧急更新:

function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count;
  const logCount = useCallback(() => {
    console.log('Current count:', countRef.current);
  }, []);
  useLayoutEffect(() => {
    const timer = setInterval(() => {
      logCount();
    }, 1000);
    return () => {
      clearInterval(timer);
    }
  }, []);
}

方案十一:使用 useDeferredValue 处理异步更新

useDeferredValue 也是 React 18 引入的 Hook,用于延迟更新某些值:

function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count;
  const logCount = useCallback(() => {
    console.log('Current count:', countRef.current);
  }, []);
  useLayoutEffect(() => {
    const timer = setInterval(() => {
      logCount();
    }, 1000);
    return () => {
      clearInterval(timer);
    }
  }, []);
}

方案十二:使用 useLayoutEffect 处理同步更新

useLayoutEffect 在 DOM 更新后同步执行,可以用于处理需要立即反映到 DOM 上的操作:

function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count;
  const logCount = useCallback(() => {
    console.log('Current count:', countRef.current);
  }, []);
  useLayoutEffect(() => {
    const timer = setInterval(() => {
      logCount();
    }, 1000);
    return () => {
      clearInterval(timer);
    }
  }, []);
}

📝 实际应用场景

闭包陷阱不仅仅出现在定时器中,还可能出现在以下场景:

  1. 事件处理函数:在 useEffect 中添加事件监听器
  2. 异步请求:在 useEffect 中发起网络请求
  3. 动画:使用 requestAnimationFrame 等 API
  4. WebSocket:建立长连接
  5. 防抖节流函数:在组件中使用防抖或节流

🎓 最佳实践建议

  1. 优先使用依赖数组:这是最直观、最符合 React 设计理念的方案
  2. 合理使用 useRef:当不需要频繁重新执行 effect 时,useRef 是很好的选择
  3. 理解清理函数的重要性:始终正确清理定时器、事件监听器等资源
  4. 使用 ESLint 插件eslint-plugin-react-hooks 可以帮助你发现遗漏的依赖项

希望这篇文章能帮助你彻底理解 React 闭包陷阱!🎉

为什么Django这么慢,却还是Python后端第一梯队呢?

作者 Sheffield
2026年2月14日 22:59

学习Web框架的Python玩家大多应该都听过:Django 性能不行”、“高并发场景根本用不了 Django”。但有趣的是,在TIOBE、PyPI下载量、企业技术栈选型中,Django始终稳居Python后端框架第一梯队,甚至是很多公司的首选。

这背后的矛盾,恰恰折射出工业级开发的核心逻辑:性能从来不是唯一的衡量标准,生产力和工程化能力才是

一、先澄清:Django 的“慢”,到底慢在哪?

首先要纠正一个认知偏差:Django的 “慢” 是相对的,而非绝对的。

1. 所谓“慢”的本质

Django被吐槽“慢”,主要集中在这几个点:

  • 全栈特性的代价:Django是“电池已内置”的全栈框架,ORM、表单验证、认证授权、Admin后台、缓存、国际化等功能开箱即用,这些内置组件会带来一定的性能开销,对比Flask、FastAPI这类轻量框架,纯接口响应速度确实稍慢(基准测试中,简单接口QPS约为FastAPI的1/3-1/2)。
  • 同步 IO 的天然限制:Django默认是同步架构,在高并发IO密集型场景(如大量请求等待数据库/第三方接口响应)下,线程/进程池容易被打满,吞吐量受限。
  • ORM 的 “便利税” :自动生成的SQL可能不够优化,新手容易写出N+1查询,进一步放大性能问题。

2. 但这些“慢”,大多是“伪问题”

绝大多数业务场景下,Django的性能完全够用:

  • 普通中小网站(日活10万以内):Django+合理缓存+数据库优化,能轻松支撑业务,性能瓶颈根本不在框架本身。
  • 所谓 “慢” 的对比场景:大多是“裸框架接口跑分”,而真实业务中,接口响应时间的80%以上消耗在数据库、缓存、网络IO上,框架本身的耗时占比不足5%。
  • 性能可优化空间大:通过异步改造(Django 3.2+原生支持ASGI)、缓存层(Redis)、数据库读写分离、CDN、Gunicorn+Nginx部署等方式,完全能把Django的性能提升到满足中高并发的水平。

二、Django能稳居第一梯队,核心是“降本增效”

企业选框架,本质是选“性价比”——开发效率、维护成本、团队协作成本,远比单点性能重要。而这正是Django的核心优势。

1. 极致的开发效率:“开箱即用” 的工业级体验

Django的设计哲学是 “不要重复造轮子”,一个命令就能生成完整的项目骨架,几行代码就能实现核心功能:

# 5行代码实现带权限的REST接口(Django+DRF)
from rest_framework import viewsets
from .models import Article
from .serializers import ArticleSerializer

class ArticleViewSet(viewsets.ModelViewSet):
    queryset = Article.objects.all()
    serializer_class = ArticleSerializer
    permission_classes = [IsAuthenticated]
  • 内置Admin后台:无需写一行前端代码,就能实现数据的增删改查,调试和运营效率拉满。
  • 完善的认证授权:Session、Token、OAuth2等认证方式开箱即用,不用自己造权限轮子。
  • 表单验证&CSRF防护:自动处理表单校验、跨站请求伪造,减少安全漏洞。
  • ORM的价值:虽然有性能损耗,但大幅降低了数据库操作的学习成本和出错概率,新手也能快速写出安全的数据库逻辑。

对于创业公司或快速迭代的业务,“快上线、少踩坑”比“多10%的性能” 重要得多——Django能让团队用最少的人力,在最短时间内搭建起稳定的业务系统。

2. 成熟的工程化体系:适合团队协作

个人项目可以用Flask自由发挥,但团队项目需要“规范”。Django的“约定优于配置”理念,强制规范了项目结构、代码组织、数据库迁移等流程:

  • 统一的项目结构:新人接手项目,不用花时间理解自定义的目录结构,直接就能上手。
  • 内置的数据库迁移工具:makemigrations/migrate 完美解决数据库版本管理问题,避免团队协作中的数据结构混乱。
  • 丰富的中间件和扩展生态:缓存中间件、跨域中间件、日志中间件等开箱即用,DRF(Django REST Framework)、Celery、Django Channels等扩展几乎能覆盖所有业务场景。
  • 完善的文档和社区:官方文档堪称 “教科书级别”,遇到问题能快速找到解决方案,招聘时也更容易找到有经验的开发者。

3. 稳定可靠:经得起生产环境的考验

Django 诞生于2005年,经过近20年的迭代,已经成为一个极其稳定的框架:

  • 长期支持版本(LTS):每2-3年发布一个LTS版本,提供3年以上的安全更新和bug修复,企业不用频繁升级框架。
  • 安全特性完善:自动防御XSS、CSRF、SQL注入等常见攻击,官方会及时修复安全漏洞,这对企业来说是“刚需”。
  • 大量知名案例背书:Instagram、Pinterest、Mozilla、Spotify、国内的知乎(早期)、豆瓣等,都在用 Django支撑核心业务——这些产品的规模,足以证明Django的可靠性。

三、Django 的 “破局之路”:性能短板正在被补齐

面对性能吐槽,Django团队也一直在迭代优化:

  • 异步支持:Django 3.0引入ASGI,3.2+完善异步视图、异步ORM,能直接对接WebSocket、长连接,IO密集型场景的并发能力大幅提升。
  • 性能优化:新版本持续优化ORM、模板引擎、中间件,减少不必要的开销,比如Django 4.0+的ORM支持批量更新/插入,性能提升显著。
  • 生态适配:可以和FastAPI混合部署(比如核心接口用FastAPI,管理后台用Django),兼顾性能和生产力;也可以通过Gunicorn+Uvicorn+异步工作进程,充分利用多核CPU。

四、总结:选框架,本质是选 “适配性”

Django的 “慢”,是为 “全栈、工程化、生产力” 付出的合理代价;而它能稳居第一梯队,核心原因是:

  1. 匹配绝大多数业务场景:90%的中小业务不需要 “极限性能”,但都需要 “快速开发、稳定运行、易维护”。
  2. 降低团队成本:统一的规范、丰富的内置功能、完善的文档,能大幅降低招聘、培训、协作成本。
  3. 生态和稳定性兜底:成熟的生态能解决几乎所有业务问题,长期支持版本让企业不用频繁重构。

最后想说:框架没有好坏,只有适配与否。如果是做高并发的API服务(如直播、秒杀),FastAPI/Tornado 可能更合适;但如果是做内容管理、电商、企业后台等需要快速落地、长期维护的业务,Django依然是Python后端的最优解之一。

这也是为什么,即便有层出不穷的新框架,Django依然能稳坐第一梯队——因为它抓住了工业级开发的核心:让开发者把精力放在业务上,而非重复造轮子

《React 受控组件 vs 非受控组件:一篇吃透表单处理精髓》

作者 随逸177
2026年2月14日 21:09

React 受控组件 vs 非受控组件:一篇吃透表单处理精髓

在 React 开发中,表单处理是高频场景——登录注册、评论提交、信息录入,几乎每个项目都会用到。但很多新手都会困惑:同样是获取表单输入值,为什么有的用 useState,有的用 useRef?这其实对应了 React 表单处理的两种核心方式:受控组件非受控组件

很多人分不清两者的区别,盲目使用导致表单出现“无法输入”“值获取不到”“性能冗余”等问题。本文将从「核心疑问出发」,拆解两者的定义、用法、区别,结合实战代码演示,帮你彻底搞懂什么时候用受控、什么时候用非受控,看完直接落地项目。

一、核心疑问:怎么拿到 React 表单的值?

原生 HTML 中,我们可以通过 DOM 直接获取表单元素的值,比如 document.querySelector('input').value。但 React 遵循“单向数据流”原则,不推荐直接操作 DOM,因此提供了两种更规范的方式获取表单值,对应两种组件类型。

先看一个最基础的示例,直观感受两者的差异:

import { useState, useRef } from 'react';

export default function App() {
  // 受控组件:用状态控制输入框
  const [value, setValue] = useState("")
  // 非受控组件:用 ref 获取 DOM 值
  const inputRef = useRef(null);

  // 表单提交逻辑
  const doLogin = (e) => {
    e.preventDefault(); // 阻止页面刷新
    console.log("非受控输入值:", inputRef.current.value); // 非受控获取值
    console.log("受控输入值:", value); // 受控获取值
  }

  return (
    <form onSubmit={
      {/* 受控输入框:value 绑定状态,onChange 更新状态 */}
      <input 
        type="text" 
        value={) => setValue(e.target.value)} 
        placeholder="受控输入框"
      />
      {/* 非受控输入框:ref 关联 DOM,无需绑定状态 */}
      <input 
        type="text" 
        ref={受控输入框"
        style={{ marginLeft: '10px' }}
      />
      <button type="submit" style={提交
  )
}

上面的代码中,两个输入框分别对应受控和非受控两种方式,核心差异在于「值的控制者」不同——一个由 React 状态控制,一个由 DOM 原生控制。

二、逐字拆解:什么是受控组件?

1. 核心定义

受控组件:表单元素的值由 React 状态(useState)完全控制,输入框的显示值 = 状态值,输入行为通过 onChange 事件更新状态,从而实现“状态 ↔ 输入框”的联动。

核心逻辑:状态驱动 DOM,符合 React 单向数据流原则——数据从状态流向 DOM,DOM 输入行为通过事件反馈给状态,形成闭环。

2. 核心用法(必记)

实现一个受控组件,必须满足两个条件:

  • 给表单元素绑定 value={状态值},让状态决定输入框显示内容;
  • 绑定 onChange 事件,通过 e.target.value 获取输入值,调用 setState 更新状态。

3. 实战:多字段受控表单(登录注册场景)

实际开发中,表单往往有多个字段(如用户名、密码),此时可以用一个对象状态管理所有字段,配合事件委托简化代码:

import { useState } from "react"

export default function LoginForm() {
  // 用对象状态管理多个表单字段
  const [form, setForm] = useState({
    username: "",
    password: ""
  });

  // 统一处理所有输入框的变化
  const handleChange = (e) => {
    // 解构事件目标的 name 和 value(输入框需设置 name 属性)
    const { name, value } = e.target;
    // 更新状态:保留原有字段,修改当前输入字段(不可直接修改原对象)
    setForm({
      ...form, // 展开原有表单数据
      [name]: value // 动态更新对应字段
    })
  }

  // 表单提交
  const handleSubmit = (e) => {
    e.preventDefault();
    // 直接从状态中获取所有表单值,无需操作 DOM
    console.log("表单数据:", form);
    // 实际开发中:这里可做表单校验、接口请求等逻辑
  }

  return (
    <form onSubmit={<div style={<input 
          type="text" 
          placeholder="请输入用户名" 
          name="username" /Change}
          value={form.username} // 绑定状态值
          style={{ padding: '6px' }}
        />
      <div style={>
        <input 
          type="password" 
          placeholder="请输入密码" 
          name="password" / 绑定状态值
          style={{ padding: '6px' }}
        />
      <button type="submit" style={注册
  )
}

4. 受控组件的关键细节

  • ⚠️ 只写 value={状态} 不写 onChange,输入框会变成「只读」——因为状态无法更新,输入框值永远固定;
  • 状态更新是异步的,但不影响表单输入(React 会批量处理状态更新,保证输入流畅);
  • 适合做「实时操作」:比如实时表单校验、输入内容实时展示、表单字段联动(如密码强度提示)。

三、逐字拆解:什么是非受控组件?

1. 核心定义

非受控组件:表单元素的值由 DOM 原生控制,React 不干预输入过程,而是通过 useRef 获取 DOM 元素,再读取其 current.value 获取输入值。

核心逻辑:DOM 驱动数据,和原生 HTML 表单逻辑一致,React 只做“被动获取”,不主动控制输入值。

2. 核心用法(必记)

实现一个非受控组件,只需一步:

  • useRef(null) 创建 Ref 对象,绑定到表单元素的 ref 属性;
  • 需要获取值时,通过 ref.current.value 读取(通常在提交、点击等事件中获取)。

可选:用 defaultValue 设置初始值(仅首次渲染生效,后续修改不影响)。

3. 实战:非受控评论框(一次性提交场景)

评论框、搜索框等“一次性提交”场景,无需实时监控输入,用非受控组件更简洁高效:

import { useRef } from 'react';

export default function CommentBox() {
  // 创建 Ref 对象,关联 textarea 元素
  const textareaRef = useRef(null);

  // 提交评论逻辑
  const handleSubmit = () => {
    // 防御性判断:避免 ref.current 为 null(极端场景)
    if (!textareaRef.current) return;
    // 获取输入值
    const comment = textareaRef.current.value.trim();
    // 表单校验
    if (!comment) return alert('请输入评论内容!');
    // 提交逻辑
    console.log("评论内容:", comment);
    // 提交后清空输入框(直接操作 DOM)
    textareaRef.current.value = "";
  }

  return (
    <div style={<textarea 
        ref={        placeholder="输入评论..."
        style={{ width: '300px', height: '100px', padding: '10px' }}
        defaultValue="请输入你的看法..." // 初始值(可选)
      />
      <button 
        onClick={={{ padding: '6px 16px', marginTop: '10px' }}
      >
        提交评论
      
  )
}

4. 非受控组件的关键细节

  • ⚠️ 不要用 value 绑定状态(否则会变成受控组件),初始值用 defaultValue
  • Ref 对象的 current 在组件首次渲染后才会指向 DOM,因此不能在组件渲染时直接读取 textareaRef.current.value(会报错);
  • 适合做「一次性操作」:比如文件上传( 必须用非受控)、简单搜索框、一次性提交的表单。

四、核心对比:受控组件 vs 非受控组件(必背)

很多人纠结“该用哪个”,其实核心看「是否需要实时控制输入」,用表格清晰对比两者差异,一目了然:

对比维度 受控组件 非受控组件
值的控制者 React 状态(useState) DOM 原生控制
核心依赖 useState + onChange useRef
值的获取方式 直接读取状态(如 form.username) ref.current.value
初始值设置 useState 初始值(如 useState("")) defaultValue 属性
是否触发重渲染 输入时触发(onChange 更新状态) 输入时不触发(无状态变化)
适用场景 实时校验、表单联动、实时展示 一次性提交、文件上传、性能敏感场景
优点 可实时控制,符合 React 单向数据流,易维护 简洁高效,无需频繁更新状态,性能更好
缺点 频繁触发重渲染,代码量稍多 无法实时控制,需手动操作 DOM,不易做联动

五、实战总结:什么时候该用哪个?(重点)

不用死记硬背,记住两个核心原则,就能快速判断:

1. 优先用受控组件的情况

  • 表单需要「实时校验」(如用户名长度限制、密码强度提示);
  • 表单字段需要「联动」(如勾选“记住密码”才显示“密码确认”);
  • 需要「实时展示输入内容」(如输入时同步显示剩余字符数);
  • 表单数据需要和其他组件共享、联动(如跨组件传递表单值)。

2. 优先用非受控组件的情况

  • 表单是「一次性提交」(如评论、搜索,无需实时监控);
  • 需要处理「文件上传」( 是天然的非受控组件,无法用状态控制);
  • 追求「性能优化」(避免频繁的状态更新和组件重渲染);
  • 简单表单(如单个输入框,无需复杂逻辑)。

3. 避坑提醒

  • 不要混合使用:同一个表单元素,不要既绑定 value 又绑定 ref,会导致逻辑混乱;
  • 非受控组件必做防御:获取值时,先判断 ref.current 是否存在,避免报错;
  • 多字段表单优先受控:用对象状态管理,代码更规范、易维护。

六、最终总结

受控组件和非受控组件没有“谁更好”,只有“谁更合适”:

✅ 受控组件是 React 表单处理的「主流方式」,符合单向数据流,适合复杂表单、需要实时控制的场景;

✅ 非受控组件更「简洁高效」,贴近原生 HTML,适合简单场景、性能敏感场景和文件上传;

记住:判断的核心是「是否需要实时控制输入值」。掌握两者的用法和区别,就能轻松应对 React 中的所有表单场景,写出简洁、高效、可维护的代码。

❌
❌