普通视图

发现新文章,点击刷新页面。
今天 — 2026年5月1日首页

Webpack vs Vite:一个是“老黄牛”,一个是“猎豹”,你选谁?

作者 kyriewen
2026年5月1日 12:57

你准备搭一个新项目,打开搜索引擎:“Webpack还是Vite?” 答案一半一半,你更懵了。今天我们就来场正面PK:Webpack像头任劳任怨的老黄牛,啥都能干,但起步慢;Vite像只猎豹,瞬间冲刺,但偶尔挑食。看完你就能拍板:我的项目,就该用那个!

前言

前端工具链的“内卷”从未停止。Webpack多年霸主,几乎成了“打包”的代名词。但Vite横空出世,以“快”为刀,砍向Webpack的软肋:开发服务器启动慢、热更新慢。

两者没有绝对好坏,只有合不合适。今天我们从开发体验、生产构建、生态、配置复杂度四个维度,来场硬核对比。

一、核心原理:一个全量打包,一个按需编译

  • Webpack:开发时,从入口开始,递归分析所有模块依赖,打包成一个或多个bundle(哪怕你只用了一个组件,它也把你整个项目打包一遍)。启动慢,但随着项目变大越来越慢。热更新时,需要重新打包变更的模块及其依赖,可能还是慢。

  • Vite:利用浏览器原生ESM(<script type="module">),开发时不打包,只启动一个静态服务器。浏览器请求哪个文件,Vite实时编译哪个文件(比如把JSX转成JS,把TS转成JS)。启动极快(毫秒级),热更新也只更新被改的模块,速度飞快。

比喻:Webpack像搬家公司的卡车,把整个房子家具先打包再运;Vite像快递员,你点一个包裹,他送一个。

二、速度实测:秒开 vs 等咖啡

操作 Webpack Vite
冷启动(大型项目) 10~30秒 <1秒
热更新(改一行代码) 200~500ms(可能更多) <50ms
生产构建 中等(但可优化) 稍慢(用Rollup,但整体可接受)

Vite在开发体验上完胜。尤其大型项目,Webpack启动一次够你刷几条短视频,Vite眨眨眼就好了。

三、生产构建:Webpack还是稳

Vite开发时用ESM,但生产打包不用ESM(会产生太多请求),它底层用的是Rollup。Rollup对tree-shaking、代码分割也很强,但某些复杂场景(比如需要自定义打包逻辑的库)不如Webpack灵活。

Webpack经过多年打磨,插件生态极其丰富,任何你能想到的打包需求(比如特殊文件处理、自定义chunk分割、微前端),Webpack几乎都能找到现成方案。

结论:普通应用项目,Vite的生产构建够用;搞复杂库或需要精细控制打包,Webpack更成熟。

四、配置复杂度:Webpack劝退新手,Vite开箱即用

Webpack配置堪称“噩梦”。从零配置一个支持TypeScript、React、CSS Modules、热更新的项目,要写几十行甚至上百行。虽然官方有create-react-app等脚手架掩盖了配置,但一旦需要 eject 或自定义,头就大了。

Vite默认支持TS、JSX、CSS预处理器、热更新,配置文件极简。你需要做的只是:

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
});

Vite还提供了create-vite脚手架,选择模板一键生成。

五、生态与兼容性:Webpack的护城河

Webpack的插件/loader生态是它的最大优势。比如:

  • file-loader/url-loader 处理静态资源。
  • raw-loader 导入文本。
  • html-webpack-plugin 生成HTML。
  • mini-css-extract-plugin 抽离CSS。
  • webpack-manifest-plugin 生成资源清单。

Vite虽然也支持大多数常见需求(通过插件),但一些老旧的、小众的loader可能没有直接替代。不过对于绝大多数项目,Vite的插件生态已经足够。

另外,Vite要求浏览器支持ESM(现代浏览器都支持),但如果你需要兼容IE11,那不好意思,Vite官方不支持(需要额外插件且很麻烦),这时候Webpack是唯一选择。

六、实战选择:到底用哪个?

用Vite,如果:

  • 新项目,没有历史包袱。
  • 追求极致的开发体验(快!)。
  • 不需要兼容IE11。
  • 项目是常规SPA或静态站点。

用Webpack,如果:

  • 项目已经用Webpack,迁移成本高。
  • 需要兼容IE11。
  • 用了大量Webpack专属插件或自定义loader。
  • 项目是非常复杂的库,需要精细化控制打包。

七、未来趋势:Vite会取代Webpack吗?

短期不会。Webpack在大型企业级项目、复杂构建场景仍有优势。但Vite作为“下一代前端工具链”,已经被Vue、React等官方推荐。尤其在Vue生态,Vite已经是默认配置。

长期看,Vite会逐渐蚕食Webpack在新项目中的份额。但Webpack也不会坐以待毙,Webpack 5 已经改进了缓存和模块联邦,但启动速度这个底层设计问题很难根治。

八、迁移指南:从Webpack到Vite

如果你决定尝鲜,步骤很简单:

  1. create-vite新建一个空项目,复制源码。
  2. require改成import(如果之前用CommonJS)。
  3. 把环境变量从process.env改成import.meta.env
  4. 找对应的Vite插件替代webpack loader。
  5. 测试。

对于中小项目,半天就能完成迁移。

九、总结:没有最好,只有最合适

  • Webpack:老黄牛,稳重、能干、啥都有,但动作慢、配置复杂。
  • Vite:猎豹,快、轻盈、开箱即用,但偶尔挑食(生态稍弱、不支持IE)。

新个人项目、创业项目,无脑上Vite,享受飞一般的开发体验。大厂遗留项目、需要IE兼容,继续Webpack。两者可以共存,甚至可以在一个项目里用Vite开发,Webpack打包(不常见)。

选工具就像选对象,适合的才是最好的。现在你知道该怎么选了。

昨天以前首页

Next.js部署:从本地跑得欢,到线上飞得稳

作者 kyriewen
2026年4月29日 18:57

你在本地npm run dev,页面秒开,爽得不行。一部署到服务器,慢得像老太太过马路,图片加载半天,首屏白屏几秒,用户投诉。今天我们就来把Next.js应用“送上天”——从部署到优化,让你的线上应用和本地一样快。而且,还能免费蹭HTTPS和CDN。

前言

Next.js最大的优势之一就是部署极其方便。官方团队就是做部署平台Vercel的,所以你用Next.js,就等于半只脚踩进了“一键部署”的门槛。但这不代表你可以随便扔到服务器就跑。部署姿势不对,照样卡成狗。

今天我们讲两种部署方式:无脑简单版(Vercel)手搓硬核版(自托管),以及上线前必做的优化。

一、Vercel部署:连命令都不用记

Vercel是Next.js的亲爹(母公司)。你把代码推到GitHub/GitLab,Vercel自动构建、部署、给CDN、给HTTPS,甚至自动给你一个.vercel.app域名。

步骤

  1. 把代码推到Git仓库。
  2. 登录vercel.com,用GitHub账号登录。
  3. 点击“Import Project”,选择你的仓库。
  4. 默认配置(Next.js自动识别),点击Deploy。
  5. 几十秒后,你会得到一个链接 https://你的项目.vercel.app,已经上线了。

之后每次git push,Vercel自动重新部署。你连服务器都不用买。

优点:零运维、自动HTTPS、全球CDN、自动优化(图片、字体)、免费额度够用(个人项目)。
缺点:自带域名在国内访问可能慢(但可绑定自己的域名,解析到国内CDN节点)。流量超了要付费。

生活比喻:你把菜做好,递给外卖骑手,他帮你送到客户手里,你什么都不用管。

二、自托管(自己买服务器):更自由但更折腾

如果你必须用国内服务器、或者公司要求私有化部署,那就得自己搭。

方案一:Node.js服务器运行

npm run build   # 构建生产版本
npm start       # 启动Node服务器(默认3000端口)

然后用Nginx反向代理,配置HTTPS。注意:你需要自己管理进程(用PM2),自己配置CDN,自己处理日志。

pm2 start npm --name "nextjs" -- start

方案二:静态导出(如果全站都是SSG)

如果你的所有页面都用了getStaticProps(没有getServerSideProps),可以导出纯静态文件,放到Nginx或OSS上。

next build && next export

会生成out文件夹,直接扔到任意静态托管(如阿里云OSS + CDN),超便宜,超快。

缺点:不能用getServerSideProps、API Routes等服务器特性。

方案三:Docker容器化

写Dockerfile,构建镜像,跑在K8s或Docker Compose上。

FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]

适合云原生环境。

三、上线前必做的优化:让Next.js飞起来

1. 图片用next/image,别用<img>

next/image自动压缩、懒加载、转webp、响应式。你啥都不用做,图片体积直接小一半。

import Image from 'next/image';
<Image src="/hero.png" width={1200} height={600} alt="Hero" />

:宽高必须指定,或者用layout="fill" + 父容器相对定位。

2. 字体用next/font

Next.js 13+内置字体优化,自动内联CSS、避免布局偏移。

import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'] });
<html className={inter.className}>

3. 脚本用next/script

控制第三方脚本加载时机,不阻塞页面。

import Script from 'next/script';
<Script src="https://example.com/tracker.js" strategy="afterInteractive" />

4. 开启压缩(Vercel默认开启,自托管需配置)

next.config.js中:

module.exports = {
  compress: true, // 开启gzip,Nginx也要配
};

5. 移除未使用的CSS(结合Tailwind或PurgeCSS)

如果你用Tailwind,默认已清理。如果用普通CSS,可以考虑@fullhuman/postcss-purgecss

6. 设置缓存头

自托管时,在Nginx里对静态资源(_next/static)设置永久缓存:

location /_next/static {
  expires 1y;
  add_header Cache-Control "public, immutable";
}

7. 启用增量静态再生(ISR)

不需要每个页面都getServerSideProps,能用getStaticProps + revalidate尽量用。

export async function getStaticProps() {
  return { props: { data }, revalidate: 60 };
}

四、性能监控:别靠猜

部署后,用Vercel Analytics(付费)或自建Google Lighthouse CI,定期跑分。也可以集成Sentry监控运行时错误。

npm install @sentry/nextjs

配置后,线上报错自动发到Sentry,不用等用户骂你。

五、总结:从开发到上线,一条龙

  • 个人项目/创业公司:无脑用Vercel,省下的时间写代码。
  • 企业自托管:用Node.js + PM2 + Nginx,或Docker + K8s。
  • 纯静态站点next export扔OSS。
  • 优化必做:图片、字体、压缩、缓存、ISR。

做了这些,你的Next.js应用就能从“本地火箭”变成“太空飞船”。用户打开,秒开;谷歌爬虫,狂喜;老板看数据,点头。

别再乱装图片插件了!我手写了一个,能扒光整个网页(含背景/iframe/Shadow DOM)

作者 kyriewen
2026年4月28日 18:47

开场白

我真的受够了,每次想从网页批量保存图片,要么右键被禁用,要么装了五六个插件还漏掉一半的 CSS 背景图,要么好不容易抓到图了,却发现插件在后台偷偷上报我的浏览记录。

于是我自己写了一个 —— Image Harvest。它能把网页里所有图片(包括 <img>、CSS 背景、iframe 内嵌、甚至 Shadow DOM 里的)全部扒出来,一键打包 ZIP,而且本地处理,零追踪。

点击立即体验

hero.gif

这篇文章不讲产品吹水,只说技术实现:MV3 踩坑、深度图片提取、客户端感知哈希去重、Side Panel + Popup 双形态共存。干货 + 代码 + 真实踩坑记录,希望对写 Chrome 插件的朋友有帮助。


一、为什么我要自己写一个图片下载插件?

现有的同类插件,我试过 10+ 个,普遍三个问题:

  1. 抓不全:只能抓 <img>,CSS background-imageiframe、Shadow DOM 里的图基本放弃。
  2. 有隐私风险:manifest 里申请 <all_urls> + webRequest,还往未知服务器发数据。
  3. 体验拉胯:批量下载要么一张张点,要么 ZIP 包里一半是占位图。

所以我决定自己造轮子。核心目标:

  • 不漏图(递归提取所有图片源)
  • 不监守自盗(纯本地,零数据收集)
  • 好用(侧边栏/弹窗双模式、暗色主题、3 档密度)

二、Manifest V3 的几个坑(附解法)

2.1 Service Worker 冷启动

V3 用 service worker 替代 V2 的常驻 background page。它会在几秒无活动后休眠,导致下次调用时变量全丢。

解决方案:用 chrome.storage.session 缓存关键状态。

// 抓取完成后存入 session
await chrome.storage.session.set({ 
  lastExtract: { images, timestamp: Date.now() } 
});

// 下次打开面板时恢复
const cached = await chrome.storage.session.get('lastExtract');
if (cached && Date.now() - cached.timestamp < 60000) {
  return cached.images;
}

collection.jpg

2.2 远程代码被禁止

V3 完全禁止执行从外部下载的脚本。对我没影响:Image Harvest 所有代码本地打包,不依赖任何远程配置。

2.3 webRequestdeclarativeNetRequest 替代

如果你需要修改网络请求(如给图片请求加 header),现在只能用声明式规则,灵活性降低。不过图片下载器不需要这玩意儿。


三、深度图片提取:从 <img> 到 Shadow DOM

3.1 基础提取:<img><picture>

function extractSimpleImages() {
  const urls = [];
  document.querySelectorAll('img').forEach(img => {
    if (img.src) urls.push(img.src);
  });
  document.querySelectorAll('picture source').forEach(source => {
    if (source.srcset) {
      const highest = source.srcset.split(',').pop().trim().split(' ')[0];
      urls.push(highest);
    }
  });
  return urls;
}

3.2 提取 CSS background-image

很多网站的 Banner、图标都用背景图实现,必须挖出来。

function extractBgImages(root = document) {
  const elements = root.querySelectorAll('*');
  const bgUrls = [];
  for (let i = 0; i < Math.min(elements.length, 2000); i++) {
    const bg = getComputedStyle(elements[i]).backgroundImage;
    if (bg && bg !== 'none') {
      const match = bg.match(/url\(["']?([^"')]+)["']?\)/);
      if (match) bgUrls.push(match[1]);
    }
  }
  return bgUrls;
}

batch-download.jpg

3.3 递归 Shadow DOM

现代前端框架(React/Vue)常把图片封装在 Shadow DOM 里,必须递归遍历。

function extractFromShadowDOM(root = document) {
  let results = [];
  // 普通图片
  results.push(...extractSimpleImages(root));
  results.push(...extractBgImages(root));
  // 递归 Shadow DOM
  const hosts = root.querySelectorAll('*');
  hosts.forEach(host => {
    if (host.shadowRoot) {
      results.push(...extractFromShadowDOM(host.shadowRoot));
    }
  });
  return results;
}

multi-tab-extract.jpg

3.4 iframe 处理

同源 iframe 可以用 chrome.scripting.executeScript 注入提取函数。需要 webNavigation 权限获取所有 frame。

const frames = await chrome.webNavigation.getAllFrames({ tabId });
for (const frame of frames) {
  if (frame.parentFrameId !== -1) continue; // 只处理顶层 iframe
  const injection = await chrome.scripting.executeScript({
    target: { tabId, frameIds: [frame.frameId] },
    func: extractFromShadowDOM,
  });
  // 合并结果...
}

四、客户端感知哈希(pHash)实现相似图去重

很多用户反馈:“下载 100 张图,里面有 30 张是重复的缩略图”。所以我在 Pro 版中加了相似图检测。

4.1 算法选择:dHash

  • 速度快(纯前端)
  • 对缩放、轻微裁剪不敏感
  • 汉明距离 ≤ 5 判定为相似

4.2 核心代码

async function computeDHash(blob) {
  const img = await createImageBitmap(blob);
  const canvas = new OffscreenCanvas(9, 8);
  const ctx = canvas.getContext('2d');
  ctx.drawImage(img, 0, 0, 9, 8);
  const data = ctx.getImageData(0, 0, 9, 8).data;
  
  // 转灰度
  const gray = [];
  for (let i = 0; i < data.length; i += 4) {
    gray.push(0.299 * data[i] + 0.587 * data[i+1] + 0.114 * data[i+2]);
  }
  
  // 差分哈希
  let hash = 0n;
  for (let row = 0; row < 8; row++) {
    for (let col = 0; col < 7; col++) {
      const left = gray[row * 9 + col];
      const right = gray[row * 9 + col + 1];
      if (right > left) hash |= (1n << BigInt(row * 7 + col));
    }
  }
  return hash;
}

4.3 Worker 中运行,不阻塞 UI

const worker = new Worker('phash-worker.js');
worker.postMessage({ blob });
worker.onmessage = (e) => {
  console.log(`哈希: ${e.data.hash}`);
};

reverse-search.jpg


五、Side Panel + Popup 双模式共存

Chrome 115+ 支持 Side Panel,但老用户习惯 Popup。我两个都要。

实现要点

  • manifest 中配置 side_panel.default_path = "sidepanel.html"
  • action.default_popup 留空,动态控制
chrome.action.onClicked.addListener(async (tab) => {
  const settings = await getAppSettings();
  if (settings.useSidePanel) {
    await chrome.sidePanel.open({ tabId: tab.id });
  } else {
    await chrome.action.setPopup({ tabId: tab.id, popup: 'popup.html' });
    chrome.action.openPopup();
  }
});

同一套 UI 代码,通过 window.location.pathname 判断当前模式,微调布局(弹窗固定 620×600,侧边栏自适应)。

similar-images.jpg


六、上架 Chrome Web Store 的 4 个雷区

  1. 图标尺寸不全:必须 16/32/48/128 px,缺一个直接驳回。
  2. 描述太短:简短描述 ≤132 字符,要包含核心关键词。
  3. 隐私政策缺失:即使不收集数据,也要写一份说明“不收集什么”。
  4. 权限过度:不需要 <all_urls> 就别写,否则审核会问。

我第一次提交被拒就是因为隐私政策链接 404。补上后 2 天过审。

你的数据该在哪儿拿?Next.js三种姿势一次讲清

作者 kyriewen
2026年4月28日 18:38

前言

Next.js给了你三把“钥匙”去开门拿数据。选错了,要么页面慢得像蜗牛,要么用户数据混乱,要么服务器天天崩。别怕,其实规则很简单:数据变不变?要不要实时?要不要SEO? 回答了这三个问题,答案就出来了。

我们用“开餐厅”来打个比方:

  • 静态生成(SSG):菜单印在纸上,顾客看的是纸质菜单。印刷一次管好几天,永不变化。
  • 服务端渲染(SSR):电子黑板,每次客人来,服务员现写菜单,保证最新。
  • 客户端渲染(CSR):客人扫码点餐,手机上的菜单是动态加载的。

三种方式对应三种数据需求。

一、getStaticProps:预制菜,又快又省

适用场景:数据基本不变,或者你可以接受一定延迟更新。比如博客文章、产品介绍页、帮助文档。

怎么做:在构建时(next build)获取数据,生成静态HTML。之后每次请求直接返回这个HTML,速度极快,还能放CDN。

// pages/posts/[id].js
export async function getStaticPaths() {
  const posts = await fetch('https://api.example.com/posts').then(r => r.json());
  const paths = posts.map(post => ({ params: { id: post.id } }));
  return { paths, fallback: false };
}

export async function getStaticProps({ params }) {
  const post = await fetch(`https://api.example.com/posts/${params.id}`).then(r => r.json());
  return {
    props: { post },
    revalidate: 60, // 增量静态再生(ISR):60秒后重新生成,不是必须
  };
}

优点:快(CDN缓存)、省服务器资源、SEO完美。 缺点:构建时数据必须是可得的;如果有大量页面,构建时间会变长(可用fallback: true或ISR缓解)。

生活比喻:预制菜包。你提前做好,客人来了热一下就能吃。适合麦当劳(每家的巨无霸都一样)。

二、getServerSideProps:现点现做,永远新鲜

适用场景:数据随用户不同而变化,或者数据实时性要求极高。比如用户个人主页、购物车、实时搜索页。

怎么做:每次请求都跑到服务器上执行getServerSideProps,获取数据后渲染成HTML返回。

// pages/profile.js
export async function getServerSideProps(context) {
  const { req } = context;
  const token = req.cookies.token;
  const user = await fetch('https://api.example.com/user', {
    headers: { Authorization: `Bearer ${token}` }
  }).then(r => r.json());
  return { props: { user } };
}

优点:数据永远最新,可以读取请求上下文(cookies、headers),适合个性化内容。 缺点:每个请求都调用服务器,性能比静态生成差,不能放CDN。

生活比喻:餐厅里的大厨现炒。客人点一份炒一份,味道新鲜,但慢一点,厨师也累。

三、客户端获取(CSR):扫码点餐,不占服务资源

适用场景:数据实时性要求高,但SEO不重要,或者你不希望服务器压力大。比如用户仪表盘、图表数据、实时消息列表。

怎么做:在组件里用useEffect + fetch,或者用SWR、React Query等库。

import { useState, useEffect } from 'react';

export default function Dashboard() {
  const [data, setData] = useState(null);
  useEffect(() => {
    fetch('/api/dashboard')
      .then(r => r.json())
      .then(setData);
  }, []);
  if (!data) return <div>加载中...</div>;
  return <div>{/* 渲染数据 */}</div>;
}

优点:服务器负担小(只提供API),可以实时更新,适合交互密集的后台。 缺点:首次加载有白屏,SEO不友好(因为内容靠JS填充)。

生活比喻:扫码点餐。客人自己看手机菜单,下单后厨房再做。菜单可以随时改,但客人得先扫个码(等待JS加载)。

四、混合模式:ISR(增量静态再生)

Next.js还提供了一个“中间态”:revalidate 参数。你依然用getStaticProps,但指定一个秒数,超过这个时间后,下次请求会尝试重新生成页面,同时返回旧版本。这样既有静态的速度,又能定期更新。

return { props: { data }, revalidate: 3600 }; // 每小时更新一次

适合数据偶尔变化,但又不希望每次请求都重新生成的场景,比如电商的商品库存(每小时更新就行)。

五、怎么选?决策树

  1. 数据是否需要实时(每次请求都要最新)?

    • 是 → 是否需要SEO?是 → getServerSideProps;否 → 客户端获取。
    • 否 → 进入2
  2. 数据是否依赖用户身份(cookies/headers)?

    • 是 → getServerSideProps
    • 否 → getStaticProps(甚至可以ISR)
  3. 数据量巨大且变化频繁,但不需要SEO? → 客户端获取。

简单口诀

  • 博客文章、产品介绍:getStaticProps
  • 用户个人中心、购物车:getServerSideProps
  • 后台图表、实时看板:客户端获取。

六、实战:一个混合使用的首页

假设首页包含:顶部最新公告(实时)、中间产品列表(每天更新一次)、底部用户推荐(个性化)。

  • 公告:getServerSideProps(实时,且SEO重要?其实公告可以客户端获取,但为了体验,可以SSR)。
  • 产品列表:getStaticProps + revalidate: 86400(一天更新一次)。
  • 用户推荐:客户端获取(依赖登录状态,且SEO不重要)。
export default function Home({ announcement, products }) {
  const [recommendations, setRecommendations] = useState([]);
  useEffect(() => {
    fetch('/api/recommendations').then(r => r.json()).then(setRecommendations);
  }, []);
  return (
    <>
      <Announcement data={announcement} />
      <ProductList data={products} />
      <Recommendations data={recommendations} />
    </>
  );
}

export async function getServerSideProps() {
  const announcement = await fetchAnnouncement(); // 实时
  const products = await fetchProducts(); // 缓存一小时
  return {
    props: { announcement, products },
    revalidate: 3600, // 对products有效,对announcement无效(因为SSR总是实时)
  };
}

但注意:getServerSideProps里不能同时用revalidate(它只对静态生成有效)。如果你要混合,可以把产品数据用getStaticProps单独抽出来,或者全部在客户端获取。上面代码只是示意:实际中getServerSideProps的返回值里加revalidate是无意义的。

更佳实践:产品列表单独做一个静态页面,首页通过客户端请求该接口。

七、总结:没有银弹,只有合适

  • getStaticProps:适合“快、不变、要SEO”。
  • getServerSideProps:适合“实时、要SEO、可接受稍慢”。
  • 客户端获取:适合“实时、不要SEO、降低服务器压力”。

掌握这三种,你就能游刃有余地驾驭Next.js的数据层。别再每页都用客户端fetch了,下次老板说页面慢,你就能有理有据地告诉他:“这个页面应该用ISR!”

React Diff算法:3个“神级假设”让虚拟DOM快得像闪电

作者 kyriewen
2026年4月26日 23:56

前言

假设你有两棵各有1000个节点的树,传统树对比算法需要十亿级别的操作(O(n³))。那根本不可能用在浏览器里——一更新就死机。React团队发现,在实际Web应用中,树的变化符合一些规律,于是他们大胆做了3个假设,把复杂度降到了线性(O(n))。虽然有些场景会误判,但在99%的情况下,它准得吓人还快得离谱。

今天我们就来揭开这3个“神级假设”,以及React是怎么基于它们对比DOM的。

一、3个假设:React的“赌注”

  1. 同层对比:两个不同类型的元素会产生不同的树。
    比如 <div> 变成 <span>,React会直接销毁旧子树,重建新子树,不会浪费时间去比较子节点。
  2. 唯一标识:开发者可以通过 key 属性告诉React哪些子元素是稳定的。
    比如列表顺序变化时,有key就能识别“这个li还是那个li”,只是挪了个位置。
  3. 同级子节点只在该层比较:不会跨层级移动节点。
    如果某个节点从子节点变成了父节点的兄弟,React会销毁重建,而不是复用。

基于这些假设,React设计出了基于广度优先遍历的Diff算法。

二、节点类型不同:直接“拆房重建”

如果旧树是 <div>,新树是 <span>,React压根不看子节点,直接删掉旧节点及其所有子节点,重新创建 <span> 及子节点。

// 旧
<div><Counter /></div>
// 新
<span><Counter /></span>

即使 <Counter /> 是一样的,整个组件也会被卸载再重新挂载,Counter 的state会丢失,生命周期重新走一遍。

所以尽量保持DOM类型稳定,比如别把 <div> 随意改成 <section>

三、同一类型节点:保留DOM,只更新属性和子节点

如果新旧节点类型相同(比如都是 <div>),React会保留该节点的DOM元素,然后对比属性,更新改变的属性。接着递归对比子节点。

// 旧:<div className="old" title="tip">hello</div>
// 新:<div className="new" title="tip">world</div>

React保留 <div>,把 className"old" 改为 "new",然后对比文本子节点,把 "hello" 改成 "world"

这时子节点的对比就进入“列表对比”阶段。

四、列表对比:没有key VS 有key

这是Diff最精彩的部分。

没有key时:React的“暴力”

假设子节点都是同一类型,但顺序变化。没有key,React只能逐个比较位置。

// 旧:A - B - C
// 新:C - A - B

React的做法:

  1. 旧第一个A,新第一个C:不同,更新A为C。
  2. 旧第二个B,新第二个A:不同,更新B为A。
  3. 旧第三个C,新第三个B:不同,更新C为B。 最终结果正确,但进行了3次更新操作。实际上只需要把C移到最前面就能复用A、B。这就是没有key的低效。

有key时:移动、插入、删除三步走

给每个子节点加唯一key,React就能追踪节点的身份。

// 旧:key=A - key=B - key=C
// 新:key=C - key=A - key=B

React会构建一个“旧节点键值映射”,然后遍历新列表:

  • 新第一个C,在旧里有,且位置变了,标记为“移动”。
  • 新第二个A,旧里有,标记为“移动”。
  • 新第三个B,旧里有,标记为“移动”。 最后React只做一次移动操作(将C移到最前),其余复用。性能大大提升。

注意:千万不要用 index 作为key!因为列表顺序变化时,index也会变,React会误判,导致性能退化和组件状态错乱。

五、跨层级移动:React无能为力

由于第3个假设“不同层级不比较移动”,如果你把一个子节点从父节点内移动到另一个父节点下,React会直接卸载重建,而不是复用。

// 旧
<div>
  <span>hello</span>
</div>
// 新
<span>hello</span>

React会把 <span><div> 下删掉,再重新创建到新位置。虽然有点浪费,但这样可以保持算法简单快速。

六、递归Diff与性能优化

整个Diff过程是递归的:从根开始,深度优先遍历,同级对比子节点。由于假设了同层对比,整个递归树的大小就是原树的大小,复杂度O(n)。

配合 shouldComponentUpdateReact.memo 可以跳过整棵子树的Diff,进一步提升性能。

七、总结:Diff算法的“三板斧”

  • 类型不同:删了重建。
  • 类型相同:保留DOM,更新属性和子节点。
  • 子节点列表:靠key识别身份,移动/增删。

这三条简单规则,让React在大多数场景下既快又准。理解Diff,你就能写出更高效的组件:给列表加稳定key,避免不必要的DOM类型改变,用 memo 跳过无意义的更新。

现在你知道为什么map时要加key,为什么不能随意把div改成span,为什么index做key会出问题了吧?

❌
❌