阅读视图

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

JS复杂去重一定要先排序吗?深度解析与性能对比

引言

在日常开发中,数组去重是JavaScript中常见的操作。对于简单数据类型,我们通常会毫不犹豫地使用Set。但当面对复杂对象数组时,很多开发者会产生疑问:复杂去重一定要先排序吗?

这个问题背后其实隐藏着几个更深层次的考量:

  • 排序是否会影响原始数据顺序?
  • 排序的性能开销是否值得?
  • 是否有更优雅的解决方案?

1. 常见的排序去重方案

1.1 传统的排序去重思路
// 先排序后去重的经典写法
function sortThenUnique(arr, key) {
  return arr
    .slice()
    .sort((a, b) => {
      // 避免修改原始数组
      const valueA = key ? a[key] : a;
      const valueB = key ? b[key] : b;
      if (valueA < valueB) return -1;
      if (valueA > valueB) return 1;
      return 0;
    })
    .filter((item, index, array) => {
      if (index === 0) return true; // 保留第一个元素
      const value = key ? item[key] : item;
      const prevValue = key ? array[index - 1][key] : array[index - 1];
      return value !== prevValue; // 仅保留与前一个元素不同的元素
    });
}
1.2 排序去重的优缺点

优点:

  • 代码逻辑相对直观
  • 对于已排序或需要排序的数据,可以一步完成
  • 在某些算法题中可能是必要步骤

缺点:

  • 时间复杂度至少为 O(n log n)
  • 改变了原始数据的顺序
  • 对于不需要排序的场景是额外开销

2. 不排序的去重方案

2.1 基于Map的保持顺序方案
function uniqueByKey(arr, key) {
  const seen = new Map();
  const result = [];

  for (const item of arr) {
    const keyValue = item[key];
    if (!seen.has(keyValue)) {
      seen.set(keyValue, true);
      result.push(item);
    }
  }
  return result;
}

// 支持多个字段的复合键
function uniqueByMultipleKeys(arr, keys) {
  const seen = new Set();
  return arr.filter((item) => {
    const compositeKey = keys.map((key) => item[key]).join("|");
    if (seen.has(compositeKey)) {
      return false;
    }
    seen.add(compositeKey);
    return true;
  });
}
2.2 基于对象的缓存方案
function uniqueByKeyWithObject(arr, key) {
  const cache = {};
  return arr.filter((item) => {
    const keyValue = item[key];
    if (cache[keyValue]) {
      return false;
    }
    cache[keyValue] = true;
    return true;
  });
}
2.3 基于自定义比较函数的方案
function uniqueWithCustomComparator(arr, comparator) {
  return arr.filter((current, index, self) => {
    // 查找第一个相同元素的位置
    return self.findIndex((item) => comparator(item, current)) === index;
  });
}

// 使用示例
const users = [
  { id: 1, name: "Alice", age: 25 },
  { id: 2, name: "Bob", age: 30 },
  { id: 1, name: "Alice", age: 25 }, // 重复
  { id: 1, name: "Alice", age: 26 }, // ID相同但年龄不同
];

const uniqueUsers = uniqueWithCustomComparator(
  users,
  (a, b) => a.id === b.id && a.name === b.name
);

console.log(uniqueUsers);
// [ { id: 1, name: 'Alice', age: 25 }, { id: 2, name: 'Bob', age: 30 } ]

3. 性能对比分析

3.1 时间复杂度对比
方法 时间复杂度 空间复杂度 是否保持顺序
排序后去重 O(n log n) O(1) 或 O(n)
Map去重 O(n) O(n)
对象缓存去重 O(n) O(n)
filter + findIndex O(n²) O(1)
3.2 实际性能测试
// 性能测试代码示例
function generateTestData(count) {
  return Array.from({length: count}, (_, i) => ({
    id: Math.floor(Math.random() * count / 10), // 产生大量重复
    value: `item-${i}`,
    data: Math.random()
  }));
}

function runPerformanceTest() {
  const data = generateTestData(10000);
  
  console.time('Map去重');
  uniqueByKey(data, 'id');
  console.timeEnd('Map去重');
  
  console.time('排序去重');
  sortThenUnique(data, 'id');
  console.timeEnd('排序去重');
  
  console.time('filter+findIndex');
  uniqueWithCustomComparator(data, (a, b) => a.id === b.id);
  console.timeEnd('filter+findIndex');
}

测试结果趋势:

  • 数据量<1000:各种方法差异不大
  • 数据量1000-10000:Map方案明显占优
  • 数据量>10000:排序方案开始显现劣势

4. 应用场景与选择建议

4.1 什么时候应该考虑排序?
1.需要有序输出时
// 既要去重又要按特定字段排序
const getSortedUniqueUsers = (users) => {
  const uniqueUsers = uniqueByKey(users, 'id');
  return uniqueUsers.sort((a, b) => a.name.localeCompare(b.name));
};
2. 数据本身就需要排序时
// 如果业务本来就需要排序,可以合并操作
const processData = (data) => {
  // 先排序便于后续处理
  data.sort((a, b) => a.timestamp - b.timestamp);
  // 去重
  return uniqueByKey(data, 'id');
};
3.处理流式数据时
// 实时数据流,需要维持有序状态
class SortedUniqueCollection {
  constructor(key) {
    this.key = key;
    this.data = [];
    this.seen = new Set();
  }
  
  add(item) {
    const keyValue = item[this.key];
    if (!this.seen.has(keyValue)) {
      this.seen.add(keyValue);
      // 插入到正确位置维持有序
      let index = 0;
      while (index < this.data.length && 
             this.data[index][this.key] < keyValue) {
        index++;
      }
      this.data.splice(index, 0, item);
    }
  }
}
4.2 什么时候应该避免排序?
1.需要保持原始顺序时
// 日志记录、时间线数据等
const logEntries = [
  {id: 3, time: '10:00', message: '启动'},
  {id: 1, time: '10:01', message: '初始化'},
  {id: 3, time: '10:02', message: '启动'}, // 重复
  {id: 2, time: '10:03', message: '运行'}
];

// 保持时间顺序很重要!
const uniqueLogs = uniqueByKey(logEntries, 'id');
2.性能敏感的应用
// 实时渲染大量数据
function renderItems(items) {
  // 使用Map去重避免不必要的排序开销
  const uniqueItems = uniqueByKey(items, 'id');
  // 快速渲染
  return uniqueItems.map(renderItem);
}
3. 数据不可变要求
// React/Vue等框架中,避免改变原数组
const DeduplicatedList = ({ items }) => {
  // 不改变原始数据
  const uniqueItems = useMemo(
    () => uniqueByKey(items, 'id'),
    [items]
  );
  return <List items={uniqueItems} />;
};

5. 高级技巧和优化

5.1 惰性去重迭代器
function* uniqueIterator(arr, getKey) {
  const seen = new Set();
  for (const item of arr) {
    const key = getKey(item);
    if (!seen.has(key)) {
      seen.add(key);
      yield item;
    }
  }
}

// 使用示例
const data = [...]; // 大数据集
for (const item of uniqueIterator(data, x => x.id)) {
  // 逐个处理,节省内存
  processItem(item);
}
5.2 增量去重
class IncrementalDeduplicator {
  constructor(key) {
    this.key = key;
    this.seen = new Map();
    this.count = 0;
  }
  
  add(items) {
    return items.filter(item => {
      const keyValue = item[this.key];
      if (this.seen.has(keyValue)) {
        return false;
      }
      this.seen.set(keyValue, ++this.count); // 记录添加顺序
      return true;
    });
  }
  
  getAddedOrder(keyValue) {
    return this.seen.get(keyValue);
  }
}
5.3 内存优化版本
function memoryEfficientUnique(arr, key) {
  const seen = new Map();
  const result = [];
  
  // 使用WeakMap处理对象键
  const weakMap = new WeakMap();
  
  for (let i = 0; i < arr.length; i++) {
    const item = arr[i];
    const keyValue = item[key];
    
    // 对于对象类型的键值,使用WeakMap
    if (typeof keyValue === 'object' && keyValue !== null) {
      if (!weakMap.has(keyValue)) {
        weakMap.set(keyValue, true);
        result.push(item);
      }
    } else {
      if (!seen.has(keyValue)) {
        seen.set(keyValue, true);
        result.push(item);
      }
    }
  }
  
  return result;
}

6. 实战案例分析

6.1 电商商品去重
// 场景:合并多个来源的商品数据
const productsFromAPI = [...];
const productsFromCache = [...];
const userUploadedProducts = [...];

// 需求:按商品SKU去重,保持最新数据
function mergeProducts(productLists) {
  const merged = [];
  const skuMap = new Map();
  
  // 按优先级处理(后处理的优先级高)
  productLists.forEach(list => {
    list.forEach(product => {
      const existing = skuMap.get(product.sku);
      if (!existing || product.updatedAt > existing.updatedAt) {
        if (existing) {
          // 移除旧的
          const index = merged.findIndex(p => p.sku === product.sku);
          merged.splice(index, 1);
        }
        merged.push(product);
        skuMap.set(product.sku, product);
      }
    });
  });
  
  return merged;
}
6.2 实时消息去重
// 场景:聊天应用消息去重
class MessageDeduplicator {
  constructor(timeWindow = 5000) {
    this.timeWindow = timeWindow;
    this.messageIds = new Set();
    this.timestamps = new Map();
  }
  
  addMessage(message) {
    const now = Date.now();
    const { id } = message;
    
    // 清理过期记录
    this.cleanup(now);
    
    // 检查是否重复
    if (this.messageIds.has(id)) {
      return false;
    }
    
    // 添加新记录
    this.messageIds.add(id);
    this.timestamps.set(id, now);
    return true;
  }
  
  cleanup(now) {
    for (const [id, timestamp] of this.timestamps) {
      if (now - timestamp > this.timeWindow) {
        this.messageIds.delete(id);
        this.timestamps.delete(id);
      }
    }
  }
}

结论

回到最初的问题:JS复杂去重一定要先排序吗?

答案是否定的。 排序只是众多去重策略中的一种,而非必需步骤。

我的建议:

  1. 默认使用Map方案: 对于大多数场景,基于Map或Set的去重方法在性能和功能上都是最佳选择。
  2. 根据需求选择:
  • 需要保持顺序 → 使用Map
  • 需要排序结果 → 先排序或后排序
  • 数据量很大 → 考虑迭代器或流式处理
  • 内存敏感 → 使用WeakMap或定期清理
  1. 考虑可读性和维护性: 有时清晰的代码比微小的性能优化更重要。
  2. 进行实际测试: 在性能关键路径上,用真实数据测试不同方案。

实践总结:

// 通用推荐方案
function deduplicate(arr, identifier = v => v) {
  const seen = new Set();
  return arr.filter(item => {
    const key = typeof identifier === 'function' 
      ? identifier(item)
      : item[identifier];
    
    if (seen.has(key)) return false;
    seen.add(key);
    return true;
  });
}

// 需要排序时的方案
function deduplicateAndSort(arr, key, sortBy) {
  const unique = deduplicate(arr, key);
  return unique.sort((a, b) => {
    const aVal = a[sortBy];
    const bVal = b[sortBy];
    return aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
  });
}

记住,没有银弹。最合适的去重方案取决于你的具体需求:数据规模、顺序要求、性能需求和代码上下文。希望这篇文章能帮助你在面对复杂去重问题时做出明智的选择!

二叉搜索树:让数据在有序中生长的智慧之树

二叉搜索树:让数据有序生长的智慧树

想象一下,你正在整理一个家庭相册。你会把年代久远的照片放在左边,近几年的照片放在右边,每一张照片都可以按照时间顺序快速找到——这就是二叉搜索树的思想。它是一种让数据保持有序、支持快速查找、插入和删除的树形数据结构。

什么是二叉搜索树?

二叉搜索树(Binary Search Tree,简称 BST),又称为二叉排序树二叉查找树,是一种特殊的二叉树数据结构,具有以下核心性质:

  1. 左子树中所有节点的值 严格小于 根节点的值;
  2. 右子树中所有节点的值 严格大于 根节点的值;
  3. 左子树和右子树 本身也是二叉搜索树
  4. (通常假设)树中不存在重复值(也可根据实现允许相等值统一放在左/右)。

简单来说就是:

左子树所有节点的值 < 根节点的值 < 右子树所有节点的值

示例

        50
       /  \
     30    70
    / \    / \
  20  40 60  80

这种结构让查找变得非常高效,平均情况下只需 O(log n) 的时间,就像在电话簿中按字母顺序找人一样快。

不过,如果树长得“歪”了(比如所有节点都只有右孩子),最坏情况下(如按升序连续插入),BST 会退化成一条链表,查找效率从“电话簿式跳跃”退化为“一页页翻书”,时间复杂度变为 O(n)

时间复杂度O(n)示例:

1
 \
  2
   \
    3
     \
      4
       \
        5

查找:在树中寻宝

假设我们有一棵这样的二叉搜索树:

class TreeNode {
    constructor(val) {
        this.val = val;
        this.left = null;
        this.right = null;
    }
}

// 构建一棵树
const root = new TreeNode(6);
root.left = new TreeNode(3);
root.right = new TreeNode(8);
root.left.left = new TreeNode(1);
root.left.right = new TreeNode(4);
root.right.left = new TreeNode(7);
root.right.right = new TreeNode(9);

这棵树的结构如下:

      6
     / \
    3   8
   / \ / \
  1  4 7  9

现在,我们要在这棵树里寻找值为 7 的节点:

function search(root, n) {
  if (!root) {
    return;
  }

  if (root.val === n) {
    console.log('找到目标节点', root);
  } else if (root.val > n) {
    // 如果目标值比当前节点小,去左边找
    search(root.left, n);
  } else {
    // 如果目标值比当前节点大,去右边找
    search(root.right, n);
  }
}

search(root, 7); // 输出:找到目标节点

查找过程就像在迷宫中选择正确的岔路:每次都判断目标在左还是右,一步步缩小范围,直到找到宝藏。

插入:为树添加新成员

当有新成员要加入这个有序的“家庭”时,我们需要为它找到合适的位置。比如要在树中插入数字 5

function insertIntoBst(root, n) {
  // 如果当前位置是空的,就在这里安家
  if (!root) {
    root = new TreeNode(n);
    return root;
  }
  
  // 比当前节点小,去左边找位置
  if (root.val > n) {
    root.left = insertIntoBst(root.left, n);
  } else {
    // 比当前节点大,去右边找位置
    root.right = insertIntoBst(root.right, n);
  }

  return root;
}

// 插入数字5
const newRoot = insertIntoBst(root, 5);

插入后,树的结构变为:

      6
     / \
    3   8
   / \ / \
  1  4 7  9
      \
       5    ← 新成员在这里找到了家

插入过程就像在图书馆找书架放新书:先比较书名首字母,决定去左边还是右边的书架,直到找到一个空位。

🗑️ 删除:优雅地告别

删除节点是二叉搜索树操作中最精妙的部分,需要考虑三种情况:

1. 叶子节点(没有孩子)

直接移除即可,就像摘下一片树叶。

2. 只有一个孩子

用这个孩子接替自己的位置,就像父亲把家业传给独子。

3. 有两个孩子

这是最有趣的情况:需要找一个合适的“接班人”。可以选择左子树中最大的节点,或者右子树中最小的节点来替代自己。

function deleteNode(root, n) {
  if (!root) {
    return root;
  }

  if (root.val === n) {
    // 情况1:没有孩子
    if (!root.left && !root.right) {
      return null;
    }
    // 情况2:有左孩子,找左子树中最大的
    else if (root.left) {
      const maxLeft = findMax(root.left);
      root.val = maxLeft.val;  // 用左子树最大节点替代自己
      root.left = deleteNode(root.left, maxLeft.val); // 删除那个最大节点
    }
    // 情况3:只有右孩子,找右子树中最小的
    else {
      const minRight = findMin(root.right);
      root.val = minRight.val;  // 用右子树最小节点替代自己
      root.right = deleteNode(root.right, minRight.val); // 删除那个最小节点
    }
  } else if (root.val > n) {
    root.left = deleteNode(root.left, n);
  } else {
    root.right = deleteNode(root.right, n);
  }
  return root;
}

// 寻找左子树中的最大值(一直往右走)
function findMax(root) {
  while (root.right) {
    root = root.right;
  }
  return root;
}

// 寻找右子树中的最小值(一直往左走)
function findMin(root) {
  while (root.left) {
    root = root.left;
  }
  return root;
}

// 删除值为4的节点
deleteNode(root, 4);

整体思路:递归 + 分类处理

函数采用递归方式遍历树:

  1. 先定位要删除的节点;
  2. 再根据该节点的子树情况,分三种情形处理;
  3. 最后通过返回值重建父子链接,确保树结构完整。

删除有两个孩子的节点,就像公司CEO离职:需要从现有团队中选一个最合适的接班人(左子树最大或右子树最小),让这个接班人坐在CEO的位置上,然后再处理接班人原来的职位。

二叉搜索树操作全景回顾

至此,我们已完整走过了二叉搜索树的三大核心操作,它们共同构成了 BST 的生命力:

  • 查找(Search)
    沿着“左小右大”的路径逐层深入,时间复杂度平均为 O(log n) ,最坏为 O(n) 。它是所有操作的基础。

  • 插入(Insert)
    本质是一次“带记忆的查找”——找到空位后安放新节点,不破坏原有有序性,实现简单却至关重要。

  • 删除(Delete)
    最具挑战性的操作,需分三种情形处理:

    • 叶子节点 → 直接移除;
    • 单子节点 → 子承父业;
    • 双子节点 → 以前驱(左子树最大)或后继(右子树最小) 替代,再递归删除替代者。

这三者都基于递归思想BST 的有序性质,在保持结构合法的同时,高效维护数据的动态有序。

正是这种“有序 + 递归 + 分治”的组合,让二叉搜索树成为连接线性结构与高级平衡树之间的关键桥梁。


结语:有序之美,源于结构之智

二叉搜索树不仅是一种数据结构,更是一种有序思维的体现——用“左小右大”的简单规则,让数据在树中自然生长,查找、插入、删除皆如行云流水。

但若插入失序,BST 会退化为链表,效率骤降。这提醒我们:结构决定性能,平衡方能持久。于是 AVL 树、红黑树等自平衡结构应运而生,在动态中守护秩序。

掌握 BST,不只是学会一种算法,更是理解递归、分治与有序组织的起点。下次你快速翻到某张照片或找到某个联系人时,那背后或许正有一棵“智慧树”在默默工作。

理解 BST,是通往高级数据结构的第一步。

JavaScript 中的深拷贝与浅拷贝详解

深拷贝和浅拷贝是 JavaScript 中处理引用类型数据(对象、数组等)的核心概念,二者的本质区别在于是否复制引用类型的深层嵌套数据,直接影响数据操作的独立性,是开发中避免数据污染的关键。

一、先明确:为什么需要拷贝?(引用类型的特性)

JavaScript 数据类型分为两类,拷贝行为仅对引用类型有区分(原始类型为值传递,不存在深浅拷贝):

数据类型类别 包含类型 拷贝特性
原始类型 String、Number、Boolean、Null、Undefined、Symbol、BigInt 赋值 / 拷贝时传递「值本身」,修改新值不会影响原值
引用类型 Object(普通对象、数组、函数、正则等) 赋值 / 浅拷贝时传递「内存地址(引用)」,修改新数据会影响原数据;深拷贝才会复制数据本身,实现完全独立

示例:引用类型的默认赋值(引用传递,非拷贝)

// 引用类型:数组
const arr1 = [1, 2, { name: "张三" }];
const arr2 = arr1; // 仅传递引用,不是拷贝
arr2[0] = 100;
arr2[2].name = "李四";
console.log(arr1); // [100, 2, { name: "李四" }](原值被修改)
console.log(arr2); // [100, 2, { name: "李四" }]

二、浅拷贝(Shallow Copy):仅复制表层数据

1. 核心定义

浅拷贝是指只复制引用类型的表层属性(第一层数据) ,对于深层嵌套的引用类型(如对象中的对象、数组中的数组),仅复制其内存地址(引用),新旧数据的深层嵌套部分会共享同一块内存,修改其中一个的深层数据会影响另一个。

2. 常见实现方式

(1)数组浅拷贝

  • Array.prototype.slice()

    const arr1 = [1, 2, { age: 25 }];
    const arr2 = arr1.slice(); // 浅拷贝数组
    // 修改表层数据:不影响原值
    arr2[0] = 100;
    console.log(arr1[0]); // 1
    console.log(arr2[0]); // 100
    // 修改深层引用类型:影响原值
    arr2[2].age = 30;
    console.log(arr1[2].age); // 30(原值被修改)
    console.log(arr2[2].age); // 30
    
  • Array.prototype.concat()

    const arr1 = [1, 2, { age: 25 }];
    const arr2 = arr1.concat(); // 浅拷贝
    
  • 扩展运算符 [...arr]

    const arr1 = [1, 2, { age: 25 }];
    const arr2 = [...arr1]; // 浅拷贝
    

(2)对象浅拷贝

  • Object.assign(target, ...sources)

    const obj1 = { name: "张三", info: { age: 25 } };
    const obj2 = Object.assign({}, obj1); // 浅拷贝到空对象
    // 修改表层数据:不影响原值
    obj2.name = "李四";
    console.log(obj1.name); // 张三
    console.log(obj2.name); // 李四
    // 修改深层引用类型:影响原值
    obj2.info.age = 30;
    console.log(obj1.info.age); // 30(原值被修改)
    console.log(obj2.info.age); // 30
    
  • 扩展运算符 {...obj}

    const obj1 = { name: "张三", info: { age: 25 } };
    const obj2 = { ...obj1 }; // 浅拷贝
    

3. 浅拷贝的特点

  • 优点:实现简单、性能开销小,适合仅包含表层数据的引用类型;
  • 缺点:无法独立深层嵌套数据,修改深层数据会造成原数据污染;
  • 适用场景:只需复制表层数据,无需修改深层嵌套内容的场景(如展示数据副本、临时修改表层属性)。

三、深拷贝(Deep Copy):复制所有层级数据

1. 核心定义

深拷贝是指递归复制引用类型的所有层级数据,不仅复制表层属性,还会对深层嵌套的每个引用类型都创建独立的副本,新旧数据完全隔离,修改其中一个不会影响另一个,实现真正意义上的 “复制”。

2. 常见实现方式

(1)JSON 序列化 / 反序列化(简单场景首选)

通过 JSON.stringify() 将对象转为 JSON 字符串,再通过 JSON.parse() 解析为新对象,实现深拷贝。

const obj1 = { name: "张三", info: { age: 25 }, hobbies: ["篮球", "游戏"] };
const obj2 = JSON.parse(JSON.stringify(obj1)); // 深拷贝

// 修改表层数据:不影响原值
obj2.name = "李四";
// 修改深层数据:不影响原值
obj2.info.age = 30;
obj2.hobbies[0] = "足球";

console.log(obj1.name); // 张三
console.log(obj1.info.age); // 25
console.log(obj1.hobbies[0]); // 篮球
console.log(obj2.name); // 李四
console.log(obj2.info.age); // 30
console.log(obj2.hobbies[0]); // 足球

注意:JSON 方式的局限性(无法处理特殊类型)

  • 无法拷贝函数、正则表达式、Date 对象(会转为字符串 / 对象字面量,丢失原有特性);
  • 无法拷贝 Symbol 类型属性、undefined 类型属性(会被忽略);
  • 无法处理循环引用(如 obj.a = obj,会报错)。

(2)手动递归实现(灵活可控,支持特殊类型)

通过递归遍历对象 / 数组的每一层,对原始类型直接赋值,对引用类型创建新副本,可自定义处理特殊类型。

// 深拷贝工具函数
function deepClone(target) {
  // 1. 处理原始类型和 null
  if (typeof target !== "object" || target === null) {
    return target;
  }

  // 2. 处理 Date 对象
  if (target instanceof Date) {
    return new Date(target);
  }

  // 3. 处理 RegExp 对象
  if (target instanceof RegExp) {
    return new RegExp(target.source, target.flags);
  }

  // 4. 处理数组和普通对象(创建新副本)
  const result = Array.isArray(target) ? [] : {};

  // 5. 递归遍历,拷贝所有层级属性
  for (let key in target) {
    // 仅拷贝自身属性,不拷贝原型链属性
    if (target.hasOwnProperty(key)) {
      result[key] = deepClone(target[key]);
    }
  }

  return result;
}

// 测试
const obj1 = {
  name: "张三",
  info: { age: 25 },
  hobbies: ["篮球", "游戏"],
  birth: new Date("1999-01-01"),
  reg: /abc/gi,
  fn: () => console.log("hello")
};
const obj2 = deepClone(obj1);

obj2.info.age = 30;
obj2.birth.setFullYear(2000);
obj2.fn = () => console.log("world");

console.log(obj1.info.age); // 25(不影响原值)
console.log(obj1.birth.getFullYear()); // 1999(不影响原值)
console.log(obj1.fn()); // hello(函数独立)
console.log(obj2.fn()); // world

(3)第三方库(成熟稳定,推荐生产环境)

  • Lodash 库的 _.cloneDeep()(支持所有类型,处理循环引用)

    // 安装:npm i lodash
    const _ = require("lodash");
    
    const obj1 = { name: "张三", info: { age: 25 }, a: obj1 }; // 循环引用
    const obj2 = _.cloneDeep(obj1); // 深拷贝,正常处理循环引用
    
    obj2.info.age = 30;
    console.log(obj1.info.age); // 25
    
  • jQuery 库的 $.extend(true, {}, obj)(true 表示深拷贝)

    const obj1 = { name: "张三", info: { age: 25 } };
    const obj2 = $.extend(true, {}, obj1); // 深拷贝
    

3. 深拷贝的特点

  • 优点:新旧数据完全独立,修改任意一方不会影响另一方,避免数据污染;
  • 缺点:实现复杂(手动递归需处理多种特殊类型)、性能开销大(递归遍历所有层级);
  • 适用场景:需要修改拷贝后的数据,且数据包含深层嵌套引用类型的场景(如表单提交、状态管理、复杂数据处理)。

四、深拷贝 vs 浅拷贝 核心对比

对比维度 浅拷贝(Shallow Copy) 深拷贝(Deep Copy)
拷贝层级 仅拷贝表层(第一层)数据 递归拷贝所有层级数据
引用类型处理 深层嵌套引用类型仅复制内存地址(共享) 深层嵌套引用类型创建独立副本(不共享)
数据独立性 深层数据共享,修改会相互影响 完全独立,修改互不影响
实现难度 简单(原生 API 即可实现) 复杂(需处理特殊类型、循环引用)
性能开销 小(仅遍历表层) 大(递归遍历所有层级)
适用场景 表层数据拷贝、无需修改深层数据 复杂嵌套数据拷贝、需要独立修改数据
常见实现 数组:slice、concat、[...arr];对象:Object.assign、{...obj} JSON.parse (JSON.stringify ())、手动递归、_.cloneDeep ()

五、常见误区

  1. 认为 Object.assign 是深拷贝Object.assign 仅对第一层数据实现值拷贝,深层引用类型仍为引用传递,属于浅拷贝;
  2. JSON 方式能处理所有数据:JSON 序列化无法处理函数、正则、循环引用、Symbol 等类型,仅适用于简单 JSON 数据;
  3. 原始类型需要深浅拷贝:原始类型赋值时直接传递值,不存在引用,无需区分深浅拷贝;
  4. 深拷贝一定优于浅拷贝:深拷贝性能开销大,若数据无深层嵌套,浅拷贝更高效,无需过度使用深拷贝。

总结

  1. 核心区别:是否拷贝深层嵌套的引用类型,决定数据是否独立;
  2. 原始类型无深浅拷贝之分,引用类型才需要区分;
  3. 浅拷贝:简单高效,适合表层数据,推荐 [...arr]/{...obj}/Object.assign
  4. 深拷贝:完全独立,适合复杂嵌套数据,简单场景用 JSON.parse(JSON.stringify()),生产环境推荐 _.cloneDeep()
  5. 选型原则:根据数据结构选择,无需深层独立时优先浅拷贝,避免性能浪费。

面试和算法:常见面试题实现与深度解析

本文将深入探讨前端面试中常见的算法和编程题,提供多种实现方案和性能优化策略,帮助大家全面掌握核心面试技能。

1. 函数柯里化(Currying)

1.1 基础柯里化实现
/**
 * 基础柯里化函数
 * 将多参数函数转换为一系列单参数函数
 */
function curry(fn) {
  return function curried(...args) {
    // 如果参数数量足够, 直接执行原函数
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    }
    // 否则返回一个新函数, 继续收集参数
    return function (...nextArgs) {
      return curried.apply(this, args.concat(nextArgs));
    };
  };
}
// 示例:加法函数柯里化
function add(a, b, c) {
  return a + b + c;
}

const curriedAdd = curry(add);

// 测试
console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6
console.log(curriedAdd(1)(2, 3)); // 6
1.2 高级柯里化实现(支持占位符)
/**
 * 高级柯里化函数, 支持占位符
 */
function advancedCurry(fn) {
  return function curried(...args) {
    // 检查参数是否足够且没有占位符
    const complete =
      args.length >= fn.length &&
      !args.slice(0, fn.length).includes(advancedCurry.placeholder);
    if (complete) {
      return fn.apply(this, args);
    }

    return function (...nextArgs) {
      // 替换占位符
      const combinedArgs = args
        .map((arg) =>
          arg === advancedCurry.placeholder && nextArgs.length
            ? nextArgs.shift()
            : arg
        )
        .concat(nextArgs);

      return curried.apply(this, combinedArgs);
    };
  };
}

// 定义占位符
advancedCurry.placeholder = Symbol("_");
// 示例使用
const curriedMultiply = advancedCurry((a, b, c) => a * b * c);

const _ = advancedCurry.placeholder;

// 测试
console.log(curriedMultiply(2)(3)(4)); // 24
console.log(curriedMultiply(_, 3)(2)(4)); // 24
console.log(curriedMultiply(2, _, 4)(3)); // 24

2. 函数组合(Compose)

2.1 基础函数组合
function compose(...fns) {
  return function(x) {
    return fns.reduceRight((acc, fn) => fn(acc), x);
  };
}

function pipe(...fns) {
  return function(x) {
    return fns.reduce((acc, fn) => fn(acc), x);
  };
}

// 测试
const add1 = x => x + 1;
const multiply2 = x => x * 2;
const square = x => x * x;

const composed = compose(square, multiply2, add1);
console.log(composed(2)); // 36

3. 斐波那契数列优化

3.1 多种实现对比
// 1. 递归(性能差)
function fibonacciRecursive(n) {
  if (n <= 1) return n;
  return fibonacciRecursive(n - 1) + fibonacciRecursive(n - 2);
}

// 2. 记忆化递归
function fibonacciMemo(n, memo = {}) {
  if (n <= 1) return n;
  if (memo[n]) return memo[n];
  memo[n] = fibonacciMemo(n - 1, memo) + fibonacciMemo(n - 2, memo);
  return memo[n];
}

// 3. 动态规划
function fibonacciDP(n) {
  if (n <= 1) return n;
  const dp = [0, 1];
  for (let i = 2; i <= n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];
  }
  return dp[n];
}

// 4. 空间优化
function fibonacciOptimized(n) {
  if (n <= 1) return n;
  let prev = 0, curr = 1;
  for (let i = 2; i <= n; i++) {
    const next = prev + curr;
    prev = curr;
    curr = next;
  }
  return curr;
}

4. 数组去重多种方法

4.1 基础方法
// 1. Set
function uniqueSet(arr) {
  return [...new Set(arr)];
}

// 2. filter + indexOf
function uniqueFilter(arr) {
  return arr.filter((item, index) => arr.indexOf(item) === index);
}

// 3. reduce
function uniqueReduce(arr) {
  return arr.reduce((acc, curr) => {
    if (!acc.includes(curr)) acc.push(curr);
    return acc;
  }, []);
}
4.2 复杂对象去重
function uniqueComplex(arr, keyFn) {
  const seen = new Map();
  const result = [];
  
  for (let item of arr) {
    const key = keyFn ? keyFn(item) : JSON.stringify(item);
    if (!seen.has(key)) {
      seen.set(key, true);
      result.push(item);
    }
  }
  
  return result;
}

// 测试
const users = [
  { id: 1, name: 'Alice' },
  { id: 1, name: 'Alice' },
  { id: 2, name: 'Bob' }
];
console.log(uniqueComplex(users, user => user.id));

5. 深比较(DeepEqual)

function deepEqual(a, b) {
  if (a === b) return true;
  
  if (a == null || b == null || typeof a !== 'object' || typeof b !== 'object') {
    return false;
  }
  
  if (Array.isArray(a) && Array.isArray(b)) {
    if (a.length !== b.length) return false;
    for (let i = 0; i < a.length; i++) {
      if (!deepEqual(a[i], b[i])) return false;
    }
    return true;
  }
  
  const keysA = Object.keys(a);
  const keysB = Object.keys(b);
  
  if (keysA.length !== keysB.length) return false;
  
  for (let key of keysA) {
    if (!keysB.includes(key) || !deepEqual(a[key], b[key])) {
      return false;
    }
  }
  
  return true;
}

6. 防抖与节流

防抖(Debounce) 节流(Throttle)

function debounce(fn, delay) {
  let timer = null;
  return function(...args) {
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

function throttle(fn, interval) {
  let lastTime = 0;
  return function(...args) {
    const now = Date.now();
    if (now - lastTime >= interval) {
      fn.apply(this, args);
      lastTime = now;
    }
  };
}

7. Promise实现

手写 Promise:深入理解 JavaScript 异步编程的核心

class MyPromise {
  constructor(executor) {
    this.state = 'pending';
    this.value = undefined;
    this.reason = undefined;
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];
    
    const resolve = (value) => {
      if (this.state === 'pending') {
        this.state = 'fulfilled';
        this.value = value;
        this.onFulfilledCallbacks.forEach(cb => cb());
      }
    };
    
    const reject = (reason) => {
      if (this.state === 'pending') {
        this.state = 'rejected';
        this.reason = reason;
        this.onRejectedCallbacks.forEach(cb => cb());
      }
    };
    
    try {
      executor(resolve, reject);
    } catch (error) {
      reject(error);
    }
  }
  
  then(onFulfilled, onRejected) {
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : v => v;
    onRejected = typeof onRejected === 'function' ? onRejected : e => { throw e };
    
    const promise2 = new MyPromise((resolve, reject) => {
      const handleFulfilled = () => {
        setTimeout(() => {
          try {
            const x = onFulfilled(this.value);
            this.resolvePromise(promise2, x, resolve, reject);
          } catch (error) {
            reject(error);
          }
        });
      };
      
      const handleRejected = () => {
        setTimeout(() => {
          try {
            const x = onRejected(this.reason);
            this.resolvePromise(promise2, x, resolve, reject);
          } catch (error) {
            reject(error);
          }
        });
      };
      
      if (this.state === 'fulfilled') {
        handleFulfilled();
      } else if (this.state === 'rejected') {
        handleRejected();
      } else {
        this.onFulfilledCallbacks.push(handleFulfilled);
        this.onRejectedCallbacks.push(handleRejected);
      }
    });
    
    return promise2;
  }
  
  resolvePromise(promise2, x, resolve, reject) {
    if (promise2 === x) {
      return reject(new TypeError('循环引用'));
    }
    
    let called = false;
    
    if (x && (typeof x === 'object' || typeof x === 'function')) {
      try {
        const then = x.then;
        if (typeof then === 'function') {
          then.call(
            x,
            y => {
              if (called) return;
              called = true;
              this.resolvePromise(promise2, y, resolve, reject);
            },
            r => {
              if (called) return;
              called = true;
              reject(r);
            }
          );
        } else {
          resolve(x);
        }
      } catch (error) {
        if (called) return;
        called = true;
        reject(error);
      }
    } else {
      resolve(x);
    }
  }
  
  catch(onRejected) {
    return this.then(null, onRejected);
  }
}

8. call、apply、bind实现

JavaScript 核心方法深度解析:手写 call、apply、bind 和 Object.create

Function.prototype.myCall = function(context = window, ...args) {
  const fnKey = Symbol('fn');
  context[fnKey] = this;
  const result = context[fnKey](...args);
  delete context[fnKey];
  return result;
};

Function.prototype.myApply = function(context = window, args = []) {
  const fnKey = Symbol('fn');
  context[fnKey] = this;
  const result = context[fnKey](...args);
  delete context[fnKey];
  return result;
};

Function.prototype.myBind = function(context = window, ...bindArgs) {
  const self = this;
  return function(...callArgs) {
    return self.apply(context, [...bindArgs, ...callArgs]);
  };
};

9. 事件总线(EventEmitter)

手写 EventEmitter:深入理解发布订阅模式

class EventEmitter {
  constructor() {
    this.events = new Map();
  }
  
  on(event, listener) {
    if (!this.events.has(event)) {
      this.events.set(event, []);
    }
    this.events.get(event).push(listener);
  }
  
  off(event, listener) {
    if (!this.events.has(event)) return;
    const listeners = this.events.get(event);
    const index = listeners.indexOf(listener);
    if (index > -1) listeners.splice(index, 1);
  }
  
  emit(event, ...args) {
    if (!this.events.has(event)) return false;
    this.events.get(event).forEach(listener => listener.apply(this, args));
    return true;
  }
  
  once(event, listener) {
    const onceWrapper = (...args) => {
      listener.apply(this, args);
      this.off(event, onceWrapper);
    };
    this.on(event, onceWrapper);
  }
}

10. LRU缓存

JavaScript性能与优化:手写实现关键优化技术 JavaScript 性能与优化:数据结构和算法

class LRUCache {
  constructor(capacity) {
    this.capacity = capacity;
    this.cache = new Map();
  }
  
  get(key) {
    if (!this.cache.has(key)) return -1;
    const value = this.cache.get(key);
    this.cache.delete(key);
    this.cache.set(key, value);
    return value;
  }
  
  put(key, value) {
    if (this.cache.has(key)) {
      this.cache.delete(key);
    } else if (this.cache.size >= this.capacity) {
      const firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }
    this.cache.set(key, value);
  }
}

11. 快速排序

JavaScript 数组原生方法手写实现 JavaScript 性能与优化:数据结构和算法

function quickSort(arr) {
  if (arr.length <= 1) return arr;
  
  const pivotIndex = Math.floor(arr.length / 2);
  const pivot = arr[pivotIndex];
  const left = [];
  const right = [];
  
  for (let i = 0; i < arr.length; i++) {
    if (i === pivotIndex) continue;
    if (arr[i] < pivot) {
      left.push(arr[i]);
    } else {
      right.push(arr[i]);
    }
  }
  
  return [...quickSort(left), pivot, ...quickSort(right)];
}

12. 二分查找

JavaScript 性能与优化:数据结构和算法

function binarySearch(arr, target) {
  let left = 0, right = arr.length - 1;
  
  while (left <= right) {
    const mid = Math.floor((left + right) / 2);
    if (arr[mid] === target) return mid;
    if (arr[mid] < target) {
      left = mid + 1;
    } else {
      right = mid - 1;
    }
  }
  
  return -1;
}

总结

本文涵盖了前端面试中常见的算法和编程题,包括函数柯里化、函数组合、斐波那契数列、数组去重、深比较、防抖节流、Promise实现等核心知识点。掌握这些内容有助于提升编程能力和面试表现。

关键要点:

  1. 函数柯里化: 理解函数式编程思想,掌握基础实现和高级功能
  2. 函数组合: 学会构建可复用的函数管道,支持同步和异步操作
  3. 算法优化: 掌握递归优化、动态规划、空间优化等技巧
  4. 数据处理: 了解不同去重方法的适用场景和性能差异
  5. 深度比较: 处理复杂对象的比较,包括循环引用和特殊类型
  6. 异步控制: 实现防抖和节流,优化高频事件处理
  7. Promise实现: 深入理解异步编程模型
  8. 原生方法实现: 掌握call、apply、bind的内部原理
  9. 设计模式: 实现事件总线,理解发布-订阅模式

这些知识点不仅是面试的常见考点,也是实际开发中的重要技能。建议大家不仅要理解代码实现,更要掌握背后的设计思想和适用场景。

一文搞懂 Tailwind CSS v4 主题变量映射背后的原理

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。

如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。

在 Tailwind CSS v4 中,theme variables(设计变量或主题变量)与页面样式之间的映射关系是一个值得深入理解的核心机制。本文将从底层原理、生成阶段、作用域和命名空间等维度,详细解析变量是如何一步步与页面和 class 建立起映射关系的。

总体架构概览

Tailwind CSS v4 引入了一个核心变化:design tokens(设计变量)默认以 CSS 自定义属性(CSS variables)的方式暴露,并结合新的 CSS-first 配置 DSL(@theme 指令在 CSS 中)来定义变量。这些 design tokens 不仅仅是值的存储,还直接决定哪些 utility classesresponsive 变体或 variant 等会被生成。

整个映射过程可以概括为以下四个步骤:

  1. 在 CSS 文件中,使用 @theme 定义主题变量(命名空间变量)
  2. Tailwind 构建阶段读取这些变量,生成对应的 CSS 自定义属性(在 :root 或主题作用域中)以及与这些变量关联的 utility classesvariants
  3. 在页面(HTML 或模板)中使用这些类名,或者通过 var(--theme-variable) 方式直接访问变量
  4. 如果需要主题切换、暗黑模式、亮色模式或其他主题,则在不同作用域中重写变量值,使同样的类名在 UI 上呈现不同的视觉效果

详细步骤拆解

下面我们将深入解析从代码编写到页面效果呈现的完整流程,揭示变量是如何被映射的。

步骤 1:定义 theme 变量(design tokens)

在 CSS 文件中(通常是一个入口文件,如 app.css),你可以这样定义主题变量:

@import "tailwindcss";

@theme {
  --color-primary: #3490dc;
  --color-secondary: #ff8800;
  --breakpoint-lg: 1024px;
  --spacing-base: 1rem;
  /* 更多 theme 变量 ... */
}

@theme { ... } 是 Tailwind v4 中用来定义那些会影响实用类的 design token 的指令。命名规则中通常包含命名空间,例如 --color-*--font-*--spacing-*--breakpoint-* 等。每个 namespace 通常对应一种或一组 utilityvariant 的生成逻辑。

需要注意的是,这些变量的定义必须在顶层(非嵌套在选择器内、非嵌套在 media query 内)才能被 Tailwind 正确识别为 theme variables

步骤 2:识别并生成 CSS 自定义属性

在编译阶段,Tailwind 会把你在 @theme 中定义的变量转换成 CSS 自定义属性(custom properties),输出通常在 :root 或主题作用域中。也就是说,这些变量会变成浏览器可识别的 --color-primary--spacing-base 等。

简化后的生成示例可能是:

:root {
  --color-primary: #3490dc;
  --color-secondary: #ff8800;
  --breakpoint-lg: 1024px;
  --spacing-base: 1rem;
  /* 其他变量 ... */
}

步骤 3:根据命名空间生成对应的 utility classes 和 variants

这一步是整个映射机制的关键:Tailwind 会根据那些 namespace 变量来决定哪些实用类需要生成。换句话说,变量不仅只是值,它们也决定了哪些 class 是存在的。

举几个具体的例子:

  • 如果你定义了 --color-primary(在 --color-* 命名空间中),那么 Tailwind 会生成 .bg-primary.text-primary.border-primary.fill-primary 等与颜色相关的 utility
  • 如果你定义了 --breakpoint-lg,那么 .lg: 这个 responsive variant 会相对于这个断点存在。比如在 HTML 中你可以写 lg:text-xl,只有在视口宽度大于等于 --breakpoint-lg 时才会应用该样式
  • 如果你定义了 --spacing-basespacing 相关的类(如 p-<n>m-<n>gap-<n> 等)就会基于这个变量(spacing scale)来生成。Tailwind 默认会生成基于 spacing scalemarginpaddinggapwidth/height 等类,这些生成会参考 theme 变量

所以 namespaceutilities / variants 是映射的规则。具体映射关系如下表所示:

命名空间(namespace) 实用类 / variant 类型可能的映射
--color-* 背景色 background、文本颜色 text、边框色 border、填充 fill/stroke 等颜色相关的类
--font-* font-family utilities,例如 font-sansfont-serif
--text-*--text-size-* font-size utilities,如 text-xl
--spacing-* margin / padding / width / height / gap / inset 等与大小、间距相关的实用类
--breakpoint-* 响应式变体(breakpoints),如 sm:...md:...lg:...

步骤 4:CSS 输出和类的形式

在编译输出的 CSS 文件中,会有两部分内容:

  • :root 或主题基础作用域下定义所有被识别的 theme variablesCSS 自定义属性
  • utilities(以及 base / components 层)中,Tailwind 为每个被 theme-variable 驱动的实用类生成对应的 CSS 规则,这些规则使用变量值或者直接映射变量

例如,如果定义了 --color-primary,会生成 .bg-primary { background-color: var(--color-primary); } 或等效的方式。也可能生成 opacity 可变的版本(如 .bg-primary/50)等。

另外,类似 breakpoints 会在 media query 中生成对应 variantclass。比如定义 --breakpoint-lg,那么在 @media (min-width: var(--breakpoint-lg)) { ... } 中会输出 .lg:bg-primary.lg:text-xl 等类。

步骤 5:页面中的使用方式

在页面或模板中,开发者使用 Tailwind utility class 名称。例如:

<div class="bg-primary text-secondary p-4 lg:text-xl">Hello</div>

解析这行代码:

  • bg-primary 会应用 background-color: var(--color-primary)
  • text-secondary 会应用 color: var(--color-secondary)
  • p-4 会应用 padding: calc(var(--spacing-base) * 4) 或类似计算(取决于 spacing 命名空间的定义方式)
  • lg:text-xl 会在大于等于 --breakpoint-lg 的视口上应用 text-xl

步骤 6:主题切换和作用域变量重写

因为主题变量是 CSS 自定义属性,你可以在不同作用域或基于某些属性、数据属性、暗黑模式、亮色模式等重写这些变量的值,从而用同样的 utility 类名产生不同的视觉样式。

示例:

/* 默认 / light 模式 */
@theme {
  --color-primary: #3490dc;
  --color-secondary: #ff8800;
}

/* 暗黑模式或其他 theme 作用域 */
[data-theme="dark"] {
  --color-primary: #0a2239;
  --color-secondary: #ff5500;
}

页面中使用 .bg-primary 的地方会根据 data-theme 的值决定实际背景色。这样类名不变,但变量值会动态变化。

其他细节和边缘情况

inline 选项:如果你定义 @theme inline { ... },则某些 utility 类会直接写入变量值而不是引用变量,例如 .font-sans { font-family: Inter, sans-serif; } 而不是 font-family: var(--font-sans)。这个主要影响变量引用的方式和层次。

静态生成 vs 动态按需生成:Tailwind 会扫描你项目中用到的 class,然后只生成这些所需的 utilities 和对应的媒体查询 / variants,从而减小最终 CSS 大小。变量虽然都在 :root(或主题作用域)定义,但 utility 类如果没有被使用,不会生成对应规则。

arbitrary values:有时候你可能要用一个不在 theme 中的值,这种情况下可以使用 [...] 的语法,例如 bg-[#abcdef] 或者 w-[calc(var(--spacing-base) * 3 + 1rem)] 等,这样会跳过 theme 类的生成逻辑,直接生成或内联这些值。

流程图

为了帮助理解,下面是一个流程图,展示从定义变量到页面生效的完整流程:

20251222142708

设计动机和优势

理解这个映射流程之后,你会明白 Tailwind v4 这样设计的动机与优势:

统一定义和 CSS-first:将设计变量(design tokens)定义在 CSS 中,使整个样式系统更接近 CSS 原生工作流程,无需 JS 配置累赘。

变量暴露和运行时可用性:变量是原生 CSS custom properties,可以在运行时被引用、覆盖、修改(例如主题切换、样式插值、JS 动态样式等),不仅仅在编译阶段。

按需生成:只生成你实际用到的 class,避免生成一大堆冗余 CSS。媒体查询和变体也只有在需要时生成,这样最终 bundle 文件更小。

灵活性与可扩展性:你可以扩展命名空间,新增变量,重写默认主题,实现多个主题,实现暗黑模式等。并且 arbitrary values 给了例外情况下的自由度。

总结

变量与页面建立映射的过程可以总结为:

  1. 定义 theme 变量(design tokens
  2. Tailwind 根据这些变量创建 CSS 自定义属性 + utility classes / variants
  3. 页面通过 class 使用这些 utilities 或直接用 var(...) 引用变量
  4. 若重写变量或在不同作用域里变量的值不同,可实现主题切换等行为

通过这种机制,Tailwind CSS v4 实现了设计系统与样式输出的无缝衔接,既保持了灵活性,又提供了强大的主题定制能力。

从 0 搭建 React 待办应用:状态管理、副作用与双向绑定模拟

React 作为前端主流框架,其单向数据流 组件化 状态驱动视图的设计理念,看似抽象却能通过一个简单的 TodoList 案例彻底吃透。本文不只是 “解释代码”,而是从设计初衷、底层逻辑、实际价值三个维度,拆解 useState useEffect、受控组件模拟双向绑定、父子通信等核心知识点,让你不仅 “会用”,更 “懂为什么这么用”。

一、案例整体架构:先懂 “拆分逻辑”,再看 “代码细节”

在动手写代码前,React 开发的第一步是组件拆分—— 遵循单一职责原则,把复杂页面拆成独立、可复用的小组件,这是 React 组件化思想的核心。

本次 TodoList 的组件拆分如下:

组件名 核心职责 核心交互
App(根组件) 全局状态管理 + 核心逻辑封装 定义新增 / 删除 / 切换待办、数据持久化等方法
TodoInput 待办输入 + 提交 收集用户输入,触发 “新增待办” 逻辑
TodoList 待办列表渲染 展示待办项,转发 “删除 / 切换完成状态” 事件
TodoStats 待办数据统计 展示总数 / 已完成 / 未完成数,触发 “清除已完成” 逻辑

这种拆分的核心价值:每个组件只做一件事,便于维护、复用和调试(比如后续想改输入框样式,只动 TodoInput 即可,不影响列表和统计逻辑)。

二、核心 API 深度拆解:不止 “会用”,更懂 “为什么这么设计”

1. useState:React 状态管理的 “灵魂”

React 中所有可变数据都必须通过**状态(State)**管理,而 useState 是最基础、最核心的状态钩子 —— 它解决了 “函数组件无法拥有自身状态” 的问题,也是 “状态驱动视图” 的核心载体。

(1)基础原理:为什么需要 useState?

纯函数组件本身是 “无状态” 的(执行完就销毁,无法保存数据),而用户交互(比如输入待办、切换完成状态)必然需要 “保存可变数据”。useState 本质是给函数组件提供了持久化的状态存储空间,且这个存储空间和组件渲染周期绑定:

  • 状态更新 → 组件重新渲染 → 视图同步更新;
  • 状态不更新 → 组件不会重复渲染,保证性能。

(2)两种初始化方式:普通初始化 vs 惰性初始化

// 方式1:普通初始化(适合简单、无计算的初始值)
const [count, setCount] = useState(0);

// 方式2:惰性初始化(重点! TodoList 中用的就是这种)
const [todos, setTodos] = useState(() => {
  const saved = localStorage.getItem('todos');
  return saved ? JSON.parse(saved) : [];
});

关键区别与设计初衷

  • 普通初始化:useState(初始值) 中,初始值表达式会在组件每次渲染时都执行(哪怕状态没变化);
  • 惰性初始化:useState(() => { ... }) 中,传入的函数仅在**组件首次渲染*时执行一次,后续渲染不会再跑。

TodoList 中用惰性初始化的核心原因:localStorage.getItem('todos') 是浏览器本地读取操作,虽然开销小,但如果放在普通初始化里,每次组件渲染(比如新增 / 删除待办)都会重复读取本地存储,完全没必要;而惰性初始化只执行一次,既拿到了初始数据,又避免了性能浪费 —— 这是 React 性能优化的 “小细节”,也是理解 useState 设计的关键。

(3)状态更新的 “不可变原则”:为什么必须返回新值?

React 规定:状态是只读的,修改状态必须返回新值,不能直接修改原状态。比如这里的 “新增待办” 逻辑:

const addTodo = (text) => {
  // 错误写法:直接修改原数组(React 无法检测到状态变化,视图不更新)
  // todos.push({ id: Date.now(), text, completed: false });
  // setTodos(todos);

  // 正确写法:解构原数组 + 新增项,返回新数组
  setTodos([...todos, {
    id: Date.now(),
    text,
    completed: false
  }]);
};

底层逻辑:React 判断状态是否变化的依据是引用是否改变。数组 / 对象是引用类型,直接修改原数组(todos.push),数组的引用没变化,React 会认为 “状态没改”,因此不会触发组件重新渲染;而通过 [...todos] 解构生成新数组,引用变了,React 才能检测到状态变化,进而更新视图。

这也是 React “单向数据流” 的核心体现:状态更新是 “不可变” 的,每一次状态变化都会生成新值,便于追踪数据流转(比如调试时能清晰看到每次状态更新的前后值)。

2. useEffect:副作用处理的 “专属管家”

React 组件的核心职责是根据状态渲染视图,而像 “读取本地存储、发送网络请求、绑定事件监听、修改 DOM” 这类不直接参与渲染,但又必须执行的操作,统称为 “副作用(Side Effect)”。useEffect 是 React 专门为处理副作用设计的钩子,替代了类组件中 componentDidMount componentDidUpdate componentWillUnmount 等生命周期方法,且逻辑更集中。

(1)核心语法与执行机制

useEffect(() => {
  // 副作用逻辑:比如保存数据到本地存储
  localStorage.setItem('todos', JSON.stringify(todos));

  // 可选的清理函数(比如取消事件监听、清除定时器)
  return () => {
    // 组件卸载/依赖变化前执行
  };
}, [todos]); // 依赖数组:决定副作用的执行时机

执行时机的深度解析

  • 依赖数组为空 []:仅在组件首次渲染完成后执行一次(对应类组件 componentDidMount);
  • 依赖数组有值 [todos]:组件首次渲染执行 + 每次依赖项(todos)变化后执行(对应 componentDidMount + componentDidUpdate);
  • 无依赖数组:组件每次渲染完成后都执行(极少用,易导致性能问题);
  • 清理函数:组件卸载前 / 下一次副作用执行前触发(比如监听窗口大小变化后,卸载组件时要取消监听,避免内存泄漏)。

(2)在 TodoList 中的核心应用:数据持久化

代码中,useEffect 用来将 todos 同步到 localStorage,这是前端 “数据持久化” 的经典场景,我们拆解其价值:

useEffect(() => {
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
  • 为什么 localStorage 只能存字符串? localStorage 是浏览器提供的本地存储 API,其底层设计只支持字符串键值对存储,因此存储数组 / 对象时,必须用 JSON.stringify 转为字符串;读取时用 JSON.parse 转回原数据类型,这是前端本地存储的通用规则。

(3)useEffect 在这里的核心价值(为什么非它不可)

1. 精准触发:只在需要时执行,保证性能

useEffect 的第二个参数(依赖数组 [todos])是关键:

  • 组件首次渲染时,执行一次(把初始的 todos 保存到本地);
  • 只有 todos 发生实际变化时,才会再次执行(新增 / 删除 / 切换状态 / 清除已完成,只要 todos 变了,就同步保存);
  • todos 没变化时(比如组件因其他状态重新渲染),完全不执行,避免无效操作。

对比 “写在组件顶层” 的无差别执行,useEffect 实现了 “按需执行”,既保证数据同步,又不浪费性能。

2. 时机正确:拿到最新的状态,避免数据不一致

useEffect 的执行时机是「组件渲染完成后」—— 也就是说,当 useEffect 里的代码执行时,setTodos 已经完成了状态更新,todos 一定是最新的。

比如新增待办时:

  1. 调用 addTodo → 执行 setTodos → 组件重新渲染(todos 变为新值);
  2. 渲染完成后,useEffect 检测到 todos 变化 → 执行保存逻辑 → 拿到的是最新的 todos

这就避免了 “异步更新导致保存旧值” 的问题,保证本地存储的数据和组件状态完全一致。

3. 逻辑聚合:一处监听,全场景生效

不管是新增、删除、切换状态、清除已完成,只要最终导致 todos 变化,useEffect 都会自动触发保存 —— 无需在每个修改 todos 的函数里重复写保存逻辑,代码简洁、易维护,后续新增修改 todos 的逻辑(比如批量修改),完全不用动保存代码,天然符合 “开闭原则”。

(4)useEffect 的设计价值:分离 “渲染逻辑” 与 “副作用逻辑”

React 追求 “组件核心逻辑纯净”—— 组件顶层只关注 “根据状态渲染什么”,副作用全部交给 useEffect 处理,这样:

  • 代码结构更清晰:渲染和副作用分离,一眼能区分 “视图相关” 和 “非视图相关” 逻辑;
  • 便于调试:副作用的执行时机由依赖数组明确控制,能精准定位 “什么时候执行、为什么执行”;
  • 避免内存泄漏:通过清理函数可优雅处理 “组件卸载后仍执行副作用” 的问题(比如请求数据时组件卸载了,清理函数可取消请求)。

3. 受控组件:模拟双向绑定的底层逻辑

Vue 中用 v-model 就能实现 “表单值 ↔ 数据” 的双向绑定,但 React 没有内置的双向绑定语法 —— 不是 “做不到”,而是 React 坚持单向数据流,通过 “受控组件” 手动模拟双向绑定,虽然代码多了几行,但能完全掌控数据流转。

(1)双向绑定的本质:视图 ↔ 数据同步

不管是 Vue 的 v-model 还是 React 的受控组件,双向绑定的核心是两件事:

  1. 数据 → 视图:数据(状态)变化,视图(输入框)自动更新;
  2. 视图 → 数据:视图(用户输入)变化,数据(状态)自动更新。

(2)React 受控组件的实现:拆解每一步

以 TodoInput 组件为例,逐行解析双向绑定的实现逻辑:

const TodoInput = ({ onAdd }) => {
  // 步骤1:定义状态存储输入框值(数据层)
  const [inputValue, setInputValue] = useState('');

  // 步骤2:处理表单提交
  const handleSubmit = (e) => {
    // 关键:阻止表单默认提交行为
    e.preventDefault();
    // 输入内容校验:去除首尾空格,避免空提交
    const text = inputValue.trim();
    if (!text) return;
    // 步骤3:将输入内容传给父组件(父子通信)
    onAdd(text);
    // 步骤4:清空输入框(修改状态 → 视图清空)
    setInputValue('');
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        // 核心1数据视图状态控制输入框显示value={inputValue}
        // 核心2视图数据输入变化同步更新状态onChange={e => setInputValue(e.target.value)}
        placeholder="请输入待办事项..."
      />
      <button type="submit">Add</button>
    </form>
  );
};

逐点深度解析

  • 数据 → 视图value={inputValue} 是 “单向绑定” 的核心 —— 输入框显示的内容完全由 inputValue 状态决定,而非 DOM 自身的 value。比如执行 setInputValue('')inputValue 变为空,输入框就会立刻清空,这是 “状态驱动视图” 的体现。
  • 视图 → 数据onChange 事件监听输入框的每一次字符变化,e.target.value 是输入框当前的 DOM 取值,通过 setInputValue 将其同步到 inputValue 状态 —— 这一步是 “手动补全” 双向绑定的反向流程,也是 React 与 Vue 的核心区别(Vue 把这一步封装成了 v-model,React 让开发者手动控制,更灵活)。
  • e.preventDefault() :表单的默认行为是 “提交并刷新页面”,而 React 是单页应用,刷新页面会导致所有状态丢失,因此必须阻止这个默认行为 —— 这是前端开发的通用知识点,也是 React 处理表单的 “必做步骤”。
  • 为什么用 form + onSubmit 而非 button + onClick除了点击按钮提交,用户在输入框按回车键也能触发 onSubmit,而单纯的 onClick 无法响应回车提交,这是语义化 + 用户体验的双重考量。

(3)受控组件的核心优势:完全可控

相比 Vue 的 v-model 黑盒封装,React 受控组件的 “手动操作” 带来了两个核心价值:

  • 可校验性:在 onChange 或 handleSubmit 中可随时对输入内容做校验(比如禁止输入特殊字符、限制长度、去除空格),比如在代码中 inputValue.trim() 就是简单的校验,若需要更复杂的校验(比如手机号格式),可直接在这一步处理;
  • 可追溯性:输入框的每一次值变化都必须通过 setInputValue 触发,在调试工具中能清晰看到 inputValue 的每一次更新记录,便于定位 “输入异常” 问题(比如输入框值不变,可直接查 setInputValue 是否执行)。

4. 父子组件通信:单向数据流的极致体现

React 的 “单向数据流” 不是 “限制”,而是 “保障”—— 数据只能从父组件通过 props 流向子组件,子组件不能直接修改父组件的状态,只能通过父组件传递的回调函数 “通知” 父组件修改状态。这种设计让数据流转路径清晰,避免了 “多个组件随意修改数据导致的混乱”。

(1)通信流程:以 “清除已完成任务” 为例

  1. 父组件(App) :定义状态修改逻辑 + 传递回调函数
// 步骤1:父组件定义修改状态的核心逻辑
const clearCompleted = () => {
  setTodos(todos.filter(todo => !todo.completed));
};

// 步骤2:通过 props 将回调函数传递给子组件
<TodoStats 
  total={todos.length}
  completed={completedCount}
  active={activeCount}
  onClearCompleted={clearCompleted} // 传递回调
/>
  1. 子组件(TodoStats) :接收回调函数 + 触发回调
const TodoStats = ({ total, completed, active, onClearCompleted }) => {
  return (
    <div>
      <p>Total: {total}</p>
      <p>Completed: {completed}</p>
      <p>Active: {active}</p>
      {/* 条件渲染:有已完成任务才显示按钮 */}
      {completed > 0 && (
        <button onClick={onClearCompleted} className="clear-btn">
          清除已完成任务
        </button>
      )}
    </div>
  );
};

深度解析

  • 子组件 TodoStats 只负责 “展示数据 + 触发交互”,不关心 “清除已完成任务” 的具体逻辑 —— 哪怕后续修改清除逻辑(比如加确认弹窗),只需改父组件的 clearCompleted,子组件完全不用动,符合 “开闭原则”。
  • 回调函数是 “子组件通知父组件” 的唯一方式:子组件无法直接访问父组件的 todos 状态,也不能直接调用 setTodos,只能通过父组件传递的 onClearCompleted 回调,触发父组件的状态修改逻辑 —— 这就是 “单向数据流”:数据向下传(父→子),事件向上传(子→父),所有状态修改都集中在父组件,便于追踪和调试。

(2)props 的本质:只读的 “数据桥梁” (后面会单独来讲)

props 是父子组件通信的唯一桥梁,但有一个核心规则:子组件不能修改 props。比如 TodoStats 接收的 completed total 等 props,子组件只能读取,不能修改 —— 因为 props 是父组件状态的 “快照”,修改 props 会导致数据源头混乱(比如子组件改了 completed,父组件的 completedCount 却没变化,数据不一致)。

image.png

三、核心设计思想:从 TodoList 看 React 的底层逻辑

通过这个 TodoList 案例,我们能提炼出 React 最核心的 4 个设计思想,这也是理解 React 的关键:

1. 状态驱动视图

React 中 “视图是什么样” 完全由 “状态是什么样” 决定,没有 “手动操作 DOM” 的场景(比如不用 document.getElementById 改输入框值,不用 appendChild 加待办项)。所有视图变化,都是先修改状态,再由 React 自动更新 DOM—— 这避免了手动操作 DOM 的繁琐和易出错,也让代码更易维护(只需关注状态变化,不用关注 DOM 变化)。

2. 单向数据流

数据只有一个流向:父组件 → 子组件,状态只有一个修改入口:定义状态的组件(比如 todos 定义在 App,只有 App 能改,子组件只能通过回调通知 App 改)。这种设计让数据流转 “可预测”—— 不管项目多复杂,都能顺着 props 找到数据的源头,顺着回调找到状态修改的地方。

3. 组件化与单一职责

每个组件只做一件事:TodoInput 只处理输入,TodoList 只渲染列表,TodoStats 只展示统计。这种拆分让组件 “高内聚、低耦合”:

  • 高内聚:组件内部逻辑围绕核心职责展开,不掺杂其他功能;
  • 低耦合:组件之间通过 props 通信,修改一个组件不会影响其他组件。

4. 副作用与渲染分离

useEffect 将 “副作用逻辑”(比如本地存储)与 “渲染逻辑”(比如展示待办列表)分离,让组件的核心逻辑(根据状态渲染视图)保持 “纯净”—— 纯净的组件逻辑更易测试、更易复用,这也是 React 推崇的 “函数式编程” 思想的体现。

四、总结:从 TodoList 到 React 核心能力

这个看似简单的 TodoList,实则涵盖了 React 日常开发的核心知识点:

  • useState 实现状态管理,理解 “不可变更新” 和 “惰性初始化”;
  • useEffect 处理副作用,理解 “依赖数组” 和 “数据持久化”;
  • 受控组件模拟双向绑定,理解 “状态驱动视图” 和 “单向数据流”;
  • 父子组件通信,理解 props 的 “只读特性” 和回调函数的作用。

从零实现前端监控告警系统:SMTP + Node.js + 个人邮箱 完整免费方案

本文将详细介绍如何为前端监控平台设计并实现一套完整的邮件告警系统,包括架构设计、核心原理、代码实现和最佳实践。

一、为什么需要告警系统?

在前端监控平台中,我们通常会采集大量的错误和性能数据。但如果只是被动地等待开发者登录 Dashboard 查看,很多问题可能已经影响了大量用户。

告警系统的核心价值:

  • 🚨 实时感知:错误发生时第一时间通知相关人员
  • 🎯 精准触达:根据规则过滤,避免告警轰炸
  • 快速响应:缩短问题发现到修复的时间窗口

二、整体架构设计

┌─────────────────────────────────────────────────────────────────┐
│                        前端应用                                  │
│                    (SDK 错误采集)                                │
└─────────────────────┬───────────────────────────────────────────┘
                      │ HTTP POST /api/report
                      ▼
┌─────────────────────────────────────────────────────────────────┐
│                      Server 层                                   │
│  ┌─────────────┐   ┌─────────────┐   ┌─────────────┐           │
│  │  数据接收    │──▶│  错误聚合   │──▶│  告警检查   │           │
│  │  (report)   │   │ (fingerprint)│   │  (rules)   │           │
│  └─────────────┘   └─────────────┘   └──────┬──────┘           │
│                                              │                   │
│                                              ▼                   │
│                                     ┌─────────────┐             │
│                                     │  规则评估   │             │
│                                     │ (evaluate)  │             │
│                                     └──────┬──────┘             │
│                                              │ 触发              │
│                                              ▼                   │
│  ┌─────────────┐   ┌─────────────┐   ┌─────────────┐           │
│  │  告警历史   │◀──│  邮件发送   │◀──│  冷却检查   │           │
│  │  (history)  │   │   (SMTP)    │   │ (cooldown)  │           │
│  └─────────────┘   └─────────────┘   └─────────────┘           │
└─────────────────────────────────────────────────────────────────┘
                      │
                      ▼ SMTP
┌─────────────────────────────────────────────────────────────────┐
│                    邮件服务器                                    │
│              (QQ邮箱/163/企业邮箱)                               │
└─────────────────────────────────────────────────────────────────┘
                      │
                      ▼ 📧
                  开发者邮箱

三、核心模块设计

3.1 告警规则模型

告警规则是整个系统的核心,定义了"什么情况下触发告警":

interface AlertRule {
  id: number;
  dsn: string;           // 项目标识
  name: string;          // 规则名称
  type: AlertType;       // 告警类型
  enabled: boolean;      // 是否启用
  threshold?: number;    // 阈值
  timeWindow?: number;   // 时间窗口(分钟)
  recipients: string[];  // 收件人列表
  cooldown: number;      // 冷却时间(分钟)
}

type AlertType = 
  | 'new_error'        // 新错误首次出现
  | 'error_threshold'  // 错误累计次数超过阈值
  | 'error_spike';     // 时间窗口内错误激增

三种告警类型的适用场景:

类型 场景 示例
new_error 捕获未知错误 新上线功能出现 bug
error_threshold 监控已知问题 某接口错误超过 100 次
error_spike 检测异常波动 5 分钟内错误数突增

3.2 冷却机制

为了避免同一个错误短时间内重复告警(告警轰炸),我们引入了冷却机制:

// 内存缓存:记录最近告警时间
const alertCooldowns = new Map<string, number>();

function shouldTrigger(rule: AlertRule, fingerprint: string): boolean {
  const cooldownKey = `${rule.id}-${fingerprint}`;
  const lastAlert = alertCooldowns.get(cooldownKey);
  
  // 检查是否在冷却期内
  if (lastAlert && Date.now() - lastAlert < rule.cooldown * 60 * 1000) {
    return false; // 冷却中,不触发
  }
  
  return true;
}

// 触发告警后更新冷却时间
function updateCooldown(rule: AlertRule, fingerprint: string) {
  const cooldownKey = `${rule.id}-${fingerprint}`;
  alertCooldowns.set(cooldownKey, Date.now());
}

冷却机制的关键点:

  1. 按规则+错误指纹组合作为冷却 key,而不是全局冷却
  2. 使用内存 Map 存储,重启后冷却状态重置(可接受)
  3. 冷却时间可配置,建议默认 30 分钟

3.3 规则评估引擎

规则评估是告警系统的"大脑",决定是否触发告警:

async function evaluateRule(rule: AlertRule, errorData: ErrorData): Promise<boolean> {
  // 1. 先检查冷却
  if (!shouldTrigger(rule, errorData.fingerprint)) {
    return false;
  }

  // 2. 根据规则类型评估
  switch (rule.type) {
    case 'new_error':
      // 新错误:检查是否首次出现
      return errorData.isNew;

    case 'error_threshold':
      // 阈值:检查累计次数
      return errorData.count >= rule.threshold;

    case 'error_spike': {
      // 激增:查询时间窗口内的错误数
      const windowStart = Date.now() - rule.timeWindow * 60 * 1000;
      const recentCount = await getErrorCountSince(
        errorData.dsn, 
        errorData.fingerprint, 
        windowStart
      );
      return recentCount >= rule.threshold;
    }

    default:
      return false;
  }
}

四、邮件服务实现

4.1 Nodemailer 配置

使用 nodemailer 库发送邮件,支持主流 SMTP 服务:

import nodemailer, { Transporter } from 'nodemailer';

let transporter: Transporter | null = null;

function initEmailService(config: EmailConfig) {
  transporter = nodemailer.createTransport({
    host: config.host,      // smtp.qq.com
    port: config.port,      // 465
    secure: true,           // 使用 SSL
    auth: {
      user: config.user,    // 邮箱账号
      pass: config.pass     // 授权码(不是登录密码!)
    }
  });
}

常见 SMTP 配置:

服务商 Host Port 备注
QQ邮箱 smtp.qq.com 465 需开启 SMTP 服务,使用授权码
163邮箱 smtp.163.com 465 需开启 SMTP 服务
Gmail smtp.gmail.com 587 需开启两步验证,使用应用密码
企业微信 smtp.exmail.qq.com 465 企业邮箱

4.2 邮件模板设计

告警邮件需要清晰展示关键信息:

image.png

image.png邮件设计要点:

  1. 使用内联样式(邮件客户端不支持外部 CSS)
  2. 关键信息突出显示(错误消息、次数)
  3. 提供快捷操作入口(查看详情按钮)
  4. 移动端适配(响应式布局)

4.3 发送邮件

async function sendAlertEmail(data: AlertEmailData): Promise<boolean> {
  if (!transporter) {
    console.warn('[Email] Service not initialized');
    return false;
  }

  try {
    const info = await transporter.sendMail({
      from: '"Sentinel 监控" <monitor@example.com>',
      to: data.recipients.join(', '),
      subject: `🚨 [${data.ruleName}] ${data.errorMessage.slice(0, 50)}`,
      html: generateAlertEmailHtml(data)
    });
    
    console.log('[Email] Sent:', info.messageId);
    return true;
  } catch (error) {
    console.error('[Email] Failed:', error);
    return false;
  }
}

五、完整告警流程

5.1 错误上报时触发检查

在错误数据入库后,异步检查告警规则:

// routes/report.ts
async function saveErrorEvent(dsn: string, event: ErrorEvent) {
  const { fingerprint } = generateFingerprint(event);
  
  // 1. 检查是否新错误
  const existing = await db.query(
    'SELECT id, count FROM errors WHERE fingerprint = $1',
    [fingerprint]
  );
  const isNew = existing.rows.length === 0;
  const count = isNew ? 1 : existing.rows[0].count + 1;
  
  // 2. 保存/更新错误记录
  if (isNew) {
    await db.query('INSERT INTO errors ...', [...]);
  } else {
    await db.query('UPDATE errors SET count = $1 ...', [count]);
  }
  
  // 3. 异步检查告警(不阻塞响应)
  checkAndTriggerAlerts({
    dsn,
    type: event.type,
    message: event.message,
    fingerprint,
    url: event.url,
    isNew,
    count
  }).catch(err => console.error('[Alert] Check failed:', err));
}

5.2 告警检查主流程

async function checkAndTriggerAlerts(errorData: ErrorData) {
  // 1. 检查邮件服务是否可用
  if (!isEmailConfigured()) return;

  // 2. 获取该项目的所有启用规则
  const rules = await getAlertRules(errorData.dsn);
  const enabledRules = rules.filter(r => r.enabled);

  // 3. 逐个评估规则
  for (const rule of enabledRules) {
    const shouldTrigger = await evaluateRule(rule, errorData);
    
    if (shouldTrigger) {
      // 4. 触发告警
      await triggerAlert(rule, errorData);
    }
  }
}

async function triggerAlert(rule: AlertRule, errorData: ErrorData) {
  // 1. 发送邮件
  const emailSent = await sendAlertEmail({
    to: rule.recipients,
    subject: `🚨 [${rule.name}] ${errorData.message.slice(0, 50)}`,
    errorMessage: errorData.message,
    errorType: errorData.type,
    errorCount: errorData.count,
    url: errorData.url,
    timestamp: Date.now()
  });

  // 2. 记录告警历史
  await saveAlertHistory(rule.id, errorData, emailSent);

  // 3. 更新冷却时间
  updateCooldown(rule, errorData.fingerprint);
  
  console.log(`[Alert] Triggered: ${rule.name}, sent: ${emailSent}`);
}

六、数据库设计

6.1 告警规则表

CREATE TABLE alert_rules (
  id SERIAL PRIMARY KEY,
  dsn TEXT NOT NULL,                    -- 项目标识
  name VARCHAR(100) NOT NULL,           -- 规则名称
  type VARCHAR(20) NOT NULL,            -- 告警类型
  enabled BOOLEAN DEFAULT true,         -- 是否启用
  threshold INTEGER,                    -- 阈值
  time_window INTEGER DEFAULT 60,       -- 时间窗口(分钟)
  recipients TEXT[] NOT NULL,           -- 收件人数组
  cooldown INTEGER DEFAULT 30,          -- 冷却时间(分钟)
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_alert_rules_dsn ON alert_rules(dsn);

6.2 告警历史表

CREATE TABLE alert_history (
  id SERIAL PRIMARY KEY,
  rule_id INTEGER REFERENCES alert_rules(id),
  dsn TEXT NOT NULL,
  fingerprint TEXT,                     -- 错误指纹
  error_message TEXT,                   -- 错误消息
  triggered_at TIMESTAMP DEFAULT NOW(), -- 触发时间
  email_sent BOOLEAN DEFAULT false      -- 邮件是否发送成功
);

CREATE INDEX idx_alert_history_dsn ON alert_history(dsn, triggered_at);

七、API 设计

7.1 告警规则 CRUD

// 获取规则列表
GET /api/alerts/rules?dsn=demo-app

// 创建规则
POST /api/alerts/rules
{
  "dsn": "demo-app",
  "name": "生产环境错误告警",
  "type": "new_error",
  "recipients": ["dev@example.com"],
  "cooldown": 30
}

// 更新规则
PATCH /api/alerts/rules/:id
{
  "enabled": false,
  "threshold": 50
}

// 删除规则
DELETE /api/alerts/rules/:id

7.2 告警历史查询

// 获取告警历史
GET /api/alerts/history?dsn=demo-app&limit=50

// 响应
{
  "history": [
    {
      "id": 1,
      "ruleId": 1,
      "errorMessage": "Cannot read property 'x' of undefined",
      "triggeredAt": "2024-01-15T10:30:00Z",
      "emailSent": true
    }
  ]
}

7.3 邮件服务状态

// 检查邮件服务状态
GET /api/alerts/email-status
// { "configured": true, "connected": true }

// 发送测试邮件
POST /api/alerts/test-email
{ "email": "test@example.com" }

八、最佳实践

8.1 告警规则配置建议

// 推荐的规则组合
const recommendedRules = [
  {
    name: '新错误告警',
    type: 'new_error',
    cooldown: 60,        // 1小时内同一错误不重复告警
    recipients: ['oncall@team.com']
  },
  {
    name: '错误激增告警',
    type: 'error_spike',
    threshold: 100,      // 5分钟内超过100次
    timeWindow: 5,
    cooldown: 30,
    recipients: ['oncall@team.com', 'manager@team.com']
  },
  {
    name: '关键错误阈值',
    type: 'error_threshold',
    threshold: 1000,     // 累计超过1000次
    cooldown: 120,       // 2小时冷却
    recipients: ['dev@team.com']
  }
];

8.2 避免告警疲劳

  1. 合理设置冷却时间:避免同一问题反复告警
  2. 分级告警:不同严重程度发送给不同人员
  3. 聚合告警:相似错误合并为一条告警
  4. 静默时段:非工作时间降低告警频率

8.3 邮件发送优化

// 使用队列异步发送,避免阻塞主流程
import { Queue } from 'bull';

const emailQueue = new Queue('email-alerts');

emailQueue.process(async (job) => {
  await sendAlertEmail(job.data);
});

// 触发告警时加入队列
async function triggerAlert(rule, errorData) {
  await emailQueue.add({
    to: rule.recipients,
    subject: `🚨 ${errorData.message}`,
    // ...
  });
}

九、扩展方向

当前实现了邮件告警,后续可以扩展:

  1. 多渠道通知

    • 钉钉/飞书 Webhook
    • 企业微信机器人
    • Slack 集成
    • 短信通知(严重告警)
  2. 智能告警

    • 基于历史数据的异常检测
    • 告警收敛和去重
    • 根因分析关联
  3. 告警升级

    • 未处理告警自动升级
    • 值班表集成
    • 告警认领机制

十、总结

本文介绍了前端监控告警系统的完整实现方案:

  • 架构设计:错误上报 → 规则评估 → 冷却检查 → 邮件发送
  • 核心机制:三种告警类型 + 冷却防抖 + 异步处理
  • 技术选型:Node.js + Nodemailer + PostgreSQL

告警系统是监控平台的"最后一公里",让被动查看变为主动通知,大大提升了问题响应效率。


完整代码已开源:GitHub - Sentinel 前端监控平台

如果觉得有帮助,欢迎 Star ⭐️

JS原型链详解

原型链是 JavaScript 实现继承的核心机制,本质是一条「实例与原型之间的引用链条」,用于解决属性和方法的查找、共享与继承问题,理解原型链是掌握 JavaScript 面向对象编程的关键。

一、先搞懂 3 个核心概念(原型链的基础)

在讲原型链之前,必须先明确 prototype__proto__constructor 这三个不可分割的概念,它们是构成原型链的基本单元。

1. prototype(原型属性 / 显式原型)

  • 定义:只有函数(构造函数)才拥有 prototype 属性,它指向一个对象(称为「原型对象」),这个对象的作用是存放所有实例需要共享的属性和方法

  • 通俗理解:构造函数的「原型仓库」,所有通过该构造函数创建的实例,都能共享这个仓库里的内容,避免方法重复创建浪费内存。

  • 示例

    // 构造函数
    function Person(name) {
      this.name = name; // 实例私有属性
    }
    // prototype 指向原型对象,存放共享方法
    Person.prototype.sayName = function() {
      console.log('我的名字:', this.name);
    };
    
    console.log(Person.prototype); // { sayName: ƒ, constructor: ƒ Person() }
    

2. __proto__(原型链指针 / 隐式原型)

  • 定义:几乎所有对象(除 null/undefined都拥有 __proto__ 属性(ES6 规范中称为 [[Prototype]]__proto__ 是浏览器提供的访问接口),它指向创建该对象的构造函数的原型对象(prototype

  • 通俗理解:对象的「原型导航器」,通过它可以找到自己的 “原型仓库”,进而向上查找属性 / 方法。

  • 示例

    const person1 = new Person('张三');
    // person1 的 __proto__ 指向 Person.prototype
    console.log(person1.__proto__ === Person.prototype); // true
    console.log(person1.__proto__.sayName === Person.prototype.sayName); // true
    

3. constructor(构造函数指向)

  • 定义:原型对象(prototype)中默认包含 constructor 属性,它指向对应的构造函数本身,用于标识对象的创建来源。

  • 作用:修复原型指向后,保证实例能正确追溯到构造函数(避免继承时构造函数指向混乱)。

  • 示例

    // 原型对象的 constructor 指向构造函数
    console.log(Person.prototype.constructor === Person); // true
    // 实例可通过 __proto__ 找到 constructor
    console.log(person1.__proto__.constructor === Person); // true
    console.log(person1.constructor === Person); // true(自动向上查找)
    

二、原型链的核心定义与形成过程

1. 核心定义

原型链是由 __proto__ 串联起来的「对象 → 原型对象 → 上层原型对象 → ... → null」的链式结构,当访问一个对象的属性 / 方法时,JavaScript 会先在对象自身查找,找不到则通过 __proto__ 向上查找原型对象,依次类推,直到找到属性 / 方法或到达原型链末端(null)。

2. 原型链的形成过程(三步成型)

我们以 Person 实例为例,拆解原型链的形成:

  1. 第一步:创建构造函数 Person,其 prototype 指向 Person 原型对象(包含 sayName 方法和 constructor);
  2. 第二步:通过 new Person() 创建实例 person1person1.__proto__ 指向 Person.prototype(形成第一层链接);
  3. 第三步Person.prototype 是一个普通对象,它的 __proto__ 指向 Object.prototype(JavaScript 所有对象的根原型),Object.prototype.__proto__ 指向 null(原型链末端)。

最终形成的原型链:

plaintext

person1(实例)
  ↓ __proto__
Person.prototypePerson 原型对象)
  ↓ __proto__
Object.prototype(根原型对象)
  ↓ __proto__
null(原型链末端)

可视化示例

// 验证原型链结构
const person1 = new Person('张三');

// 第一层:person1 -> Person.prototype
console.log(person1.__proto__ === Person.prototype); // true
// 第二层:Person.prototype -> Object.prototype
console.log(Person.prototype.__proto__ === Object.prototype); // true
// 第三层:Object.prototype -> null
console.log(Object.prototype.__proto__ === null); // true
// 完整原型链:person1 -> Person.prototype -> Object.prototype -> null

三、原型链的核心作用:属性 / 方法查找机制

这是原型链最核心的功能,遵循「自身优先,向上追溯,末端终止」的规则:

1. 查找规则步骤

  1. 当访问对象的某个属性 / 方法时,先在对象自身的属性中查找(比如 person1.name,直接在 person1 上找到);
  2. 如果自身没有,就通过 __proto__ 向上查找原型对象(比如 person1.sayName(),自身没有,找到 Person.prototype 上的 sayName);
  3. 如果原型对象也没有,继续通过原型对象的 __proto__ 向上查找上层原型(比如 person1.toString()Person.prototype 没有,找到 Object.prototype 上的 toString);
  4. 直到找到目标属性 / 方法,或到达原型链末端 null,此时返回 undefined(属性)或报错(方法)。

2. 代码示例

const person1 = new Person('张三');

// 1. 查找自身属性:name
console.log(person1.name); // 张三(自身存在,直接返回)

// 2. 查找原型方法:sayName
console.log(person1.sayName()); // 我的名字:张三(自身没有,向上找到 Person.prototype)

// 3. 查找上层原型方法:toString
console.log(person1.toString()); // [object Object](Person.prototype 没有,向上找到 Object.prototype)

// 4. 查找不存在的属性:age
console.log(person1.age); // undefined(原型链末端仍未找到,返回 undefined)

3. 注意:属性修改仅影响自身,不影响原型

原型链是「只读」的查找链路,修改对象的属性时,只会修改对象自身,不会改变原型对象的属性(除非直接显式修改原型):

// 错误:试图修改原型方法(实际是给 person1 新增了一个私有方法 sayName,覆盖了原型查找)
person1.sayName = function() {
  console.log('我是私有方法:', this.name);
};
person1.sayName(); // 我是私有方法:张三(优先访问自身方法)
console.log(Person.prototype.sayName()); // 我的名字:undefined(原型方法未被修改)

四、原型链与继承的关系

原型链是 JavaScript 继承的底层支撑,所有继承方式(原型链继承、组合继承等)本质都是通过修改 __proto__ 或 prototype,构建新的原型链结构,实现子类对父类属性 / 方法的继承。

示例:简单继承的原型链结构

// 父类构造函数
function Animal(name) {
  this.name = name;
}
Animal.prototype.sayName = function() {
  console.log('名称:', this.name);
};

// 子类构造函数
function Dog(name, breed) {
  Animal.call(this, name);
  this.breed = breed;
}
// 构建继承:让 Dog.prototype.__proto__ 指向 Animal.prototype
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

// 子类实例的原型链
const dog1 = new Dog('旺财', '中华田园犬');
// 原型链:dog1 -> Dog.prototype -> Animal.prototype -> Object.prototype -> null
console.log(dog1.__proto__ === Dog.prototype); // true
console.log(Dog.prototype.__proto__ === Animal.prototype); // true
console.log(Animal.prototype.__proto__ === Object.prototype); // true
// 继承生效:dog1 能访问 Animal.prototype 的 sayName 方法
dog1.sayName(); // 名称:旺财

五、原型链的末端:Object.prototype 与 null

  1. Object.prototype:是 JavaScript 所有对象的「根原型」,所有对象最终都会继承它的属性和方法(如 toString()hasOwnProperty()valueOf() 等);

  2. null:是原型链的「终点」,Object.prototype.__proto__ 指向 null,表示没有上层原型,查找过程到此终止;

  3. 验证

    console.log(Object.prototype.__proto__); // null
    console.log(Object.prototype.hasOwnProperty('toString')); // true(根原型的自有方法)
    console.log(person1.hasOwnProperty('name')); // true(自身属性)
    console.log(person1.hasOwnProperty('sayName')); // false(原型上的方法,非自身属性)
    

六、常见误区

  1. 混淆 prototype 和 __proto__prototype 是函数的属性,__proto__ 是对象的属性,两者的关联是「对象.proto = 构造函数.prototype」;
  2. 原型链是可写的__proto__ 可以手动修改(不推荐,会破坏原有继承结构,影响性能);
  3. 所有对象都有 prototype:只有函数才有 prototype,普通对象只有 __proto__
  4. hasOwnProperty 能查找原型属性hasOwnProperty 仅判断对象自身是否有该属性,不会向上查找原型链。

总结

  1. 原型链的核心是 __proto__ 串联的链式结构,末端是 null,根节点是 Object.prototype
  2. 3 个核心概念:prototype(函数的原型仓库)、__proto__(对象的原型指针)、constructor(原型的构造函数指向);
  3. 核心功能:实现属性 / 方法的分层查找(自身 → 原型 → 上层原型 → ... → null),支撑 JavaScript 继承机制;
  4. 本质:通过共享原型对象的属性 / 方法,实现代码复用,减少内存消耗。

JS继承方式详解

JavaScript 继承基于原型链实现,不存在类继承的原生语法(ES6 class 是语法糖,底层仍为原型继承),常见继承方式按演进逻辑可分为以下 6 种,各有优劣与适用场景:

一、原型链继承(最基础的继承方式)

核心原理

将父类的实例作为子类的原型(SubType.prototype = new SuperType()),子类实例通过原型链向上查找父类的属性和方法,实现继承。

代码示例

javascript

运行

// 父类
function Animal(name) {
  this.name = name; // 实例属性
  this.colors = ['black', 'white']; // 引用类型实例属性
}
Animal.prototype.sayName = function() { // 原型方法
  console.log('动物名称:', this.name);
};

// 子类
function Dog() {}
// 核心:将父类实例赋值给子类原型
Dog.prototype = new Animal('小狗');
Dog.prototype.constructor = Dog; // 修复构造函数指向

// 测试
const dog1 = new Dog();
const dog2 = new Dog();
dog1.colors.push('brown');
console.log(dog1.colors); // ['black', 'white', 'brown']
console.log(dog2.colors); // ['black', 'white', 'brown'](引用类型属性被共享)
dog1.sayName(); // 动物名称:小狗

优点与缺点

  • 优点:实现简单,子类可继承父类原型上的所有方法;

  • 缺点

    1. 父类的引用类型实例属性会被所有子类实例共享(一个实例修改会影响其他实例);
    2. 无法向父类构造函数传递参数(子类实例创建时,无法自定义父类实例属性)。

二、构造函数继承(借用父类构造函数)

核心原理

在子类构造函数中,通过 call()/apply() 调用父类构造函数,将父类的实例属性绑定到子类实例上,实现实例属性的继承。

代码示例

javascript

运行

// 父类
function Animal(name) {
  this.name = name;
  this.colors = ['black', 'white'];
  this.sayName = function() {
    console.log('动物名称:', this.name);
  };
}

// 子类
function Dog(name, breed) {
  // 核心:借用父类构造函数,传递参数
  Animal.call(this, name);
  this.breed = breed; // 子类自有属性
}

// 测试
const dog1 = new Dog('旺财', '中华田园犬');
const dog2 = new Dog('小白', '萨摩耶');
dog1.colors.push('brown');
console.log(dog1.colors); // ['black', 'white', 'brown']
console.log(dog2.colors); // ['black', 'white'](引用类型属性不共享)
dog1.sayName(); // 动物名称:旺财
console.log(dog1.breed); // 中华田园犬

优点与缺点

  • 优点

    1. 解决了原型链继承中引用类型属性共享的问题;
    2. 可以向父类构造函数传递参数;
  • 缺点

    1. 只能继承父类的实例属性和方法,无法继承父类原型上的方法(每个子类实例都会复制一份父类方法,浪费内存);
    2. 子类实例无法共享父类方法,违背原型链的设计初衷。

三、组合继承(原型链 + 构造函数,最常用)

核心原理

结合原型链继承和构造函数继承的优点:

  1. 原型链继承继承父类原型上的方法(实现方法共享);
  2. 构造函数继承继承父类的实例属性(避免引用类型共享,支持传参)。

代码示例

javascript

运行

// 父类
function Animal(name) {
  this.name = name;
  this.colors = ['black', 'white'];
}
Animal.prototype.sayName = function() {
  console.log('动物名称:', this.name);
};

// 子类
function Dog(name, breed) {
  // 构造函数继承:继承实例属性,传参
  Animal.call(this, name);
  this.breed = breed;
}
// 原型链继承:继承原型方法,实现方法共享
Dog.prototype = new Animal();
Dog.prototype.constructor = Dog; // 修复构造函数指向
// 子类原型方法
Dog.prototype.sayBreed = function() {
  console.log('犬种:', this.breed);
};

// 测试
const dog1 = new Dog('旺财', '中华田园犬');
const dog2 = new Dog('小白', '萨摩耶');
dog1.colors.push('brown');
console.log(dog1.colors); // ['black', 'white', 'brown']
console.log(dog2.colors); // ['black', 'white'](引用类型不共享)
dog1.sayName(); // 动物名称:旺财(继承父类原型方法)
dog1.sayBreed(); // 犬种:中华田园犬(子类自有方法)
console.log(dog1 instanceof Animal); // true( instanceof 检测正常)

优点与缺点

  • 优点

    1. 兼顾了原型链继承和构造函数继承的优点,既实现了方法共享,又避免了引用类型属性共享;
    2. 支持向父类传参,instanceof 检测正常;
  • 缺点:父类构造函数被调用了两次(一次是创建子类原型时 new Animal(),一次是子类构造函数中 Animal.call(this)),导致子类原型上存在多余的父类实例属性(虽不影响使用,但造成内存冗余)。

四、原型式继承(基于已有对象创建新对象)

核心原理

通过 Object.create()(或手动封装的原型方法),以一个已有对象为原型,创建新的对象,实现对已有对象属性和方法的继承。

代码示例

javascript

运行

// 已有对象(作为原型)
const animal = {
  name: '动物',
  colors: ['black', 'white'],
  sayName: function() {
    console.log('动物名称:', this.name);
  }
};

// 核心:用 Object.create 创建新对象,继承 animal
const dog = Object.create(animal);
dog.name = '旺财'; // 重写实例属性
dog.breed = '中华田园犬'; // 新增自有属性

// 测试
const cat = Object.create(animal);
dog.colors.push('brown');
console.log(dog.colors); // ['black', 'white', 'brown']
console.log(cat.colors); // ['black', 'white', 'brown'](引用类型属性共享)
dog.sayName(); // 动物名称:旺财
console.log(dog.breed); // 中华田园犬

优点与缺点

  • 优点:无需定义构造函数,实现简单,适合快速创建基于已有对象的新对象;

  • 缺点

    1. 引用类型属性会被所有新对象共享(与原型链继承一致);
    2. 无法向父对象传递参数,只能在创建新对象后手动修改属性。

五、寄生式继承(原型式继承的增强版)

核心原理

在原型式继承的基础上,封装一个创建对象的函数,在函数内部为新对象添加自有属性和方法,增强新对象的功能,最后返回新对象。

代码示例

javascript

运行

// 封装创建继承对象的函数(寄生函数)
function createAnimal(proto, name, breed) {
  // 原型式继承:创建新对象
  const obj = Object.create(proto);
  // 增强新对象:添加自有属性和方法
  obj.name = name;
  obj.breed = breed;
  obj.sayBreed = function() {
    console.log('犬种/品种:', this.breed);
  };
  return obj;
}

// 原型对象
const animal = {
  colors: ['black', 'white'],
  sayName: function() {
    console.log('名称:', this.name);
  }
};

// 测试
const dog = createAnimal(animal, '旺财', '中华田园犬');
const cat = createAnimal(animal, '咪咪', '橘猫');
dog.colors.push('brown');
console.log(dog.colors); // ['black', 'white', 'brown']
console.log(cat.colors); // ['black', 'white', 'brown'](引用类型共享)
dog.sayName(); // 名称:旺财
dog.sayBreed(); // 犬种/品种:中华田园犬

优点与缺点

  • 优点:无需定义构造函数,可灵活增强新对象的功能,实现简单;

  • 缺点

    1. 引用类型属性共享问题依然存在;
    2. 每个新对象的自有方法都是独立的(无法共享),浪费内存;
    3. 无法实现方法的复用,类似构造函数继承的缺点。

六、寄生组合式继承(完美继承方案)

核心原理

结合组合继承和寄生式继承的优点,解决组合继承中父类构造函数被调用两次的问题:

  1. 寄生式继承继承父类的原型(仅继承原型方法,不调用父类构造函数);
  2. 构造函数继承继承父类的实例属性(支持传参,避免引用类型共享)。

代码示例

javascript

运行

// 寄生函数:继承父类原型,不调用父类构造函数
function inheritPrototype(SubType, SuperType) {
  // 创建父类原型的副本(避免直接修改父类原型)
  const prototype = Object.create(SuperType.prototype);
  prototype.constructor = SubType; // 修复构造函数指向
  SubType.prototype = prototype; // 将副本赋值给子类原型
}

// 父类
function Animal(name) {
  this.name = name;
  this.colors = ['black', 'white'];
}
Animal.prototype.sayName = function() {
  console.log('名称:', this.name);
};

// 子类
function Dog(name, breed) {
  // 构造函数继承:继承实例属性,传参(仅调用一次父类构造函数)
  Animal.call(this, name);
  this.breed = breed;
}

// 核心:寄生式继承父类原型
inheritPrototype(Dog, Animal);

// 子类原型方法
Dog.prototype.sayBreed = function() {
  console.log('犬种:', this.breed);
};

// 测试
const dog1 = new Dog('旺财', '中华田园犬');
const dog2 = new Dog('小白', '萨摩耶');
dog1.colors.push('brown');
console.log(dog1.colors); // ['black', 'white', 'brown']
console.log(dog2.colors); // ['black', 'white'](引用类型不共享)
dog1.sayName(); // 名称:旺财
dog1.sayBreed(); // 犬种:中华田园犬
console.log(dog1 instanceof Animal); // true
console.log(Dog.prototype.constructor === Dog); // true(构造函数指向正确)

优点与缺点

  • 优点

    1. 父类构造函数仅被调用一次,避免了内存冗余;
    2. 实现了方法共享,避免了引用类型属性共享;
    3. 支持向父类传参,instanceof 检测和构造函数指向均正常;
    4. 是 JavaScript 继承的 “完美方案”,ES6 class extends 底层基于此实现。
  • 缺点:实现相对复杂(需封装寄生函数),但可复用该函数。

七、ES6 Class 继承(语法糖)

核心原理

通过 class 定义类,extends 关键字实现继承,super() 调用父类构造函数,底层仍是寄生组合式继承,只是语法更简洁、更接近传统类继承。

代码示例

javascript

运行

// 父类
class Animal {
  constructor(name) {
    this.name = name;
    this.colors = ['black', 'white'];
  }

  sayName() {
    console.log('名称:', this.name);
  }
}

// 子类:extends 实现继承
class Dog extends Animal {
  constructor(name, breed) {
    super(name); // 必须先调用 super(),才能使用 this
    this.breed = breed;
  }

  sayBreed() {
    console.log('犬种:', this.breed);
  }
}

// 测试
const dog1 = new Dog('旺财', '中华田园犬');
const dog2 = new Dog('小白', '萨摩耶');
dog1.colors.push('brown');
console.log(dog1.colors); // ['black', 'white', 'brown']
console.log(dog2.colors); // ['black', 'white'](引用类型不共享)
dog1.sayName(); // 名称:旺财
dog1.sayBreed(); // 犬种:中华田园犬
console.log(dog1 instanceof Animal); // true

优点与缺点

  • 优点:语法简洁直观,符合面向对象编程习惯,易于理解和维护,支持静态方法继承(static 关键字);
  • 缺点:本质是语法糖,底层仍依赖原型链,新手可能忽略原型继承的本质。

八、各类继承方式对比与选型建议

继承方式 核心优点 核心缺点 适用场景
原型链继承 实现简单,方法共享 引用类型共享,无法传参 简单场景,无需传参,不关心引用类型共享
构造函数继承 支持传参,引用类型不共享 无法继承原型方法,方法冗余 仅需继承实例属性,无需共享方法
组合继承 方法共享,支持传参,功能完善 父类构造函数调用两次 常规业务场景,兼容性要求高
原型式继承 无需构造函数,快速创建对象 引用类型共享,无法传参 基于已有对象快速创建新对象
寄生式继承 灵活增强对象功能 方法冗余,引用类型共享 快速创建并增强新对象,简单场景
寄生组合式继承 完美解决所有缺陷,性能最优 实现复杂 追求性能和严谨性的场景,框架开发
ES6 Class 继承 语法简洁,符合 OOP 习惯 底层仍是原型继承 现代项目开发,兼容性良好(ES6+)

总结

  1. 原型链是 JavaScript 继承的基础,所有继承方式均围绕原型链展开;
  2. 寄生组合式继承是 “完美方案”,ES6 class extends 是其语法糖,推荐现代项目优先使用;
  3. 简单场景可使用原型式 / 寄生式继承,兼容旧环境可使用组合继承,仅需实例属性继承可使用构造函数继承。

Vue 转盘抽奖 transform

Vue 转盘抽奖 transform

简介:电商食用级转盘抽奖

讲在前面

在我们日常生活,电子购物已是必不可少的环节了。营销手段更是层出不穷,要数经典的还是转盘抽奖了,紧张又刺激(其实概率还不都是咱们程序猿弄的,刺激个der~)

虽说如此...

但 还是决定自己搞一个试试!

核心 transform

transform 属性向元素应用 2D 或 3D 转换。该属性允许我们对元素进行旋转、缩放、移动或倾斜。

但是既然我们说转盘,当然用到的是旋转啦:rotate

简单示例 顺时针旋转10deg

transform:rotate(10deg);

什么?你已经会这个css属性了? 那恭喜你,你已经能自己独立制作转盘抽奖啦~

核心代码

1. 转盘UI

<template>

    <view class="">

      
            <!-- 转盘包裹 -->
            <view class="rotate">
              <!-- 绘制圆点 -->
              <view :class="'circle circle_' + index" v-for="(item, index) in circleList" :key="index"
                :style="{ background: index % 2 == 0 ? colorCircleFirst : colorCircleSecond }"></view>
              <!-- 转盘图片 -->
              <image class="dish" src="/static/demo/pan.png" :style="{ transform: rotate_deg, transition: rotate_transition }" ></image>
              <!-- 指针图片 -->
              <image class="pointer" src="/static/demo/zhen.png" @click="start" ></image>
              
            
            </view>



      
    </view>

</template>


<style lang="scss" scoped>
.rotate {
  width: 600rpx;
  height: 600rpx;
  background: #ffbe04;
  border-radius: 50%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  position: absolute;
  top: 48%;
  left: 50%;
  transform: translate(-50%, -50%);
}

.rotate .dish {
  width: 550rpx;
  height: 550rpx;
}

.pointer {
  width: 142rpx;
  height: 200rpx;
  position: absolute;
  top: 46%;
  left: 50%;
  transform: translate(-50%, -50%);
}

/* 圆点 */
.rotate .circle {
  position: absolute;
  display: block;
  border-radius: 50%;
  height: 20rpx;
  width: 20rpx;
  background: black;
}

/*这里只写了一个点的位置,其他的自己补充一下 调调位置就好啦*/
.rotate .circle_0 {
  top: 2rpx;
  left: 284rpx;
}
    
</style>

2.让转盘转动起来

var light_timer; //灯光定时器

data() {
        return {
            circleList: [], //圆点列表
            colorCircleFirst: "#FF0000", //圆点闪烁颜色一
            colorCircleSecond: "#fff", //圆点闪烁颜色二
            cat: 45, //总共8个扇形区域,每个区域45度,这就取决去奖品池的UI图了
            isAllowClick: true, //是否能够点击
            rotate_deg: 0, //指针旋转的角度
            rotate_transition: "transform 3s ease-in-out" //过渡属性,渐入渐出

        };
    },
        
onLoad() {
    this.showcircleList();
},
    
    methods: {
        // 设置边缘一圈16个圆点,可以根据需要修改
        showcircleList() {
            let circleList = [];
            for (var i = 0; i < 16; i++) {
                circleList.push(i);
            }
            this.circleList = circleList;
            this.light();   
        },

        //设置边缘灯光闪动效果
        light: function() {
            var that = this;
            clearInterval(light_timer);
            light_timer = setInterval(function() {
                if (that.colorCircleFirst == "#FF0000") {
                    that.colorCircleFirst = "#fff";
                    that.colorCircleSecond = "#FF0000";
                } else {
                    that.colorCircleFirst = "#FF0000";
                    that.colorCircleSecond = "#fff";
                }
            }, 300); //设置圆点闪烁的间隔时间效果
        },
        //点击开始抽奖
        start() {
            this.rotating();
        },
        //旋转
        rotating() {
            if (!this.isAllowClick) return;
            this.isAllowClick = false;
            this.rotate_transition = "transform 3s ease-in-out";
            this.LuckyClick--;
            var rand_circle = 5; //默认多旋转5圈
            var winningIndex = this.set(); //设置概率
            console.log(winningIndex);
            var randomDeg = 360 - winningIndex * 45; //8个区域。一圈是360度,对应区域旋转度数就是顺时针的 360 - winningIndex*45°
            var deg = rand_circle * 360 + randomDeg; //把本来定义多转的圈数度数也加上
            this.rotate_deg = "rotate(" + deg + "deg)";

            var that = this;
            setTimeout(function() {
                that.isAllowClick = true;
                that.rotate_deg = "rotate(" + randomDeg + "deg)"; //定时器关闭的时候角度调回五圈之前相同位置,依照产品需求可以自己更改
            that.rotate_transition = "";

                if (winningIndex == 0) {
                    console.log("恭喜您,IphoneX");
                } else if (winningIndex == 1) {
                    console.log("恭喜您,获得10元现金");
                } else if (winningIndex == 2) {
                    console.log("很遗憾,重在参与");
                    uni.showToast({
                        title:"很遗憾,重在参与",
                        icon:"none"
                    })
                } else if (winningIndex == 3) {
                    console.log("恭喜您,获得30元现金");
                } else if (winningIndex == 4) {
                    console.log("恭喜您,获得20元现金");
                } else if (winningIndex == 5) {
                    console.log("恭喜您,获得50元现金");
                } else if (winningIndex == 6) {
                    console.log("恭喜您,获得5元现金");
                } else if (winningIndex == 7) {
                    console.log("恭喜您,获得100元现金");
                }
            }, 3500);
        },

        //设置概率
        set() {
            var winIndex;
            var __rand__ = Math.random();
            // 随机数 设置抽奖概率 winIndex 记得参考奖品池的UI图
            if (__rand__ < 0.30) winIndex = 2;
            else if (__rand__ < 0.55) winIndex = 6;
            else if (__rand__ < 0.75) winIndex = 1;
            else if (__rand__ < 0.85) winIndex = 4;
            else if (__rand__ < 0.92) winIndex = 3;
            else if (__rand__ < 0.97) winIndex = 5;
            else if (__rand__ < 0.99) winIndex = 7;
            else if (__rand__ == 0.99) winIndex = 0;
            return winIndex;
        },



}

最终效果展示

zhuanpan.png

总结

其实就是利用背景图进行旋转,设置好旋转角度!如果有兴趣的话就快速行动吧,冲冲冲!!!

vue中hash模式和history模式的区别

一句话总结

  • Hash 模式:利用 URL 中 # 后的内容变化实现前端路由,不触发页面刷新,兼容性好。
  • History 模式:基于 HTML5 的 history.pushState()popstate 事件,URL 更干净,但需要服务端配合。

一、Hash 模式(默认模式)

1. 基本形式

https://example.com/#/user/profilehash 部分

2. 工作原理

  • 核心机制:监听 window.onhashchange 事件。
  • 当用户点击链接或调用 router.push() 时,Vue Router 修改 location.hash(如 #/home#/about)。
  • 浏览器不会向服务器发起请求,因为 # 及其后的内容不会发送给服务器
  • 页面 URL 改变但不刷新,前端根据新的 hash 值匹配路由并渲染对应组件。

3. 特点

优点 缺点
✅ 兼容性极好(IE8+ 支持) ❌ URL 中带有 #,不够美观
✅ 无需服务端配置 ❌ SEO 友好性略差(部分爬虫可能忽略 hash)
✅ 天然避免 404 问题 ❌ 不符合传统 URL 语义

4. 示例代码(Vue Router 配置)

const router = new VueRouter({
  mode: 'hash', // 默认值,可省略
  routes: [...]
});

二、History 模式(推荐用于现代项目)

1. 基本形式

https://example.com/user/profile

URL 看起来和传统多页应用一致,无 # 符号。

2. 工作原理

  • 核心技术
    • history.pushState(state, title, url):在不刷新页面的情况下修改浏览器历史记录和 URL。
    • history.replaceState(...):替换当前历史记录。
    • window.onpopstate:监听浏览器前进/后退操作(如点击 ← → 按钮)。
  • 流程示例
    1. 用户访问 /home → 前端加载,Vue Router 渲染 Home 组件。
    2. 点击“关于”链接 → 调用 router.push('/about') → 执行 history.pushState(null, '', '/about')
    3. URL 变为 https://example.com/about,页面不刷新,About 组件被渲染。
    4. 用户刷新页面 → 浏览器向服务器请求 /about 资源。

3. 关键问题:刷新 404

  • 原因:服务器收到 /about 请求时,若未配置,会尝试查找物理路径下的 about.html 或目录,找不到则返回 404。
  • 解决方案服务端需配置“兜底路由” ,将所有前端路由请求重定向到 index.html

Nginx 配置示例:

location / {
  try_files $uri $uri/ /index.html;
}

4. 特点

优点 缺点
✅ URL 简洁美观,符合 REST 风格 ❌ 需要服务端支持(部署配置)
✅ 更好的 SEO(主流爬虫已支持) ❌ 在纯静态托管(如 GitHub Pages)中需额外处理
✅ 用户体验更接近原生 Web ❌ 旧浏览器(IE9 以下)不支持

5. 示例代码(Vue Router 配置)

const router = new VueRouter({
  mode: 'history',
  routes: [...]
});

🔁 三、对比总结

特性 Hash 模式 History 模式
URL 样式 example.com/#/path example.com/path
刷新是否 404 ❌ 不会(# 后内容不发给服务器) ✅ 会(需服务端配置兜底)
浏览器兼容性 IE8+ IE10+(HTML5 History API)
服务端要求 必须配置 fallback 到 index.html
SEO 友好性 一般 较好(现代爬虫支持)
使用场景 快速原型、老旧环境、无服务端控制权 正式项目、追求用户体验、有运维支持

💡 最佳实践建议

  • 开发阶段:两种模式均可,推荐 history 提前暴露部署问题。
  • 生产部署
    • 若使用 Nginx/Apache/Caddy → 优先选 history + 配置 fallback。
  • 无法控制服务端? → 用 hash 模式最稳妥。

补充知识

  • 为什么 hash 不发给服务器?
    根据 HTTP 规范,URL 中 #fragment 部分仅用于客户端定位(如锚点),不会包含在 HTTP 请求中
  • History API 安全限制
    pushState 只能修改同源下的路径,不能跨域篡改 URL,保障了安全性。

顶层元素问题:popover vs. dialog

原文:Top layer troubles: popover vs. dialog 作者:Stephanie Eckles 日期:2025年12月1日 翻译:田八

来源:前端周刊

你是否曾尝试通过设置 z-index: 9999 解决元素层级问题?如果是,那你其实是在与一个基础的CSS概念 ——层叠上下文—— 斗争。

层叠上下文定义了元素在第三维度(即“z轴”)上的排列顺序。你可以把z轴想象成视口中层叠上下文根节点与用户(即通过浏览器视口观察的你)之间的DOM元素的层级。

image.png

一个元素只能在同一层叠上下文中重新调整层级。虽然 z-index 是实现这一点的工具,但失败往往源于层叠上下文的变化。这种变化可能通过多种方式发生,例如使用固定定位(fixed)、粘性定位(sticky)元素,或是将绝对定位(absolute)/相对定位(relative)与 z-index 结合使用等,MDN 上列出了更多原因

现代网页设计有一个“顶层”特性,它保证使其位于所有其他层叠上下文的最顶层。它覆盖整个视口,不过顶层中的元素实际可见尺寸可能更小。

将元素提升到顶层,可使其摆脱原本所在的任何层叠上下文。

虽然顶层直接解决了一个与CSS相关的问题,但目前还没有属性可用于将元素提升到顶层。取而代之的是,某些元素和特定条件可以访问顶层,例如通过 <div> 标签显示的原生对话框 showModal() 和被指定为 Popover 的元素。

Popover API是一项新推出的 HTML 功能,它允许你声明式的创建非模态覆盖元素。通过使用 Popover API 用来摆脱任何层叠上下文,这是它的一个理想特性。然而,在急于选择这种原生能力之前,需要注意一个潜在的问题。

场景设定

想象一下,在2025年的网络世界:你的网页应用包含一个通过“Toast”消息显示通知的服务。你知道的,就是那些通常出现在角落或其他不太可能遮挡其他用户界面(UI)位置的弹出消息。

通常,这些Toast通知通常用于实时提醒,比如保存成功,或者表单提交失败等错误提示。它们有时有时间限制,或者包含如关闭按钮这样的关闭机制。有时它们还包含额外操作,例如“重试”选项,用于重新提交失败的工作流。

既然您的应用紧跟时代潮流,你最近决定将Toast升级为使用Popover API。这样你就可以将Toast组件放置在应用的任何结构中,而无需为了解决层叠上下文问题而采用一些变通方法。毕竟,Toast必须显示在所有其他元素之上,因此通过 Popover 实现顶层访问是明智之举!

你发布了改进版本,并为自己的工作感到自豪。

发布的当周晚些时候,你收到了一份紧急错误报告。不是普通的错误报告,而是一个可访问性违规报告。

Dialog vs. popover

你的应用很新潮,你之前也升级使用了原生HTML对话框。那是一次很棒的升级,因为你用原生 Web 功能取代了对 JavaScript 的依赖。这也是你兴奋地将Toast也升级为使用Popover的另一个原因。

那么,错误是什么呢?一位键盘用户正在使用一个包含对话框的工作流程,对话框打开期间,后台进程触发了一个弹出式通知。该通知提示存在错误,需要用户进行交互。

当这位键盘用户试图将焦点切换到Toast上时,出现了错误。他们虽然在视觉上能看到Toast显示在对话框背景之上,但焦点无法成功进入Toast,而是意外地跳到了浏览器UI上。

你可以在这个CodePen示例中亲自体验这个错误,使用Tab键,你会发现你永远无法访问到Toast。你也可以尝试使用屏幕阅读器,会发现虚拟光标也无法进入Toast。

CodePen

如果你能够点击弹出框,可能会觉得至少点击操作是可行的。但很快我们就会发现,事情并非如此。

为什么Toast弹出框无法访问

虽然顶层可以超越标准的层叠上下文,但顶层中的元素仍然受分层顺序的影响。最近添加到顶层的元素会显示在之前添加的顶层元素之上。这就是为什么Toast在视觉上会显示在对话框背景之上。

如果弹出框在视觉上可用,那为什么通过键盘或屏幕阅读器的虚拟光标却无法访问呢?

原因在于弹出框与 模态 对话框之间存在竞争关系。当通过 showModal()方法启动原生HTML对话框时,对话框外部的页面会变为 惰性状态惰性状态 是一种必要的可访问性行为,它会隔离对话框内容,并阻止通过Tab键和虚拟光标访问背景页面。

这个错误是由于Toast弹出框是背景页面DOM的一部分。这意味着由于它位于对话框DOM边界之外,所以也变成了惰性状态。

但是,由于顶层顺序的原因,因为它是在对话框打开后创建的,所以在视觉上,它被放置在对话框的顶部,这一点可能会让你感到困惑。

如果你以为点击弹出框就能关闭它,实际上并非如此,尽管弹出框确实会消失。真正发生的情况是,你触发了弹出框的 轻触关闭 行为。这意味着它关闭是因为你实际上点击了它的边界之外,因为对话框捕获了点击操作。

所以,虽然弹出框被关闭了,但“重试”按钮实际上并没有被点击,这意味着任何关联的事件监听器都不会被触发。

即使你创建了一个自动化测试来专门检查当对话框打开时Toast的提醒功能,该自动化测试仍可能出现误报,因为它触发了对Toast按钮的编程式点击。这种伪点击错误地绕过了由于对话框导致页面变为惰性状态所引发的问题。

重新获得弹出框访问权限

解决方案有两个方面:

  1. 将弹出框(popover)在DOM中实际放置在对话框(dialog)内部。
  2. 确保使用 popover="manual",以防止对话框内的点击操作过早触发弹出框的轻触关闭。

完成这两步后,弹出框现在既在视觉上可用,又可以通过任何方式完全交互。

Codepan

经验教训与额外考虑

我们了解到,如果你的网站或应用有可能同时显示弹出框和对话框,并且它们有独立的时间线,那么你需要想出一种在对话框内启动弹出框的机制。

或者,您可以选择在对话框关闭之前禁用后台页面弹出窗口。但如果通知需要及时交互,或者对话框内容有可能触发 Toast 提示,则此方法可能并不理想。

除了可见性和交互性之外,您可能还需要考虑另一个问题:弹出窗口是否需要在对话框关闭后继续保持打开状态。也就是说,即使对话框关闭,弹出窗口也需要保持打开状态,例如继续等待用户执行操作。

虽然我非常支持使用原生平台功能,而且我认为弹出框(popover)尤其出色,但有时冲突是无法完全避免的。事实上,您可能已经遇到过类似的问题,即模态对话框的惰性行为。因此,本文的主要目的是提醒您,如果同时显示背景弹出框和模态对话框,可能会出现问题,因此不要完全放弃之前自定义的弹出框架构。

如果这个问题目前或将来会影响到你的工作,请关注这个HTML问题,其中正在讨论解决方案。

关于斯蒂芬妮·埃克尔斯

Stephanie Eckles 是 Adobe Spectrum CSS 的高级设计工程师,也是 CSSWG 的成员,同时还是 ModernCSS.dev 的作者。Steph 拥有超过 15 年的 Web 开发经验,她乐于以作家、研讨会讲师和会议演讲者的身份分享这些经验。她致力于倡导无障碍设计、可扩展 CSS 和 Web 标准。业余时间,她是两个女儿的妈妈,喜欢烘焙和水彩画。

博客:ModernCSS.dev Mastodon:@5t3ph

译者注:

  1. popover:弹出框指的是轻提示的弹出式框,没有过多的交互逻辑
  2. dialog:对话框指的是带有交互逻辑的弹出框,例如存在确认和取消按钮,输入框等

这两个都是新特性,具体内容可参考MDN

TypeScript 严格性是非单调的:strict-null-checks 和 no-implicit-any 的相互影响

原文: TypeScript strictness is non-monotonic: strict-null-checks and no-implicit-any interact

翻译: 嘿嘿

来源:前端周刊

TypeScript 编译器选项 strictNullChecksnoImplicitAny 以一种奇怪的方式相互作用:仅启用 strictNullChecks 会导致类型错误,而在同时启用 noImplicitAny 后这些错误却消失了。这意味着更严格的设置反而导致更少的错误!

这虽然是一个影响不大的奇闻异事,但我在实际工作中确实遇到了它,当时我正在将一些模块更新为更严格的设置。

背景

TypeScript 是驯服 JavaScript 代码库的强大工具,但要获得最大的保障,需要在“严格”模式下使用它。

在现有的 JavaScript 代码库中采用 TypeScript 可以逐步完成:逐个打开每个严格的子设置,并逐一处理出现的错误。这种渐进式方法使得采用变得可行:不要在一次大爆炸中修复整个世界,而是进行多次较小的更改,直到最终世界被修复。

在工作中,我们最近一直在以这种方式逐步提高代码的严格性,然后我遇到了这种相互作用。

示例

下面这段代码中,array 的类型是什么?

const array = [];
array.push(123);

作为一个独立的代码片段,它看起来奇怪且毫无意义(“为什么不直接用 const array = [123];?”),但它是真实代码的最小化版本。

const featureFlags = [];

if (enableRocket()) {
  featureFlags.push("rocket");
}
if (enableParachute()) {
  featureFlags.push("parachute");
}

prepareForLandSpeedRecord(featureFlags);

这里没有显式的类型注解,所以 TypeScript 需要推断它。这种推断有点巧妙,因为它需要“时间旅行”(指需要运行后续语句后回头去修改推断的类型,类似正则回溯):const array = [] 这个声明并没有说明数组中可能包含什么,这个信息只来自代码后面出现的 push

考虑到所有这些,推断出的确切类型依赖于两个 TypeScript 语言选项也就不足为奇了:

strictNullChecks noImplicitAny 推断类型
最不严格 any[]
number[]
never[]
最严格 number[]

选项说明

这里影响推断类型的两个选项是:

  • strictNullChecks:正确强制处理可选/可为空的值。例如,启用后,一个可为空的字符串变量(类型为 string | null)不能直接用在期望普通 string 值的地方。
  • noImplicitAny:避免在一些模棱两可的情况下推断出“全能”的 any 类型。

最好同时启用它们:strictNullChecks 解决了“十亿美元的错误”,而 noImplicitAny 减少了感染代码库的容易出错的 any 的数量。

问题所在

我们上表中第三种配置,即启用 strictNullChecks 但禁用 noImplicitAny 时,推断出 array: never[]。因此,代码片段无效并被报错(在线示例):

array.push(123);
//         ^^^ 错误:类型“123”的参数不能赋给类型“never”的参数。

没有任何东西(既不是字面量 123,也不是任何其他 number,也不是任何其他东西)是 never 的“子类型”,所以,是的,这段代码无效是合理的。

奇怪之处

“启用一些更严格的要求,然后得到一个错误”并不令人惊讶,也不值得注意……但让我们再仔细看看表格:

strictNullChecks noImplicitAny 推断类型
最不严格 any[]
number[]
报错! never[]
最严格 number[]

所以,如果我们从一个宽松的代码库开始,并希望使其变得严格,我们可能会:

  1. 启用 strictNullChecks,然后遇到一个新错误(不奇怪),然后
  2. 解决这个错误,无需更改代码,只需启用 noImplicitAny(奇怪!)。

当我们朝着完全严格的方向前进时,逐个启用严格选项可能会导致一些“虚假的”错误短暂出现,仅仅出现在中间的半严格状态。随着我们打开设置,错误数量会先上升后下降!

我个人期望启用严格选项是单调的:启用的选项越多 = 报错越多。但这一对选项违反了这种期望。

解决方案

在尝试使 TypeScript 代码库变得严格时,有几种方法可以“解决”这种奇怪现象:

  1. 直接用显式注解修复错误,例如 const array: number[] = []
  2. 使用不同的逐个启用顺序:先启用 noImplicitAny,然后再启用 strictNullChecks。如上表所示,按照这个顺序,两个步骤的推断结果都是 array: number[],因此没有错误。
  3. 同时启用它们:不要试图完全渐进,而是将这两个选项作为一步启用。

解释

为什么启用 strictNullChecks 并禁用 noImplicitAny 会导致一个在其他地方不出现的错误?jcalz 在 StackOverflow 上解释得很好,其核心是:

  • 这种有问题的组合是一个为了向后兼容而留下的边缘情况,其中 array 的类型在其声明处被推断为 never[],并在后续代码中被锁定。
  • 启用 noImplicitAny 会使编译器在模棱两可的位置(在没有 noImplicitAny 时会推断为 any 的地方)使用“演化”类型(evolving types,可理解为先推断为 any/never 然后后续追加推断的类型):因此,array 的类型不会在其声明行被确定,并且可以结合来自 push 的信息进行推断。

评论

这感觉像是一个有趣的脑筋急转弯,而不是一个重大问题:

  • 修复这些虚假错误并不是一个重大的负担或显著的浪费时间,而且可以说,添加注解可能使这类代码更清晰。
  • 半严格状态可能有奇怪的行为是可以理解的:我想 TypeScript 开发者更关心完全严格模式下的良好体验,希望中间状态只是垫脚石,而不是长期状态。

总结

TypeScript 选项 strictNullChecksnoImplicitAny 以一种奇怪的方式相互作用:以“错误”的顺序逐个启用它们会导致错误出现然后又消失,违反了单调性的期望(启用的严格选项越多 = 错误越多)。这可能发生在真实代码中,但影响极小,因为很容易解决和/或规避。

告别杂乱数字:用 Intl.NumberFormat 打造全球友好的前端体验

大家好,我是CC,在这里欢迎大家的到来~

开场

书接上文,Intl 下的 Segmenter 对象可以实现对文本的分割,Collator 对象可以处理字符串的比较,除此之外,还有数字格式化、日期格式化等其他功能。

这篇文章先来看看数字格式化,现在来理论加实践一下。

数字格式化

Intl.NumberFormat使数字在特定语言环境下格式化。

配置项

为了方便阅读,属性列表根据用途划分为多个部分,包括区域选项、样式选项、数字选项和其他选项。

区域选项

  • localeMatcher
    • 使用的区域匹配算法,可能的值包括:
    • 默认值为 best fit,还有 lookup
  • numberingSystem
    • 数字格式化的数字系统,像阿拉伯数字 arab、简体中文数字 hans、无衬线数字 mathsans
    • 默认值取决于区域
    • 同 locales 的 Unicode 扩展键 nu 设置,但优先级高于他

样式选项

  • style
    • 使用的格式化样式,可选的值包括:
    • decimal: 普通数字格式化
    • currency: 货币格式化
    • percent: 百分比格式化
    • unit: 单位格式化
    • 默认值是 decimal
  • currency
    • 货币格式化中使用的货币,像美元 USD、欧元 EUR 和人民币 CNY。
    • 没有默认值,style 为 currency 时必须提供,内容会被转换为大写。
  • currencyDisplay
    • 货币格式化中如何显示货币,可选的值包括:
    • code: 使用 ISO 货币代码
    • symbol: 使用本地化货币符号
    • narrowSymbol: 使用窄格式符号,像 100而不是US100 而不是 US100
    • name: 使用本地化货币名称,像 dollar
  • currencySign
    • 使用括号将数字括起来,而不是添加负号,可选的值包括:
    • standard: 默认值
    • accounting: 会计
  • unit
    • 格式化的单位
    • style 为 unit 时必填
  • unitDisplay
    • unit 格式化时使用的格式化风格,可选的值包括:
    • short: 默认值,例如 16 l
    • narrow: 例如 16l
    • long: 例如 16 litres

数字选项,由 Intl.PluralRules 支持

  • minimumIntegerDigits
    • 最小整数位数,默认值为 1,范围是 1~21
    • 若实际整数位数不足会在左侧用 0 补足,比如对于数字 5 该值设置为 3 则显示为“005”
  • minimumFractionDigits
    • 小数部分的最小位数,范围是 0~100
    • 若小数位数不足时会在右侧补 0,超过时会按四舍五入截断
    • 默认值对于普通数字和百分比是 0,对于 currency 是 2(ISO 4217 标准小数位数)
  • maximumFractionDigits
    • 小数部分的最大位数,范围是 0~100
    • 若小数位数不足时会在右侧补 0,超过时会按四舍五入截断
    • 默认值对于普通数字和百分比是 3,对于 currency 是 2(ISO 4217 标准小数位数)
  • minimumSignificanntDigits
    • 最小有效数字,默认值为 1。范围是 1~21。
    • 优先级高于 minimumFractionDigits
  • maximumSignificanntDigits
    • 最大有效数字,默认值为 21。范围是 1~21。
    • 优先级高于 maximumFractionDigits
  • roundingPriority
    • 当同时使用 FractionDigits 和 SignificantDigits 时指定如何解决四舍五入冲突,可选的值包括:
    • auto: 默认值,使用有效数字属性
    • morePrecision: 使用精度更高的属性
    • lessPrecision: 使用精度更低的属性
    • auto 属性会在 natation 为 compact 时且未设置任何四个 FractionDigits/SignificantDigits 时会被设置为 morePrecision
    • 除 auto 属性以外的值会根据 maximumSignificanntDigits 和 maximumFractionDigits 计算出更高精度,忽略最小小数位和有效数字位
  • roundingIncrement
    • 相对于计算出的舍入单位的舍入增量
    • 默认值为 1,其他值包括 1、2、5、10、20、25、50、100、200、250、500、1000、2000、5000
    • 不能与有效数字位舍入或任何 roundingPriority(除了 auto) 混合使用
  • roundingMode
    • 对小数进行舍入,可选的值包括:
    • ceil: 向正无穷舍入,正数向上,负数“向正”
    • floor: 向负无穷舍入,正数向下,负数“向负”
    • expand: 四舍五入远离 0,绝对值增大
    • trunc: 四舍五入朝向 0,绝对值减小
    • halfCeil: 趋向于正无穷舍入,包括半值
    • halfFloor: 趋向于负无穷舍入,包括半值
    • halfExpand: 默认值,半值远离 0 舍入
    • halfTrunc: 向 0 取整,包括半值
    • halfEven: 半值向最接近的偶数整数舍入,常用于统计,减少片差
  • trailingZeroDisplay
    • 整数末尾 0 的显示策略,可选的值包括:
    • auto: 默认值,根据 minimumFractionDigits 和 minimumSignificanntDigits 保持末尾 0
    • stripIfInteger: 如果小数部分全为 0 则删除小数部分,如果小数部分有任何非零数则与 auto 相同

其他选项

  • notation
    • 数字的显示格式,可选的值包括:
    • standard: 默认值,纯数字格式
    • scientific: 返回格式化数字的数量级
    • engineering: 返回能被 3 整除的 10 的指数
    • compact: 表示指数的字符串,默认使用 short 形式
  • compactDisplay
    • 仅当 notation 为 compact 时使用,可选的值包括:
    • short: 默认值
    • long
  • useGrouping
    • 是否使用分组分隔符,像千位分隔符或者千/十万/千万分隔符,可选的值包括:
    • always: 即使 locale 偏好不同也展示分组分隔符
    • auto: 根据 locale 偏好显示分组分隔符,也取决于货币
    • min2: 当一组数字至少有 2 位数字时显示分组分隔符
    • true: 同 always
    • false: 不展示分组分隔符
    • 当 notation 为 compact 时默认值为 min2,否则默认值为 auto
    • 字符串 true 和 false 会被转化为默认值
  • signDisplay
    • 何时显示数字符号,可选的值包括:
    • auto: 默认值
    • always: 总是显示
    • exceptZero: 正数和负数显示符号,但 0 不显示
    • negative: 仅显示负数的符号,不包括负零
    • never: 从不展示

格式化

format()方法会基于区域和格式化选项进行数字格式化。支持数字、大数和字符串。

数字可能因为太大或太小而丢失精度

const numberFormat = new Intl.NumberFormat("en-US");
console.log(numberFormat.format(987654321987654321));
// 987,654,321,987,654,300

但是使用大数就不会有问题

const numberFormat = new Intl.NumberFormat("en-US");
console.log(numberFormat.format(987654321987654321n));
// 987,654,321,987,654,321

字符串也不会丢失精度

const numberFormat = new Intl.NumberFormat("en-US");
console.log(numberFormat.format("987654321987654321"));
// 987,654,321,987,654,321

使用指数表示

const numberFormat = new Intl.NumberFormat("en-US");
const bigNum = 987654321987654321n;
console.log(numberFormat.format(`${bigNum}E-6`));
// 987,654,321,987.654

格式化分割成多部分

formatToParts()将会返回一个对象数组,包含格式化后的每一部分,适合用来自定义字符串格式化。

const number = 3500;

const formatter = new Intl.NumberFormat("de-DE", {
  style: "currency",
  currency: "EUR",
});

console.log(formatter.format(number));
// "3.500,00 €"

console.log(formatter.formatToParts(number));
// [
//   { type: "integer", value: "3" },
//   { type: "group", value: "." },
//   { type: "integer", value: "500" },
//   { type: "decimal", value: "," },
//   { type: "fraction", value: "00" },
//   { type: "literal", value: " " },
//   { type: "currency", value: "€" },
// ];

格式化数字范围

formatRange()返回一个字符串表示数字范围格式化后的内容。

const nf = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 0,
});

console.log(nf.formatRange(3, 5));
// "$3 – $5"

如果开始值和结束值四舍五入值相同或者完全相同时则会添加近似等于符号。

console.log(nf.formatRange(2.9, 3.1));
// "~$3"

格式化数字范围分割成多部分

formatRangeToParts()返回一个对象数组,包含格式化后的每一部分,适合用来自定义数字字符串的格式化范围。

const startRange = 3500;
const endRange = 9500;

const formatter = new Intl.NumberFormat("de-DE", {
  style: "currency",
  currency: "EUR",
});

console.log(formatter.formatRange(startRange, endRange));
// "3.500,00–9.500,00 €"

console.log(formatter.formatRangeToParts(startRange, endRange));
// [
//   { type: "integer", value: "3", source: "startRange" },
//   { type: "group", value: ".", source: "startRange" },
//   { type: "integer", value: "500", source: "startRange" },
//   { type: "decimal", value: ",", source: "startRange" },
//   { type: "fraction", value: "00", source: "startRange" },
//   { type: "literal", value: "–", source: "shared" },
//   { type: "integer", value: "9", source: "endRange" },
//   { type: "group", value: ".", source: "endRange" },
//   { type: "integer", value: "500", source: "endRange" },
//   { type: "decimal", value: ",", source: "endRange" },
//   { type: "fraction", value: "00", source: "endRange" },
//   { type: "literal", value: " ", source: "shared" },
//   { type: "currency", value: "€", source: "shared" },
// ]

获取配置项

const de = new Intl.NumberFormat("de-DE", {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 2,
  roundingIncrement: 5,
  roundingMode: "halfCeil",
});

const usedOptions = de.resolvedOptions();
console.log(usedOptions.locale); // "de-DE"
console.log(usedOptions.numberingSystem); // "latn"
console.log(usedOptions.compactDisplay); // undefined ("notation" not set to "compact")
console.log(usedOptions.currency); // "USD"
console.log(usedOptions.currencyDisplay); // "symbol"
console.log(usedOptions.currencySign); // "standard"
console.log(usedOptions.minimumIntegerDigits); // 1
console.log(usedOptions.minimumFractionDigits); // 2
console.log(usedOptions.maximumFractionDigits); // 2
console.log(usedOptions.minimumSignificantDigits); // undefined (maximumFractionDigits is set)
console.log(usedOptions.maximumSignificantDigits); // undefined (maximumFractionDigits is set)
console.log(usedOptions.notation); // "standard"
console.log(usedOptions.roundingIncrement); // 5
console.log(usedOptions.roundingMode); // halfCeil
console.log(usedOptions.roundingPriority); // auto
console.log(usedOptions.signDisplay); // "auto"
console.log(usedOptions.style); // "currency"
console.log(usedOptions.trailingZeroDisplay); // auto
console.log(usedOptions.useGrouping); // auto

判断返回支持的 locale

在给定的 locales 数组中判断出 NumberFormat 支持的 locales。但是可能每个浏览器支持的不大一样。

const locales = ["ban", "id-u-co-pinyin", "de-ID"];
const options = { localeMatcher: "lookup" };

console.log(Intl.NumberFormat.supportedLocalesOf(locales, options));
// ["id-u-co-pinyin", "de-ID"]

总结

Intl.NumberFormat用于根据语言和地区格式化数字内容,像把数字格式化为货币、百分比或带单位的本地化字符串,精确控制数字的小数位数、有效数字和整数部分的最小位数,设置丰富的舍入模式像四舍五入、向零舍入或银行家舍入法这些场景下都十分适用。

HTTP一些问题的解答(接上篇)

一、在弱网环境下HTTP1会比HTTP2更快的原因是啥?

在弱网环境(高延迟、高丢包率)下,HTTP/1.x 有时比 HTTP/2 表现更好,核心原因是 HTTP/2 的多路复用机制与 TCP 协议的固有缺陷在弱网下产生了 “负协同效应” ,而 HTTP/1.x 的多连接策略反而规避了这种风险。具体可从以下几个角度拆解:

1. 多路复用放大了 TCP 队头阻塞的影响

HTTP/2 的核心优势是 “多路复用”—— 所有请求通过单个 TCP 连接传输,不同请求的帧(Frame)在该连接中交错发送。但这也意味着:单个 TCP 数据包的丢失会阻塞所有请求

  • 弱网下的问题:弱网环境丢包率高(比如 5% 以上),TCP 层一旦丢失一个数据包(可能包含某个请求的帧),会触发重传机制。由于 TCP 是 “按序交付” 的,重传期间,该连接上所有后续数据包(无论属于哪个请求)都会被暂存在接收端的 TCP 缓冲区,无法提交给 HTTP/2 应用层。例如:一个 TCP 连接上有 10 个并发请求的帧在传输,若第 3 个请求的某个帧丢失,TCP 重传期间,后面 7 个请求的帧即使已到达,也会被阻塞,导致所有 10 个请求都变慢。
  • HTTP/1.x 的规避方式:HTTP/1.x 依赖多个并行 TCP 连接(浏览器通常限制为 6-8 个),每个连接处理一个串行请求。若某个连接发生丢包,仅影响该连接上的请求,其他连接的请求仍可正常传输。例如:8 个连接中 1 个丢包,仅 1 个请求受影响,其余 7 个可继续,整体效率反而更高。

2. HTTP/2 的复杂机制在弱网下 “水土不服”

HTTP/2 为优化性能引入的机制(如头部压缩、流优先级),在弱网环境下可能变成负担:

  • HPACK 头部压缩的脆弱性:HTTP/2 用 HPACK 算法压缩请求头,依赖客户端和服务器维护 “共享压缩上下文”(记录已传输的头部字段)。若传输过程中某个头部帧丢失,可能导致双方压缩上下文不一致,需要重新同步,反而增加额外的传输开销和延迟。而 HTTP/1.x 的头部虽未压缩(冗余字节多),但结构简单,单个请求的头部丢失仅影响该请求,无需复杂同步。
  • 流优先级调度的失效:HTTP/2 允许标记请求的优先级(如 CSS/JS 优先于图片),但弱网下,TCP 层的丢包和重传会打乱优先级 —— 高优先级请求的帧可能因低优先级帧的丢包而被阻塞,导致优先级机制失效。而 HTTP/1.x 虽无优先级调度,但多连接天然隔离了不同请求,重要资源可通过独立连接传输,受其他请求影响更小。

3. TCP 拥塞控制对单连接的 “惩罚” 更重

TCP 有拥塞控制机制(如慢启动、拥塞避免),当检测到丢包时,会剧烈降低发送窗口(减少数据发送速率),直到网络恢复。

  • HTTP/2 的单连接劣势:单个 TCP 连接发生丢包后,拥塞窗口会被大幅缩减,导致所有请求的传输速率同时下降。弱网下丢包频繁,单连接可能长期处于低速率状态,整体吞吐量骤降。
  • HTTP/1.x 的多连接优势:多个 TCP 连接独立进行拥塞控制,某个连接因丢包被降速时,其他连接可保持正常速率。即使部分连接受影响,整体吞吐量仍能维持在较高水平。

总结:弱网下 “简单” 比 “高效” 更可靠

HTTP/2 的设计是为了在良好网络环境下通过多路复用提升效率(减少连接开销、优化资源调度),但依赖 TCP 作为底层传输协议。而弱网环境的高丢包、高延迟会放大 TCP 队头阻塞的影响,让多路复用从 “优势” 变成 “劣势”。

HTTP/1.x 虽然存在连接开销大、串行请求的问题,但多连接的 “分散风险” 特性在弱网下更稳健 —— 丢包的影响被限制在单个连接内,整体表现反而更优。这也是为什么在 3G 网络、高丢包的偏远地区,部分场景下 HTTP/1.x 反而比 HTTP/2 更快的核心原因。

二、HTTP2只能有一个连接吗?

HTTP/2 不是 “只能有一个连接” ,而是推荐使用单个 TCP 连接实现多路复用,但协议本身并不禁止建立多个连接。是否使用单连接,更多是基于性能优化的 “最佳实践”,而非强制限制。

1. 为什么 HTTP/2 通常用 “单个连接”?

HTTP/2 的核心设计目标之一是解决 HTTP/1.x “多连接开销大” 的问题,因此推荐通过单个 TCP 连接承载所有请求,利用 “多路复用”(多个 Stream 在同一连接中交错传输)提升效率:

  • 减少连接建立成本:TCP 三次握手、TLS 握手都有延迟(尤其是首次连接),单个连接可避免多次握手的开销;
  • 优化拥塞控制:单个连接的 TCP 拥塞窗口(发送速率)可集中利用带宽,多个连接可能因各自的拥塞控制相互竞争带宽,反而降低整体效率;
  • 简化多路复用逻辑:单个连接中,所有 Stream 的帧(Frame)通过Stream ID区分,接收端更容易管理和调度,多个连接会增加状态同步的复杂度。

2. 什么情况下 HTTP/2 会用 “多个连接”?

虽然不推荐,但 HTTP/2 协议允许同一客户端与服务器建立多个 TCP 连接,常见场景包括:

  • 兼容性兜底:部分老旧服务器或中间代理(如 CDN 节点)对 HTTP/2 的多路复用支持不完善(比如不识别 Stream ID),客户端可能 fallback 到多个连接以确保通信正常;
  • 域名分片残留:HTTP/1.x 常用 “域名分片”(将资源分散到多个子域名,突破浏览器单域名连接数限制),迁移到 HTTP/2 后,若未完全改造,可能仍保留多个子域名的连接(每个子域名一个 HTTP/2 连接);
  • 故障隔离:若单个连接因网络问题(如长时间卡顿、丢包)不可用,客户端可新建一个 HTTP/2 连接继续传输,避免整体中断;
  • 带宽限制突破:某些场景下(如超大文件传输),单个 TCP 连接的拥塞控制可能无法充分利用带宽,通过多个连接 “并行” 传输(类似 HTTP/1.x)可提升吞吐量(但这是对 HTTP/2 设计的 “反用”,较少见)。

3. 浏览器的实际行为:单连接为主,多连接为辅

现代浏览器(如 Chrome、Firefox)对 HTTP/2 的实现遵循 “单连接优先” 原则:

  • 同一域名,默认只建立1 个 HTTP/2 连接,所有请求通过该连接的多路复用传输;
  • 若该连接出现异常(如 TCP 断连),浏览器会自动新建一个 HTTP/2 连接替代;
  • 不同域名,仍会建立独立的 HTTP/2 连接(与 HTTP/1.x 一致,因跨域连接无法共享)。

总结

HTTP/2 的 “单个连接” 是推荐的最佳实践(为了最大化多路复用的优势),但协议本身不限制连接数量。实际应用中,绝大多数场景会用单连接,仅在兼容性、故障恢复等特殊情况下使用多个连接。这种设计既保留了灵活性,又通过单连接默认策略解决了 HTTP/1.x 的核心性能问题。

三、HTTP2中,多路复用的原理是什么?

HTTP/2 的多路复用(Multiplexing)是其核心特性之一,本质是在单一 TCP 连接上同时处理多个请求 - 响应事务,解决了 HTTP/1.x 中 “队头阻塞”(Head-of-Line Blocking)和连接效率低下的问题。其实现原理可拆解为三个关键机制:

1. 帧(Frame):数据传输的最小单位

HTTP/2 将所有传输的数据(请求头、响应体等)拆分为二进制帧,每个帧大小固定(默认最大 16KB),并包含以下关键信息:

  • 流标识符(Stream ID) :标记该帧属于哪个 “流”(对应一个请求 - 响应);
  • 类型(Type) :区分帧的用途(如HEADERS帧承载请求头,DATA帧承载正文,SETTINGS帧配置参数等);
  • 长度(Length) :帧的实际数据大小;
  • 标志位(Flags) :附加控制信息(如END_STREAM标记流结束)。

二进制帧的设计相比 HTTP/1.x 的文本格式,不仅解析效率更高,更重要的是为 “交错传输” 奠定了基础。

2. 流(Stream):请求 - 响应的逻辑通道

每个请求 - 响应事务对应一个,流是 TCP 连接内的 “虚拟通道”,具有以下特性:

  • 双向性:一个流中可同时传输客户端到服务器(请求)和服务器到客户端(响应)的帧;
  • 唯一标识:每个流有唯一的Stream ID(客户端发起的流 ID 为奇数,服务器发起的为偶数);
  • 优先级:可通过PRIORITY帧指定流的优先级(如 CSS/JS 资源优先于图片),服务器据此调整帧的发送顺序;
  • 可中断与复用:流可被暂停、恢复或终止,释放的 ID 可被新流复用。

通过流的隔离,多个请求 - 响应的帧可以在同一 TCP 连接上 “交错传输”(如请求 A 的DATA帧和请求 B 的HEADERS帧交替发送),接收方再根据Stream ID将帧重新组装成完整的请求 / 响应。

3. 单一 TCP 连接的复用

HTTP/2 通过上述 “帧 + 流” 机制,实现了单一 TCP 连接上的多路复用

  • 所有请求 / 响应共享一个 TCP 连接,无需为每个请求建立新连接(减少三次握手 / 慢启动的开销);
  • 多个流的帧可并行传输,避免了 HTTP/1.x 中 “一个请求阻塞导致后续请求排队” 的队头阻塞问题;
  • 即使某个流因网络问题阻塞,其他流的帧仍可正常传输(仅影响单个流,不阻塞整个连接)。

对比 HTTP/1.x 的核心优势

HTTP/1.x HTTP/2 多路复用
多个请求需建立多个 TCP 连接(或串行复用同一连接) 所有请求共享单一 TCP 连接
文本格式传输,解析效率低 二进制帧传输,解析更快
一个请求阻塞会导致后续请求排队(队头阻塞) 流隔离,单个流阻塞不影响其他流

简言之,HTTP/2 的多路复用通过 “帧拆分 + 流标识 + 单连接复用”,彻底解决了 HTTP/1.x 的连接效率问题,大幅提升了高并发场景下的性能(如网页加载大量资源时)。

四、HTTP/1为啥会一个请求阻塞会导致后续请求排队(队头阻塞)?

HTTP/1.x 出现 “队头阻塞”(Head-of-Line Blocking)的核心原理,是由TCP 协议的 “按序交付” 特性HTTP/1.x 协议的 “串行请求 - 响应” 设计共同决定的,两者叠加导致了 “前一个请求阻塞后续所有请求” 的现象。

原理拆解:两层机制的叠加限制

1. 底层 TCP 协议的 “按序交付” 特性(根本原因)

TCP 是面向连接的 “可靠字节流协议”,其核心特性之一是 “按序交付”

  • 发送方会给每个数据包分配一个唯一的 “序号”,接收方必须按序号从小到大的顺序接收并组装数据;
  • 若中间某个数据包丢失(如网络波动),接收方会触发 “超时重传” 机制,等待发送方重新发送丢失的数据包;
  • 在丢失的数据包被重传并接收前,后续所有已到达的数据包即使完整,也会被暂存队列中,无法提交给应用层处理(因为顺序被打乱,无法保证数据完整性)。

这就是 “TCP 层的队头阻塞”—— 单个数据包的问题会阻塞后续所有数据的处理。

2. HTTP/1.x 协议的 “串行请求 - 响应” 设计(放大问题)

HTTP/1.x 运行在 TCP 之上,但其协议设计进一步放大了 TCP 的队头阻塞问题:

  • 无 “流标识” 机制:HTTP/1.x 没有像 HTTP/2 那样的 “流 ID” 来区分不同请求的数据包。接收方(如浏览器)只能通过 “请求发送顺序” 来匹配对应的响应。
  • 严格串行处理:在同一个 TCP 连接上,HTTP/1.x 要求:
    • 必须等前一个请求的完整响应被接收后,才能发送下一个请求;
    • 响应也必须按请求发送的顺序返回(否则接收方无法判断哪个响应对应哪个请求)。

最终导致队头阻塞的过程

假设在一个 TCP 连接上,浏览器按顺序发送 3 个请求:请求A → 请求B → 请求C,过程如下:

  1. 服务器正常返回响应A的部分数据,但中途某个数据包丢失;
  2. 由于 TCP 按序交付特性,接收方会等待丢失的数据包重传,此时响应A的后续数据和已到达的响应B响应C的完整数据,都会被暂存在 TCP 缓冲区中,无法提交给浏览器处理;
  3. 同时,由于 HTTP/1.x 的串行规则,浏览器必须等响应A完全接收后,才能处理响应B响应C—— 即使响应B响应C的数据早已到达,也只能排队等待。

最终,单个请求(A)的阻塞会像 “多米诺骨牌” 一样,导致后续所有请求(B、C)被卡住,这就是 HTTP/1.x 队头阻塞的完整原理。

总结

TCP 的 “按序交付” 导致单个数据包问题阻塞后续数据,而 HTTP/1.x 缺乏 “流标识” 和 “并行处理” 能力,只能通过 “串行请求 - 响应” 来保证数据匹配,两者叠加使得一个请求的延迟会阻塞同一连接上所有后续请求,这就是队头阻塞的本质。

一文精通-Mixin特性

Dart Mixin 详细指南

1. 基础 Mixin 用法

1.1 基本 Mixin 定义和使用

dart

// 定义 Mixin
mixin LoggerMixin {
  String tag = 'Logger';
  
  void log(String message) {
    print('[$tag] $message');
  }
  
  void debug(String message) {
    print('[$tag] DEBUG: $message');
  }
}

mixin ValidatorMixin {
  bool validateEmail(String email) {
    return RegExp(r'^[^@]+@[^@]+.[^@]+').hasMatch(email);
  }
  
  bool validatePhone(String phone) {
    return RegExp(r'^[0-9]{10,11}$').hasMatch(phone);
  }
}

// 使用 Mixin
class UserService with LoggerMixin, ValidatorMixin {
  void registerUser(String email, String phone) {
    if (validateEmail(email) && validatePhone(phone)) {
      log('用户注册成功: $email');
    } else {
      debug('注册信息验证失败');
    }
  }
}

void main() {
  final service = UserService();
  service.registerUser('test@example.com', '13800138000');
}

2. Mixin 定义抽象方法

dart

mixin AuthenticationMixin {
  // 抽象方法 - 强制混入类实现
  Future<String> fetchToken();
  
  // 具体方法 - 可以使用抽象方法
  Future<Map<String, dynamic>> getProfile() async {
    final token = await fetchToken();
    log('使用 token: $token 获取用户资料');
    return {'name': '张三', 'token': token};
  }
  
  void log(String message) {
    print('[Auth] $message');
  }
}

class ApiService with AuthenticationMixin {
  @override
  Future<String> fetchToken() async {
    // 实现抽象方法
    await Future.delayed(Duration(milliseconds: 100));
    return 'jwt_token_123456';
  }
}

void main() async {
  final api = ApiService();
  final profile = await api.getProfile();
  print('用户资料: $profile');
}

3. 使用 on 关键字限制 Mixin 范围

dart

// 基类
abstract class Animal {
  String name;
  Animal(this.name);
  
  void eat() {
    print('$name 正在吃东西');
  }
}

// 只能用于 Animal 及其子类的 Mixin
mixin WalkerMixin on Animal {
  void walk() {
    print('$name 正在行走');
    eat(); // 可以访问宿主类的方法
  }
}

mixin SwimmerMixin on Animal {
  void swim() {
    print('$name 正在游泳');
  }
}

// 正确使用
class Dog extends Animal with WalkerMixin {
  Dog(String name) : super(name);
  
  void bark() {
    print('$name: 汪汪!');
  }
}

// 错误使用(编译错误):
// class Robot with WalkerMixin {} // 错误:WalkerMixin 只能用于 Animal

void main() {
  final dog = Dog('小黑');
  dog.walk();  // 小黑 正在行走
  dog.bark();  // 小黑: 汪汪!
  dog.eat();   // 小黑 正在吃东西
}

4. 多 Mixin 组合

dart

// 功能模块化 Mixin
mixin ApiClientMixin {
  Future<Map<String, dynamic>> get(String url) async {
    print('GET 请求: $url');
    await Future.delayed(Duration(milliseconds: 100));
    return {'status': 200, 'data': '响应数据'};
  }
}

mixin CacheMixin {
  final Map<String, dynamic> _cache = {};
  
  void cacheData(String key, dynamic data) {
    _cache[key] = data;
  }
  
  dynamic getCache(String key) => _cache[key];
}

mixin LoggingMixin {
  void logRequest(String method, String url) {
    print('[${DateTime.now()}] $method $url');
  }
}

// 组合多个 Mixin
class NetworkService with ApiClientMixin, CacheMixin, LoggingMixin {
  Future<Map<String, dynamic>> fetchWithCache(String url) async {
    final cached = getCache(url);
    if (cached != null) {
      print('使用缓存数据');
      return cached;
    }
    
    logRequest('GET', url);
    final response = await get(url);
    cacheData(url, response);
    
    return response;
  }
}

void main() async {
  final service = NetworkService();
  final result1 = await service.fetchWithCache('/api/user');
  final result2 = await service.fetchWithCache('/api/user'); // 第二次使用缓存
}

5. 同名方法冲突与线性化顺序

dart

mixin A {
  String message = '来自A';
  
  void show() {
    print('A.show(): $message');
  }
  
  void methodA() {
    print('A.methodA()');
  }
}

mixin B {
  String message = '来自B';
  
  void show() {
    print('B.show(): $message');
  }
  
  void methodB() {
    print('B.methodB()');
  }
}

mixin C {
  String message = '来自C';
  
  void show() {
    print('C.show(): $message');
  }
}

// 父类
class Base {
  String message = '来自Base';
  
  void show() {
    print('Base.show(): $message');
  }
}

// 混入顺序:Base -> A -> B -> C(最后混入的优先级最高)
class MyClass extends Base with A, B, C {
  // 可以通过super调用线性化链中的方法
  @override
  void show() {
    super.show(); // 调用C的show方法
    print('MyClass.show() 完成');
  }
}

// 线性化顺序验证
class AnotherClass with C, B, A {
  // 顺序:Object -> C -> B -> A
  void test() {
    show(); // 调用A的show(最后混入)
    print(message); // 输出:来自A
  }
}

void main() {
  print('=== MyClass 测试 ===');
  final obj1 = MyClass();
  obj1.show();    // 调用C.show(),因为C最后混入
  print(obj1.message); // 输出:来自C
  
  print('\n=== AnotherClass 测试 ===');
  final obj2 = AnotherClass();
  obj2.test();
  
  print('\n=== 方法调用链 ===');
  obj1.methodA(); // 可以调用
  obj1.methodB(); // 可以调用
  
  // 验证类型
  print('\n=== 类型检查 ===');
  print(obj1 is Base); // true
  print(obj1 is A);    // true
  print(obj1 is B);    // true
  print(obj1 is C);    // true
}

6. 复杂的线性化顺序示例

dart

class Base {
  void execute() => print('Base.execute()');
}

mixin Mixin1 {
  void execute() {
    print('Mixin1.execute() - 开始');
    super.execute();
    print('Mixin1.execute() - 结束');
  }
}

mixin Mixin2 {
  void execute() {
    print('Mixin2.execute() - 开始');
    super.execute();
    print('Mixin2.execute() - 结束');
  }
}

mixin Mixin3 {
  void execute() {
    print('Mixin3.execute() - 开始');
    super.execute();
    print('Mixin3.execute() - 结束');
  }
}

class MyService extends Base with Mixin1, Mixin2, Mixin3 {
  @override
  void execute() {
    print('MyService.execute() - 开始');
    super.execute(); // 调用链:Mixin3 -> Mixin2 -> Mixin1 -> Base
    print('MyService.execute() - 结束');
  }
}

void main() {
  final service = MyService();
  service.execute();
  
  // 输出顺序:
  // MyService.execute() - 开始
  // Mixin3.execute() - 开始
  // Mixin2.execute() - 开始
  // Mixin1.execute() - 开始
  // Base.execute()
  // Mixin1.execute() - 结束
  // Mixin2.execute() - 结束
  // Mixin3.execute() - 结束
  // MyService.execute() - 结束
}

7. 工厂模式与 Mixin

dart

// 可序列化接口
abstract class Serializable {
  Map<String, dynamic> toJson();
}

// Mixin 提供序列化功能
mixin JsonSerializableMixin implements Serializable {
  @override
  Map<String, dynamic> toJson() {
    final json = <String, dynamic>{};
    
    // 使用反射获取所有字段(实际项目中可能需要 dart:mirrors 或代码生成)
    // 这里简化处理
    for (final field in _getFields()) {
      json[field] = _getFieldValue(field);
    }
    
    return json;
  }
  
  List<String> _getFields() {
    // 实际实现应使用反射
    return [];
  }
  
  dynamic _getFieldValue(String field) {
    // 实际实现应使用反射
    return null;
  }
}

// 使用 Mixin 增强类的功能
class User with JsonSerializableMixin {
  final String name;
  final int age;
  
  User(this.name, this.age);
  
  @override
  List<String> _getFields() => ['name', 'age'];
  
  @override
  dynamic _getFieldValue(String field) {
    switch (field) {
      case 'name': return name;
      case 'age': return age;
      default: return null;
    }
  }
}

void main() {
  final user = User('张三', 25);
  print(user.toJson()); // {name: 张三, age: 25}
}

8. 依赖注入模式中的 Mixin

dart

// 服务定位器 Mixin
mixin ServiceLocatorMixin {
  final Map<Type, Object> _services = {};
  
  void registerService<T>(T service) {
    _services[T] = service;
  }
  
  T getService<T>() {
    final service = _services[T];
    if (service == null) {
      throw StateError('未找到服务: $T');
    }
    return service as T;
  }
}

// 网络服务
class NetworkService {
  Future<String> fetchData() async {
    await Future.delayed(Duration(milliseconds: 100));
    return '网络数据';
  }
}

// 数据库服务
class DatabaseService {
  Future<String> queryData() async {
    await Future.delayed(Duration(milliseconds: 50));
    return '数据库数据';
  }
}

// 使用 Mixin 的应用类
class MyApp with ServiceLocatorMixin {
  MyApp() {
    // 注册服务
    registerService(NetworkService());
    registerService(DatabaseService());
  }
  
  Future<void> run() async {
    final network = getService<NetworkService>();
    final database = getService<DatabaseService>();
    
    final results = await Future.wait([
      network.fetchData(),
      database.queryData(),
    ]);
    
    print('结果: $results');
  }
}

void main() async {
  final app = MyApp();
  await app.run();
}

9. Mixin 最佳实践示例

dart

// 1. 单一职责的 Mixin
mixin EquatableMixin<T> {
  bool equals(T other);
  
  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is T && equals(other);
      
  @override
  int get hashCode => toString().hashCode;
}

mixin CloneableMixin<T> {
  T clone();
}

// 2. 带生命周期的 Mixin
mixin LifecycleMixin {
  bool _isInitialized = false;
  
  void initialize() {
    if (!_isInitialized) {
      _onInit();
      _isInitialized = true;
    }
  }
  
  void dispose() {
    if (_isInitialized) {
      _onDispose();
      _isInitialized = false;
    }
  }
  
  // 钩子方法
  void _onInit() {}
  void _onDispose() {}
}

// 3. 可观察的 Mixin
mixin ObservableMixin {
  final List<Function()> _listeners = [];
  
  void addListener(Function() listener) {
    _listeners.add(listener);
  }
  
  void removeListener(Function() listener) {
    _listeners.remove(listener);
  }
  
  void notifyListeners() {
    for (final listener in _listeners) {
      listener();
    }
  }
}

// 使用多个 Mixin 的模型类
class UserModel with EquatableMixin<UserModel>, CloneableMixin<UserModel>, ObservableMixin {
  String name;
  int age;
  
  UserModel(this.name, this.age);
  
  @override
  bool equals(UserModel other) =>
      name == other.name && age == other.age;
      
  @override
  UserModel clone() => UserModel(name, age);
  
  void updateName(String newName) {
    name = newName;
    notifyListeners(); // 通知观察者
  }
  
  @override
  String toString() => 'User(name: $name, age: $age)';
}

void main() {
  final user1 = UserModel('Alice', 30);
  final user2 = UserModel('Alice', 30);
  final user3 = user1.clone();
  
  print('user1 == user2: ${user1 == user2}'); // true
  print('user1 == user3: ${user1 == user3}'); // true
  
  // 添加监听器
  user1.addListener(() {
    print('用户数据已更新!');
  });
  
  user1.updateName('Bob'); // 触发监听器
}

Mixin 详细总结

特性总结

特性 说明
定义方式 使用 mixin 关键字定义
使用方式 使用 with 关键字混入到类中
继承限制 每个类只能继承一个父类,但可以混入多个 Mixin
实例化 Mixin 不能被实例化,只能被混入
构造函数 Mixin 不能声明构造函数(无参构造函数除外)
抽象方法 可以包含抽象方法,强制宿主类实现
范围限制 可以使用 on 关键字限制 Mixin 只能用于特定类
线性化顺序 混入顺序决定方法调用优先级(最后混入的优先级最高)
类型系统 Mixin 在类型系统中是透明的,宿主类拥有 Mixin 的所有接口

使用场景

  1. 横切关注点(Cross-cutting Concerns)

    • 日志记录、权限验证、性能监控
    • 数据验证、格式转换
  2. 功能组合(Feature Composition)

    • UI 组件的功能组合
    • 服务类的功能增强
  3. 接口增强(Interface Enhancement)

    • 为现有类添加额外功能而不修改原始类
    • 实现装饰器模式
  4. 代码复用(Code Reuse)

    • 将通用逻辑抽离为可复用模块
    • 避免重复代码

优点

  1. 灵活性高:可以组合多个 Mixin,实现类似多继承的效果
  2. 解耦性强:功能模块化,职责单一
  3. 避免钻石问题:通过线性化顺序解决多继承中的歧义问题
  4. 类型安全:编译时检查,运行时性能好
  5. 易于测试:可以单独测试 Mixin 的功能

缺点

  1. 理解成本:线性化顺序需要理解
  2. 调试困难:方法调用链可能较长
  3. 过度使用风险:可能导致类结构复杂
  4. 命名冲突:不同 Mixin 的同名方法可能冲突

最佳实践

  1. 单一职责:每个 Mixin 只负责一个明确的功能
  2. 命名清晰:使用 Mixin 后缀,如 LoggerMixin
  3. 适度使用:避免过度使用导致代码难以理解
  4. 文档注释:说明 Mixin 的作用和使用方式
  5. 考虑替代方案:有时继承或组合可能是更好的选择

与相关概念的对比

概念 与 Mixin 的区别
抽象类 可以有构造函数、可以有状态;Mixin 不能有构造函数
接口 只定义契约,不提供实现;Mixin 可以提供实现
扩展方法 在类外部添加方法;Mixin 在类内部添加
继承 单继承,强调 "is-a" 关系;Mixin 强调 "has-a" 或 "can-do" 关系

Mixin 是 Dart 语言中非常强大的特性,合理使用可以让代码更加模块化、可复用和可维护。

1. 什么是 Mixin?它的主要作用是什么?

精准回答:
"Mixin 是 Dart 中一种代码复用机制,它允许一个类通过 with 关键字混入一个或多个独立的功能模块。Mixin 的主要作用是解决 Dart 单继承的限制,实现类似多继承的效果,让代码更加模块化和可复用。"

加分点:

  • 强调 "代码复用机制" 而非 "继承机制"
  • 提到 "单继承限制" 和 "类似多继承"
  • 说明主要使用场景:横向功能扩展

2. Mixin 和继承、接口有什么区别?

精准回答(表格对比):

特性 Mixin 继承 接口
关系 "具有" 功能 (has-a) "是一个" (is-a) "能做什么" (can-do)
数量 可多个 单继承 可实现多个
实现 可包含具体实现 可包含具体实现 只定义契约
构造函数 不能有(除无参) 可以有 不能有
关键字 with extends implements

详细补充:
"Mixin 强调的是功能组合,让类获得某些能力;继承强调的是父子关系;接口强调的是契约实现。Mixin 提供了比接口更灵活的实现复用,又避免了传统多继承的复杂性。"

3. Mixin 的线性化顺序是什么?如何确定?

精准回答:
"Mixin 的线性化顺序遵循以下规则:

  1. 从继承链的最顶端开始
  2. 按照 with 关键字后 Mixin 的声明顺序,从左到右处理
  3. 最后混入的 Mixin 优先级最高

线性化算法:  深度优先,从左到右,不重复。"

示例说明:

dart

class A {}
mixin B {}
mixin C {}
class D extends A with B, C {}
// 线性化顺序:A → B → C → D
// 方法查找顺序:D → C → B → A → Object

4. Mixin 可以包含抽象方法吗?有什么作用?

精准回答:
"可以。Mixin 中包含抽象方法的主要作用是:

  1. 强制约束:强制混入类必须实现某些方法
  2. 模板方法模式:在 Mixin 中定义算法骨架,抽象方法由混入类具体实现
  3. 依赖注入:要求宿主类提供必要的依赖或实现"

示例:

dart

mixin ValidatorMixin {
  bool validate(String input); // 抽象方法
  void validateAndProcess(String input) {
    if (validate(input)) {
      // 处理逻辑
    }
  }
}

5. on 关键字在 Mixin 中有什么作用?

精准回答:
"on 关键字用于限制 Mixin 的使用范围,确保 Mixin 只能用于特定类型或其子类。主要有两个作用:

  1. 类型安全:防止误用,确保 Mixin 只在合适的上下文中使用
  2. 访问宿主类成员:可以安全地访问宿主类的方法和属性"

示例:

dart

mixin Walker on Animal {
  void walk() {
    move(); // 可以安全调用 Animal 的方法
  }
}
// 只能用于 Animal 及其子类

6. 多个 Mixin 有同名方法时如何解决冲突?

精准回答:
"Dart 通过线性化顺序解决同名方法冲突:

  1. 最后混入的优先级最高:线性化链中靠后的覆盖前面的
  2. 可以使用 super:调用线性化链中下一个实现
  3. 可以重写覆盖:在宿主类中重写方法进行统一处理

这是编译时确定的,不会产生运行时歧义。"

冲突解决示例:

dart

class MyClass with A, B {
  @override
  void conflictMethod() {
    // 调用特定 Mixin 的方法
    super.conflictMethod(); // 调用 B 的实现
  }
}

7. Mixin 可以有构造函数吗?为什么?

精准回答:
"Mixin 不能声明有参数的构造函数,只能有默认的无参构造函数。这是因为:

  1. 初始化顺序问题:多个 Mixin 的构造函数调用顺序难以确定
  2. 简化设计:避免复杂的初始化逻辑冲突
  3. 职责分离:Mixin 应该专注于功能实现,而不是对象构建

如果需要初始化逻辑,可以使用初始化方法配合调用。"

8. Mixin 在实际项目中有哪些典型应用场景?

精准回答(结合实际经验):
"在实际项目中,我主要将 Mixin 用于:

  1. 横切关注点(Cross-cutting Concerns)

    • 日志记录、性能监控、异常处理
    • 权限验证、数据校验
  2. UI 组件功能组合

    dart

    class Button with HoverEffect, RippleEffect, TooltipMixin {}
    
  3. 服务层功能增强

    dart

    class ApiService with CacheMixin, RetryMixin, LoggingMixin {}
    
  4. 设计模式实现

    • 装饰器模式:动态添加功能
    • 策略模式:算法切换"

9. Mixin 的优缺点是什么?

精准回答:
优点:

  1. 灵活复用:突破单继承限制
  2. 模块化:功能分离,职责单一
  3. 避免重复:DRY 原则
  4. 组合优于继承:更灵活的设计

缺点:

  1. 理解成本:线性化顺序需要理解
  2. 调试困难:调用链可能很深
  3. 命名冲突:需要合理设计
  4. 过度使用风险:可能导致 "瑞士军刀" 类

10. 什么时候应该使用 Mixin?什么时候不应该使用?

精准回答:
"应该使用 Mixin 的情况:

  1. 需要横向复用功能时
  2. 功能相对独立,不依赖过多上下文
  3. 多个类需要相同功能但类型层次不同时
  4. 需要动态组合功能时

不应该使用 Mixin 的情况:

  1. 功能之间有强耦合时
  2. 需要初始化复杂状态时
  3. 功能是类的核心职责时(应该用继承)
  4. 简单的工具方法(考虑用扩展方法)"

11. Mixin 和扩展方法(Extension Methods)有什么区别?

精准回答:
"两者都用于扩展类型功能,但适用场景不同:

方面 Mixin 扩展方法
作用域 类内部 类外部
访问权限 可访问私有成员 只能访问公开成员
适用性 需要状态时 纯函数操作时
使用方式 with 关键字 extension 关键字

扩展方法适合为现有类添加静态工具方法,Mixin 适合为类添加有状态的复杂功能。"

12. 如何处理 Mixin 之间的依赖关系?

精准回答:
"处理 Mixin 依赖关系的几种策略:

  1. 使用 on 限制:确保 Mixin 只在合适的上下文中使用
  2. 接口抽象:通过抽象方法定义依赖契约
  3. 组合模式:让一个 Mixin 依赖另一个 Mixin
  4. 依赖查找:通过服务定位器获取依赖

最佳实践:  保持 Mixin 尽可能独立,依赖通过抽象定义。"

高级面试问题回答技巧

技术深度展示:

当被问到复杂问题时,展示对底层机制的理解:

示例回答:
"Mixin 的线性化机制实际上是编译时进行的,Dart 编译器会生成一个线性的类层次结构。从实现角度看,Mixin 会被编译为普通的类,然后通过代理模式将方法调用转发到正确的实现。"

结合实际项目:

"在我之前的电商项目中,我们使用 Mixin 实现了购物车的各种行为:

  • WithCacheMixin:缓存商品信息
  • WithValidationMixin:验证库存和价格
  • WithAnalyticsMixin:记录用户行为
    这样每个业务模块都可以按需组合功能。"

展示设计思考:

"在设计 Mixin 时,我遵循 SOLID 原则:

  • 单一职责:每个 Mixin 只做一件事
  • 开闭原则:通过 Mixin 扩展而非修改
  • 接口隔离:定义清晰的抽象方法
  • 依赖倒置:依赖抽象而非具体实现"

常见陷阱与解决方案

陷阱 1:状态共享问题

问题:  "多个类混入同一个 Mixin 会共享状态吗?"

回答:  "不会。每个实例都有自己的 Mixin 状态副本。Mixin 中的字段在编译时会复制到宿主类中,每个实例独立。"

陷阱 2:初始化顺序

问题:  "如果多个 Mixin 都需要初始化怎么办?"

回答:  "使用初始化方法模式:

dart

mixin Initializable {
  void initialize() {
    // 初始化逻辑
  }
}

class MyClass with A, B {
  void init() {
    // 按需调用初始化
    (this as A).initialize();
    (this as B).initialize();
  }
}

JavaScript 列表转树(List to Tree)详解:前端面试中如何从递归 O(n²) 优化到一次遍历 O(n)

前言:Offer 是怎么没的?

在前端面试的江湖里,「列表转树(List to Tree)」 是一道妥妥的高频题。

很多同学一看到这道题,内心 OS 都是:

😎「简单啊,递归!」

代码写完,自信抬头。
面试官却慢悠悠地问了一句:

🤨「如果是 10 万条数据 呢?
👉 时间复杂度多少?
👉 会不会栈溢出?」

空气突然安静。

今天这篇文章,我们就把这道题彻底拆开:
从「能写」到「写得对」,再到「写得漂亮」。


一、为什么面试官总盯着这棵“树”?

因为在真实业务中,后端给你的几乎永远是扁平数据

例如:

const list = [
  { id: 1, parentId: 0, name: '北京市' },
  { id: 2, parentId: 1, name: '顺义区' },
  { id: 3, parentId: 1, name: '朝阳区' },
  { id: 4, parentId: 2, name: '后沙峪' },
  { id: 121, parentId: 0, name: '江西省' },
  { id: 155, parentId: 121, name: '抚州市' }
];

而前端组件(Menu、Tree、Cascader)要的却是👇

省
 └─ 市
     └─ 区

🎯 面试官的真实考点

  • 数据结构理解:是否真正理解 parentId
  • 递归意识 & 代价:不只会写,还要知道坑在哪
  • 性能优化能力:能否从 O(n²) 优化到 O(n)
  • JS 引用理解:是否理解对象在内存中的表现

二、第一重境界:递归法(能写,但不稳)

1️⃣ 最基础的递归写法

function list2tree(list, parentId = 0) {
  return list
    .filter(item => item.parentId === parentId)
    .map(item => ({
      ...item,
      children: list2tree(list, item.id)
    }));
}

逻辑非常直观:

  • 找当前 parentId 的所有子节点
  • 对每个子节点继续递归
  • 没有子节点时自然退出

三、进阶:ES6 优雅写法(看起来很高级)

如果你在面试中写出下面这段代码👇
面试官大概率会先点头。

const list2tree = (list, parentId = 0) =>
  list
    .filter(item => item.parentId === parentId)
    .map(item => ({
      ...item,              // 解构赋值,保持原对象纯净
      children: list2tree(list, item.id)
    }));

这一版代码:

  • ✅ 箭头函数
  • filter + map 链式调用
  • ✅ 解构赋值,不污染原数据
  • ✅ 可读性很好,看起来很“ES6”

👉 很多同学到这一步就觉得稳了。


🤔 面试官的经典追问

「这个方案,有什么问题?」


🎯 标准回答(一定要说出来)

「这个方案的本质是 嵌套循环
每一层递归,都会遍历一次完整的 list

👉 时间复杂度是 O(n²)
👉 如果层级过深,还可能导致 栈溢出(Stack Overflow) 。」

📌 一句话总结

ES6 写法只是“看起来优雅”,
性能问题不会因为代码好看就自动消失。


四、第二重境界:Map 优化(面试及格线)

既然慢,是因为反复遍历找父节点
那就用 Map 建立索引

👉 典型的:空间换时间


核心思路

  1. 第一遍:把所有节点放进 Map
  2. 第二遍:通过 parentId 直接挂载
  3. 利用 JS 对象引用,自动同步树结构

代码实现

function listToTreeWithMap(list) {
  const map = new Map();
  const tree = [];

  // 初始化
  for (const item of list) {
    map.set(item.id, { ...item, children: [] });
  }

  // 构建树
  for (const item of list) {
    const node = map.get(item.id);
    if (item.parentId === 0) {
      tree.push(node);
    } else {
      const parent = map.get(item.parentId);
      parent && parent.children.push(node);
    }
  }

  return tree;
}

⏱ 复杂度分析

  • 时间复杂度:O(n)
  • 空间复杂度:O(n)

📌 到这一步,已经可以应付大多数面试了。


五、终极奥义:一次遍历 + 引用魔法(Top Tier)

面试官:
「能不能只遍历一次?」

答案是:能,而且这才是天花板解法。


核心精髓:占位 + 引用同步

  • 子节点可能先于父节点出现
  • 先在 Map 里给父节点 占位
  • 后续再补全数据
  • 引用地址始终不变,树会“自己长好”

代码实现(一次遍历)

function listToTreePerfect(list) {
  const map = new Map();
  const tree = [];

  for (const item of list) {
    const { id, parentId } = item;

    if (!map.has(id)) {
      map.set(id, { children: [] });
    }

    const node = map.get(id);
    Object.assign(node, item);

    if (parentId === 0) {
      tree.push(node);
    } else {
      if (!map.has(parentId)) {
        map.set(parentId, { children: [] });
      }
      map.get(parentId).children.push(node);
    }
  }

  return tree;
}

🏆 为什么这是王者解法?

  • ✅ 一次遍历,O(n)
  • ✅ 支持乱序数据
  • ✅ 深度理解 JS 引用机制
  • ✅ 面试官一眼就懂你是“真会”

六、真实开发中的应用场景

  • 🔹 权限 / 菜单树(Ant Design / Element)
  • 🔹 省市区 / Cascader
  • 🔹 文件目录结构(云盘、编辑器)

七、面试总结 & 避坑指南

方案 时间复杂度 评价
递归 O(n²) 能写,但危险
Map 两次遍历 O(n) 面试合格
一次遍历 O(n) 面试加分

面试加分表达

  • 主动提 空间换时间
  • 点出 JS 对象是引用类型
  • 询问 parentId 是否可能为 null
  • 说明是否会修改原数据(必要时深拷贝)

结语

算法不是为了为难人,
而是为了在复杂业务中,
选出那条最稳、最优雅的路。

如果这篇文章对你有帮助👇
👍 点个赞
💬 评论区聊聊你在项目里遇到过的奇葩数据结构

❌