阅读视图

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

【LeetCode 刷题系列 | 第 1 篇】前端老司机拆解接雨水问题,从暴力到最优解💦

🌧️ 前言

Hello~大家好。我是秋天的一阵风

欢迎来到我的 LeetCode 刷题系列专栏~ 作为一名深耕前端多年的老司机,我深知算法能力对前端工程师的重要性 —— 它不仅能帮我们在面试中脱颖而出,更能提升日常业务代码的逻辑严谨性和性能优化能力。

今天咱们要攻克的是 LeetCode 中的经典 hard 题「接雨水」,这道题堪称 “面试高频钉子户”,考察的核心是对数组遍历和边界判断的理解。

很多同学一开始会被它唬住,但只要咱们从基础思路慢慢拆解,再逐步优化,就能轻松拿捏!话不多说,咱们直奔主题~

一、LeetCode 接雨水题目详情

1. 题目描述

给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。

题目链接42. 接雨水 - 力扣(LeetCode)

2. 示例演示

image.png
  • 输入:height = [0,1,0,2,1,0,1,3,2,1,2,1]
  • 输出:6
  • 解释:如题目中的高度图所示,下雨后能接住 6 单位的雨水。
  • 输入:height = [4,2,0,3,2,5]
  • 输出:9

3. 难度级别

🔴 困难:这道题之所以被归为困难,是因为它需要突破常规的遍历思维,从 “局部最优” 推导 “全局最优”。但只要掌握了核心逻辑,它其实是一道 “纸老虎” 题~

二、解题思路大剖析

1. 暴力解法:直捣黄龙的基础思路

暴力解法的核心逻辑很朴素:逐个计算每个柱子能接的雨水量,最后求和。而一个柱子能接多少水,完全取决于它左右两侧的 “最高屏障”—— 也就是左右两侧最高柱子中较矮的那一个,用这个较矮值减去当前柱子的高度,就是该位置能接的水量(若结果为负,则接 0 水)。

核心步骤拆解:

  1. 遍历数组中的每一个柱子(索引从 0 到 n-1);
  1. 对当前柱子 i,向左遍历所有柱子,找到左侧的最高高度leftMax
  1. 对当前柱子 i,向右遍历所有柱子,找到右侧的最高高度 rightMax
  1. 计算当前柱子的接水量:Math.max(0, Math.min(leftMax, rightMax) - height[i]);
  1. 累加所有柱子的接水量,得到总水量。

分步拆解演示(以输入 [0,1,0,2,1,0,1,3,2,1,2,1] 为例):

image.png

咱们逐个扒开每个位置的接水逻辑,从索引 0 开始:

  • 索引 0:左侧没有柱子,leftMax=0;右侧最高柱子高度是 3。min(0,3)=0,接水量 0-0=0;
  • 索引 1:左侧最高是 0,右侧最高是 3。min(0,3)=0,接水量 0-1=-1,取 0;
  • 索引 2:左侧遍历 [0,1],最高是 1;右侧遍历 [2,1,0,1,3,2,1,2,1],最高是 3。min(1,3)=1,接水量 1-0=1;
  • 索引 3:左侧最高是 1,右侧最高是 3。min(1,3)=1,接水量 1-2=-1,取 0;
  • 索引 4:左侧最高是 2,右侧最高是 3。min(2,3)=2,接水量 2-1=1;
  • 索引 5:左侧最高是 2,右侧最高是 3。min(2,3)=2,接水量 2-0=2;
  • 索引 6:左侧最高是 2,右侧最高是 3。min(2,3)=2,接水量 2-1=1;
  • 索引 7:左侧最高是 2,右侧最高是 2。min(2,2)=2,接水量 2-2=0;
  • 索引 8:左侧最高是 3,右侧最高是 2。min(3,2)=2,接水量 2-2=0;
  • 索引 9:左侧最高是 3,右侧最高是 2。min(3,2)=2,接水量 2-1=1;
  • 索引 10:左侧最高是 3,右侧最高是 1。min(3,1)=1,接水量 1-2=-1,取 0;
  • 索引 11:右侧没有柱子,接水量 0;

把这些有效接水量相加:1+1+2+1+1=6,和示例输出一致!

JavaScript 代码实现(暴力解法):

/**
 * @param {number[]} height
 * @return {number}
 */
var trap = function(height) {
    const n = height.length;
    let total = 0; // 总接水量
    // 遍历每个柱子(除了首尾也可以,但不影响结果,代码更简洁)
    for (let i = 0; i < n; i++) {
        let leftMax = 0; // 左侧最高柱子高度
        let rightMax = 0; // 右侧最高柱子高度
        // 向左遍历,找左侧最高
        for (let j = i; j >= 0; j--) {
            leftMax = Math.max(leftMax, height[j]);
        }
        // 向右遍历,找右侧最高
        for (let j = i; j < n; j++) {
            rightMax = Math.max(rightMax, height[j]);
        }
        // 计算当前柱子的接水量,累加到总水量
        total += Math.min(leftMax, rightMax) - height[i];
    }
    return total;
};
// 测试用例
console.log(trap([0,1,0,2,1,0,1,3,2,1,2,1])); // 输出6
console.log(trap([4,2,0,3,2,5])); // 输出9

暴力解法的优缺点:

  • 优点:思路简单直观,容易理解,适合作为入门思路;
  • 缺点:时间复杂度极高,为 O (n²)(每个柱子都要左右遍历一次),当 n 较大时(比如 10^4)会直接超时,不适合实际面试场景。

2. 双指针解法:空间优化的最优思路

暴力解法的问题在于 “重复遍历”,双指针的核心是利用左右两侧的最大值关系,在一次遍历中完成计算,把时间复杂度降到 O (n),空间复杂度优化到 O (1)。

核心原理:

  • 定义左右指针 left(初始 0)和 right(初始 n-1);
  • 定义 leftMax(左侧已遍历的最高高度)和 rightMax(右侧已遍历的最高高度);
  • 当 height[left] <= height[right] 时,左侧的最大值 leftMax 决定了当前 left 位置的接水量(因为右侧有更高的柱子兜底);
  • 反之,右侧的最大值 rightMax 决定当前 right 位置的接水量;
  • 遍历过程中不断更新 leftMax、rightMax 和总水量,直到指针相遇。

JavaScript 代码实现(双指针解法):

/**
 * @param {number[]} height
 * @return {number}
 */
var trap = function(height) {
    const n = height.length;
    if (n < 3) return 0; // 少于3个柱子无法接水
    let left = 0, right = n - 1;
    let leftMax = 0, rightMax = 0;
    let total = 0;
    while (left < right) {
        // 左侧柱子更矮,以leftMax为基准
        if (height[left] <= height[right]) {
            if (height[left] >= leftMax) {
                leftMax = height[left]; // 更新左侧最高
            } else {
                total += leftMax - height[left]; // 计算当前位置接水量
            }
            left++; // 左指针右移
        } else {
            // 右侧柱子更矮,以rightMax为基准
            if (height[right] >= rightMax) {
                rightMax = height[right]; // 更新右侧最高
            } else {
                total += rightMax - height[right]; // 计算当前位置接水量
            }
            right--; // 右指针左移
        }
    }
    return total;
};
// 测试用例
console.log(trap([0,1,0,2,1,0,1,3,2,1,2,1])); // 输出6
console.log(trap([4,2,0,3,2,5])); // 输出9

三、复杂度分析

1. 时间复杂度

  • 暴力解法:O (n²),双层循环导致重复遍历;
  • 双指针解法:O (n),一次遍历完成所有计算;

2. 空间复杂度

  • 暴力解法:O (1),仅使用常数级额外空间;
  • 双指针解法:O (1),同样仅使用常数级额外空间;

总结

好啦,今天的接雨水问题就讲到这里!相信大家已经对这道题的各种解法了如指掌。如果你有更优的思路,或者在刷题过程中遇到了疑问,欢迎在评论区留言讨论~

下一篇专栏,咱们将攻克另一道前端面试高频题,猜猜是什么?关注我,刷题不迷路!咱们下期再见~ 👋

【面试必问】手撕 LeetCode “三数之和”:双指针+去重,这一篇图解给你讲透!

前言:为什么你总倒在“三数之和”?

太多面试者在 LeetCode 第1题 “两数之和”  上重拳出击,用 HashMap 秒杀全场;然而一旦题目变成 第15题 “三数之和” ,由于无法直接套用 Hash 策略,很多人就开始支支吾吾,最后写出了一个 

O(n3)

 的暴力三层循环。

兄弟们,面试写

O(n3)

,基本上就是“回家等通知”的节奏了,还能打包一份"凉面"。  

其实,这道题是考察  “排序 + 双指针”  的经典范例。它的难点不在于找数字,而在于 如何优雅地去重。今天,就带你一步步拆解这道大厂必考题,保证你下次遇到能手撕得明明白白!


一、核心思路:降维打击(排序 + 双指针)

解决“三数之和”的核心在于将 三维问题降低到二维

如果我们固定其中一个数字(假设为 nums[i]),那么问题就变成了:在剩下的数组中,找到两个数 left 和 right,使得 nums[left] + nums[right] = -nums[i]

看!这不就变回我们熟悉的“两数之和”了吗?

但是,为了让双指针能跑起来,我们需要一个前提:数组必须是有序的

1. 为什么要排序?

这就要我们深刻理解 sort 的意义:

JavaScript

// a - b < 0  => a 在前 b 在后 (升序)
nums.sort((a, b) => a - b);

排序有两个巨大的好处:

  1. 单调性:数组有序后,如果三数之和偏大,我们只能通过左移右指针来减小总和;反之亦然。这是双指针能生效的物理基础。
  2. 方便去重:重复的元素会挨在一起,我们只需要判断 nums[i] === nums[i-1] 就能轻松跳过重复项。

2. 双指针布局

  1. 一层 for 循环,索引 i 从 0 到 length-2,这是我们的固定桩
  2. left 指针指向 i + 1(桩子的下一位)。
  3. right 指针指向 length - 1(数组末尾)。

屏幕截图 2026-01-18 210516.png


二、动图级流程解析:指针怎么动?

一旦 i 固定了,left 和 right 就开始向中间靠拢。在这个过程中,我们计算 sum = nums[i] + nums[left] + nums[right]。

这里有三种情况,逻辑非常清晰:

  1. sum > 0(和太大了)

    • 原因:数组是升序的,右边的数太大。
    • 动作:right--(右指针左移,找个小点的数)。
  2. sum < 0(和太小了)

    • 原因:左边的数太小。
    • 动作:left++ (左指针右移,找个大点的数)。
  3. sum == 0(中奖了!)

    • 动作:把 [nums[i], nums[left], nums[right]] 加入结果集 res。
    • 关键点:不仅要记录,还要同时收缩 left++ 和 right--,继续寻找下一组可能的解。

屏幕截图 2026-01-18 210923.png


三、地狱级细节:如何优雅地去重?💀

这道题 80% 的挂科率都出在去重上。题目要求结果集中不能包含重复的三元组(例如不能出现两个 [-1, 0, 1])。

我们在两个维度进行去重:

1. 外层循环去重(固定桩去重)

代码:

JavaScript

if (i > 0 && nums[i] == nums[i-1]) {
    continue;
}

面试官追问:  为什么是 nums[i] == nums[i-1] 而不是 nums[i] == nums[i+1]?

解析:

  • 如果是 nums[i] == nums[i+1],对于数组 [-1, -1, 2],当 i 指向第一个 -1 时,你就把它跳过了。那你就会漏掉 [-1, -1, 2] 这个有效解(因为这两个 -1 是不同位置的,可以共存)。
  • 我们用 nums[i] == nums[i-1] 的意思是: “如果当前的数字和上一个数字一样,说明上一个数字已经把所有可能的组合都找过了,我就不用再找一遍了”

2. 内层双指针去重

找到一个答案后,还没完!left 和 right 移动后的新位置可能还是和刚才一样的数字。
代码:

JavaScript

while(left < right && nums[left] == nums[left-1]) { left++; }
while(left < right && nums[right] == nums[right+1]) { right--; }

这步操作必须在 res.push 并且常规移动 left++/right-- 之后进行,确保彻底跳过重复段。


四、完整代码展示 (可以直接背诵版)

优化版本,加上了详细的注释:

JavaScript

function threeSum(nums) {
    const res = [];
    
    // 1. 必须先排序!这是双指针生效的前提
    // sort 是 JS 内置排序,a-b < 0 表示升序
    nums.sort((a, b) => a - b);
    
    const len = nums.length;
    
    // 2. 遍历每一个数字作为“固定桩” i
    for (let i = 0; i < len - 2; i++) {
        
        // 【核心去重1】:跳过重复的起点
        // 注意是 i > 0 且和 i-1 比,不是和 i+1 比
        if (i > 0 && nums[i] === nums[i-1]) {
            continue;
        }
        
        let left = i + 1;
        let right = len - 1;
        
        while (left < right) {
            const sum = nums[i] + nums[left] + nums[right];
            
            if (sum === 0) {
                // 找到一组解
                res.push([nums[i], nums[left], nums[right]]);
                
                // 无论如何都要移动指针
                left++;
                right--;
                
                // 【核心去重2】:跳过重复的 left
                while (left < right && nums[left] === nums[left-1]) {
                    left++;
                }
                
                // 【核心去重3】:跳过重复的 right
                while (left < right && nums[right] === nums[right+1]) {
                    right--;
                }
                
            } else if (sum < 0) {
                // 和太小,左指针右移让和变大
                left++;
            } else {
                // 和太大,右指针左移让和变小
                right--;
            }
        }
    }
    return res;
}

五、复杂度分析

  • 时间复杂度

    O(n2)
    
    • 数组排序通常是快排,复杂度 

      O(nlog⁡n)
      

    • 双指针遍历过程:外层循环 

      O(n)
      

      ,内层双指针 

      O(n)
      

      ,乘积是 

      O(n2)
      

    • 总体:

      O(n2)
      

      ,远优于暴力的 

      O(n3)
      

  • 空间复杂度

    O(1)
    

     或 

    O(log⁡n)
    
    • 如果你不计算存储结果的 res 数组,额外的空间主要是排序算法栈的空间(取决于语言底层实现),通常认为是 

      O(log⁡n)
      

       或 

      O(1)
      


六、总结

做这道题,心里要默念这句四步口诀

  1. 一排序:无序没法玩,sort 走在前。
  2. 二定桩:for 循环定 i,去重要判前(i-1)。
  3. 三双指:left、right 两头堵,大了左移小右顾。
  4. 四去重:找到答案别停步,while 循环跳重复。

学会了吗?别光看,赶紧打开编辑器 手撕 一遍吧!觉得有用的兄弟,点个赞再走呗!

2026前端面试题及答案

2026前端面试题及答案

HTML/CSS 部分

1. 什么是盒模型?标准盒模型和IE盒模型的区别是什么?

答案: 盒模型是CSS中用于布局的基本概念,每个元素都被表示为一个矩形盒子,由内容(content)、内边距(padding)、边框(border)和外边距(margin)组成。

区别:

  • 标准盒模型(W3C盒子模型)widthheight只包含内容(content)
  • IE盒模型(怪异模式盒子模型)widthheight包含内容(content)、内边距(padding)和边框(border)

可以通过box-sizing属性切换:

/* 标准盒模型 */
box-sizing: content-box;

/* IE盒模型 */
box-sizing: border-box;

2. CSS选择器优先级如何计算?

答案: CSS选择器优先级从高到低:

  1. !important
  2. 内联样式(style="")
  3. ID选择器(#id)
  4. 类选择器(.class)、属性选择器([type="text"])、伪类(:hover)
  5. 元素选择器(div)、伪元素(::before)
  6. 通配符(*)、关系选择器(>, +, ~)

计算规则:

  • ID选择器:100
  • 类/属性/伪类:10
  • 元素/伪元素:1
  • 相加比较,值大的优先级高

3. BFC是什么?如何创建BFC?

答案: BFC(Block Formatting Context)块级格式化上下文,是Web页面的可视化CSS渲染的一部分,是一个独立的渲染区域。

创建BFC的方法:

  • float不为none
  • position为absolute或fixed
  • display为inline-block、table-cell、table-caption、flex、inline-flex
  • overflow不为visible

BFC特性:

  1. 内部盒子垂直排列
  2. margin会重叠在同一个BFC中
  3. BFC区域不会与float box重叠
  4. BFC是独立容器,外部不影响内部

JavaScript部分

4. JavaScript中的事件循环机制是怎样的?

答案: JavaScript是单线程语言,通过事件循环机制实现异步。事件循环由以下部分组成:

  1. 调用栈(Call Stack):执行同步代码的地方
  2. 任务队列(Task Queue)
    • 宏任务(macrotask):script整体代码、setTimeout、setInterval、I/O、UI渲染等
    • 微任务(microtask):Promise.then/catch/finally、MutationObserver等

执行顺序:

  1. 执行同步代码(宏任务)
  2. 执行过程中遇到异步任务:
    • 微任务放入微任务队列
    • 宏任务放入宏任务队列
  3. 同步代码执行完毕,检查微任务队列并全部执行
  4. UI渲染(如果需要)
  5. 取出一个宏任务执行,重复上述过程

5. ES6中let/const与var的区别?

答案:

var let const
作用域 函数作用域 块级作用域 块级作用域
变量提升 暂时性死区 暂时性死区
重复声明 允许 不允许 不允许
全局属性 会成为 不会成为 不会成为
初始值 可不设 可不设 必须设置
修改值 可以 可以 不可以

6. Promise的原理是什么?手写一个简单的Promise实现。

答案: Promise是一种异步编程解决方案,主要解决回调地狱问题。它有三种状态:pending、fulfilled、rejected。

简单实现:

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(fn => fn());
      }
    };
    
    const reject = (reason) => {
      if (this.state === 'pending') {
        this.state = 'rejected';
        this.reason = reason;
        this.onRejectedCallbacks.forEach(fn => fn());
      }
    };
    
    try {
      executor(resolve, reject);
    } catch (err) {
      reject(err);
    }
  }
  
  then(onFulfilled, onRejected) {
    if (this.state === 'fulfilled') {
      onFulfilled(this.value);
    }
    
    if (this.state === 'rejected') {
      onRejected(this.reason);
    }
    
    if (this.state === 'pending') {
      this.onFulfilledCallbacks.push(() => onFulfilled(this.value));
      this.onRejectedCallbacks.push(() => onRejected(this.reason));
    }
  }
}

React/Vue框架部分

7.React中setState是同步还是异步的?

答案: 在React中,setState的行为表现有时"异步",有时"同步":

1.大部分情况下表现为异步(批量更新优化):

  • React合成事件处理函数中(setTimeout/setInterval/Promise回调等原生事件外)
  • React生命周期函数中

在这些情况下React会将多个setState合并为一个更新以提高性能。

2.某些情况下表现为同步:

  • setTimeout/setInterval回调中
  • DOM原生事件处理函数中
  • Promise.then等异步代码中

React18后所有情况都默认批量处理(auto batching),如需强制同步可使用flushSync。

原理原因: React通过isBatchingUpdates标志控制是否批量更新,合成事件和生命周期会开启此标志。

###8.Vue的响应式原理是怎样的?

答案: Vue2.x使用Object.defineProperty,Vue3使用Proxy实现响应式:

Vue2实现原理: 1.数据劫持:通过Object.defineProperty对data对象每个属性添加getter/setter追踪变化。

Object.defineProperty(obj, key, { 
 get() { //依赖收集 },
 set(newVal) { //触发更新 } 
})

2.依赖收集:在getter中将观察者Watcher实例添加到Dep订阅器中。 3.派发更新:setter被触发时通知Dep中的所有Watcher重新计算并更新视图。 缺点:无法检测对象属性的添加删除,数组变动需特殊处理。

Vue3使用Proxy改进:

new Proxy(data, { 
 get(target, key){},
 set(target, key, value){},
 deleteProperty(target, key){}
})

优势:可直接监听对象/数组的各种变化;无需递归遍历整个对象初始化。

##性能优化相关

###9.Webpack有哪些常见的性能优化手段?

构建速度优化:

1.缩小文件搜索范围

resolve:{ modules:[path.resolve(__dirname,'node_modules')] },
module:{ noParse:/jquery|lodash/ } //忽略未模块化库的解析  

2.缓存loader结果(cache-loader/babel-loader?cacheDirectory=true)

3.多进程构建(thread-loader/happyPack)

4.DllPlugin预编译不变模块

5.合理使用sourceMap(开发环境cheap-module-eval-source-map)

打包体积优化:

1.Tree Shaking(ES6模块+production模式+sideEffects配置)

2.Code Splitting:

optimization:{ splitChunks:{ chunks:'all' } },
entry:{ main:'./src/main.js', vendor:['lodash'] }  

3.Scope Hoisting(ModuleConcatenationPlugin) 4.UglifyJsPlugin压缩混淆代码

5.Gzip压缩(compression-webpack-plugin) 6.CDN引入外部资源(externals) 7.PurgeCSS移除无用CSS

8.OptimizeCSSAssetsPlugin压缩CSS

9.ImageMinimizerPlugin压缩图片

10.babel按需加载polyfill

##算法与编程题

###10.[编程题]手写防抖和节流函数

防抖(debounce):高频触发时只在停止触发后执行一次

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

节流(throttle):高频触发时每隔一段时间执行一次

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

//定时器版本节流:
function throttle(fn,delay){  
 let timer=null;   
 return function(...args){   
   if(!timer){     
     timer=setTimeout(()=>{      
       fn.apply(this,args);       
       timer=null;      
     },delay);     
   }   
 };   
}   

##HTTP与浏览器相关

###11.HTTPS的工作原理是什么?

HTTPS=HTTP+TLS/SSL加密层工作流程:

1.Client发送支持的加密算法列表+随机数A给Server

2.Server选择加密算法+发送数字证书+随机数B给Client

3.Client验证证书有效性(颁发机构/过期时间/域名匹配),生成随机数C并用证书公钥加密发送给Server

4.Server用私钥解密获取随机数C

5.Client和Server都用ABC三个随机数生成对称加密密钥(session key)

6.HTTP通信开始使用该密钥加密数据

关键点: -CA机构验证服务器身份防止中间人攻击
-非对称加密交换对称密钥提高安全性又保证性能
-TLS握手阶段采用非对称加密通信阶段采用对称加密

安全特性: 机密性(对称加密)+完整性(MAC校验)+身份认证(X509证书链)

2025总结:我在深圳做前端的第8年

转眼入行前端已经8个年头,我也算一名老前端了。可能自己对这一行谈不上特别喜欢,也不讨厌,工作上一直没有什么起色。

工作

去年年底我入职了一家外包公司,然后派去给一家上市公司干活。自己当时待的前端团队加上两个外包员工共有7人,涉及的项目有管理平台(微前端)以及对应的管理后台、Uniapp小程序、App(React Native)、可视化大屏系统。我主要参与的是pc端系统,都是基于Vue框架。其中管理平台主要是一些常见的业务需求的开发,但也有基于svg封装的实时监控主图组件还是比较复杂的;另外可视化大屏项目也参与的比较多,学习到了大屏适配的相关方案。

另外,今年工作过程中,自己也尝试用起了AI编程工具。我用的比较多的是阿里的通义灵码,不得不说对工作效率的提升还是很大。最近我开始转向字节的AI编辑器trae,体验上来说确实比插件要好很多。

在这家公司上班,还是比较清闲的,周末双休,平时也不会强制加班。领导和同事之间相处也比较愉快,在离场的时候,还一起吃了好几顿饭。

业余时间

其实今年自己的业余时间是比较多的,但还是没有很好的利用。可能我这个人比较懒吧,不肯放弃休闲娱乐的时间,到现在年初的目标也没实现几个。说好的多写点技术文章,结果就年终一篇总结,笑死!另外我也不是一个有耐心的人,今年本来想搭建一个自己的博客系统,但做了一半又去搞面试小程序去了,到现在两个都还没弄完。最让我气馁的还是软考,考了三次都还没过。今年考的两次在考前都刷题了很长一段时间,但最后都是其中一科差两分,太伤心了。

希望26年自己对自己要求高一点,养成自律的好习惯。

副业探索

今年我尝试的副业是虚拟店铺和网盘拉新。在网上搜罗了几十G的网盘资源,有小部分自己觉得比较好的放到了淘宝店铺上,最初还是出了几单的,但后面也慢慢没有流量了,就没有太上心。网盘拉新也差不多,特别是遭到各平台封号禁言之后,也没有去花时间了。两个副业一起大概收益不到200元,也算是副业探索上跨出的一步。其实我个人觉得这两个副业都挺好的,都不需要什么启动资金,就是要多花点时间去研究。

希望26年自己多花点时间在上面,争取副业收入月入过千。

二次被裁

年底的时候我又经历了一次裁员,与其说是被裁,其实是入职之初就能预料到的结果。因为继上一次裁员之后,我入职了一家外包公司,而且是不缴纳公积金和社保那种,最可恨的是在入职之前就让你签署各种主动放弃公积金和社保的协议。由于当时找工作几个月无果,最后无奈还是同意了。年底的时候由于驻场的甲方公司业务调整,所有外包员工都需要离场。其实在9月份的时候,外包公司迫于国家的压力,还是与我们签订了正式劳动合同,但同时也让我们签署放弃追缴赔偿的协议。虽然我也了解到这种违法劳动法的协议都是不合法的,但也不太想闹得去仲裁,就让他们配合我能领取失业金就行。

面试找工作

其实再次失业后,我心里也没有太过焦虑,也正好可以便找边休息一下。有了上一次的失业经历,我知道这次找工作也还是会很难,毕竟我的学历不行,还是非科班,技术能力也一般。其实没离场之前,我心里打定不再进外包了,但实际投简历的时候发现不考虑外包的话,面试机会就更少了。目前面了大概有5家公司,其中两家外包,有一家外包都发offer了,最后说甲方考虑到我是非统招学历,取消了offer。

这几年互联网行业下行,裁员失业的比较多,导致了市场供需不平衡。但毕竟是我工作了近8年的行业,而且目前我的副业也还没有发展起来。所以我未来几年也还是会继续深耕这一行,直到那天彻底找不到工作,或能有其它收入吧。

最后还是总结一下吧。

25年对我来说还是平淡的一年,工作和生活都没有什么大的变化。不过心态上来说,自己还是比较平和知足的,不用特别为生计发愁;而且国家也在日益强盛(虽然有产业转型的阵痛,如失业)。所以对未来,我还是有很多期待...

面试官 : “请你讲一下 JS 的 《垃圾回收机制》 ? ”

1. 垃圾回收到底是什么?

JavaScript 是自动内存管理的语言,你不用手动申请 / 释放内存(比如 C/C++ 需要 malloc/free),垃圾回收就是 JS 引擎(如 V8)自动做的两件事:

  • 找 “垃圾” :识别出程序中不再使用的变量 / 对象(占用的内存就是 “垃圾内存”);
  • 清垃圾:释放这些 “垃圾” 占用的内存,避免内存泄漏、提升性能。

举个简单例子:

function fn() {
  let num = 10; // 函数执行时,num 占用内存
}
fn(); // 函数执行完后,num 再也访问不到了 → 变成“垃圾”,GC 会回收它的内存

2. JS 怎么判断 “哪些是垃圾”?

GC 不是瞎回收的,核心判断标准是:一个对象 / 变量是否还能被 “访问到”(是否有引用指向它)

  • 能访问到 → 存活(不回收);
  • 访问不到 → 垃圾(会被回收)。

3. JS 垃圾回收的核心算法(V8 引擎为主)

不同 JS 引擎的 GC 算法略有差异,但核心是两种:标记 - 清除(主流)和引用计数(辅助 / 历史)。

算法 1:标记 - 清除(Mark-and-Sweep,现代引擎主流)

这是 V8 最核心的 GC 算法,分为 “标记” 和 “清除” 两步,逻辑很直观:

GC 启动

标记阶段:从根对象(如 window/global)出发,遍历所有可访问的对象,打上“存活”标记

清除阶段:遍历堆内存,清除所有没有“存活”标记的对象,释放内存

内存整理可选):将空闲内存碎片合并,方便后续分配

举个例子理解

// 根对象:window(浏览器环境)
let obj1 = { name: "John" }; // obj1 被 window 引用 → 标记为存活
let obj2 = obj1; // obj2 也引用 obj1 → 还是存活
obj1 = null; // 解除 obj1 的引用,但 obj2 还指向 → 仍存活
obj2 = null; // 所有引用都解除 → obj1 无法访问 → 标记为垃圾,下次 GC 清除

优点

  • 解决了引用计数的 “循环引用” 问题(下面会说);
  • 逻辑简单,效率高。

缺点

  • 清除后会产生内存碎片(比如内存里零散的空闲空间),但 V8 会通过 “内存整理” 优化。

算法 2:引用计数(Reference Counting,历史算法,已淘汰核心场景)

早期(如 IE8 之前)的算法,逻辑是:给每个对象记录 “被引用的次数”,次数为 0 就回收

  • 当对象被引用 → 计数 + 1;
  • 当引用解除 → 计数 - 1;
  • 计数 = 0 → 立即回收。

例子

let obj = { a: 1 }; // 引用计数 = 1
let obj2 = obj;     // 引用计数 = 2
obj = null;         // 引用计数 = 1(还不能回收)
obj2 = null;        // 引用计数 = 0 → 变成垃圾,被回收

致命缺点:无法处理循环引用(这也是它被标记 - 清除取代的核心原因):

// 循环引用:obj1 和 obj2 互相引用,引用计数都为 1,永远不会为 0
let obj1 = {};
let obj2 = {};
obj1.fn = obj2;
obj2.fn = obj1;

// 即使解除外部引用,计数仍为 1 → 引用计数算法不会回收,造成内存泄漏
obj1 = null;
obj2 = null;

👉 而标记 - 清除算法能解决这个问题:因为 obj1 / obj2 都无法从根对象访问到,会被标记为垃圾,最终回收。

4. V8 引擎的 GC 优化(进阶,面试高频)

V8 为了提升 GC 效率,还做了针对性优化,核心是 “分代回收”:

  • 将内存分为 新生代(Young Generation)老生代(Old Generation)

    • 新生代:存储短期存活的对象(如函数内部的临时变量),GC 频率高、速度快(用 “Scavenge 算法”,复制 - 清除);
    • 老生代:存储长期存活的对象(如全局变量),GC 频率低,用 “标记 - 清除 + 标记 - 整理” 算法。
  • 优点:避免对整个内存做全量 GC,减少卡顿(JS 是单线程,GC 时会暂停代码执行,分代回收能缩短暂停时间)。

5. 常见的内存泄漏场景(GC 没回收的 “伪垃圾”)

垃圾回收不是万能的,如果代码写得不好,会导致 “本该回收的对象没被回收”,也就是内存泄漏,常见场景:

  1. 意外的全局变量(最常见):

    function fn() {
      num = 10; // 没写 let/var/const → 自动挂载到 window → 全局变量,永远不回收
    }
    
  2. 未清除的定时器 / 事件监听

    // 定时器引用了 obj,即使页面关闭前不清除定时器,obj 永远存活
    let obj = { data: "xxx" };
    setInterval(() => { console.log(obj); }, 1000);
    // 解决:不用时 clearInterval(timer)
    
  3. 闭包滥用

    function outer() {
      let bigData = new Array(1000000); // 大数组
      return function() { // 闭包引用 bigData,outer 执行完后 bigData 也不回收
        console.log(bigData);
      };
    }
    let fn = outer();
    // 解决:不用时 fn = null,解除引用
    

最后总结 🤔

  1. 核心本质:JS 垃圾回收是引擎自动回收 “不可访问” 对象的内存,避免手动管理内存的繁琐和错误。
  2. 核心算法:现代引擎以标记 - 清除为主(解决循环引用),引用计数已淘汰核心场景。
  3. V8 优化:分代回收(新生代 + 老生代)减少 GC 卡顿,提升性能。
  4. 避坑重点:避免意外全局变量、未清除的定时器 / 监听、滥用闭包,防止内存泄漏。

搞混了 setState 同步还是异步问题

刚学 React 接触setState的时候,经常会想一个问题:setState 到底是同步的还是异步的?

“好像是异步的”,结果写代码时又发现有时候它“立刻生效”了。越想越糊涂,直到后来踩了坑、看了源码、再结合 React 18 的变化,才真正理清楚。

就最近遇到的切换页面主题的react项目,里面的有一下一段代码

const toggleTheme = () => {
  setTheme(previousState => previousState === 'light' ? 'dark' : 'light');
};

这又让我想起setState这个许久的问题,它和“同步/异步”有关系吗?决定写一篇文章来捋一捋。

一开始,我以为 setState 是“异步”的

脑子里立刻浮现出那个经典例子:

const [count, setCount] = useState(0);

const handleClick = () => {
  setCount(count + 1);
  console.log(count); 、
};

这里打印出来的还是老值,导致我一直以为是因为“setState 是异步的,还没执行完”。 但后来我才意识到——这个理解其实有点跑偏了

一、 所谓的“异步”,其实是 React 在“攒大招”

为什么 console.log(count) 打印的是 0?

并不是因为 setCountsetTimeout 或者接口请求那样真的是个异步任务,被扔到了微任务队列里。根本原因是 React 为了性能,开启了一个叫 “批处理” 的机制。

想象一下你去超市结账。如果你拿一瓶水,收银员算一次钱;再拿包薯片,收银员再算一次钱……收银员(渲染引擎)肯定会被你累死。 React 很聪明,它会把你的多次 setState 操作先“记在小本本上”,等你这一轮事件处理函数执行完了,它再一次性把所有账单结了,这个操作在react里面叫更新dom

所以,当你执行 console.log 的时候,React 甚至还没开始动手更新呢,你读到的自然是旧值。

为了验证这一点,咱们直接上代码测试,用 React 17 和 React 18 对比,真相立马浮出水面。

二、在 React 17 里的不同

后来我看了一些老教程,说“在 setTimeoutsetState 是同步的”。于是我兴奋地去试了一下:

// 环境:React 17 
const handleClick = () => {
  setTimeout(() => {
    setCount(c => c + 1);
    
    // 很多人(包括以前的我)以为这里能打印出 1
    // 结果控制台啪的一下打脸:依然是 0 !!!
    console.log(count); 
  }, 0);
};

image.png

当时我就懵了,直到我打开 Chrome 开发者工具的 Elements 面板,盯着那个 DOM 节点看,才发现了一个惊人的事实:

  1. DOM 确实变了!console.log 执行的那一瞬间,页面上的数字已经变成 1 了。说明 React 确实同步完成了渲染。
  2. count 变量没变! 因为我是用函数式组件写的。

这就触及到了知识盲区: 在 React 17 的 setTimeout 里,React 确实失去了“批处理”的能力,导致它被迫同步更新了视图。但是!由于函数式组件的闭包特性,我当前这个 handleClick 函数是在 count=0 的时候创建的,它手里拿的 count 永远是 0。

所以,视图是新的,变量是旧的。这才是最坑的地方。

三、React 18 的大一统

回到 React 18,官方推出了 自动批处理

现在,不管你是在 setTimeoutPromise 还是原生事件里,React 都会把门焊死,统统进行批处理。

setTimeout(() => {
  setCount(c => c + 1);
  setName('Alice');
  setIsLoading(false);
}, 0);

👉 结果:只 re-render 1 次!

React 18 无论你在哪调用状态更新(事件、定时器、Promise、fetch 回调等) ,都会自动把它们“攒起来”,在当前 tick 结束时一次性合并更新并渲染

这意味着,在 React 18 里,除非你用 flushSync 这种逃生舱,否则你几乎看不到 DOM 同步更新的情况了。这其实是好事,心智负担少了很多,不用再去记那些特例。

首先,我们来看最常见的场景。如果它是同步的,那我改三次,它就应该变三次

来看这段代码:

// React 18 环境
export default function App() {
  console.log("组件渲染了!"); // 埋点:监控渲染次数
  const [count, setCount] = useState(0);

  const handleClick = () => {
    // 连发三枪
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
    
    // 马上查看
    console.log("点击时的 count:", count); 
  };

  return <button onClick={handleClick}>{count}</button>;
}

image.png

运行结果直接打脸:

  1. 控制台打印 点击时的 count: 0。(说明:代码执行到这行时,状态根本没变)
  2. "组件渲染了!" 只打印了 1 次。(说明:三次操作被合并了)
  3. 页面上的数字变成了 1,而不是 3

四、setState 同步的情况

我们可以逼 React 同步执行。在 React 18 里,我们需要用 flushSync 这个 API 来关掉自动批处理。

上代码:

import { useState } from 'react';
import { flushSync } from 'react-dom';

export default function App() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    // 第一次更新:强制同步
    flushSync(() => {
      setCount(c => c + 1);
    });
    console.log("第一次 flushSync 结束,DOM 上的数字是:", document.getElementById('count-span').innerText);

    // 第二次更新:强制同步
    flushSync(() => {
      setCount(c => c + 1);
    });
    console.log("第二次 flushSync 结束,DOM 上的数字是:", document.getElementById('count-span').innerText);
  };

  return (
    <div>
      <span id="count-span">{count}</span>
      <button onClick={handleClick}>点击增加</button>
    </div>
  );
}

image.png

结论: 看,React 其实完全有能力同步更新。只要你用 flushSync 勒令它“立刻、马上干活”,它就会停下手头的工作,立刻执行更新流程。

所以,准确地说:setState 本质上是同步执行代码的,只是 React 默认挂了个“防抖”的机制,让它看起来像是异步的。

五、最坑的“假异步”(闭包陷阱)

既然上面的代码里,DOM 都已经同步变了,那我在 JS 里直接打印 count 变量

看这段代码:

const handleClick = () => {
  flushSync(() => {
    setCount(c => c + 1); 
  });
  
  // 刚才代码证明了,DOM 这里已经变成 1 
  // 那这里打印 count 应该是几?
  console.log("也就是现在的 count 是:", count); 
};

image.png

这不是 React 的锅,这是 JavaScript 闭包的锅。

我们这个 handleClick 函数,是在 count 为 0 的那次渲染中生成的。它就像一张照片,永远定格在了那一刻。

无论你用办法(比如 flushSync)让 React 在外部把 DOM 更新了,或者把 React 内部的 State 更新了,但你当前正在运行的这个 handleClick 函数作用域里,count 这个局部变量,它就是个常量 0,再怎么搞它也是 0

回到最初的问题

理清了这些,再回过头看开头那段代码:

const toggleTheme = () => {
  setTheme(previousState => previousState === 'light' ? 'dark' : 'light');
};

为什么要写成 previousState => ... 这种函数形式?

这和“同步/异步”有关系吗?有关系。

正因为 React 的 setState 是“异步”(批处理)的,而且函数式组件有闭包陷阱,如果直接写 setTheme(theme === 'light' ? ...),你拿到的 theme 很可能是旧值(也就是上面例子里那个永远是 0 的 count)。

当你传入一个函数时,你是在告诉 React:

“麻烦把当时最新的那个状态值传给我的函数。我不信我自己闭包里的旧变量,我只信你传给我的新值。”

总结一下

1、定性: “严格来说,setState 是由 React 调度的更新,表现得像异步(批处理的原因)。”

2、亮点:

  • “在 React 18 中,得益于自动批处理,无论在 React 事件还是 setTimeout 中,它都会合并更新,表现为异步。”

  • “但在 React 17 及以前,如果在 setTimeout 或原生事件中,它会脱离 React 的管控,表现为同步行为。”

3、补充特例: “如果需要在 React 18 中强制同步更新 DOM,我们可以使用 flushSync。”

4、最后补刀(闭包): “但无论 DOM 是否同步更新,在函数式组件中,由于 JS 闭包 的存在,我们在当前函数执行上下文中拿到的 state 永远是本次渲染的快照(旧值),要获取最新值应该依赖 useEffectuseRef。”

LeetCode 11. 盛最多水的容器

图解算法:为什么一定要移动那个短板?| LeetCode 11. 盛最多水的容器

前言:在面试中,有一类题目看似简单,暴力解法也能做,但面试官真正想看的是你如何将 

O(N2)

 的复杂度优化到 

O(N)

。LeetCode 11 题“盛最多水的容器”就是这类题目的典范。今天我们不背代码,而是深入探讨背后的贪心策略双指针思维。

一、 题目直觉与“木桶效应”

题目的目标非常直观:在一个数组中找到两条垂线,使得它们与 X 轴围成的容器能盛最多的水。

我们要计算的是矩形面积:

Area=Width×HeightArea=Width×Height

这里有一个物理常识至关重要,那就是木桶效应 (Short Board Effect)
一个木桶能装多少水,取决于最短的那块木板。

映射到题目中:

  • 宽度 (Width) :两条垂线在 X 轴上的距离 right - left。
  • 高度 (Height) :两条垂线中较矮的那一条,即 Math.min(height[left], height[right])。

二、 痛点:为什么暴力解法不行?

最容易想到的思路是双重循环:计算所有两两组合的面积,然后取最大值。

然而以我的经验,当你写下双循环的时候,你自己心中的无奈,没有人会比你更了解

面试官在了解到你的解题思路时,就已经将你pass掉了

任何算法题,写双循环的结果只有死路一条(因为他会认为你对空间与时间复杂度没有概念,或者你的实力就这么多)

JavaScript

//  暴力解法
let max = 0;
for (let i = 0; i < len; i++) {
    for (let j = i + 1; j < len; j++) {
        // 计算每一对组合...
    }
}

这种解法的时间复杂度是

O(N2)


题目提示中数组长度 

NN

 可达 

105105

。这意味着计算量高达 

10101010

 次。在通常的算法竞赛或面试标准中,这绝对会触发 TLE (Time Limit Exceeded)  超时错误。

我们需要一种更聪明的做法,将复杂度降维打击到

O(N)

三、 核心:双指针法与贪心策略

我们要优化的核心是:如何尽可能少地遍历,却能保证不漏掉最大值?

1. 初始布局:拉满宽度

既然面积 = 宽 × 高,我们不妨先让宽度最大
我们在数组的头尾各放置一个指针:left 指向开头,right 指向结尾。

此时,容器的底宽是最大的。接下来的每一步移动,宽度必然减小。为了弥补宽度的损失,我们必须寻找更高的垂线。

2. 决策困境:移动哪一根?

这是本题最难理解的点。假设现在的状况是:

  • 左边柱子高度 left_h = 2

  • 右边柱子高度 right_h = 8

  • 当前宽度 w = 10

  • 当前面积 = 

    10=202×10=20
    

现在我们需要向内移动一个指针,是移左边的(矮的),还是移右边的(高的)?

假设我们移动高的那一边(右边):

宽度肯定变小了(变成 9)。
而水位高度取决于谁?依然是左边那个不动的短板(高度 2)。
无论右边新遇到的柱子是高耸入云还是矮小不堪,容器的有效高度最高只能是 2

新面积=9×min⁡(2,新高度)≤18新面积=9×min(2,新高度)≤18

结论:  移动高板,宽度减小,高度受限于不动的短板(无法增加)。面积只会变小,绝对不可能变大。  这是一条死路。

贪心策略:移动矮的那一边(左边):

虽然宽度变小了(变成 9),但我们抛弃了当前的短板(高度 2)。
如果运气好,左边新遇到的柱子高度是 10,那么新的有效高度就变成了 8(受限于右边)。

新面积=9×8=72新面积=9×8=72

结论:  只有移动短板,我们才有可能找到更高的柱子来弥补宽度的损失。

这就是本题的贪心逻辑:  每一步我们都排除掉那个“导致当前高度受限”的短板,因为它已经发挥了它的最大潜力(在当前最宽的情况下),保留它没有任何意义。

四、 代码实现

理解了上述逻辑,代码实现就非常简单了。

JavaScript

/**
 * @param {number[]} height
 * @return {number}
 */
var maxArea = function(height) {
    // 1. 定义双指针,分别指向头尾
    let left = 0;
    let right = height.length - 1;
    let maxWater = 0;
    
    // 2. 当指针未相遇时循环
    while (left < right) {
        // 3. 计算当前面积
        // 高度取决于短板 (木桶效应)
        const currentHeight = Math.min(height[left], height[right]);
        const currentWidth = right - left;
        
        // 更新历史最大值
        maxWater = Math.max(maxWater, currentHeight * currentWidth);
        
        // 4. 核心决策:移动较矮的一侧
        // 如果左边是短板,那左边这块板子在当前宽度下已经发挥了最大价值,
        // 再往里缩宽度只会变小,保留左边没意义,不如向右移试试看有没有更高的。
        if (height[left] < height[right]) {
            left++;
        } else {
            right--;
        }
    }
    
    return maxWater;
};

五、 复杂度分析

  • 时间复杂度:

    O(N)
    

    双指针 left 和 right 总共遍历整个数组一次。相比于暴力解法的 

    O(N2)
    

    ,效率提升是巨大的。

  • 空间复杂度:

    O(1)O(1) 
    

    我们只需要存储指针索引和 maxWater 几个变量,不需要额外的数组空间。

六、 总结

所谓算法优化,往往不是代码写得有多复杂,而是思维模型的转换

LeetCode 11 题通过观察“木桶效应”,让我们明白:保留长板、抛弃短板是唯一可能获得更大收益的路径。这种通过排除法将搜索空间从二维矩阵(所有组合)压缩到一维线性扫描(双指针)的过程,就是算法中的降维打击

希望这篇文章能帮你彻底搞懂双指针解法!

| ES6 | 异步 | 闭包 | 原型链 | DOM操作 | 事件处理 |

一、ES6+ 新特性

ES6(ECMAScript 2015)及后续的 ES7-ES14 被统称为 ES6+,是 JavaScript 语言的重大升级,解决了 ES5 时代的语法冗余、作用域混乱、功能缺失等问题,大幅提升了代码的可读性、可维护性和开发效率。

1. 块级作用域与变量声明

ES5 中只有全局作用域和函数作用域,var 声明的变量存在 “变量提升” 和 “作用域穿透” 问题,极易引发 bug。ES6 新增 letconst 关键字,引入块级作用域({} 包裹的区域):

  • let:声明可变变量,仅在当前块级作用域有效,无变量提升,不允许重复声明;
  • const:声明常量,一旦赋值不可修改(引用类型仅保证地址不变),同样遵循块级作用域规则。示例:
// ES5 问题:变量提升+作用域穿透
if (true) {
  var a = 10;
}
console.log(a); // 10(全局作用域可访问)

// ES6 解决
if (true) {
  let b = 20;
  const c = 30;
}
console.log(b); // ReferenceError: b is not defined
console.log(c); // ReferenceError: c is not defined

2. 箭头函数

简化函数声明语法,核心特性:

  • 语法简洁:单参数可省略括号,单返回语句可省略大括号和 return
  • 无独立 this:箭头函数的 this 继承自外层作用域,解决了 ES5 中 this 指向混乱的问题(如回调函数中 this 丢失);
  • 不能作为构造函数:无法使用 new 调用,无 arguments 对象(可改用剩余参数)。示例:
// ES5 函数
const add = function(a, b) {
  return a + b;
};

// ES6 箭头函数
const add = (a, b) => a + b;

// this 指向示例
const obj = {
  name: "张三",
  fn1: function() {
    setTimeout(function() {
      console.log(this.name); // undefined(this 指向全局)
    }, 100);
  },
  fn2: function() {
    setTimeout(() => {
      console.log(this.name); // 张三(this 继承自 fn2 的作用域)
    }, 100);
  }
};
obj.fn1();
obj.fn2();

3. 解构赋值

允许从数组 / 对象中提取值,赋值给变量,简化数据提取逻辑:

  • 数组解构:按索引匹配,支持默认值;
  • 对象解构:按属性名匹配,支持重命名和默认值。示例:
// 数组解构
const [a, b, c = 30] = [10, 20];
console.log(a, b, c); // 10 20 30

// 对象解构
const { name: userName, age = 18 } = { name: "李四" };
console.log(userName, age); // 李四 18

4. 扩展运算符与剩余参数

  • 扩展运算符(...):将数组 / 对象展开为单个元素,用于合并数据、传递参数;
  • 剩余参数(...):收集剩余的参数,转为数组,替代 arguments。示例:
// 扩展运算符
const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
const arr3 = [...arr1, ...arr2]; // [1,2,3,4,5,6]

const obj1 = { a: 1 };
const obj2 = { b: 2 };
const obj3 = { ...obj1, ...obj2 }; // {a:1, b:2}

// 剩余参数
const sum = (...args) => args.reduce((total, cur) => total + cur, 0);
console.log(sum(1,2,3)); // 6

5. 模板字符串

用反引号()包裹字符串,支持换行和变量插值(${变量}`),解决 ES5 字符串拼接繁琐的问题:

const name = "王五";
const age = 20;
// ES5 拼接
const str1 = "姓名:" + name + ",年龄:" + age + "岁";
// ES6 模板字符串
const str2 = `姓名:${name},年龄:${age}岁`;

6. 其他核心特性

  • Set/Map 数据结构:Set 用于存储唯一值(数组去重),Map 键值对集合(键可为任意类型,替代对象);
  • Class 类:语法糖,简化原型链继承,提供 constructorextendssuper 等关键字;
  • 模块化(import/export):替代 CommonJS/AMD,实现按需加载,提升代码模块化程度;
  • 可选链(?.)、空值合并(??):ES2020 特性,简化空值判断,避免 Cannot read property 'xxx' of undefined 错误。

ES6+ 新特性的核心价值在于 “语法简化” 和 “功能补全”,让 JavaScript 从 “脚本语言” 向 “工程化语言” 迈进,是现代前端开发(React/Vue/TypeScript)的基础。

二、异步(Promise, async/await)

JavaScript 是单线程语言,默认同步执行代码,但网络请求、定时器、文件操作等场景需要异步处理,否则会阻塞主线程。异步编程经历了 “回调函数 → Promise → async/await” 的演进,核心目标是解决 “回调地狱”,让异步代码更易读、易维护。

1. 异步编程的核心问题:回调地狱

ES5 中异步操作依赖回调函数,多个异步嵌套时会出现 “回调地狱”(代码层级深、可读性差、错误处理繁琐):

// 回调地狱:获取用户信息 → 获取用户订单 → 获取订单详情
$.get("/api/user", (user) => {
  $.get(`/api/order?userId=${user.id}`, (order) => {
    $.get(`/api/orderDetail?orderId=${order.id}`, (detail) => {
      console.log(detail);
    }, (err) => {
      console.error("获取订单详情失败", err);
    });
  }, (err) => {
    console.error("获取订单失败", err);
  });
}, (err) => {
  console.error("获取用户失败", err);
});

问题:层级嵌套过深,错误处理分散,代码难以调试和维护。

2. Promise:异步操作的标准化封装

Promise 是 ES6 引入的异步编程解决方案,本质是一个对象,代表异步操作的 “未完成 / 成功 / 失败” 状态,核心特性:

  • 三种状态:pending(进行中)、fulfilled(已成功)、rejected(已失败),状态一旦改变不可逆转;
  • 两个回调:then() 处理成功结果,catch() 处理失败结果,支持链式调用;
  • 解决回调地狱:通过链式调用替代嵌套,错误可统一捕获。

(1)Promise 基本用法

// 创建 Promise 对象
const getPromise = (url) => {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("GET", url);
    xhr.onload = () => {
      if (xhr.status === 200) {
        resolve(JSON.parse(xhr.responseText)); // 成功:调用 resolve
      } else {
        reject(new Error(xhr.statusText)); // 失败:调用 reject
      }
    };
    xhr.onerror = () => {
      reject(new Error("网络请求失败"));
    };
    xhr.send();
  });
};

// 链式调用:解决回调地狱
getPromise("/api/user")
  .then((user) => getPromise(`/api/order?userId=${user.id}`))
  .then((order) => getPromise(`/api/orderDetail?orderId=${order.id}`))
  .then((detail) => console.log(detail))
  .catch((err) => console.error("请求失败", err)); // 统一捕获所有错误

(2)Promise 常用方法

  • Promise.all():接收多个 Promise 数组,全部成功才返回结果数组,一个失败则立即失败;
  • Promise.race():接收多个 Promise 数组,返回第一个完成的 Promise 结果(无论成功 / 失败);
  • Promise.resolve()/Promise.reject():快速创建成功 / 失败的 Promise 对象;
  • Promise.allSettled():等待所有 Promise 完成(无论成功 / 失败),返回所有结果(包含状态和值)。

示例(Promise.all):

// 同时请求多个接口,全部完成后处理
const promise1 = getPromise("/api/user");
const promise2 = getPromise("/api/goods");
Promise.all([promise1, promise2])
  .then(([user, goods]) => {
    console.log("用户信息", user);
    console.log("商品信息", goods);
  })
  .catch((err) => console.error("某个请求失败", err));

3. async/await:异步代码同步化

ES2017 引入的 async/await 是 Promise 的语法糖,允许用 “同步代码的写法” 处理异步操作,核心规则:

  • async 修饰函数:使函数返回一个 Promise 对象;
  • await 修饰 Promise:暂停函数执行,直到 Promise 状态变为成功,返回结果;若 Promise 失败,需用 try/catch 捕获错误。

(1)基本用法(解决回调地狱的终极方案)

// 封装异步请求函数(返回 Promise)
const getUser = () => getPromise("/api/user");
const getOrder = (userId) => getPromise(`/api/order?userId=${userId}`);
const getOrderDetail = (orderId) => getPromise(`/api/orderDetail?orderId=${orderId}`);

// async/await 写法:同步风格的异步代码
const getOrderInfo = async () => {
  try {
    const user = await getUser(); // 等待 getUser 完成
    const order = await getOrder(user.id); // 等待 getOrder 完成
    const detail = await getOrderDetail(order.id); // 等待 getOrderDetail 完成
    console.log(detail);
  } catch (err) {
    console.error("请求失败", err); // 统一捕获所有错误
  }
};

getOrderInfo();

(2)async/await 优势

  • 代码扁平化:无嵌套,可读性接近同步代码;
  • 错误处理统一:通过 try/catch 捕获所有异步错误,替代 Promise 的 catch()
  • 调试友好:可在 await 处打断点,调试流程与同步代码一致。

4. 异步编程的核心原则

  • 避免同步阻塞:异步操作始终不阻塞主线程(如定时器、网络请求由浏览器内核的线程处理);
  • 错误处理全覆盖:Promise 需加 catch(),async/await 需包 try/catch,避免未捕获的异步错误;
  • 并行处理优化:多个无依赖的异步操作,用 Promise.all() 替代串行 await,提升执行效率。

异步编程是前端开发的核心难点,Promise 解决了 “回调地狱” 的结构问题,async/await 则让异步代码的可读性达到了同步代码的水平,是现代前端处理网络请求、异步数据加载的标配。

三、闭包和原型链

闭包和原型链是 JavaScript 的两大核心特性,也是面试高频考点。闭包关乎作用域和变量生命周期,原型链则是 JavaScript 实现继承的底层机制,理解这两个概念能帮你突破 “语法使用” 到 “原理理解” 的瓶颈。

1. 闭包(Closure)

(1)闭包的定义

闭包是指 “有权访问另一个函数作用域中变量的函数”,本质是函数作用域链的保留:当内部函数被外部引用时,其所在的作用域不会被垃圾回收机制销毁,从而可以持续访问外层函数的变量。

(2)闭包的形成条件

  1. 存在嵌套函数(内部函数 + 外部函数);
  2. 内部函数引用外部函数的变量 / 参数;
  3. 外部函数执行后,内部函数被外部环境引用(如返回、赋值给全局变量)。

(3)基本用法与示例

// 基础闭包:外部函数执行后,内部函数仍能访问其变量
function outer() {
  const num = 10; // 外部函数的变量
  // 内部函数引用外部变量
  function inner() {
    console.log(num);
  }
  return inner; // 返回内部函数,使其被外部引用
}

const fn = outer(); // outer 执行完毕,但其作用域未被销毁
fn(); // 10(inner 仍能访问 num)

(4)闭包的核心应用场景

  • 封装私有变量:模拟 “私有属性 / 方法”,避免全局变量污染;

    // 封装计数器:count 是私有变量,只能通过方法修改
    function createCounter() {
      let count = 0;
      return {
        increment: () => count++,
        decrement: () => count--,
        getCount: () => count
      };
    }
    
    const counter = createCounter();
    counter.increment();
    counter.increment();
    console.log(counter.getCount()); // 2
    console.log(counter.count); // undefined(无法直接访问)
    
  • 防抖 / 节流函数:利用闭包保存定时器 ID、上次执行时间等状态;

    // 防抖函数(闭包保存 timer 变量)
    function debounce(fn, delay) {
      let timer = null; // 闭包保存 timer,多次调用共享同一个 timer
      return (...args) => {
        clearTimeout(timer);
        timer = setTimeout(() => {
          fn.apply(this, args);
        }, delay);
      };
    }
    
  • 柯里化函数:将多参数函数转为单参数函数,利用闭包缓存已传入的参数。

(5)闭包的注意事项

  • 内存泄漏风险:闭包会保留外层作用域,若长期引用未释放(如赋值给全局变量),会导致变量无法被垃圾回收,占用内存;
  • 解决:使用完闭包后,手动解除引用(如 fn = null),让作用域可以被回收。

2. 原型链(Prototype Chain)

JavaScript 是 “基于原型的面向对象语言”,没有类(ES6 Class 是语法糖),所有对象都通过 “原型” 实现属性和方法的继承,原型链是实现继承的核心机制。

(1)核心概念

  • 原型(prototype):函数特有的属性,指向一个对象,该对象是当前函数创建的所有实例的原型;
  • 隐式原型(__proto__):所有对象(包括函数)都有的属性,指向其构造函数的 prototype
  • 原型链:当访问对象的属性 / 方法时,先在自身查找,找不到则通过 __proto__ 向上查找,直到 Object.prototype,这个查找链条就是原型链。

(2)原型链的基本结构

// 构造函数
function Person(name) {
  this.name = name;
}
// 给原型添加方法
Person.prototype.sayHello = function() {
  console.log(`Hello, ${this.name}`);
};

// 创建实例
const p1 = new Person("张三");

// 原型链查找:p1 → Person.prototype → Object.prototype → null
console.log(p1.name); // 自身属性,直接返回
p1.sayHello(); // p1 自身无 sayHello,查找 p1.__proto__(Person.prototype)找到
console.log(p1.toString()); // p1 和 Person.prototype 无 toString,查找 Object.prototype 找到
console.log(p1.xxx); // 原型链末端为 null,返回 undefined

(3)原型链的核心应用:继承

ES5 中通过修改原型链实现继承(ES6 Class 的 extends 底层仍是原型链):

// 父类
function Parent(name) {
  this.name = name;
}
Parent.prototype.eat = function() {
  console.log(`${this.name} 吃饭`);
};

// 子类
function Child(name, age) {
  Parent.call(this, name); // 继承父类实例属性
  this.age = age;
}
// 继承父类原型方法
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child; // 修正构造函数指向

// 子类添加自有方法
Child.prototype.run = function() {
  console.log(`${this.name} 跑步,年龄 ${this.age}`);
};

const child = new Child("李四", 10);
child.eat(); // 继承父类方法
child.run(); // 子类自有方法

(4)原型链的关键规则

  • 所有对象的最终原型是 Object.prototype,其 __proto__null
  • 函数的 prototype 是普通对象,Function.prototype 是函数(特殊);
  • 修改原型会影响所有实例(原型共享特性)。

3. 闭包与原型链的关联

闭包关注 “作用域和变量保留”,原型链关注 “对象属性继承”,二者共同构成 JavaScript 的核心底层逻辑:闭包让函数可以突破作用域限制访问变量,原型链让对象可以突破自身结构继承方法,是理解 JavaScript 设计思想的关键。

四、DOM 操作和事件处理

DOM(文档对象模型)是浏览器将 HTML 文档解析成的树形结构,前端开发的核心是通过 JavaScript 操作 DOM 实现页面交互,事件处理则是响应用户操作(点击、输入、滚动等)的核心机制。

1. DOM 操作

DOM 操作分为 “查找节点”“创建 / 插入节点”“修改节点”“删除节点” 四类,核心是操作 DOM 树的节点(元素节点、文本节点、属性节点)。

(1)查找 DOM 节点(核心)

查找是 DOM 操作的第一步,常用方法:

  • 按 ID 查找:document.getElementById("id") → 返回单个元素(效率最高);
  • 按类名查找:document.getElementsByClassName("className") → 返回 HTMLCollection(动态集合);
  • 按标签名查找:document.getElementsByTagName("tagName") → 返回 HTMLCollection;
  • 按选择器查找:document.querySelector("selector")(返回第一个匹配元素)、document.querySelectorAll("selector")(返回 NodeList,静态集合)→ 最灵活,支持 CSS 选择器。

示例:

// 按 ID 查找
const box = document.getElementById("box");

// 按选择器查找
const item = document.querySelector(".list .item");
const items = document.querySelectorAll(".list .item"); // NodeList 可通过 forEach 遍历

(2)创建与插入节点

动态生成页面内容的核心,常用方法:

  • 创建元素:document.createElement("tagName")

  • 创建文本节点:document.createTextNode("text")

  • 插入节点:

    • parent.appendChild(child):将子节点插入父节点末尾;
    • parent.insertBefore(newNode, referenceNode):将新节点插入参考节点之前;
    • element.innerHTML:直接通过 HTML 字符串插入节点(简洁但有 XSS 风险)。

示例:

// 创建元素并插入
const ul = document.querySelector("ul");
const li = document.createElement("li");
li.textContent = "新列表项"; // 设置文本内容(安全,无 XSS)
ul.appendChild(li);

// innerHTML 方式(慎用,避免用户输入内容)
ul.innerHTML += "<li>新列表项</li>";

(3)修改 DOM 节点

  • 修改属性:element.setAttribute("attr", "value")(设置属性)、element.getAttribute("attr")(获取属性)、element.removeAttribute("attr")(移除属性);

    const img = document.querySelector("img");
    img.setAttribute("src", "new.jpg");
    console.log(img.getAttribute("src")); // new.jpg
    
  • 修改样式:

    • 行内样式:element.style.cssProperty = "value"(驼峰命名,如 backgroundColor);
    • 类名样式:element.classList.add("className")element.classList.remove("className")element.classList.toggle("className")(推荐,分离样式和逻辑)。
    const div = document.querySelector(".box");
    div.style.width = "200px";
    div.classList.add("active"); // 添加类名
    div.classList.toggle("show"); // 切换类名
    
  • 修改文本 / HTML:element.textContent(纯文本,安全)、element.innerHTML(HTML 字符串,有 XSS 风险)。

(4)删除 DOM 节点

  • parent.removeChild(child):父节点移除子节点;
  • element.remove():元素自身移除(ES6+ 方法,更简洁)。

示例:

const li = document.querySelector("li");
li.parentElement.removeChild(li); // 传统方式
// 或
li.remove(); // 简洁方式

(5)DOM 操作的性能优化

DOM 操作是 “重操作”,频繁修改会触发浏览器重排(Reflow)/ 重绘(Repaint),导致页面卡顿,优化手段:

  • 批量操作:先将节点脱离文档流(如隐藏父节点),操作完成后再恢复;

  • 使用文档碎片:document.createDocumentFragment(),批量插入节点仅触发一次重排;

    const fragment = document.createDocumentFragment();
    for (let i = 0; i < 1000; i++) {
      const li = document.createElement("li");
      li.textContent = `项 ${i}`;
      fragment.appendChild(li); // 先插入碎片,无重排
    }
    document.querySelector("ul").appendChild(fragment); // 仅一次重排
    
  • 避免频繁查询 DOM:将查询结果缓存到变量,减少 DOM 遍历。

2. 事件处理

事件是浏览器触发的 “信号”(如点击、输入、加载),事件处理是 JavaScript 响应用户操作的核心,分为 “事件绑定”“事件流”“事件对象”“事件优化” 四部分。

(1)事件绑定方式

  • 行内绑定(不推荐):<button onclick="handleClick()">点击</button> → 耦合度高,不利于维护;

  • DOM0 级绑定:element.onclick = function() {} → 简单,但一个事件只能绑定一个处理函数;

  • DOM2 级绑定:element.addEventListener("eventName", handler, useCapture) → 推荐,支持绑定多个处理函数,可控制事件阶段;

  • DOM0 级:浏览器原生支持,无官方规范 → element.onclick = function() {}

  • DOM1 级:仅规范 DOM 结构,未新增事件绑定方式 → 无事件相关内容

  • DOM2 级:W3C 发布标准,新增 addEventListener → 支持多绑定、事件阶段

  • DOM3 级:在 DOM2 基础上新增了更多事件类型(如键盘、鼠标滚轮事件)

const btn = document.querySelector("button");
// DOM0 级
btn.onclick = function() {
  console.log("点击1");
};
btn.onclick = function() {
  console.log("点击2"); // 覆盖上一个处理函数
};

// DOM2 级
const handleClick = () => console.log("点击1");
btn.addEventListener("click", handleClick);
btn.addEventListener("click", () => console.log("点击2")); // 可绑定多个
btn.removeEventListener("click", handleClick); // 可移除

(2)事件流(事件传播机制)

事件流分为三个阶段:

  1. 捕获阶段:事件从 document 向下传播到目标元素;
  2. 目标阶段:事件到达目标元素;
  3. 冒泡阶段:事件从目标元素向上传播到 document

addEventListener 的第三个参数 useCapturetrue 表示在捕获阶段触发,false(默认)表示在冒泡阶段触发。

(3)事件对象(Event)

事件处理函数的第一个参数是事件对象,包含事件的核心信息:

  • event.target:触发事件的原始元素(事件源);
  • event.currentTarget:绑定事件的元素;
  • event.preventDefault():阻止默认行为(如表单提交、链接跳转);
  • event.stopPropagation():阻止事件传播(冒泡 / 捕获);
  • event.stopImmediatePropagation():阻止事件传播,且阻止当前元素后续的事件处理函数执行。

示例:

// 阻止链接跳转
const a = document.querySelector("a");
a.addEventListener("click", (e) => {
  e.preventDefault(); // 阻止默认跳转
  console.log("点击链接,不跳转");
});

// 事件委托(利用事件冒泡)
const ul = document.querySelector("ul");
ul.addEventListener("click", (e) => {
  if (e.target.tagName === "LI") { // 判断点击的是 li 元素
    console.log("点击了列表项", e.target.textContent);
  }
});

(4)核心优化:事件委托

利用事件冒泡,将子元素的事件绑定到父元素,减少事件绑定数量,优化性能(尤其适合动态生成的元素):

// 动态生成的 li 无需单独绑定事件,父元素 ul 委托处理
const ul = document.querySelector("ul");
ul.addEventListener("click", (e) => {
  if (e.target.classList.contains("item")) {
    console.log("点击了动态生成的列表项");
  }
});

// 动态添加 li
const li = document.createElement("li");
li.classList.add("item");
li.textContent = "动态项";
ul.appendChild(li);

(5)常见事件类型

  • 鼠标事件:clickdblclickmouseovermouseoutmousedownmouseup
  • 键盘事件:keydownkeyupkeypress
  • 表单事件:inputchangesubmitfocusblur
  • 页面事件:loadDOMContentLoaded(DOM 解析完成)、scrollresize

DOM 操作和事件处理是前端交互的基础,核心原则是 “减少 DOM 操作次数”“合理利用事件机制”,既保证交互的流畅性,又避免性能问题。

总结

  1. ES6+ 新特性核心是简化语法、补全功能,是现代前端开发的基础,重点掌握块级作用域、箭头函数、解构、async/await 等高频用法;
  2. 异步编程从回调地狱演进到 Promise/async/await,核心是让异步代码更易读、易维护,async/await 是当前最优写法;
  3. 闭包是作用域链的保留,用于封装私有变量、实现防抖节流,需注意内存泄漏;原型链是 JS 继承的底层机制,所有对象通过 __proto__ 形成继承链条;
  4. DOM 操作需注重性能(批量操作、文档碎片),事件处理核心是事件委托,利用冒泡减少绑定数量,提升页面性能。

Vercel React 最佳实践 中文版

React 最佳实践

版本 1.0.0
Vercel 工程团队
2026年1月

注意:
本文档主要供 Agent 和 LLM 在 Vercel 维护、生成或重构 React 及 Next.js 代码库时遵循。人类开发者也会发现其对于保持一致性和自动化优化非常有帮助。


摘要

这是一份针对 React 和 Next.js 应用程序的综合性能优化指南,专为 AI Agent 和 LLM 设计。包含 8 个类别的 40 多条规则,按影响力从关键(消除瀑布流、减少打包体积)到增量(高级模式)排序。每条规则都包含详细的解释、错误与正确实现的真实代码对比,以及具体的影响指标,以指导自动重构和代码生成。


目录

  1. 消除瀑布流关键
  2. 打包体积优化关键
  3. 服务端性能
  4. 客户端数据获取中高
  5. 重渲染优化
  6. 渲染性能
  7. JavaScript 性能中低
  8. 高级模式

1. 消除瀑布流

影响力: 关键

瀑布流(Waterfalls)是头号性能杀手。每一个连续的 await 都会增加完整的网络延迟。消除它们能带来最大的收益。

1.1 推迟 Await 直到需要时

影响力: 高 (避免阻塞不使用的代码路径)

await 操作移动到实际使用它们的分支中,以避免阻塞不需要它们的代码路径。

错误:阻塞了两个分支

async function handleRequest(userId: string, skipProcessing: boolean) {
  const userData = await fetchUserData(userId)
  
  if (skipProcessing) {
    // 立即返回,但仍然等待了 userData
    return { skipped: true }
  }
  
  // 只有这个分支使用了 userData
  return processUserData(userData)
}

正确:仅在需要时阻塞

async function handleRequest(userId: string, skipProcessing: boolean) {
  if (skipProcessing) {
    // 不等待直接返回
    return { skipped: true }
  }
  
  // 仅在需要时获取
  const userData = await fetchUserData(userId)
  return processUserData(userData)
}

另一个例子:提前返回优化

// 错误:总是获取权限
async function updateResource(resourceId: string, userId: string) {
  const permissions = await fetchPermissions(userId)
  const resource = await getResource(resourceId)
  
  if (!resource) {
    return { error: 'Not found' }
  }
  
  if (!permissions.canEdit) {
    return { error: 'Forbidden' }
  }
  
  return await updateResourceData(resource, permissions)
}

// 正确:仅在需要时获取
async function updateResource(resourceId: string, userId: string) {
  const resource = await getResource(resourceId)
  
  if (!resource) {
    return { error: 'Not found' }
  }
  
  const permissions = await fetchPermissions(userId)
  
  if (!permissions.canEdit) {
    return { error: 'Forbidden' }
  }
  
  return await updateResourceData(resource, permissions)
}

当被跳过的分支经常被执行,或者被推迟的操作非常昂贵时,这种优化通过尤为有价值。

1.2 基于依赖的并行化

影响力: 关键 (2-10倍 提升)

对于具有部分依赖关系的操作,使用 better-all 来即最大化并行性。它会在尽可能早的时刻启动每个任务。

错误:profile 不必要地等待 config

const [user, config] = await Promise.all([
  fetchUser(),
  fetchConfig()
])
const profile = await fetchProfile(user.id)

正确:config 和 profile 并行运行

import { all } from 'better-all'

const { user, config, profile } = await all({
  async user() { return fetchUser() },
  async config() { return fetchConfig() },
  async profile() {
    return fetchProfile((await this.$.user).id)
  }
})

参考: github.com/shuding/bet…

1.3 防止 API 路由中的瀑布链

影响力: 关键 (2-10倍 提升)

在 API 路由和 Server Actions 中,即使此时还不 await 它们,也要立即启动独立的操作。

错误:config 等待 auth,data 等待两者

export async function GET(request: Request) {
  const session = await auth()
  const config = await fetchConfig()
  const data = await fetchData(session.user.id)
  return Response.json({ data, config })
}

正确:auth 和 config 立即启动

export async function GET(request: Request) {
  const sessionPromise = auth()
  const configPromise = fetchConfig()
  const session = await sessionPromise
  const [config, data] = await Promise.all([
    configPromise,
    fetchData(session.user.id)
  ])
  return Response.json({ data, config })
}

对于具有更复杂依赖链的操作,使用 better-all 自动最大化并行性(参见"基于依赖的并行化")。

1.4 对独立操作使用 Promise.all()

影响力: 关键 (2-10倍 提升)

当异步操作没有相互依赖关系时,使用 Promise.all() 并发执行它们。

错误:顺序执行,3 次往返

const user = await fetchUser()
const posts = await fetchPosts()
const comments = await fetchComments()

正确:并行执行,1 次往返

const [user, posts, comments] = await Promise.all([
  fetchUser(),
  fetchPosts(),
  fetchComments()
])

1.5 策略性 Suspense 边界

影响力: 高 (更快的首次绘制)

不要在异步组件中等待数据后再返回 JSX,而是使用 Suspense 边界在数据加载时更快地显示包装器 UI。

错误:包装器被数据获取阻塞

async function Page() {
  const data = await fetchData() // 阻塞整个页面
  
  return (
    <div>
      <div>Sidebar</div>
      <div>Header</div>
      <div>
        <DataDisplay data={data} />
      </div>
      <div>Footer</div>
    </div>
  )
}

即便只有中间部分需要数据,整个布局也会等待数据。

正确:包装器立即显示,数据流式传输

function Page() {
  return (
    <div>
      <div>Sidebar</div>
      <div>Header</div>
      <div>
        <Suspense fallback={<Skeleton />}>
          <DataDisplay />
        </Suspense>
      </div>
      <div>Footer</div>
    </div>
  )
}

async function DataDisplay() {
  const data = await fetchData() // 仅阻塞此组件
  return <div>{data.content}</div>
}

Sidebar、Header 和 Footer 立即渲染。只有 DataDisplay 等待数据。

替代方案:在组件间共享 promise

function Page() {
  // 立即开始获取,但不要 await
  const dataPromise = fetchData()
  
  return (
    <div>
      <div>Sidebar</div>
      <div>Header</div>
      <Suspense fallback={<Skeleton />}>
        <DataDisplay dataPromise={dataPromise} />
        <DataSummary dataPromise={dataPromise} />
      </Suspense>
      <div>Footer</div>
    </div>
  )
}

function DataDisplay({ dataPromise }: { dataPromise: Promise<Data> }) {
  const data = use(dataPromise) // 解包 promise
  return <div>{data.content}</div>
}

function DataSummary({ dataPromise }: { dataPromise: Promise<Data> }) {
  const data = use(dataPromise) // 复用同一个 promise
  return <div>{data.summary}</div>
}

两个组件共享同一个 promise,因此只会进行一次获取。布局立即渲染,而两个组件一起等待。

何时不使用此模式:

  • 布局决策所需的关键数据(影响定位)

  • 首屏(Above the fold)的 SEO 关键内容

  • Suspense 开销不值得的小型快速查询

  • 当你想要避免布局偏移(加载中 → 内容跳动)时

权衡: 更快的首次绘制 vs 潜在的布局偏移。根据你的 UX 优先级进行选择。


2. 打包体积优化

影响力: 关键

减少初始打包体积可以改善可交互时间 (TTI) 和最大内容绘制 (LCP)。

2.1 避免 Barrel 文件导入

影响力: 关键 (200-800ms 导入成本, 缓慢的构建)

直接从源文件导入而不是从 Barrel 文件导入,以避免加载数千个未使用的模块。Barrel 文件是重新导出多个模块的入口点(例如,执行 export * from './module'index.js)。

流行的图标和组件库在其入口文件中可能有 多达 10,000 个重导出。对于许多 React 包,仅导入它们就需要 200-800ms,这会影响开发速度和生产环境的冷启动。

为什么 tree-shaking 没有帮助: 当库被标记为外部(不打包)时,打包器无法对其进行优化。如果你将其打包以启用 tree-shaking,分析整个模块图会导致构建变得非常缓慢。

错误:导入整个库

import { Check, X, Menu } from 'lucide-react'
// 加载 1,583 个模块,开发环境额外耗时 ~2.8s
// 运行时成本:每次冷启动 200-800ms

import { Button, TextField } from '@mui/material'
// 加载 2,225 个模块,开发环境额外耗时 ~4.2s

正确:仅导入你需要的内容

import Check from 'lucide-react/dist/esm/icons/check'
import X from 'lucide-react/dist/esm/icons/x'
import Menu from 'lucide-react/dist/esm/icons/menu'
// 仅加载 3 个模块 (~2KB vs ~1MB)

import Button from '@mui/material/Button'
import TextField from '@mui/material/TextField'
// 仅加载你使用的内容

替代方案:Next.js 13.5+

// next.config.js - 使用 optimizePackageImports
module.exports = {
  experimental: {
    optimizePackageImports: ['lucide-react', '@mui/material']
  }
}

// 这样你可以保留符合人体工程学的 Barrel 导入:
import { Check, X, Menu } from 'lucide-react'
// 在构建时自动转换为直接导入

直接导入可提供 15-70% 更快的开发启动速度,28% 更快的构建速度,40% 更快的冷启动速度,以及显著更快的 HMR。

受影响的常见库:lucide-react, @mui/material, @mui/icons-material, @tabler/icons-react, react-icons, @headlessui/react, @radix-ui/react-*, lodash, ramda, date-fns, rxjs, react-use

参考: vercel.com/blog/how-we…

2.2 条件模块加载

影响力: 高 (仅在需要时加载大数据)

仅在功能激活时加载大数据或模块。

例子:懒加载动画帧

function AnimationPlayer({ enabled }: { enabled: boolean }) {
  const [frames, setFrames] = useState<Frame[] | null>(null)

  useEffect(() => {
    if (enabled && !frames && typeof window !== 'undefined') {
      import('./animation-frames.js')
        .then(mod => setFrames(mod.frames))
        .catch(() => setEnabled(false))
    }
  }, [enabled, frames])

  if (!frames) return <Skeleton />
  return <Canvas frames={frames} />
}

typeof window !== 'undefined' 检查可防止在 SSR 时打包此模块,从而优化服务端包体积和构建速度。

2.3 推迟非关键第三方库

影响力: 中 (水合后加载)

分析、日志记录和错误跟踪不会阻塞用户交互。应当在水合(Hydration)之后加载它们。

错误:阻塞初始包

import { Analytics } from '@vercel/analytics/react'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Analytics />
      </body>
    </html>
  )
}

正确:水合后加载

import dynamic from 'next/dynamic'

const Analytics = dynamic(
  () => import('@vercel/analytics/react').then(m => m.Analytics),
  { ssr: false }
)

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Analytics />
      </body>
    </html>
  )
}

2.4 重型组件动态导入

影响力: 关键 (直接影响 TTI 和 LCP)

使用 next/dynamic 懒加载初始渲染不需要的大型组件。

错误:Monaco 随主 chunk 打包 ~300KB

import { MonacoEditor } from './monaco-editor'

function CodePanel({ code }: { code: string }) {
  return <MonacoEditor value={code} />
}

正确:Monaco 按需加载

import dynamic from 'next/dynamic'

const MonacoEditor = dynamic(
  () => import('./monaco-editor').then(m => m.MonacoEditor),
  { ssr: false }
)

function CodePanel({ code }: { code: string }) {
  return <MonacoEditor value={code} />
}

2.5 基于用户意图预加载

影响力: 中 (减少感知延迟)

在需要之前预加载繁重的包,以减少感知延迟。

例子:悬停/聚焦时预加载

function EditorButton({ onClick }: { onClick: () => void }) {
  const preload = () => {
    if (typeof window !== 'undefined') {
      void import('./monaco-editor')
    }
  }

  return (
    <button
      onMouseEnter={preload}
      onFocus={preload}
      onClick={onClick}
    >
      打开编辑器
    </button>
  )
}

例子:当功能标志启用时预加载

function FlagsProvider({ children, flags }: Props) {
  useEffect(() => {
    if (flags.editorEnabled && typeof window !== 'undefined') {
      void import('./monaco-editor').then(mod => mod.init())
    }
  }, [flags.editorEnabled])

  return <FlagsContext.Provider value={flags}>
    {children}
  </FlagsContext.Provider>
}

typeof window !== 'undefined' 检查可防止在 SSR 时打包预加载模块,从而优化服务端包体积和构建速度。


3. 服务端性能

影响力: 高

优化服务端渲染和数据获取可消除服务端瀑布流并减少响应时间。

3.1 跨请求 LRU 缓存

影响力: 高 (跨请求缓存)

React.cache() 仅在一个请求内有效。对于跨连续请求共享的数据(用户点击按钮 A 然后点击按钮 B),请使用 LRU 缓存。

实现:

import { LRUCache } from 'lru-cache'

const cache = new LRUCache<string, any>({
  max: 1000,
  ttl: 5 * 60 * 1000  // 5 分钟
})

export async function getUser(id: string) {
  const cached = cache.get(id)
  if (cached) return cached

  const user = await db.user.findUnique({ where: { id } })
  cache.set(id, user)
  return user
}

// 请求 1: DB 查询, 结果被缓存
// 请求 2: 缓存命中, 无 DB 查询

当顺序的用户操作在几秒钟内命中多个需要相同数据的端点时,请使用此方法。

配合 Vercel 的 Fluid Compute LRU 缓存特别有效,因为多个并发请求可以共享同一个函数实例和缓存。这意味着缓存可以跨请求持久化,而无需 Redis 等外部存储。

在传统 Serverless 中: 每次调用都是隔离运行的,因此请考虑使用 Redis 进行跨进而缓存。

参考: github.com/isaacs/node…

3.2 最小化 RSC 边界序列化

影响力: 高 (减少传输数据大小)

React Server/Client 边界会将所有对象属性序列化为字符串,并将它们嵌入到 HTML 响应和后续的 RSC 请求中。此序列化数据直接影响页面重量和加载时间,因此 大小非常重要。仅传递客户端实际使用的字段。

错误:序列化所有 50 个字段

async function Page() {
  const user = await fetchUser()  // 50 个字段
  return <Profile user={user} />
}

'use client'
function Profile({ user }: { user: User }) {
  return <div>{user.name}</div>  // 使用 1 个字段
}

正确:仅序列化 1 个字段

async function Page() {
  const user = await fetchUser()
  return <Profile name={user.name} />
}

'use client'
function Profile({ name }: { name: string }) {
  return <div>{name}</div>
}

3.3 通过组件组合并行获取数据

影响力: 关键 (消除服务端瀑布流)

React Server Components 在树中顺序执行。使用组合重构以并行化数据获取。

错误:Sidebar 等待 Page 的 fetch 完成

export default async function Page() {
  const header = await fetchHeader()
  return (
    <div>
      <div>{header}</div>
      <Sidebar />
    </div>
  )
}

async function Sidebar() {
  const items = await fetchSidebarItems()
  return <nav>{items.map(renderItem)}</nav>
}

正确:两者同时获取

async function Header() {
  const data = await fetchHeader()
  return <div>{data}</div>
}

async function Sidebar() {
  const items = await fetchSidebarItems()
  return <nav>{items.map(renderItem)}</nav>
}

export default function Page() {
  return (
    <div>
      <Header />
      <Sidebar />
    </div>
  )
}

使用 children prop 的替代方案:

async function Layout({ children }: { children: ReactNode }) {
  const header = await fetchHeader()
  return (
    <div>
      <div>{header}</div>
      {children}
    </div>
  )
}

async function Sidebar() {
  const items = await fetchSidebarItems()
  return <nav>{items.map(renderItem)}</nav>
}

export default function Page() {
  return (
    <Layout>
      <Sidebar />
    </Layout>
  )
}

3.4 使用 React.cache() 进行按请求去重

影响力: 中 (请求内去重)

使用 React.cache() 进行服务端请求去重。身份验证和数据库查询受益最大。

用法:

import { cache } from 'react'

export const getCurrentUser = cache(async () => {
  const session = await auth()
  if (!session?.user?.id) return null
  return await db.user.findUnique({
    where: { id: session.user.id }
  })
})

在单个请求中,对 getCurrentUser() 的多次调用只会执行一次查询。

3.5 使用 after() 处理非阻塞操作

影响力: 中 (更快的响应时间)

使用 Next.js 的 after() 来调度应在发送响应后执行的工作。这可以防止日志记录、分析和其他副作用阻塞响应。

错误:阻塞响应

import { logUserAction } from '@/app/utils'

export async function POST(request: Request) {
  // 执行变更
  await updateDatabase(request)
  
  // 日志记录阻塞了响应
  const userAgent = request.headers.get('user-agent') || 'unknown'
  await logUserAction({ userAgent })
  
  return new Response(JSON.stringify({ status: 'success' }), {
    status: 200,
    headers: { 'Content-Type': 'application/json' }
  })
}

正确:非阻塞

import { after } from 'next/server'
import { headers, cookies } from 'next/headers'
import { logUserAction } from '@/app/utils'

export async function POST(request: Request) {
  // 执行变更
  await updateDatabase(request)
  
  // 响应发送后记录日志
  after(async () => {
    const userAgent = (await headers()).get('user-agent') || 'unknown'
    const sessionCookie = (await cookies()).get('session-id')?.value || 'anonymous'
    
    logUserAction({ sessionCookie, userAgent })
  })
  
  return new Response(JSON.stringify({ status: 'success' }), {
    status: 200,
    headers: { 'Content-Type': 'application/json' }
  })
}

响应立即发送,而日志记录在后台发生。

常见用例:

  • 分析跟踪

  • 审计日志

  • 发送通知

  • 缓存失效

  • 清理任务

重要说明:

  • 即使响应失败或重定向,after() 也会运行

  • 适用于 Server Actions、Route Handlers 和 Server Components

参考: nextjs.org/docs/app/ap…


4. 客户端数据获取

影响力: 中高

自动去重和高效的数据获取模式减少了多余的网络请求。

4.1 去重全局事件监听器

影响力: 低 (N 个组件共用单个监听器)

使用 useSWRSubscription() 在组件实例之间共享全局事件监听器。

错误:N 个实例 = N 个监听器

function useKeyboardShortcut(key: string, callback: () => void) {
  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      if (e.metaKey && e.key === key) {
        callback()
      }
    }
    window.addEventListener('keydown', handler)
    return () => window.removeEventListener('keydown', handler)
  }, [key, callback])
}

当多次使用 useKeyboardShortcut 钩子时,每个实例都会注册一个新的监听器。

正确:N 个实例 = 1 个监听器

import useSWRSubscription from 'swr/subscription'

// 模块级 Map 跟踪每个键的回调
const keyCallbacks = new Map<string, Set<() => void>>()

function useKeyboardShortcut(key: string, callback: () => void) {
  // 在 Map 中注册此回调
  useEffect(() => {
    if (!keyCallbacks.has(key)) {
      keyCallbacks.set(key, new Set())
    }
    keyCallbacks.get(key)!.add(callback)

    return () => {
      const set = keyCallbacks.get(key)
      if (set) {
        set.delete(callback)
        if (set.size === 0) {
          keyCallbacks.delete(key)
        }
      }
    }
  }, [key, callback])

  useSWRSubscription('global-keydown', () => {
    const handler = (e: KeyboardEvent) => {
      if (e.metaKey && keyCallbacks.has(e.key)) {
        keyCallbacks.get(e.key)!.forEach(cb => cb())
      }
    }
    window.addEventListener('keydown', handler)
    return () => window.removeEventListener('keydown', handler)
  })
}

function Profile() {
  // 多个快捷键将共享同一个监听器
  useKeyboardShortcut('p', () => { /* ... */ }) 
  useKeyboardShortcut('k', () => { /* ... */ })
  // ...
}

4.2 使用 SWR 自动去重

影响力: 中高 (自动去重)

SWR 支持跨组件实例的请求去重、缓存和重新验证。

错误:无去重,每个实例都获取

function UserList() {
  const [users, setUsers] = useState([])
  useEffect(() => {
    fetch('/api/users')
      .then(r => r.json())
      .then(setUsers)
  }, [])
}

正确:多个实例共享一个请求

import useSWR from 'swr'

function UserList() {
  const { data: users } = useSWR('/api/users', fetcher)
}

对于不可变数据:

import { useImmutableSWR } from '@/lib/swr'

function StaticContent() {
  const { data } = useImmutableSWR('/api/config', fetcher)
}

对于变异 (Mutations):

import { useSWRMutation } from 'swr/mutation'

function UpdateButton() {
  const { trigger } = useSWRMutation('/api/user', updateUser)
  return <button onClick={() => trigger()}>更新</button>
}

参考: swr.vercel.app


5. 重渲染优化

影响力: 中

减少不必要的重渲染可最大限度地减少浪费的计算并提高 UI 响应能力。

5.1 推迟状态读取到使用点

影响力: 中 (避免不必要的订阅)

如果你只在回调中读取动态状态(搜索参数、localStorage),则不要订阅它。

错误:订阅所有 searchParams 更改

function ShareButton({ chatId }: { chatId: string }) {
  const searchParams = useSearchParams()

  const handleShare = () => {
    const ref = searchParams.get('ref')
    shareChat(chatId, { ref })
  }

  return <button onClick={handleShare}>分享</button>
}

正确:按需读取,无订阅

function ShareButton({ chatId }: { chatId: string }) {
  const handleShare = () => {
    const params = new URLSearchParams(window.location.search)
    const ref = params.get('ref')
    shareChat(chatId, { ref })
  }

  return <button onClick={handleShare}>分享</button>
}

5.2 提取为记忆化组件

影响力: 中 (启用提前返回)

将昂贵的工作提取到记忆化 (memoized) 组件中,以便在计算及以前提前返回。

错误:即使在加载时也计算头像

function Profile({ user, loading }: Props) {
  const avatar = useMemo(() => {
    const id = computeAvatarId(user)
    return <Avatar id={id} />
  }, [user])

  if (loading) return <Skeleton />
  return <div>{avatar}</div>
}

正确:加载时跳过计算

const UserAvatar = memo(function UserAvatar({ user }: { user: User }) {
  const id = useMemo(() => computeAvatarId(user), [user])
  return <Avatar id={id} />
})

function Profile({ user, loading }: Props) {
  if (loading) return <Skeleton />
  return (
    <div>
      <UserAvatar user={user} />
    </div>
  )
}

注意: 如果你的项目启用了 React Compiler,则无需使用 memo()useMemo() 进行手动记忆化。编译器会自动优化重渲染。

5.3 缩小 Effect 依赖范围

影响力: 低 (最小化 effect 重新运行)

指定原始值依赖项而不是对象,以最大限度地减少 effect 的重新运行。

错误:在任何用户字段更改时重新运行

useEffect(() => {
  console.log(user.id)
}, [user])

正确:仅在 id 更改时重新运行

useEffect(() => {
  console.log(user.id)
}, [user.id])

对于派生状态,在 effect 外部计算:

// 错误:在 width=767, 766, 765... 时运行
useEffect(() => {
  if (width < 768) {
    enableMobileMode()
  }
}, [width])

// 正确:仅在布尔值转换时运行
const isMobile = width < 768
useEffect(() => {
  if (isMobile) {
    enableMobileMode()
  }
}, [isMobile])

5.4 订阅派生状态

影响力: 中 (降低重渲染频率)

订阅派生的布尔状态而不是连续值,以降低重渲染频率。

错误:在每个像素变化时重渲染

function Sidebar() {
  const width = useWindowWidth()  // 持续更新
  const isMobile = width < 768
  return <nav className={isMobile ? 'mobile' : 'desktop'}>
}

正确:仅在布尔值更改时重渲染

function Sidebar() {
  const isMobile = useMediaQuery('(max-width: 767px)')
  return <nav className={isMobile ? 'mobile' : 'desktop'}>
}

5.5 使用函数式 setState 更新

影响力: 中 (防止闭包陷阱和不必要的回调重建)

当基于当前状态值更新状态时,使用 setState 的函数式更新形式,而不是直接引用状态变量。这可以防止闭包陷阱 (stale closures),消除不必要的依赖,并创建稳定的回调引用。

错误:需要 state 作为依赖

function TodoList() {
  const [items, setItems] = useState(initialItems)
  
  // 回调必须依赖 items,在每次 items 更改时重建
  const addItems = useCallback((newItems: Item[]) => {
    setItems([...items, ...newItems])
  }, [items])  // ❌ items 依赖导致重建
  
  // 如果忘记依赖,会有闭包陷阱风险
  const removeItem = useCallback((id: string) => {
    setItems(items.filter(item => item.id !== id))
  }, [])  // ❌ 缺少 items 依赖 - 将使用陈旧的 items!
  
  return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
}

第一个回调每次 items 更改时都会重建,这可能会导致子组件不必要地重渲染。第二个回调有一个闭包陷阱 bug——它将始终引用初始的 items 值。

正确:稳定的回调,无闭包陷阱

function TodoList() {
  const [items, setItems] = useState(initialItems)
  
  // 稳定的回调,从未重建
  const addItems = useCallback((newItems: Item[]) => {
    setItems(curr => [...curr, ...newItems])
  }, [])  // ✅ 不需要依赖
  
  // 始终使用最新状态,无闭包陷阱风险
  const removeItem = useCallback((id: string) => {
    setItems(curr => curr.filter(item => item.id !== id))
  }, [])  // ✅ 安全且稳定
  
  return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
}

好处:

  1. 稳定的回调引用 - 状态更改时无需重建回调

  2. 无闭包陷阱 - 始终对最新状态值进行操作

  3. 更少的依赖 - 简化了依赖数组并减少了内存泄漏

  4. 防止错误 - 消除了 React 闭包 bug 的最常见来源

何时使用函数式更新:

  • 任何依赖于当前状态值的 setState

  • 在需要 state 的 useCallback/useMemo 内部

  • 引用 state 的事件处理程序

  • 更新 state 的异步操作

何时直接更新是可以的:

  • 将 state 设置为静态值:setCount(0)

  • 仅从 props/参数设置 state:setName(newName)

  • State 不依赖于先前的值

注意: 如果你的项目启用了 React Compiler,编译器可以自动优化某些情况,但仍建议使用函数式更新以确保证正确性并防止闭包陷阱 bug。

5.6 使用惰性状态初始化

影响力: 中 (每次渲染都浪费计算)

将函数传递给 useState 用于昂贵的初始值。如果不使用函数形式,初始化程序将在每次渲染时运行,即使该值仅使用一次。

错误:每次渲染都运行

function FilteredList({ items }: { items: Item[] }) {
  // buildSearchIndex() 在每次渲染时运行,即使在初始化之后
  const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items))
  const [query, setQuery] = useState('')
  
  // 当 query 更改时,buildSearchIndex 再次不必要地运行
  return <SearchResults index={searchIndex} query={query} />
}

function UserProfile() {
  // JSON.parse 在每次渲染时运行
  const [settings, setSettings] = useState(
    JSON.parse(localStorage.getItem('settings') || '{}')
  )
  
  return <SettingsForm settings={settings} onChange={setSettings} />
}

正确:仅运行一次

function FilteredList({ items }: { items: Item[] }) {
  // buildSearchIndex() 仅在初始渲染时运行
  const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items))
  const [query, setQuery] = useState('')
  
  return <SearchResults index={searchIndex} query={query} />
}

function UserProfile() {
  // JSON.parse 仅在初始渲染时运行
  const [settings, setSettings] = useState(() => {
    const stored = localStorage.getItem('settings')
    return stored ? JSON.parse(stored) : {}
  })
  
  return <SettingsForm settings={settings} onChange={setSettings} />
}

当从 localStorage/sessionStorage 计算初始值、构建数据结构(索引、Map)、从 DOM 读取或执行繁重的转换是,请使用惰性初始化。

对于简单的原始值 (useState(0))、直接引用 (useState(props.value)) 或廉价的字面量 (useState({})),函数形式是不必要的。

5.7 对非紧急更新使用 Transitions

影响力: 中 (保持 UI 响应及)

将频繁的、非紧急的状态更新标记为 transitions,以保持 UI 响应能力。

错误:每次滚动都阻塞 UI

function ScrollTracker() {
  const [scrollY, setScrollY] = useState(0)
  useEffect(() => {
    const handler = () => setScrollY(window.scrollY)
    window.addEventListener('scroll', handler, { passive: true })
    return () => window.removeEventListener('scroll', handler)
  }, [])
}

正确:非阻塞更新

import { startTransition } from 'react'

function ScrollTracker() {
  const [scrollY, setScrollY] = useState(0)
  useEffect(() => {
    const handler = () => {
      startTransition(() => setScrollY(window.scrollY))
    }
    window.addEventListener('scroll', handler, { passive: true })
    return () => window.removeEventListener('scroll', handler)
  }, [])
}

6. 渲染性能

影响力: 中

优化渲染过程可减少浏览器需要做的工作。

6.1 动画化 SVG 包装器而非 SVG 元素

影响力: 低 (启用硬件加速)

许多浏览器不支持对 SVG 元素的 CSS3 动画进行硬件加速。将 SVG 包装在 <div> 中并对包装器进行动画处理。

错误:直接动画化 SVG - 无硬件加速

function LoadingSpinner() {
  return (
    <svg 
      className="animate-spin"
      width="24" 
      height="24" 
      viewBox="0 0 24 24"
    >
      <circle cx="12" cy="12" r="10" stroke="currentColor" />
    </svg>
  )
}

正确:动画化包装器 div - 硬件加速

function LoadingSpinner() {
  return (
    <div className="animate-spin">
      <svg 
        width="24" 
        height="24" 
        viewBox="0 0 24 24"
      >
        <circle cx="12" cy="12" r="10" stroke="currentColor" />
      </svg>
    </div>
  )
}

这适用于所有 CSS 变换和过渡(transform, opacity, translate, scale, rotate)。包装器 div 允许浏览器使用 GPU 加速来实现更流畅的动画。

6.2 长列表使用 CSS content-visibility

影响力: 高 (更快的首次渲染)

应用 content-visibility: auto 以推迟屏幕外渲染。

CSS:

.message-item {
  content-visibility: auto;
  contain-intrinsic-size: 0 80px;
}

例子:

function MessageList({ messages }: { messages: Message[] }) {
  return (
    <div className="overflow-y-auto h-screen">
      {messages.map(msg => (
        <div key={msg.id} className="message-item">
          <Avatar user={msg.author} />
          <div>{msg.content}</div>
        </div>
      ))}
    </div>
  )
}

对于 1000 条消息,浏览器会跳过 ~990 个屏幕外项目的布局/绘制(首次渲染快 10 倍)。

6.3 提升静态 JSX 元素

影响力: 低 (避免重新创建)

将静态 JSX 提取到组件外部以避免重新创建。

错误:每次渲染都重新创建元素

function LoadingSkeleton() {
  return <div className="animate-pulse h-20 bg-gray-200" />
}

function Container() {
  return (
    <div>
      {loading && <LoadingSkeleton />}
    </div>
  )
}

正确:复用相同元素

const loadingSkeleton = (
  <div className="animate-pulse h-20 bg-gray-200" />
)

function Container() {
  return (
    <div>
      {loading && loadingSkeleton}
    </div>
  )
}

这对于大型和静态的 SVG 节点特别有用,因为在每次渲染时重新创建它们可能会很昂贵。

注意: 如果你的项目启用了 React Compiler,编译器会自动提升静态 JSX 元素并优化组件重渲染,使得手动提升变得不必要。

6.4 优化 SVG 精度

影响力: 低 (减小文件大小)

降低 SVG 坐标精度以减小文件大小。最佳精度取决于 viewBox 大小,但在一般情况下,应考虑降低精度。

错误:过高的精度

<path d="M 10.293847 20.847362 L 30.938472 40.192837" />

正确:1 位小数

<path d="M 10.3 20.8 L 30.9 40.2" />

使用 SVGO 自动化:

npx svgo --precision=1 --multipass icon.svg

6.5 无闪烁防止水合不匹配

影响力: 中 (避免视觉闪烁和水合错误)

当渲染依赖于客户端存储(localStorage, cookies)的内容时,通过注入一个同步脚本在 React 水合之前更新 DOM,以避免 SSR 中断和水合后的闪烁。

错误:破坏 SSR

function ThemeWrapper({ children }: { children: ReactNode }) {
  // localStorage 在服务器上不可用 - 抛出错误
  const theme = localStorage.getItem('theme') || 'light'
  
  return (
    <div className={theme}>
      {children}
    </div>
  )
}

服务端渲染将失败,因为 localStorage 未定义。

错误:视觉闪烁

function ThemeWrapper({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState('light')
  
  useEffect(() => {
    // 在水合后运行 - 导致可见的闪烁
    const stored = localStorage.getItem('theme')
    if (stored) {
      setTheme(stored)
    }
  }, [])
  
  return (
    <div className={theme}>
      {children}
    </div>
  )
}

组件首先使用默认值(light)渲染,然后在水合后更新,导致不正确内容的可见闪烁。

正确:无闪烁,无水合不匹配

function ThemeWrapper({ children }: { children: ReactNode }) {
  return (
    <>
      <div id="theme-wrapper">
        {children}
      </div>
      <script
        dangerouslySetInnerHTML={{
          __html: `
            (function() {
              try {
                var theme = localStorage.getItem('theme') || 'light';
                var el = document.getElementById('theme-wrapper');
                if (el) el.className = theme;
              } catch (e) {}
            })();
          `,
        }}
      />
    </>
  )
}

内联脚本在显示元素之前同步执行,确保 DOM 已经具有正确的值。无闪烁,无水合不匹配。

此模式对于主题切换、用户偏好、身份验证状态以及任何应立即渲染而不闪烁默认值的仅客户端数据特别有用。

6.6 使用 Activity 组件进行显示/隐藏

影响力: 中 (保留状态/DOM)

使用 React 的 <Activity> 来为频繁切换可见性的昂贵组件保留状态/DOM。

用法:

import { Activity } from 'react'

function Dropdown({ isOpen }: Props) {
  return (
    <Activity mode={isOpen ? 'visible' : 'hidden'}>
      <ExpensiveMenu />
    </Activity>
  )
}

避免昂贵的重渲染和状态丢失。

6.7 使用显式条件渲染

影响力: 低 (防止渲染 0 或 NaN)

当条件可能为 0NaN 或其他会渲染的假值时,使用显式三元运算符 (? :) 而不是 && 进行条件渲染。

错误:当 count 为 0 时渲染 "0"

function Badge({ count }: { count: number }) {
  return (
    <div>
      {count && <span className="badge">{count}</span>}
    </div>
  )
}

// 当 count = 0, 渲染: <div>0</div>
// 当 count = 5, 渲染: <div><span class="badge">5</span></div>

正确:当 count 为 0 时不渲染任何内容

function Badge({ count }: { count: number }) {
  return (
    <div>
      {count > 0 ? <span className="badge">{count}</span> : null}
    </div>
  )
}

// 当 count = 0, 渲染: <div></div>
// 当 count = 5, 渲染: <div><span class="badge">5</span></div>

7. JavaScript 性能

影响力: 中低

对热路径的微优化可以累积成有意义的改进。

7.1 批量 DOM CSS 更改

影响力: 中 (减少重排/重绘)

避免通过一次修改一个属性的方式更改样式。通过类或 cssText 将多个 CSS 更改组合在一起,以最大程度地减少浏览器重排 (reflows)。

错误:多次重排

function updateElementStyles(element: HTMLElement) {
  // 每一行都会触发一次重排
  element.style.width = '100px'
  element.style.height = '200px'
  element.style.backgroundColor = 'blue'
  element.style.border = '1px solid black'
}

正确:添加类 - 单次重排

// CSS 文件
.highlighted-box {
  width: 100px;
  height: 200px;
  background-color: blue;
  border: 1px solid black;
}

// JavaScript
function updateElementStyles(element: HTMLElement) {
  element.classList.add('highlighted-box')
}

正确:改变 cssText - 单次重排

function updateElementStyles(element: HTMLElement) {
  element.style.cssText = `
    width: 100px;
    height: 200px;
    background-color: blue;
    border: 1px solid black;
  `
}

React 例子:

// 错误:逐个更改样式
function Box({ isHighlighted }: { isHighlighted: boolean }) {
  const ref = useRef<HTMLDivElement>(null)
  
  useEffect(() => {
    if (ref.current && isHighlighted) {
      ref.current.style.width = '100px'
      ref.current.style.height = '200px'
      ref.current.style.backgroundColor = 'blue'
    }
  }, [isHighlighted])
  
  return <div ref={ref}>内容</div>
}

// 正确:切换类
function Box({ isHighlighted }: { isHighlighted: boolean }) {
  return (
    <div className={isHighlighted ? 'highlighted-box' : ''}>
      内容
    </div>
  )
}

尽可能使用 CSS 类而不是内联样式。类会被浏览器缓存,并提供更好的关注点分离。

7.2 为重复查找构建索引 Map

影响力: 中低 (1M 操作 -> 2K 操作)

同一键的多次 .find() 调用应使用 Map。

错误 (每次查找 O(n)):

function processOrders(orders: Order[], users: User[]) {
  return orders.map(order => ({
    ...order,
    user: users.find(u => u.id === order.userId)
  }))
}

正确 (每次查找 O(1)):

function processOrders(orders: Order[], users: User[]) {
  const userById = new Map(users.map(u => [u.id, u]))

  return orders.map(order => ({
    ...order,
    user: userById.get(order.userId)
  }))
}

构建一次 Map (O(n)),然后所有查找都是 O(1)。

对于 1000 个订单 × 1000 个用户:100万次操作 → 2000 次操作。

7.3 在循环中缓存属性访问

影响力: 中低 (减少查找)

在热路径中缓存对象属性查找。

错误:3 次查找 × N 次迭代

for (let i = 0; i < arr.length; i++) {
  process(obj.config.settings.value)
}

正确:总过 1 次查找

const value = obj.config.settings.value
const len = arr.length
for (let i = 0; i < len; i++) {
  process(value)
}

7.4 缓存重复函数调用

影响力: 中 (避免冗余计算)

当在渲染期间使用相同的输入重复调用相同的函数时,使用模块级 Map 缓存函数结果。

错误:冗余计算

function ProjectList({ projects }: { projects: Project[] }) {
  return (
    <div>
      {projects.map(project => {
        // slugify() 对相同的项目名称调用了 100+ 次
        const slug = slugify(project.name)
        
        return <ProjectCard key={project.id} slug={slug} />
      })}
    </div>
  )
}

正确:缓存结果

// 模块级缓存
const slugifyCache = new Map<string, string>()

function cachedSlugify(text: string): string {
  if (slugifyCache.has(text)) {
    return slugifyCache.get(text)!
  }
  const result = slugify(text)
  slugifyCache.set(text, result)
  return result
}

function ProjectList({ projects }: { projects: Project[] }) {
  return (
    <div>
      {projects.map(project => {
        // 每个唯一的项目名称仅计算一次
        const slug = cachedSlugify(project.name)
        
        return <ProjectCard key={project.id} slug={slug} />
      })}
    </div>
  )
}

单值函数的更简单模式:

let isLoggedInCache: boolean | null = null

function isLoggedIn(): boolean {
  if (isLoggedInCache !== null) {
    return isLoggedInCache
  }
  
  isLoggedInCache = document.cookie.includes('auth=')
  return isLoggedInCache
}

// 身份验证更改时清除缓存
function onAuthChange() {
  isLoggedInCache = null
}

使用 Map(而不是 hook),这样它可以在任何地方工作:工具函数、事件处理程序,而不仅仅是 React 组件。

参考: vercel.com/blog/how-we…

7.5 缓存 Storage API 调用

影响力: 中低 (减少昂贵的 I/O)

localStorage, sessionStoragedocument.cookie 是同步且昂贵的。在内存中缓存读取。

错误:每次调用都读取存储

function getTheme() {
  return localStorage.getItem('theme') ?? 'light'
}
// 调用 10 次 = 10 次存储读取

正确:Map 缓存

const storageCache = new Map<string, string | null>()

function getLocalStorage(key: string) {
  if (!storageCache.has(key)) {
    storageCache.set(key, localStorage.getItem(key))
  }
  return storageCache.get(key)
}

function setLocalStorage(key: string, value: string) {
  localStorage.setItem(key, value)
  storageCache.set(key, value)  // 保持缓存同步
}

使用 Map(而不是 hook),这样它可以在任何地方工作:工具函数、事件处理程序,而不仅仅是 React 组件。

Cookie 缓存:

let cookieCache: Record<string, string> | null = null

function getCookie(name: string) {
  if (!cookieCache) {
    cookieCache = Object.fromEntries(
      document.cookie.split('; ').map(c => c.split('='))
    )
  }
  return cookieCache[name]
}

重要:在外部更改时失效

window.addEventListener('storage', (e) => {
  if (e.key) storageCache.delete(e.key)
})

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') {
    storageCache.clear()
  }
})

如果存储可以在外部更改(另一个标签页、服务器设置的 cookie),请使缓存失效。

7.6合并多个数组迭代

影响力: 中低 (减少迭代)

多个 .filter().map() 调用会多次迭代数组。合并为一个循环。

错误:3 次迭代

const admins = users.filter(u => u.isAdmin)
const testers = users.filter(u => u.isTester)
const inactive = users.filter(u => !u.isActive)

正确:1 次迭代

const admins: User[] = []
const testers: User[] = []
const inactive: User[] = []

for (const user of users) {
  if (user.isAdmin) admins.push(user)
  if (user.isTester) testers.push(user)
  if (!user.isActive) inactive.push(user)
}

7.7 数组比较前先检查长度

影响力: 中高 (避免长度不同时的昂贵操作)

在通过昂贵操作(排序、深度相等、序列化)比较数组时,先检查长度。如果长度不同,数组就不可能相等。

在实际应用中,当比较运行在热路径(事件处理程序、渲染循环)中时,此优化通过尤为有价值。

错误:总是运行昂贵的比较

function hasChanges(current: string[], original: string[]) {
  // 即使长度不同,也总是进行排序和连接
  return current.sort().join() !== original.sort().join()
}

即使 current.length 是 5 而 original.length 是 100,也会运行两次 O(n log n) 排序。连接数组和比较字符串也有开销。

正确 (先进行 O(1) 长度检查):

function hasChanges(current: string[], original: string[]) {
  // 如果长度不同,提前返回
  if (current.length !== original.length) {
    return true
  }
  // 仅当长度匹配时才排序/连接
  const currentSorted = current.toSorted()
  const originalSorted = original.toSorted()
  for (let i = 0; i < currentSorted.length; i++) {
    if (currentSorted[i] !== originalSorted[i]) {
      return true
    }
  }
  return false
}

这种新方法更高效,因为:

  • 当长度不同时,它避免了排序和连接数组的开销

  • 它避免了消耗内存来连接字符串(对于大数组尤其重要)

  • 它避免了修改原始数组

  • 发现差异时提前返回

7.8 函数提前返回

影响力: 中低 (避免不必要的计算)

确当定结果时提前返回,以跳过不必要的处理。

错误:即使找到答案也处理所有项目

function validateUsers(users: User[]) {
  let hasError = false
  let errorMessage = ''
  
  for (const user of users) {
    if (!user.email) {
      hasError = true
      errorMessage = 'Email required'
    }
    if (!user.name) {
      hasError = true
      errorMessage = 'Name required'
    }
    // 即使发现错误也继续检查所有用户
  }
  
  return hasError ? { valid: false, error: errorMessage } : { valid: true }
}

正确:一发现错误立即返回

function validateUsers(users: User[]) {
  for (const user of users) {
    if (!user.email) {
      return { valid: false, error: 'Email required' }
    }
    if (!user.name) {
      return { valid: false, error: 'Name required' }
    }
  }

  return { valid: true }
}

7.9 提升 RegExp 创建

影响力: 中低 (避免重新创建)

不要在 render 内部创建 RegExp。提升到模块作用域或使用 useMemo() 进行记忆化。

错误:每次渲染都创建新的 RegExp

function Highlighter({ text, query }: Props) {
  const regex = new RegExp(`(${query})`, 'gi')
  const parts = text.split(regex)
  return <>{parts.map((part, i) => ...)}</>
}

正确:记忆化或提升

const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/

function Highlighter({ text, query }: Props) {
  const regex = useMemo(
    () => new RegExp(`(${escapeRegex(query)})`, 'gi'),
    [query]
  )
  const parts = text.split(regex)
  return <>{parts.map((part, i) => ...)}</>
}

警告:全局 regex 具有可变状态

const regex = /foo/g
regex.test('foo')  // true, lastIndex = 3
regex.test('foo')  // false, lastIndex = 0

全局 regex (/g) 具有可变的 lastIndex 状态。

7.10 使用循环求最小/最大值而非排序

影响力: 低 (O(n) 而非 O(n log n))

查找最小或最大元素只需要遍历数组一次。排序是浪费且更慢的。

错误 (O(n log n) - 排序以查找最新):

interface Project {
  id: string
  name: string
  updatedAt: number
}

function getLatestProject(projects: Project[]) {
  const sorted = [...projects].sort((a, b) => b.updatedAt - a.updatedAt)
  return sorted[0]
}

仅为了查找最大值而对整个数组进行排序。

错误 (O(n log n) - 排序以查找最旧和最新):

function getOldestAndNewest(projects: Project[]) {
  const sorted = [...projects].sort((a, b) => a.updatedAt - b.updatedAt)
  return { oldest: sorted[0], newest: sorted[sorted.length - 1] }
}

仅需要最小/最大值时仍然不必要地排序。

正确 (O(n) - 单次循环):

function getLatestProject(projects: Project[]) {
  if (projects.length === 0) return null
  
  let latest = projects[0]
  
  for (let i = 1; i < projects.length; i++) {
    if (projects[i].updatedAt > latest.updatedAt) {
      latest = projects[i]
    }
  }
  
  return latest
}

function getOldestAndNewest(projects: Project[]) {
  if (projects.length === 0) return { oldest: null, newest: null }
  
  let oldest = projects[0]
  let newest = projects[0]
  
  for (let i = 1; i < projects.length; i++) {
    if (projects[i].updatedAt < oldest.updatedAt) oldest = projects[i]
    if (projects[i].updatedAt > newest.updatedAt) newest = projects[i]
  }
  
  return { oldest, newest }
}

单次遍历数组,无复制,无排序。

替代方案:Math.min/Math.max 用于小数组

const numbers = [5, 2, 8, 1, 9]
const min = Math.min(...numbers)
const max = Math.max(...numbers)

这对于小数组有效,但对于非常大的数组,由于展开运算符的限制,可能会更慢。为了可靠性,建议使用循环方法。

7.11 使用 Set/Map 进行 O(1) 查找

影响力: 中低 (O(n) -> O(1))

将数组转换为 Set/Map 以进行重复的成员身份检查。

错误 (每次检查 O(n)):

const allowedIds = ['a', 'b', 'c', ...]
items.filter(item => allowedIds.includes(item.id))

正确 (每次检查 O(1)):

const allowedIds = new Set(['a', 'b', 'c', ...])
items.filter(item => allowedIds.has(item.id))

7.12 使用 toSorted() 代替 sort() 以保证不可变性

影响力: 中高 (防止 React 状态中的变异 bug)

.sort() 会原地修改数组,这可能会导致 React 状态和 props 出现 bug。使用 .toSorted() 创建一个新的排序数组而不进行变异。

错误:修改原始数组

function UserList({ users }: { users: User[] }) {
  // 修改了 users prop 数组!
  const sorted = useMemo(
    () => users.sort((a, b) => a.name.localeCompare(b.name)),
    [users]
  )
  return <div>{sorted.map(renderUser)}</div>
}

正确:创建新数组

function UserList({ users }: { users: User[] }) {
  // 创建新的排序数组,原始数组未更改
  const sorted = useMemo(
    () => users.toSorted((a, b) => a.name.localeCompare(b.name)),
    [users]
  )
  return <div>{sorted.map(renderUser)}</div>
}

为什么这在 React 中很重要:

  1. Props/state 变异打破了 React 的不可变性模型 - React 期望 props 和 state 被视为只读

  2. 导致闭包陷阱 bug - 在闭包(回调、effects)内修改数组可能导致意外行为

浏览器支持:旧版浏览器回退

// 旧版浏览器的回退
const sorted = [...items].sort((a, b) => a.value - b.value)

.toSorted() 在所有现代浏览器(Chrome 110+, Safari 16+, Firefox 115+, Node.js 20+)中均可用。对于旧环境,使用展开运算符。

其他不可变数组方法:

  • .toSorted() - 不可变排序

  • .toReversed() - 不可变反转

  • .toSpliced() - 不可变拼接

  • .with() - 不可变元素替换


8. 高级模式

影响力: 低

针对需要谨慎实现的特定情况的高级模式。

8.1 在 Refs 中存储事件处理程序

影响力: 低 (稳定的订阅)

当在不应因回调更改而重新订阅的 effect 中使用时,将回调存储在 refs 中。

错误:每次渲染都重新订阅

function useWindowEvent(event: string, handler: () => void) {
  useEffect(() => {
    window.addEventListener(event, handler)
    return () => window.removeEventListener(event, handler)
  }, [event, handler])
}

正确:稳定的订阅

import { useEffectEvent } from 'react'

function useWindowEvent(event: string, handler: () => void) {
  const onEvent = useEffectEvent(handler)

  useEffect(() => {
    window.addEventListener(event, onEvent)
    return () => window.removeEventListener(event, onEvent)
  }, [event])
}

替代方案:如果你使用的是最新版 React,请使用 useEffectEvent

useEffectEvent 为相同的模式提供了更清晰的 API:它创建一个稳定的函数引用,该引用始终调用处理程序的最新版本。

8.2 使用 useLatest 获取稳定的回调 Refs

影响力: 低 (防止 effect 重新运行)

在不将值添加到依赖数组的情况下访问回调中的最新值。防止 effect 重新运行,同时避免闭包陷阱。

实现:

function useLatest<T>(value: T) {
  const ref = useRef(value)
  useEffect(() => {
    ref.current = value
  }, [value])
  return ref
}

错误:在每次回调更改时重新运行 effect

function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
  const [query, setQuery] = useState('')

  useEffect(() => {
    const timeout = setTimeout(() => onSearch(query), 300)
    return () => clearTimeout(timeout)
  }, [query, onSearch])
}

正确:稳定的 effect,新鲜的回调

function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
  const [query, setQuery] = useState('')
  const onSearchRef = useLatest(onSearch)

  useEffect(() => {
    const timeout = setTimeout(() => onSearchRef.current(query), 300)
    return () => clearTimeout(timeout)
  }, [query])
}

参考资料

  1. react.dev
  2. nextjs.org
  3. swr.vercel.app
  4. github.com/shuding/bet…
  5. github.com/isaacs/node…
  6. vercel.com/blog/how-we…
  7. vercel.com/blog/how-we…
❌