阅读视图

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

别再手写i18n了!深入浏览器原生Intl对象(数字、日期、复数处理)

我们做前端,经常会遇到国际化(i18n)的需求。最常见的就是,如何根据用户的地区,显示不同格式的数字、货币和日期。

在不了解Intl对象之前,我们可能会写出这样的代码:

// 场景:显示商品价格
function formatPrice(price, currency) {
  if (currency === 'USD') {
    return '$' + price.toFixed(2);
  } else if (currency === 'JPY') {
    return '¥' + price.toFixed(0);
  } else {
    // 更多if...else...
    return price;
  }
}

这段代码不仅繁琐、难以维护,而且根本不严谨。比如,数字的千分位分隔符在不同国家是不一样的(美国用逗号,,德国用点.)。

为了解决这些问题,我们通常会引入一个庞大的第三方库,增加了项目的打包体积。

但实际上,浏览器已经给了我们一个“官方答案”——Intl对象。本文将带你深入了解这个原生API,看看如何用它来优雅地处理国际化需求,让你告别大部分手写i18n的繁琐工作。


数字格式化 (Intl.NumberFormat):不只是加个千分位

这是Intl对象中最常用的功能之一。它可以根据不同的语言环境,格式化数字、货币、单位等。

1. 千分位处理

const number = 1234567.89;

// 美式英语
console.log(new Intl.NumberFormat('en-US').format(number));
// 输出: "1,234,567.89"

// 德语
console.log(new Intl.NumberFormat('de-DE').format(number));
// 输出: "1.234.567,89" (注意分隔符的区别)

2. 格式化货币、单位和百分比

Intl.NumberFormat的第二个参数是一个options对象,可以解锁更多强大的功能。

const price = 99.9;

// 格式化货币
console.log(new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(price));
// 输出: "$99.90"
console.log(new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' }).format(price));
// 输出: "¥100" (日元会自动四舍五入到整数)

const speed = 120;
// 格式化单位
console.log(new Intl.NumberFormat('en-US', { style: 'unit', unit: 'kilometer-per-hour' }).format(speed));
// 输出: "120 km/h"
console.log(new Intl.NumberFormat('zh-CN', { style: 'unit', unit: 'gigabyte', unitDisplay: 'long' }).format(100));
// 输出: "100吉字节"

const percentage = 0.85;
// 格式化百分比
console.log(new Intl.NumberFormat('en-US', { style: 'percent' }).format(percentage));
// 输出: "85%"

3. 紧凑数字格式

这个功能非常适合在UI空间有限的地方显示大数字。

const views = 123456;

console.log(new Intl.NumberFormat('en-US', { notation: 'compact' }).format(views));
// 输出: "123K"
console.log(new Intl.NumberFormat('zh-CN', { notation: 'compact' }).format(views));
// 输出: "12万"

日期和时间格式化 (Intl.DateTimeFormat):date-fns的轻量替代

处理日期是另一个常见的痛点。Intl.DateTimeFormat提供了一套完整且灵活的解决方案。

1. 基础用法与预设样式

const date = new Date(); // 假设现在是 2025年7月25日

// 使用预设样式
console.log(new Intl.DateTimeFormat('en-US', { dateStyle: 'full' }).format(date));
// 输出: "Friday, July 25, 2025"
console.log(new Intl.DateTimeFormat('zh-CN', { dateStyle: 'long' }).format(date));
// 输出: "2025年7月25日"
console.log(new Intl.DateTimeFormat('en-GB', { dateStyle: 'short', timeStyle: 'short' }).format(date));
// 输出: "25/07/2025, 11:11" (英式日期格式)

2. 自定义格式与时区处理

你可以精确地控制你想显示的每一个部分。

const date = new Date();

const options = {
    year: 'numeric',    // "2025"
    month: 'long',      // "July" or "七月"
    day: '2-digit',     // "25"
    weekday: 'short',   // "Fri" or "周五"
    hour: 'numeric',
    minute: 'numeric',
    second: 'numeric',
    timeZoneName: 'short',
    timeZone: 'Asia/Taipei' // 关键!指定时区
};

console.log(new Intl.DateTimeFormat('en-US', options).format(date));
// 输出: "Fri, July 25, 2025 at 11:11:10 AM GMT+8"
console.log(new Intl.DateTimeFormat('zh-TW', options).format(date));
// 输出: "2025年7月25日 週五 上午11:11:10 GMT+8"

时区处理Intl.DateTimeFormat的一大亮点,无需任何第三方库,就能准确地在不同时区之间转换和显示时间。


复数处理 (Intl.PluralRules):终结 if (count > 1)

如何根据数量显示单数或复数?比如 "1 item" 和 "2 items"。在英语里,一个if (count > 1)似乎就解决了。但在其他语言里,复数规则远比这复杂(比如俄语、阿拉伯语有好几种复数形式)。

Intl.PluralRules就是用来解决这个问题的“标准答案”。它不会直接帮你拼接字符串,而是告诉你一个数字在特定语言环境下,属于哪种复数类别"zero", "one", "two", "few", "many", "other")。

// 以英语为例
const enPluralRules = new Intl.PluralRules('en-US');

console.log(enPluralRules.select(0)); // "other"
console.log(enPluralRules.select(1)); // "one"
console.log(enPluralRules.select(2)); // "other"

// 以波兰语为例,它的复数规则更复杂
const plPluralRules = new Intl.PluralRules('pl-PL');

console.log(plPluralRules.select(1)); // "one" (1)
console.log(plPluralRules.select(2)); // "few" (2, 3, 4)
console.log(plPluralRules.select(5)); // "many" (5, 6, ...)

用法:

const messages = {
  zh: {
    one: `有 {count} 个项目`,
    other: `有 {count} 个项目`
  },
  en: {
    one: `Found {count} item`,
    other: `Found {count} items`
  }
};

function getPluralMessage(locale, count) {
  const langMessages = messages[locale];
  const pluralRules = new Intl.PluralRules(locale);
  const rule = pluralRules.select(count); // 获取复数类别
  return langMessages[rule].replace('{count}', count); // 根据类别选择正确的字符串
}

console.log(getPluralMessage('en', 1)); // "Found 1 item"
console.log(getPluralMessage('en', 5)); // "Found 5 items"

相对时间格式化 (Intl.RelativeTimeFormat)

这也是一个非常实用的功能,用于显示“昨天”、“5分钟前”、“3周后”这种相对时间。

const rtf = new Intl.RelativeTimeFormat('zh-CN', { numeric: 'auto' });

console.log(rtf.format(-1, 'day'));   // "昨天"
console.log(rtf.format(0, 'day'));    // "今天"
console.log(rtf.format(2, 'hour'));   // "2小时后"
console.log(rtf.format(-5, 'minute'));// "5分钟前"

Intl对象是浏览器提供的一套强大、标准且高效的国际化工具集。

下次再遇到国际化需求时,先别急着npm install。看一看浏览器原生的Intl对象,它可能已经为你准备好了更轻量、更标准的解决方案。

AVIF vs. JPEG XL:2025年,我们该为网站选择哪种下一代图片格式?

大概从五六年前开始,WebP作为Google推出的图片格式,凭借其出色的压缩能力,逐渐取代了JPEG和PNG,成为我们前端性能优化的首选。

但技术总是在进步。当我们还在享受WebP带来的红利时,两个更强大的“挑战者”已经悄然成熟,它们就是AVIFJPEG XL

这两个格式,都号称比WebP有更强的压缩率和更好的画质。那么,在2025年的今天,当我们需要为新项目选择图片格式时,到底该选谁?它们之间又有什么关键的区别?

这篇文章,将从压缩率与画质、功能特性、编解码速度浏览器支持度这几个核心维度,对它们进行一次全面的对比,希望能帮你做出明智的决策。


两种格式的背景

在对比之前,我们先简单了解一下它们的来历。

9cb64b1ddf50eff3.svg

AVIF (AV1 Image File Format):它的“父亲”是AV1视频编码标准,背后是开放媒体联盟(AOMedia),成员包括Google、Apple、Netflix、Amazon这些行业巨头。可以说,AVIF是含着“金钥匙”出生的,它的核心优势继承自AV1,就是极高的压缩率

image.png

  • JPEG XL (JXL):它的出身则更为“正统”,由JPEG委员会(就是创造JPEG格式的那个组织)推出,目标是成为JPEG的下一代“完全体”。它的核心优势是全面的功能对现有生态的向后兼容

四大维度的全面比较

维度一:压缩率与画质

这是大家最关心的部分。一个图片格式好不好,最直观的就是“体积小不小,图片清不清晰”。

总的来说,AVIF和JXL的压缩率都显著优于WebP和JPEG。但在不同场景下,二者表现有所差异。

  • AVIF:在极低的比特率下(也就是把图片压到非常小的时候),AVIF通常能保持比JXL更好的可用画质,涂抹感控制得更好。
  • JPEG XL:在中高画质下,JXL对细节和纹理的保留能力非常出色,画面更自然、更接近原图,很少出现AVIF那种色块和振铃效应。

口说无凭,咱们看图👇

image.png

image.png

维度二:功能特性

如果说压缩率是“硬实力”,那功能特性就是“软实力”。

特性 AVIF JPEG XL 说明
无损/有损压缩 支持 支持 两者都支持
动画 支持 支持 两者都能取代GIF
Alpha透明通道 支持 支持 两者都能取代PNG
渐进式渲染 不支持 原生支持 JXL的巨大优势,图片可以由模糊变清晰,体验更好
广色域/HDR 支持 支持 两者都支持高动态范围和广色域
JPEG无损再压缩 不支持 原生支持 JXL的“杀手级特性”,可以将现有JPEG无损转换为JXL,体积减小约20%

JPEG XL的功能全面性,可以说是“降维打击”。特别是 渐进式渲染JPEG无损再压缩 这两个特性,对于提升用户体验和降低迁移成本,具有巨大的现实意义。

维度三:编解码速度
  • 编码速度(生成图片):这是AVIF目前最大的短板。AVIF的编码过程非常消耗计算资源,速度很慢。而JPEG XL的编码速度则快得多,几乎和使用mozjpeg压缩JPEG一样快。
  • 解码速度(浏览器显示图片):两者解码速度都很快,能满足Web需求。JPEG XL的设计考虑到了多线程并行解码,理论上更具优势。

咱们看一组图:

image.png

虽然解码速度比不上JPEG,WEBP 那么强,但是他的压缩效率和解压速度 还是值得参考的。

维度四:浏览器支持度(2025年的现状)

这是决定一个技术能否被广泛使用的关键,也是两者“恩怨情仇”的开始。

  • AVIF:得益于Google的强力推动,到2025年,AVIF的支持度已经非常好,几乎所有主流浏览器(Chrome, Firefox, Safari)都已稳定支持。

image.png

  • JPEG XL:JXL的经历则要曲折得多。Chrome曾在2022年底以“生态系统利益不足”为由,移除了对JXL的支持,引发了社区的巨大争议。但在其他浏览器厂商(特别是苹果和Mozilla)的坚持,以及社区的持续呼吁下,情况迎来了转机。

image.png

好消息是,到了2025年7月的今天,我们可以发现JXL已经在safari 上开始支持,尽管其普及率可能还不能用在生产环境中,但将来各大浏览器肯定会追上脚步。


我到底该怎么选?

好了,分析了这么多,我们来总结一下,在你的项目中到底该如何选择。

让浏览器自己选!哈哈哈哈😀

其实,我们不必做出“二选一”的决定。最好的方式,是同时提供多种格式,利用HTML的<picture>元素,让浏览器根据自己的支持情况,去加载最优的格式。

<picture>
  <source srcset="image.jxl" type="image/jxl">
  
  <source srcset="image.avif" type="image/avif">
  
  <source srcset="image.webp" type="image/webp">
  
  <img src="image.jpg" alt="描述文字">
</picture>

分析完毕!

localStorage 你很好,我选 IndexedDB

localStorage 你很好,我选 IndexedDB

在 Web 开发的早期,我们对 localStorage 是爱得深沉的。

简单的 API,直接调用就能存数据,写起来干净利落。

localStorage.setItem('theme', 'dark');

用过的人都懂,仿佛就是前端开发入门的“Hello World”。

但当应用体积变大,需求复杂了,我意识到,

是时候和 old friend 分手了。

我不是不爱你,只是你真的满足不了我了(渣言渣语~)。

大家好,我是芝士,欢迎点此扫码加我微信 Hunyi32 交流,最近创建了一个低代码/前端工程化交流群,欢迎加我微信 Hunyi32 进群一起交流学习,也可关注我的公众号[ 前端界 ] 持续更新优质技术文章

你很好,但问题也不少

1. 安全问题:XSS 一旦发生,localStorage 首当其冲

localStorage 是同步 API,不受同源页面任何 JS 限制,这也意味着:

// 如果页面存在 XSS 漏洞
const token = localStorage.getItem('authToken');
// 那你的用户登录态,可能就被黑了

对攻击者来说,这比写 cookie 更简单。

2. 性能问题:你会阻塞 UI 主线程

在现代前端应用中,我们尽量避免“卡顿”。

但只要用到 localStorage.setItem(),尤其是数据一大,整个主线程就像被泼了冷水。

// 会阻塞页面渲染,影响用户体验
localStorage.setItem('hugeData', massiveJsonString);

这在写入缓存、异步数据同步中,非常危险。

3. 容量限制:你装不下我想存的世界

现代应用动不动就要缓存几百 MB 的图、音、视频资源。而 localStorage 的可用空间……顶天就 5MB。

更别说,它只支持字符串,所有数据都得自己 JSON.stringify() / parse(),很麻烦。

// 不能直接存对象,也不能存二进制
localStorage.setItem('user', JSON.stringify(userObj));

IndexedDB:我的新选择

我承认,第一次接触 IndexedDB,我是拒绝的。

API 繁琐、异步、回调多、还有版本控制 —— 这谁顶得住?

直到我真正用上它,我才明白:这是为现代 Web 应用而生的本地数据库

非阻塞,异步存储的典范

IndexedDB 所有操作都是异步的,写再多也不会卡住页面。

const dbRequest = indexedDB.open('MyApp', 1);
dbRequest.onsuccess = () => {
  const db = dbRequest.result;
  const store = db.transaction(['users'], 'readwrite').objectStore('users');
  store.add({ id: 1, name: 'Alice' });
};

再也不用担心卡顿、闪屏、用户诧异地问:“刚刚页面是不是卡住了?”

结构化对象 + 二进制,一把拿捏

不用再手动序列化、反序列化。直接存对象,存 Blob,存 Buffer,存你想存的一切。

store.add({
  id: 2,
  name: 'Bob',
  avatar: blobData, // 二进制数据
  preferences: { theme: 'dark', language: 'en' }
});

这就是浏览器原生支持的 NoSQL 数据库体验

存储空间,几乎无限

一般来说,IndexedDB 的存储上限在几百 MB 甚至数 GB。

如果你在做离线应用、缓存图片资源、预存图谱模型,这就是你最好的选择。

localStorage 只能存备忘录,而 IndexedDB 可以做整套图书馆管理。

localStorage 也有它的位置

别误会,我不是全盘否定 localStorage

像这类“小而简单”的场景,localStorage 依旧是你的好拍档:

  • UI 状态缓存(主题、语言)
  • 非敏感布尔标记(关闭广告提示框)
  • 临时、简单的设置项
localStorage.setItem('theme', 'dark');
localStorage.setItem('hideModal', 'true');

但你只要一涉及到性能、容量、安全性,我建议你立刻切换 IndexedDB。

迁移指南:如何平滑过渡?

1. 先审查当前 localStorage 用法

const auditLocalStorage = () => {
  const result = {};
  for (let i = 0; i < localStorage.length; i++) {
    const key = localStorage.key(i);
    result[key] = localStorage.getItem(key)?.length || 0;
  }
  return result;
};

2. 设计数据库结构(类似建表)

const schema = {
  name: 'MyAppDB',
  version: 1,
  stores: [
    {
      name: 'userStore',
      keyPath: 'id',
      indexes: [{ name: 'email', keyPath: 'email', unique: true }]
    },
    {
      name: 'cacheStore',
      keyPath: 'url',
    }
  ]
};

如果你觉得 API 太原始,还可以用第三方封装库,如:

  • localForage:封装了 IndexedDB / WebSQL / localStorage,统一 Promise API
  • Dexie.js:提供类 SQL 查询方式,更适合开发者

总结:不是分手,是升级

localStorage 之于前端,就像 Nokia 之于手机历史 —— 功不可没,但终究被替代。

当你的项目:

  • 对性能有要求
  • 要求安全可靠的数据存储
  • 需要存储海量结构化数据

那么,是时候说一句:

localStorage,你很好,但我选 IndexedDB。

如果你想了解如何一步步在项目中引入 IndexedDB 或封装 localForage,欢迎留言交流,我可以写一篇实战教程来补上这块拼图 👀

大家好,我是芝士,欢迎点此扫码加我微信 Hunyi32 交流,最近创建了一个低代码/前端工程化交流群,欢迎加我微信 Hunyi32 进群一起交流学习,也可关注我的公众号[ 前端界 ] 持续更新优质技术文章

写在最后:最好的本地存储方案,从不是最流行的,而是最适合你项目需求的那一个。希望你不再被 5MB 局限,也别再手动 JSON.stringify() 了。

下一次存储用户数据时,不妨尝试一下 IndexedDB 吧,它可能会让你彻底“改观”。

🌍 Three.js × 地理坐标:在地球上画一条银河

如何优雅地将 Three.js 与 Mapbox 联姻

“一切3D图形的壮美,如果不落于地理坐标系上,终究只是漂浮在虚空之中。”


🪐 开场白:两个宇宙的语言

在这片数字宇宙中,Three.js 是一个画家,擅长在三维空间里舞动顶点与光影。
而 Mapbox,是一位地图编舞师,它把地球切割成瓦片,铺展开数据与现实的边界。

你想让你的 3D 模型落在北京天安门广场?
或者想在巴黎铁塔上浮现一只飞龙?
抱歉,Three.js 默认的 (0, 0, 0),可不知道这些地方在哪儿。

于是,我们必须做一件事:让地理坐标和 Three.js 的笛卡尔坐标握手言和


🧭 第一课:地理坐标是怎么回事?

地球上的点,通常使用 经纬度表示:

  • 经度(Longitude) :东经/西经多少度
  • 纬度(Latitude) :北纬/南纬多少度
  • 可选的还有:高度(Altitude / Elevation)

看起来像这样:

const lng = 116.3913; // 北京
const lat = 39.9075;
const altitude = 50; // 海拔50米

但在 Three.js 中,坐标却是像这样:

const position = new THREE.Vector3(x, y, z);

经度纬度是地球的语言,Three.js 坐标是线性代数的语言,我们需要一个翻译官。


🌐 第二课:Mapbox 如何翻译地理坐标?

Mapbox 的世界是一张Web Mercator 投影地图,地球被拉成了一个矩形。每个地理点,都可以被转换为一个世界坐标

const mercator = mapboxgl.MercatorCoordinate.fromLngLat(
  { lng: 116.3913, lat: 39.9075 },
  0 // 海拔高度(单位:米)
);

这个函数返回了一个对象:

{
  x: 0.91576,
  y: 0.48563,
  z: 0
}

这个 x, y, z 就是我们可以在 Three.js 中使用的坐标!

不过,慢着……我们要注意一点:Mapbox 的单位是“米”,Three.js 的单位是“自己说了算”

所以我们还需要:统一单位比例(scaling factor)


🛠️ 第三课:Three.js 模型落地地球实战

下面是一个 Three.js + Mapbox 联动的完整流程。

🎥 初始化 Mapbox 场景

mapboxgl.accessToken = '你的 Mapbox Token';

const map = new mapboxgl.Map({
  container: 'map',
  style: 'mapbox://styles/mapbox/light-v11',
  center: [116.3913, 39.9075],
  zoom: 16,
  pitch: 60,
  bearing: -17,
  antialias: true
});

🎮 添加 Three.js 场景

map.on('style.load', () => {
  const customLayer = {
    id: '3d-model-layer',
    type: 'custom',
    renderingMode: '3d',
    onAdd: function (map, gl) {
      this.camera = new THREE.Camera();
      this.scene = new THREE.Scene();

      const light = new THREE.DirectionalLight(0xffffff);
      light.position.set(0, -70, 100).normalize();
      this.scene.add(light);

      const geometry = new THREE.BoxGeometry(10, 10, 10);
      const material = new THREE.MeshStandardMaterial({ color: 0xff0000 });
      const cube = new THREE.Mesh(geometry, material);

      // 经纬度转地图单位坐标
      const mercator = mapboxgl.MercatorCoordinate.fromLngLat(
        { lng: 116.3913, lat: 39.9075 },
        0
      );

      const scale = mercator.meterInMercatorCoordinateUnits(); // 米转单位

      cube.position.set(mercator.x, mercator.y, mercator.z);
      cube.scale.set(scale, scale, scale);

      this.scene.add(cube);

      this.renderer = new THREE.WebGLRenderer({ canvas: map.getCanvas(), context: gl, antialias: true });
      this.renderer.autoClear = false;
    },
    render: function (gl, matrix) {
      const m = new THREE.Matrix4().fromArray(matrix);
      this.camera.projectionMatrix = m;
      this.renderer.state.reset();
      this.renderer.render(this.scene, this.camera);
      map.triggerRepaint();
    }
  };

  map.addLayer(customLayer);
});

🧪 实验小结

“当一座三维城堡落在地图中,它才有了现实世界的归属。”

本例中,我们通过 Mapbox 的 MercatorCoordinate 把经纬度转换为 Three.js 使用的线性坐标,并使用 scale = meterInMercatorCoordinateUnits() 来保证大小看起来正确。


🧠 拓展思考:为什么它行得通?

  • 地图的空间系统和 Three.js 的世界空间,其实都是数学模型;
  • 投影系统(Web Mercator)把地球表面摊平;
  • 只要单位对齐、坐标转换无误,Two Worlds 就可以 Merge。

🧙 Bonus:地球是圆的,但你画的是平的?

是的,这里使用的是 Web Mercator 投影,本质上是 局部平面展开
所以在小范围内非常准确,但如果你试图把 3D 模型放在南极和北极之间飞行……它就开始“变形”了。


🎇 结语:宇宙中最浪漫的交汇

“图形的浪漫在于幻想,地图的浪漫在于现实。而当你让三维图形落地在真实世界,那是浪漫与理性最优雅的交融。”

从此,Three.js 不再漂浮在抽象空间,而是拥有了家,落在了经纬度上。
你可以在东京摆一尊观音,在纽约放一条龙,或在你家门口放一艘飞船。

Vue商城小技巧:返回列表页时,如何记住滚动位置?

大家好,我是小杨,一个写了6年前端的老码农。最近在做商城项目时,遇到一个很常见的需求:从列表页进入详情页,返回时希望列表页能自动滚动到之前的位置,而不是从头开始。

这功能听起来简单,但如果不注意细节,用户体验就会很糟糕。今天我就来分享一下我的实现方案,保证简单易懂,直接上代码!


1. 问题场景复现

假设我们有一个商品列表页(GoodsList.vue),点击某个商品进入详情页(GoodsDetail.vue),然后返回时发现列表页又回到了顶部,用户体验很不友好:

<!-- GoodsList.vue -->
<template>
  <div>
    <div v-for="item in goodsList" :key="item.id" @click="goToDetail(item.id)">
      {{ item.name }}
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      goodsList: [
        { id: 1, name: "商品1" },
        { id: 2, name: "商品2" },
        // ... 假设有100个商品
      ]
    }
  },
  methods: {
    goToDetail(id) {
      this.$router.push(`/detail/${id}`)
    }
  }
}
</script>

这时候,用户滚动到第50个商品,点击进入详情页,再返回时,列表页又回到了顶部,体验很糟糕!


2. 解决方案:使用keep-alive + scrollBehavior

2.1 用<keep-alive>缓存列表页

Vue的<keep-alive>可以让组件不销毁,保留之前的状态(包括滚动位置)。

<!-- App.vue 或 路由出口处 -->
<template>
  <div id="app">
    <keep-alive>
      <router-view v-if="$route.meta.keepAlive" />
    </keep-alive>
    <router-view v-if="!$route.meta.keepAlive" />
  </div>
</template>

然后在路由配置里标记哪些页面需要缓存:

// router.js
const routes = [
  {
    path: '/list',
    name: 'GoodsList',
    component: GoodsList,
    meta: { keepAlive: true } // 标记需要缓存
  },
  {
    path: '/detail/:id',
    name: 'GoodsDetail',
    component: GoodsDetail
  }
]

2.2 记录滚动位置,并在返回时恢复

我们可以利用Vue Router的scrollBehavior来管理滚动行为:

// router.js
const router = new VueRouter({
  routes,
  scrollBehavior(to, from, savedPosition) {
    // 如果是从详情页返回,并且有保存的位置,就恢复
    if (from.name === 'GoodsDetail' && savedPosition) {
      return savedPosition
    }
    // 否则默认滚动到顶部
    return { x: 0, y: 0 }
  }
})

2.3 优化:手动记录滚动位置(更精准)

如果scrollBehavior不够精准,我们还可以手动记录滚动位置:

// GoodsList.vue
export default {
  data() {
    return {
      scrollTop: 0
    }
  },
  activated() {
    // 从缓存恢复时,滚动到之前的位置
    document.documentElement.scrollTop = this.scrollTop
  },
  beforeRouteLeave(to, from, next) {
    // 离开时记录滚动位置
    this.scrollTop = document.documentElement.scrollTop
    next()
  }
}

3. 最终效果

  • ✅ 进入详情页后返回,列表页会自动滚动到之前的位置
  • ✅ 用户体验流畅,不会出现“突然跳回顶部”的尴尬情况
  • ✅ 代码简单,维护成本低

4. 可能遇到的问题

  • keep-alive缓存太多页面导致内存问题?  → 可以结合include/exclude控制缓存范围
  • 滚动位置不准?  → 检查CSS布局(比如overflow设置)
  • 动态路由(如分页)怎么处理?  → 可以用key强制刷新组件

5. 总结

在Vue项目中,保持滚动位置其实很简单,核心就是:

  1. <keep-alive>缓存页面
  2. scrollBehavior或手动记录滚动位置
  3. 注意动态路由的特殊情况

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

前端工程化:Webpack Scope Hoisting

当 webpack 把几百个模块打包进同一个 bundle 时,默认会为每个模块生成一段包裹函数。这些函数在运行期通过 __webpack_require__ 逐个调用,保证了模块作用域互不干扰,却也带来了额外的函数调用开销与体积膨胀。Scope Hoisting(作用域提升)正是为了拆除这道“隔离墙”,让模块内容直接合并进更大的作用域,从而减少函数数量、提升执行效率。

一、隔离墙的诞生

在 webpack 3 之前,所有模块被转译成如下形式:

function(module, exports, __webpack_require__) {
  // 模块 A 的源码
}

运行期通过索引调用这些闭包,相当于在浏览器里模拟 CommonJS 的同步 require。虽然安全,但每多一个模块就多一次闭包创建与调用,CPU 与内存都不讨好。

二、作用域提升的机理

Scope Hoisting 的核心思想是静态分析 + 代码合并。webpack 会在生产模式下启用 ModuleConcatenationPlugin,流程大致如下:

  1. 构建依赖图:遍历模块,标记所有导入导出。
  2. 闭包消除:将模块代码平铺到同一个函数作用域,同时重命名冲突标识符。
  3. 作用域链重构:通过 AST 重写,确保 import/export 语义不变,但不再产生运行时闭包。

最终产物里,原本分散的几十个小函数被“拍扁”成一段连续代码,模块间引用变成普通变量访问,直接去掉一层函数调用栈。

三、启用条件与边界情况

为了安全合并作用域,webpack 要求模块满足:

  • 必须是 ESM 格式(import/export 静态声明);
  • 不能出现循环依赖多次引用;
  • 不能包含动态导入 import()
  • 不能是 CommonJS 或 AMD 模块。

只要任一条件不成立,当前模块及其子树会回退到传统包裹函数,保证行为一致。

四、收益量化

以典型的中型应用为例,开启 Scope Hoisting 后常见指标:

  • 包体积减少 5%~15%,主要节省函数声明与闭包开销;
  • 运行时 CPU 占用降低,尤其在启动阶段;
  • 内存占用下降,因为减少了函数对象数量。

五、开发者如何干预

webpack 在生产模式下默认启用该优化,无需额外配置。若需手动关闭(调试阶段),可通过:

optimization.concatenateModules = false;

强制禁用。此外,保持代码 ESM 化、避免循环依赖、减少动态导入,都能让 Scope Hoisting 覆盖更多模块,进一步放大优化效果。

结语

Scope Hoisting 把“模块隔离”这一历史包袱转化为性能红利,体现了现代打包工具向静态分析与编译时优化演进的趋势。理解其原理与限制,有助于在大型项目中制定更合理的模块拆分与依赖策略,让代码既安全又高效。

如何优雅地实现每 5 秒轮询请求?

在做实时监控系统时,比如服务器状态面板、订单处理中心或物联网设备看板,每隔 5 秒自动拉取最新数据是再常见不过的需求了。 但你有没有遇到过这些问题? 页面切到后台还在疯狂发请求,浪费资源 上一次请求还

Vue3 Effect源码解析

版本:Vue 3.5.17 1. 核心概念 effect 是 Vue 3 响应式系统的核心部分,主要负责依赖追踪和自动响应。它通过 ReactiveEffect 类来封装副作用逻辑,实现依赖收集和触发

React 路由配置:useRoutes 的使用详解

在现代前端开发中,路由配置是构建单页应用(SPA)的重要组成部分。React 中最常用的路由库是 react-router-dom,它帮助我们实现页面之间的跳转和组件的动态加载。 今天我们就来聊一聊

Webpack 5 新特性解析

一、引言

历经五年迭代,Webpack 5 于 2020 年 10 月正式 GA,随后在 5.x 的历次小版本中持续交付性能红利与开发体验升级。本文将聚焦五个关键领域:输出清理、顶层 await、体积优化、持久缓存与资源模块,帮助团队无痛迁移并充分理解新版本。

二、输出目录自动清理

在 Webpack 4 时代,开发者需引入 clean-webpack-plugin 以避免旧文件残留。Webpack 5 将这一需求下沉至核心:

module.exports = {
  output: {
    clean: true
  }
}

启用后,每次构建前会递归清空 output.path 目录,确保产物纯净,无需额外插件与脚本。

三、顶层 await

ECMAScript 提案的 top-level-await 允许在模块顶层直接使用 await 语法,Webpack 5 通过 experiments.topLevelAwait 实验开关率先落地:

// src/index.js
const resp = await fetch('https://api.example.com');
export default await resp.json();

配置片段:

module.exports = {
  experiments: { topLevelAwait: true }
}

构建阶段,Webpack 会将包含顶层 await 的模块标记为异步边界,动态生成 Promise 包裹,保持运行语义不变,同时兼容 Tree Shaking 与 Scope Hoisting。

四、打包体积优化再进化

Webpack 5 在模块合并、作用域提升(Scope Hoisting)、Tree Shaking 三条路径上引入更激进的静态分析策略:

  • 副作用标记(sideEffects)与导出使用追踪(usedExports)联动,精确剪除未引用代码;
  • ConcatenatedModule 算法优化,减少闭包与函数声明数量;
  • 嵌套 import() 场景下,公共依赖自动提升至共享 Chunk,避免重复打包。
    实测中型项目体积降幅 8%–15%,冷启动内存占用同步下降。

五、持久缓存

Webpack 4 时代需借助 cache-loader 或 hard-source-webpack-plugin 实现缓存。Webpack 5 默认启用文件系统级缓存:

const path = require('path');
module.exports = {
  cache: {
    type: 'filesystem',
    cacheDirectory: path.resolve(__dirname, 'node_modules/.cache/webpack')
  }
}

首次构建后,模块与 Chunk 的编译结果序列化至磁盘;二次构建仅重新编译变更模块,配合 incremental compilation,二次构建耗时缩短 60% 以上。CI 环境下,将缓存目录挂载至持久化存储,可显著降低流水线运行时间。

六、资源模块:告别 Loader 地狱

Webpack 4 通过 file-loader、url-loader、raw-loader 处理静态资源,配置冗长且版本碎片化。Webpack 5 原生引入 Asset Modules:

  • asset/resource:等价于 file-loader,输出独立文件;
  • asset/inline:等价于 url-loader ≤ 8KB 内联;
  • asset/source:等价于 raw-loader,返回源文件字符串;
  • asset:根据体积阈值自动选择 inline 或 resource。

示例:

module.exports = {
  module: {
    rules: [
      {
        test: /\.(png|jpg|gif)$/i,
        type: 'asset',
        parser: { dataUrlCondition: { maxSize: 8 * 1024 } }
      }
    ]
  }
}

原生实现带来零依赖、更小的维护面与更一致的行为语义。

七、迁移路径与实践建议

  1. 渐进式升级:保留 Webpack 4 配置主干,逐项启用新特性验证回归。
  2. 缓存策略:CI 流水线挂载 node_modules/.cache/webpack,缩短二次构建。
  3. 资源模块:逐步移除 file-loader/url-loader,统一使用 Asset Modules。
  4. 性能基线:构建前后分别记录产物体积、构建耗时、内存峰值,量化收益。

结语

Webpack 5 以“开箱即用”为核心理念,将过去需要插件介入的能力下沉至核心,既保持了向后兼容,又提供了显著的性能红利。通过合理配置 clean、cache、asset 类型与顶层 await,开发者可在零额外依赖的前提下,获得更快的构建、更小的包体以及更清晰的工程结构。

package.json 中 dependencies 的版本号:它真的是版本号吗?

在 Node.js 或前端项目中,package.json 是项目依赖管理的核心文件。我们常常在 dependenciesdevDependenciespeerDependencies 等字段中指定每个依赖的“版本号”。然而,这些“版本号”并不总是真正的版本号,它们还可以是本地路径、Git 地址、文件系统地址,甚至是通配符等。本文将全面介绍这些用法及其含义,并通过示例加深理解。

一、常规版本号语法

在介绍一些你可能不熟悉的用法前,让我们先回顾一下你所熟悉的用法。

1. 精确版本

"lodash": "4.17.21"

表示只能安装 4.17.21 版本,不能有任何波动。

2. 范围版本

^:兼容主版本

"lodash": "^4.17.0"

表示安装 >=4.17.0 <5.0.0 的版本。常用于库依赖,确保 API 向后兼容。

~:兼容小版本

"lodash": "~4.17.0"

表示安装 >=4.17.0 <4.18.0 的版本。适用于只允许 patch 更新的情况。

区间范围

"lodash": ">=4.17.0 <5.0.0"

明确指定版本范围,更加灵活。

二、非常规版本号语法

下面让我们来瞧一瞧一些非常规的版本号。

1. 星号 *

"lodash": "*"

表示任意版本都可以安装。这种用法在生产环境中风险较高,容易引入不兼容版本,通常只在快速原型或测试中使用。在使用monorepo的时候, * 还有一些特殊的用处,将在 第八节 中进行介绍。

2. 最新版本标签(例如 latestbeta

json
"some-lib": "latest"
"some-lib": "next"
"some-lib": "beta"

这些是 NPM 的 dist-tag,会安装对应 tag 指向的版本。例如 latest 通常是当前稳定版。

三、本地路径引用

在本地开发多个包时联调,这种用法非常有用,可以让包管理工具从本地文件夹安装依赖。

1. file: 协议

json
复制编辑
"my-lib": "file:../my-local-lib"

表示从本地文件夹安装依赖。你也可以指定 .tgz 包文件:

"my-lib": "file:./libs/my-lib-1.0.0.tgz"

2. 省略 file: 前缀后的行为分析

有时候,你可能会看到不少人在使用file: 协议的时候,省略了 file: 前缀。其实,这种写法是有一些问题的,因为它的行为取决于你使用的包管理器(npm、yarn、pnpm)以及路径的格式。

✅ 可以省略 file: 的情况(部分工具 & 格式)

某些情况下,比如你写的是一个相对路径(不带协议),部分包管理器会自动推断为本地路径,并当作 file: 来处理

例如:

"my-lib": "../my-lib"

等价于:

"my-lib": "file:../my-lib"

在以下情况下大多数工具都可以正确解析

  • 相对路径:../lib./lib
  • 绝对路径:/Users/xxx/project/lib

🚫 不能省略 file: 的情况

以下情况必须带 file: 前缀:

  • 路径为压缩文件(如 .tgz)时,必须加 file:
"my-lib": "file:../my-lib-1.0.0.tgz""my-lib": "../my-lib-1.0.0.tgz" ❌(npm 会报错)
  • Monorepo 使用 Yarn workspace 时,推荐显式加 file:

虽然 Yarn 可以自动识别 workspace 下的路径,但显式指定 file:workspace: 更清晰且可读性更强。

各工具行为差异总结

场景 / 工具 相对路径是否可以省略 file: .tgz 是否可以省略 file: 推荐做法
npm ✅ 是(对文件夹) ❌ 否(对文件) 显式写 file: 更稳妥
Yarn Classic ✅ 是 ❌ 否 显式写 file: 更清晰
Yarn Berry ✅ 是 ❌ 否 更推荐 workspace:file:
pnpm ✅ 是(支持路径 auto 推断) ❌ 否 推荐使用 file:

3. 推荐实践

为了 最大兼容性可读性清晰,建议始终显式使用 file:

"my-lib": "file:../my-lib"
"my-lib": "file:../my-lib-1.0.0.tgz"

四、Monorepo 场景中的 workspace 协议

在使用Monorep的时候,工作区中的package.json(即非根package.json)中可以使用 workspace:*workspace:^workspace:~,例如:

"my-shared-lib": "workspace:*"

这是 Yarnpnpm 在 Monorepo 项目中支持的特性,用于声明依赖于工作区中其它包。

  • workspace:*:匹配任意版本。
  • workspace:^1.2.0:等价于 ^1.2.0,但强制来自 workspace 中的包。
  • workspace:~1.2.0:与上类似,强制来自 workspace。

注意npm 从 v7 之后也开始支持 workspaces,但不支持 workspace:* 这种语法。

五、Git 仓库引用

1. Git 地址(使用 HTTPS 或 SSH)

"my-lib": "git+https://github.com/username/my-lib.git"

"my-lib": "git+ssh://git@github.com:username/my-lib.git"

默认会安装该仓库的 master/main 分支的最新提交。

2. Git 地址 + tag / branch / commit

"my-lib": "git+https://github.com/username/my-lib.git#v1.2.3"
"my-lib": "git+https://github.com/username/my-lib.git#develop"
"my-lib": "git+https://github.com/username/my-lib.git#6db6f8a"
  • #v1.2.3:指定 tag
  • #develop:指定分支
  • #commit-hash:指定具体 commit

六、URL(HTTP 资源)

"my-lib": "https://example.com/path/to/my-lib.tgz"

可以直接从远程地址下载 .tgz 包。这种方式不常见,适用于自建仓库或发布测试包。

七、其他不常见用法

GitHub 缩写(npm 特有语法)

"my-lib": "username/my-lib"

等价于:

"my-lib": "git+https://github.com/username/my-lib.git"

还可以加上 tag:

"my-lib": "username/my-lib#v1.0.0"

八、混用情况分析:版本冲突怎么处理?

以 Monorepo 中为例:

// 根 package.json
"lodash": "^4.17.0"

// 工作区中某个子包的 package.json
"lodash": "*"

在这种情况下,具体安装哪个版本由依赖管理工具(Yarn、PNPM、npm)决定:

  • Yarn Berry/PNPM(hoist=false) 会使用子包中的声明版本(也可能安装多个版本)
  • NPM/Yarn classic(hoist=true) 优先使用根目录中的版本(如果符合子包声明)

所以建议统一声明版本,或在子包中使用 workspace:* 强制引用根版本。

九、总结

类型 示例 说明
精确版本 "lodash": "4.17.21" 只安装指定版本
版本范围 "lodash": "^4.17.0" 安装范围内最新版本
任意版本 "lodash": "*" 安装任意版本(不推荐)
dist-tag "my-lib": "latest" 安装发布标签对应版本
本地文件夹 "my-lib": "file:../my-lib" 引用本地包
本地 tar 包 "my-lib": "file:./lib.tgz" 本地 tgz 文件
Git 仓库 "my-lib": "git+https://github.com/u/lib.git" 从 Git 拉取
Git + tag/commit "my-lib": "git+https://...#v1.0.0" 指定 tag 或提交
URL 下载 "my-lib": "https://example.com/lib.tgz" HTTP 下载
workspace 协议 "my-lib": "workspace:*" Monorepo 工作区依赖

十、推荐实践

  • 生产环境中避免使用 *latest,防止出现意料之外的版本升级。
  • Monorepo 中尽量使用 workspace: ,确保一致性与版本对齐。
  • 本地开发联调建议使用 file: ,快速迭代。
  • 使用 ^~ 时要结合语义化版本管理(SemVer)策略,避免不兼容变更。

一个前端开发者的救赎之路——JS基础回顾(二)

空语句

代码第一行是{,将其识别为代码块,例如:

/**
* 因为第一行是{},被识别成一个代码块,
* 然后代码块内部什么都没有就是空代码块,无操作
* 就等同于 + 0,所以结果为数字0
**/
{} + 0 = 0
// 这个逻辑和上面的一样,- 0 = -0
{} - 0 = -0
/**
* 因为第一行是{},被识别成一个空代码块
* 就成了 + {} => + Number([object object]) => + NaN
**/
{} + {} = NaN

关于上面的{}+{}=NaN,后面的步骤,D老师给的解释

  • 一元 + 运算符ToNumber 抽象操作):
    根据规范 §13.5.1+ 会调用 ToNumber 对操作数进行强制转换:
    • 对第二个 {}(对象)应用 ToNumber
      1. 调用 ToPrimitivehint: "number")转换为原始值(§7.1.1

        • 调用 valueOf():返回对象本身(非原始值)。
        • 调用 toString():返回 "[object Object]"(字符串)。
      2. 对字符串 "[object Object]" 应用 ToNumber§7.1.3):

        • 无法解析为数字,返回 NaN

分支语句

1. if/else

  • 基本语法:

        // else if 和 else 非必须且不可单独使用
        if(exp){
            statement1
        } else if (exp2) {
            statement2
        } else {
            statement3
        }
    
  • 案例一:判断奇偶数

    /**
    * 判断奇偶数
    * 数学:能整除2的就是偶数,否则就是奇数
    **/
    if (num % 2) {
        console.log(num + '是一个奇数')
    } else {
         console.log(num + '是一个偶数')
    }
    
  • 案例二:根据0 ~ 100输出成绩

    /**
    * 根据0 ~ 100输出成绩
    *  [90, 100] 输出A
    *  [80, 90] 输出B
    *  [70, 80] 输出C
    *  [60, 70] 输出D
    *  [0, 60] 输出E
    **/
    if (score >= 90 && score <= 100) {
        console.log('您的成绩为:A')
    } else if(score >= 80) {
        console.log('您的成绩为:B')
    } else if(score >= 70) {
        console.log('您的成绩为:C')
    } else if(score >= 60) {
        console.log('您的成绩为:D')
    } else {
        console.log('您的成绩为:E')
    }
    
  • 案例三:判断闰年

    /**
    * 什么是闰年?
    * 世纪闰年:公历年份是整百的,必须是400的倍数才是闰年
    * 普通闰年:公历年份是4的倍数,且不是100的倍数的
    **/
    if (!(year % 400 || !(year % 4) && year % 100) {
        console.log(year + '年,是闰年!')
    } else {
        console.log(year + '年,是平年!')
    }
    

2. switch

  • 基础语法

    switch(n) {
    case 1:          // 如果n === 1,从这里开始执行
        // 执行第一个代码块
        break;       // 到这里停止
    case 2:          // 如果n === 2,从这里开始执行
        // 执行第二个代码块
        break;       // 到这里停止
    case 3:          // 如果n === 3,从这里开始执行
        // 执行第三个代码块
        break;       // 到这里停止
    default:         // 如果前面都不匹配,从这里开始执行
        // 执行第四个代码块
        break;       // 到这里停止
    
  • 案例一:Switch语句在实际开发中常用来做一些状态标签展示

    switch(n) {
    case '001':          
        document.write("未付款")
        break;       
    case '002':          
        document.write("已付款")
        break;       
    case '003':          
        document.write("已发货")
        break;       
    case '004':         
        document.write("已完成")
        break; 
    default:         // 如果前面都不匹配,从这里开始执行
        document.write("出错了")
        break;       // 到这里停止
    
  • 案例二:判断一个月有多少天

    // 根据1 ~ 12的数字来输出一个月有多少天,不考虑闰年
    switch(month) {
        case 1:
        case 3:
        case 5:
        case 7:
        case 8:
        case 10:
        case 12:
            console.log(month + '月有31天')
            break;
        case 4:
        case 6:
        case 9:
        case 11:
            console.log(month + '月有30天')
            break;
        case 2:
            console.log(month + '月有28天')
            break;
    }
    

  switch语句中的case子句只指定了预期代码的起点,并没有指定终点。在没有break语句的情况下,switch语句从匹配其表达式值的case代码块开始执行,一直执行到代码块结束。

  注意在前边两个例子中,case关键字后面分别是数值和字符串字面量。这是实践中使用switch语句的常见方式,但注意ECMAScript标准孕育每个case后面跟任意表达式。(不建议这样,不如直接使用if语句

  switch语句的首先对跟在switch关键字后面的表达式求值,然后再按照顺序求值case表达式,直至遇到匹配的值。这里的匹配使用的是===全等操作符,而不是==相等操作符,因此表达式必须在没有类型转换的情况下匹配。

  考虑到在switch语句执行时,并不是所有case表达式都会被求值,所以应该避免使用包含副作用的case表达式,比如函数调用或赋值表达式。最可靠的做法是在case后面只写常量表达式。

循环语句

循环语句必须要有某些固定的内容:

  1. 初始化
  2. 条件判断
  3. 要执行的代码
  4. 自身改变

while

  • 基本语法

    while (exp) {
        statement
    }
    
  • 案例一:求数字1-100的和

        var num = 1;
        var sum = 0;
        while (num <= 100) {
            sum += num;
            num++;
        }
    
  • 案例二:求一个数的阶乘

    var factorial = 1, i = 1;
    while(i < num) {
        factorial *= i;
        i++;
    }
    

do/while

  do/while循环与while循环类似,区别是对循环表达式的测试在循环底部而不是顶部。这意味着循环体始终会至少执行一次。语法如下:

```js
do {
    statement
} while (exp)
```

  do/while循环可以说是实际开发中使用的最少的一个循环了,几乎没用过,下面给一个do/while循环的场景

```js
// 进入一个系统前必须输入密码或者验证码
do {
    const input = prompt('请输入验证码');
} while (!input);
```

注意: do/while循环必须始终以分号终止。而while循环在循环体使用花括号时不需要分号。

for

  • 基础语法:

    for(initialize; test ; increment)
        statement
    
    • initialize, test, increment是三个表达式(以分号隔开),分别负责初始化、测试和递增寻喊变量。
    • 对for循环,三个表达式中任何一个都可以省略,只有两个分号是必需的
    • 因此,for(;;)与while(true)一样,是另一种编写无穷循环的方式
  • 案例一:求1-100的和

    var sum = 0;
    for (num = 1; num <= 100; num++) {
        sum += num;
    }
    
  • 案例二:九九乘法表

    // 使用 var 声明变量
    for (var i = 1; i <= 9; i++) {
      var row = ''; // 存储当前行的字符串
      for (var j = 1; j <= i; j++) {
        // 拼接每个乘法式,\t 用于对齐
        row += j + ' × ' + i + ' = ' + (i * j) + '\t';
      }
      console.log(row); // 输出当前行
    }
    

三种循环语句如何选择

graph TD
    A[开始循环] --> B{循环次数是否明确?}
    B -->|是| C[使用 for]
    B -->|否| D{是否需要至少执行一次?}
    D -->|是| E[使用 do-while]
    D -->|否| F[使用 while]

一个 ID 溢出引发的线上资损

你给某支付平台做「交易流水导出」功能。
需求很直接:把数据库里 bigint(20) 的订单 ID 渲染到表格里。
你顺手写了这么一行:

// ❌ 线上事故代码
const row = `<tr><td>${order.id}</td><td>${order.amount}</td></tr>`;

结果上线第二天,财务发现:
“有笔 1.8e+17 的订单,点进去详情金额对不上!”

排查发现:

  • 数据库 ID 是 18012345678901234567
  • 但 JS 里 Number(order.id) 变成了 18012345678901234000 —— 尾部 567 直接丢了
  • 因为它超过了 Number.MAX_SAFE_INTEGER(9007199254740991),JS 的 64 位浮点数精度崩了。

这可不是显示问题,而是 ID 错位导致查串了订单,差点引发资损。


解决方案:三层防御,把大数关进“安全笼”

1. 表面用法:用 BigInt 代替 Number

// ✅ 正确处理大数
const bigId = BigInt("18012345678901234567"); // 🔍 字符串转 BigInt
console.log(bigId.toString()); // "18012345678901234567"

// 用于计算
const nextId = bigId + 1n; // 🔍 必须加后缀 n

关键点:

  • 必须用字符串初始化BigInt(18012345678901234567) 会先被转成 Number 再转 BigInt,已经丢精度了;
  • 运算时操作数必须都是 BigInt,不能和 Number 混算;
  • 比较可以用 ===,但 == 会自动转换,有坑。

2. 底层机制:为什么 JS 数字会“失精”?

类型 存储方式 范围 精度
Number IEEE 754 双精度浮点 ±1.79e+308 53 位有效数字
BigInt 任意长度整数 无上限 完全精确

原理图(文字版):

flowchart LR
    A["Number: [1位符号][11位指数][52位尾数]"] --> B["实际精度 2^53 - 1 = 9007199254740991"]
    B --> C["超过这个值,尾数不够用,低位被舍入"]

所以 9007199254740992 === 9007199254740993 在 JS 里居然是 true

3. 设计哲学:从“传输”到“渲染”全链路防溢出

(1)接口层:后端传字符串,前端不碰大数

{
  "order_id": "18012345678901234567",  // 🔍 ID 用字符串
  "amount": 123456789,                 // 数值小,可用 Number
  "user_id": "18012345678901234568"
}

(2)状态层:用 BigInt 做计算,但不存进 Redux

// calc.js
export function addId(idStr, offset) {
  const id = BigInt(idStr);
  return (id + BigInt(offset)).toString(); // 🔍 计算完转回字符串
}

(3)渲染层:永远用字符串插值

// ✅ 安全渲染
const row = `<tr data-id="${order.id}">  // 🔍 直接用字符串,不转 Number
  <td>${order.id}</td>
</tr>`;

应用扩展:可复用的配置片段

1. Axios 自动转换大数字段

// axios.interceptor.js
axios.defaults.transformResponse = [
  (data, headers) => {
    if (headers['content-type']?.includes('json')) {
      return JSON.parse(data, (key, value) => {
        // 🔍 指定字段转 BigInt
        if (['order_id', 'user_id'].includes(key) && /^\d{16,}$/.test(value)) {
          return value; // 🔍 保持字符串,由业务层决定是否转 BigInt
        }
        return value;
      });
    }
    return data;
  }
];

2. 环境适配说明

场景 注意点
IE 浏览器 BigInt 不支持,需降级用 string + bignumber.js
TypeScript 类型定义用 bigintstring,别用 number
JSON 序列化 BigInt 不能直接 JSON.stringify(),需自定义 toJSON

举一反三:3 个变体场景

  1. 金融计算(高精度小数)
    BigInt 模拟定点数:123.45 存为 12345n(单位:分),运算后再除 100
  2. 数据库主键生成(Snowflake ID)
    前端生成 ID 时用 BigInt 拼接时间戳、机器码、序列号,避免重复;
  3. 区块链地址校验
    以太坊地址是 256 位整数,用 BigInt 做范围校验和签名计算。

小结

别让 Number 碰超过 16 位的数字。
传用字符串,算用 BigInt,渲染不转 Number,三招封死精度陷阱。

一个 4.7 GB 视频把浏览器拖进 OOM

你给一家在线教育平台做「课程视频批量上传」功能。
需求听起来很朴素:讲师后台一次性拖 20 个 4K 视频,浏览器要稳、要快、要能断网续传。
你第一版直接 <input type="file"> + FormData,结果上线当天就炸:

  • 讲师 A 上传 4.7 GB 的 .mov,Chrome 直接 内存溢出 崩溃;
  • 讲师 B 网断了 3 分钟,重新上传发现进度条归零,心态跟着归零;
  • 运营同学疯狂 @ 前端:“你们是不是没做分片?”

解决方案:三层防线,把 4 GB 切成 2 MB 的“薯片”

1. 表面用法:分片 + 并发,浏览器再也不卡

// upload.js
const CHUNK_SIZE = 2 * 1024 * 1024;    // 🔍 2 MB 一片,内存友好
export async function* sliceFile(file) {
  let cur = 0;
  while (cur < file.size) {
    yield file.slice(cur, cur + CHUNK_SIZE);
    cur += CHUNK_SIZE;
  }
}
// uploader.js
import pLimit from 'p-limit';
const limit = pLimit(5);               // 🔍 最多 5 并发,防止占满带宽
export async function upload(file) {
  const hash = await calcHash(file);   // 🔍 秒传、断点续传都靠它
  const tasks = [];
  for await (const chunk of sliceFile(file)) {
    tasks.push(limit(() => uploadChunk({ hash, chunk })));
  }
  await Promise.all(tasks);
  await mergeChunks(hash, file.name);  // 🔍 通知后端合并
}

逐行拆解:

  • sliceFilefile.slice 生成 Blob 片段,不占额外内存
  • p-limit 控制并发,避免 100 个请求同时打爆浏览器;
  • calcHash 用 WebWorker 算 MD5,页面不卡顿(后面细讲)。

2. 底层机制:断点续传到底续在哪?

角色 存储位置 内容 生命周期
前端 IndexedDB hash → 已上传分片索引数组 浏览器本地,清缓存即失效
后端 Redis / MySQL hash → 已接收分片索引数组 可配置 TTL,支持跨端续传
sequenceDiagram
    participant F as 前端
    participant B as 后端

    F->>B: POST /prepare {hash, totalChunks}
    B-->>F: 200 OK {uploaded:[0,3,7]}

    loop 上传剩余分片
        F->>B: POST /upload {hash, index, chunkData}
        B-->>F: 200 OK
    end

    F->>B: POST /merge {hash}
    B-->>F: 200 OK
    Note over B: 按顺序写磁盘

  1. 前端先 POST /prepare 带 hash + 总分片数;
  2. 后端返回已上传索引 [0, 3, 7]
  3. 前端跳过这 3 片,只传剩余;
  4. 全部完成后 POST /merge,后端按顺序写磁盘。

3. 设计哲学:把“上传”做成可插拔的协议

interface Uploader {
  prepare(file: File): Promise<PrepareResp>;
  upload(chunk: Blob, index: number): Promise<void>;
  merge(): Promise<string>;            // 🔍 返回文件 URL
}

我们实现了三套:

  • BrowserUploader:纯前端分片;
  • TusUploader:遵循 tus.io 协议,天然断点续传;
  • AliOssUploader:直传 OSS,用 OSS 的断点 SDK。
方案 并发控制 断点续传 秒传 代码量
自研 手动 自己实现 手动 300 行
tus 内置 协议级 需后端 100 行
OSS 内置 SDK 级 自动 50 行

应用扩展:拿来即用的配置片段

1. WebWorker 算 Hash(防卡顿)

// hash.worker.js
importScripts('spark-md5.min.js');
self.onmessage = ({ data: file }) => {
  const spark = new SparkMD5.ArrayBuffer();
  const reader = new FileReaderSync();
  for (let i = 0; i < file.size; i += CHUNK_SIZE) {
    spark.append(reader.readAsArrayBuffer(file.slice(i, i + CHUNK_SIZE)));
  }
  self.postMessage(spark.end());
};

2. 环境适配

环境 适配点
浏览器 需兼容 Safari 14 以下无 File.prototype.slice(用 webkitSlice 兜底)
Node fs.createReadStream 分片,Hash 用 crypto.createHash('md5')
Electron 渲染进程直接走浏览器方案,主进程可复用 Node 逻辑

举一反三:3 个变体场景

  1. 秒传
    上传前先算 hash → 调后端 /exists?hash=xxx → 已存在直接返回 URL,0 流量完成。
  2. 加密上传
    uploadChunk 里加一层 AES-GCM 加密,后端存加密块,下载时由前端解密。
  3. P2P 协同上传
    用 WebRTC 把同局域网学员的浏览器变成 CDN,分片互传后再统一上报,节省 70% 出口带宽。

小结

大文件上传的核心不是“传”,而是“断”。
把 4 GB 切成 2 MB 的薯片,再配上一张能续命的“进度表”,浏览器就能稳稳地吃下任何体积的视频。

❌