阅读视图

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

别再吹性能优化了:你的应用卡顿,纯粹是因为产品设计烂🤷‍♂️

image.png

大家好!

最近面试,我发现一个很有意思的事情。几乎每个高级前端的简历上,都专门开辟了一栏,叫性能优化

里面写满了各种高大上的名词😖:

使用Virtual List(虚拟列表)优化长列表渲染...

使用Web Worker把复杂计算移出主线程...

使用WASM重写核心算法...

看着这些,我通常会问一个问题:

你为什么要渲染一个有一万条数据的列表?用户真的看得过来吗?

候选人通常会愣住,然后支支吾吾地说:“呃...这是我们产品经理要求的🤷‍♂️。”

这就是今天我想聊的话题:

在2025年的今天,前端领域90%的所谓性能瓶颈,根本不是技术问题,而是产品问题。

我们这群工程师,拿着最先进的前端技术(Vite, Rust, WASM),却在日复一日地给一坨屎💩(糟糕的产品设计)雕花。


我们正在解决错误的问题

让我们还原一个经典的性能优化现场吧👇。

场景:一个中后台的超级表格(默认大家应该比较熟悉🤔)。

产品经理说需求:这个表格要展示所有订单,大概有50列,每页要展示500条,而且要支持实时搜索,还要支持列拖拽,每个单元格里可能还有下拉菜单...

image.png

开发者的第一反应(技术视角)

  • 50列 x 500行 = 25000个DOM节点,浏览器肯定卡死。
  • 快!上虚拟滚动(Virtual Scroll)!
  • 快!上防抖(Debounce)!
  • 快!上Memoization(缓存)!

我们为了这个需求,引入了复杂的 第三方库,写了晦涩难懂的优化代码,甚至为了解决虚拟滚动带来的样式问题(比如高度坍塌、定位异常),又打了一堆补丁。

最后,页面终于不卡了。我们觉得自己很牛逼,技术很强。

但我们从来没问过那个最核心的问题:

人类的视网膜和大脑,真的能同时处理50列 x 500行的数据吗?

答案是:不能。

当屏幕上密密麻麻挤满了数据时,用户的认知负荷已经爆表了。他根本找不到他要看的东西。他需要的不是高性能的渲染,他需要的是筛选搜索

我们用顶级的技术,去实现了一个反人类的设计。 这不是优化,这是叫作恶😠。


真正的优化,是从砍需求开始

我曾经接手过一个类似的项目,页面卡顿到FPS只有10。前任开发留下了几千行用来优化渲染的复杂代码,维护起来生不如死。

我接手后,没有改一行渲染代码。

我直接去找了产品总监,把那个页面投在大屏幕上,问了他三个问题:

1.你看这一列 订单原始JSON日志,平均长度3000字符,你把它全展示在表格里,谁会看?

砍掉!改成一个查看详情的按钮,点开再加载。DOM节点减少20%。

2.这50列数据,用户高频关注的真的有这么多吗?

默认只展示核心的8列。剩下的放在自定义列里,用户想看自己勾选。DOM节点减少80%。

3.我就不知道为什么🤷‍♂️ 要一次性加载500条?用户翻到第400条的时候,他还记得第1条是什么吗?

赶紧砍掉!改成标准的分页,每页20条。DOM节点减少96%。

做完这三件事,我甚至把之前的虚拟滚动代码全删了,回退到了最朴素的<table>标签。

结果呢?

  • 页面飞一样快(因为DOM只有原来的1%)。
  • 代码极其简单(维护就更简单了🤔)。
  • 用户反而更开心了(因为界面清爽了,信息层级清晰了)。

这才是最高级的性能优化:不仅优化了机器的性能,更优化了人的体验。


技术自负的陷阱

为什么我们总是陷在技术优化的泥潭里出不来呢?😒

因为我们有技术自负

作为工程师,我们潜意识里觉得:承认这个需求做不了(或者做不好),是因为我技术不行。

产品经理要五彩斑斓的黑,我就得给他做出来!

产品经理要在这个页面跑3D地球,我就得去学Three.js!

我们试图用技术去弥补产品逻辑上的懒惰!(非常有触感😖)

因为产品经理懒得思考信息的层级 ,所以他把所有信息一股脑扔给前端,让你去搞懒加载。

技术不是万能的。

浏览器的渲染能力是有上限的,JS的主线程是单核的,移动端的电量是有限的。更重要的是,用户的注意力是极其有限的。

当你发现你需要用极其复杂的新技术才能勉强让一个页面跑起来的时候

请停下来!

stOpStopstopstOpstOp.gif

这时候,问题的根源通常不在代码里,而可能是在 PRD(需求文档) 里。


说了那么多,该怎么做呢?

下次,当你再面对一个导致卡顿的需求时,别急着打开Profiler分析性能。

请试着做以下几步:

我们真的需要在前端处理10万条数据吗?能不能在后端聚合好,只给我返回结果?

这个图表真的需要实时刷新吗?用户真的能看清1毫秒的变化吗?改成5秒刷新一次行不行?

在这个弹窗里塞个完整地图太卡了。能不能改成:点击缩略图,跳转到专门的地图页面?

你要告诉产品经理: 性能本身,也是一个产品功能。

如果为了塞下更多的功能,牺牲了流畅度这个最核心的功能,那是丢了西瓜捡芝麻。


最好的代码,是 没有代码(No Code)

同理,最好的性能优化,是没有需求

作为高级工程师,你的价值不仅仅体现在你会写Virtual List,更体现在你敢不敢在需求评审会上,拍着桌子说:

这个设计怎么这么反人类😠!我们能不能换个更好的方式?🤷‍♂️

别再给屎山💩雕花了。把那座山推了,才是真正的优化。

关于这个观点你们怎么看?

npm scripts的高级玩法:pre、post和--,你真的会用吗?

image.png

我们每天的开发,可能都是从一个npm run dev开始的。npm scripts对我们来说,天天用它,但很少去思考它。

不信,你看看你项目里的package.json,是不是长这样👇:

"scripts": {
  "dev": "vite",
  "build": "rm -rf dist && tsc && vite build", // 嘿,眼熟吗?
  "lint": "eslint .",
  "lint:fix": "eslint . --fix",
  "test": "vitest",
  "test:watch": "vitest --watch",
  "preview": "vite preview"
}

这能用吗?当然能用。

但这专业吗?在我看来,未必!

一个好的scripts,应该是原子化的、跨平台的。而上面这个,一个build命令就不行,而且rm -rf在Windows上还得装特定环境才能跑🤷‍♂️。

今天,我就来聊聊,如何用prepost--,把你的脚本,升级成专业的脚本。


prepost:命令的生命周期钩子

prepost,是npm内置的一种钩子机制。

它的规则很简单:

  • 当你执行npm run xyz时,npm自动先去找,有没有一个叫prexyz的脚本,有就先执行它。
  • xyz执行成功后,npm自动再去找,有没有一个叫postxyz的脚本,有就最后再执行它。

这个自动的特性,就是神一般的存在。

我们来改造那个前面👆提到的build脚本。

业余写法 (用&&手动编排)

"scripts": {
  "clean": "rimraf dist", // rimraf 解决跨平台删除问题
  "lint": "eslint .",
  "build:tsc": "tsc",
  "build:vite": "vite build",
  "build": "npm run clean && npm run lint && npm run build:tsc && npm run build:vite"
}

你的build脚本,它必须记住所有的前置步骤。如果哪天你想在build前,再加一个test,你还得去修改build的定义。这违反了单一职责

专业写法 (用pre自动触发)

"scripts": {
  "clean": "rimraf dist",
  "lint": "eslint .",
  "test": "vitest run",
  "build:tsc": "tsc",
  "build:vite": "vite build",

  // build的前置钩子
  "prebuild": "npm run clean && npm run lint && npm run test", 
  
  // build的核心命令
  "build": "npm run build:tsc && npm run build:vite",
  
  // build的后置钩子
  "postbuild": "echo 'Build complete! Check /dist folder.'"
}

看到区别了吗?

现在,当我只想构建时,我依然执行npm run build。

npm会自动帮我执行prebuild(清理、Lint、测试)👉 然后执行build(编译、打包)👉 最后执行postbuild(打印日志)。

我的build脚本,只关心构建这件事。而prebuild脚本,只关心前置检查这件事

这就是单一职责和关注点分离。

你甚至可以利用这个特性,搞点骚操作😁:

"scripts": {
  // 当你执行npm start时,它会自动先执行npm run build
  "prestart": "npm run build", 
  "start": "node dist/server.js"
}

-- (双短线):脚本参数

--是我最爱的一个特性。它是一个参数分隔符

它的作用是:告诉npm,我的npm参数到此为止了,后面所有的东西,都原封不动地,传给我要执行的那个底层命令。”

我们来看开头👆那个脚本:

"scripts": {
  "test": "vitest",
  "test:watch": "vitest --watch"
}

为了一个--watch参数,你复制了一个几乎一模一样的脚本。如果明天你还想要--coverage呢?再加一个test:coverage?这叫垃圾代码💩

专业写法 (用--动态传参)

"scripts": {
  "test": "vitest"
}

就这一行,够了。

等等,那我怎么跑watch和coverage?

答案,就是用--🤷‍♂️:

# 1. 只跑一次
$ npm run test -- --run
# 实际执行: vitest --run

# 2. 跑watch模式
$ npm run test -- --watch
# 实际执行: vitest --watch

# 3. 跑覆盖率
$ npm run test -- --coverage
# 实际执行: vitest --coverage

# 4. 跑某个特定文件
$ npm run test -- src/my-component.test.ts
# 实际执行: vitest src/my-component.test.ts

--就像一个参数隧道 ,它把你在命令行里,跟在--后面的所有参数,原封不动地扔给了vitest命令。


一个专业的CI/CD脚本

好了,我们把pre/post--结合起来,看看一个专业的package.json是长什么样子👇。

"scripts": {
  // 1. Lint
  "lint": "eslint .",
  "lint:fix": "eslint . --fix",

  // 2. Test
  "test": "vitest",
  "pretest": "npm run lint", // 在test前,必须先lint

  // 3. Build
  "build": "tsc && vite build",
  "prebuild": "npm run test -- --run", // 在build前,必须先test通过
  
  // 4. Publish (发布的前置钩子)
  // prepublishOnly 是一个npm内置的、比prepublish更安全的钩子
  // 它只在 npm publish 时执行,而在 npm install 时不执行
  "prepublishOnly": "npm run build" // 在发布前,必须先build
}

看看我们构建了怎样一条自动化脚本:

  1. 你兴高采烈地敲下npm publish,准备发布。
  2. npm一看,有个prepublishOnly,于是它先去执行npm run build
  3. npm一看,build有个prebuild,于是它又先去执行npm run test -- --run
  4. npm一看,test有个pretest,于是它又双叒叕先去执行npm run lint

最终的执行流是:Lint -> Test -> Build -> Publish

这些脚本,被pre钩子,自动地、强制地串联了起来。你作为开发者,根本没有机会犯错。你不可能发布一个连Lint都没过或者测试未通过的包😁。


npm scripts,它不是一个简单的脚本快捷方式。它是一个工作流(Workflow)的定义

prepost,定义了你工作流的执行顺序依赖,保证了代码检查等功能,而--是确保你工作流中的脚本参数

现在,马上去打开你项目的package.json,看看它,是专业的,还是业余的呢?🤣

我为什么说全栈正在杀死前端?

大家好,我又来了🤣。 打开2025年的招聘软件,十个资深前端岗位,有八个在JD(职位描述)里写着:“有Node.js/Serverless/全栈经验者优先”。 全栈 👉 成了我们前端工程师内卷的一种方
❌