普通视图

发现新文章,点击刷新页面。
昨天 — 2026年1月10日技术

前端必备动态规划的10道经典题目

作者 颜酱
2026年1月10日 21:36

前端必备动态规划:10道经典题目详解(DP五部曲实战)

动态规划是前端算法面试的高频考点。本文通过「DP五部曲」框架,手把手带你掌握10道前端必备的DP题目,从基础递推到背包问题,每道题都包含详细注释、易错点分析和前端实际应用场景。

动态规划零基础的可以先补下

一、动态规划五部曲(核心框架)

无论什么DP问题,都可以按以下5个步骤拆解,这是解决DP问题的「万能钥匙」:

  1. 确定dp数组及下标的含义:明确 dp[i](或二维 dp[i][j])代表什么物理意义(比如"第i阶台阶的爬法数")
  2. 确定递推公式:找到 dp[i] 与子问题 dp[i-1]/dp[i-2] 等的依赖关系(核心)
  3. dp数组如何初始化:根据问题边界条件,初始化无法通过递推得到的基础值
  4. 确定遍历顺序:保证计算 dp[i] 时,其依赖的子问题已经被计算完成
  5. 打印dp数组(验证):通过打印中间结果,验证递推逻辑是否正确(调试必备)

下面结合具体问题,逐一实战这套框架。


二、入门级(3道,理解DP核心三步法,必刷)

1. LeetCode70. 爬楼梯 ★

题目链接70. 爬楼梯

难度:简单

核心:单状态转移,入门必做,会基础版 + 空间优化版

前端场景:步数计算、递归转迭代优化、分页器跳转步数计算、游戏角色移动路径数计算

题目描述

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬 12 个台阶。你有多少种不同的方法可以爬到楼顶呢?

示例 1

输入:n = 2
输出:2
解释:有两种方法可以爬到楼顶。
1. 1 阶 + 1 阶
2. 2 阶

示例 2

输入:n = 3
输出:3
解释:有三种方法可以爬到楼顶。
1. 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 2 阶
3. 2 阶 + 1 阶
DP五部曲分析
  1. dp数组含义dp[i] 表示爬到第i阶台阶的不同方法数
  2. 递推公式dp[i] = dp[i-1] + dp[i-2](到第i阶的方法=到i-1阶爬1步 + 到i-2阶爬2步)
  3. 初始化dp[1] = 1(1阶只有1种方法),dp[2] = 2(2阶有2种方法)
  4. 遍历顺序:从左到右(i从3到n)
  5. 打印验证:遍历过程中打印dp[i],验证方法数是否符合预期
完整版代码(二维DP思想,但实际用一维数组)
/**
 * LeetCode70. 爬楼梯
 * 时间复杂度:O(n)
 * 空间复杂度:O(n)
 */
function climbStairs(n) {
  // 【步骤1】确定dp数组及下标的含义
  // dp[i] 表示爬到第i阶台阶的不同方法数
  const dp = new Array(n + 1);

  // 【步骤3】dp数组如何初始化
  // 边界条件:1阶只有1种方法,2阶有2种方法
  if (n === 1) return 1;
  if (n === 2) return 2;

  dp[1] = 1; // 1阶:只有1种方法(直接爬1阶)
  dp[2] = 2; // 2阶:有2种方法(1+1 或 2)

  // 【步骤4】确定遍历顺序:从左到右,保证计算dp[i]时dp[i-1]和dp[i-2]已计算
  for (let i = 3; i <= n; i++) {
    // 【步骤2】确定递推公式
    // 到达第i阶只有两种方式:
    // 1. 从第i-1阶爬1步到达 → 方法数 = dp[i-1]
    // 2. 从第i-2阶爬2步到达 → 方法数 = dp[i-2]
    // 总方法数 = 两种方式的方法数之和(加法原理)
    dp[i] = dp[i - 1] + dp[i - 2];

    // 【步骤5】打印dp数组(验证) - 调试时可以取消注释
    // console.log(`dp[${i}] = ${dp[i]}`);
  }

  return dp[n];
}

// 测试用例
console.log(climbStairs(2)); // 2
console.log(climbStairs(3)); // 3
console.log(climbStairs(4)); // 5
console.log(climbStairs(5)); // 8
空间优化版(滚动数组)
/**
 * 空间优化版:滚动数组
 * 时间复杂度:O(n)
 * 空间复杂度:O(1) ← 从O(n)优化到O(1)
 *
 * 【优化思路】
 * 观察递推公式:dp[i] = dp[i-1] + dp[i-2]
 * 发现dp[i]只依赖前两个状态,不需要保存整个数组
 * 可以用三个变量滚动更新:prevPrev(dp[i-2]), prev(dp[i-1]), cur(dp[i])
 */
function climbStairs(n) {
  // 【易错点1】边界处理:n=1或n=2时需要提前返回
  // 如果n=1时进入循环,prevPrev=1, prev=2会计算出错误结果
  if (n === 1) return 1;
  if (n === 2) return 2;

  // 初始化:对应dp[1]=1, dp[2]=2
  let prevPrev = 1; // dp[i-2],初始表示dp[1]=1
  let prev = 2; // dp[i-1],初始表示dp[2]=2
  let cur;

  // 从第3阶开始计算
  for (let i = 3; i <= n; i++) {
    // 计算当前阶的方法数
    cur = prevPrev + prev;

    // 【易错点2】滚动更新顺序很重要:先更新prevPrev,再更新prev
    // 如果顺序错误(如先更新prev),会导致prevPrev获取到错误的值
    prevPrev = prev; // 下一轮的dp[i-2] = 当前的dp[i-1]
    prev = cur; // 下一轮的dp[i-1] = 当前的dp[i]
  }

  return cur;
}

前端应用场景

  • 分页器组件:计算从第1页跳转到第n页的不同路径数(每次可以跳1页或2页)
  • 游戏开发:角色在台阶上移动,每次可以走1步或2步,计算到达目标位置的方案数
  • 动画路径计算:计算元素从位置A到位置B的不同动画路径数量

2. LeetCode53. 最大子数组和 ★

题目链接53. 最大子数组和

难度:简单

核心:贪心 + DP 结合,理解「状态转移的条件选择」

前端场景:数据趋势统计、收益/数值最值分析、股票K线图最大收益区间、用户行为数据峰值分析

题目描述

给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

子数组是数组中的一个连续部分。

示例 1

输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:连续子数组 [4,-1,2,1] 的和最大,为 6。

示例 2

输入:nums = [1]
输出:1

示例 3

输入:nums = [5,4,-1,7,8]
输出:23
DP五部曲分析
  1. dp数组含义dp[i] 表示以 nums[i] 为结尾的最大子数组和(注意:必须以nums[i]结尾)
  2. 递推公式dp[i] = Math.max(nums[i], dp[i-1] + nums[i])(要么重新开始,要么延续前面的和)
  3. 初始化dp[0] = nums[0](第一个元素的最大子数组和就是它自己)
  4. 遍历顺序:从左到右(i从1到n-1)
  5. 打印验证:打印dp数组,找到最大值
完整版代码
/**
 * LeetCode53. 最大子数组和
 * 时间复杂度:O(n)
 * 空间复杂度:O(n)
 */
function maxSubArray(nums) {
  const len = nums.length;

  // 【易错点1】边界处理:空数组返回0
  if (len === 0) return 0;
  if (len === 1) return nums[0];

  // 【步骤1】确定dp数组及下标的含义
  // dp[i] 表示以nums[i]为结尾的最大子数组和(注意:必须以nums[i]结尾)
  // 这个定义很关键:保证子数组是连续的
  const dp = new Array(len);

  // 【步骤3】dp数组如何初始化
  // 第一个元素的最大子数组和就是它自己(没有前面的元素可以延续)
  dp[0] = nums[0];

  // 用于记录全局最大值(因为dp[i]只表示以i结尾的最大和,不一定全局最大)
  let maxSum = dp[0];

  // 【步骤4】确定遍历顺序:从左到右
  for (let i = 1; i < len; i++) {
    // 【步骤2】确定递推公式
    // 核心思想:如果前面的和是负数,不如重新开始(贪心思想)
    // 两种选择:
    // 1. 重新开始:只选当前元素nums[i](前面的和是负数,拖累总和)
    // 2. 延续前面的:dp[i-1] + nums[i](前面的和是正数,可以继续累加)
    dp[i] = Math.max(nums[i], dp[i - 1] + nums[i]);

    // 【易错点2】必须实时更新全局最大值
    // 因为dp[i]只是以i结尾的最大和,最终答案不一定是dp[len-1]
    maxSum = Math.max(maxSum, dp[i]);

    // 【步骤5】打印验证
    // console.log(`dp[${i}] = ${dp[i]}, maxSum = ${maxSum}`);
  }

  return maxSum;
}

// 测试用例
console.log(maxSubArray([-2, 1, -3, 4, -1, 2, 1, -5, 4])); // 6
console.log(maxSubArray([1])); // 1
console.log(maxSubArray([5, 4, -1, 7, 8])); // 23
console.log(maxSubArray([-1])); // -1
空间优化版(只需一个变量)
/**
 * 空间优化版:滚动变量
 * 时间复杂度:O(n)
 * 空间复杂度:O(1) ← 从O(n)优化到O(1)
 *
 * 【优化思路】
 * dp[i]只依赖dp[i-1],不需要保存整个数组
 * 用一个变量prev保存上一个状态即可
 */
function maxSubArray(nums) {
  const len = nums.length;
  if (len === 0) return 0;
  if (len === 1) return nums[0];

  // 用prev代替dp[i-1],初始值为dp[0]
  let prev = nums[0];
  let maxSum = prev;

  for (let i = 1; i < len; i++) {
    // 计算当前状态:要么重新开始,要么延续前面的
    prev = Math.max(nums[i], prev + nums[i]);

    // 更新全局最大值
    maxSum = Math.max(maxSum, prev);
  }

  return maxSum;
}

前端应用场景

  • 股票K线图:计算某段时间内买入卖出的最大收益(价格差的最大连续子数组和)
  • 用户行为分析:分析用户在某段时间内的活跃度峰值(数据流的最大连续区间和)
  • 性能监控:找到服务器响应时间最差的连续时间段(负值转换为响应时间)
  • 数据可视化:在折线图中高亮显示数据增长最快的连续区间

3. LeetCode198. 打家劫舍 ★

题目链接198. 打家劫舍

难度:简单

核心:状态转移的「条件限制」(相邻不选),基础空间优化

前端场景:资源筛选、最优选择问题、权限分配优化、任务调度(不能同时执行相邻任务)

题目描述

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组 nums,请计算你不触动警报装置的情况下,一夜之内能够偷窃到的最高金额。

示例 1

输入:nums = [1,2,3,1]
输出:4
解释:偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。总金额 = 1 + 3 = 4

示例 2

输入:nums = [2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 9),接着偷窃 5 号房屋(金额 = 1)。总金额 = 2 + 9 + 1 = 12
DP五部曲分析
  1. dp数组含义dp[i] 表示前i间房屋能偷到的最高金额
    • 可以用二维状态:dp[i][0] 表示第i间不偷,dp[i][1] 表示第i间偷
  2. 递推公式
    • dp[i][0] = Math.max(dp[i-1][0], dp[i-1][1])(不偷当前,前一间可偷可不偷)
    • dp[i][1] = dp[i-1][0] + nums[i-1](偷当前,前一间必须不偷)
  3. 初始化dp[0] = [0, 0](前0间,偷或不偷都是0)
  4. 遍历顺序:从左到右(i从1到n)
  5. 打印验证:打印dp数组验证
完整版代码(二维状态)
/**
 * LeetCode198. 打家劫舍
 * 时间复杂度:O(n)
 * 空间复杂度:O(n)
 */
function rob(nums) {
  const len = nums.length;

  // 【易错点1】边界处理
  if (len === 0) return 0;
  if (len === 1) return nums[0];

  // 【步骤1】确定dp数组及下标的含义
  // dp[i][0] = 前i间房屋,第i间不偷能获取的最高金额
  // dp[i][1] = 前i间房屋,第i间偷能获取的最高金额
  // 使用二维状态可以清晰地表达"相邻不能同时偷"的约束
  const dp = new Array(len + 1);

  // 【步骤3】dp数组如何初始化
  // 前0间房屋:不偷和偷的金额都是0(没有房屋可偷)
  dp[0] = [0, 0];

  // 【步骤4】确定遍历顺序:从左到右
  for (let i = 1; i <= len; i++) {
    // 【易错点2】数组索引转换:dp[i]对应nums[i-1]
    // dp[1]对应nums[0](第1间房屋),dp[2]对应nums[1](第2间房屋)
    const curVal = nums[i - 1];

    // 【步骤2】确定递推公式
    // 状态1:第i间不偷 → 前i-1间可以偷也可以不偷,取最大值
    const valNotThief = Math.max(...dp[i - 1]);

    // 状态2:第i间偷 → 前i-1间必须不偷(相邻不能同时偷),加上当前金额
    // 【易错点3】必须是dp[i-1][0],不能是dp[i-1][1](违反相邻规则)
    const valThief = curVal + dp[i - 1][0];

    // 更新当前状态
    dp[i] = [valNotThief, valThief];

    // 【步骤5】打印验证
    // console.log(`dp[${i}] = [不偷:${valNotThief}, 偷:${valThief}]`);
  }

  // 最终结果:前len间房屋偷或不偷的最大值
  return Math.max(...dp[len]);
}

// 测试用例
console.log(rob([1, 2, 3, 1])); // 4
console.log(rob([2, 7, 9, 3, 1])); // 12
console.log(rob([2, 1, 1, 2])); // 4
空间优化版(两个变量)
/**
 * 空间优化版:滚动变量
 * 时间复杂度:O(n)
 * 空间复杂度:O(1) ← 从O(n)优化到O(1)
 *
 * 【优化思路】
 * 观察递推公式:dp[i]只依赖dp[i-1]的两个值
 * 可以用两个变量vNot和vYes滚动更新
 */
function rob(nums) {
  const len = nums.length;
  if (len === 0) return 0;
  if (len === 1) return nums[0];

  // 初始化:对应dp[0] = [0, 0]
  let vNot = 0; // 前i间不偷的最大值
  let vYes = 0; // 前i间偷的最大值

  for (let i = 1; i <= len; i++) {
    const curVal = nums[i - 1];

    // 【易错点4】关键:提前保存上一轮的所有状态,避免更新时覆盖
    // 如果直接使用vNot和vYes,更新vNot时可能会用到已经更新的vYes值
    const prevNot = vNot;
    const prevYes = vYes;

    // 不偷当前间:上一轮偷或不偷的最大值
    vNot = Math.max(prevNot, prevYes);

    // 偷当前间:上一轮不偷的最大值 + 当前金额
    // 【易错点5】必须用prevNot,不能用vNot(因为vNot已经更新了)
    vYes = curVal + prevNot;
  }

  return Math.max(vNot, vYes);
}

前端应用场景

  • 任务调度:在任务列表中,某些任务不能同时执行(有依赖关系),求最大收益
  • 权限分配:某些权限不能同时授予(互斥权限),求最大权限价值组合
  • 资源选择:在资源列表中,相邻资源有冲突,求最优选择方案
  • 广告投放优化:相邻时段的广告不能同时投放,求最大收益的投放方案

三、经典应用级(4道,DP核心考点,高频考)

4. LeetCode62. 不同路径 ★

题目链接62. 不同路径

难度:中等

核心:二维DP基础(可优化为一维),理解「路径型DP」

前端场景:可视化布局路径计算、网格类问题、Canvas/SVG路径绘制、游戏地图路径规划

题目描述

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 "Start" )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 "Finish")。

问总共有多少条不同的路径?

示例 1

输入:m = 3, n = 7
输出:28

示例 2

输入:m = 3, n = 2
输出:3
解释:从左上角开始,总共有 3 条路径可以到达右下角:
1. 向右 → 向下 → 向下
2. 向下 → 向下 → 向右
3. 向下 → 向右 → 向下
DP五部曲分析
  1. dp数组含义dp[i][j] 表示从起点(0,0)走到位置(i,j)的不同路径数
  2. 递推公式dp[i][j] = dp[i-1][j] + dp[i][j-1](只能从上方或左方来)
  3. 初始化
    • 第一行所有位置:dp[0][j] = 1(只能从左边来)
    • 第一列所有位置:dp[i][0] = 1(只能从上边来)
  4. 遍历顺序:从上到下、从左到右(双重循环)
  5. 打印验证:打印dp数组验证
完整版代码(二维DP)
/**
 * LeetCode62. 不同路径
 * 时间复杂度:O(m * n)
 * 空间复杂度:O(m * n)
 */
function uniquePaths(m, n) {
  // 【步骤1】确定dp数组及下标的含义
  // dp[i][j] 表示从左上角(0,0)走到位置(i,j)的不同路径数
  // 为了方便处理边界,使用dp[i+1][j+1]表示网格(i,j)的路径数
  const dp = new Array(m + 1).fill(0).map(() => new Array(n + 1).fill(0));

  // 【步骤3】dp数组如何初始化
  // 第一行(i=1):所有位置只能从左边来,路径数都是1
  for (let j = 1; j <= n; j++) {
    dp[1][j] = 1;
  }

  // 第一列(j=1):所有位置只能从上边来,路径数都是1
  for (let i = 1; i <= m; i++) {
    dp[i][1] = 1;
  }

  // 【步骤4】确定遍历顺序:从上到下、从左到右
  // 从(2,2)开始,因为第一行和第一列已经初始化
  for (let i = 2; i <= m; i++) {
    for (let j = 2; j <= n; j++) {
      // 【步骤2】确定递推公式
      // 走到(i,j)只有两种方式:
      // 1. 从上方(i-1,j)向下走一步 → 路径数 = dp[i-1][j]
      // 2. 从左方(i,j-1)向右走一步 → 路径数 = dp[i][j-1]
      // 总路径数 = 两种方式的路径数之和(加法原理)
      dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
    }
  }

  // 【步骤5】打印验证(调试时取消注释)
  // console.log('DP数组:');
  // for (let i = 1; i <= m; i++) {
  //   console.log(dp[i].slice(1).join(' '));
  // }

  return dp[m][n];
}

// 测试用例
console.log(uniquePaths(3, 7)); // 28
console.log(uniquePaths(3, 2)); // 3
console.log(uniquePaths(7, 3)); // 28
空间优化版(一维DP)
/**
 * 空间优化版:一维DP
 * 时间复杂度:O(m * n)
 * 空间复杂度:O(n) ← 从O(m*n)优化到O(n)
 *
 * 【优化思路】
 * 观察递推公式:dp[i][j] = dp[i-1][j] + dp[i][j-1]
 * 计算第i行时,只需要用到:
 * 1. 上一行第j列的值(dp[i-1][j])→ 对应更新前的dp[j]
 * 2. 当前行第j-1列的值(dp[i][j-1])→ 对应更新后的dp[j-1]
 * 可以用一维数组dp[j]滚动更新
 */
function uniquePaths(m, n) {
  // 【步骤1】一维dp数组:dp[j]表示当前行第j列的路径数
  // 初始化为第一行的值:所有位置路径数都是1(只能从左边来)
  const dp = new Array(n + 1).fill(1);

  // 【步骤4】确定遍历顺序:从第2行开始遍历
  for (let i = 2; i <= m; i++) {
    // 【易错点1】j从2开始,因为第1列(j=1)的值永远是1(只能从上边来)
    for (let j = 2; j <= n; j++) {
      // 【步骤2】递推公式(一维版)
      // dp[j](更新前)= 上一行第j列的路径数(dp[i-1][j])
      // dp[j-1](更新后)= 当前行第j-1列的路径数(dp[i][j-1])
      // 更新:dp[j] = dp[j](旧值,来自上方)+ dp[j-1](新值,来自左方)
      dp[j] = dp[j] + dp[j - 1];

      // 【易错点2】注意:这里dp[j-1]是已经更新的值(当前行),
      // 而dp[j]是旧值(上一行),正好符合递推公式的需求
    }
  }

  return dp[n];
}

前端应用场景

  • Canvas/SVG路径绘制:计算从起点到终点的不同绘制路径数量
  • 游戏地图:计算角色从起点到终点的移动方案数(只能向右或向下)
  • 网格布局计算:在CSS Grid或Flex布局中,计算元素排列的不同路径数
  • 路由规划:在地图应用中,计算从A点到B点的不同路线数量

5. LeetCode63. 不同路径 II

题目链接63. 不同路径 II

难度:中等

核心:带障碍的路径DP,学会「状态转移的边界判断」

前端场景:网格布局中的障碍物处理、表单验证路径计算、游戏地图障碍物路径规划

题目描述

一个机器人位于一个 m x n 网格的左上角(起始点在下图中标记为 "Start" )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 "Finish")。

现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?

网格中的障碍物和空位置分别用 10 来表示。

示例 1

输入:obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]]
输出:2
解释:3x3 网格的正中间有一个障碍物。
从左上角到右下角一共有 2 条不同的路径:
1. 向右 → 向右 → 向下 → 向下
2. 向下 → 向下 → 向右 → 向右

示例 2

输入:obstacleGrid = [[0,1],[0,0]]
输出:1
DP五部曲分析
  1. dp数组含义dp[i][j] 表示从起点到达位置(i,j)的路径数
  2. 递推公式
    • 如果(i,j)是障碍物:dp[i][j] = 0
    • 否则:dp[i][j] = dp[i-1][j] + dp[i][j-1]
  3. 初始化
    • 第一行:遇到障碍物前都是1,遇到障碍物后都是0
    • 第一列:遇到障碍物前都是1,遇到障碍物后都是0
  4. 遍历顺序:从上到下、从左到右
  5. 打印验证:打印dp数组验证
完整版代码(二维DP)
/**
 * LeetCode63. 不同路径 II(带障碍物)
 * 时间复杂度:O(m * n)
 * 空间复杂度:O(m * n)
 */
function uniquePathsWithObstacles(obstacleGrid) {
  const m = obstacleGrid.length;
  const n = obstacleGrid[0].length;

  // 【易错点1】边界处理:起点或终点是障碍物,直接返回0
  if (obstacleGrid[0][0] === 1 || obstacleGrid[m - 1][n - 1] === 1) {
    return 0;
  }

  // 【步骤1】确定dp数组及下标的含义
  // dp[i][j] 表示从起点到达位置(i-1,j-1)的路径数(索引从1开始便于处理边界)
  const dp = new Array(m + 1).fill(0).map(() => new Array(n + 1).fill(0));

  // 【步骤3】dp数组如何初始化
  // 初始化第一行:只能从左边来,遇到障碍物则后续位置无法到达
  for (let j = 1; j <= n; j++) {
    const curGrid = obstacleGrid[0][j - 1]; // 网格索引转换
    if (curGrid === 1) {
      // 【易错点2】遇到障碍物,后续位置都无法到达,直接跳出
      break;
    }
    dp[1][j] = 1;
  }

  // 初始化第一列:只能从上边来,遇到障碍物则后续位置无法到达
  for (let i = 2; i <= m; i++) {
    // 【易错点3】从i=2开始,因为dp[1][1]已在第一行初始化
    const curGrid = obstacleGrid[i - 1][0];
    if (curGrid === 1) {
      break;
    }
    dp[i][1] = 1;
  }

  // 【步骤4】确定遍历顺序:从第2行第2列开始
  for (let i = 2; i <= m; i++) {
    for (let j = 2; j <= n; j++) {
      // 网格索引转换:dp[i][j]对应网格(i-1, j-1)
      const curGrid = obstacleGrid[i - 1][j - 1];

      // 【步骤2】确定递推公式
      if (curGrid === 1) {
        // 【易错点4】当前位置是障碍物,无法到达,路径数为0
        dp[i][j] = 0;
      } else {
        // 当前位置不是障碍物,可以从上方或左方到达
        dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
      }
    }
  }

  return dp[m][n];
}
空间优化版(一维DP)
/**
 * 空间优化版:一维DP
 * 时间复杂度:O(m * n)
 * 空间复杂度:O(n)
 */
function uniquePathsWithObstacles(obstacleGrid) {
  const m = obstacleGrid.length;
  const n = obstacleGrid[0].length;

  if (obstacleGrid[0][0] === 1 || obstacleGrid[m - 1][n - 1] === 1) {
    return 0;
  }

  // 一维dp数组:dp[j]表示当前行第j列的路径数
  const dp = new Array(n + 1).fill(0);

  // 初始化第一行
  for (let j = 1; j <= n; j++) {
    const curGrid = obstacleGrid[0][j - 1];
    if (curGrid === 1) break;
    dp[j] = 1;
  }

  // 从第2行开始遍历
  for (let i = 2; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
      const curGrid = obstacleGrid[i - 1][j - 1];

      if (curGrid === 1) {
        // 【易错点5】障碍物位置路径数置0
        dp[j] = 0;
      } else if (j === 1) {
        // 第一列:只能从上边来,保持dp[j]不变(如果上边是障碍物,dp[j]已经是0)
        // 不需要更新,因为第一列的路径数在初始化时已经确定
      } else {
        // 非第一列:可以从上方或左方到达
        dp[j] = dp[j] + dp[j - 1];
      }
    }
  }

  return dp[n];
}

前端应用场景

  • 表单验证:在复杂的多步骤表单中,某些步骤可能被禁用(障碍物),计算完成表单的不同路径
  • 游戏地图:在游戏中,某些格子是障碍物,计算从起点到终点的路径数
  • 权限路由:在权限系统中,某些路由节点被禁用,计算用户可访问的路由路径数
  • 工作流设计:在工作流中,某些节点可能被跳过,计算完成流程的不同路径

6. LeetCode213. 打家劫舍 II

题目链接213. 打家劫舍 II

难度:中等

核心:环形DP,拆分为两个基础DP问题(分治思想)

前端场景:环形资源分配、循环任务调度、权限系统中的循环依赖处理

题目描述

你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都围成一圈,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组 nums,请计算你在不触动警报装置的情况下,今晚能够偷窃到的最高金额。

示例 1

输入:nums = [2,3,2]
输出:3
解释:你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2),因为他们是相邻的。

示例 2

输入:nums = [1,2,3,1]
输出:4
解释:你可以先偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。偷窃到的最高金额 = 1 + 3 = 4

示例 3

输入:nums = [1,2,3]
输出:3
DP五部曲分析(分治思想)

核心思路:环形问题转化为两个线性问题

  • 情况1:不偷第一间(可以偷最后一间)→ 转换为线性问题:偷 [1, len-1]
  • 情况2:不偷最后一间(可以偷第一间)→ 转换为线性问题:偷 [0, len-2]
  • 取两种情况的最大值
完整版代码
/**
 * LeetCode213. 打家劫舍 II(环形)
 * 时间复杂度:O(n)
 * 空间复杂度:O(n)
 */
function rob(nums) {
  const len = nums.length;

  // 【易错点1】边界处理
  if (len === 0) return 0;
  if (len === 1) return nums[0];
  if (len === 2) return Math.max(nums[0], nums[1]);

  // 【核心思路】环形问题拆分为两个线性问题
  // 情况1:不偷第一间(可以偷最后一间)→ 范围 [1, len-1]
  // 情况2:不偷最后一间(可以偷第一间)→ 范围 [0, len-2]
  // 取两种情况的最大值

  /**
   * 辅助函数:线性数组的打家劫舍(LeetCode198的解法)
   * @param {number[]} arr - 线性房屋数组
   * @returns {number} - 能偷到的最大金额
   */
  function robLinear(arr) {
    const n = arr.length;
    if (n === 0) return 0;
    if (n === 1) return arr[0];

    // 二维状态DP
    const dp = new Array(n + 1);
    dp[0] = [0, 0]; // 前0间:不偷和偷都是0

    for (let i = 1; i <= n; i++) {
      const curVal = arr[i - 1];
      // 不偷当前间:前i-1间偷或不偷的最大值
      const valNotThief = Math.max(...dp[i - 1]);
      // 偷当前间:前i-1间必须不偷
      const valThief = curVal + dp[i - 1][0];
      dp[i] = [valNotThief, valThief];
    }

    return Math.max(...dp[n]);
  }

  // 情况1:不偷第一间,范围是nums[1]到nums[len-1]
  const case1 = robLinear(nums.slice(1));

  // 情况2:不偷最后一间,范围是nums[0]到nums[len-2]
  const case2 = robLinear(nums.slice(0, len - 1));

  // 【易错点2】返回两种情况的最大值
  return Math.max(case1, case2);
}

// 测试用例
console.log(rob([2, 3, 2])); // 3
console.log(rob([1, 2, 3, 1])); // 4
console.log(rob([1, 2, 3])); // 3
空间优化版(使用滚动变量)
/**
 * 空间优化版:robLinear函数使用滚动变量
 */
function rob(nums) {
  const len = nums.length;
  if (len === 0) return 0;
  if (len === 1) return nums[0];
  if (len === 2) return Math.max(nums[0], nums[1]);

  // 辅助函数:线性数组打家劫舍(空间优化版)
  function robLinear(arr) {
    const n = arr.length;
    if (n === 0) return 0;
    if (n === 1) return arr[0];

    let vNot = 0; // 不偷的最大值
    let vYes = 0; // 偷的最大值

    for (let i = 0; i < n; i++) {
      const curVal = arr[i];
      const prevNot = vNot;
      const prevYes = vYes;

      vNot = Math.max(prevNot, prevYes);
      vYes = curVal + prevNot;
    }

    return Math.max(vNot, vYes);
  }

  const case1 = robLinear(nums.slice(1));
  const case2 = robLinear(nums.slice(0, len - 1));

  return Math.max(case1, case2);
}

前端应用场景

  • 循环任务调度:在循环列表中,某些任务不能同时执行,求最大收益的调度方案
  • 环形权限分配:在权限环中,相邻权限互斥,求最大权限价值组合
  • 资源循环利用:在循环资源池中,某些资源不能同时使用,求最优资源分配
  • 时间轮调度:在时间轮算法中,计算最优的任务执行方案

7. LeetCode322. 零钱兑换 ★

题目链接322. 零钱兑换

难度:中等

核心:完全背包基础版,理解「最值型DP」的状态转移

前端场景:金额/资源最优分配、最少步骤问题、支付找零算法、资源最小化配置

题目描述

给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。

计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1

你可以认为每种硬币的数量是无限的。

示例 1

输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1

示例 2

输入:coins = [2], amount = 3
输出:-1
解释:无法凑成总金额 3

示例 3

输入:coins = [1], amount = 0
输出:0
DP五部曲分析
  1. dp数组含义dp[i][j] 表示用前i种硬币凑出金额j所需的最少硬币个数
  2. 递推公式
    • 不选第i种硬币:dp[i][j] = dp[i-1][j]
    • 选第i种硬币:dp[i][j] = dp[i][j-coins[i-1]] + 1(注意是dp[i]不是dp[i-1],因为可以重复选)
    • 取最小值:dp[i][j] = Math.min(dp[i-1][j], dp[i][j-coins[i-1]] + 1)
  3. 初始化
    • dp[0][0] = 0(0种硬币凑0元需要0个)
    • dp[0][j>0] = Infinity(0种硬币无法凑正数金额)
    • dp[i][0] = 0(凑0元永远需要0个)
  4. 遍历顺序:外层遍历硬币种类,内层遍历金额(正序,因为是完全背包)
  5. 打印验证:打印dp数组验证
完整版代码(二维DP)
/**
 * LeetCode322. 零钱兑换(完全背包-最值型)
 * 时间复杂度:O(coins.length * amount)
 * 空间复杂度:O(coins.length * amount)
 */
function coinChange(coins, amount) {
  const coinCount = coins.length;
  const target = amount;

  // 【易错点1】边界处理:凑0元直接返回0
  if (target === 0) return 0;

  // 【步骤1】确定dp数组及下标的含义
  // dp[i][j] = 用前i种硬币凑出金额j所需的最少硬币个数
  // 初始化:所有值先填Infinity(表示初始无法凑出)
  const dp = new Array(coinCount + 1).fill(0).map(() => new Array(target + 1).fill(Infinity));

  // 【步骤3】dp数组如何初始化
  dp[0][0] = 0; // 0种硬币凑0元,需要0个
  // 0种硬币凑正数金额,无法凑出(保持Infinity)
  for (let j = 1; j <= target; j++) {
    dp[0][j] = Infinity;
  }

  // 【步骤4】确定遍历顺序:外层遍历硬币种类,内层遍历金额(正序)
  // 正序遍历是因为完全背包:每种硬币可以使用无限次
  for (let i = 1; i <= coinCount; i++) {
    dp[i][0] = 0; // 凑0元永远需要0个硬币
    const curCoin = coins[i - 1]; // 【易错点2】数组索引转换:第i种硬币对应coins[i-1]

    for (let j = 1; j <= target; j++) {
      if (j < curCoin) {
        // 金额不足,无法使用当前硬币,继承前i-1种硬币的结果
        dp[i][j] = dp[i - 1][j];
      } else {
        // 【步骤2】确定递推公式
        // 完全背包核心:用当前硬币时是dp[i][j-curCoin]+1(而非dp[i-1])
        // 因为硬币可以重复使用,所以用dp[i](已经考虑了当前硬币)
        dp[i][j] = Math.min(
          dp[i - 1][j], // 不用第i种硬币
          dp[i][j - curCoin] + 1 // 用第i种硬币(注意是dp[i],可以重复选)
        );
      }
    }
  }

  // 【易错点3】无法凑出时返回-1,而非Infinity
  return dp[coinCount][target] === Infinity ? -1 : dp[coinCount][target];
}

// 测试用例
console.log(coinChange([1, 2, 5], 11)); // 3
console.log(coinChange([2], 3)); // -1
console.log(coinChange([1], 0)); // 0
空间优化版(一维DP)
/**
 * 空间优化版:一维DP
 * 时间复杂度:O(coins.length * amount)
 * 空间复杂度:O(amount) ← 从O(coins.length * amount)优化到O(amount)
 *
 * 【优化思路】
 * 观察递推公式:dp[i][j] = Math.min(dp[i-1][j], dp[i][j-coins[i-1]] + 1)
 * 计算dp[i][j]时只需要:
 * 1. 上一行第j列的值(dp[i-1][j])→ 对应更新前的dp[j]
 * 2. 当前行第j-coins[i-1]列的值(dp[i][j-coins[i-1]])→ 对应更新后的dp[j-coins[i-1]]
 * 可以用一维数组dp[j]正序遍历(完全背包特征:正序)
 */
function coinChange(coins, amount) {
  const coinCount = coins.length;
  const target = amount;

  if (target === 0) return 0;

  // 【步骤1】一维dp数组:dp[j] = 凑出金额j所需的最少硬币个数
  const dp = new Array(target + 1).fill(Infinity);

  // 【步骤3】初始化
  dp[0] = 0; // 凑0元需要0个硬币

  // 【步骤4】确定遍历顺序:外层遍历硬币,内层正序遍历金额
  // 【易错点4】完全背包必须正序遍历:保证每种硬币可以使用无限次
  // 如果倒序遍历,就变成了01背包(每种硬币只能用一次)
  for (let i = 1; i <= coinCount; i++) {
    const curCoin = coins[i - 1];

    // 【易错点5】从curCoin开始遍历,避免j<curCoin的无效判断
    for (let j = curCoin; j <= target; j++) {
      // 【步骤2】递推公式(一维版)
      // dp[j](更新前)= 不用当前硬币的最少个数(上一轮的结果)
      // dp[j - curCoin] + 1 = 用当前硬币的最少个数(当前轮已更新的结果)
      dp[j] = Math.min(dp[j], dp[j - curCoin] + 1);
    }
  }

  // 【易错点6】返回前检查是否为Infinity
  return dp[target] === Infinity ? -1 : dp[target];
}

前端应用场景

  • 支付找零:在支付系统中,计算用最少硬币数找零给用户
  • 资源最小化配置:在资源分配中,用最少的资源组合达到目标值
  • API调用优化:计算用最少的API调用次数完成某个任务
  • 组件懒加载:计算用最少的组件加载次数完成页面渲染

8. LeetCode518. 零钱兑换 II

题目链接518. 零钱兑换 II

难度:中等

核心:完全背包的「组合数型DP」,与322(最值型)做区分

前端场景:组合方案统计、支付方式组合数计算、资源配置方案数统计

题目描述

给你一个整数数组 coins 表示不同面额的硬币,另给一个整数 amount 表示总金额。

请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0 。

假设每一种面额的硬币有无限个。

示例 1

输入:amount = 5, coins = [1, 2, 5]
输出:4
解释:有四种方式可以凑成总金额:
5=5
5=2+2+1
5=2+1+1+1
5=1+1+1+1+1

示例 2

输入:amount = 3, coins = [2]
输出:0
解释:只用面额 2 的硬币无法凑成总金额 3。

示例 3

输入:amount = 10, coins = [10]
输出:1
DP五部曲分析(与322的区别)
  1. dp数组含义dp[i][j] 表示用前i种硬币凑出金额j的组合数(注意:是组合数,不是最少个数)
  2. 递推公式
    • 不选第i种硬币:dp[i][j] = dp[i-1][j]
    • 选第i种硬币:dp[i][j] = dp[i][j-coins[i-1]](注意是加法,不是取最小值)
    • 总组合数:dp[i][j] = dp[i-1][j] + dp[i][j-coins[i-1]]
  3. 初始化
    • dp[0][0] = 1(0种硬币凑0元,有1种组合:不选任何硬币)
    • dp[i][0] = 1(凑0元永远只有1种组合)
    • dp[0][j>0] = 0(0种硬币无法凑正数金额)
  4. 遍历顺序:外层遍历硬币(保证组合不重复),内层正序遍历金额
  5. 打印验证:打印dp数组验证
完整版代码(二维DP)
/**
 * LeetCode518. 零钱兑换 II(完全背包-组合数型)
 * 时间复杂度:O(coins.length * amount)
 * 空间复杂度:O(coins.length * amount)
 *
 * 【与322的区别】
 * - 322求:最少的硬币个数(最值型)→ Math.min
 * - 518求:组合数(计数型)→ 加法
 */
function change(amount, coins) {
  const coinCount = coins.length;
  const targetAmount = amount;

  // 【易错点1】边界处理:凑0元返回1(唯一组合:不选任何硬币)
  if (targetAmount === 0) return 1;

  // 【步骤1】确定dp数组及下标的含义
  // dp[i][j] = 用前i种硬币凑出金额j的组合数
  const dp = new Array(coinCount + 1).fill(0).map(() => new Array(targetAmount + 1).fill(0));

  // 【步骤3】dp数组如何初始化
  // 【易错点2】凑0元的组合数是1(不选任何硬币),不是0
  for (let i = 0; i <= coinCount; i++) {
    dp[i][0] = 1; // 凑0元永远只有1种组合
  }

  // 【步骤4】确定遍历顺序:外层遍历硬币,内层正序遍历金额
  // 【关键】外层遍历硬币保证了组合不重复:如[1,2]和[2,1]被视为同一种组合
  for (let i = 1; i <= coinCount; i++) {
    const currentCoin = coins[i - 1]; // 【易错点3】数组索引转换

    for (let j = 1; j <= targetAmount; j++) {
      if (j < currentCoin) {
        // 金额不足,无法用当前硬币,继承前i-1种的组合数
        dp[i][j] = dp[i - 1][j];
      } else {
        // 【步骤2】确定递推公式(组合数 = 不用 + 用)
        // dp[i-1][j]:不用第i种硬币的组合数
        // dp[i][j-currentCoin]:用第i种硬币的组合数(注意是dp[i],可重复选)
        dp[i][j] = dp[i - 1][j] + dp[i][j - currentCoin];
      }
    }
  }

  // 无法凑出时自然返回0(符合题目要求)
  return dp[coinCount][targetAmount];
}

// 测试用例
console.log(change(5, [1, 2, 5])); // 4
console.log(change(3, [2])); // 0
console.log(change(10, [10])); // 1
console.log(change(0, [1, 2])); // 1
空间优化版(一维DP)
/**
 * 空间优化版:一维DP
 * 时间复杂度:O(coins.length * amount)
 * 空间复杂度:O(amount)
 */
function change(amount, coins) {
  const coinCount = coins.length;
  const targetAmount = amount;

  if (targetAmount === 0) return 1;

  // 【步骤1】一维dp数组:dp[j] = 凑出金额j的组合数
  const dp = new Array(targetAmount + 1).fill(0);
  dp[0] = 1; // 【核心初始化】凑0元的组合数为1

  // 【步骤4】遍历顺序:外层遍历硬币,内层正序遍历金额
  // 【关键理解】外层遍历硬币 → 保证组合数不重复
  // 如果外层遍历金额,内层遍历硬币,会得到排列数(顺序有关)
  for (let i = 1; i <= coinCount; i++) {
    const currentCoin = coins[i - 1];

    // 【易错点4】完全背包:金额正序遍历(从currentCoin开始)
    for (let j = currentCoin; j <= targetAmount; j++) {
      // 【步骤2】递推公式(一维版)
      // dp[j](更新前)= 不用当前硬币的组合数(上一轮的结果)
      // dp[j - currentCoin](更新后)= 用当前硬币的组合数(当前轮已更新的结果)
      dp[j] = dp[j] + dp[j - currentCoin];
    }
  }

  return dp[targetAmount];
}

前端应用场景

  • 支付方式组合:计算用户可以用多少种不同的支付方式组合完成支付
  • 资源配置方案:计算有多少种不同的资源配置方案可以达到目标
  • 功能组合统计:计算有多少种不同的功能组合可以满足用户需求
  • 优惠券组合:计算有多少种不同的优惠券组合可以使用

四、进阶拓展级(3道,中大厂加分,理解即可)

9. LeetCode300. 最长递增子序列

题目链接300. 最长递增子序列

难度:中等

核心:单维度DP的经典拓展,理解「非连续状态转移」

前端场景:数据趋势分析、序列统计、时间线组件、用户行为序列分析

题目描述

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。

子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。

示例 1

输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4。

示例 2

输入:nums = [0,1,0,3,2,3]
输出:4
解释:最长递增子序列是 [0,1,2,3],长度为 4

示例 3

输入:nums = [7,7,7,7,7,7,7]
输出:1
解释:最长递增子序列是 [7],长度为 1
DP五部曲分析
  1. dp数组含义dp[i] 表示以 nums[i] 为最后一个元素的最长严格递增子序列的长度
  2. 递推公式
    • 对于每个 nums[i],遍历前面所有元素 nums[j] (j < i)
    • 如果 nums[i] > nums[j],则 nums[i] 可以接在 nums[j] 的子序列后面
    • dp[i] = Math.max(dp[i], dp[j] + 1) (在所有满足条件的j中取最大值)
  3. 初始化dp[i] = 1(每个元素自身构成长度为1的子序列)
  4. 遍历顺序:外层遍历i(从1到n-1),内层遍历j(从0到i-1)
  5. 打印验证:打印dp数组,返回最大值(注意:最长子序列不一定以最后一个元素结尾)
完整版代码
/**
 * LeetCode300. 最长递增子序列
 * 时间复杂度:O(n²)
 * 空间复杂度:O(n)
 */
function lengthOfLIS(nums) {
  const count = nums.length;

  // 【易错点1】边界处理:空数组/单元素数组
  if (count <= 1) return count;

  // 【步骤1】确定dp数组及下标的含义
  // dp[i] = 以nums[i]为最后一个元素的最长严格递增子序列的长度
  // 【易错点2】初始化错误:必须初始化为1,不能初始化为0
  // 因为每个元素自身至少构成长度为1的子序列
  const dp = new Array(count).fill(1);

  // 【步骤4】确定遍历顺序:外层遍历i,内层遍历j
  // 【易错点3】i从1开始:i=0时前面没有元素,无法计算
  for (let i = 1; i < count; i++) {
    const curNum = nums[i]; // 当前元素

    // 内层遍历:检查i前面所有元素j(而非仅j=i-1)
    // 【易错点4】必须遍历所有j<i,不能只遍历j=i-1
    // 因为子序列可以非连续,nums[i]可以接在任意满足条件的nums[j]后面
    for (let j = 0; j < i; j++) {
      // 【步骤2】确定递推公式
      // 【易错点5】递增条件:必须是严格递增(>),不能是>=
      if (curNum > nums[j]) {
        // 如果nums[i] > nums[j],则nums[i]可以接在nums[j]的子序列后面
        // 取所有满足条件的dp[j]+1的最大值
        dp[i] = Math.max(dp[i], dp[j] + 1);
      }
    }

    // 【步骤5】打印验证
    // console.log(`dp[${i}] = ${dp[i]}`);
  }

  // 【易错点6】返回错误:不能返回dp[count-1]
  // 因为最长递增子序列不一定以最后一个元素结尾
  // 必须返回dp数组中的最大值
  return Math.max(...dp);
}

// 测试用例
console.log(lengthOfLIS([10, 9, 2, 5, 3, 7, 101, 18])); // 4
console.log(lengthOfLIS([0, 1, 0, 3, 2, 3])); // 4
console.log(lengthOfLIS([7, 7, 7, 7, 7, 7, 7])); // 1
console.log(lengthOfLIS([1, 3, 6, 7, 9, 4, 10, 5, 6])); // 6

前端应用场景

  • 时间线组件:在时间线中,找到最长连续增长的时间段
  • 用户行为分析:分析用户行为序列中最长的正向发展趋势
  • 数据可视化:在图表中高亮显示数据的最长递增区间
  • 版本号比较:找到版本号序列中最长的递增子序列

10. LeetCode121. 买卖股票的最佳时机 ★

题目链接121. 买卖股票的最佳时机

难度:简单

核心:DP + 贪心结合,也可纯DP实现,理解「状态定义的简化」

前端场景:数据趋势、收益计算、股票K线图分析、价格波动分析

题目描述

给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。

你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。

如果你不能获取任何利润,返回 0 。

示例 1

输入:[7,1,5,3,6,4]
输出:5
解释:在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5。
     注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格;同时,你不能在买入前卖出股票。

示例 2

输入:prices = [7,6,4,3,1]
输出:0
解释:在这种情况下, 交易无法完成, 所以返回 0。
DP五部曲分析
  1. dp数组含义dp[i] 表示第i天卖出股票能获得的最大利润
  2. 递推公式dp[i] = prices[i] - minPrice(当天价格减去之前的最小价格)
  3. 初始化dp[0] = 0(第0天无法卖出),minPrice = prices[0]
  4. 遍历顺序:从左到右(i从1到n-1),同时维护最小价格
  5. 打印验证:打印dp数组,返回最大值(注意:最大利润不一定在最后一天卖出)
完整版代码
/**
 * LeetCode121. 买卖股票的最佳时机
 * 时间复杂度:O(n)
 * 空间复杂度:O(n)
 */
function maxProfit(prices) {
  const count = prices.length;

  // 【易错点1】边界处理:空数组/单元素数组
  if (count <= 1) return 0;

  // 【步骤1】确定dp数组及下标的含义
  // dp[i] = 第i天卖出股票能获得的最大利润
  const dp = new Array(count).fill(0);

  // 【步骤3】dp数组如何初始化
  // 第0天无法卖出(必须先买入),利润为0
  dp[0] = 0;

  // 维护遍历到当前的最小价格(用于计算利润)
  let minPrice = prices[0]; // 初始买入价格是第0天的价格

  // 【步骤4】确定遍历顺序:从左到右
  for (let i = 1; i < count; i++) {
    const curPrice = prices[i]; // 当天价格

    // 【核心逻辑1】更新最小价格(必须先更新,再计算利润)
    // 【易错点2】顺序错误:如果先计算利润再更新minPrice,会导致"当天买当天卖"的逻辑错误
    // 正确的顺序:先更新minPrice(基于之前的价格),再计算当天卖出的利润
    minPrice = Math.min(minPrice, curPrice);

    // 【步骤2】确定递推公式
    // 第i天卖出的最大利润 = 当天价格 - 之前的最小价格(最佳买入点)
    // 如果结果为负,dp[i]保持0(等价于不交易)
    dp[i] = Math.max(0, curPrice - minPrice);

    // 【步骤5】打印验证
    // console.log(`第${i}天:价格=${curPrice}, 最小价格=${minPrice}, 利润=${dp[i]}`);
  }

  // 【易错点3】返回错误:不能返回dp[count-1]
  // 因为最大利润不一定在最后一天卖出(如示例1中最大利润在第5天,不是最后一天)
  // 必须返回dp数组中的最大值
  return Math.max(...dp);
}

// 测试用例
console.log(maxProfit([7, 1, 5, 3, 6, 4])); // 5
console.log(maxProfit([7, 6, 4, 3, 1])); // 0
console.log(maxProfit([2, 4, 1])); // 2
console.log(maxProfit([3, 2, 6, 5, 0, 3])); // 4
空间优化版(只需一个变量)
/**
 * 空间优化版:贪心思想
 * 时间复杂度:O(n)
 * 空间复杂度:O(1) ← 从O(n)优化到O(1)
 *
 * 【优化思路】
 * 观察:dp[i]只依赖dp[i-1]和minPrice
 * 而且我们只需要最大值,不需要保存整个dp数组
 * 用一个变量maxProfit实时更新最大值即可
 */
function maxProfit(prices) {
  const count = prices.length;
  if (count <= 1) return 0;

  let minPrice = prices[0]; // 最小买入价格
  let maxProfit = 0; // 最大利润

  for (let i = 1; i < count; i++) {
    const curPrice = prices[i];

    // 更新最小价格
    minPrice = Math.min(minPrice, curPrice);

    // 计算当天卖出的利润,并更新最大利润
    maxProfit = Math.max(maxProfit, curPrice - minPrice);
  }

  return maxProfit;
}

前端应用场景

  • 股票K线图:在股票图表中,计算买入卖出的最佳时机和最大收益
  • 价格趋势分析:分析商品价格变化,找到最佳买卖点
  • 收益计算器:在投资应用中,计算投资组合的最大收益
  • 数据波动分析:分析数据序列中的最大正向波动(类似股票收益)

五、总结

通过这10道动态规划经典题目,我们掌握了:

核心框架:DP五部曲

  1. 确定dp数组及下标的含义
  2. 确定递推公式
  3. dp数组如何初始化
  4. 确定遍历顺序
  5. 打印dp数组(验证)

题目分类

类别 题目 核心特点 空间优化
基础递推 爬楼梯、最大子数组和、打家劫舍 一维DP,状态转移简单 滚动变量 O(1)
路径型DP 不同路径、不同路径II 二维DP,网格问题 一维数组 O(n)
背包问题 零钱兑换、零钱兑换II 完全背包,最值/计数 一维数组 O(amount)
序列问题 最长递增子序列 非连续状态转移 难优化
状态机DP 买卖股票、打家劫舍II 状态转换,环形问题 滚动变量 O(1)

易错点总结

  1. 边界处理:空数组、单元素、索引转换
  2. 初始化:根据问题特点正确初始化(0、1、Infinity等)
  3. 遍历顺序:完全背包正序,01背包倒序
  4. 返回值:注意是返回dp[n]还是Math.max(...dp)
  5. 空间优化:注意更新顺序,避免覆盖未使用的值

前端应用价值

动态规划在前端开发中广泛应用于:

  • 性能优化:资源分配、组件懒加载优化
  • 业务逻辑:支付找零、权限分配、任务调度
  • 数据可视化:趋势分析、K线图、时间线组件
  • 算法优化:路径规划、组合统计、最值计算

掌握这10道题目,足以应对前端算法面试中的大部分DP问题。记住:先理解DP五部曲框架,再套用到具体问题,最后优化空间复杂度


相关资源

前端佬们!!AI大势已来,未来的上限取决你的独特气质!恭请批阅!!

作者 大怪v
2026年1月10日 20:23

前言

写这篇文章,纯粹是AI热潮给炸出来的。本来想继续更我的虚拟世界专栏,但看了一眼沸点,好家伙,大家都在聊DeepSeek、ChatGPT,感觉我不说两句显得我很不合群。

还有另外一个原因,就是身边的很多程序员,都在焦虑。

那么,程序员真的会被替代吗?兄弟们,别急别急!

我直接给出我自己的观点:如果还是之前那种记俩API、写两功能的程序员,我相信很快会替代。但是那种会整活、会整合,直接面向问题解决的程序员,不会的!!!

O9.gif

对比以前还要苦哈哈地背API,现在AI把门槛直接铲平了。这哪里是危机?这分明是从“螺丝钉”进化成“架构师”的最佳版本!

我的核心观点:AI对于之前的经验总结、归纳非常牛牪犇!!但是对于复杂问题、现实的敏感度以及所谓创新,他们还直接不能!!

来,上理由!

0变1的门槛,被无限拉低了!

以前你想做一个全栈应用,你得懂前端、后端、数据库、运维... 还没开始写代码,环境配置就先把你劝退了。

现在呢?

只要你的想法够骚,AI就是你的一万个分身。

骄傲.gif

我有一个专栏,手搓虚拟世界

在没有 AI 的时候,你想从 0 到 1 做一个产品,你要懂后端、懂数据库、懂运维,甚至还得懂点 UI 设计。这每一项,都是一座大山。很多很棒的 idea,就死在了“我不会写后端接口”或者“这 UI 丑得我没眼看”上。

现在呢?

AI 就是你的那个全能外包团队。你不会写 SQL?问它。你不会画 icon?让它画。 以前我们为了画一个完美的圆,可能要算半天 Math.PI,现在你只需要告诉 AI:“给我整一个圆,要五彩斑斓的那种。”

0 变 1 的过程,不再是技术的堆砌,而是你“脑洞”的直接具象化。 只要你有想法,技术实现的壁垒正在被 AI 暴力拆除。

这是拼气质的时代

很多人说 AI 出来的代码没有灵魂,是缝合怪。 我说:别急别急!

当所有人都能一键生成标准化代码的时候,什么东西最值钱? 是个性。是那种“独属前端佬气质”的创新。

0.gif

就像当年的 Flash,工具大家都有,但只有少数人能做出《小小火柴人》。AI 时代同理,它能帮你生成 90% 的通用代码,但剩下那 10% 的、决定产品气质的、让人眼前一亮的 “手搓” 部分,才是你真正的价值所在。

未来的牛人,不是谁 API 背得熟,而是谁能用 AI 这个超级引擎,组合出别人没见过的玩法。这不就是我们最擅长的吗?不依赖第三方库(因为 AI 可能会瞎引用),纯靠逻辑和创意,去构建一个新的虚拟世界。

能做什么,取决于你能想到什么

以前想整一个事情,大致如下流程:想法=>需求=>原型=>UI=>交互=>编写代码

完全靠人海战术。 现在?一个拿着 AI 的工程师(或者说“全栈工程师”),战斗力可能抵得上以前的一个公司。

这意味着什么?意味着个人创新的回报率被无限放大了。你不需要在一个项目中,当一颗在大机器里运转的螺丝钉,你有机会成为那个设计机器的人。

假如未来硬件再大升级(就像我之前说的智能眼镜、脑机接口),结合 AI 的生产力,一个人手搓一个“元宇宙”雏形,可能真的不再是梦。

93d794510fb30f248be3f2baca95d143ac4b03e8.gif

AI 不会淘汰有想法的人,它只会淘汰那些只会 copy-paste 的“代码搬运工”。

与其在焦虑中等待被替代,不如现在就头脑热一把,利用这个时代的红利,去“手搓”一点属于你自己的、独一无二的东西。

毕竟,只有当现有的规则已经装不下你的野心时,打破规则才更有乐趣。

前端佬们,别怂,干!

两个字符串的最小ASCII删除和

2022年8月1日 09:55

方法一:动态规划

假设字符串 $s_1$ 和 $s_2$ 的长度分别为 $m$ 和 $n$,创建 $m+1$ 行 $n+1$ 列的二维数组 $\textit{dp}$,其中 $\textit{dp}[i][j]$ 表示使 $s_1[0:i]$ 和 $s_2[0:j]$ 相同的最小 $\text{ASCII}$ 删除和。

上述表示中,$s_1[0:i]$ 表示 $s_1$ 的长度为 $i$ 的前缀,$s_2[0:j]$ 表示 $s_2$ 的长度为 $j$ 的前缀。

动态规划的边界情况如下:

  • 当 $i=j=0$ 时,$s_1[0:i]$ 和 $s_2[0:j]$ 都为空,两个空字符串相同,不需要删除任何字符,因此有 $\textit{dp}[0][0]=0$;

  • 当 $i=0$ 且 $j>0$ 时,$s_1[0:i]$ 为空且 $s_2[0:j]$ 不为空,空字符串和任何字符串要变成相同,只有将另一个字符串的字符全部删除,因此对任意 $1 \le j \le n$,有 $\textit{dp}[0][j]=\textit{dp}[0][j-1]+s_2[j-1]$;

  • 当 $j=0$ 且 $i>0$ 时,$s_2[0:j]$ 为空且 $s_1[0:i]$ 不为空,同理可得,对任意 $1 \le i \le m$,有 $\textit{dp}[i][0]=\textit{dp}[i-1][0]+s_1[i-1]$。

当 $i>0$ 且 $j>0$ 时,考虑 $\textit{dp}[i][j]$ 的计算:

  • 当 $s_1[i-1]=s_2[j-1]$ 时,将这两个相同的字符称为公共字符,考虑使 $s_1[0:i-1]$ 和 $s_2[0:j-1]$ 相同的最小 $\text{ASCII}$ 删除和,增加一个公共字符之后,最小 $\text{ASCII}$ 删除和不变,因此 $\textit{dp}[i][j]=\textit{dp}[i-1][j-1]$。

  • 当 $s_1[i-1] \ne s_2[j-1]$ 时,考虑以下两项:

    • 使 $s_1[0:i-1]$ 和 $s_2[0:j]$ 相同的最小 $\text{ASCII}$ 删除和,加上删除 $s_1[i-1]$ 的 $\text{ASCII}$ 值;

    • 使 $s_1[0:i]$ 和 $s_2[0:j-1]$ 相同的最小 $\text{ASCII}$ 删除和,加上删除 $s_2[j-1]$ 的 $\text{ASCII}$ 值。

    要得到使 $s_1[0:i]$ 和 $s_2[0:j]$ 相同的最小 $\text{ASCII}$ 删除和,应取两项中较小的一项,因此 $\textit{dp}[i][j]=\min(\textit{dp}[i-1][j]+s_1[i-1],\textit{dp}[i][j-1]+s_2[j-1])$。

由此可以得到如下状态转移方程:

$$
\textit{dp}[i][j] = \begin{cases}
\textit{dp}[i-1][j-1], & s_1[i-1]=s_2[j-1] \
\min(\textit{dp}[i-1][j]+s_1[i-1],\textit{dp}[i][j-1]+s_2[j-1]), & s_1[i-1] \ne s_2[j-1]
\end{cases}
$$

最终计算得到 $\textit{dp}[m][n]$ 即为使 $s_1$ 和 $s_2$ 相同的最小 $\text{ASCII}$ 删除和。

实现方面,需要将 $s_1[i-1]$ 和 $s_2[j-1]$ 转换成相应的 $\text{ASCII}$ 值。

###Java

class Solution {
    public int minimumDeleteSum(String s1, String s2) {
        int m = s1.length(), n = s2.length();
        int[][] dp = new int[m + 1][n + 1];
        for (int i = 1; i <= m; i++) {
            dp[i][0] = dp[i - 1][0] + s1.codePointAt(i - 1);
        }
        for (int j = 1; j <= n; j++) {
            dp[0][j] = dp[0][j - 1] + s2.codePointAt(j - 1);
        }
        for (int i = 1; i <= m; i++) {
            int code1 = s1.codePointAt(i - 1);
            for (int j = 1; j <= n; j++) {
                int code2 = s2.codePointAt(j - 1);
                if (code1 == code2) {
                    dp[i][j] = dp[i - 1][j - 1];
                } else {
                    dp[i][j] = Math.min(dp[i - 1][j] + code1, dp[i][j - 1] + code2);
                }
            }
        }
        return dp[m][n];
    }
}

###C#

public class Solution {
    public int MinimumDeleteSum(string s1, string s2) {
        int m = s1.Length, n = s2.Length;
        int[,] dp = new int[m + 1, n + 1];
        for (int i = 1; i <= m; i++) {
            dp[i, 0] = dp[i - 1, 0] + s1[i - 1];
        }
        for (int j = 1; j <= n; j++) {
            dp[0, j] = dp[0, j - 1] + s2[j - 1];
        }
        for (int i = 1; i <= m; i++) {
            int code1 = s1[i - 1];
            for (int j = 1; j <= n; j++) {
                int code2 = s2[j - 1];
                if (code1 == code2) {
                    dp[i, j] = dp[i - 1, j - 1];
                } else {
                    dp[i, j] = Math.Min(dp[i - 1, j] + code1, dp[i, j - 1] + code2);
                }
            }
        }
        return dp[m, n];
    }
}

###JavaScript

var minimumDeleteSum = function(s1, s2) {
    const m = s1.length, n = s2.length;
    const dp = new Array(m + 1).fill(0).map(() => new Array(n + 1).fill(0));
    for (let i = 1; i <= m; i++) {
        dp[i][0] = dp[i - 1][0] + s1[i - 1].charCodeAt();
    }
    for (let j = 1; j <= n; j++) {
        dp[0][j] = dp[0][j - 1] + s2[j - 1].charCodeAt();
    }
    for (let i = 1; i <= m; i++) {
        const code1 = s1[i - 1].charCodeAt();
        for (let j = 1; j <= n; j++) {
            const code2 = s2[j - 1].charCodeAt();
            if (code1 === code2) {
                dp[i][j] = dp[i - 1][j - 1];
            } else {
                dp[i][j] = Math.min(dp[i - 1][j] + code1, dp[i][j - 1] + code2);
            }
        }
    }
    return dp[m][n];
};

###Python

class Solution:
    def minimumDeleteSum(self, s1: str, s2: str) -> int:
        m, n = len(s1), len(s2)
        dp = [[0] * (n + 1) for _ in range(m + 1)]
        for i in range(1, m + 1):
            dp[i][0] = dp[i - 1][0] + ord(s1[i - 1])
        for j in range(1, n + 1):
            dp[0][j] = dp[0][j - 1] + ord(s2[j - 1])

        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if s1[i - 1] == s2[j - 1]:
                    dp[i][j] = dp[i - 1][j - 1]
                else:
                    dp[i][j] = min(dp[i - 1][j] + ord(s1[i - 1]), dp[i][j - 1] + ord(s2[j - 1]))
        
        return dp[m][n]

###C++

class Solution {
public:
    int minimumDeleteSum(string s1, string s2) {
        int m = s1.size();
        int n = s2.size();
        vector<vector<int>> dp(m + 1, vector<int>(n + 1));

        for (int i = 1; i <= m; ++i) {
            dp[i][0] = dp[i - 1][0] + s1[i - 1];
        }
        for (int j = 1; j <= n; ++j) {
            dp[0][j] = dp[0][j - 1] + s2[j - 1];
        }
        for (int i = 1; i <= m; i++) {
            char c1 = s1[i - 1];
            for (int j = 1; j <= n; j++) {
                char c2 = s2[j - 1];
                if (c1 == c2) {
                    dp[i][j] = dp[i - 1][j - 1];
                } else {
                    dp[i][j] = min(dp[i - 1][j] + s1[i - 1], dp[i][j - 1] + s2[j - 1]);
                }
            }
        }

        return dp[m][n];
    }
};

###Go

func minimumDeleteSum(s1 string, s2 string) int {
    m, n := len(s1), len(s2)
    dp := make([][]int, m+1)
    for i := range dp {
        dp[i] = make([]int, n+1)
        if i > 0 {
            dp[i][0] = dp[i-1][0] + int(s1[i-1])
        }
    }
    for j := range dp[0] {
        if j > 0 {
            dp[0][j] = dp[0][j-1] + int(s2[j-1])
        }
    }
    for i, c1 := range s1 {
        for j, c2 := range s2 {
            if c1 == c2 {
                dp[i+1][j+1] = dp[i][j]
            } else {
                dp[i+1][j+1] = min(dp[i][j+1] + int(c1), dp[i+1][j] + int(c2))
            }
        }
    }
    return dp[m][n]
}

###C

int minimumDeleteSum(char* s1, char* s2) {
    int m = strlen(s1);
    int n = strlen(s2);
    
    int** dp = (int**)malloc((m + 1) * sizeof(int*));
    for (int i = 0; i <= m; i++) {
        dp[i] = (int*)malloc((n + 1) * sizeof(int));
    }
    
    dp[0][0] = 0;
    for (int i = 1; i <= m; i++) {
        dp[i][0] = dp[i - 1][0] + s1[i - 1];
    }
    for (int j = 1; j <= n; j++) {
        dp[0][j] = dp[0][j - 1] + s2[j - 1];
    }
    for (int i = 1; i <= m; i++) {
        char c1 = s1[i - 1];
        for (int j = 1; j <= n; j++) {
            char c2 = s2[j - 1];
            if (c1 == c2) {
                dp[i][j] = dp[i - 1][j - 1];
            } else {
                dp[i][j] = fmin(dp[i - 1][j] + s1[i - 1], dp[i][j - 1] + s2[j - 1]);
            }
        }
    }
    
    int result = dp[m][n];    
    for (int i = 0; i <= m; i++) {
        free(dp[i]);
    }
    free(dp);
    
    return result;
}

###TypeScript

function minimumDeleteSum(s1: string, s2: string): number {
    const m = s1.length;
    const n = s2.length;
    
    const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
    for (let i = 1; i <= m; i++) {
        dp[i][0] = dp[i - 1][0] + s1.charCodeAt(i - 1);
    }
    for (let j = 1; j <= n; j++) {
        dp[0][j] = dp[0][j - 1] + s2.charCodeAt(j - 1);
    }
    for (let i = 1; i <= m; i++) {
        const c1 = s1[i - 1];
        for (let j = 1; j <= n; j++) {
            const c2 = s2[j - 1];
            if (c1 === c2) {
                dp[i][j] = dp[i - 1][j - 1];
            } else {
                dp[i][j] = Math.min(
                    dp[i - 1][j] + s1.charCodeAt(i - 1),
                    dp[i][j - 1] + s2.charCodeAt(j - 1)
                );
            }
        }
    }
    
    return dp[m][n];
}

###Rust

impl Solution {
    pub fn minimum_delete_sum(s1: String, s2: String) -> i32 {
        let m = s1.len();
        let n = s2.len();
        let s1_bytes = s1.as_bytes();
        let s2_bytes = s2.as_bytes();
        
        let mut dp = vec![vec![0; n + 1]; m + 1];
        for i in 1..=m {
            dp[i][0] = dp[i - 1][0] + s1_bytes[i - 1] as i32;
        }
        for j in 1..=n {
            dp[0][j] = dp[0][j - 1] + s2_bytes[j - 1] as i32;
        }
        for i in 1..=m {
            for j in 1..=n {
                if s1_bytes[i - 1] == s2_bytes[j - 1] {
                    dp[i][j] = dp[i - 1][j - 1];
                } else {
                    dp[i][j] = std::cmp::min(
                        dp[i - 1][j] + s1_bytes[i - 1] as i32,
                        dp[i][j - 1] + s2_bytes[j - 1] as i32
                    );
                }
            }
        }
        
        dp[m][n]
    }
}

复杂度分析

  • 时间复杂度:$O(mn)$,其中 $m$ 和 $n$ 分别是字符串 $s_1$ 和 $s_2$ 的长度。二维数组 $\textit{dp}$ 有 $m+1$ 行和 $n+1$ 列,需要对 $\textit{dp}$ 中的每个元素进行计算。

  • 空间复杂度:$O(mn)$,其中 $m$ 和 $n$ 分别是字符串 $s_1$ 和 $s_2$ 的长度。创建了 $m+1$ 行 $n+1$ 列的二维数组 $\textit{dp}$。

Parcel 作者:如何用静态Hermes把JavaScript编译成C语言

作者 Yanni4Night
2026年1月10日 17:51

原文:devongovett.me/blog/static…,parcel 作者 Devon 的博客

最近我一直在把Parcel的更多部分移植到Rust上。基于Rust的工具面临的一大挑战就是如何支持插件。很多常用工具已经有了Rust版本的平替:比如处理JavaScript的SWC和OXC,CSS的Lightning CSS,SVG的oxvg等等。但像React Compiler、Less和Sass这些热门工具还在用JavaScript编写,所以我们得想办法在Rust工具里运行它们。

一种方案是通过napi把Rust核心嵌入Node。这种模式下,程序的入口是JavaScript,它会调用Rust代码。当Rust需要调用JS插件时,再回调到JavaScript引擎。Lightning CSS的JS插件就是这么实现的。不过这会带来一些性能损耗:我开发Lightning CSS时测过,用JS插件比不用慢了大概7倍。

另一种类似的方案是跨进程通信。这种模式下入口是Rust,需要运行插件时就启动Node子进程。这同样会有不小的性能开销。

静态Hermes

Hermes是Facebook为React Native打造的定制JavaScript引擎。最新版本的静态Hermes(Static Hermes)采用了全新思路:它不再在运行时使用JIT(即时)编译器,而是提前把JavaScript编译成字节码或原生二进制文件。这样就省去了运行时编译和优化代码的启动时间,这在手机上可是个不小的提升。

静态Hermes的工作原理是把JavaScript编译成C代码,再通过LLVM编译成机器码。最终生成的是完全独立的二进制文件,不需要JavaScript虚拟机就能运行。编译出的C代码会用到Hermes提供的一些辅助函数,这些函数会像Rust等语言的标准库一样被静态链接到二进制文件中。这不仅能利用LLVM的高级优化提升性能,还能让JavaScript轻松嵌入到Rust这类能和C交互的语言编写的程序中。

把Less.js编译成C

我打算给Parcel做个Less插件,能从Rust调用的那种。借助静态Hermes,我成功把它编译成了C库,然后就能从Rust调用了。

第一步是把less这个npm模块打包成一个没有外部依赖的单文件JavaScript。Hermes不支持Node模块,所以一切都得自给自足,也不能依赖fspath这些Node内置模块。当然,我用Parcel来做这件事。

// 使用Less的环境无关版本,并模拟PluginLoader
const less = require('less/lib/less').default({}, {})
less.PluginLoader = function() {}

// 暴露一个全局函数,把Less代码字符串编译成CSS
function compile(input) {
  let result
  less.render(input, (err, res) => {
    result = res.css
  })
  return result
}

globalThis.compile = compile

用Parcel编译:

parcel build less.js --no-optimize

这样就生成了dist/less.js,一个完全独立的文件,暴露了全局的compile函数。

接下来把它编译成C库。首先得自己编译静态Hermes,按照官方文档操作就行。

./build_release/bin/shermes -O -c -exported-unit=less dist/less.js
  • -O 生成优化过的构建
  • -c 编译成原生目标文件,之后可以链接到更大的程序中
  • -exported-unit=less 告诉Hermes不要生成main函数,而是导出一个叫less的编译单元

这会生成less.o目标文件。(如果想看看编译出的C源代码,可以把-c换成-emit-c。)

然后需要一个小的C包装器来调用这个JavaScript函数。

// compile.c
#include <stdlib.h>
#include <hermes/VM/static_h.h>
#include <hermes/hermes.h>

// 声明静态Hermes生成的`less`单元
// 这个会来自`less.o`
extern \"C\" SHUnit sh_export_less;

extern \"C\" char* compile_less(char *in) {
  // 初始化Hermes运行时
  static SHRuntime *s_shRuntime = nullptr;
  static facebook::hermes::HermesRuntime *s_hermes = nullptr;

  if (s_shRuntime == nullptr) {
    s_shRuntime = _sh_init(0, nullptr);
    s_hermes = _sh_get_hermes_runtime(s_shRuntime);
    if (!_sh_initialize_units(s_shRuntime, 1, &sh_export_less)) {
      abort();
    }
  }

  // 获取全局的`compile`函数并调用
  std::string res = s_hermes->global()
    .getPropertyAsFunction(*s_hermes, \"compile\")
    .call(*s_hermes, std::string(in))
    .getString(*s_hermes)
    .utf8(*s_hermes);

  // 把C++字符串转成能返回的C字符串
  char* result = new char[res.size() + 1];
  strcpy(result, res.c_str());
  return result;
}

clang++把这个编译成另一个目标文件:

clang++ -c -O3 -std=c++17 -IAPI -IAPI/jsi -Iinclude -Ipublic -Ibuild_release/lib/config compile.c
  • -c 生成目标文件
  • -O3 生成优化过的构建
  • -std=c++17 启用C++17特性
  • -I 添加Hermes的头文件路径

这会生成compile.o目标文件。

最后就是从Rust调用这个compile_less函数了。

// main.rs
use std::ffi::{CStr, CString};
use std::os::raw::c_char;

// 声明要调用的C函数
extern \"C\" {
  fn compile_less(input: *const c_char) -> *const c_char;
}

fn main() {
  // 创建C字符串
  let input = CString::new(
    r#\"// 变量
      @link-color: #428bca;
      @link-color-hover: darken(@link-color, 10%);

      a,
      .link {
        color: @link-color;
      }
      a:hover {
        color: @link-color-hover;
      }
      .widget {
        color: #fff;
        background: @link-color;
      }
    \"#,
  )
  .unwrap();

  // 调用C函数并转成Rust字符串
  let res = unsafe {
    let ptr = compile_less(input.as_ptr());
    CStr::from_ptr(ptr).to_string_lossy().into_owned()
  };

  // 打印结果
  println!(\"OUTPUT: {}\", res);
}

rustc编译并链接所有东西:

rustc main.rs -O -C link-arg=less.o -C link-arg=compile.o -Lbuild_release/lib -Lbuild_release/jsi -Lbuild_release/tools/shermes -lshermes_console_a -lhermesvm_a -ljsi -lc++ -Lbuild_release/external/boost/boost_1_86_0/libs/context/ -lboost_context -l framework=Foundation
  • -O 生成优化过的构建
  • -C link-arg=less.o -C link-arg=compile.o 链接之前创建的C库
  • -L ... 添加库搜索路径
  • -l ... 链接Hermes库和依赖
  • -l framework=Foundation 是macOS特有的,用来链接Foundation框架

现在运行程序,就能看到它通过Rust编译Less了!🪄

./main

总结

这只是个简单的初步示例,但展示了原生工具整合预编译JS插件的潜力,而且不需要嵌入解释器。另一个潜在用例是基于Babel的React Compiler——对很多人来说,这可能是他们构建流程中仅存的JS工具了。我简单试了下,但遇到了一些问题,可能是目前还缺少某些特性

【React 19 尝鲜】第一篇:use和useActionState

2026年1月10日 17:40

本文将介绍react 19的一些新特性和API,主打一个结合例子和踩坑记录的方式来分享。

一、use: 消费Context和Promise

1. Context

关于Context react19 带来了两点变化:

  • 可以使用use 在条件中消费 Context
  • 可以不再使用 <Context.Provider>,而是直接使用<Context>
import { use } from 'react';
const ThemeContext = createContext('');

function App() {
 // ✅ 直接 ThemeContext 作为组件
  return (
    <ThemeContext value="dark">
      <Button showTheme={true}/>
    </ThemeContext>
  );  
}

function Button({ showTheme }) {
  if (showTheme) {
    // ✅ 可以在条件语句中使用 use
    const theme = use(ThemeContext);
    return <button className={theme}>Styled Button</button>;
  }

  return <button>Default Button</button>;
}

2. 消费Promise

消费promise,最常见的场景就是处理 GET类请求

下面以表格分页查询这个场景为例子,进行探索。

  • 进入页面,需要加载页面数据
  • 当修改分页(点击下一页),则请求新的数据

1.切换路由-请求数据

react 19不支持在渲染中直接创建promise 然后用use 进行消费,所以创建promise的工作交给了react-router,让它在初始化页面组件时传入一个promise。

Page.tsx

import { useLoaderData, type ClientLoaderFunctionArgs } from "react-router";

export async function clientLoader() {
  const p = getVideos()
  
  return {
    videoListPromise: p
  }
}

export function Page() {
const { videoListPromise } = useLoaderData<typeof clientLoader>()

return <Suspense fallback={<div>Loading...</div>}>
<VideoList {videoListPromise}/>
</Suspense>
}

export async VideoList() {
const { data: response } = use(videoListPromise)
const videos = response?.data?.items || [];

return <div>
{videos.map((video) => (
<VideoCard 
key={video.id} 
video={video} 
/>
))}
<div>
}

P.S. 这里使用的是react-router v7 的 framework mode,clientLoader表示页面异步请求,react-router等clientLoader这个promise完成后 挂载组件,此时可以通过useLoaderData获取数据。

这个例子中,clientLoader并没有真的发起异步请求,而是返回一个pending的promise,这个是关键,就是为了让use(promise).

当渲染中,遇到use(videoListPromise) , 会throw 一个 promise,<Suspense>组件能捕获到从而渲染fallback。当这个promise 成功(resolved)就继续执行下面的渲染代码。

注意点:

  1. use()需要在<Suspense>内使用,否则就无法捕获到promise,就不能加载fallback.
  2. use()消费的promise如果失败(rejected),会触发<ErrorBoundary>(如果有的话),不会继续渲染下面的组件。在这种范式下,trycatch就没必要了,只需要定义好<ErrorBoundary>
  3. 不建议在渲染中创建promise给use使用,因为这样每次重新渲染就会重新创建一个新promise,重复请求。最佳实践是这个promise要从组件外部传入。

2.交互-请求数据

前面展示了一个“纯粹的”切换到某个页面,页面加载初始数据。但有时候需要在交互后,继续触发数据请求,比如“分页”。

此时我们会发出一个疑问 —— promise在组件外面创建的,我的参数怎么传递呢? 答:将参数“URL 化”.

先看效果:

image.png

要在 React 19 中结合 React Router 使用 use + Suspense 并利用 URL 实现分页,核心思路是:将 URL 中的查询参数(如 ?page=2)作为触发 clientLoader 重新运行的依赖项。

当 URL 改变时,React Router 会重新调用 clientLoader,产生一个新的 Promise,VideoList 组件通过 use(promise) 挂起并触发 Suspense 状态。

P.S这是 clientLoader(以及服务端的 loader)设计的核心优势之一:数据加载与路由变化天然绑定,无需手动监听 useParams()useLocation()。 如果你不希望参数变化时重新加载(极少见),可以通过缓存或自定义逻辑在 clientLoader 内部跳过请求,但通常不需要。

以下是完整的改进方案:

a. 改进 clientLoader

我们需要从 request.url 中提取分页参数,并将其传递给 API 函数。

import { useLoaderData, type ClientLoaderFunctionArgs, useSearchParams } from "react-router";
import { use, Suspense } from "react";

// 模拟 API
declare function getVideos(params: { page: number }): Promise<any>;

export async function clientLoader({ request }: ClientLoaderFunctionArgs) {
  const url = new URL(request.url);
  const page = Number(url.searchParams.get("page")) || 1; // 默认第一页
  
  // 返回一个新的 Promise
  const p = getVideos({ page });
  
  return {
    videoListPromise: p,
    currentPage: page
  };
}
b. 改进 PageVideoList 组件
export function Page() {
  // 1. 获取 loader 传递的 Promise 和当前页码
  const { videoListPromise, currentPage } = useLoaderData<typeof clientLoader>();
  const [searchParams, setSearchParams] = useSearchParams();

  // 处理翻页逻辑:直接更新 URL 查询参数
  const handlePageChange = (newPage: number) => {
    setSearchParams((prev) => {
      prev.set("page", String(newPage));
      return prev;
    });
  };

  return (
    <div className="container">
      <h1>Video Library - Page {currentPage}</h1>
      
      {/* 2. Suspense 包裹异步渲染区域 */}
      <Suspense fallback={<div className="loading">Loading videos...</div>}>
        <VideoList videoListPromise={videoListPromise} />
      </Suspense>

      {/* 3. 分页控制器 */}
      <div className="pagination-controls">
        <button 
          disabled={currentPage <= 1} 
          onClick={() => handlePageChange(currentPage - 1)}
        >
          Previous
        </button>
        <span> Page {currentPage} </span>
        <button 
          onClick={() => handlePageChange(currentPage + 1)}
        >
          Next
        </button>
      </div>
    </div>
  );
}

// 4. VideoList 使用 React 19 的 use() 钩子
interface VideoListProps {
  videoListPromise: Promise<any>;
}

export function VideoList({ videoListPromise }: VideoListProps) {
  // use() 会自动解构 Promise,并在 Pending 时挂起 Suspense
  const response = use(videoListPromise);
  const videos = response?.data?.items || [];

  
  return (
    <div>
      {videos.map((video: any) => (
        <VideoCard key={video.id} video={video} />
      ))}
    </div>
  );
}
c. 重新加载数据的坑

第一次进入页面,触发了<Suspense>的fallback。 切换分页,重新请求数据时,useLoaderData会返回一个新的Promise {<pending>} 但是却没有触发<Suspense>的fallback。。。 有点反直觉

解决办法就是,给<Suspense>加key(不加的话,react重新渲染可能直接跳过Suspense的协调,跳过渲染) <Suspense key={currentPage} fallback={<Loading />} >

3.小总结

有些场景不太适合 clientLoader + use + <Suspense> 这种实践方式,比如满足「参数控制刷新」+ 「局部刷新」的场景。

  • 这种场景,往往是有多个请求,每个请求都需要传递参数(URL只有一个就不太好表达)。每个局部刷新对应的UI 都需要 fallback。(fallback会暂时覆盖原来的UI,不好)
  • 举个例子:「列表加载更多」的场景,往上滚动时需要加载更多数据,总不能加载时用fallback来访覆盖UI吧。还是得退化到useEffectuseEffect处理副作用终归还是必不可少的。

use(promise) 更适合“声明式、一次性解析”,加载页面级数据。

🆚 对比总结

维度 use(promise) + Suspense useEffect / React Query
适用场景 页面级数据(如文章详情、商品页) 列表、表格、仪表盘等交互密集型 UI
Loading 体验 全局 fallback(看不到数据) 局部 loading(还可以看到历史数据)✅
状态控制 仅 pending/success/error 支持 isFetching, isRefetching, error, cache 等 ✅
用户交互响应 弱(被动挂起) 强(主动触发 + 精确反馈)✅
开发心智模型 声明式(数据即依赖) 命令式(事件 → 请求 → 更新)✅(对管理端更直观)
生态支持 需手动管理 React Query/SWR 提供开箱即用分页支持 ✅

二、Actions:处理表单和POST类请求

1. 什么是Actions?

1.在 React 19 中,Actions 本质上是支持异步函数的 Transition。

2.在 React 19 之前,处理异步操作通常需要手动管理 pending(加载中)、error(错误)和 data(数据)状态。Actions 的出现让这些流程自动化了。

3.当你将一个异步函数传递给 useTransitionuseActionState 时,React 会自动处理:

  • Pending 状态:自动开始并结束,无需手动 setIsLoading(true/false)。
  • 自动排队:React 保证多个提交按顺序处理。
  • UI 响应性:UI 在数据请求期间保持响应,不会阻塞。

2. react 19 的表单提交

1. 原生的表单

<form action="/submit-data" method="POST">

通过onsubmit监听 submit事件,函数return true,走原生表单提交逻辑(即发送到action的url)。return false,则阻止提交

<form onsubmit="return validateForm()">
    <!-- 表单字段 -->
    <input type="submit" value="提交">
</form>
<script>
    function validateForm() {
        // 验证逻辑...
        return true; // 或 false
    }
</script>

更多情况下,利用 onsubmit来手动发送http请求,提交POST请求。然后自己e.preventDefault();(阻止默认的表单提交行为)

2. 使用useActionState处理表单

React 19 对表单处理引入了较大的改进,目标是简化数据变更、状态管理和提升用户体验。

这些变化的核心是引入了「表单对action的支持」和「3个新的 Hooks」,它们旨在让您能够更多地利用 Web 标准的 <form> 元素,减少手动处理状态和 event.preventDefault() 的样板代码。

  • form元素支持action传入一个异步函数(asyncFunction
  • 提供useActionState 创建 asyncFunction 和管理异步状态
  • 提供useFormStatus 方便表单子组件 获取表单的状态
  • 提供useOptimistic 乐观更新

下面以登录表单为场景来介绍 react19 表单提交的变化。

先介绍下react19之前的版本,最直接的登录表单是如何做的。

import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Form, useNavigate } from "react-router"
import { loginApiAuthLoginPost } from "@/APIs"
import { useAuthStore } from "@/store/useAuthStore"

export default function Login() {
  const navigate = useNavigate();
  const setAuth = useAuthStore((state) => state.setAuth);
  
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  const handleLogin = async (e: React.FormEvent) => {
    e.preventDefault();
    setError(null);
    setLoading(true);

    try {
      const { data, error: apiError } = await loginApiAuthLoginPost({
        body: { username, password }
      });

      if (apiError) {
        setError("Login failed. Please check your credentials.");
        return;
      }

      if (data && data.code === 200 && data.data) {
        setAuth(data.data.token, data.data.user);
        navigate("/dashboard");
      } else {
        setError(data?.err_msg || "Unknown error occurred");
      }
    } catch (err) {
      console.error(err);
      setError("An unexpected error occurred. Please try again.");
    } finally {
      setLoading(false);
    }
  };

  return (
    <form onSubmit={handleLogin} className="grid gap-4">
<div className="grid gap-2">
  <Label htmlFor="username">Username</Label>
  <Input 
id="username" 
type="text" 
placeholder="admin" 
required 
value={username}
onChange={(e) => setUsername(e.target.value)}
  />
</div>
<div className="grid gap-2">
  <Label htmlFor="password">Password</Label>
  <Input 
id="password" 
type="password" 
required 
value={password}
onChange={(e) => setPassword(e.target.value)}
  />
</div>
{error && (
  <div className="text-sm text-red-500 font-medium">
{error}
  </div>
)}
<Button className="w-full" type="submit" disabled={loading}>
  {loading ? "Signing in..." : "Sign in"}
</Button>
  </form>
  )
}

image.png

a. form的action属性

1.<form action> 支持函数: 现在,您可以直接将一个 异步函数(Action) 传递给 <form> 元素的 action 属性。

  • 当表单提交时,React 会自动调用这个函数,并且在函数内不需要手动调用 event.preventDefault()
  • Actions 默认在 Transition 中运行,可以自动处理 Pending (等待中) 状态、错误和乐观更新。
  • 自动重置表单。当 <form> Action 成功执行后,React 会自动为 非受控组件 重置表单。

2.web标准的 FormData接口 , 原生表单的提交事件会传递一个FormData对象给函数,该对象包含了表单的键值对。

3.核心逻辑变成了 (formData: FormData) => Promise<any>的函数

const handleLogin = async (formData: FormData) => {
    setError(null);
    setLoading(true);
    const payload = Object.fromEntries(formData.entries());
    try {
      const { data, error: apiError } = await loginApiAuthLoginPost({
        body: { ...payload }
      });
      //...
    } catch (err) {
      console.error(err);
      setError("An unexpected error occurred. Please try again.");
    } finally {
      setLoading(false);
    }

4.将handleLogin传递给form的action, 记得给form表单控件加上name="xxx"属性 。

<form action={handleLogin} className="grid gap-4">
<div className="grid gap-2">
  <Label htmlFor="username">Username</Label>
  <Input 
id="username" 
name="username"
type="text" 
placeholder="admin" 
required 
  />
</div>
<div className="grid gap-2">
  <Label htmlFor="password">Password</Label>
  <Input 
id="password" 
name="password"
type="password" 
required 
  />
</div>
{error && (
  <div className="text-sm text-red-500 font-medium">
{error}
  </div>
)}
<Button className="w-full" type="submit" disabled={loading}>
  {loading ? "Signing in..." : "Sign in"}
</Button>
  </form>
b. useActionState

useActionState介绍

  • 它将表单提交的异步操作、状态管理和加载指示结合在一起。
  • 它返回最新的状态、一个Action 函数,以及一个 isPending 状态,您可以使用它来处理表单的提交结果(如成功消息或错误)。
  • 优点: 减少了对多个 useState 的需求,简化了状态逻辑。

参数细节

  • useActionState 接受一个函数,有两个参数:

    • prevState 代表之前状态
    • formData 代表action提交的数据(如果是form表单,则传入的是FormData)
  • useActionState 可以指定两个泛型,分别对应prevState和formData的类型,比如

    • useActionState<string | null, FormData>()
  • 返回值[state, submitAction, isPending]

常见问题 有点麻烦的是:FormData对ts很不友好,你无法得知FormData上有什么属性和value。

const payload = Object.fromEntries(formData.entries()) 中的payload ts类型如下:

const payload: {  
[k: string]: FormDataEntryValue;  
}

这就要借助牛逼的zodzod-form-data两个库了,顺便把表单的前端校验也做了~

登录表单案例 使用 Actions( useActionState) 改进后的代码如下:

import { useActionState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useNavigate } from "react-router"
import { loginApiAuthLoginPost } from "@/APIs"
import { useAuthStore } from "@/store/useAuthStore"


import { z } from "zod";
import { zfd } from "zod-form-data";

const schema = zfd.formData({
  username: zfd.text(),
  password: zfd.text(z.string().min(6))
});

export default function Login() {
  const navigate = useNavigate();
  const setAuth = useAuthStore((state) => state.setAuth);
 

  const [error, handleLogin, loading] = useActionState<string | null, FormData>(async (_prevState, formData) => {
    // const payload = Object.fromEntries(formData.entries()) //禁止这种ts不友好的转换
    const payload = schema.parse(formData)

    try {
      const { data, error: apiError } = await loginApiAuthLoginPost({
        body: payload,
      });

      if (apiError) {
        return "Login failed. Please check your credentials."
      } else if (data && data.code >= 300) {
        return data?.err_msg || "Unknown error occurred"
      }
      setAuth(data.data.token, data.data.user);
      navigate("/dashboard");
      return null;
    } catch (err) {
      return "An unexpected error occurred. Please try again."
    } 
  }, null);


  return (
    <form action={handleLogin} className="grid gap-4">
<div className="grid gap-2">
  <Label htmlFor="username">Username</Label>
  <Input 
id="username" 
name="username"
type="text" 
placeholder="admin" 
required 
  />
</div>
<div className="grid gap-2">
  <Label htmlFor="password">Password</Label>
  <Input 
id="password" 
name="password"
type="password" 
required 
  />
</div>
{error && (
  <div className="text-sm text-red-500 font-medium">
{error}
  </div>
)}
<Button className="w-full" type="submit" disabled={loading}>
  {loading ? "Signing in..." : "Sign in"}
</Button>
  </form>
  )
}
c. useFormStatus

用于在子组件中访问父级表单的状态(特别是 pending 状态),解决“属性透传(Prop Drilling)”问题。

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? "提交中..." : "提交"}</button>;
}

// 在父组件中
<form action={action}>
  <SubmitButton />
</form>
d. useOptimistic

在异步请求完成之前,先假设操作成功并立即更新 UI。如果请求失败,React 会自动回滚到旧状态。

适用的场景

  • 社交互动,比如:点赞、收藏、关注
  • 简单的列表操作,比如:删除、添加、排序
  • 单字段的表单更新,比如:修改昵称、切换某个开关(Switch)

这些场景的共同点是:逻辑简单、操作频繁、用户对即时反馈要求高、且失败率通常较低。

3. 小总结

总的来说,对于一些简单的表单 场景, 可以尽量不依赖第三方组件,做到优雅快速开发。同时Actions这种范式也不仅仅只用于form,任何POST请求都可以用这种方式来处理,减少了模板代码。 类似一个简易的useAsync hook。

JS判断空值只知道“||”?不如来试试这个操作符

2026年1月10日 17:25

今天我们来聊一个JavaScript里非常实用的语法:空值合并运算符。它的写法是两个问号:??

这个东西有什么用呢?简单说,它帮你处理那些“可能有值,也可能是nullundefined”的情况。

?? 运算符怎么用

空值合并运算符的用法很简单:

let result = 左侧值 ?? 右侧值;

它的规则是:

• 如果左侧值不是null也不是undefined,就返回左侧值

• 如果左侧值nullundefined,就返回右侧值

用这个运算符重写上面的例子:

let user = {
  name: null
};

let displayName = user.name ?? "游客";

console.log(displayName); // 输出:游客

一行代码就搞定了!是不是很简洁?

和 || 运算符的区别

你可能用过||(逻辑或)运算符来设置默认值:

let value = someValue || "默认值";

||??很像,但有一个重要区别:

• || 会在左侧值为 假值 时返回右侧值 • ?? 只在左侧值为 null或undefined 时返回右侧值

JavaScript中的假值包括:

• false

• 0

• ""(空字符串)

• null

• undefined

• NaN

看一个对比例子:

let count0;
let price"";

// 使用 ||
let result1 = count || 10;  // 输出:10(0是假值)
let result2 = price || "未知";  // 输出:"未知"(空字符串是假值)

// 使用 ??
let result3 = count ?? 10;  // 输出:0(0不是null或undefined)
let result4 = price ?? "未知";  // 输出:""(空字符串不是null或undefined)

在实际开发中,如果你希望0false""这些值被保留,就用??。如果你希望所有假值都用默认值替换,就用||

实际场景

场景1:函数参数默认值

function greet(name) {
  // 如果没传name,或者传了null/undefined,就用"朋友"
  let finalName = name ?? "朋友";
  console.log(`你好,${finalName}!`);
}

greet("张三");  // 输出:你好,张三!
greet(null);    // 输出:你好,朋友!
greet();        // 输出:你好,朋友!

场景2:配置对象合并

// 用户设置
let userSettings = {
  themenull// 用户没选主题
  fontSize14
};

// 默认设置
let defaultSettings = {
  theme"light",
  fontSize16,
  language"zh-CN"
};

// 合并设置:优先用用户的,用户没设置就用默认的
let finalSettings = {
  theme: userSettings.theme ?? defaultSettings.theme,
  fontSize: userSettings.fontSize ?? defaultSettings.fontSize,
  language: userSettings.language ?? defaultSettings.language
};

console.log(finalSettings);
// 输出:{ theme: "light", fontSize: 14, language: "zh-CN" }

场景3:链式调用

let user = {
  profile: {
    address: {
      city: "北京"
    }
  }
};

// 安全地获取嵌套属性
let city = user?.profile?.address?.city ?? "未知城市";
console.log(city); // 输出:北京

// 如果中间某个属性不存在
let user2 = {
  profile: null
};

let city2 = user2?.profile?.address?.city ?? "未知城市";
console.log(city2); // 输出:未知城市

这里用到了可选链操作符?.,它和??配合使用非常方便。

注意事项

1. 不能直接和&&||一起用

// 这样写会报错
let x = a && b ?? c;  // SyntaxError

// 需要加括号
let x = (a && b) ?? c;  // 正确
let y = a && (b ?? c);  // 正确

2. 优先级问题

??的优先级比=高,但比大多数运算符低。为了代码清晰,建议在复杂表达式中使用括号。

// 这样写可能不是你想要的
let result = a ?? b && c;

// 加括号更清晰
let result = a ?? (b && c);

3. 在旧版本浏览器中可能不支持

??是ES2020引入的语法。如果你需要支持旧浏览器,可能需要使用Babel等工具进行转换。

什么时候用??

记住这个简单的判断标准:

• 当你只想在变量是nullundefined时才用默认值,用??

• 当你想在所有假值(false0""等)情况下都用默认值,用||

在实际项目中,??通常更适合处理配置、用户输入、API响应等场景,因为这些场景中0false""往往是有效值,不应该被默认值替换。

前端面试必问 asyncawait 到底要不要加 trycatch 90% 人踩坑 求职加分技巧揭秘

作者 海云前端1
2026年1月10日 16:56

现在写前端异步代码 基本离不开 Promise 和 asyncawait

相比之下 asyncawait 写起来更像同步代码 读起来更顺畅 被很多开发者称为异步编程的终极方案

但有个让新手纠结不已的问题 用 asyncawait 时到底要不要加 trycatch 捕获错误

看了不少项目代码 有人全程加有人完全不加 还有人用 catch 链式调用 各种写法让人眼花缭乱 更关键的是 这也是前端面试高频考点 很多求职者因为说不清楚其中逻辑 直接错失 offer

今天这篇文章 不光把三种常见写法拆解得明明白白 告诉你不同场景该怎么选 还会揭秘面试官考察这个问题的核心逻辑

一 三种常见写法 优缺点一眼看清

先看最常用的三种写法 各自的适用场景和坑都给你列好了 面试时直接举例就能加分

1 稳妥写法 trycatch 包裹

这是最规范的写法 把 await 调用的异步代码放在 try 块里 错误统一在 catch 里处理

javascript

运行

function getUserInfo () {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            reject(请求出错了哦)
        }, 1000)
    })
}

async function logined () {
    try {
        let userInfo = await getUserInfo()
        // 一旦上面出错 下面代码不会执行
        let pageInfo = await getPageInfo(userInfo?.userId)
    } catch(e) {
        console.warn(抓到一个错误, e)
    }
}

logined()

优点很明显 错误能精准捕获 而且代码会在出错位置直接停止 不会引发后续连锁问题 缺点就是看起来有点啰嗦 每个异步逻辑都这么写 会多几行代码

2 简洁写法 直接接 catch 链式调用

不想写 trycatch 的话 可以在 await 后面直接加 catch 方法 捕获当前异步操作的错误

javascript

运行

function getUserInfo () {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            reject(请求出错了哦)
        }, 1000)
    })
}

async function logined () {
    let userInfo = await getUserInfo().catch(e => console.warn(抓到一个错误, e))
    // 注意 出错后代码还会继续执行
    if (!userInfo) return // 必须手动判断是否获取成功
    let pageInfo = await getPageInfo(userInfo?.userId)
}

logined()

这种写法更简洁 不用额外包裹代码 但有个关键坑 即使前面异步操作失败 程序也不会自动停止 会继续执行后面的代码 所以必须手动检查返回值是否有效 否则可能出现空值报错

3 中断写法 catch 里二次 reject

想让程序出错后停止 又不想用 trycatch 可以在 catch 里把错误再次 reject 出去

javascript

运行

function getUserInfo () {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            reject(请求出错了哦)
        }, 1000)
    })
}

async function logined () {
    let userInfo = await getUserInfo().catch(e => {
        console.warn(抓到一个错误, e)
        return Promise.reject(e) // 再次抛出错误
    })
    // 代码会在这里停止 不再往下执行
    let pageInfo = await getPageInfo(userInfo?.userId)
}

logined()

这种写法能实现错误中断的效果 但有个小问题 控制台会出现 uncaught in promise 的错误提示 虽然不影响功能 但显得不够规范 面试时要能说清这个细节

二 核心判断逻辑 到底该怎么选

其实真实项目中 我们很少直接用原生 Promise 发请求 大多会用 axios 或 fetch 而且通常会对这些请求库做一层封装 统一处理错误状态码 超时提示等

至于 await 后面要不要加 trycatch 核心判断标准就一个 你是否想让程序在出错时停止执行

场景 1 不想中断程序 允许后续逻辑继续执行

比如获取非核心数据 即使失败也不影响页面主要功能 可以用 catch 链式调用 再手动判断返回值

javascript

运行

let userInfo = await getUserInfo().catch(e => console.warn(e))
if (!userInfo) return // 终止当前函数 但不影响其他代码
// 后续非依赖逻辑可以继续执行
renderDefaultInfo()

这种写法控制台不会出现红字错误 用户体验更友好

场景 2 想中断程序 避免错误扩散

比如登录请求 必须获取用户信息后才能继续 一旦失败就该停止后续操作 这时 trycatch 是最佳选择

javascript

运行

try {
    let userInfo = await getUserInfo()
    let pageInfo = await getPageInfo(userInfo?.userId)
    // 依赖用户信息的核心逻辑
} catch(e) {
    console.warn(e)
    // 可以在这里添加错误提示 比如弹窗告知用户
    showErrorToast(获取信息失败 请重试)
}

错误处理集中在一个地方 便于管理和维护 代码逻辑也更清晰

场景 3 想中断程序 不介意控制台报错

如果是内部工具类或非用户 - facing 的功能 可以用 catch 二次 reject 的写法 虽然控制台会有报错提示 但能快速定位问题 不过这种写法在面试中不推荐作为首选 除非能说清适用场景和优缺点

三 面试加分建议 推荐这样写

结合项目实操和面试得分点 个人最推荐 trycatch 的写法 原因有四点1 代码逻辑更清晰 符合同步代码的直觉 出错就停止 容易理解和维护2 错误处理集中 所有异常都在 catch 块里统一处理 不用分散在各个链式调用中3 控制台不会出现多余的 uncaught 错误提示 代码更规范 显得专业4 风格统一 既然用 asyncawait 追求同步写法的简洁 搭配 trycatch 能保持代码风格一致

虽然多了几行代码 但比起可读性和可维护性 这点代价完全值得 面试时说清这个权衡思路 能体现你的工程化思维

四 面试官真正想考的 不只是写法

这道题表面考错误处理写法 实际考察三个核心能力 面试时要针对性回应1 对异步编程原理的理解 是否清楚 Promise 和 asyncawait 的错误传递机制2 场景化思维 是否能根据不同业务场景选择合适的错误处理方式3 代码规范和可维护性意识 是否考虑到后续开发和排查问题的便捷性

面试时可以按这个逻辑组织语言 先说明三种写法的区别 再讲核心判断标准 最后给出推荐方案和理由 比如asyncawait 的错误处理有三种常见方式 分别是 trycatch 包裹 catch 链式调用 以及 catch 中二次 reject 选择哪种主要看是否需要中断程序 核心业务比如登录 必须获取结果后才能继续 推荐用 trycatch 能集中处理错误并避免扩散 非核心业务比如获取推荐列表 可以用 catch 链式调用 不中断后续逻辑 个人更推荐 trycatch 因为代码规范 错误处理集中 更利于维护

这样回答 既展示了技术功底 又体现了场景化思维 面试官会觉得你不仅会写代码 还懂怎么写好代码

五 求职福利 这道题答好 offer 稳一半

很多前端求职者技术不错 却栽在这种基础考点上 不是不会写 而是不会提炼核心逻辑 不会结合面试场景组织语言 更不知道怎么把项目经验转化为面试加分项

作为专注前端简历面试辅导和求职陪跑的导师 我见过太多类似案例 有人能熟练使用 trycatch 处理错误 却讲不清适用场景 有人项目里踩过异步错误的坑 却不知道怎么在面试中表达 还有人简历里只写会用 asyncawait 没说清错误处理的最佳实践 自然吸引不了面试官

如果你也面临这些困扰

  • 面试时讲不清技术细节 不会结合场景举例
  • 简历里的技术亮点没突出 投递后石沉大海
  • 遇到深度追问就慌 不知道怎么展现实力
  • 想进大厂 却摸不透面试考察重点

现在机会来了 我的前端简历面试辅导加求职陪跑计划 专为前端求职者量身打造

✅ 1v1 简历优化 挖掘你项目中的异步编程 错误处理等实践 把技术亮点转化为面试加分项 让 HR 一眼看中

✅ 模拟面试实战 还原大厂面试场景 针对性训练 asyncawait 性能优化 跨域等高频题 教你说出面试官想听的答案

✅ 技术答疑加求职规划 从基础到进阶解决你的技术痛点 帮你避开求职陷阱 精准匹配目标公司

✅ 独家资料放送 整理 100 + 前端高频面试题 包含异步编程 框架 安全等核心知识点 附答案解析和实操场景 让你面试不慌

打造 AI 驱动的 Git 提交规范助手:基于 React + Express + Ollama+langchain 的全栈实践

作者 ohyeah
2026年1月10日 16:50

在现代软件开发中,高质量的 Git 提交信息不仅是团队协作的基础,更是项目可维护性、可追溯性和工程文化的重要体现。然而,许多开发者(尤其是初学者)常常忽略提交信息的规范性,导致提交日志混乱、难以理解,甚至影响 Code Review 和故障排查效率。

为了解决这一痛点,本文将带你从零构建一个 AI 驱动的 Git 提交规范助手 —— git-differ。该项目结合了前端(React + Tailwind CSS + Axios)、后端(Node.js + Express)以及本地大模型(Ollama + DeepSeek-R1:8B),通过分析 git diff 内容,自动生成符合 Conventional Commits 规范的提交信息。

我们将深入剖析项目的代码结构、技术选型与关键知识点,并围绕 跨域处理、LangChain 集成、自定义 React Hook 封装、Express 路由设计 等核心内容展开详细讲解。


一、项目整体架构与技术栈

git-differ 是一个典型的前后端分离全栈应用:

  • 前端:运行于浏览器(如 http://localhost:5173),使用 React 构建 UI,Tailwind CSS 实现响应式样式,Axios 发起 HTTP 请求。
  • 后端:运行于 Node.js 环境(http://localhost:3000),基于 Express 框架提供 RESTful API。
  • AI 引擎:本地部署 Ollama 服务(http://localhost:11434),加载开源大模型 deepseek-r1:8b,通过 LangChain 进行提示工程与输出解析。

整个数据流如下:

用户输入 git diff → 前端发送 POST /chat → 后端接收并调用 Ollama → AI 生成 commit message → 返回前端展示

这种架构不仅解耦清晰,还便于后续扩展(如支持多模型、历史记录、配置管理等)。


二、前端实现:React + 自定义 Hook + Axios 模块化

1. 主组件 App.jsx:UI 与逻辑分离

import { useEffect } from "react"
import { chat } from './api/axios'
import { useGitDiff } from "./hooks/useGitDiff"

export default function App(){
  const { loading, content } = useGitDiff('hello')

  return (
    <div className="flex">
      {loading ? 'loading...' : content}
    </div>
  )
}

主组件极其简洁,仅负责渲染状态。真正的业务逻辑被封装在 useGitDiff 自定义 Hook 中,体现了 “组件只负责 UI” 的最佳实践。

“use开头 封装响应式业务 副作用等 从组件里面剥离 组件只负责UI”

这种模式极大提升了代码的可读性与复用性。未来若需在多个页面使用 AI 生成 commit message 功能,只需调用 useGitDiff 即可。


2. API 层:Axios 模块化封装

// src/api/axios.js
import axios from 'axios'

// 创建axios实例 统一进行配置
const service = axios.create({
  baseURL: 'http://localhost:3000',
  headers: {
    'Content-Type': 'application/json'
  },
  timeout: 60000
})

export const chat = (message) => {
  return service.post('/chat', {
    message
  })
}

这里通过 axios.create() 创建了一个 专用的 HTTP 客户端实例,统一配置:

  • baseURL:避免在每个请求中重复写后端地址;
  • headers:确保请求体为 JSON 格式;
  • timeout:设置超时时间,防止请求卡死。

“模块化 在api目录下管理所有的请求”

封装了api请求,在其他组件(如:useGitDiff.js)中只需要模块化导入chat 即可以发起请求,这种组织方式是大型 React 项目的标准做法,便于维护和测试。


3. 自定义 Hook:useGitDiff —— 封装副作用与状态

// src/hooks/useGitDiff.js
import { useState, useEffect } from "react"
import { chat } from "../api/axios"

export const useGitDiff = (diff) => {
  const [content, setContent] = useState('')
  const [loading, setLoading] = useState(false)

  useEffect(() => {
    (async () => {
      if(!diff) return 
      setLoading(true)
      const { data } = await chat(diff)
      setContent(data.reply)
      setLoading(false)
    })()
    // 立即执行异步函数  避免顶层async
  }, [diff])

// 将外部需要的loading状态和content内容 return
  return {
    loading, // 加载状态 用户体验
    content // llm得出的commit message
  }
}

该 Hook 接收 diff 字符串作为依赖,当其变化时自动发起请求。关键点包括:

  • 使用 立即执行异步函数(IIFE)useEffect 中处理异步逻辑;
  • 通过 setLoading 提供加载状态反馈;
  • 依赖数组 [diff] 确保只在 diff 变化时触发请求;
  • 返回结构化对象,便于解构使用。

“通过立即执行函数 执行异步函数”

这种写法规避了 useEffect 的回调函数不能直接使用 async/await 的限制。


三、后端实现:从零构建一个健壮的 Express AI 服务

后端是整个 git-differ 项目的中枢神经。它不仅要接收前端请求、调用本地大模型,还需保证安全性、稳定性、可维护性与协议规范性。以下我们将结合完整代码,逐层剖析其技术内涵。

1. 基础服务初始化:Express 应用骨架

import express from 'express'
import cors from 'cors'

const app = express()
app.use(express.json())
app.use(cors())

app.listen(3000, () => {
  console.log('server is running on port 3000')
})

这段看似简单的代码,实际上完成了现代 Web API 服务的三大基础配置:

express():创建应用实例

  • Express 是 Node.js 生态中最流行的 Web 框架,其核心思想是 “中间件管道”
  • app 是一个可配置、可扩展的 HTTP 服务器容器,后续所有路由、中间件都挂载于此。

app.use(express.json()):请求体解析中间件

  • 默认情况下,Express 不会自动解析请求体req.bodyundefined)。
  • express.json() 是一个内置中间件,用于解析 Content-Type: application/json 的请求体,并将其转换为 JavaScript 对象。
  • 若省略此中间件,req.body 将无法获取前端发送的 { message: "..." } 数据,导致后续逻辑失败。

💡 最佳实践:应在所有路由定义之前注册全局中间件,确保所有请求都能被正确解析。

app.use(cors()):跨域资源共享(CORS)支持

  • 前端开发服务器(如 Vite,默认端口 5173)与后端(3000)构成跨域请求(协议、域名或端口不同)。

  • 浏览器出于安全考虑,会拦截跨域请求的响应,除非服务器明确允许。

  • cors() 中间件自动处理 OPTIONS 预检请求,并在响应头中添加:

    Access-Control-Allow-Origin: *
    Access-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE
    Access-Control-Allow-Headers: Content-Type
    
  • 在生产环境中,应限制 origin 为可信域名(如 origin: ['https://your-app.com']),避免开放全站跨域。


2. 路由设计:RESTful API 与 HTTP 语义

app.get('/hello', (req, res) => {
  res.send('hello world')
})

app.post('/chat', async (req, res) => {
  // ... 业务逻辑
})

📌 路由方法与资源操作语义

  • GET /hello:用于健康检查或简单测试,无副作用,符合幂等性。

  • POST /chat:用于提交 git diff 内容并获取 AI 生成结果。使用 POST 是因为: 请求包含复杂数据体(diff 文本可能很长);

📌 请求与响应对象(req / res

  • req.body:由 express.json() 解析后的请求体数据;
  • res.status(code).json(data):设置 HTTP 状态码并返回 JSON 响应;
  • res.send():返回纯文本(不推荐用于 API,应统一使用 JSON)。

3. 输入验证:防御性编程的第一道防线

const { message } = req.body
if (!message || typeof message !== 'string') {
  return res.status(400).json({
    error: 'message 必填,必须是字符串'
  })
}
  • 永远不要信任客户端输入,后端稳定性 是第一位。即使前端做了校验,后端也必须二次验证。

  • 此处检查:

    • message 是否存在(防止 nullundefined);
    • 类型是否为字符串(防止传入对象、数组等非法类型)。
  • 返回 400 Bad Request 状态码,明确告知客户端请求格式错误。

  • 错误信息清晰具体,便于前端调试。


4. AI 集成:LangChain 链式调用与错误隔离

import { ChatOllama } from '@langchain/ollama'
import { ChatPromptTemplate } from '@langchain/core/prompts'
import { StringOutputParser } from '@langchain/core/output_parsers'

const model = new ChatOllama({
  baseUrl: 'http://localhost:11434',
  model: 'deepseek-r1:8b',
  temperature: 0.1 // 
})

🔗 LangChain 核心抽象:链(Chain)

LangChain 的核心思想是将 LLM 调用过程模块化、可组合。本项目使用了典型的三段式链:

  1. Prompt Template:结构化输入

    const prompt = ChatPromptTemplate.fromMessages([  ['system', `你是一个专业的 Git 提交信息生成器。请根据以下 git diff 内容,生成一条简洁、符合 Conventional Commits 规范的 commit message。不要解释,只输出 commit message`],
      ['human', '{input}']
    ])
    
    • 将原始 message 注入到预设对话模板中;
    • 支持多轮对话上下文(未来可扩展);
    • 确保提示词格式符合模型预期。
  2. Model:大模型调用

    • ChatOllama 是 LangChain 对 Ollama API 的封装;
    • baseUrl 指向本地 Ollama 服务;
    • temperature: 0.1 降低随机性,使输出更确定(适合生成规范文本)。
  3. Output Parser:标准化输出

    new StringOutputParser()
    
    • 强制将模型返回的复杂对象(如 { content: "...", role: "assistant" })转换为纯字符串;
    • 避免前端处理不必要的元数据;
    • 保证 API 响应结构稳定:{ reply: "string" }

⚠️ 错误处理:隔离 AI 不稳定性

try {
  const result = await chain.invoke({ input: message })
  res.json({ reply: result })
} catch (err) {
  res.status(500).json({ error: '调用大模型失败' })
}
  • 大模型调用可能因网络、内存、模型加载失败等原因抛出异常;
  • 使用 try/catch 捕获所有同步/异步错误;
  • 返回 500 Internal Server Error,避免服务崩溃;

5. 工程化考量:可维护性与可观测性

  • 日志记录console.log('正在调用大模型') 提供基本执行追踪;
  • 超时控制:虽未显式设置,但 Ollama 客户端和 Axios 均有默认超时;
  • 依赖解耦:AI 逻辑封装在路由内,未来可提取为独立 service 层;

总结:一个合格的后端 API 应具备什么?

维度 本项目实现
协议合规 正确使用 HTTP 方法、状态码、Content-Type
输入安全 严格校验请求体格式与类型
错误处理 区分客户端错误(4xx)与服务端错误(5xx)
跨域支持 通过 CORS 中间件解决开发跨域问题
AI 集成 使用 LangChain 实现可维护的提示工程
可扩展性 模块化结构,便于未来增加新功能

这才是一个面向生产、面向读者的后端实现应有的深度与广度。


四、AI 部分:Ollama 与本地大模型部署

1. 为什么选择 Ollama?

  • 开源、轻量、支持多种模型(Llama, DeepSeek 等);
  • 提供类 OpenAI 的 API 接口(/api/chat),便于集成;
  • 可在消费级 GPU 或 CPU 上运行(需足够内存)。

2. 模型选择:deepseek-r1:8b

  • r1 表示 Reasoning 版本,推理能力更强;

  • 8b 为 80 亿参数,平衡性能与资源消耗;

  • 需通过命令下载:

    ollama pull deepseek-r1:8b
    ollama run deepseek-r1:8b  # 测试
    

“ollama帮助我们像openai一样的api 接口 http://localhost:11434”

3. 性能提示

  • 首次加载模型较慢(需加载到内存);
  • temperature: 0.1 降低随机性,使输出更确定、规范;
  • 若响应慢,可考虑量化版本(如 q4_K_M)。

五、总结

通过 git-differ 项目,我们完整实践了一个 AI 增强型开发者工具 的全栈开发流程:

  • 前端:React 自定义 Hook + Axios 模块化;
  • 后端:Express 路由 + CORS + 错误处理;
  • AI:Ollama + LangChain 提示工程 + 输出解析。

这不仅解决了“如何写好 Git 提交信息”的实际问题,更展示了 本地大模型在开发者工具链中的落地场景。随着开源模型能力不断提升,类似工具将极大提升个人与团队的开发效率与代码质量。

最终目标:让新手也能像高手一样,写出清晰、规范、有价值的 Git 提交记录。


现在,启动你的 AI Git 助手,让每一次提交都成为工程艺术的一部分!

第11章 LangChain

作者 XiaoYu2002
2026年1月10日 16:28

LangChain 是一个用于开发基于大语言模型(LLM)应用程序的开源框架,它通过提供模块化的抽象组件和链式调用工具,将 LLM 与外部数据源(如文档、数据库)和计算工具(如搜索引擎、代码解释器)智能连接,从而构建出具备记忆、推理和行动能力的增强型 AI 应用,典型场景包括智能问答、内容生成和智能体(Agent)系统。

LangChain官方文档:docs.langchain.com/。网址的组成逻辑和Ne…

LangChain支持Python和TypeScript两种编程语言,如图11-1所示。

image-20251219041533100

图11-1 LangChain开源代理框架-语言选项

LangChain开源代理框架有3种选择方案:

(1)LangChain:一些普通对话,音频识别、文字生成,图片生成等等与AIGC相关的,用这选择方案就够了。

(2)LangGraph:想做工作流,类似Dify,Coze,那么就需要使用该方案。

(3)Deep Agents:想做一些大型的AI相关高级应用,就需要使用该方案,Deep Agents是深度集成的意思。

LangChain 作为基础框架,适合构建常规的AIGC应用(如对话、文生图);LangGraph 专注于通过有状态、可循环的图结构来编排复杂、多步骤的智能体工作流,是开发类Dify/Coze平台或自动化业务流程的核心选择;而 Deep Agents 则代表了一种更深度集成、能处理高复杂度任务与自主决策的高级智能体架构,常用于需要多智能体协作或模拟人类工作流的大型企业级AI应用。

我们这里学习的话,使用第一个LangChain就完全够用。LangChain下载使用说明如图11-2所示。我们点击如图11-1所示的第一个选项后,会跳转到如图11-2所示的界面,需要点击左侧边栏的install选项。官方文档有对应的使用说明。

image-20251219042243761

图11-2 LangChain下载使用说明

## 11.1 初始化项目

接下来,我们要开始初始化这次的项目。会沿用第10章 SSE魔改的代码,在该基础上,需要补充以下安装步骤:

(1)@langchain/core:LangChain 的核心基础库,包含链、提示模板、检索器等核心抽象。

(2)@langchain/deepseek: LangChain 为DeepSeek 模型专门提供的集成包,让我们能在 LangChain 框架中直接调用 DeepSeek 的 API。安装规则是@langchain/所需AI大模型,例如@langchain/openai。

(3)langchain:LangChain 的主包,提供了高级、易于使用的接口来组合和使用 LLM。

安装LangChain之后,我们需要一个AI大模型来支持,在这次示例中,选择DeepSeek,因为它的API非常便宜。

// 终端执行命令
npm install @langchain/core @langchain/deepseek langchain

安装之后的package.json文件如下所示。

{
  "type": "module",
  "dependencies": {
    "@langchain/core": "^1.1.6",
    "@langchain/deepseek": "^1.0.3",
    "@types/cors": "^2.8.19",
    "@types/express": "^5.0.6",
    "cors": "^2.8.5",
    "express": "^5.2.1",
    "langchain": "^1.2.1"
  }
}

由于我们在7.1小节已经升级过Node.js版本,因此可以直接用Node.js去运行ts后缀文件,满足Node.js版本大于23的都可以这么做,如果无法运行ts后缀文件,需要检查一下Node.js版本或者采用ts-node。

接下来到index.ts文件中初始化后端服务。

// index.ts
import express from 'express'
import cors from 'cors'

const app = express()
app.use(cors())
app.use(express.json())

app.post('/api/chat', async (req, res) => { })

app.listen(3000, () => {
  console.log('Server is running on port 3000')
})

11.2 接入大模型

接着接入我们的AI大模型DeepSeek,还是在index.ts文件中。

在这里引入了一个key.ts文件,该文件存放着DeepSeek API的Key。

import { ChatDeepSeek } from '@langchain/deepseek'
import { key } from './key.ts'
const deepseek = new ChatDeepSeek({
    apiKey: key,
    model: 'deepseek-chat',
    temperature: 1.3,
    maxTokens: 1000, //500-600个汉字
    topP: 1, //设得越小,AI 说话越"死板";设得越大,AI 说话越"放飞自我"
    frequencyPenalty: 0,//防复读机诉 AI:"你别老重复同一个词!"-2   2
    presencePenalty: 0,//鼓励换话题告诉 AI:"别老聊同一件事!" -2   2
})

获取DeepSeek的key,如下4步骤:

(1)打开DeepSeek的API开放平台:platform.deepseek.com/

(2)微信扫码登录,去实名认证。

(3)点击左侧边栏的用量信息选择去充值选项,有在线充值和对公汇款两个选项,选择在线充值,自定义输入1块钱(够用了),然后自己选择支付宝或者微信支付去付款。

(4)付款成功后,点击左侧边栏的API keys选项,创建API key,随便输入一个名称(中英文都可以),然后会弹出key值,此时复制key值再关闭弹窗,因为当你关闭后,就再也拿不到这个key值了。忘记就只能重新再建一个,DeepSeek会提醒你的,如图11-3所示。

友情提示:保护好你的key值,别暴露在公网中,在开源项目上传GitHub中,可以让git忽略key.ts文件。或者如果你的DeepSeek API就只充了1块钱,然后用得剩几毛钱,并且以后都不怎么打算充,那想不想保护key值,就看你心情了。

image.png

图11-3 创建API key注意事项

通过以上步骤获取到DeepSeek的key值后,在项目创建key.ts文件,创建常量key,填入你的key值并导出。

export const key = '你DeepSeek的key值'

回到index.ts文件,接入DeepSeek大模型之后,ChatDeepSeek有一个model字段,这是用于选择我们模型的。已有的模型类型需要从DeepSeek官方文档中获取:模型 & 价格 | DeepSeek API Docs

// DeepSeek字段
apiKey: key,
model: 'deepseek-chat',
temperature: 1.3,
maxTokens: 1000, // 500-600个汉字
topP: 1, // 设得越小,AI 说话越"死板";设得越大,AI 说话越"放飞自我"
frequencyPenalty: 0,// 防复读机诉 AI:"你别老重复同一个词!"-2   2
presencePenalty: 0,// 鼓励换话题告诉 AI:"别老聊同一件事!" -2   2

目前可选的模型有deepseek-chat和deepseek-reasoner。DeepSeek官网价格计算是以百万Token为单位,其中1 个英文字符 ≈ 0.3 个 token;1 个中文字符 ≈ 0.6 个 token。只要大概知道很便宜就足够了。

image-20251219050542592

图11-4 DeepSeek模型选择

temperature字段是温度的含义,在DeepSeek官方文档中有直接给出对应的建议,我们的示例是打算用于对话,因此设置1.3就足够了,Temperature参数设置如图11-5所示。

从应用场景,我们可以理解为temperature字段大概是理性与感性的权衡度,逻辑性越强的场景,温度越低;越感性的场景温度越高。所有AI大模型都是类似的,从他们对应的官方文档去获取对应信息就可以了。

image-20251219050952588

图11-5 DeepSeek模型-Temperature参数设置

其余4个参数maxTokens、topP,frequencyPenalty和presencePenalty如下:

(1)maxTokens 直接决定了 AI 回复的最大长度,限制了单次响应的文本量;

(2)topP(核采样参数)通过控制候选词的概率分布来影响文本的创造性与稳定性——值越低则 AI 的选词越集中和可预测,输出趋于“死板”,值越高则选词范围越宽,输出越“放飞自我”并富有创意。

(3)而 frequencyPenalty 与 presencePenalty 则分别从词频和话题层面抑制重复:frequencyPenalty 正值会惩罚在当前回复中已经频繁出现的词语,促使用词更加多样;presencePenalty 正值则会惩罚在已生成的上下文中出现过的所有主题,鼓励 AI 主动切换到新的话题或角度,从而共同确保生成内容的多样性和连贯性,避免陷入单调或循环重复的表达。

这些值具体设置多少,则需要根据具体场景的经验以及自身的理解,推荐看我写的AI使用手册,开头有讲解到这一部分注意力机制:AI精准提问手册:从模糊需求到精准输出的核心技能(上)

11.3 AI对话

接下来需要从langchain引入createAgent方法,并使用我们设置好的deepseek实例对象。我们调用agent身上的invoke()方法,该方法更适合单次输出(一次性直接返回),即非流式返回。

通过createAgent方法除了可以设置接入的大模型,还可以通过systemPrompt字段去设置Prompt提示词。

通过LangChain代理的stream()方法调用DeepSeek模型处理用户请求:将客户端发送的req.body.message作为用户消息输入,并设置streamMode为 "messages" 来获取结构化的消息流响应;在等待代理完成流式生成后,将整个结果集作为JSON数据一次性返回给客户端。

import express from 'express'
import cors from 'cors'
import { ChatDeepSeek } from '@langchain/deepseek'
import { key } from './key.ts'
import { createAgent } from 'langchain'
const deepseek = new ChatDeepSeek({
  apiKey: key,
  model: 'deepseek-chat',
  temperature: 1.3,
  maxTokens: 1000, // 500-600个汉字
  topP: 1, // 设得越小,AI 说话越"死板";设得越大,AI 说话越"放飞自我"
  frequencyPenalty: 0, // 防复读机诉 AI:"你别老重复同一个词!"-2   2
  presencePenalty: 0, // 鼓励换话题告诉 AI:"别老聊同一件事!" -2   2
})

const app = express()
app.use(cors())
app.use(express.json())

app.post('/api/chat', async (req, res) => {
  res.setHeader('Content-Type', 'application/json')
  res.setHeader('Cache-Control', 'no-cache')
  res.setHeader('Connection', 'keep-alive')
  const agent = createAgent({
    model: deepseek,
    systemPrompt: `你是一个聊天机器人,请根据用户的问题给出回答。`,
  })
  const result = await agent.invoke({
    messages: [
      {
        role: 'user',
        content: req.body.message,
      }
    ]
  })
  res.json(result)
})

app.listen(3000, () => {
  console.log('Server is running on port 3000')
})

接下来回到index.html文件一下,我们需要设置客户端返回给后端的问题,也就是往req.body.message里塞一下咨询AI的问题。

<script>
    fetch('http://localhost:3000/api/chat', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({ message: '请问你是什么AI大模型' })
    }).then(async res=>{
        const reader = res.body.getReader()
        const decoder = new TextDecoder()
        while (true) {
            const { done, value } = await reader.read()
            if (done) {
                break
            }
            const text = decoder.decode(value, { stream: true })
            console.log(text)
        }
    })
</script>

我们问了一个:“你是什么AI大模型”的问题,浏览器返回AI对话信息如图11-6所示。

image-20251219054656481

图11-6 DeepSeek模型-Temperature参数设置

到这为止,我们就正式打通了AI对话的环节。并且如果我们打开网络选项卡,可以发现AI对话返回的内容是post请求的。如果我们想改成流式输出也是post请求,在第10.3小节所学习的SSE设置post请求就可以用上了。

11.4 流式输出AI对话

如果我们想修改成流式输出对话的话,需要修改3个地方:

(1)后端设置的Content-Type类型改成事件流类型。

(2)agent不使用invoke()方法,该换专门的agent.stream()流输出方法,并调整对应参数。

(3)agent.stream()流输出方法返回迭代器,针对迭代器去调整输出形式。

import express from 'express'
import cors from 'cors'
import { ChatDeepSeek } from '@langchain/deepseek'
import { key } from './key.ts'
import { createAgent } from 'langchain'
const deepseek = new ChatDeepSeek({
  apiKey: key,
  model: 'deepseek-chat',
  temperature: 1.3,
  maxTokens: 1000, // 500-600个汉字
  topP: 1, // 设得越小,AI 说话越"死板";设得越大,AI 说话越"放飞自我"
  frequencyPenalty: 0, // 防复读机诉 AI:"你别老重复同一个词!"-2   2
  presencePenalty: 0, // 鼓励换话题告诉 AI:"别老聊同一件事!" -2   2
})

const app = express()
app.use(cors())
app.use(express.json())

app.post('/api/chat', async (req, res) => {
  res.setHeader('Content-Type', 'application/event-stream')
  res.setHeader('Cache-Control', 'no-cache')
  res.setHeader('Connection', 'keep-alive')
  const agent = createAgent({
    model: deepseek,
    systemPrompt: `你是一个聊天机器人,请根据用户的问题给出回答。`,
  })
  const result = await agent.stream({
    messages: [
      {
        role: 'user',
        content: req.body.message,
      }
    ]
  }, { streamMode: "messages" })
  for await (const chunk of result) {
    res.write(`data: ${JSON.stringify(chunk)}\n\n`)
  }
  res.end()
})

app.listen(3000, () => {
  console.log('Server is running on port 3000')
})

agent.stream()方法有第二个参数,用于指定流式输出的数据格式和粒度,决定了从流中接收到的是原始令牌、结构化消息还是其他中间结果。agent.stream()方法第二个参数的选项如表11-1所示。我们选择messages就可以了。如果想要真正存粹的打字效果并且节约token,可以使用values选项。

表11-1 agent.stream()方法第二个参数的选项

流模式 返回的数据类型 典型用途 示例输出(逐块)
"messages" 完整的消息对象 需要处理结构化对话(如获取AI回复的完整消息) {"role": "assistant", "content": "你好"}
"values" 底层值(如原始token) 需要实现逐字打印效果或最低级控制 "你" "好"
"stream" 混合事件流 需要同时获取token和消息等多样信息 {"type": "token", "value": "你"}
const result = await agent.stream({
    messages: [
      {
        role: 'user',
        content: req.body.message,
      }
    ]
  }, { streamMode: "values" })

其次,由于agent.stream()方法的返回值类型是IterableReadableStream<StreamMessageOutput>,说明返回值就是一个迭代器。因此可以使用for await of语法糖来流式输出内容,不用手动的去调用迭代器的next()方法。

  for await (const chunk of result) {
    res.write(`data: ${JSON.stringify(chunk)}\n\n`)
  }
  res.end()

AI对话-流式输出如图11-7所示。会按顺序返回非常多的JSON格式数据,通过data字段下的kwargs的content可以看到AI返回内容以三两字的形式不断输出。并且在前端的接收流式输出,不会因post请求而出现问题。

image-20251219060557208

图11-7 AI对话-流式输出

流式输出AI对话的完整代码如下:

// index.ts
import express from 'express'
import cors from 'cors'
import { ChatDeepSeek } from '@langchain/deepseek'
import { key } from './key.ts'
import { createAgent } from 'langchain'
const deepseek = new ChatDeepSeek({
  apiKey: key,
  model: 'deepseek-chat',
  temperature: 1.3,
  maxTokens: 1000, // 500-600个汉字
  topP: 1, // 设得越小,AI 说话越"死板";设得越大,AI 说话越"放飞自我"
  frequencyPenalty: 0, // 防复读机诉 AI:"你别老重复同一个词!"-2   2
  presencePenalty: 0, // 鼓励换话题告诉 AI:"别老聊同一件事!" -2   2
})

const app = express()
app.use(cors())
app.use(express.json())

app.post('/api/chat', async (req, res) => {
  res.setHeader('Content-Type', 'application/event-stream')
  res.setHeader('Cache-Control', 'no-cache')
  res.setHeader('Connection', 'keep-alive')
  const agent = createAgent({
    model: deepseek,
    systemPrompt: `你是一个聊天机器人,请根据用户的问题给出回答。`,
  })
  const result = await agent.stream({
    messages: [
      {
        role: 'user',
        content: req.body.message,
      }
    ]
  }, { streamMode: "messages" })
  for await (const chunk of result) {
    res.write(`data: ${JSON.stringify(chunk)}\n\n`)
  }
  res.end()
})

app.listen(3000, () => {
  console.log('Server is running on port 3000')
})
// index.html
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>

<body>
  <script>
    fetch('http://localhost:3000/api/chat', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ message: '请问你是什么AI大模型' })
    }).then(async res => {
      const reader = res.body.getReader()
      const decoder = new TextDecoder()
      while (true) {
        const { done, value } = await reader.read()
        if (done) {
          break
        }
        const text = decoder.decode(value, { stream: true })
        console.log(text)
      }
    })
  </script>
</body>

</html>

以上就是使用LangChain接入DeepSeek大模型并实现AI对话和基础提示词的案例。

启动 Taro 4 项目报错:Error: The specified module could not be found.

2026年1月10日 15:59

最近在 Windows 上 clone 了一个之前就写好的 Taro 4.x 小程序项目,本地用 VS Code 启动的时候直接报错:

Error: The specified module could not be found.
…/node_modules/@tarojs/plugin-doctor-win32-x64-msvc/
taro-doctor.win32-x64-msvc.nodez

最后在taro的github里面找到了一样的问题

原因

taro-doctor.win32-x64-msvc.node 是一个 Node 原生模块
在 Windows 上运行时,需要系统里有对应的 VC++ 运行库

如果系统里没有,或者版本不完整,Node 加载这个 .node 文件时就会直接报:

The specified module could not be found

即使文件本身是存在的。

解决方法

去微软官网下载并安装最新版的 Microsoft Visual C++ Redistributable

链接:learn.microsoft.com/zh-cn/cpp/w…

装完之后 重启了一次系统,再启动项目:

npm run dev

没有再报错,可以正常运行了。

docker+nginx部署

2026年1月10日 15:52

nginx.conf



worker_processes  1;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;
    sendfile        on;
    keepalive_timeout  65;
    server {
        listen       80;
        server_name  localhost;
        

        location / {
            root   /usr/share/nginx/html;
            index  index.html index.htm;
        }

        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
    }
}


Dockerfile


#引入node
FROM node as build
#拷贝当前文件夹到docker中的demo文件夹,demo文件夹自动生成
COPY . ./demo
#定位到demo文件夹
WORKDIR /demo
#设置源
RUN npm config set registry https://registry.npm.taobao.org
#下载
RUN npm install
#打包
RUN npm run build
#引入nginx
FROM nginx
#复制项目的nginx.conf到 /etc/nginx目录,并修改为文件名为nginx.conf
COPY nginx.conf /etc/nginx/nginx.conf
#复制生成的dist文件夹到nginx的html目录下
COPY --from=build /demo/dist /usr/share/nginx/html
#显式地标明镜像开放端口,一定程度上提供了操作的便利,也提高了 Dockerfile 的可读性和可维护性
EXPOSE 80
#运行nginx
CMD ["nginx","-g","daemon off;"] 


项目根目录运行命令,打包为镜像

docker image build -t vue_nginx_demo .

运行镜像

docker container run -p 8090:80 -d vue_nginx_demo

其他的自动化,使用jenkins或者k8s写部署脚本

前端模块化发展

作者 charmson
2026年1月10日 15:42

前端模块化

前言

┌─────────────────────────────────────────────────────────────────────────────┐
│                         知识体系递进关系                                      │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  第一章:为什么需要模块化?                                                  │
│     └── 问题背景、历史演进                                                   │
│            │                                                                │
│            ▼                                                                │
│  第二章:模块化规范有哪些?                                                  │
│     └── CommonJSAMD/UMDESM 语法与特性对比                                │
│            │                                                                │
│            ▼                                                                │
│  第三章:ESM 如何工作?(官方标准深入)                                       │
│     └── 三阶段加载、静态特性、实时绑定、循环依赖                              │
│            │                                                                │
│            ▼                                                                │
│  第四章:为什么需要构建工具?                                                │
│     └── ESM 的局限性、Webpack 的价值定位                                     │
│            │                                                                │
│            ▼                                                                │
│  第五章:Webpack 构建原理                                                   │
│     └── 构建流程、Module/Chunk/Bundle、模块包裹机制                          │
│            │                                                                │
│            ▼                                                                │
│  第六章:Webpack 运行时机制                                                 │
│     └── __webpack_require__、异步加载、JSONP 回调                            │
│            │                                                                │
│            ▼                                                                │
│  第七章:优化策略与最佳实践                                                  │
│     └── Tree Shaking、代码分割、模块输出格式                                 │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

第一章:模块化的历史演进与问题背景

本章解决的问题:为什么 JavaScript 需要模块化?模块化是如何一步步发展的?

1.1 无模块时代的痛点(1995-2009)

JavaScript 诞生之初并没有模块系统,所有代码共享全局作用域:

// a.js
var name = "moduleA";
function helper() { /*...*/ }

// b.js
var name = "moduleB";  // 💥 覆盖了 a.js 的 name!
function helper() { /*...*/ }  // 💥 覆盖了 a.js 的 helper!

// index.html - 必须手动管理加载顺序
<script src="a.js"></script>
<script src="b.js"></script>

核心问题

  1. 命名冲突:全局变量相互覆盖
  2. 依赖管理:手动维护 script 标签顺序
  3. 按需加载:无法实现,所有代码一次性加载

早期解决方案:IIFE + 命名空间

var MyApp = MyApp || {};
MyApp.moduleA = (function () {
  var privateVar = "private";
  return {
    publicMethod: function () { /*...*/ },
  };
})();

1.2 模块化演进时间线

┌─────────────────────────────────────────────────────────────────────────────┐
                     JavaScript 模块化演进史                                   
├─────────────────────────────────────────────────────────────────────────────┤
                                                                             
  1995        2009         2011         2015         2017          现在       |
                                                                       
                                                                       
  无模块    CommonJS     AMD/UMD   ES Modules   Node支持ESM  ESM成为主流   
                                                                       
  全局变量    Node.js      浏览器异步     语言标准       生态统一     构建工具      
  命名冲突   服务端模块      加载需求      静态分析       双格式并存    深度优化      
                                                                             
└─────────────────────────────────────────────────────────────────────────────┘
阶段 时间 规范 背景 特点
1 2009 CommonJS Node.js 诞生,服务端需要模块系统 同步加载,运行时解析
2 2011 AMD/UMD 浏览器需要异步加载(网络延迟) 异步加载,兼容多环境
3 2015 ES Modules JavaScript 语言层面的官方标准 静态分析,编译时确定
4 2017+ 生态统一 Node.js 12+ 原生支持 ESM ESM + CJS 双格式并存

第二章:模块化规范详解

本章解决的问题:三大模块规范(CommonJS、AMD/UMD、ESM)各有什么语法和特性?如何选择?

2.1 规范对比总览

特性 CommonJS AMD UMD ESM
设计目标 服务端 浏览器异步 通用兼容 语言标准
加载时机 运行时 运行时 运行时 编译时
加载方式 同步 异步 依环境 异步(可同步)
导出类型 值拷贝 值拷贝 值拷贝 引用绑定
静态分析
Tree Shaking

2.2 CommonJS 详解

语法

// 导出
module.exports = { a: 1, b: 2 };
// 或
exports.a = 1;
exports.b = 2;

// 导入
const lib = require('./lib');
const { a, b } = require('./lib');

运行时特性(动态)

// ✅ 条件导入
if (process.env.NODE_ENV === 'production') {
  module.exports = require('./prod.js');
} else {
  module.exports = require('./dev.js');
}

// ✅ 动态路径
const name = 'utils';
const utils = require(`./${name}.js`);

// ✅ 循环中导入
['a', 'b', 'c'].forEach(name => {
  modules[name] = require(`./${name}.js`);
});

值拷贝特性

// counter.js
let count = 0;
module.exports = {
  count,
  increment: () => count++,
};

// index.js
const counter = require('./counter');
console.log(counter.count);  // 0
counter.increment();
console.log(counter.count);  // 0 ← 还是 0!值拷贝!

2.3 UMD 详解(通用模块定义)

UMD 的目标是一份代码,多环境运行

(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
    // AMD 环境 (RequireJS)
    define(['dependency'], factory);
  } else if (typeof module === 'object' && module.exports) {
    // CommonJS 环境 (Node.js)
    module.exports = factory(require('dependency'));
  } else {
    // 浏览器全局变量
    root.MyLibrary = factory(root.Dependency);
  }
})(typeof self !== 'undefined' ? self : this, function (dependency) {
  return {
    doSomething: function () {
      console.log('Hello from UMD!');
    },
  };
});

环境判断流程

┌──────────────────────┐
│ typeof exports ===   │
│ "object" &&          │─── YES ──→ CommonJS2: module.exports = ...
│ typeof module ===    │
│ "object"             │
└──────────┬───────────┘
           │ NO
           ▼
┌──────────────────────┐
│ typeof define ===    │
│ "function" &&        │─── YES ──→ AMD: define([], factory)
│ define.amd           │
└──────────┬───────────┘
           │ NO
           ▼
┌──────────────────────┐
│ typeof exports ===   │─── YES ──→ CommonJS: exports["name"] = ...
│ "object"             │
└──────────┬───────────┘
           │ NO
           ▼
     浏览器全局变量: root["name"] = ...

2.4 ESM 详解(官方标准)

导出语法

// ─────────── 命名导出 ───────────
export const name = 'ESM';
export function greet() { /*...*/ }
export class Person { /*...*/ }

// 批量导出
const a = 1, b = 2;
export { a, b };

// 重命名导出
export { a as aliasA, b as aliasB };

// ─────────── 默认导出 ───────────
export default function() { /*...*/ }
export default class { /*...*/ }

// ─────────── 聚合导出(re-export)───────────
export { foo, bar } from './other.js';
export * from './utils.js';
export * as utils from './utils.js';  // 命名空间聚合

导入语法

// ─────────── 命名导入 ───────────
import { name, greet } from './module.js';
import { name as aliasName } from './module.js';

// ─────────── 默认导入 ───────────
import MyDefault from './module.js';

// ─────────── 混合导入 ───────────
import MyDefault, { name, greet } from './module.js';

// ─────────── 命名空间导入 ───────────
import * as Module from './module.js';

// ─────────── 副作用导入 ───────────
import './polyfill.js';  // 只执行,不导入任何值

// ─────────── 动态导入 ───────────
const module = await import('./module.js');

静态结构限制(与 CommonJS 的关键区别)

// ❌ ESM 不允许 - 动态导入路径
import { foo } from getModulePath();  // SyntaxError

// ❌ ESM 不允许 - 条件导入
if (condition) {
  import { bar } from './bar.js';  // SyntaxError
}

// ✅ CommonJS 允许 - 完全动态
const mod = require(getModulePath());
if (condition) {
  const bar = require('./bar.js');
}

第三章:ESM 工作原理深入

本章解决的问题:ESM 作为官方标准,它的加载机制是什么?静态特性和实时绑定是如何实现的?

3.1 ESM 三阶段加载过程

ESM 的加载过程分为三个完全独立的阶段:

┌─────────────────────────────────────────────────────────────────┐
│                        时机划分                                  │
├──────────────────┬──────────────────────────────────────────────┤
│                  │                                              │
│   编译时/加载时   │  ① 构建 (Construction) - 解析、发现依赖      │
│   (静态分析)     │  ② 实例化 (Instantiation) - 分配内存、连接绑定│
│                  │                                              │
├──────────────────┼──────────────────────────────────────────────┤
│                  │                                              │
│   运行时         │  ③ 求值 (Evaluation) - 执行代码、填充导出值   │
│   (代码执行)     │                                              │
│                  │                                              │
└──────────────────┴──────────────────────────────────────────────┘
3.1.1 构建阶段 (Construction)
┌─────────────────────────────────────────────────────────────┐
│                      构建阶段                                │
├─────────────────────────────────────────────────────────────┤
│  1. 解析模块说明符 (Module Specifier)                        │
│     './utils.js''file:///project/utils.js'              │
│                                                             │
│  2. 获取模块文件 (Fetch)                                     │
│     - 浏览器: HTTP 请求                                      │
│     - Node.js: 文件系统读取                                  │
│                                                             │
│  3. 解析为模块记录 (Module Record)                           │
│     - 静态分析 import/export 语句                            │
│     - 不执行任何代码                                         │
│     - 构建模块依赖图                                         │
└─────────────────────────────────────────────────────────────┘

关键点

  • 每个模块只会被解析一次,结果缓存在 Module Map
  • 所有依赖通过深度优先遍历被发现和加载
  • 此阶段完全是静态分析,不执行任何 JavaScript 代码
// 模块记录 (Module Record) 的简化结构
{
  [[RequestedModules]]: ['./dep1.js', './dep2.js'],  // 依赖列表
  [[ImportEntries]]: [...],   // 导入条目
  [[ExportEntries]]: [...],   // 导出条目
  [[Status]]: 'unlinked',     // 模块状态
  [[EvaluationError]]: null   // 执行错误
}
3.1.2 实例化阶段 (Instantiation / Linking)
┌─────────────────────────────────────────────────────────────┐
│                      实例化阶段                              │
├─────────────────────────────────────────────────────────────┤
│  1. 为每个模块分配内存空间 (Module Environment Record)        │
│                                                             │
│  2. 创建导出绑定 (Export Bindings)                           │
│     - 在内存中为所有 export 创建"槽位"                        │
│     - 此时槽位未初始化 (uninitialized)                       │
│                                                             │
│  3. 连接导入导出 (Linking)                                   │
│     - import 直接指向 export 的内存地址                      │
│     - 这就是"实时绑定" (Live Binding)                        │
└─────────────────────────────────────────────────────────────┘
3.1.3 求值阶段 (Evaluation)
┌─────────────────────────────────────────────────────────────┐
│                      求值阶段                                │
├─────────────────────────────────────────────────────────────┤
│  1. 按照深度优先、后序遍历的顺序执行模块代码                    │
│     (先执行依赖模块,再执行当前模块)                          │
│                                                             │
│  2. 填充导出槽位的实际值                                      │
│                                                             │
│  3. 每个模块只执行一次,结果被缓存                            │
└─────────────────────────────────────────────────────────────┘

执行顺序示例

// main.js
import { b } from './b.js';
import { a } from './a.js';
console.log('main');

// a.js
console.log('a');
export const a = 'A';

// b.js
import { a } from './a.js';
console.log('b');
export const b = 'B';

// 执行顺序: a → b → main
// 输出: 'a', 'b', 'main'

3.2 静态结构特性

静态分析:在代码执行前(编译阶段),仅通过分析代码文本结构,就能确定所有模块依赖关系。

┌─────────────────────────────────────────────────────────────────┐
│                     静态 vs 动态                                 │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  ESM 静态分析(编译时)                                          │
│  ─────────────────────                                           │
│  import { add } from './math.js';                                │
│       ↓                                                          │
│  编译器读取代码文本 → 看到 import 语句 → 知道依赖 math.js        │
│       ↓                                                          │
│  无需执行代码,100% 确定依赖关系                                 │
│                                                                  │
│  ─────────────────────────────────────────────────────────────   │
│                                                                  │
│  CommonJS 动态分析(运行时)                                     │
│  ─────────────────────────                                       │
│  const math = require(getPath());                                │
│       ↓                                                          │
│  必须执行 getPath() 才知道路径是什么                             │
│       ↓                                                          │
│  编译时无法确定依赖!                                            │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

静态结构的优势

┌─────────────────────────────────────────────────────────────┐
│                   静态结构的优势                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. Tree Shaking (摇树优化)                                  │
│     ├── 在构建时分析哪些导出被使用                           │
│     └── 移除未使用的代码 (Dead Code Elimination)             │
│                                                             │
│  2. 更快的查找                                               │
│     ├── 变量查找在编译时确定                                 │
│     └── 无需运行时的动态查找                                 │
│                                                             │
│  3. 类型检查                                                 │
│     ├── TypeScript 可在编译时验证导入                        │
│     └── IDE 提供准确的自动补全                               │
│                                                             │
│  4. 循环依赖处理更可靠                                       │
│     └── 静态分析可以提前发现问题                             │
│                                                             │
└─────────────────────────────────────────────────────────────┘

3.3 实时绑定 (Live Binding)

ESM 最重要的特性之一,与 CommonJS 的值拷贝形成鲜明对比:

// ============ ESM: 实时绑定 ============
// counter.mjs
export let count = 0;
export function increment() { count++; }

// main.mjs
import { count, increment } from './counter.mjs';
console.log(count);  // 0
increment();
console.log(count);  // 1 ← 看到了变化!

// ============ CommonJS: 值拷贝 ============
// counter.cjs
let count = 0;
module.exports = {
  count,
  increment() { count++; }
};

// main.cjs
const { count, increment } = require('./counter.cjs');
console.log(count);  // 0
increment();
console.log(count);  // 0 ← 还是 0!(拷贝的值)

绑定原理图解

┌────────────────── ESM 实时绑定 ──────────────────┐
                                                  
   导出模块内存                 导入模块          
   ┌──────────────┐           ┌──────────────┐   
    count: [ref]─┼───────────┼─► count         
                                            
                                            
         ┌───┐                               
          0      同一内存                   
         └───┘                               
   └──────────────┘           └──────────────┘   
                                                  
└──────────────────────────────────────────────────┘

┌────────────────── CJS 值拷贝 ───────────────────┐
                                                  
   导出模块                     导入模块          
   ┌──────────────┐           ┌──────────────┐   
    count: ───┐              count: ───┐     
                  拷贝操作                  
                  ═══════►                 
           ┌───┐                    ┌───┐    
            0                      0     
           └───┘                    └───┘    
   └──────────────┘           └──────────────┘   
                                                  
      独立内存                    独立内存        
└──────────────────────────────────────────────────┘

导入是只读的

// module.js
export let value = 1;

// main.js
import { value } from './module.js';
value = 2;  // TypeError: Assignment to constant variable
// 导入的绑定是只读的!

// 但可以修改导入对象的属性
import { obj } from './module.js';
obj.prop = 'new';  // ✅ 这是允许的

3.4 循环依赖处理

使用 const/let 声明 —— 抛出 ReferenceError
// a.mjs
console.log('a.mjs 开始执行');
import { b } from './b.mjs';
console.log('在 a.mjs 中, b =', b);
export const a = 'a 的值';

// b.mjs
console.log('b.mjs 开始执行');
import { a } from './a.mjs';
console.log('在 b.mjs 中, a =', a);  // ❌ ReferenceError!
export const b = 'b 的值';

// 执行 node a.mjs
// 输出:
// b.mjs 开始执行
// ReferenceError: Cannot access 'a' before initialization
//
// 原因:const/let 存在暂时性死区 (TDZ),在初始化前访问会报错
使用 var 声明 —— 得到 undefined
// a.mjs
import { b } from './b.mjs';
export var a = 'a 的值';  // 使用 var

// b.mjs
import { a } from './a.mjs';
console.log('在 b.mjs 中, a =', a);  // undefined (var 会提升)
export var b = 'b 的值';
使用函数声明 —— 正常工作
// a.mjs
import { b } from './b.mjs';
console.log('在 a.mjs 中, b() =', b());
export function a() { return 'a 的值'; }  // 函数声明会提升

// b.mjs
import { a } from './a.mjs';
console.log('在 b.mjs 中, a() =', a());  // ✅ 正常工作!
export function b() { return 'b 的值'; }

循环依赖执行流程

执行 a.mjs:
    │
    ▼
┌─────────────────────────────────────────────────────────────┐
│ 1. 解析 a.mjs,发现依赖 b.mjs                                │
│ 2. 解析 b.mjs,发现依赖 a.mjs (循环!)                       │
│ 3. a.mjs 已在处理中,跳过 (使用未初始化的绑定)                │
│ 4. 执行 b.mjs,此时 a 的导出槽位:                           │
│    - const/let: 处于 TDZ → 访问抛出 ReferenceError          │
│    - var: 已提升但未赋值 → undefined                        │
│    - function: 完整提升 → 可正常调用                        │
│ 5. b.mjs 执行完毕(如果没有报错),b 的导出槽位被填充        │
│ 6. 回到 a.mjs 继续执行                                       │
│ 7. a.mjs 执行完毕,a 的导出槽位被填充                        │
└─────────────────────────────────────────────────────────────┘

避免循环依赖问题的方法

// ✅ 方法1: 使用函数延迟访问
// a.mjs
import { getB } from './b.mjs';
export const a = 'a';
export function getA() { return a; }
console.log(getB());  // 在函数调用时,b 已初始化

// b.mjs
import { getA } from './a.mjs';
export const b = 'b';
export function getB() { return b; }

// ✅ 方法2: 将共享状态提取到第三个模块
// shared.mjs
export const shared = { a: null, b: null };

// a.mjs
import { shared } from './shared.mjs';
shared.a = 'a';

// b.mjs
import { shared } from './shared.mjs';
shared.b = 'b';

3.5 ESM 在不同环境中的实现

浏览器中的 ESM
<!-- 使用 type="module" -->
<script type="module">
  import { func } from './module.js';
  func();
</script>

<!-- 外部模块 -->
<script type="module" src="./main.js"></script>

<!-- 模块特性 -->
<script type="module">
  // 1. 默认 defer - 不阻塞 HTML 解析
  // 2. 默认 strict mode
  // 3. 顶层 this 是 undefined
  // 4. 支持顶层 await (ES2022)
  // 5. 同源策略 - 跨域需要 CORS
</script>

<!-- 兼容降级 -->
<script type="module" src="modern.js"></script>
<script nomodule src="legacy.js"></script>
Node.js 中的 ESM
// 方式1: 使用 .mjs 扩展名
// utils.mjs
export function helper() {}

// 方式2: package.json 中设置 "type": "module"
// package.json
{
  "type": "module"  // 所有 .js 文件视为 ESM
}

// 方式3: .cjs 扩展名强制 CommonJS
// legacy.cjs - 即使 type: module,也是 CommonJS

// Node.js 特有的导入方式
import fs from 'node:fs';
import { readFile } from 'node:fs/promises';

// import.meta 对象
console.log(import.meta.url);       // file:///path/to/module.mjs
console.log(import.meta.dirname);   // /path/to (Node 20.11+)
console.log(import.meta.filename);  // /path/to/module.mjs

第四章:为什么需要构建工具?

本章解决的问题:ESM 已经是官方标准了,为什么还需要 Webpack 这样的构建工具?

4.1 前端工程化的痛点

虽然 ESM 解决了模块化的语法标准问题,但前端开发还面临更多挑战:

┌─────────────────────────────────────────────────────────────────────────┐
│                        前端模块化的痛点                                   │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  1. 模块规范混乱        2. 浏览器兼容性       3. 资源类型多样            │
│  ┌─────────────┐       ┌─────────────┐       ┌─────────────┐           │
│  │ CommonJS    │       │ ES Module   │       │ .js .css    │           │
│  │ AMD / UMD   │       │ 浏览器支持  │       │ .png .svg   │           │
│  │ ES Module   │       │ 有限        │       │ .json .wasm │           │
│  └─────────────┘       └─────────────┘       └─────────────┘           │
│                                                                         │
│  4. 开发效率            5. 性能优化                                      │
│  ┌─────────────┐       ┌─────────────┐                                  │
│  │ 热更新      │       │ 代码分割    │                                  │
│  │ Source Map  │       │ Tree Shaking│                                  │
│  └─────────────┘       │ 压缩混淆    │                                  │
│                        └─────────────┘                                  │
│                                                                         │
│                              ↓                                          │
│                     ┌─────────────────┐                                 │
│                     │    Webpack      │                                 │
│                     │  统一解决方案    │                                 │
│                     └─────────────────┘                                 │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

4.2 Webpack 的价值定位

Webpack 本质:一个静态模块打包器,以 entry 为起点,递归构建依赖图,将所有模块打包成浏览器可运行的 Bundle。

问题 ESM 原生能力 Webpack 解决方案
模块规范混乱 只支持 ESM 统一处理 CJS/AMD/ESM
浏览器兼容 需要现代浏览器 转译为兼容代码
非 JS 资源 不支持 Loader 处理任意类型
性能优化 Tree Shaking、代码分割
开发体验 HMR、Source Map

4.3 构建工具链全景

┌─────────────────────────────────────────────────────────────────────────────┐
│                         现代构建工具处理流程                                 │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  源代码 (ES6+ / ESM)                                                         │
│        │                                                                     │
│        ▼                                                                     │
│  ┌─────────────────────────────────────────────────────────────────────┐    │
│  │                         Babel-loader                                 │    │
│  │  ─────────────────────────────────────────────────────────────────  │    │
│  │  • 转换 ES6+ 语法(async/await, class, 箭头函数等)                 │    │
│  │  • 保留 import/export 语法(modules: false)← 关键!                │    │
│  │  • 转换 JSXTypeScript 等                                          │    │
│  └─────────────────────────────────────────────────────────────────────┘    │
│        │                                                                     │
│        ▼                                                                     │
│  ┌─────────────────────────────────────────────────────────────────────┐    │
│  │                           Webpack                                    │    │
│  │  ─────────────────────────────────────────────────────────────────  │    │
│  │  1. 解析入口文件,构建依赖图                                        │    │
│  │  2.import/export 转换为 __webpack_require__                     │    │
│  │  3. 标记未使用的导出(usedExports)                                 │    │
│  │  4. 代码分割(动态 import → 单独 chunk)                            │    │
│  └─────────────────────────────────────────────────────────────────────┘    │
│        │                                                                     │
│        ▼                                                                     │
│  ┌─────────────────────────────────────────────────────────────────────┐    │
│  │                           Terser                                     │    │
│  │  ─────────────────────────────────────────────────────────────────  │    │
│  │  • 删除标记为未使用的代码(Tree Shaking 完成)                      │    │
│  │  • 压缩变量名、移除空白、内联简单函数                               │    │
│  │  • 删除 console.log(可配置)                                       │    │
│  └─────────────────────────────────────────────────────────────────────┘    │
│        │                                                                     │
│        ▼                                                                     │
│  最终 Bundle(优化后的 ES5 代码)                                            │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

第五章:Webpack 构建原理

本章解决的问题:Webpack 内部是如何工作的?构建流程是怎样的?

5.1 三大核心角色

角色 职责 类比
Compiler 编译器,全局单例,贯穿整个生命周期 总指挥
Compilation 单次编译过程,包含模块、依赖、Chunk 等 一次构建任务
Module 文件的抽象,每个源文件对应一个 Module 构建的最小单位

5.2 核心概念:Module、Chunk、Bundle

概念 定义 生命阶段
Module 源文件的抽象,Webpack 处理的最小单位 Make 阶段
Chunk 多个 Module 的集合,打包的中间态 Seal 阶段
Bundle Chunk 经过处理后输出的最终文件 Emit 阶段
┌─────────────────────────────────────────────────────────────────────────┐
│                       三者关系:一对多                                    │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│   Module (源文件)              Chunk (中间态)             Bundle (产物) │
│   ┌─────────┐                                                           │
│   │  a.js   │─┐                                                         │
│   └─────────┘ │              ┌─────────────────┐                        │
│   ┌─────────┐ ├─────────────→│   main chunk    │────────→  main.js     │
│   │  b.js   │─┤              └─────────────────┘                        │
│   └─────────┘ │                                                         │
│   ┌─────────┐─┘                                                         │
│   │  c.css  │                                                           │
│   └─────────┘                                                           │
│                                                                         │
│   ┌─────────┐                ┌─────────────────┐                        │
│   │ lodash  │───────────────→│  vendor chunk   │────────→  vendor.js   │
│   └─────────┘                └─────────────────┘                        │
│                                                                         │
│   ┌─────────┐                ┌─────────────────┐                        │
│   │ lazy.js │───────────────→│   async chunk   │────────→  lazy.js     │
│   └─────────┘                └─────────────────┘                        │
│                                                                         │
│   关系:N Module → 1 Chunk → 1 Bundle                                   │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

Chunk 的三种产生方式

产生方式 触发条件 示例
Entry Chunk 每个 entry 配置 entry: { main: './src/index.js' }
Async Chunk 动态导入 import() import('./lazy.js')
Split Chunk SplitChunks 配置提取公共模块 splitChunks: { chunks: 'all' }

5.3 完整构建流程

┌─────────────────────────────────────────────────────────────────────────────┐
│                          Webpack 构建全流程                                   │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  ┌──────────┐    ┌──────────┐    ┌──────────┐    ┌──────────┐    ┌───────┐ │
│  │Initialize│───→│   Make   │───→│   Seal   │───→│ Optimize │───→│ Emit  │ │
│  │  初始化   │    │  构建    │    │   封装   │    │   优化   │    │ 输出  │ │
│  └──────────┘    └──────────┘    └──────────┘    └──────────┘    └───────┘ │
│       │               │               │               │              │      │
│       ▼               ▼               ▼               ▼              ▼      │
│  ┌──────────┐    ┌──────────┐    ┌──────────┐    ┌──────────┐    ┌───────┐ │
│  │合并配置   │    │从Entry   │    │形成Chunk │    │SplitChunks│   │写入   │ │
│  │创建Compiler│   │递归解析  │    │建立映射  │    │TreeShaking│   │文件   │ │
│  │注册Plugin │    │执行Loader│    │生成代码  │    │压缩混淆  │    │系统   │ │
│  └──────────┘    └──────────┘    └──────────┘    └──────────┘    └───────┘ │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
5.3.1 Initialize 阶段
// 核心任务
// 1. 合并配置(命令行 + 配置文件 + 默认值)
const options = merge(defaultConfig, userConfig, cliConfig);

// 2. 创建 Compiler 实例(全局单例)
const compiler = new Compiler(options);

// 3. 注册所有插件
for (const plugin of options.plugins) {
  plugin.apply(compiler);  // 插件通过 apply 方法注入 hooks
}

// 4. 触发 environment 钩子
compiler.hooks.environment.call();
compiler.hooks.afterEnvironment.call();
5.3.2 Make 阶段(核心:构建 ModuleGraph)
┌─────────────────────────────────────────────────────────────────────────┐
│                           Make 阶段详解                                   │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│   Entry Point                      递归处理流程                          │
│   ┌─────────┐                                                           │
│   │index.js │                                                           │
│   └────┬────┘                                                           │
│        │                                                                │
│        ▼                                                                │
│   ┌─────────────────────────────────────────────────────────────────┐  │
│   │  Step 1: 创建 Module                                             │  │
│   │  const module = new NormalModule({                               │  │
│   │    request: './src/index.js',                                    │  │
│   │    type: 'javascript/auto',                                      │  │
│   │    loaders: [babel-loader, ...]                                  │  │
│   │  });                                                             │  │
│   └─────────────────────────────────────────────────────────────────┘  │
│        │                                                                │
│        ▼                                                                │
│   ┌─────────────────────────────────────────────────────────────────┐  │
│   │  Step 2: Loader 转换                                             │  │
│   │  source = runLoaders(loaders, originalSource);                   │  │
│   │                                                                  │  │
│   │  // Loader 链式调用(从右到左)                                   │  │
│   │  // sass-loader → css-loader → style-loader                      │  │
│   │  // ts-loader → babel-loader                                     │  │
│   └─────────────────────────────────────────────────────────────────┘  │
│        │                                                                │
│        ▼                                                                │
│   ┌─────────────────────────────────────────────────────────────────┐  │
│   │  Step 3: AST 解析,提取依赖                                       │  │
│   │  const ast = parse(source);                                      │  │
│   │                                                                  │  │
│   │  // 识别 import/require 语句                                      │  │
│   │  import utils from './utils';  → HarmonyImportDependency         │  │
│   │  require('./config');          → CommonJsDependency              │  │
│   │  import('./lazy');             → ImportDependency (async)        │  │
│   └─────────────────────────────────────────────────────────────────┘  │
│        │                                                                │
│        ▼                                                                │
│   ┌─────────────────────────────────────────────────────────────────┐  │
│   │  Step 4: 递归处理依赖                                            │  │
│   │  for (const dep of module.dependencies) {                        │  │
│   │    this.handleModuleCreation(dep);  // 递归回到 Step 1           │  │
│   │  }                                                               │  │
│   └─────────────────────────────────────────────────────────────────┘  │
│        │                                                                │
│        ▼                                                                │
│   最终产物:ModuleGraph(依赖关系图)                                    │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘
5.3.3 Seal 阶段(核心:生成 Chunk)

这是 SplitChunks 生效的阶段

┌─────────────────────────────────────────────────────────────────────────┐
                           Seal 阶段详解                                   
├─────────────────────────────────────────────────────────────────────────┤
                                                                         
   Step 1: 形成初始 Chunk                                                
   ┌─────────────────────────────────────────────────────────────────┐  
     Entry A ──→ Initial Chunk A                                      
                 ├── a.js                                             
                 ├── utils.js (被 A、B 都引用)                         
                 └── lodash   (被 A、B 都引用)                         
                                                                      
     Entry B ──→ Initial Chunk B                                      
                 ├── b.js                                             
                 ├── utils.js (重复!)                                 
                 └── lodash   (重复!)                                 
                                                                      
     import() ──→ Async Chunk                                         
                 └── lazy.js                                          
   └─────────────────────────────────────────────────────────────────┘  
                                                                        
                                                                        
   Step 2: SplitChunks 优化(optimizeChunks 钩子)                        
   ┌─────────────────────────────────────────────────────────────────┐  
     遍历所有 Module,检查:                                           
     ┌───────────────────────────────────────────────────────────┐   
       lodash:                                                     
         - 被引用次数: 2 (Chunk A, B)     minChunks: 1          
         - 模块大小: 70KB                 minSize: 20KB         
         - 来源: node_modules            匹配 test 正则          
          提取到 vendors chunk                                    
     └───────────────────────────────────────────────────────────┘   
     ┌───────────────────────────────────────────────────────────┐   
       utils.js:                                                   
         - 被引用次数: 2 (Chunk A, B)     minChunks: 2          
         - 模块大小: 5KB                 < minSize: 20KB         
          不提取,保留在原 chunk                                  
     └───────────────────────────────────────────────────────────┘   
   └─────────────────────────────────────────────────────────────────┘  
                                                                        
                                                                        
   Step 3: 代码生成                                                       
   ┌─────────────────────────────────────────────────────────────────┐  
     最终 Chunk 结构:                                                 
     Chunk A:  a.js + utils.js                                        
     Chunk B:  b.js + utils.js                                        
     Chunk vendors: lodash                                            
     Chunk async: lazy.js                                             
                                                                      
     为每个 Chunk 生成代码:                                           
     - 包裹 Module 为函数                                             
     - 注入 Runtime 代码                                              
     - 建立 Chunk 间依赖关系                                          
   └─────────────────────────────────────────────────────────────────┘  
                                                                         
└─────────────────────────────────────────────────────────────────────────┘

5.4 Module 包裹机制

为什么要包裹成函数?
┌─────────────────────────────────────────────────────────────────────────┐
│                       Module 包裹的三大目的                               │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  1. 创建独立作用域                                                       │
│     ┌───────────────────────────────────────────────────────────────┐  │
│     │  每个模块的变量不会污染全局                                     │  │
│     │  var name = 'a.js'var name = 'b.js' 不会冲突               │  │
│     └───────────────────────────────────────────────────────────────┘  │
│                                                                         │
│  2. 注入模块系统                                                         │
│     ┌───────────────────────────────────────────────────────────────┐  │
│     │  module        → 当前模块对象                                   │  │
│     │  exports       → 导出对象 (module.exports 的引用)               │  │
│     │  __webpack_require__ → 引入其他模块的函数                       │  │
│     └───────────────────────────────────────────────────────────────┘  │
│                                                                         │
│  3. 按需执行 + 缓存                                                      │
│     ┌───────────────────────────────────────────────────────────────┐  │
│     │  函数只有被 require 时才执行(惰性加载)                        │  │
│     │  执行一次后结果被缓存到 installedModules(单例模式)            │  │
│     └───────────────────────────────────────────────────────────────┘  │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘
包裹前后对比

源代码

// src/utils.js
export const add = (a, b) => a + b;
export const name = 'utils';

编译后(ES Module)

"./src/utils.js": function(module, __webpack_exports__, __webpack_require__) {
  "use strict";
  // 标记为 ES Module(用于和 CommonJS 区分)
  __webpack_require__.r(__webpack_exports__);

  // 定义导出(使用 getter,实现 live binding)
  __webpack_require__.d(__webpack_exports__, {
    "add": function() { return add; },
    "name": function() { return name; }
  });

  // 原始代码
  const add = (a, b) => a + b;
  const name = 'utils';
}

5.5 最终 Bundle 结构

// bundle.js
(function(modules) {
  // ========== Runtime 代码 ==========

  // 模块缓存
  var installedModules = {};

  // 核心加载函数
  function __webpack_require__(moduleId) {
    // 检查缓存
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }

    // 创建新模块
    var module = installedModules[moduleId] = {
      i: moduleId,
      l: false,
      exports: {}
    };

    // 执行模块函数
    modules[moduleId].call(
      module.exports,
      module,
      module.exports,
      __webpack_require__
    );

    module.l = true;
    return module.exports;
  }

  // 工具函数
  __webpack_require__.r = function(exports) {
    Object.defineProperty(exports, '__esModule', { value: true });
  };

  __webpack_require__.d = function(exports, definition) {
    for (var key in definition) {
      Object.defineProperty(exports, key, {
        enumerable: true,
        get: definition[key]  // getter 实现 live binding
      });
    }
  };

  // ========== 启动入口 ==========
  return __webpack_require__('./src/index.js');

})({
  // ========== 所有 Module(包裹后)==========
  "./src/index.js": function(module, exports, __webpack_require__) {
    var utils = __webpack_require__("./src/utils.js");
    console.log(utils.add(1, 2));
  },
  "./src/utils.js": function(module, __webpack_exports__, __webpack_require__) {
    __webpack_require__.r(__webpack_exports__);
    __webpack_require__.d(__webpack_exports__, {
      "add": function() { return add; }
    });
    const add = (a, b) => a + b;
  }
});

第六章:Webpack 运行时机制

本章解决的问题:Webpack 打包后的代码在浏览器中是如何运行的?同步和异步加载是如何实现的?

6.1 同步加载:__webpack_require__

┌─────────────────────────────────────────────────────────────────────────┐
│                    __webpack_require__(moduleId) 流程                    │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│                         require('./utils.js')                           │
│                                   │                                     │
│                                   ▼                                     │
│                    ┌──────────────────────────────┐                     │
│                    │   检查 installedModules      │                     │
│                    │   是否有缓存?                │                     │
│                    └──────────────────────────────┘                     │
│                           │                                             │
│              ┌────────────┴────────────┐                                │
│              │ YES                     │ NO                             │
│              ▼                         ▼                                │
│      ┌─────────────┐         ┌─────────────────────┐                   │
│      │ 直接返回     │         │ 创建 module 对象     │                   │
│      │ 缓存的       │         │ {                   │                   │
│      │ exports     │         │   i: moduleId,      │                   │
│      └─────────────┘         │   l: false,         │                   │
│                              │   exports: {}       │                   │
│                              │ }                   │                   │
│                              └─────────────────────┘                   │
│                                        │                                │
│                                        ▼                                │
│                              ┌─────────────────────┐                   │
│                              │ 放入缓存             │                   │
│                              │ installedModules    │                   │
│                              │ [moduleId] = module │                   │
│                              └─────────────────────┘                   │
│                                        │                                │
│                                        ▼                                │
│                              ┌─────────────────────┐                   │
│                              │ 执行模块函数         │                   │
│                              │ modules[moduleId]   │                   │
│                              │ .call(...)          │                   │
│                              └─────────────────────┘                   │
│                                        │                                │
│                                        ▼                                │
│                              ┌─────────────────────┐                   │
│                              │ module.l = true     │                   │
│                              │ 返回 module.exports │                   │
│                              └─────────────────────┘                   │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

6.2 异步加载:__webpack_require__.e

动态 import() 会被转换为 __webpack_require__.e

// 源代码
import('./lazy').then(module => {
  module.doSomething();
});

// 编译后
__webpack_require__.e(/* chunkId */ 'lazy')
  .then(__webpack_require__.bind(null, './src/lazy.js'))
  .then(module => {
    module.doSomething();
  });

__webpack_require__.e 实现原理

// Chunk 加载状态
// undefined: 未加载
// [resolve, reject]: 正在加载
// 0: 已加载
var installedChunks = {};

__webpack_require__.e = function(chunkId) {
  var promises = [];

  var installedChunkData = installedChunks[chunkId];

  // 0 表示已加载
  if (installedChunkData !== 0) {
    // 正在加载中,复用 Promise
    if (installedChunkData) {
      promises.push(installedChunkData[2]);
    } else {
      // 创建新的 Promise
      var promise = new Promise(function(resolve, reject) {
        installedChunkData = installedChunks[chunkId] = [resolve, reject];
      });
      promises.push(installedChunkData[2] = promise);

      // 创建 script 标签
      var script = document.createElement('script');
      script.charset = 'utf-8';
      script.timeout = 120;
      script.src = __webpack_require__.p + chunkId + '.bundle.js';

      document.head.appendChild(script);
    }
  }

  return Promise.all(promises);
};

6.3 异步 Chunk 的注册(JSONP)

// lazy.chunk.js(异步 chunk 文件格式)
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([
  ["lazy"],  // chunkIds
  {          // modules
    "./src/lazy.js": function(module, exports) {
      exports.doSomething = function() {
        console.log("lazy loaded!");
      };
    }
  }
]);

主 bundle 中的 JSONP 回调注册

// 注册 JSONP 回调
var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);

jsonpArray.push = webpackJsonpCallback;

function webpackJsonpCallback(data) {
  var chunkIds = data[0];
  var moreModules = data[1];

  // 将新模块添加到 modules 对象
  for (moduleId in moreModules) {
    modules[moduleId] = moreModules[moduleId];
  }

  // 标记 chunk 已加载,执行 resolve
  for (var i = 0; i < chunkIds.length; i++) {
    var chunkId = chunkIds[i];
    if (installedChunks[chunkId]) {
      installedChunks[chunkId][0]();  // resolve
    }
    installedChunks[chunkId] = 0;     // 标记已加载
  }
}

6.4 运行时流程总览

┌─────────────────────────────────────────────────────────────────────────┐
│                          运行时完整流程                                   │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  页面加载                                                                │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │ <script src="main.js"></script>                                 │   │
│  │ <script src="vendor.js"></script>                               │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                              │                                          │
│                              ▼                                          │
│  初始化 Runtime                                                          │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │ • 创建 installedModules 缓存                                     │   │
│  │ • 创建 installedChunks 缓存                                      │   │
│  │ • 定义 __webpack_require__ 函数                                  │   │
│  │ • 注册 JSONP 回调                                                │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                              │                                          │
│                              ▼                                          │
│  执行入口模块                                                            │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │ __webpack_require__("./src/index.js")                           │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                              │                                          │
│               ┌──────────────┴──────────────┐                           │
│               │                             │                           │
│               ▼                             ▼                           │
│  同步依赖                              异步依赖                          │
│  ┌────────────────────┐            ┌────────────────────┐              │
│  │__webpack_require__ │            │__webpack_require__.e│              │
│  │ 从 modules 取      │            │ 创建 script 标签   │              │
│  │ 执行 + 缓存        │            │ 加载 chunk 文件    │              │
│  └────────────────────┘            └────────────────────┘              │
│                                             │                           │
│                                             ▼                           │
│                                    ┌────────────────────┐              │
│                                    │ JSONP 回调         │              │
│                                    │ 注册新 modules     │              │
│                                    │ resolve Promise    │              │
│                                    └────────────────────┘              │
│                                             │                           │
│                                             ▼                           │
│                                    ┌────────────────────┐              │
│                                    │__webpack_require__ │              │
│                                    │ 执行异步模块       │              │
│                                    └────────────────────┘              │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

第七章:优化策略与最佳实践

本章解决的问题:如何利用 ESM 的静态特性和 Webpack 的优化能力,实现最佳的构建效果?

7.1 Tree Shaking 原理

Tree Shaking:移除 JavaScript 中未使用的代码(Dead Code Elimination)

┌─────────────────────────────────────────────────────────────────┐
│                Tree Shaking 工作原理                             │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  源代码(math.js)                                               │
│  ─────────────                                                   │
│  export const add = (a, b) => a + b;      // 被使用              │export const sub = (a, b) => a - b;      // 未使用              │export const mul = (a, b) => a * b;      // 未使用              │
│                                                                  │
│  使用方(index.js)                                              │
│  ─────────────                                                   │
│  import { add } from './math.js';                                │
│  console.log(add(1, 2));                                         │
│                                                                  │
│         │                                                        │
│         ▼                                                        │
│                                                                  │
│  ┌─────────────────────────────────────────────────────────┐    │
│  │  阶段 1: Webpack 标记(Mark Phase)                      │    │
│  │  ─────────────────────────────────                       │    │
│  │  分析 import 语句 → 标记 add 为"已使用"                  │    │
│  │  sub, mul 没有被任何 import → 标记为"未使用"             │    │
│  │                                                          │    │
│  │  生成代码(带标记):                                    │    │
│  │  __webpack_require__.d(exports, { add: () => add });     │    │
│  │  /* unused harmony export sub */                         │    │
│  │  /* unused harmony export mul */                         │    │
│  └─────────────────────────────────────────────────────────┘    │
│                                                                  │
│         │                                                        │
│         ▼                                                        │
│                                                                  │
│  ┌─────────────────────────────────────────────────────────┐    │
│  │  阶段 2: Terser 删除(Sweep Phase)                      │    │
│  │  ─────────────────────────────────                       │    │
│  │  识别 unused 标记 → 这些变量没有被引用                   │    │
│  │  安全删除 sub 和 mul 的定义                              │    │
│  └─────────────────────────────────────────────────────────┘    │
│                                                                  │
│         │                                                        │
│         ▼                                                        │
│                                                                  │
│  最终 Bundle                                                     │
│  ──────────                                                      │
│  const add = (a, b) => a + b;  // 只保留 add!                   │console.log(add(1, 2));                                         │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Tree Shaking 生效条件

条件 说明 原因
使用 ESM import/export 语法 静态分析的前提
production 模式 usedExports: true 启用标记功能
声明无副作用 sideEffects: false 告诉打包工具可安全删除
启用压缩 Terser/UglifyJS 实际删除代码
避免整体导入 { a } 不用 * as 明确使用范围

7.2 sideEffects 配置

什么是副作用?

// ─────────── 有副作用 ───────────
// polyfill.js - 修改全局对象
Array.prototype.myMethod = function() {};

// analytics.js - 执行时发送请求
fetch('/api/track?page=home');

// styles.css - 影响页面样式
.button { color: red; }

// ─────────── 无副作用 ───────────
// utils.js - 纯函数,不影响外部
export const add = (a, b) => a + b;
export const format = (str) => str.trim();

package.json 配置

{
  "name": "my-library",
  "sideEffects": false
}
{
  "name": "my-library",
  "sideEffects": [
    "*.css",
    "*.scss",
    "./src/polyfill.js"
  ]
}

7.3 代码分割策略(SplitChunks)

默认配置详解

optimization: {
  splitChunks: {
    // 对哪些 chunk 生效
    // 'async': 只处理异步 chunk(默认)
    // 'initial': 只处理入口 chunk
    // 'all': 处理所有 chunk(推荐)
    chunks: 'async',

    // 分割阈值
    minSize: 20000,           // 最小 20KB 才分割
    minRemainingSize: 0,      // 分割后剩余最小体积
    minChunks: 1,             // 最少被引用 1 次

    // 并行请求限制
    maxAsyncRequests: 30,     // 按需加载时最大并行请求数
    maxInitialRequests: 30,   // 入口点最大并行请求数

    // 缓存组(核心配置)
    cacheGroups: {
      // 第三方库
      defaultVendors: {
        test: /[\\/]node_modules[\\/]/,
        priority: -10,
        reuseExistingChunk: true,
      },
      // 公共模块
      default: {
        minChunks: 2,
        priority: -20,
        reuseExistingChunk: true,
      },
    },
  },
}

推荐配置

optimization: {
  splitChunks: {
    chunks: 'all',
    cacheGroups: {
      // React 全家桶单独打包
      react: {
        test: /[\\/]node_modules[\\/](react|react-dom|react-router)[\\/]/,
        name: 'react-vendor',
        priority: 20,
        chunks: 'all',
      },
      // 其他第三方库
      vendors: {
        test: /[\\/]node_modules[\\/]/,
        name: 'vendors',
        priority: 10,
        chunks: 'all',
      },
      // 业务公共模块
      common: {
        name: 'common',
        minChunks: 2,
        priority: 5,
        reuseExistingChunk: true,
      },
    },
  },
  // 运行时代码单独分离(利于缓存)
  runtimeChunk: {
    name: 'runtime',
  },
}

7.4 模块输出格式

格式 导出语法 使用场景 Tree Shaking
var var MyLib = ... 全局变量
commonjs exports.MyLib = ... Node.js
commonjs2 module.exports = ... Node.js
amd define([], factory) RequireJS
umd 通用模块定义 多环境兼容
module export { ... } ES Module

多格式输出配置

// webpack.config.js - 同时输出多种格式
module.exports = [
  // ESM 版本(现代环境,支持 Tree Shaking)
  {
    entry: './src/index.js',
    experiments: { outputModule: true },
    output: {
      filename: 'my-library.esm.js',
      library: { type: 'module' },
    },
  },
  // CommonJS 版本(Node.js)
  {
    entry: './src/index.js',
    output: {
      filename: 'my-library.cjs.js',
      library: { type: 'commonjs2' },
    },
  },
  // UMD 版本(浏览器直接引用)
  {
    entry: './src/index.js',
    output: {
      filename: 'my-library.umd.js',
      library: { name: 'MyLibrary', type: 'umd' },
      globalObject: 'this',
    },
  },
];

对应 package.json

{
  "name": "my-library",
  "main": "./dist/my-library.cjs.js",
  "module": "./dist/my-library.esm.js",
  "browser": "./dist/my-library.umd.js",
  "exports": {
    ".": {
      "import": "./dist/my-library.esm.js",
      "require": "./dist/my-library.cjs.js"
    }
  },
  "sideEffects": false
}

7.5 最佳实践汇总

模块规范选择
┌────────────────────────────────────────────────────────────────┐
│                     模块规范选择指南                            │
├────────────────────────────────────────────────────────────────┤
│                                                                 │
│   ┌─────────────────┐                                          │
│   │ 新项目?        │                                          │
│   └────────┬────────┘                                          │
│            │                                                    │
│      是 ←──┴──→ 否                                              │
│      │          │                                               │
│      ▼          ▼                                               │
│   ┌──────┐   ┌─────────────────┐                               │
│   │ ESM  │   │ 需要兼容老环境? │                               │
│   └──────┘   └────────┬────────┘                               │
│                       │                                         │
│                 是 ←──┴──→ 否                                   │
│                 │          │                                    │
│                 ▼          ▼                                    │
│              ┌─────┐    ┌──────┐                                │
│              │ UMD │    │ ESM  │                                │
│              └─────┘    └──────┘                                │
│                                                                 │
│   ─────────────────────────────────────────────────────────    │
│                                                                 │
│   发布 npm 包? ────────────→ 同时提供 ESM + CJS                │
│                                                                 │
│   CDN 直接引用? ───────────→ UMD                               │
│                                                                 │
│   需要 Tree Shaking? ──────→ ESM(必须)                       │
│                                                                 │
└────────────────────────────────────────────────────────────────┘
导入最佳实践
// ❌ 不推荐:导入整个库
import _ from 'lodash';
_.map([1, 2], x => x * 2);

// ✅ 推荐:按需导入
import { map } from 'lodash-es';
map([1, 2], x => x * 2);

// ❌ 不推荐:命名空间导入(阻止 Tree Shaking)
import * as utils from './utils';
utils.format(str);

// ✅ 推荐:具名导入
import { format } from './utils';
format(str);

// ✅ 懒加载:大模块动态导入
const loadChart = async () => {
  const { Chart } = await import('./chart');
  return new Chart();
};
CommonJS 迁移到 ESM
// ─────────── CommonJS ───────────
const fs = require('fs');
const path = require('path');
const { myFunc } = require('./utils');

module.exports = { a, b };
module.exports.c = c;

// ─────────── ESM ───────────
import fs from 'fs';
import path from 'path';
import { myFunc } from './utils.js';  // 注意扩展名!

export { a, b };
export { c };

// __dirname/__filename 替代方案
import { fileURLToPath } from 'url';
import { dirname } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

附录:核心概念速查表

A. 模块规范对比

特性 CommonJS ESM
语法 require/exports import/export
加载时机 运行时 编译时
绑定类型 值拷贝 实时绑定
静态分析
Tree Shaking
顶层 await

B. Webpack 构建阶段

阶段 核心任务 关键 Hook
Initialize 合并配置、创建 Compiler、注册 Plugin environment
Make 从 Entry 递归解析,执行 Loader,构建 ModuleGraph make
Seal 生成 Chunk、执行 SplitChunks、生成代码 optimizeChunks
Optimize Tree Shaking、压缩混淆 optimizeTree
Emit 输出 Bundle 文件 emit

C. 运行时机制

机制 说明
__webpack_require__ 同步加载模块,从 modules 对象取并执行
__webpack_require__.e 异步加载 Chunk,动态创建 script 标签
installedModules 模块缓存,避免重复执行
installedChunks Chunk 缓存,避免重复加载
webpackJsonp JSONP 回调,注册异步 Chunk 的模块

D. 知识体系一图总结

┌─────────────────────────────────────────────────────────────────────────────┐
│                                                                              │
│                    JavaScript 模块化知识体系                                 │
│                                                                              │
├──────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│   历史演进                                                                   │
│   ────────                                                                   │
│   全局变量 → CommonJS(Node) → AMD(浏览器) → UMD(通用) → ESM(标准)            │
│                                                                              │
│   核心区别                                                                   │
│   ────────                                                                   │
│   CommonJS: 运行时加载,值拷贝,动态路径 → 无法 Tree Shaking                 │
│   ESM:      编译时分析,引用绑定,静态路径 → 支持 Tree Shaking               │
│                                                                              │
│   ESM 三阶段                                                                 │
│   ──────────                                                                 │
│   构建(解析依赖) → 实例化(分配内存/连接绑定) → 求值(执行代码)                │
│                                                                              │
│   构建工具链                                                                 │
│   ──────────                                                                 │
│   源码(ESM) → Babel(保留ESM,转语法) → Webpack(打包,标记) → Terser(删除,压缩) │
│                                                                              │
│   Webpack 核心                                                               │
│   ────────────                                                               │
│   Module(源文件) → Chunk(中间态) → Bundle(产物)                              │
│   Make(构建ModuleGraph) → Seal(生成Chunk) → Emit(输出文件)                   │
│                                                                              │
│   最佳实践                                                                   │
│   ────────                                                                   │
│   • 新项目首选 ESM                                                           │
│   • npm 包同时提供 ESM + CJS                                                 │
│   • 按需导入,避免 import *                                                  │
│   • 配置 sideEffects: false                                                  │
│   • 大模块使用动态 import() 懒加载                                           │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

开发一个美观的 VitePress 图片预览插件

2026年1月10日 15:32

前言

笔者维护的 VitePress 博客主题已经集成了非常多的功能,为便于在主题之外复用,因此有计划将其一部分功能分离出来,形成独立的插件。

现在又有AI加持,再已经有通用插件模板前提下,使用AI就能完成95%的插件工作量!

分离的 图片预览插件,效果如下:

组件样式实现参考了 Element Plus Image Viewer

接下来先简单介绍一下用法,再快速讲解核心原理。

插件开发基于之前创建的一个通用模板,vitepress-plugin-slot-inject-template,在模板的基础上,插件95%的代码由 Gemini 3.0 生成。

如何使用

只需要 2 步:

  1. 安装插件
pnpm add vitepress-plugin-image-preview
  1. 配置插件

引入插件在 .vitepress/config.mts VitePress 配置文件中

import { defineConfig } from 'vitepress'
import { ImagePreviewPlugin } from 'vitepress-plugin-image-preview'

export default defineConfig({
  vite: {
    plugins: [
      ImagePreviewPlugin()
    ]
  }
})

实现原理

这里只阐述关键点,细节与之前的公告插件类似,这里不做赘述。

VitePress 默认主题 Layout.vue 组件预设的一些插槽,只需将实现自定义组件注入到对应插槽为止即可。

所有的 slotsVitePress 文档里也有介绍

注入自定义组件

利用插件的 transform 钩子,将我们的 <ImagePreview /> 组件插入到 Layout.vue 的特定插槽位置

图片预览组件我这里使用的是 doc-beforepage-top 两个插槽。

使用 alias 保证引入组件的路径正确映射。

// 仅包含关键代码
const componentName = 'ImagePreview'
const componentFile = `${componentName}.vue`
const aliasComponentFile = `${getDirname()}/components/${componentFile}`
function ImagePreviewPlugin(options = {}) {
  return {
    // 添加alias
    config: () => {
      return {
        resolve: {
          alias: {
            [`./${componentFile}`]: aliasComponentFile
          }
        }
      }
    },
    transform(code, id) {
      // 筛选出 Layout.vue
      if (id.endsWith('vitepress/dist/client/theme-default/Layout.vue')) {
        let transformResult = code

        // 插入组件
        const slots = [options.slots || ['doc-before', 'page-top']].flat()
        for (const slot of slots) {
          const slotPosition = `<slot name="${slot}" />`
          // 添加 ClientOnly 目的是避免组件在SSG的时候被渲染
          transformResult = transformResult.replace(slotPosition, `${slotPosition}\n<ClientOnly><${componentName} /></ClientOnly>`)
        }

        // 导入组件
        const setupPosition = '<script setup lang="ts">'
        transformResult = transformResult.replace(setupPosition, `${setupPosition}\nimport ${componentName} from './${componentFile}'`)
        return transformResult
      }
    },
  }
}

插件配置传递

采用虚拟模块的方式传递配置。

组件中导入配置:

import options from 'virtual:image-preview-options'

插件中处理虚拟模块:

const virtualModuleId = 'virtual:image-preview-options'
const resolvedVirtualModuleId = `\0${virtualModuleId}`
function ImagePreviewPlugin(options = {}) {
  return {
    // 省略其它无关代码...
    resolveId(id) {
      if (id === virtualModuleId) {
        return resolvedVirtualModuleId
      }
    },
    load(this, id) {
      if (id === resolvedVirtualModuleId) {
        return `export default ${stringify(options)}`
      }
    },
  }
}

核心交互实现

图片预览的核心逻辑在于监听图片的点击事件,获取图片列表,并显示预览遮罩。

  1. 事件监听:在 onMounted时,给内容的容器注册点击事件,在点击的时候获取容器中所有的图片元素,然后做后续操作。
onMounted(() => {
  const wrapperId = imagePreviewOptions?.wrapperId || '#VPContent'
  const docDomContainer = document.querySelector(wrapperId)
  docDomContainer?.addEventListener('click', previewImage)
})

function previewImage(e: Event) {
  const target = e.target as HTMLElement
  const currentTarget = e.currentTarget as HTMLElement
  if (target.tagName.toLowerCase() === 'img') {
    const selector = imagePreviewOptions?.selector || '.content-container .main img,.VPPage img'
    const imgs = currentTarget.querySelectorAll<HTMLImageElement>(selector)
    const idx = Array.from(imgs).findIndex(el => el === target)
    const urls = Array.from(imgs).map(el => el.src)
    // 省略其它逻辑
  }
}
  1. 预览组件:参考了 Element Plus 的 图片预览组件的样式与功能,这部分完全由 AI 实现(Gemini 3.0),还原度非常高。

插件模板介绍

在开发插件的过程中,笔者把此类基于 slot 位置注入的插件分离了一个模板 vitepress-plugin-slot-inject-template

有相关诉求的朋友,可以基于此模板,配合 AI 快速的开发各种基于插槽就可以实现的组件能力。

最后

插件完整源码 vitepress-plugin-image-preview

最后再感叹一句,AI 太牛逼了,效率起飞。

欢迎评论区交流&指导。

webpack/vite配置

2026年1月10日 15:28

webpack配置

webpack.base.cjs

// 导入 Node.js 内置的 path 模块,用于处理文件路径
const path = require('path');
// 导入 vue-loader 插件,用于处理 .vue 文件
const { VueLoaderPlugin } = require('vue-loader');
// 导入 HtmlWebpackPlugin,用于生成 HTML 文件
const HtmlWebpackPlugin = require('html-webpack-plugin');
// 导入 unplugin-auto-import 的 webpack 版本,用于自动导入 Vue 组合式 API
const AutoImport = require('unplugin-auto-import/webpack');
// 导入 unplugin-vue-components 的 webpack 版本,用于自动导入 Vue 组件
const Components = require('unplugin-vue-components/webpack');
// 导入 ElementPlusResolver,用于自动导入 Element Plus 组件和样式
const { ElementPlusResolver } = require('unplugin-vue-components/resolvers');

// 导出 webpack 配置对象
module.exports = {
    // 入口文件配置
    entry: './src/main.ts',

    // 输出配置
    output: {
        // 输出目录,使用绝对路径
        path: path.resolve(__dirname, 'dist'),
    },

    // 解析配置
    resolve: {
        // 路径别名配置
        alias: {
            // 将 @ 别名指向 src 目录
            '@': path.resolve(__dirname, 'src'),
        },
        // 自动解析的扩展名列表,这样导入时可以省略扩展名
        extensions: ['.ts', '.tsx', '.js', '.jsx', '.vue', '.json'],
    },

    // 插件配置
    plugins: [
        // 自动导入配置
        AutoImport({
            // 解析器配置,使用 ElementPlusResolver 自动导入 Element Plus
            resolvers: [ElementPlusResolver()],
        }),
        // 自动组件导入配置
        Components({
            // 解析器配置,使用 ElementPlusResolver 自动导入 Element Plus 组件
            resolvers: [ElementPlusResolver()],
        }),
        // VueLoaderPlugin 必须配置,用于编译 Vue 单文件组件
        new VueLoaderPlugin(),
        // HtmlWebpackPlugin 配置,用于生成 HTML 文件
        new HtmlWebpackPlugin({
            // 使用 public/index.html 作为模板
            template: './public/index.html',
        }),
    ],

    // 模块配置
    module: {
        // 规则配置,用于处理不同类型的文件
        rules: [
            // 处理 .vue 文件的规则
            {
                // 匹配 .vue 文件
                test: /\.vue$/,
                // 使用 vue-loader 处理
                loader: 'vue-loader',
                // vue-loader 选项配置
                options: {
                    // 编译器选项
                    compilerOptions: {
                        // 不保留空格,减小文件体积
                        preserveWhitespace: false,
                    },
                },
            },
            // 处理 .ts 文件的规则
            {
                // 匹配 .ts 文件
                test: /\.ts$/,
                // 排除 node_modules 目录
                exclude: /node_modules/,
                // 使用多个 loader 处理
                use: [
                    // 首先使用 babel-loader 处理
                    {
                        loader: 'babel-loader',
                        // babel-loader 选项
                        options: {
                            // 使用 @babel/preset-env 预设,将 ES6+ 转换为 ES5
                            presets: ['@babel/preset-env'],
                            // 使用 @babel/plugin-transform-runtime 插件,避免重复注入 helper 函数
                            plugins: ['@babel/plugin-transform-runtime'],
                        },
                    },
                    // 然后使用 ts-loader 处理
                    {
                        loader: 'ts-loader',
                        // ts-loader 选项
                        options: {
                            // 为 .vue 文件添加 .ts 后缀,以便 ts-loader 处理
                            appendTsSuffixTo: [/\.vue$/],
                            // 只进行转译,不进行类型检查,提高构建速度
                            transpileOnly: true,
                        },
                    },
                ],
            },
            // 处理 .js 文件的规则
            {
                // 匹配 .js 文件
                test: /\.js$/,
                // 排除 node_modules 目录
                exclude: /node_modules/,
                // 使用 babel-loader 处理
                use: {
                    loader: 'babel-loader',
                    // babel-loader 选项
                    options: {
                        // 使用 @babel/preset-env 预设,将 ES6+ 转换为 ES5
                        presets: ['@babel/preset-env'],
                        // 使用 @babel/plugin-transform-runtime 插件,避免重复注入 helper 函数
                        plugins: ['@babel/plugin-transform-runtime'],
                    },
                },
            },
            // 处理 .css 文件的规则
            {
                // 匹配 .css 文件
                test: /\.css$/,
                // 使用 style-loader 和 css-loader 处理
                // 执行顺序:从右到左,先使用 css-loader 解析 CSS,再使用 style-loader 将 CSS 插入到页面
                use: ['style-loader', 'css-loader'],
            },
            // 处理 .scss 和 .sass 文件的规则
            {
                // 匹配 .scss 和 .sass 文件
                test: /\.s[ac]ss$/i,
                // 使用 style-loader、css-loader 和 sass-loader 处理
                // 执行顺序:从右到左,先使用 sass-loader 编译 Sass,再使用 css-loader 解析 CSS,最后使用 style-loader 将 CSS 插入到页面
                use: ['style-loader', 'css-loader', 'sass-loader'],
            },
            // 处理图片文件的规则
            {
                // 匹配 png、jpg、jpeg、gif 和 webp 格式的图片文件
                test: /\.(png|jpe?g|gif|webp)$/i,
                // 使用 file-loader 处理,将图片文件复制到输出目录并返回文件路径
                use: 'file-loader',
            },
        ],
    },
};

webpack.dev.cjs

// 导入 webpack-merge 模块,用于合并 webpack 配置
const { merge } = require('webpack-merge');
// 导入基础配置文件
const baseConfig = require('./webpack.base.cjs');
// 导入 Node.js 内置的 path 模块,用于处理文件路径
const path = require('path');

// 合并基础配置和开发环境配置
module.exports = merge(baseConfig, {
    // 模式设置为开发环境
    mode: 'development',

    // 输出配置
    output: {
        // 输出文件名,开发环境使用简单的 bundle.js
        filename: 'bundle.js',
    },

    // 开发工具配置,用于生成 source map
    // eval-cheap-module-source-map 能在开发时提供较好的性能和调试体验
    devtool: 'eval-cheap-module-source-map',

    // 开发服务器配置
    devServer: {
        // 静态文件目录配置
        static: {
            // 指定 public 目录为静态文件目录
            directory: path.join(__dirname, 'public'),
        },
        // 是否启用 gzip 压缩
        compress: true,
        // 开发服务器端口
        port: 8000,
        // 是否启用热模块替换(HMR)
        hot: true,
        // 构建完成后是否自动打开浏览器
        open: true,
        // 客户端配置
        client: {
            // 错误和警告的覆盖层配置
            overlay: {
                // 显示错误覆盖层
                errors: true,
                // 不显示警告覆盖层
                warnings: false,
            },
        },
        // 支持 SPA 路由,所有 404 请求都会返回 index.html
        historyApiFallback: true,
        // 代理配置,用于将 API 请求代理到后端服务器
        proxy: [
            {
                // 匹配需要代理的路径
                context: ['/test'],
                // 代理目标地址
                target: 'http://localhost:3000',
                // 是否验证 SSL 证书
                secure: false,
                // 是否修改请求头中的 Origin
                changeOrigin: true,
                // 路径重写配置(当前已注释)
                // pathRewrite: {
                //     '^/api': ''
                // }
            },
        ],
    },

    // 优化配置
    optimization: {
        // 启用 tree shaking,只打包使用到的代码
        usedExports: true,
    },
});

webpack.prod.cjs

// 导入 webpack-merge 模块,用于合并 webpack 配置
const { merge } = require('webpack-merge');
// 导入基础配置文件
const baseConfig = require('./webpack.base.cjs');
// 导入 TerserPlugin,用于压缩 JavaScript 代码
const TerserPlugin = require('terser-webpack-plugin');
// 导入 CssMinimizerPlugin,用于压缩 CSS 代码
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
// 导入 BundleAnalyzerPlugin,用于分析打包后的文件结构和大小
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

// 合并基础配置和生产环境配置
module.exports = merge(baseConfig, {
    // 模式设置为生产环境
    mode: 'production',

    // 输出配置
    output: {
        // 输出文件名,使用 contenthash 确保文件内容变化时文件名变化,有利于缓存
        filename: 'bundle.[contenthash].js',
        // 构建前清理输出目录
        clean: true,
    },
    devtool: 'hidden-source-map',
    // 优化配置
    optimization: {
        // 启用代码压缩
        minimize: true,
        // 配置压缩器
        minimizer: [
            // JavaScript 压缩器配置
            new TerserPlugin({
                // Terser 配置选项
                terserOptions: {
                    // 压缩配置
                    compress: {
                        // 删除所有 console 语句
                        drop_console: true,
                        // 删除所有 debugger 语句
                        drop_debugger: true,
                    },
                },
            }),
            // CSS 压缩器
            new CssMinimizerPlugin(),
            // 打包分析工具配置
            new BundleAnalyzerPlugin({
                // 分析模式设置为静态文件
                analyzerMode: 'static',
                // 不自动打开分析报告
                openAnalyzer: false,
            }),
        ],
        // 代码分割配置
        splitChunks: {
            // 对所有类型的 chunk 进行拆分(包括异步和同步)
            chunks: 'all',
            // 控制生成 chunk 的最小大小(20KB)
            minSize: 20000,
            // 剩余大小的最小值,确保拆分后剩余部分不会过小
            minRemainingSize: 0,
            // 模块被引用的最小次数
            minChunks: 1,
            // 异步加载时的最大请求数
            maxAsyncRequests: 30,
            // 初始加载时的最大请求数
            maxInitialRequests: 30,
            // 强制执行大小阈值(50KB),超过此值的 chunk 会被强制拆分
            enforceSizeThreshold: 50000,
            // 缓存组配置,用于将不同类型的模块分割到不同的 chunk 中
            cacheGroups: {
                // Element Plus 组件库缓存组
                elementPlus: {
                    // 输出文件名
                    name: 'element-plus',
                    // 匹配 Element Plus 模块
                    test: /[\/]node_modules[\/]element-plus[\/]/,
                    // 优先级,数字越大优先级越高
                    priority: 20,
                    // 对所有类型的 chunk 生效
                    chunks: 'all',
                    // 重用已存在的 chunk,避免重复打包
                    reuseExistingChunk: true,
                },
                // Vue Router 路由库缓存组
                vueRouter: {
                    name: 'vue-router',
                    test: /[\/]node_modules[\/]vue-router[\/]/,
                    priority: 20,
                    chunks: 'all',
                    reuseExistingChunk: true,
                },
                // Pinia 状态管理库缓存组
                pinia: {
                    name: 'pinia',
                    test: /[\/]node_modules[\/]pinia[\/]/,
                    priority: 20,
                    chunks: 'all',
                    reuseExistingChunk: true,
                },
                // Axios HTTP 客户端库缓存组
                axios: {
                    name: 'axios',
                    test: /[\/]node_modules[\/]axios[\/]/,
                    priority: 20,
                    chunks: 'all',
                    reuseExistingChunk: true,
                },
                // Vue 核心库缓存组
                vue: {
                    name: 'vue',
                    test: /[\/]node_modules[\/]vue[\/]/,
                    priority: 20,
                    chunks: 'all',
                    reuseExistingChunk: true,
                },
                // VueUse 工具库缓存组
                vueUse: {
                    name: 'vue-use',
                    test: /[\/]node_modules[\/]@vueuse[\/]/,
                    priority: 20,
                    chunks: 'all',
                    reuseExistingChunk: true,
                },
                // 公共代码缓存组
                common: {
                    name: 'common',
                    // 至少被引用 2 次的模块才会被打包到这个 chunk
                    minChunks: 2,
                    priority: 5,
                    chunks: 'all',
                    reuseExistingChunk: true,
                },
                // 其他第三方依赖缓存组
                vendors: {
                    name: 'vendors',
                    // 匹配所有 node_modules 中的模块
                    test: /[\/]node_modules[\/]/,
                    priority: 10,
                    // 只对初始加载的 chunk 生效
                    chunks: 'initial',
                    reuseExistingChunk: true,
                    // 强制执行缓存组,即使不符合其他条件
                    enforce: true,
                },
            },
        },
        // 运行时代码分割配置
        runtimeChunk: {
            // 运行时代码的输出文件名
            name: 'runtime',
        },
    },
});

vite配置

vite.base.ts

// 导入Vite的defineConfig函数,用于定义Vite配置
import { defineConfig } from 'vite';
// 导入Vue插件,用于支持Vue组件
import vue from '@vitejs/plugin-vue';
// 导入自动导入插件,用于自动导入Vue API和其他库
import AutoImport from 'unplugin-auto-import/vite';
// 导入组件自动注册插件,用于自动注册Vue组件
import Components from 'unplugin-vue-components/vite';
// 导入Element Plus解析器,用于自动导入Element Plus组件和API
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';
// 导入Node.js的path模块,用于处理文件路径
import path from 'path';

// 导出公共配置
export default defineConfig({
    // 设置项目基础路径,使用相对路径
    base: './',

    // 配置Vite插件
    plugins: [
        // 使用Vue插件
        vue(),

        // 配置自动导入插件
        AutoImport({
            // 使用Element Plus解析器
            resolvers: [ElementPlusResolver()],
            // 生成类型声明文件
            dts: true,
            // 自动导入的库
            imports: ['vue', 'vue-router', 'pinia'],
        }),

        // 配置组件自动注册插件
        Components({
            // 使用Element Plus解析器
            resolvers: [ElementPlusResolver()],
            // 生成类型声明文件
            dts: true,
            // 组件所在目录
            dirs: ['./src/components'],
        }),
    ],

    // 配置模块解析
    resolve: {
        // 配置路径别名
        alias: {
            // 将@符号指向src目录
            '@': path.resolve(__dirname, './src'),
        },
        // 配置文件扩展名,导入时可以省略
        extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue'],
    },

    // 配置CSS
    css: {
        // 配置预处理器选项
        preprocessorOptions: {
            // SCSS预处理器配置
            scss: {
                // 移除了additionalData,因为style.css已经在main.ts中导入
            },
        },
    },

    // 配置开发服务器
    server: {
        // 服务器端口
        port: 9000,
        // 自动打开浏览器
        open: true,
        // 允许外部访问
        host: '0.0.0.0',
        // 配置代理
        proxy: {
            // 将/test路径代理到http://localhost:3000
            '/test': {
                // 代理目标地址
                target: 'http://localhost:3000',
                // 改变请求头中的Origin
                changeOrigin: true,
                // 允许HTTPS证书无效
                secure: false,
            },
        },
    },

    // 配置依赖优化
    optimizeDeps: {
        // 预构建的依赖列表
        include: ['vue', 'vue-router', 'pinia', 'axios', 'element-plus', '@vueuse/core'],
    },
});

vite.dev.ts

// 导入Vite的defineConfig和mergeConfig函数
// defineConfig用于定义Vite配置,mergeConfig用于合并配置
import { defineConfig, mergeConfig } from 'vite';

// 导入公共配置
import baseConfig from './vite.base.ts';

// 导出开发环境配置
export default defineConfig(
    // 使用mergeConfig合并公共配置和开发环境配置
    mergeConfig(
        // 公共配置
        baseConfig,
        // 开发环境特定配置
        defineConfig({
            // 设置环境模式为development
            mode: 'development',
            // 配置全局常量替换
            define: {
                // Vue feature flag for hydration mismatch details
                __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false,
            },
            // 配置构建选项
            build: {
                // 构建输出目录
                outDir: 'dist',
                // 静态资源输出目录
                assetsDir: 'assets',
                // 开发环境启用sourcemap,便于调试
                sourcemap: true,
                // 开发环境不压缩代码,提高构建速度
                minify: false,
            },
        })
    )
);

vite.prod.ts

// 导入Vite的defineConfig和mergeConfig函数
// defineConfig用于定义Vite配置,mergeConfig用于合并配置
import { defineConfig, mergeConfig } from 'vite';

// 导入构建分析工具,用于生成构建包大小分析报告
import { visualizer } from 'rollup-plugin-visualizer';

// 导入Gzip压缩插件,用于压缩静态资源
import Compression from 'vite-plugin-compression';

// 导入公共配置
import baseConfig from './vite.base.ts';

// 导出生产环境配置
export default defineConfig(
    // 使用mergeConfig合并公共配置和生产环境配置
    mergeConfig(
        // 公共配置
        baseConfig,
        // 生产环境特定配置
        defineConfig({
            // 设置环境模式为production
            mode: 'production',
            // 配置全局常量替换
            define: {
                // Vue feature flag for hydration mismatch details
                __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: true,
            },
            // 配置生产环境特定插件
            plugins: [
                // 构建分析工具配置
                visualizer({
                    // 构建完成后不自动打开报告
                    open: false,
                    // 显示gzip压缩后的大小
                    gzipSize: true,
                    // 显示brotli压缩后的大小
                    brotliSize: true,
                    // 报告生成路径
                    filename: 'dist/stats.html',
                }),

                // Gzip压缩插件配置
                Compression({
                    // 输出压缩信息
                    verbose: true,
                    // 启用压缩
                    disable: false,
                    // 压缩阈值,大于10KB的文件才会被压缩
                    threshold: 10240,
                    // 压缩算法
                    algorithm: 'gzip',
                    // 压缩文件扩展名
                    ext: '.gz',
                }),
            ],

            // 配置构建选项
            build: {
                // 构建输出目录
                outDir: 'dist',
                // 静态资源输出目录
                assetsDir: 'assets',
                // 生产环境禁用sourcemap,减少文件大小
                sourcemap: false,
                // 生产环境使用terser进行代码压缩
                minify: 'terser',

                // terser压缩配置
                terserOptions: {
                    // 压缩选项
                    compress: {
                        // 移除console语句
                        drop_console: true,
                        // 移除debugger语句
                        drop_debugger: true,
                    },
                },

                // 输出配置(使用rolldown替代rollup)
                rollupOptions: {
                    output: {
                        // 入口文件命名规则
                        entryFileNames: 'assets/js/[name].[hash].js',
                        // 代码块命名规则
                        chunkFileNames: 'assets/js/[name].[hash].js',
                        // 静态资源命名规则
                        assetFileNames: 'assets/[ext]/[name].[hash].[ext]',

                        // 手动代码分割配置
                        manualChunks: {
                            // 将Vue相关库打包到vue-vendor chunk
                            'vue-vendor': ['vue', 'vue-router', 'pinia'],
                            // 将Element Plus打包到element-plus chunk
                            'element-plus': ['element-plus'],
                            // 将Axios打包到axios chunk
                            axios: ['axios'],
                            // 将@vueuse/core打包到@vueuse/core chunk
                            '@vueuse/core': ['@vueuse/core'],
                        },
                    },
                },
            },
        })
    )
);

小程序上线半年我赚了多少钱?

作者 是江迪呀
2026年1月10日 15:17

1. 开发历程

时间过得真快,转眼间福猪记账小程序上线已经有半年时间了,从v1.0.0 版本做到了 v1.6.1 版本。今天整理了开发日志,真的不敢相信自己在工作之余能够把它做出来并上线。

2026-01-10-14-22-50.png

2026-01-10-14-23-15.png

2026-01-10-14-23-23.png

2.用户积累

到目前2026年1月10日为止,已经积累了872位用户,感谢你们的使用和支持!

2026-01-10-14-23-32.png

2026-01-10-14-23-42.png

我在小程序上线后也没怎么宣传,只写了一篇公众号有1200多浏览量,然后全靠自然流量。虽然用户增长数据可观,但是留存数据不容乐观,从之前的日活:70多人到现在稳定在10几人。这也让我怀疑是不是我做的东西不好用,帮助不了用户,为了 防止闭门造车 我会积极听取身边朋友和用户的反馈和建议,作出调整优化, 让它越来越好 。 无论未来如何,这个项目我会一直更新迭代下去, 服务更多的用户,它是我的第一个完整的作品,同时也是我迈出独立开发的第一步!我一定会认真对待。

3. 收益

目前收益途径仅有流量主,功能全部是免费的。为了不影响用户的体验在流量主开通后,我只引入了开屏广告。目前流量主开通了3个月,一共收入69.32。平均每天是0.4左右,收益等于没有,哈哈。没有收益的软件如同无源之水,无根之木,是不长久的,也容易让用户产生我会随时跑路的担忧,我的计划是等我把福猪记账做得更完整更好的时候,再推出VIP功能。

2026-01-10-14-24-02.png

4.感悟

开发福猪记账是我迈向独立开发的第一步,让我意识到做demo和做产品有着云泥之别。从能看的demo,做到能用,再从能用做到好用,是需要动脑子,下功夫的。需要考虑很多东西比如UI设计、用户体验、用户引导、功能测试等等。眼高手低是我常犯的一个错误,也是我要尽快克服的毛病,避免完美主义,完美的前提是完成。

做独立开发,要缩小程序员思维占的位置,要将产品思维、运营思维加入进来。在公司上班,你只需要关注你的一亩三分地即可,功能实现了没问题你就可以点杯咖啡开始划水了。但是独立开发不行,你要既当爹又当妈,不要太过于注重代码写的是否美观、健壮,也不要上来就用这个框架、那个技术、设计模式、算法咔咔一通乱整。前期一定要避免这样,不然精力很快就会被耗尽,你需要考虑的是什么可以让你的功能快速实现并上线!别管白猫黑猫逮到耗子就是好猫。

AI真的改变了我们的开发模式,它帮我节省了大量开发的时间,我们应该积极拥抱这种变化。我敢说如果没有AI的协助,我这个项目至少要双倍的时间来完成,甚至完不成!但是也不像网上说的那样离谱,一句话AI就可以给你做出一款APP,那不现实,即使做出来也存在很多问题,需要大量时间调整修复。通过这段时间和AI协作开发,我发现AI的能力的天花板其是你个人能力的天花板,你强AI就强,你就像是一个指挥官,AI帮你干活,但是如果你能力不行专业知识不够,瞎指挥,AI也变得傻乎乎的,它做不出来你脑子里面没有的东西!在AI迅速发展的时代,我应该着重培养自己的思维能力:代码思维、产品思维。我记得一句话是这样说的:代码是在你脑海中形成的,只要思路正确,写代码只是一个堆砌的过程。现在AI帮我做了写代码这一步,那么我们就应该学会如何将脑海中的思维清晰的表述出来,让AI听得懂,并且按照我们的思路去做。

最重要的永远在最后:产品定位和运营宣传,你东西做的再好没人知道也是不行的。产品定位很重要,如果你选择的是红海领域比如记账,这个类型的应用随便一搜索就不下10几20个,要从头部app中抢流量这太难了,但是我们再仔细想一个问题:为什么记账app已经那么多了,偶尔还是会出现一两款记账软件呢?我的答案是:没有一款软件可以满足所有用户的需求!比如我就是其中之一,我相信肯定也有其他人和我一样,所以我要做到差异化,如果只是想复刻别人产品那是行不通的,差异化可以是功能、UI、交互。避免大而全,专注小而美,聚焦某一类特定的用户需求,才是破局之道。至于宣传,没有钱来投广告那就靠自己,除了在各大平台发文章宣传方式外别无他法,这种冷启动实是无奈之举,这也更加体现了打造个人IP的重要性。

独立开发是一条夜路,没有目标、没有终点,一切都是未知数,但是我喜欢未知数,这表示我的生活有着无限的可能!

一篇文章带你搞懂原型和原型链

作者 陟上青云
2026年1月10日 14:58

我们在理解原型和原型链的时候,喜欢去看概念强行记住,当时以为记住了就懂了可等下次又会忘记,其实是还没有真正弄懂。想要弄懂,就要先知道到底难在哪,理清它们之间的关系,再去看概念性的东西,就轻松很多了。

一、原型和原型链的核心难点拆解

难点 1:__proto__prototypeconstructor 三者的关系(最易混淆)

这三个属性是原型体系的 “三角关系”,新手很容易记混、用错,先看核心结论

  • prototype:函数独有,指向“原型对象”,原型对象里默认有constructor属性。
  • __proto__所有对象独有,指向自己的“原型对象”。
  • constructor:原型对象独有,指向创建该原型对象的构造函数。

核心公式(必记)

实例.__proto__ === 构造函数.prototype
构造函数.prototype.constructor === 构造函数
实例.constructor === 构造函数 (本质是通过原型链查找 constructor)
function Person(name) {
this.name = name
}
const res = new Person('老王')
// 验证1,实例的隐式原型(__proto__)=== 构造函数的显式原型(prototype)
console.log(res.__proto__ === Person.prototype,) // true
// 验证2,原型对象的constructor指向构造函数
 console.log(Person.prototype.constructor === Person) // true
// 验证3,实例的本身没有constructor 是通过原型链找到原型对象的constructor
 console.log(res.constructor === Person) //true

原型对象本身也是对象,因此它的 __proto__ 会指向更高层的原型对象,最终形成原型链

// Person.prototype 是对象,其 __proto__ 指向 Object.prototype
Person.prototype.__proto__ === Object.prototype; // true

// Object.prototype 是原型链的顶端,其 __proto__ 为 null
Object.prototype.__proto__ === null; // true

注意点:修改 prototype 会影响 constructor

若直接覆盖构造函数的 prototype(而非添加属性),会丢失原有的 constructor(默认指向 Object),需手动恢复

// 错误示例:直接覆盖prototype,constructor会指向Object
Person.prototype = {
  sayHi: function() { console.log('Hi'); }
};
p.constructor === Object; // true(不符合预期)

// 正确做法:覆盖后手动设置constructor
Person.prototype = {
  constructor: Person, // 手动指向原构造函数
  sayHi: function() { console.log('Hi'); }
};
p.constructor === Person; // true(恢复预期)

总结三者的核心关系

构造函数(如Person)
  ↓ 拥有
  prototype → 原型对象(如Person.prototype)
                ↓ 拥有
                constructor → 构造函数(Person)
实例(如p)
  ↓ 拥有
  __proto__ → 原型对象(Person.prototype

二,原型和原型链的核心理解

简单来说每个函数创建都会默认添加一个prototype的属性,创建每个对象也会有一个__proto__的属性,上面也验证过 实例的__proto__(隐式原型)是指向构造函数的prototype(显示原型的)两者是相等的。

构造函数.prototype也是一个对象,所以也会有一个__proto__,指向的是Object.prototype,因此Object.prototype也是一个对象,所以也会有__proto__,Object.prototype.proto 指向的却是null,因为这是原型链的最顶层了,顶层就是null

原型链:实例会先从自身上找,没找到会通过obj.proto 从原型对象上去找(构造函数.prototype),没找到会通过obj.proto.proto 从原型对象上的原型上去找(构造函数.prototype.proto),直到最顶层Object.prototype去找没找到,就回去Object.prototype.proto 值为null 结束 返回 undefined

 function Person(name) {
            this.name = name
            this.age = 20
        }

        const obj = new Person('zhangsan')
        Person.prototype.age2 = 30
        Object.prototype.age3 = 40
        console.log(obj.age) //访问的是自身上的属性
        console.log(obj.age2) //访问的是原型对象上的属性
        console.log(obj.age3) //访问的是原型对象的原型上的属性

        /**
         * obj={
         *   console.log(obj.age) 30  先找自身
         * __proto__ === 构造函数.prototype {
         *     console.log(obj.age2) 30  如果自身没有找到 就会去找原型对象
         *   Person.prototype.__proto__ === Object.prototype ={
         *       console.log(obj.age3) 40  原型对象上没有  就会去找原型对象上的原型上去找
         *     }
         *      Object.prototype.__proto__ {
         *        直到最顶层  Object.prototype 它的__proto__ === null  所有原型链的最顶层就是
         *      }  
         *   
         *   }
         * 
         * }
         * **/

推荐几个国外比较流行的UI库(上)

2026年1月10日 14:45

1、Tailwind CSS

现在写样式的时候,我基本已经离不开 Tailwind CSS 了。最开始接触它的时候,其实挺不适应的,感觉类名又多又杂,全写在标签上。但真正用顺了之后,反而不太想回到以前那种来回切 CSS 文件的方式。

我比较喜欢的是它处理响应式的方式,断点直接写在类名前面,逻辑非常直观。页面在不同尺寸下怎么变,一眼就能看出来。再加上配置文件可以统一管颜色、间距、字体这些东西,对我来说维护起来反而更轻松。

缺点当然也有,比如结构看起来不那么“干净”,但这个在我这里已经不算什么问题了。

下面我们来实现一个瀑布流

<div class="columns-3 ..."> 
    <img class="aspect-3/2 ..." src="/img/mountains-1.jpg" /> 
    <img class="aspect-square ..." src="/img/mountains-2.jpg" /> 
    <img class="aspect-square ..." src="/img/mountains-3.jpg" /> 
    <!-- ... -->
</div>

效果 image.png


2、Bootstrap

虽然 Bootstrap 已经很多年了,但说实话,在一些需求明确、节奏比较快的项目里,它依然很好用。栅格、常见组件基本都有,直接拼就能出页面,几乎不用想太多。

image.png


3、Foundation

Foundation 是一个开源的响应式前端框架,用于构建结构清晰、视觉一致的网页界面。它提供了完整的工具体系,包括响应式网格系统、设计模板,以及基于 HTML、CSS 和 SASS 的样式方案。同时,框架内置了按钮、导航、表单、排版等常见 UI 能力,并支持通过 JavaScript 扩展进一步增强交互功能。

Foundation 采用移动优先的设计理念,与 Bootstrap 类似,布局从小屏设备开始构建,再逐步扩展到更大的屏幕尺寸。这种方式使页面能够自然适配不同设备,无需额外处理复杂的适配逻辑,从而在手机、平板和桌面端之间保持一致且流畅的体验。

在布局层面,Foundation 提供了基于 Flexbox 的 12 列响应式网格系统。页面结构可以通过行与列的组合快速搭建,而网格系统会自动处理不同断点下的尺寸变化与内容堆叠,使整体布局保持简洁、直观且易于维护。

Foundation 的工具包体系也是其重要特性之一。框架内置了可直接使用的网页与邮件组件,使项目在启动阶段不必从零搭建基础结构。这种方式在多平台场景下有助于维持统一的视觉风格,并显著减少重复性工作。

在灵活性方面,Foundation 并未强制绑定特定的设计语言或样式规范。默认配置可根据项目需求进行调整或覆盖,从而在不受框架限制的前提下实现定制化界面设计。这种设计思路在效率与自由度之间取得了较好的平衡。

从整体特性来看,Foundation 对无障碍访问和移动优先设计的重视,使其在构建现代化、包容性网页体验时具有明显优势。模块化架构与 SASS 集成提升了组件定制的效率,也使复杂布局的原型构建更加顺畅。

相对而言,Foundation 的学习成本高于 Bootstrap 等更大众化的方案,对初学者存在一定门槛。此外,其社区规模和生态资源不及 Tailwind 和 Bootstrap 丰富,可直接复用的第三方资源相对有限。在功能完整度较高的同时,对于体量较小的项目而言,可能会引入不必要的复杂度。

特点

1.  **响应式**:先做好手机,再适配平板和电脑。
2.  **网格灵活**:12 列 Flexbox 布局,布局复杂也能处理好。
3.  **组件齐全**:带 JS 插件,交互也有现成的(弹窗、菜单等)。

适合谁:想快速搭复杂页面,有交互,又想用框架自带组件的人。

缺点:学习稍复杂,功能多了,小项目可能显得重。

下面我们来使用它的按钮样式

<!-- Anchors (links) --> 
<a href="about.html" class="button">Learn More</a> 
<a href="#features" class="button">View All Features</a> 

<!-- Buttons (actions) --> 
<button class="submit success button">Save</button> 
<button type="button" class="alert button">Delete</button>

效果

image.png


4、Bulma

Bulma 是那种一看就懂、上手很快的框架。类名语义清楚,布局基于 Flexbox,用起来很顺。

它不依赖 JavaScript 这一点,拿来配合任何技术栈都很方便。不过也正因为这样,一些交互相关的东西需要自己补,这点在用之前心里要有预期。

特点

1.  **响应式**:移动优先,Flexbox 网格布局。
2.  **轻量**:按钮、卡片、表单都有样式,但没有 JS。
3.  **易用**:学习成本低,改样式很方便。

适合谁:只需要快速搭页面、布局和样式固定、不需要框架自带交互的人。

缺点:没有交互组件,复杂行为要自己写。

下面我们来写一个简单的表单

<form class="box">
  <div class="field">
    <label class="label">Email</label>
    <div class="control">
      <input class="input" type="email" placeholder="e.g. alex@example.com" />
    </div>
  </div>

  <div class="field">
    <label class="label">Password</label>
    <div class="control">
      <input class="input" type="password" placeholder="********" />
    </div>
  </div>

  <button class="button is-primary">Sign in</button>
</form>

效果

image.png


react使用Ant Design

作者 加油乐
2026年1月9日 11:58

一、安装

npm install antd --save
  • yarn
yarn add antd
  • pnpm
pnpm install antd --save

二、main引用

  • 引入Ant Design的reset.css文件
  • 引入ConfigProvider全局配置,且包含所有组件
  • 设置locale属性为中文
// 导入严格模式组件,用于开发环境下检测潜在问题
import { StrictMode } from 'react';
// 导入客户端渲染方法,用于创建根节点并渲染应用
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
// 导入Ant Design的样式文件
import 'antd/dist/reset.css';
// 导入全局自定义样式文件
import './index.css';
// 导入Ant Design配置提供者组件
import { ConfigProvider } from 'antd';
// 导入Ant Design的中文语言包
import zhCN from 'antd/locale/zh_CN';

// 获取 HTML 中 id 为 'root' 的 DOM 元素作为应用根节点
const rootElement = document.getElementById('root');

// 如果找不到根节点,则抛出错误
if (!rootElement) {
throw new Error('找不到id为"root"的DOM元素,请检查index.html文件');
}

// 创建 React 根节点
const root = createRoot(rootElement);

// 渲染整个 React 应用
root.render(
// 使用严格模式包装应用
// 注意:严格模式只在开发环境生效,生产环境会自动禁用
<StrictMode>
{/*
 ConfigProvider 是 Ant Design 的全局配置组件
 此处设置 locale 属性为中文语言包,实现组件国际化
 可以在这里添加更多全局配置,如主题定制等
 */}
<ConfigProvider locale={zhCN}>
{/*
 应用主组件
 所有页面和路由都将在 App 组件内部定义和管理
 */}
<App />
</ConfigProvider>
</StrictMode>
);

三、组件引用

  • 引用Button,查看使用效果
import { Button } from 'antd';

function App() {
return (
<div>
<Button type="primary">按钮</Button>
</div>
);
}

export default App;

JavaScript Date 语法要过时了!以后用这个替代!

作者 冴羽
2026年1月9日 11:52

1. 前言

作为一名前端开发工程师,你一定被 JavaScript 的日期处理折磨过。

这不是你的问题,是 JavaScript 自己的问题——它的 Date 功能真的很糟糕。

2. Date 的离谱行为

让我给你举几个例子,你就明白有多离谱了:

月份从 0 开始计数:

// 你以为这是 2026 年 1 月 1 日?
console.log(new Date(2026, 1, 1));
// 结果:2026 年 2 月 1 日!

// 因为月份是从 0 开始数的:0=1月,1=2月...
// 但年份和日期又是正常计数的

日期格式混乱到让人抓狂:

// 用斜杠分隔,加不加前导零都没问题
console.log(new Date("2026/01/02"));
// Fri Jan 02 2026 00:00:00 GMT+0800 (中国标准时间)

// 但如果用短横线分隔,同样的写法
console.log(new Date("2026-01-02"));
// Fri Jan 02 2026 08:00:00 GMT+0800 (中国标准时间)

// 时间居然不一样了!

// 如果用东半球标准时间,更离谱!一个是 1 月 2 日,一个是 1 月 1 日

两位数年份的迷惑行为:

console.log(new Date("49")); // 2049 年
console.log(new Date("99")); // 1999 年
console.log(new Date("100")); // 公元 100 年!

规则莫名其妙:33-99 代表 1900 年代,但 32-49 又代表 2000 年代,100 以上就真的是公元那一年了。

更致命的问题是 —— 日期居然可以被“改变”!

const today = new Date();
console.log(today.toDateString()); // Fri Jan 09 2026

// 我想算一下明天是几号
const addDay = (theDate) => {
  theDate.setDate(theDate.getDate() + 1);
  return theDate;
};

console.log(`明天是 ${addDay(today).toLocaleDateString()}。`);
// 明天是 2026/1/10。

console.log(`今天是 ${today.toLocaleDateString()}。`);
// 今天是 2026/1/10。

// 等等,今天怎么也变成明天了?!

当然这是可以解释的:

因为 today 就像一个地址,指向内存里的某个位置。当你把 today 传给函数时,函数拿到的也是这个地址。所以当函数修改日期时,原来的 today 也被改了。

但这种设计违反了一个基本常识:日期应该是固定的。“2026 年 1 月 10 日”就是“2026 年 1 月 10 日”,不应该因为你拿它做了个计算,它自己就变了。

所以 Date 真的很糟糕。实际上,它就是挂羊头卖狗肉,它叫做 Date,表示日期,实际上,它是时间。

在内部,Date 是以数值形式存储的,这就是我们熟悉的以 1000 毫秒为单位的时间戳。

时间当然包含日期,你可以从时间中推断出日期,但这多少有点恶心了。

Java 早在 1997 年就弃用了其 Date 类,而 JavaScript 的 Date 类仅仅在几年后就问世了;与此同时,我们却一直被这个烂摊子困扰着。

正如你目前所见,它在解析日期方面极其不稳定。它除了本地时间和格林威治标准时间 (GMT) 之外,对其他时区一无所知。而且,Date 类只支持公历。它完全不理解夏令时的概念。当然最糟糕的还是它的可变的,这直接让他偏离了时间的本质。

所有这些缺陷使得使用第三方库来解决这些问题变得异常普遍,其中一些库体积庞大,这种性能损耗已经对网络造成了切实可衡量的损害。

3. Temporal 才是未来

幸运的是,Date 即将彻底退出历史舞台。

当然这样说,还是有点夸张了。

实际上是它会一直存在,但如果可以避免,你最好不要再用它了。

因为我们会有一个完全取代 Date 的对象 —— Temporal。

部分同学可能对 Temporal 这个单词不太熟悉,实际上,它的意思就是“时间”,你可以理解为它是一个更专业的词汇:

与 Date 不同,Temporal 不是构造函数,它是一个命名空间对象——一个由静态属性和方法组成的普通对象,就像 Math 对象一样:

console.log(Temporal);
/* Result (expanded):
Temporal { … }
  Duration: function Duration()
  Instant: function Instant()
  Now: Temporal.Now { … }
  PlainDate: function PlainDate()
  PlainDateTime: function PlainDateTime()
  PlainMonthDay: function PlainMonthDay()
  PlainTime: function PlainTime()
  PlainYearMonth: function PlainYearMonth()
  ZonedDateTime: function ZonedDateTime()
  Symbol(Symbol.toStringTag): "Temporal"
*/

Temporal 包含的类和命名空间对象允许你计算两个时间点之间的持续时间、表示一个时间点(无论是否具有时区信息)、通过 Now 属性访问当前时间点等。

如果我们要获取当前时间:

console.log(Temporal.Now.plainDateISO());
/* Result (expanded):
Temporal.PlainDate 2025-12-31
  <prototype>: Object { … }
*/

该方法返回的是当前时区的今天日期。

Temporal 还能支持时区:

const date = Temporal.Now.plainDateISO();

// 指定这个日期在伦敦时区
console.log(date.toZonedDateTime("Europe/London"));

Temporal 还可以计算日期差:

const today = Temporal.Now.plainDateISO();
const jsShipped = Temporal.PlainDate.from("1995-12-04"); // JavaScript 发布日期
const difference = today.since(jsShipped, { largestUnit: "year" });

console.log(`JavaScript 已经存在了 ${difference.years}${difference.months} 个月零 ${difference.days} 天。`);

各种时间操作也会更加直观:

const today = Temporal.Now.plainDateISO();

// 加一天
console.log(today.add({ days: 1 }));

// 加一个月零一天,再减两年——可以链式操作
console.log(today.add({ months: 1, days: 1 }).subtract({ years: 2 }));

// 看,多清楚!

当然,更重要的是,日期不会被意外修改

const today = Temporal.Now.plainDateISO();

// 计算明天的日期
const tomorrow = today.add({ days: 1 });

console.log(`今天是 ${today}。`); // 2025-12-31
console.log(`明天是 ${tomorrow}。`); // 2026-01-01

// 今天还是今天,完美!

add 方法会返回一个新的日期对象,而不是修改原来的。就像你复印了一份日历,在复印件上写字,原件不会被弄脏。

4. 什么时候能用?

好消息:最新版的 Chrome 和 Firefox 已经支持了!

坏消息:它还在“实验阶段”,这意味着具体用法可能还会微调,但大方向已定。

我们终于要和 Date 的噩梦说再见了。

我是冴羽,10 年笔耕不辍,专注前端领域,更新了 10+ 系列、300+ 篇原创技术文章,翻译过 Svelte、Solid.js、TypeScript 文档,著有小册《Next.js 开发指南》、《Svelte 开发指南》、《Astro 实战指南》。

欢迎围观我的“网页版朋友圈”,关注我的公众号:冴羽(或搜索 yayujs) ,每天分享前端知识、AI 干货。

❌
❌