普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月16日掘金 前端

JavaScript 防抖与节流进阶:从原理到实战

作者 wuhen_n
2026年2月16日 06:12

当用户疯狂点击按钮、疯狂滚动页面、疯狂输入搜索关键词时,应用还能流畅运行吗?防抖(Debounce)和节流(Throttle)是应对高频事件的终极武器。本文将从源码层面深入理解它们的差异,并实现一个支持立即执行、延迟执行、取消功能、记录参数的完整版本。

前言:高频事件带来的挑战

我们先来看一些简单的场景:

window.addEventListener('resize', () => {
    // 窗口大小改变时重新计算布局
    recalcLayout(); // 一秒可能触发几十次!
});

searchInput.addEventListener('input', () => {
    // 用户每输入一个字符就发起搜索请求
    fetchSearchResults(input.value); // 浪费大量请求!
});

window.addEventListener('scroll', () => {
    // 滚动时加载更多数据
    loadMoreData(); // 滚动一下触发几十次!
});

在这些场景中,当事件触发频率远高于我们需要的处理频率,就会出现卡顿、闪屏等现象,这就是防抖和节流要解决的核心问题。

理解防抖与节流的本质差异

核心概念对比

类型 防抖 节流
概念 将多次高频操作合并为一次,仅在最后一次操作后的延迟时间到达时执行 保证在单位时间内只执行一次,稀释执行频率
场景示例 电梯关门:等最后一个人进来后才关门,中间如果有人进来就重新计时 地铁安检:无论多少人排队,每秒钟只能通过一个人
执行次数 只执行最后一次 定期执行,不保证最后一次
频率 N次高频调用 → 1次执行 N次高频调用 → N/间隔时间次执行

适用场景对比

防抖场景

  • 搜索框输入(用户停止输入后才搜索)
  • 窗口大小调整(窗口调整完成后重新计算)
  • 表单验证(用户输完才验证)
  • 自动保存(停止编辑后保存)
  • 按钮防连点(避免重复提交)

节流场景

  • 滚动加载更多(滚动过程中定期检查)
  • 动画帧(控制动画执行频率)
  • 游戏循环(固定帧率)
  • 鼠标移动事件(实时位置但不过度频繁)
  • DOM元素拖拽(平滑移动)

防抖函数实现

基础防抖实现

function debounce(fn, delay) {
  let timer = null;

  return function (...args) {
    // 每次调用都清除之前的定时器
    if (timer) {
      clearTimeout(timer);
    }

    // 设置新的定时器
    timer = setTimeout(() => {
      fn.apply(this, args);
      timer = null;
    }, delay);
  };
}

支持立即执行的防抖

function debounceEnhanced(fn, delay, immediate = false) {
  let timer = null;
  let lastContext = null;
  let lastArgs = null;
  let lastResult = null;
  let callCount = 0;

  return function (...args) {
    lastContext = this;
    lastArgs = args;

    // 第一次调用且需要立即执行
    if (immediate && !timer) {
      lastResult = fn.apply(lastContext, lastArgs);
      callCount++;
      console.log(`立即执行 (调用 #${callCount})`);
    }

    // 清除之前的定时器
    if (timer) {
      clearTimeout(timer);
    }

    // 设置延迟执行
    timer = setTimeout(() => {
      // 如果不是立即执行模式,或者已经执行过立即执行
      if (!immediate) {
        lastResult = fn.apply(lastContext, lastArgs);
        callCount++;
        console.log(`延迟执行 (调用 #${callCount})`);
      }

      // 清理
      timer = null;
      lastContext = null;
      lastArgs = null;
    }, delay);

    return lastResult;
  };
}

完整版防抖(支持取消、取消、参数记录)

class DebouncedFunction {
  constructor(fn, delay, options = {}) {
    this.fn = fn;
    this.delay = delay;
    this.immediate = options.immediate || false;
    this.maxWait = options.maxWait || null;

    this.timer = null;
    this.lastArgs = null;
    this.lastContext = null;
    this.lastResult = null;
    this.lastCallTime = null;
    this.lastInvokeTime = null;

    // 参数历史记录
    this.history = [];
    this.maxHistory = options.maxHistory || 10;

    // 调用次数统计
    this.stats = {
      callCount: 0,
      invokedCount: 0,
      canceledCount: 0
    };
  }

  /**
   * 执行函数
   */
  _invoke() {
    const time = Date.now();
    this.stats.invokedCount++;
    this.lastInvokeTime = time;

    // 记录参数历史
    if (this.lastArgs) {
      this.history.push({
        args: [...this.lastArgs],
        timestamp: time,
        type: this.timer ? 'delayed' : 'immediate'
      });

      // 限制历史记录数量
      if (this.history.length > this.maxHistory) {
        this.history.shift();
      }
    }

    // 执行原函数
    this.lastResult = this.fn.apply(this.lastContext, this.lastArgs);

    // 清理
    this.lastArgs = null;
    this.lastContext = null;

    return this.lastResult;
  }

  /**
   * 调用防抖函数
   */
  call(...args) {
    const now = Date.now();
    this.stats.callCount++;
    this.lastArgs = args;
    this.lastContext = this;
    this.lastCallTime = now;

    // 立即执行模式处理
    if (this.immediate && !this.timer) {
      this._invoke();
    }

    // 清除现有定时器
    if (this.timer) {
      clearTimeout(this.timer);
    }

    // 最大等待时间处理
    if (this.maxWait && this.lastInvokeTime) {
      const timeSinceLastInvoke = now - this.lastInvokeTime;
      if (timeSinceLastInvoke >= this.maxWait) {
        this._invoke();
        return this.lastResult;
      }
    }

    // 设置新的定时器
    this.timer = setTimeout(() => {
      // 非立即执行模式,或者已经执行过立即执行
      if (!this.immediate) {
        this._invoke();
      }
      this.timer = null;
    }, this.delay);

    return this.lastResult;
  }

  /**
   * 取消当前待执行的防抖
   */
  cancel() {
    if (this.timer) {
      clearTimeout(this.timer);
      this.timer = null;
      this.stats.canceledCount++;
    }

    this.lastArgs = null;
    this.lastContext = null;
  }

  /**
   * 立即执行并取消后续
   */
  flush() {
    if (this.lastArgs) {
      this._invoke();
      this.cancel();
    }
    return this.lastResult;
  }

  /**
   * 判断是否有待执行的任务
   */
  pending() {
    return this.timer !== null;
  }

  /**
   * 获取调用历史
   */
  getHistory() {
    return [...this.history];
  }

  /**
   * 获取统计信息
   */
  getStats() {
    return { ...this.stats };
  }

  /**
   * 重置状态
   */
  reset() {
    this.cancel();
    this.history = [];
    this.stats = {
      callCount: 0,
      invokedCount: 0,
      canceledCount: 0
    };
    this.lastResult = null;
    this.lastCallTime = null;
    this.lastInvokeTime = null;
  }
}

节流函数实现

基础节流实现

function throttleTimer(fn, interval) {
  let timer = null;

  return function (...args) {
    if (!timer) {
      timer = setTimeout(() => {
        fn.apply(this, args);
        timer = null;
      }, interval);
    }
  };
}

完整版节流(支持首尾执行)

class ThrottledFunction {
  constructor(fn, interval, options = {}) {
    this.fn = fn;
    this.interval = interval;
    this.leading = options.leading !== false; // 是否立即执行
    this.trailing = options.trailing !== false; // 是否最后执行

    this.timer = null;
    this.lastArgs = null;
    this.lastContext = null;
    this.lastResult = null;
    this.lastInvokeTime = 0;

    // 参数历史
    this.history = [];
    this.maxHistory = options.maxHistory || 10;

    // 统计信息
    this.stats = {
      callCount: 0,
      invokedCount: 0,
      throttledCount: 0
    };
  }

  /**
   * 执行函数
   */
  _invoke() {
    const now = Date.now();
    this.lastInvokeTime = now;
    this.stats.invokedCount++;

    // 记录历史
    if (this.lastArgs) {
      this.history.push({
        args: [...this.lastArgs],
        timestamp: now,
        type: 'executed'
      });

      if (this.history.length > this.maxHistory) {
        this.history.shift();
      }
    }

    // 执行函数
    this.lastResult = this.fn.apply(this.lastContext, this.lastArgs);
    this.lastArgs = null;
    this.lastContext = null;
  }

  /**
   * 调用节流函数
   */
  call(...args) {
    const now = Date.now();
    this.stats.callCount++;
    this.lastArgs = args;
    this.lastContext = this;

    // 检查是否在节流期内
    const timeSinceLastInvoke = now - this.lastInvokeTime;
    const isThrottled = timeSinceLastInvoke < this.interval;

    if (isThrottled) {
      this.stats.throttledCount++;

      // 如果需要尾部执行
      if (this.trailing) {
        // 清除现有的尾部执行定时器
        if (this.timer) {
          clearTimeout(this.timer);
        }

        // 设置尾部执行定时器
        const remainingTime = this.interval - timeSinceLastInvoke;
        this.timer = setTimeout(() => {
          if (this.lastArgs) {
            this._invoke();
          }
          this.timer = null;
        }, remainingTime);
      }

      return this.lastResult;
    }

    // 不在节流期内
    if (this.leading) {
      // 头部执行
      this._invoke();
    } else if (this.trailing) {
      // 延迟执行
      if (this.timer) {
        clearTimeout(this.timer);
      }
      this.timer = setTimeout(() => {
        if (this.lastArgs) {
          this._invoke();
        }
        this.timer = null;
      }, this.interval);
    }

    return this.lastResult;
  }

  /**
   * 取消尾部执行
   */
  cancel() {
    if (this.timer) {
      clearTimeout(this.timer);
      this.timer = null;
    }
    this.lastArgs = null;
    this.lastContext = null;
  }

  /**
   * 立即执行并取消尾部执行
   */
  flush() {
    if (this.lastArgs) {
      this._invoke();
      this.cancel();
    }
    return this.lastResult;
  }

  /**
   * 判断是否有尾部待执行
   */
  pending() {
    return this.timer !== null;
  }

  /**
   * 获取历史记录
   */
  getHistory() {
    return [...this.history];
  }

  /**
   * 获取统计信息
   */
  getStats() {
    return { ...this.stats };
  }

  /**
   * 重置状态
   */
  reset() {
    this.cancel();
    this.history = [];
    this.stats = {
      callCount: 0,
      invokedCount: 0,
      throttledCount: 0
    };
    this.lastInvokeTime = 0;
    this.lastResult = null;
  }
}

进阶实现与组合优化

支持最大等待时间的防抖

支持最大等待时间的防抖,就是确保函数至少每隔 maxWait 时间执行一次:

function debounceMaxWait(fn, delay, maxWait) {
  let timer = null;
  let lastArgs = null;
  let lastContext = null;
  let lastInvokeTime = null;
  let maxTimer = null;

  const invoke = () => {
    lastInvokeTime = Date.now();
    fn.apply(lastContext, lastArgs);
    lastArgs = null;
    lastContext = null;
  };

  const startMaxWaitTimer = () => {
    if (maxTimer) clearTimeout(maxTimer);

    maxTimer = setTimeout(() => {
      if (lastArgs) {
        console.log('达到最大等待时间,强制执行');
        invoke();
      }
    }, maxWait);
  };

  return function (...args) {
    lastArgs = args;
    lastContext = this;

    // 清除现有延迟定时器
    if (timer) {
      clearTimeout(timer);
    }

    // 设置最大等待时间定时器
    if (maxWait && !lastInvokeTime) {
      startMaxWaitTimer();
    }

    // 设置新的延迟定时器
    timer = setTimeout(() => {
      invoke();
      timer = null;

      if (maxTimer) {
        clearTimeout(maxTimer);
        maxTimer = null;
      }
    }, delay);
  };
}

动态调整延迟时间的防抖

根据调用频率动态调整等待时间:

function debounceAdaptive(fn, baseDelay, options = {}) {
  const {
    minDelay = 100,
    maxDelay = 1000,
    factor = 0.8
  } = options;

  let timer = null;
  let lastArgs = null;
  let lastContext = null;
  let callTimes = [];
  let currentDelay = baseDelay;

  const calculateDelay = () => {
    // 计算最近1秒内的调用频率
    const now = Date.now();
    callTimes = callTimes.filter(t => now - t < 1000);
    const frequency = callTimes.length;

    // 根据频率调整延迟
    if (frequency > 10) {
      // 高频调用,增加延迟
      currentDelay = Math.min(currentDelay * (1 + frequency / 100), maxDelay);
    } else if (frequency < 2) {
      // 低频调用,减少延迟
      currentDelay = Math.max(currentDelay * factor, minDelay);
    }

    return currentDelay;
  };

  return function (...args) {
    callTimes.push(Date.now());
    lastArgs = args;
    lastContext = this;

    if (timer) {
      clearTimeout(timer);
    }

    const delay = calculateDelay();
    console.log(`  当前延迟: ${Math.round(delay)}ms (调用频率: ${callTimes.length}/秒)`);

    timer = setTimeout(() => {
      fn.apply(lastContext, lastArgs);
      timer = null;
    }, delay);
  };
}

实际应用场景实战

搜索框自动补全

class SearchAutoComplete {
  constructor(options = {}) {
    this.searchAPI = options.searchAPI || this.mockSearchAPI;
    this.minLength = options.minLength || 2;
    this.debounceDelay = options.debounceDelay || 300;
    this.maxResults = options.maxResults || 10;
    this.cacheResults = options.cacheResults !== false;

    // 搜索缓存
    this.cache = new Map();

    // 创建防抖搜索函数
    this.debouncedSearch = this.createDebouncedSearch();

    // 请求计数器
    this.requestCount = 0;
    this.cacheHitCount = 0;
  }

  /**
   * 模拟搜索API
   */
  async mockSearchAPI(query) {
    // 模拟网络延迟
    await new Promise(resolve => setTimeout(resolve, 200));

    // 模拟搜索结果
    const results = [];
    const prefixes = ['apple', 'banana', 'orange', 'grape', 'watermelon'];

    for (let i = 1; i <= 5; i++) {
      results.push({
        id: i,
        text: `${query} 结果 ${i}`,
        category: prefixes[i % prefixes.length]
      });
    }

    return results;
  }

  /**
   * 创建防抖搜索函数
   */
  createDebouncedSearch() {
    const searchFn = async (query) => {
      // 检查缓存
      if (this.cacheResults && this.cache.has(query)) {
        this.cacheHitCount++;
        return this.cache.get(query);
      }

      // 执行真实搜索
      this.requestCount++;
      console.log(`  🌐 [请求#${this.requestCount}] "${query}"`);

      try {
        const results = await this.searchAPI(query);

        // 存入缓存
        if (this.cacheResults) {
          this.cache.set(query, results);

          // 限制缓存大小
          if (this.cache.size > 50) {
            const oldestKey = this.cache.keys().next().value;
            this.cache.delete(oldestKey);
          }
        }

        return results;
      } catch (error) {
        console.error(`搜索失败: ${query}`, error);
        return [];
      }
    };

    // 使用完整版防抖
    return debounceComplete(searchFn, this.debounceDelay, {
      immediate: false,
      maxWait: 1000
    });
  }

  /**
   * 用户输入处理
   */
  onInput(query) {

    // 忽略空查询
    if (!query || query.length < this.minLength) {
      console.log('  查询太短,忽略');
      return Promise.resolve([]);
    }

    // 执行防抖搜索
    return this.debouncedSearch(query)
      .then(results => {
        const limited = results.slice(0, this.maxResults);
        console.log(`返回 ${limited.length} 条结果`);
        this.renderResults(limited);
        return limited;
      })
      .catch(error => {
        console.error('搜索失败:', error);
        return [];
      });
  }

  /**
   * 渲染搜索结果
   */
  renderResults(results) {
    // 实际项目中这里会更新DOM
    console.log(' 搜索结果:');
    results.slice(0, 3).forEach((result, i) => {
      console.log(`    ${i + 1}. ${result.text}`);
    });
    if (results.length > 3) {
      console.log(`... 等 ${results.length} 条`);
    }
  }

  /**
   * 清空缓存
   */
  clearCache() {
    this.cache.clear();
    this.cacheHitCount = 0;
    console.log('搜索缓存已清空');
  }

  /**
   * 获取统计信息
   */
  getStats() {
    return {
      requestCount: this.requestCount,
      cacheHitCount: this.cacheHitCount,
      cacheSize: this.cache.size,
      pending: this.debouncedSearch.pending(),
      debounceStats: this.debouncedSearch.getStats?.()
    };
  }
}

无限滚动加载

console.log('\n=== 无限滚动加载 ===\n');

class InfiniteScroll {
  constructor(options = {}) {
    this.loadMoreAPI = options.loadMoreAPI || this.mockLoadMoreAPI;
    this.throttleInterval = options.throttleInterval || 200;
    this.threshold = options.threshold || 200;
    this.pageSize = options.pageSize || 20;

    this.currentPage = 0;
    this.hasMore = true;
    this.isLoading = false;
    this.items = [];

    // 创建节流滚动处理函数
    this.throttledScroll = this.createThrottledScroll();

    // 记录最后一次滚动位置
    this.lastScrollPosition = 0;
    this.scrollHistory = [];
  }

  /**
   * 模拟加载更多数据
   */
  async mockLoadMoreAPI(page, pageSize) {
    // 模拟网络延迟
    await new Promise(resolve => setTimeout(resolve, 300));

    // 模拟数据
    const start = page * pageSize;
    const items = [];

    for (let i = 0; i < pageSize; i++) {
      items.push({
        id: start + i,
        title: `项目 ${start + i}`,
        content: `这是第 ${start + i} 个项目的内容`,
        timestamp: Date.now()
      });
    }

    // 模拟没有更多数据
    const hasMore = page < 10;

    return { items, hasMore };
  }

  /**
   * 创建节流滚动处理函数
   */
  createThrottledScroll() {
    const scrollHandler = async (scrollTop, clientHeight, scrollHeight) => {
      const distanceFromBottom = scrollHeight - scrollTop - clientHeight;

      console.log(`滚动位置: ${scrollTop}, 距离底部: ${distanceFromBottom}px`);

      // 记录滚动位置
      this.lastScrollPosition = scrollTop;
      this.scrollHistory.push({
        position: scrollTop,
        timestamp: Date.now()
      });

      // 限制历史记录大小
      if (this.scrollHistory.length > 20) {
        this.scrollHistory.shift();
      }

      // 检查是否需要加载更多
      if (distanceFromBottom < this.threshold) {
        await this.loadMore();
      }
    };

    return throttleComplete(scrollHandler, this.throttleInterval, {
      leading: true,
      trailing: true
    });
  }

  /**
   * 处理滚动事件
   */
  onScroll(event) {
    const target = event.target;
    const scrollTop = target.scrollTop || target.scrollingElement?.scrollTop || 0;
    const clientHeight = target.clientHeight || window.innerHeight;
    const scrollHeight = target.scrollHeight || document.documentElement.scrollHeight;

    this.throttledScroll(scrollTop, clientHeight, scrollHeight);
  }

  /**
   * 加载更多数据
   */
  async loadMore() {
    if (this.isLoading || !this.hasMore) {
      console.log(`${this.isLoading ? '正在加载中' : '没有更多数据'}`);
      return;
    }

    this.isLoading = true;
    this.currentPage++;

    console.log(`加载第 ${this.currentPage} 页数据...`);

    try {
      const result = await this.loadMoreAPI(this.currentPage, this.pageSize);

      this.hasMore = result.hasMore;
      this.items.push(...result.items);

      console.log(`加载完成,当前总条目: ${this.items.length}`);
      console.log(`还有更多: ${this.hasMore}`);

      this.renderItems(result.items);
    } catch (error) {
      console.error('加载失败:', error);
      this.currentPage--; // 回退页数
    } finally {
      this.isLoading = false;
    }
  }

  /**
   * 渲染新加载的项目
   */
  renderItems(newItems) {
    // 实际项目中这里会更新DOM
    console.log('新增项目:');
    newItems.slice(0, 3).forEach((item, i) => {
      console.log(`${item.id}. ${item.title}`);
    });
    if (newItems.length > 3) {
      console.log(`... 等 ${newItems.length} 条`);
    }
  }

  /**
   * 重置到顶部
   */
  reset() {
    this.currentPage = 0;
    this.hasMore = true;
    this.isLoading = false;
    this.items = [];
    this.scrollHistory = [];
    console.log('滚动列表已重置');
  }

  /**
   * 获取滚动统计
   */
  getScrollStats() {
    if (this.scrollHistory.length < 2) {
      return { avgSpeed: 0 };
    }

    const recent = this.scrollHistory.slice(-10);
    let totalSpeed = 0;

    for (let i = 1; i < recent.length; i++) {
      const distance = recent[i].position - recent[i - 1].position;
      const timeDiff = recent[i].timestamp - recent[i - 1].timestamp;
      const speed = distance / timeDiff; // px/ms
      totalSpeed += speed;
    }

    return {
      avgSpeed: totalSpeed / (recent.length - 1),
      scrollCount: this.scrollHistory.length,
      lastPosition: this.lastScrollPosition
    };
  }
}

最佳实践指南

防抖最佳实践

  • 默认延迟时间:300-500ms(用户输入)、200-300ms(窗口调整)、1000ms(自动保存)
  • 搜索框建议使用防抖,避免频繁请求
  • 表单验证使用防抖,用户输完再验证
  • 提交按钮使用防抖,防止重复提交
  • 需要立即反馈的操作设置 immediate: true

节流最佳实践

  • 滚动加载:200-300ms(平衡响应性和性能)
  • 拖拽事件:16-33ms(约30-60fps)
  • 窗口大小调整:100-200ms
  • 游戏循环:使用 requestAnimationFrame 替代定时器节流
  • 频繁的状态更新:考虑使用 requestAnimationFrame

内存管理实践

  • 组件卸载时取消未执行的防抖/节流
  • 避免在全局作用域创建过多的防抖/节流函数
  • 使用缓存时注意设置最大缓存大小
  • 定期清理过期的缓存数据

调试技巧

  • 添加日志追踪函数调用
  • 记录调用历史便于回溯问题
  • 使用 Stats 统计调用次数和节流情况
  • 开发环境设置更短的延迟时间便于测试

防抖节流选择决策树

是否需要处理高频事件?
        │
        ├─→ 是
        │   │
        │   ├─→ 是否需要关注最后一次执行?
        │   │   │
        │   │   ├─→ 是 → 使用防抖
        │   │   │   │
        │   │   │   ├─→ 搜索建议、自动保存、表单验证
        │   │   │   └─→ 窗口调整、拖拽结束
        │   │   │
        │   │   └─→ 否 → 使用节流
        │   │       │
        │   │       ├─→ 滚动加载、拖拽中、动画帧
        │   │       └─→ 游戏循环、鼠标移动
        │   │
        │   └─→ 是否需要立即执行?
        │       │
        │       ├─→ 是 → immediate: true
        │       │   │
        │       │   ├─→ 按钮提交(防止双击)
        │       │   └─→ 数据埋点
        │       │
        │       └─→ 否 → immediate: false
        │           │
        │           ├─→ 搜索建议(避免每个字符都请求)
        │           └─→ 自动保存(停止编辑后保存)
        │
        └─→ 否 → 不需要特殊处理

最终建议

  1. 不要盲目使用防抖/节流,先评估是否真的需要
  2. 根据用户体验选择合理的延迟时间
  3. 为防抖/节流函数命名时标明其特性
  4. 在类组件中绑定this时注意上下文
  5. 优先使用成熟的库实现(lodash、underscore)
  6. 理解原理,但不一定需要每次都自己实现
  7. 监控实际效果,根据数据持续优化

结语

防抖和节流是前端性能优化的基本工具,掌握它们不仅能提升应用性能,还能优化用户体验。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

html与CSS伪类技巧

2026年2月16日 05:01

CSS选择器文档

本文档基于15个HTML示例文件介绍了CSS的各种技巧实践,旨在记录开发时候CSS的场景应用。

  1. 影子DOM样式隔离
  2. :not()伪类简化代码
  3. :is()伪类简化选择器
  4. :active伪类实现埋点统计
  5. :focus-within实现搜索交互
  6. <small>标签的使用
  7. <details><summary>实现折叠面板
  8. :placeholder-shown实现输入提示
  9. :default伪类标记默认选项
  10. <label>交互与计数器
  11. 表单校验与反馈
  12. <fieldset><legend>组织表单
  13. :empty伪类处理空内容
  14. :only-child伪类条件显示
  15. :not()伪类初始化样式

1. 影子DOM样式隔离

使用attachShadow()方法创建影子DOM,实现组件的样式隔离,避免样式冲突。

<body>
   <p>外部文字,颜色为黑色</p>
   <div id="hostElement"></div>
</body>

<script>
   const hostElement = document.querySelector('#hostElement')
   const shadow = hostElement.attachShadow({ mode: 'open' })
   shadow.innerHTML = `<p>可以实现组件的样式隔离,颜色为红色</p>`
   shadow.innerHTML += `<style>p{color:red}</style>`
</script>
  • 组件开发,特别是自定义元素
  • 第三方插件集成
  • 样式封装和隔离

2. :not()伪类简化代码

使用:not()伪类排除特定元素,简化CSS选择器,避免重复样式定义。

<ul>
      <li>列表1</li>
      <li>列表2</li>
      <li>列表3</li>
</ul>

<style>
    /*列表最后一项排除*/
    li:not(:last-child){
        border-bottom: 1px solid black;
        padding-bottom:8px;
        margin-bottom: 8px;
    }
</style>
  • 列表项样式处理(如最后一项无边框)
  • 导航菜单样式
  • 表单元素样式排除

3. :is()伪类简化选择器

使用:is()伪类将多个选择器组合成一个,简化CSS代码结构,提高可读性。

<body>
    <header> <a href='#'> 头部字体 </a>  </header>
    <main> <a href='#'> 主题字体 </a>  </main>
    <footer> <a href='#'> 底部字体 </a> </footer>
</body>

<style>
   /* 常见嵌套写法,需经过编译
    header,main,footer{  
       a{   color: red;   } 
    } 
   编译过后 
   header a,main a,footer a{  color: red  } 
   */

    /* :is() 是原生 CSS 语法,无需编译,浏览器直接识别 */
    :is(header, main, footer) a  {   color: red   }
</style>
  • 多个容器内相同元素的样式统一
  • 复杂选择器的简化

4. :active伪类实现埋点统计

使用:active伪类结合content属性,实现无JavaScript的埋点统计功能。

<body>
    <button class="button1">点我上传</button>
    <button class="button2">点我上传2</button>
</body>

<style>
    /* 第一次点击可以触发 */
.button1:active::after{
    content: url(./pixxel.gif?action=click&id=button1);
}
.button2:active::after{
    content: url(./pixxel.gif?action=click&id=button2);
}
</style>
  • 简单的用户行为统计
  • 按钮点击事件追踪
  • 无需JavaScript的埋点方案

5. :focus-within实现搜索交互

使用:focus-within伪类实现父元素在子元素获得焦点时的样式变化,适用于搜索框下拉菜单等交互场景。

<!-- 适合做 搜索框结果的 下拉 -->
<div class="cs-details">
    <a href="javascript:" class="cs-summary">我的消息</a>
    <div class="cs-datalist">
        <a href>我的回答<sup>12</sup></a>
        <a href>我的私信</a>
        <a href>未评价订单</a>
        <a href>我的关注</a>
    </div>
</div>


<style>
    .cs-datalist {
        display: none;
        position: absolute;
        border: 1px solid #000;
        background-color: #fff;
    }

    .cs-details:focus-within .cs-datalist {
        display: block;
    }
</style>
  • 搜索框下拉菜单
  • 导航菜单的子菜单显示
  • 表单元素的关联信息展示

6. <small>标签的使用

使用<small>标签表示小号文本,通常用于免责声明、注释等次要信息。

<small>你好</small>
<div>你好123</div>
  • 法律声明和条款
  • 文章注释和说明
  • 表单字段的辅助信息

7. <details><summary>实现折叠面板

使用<details><summary>标签实现原生的折叠面板功能,无需JavaScript。

<details style="user-select:none;">
    <summary>请选择</summary>
    <ul>
        <li>选项1</li>
        <li>选项2</li>
        <li>选项3</li>
    </ul> 
</details>  
  • 常见问题解答(FAQ)
  • 内容折叠展示
  • 配置选项面板

8. :placeholder-shown实现输入提示

使用:placeholder-shown伪类检测输入框是否显示占位符,实现输入状态的样式变化。

<input type="search" placeholder="请输入内容">
<small>尚未输入内容</small>

<style>
    :not(:placeholder-shown)+small {
        color: transparent;
    }
</style>
  • 输入框的状态提示
  • 表单验证的视觉反馈
  • 提升用户输入体验

9. :default伪类标记默认选项

使用:default伪类标记表单中的默认选项,如默认选中的单选按钮。

<!-- 更换选择,自动补充(推荐) -->
<p>请选择支付方式:</p>
<p><input name="pay" type="radio"><label>支付宝</label></p>
<p><input name="pay" type="radio" checked><label>微信</label></p>
<p><input name="pay" type="radio"><label>银行卡</label></p>

<style>
    input:default+label::after {
        content: '(推荐)';
    }
</style>
  • 表单默认选项标记
  • 推荐选项提示
  • 提高用户表单填写效率

10. <label>交互与计数器

使用<label>标签与复选框关联,结合CSS计数器实现选中项数量统计。

<body>
    <p>请选择你感兴趣的话题:</p>

    <input type="checkbox" id="topic1">
    <label for="topic1" class="cs-topic">科技</label>

    <input type="checkbox" id="topic2">
    <label for="topic2" class="cs-topic">体育</label>

    <input type="checkbox" id="topic3">
    <label for="topic3" class="cs-topic">军事</label>

    <input type="checkbox" id="topic4">
    <label for="topic4" class="cs-topic">娱乐</label>

    <p>您已选择 <span class="cs-topic-counter"></span>个话题。</p>

</body>

<style>
    .cs-topic {
        padding:5px 15px;
        cursor: pointer;
        border: 1px solid #000;
    }
    :checked+.cs-topic {
        border-color: skyblue;
        background-color: azure;
    }
    [type='checkbox'] {
        position: absolute;
        clip: rect(0 0 0 0);
    }

    body {
        counter-reset: topicCounter;
    }

    :checked+.cs-topic {
        counter-increment: topicCounter;
    }

    .cs-topic-counter::before {
        content: counter(topicCounter);
    }
</style>
  • 兴趣标签选择
  • 商品属性选择
  • 多选项表单交互

11. 表单校验与反馈

使用CSS伪类实现表单验证的视觉反馈,包括输入合法、非法和空值状态。

<!-- 表单校验 -->

<form id="csForm" novalidate>
    <p>
        验证码:
        <input class="cs-input" required pattern="\w{4}" placeholder="">
        <span class="cs-vaild-tips"></span>
    </p>

    <input type="submit" />
</form>

<style>
    /* 校验通过 */
    .cs-input:valid {
        background-color: green;
        color: #fff;
    }
    .valid .cs-input:valid+.cs-vaild-tips::before {
        content: '√';
        color: green;
    }

    /* 校验不合法提示 */
    .valid .cs-input:not(:placeholder-shown):invalid {
        border: 2px solid red;
    }
    .valid .cs-input:not(:placeholder-shown):invalid+.cs-vaild-tips::before {
        content: '不符合要求';
        color: red;
    }

    /* 空值提示 */
    .valid .cs-input:placeholder-shown+.cs-vaild-tips::before {
        content: '尚未输入值';
    }
</style>

<script>
    const form = document.querySelector('#csForm')
    const input = document.querySelector('.cs-input')

    // 即时的校验
    // form.addEventListener('input',(e)=>{
    //     form.classList.add('valid')
    // })

    // 优化:输入时实时更新校验提示(可选,提升体验)
    input.addEventListener('input', () => {
        if (form.classList.contains('valid')) {
            // 强制重绘,更新样式
            void form.offsetWidth;
        }
    })

    form.addEventListener('submit', (e) => {
        e.preventDefault()
        form.classList.add('valid') // 触发校验样式

        if (form.checkValidity()) {
            alert('校验通过')
        }
    })
</script>
  • 表单验证反馈
  • 实时输入校验
  • 提升表单填写体验

12. <fieldset><legend>组织表单

使用<fieldset><legend>标签组织表单内容,提高表单的结构性。

<form>
    <fieldset>
        <legend>问卷调查</legend>
        <ol>
            <li>1-3年</li>
            <li>3-5年</li>
            <li>5年以上</li>
            <h4>你从事前端几年了?</h4>
        </ol>
    </fieldset>
</form>
  • 复杂表单的分组
  • 提高表单的可访问性
  • 增强表单的语义结构

13. :empty伪类处理空内容

使用:empty伪类检测元素是否为空,为空白元素添加默认内容或样式。

<dl>
    <dt>姓名:</dt>   <dd>张三</dd>
    <dt>性别:</dt>   <dd></dd>
    <dt>手机:</dt>   <dd></dd>
    <dt>邮箱:</dt>   <dd></dd>
</dl>

<!-- :empty 兼容 ''、null,配合伪元素可填充自定义内容 -->
<style>
    dt {   float: left    }

    dd:empty::before {
        color: gray;
        /* content: '-'; */
        content: '暂无';
    }
</style>
  • 数据展示中的空值处理
  • 搜索结果为空的提示
  • 表单字段的默认显示

14. :only-child伪类条件显示

使用:only-child伪类检测元素是否为唯一子元素,实现条件性的样式显示。

<ul>
    <li> 仅剩一项时不可删除 <button>删除</button> </li>
    <li> 仅剩一项时不可删除 <button>删除</button> </li>
    <li> 仅剩一项时不可删除 <button>删除</button> </li>
</ul>

<style>
    li:only-child button { display: none  }
</style>


<script>
    // 点击删除 li
    const buttons = document.querySelectorAll('li button')
    buttons.forEach(btn => {
        btn.addEventListener('click', function () {
            btn.parentElement.remove()
        })
    })
</script>
  • 列表项的删除按钮控制
  • 条件性UI元素显示
  • 动态内容的样式调整

15. :not()伪类初始化样式

使用:not()伪类排除特定元素,实现样式的初始化和重置。

<!-- 激活面板优雅切换 -->
<!-- 
<div class="cs-panel">面板1</div>
<div class="cs-panel active">面板2</div>
<div class="cs-panel">面板3</div>

<style>  .cs-panel:not(.active) { display: none} </style>
 -->


<!-- 灵活性 -->
<div class="cs-panel">面板1</div>
<div class="cs-panel  flex">面板2</div>
<div class="cs-panel active grid">面板3</div>

<style>
    .cs-panel:not(.active) {  display: none  }
    
    .flex {    display: flex  }
    .grid {    display: grid   }
</style>
  • 选项卡面板切换
  • 激活状态的样式控制
  • 组件的默认状态管理

总结

本文档介绍了CSS新世界中的15个实用技巧,涵盖了样式隔离、选择器优化、表单交互、内容展示等多个方面。这些技巧充分利用了现代CSS的新特性,能够帮助开发者编写更简洁、高效、可维护的代码,同时提升用户体验。

随着CSS标准的不断发展,我们可以期待更多强大的特性和技巧出现。希望本文档能够为开发者提供参考,让大家在CSS的世界中探索更多可能性。

参考资源

  • 张鑫旭《css选择器》

Elpis 动态组件扩展设计:配置驱动的边界与突破

作者 飞雪飘摇
2026年2月15日 22:01

配置驱动的边界问题

Elpis 通过配置驱动解决了 80% 的中后台 CRUD 场景,但总会遇到内置组件无法覆盖的情况:

  • 需要省市区三级联动选择器
  • 需要带千分位格式化的金额输入框
  • 需要集成公司自研的图片裁剪上传组件
  • 需要富文本编辑器、图表组件等第三方库

这时候有三个选择:

方案 A:放弃配置驱动,回到手写代码

方案 B:等框架作者更新内置组件

方案 C:自己扩展组件,像内置组件一样使用

Elpis 选择了方案 C,通过动态组件扩展机制,让框架既保持标准化,又具备灵活性。

核心设计:一个"字符串"的魔法

Elpis 的扩展机制说穿了就一个核心思想:配置里写的是字符串,渲染时才决定用哪个组件

看这段配置:

product_name: {
  createFormOption: {
    comType: 'input',  // 这只是个字符串
  }
}

这个 'input' 不是直接对应某个组件,而是一个"代号"。真正的组件在哪?在一个叫"注册中心"的地方:

// form-item-config.js
const FormItemConfig = {
  'input': { component: InputComponent },
  'select': { component: SelectComponent },
  'richEditor': { component: RichEditorComponent }
};

渲染时,Elpis 做的事情很简单:

<component :is="FormItemConfig[配置里的comType].component" />

就这样,配置和组件解耦了。你想加新组件?往注册中心加一行,配置里就能用。

这个设计妙在哪?

1. 配置稳定: 即使你把 InputComponent 整个重写了,配置文件一个字都不用改。因为配置里只是写了个 'input' 字符串。

2. 场景隔离: 搜索栏有自己的注册中心,表单有自己的注册中心。同样是 'input',在搜索栏可能是个简单输入框,在表单里可能是个带校验的复杂组件。

3. 扩展简单: 不需要改框架代码,不需要发 PR,不需要等更新。自己加一行注册,立刻就能用。

实战:扩展一个富文本编辑器组件

通过实际案例演示如何扩展组件。假设需要添加富文本编辑器支持。

第一步:实现组件

创建文件 app/pages/widgets/schema-form/complex-view/rich-editor/rich-editor.vue

<template>
  <div class="form-item">
    <div class="item-label">
      <span>{{ schema.label }}</span>
      <span v-if="schema.option?.required" class="required">*</span>
    </div>
    <div class="item-value">
      <QuillEditor v-model:content="value" />
      <div v-if="!isValid" class="valid-tips">{{ validMessage }}</div>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import { QuillEditor } from '@vueup/vue-quill';

const props = defineProps({
  schemaKey: String,    // 字段名
  schema: Object,       // 字段配置
  model: String         // 初始值
});

const value = ref(props.model || '');
const isValid = ref(true);
const validMessage = ref('');

// 必须实现的接口方法
const validate = () => {
  if (props.schema.option?.required && !value.value) {
    isValid.value = false;
    validMessage.value = '这个字段必填';
    return false;
  }
  isValid.value = true;
  return true;
};

const getValue = () => {
  return { [props.schemaKey]: value.value };
};

defineExpose({ validate, getValue });
</script>

关键约定

  • Props 必须包含 schemaKeyschemamodel
  • 必须暴露 validate()getValue() 方法
  • 其他实现细节可自由发挥

第二步:注册组件

app/pages/widgets/schema-form/form-item-config.js 中注册:

import richEditor from "./complex-view/rich-editor/rich-editor.vue";

const FormItemConfig = {
  input: { component: input },
  select: { component: select },
  richEditor: { component: richEditor }  // 新增注册
};

第三步:配置使用

在业务模型中使用新组件:

product_description: {
  type: 'string',
  label: '商品描述',
  createFormOption: {
    comType: 'richEditor',  // 使用扩展组件
    required: true
  }
}

完成。刷新页面,富文本编辑器自动渲染,校验、提交等功能自动生效。

背后的技术:Vue 3 的动态组件

你可能好奇 Elpis 是怎么做到"运行时决定渲染哪个组件"的。答案是 Vue 3 的 <component :is>

看 Elpis 的核心渲染代码:

<template>
  <template v-for="(itemSchema, key) in schema.properties">
    <component
      :is="FormItemConfig[itemSchema.option?.comType]?.component"
      :schemaKey="key"
      :schema="itemSchema"
      :model="model[key]"
    />
  </template>
</template>

这段代码在做什么?

  1. 遍历配置里的每个字段
  2. 读取字段的 comType(比如 'input'
  3. 从注册中心找到对应的组件(FormItemConfig['input'].component
  4. :is 动态渲染这个组件

关键点:is 后面可以是一个变量,这个变量的值是什么组件,就渲染什么组件。

这就是为什么你改配置文件就能换组件——因为组件是运行时决定的,不是编译时写死的。

一个容易忽略的细节:统一接口

注意到没有,所有组件都接收同样的 props:

:schemaKey="key"
:schema="itemSchema"
:model="model[key]"

这是 Elpis 的"约定"。只要你的组件遵守这个约定,就能被动态渲染。

这就像 USB 接口,不管你是键盘、鼠标还是 U 盘,只要接口对得上,就能插上用。

所以写扩展组件时,记住三件事:

  1. Props 要有 schemaKeyschemamodel
  2. 要暴露 validate()getValue() 方法
  3. 其他的随便你发挥

对比:Elpis vs 其他方案

vs Element Plus / Ant Design(组件库)

组件库:给你一堆组件,你自己拼。

<el-form>
  <el-form-item label="商品名称">
    <el-input v-model="form.name" />
  </el-form-item>
  <el-form-item label="价格">
    <el-input-number v-model="form.price" />
  </el-form-item>
  <!-- 每个字段都要写 -->
</el-form>

Elpis:写个配置,自动生成。

{
  product_name: { createFormOption: { comType: 'input' } },
  price: { createFormOption: { comType: 'inputNumber' } }
}

结论:组件库灵活但重复劳动多,Elpis 标准化但省事。适用场景不同,不是替代关系。

vs Formily / React JSON Schema Form(表单方案)

JSON Schema 表单:只管表单,其他的你自己搞。

Elpis:搜索 + 表格 + 表单 + 详情,一套配置全搞定。

结论:Elpis 是 JSON Schema 思想在整个中后台系统的延伸。

写在最后

Elpis 的动态组件扩展机制核心就三件事:

  1. 配置里写字符串标识,不直接引用组件
  2. 用注册中心做类型映射,字符串对应具体组件
  3. 用 Vue 的 :is 实现运行时动态渲染

这套设计让框架在标准化和灵活性之间找到了平衡:

  • 80% 的场景用内置组件,配置驱动,快速开发
  • 20% 的场景扩展组件,一次封装,到处复用

扩展组件的成本是一次性的,但收益是长期的。当你的组件库逐渐丰富,配置驱动的威力就会越来越明显。

框架的价值不在于限制开发者,而在于提供清晰的扩展路径,让开发者在需要时能够突破标准化的边界。

引用: 抖音“哲玄前端”《大前端全栈实践》

【翻译】Rolldown工作原理:模块加载、依赖图与优化机制全揭秘

2026年2月15日 21:59

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

作者: Atriiy

引言

Rolldown 是一款基于 Rust 开发的极速 JavaScript 打包工具,专为无缝兼容 Rollup API 设计。其核心目标是在不久的将来成为 Vite 的统一打包器,为 Vite 提供底层支撑。目前 Vite 在本地开发阶段依赖 esbuild 实现极致的构建速度,而生产环境构建则基于 Rollup;切换为 Rolldown 这类单一打包器后,有望简化整个构建流程,让开发者更有信心 —— 开发环境所见的效果,与生产环境最终上线的结果完全一致。此外,Rolldown 的打包速度预计比 Rollup 快 10~30 倍。想了解更多细节?可查阅 Rolldown 官方文档。

本文将先从 Rolldown 的整体架构入手,帮你建立对其工作原理的全局认知,避免过早陷入细节而迷失方向。在此基础上,我们会深入本文的核心主题:模块加载器 —— 这是 Rolldown 扫描阶段的核心组件,我们将剖析其关键功能,以及支撑它运行的重要数据结构。

接下来,我们还会探讨依赖图,以及 Rolldown 采用的部分性能优化策略。尽管其中部分内容你可能此前有所接触,但结合上下文重新梳理仍有价值 —— 这些内容是理解 Rolldown 如何实现极致速度与效率的关键。

好了,让我们正式走进 Rolldown 的世界吧。😉

Rolldown 整体架构概览

Rolldown 的核心流程分为四个主要步骤:依赖图构建 → 优化 → 代码生成 / 打包 → 输出。最终生成的打包产物会根据场景(本地开发 / 生产构建)写入内存或文件系统。你可在 crates/rolldown/src/bundler.ts 路径下找到入口模块的实现。以下是该流程的示意图:

graph TD
start["Start: Read Config & Entry Points"]
parse["Parse Entry Module"]
build{"Build Module Graph"}
load["Load & Parse Dependency Modules"]
optimaze["Code optimization"]
generate["Code Generation: Generate\n Chunks"]
return["Return Output Assets In\n Memory"]
write["Write Output Files to Disk"]
start --> parse
parse --> build
build -->|Scan Dependencies|load
load -->|Repeat until all\n dependencies are processed|build
build --> optimaze
optimaze --> generate
generate -->|Generate Mode: rolldown.generate| return
generate -->|Write Mode: rolldown.write| write

模块加载器是构建模块依赖图阶段的核心组件,它由 Bundler 结构体中的 scan 函数触发调用。为实现更清晰的职责分离,整个扫描流程已被封装到专用的 ScanStage 结构体中。

但真正的核心工作都发生在 ModuleLoader(模块加载器)内部:它负责处理构建依赖图、解析单个模块等关键任务,也是 Rolldown 中大量核心计算逻辑的落地之处 —— 这正是本文要重点探讨的内容。

模块加载器(Module Loader)

简而言之,模块加载器的核心职责是定位、获取并解析单个模块(包括源码文件、CSS 文件等各类资源),并将这些模块转换为打包器能够识别和处理的内部数据结构。这一步骤是构建精准且高效的模块依赖图的关键。

以下示意图展示了 Rolldown 在打包流程中如何使用模块加载器:

graph TD
prepare["Bundler: Prepare needs to\n Build Module Graph"]
create["Create Module Loader\n Instance"]
calls["Bundler: Calls Module\n Loader's fetch_modules"]
load[["Module Loader Operation"]]
return["Module Loader: Returns\n Aggregated Results to\n Bundler"]
result["Bundler: Uses Results for\n Next Steps - e.g., Linking,\n Optimization, Code Gen"]
prepare --> create
create --> calls
calls --> load
load --> return
return --> result

上述所有步骤均发生在 ScanStage 结构体的 scan 函数内部。你可以将 scan 函数理解为一个编排器(builder) —— 它统筹并封装了运行模块加载器所需的全部逻辑。

拉取模块(Fetch modules)

fetch_modules 是整个流程的 “魔法起点”。它扮演着调度器(scheduler) 的角色,启动一系列异步任务来解析所有相关模块。该函数负责处理用户定义的入口点 —— 这也是模块扫描算法的起始位置。

在进入 fetch_modules 之前,scan 函数会先解析这些入口点,并将其转换为 Rolldown 内部的 ResolvedId 结构体。这一预处理步骤由 resolve_user_defined_entries 函数完成。

以下示意图展示了 fetch_modules 函数的核心工作流程:

graph TD
start["Start: Receive Resolved\n User Defined Entries"]
init["Initialize: Task Counter,\n Module Cache, Result\n Collectors"]
launch["Launch Async Tasks for Each\n Entry"]
loop{"Message Loop: Listen while\n Task Counter > 0"}
store["Store Results;\nProcess Dependencies"]
request["Launch Task for Requested\n Module; Increment Counter"]
resolve["Resolve & Launch Task for\n New Entry; Increment\n Counter"]
record["Record Error; Decrement\n Counter"]
depence{"New Dependencies?"}
tasks["Launch Tasks for New\n Dependencies; Increment\n Counter"]
dec["Decrement Task Counter"]
zero{"Task Counter == 0?"}
final["Finalize: Update\n Dependency Graph,\n Organize Results"]
return["End: Return Output to\n Caller"]
start --> init
init --> launch
launch --> loop
loop -->|Module Done| store
loop -->|Plugin Fetch Module| request
request --> loop
loop -->|Plugin Add Entry| resolve
resolve --> loop
loop -->|Build Error| record
record --> loop
store --> depence
depence -->|Yes| tasks
tasks --> loop
depence -->|No| dec
dec --> zero
zero -->|No| loop
zero -->|Yes| final
final --> return

看起来有点复杂,对吧?这是因为该阶段集成了大量优化策略和功能特性。不过别担心 —— 我们可以暂时跳过细枝末节,先聚焦整体流程。

如前文所述,fetch_modules 函数以解析后的用户定义入口点为输入,开始执行处理逻辑。对于每个入口点,它会调用 try_spawn_new_task 函数:该函数先判定模块属于内部模块还是外部模块,再执行对应的处理逻辑,最终返回一个类型安全的 ModuleIdx(模块索引) 。这个索引后续会作为整个系统中引用对应模块的唯一标识。

当所有入口点的初始任务都已启动后,fetch_modules 会进入循环,监听一个基于 tokio::sync::mpsc 实现的消息通道。每个模块处理任务都持有该通道的发送端句柄(sender handle),并向主进程上报事件。fetch_modules 内部的消息监听器会响应这些消息,具体包括以下类型:

  • 普通 / 运行时模块处理完成:存储处理结果,并调度该模块的所有依赖模块;
  • 拉取模块:响应插件的按需加载特定模块请求;
  • 添加入口模块:在扫描过程中新增入口点(通常由插件触发);
  • 构建错误:捕获加载或转换过程中出现的所有错误。

当所有模块处理完毕且无新消息传入时,消息通道会被关闭,循环随之退出。随后 fetch_modules 执行收尾清理工作:存储已处理的入口点、更新依赖图,并将聚合后的结果返回给调用方(即 scan 函数)。该结果包含模块、抽象语法树(AST)、符号、入口点、警告信息等核心数据 —— 这些都会被用于后续的优化和代码生成阶段。

启动新任务(Spawn new task)

try_spawn_new_task 函数首先尝试从模块加载器的缓存中获取 ModuleIdx(模块索引)。由于扫描阶段本质上是对依赖图的遍历过程,该缓存通过哈希映射表跟踪每个模块的访问状态 —— 其中键为模块 ID,值用于标识该模块是否已处理完成。

接下来,函数会根据模块类型,将其转换为普通模块或外部模块结构,以便进行后续处理。理解外部模块的处理逻辑尤为重要:这类模块不会被 Rolldown 打包 —— 它们预期由运行时环境提供(例如 node_modules 中的第三方库)。尽管不会被纳入最终打包产物,但 Rolldown 仍会记录其元数据,实际上是将其视为占位符(placeholder) 。打包产物会假定这些模块在运行时可用,并在需要时直接引用它们。

而普通模块(通常由用户编写)的处理方式则不同:try_spawn_new_task 会为每个普通模块创建一个专属的模块任务,并以异步方式执行。这些任务由 Rust 异步运行时 Tokio 管理。如前文所述,每个任务都持有消息通道的发送端,可在运行过程中上报错误、新发现的导入项,或动态添加的入口点。

数据结构(Data structures)

为提升性能和代码复用性,Rolldown 大量使用专用数据结构。理解模块加载器中几个核心数据结构的设计,能让你更清晰地认知扫描流程的底层实现逻辑。

ModuleIdx & HybridIndexVec

ModuleIdx 是一种自定义数值索引,会在模块处理过程中动态分配。这种索引设计兼顾类型安全与性能:Rolldown 不会传递或克隆完整的模块结构体,而是使用这种轻量级标识符(类似其他编程语言中的指针),在整个系统中引用模块。

pub struct ModuleIdx = u32;

HybridIndexVec 是 Rolldown 用于存储模块数据的智能自适应容器。由于 Rolldown 核心操作的对象是 ModuleIdx(模块索引)而非实际的模块数据,实现高效的 “基于 ID 查找” 就至关重要 —— 而这正是 HybridIndexVec 的设计初衷:它会针对不同的打包场景做针对性优化。

pub enum HybridIndexVec<I: Idx, T> {
  IndexVec(IndexVec<I, T>),
  Map(FxHashMap<I, T>),
}

打包工具通常运行在两种模式下:

  • 全量打包(Full bundling) (生产环境构建的主流模式):所有模块仅扫描一次,并以连续存储的方式保存。针对这种场景,Rolldown 采用名为 IndexVec紧凑高性能结构—— 它的行为类似向量(vector),但强制要求类型安全的索引访问。
  • 增量打包(Partial bundling) (常用于开发环境):模块依赖图可能频繁变化(例如开发者编辑文件时)。这种场景下,稀疏结构(sparse structure)更适用,Rolldown 会使用基于 FxHash 算法的哈希映射表,以实现高效的键值对访问。

FxHash 算法比 Rust 默认哈希算法更快,尽管其哈希冲突的概率略高。由于键和值均由 Rolldown 内部管理,且 “可预测的性能” 比安全性更重要,因此这种取舍对于 Rolldown 的使用场景而言是可接受的。

模块(Module)

普通模块由用户定义 —— 通常是需要解析、转换或分析的源码文件。Rolldown 会加载这些文件,并根据文件扩展名进行处理。例如,.ts(TypeScript)文件会通过高性能的 JavaScript/TypeScript 解析器 Oxc 完成解析。

pub enum Module {
  Normal(Box<NormalModule>),
  External(Box<ExternalModule>),
}

内部的 NormalModule 结构体存储着每个模块的详细信息:既包含 idx(索引)、module_type(模块类型)等基础元数据,也涵盖模块内容的富表示形式(richer representations) 。根据文件类型的不同,这些内容具体包括:

  • ecma_view:用于 JavaScript/TypeScript 模块
  • css_view:用于样式表文件
  • asset_view:用于静态资源文件

这种结构化设计,能让打包流程后续阶段(如优化、代码生成)高效处理已解析的模块内容。

ScanStageCache(扫描阶段缓存)

这是一个在模块加载过程中存储所有缓存数据的结构体。以下是该数据结构的定义:

pub struct ScanStageCache {
  snapshot: Option<NormalizedScanStageOutput>,
  pub module_id_to_idx: FxHashMap<ArcStr, VisitState>,
  pub importers: IndexVec<ModuleIdx, Vec<ImporterRecord>>,
}

snapshot(快照)存储着上一次扫描阶段的执行结果,用于支持增量构建。Rolldown 无需从头重新扫描所有模块,而是复用上次扫描的部分结果 —— 当仅有少量文件变更时,这一机制能大幅缩短构建耗时。

module_id_to_idx 是一个哈希映射表,存储模块 ID 与其访问状态的映射关系。程序可通过它快速判断某个模块是否已处理完成。

该映射表的键类型为 ArcStr—— 这是一种内存高效、支持引用计数的字符串类型,专为跨线程共享场景优化。更重要的是,这个字符串是模块的全局唯一且稳定的标识符,在多次构建过程中保持一致,这对缓存的可靠性至关重要。

importers 是模块依赖图的反向邻接表:针对每个模块,它会跟踪 “哪些其他模块导入了该模块”。这在增量构建中尤为实用:当某个模块内容变更时,importers 能帮助 Rolldown 快速确定受影响模块的范围 —— 本质上就是识别出需要重新处理的模块。

需注意,importers 还会有一个临时版本存储在 IntermediateNormalModules(中间普通模块)中。你可以将其理解为 “草稿状态”,会在当前构建过程中动态生成。

依赖图(Dependency graph)

依赖图描述了模块间的相互依赖关系,也是扫描阶段最重要的输出之一。Rolldown 会在后续阶段(如摇树优化、代码分块、代码生成)利用这份关系映射表完成各类核心任务。

在深入讲解具体实现前,我们先介绍邻接表的概念 —— 它是依赖图的表示与遍历的核心载体。

图与邻接表(Graph and Adjacency table)

众所周知,图是用于表示 “事物间关联关系” 的数据结构,由两部分组成:

  • 节点(Nodes):被关联的项或实体(对应 Rolldown 中的模块)
  • 边(Edges):节点之间的关联或依赖关系(对应模块间的导入导出关系)

图有两种常见的表示方式:邻接矩阵邻接表

邻接矩阵是一个二维网格(矩阵),每行和每列对应一个节点。矩阵中某个单元格的值表示两个节点之间是否存在边:例如,值为 1 表示存在关联,值为 0 则表示无关联。

  | A | B | C
A | 0 | 1 | 0
B | 1 | 0 | 1
C | 0 | 1 | 0

这种方式(邻接矩阵)简单直观,在稠密图场景下表现优异 —— 即大多数节点之间都存在关联的图。但对于稀疏图而言,它的内存利用率极低,而 Rolldown 这类打包工具中的模块依赖图恰好属于稀疏图。(相信没人会在项目里把所有模块都导入到每一个文件中吧。😉)

邻接表则是另一种存储方式:每个节点都维护一个 “邻居节点列表”。它不会使用固定大小的矩阵,而是只存储实际存在的关联关系,因此在稀疏图场景下效率更高。

举个例子:若节点 A 关联到节点 B,节点 B 关联到节点 A 和 C,最终节点 C 仅关联到节点 B。

A[B]
B[A, C]
C → [B]

这种结构(邻接表)内存利用率高,且能轻松适配大型稀疏图场景 —— 比如 Rolldown 这类打包工具所处理的模块依赖图。同时,它还能让程序仅遍历相关的关联关系,这一点在扫描或优化阶段尤为实用。

正向与反向依赖图(Forward & reverse dependency graph)

在扫描阶段,Rolldown 会构建两种类型的依赖图:正向依赖图和反向依赖图。其中,正向依赖图存储在每个模块的 ecma_view 中,记录当前模块所导入的其他模块。

pub struct ecma_view {
  pub import_records: IndexVec<ImportRecordIdx, ResolvedImportRecord>,
  // ...
}

正向依赖图对打包至关重要。模块加载器从用户定义的入口点出发,构建这张图来确定最终打包产物需要包含哪些模块。它在确定执行顺序管理变量作用域方面也扮演着关键角色。

此外,模块加载器还会创建一张反向依赖图,方便追踪哪些模块导入了指定模块。这对摇树优化(Tree Shaking)、副作用分析、增量构建、代码分块和代码分割等功能至关重要。

这些功能涉及大量上下文,这里就不展开细讲。你可以简单这样理解:如果我(某个模块)发生了变化,谁会受到影响? 答案是:所有依赖这个变更模块的模块都需要重新处理。这就是实现增量构建热模块替换(HMR) 的核心思想。

性能优化

Rolldown 底层包含大量性能优化手段。得益于 Rust 的零成本抽象所有权模型,再搭配 Tokio 强大的异步运行时,开发者拥有了将性能推向新高度的工具。模块加载器本身也运用了多种提速技术,这里我们简要介绍一下,大部分内容前面已经提到过。

异步并发处理

并发是模块加载器的核心。如前所述,它的主要职责是遍历所有模块并构建依赖图。在实际项目中,导入关系会迅速变得复杂且嵌套很深,这使得异步并发至关重要。

在 Rust 中,asyncawait 是异步函数的基础构建块。异步函数会返回一个 Future,它不会立即执行,只有在显式 await 时才会运行。Rolldown 基于 Rust 最主流的异步运行时 Tokio,高效并发地执行这些模块处理任务。

缓存

由于 Rolldown 会执行大量异步操作,并且在本地开发环境中会频繁重复运行,缓存就成了避免重复工作的关键。

模块加载器的缓存存放在 ModuleLoader 结构体内部,包含 snapshotmodule_id_to_idximporters 等数据,大部分我们在前面章节已经介绍过。这些缓存能帮助 Rolldown 避免重复处理相同模块,让增量构建速度大幅提升。

未来展望

Rolldown 仍在积极开发中。未来,它有望成为 Vite 的底层引擎,提供一致的构建结果极致的性能。你可以在这里查看路线图。

我写这篇文章是为了记录我研究 Rolldown 的过程,也希望能为你揭开它那些出色底层实现的神秘面纱。如果你发现错误或觉得有遗漏,欢迎在下方留言 —— 我非常期待你的反馈!😊

感谢阅读,我们下篇文章见!

从零开始学 React Hooks:useState 与 useEffect 核心解析

2026年2月15日 21:47

从零开始学 React Hooks:useState 与 useEffect 核心解析

作为 React 官方主推的语法,Hooks 让函数组件拥有了状态管理和生命周期的能力,彻底摆脱了类组件的繁琐语法,让 React 代码更贴近原生 JS。本文从纯函数与副作用的基础概念出发,由浅入深讲解useStateuseEffect两个核心 Hooks 的使用,适合 JS 初学者快速上手,所有案例均基于实战代码拆解,易懂易练。

一、前置基础:纯函数与副作用

在学习 Hooks 前,必须先理解纯函数副作用这两个核心概念,它们是 Hooks 设计的底层逻辑,也是 React 组件设计的重要原则。

1.1 纯函数

纯函数是相同输入始终返回相同输出,且无任何副作用的同步函数,这是纯函数的三大核心特征:

  1. 输入确定,输出确定:不会因外部变量、环境变化改变返回结果
  2. 无副作用:不修改函数外部的变量、不操作 DOM、不发起网络请求等
  3. 必须同步:不包含异步操作(异步会导致返回结果不确定)

纯函数示例

// 纯函数:输入x和y,输出固定的和,无任何外部影响
const add = (x, y) => x + y;
// React中useState的初始值计算函数也是纯函数
const getInitNum = () => {
  const a = 1 + 2;
  const b = 2 + 3;
  return a + b; // 输入固定,返回值永远是8
};

1.2 副作用

副作用是指函数执行过程中,对函数外部环境产生的一切影响,简单来说:非纯函数的操作,基本都是副作用

常见的副作用场景:

  • 修改函数外部的变量、数组、对象(如给数组 push 元素)
  • 发起网络请求(fetch/axios)、定时器 / 延时器(setTimeout/setInterval
  • 操作 DOM、本地存储(localStorage
  • 订阅 / 取消订阅事件

副作用示例

// 有副作用:修改了外部的nums2数组
function add(nums2) {
  nums2.push(3); // 改变外部变量,副作用
  return nums2.reduce((pre, cur) => pre + cur, 0);
}
const nums2 = [1, 2];
add(nums2);
console.log(nums2); // [1,2,3],原数组被修改

// 有副作用:包含网络请求(不确定操作)
const add2 = (x, y) => {
  fetch('https://www.baidu.com'); // 网络请求,副作用
  return x + y;
};

1.3 组件与纯函数的关系

React 函数组件的核心逻辑应该是纯函数:输入 props/state,输出固定的 JSX,不包含副作用。而所有的副作用操作,都需要交给专门的 Hooks 来处理(如useEffect),这是 React 的设计规范,能保证组件的可预测性和稳定性。

二、useState:让函数组件拥有响应式状态

useState是 React 最基础的 Hooks,作用是为函数组件添加响应式状态,并提供修改状态的方法。状态(state)就是组件中会变化的数据,也是组件的核心,状态变化时,组件会自动重新渲染,更新页面内容。

2.1 基本使用

语法

import { useState } from 'react';
// 解构赋值:state为当前状态值,setState为修改状态的方法
const [state, setState] = useState(initialValue);
  • initialValue:状态的初始值,可以是任意 JS 类型(数字、字符串、数组、对象等)
  • state:获取当前的状态值
  • setState:修改状态的方法,调用后会更新 state 并触发组件重新渲染

基础示例

import { useState } from 'react'
export default function App(){
  // 初始化数字状态,初始值为1
  const [num, setNum] = useState(1);
  return (
    // 点击div,修改num状态
    <div onClick={() => setNum(num + 1)}>
      当前数字:{num}
    </div>
  )
}

点击页面中的 div,数字会逐次加 1,页面自动更新,这就是响应式状态的核心效果。

2.2 高级用法 1:函数式初始化

如果状态的初始值需要复杂计算(如多个变量运算、循环处理),直接传值会导致每次组件渲染都重复计算,造成性能浪费。此时可以使用函数式初始化,该函数只会在组件首次挂载时执行一次,后续渲染不再执行。

语法

// 传入纯函数,返回值作为初始值
const [state, setState] = useState(() => {
  // 复杂的同步计算逻辑(纯函数,无异步、无副作用)
  return 计算后的初始值;
});

实战示例

import { useState } from 'react'
export default function App(){
  // 函数式初始化:仅首次挂载执行,计算初始值为8
  const [num, setNum] = useState(() => {
    const num1 = 1 + 2;
    const num2 = 2 + 3;
    return num1 + num2;
  });
  return (
    <div onClick={() => setNum(num + 1)}>
      初始值计算后:{num}
    </div>
  )
}

⚠️ 注意:初始化的函数必须是纯函数,不能包含异步操作(如setTimeout、网络请求),因为异步会导致初始值不确定,而 React 要求状态的初始值必须是确定的。

2.3 高级用法 2:函数式更新状态

修改状态时,setState不仅可以直接传入新值,还可以传入一个函数,该函数的参数是上一次的状态值,返回值为新的状态值。

适用场景:当新的状态值依赖于上一次的状态值时,推荐使用函数式更新,能避免因 React 状态更新的异步性导致的取值错误。

语法

setState(preState => {
  // preState:上一次的状态值(React自动传入)
  return 新的状态值;
});

实战示例

import { useState } from 'react'
export default function App(){
  const [num, setNum] = useState(1);
  return (
    // 函数式更新:preNum为上一次的num值
    <div onClick={() => setNum((preNum) => {
      console.log('上一次的数字:', preNum);
      return preNum + 1; // 返回新值
    })}>
      当前数字:{num}
    </div>
  )
}

点击 div 时,会先打印上一次的数字,再返回新值,确保状态更新的准确性。

2.4 核心注意点

  1. useState必须在函数组件的顶层调用,不能在 if、for、嵌套函数中使用(React 通过调用顺序识别 Hooks)
  2. setState异步操作,调用后不能立即获取到新的状态值
  3. 状态更新是不可变的:如果状态是对象 / 数组,不能直接修改原数据,需返回新的对象 / 数组(如setArr(pre => [...pre, newItem])

三、useEffect:处理组件的所有副作用

useEffect是 React 处理副作用的核心 Hooks,作用是在函数组件中执行副作用操作,同时它还能模拟类组件的生命周期(如挂载、更新、卸载),让函数组件拥有了生命周期的能力。

3.1 基本概念

  • useEffect的直译是副作用效果,专门用来包裹组件中的所有副作用代码
  • 组件的核心逻辑(纯函数)负责渲染 JSX,副作用逻辑(请求、定时器、DOM 操作)全部放在useEffect
  • useEffect接收两个参数:副作用函数依赖项数组

3.2 基本语法

import { useEffect } from 'react';
useEffect(() => {
  // 副作用函数:执行所有副作用操作(请求、定时器、DOM操作等)
  // 可选:返回一个清理函数
  return () => {
    // 清理函数:清除副作用(如清除定时器、取消订阅、关闭请求)
  };
}, [deps]); // 依赖项数组:控制useEffect的执行时机

3.3 三种使用场景(核心)

useEffect的执行时机完全由第二个参数(依赖项数组) 控制,分为三种核心场景,对应组件的不同生命周期阶段,这是useEffect的重点,一定要掌握!

场景 1:无依赖项数组 → 每次渲染都执行
useEffect(() => {
  console.log('每次渲染/更新都会执行');
});
  • 组件首次挂载时执行一次
  • 组件每次状态更新 / 重新渲染时都会再次执行
  • 适用场景:需要实时响应组件所有变化的副作用(较少使用,注意性能)
场景 2:空依赖项数组 [] → 仅组件挂载时执行一次
useEffect(() => {
  console.log('仅挂载时执行,模拟onMounted');
  // 示例:挂载时发起异步请求
  queryData().then(data => setNum(data));
}, []);
  • 仅在组件首次挂载到 DOM时执行一次,后续无论状态如何更新,都不会再执行
  • 对应类组件的componentDidMount生命周期,是最常用的场景
  • 适用场景:初始化请求数据、初始化定时器、添加全局事件监听等
场景 3:有依赖项的数组 [state1, state2] → 依赖项变化时执行
const [num, setNum] = useState(0);
useEffect(() => {
  console.log('num变化时执行', num);
}, [num]); // 依赖项为num
  • 组件首次挂载时执行一次
  • 只有当依赖项数组中的值发生变化时,才会再次执行
  • 对应类组件的componentDidUpdate生命周期
  • 适用场景:依赖某个 / 某些状态的副作用(如状态变化时更新定时器、重新请求数据)

3.4 清理函数:清除副作用(避免内存泄漏)

useEffect的副作用函数可以返回一个清理函数,这是 React 的重要设计,用于清除副作用,避免内存泄漏。

清理函数的执行时机
  1. 当组件重新渲染,且useEffect即将再次执行时,先执行上一次的清理函数
  2. 当组件从 DOM 中卸载时,执行清理函数
核心使用场景:清除定时器 / 延时器

定时器是最常见的副作用,如果不及时清除,组件卸载后定时器仍会运行,导致内存泄漏,useEffect的清理函数完美解决这个问题。

实战示例

import { useState, useEffect } from 'react'
export default function App() {
  const [num, setNum] = useState(0);
  useEffect(() => {
    console.log('num更新,创建新定时器');
    // 创建定时器:每秒打印当前num
    const timer = setInterval(() => {
      console.log(num);
    }, 1000);
    // 返回清理函数:清除上一次的定时器
    return () => {
      console.log('清除定时器');
      clearInterval(timer);
    };
  }, [num]); // 依赖num,num变化时执行

  return (
    <div onClick={() => setNum(pre => pre + 1)}>
      点击修改num:{num}
    </div>
  )
}

执行效果

  1. 组件挂载时,创建定时器,每秒打印 num
  2. 点击 div 修改 num,useEffect先执行清理函数清除旧定时器,再创建新定时器
  3. 组件卸载时,执行清理函数清除定时器,避免内存泄漏
其他清理场景
  • 取消网络请求(如 AbortController)
  • 移除全局事件监听(如window.removeEventListener
  • 取消订阅(如 Redux 订阅、WebSocket 订阅)

3.5 实战:结合 useEffect 实现异步请求初始化数据

前面提到,useState的函数式初始化不支持异步,因此组件挂载时的异步请求数据,需要结合useEffect(空依赖)实现,这是项目中的高频用法。

实战示例

import { useState, useEffect } from 'react'
// 模拟异步请求接口
async function queryData() {
  const data = await new Promise(resolve => {
    setTimeout(() => {
      resolve(666); // 模拟接口返回数据
    }, 2000);
  });
  return data;
}
export default function App() {
  const [num, setNum] = useState(0);
  // 空依赖:仅挂载时请求数据
  useEffect(() => {
    queryData().then(data => {
      setNum(data); // 请求成功后修改状态,更新页面
    });
  }, []);

  return <div>接口返回数据:{num}</div>;
}

组件挂载后,发起异步请求,请求成功后修改num状态,页面自动更新为接口返回的 666。

四、Hooks 的通用使用规则

除了useStateuseEffect,React 所有的 Hooks(包括自定义 Hooks)都遵循以下两条核心规则,这是 React 官方强制要求的,违反会导致组件运行异常:

4.1 只能在函数组件 / 自定义 Hooks 中调用

Hooks 只能在React 函数组件的顶层,或者自定义 Hooks中调用,不能在普通 JS 函数、类组件中使用。

4.2 只能在顶层调用,不能嵌套

Hooks 不能在 if、for、while、嵌套函数(如 useEffect 的副作用函数)中调用,必须在函数组件的顶层作用域调用。因为 React 通过调用顺序来识别和管理每个 Hooks 的状态,如果嵌套调用,会导致调用顺序混乱,Hooks 状态失效。

五、实战综合案例:条件渲染 + 副作用清理

结合useState的状态管理、useEffect的副作用处理、React 的条件渲染,实现一个完整的小案例,覆盖本文所有核心知识点:

  1. 点击页面修改数字状态,数字为偶数时渲染Demo组件,奇数时卸载
  2. Demo组件挂载时创建定时器,卸载时清除定时器
  3. 主组件的数字变化时,更新定时器并实时打印

主组件 App.jsx

import { useState, useEffect } from 'react'
import Demo from './Demo';
export default function App() {
  const [num, setNum] = useState(0);
  // 依赖num的副作用,处理定时器
  useEffect(() => {
    const timer = setInterval(() => {
      console.log('当前num:', num);
    }, 1000);
    return () => clearInterval(timer);
  }, [num]);

  // 条件渲染:num为偶数时渲染Demo组件
  return (
    <div onClick={() => setNum(pre => pre + 1)} style={{ fontSize: '24px' }}>
      点击修改数字:{num}
      {num % 2 === 0 && <Demo />}
    </div>
  )
}

子组件 Demo.jsx

import { useEffect } from 'react'
export default function Demo() {
  // 空依赖:仅挂载时创建定时器,卸载时清除
  useEffect(()=>{
      console.log('Demo组件挂载');
      const timer=setInterval(()=>{
          console.log('Demo组件的定时器');
      },1000)
      // 组件卸载时执行,清除定时器
      return ()=>{
          console.log('Demo组件卸载,清除定时器');
          clearInterval(timer)
      }
  },[])
  return <div style={{ marginTop: '20px' }}>我是偶数时显示的Demo组件</div>
}

案例效果

  1. 初始 num=0(偶数),渲染 Demo 组件,Demo 挂载并创建定时器
  2. 点击一次 num=1(奇数),卸载 Demo 组件,执行 Demo 的清理函数清除定时器
  3. 每次点击修改 num,主组件的useEffect都会先清除旧定时器,再创建新定时器
  4. 组件卸载时,所有定时器都会被清除,无内存泄漏

六、总结

本文从基础的纯函数与副作用出发,讲解了 React 中最核心的两个 Hooks,核心知识点总结如下:

  1. 纯函数:相同输入返回相同输出,无副作用、同步执行;副作用:修改外部变量、请求、定时器等对外部环境的操作
  2. useState:为函数组件添加响应式状态,支持函数式初始化(复杂计算)和函数式更新(依赖上一次状态)
  3. useEffect:处理所有副作用,通过依赖项数组控制执行时机,返回清理函数清除副作用,避免内存泄漏
  4. Hooks 通用规则:仅在函数组件 / 自定义 Hooks 的顶层调用
  5. 异步请求初始化数据:使用useEffect空依赖实现,而非useState的初始化函数

useStateuseEffect是 React Hooks 的基础,掌握这两个 Hooks,就能实现大部分函数组件的开发需求。后续可以继续学习useRefuseContextuseReducer等进阶 Hooks,以及自定义 Hooks 的封装,让 React 代码更简洁、更高效。

最后:建议大家跟着本文的案例手动敲一遍代码,体会状态更新和副作用执行的时机,只有实战才能真正掌握 Hooks 的核心逻辑!

Elpis NPM 包抽离过程

作者 温言winslow
2026年2月15日 21:25

Elpis NPM 包抽离过程 - 难点与卡点分析

背景

将 elpis 从一个独立运行的应用抽离成可被其他项目引用的 npm 包(@shhhwm/elpis)。


难点一:路径解析的根本性变化

问题描述

独立应用时,process.cwd()__dirname 指向同一个项目根目录。但作为 npm 包被引用后:

  • process.cwd() → 业务项目根目录
  • __dirname → node_modules/@shhhwm/elpis/xxx

解决方案

所有 loader 都需要区分两套路径:

// elpis 框架自身的路径(使用 __dirname)
const elpisControllerPath = path.resolve(__dirname, `..${sep}..${sep}app${sep}controller`);

// 业务项目的路径(使用 process.cwd())
const businessControllerPath = path.resolve(app.businessPath, `.${sep}controller`);

难点二:Webpack Loader 找不到

问题描述

作为 npm 包时,webpack 配置中直接写 loader: "vue-loader" 会报错找不到 loader,因为 webpack 默认从业务项目的 node_modules 查找。

解决方案

所有 loader 配置改用 require.resolve() 确保从 elpis 包内解析:

// 错误写法
use: "vue-loader"

// 正确写法
use: require.resolve("vue-loader")

同理,样式相关 loader 也需要处理:

use: [
  require.resolve("style-loader"),
  require.resolve("css-loader"),
  require.resolve("less-loader"),
]

难点三:双层加载机制设计

问题描述

框架需要同时加载自身的 controller/service/router 和业务项目的,且业务项目的可以覆盖框架的。

解决方案

所有 loader 改造为"先加载 elpis,再加载业务"的模式:

// 1. 先加载 elpis 框架的 controller
const elpisFileList = glob.sync(path.resolve(elpisControllerPath, `.${sep}**${sep}**.js`));
elpisFileList.forEach((file) => handleFile(file));

// 2. 再加载业务项目的 controller(可覆盖同名)
const businessFileList = glob.sync(path.resolve(businessControllerPath, `.${sep}**${sep}**.js`));
businessFileList.forEach((file) => handleFile(file));

config 加载也是类似逻辑,业务配置覆盖框架默认配置:

defaultConfig = {
  ...elpisDefaultConfig,
  ...businessConfig,
};

难点四:前端钩子扩展机制

问题描述

框架提供了 dashboard、schema-form、schema-table 等通用页面,但业务项目需要能够:

  1. 扩展路由
  2. 扩展自定义组件
  3. 扩展表单项类型

如何在编译时动态判断业务项目是否提供了扩展配置?

解决方案

引入 blank.js 空模块 + webpack alias 动态判断:

// blank.js - 空模块作为默认值
module.exports = {};

webpack alias 配置中动态判断:

const blankModulePath = path.resolve(__dirname, "../libs/blank.js");

// 判断业务项目是否提供了路由扩展配置
const businessDashboardRouterConfig = path.resolve(
  process.cwd(),
  "./app/pages/dashboard/router.js"
);
aliasMap["$businessDashboardRouterConfig"] = fs.existsSync(businessDashboardRouterConfig)
  ? businessDashboardRouterConfig
  : blankModulePath;

业务代码中使用:

import businessDashboardRouterConfig from "$businessDashboardRouterConfig";

// 如果业务项目提供了扩展,则执行
if (typeof businessDashboardRouterConfig === "function") {
  businessDashboardRouterConfig({ routes, siderRoutes });
}

支持的扩展点:

  • $businessDashboardRouterConfig - 路由扩展
  • $businessComponentConfig - schema-view 组件扩展
  • $businessFormItemConfig - schema-form 表单项扩展
  • $businessSearchItemConfig - schema-search-bar 搜索项扩展

难点五:依赖分类调整

问题描述

npm 包被安装时,devDependencies 不会被安装。但 webpack、babel、loader 等构建依赖在运行时是必需的。

解决方案

将构建相关依赖从 devDependencies 移到 dependencies

{
  "dependencies": {
    "webpack": "^5.88.1",
    "webpack-cli": "^5.1.4",
    "vue-loader": "^17.2.2",
    "babel-loader": "^8.0.4",
    "css-loader": "^0.23.1",
    "less-loader": "^11.1.3",
    // ... 其他构建依赖
  },
  "devDependencies": {
    // 只保留开发/测试工具
    "eslint": "^7.32.0",
    "mocha": "^6.1.4"
  }
}

难点六:中间件分层加载

问题描述

框架有自己的全局中间件,业务项目也可能有自己的中间件,需要确保加载顺序正确。

解决方案

在 elpis-core 中分两步加载:

// 1. 先注册 elpis 框架的全局中间件
const elpisMiddleware = require(`${elpisPath}/app/middleware.js`);
elpisMiddleware(app);

// 2. 再注册业务项目的全局中间件
try {
  require(`${app.businessPath}/middleware.js`)(app);
} catch (e) {
  console.log("[exception] there is no global business middleware file.");
}

难点七:入口改造为 SDK 模式

问题描述

原来是直接启动应用,现在需要导出 API 供业务项目调用。

解决方案

// 之前:直接执行
ElpisCore.start({ name: 'Elpis' });

// 之后:导出 SDK 接口
module.exports = {
  Controller: { Base: require("./app/controller/base.js") },
  Service: { Base: require("./app/service/base.js") },

  frontendBuild(env) {
    if (env === "local") FEBuildDev();
    else if (env === "production") FEBuildProd();
  },

  serverStart(options) {
    return ElpisCore.start(options);
  }
};

webpack 构建脚本也从"直接执行"改为"导出函数":

// 之前
const compiler = webpack(webpackConfig);
app.listen(port);

// 之后
module.exports = () => {
  const compiler = webpack(webpackConfig);
  app.listen(port);
};

总结

难点 核心问题 解决思路
路径解析 cwd vs dirname 语义变化 区分 elpis 路径和业务路径
Loader 找不到 webpack 默认从业务项目查找 使用 require.resolve()
双层加载 框架和业务代码需要合并 先加载框架,再加载业务
钩子扩展 编译时动态判断扩展配置 blank.js + webpack alias
依赖分类 devDeps 不会被安装 构建依赖移到 dependencies
中间件分层 加载顺序问题 框架中间件先于业务中间件
SDK 模式 从应用变为库 导出函数而非直接执行

AI Agent开发之向量检索:一篇讲清「稀疏 + 稠密 + Hybrid Search」怎么落地

2026年2月15日 20:02

AI Agent开发之向量检索:一篇讲清「稀疏 + 稠密 + Hybrid Search」怎么落地

核心结论

在 AI 搜索和知识库场景中,混合检索(Hybrid Search)是当前最优解:

  • 稠密向量(Dense):擅长处理语义相似的查询,能够理解同义词、口语化表达
  • 稀疏向量(Sparse):擅长精确匹配关键词,如产品名称、接口名、错误码等专有术语
  • 混合检索(Hybrid):通过 RRF(Reciprocal Rank Fusion)算法融合两者优势,在生产环境中表现最稳定

单独使用稠密向量会导致专有名词召回不准确,而仅使用稀疏向量则无法理解语义相近的不同表述。混合检索能够同时规避这两个问题。

应用场景与痛点分析

典型应用场景

混合检索方案特别适用于以下前端场景:

  • 站内搜索:用户使用自然语言或关键词检索站内内容
  • 帮助中心问答:智能匹配用户问题与知识库文档
  • 聊天助手上下文召回:为 AI 助手提供相关上下文信息

单一检索方案的局限性

仅使用稠密向量检索时的问题:

  • 专有名词召回不稳定:如 "ERR_CONNECTION_RESET" 等错误码可能无法准确匹配
  • 短查询偏移:当用户输入 2~6 个词的短查询时,容易产生语义偏移

仅使用 BM25/关键词检索时的问题:

  • 语义理解缺失:"登录失败" 和 "无法完成认证" 虽然语义相近,但因关键词不同导致召回效果差

技术架构

flowchart LR
  A["文档内容"] --> B["生成稠密向量<br/>Embedding"]
  A --> C["生成稀疏向量<br/>分词+词频"]
  B --> D["写入向量库(dense)"]
  C --> E["写入向量库(sparse)"]

  Q["用户Query"] --> Q1["Query Embedding"]
  Q --> Q2["Query Sparse Vector"]

  Q1 --> S1["Dense Search"]
  Q2 --> S2["Sparse Search"]
  S1 --> F["RRF 融合排序"]
  S2 --> F
  F --> R["TopK 返回前端"]

稀疏向量与稠密向量的本质区别

稀疏向量(Sparse)

  • 来源:分词 + 词频(TF),可叠加 IDF/BM25
  • 特征:高维稀疏(大部分是 0)
  • 长处:关键词强匹配、可解释

示例文本:我是好学生,每天8点起床

分词后:

["我", "是", "好", "学生", "每天", "8", "点", "起床"]

稀疏结构(示意):

{
  indices: [102, 1552, 30091],
  values: [1, 1, 1]
}

稠密向量(Dense)

  • 来源:Embedding 模型(如 text-embedding-3-small
  • 特征:低维连续浮点向量
  • 长处:语义理解强(能懂同义改写)

核心实现

以下代码采用通用写法,不依赖特定项目结构,可直接迁移到任意 TypeScript 项目中

文档入库:双向量写入策略

async function addDocument(content: string, metadata?: Record<string, any>) {
  const dense = await embedText(content) // number[]
  const sparse = textToSparseVector(content) // { indices, values }

  await qdrant.upsert('documents', {
    points: [
      {
        id: crypto.randomUUID(),
        vector: {
          dense,
          bm25: sparse,
        },
        payload: { content, metadata },
      },
    ],
  })
}

稀疏向量生成:分词 + 哈希 + 词频统计

import { createRequire } from 'node:module'
import { Jieba } from '@node-rs/jieba'

type SparseVector = { indices: number[]; values: number[] }

const require = createRequire(import.meta.url)
const { dict } = require('@node-rs/jieba/dict') as { dict: Uint8Array }
const jieba = Jieba.withDict(dict)

function fnv1aHash(str: string): number {
  let hash = 0x811c9dc5
  for (let i = 0; i < str.length; i++) {
    hash ^= str.charCodeAt(i)
    hash = Math.imul(hash, 0x01000193)
  }
  return hash >>> 0
}

function textToSparseVector(text: string): SparseVector {
  const tokens = jieba
    .cutForSearch(text, true)
    .map((t) => t.trim().toLowerCase())
    .filter(Boolean)
    .filter((t) => !/^[\p{P}\p{S}\p{Z}]+$/u.test(t))

  const tf = new Map<number, number>()
  for (const token of tokens) {
    const idx = fnv1aHash(token)
    tf.set(idx, (tf.get(idx) ?? 0) + 1)
  }

  const entries = [...tf.entries()].sort((a, b) => a[0] - b[0])
  return {
    indices: entries.map(([i]) => i),
    values: entries.map(([, v]) => v),
  }
}

向量数据库配置:双向量索引声明

await qdrant.createCollection('documents', {
  vectors: {
    dense: { size: 512, distance: 'Cosine' },
  },
  sparse_vectors: {
    bm25: { modifier: 'idf' },
  },
})

说明:

  • 稀疏向量通常先传 TF(词频)
  • IDF 在向量库侧处理(这里是 modifier: 'idf'

查询实现:三种检索模式

type SearchMode = 'dense' | 'sparse' | 'hybrid'

async function search(query: string, topK = 5, mode: SearchMode = 'hybrid') {
  const querySparse = textToSparseVector(query)
  const queryDense = mode === 'sparse' ? null : await embedText(query)

  if (mode === 'dense') return searchDense(queryDense!, topK)
  if (mode === 'sparse') return searchSparse(querySparse, topK)

  const [denseRes, sparseRes] = await Promise.all([
    searchDense(queryDense!, topK),
    searchSparse(querySparse, topK),
  ])
  return fuseByRRF(denseRes, sparseRes, topK)
}

RRF 融合算法:工程化的最佳选择

const RRF_K = 60
const rrf = (rank: number) => 1 / (RRF_K + rank + 1)

RRF(Reciprocal Rank Fusion)算法的核心思想是基于排名而非分数进行融合。当同一文档在稠密检索和稀疏检索的结果中排名都靠前时,其最终融合分数会更高。

相比于传统的加权融合方法,RRF 的优势在于:

  • 无需手动调整稠密向量和稀疏向量的权重比例
  • 对不同业务场景的适应性更强
  • 实现简单且效果稳定

实施路径

基于上述技术方案,完整的实施流程包括以下步骤:

  1. 选择 Embedding 模型:初期可选择 512 维的轻量级模型,平衡性能与成本
  2. 实现双向量生成:在文本入库时同时生成稠密向量和稀疏向量
  3. 配置向量数据库:创建包含 vectorssparse_vectors 的集合
  4. 实现混合检索:搜索接口默认使用 hybrid 模式
  5. 提供模式切换:为前端提供检索模式切换能力,支持关键词优先场景(sparse 模式)

常见问题与最佳实践

稀疏查询向量为空的处理

当查询文本全是标点符号或停用词时,稀疏向量可能为空。此时应返回空数组或降级到纯稠密检索,避免出现异常。

稠密向量的必要性检查

在稠密检索分支中,必须确保 embedding 已成功生成。空向量应直接抛出错误,而非静默返回不可靠的结果。

向量维度变更与数据迁移

当 embedding 模型的向量维度发生变化(如从 512 维升级到 1536 维)时,现有的向量集合通常无法直接复用,需要重新生成所有文档的向量并迁移数据。

中文分词词典的重要性

业务专有术语如果未被包含在分词词典中,会显著影响稀疏向量的召回效果。建议根据业务场景定制分词词典,加入领域特定术语。

总结

混合检索方案将向量检索技术从算法研究转化为可落地的搜索体验工程实践:

  • 稠密向量负责语义理解,解决同义词和口语化表达问题
  • 稀疏向量负责关键词精确匹配,确保专有名词召回准确性
  • 混合检索通过 RRF 算法融合两者优势,保证生产环境的稳定性

《对象与解构赋值:接口数据解包的 10 个常见写法》

作者 SuperEugene
2026年2月15日 19:14

前言

后台接口返回的数据,常常是嵌套对象或数组,很多人习惯一层层 data.user.name 这样写,既啰嗦又容易在某一层是 undefined 时直接报错。
解构赋值 + 默认值,可以把取数写得又短又安全。本文用 10 个常见写法,帮你把「接口数据解包」这件事理清楚。

适合读者:

  • 已经会写 JS,但对解构、默认值组合用法不熟
  • 刚学 JS,希望一开始就养成规范写法
  • 有一定经验,想统一团队里的接口数据处理方式

一、先搞清楚:解构在干什么

解构不是黑魔法,本质是按结构从对象/数组中「拆包」出变量,语法更短,逻辑更直观。

// 传统写法:手动挨个取值
const user = { name: '张三', age: 28, city: '北京' };
const name = user.name;
const age = user.age;

// 解构写法:一次性拆出来
const { name, age } = user;

如果接口返回的 user 某天变成 null,传统写法会在 user.name 直接报错,解构可以配合默认值一起用,后面会展开。

二、接口数据解包的 10 个常见写法

假设后台返回结构类似:

{
  code: 200,
  data: {
    user: {
      id: 1,
      name: '李四',
      profile: {
        avatar: 'https://xxx/avatar.png',
        bio: '前端工程师'
      }
    },
    list: [
      { id: 1, title: '文章1' },
      { id: 2, title: '文章2' }
    ]
  }
}

下面 10 个写法,都是日常会用到的。

写法 1:只解构第一层,其余用 rest 收走

const { user, list, ...rest } = response.data;
// user、list 单独用,其他字段在 rest 里

适用: 只需要其中几个字段,但不想丢掉其他字段。
注意: rest 不会包含已解构的 userlist

写法 2:解构 + 默认值,防止 undefined

const { user = {}, list = [] } = response.data || {};

适用: 接口可能返回 datanullundefined,或字段缺失。
注意: 默认值只在值为 undefined 时生效,null 不会触发默认值。

写法 3:多层嵌套一次解构

const { user: { profile: { avatar, bio } = {} } = {} } = response.data || {};

适用: 需要深层字段,不想写 data.user.profile.avatar
踩坑: 每一层都要给默认值 = {},否则中间某层是 undefined 会报错。

写法 4:解构时重命名,避免变量冲突

const { user: currentUser, list: articleList } = response.data || {};

适用: 接口字段名不直观,或和已有变量重名。
语法: 原属性名: 新变量名

写法 5:解构 + 默认值 + 重命名一起用

const { user: currentUser = {}, list: articleList = [] } = response.data || {};

适用: 既要改名,又要防缺。
推荐: 作为接口数据解包的常规写法,可读性和安全性都较好。

写法 6:数组解构取首项

const [firstItem] = response.data?.list || [];

适用: 列表只关心第一项(例如「最新一条」)。
注意: 用可选链 ?.|| [] 避免 listnull/undefined 时报错。

ps· 如果你不知道可选链请点击这里,一文让你轻松了解

写法 7:解构数组元素并设默认值

const [first = {}, second = {}] = response.data?.list || [];

适用: 需要前几项,且要保证拿到的一定是对象。
注意: 空数组时 firstsecond 都是 {}

写法 8:在 map 中解构,简化遍历

const titles = (response.data?.list || []).map(({ id, title }) => title);

适用: 列表只需部分字段,不想写 item.iditem.title
好处: 代码短,意图清晰。

写法 9:解构函数参数,配合默认值

function renderUser({ name = '游客', avatar = '/default.png' } = {}) {
  // 函数内部直接用 name、avatar
}
renderUser(response.data?.user); // 即使传入 undefined 也不报错

适用: 组件、工具函数接收配置对象时。
双重默认值:

  • = {}:整个参数缺失时
  • name = '游客'name 缺失时

写法 10:安全取出深层字段的「一层层解构」写法

const { data } = response || {};
const { user } = data || {};
const { profile } = user || {};
const { avatar } = profile || {};

// 或者一行(每层都要默认值)
const avatar = ((response || {}).data || {}).user?.profile?.avatar ?? '默认头像';

适用: 接口结构不稳定,或经常变更。
建议: 优先用可选链 ?. 和空值合并 ??,逻辑更简洁。

三、容易踩的坑

1. 默认值只对 undefined 生效

const { name = '默认' } = { name: null };
// name 是 null,不是 '默认'

需要兼容 null 时,用空值合并运算符 ??

const name = (obj.name ?? '默认');

2. 嵌套解构少了中间层的默认值

const { user: { profile } } = response.data;  // 若 user 为 undefined,直接报错
const { user: { profile } = {} } = response.data;  // 依然可能报错,user 本身可能 undefined
const { user: { profile } = {} } = response.data || {};  // 正确:两层都要有兜底

3. 解构赋值和变量声明混在一起

const obj = { a: 10 };

// ✅ 正确:声明+解构一步完成({}是声明语法的一部分,解析器认解构)
let {a} = obj; 

let b;
// {b} = obj; // ❌ 报错:语句开头的{}被解析为“块级作用域”,而非解构
({b} = obj);  // ✅ 正确:括号让{}变成表达式,解析器认解构

4. 把 rest 用在已解构过的属性上

const obj = { a: 1, b: 2, c: 3 };
// 解构:单独取出a,剩余属性打包到rest
const { a, ...rest } = obj;

console.log(a);    // 输出:1(单独提取的a)
console.log(rest); // 输出:{ b: 2, c: 3 }(rest不含已解构的a)

四、实战推荐写法模板

通用接口解包:

const response = {
  code: 200,
  msg: "请求成功",
  data: {
    user: {
      name: "张三",
      age: 25,
      profile: {
        avatar: "https://example.com/avatar.jpg"
      }
    },
    list: [
      { id: 1, title: "文章1", content: "内容1" },
      { id: 2, title: "文章2", content: "内容2" }
    ]
  }
};

// 1. 最外层兜底:避免response/null/undefined导致解构报错
const { data = {} } = response || {};
// 2. 解构data层:给user/List设默认值,避免属性不存在
const { user = {}, list = [] } = data;

// 3. 深层解构user:给profile兜底,避免profile为undefined时报错
const { name, profile: { avatar } = {} } = user;

// 4. 列表解构:只提取需要的id/title,过滤无用字段
const items = list.map(({ id, title }) => ({ id, title }));

// 输出结果(验证解构效果)
console.log(name);   // 张三
console.log(avatar); // https://example.com/avatar.jpg
console.log(items);  // [{id:1,title:"文章1"}, {id:2,title:"文章2"}]

封装成工具函数:

function parseUserResponse(response) {
  const { data: { user = {} } = {} } = response || {};
  const { name = '未知', profile: { avatar = '/default.png' } = {} } = user;
  return { name, avatar };
}

五、小结

场景 推荐写法
防缺 const { a = {} } = obj || {}
嵌套解构 每一层都写 = {} 兜底
需要改名 const { a: newName } = obj
取列表首项 const [first] = list || []
列表 map list.map(({ id, title }) => ...)
函数参数 ({ a = 1 } = {}) 双重默认值

记住一点:解构是语法糖,默认值是兜底,把两者结合起来,接口数据处理会干净很多,也更容易排查问题。


以上就是本次的学习分享,欢迎大家在评论区讨论指正,与大家共勉。

我是 Eugene,你的电子学友。

如果文章对你有帮助,别忘了点赞、收藏、加关注,你的认可是我持续输出的最大动力~

用 ASCII 草图 + AI 快速生成前端代码

作者 风象南
2026年2月16日 08:05

引言

从想法到代码,中间往往要经历画原型、出设计稿等环节。

用 ASCII 草图,可以跳过大量原型绘制、结构拆解和手动搭骨架的中间步骤。

这种表达方式其实一直存在,但真正让它进入工程流程的,是 AI 的能力提升。大语言模型对结构化文本具有很强的解析能力,能够识别文本中的层级、对齐关系与空间划分,并将这些结构信息稳定地映射为组件树和页面布局。

因此,ASCII 不再只是沟通草稿,而成为一种可执行的结构描述。

什么是 "ASCII 草图"

提到 ASCII,很多人的第一反应可能是那个年代久远的“字符画”。没错,ASCII 草图就是用字符来构建页面布局。

在 AI 时代,这种看似简陋的草图,其实蕴含着巨大的能量。大语言模型(LLM)对结构化文本的理解能力极强。相比于模糊的自然语言描述(“我要一个左边宽右边窄的布局”),ASCII 草图提供了一种所见即所得的结构化 Prompt

简单来说,ASCII 草图充当了视觉蓝图的角色,AI 根据这个结构生成代码。

为什么要让 AI 先生成 ASCII 草图 ?

你可能会想:直接让 AI 生成代码不就行了吗?为什么要中间多这一步?

这就涉及到一个沟通精度的问题。

直接描述布局的问题

用自然语言描述布局,很容易产生歧义。比如你说"左边放导航,右边放内容",AI 可能会理解成左右各占 50%,而你想要的是导航 200px 宽度。你说"卡片要突出一点",AI 理解的"突出"可能是加阴影,而你想要的是加大字号。

这些细节上的偏差,会导致生成出来的代码需要反复调整。

ASCII 草图作为中间层的价值

让 AI 先生成 ASCII 草图,相当于在需求和代码之间加了一个可视化确认步骤

结构一目了然:ASCII 图能直观展示层级关系、组件位置、相对大小,比自然语言描述更精确

快速迭代:草图不对,让 AI 改几句就行,比改代码快得多

专注布局:这一步只讨论结构,不涉及样式细节,避免过早陷入细节纠结

简单来说,ASCII 草图充当了视觉蓝图的角色——先确认布局结构没问题,再让 AI 填充代码实现。

实战演练:三步构建一个 Dashboard

光说不练假把式。假设我们要开发一个常见的后台管理系统 Dashboard,包含顶部导航、侧边栏、数据统计卡片和图表区域。

第一步:描述需求,让 AI 生成草图

你只需要用自然语言描述布局结构,让 AI 来生成 ASCII 草图。

Prompt 示例

> 我需要一个后台管理系统的 Dashboard 布局,包含:
> - 顶部导航栏:左侧是 LOGO,中间是导航菜单,右侧是用户头像
> - 左侧边栏:垂直排列的导航菜单项
> - 主内容区:
>   - 标题"Dashboard Overview"
>   - 三个横向排列的统计卡片:Users、Revenue、Orders
>   - 下方是一个大的区域图表
>
> 请帮我生成对应的 ASCII 布局草图。

AI 会输出类似这样的草图:

+-------------------------------------------------------+
|  LOGO    [ Home ] [ Dashboard ] [ Settings ]   [User] |
+-------------------------------------------------------+
|        |                                              |
| Menu   |  Dashboard Overview                         |
|        |                                              |
| [Nav1] |  +----------+  +----------+  +----------+   |
| [Nav2] |  | Users    |  | Revenue  |  | Orders   |   |
| [Nav3] |  |  1,234   |  |  $12,000 |  |  567     |   |
|        |  +----------+  +----------+  +----------+   |
|        |                                              |
|        |  +----------------------------------------+  |
|        |  |                                        |  |
|        |  |         Revenue Chart (Area)           |  |
|        |  |                                        |  |
|        |  +----------------------------------------+  |
+--------+----------------------------------------------+

这一步的核心是让 AI 帮你理清布局结构,而不是自己手工画图。

第二步:让 AI 根据草图生成代码

草图确认无误后,让 AI 基于这个结构生成实际代码。 Prompt 模板建议

> **角色设定**:你是一位精通现代前端架构的高级工程师。
> **任务**:请根据我提供的 ASCII 布局草图,生成对应的前端代码。
> **技术栈**:React + Tailwind CSS (或者 Vue3 + UnoCSS)。
> **具体要求**
> 1. 响应式设计:侧边栏在移动端折叠。
> 2. 组件化:请将顶栏、侧边栏、卡片、图表区域拆分为独立组件。
> 3. 样式:使用现代扁平化风格,配色参考 Stripe 官网。
>
> **ASCII 草图如下**
> [在此处粘贴上面的 ASCII 图]
第三步:见证奇迹,微调与落地

点击发送,AI 会迅速解析你的 ASCII 结构,并输出代码。

AI 的思考路径通常是这样的

1.解析外层结构:识别出 +---+| 包围的区域,判定这是一个 Header + Sidebar + Main Content 的经典布局。

2.识别组件:看到 Dashboard Overview 下的三个方块,识别为“统计卡片”,并且知道要复用三次。

3.推断样式:根据你的描述“现代扁平化”,它会自动填充 shadow-mdrounded-lg 等类名。

生成出来的代码通常已经具备了 80% 的可用性。你需要做的仅仅是:

  • 替换掉 AI 臆造的假数据。
  • 引入真实的图表库(如 Echarts 或 Recharts)替换占位符。
  • 微调一下 Tailwind 的间距。 短短几分钟,一个结构清晰、样式现代化的页面骨架就诞生了。

附效果图

111.png

进阶技巧:让 ASCII 更“懂” AI

如果你想把这把“瑞士军刀”用得更溜,这里有几个实战技巧

1. 标注优于复杂图形 不要试图用 ASCII 画出圆角或阴影,那是浪费时间。你应该在图形旁边写注释。 例如:

+-----------------+
| [Icon] Title    |  <-- 这里的 Icon 请使用 lucide-react
+-----------------+
| Content here... |  <-- 文字限制两行,超出省略
+-----------------+

AI 能够读懂这些注释,并将其转化为代码约束。

2. 模块化思维 面对复杂的页面,不要试图画一张巨大的图。你可以分块输入:

  • Prompt A:画 Header。
  • Prompt B:画 Sidebar。
  • Prompt C:画 Content Area。 最后让 AI 把它们组合起来。这样能大大降低 AI 解析错误的概率。

3. 迭代式修改 如果你觉得布局不对,不需要重画。直接在对话中修改字符:

  • 用户:“把侧边栏移到右边,宽度缩小一点。”
  • AI:(自动调整 CSS,将侧边栏 DOM 移到主内容区后面或改变 Flex 属性)。 这种**“草图重构”**比“代码重构”要快得多,也更直观。

局限性

草图虽然方便,在效率上有极大提升,但是也存在一定的限制

1. 细节缺失:ASCII 无法表达字体大小、微妙的颜色渐变或复杂的动画。它解决的是布局问题,而不是视觉设计问题。

2. 非结构化内容:如果是图文混排非常复杂的文章页,ASCII 往往难以精确描述,这时候不如直接写 HTML 伪代码。

3. 逻辑盲区:AI 生成的是 UI 骨架,具体的业务逻辑(点击按钮触发什么 API)依然需要你手动注入。

总结

从 ASCII 草图到前端代码,本质上是一种降低沟通损耗的尝试。它让我们从繁琐的 HTML 标签嵌套中解脱出来,回归到结构设计本身。

而 AI,则让这种朴素的结构表达拥有了执行力。

当机器能够理解结构,文本就不再只是说明,而成为代码的源头。

Memo Code 安全设计:子进程、命令防护与权限审批的统一方案

作者 mCell
2026年2月15日 22:34

同步至个人站点:Memo Code 安全设计:子进程、命令防护与权限审批的统一方案

202622

Memo Code 是我最近两个多月投入较多精力的 Agent 项目。类似于Claude Code 和 Codex 的 轻量级本地编程 Agent,目前已具备 Coding Agent 完备技能。

如果你感兴趣的话,欢迎参与:Memo Code - Github,或者给个 Star 鼓励一下哈哈~

做 Agent 这类能「替用户干活」的工具,安全性是躲不掉的坎。

我一开始做 memo(github.com/minorcell/m…)的时候,安全问题还没想那么多——能跑起来就行。后来工具越加越多,shell 命令也越跑越复杂,就开始踩坑了:

  • 子进程忘了关,内存慢慢涨
  • rm -rf / 差点真被我跑出来
  • 每次执行都要点批准,用户体验稀碎

这些问题逼着我认真设计了整套安全方案。今天把思路和实现细节都分享出来,希望对你有帮助。

先想清楚:安全设计要解决什么问题?

我把它拆成三件事:

  1. 资源可控:子进程不能无限开,不能忘了关
  2. 操作安全:危险命令要拦截,误操作要有缓冲
  3. 权限平衡:该拦的拦住,该放的放行,还要给用户留个「后门」

下面逐一展开。

第一道防线:子进程管理——防止内存泄漏与资源耗尽

memo 的 shell 执行用的是 Node.js 的 child_process.spawn,但光 spawn 是不够的——你还得管得住。

统一会话管理器

我写了一个 UnifiedExecManagerpackages/tools/src/tools/exec_runtime.ts),核心思路是单例 + 会话池

class UnifiedExecManager {
  private sessions = new Map<number, SessionState>()
  private nextId = 1
  private MAX_SESSIONS = 64
}

好处很明显:

  • 所有子进程都有唯一 ID
  • 随时可以查询状态、发送信号、获取输出
  • 资源回收有统一入口

资源限制:数量 + 内存 + 时间

先看数量限制:

async start(request: StartExecRequest) {
    this.cleanupSessions()
    if (this.activeSessionCount() >= MAX_SESSIONS) {
        throw new Error(`too many active sessions (max ${MAX_SESSIONS})`)
    }
    // ...
}

超过 64 个活跃会话就直接拒绝,防止被LLM恶意耗尽系统资源。

再看输出限制。Agent 交互是基于 token 计费的,子进程输出不能无限制返回:

function truncateByTokens(text: string, maxOutputTokens?: number) {
  const maxChars = (maxOutputTokens || 2000) * 4
  if (text.length <= maxChars) {
    return { output: text, deliveredChars: text.length }
  }
  return {
    output: text.slice(0, maxChars),
    deliveredChars: maxChars,
  }
}

默认最多返回 8000 字符,不够可以调,但不会无限大。

超时终止:SIGTERM → SIGKILL

子进程跑飞了是常见问题。memo 的策略是先礼貌后强硬

private async terminateForTimeout(session: SessionState) {
    if (session.exited) return
    session.proc.kill('SIGTERM')
    await waitForExit(session, 200)  // 等 200ms
    if (!session.exited) {
        session.proc.kill('SIGKILL')  // 还是没退就直接杀了
        await waitForExit(session, 200)
    }
}

为什么要等一下?因为有些程序接收到 SIGTERM 会做清理工作(比如写入缓存、关闭句柄),直接 SIGKILL 可能导致数据丢失。

内存泄漏防护:自动清理已退出的会话

会话不能只增不减。我加了一个自动清理逻辑:

private cleanupSessions() {
    if (this.sessions.size <= MAX_SESSIONS) return
    // 优先清理已退出的,按启动时间从早到晚排序
    const ended = Array.from(this.sessions.values())
        .filter(session => session.exited)
        .sort((a, b) => a.startedAtMs - b.startedAtMs)

    for (const session of ended) {
        if (this.sessions.size <= MAX_SESSIONS) break
        this.sessions.delete(session.id)
    }
}

这样即使跑了几百个命令,内存也不会无限涨。

第二道防线:命令守卫——拦截危险操作

子进程管住了还不够,还得管住跑什么命令

我见过太多「rm -rf /」惨案,也见过 dd if=/dev/zero of=/dev/sda 这种物理层面不可逆的破坏。memo 的做法是命令解析 + 黑名单匹配

命令解析:不只是字符串匹配

直接正则匹配 rm -rf 是有漏洞的。比如 sudo rm -rf /、包裹在 bash -c 里、甚至写成十六进制,都能绕过简单匹配。

memo 的做法是先把命令拆成「段」,再逐段解析:

function splitCommandSegments(command: string) {
  // 按 ; | && || 分割,处理引号和转义
  // 返回每一段独立的命令
}

function parseSegment(segment: string) {
  // 跳过 sudo/env/nohup 等包装
  // 提取真实的命令名和参数
}

这样不管外面包了多少层 sudo env bash -c,最终都能追溯到真正的命令。

危险命令黑名单

目前 memo 拦截这几类(packages/tools/src/tools/command_guard.ts):

规则 触发条件 危险等级
rm_recursive_critical_target rm -rf 目标包含 /~$HOME 等关键路径 极高
mkfs_filesystem_create mkfs/mkfs.xxx 极高
dd_write_block_device dd 写入 /dev/ 下的块设备 极高
disk_mutation_block_device fdisk/parted/shred 等操作块设备
redirect_block_device 输出重定向到 /dev/ 块设备

拦截后返回的是 <system_hint> 标记,不是直接报错,方便 Agent 理解为什么被拦:

<system_hint type="tool_call_denied"
    tool="exec_command"
    reason="dangerous_command"
    policy="blacklist"
    rule="rm_recursive_critical_target"
    command="rm -rf /">
    Blocked a high-risk shell command to prevent irreversible data loss.
    Use a safer and scoped alternative.
</system_hint>

第三道防线:审批系统——平衡权限与体验

命令守卫是第一道关卡,但还有很多「不危险但需要知道」的操作,比如写文件、改配置。审批系统的目标就是分级管理、可追溯、可配置

风险分级

memo 把工具分成三级(packages/tools/src/approval/constants.ts):

级别 含义 审批策略(auto 模式)
read 只读操作 免审批
write 文件修改 需审批
execute 执行命令 需审批

审批模式

  • auto 模式:只读工具免审批,写/执行类工具需要审批
  • strict 模式:所有工具都需要审批,一个都跑不掉
check(toolName: string, params: unknown): ApprovalCheckResult {
    if (ALWAYS_AUTO_APPROVE_TOOLS.has(toolName)) {
        return { needApproval: false, decision: 'auto-execute' }
    }

    const riskLevel = classifier.getRiskLevel(toolName)
    if (!classifier.needsApproval(riskLevel, approvalMode)) {
        return { needApproval: false, decision: 'auto-execute' }
    }
    // 生成指纹,返回需要审批
}

审批记忆:一次批准,记住一整场

如果每次执行都要点批准,用户体验会非常差。memo 用指纹 + 缓存解决这个问题:

const fingerprint = generateFingerprint(toolName, params)
cache.toolByFingerprint.set(fingerprint, toolName)

// 审批后记录
recordDecision(fingerprint, decision: 'session' | 'once' | 'deny') {
    switch (decision) {
        case 'session': cache.sessionTools.add(toolName); break
        case 'once': cache.onceTools.add(toolName); break
        case 'deny': cache.deniedTools.add(toolName); break
    }
}
  • session:这场对话内一直有效
  • once:用一次就失效
  • deny:以后再问直接拦截

dangerous 模式

审批系统是安全了,但有时候用户就是想要「无限制」——比如在本地开发、或者明确知道自己在干什么。

memo 提供了 dangerous 模式:

if (dangerous) {
  return {
    isDangerousMode: true,
    getRiskLevel: () => 'read', // 所有操作都视为最低风险
    check: () => ({ needApproval: false, decision: 'auto-execute' }),
    isGranted: () => true,
  }
}

开启也很简单,CLI 里加上 --dangerous 标记:

memo --dangerous

开启后:

  • 所有工具都免审批

这是一把双刃剑。 我在 CLI 里加了这个选项,但默认是关闭的。开发者如果想用,需要明确加上 --dangerous 标记。

总结:三层防护 + 一个后门

memo 的安全设计可以总结为:

  1. 子进程管理:数量限制 + 输出截断 + 超时终止 + 自动清理
  2. 命令守卫:命令解析 + 黑名单拦截 + stdin 检测
  3. 审批系统:风险分级 + 审批模式 + 记忆缓存
  4. dangerous 模式:留一个「我知道我在干什么」的后门

这套方案不完美,还在持续迭代。比如命令守卫目前是硬编码的黑名单,后续可以考虑支持用户自定义规则;审批系统也可以考虑接入外部信任模型。

(完)

昨天 — 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 做精读)也是值得探索的方向,可以兼顾效率和精确度。


【节点】[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与生成器

❌
❌