阅读视图

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

深入理解 reduce:从面试题到设计思维

在准备前端面试的过程中,我发现一个有趣的现象:刷题时遇到的很多问题都能用 reduce 优雅地解决,但回想自己的实际项目经历,却几乎没有直接使用过它。这种"面试高频、项目冷门"的反差让我开始重新审视这个数组方法——它究竟只是个语法糖,还是代表着某种更深层的编程思维?

这篇文章记录了我重新学习 reduce 的过程。我想探讨的不只是"怎么用",更是"为什么用"以及"什么时候该想到它"。

为什么要重新认识 reduce

面试高频 vs 项目冷门的现象

翻看 LeetCode 和各种面试题库,reduce 的身影无处不在:

  • 数组求和、求积
  • 数组扁平化
  • 实现 mapfilter
  • 函数组合 (compose/pipe)
  • 对象转换、分组

但在实际项目中,我更习惯用 for 循环、mapfilter,甚至是 forEach。这是为什么?

我的反思是:可能并不是 reduce 不好用,而是我还没有建立起使用它的心智模型。就像刚学编程时,明明知道函数很重要,却还是习惯把所有代码写在一个文件里一样。

reduce 真正的价值

经过一段时间的研究,我逐渐意识到:reduce 不只是众多数组方法中的一个,它更像是一种数据转换的思维范式

当我们使用 map 时,我们在说:"把数组中的每个元素转换一下"。
当我们使用 filter 时,我们在说:"筛选出符合条件的元素"。
当我们使用 reduce 时,我们在说:

"把整个数组归约成另一种形态"。

这种"形态转换"的视角,让我看到了更多可能性。

本文目标

这篇文章希望达到三个目标:

  1. 理解原理reduce 到底在做什么?它的执行流程是怎样的?
  2. 建立思维: 什么样的问题适合用 reduce 解决?如何培养这种直觉?
  3. 实战应用: 从面试题到实际场景,如何灵活运用?

reduce 的工作原理

核心概念:累加器的演变

reduce 方法的核心在于累加器 (accumulator) 的概念。想象一个累加过程:

// 环境: 浏览器 / Node.js
// 场景: 理解 reduce 的基本执行流程

const numbers = [12345];

// 传统方式: 用 for 循环累加
let sum = 0; // 初始累加器
for (let i = 0; i < numbers.length; i++) {
  sum = sum + numbers[i]; // 更新累加器
}
console.log(sum); // 15

// reduce 方式
const sum2 = numbers.reduce((acc, curr) => acc + curr, 0);
console.log(sum2); // 15

这两种方式在逻辑上是等价的。reduce 做的事情就是:

  1. 提供一个初始值 (累加器的起点)
  2. 对数组中的每个元素,执行一个函数来更新累加器
  3. 返回最终的累加器值

reduce 的优势在于:它是声明式的。我们描述了"做什么"(把所有元素加起来),而不是"怎么做"(逐个遍历、累加)。

参数拆解: reducer 函数的四个参数

reduce 的完整签名是:

array.reduce(callback(accumulator, currentValue, currentIndex, array), initialValue)

让我们逐个理解这些参数:

// 环境: 浏览器 / Node.js
// 场景: 完整的 reduce 参数演示

const fruits = ['apple''banana''cherry'];

const result = fruits.reduce(
  (acc, curr, index, arr) => {
    console.log({
      iteration: index + 1,
      accumulator: acc,
      currentValue: curr,
      currentIndex: index,
      originalArray: arr
    });
    
    // 返回新的累加器值
    return acc + curr.length;
  },
  0 // 初始值
);

console.log('Final result:', result); // 18

/*
输出:
{ iteration: 1, accumulator: 0, currentValue: 'apple', currentIndex: 0, ... }
{ iteration: 2, accumulator: 5, currentValue: 'banana', currentIndex: 1, ... }
{ iteration: 3, accumulator: 11, currentValue: 'cherry', currentIndex: 2, ... }
Final result: 18
*/

参数说明

  • accumulator (acc): 累加器,保存每次迭代的中间结果
  • currentValue (curr): 当前正在处理的元素
  • currentIndex (index): 当前元素的索引 (可选,不常用)
  • array (arr): 原始数组 (可选,几乎不用)

大多数情况下,我们只需要前两个参数。

执行流程可视化

让我用一个更直观的例子来展示 reduce 的执行流程:

// 环境: 浏览器 / Node.js
// 场景: 购物车总价计算

const cart = [
  { name: 'book', price: 30 },
  { name: 'pen', price: 5 },
  { name: 'bag', price: 80 }
];

const total = cart.reduce((acc, item) => {
  console.log(`Current total: ${acc}, adding ${item.name} (${item.price})`);
  return acc + item.price;
}, 0);

console.log('Total:', total); // 115

/*
执行流程:
初始状态: acc = 0

第 1 次迭代:
  - 当前商品: { name: 'book', price: 30 }
  - acc = 0 + 30 = 30

第 2 次迭代:
  - 当前商品: { name: 'pen', price: 5 }
  - acc = 30 + 5 = 35

第 3 次迭代:
  - 当前商品: { name: 'bag', price: 80 }
  - acc = 35 + 80 = 115

返回最终的 acc: 115
*/

可以看到,reduce 其实是在不断"滚雪球":从一个初始值开始,每次迭代都基于上次的结果继续累积。

初始值的重要性

这是一个容易被忽视但很重要的点:初始值可以不提供

// 环境: 浏览器 / Node.js
// 场景: 有无初始值的区别

const numbers = [1234];

// 有初始值 (推荐)
const sum1 = numbers.reduce((acc, curr) => acc + curr, 0);
console.log(sum1); // 10

// 无初始值: 第一个元素作为初始值,从第二个元素开始迭代
const sum2 = numbers.reduce((acc, curr) => acc + curr);
console.log(sum2); // 10

// 看似结果相同,但有个陷阱:
const emptyArray = [];

// 有初始值: 正常返回 0
const safeSum = emptyArray.reduce((acc, curr) => acc + curr, 0);
console.log(safeSum); // 0

// 无初始值: 抛出错误!
try {
  const unsafeSum = emptyArray.reduce((acc, curr) => acc + curr);
} catch (error) {
  console.error('Error:', error.message); 
  // TypeError: Reduce of empty array with no initial value
}

关键点

  • 不提供初始值时,reduce 会用数组的第一个元素作为初始值
  • 这在处理空数组时会报错
  • 建议总是提供初始值,让代码更健壮

另一个微妙之处:初始值的类型决定了最终结果的类型。

// 环境: 浏览器 / Node.js
// 场景: 初始值类型影响最终结果

const numbers = [123];

// 初始值是数字
const sum = numbers.reduce((acc, curr) => acc + curr, 0);
console.log(sum); // 6 (number)

// 初始值是字符串
const str = numbers.reduce((acc, curr) => acc + curr, '');
console.log(str); // '123' (string)

// 初始值是数组
const doubled = numbers.reduce((acc, curr) => {
  acc.push(curr * 2);
  return acc;
}, []);
console.log(doubled); // [2, 4, 6]

// 初始值是对象
const stats = numbers.reduce((acc, curr) => {
  acc.sum += curr;
  acc.count += 1;
  return acc;
}, { sum: 0, count: 0 });
console.log(stats); // { sum: 6, count: 3 }

这就引出了 reduce 的一个强大特性:它可以把数组转换成任何数据结构——数字、字符串、对象、甚至另一个数组。

reduce 的设计哲学

声明式编程:描述"做什么"而非"怎么做"

当我刚开始学编程时,我的思维是"命令式"的:

// 命令式思维: 告诉计算机每一步怎么做
function getAdults(users) {
  const result = [];
  for (let i = 0; i < users.length; i++) {
    if (users[i].age >= 18) {
      result.push(users[i].name);
    }
  }
  return result;
}

reduce (以及其他函数式方法) 鼓励我们用"声明式"思维:

// 声明式思维: 描述我想要什么
function getAdults(users) {
  return users
    .filter(user => user.age >= 18)
    .map(user => user.name);
}

两者的区别在于:抽象层次。声明式代码更接近"我想要成年用户的名字",而命令式代码更像"先创建空数组,然后遍历,如果年龄大于等于 18..."。

reduce 把这种声明式思维推向了极致:我们只需要描述"如何从一个值变成下一个值",剩下的交给方法本身。

数据转换思维:输入形态 → 输出形态

使用 reduce 的关键在于:清晰地定义输入和输出的形态

让我举个例子:

// 环境: 浏览器 / Node.js
// 场景: 将用户数组转换为按年龄分组的对象

const users = [
  { name: 'Alice', age: 25 },
  { name: 'Bob', age: 30 },
  { name: 'Charlie', age: 25 },
  { name: 'David', age: 30 }
];

// 思考过程:
// 输入: Array<User>
// 输出: { [age]: Array<User> }
// 初始值: {} (空对象)

const grouped = users.reduce((acc, user) => {
  const age = user.age;
  
  // 如果这个年龄还没有对应的数组,创建一个
  if (!acc[age]) {
    acc[age] = [];
  }
  
  // 把用户添加到对应年龄的数组中
  acc[age].push(user);
  
  return acc;
}, {});

console.log(grouped);
/*
{
  25: [
    { name: 'Alice', age: 25 },
    { name: 'Charlie', age: 25 }
  ],
  30: [
    { name: 'Bob', age: 30 },
    { name: 'David', age: 30 }
  ]
}
*/

这个例子展示了典型的 reduce 思维:

  1. 明确输入形态:数组
  2. 明确输出形态:对象
  3. 选择合适的初始值:空对象
  4. 定义转换规则:根据年龄分组

当我开始用这种方式思考问题时,很多复杂的数据处理突然变得清晰了。

为什么说 reduce 是最底层的抽象

这是一个很有趣的发现:我们可以用 reduce 来实现 mapfilter 等其他数组方法。

// 环境: 浏览器 / Node.js
// 场景: 用 reduce 实现其他数组方法

// 1. 实现 map
Array.prototype.myMap = function(callback) {
  return this.reduce((acc, curr, index) => {
    acc.push(callback(curr, index));
    return acc;
  }, []);
};

const doubled = [123].myMap(x => x * 2);
console.log(doubled); // [2, 4, 6]

// 2. 实现 filter
Array.prototype.myFilter = function(callback) {
  return this.reduce((acc, curr, index) => {
    if (callback(curr, index)) {
      acc.push(curr);
    }
    return acc;
  }, []);
};

const evens = [1234].myFilter(x => x % 2 === 0);
console.log(evens); // [2, 4]

// 3. 实现 find
Array.prototype.myFind = function(callback) {
  return this.reduce((acc, curr) => {
    // 如果已经找到,直接返回
    if (acc !== undefined) return acc;
    // 否则检查当前元素
    return callback(curr) ? curr : undefined;
  }, undefined);
};

const firstEven = [1234].myFind(x => x % 2 === 0);
console.log(firstEven); // 2

这说明什么?

reduce 是一种更通用的抽象mapfilter 都是它的特例:

  • map: 把数组转换成另一个等长的数组
  • filter: 把数组转换成长度可能更小的数组
  • reduce: 把数组转换成任何东西

从这个角度看,reduce 代表的是"归约"这个更本质的概念。

与 map/filter 的关系

那是不是说我们应该用 reduce 替代所有其他方法?并不是。

我的理解是:

  • mapfilter 表达的是特定意图,代码可读性更好
  • reduce 更加通用,但也更抽象,可能降低可读性
  • 选择合适的工具取决于具体场景
// 环境: 浏览器 / Node.js
// 场景: 可读性对比

const numbers = [12345];

// 方案 A: 链式调用 (推荐,意图清晰)
const result1 = numbers
  .filter(x => x % 2 === 0)  // 我想要偶数
  .map(x => x * 2);          // 我想要它们的两倍

// 方案 B: 单一 reduce (更高效,但意图不够清晰)
const result2 = numbers.reduce((acc, x) => {
  if (x % 2 === 0) {
    acc.push(x * 2);
  }
  return acc;
}, []);

console.log(result1); // [4, 8]
console.log(result2); // [4, 8]

在大多数情况下,我会选择方案 A,因为可读性 > 微小的性能差异。但当链式调用导致多次遍历,且性能成为瓶颈时,单一的 reduce 可能是更好的选择。

典型应用场景

理解了原理和哲学,让我们看看 reduce 在实际场景中如何应用。

场景 1: 数据聚合

这是 reduce 最常见的用途:把一组数据聚合成单个值。

// 环境: 浏览器 / Node.js
// 场景: 订单统计

const orders = [
  { id: 1, amount: 100, status: 'completed' },
  { id: 2, amount: 200, status: 'pending' },
  { id: 3, amount: 150, status: 'completed' },
  { id: 4, amount: 300, status: 'completed' }
];

// 1. 求总金额
const total = orders.reduce((sum, order) => sum + order.amount0);
console.log('Total:', total); // 750

// 2. 求已完成订单的金额
const completedTotal = orders.reduce((sum, order) => {
  return order.status === 'completed' ? sum + order.amount : sum;
}, 0);
console.log('Completed:', completedTotal); // 550

// 3. 求最大金额订单
const maxOrder = orders.reduce((max, order) => {
  return order.amount > max.amount ? order : max;
});
console.log('Max order:', maxOrder); // { id: 4, amount: 300, ... }

// 4. 一次遍历获取多个统计信息
const stats = orders.reduce((acc, order) => {
  acc.total += order.amount;
  acc.count += 1;
  if (order.status === 'completed') {
    acc.completed += 1;
  }
  return acc;
}, { total: 0, count: 0, completed: 0 });

console.log('Stats:', stats);
// { total: 750, count: 4, completed: 3 }

第 4 个例子展示了 reduce 的一个优势:一次遍历完成多项统计。如果分开计算,就需要多次遍历数组。

场景 2: 数据重组

reduce 可以把数组转换成对象,这在很多场景下非常有用。

// 环境: 浏览器 / Node.js
// 场景: 构建查找表 (lookup table)

const products = [
  { id: 'p1', name: 'Laptop', price: 1000 },
  { id: 'p2', name: 'Mouse', price: 50 },
  { id: 'p3', name: 'Keyboard', price: 80 }
];

// 1. 按 id 索引 (常用于快速查找)
const productsById = products.reduce((acc, product) => {
  acc[product.id] = product;
  return acc;
}, {});

console.log(productsById['p2']);
// { id: 'p2', name: 'Mouse', price: 50 }

// 2. 按价格区间分组
const priceRanges = products.reduce((acc, product) => {
  const range = product.price < 100'cheap''expensive';
  if (!acc[range]) {
    acc[range] = [];
  }
  acc[range].push(product);
  return acc;
}, {});

console.log(priceRanges);
/*
{
  expensive: [{ id: 'p1', name: 'Laptop', price: 1000 }],
  cheap: [
    { id: 'p2', name: 'Mouse', price: 50 },
    { id: 'p3', name: 'Keyboard', price: 80 }
  ]
}
*/

// 3. 数组去重 (利用对象的 key 唯一性)
const numbers = [1223334];
const unique = Object.keys(
  numbers.reduce((acc, num) => {
    acc[num] = true;
    return acc;
  }, {})
).map(Number);

console.log(unique); // [1, 2, 3, 4]

这些转换在实际开发中非常常见,比如:

  • 从 API 获取数组数据,转换成对象以便快速查找
  • 对数据进行分组、分类
  • 去重、去除无效数据

场景 3: 数据扁平化

扁平化是面试题的常客,用 reduce 实现很自然。

// 环境: 浏览器 / Node.js
// 场景: 多维数组扁平化

// 1. 二维数组扁平化
const nested2D = [[12], [34], [5]];

const flat2D = nested2D.reduce((acc, arr) => {
  return acc.concat(arr);
}, []);

console.log(flat2D); // [1, 2, 3, 4, 5]

// 2. 多维数组扁平化 (递归)
function flattenDeep(arr) {
  return arr.reduce((acc, item) => {
    // 如果是数组,递归扁平化
    if (Array.isArray(item)) {
      return acc.concat(flattenDeep(item));
    }
    // 否则直接添加
    return acc.concat(item);
  }, []);
}

const nested = [1, [2, [3, [4]], 5]];
console.log(flattenDeep(nested)); // [1, 2, 3, 4, 5]

// 3. 对象数组中的嵌套数组扁平化
const data = [
  { id: 1, tags: ['js''react'] },
  { id: 2, tags: ['css''html'] },
  { id: 3, tags: ['js''vue'] }
];

const allTags = data.reduce((acc, item) => {
  return acc.concat(item.tags);
}, []);

console.log(allTags);
// ['js', 'react', 'css', 'html', 'js', 'vue']

// 去重后的所有标签
const uniqueTags = [...new Set(allTags)];
console.log(uniqueTags);
// ['js', 'react', 'css', 'html', 'vue']

值得一提的是,现代 JavaScript 提供了原生的 flat() 方法,但理解如何用 reduce 实现它,有助于加深对 reduce 的理解。

场景 4: 函数组合 (compose/pipe)

这是一个更高级的场景,但在函数式编程中非常重要。

// 环境: 浏览器 / Node.js
// 场景: 实现函数组合工具

// 1. compose: 从右到左执行函数
// compose(f, g, h)(x) === f(g(h(x)))
const compose = (...fns) => {
  return (initialValue) => {
    return fns.reduceRight((acc, fn) => fn(acc), initialValue);
  };
};

// 2. pipe: 从左到右执行函数  
// pipe(f, g, h)(x) === h(g(f(x)))
const pipe = (...fns) => {
  return (initialValue) => {
    return fns.reduce((acc, fn) => fn(acc), initialValue);
  };
};

// 示例:数据处理管道
const double = x => x * 2;
const addTen = x => x + 10;
const square = x => x * x;

// 使用 pipe (更符合阅读习惯)
const transform = pipe(double, addTen, square);
console.log(transform(5)); // ((5 * 2) + 10) ^ 2 = 400

// 使用 compose (数学函数的传统写法)
const transform2 = compose(square, addTen, double);
console.log(transform2(5)); // 同样是 400

// 实际场景:用户数据处理
const users = [
  { name: 'alice', age: 17, active: true },
  { name: 'bob', age: 25, active: false },
  { name: 'charlie', age: 30, active: true }
];

const processUsers = pipe(
  users => users.filter(u => u.active),      // 只要活跃用户
  users => users.filter(u => u.age >= 18),   // 只要成年用户
  users => users.map(u => u.name),           // 只要名字
  names => names.map(n => n.toUpperCase())   // 转大写
);

console.log(processUsers(users)); // ['CHARLIE']

虽然在日常开发中我们可能不会频繁使用 compose/pipe,但这个例子展示了 reduce 作为一种抽象工具的强大之处。

场景 5: 异步场景中的 reduce

这是一个比较进阶但很实用的技巧:用 reduce 串行执行异步操作。

// 环境: Node.js / 浏览器
// 场景: 串行执行 Promise

// 假设我们有一组需要顺序执行的异步任务
const tasks = [
  () => new Promise(resolve => {
    setTimeout(() => {
      console.log('Task 1 done');
      resolve(1);
    }, 1000);
  }),
  () => new Promise(resolve => {
    setTimeout(() => {
      console.log('Task 2 done');
      resolve(2);
    }, 500);
  }),
  () => new Promise(resolve => {
    setTimeout(() => {
      console.log('Task 3 done');
      resolve(3);
    }, 800);
  })
];

// 使用 reduce 串行执行
async function runSequentially(tasks) {
  return tasks.reduce(async (previousPromise, currentTask) => {
    // 等待上一个任务完成
    const results = await previousPromise;
    // 执行当前任务
    const result = await currentTask();
    // 累积结果
    return [...results, result];
  }, Promise.resolve([]));
}

// 执行
runSequentially(tasks).then(results => {
  console.log('All tasks done:', results);
  // 输出顺序: Task 1 done, Task 2 done, Task 3 done
  // All tasks done: [1, 2, 3]
});

// 对比:如果用 Promise.all (并行执行)
// Promise.all(tasks.map(task => task())).then(results => {
//   console.log('All tasks done:', results);
//   // 输出顺序可能是: Task 2 done, Task 3 done, Task 1 done
// });

这个技巧在需要按顺序处理一系列异步操作时非常有用,比如:

  • 按顺序上传多个文件
  • 按顺序执行多个 API 请求 (每个请求依赖前一个的结果)
  • 数据库的顺序迁移操作

进阶技巧

处理异步:串行执行 Promise

在上面的场景 5 中我们已经看到了一个例子,让我再展开一些变体:

// 环境: Node.js / 浏览器
// 场景: 更复杂的异步串行处理

// 1. 每个任务依赖前一个任务的结果
const steps = [
  async (prev) => {
    console.log('Step 1, prev:', prev);
    return prev + 1;
  },
  async (prev) => {
    console.log('Step 2, prev:', prev);
    return prev * 2;
  },
  async (prev) => {
    console.log('Step 3, prev:', prev);
    return prev + 10;
  }
];

async function pipeline(steps, initialValue) {
  return steps.reduce(async (prevPromise, step) => {
    const prevValue = await prevPromise;
    return step(prevValue);
  }, Promise.resolve(initialValue));
}

pipeline(steps, 0).then(result => {
  console.log('Final result:', result);
  // Step 1, prev: 0 => 1
  // Step 2, prev: 1 => 2
  // Step 3, prev: 2 => 12
  // Final result: 12
});

// 2. 带错误处理的版本
async function pipelineWithErrorHandling(steps, initialValue) {
  return steps.reduce(async (prevPromise, step, index) => {
    try {
      const prevValue = await prevPromise;
      return await step(prevValue);
    } catch (error) {
      console.error(`Error at step ${index}:`, error.message);
      throw error; // 或者根据需求决定是否继续
    }
  }, Promise.resolve(initialValue));
}

性能考量:什么时候不该用 reduce

虽然 reduce 很强大,但并非万能。在某些情况下,使用它可能不是最佳选择:

// 环境: 浏览器 / Node.js
// 场景: 性能对比

const largeArray = Array.from({ length: 100000 }, (_, i) => i);

// 场景 1: 简单的求和
console.time('for loop');
let sum1 = 0;
for (let i = 0; i < largeArray.length; i++) {
  sum1 += largeArray[i];
}
console.timeEnd('for loop'); // 通常最快

console.time('reduce');
const sum2 = largeArray.reduce((acc, num) => acc + num, 0);
console.timeEnd('reduce'); // 稍慢,但差异不大

// 场景 2: 需要提前退出的情况
console.time('for with break');
let found1 = null;
for (let i = 0; i < largeArray.length; i++) {
  if (largeArray[i] === 50000) {
    found1 = largeArray[i];
    break; // 可以提前退出
  }
}
console.timeEnd('for with break');

console.time('reduce no early exit');
const found2 = largeArray.reduce((acc, num) => {
  if (acc !== null) return acc; // 模拟提前退出,但仍会遍历所有元素
  return num === 50000 ? num : null;
}, null);
console.timeEnd('reduce no early exit'); // 无法真正提前退出,性能较差

// 场景 3: find 比 reduce 更合适
console.time('find');
const found3 = largeArray.find(num => num === 50000);
console.timeEnd('find'); // 可以提前退出,性能好

我的建议

  1. 对于简单的求和、求积,性能差异可以忽略,优先考虑可读性
  2. 需要提前退出的场景,不要用 reduce,用 for 循环或 find/some 等方法
  3. 不要为了用 reduce 而用 reduce,选择最适合表达意图的方法

可读性平衡:复杂场景下的取舍

reduce 的逻辑变得复杂时,可读性可能成为问题:

// 环境: 浏览器 / Node.js
// 场景: 复杂的 reduce vs 多步骤处理

const transactions = [
  { type: 'income', amount: 1000, category: 'salary' },
  { type: 'expense', amount: 200, category: 'food' },
  { type: 'expense', amount: 300, category: 'transport' },
  { type: 'income', amount: 500, category: 'bonus' }
];

// 方案 A: 单一复杂的 reduce (不推荐)
const summary1 = transactions.reduce((acc, tx) => {
  if (tx.type === 'income') {
    acc.income += tx.amount;
    if (!acc.incomeByCategory[tx.category]) {
      acc.incomeByCategory[tx.category] = 0;
    }
    acc.incomeByCategory[tx.category] += tx.amount;
  } else {
    acc.expense += tx.amount;
    if (!acc.expenseByCategory[tx.category]) {
      acc.expenseByCategory[tx.category] = 0;
    }
    acc.expenseByCategory[tx.category] += tx.amount;
  }
  acc.balance = acc.income - acc.expense;
  return acc;
}, { 
  income: 0, 
  expense: 0, 
  balance: 0,
  incomeByCategory: {},
  expenseByCategory: {}
});

// 方案 B: 分步处理 (推荐)
const income = transactions
  .filter(tx => tx.type === 'income')
  .reduce((sum, tx) => sum + tx.amount0);

const expense = transactions
  .filter(tx => tx.type === 'expense')
  .reduce((sum, tx) => sum + tx.amount0);

const summary2 = {
  income,
  expense,
  balance: income - expense
};

console.log(summary2); // 更清晰

我的权衡原则:

  • 如果 reduce 的回调函数超过 5-7 行,考虑拆分或用其他方法
  • 如果需要嵌套的条件判断,可能不适合用 reduce
  • 优先考虑代码的可维护性,而非炫技

常见陷阱与调试技巧

在使用 reduce 时,我遇到过一些容易犯的错误:

// 环境: 浏览器 / Node.js
// 场景: 常见错误示例

// 陷阱 1: 忘记返回 accumulator
const wrong1 = [123].reduce((acc, num) => {
  acc.push(num * 2);
  // 忘记 return acc!
}, []);
console.log(wrong1); // undefined

// 正确做法
const correct1 = [123].reduce((acc, num) => {
  acc.push(num * 2);
  return acc; // 必须返回
}, []);

// 陷阱 2: 意外修改了原始对象
const data = { count: 0 };
const result = [123].reduce((acc, num) => {
  acc.count += num;
  return acc;
}, data); // 使用了外部对象作为初始值

console.log(data.count); // 6 - 原始对象被修改了!

// 正确做法:使用新对象
const correct2 = [123].reduce((acc, num) => {
  acc.count += num;
  return acc;
}, { count: 0 }); // 使用新对象

// 陷阱 3: 在 reduce 中使用 push 但期望得到新数组
const original = [123];
const result3 = original.reduce((acc, num) => {
  acc.push(num * 2);
  return acc;
}, []); // 虽然初始值是新数组,但每次都在修改同一个数组

// 如果需要不可变性,使用 concat
const immutable = original.reduce((acc, num) => {
  return acc.concat(num * 2);
}, []);

调试技巧

// 在 reducer 函数中添加日志
const debugReduce = [123].reduce((acc, num, index) => {
  console.log({
    iteration: index,
    current: num,
    accumulator: acc,
    returned: acc + num
  });
  return acc + num;
}, 0);

建立自己的 reduce 思维

识别模式:什么问题适合用 reduce

经过一段时间的学习和实践,我总结了一些"信号",提示我可能需要用 reduce

强信号 (很可能适合):

  1. 需要把数组"聚合"成单个值 (求和、求积、最值)
  2. 需要把数组转换成对象 (索引、分组)
  3. 需要累积一个复杂的状态 (计数器、统计信息)
  4. 需要扁平化嵌套结构
  5. 需要函数组合或管道处理

弱信号 (可能适合,但有其他选择):

  1. 需要转换数组 → 考虑 map 是否更清晰
  2. 需要过滤数组 → 考虑 filter 是否更清晰
  3. 需要查找元素 → 考虑 findsomeevery

反向信号 (可能不适合):

  1. 需要提前退出循环
  2. 逻辑非常复杂,嵌套层级深
  3. 团队成员对函数式编程不熟悉 (可读性第一)

思考框架:如何设计 reducer 函数

当我确定要用 reduce 后,我通常按这个步骤思考:

Step 1: 明确输入和输出

输入: [1, 2, 3, 4]
输出: 10

Step 2: 选择初始值

初始值: 0 (因为我要求和,0 是加法的单位元)

Step 3: 定义转换规则

每次迭代: 累加器 + 当前元素 = 新累加器

Step 4: 写成代码

[1234].reduce((acc, curr) => acc + curr, 0)

让我用一个更复杂的例子演示这个思考过程:

// 环境: 浏览器 / Node.js
// 场景: 统计单词出现次数

const text = 'hello world hello javascript world';
const words = text.split(' ');
// ['hello', 'world', 'hello', 'javascript', 'world']

// Step 1: 明确输入输出
// 输入: Array<string>
// 输出: { [word]: count }

// Step 2: 选择初始值
// 初始值: {} (空对象,用于存储单词和计数)

// Step 3: 定义转换规则
// 每次迭代:
//   - 如果单词已存在,计数 +1
//   - 如果单词不存在,设置为 1

// Step 4: 实现
const wordCount = words.reduce((acc, word) => {
  acc[word] = (acc[word] || 0) + 1;
  return acc;
}, {});

console.log(wordCount);
// { hello: 2, world: 2, javascript: 1 }

从面试题到实际项目的迁移

在刷题过程中,我发现很多 reduce 的技巧可以直接应用到实际项目中:

面试题场景 → 实际项目场景

面试题 实际场景
数组求和 购物车总价计算
数组转对象 API 数据索引优化
数组扁平化 处理嵌套的评论/回复数据
函数组合 (compose) 数据处理管道、中间件链
异步串行执行 文件上传、数据库迁移
按条件分组 数据可视化、报表生成
// 环境: React 项目
// 场景: 购物车总价计算 (实际项目例子)

// 购物车数据结构
const cartItems = [
  { id: 1, name: 'Book', price: 30, quantity: 2 },
  { id: 2, name: 'Pen', price: 5, quantity: 10 },
  { id: 3, name: 'Bag', price: 80, quantity: 1 }
];

// 计算总价 (考虑数量和折扣)
const calculateTotal = (items, discountRate = 0) => {
  const subtotal = items.reduce((sum, item) => {
    return sum + (item.price * item.quantity);
  }, 0);
  
  return subtotal * (1 - discountRate);
};

console.log(calculateTotal(cartItems)); // 190
console.log(calculateTotal(cartItems, 0.1)); // 171 (打9折)

// 在 React 组件中使用
function ShoppingCart({ items }) {
  const total = items.reduce((sum, item) => 
    sum + item.price * item.quantity0
  );
  
  return (
    <div>
      <h2>Total: ${total}</h2>
    </div>
  );
}

持续练习的建议

我的学习方法:

  1. 刷题时有意识地练习 :每次遇到可以用 reduce 解决的问题,先用 reduce 实现一遍,即使有更简单的方法

  2. 重构已有代码 :回顾项目中的循环逻辑,看看哪些可以用 reduce 改写

  3. 阅读优秀代码 :看看 Redux、Lodash 等库中 reduce 的使用方式

  4. 写博客总结 :就像我现在做的,把学到的东西写出来,加深理解

  5. 小项目实践 :试着用 reduce 实现一些工具函数:

    • 深拷贝
    • 对象 merge
    • 路径取值 (get、set)
    • 简单的状态管理

延伸与发散

在研究 reduce 的过程中,我产生了一些新的思考:

reduce 与函数式编程

reduce 其实来自函数式编程中的 fold 操作。在 Haskell、OCaml 等语言中,fold 是一个核心概念。这让我意识到:学习 reduce 不只是学一个数组方法,更是在学习一种编程范式

函数式编程的一些核心思想:

  • 不可变性 :每次返回新值,而不是修改旧值
  • 纯函数 :相同输入总是产生相同输出,无副作用
  • 声明式 :描述"做什么",而非"怎么做"

这些思想在现代前端开发中越来越重要,特别是在使用 React、Redux 等框架时。

reduce 在状态管理中的应用

Redux 的核心概念正是基于 reduce

// Redux 的 reducer 本质上就是一个 reduce 操作
function todosReducer(state = [], action) {
  switch (action.type) {
    case 'ADD_TODO'return [...state, action.payload];
    case 'REMOVE_TODO'return state.filter(todo => todo.id !== action.payload);
    defaultreturn state;
  }
}

// 实际上就是:
const finalState = actions.reduce(todosReducer, initialState);

理解 reduce 有助于理解 Redux 的设计哲学:状态是不可变的,每次操作都产生新状态。

相关技术的对比

在学习 reduce 时,我也了解了一些相关的概念:

  • Array.prototype.reduceRight :从右往左 reduce,用于 compose 函数
  • Observable.reduce (RxJS):在响应式编程中的应用
  • Stream.reduce (Node.js):在流处理中的应用

这些概念虽然语法不同,但核心思想是一致的:把一系列值归约成单个值。

未来可能的演进

JavaScript 还在不断演进,可能未来会有更多与 reduce 相关的特性:

  • Pipeline Operator (|>):让函数组合更自然
  • Pattern Matching:让条件分支更简洁
  • Records & Tuples:不可变数据结构的原生支持

这些提案都与 reduce 的思想相关,值得持续关注。

我的困惑与疑问

在学习过程中,我还有一些未解的疑问:

  1. 性能优化的临界点:在什么规模的数据下,reduce 的性能劣势会明显?

  2. 可读性的度量:如何量化"可读性"?如何在团队中达成共识?

  3. 初学者友好性reduce 对新手来说确实比较抽象,如何更好地教学?

  4. 最佳实践的边界:什么情况下"过度使用 reduce"?如何把握这个度?

这些问题可能没有标准答案,但思考它们本身就很有价值。

小结

写完这篇文章,我对 reduce 有了更深的理解。它不仅仅是一个数组方法,更是一种归约思维的体现。

这个学习过程让我意识到:

  • 工具的价值不在于它有多强大,而在于我们是否真正理解并掌握了它
  • 很多时候"不会用"不是因为方法不好,而是缺少合适的心智模型
  • 从面试题到实际应用,需要的是迁移能力识别模式的直觉

我现在还不能说自己完全掌握了 reduce,但至少建立了一个思考框架。接下来的计划是:

  1. 在项目中有意识地寻找 reduce 的应用场景
  2. 尝试用 reduce 重构一些旧代码,观察效果
  3. 继续研究函数式编程的其他概念

如果你也在学习 reduce,或者有不同的理解和经验,欢迎交流。学习是一个持续迭代的过程,这篇文章只是我的一个阶段性总结。

最后,引用一句话:

"Simplicity is the ultimate sophistication." — Leonardo da Vinci

reduce 的美,或许就在于它用一个简单的概念,表达了复杂的转换过程。

参考资料

gsap -滚动插件 ScrollTrigger 简单demo

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>GSAP Scroll Demo</title>
    <style>
      body {
        margin: 0;
        font-family: "Segoe UI", sans-serif;
        background: #0b1020;
        color: #e2e8f0;
      }
      section {
        height: 100vh;
        position: relative;
        overflow: hidden;
      }
      .center-stack {
        position: absolute;
        left: 50%;
        top: 50%;
        transform: translate(-50%, -50%);
        width: 80vw;
        height: 60vh;
      }
      .stack-item {
        position: absolute;
        left: 50%;
        top: 50%;
        transform: translate(-50%, -50%);
        border-radius: 18px;
        opacity: 0.95;
      }
      .stack-1 { width: 140px; height: 140px; background: #0ea5e9; }
      .stack-2 { width: 200px; height: 200px; background: #22c55e; }
      .stack-3 { width: 280px; height: 280px; background: #f59e0b; }
      .stack-4 { width: 200px; height: 200px; background: #a855f7; }
      .stack-5 { width: 140px; height: 140px; background: #ef4444; }

      .quad {
        position: absolute;
        padding: 12px 16px;
        border-radius: 10px;
        font-size: 20px;
        font-weight: 600;
        background: #111827;
      }
      .quad-up { left: 50%; top: 20%; transform: translate(-50%, 0); }
      .quad-down { left: 50%; bottom: 20%; transform: translate(-50%, 0); }
      .quad-left { left: 12%; top: 50%; transform: translate(0, -50%); }
      .quad-right { right: 12%; top: 50%; transform: translate(0, -50%); }

      .list {
        position: absolute;
        left: 10%;
        top: 50%;
        transform: translateY(-50%);
        display: flex;
        flex-direction: column;
        gap: 16px;
      }
      .list-item {
        width: 140px;
        padding: 12px 16px;
        border-radius: 10px;
        background: #111827;
        font-size: 16px;
        font-weight: 600;
      }
    </style>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/ScrollTrigger.min.js"></script>
</head>
<body>
    <section id="view-one">
      <div class="center-stack">
        <div class="stack-item stack-1" id="stack-1"></div>
        <div class="stack-item stack-2" id="stack-2"></div>
        <div class="stack-item stack-3" id="stack-3"></div>
        <div class="stack-item stack-4" id="stack-4"></div>
        <div class="stack-item stack-5" id="stack-5"></div>
      </div>
    </section>

    <section id="view-two">
      <div class="quad quad-up" id="quad-up">向上</div>
      <div class="quad quad-down" id="quad-down">向下</div>
      <div class="quad quad-left" id="quad-left">向左</div>
      <div class="quad quad-right" id="quad-right">向右</div>
    </section>

    <section id="view-three">
      <div class="list">
        <div class="list-item" id="list-1">第一个</div>
        <div class="list-item" id="list-2">第二个</div>
        <div class="list-item" id="list-3">第三个</div>
      </div>
    </section>

    <script>
      gsap.registerPlugin(ScrollTrigger);
       // 第一视图
      gsap.set(["#stack-1", "#stack-5"], { x: 0 });
      gsap.set(["#stack-2", "#stack-4"], { x: 0 });
      gsap.set("#stack-3", { x: 0 });

      gsap.timeline({
        scrollTrigger: {
          trigger: "#view-one",
          start: "top top",
          end: "+=400%",
          scrub: true,
          pin: true
        }
      })
      .to(["#stack-1", "#stack-5"],{ x:(i) => i === 0 ? -220 : 220, duration: 0.4 })
      .to(["#stack-2", "#stack-4"], { x:(i) => i === 0 ? -160 : 160, duration: 0.4 })

     // 第二视图
      gsap.set(["#quad-up", "#quad-down", "#quad-left", "#quad-right"], { opacity: 0 });
      gsap.set("#quad-up", { y: -40 });
      gsap.set("#quad-down", { y: 40 });
      gsap.set("#quad-left", { x: -40 });
      gsap.set("#quad-right", { x: 40 });

      gsap.timeline({
        scrollTrigger: {
          trigger: "#view-two",
          start: "top top",
          end: "+=400%",
          scrub: true,
          pin: true
        }
      })
      .to("#quad-up", { y: 0, opacity: 1, duration: 0.25 })
      .to("#quad-down", { y: 0, opacity: 1, duration: 0.25 })
      .to("#quad-left", { x: 0, opacity: 1, duration: 0.25 })
      .to("#quad-right", { x: 0, opacity: 1, duration: 0.25 });

     // 第三视图
      gsap.set(["#list-1", "#list-2", "#list-3"], { x: 0 });

      gsap.timeline({
        scrollTrigger: {
          trigger: "#view-three",
          start: "top top",
          end: "+=400%",
          scrub: true,
          pin: true
        }
      })
      .to("#list-1", { x: 100, duration: 0.33 })
      .to("#list-2", { x: 200, duration: 0.33 })
      .to("#list-3", { x: 300, duration: 0.33 });
    </script>
</body>
</html>

这个cdn是用官网的 如果报错 请使用魔法

scrollTrigger: { 
trigger: "#element-id", // 触发动画的元素
start: "top top", // 起始位置(元素顶部, 视口顶部)
end: "+=400%", // 结束位置(起始位置+400%视口高度)
scrub: true, // 动画进度与滚动同步
pin: true // 在动画期间固定元素 
}

效果就是

image.png

image.png

依次出现上下左右 文字

image.png

然后依次 向右移动

image.png

02 登录功能实现

1. 登录界面基础校验

1.1 基础数据的双向绑定

登录界面的element-UI组件由三层组成,最外层是el-form,中间层是el-form-item,最内层是el-form表单元素组件。

三个组件各施其职共同完成表单校验功能,具体的实现方式:在el-input组件上使用v-model指令实现双向绑定,比如:v-model="form.username"

1.2 表单校验配置

  1. 表单对象的字段命名需与接口字段保持一致,便于后续表单提交。比如,这里的username和password。

  2. 规则对象是采用对象嵌套数组的形式,比如:

rules: {
        username: [
          {
            required: true,
            message: '请输入账号',
            trigger: 'blur'
          }
        ]
      }

校验规则:required设置必填项;message校验失败提示信息;trigger触发校验的事件,常用blur失焦事件。

  1. 组件绑定 el-form绑定:需要同时绑定:model="form":rules="rules"两个属性。el-form-item配置:通过prop属性指定对应的校验规则。

1.3 总结

表单校验是通过el-formel-form-itemel-input组件联合实现表单检验。其中,el-form绑定表单对象和规则对象,el-form-item指定校验规则,el-input双向绑定数据。

完整代码:

<template>
  <div class="login_body">
    <div class="bg" />
    <div class="box">
      <div class="title">智慧园区-登录</div>
      <!-- el-form :model="表单对象" :rules="规则对象"
        el-form-item prop属性指定一下要使用哪条规则
        el-input v-model双向绑定
      -->
      <el-form ref="form" :model="form" :rules="rules">
        <el-form-item
          label="账号"
          prop="username"
        >
          <el-input v-model="form.username"/>
        </el-form-item>

        <el-form-item
          label="密码"
          prop="password"
        >
          <el-input v-model="form.password"/>
        </el-form-item>

        <el-form-item prop="remember">
          <el-checkbox>记住我</el-checkbox>
        </el-form-item>

        <el-form-item>
          <el-button type="primary" class="login_btn">登录</el-button>
        </el-form-item>
      </el-form>
    </div>
  </div>
</template>

<script>

export default {
  name: 'Login',
  data() {
    return {
      // 表单对象
      form: {
        username: '',
        password: ''
      },
      // 规则对象
      rules: {
        username: [
          {
            required: true,
            message: '请输入账号',
            trigger: 'blur'
          }
        ],
        password: [
          {
            required: true,
            message: '请输入密码',
            trigger: 'blur'
          }
        ]
      }
    }
  }
}

</script>

2. 登录界面统一校验

实际上,登录时表单的校验分为两部分,第一部分是用户输入框失焦时触发的校验,另一根时点击登录按钮时的统一校验。

因为用户如果没进行任何输入,直接点击登录按钮,第一部分的校验将没法触发,因此,需要第二部分的统一校验来兜底。

实现方式: 通过给el-form组件添加ref="form"属性,然后使用this.$refs.form获取表单实例对象。

每个表单项需要通过prop属性指定对应的校验规则,如prop="username"会关联rules对象中定义的username的校验规则。

核心方法:调用表单实例的validate方法会对所有带prop属性的表单项进行统一校验。

回调参数:validate方法接收回调函数,参数valid为布尔值,当所有校验通过时为true,否则为false。

逻辑处理:通常在回调函数中判断valid值,为true时才执行后续登录逻辑。

事件绑定:给登录按钮添加@click="loginHandler"事件,在methods中定义loginHandler方法。

方法实现:在loginHandler中调用this.$refs.form.validate方法进行统一校验。

<template>
  <div class="login_body">
    <div class="bg" />
    <div class="box">
      <div class="title">智慧园区-登录</div>
      <!-- 基础校验:
        el-form :model="表单对象" :rules="规则对象"
        el-form-item prop属性指定一下要使用哪条规则
        el-input v-model双向绑定
        统一校验:
        获取表单的实例对象
        调用validate方法
      -->
      <el-form ref="form" :model="form" :rules="rules">
        <el-form-item
          label="账号"
          prop="username"
        >
          <el-input v-model="form.username"/>
        </el-form-item>

        <el-form-item
          label="密码"
          prop="password"
        >
          <el-input v-model="form.password"/>
        </el-form-item>

        <el-form-item prop="remember">
          <el-checkbox>记住我</el-checkbox>
        </el-form-item>

        <el-form-item>
          <el-button type="primary" class="login_btn" @click="loginHandler">登录</el-button>
        </el-form-item>
      </el-form>
    </div>
  </div>
</template>

<script>

export default {
  name: 'Login',
  data() {
    return {
      // 表单对象
      form: {
        username: '',
        password: ''
      },
      // 规则对象
      rules: {
        username: [
          {
            required: true,
            message: '请输入账号',
            trigger: 'blur'
          }
        ],
        password: [
          {
            required: true,
            message: '请输入密码',
            trigger: 'blur'
          }
        ]
      }
    }
  },
  methods: {
    loginHandler() {
      this.$refs.form.validate(valid => {
        // 所有的表单项都通过校验,valid变量才为true,否则就是false
        console.log(valid)
        if (valid) {
          console.log(111111)
        }
      })
    }
  }
}

</script>

<style scoped lang="scss">
  .login_body {
    display: flex;
  }
  .bg {
    width: 60vw;
    height: 100vh;
    background: url('~@/assets/login-bg.svg') no-repeat;
    background-position: right top;
    background-size: cover;
  }
  .box {
    margin: 200px 10% 0;
    flex: 1;
    .title {
      padding-bottom: 76px;
      font-size: 26px;
      font-weight: 500;
      color: #1e2023;
    }
    ::v-deep() {
      .ant-form-item {
        display: flex;
        margin-bottom: 62px;
        flex-direction: column;
      }
      .ant-form-item-label label {
        font-size: 16px;
        color: #8b929d;
      }
      .ant-input,
      .ant-input-password {
        border-radius: 8px;
      }
    }
  }
  .login_btn{
    width: 100%;
  }
</style>

  1. 测试场景1:不输入任何内容直接点击登录,valid值为false,阻止登录流程。
  2. 测试场景2:输入符合规则的账号密码后点击登录,valid值为true,允许继续登录操作。
  3. 调试技巧:可以在回调函数中添加console.log(valid)来验证校验结果是否符合预期。

3. 使用vuex管理用户的token

登录的目的是获取token用于多组件共享,token是字符串类型。

e8ec8a07-8bf2-47d3-b750-043fc0c39ae5.png 三大模块:

  1. state:存储token数据状态
  2. mutation: 同步修改token的唯一途径
  3. action: 包含接口调用和提交mutation

组件只需触发action,完成接口调用后提交mutation更新state。 store/modules/user.js中代码:

import { loginAPI } from '@/api/user'

export default {
  namespaced: true,
  // 数据状态 响应式
  state: {
    token: ''
  },
  // 同步修改 Vuex架构中,有且只有一种提交mutation
  mutations: {
    setToken(state, newToken) {
      state.token = newToken
    }
  },
  // 异步,接口请求 + 提交mutation
  actions: {
    async asyncLogin(ctx, { username, password }) {
      // 1. 调用登录接口
      const res = await loginAPI({ username, password })
      // 2. 提交mutation
      ctx.commit('setToken', res.data.token)
    }
  }
}

代码解读:

首先,token初始化为'',与后端返回的类型一致。namespaced:true确保模块的独立性。

mutations为规范写法,第一个参数为state对象,第二个参数newToken为荷载(payload),通过state.token=newToken直接赋值。

异步请求接口为规范写法,第一个参数ctx为上下文对象,第二个参数使用解构赋值明确参数要求。接口分为两步:第一步调用loginAPI接口获取token;第二步通过ctx.commit提交mutation并更新state。

api/user.js中代码:

import request from '@/utils/request'

// 登录函数
/**
 * @description: 登录函数
 * @param {*} data { username,password}
 * @return {*} promise
 */

// 函数:参数 + 逻辑 + 返回值
export function loginAPI(data) {
  return request({
    url: '/park/login', // baseURL + url
    method: 'POST', // GET/POST/PUT/DELETE
    data // 请求体参数
  })
  // 返回的是一个promise
}

这里面定义了loginAPI接口,接口参数:

url: '/park/login'
method: 'POST'
参数对象包含username和password字段
返回值:Promise对象,data中包含token字段

views/Login/index.vue组件中执行代码this.$store.dispatch('user/asyncLogin', this.form)即可触发调用。

4. 登录后跳转到首页

Token存储机制:登录接口调用成功后,将获取的token数据存储在Vuex状态管理中

路由跳转时机:登录成功后需要进行首页跳转,但要注意异步操作的顺序问题

职责分离原则:

  • Vuex只负责处理用户数据相关逻辑(如token存储)
  • 业务代码(路由跳转、提示消息)应放在业务组件中实现
  • 避免将业务逻辑混入状态管理模块

核心代码:

<script>

export default {
  name: 'Login',
  data() {
    return {
      // 表单对象
      form: {
        username: '',
        password: ''
      },
      // 规则对象
      rules: {
        username: [
          {
            required: true,
            message: '请输入账号',
            trigger: 'blur'
          }
        ],
        password: [
          {
            required: true,
            message: '请输入密码',
            trigger: 'blur'
          }
        ]
      }
    }
  },
  methods: {
    loginHandler() {
      this.$refs.form.validate(async valid => {
        // 所有的表单项都通过校验,valid变量才为true,否则就是false
        console.log(valid)
        if (valid) {
          // 确保token返回之后再跳转到首页,防止首页有一些需要依赖token的逻辑
          await this.$store.dispatch('user/asyncLogin', this.form)
          // 跳转到首页
          this.$router.push('/')
          // 提示用户登录成功
          this.$message({
            type: 'success',
            message: '登录成功'
          })
        }
      })
    }
  }
}

</script>

5. token持久化操作

Token的有效期会持续一定时间,在这段时间内没有必要重复请求token,但是vuex本身是基于内存的管理方式,刷新浏览器Token会丢失,为了避免丢失需要配置持久化进行缓存。

解决思路:1. 存储Token数据时,一份存入vuex,一份存入cookie;2.vuex中初始化Token时,优先从本地cookie获取,本地获取不到再初始化为空字符串。

  1. utils/auth.js中封装cookie操作的方法
// 专门用来操作cookie的方法包
// 内部封装了繁琐的操作方法 参数处理 暴露三个函数 get,set,remove
import Cookies from 'js-cookie'
import { TOKEN_KEY } from '@/constants/KEY'
// 获取token的方法
export function getToken() {
  return Cookies.get(TOKEN_KEY)
}

// 设置方法
export function setToken(token) {
  return Cookies.set(TOKEN_KEY, token)
}

// 删除方法
export function removeToken() {
  return Cookies.remove(TOKEN_KEY)
}
  1. store/modules/user.js中导入操作cookie的方法并新增存入cookie的操作,并在token初始化时优先从cookie获取
import { loginAPI } from '@/api/user'
import { setToken, getToken } from '@/utils/auth'

export default {
  namespaced: true,
  // 数据状态 响应式
  state: {
    // 2. vuex中初始化Token时,优先从本地cookie取,取不到再初始化为空字符串。
    token: getToken() || ''
  },
  // 同步修改 Vuex架构中,有且只有一种提交mutation
  mutations: {
    // 1. 存Token数据时,一份存入vuex,一份存入cookie
    setToken(state, newToken) {
      // 存入vuex
      state.token = newToken
      // 存入cookie
      setToken(newToken)
    }
  },
  // 异步,接口请求 + 提交mutation
  actions: {
    async asyncLogin(ctx, { username, password }) {
      // 1. 调用登录接口
      const res = await loginAPI({ username, password })
      // 2. 提交mutation
      ctx.commit('setToken', res.data.token)
    }
  }
}

6. token存取方式对比

  1. 为什么要使用vuex+cookies存储的方式

内存存储优势:Vuex基于内存管理,存取速度特别快(毫秒级),且封装了便捷的方法调用方式,适合高频操作场景

持久化需求:Cookies/localStorage基于磁盘存储,虽然存取速度稍慢(约慢一个量级),但具有刷新不丢失的特性

组合方案价值:同时利用Vuex的速度优势(运行时状态管理)和Cookies的持久化特性(长期存储),典型场景如用户登录态保持

  1. cookie vs localStorage

存储容量:

localStorage约5MB(不同浏览器有差异);
cookie仅几KB(个位数级别),大容量数据存储首选localStorage

操作权限:

localStorage纯前端操作;
cookie前后端均可操作(实际开发中后端操作为主),通过Set-Cookie头实现

请求携带:

cookie自动跟随接口发送(无需手动设置)
localStorage需手动添加到请求头(如Authorization头)

7. 添加token到请求头

前端请求接口后,后端需要对接口做鉴权,只有token有效,接口才能正常响应,返回正常的数据,token就是后端接口判断的标识。项目中,前端页面会请求非常多的接口,axios请求拦截器可以统一控制,一次添加,多个接口都生效。

utils/request.js中写入如下代码:

import axios from 'axios'
import { getToken } from './auth'

const service = axios.create({
  baseURL: 'https://api-hmzs.itheima.net/v1',
  timeout: 5000 // request timeout
})

// 请求拦截器
service.interceptors.request.use(
  config => {
    // 添加token
    const token = getToken()
    if (token) {
      // 前面是固定写法,后面token的拼接模式是由后端来决定。
      config.headers.Authorization = token
    }
    return config
  },
  error => {
    return Promise.reject(error)
  }
)

// 响应拦截器
service.interceptors.response.use(
  response => {
    return response.data
  },
  error => {
    return Promise.reject(error)
  }
)

export default service

8. 记住我功能的实现

交互表现

1.选中状态:当用户选中"记住我"复选框并登录后,再次返回登录页时,系统会自动回填之前输入的用户名和密码
2.未选中状态:当用户未选中复选框时登录,再次返回登录页会清除本地存储的账号密码数据

实现逻辑:

  1. 登录时处理: 当remember为true时,将账号密码存入localStorage;当remember为false时,清除localStorage中的账号密码
  2. 初始化处理:组件初始化时从localStorage取值并回填到表单

views/Login/index.vue中代码:

<template>
  <div class="login_body">
    <div class="bg" />
    <div class="box">
      <div class="title">智慧园区-登录</div>
      <!-- 基础校验:
        el-form :model="表单对象" :rules="规则对象"
        el-form-item prop属性指定一下要使用哪条规则
        el-input v-model双向绑定
        统一校验:
        获取表单的实例对象
        调用validate方法
      -->
      <el-form ref="form" :model="form" :rules="rules">
        <el-form-item
          label="账号"
          prop="username"
        >
          <el-input v-model="form.username"/>
        </el-form-item>

        <el-form-item
          label="密码"
          prop="password"
        >
          <el-input v-model="form.password"/>
        </el-form-item>
        <!-- 1.完成选择框的双向绑定,得到一个true或者false的选中状态 -->
        <!-- 2. 如果当前为true,点击登陆时,表示要己住,把当前的用户名和密码存入本地 -->
        <!-- 3. 组件初始化的时候,从本地取账号和密码,把账号密码存入用来双向绑定的form身上。 -->
         <!-- 4. 如果当前用户没有记住,状态为false,点击登录的时候要把之前的数据清空 -->
        <el-form-item prop="remember">
          <el-checkbox v-model="remember">记住我</el-checkbox>
        </el-form-item>

        <el-form-item>
          <el-button type="primary" class="login_btn" @click="loginHandler">登录</el-button>
        </el-form-item>
      </el-form>
    </div>
  </div>
</template>

<script>
const REMEMBER_KEY = 'remember_key'
export default {
  name: 'Login',
  data() {
    return {
      // 表单对象
      form: {
        username: '',
        password: ''
      },
      remember: false,
      // 规则对象
      rules: {
        username: [
          {
            required: true,
            message: '请输入账号',
            trigger: 'blur'
          }
        ],
        password: [
          {
            required: true,
            message: '请输入密码',
            trigger: 'blur'
          }
        ]
      }
    }
  },
  created() {
    // 去本地取一下之前存入的账号和密码,如果取到了就进行赋值操作
    const formStr = localStorage.getItem(REMEMBER_KEY)
    if (formStr) {
      const formObj = JSON.parse(formStr)
      this.form = formObj
    }
  },
  methods: {
    loginHandler() {
      this.$refs.form.validate(async valid => {
        // 所有的表单项都通过校验,valid变量才为true,否则就是false
        console.log(valid)
        if (valid) {
          // 添加记住我逻辑
          if (this.remember) {
            localStorage.setItem(REMEMBER_KEY, JSON.stringify(this.form))
          } else {
            localStorage.removeItem(REMEMBER_KEY)
          }
          // 确保token返回之后再跳转到首页,防止首页有一些需要依赖token的逻辑
          await this.$store.dispatch('user/asyncLogin', this.form)
          // 跳转到首页
          this.$router.push('/')
          // 提示用户登录成功
          this.$message({
            type: 'success',
            message: '登录成功'
          })
        }
      })
    }
  }
}

</script>

<style scoped lang="scss">
  .login_body {
    display: flex;
  }
  .bg {
    width: 60vw;
    height: 100vh;
    background: url('~@/assets/login-bg.svg') no-repeat;
    background-position: right top;
    background-size: cover;
  }
  .box {
    margin: 200px 10% 0;
    flex: 1;
    .title {
      padding-bottom: 76px;
      font-size: 26px;
      font-weight: 500;
      color: #1e2023;
    }
    ::v-deep() {
      .ant-form-item {
        display: flex;
        margin-bottom: 62px;
        flex-direction: column;
      }
      .ant-form-item-label label {
        font-size: 16px;
        color: #8b929d;
      }
      .ant-input,
      .ant-input-password {
        border-radius: 8px;
      }
    }
  }
  .login_btn{
    width: 100%;
  }
</style>

代码关键点分析:

1)选择框的双向绑定
绑定方式:使用v-model绑定remember变量
数据位置:remember作为独立变量而非表单对象属性,因为它不需要提交给后端
默认值:初始设置为false表示未选中状态
2)记住我逻辑 
存储机制:
使用localStorage存储账号密码
需要将表单对象转为JSON字符串存储
条件判断:仅在remember为true时执行存储操作,remember为false时,清除操作
常量定义:使用REMEMBER_KEY常量避免硬编码字符串重复
3)组件初始化回填
生命周期:在created或mounted钩子中执行回填逻辑
取值流程:从localStorage获取存储的字符串
使用JSON.parse转为对象
赋值给表单对象完成回填
健壮性处理:添加非空判断避免未存储数据时的错误
4)代码优化
常量提取:将'remember_key'提取为常量REMEMBER_KEY

9. 退出登录实现

退出登录功能位于导航栏右上角的用户下拉菜单中,对应组件文件为src/layout/components/Navbar.vue,方法绑定:已预置logout方法绑定在退出登录按钮的点击事件上。

<template>
  <div class="navbar">
    <div class="right-menu">
      <el-dropdown class="avatar-container" trigger="click">
        <div class="avatar-wrapper">
          <!-- 用户名称 -->
          <span class="name">黑马管理员</span>
        </div>
        <el-dropdown-menu slot="dropdown" class="user-dropdown">
          <router-link to="/">
            <el-dropdown-item> 首页 </el-dropdown-item>
          </router-link>
          <a target="_blank">
            <el-dropdown-item> 项目地址 </el-dropdown-item>
          </a>
          <!-- 实现思路:1.询问用户是否真的要退出登录;2. 用户同意之后,清空当前的用户数据并跳转到登录页 -->
          <el-dropdown-item divided @click.native="logout">
            <span style="display: block">退出登录</span>
          </el-dropdown-item>
        </el-dropdown-menu>
      </el-dropdown>
    </div>
  </div>
</template>

<script>
export default {
  methods: {
    // 退出登录
    logout() {
      // 1. 询问用户
      this.$confirm('确认要退出登录吗,是否继续?', '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
      }).then(() => {
        // 确认回调
        // 1. 清空数据
        this.$store.commit('user/clearUserInfo')
        // 2. 跳转到登录界面
        this.$router.push(`/login?redirect=${this.$route.fullPath}`)
      }).catch(() => {
        // 取消或者.then中有错误
      })
    }
  }
}
</script>

使用Element UI的$confirm方法实现二次确认,文案修改为"确认要退出登录吗,是否继续?"。

确认回调:.then()中执行数据清除和跳转逻辑
取消回调:.catch()中显示"已取消删除"提示信息
  • 数据清除

Vuex清除:通过state.token = ''清空状态管理中的token

Cookie清除:调用removeToken()方法清除本地存储的token

mutation编写:在user模块中添加clearUserInfo方法,同时处理Vuex和Cookie的清理

  • 跳转登录页

路由跳转:使用this.$router.push('/login')实现页面跳转

完整调用:通过this.$store.commit('user/clearUserInfo')提交mutation

store/modules/user.js中具体代码如下:

import { loginAPI } from '@/api/user'
import { setToken, getToken, removeToken } from '@/utils/auth'

export default {
  namespaced: true,
  // 数据状态 响应式
  state: {
    // 2. vuex中初始化Token时,优先从本地cookie取,取不到再初始化为空字符串。
    token: getToken() || ''
  },
  // 同步修改 Vuex架构中,有且只有一种提交mutation
  mutations: {
    // 1. 存Token数据时,一份存入vuex,一份存入cookie
    setToken(state, newToken) {
      // 存入vuex
      state.token = newToken
      // 存入cookie
      setToken(newToken)
    },
    clearUserInfo(state) {
      // 清除vuex中的
      state.token = ''
      // 清除本地cookie中的
      removeToken()
    }
  },
  // 异步,接口请求 + 提交mutation
  actions: {
    async asyncLogin(ctx, { username, password }) {
      // 1. 调用登录接口
      const res = await loginAPI({ username, password })
      // 2. 提交mutation
      ctx.commit('setToken', res.data.token)
    }
  }
}

10. Token控制路由跳转

如果用户没有登录,即没有token,则不让用户进入某些页面。因此,需要通过token的有无来控制路由的跳转。

54168ac7-5dfa-4156-9fe3-c434ecc83f69.png

  1. 新建src/permission.js文件,并在main.js中通过import './permission'方式引入。
permission.js文件代码

// 所有和权限控制相关的代码
import router from "./router"
import { getToken } from "./utils/auth"

console.log('权限控制生效了')

const WHITE_LIST = ['/login', '/404']

// 1. 路由前置守卫
router.beforeEach((to, from, next) => {
     // to: 目标路由对象 到哪里去
    // from:路由对象 从哪里来的那个对象
    // next: 放行函数
    const token = getToken()
    if (token) {
        // 有token
        next()
    } else {
        // 没有token
        // 1. 是否在白名单内,即在白名单数组中是否存在
        if (WHITE_LIST.includes(to.path)) {
            next()
        } else {
            next('/login')
        }

    }
})

11. 接口错误统一处理

系统中会调用很多接口,每个接口都可能会出错,为了交互体验,需要对所有的接口进行错误处理,当错误发生时,告诉用户。

实现方式:利用axios的响应拦截器捕获所有接口错误。位置:utils/request.js

import axios from 'axios'
import { getToken } from './auth'
import { Message } from 'element-ui'

const service = axios.create({
  baseURL: 'https://api-hmzs.itheima.net/v1',
  timeout: 5000 // request timeout
})

// 请求拦截器
......

// 响应拦截器
service.interceptors.response.use(
  response => {
    return response.data
  },
  // 接口出错时,自动执行这个回调
  error => {
    // console.dir(error.response.data.msg)
    // 错误类型有可能有很多种,根据不同的错误码做不同的用户提示,写的位置都在这里
    Message({
      type: 'warning',
      message: error.response.data.msg
    })
    return Promise.reject(error)
  }
)

export default service

非组件调用:通过import { Message } from 'element-ui'引入独立消息组件

类型配置:使用type: 'warning'设置警告类型提示框

参数传递:所有的错误在error中,本次的封装在将error.response.data.msg中的,取出来作为message参数传递

继TailWindCss和UnoCss后的CSS-in-JS vs Utility-First 深度对比

CSS-in-JS vs Utility-First 深度对比

完整技术指南:从架构设计到开发实践的全面对比分析 配合AI硬肝

⚠️ 注意:本文讨论的是整个 CSS 方案生态,包括传统 CSS-in-JS(Styled Components、Emotion)和 Utility-First(Tailwind CSS、UnoCSS),为你选择最适合的样式方案提供全面参考。


目录

  1. 背景与趋势
  2. 核心概念解析
  3. Styled Components 深度指南
  4. Emotion 深度指南
  5. 架构设计深度对比
  6. 性能基准测试
  7. 开发体验详解
  8. 实战案例
  9. 迁移与混用策略
  10. 常见问题与解决方案
  11. 总结与选型建议

1. 背景与趋势

1.1 CSS 方案的演进历程

┌─────────────────────────────────────────────────────────────────────────────┐
                           CSS 方案演进时间线                                  
└─────────────────────────────────────────────────────────────────────────────┘

2013        2015        2017        2019        2021        2024        2026
                                                                    
                                                                    
CSS Modules  CSS-in-JS   Tailwind    Emotion     UnoCSS      Tailwind    混合方案
  (eBay)     (V1)       CSS V1      11          (Anthony    CSS 4.0    成为主流
                                      Fu)                      (Rust)
                                      
                                      
                              React 官方移除
                              CSS-in-JS 推荐

1.2 为什么 CSS-in-JS 引发争议?

1.2.1 React 团队的立场变化
// 2020 年:React 官方博客
// "We recommend CSS-in-JS libraries" - React 官方博客

// 2023 年:React 团队的变化
// React Server Components (RSC) 的出现
// - 运行时样式注入与 SSR 不兼容
// - 需要额外处理 hydration
// - 首屏性能影响显著

// React 团队的建议变化
const reactTeamRecommendation = {
  before: "CSS-in-JS is a great solution",
  now: "Consider CSS Modules or utility-first CSS",
  reason: "RSC compatibility + performance"
};
1.2.2 CSS-in-JS 的核心问题
// 问题 1:运行时开销
// 每次渲染都需要生成样式
const Button = styled.button`
  background: ${props => props.primary ? 'blue' : 'gray'};
`;

// 编译后的伪代码
function Button(props) {
  // 每次渲染都会执行
  const className = generateHash('background: blue'); // 运行时计算
  return <button className={className} />;
}

// 问题 2:SSR 不兼容
// 服务端渲染时样式未注入
// 需要使用 extractCritical 等工具
const ssrProblem = {
  server: "HTML without styles",
  client: "Flash of unstyled content (FOUC)",
  solution: "Additional SSR setup required"
};

// 问题 3:Bundle 体积
// 运行时库增加 JS Bundle
const bundleImpact = {
  'styled-components': '~32KB',
  'emotion': '~24KB',
  'total-react-app': '~400KB',
  percentage: '~8% of bundle'
};

1.3 Utility-First 的崛起

1.3.1 核心理念回归
/* 传统 CSS */
.button {
  padding: 10px 20px;
  background: blue;
  color: white;
  border-radius: 4px;
}

/* Utility-First */
<button class="px-5 py-2 bg-blue-500 text-white rounded">
  Button
</button>

/* 理念: */
/* 1. 单一职责:每个类只做一件事 */
/* 2. 组合优于继承:类名组合构建复杂样式 */
/* 3. 约束设计:预定义设计系统 */
1.3.2 为什么 2024-2026 年 Utility-First 主导?
┌─────────────────────────────────────────────────────────────────────────────┐
│                        Utility-First 主导的原因                              │
└─────────────────────────────────────────────────────────────────────────────┘

1. 性能优势
   ├── 构建时生成,无运行时开销
   ├── 原子化 CSS,Bundle 更小
   └── 首屏加载更快

2. 开发效率
   ├── 无需切换文件(样式在 HTML 中)
   ├── 响应式设计原生支持
   └── 重构友好(配置变更全局生效)

3. 生态成熟
   ├── Tailwind CSS 4.0 (Rust 引擎)
   ├── UnoCSS (即时生成,更快)
   └── 完善的设计系统集成

4. 框架无关
   ├── React、Vue、Svelte 都支持
   └── React Native (NativeWind)

2. 核心概念解析

2.1 什么是 CSS-in-JS?

CSS-in-JS 是一种将 CSS 样式作为 JavaScript 对象或字符串来编写,并最终注入到 DOM 的技术方案。

2.1.1 核心特征
// 特征 1:样式定义为 JavaScript
const styles = {
  button: {
    padding: '10px 20px',
    backgroundColor: 'blue',
    color: 'white'
  }
};

// 特征 2:组件与样式绑定
const Button = styled.button`
  background: blue;
  color: white;
`;

// 特征 3:动态样式基于 props
const DynamicButton = styled.button`
  background: ${props => props.variant === 'primary' ? 'blue' : 'gray'};
`;

// 特征 4:主题系统
const ThemedButton = styled.button`
  background: ${props => props.theme.colors.primary};
`;
2.1.2 解决的问题
问题 传统 CSS CSS-in-JS
全局污染 需要 BEM 命名 自动作用域
样式冲突 难以追踪 唯一哈希
动态样式 需要模板字符串 原生支持
死代码 难以移除 摇树优化

2.2 什么是 Utility-First?

Utility-First 是一种使用大量单一功能类(Utility Classes)组合构建界面的方法。

2.2.1 核心特征
// 特征 1:原子化类名
// flex = display: flex
// p-4 = padding: 1rem
// text-center = text-align: center
// rounded-lg = border-radius: 0.5rem

// 特征 2:约束设计系统
const designSystem = {
  colors: {
    primary: '#3b82f6',
    secondary: '#6b7280'
  },
  spacing: {
    1: '0.25rem',
    2: '0.5rem',
    4: '1rem'
  }
};

// 特征 3:响应式变体
// md:flex = @media (min-width: 768px) { .flex { display: flex; } }

// 特征 4:状态变体
// hover:bg-blue-500 = :hover { background: #3b82f6; }
2.2.2 解决的问题
问题 传统 CSS Utility-First
类名命名 需要思考名称 类名即样式
响应式 手动写 media query 前缀变体
设计一致性 需要规范文档 内置设计系统
样式复用 需要 CSS 组合 类名组合

3. Styled Components 深度指南

3.1 基础入门

3.1.1 安装与配置
# 安装
npm install styled-components
# 或
yarn add styled-components

# TypeScript 类型
npm install -D @types/styled-components
3.1.2 第一个组件
import styled from 'styled-components';

// 方法 1:模板字符串(推荐)
const Button = styled.button`
  padding: 10px 20px;
  background-color: blue;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  
  &:hover {
    background-color: darkblue;
  }
`;

// 方法 2:对象语法
const Button2 = styled.button({
  padding: '10px 20px',
  backgroundColor: 'blue',
  color: 'white',
  border: 'none',
  borderRadius: '4px',
  cursor: 'pointer'
});

// 使用
function App() {
  return <Button>Click me</Button>;
}

3.2 进阶用法

3.2.1 扩展样式(Extending Styles)
// 基础按钮
const BaseButton = styled.button`
  padding: 10px 20px;
  border-radius: 4px;
  cursor: pointer;
  transition: all 0.2s;
`;

// 扩展:主要按钮
const PrimaryButton = styled(BaseButton)`
  background-color: #3b82f6;
  color: white;
  
  &:hover {
    background-color: #2563eb;
  }
`;

// 扩展:大按钮
const LargeButton = styled(BaseButton)`
  padding: 15px 30px;
  font-size: 18px;
`;

// 使用
function ButtonExamples() {
  return (
    <div>
      <BaseButton>基础</BaseButton>
      <PrimaryButton>主要</PrimaryButton>
      <LargeButton>大号</LargeButton>
    </div>
  );
}
3.2.2 动态属性(Passed Props)
// 接收 props 控制样式
const Button = styled.button<{ $variant?: 'primary' | 'secondary' | 'danger' }>`
  padding: 10px 20px;
  border-radius: 4px;
  border: none;
  cursor: pointer;
  
  /* 基于 props 条件渲染 */
  background-color: ${props => {
    switch (props.$variant) {
      case 'primary': return '#3b82f6';
      case 'danger': return '#ef4444';
      default: return '#6b7280';
    }
  }};
  
  color: ${props => props.$variant === 'secondary' ? '#1f2937' : 'white'};
  
  /* 基于 props 控制显示 */
  opacity: ${props => props.disabled ? 0.5 : 1};
  cursor: ${props => props.disabled ? 'not-allowed' : 'pointer'};
`;

// 使用
function App() {
  return (
    <div>
      <Button>Default</Button>
      <Button $variant="primary">Primary</Button>
      <Button $variant="danger">Danger</Button>
      <Button disabled>Disabled</Button>
    </div>
  );
}
3.2.3 附加 Props(Attrs)
// 使用 attrs 添加静态属性
const Input = styled.input.attrs({
  type: 'text',
  placeholder: 'Enter text...'
})`
  padding: 10px;
  border: 1px solid #ddd;
`;

// 使用 attrs 添加动态属性
const EmailInput = styled.input.attrs(props => ({
  type: 'email',
  'data-testid': props.$testId,
  ariaLabel: props.$label
}))`
  padding: 10px;
  border: 1px solid #ddd;
`;

// 使用
function App() {
  return (
    <div>
      <Input />
      <EmailInput $testId="email" $label="Email Address" />
    </div>
  );
}

3.3 主题系统(Theming)

3.3.1 ThemeProvider 配置
import { ThemeProvider } from 'styled-components';

// 定义主题类型
interface Theme {
  colors: {
    primary: string;
    secondary: string;
    background: string;
    text: string;
    border: string;
  };
  spacing: {
    sm: string;
    md: string;
    lg: string;
  };
  borderRadius: {
    sm: string;
    md: string;
    lg: string;
  };
}

// 定义主题
const lightTheme: Theme = {
  colors: {
    primary: '#3b82f6',
    secondary: '#6b7280',
    background: '#ffffff',
    text: '#1f2937',
    border: '#e5e7eb'
  },
  spacing: {
    sm: '0.5rem',
    md: '1rem',
    lg: '1.5rem'
  },
  borderRadius: {
    sm: '4px',
    md: '8px',
    lg: '12px'
  }
};

const darkTheme: Theme = {
  ...lightTheme,
  colors: {
    primary: '#60a5fa',
    secondary: '#9ca3af',
    background: '#1f2937',
    text: '#f9fafb',
    border: '#374151'
  }
};

// 使用主题
const ThemedButton = styled.button`
  background: ${props => props.theme.colors.primary};
  color: ${props => props.theme.colors.background};
  padding: ${props => props.theme.spacing.md};
  border-radius: ${props => props.theme.borderRadius.md};
`;

// App 包装
function App() {
  const [isDark, setIsDark] = useState(false);
  
  return (
    <ThemeProvider theme={isDark ? darkTheme : lightTheme}>
      <ThemedButton>Theme Button</ThThemeProvider>
    </ThemeProvider>
  );
}
3.3.2 使用 useTheme
import { useTheme } from 'styled-components';

function ThemedComponent() {
  const theme = useTheme();
  
  return (
    <div style={{ 
      color: theme.colors.text,
      padding: theme.spacing.lg 
    }}>
      Current theme: {theme.colors.primary}
    </div>
  );
}

3.4 全局样式

import { createGlobalStyle } from 'styled-components';

const GlobalStyle = createGlobalStyle`
  /* 重置样式 */
  *, *::before, *::after {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
  }
  
  /* 全局样式 */
  html {
    font-size: 16px;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
  }
  
  body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    background: ${props => props.theme.colors.background};
    color: ${props => props.theme.colors.text};
    line-height: 1.5;
  }
  
  /* 全局链接样式 */
  a {
    color: ${props => props.theme.colors.primary};
    text-decoration: none;
    
    &:hover {
      text-decoration: underline;
    }
  }
  
  /* 全局按钮样式 */
  button {
    font-family: inherit;
  }
`;

// 使用
function App() {
  return (
    <>
      <GlobalStyle />
      <YourApp />
    </>
  );
}

3.5 样式组合与嵌套

3.5.1 伪类与伪元素
const InteractiveBox = styled.div`
  /* 伪类 */
  &:hover {
    background: blue;
    color: white;
  }
  
  &:focus {
    outline: 2px solid blue;
    outline-offset: 2px;
  }
  
  &:active {
    transform: scale(0.98);
  }
  
  /* 伪元素 */
  &::before {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 2px;
    background: blue;
  }
  
  &::after {
    content: ' →';
  }
  
  /* 状态组合 */
  &:hover::after {
    content: ' ←';
  }
`;
3.5.2 嵌套选择器
const Card = styled.article`
  padding: 20px;
  border: 1px solid #ddd;
  
  /* 直接子元素 */
  & > h2 {
    font-size: 1.5rem;
    margin-bottom: 10px;
  }
  
  /* 后代元素 */
  & p {
    color: #666;
    line-height: 1.6;
    
    /* 嵌套伪类 */
    &:first-of-type {
      font-weight: bold;
    }
  }
  
  /* 同一父级下的其他元素 */
  & + & {
    margin-top: 20px;
  }
  
  /* 引用父级 */
  &:hover & {
    border-color: blue;
  }
`;

3.6 动画与关键帧

import { keyframes, css } from 'styled-components';

// 定义动画
const fadeIn = keyframes`
  from {
    opacity: 0;
    transform: translateY(10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
`;

const pulse = keyframes`
  0%, 100% {
    opacity: 1;
  }
  50% {
    opacity: 0.5;
  }
`;

// 使用动画
const AnimatedBox = styled.div`
  animation: ${fadeIn} 0.3s ease-out;
  
  /* 条件动画 */
  ${props => props.$isLoading && css`
    animation: ${pulse} 1.5s ease-in-out infinite;
  `}
`;

// 组合多个动画
const ComplexAnimation = styled.div`
  animation: 
    ${fadeIn} 0.3s ease-out,
    ${pulse} 2s ease-in-out 0.3s;
`;

3.7 CSS 媒体查询

const ResponsiveBox = styled.div`
  /* 默认样式(移动端) */
  width: 100%;
  padding: 10px;
  
  /* 平板 */
  @media (min-width: 768px) {
    width: 50%;
    padding: 20px;
  }
  
  /* 桌面 */
  @media (min-width: 1024px) {
    width: 33.333%;
    padding: 30px;
  }
  
  /* 更大屏幕 */
  @media (min-width: 1440px) {
    max-width: 1200px;
    margin: 0 auto;
  }
`;

3.8 与 React 深度集成

3.8.1 继承 HTML 元素
// 支持所有 HTML 元素
const StyledDiv = styled.div``;
const StyledSpan = styled.span``;
const StyledA = styled.a``;
const StyledInput = styled.input``;
const StyledSelect = styled.select``;
const StyledTextarea = styled.textarea``;

// 自定义元素
const StyledSvg = styled.svg`
  width: 24px;
  height: 24px;
  fill: currentColor;
`;
3.8.2 传递 className
// styled-components 会自动传递 className
// 但如果有其他 className 来源,需要合并

const StyledButton = styled.button`
  padding: 10px 20px;
  
  /* 接收外部 className */
  ${props => props.className && css`
    /* 外部样式会应用 */
  `}
`;

function App() {
  // 外部传入的 className 会自动合并
  return <StyledButton className="external-class">Button</StyledButton>;
}
3.8.3 Ref 转发
import { forwardRef } from 'react';

// styled-components 默认支持 ref
const Input = styled.input`
  padding: 10px;
  border: 1px solid #ddd;
`;

function App() {
  const inputRef = useRef<HTMLInputElement>(null);
  
  return (
    <Input 
      ref={inputRef} 
      placeholder="Focus me"
    />
  );
}

// 自定义 ref 转发
const CustomInput = forwardRef<HTMLInputElement, Props>(
  ({ placeholder }, ref) => (
    <StyledInput ref={ref} placeholder={placeholder} />
  )
);

3.9 SSR 支持

3.9.1 Next.js 中的使用
// pages/_document.js (Pages Router)
import Document, { Html, Head, Main, NextScript } from 'next/document';
import { ServerStyleSheet } from 'styled-components';

export default class MyDocument extends Document {
  static async getInitialProps(ctx) {
    const sheet = new ServerStyleSheet();
    const originalRenderPage = ctx.renderPage;

    try {
      ctx.renderPage = () =>
        originalRenderPage({
          enhanceApp: (App) => (props) =>
            sheet.collectStyles(<App {...props} />),
        });

      const initialProps = await Document.getInitialProps(ctx);
      return {
        ...initialProps,
        styles: (
          <>
            {initialProps.styles}
            {sheet.getStyleElement()}
          </>
        ),
      };
    } finally {
      sheet.seal();
    }
  }

  render() {
    return (
      <Html lang="en">
        <Head />
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}
3.9.2 App Router (Next.js 13+)
// 需要使用 Registry 组件
// app/components/Registry.tsx
'use client';

import React, { useState } from 'react';
import { useServerInsertedHTML } from 'next/navigation';
import { ServerStyleSheet, StyleSheetManager } from 'styled-components';

export default function StyledComponentsRegistry({
  children,
}: {
  children: React.ReactNode;
}) {
  const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet());

  useServerInsertedHTML(() => {
    const styles = styledComponentsStyleSheet.getStyleElement();
    styledComponentsStyleSheet.instance.clearTag();
    return <>{styles}</>;
  });

  if (typeof window !== 'undefined') {
    return <>{children}</>;
  }

  return (
    <StyleSheetManager sheet={styledComponentsStyleSheet.instance}>
      {children}
    </StyleSheetManager>
  );
}

// app/layout.tsx
import StyledComponentsRegistry from './components/Registry';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html>
      <body>
        <StyledComponentsRegistry>{children}</StyledComponentsRegistry>
      </body>
    </html>
  );
}

4. Emotion 深度指南

4.1 基础入门

4.1.1 安装
# 完整安装(推荐)
npm install @emotion/react @emotion/styled

# 仅核心
npm install @emotion/react

# 僅 styled API
npm install @emotion/styled
4.1.2 三种使用方式
// 方式 1:css prop(最常用)
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';

function App() {
  return (
    <div
      css={css`
        padding: 20px;
        background: blue;
      `}
    >
      Hello
    </div>
  );
}

// 方式 2:styled 组件
import styled from '@emotion/styled';

const Button = styled.button`
  background: blue;
  color: white;
`;

function App() {
  return <Button>Click</Button>;
}

// 方式 3:jsx 函数
import { jsx } from '@emotion/react';

const styles = {
  container: {
    padding: 20,
    background: 'blue'
  }
};

function App() {
  return <div css={styles.container}>Hello</div>;
}

4.2 css prop 详解

4.2.1 基础使用
/** @jsxImportSource @emotion/react */

function BasicExample() {
  return (
    <div
      css={{
        padding: '20px',
        backgroundColor: '#f0f0f0',
        borderRadius: '8px'
      }}
    >
      Content
    </div>
  );
}
4.2.2 嵌套与选择器
function NestedExample() {
  return (
    <div
      css={{
        padding: '20px',
        
        // 嵌套选择器
        '& .title': {
          fontSize: '24px',
          fontWeight: 'bold'
        },
        
        // 伪类
        '&:hover': {
          backgroundColor: 'blue'
        },
        
        // 伪元素
        '&::before': {
          content: '"→"',
          marginRight: '8px'
        },
        
        // 媒体查询
        '@media (min-width: 768px)': {
          padding: '40px'
        }
      }}
    >
      <div className="title">Title</div>
    </div>
  );
}
4.2.3 动画
import { keyframes } from '@emotion/react';

const fadeIn = keyframes`
  from { opacity: 0; }
  to { opacity: 1; }
`;

function AnimatedExample() {
  return (
    <div
      css={{
        animation: `${fadeIn} 0.5s ease-out`,
        
        // 动态值
        animationDuration: '0.3s',
        animationDelay: '0.1s'
      }}
    >
      Animated Content
    </div>
  );
}

4.3 Styled Components 详解

4.3.1 基础语法
import styled from '@emotion/styled';

// 模板字符串语法
const Button = styled.button`
  padding: 10px 20px;
  background: blue;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
`;

// 对象语法
const Button2 = styled.button({
  padding: '10px 20px',
  background: 'blue',
  color: 'white',
  border: 'none',
  borderRadius: '4px',
  cursor: 'pointer'
});
4.3.2 动态 Props
interface ButtonProps {
  $variant?: 'primary' | 'secondary' | 'danger';
  $size?: 'sm' | 'md' | 'lg';
}

const StyledButton = styled.button<ButtonProps>`
  padding: ${props => {
    switch (props.$size) {
      case 'sm': return '5px 10px';
      case 'lg': return '15px 30px';
      default: return '10px 20px';
    }
  }};
  
  background: ${props => {
    switch (props.$variant) {
      case 'danger': return '#ef4444';
      case 'secondary': return '#6b7280';
      default: return '#3b82f6';
    }
  }};
  
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  
  &:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }
`;

function App() {
  return (
    <>
      <StyledButton>Default</StyledButton>
      <StyledButton $variant="primary">Primary</StyledButton>
      <StyledButton $size="lg">Large</StyledButton>
      <StyledButton disabled>Disabled</StyledButton>
    </>
  );
}
4.3.3 继承与扩展
// 基础样式
const BaseButton = styled.button`
  padding: 10px 20px;
  border-radius: 4px;
  cursor: pointer;
  transition: all 0.2s;
`;

// 扩展样式
const PrimaryButton = styled(BaseButton)`
  background: #3b82f6;
  color: white;
  
  &:hover {
    background: #2563eb;
  }
`;

// 使用 as 切换基础元素
const LinkButton = styled(BaseButton)`
  background: transparent;
  color: #3b82f6;
  
  &:hover {
    text-decoration: underline;
  }
`;

function App() {
  return (
    <>
      <PrimaryButton as="a" href="/submit">As Link</PrimaryButton>
      <LinkButton>Actual Link</LinkButton>
    </>
  );
}

4.4 样式组合

4.4.1 css 标签
import { css } from '@emotion/react';

const baseStyles = css`
  padding: 10px 20px;
  border-radius: 4px;
`;

const primaryStyles = css`
  background: blue;
  color: white;
`;

function Component() {
  return (
    <div css={[baseStyles, primaryStyles]}>
      Combined Styles
    </div>
  );
}
4.4.2 条件样式
function ConditionalStyles({ isActive, isPrimary }) {
  return (
    <div
      css={[
        css`
          padding: 10px 20px;
          border-radius: 4px;
        `,
        isPrimary && css`
          background: blue;
          color: white;
        `,
        isActive && css`
          border: 2px solid blue;
        `
      ]}
    >
      Content
    </div>
  );
}

4.5 主题系统

4.5.1 ThemeProvider
import { ThemeProvider } from '@emotion/react';

interface Theme {
  colors: {
    primary: string;
    secondary: string;
    background: string;
  };
}

const theme: Theme = {
  colors: {
    primary: '#3b82f6',
    secondary: '#6b7280',
    background: '#ffffff'
  }
};

function App() {
  return (
    <ThemeProvider theme={theme}>
      <ThemedComponent />
    </ThemeProvider>
  );
}
4.5.2 使用主题
import { useTheme } from '@emotion/react';

function ThemedComponent() {
  const theme = useTheme();
  
  return (
    <div
      css={{
        background: theme.colors.background,
        color: theme.colors.primary
      }}
    >
      Themed Content
    </div>
  );
}

// 在 styled 中使用
const ThemedButton = styled.button`
  background: ${props => props.theme.colors.primary};
  color: ${props => props.theme.colors.background};
`;

4.6 全局样式

import { Global, css } from '@emotion/react';

function GlobalStyles() {
  return (
    <Global
      styles={css`
        * {
          box-sizing: border-box;
          margin: 0;
          padding: 0;
        }
        
        body {
          font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
          line-height: 1.5;
        }
        
        a {
          color: inherit;
          text-decoration: none;
        }
        
        button {
          font-family: inherit;
          cursor: pointer;
        }
      `}
    />
  );
}

function App() {
  return (
    <>
      <GlobalStyles />
      <YourApp />
    </>
  );
}

4.7 关键优化技巧

4.7.1 静态样式提取
// ❌ 性能问题:每次渲染都创建新对象
function BadExample({ isPrimary }) {
  return (
    <div
      css={{
        padding: '10px',
        background: isPrimary ? 'blue' : 'gray', // 动态部分
        color: 'white' // 静态部分
      }}
    />
  );
}

// ✅ 优化:分离静态和动态样式
const staticStyles = css`
  padding: 10px;
  color: white;
`;

function GoodExample({ isPrimary }) {
  return (
    <div
      css={[
        staticStyles,
        isPrimary ? css`background: blue;` : css`background: gray;`
      ]}
    />
  );
}
4.7.2 useMemo 缓存
import { useMemo } from 'react';

function OptimizedComponent({ variant, size }) {
  const styles = useMemo(() => css`
    padding: ${size === 'lg' ? '20px' : '10px'};
    background: ${variant === 'primary' ? 'blue' : 'gray'};
  `, [variant, size]);
  
  return <div css={styles}>Content</div>;
}

4.8 SSR 支持

4.8.1 extractCritical
import { renderToString } from 'react-dom/server';
import { extractCritical } from '@emotion/server';

function renderToHTML(element) {
  const html = renderToString(element);
  const { ids, css } = extractCritical(html);
  
  return {
    html,
    css,
    ids // 用于 hydration
  };
}
4.8.2 Next.js App Router
// lib/EmotionCache.tsx
'use client';

import createCache from '@emotion/cache';
import { useServerInsertedHTML } from 'next/navigation';
import { CacheProvider } from '@emotion/react';
import React, { useState } from 'react';

export default function EmotionCacheProvider({ children }) {
  const [cache] = useState(() => createCache({ key: 'css' }));

  useServerInsertedHTML(() => {
    const names = Object.keys(cache.inserted);
    let i = names.length;
    let css = '';
    while (i--) {
      const name = names[i];
      css += cache.inserted[name];
    }
    return (
      <style
        key={cache.key}
        data-emotion={`${cache.key} ${names.join(' ')}`}
        dangerouslySetInnerHTML={{
          __html: css,
        }}
      />
    );
  });

  return <CacheProvider value={cache}>{children}</CacheProvider>;
}

4.9 Styled Components vs Emotion 对比

┌─────────────────────────────────────────────────────────────────────────────┐
│                    Styled Components vs Emotion                             │
├───────────────────────────────────────┬─────────────────────────────────────┤
│            Styled Components          │              Emotion                │
├───────────────────────────────────────┼─────────────────────────────────────┤
│ 模板字符串为主要 API                   │ 三种方式:css prop、styled、jsx      │
│ 自动生成类名                          │ 需要手动处理类名                     │
│ React Native 支持                     │ 更轻量,性能更好                    │
│ 更成熟的 SSR 支持                      │ 更灵活的样式组合                    │
│ API 简洁直观                          │ 学习曲线稍陡,但更灵活               │
│                                      │                                     │
│ 适用:喜欢模板字符串语法                │ 适用:需要极致性能                   │
│      React Native 项目                │      需要 css prop 便利性            │
└───────────────────────────────────────┴─────────────────────────────────────┘

5. 架构设计深度对比

5.1 渲染流程对比

┌─────────────────────────────────────────────────────────────────────────────┐
│                          CSS-in-JS 渲染流程                                  │
└─────────────────────────────────────────────────────────────────────────────┘

Styled Components:

[组件定义]
     │
     ▼
[模板解析] ──→ 生成哈希类名
     │
     ▼
[React 渲染] ──→ createElement()
     │
     ▼
[生成 <style>] ──→ 注入到 DOM
     │
     ▼
[浏览器解析] ──→ 样式应用


输出示例:
<style>
  .Button-sc-1a2b3c { background: blue; }
</style>
<button class="Button-sc-1a2b3c">Click</button>


─────────────────────────────────────────────────────────────────────────────

Emotion (@emotion/react):

[组件渲染]
     │
     ▼
[css prop 处理]
     │
     ▼
[样式序列化]
     │
     ▼
[生成 <style>] ──→ 带缓存
     │
     ▼
[浏览器解析]


─────────────────────────────────────────────────────────────────────────────

Utility-First (Tailwind CSS):

[源代码编写]
     │
     ▼
[构建阶段扫描] ──→ 提取 class 属性
     │
     ▼
[JIT 编译] ──→ 匹配工具类
     │
     ▼
[生成 CSS] ──→ 原子化输出
     │
     ▼
[打包到 CSS 文件]


输出示例:
.bg-blue-500 { --tw-bg-opacity: 1; background-color: rgb(59 130 246); }
<button class="bg-blue-500">Click</button>

5.2 样式隔离机制对比

机制 Styled Components Emotion Tailwind CSS
隔离方式 哈希类名 哈希类名 原子类名
全局污染
动态样式 运行时生成 运行时生成 类名组合
清理机制 组件卸载时 组件卸载时 无需清理
SSR 兼容 需要配置 需要配置 原生支持

5.3 动态样式能力对比

场景 Styled Components Emotion Tailwind CSS
props 驱动 ✅ 原生支持 ✅ 原生支持 ⚠️ 需条件类名
主题系统 ✅ 完整 ✅ 完整 ✅ 完整
CSS 变量 ✅ 支持 ✅ 支持 ✅ 支持
计算样式 ✅ 支持 ✅ 支持 ⚠️ 受限

6. 性能基准测试

6.1 测试场景

测试项目:
  组件数量: 500
  样式规则: 平均 15 条/组件
  动态样式: 30% 组件有 props 样式
  测试框架: React 18 + Vite 5
  渲染次数: 1000 次状态更新

6.2 开发环境性能

首次加载时间
┌─────────────────────────────────────────────────────────────────────────────┐
│                         首次加载时间 (ms)                                    │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                            │
│  @emotion/react                                                           │
│  ████████████████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░  165ms  │
│                                                                            │
│  @emotion/styled                                                         │
│  ██████████████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░  155ms  │
│                                                                            │
│  styled-components                                                        │
│  ██████████████████████████████████████████████░░░░░░░░░░░░░░░░░░  185ms  │
│                                                                            │
│  Tailwind CSS                                                            │
│  ████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  85ms   │
│                                                                            │
│  UnoCSS                                                                  │
│  ████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  65ms   │
│                                                                            │
└─────────────────────────────────────────────────────────────────────────────┘
内存占用
┌─────────────────────────────────────────────────────────────────────────────┐
│                         运行时内存占用 (MB)                                  │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                            │
│  styled-components                                                        │
│  ████████████████████████████████████████████████████████░░░░░  52MB     │
│                                                                            │
│  @emotion/react                                                           │
│  ██████████████████████████████████████████████░░░░░░░░░░░░░░░  42MB     │
│                                                                            │
│  @emotion/styled                                                          │
│  ████████████████████████████████████████████░░░░░░░░░░░░░░░░░░  38MB    │
│                                                                            │
│  Tailwind CSS                                                             │
│  ██████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  18MB     │
│                                                                            │
│  UnoCSS                                                                  │
│  ███████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  14MB    │
│                                                                            │
└─────────────────────────────────────────────────────────────────────────────┘

6.3 状态更新性能

1000 次渲染耗时
┌─────────────────────────────────────────────────────────────────────────────┐
│                         渲染耗时 (ms)                                        │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                            │
│  styled-components                                                        │
│  ██████████████████████████████████████████████████████████████  380ms    │
│                                                                            │
│  @emotion/react                                                           │
│  ██████████████████████████████████████████████████████████░░░░░░░  345ms   │
│                                                                            │
│  @emotion/styled                                                          │
│  ████████████████████████████████████████████████████████░░░░░░░░  320ms    │
│                                                                            │
│  Tailwind CSS                                                             │
│  ████████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░  145ms    │
│                                                                            │
│  UnoCSS                                                                  │
│  ██████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  120ms    │
│                                                                            │
└─────────────────────────────────────────────────────────────────────────────┘

6.4 生产构建对比

Bundle 大小
┌─────────────────────────────────────────────────────────────────────────────┐
│                         JS Bundle 增加 (KB)                                 │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                            │
│  styled-components                                                        │
│  ████████████████████████████████████████████████████████░░░░  +32KB     │
│                                                                            │
│  @emotion/react                                                           │
│  ██████████████████████████████████████████████░░░░░░░░░░░░░░░░  +18KB    │
│                                                                            │
│  @emotion/styled                                                          │
│  █████████████████████████████████████████░░░░░░░░░░░░░░░░░░░░░░  +14KB    │
│                                                                            │
│  Tailwind CSS                                                             │
│  ████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░   +2KB     │
│                                                                            │
│  UnoCSS                                                                  │
│  ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░   +1KB     │
│                                                                            │
└─────────────────────────────────────────────────────────────────────────────┘
CSS 输出大小
┌─────────────────────────────────────────────────────────────────────────────┐
│                         CSS 输出大小 (KB, 500 组件)                          │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                            │
│  styled-components                                                        │
│  ██████████████████████████████████████████████████████████████  156KB   │
│                                                                            │
│  @emotion/react                                                           │
│  ██████████████████████████████████████████████████████████░░░░░░░░  138KB  │
│                                                                            │
│  @emotion/styled                                                          │
│  ████████████████████████████████████████████████████████░░░░░░░░░░  128KB  │
│                                                                            │
│  Tailwind CSS (JIT)                                                       │
│  ████████████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░   85KB   │
│                                                                            │
│  UnoCSS                                                                   │
│  ██████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░   52KB   │
│                                                                            │
└─────────────────────────────────────────────────────────────────────────────┘

7. 开发体验详解

7.1 代码组织对比

CSS-in-JS 模式
// 优点:样式与组件共存,易于查找
// 缺点:组件文件可能变长

// Button.tsx
const Button = styled.button`
  padding: 10px 20px;
  background: blue;
  color: white;
`;

const IconButton = styled(Button)`
  padding: 8px;
  border-radius: 50%;
`;

export function ButtonGroup() {
  return (
    <div>
      <Button>Save</Button>
      <IconButton>🔔</IconButton>
    </div>
  );
}
Utility-First 模式
// 优点:HTML 即视图,样式自解释
// 缺点:类名可能很长

// ButtonGroup.tsx
function ButtonGroup() {
  return (
    <div className="flex gap-2">
      <button className="px-5 py-2 bg-blue-500 text-white rounded">
        Save
      </button>
      <button className="p-2 bg-blue-500 text-white rounded-full">
        🔔
      </button>
    </div>
  );
}

7.2 类型安全对比

CSS-in-JS(完整类型推断)
// Styled Components
import styled, { CSSProperties, Theme } from 'styled-components';

interface Props {
  $variant: 'primary' | 'secondary';
  $size: 'sm' | 'md' | 'lg';
}

// 完整类型推断
const Button = styled.button<Props>`
  padding: ${p => p.$size === 'lg' ? '16px 32px' : '8px 16px'};
  background: ${p => p.$variant === 'primary' ? 'blue' : 'gray'};
  
  &:hover {
    background: ${p => p.$variant === 'primary' ? 'darkblue' : 'darkgray'};
  }
`;

// 使用时自动推断
<Button $variant="primary" $size="md" /> // ✅
<Button $variant="invalid" $size="md" /> // ❌ TypeScript 报错
Utility-First(类型辅助)
// 需要使用辅助库
import { cva, type VariantProps } from 'class-variance-authority';

const buttonVariants = cva(
  'rounded-md font-medium transition-colors',
  {
    variants: {
      variant: {
        primary: 'bg-blue-500 text-white hover:bg-blue-600',
        secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
      },
      size: {
        sm: 'px-3 py-1.5 text-sm',
        md: 'px-4 py-2',
        lg: 'px-6 py-3 text-lg',
      },
    },
    defaultVariants: {
      variant: 'primary',
      size: 'md',
    },
  }
);

// 类型推断
type ButtonProps = VariantProps<typeof buttonVariants>;

function Button({ variant, size, className, ...props }: ButtonProps) {
  return (
    <button 
      className={buttonVariants({ variant, size, className })} 
      {...props} 
    />
  );
}

7.3 重构体验对比

样式变更场景
场景:将所有主要按钮从蓝色改为绿色

CSS-in-JS:
├── 需要逐个文件修改
├── 搜索: styled.button`background: blue
├── 替换: background: green
└── 风险: 可能误改其他样式

Utility-First:
├── 修改 tailwind.config.js
├── theme: { colors: { primary: 'green-500' } }
└── 自动全局生效

7.4 调试体验对比

浏览器 DevTools
CSS-in-JS:
├── .sc-Button-abc123 { background: blue; }
├── React DevTools 显示组件树
├── styled-components 插件显示样式
└── 缺点: 哈希类名不易理解

Utility-First:
├── .bg-blue-500 { background: #3b82f6; }
├── 类名即样式含义
├── Tailwind DevTools 插件
└── 优点: 自解释类名

8. 实战案例

8.1 案例:带表单验证的登录页

需求
  • 邮箱/密码输入框
  • 实时验证
  • 错误提示
  • 加载状态
  • 暗色/亮色主题
Styled Components 实现
import styled, { css, keyframes } from 'styled-components';

const spin = keyframes`
  to { transform: rotate(360deg); }
`;

const Container = styled.div`
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background: ${props => props.theme.colors.background};
  padding: 20px;
`;

const Form = styled.form`
  width: 100%;
  max-width: 400px;
  padding: 40px;
  background: ${props => props.theme.colors.card};
  border-radius: 12px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
`;

const Title = styled.h1`
  font-size: 24px;
  font-weight: 600;
  color: ${props => props.theme.colors.text};
  margin-bottom: 24px;
  text-align: center;
`;

const InputGroup = styled.div`
  margin-bottom: 16px;
`;

const Label = styled.label`
  display: block;
  font-size: 14px;
  font-weight: 500;
  color: ${props => props.theme.colors.text};
  margin-bottom: 6px;
`;

const Input = styled.input<{ $hasError?: boolean }>`
  width: 100%;
  padding: 12px 16px;
  font-size: 16px;
  border: 2px solid ${props => 
    props.$hasError ? '#ef4444' : props.theme.colors.border
  };
  border-radius: 8px;
  outline: none;
  transition: border-color 0.2s;
  
  &:focus {
    border-color: ${props => 
      props.$hasError ? '#ef4444' : props.theme.colors.primary
    };
  }
  
  &::placeholder {
    color: ${props => props.theme.colors.placeholder};
  }
`;

const ErrorMessage = styled.span`
  display: block;
  font-size: 12px;
  color: #ef4444;
  margin-top: 4px;
`;

const SubmitButton = styled.button<{ $isLoading?: boolean }>`
  width: 100%;
  padding: 14px;
  font-size: 16px;
  font-weight: 600;
  color: white;
  background: ${props => props.theme.colors.primary};
  border: none;
  border-radius: 8px;
  cursor: pointer;
  transition: all 0.2s;
  position: relative;
  
  &:hover:not(:disabled) {
    background: ${props => props.theme.colors.primaryHover};
    transform: translateY(-1px);
  }
  
  &:disabled {
    opacity: 0.7;
    cursor: not-allowed;
  }
  
  ${props => props.$isLoading && css`
    color: transparent;
    
    &::after {
      content: '';
      position: absolute;
      width: 20px;
      height: 20px;
      top: 50%;
      left: 50%;
      margin-left: -10px;
      margin-top: -10px;
      border: 2px solid white;
      border-right-color: transparent;
      border-radius: 50%;
      animation: ${spin} 0.8s linear infinite;
    }
  `}
`;

// 主题
const theme = {
  colors: {
    background: '#f5f5f5',
    card: '#ffffff',
    text: '#1f2937',
    primary: '#3b82f6',
    primaryHover: '#2563eb',
    border: '#d1d5db',
    placeholder: '#9ca3af'
  }
};

// 组件
function LoginFormStyled() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [errors, setErrors] = useState<{email?: string; password?: string}>({});
  const [isLoading, setIsLoading] = useState(false);
  
  const validate = () => {
    const newErrors: typeof errors = {};
    if (!email.includes('@')) {
      newErrors.email = '请输入有效的邮箱地址';
    }
    if (password.length < 6) {
      newErrors.password = '密码至少需要 6 位';
    }
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!validate()) return;
    
    setIsLoading(true);
    // 模拟 API 调用
    await new Promise(resolve => setTimeout(resolve, 1500));
    setIsLoading(false);
  };
  
  return (
    <ThemeProvider theme={theme}>
      <Container>
        <Form onSubmit={handleSubmit}>
          <Title>登录</Title>
          
          <InputGroup>
            <Label>邮箱</Label>
            <Input
              type="email"
              placeholder="your@email.com"
              value={email}
              onChange={e => setEmail(e.target.value)}
              $hasError={!!errors.email}
            />
            {errors.email && <ErrorMessage>{errors.email}</ErrorMessage>}
          </InputGroup>
          
          <InputGroup>
            <Label>密码</Label>
            <Input
              type="password"
              placeholder="••••••••"
              value={password}
              onChange={e => setPassword(e.target.value)}
              $hasError={!!errors.password}
            />
            {errors.password && <ErrorMessage>{errors.password}</ErrorMessage>}
          </InputGroup>
          
          <SubmitButton type="submit" $isLoading={isLoading}>
            {isLoading ? '' : '登录'}
          </SubmitButton>
        </Form>
      </Container>
    </ThemeProvider>
  );
}
Tailwind CSS + clsx 实现
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';

function cn(...inputs: (string | undefined | null | false)[]) {
  return twMerge(clsx(inputs));
}

function LoginFormTailwind() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [errors, setErrors] = useState<{email?: string; password?: string}>({});
  const [isLoading, setIsLoading] = useState(false);
  
  const validate = () => {
    const newErrors: typeof errors = {};
    if (!email.includes('@')) {
      newErrors.email = '请输入有效的邮箱地址';
    }
    if (password.length < 6) {
      newErrors.password = '密码至少需要 6 位';
    }
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!validate()) return;
    
    setIsLoading(true);
    await new Promise(resolve => setTimeout(resolve, 1500));
    setIsLoading(false);
  };
  
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50 p-5">
      <form 
        onSubmit={handleSubmit}
        className="w-full max-w-md p-10 bg-white rounded-xl shadow-lg"
      >
        <h1 className="text-2xl font-semibold text-gray-900 mb-6 text-center">
          登录
        </h1>
        
        <div className="mb-4">
          <label className="block text-sm font-medium text-gray-700 mb-1.5">
            邮箱
          </label>
          <input
            type="email"
            placeholder="your@email.com"
            value={email}
            onChange={e => setEmail(e.target.value)}
            className={clsx(
              "w-full px-4 py-3 text-base border-2 rounded-lg outline-none transition-colors",
              "placeholder:text-gray-400",
              errors.email 
                ? "border-red-500 focus:border-red-500" 
                : "border-gray-300 focus:border-blue-500"
            )}
          />
          {errors.email && (
            <span className="block text-xs text-red-500 mt-1">
              {errors.email}
            </span>
          )}
        </div>
        
        <div className="mb-6">
          <label className="block text-sm font-medium text-gray-700 mb-1.5">
            密码
          </label>
          <input
            type="password"
            placeholder="••••••••"
            value={password}
            onChange={e => setPassword(e.target.value)}
            className={clsx(
              "w-full px-4 py-3 text-base border-2 rounded-lg outline-none transition-colors",
              "placeholder:text-gray-400",
              errors.password 
                ? "border-red-500 focus:border-red-500" 
                : "border-gray-300 focus:border-blue-500"
            )}
          />
          {errors.password && (
            <span className="block text-xs text-red-500 mt-1">
              {errors.password}
            </span>
          )}
        </div>
        
        <button
          type="submit"
          disabled={isLoading}
          className={clsx(
            "w-full py-3.5 text-base font-semibold text-white rounded-lg",
            "transition-all duration-200",
            "hover:-translate-y-0.5 hover:shadow-lg",
            "disabled:opacity-70 disabled:cursor-not-allowed",
            isLoading && "relative text-transparent"
          )}
          style={{ background: '#3b82f6' }}
        >
          {isLoading ? '' : '登录'}
          {isLoading && (
            <span className="absolute inset-0 flex items-center justify-center">
              <span className="w-5 h-5 border-2 border-white border-r-transparent rounded-full animate-spin" />
            </span>
          )}
        </button>
      </form>
    </div>
  );
}

8.2 代码量对比

指标 Styled Components Tailwind CSS
总行数 142 行 78 行
组件文件 1 个 1 个
样式定义 内联 类名
可读性 ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
可维护性 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐

9. 迁移与混用策略

9.1 从 CSS-in-JS 迁移到 Utility-First

渐进式迁移策略
// 阶段 1:新组件使用 Utility-First,旧组件保持不变
function NewComponent() {
  return <div className="p-4 bg-white">新组件</div>;
}

const OldComponent = styled.div`
  padding: 1rem;
  background: white;
`;

// 阶段 2:共存
function Page() {
  return (
    <OldComponent>
      <NewComponent />
    </OldComponent>
  );
}

// 阶段 3:逐步重写旧组件
样式映射表
┌─────────────────────────────────────────────────────────────────────────────┐
                         样式转换对照表                                       
├─────────────────────────────────────┬─────────────────────────────────────┤
 styled-components                    Tailwind CSS                        
├─────────────────────────────────────┼─────────────────────────────────────┤
 display: flex                        flex                                
 display: grid                        grid                                
 flex-direction: column              flex-col                            
 align-items: center                 items-center                        
 justify-content: space-between      justify-between                    
 padding: 16px                       p-4                                 
 padding-top: 16px                   pt-4                                
 margin: 16px                        m-4                                 
 margin-bottom: 16px                 mb-4                                
 color: #fff                        │ text-white                          │
 background: #000                   │ bg-black                            │
 border-radius: 4px                 rounded                             
 font-size: 16px                    text-base (1rem)                   
 font-weight: 700                   font-bold                           
 width: 100%                         w-full                              
 height: 100%                        h-full                              
 box-shadow: 0 1px 3px rgba(0,0,0,0.1)  shadow-sm                          
 &:hover { ... }                    hover:...                           
 @media (min-width: 768px) { ... }  md:...                              
└─────────────────────────────────────┴─────────────────────────────────────┘

9.2 混合使用模式

场景:组件库 + 项目样式
// 使用 UI 库(可能使用 styled-components)
import { Button as AntButton } from 'antd';

// 项目样式使用 Tailwind
function MyPage() {
  return (
    <div className="p-4">
      <AntButton type="primary">库组件</AntButton>
      <button className="ml-4 px-4 py-2 bg-blue-500">
        项目按钮
      </button>
    </div>
  );
}
场景:Tailwind + CSS-in-JS 动画
import styled from 'styled-components';
import { keyframes } from 'styled-components';

// 使用 styled-components 处理复杂动画
const fadeIn = keyframes`
  from { opacity: 0; transform: scale(0.9); }
  to { opacity: 1; transform: scale(1); }
`;

const AnimatedContainer = styled.div`
  animation: ${fadeIn} 0.3s ease-out;
`;

// Tailwind 处理布局
function Modal() {
  return (
    <AnimatedContainer className="fixed inset-0 flex items-center justify-center bg-black/50">
      <div className="bg-white rounded-lg p-6 max-w-md">
        Modal Content
      </div>
    </AnimatedContainer>
  );
}

10. 常见问题与解决方案

10.1 CSS-in-JS 常见问题

Q1: 样式闪烁(FOUC)
// 问题:SSR 时页面闪烁
// 解决:使用 extractCritical 或 styled-components 的 SSR 支持

// Next.js App Router
import StyledComponentsRegistry from './lib/registry';

export default function Layout({ children }) {
  return (
    <html>
      <body>
        <StyledComponentsRegistry>{children}</StyledComponentsRegistry>
      </body>
    </html>
  );
}
Q2: 样式不生效
// 问题:props 样式未应用
// 解决:确保使用正确的 prop 名称

// ❌ 错误
const Button = styled.button`
  background: ${props.variant}; // 缺少 theme 或正确引用
`;

// ✅ 正确
const Button = styled.button`
  background: ${props => props.$variant}; // 使用 transient props
  background: ${props => props.theme.colors.primary}; // 使用主题
`;
Q3: 性能问题
// 问题:大量动态样式导致性能下降
// 解决:提取静态样式

// ❌ 每次渲染都创建对象
const Bad = styled.div`
  padding: 10px;
  background: ${props => props.$color}; // 动态
`;

// ✅ 分离静态和动态
const StaticStyles = styled.css`
  padding: 10px;
`;

const Good = styled.div`
  ${StaticStyles}
  background: ${props => props.$color};
`;

10.2 Utility-First 常见问题

Q1: 类名过长
// 问题:复杂组件类名太多
// 解决:使用 shortcuts 或组件封装

// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      shortcuts: {
        'card': 'bg-white rounded-lg shadow-sm p-6',
        'btn-primary': 'px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600',
        'input-base': 'w-full px-4 py-2 border border-gray-300 rounded focus:ring-2',
      }
    }
  }
};

// 使用
function Component() {
  return (
    <div className="card">
      <button className="btn-primary">Click</button>
    </div>
  );
}
Q2: 任意值写法
// 问题:需要使用非预设值
// 解决:使用方括号语法

// 任意颜色
<div className="bg-[#123456]">任意颜色</div>

// 任意数值
<div className="w-[123px] h-[calc(100vh-2rem)]">任意值</div>

// 任意属性
<div className="[--color:red]">CSS 变量</div>
Q3: 深层选择器
// 问题:需要复杂选择器
// 解决:使用 [&] 任意选择器

// & = 当前元素
<div className="[&]:p-4">当前元素</div>

// 嵌套
<div className="[&_p]:font-bold [&_p+&_p]:mt-2">
  <p>子元素</p>
</div>

// 伪类组合
<div className="[&:hover>span]:opacity-100">
  <span>悬停显示</span>
</div>

11. 总结与选型建议

11.1 方案对比矩阵

维度 Styled Components Emotion Tailwind CSS UnoCSS
性能 ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
易用性 ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐
灵活性 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐
类型安全 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐
SSR ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
学习曲线 ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐
生态 ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐
未来趋势 ⭐⭐ ⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐

11.2 选型决策

┌─────────────────────────────────────────────────────────────────────────────┐
│                            选型决策树                                        │
└─────────────────────────────────────────────────────────────────────────────┘

开始
  │
  ├─► 项目类型?
  │    │
  │    ├─► React Native → styled-components ✅
  │    │
  │    ├─► 新 Web 项目 → Tailwind CSS / UnoCSS ✅
  │    │
  │    └─► Vue 项目 → Tailwind CSS / UnoCSS ✅
  │
  ├─► 团队背景?
  │    │
  │    ├─► 熟悉 JS/React → CSS-in-JS 或 Utility-First 都可
  │    │
  │    └─► 熟悉 CSS → Utility-First 更容易上手
  │
  ├─► 性能要求?
  │    │
  │    ├─► 极致性能 → Utility-First
  │    │
  │    └─► 一般性能 → 都可以
  │
  └─► 项目规模?
       │
       ├─► 小型 → 任意方案
       │
       ├─► 中大型 → Utility-First + 组件库
       │
       └─► 遗留项目 → 渐进式迁移

11.3 2026 年建议

┌─────────────────────────────────────────────────────────────────────────────┐
│                          2026 年技术建议                                     │
└─────────────────────────────────────────────────────────────────────────────┘

✅ 推荐选择:
│
├── 新 Web 项目 → Tailwind CSS 4.0
│   ├── 理由:性能最佳、生态成熟、Rust 引擎
│   └── 适用:ReactVueSvelte
│
├── 需要 React Nativestyled-components
│   ├── 理由:唯一全面支持 RN 的方案
│   └── 注意:考虑逐步迁移
│
├── 遗留项目 → 渐进式迁移
│   ├── 新组件:Utility-First
│   └── 旧组件:保持不变,逐步重写
│
└── 追求极致性能 → UnoCSS
    ├── 理由:即时生成、最小 Bundle
    └── 适用:大型项目、高性能需求

⚠️ 谨慎选择:
│
├── 新项目使用 CSS-in-JS
│   ├── React 官方不推荐
│   └── 维护风险增加
│
└── 纯 CSS-in-JS 方案
    └── 考虑混合方案

🔄 趋势观察:
│
├── Tailwind CSS 4.0 (Rust) 将成为主流
├── UnoCSS 生态快速发展
├── CSS 原生特性 (@layer, CSS 变量) 减少框架依赖
└── 混合方案(Utility-First + 组件库)成为常态

11.4 行动清单

## 开始使用 Utility-First

1. [ ] 安装 Tailwind CSS 或 UnoCSS
2. [ ] 配置设计系统(颜色、间距、字体)
3. [ ] 团队学习基础工具类
4. [ ] 使用 cva/class-variance-authority 管理变体
5. [ ] 使用 tailwind-merge 处理类名合并

## 从 CSS-in-JS 迁移

1. [ ] 评估当前样式复杂度
2. [ ] 新组件使用 Utility-First
3. [ ] 旧组件逐步重写
4. [ ] 移除不必要的 CSS-in-JS 依赖
5. [ ] 统一代码规范

📚 延伸阅读


CSS、Tailwind CSS、UnoCSS篇结束

彻底讲透医院移动端手持设备PDA离线同步架构:从"记账本"到"分布式共识",吊打面试官

一套解决"手术室铅门屏蔽导致WiFi掉线"的工业级方案,如何从生活常识进化成分布式系统理论?


第一层:幼儿园版 —— 为什么要有这个算法?

想象一下,你是一个在手术室工作的护士。

场景还原

  • 你拿着一个PDA(像一个大手机)给病人做登记
  • 手术室的铅门像一个大铁盖子,WiFi信号根本穿不进来
  • 电梯里、地下室、病区走廊,网络时有时无

问题来了
如果你每次点“保存”都要等网络响应,那在信号差的地方,APP就会一直转圈圈,甚至闪退。病人等着做手术,你却在和机器怄气。

最朴素的想法
能不能不管有没有网,我先记下来?等有网的时候,手机自己悄悄传上去,别让我操心。

这就是算法的原点本地优先(Offline-First) ——网络只是用来同步的工具,不是工作的前提。


第二层:小学生版 —— 用“草稿本”和“作业本”理解

我们把整个过程简化成小学生写作业的场景。

传统模式(在线模式)

  • 老师(服务器)说:“写作业必须在我眼皮底下写”
  • 你(客户端)只能对着老师写,老师一转身(断网),你就写不了
  • 这就是“在线API”的困境

本地优先模式

第一步:准备草稿本(本地数据库)
你随身带一个草稿本(手机里的SQLite数据库)。不管老师在不在,你先在草稿本上写。

第二步:给作业打标签
你在每道题旁边画个小标记:

  • 已写完(已保存到本地)
  • 老师还没看(待同步)
  • 这是修改过的(操作类型)

第三步:抄作业机制(同步逻辑)
网络好了,你开始往老师的正式作业本上抄:

  • 先抄新写的(增量同步)
  • 抄到一半断网了,记住抄到哪了(断点续传)
  • 下次联网接着抄

第四步:两人同时改作业怎么办(冲突解决)
如果两个同学同时改了同一道题:

  • 简单处理:谁最后改的听谁的(时间戳优先)
  • 高级处理:A改了第一问,B改了第二问,合并起来(字段级合并)

核心口诀先写草稿,有空再抄,抄不完的记位置,打架了看情况合并。


第三层:初中生版 —— 数据结构的雏形

现在我们要把草稿本设计得更科学一些。

3.1 普通笔记本的局限

如果只是简单存数据,会碰到几个问题:

  1. 我怎么知道哪些数据已经同步过了?
  2. 数据被改了好几次,只记最后的结果够吗?
  3. 每次同步要把整个本子都给老师看吗?太费劲了。

3.2 给数据加“贴纸”

我们在数据库的每一行数据后面,贴上几个隐藏标签:

字段名 含义 取值
sync_status 同步状态 0-未同步,1-同步中,2-已同步
op_type 操作类型 INSERT/UPDATE/DELETE
version 版本号 时间戳或自增数字

这样设计的好处

  • 一眼就能看出哪些数据还没上传
  • 知道这条数据是新增的、修改的还是删除的
  • 版本号可以用来比对谁更新

3.3 增量同步的雏形

不用每次都把所有数据传给服务器。客户端记住自己最后一次同步的版本号(last_sync_version),下次只问服务器:

“上次同步到版本100了,你这有版本101之后的新数据吗?”

这就是增量步进机制的雏形。


第四层:高中生版 —— 引入“流水账”思维

到了高中,我们要解决一个更复杂的问题:操作日志(Op-Log)

4.1 只记结果的问题

假设你修改了一条数据3次:

  1. 体温36.5 → 37.0
  2. 体温37.0 → 37.5
  3. 体温37.5 → 36.8

如果只存最后的结果(36.8),服务器永远不知道中间发生了什么。这在某些场景下是不行的(比如医疗审计需要完整轨迹)。

4.2 引入“流水账”

我们不再只关心数据长什么样,而是关心数据是怎么变的。

新建一个操作日志表,记录:

时间 操作人 对象 字段 旧值 新值
10:01 护士A 患者X 体温 36.5 37.0
10:05 护士A 患者X 体温 37.0 37.5
10:10 护士B 患者X 血压 120 130

这个设计的神奇之处

  • 网络断了也不怕,流水账存在本地
  • 恢复联网后,按顺序重放(Replay)这些操作
  • 即使服务器数据乱了,也能通过重放恢复到正确状态
  • 可以追溯每一个操作的源头

4.3 触发器自动记账

手动记录太麻烦。我们让数据库自己记:

-- 创建触发器:当体温表被修改时,自动往日志表插一条记录
CREATE TRIGGER log_temperature_changes
AFTER UPDATE ON patient_vitals
FOR EACH ROW
BEGIN
    INSERT INTO sync_log (record_id, field_name, old_value, new_value, op_time)
    VALUES (NEW.id, 'temperature', OLD.temperature, NEW.temperature, NOW());
END;

这就是数据操作溯源的核心思想。


第五层:大学本科版 —— 完整同步协议设计

现在我们要设计一套完整的同步协议,包含握手、传输、确认、重试、冲突解决。

5.1 网络状态检测

APP需要知道网络什么时候好、什么时候坏。

基础版:监听浏览器的online/offline事件

window.addEventListener('online', () => {
    console.log('网络恢复了,开始同步');
    startSync();
});

进阶版:自适应心跳检测

  • 正常时:每30秒发一次心跳(省电)
  • 弱网时:每5秒发一次心跳(快速感知恢复)
  • 断网时:停止心跳(省流量)

5.2 同步的四个阶段

当检测到网络恢复,启动以下流程:

第一阶段:数据预校验

客户端先发个“打招呼”包,告诉服务器:

  • 我有多少条待同步数据
  • 这些数据的MD5摘要

服务器快速比对,如果有冲突,提前告诉客户端:“你有一条数据和服务器版本不一致,准备打架。”

第二阶段:双向增量同步

向上推(Push)

  • 把本地sync_status=0的数据打包
  • 每20条一个包(分片上传),避免一次性数据太大
  • 每个包带一个唯一ID(client_request_id

幂等设计:如果网络波动导致同一个包发了两次,服务器看到重复的ID,直接返回“已收到”,不重复入库。这保证了数据不重复

向下拉(Pull)

  • 客户端告诉服务器自己最新的版本号
  • 服务器返回更新的数据

第三阶段:事务确认(ACK机制)

原子提交:只有当收到服务器的成功确认(ACK)后,客户端才把本地sync_status从0改成2。

重试策略:如果失败,不能疯狂重试。采用指数避退

  • 第1次失败:等1秒重试
  • 第2次失败:等2秒
  • 第3次:等4秒
  • 第4次:等8秒
  • 最大不超过1分钟

这防止了网络刚恢复又断开时的“雪崩效应”。

第四阶段:冲突裁决

这是最复杂的部分。两个护士同时改同一个病人怎么办?

策略一:时间戳优先(Last Write Wins)

  • 谁最后改的听谁的
  • 适用于体征数据这种“只取最新值”的场景

策略二:字段级合并

  • A护士改了体温,B护士改了血压
  • 服务器把两个修改合并成一条新数据
  • 适用于病历文书这种多字段独立的场景

策略三:版本向量(Vector Clock)

  • 分布式系统的高级解法
  • 记录每个节点的修改历史
  • 复杂但精确

第六层:硕士阶段 —— 极端场景下的专项优化

现在我们要把系统做到99.9%的可用性,必须处理各种极端情况。

6.1 弱网下的分片传输

如果同步的数据里有照片(比如手术签字单),文件可能好几兆。

问题:一次性传一个大文件,传一半断网了,下次要从头传。

解法:二进制分片 + 断点续传

// 把文件切成1MB的片
const CHUNK_SIZE = 1024 * 1024; // 1MB

function uploadFile(file, fileId) {
    const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
    
    for (let i = 0; i < totalChunks; i++) {
        const chunk = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
        uploadChunk(chunk, fileId, i);
    }
}

// 上传每个片
function uploadChunk(chunk, fileId, index) {
    // 检查这个片是否已经上传过(断点续传)
    if (isChunkUploaded(fileId, index)) {
        return; // 已上传,跳过
    }
    
    // 上传逻辑...
}

效果:医生走出手术室WiFi覆盖区,回到办公室后能从上次断开的字节位继续传,不用重头传。

6.2 乐观UI解决卡顿问题

痛点:护士点保存,如果网络不好,界面转圈圈,护士以为卡了,会再点一次,导致重复提交。

解法:乐观UI

function saveVitalSign(data) {
    // 1. 立即显示"已保存"(乐观更新)
    showSuccessMessage('已保存(本地)');
    
    // 2. 角落里显示黄色小图标"同步中"
    showSyncStatus('syncing', 'yellow');
    
    // 3. 真正去同步
    syncToServer(data).then(() => {
        // 4. 同步成功,黄变绿
        showSyncStatus('synced', 'green');
    }).catch(() => {
        // 5. 同步失败,黄变红
        showSyncStatus('failed', 'red');
    });
}

用户体验:护士不用盯着进度条发呆,可以继续做下一件事。真正实现了无感覆盖

6.3 写前日志(WAL)解决并发卡顿

问题:后台正在同步大量数据(写数据库),前台护士想查患者列表(读数据库),会不会卡?

解法:SQLite的WAL模式

默认情况下,SQLite是读写互斥的:写的时候不能读,读的时候不能写。

开启WAL(Write-Ahead Logging)模式后:

  • 写操作:写在日志文件里
  • 读操作:读原数据库文件
  • 两者可以同时进行
PRAGMA journal_mode=WAL; -- 开启WAL模式

效果:同步任务在后台疯狂写数据,前台查询患者列表依然丝滑流畅。

6.4 智能带宽管控

如果同时有很多数据要同步,不能一股脑全发出去,会把正常业务带宽占满。

策略

  • 核心数据(如危急值):高优先级,立即发
  • 普通数据(如常规体征):中优先级,排队发
  • 非关键数据(如操作日志):低优先级,空闲时发

实现:维护三个优先级的队列

class SyncQueue {
    constructor() {
        this.highPriority = []; // 立即发
        this.mediumPriority = []; // 普通
        this.lowPriority = []; // 空闲时发
    }
    
    add(data, priority) {
        this[priority + 'Priority'].push(data);
        this.scheduleSync();
    }
    
    scheduleSync() {
        // 先发高优先级
        if (this.highPriority.length > 0) {
            this.sendBatch(this.highPriority);
        } 
        // 如果网络空闲,发中优先级
        else if (this.isNetworkIdle()) {
            this.sendBatch(this.mediumPriority);
        }
        // 极空闲时发低优先级
        // ...
    }
}

第七层:博士阶段 —— 理论的升华与范式总结

站在更高的维度,我们可以总结出这套算法的数学本质哲学意义

7.1 从CAP定理看本地优先

分布式系统有个著名的CAP定理:一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance),三者只能取其二。

传统在线API选择了:

  • 放弃分区容错性(P):网络断了你就用不了
  • 保持一致性(C)和可用性(A)

本地优先架构的选择

  • 接受分区是常态(P)
  • 保证可用性(A):断网也能用
  • 通过异步同步实现最终一致性(Eventually Consistent)

哲学转变:从“强一致性”到“最终一致性”,从“网络必须可靠”到“网络不可靠是默认前提”。

7.2 数据结构的数学本质

这套算法的核心数据结构可以抽象为:

本地影子库 = 业务数据 + 元数据(状态+版本+操作类型)

操作日志 = 时间序列上的状态转移函数

同步协议 = 分布式状态机中的状态复制

用数学语言描述:

  • 每个客户端是一个独立的状态机
  • 操作日志是状态转移的输入序列
  • 同步过程是两个状态机之间的状态对齐
  • 冲突解决是状态合并函数

7.3 CRDT的引入(最前沿的方向)

CRDT(Conflict-free Replicated Data Types,无冲突复制数据类型)是一种更高级的解决方案。

传统冲突解决:先发生冲突,再解决(打架了再拉架)

CRDT的思路:设计数据结构,使其天生不会打架

比如一个计数器:

  • A护士加1
  • B护士加2
  • 无论以什么顺序同步,最终结果都是3

这就是数学上可证明的最终一致性

CRDT在医疗场景的应用

  • 计数器类数据(如输液滴数):天然适用
  • 集合类数据(如用药清单):可以设计成“添加永不冲突”的结构
  • 文本类数据(如病历):可以使用类似于Git的合并算法

7.4 算法复杂度分析

空间复杂度

  • 本地影子库:O(n),n是业务数据量
  • 操作日志:O(m),m是操作次数,可能远大于n

时间复杂度

  • 增量同步:O(k),k是变更的数据量,不是全量
  • 冲突检测:O(1) 通过版本号
  • 字段级合并:O(f),f是字段数量

网络开销

  • 相比全量同步,减少90%以上的流量
  • 相比在线API,增加约20%的握手开销

7.5 理论的落地:一个完整的数学定义

我们可以给出这个同步算法的形式化定义:

设客户端状态为 C,服务器状态为 S,同步协议 P 是一个四元组:

P = (D, L, V, M)

其中:

  • D 是本地影子库,D = {(key, value, status, version)}
  • L 是操作日志,L = [(op, timestamp, vector_clock)]
  • V 是版本向量,V = [v1, v2, ..., vn]
  • M 是合并函数,M: (C_state, S_state) → new_state

同步的目标是:经过有限次同步后,C 和 S 达到最终一致,即:
lim_{t→∞} distance(C_t, S_t) = 0


第八层:简历/面试话术 —— 如何包装成亮点

现在你已经完全理解了这套算法,关键是怎么在面试中说出来。

8.1 初级话术(说得清)

“我在做医院移动护理项目时,解决了手术室WiFi信号差的问题。我采用了本地优先的设计,数据先存SQLite,网络好了再同步。通过给数据加同步状态字段,实现了增量同步。还用了操作日志记录变更历史,保证数据不丢。”

8.2 中级话术(有深度)

“针对手术室铅门屏蔽导致的频繁断网场景,我设计了一套本地优先的增量同步架构。核心是本地影子库+操作日志+增量步进的三位一体模型。

我在业务表中扩展了sync_status、version等元数据,用于状态追踪。同时通过数据库触发器记录操作日志,确保操作可追溯。同步时采用版本比对,只传增量数据,减少90%的流量。

为了解决并发冲突,我实现了字段级合并策略,两个护士同时修改不同字段时能自动合并。针对大文件传输,我做了二进制分片和断点续传,保证照片等数据能可靠上传。”

8.3 高级话术(有体系,有数据)

“在处理手术室移动端业务时,针对铅门屏蔽导致的频繁掉线难题,我放弃了传统的在线API模式,实现了一套本地优先的增量同步架构

架构设计
我基于SQLite构建了本地影子库,在业务表基础上扩展了sync_status、version等元数据,实现数据状态的本地持久化。同时引入操作日志表,通过数据库触发器自动记录每一次字段级变更,形成可追溯的变更流水线。

同步协议
设计了四阶段同步流程:预校验(MD5摘要比对)→双向增量(分片上传+幂等处理)→事务确认(原子提交+指数避退)→冲突裁决(时间戳优先+字段级合并)。

专项优化

  • 针对弱网环境,实现二进制分片传输和断点续传,大文件传输成功率从72%提升到99.5%
  • 采用自适应心跳检测,网络恢复后500ms内启动同步
  • 引入乐观UI,护士点击保存后即时反馈,后台静默同步,用户无感知
  • 开启SQLite WAL模式,实现读写并发,同步时不阻塞前台查询

成果
这套架构把数据同步的失败率从原始的15%降低到了0.1%以下。最关键的是实现了业务上的无感覆盖:医生在盲区录入的数据,走出病区的瞬间就能在几百毫秒内完成静默同步。医生根本不知道网络断过,业务照常进行。

理论升华
这套方案的实质是从CAP理论中选择了AP(可用性+分区容忍性),通过最终一致性保证数据准确。从数学上看,它是分布式状态机之间的状态复制协议,操作日志是状态转移函数的输入序列。”

8.4 应对追问:你可能被问到的点

Q1:如果本地数据量很大,同步会不会很慢?

A:我们做了三级优化。第一,增量同步,只传变更数据。第二,分片并发,20条一批同时上传。第三,优先级调度,核心数据优先传。实测1万条数据能在30秒内完成同步。

Q2:怎么保证数据不丢?

A:四重保障。第一,本地持久化,写入成功才返回用户。第二,事务确认,收到服务端ACK才标记已同步。第三,重试机制,失败后指数避退重试。第四,操作日志溯源,即使极端情况也能通过日志恢复。

Q3:多个端同时改同一份数据怎么办?

A:我们实现了字段级合并。通过版本向量记录每个字段的最后修改时间和节点,同步时对比向量,不同字段自动合并,同一字段以时间戳为准。这比简单的“最后写入胜出”更精细。

Q4:你们的方案和现有的框架(如CouchDB、PouchDB)有什么区别?

A:现有框架解决的是通用同步问题,但我们针对医疗场景做了深度定制。比如字段级合并策略符合医疗文书的多作者协作场景,优先级调度保证危急值优先上传,分片传输针对医疗影像优化。我们是业务驱动的技术选型和定制。


第九层:上帝视角 —— 与其他技术的对比

9.1 与CouchDB/PouchDB对比

CouchDB是成熟的Offline-First数据库,自带同步协议。

我们的方案 vs CouchDB

  • 相同点:都采用MVCC(多版本并发控制)、增量同步、冲突检测
  • 不同点:我们更轻量,直接基于SQLite,不需要部署CouchDB服务端
  • 优势:医疗系统常有现有关系数据库,我们的方案更容易集成

9.2 与GraphQL订阅对比

GraphQL订阅通过WebSocket实现实时推送。

适用场景不同:

  • GraphQL订阅:适合在线实时协作(如在线文档)
  • 我们的方案:适合网络不稳定、需要离线工作的场景(如移动护理)

9.3 与WebSocket/长连接对比

WebSocket假设网络持续可用。

我们的方案假设网络不可靠是常态。

哲学差异:WebSocket是在线优先,我们是离线优先

9.4 与Git版本控制类比

有趣的是,我们的方案和Git惊人地相似:

Git 我们的方案
本地仓库 本地影子库
commit 操作日志
push/pull 双向同步
merge 冲突解决
branch 多客户端分支
rebase 版本对齐

这个类比可以帮助面试官快速理解。


第十层:总结与核心记忆点

如果面试紧张,只要记住这4个关键词,就能串联起整个知识体系:

核心四词记忆法

1. 本地优先(Offline-First)

  • 哲学:网络是同步工具,不是工作前提
  • 实现:数据先写本地SQLite

2. 操作日志(Op-Log)

  • 哲学:记流水账比记结果更有价值
  • 实现:触发器自动记录变更历史

3. 增量同步(Incremental Sync)

  • 哲学:只传变化的部分
  • 实现:版本号+MD5摘要+分片传输

4. 最终一致性(Eventual Consistency)

  • 哲学:允许暂时不一致,但最终会一致
  • 实现:冲突解决+字段级合并

🎯 一句话概括

这是一套把“网络不可靠”作为默认前提,通过“本地存储+操作日志+增量同步+冲突解决”实现业务无感覆盖的分布式数据同步方案。

🔥 终极必杀技

如果面试官问:“你觉得自己最牛的技术方案是什么?”

你可以这样回答(配合自信的眼神):

“我最引以为豪的是一个解决手术室断网同步的方案。在那个场景里,网络不是偶尔断,是物理层面被铅门屏蔽。我设计了一套本地优先的增量同步架构,把数据同步的失败率从15%降到0.1%以下。

最让我得意的是,这个方案不仅仅是写代码,而是从哲学层面重新思考了网络和业务的关系——我们不再依赖网络,而是让网络服务于业务。医生在盲区录入的数据,走出手术室的瞬间就完成静默同步,他完全感知不到网络的存在。

我觉得,最好的技术就是让用户感受不到技术的存在。这套方案做到了。”

CSS属性 - 边距属性

CSS属性 - 边距属性

内边距

边框和内容之间的距离就是内边距。

  • 格式:
/* 非连写 */
padding-top: ${padding-top};
padding-right: ${padding-right};
padding-bottom: ${padding-bottom};
padding-left: ${padding-left};

/* 连写 */
padding: ${padding-top} ${padding-right} ${padding-bottom} ${padding-left};
  • 渲染样式:

    未设置padding属性的渲染效果:

    可以通过开发者工具中的Computed面板观察外面红色盒子的样式:

    nopadding-computed

    Computed面板当中可以看到这个时候的padding属性为0。当我们设置padding属性后观察一下变化:

    image-20260214102417792

    再通过Computed面板观察一下外面红色盒子当前的样式:

    image-20260214102741517

    这个时候可以看到盒子里面的padding属性为30px,此时元素整体的宽高也会变成560px。

    示例代码:

    <!DOCTYPE html>
    <html>
        <head>
            <meta charset="utf-8">
            <title>边距属性练习</title>
            <style>
                .outBox {
                    background-color: red;
                    width: 500px;
                    height: 500px;
                    /*这里使用了连写的方式同时设置了,上右下左四个方向的内边距*/
                    padding: 30px;
                }
                .inBox {
                    background-color: green;
                    width: 200px;
                    height: 200px;
                }
            </style>
        </head>
        <body>
            <div class="outBox">
                <div class="inBox"></div>
            </div>
        </body>
    </html>
    

    补充内容:

    Computed 面板展示的是“计算后的值” ‌:是将影响元素尺寸的所有 CSS 属性(如 widthheightpaddingbordermargin)都拆解并显示为最终的、浏览器实际使用的像素值(或其它单位)。

    盒模型图是核心可视化工具‌:在 Computed 面板的底部,通常会有一个‌可交互的盒模型示意图‌。这个图从内到外清晰地展示了四个层级:

    • 内容区 (content) ‌:显示 widthheight 的最终计算值。如果这两个值在 CSS 中被设置为 auto,这里就会显示 auto × auto,表示其尺寸由内容或父容器决定。
    • 内边距 (padding) ‌:显示四个方向的内边距数值。
    • 边框 (border) ‌:显示四个方向的边框宽度。
    • 外边距 (margin) ‌:显示四个方向的外边距数值。

    元素宽高‌:可以通过将 width + padding-left + padding-right + border-left + border-right 来计算出元素的‌总宽度‌(即“布局宽度”或 offsetWidth)。同理,可以通过将height + padding-top + padding-bottom + border-top + border-bottom来计算出‌总高度‌。这个“整体大小”就是元素在页面上实际占据的空间。

  • 注意点:

    • 通过上述示例我们可以观察到,给标签设置了内边距,标签的宽度和高度会发生变化(增加内边距的距离)。

    • 连写格式这三个属性的取值省略规律:

      • ${padding-top} ${padding-right} ${padding-bottom} ${padding-left} ---> ${padding-top} ${padding-right} ${padding-bottom} :省略左内边距的设置,取值和右内边距一样。
      • ${padding-top} ${padding-right} ${padding-bottom} ${padding-left} ---> ${padding-top} ${padding-right} : 省略下内边距、左内边距的设置,下内边距取值和上内边距取值一样,左内边距取值和右内边距取值一样。
      • ${padding-top} ${padding-right} ${padding-bottom} ${padding-left} ---> ${padding-top}:省略右内边距、下内边距、左内边距,则被省略的内边距的取值和上内边距的取值一样。
    • 通过上述示例可以发现,内边距也会有背景颜色,同父元素颜色。

外边距

标签和标签之间的距离就是外边距。

  • 格式:
/* 非连写 */
margin-top: ${margin-top};
margin-right: ${margin-right};
margin-bottom: ${margin-bottom};
margin-left: ${margin-left};

/* 连写 */
margin: ${margin-top} ${margin-right} ${margin-bottom} ${margin-left};
  • 渲染样式:

    • 两元素父子关系:

      先设置一个父元素div和一个子元素div的初始样式:

      nomargin

      然后给子元素设置margin-top属性:

      margin-1

      可以看到父元素的margin属性仍为0

      margin-computed

      可以观察到子元素的margin属性为30px

      margin-computed

      可以观察到,如果设置了margin-top: 30px;属性,父元素会一同被顶下来。

      我们可以通过给父元素设置border属性,来先观察当下需要了解的margin属性:

      margin-2

      <!DOCTYPE html>
      <html>
          <head>
              <meta charset="utf-8">
              <title>边距属性练习</title>
              <style>
                  .outBox {
                      background-color: red;
                      width: 500px;
                      height: 500px;
                      border: 1px solid #000;
                  }
                  .inBox {
                      background-color: green;
                      width: 200px;
                      height: 200px;
                      margin: 30px;
                  }
              </style>
          </head>
          <body>
              <div class="outBox">
                  <div class="inBox"></div>
              </div>
          </body>
      </html>
      
    • 两元素为兄弟关系:

      这里就不再做对比,直接展示设置margin属性后的效果:

      margin-brother

      <!DOCTYPE html>
      <html>
          <head>
              <meta charset="utf-8">
              <title>边距属性练习</title>
              <style>
                  .box1 {
                      background-color: red;
                      width: 100px;
                      height: 100px;
                  }
                  .box2 {
                      background-color: green;
                      width: 100px;
                      height: 100px;
                      margin: 30px;
                  }
              </style>
          </head>
          <body>
              <div class="box1"></div>
              <div class="box2"></div>
          </body>
      </html>
      
  • 注意点:

    • 连写格式取值省略时的规律:

      • ${margin-top} ${margin-right} ${margin-bottom} ${margin-left} ---> ${margin-top} ${margin-right} ${margin-bottom}:省略对左边距的指定,左边距的取值和右边距一样。
      • ${margin-top} ${margin-right} ${margin-bottom} ${margin-left} ---> ${margin-top} ${margin-right}: 省略下边距、左边距的指定,下边距取值同上边句一样,左边距取值同右边距一样。
      • ${margin-top} ${margin-right} ${margin-bottom} ${margin-left} ---> ${margin-top}:省略右边距、下边距、左边距的指定,被省略的边距的取值同上边距一样。
  • 外边距的那一部分是没有背景颜色的。
  • 在默认布局的垂直方向上,默认情况下外边距是不会叠加的,会出现合并显现,原则上是谁设定的外边距比较大就听谁的。
  • 在默认布局的水平方向上,默认情况下外边距是会叠加的,不会进行合并。

外边距合并

外边距合并的现象简单来说就是当两个垂直方向的外边距相遇,会形成一个外边距,此外边距大小为两个边距中的最大值(如果两者相等则取其中一个)。

外边距合并的三种情况:

  • 两元素为相邻的兄弟关系:

    image-20260224150323247

  • 两元素为父子关系:

    这里引用MDN文档的解释:

    如果没有设定边框(border)、内边距(padding)、行级(inline)内容,也没有创建区块格式化上下文或间隙来分隔块级元素的上边距(margin-top)与其内一个或多个子代块级元素的上边距(margin-top);或者没有设定边框、内边距、行级内容、高度(height)或最小高度(min-height)来分隔块级元素的下边距(margin-bottom)与其内部的一个或多个后代后代块元素的下边距(margin-bottom),则会出现这些外边距的折叠,重叠部分最终会溢出到父代元素的外面。

    也就是说合并后的外边距大小是取父元素及其内首个/最后一个子代块级元素的上边距/下边距的较大值,但是最终的渲染结果都会是“溢出”到父元素面的外边距。

    image-20260224155323022

  • 空元素的外边距合并:

    如果块级元素没有设定边框、内边距、行级内容、高度来分隔块级元素的上边距(margin-top)及其下边距(margin-bottom),则会出现其上下外边距的折叠。

    image-20260224160810735

注意点:

  • 上述情况的组合会产生更复杂的(超过两个外边距的)外边距折叠。
  • 即使某一外边距为 0,这些规则仍然适用。因此就算父元素的外边距是 0,第一个或最后一个子元素的外边距仍然会(根据上述规则)“溢出”到父元素的外面。
  • 如果包含负边距,折叠后的外边距的值为最大的正边距与最小(绝对值最大)的负边距的和。
  • 如果所有的外边距都为负值,折叠后的外边距的值为最小(绝对值最大)的负边距的值。这一规则适用于相邻元素和嵌套元素。
  • 外边距折叠仅与垂直方向有关。

解决外边距折叠的方法:

  • 可以通过触发BFC来解决外边距合并的问题
  • 可以通过设置padding、border属性来解决

参考资料:

W3School官方文档:www.w3school.com.cn

MDN官方文档:developer.mozilla.org

Vibe coding(AI编程一网打尽)

前言

近一年来vibe coding席卷全球,现在很多公司都开始并且要求使用AI编程工具,我本人使用过类似Cursor这种IDE形式工具,也是使用codexClaude code这种ClI工具。接下来我就使用Open code这款开源的代码agent工具来从头过一遍rulescommandagent抓取agent请求分析MCP服务skills这些基础概念以及使用,也方便后续我自己能够快速查找对应的内容。主要先用起后续在深入agent开发。

opencode的使用

1. 安装

npm install -g opencode-ai

2. 创建next.js项目(用于演示)

pnpm create next-app@latest ai-coding --yes

3. 进入到ai-coding目录,执行opencode,出现如下界面表示成功

image.png

4. 配置第三方模型

opencode也提供一些免费的模型,例如Kimi K2.5GLM-4.7等等,可以通过命令/modles来查看,后面有FREE表示免费标识。 image.png 为了达到演示效果,接下来我将配置Googlegemini服务。当然你也可以选择其他模型提供商,例如OPEN AIGITHUB Copilot

  • 配置opencode-antigravity-auth插件,该插件用于在opencode启用OAuth 对 Antigravity(Google 的 IDE)进行身份验证,这样就可以通过谷歌凭证访问Antigravity的模型。例如 gemini-3-pro 和 claude-opus-4-5-thinking

    • 方式一: 让AI agent自动帮我们完成opencode-antigravity-auth安装 (让LLM处理)
    Install the opencode-antigravity-auth plugin and add the Antigravity model definitions to ~/.config/opencode/opencode.json by following: https://raw.githubusercontent.com/NoeFabris/opencode-antigravity-auth/dev/README.md
    
    • 方式二:
      编辑~/.config/opencode/opencode.json文件,追加如下内容
    {
      "plugin": ["opencode-antigravity-auth@latest"]
    }
    
    • 登录认证
    opencode auth login
    

image.png

在进行认证的时候,无法完成Goole谷歌大概率是VPN墙的问题。导致了如下两个链接无法访问

https://oauth2.googleapis.com/token
https://www.googleapis.com/oauth2/v1/userinfo

可以通过postman主动去获取这两个数据,并且修改它的插件代码既可以绕过请求。在之后遇到授权问题时,可以通过debugger这个插件来排查是哪一步存在问题
<主机目录>/.cache\opencode\node_modules\opencode-antigravity-auth

image.png

关于opencode中Oauth认证的流程如下图: image.png

尴尬的是这样子绕过了登录流程的,虽然能够获取到Gemini模型列表了,但是已经模型请求依旧显示网络异常,所以还是要解决CLI不能使用系统代理问题。

提示: 通过解决上面的授权,我们能够更加清晰的知道整个授权流程是怎么样的,以及可以确定插件内部无法发送oauth2.googleapis.com导致,而不是其他问题导致的

我的翻墙工具存在终端代理的命令,只需要将这个命令输入到你需要执行opencode的窗口,然后就可以正常使用模型了。如果你的Clash应该存在TUN模式打开即可

export https_proxy=http://127.0.0.1:50750 http_proxy=http://127.0.0.1:50750 all_proxy=socks5://127.0.0.1:50751

image.png

5. oh-my-opencode (多代理协作插件)

用 Claude 做编排,用 GPT 做推理,用 Kimi 提速度,用 Gemini 处理视觉。模型正在变得越来越便宜,越来越聪明。没有一个提供商能够垄断。我们正在为那个开放的市场而构建。Anthropic 的牢笼很漂亮。但我们不住那。

核心理念

很多人使用 AI 编程工具会经历从”惊艳”到”冷静”的过程:写小功能很快,但一到重构、迁移、补测试、清 ESLint 警告等大型任务就容易卡壳。Claude-code中这个问题也是存在的, 在Cladue上面有Ralph插件来做这个事儿, 但OpenCode则有更强的 Oh-My-OpenCode 插件:

  • 不是简单叠加 UI,而是将”单模型”升级为多代理协作系统
  • 通过主控代理 Sisyphus 负责任务拆分、委派、推进
  • 把不同类型的工作分派给不同角色的代理,模拟真实开发团队的协作方式
代理角色
角色 职责
Sisyphus(主控) Tech Lead + 项目经理组合,负责拆解 TODO、分配任务、推动进度
Oracle 架构设计、深度调试、复杂问题分析
Librarian 文档检索、API 查阅、资料收集
Explore 代码库探索、依赖分析、边界定位
Frontend Engineer UI/UX 设计、前端组件开发
工作模式

ultrawork(或简写 ulw)是核心的工作模式开关:

小任务:正常使用即可
大任务(跨文件/跨模块/需要查资料/需要持续推进):开启 ultrawork 更稳

当开启 ultrawork 时,系统会:

  • 并行探索代码库
  • 启动后台任务
  • 强力推进直到完成

到目前我们完成opencode的安装以及关联第三方提供商,接下来就来了解一些关于AI agent的一些常见概念以及使用

常用的command

接下来学习一下opencode中常用且好用快捷操作命令有哪些? 帮你提效AI工具

快捷键 功能 说明
Ctrl+X -> N 新建会话 ctrl+x同时按,然后松开再按N
Ctrl+X -> L 会话列表 ctrl+x同时按,然后松开再按N
shift+Enter 换行(不发送) 用于多行提示词
⬆/⬇ 切换历史记录 用于多行提示词
ESC 中断这次会话 中断这次会话
Ctrl+X -> M 打开模型列表 切换模型列表
Ctrl+X -> U 撤销(undo) 回退到这次对话初始状态(觉得这次对话不好)
Ctrl+X -> R 重做(redo) 重回到这一次undo之前
F2 切换最近模型 快捷切换最近使用的模型
Ctrl+X -> y 复制AI消息 复制AI的完整回复
Ctrl+X -> B 切换侧边栏 查看会话饼图
PageUp/Down 滚动对话 快速浏览长对话

关于AGENTS.md文件

AGENTS.md是一个markdown文件,我们可以将其提交到Git仓库中,用于自定义AI编码代理在仓库中的行为。它位于对话记录的顶部,紧邻系统提示符下方。(该文件不是必须的)

1. 为什么需要写AGENTS.md?

AGENT非常擅长局部修改,但缺乏上下文时容易“跑偏”。AGENTS.md 就像一张地图:告诉它命令在哪里,用什么测试、PR 怎么写、哪些操作禁止。顺带一提,这也能帮助新同事快速融入团队。

2. AGENTS.md如何查找呢?(Monorepos

关于AGENTS.md文件查找跟node_modules的查找方式类似,以就近文件优先进行查找

repo/  
├─ AGENTS.md            # 全仓默认规则  
├─ apps/  
│  └─ web/  
│     ├─ AGENTS.md      # 仅对 /apps/web/** 生效  
│     └─ src/components/Button.tsx  
└─ packages/  
   └─ api/  
      ├─ AGENTS.md      # 仅对 /packages/api/** 生效  
      └─ src/routes/users.ts

3. 可选的AGENTS.override.md

有时可能需要一份临时、优先级更高的规则,例如发布封板、事故应急、合规冲刺等
• 该文件名只是约定,不是标准;只有部分工具/CLI 会自动识别。
• 如果工具不支持,可通过命令行额外指定策略文件实现同样效果。
• 建议“少量、明确、短期”:写清目的、约束内容、结束条件,窗口结束后立即删除。

repo/  
├─ AGENTS.md  
├─ AGENTS.override.md        # 全仓临时规则(若被支持)  
└─ packages/  
   └─ api/  
      ├─ AGENTS.md  
      └─ AGENTS.override.md  # 仅 API 范围的事故模式(若被支持)

4. 针对过大的agents.md文件可能来自于如下原因:

agents.md文件会在每次对话开始时自动加载,让Agent能立即了解的我们的项目。因此表明agent.md文件不宜过大才不会占用过多的上下文和消耗过多的token

It's automatically loaded at the start of every conversation, giving the agent immediate understanding of your project.
  • 不同的开发者添加了相互矛盾的建议,没有人能够完成完整风格,难以维护的混乱实际上损害了Agents的性能。
  • 另一个罪魁祸首:自动生成的 AGENTS.md 文件。 切勿使用初始化脚本自动生成 AGENTS.md。 他们在文件中充斥着“对大多数情况有用”但最好逐步披露的内容。 生成的文件优先考虑全面性而不是限制性

对于agents.md会在每一次会话中都会加载,无论这个agents.md内容是否相关,这会导致token浪费问题

设想 影响
轻量、专注的agents.md 有更多的token用于特定的任务
体积庞大、臃肿的agents.md 用于实际工作的tokens变少、agent会感到困惑
不相干的说明 tokens的浪费 + agent分心 = 浪费性能

意味编写的agents.md文件应该尽可能的小。 同时也需要注意要保持agent.md文件的更新,不要使用过时的agent.md文件

5. AGENTS.md文件内容

一般情况agents.md包含下面几大类既可以了,而且尽可能极简

  1. 整个项目的架构和模式(技术栈、项目结构)
  2. 遵循我们的编码风格和规范(代码规范)
  3. 声明正确的命令进行构建和测试(包管理工具、构建命令)
  4. 避免已知的陷阱和反模式(已知问题)
  5. 做出符合你的偏好的决策

推荐agents结构

# AGENTS.md

## Project overview
Brief description of what this project does and its primary purpose.

## Tech stack
- Framework: Next.js 14 with App Router
- Language: TypeScript (strict mode)
- Database: PostgreSQL with Prisma ORM
- Styling: Tailwind CSS

## Architecture
Explain the codebase structure and key patterns.

## Commands
```bash
bun install        # Install dependencies
bun run dev        # Start development server
bun run test       # Run tests
bun run build      # Production build
```

## Code style
- Prefer functional components
- Use early returns
- Name files in kebab-case
- Write tests for business logic

## Patterns to follow
Document established patterns in the codebase.

## Things to avoid
List antipatterns and common mistakes.

6. 使用渐进式披露

不要将所有的内容都塞进AGENTS.md中,而是使用渐进式披露。仅向Agent提供其当前所需要的内容,并在需要时将其指向其他资源。

模型启动时会加载所有 Skills 的基本信息(名称和用途),但不会读取具体内容。只有当模型真正需要某个 Skill 来指导工作时,才会去读取那份文档的详细内容。这种按需加载的方式,就是渐进式披露

特定语言的规则移至单独的文件

如下文件属于属于大包大揽行为在开启会话的时候会将所有agent.md内容都加载到会话中 image.png 针对上一个文件进行渐进式披露优化,agents.md只包含大纲内容,不会再初次开启会话时就将agent.md所有的内容都加载而是使用才加载对应的规则。例如当编写Typescript的时候才会去加载TypeScript Conventions,可以理解为按需加载 image.png

7. 如何重构AGENTS.md文件

如果我们对已有的agents.md感觉到困惑难以维护,使用如下提示词以渐进式的公式重构我们的提示词。

I want you to refactor my AGENTS.md file to follow progressive disclosure principles.

Follow these steps:

1. **Find contradictions**: Identify any instructions that conflict with each other. For each contradiction, ask me which version I want to keep.

2. **Identify the essentials**: Extract only what belongs in the root AGENTS.md:
   - One-sentence project description
   - Package manager (if not npm)
   - Non-standard build/typecheck commands
   - Anything truly relevant to every single task

3. **Group the rest**: Organize remaining instructions into logical categories (e.g., TypeScript conventions, testing patterns, API design, Git workflow). For each group, create a separate markdown file.

4. **Create the file structure**: Output:
   - A minimal root AGENTS.md with markdown links to the separate files
   - Each separate file with its relevant instructions
   - A suggested docs/ folder structure

5. **Flag for deletion**: Identify any instructions that are:
   - Redundant (the agent already knows this)
   - Too vague to be actionable
   - Overly obvious (like "write clean code")

image.png

rules (角色)

rules是给agents一个行为准则,当我们发现需要反复告诉AI同样的事情时就需要Rules了。例如问答时使用英/中回答、回复时需要精简回答、遇到不确定的事情需要告知用户、系统环境、用户偏好设置及其他无法通过代码编译器或检查器管理发现的实现细节

  • rules的特点

    1. 一次设置,长期生效
    2. 定义AI的人设行为模式
    3. 适合长期稳定的需求
  • rules的缺点

    1. 只能定义做什么不做什么无法定义怎么做
    2. 无法包含复杂的操作步骤
    3. 仍然比较抽象

如何写好rules可以根据cursor中的最佳实践来实现

示例

下面来看看加了rules和没有加rules的区别,如下是我的提示词,有规则和没有规则只有生成的文件名称导出变量不同

在当前目录下新建一个hooks-by-rules.ts,创建一个名称为useListByRules的hooks方法。请求url为请求地址参数,params为请求参数,返回对应的loading、pageSize、total等参数以及refresh、getList、changePage等方法
  • hooks.ts文件是还没有创建rules规则时创建的hooks,会有堆的loading、error的useState
  • hooks-by-rule.ts文件是具备rules规则时创建的hooks,采用了@tanstack/react-query的useQuery来实现 image.png 如下是我的hook.mdc规则编写,声明了需要使用useQuery方式来实现。 针对rules你可以自己写一部分然后让agent帮你完善或者改进
    • globs声明作用域文件
    • description标识当前rule的限定范围
    • alwaysApply 是否应用于每次会话 image.png

目前我在opencode.json中配置了,agent并没有自动读取我的rules。(暂时不知道为什么)

{
  "$schema": "https://opencode.ai/config.json",
  "instructions": [
    ".opencode/rules/*.md"
  ]
}

为了让opencode自动读取我的rules,我在agent.md文件追加了如下内容 image.png 是不是觉得rulesagent.md文件的能力差不多?
是的,你没有理解错。agent.md只是rules的更简单的一种方式,它是纯markdown文件,没有结构化配置元数据头部(就是头部不需要声明globs、description、alwaysApply定义)。它适用于项目规则比较简单的场景,Agent.md更加轻量、更易读的指导说明替代方案

MCP(模型上下文协议)

MCP是由Claude背后的公司Anthropic开放的标准。虽然听起来很有技术性,但是核心思想很简单。为AI Agent代理提供统一的方式来连接工具、服务和数据。

使用MCP即插即用。agent可以将结构化请求发送到任何MCP兼容工具,实时获取结果,甚至将多个工具链接在一起,无需提前了解具体细节

MCP Server的调用方法,当用户在AI Agent提问的时候,会将MCP作为system提示词发送给LLM,其中提示词包含了这个MCP的能力描述,以及对应的API能力(有点类似一个插件的API文档),然后LLM模型就知道了该MCP具备什么能力,从而能够判断是否需要调用MCP Server来实现你的需求。 后续在抓取请求会在分析具体传递的MCP数据会更加直观。 MCP.png

接下来实战一下MCP Server的使用,你也可以通过awesome-mcp-serverssmithery.ai来查找你需要的MCP服务

Chrome Devtools Mcp

在使用Agent工具调试前端项目的时候,是不是经常遇到这样一个痛点。Agent没办法获取到浏览器控制台的信息,导致你在调试的时候都需要手动的告知或复制对应信息Agent工具,它才能帮你修复bug问题。下面通过集成 chrome-devtools-mcp来解决这一痛点

Chrome Devtools Mcp的时候,让我们的code agent能够实时的检测和控制谷歌浏览器。它充当模型上下文协议(MCP)服务器,使我们的code agent能够访问Chrome Devtools的全部功能,实现可靠的自动化深度调试性能分析

在没有集成Chrome Devtools Mcp的时候,openCode编程工具并不能直接访问读取我们的谷歌浏览器的控制面板 image.png

接下来我们来实现一下在opencode集成Chrome Devtools Mcp,当我根据文档在~/.config/opencode/opencode.json中配置如下配置,然后通过/mcps查找发现Chrome Devtools Mcp属于disabled状态。但是并没有任何报错,因此我们可以通过向Agent提问来解决这个问题。

 "mcp": {
    "chrome-devtools": {
      "type": "local",
      "command": ["npx", "-y", "chrome-devtools-mcp@latest"],
      "enabled": true
    }
  }

通过在Agent中输入npx -y chrome-devtools-mcp@latest加上我在前面问为什么配置的MCP为disabled状态。那么模型会自动思考这个问题,发现我本地的Node版本低了一点。(在使用coding agent工具的时候,定位问题方式跟传统定位问题方式也有所改变,我们应该先让Agent帮我分析一下可能的问题,让Agent自己尝试解决问题image.png 可以通过Space空格键切换mcp的disabled或者enable状态

打开github找到chrome-devtools-mcp项目,给他点个star

在命令行输入如上prompt来看看是否Chrome Devtools Mcp是否配置成功,因为默认启动新的Chorm实例的沙盒环境因此没有登录信息,opencode会提示你登录用户信息,当你登录完成在叫opencode帮你继续执行即可。 image.png

默认chrome-devtools-mcp都会启动新 Chrome 实例的沙盒环境中运行 MCP 服务器。在沙盒环境中用户登录信息无法共享,那么如何将mcp链接到正在运行的Chrome实例呢?

  1. 在mcp配置文件中添加--browser-url选项,这个选项值是正常运行Chrom实例的URL地址。默认为http://127.0.0.1:9222地址
 "mcp": {
    "chrome-devtools": {
      "type": "local",
      "command": ["npx", "-y", "chrome-devtools-mcp@latest","--browser-url=http://127.0.0.1:9222"],
      "enabled": true
    }
  }
  1. 启动Chorme浏览器
    针对该文章更改了远程调试开关以提高安全性,从 Chrome 136 开始,我们将更改 --remote-debugging-port 和 --remote-debugging-pipe 的行为如果尝试调试默认的 Chrome 数据目录,系统将不再遵循这些开关。,这些开关必须与 --user-data-dir 开关搭配使用,才能指向非标准目录。非标准数据目录使用不同的加密密钥,这意味着 Chrome 的数据现在可以免受攻击者的侵害。
"C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222 --user-data-dir="%TEMP%\chrome-profile-stable"
  1. 测试一下
    输入如下的prompt提示词,就会在我使用上面命令启动的Chrome实例上运行内容,而不是又独自打开一个新的Chrome实例运行。只需要在第二步打开的Chrome实例登录保存用户即可
打开github找到chrome-devtools-mcp项目,给他点个star

image.png

让opencode结合chrome的面板帮我分析一下我的next.js代码哪里有错,如下是我的源代码 image.png 给opencode提供prompt提示词如下

http://localhost:3000/请结合chrome的面板,分析一下为什么我点击"测试一下"按钮,会报错,在控制台并没有打印出"我是测试按钮"

opencode会找到对应的错误,并且给出修复建议 image.png

我们给opencode配置了Chrome Devtools Mcp后,不再需要我们手动复制Chrome控制台信息给opencode了,我们也可以通过Chrome Devtools Mcp来实现网站性能/安全分析并且给出对应的修复建议。我们也了解了MCP的配置和用法,针对其他MCP用法也是大同小异就不一一演示了,感兴趣的小伙伴自行查阅对应MCP官网进行配置就可以了

注意事项

当我们使用MCP服务器时,会将该MCP的描述、API能力以系统提示词添加到我们的上下文中。如果我们有很多MCP服务,那么会把所有MCP服务都添加到我们的上下文中。因此我们应该只安装或者开启我们需要的MCP服务从而节省上下文,防止超出上下文限制。在opencode中可以/mcps选择对应的mcp服务器,再通过Space空格键来切换状态

skills

什么是的skills

Agent skills是一种轻量的开放格式,用于通过专业知识和工作流程扩展AI agent的功能。

skill是包含SKILL.md文件的文件夹。该文件包含元数据(name和description)以及告诉Agent如何执行特定任务的说明。SKILLS也可以包含scriptreferencesassets

my-skill/
├── SKILL.md          # Required: instructions + metadata
├── scripts/          # Optional: executable code
├── references/       # Optional: documentation
└── assets/           # Optional: templates, resources

为什么需要使用skills

skills是可重用的,基于文件系统资源为AI agent提供特定领域的专业知识。将通用AI agent变为专业的Agent在工作流程、上下文和最佳实践。与prompt不同(一次性任务对话说明),skills按需加载,无需在多个对话中重复提供相同的指导。

将通用Agent看作为一家饭店,skills在其中就充当菜谱的角色。当你的饭店拥有了菜谱那么这家饭店就具备了那些特色菜,也将菜系作为一个工作流的方式存储下。这样每次顾客点西红柿炒蛋就无需重新学一遍,做出来的口味也会更加统一

主要优点

  • 更专业的Agent(垂直agent): 根据特定领域任务定制功能
  • 减少重复: 一次创建,自动使用
  • 组合功能: 组合skills去构建复杂的工作流程

使用skills

skills在代码执行环境中运行,AI Agent拥有文件系统访问权限、bash命令和代码执行能力。 image.png

接下来演示一下find skillscreate skills,因为这两个skill比较常用和通用。我们可以在skills市场搜索开源skills使用,也可以自定义我们自己的skills。

find skills

这个skills可以帮助我们从开放Agent skills生态系统发现并且安装skill

skills.sh找到其使用方法和skills功能说明 image.png 在终端执行如下命令进行find-skills进行安装

npx skills add https://github.com/vercel-labs/skills --skill find-skills

image.png 重启opencode输入/skills即可查看已经安装skills image.png 接下来来看一下find-skillls的SKILL.md文件。一个skill文件一般包含了使用场景(description)触发条件具体的workflow可能的script\reference\assets具体实例错误处理等等。(对于使用者来说只了解该skills是解决什么问题以及如何通过prompt触发它即可)

  • description用于声明这个skills的职责是什么(清晰+简短)
  • When to Use This Skill声明了何时触发这个skill(用户询问如何完成某件事情,其中这件事情是一项常规任务且已掌握相关技能。询问可以完成某件事情的skill等等...) image.png

了解了find-skills的触发条件之。下面我们来测试一下,输入下面的prompt,opencode会自动使用find-skills提供的npx skills find去查找是否存在符合要求的skills

我想在想要创建创建一个skills,有没有对应的skills帮助我实现这个操作

image.png

skill creator

一般情况下每个公司每到年底都需要年终述职,那是不是也可以自定义一个skills来帮我们生成一个关于年终述职的PPT呢? 对于书写自定义skills我们可以通过skill-creator这个skills帮我创建。接下来通过skill-creator来创建一个关于年终述职PPT的skills

帮我生成一个提示词,用于实现年终总结PPT的skills实现的提示,我会把这个提示词喂给skill-creator来创建我的自定义skills

image.pngskill-creator也提供了一个python脚本用于打包分享给其他opencode用户 image.png 通过该skills帮我生成了一个ppt现在有点丑。因为只是演示关系我就不再优化skills提示词了,例如我们可以优化使用Office-PowerPoint-MCP-Server来使用ppt操作,而不是通过python脚本直接生成。追加更多与用户交互的提示词(让用户提供更多的信息、以及年终数据...)等等。(发挥你的想象力,去创建使用好玩的skills,分享在skills.sh也是ok的) image.png

让你的skills更加专业

skill文件一般包含了使用场景(description)触发条件具体的workflow可能的script\reference\assets具体实例错误处理等等,如何写好skills呢? 如下有三个建议:

1. 功能职责单一化
一个专业的skill首先要有清晰的职责范围,不要试图让一个skill做太多的事情,专注于解决一个具体的问题会更好。例如: 文件整理就文件整理,不要又涉及分析文件内容。这样让skill变得复杂且难以维护

2. 渐进式批漏
不要一次性把所有内容塞给AI agent,而是根据需要逐步获取信息。因为模型的上下文窗口是有限制的,而skill本质也是prompt提示词给到AI agent,全部加载进来的话会占用大量的上下文空间。渐进式纰漏就是让skill先加载目录(文件名称、description描述),只有确定需要使用某个skill才加载其的详细内容。我们一般将skill的详细文档示例代码参考资料放到单独的文件,然后再SKILL.md通过链接的形式进行关联。

就像维基百科一样: 从一个目录开始,仅在必要时才会引用越来越深的片段

3. description清晰度
如下SKILL.md可以使用,但是给出的信息太少,AI agent不知道这个skill是干什么的

name: file-rename  
description: 重命名文件  
# 文件重命名  
  
xxxxxxx

改进之后,description提供了更多信息,让AI agent能够更加准确理解这个SKILL的用途和方法

name: file-rename  
description: 批量重命名文件,支持按规则添加前缀、后缀、序号等。当用户需要批量修改文件名、统一文件命名格式时使用。  
# 文件批量重命名工具  
  
xxxxx

同时也可以参考一些优秀的开源skills写法,例如: 如何写出好的 Skill?拆解 skill-creator 背后的设计

skills如何工作

这里不再概述了claude code写的非常清晰了。这个Claude Code Skills & skills.sh - Crash Course来自于Youtube的视频讲skills也非常清晰

推荐skills

superpowers是一个完整的软件开发工作流程,适用于您的编码代理。它基于一组可组合的“skills”和一些初始指令构建而成,确保您的代理能够使用这些技能。让coding agent更加规范的编写代码,避免屎山代码

superpowers基本工作流(让AI agent执行更加规范、系统化)
image.png
安装完成superpowersskills集合后,其包含了brainstormin头脑风暴将你的粗略想法(想做一个需求,但是细节并没有想明白)输出为设计文档、writing-plans根据设计文档来生成执行计划、然后execute-plan来执行和实施计划等等工作流。 image.png 针对opencode可给定superpowers工作流需要执行哪些步骤,你可以通过/brainstormin/write-plan/execute-plan这个命令来执行。

https://github.com/obra/superpowers 学学 superpowers 怎么用,
当我提到"使用 superpowers 工作流"时,按这个顺序来:
1)先头脑风暴(brainstorm)
2)再写计划(write-plan)
3)最后按计划实现和测试(execute-plan)

上面提到的/brainstormin/write-plan/execute-plan这三步也是最核心步骤

  1. /brainstormin明确需求确定需要做什么
  2. /write-plan 写个执行计划
  3. /execute-plan按照执行计划一步一步执行

先通过brainstormin生成主题设计文档,然后通过write-plan拆分主题设计文档生成主题执行计划,然后再调用execute-plan执行任务。
image.png 最终效果如下
image.png

AGENTS.md、rules、mcp、skills的关系

3061f42318710801c85df8e1b946cc7e.png

总结

经过前面步骤,我们学会了opencode的基础操作,以及涉及了Agent.mdRulesMcps以及skills的使用,由于这篇幅比较长了。关于open routes多Agents协作、抓取Agent请求分析MCP\SKILLS交互原理、模型对比选择等放到之后有时间再整理

参考资料

agents
AGENTS.md Guide
A Complete Guide To AGENTS.md
AGENTS.md、层级规则与可选的 AGENTS.override.md
Rules & Guidelines
Agent Skills、Rules、Prompt、MCP,一文把它们理清楚了
Cursor Rules
opencode-rules
MCP Explained: The New Standard Connecting AI to Everything
chrome-devtools-mcp
Claude Code Skills 上手指南:从概念到真正用起来
Agent Skills Claude Code Skills & skills.sh - Crash Course
Introducing Agent Skills
Superpowers-入门快速指南 从“代码搬砖工”到“AI 团队主管”:OpenCode + Oh My OpenCode 开启多智能体协作新纪元

React项目白屏兜底神器?ErrorBounary你了解吗?

技术背景

JavaScript 的错误会破坏 React 的内部状态,进而导致整个页面崩溃。为了解决这个问题,React 16 引入了错误边界(ErrorBounary),错误边界可以捕获子组件的 JavaScript 错误,打印这些错误并展示降级 UI。

官方文档定义:

默认情况下,如果你的应用程序在渲染过程中抛出错误,React 将从屏幕上删除其 UI。为了防止这种情况,你可以将 UI 的一部分包装到 错误边界 中。错误边界是一个特殊的组件,可让你显示一些后备 UI,而不是显示例如错误消息这样崩溃的部分。

要实现错误边界组件,你需要提供 static getDerivedStateFromError,它允许你更新状态以响应错误并向用户显示错误消息。你还可以选择实现 componentDidCatch 来添加一些额外的逻辑,例如将错误添加到分析服务。

使用方式:

可以使用已有的 JS库 react-error-bounary 替代自己实现

# npm
npm install react-error-boundary

# pnpm
pnpm add react-error-boundary

# yarn
yarn add react-error-boundary




import { ErrorBoundary } from "react-error-boundary";

<ErrorBoundary fallback={<div>Something went wrong</div>}>
  <ExampleApplication />
</ErrorBoundary>

源码分析:

import * as React from 'react';

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    //构造函数初始化状态
    this.state = { hasError: false };
  }

  //这是一个静态生命周期方法,当子组件抛出错误时会被调用
  static getDerivedStateFromError(error) {
    // 更新状态,以便下一次渲染将显示后备 UI。
    return { hasError: true };
  }

  //在错误发生后调用,用于记录错误信息
  componentDidCatch(error, info) {
    logErrorToMyService(
      error,
      // 示例“组件堆栈”:
      // 在 ComponentThatThrows 中(由 App 创建)
      // 在 ErrorBoundary 中(由 APP 创建)
      // 在 div 中(由 APP 创建)
      // 在 App 中
      info.componentStack,
      // 警告:Owner Stack 在生产中不可用
      React.captureOwnerStack(),
    );
  }

  
  render() {
    if (this.state.hasError) {
      // 你可以渲染任何自定义后备 UI
            return (
        <div>
          <p>当前页面出错了,请联系Bone值班同学</p>
        </div>
      );
    }

    return this.props.children;
  }
}
<ErrorBoundary fallback={<p>Something went wrong</p>}>
  <Profile />
</ErrorBoundary>

官网地址: zh-hans.react.dev/reference/r…]

影响范围&边界

错误边界可捕获常见场景中的错误:

  1. 子组件内部错误
const ErrorUaughtComponent = () => {
  return (
    <div>
     //未定义变量CcConfig
      <h1>{CcConfig.length}</h1>
    </div>
  );
};
  1. 组件主动抛出错误
const ErrorComponent = () => {
  throw new Error('这是一个测试错误');
};
  1. 组件new一个错误(语法错误) ps:一个 React「函数组件」必须返回 ReactNode
const ErrorNewtComponent = () => {
  return new Error('这是一个测试错误');
};

错误边界无法捕获以下场景中出现的错误:

  1. 它自身抛出来的错误(并非它的子组件)
// 把错误边界自身写成会崩溃的组件
const BadBoundary = () => {
  throw new Error('BadBoundary 自己炸了');
};

const Demo1 = () => (
  <ErrorBoundary>
    <BadBoundary />
  </ErrorBoundary>
);
  1. 异步的错误(例如 setTimeoutrequestAnimationFrame 回调函数,接口报错,

form.validateFields()校验错误等)

const ErrorComponent = () => {
  setTimeout(() => {
    throw new Error('这是一个测试错误');
  }, 1000);
};
  1. 事件中的错误 (错误边界无法捕获事件处理器内部的错误)
const ErrorComponent = () => {
  const handleClick = () => {
    throw new Error('点击事件里的错误');
  };
  return <button onClick={handleClick}>点我报错</button>;
};

  1. 服务端渲染的错误(SSR渲染)
  2. console.error () (只是打印错误信息到控制台,不会终止程序)
const ErrorNewtComponent = () => {
 console.error('这只是一条日志,不会中断渲染');
};

目前已在在内部系统以页面维度、组件维度进行试用:

const ErrorBoundaryWrapper: React.FC<ErrorBoundaryProps> = (
  props: ErrorBoundaryProps,
) => {
  const location = useLocation();
  const shouldEnableErrorBoundary =
    location.pathname.startsWith('/feature/index') ||
    location.pathname.startsWith('/featureDetails/index');

  if (!shouldEnableErrorBoundary) {
    return props.children;
  }
  return <ErrorBoundary {...props} />;
};

总结

✅收益

  1. 兜底白屏:React 18 以后,生产环境任何未被捕获的错误都会把整棵组件树卸载成“白屏”;全局 ErrorBoundary 可以把白屏变成降级 UI(如“系统开小差”)。
  2. 统一埋点:一次 catch 所有渲染阶段错误,便于 Sentry、阿里 ARMS、灯塔等监控平台统计。
  3. 渐进式降级:可以配合 React.lazy、Suspense,对局部模块再包一层 ErrorBoundary,形成“全局兜底 + 局部细粒度”两级策略。

⚠️ 需要注意的 6 件事

场景 注意点 建议
事件处理器/异步代码 ErrorBoundary 只能捕获渲染阶段错误;onClicksetTimeoutPromise.reject 不在其捕获范围。 考虑事件里手动 try/catch
SSR 服务端渲染时,ErrorBoundary 抛出的错误如果不处理,Node 进程会 500。 在 SSR 入口也包一层 ErrorBoundary,并返回 500 页面。
重复渲染 一旦进入 Error 状态,React 会卸载整棵树并显示 fallback;如果 fallback 本身又抛错,会死循环。 fallback 组件必须足够简单(纯静态 UI),不要依赖 props/context。
性能 全局 ErrorBoundary 会阻止整页重渲染,对交互密集场景(如编辑器、画布)可能过度杀伤。 路由级业务模块级再包一层,做到“局部爆炸局部降级”。
错误信息泄露 生产环境不要把 error.stack 直接展示给用户,防止源码路径泄露。 fallback={error => <ErrorPage code={500} message="系统繁忙" />}
热更新 Vite/Webpack 热更新时抛错会被 ErrorBoundary 吞掉,导致看不到编译错误。 开发环境单独关掉全局 ErrorBoundary,或在 fallback 里加一个“刷新页面”按钮。

一句话总结

全局 ErrorBoundary 是“保险丝”,不是“万能药”。
只要记住“只兜底渲染错误 + 保持 fallback 简单 + 异步错误手动补”这三点,就可以放心用。

如何处理axios请求中post请求的坑

一、问题描述

  • axios.post() 直接传对象,后端收不到数据。
  • 原因是 axios 默认会把 POST 数据序列化为 JSON 格式,而很多后端(尤其是老项目)默认只识别 application/x-www-form-urlencoded 格式(即表单格式)。
  • 所以数据被 “拦截” 了,本质是前后端数据格式不兼容,而不是 axios 主动拦截

二、代码示例:为什么直接传对象不行?

  1. 错误写法(后端收不到数据)
// 错误示例:直接传对象 
axios.post('/api/login', { 
    username: 'admin',
    password: '123456' }).then(res => { 
    console.log(res)
}).catch(err => { 
    console.error(err) 
})

问题分析

  • axios 默认设置 Content-Type: application/json,发送的是 JSON 字符串:
{ "username": "admin", "password": "123456" }
  • 如果后端是用 PHP、Java 等传统方式读取 $_POSTrequest.getParameter(),它们默认只解析 application/x-www-form-urlencoded 格式,所以读不到 JSON 里的字段,表现为 “数据传不过去”。

三、正确的解决方案(代码实现)

方案一:使用 qs 库,将数据转为表单格式(推荐)

这是最稳妥的方式,和后端传统表单提交格式完全一致。

  1. 安装 qs
npm install qs --save
  1. 封装请求(推荐写法)
import axios from 'axios' 
import qs from 'qs' 

// 创建实例 
const request = axios.create({ 
  baseURL: '/api', 
  timeout: 5000 
}) 

// 请求拦截器:统一处理 POST 数据格式 
request.interceptors.request.use(config => { 
// 只对 POST 请求做处理 
    if (config.method === 'post' && config.data) { 
    // 将 JSON 对象转为 application/x-www-form-urlencoded 格式 
    config.data = qs.stringify(config.data) 
    // 同时修改 Content-Type 头(有些后端需要明确指定)
    config.headers['Content-Type'] = 'application/x-www-form-  urlencoded'
} 
  return config 
}, error => { 
  return Promise.reject(error) 
}) 
// 使用 
request.post('/login', { 
  username: 'admin', 
  password: '123456' 
})

发送的数据格式

username=admin&password=123456

后端可以直接用 $_POST['username']request.getParameter("username") 读取。

方案二:手动设置 FormData(适合上传文件或混合数据)

const formData = new FormData() 
formData.append('username', 'admin') 
formData.append('password', '123456') 

axios.post('/api/login', formData, { 
    headers: { 
        'Content-Type': 'multipart/form-data' 
    }
})

适用场景

  • 需要上传文件(File 对象)
  • 混合文本和文件数据

方案三:让后端支持 JSON 格式(现代项目首选)

如果后端是 Node.js(Express),可以直接用中间件解析 JSON:

// 后端 Express 示例 
const express = require('express') 
const app = express() 

// 解析 JSON 格式的请求体 
app.use(express.json()) 

// 现在就能直接读取 req.body.username 了 
app.post('/api/login', (req, res) => { 
    const { username, password } = req.body 
    res.send({ username, password }) 
})

前端代码就可以保持最简洁的写法:

axios.post('/api/login', { 
    username: 'admin', 
    password: '123456' 
})

四、总结:为什么说 “axios 有拦截功能”?

这句话其实是对 “默认行为” 的误解:

  • axios 并没有主动 “拦截” 你的数据,而是自动做了序列化
    • GET 请求:自动把参数拼到 URL 上。
    • POST 请求:默认把对象转为 JSON,并设置 Content-Type: application/json
  • 当后端不支持这种格式时,数据就 “传不过去”,看起来像是被拦截了。

最佳实践

  • 新项目:前后端统一使用 JSON 格式,后端配置 express.json() 等中间件。
  • 老项目:用 qs 统一转为表单格式,避免逐个接口处理。

React 核心深度解析:调度、协调与提交的闭环全解

React的更新机制

Trigger --> Schedule --> Render --> Commit

  • Trigger (触发):

setState触发状态变化

  • Schedule (调度):

根据优先级排队,决定什么时候开始更新

  • Render (协调/对比):

负责在内存中计算出一棵新的 Fiber 树(WIP 树),通过 Diff 算法找出所有需要变更的“标记(Flags)”

  • Commit (提交渲染):

把 Render Phase 的计算结果真正同步到真实 DOM

具体更新操作

Render
阶段一Render(协调
  • 特点: 异步、可中断。
  • 目标: 生成一颗新的 Fiber 树(WIP),并计算出需要更新的标记。
① 初始化阶段 (Initialization)
  • 核心函数:调用 prepareFreshStack
  • 做了什么
    • 创建 WIP 树的根节点:通过 createWorkInProgress(root.current, ...) 克隆出第一个 Fiber。
    • 设置全局变量 workInProgress:让它指向这个刚创建的根节点。此时,WIP 只有一个初始节点
② 下行阶段 (Downward - “铺路”与“标记”)
  • 核心函数beginWork
  • 做了什么这是 WIP 树生长的核心。
    • Diff 算法(协调) :对比当前节点的旧 Fiber 和新的 React Element。
    • 动态生成子节点:调用 reconcileChildFibers 现场创建出子 Fiber 节点,并将其连接到 WIP 树上。
    • 打上补丁标记 (Flags) :如果发现节点变了(比如文字改了、位置动了),就在这个 WIP 节点上标记 Placement(新增)或 Update(更新)。
    • 推进指针:返回刚建好的子节点。workLoop 会让指针跳到子节点上,循环执行下一轮 beginWork
③ 上行阶段 (Upward - “收尾”与“汇总”)
  • 核心函数completeWork
  • 触发时机:当 beginWork 返回 null(即当前分支已经修到叶子节点,没路了)时开始回溯。
  • 做了什么
    • 创建 DOM 实例:如果是第一次创建的节点,会调用 createElement 创建真实 DOM 并挂在 fiber.stateNode 上。
    • 属性初始化:处理 props,比如把 className 变成 DOM 的属性,但此时不挂载到页面上。
    • 副作用冒泡 (Flag Bubbling) :这是最重要的一步!它把子节点的 flags(变动标记)全部合并到父节点的 subtreeFlags 里。
    • 寻找兄弟:如果有兄弟节点,指针跳到兄弟节点,重新进入 ② 阶段;如果没有兄弟,继续向上回溯执行父节点的 completeWork
④ 完成阶段 (Completion)
  • 关键点:当 workInProgress 指针重新回到根节点并完成 completeWork 时,循环结束。
  • 结果:此时,内存中已经诞生了一棵完整的、被打满了变动标记的 WIP Fiber 树
  • 交接棒:React 将这棵树命名为 finishedWork,准备交给 Commit 阶段 去执行DOM 操作。

Commit
阶段二Commit(提交)
  • 特点: 同步、不可中断。
  • 目标: 操作真实 DOM,执行生命周期/Hooks 副作用。

当 Render Phase 结束,workLoop 退出,React 会拿着 WIP 树进入 Commit阶段。它会依次执行以下三个阶段

① Before Mutation 阶段 (DOM 变更前)
  • 核心函数: commitBeforeMutationEffects
  • 做了什么: 调用 getSnapshotBeforeUpdate 生命周期函数;处理 useEffect 的异步调度。
    • useEffect 仅在 Before Mutation 阶段被「调度」,实际异步执行在整个 Commit 阶段结束后
② Mutation 阶段 (DOM 变更)
  • 核心函数: commitMutationEffects
  • 做了什么: 真正操作 DOM。根据 Render 阶段打上的 Flags (Placement新增/移动, Update更新, Deletion删除) ,执行 appendChild, removeChild, commitUpdate。此时用户就能看到屏幕上的变化
③ Layout 阶段 (DOM 变更后)
  • 核心函数: commitLayoutEffects
  • 做了什么:
    • 同步生命周期/Hooks:此时真实 DOM 已经更新完毕。React 会执行 componentDidMount/Update 以及 useLayoutEffect
    • ref赋值:将最新的 DOM 实例赋值给你的 ref.current
    • 再次调度更新:如果在 useLayoutEffect 里又触发了 setState,会在这里再次发起一个新的调度任务
  • 关键点: 在这个阶段,root.current = finishedWork双缓存树的指针在这里正式完成切换。 WIP 树变成了 Current 树。

workLoop

WorkLoop 是驱动 Render 阶段不断向下执行的引擎。

// 摘自 ReactFiberWorkLoop.js
function workLoopConcurrent() {
  // Perform work until Scheduler asks us to yield
  // 核心:只要有任务且浏览器没掉帧,就一直干活
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

function performUnitOfWork(unitOfWork) {
  const current = unitOfWork.alternate;
  let next;

  // 1. 【向下推导】:调用 beginWork,生成子 Fiber
  next = beginWork(current, unitOfWork, renderLanes);

  unitOfWork.memoizedProps = unitOfWork.pendingProps;
  if (next === null) {
    // 2. 【向上回溯】:如果没有子节点了,说明当前分支到底了,执行 completeUnitOfWork
    completeUnitOfWork(unitOfWork);
  } else {
    workInProgress = next;
  }
}

Fiber

Fiber 既是静态的数据结构,也是动态的工作单元

Fiber 是 React 为Render阶段设计的 「任务单元 + 数据结构」,本质是:

  1. 数据结构层面:Fiber 是一个「增强版虚拟 DOM 节点」,用双向链表(有父 / 子 / 兄弟指针)替代传统单向树,每个 Fiber 节点对应一个组件 / DOM 元素,记录了组件的更新状态、优先级、要执行的操作(比如新增 / 更新);
  2. 执行层面:Fiber 是「最小渲染任务单元」,把原来「一整块渲染任务」拆成无数个 Fiber 小任务,每个任务可独立执行、暂停、重启。
总结
  • Fiber 本质:双向链表结构的「任务单元 + 虚拟 DOM 节点」;
  • 核心作用 1:把渲染拆成可中断的小任务,解决页面卡顿;
  • 核心作用 2:支持任务优先级,保证用户交互优先执行;
  • 核心作用 3:支撑双缓存树,让 DOM 更新更高效、无闪烁。
// 摘自 ReactInternalTypes.js
function FiberNode(tag, pendingProps, key, mode) {
  // 1. 实例属性
  this.tag = tag;             // 标记组件类型 (Function/Class/Host)
  this.key = key;
  this.elementType = null;    // 大部分情况下同 type
  this.type = null;           // 具体的组件函数或 DOM 标签
  this.stateNode = null;      // 对应的真实 DOM 节点或 Class 实例

  // 2. 构成 Fiber 树的物理链表结构
  this.return = null;         // 父节点
  this.child = null;          // 第一个子节点
  this.sibling = null;        // 右侧第一个兄弟节点
  this.index = 0;

  // 3. 工作属性 (数据)
  this.pendingProps = pendingProps; // 新传入的 props
  this.memoizedProps = null;        // 上一次渲染的 props
  this.updateQueue = null;          // 状态更新队列 (存放 setState 的 action)
  this.memoizedState = null;        // 上一次渲染的 state (Hooks 存放在这)

  // 4. 副作用相关
  this.flags = NoFlags;             // 记录当前节点的增/删/改标记
  this.subtreeFlags = NoFlags;      // 子树的副作用标记 (性能优化关键)
  this.deletions = null;            // 待删除的子节点

  // 5. 优先级调度 (Lane 模型)
  this.lanes = NoLanes;
  this.childLanes = NoLanes;

  // 6. 双缓存机制
  this.alternate = null;            // 指向 workInProgress 树或 current 树的对应节点
}

当前机制的弊端

React 的 beginWork 默认是贪婪的。只要父组件更新,React 无法确定子组件内部是否引用了会导致变化的数据,因此默认会重新执行所有子组件。

补救机制

React.memo

React.memo 是针对“组件级别”的拦截。

  • 原理:它将组件包装成一个特殊的 Fiber 类型(MemoComponent)。
  • 核心逻辑
    1. workLoop 走到被 memo 包裹的组件时,会触发 updateMemoComponent 函数。
    2. 它不再进行简单的引用比较,而是执行 shallowEqual (浅比较)
    3. 如果浅比较结果为 true(即所有 Props 的值都没变),它会立即执行 Bailout(跳过) 逻辑。
  • 效果:直接切断 beginWork 的向下递归,该组件及其子树完全不执行,直接复用上次的渲染结果。
const MemoChild = React.memo(function Child() {
  console.log("只有我的 Props 变了,你才能看到我打印");
  return <div>我是受保护的子组件</div>;
});

function Parent() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>更新父组件</button>
      {/* 此时 beginWork 走到这里,会执行 shallowEqual,发现没变,直接跳过 */}
      <MemoChild /> 
    </div>
  );
}
useMemo

useMemo 是针对“计算逻辑/数据”的拦截。

  • 原理:在 Fiber 节点的 memoizedState 链表中开辟一块空间,用于持久化存储数据。
  • 核心逻辑
    1. 存储结构:它在内存中存储一个格式为 [value, deps] 的数组。
    2. 对比更新:每次组件执行时,它会取出上次存储的 deps 与当前的 deps 进行逐项对比。
    3. 拦截逻辑:如果 deps 没变,直接从内存读取 value 并返回,不再重新执行计算函数。
  • 效果:避免了昂贵的计算逻辑在每次渲染时重复运行。
function DataList({ items, filterText }) {
  // ❌ 错误示范:只要组件重绘(哪怕是因为 filterText 无关的更新),都会重新排序
  // const sortedItems = items.sort(); 

  // ✅ 正确示范:只有 items 变化时,才重新计算排序
  const sortedItems = useMemo(() => {
    console.log("正在执行高昂的排序计算...");
    return items.sort();
  }, [items]); // 依赖项检查

  return <div> { sortedItems } </div>;
}
useCallback

useCallback 是针对“函数引用”的拦截。

  • 原理:它是 useMemo 的一个变体(语法糖),专门用于缓存函数引用。
  • 核心逻辑
    1. 存储结构:在 memoizedState 中存储 [callback, deps]
    2. 引用稳定:只要依赖项(deps)不改变,它始终返回同一个函数的内存地址。
  • 为什么需要它
    • 它的核心存在意义是为了配合 React.memo
    • 如果父组件给子组件传了一个函数,不包裹 useCallback 的话,每次父组件渲染都会生成新函数,导致子组件的 React.memo 因为发现 props.onClick 引用变了而拦截失败。
const BigButton = React.memo(({ onClick }) => {
  console.log("BigButton 渲染了");
  return <button onClick={onClick}>大按钮</button>;
});

function App() {
  const [count, setCount] = useState(0);

  // ❌ 坑点:如果不包 useCallback,每次 App 更新都会生成一个新的 handleClick
  // 这会导致 BigButton 的 React.memo 浅比较失效(引用地址变了)
  // const handleClick = () => console.log("clicked");

  // ✅ 填坑:保证 handleClick 指向同一个内存地址,不会被setCount的更新影响
  const handleClick = useCallback(() => {
    console.log("clicked");
  }, []); 

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>触发更新App</button>
      <BigButton onClick={handleClick} />
    </div>
  );
}

总结:优化的本质

在 React 的 Fiber 架构中,所有的优化手段(memouseMemouseCallback)其实都在做同一件事:

通过牺牲一小部分内存(存储旧数据/旧引用),来换取在 beginWork 阶段执行 Bailout (跳过)的机会,从而减少 CPU 的计算负担。

vue3项目搭建基础

element-plus

安装依赖(确保版本适配 Vue 3)

npm install element-plus --save

plugins/element.js 实现按需引入组件

import { ElButton } from 'element-plus'  // 导入 Element Plus 的 Button(Vue 3 版本)
import 'element-plus/dist/index.css' //全局引入样式,避免找不到

 // 接收 main.js 传入的 app 实例
export default function setupElement(app) {
 // 注册 Button 组件(Vue 3 用 app.component 注册单个组件)
  app.component('ElButton', ElButton)
}

main.js

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import setupElement from './plugins/element.js'

//创建应用实例
const app = createApp(App)
//注册 Element Plus 插件
setupElement(app)
app.use(router)
//挂载应用
app.mount('#app')

App.vue

<template>
  <div id="app">
        <el-button type="danger">Danger</el-button>
  </div>
</template>

<script>
export default {
  name: 'app'
}
</script>

<style>
        html,body,#app{
                width: 100%;
                height: 100%;
                padding: 0;
                margin: 0;
        }
</style>

代码是从上往下一行一行执行的

  • 执行 main.js中引入代码

  • import { createApp } from 'vue' → 从 Vue 库导入 createApp 方法;

  • import App from './App.vue' → 导入根组件 App.vue(仅加载,未渲染);

  • import router from './router' → 导入路由(如果有配置);

  • 当执行到import setupElement from './plugins/element.js'会立即进入 element.js 执行里面的代码,但注意:element.js 中只有「导入组件 + 定义函数」的逻辑会执行,app.component 注册组件的逻辑要等 main.js 中调用 setupElement(app) 时才执行。

从 Element Plus 加载按钮组件的代码;

执行 import 'element-plus/dist/index.css' → 加载 Element Plus 的全局样式(CSS 生效);

执行 export default function setupElement(app) {...} → 定义 setupElement 函数(仅定义,不执行函数内部代码);

执行完 element.js 后,将 setupElement 函数作为返回值,赋值给 main.js 中的 setupElement 变量

  • 执行 main.js 中的实例创建和注册逻辑

  • const app = createApp(App) → 创建 Vue 应用实例(此时还未挂载组件);

  • setupElement(app)调用 element.js 中定义的函数

    • 将 Vue 实例 app 传入函数,执行 app.component('ElButton', ElButton) → 全局注册 ElButton 组件;
  • app.use(router) → 注册路由(如果有);

  • app.mount('#app') → 将 Vue 实例挂载到页面的 #app 元素上:

    • 渲染 App.vue 组件;
    • 解析 App.vue 中的 <el-button> 标签 → 匹配到全局注册的 ElButton 组件 → 渲染出带样式的危险按钮。
import setupElement from './plugins/element.js'

//创建应用实例
const app = createApp(App)
//注册 Element Plus 插件
setupElement(app)

相当于这里要引用setupElement这个方法,创建实例时,调用setupElement这个方法,并将创建的实例当成参数传入,实现vue组件的注册。

简单说:setupElement 是一个 “组件注册工具函数”,调用它并传入 Vue 实例 app,本质就是把 ElButton 组件挂载到 Vue 应用实例上,这样整个项目的所有组件(比如 App.vue)都能使用 <el-button> 标签。

element.js

import { ElCard,ElCol,ElRow } from 'element-plus'
import 'element-plus/dist/index.css'

export default function setupElement(app) {
  app.component('ElCard', ElCard)
  app.component('ElCol', ElCol)
  app.component('ElRow', ElRow)

}

进行优化

import { ElCard,ElCol,ElRow } from 'element-plus'
import 'element-plus/dist/index.css'

export default function setupElement(app) {

    //批量注册
    const components = { ElCard, ElCol, ElRow }
    Object.entries(components).forEach(([name, component]) => {
      app.component(name, component)
    })
}

将需要注册的组件放入对象中,这里是简写语法。 const components = { ElCard, ElCol, ElRow }

const components = { ElCard: ElCard, ElCol: ElCol, ElRow: ElRow }

Object.entries(components)把对象转成「键值对数组」:转换成「二维数组」,每个子数组包含「键、值」

[     ['ElCard', ElCard], 
    ['ElCol', ElCol],
    ['ElRow', ElRow]
   ]

.forEach(...) → 遍历这个二维数组

([name, component]):ES6 数组解构,把遍历到的子数组 ['ElCard', ElCard] 拆成两个变量:

  • name = 'ElCard'(组件注册名);
  • component = ElCard(组件对象)

app.component(name, component):调用 Vue 3 的组件注册方法,等价于 app.component('ElCard', ElCard)

echarts

安装依赖

ESLint 相关的配置包版本太旧,和新版的 Vue ESLint 插件不兼容,连带导致 echarts 安装失败.直接在安装命令后加 --legacy-peer-deps,强制忽略版本冲突,快速安装 echarts

npm i -S echarts --legacy-peer-dep

正常安装

npm i -S echarts

彻底解决依赖冲突

如果想从根本上修复版本冲突,执行以下步骤(不影响已安装的 Element Plus):

步骤 1:卸载旧的 ESLint 配置包

bash

运行

npm uninstall @vue/eslint-config-standard --save-dev
步骤 2:安装兼容新版 eslint-plugin-vue@8.x 的配置包

bash

运行

npm install @vue/eslint-config-standard@latest --save-dev --legacy-peer-deps
步骤 3:重新安装 echarts

bash

运行

npm i -S echarts

main.js

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import setupElement from './plugins/element.js'
import * as echarts from 'echarts'

const app = createApp(App)
setupElement(app)
app.config.globalProperties.$echarts = echarts
app.use(router)
app.mount('#app')

Vue 3 中移除 Vue.prototype,改用 app.config.globalProperties 的本质是:Vue 3 不再基于构造函数原型链扩展,而是基于应用实例的全局配置来挂载全局属性 / 方法,更贴合 Vue 3 的「实例化」设计(每个 createApp() 都是独立的应用实例)。app.config.globalProperties 挂载到这个对象上的属性 / 方法,会被注入到所有组件的「选项式 API」上下文(即 this)中,效果和 Vue 2 的 Vue.prototype 完全一致,但作用域仅限当前应用实例。

// 创建应用实例 
const app = createApp(App) 
// 挂载全局属性/方法 
app.config.globalProperties.自定义属性名 = 要挂载的内容


特性 Vue 2 Vue 3
核心载体 全局构造函数 Vue(所有组件共享同一个原型链) 应用实例 appcreateApp() 创建,多实例隔离)
全局挂载 Vue.prototype.$xxx = xxx(挂载到构造函数原型,全局共享) app.config.globalProperties.$xxx = xxx(挂载到单个应用实例,实例隔离)
  • 多实例隔离:Vue 3 支持一个页面创建多个独立的 Vue 应用实例(比如 app1 = createApp(), app2 = createApp()),如果用原型链,会导致多个实例的全局属性互相污染;而 globalProperties 是绑定到单个 app 实例的,不同实例的全局属性互不影响。

  • 更符合模块化:Vue 3 推崇「模块化」「按需使用」,原型链扩展是侵入式的(修改全局构造函数),而 globalProperties 是配置式的(仅修改当前应用实例),更灵活。

import * as echarts from 'echarts'

import * as echarts from 'echarts' 的作用是:把 echarts 库中所有导出的内容,整体导入并挂载到 echarts 这个变量上。这么写的根本原因是:echarts v5+ 采用「命名导出」(Named Export),而非「默认导出」(Default Export),所以不能用 import echarts from 'echarts' 这种默认导入方式

导出方式 语法示例(库作者写的代码) 导入方式(你写的代码) 适用场景
默认导出(Default Export) export default { init: () => {} } import echarts from 'echarts' 库只有一个核心导出(比如 Vue、React)
命名导出(Named Export) export const init = () => {}``export const dispose = () => {} import * as echarts from 'echarts'import { init } from 'echarts' 库有多个独立导出(比如 echarts、lodash)

echarts 源码的核心导出逻辑类似这样(简化版):

// echarts 源码中的导出逻辑(模拟)
export const init = (dom) => { /* 初始化图表 */ }
export const dispose = (instance) => { /* 销毁图表 */ }
export const registerMap = (name, data) => { /* 注册地图 */ }
// ... 还有上百个命名导出的方法/对象

:echarts 没有写 export default,只有一堆 export const/function 的「命名导出」—— 这就是为什么你用 import echarts from 'echarts' 会报错(找不到默认导出)

import * as echarts from 'echarts' 语法拆解

语法片段 含义
import * 导入目标模块中所有命名导出的内容(init/dispose/registerMap 等)
as echarts 把这些导入的内容,统一挂载到一个名为 echarts命名空间对象
from 'echarts' echarts 这个模块导入

相当于给 echarts 库的所有导出内容做了一个 “收纳箱”,箱子名字叫 echarts

  • 原本分散的 init 方法 → 现在是 echarts.init
  • 原本分散的 dispose 方法 → 现在是 echarts.dispose
  • 所有 echarts 的功能,都可以通过 echarts.xxx 访问,既整洁又不会污染全局变量。

如果想只导入部分方法,也可以写:

// 只导入 init 方法(命名导出的精准导入) 
import { init } from 'echarts'
// 使用时直接写 init(),而非 echarts.init() 
const chart = init(document.getElementById('chart'))

echarts 极致按需导入

// 极致按需导入(推荐生产环境用)
import { init } from 'echarts/core'
import { BarChart } from 'echarts/charts'
import { TitleComponent, TooltipComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'

// 注册需要的模块
init.use([BarChart, TitleComponent, TooltipComponent, CanvasRenderer])

// 使用 init 方法创建图表
const chart = init(document.getElementById('chart'))

echarts使用

<div ref="totalOrderRef" :style="{width:'100%',height:'100px'}" />

<script setup>
        import { onMounted,ref,getCurrentInstance } from 'vue'
        // 1. 定义 ref 变量,和模板中的 ref="totalOrderRef" 对应
        const totalOrderRef = ref(null)
        // 2. 在 onMounted 中获取元素(DOM 渲染完成)
        onMounted(() => {
          // 通过 ref 获取元素(Vue3 推荐方式)
          const chartDom = totalOrderRef.value
          if (chartDom) {
                 const instance = getCurrentInstance()
                 const $echarts = instance.appContext.config.globalProperties.$echarts
                 const chart = $echarts.init(chartDom)
                 chart.setOption({
                         xAxis: {
                                 type: 'value',
                                 show: false
                         },
                         yAxis: {
                                 type: 'category',
                                 show: false
                         },
                         series: [{
                                name: '上月平台用户数',
                                type: 'bar',
                                stack: '总量',
                                data: [300],
                                barWidth: 10,
                                itemStyle: {
                                        color: '#45c946'
                                }
                         },{
                                name: '今日平台用户数',
                                type: 'bar',
                                stack: '总量',
                                data: [200],
                                itemStyle: {
                                        color: '#ddd'
                                }
                         },{
                                 type: 'custom',
                                 stack: '总量',
                                 data: [300],
                                 renderItem: (params,api) => {
                                        const value = api.value(0)
                                        const point = api.coord([value,0])
                                        return {
                                                type: 'group',
                                                position: [point[0] - 10, point[1]] ,
                                                children: [{
                                                        type: 'path',
                                                        shape: {
                                                                d: 'M0 767.909l512.029-511.913L1024 767.909 0 767.909z',
                                                                x: 0,
                                                                y: 2,
                                                                width: 20,
                                                                height: 20
                                                        },
                                                        style: {
                                                                fill: '#45c946'
                                                        }
                                                },{
                                                        type: 'path',
                                                        shape: {
                                                                d: 'M1024 255.996 511.971 767.909 0 255.996 1024 255.996z',
                                                                x: 0,
                                                                y: -22,
                                                                width: 20,
                                                                height: 20
                                                        },
                                                        style: {
                                                                fill: '#45c946'
                                                        }
                                                }]
                                        }
                                 }
                         }],
                         grid: {
                                top: 0,
                                bottom: 0,
                                left: 0,
                                right: 0
                         }
                 })
          }
        })
</script>



getCurrentInstance()

<script setup> 中 “接触” 到组件底层实例的唯一官方入口(<script setup> 是封闭作用域,无法直接访问 this,所以无法像 Vue2 那样用 this.$echarts,只能通过 getCurrentInstance() 获取 appContext(应用上下文),进而访问全局挂载的 $echarts。必须在组件生命周期内调用,onMountedonCreated<script setup> 顶层调用。异步回调(如 setTimeout、接口请求回调)中调用可能获取不到实例。

创建 ECharts 实例

$echarts.init() 方法会创建一个独立的 ECharts 实例,并返回给变量 chart。这个实例是操作图表的 “唯一入口”

  • 每个 DOM 容器对应一个独立的 ECharts 实例(避免多个图表冲突);

  • 实例包含图表的所有配置、数据、渲染状态等核心信息。

setOption

向 ECharts 实例传入图表的配置项(Option),让 ECharts 根据配置渲染 / 更新图表

    chart.setOption({
      xAxis: { type: 'value', show: false }, // x轴配置
      yAxis: { type: 'category', show: false }, // y轴配置
      series: [/* 柱状图/自定义图形系列配置 */], // 图表数据和样式
      grid: { top: 0, bottom: 0 } // 网格布局
    })

配置合并规则

   // 完全替换旧配置,重新渲染图表 
   chart.setOption(newOption, true)            
  • 默认:setOption合并新旧配置(新配置覆盖旧配置,未修改的保留);

  • 强制替换:如需完全替换配置(而非合并),可传第二个参数 true

renderItem

它是 ECharts 「自定义系列(custom series)」的核心渲染函数,作用是「告诉 ECharts 如何手动绘制每一个数据项的图形」

renderItem 是自定义系列(type: 'custom')的必填配置,ECharts 渲染自定义系列时,会对每一个数据项调用一次 renderItem,你需要在这个函数中返回「图形描述对象」,ECharts 会根据这个描述画出对应的图形。

当 ECharts 渲染 type: 'custom' 的系列时,会遍历该系列的 data 数组(比如写的 data: [200]),对每一个数据项(这里是 200)执行一次 renderItem 函数。

通过 renderItem,你可以突破 ECharts 内置图表(如柱状图、折线图)的限制,实现:

  • 自定义形状(如三角形、五角星、不规则路径);
  • 精准控制图形位置(如定位到 200 数值处);
  • 组合多个图形(如一个三角形 + 一个文本标签);
  • 动态调整图形样式(如根据数据值改变颜色 / 大小)。

api对象提供 ECharts 内置的「坐标转换 / 数据获取」工具方法:

api.value(dimIndex):获取当前数据项指定维度的值(如 api.value(0) 取 200);

api.coord([x, y]):把逻辑坐标(如 [200, 0])转换成画布像素坐标;

api.size([width, height]):把逻辑尺寸转换成像素尺寸;

api.style():获取系列默认样式

params对象包含当前数据项的上下文信息:

params.dataIndex:当前数据项的索引(如 0)

params.value:当前数据项的原始值(如 200);

params.seriesIndex:当前系列的索引;

params.coordSys:坐标系信息(如 x/y 轴类型)

renderItem 必须返回一个「图形描述对象」(或数组),ECharts 会根据这个对象绘制图形

    return {
     type: 'group', // 图形类型:组合图形(可包含多个子图形)
     position: point, // 图形的基准位置(像素坐标)
     children: [{ // 子图形列表
       type: 'path', // 图形类型:路径(自定义形状)
       shape: { // 形状配置
         d: 'M0 767.909...', // 路径指令(三角形的绘制路径)
         x: 0,//路径的偏移
         y: 0, //路径的偏移
         width: 20, // 尺寸
         height: 20 // 尺寸
       },
       style: { fill: 'red' } // 样式:填充红色
     }]
   }
类型 作用 示例场景
path 绘制自定义路径(如三角形、不规则图形) 你画红色三角形的核心
rect 绘制矩形 自定义柱状图
circle 绘制圆形 自定义散点图
text 绘制文本 给图形加标签
group 组合多个图形 三角形 + 文本标签

和前面用 type: 'bar' 画柱状图,ECharts 会自动渲染;而 type: 'custom' 则是把渲染权完全交给你,核心差异

维度 内置系列(bar/line) 自定义系列(custom)
渲染逻辑 ECharts 自动绘制(固定形状) 你通过 renderItem 手动定义
灵活性 低(只能改样式,不能改形状) 高(可画任意形状)
复杂度 简单(只需配置 data/style) 稍高(需手动计算坐标 / 形状)
适用场景 标准图表(柱状图、折线图) 非标准图形(自定义标记、组合图形)

配置

stack

它是 ECharts 中用于实现「堆叠式图表」的关键配置,作用是「将多个同系列类型(如 bar)、同 stack 值的系列,在同一类目下按数值累加堆叠显示」 —— 两个柱状图系列因为都配置了 stack: '总量',才会从 0 开始依次累加(200+260),形成绿色 + 灰色的分段堆叠效果

组件引用

把导入的 TopView 组件 “注册” 到 Home 组件的作用域中,只有注册后,才能在 <template> 中使用;

  <template>
      <div class="home">
              <!-- 引用组件 -->
              <top-view/>  
      </div>
</template>

<script>
        import TopView from '../components/TopView'
        export default {
                name: 'Home',
                components: {
                        TopView,// 局部注册组件
                }
        }
</script>

<style>
        .home{
                width: 100%;
                height: 100%;
                padding: 0 20px;
                background: #eee;
                box-sizing: border-box;
        }
</style>

仅在当前 Home 组件内可用,其他组件(如 About.vue)不能直接用 <top-view>,需重新导入 + 注册。不会污染全局作用域,适合只在单个页面使用的组件

公共组件提取

如CommonCard这个组件很多地方用到,进行提取

创建一个mixins文件夹,创建对应的文件名card.js

import CommonCard from '../components/CommonCard/index'
export default {
        components: {
                CommonCard
        }
}

需要调用时引入card.js

<template>
        <common-card/>
</template>

<script>
        import CommonCard from '../../mixins/card'
        export default {
                mixins: [CommonCard]
        }
</script>

<style>
</style>

vue3调用公共组件

<template>
        <common-card
                title="累计订单量"
                value="2,124,223"
        >
        </common-card>
</template>

<script setup>
        import { onMounted, ref,defineOptions } from 'vue'
        import commonCardMixin from '../../mixins/commonCardMixin'
        defineOptions({
          mixins: [commonCardMixin]
        })

</script>

需要使用defineOptions

Mixin 是 Vue2 的经典写法,Vue3 更推荐用 “组件导入函数”“全局注册组件” 替代 mixin(减少隐式依赖)

// utils/importComponents.js
export const useCommonCard = () => {
  const CommonCard = defineAsyncComponent(() => import('../components/CommonCard/index'))
  return { CommonCard }
}

业务组件中使用:

 <script setup>
    import { useCommonCard } from '../../utils/importComponents'
    const { CommonCard } = useCommonCard() // 显式获取组件
</script>

全局注册组件(适合全项目高频使用的组件)

// main.js
import { createApp } from 'vue'
import CommonCard from './components/CommonCard/index'

const app = createApp(App)
app.component('CommonCard', CommonCard) // 全局注册,所有组件可直接使用

插槽

引用子组件common-card时,自定义两个插槽的内容及样式。下面代码存在有误地方,

    <template>
      <div class="compare-wrapper">
        <div class="compare">
          <span>同比</span>
          <span class="emphasis">7.3%</span>
        </div>
      </div> 
    </template>

这段代码无法正常显示出默认插槽中的内容,<template> 无任何指令(如 v-slot)直接包裹默认插槽 → 编译时会被忽略,内容不渲染.

父组件:
<template>
  <common-card 
    title="销售额"
  >

    <template>
      <div class="compare-wrapper">
        <div class="compare">
          <span>同比</span>
          <span class="emphasis">7.3%</span>
        </div>
      </div> 
    </template>
    <!-- footer 具名插槽 -->
    <template v-slot:footer>
      <span>昨日销售额:</span>
      <span class="money">¥40,123</span>
    </template> 
  </common-card>
</template>

子组件:
<template>
<div class="common-card">
<div class="title">{{title}}</div>
<div>
                <slot></slot>
</div>
<div class="total">
    <slot name="footer"></slot>
</div>
</div>
</template>

<template>...</template>,Vue 不知道这是 “默认插槽”,就不会把内容插入到 <slot></slot> 位置;加 v-slot 指令后,Vue 才会识别并渲染。

    <template v-slot>
      <div class="compare-wrapper">
        <div class="compare">
          <span>同比</span>
          <span class="emphasis">7.3%</span>
        </div>
      </div> 
    </template>
    

或者不需要template包裹

        <div class="compare-wrapper">
        <div class="compare">
          <span>同比</span>
          <span class="emphasis">7.3%</span>
        </div>
      </div> 
      
写法 是否生效 原因
<template>内容</template> ❌ 不生效 Vue 无法识别这是默认插槽,编译时忽略该 template
<template v-slot>内容</template> ✅ 生效 v-slot 指令明确标识这是「默认插槽」,Vue 会把内容插入到 <slot></slot> 位置
直接写内容(无 template) ✅ 生效 Vue 自动把未包裹的内容识别为默认插槽,是最简洁的写法

Vue2 和 Vue3 在「<template> 包裹默认插槽」的语法上确实有差别 ——Vue2 中 <template> 包裹默认插槽无需加 v-slot 就能生效,而 Vue3 必须显式加 v-slot 指令

场景 Vue2 写法(生效) Vue3 写法(生效) Vue3 错误写法(不生效)
template 包裹默认插槽 <template>内容</template> <template v-slot>内容</template> <template>内容</template>
直接写默认插槽内容 直接写内容(生效) 直接写内容(生效) -
具名插槽 <template slot="footer"> / <template v-slot:footer> <template #footer> / <template v-slot:footer> -

vue3这样都通过 v-slot 指令管理,逻辑更清晰,也避免了 “无指令 template 被误解析” 的问题。

slot作用

Vue3 中的插槽,本质是组件对外暴露的 “自定义渲染接口” —— 组件开发者给组件预留 “空白位置”(<slot>),组件使用者可以往这个位置插入任意内容(HTML、子组件、逻辑渲染的内容),实现「组件固定结构复用 + 自定义内容灵活定制」。

插槽就像你买的 “定制化蛋糕胚”,蛋糕胚的大小、形状(组件的基础 UI / 逻辑)是固定的,但你可以往上面加水果、奶油、巧克力(插槽内容),做出不同样式的蛋糕,而不用重新做蛋糕胚。

复用性:

没有插槽的组件,只能显示固定内容

<!-- 无插槽的 CommonCard 组件 -->
<template>
  <div class="card">
    <div class="title">累计销售额</div>
    <div class="content">¥1,211,312</div>
  </div>
</template>

有插槽,复用性拉满

<!-- 有插槽的 CommonCard 组件 -->
<template>
  <div class="card">
    <div class="title">{{ title }}</div>
    <!-- 插槽:预留自定义位置 -->
    <div class="content">
      <slot></slot>
    </div>
  </div>
</template>
<script>
export default { props: ['title'] }
</script>

可自定义内容,同一个组件适配多场景

<!-- 场景1:显示销售额 -->
<common-card title="累计销售额">
  <div>¥1,211,312</div>
</common-card>

<!-- 场景2:显示订单数 -->
<common-card title="累计订单数">
  <div>123,456 单</div>
</common-card>

<!-- 场景3:显示复杂内容(同比数据+箭头) -->
<common-card title="累计销售额">
  <div class="compare">
    <span>日同比 7.3%</span>
    <div class="arrow-up"></div>
  </div>
</common-card>


逻辑与 UI 解耦,降低维护成本,比如修改卡片的基础样式只需改 CommonCard,修改销售额的显示格式只需改插槽内容。

插槽可以插入任意内容:

  • 普通 HTML 标签(<div>/<span>);
  • 其他 Vue 组件(<el-button>/<chart>);
  • 带逻辑的渲染内容(v-if/v-for/{{ 变量 }});
  • 甚至是 JSX/TSX(复杂场景)。

Vue3 把插槽分为 3 类,覆盖所有自定义场景,且废弃了 Vue2 的 slot 属性,统一用 v-slot 指令(简写 #

1. 默认插槽(匿名插槽)

  • 定义:无 name 属性的 <slot>,是组件的 “默认自定义位置”;

  • 用法

    • 简洁写法(推荐):直接把内容写在组件标签内,无需 <template>
    • 完整写法:用 <template v-slot> 包裹(Vue3 必须加 v-slot)。

2. 具名插槽

  • 定义:有 name 属性的 <slot>,用于组件内多位置自定义(比如卡片的 header、body、footer);

  • 用法:用 <template #插槽名>(或 <template v-slot:插槽名>)包裹内容,和组件内的 name 一一对应。

    <!-- 组件内定义多个具名插槽 -->
    <template>
      <div class="card">
        <div class="header">
          <slot name="header"></slot> <!-- 具名插槽:header -->
        </div>
        <div class="body">
          <slot></slot> <!-- 默认插槽 -->
        </div>
        <div class="footer">
          <slot name="footer"></slot> <!-- 具名插槽:footer -->
        </div>
      </div>
    </template>
    
    <!-- 使用具名插槽 -->
    <common-card>
      <template #header>
        <h3>累计销售额</h3> <!-- 对应 header 插槽 -->
      </template>
      <div>¥1,211,312</div> <!-- 对应默认插槽 -->
      <template #footer>
        <span>昨日销售额:¥40,123</span> <!-- 对应 footer 插槽 -->
      </template>
    </common-card>
    
    

3. 作用域插槽(带数据的插槽)

  • 定义:组件可以给插槽传递数据(作用域数据),使用者可以接收并基于这些数据自定义渲染 —— 这是插槽的 “高级玩法”,实现「组件传数据 + 使用者自定义渲染」;

  • 用法

    1. 组件内:给 <slot> 绑定属性(:数据名="数据值");

    2. 使用者:用 <template v-slot="插槽变量"> 接收数据,在插槽内使用。

      <!-- 组件内定义作用域插槽(给插槽传数据) -->
      <template>
        <div class="card">
          <!-- 给默认插槽传数据:salesData -->
          <slot :salesData="sales"></slot>
          <!-- 给 footer 插槽传数据:yesterdaySales -->
          <slot name="footer" :yesterdaySales="yesterday"></slot>
        </div>
      </template>
      <script>
      export default {
        data() {
          return {
            sales: { total: 1211312, dayRatio: 7.3 }, // 组件内部数据
            yesterday: 40123
          }
        }
      }
      </script>
      
      <!-- 使用作用域插槽(接收并使用数据) -->
      <common-card>
        <!-- 接收默认插槽的 data,自定义渲染 -->
        <template v-slot="slotProps">
          <div>
            累计销售额:¥{{ slotProps.salesData.total.toLocaleString() }}
            <span>日同比:{{ slotProps.salesData.dayRatio }}%</span>
          </div>
        </template>
        <!-- 接收 footer 插槽的 data,自定义渲染 -->
        <template #footer="footerProps">
          <span>昨日销售额:¥{{ footerProps.yesterdaySales.toLocaleString() }}</span>
        </template>
      </common-card>
      

Vue3 支持对插槽变量解构,让代码更简洁:

        <!-- 解构默认插槽数据 -->
            <template v-slot="{ salesData }">
              <div>累计销售额:¥{{ salesData.total }}</div>
            </template>

            <!-- 解构具名插槽数据 + 重命名 -->
            <template #footer="{ yesterdaySales: ys }">
              <span>昨日销售额:¥{{ ys }}</span>
            </template>  
            

4.支持动态插槽名

可以用变量作为插槽名

<template #[dynamicSlotName]>
  <div>动态插槽内容</div>
</template>
<script>
export default {
  data() {
    return { dynamicSlotName: 'footer' } // 动态指定插槽名
  }
}
</script>

插槽的实战场景

数据可视化组件:封装图表组件时,用作用域插槽传递图表数据,使用者自定义 tooltip、图例的显示样式

列表渲染组件:装通用列表组件时,用作用域插槽传递列表项数据,使用者自定义列表项的渲染样式

通用组件封装:封装卡片、表格、弹窗、导航栏等通用组件时,用插槽预留自定义位置

vue-echarts

vue-echarts 完全兼容 Vue3,且专门为 Vue3 做了适配(支持 <script setup>、组合式 API 等),相比直接使用原生 ECharts,它的优势是:

  • 自动处理 ECharts 实例的创建 / 销毁,避免内存泄漏;
  • 响应式更新配置,数据变化时自动重绘图表;
  • 无需手动获取 DOM 元素,直接通过组件属性传参。
    npm install echarts vue-echarts -S

    npm install vue-echarts echarts --save

全局注册

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import setupElement from './plugins/element.js'

// 1. 核心补充:按需导入 ECharts 核心模块(必填)
import { use } from 'echarts/core'
// 导入你需要的图表类型(根据业务场景添加,比如柱状图、自定义系列)
import { BarChart, CustomChart, LineChart } from 'echarts/charts'
// 导入渲染器(必须,推荐 CanvasRenderer)
import { CanvasRenderer } from 'echarts/renderers'
// 可选:导入交互组件(如提示框、图例,按需添加)
import { TooltipComponent, LegendComponent } from 'echarts/components'

// 2. 核心补充:注册 ECharts 模块(必填)
use([
  BarChart, CustomChart, LineChart, // 图表类型
  CanvasRenderer,                  // 渲染器(必须)
  TooltipComponent, LegendComponent // 可选交互组件
])

// 3. 导入 vue-echarts 组件(你的原有代码)
import VueECharts from 'vue-echarts'

const app = createApp(App)
setupElement(app)

// 4. 全局注册 <v-chart> 组件(你的原有代码,正确)
app.component('v-chart', VueECharts)

app.use(router)
app.mount('#app')    

v-echarts

npm i v-charts echarts -S

或安装(直接执行以下命令,纠正包名 + 忽略 peer 依赖冲突

npm install vue-echarts echarts -S --legacy-peer-deps

vue2和vue3支持状态

维度 v-charts(第三方) vue-echarts(官方)
Vue3 适配 ❌ 无官方支持 ✅ 完全适配(v6+ 版本专为 Vue3 设计)
维护状态 ❌ 停止维护 ✅ 持续更新,和 ECharts 版本同步
打包体积 ❌ 内置全量 ECharts,体积大 ✅ 按需导入模块,体积可控
功能完整性 ❌ 仅封装部分 ECharts 功能 ✅ 支持 ECharts 所有功能(包括自定义系列、交互)
文档 / 社区 ❌ 文档陈旧,无社区支持 ✅ 官方文档完善,问题可在 ECharts 社区解决

v-charts(第三方)和 vue-echarts(官方),前者是 Vue2 专属,后者是 Vue3 首选

Element Plus

el-menu

<el-menu> 组件的基础使用方式,用于实现水平导航菜单(如页面顶部的销售额 / 访问量切换)

<el-menu 
  mode="horizontal" 
  :default-active="'1'"
  @select="pnSelect"
>
配置项 类型 作用 & 意义
mode="horizontal" 字符串 定义菜单的布局模式: horizontal:水平布局(顶部导航); vertical:垂直布局(侧边栏); inline:内嵌布局(侧边栏子菜单展开)
:default-active="'1'" 字符串 / 数字(响应式绑定) 设置菜单的默认选中项,值需和 <el-menu-item>index 匹配;注意:index 本质是字符串,所以这里用 '1'(加引号)更规范(也可写 1,Element Plus 会自动转换) 默认选中 “销售额”(index="1"),页面加载后该菜单项会高亮
@select="pnSelect" 事件绑定 监听菜单选中事件:点击 <el-menu-item> 时触发,回调函数会接收当前选中项的 index 点击 “销售额”/“访问量” 时,pnSelect 方法会拿到 index(1/2),用于后续业务逻辑(如切换图表数据)

react图解源码之初始化挂载

react内部的初始化挂载

现在下面有如下页面渲染代码:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>React in HTML</title>
    <script src="./react.development.js"></script>
    <script src="./react-dom.development.js"></script>
    <script crossorigin src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
</head>
<style>
  .component {
    border: 1px solid #ccc;
    padding: 10px;
    margin: 10px;
  }
</style>
<body>
  <div id="root"></div>

  <!-- 使用 type="text/babel" 让 Babel 编译 JSX -->
  <script type="text/babel">

    function A() {
      return (
        <div className="component" data-name="A">
          <div>A</div>
          <div>B</div>
        </div>
      );
    }


    // 创建 React 组件
    function App() {
      return (
        <A />
      );
    }

    // 渲染组件到 DOM
    const root = ReactDOM.createRoot(document.getElementById('root'));
    root.render(<App />);
  </script>
</body>
</html>

Fiber结构介绍

上面的代码大概是如下的层级结构:

<App>
    <A>
        <div>
            <div>A</div>
            <div>B</div>
        </div>
    </A>
</App>

上面的结构在React中会构建如下图的一个FiberTree

image-2.png

从上图中可以看到,在该FiberTree中一共包含2种不同的类型:

  • FiberRootNode: FiberRootNode 是一个特殊节点,充当 React 的根节点,它保存着整个应用程序所需的元数据。其 current 属性指向实际的 Fiber 树结构,每次构建新的 Fiber 树时,它都会将 current 重新指向新的 HostRoot
  • FiberNode: react内部中对结点的一种表示,包含很多属性可以对其结点进行描述
    • tag: FiberNode有许多不同的子类型,在render以及commit阶段会根据该值进行不同的处理,如HostRootFunctionComponentClassComponentHostComponent等等
    • stateNode: 对于tagHostComponetFiberNode,其指向页面中实际渲染的DOM节点
    • childsiblingreturn: 分别指向子节点,兄弟节点以及父节点,用于构造完整Fiber
    • flags: 用于表示在 commit 阶段需要更新的类型。subtreeFlags 表示其子树需要更新的类型

上面对于FiberNode的属性介绍只包含当前初始化页面所需要的属性,其他属性在后面需要用到时再进行解释。

大致过程

我们先来把整个渲染过程进行一个粗略的介绍,我们在渲染页面时,会执行下面的代码

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);

上面的代码中,分为了以下两个主要过程:

  1. createRoot
  2. render

createRoot

上面的createRoot会创建一个FiberRootNode

function createRoot(container, options) {
    var root = createContainer(container, ConcurrentRoot, null, isStrictMode, concurrentUpdatesByDefaultOverride, identifierPrefix, onRecoverableError);
    return new ReactDOMRoot(root);
  }

function createContainer(containerInfo, tag, hydrationCallbacks, isStrictMode, concurrentUpdatesByDefaultOverride, identifierPrefix, onRecoverableError, transitionCallbacks) {
    var hydrate = false;
    var initialChildren = null;
    return createFiberRoot(containerInfo, tag, hydrate, initialChildren, hydrationCallbacks, isStrictMode, concurrentUpdatesByDefaultOverride, identifierPrefix, onRecoverableError);
  }


function createFiberRoot(containerInfo, tag, hydrate, initialChildren, hydrationCallbacks, isStrictMode, concurrentUpdatesByDefaultOverride, identifierPrefix, onRecoverableError, transitionCallbacks) {
    var root = new FiberRootNode(containerInfo, tag, hydrate, identifierPrefix, onRecoverableError);
    var uninitializedFiber = createHostRootFiber(tag, isStrictMode);
    root.current = uninitializedFiber;
    uninitializedFiber.stateNode = root;
    return root;
  }

上面代码的大致执行流程如下:

image-13.png

执行完成后,会创建一个FiberRootNode,保存在ReactDomRoot实例的this._internalRoot中,充当 React 的根节点,它保存着整个应用程序所需的元数据。其 current 属性指向实际的 Fiber 树结构,每次构建新的 Fiber 树时,它都会将 current 重新指向新的 HostRoot。生成如下FiberTree结构:

image-8.png

当前的root属性如下:

image-4.png

上图中的root为当前ReactDOMRoot的实例,内部_internalRoot为当前实例的FiberRootNode,其current指向当前页面的FiberNode节点。其中tag的值为3,表示FiberNode的节点类型为HostRoot

image-5.png

render

render过程中主要执行了下面内容:

ReactDOMRoot.prototype.render = function (children) {
  var root = this._internalRoot;
  updateContainer(children, root, null, null);
};

function updateContainer(element, container, parentComponent, callback) {
  // some other code...
  var update = createUpdate(eventTime, lane);
  update.payload = {
    element: element
  };
  callback = callback === undefined ? null : callback;
  var root = enqueueUpdate(current$1, update, lane);

  if (root !== null) {
    // 进入调度器 schedule
    scheduleUpdateOnFiber(root, current$1, lane, eventTime);
  }
  return lane;
}

function scheduleUpdateOnFiber(root, fiber, lane, eventTime) {
  if ((executionContext & RenderContext) !== NoLanes && root === workInProgressRoot) {
    // some code...
  } else {
    // 确保FiberRootNode被调度
    ensureRootIsScheduled(root, eventTime);
  }
}

function ensureRootIsScheduled(root, currentTime) {
  // some code...
  // 获取更新优先级,拿取最高级别的优先级任务进行执行
   scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
}

function performSyncWorkOnRoot(root) {
    var exitStatus = renderRootSync(root, lanes);
    var finishedWork = root.current.alternate;
    root.finishedWork = finishedWork;
    root.finishedLanes = lanes;
    commitRoot(root, workInProgressRootRecoverableErrors, workInProgressTransitions);
    return null;
  }

上面的代码可以概括为下面的流程图:

image-6.png

其中scheduleUpdateOnFiberensureRootIsScheduled以及scheduleSyncCallback都是调度相关的函数,本章的重点是渲染,先暂时跳过这些内容,后期调度相关会详细讲解。渲染相关的核心函数为performSyncWorkOnRoot,以及performConcurrentWorkOnRoot函数,performSyncWorkOnRoot同步模式performConcurrentWorkOnRoot并发模式

同步模式
performSyncWorkOnRoot
    ↓
renderRootSync  // 同步渲染根节点
    ↓
workLoopSync    // 同步工作循环(不可中断)
    ↓
completeUnitWork // 完成单元工作
    ↓
commitRoot      // 提交变更到DOM
并发模式
performConcurrentWorkOnRoot
    ↓
renderRootConcurrent  // 并发渲染根节点
    ↓
workLoopConcurrent    // 并发工作循环(可中断)
    ↓
shouldYield? → 是 → 暂停并返回
    ↓
completeUnitWork
    ↓
commitRoot

上面的同步模式与并发模式的主要区别为渲染根节点的过程,同步模式创建FiberTree的过程不可中断并发模式则可以被高优先级任务中断,而commitRoot过程则是一致的,都是同步执行。

上面代码可以看到render函数中执行了renderRootSynccommitRoot两个函数,也是React中比较重要的两个部分,一个是创建FiberNode以及对应的stateNodeflagssubtreeFlags,并生成FiberTree,另一个是根据前面创建的FiberTree,获取对应节点的flags以及subtreeFlags来进行对应的DOM节点挂载操作。

📢 注意:由于commitRoot一次执行完成的挂载过程,为了避免浏览器产生闪烁以及重绘等,React内部进行了优化,也就是在rendercomplete阶段,就将子树的整个DOM树构建完成了,并将其对应的flags冒泡到父节点的subtreeFlags属性中,后续在commit阶段直接比较该属性进行相应的子树挂载即可。下面会进行详细的解析:

renderRootSync

renderRootSync函数中的核心为do...while(true)的执行workLoopSync函数

function renderRootSync(root, lanes) {
  prepareFreshStack(root, lanes);
  do {
    try {
      workLoopSync();
      break;
    } catch (thrownValue) {
      handleError(root, thrownValue);
    }
  } while (true);
  workInProgressRoot = null;
  workInProgressRootRenderLanes = NoLanes;

  return workInProgressRootExitStatus;
}

function prepareFreshStack(root, lanes) {
  root.finishedWork = null;
  root.finishedLanes = NoLanes;

  workInProgressRoot = root;
  var rootWorkInProgress = createWorkInProgress(root.current, null);
  workInProgress = rootWorkInProgress;
  finishQueueingConcurrentUpdates();
  return rootWorkInProgress;
}

function createWorkInProgress(current, pendingProps) {
  var workInProgress = current.alternate;

  if (workInProgress === null) {
    // We use a double buffering pooling technique because we know that we'll
    // only ever need at most two versions of a tree. We pool the "other" unused
    // node that we're free to reuse. This is lazily created to avoid allocating
    // extra objects for things that are never updated. It also allow us to
    // reclaim the extra memory if needed.
    // 双缓存机制,创建 alternate
    workInProgress = createFiber(current.tag, pendingProps, current.key, current.mode);
    workInProgress.elementType = current.elementType;
    workInProgress.type = current.type;
    workInProgress.stateNode = current.stateNode;

    workInProgress.alternate = current;
    current.alternate = workInProgress;
  } else {
    workInProgress.pendingProps = pendingProps;
    workInProgress.type = current.type;
    workInProgress.flags = NoFlags;
    workInProgress.subtreeFlags = NoFlags;
    workInProgress.deletions = null;
  }


  workInProgress.flags = current.flags & StaticMask;
  workInProgress.childLanes = current.childLanes;
  workInProgress.lanes = current.lanes;
  workInProgress.child = current.child;
  workInProgress.memoizedProps = current.memoizedProps;
  workInProgress.memoizedState = current.memoizedState;
  workInProgress.updateQueue = current.updateQueue;

  var currentDependencies = current.dependencies;
  workInProgress.sibling = current.sibling;
  workInProgress.index = current.index;
  workInProgress.ref = current.ref;

  return workInProgress;
}
function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

function performUnitOfWork(unitOfWork) {
  var current = unitOfWork.alternate;
  setCurrentFiber(unitOfWork);
  var next;

  if ( (unitOfWork.mode & ProfileMode) !== NoMode) {
    next = beginWork(current, unitOfWork, subtreeRenderLanes);
  } else {
    next = beginWork(current, unitOfWork, subtreeRenderLanes);
  }

  resetCurrentFiber();
  unitOfWork.memoizedProps = unitOfWork.pendingProps;

  if (next === null) {
    completeUnitOfWork(unitOfWork);
  } else {
    workInProgress = next;
  }

  ReactCurrentOwner$2.current = null;
}

上面的代码中,主要实现了两部分重要内容:

  • 1.创建workInProgressprepareFreshStack函数将当前FiberRootNode类型的节点传入,使用其root.current属性创建workInProgressroot.current为当前容器的FiberNode节点,也就是FiberNode(HostRoot)
  • 2.开启工作循环:workLoopSync以及performUnitOfWork函数实现了对FiberTree的创建,其中beginWork是创建FiberNodecompleteUnitOfWork为创建stateNode并构建以当前节点为根结点的DOM树的过程。

执行完上面的内容后,当前内存中的数据结构如下:

image-14.png

workLoopSync

下面是beginWork函数的主要内容,主要就是根据fiberNodetag值执行不同的方法

function beginWork(current, workInProgress, renderLanes) {
  workInProgress.lanes = NoLanes;

  switch (workInProgress.tag) {
    case IndeterminateComponent:
      {
        return mountIndeterminateComponent(current, workInProgress, workInProgress.type, renderLanes);
      }

    case FunctionComponent:
      {
        var Component = workInProgress.type;
        var unresolvedProps = workInProgress.pendingProps;
        var resolvedProps = workInProgress.elementType === Component ? unresolvedProps : resolveDefaultProps(Component, unresolvedProps);
        return updateFunctionComponent(current, workInProgress, Component, resolvedProps, renderLanes);
      }

    case HostRoot:
      return updateHostRoot(current, workInProgress, renderLanes);

    case HostComponent:
      return updateHostComponent(current, workInProgress, renderLanes);

    case HostText:
      return updateHostText(current, workInProgress);
  }
}

function updateHostRoot(current, workInProgress, renderLanes) {
  var nextProps = workInProgress.pendingProps;
  var prevState = workInProgress.memoizedState;
  var prevChildren = prevState.element;
  var nextState = workInProgress.memoizedState;
  var root = workInProgress.stateNode;
  var nextChildren = nextState.element;

  reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  return workInProgress.child;
}

让我们从头进入该函数,了解一下执行过程,现在使用了如下的结构进行渲染:

<App>
    <A>
        <div>
            <div>A</div>
            <div>B</div>
        </div>
    </A>
</App>
beginWork阶段
第一次 workLoop

函数执行过程如下图:

react-begin-loop1.png

这是第一次workLoop循环,当前的workInProgress === FiberNode(HostRoot),进入beginWork函数,根据当前tag === 3,进入updateHostRoot函数,然后进入reconcileChildren函数中,此时,会将 root.render(<App />)代码中的<App />函数组件,经过babel编译后的ReactElement对象作为nextChildren传入reconcileChildFibers作为函数,该函数为执行ChildReconciler(true)后返回的函数,也就是ChildReconciler函数中的闭包reconcileChildFibers函数,其中shouldTrackSideEffects参数为true。后面继续进入reconcileSingleElement函数,该函数的主要作用是创建FiberNode(<App />),并将其return属性,指向当前FiberNode(HostRoot)。后续将当前新创建的FiberNode(<App />)作为参数传入placeSingleChild函数,添加flags,其shouldTrackSideEffects === true,则FiberNode(<App />).flags === Placement,执行到当前的内存数据接口如下:

image-17.png

然后从placeSingleChild依次从调用栈中进行返回,其中比较重要的就是在reconcileChildren函数中,将workInProgress.child属性指向了新创建的FiberNode(<App />)节点。此时结构如下:

image-18.png

然后在performUnitOfWork函数中,将workInProgress指向了FiberNode(<App />)节点。此时结构如下:

image-19.png

第二次workLoop

此时进入下一次workLoopSync循环,流程如下:

react-work-loop2.png

当前循环与上一次循环不同之处在于,此时的FiberNode(<App />).tag === IndeterminateComponent并且此时workInProgresscurrentnull,于是进入mountIndeterminateComponent函数,将该FiberNode<App />.tag修改为FunctionComponent,然后继续执行renderWithHooks函数,在该函数内,如果是函数组价,则获取其type,也就是函数来进行执行,执行完成后的返回值,传入reconcileChildren函数内,由于current === null,所以执行mountChildFibers函数,创建FiberNode(<A />),当前数据结构如下:

image-22.png

在函数返回后逐渐返回调用栈中的函数,并在reconcileChildren函数中将workInProgress.child指向当前新的结点FiberNode(<A />),并在performUnitOfWork函数中将当前新创建的结点指向workInProgress,当前数据结构如下:

image-23.png

第三次workLoop

继续执行第三次workLoop循环,流程如下:

react-work-loop3.png 当前循环与上次循环过程基本一致,除了生成的FiberNodetag === HostComponent类型,这是在createFiberFromTypeAndProps函数中根据当前的type值决定的,当前type === div,表示为宿主组件,则将tag = HostComponent,当前数据结构如下:

image-24.png 依次返回调用栈中的函数,本轮循环执行完成后数据结构如下:

image-25.png

第四次workLoop

react-work-loop4.png

第四次循环流程如上图👆🏻,当前workInProgress === FiberNode(div),则在beginWork函数中根据tag === HostComponent,会进入updateHostComponent函数,babel在编译时将HostComponent组件的子元素作为children属性放在了其element.props中,然后再创建FiberNode时,保存在了FiberNode(div).pendingProps属性中。如下代码:

function createFiberFromElement(element, mode, lanes) {
  var type = element.type;
  var key = element.key;
  var pendingProps = element.props;
  var fiber = createFiberFromTypeAndProps(type, key, pendingProps, owner, mode, lanes);
  return fiber;
}

然后再updateHostComponent中执行下面代码,将其子元素传入了reconcileChildren函数

var nextProps = workInProgress.pendingProps;
var prevProps = current !== null ? current.memoizedProps : null;
var nextChildren = nextProps.children;

执行到reconcileChildFibers函数内部,发现其isArray(newChild) === true,则执行了reconcileChildrenArray函数

function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes) {
  var resultingFirstChild = null;
  var previousNewFiber = null;
  var oldFiber = currentFirstChild;
  var lastPlacedIndex = 0;
  var newIdx = 0;
  var nextOldFiber = null;

  if (oldFiber === null) {
    for (; newIdx < newChildren.length; newIdx++) {
      var _newFiber = createChild(returnFiber, newChildren[newIdx], lanes);

      if (_newFiber === null) {
        continue;
      }

      lastPlacedIndex = placeChild(_newFiber, lastPlacedIndex, newIdx);

      if (previousNewFiber === null) {
        resultingFirstChild = _newFiber;
      } else {
        previousNewFiber.sibling = _newFiber;
      }

      previousNewFiber = _newFiber;
    }

    return resultingFirstChild;
  }
  return resultingFirstChild;
}

当前函数执行完成后的内存模型如下:

image-27.png 如上图所示,生成了对应的FiberNode(div-A)节点,以及FiberNode(div-B)节点,并将其通过sibling属性进行连接。并且它们的return节点都指向了FiberNode(div)节点。

后续依次返回调用栈中的函数,本轮循环执行完成后数据结构如下:

image-29.png

第五次workLoop

进入第五次workLoop循环中的beginWork函数,当前workInProgress === FiberNode(div-A),流程如下: react-work-loop5.png 大致流程与上次循环一致,不同点为没有其子元素,函数返回的为null,其没有子节点,于是进入performUnitOfWork函数中的completeUnitOfWork函数。当前内存中数据结构如下:

image-30.png

整体的beginWork流程如下图:

beginWork.png

一个 ERR_SSL_PROTOCOL_ERROR 让我们排查了三层问题,最后发现根本不是 SSL 的锅

这篇文章写给所有在本地开发时被浏览器报错 ERR_SSL_PROTOCOL_ERROR 整崩溃过的人。

背景

我使用ngrok给我的前端做了一个内网穿透。但后端一直不接受http请求。后端跑的是 HTTP,前端发的是 HTTPS,两者对不上,浏览器给了一个 ERR_SSL_PROTOCOL_ERROR。修复方案写了三层,每一层都有对应的代码证据。整个排查过程涉及:SSL 协议层、Vite 代理路由层、业务会话上下文层。

第一章:事情是怎么发生的

用户打开前端页面,点击任何一个需要后端数据的功能,浏览器 network 面板直接红:

GET https://localhost:8000/api/... 
ERR_SSL_PROTOCOL_ERROR

同一时间,后端日志里出现:

WARNING: Invalid HTTP request received.

这个警告是 uvicorn 抛出来的。uvicorn 收到了一个它根本看不懂的请求——因为客户端发来的是 TLS 握手包,而 uvicorn 根本没有启用 TLS,它启动命令是:

uvicorn http://0.0.0.0:8000

没有 --ssl-keyfile,没有 --ssl-certfile,就是纯 HTTP。

所以整件事的本质很简单:前端用了 HTTPS 去打一个 HTTP 服务器的端口,服务器不认识 TLS 握手,直接丢弃,浏览器报 SSL 错误。

但"为什么前端会用 HTTPS 去请求 localhost",这才是真正需要拆开说的部分。

第二章:前端是怎么一步步走到 HTTPS 的

场景一:开发者用 HTTPS 打开了 Vite 开发服务器

Vite 支持 HTTPS 模式启动。如果开发者本地配置了 --https 或者浏览器历史记录里有 https://localhost:5173,那么所有从这个页面发出去的 fetch 请求,如果 base URL 是绝对路径 https://localhost:8000,就会直接绕过 Vite proxy,用 HTTPS 去打后端。

而 Vite proxy(vite.config.ts:11)配置的是把 /api 转发到 http://localhost:8000这个 proxy 只在相对路径请求时生效。一旦前端代码里写死了 https://localhost:8000,请求就直接出去了,proxy 根本插不上手。

场景二:通过 ngrok 暴露后在本地调试

ngrok 给你一个 https://xxxx.ngrok.io 的域名,前端页面从这个域名加载。此时 window.location.protocolhttps:window.location.hostnamexxxx.ngrok.io(不是 localhost)。

如果前端的 API base URL 逻辑是"我在 HTTPS 环境,所以我用 https://localhost:8000 来请求后端",那就出问题了。从 ngrok 的 HTTPS 页面发出 https://localhost:8000 的请求,浏览器不会走 Vite proxy(因为你不是在 localhost 上),请求直接打到本机 8000 端口,而那里跑的是 HTTP,凉了。

第三章:修复是怎么做的?

修复分三个层次

层次一:normalizeApiBase

这个函数处理"当前环境到底该用什么 base URL"的问题。

逻辑是:如果检测到当前是 HTTPS 环境或远程 host,但目标是 localhost,就回退为空字符串(相对路径)。

空字符串意味着请求走的是 /api/... 这种相对路径,这样 Vite proxy 就能接管,把它转发到 http://localhost:8000

这一步解决了"HTTPS 页面不小心拼出 https://localhost:8000"的问题。

层次二:installLocalhostFetchPatch

这是一个更激进的兜底。它在 window.fetch 上打了一个 monkey patch:拦截所有目标是 https://localhost 的请求,把它们重写成 http://127.0.0.1:xxxx

为什么要用 127.0.0.1 而不是 localhost?因为某些浏览器对 localhost 有特殊的安全策略处理,用 127.0.0.1 更保险。

这一步是防御性的,即使上面那一层没拦住,这里也能把 HTTPS 的 localhost 请求"降级"到 HTTP。

层次三:Vite Proxy

proxy: {
  '/api': 'http://localhost:8000'
}

所有走相对路径 /api/... 的请求,在 Vite dev server 层面就被代理到后端,完全不经过浏览器的 HTTPS/HTTP 协议判断,是最干净的解法。

同时 vite.config.ts:10allowedHosts 包含了 ngrok 域名,确保通过 ngrok 访问时 Vite 不会拒绝请求。

明白了,你想把这一章从"这套方案的局限"扩展成一篇更有普适价值的 SSL 错误指南——用这次排查作为引子,讲清楚开发者最常碰到的那几类 SSL 问题。我来重写这两个部分:

第四章:SSL 报错那么多,到底哪种是哪种

ERR_SSL_PROTOCOL_ERROR 只是浏览器 SSL 错误家族里的一个成员。把它们放在一起看,你会发现每一种错误背后的根因其实差异很大,但开发者往往一看到 SSL 就开始检查证书,其实南辕北辙。

ERR_SSL_PROTOCOL_ERROR:协议对不上

这就是本文的主角。不是证书的问题,是客户端发了 TLS 握手,服务端根本不认识这个握手。最常见的触发条件:后端跑 HTTP,前端用 HTTPS 去打;或者服务端配置的 TLS 版本太低(比如只支持 TLS 1.0),而客户端要求 TLS 1.2 以上。

排查方向:先用 curl -v https://your-host:port 看连接阶段的输出,确认服务端有没有在做 TLS 握手响应。如果 curl 直接报 SSL handshake failure,问题在服务端;如果 curl 能通但浏览器不行,问题在浏览器侧(HSTS、证书信任等)。

ERR_CERT_AUTHORITY_INVALID:CA 不被信任

证书是真实的,但签发这张证书的 CA 不在浏览器的信任链里。本地开发用 openssl 自签名证书时最常见。解法有两个:一是用 mkcert 这类工具生成本地可信证书(它会把自己的 CA 写入系统信任库);二是在 Chrome 地址栏输入 thisisunsafe 临时跳过(仅限开发调试,绝对不能用于生产)。

ERR_CERT_COMMON_NAME_INVALID:域名对不上

证书是有效的,CA 也可信,但证书里写的域名和你实际访问的域名不一致。比如证书颁发给 api.example.com,你用 www.example.com 去访问,就报这个错。通配符证书(*.example.com)可以解决同一域下多子域的问题,但它不覆盖根域本身,也不覆盖二级以上的子域。

用 ngrok 做内网穿透时有时会碰到这个,因为 ngrok 的域名每次可能不同,而你本地配置的证书是固定域名。

ERR_CERT_DATE_INVALID:证书过期

最好排查也最尴尬的一种——证书到期了。Let's Encrypt 的免费证书有效期是 90 天,如果自动续签的 cron job 挂了,就会在某天突然全站 SSL 报错。运维侧应该有证书过期的提前告警(比如到期前 30 天、7 天各发一次通知)。

检查命令:

echo | openssl s_client -connect your-domain:443 2>/dev/null | openssl x509 -noout -dates

输出里的 notAfter 就是过期时间。

NET::ERR_CERT_REVOKED:证书被吊销

证书被 CA 标记为不可信,原因通常是私钥泄露或者证书错误签发。浏览器会通过 OCSP(Online Certificate Status Protocol)或 CRL(Certificate Revocation List)实时查询证书状态。这种错误在开发阶段几乎不会遇到,生产环境一旦出现,需要立即联系 CA 重新签发。

HSTS 导致的强制 HTTPS(没有专属错误码,但很坑)

HSTS(HTTP Strict Transport Security)是服务端通过响应头 Strict-Transport-Security 告诉浏览器:"以后访问我这个域名,只准用 HTTPS。"浏览器会把这个策略缓存下来,即使后来服务端改回 HTTP,浏览器也拒绝发 HTTP 请求。

本地开发最容易踩这个坑:你之前在某个端口跑过 HTTPS 服务并发了 HSTS 头,后来改回 HTTP,结果浏览器死活不肯发 HTTP 请求,报的错看起来像 SSL 问题,但其实是 HSTS 缓存在作怪。

解法:Chrome 里打开 chrome://net-internals/#hsts,在 "Delete domain security policies" 里输入对应的域名或 localhost,删掉缓存。

最后

回头看这次排查,ERR_SSL_PROTOCOL_ERROR 这个报错本身其实挺有误导性的——它让人第一反应是去检查证书、检查 TLS 配置,但真正的问题是连 TLS 都没启用,谈何配置

SSL 报错的排查有一个基本原则值得记住:先确认 TLS 在哪一层断掉的,再去找断掉的原因。 是服务端根本没有 TLS(本文的情况)、还是握手失败(协议版本不兼容)、还是握手成功但证书校验失败(域名不对、CA 不信任、已过期)——这三个阶段的问题,修法完全不同,不能混为一谈。

最短排查路径:curl -v https://target:port 看握手阶段输出,能比浏览器给你更原始的错误信息,省掉很多猜测。

Element Plus 日期选择器(DatePicker)深度解析:从基础用法到高级定制

本文基于 Element Plus(Vue 3 + TypeScript),适用于现代前端开发场景。Element UI(Vue 2)的 DatePicker 原理相似,但 API 已有差异,请注意版本适配


一、核心概念与组件分类

Element Plus 的 el-date-picker 实际是 复合型组件,通过 type 属性切换不同模式:

类型 type 功能说明 适用场景
日期选择 date 单日选择 入住日期、生日等
日期范围 daterange 起止日期选择 预订入住/离店、活动周期
日期时间 datetime 精确到秒 会议预约、系统事件记录
日期时间范围 datetimerange 起止时间范围 课程安排、设备租用时段
年份选择 year 年份选择 统计报表筛选
月份选择 month 月份选择 季度分析、月度计划
<el-date-picker v-model="date" type="date" placeholder="选择日期" />
<el-date-picker v-model="range" type="daterange" range-separator="至" />

💡 注意:v-model 绑定的数据类型随 type 变化:

  • datestring(如 "2024-03-15")或 Date 对象
  • daterangeArray<string>Array<Date>
  • datetimestring(含时分秒)或 Date

二、关键属性详解

1. value-format:格式化输出值

控制 v-model 绑定的值格式(不改变 UI 显示):

<!-- 输出为 YYYY-MM-DD -->
<el-date-picker v-model="date" type="date" value-format="YYYY-MM-DD" />

<!-- 输出为时间戳 -->
<el-date-picker v-model="date" type="date" value-format="x" />

<!-- 输出为 ISO 字符串 -->
<el-date-picker v-model="date" type="date" value-format="YYYY-MM-DDTHH:mm:ssZ" />

✅ 推荐使用 YYYY-MM-DD(兼容性好),避免直接绑定 Date 对象导致序列化问题。

2. disabled-date:禁用特定日期

用于实现业务规则限制(如:不可选过去日期、节假日禁用):

const disabledStartDate = (date) => {
  return date.getTime() < Date.now() - 86400000; // 禁用昨天及之前
}

const disabledEndDate = (date) => {
  const startDate = new Date(roomClockForm.lockInTime);
  return date.getTime() < startDate.getTime(); // 结束时间不能早于开始时间
}
<el-date-picker 
  v-model="roomClockForm.lockInTime"
  :disabled-date="disabledStartDate"
/>
<el-date-picker 
  v-model="roomClockForm.lockOutTime"
  :disabled-date="disabledEndDate"
/>

3. default-time:默认时间点

type="date" 时,配合 value-format="YYYY-MM-DD HH:mm:ss" 可指定默认时刻:

<!-- 开始时间默认为 00:00:00 -->
<el-date-picker 
  v-model="startTime"
  type="date"
  :default-time="['00:00:00']"
  value-format="YYYY-MM-DD HH:mm:ss"
/>

<!-- 结束时间默认为 23:59:59 -->
<el-date-picker 
  v-model="endTime"
  type="date"
  :default-time="['23:59:59']"
  value-format="YYYY-MM-DD HH:mm:ss"
/>

4. picker-options(已弃用)→ 替代方案

Element Plus 中 picker-options 已被废弃,改用组合式 API:

  • disabled-date
  • shortcuts(快捷选项)
  • cell-class-name(自定义单元格样式)

三、高级功能实战

✅ 场景1:锁房管理中的日期约束(你项目中的需求)

// 仅允许选择今天及之后的日期
const disabledDate = (date) => {
  const today = new Date();
  today.setHours(0, 0, 0, 0); // 归零时分秒
  return date.getTime() < today.getTime();
}

// 结束时间 ≥ 开始时间
const disabledEndDate = (date) => {
  if (!roomClockForm.lockInTime) return disabledDate(date);
  const start = new Date(roomClockForm.lockInTime);
  start.setHours(0, 0, 0, 0);
  return date.getTime() < start.getTime();
}
<el-date-picker 
  v-model="roomClockForm.lockInTime"
  type="date"
  :disabled-date="disabledDate"
  value-format="YYYY-MM-DD"
/>
<el-date-picker 
  v-model="roomClockForm.lockOutTime"
  type="date"
  :disabled-date="disabledEndDate"
  value-format="YYYY-MM-DD"
/>

✅ 场景2:动态快捷选项(Shortcuts)

const shortcuts = [
  {
    text: '最近一周',
    value: () => {
      const end = new Date();
      const start = new Date();
      start.setTime(start.getTime() - 7 * 86400000);
      return [start, end];
    }
  },
  {
    text: '本月',
    value: () => {
      const end = new Date();
      const start = new Date(end.getFullYear(), end.getMonth(), 1);
      return [start, end];
    }
  }
]
<el-date-picker
  v-model="dateRange"
  type="daterange"
  :shortcuts="shortcuts"
  range-separator="至"
/>

✅ 场景3:自定义单元格样式(突出显示特殊日期)

const cellClassName = (date) => {
  const day = date.getDate();
  if (day === 1) return 'special-day'; // 每月1号加粗
  if (date.getDay() === 0 || date.getDay() === 6) return 'weekend'; // 周末灰色
  return '';
}
<el-date-picker 
  v-model="date"
  type="date"
  :cell-class-name="cellClassName"
/>
.special-day .el-date-table td {
  font-weight: bold;
  background-color: #f5f7fa;
}
.weekend .el-date-table td {
  color: #999;
}

四、常见问题与避坑指南

问题 原因 解决方案
选择日期后 v-model 为空 value-format 与绑定值类型不匹配 统一使用 string 格式,避免混用 Date 对象
时区偏移导致日期错位 浏览器本地时区 vs UTC 时间 使用 new Date(dateStr).toISOString() 转为 UTC,或后端统一处理
daterange 无法清空 v-model 绑定的是数组,需设为 null[] dateRange = nulldateRange = []
快捷选项点击无反应 value 返回值类型错误 确保返回 Date 对象数组(非字符串)
移动端日期选择体验差 默认弹出层过大 使用 popper-class 自定义样式,或考虑 el-date-picker + el-popover 组合

五、性能优化建议

  1. 懒加载日期数据

    // 避免在 mounted 中一次性加载大量房间数据
    watch(() => dialogVisible.value, (visible) => {
      if (visible) loadRoomList(); // 按需加载
    });
    
  2. 防抖提交

    const handleSearch = _.debounce(() => {
      loadRoomClockList();
    }, 300);
    
  3. 虚拟滚动(大数据量)

    当日期范围跨度极大(如10年)时:

    <!-- 使用 el-select + 自定义滚动组件替代原生 DatePicker -->
    <el-select v-model="yearMonth" placeholder="选择年月">
      <el-option v-for="y in years" :key="y" :label="y" :value="y" />
    </el-select>
    

六、源码级原理浅析

Element Plus 的 DatePicker 内部结构:

ElDatePicker
├── ElPopper (浮层容器)
│   └── DatePickerPanel (核心面板)
│       ├── DateTable (日历表格)
│       │   └── Cell (每个日期单元格)
│       ├── TimePicker (时间选择器)
│       └── ShortcutPanel (快捷选项)
└── Input (触发输入框)

关键逻辑:

  • DateTable 通过 generateDateCells() 计算当月所有日期
  • disabled-dateisDisabledDate() 中调用
  • value-formatformatDate()parseDate() 处理转换
  • default-timegetDefaultValue() 中注入

🔍 源码路径:node_modules/element-plus/es/components/date-picker/src


七、未来演进方向

  1. 国际化增强
    支持 first-day-of-week(周一开始/周日开始)、农历显示

  2. 无障碍(a11y)支持
    键盘导航(↑↓←→)、ARIA 标签完善

  3. Composition API 重构
    更细粒度的 useDatePicker Hook 封装

  4. el-calendar 联动
    实现“日历视图 + 详情编辑”一体化操作


总结

Element Plus 的 el-date-picker 是一个高度可配置的成熟组件,掌握以下要点即可应对绝大多数场景:

  • ✅ 用 value-format 统一数据格式
  • ✅ 用 disabled-date 实现业务规则
  • ✅ 用 default-time 控制默认时刻
  • ✅ 用 shortcuts 提升用户体验
  • ✅ 避免混用 Date 对象与字符串

📌 最佳实践:始终将日期作为字符串(YYYY-MM-DD)在前后端传输

如你在 RoomClockView.vue 中的需求——“只精确到天”,正是通过 type="date" + value-format="YYYY-MM-DD" + 提交时补全时分秒实现的完美方案。

希望这篇技术博客能帮你彻底吃透 Element Plus 日期选择器!如有具体场景疑问,欢迎继续探讨。

哨兵模式-无限滚动

前端哨兵模式(Sentinel Pattern)—— 优雅实现滚动加载

一、什么是哨兵模式?

想象你在排队买奶茶,你不知道什么时候轮到你。但如果在你前面第 3 个人身上贴了一张纸条,写着"看到我就准备点单"——这个人就是"哨兵"

在前端开发中,哨兵模式就是在页面的某个位置放一个不可见的元素(哨兵),当用户滚动页面让这个元素进入视口时,自动触发特定操作(比如加载下一页数据)。

它的核心技术是浏览器原生 API —— IntersectionObserver


二、原理

IntersectionObserver 是什么?

IntersectionObserver(交叉观察器)是浏览器提供的一个 API,用来异步地观察一个元素与视口(或某个祖先元素)的交叉状态

简单说:它能告诉你——"某个元素是否出现在了屏幕上"。

工作流程

┌─────────────────────────────────────┐
│            可视区域(视口)            │
│                                     │
│   ┌─────────────────────────────┐   │
│   │        已加载的列表项         │   │
│   │        ...                  │   │
│   │        列表项 N              │   │
│   └─────────────────────────────┘   │
│                                     │
│   ┌─────────────────────────────┐   │
│   │  🚨 哨兵元素(高度 1px)      │ ← 当它进入视口,触发回调
│   └─────────────────────────────┘   │
│                                     │
└─────────────────────────────────────┘
         ↓ 触发回调
    fetchNextPage()  → 加载更多数据
         ↓ 新数据渲染
    哨兵被推到新列表底部 → 等待下次进入视口

关键:每次新数据渲染后,哨兵自然地被推到列表最底部,形成一个自动循环:滚到底 → 加载 → 哨兵下移 → 再滚到底 → 再加载…


三、规则

使用哨兵模式时,需要遵守以下规则:

规则 说明
1. 哨兵元素必须始终在列表末尾 只有在最后面,用户滚到底才能触发
2. 防止重复触发 加载中时不要重复请求,用 loading 状态锁住
3. 有数据才放哨兵 没有数据或已加载完毕时,不渲染哨兵元素
4. 及时断开观察 组件卸载或条件变化时调用 observer.disconnect() 防止内存泄漏
5. 依赖项要完整 useEffect 的依赖数组要包含所有会影响是否加载的状态
6. 哨兵尽量小 高度 1px 即可,不要影响布局和用户体验

四、用法

基础用法(React + TypeScript)

import { useRef, useEffect, useState } from 'react';

function InfiniteList() {
  const [list, setList] = useState<string[]>([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);

  // 1️⃣ 创建哨兵元素的 ref
  const sentinelRef = useRef<HTMLDivElement | null>(null);

  // 2️⃣ 加载数据的函数
  const fetchData = async (p: number) => {
    if (loading) return;
    setLoading(true);
    try {
      const res = await fetch(`/api/list?page=${p}`);
      const data = await res.json();
      setList((prev) => [...prev, ...data.items]);
      setHasMore(data.items.length === 20);
      setPage(p);
    } finally {
      setLoading(false);
    }
  };

  // 3️⃣ 设置 IntersectionObserver
  useEffect(() => {
    const el = sentinelRef.current;
    if (!el) return;

    const observer = new IntersectionObserver(
      (entries) => {
        // 当哨兵进入视口,且满足加载条件
        if (entries[0].isIntersecting && hasMore && !loading) {
          fetchData(page + 1);
        }
      },
      { threshold: 0.1 } // 哨兵露出 10% 就触发
    );

    observer.observe(el);

    // 4️⃣ 清理:组件卸载或依赖变化时断开观察
    return () => observer.disconnect();
  }, [hasMore, loading, page]);

  return (
    <div>
      {list.map((item, i) => (
        <div key={i} className="list-item">{item}</div>
      ))}

      {/* 加载中提示 */}
      {loading && <div className="loading">加载中...</div>}

      {/* 5️⃣ 哨兵元素:有更多数据时才渲染 */}
      {hasMore && list.length > 0 && (
        <div ref={sentinelRef} style={{ height: 1 }} />
      )}

      {/* 没有更多了 */}
      {!hasMore && <div className="no-more">没有更多了</div>}
    </div>
  );
}

threshold 参数说明

new IntersectionObserver(callback, {
  threshold: 0.1,   // 元素露出 10% 时触发(推荐)
  // threshold: 0,   // 元素刚刚出现就触发
  // threshold: 1.0, // 元素完全可见才触发
  // rootMargin: '0px 0px 200px 0px', // 提前 200px 触发(预加载)
});

💡 小技巧:设置 rootMargin: '0px 0px 200px 0px' 可以让用户还没滚到底部就提前加载,体验更流畅。


五、适用场景

✅ 适合使用哨兵模式的场景

场景 说明
长列表滚动加载 商品列表、新闻流、聊天记录等
瀑布流加载 图片瀑布流、Pinterest 风格布局
分页数据替代方案 用无限滚动代替传统"上一页/下一页"
图片懒加载 图片进入视口才开始加载 src
曝光埋点 元素出现在屏幕上时上报埋点数据
动画触发 元素滚动到可视区域时播放动画

❌ 不适合的场景

场景 原因
数据量极少(< 1 页) 没有分页需求,多此一举
需要精确跳转到某页 无限滚动无法直接跳到第 N 页
SEO 要求高的页面 动态加载的内容不利于搜索引擎抓取
需要"回到顶部"后保持位置 无限滚动在页面刷新后无法恢复滚动位置

六、举个生活化的例子 🌰

场景:自助火锅的传送带

想象你在吃回转寿司

  1. 传送带 = 你的页面可滚动区域
  2. 寿司盘子 = 一条条数据
  3. 你的座位前方 = 视口(你能看到的区域)
  4. 最后一个盘子后面的"加菜牌" = 🚨 哨兵元素

当传送带转啊转,"加菜牌"经过你面前时,后厨就知道:盘子快被拿完了,赶紧做新的放上来!

  • 后厨正在做(loading = true)→ 不会重复通知
  • 盘子全上完了(hasMore = false)→ 把"加菜牌"撤掉
  • 还没开始吃(list.length === 0)→ "加菜牌"也不需要放

这就是哨兵模式的全部思想!


七、对比传统方案

方案 实现方式 优点 缺点
监听 scroll 事件 addEventListener('scroll', ...) 兼容性好 频繁触发、需要节流、计算滚动位置复杂
"加载更多"按钮 用户手动点击 简单直接 用户体验差,需要主动操作
🚨 哨兵模式 (IntersectionObserver) 观察哨兵元素 性能好、代码简洁、自动触发 极老浏览器不支持(IE 不支持)

性能对比

scroll 事件:每秒可能触发 60+ 次 → 需要 throttle/debounce
哨兵模式:  只在交叉状态变化时触发 → 天然高性能 🚀

八、注意事项

  1. 浏览器兼容性IntersectionObserver 在现代浏览器中均支持(Chrome 51+、Safari 12.1+)。如需兼容老浏览器,可引入 polyfill:
npm install intersection-observer
  1. 避免闪烁:如果页面初始内容不够长(不足以滚动),哨兵会立即可见并触发加载,这其实是正确行为——它会连续加载直到内容填满屏幕或没有更多数据。

  2. 配合 useCallback:如果 fetchData 函数作为依赖传入 useEffect,建议用 useCallback 包裹,避免不必要的 observer 重建。


总结

哨兵模式 = 放一个隐形元素在底部 + 用 IntersectionObserver 监听它是否出现 + 出现就加载数据

三句话,就是全部核心。剩下的只是条件判断和状态管理。它是目前前端实现无限滚动最优雅、性能最好的方案。

从 Recoil 的兴衰看前端状态管理的技术选型

从 Recoil 的兴衰看前端状态管理的技术选型

2023 年底,Meta 官方宣布 Recoil 进入“维护模式”,从它的兴衰历程中,我们看到了什么?

Recoil 的发展历程

2020 年:Recoil 横空出世

2020 年的一个下午,我正在 Twitter 上刷着动态,突然刷到一条让我想打开电脑蠢蠢欲动的动态 "Facebook 发布了 Recoil,一个实验性的状态管理库",状态管理在 React 生态里面一直是个工程负担,无论是团队角度从复杂工程的状态管理意识培养还是从开发者体验(DX)角度来看,React 状态管理一直像在等一个 “救世主”。

"Recoil 是一个实验性的状态管理库,为 React 提供了更好的状态管理体验。"

上面这是 Recoil 的宣传语,如果是2020年在写前端的同学肯定很熟悉当时的背景

当时的背景

  • Redux 虽然强大,但样板代码太多
  • Context API 性能问题明显,所有消费者都会重渲染
  • 社区渴望一个更简单、更现代的状态管理方案

Recoil 的核心优势

  1. 解决了 Context 的性能问题
    我只是想切换主题,为什么用户信息组件也要重渲染?应用越来越慢,每次状态变化都要重渲染几十个组件让我不得不拆分出非常多的 Context,有没有一种更简单的方式只订阅需要的状态。

  2. 比 Redux 更简单,减少了样板代码
    我只是想写一个计数器,为什么要写三个文件?action、reducer、dispatch...这些概念为什么这么抽象?这将层层传递(漏传) Props 的痛苦转变为一种新的痛苦。 有没有简单的状态管理方案?

  3. 原子化的状态管理理念
    工作台应用的复杂度完全取决于状态管理的复杂度,实际的业务逻辑并没有什么复杂的,反而我们在“技术”上花费大量时间建立开发者信心,这种 ROI 是经不起推敲的。 比如更新一个用户信息,我们 dispatch 一个 UPDATE_USER 类型的参数, 这时候心智负担是 A组件会重新渲染吗? B组件不应该渲染,但是渲染了。 C组件我也不知道是否会重新渲染。

  4. 官方背书
    既然是官方出的,那开发者生态自然会觉得这个方案非常可靠, 并且 TypeScript 支持优秀,我们下意识里面觉得是时候改变我们项目了。

2021-2022 年:快速成长期

Recoil 被广泛采用,成为 React 状态管理的热门选择之一。当时的成功案例和教程大量的涌现在互联网上 “Meta 内部项目开始使用”,"某知名开源项目也集成了 Recoil",生态的发展可是如火如荼,出现了 Recoil DevTools,与 React Router、React Query 等库集成良好。

在当时我们团队内部,我也开始推广 AtomSelector API 的使用,并对负责的项目进行状态管理改造。当时我比喻 Atom 就是一个保险柜:

  • 任何需要用钱的人都可以打开它
  • 任何人都可以往里面放钱或取钱
  • 保险箱里的钱变了,所有知道这个保险箱的人都会收到通知

假设我存了 100块到保险柜里

import { atom } from 'recoil';

// 创建一个 Atom,就像创建一个保险箱
const moneyState = atom({
  key: 'moneyState',      // 保险箱的名字(必须唯一)
  default: 100,           // 保险箱里的初始钱数
});

那么其他组件想交互我这个保险柜的状态像喝水一样简单

import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';

// 在组件中使用
function Wallet() {
  // 1. 读取和写入(既能看钱,也能存钱取钱)
  const [money, setMoney] = useRecoilState(moneyState);
  
  // 2. 只读取(只能看钱,不能改)
  const money = useRecoilValue(moneyState);
  
  // 3. 只写入(只能改钱,不能看)
  const setMoney = useSetRecoilState(moneyState);
  
  return (
    <div>
      <p>我的钱:{money}元</p>
      <button onClick={() => setMoney(money + 10)}>存10元</button>
      <button onClick={() => setMoney(money - 10)}>取10元</button>
    </div>
  );
}

当然我们也可以使用与保险柜配套的智能计算器 selector 来与保险柜交互,他会自动管理依赖关系,举个例子,今天大A跌了,我们取出来的钱要 x0.8, 我们如果使用普通函数实现的话,组件渲染每次都会新建这个函数,每次都会重新计算,这会损耗性能。如果使用原生useCallback API 的话引入了手动管理依赖的心智负担。

import { selector } from 'recoil';

const doubleMoneyState = selector({
  key: 'newMoneyState',  // 计算器的名字
  get: ({ get }) => {          // 计算逻辑
    const money = get(moneyState);  // 从保险箱里读取钱数
    return money * 0.8;               // 跌了!
  },
});

那重复计算场景,根据多个状态计算场景,过滤或者排序状态场景都可以轻松通过 selector 解决了。但在真实的实际使用过程中,发现 Recoil 的学习曲线并不是那么平滑,虽然 atom 和 selector 可以让新人2天内上手,但是 atomFamily、selectorFamily 的使用负担,selector越写越复杂导致的性能问题,以及没有彻底改造异步状态为 waitForAll,而是通过 Promise.all 的 JS API 来组合管理异步状态, 都让我感觉这次改造不如“预期” 。

Recoil 一直标记为 “试验性” ,刚开始我可能只是觉得 新项目嘛,API 可能随时变更,高速迭代,一直朝着最好的方向发展,但是渐渐的发现。企业级应用很难陪跑试验性的项目,没有人愿意为技术的升级买单。

2023 年:宣布进入维护模式

Recoil 团队宣布不再积极开发新功能,进入维护模式。

官方声明

"Recoil 已经进入维护模式,我们将继续修复关键 bug,但不会开发新功能。我们建议用户考虑其他状态管理方案。"

从官方公开资料与社区的状态来看,我觉得 Recoil 的衰落有3个原因:

  1. 资源有限

    • Meta 团队资源有限,无法同时维护多个状态管理方案
    • 优先级调整,资源分配到其他更重要项目(AI时代的趋势)
    • 维护一个"实验性"库的成本效益比不高
  2. 竞争激烈

    • Zustand、Jotai(Recoil原作者) 等新方案更轻量、更简单
    • 这些方案提供了类似的功能,但学习曲线更平缓
    • 社区开始转向更活跃的方案
  3. 需求变化

    • 前端技术栈快速演进,Recoil 的设计可能不再是最优解
    • 新的范式(如 Signals)开始兴起
    • 社区对状态管理的需求发生了变化

我从开始的震惊,转变为理解,也在社区开始讨论迁移方案,也在翻阅大量迁移指南和对比实践。

这次技术选型的经验

不要盲目追求"官方"方案

其实很多项目选择 Recoil 的一个重要原因是:它是 Meta 官方方案

  • "Recoil 是 Facebook 官方的,肯定可靠"
  • "Meta 的技术团队很厉害,他们的方案一定最好"
  • "官方方案 = 最佳方案"

但是回过头我们才发现

  • 官方 ≠ 最适合我们的项目
  • 官方方案也可能被放弃
  • 官方方案的学习曲线可能更陡峭
  • 官方方案的更新频率可能不如社区方案

Recoil 在后期面临的一个重要问题是:社区反馈响应不够及时。 提交了 Issue,但几周都没有回复,新功能的 Roadmap 始终没有看到,可持续性不够。

虽然切换到 Recoil 产生的技术收益不如预期,并且完全掉到另一个坑里(停止维护),但是团队通过这次迁移,强化和实践了 Recoil 可复制的理念。 比如每个状态都有自己的 R&R, 避免不必要的渲染。即使在后面迁移到 Zustand 我们的状态关系依然没有大的变化,状态之间的依赖关系清晰,并且可以独立测试每个状态。以及将一些老代码的 Context 也做了原子化拆分。

状态管理的技术趋势

3.1 简约主义:少即是多

当前最明显的趋势是追求简约。Zustand 的下载量超过了 Recoil 和 Jotai 的总和,大小却只有几KB,这正是与社区开发者共情 “这么简单的东西,我要写那么复杂吗?”,2018 年 统治者地位的 Redux 写个计数器demo 4个文件几十行代码,现如今。Zustand 3行代码,1个文件,开发1天内就能上手 API,更少的代码意味着更少的出错机会,团队反馈在 PeerReveiw 时的信心也增加了许多。Zustand 它让 80% 的场景变得简单,同时让 20% 的复杂场景仍然可行。

const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}));

function Counter() {
  const { count, increment } = useStore();
  return <button onClick={increment}>{count}</button>;
}

3.2 按需选择

社区不再追求"大一统"解决方案,就像工具箱里有锤子、螺丝刀、扳手,我们不会用锤子去拧螺丝。而是根据场景选择:

场景 推荐方案 理由
小型应用 React Context + Hooks 无需额外依赖
中型应用 Zustand 简单高效
大型企业应用 Redux Toolkit 生态成熟,工具完善
复杂状态依赖 Jotai 原子化设计
响应式需求 MobX / Valtio 自动追踪依赖

3.3 渐进式平滑迁移

在实际开发中,我听过很多“XXX技术好,我们要拥抱新技术!”,除去迁移风险,开发交付的核心是业务价值,重构过去稳定的模块在业务上也许完全没有收益, 就算以前写的很垃圾,经过这么多涂涂改改它也很稳定。 那我们的技术洁癖应该是渐进式迁移, 我们引入新方案与旧方案并存,新的业务享受着新技术的便利性,老的方案享受着无变更的稳定性。

那渐进式迁移的前提是什么? 迁移复杂度评估。目前主流的前端状态管理库似乎都意识到了这些工程难题:

  • 概念差异越大,迁移成本越高。
  • 代码结构差异越大,重构工作量越大。
  • 依赖的中间件、工具越多,迁移越复杂。

从而像充电器一样在做类似的“标准”。我们从 Recoil 迁移到 Zustand 的复杂度很低,因为两者的概念类似。

后话

技术会过时,但理念是:永恒的。

Vue 3 从基础到组合式 API 全解析

目录


1.1 基础概念

MVVM 模式

MVVM 由三部分组成:Model(模型)、View(视图)、ViewModel(视图模型)。

graph LR
    subgraph View_Layer["🖥️ View 层"]
        direction TB
        V1["📄 HTML 模板 + CSS 样式"]
        V3["👆 用户交互事件<br/>click / input / submit"]
    end

    subgraph ViewModel_Layer["⚙️ ViewModel 层"]
        direction TB
        VM1["📦 响应式数据<br/>data / ref / reactive"]
        VM2["🔄 计算属性<br/>computed"]
        VM3["👁️ 侦听器<br/>watch"]
        VM4["🔗 生命周期钩子<br/>mounted / updated"]
        VM5["🛠️ 方法<br/>methods"]
    end

    subgraph Model_Layer["🗄️ Model 层"]
        direction TB
        M1["🌐 API 请求<br/>axios / fetch"]
        M3["💡 业务逻辑<br/>数据处理 / 校验 / 数据模型"]
        M4["🏪 状态管理<br/>Vuex / Pinia"]
    end

    %% View → ViewModel
    V3 -- "① 用户操作触发" --> VM5
    V1 -- "② v-model 双向绑定" --> VM1

    %% ViewModel 内部依赖
    VM1 -- "③-a 依赖数据变化<br/>触发重新计算" --> VM2
    VM1 -- "③-b 依赖数据变化<br/>触发侦听器" --> VM3
    VM4 -- "③-c 生命周期触发<br/>初始化加载等" --> VM5

    %% ViewModel → View
    VM1 -- "④ 数据驱动视图更新" --> V1
    VM2 -- "⑤ 计算结果渲染到模板" --> V1

    %% ViewModel → Model
    VM5 -- "⑥ 调用 API" --> M1
    VM3 -- "⑦ 监听变化触发业务逻辑" --> M3

    %% Model → ViewModel
    M1 -- "⑧ 返回数据 → 写入响应式变量" --> VM1
    M4 -- "⑨ 状态变更通知 → 写入响应式变量" --> VM1

    %% 自动触发渲染
    M1 -. "⑧→④ 自动触发视图渲染 🔄" .-> V1
    M4 -. "⑨→④ 自动触发视图渲染 🔄" .-> V1

    style View_Layer fill:#E3F2FD,stroke:#1565C0,stroke-width:3px
    style ViewModel_Layer fill:#FFF3E0,stroke:#E65100,stroke-width:3px
    style Model_Layer fill:#E8F5E9,stroke:#2E7D32,stroke-width:3px

    style V1 fill:#BBDEFB,stroke:#1565C0,stroke-width:2px
    style V3 fill:#BBDEFB,stroke:#1565C0,stroke-width:2px
    style VM1 fill:#FFE0B2,stroke:#E65100,stroke-width:2px
    style VM2 fill:#FFE0B2,stroke:#E65100,stroke-width:2px
    style VM3 fill:#FFE0B2,stroke:#E65100,stroke-width:2px
    style VM4 fill:#FFE0B2,stroke:#E65100,stroke-width:2px
    style VM5 fill:#FFE0B2,stroke:#E65100,stroke-width:2px
    style M1 fill:#C8E6C9,stroke:#2E7D32,stroke-width:2px
    style M3 fill:#C8E6C9,stroke:#2E7D32,stroke-width:2px
    style M4 fill:#C8E6C9,stroke:#2E7D32,stroke-width:2px

    linkStyle 0 stroke:#D32F2F,stroke-width:2.5px
    linkStyle 1 stroke:#D32F2F,stroke-width:2.5px
    linkStyle 2 stroke:#FF6F00,stroke-width:2.5px
    linkStyle 3 stroke:#FF6F00,stroke-width:2.5px
    linkStyle 4 stroke:#FF6F00,stroke-width:2.5px
    linkStyle 5 stroke:#1565C0,stroke-width:2.5px
    linkStyle 6 stroke:#1565C0,stroke-width:2.5px
    linkStyle 7 stroke:#FF6F00,stroke-width:2.5px
    linkStyle 8 stroke:#FF6F00,stroke-width:2.5px
    linkStyle 9 stroke:#2E7D32,stroke-width:2.5px
    linkStyle 10 stroke:#2E7D32,stroke-width:2.5px
    linkStyle 11 stroke:#9C27B0,stroke-width:2px,stroke-dasharray:5
    linkStyle 12 stroke:#9C27B0,stroke-width:2px,stroke-dasharray:5

三层职责与 Vue 中的映射:

缩写 全称 职责 Vue 中的对应
M Model(模型) 应用数据与业务逻辑。不关心数据如何展示,只负责存储和处理 reactive()/ref() 声明的响应式状态、API 请求返回的数据、Pinia store
V View(视图) 用户看到的界面。不包含业务逻辑,只负责声明式地描述 UI 结构 <template> 中的 HTML 模板、最终渲染出的真实 DOM
VM ViewModel(视图模型) M 与 V 之间的桥梁。监听 Model 变化自动更新 View,捕获 View 上的用户交互反向更新 Model Vue 组件实例本身——编译器将模板转为渲染函数,响应式系统追踪依赖并触发更新

流程总结:

整个 MVVM 的运转可以概括为一个闭环:

  1. 用户交互驱动(V → VM): 用户在 View 层触发事件(click、input 等),通过 v-model 或事件绑定将操作传递给 ViewModel 层的方法或响应式变量。
  2. ViewModel 内部联动(VM 内部): 响应式数据变化后,computed 自动重新计算派生值,watch 触发副作用逻辑,生命周期钩子在适当时机执行。
  3. 业务处理(VM → M): ViewModel 调用 Model 层的 API 请求或业务逻辑函数,完成数据的增删改查。
  4. 数据回写(M → VM): Model 层返回结果写入响应式变量,或 Pinia/Vuex 状态变更通知 ViewModel。
  5. 视图自动更新(VM → V): 响应式系统检测到数据变化,自动触发虚拟 DOM diff,最小化更新真实 DOM,用户看到最新界面。

核心价值: 开发者只需关注数据(M)模板(V),中间的同步、diff、DOM 操作全部由 Vue 的 ViewModel 层自动完成。v-model:modelValue + @update:modelValue 的编译时语法糖,本质仍由 VM 层协调,Vue 3 整体是单向数据流 + 双向绑定语法糖的设计。


1.2 项目创建(Vite)

基于原生 ES Module,毫秒级冷启动,HMR 不随项目规模变慢。

# 推荐使用 create-vue(Vue 官方脚手架,底层基于 Vite)
npm create vue@latest

典型项目结构:

my-project/
├── public/                  # 静态资源(不经过构建)
├── src/
│   ├── assets/              # 需构建处理的资源(图片、样式)
│   ├── components/          # 通用组件
│   ├── composables/         # 组合式函数
│   ├── router/              # 路由配置
│   ├── stores/              # Pinia 状态管理
│   ├── views/               # 页面级组件
│   ├── App.vue
│   └── main.ts
├── index.html               # 入口 HTML(Vite 以此为入口)
├── vite.config.ts
├── tsconfig.json
└── package.json

1.3 模板语法

插值与绑定

<template>
  <!-- 文本插值 -->
  <span>{{ message }}</span>

  <!-- 属性绑定(v-bind 简写 :) -->
  <img :src="imgUrl" :alt="title" />

  <!-- 动态绑定多个属性 -->
  <div v-bind="attrs"></div>
  <!-- 等价于 <div :id="attrs.id" :class="attrs.class" /> -->

  <!-- 事件绑定(v-on 简写 @) -->
  <button @click="submit">提交</button>
  <input @keyup.enter="search" />         <!-- 按键修饰符 -->
  <form @submit.prevent="save" />          <!-- 阻止默认行为 -->
</template>

条件渲染

<template>
  <!-- v-if:条件为 false 时 DOM 不存在(适合不频繁切换) -->
  <div v-if="status === 'loading'">加载中</div>
  <div v-else-if="status === 'error'">出错了</div>
  <div v-else>{{ data }}</div>

  <!-- v-show:始终渲染 DOM,通过 display 切换(适合频繁切换) -->
  <div v-show="visible">我一直在 DOM 中</div>
</template>
指令 DOM 行为 初始开销 切换开销 适用场景
v-if 销毁/重建 低(不渲染) 条件很少变化
v-show display: none 高(始终渲染) 频繁切换显示

列表渲染

<template>
  <!-- 数组遍历 -->
  <li v-for="(item, index) in list" :key="item.id">
    {{ index }}. {{ item.name }}
  </li>

  <!-- 对象遍历 -->
  <div v-for="(value, key) in obj" :key="key">
    {{ key }}: {{ value }}
  </div>

  <!-- v-for + v-if 不能同级使用,需用 <template> 包裹 -->
  <template v-for="item in list" :key="item.id">
    <li v-if="item.active">{{ item.name }}</li>
  </template>
</template>

key 的作用: 帮助 Vue 的 diff 算法识别节点身份,复用和重排已有元素而非重新创建。务必使用唯一业务 ID,避免用 index(排序/删除时会导致错误复用)。


2. 组件开发

组件是 Vue 的核心抽象单元——将 UI 拆分为独立、可复用的模块,每个组件封装自己的模板、逻辑和样式,通过明确的接口(props/emits)进行通信。

2.1 组件基础

组件定义与注册

Vue 3 推荐使用单文件组件(SFC) + <script setup> 语法,编译器自动处理注册,无需手动声明。

<!-- MyButton.vue — 单文件组件 -->
<template>
  <button :class="type" @click="emit('click', $event)">
    <slot />
  </button>
</template>

<script setup lang="ts">
defineProps<{ type?: 'primary' | 'default' }>()
const emit = defineEmits<{ click: [e: MouseEvent] }>()
</script>

<style scoped>
.primary { background: #409eff; color: #fff; }
</style>

使用方式:<script setup> 中导入即可直接在模板使用,无需注册。

<template>
  <MyButton type="primary" @click="save">保存</MyButton>
</template>

<script setup lang="ts">
import MyButton from './MyButton.vue'
</script>

SFC 的价值: 一个 .vue 文件 = 模板 + 逻辑 + 样式,scoped 实现样式隔离,<script setup> 减少样板代码,编译器自动优化。


2.2 组件通信

Vue 组件间通信方式按场景选择,核心原则:props 向下,events 向上,跨层用 provide/inject

Props(父 → 子)

父组件通过属性向子组件传递数据,子组件只读不可修改。

<!-- Child.vue -->
<template>
  <h2>{{ title }} ({{ count }})</h2>
</template>

<script setup lang="ts">
const props = withDefaults(defineProps<{
  title: string
  count?: number
}>(), {
  count: 0   // 类型声明的 props 通过 withDefaults 设置默认值
})
</script>
<!-- Parent.vue -->
<Child title="订单" :count="orderCount" />

Emits(子 → 父)

子组件通过事件通知父组件,保持单向数据流。

<!-- Child.vue -->
<template>
  <button @click="remove(item.id)">删除</button>
</template>

<script setup lang="ts">
const emit = defineEmits<{
  update: [value: string]
  delete: [id: number]
}>()

function remove(id: number) {
  emit('delete', id)   // 触发事件,父组件通过 @delete 监听
}
</script>
<!-- Parent.vue -->
<Child @update="handleUpdate" @delete="handleDelete" />

v-model(双向绑定语法糖)

v-model 本质是 :modelValue + @update:modelValue 的简写,支持多个 v-model。

<!-- SearchInput.vue -->
<template>
  <input v-model="keyword" />
  <select v-model="status">
    <option value="all">全部</option>
    <option value="active">启用</option>
  </select>
</template>

<script setup lang="ts">
const keyword = defineModel<string>()           // 默认 v-model
const status = defineModel<string>('status')    // v-model:status
</script>
<!-- Parent.vue — 语法糖写法 -->
<SearchInput v-model="keyword" v-model:status="currentStatus" />

上面的 v-model:status 等价于展开写法:

<!-- Parent.vue — 展开写法(与上方完全等价) -->
<SearchInput
  v-model="keyword"
  :status="currentStatus"
  @update:status="currentStatus = $event"
/>

v-model:status 编译后就是 :status + @update:statusdefineModel('status') 内部帮你处理了 props 接收和 emit 触发。

Provide / Inject(跨层级)

祖先组件提供数据,任意后代组件注入,避免 props 逐层透传。

<!-- 祖先组件 -->
<script setup lang="ts">
import { provide, ref } from 'vue'

const theme = ref<'light' | 'dark'>('light')
provide('theme', theme)       // key-value 形式提供
</script>
<!-- 任意深度的后代组件 -->
<template>
  <div :class="theme">当前主题:{{ theme }}</div>
</template>

<script setup lang="ts">
import { inject } from 'vue'
import type { Ref } from 'vue'

const theme = inject<Ref<'light' | 'dark'>>('theme')  // 注入
</script>

使用 InjectionKey 实现类型安全(推荐):

字符串 key 容易拼错且无类型推导,推荐抽取 InjectionKey 常量:

// keys.ts — 统一管理 injection key
import type { InjectionKey, Ref } from 'vue'

export const ThemeKey: InjectionKey<Ref<'light' | 'dark'>> = Symbol('theme')
// 祖先组件中 provide
import { ThemeKey } from './keys'
provide(ThemeKey, theme)      // TS 自动校验 value 类型

// 后代组件中 inject
import { ThemeKey } from './keys'
const theme = inject(ThemeKey) // 自动推导为 Ref<'light' | 'dark'> | undefined

适用场景: 主题、国际化、全局配置等需要跨多层传递但不适合放 Pinia 的数据。

Ref(父访问子实例)

通过模板 ref 获取子组件实例,直接调用子组件暴露的方法或属性。

defineExpose 的作用:<script setup> 中,组件内部的变量和方法默认对外不可见(与 Options API 不同)。必须通过 defineExpose 显式声明哪些内容允许父组件通过 ref 访问。未暴露的内容,父组件拿到 ref 后也无法调用。

<!-- Child.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)
const secret = ref('隐藏数据')     // 未暴露,父组件无法访问
function reset() { count.value = 0 }

// 只有 count 和 reset 对外可见,secret 外部不可访问
defineExpose({ count, reset })
</script>
<!-- Parent.vue -->
<template>
  <Child ref="childRef" />
  <button @click="resetChild">重置子组件</button>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'

const childRef = ref<InstanceType<typeof Child>>()

function resetChild() {
  childRef.value?.reset()   // 调用子组件通过 defineExpose 暴露的 reset 方法
}
</script>

注意: 模板 ref 访问破坏了组件封装性,仅在表单校验、弹窗控制等必要场景使用。


2.3 插槽

插槽让父组件向子组件内部注入模板片段,实现布局和内容的解耦。

默认插槽

子组件用 <slot /> 占位,父组件传入内容替换。

<!-- Card.vue -->
<template>
  <div class="card">
    <slot />   <!-- 父组件传入的内容渲染在这里 -->
  </div>
</template>
<Card>
  <p>这段内容会替换 slot 占位</p>
</Card>

具名插槽

多个插槽通过 name 区分,父组件用 v-slot:name(简写 #name)指定。

<!-- Layout.vue -->
<template>
  <header><slot name="header" /></header>
  <main><slot /></main>
  <footer><slot name="footer" /></footer>
</template>
<Layout>
  <template #header>
    <h1>页面标题</h1>
  </template>

  <p>默认插槽内容(main 区域)</p>

  <template #footer>
    <span>© 2026</span>
  </template>
</Layout>

作用域插槽

子组件通过 slot 向父组件回传数据,父组件拿到数据后自定义渲染。

<!-- DataList.vue -->
<script setup lang="ts">
defineProps<{ items: { id: number; name: string }[] }>()
</script>

<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      <slot :item="item" :index="item.id" />  <!-- 回传数据 -->
    </li>
  </ul>
</template>
<!-- 父组件自定义每一行的渲染方式 -->
<DataList :items="list">
  <template #default="{ item, index }">
    <span>{{ index }}. {{ item.name }}</span>
    <button @click="remove(item.id)">删除</button>
  </template>
</DataList>

作用域插槽的价值: 子组件负责数据遍历和逻辑,父组件负责 UI 呈现,实现逻辑与视图的分离。常见于表格列自定义、列表项渲染等场景。


2.4 动态组件

<component :is>

根据变量动态切换渲染的组件,适用于 Tab 切换、多表单步骤等场景。

<template>
  <button v-for="tab in tabs" :key="tab.label" @click="current = tab.comp">
    {{ tab.label }}
  </button>
  <component :is="current" />
</template>

<script setup lang="ts">
import { shallowRef } from 'vue'
import TabA from './TabA.vue'
import TabB from './TabB.vue'
import TabC from './TabC.vue'

const tabs = [
  { label: '基本信息', comp: TabA },
  { label: '详细配置', comp: TabB },
  { label: '操作日志', comp: TabC },
]
const current = shallowRef(tabs[0].comp)  // shallowRef 避免深度代理组件对象
</script>

<keep-alive>

缓存被切走的组件实例,切回时保留状态(表单输入、滚动位置等),避免重新创建和销毁。

<keep-alive :include="['TabA', 'TabB']" :max="5">
  <component :is="current" />
</keep-alive>
属性 说明
include 只缓存匹配的组件(名称或正则)
exclude 排除不缓存的组件
max 最大缓存实例数,超出时销毁最久未使用的(LRU)

keep-alive 缓存的组件可使用两个专属生命周期:

<script setup lang="ts">
import { onActivated, onDeactivated } from 'vue'

onActivated(() => {
  // 组件从缓存中被激活(切回)时触发,可用于刷新数据
})

onDeactivated(() => {
  // 组件被缓存(切走)时触发,可用于清理定时器
})
</script>

典型场景: 后台管理的多 Tab 页面切换——用户在 Tab A 填了一半表单,切到 Tab B 再切回来,数据不丢失。

defineAsyncComponent(异步组件)

将组件的 JS 代码从主包中分离,用到时才加载,减少首屏体积。Vite 构建时会自动将其拆为独立 chunk。

import { defineAsyncComponent } from 'vue'

// 基本用法:传入返回 import() 的工厂函数
const HeavyChart = defineAsyncComponent(() => import('./HeavyChart.vue'))

// 完整配置:加载状态、超时、错误处理
const HeavyChart = defineAsyncComponent({
  loader: () => import('./HeavyChart.vue'),
  loadingComponent: LoadingSpinner,   // 加载中显示的组件
  errorComponent: ErrorBlock,         // 加载失败显示的组件
  delay: 200,                         // 延迟 200ms 后才显示 loading(避免闪烁)
  timeout: 10000,                     // 超过 10s 视为超时,显示 errorComponent
})
<!-- 在模板中像普通组件一样使用,Vue 自动处理懒加载 -->
<template>
  <HeavyChart v-if="showChart" :data="chartData" />
</template>

适用场景: 大型图表、富文本编辑器、PDF 预览等体积较大且非首屏必需的组件。与路由懒加载(() => import('./views/xxx.vue'))原理相同,区别在于异步组件是组件级别的按需加载。


2.5 Teleport

将组件模板的一部分渲染到DOM 树的其他位置(如 body),解决弹窗/浮层被父组件 overflow: hiddenz-index 影响的问题。

<template>
  <button @click="visible = true">打开弹窗</button>

  <!-- 内容渲染到 body 下,而非当前组件 DOM 内 -->
  <Teleport to="body">
    <div v-if="visible" class="modal-overlay">
      <div class="modal">
        <p>弹窗内容</p>
        <button @click="visible = false">关闭</button>
      </div>
    </div>
  </Teleport>
</template>

<script setup lang="ts">
import { ref } from 'vue'
const visible = ref(false)
</script>
属性 说明
to CSS 选择器或 DOM 元素,指定渲染目标(如 "body""#modal-root"
disabled true 时禁用传送,内容回到组件原位

逻辑上仍属于当前组件(props/emits/provide 照常工作),只是 DOM 位置变了。


2.6 自定义指令

封装对 DOM 的底层操作为可复用指令,命名 v-xxx

// directives/vFocus.ts
import type { Directive } from 'vue'

export const vFocus: Directive = {
  mounted(el: HTMLElement) {
    el.focus()   // 元素挂载后自动聚焦
  }
}
<template>
  <input v-focus />
</template>

<script setup lang="ts">
import { vFocus } from '@/directives/vFocus'
</script>

指令生命周期钩子:

钩子 触发时机
created 元素属性/事件绑定前
beforeMount 插入 DOM 前
mounted 插入 DOM 后 ✅ 最常用
beforeUpdate 组件更新前
updated 组件更新后
beforeUnmount 卸载前
unmounted 卸载后

带参数的实际示例(权限指令):

// directives/vPermission.ts
import type { Directive } from 'vue'

export const vPermission: Directive<HTMLElement, string> = {
  mounted(el, binding) {
    // binding.value 就是 v-permission="'admin'" 中的 'admin'
    const userRole = getUserRole()
    if (userRole !== binding.value) {
      el.parentNode?.removeChild(el)  // 无权限则移除元素
    }
  }
}
<button v-permission="'admin'">仅管理员可见</button>

3. Composition API

Composition API 是 Vue 3 的核心编程范式,以函数为基本组织单位,替代 Options API 的 data/methods/computed/watch 分散写法,使相关逻辑聚合在一起,便于复用和维护。

3.1 响应式 API

ref

包装任意类型为响应式数据。JS/TS 中通过 .value 读写,模板中自动解包。包装对象时内部调用 reactive 实现深层响应

import { ref } from 'vue'

const count = ref(0)                    // 基本类型
const user = ref<User | null>(null)     // 对象类型,支持泛型

count.value++                           // JS 中需要 .value

// 嵌套对象同样响应式(内部自动 reactive)
const config = ref({ theme: { color: 'blue' } })
config.value.theme.color = 'red'        // ✅ 视图更新
config.value = { theme: { color: 'green' } }  // ✅ 整体替换也响应式

// 数组同样深层响应式
const list = ref([{ id: 1, name: '张三' }, { id: 2, name: '李四' }])
list.value.push({ id: 3, name: '王五' })       // ✅ 新增元素,视图更新
list.value[0].name = '赵六'                     // ✅ 修改元素属性,视图更新
list.value = list.value.filter(i => i.id !== 2) // ✅ 整体替换,视图更新

自动解包规则 & 注意事项:

场景 需要 .value 说明
模板 {{ count }} 自动解包
JS/TS 代码 count.value++
嵌入 reactive 对象 reactive({ count }).count++
放入数组 / Map reactive([ref(1)])[0].value
解构 .value 丢失响应性,需 toRefs() 转换

reactive

将对象转为深层响应式代理,访问属性无需 .value不能用于基本类型,且不能整体替换(会丢失响应性)。

import { reactive } from 'vue'

const form = reactive({
  name: '',
  age: 0,
  address: { city: '', zip: '' }  // 嵌套对象也是响应式
})

form.name = '张三'             // 直接赋值,无需 .value
form.address.city = '深圳'     // 深层属性也是响应式

ref vs reactive 选择:

场景 推荐 原因
基本类型(string / number / boolean) ref reactive 不支持基本类型
可能被整体替换的对象 ref reactive 重新赋值会丢失响应性
表单等字段固定的复杂对象 reactive 无需 .value,代码更简洁
composable 函数返回值 ref 解构时不丢失响应性

computed

基于响应式依赖自动缓存的派生值,依赖不变则不重新计算。

import { ref, computed } from 'vue'

const price = ref(100)
const quantity = ref(3)

// 只读计算属性
const total = computed(() => price.value * quantity.value)

// 可写计算属性(少用)
const fullName = computed({
  get: () => `${firstName.value} ${lastName.value}`,
  set: (val: string) => {
    const [first, last] = val.split(' ')
    firstName.value = first
    lastName.value = last ?? ''
  }
})

与方法的区别: computed 有缓存,多次访问只在依赖变化时重新计算;方法每次调用都重新执行。

watch

监听特定响应式数据,变化时执行回调。适合需要旧值对比有条件执行的场景。

import { ref, watch } from 'vue'

const keyword = ref('')

// 监听单个 ref
watch(keyword, (newVal, oldVal) => {
  console.log(`搜索词从 "${oldVal}" 变为 "${newVal}"`)
})

// 监听多个源
watch([keyword, page], ([newKeyword, newPage], [oldKeyword, oldPage]) => {
  fetchList(newKeyword, newPage)
})

// 监听 reactive 对象的某个属性(需用 getter 函数)
const form = reactive({ name: '', age: 0 })
watch(
  () => form.name,
  (newName) => { validate(newName) }
)

// 常用选项
watch(keyword, handler, {
  immediate: true,  // 创建时立即执行一次
  deep: true,       // 深层监听(reactive 对象默认深层,ref 对象需手动开启)
  flush: 'post',    // 在 DOM 更新后执行回调(默认 'pre')
})

watchEffect

自动追踪回调中用到的所有响应式依赖,不需要指定监听源。适合"用了什么就监听什么"的场景。

import { ref, watchEffect } from 'vue'

const keyword = ref('')
const page = ref(1)

// 回调中访问了 keyword 和 page,两者变化都会重新执行
const stop = watchEffect(() => {
  fetchList(keyword.value, page.value)
})

stop()  // 手动停止监听(组件卸载时自动停止)

watch vs watchEffect 对比:

维度 watch watchEffect
监听源 需显式指定 自动追踪回调中的依赖
旧值访问 (newVal, oldVal) ❌ 无旧值
首次执行 默认不执行(immediate: true 开启) 默认立即执行
适用场景 需要旧值对比、条件触发 依赖多且不需要旧值

nextTick

Vue 的 DOM 更新是异步批量的,修改数据后 DOM 不会立即更新。nextTick 等待 DOM 更新完成后执行回调。

import { ref, nextTick } from 'vue'

const show = ref(false)

async function expand() {
  show.value = true
  // 此时 DOM 尚未更新,拿不到新元素
  await nextTick()
  // DOM 已更新,可安全操作
  document.querySelector('.detail')?.scrollIntoView()
}

响应式工具函数

函数 作用 典型场景
toRef(obj, key) 将 reactive 对象的单个属性转为 ref 传递单个属性给 composable
toRefs(obj) 将 reactive 对象的所有属性转为 ref 解构 reactive 不丢失响应性
toRaw(proxy) 返回代理的原始对象 传给第三方库(避免代理副作用)
shallowRef(val) 只有 .value 替换时触发更新,深层属性变化不触发 大型对象 / 组件引用
shallowReactive(obj) 只有顶层属性变化触发更新 扁平配置对象
markRaw(obj) 标记对象永不被代理 第三方类实例(echarts、地图等)
import { reactive, toRefs, toRaw, shallowRef, markRaw } from 'vue'

// toRefs:解构不丢失响应性
const state = reactive({ name: '张三', age: 20 })
const { name, age } = toRefs(state)   // name、age 都是 Ref
name.value = '李四'                    // state.name 同步变化

// shallowRef:大型对象只在整体替换时触发更新
const tableData = shallowRef<Row[]>([])
tableData.value[0].name = '新名字'     // ❌ 不触发更新
tableData.value = [...tableData.value] // ✅ 整体替换才触发

// markRaw:排除不需要响应式的对象
const chart = markRaw(echarts.init(el))

3.2 依赖注入(provide / inject)

已在 2.2 组件通信 — Provide / Inject 中详细介绍,包含基本用法和 InjectionKey 类型安全写法。

核心要点回顾:

  • provide(key, value) 在祖先组件提供数据
  • inject(key) 在任意后代组件注入
  • 推荐使用 InjectionKey<T> 常量管理 key,实现自动类型推导
  • 适用于主题、国际化等跨层级共享数据的场景

3.3 生命周期钩子

Composition API 中通过 onXxx 函数注册生命周期回调,对应组件从创建到销毁的各个阶段。

graph TD
    A["setup()"] --> B["onBeforeMount"]
    B --> C["onMounted<br/>DOM 已挂载,可访问 DOM / 发请求"]
    C --> D["onBeforeUpdate"]
    D --> E["onUpdated<br/>DOM 已更新"]
    E --> D
    C --> F["onBeforeUnmount"]
    F --> G["onUnmounted<br/>组件已销毁,清理副作用"]
import {
  onBeforeMount,
  onMounted,
  onBeforeUpdate,
  onUpdated,
  onBeforeUnmount,
  onUnmounted
} from 'vue'

// setup 本身等价于 beforeCreate + created,无需对应钩子

onMounted(() => {
  // DOM 已渲染,适合:获取 DOM 引用、发起初始请求、初始化第三方库
  initChart()
  fetchData()
})

onUpdated(() => {
  // 响应式数据变化导致 DOM 更新后触发
  // 注意:避免在此修改响应式数据,可能导致无限循环
})

onBeforeUnmount(() => {
  // 组件即将销毁,适合:清除定时器、取消订阅、销毁第三方库实例
  clearInterval(timer)
  chart?.dispose()
})

Options API 与 Composition API 钩子映射:

Options API Composition API 说明
beforeCreate setup() 本身 setup 在所有 Options API 钩子之前执行
created setup() 本身 响应式数据已就绪,但 DOM 未挂载
beforeMount onBeforeMount DOM 挂载前
mounted onMounted DOM 已挂载 ✅
beforeUpdate onBeforeUpdate 数据变化,DOM 更新前
updated onUpdated DOM 已更新
beforeUnmount onBeforeUnmount 组件销毁前
unmounted onUnmounted 组件已销毁

常用原则: 初始化请求放 onMounted(而非 setup),清理工作放 onBeforeUnmount。同一钩子可多次调用,按注册顺序执行。


3.4 组合式函数(Composables)

相关联的响应式状态 + 逻辑提取为独立函数,实现跨组件复用。命名约定以 use 开头。

// composables/useFetch.ts
import { ref, type Ref } from 'vue'

interface UseFetchReturn<T> {
  data: Ref<T | null>
  error: Ref<string>
  isFetching: Ref<boolean>
  execute: () => Promise<void>
}

export function useFetch<T>(url: string | Ref<string>): UseFetchReturn<T> {
  const data = ref<T | null>(null) as Ref<T | null>
  const error = ref('')
  const isFetching = ref(false)

  async function execute() {
    isFetching.value = true
    error.value = ''
    try {
      const resolvedUrl = typeof url === 'string' ? url : url.value
      const res = await fetch(resolvedUrl)
      data.value = await res.json()
    } catch (e: any) {
      error.value = e.message
    } finally {
      isFetching.value = false
    }
  }

  execute()   // 创建时自动执行一次

  return { data, error, isFetching, execute }
}
<!-- 在组件中使用 -->
<template>
  <div v-if="isFetching">加载中...</div>
  <div v-else-if="error">{{ error }}</div>
  <ul v-else>
    <li v-for="item in data" :key="item.id">{{ item.name }}</li>
  </ul>
</template>

<script setup lang="ts">
import { useFetch } from '@/composables/useFetch'

const { data, error, isFetching } = useFetch<Item[]>('/api/items')
</script>

Composable 设计原则:

原则 说明
单一职责 一个 composable 只解决一类问题(如请求、分页、表单校验)
返回 ref 返回值使用 ref 而非 reactive,调用方解构时不丢失响应性
命名 useXxx 约定以 use 开头,表明这是一个组合式函数
可接收 ref 参数 参数支持 `string Ref`,提高灵活性

与 Mixin 的对比: Options API 时代用 Mixin 复用逻辑,但存在命名冲突、来源不明、隐式依赖等问题。Composable 通过显式导入和返回值,完全解决了这些问题。


3.5 <script setup> 语法糖

<script setup> 是 Composition API 在 SFC 中的编译时语法糖,编译器自动处理导出、注册、类型推导,减少样板代码。

核心编译宏

编译宏无需导入,编译器自动识别:

作用 示例
defineProps 声明 props defineProps<{ title: string }>()
defineEmits 声明 emits defineEmits<{ change: [value: string] }>()
defineExpose 暴露实例属性/方法 defineExpose({ reset })
defineModel 声明 v-model 双向绑定 defineModel<string>('status')
withDefaults 为类型声明的 props 设置默认值 withDefaults(defineProps<P>(), { count: 0 })
defineOptions 声明组件选项(如 name / inheritAttrs) defineOptions({ name: 'MyComp' })

<script setup> vs 普通 <script>

<!-- ❌ 普通 script:需要手动 return、注册组件 -->
<script lang="ts">
import { ref, defineComponent } from 'vue'
import MyButton from './MyButton.vue'

export default defineComponent({
  components: { MyButton },
  setup() {
    const count = ref(0)
    function increment() { count.value++ }
    return { count, increment }  // 必须手动 return
  }
})
</script>

<!-- ✅ script setup:顶层变量/导入组件自动暴露给模板 -->
<script setup lang="ts">
import { ref } from 'vue'
import MyButton from './MyButton.vue'   // 自动注册,模板中直接用

const count = ref(0)
function increment() { count.value++ }
// 无需 return,顶层声明自动可在模板中使用
</script>

defineOptions 与属性透传(inheritAttrs)

默认情况下,父组件传给子组件的未声明为 props 的属性(如 classstyleiddata-*)会自动透传到子组件的根元素上。通过 defineOptions({ inheritAttrs: false }) 可关闭自动透传,改用 useAttrs() 手动控制。

<!-- BaseInput.vue -->
<template>
  <!-- 关闭自动透传后,attrs 不会自动加到根 div 上 -->
  <div class="input-wrapper">
    <!-- 手动绑定到指定元素 -->
    <input v-bind="attrs" />
  </div>
</template>

<script setup lang="ts">
import { useAttrs } from 'vue'

defineOptions({
  name: 'BaseInput',        // 组件名(keep-alive include 匹配用)
  inheritAttrs: false        // 关闭自动透传
})

const attrs = useAttrs()     // 获取所有透传属性
</script>
<!-- 父组件使用 -->
<template>
  <!-- class、placeholder、@focus 都会透传到 BaseInput 内部的 <input> 上 -->
  <BaseInput class="custom" placeholder="请输入" @focus="onFocus" />
</template>
场景 inheritAttrs 效果
单根元素组件(默认) true attrs 自动添加到根元素
需要将 attrs 绑定到非根元素 false + v-bind="attrs" 手动控制透传目标
多根元素组件 Vue 警告,必须手动 v-bind="$attrs" 指定

Speckit、OpenSpec、Superpowers 和 everything-claude-code AI辅助编程工具对比分析

1. 概述

随着AI编码能力(如 Claude Code、Cursor 等)的普及,软件开发领域正从“Vibe Coding”(随心灵感编码)向更工程化的方向演进。为了应对AI生成代码的不确定性、上下文丢失以及协作一致性等问题,社区涌现了多种规范驱动开发(Spec-Driven Development, SDD)框架和工作流方法论

选取了目前最具代表性的四个项目进行对比:

  • Speckit:GitHub官方出品的结构化规范驱动工具包

  • OpenSpec:专注于增量变更和棕地项目的轻量级框架

  • Superpowers:强调强制流程和TDD的代理技能框架

  • everything-claude-code:黑客松冠军开源的综合性 Claude Code 方法论

2. 核心属性速览

维度 Speckit OpenSpec Superpowers everything-claude-code
核心理念 规范即代码,通过严格的结构化流程实现工业级控制 增量即真相,通过Delta机制管理现有项目的演进 强制方法论,通过不可跳过的“技能”约束AI行为 CLI+Skills替代MCP,通过编排与并行化榨干模型性能
目标场景 绿地项目 (0→1) ,复杂且需要严格文档的团队协作 棕地项目 (1→n) ,在已有代码库上进行频繁修改和功能迭代 从0到1的需求探索,对代码质量、测试覆盖有极高要求的项目 重度Claude Code用户,追求极致Token效率和复杂任务并行处理的场景
工作流程 五阶段线性流程:Constitution → Specify → Plan → Tasks → Implement 四阶段循环流程:Draft Proposal → Review → Apply → Archive 三阶段严格流程:Brainstorm → Write Plan → Execute Plan (含TDD) 五阶段Agent编排:Research → Plan → Implement → Review → Verify
关键机制 9条不可变架构原则、7层LLM输出约束、ADR决策记录 Specs/与Changes/隔离、Delta增量存储、Fail-Fast冲突检测 强制技能调用、Red-Green-Refactor TDD、子代理审查 多Agent编排、并行化(Git Worktrees)、动态系统提示、记忆钩子

3. 详细维度对比

3.1 核心理念与哲学

  • Speckit:秉持“规范优先”的哲学。它假设需求在编码前可以被完全定义,通过类似于法律的“宪章”来约束AI的每一次产出。它将开发视为一个严谨的、逐层分解的工程过程,试图通过结构的确定性来对抗AI的随机性

  • OpenSpec:秉持“演进优先”的哲学。它承认需求是动态变化的,特别是在维护老项目时。其核心理念是将“当前稳定状态”(Specs)与“提议变更”(Changes)分离,每次只处理增量,最终将验证后的增量合并回主干,实现系统的平滑演进

  • Superpowers:秉持“流程即法律”的哲学。它不信任AI的自由发挥,通过一套不可跳过的“技能”(Skills)来强制AI遵循人类软件工程的最佳实践(如必须先写测试)。它像一名“教官”,强制AI按照TDD、Code Review等严谨流程行动

  • everything-claude-code:秉持“效率与编排”的哲学。它将AI视为一个可编排的智能体集群,通过精细化的工程手段(如用CLI替代MCP省Token、多实例并行)来最大化模型性能,降低成本,实现复杂的、多步骤的研发任务

3.2 目标用户与适用项目

  • Speckit:适合中大型团队,特别是需要严格合规、文档齐全的企业级项目。它明确了产品经理、技术负责人、开发者之间的交接点(如Spec、Plan),适合角色分工明确的团队

  • OpenSpec:适合全栈开发者或小型团队,尤其是在维护复杂老系统的团队。对于经常需要跨多个服务或模块进行小范围修改的场景,它的轻量级和高效增量特性极具吸引力

  • Superpowers:适合任何追求代码质量的开发者,尤其是从0到1启动项目时。它的头脑风暴模式对需求不明确的项目非常友好,强制TDD则确保了代码的健壮性

  • everything-claude-code:适合Claude Code的重度用户和技术极客。适合需要处理复杂、多步骤、多文件任务的场景,或是希望在API调用成本上精打细算的开发者

3.3 工作流与落地实践

  • Speckit:流程线性且严格

    • Constitution:定义开发原则和不可变规则

    • Specify:详细描述需求(What & Why)

    • Plan:基于技术栈制定架构方案

    • Tasks:分解为可执行的任务列表

    • Implement:逐一执行任务

      实践发现,若前期需求或设计有误,后期返工成本极高。在动态变化的企业环境中,这种线性流程常面临“理想很丰满,现实很骨感”的挑战

  • OpenSpec:流程循环且隔离

    • Proposal:在 changes/ 目录下创建变更提案、任务和Spec增量

    • Review:人与AI审查、对齐提案,可利用 openspec validate 进行冲突检测

    • Apply:AI严格根据 tasks.md 和增量Spec实施编码

    • Archive:将验证通过的变更合并(归档)到 specs/ 目录,更新“真相源”

      这种模式确保了主分支的Spec始终反映最新状态,且归档动作实现了知识的持续沉淀

  • Superpowers:流程强制且循环

    • Brainstorm:AI通过多轮问答帮助用户精炼需求,探索方案,输出设计文档

    • Write Plan:将设计拆解为极小的任务(2-5分钟),每个任务包含精确的文件路径、代码片段和测试命令

    • Execute Plan:通过子代理或批量模式执行计划,强制遵循 TDD(Red-Green-Refactor) ,并进行代码审查

      任何跳过步骤(如先写代码)的行为都会被AI视为违规

  • everything-claude-code:流程编排且并行

    • 编排:通过 Research AgentPlanner AgentTDD-guide AgentReviewer AgentResolver Agent 的有序协作完成任务。每个Agent输入输出清晰,中间用 /clear 清理上下文

    • 并行:利用 Git Worktrees 创建多个工作目录,同时运行多个Claude实例处理不同任务,互不干扰。采用 Two-Instance Kickoff(一个搭骨架,一个做调研)启动新项目

3.4 优缺点分析

工具/方法论 优点 缺点/挑战
Speckit 结构最严谨,文档最完善,适合大型项目治理;通过ADR记录决策,可追溯性强 。 太重、太理想化。对动态需求适应性差,流程僵化;生成文档冗长(相较OpenSpec多出数倍),上下文窗口易爆,返工成本高 。
OpenSpec 轻量、Token效率高。增量模式对老项目友好;archive机制能反向构建知识库;学习曲线平缓 。 对命名敏感,Delta机制依赖稳定的命名进行匹配;冲突解决依赖人工介入,对认知负担有一定挑战 ;不适合需要顶层宏观设计的0→1项目 。
Superpowers 质量保障最强。通过强制TDD和Code Review,产出代码可靠性高;头脑风暴功能极佳,能深度挖掘模糊需求 。 流程强制带来的“笨重感” 。即使是修个小Bug,也可能触发全套流程(TDD),对于追求快速验证的场景可能显得繁琐 。
everything-claude-code 极致的技术效率。Token优化策略显著降本;并行化和Agent编排极大提升复杂任务吞吐量;开源且模块化,扩展性强 。 上手门槛较高。需要理解其Agent编排、Hooks、Worktrees等整套哲学,对新手不够友好;部分技巧(如记忆钩子)需要手动配置 。

4. 选型建议

根据不同的团队类型和项目阶段,可以遵循以下建议进行选型

  • 如果你是维护复杂老系统的“单人开发者”或“小型团队”

    • 首选 OpenSpec。它的增量哲学能让你在不扰乱现有架构的前提下,安全地嵌入新功能。归档机制能帮你逐步梳理出混乱系统的“隐形文档”
  • 如果你正在启动一个全新的、复杂度较高的项目,且有明确的架构要求

    • 如果你是严谨派,追求工业级的代码质量:可以尝试 Speckit,但要做好前期投入大量时间编写规范的准备

    • 如果你是敏捷派,需求尚在演变中:强烈推荐 Superpowers。先利用其 Brainstorm 功能理清思路,再利用强制TDD构建稳固的核心,体验会非常流畅

  • 如果你是重度AI用户(特别是 Claude Code),追求极致的开发效率和成本控制

    • everything-claude-code 是你的不二之选。学习并采用它的CLI+Skills思想、Agent编排和并行化策略,你将能驾驭AI完成以往需要一个小团队才能完成的复杂任务
  • 如果你在大型团队中协作,需要明确的产品与开发交接流程

    • Speckit 的结构化文档(Constitution, Spec, Plan)可以作为团队协作的契约,明确各方职责,减少沟通误差。但需确保项目需求相对稳定,以避免因变更导致的巨大返工成本

5. 总结

AI编程正从“无序的 vibe coding”走向“有序的工程化”。这四种工具代表了不同的工程化路径

  • Speckit 走的是“计划经济的道路”,通过周密的计划来控制生产

  • OpenSpec 走的是“改革的道路”,在保持系统稳定的前提下,通过小步快跑实现演进

  • Superpowers 走的是“素质教育的道路”,通过严格的训练(流程)让AI养成良好的编码习惯

  • everything-claude-code 走的是“科技强军的道路”,通过先进的装备(编排、并行)和战术配合来发挥AI的最大战斗力

最终的选择没有绝对的对错,关键在于你的项目痛点、团队文化以及对AI协作的期望。希望这份报告能帮助你在这个快速发展的领域中找到最适合自己的方向

Vue3 工程构建

Vue3 工程构建

概述

Vue项目在搭建初期应当设定好目标、规范以及结构,以便后期扩展,避免结构混乱,代码难读、难以修改。

文档以vue3和ant-design-vue组件库为例,从零搭建项目,项目依赖如下:

生产依赖

{
    "dependencies": {
        "@ant-design/icons-vue": "7.0.1",
        "ant-design-vue": "4.2.2",
        "autoprefixer": "10.4.20",
        "axios": "1.7.7",
        "less": "4.2.0",
        "mockjs": "1.1.0",
        "pinia": "2.3.1",
        "postcss": "8.5.3",
        "tailwindcss": "3.4.17",
        "vue": "3.5.27",
        "vue-router": "4.4.5"
    },
}

开发依赖

{
    "devDependencies": {
        "@commitlint/config-conventional": "19.7.1",
        "@eslint/js": "^9.39.2",
        "@types/node": "22.12.0",
        "@typescript-eslint/eslint-plugin": "8.26.1",
        "@typescript-eslint/parser": "8.26.1",
        "@vitejs/plugin-vue": "5.0.4",
        "@vue/test-utils": "2.4.6",
        "@vue/tsconfig": "^0.8.1",
        "commitlint": "19.7.1",
        "cssnano": "^7.1.2",
        "eslint": "9.17.0",
        "eslint-plugin-vue": "9.28.0",
        "globals": "^17.3.0",
        "happy-dom": "20.5.0",
        "husky": "9.1.7",
        "lint-staged": "15.2.10",
        "prettier": "3.5.3",
        "stylelint": "16.12.0",
        "stylelint-order": "^7.0.1",
        "typescript": "5.9.3",
        "unplugin-auto-import": "^21.0.0",
        "unplugin-vue-components": "^31.0.0",
        "vite": "5.4.21",
        "vitest": "2.1.8",
        "vue-eslint-parser": "^10.2.0",
        "vue-tsc": "2.2.8"
    }
}

pnpm安装依赖

首先项目建议使用pnpm进行包管理和安装,

  1. pnpm 的 node_modules 布局使用符号链接来创建依赖项的嵌套结构。node_modules 中每个包的每个文件都是来自内容可寻址存储的硬链接。(避免重复安装
  2. pnpm 是默认支持 monorepo 多项目管理(多项目管理
  3. pnpm 使用链接仅将项目的直接依赖项添加到模块目录的根目录中(幽灵依赖
npm i -g pnpm

项目生产依赖

在项目生产环境中的依赖,主要考虑项目性质以及UI设计:(由于vue3只能在现代浏览器下运行。所以应该从兼容现代浏览器的版本开始,不需要兼容ie版本)

  1. 项目使用vue3应该配套使用状态管理库pinia以及路由管理vue-router
  2. 项目组件库为ant-design-vue,需要安装icon图标**@ant-design/icons-vue**,以及统一使用less作为css预处理器
  3. css工程化统一使用tailwindcsspostcssautoprefixer自动补齐css前缀。
  4. 使用axios请求接口,mockjs可以模拟接口数据。方便前期没有接口条件下开发。

项目开发环境vite以及vite相关配置

项目直接使用vite构建vue3+typescript项目

// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import AutoImport from 'unplugin-auto-import/vite';
import Components from 'unplugin-vue-components/vite';
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers';
import { resolve, extname } from 'path';
// https://vitejs.dev/config/
export default defineConfig({
    plugins: [
        vue(),
        AutoImport({
            // 自动导入常用 API(无需手写 import)
            imports: ['vue', 'vue-router', 'pinia'],
            resolvers: [
                AntDesignVueResolver({
                    importStyle: 'less',
                }),
            ],
            dts: 'src/auto-imports.d.ts', // 生成 `auto-imports.d.ts` 全局 API 类型声明,支持 IDE 代码提示
        }),
        Components({
            resolvers: [
                AntDesignVueResolver({
                    importStyle: 'less', // 这里设置为 'less',以便在使用组件时自动引入对应的 Less 样式文件
                }),
            ],
            dts: 'src/components.d.ts', // 生成 `components.d.ts` 全局组件类型声明,支持 IDE 代码提示
        }),
    ],
    resolve: {
        alias: {
            '@': resolve(__dirname, 'src'), // 设置 '@' 代表 'src' 目录,方便在项目中使用绝对路径导入模块
        },
    },
    css: {
        preprocessorOptions: {
            // 配置 Less 预处理器选项
            less: {
                javascriptEnabled: true, // 允许在 Less 文件中使用 JavaScript 表达式,这对于 Ant Design Vue 的样式定制非常重要
            },
        },
    },
    // 开发服务器配置
    server: {
        port: 3000,
        open: true,
        cors: true,
    },
    build: {
        target: 'es2020', // 设置构建目标为 ES2020,利用现代浏览器的特性提升性能
        outDir: 'dist',
        sourcemap: false,
        rollupOptions: {
            output: {
                // 按类型分类输出文件
                entryFileNames: 'assets/js/[name]-[hash].js',
                chunkFileNames: 'assets/js/[name]-[hash].js',
                assetFileNames: function (assetInfo) {
                    var _a;
                    var ext = extname((_a = assetInfo.name) !== null && _a !== void 0 ? _a : '');
                    if (ext === '.css') {
                        return 'assets/css/[name]-[hash][extname]';
                    }
                    return 'assets/[name]-[hash][extname]';
                },
                // 将核心依赖单独拆分成独立 chunk,方便 CDN 长效缓存
                manualChunks: {
                    vue: ['vue', 'vue-router', 'pinia'],
                    antd: ['ant-design-vue', '@ant-design/icons-vue'],
                    vendor: ['axios'],
                },
            },
        },
    },
});

代码规范eslint

ESLint 用于统一代码风格和查找潜在问题。本项目使用 JS/TS/Vue 分块配置,并根据不同文件类型启用对应规则。

//eslint.config.js
import js from '@eslint/js';
import globals from 'globals';
import vuePlugin from 'eslint-plugin-vue';
import vueParser from 'vue-eslint-parser';
import tseslint from '@typescript-eslint/eslint-plugin';
import tsparser from '@typescript-eslint/parser';

const isProd = process.env.NODE_ENV === 'production';

export default [
    // ==================== 基础配置 ====================
    {
        // 全局忽略的文件和目录
        ignores: [
            '**/node_modules/**',
            '**/dist/**',
            '**/build/**',
            '**/coverage/**',
            '**/public/**',
            '**/*.min.js',
            '**/*.d.ts',
            '**/package-lock.json',
            '**/pnpm-lock.yaml',
            '**/yarn.lock',
            '**/vite.config.d.ts',
            '**/vitest.config.d.ts',
        ],
    },

    // ==================== JavaScript 通用配置 ====================
    {
        files: ['**/*.{js,mjs,cjs}'],
        languageOptions: {
            ecmaVersion: 'latest',
            sourceType: 'module',
            globals: {
                ...globals.browser,
                ...globals.node,
                ...globals.es2020,
            },
        },
        rules: {
            ...js.configs.recommended.rules,

            // 自定义规则
            'no-console': isProd ? ['warn', { allow: ['warn', 'error'] }] : 'off',
            'no-debugger': isProd ? 'warn' : 'off',
            'no-alert': 'warn',
            'no-unused-vars': 'off', // 由 TypeScript 处理
            'prefer-const': 'error',
            eqeqeq: ['error', 'always'],
            curly: ['error', 'all'],
        },
    },

    // ==================== TypeScript 配置 ====================
    {
        files: ['**/*.{ts,tsx}'],
        languageOptions: {
            parser: tsparser,
            parserOptions: {
                ecmaVersion: 'latest',
                sourceType: 'module',
                // 使用应用和 Node 两个 tsconfig,提升类型检查覆盖面
                project: ['./tsconfig.app.json', './tsconfig.node.json'],
            },
            globals: {
                ...globals.browser,
                ...globals.node,
                ...globals.es2020,
            },
        },
        plugins: {
            '@typescript-eslint': tseslint,
        },
        rules: {
            ...tseslint.configs.recommended.rules,

            // TypeScript 特定规则
            '@typescript-eslint/no-explicit-any': 'warn',
            '@typescript-eslint/no-unused-vars': [
                'error',
                {
                    argsIgnorePattern: '^_',
                    varsIgnorePattern: '^_',
                    caughtErrorsIgnorePattern: '^_',
                },
            ],
            '@typescript-eslint/ban-ts-comment': 'warn',
            '@typescript-eslint/no-empty-function': 'warn',
            '@typescript-eslint/no-non-null-assertion': 'warn',
            '@typescript-eslint/explicit-function-return-type': 'off',
            '@typescript-eslint/explicit-module-boundary-types': 'off',
            '@typescript-eslint/no-inferrable-types': 'warn',
            '@typescript-eslint/consistent-type-imports': [
                'warn',
                {
                    prefer: 'type-imports',
                    disallowTypeAnnotations: false,
                },
            ],
        },
    },

    // ==================== Vue 3 文件配置 ====================
    {
        files: ['**/*.vue'],
        plugins: {
            vue: vuePlugin,
        },
        languageOptions: {
            // 使用官方 Vue 解析器对象,支持 <template> + <script setup>
            parser: vueParser,
            parserOptions: {
                parser: tsparser,
                ecmaVersion: 'latest',
                sourceType: 'module',
                extraFileExtensions: ['.vue'],
                project: ['./tsconfig.app.json', './tsconfig.node.json'],
            },
            globals: {
                ...globals.browser,
            },
        },
        rules: {
            // 继承 Vue 3 推荐规则
            ...vuePlugin.configs['vue3-recommended'].rules,

            // ========== Vue 3 自定义规则 ==========
            // 1. 组件命名规则(针对 Vue 3 单文件组件)
            'vue/multi-word-component-names': [
                'error',
                {
                    ignores: [
                        'index', // index.vue
                        'App', // App.vue
                        '404', // 404.vue
                        '[id]', // 动态路由组件
                        '[...all]', // 动态路由组件
                        'Layout', // Layout.vue
                        'Default', // Default.vue
                        'Main', // Main.vue
                    ],
                },
            ],

            // 2. 组件属性换行规则(针对 Ant Design Vue 属性多的特点)
            'vue/max-attributes-per-line': [
                'error',
                {
                    singleline: 5, // Ant Design 组件通常属性较多,放宽到5个
                    multiline: {
                        max: 1,
                    },
                },
            ],

            // 3. Ant Design Vue 组件名特殊处理(关键配置)
            'vue/component-name-in-template-casing': [
                'error',
                'PascalCase',
                {
                    registeredComponentsOnly: false,
                    ignores: [
                        // Ant Design Vue 组件前缀 (a-)
                        '/^a-/', // a-button, a-input, a-modal
                        '/^A[A-Z]/', // AButton, AInput
                        // Vue 内置组件
                        'router-view',
                        'router-link',
                        'transition',
                        'transition-group',
                        'keep-alive',
                        'component',
                        'slot',
                        'template',
                        // 常见第三方组件
                        'icon',
                        'icons',
                    ],
                },
            ],

            // 4. 其他 Vue 规则调整
            'vue/require-default-prop': 'off', // 不要求必须默认值
            'vue/no-v-html': 'warn', // 警告使用 v-html
            'vue/prop-name-casing': ['error', 'camelCase'], // props 使用驼峰
            'vue/attribute-hyphenation': ['error', 'always'], // 属性使用连字符

            // 5. 模板内容换行
            'vue/html-closing-bracket-newline': [
                'error',
                {
                    singleline: 'never',
                    multiline: 'always',
                },
            ],

            // 7. 顺序规则(可选,使代码更整洁)
            'vue/attributes-order': [
                'error',
                {
                    order: [
                        'DEFINITION', // is, v-is
                        'LIST_RENDERING', // v-for
                        'CONDITIONALS', // v-if, v-else-if, v-else, v-show, v-cloak
                        'RENDER_MODIFIERS', // v-once, v-pre
                        'GLOBAL', // id
                        'UNIQUE', // ref, key, v-slot, v-model
                        'SLOT', // v-slot
                        'TWO_WAY_BINDING', // v-model
                        'OTHER_DIRECTIVES', // v-custom-directive
                        'OTHER_ATTR', // 其他属性
                        'EVENTS', // v-on
                        'CONTENT', // v-text, v-html
                    ],
                },
            ],
        },
    },

    // ==================== 测试文件特殊配置 ====================
    {
        files: ['**/__tests__/**/*.{js,ts,vue}', '**/*.test.{js,ts,vue}', '**/*.spec.{js,ts,vue}'],
        plugins: {
            '@typescript-eslint': tseslint,
        },
        languageOptions: {
            parser: tsparser,
            parserOptions: {
                ecmaVersion: 'latest',
                sourceType: 'module',
            },
            globals: {
                // 使用 Vitest 全局变量
                ...globals.vitest,
            },
        },
        rules: {
            'no-console': 'off',
            'no-debugger': 'off',
            '@typescript-eslint/no-explicit-any': 'off',
            '@typescript-eslint/no-non-null-assertion': 'off',
        },
    },

    // ==================== 配置文件特殊处理 ====================
    {
        files: ['**/vite.config.{js,ts}', '**/vitest.config.{js,ts}', '**/eslint.config.{js,mjs}', '**/*.config.{js,ts}'],
        plugins: {
            '@typescript-eslint': tseslint,
        },
        languageOptions: {
            parser: tsparser,
            parserOptions: {
                ecmaVersion: 'latest',
                sourceType: 'module',
            },
            globals: {
                ...globals.node,
            },
        },
        rules: {
            'no-console': 'off',
            '@typescript-eslint/no-unused-vars': 'warn',
        },
    },
];

全局忽略配置

  • ignores:全局忽略的目录和文件模式,如:
    • **/node_modules/****/dist/****/build/**:依赖与构建输出目录。
    • **/coverage/**:测试覆盖率报告。
    • **/public/**:静态资源目录。
    • **/*.min.js**/*.d.ts:压缩 JS 与类型声明文件。
    • 各类锁文件和 *.config.d.ts 类型声明文件等。

JavaScript 通用配置

  • files:**/*.{js,mjs,cjs}
    • 对所有 JS 文件启用该配置。
  • languageOptions:
    • ecmaVersion:latest,使用最新 ECMAScript 语法。
    • sourceType:module,按 ES Module 解析。
    • globals:合并 browsernodees2020 全局变量,避免误报。
  • rules:
    • ...js.configs.recommended.rules:继承官方 ESLint 推荐规则。
    • no-console:生产环境下仅允许 console.warn / console.error,开发环境关闭。
    • no-debugger:生产环境警告,开发环境关闭。
    • no-alert:使用 alert 时给出警告。
    • no-unused-vars:关闭,由 TypeScript 规则接管。
    • prefer-const:推荐使用 const。
    • eqeqeq:强制使用 === / !==
    • curly:要求所有控制语句使用大括号。

TypeScript 配置

  • files:**/*.{ts,tsx}
  • languageOptions:
    • parser:@typescript-eslint/parser,支持 TS 语法。
    • parserOptions.project:./tsconfig.app.json, ./tsconfig.node.json,启用基于项目的类型信息检查。
    • globals:同样合并 browser/node/es2020 全局。
  • plugins:
    • @typescript-eslint:启用 TS 专用规则。
  • rules(节选):
    • 基于 @typescript-eslint 官方 recommended 规则。
    • @typescript-eslint/no-explicit-any:对 any 给出警告。
    • @typescript-eslint/no-unused-vars:检查未使用变量,可通过 _ 前缀忽略。
    • @typescript-eslint/ban-ts-comment:限制 // @ts-ignore 等用法。
    • @typescript-eslint/no-non-null-assertion:对 ! 非空断言警告。
    • @typescript-eslint/explicit-function-return-type:关闭强制显式返回类型。
    • @typescript-eslint/consistent-type-imports:推荐使用 type 导入形式。

Vue 3 文件配置

  • files:**/*.vue
  • plugins:
    • vue:Vue 官方 ESLint 插件。
  • languageOptions:
    • parser:vue-eslint-parser,支持 <template> + <script setup>
    • parserOptions.parser:内部再使用 TS 解析器,支持 TypeScript。
    • parserOptions.project:同样引用 tsconfig.app.jsontsconfig.node.json
  • rules(节选):
    • ...vuePlugin.configs['vue3-recommended'].rules:继承 Vue3 推荐规则集。
    • vue/multi-word-component-names:强制组件名多词,忽略特定名称(如 App、Layout、index 等)。
    • vue/max-attributes-per-line:单行最多 5 个属性,多行时每行 1 个,方便 Ant Design Vue 组件阅读。
    • vue/component-name-in-template-casing:模板中组件名强制 PascalCase,但对 a-button 等 Ant Design 组件和部分内置组件放宽。
    • vue/require-default-prop:关闭 props 强制默认值。
    • vue/no-v-html:对 v-html 给出警告。
    • vue/prop-name-casing:props 必须使用 camelCase。
    • vue/attribute-hyphenation:模板属性使用连字符形式。
    • vue/html-closing-bracket-newline:多行标签关闭时必须换行。
    • vue/attributes-order:规范属性书写顺序(如定义、条件、事件等)。

测试文件特殊配置

  • files:
    • **/__tests__/**/*.{js,ts,vue}
    • **/*.test.{js,ts,vue}
    • **/*.spec.{js,ts,vue}
  • languageOptions.globals:
    • 使用 globals.vitest,注入 Vitest 的全局(如 describeitexpect 等)。
  • rules:
    • no-console / no-debugger:在测试中关闭限制。
    • 放宽 TypeScript 关于 any 和非空断言的限制,方便编写测试用例。

配置文件自身的特殊处理

  • files:vite.config.{js,ts}vitest.config.{js,ts}eslint.config.{js,mjs} 以及 *.config.{js,ts}
  • languageOptions:仅使用 Node 环境的全局变量。
  • rules:
    • no-console:关闭,允许在配置文件中打印调试信息。
    • @typescript-eslint/no-unused-vars:降级为 warn,避免轻微未使用变量导致出错。

.prettierrc代码自动格式化

本项目使用 Prettier 统一代码格式,配置文件为 .prettierrc,并通过 npm script 与 lint-staged 集成,在保存/提交时代码会被自动格式化。

//.prettierrc
{
    "printWidth": 150,
    "tabWidth": 4,
    "useTabs": false,
    "semi": true,
    "singleQuote": true,
    "trailingComma": "es5",
    "bracketSpacing": true,
    "arrowParens": "avoid",
    "vueIndentScriptAndStyle": true,
    "htmlWhitespaceSensitivity": "css",
    "endOfLine": "lf"
}

通过vscode插件Prettier - Code formatter对代码进行自动格式化。能够更专注于代码逻辑书写。

.prettierrc 关键选项

  • printWidth:150
    • 每行最大字符数,超过会自动换行。
  • tabWidth:4
    • 一个缩进级别使用 4 个空格。
  • useTabs:false
    • 使用空格而不是制表符进行缩进。
  • semi:true
    • 语句末尾总是添加分号。
  • singleQuote:true
    • 使用单引号代替双引号。
  • trailingComma:"es5"
    • 在 ES5 允许的地方(对象、数组等)尽量保留尾随逗号。
  • bracketSpacing:true
    • 对象字面量的大括号两侧保留空格,例如 { foo: bar }
  • arrowParens:"avoid"
    • 能省略箭头函数参数括号时就省略,例如 x => x + 1
  • vueIndentScriptAndStyle:true
    • 在 .vue 文件中对 <script><style> 内容进行缩进。
  • htmlWhitespaceSensitivity:"css"
    • 按 CSS 的规则处理 HTML 空白字符,避免过度压缩影响布局。
  • endOfLine:"lf"
    • 统一使用 LF 换行符,有利于跨平台一致性。

其他常用配置(可选)

  • singleAttributePerLine
    • 默认:false
    • 作用:在 HTML / Vue / JSX 标签中,每个属性是否独占一行。属性较多的组件,设为 true 可读性更好,但文件会更长。
  • jsxSingleQuote
    • 默认:false
    • 作用:控制 JSX/TSX 内是否也使用单引号。若项目希望“所有地方都统一用单引号”,可以设为 true
  • quoteProps
    • 默认:"as-needed"
    • 常用值:"as-needed"(默认,仅在需要时加引号)、"consistent"(同一对象内保持一致)、"preserve"(保留原样)。
    • 作用:控制对象属性名(key)是否加引号,可根据团队对 JSON/对象风格的偏好统一约定。
  • bracketSameLine
    • 默认:false
    • 作用:控制多行 JSX/HTML 标签的闭合 > 是否与最后一行内容在同一行。不同团队习惯不同,可按团队偏好统一。
  • proseWrap
    • 默认:"preserve"
    • 常用值:"always""never""preserve"
    • 作用:控制 Markdown 文本是否在 printWidth 处自动换行。文档较多的项目,若希望 diff 更细致、行宽统一,可考虑设为 "always"
  • embeddedLanguageFormatting
    • 默认:"auto"
    • 常用值:"auto""off"
    • 作用:是否格式化字符串模板或文件中嵌入的代码块(如 Markdown 里的代码块、内联脚本等)。若不希望被自动改动,可设为 "off"
  • requirePragma
    • 默认:false
    • 作用:只有在文件头部包含特定注释(例如 @format)时才会被 Prettier 格式化。通常用于大型旧项目的“渐进式接入”。
  • insertPragma
    • 默认:false
    • 作用:在被 Prettier 格式化过的文件头部自动插入 @format 注释,常配合 requirePragma 使用。
  • rangeStart / rangeEnd
    • 默认:0 / Infinity
    • 作用:仅格式化文件的某一段范围,一般通过 CLI 或编辑器集成设置,适用于“只格式化选中区域”的场景。
  • overrides
    • 类型:数组
    • 作用:按文件匹配规则(files / excludeFiles)为不同类型文件指定不同的 Prettier 配置,例如:
      • *.md 使用不同的 printWidthproseWrap
      • *.json 关闭某些影响可读性的规则等。
  • plugins
    • 类型:数组
    • 作用:引入第三方 Prettier 插件,例如:
      • 排序 import、属性或 Tailwind 类名;
      • 支持额外的语法/语言。
    • 仅在确有需求时再引入,避免增加不必要的依赖和格式化开销。

与脚本命令的关系

  • format 脚本:prettier --write .
    • 手动运行时,会对整个项目文件执行一次格式化。

与 ESLint / Stylelint / lint-staged 的协同

  • .lintstagedrc 中:
    • *.{js,ts,vue} 文件:先用 eslint --cache --fix --max-warnings=0 再用 prettier --write,先修复语法/风格问题,再统一格式。
    • *.{css,less,scss}:先用 stylelint --cache --fix 检查样式规范,再用 Prettier 格式化。
    • *.{json,md,html,yml,yaml}:直接使用 prettier --write 进行格式化。
  • 这样可以保证:
    • ESLint/Stylelint 负责“代码/样式是否合理、有没有问题”;
    • Prettier 负责“长什么样、缩进和空格如何对齐”。

typescript 配置

本项目使用多层 tsconfig 管理不同环境和用途的 TypeScript 配置:

  • tsconfig.json:顶层工程引用文件。
  • tsconfig.app.json:应用源码相关配置。
  • tsconfig.node.json:Node 环境下的 TS 配置(主要用于 vite.config.ts、vitest.config.ts 等)。

tsconfig.json

{
    "files": [],
    "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }],
}
  • files:空数组
    • 顶层不直接编译任何文件,仅作为引用入口。
  • references:
    • 引用 tsconfig.app.jsontsconfig.node.json,组成 TS 的多工程(project references)结构,便于增量构建和工具支持。

tsconfig.app.json

{
    "extends": "@vue/tsconfig/tsconfig.dom.json",
    "compilerOptions": {
        "target": "ES2020",
        "useDefineForClassFields": true,
        "lib": ["ES2020", "DOM", "DOM.Iterable"],
        "module": "ESNext",
        "skipLibCheck": true,
        "moduleResolution": "bundler",
        "allowImportingTsExtensions": true,
        "resolveJsonModule": true,
        "isolatedModules": true,
        "noEmit": true,
        "jsx": "preserve",
        "strict": true,
        "noUnusedLocals": true,
        "noUnusedParameters": true,
        "noFallthroughCasesInSwitch": true,
        "baseUrl": ".",
        "paths": {
            "@/*": ["src/*"]
        }
    },
    "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
    "references": [
        {
            "path": "./tsconfig.node.json"
        }
    ]
}
extends
  • "@vue/tsconfig/tsconfig.dom.json"
    • 基于 Vue 官方推荐的 DOM 环境配置,自动包含适合 Vue 3 Web 应用的编译选项和 lib 设置。
compilerOptions(节选)
  • target:"ES2020"
    • 输出目标为 ES2020,支持较新的 JS 特性。
  • useDefineForClassFields:true
    • 使用符合 TC39 标准的类字段语义。
  • lib:["ES2020", "DOM", "DOM.Iterable"]
    • 包含 ES2020 和 DOM 相关的类型声明。
  • module:"ESNext"
    • 使用 ESNext 模块系统,交给打包工具处理。
  • skipLibCheck:true
    • 跳过库声明文件的类型检查,提高编译速度。
  • moduleResolution:"bundler"
    • 使用适合打包工具(如 Vite)的模块解析策略。
  • allowImportingTsExtensions:true
    • 允许显式导入 .ts 扩展名文件。
  • resolveJsonModule:true
    • 允许导入 JSON 文件,并生成对应的类型。
  • isolatedModules:true
    • 强制每个文件都可单独编译,有利于配合 Babel/Vite 使用。
  • noEmit:true
    • 不输出编译结果文件,仅做类型检查。
  • jsx:"preserve"
    • 保留 JSX,交给后续工具处理(如果使用 JSX/TSX)。
  • strict:true
    • 开启严格模式,包含多项严格类型检查选项。
  • noUnusedLocals / noUnusedParameters:true
    • 禁止未使用的本地变量和参数。
  • noFallthroughCasesInSwitch:true
    • 阻止 switch 语句的 case 贯穿错误。
  • baseUrl:"."
    • 以项目根目录为基础路径。
  • paths:
    • "@/*": ["src/*"]
    • 对应 Vite 中的 @ 别名,使 TS 能理解 @/xxx 导入路径。
include
  • "src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"
    • 指明应用代码中需要被 TS 类型系统分析的文件范围。
references
  • 引用 ./tsconfig.node.json
    • 让应用配置依赖 Node 配置,便于统一工程结构和增量编译。

tsconfig.node.json

{
    "compilerOptions": {
        "composite": true,
        "skipLibCheck": true,
        "module": "ESNext",
        "moduleResolution": "bundler",
        "allowSyntheticDefaultImports": true,
        "strict": true
    },
    "include": ["vite.config.ts", "vitest.config.ts"]
}
compilerOptions
  • composite:true
    • 表明该配置参与 TS 的工程引用(project references),允许生成增量信息。
  • skipLibCheck:true
    • 跳过声明文件检查,加快编译。
  • module:"ESNext"
    • 使用 ESNext 模块系统。
  • moduleResolution:"bundler"
    • 适配 Vite 等打包工具的解析方式。
  • allowSyntheticDefaultImports:true
    • 允许对仅有 export = 的模块使用默认导入,兼容 CommonJS 包。
  • strict:true
    • 同样开启严格类型检查。
include
  • ["vite.config.ts", "vitest.config.ts"]
    • 仅对这两个 Node 环境运行的配置文件进行类型检查,保证其类型安全和智能提示。

TypeScript 工具链与脚本(补充)

vue-tsc

- 专门针对 Vue 3 项目(包括 `.vue` 文件)进行类型检查的工具。
- 在 package.json 中通过脚本:
    - `type-check`: `vue-tsc -b`
    - `build`: `vue-tsc -b && vite build`
- 先基于 tsconfig 工程配置做一遍完整类型检查,再进入 Vite 构建流程,避免类型错误进入打包阶段。

ESLint + @typescript-eslint/*

- ESLint 使用 `@typescript-eslint/parser``@typescript-eslint/eslint-plugin` 读取 tsconfig 中的编译选项和类型信息,对 TS/TSX 代码做更精细的规则检查。
- 通过 parserOptions.project 指向 `tsconfig.app.json` / `tsconfig.node.json`,确保类型感知规则(如 no-unused-vars、no-explicit-any 等)能够发挥作用。

Vitest 与 TS: - vitest.config.ts 通过 mergeConfig 复用 Vite 配置,使测试文件也能使用同样的别名与 TS 配置。 - 测试代码本身的类型检查依托于上述 tsconfig 和 vue-tsc/TypeScript 工具链。

Git Hooks & 提交规范配置说明

本项目通过 Husky、lint-staged 和 Commitlint 组成一套 Git 提交前检查与提交消息规范校验流程。

Husky 目录结构

  • .husky/pre-commit
    • 提交前(pre-commit hook)执行。
  • .husky/commit-msg
    • 输入提交信息后、真正写入提交之前执行。

package.json 中:

  • "prepare": "husky"
    • 安装依赖后会自动初始化 Husky(创建 .husky 目录),确保 Git Hooks 生效。

pre-commit 钩子:代码质量检查

文件:.husky/pre-commit

内容:

# 严格模式:提交前必须通过 lint-staged 检查
pnpm lint-staged

含义:

  • 在执行 git commit 时,只对暂存区(staged)的文件运行 lint-staged。
  • 如果 lint-staged 中配置的命令有失败,则本次提交会被中断,强制开发者先修复问题。

lint-staged 配置:只检查改动文件

文件:.lintstagedrc

核心规则:

{
    "*.{js,ts}": ["eslint --cache --fix --max-warnings=0", "prettier --write"],
    "*.vue": ["eslint --cache --fix --max-warnings=0", "prettier --write"],
    "*.{css,less,scss}": ["stylelint --cache --fix", "prettier --write"],
    "*.{json,md,html,yml,yaml}": "prettier --write",
    "src/**/components/**/*.{vue,js,ts}": ["eslint --cache --fix --max-warnings=5"],
    "src/**/antd/**/*.{vue,js,ts}": ["eslint --cache --fix --max-warnings=5"],
}

说明:

  • *.{js,ts} / *.vue
    • 先使用 ESLint(带缓存、自动修复、并将允许的 warning 数量控制为 0)。
    • 再使用 Prettier 统一代码格式。
  • *.{css,less,scss}
    • 使用 Stylelint 进行样式规范检查并自动修复,然后交给 Prettier 统一格式。
  • *.{json,md,html,yml,yaml}
    • 仅用 Prettier 进行格式化,保证缩进与风格统一。
  • src/**/components/**/*.{vue,js,ts}src/**/antd/**/*.{vue,js,ts}
    • 对关键目录(组件、Ant Design 相关目录)额外运行一次 ESLint,并允许少量 warning(max-warnings=5),强调这里的代码质量。

commit-msg 钩子:提交信息规范

文件:.husky/commit-msg

内容:

# 严格模式:提交信息必须符合规范,否则中断提交
pnpm commitlint --edit "$1"

含义:

  • 在编写完 commit message 后,使用 Commitlint 对提交信息进行校验。
  • 如果不符合规范(例如类型不合法、描述太短等),提交会被中止,需要修改提交说明后重试。

Commitlint 规则

文件:.commitlintrc.cjs

核心规则:

// .commitlintrc.cjs
module.exports = {
    extends: ['@commitlint/config-conventional'],

    rules: {
        // 1. 提交类型(必需)
        'type-enum': [
            2,
            'always',
            [
                'feat', // 新功能
                'fix', // Bug修复
                'docs', // 文档更新
                'style', // 代码格式(空格、分号等,不影响功能)
                'refactor', // 重构(既不是新功能也不是bug修复)
                'test', // 测试相关
                'chore', // 构建过程或辅助工具变动
                'perf', // 性能优化
                'build', // 构建系统或外部依赖变更
                'ci', // CI配置变更
                'revert', // 回滚提交
                'other', // 其他类型
            ],
        ],

        // 2. 类型必须小写
        'type-case': [2, 'always', 'lower-case'],

        // 3. 类型不能为空
        'type-empty': [2, 'never'],

        // 4. 主题(描述)不能为空
        'subject-empty': [2, 'never'],

        // 5. 主题不以句号结尾
        'subject-full-stop': [2, 'never', '.'],

        // 6. 主题最少3个字符
        'subject-min-length': [2, 'always', 3],

        // 7. 主题最多100个字符(建议一行能显示完整)
        'subject-max-length': [2, 'always', 100],

        // 8. 作用域(可选)
        'scope-enum': [
            2,
            'always',
            [
                'component', // 组件
                'page', // 页面
                'layout', // 布局
                'router', // 路由
                'store', // 状态管理(Pinia)
                'api', // API接口
                'utils', // 工具函数
                'styles', // 样式
                'types', // TypeScript类型
                'config', // 配置
                'deps', // 依赖更新
                'other', // 其他(不属于以上分类的提交)
            ],
        ],
    },
};

关键点:

  • extends:['@commitlint/config-conventional']

    • 基于社区常用的 Conventional Commits 规范。
  • type-enum

    • 限制可用的提交类型,例如:featfixdocsstylerefactortestchoreperfbuildcirevertother 等。
  • type-case / type-empty

    • 类型必须小写,且不能为空。
  • subject-empty / subject-min-length / subject-max-length

    • 提交描述必填,长度在 3~100 字符之间,且不允许以句号结尾(subject-full-stop 规则)。
  • scope-enum

    • 可选的作用域列表,如 componentpagelayoutrouterstoreapiutilsstylestypesconfigdepsother 等,帮助约束“这个提交主要改了哪一类东西”。

日常使用建议

  • 开发过程中:
    • 经常本地执行 pnpm lintpnpm format 保持代码整洁。
  • 提交代码时:
    • 按照约定的格式书写提交信息,例如:
      • feat(component): 新增用户列表组件
      • fix(api): 修复登录接口返回值解析错误
    • pre-commit 和 commit-msg 钩子会自动帮你做最后一层把关。

postcss.config.cjs 配置说明

PostCSS 用于在构建过程中对 CSS 进行各种自动化处理。本配置主要启用了 Tailwind CSS、Autoprefixer 以及在生产环境使用的 cssnano 压缩。

plugins 插件配置

// postcss.config.cjs
module.exports = {
    plugins: {
        // Tailwind CSS 插件
        tailwindcss: {
            config: './tailwind.config.js', // 指定 Tailwind 配置文件路径
        },

        // Autoprefixer 自动添加浏览器前缀
        autoprefixer: {
            overrideBrowserslist: [
                'last 2 versions', // 支持最近2个版本的浏览器
                '> 1%', // 全球使用率 > 1% 的浏览器
                'ios >= 8', // iOS 8+
                'android >= 4.4', // Android 4.4+
                'not ie <= 11', // 不支持 IE 11 及以下
                'not dead', // 不包含已死亡的浏览器
            ],
            grid: true, // 为 IE 启用 CSS Grid 前缀
            flexbox: true, // 为旧版浏览器添加 Flexbox 前缀
            remove: false, // 不删除过时的前缀
        },

        // 可选:CSS 压缩(生产环境)
        ...(process.env.NODE_ENV === 'production'
            ? {
                  cssnano: {
                      preset: [
                          'default',
                          {
                              discardComments: { removeAll: true }, // 删除所有注释
                              normalizeWhitespace: false, // 不压缩空格(由构建工具处理)
                          },
                      ],
                  },
              }
            : {}),
    },
};
tailwindcss
  • 作用:启用 Tailwind CSS,按需生成原子化工具类样式。
  • config:'./tailwind.config.js'
    • 指定 Tailwind 的配置文件路径,统一管理扫描范围、主题颜色等。
autoprefixer
  • 作用:自动为 CSS 添加浏览器前缀,提升兼容性。
  • overrideBrowserslist:浏览器兼容策略列表:
    • last 2 versions:支持最近 2 个版本的各主流浏览器。
    • > 1%:全球使用率大于 1% 的浏览器。
    • ios >= 8:支持 iOS 8 及以上版本。
    • android >= 4.4:支持 Android 4.4 及以上版本。
    • not ie <= 11:排除 IE11 及以下版本。
    • not dead:排除已经停止维护的“死亡”浏览器。
  • grid:true
    • 为 IE 等浏览器添加 CSS Grid 前缀。
  • flexbox:true
    • 为旧版浏览器添加 Flexbox 前缀。
  • remove:false
    • 不删除已有的旧前缀,避免影响兼容性。
cssnano(仅生产环境)
  • 作用:在生产环境中对 CSS 进行压缩和优化,减小包体积。
  • 条件启用:process.env.NODE_ENV === 'production' 时才添加该插件。
  • preset:
    • 'default':使用 cssnano 默认优化策略。
    • 配置对象:
      • discardComments.removeAll:true,删除所有 CSS 注释。
      • normalizeWhitespace:false,不在此处压缩空白字符(交由构建工具处理)。

总结

  • 开发环境:Tailwind + Autoprefixer,便于快速开发与兼容性处理。
  • 生产环境:在上述基础上额外启用 cssnano,进一步压缩 CSS 提高加载性能。

tailwind.config.js 配置说明

该文件定义了 Tailwind CSS 的扫描范围、主题扩展以及与 Ant Design Vue 的配合策略。

// tailwind.config.js(精简版)
module.exports = {
    content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],

    theme: {
        extend: {
            // Ant Design 主题颜色
            colors: {
                primary: '#1890ff',
                success: '#52c41a',
                warning: '#faad14',
                error: '#f5222d',
                info: '#13c2c2',
            },

            // 字体
            fontFamily: {
                sans: ['-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', 'sans-serif'],
            },

            // 圆角
            borderRadius: {
                ant: '6px',
            },

            // 阴影
            boxShadow: {
                ant: '0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 9px 28px 8px rgba(0, 0, 0, 0.05)',
            },
        },

        // 响应式断点
        screens: {
            xs: '480px',
            sm: '640px',
            md: '768px',
            lg: '1024px',
            xl: '1280px',
            '2xl': '1536px',
        },
    },

    // 关键:禁用 preflight 避免与 Ant Design 冲突
    corePlugins: {
        preflight: false,
    },

    plugins: [],
};

content 扫描范围

  • ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}']
    • Tailwind 会在这些文件中扫描类名,只生成实际用到的工具类,减少最终 CSS 体积。

theme 主题配置

extend 扩展主题
  • colors:

    • primary:#1890ff
    • success:#52c41a
    • warning:#faad14
    • error:#f5222d
    • info:#13c2c2
    • 这些颜色与 Ant Design 的主题色保持一致,方便统一 UI 风格。
  • fontFamily.sans:

    • ['-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', 'sans-serif']
    • 定义无衬线字体的优先级列表,提升跨平台字体一致性。
  • borderRadius.ant:

    • 6px,用于配合 Ant Design 的默认圆角风格,可在项目中通过自定义类统一使用。
  • boxShadow.ant:

    • 一组与 Ant Design 近似的阴影配置,使自定义元素与 AntD 组件视觉统一。
screens 断点定义
  • xs:480px
  • sm:640px
  • md:768px
  • lg:1024px
  • xl:1280px
  • 2xl:1536px

这些断点用于响应式布局,如 md:w-1/2 表示在 md 及以上宽度时占一半宽度。

corePlugins 内置插件控制

  • preflight:false
    • 关闭 Tailwind 的预设 CSS 重置(preflight),以避免与 Ant Design 自身的样式重置产生冲突。
    • 通过此设置,可以让 Ant Design 的默认样式在项目中保持预期行为。

plugins 自定义插件

  • 当前为 [](空数组)。
    • 需要时可以在此添加社区或自定义的 Tailwind 插件,例如表单、美化滚动条等。

vitest.config.ts 配置说明

Vitest 是与 Vite 深度集成的测试框架。本配置基于 Vite 配置进行扩展,使测试环境与实际构建环境保持一致。

import { mergeConfig } from 'vitest/config';
import viteConfig from './vite.config';

export default mergeConfig(viteConfig, {
    test: {
        globals: true,
        environment: 'happy-dom',
        include: ['src/**/*.{test,spec}.{js,ts,vue}'],
    },
});

mergeConfig 与基础配置

  • import { mergeConfig } from 'vitest/config'
    • 用于在 Vitest 配置中复用并扩展 Vite 的配置。
  • import viteConfig from './vite.config'
    • 引入项目的 Vite 配置,保证测试环境的别名、插件等与开发/构建保持一致。
  • export default mergeConfig(viteConfig, { ... })
    • 使用 mergeConfig 将 Vitest 相关配置与 Vite 基础配置合并。

test 选项

  • globals:true
    • 启用全局测试 API,如 describeitexpect 等,无需手动 import。
  • environment:'happy-dom'
    • 使用 happy-dom 提供类似浏览器的 DOM 环境,适合测试 Vue 组件与涉及 DOM 操作的逻辑。
  • include:['src/**/*.{test,spec}.{js,ts,vue}']
    • 指定测试文件匹配模式:
      • 位于 src 目录及其子目录中。
      • 文件名包含 .test..spec.
      • 支持 js/ts/vue 等扩展名。

与 ESLint 中测试配置的关系

  • ESLint 的测试规则块会为这些测试文件注入 Vitest 的全局变量,防止 describe 等被报未定义。
  • Vitest 配置中的 include 对测试执行范围负责,两者配合保证测试既能正确运行又能通过 lint 检查。

项目目录结构说明

本文档说明本项目主要目录的作用和推荐使用方式,便于团队成员快速理解和扩展。

目录结构总览

├─ src/
│  ├─ assets/
│  │  ├─ images/
│  │  └─ styles/
│  ├─ components/
│  │  ├─ common/
│  │  └─ business/
│  ├─ layouts/
│  ├─ router/
│  │  └─ modules/
│  ├─ services/
│  │  └─ modules/
│  ├─ stores/
│  │  └─ modules/
│  ├─ hooks/
│  ├─ utils/
│  ├─ types/
│  ├─ constants/
│  ├─ tests/
│  │  ├─ unit/
│  │  └─ components/
│  ├─ App.vue
│  └─ main.ts
├─ public/
├─ doc/
│  ├─ project-structure.md
│  └─ ...(其他配置/规范文档)
├─ package.json
├─ vite.config.ts / vite.config.js
├─ vitest.config.ts / vitest.config.js
├─ tsconfig*.json
├─ eslint.config.js
├─ tailwind.config.js
└─ .prettierrc

根目录

  • src/
    • 前端应用的主要源码目录。
  • public/
    • 静态公共资源目录,打包时会原样拷贝到构建结果中。
  • doc/
    • 项目文档目录(工程规范、配置说明、目录结构等)。

src 目录

  • src/main.ts
    • 应用入口文件,创建 Vue 应用实例,注册路由、状态管理、全局组件等。
  • src/App.vue
    • 根组件,一般只负责基本布局容器和路由出口等。
  • src/assets/
    • 静态资源:图片、图标、全局样式等。
    • 建议按类型或业务拆分子目录,例如:images/styles/ 等。
  • src/components/
    • 可复用的通用组件。
    • 可再划分:common/(基础通用)、business/(跨页面业务组件)等。
  • src/views/
    • 页面级组件(通常与路由一一对应)。
    • 每个页面一个目录,内部放该页面的子组件,例如:views/user/List.vueviews/user/components/UserTable.vue
  • src/layouts/
    • 布局组件:如后台管理系统的主框架(侧边栏 + 顶栏 + 内容区)、登录页布局等。
    • 常见约定:DefaultLayout.vueAuthLayout.vue 等。
  • src/router/
    • 路由相关配置。
    • 一般包含:index.ts(创建 router 实例)、按模块拆分的路由配置文件(如 modules/user.ts)。
  • src/stores/
    • 状态管理(例如 Pinia)相关的 store 定义。
    • 每个业务领域一个 store 文件,如:userStore.tsappStore.ts 等。
  • src/services/
    • 与后端交互的服务层代码(API 请求封装)。
    • 推荐:
      • request.ts:封装 Axios/Fetch,统一处理请求、响应、错误。
      • 按业务模块拆分 API 文件,如:user.tsauth.tssystem.ts
  • src/utils/
    • 工具函数库,纯函数、与业务相对无关的通用逻辑。
    • 可按功能再拆分:date.tsformat.tsvalidator.ts 等。
  • src/hooks/
    • 组合式函数(Composition API Hooks),抽离可复用的状态逻辑。
    • 命名建议以 use 开头,例如:useRequestusePaginationuseDialog 等。
  • src/types/
    • TypeScript 类型定义,接口类型、全局类型、枚举等。
    • 可以按模块管理:user.d.tsauth.d.tsapi.d.ts 等。
  • src/constants/
    • 项目中用到的常量定义,如枚举值、字典、配置项、路由名称常量等。
    • 例如:route-names.tsstorage-keys.tsbusiness.ts
  • src/tests/
    • 单元测试与组件测试目录,使用 Vitest 运行。
    • 推荐按类型再分:tests/unit/(工具函数、逻辑单元)和 tests/components/(组件相关测试)。
    • 测试文件命名建议:*.test.ts*.spec.ts,Vitest 已在 vitest.config.ts 中配置 include: ['src/**/*.{test,spec}.{js,ts,vue}'],会自动匹配。

常见子目录约定

以下为项目中推荐使用的一些二级子目录,实际可根据业务扩展或精简:

  • src/assets/images/
    • 存放图片、图标等静态资源,可按业务或功能再拆子目录。
  • src/assets/styles/
    • 全局样式、Tailwind 扩展、主题相关样式等。
  • src/components/common/
    • 基础通用组件,如按钮封装、表格封装、对话框封装等,可在多个业务模块中复用。
  • src/components/business/
    • 跨页面的业务组件,例如“用户选择器”、“部门树选择”等。
  • src/views/dashboard/
    • 仪表盘 / 概览页相关的页面组件。
  • src/router/modules/
    • 路由模块配置文件,按业务模块拆分,例如:system.tsuser.tsdashboard.ts
  • src/stores/modules/
    • 按业务模块拆分的 store 文件,例如:system.tsuser.tsapp.ts 等。
  • src/services/modules/
    • 按业务模块拆分的 API 服务文件,例如:system.tsuser.tsauth.ts 等。

约定与建议

  • 页面(views)和业务组件的目录结构,尽量与路由、业务模块保持一致,便于查找和重构。
  • 通用组件、hooks、utils、services 等尽量保持“可复用、低耦合”,避免将具体页面逻辑写进去。
  • 新增目录或模块时,优先考虑是否属于现有模块,尽量保持目录层级清晰、简洁。
❌