阅读视图

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

使用函数式封装绘制科赫雪花(Koch Snowflake)

科赫雪花(Koch Snowflake)是经典的分形几何图形,由 Helge von Koch 在 1904 年提出。它通过不断递归生成边上的“锯齿”,形成复杂而美丽的雪花形状。本文将介绍如何用 函数式编程封装科赫雪花生成与绘制,方便在 Canvas 上渲染,同时可封装成可复用的模块。


1. 算法原理

科赫雪花的生成规则非常直观:

  1. 从一个等边三角形开始。
  2. 将每条边分为三等份,并在中间段向外凸出一个等边小三角形。
  3. 对生成的新边重复步骤 2,递归指定层数。
  4. 最终得到雪花状的复杂轮廓。

特点:

  • 每增加一层递归,边数呈指数增长。
  • 轮廓越来越复杂,但数学公式可精确描述。

2. 函数式封装设计

在传统实现中,可能使用类或对象存储状态。函数式封装的思路是 只依赖纯函数和数组操作,保证雪花点集可重复使用,同时避免副作用。

核心函数

  • koch(p1, p2, iter):递归生成科赫曲线上的点
  • createKochSnowflake(size, iterations):生成雪花点集和绘制函数
  • draw(ctx, x, y, strokeStyle, lineWidth):将雪花绘制到 Canvas

3. 完整函数实现

export const createKochSnowflake = function(size = 200, iterations = 4) {
  // 参数校验
  if (typeof size !== "number" || size <= 0) {
    throw new Error("[KochSnowflake] 参数错误: size 必须是大于 0 的数字");
  }
  if (!Number.isInteger(iterations) || iterations < 0) {
    throw new Error("[KochSnowflake] 参数错误: iterations 必须是大于等于 0 的整数");
  }

  // 科赫递归函数
  const koch = (p1, p2, iter) => iter === 0
    ? [p1, p2]
    : (() => {
        const dx = (p2.x - p1.x) / 3;
        const dy = (p2.y - p1.y) / 3;
        const pa = {x: p1.x + dx, y: p1.y + dy};
        const pb = {x: p1.x + 2*dx, y: p1.y + 2*dy};
        const angle = Math.PI / 3;
        const peak = {
          x: pa.x + Math.cos(angle) * (pb.x - pa.x) - Math.sin(angle) * (pb.y - pa.y),
          y: pa.y + Math.sin(angle) * (pb.x - pa.x) + Math.cos(angle) * (pb.y - pa.y)
        };
        return [
          ...koch(p1, pa, iter - 1).slice(0, -1),
          ...koch(pa, peak, iter - 1).slice(0, -1),
          ...koch(peak, pb, iter - 1).slice(0, -1),
          ...koch(pb, p2, iter - 1)
        ];
      })();

  // 初始等边三角形(以原点为中心)
  const h = size * Math.sqrt(3) / 2;
  const triangle = [
    {x: -size/2, y: h/3},
    {x: size/2, y: h/3},
    {x: 0, y: -2*h/3}
  ];

  // 生成雪花点集
  const points = triangle.flatMap((p, i) =>
    koch(p, triangle[(i+1)%3], iterations).slice(0, -1)
  ).concat([triangle[0]]);

  // 返回雪花句柄
  return {
    points,

    /**
     * 绘制雪花
     * @param {CanvasRenderingContext2D} ctx - Canvas 上下文
     * @param {number} x - 中心 X 坐标
     * @param {number} y - 中心 Y 坐标
     * @param {string} strokeStyle - 线条颜色
     * @param {number} lineWidth - 线宽
     */
    draw(ctx, x, y, strokeStyle = "white", lineWidth = 2) {
      if (!ctx || typeof ctx.moveTo !== "function") {
        throw new Error("[KochSnowflake] ctx 必须是 CanvasRenderingContext2D");
      }
      if (typeof x !== "number" || typeof y !== "number") {
        throw new Error("[KochSnowflake] x, y 必须是数字");
      }
      if (typeof strokeStyle !== "string") {
        throw new Error("[KochSnowflake] strokeStyle 必须是字符串");
      }
      if (typeof lineWidth !== "number" || lineWidth <= 0) {
        throw new Error("[KochSnowflake] lineWidth 必须大于 0");
      }

      ctx.beginPath();
      ctx.moveTo(points[0].x + x, points[0].y + y);
      points.slice(1).forEach(p => ctx.lineTo(p.x + x, p.y + y));
      ctx.closePath();
      ctx.strokeStyle = strokeStyle;
      ctx.lineWidth = lineWidth;
      ctx.stroke();
    }
  };
};

4. 使用示例

<canvas id="canvas" width="500" height="500"></canvas>
<script type="module">
import { createKochSnowflake } from './koch-snowflake.js';

const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");

// 创建雪花句柄
const snowflake = createKochSnowflake(150, 4);

// 绘制在不同位置
snowflake.draw(ctx, 150, 150, "cyan", 2);
snowflake.draw(ctx, 350, 150, "yellow", 2);
snowflake.draw(ctx, 250, 350, "white", 2);
</script>

5. 优势与扩展

  1. 函数式封装:避免副作用,易于测试
  2. 性能优化:雪花点只计算一次,可多次绘制
  3. 参数验证:减少调用错误
  4. 可扩展性:未来可增加旋转、缩放、3D 投影或动画

6. 总结

通过函数式封装科赫雪花,我们实现了:

  • 简洁、可维护的代码结构
  • 高性能绘制,可重复使用
  • 易于封装成 NPM 模块或在前端项目中复用

这种方法不仅适用于科赫雪花,也适合其他递归分形图形的封装,实现算法与渲染解耦,方便扩展和复用。

太强了,Trae帮我把可视化面板写了个登录页面

前言

之前不是给甲方爸爸写了一个可视化面板,但是甲方爸爸还想要一个登录页面,那我当然要表现得很为难,说排期时间不够,得加钱,加钱都好说,甲方爸爸也是爽快的答应了,并询问我什么时候可以上线

那当然是明天,越快越好,甲方爸爸表示很满意。

接着我就打开了Trae,并且对Trae进行了提问,帮我把可视化面板项目添加一个企业是专业软件的登录页面,看看能不能最快时间进行页面ui的构建

image.png

Trae先看了我的可视化面板的构成,马上给出了专业的方案,下面是第一版的效果,看起来布局和颜色都很符合可视化面板的颜色搭配,那么就开始跟Trae继续对登录的主要方式以及注意点

image.png

甲方爸爸需要的是手机号或者账号密码登录,所以我再次提问 image.png

这次的输出,移除了社交媒体登录 :去除了Google和Microsoft登录按钮,用户可切换账号密码登录或手机号登录,支持11位手机号格式验证、6位验证码的校验,看看界面是怎么样的先

image.png

相信大家也看到问题的所在,下拉框的切换方式不是很高效,换成tab肯定是比较符合现代化的页面构成,还有勾选记住密码,字体是白色的,看不清楚,这次一并让Trae改了,看看能不能得到甲方爸爸的青睐,Trae开干吧

image.png

账号密码登陆的界面样式 image.png 手机号获取验证码的界面样式,

1、手机号没有输入的话,会提示请先输入手机号

2、点击发送验证码按钮,前端会出现60s的倒计时,并有绿色字体的提示,验证码已经发送,让用户有比较好的体验,这一点Trae做的非常好 image.png 接下来就是发给亲爱的甲方爸爸看一下页面ui,如果没有问题的话,就可以继续下面的开发,然后让Trae把接口联调的脏活累活接了

甲方爸爸表示页面很好,目前不需要有忘记密码,可以先把入口隐藏,后续有需要的话可以再显示

Trae的关键代码

检查登录状态也接入到可视化面板上面

image.png 退出登录功能

image.png

tab切换的代码

image.png 发送验证码,并进入倒计时

image.png 不错嘛,Trae很高效的完成了登录页面的开发,但是光有页面肯定不行,下期,我继续分享,Trae怎么帮我将登录页面的后端接口联调,敬请期待吧~让大家感受到Trae的编程魅力,从此爱上Trae

Node.js v24.6.0 新功能速览 🚀🚀🚀

前言

Node.js v24.6.0 发布了,让我们一起来看看这些朴实却强大的变化!

往期精彩推荐

正文

以下是 v24.6.0 的核心更新和功能的详细介绍。

1. CLI:系统 CA 支持

Node.js v24.6.0 新增 NODE_USE_SYSTEM_CA=1 环境变量,支持使用系统 CA 证书。这简化了企业环境下的证书配置,提升兼容性。

示例

NODE_USE_SYSTEM_CA=1 node app.js

这对需要严格安全合规的场景尤其实用。

2. Crypto:支持 ML-DSA 算法

Crypto 模块新增了对 ML-DSA(Module Lattice-based Digital Signature Algorithm)的支持,包括 KeyObject 生成、签名和验证。这是后量子密码学算法,为未来安全奠定基础。

示例

const crypto = require('crypto');

// 生成 ML-DSA 密钥对
const { publicKey, privateKey } = crypto.generateKeyPairSync('ml-dsa');

// 签名
const signature = crypto.sign(null, Buffer.from('data'), privateKey);

// 验证
const isValid = crypto.verify(null, Buffer.from('data'), publicKey, signature);
console.log(isValid); // true

这为高安全需求的加密应用提供了新选择。

3. Zlib:zstdCompress 和 zstdDecompress 字典支持

Zlib 模块的 zstdCompresszstdDecompress 函数新增字典支持,通过预定义常见模式提升压缩效率。

示例

const zlib = require('zlib');
const dict = Buffer.from('common patterns'); // 自定义字典

const compressed = zlib.zstdCompressSync('data to compress', { dictionary: dict });
const decompressed = zlib.zstdDecompressSync(compressed, { dictionary: dict });
console.log(decompressed.toString()); // 'data to compress'

这优化了数据传输和存储场景。

4. HTTP:新增 keepAliveTimeoutBuffer 选项

HTTP 服务器新增 keepAliveTimeoutBuffer 选项,用于缓冲 keep-alive 超时,提升连接管理效率。

示例

const http = require('http');

const server = http.createServer((req, res) => res.end('Hello'));
server.keepAliveTimeoutBuffer = 1000; // 缓冲 1 秒
server.listen(3000);

这有助于减少网络抖动,提高服务器稳定性。

5. Lib:文档废弃 http*

内部 HTTP 模块的 _http_* 函数已被文档废弃,鼓励使用标准 API,提升代码规范性。

6. FS:移植 SonicBoom 作为 Utf8Stream

FS 模块引入了 Utf8Stream,通过移植 SonicBoom 提升文件流处理性能,适合高吞吐量场景。

7. 其他改进

  • 基准测试:优化基准脚本,提升测试效率。
  • 依赖更新:升级 ada 到 3.2.7、OpenSSL 到 3.5.2,确保安全性。
  • 文档优化:修复 Pbkdf2Params 和 x509.keyUsage 的文档问题。

最后

Node.js 新版本从 Crypto 的后量子算法到 HTTP 的连接优化,这些功能让你的项目更健壮、更高效。快来升级到 v24.6.0,体验这些实用的新特性吧!

今天的分享就这些了,感谢大家的阅读!如果文章中存在错误的地方欢迎指正!

往期精彩推荐

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

在使用 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 却出现滚动条?

🎉 写在前面 你是否遇到过这样的诡异场景:明明设置了 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路由拦截如何破?

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

前言

“大家要是使用过京东的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%安全;

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

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

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

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

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

关于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 配置与优化全攻略:从基础到进阶实战

在前端工程化中,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为例

  • 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 接口入门:定义代码的契约与形态

一、什么是接口?

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

// 定义一个名为 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增强用户‘/’体验

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

由团队成员 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如何让童年游戏更现代?

前言

今天来还原童年记忆中的扫雷游戏,主要是让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算法的五步优化策略与最长递增子序列的巧妙应用

🚀 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版实现

前言

今天来还原童年记忆中的打地鼠游戏,主要是让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 的速度提升了两倍以上

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

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

原文地址: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 工具

前言

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 打造“图音双绝”的上传组件》

开场三句话

  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%:前端性能优化终极指南

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

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服务更稳定

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

❌