代码分割 + 路由懒加载 + 字体子集化:前端瘦身三板斧

前言

做过大型 SPA 项目的同学一定遇到过这样的场景:首屏白屏时间超过 3 秒,用户还没看到内容就已经走了。打开 DevTools 一看,一个 main.js 直接干到 2MB,里面塞满了 ECharts、Monaco Editor、Moment.js 和各种没用到的页面逻辑。

问题的根源很简单------我们把所有代码打成了一个包。用户访问首页时,却要下载整个应用的全部代码,包括他可能永远不会打开的管理后台页面。

这篇文章从三个维度来解决这个问题:代码分割 减小单次加载的 JS 体积,路由懒加载 让页面按需加载,字体子集化砍掉字体文件中 90% 的无用字符。三板斧下去,首屏加载体积可以降低 60% 以上。

JS 体积为什么重要

很多人知道图片优化,但忽略了 JS 体积对性能的影响远大于同等大小的图片。原因在于 JS 不仅需要下载,还需要解析、编译和执行。

资源类型 200KB 的处理耗时(中端手机) 对交互的影响
图片 ~50ms(解码) 不阻塞交互
CSS ~30ms(解析+CSSOM) 阻塞渲染但不阻塞交互
JavaScript ~300-500ms(解析+编译+执行) 阻塞渲染和交互

Google 的研究数据表明,每增加 100KB 的 JS(压缩后),在中端 Android 设备上大约增加 150-300ms 的 TTI(Time to Interactive)。当你的 bundle 从 500KB 膨胀到 1.5MB 时,用户感知到的可交互时间可能从 2 秒变成 5 秒。

更关键的是,JS 体积有一个"隐性成本":即使代码被缓存了,每次页面加载仍然需要解析和编译。这不像图片缓存那样直接从磁盘读取渲染------V8 虽然有字节码缓存机制,但首次访问时的解析成本是实实在在的。

Dynamic import() 的工作原理

ES2020 引入的 import() 是代码分割的基础。与静态 import 不同,它返回一个 Promise,在运行时动态加载模块:

typescript 复制代码
// 静态导入:打包时就会被包含进 bundle
import { heavyFunction } from './heavyModule';

// 动态导入:运行时按需加载,返回 Promise
const module = await import('./heavyModule');
module.heavyFunction();

Webpack 遇到 import() 时,会自动将目标模块及其依赖抽成单独的 chunk 文件。构建产物从一个大文件变成了主文件 + 若干异步 chunk。

Webpack Magic Comments

Webpack 提供了几个魔法注释来控制动态导入的行为:

typescript 复制代码
// 1. webpackChunkName:给 chunk 指定可读的名字
const AdminPage = () => import(
  /* webpackChunkName: "admin" */
  './pages/Admin'
);
// 产物:admin.abc123.js(而不是 chunk-7.abc123.js)

// 2. webpackPrefetch:在浏览器空闲时预加载
const SettingsPage = () => import(
  /* webpackChunkName: "settings" */
  /* webpackPrefetch: true */
  './pages/Settings'
);
// 会在 <head> 中注入 <link rel="prefetch" href="settings.abc123.js">

// 3. webpackPreload:与父 chunk 并行加载
const HeroChart = () => import(
  /* webpackChunkName: "hero-chart" */
  /* webpackPreload: true */
  './components/HeroChart'
);
// 会在 <head> 中注入 <link rel="preload" href="hero-chart.abc123.js">

prefetchpreload 的区别需要理解清楚:

策略 加载时机 优先级 适用场景
无注释 代码执行到时 - 很少访问的页面
webpackPrefetch 父 chunk 加载完后,浏览器空闲时 用户很可能接下来访问的页面
webpackPreload 与父 chunk 并行 当前页面一定会用到的模块

一个常见的误用是把所有异步 chunk 都加上 prefetch。当你有 30 个路由时,浏览器空闲后会同时发起 30 个请求去下载所有 chunk,这在移动端网络下反而会影响体验。只对用户大概率会访问的下一页使用 prefetch

React.lazy + Suspense 路由级代码分割

React 16.6 引入了 React.lazySuspense,让路由级别的代码分割变得非常简单。下面是一个完整的 React Router v6 示例:

tsx 复制代码
// src/App.tsx
import React, { Suspense, lazy } from 'react';
import {
  BrowserRouter,
  Routes,
  Route,
  Navigate,
} from 'react-router-dom';
import { MainLayout } from './layouts/MainLayout';
import { LoadingSpinner } from './components/LoadingSpinner';
import { ErrorBoundary } from './components/ErrorBoundary';

// 路由级别的懒加载
const HomePage = lazy(() => import(
  /* webpackChunkName: "home" */
  './pages/Home'
));

const ProductListPage = lazy(() => import(
  /* webpackChunkName: "product-list" */
  /* webpackPrefetch: true */
  './pages/ProductList'
));

const ProductDetailPage = lazy(() => import(
  /* webpackChunkName: "product-detail" */
  './pages/ProductDetail'
));

const AdminDashboard = lazy(() => import(
  /* webpackChunkName: "admin" */
  './pages/admin/Dashboard'
));

const UserSettings = lazy(() => import(
  /* webpackChunkName: "settings" */
  './pages/UserSettings'
));

function App() {
  return (
    <BrowserRouter>
      <ErrorBoundary>
        <MainLayout>
          <Suspense fallback={<LoadingSpinner />}>
            <Routes>
              <Route path="/" element={<HomePage />} />
              <Route path="/products" element={<ProductListPage />} />
              <Route path="/products/:id" element={<ProductDetailPage />} />
              <Route path="/settings" element={<UserSettings />} />
              <Route path="/admin/*" element={<AdminDashboard />} />
              <Route path="*" element={<Navigate to="/" replace />} />
            </Routes>
          </Suspense>
        </MainLayout>
      </ErrorBoundary>
    </BrowserRouter>
  );
}

export default App;

几个关键实践:

第一,ErrorBoundary 必须包在 Suspense 外面。 当网络异常导致 chunk 加载失败时,React.lazy 会抛出错误。如果没有 ErrorBoundary 兜底,整个应用会白屏。一个基本的 ErrorBoundary 实现:

tsx 复制代码
// src/components/ErrorBoundary.tsx
import React, { Component, ReactNode } from 'react';

interface Props {
  children: ReactNode;
}

interface State {
  hasError: boolean;
  error: Error | null;
}

class ErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false, error: null };

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }

  handleRetry = () => {
    this.setState({ hasError: false, error: null });
  };

  render() {
    if (this.state.hasError) {
      return (
        <div className="error-fallback">
          <h2>页面加载失败</h2>
          <p>请检查网络连接后重试</p>
          <button onClick={this.handleRetry}>重新加载</button>
        </div>
      );
    }
    return this.props.children;
  }
}

export { ErrorBoundary };

第二,fallback 组件要轻量。 Suspensefallback 不应该依赖任何异步模块,否则会陷入死循环。一个简单的 CSS 动画 spinner 就够了,不要在 fallback 里用 Ant Design 的 Spin 组件------那玩意儿自己也不小。

第三,合理划分 Suspense 边界。 不要只在最外层放一个 Suspense,也不要每个组件都包一个。推荐按"独立功能区域"划分:

tsx 复制代码
function AdminDashboard() {
  return (
    <div className="admin-layout">
      {/* 侧边栏始终显示 */}
      <AdminSidebar />
      <div className="admin-content">
        {/* 内容区域独立的 Suspense 边界 */}
        <Suspense fallback={<ContentSkeleton />}>
          <Routes>
            <Route path="overview" element={<AdminOverview />} />
            <Route path="users" element={<AdminUsers />} />
            <Route path="analytics" element={<AdminAnalytics />} />
          </Routes>
        </Suspense>
      </div>
    </div>
  );
}

这样切换管理后台的子页面时,侧边栏不会闪烁,只有内容区域显示 loading 状态。

组件级代码分割

路由级分割是最基本的,但很多时候真正的"大胖子"藏在组件里。典型的重量级组件包括:图表库(ECharts ~800KB)、富文本编辑器(TinyMCE ~500KB)、代码编辑器(Monaco ~2MB)、PDF 预览等。

对这些组件做按需加载的方式如下:

tsx 复制代码
// src/components/LazyChart.tsx
import React, { Suspense, lazy, useState } from 'react';

const EChartsReact = lazy(() => import(
  /* webpackChunkName: "echarts" */
  'echarts-for-react'
));

interface ChartProps {
  data: number[];
  title: string;
}

function LazyChart({ data, title }: ChartProps) {
  const option = {
    title: { text: title },
    xAxis: { type: 'category' as const },
    yAxis: { type: 'value' as const },
    series: [{ data, type: 'bar' as const }],
  };

  return (
    <Suspense fallback={<div className="chart-skeleton" />}>
      <EChartsReact option={option} style={{ height: 400 }} />
    </Suspense>
  );
}

对于弹窗类组件,可以在用户触发操作时才加载:

tsx 复制代码
// src/components/ExportDialog.tsx
import React, { Suspense, lazy, useState, useCallback } from 'react';

const HeavyExportDialog = lazy(() => import(
  /* webpackChunkName: "export-dialog" */
  './HeavyExportDialog'
));

function ExportButton() {
  const [showDialog, setShowDialog] = useState(false);

  const handleClick = useCallback(() => {
    setShowDialog(true);
  }, []);

  // 鼠标悬停时预加载,点击时秒开
  const handleMouseEnter = useCallback(() => {
    import(/* webpackChunkName: "export-dialog" */ './HeavyExportDialog');
  }, []);

  return (
    <>
      <button
        onClick={handleClick}
        onMouseEnter={handleMouseEnter}
      >
        导出报告
      </button>
      {showDialog && (
        <Suspense fallback={<div>加载中...</div>}>
          <HeavyExportDialog onClose={() => setShowDialog(false)} />
        </Suspense>
      )}
    </>
  );
}

注意 onMouseEnter 中的 import() 调用------当用户把鼠标移到按钮上时就开始加载 chunk,等到真正点击时通常已经加载完毕。这个技巧在用户意图明确的场景下(比如"导出"按钮、"高级设置"入口)非常有效。

Vendor Chunk 拆分策略

光做路由和组件级分割还不够,还需要合理配置 splitChunks 来拆分第三方依赖。一个经过实践验证的配置如下:

typescript 复制代码
// webpack.config.ts(webpack 5)
import type { Configuration } from 'webpack';

const config: Configuration = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      maxInitialRequests: 20,
      maxAsyncRequests: 20,
      minSize: 20_000,      // 20KB 以下不拆
      maxSize: 244_000,     // 超过 244KB 的 chunk 尝试继续拆分
      cacheGroups: {
        // React 全家桶,变动频率低,单独缓存
        reactVendor: {
          test: /[\\/]node_modules[\\/](react|react-dom|react-router|react-router-dom)[\\/]/,
          name: 'vendor-react',
          priority: 40,
          reuseExistingChunk: true,
        },
        // UI 组件库
        uiVendor: {
          test: /[\\/]node_modules[\\/](antd|@ant-design)[\\/]/,
          name: 'vendor-ui',
          priority: 30,
          reuseExistingChunk: true,
        },
        // 工具库
        utilVendor: {
          test: /[\\/]node_modules[\\/](lodash|dayjs|axios)[\\/]/,
          name: 'vendor-utils',
          priority: 20,
          reuseExistingChunk: true,
        },
        // 其他第三方依赖兜底
        defaultVendors: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendor-common',
          priority: 10,
          reuseExistingChunk: true,
          minChunks: 2,   // 被至少 2 个 chunk 引用才拆出来
        },
        // 项目内公共模块
        common: {
          name: 'common',
          minChunks: 3,
          priority: 5,
          reuseExistingChunk: true,
        },
      },
    },
  },
};

export default config;

这套配置的核心思路:

  1. 按更新频率分组。React 可能半年升一次,antd 可能每月升一次,工具库可能每次发版都会更新。分开打包后,React 的 chunk 可以长期走浏览器缓存,用户只需要重新下载变化的部分。

  2. minChunks 控制公共模块提取 。如果一个模块只被一个路由页面引用,没必要拆出来做公共 chunk,因为拆出来反而多了一次 HTTP 请求。minChunks: 2minChunks: 3 是比较合理的阈值。

  3. maxSize 防止单个 chunk 过大。设为 244KB(gzip 后约 60-80KB)是一个经验值,兼顾了 HTTP/2 多路复用的优势和浏览器解析效率。

如果你使用 Vite,对应的配置方式如下:

typescript 复制代码
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  build: {
    rollupOptions: {
      output: {
        manualChunks: (id: string) => {
          if (id.includes('node_modules')) {
            if (/react|react-dom|react-router/.test(id)) {
              return 'vendor-react';
            }
            if (/antd|@ant-design/.test(id)) {
              return 'vendor-ui';
            }
            return 'vendor-common';
          }
        },
      },
    },
  },
});

字体优化

字体文件是另一个容易被忽略的性能黑洞。一个中文字体文件动辄 5-10MB(包含 2 万多个汉字),即使是英文字体,多个字重加起来也有几百 KB。

font-display 策略对比

font-display 控制浏览器在字体加载期间的渲染行为。不同值的表现差异很大:

css 复制代码
@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom.woff2') format('woff2');
  font-display: swap;  /* 根据场景选择合适的值 */
}
阻塞期 交换期 行为描述 适用场景
auto 由浏览器决定 由浏览器决定 通常等同于 block 不推荐使用
block 3秒 无限 先隐藏文字,加载完后替换 图标字体
swap 无限 先用后备字体,加载完后替换 正文文字(推荐)
fallback 100ms 3秒 短暂隐藏,3秒内加载完就替换,否则放弃 重要但非关键的字体
optional 100ms 极短隐藏,由浏览器决定是否替换 装饰性字体

大多数场景推荐 swap------用户先看到后备字体渲染的文字,字体加载完成后再平滑替换。唯一的缺点是可能出现 FOUT(Flash of Unstyled Text),但比 3 秒白屏强太多了。

对于首屏标题等关键文字,配合 preload 可以进一步减少 FOUT 的持续时间:

html 复制代码
<link
  rel="preload"
  href="/fonts/title-font.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>

注意 crossorigin 属性不能省略,即使字体文件和页面同源。这是因为字体请求默认使用匿名 CORS 模式,如果 preload 没加 crossorigin,浏览器会发起两次请求------一次是 preload 的非 CORS 请求,一次是 @font-face 触发的 CORS 请求。

字体子集化

中文字体优化的核心手段是子集化------从完整字体中只提取页面实际用到的字符。一个完整的思源黑体约 8MB,而一个典型的企业官网可能只用到 800 个汉字,子集化后可以压缩到 100KB 以内。

使用 fonttools(Python 工具)进行子集化:

bash 复制代码
# 安装工具
pip install fonttools brotli

# 从文本文件提取字符并生成子集字体
pyftsubset NotoSansSC-Regular.otf \
  --text-file=chars.txt \
  --output-file=NotoSansSC-subset.woff2 \
  --flavor=woff2 \
  --layout-features='kern,liga,calt' \
  --no-hinting \
  --desubroutinize

# 也可以直接指定 Unicode 范围
pyftsubset NotoSansSC-Regular.otf \
  --unicodes="U+0000-00FF,U+4E00-9FFF" \
  --output-file=NotoSansSC-common.woff2 \
  --flavor=woff2

在实际项目中,可以写一个构建脚本自动从源码中提取用到的中文字符:

typescript 复制代码
// scripts/extract-chars.ts
import * as fs from 'fs';
import * as path from 'path';
import { execSync } from 'child_process';

function extractChineseChars(dir: string): Set<string> {
  const chars = new Set<string>();
  const chineseRegex = /[一-鿿㐀-䶿]/g;

  function walk(currentDir: string) {
    const files = fs.readdirSync(currentDir);
    for (const file of files) {
      const fullPath = path.join(currentDir, file);
      const stat = fs.statSync(fullPath);
      if (stat.isDirectory() && !file.startsWith('.') && file !== 'node_modules') {
        walk(fullPath);
      } else if (/\.(tsx?|jsx?|json|md)$/.test(file)) {
        const content = fs.readFileSync(fullPath, 'utf-8');
        const matches = content.match(chineseRegex);
        if (matches) {
          matches.forEach((char) => chars.add(char));
        }
      }
    }
  }

  walk(dir);
  return chars;
}

const chars = extractChineseChars('./src');
const charList = Array.from(chars).sort().join('');
fs.writeFileSync('./fonts/chars.txt', charList, 'utf-8');
console.log(`提取到 ${chars.size} 个中文字符`);

// 调用 pyftsubset 生成子集
execSync(
  `pyftsubset fonts/NotoSansSC-Regular.otf ` +
  `--text-file=fonts/chars.txt ` +
  `--output-file=public/fonts/NotoSansSC-subset.woff2 ` +
  `--flavor=woff2 --no-hinting --desubroutinize`
);

可变字体替代多字重文件

传统做法需要为每个字重单独引入一个字体文件:

css 复制代码
/* 传统方式:3 个字重 = 3 个文件 */
@font-face {
  font-family: 'Inter';
  font-weight: 400;
  src: url('/fonts/Inter-Regular.woff2') format('woff2');
}
@font-face {
  font-family: 'Inter';
  font-weight: 600;
  src: url('/fonts/Inter-SemiBold.woff2') format('woff2');
}
@font-face {
  font-family: 'Inter';
  font-weight: 700;
  src: url('/fonts/Inter-Bold.woff2') format('woff2');
}
/* 总计: ~300KB */

使用可变字体(Variable Font)可以用一个文件覆盖所有字重:

css 复制代码
/* 可变字体:1 个文件覆盖所有字重 */
@font-face {
  font-family: 'Inter';
  font-weight: 100 900;
  font-display: swap;
  src: url('/fonts/Inter-Variable.woff2') format('woff2-variations');
}

/* 使用时可以指定任意字重值 */
h1 { font-weight: 750; }
.subtitle { font-weight: 350; }
/* 总计: ~200KB,且支持更灵活的字重 */

可变字体的文件体积通常比 2-3 个静态字体文件的总和更小,同时提供了连续的字重选择空间。对于需要多个字重的项目,可变字体几乎总是更优的选择。

预加载关键字体

综合以上策略,一个完整的字体加载方案如下:

html 复制代码
<!DOCTYPE html>
<html>
<head>
  <!-- 预加载首屏关键字体 -->
  <link
    rel="preload"
    href="/fonts/Inter-Variable.woff2"
    as="font"
    type="font/woff2"
    crossorigin
  />
  <link
    rel="preload"
    href="/fonts/NotoSansSC-subset.woff2"
    as="font"
    type="font/woff2"
    crossorigin
  />

  <style>
    @font-face {
      font-family: 'Inter';
      font-weight: 100 900;
      font-display: swap;
      src: url('/fonts/Inter-Variable.woff2') format('woff2-variations');
    }

    @font-face {
      font-family: 'Noto Sans SC';
      font-weight: 400;
      font-display: swap;
      src: url('/fonts/NotoSansSC-subset.woff2') format('woff2');
    }

    body {
      font-family: 'Inter', 'Noto Sans SC', -apple-system, BlinkMacSystemFont,
                   'Segoe UI', Roboto, sans-serif;
    }
  </style>
</head>
</html>

只预加载首屏一定会用到的字体文件(通常 1-2 个),其他字体交给浏览器按需加载即可。预加载过多字体文件会占用带宽,反而拖慢其他关键资源的加载。

效果度量:优化前后对比

说了这么多优化手段,落地效果如何?以一个真实的中后台管理系统项目为例,展示优化前后的 bundle 分析数据。

优化前

less 复制代码
主包 main.js:          1,847 KB (gzip: 512 KB)
vendor.js:              923 KB (gzip: 287 KB)
字体文件总计:            8,200 KB
────────────────────────────────
首屏加载总计:            ~9,970 KB
首屏 LCP (4G):          4.2s
首屏 TTI (4G):          5.8s

优化后

less 复制代码
主包 main.js:            187 KB (gzip: 58 KB)
vendor-react.js:         142 KB (gzip: 45 KB)
vendor-ui.js:            按需加载
vendor-utils.js:          38 KB (gzip: 12 KB)
路由 chunk (首页):         23 KB (gzip: 7 KB)
字体文件总计:              186 KB
────────────────────────────────
首屏加载总计:              ~555 KB
首屏 LCP (4G):           1.4s
首屏 TTI (4G):           2.1s
指标 优化前 优化后 降幅
首屏 JS 体积 2,770 KB 390 KB -86%
字体体积 8,200 KB 186 KB -98%
首屏总加载 9,970 KB 555 KB -94%
LCP 4.2s 1.4s -67%
TTI 5.8s 2.1s -64%

webpack-bundle-analyzer 可以直观地看到优化效果:

bash 复制代码
# 安装分析工具
npm install --save-dev webpack-bundle-analyzer

# 生成分析报告
npx webpack --profile --json > stats.json
npx webpack-bundle-analyzer stats.json

如果使用 Vite,可以用 rollup-plugin-visualizer

bash 复制代码
npm install --save-dev rollup-plugin-visualizer
typescript 复制代码
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  plugins: [
    react(),
    visualizer({
      open: true,
      gzipSize: true,
      brotliSize: true,
      filename: 'dist/stats.html',
    }),
  ],
});

决策树:什么该拆、什么不该拆

代码分割不是越多越好。每拆一个 chunk 就多一次 HTTP 请求(虽然 HTTP/2 缓解了这个问题),也多了 webpack runtime 的管理开销。以下是一个实用的决策框架:

应该拆分的:

  • 路由页面:每个路由页面独立成 chunk,这是最基本的分割
  • 大型第三方库(超过 100KB):如 ECharts、Monaco Editor、PDF.js
  • 条件渲染的重型组件:弹窗、抽屉中的复杂表单、管理后台模块
  • 低频使用的功能:导出报表、数据迁移工具、高级设置
  • A/B 测试的变体:不同实验分支的组件

不应该拆分的:

  • 首屏直接渲染的组件:拆了反而多一次请求延迟
  • 体积小于 20KB 的模块:拆分的开销大于收益
  • 几乎每个页面都用到的公共组件:Header、Footer、Toast
  • 紧密耦合的模块组:两个模块总是一起使用,拆开毫无意义

需要根据情况判断的:

  • 用户大概率会访问的下一页:用 prefetch 而非拆分
  • 首屏组件的非关键状态:比如表格的"列设置"面板,可以做组件级分割
  • 国际化资源文件:如果每种语言包超过 50KB,考虑按语言拆分

一个简化的判断流程:

markdown 复制代码
模块体积 > 100KB?
  ├── 是 → 首屏必需?
  │        ├── 是 → 不拆,但做 vendor 分组缓存
  │        └── 否 → 拆分为异步 chunk
  └── 否 → 被多个路由共享?
           ├── 是 → 放入 common chunk
           └── 否 → 保留在所属路由 chunk 中

总结

前端瘦身的核心思路是"用户当前需要什么,就只加载什么"。代码分割解决的是"不要一次加载所有 JS"的问题,路由懒加载解决的是"不要加载用户没访问的页面"的问题,字体子集化解决的是"不要加载用户看不到的字符"的问题。

三板斧的实施建议按以下优先级推进:

  1. 先用 bundle analyzer 看清现状,找到最大的优化空间
  2. 实施路由级代码分割,这是投入产出比最高的优化
  3. 配置 splitChunks 做 vendor 分组,提高缓存命中率
  4. 对重型组件做按需加载
  5. 字体子集化 + preload 关键字体
  6. 持续监控,在 CI 中加入 bundle size 检查,防止体积回涨

每一步都要量化效果,用数据驱动优化决策,而不是凭感觉把所有东西都拆一遍。

如果觉得有帮助,欢迎点赞收藏关注,后续会继续分享前端性能优化相关的实践。

相关推荐
dsyyyyy11014 小时前
CSS 2D 效果、3D 效果 与 Animation 总结
前端·css·3d
jerrywus4 小时前
Vibe Coding 实战:三天,一个人,一个 Claude Session Viewer——给三家 AI CLI 当统一会话浏览器
前端·claude·gemini
lichenyang4534 小时前
HarmonyOS AI 聊天模块架构复盘:从 UI、状态、Controller 到 Provider、SSE 与业务卡片
前端
wanger615 小时前
AI Agent
前端·javascript·人工智能
徐小夕5 小时前
面试官:AI生成到90%突然断了,你的解决方案是什么?(万字长文深度剖析)
前端·vue.js·算法
剑神一笑5 小时前
Linux zip 与 unzip 命令详解:压缩算法原理与实战技巧
linux·前端·chrome
PieroPC5 小时前
Nginx 完全教程
前端
大波V55 小时前
claude-code cli 跳过登录
java·服务器·前端