# Tree Shaking 深度解析:为什么你的代码没被摇掉?

前言

你可能已经听说过 Tree Shaking ------ 这个听起来很酷的术语,意思是"把没用到的代码像枯叶一样摇下来"。Webpack、Rollup、esbuild 都声称支持 Tree Shaking,你也确实在配置里开启了 production 模式,但打完包一看:bundle 体积依然庞大,很多明明没用到的代码还是被打了进去。

问题出在哪?

本文将从原理层面深入剖析 Tree Shaking 的工作机制,列举它失效的常见场景,并给出可落地的排查和修复方案。无论你是业务开发者还是库作者,都能从中找到优化思路。

什么是 Tree Shaking

Tree Shaking 是一种基于 ES Module 静态分析的死代码消除(Dead Code Elimination)技术

它的核心思想很简单:如果一个模块导出了 10 个函数,但使用方只 import 了其中 2 个,那么剩下的 8 个函数就不应该出现在最终产物中。

bash 复制代码
源码依赖树(摇树前)            打包产物(摇树后)

+------------------+           +------------------+
|   utils/index.ts |           |   utils/index.ts |
|                  |           |                  |
|  export add      | -------> |  export add      |
|  export subtract |    X     |                  |
|  export multiply |    X     |                  |
|  export divide   | -------> |  export divide   |
+------------------+           +------------------+

X = 未被任何模块引用,被 Tree Shaking 移除

需要注意的是,Tree Shaking 并不是一个独立的优化手段,它通常和**压缩(Minification)**配合工作。打包工具先通过静态分析标记未使用的导出,然后由压缩器(如 Terser)在最终阶段将这些标记为"未使用"的代码真正删除。

工作原理:为什么必须是 ES Module

Tree Shaking 能够工作的前提是:模块系统必须是静态可分析的。这正是 ES Module 和 CommonJS 的根本区别。

ES Module 的静态结构

typescript 复制代码
// ES Module - 静态导入,编译时即可确定依赖关系
import { add } from './math';       // 明确知道只用了 add
import { format } from './string';  // 明确知道只用了 format

ES Module 的关键特性:

  • import / export 必须出现在模块顶层,不能嵌套在 if/for 等语句中
  • 导入的模块标识符是字符串字面量,不能是变量
  • 导入的绑定是只读的,不能被重新赋值

这些约束使得打包工具可以在**编译时(而非运行时)**确定模块之间的依赖关系,从而安全地移除未使用的导出。

CommonJS 的动态特性

javascript 复制代码
// CommonJS - 动态加载,运行时才能确定依赖
const lib = require('./math');           // 导入整个模块对象
const fn = require(condition ? 'a' : 'b'); // 模块路径可以是表达式
if (process.env.NODE_ENV === 'dev') {
  require('./dev-tools');                // 条件加载
}
module.exports[name] = value;            // 动态导出

CommonJS 的 require 是一个普通函数调用,可以出现在任何位置、接受任何表达式作为参数。这意味着打包工具无法在编译时确定哪些导出会被使用,只能保守地将整个模块打包进去。

对比总结

lua 复制代码
+--------------------+------------------------+------------------------+
|       特性         |      ES Module         |      CommonJS          |
+--------------------+------------------------+------------------------+
| 语法               | import / export        | require / module.exports|
| 加载时机           | 编译时静态解析          | 运行时动态加载          |
| 导入位置           | 只能在顶层              | 可在任意位置            |
| 模块标识符         | 必须是字符串字面量       | 可以是表达式            |
| Tree Shaking       | 支持                   | 不支持                  |
| 浏览器原生支持      | 是                     | 否                      |
+--------------------+------------------------+------------------------+

为什么你的代码没被摇掉:常见陷阱

理论上 Tree Shaking 应该能自动移除所有未使用的代码,但实际项目中它经常"失灵"。以下是最常见的五个原因。

陷阱一:模块存在副作用(Side Effects)

这是 Tree Shaking 失效的最常见原因

所谓"副作用",是指模块在被导入时,除了导出值之外还会执行一些影响外部状态的操作。

typescript 复制代码
// side-effect-example.ts
// 这个模块有副作用:导入时会修改全局对象
window.globalConfig = { theme: 'dark' };

// 这个模块有副作用:导入时会执行 polyfill
if (!Array.prototype.flat) {
  Array.prototype.flat = function() { /* ... */ };
}

// 这个模块有副作用:IIFE 立即执行
(function() {
  console.log('module loaded');
})();

export function pureFunction() {
  return 42;
}

即使你只导入了 pureFunction,打包工具也不敢移除这个模块的其他代码,因为那些副作用可能是项目正常运行所必需的。

package.json 的 sideEffects 字段

为了解决这个问题,Webpack 引入了 sideEffects 配置。它允许库作者声明自己的包是否存在副作用:

json 复制代码
{
  "name": "my-utils",
  "sideEffects": false
}

sideEffects: false 告诉打包工具:"这个包的所有模块都是纯的,没有副作用。如果某个模块的导出没被使用,可以安全地整个跳过。"

你也可以指定哪些文件有副作用:

json 复制代码
{
  "name": "my-ui-lib",
  "sideEffects": [
    "*.css",
    "*.scss",
    "./src/polyfills.ts",
    "./src/register-global.ts"
  ]
}

这样打包工具就知道:除了列出的文件,其他模块都可以安全地 Tree Shake。

陷阱二:桶文件重导出(Barrel Exports)

这是中大型项目中非常常见的模式:

typescript 复制代码
// utils/index.ts - 桶文件(barrel file)
export { add, subtract, multiply, divide } from './math';
export { formatDate, parseDate, diffDate } from './date';
export { capitalize, truncate, slugify } from './string';
export { debounce, throttle } from './timing';
export { deepClone, merge, pick, omit } from './object';

使用方通常这样导入:

typescript 复制代码
// 只需要 add 和 formatDate
import { add, formatDate } from '@/utils';

看起来很整洁,但问题在于:打包工具需要先加载整个 index.ts,解析所有重导出的模块,然后才能确定哪些导出被使用了。

在理想情况下(所有模块都标记了 sideEffects: false),Tree Shaking 可以正常工作。但在实际项目中,往往会因为以下原因导致失效:

sql 复制代码
使用桶文件导入:
import { add } from '@/utils'
                |
                v
+---------------------------+
|     utils/index.ts        |  <-- 必须加载解析整个文件
|                           |
|  re-export math    -------+---> 加载 math.ts (需要 add)
|  re-export date    -------+---> 加载 date.ts (不需要,但可能有副作用)
|  re-export string  -------+---> 加载 string.ts (不需要,但可能有副作用)
|  re-export timing  -------+---> 加载 timing.ts (不需要,但可能有副作用)
|  re-export object  -------+---> 加载 object.ts (不需要,但可能有副作用)
+---------------------------+

直接导入:
import { add } from '@/utils/math'
                |
                v
+---------------------------+
|     utils/math.ts         |  <-- 只加载这一个文件
+---------------------------+

桶文件的问题不仅是 Tree Shaking 失效,还会拖慢构建速度(需要解析更多模块)和开发时的 HMR 速度。

陷阱三:CommonJS 模块无法被 Tree Shake

如果你的依赖是用 CommonJS 格式发布的,那么 Tree Shaking 对它基本无效:

javascript 复制代码
// node_modules/some-lib/index.js (CommonJS)
module.exports = {
  funcA: function() { /* ... */ },
  funcB: function() { /* ... */ },
  funcC: function() { /* ... */ },
};

// 你的代码
import { funcA } from 'some-lib';
// funcB 和 funcC 依然会被打包进来

虽然 Webpack 5 对 CommonJS 做了一定程度的静态分析优化,但效果远不如原生 ES Module。这也是为什么社区一直在推动"ESM-first"的原因。

陷阱四:动态属性访问

当你使用动态的方式访问模块导出时,打包工具无法确定哪些属性会被访问到:

typescript 复制代码
// 打包工具无法分析动态属性访问
import * as validators from './validators';

function validate(type: string, value: unknown) {
  // type 是运行时才知道的值,打包工具无法确定需要哪些 validator
  const validatorFn = validators[type];
  if (validatorFn) {
    return validatorFn(value);
  }
  return false;
}

改写为静态导入可以让 Tree Shaking 正常工作:

typescript 复制代码
// 静态导入 + 显式映射
import { isEmail, isPhone, isUrl } from './validators';

const validatorMap = {
  email: isEmail,
  phone: isPhone,
  url: isUrl,
} as const;

function validate(type: keyof typeof validatorMap, value: unknown) {
  return validatorMap[type](value);
}

陷阱五:类方法无法被单独 Tree Shake

Tree Shaking 的粒度是模块导出,而不是类的方法:

typescript 复制代码
// DateHelper.ts
export class DateHelper {
  static format(date: Date): string { /* 100 行实现 */ }
  static parse(str: string): Date { /* 80 行实现 */ }
  static diff(a: Date, b: Date): number { /* 60 行实现 */ }
  static add(date: Date, days: number): Date { /* 40 行实现 */ }
}

// 使用方
import { DateHelper } from './DateHelper';
DateHelper.format(new Date());
// parse, diff, add 的代码依然会被打包

因为类是一个整体,打包工具无法确定你是否会在运行时通过反射等方式访问其他方法。

解决方案:使用独立的函数导出代替类方法

typescript 复制代码
// date-utils.ts
export function formatDate(date: Date): string { /* ... */ }
export function parseDate(str: string): Date { /* ... */ }
export function diffDate(a: Date, b: Date): number { /* ... */ }
export function addDays(date: Date, days: number): Date { /* ... */ }

// 使用方 - 只有 formatDate 会被打包
import { formatDate } from './date-utils';
formatDate(new Date());

实战排查:确认 Tree Shaking 是否生效

方法一:webpack-bundle-analyzer

这是最直观的方式。它会生成一个交互式的可视化图表,展示 bundle 中每个模块的体积占比。

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

# webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: 'static',        // 生成静态 HTML 文件
      reportFilename: 'bundle-report.html',
      openAnalyzer: false,
    }),
  ],
};

构建后打开生成的 HTML 文件,你可以清楚地看到:

  • 哪些模块占用了大量体积
  • 是否有重复打包的模块
  • 某个库是否被完整打包进来(而不是只包含你用到的部分)
lua 复制代码
+-------------------------------------------------------+
|                    bundle-report                       |
|                                                        |
|  +------------------+  +---------------------------+  |
|  |                  |  |                           |  |
|  |   lodash         |  |     moment                |  |
|  |   71.5 KB        |  |     232 KB                |  |
|  |                  |  |                           |  |
|  |  (你只用了       |  |  (包含全部 locale 文件     |  |
|  |   3 个函数)      |  |   你只需要中文)            |  |
|  +------------------+  +---------------------------+  |
|                                                        |
|  +----------+  +--------+  +--------+  +-----------+  |
|  | src/     |  | react  |  | axios  |  | other     |  |
|  | 45 KB    |  | 6.3 KB |  | 13 KB  |  | 28 KB     |  |
|  +----------+  +--------+  +--------+  +-----------+  |
+-------------------------------------------------------+

方法二:source-map-explorer

如果你不想安装 Webpack 插件,可以使用 source-map-explorer 直接分析已有的 source map:

bash 复制代码
# 安装
npm install --save-dev source-map-explorer

# 分析
npx source-map-explorer dist/main.js

# 生成 HTML 报告
npx source-map-explorer dist/main.js --html result.html

方法三:手动检查打包产物

对于快速验证,你可以直接在打包产物中搜索特定函数名:

bash 复制代码
# 在未压缩的产物中搜索
# 如果某个你没用到的函数名出现在产物中,说明 Tree Shaking 没有生效
grep -n "unusedFunction" dist/main.js

方法四:Webpack Stats 分析

javascript 复制代码
// webpack.config.js
module.exports = {
  // 开启 usedExports,在产物中标记未使用的导出
  optimization: {
    usedExports: true,  // 在开发模式下方便调试
  },
};

在开发模式下构建,Webpack 会在产物中添加注释标记:

javascript 复制代码
/* unused harmony export subtract */
/* unused harmony export multiply */

看到这些注释说明 Webpack 正确地识别出了未使用的导出,在 production 构建时 Terser 会将它们移除。

修复 Tree Shaking 问题

1. 正确配置 sideEffects

在你的项目和库的 package.json 中明确声明副作用:

json 复制代码
{
  "name": "my-project",
  "sideEffects": [
    "*.css",
    "*.less",
    "*.scss",
    "./src/polyfills.ts",
    "./src/init.ts"
  ]
}

如果你的项目是纯函数式的,没有任何副作用文件:

json 复制代码
{
  "sideEffects": false
}

2. 消除桶文件的影响

方案 A:直接从源文件导入

typescript 复制代码
// Before - 从桶文件导入
import { debounce } from '@/utils';

// After - 直接导入
import { debounce } from '@/utils/timing';

方案 B:使用路径映射简化导入

json 复制代码
// tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@/utils/math": ["./src/utils/math"],
      "@/utils/date": ["./src/utils/date"],
      "@/utils/string": ["./src/utils/string"]
    }
  }
}

方案 C:如果必须保留桶文件,确保所有子模块都标记为无副作用

json 复制代码
// utils/package.json
{
  "sideEffects": false
}

3. 选择 ESM 格式的依赖包

typescript 复制代码
// Before - CommonJS 版本,无法 Tree Shake
import { get } from 'lodash';         // 打包整个 lodash (71.5 KB)

// After - ESM 版本,支持 Tree Shake
import { get } from 'lodash-es';       // 只打包 get 相关代码 (~2 KB)

如果某个包没有提供 ESM 版本,可以考虑:

  • 寻找同功能的 ESM 替代包
  • 使用按路径导入(如果包支持的话)
  • 向包作者提 Issue 请求发布 ESM 版本

4. 避免 namespace import 后动态访问

typescript 复制代码
// Before - 阻碍 Tree Shaking
import * as utils from './utils';
const fn = utils[name];

// After - 支持 Tree Shaking
import { specificUtil } from './utils';

5. Webpack 关键配置检查清单

javascript 复制代码
// webpack.config.js (production)
module.exports = {
  mode: 'production',                   // 必须
  optimization: {
    usedExports: true,                  // 标记未使用的导出 (production 默认开启)
    minimize: true,                     // 启用压缩以真正删除代码 (production 默认开启)
    concatenateModules: true,           // 模块合并,也叫 Scope Hoisting (production 默认开启)
    sideEffects: true,                  // 读取 package.json 的 sideEffects 字段 (production 默认开启)
  },
};

库作者指南:如何发布可 Tree Shake 的包

如果你是一个 npm 包的作者,以下是让你的包支持 Tree Shaking 的最佳实践。

1. 同时提供 ESM 和 CJS 格式

json 复制代码
{
  "name": "my-awesome-lib",
  "version": "1.0.0",
  "main": "./dist/cjs/index.js",
  "module": "./dist/esm/index.mjs",
  "exports": {
    ".": {
      "import": "./dist/esm/index.mjs",
      "require": "./dist/cjs/index.js",
      "types": "./dist/types/index.d.ts"
    },
    "./math": {
      "import": "./dist/esm/math.mjs",
      "require": "./dist/cjs/math.js",
      "types": "./dist/types/math.d.ts"
    }
  },
  "sideEffects": false,
  "files": ["dist"]
}

各字段的作用:

lua 复制代码
+------------------+--------------------------------------------------+
|     字段          |     作用                                          |
+------------------+--------------------------------------------------+
| main             | CommonJS 入口,Node.js require() 使用              |
| module           | ESM 入口,打包工具(Webpack/Rollup)优先使用         |
| exports          | Node.js 12.11+ 的条件导出,最高优先级               |
| sideEffects      | 告诉打包工具哪些文件有副作用                         |
| files            | 控制 npm publish 包含哪些文件                       |
+------------------+--------------------------------------------------+

2. 使用独立函数而非类

typescript 复制代码
// 推荐 - 每个函数可独立 Tree Shake
export function createStore<T>(initial: T) { /* ... */ }
export function combineReducers(reducers: Record<string, Function>) { /* ... */ }
export function applyMiddleware(...middlewares: Function[]) { /* ... */ }

// 不推荐 - 类无法按方法粒度 Tree Shake
export class Store {
  create<T>(initial: T) { /* ... */ }
  combineReducers(reducers: Record<string, Function>) { /* ... */ }
  applyMiddleware(...middlewares: Function[]) { /* ... */ }
}

3. 提供子路径导入

json 复制代码
// package.json
{
  "exports": {
    ".": "./dist/esm/index.mjs",
    "./math": "./dist/esm/math.mjs",
    "./date": "./dist/esm/date.mjs",
    "./string": "./dist/esm/string.mjs"
  }
}

这样使用者可以只导入需要的子模块:

typescript 复制代码
import { add } from 'my-awesome-lib/math';

4. 标注纯函数

对于打包工具难以判断的调用,使用 /*#__PURE__*/ 注释标记纯函数调用:

typescript 复制代码
// 告诉打包工具:如果返回值没被使用,这个调用可以安全移除
export const defaultConfig = /*#__PURE__*/ createDefaultConfig();
export const logger = /*#__PURE__*/ createLogger({ level: 'info' });

5. 构建工具配置示例(Rollup)

javascript 复制代码
// rollup.config.js
export default {
  input: 'src/index.ts',
  output: [
    {
      dir: 'dist/esm',
      format: 'esm',
      preserveModules: true,       // 保留模块结构,有利于 Tree Shaking
      entryFileNames: '[name].mjs',
    },
    {
      dir: 'dist/cjs',
      format: 'cjs',
      preserveModules: true,
      entryFileNames: '[name].js',
    },
  ],
  external: ['react', 'react-dom'],
  plugins: [
    typescript({ tsconfig: './tsconfig.build.json' }),
  ],
};

真实案例分析

案例一:lodash vs lodash-es

lodash(CommonJS 版本)

typescript 复制代码
// 导入方式 1:从主入口导入(最差)
import { get, set, cloneDeep } from 'lodash';
// 结果:打包整个 lodash,约 71.5 KB (gzipped ~25 KB)

// 导入方式 2:按路径导入(较好,但依然是 CJS)
import get from 'lodash/get';
import set from 'lodash/set';
// 结果:只打包用到的函数,约 8 KB

lodash-es(ESM 版本)

typescript 复制代码
// 从 ESM 版本导入,Tree Shaking 自动生效
import { get, set, cloneDeep } from 'lodash-es';
// 结果:只打包用到的函数,约 6 KB

对比数据:

diff 复制代码
+---------------------+-----------------+------------------+
|     导入方式         |   打包体积       |   Tree Shaking   |
+---------------------+-----------------+------------------+
| lodash 整体导入      |   71.5 KB       |   不生效          |
| lodash 按路径导入    |   ~8 KB          |   手动拆分        |
| lodash-es 导入      |   ~6 KB          |   自动生效        |
+---------------------+-----------------+------------------+

案例二:moment vs dayjs

moment.js 是一个典型的"无法 Tree Shake"的库:

  • 使用 CommonJS 格式
  • 内置了所有 locale 文件
  • 采用可变对象(mutable)的 API 设计
typescript 复制代码
// moment - 即使只用了 format,也会打包整个库
import moment from 'moment';
moment().format('YYYY-MM-DD');
// 打包体积:232 KB (gzipped ~67 KB),其中 locale 文件占 170+ KB

dayjs 是 moment 的现代替代品,专为 Tree Shaking 设计:

  • 使用 ESM 格式
  • locale 和插件按需加载
  • 不可变(immutable)的 API 设计
  • API 与 moment 高度兼容
typescript 复制代码
// dayjs - 按需导入,体积极小
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/zh-cn';

dayjs.extend(relativeTime);
dayjs.locale('zh-cn');
dayjs().format('YYYY-MM-DD');
// 打包体积:核心 2.9 KB + relativeTime 插件 ~1 KB = ~4 KB (gzipped)

对比数据:

lua 复制代码
+------------------+-------------------+------------------+-----------------+
|      库           |   完整打包体积     |  按需加载体积     |   Tree Shaking  |
+------------------+-------------------+------------------+-----------------+
| moment           |   232 KB          |  无法按需         |   不支持         |
| dayjs            |   2.9 KB (核心)    |  ~4 KB (含插件)  |   支持           |
| date-fns         |   78 KB (完整)     |  ~6 KB (3个函数) |   支持           |
+------------------+-------------------+------------------+-----------------+

案例三:实际项目优化前后对比

以一个典型的中后台管理系统为例,通过 Tree Shaking 优化前后的 bundle 体积对比:

sql 复制代码
优化前(未正确配置 Tree Shaking)
+------------------------------------------+
|              main.js (1.82 MB)            |
|                                           |
|  lodash          71.5 KB   ████████       |
|  moment          232 KB    ██████████████ |
|  antd (全量)      580 KB   ██████████████████████████████ |
|  @utils (桶文件)   45 KB   █████          |
|  业务代码          180 KB  ██████████     |
|  其他依赖          712 KB  ████████████████████████████   |
+------------------------------------------+

优化后(正确配置 Tree Shaking + 替换依赖)
+------------------------------------------+
|              main.js (486 KB)             |
|                                           |
|  lodash-es        6 KB    █              |
|  dayjs            4 KB    █              |
|  antd (按需)      95 KB   ██████         |
|  @utils (直接导入) 12 KB  █              |
|  业务代码          180 KB ██████████     |
|  其他依赖          189 KB ██████████     |
+------------------------------------------+

体积减少: 1.82 MB -> 486 KB (减少 73%)

优化措施总结:

lua 复制代码
+-----+---------------------------+---------------------------+-----------+
| 序号 |  优化项                    |  具体操作                  | 体积减少   |
+-----+---------------------------+---------------------------+-----------+
|  1  | lodash -> lodash-es       | 替换导入源                 | -65.5 KB  |
|  2  | moment -> dayjs           | 替换库 + 按需加载插件       | -228 KB   |
|  3  | antd 按需导入              | babel-plugin-import 配置  | -485 KB   |
|  4  | 消除桶文件                  | 改为直接导入源文件          | -33 KB    |
|  5  | 添加 sideEffects 配置      | package.json 声明          | -52 KB    |
|  6  | 其他依赖替换 ESM 版本       | 逐个排查替换               | -523 KB   |
+-----+---------------------------+---------------------------+-----------+
|     |  合计                      |                           | -1386 KB  |
+-----+---------------------------+---------------------------+-----------+

总结

Tree Shaking 是前端性能优化中投入产出比极高的一环,但它并不是"开了 production 模式就万事大吉"。要真正发挥它的效果,需要理解它的工作原理和局限性:

  1. 前提条件:使用 ES Module 格式,这是 Tree Shaking 工作的基础
  2. 副作用声明 :在 package.json 中正确配置 sideEffects 字段
  3. 避免桶文件 :尽量直接从源模块导入,而非通过 index.ts 中转
  4. 选择 ESM 依赖:优先使用提供 ESM 格式的依赖包(lodash-es、dayjs 等)
  5. 静态分析友好:避免动态属性访问、namespace import 等模式
  6. 定期检查:使用 webpack-bundle-analyzer 等工具定期审查 bundle 体积
  7. 库作者责任:如果你发布 npm 包,请同时提供 ESM 格式并声明 sideEffects

Tree Shaking 不是银弹,但当你理解了它的原理并正确配置后,轻松减少 50% 以上的 bundle 体积是完全可以实现的。

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

相关推荐
小江的记录本3 小时前
【Java基础】反射与注解:核心原理、自定义注解、注解解析方式(附《思维导图》+《面试高频考点清单》)
java·数据结构·python·mysql·spring·面试·maven
前端流一4 小时前
踩坑实录:Vite打包AntD5报错 rc-picker/es/generate/dayjs 模块找不到
前端
_按键伤人_4 小时前
三、手把手教你从零写一个本地 RAG
前端·llm·ai编程
008爬虫实战录4 小时前
【码上爬】 题十二:如来神掌 困难, JSVMP加密,使用代理补环境
前端·javascript·node.js
008爬虫实战录4 小时前
【码上爬】 题十:魔改算法 堆栈分析,找加密值过程详解
前端·python·算法
无人装备硬件开发爱好者4 小时前
深度解析GPS天线设计:从贴片天线到LNA前端的完整硬件方案
前端
罗超驿4 小时前
19.告别复杂SQL!用MySQL视图把逻辑拆成“变量”式操作
数据库·mysql·面试
卷帘依旧4 小时前
React Hook采用环形链表的原因
前端
lichenyang4534 小时前
从 HarmonyOS AI 聊天模块理解工程化架构:MVVM、Controller、Provider、请求封装与 SSE
前端