普通视图

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

ECharts 实战技巧:揭秘 X 轴末项标签 “莫名加粗” 之谜及破解之道

作者 小小愿望
2025年8月16日 18:00

在使用 ECharts 进行数据可视化时,你是否遇到过 X 轴最后一个标签文字突然变粗的情况?这一看似诡异的现象背后其实隐藏着渲染机制的小陷阱。本文将深入剖析问题根源,并提供多种实用解决方案,助你精准掌控图表的每一个细节。


一、引言

在利用 ECharts 构建精美图表的过程中,一些细微却棘手的问题时常困扰着开发者。其中,X 轴最后一项标签字体莫名变粗就是一个典型例子。这一问题虽不影响数据准确性,但却破坏了图表的整体美观度与专业性,尤其对于追求极致视觉效果的项目而言,更是亟待解决的难题。今天,我们就一同揭开这个 “谜团”,探索其背后的成因及有效的应对策略。

二、问题重现与影响

当你按照常规流程配置好 X 轴的相关参数,满心期待地看到整齐划一的坐标标签时,却发现最后一个标签仿佛被施了魔法一般,字体比其他项更粗。这种突兀的变化使得整个 X 轴看起来极不协调,降低了图表的专业性和可读性。无论是用于内部汇报还是对外展示,这样的瑕疵都可能让人对你的工作成果产生质疑。

三、问题根源深度解析

经过深入研究和大量实践验证,我们发现这一问题主要源于以下几个因素的综合作用: 重复渲染机制:当设置 axisLabel.interval0(即强制显示所有标签)时,ECharts 内部的渲染引擎可能会对最后一个标签进行额外的重复绘制操作。由于叠加效应,导致视觉上呈现出字体加粗的效果。这是因为在某些情况下,为了确保长文本或其他特殊布局的需求,框架会自动添加一层备用渲染层,而恰好在这个边界条件下触发了两次绘制。

四、多维度解决方案汇总

针对上述问题根源,我们提供了以下几种行之有效的解决方法,你可以根据实际需求选择合适的方案:

✅ 方案一:巧用边框覆盖法

此方法的核心思想是通过给标签添加一个与背景色相同的宽边框,从而巧妙地遮盖住下方重复渲染的文字,达到视觉上的修正效果。

xAxis: {
    type: 'category',
    axisLabel: {
        borderWidth: 10,      // 设置较大的边框宽度以完全覆盖下层文字
        borderColor: '#fff', // 边框颜色需与背景色一致
        interval: 0,         // 强制显示所有标签
        rotate: -30          // 可选:适当旋转文字防止重叠
    },
    data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
};

优势:无需改动现有数据结构和核心逻辑,仅需简单调整样式即可快速见效;兼容性良好,适用于大多数场景。 注意:边框宽度应根据实际字体大小进行调整,确保能完整覆盖底层文字;若背景非纯白色,则需相应修改 borderColor 的值。

🔄 方案二:调整 interval 属性类型

如果你的业务场景允许并非所有标签都强制显示,可以将 interval 改为 'auto',让 ECharts 根据空间大小自动计算合适的显示间隔。这样可以有效避免末尾标签的重复渲染问题。

xAxis: {
    type: 'category',
    axisLabel: {
        interval: 'auto'    // 自动计算显示间隔
    },
    data: [...]
};

优势:实现简单,一行代码即可解决问题;由框架自动控制显示密度,适应不同屏幕尺寸。 局限:可能会导致部分中间标签被省略,不适合必须完整显示所有分类的场景。

🛠️ 方案三:自定义函数精确控制

通过将 interval 设置为自定义函数,你可以获得对每个标签显示与否的完全控制权。以下是强制显示所有标签但不触发重复渲染的写法:

xAxis: {
    type: 'category',
    axisLabel: {
        interval: function(index, value) {
            return true; // 对所有标签返回 true,确保全部显示
        }
    },
    data: [...]
};

优势:灵活性最高,既能保证所有标签可见,又能规避重复渲染导致的样式问题;可用于实现更复杂的条件判断逻辑。 提示:该方法适合对性能要求不高但需要精细控制的场景,因为每次渲染都需要执行回调函数。

💻 方案四:直接操作 DOM(进阶)

对于极端情况或高级定制需求,可以在图表渲染完成后,通过 JavaScript 直接修改最后一个标签的 CSS 样式。

const chart = echarts.init(document.getElementById('main'));
chart.setOption({ /* 你的配置项 */ });

// 监听渲染完成事件
chart.on('finished', () => {
    const labels = document.querySelectorAll('.echarts-label');
    if (labels.length > 0) {
        const lastLabel = labels[labels.length - 1];
        lastLabel.style.fontWeight = 'normal'; // 取消加粗
    }
});

优势:最直接的修复方式,不受框架内部逻辑限制;可结合其他 DOM 操作实现更多特效。 警告:属于 hack 性质的方法,未来框架更新可能导致失效;慎用于生产环境,建议充分测试。

五、避坑指南与最佳实践

  1. 版本敏感性:不同版本的 ECharts 可能存在行为差异,建议查阅官方文档并在项目中固定使用的 ECharts 版本,出现这种情况的好像是v4,据说下一版本已修复。
  2. 响应式考量:如果图表需要在多种设备上展示,建议优先考虑方案二或方案三,它们能更好地适应不同屏幕尺寸下的标签排列。
  3. 性能权衡:频繁调用 finished 事件的方案四可能影响性能,尤其在大数据量或高频更新的场景下应谨慎使用。

六、结语

X 轴末项标签字体变粗虽是一个小概率事件,但却考验着我们对 ECharts 渲染机制的理解深度。通过本文的介绍,相信你已掌握了多种应对之策。在实际项目中,建议优先尝试方案一或方案三,它们能在保持代码简洁的同时提供可靠的解决方案。记住,优秀的可视化作品不仅在于数据的准确传达,更在于每一个细节的精心雕琢。愿你在未来的数据可视化之旅中,能够更加游刃有余地驾驭 ECharts 这个强大的工具!

移动端浏览器中设置 100vh 却出现滚动条?

作者 小小愿望
2025年8月16日 17:58

🎉 写在前面 你是否遇到过这样的诡异场景:明明设置了 height: 100vh,却在移动端意外触发了滚动条?本文将从底层原理到实战方案为你彻底剖析这一经典陷阱,并提供多种可靠解决方案。


以下是对问题的详细解答:

一、现象原因分析

  1. 浏览器UI元素的动态特性:移动浏览器(如Chrome、Safari)的地址栏、工具栏等界面组件会根据用户操作(如滚动页面)自动显示或隐藏。这种动态行为会导致视口(viewport)的可用高度发生变化,但 100vh 的值始终基于初始隐藏状态下的视口高度计算,而非实时变化的可见区域高度。

  2. 视口高度计算偏差:当地址栏从隐藏变为可见时,实际可用视口高度会减小,但 100vh 仍保持原值,导致内容超出可视区域,触发滚动条。

  3. 浏览器厂商差异:不同浏览器对视口高度的计算逻辑存在差异,例如 iOS Safari 更倾向于将 100vh 视为未包含地址栏的高度。


二、解决方案

✅方案1:动态计算视口高度 + CSS 变量(推荐)

  1. 核心思路:通过 JavaScript 实时获取 window.innerHeight(当前可视区域高度),将其转换为 CSS 变量,并在样式中使用该变量替代 100vh

  2. 实现步骤

    • JavaScript 部分:监听窗口大小变化事件,动态更新 CSS 变量。
      function setViewportHeight() {
        const innerHeight = window.innerHeight ; 
        document.documentElement.style.setProperty('--innerHeight', `${innerHeight}px`);
      }
      window.addEventListener('resize', setViewportHeight);
      setViewportHeight(); // 初始化
      
    • CSS 部分:使用自定义变量控制元素高度。
      .fullscreen {
        height: var(--innerHeight);
        background: pink;
        overflow: hidden; /* 避免子内容溢出 */
      }
      

    优势:
    ✔️ 完美适配各种设备状态变化
    ✔️ 兼容所有支持 CSS 变量的现代浏览器
    ✔️ 无需修改现有布局结构


✅方案2:绝对定位 + 全屏覆盖

  1. 适用场景:简单布局且需完全覆盖屏幕的元素。

  2. 实现代码

    .fullscreen {
      position: absolute;
      width: 100%;
      height: 100%;
      top: 0;
      left: 0;
      background: lightblue;
    }
    

    注意:
    ⚠️ 如果父元素不是 body,需确保其父级链上的所有元素都有 height: 100%
    ⚠️ 此方法会脱离文档流,可能影响其他元素布局

    适用场景

    • 模态对话框/加载动画等临时全屏组件
    • 视频播放器等需要强制全屏的场景

✅方案3:使用动态视口单位(dvh)

  1. 实验性方案:部分现代浏览器支持 dvh(Dynamic Viewport Units),可直接响应视口变化。
    .fullscreen {
      height: 100dvh; /* 根据最新标准动态计算 */
    }
    
    现状: 📱 仅部分现代浏览器支持(Chrome 88+、Edge 88+) 🚫 iOS Safari 暂未支持 👉 适合作为渐进增强方案,需配合 fallback 使用 在这里插入图片描述

💡 经验之谈:无论采用哪种方案都能解决大多数问题,如果不行可以叠加其他方案试试,只用不断地尝试,不断优化适配策略。

taro3.x-4.x路由拦截如何破?

作者 前端嘿起
2025年8月16日 17:51

✨点击上方关注☝️,追踪不迷路!

前言

“大家要是使用过京东的taro框架,都知道taro1.x是支持生命周期BeforeRouteLeave的,这个生命周期主要是用来监听用户返回页面或前进操作,用于弹出弹窗挽留用户等,那么假如你升级到了taro3或taro4,官方是不支持这个生命周期的,需要自己实现,本文主要就是介绍如何添加实现这个功能”

lj1.jpg

hook接口设计

接口名称

useBeforeRouteLeave(fn)

自定义 React 钩子,用于在 Taro 应用中拦截路由跳转,并在跳转前执行自定义逻辑(例如提示用户挽留)

使用场景

  • 在用户尝试离开当前页面时,提示挽留等。
  • 在特定条件下阻止路由跳转。

入参说明

参数 类型 是否必须 描述
fn Fuction 拦截逻辑回调函数,接收一个对象参数{ from, to, next }。

fn回调参数说明

参数 类型 是否必须 描述
from String 当前路由路径
to String 目标路由路径
next String 控制是否允许跳转的函数。flag=true允许跳转,flag=false阻止跳转。

返回值

无返回值

示例代码

import useBeforeRouteLeave from './hooks/useBeforeRouteLeave';

function MyComponent() {
  useBeforeRouteLeave(({ from, to, next }) => {
    Taro.showModal({
      title: '是否离开?',
      confirmText: "确定"
    }).then(res=>{
      if (res.confirm) {
        next(true)
      } else {
        next(false)
      }
    })
  });

  return <div>My Component</div>;
}

代码逻辑设计

代码实现设计

import { useEffect } from 'react'
import { useDidShow } from '@tarojs/taro'
import { history } from '@tarojs/router'
export default function useBeforeRouteLeave(fn) {
  let isunBlocked = false; // 标记拦截状态
  const from = history.location.pathname;
  const unblockWrap = () => {
    let unblock = history.block((tx) => {
      let to = tx.location.pathname
      const next = (flag) => {
        if (flag) {
          setTimeout(() => {
            unblock() // 解除拦截
            tx.retry() // 重试跳转
            isunBlocked = true; // 更新拦截状态
          })
        }
      }
      fn({ from, to, next })
    })
    return () => unblock(); // 返回清理函数
  }
  useEffect(() => { // 注册拦截
    return unblockWrap()
  })

  useDidShow(() => {
    if(isunBlocked) {
      isunBlocked = false;
      unblockWrap() // 重新启用拦截
    }
  })
}
  • 初始化拦截状态:isunBlocked 标记拦截状态,默认为false。
  • 注册拦截逻辑:通过history.block拦截路由跳转。
  • 执行回调:当拦截触发时,调用用户传入的fn函数。
  • 用户决策:用户通过next(flag)决定是否允许跳转。
  • 如果flag=true,解除拦截并重试跳转。
  • 如果flag=false,保持拦截状态。
  • 重新启用拦截:当页面重新显示时(useDidShow),重置拦截状态并重新注册拦截逻辑。

装饰器接口设计

接口名称

useBeforeRouteLeaveHoc()

自定义 React 高阶组件,用于在 Taro 应用中拦截路由跳转,并在跳转前执行自定义逻辑(例如提示挽留)

使用场景

  • 在用户尝试离开当前页面时,提示挽留等。
  • 在特定条件下阻止路由跳转。

装饰说明

给 class 类组件注入了生命周期beforeRouteLeave({from, to, next})

反参说明

参数 类型 是否必须 描述
from String 当前路由路径
to String 目标路由路径
next String 控制是否允许跳转的函数。flag=true允许跳转,flag=false阻止跳转。

示例代码

import { Component } from 'react';
import Taro from "@tarojs/taro";
import { Button, View } from "@tarojs/components";
import { beforeRouteLeaveHoc } from '../../hoc/index';

@beforeRouteLeaveHoc()
class MyComponent extends Component {
  beforeRouteLeave({from, to, next}) {
    console.log('wen', 'beforeRouteLeave');
    Taro.showModal({
      title: 'beforeRouteLeave确定离开吗'
    }).then((res) => {
      if (res.confirm) {
        next(true);
      }

      if (res.cancel) {
        next(false);
      }
    });
  }
  handleRoute = () => {
    Taro.navigateTo({
      url: '/pages/index/index'
    })
  }
  render() {
    return (<View>
      <Button onClick={this.handleRoute.bind(this)}>跳转首页</Button>
    </View>)
  }
}

export default MyComponent;

代码逻辑设计

和hook方式代码逻辑设计一样

代码实现设计

import { history } from '@tarojs/router';
/**
 * 路由离开拦截装饰器
 */
export function beforeRouteLeaveHoc() {
  return function (constructor) {
    return class extends constructor {
      constructor(...args) {
        super(...args);
        this.isunBlocked = false; // 拦截状态标记
        this.from = history.location.pathname; // 当前路径
        this.unblock = null; // 拦截器清理函数
      }

      componentDidMount() {
        if (super.componentDidMount) super.componentDidMount();
        this.setupRouteInterceptor();
      }

      componentDidShow() {
        if (super.componentDidShow) super.componentDidShow();
        if (this.isunBlocked) {
          this.isunBlocked = false;
          this.setupRouteInterceptor();
        }
      }

      componentWillUnmount() {
        if (super.componentWillUnmount) super.componentWillUnmount();
        if (this.unblock) this.unblock();
      }

      setupRouteInterceptor() {
        this.unblock = history.block((tx) => {
          const to = tx.location.pathname;
          const next = (flag) => {
            if (flag) {
              setTimeout(() => {
                if (this.unblock) this.unblock(); // 解除拦截
                tx.retry(); // 重试跳转
                this.isunBlocked = true; // 更新拦截状态
              });
            }
          };
          super.beforeRouteLeave && super.beforeRouteLeave({ from: this.from, to, next });
        });
      }
    };
  };
}

最后,创作不易请允许我插播一则自己开发的小程序广告,感兴趣可以访问体验:

【「合图图」产品介绍】

  • 主要功能为:本地添加相册图片进行无限长图高清拼接,各种布局拼接等

  • 安全:无后台服务无需登录,全程设备本地运行,隐私100%安全;

  • 高效:自由布局+实时预览,效果所见即所得;

  • 高清:秒生高清拼图,一键保存相册。

  • 立即体验 →合图图 或微信小程序搜索「合图图」

如果觉得本文有用 ,欢迎点个赞👍和收藏⭐支持我吧!

请不要再只会回答宏任务和微任务了

作者 fail_to_code
2025年8月16日 17:41

关于js的事件循环,我相信凡是从事前端工作的开发者,都有一定程度的了解,但大多都是“背书”,从“js是个单线程语言”开始,到“宏任务和微任务队列,微任务优先级更高”结束。 概念其实没什么大问题,但是随着浏览器逐渐演变成仅次于操作系统的复杂应用,我们的传统观念也需要一定的更新,今天,带大家从浏览器的视角出发,看看当下的事件循环是什么样子。

浏览器的进程模型

何为进程

程序运行需要有它自己专属的内存空间,可以把这块内存空间简单的理解为进程。 image-20220809205743532 每个应用至少有一个进程,进程之间相互独立,即使要通信,也需要双方同意。

何为线程

有了进程后,就可以运行程序的代码了。 运行代码的「人」称之为「线程」。 一个进程至少有一个线程,所以在进程开启后会自动创建一个线程来运行代码,该线程称之为主线程。 如果程序需要同时执行多块代码,主线程就会启动更多的线程来执行代码,所以一个进程中可以包含多个线程。 image-20220809210859457

浏览器有哪些进程线程

首先要明确一点:浏览器是一个多进程多线程的应用程序。 浏览器内部工作极其复杂。为了避免相互影响,为了减少连环崩溃的几率,当启动浏览器后,它会自动启动多个进程。 image-20220809213152371

可以在浏览器的任务管理器中查看当前的所有进程 其中,最主要的进程有:

  1. 浏览器进程

    主要负责界面显示、用户交互、子进程管理等。浏览器进程内部会启动多个线程处理不同的任务。

  2. 网络进程

    负责加载网络资源。网络进程内部会启动多个线程来处理不同的网络任务。

  3. 渲染进程(本文重点讲解的进程)

    渲染进程启动后,会开启一个渲染主线程,主线程负责执行 HTML、CSS、JS 代码。

    默认情况下,浏览器会为每个标签页开启一个新的渲染进程,以保证不同的标签页之间不相互影响。

    将来该默认模式可能会有所改变,有兴趣的同学可参见chrome官方说明文档

渲染主线程是如何工作的

渲染主线程是浏览器中最繁忙的线程,需要它处理的任务包括但不限于:

  • 解析 HTML
  • 解析 CSS
  • 计算样式
  • 布局
  • 处理图层
  • 每秒把页面画 60 次
  • 执行全局 JS 代码
  • 执行事件处理函数
  • 执行计时器的回调函数
  • ......

关于渲染进程为什么不使用多线程来处理这么多任务,我其实推荐大家可以去看看《你不知道的javascript》中卷第二章,里面详细讲述了js为什么被设计为单线程

要处理这么多的任务,主线程遇到了一个前所未有的难题:如何调度任务? 比如:

  • 我正在执行一个 JS 函数,执行到一半的时候用户点击了按钮,我该立即去执行点击事件的处理函数吗?
  • 我正在执行一个 JS 函数,执行到一半的时候某个计时器到达了时间,我该立即去执行它的回调吗?
  • 浏览器进程通知我“用户点击了按钮”,与此同时,某个计时器也到达了时间,我应该处理哪一个呢?
  • ......

渲染主线程想出了一个绝妙的主意来处理这个问题:排队。 image-20220809223027806

  1. 在最开始的时候,渲染主线程会进入一个无限循环
  2. 每一次循环会检查消息队列中是否有任务存在。如果有,就取出第一个任务执行,执行完一个后进入下一次循环;如果没有,则进入休眠状态。
  3. 其他所有线程(包括其他进程的线程)可以随时向消息队列添加任务。新任务会加到消息队列的末尾。在添加新任务时,如果主线程是休眠状态,则会将其唤醒以继续循环拿取任务,这样一来,就可以让每个任务有条不紊的、持续的进行下去了。

整个过程,被称之为事件循环(消息循环)

何为异步

代码在执行过程中,会遇到一些无法立即处理的任务,比如:

  • 计时完成后需要执行的任务 —— setTimeoutsetInterval
  • 网络通信完成后需要执行的任务 -- XHRFetch
  • 用户操作后需要执行的任务 -- addEventListener

如果让渲染主线程等待这些任务的时机达到,就会导致主线程长期处于「阻塞」的状态,从而导致浏览器「卡死」

image-20220810104344296

渲染主线程承担着极其重要的工作,无论如何都不能阻塞! 因此,浏览器选择异步来解决这个问题

image-20220810104858857

使用异步的方式,渲染主线程永不阻塞

任务有优先级吗

我们都知道事件循环的过程中包含宏任务和微任务的说法,经常讲,事件循环往往从宏任务开始,但是在执行下一个宏任务前,我们需要先将本轮宏任务产生的微任务执行完毕。 那么任务有优先级吗?答案是没有但是任务队列有。 根据 W3C 的最新解释:

  • 每个任务都有一个任务类型,同一个类型的任务必须在一个队列,不同类型的任务可以分属于不同的队列。 在一次事件循环中,浏览器可以根据实际情况从不同的队列中取出任务执行。
  • 浏览器必须准备好一个微队列,微队列中的任务优先所有其他任务执行 html.spec.whatwg.org/multipage/w…

宏任务队列已经无法满足当前的浏览器要求了

在目前 chrome 的实现中,至少包含了下面的队列:

  • 延时队列:用于存放计时器到达后的回调任务,优先级「中」
  • 交互队列:用于存放用户操作后产生的事件处理任务,优先级「高」
  • 微队列:用户存放需要最快执行的任务,优先级「最高」
  • .....

以下是模拟浏览器任务调度的伪代码(由豆包生成),重点体现了三种队列的优先级关系和处理流程:

// 模拟浏览器的三种任务队列
const queues = {
  microtasks: [],         // 微任务队列(最高优先级)
  inputQueue: [],         // 交互队列(用户输入等)
  timerQueue: [],         // 延时队列(setTimeout/setInterval)
  renderingQueue: []      // 渲染队列(额外补充,用于完整模拟)
};

// 任务调度器状态
let isProcessing = false;

// 模拟浏览器的任务处理主循环
function browserMainLoop() {
  // 持续运行的事件循环
  while (true) {
    // 1. 先处理所有微任务(最高优先级)
    processAllMicrotasks();
    
    // 2. 检查是否需要渲染(通常在微任务后考虑)
    if (shouldRender()) {
      processRenderingTasks();
    }
    
    // 3. 处理高优先级任务:交互队列(用户输入优先于定时器)
    if (queues.inputQueue.length > 0) {
      processNextTask(queues.inputQueue);
      continue;
    }
    
    // 4. 处理延时队列任务(优先级低于交互)
    if (queues.timerQueue.length > 0) {
      // 只处理已到期的定时器任务
      const now = getCurrentTime();
      const readyTimers = queues.timerQueue.filter(task => task.expires <= now);
      if (readyTimers.length > 0) {
        // 按到期时间排序,先处理最早到期的
        readyTimers.sort((a, b) => a.expires - b.expires);
        processNextTask(readyTimers);
        continue;
      }
    }
    
    // 5. 若没有任务,进入休眠等待新任务
    waitForNewTasks();
  }
}

// 处理所有微任务(执行到队列为空)
function processAllMicrotasks() {
  while (queues.microtasks.length > 0) {
    const microtask = queues.microtasks.shift();
    executeTask(microtask);
  }
}

// 处理单个任务队列中的下一个任务
function processNextTask(queue) {
  if (queue.length === 0) return;
  
  isProcessing = true;
  const task = queue.shift();
  executeTask(task);
  isProcessing = false;
  
  // 执行完一个任务后,再次检查微任务(微任务会在当前任务后立即执行)
  processAllMicrotasks();
}

// 执行任务的具体逻辑
function executeTask(task) {
  try {
    task.callback();  // 执行任务的回调函数
  } catch (error) {
    reportError(error);  // 处理任务执行中的错误
  }
}

// 辅助函数:检查是否需要渲染
function shouldRender() {
  // 简化逻辑:根据浏览器刷新频率(如60Hz约16ms一次)判断是否需要渲染
  return getCurrentTime() - lastRenderTime > 16;
}

// 模拟添加任务的API(对应浏览器提供的API)
const browser = {
  // 添加微任务(如Promise.then)
  queueMicrotask(callback) {
    queues.microtasks.push({ callback });
  },
  
  // 添加延时任务(如setTimeout)
  setTimeout(callback, delay) {
    const expires = getCurrentTime() + delay;
    queues.timerQueue.push({ callback, expires });
  },
  
  // 添加交互任务(如click事件)
  addInputTask(callback) {
    queues.inputQueue.push({ callback });
  },
  
  // 添加渲染任务
  requestAnimationFrame(callback) {
    queues.renderingQueue.push({ callback });
  }
};

核心优先级规则说明:

  1. 微任务队列(microtasks)优先级最高 - 无论其他队列是否有任务,当前执行栈空闲时会先清空所有微任务 - 对应API:Promise.thenqueueMicrotaskasync/await
  2. 交互队列(inputQueue)次之 - 用户输入(点击、键盘等)任务优先级高于定时器,保证用户操作的响应速度 - 浏览器会优先处理用户交互,避免界面卡顿感
  3. 延时队列(timerQueue)优先级较低 - setTimeout/setInterval任务仅在没有交互任务时才会被处理 - 定时器的实际执行时间可能比设定时间晚(受队列阻塞影响)
  4. 渲染任务(renderingQueue)适时执行 - 通常在微任务处理后、其他任务执行前检查是否需要渲染 - 遵循显示器刷新率(如60fps),避免过度渲染消耗性能

这个伪代码简化了浏览器的实际实现,但核心逻辑符合现代浏览器(包括Chrome)的任务调度原则:优先保证用户交互响应速度,其次处理定时任务,而微任务则始终在当前任务周期内立即完成

实际浏览器中,任务调度会更复杂,还会涉及任务优先级动态调整、线程池管理、节能策略等,但上述伪代码已能体现三种队列的核心优先级关系。

总结

本文主要从浏览器的渲染进程的视角出发,为大家讲解当前浏览器环境下的事件循环是什么样的,如果正在阅读本文的你之前并不了解什么是事件循环,我这里推荐你阅读这篇文章,我相信读完以后,你一定能对事件循环有一定程度的了解。

Webpack 配置与优化全攻略:从基础到进阶实战

2025年8月16日 17:40

在前端工程化中,Webpack 作为模块打包工具的核心地位无可替代。无论是项目构建、代码优化还是开发体验提升,Webpack 的配置与优化能力直接影响开发效率和线上性能。本文将结合实际场景,系统梳理 Webpack 的基础配置与进阶优化策略,助你从入门到精通。


一、Webpack 基础配置:从零搭建项目

1. 核心概念速览

  • Entry:入口文件,打包的起点(如 src/index.js)。
  • Output:输出配置,指定打包后的文件路径和名称。
  • Loader:处理非 JS 文件(如 CSS、图片、TS),通过管道链式调用。
  • Plugin:扩展功能(如生成 HTML、压缩代码、优化依赖)。
  • Mode:环境模式(development/production),影响内置优化策略。

2. 最小化配置示例

// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js',
  },
  module: {
    rules: [
      { 
        test: /\.js$/, 
        exclude: /node_modules/, 
        use: 'babel-loader' 
      },
      { 
        test: /\.css$/, 
        use: ['style-loader', 'css-loader'] 
      },
    ],
  },
  plugins: [new HtmlWebpackPlugin({ template: './public/index.html' })],
  mode: 'production',
};

3. 关键配置解析

  • Loader 顺序:从右到左执行(如 ['style-loader', 'css-loader'] 先解析 CSS,再注入样式)。
  • Plugin 作用HtmlWebpackPlugin 自动生成 HTML 并注入打包后的 JS 文件。
  • 环境区分:通过 mode 自动启用对应环境的优化(如生产模式默认压缩代码)。

二、Webpack 优化策略:提升性能与体验

1. 代码分割(Code Splitting)

问题:单文件过大导致首屏加载慢。
解决方案

  • 路由级懒加载:结合 React/Vue 的动态导入(import())。
    // React 示例
    const Home = React.lazy(() => import('./Home'));
    
  • 公共依赖提取:使用 SplitChunksPlugin 拆分 node_modules
    optimization: {
      splitChunks: {
        chunks: 'all',
        cacheGroups: {
          vendor: {
            test: /[\\/]node_modules[\\/]/,
            name: 'vendors',
          },
        },
      },
    },
    

2. Tree Shaking:移除未使用代码

原理:基于 ES6 模块的静态分析,标记未导出代码。
配置

  • 生产模式自动启用(mode: 'production')。
  • 确保代码使用 ES6 模块语法(import/export)。
  • package.json 中添加 "sideEffects": false(或指定有副作用的文件)。

3. 缓存优化:加速二次构建

场景:开发时重复构建耗时。
方案

  • 文件内容哈希output.filename: '[name].[contenthash].js',文件内容变化时哈希更新。
  • Loader 缓存:配置 babel-loader 缓存目录。
    {
      test: /\.js$/,
      use: {
        loader: 'babel-loader',
        options: { cacheDirectory: true },
      },
    }
    
  • Webpack 5 持久化缓存
    cache: {
      type: 'filesystem', // 使用文件系统缓存
      buildDependencies: {
        config: [__filename], // 当配置文件变更时缓存失效
      },
    },
    

4. 缩小打包体积

方法

  • 压缩代码
    • JS:TerserPlugin(Webpack 5 内置)。
    • CSS:CssMinimizerPlugin
  • 图片压缩:使用 image-webpack-loader
  • CDN 引入:通过 externals 排除大型库(如 React、Lodash)。
    externals: {
      react: 'React',
      lodash: '_',
    },
    

5. 构建速度优化

痛点:项目规模扩大后构建变慢。
策略

  • 缩小文件搜索范围
    resolve: {
      extensions: ['.js', '.jsx'], // 减少扩展名猜测
      alias: { '@': path.resolve(__dirname, 'src') }, // 路径别名
    },
    
  • 多进程构建:使用 thread-loader 并行处理耗时任务(如 Babel 转译)。
    {
      test: /\.js$/,
      use: ['thread-loader', 'babel-loader'],
    }
    
  • 忽略大型依赖:通过 noParse 跳过已压缩的文件(如 jQuery)。
    module: {
      noParse: /jquery|lodash/,
    }
    

三、开发体验优化:提升效率

1. 热更新(HMR)

作用:修改代码后局部更新,无需刷新页面。
配置

devServer: {
  hot: true, // 启用 HMR
  open: true, // 自动打开浏览器
},

2. Source Map 调试

场景:生产环境报错时定位源码。
方案

  • 开发环境:devtool: 'eval-cheap-module-source-map'(快速生成)。
  • 生产环境:devtool: 'source-map'(完整映射,但体积大)。

四、Webpack 5 新特性(2024 必知)

  1. 持久化缓存:默认启用文件系统缓存,显著提升二次构建速度。
  2. 模块联邦(Module Federation):实现微前端架构的跨应用代码共享。
  3. 更好的 Tree Shaking:支持嵌套 Tree Shaking 和 CommonJS 模块的静态分析。

五、总结与实战建议

  • 优化效果对比

    优化项 构建时间 打包体积 首屏加载时间
    基础配置 12s 1.2MB 3.5s
    代码分割+缓存 8s 800KB 1.8s
    Webpack 5 全优化 3s 600KB 1.2s
  • 推荐工具链

    • 脚手架:create-vite(基于 Rollup,但 Webpack 生态兼容)。
    • 监控:webpack-bundle-analyzer 分析打包依赖。

最后:Webpack 的优化是一个动态过程,需结合项目规模、团队习惯和业务场景灵活调整。建议从实际痛点出发,逐步引入优化策略,避免过度配置。


延伸阅读

【渲染流水线】[几何阶段]-[归一化NDC]以UnityURP为例

作者 SmalBox
2025年8月16日 17:34
  • NDC空间‌:透视除法的结果,顶点坐标已归一化,可直接用于视口映射和裁剪‌

【从UnityURP开始探索游戏渲染】专栏-直达

  • 在渲染管线中,‌归一化严格等同于透视除法‌,是齐次坐标到NDC空间转换的核心步骤‌。Unity中这步,自动执行。
  • 数据归一化主要通过‌NDC空间(归一化设备坐标)转换‌实现,其核心原理是将裁剪空间坐标统一映射到标准范围([-1,1]的立方体内(OpenGL标准)或[0,1](DirectX标准))
  • 可以看作是一个矩形内的坐标体系。经过转化后的坐标体系是 限制在一个立方体内的坐标体系。无论x y z轴在坐标体系内的范围都是(-1, 1)。归一化后,z轴向屏幕内。
  • 归一化范围在OpenGL中范围为[-1, 1],DirectX中为[0, 1]。映射到屏幕时(0, 0)点:GpenGL是左下角,DirectX是左上角。

归一化原理

透视除法(Perspective Division)

将齐次裁剪空间坐标的(x,y,z)分量除以w分量,得到NDC坐标

此操作将坐标归一化至[-1,1]范围(OpenGL/Unity)或[0,1]范围(Direct3D)‌。

NDCExample.shader

  • 1.URP标准坐标转换流程
  • 2.手动NDC坐标计算
  • 3.通过v2f结构传递NDC数据
// hlsl
Shader "Custom/NDCDemo"
{
    SubShader
    {
        Pass
        {
            HLSLPROGRAM
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

            struct Attributes { float4 vertex : POSITION; };
            struct Varyings { float4 pos : SV_POSITION; float3 ndc : TEXCOORD0; };

            Varyings vert(Attributes v)
            {
                Varyings o;
                o.pos = TransformObjectToHClip(v.vertex.xyz);
                // 手动计算NDC坐标
                o.ndc = o.pos.xyz / o.pos.w; 
                return o;
            }
            ENDHLSL
        }
    }
}

URP中的NDC

Unity URP(Universal Render Pipeline)中,归一化的设备坐标(NDC)映射范围取决于具体的API平台:

  1. Direct3D风格平台‌(如Windows、Xbox等):
    • NDC范围是 ‌**[-1, 1]³**‌(x,y,z三个维度)
    • 深度值(z)映射到[0,1(通过投影矩阵转换)
  2. OpenGL风格平台‌(如MacOS、Linux等):
    • NDC范围是 ‌**[-1, 1]³**‌
    • 深度值(z)保持[-1,1]

URP默认使用‌**[-1,1]³**‌的NDC范围(与Built-in管线一致),但最终会适配目标平台的约定。

坐标转换示例过程

假设有一个世界空间点(2, 1, 5):

  1. 通过视图矩阵转换到视图空间(相机空间)
  2. 通过URP投影矩阵转换到裁剪空间(clip space)
  3. 透视除法得到NDC坐标(w分量除法)

具体数值示例(假设使用D3D风格):

世界坐标 (2, 1, 5)
↓ 视图矩阵转换
视图坐标 (1.5, 0.8, 4.2)
↓ 投影矩阵转换
裁剪坐标 (3.2, 1.6, 8.4, 4.2)
↓ 透视除法 (x/w, y/w, z/w)
NDC坐标 (0.76, 0.38, 2.0) → 超出[-1,1]会被裁剪

深度值特殊处理

在URP中,深度缓冲区的值会被重新映射:

  • 原始NDC的z ∈ [-1,1](OpenGL)或 [0,1](D3D)
  • 最终存储到深度纹理时统一映射到[0,1]范围

可以通过Shader验证:

hlsl
// 在Fragment Shader中:
float ndcZ = clipPos.z / clipPos.w; // 透视除法后的z值
float depth = ndcZ * 0.5 + 0.5;    // D3D平台下实际存储值

URP通过_UNITY_UV_STARTS_AT_TOP等宏处理不同平台的坐标差异,保证跨平台一致性。

NDC转换在实际中的应用

虽然默认NDC计算是固定加速计算的过程,但是有时需要手动计算实现一些定制效果。

在Unity URP中,几何着色器(Geometry Shader)手动计算NDC并实现屏幕映射的典型应用场景包括:

1. 视锥裁剪

  • 将世界坐标转换为NDC后判断是否在[-1,1]范围内

2. 屏幕空间特效

  • ‌ 通过NDC坐标计算UV用于采样屏幕纹理

3. 几何体动态生成

  • ‌ 根据NDC坐标控制顶点生成范围

计算NDC并实现屏幕空间粒子生成示例ScreenSpaceParticle.shader

  • 在几何着色器中通过clipPos.xyz / clipPos.w完成透视除法得到NDC坐标
  • 使用NDC坐标时需注意:
    • D3D平台下y轴需要取反(screenUV.y = 1 - screenUV.y
    • 深度值在D3D平台需映射到[0,1]范围
  • 示例实现了屏幕空间粒子生成效果,可通过NDC坐标控制生成范围

实际应用时可结合_UNITY_MATRIX_VP矩阵进行完整坐标空间转换链验证。


Shader "Custom/NDCGeometryShader"
{
    Properties { _MainTex ("Texture", 2D) = "white" {} }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        Pass
        {
            HLSLPROGRAM
            #pragma vertex vert
            #pragma geometry geom
            #pragma fragment frag
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

            struct v2g {
                float4 pos : SV_POSITION;
                float2 uv : TEXCOORD0;
            };

            struct g2f {
                float4 pos : SV_POSITION;
                float2 uv : TEXCOORD0;
                float3 ndc : TEXCOORD1;
            };

            v2g vert(appdata_base v) {
                v2g o;
                o.pos = TransformObjectToHClip(v.vertex);
                o.uv = v.texcoord;
                return o;
            }

            [maxvertexcount(4)]
            void geom(point v2g input[1], inout TriangleStream<g2f> stream) {
                // 手动计算NDC坐标
                float4 clipPos = input[0].pos;
                float3 ndc = clipPos.xyz / clipPos.w;

                // 屏幕空间扩展(生成四边形粒子)
                float size = 0.1;
                g2f o;
                for(int i=0; i<4; i++) {
                    o.pos = clipPos;
                    o.pos.xy += float2((i%2)*2-1, (i/2)*2-1) * size * clipPos.w;
                    o.uv = input[0].uv;
                    o.ndc = ndc;
                    stream.Append(o);
                }
                stream.RestartStrip();
            }

            half4 frag(g2f i) : SV_Target {
                // 使用NDC坐标采样屏幕纹理
                float2 screenUV = i.ndc.xy * 0.5 + 0.5;
                return SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, screenUV);
            }
            ENDHLSL
        }
    }
}

【从UnityURP开始探索游戏渲染】专栏-直达

(欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

TypeScript 接口入门:定义代码的契约与形态

作者 烛阴
2025年8月16日 17:07

一、什么是接口?

用于描述一个对象的结构。

// 定义一个名为 User 的接口
interface User {
    id: number;
    name: string;
    email: string;
}

function printUserInfo(user: User) {
    console.log(`ID: ${user.id}, Name: ${user.name}, Email: ${user.email}`);
}

const myUser: User = {

    id: 1,
    name: 'Alice',
    email: 'alice@example.com',
};

printUserInfo(myUser); // OK

const invalidUser: User = {
    id: 2,
    username: 'Bob', // 属性名不匹配 编译时错误
    // 缺少 name,email 属性
};


二、接口的丰富特性

1. 可选属性(Optional Properties)

有时,对象的某些属性不是必需的。我们可以使用 ? 来标记它们。

interface UserProfile {
    id: number;
    username: string;
    bio?: string; // bio 是可选的
}

const user1: UserProfile = { id: 1, username: 'Alice' }; // OK
const user2: UserProfile = { id: 2, username: 'Bob', bio: 'Developer' }; // OK

2. 只读属性(Readonly Properties)

我们可以使用 readonly 关键字来防止对象属性在创建后被修改,这对于创建不可变数据非常有用。

interface Point {
    readonly x: number;
    readonly y: number;
}

const p1: Point = { x: 10, y: 20 };
p1.x = 5; // Error: 无法为“x”赋值,因为它是只读属性。

3. 函数类型

接口也能用来定义函数的签名(参数类型和返回值类型)。

interface SearchFunc {
    (source: string, subString: string): boolean;
}

let mySearch: SearchFunc = function (src: string, sub: string) {
    let result = src.search(sub);
    return result > -1;
};

console.log(mySearch('hello', 'll'));

4. 可索引类型(Indexable Types)

接口可以描述那些可以通过索引得到的类型,比如数组和对象。

interface StringArray {
    [index: number]: string; // 索引是数字,值是字符串
}

let myArray: StringArray;
myArray = ['Bob', 'Fred'];
let myStr: string = myArray[0]; // OK
console.log(myStr);


interface Dictionary {
    [key: string]: any; // 索引是字符串,值是任意类型
}

let user: Dictionary = {
    name: '张三',
    age: 18,
    sex: '男',
}

console.log(user.name);

5. 类实现(Class Implementations)

接口可以被类(Class)implements(实现),强制一个类必须遵循接口定义的契约。

interface ClockInterface {
    currentTime: Date;
    setTime(d: Date): void;
}

class Clock implements ClockInterface {
    currentTime: Date = new Date();
    setTime(d: Date) {
        this.currentTime = d;
    }
    constructor(h: number, m: number) {
        this.currentTime.setHours(h);
        this.currentTime.setMinutes(m);
    }

    printTime() {
        console.log(this.currentTime.toLocaleTimeString());
    }
}


let clock = new Clock(12, 30);
clock.printTime(); //12:30:43
clock.setTime(new Date('2024-5-6 09:30:43'));
clock.printTime(); //09:30:43

三、接口的扩展与合并

1. 继承(Extends)

一个接口可以像类一样继承另一个接口,从而复用和扩展类型定义。

interface Shape {
    color: string;
}

interface PenStroke {
    penWidth: number;
}

// Square 继承了 Shape 和 PenStroke
interface Square extends Shape, PenStroke {
    sideLength: number;
}

let square: Square = {
    color: 'blue',
    penWidth: 5.0,
    sideLength: 10,
};

2. 声明合并(Declaration Merging)

这是一个接口独有的、非常强大的特性。如果你在同一个作用域内定义了两个同名的接口,它们会自动合并成一个单一的接口。

interface Box {
    height: number;
    width: number;
}

interface Box {
    scale: number;
}

// 合并后,Box 接口同时拥有 height, width, 和 scale 属性
const box: Box = { height: 5, width: 6, scale: 10 };

常用的用法 扩展第三方库的类型定义。例如,如果你想为 window 对象添加一个自定义属性,你可以这样做,而不会覆盖原有的定义:

// 在你的 .d.ts 文件中
declare global {
    interface Window {
        myAppConfig: object;
    }
}

// 现在你可以在代码中安全地访问它
window.myAppConfig = { version: '1.0' };

总结

如果你喜欢本教程,记得点赞+收藏!关注我获取更多TypeScript开发干货

使用自定义高亮API增强用户‘/’体验

2025年8月16日 16:48

本篇依然来自于我们的 《前端周刊》 项目!

由团队成员 0bipinnata0 翻译,这位佬有技术追求、翻译风格精准细腻,还擅长挖掘原文背后的技术细节~

欢迎大家 进群 同该佬深度交流😁 以及持续追踪全球最新前端资讯!!

原文地址:Using the Custom Highlight API

生成前端周刊图.png

最近 CSS Custom Highlight API 引起了我的注意,因为 Firefox 最近开始支持它(Firefox 140,2025年6月),这使得所有主流浏览器都支持了这个 API。通过它,你可以对通过 JavaScript 中的 Range() 类获取的文本应用(某些)样式。我本来想说是你选择的文本,但这里实际上并没有涉及真正的普通选择器,这对于像我这样的 CSS 开发者来说是相当不寻常的。

我认为这里需要一个基本的文字说明,因为当我第一次开始研究它时,这样的说明肯定会对我有帮助:

  1. 你需要一个 textNode(例如 document.querySelector("p").firstChild

  2. 然后你需要一个 Range(),在其上执行 setStartsetEnd,这意味着范围现在在这两个整数之间。

  3. 然后你在该 Range 上调用 CSS.highlights.set(),给它一个名称。

  4. 然后你在 CSS 中使用 ::highlight(),传入你刚才使用的名称。

如果我们在页面上有一个 <p> 文本,整个过程看起来是这样的:

const WORD_TO_HIGHLIGHT = "wisdom";
const NAME_OF_HIGHLIGHT = "our-highlight";

const textNode = document.querySelector("p").firstChild;
const textContent = textNode.textContent;

const startIndex = textContent.indexOf(WORD_TO_HIGHLIGHT);
const endIndex = startIndex + WORD_TO_HIGHLIGHT.length;

const range = new Range();
range.setStart(textNode, startIndex);
range.setEnd(textNode, endIndex);

const highlight = new Highlight(range);
CSS.highlights.set(NAME_OF_HIGHLIGHT, highlight); 

在开发者工具中看到这个效果很有趣,单词 "wisdom" 明显应用了自定义 CSS 样式,但在该单词周围没有你通常认为应用这些样式所必需的元素。

image.png

这很可能就是浏览器本身在需要仅对文本的某些部分应用样式时所做的事情,比如当你使用浏览器内置的查找功能时。

image.png

这是演示:

codepen.io/editor/anon…

为什么这很有用?

  • 能够在完全不需要操作 DOM 的情况下定位和样式化文本是很有趣的。有时,DOM API 被批评为缓慢,所以能够避免这种情况可能是有利的,特别是如果你需要大量这样做的话。

  • 添加和删除 <span> 元素,除了可能"缓慢"之外,还会影响 DOM 结构,从而可能影响其他处理 DOM 的 CSS 和 JavaScript。

  • DOM 复杂度可能是网页性能的一个问题。过多的 DOM 节点,重新计算可能非常"昂贵",页面上的用户体验可能会受到影响,比如动画和滚动变慢。

这是一个只有 17 个更改文件的 GitHub PR 页面。该页面已经有超过 4,500 个 span 元素,用于诸如代码差异着色和语法高亮等功能。这已经相当重了,而且肯定会变得更糟。

![image.png](使用自定义高亮API增强用户‘+’体验+54149756-b7ed-41c7-9c27-b0ec61235095/image 2.png)

我确信这个 API 存在的原因还有很多,但这些只是我立即想到的几个原因。

做更多事情(搜索示例)

创建一个 new Highlight() 可以接受多个 Range。这意味着 CSS 中的单个 ::highlight() 可以应用于许多文本范围。如果我们在页面上构建自己的搜索功能,这将很有用。如果搜索是你正在构建的 Web 应用程序的关键功能,我可以很容易地想象为它构建自己的 UI,而不是依赖内置的浏览器功能。

这次,让我们让要在文本中查找的单词来自用户:

<label>
  Search the text below
  <input type="search" value="oven" id="searchTerm">
</label>  

然后我们监听变化:

window.searchTerm.addEventListener("input", (e) => {
  doSearch(e.target.value.toLowerCase());
}); 

注意我们将输入的值传递给一个函数,并在传递时将其转换为小写,因为搜索在不区分大小写时通常最有用。

我们的 doSearch 函数然后将接受该搜索词并在所有文本上运行正则表达式:

const regex = new RegExp(searchTerm, "gi"); 

我们需要的是一个包含所有找到的文本实例索引的数组。这是一段有点冗长的代码,但就是这样:

const indexes = [...theTextContent.matchAll(new RegExp(searchTerm, 'gi'))].map(a => a.index); 

有了这个索引数组,我们可以循环遍历它们创建 Range,然后将所有 Range 发送到新的 Highlight。

const arrayOfRanges = [];

indexes.forEach(matchIndex => {
  // 从索引值创建一个 "Range"。
  const searchRange = new Range();
  searchRange.setStart(par, matchIndex);
  searchRange.setEnd(par, matchIndex + searchTerm.length);

  arrayOfRanges.push(searchRange);
})

const ourHighlight = new Highlight(...arrayOfRanges);
CSS.highlights.set("search-results", ourHighlight); 

总的来说,它创建了一个功能完整的搜索体验:

codepen.io/editor/anon…

用于语法高亮

感觉语法高亮代码是这个 API 的一个很好的用例。André Ruffert 已经采用了这个想法并付诸实践,制作了一个 [<syntax-highlight> Web Component](https://andreruffert.github.io/syntax-highlight-element/),它使用 Lea Verou 的 Prism.js 来解析代码,但然后不像开箱即用的 Prism 那样应用 <span>,而是使用这个自定义高亮 API。

示例:

codepen.io/editor/anon…

我认为这很棒,但值得注意的是,这个 API 只能在客户端使用。对于语法高亮这样的功能,这可能意味着在看到代码和语法高亮"生效"之间会有延迟。我承认在可能的情况下,我更喜欢服务器端渲染的语法高亮。这意味着如果你可以从服务器提供一堆像这样的 <span>(并且不会严重影响性能或可访问性),那可能会更好。

我也承认我仍然对内置语法高亮的字体有些着迷,这感觉像是字体厂商可以进入的未开发领域。

焕新扫雷体验,Trae如何让童年游戏更现代?

2025年8月16日 16:43

前言

今天来还原童年记忆中的扫雷游戏,主要是让Trae用代码实现这个游戏的核心功能,你是否还记得初中上计算机课,偷偷背着老师玩扫雷游戏,今天就看看Trae怎么实现。

这个游戏的核心功能

先把这个核心逻辑发给Trae,看看他完成的是不是你想要的童年记忆

  1. 游戏开始时,玩家会看到一个由方格组成的网格,其中一些方格内藏有地雷。
  2. 玩家通过点击方格来揭开其内容,若点击到地雷,则游戏结束。
  3. 若点击的方格没有地雷,则会显示该方格周围相邻方格中地雷的数量。
  4. 玩家可以右键标记疑似藏有地雷的方格,防止误触。
  5. 玩家需要在不触碰地雷的前提下,揭开所有没有地雷的方格,才算通关。

image.png

这效果、这完成度已经不错了,看起来有些微信小游戏的味道了,右上角还很贴心的安排上音效按钮,如果不喜欢我们可以关闭音效,沉浸式的扫雷。 image.png

Trae代码解读

通过设定网格的行列数和地雷的总数,来初始化游戏的布局,生成一个二维数组来表示游戏面板,通过for循环来填充网格,随机放置地雷的位置。

for (let i = 0; i < rows; i++) {
    const row = [];
    for (let j = 0; j < cols; j++) {
        row.push({
            isMine: false,
            isRevealed: false,
            isMarked: false,
            neighborMines: 0
        });
    }
    grid.push(row);
}

const minesToPlace = totalMines;
let minesPlaced = 0;

while (minesPlaced < minesToPlace) {
    const row = Math.floor(Math.random() * rows);
    const col = Math.floor(Math.random() * cols);
    if (!grid[row][col].isMine) {
        grid[row][col].isMine = true;
        minesPlaced++;
    }
}

通过遍历网格,计算每个非地雷方格周围相邻地雷的数量

for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
        if (!grid[i][j].isMine) {
            grid[i][j].neighborMines = countNeighborMines(i, j);
        }
    }
}

通过事件监听实现玩家点击揭开或标记方格的动作,并判断游戏是否胜利或失败

board.addEventListener('click', (e) => {
    const col = Math.floor((e.clientX - boardRect.left) / cellSize);
    const row = Math.floor((e.clientY - boardRect.top) / cellSize);
    revealCell(row, col);
    checkGameStatus();
});

board.addEventListener('contextmenu', (e) => {
    e.preventDefault();
    const col = Math.floor((e.clientX - boardRect.left) / cellSize);
    const row = Math.floor((e.clientY - boardRect.top) / cellSize);
    toggleMarkCell(row, col);
});

最后是来自Trae自己对这款扫雷的总结,主要是游戏功能和设计,还有考虑到游戏体验,非常的人性化

image.png Trae在生成时,考虑的情况,主要是地雷、数字提示、计时器、难度选择等因素 image.png

总结

1、这个游戏的核心功能,主要是靠玩家来标记,以及揭开,Trae非常人性化的支持双击快速揭开,来让高级玩家有好的游戏体验。

2、考虑到游戏玩家可能没玩过,Trae也是帮我们设计了三个游戏难度,不会让新手玩家没有体验感直接进入到地狱难度,可以一步步的体验到游戏的难度。

image.png

你是否也玩过在课堂上偷偷的这个游戏呢?啊哈哈哈,你有没有被老师抓到过?

🚀 Vue3 源码深度解析:Diff算法的五步优化策略与最长递增子序列的巧妙应用

2025年8月16日 16:36

🚀 Vue3 源码深度解析:Diff算法的五步优化策略与最长递增子序列的巧妙应用

📚 学习目标

通过本文,你将深入理解:

  • 🎯 Vue3 Diff算法的完整五步策略,而非仅仅是最长递增子序列
  • 🔧 双端比较算法如何最大化节点复用,减少DOM操作
  • ⚡ 最长递增子序列在乱序场景下的核心作用与实现原理
  • 🎨 Key值在Diff算法中的关键作用与性能影响
  • 💡 从算法设计角度理解Vue3相比Vue2的性能提升

🌟 引言

在前端面试中,"Vue3的Diff算法"是一个高频考点。许多候选人的第一反应是"最长递增子序列",但这个回答并不完整。

真相是:Vue3的Diff算法是一个精心设计的五步优化策略,最长递增子序列只是其中一个环节。它通过双端比较、增删处理、乱序优化等多个步骤,实现了对DOM操作的最大化优化。

让我们深入源码,揭开Vue3 Diff算法的神秘面纱。

🔬 核心函数:patchKeyedChildren

patchKeyedChildren 是Vue3 Diff算法的核心实现,负责处理带有key的子节点列表的更新。这个函数体现了Vue3团队在性能优化方面的深度思考。

🎯 算法概览

Vue3的Diff算法采用分治策略,将复杂的列表比较问题分解为五个相对简单的子问题:

  1. 前序比较:处理列表开头的相同节点
  2. 后序比较:处理列表结尾的相同节点
  3. 新增处理:挂载新出现的节点
  4. 删除处理:卸载不再需要的节点
  5. 乱序处理:使用最长递增子序列优化节点移动

这种设计的巧妙之处在于:大多数实际场景下,列表的变化都集中在前四步,只有少数复杂场景才需要进入第五步

  const patchKeyedChildren = (
    c1: VNode[],
    c2: VNode[],
    container: Element,
    parentAnchor: any
  ) => {
    // 📏 初始化指针和长度变量
    let newLen = c2.length // 新子节点数组的长度
    let oldLen = c1.length - 1 // 旧子节点数组的最后一个索引
    let e1 = oldLen // 旧数组的结束指针(从后往前移动)
    let e2 = newLen - 1 // 新数组的结束指针(从后往前移动)
    let i = 0 // 开始指针(从前往后移动)
    
    // 🔍 第一步:从前往后比较,找出开头相同的节点
    // 目的:跳过开头相同的节点,减少后续比较的工作量
    // 例如:[A,B,C,D] vs [A,B,X,Y] → 跳过A,B,从C,D vs X,Y开始处理
    while (i <= e1 && i <= e2) {
      const n1 = c1[i] // 当前旧节点
      const n2 = c2[i] // 当前新节点
      
      // 如果节点类型和key都相同,说明可以复用
      if (isSameVNodeType(n1, n2)) {
        // 递归更新这个节点(可能属性或子节点有变化)
        patch(n1, n2, container, parentAnchor)
      } else {
        // 遇到不同的节点,停止前向比较
        break
      }
      i++ // 指针前移
    }
    
    // 🔍 第二步:从后往前比较,找出结尾相同的节点
    // 目的:跳过结尾相同的节点,进一步缩小需要处理的范围
    // 例如:[A,B,C,D] vs [X,Y,C,D] → 跳过C,D,只需处理A,B vs X,Y
    while (i <= e1 && i <= e2) {
      const n1 = c1[e1] // 当前旧节点(从后往前)
      const n2 = c2[e2] // 当前新节点(从后往前)
      
      // 如果节点类型和key都相同,说明可以复用
      if (isSameVNodeType(n1, n2)) {
        // 递归更新这个节点
        patch(n1, n2, container, parentAnchor)
      } else {
        // 遇到不同的节点,停止后向比较
        break
      }
      e1-- // 旧数组指针前移
      e2-- // 新数组指针前移
    }
    
    // 📊 经过前后两轮比较后的状态分析:
    // - i: 第一个不同节点的位置
    // - e1: 旧数组中最后一个需要处理的节点位置
    // - e2: 新数组中最后一个需要处理的节点位置
    
    // ✅ 第三步:处理新增节点的情况
    // 条件:i > e1 说明旧节点已经处理完,但新节点还有剩余
    // 例如:旧[A,B] 新[A,B,C,D] → 需要新增C,D
    if (i > e1) {
      if (i <= e2) {
        // 确定插入位置的锚点
        const nextPos = e2 + 1
        // 如果下一个位置存在节点,就插入到它前面;否则插入到容器末尾
        const anchor = nextPos < newLen ? c2[nextPos].el : parentAnchor
        
        // 挂载所有新增的节点
        while (i <= e2) {
          // patch(null, newNode) 表示挂载新节点
          patch(null, c2[i], container, anchor)
          i++
        }

### 🎯 第三部分:最优移动策略与最长递增子序列

这是Vue3 Diff算法最精彩的部分,也是**最长递增子序列**真正发挥作用的地方。当前四步都无法处理时,说明遇到了复杂的乱序场景。

#### 🎯 核心挑战

乱序场景的核心挑战是:**如何用最少的DOM移动操作,将旧列表转换为新列表?**

```typescript
// 典型乱序场景
// 旧列表:[A, B, C, D, E]
// 新列表:[A, C, E, B, D, F]
// 挑战:B和D需要移动,F需要新增,同时要保持C和E的相对位置不变
🧩 三步解决策略

Vue3将这个复杂问题分解为三个子问题:

  1. 🗺️ 构建映射表:建立新节点key到索引的快速查找表
  2. 🔍 标记可复用节点:找出哪些旧节点可以复用,哪些需要删除
  3. 🎯 最优移动策略:使用最长递增子序列计算最少移动方案
🔧 关键数据结构
// newIndexToOldIndexMap: 核心数据结构
// 索引:新列表中的位置
// 值:对应旧列表中的位置 + 1(+1是为了区分0和未找到)
// 例:[3, 1, 4, 0] 表示:
// - 新列表[0] 对应 旧列表[2]
// - 新列表[1] 对应 旧列表[0] 
// - 新列表[2] 对应 旧列表[3]
// - 新列表[3] 是新增节点
⚡ 移动检测算法
// 移动检测的巧妙之处
let maxNewIndexSoFar = 0
for (let i = s1; i <= e1; i++) {
  const newIndex = keyToNewIndexMap.get(prevChild.key)
  if (newIndex >= maxNewIndexSoFar) {
    maxNewIndexSoFar = newIndex  // 节点位置递增,无需移动
  } else {
    moved = true  // 发现逆序,需要移动
  }
}

这个算法的精髓在于:如果新位置总是递增的,说明相对顺序没变,无需移动

📊 性能优化细节
// 早期退出优化
if (patched >= toBePatched) {
  unmount(prevChild)
  continue
}

// 这个优化的作用:
// 如果已经处理的节点数量达到新节点总数
// 说明剩余的旧节点都是多余的,直接删除
// 避免不必要的查找和比较操作
🔑 Key值的重要性
// 有key的情况:O(1)查找
if (prevChild.key != null) {
  newIndex = keyToNewIndexMap.get(prevChild.key)
}
// 无key的情况:O(n)查找
else {
  for (j = s2; j <= e2; j++) {
    if (newIndexToOldIndexMap[j - s2] === 0 && 
        isSameVNodeType(prevChild, c2[j])) {
      newIndex = j
      break
    }
  }
}

这就是为什么Vue强烈建议在v-for中使用key的原因

  • ✅ 有key:时间复杂度O(1)
  • ❌ 无key:时间复杂度O(n²)
🎯 最长递增子序列的核心作用

在第三步的移动处理中,最长递增子序列发挥了关键作用:

// 核心执行逻辑
const increasingNewIndexSequence = moved
  ? getSequence(newIndexToOldIndexMap)
  : []

// 示例场景
// 旧列表:[A, B, C, D, E]  索引:[0, 1, 2, 3, 4]
// 新列表:[A, C, E, B, D]  索引:[0, 1, 2, 3, 4]
// newIndexToOldIndexMap: [1, 3, 5, 2, 4]  // +1偏移后的值

// 最长递增子序列:[1, 3, 5] 对应节点 [A, C, E]
// 结论:A, C, E 不需要移动,只需移动 B, D
⚡ 移动策略优化
// 逆向遍历的巧妙之处
for (i = toBePatched - 1; i >= 0; i--) {
  const nextIndex = s2 + i
  const nextChild = c2[nextIndex]
  const anchor = nextIndex + 1 < newLen ? c2[nextIndex + 1].el : parentAnchor
  
  if (newIndexToOldIndexMap[i] === 0) {
    // 新增节点
    patch(null, nextChild, container, anchor)
  } else if (moved) {
    if (j < 0 || i !== increasingNewIndexSequence[j]) {
      // 需要移动的节点
      container.insertBefore(nextChild.el, anchor)
    } else {
      j--  // 在最长递增子序列中,不需要移动
    }
  }
}

为什么要逆向遍历?

  • 🎯 保证锚点的正确性:后面的节点位置确定后,前面的节点才能找到正确的插入位置
  • ⚡ 减少DOM操作:避免重复的位置计算
🧮 算法复杂度分析
  • 时间复杂度:O(n log n) - 最长递增子序列算法

  • 空间复杂度:O(n) - 存储序列信息

  • 实际效果:将移动操作从O(n²)优化到接近O(n) } } // 🗑️ 第四步:处理删除节点的情况 // 条件:i > e2 说明新节点已经处理完,但旧节点还有剩余 // 例如:旧[A,B,C,D] 新[A,B] → 需要删除C,D else if (i > e2) { while (i <= e1) { // 卸载多余的旧节点 unmount(c1[i]) i++ } } // 乱序情况:需要进行复杂的diff算法 // 使用最长递增子序列算法来最小化DOM移动操作 else { // 🎯 乱序情况的处理:这是Vue3 diff算法最复杂的部分 // 目标:用最少的DOM操作,将旧子节点列表转换为新子节点列表

    const s1 = i // 旧子节点数组中需要处理的起始位置
    const s2 = i // 新子节点数组中需要处理的起始位置
    
    // 📋 第一步:建立"新节点key → 新节点索引"的快速查找表
    // 作用:后面遍历旧节点时,可以快速找到对应的新节点位置
    // 例如:新节点 [A, B, C] → Map { 'A': 0, 'B': 1, 'C': 2 }
    const keyToNewIndexMap: Map<string | number | symbol, number> = new Map()
    for (i = s2; i <= e2; i++) {
      const nextChild = c2[i]
      if (nextChild.key != null) {
        keyToNewIndexMap.set(nextChild.key, i)
      }
    }
    
    // 🔄 第二步:遍历旧子节点,找出可以复用的节点并记录移动信息
    let j
    let patched = 0 // 已经处理(patch)的节点数量
    const toBePatched = e2 - s2 + 1 // 新子节点中需要处理的总数量
    let moved = false // 标记是否有节点需要移动位置
    let maxNewIndexSoFar = 0 // 记录到目前为止遇到的最大新索引
    
    // 📊 创建"新节点索引 → 旧节点索引"的映射数组
    // 作用:记录每个新节点对应的旧节点位置,用于后续的移动优化
    // 值的含义:0 = 全新节点,>0 = 可复用的旧节点索引+1
    const newIndexToOldIndexMap = new Array(toBePatched)
    for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
    
    // 🔍 遍历所有旧子节点,决定每个节点的命运
    for (i = s1; i <= e1; i++) {
      const prevChild = c1[i] // 当前处理的旧节点
    
      // ⚡ 性能优化:如果已处理的节点数量达到新节点总数,剩余旧节点直接删除
      // 例如:新节点只有3个,但已经处理了3个,那么剩下的旧节点都是多余的
      if (patched >= toBePatched) {
        unmount(prevChild) // 卸载多余的旧节点
        continue
      }
    
      let newIndex // 旧节点在新节点数组中对应的位置
    
      // 🔑 如果旧节点有key,通过key快速查找对应的新节点位置
      if (prevChild.key != null) {
        newIndex = keyToNewIndexMap.get(prevChild.key)
      } else {
        // 🔍 如果旧节点没有key,只能线性搜索找到相同类型的新节点
        // 注意:这种情况性能较差,建议给列表项添加key
        for (j = s2; j <= e2; j++) {
          // 检查:1) 新节点还没有被匹配 2) 新旧节点类型相同
          if (
            newIndexToOldIndexMap[j - s2] === 0 &&
            isSameVNodeType(prevChild, c2[j])
          ) {
            newIndex = j
            break
          }
        }
      }
    
      // 🗑️ 如果旧节点在新节点中找不到对应项,说明被删除了
      if (newIndex === undefined) {
        unmount(prevChild) // 从DOM中移除
      } else {
        // ✅ 找到了对应的新节点,记录映射关系
        // +1是因为0被用来表示"新节点",所以旧索引要+1存储
        newIndexToOldIndexMap[newIndex - s2] = i + 1
    
        // 🚀 移动检测的巧妙算法:
        // 如果新索引是递增的,说明节点顺序没变,不需要移动
        // 如果新索引比之前的小,说明节点顺序乱了,需要移动
        // 例如:旧节点A在位置0,B在位置1,如果新顺序是B(1)→A(0),
        //      那么处理A时,newIndex=0 < maxNewIndexSoFar=1,需要移动
        if (newIndex >= maxNewIndexSoFar) {
          maxNewIndexSoFar = newIndex // 更新最大索引
        } else {
          moved = true // 标记需要移动
        }
    
        // 🔧 对找到的节点进行patch(更新属性、子节点等)
        patch(prevChild, c2[newIndex], container, null)
        patched++ // 已处理数量+1
      }
    }
    
    // 🎯 第三步:处理节点的移动和新节点的挂载
    // 核心思想:只移动必要的节点,最大化复用现有DOM
    
    // 🧮 如果需要移动,计算最长递增子序列(LIS)
    // LIS的作用:找出哪些节点已经在正确位置,不需要移动
    // 例如:[4,2,3,1,5] 的LIS是 [2,3,5],这些位置的节点不用动
    const increasingNewIndexSequence = moved
      ? getSequence(newIndexToOldIndexMap)
      : []
    
    j = increasingNewIndexSequence.length - 1 // LIS的指针,从后往前
    
    // 🔄 从后往前遍历新子节点,确保插入位置正确
    // 为什么从后往前?因为插入时需要知道"锚点"(插入位置的参考节点)
    for (i = toBePatched - 1; i >= 0; i--) {
      const nextIndex = s2 + i // 当前新节点在整个新数组中的真实索引
      const nextChild = c2[nextIndex] // 当前要处理的新节点
    
      // 🎯 确定插入的锚点:下一个节点的DOM元素
      // 如果没有下一个节点,就插入到容器末尾
      const anchor =
        nextIndex + 1 < newLen ? c2[nextIndex + 1].el : parentAnchor
    
      // 🆕 如果是全新节点(映射值为0),直接挂载到DOM
      if (newIndexToOldIndexMap[i] === 0) {
        patch(null, nextChild, container, anchor)
      }
      // 🚚 如果需要移动节点
      else if (moved) {
        // 🎯 移动策略:只移动不在最长递增子序列中的节点
        // 如果当前节点在LIS中,说明它已经在正确位置,不用移动
        if (j < 0 || i !== increasingNewIndexSequence[j]) {
          // 移动节点到正确位置(插入到anchor之前)
          container.insertBefore(nextChild.el, anchor)
        } else {
          // 当前节点在LIS中,位置正确,不需要移动
          j-- // LIS指针前移
        }
      }
    }
    

🎯 五步优化策略详解


通过上面的核心代码,我们可以清晰地看到Vue3 Diff算法的五步处理逻辑。让我们逐一深入分析:

## 🔍 第一步:前序比较优化

```ts
   // 📏 初始化指针和长度变量
    let newLen = c2.length // 新子节点数组的长度
    let oldLen = c1.length - 1 // 旧子节点数组的最后一个索引
    let e1 = oldLen // 旧数组的结束指针(从后往前移动)
    let e2 = newLen - 1 // 新数组的结束指针(从后往前移动)
    let i = 0 // 开始指针(从前往后移动)
    
    // 🔍 第一步:从前往后比较,找出开头相同的节点
    // 目的:跳过开头相同的节点,减少后续比较的工作量
    // 例如:[A,B,C,D] vs [A,B,X,Y] → 跳过A,B,从C,D vs X,Y开始处理
    while (i <= e1 && i <= e2) {
      const n1 = c1[i] // 当前旧节点
      const n2 = c2[i] // 当前新节点
      
      // 如果节点类型和key都相同,说明可以复用
      if (isSameVNodeType(n1, n2)) {
        // 递归更新这个节点(可能属性或子节点有变化)
        patch(n1, n2, container, parentAnchor)
      } else {
        // 遇到不同的节点,停止前向比较
        break
      }
      i++ // 指针前移
    }

🎯 核心思想

前序比较的核心思想是跳过开头相同的节点,这是一个非常实用的优化策略:

  • 时间复杂度:O(n),其中n是相同前缀的长度
  • 空间复杂度:O(1),只使用常数级别的额外空间
  • 实际效果:在列表末尾添加/删除元素的场景下,这一步就能处理大部分工作

📊 性能优势

// 场景示例:在列表末尾添加元素
// 旧列表:[A, B, C]
// 新列表:[A, B, C, D, E]
// 前序比较后:只需处理 [D, E] 的新增,跳过了 A, B, C 的比较

这种设计让Vue3在处理追加型更新(如聊天记录、商品列表加载更多)时性能极佳。

🔧 实现细节

// isSameVNodeType 的判断逻辑
function isSameVNodeType(n1: VNode, n2: VNode): boolean {
  return n1.type === n2.type && n1.key === n2.key
}

// 为什么要调用 patch?
// 即使节点类型和key相同,节点的props或children可能发生变化
// patch函数会递归处理这些细节更新

🔄 第二步:后序比较优化

   // 🔍 第二步:从后往前比较,找出结尾相同的节点
    // 目的:跳过结尾相同的节点,进一步缩小需要处理的范围
    // 例如:[A,B,C,D] vs [X,Y,C,D] → 跳过C,D,只需处理A,B vs X,Y
    while (i <= e1 && i <= e2) {
      const n1 = c1[e1] // 当前旧节点(从后往前)
      const n2 = c2[e2] // 当前新节点(从后往前)
      
      // 如果节点类型和key都相同,说明可以复用
      if (isSameVNodeType(n1, n2)) {
        // 递归更新这个节点
        patch(n1, n2, container, parentAnchor)
      } else {
        // 遇到不同的节点,停止后向比较
        break
      }
      e1-- // 旧数组指针前移
      e2-- // 新数组指针前移
    }

🎯 核心思想

后序比较是前序比较的镜像操作,专门处理列表尾部的相同节点

  • 双指针技术e1e2分别指向旧列表和新列表的末尾
  • 逆向遍历:从后往前比较,跳过尾部相同的节点
  • 互补优化:与前序比较形成完美互补,覆盖更多优化场景

📊 典型应用场景

// 场景示例:在列表开头插入元素
// 旧列表:[A, B, C]
// 新列表:[X, Y, A, B, C]
// 后序比较后:跳过 A, B, C,只需处理 X, Y 的新增

🚀 双端优化的威力

前序 + 后序比较的组合,能够高效处理:

  • ✅ 列表头部插入/删除
  • ✅ 列表尾部插入/删除
  • ✅ 列表两端同时变化
  • ✅ 简单的元素替换

➕ 第三步:新增节点处理

    // 📊 经过前后两轮比较后的状态分析:
    // - i: 第一个不同节点的位置
    // - e1: 旧数组中最后一个需要处理的节点位置
    // - e2: 新数组中最后一个需要处理的节点位置
    
    // ✅ 第三步:处理新增节点的情况
    // 条件:i > e1 说明旧节点已经处理完,但新节点还有剩余
    // 例如:旧[A,B] 新[A,B,C,D] → 需要新增C,D
    if (i > e1) {
      if (i <= e2) {
        // 确定插入位置的锚点
        const nextPos = e2 + 1
        // 如果下一个位置存在节点,就插入到它前面;否则插入到容器末尾
        const anchor = nextPos < newLen ? c2[nextPos].el : parentAnchor
        
        // 挂载所有新增的节点
        while (i <= e2) {
          // patch(null, newNode) 表示挂载新节点
          patch(null, c2[i], container, anchor)
          i++
        }
      }
    }

🎯 判断逻辑

经过前两步的双端比较后,如果满足 i > e1 && i <= e2,说明存在需要新增的节点:

  • i > e1:旧列表已经遍历完毕
  • i <= e2:新列表还有未处理的节点
  • 结论:这些未处理的节点就是需要新增的节点

🔧 实现细节

// 锚点计算的巧妙之处
const nextPos = e2 + 1
const anchor = nextPos < newLen ? c2[nextPos].el : parentAnchor

// 为什么需要锚点?
// DOM的insertBefore需要一个参考节点
// 如果没有参考节点,就插入到容器末尾

📊 性能特点

  • 时间复杂度:O(m),其中m是新增节点的数量
  • 空间复杂度:O(1)
  • DOM操作:只进行必要的插入操作,无多余的移动

当旧节点的数量少于新节点的数量时,那么此时就需要创建新节点来插入到对应的位置

🗑️ 第四步:删除节点处理

   // 🗑️ 第四步:处理删除节点的情况
    // 条件:i > e2 说明新节点已经处理完,但旧节点还有剩余
    // 例如:旧[A,B,C,D] 新[A,B] → 需要删除C,D
    else if (i > e2) {
      while (i <= e1) {
        // 卸载多余的旧节点
        unmount(c1[i])
        i++
      }
    }

🎯 判断逻辑

当满足 i > e2 && i <= e1 时,说明存在需要删除的节点:

  • i > e2:新列表已经遍历完毕
  • i <= e1:旧列表还有未处理的节点
  • 结论:这些未处理的旧节点需要被删除

🔧 实现细节

// 删除操作的实现
if (i > e2) {
  while (i <= e1) {
    unmount(c1[i], parentComponent, parentSuspense, true)
    i++
  }
}

⚡ 性能优势

  • 批量删除:一次性处理所有需要删除的节点
  • 内存释放:及时释放不再需要的DOM节点和组件实例
  • 事件清理:自动清理相关的事件监听器和响应式依赖
  • 时间复杂度:O(k),其中k是需要删除的节点数量

📊 典型应用场景

// 场景示例:删除列表中的部分元素
// 旧列表:[A, B, C, D, E]
// 新列表:[A, B]
// 删除处理:自动卸载 C, D, E

🌪️ 第五步:乱序情况下的终极优化

这是Vue3 Diff算法最精彩的部分,也是最长递增子序列真正发挥作用的地方。当前四步都无法处理时,说明遇到了复杂的乱序场景。

🎯 核心挑战

乱序场景的核心挑战是:如何用最少的DOM移动操作,将旧列表转换为新列表?

// 典型乱序场景
// 旧列表:[A, B, C, D, E]
// 新列表:[A, C, E, B, D, F]
// 挑战:B和D需要移动,F需要新增,同时要保持C和E的相对位置不变

🧩 三步解决策略

Vue3将这个复杂问题分解为三个子问题:

  1. 🗺️ 构建映射表:建立新节点key到索引的快速查找表
  2. 🔍 标记可复用节点:找出哪些旧节点可以复用,哪些需要删除
  3. 🎯 最优移动策略:使用最长递增子序列计算最少移动方案

🗺️ 第一部分:构建映射表

   // 🎯 乱序情况的处理:这是Vue3 diff算法最复杂的部分
      // 目标:用最少的DOM操作,将旧子节点列表转换为新子节点列表

      const s1 = i // 旧子节点数组中需要处理的起始位置
      const s2 = i // 新子节点数组中需要处理的起始位置

      // 📋 第一步:建立"新节点key → 新节点索引"的快速查找表
      // 作用:后面遍历旧节点时,可以快速找到对应的新节点位置
      // 例如:新节点 [A, B, C] → Map { 'A': 0, 'B': 1, 'C': 2 }
      const keyToNewIndexMap: Map<string | number | symbol, number> = new Map()
      for (i = s2; i <= e2; i++) {
        const nextChild = c2[i]
        if (nextChild.key != null) {
          keyToNewIndexMap.set(nextChild.key, i)
        }
      }

🎯 设计目的

构建映射表是一个经典的空间换时间优化策略:

  • 时间复杂度:从O(n²)降低到O(n)
  • 空间复杂度:O(n),用于存储映射关系
  • 查找效率:从线性查找提升到常数时间查找
📊 性能对比
// 没有映射表的查找(O(n²))
for (let i = 0; i < oldChildren.length; i++) {
  for (let j = 0; j < newChildren.length; j++) {
    if (oldChildren[i].key === newChildren[j].key) {
      // 找到匹配节点
    }
  }
}

// 使用映射表的查找(O(n))
const keyToNewIndexMap = new Map()
for (let i = 0; i < newChildren.length; i++) {
  keyToNewIndexMap.set(newChildren[i].key, i)
}

for (let i = 0; i < oldChildren.length; i++) {
  const newIndex = keyToNewIndexMap.get(oldChildren[i].key)
  // 常数时间找到匹配节点
}
🔧 实现细节
// 为什么使用 Map 而不是普通对象?
// 1. Map 支持任意类型的 key(string | number | symbol)
// 2. Map 的查找性能更稳定
// 3. Map 避免了原型链污染问题

// key 的类型检查
if (nextChild.key != null) {
  // 只有明确设置了 key 的节点才参与映射
  // undefined 和 null 都会被跳过
  keyToNewIndexMap.set(nextChild.key, i)
}

🔍 第二部分:标记可复用节点与移动检测

 // 🔄 第二步:遍历旧子节点,找出可以复用的节点并记录移动信息
      let j
      let patched = 0 // 已经处理(patch)的节点数量
      const toBePatched = e2 - s2 + 1 // 新子节点中需要处理的总数量
      let moved = false // 标记是否有节点需要移动位置
      let maxNewIndexSoFar = 0 // 记录到目前为止遇到的最大新索引

      // 📊 创建"新节点索引 → 旧节点索引"的映射数组
      // 作用:记录每个新节点对应的旧节点位置,用于后续的移动优化
      // 值的含义:0 = 全新节点,>0 = 可复用的旧节点索引+1
      const newIndexToOldIndexMap = new Array(toBePatched)
      for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0

      // 🔍 遍历所有旧子节点,决定每个节点的命运
      for (i = s1; i <= e1; i++) {
        const prevChild = c1[i] // 当前处理的旧节点

        // ⚡ 性能优化:如果已处理的节点数量达到新节点总数,剩余旧节点直接删除
        // 例如:新节点只有3个,但已经处理了3个,那么剩下的旧节点都是多余的
        if (patched >= toBePatched) {
          unmount(prevChild) // 卸载多余的旧节点
          continue
        }

        let newIndex // 旧节点在新节点数组中对应的位置

        // 🔑 如果旧节点有key,通过key快速查找对应的新节点位置
        if (prevChild.key != null) {
          newIndex = keyToNewIndexMap.get(prevChild.key)
        } else {
          // 🔍 如果旧节点没有key,只能线性搜索找到相同类型的新节点
          // 注意:这种情况性能较差,建议给列表项添加key
          for (j = s2; j <= e2; j++) {
            // 检查:1) 新节点还没有被匹配 2) 新旧节点类型相同
            if (
              newIndexToOldIndexMap[j - s2] === 0 &&
              isSameVNodeType(prevChild, c2[j])
            ) {
              newIndex = j
              break
            }
          }
        }

        // 🗑️ 如果旧节点在新节点中找不到对应项,说明被删除了
        if (newIndex === undefined) {
          unmount(prevChild) // 从DOM中移除
        } else {
          // ✅ 找到了对应的新节点,记录映射关系
          // +1是因为0被用来表示"新节点",所以旧索引要+1存储
          newIndexToOldIndexMap[newIndex - s2] = i + 1

          // 🚀 移动检测的巧妙算法:
          // 如果新索引是递增的,说明节点顺序没变,不需要移动
          // 如果新索引比之前的小,说明节点顺序乱了,需要移动
          // 例如:旧节点A在位置0,B在位置1,如果新顺序是B(1)→A(0),
          //      那么处理A时,newIndex=0 < maxNewIndexSoFar=1,需要移动
          if (newIndex >= maxNewIndexSoFar) {
            maxNewIndexSoFar = newIndex // 更新最大索引
          } else {
            moved = true // 标记需要移动
          }

          // 🔧 对找到的节点进行patch(更新属性、子节点等)
          patch(prevChild, c2[newIndex], container, null)
          patched++ // 已处理数量+1
        }
      }

第三步:处理节点的移动和新节点的挂载

 // 🎯 第三步:处理节点的移动和新节点的挂载
      // 核心思想:只移动必要的节点,最大化复用现有DOM

      // 🧮 如果需要移动,计算最长递增子序列(LIS)
      // LIS的作用:找出哪些节点已经在正确位置,不需要移动
      // 例如:[4,2,3,1,5] 的LIS是 [2,3,5],这些位置的节点不用动
      const increasingNewIndexSequence = moved
        ? getSequence(newIndexToOldIndexMap)
        : []

      j = increasingNewIndexSequence.length - 1 // LIS的指针,从后往前

      // 🔄 从后往前遍历新子节点,确保插入位置正确
      // 为什么从后往前?因为插入时需要知道"锚点"(插入位置的参考节点)
      for (i = toBePatched - 1; i >= 0; i--) {
        const nextIndex = s2 + i // 当前新节点在整个新数组中的真实索引
        const nextChild = c2[nextIndex] // 当前要处理的新节点

        // 🎯 确定插入的锚点:下一个节点的DOM元素
        // 如果没有下一个节点,就插入到容器末尾
        const anchor =
          nextIndex + 1 < newLen ? c2[nextIndex + 1].el : parentAnchor

        // 🆕 如果是全新节点(映射值为0),直接挂载到DOM
        if (newIndexToOldIndexMap[i] === 0) {
          patch(null, nextChild, container, anchor)
        }
        // 🚚 如果需要移动节点
        else if (moved) {
          // 🎯 移动策略:只移动不在最长递增子序列中的节点
          // 如果当前节点在LIS中,说明它已经在正确位置,不用移动
          if (j < 0 || i !== increasingNewIndexSequence[j]) {
            // 移动节点到正确位置(插入到anchor之前)
            container.insertBefore(nextChild.el, anchor)
          } else {
            // 当前节点在LIS中,位置正确,不需要移动
            j-- // LIS指针前移
          }
        }
      }

🧮 最长递增子序列算法深度解析

在第3部分中,涉及到了最长递增子序列:getSequence(newIndexToOldIndexMap),这个函数是Vue3 Diff算法的核心优化,用于计算出最少的DOM移动次数。

🎯 算法核心思想

最长递增子序列(Longest Increasing Subsequence, LIS)在Vue3中的作用是:找出哪些节点已经处于正确的相对位置,无需移动

// 示例场景
// newIndexToOldIndexMap: [4, 2, 3, 1, 5]
// 表示:新位置0对应旧位置3,新位置1对应旧位置1,以此类推
// 
// LIS算法会找出:[2, 3, 5] (索引为1, 2, 4的元素)
// 含义:这些位置的节点相对顺序正确,不需要移动
// 只需要移动其他节点:索引0和3的节点

⚡ 算法实现与优化

/**
 * 计算最长递增子序列的函数
 * 这是Vue3 diff算法的核心优化,用于最小化DOM移动操作
 *
 * 🎯 算法原理:
 * 1. 使用动态规划 + 二分查找,时间复杂度O(n log n)
 * 2. 维护一个递增序列,对每个元素二分查找插入位置
 * 3. 通过前驱数组记录路径,最后回溯得到完整序列
 * 4. 贪心策略:总是保持当前长度下的最小尾元素
 *
 * @param arr 输入数组,通常是newIndexToOldIndexMap
 * @returns 最长递增子序列的索引数组
 */
function getSequence(arr: number[]): number[] {
  const p = arr.slice() // 🔗 前驱数组,记录每个位置的前一个元素索引
  const result = [0] // 📊 结果数组,存储最长递增子序列的索引
  let i, j, u, v, c
  const len = arr.length

  // 🔄 主循环:处理每个元素
  for (i = 0; i < len; i++) {
    const arrI = arr[i]
    
    // ⚡ 关键优化:跳过值为0的元素
    // 0表示新节点,不参与LIS计算,因为新节点没有"原始位置"
    if (arrI !== 0) {
      j = result[result.length - 1] // 当前序列的最后一个索引

      // 🚀 快速路径:如果当前元素大于序列最后元素,直接追加
      // 这是最常见的情况,避免了二分查找的开销
      if (arr[j] < arrI) {
        p[i] = j // 记录前驱关系
        result.push(i) // 扩展序列
        continue
      }

      // 🔍 二分查找:找到第一个大于等于arrI的位置
      // 目标:在保持序列递增的前提下,找到最佳插入位置
      u = 0 // 左边界
      v = result.length - 1 // 右边界
      
      while (u < v) {
        c = (u + v) >> 1 // 🎯 位运算取中点,比Math.floor((u + v) / 2)更快
        
        if (arr[result[c]] < arrI) {
          u = c + 1 // 在右半部分继续查找
        } else {
          v = c // 在左半部分继续查找
        }
      }

      // 🔄 贪心替换:如果找到更优的元素,进行替换
      // 贪心策略:相同长度的递增序列中,尾元素越小越好
      if (arrI < arr[result[u]]) {
        if (u > 0) {
          p[i] = result[u - 1] // 记录前驱关系
        }
        result[u] = i // 替换为更优的元素
      }
    }
  }

  // 🔙 回溯构建最长递增子序列
  // 由于替换操作,result数组存储的不是最终序列
  // 需要通过前驱数组p来重建真正的LIS
  u = result.length
  v = result[u - 1] // 从最后一个元素开始回溯
  
  while (u-- > 0) {
    result[u] = v // 重建序列
    v = p[v] // 跳转到前驱元素
  }

  return result
}

📊 算法复杂度分析

操作 时间复杂度 空间复杂度 说明
构建LIS O(n log n) O(n) 二分查找优化的动态规划
回溯重建 O(k) O(1) k为LIS长度
总体 O(n log n) O(n) 相比暴力O(n²)有显著提升

🎨 实际应用示例

// 🎯 实际场景演示
// 旧列表:[A, B, C, D, E]  索引:[0, 1, 2, 3, 4]
// 新列表:[B, A, D, C, E]  索引:[0, 1, 2, 3, 4]

// Step 1: 构建 newIndexToOldIndexMap
// B(新0) -> 旧1: map[0] = 2  (+1偏移)
// A(新1) -> 旧0: map[1] = 1  (+1偏移)
// D(新2) -> 旧3: map[2] = 4  (+1偏移)
// C(新3) -> 旧2: map[3] = 3  (+1偏移)
// E(新4) -> 旧4: map[4] = 5  (+1偏移)
// 结果:[2, 1, 4, 3, 5]

// Step 2: 计算LIS
const lis = getSequence([2, 1, 4, 3, 5])
// 返回:[1, 3, 4] (对应新列表中A, C, E的位置)

// Step 3: 移动策略
// 不移动:A(位置1), C(位置3), E(位置4) - 在LIS中
// 需移动:B(位置0), D(位置2) - 不在LIS中
// 结果:只需要2次DOM移动操作,而不是4次

🚀 性能优化细节

1. 位运算优化
// 使用位运算代替除法,提升性能
c = (u + v) >> 1  // 比 Math.floor((u + v) / 2) 快约20%
2. 早期退出策略
// 快速路径:避免不必要的二分查找
if (arr[j] < arrI) {
  result.push(i)
  continue  // 直接跳过二分查找
}
3. 贪心策略
// 相同长度的序列中,选择尾元素最小的
// 这样为后续元素提供更多的扩展可能性
if (arrI < arr[result[u]]) {
  result[u] = i  // 贪心替换
}

🎯 为什么选择LIS?

  1. 最优性保证:LIS确保找到需要移动的最少节点数
  2. 稳定性:相对位置正确的节点不会被移动
  3. 高效性:O(n log n)的时间复杂度,适合大列表
  4. 实用性:大多数实际场景下,列表变化都有一定的局部性

这就是Vue3 Diff算法中最长递增子序列的完整实现和优化策略。它不仅仅是一个算法,更是Vue3性能优化的核心体现。

🎯 核心原理总结

🔍 关键技术洞察

1. 五步优化策略的设计哲学

Vue3的Diff算法并非单纯依赖最长递增子序列,而是采用分层优化的设计思想:

  • 前四步:处理90%的常见场景(前后端比较、增删操作)
  • 第五步:处理10%的复杂场景(乱序移动)
  • 核心理念:用简单算法处理简单问题,用复杂算法处理复杂问题
2. Key值的核心作用机制
// Key值的三重作用
1. 🔍 节点识别:快速判断节点是否可复用
2. ⚡ 性能优化:从O(n²)降低到O(n)
3. 🎯 移动计算:为LIS算法提供准确的位置映射

为什么v-for需要手动添加key?

  • ✅ 其他节点:Vue3自动生成key(基于节点类型和位置)
  • ❌ v-for节点:动态生成,无法自动推断稳定的key
  • 🎯 解决方案:开发者提供业务相关的唯一标识
3. 算法复杂度的渐进优化
场景 传统算法 Vue3算法 优化效果
前后端添加 O(n²) O(n) 🚀 线性优化
简单移动 O(n²) O(n) 🚀 线性优化
复杂乱序 O(n²) O(n log n) ⚡ 对数优化
无key场景 O(n³) O(n²) 📈 仍需优化

🎨 设计模式分析

1. 分治策略(Divide and Conquer)
// 将复杂的列表比较问题分解为5个子问题
// 每个子问题都有针对性的优化策略
function patchKeyedChildren() {
  // 分治:前序比较
  syncFromStart()
  // 分治:后序比较  
  syncFromEnd()
  // 分治:新增处理
  mountNewNodes()
  // 分治:删除处理
  unmountOldNodes()
  // 分治:乱序处理
  handleComplexCase()
}
2. 贪心算法(Greedy Algorithm)
// 在LIS算法中的应用
// 总是选择当前长度下的最小尾元素
// 为后续扩展提供最大可能性
if (arrI < arr[result[u]]) {
  result[u] = i  // 贪心选择
}
3. 动态规划(Dynamic Programming)
// LIS算法的DP思想
// 状态:dp[i] = 以i结尾的最长递增子序列长度
// 转移:通过二分查找优化状态转移

🚀 性能优化要点

1. 空间换时间
  • 映射表:O(n)空间换取O(1)查找时间
  • 前驱数组:O(n)空间支持LIS回溯
  • 索引映射:避免重复的DOM查询
2. 算法层面优化
  • 二分查找:将LIS从O(n²)优化到O(n log n)
  • 位运算:使用>>代替除法运算
  • 早期退出:避免不必要的计算
3. 工程层面优化
  • 批量操作:减少DOM操作次数
  • 锚点策略:精确控制插入位置
  • 内存管理:及时释放不再需要的引用

🔮 与Vue2的对比

特性 Vue2 Vue3 改进
算法策略 双端比较 五步优化 🎯 更全面
复杂度 O(n²) O(n log n) ⚡ 更高效
移动优化 启发式 LIS算法 🧮 更精确
内存使用 较高 优化 💾 更节省

💡 最佳实践建议

1. Key值设计原则
// ✅ 推荐:使用稳定的业务ID
<li v-for="user in users" :key="user.id">

// ❌ 避免:使用数组索引
<li v-for="(user, index) in users" :key="index">

// ❌ 避免:使用随机值
<li v-for="user in users" :key="Math.random()">
2. 列表更新策略
// 🚀 高效:批量更新
const newUsers = [...users, ...newData]
users.value = newUsers

// 🐌 低效:逐个更新
newData.forEach(user => users.value.push(user))
3. 性能监控
// 开发环境下监控Diff性能
if (__DEV__) {
  console.time('diff-performance')
  patchKeyedChildren()
  console.timeEnd('diff-performance')
}

🎓 进阶学习建议

  1. 算法基础:深入学习动态规划、贪心算法、二分查找
  2. 数据结构:理解Map、数组操作的性能特点
  3. 浏览器原理:了解DOM操作的性能成本
  4. Vue源码:阅读完整的patch函数实现
  5. 性能调优:使用Vue DevTools分析实际项目的Diff性能

🌟 结语

Vue3的Diff算法是前端框架设计的典型代表,它完美诠释了工程化思维

  • 🎯 问题分解:将复杂问题分解为可管理的子问题
  • 性能优先:在保证正确性的前提下追求极致性能
  • 🔧 工程实用:算法设计贴近实际应用场景
  • 📈 持续优化:从Vue2到Vue3的不断改进

掌握Vue3 Diff算法,不仅能帮助我们写出更高性能的Vue应用,更能提升我们的算法思维和工程能力。这正是优秀前端工程师必备的核心素养。

记忆中的打地鼠游戏居然是这样实现的,Trae版实现

2025年8月16日 16:27

前言

今天来还原童年记忆中的打地鼠游戏,主要是让Trae用代码实现这个游戏的核心功能。

这个游戏的核心功能

先把这个核心逻辑发给Trae,看看他完成的是不是你想要的童年记忆

  1. 玩家控制一个木槌,通过鼠标点击来敲打地鼠。
  2. 地鼠会随机从地面的洞口冒出。
  3. 玩家敲中地鼠后,地鼠会缩回洞中,并且玩家获得得分。
  4. 游戏会有一个倒计时,当倒计时结束时,游戏结束,玩家需要在规定时间内获得尽可能高的分数。
  5. 地鼠冒出的频率会随着游戏时间逐渐加快,增加游戏难度。
  6. 游戏会有一个得分系统,玩家每敲中一个地鼠,就会获得一定的分数。

由于上一篇的坦克大战生成的ui太过于粗糙,这次我们就让他生成的时候要精美的页面 image.png

这效果还是有点像,毕竟我们没有资源文件,这样的完成度已经不错了,看起来有些微信小游戏的味道了,右上角还很贴心的安排上音效按钮,如果不喜欢我们可以关闭音效,沉浸式的打地鼠

image.png

Trae代码解读

首先是生成Grid布局,for循环生成九个洞口

image.png 设置速度,来表示简单、中等、困难的等级,玩家可以自由的选择等级来玩打地鼠

image.png

Trae通过Math.random来随机生成地鼠在哪一个洞口出现,通过setTimeout来持续生成

image.png

生成新地鼠,如果需要的话,并更新计时器,源源不断的生成地鼠

this.gopherSpawnTimer++;
        if (this.gopherSpawnTimer >= this.gopherSpawnDelay && this.gophers.filter(g => g.alive).length < 4) {
            this.spawnGopher();
            this.gopherSpawnTimer = 0;
        }

木槌与地鼠碰撞检测,碰撞了就把地鼠消失,这样就可以新生成地鼠,这个消失主要是给dom元素添加class,让地鼠实现消失的效果,可以说是非常的精妙

image.png 为了丰富玩家的游戏的体验,Trae还帮我们添加了击中的特效,短暂的延迟在消失,看起来就有一定的视觉冲击

image.png 最后是来自Trae自己对这款打地鼠的评价

image.png

总结

1、这个游戏的核心功能包括玩家控制木槌敲打地鼠,地鼠的随机冒出和消失,得分系统,以及倒计时结束时的游戏结束。通过这些功能,玩家可以在规定时间内获得尽可能高的分数。

2、通过绘制洞口,地鼠可以随机从洞口冒出。同时,洞口也可以作为游戏的地图边界,防止地鼠超出地图范围。主要还是还原童年的骚操作,可以让自己在游戏保证不死的通过游戏关卡。都是满满的童年回忆。

你是否也玩过这个游戏呢?

我们让 JSON.stringify 的速度提升了两倍以上

2025年8月16日 15:32

本篇依然来自于我们的 《前端周刊》 项目!

由团队成员 掘金安东尼 翻译,欢迎大家 进群 持续追踪全球最新前端资讯!!

原文地址:v8.dev/blog/json-s…

生成前端周刊图.png

译者小结

JSON.stringify 提速的核心为以下6点:

  1. 快速路径:避开一大堆通用检查(节省 CPU 时间)
  2. 专用版本:按字符串类型分开编译(减少分支判断)
  3. 批量扫描:一次看多字符(降低循环次数)
  4. 缓存形状:重复对象直接批量处理(跳过重复工作)
  5. 更快算法:数字转字符串的计算加速(核心耗时优化)
  6. 分段缓冲:内存分配更聪明(避免大搬家)

原文

JSON.stringify 是 JavaScript 中用于序列化数据的核心函数。它的性能直接影响着 Web 上的常见操作——从为网络请求序列化数据,到将数据保存到 localStorage。更快的 JSON.stringify 意味着页面交互更迅速、应用响应更灵敏。这就是为什么我们很高兴地分享:最近的一次工程改进,使得 V8 中的 JSON.stringify 性能提升了两倍以上。本文将拆解实现这一提升的技术优化。

无副作用的快速路径

此次优化的基础是一条新的快速路径,建立在一个简单的前提上:如果我们能够保证序列化对象时不会触发任何副作用,就可以使用更快的专用实现。这里的“副作用”指的是任何会打破对象简单、顺序遍历的情况。

这不仅包括明显的情况,比如在序列化过程中执行用户定义的代码,还包括一些更隐蔽的内部操作,比如可能触发垃圾回收的过程。有关哪些情况会导致副作用,以及如何避免它们的更多细节,请参见 Limitations

只要 V8 能确定序列化过程不会出现这些情况,就可以一直停留在高度优化的路径上。这使它能够绕过通用序列化器中许多昂贵的检查和防御逻辑,从而在处理最常见的、代表纯数据的 JavaScript 对象时获得显著加速。

此外,这条新快速路径是迭代式的,而不是像通用序列化器那样递归。这一架构选择不仅免去了栈溢出检查,并允许我们在编码改变后快速恢复,还能让开发者序列化比以前更深层嵌套的对象图。

处理不同的字符串表示

在 V8 中,字符串可以用单字节或双字节字符表示。如果一个字符串只包含 ASCII 字符,它会被存储为单字节字符串,每个字符占 1 个字节。但如果字符串中有一个字符超出 ASCII 范围,那么整个字符串都会使用双字节表示,内存占用翻倍。

为了避免统一实现中不断分支和类型检查的开销,整个字符串序列化器现在基于字符类型进行模板化。这意味着我们会编译两个独立的、专门优化的版本:一个完全针对单字节字符串优化,另一个针对双字节字符串优化。这确实会影响二进制大小,但我们认为性能提升绝对值得。

该实现还能高效处理混合编码。在序列化过程中,我们必须检查每个字符串的实例类型,以检测无法在快速路径处理的表示形式(比如 ConsString,它在扁平化时可能触发 GC),这些会回退到慢路径。这个检查同时也能知道字符串是单字节还是双字节编码。

因此,从乐观的单字节序列化器切换到双字节版本几乎是零成本的。当检查发现双字节字符串时,就会新建一个双字节序列化器,并继承当前状态。最后,只需将初始单字节序列化器的输出与双字节版本的输出拼接即可。这种策略确保了在常见情况下保持高度优化的路径,同时转向处理双字节字符的开销很小且高效。

使用 SIMD 优化字符串序列化

在 JavaScript 中,任意字符串在序列化为 JSON 时都可能包含需要转义的字符(例如 " 或 ``)。传统的逐字符循环查找这些字符速度很慢。

为了加速这一过程,我们基于字符串长度采用了两级策略:

  • 长字符串:使用专用的硬件 SIMD 指令(例如 ARM64 Neon)。这样可以将字符串的大块内容加载到宽 SIMD 寄存器中,并在几条指令内同时检查多个字节是否存在需要转义的字符。
  • 短字符串:使用 SWAR(寄存器内 SIMD)技术。该方法通过在标准通用寄存器上进行巧妙的按位逻辑运算,以极低开销一次处理多个字符。

无论采用哪种方法,流程都很高效:按块快速扫描字符串。如果某个块中没有特殊字符(这是常见情况),就可以直接复制整个字符串。

快速路径上的“快速通道”

即使在主快速路径中,我们也找到了进一步加速的机会。默认情况下,快速路径仍需遍历对象的每个属性,并对每个键执行一系列检查:确认不是 Symbol、确保可枚举、扫描字符串是否包含需要转义的字符(例如 " 或 ``)。

为消除这些步骤,我们在对象的隐藏类上引入了一个标志。一旦我们序列化了对象的所有属性,就会将其隐藏类标记为 fast-json-iterable,前提是属性键都不是 Symbol、全部可枚举、且不包含需要转义的字符。

当我们序列化另一个具有相同隐藏类的对象(这种情况很常见,比如一组形状相同的对象数组)并且它是 fast-json-iterable 时,我们可以直接将所有键复制到字符串缓冲区,而无需进一步检查。

我们还将这种优化应用到了 JSON.parse,当解析数组时,如果数组中的对象通常有相同的隐藏类,就可以用它来进行快速键比较。

更快的数字转字符串算法

将数字转换为字符串是一个出乎意料的复杂且性能关键的任务。在 JSON.stringify 的优化中,我们发现可以显著加速这一过程,于是升级了核心的 DoubleToString 算法。我们用 Dragonbox 替换了长期使用的 Grisu3 算法,用于最短长度的数字转字符串转换。

虽然这一优化是为了 JSON.stringify,但新的 Dragonbox 实现会惠及 V8 中所有 Number.prototype.toString() 的调用。这意味着任何数字转字符串的代码,不仅仅是 JSON 序列化,都会自动获得这一性能提升。

优化底层临时缓冲区

任何字符串构建操作中的一个主要开销是内存管理。之前,我们的序列化器会在 C++ 堆上构建一个单一的连续缓冲区。虽然简单,但这种方式有一个显著缺点:一旦缓冲区空间耗尽,就必须分配更大的缓冲区,并将全部现有内容复制过去。对于大型 JSON 对象,这种反复分配和复制的过程会造成很大的性能损耗。

关键洞察是,强制这个临时缓冲区保持连续并没有真正的好处,因为最终结果只会在最后一步组装成一个字符串。

基于此,我们将旧系统替换为分段缓冲区。不再是一个大的、不断增长的内存块,而是使用 V8 的 Zone 内存分配一组较小的缓冲段。当一个段写满时,我们直接分配一个新的段继续写,完全消除了昂贵的复制操作。

限制

新的快速路径通过专门优化常见、简单的情况来实现速度提升。如果被序列化的数据不满足这些条件,V8 会回退到通用序列化器以确保正确性。要获得全部性能提升,JSON.stringify 调用需要满足以下条件:

  • 无 replacer 或 space 参数:提供 replacer 函数或 space/gap 参数(用于美化输出)会使其进入通用路径。快速路径仅支持紧凑的、未转换的序列化。
  • 纯数据对象和数组:被序列化的对象应是简单的数据容器,即它们及其原型不能有自定义的 .toJSON() 方法。快速路径假设标准原型(如 Object.prototype、Array.prototype),且无自定义序列化逻辑。
  • 对象无索引属性:快速路径针对具有常规字符串键的对象进行优化。如果对象包含类数组的索引属性(如 '0'、'1'…),则会使用较慢的通用序列化器。
  • 简单字符串类型:某些内部 V8 字符串表示(如 ConsString)在序列化前需要分配内存进行扁平化。快速路径避免执行可能触发这种分配的操作,最适合处理简单的顺序字符串。作为 Web 开发者,这一点难以直接控制,但大多数情况下都能正常工作。

对于绝大多数使用场景(如为 API 响应序列化数据、缓存配置对象),这些条件都是自然满足的,开发者可以自动享受到性能提升。

结论

通过从高层逻辑到底层内存与字符处理的全方位重构,我们在 JetStream2 的 json-stringify-inspector 基准测试中实现了超过 2 倍的性能提升。下图展示了在不同平台上的结果。这些优化从 V8 版本 13.8(Chrome 138)开始可用。

image.png

🧐Text-Well:我做了一个能帮你进行多视角内容评审的 AI 工具

作者 oil欧哟
2025年8月15日 14:42

前言

Hello 大家好,我是一名在工作时需要写大量文档的前端开发者和产品经理。我想和大家聊聊一个可能很多人都遇到过的场景:写完东西后,总觉得不放心。

无论是技术文章、产品文档,还是普通的邮件,我们都希望它看起来专业、清晰,没有那些掉价的低级错误。正是基于这个最朴素的想法,我利用业余时间开发了一个AI写作辅助工具——Text-Well。它是一个网页应用,希望能帮你更自信地完成每一次书写。名字叫 Text-Well

作为一个开发者,我在实现了一些比较复杂的功能,或者解决了一些网上缺少资料的 Bug 时,会将开发过程或者解决思路记录下来,分享在一些技术社区中,例如掘金或者 CSDN。除了技术文章之外,我在工作时还经常需要做产品需求设计文档(PRD),写产品的发布文档、使用文档之类的,经常需要与文字打交道。

自从 AI 出现后,我每一次写的文章我都会先用 AI 过一遍基础性错误,比如错别字、语法问题、或者语句不通顺的问题,让文章整体不会出现很掉价的基础问题。但是在我使用 AI 检查文章问题的过程中,我发现了一个很麻烦的点。假设我有一篇比较长的文章想要交给 AI 检查,通常有两种方式:

  1. 第一种是让 AI 告诉我文章的哪个位置有问题,并且告诉我如何修改,这种方式我需要根据 AI 的响应结果自己一个一个去修改,相对比较麻烦,但是比较准确,因为每个改动的地方相当于自己又 review 了一次。如果 AI 乱改或者有一些改的不好的地方,我们可以及时发现,选择自己调整或者不改这个地方。
  2. 第二种方式是让 AI 直接给我们修改后的文本,这种方式最简单,我们不用自己一个一个改, AI 通常也会告诉我们它改了哪个位置,咱们人工 Review 一下最终结果就好了。但这样也存在问题,有的时候我们告诉 AI 帮我们把文章中的描述改的流畅一点,他可能就擅做主张,把一些带有个人风格的段落改的特别有 AI 味儿。

因此我就想,要是我可以类似于像写代码的时候处理代码冲突一样,自己选择是否要应用 AI 给出的建议,是不是这样起来会更方便呢?就是出于这么简单的一个想法,我决定自己来做一个工具给自己检查的时候用。正好当时 Claude Code 热度很高,我一直用的是 cursor,刚好试试这个新工具的深浅。

 

开始动手

于是我和 Claude Code 配合,在两天时间里,我实现了这个工具的第一个检查功能,并为这个工具命名为 Text-Well:

image.png

就像上图中展示的那样,左侧是一个工具栏,右侧是一个编辑器,输入文本后点击开始检查,系统就会让 AI 对文本中的错别字进行检查,并且还会给出原因。

检查完成后,左侧的工具栏中会展示当前问题严重程度的分布,底下会有一个问题项的列表,右侧的编辑器中则是会用不同颜色的高亮展示出当前问题出现问题的位置,当鼠标悬浮在高亮位置时,会有一个小气泡也展示当前的问题,我们可以只看左侧工具栏或者只看右侧编辑器进行操作。

工具栏和编辑器是联动的,不论点击左侧问题项还是点击右侧的高亮位置,都会滚动到对应的位置,很符合直觉。

除此以外,我还实现了一些键盘的快捷键,用来更加高效的切换不同的问题项:

image.png

到这一步,我对整体功能已经挺满意了,比我最开始想象的做的还更多了,其实类似的功能我之前也在 grammarly 用过,但是 grammarly 主要还是在英文的场景使用,Textwell 的话还是略有差异化的,所以我就想着把 Textell 给产品化了,把一些周边功能补齐!

补齐周边功能

由于 Textwell 的产品形态是一个 web 网站,各种认证,后端 API 实现都是熟门熟路,加上 Claude Code 超强的开发能力,我用了不到两天,就把 Textwell 补齐了登录注册,额度限制,这些基础的用户模块,做了一个简单的额度查看,并且给未登录的用户也增加了体验额度。毕竟功能的实现是需要消耗 AI token 的,我作为个人开发者,也只能先力所能及的提供一些免费额度了,模型也只能选择一些性价比比较高的,没法用上最顶级的大模型。

image.png

除了用户模块,还做了国际化,支持中文和英文,后续补上了西语和法语(现在又因为维护太繁琐移除掉了)。 

image.png

文本除了把内容粘贴进去,也可以直接拖拽文件到编辑器区域,像是常见的 markdown 、docs、pdf、txt 这些格式都支持的。

image.png

基础功能补齐后我就直接把网站上线了,域名就是 text-well.com。运气还挺好的,可以选到一个很合适的域名。

首页设计

虽说只是一个很简单的工具,但是作为一个产品,我还是想把它的设计理念和使用的方式快速的告诉大家,也为了更好的宣传,我决定为它设计一个首页!

由于 Textwell 最开始功能真的很简单,我对于它的首页怎么做没有头绪,没有用户使用反馈,没有数据支撑,我也不想瞎编,又想把网站做的好看,关于如何设计就纠结了很久...

后来我想到我可以在首页很直观的展示系统内是如何进行操作的,然后把我的一些设计初衷通过 UI 的形式展示出来,再加点 FAQ 模块丰富一下页面,内容应该也还可以支撑一个完整的网站设计,于是我就开始动手喽~

image.png

image.png

image.png

image.png 最早期的时候,网站就是以上的几个模块组成的,首屏是左右布局,右侧是一个自动执行的动画,我将系统里的核心操作模拟给用户看,这样大家一看到首屏就知道整个系统的效果。第二屏是一个理念的传达,告诉大家我开发这个工具的初衷,以及用 Textwell 和直接使用 AI 对话进行文本优化的区别。第三屏是 FAQ,最后加了一个底部栏。

Textwell 的 Logo 还做了一个简单的动画效果,想传达的意思就是让文本质量更好“一点”,所以第一个字母 T 的右上角有一个橙色的小圆点。

image.png

继续拓展

在网站上线后,我去阮一峰老师的 Github 去投稿了一下周刊,觉得自己用心做的东西还是有机会被发现的,把“孩子”养大,总想让更多人看看。  抱着试一试的心态,我去阮一峰老师的每周分享仓库里提了个issue,推荐了Text-Well。说实话,当时没抱太大希望,毕竟优秀的个人项目太多了。

直到周五,当我看到新一期的周刊发布,Text-Well 赫然出现在上面时,那种被认可的喜悦感是难以言喻的,文章在周刊的第 359 期 www.ruanyifeng.com/blog/2025/0…

image.png

虽然只有很简单的一个介绍,但是当天的访问量还是高了很多的,而且得益于我把这张图做成正方形而不是横向的完整屏幕,而阮一峰老师博客里面的图片都是宽度占满的,高度按着原始比例撑开的,导致我这张图占了很大篇幅,现在很庆幸自己没有随便截个图敷衍了事。

有了第一批用户还是很开心的,后续我就继续拓展功能,并且把一些犄角旮旯的小体验持续优化。基于最基础的语法/错别字/标点符号检查,我还拓展了一些其他检查方式:

image.png

在把基础的检查功能完善后,我又有了一个新的想法,就是做一个模拟评审功能~ 因为不论是什么内容,最终都是要传达给其他人看的,如果只有一个检查功能,只能保障文本的下限,那么如果要提升文本的整体质量,提前了解别人看到文章后的想法应该是一个不错的方式。我自己作为一个产品经理,在写好产品需求文档后进行评审时就经常被毒打,如果能够提前被毒打一番,可能在面向真正的人进行传达时会有更加充分的准备!

既然我已经开发了这样一个文本优化工具,我觉得这个产品形态很适合去再增加一个评审功能,因为我的 AI 检查功能,左侧工具栏展示的是一个问题项,如果是 AI 评审功能的话,就将左侧的问题项参考飞书文档那样变成一个个的评论就好了。既然实现没那么麻烦,又是我自己觉得有意义的功能,就开始动手做了。

实现模拟评审功能

说干就干,我先用一天时间把一个基础的评审逻辑给设计好,包括整体的评估机制,评审人的背景、世界观,Prompt 的设计,大模型的选择,以及如何交互等等。在方案设计的时候我通常会使用 Gemini 来辅助我思考并整理文档。这里偏题一下,Gemini 2.5 Pro 的文本能力和理解能力真的很强,也经常给予我一些鼓励,在我开发的过程中给了我很多的帮助。

最终实现的效果是这样的:

Text-Well AI 评审

image.png

在左侧的工具栏中,我增加了一个标签栏,可以用于切换检查模式和评审模式,在评审模式中,第一步我们需要选择评审人:

image.png

最初我是只设计了智能匹配功能,智能匹配会检查你的文档类型。比如说你想评审一篇技术文档,它就会给你匹配你的目标读者,可能会有技术小白,可能会有技术大牛。除了目标读者,还会有和你同领域的专家,可能有技术社区的运营这一类的。每一个评审人他们都有自己的世界观,有自己的评审标准,而且他们的关注点各有不同,你不用担心三个人的评论同质化非常严重。

除了智能评审, 我还内置了一些常用的评审团队,大家也可以在上图中看到,之所以内置一些团队是为了让大家更快的了解评审功能到底可以用在哪些场景,而且内置的这些评审团队的人物背景和关注点是精心设计过的,相较于智能匹配可能没有那么有趣,但是会更加专业一点。大家可以在 Text-Well 评审 查看所有的评审团队以及他们对应的场景。

评审人完成评审后,会给你一个整体评论,还有针对每一句话的详细评论,展示效果和检查模式差不多时一致的,只是高亮的颜色会有所不同,不同的评审人会有不同高亮的颜色,高亮的颜色和他们头像右上角的那个小圆点的颜色是对应的。

image.png

如果你的同一个位置被多个人评论了,那么高亮位置就会变成渐变色。有的时候看了评审人的评论,我感觉我自己才是 AI 🥹

image.png

写到这里,你可能会问,这个“模拟评审”功能,和直接把文章丢给AI,让它扮演一个角色来提意见,有什么本质区别呢?

一开始,我也在思考这个问题。但随着我自己不断地使用和打磨,我发现区别是蛮大的。它体现在 “结构化” 和  “视角化” 这两个核心点上。

1. 结构化的反馈,而不只是观点

直接和 AI 对话,你得到的是一段连续的、观点性的文字。而 Text-Well 的评审功能,把反馈拆解成了“整体评价(Overall)”和“逐行评论(Comments)”。更重要的是,每一条评论都被结构化地呈现在原文的对应位置。

这意味着你不再需要在大段的 AI 回复中,去费力地找它到底在评论哪一句话。所有的反馈都像Code Review 一样,清晰地展示在原文上。你可以逐条处理、采纳、或是忽略。这种掌控感和效率,是单纯的 AI 回答没法比的。

2. 视角化的冲突,而不只是角色扮演

这可能是这个功能最核心的价值所在。我为 AI 评审员设计的 Prompt,不仅仅是让他们“扮演”某个角色,而是强迫他们“坚守”一个独特的、甚至有些偏执的视角,并刻意让他们在某些方面产生冲突。

这种“冲突”不是 Bug,而是 Feature。它强迫我们这些写作者,去思考那个最重要但最难的问题:我到底要为谁写作?我最想达成的目标是什么?

它没有给我一个“标准答案”,但它给了我一个更高维度的决策框架。这让我意识到,我做的不仅仅是一个工具,更像是一个“写作决策模拟器”。

未来的规划与思考

当然,Text-Well现在还很稚嫩。

作为一个个人项目,我能投入的资源有限,无法用上最顶级的、最昂贵的 AI 模型。有时AI评审员的反馈可能还不够深刻,甚至会说一些“正确的废话”。但我相信,优秀的产品形态和对用户工作流的深度理解,可以在一定程度上弥补模型本身的不足,而且模型后面肯定会越来越好,我要做的就是换个模型就好了,但是产品形态和 UI 的易用是现在我认真打磨的。

写这篇文章,一方面是想和大家分享我做这个小产品的历程和思考;另一方面,也是最重要的一方面,是希望能听到来自大家的声音。

我深知自己作为一个开发者的局限性,很多时候会陷入自己的世界里。所以,我非常需要来自不同领域、不同背景的你的反馈。任何想法,无论大小,对我来说都至关重要。它们是我把这个小小的side project继续做下去的最大动力。

如果你对 Text-Well 感兴趣,欢迎访问它的官网 text-well.com 体验。

感谢你耐心读到这里。希望我的分享,能给你带来一点点启发。也期待在评论区,看到你的想法。最后给大家看看我现在这篇文章评审人给我的总结:

image.png

《会聊天的文件筐:用 Next.js 打造“图音双绝”的上传组件》

作者 LeonGao
2025年8月15日 10:02

开场三句话

  1. 用户说:“发张图。”
  2. 用户说:“发段语音。”
  3. 你说:“稍等,我让浏览器先开个 AI 小灶。”

今天,我们要写一个聊天 UI 的上传组件,它既能识图又能辨音,还要保持界面优雅,像一位会魔法的管家。
(配图:一只端着托盘的小机器人,托盘上躺着一张猫咪照片和一只麦克风)


一、需求拆解:到底要上传什么?

类型 浏览器能做什么 我们要做什么
图片 <input type="file" accept="image/*"> 预览、压缩、OCR/打标签
音频 <input type="file" accept="audio/*"> or MediaRecorder 波形预览、转文字、情绪分析

一句话:浏览器负责“拿”,我们负责“看/听”


二、技术地图:从点击到 AI 的大脑

┌────────────┐     ┌──────────────┐     ┌──────────┐
│ 用户点击   │──→──│ 前端预览     │──→──│ 后端识别  │
│ input file │     │ canvas /    │     │ OCR /    │
└────────────┘     │ Web Audio   │     │ Whisper  │
                   └──────────────┘     └──────────┘

三、前端实现:React + TypeScript(Next.js 亦可)

3.1 组件骨架:一个 Hook 统治所有上传

// hooks/useUploader.ts
import { useState, useCallback } from 'react';

type FileType = 'image' | 'audio';

export function useUploader() {
  const [file, setFile] = useState<File | null>(null);
  const [preview, setPreview] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  const handleChange = useCallback(
    (type: FileType) => (e: React.ChangeEvent<HTMLInputElement>) => {
      const f = e.target.files?.[0];
      if (!f) return;
      setFile(f);
      setPreview(URL.createObjectURL(f));
      setLoading(true);
      // ⭐ 交给识别函数
      recognize(type, f).then((result) => {
        console.log('识别结果', result);
        setLoading(false);
      });
    },
    []
  );

  return { file, preview, loading, handleChange };
}

3.2 图片识别:浏览器端就能 OCR(tesseract.js)

// utils/recognize.ts
import Tesseract from 'tesseract.js';

export async function recognize(type: 'image' | 'audio', file: File) {
  if (type === 'image') {
    const { data: { text } } = await Tesseract.recognize(file, 'eng+chi_sim');
    return { text };
  }
  if (type === 'audio') {
    // 音频先上传,后端 Whisper 转文字,下文细讲
    const form = new FormData();
    form.append('audio', file);
    const res = await fetch('/api/transcribe', { method: 'POST', body: form });
    return res.json();
  }
}

浏览器里跑 OCR 就像让小学生在操场上背圆周率——能背,但跑不快。
所以我们只在小图离线场景用 tesseract.js,大图还是走后端 GPU。


3.3 音频录制:边录边传,体验拉满

// components/AudioRecorder.tsx
import { useState } from 'react';

export default function AudioRecorder({ onDone }: { onDone: (f: File) => void }) {
  const [recording, setRecording] = useState(false);
  const mediaRef = useRef<MediaRecorder | null>(null);

  const start = async () => {
    const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
    const mr = new MediaRecorder(stream, { mimeType: 'audio/webm' });
    const chunks: BlobPart[] = [];
    mr.ondataavailable = (e) => chunks.push(e.data);
    mr.onstop = () => {
      const blob = new Blob(chunks, { type: 'audio/webm' });
      onDone(new File([blob], 'speech.webm'));
    };
    mr.start();
    mediaRef.current = mr;
    setRecording(true);
  };

  const stop = () => {
    mediaRef.current?.stop();
    setRecording(false);
  };

  return (
    <>
      <button onClick={recording ? stop : start}>
        {recording ? '⏹️ 停止' : '🎤 录音'}
      </button>
    </>
  );
}

浏览器录音使用的是 MediaDevices.getUserMedia → MediaRecorder → Blob 这条“黄金管道”。
数据在内存里是 PCM 原始波形,压缩成 webm/opus 后才上传,节省 90% 流量。


四、后端识别:GPU 才是第一生产力

4.1 图片:OCR + 打标签(Python 示例,Next.js API Route 可调用)

# api/ocr.py  (FastAPI 伪代码)
from fastapi import UploadFile
import pytesseract, torch, timm

@app.post("/ocr")
async def ocr(file: UploadFile):
    img = await file.read()
    text = pytesseract.image_to_string(img, lang='eng+chi_sim')
    labels = model(img)  # timm 预训练 ResNet
    return {"text": text, "labels": labels}

4.2 音频:用 Whisper 转文字(OpenAI 开源版)

# api/transcribe.py
import whisper, tempfile, os

model = whisper.load_model("base")

@app.post("/transcribe")
async def transcribe(file: UploadFile):
    with tempfile.NamedTemporaryFile(delete=False, suffix=".webm") as tmp:
        tmp.write(await file.read())
        tmp.flush()
        result = model.transcribe(tmp.name, language='zh')
        os.unlink(tmp.name)
        return {"text": result["text"]}

Whisper 的「魔法」:把 30 秒音频切成 mel 频谱 → Transformer 编码 → 解码文字。
在 A100 上,转 30 秒音频只需 100 ms,比你泡咖啡还快。


五、前端 UI:让文件像聊天泡泡一样优雅

┌────────────────────────────┐
│  用户 A                   │
│  [猫咪照片预览]           │
│  🖼️ 识别:一只橘猫在打盹 │
└────────────────────────────┘

实现思路:

  1. 上传成功 → 本地先渲染占位泡泡(带 spinner)。
  2. 后端返回结果 → 更新泡泡内容(图片 + 文字 / 语音 + 文字)。
  3. 失败 → 泡泡变红色,重试按钮出现。

六、性能 & 体验小贴士

问题 解法
大图片 10 MB+ 浏览器 canvas.toBlob(file, 'image/jpeg', 0.8) 压缩
音频长 5 min+ 分片上传 + 后端流式转写
弱网 上传前存 IndexedDB,网络恢复后重试
隐私 敏感图片走本地 OCR,不上传

七、彩蛋:一行代码让上传支持拖拽

<div
  onDrop={(e) => {
    e.preventDefault();
    const f = e.dataTransfer.files[0];
    // 复用前面 useUploader 的逻辑
  }}
  onDragOver={(e) => e.preventDefault()}
  className="border-2 border-dashed border-gray-400 rounded p-8"
>
  📂 把文件扔进来
</div>

八、结语:上传的尽头,是理解

当 AI 把猫咪照片识别成“一只橘猫在打盹”,把语音转成“今晚吃什么?”时,
上传组件就不再是冷冰冰的 <input>,而是人类与算法握手言欢的桥梁

愿你写的每一个上传按钮,都能把比特变成诗。
祝你编码愉快,文件永不 413!

性能提升60%:前端性能优化终极指南

作者 石小石Orz
2025年8月15日 09:50

hi,我是石小石~


性能优化一直是前端绕不开的话题。页面加载慢、交互卡顿,不仅影响用户体验,还可能直接流失用户。本文将从加载、运行、构建、网络四个环节,系统梳理前端能想到的各种性能优化手段,帮助我们尽可能的提升前端页面性能。

加载性能优化:更快呈现首屏

加载阶段的目标是尽快把可见内容展示给用户,减少白屏和首屏等待时间。

资源压缩与代码混淆

资源压缩的核心目标就是——让浏览器传输和解析的文件尽可能小,这样加载速度自然就快了。

  • 代码压缩:通过移除 HTML、CSS、JS 中的空格、注释,并缩短变量名来减小文件体积。打包阶段可借助 ViteWebpack 等构建工具内置或插件化的压缩方案如 Terser自动完成。
  • 图片优化:优先使用 WebPAVIF 等高压缩比格式,并通过 imagemintinypng 等工具进一步压缩体积;对于大量小图标,可使用 CSS Sprites 合并成一张精灵图,减少 HTTP 请求数量。

代码分割(Code Splitting)

代码分割就是把项目代码按功能或页面拆成多个小文件,用户访问时只加载当前需要的部分,如路由懒加载:

React

import React, { Suspense } from 'react';
const Chart = React.lazy(() => import('./Chart'));

<Suspense fallback={<div>Loading...</div>}>
  <Chart />
</Suspense>

Vue

const routes = [
  { path: '/', component: () => import('@/views/Home.vue') },
  { path: '/about', component: () => import('@/views/About.vue') }
];

Tree Shaking摇树优化

Tree Shaking 是一种在打包阶段自动删除未使用代码的优化技术,能让最终文件更小、加载更快。它依赖 ES Module (import/export) 的静态结构来分析哪些代码实际被用到,没用到的就会被“摇掉”。

Vite(基于 Rollup)和大多数现代构建工具里,Tree Shaking 在生产构建时是默认开启的,只需要:

  • 使用 ES Module 语法,而不是 require
  • 避免全局副作用代码,或在 package.json 中声明:
{ "sideEffects": false }

CDN 加速

CDN 加速就是把网站的静态资源(JS、CSS、图片、字体等)分发到全球多个节点,让用户就近从最近的服务器获取资源,从而减少网络延迟、提高加载速度。

项目中,可以将静态资源(JS、CSS、图片、字体)部署到 阿里云或腾讯云等CDN,让用户从最近的节点获取资源。

减少渲染阻塞

渲染阻塞是指浏览器在解析 HTML 时,遇到某些资源(如 CSS、同步 JS)会暂停页面渲染,直到这些资源加载并解析完成,这会直接延迟首屏显示时间。减少渲染阻塞的核心,就是让关键内容先呈现,非关键资源延后或异步加载。

  • CSS 优化:将首屏必需的 CSS 抽取为关键 CSS直接内联到 HTML,其余样式文件通过 media 属性或延迟加载方式引入。
<link rel="stylesheet" href="style.css" media="print" onload="this.media='all'">
  • JS 优化:对非首屏必须执行的 JS 使用 deferasync,避免阻塞 HTML 解析。
<script src="app.js" defer></script>
<script src="analytics.js" async></script>
  • 字体加载优化:使用 font-display: swap,让文字在字体加载前先用系统字体渲染,避免长时间空白。
@font-face {
  font-family: 'MyFont';
  src: url('myfont.woff2') format('woff2');
  font-display: swap;
}

预加载与预渲染

预加载与预渲染的目标是提前把可能需要的资源或页面准备好,让用户在点击或访问时几乎无等待。

  • preload:提前加载关键资源(如字体、CSS、首屏图片),确保它们在渲染时已经准备就绪。
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
  • prefetch:利用浏览器空闲时间加载未来可能使用的资源(如下一页的 JS 文件),等真正用到时直接从缓存读取。
<link rel="prefetch" href="/next-page.js">

SSR 与 SSG

这两种方式都是在用户请求到达前,就把页面 HTML 准备好,从而减少白屏时间。

  • SSR(Server-Side Rendering) :由服务端实时生成 HTML 并返回给浏览器,用户首屏几乎立刻可见,后续由前端接管交互。适合需要动态数据的场景,比如电商、资讯类网站。react中我们可以借助next.js实现这一需求。
  • SSG(Static Site Generation) :在构建阶段一次性生成所有静态 HTML 文件,用户访问时直接从服务器或 CDN 获取,速度极快且可离线缓存。适合内容更新不频繁的站点,比如博客、文档站。React 中可以使用 Astro 或 Next.js SSG 模式,Vue 生态中则有 VitePress 和 VuePress 等优秀工具。

Gzip/Brotli 压缩

在服务器端启用 Gzip 或 Brotli 压缩,可以显著减小传输文件的体积,尤其是 JS、CSS、HTML 等文本类资源,通常能减少 60%~80% 的网络传输量。开启Gzip压缩,只需要在vite或webapck中开启配置,并在nginx中配置即可。

依赖共享

在多页面应用(MPA)或微前端场景中,把公共依赖(如 React、Vue、Lodash 等)提取出来,通过 浏览器缓存 或 CDN 共享加载,可以避免重复下载同一依赖,减少首屏加载体积。

  • Webpack 中可通过 SplitChunksPlugin 配置 vendor 包。
  • Vite 中可利用 optimizeDepsmanualChunks 手动拆分依赖。

运行阶段优化

运行阶段的目标是让页面在交互过程中保持流畅不卡顿,通过优化渲染策略和代码,可以有效减少性能浪费。

避免不必要的重绘与回流

  • 回流(Reflow) :当元素的大小、位置、布局发生变化时,浏览器需要重新计算布局,并重新渲染页面。
  • 重绘(Repaint) :当元素外观(如颜色、背景)改变但布局没变时,只需要重新渲染外观

回流是性能杀手,它会引发页面重新计算布局,尤其是在复杂 DOM 结构下,代价非常高。重绘成本低一些,但频繁发生也会卡顿。

虚拟滚动/列表

当你要渲染一个 1 万行的长表格,如果一次性全渲染,浏览器直接卡到怀疑人生。虚拟列表的思路其实很简单只渲染当前可见区域的内容,滚动时替换 DOM 节点,保证 DOM 数量稳定。Vue和React也有很多开源库可以使用。

防抖与节流

防抖节流 都是用来优化高频事件触发的技术,但原理和应用场景不同:

  • 防抖(Debounce)
    在事件频繁触发时,只在最后一次触发后 等待一段时间 才执行回调。如果在等待时间内事件又被触发,就重新计时。

适用场景:搜索框输入、窗口大小调整(resize)、表单实时验证等。

  • 节流(Throttle)
    在事件频繁触发时,保证 固定时间间隔 内只执行一次回调,即使事件被多次触发也不会更快执行。

适用场景:滚动(scroll)、拖拽(drag)、鼠标移动(mousemove)等。

图片懒加载

在网页加载时,只加载首屏或当前可见区域内的图片,其他图片等用户滚动到可见区域时再加载。这种方式称为图片懒加载,它有以下优点:

  • 减少首屏加载时间,提升页面打开速度
  • 降低首屏网络请求数量,节省带宽
  • 减轻服务器瞬时压力

在HTML 中,原生懒加载写法如下:

<img src="image.jpg" loading="lazy" alt="example" />

当然,社区也有对应的开源库,如React的react-lazyload,vue的vue-lazyload

Web Worker 分担计算压力

JavaScript 是单线程运行的,如果在主线程执行复杂计算(如文件解析、加密、压缩),会阻塞 UI 渲染。
Web Worker 允许我们在浏览器中开启一个独立的线程来执行 JavaScript 代码,把耗时、计算量大的任务放在这个线程中执行。

它的常见用途包括:

  • 大量数据计算(加密、解密、数据分析)
  • 图片、视频的压缩与处理
  • 大文件解析(CSV、JSON)
  • 实时数据流处理

注意,它依旧不是传统意义上的多线程。

使用案例:

worker.js

self.onmessage = e => {
  let sum = 0;
  for (let i = 0; i < e.data; i++) sum += i;
  self.postMessage(sum);
};

📄 App.jsx

import React from 'react';

export default function App() {
  const runWorker = () => {
    const worker = new Worker(new URL('./worker.js', import.meta.url));
    worker.postMessage(1e8); // 计算 1 亿次
    worker.onmessage = e => {
      alert(`结果: ${e.data}`);
      worker.terminate();
    };
  };

  return <button onClick={runWorker}>开始计算</button>;
}

内存泄漏监控与优化

内存泄漏会让网页在长时间运行后越来越卡,最终崩溃。常见原因有:

  • 未清理的定时器 / 事件监听
  • 被引用的 DOM 节点未释放
  • 闭包中保留了不必要的变量

优化手段:

  • 使用 Chrome Performance 工具分析内存快照
  • 组件卸载时(React useEffect 返回清理函数 / Vue beforeUnmount)释放资源

构建优化

压缩与混淆

在 React/Vue 等前端项目里,压缩与混淆基本都是构建工具自动完成的,你几乎不需要手动去配置。

第三方库优化

分包策略

分包策略是指将打包后的代码分成多个 bundle,避免一次性加载所有资源,提高首屏速度。常见策略如按路由分包按组件分包按依赖分包。它能延迟非必要资源加载,提升首屏加载速度。

被分包的依赖,如第三方库,打包后hash值不变,重新部署会使用缓存文件,也能提升首屏加载速度。

在Vite中,使用它也很简单:

// Vite Rollup 分包配置
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          react: ['react', 'react-dom'], // 单独打包 react
          lodash: ['lodash'] // lodash 单独打包
        }
      }
    }
  }
}

懒加载第三方资源

这种方式类似路由懒加载,延迟非关键资源的下载,按需加载第三方库或模块,避免在初始加载时引入全部依赖,减轻首页负担。

// React 动态导入
const Chart = React.lazy(() => import('./Chart'));

export default function Page() {
  return (
    <React.Suspense fallback={<div>Loading...</div>}>
      <Chart />
    </React.Suspense>
  );
}

依赖排除

构建时将某些依赖排除,不打包进 bundle,而是从 CDN 加载。它可以有效减少 bundle 体积,利用缓存和边缘节点加速首页访问。

但并非依赖排除的越多越好,js请求也需要网络。

如:

// Vite 配置
export default {
  build: {
    rollupOptions: {
      external: ['vue'], // 排除 vue
    }
  }
}

网络优化

TCP 预连接

提前与目标服务器建立 TCP + TLS 连接,减少请求延迟。

<link rel="preconnect" href="https://cdn.example.com">

DNS 预解析

提前解析域名,减少 DNS 查询延迟。

<link rel="dns-prefetch" href="//cdn.example.com">

请求合并

对多个重复请求进行合并处理,前端可以通过防抖或判断接口状态实现。

<script setup>
import { ref } from 'vue';
import axios from 'axios';

const timeoutId = ref(null);

function debounce(fn, delay) {
  return function(...args) {
    if (timeoutId.value) clearTimeout(timeoutId.value);
    timeoutId.value = setTimeout(() => {
      fn(...args);
    }, delay);
  };
}

function fetchData() {
  axios.get('http://api/gcshi)  // 使用示例API
    .then(response => {
      console.log(response.data);
    })
}

const debouncedFetchData = debounce(fetchData, 300);
</script>

如何查看网页性能

浏览器内置工具

网络面板

查看网络请求,查看所有请求的耗时、大小、缓存命中情况,找出大文件、重复请求、慢响应资源。

性能面板

  • FCP(First Contentful Paint,首次内容绘制时间)
  • LCP(Largest Contentful Paint,最大内容绘制时间)
  • CLS(Cumulative Layout Shift,累计布局偏移)
  • TTI(Time to Interactive,可交互时间)

Lighthouse面板

自动化检测性能、可访问性、SEO 等综合评分,并给出优化建议。

第三方平台工具

WebPageTest

  • URL:www.webpagetest.org/
  • 模拟不同地区、网络条件下的页面加载,查看瀑布图、渲染时间线。

GTmetrix

  • URL:gtmetrix.com/
  • 类似 Lighthouse,但报告更细,可看首屏截图、视频回放,方便对比优化前后效果。

性能监控与上报

  • Sentry:可采集性能、JS 错误、慢接口
  • 阿里云 ARMS / 字节火山监控:支持前端 + 后端链路追踪
  • 自建埋点系统:结合 Performance API,将指标上报到日志系统

总结

本文梳理了一些比较常见的前端可行性能优化方案,有遗漏的地方,欢迎大家补充。

构建闪电级i18n替代方案:我为何抛弃i18next选择原生JavaScript

作者 CF14年老兵
2025年8月15日 09:10

11.webp

作为长期奋战在前线的前端开发者,我曾深陷国际化(i18n)的性能泥潭。今天分享我如何用原生JavaScript构建高性能i18n方案,将项目性能提升300%的实战经验。


我的性能噩梦:现代i18n之痛

当项目国际化需求增长到3000+翻译字段时,我亲历的性能灾难:

| 问题类型        | 具体表现                          | 我的痛苦指数 |
|-----------------|-----------------------------------|--------------|
| 编译时间        | 每1000个翻译字段增加1秒tsc编译时间 | 😫😫😫😫      |
| IDE响应         | 类型提示延迟300ms+                | 😫😫😫       |
| 包体积          | i18next基础库41.6kB(13.2kB gzip)  | 😫😫😫😫      |
| 运行时解析      | DSL解析成为性能瓶颈               | 😫😫😫😫😫    |

真实项目中的血泪教训:

"我们不得不完全移除i18n类型检查,因为CI在~3000个翻译时内存溢出" - 某生产环境开发者
"移除i18next后SSR性能提升3倍,功能毫无损失" - 性能优化工程师

我的顿悟时刻:现代浏览器原生国际化API已足够强大,何必引入重型库?


我的技术选型依据

为什么选择原生方案? 经过深度技术评估,我发现:

// 现代浏览器原生能力已覆盖核心需求
const intlFeatures = {
  number: Intl.NumberFormat,       // 数字/货币/单位格式化
  date: Intl.DateTimeFormat,       // 日期时间处理
  plural: Intl.PluralRules,        // 复数规则处理
  relative: Intl.RelativeTimeFormat // "2天前"类相对时间
};

原生方案三大杀手锏:

  1. 零成本:浏览器内置,无额外依赖
  2. 极致性能:比任何第三方库都快
  3. Tree Shaking友好:只打包实际使用功能

我的五文件极简方案

耗时两周打磨出这套高性能i18n架构:

1. 智能语言检测器 (lang.ts)

import { cookie } from "./cookie";

// 精心设计的语言白名单
const LANG_MAP = { en: "English", ru: "Русский" } as const;
type LangType = keyof typeof LANG_MAP;

// 我的优先检测策略:cookie > navigator
export const currentLang = () => {
  const savedLang = cookie.get("lang");
  if (savedLang && savedLang in LANG_MAP) return savedLang as LangType;
  
  const browserLang = navigator.language.split("-")[0];
  return browserLang in LANG_MAP ? browserLang as LangType : "en";
};

// 原生格式化器 - 零开销!
export const temperatureFormatter = new Intl.NumberFormat(currentLang(), {
  style: "unit",
  unit: "celsius",
  unitDisplay: "narrow"
});

2. 按需加载引擎 (loader.ts)

import { currentLang } from "./lang";

// 动态导入策略:仅加载所需语言
const loadTranslations = async () => {
  const lang = currentLang();
  const module = await import(`./locales/${lang}.ts`);
  return module.vocab;
};

// 我的单例访问器
export const t = await loadTranslations();

3. 类型安全词库 (en.ts)

import { temperatureFormatter } from "./lang";

export default {
  welcome: "Hello, Developer!",
  // 函数式翻译项
  currentTemp: (value: number) => 
    `Current temperature: ${temperatureFormatter.format(value)}`,
  
  // 高级复数处理
  unreadMessages: (count: number) => {
    if (count === 0) return "No new messages";
    if (count === 1) return "1 new message";
    return `${count} new messages`;
  }
};

4. 轻量Cookie工具 (cookie.ts)

// 我的极简实现 - 仅需15行代码
export const cookie = {
  get(name: string): string | undefined {
    return document.cookie
      .split('; ')
      .find(row => row.startsWith(`${name}=`))
      ?.split('=')[1];
  },
  
  set(name: string, value: string, days = 365) {
    const date = new Date();
    date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
    document.cookie = `${name}=${value};expires=${date.toUTCString()};path=/`;
  }
};

5. 组件集成示范 (Component.tsx)

import { useState } from 'react';
import { currentLang, changeLang } from './lang';
import { t } from './loader';

export default function LanguageSwitcher() {
  const [temp, setTemp] = useState(25);

  return (
    <div className="p-4 border rounded-lg">
      <h1 className="text-xl font-bold">{t.welcome}</h1>
      
      <div className="my-4 p-2 bg-gray-100 rounded">
        {t.currentTemp(temp)}
      </div>
      
      <div className="flex items-center gap-2">
        <span>Language:</span>
        <select 
          value={currentLang()} 
          onChange={e => changeLang(e.target.value)}
          className="border px-2 py-1 rounded"
        >
          {Object.entries(LANG_MAP).map(([code, name]) => (
            <option key={code} value={code}>{name}</option>
          ))}
        </select>
      </div>
    </div>
  );
}

我的方案核心优势

| 特性                | 传统方案          | 我的方案         | 优势指数 |
|---------------------|------------------|------------------|----------|
| 类型安全            | 复杂类型映射      | 自动类型推断     | ⭐⭐⭐⭐⭐   |
| 运行时开销          | 41.6kB基础库     | **0kB**          | ⭐⭐⭐⭐⭐   |
| 加载策略            | 全量加载          | 按需加载         | ⭐⭐⭐⭐    |
| 格式化能力          | 依赖插件          | 原生API          | ⭐⭐⭐⭐    |
| 框架兼容性          | 需要适配器        | 直接使用         | ⭐⭐⭐⭐⭐   |
| SSR支持             | 复杂配置          | 开箱即用         | ⭐⭐⭐⭐    |

高级技巧:智能复数处理

我设计的可扩展复数方案:

// plural.ts
export const createPluralizer = (locale: string) => {
  const rules = new Intl.PluralRules(locale);
  
  return (config: Record<string, string>) => 
    (count: number) => {
      const type = rules.select(count);
      return config[type].replace("{count}", count.toString());
    };
};

// 使用示例 (ru.ts)
import { createPluralizer } from './plural';

const pluralize = createPluralizer('ru');

export default {
  apples: pluralize({
    one: "{count} яблоко",
    few: "{count} яблока",
    many: "{count} яблок"
  })
};

// 组件中调用
t.apples(1);  // "1 яблоко"
t.apples(3);  // "3 яблока"
t.apples(10); // "10 яблок"

SSR优化方案

针对服务端渲染的特殊处理:

// server/context.ts
import { AsyncLocalStorage } from 'async_hooks';

// 我的请求级上下文方案
export const i18nContext = new AsyncLocalStorage<string>();

// server/middleware.ts
import { i18nContext } from './context';

app.use((req, res, next) => {
  const lang = detectLanguage(req); // 自定义检测逻辑
  i18nContext.run(lang, () => next());
});

// 服务端组件
import { i18nContext } from '../server/context';

const getTranslations = async () => {
  const lang = i18nContext.getStore() || 'en';
  return (await import(`../locales/${lang}.ts`)).default;
};

我的实施建议

适用场景:

  • 性能敏感型应用
  • 轻量级项目
  • 开发者主导的国际化需求

不适合场景:

  • 需要非技术人员维护翻译
  • 超大型多语言项目(5000+字段)

折中方案:

graph LR
    A[外部CMS] -->|构建时| B(生成JSON)
    B --> C[转换为TS模块]
    C --> D[集成到方案]

迁移成果

实施此方案后,我的项目获得显著提升:

  • 构建时间减少68%:从42秒降至13秒
  • 包体积缩小175kB:主包从210kB降至35kB
  • TTI(交互就绪时间)提升3倍:1.2秒 → 0.4秒
  • 内存占用下降40%:SSR服务更稳定

"性能优化不是减少功能,而是更聪明地实现" - 我的前端哲学

Java中如何判断两个值是否相等?一篇文章讲透比较机制

作者 BestAns
2025年8月15日 08:56

引言:为什么"相等"判断如此重要?

在Java开发中,判断两个值是否相等是最基础也最容易出错的操作之一。无论是数据校验、集合操作还是业务逻辑判断,都离不开"相等性"比较。但Java中的"=="运算符与equals()方法常常让开发者混淆,甚至资深工程师也可能在复杂场景中踩坑。本文将系统梳理Java中的相等判断机制,帮你彻底掌握各种场景下的正确比较方式。

一、基本数据类型的比较:==运算符的正确使用

1.1 基本类型比较的本质

Java中的8种基本数据类型(byte, short, int, long, float, double, char, boolean)比较时,必须使用==运算符。这是因为基本类型变量直接存储值,而非引用,==比较的是它们的实际数值

int a = 10;
int b = 10;
System.out.println(a == b); // true,直接比较数值

double c = 3.14;
double d = 3.14;
System.out.println(c == d); // true

1.2 浮点类型比较的注意事项

⚠️ 注意:float和double类型由于二进制存储特性,存在精度问题,绝对不能直接使用==比较

float f1 = 0.1f;
double d1 = 0.1;
double d2 = (double)f1;
System.out.println(f1 == d1); // false!精度损失导致不相等
System.out.println(d1 == d2); // false!同样不相等

正确做法:使用误差范围比较

float a = 0.1f;
float b = 0.10000001f;
float epsilon = 0.00001f; // 定义可接受的误差范围
if (Math.abs(a - b) < epsilon) {
    System.out.println("相等"); // 会执行此分支
}

二、引用类型的比较:==与equals()的核心区别

2.1 ==运算符的工作原理

对于引用类型(对象),==比较的是对象在内存中的地址,即判断两个引用是否指向同一个对象实例:

String s1 = new String("hello");
String s2 = new String("hello");
System.out.println(s1 == s2); // false,两个不同的对象实例

2.2 equals()方法的设计初衷

Object类定义的equals()方法默认实现与==相同,但许多类(如String、Integer等)重写了该方法,使其比较对象的内容而非地址

String s1 = new String("hello");
String s2 = new String("hello");
System.out.println(s1.equals(s2)); // true,比较字符串内容

2.3 常见类的equals()实现特点

类名 equals()比较内容 特殊说明
String 字符序列 区分大小写
Integer 数值 自动拆箱后比较
Double 数值 注意精度问题
Date 时间戳 精确到毫秒
List 元素顺序和内容 递归调用元素的equals()

三、特殊类型的比较技巧

3.1 String类的比较陷阱

String有常量池机制,直接赋值与new创建的对象比较有差异:

String s1 = "hello"; // 存储在常量池
String s2 = "hello"; // 复用常量池对象
String s3 = new String("hello"); // 存储在堆内存

System.out.println(s1 == s2); // true,同一常量池对象
System.out.println(s1 == s3); // false,不同内存地址
System.out.println(s1.equals(s3)); // true,内容相同

最佳实践:比较字符串始终使用equals(),并避免空指针异常:

// 安全的比较方式(防止str为null导致NullPointerException)
if ("target".equals(str)) { 
    // 业务逻辑
}

3.2 包装类的比较注意事项

包装类(Integer、Long等)有缓存机制,在特定范围内会复用对象:

Integer i1 = 100; // 自动装箱,使用缓存
Integer i2 = 100;
Integer i3 = new Integer(100);
Integer i4 = 200; // 超过缓存范围
Integer i5 = 200;

System.out.println(i1 == i2); // true(-128~127范围内)
System.out.println(i1 == i3); // false(new创建的对象)
System.out.println(i4 == i5); // false(超出缓存范围)
System.out.println(i1.equals(i3)); // true(比较内容)

四、自定义类的比较实现

4.1 重写equals()的规范

自定义类需要重写equals()以实现内容比较,必须遵循以下规则:

  1. 自反性:x.equals(x)必须返回true
  2. 对称性:x.equals(y)与y.equals(x)结果一致
  3. 传递性:x.equals(y)且y.equals(z),则x.equals(z)
  4. 一致性:多次调用结果应一致
  5. 非空性:x.equals(null)必须返回false

4.2 正确实现equals()和hashCode()

根据Java规范,重写equals()必须同时重写hashCode(),否则会导致HashMap等集合类工作异常:

public class User {
    private String id;
    private String name;
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return Objects.equals(id, user.id) && 
               Objects.equals(name, user.name);
    }
    
    @Override
    public int hashCode() {
        return Objects.hash(id, name);
    }
}

💡 技巧:使用IDE自动生成equals()和hashCode(),避免手动编写错误

五、常见错误案例分析

案例1:null值比较导致空指针异常

String str = null;
if (str.equals("test")) { // 抛出NullPointerException
    // 业务逻辑
}

// 正确写法
if ("test".equals(str)) { // 安全,不会抛出异常
    // 业务逻辑
}

案例2:集合中对象的比较

List<User> userList = new ArrayList<>();
User user = new User("1", "张三");
userList.add(user);

// 如果User没有重写equals(),contains()会使用==比较,返回false
if (userList.contains(new User("1", "张三"))) { 
    System.out.println("存在"); 
}

案例3:使用==比较枚举类型

enum Status { ACTIVE, INACTIVE }

Status s1 = Status.ACTIVE;
Status s2 = Status.ACTIVE;
System.out.println(s1 == s2); // true(枚举是单例,可安全使用==)

⚠️ 注意:枚举比较可以安全使用==,因为枚举值是单例的

六、最佳实践

  1. 基本类型:使用==比较(浮点类型注意精度问题)
  2. 字符串:始终使用equals(),并采用"常量.equals(变量)"避免空指针
  3. 包装类:使用equals()比较,避免缓存机制陷阱
  4. 自定义类:必须同时重写equals()和hashCode()
  5. 集合元素:确保元素类重写了equals()和hashCode()
  6. null安全:使用Objects.equals(a, b)处理可能为null的对象
// JDK7+提供的null安全比较方法
Objects.equals(null, "test"); // false,不会抛出异常
Objects.equals("a", "a"); // true

结语

Java中的"相等"判断看似简单,实则涉及内存模型、类设计和API规范等多方面知识。选择正确的比较方式,不仅是技术要求,更是代码质量的体现

更多精彩文章,欢迎关注我的公众号:前端架构师笔记

Next.js 15 数据获取指南:掌握服务器组件与客户端数据流(七)

2025年8月15日 08:20

为什么“服务器优先”?

在探索具体的数据获取方法之前,我们必须先理解 Next.js App Router 的核心设计理念:服务器优先(Server-First)

在传统的 React 开发(例如 Create React App)中,我们习惯于在浏览器(客户端)加载完页面骨架后,再通过 useEffect 去请求数据。这会导致用户先看到一个加载中的空白状态,然后数据才姗姗来迟,这种体验并不理想,我们称之为“请求瀑布”。

Next.js 彻底改变了这一点。通过引入服务器组件(Server Components),数据获取的默认执行环境从客户端转移到了服务器

这意味着什么?

  1. 更快的初始加载:数据在服务器上获取完成,与页面HTML一同返回给浏览器。用户打开网页时,看到的就是一个内容完整的页面,不再有烦人的加载状态和布局抖动。
  2. 更安全的数据请求:你可以在服务器组件中安全地访问数据库、使用私密的 API 密钥,因为这些代码永远不会泄露到客户端。
  3. 更小的前端包体积:用于数据获取(如 fetch)和相关逻辑都留在了服务器,无需发送到浏览器,减轻了客户端的负担。

简而言之,Next.js 鼓励我们:尽可能地在服务器上获取数据。只有在确实需要交互性、且数据依赖于客户端状态时(例如,根据用户的输入进行搜索),我们才考虑在客户端获取数据。

fetch 的魔法:不仅仅是请求

在 Next.js 中,fetch API 被赋予了"魔法"。它与 React 和 Next.js 的核心渲染、缓存机制深度集成,提供了强大的请求去重和缓存控制能力。

Next.js 15 的重要变化:默认不缓存

从 Next.js 15 开始,fetch 响应默认不再被缓存。这是一个重大的行为变化,意味着:

  • 默认行为:每次请求都会从远程服务器获取最新数据
  • 性能优化:Next.js 仍会预渲染路由,输出结果会被缓存以提升性能
  • 请求去重:在同一个渲染过程中,相同 URL 和选项的 fetch 请求仍会被自动去重(Request Memoization)

请求去重机制(Request Memoization)

虽然默认不缓存响应,但 Next.js 仍提供了请求去重功能。在同一个 React 组件树的渲染过程中,相同的 fetch 请求只会执行一次:

// app/posts/page.tsx
async function getPosts() {
  // Next.js 15: 默认不缓存,每次都获取最新数据
  const res = await fetch('https://api.example.com/posts');
  return res.json();
}

// 在同一次渲染中,这两个调用只会发送一次网络请求
async function getPostsAgain() {
  // 这个请求会被去重,不会发送新的网络请求
  const res = await fetch('https://api.example.com/posts');
  return res.json();
}

export default async function Page() {
  const posts = await getPosts(); // 第一次:发送网络请求
  const morePosts = await getPostsAgain(); // 第二次:从内存返回
  // ...
}

控制缓存策略

虽然 Next.js 15 默认不缓存 fetch 响应,但你仍然可以通过配置选项来精确控制缓存行为。

1. 启用缓存(force-cache)

如果你希望缓存某些稳定的数据(如配置信息、静态内容),可以显式设置 cache: 'force-cache'

// 启用缓存,数据会被持久化存储
const res = await fetch('https://api.example.com/config', {
  cache: 'force-cache', // 显式启用缓存
});

1. 确保不缓存(no-store)

对于需要实时更新的数据(如股票价格、新闻快讯),你可以显式设置 cache 选项为 'no-store'(虽然这已经是默认行为):

// 确保每次都重新请求(Next.js 15 的默认行为)
const res = await fetch('https://api.example.com/real-time-data', {
  cache: 'no-store', // 显式禁用缓存
});

3. 定期重新验证(增量静态再生 - ISR)

你可以让数据在一定时间后自动更新。例如,一个博客文章列表,每小时更新一次就足够了。这通过 next.revalidate 选项实现:

// 启用缓存并设置重新验证时间
const res = await fetch('https://api.example.com/posts', {
  next: { revalidate: 60 }, // 60秒后重新验证
  // 注意:使用 revalidate 时会自动启用缓存
});

重要提示:当你设置 next.revalidate 时,Next.js 会自动启用缓存,无需显式设置 cache: 'force-cache'。这个特性让你的网站兼具静态网站的访问速度和动态网站的内容更新能力。

Next.js 15 缓存行为总结

为了帮助你更好地理解 Next.js 15 的缓存变化,这里是一个快速参考表:

配置 Next.js 14 及之前 Next.js 15 说明
默认行为 自动缓存 不缓存 重大变化:默认获取最新数据
cache: 'force-cache' 缓存 缓存 显式启用缓存
cache: 'no-store' 不缓存 不缓存 显式禁用缓存
next: { revalidate: 60 } 缓存+重新验证 缓存+重新验证 自动启用缓存
请求去重 同一渲染中的相同请求仍会去重

迁移建议

  • 如果你的应用依赖自动缓存,需要显式添加 cache: 'force-cache' 或使用 next.revalidate
  • 对于实时数据,新的默认行为更符合预期,无需额外配置
  • 开发环境中,HMR 缓存仍然有效,避免了频繁的 API 调用

数据获取实战演练

理论说完了,让我们进入实战环节。

场景一:在服务器组件中获取数据(推荐)

这是最常见、也是最推荐的方式。它非常直观,就像写 Node.js 代码一样。

示例:创建一个博客文章列表页面

// app/blog/page.tsx

// 定义文章类型,这是个好习惯
interface Post {
  id: number;
  title: string;
  body: string;
}

// 异步组件,可以直接使用 await
export default async function BlogPage() {
  console.log("正在服务器上获取数据...");

  // 1. 获取数据
  const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=10', {
    next: { revalidate: 3600 } // 每小时更新一次,自动启用缓存
  });

  if (!res.ok) {
    // 更好的错误处理方式见后文
    throw new Error('Failed to fetch posts');
  }

  const posts: Post[] = await res.json();

  // 2. 渲染UI
  return (
    <main className="p-8">
      <h1 className="text-3xl font-bold mb-6">我的博客</h1>
      <ul className="space-y-4">
        {posts.map((post) => (
          <li key={post.id} className="p-4 border rounded-md">
            <h2 className="text-xl font-semibold">{post.title}</h2>
          </li>
        ))}
      </ul>
    </main>
  );
}

就这么简单!没有 useState,没有 useEffect,也没有加载状态的管理。你只需要 async/await,剩下的交给 Next.js。

场景二:在客户端组件中获取数据

什么时候需要在客户端获取数据呢?

  • 当数据是用户专属且高度动态的(如购物车内容)。
  • 当数据依赖于用户的实时交互(如搜索框的自动完成建议)。

要在客户端组件中获取数据,你需要使用 "use client" 指令。

传统方式:useEffect + useState

在 React 19 之前,我们通常这样做:

"use client";

import { useState, useEffect } from 'react';

// ... Post 类型定义

export default function UserProfile() {
  const [posts, setPosts] = useState<Post[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    setLoading(true);
    fetch('/api/user/posts') // 假设有一个获取用户文章的 API
      .then(res => res.json())
      .then(data => {
        setPosts(data);
      })
      .finally(() => {
        setLoading(false);
      });
  }, []); // 空依赖数组,仅在组件挂载时执行一次

  if (loading) {
    return <p>加载中...</p>;
  }

  return (
    // ... 渲染 posts
  )
}

这种方式代码量多,且需要手动管理 loadingerror 状态,比较繁琐。

现代方式:使用 React 19 use Hook

use hook 是 React 19 带来的革命性新特性,它极大地简化了在客户端组件中处理异步操作(如 fetch)的方式。

前提:你需要一个包裹 fetch 的函数,它会处理 Promise。

// lib/data.ts
import { cache } from 'react';

// `cache` 函数可以包装数据请求,确保在一次渲染中,即使多次调用 `getUserPosts`,也只执行一次。
export const getUserPosts = cache((userId: string) =>
  fetch(`https://api.example.com/users/${userId}/posts`).then((res) => res.json())
);

现在,在你的客户端组件中:

"use client";

import { use } from 'react';
import { getUserPosts } from '@/lib/data';

interface UserPostsProps {
  userId: string;
}

// ... Post 类型定义

function PostsList({ userId }: { userId: string }) {
  // 1. 使用 `use` Hook 获取数据
  // 当 `getUserPosts` 的 Promise 还在 pending 状态时,`use` 会自动抛出这个 Promise,
  // 这会被最近的 <Suspense> 边界捕获。
  const posts: Post[] = use(getUserPosts(userId));

  // 2. 渲染UI
  // 代码能执行到这里,说明数据已经成功获取
  return (
    <ul>
      {posts.map(post => <li key={post.id}>{post.title}</li>)}
    </ul>
  );
}

export default function UserProfilePage({ params }: { params: { userId: string } }) {
  return (
    <div>
      <h1 className="text-2xl">用户文章</h1>
      {/* 必须用 Suspense 包裹使用 `use` 的组件 */}
      <Suspense fallback={<p>正在加载文章列表...</p>}>
        <PostsList userId={params.userId} />
      </Suspense>
    </div>
  )
}

看到了吗?use hook 让客户端数据获取变得和服务器端一样直观简洁。它内置了对 Suspense 的支持,你不再需要手动管理 loading 状态。use 会自动“暂停”组件的渲染,直到数据准备就绪。

加载中与错误处理

一个健壮的应用必须优雅地处理加载和错误状态。Next.js 提供了专门的文件约定来解决这个问题。

使用 loading.tsx 处理加载状态

当你在服务器组件中获取数据时,Next.js 会自动寻找与你的页面平级的 loading.tsx 文件,并将其作为加载指示器。

示例:为博客页面添加入场动画

app/blog/ 目录下,创建一个 loading.tsx 文件:

// app/blog/loading.tsx
export default function Loading() {
  // 你可以在这里设计任何酷炫的加载动画
  return (
    <div className="flex justify-center items-center h-screen">
      <div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-blue-500"></div>
    </div>
  );
}

现在,当用户访问 /blog 页面时,在数据加载完成前,会首先看到这个旋转动画,而不是一个空白页面。这与 React 的 Suspense 边界协同工作,提供了无缝的加载体验。

使用 error.tsx 处理错误

如果数据获取失败(例如,API 服务器宕机),Next.js 会自动捕获错误,并渲染与页面平级的 error.tsx 文件。

注意error.tsx 必须是一个客户端组件 ("use client")。

示例:为博客页面添加错误边界

app/blog/ 目录下,创建一个 error.tsx 文件:

"use client"; // 错误组件必须是客户端组件

import { useEffect } from 'react';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // 你可以在这里记录错误日志
    console.error(error);
  }, [error]);

  return (
    <div className="text-center py-10">
      <h2 className="text-2xl font-bold text-red-600">糟糕,出错了!</h2>
      <p className="my-4">获取文章列表时遇到了问题,请稍后再试。</p>
      <button
        onClick={
          // 尝试重新渲染该路由段
          () => reset()
        }
        className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
      >
        重试
      </button>
    </div>
  );
}

现在,如果 app/blog/page.tsx 中的 fetch 抛出错误,用户将看到这个友好的错误界面,而不是一个崩溃的应用。他们还可以通过点击“重试”按钮来尝试重新加载。

进阶技巧:数据变更与更新

获取数据只是故事的一半,我们还需要更新数据。Server Actions 是 Next.js 用于在服务器上执行数据变更(创建、更新、删除)的利器。

当一个 Server Action 执行后,我们通常需要更新页面上显示的数据。Next.js 提供了两种强大的方式来重新验证缓存:

  1. revalidatePath:使特定路径下的数据缓存失效,下次访问时会重新获取。
  2. revalidateTag:更精细的控制。你可以在 fetch 时给数据打上标签,然后只让带有特定标签的数据缓存失效。

这是一个简化的示例,让你感受一下:

// app/actions.ts
'use server';

import { revalidateTag } from 'next/cache';

export async function addPost(data: FormData) {
  // 1. 调用 API 创建新文章
  await fetch('https://api.example.com/posts', {
    method: 'POST',
    body: JSON.stringify({ title: data.get('title') }),
  });
  
  // 2. 让所有标记为 'posts' 的数据缓存失效
  revalidateTag('posts');
}

// 在 fetch 时打上标签
fetch('https://api.example.com/posts', { next: { tags: ['posts'] } });

进一步提升:结合数据获取库

尽管 Next.js 对 fetch API 进行了强大扩展,满足了大部分数据获取需求,但在复杂的客户端数据管理场景中(例如,需要频繁更新、离线模式、请求重试、缓存过期等),专业的客户端数据获取库能提供更强大的能力和更优雅的开发体验。

目前最受欢迎的两个库是 SWR (Stale-While-Revalidate) 和 React Query (现已更名为 TanStack Query)。它们都基于一个核心思想:“旧数据,新验证”。这意味着它们会立即返回缓存中的旧数据(如果存在),同时在后台发起新的数据请求进行验证和更新。这种模式极大地提升了用户感知的性能。

为什么使用数据获取库?

  1. 自动缓存和去重:自动管理数据缓存,避免重复请求。
  2. 自动重新验证(Revalidation):在窗口重新聚焦、网络重连等场景下自动重新请求数据,确保数据新鲜度。
  3. 错误处理和重试机制:内置完善的错误捕获和自动重试策略。
  4. 加载状态和分页/无限滚动:提供了简单的方式来管理加载状态,并支持高级的分页和无限滚动模式。
  5. 乐观更新:在数据变更时,可以先更新 UI,再等待服务器响应,提升用户体验。

SWR 示例

SWR 由 Vercel (Next.js 的创造者) 团队开发,与 Next.js 的配合非常默契。

// app/dashboard/client-data-fetcher.tsx
"use client";

import useSWR from 'swr';

interface UserData {
  id: number;
  name: string;
  email: string;
}

// 定义一个 fetcher 函数,SWR 会用它来实际请求数据
const fetcher = (url: string) => fetch(url).then(res => res.json());

export default function UserDashboard() {
  // useSWR 的第一个参数是请求的 key (通常是 URL),第二个参数是 fetcher 函数
  const { data, error, isLoading } = useSWR<UserData>('/api/me', fetcher);

  if (error) return <div className="text-red-500">加载失败</div>;
  if (isLoading) return <div className="text-blue-500">加载中...</div>;
  if (!data) return null; // 确保数据存在

  return (
    <div className="p-8">
      <h1 className="text-2xl font-bold mb-4">欢迎, {data.name}!</h1>
      <p>邮箱: {data.email}</p>
      {/* 更多用户数据展示 */}
    </div>
  );
}

如何集成:

  1. 安装 SWRnpm install swryarn add swr
  2. 创建客户端组件:确保你的组件有 "use client" 指令。
  3. 包裹 SWRConfig (可选但推荐):在应用的根组件(例如 layout.tsx 或自定义 _app.tsx)中使用 SWRConfig 提供全局配置,如默认的 fetcher 或错误处理。

React Query (TanStack Query) 示例

React Query 提供了非常丰富的功能和更细粒度的控制。

// app/products/client-product-list.tsx
"use client";

import { useQuery, QueryClient, QueryClientProvider } from '@tanstack/react-query';

interface Product {
  id: number;
  name: string;
  price: number;
}

const queryClient = new QueryClient(); // 创建 QueryClient 实例

// 假设的 API 请求函数
async function getProducts(): Promise<Product[]> {
  const res = await fetch('https://api.example.com/products');
  if (!res.ok) {
    throw new Error('Failed to fetch products');
  }
  return res.json();
}

function ProductsList() {
  // useQuery 的第一个参数是查询键 (一个数组,用于缓存识别),第二个是查询函数
  const { data, error, isLoading } = useQuery<Product[], Error>({
    queryKey: ['products'],
    queryFn: getProducts,
  });

  if (isLoading) return <div className="text-blue-500">加载产品中...</div>;
  if (error) return <div className="text-red-500">错误: {error.message}</div>;

  return (
    <div className="p-8">
      <h1 className="text-2xl font-bold mb-4">产品列表</h1>
      <ul>
        {data?.map(product => (
          <li key={product.id}> {product.name} - ¥{product.price.toFixed(2)}</li>
        ))}
      </ul>
    </div>
  );
}

export default function ProductsPage() {
  return (
    // 必须用 QueryClientProvider 包裹,才能在子组件中使用 useQuery
    <QueryClientProvider client={queryClient}>
      <ProductsList />
    </QueryClientProvider>
  );
}

如何集成:

  1. 安装 React Querynpm install @tanstack/react-queryyarn add @tanstack/react-query
  2. 创建 QueryClientProvider:在应用的根组件或需要使用 React Query 的组件树顶层提供 QueryClientProvider

总结:在处理客户端数据时,如果仅仅是简单的展示,Next.js fetch + use Hook 可能已经足够。但对于需要高级缓存、优化交互、错误重试、数据同步等功能的场景,SWR 或 React Query 将是你的最佳选择。它们能让你以更声明式、更强大的方式管理客户端数据流。

优化用户体验:流式渲染与 Suspense

在现代 Web 应用中,用户体验至关重要。即使后端数据响应较慢,我们也希望用户能够尽快看到页面的骨架内容,而不是长时间的白屏。Next.js 15 结合 React 18+ 的并发特性,通过**流式渲染(Streaming)**和 Suspense 为我们带来了极致的用户体验优化。

什么是流式渲染?

想象一下,你正在访问一个包含多个独立部分(例如,一个显示产品列表,一个显示用户评论)的页面。在传统模式下,即使产品列表数据已经就绪,浏览器也必须等待所有部分的数据都加载完毕,才能开始渲染整个页面。

流式渲染改变了这一点。它允许服务器将页面的 HTML 分块发送到浏览器。

  1. 先发送“外壳”HTML:服务器可以立即发送页面布局(例如,导航栏、页脚)的 HTML,而不必等待所有数据加载完成。这让浏览器可以立即开始解析和渲染页面。
  2. 数据就绪时“流”入内容:当某个部分的数据加载完成后,服务器会以 script 标签的形式,将该部分的 HTML 和相关 JavaScript 流式地发送给浏览器。浏览器接收到这些内容后,会将其插入到页面的正确位置。

这意味着用户可以更快地看到页面内容,即使数据尚未完全加载,他们也能够感受到页面正在逐步填充。这显著提升了用户感知的性能。

Suspense 在 Next.js 中的作用

Suspense 是 React 的一个内置组件,它允许你“暂停”组件的渲染,直到其内部的异步操作(例如数据获取)完成。当异步操作处于 pending 状态时,Suspense 会渲染一个 fallback 属性提供的备用内容(例如加载指示器)。

在 Next.js 的 App Router 中,loading.tsx 文件实际上就是 Suspense 的一个应用。

// app/dashboard/layout.tsx (示例)

import { Suspense } from 'react';
import DashboardNav from './DashboardNav';
import DashboardContent from './DashboardContent';

export default function DashboardLayout({
  children,
}: { children: React.ReactNode }) {
  return (
    <section>
      <DashboardNav />
      {/* 这个 Suspense 边界会捕获 DashboardContent 内部可能出现的异步操作 */}
      <Suspense fallback={<p>加载仪表盘内容...</p>}>
        <DashboardContent />
      </Suspense>
      {children}
    </section>
  );
}

loading.tsx 是如何工作的?

当你在一个路由段中定义 loading.tsx 时,Next.js 会自动将其包裹在对应的 Suspense 边界中。例如,对于 /app/blog/page.tsx/app/blog/loading.tsx,Next.js 内部会将其处理为:

// 概念上类似于 Next.js 的内部处理
<Suspense fallback={<Loading />}>
  <Page />
</Suspense>

分层级加载:让页面内容渐进显示

利用流式渲染和 Suspense,我们可以实现页面的分层级加载。这意味着我们可以将页面划分为多个独立的部分,每个部分在自己的数据准备就绪后独立渲染。

示例:一个复杂的用户主页

假设一个用户主页包含:

  • 顶部用户信息 (快速加载)
  • 文章列表 (可能较慢)
  • 好友推荐 (独立加载,可能最慢)
// app/profile/[userId]/page.tsx

import { Suspense } from 'react';
import UserInfo from './UserInfo'; // 假设这里不需要异步数据或数据非常快
import Articles from './Articles'; // 需要异步获取文章列表
import FriendsRecommendations from './FriendsRecommendations'; // 需要异步获取好友推荐

export default async function UserProfilePage({ params }: { params: { userId: string } }) {
  const userId = params.userId;

  return (
    <div className="p-8">
      {/* 用户信息部分,快速渲染 */}
      <UserInfo userId={userId} />

      <h2 className="text-2xl font-bold mt-8 mb-4">我的文章</h2>
      {/* 文章列表,使用 Suspense 边界包裹,数据加载时显示加载状态 */}
      <Suspense fallback={<p>加载文章中...</p>}>
        <Articles userId={userId} />
      </Suspense>

      <h2 className="text-2xl font-bold mt-8 mb-4">好友推荐</h2>
      {/* 好友推荐,独立 Suspense 边界,即便文章列表加载慢,它也可以在自己的数据就绪后显示 */}
      <Suspense fallback={<p>加载好友推荐中...</p>}>
        <FriendsRecommendations userId={userId} />
      </Suspense>
    </div>
  );
}

// app/profile/[userId]/Articles.tsx (服务器组件)
async function Articles({ userId }: { userId: string }) {
  // 模拟较慢的数据获取
  await new Promise(resolve => setTimeout(resolve, 2000)); 
  const res = await fetch(`https://api.example.com/users/${userId}/articles`);
  const articles = await res.json();
  return (
    <ul>
      {articles.map(article => <li key={article.id}>{article.title}</li>)}
    </ul>
  );
}

// app/profile/[userId]/FriendsRecommendations.tsx (服务器组件)
async function FriendsRecommendations({ userId }: { userId: string }) {
  // 模拟最慢的数据获取
  await new Promise(resolve => setTimeout(resolve, 4000)); 
  const res = await fetch(`https://api.example.com/users/${userId}/recommendations`);
  const recommendations = await res.json();
  return (
    <ul>
      {recommendations.map(friend => <li key={friend.id}>{friend.name}</li>)}
    </ul>
  );
}

在这个例子中,UserInfo 会立即显示。同时,ArticlesFriendsRecommendations 组件会并行请求数据,并在数据返回后,通过流式渲染逐步填充到页面中。这种方式极大地提升了用户感知的加载速度,因为他们不必等待最慢的数据。

总结:何时使用 Suspense?

  • 服务器组件loading.tsx 提供了页面的 Suspense 边界。
  • 客户端组件:当你需要在客户端组件内部进行异步数据获取,并希望在数据加载时显示加载状态,同时避免手动管理 loading 状态时,可以使用 React 19 的 use hook 结合 <Suspense> 组件。
  • 分层级加载:当页面包含多个独立且加载时间可能不同的部分时,为每个异步部分包裹 Suspense 边界,可以实现更平滑的渐进式加载体验。

理解并善用流式渲染和 Suspense,是构建高性能 Next.js 应用的关键一步。

服务器组件与客户端组件:数据传递与交互

Next.js App Router 引入了服务器组件(Server Components)和客户端组件(Client Components)的概念,这在提供强大功能的同时,也带来了新的数据流和交互模式。理解它们之间如何通信是掌握 Next.js 的关键。

从服务器到客户端:Props 传递

最常见的数据传递方式是通过组件的 props。服务器组件可以在渲染时获取数据,然后将这些数据作为 props 传递给嵌套的客户端组件。

重要原则:传递给客户端组件的 props 必须是**可序列化(Serializable)**的。这意味着你不能直接传递函数、Symbol、Date 对象(需要转换为字符串或时间戳)、Class 实例等非基本类型数据。如果需要传递这些类型的数据,通常需要进行转换。

示例:服务器组件传递数据给客户端组件

// app/dashboard/page.tsx (服务器组件)

import UserGreeting from './UserGreeting'; // 这是一个客户端组件

interface UserProfile {
  name: string;
  lastLogin: string; // 假设是 ISO 格式字符串
}

async function getUserProfile(): Promise<UserProfile> {
  // 在服务器上获取用户数据
  const res = await fetch('https://api.example.com/user/profile', { cache: 'no-store' });
  if (!res.ok) {
    throw new Error('Failed to fetch user profile');
  }
  return res.json();
}

export default async function DashboardPage() {
  const userProfile = await getUserProfile();

  return (
    <main className="p-8">
      <h1 className="text-3xl font-bold mb-6">仪表盘</h1>
      {/* 将服务器获取的数据作为 props 传递给客户端组件 */}
      <UserGreeting userName={userProfile.name} lastLogin={userProfile.lastLogin} />
      {/* 其他仪表盘内容 */}
    </main>
  );
}

// app/dashboard/UserGreeting.tsx (客户端组件)
"use client";

import { formatDistanceToNow } from 'date-fns'; // 客户端库

interface UserGreetingProps {
  userName: string;
  lastLogin: string; // 接收字符串,客户端再处理
}

export default function UserGreeting({ userName, lastLogin }: UserGreetingProps) {
  const loginTime = new Date(lastLogin); // 在客户端将字符串转换为 Date 对象
  const timeAgo = formatDistanceToNow(loginTime, { addSuffix: true, locale: 'zh-CN' });

  return (
    <div className="mb-4 p-4 bg-green-100 rounded-md">
      <p className="text-lg">你好, <span className="font-semibold">{userName}</span>!</p>
      <p className="text-sm text-gray-600">上次登录: {timeAgo}</p>
    </div>
  );
}

在这个例子中,DashboardPage (服务器组件) 获取用户数据,然后将 userNamelastLogin 作为 props 传递给 UserGreeting (客户端组件)。UserGreeting 在客户端利用 date-fns 库格式化时间,这是只有在客户端才能执行的操作。

从客户端到服务器:Server Actions

客户端组件需要与服务器端逻辑交互时,Server Actions 是最佳选择。它们允许你在客户端组件中直接调用服务器端函数,而无需手动创建 API 路由。

Server Actions 可以在任何服务器组件或 "use server" 文件中定义。

示例:客户端组件触发服务器行为

// app/comments/add-comment-form.tsx (客户端组件)
"use client";

import { useRef } from 'react';
import { addComment } from '@/app/actions'; // 引入服务器动作

export default function AddCommentForm() {
  const formRef = useRef<HTMLFormElement>(null);

  // 使用 bind 来预设参数,或者直接在 action 属性中使用箭头函数
  const handleSubmit = async (formData: FormData) => {
    await addComment(formData); 
    formRef.current?.reset(); // 提交后清空表单
  };

  return (
    <form ref={formRef} action={handleSubmit} className="p-4 border rounded-md shadow-sm">
      <h2 className="text-xl font-semibold mb-3">添加评论</h2>
      <textarea
        name="commentText"
        rows={4}
        placeholder="留下你的评论..."
        className="w-full p-2 border rounded-md mb-3 focus:outline-none focus:ring-2 focus:ring-blue-500"
        required
      ></textarea>
      <button
        type="submit"
        className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
      >
        发布评论
      </button>
    </form>
  );
}

// app/actions.ts (服务器文件)
"use server";

import { revalidatePath } from 'next/cache';

export async function addComment(formData: FormData) {
  const commentText = formData.get('commentText') as string;

  if (!commentText) {
    throw new Error("评论内容不能为空。");
  }

  try {
    // 模拟数据存储到数据库或调用外部 API
    console.log(`正在保存评论: "${commentText}"`);
    await new Promise(resolve => setTimeout(resolve, 1000)); // 模拟网络延迟
    
    // 重新验证路径,以便客户端能看到新评论
    revalidatePath('/comments'); 
    console.log("评论保存成功并重新验证了 /comments 路径。");

  } catch (error) {
    console.error("保存评论时出错:", error);
    throw new Error("评论发布失败,请稍后再试。");
  }
}

在这个示例中:

  1. AddCommentForm 是一个客户端组件,因为它处理用户交互和表单提交。
  2. 它通过 action 属性直接引用了 addComment 这个 Server Action。
  3. addComment 是一个在服务器上运行的异步函数,负责处理实际的业务逻辑(保存评论)。
  4. revalidatePath('/comments') 会在评论成功保存后,强制 Next.js 重新获取 /comments 路径下的最新数据,从而更新 UI。

总结

  • 服务器到客户端:通过 props 传递可序列化的数据。
  • 客户端到服务器:通过 Server Actions 触发服务器端逻辑,实现数据变更或复杂操作。Server Actions 提供了一种安全、高效的方式,将前端交互与后端逻辑紧密结合。

掌握服务器组件与客户端组件的协作机制,是构建高性能、可扩展 Next.js 应用的关键。通过合理地划分组件职责,并在必要时进行数据传递和交互,你可以充分发挥 Next.js 在服务器端渲染和客户端交互方面的优势。

健全的错误处理策略:不止是 error.tsx

在构建任何健壮的应用时,错误处理是不可或缺的一环。Next.js 提供了 error.tsx 作为路由级别的错误边界,但实际开发中,我们可能需要更细致、更灵活的错误处理方案。

error.tsx:路由级别的错误边界

我们已经在文章前面提到过 error.tsx。它是一个 React 错误边界,能够捕获其子组件树中发生的运行时错误,并提供一个备用 UI。记住,它必须是客户端组件("use client")。

适用场景:捕获整个路由段或页面渲染过程中的非预期错误。

局限性

  • 无法捕获布局组件(layout.tsx)中的错误。
  • 无法捕获同级或父级 error.tsx 中的错误。
  • 默认情况下,它会重置页面状态并刷新,可能不是所有错误场景都希望的行为。

在异步组件内部处理错误

对于服务器组件中的数据获取,你可以直接使用标准的 try...catch 语句来处理异步操作中可能发生的错误。这允许你更精确地控制错误发生时的行为,而不是简单地抛出到 error.tsx

示例:细粒度错误处理

// app/products/page.tsx

interface Product {
  id: number;
  name: string;
  price: number;
}

async function getProducts() {
  try {
    const res = await fetch('https://api.example.com/products', { cache: 'no-store' }); // Next.js 15 中可省略,默认不缓存

    if (!res.ok) {
      // 如果响应状态码不是 2xx,手动抛出错误
      throw new Error(`Failed to fetch products: ${res.status} ${res.statusText}`);
    }

    const products: Product[] = await res.json();
    return products;
  } catch (error) {
    console.error("获取产品数据时出错:", error); // 记录错误
    // 你可以选择返回空数组,或者抛出更友好的错误信息
    throw new Error("抱歉,暂时无法加载产品列表。请稍后再试。"); 
  }
}

export default async function ProductsPage() {
  let products: Product[] = [];
  let errorMessage: string | null = null;

  try {
    products = await getProducts();
  } catch (error: any) {
    errorMessage = error.message;
  }

  return (
    <main className="p-8">
      <h1 className="text-3xl font-bold mb-6">产品目录</h1>
      {errorMessage ? (
        <div className="text-red-600 text-center py-4">{errorMessage}</div>
      ) : (
        <ul className="space-y-4">
          {products.map((product) => (
            <li key={product.id} className="p-4 border rounded-md">
              <h2 className="text-xl font-semibold">{product.name}</h2>
              <p>价格: ¥{product.price.toFixed(2)}</p>
            </li>
          ))}
        </ul>
      )}
    </main>
  );
}

这种方式的优势在于:

  • 更精确的控制:你可以在数据获取函数内部直接处理错误,而不是让它冒泡到整个页面。这对于不同类型的错误需要不同反馈时非常有用。
  • 用户友好反馈:可以在组件内部显示更具体、更友好的错误消息,而不是统一的错误页面。
  • 数据回退:在某些情况下,你可能希望在数据获取失败时,显示一部分默认数据或缓存数据,而不是完全的错误页面。

全局错误日志与监控

对于生产环境的应用,仅仅在 UI 上显示错误是不够的,你还需要将错误日志发送到外部服务进行监控和分析(如 Sentry、Datadog 等)。

  1. error.tsx 中记录error.tsx 组件的 useEffect 是一个很好的地方来记录客户端捕获的错误。

    // app/blog/error.tsx
    // ...
    useEffect(() => {
      // 将错误发送到你的日志服务
      console.error(error);
      // Sentry.captureException(error); // 示例:集成 Sentry
    }, [error]);
    // ...
    
  2. 在 Server Actions 或 API 路由中记录:由于 Server Actions 和 API 路由在服务器端运行,你可以直接使用 Node.js 环境的日志库(如 winstonpino),或者将其错误发送到云服务提供商的日志系统(如 AWS CloudWatch、Google Cloud Logging)。

    // app/actions.ts
    'use server';
    import { revalidateTag } from 'next/cache';
    
    export async function addPost(data: FormData) {
      try {
        await fetch('https://api.example.com/posts', { /* ... */ });
        revalidateTag('posts');
      } catch (error) {
        console.error("新增文章失败:", error);
        // 可以在这里返回一个错误状态给客户端
        return { success: false, message: "新增文章失败,请稍后再试。" };
      }
    }
    

总结

  • 路由级错误:使用 error.tsx 作为全局错误边界,捕获渲染期间的意外错误。
  • 组件内错误:在异步组件(尤其是服务器组件)内部使用 try...catch 进行细粒度的错误处理,提供更友好的用户反馈或回退机制。
  • 日志监控:将客户端和服务器端的错误都发送到集中式日志服务,以便及时发现和解决问题。

总结与最佳实践

  1. 服务器优先:默认在服务器组件中获取数据,以获得最佳性能和安全性。
  2. 理解 Next.js 15 的 fetch 变化:默认不再缓存响应,确保数据新鲜度。通过 cache: 'force-cache'next.revalidate 选项精细控制缓存行为。请求去重机制仍然有效,避免同一渲染中的重复请求。
  3. 拥抱 async/await:在服务器组件中,直接使用 async/await 就能获取数据,代码简洁明了。
  4. use Hook 简化客户端获取:当必须在客户端获取数据时,优先使用 React 19 的 use hook,它能与 Suspense 无缝集成,告别手动的 loading 状态管理。
  5. 专业处理边界情况:使用 loading.tsx 提供流畅的加载体验,使用 error.tsx 创建优雅的错误边界。
  6. Server Actions + Revalidation:使用 Server Actions 处理数据变更,并用 revalidatePathrevalidateTag 来保持UI与数据同步。

React Hook 核心指南:从实战到源码,彻底掌握状态与副作用

2025年8月16日 14:07

Hook 的出现让函数组件拥有了管理状态和副作用的能力,极大地提升了代码的可读性和复用性。今天,我想和大家分享 React 中最核心、最常用的几个 Hook:useStateuseEffectuseContextuseReduceruseCallbackuseMemouseRef。我会从基本用法讲起,结合实际例子,深入探讨它们的原理和最佳实践,并手写简化版实现,最后附上高频面试题。希望这篇博客能让你对 React Hook 有更深刻的理解。

1. useState:管理组件状态

1.1 基本概念

useState 是最基础的 Hook,用于在函数组件中添加状态。它接收一个初始状态值,返回一个包含当前状态和更新函数的数组。

const [state, setState] = useState(initialState);
  • state:当前状态值。
  • setState:更新状态的函数。
  • initialState:初始状态,可以是任意值(原始值、对象、函数等)。

1.2 使用场景

计数器是最经典的例子:

import React, { useState } from 'react';

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

    return (
        <div>
            <p>当前计数: {count}</p>
            <button onClick={() => setCount(count + 1)}>
                增加
            </button>
            <button onClick={() => setCount(prevCount => prevCount - 1)}>
                减少
            </button>
        </div>
    );
}

关键点:

  • setState 是异步的,多次调用会进行批处理。
  • 更新函数可以接收一个函数,该函数的参数是前一个状态值(prevState),这在需要基于前一个状态更新时非常有用。

1.3 手写实现(简化版)

// 模拟 React 内部状态存储
let hooks = [];
let currentHookIndex = 0;

function useState(initialValue) {
    const hookIndex = currentHookIndex;

    // 如果是第一次调用,初始化状态
    if (!hooks[hookIndex]) {
        hooks[hookIndex] = initialValue;
    }

    // 返回当前状态和更新函数
    const setState = (newValue) => {
        // 如果传入的是函数,执行它
        const value = typeof newValue === 'function' 
            ? newValue(hooks[hookIndex]) 
            : newValue;
        
        hooks[hookIndex] = value;
        // 模拟触发重新渲染
        render();
    };

    return [hooks[hookIndex], setState];
}

// 模拟组件渲染
function render() {
    currentHookIndex = 0; // 重置索引
    // 重新执行组件函数
    App();
}

function App() {
    const [count, setCount] = useState(0);
    const [name, setName] = useState('张三');

    console.log('Count:', count, 'Name:', name);
}

// 初始渲染
App(); // Count: 0 Name: 张三
setCount(1); // Count: 1 Name: 张三
setName('李四'); // Count: 1 Name: 李四

注意: 这只是一个极度简化的模型,真实的 React 使用 Fiber 节点和 Hook 链表来管理状态。

1.4 注意事项

  • 不要在条件或循环中使用 Hook:React 依赖 Hook 的调用顺序来正确匹配状态。违反规则会导致 Invalid hook call 错误。
  • 状态更新是异步的setState 后立即读取状态可能得不到最新值。
  • 函数式更新:当新状态依赖于前一个状态时,使用函数形式 setState(prev => prev + 1)

2. useEffect:处理副作用

2.1 基本概念

useEffect 用于在函数组件中执行副作用操作,如数据获取、订阅、手动 DOM 操作等。它替代了类组件中的 componentDidMountcomponentDidUpdatecomponentWillUnmount

useEffect(() => {
    // 执行副作用
    return () => {
        // 清理副作用(可选)
    };
}, [dependencies]); // 依赖数组

2.2 使用场景

场景一:数据获取

import React, { useState, useEffect } from 'react';

function UserProfile({ userId }) {
    const [user, setUser] = useState(null);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
        // 模拟 API 请求
        const fetchUser = async () => {
            setLoading(true);
            try {
                // 假设这里调用 API
                const response = await fetch(`/api/users/${userId}`);
                const userData = await response.json();
                setUser(userData);
            } catch (error) {
                console.error('获取用户信息失败:', error);
            } finally {
                setLoading(false);
            }
        };

        fetchUser();
    }, [userId]); // 仅当 userId 变化时重新执行

    if (loading) return <div>加载中...</div>;
    if (!user) return <div>用户不存在</div>;

    return (
        <div>
            <h1>{user.name}</h1>
            <p>{user.email}</p>
        </div>
    );
}

场景二:订阅和清理

import React, { useState, useEffect } from 'react';

function MouseTracker() {
    const [position, setPosition] = useState({ x: 0, y: 0 });

    useEffect(() => {
        const handleMouseMove = (event) => {
            setPosition({ x: event.clientX, y: event.clientY });
        };

        // 添加事件监听器
        window.addEventListener('mousemove', handleMouseMove);

        // 返回清理函数
        return () => {
            window.removeEventListener('mousemove', handleMouseMove);
        };
    }, []); // 空依赖数组,只在挂载时执行一次

    return (
        <div>
            鼠标位置: {position.x}, {position.y}
        </div>
    );
}

2.3 依赖数组详解

  • []:只在组件挂载时执行一次(类似 componentDidMount)。
  • [dep1, dep2]:当 dep1dep2 变化时执行。
  • 不传:每次组件重新渲染时都执行(不推荐,容易造成性能问题或无限循环)。

2.4 手写实现(简化版)

let dependencies = [];
let cleanup = null;

function useEffect(callback, deps) {
    const hasChanged = !deps || 
        deps.some((dep, index) => !dependencies[index] || dep !== dependencies[index]);

    if (hasChanged) {
        // 执行清理函数(如果存在)
        if (cleanup) {
            cleanup();
        }
        // 执行副作用
        cleanup = callback();
        // 更新依赖
        dependencies = deps || [];
    }
}

// 模拟组件
function Component({ count }) {
    useEffect(() => {
        console.log('Effect 执行,count:', count);
        return () => {
            console.log('清理 Effect,count:', count);
        };
    }, [count]);

    return <div>Count: {count}</div>;
}

// 初始渲染
Component({ count: 0 }); // Effect 执行,count: 0

// 更新
Component({ count: 1 }); // 清理 Effect,count: 0 -> Effect 执行,count: 1

2.5 注意事项

  • 必须返回函数或 undefined:清理函数必须是同步的。
  • 避免无限循环:确保依赖数组正确,避免在 useEffect 内部更新依赖项。
  • 异步操作:不能直接在 useEffect 回调中使用 async,需要在内部创建异步函数。
// ❌ 错误
useEffect(async () => {
    const data = await fetchData();
}, []);

// ✅ 正确
useEffect(() => {
    const fetchData = async () => {
        const data = await fetchData();
    };
    fetchData();
}, []);

3. useContext:跨组件传递数据

3.1 基本概念

useContext 用于订阅 React 的 Context。它接收一个 context 对象(React.createContext 的返回值),并返回当前 context 的值。

const value = useContext(MyContext);

3.2 使用场景

主题切换是经典例子:

import React, { createContext, useContext, useState } from 'react';

// 创建 Context
const ThemeContext = createContext();

// 主题提供者组件
function ThemeProvider({ children }) {
    const [theme, setTheme] = useState('light');

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

    return (
        <ThemeContext.Provider value={{ theme, toggleTheme }}>
            {children}
        </ThemeContext.Provider>
    );
}

// 使用 Context 的组件
function Header() {
    const { theme, toggleTheme } = useContext(ThemeContext);

    return (
        <header style={{ background: theme === 'light' ? '#fff' : '#333', color: theme === 'light' ? '#000' : '#fff' }}>
            <h1>我的网站</h1>
            <button onClick={toggleTheme}>
                切换到 {theme === 'light' ? '暗色' : '亮色'} 主题
            </button>
        </header>
    );
}

// 根组件
function App() {
    return (
        <ThemeProvider>
            <Header />
            <MainContent />
        </ThemeProvider>
    );
}

3.3 手写实现(简化版)

// 模拟 Context
const contextStack = [];

function createContext(defaultValue) {
    return { defaultValue };
}

function Provider({ context, value, children }) {
    contextStack.push(value);
    const result = children;
    contextStack.pop();
    return result;
}

function useContext(context) {
    return contextStack[contextStack.length - 1] || context.defaultValue;
}

// 使用
const ThemeContext = createContext('light');

function ThemeProvider({ children }) {
    const [theme] = useState('dark');
    return Provider({ context: ThemeContext, value: theme, children });
}

function ThemeDisplay() {
    const theme = useContext(ThemeContext);
    return <div>当前主题: {theme}</div>;
}

3.4 注意事项

  • useContext 会订阅 Context 的变化,当 Providervalue 变化时,所有使用该 Context 的组件都会重新渲染。
  • 避免将 Context 用于频繁变化的状态,可能导致性能问题。

4. useReducer:管理复杂状态逻辑

4.1 基本概念

useReduceruseState 的替代方案,适用于状态逻辑较复杂的情况(如包含多个子值或下一个状态依赖于前一个状态)。它接收一个 reducer 函数和初始状态,返回当前状态和 dispatch 函数。

const [state, dispatch] = useReducer(reducer, initialState);
  • reducer(state, action) => newState,纯函数。
  • initialState:初始状态。

4.2 使用场景

管理表单或购物车状态:

import React, { useReducer } from 'react';

// 定义 action 类型
const ADD_ITEM = 'ADD_ITEM';
const REMOVE_ITEM = 'REMOVE_ITEM';
const UPDATE_QUANTITY = 'UPDATE_QUANTITY';

// Reducer 函数
function cartReducer(state, action) {
    switch (action.type) {
        case ADD_ITEM:
            return {
                ...state,
                items: [...state.items, action.payload]
            };
        case REMOVE_ITEM:
            return {
                ...state,
                items: state.items.filter(item => item.id !== action.payload.id)
            };
        case UPDATE_QUANTITY:
            return {
                ...state,
                items: state.items.map(item =>
                    item.id === action.payload.id
                        ? { ...item, quantity: action.payload.quantity }
                        : item
                )
            };
        default:
            return state;
    }
}

function ShoppingCart() {
    const [state, dispatch] = useReducer(cartReducer, { items: [] });

    const addItem = (item) => {
        dispatch({ type: ADD_ITEM, payload: item });
    };

    const removeItem = (id) => {
        dispatch({ type: REMOVE_ITEM, payload: { id } });
    };

    return (
        <div>
            <h2>购物车</h2>
            <ul>
                {state.items.map(item => (
                    <li key={item.id}>
                        {item.name} - 数量: {item.quantity}
                        <button onClick={() => removeItem(item.id)}>删除</button>
                    </li>
                ))}
            </ul>
            <button onClick={() => addItem({ id: Date.now(), name: '商品', quantity: 1 })}>
                添加商品
            </button>
        </div>
    );
}

4.3 手写实现(简化版)

function useReducer(reducer, initialState) {
    const [state, setState] = useState(initialState);

    const dispatch = (action) => {
        const newState = reducer(state, action);
        setState(newState);
    };

    return [state, dispatch];
}

// 使用
const [state, dispatch] = useReducer((state, action) => {
    switch (action.type) {
        case 'INCREMENT':
            return { count: state.count + 1 };
        default:
            return state;
    }
}, { count: 0 });

4.4 注意事项

  • useReducer 适合状态更新逻辑复杂或需要处理多个 action 的场景。
  • reducer 必须是纯函数。

5. useCallback:缓存函数

5.1 基本概念

useCallback 返回一个记忆化的回调函数。它接收一个内联回调函数和依赖数组,只有当依赖项变化时,才会返回新的函数。

const memoizedCallback = useCallback(
    () => {
        doSomething(a, b);
    },
    [a, b],
);

5.2 使用场景

避免子组件不必要的重新渲染:

import React, { useState, useCallback } from 'react';

// 子组件(使用 React.memo 优化)
const ExpensiveComponent = React.memo(({ onClick, value }) => {
    console.log('ExpensiveComponent 渲染');
    return (
        <button onClick={onClick}>
            点击我 ({value})
        </button>
    );
});

function Parent() {
    const [count, setCount] = useState(0);
    const [text, setText] = useState('');

    // 使用 useCallback 缓存函数
    const handleClick = useCallback(() => {
        setCount(prev => prev + 1);
    }, []); // 依赖数组为空,函数不会变化

    return (
        <div>
            <p>计数: {count}</p>
            <input value={text} onChange={e => setText(e.target.value)} />
            {/* 传递缓存的函数 */}
            <ExpensiveComponent onClick={handleClick} value={count} />
        </div>
    );
}

如果没有 useCallback,每次 Parent 重新渲染时,handleClick 都会是一个新的函数,导致 ExpensiveComponent 重新渲染。

5.3 手写实现(简化版)

let memoizedCallback = null;
let deps = null;

function useCallback(callback, dependencies) {
    const hasChanged = !deps || 
        dependencies.some((dep, index) => !deps[index] || dep !== deps[index]);

    if (hasChanged) {
        memoizedCallback = callback;
        deps = dependencies;
    }

    return memoizedCallback;
}

5.4 注意事项

  • 只在需要传递给子组件且子组件使用 React.memo 时才使用 useCallback
  • 过度使用 useCallback 可能导致内存占用增加。

6. useMemo:缓存计算结果

6.1 基本概念

useMemo 返回一个记忆化的值。它接收一个计算函数和依赖数组,只有当依赖项变化时,才会重新计算。

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

6.2 使用场景

优化昂贵的计算:

import React, { useState, useMemo } from 'react';

function Fibonacci({ n }) {
    // 模拟昂贵的计算
    const fibonacci = (num) => {
        if (num <= 1) return num;
        return fibonacci(num - 1) + fibonacci(num - 2);
    };

    // 使用 useMemo 缓存计算结果
    const result = useMemo(() => fibonacci(n), [n]);

    return <div>斐波那契数列第 {n} 项: {result}</div>;
}

function App() {
    const [n, setN] = useState(10);
    const [input, setInput] = useState('');

    return (
        <div>
            <input value={input} onChange={e => setInput(e.target.value)} placeholder="输入文本" />
            <Fibonacci n={n} />
            <button onClick={() => setN(prev => prev + 1)}>增加 n</button>
        </div>
    );
}

如果没有 useMemo,每次 input 变化导致 App 重新渲染时,都会重新计算 fibonacci(n)

6.3 手写实现(简化版)

let memoizedValue = null;
let deps = null;

function useMemo(factory, dependencies) {
    const hasChanged = !deps || 
        dependencies.some((dep, index) => !deps[index] || dep !== deps[index]);

    if (hasChanged) {
        memoizedValue = factory();
        deps = dependencies;
    }

    return memoizedValue;
}

6.4 注意事项

  • 只在计算确实昂贵时使用 useMemo
  • 不要为了“优化”而使用,React 的重新渲染本身并不慢。

7. useRef:获取 DOM 节点或保存可变值

7.1 基本概念

useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数。ref 对象在组件的整个生命周期内保持不变。

const refContainer = useRef(initialValue);

7.2 使用场景

场景一:访问 DOM 元素

import React, { useRef, useEffect } from 'react';

function TextInputWithFocusButton() {
    const inputEl = useRef(null);

    const onButtonClick = () => {
        // 直接操作 DOM
        inputEl.current.focus();
    };

    return (
        <>
            <input ref={inputEl} type="text" />
            <button onClick={onButtonClick}>
                聚焦输入框
            </button>
        </>
    );
}

场景二:保存可变值(不触发重新渲染)

import React, { useState, useEffect, useRef } from 'react';

function Timer() {
    const [count, setCount] = useState(0);
    // 使用 ref 保存计时器 ID
    const intervalRef = useRef();

    useEffect(() => {
        intervalRef.current = setInterval(() => {
            setCount(prev => prev + 1);
        }, 1000);

        return () => {
            // 清理时使用 ref
            clearInterval(intervalRef.current);
        };
    }, []);

    return <div>计数: {count}</div>;
}

7.3 手写实现(简化版)

function useRef(initialValue) {
    const refObject = { current: initialValue };
    return refObject;
}

7.4 注意事项

  • useRef 返回的对象在组件生命周期内是同一个。
  • 修改 .current 不会触发组件重新渲染。
  • 可以用于保存任何可变值,如定时器 ID、上一次的 props 等。

8. 常见面试题

  1. useStatesetState 是同步还是异步?

    • 在 React 事件处理中是异步批处理的;在 setTimeout 或原生事件中是同步的。
  2. useEffect 的清理函数在什么时候执行?

    • 在组件卸载时,或在下一次 useEffect 执行前(如果依赖变化)。
  3. useCallbackuseMemo 的区别是什么?

    • useCallback 缓存函数,useMemo 缓存值。useCallback(fn, deps) 等价于 useMemo(() => fn, deps)
  4. 如何在 useEffect 中使用 async 函数?

    • useEffect 回调内部定义并立即调用一个 async 函数。
  5. useRefcreateRef 的区别?

    • useRef 在函数组件中使用,每次渲染返回同一个对象;createRef 每次调用都返回新对象,通常在类组件中使用。
  6. 为什么不能在条件中使用 Hook?

    • React 依赖 Hook 的调用顺序来正确匹配状态,条件使用会破坏这个顺序。
  7. useReducer 适合什么场景?

    • 状态逻辑复杂、包含多个子值、或下一个状态依赖于前一个状态时。
  8. useContext 会导致性能问题吗?

    • 如果 Providervalue 频繁变化,可能会导致所有订阅的组件重新渲染。可以使用 useMemo 缓存 value 或拆分 Context

结语

React Hook 让函数组件变得强大而灵活。掌握这些核心 Hook 的用法、原理和最佳实践,是成为一名优秀 React 开发者的关键。记住:

  • useState:管理简单状态。
  • useEffect:处理副作用,注意依赖和清理。
  • useContext:跨层级传递数据。
  • useReducer:管理复杂状态逻辑。
  • useCallback / useMemo:性能优化,按需使用。
  • useRef:访问 DOM 或保存可变值。

在实际项目中,多思考、多实践,你会发现 Hook 的组合能解决各种复杂的 UI 问题。希望这篇博客能成为你深入 React 的坚实阶梯。

❌
❌