普通视图

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

Vue创建一个简单的Agent聊天

2026年5月7日 11:11

创建项目

用的为vue3.6测试版本

npm create vue@latest

什么是Agent?

就是大语言模型+记忆+工具调用

  • 大语言模型:大脑,会思考、理解、规划、推理

  • 记忆:短期对话记忆 + 长期知识库,知道上下文、历史人设、过往任务

  • 工具调用:会联网搜索、查文件、写代码、调接口、操作插件

安装依赖

  npm install @langchain/core @langchain/langgraph @langchain/openai

@langchain/core

LangChain 核心底座

  • 提供统一接口:LLM、Prompt、Runnable、回调、工具
  • 所有 LangChain 生态都依赖它
  • 相当于底层操作系统

@langchain/openai

对接 OpenAI 大模型

  • 让你能调用 gpt-4o, gpt-3.5-turbo
  • 支持结构化输出、工具调用(Tool Calling)
  • 就是 Agent 的大脑

@langchain/langgraph

做 Agent / 智能体的框架

  • 管理:记忆、状态、工具调用、多步骤流程
  • 支持:循环思考、决策、多 Agent 协作
  • 你说的「LLM + 记忆 + 工具调用」= 全靠它实现

env全局变量

TITLE=聊天机器人

# 模型配置
VITE_API_KEY=你的ApiKey 
VITE_AI_MODEL=模型
VITE_AI_BASE_URL=https://api.deepseek.com/v1 # api地址,此处为deepseek

# api配置
# 高德地图配置 https://console.amap.com/dev/key/app # 高德地图api地址
VITE_AMAP_KEY=你的高德地图APIKEy(用于工具:ip地址查询,天气查询)

ts类型

type ROLE = 'user' | 'assistant' | 'system';

interface IChat {
  id: string,
  name: string,
  messages: string,
  role: ROLE,
  created_at: Date
}

export type Chat = IChat;

创建agent

import type { Chat } from "@/types/chat";
import { AIMessage, HumanMessage } from "@langchain/core/messages";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { RunnableSequence } from "@langchain/core/runnables";
import { ChatOpenAI } from "@langchain/openai";

function createLLM() {
  // 创建LLM
  const llm = new ChatOpenAI({
    model: import.meta.env.VITE_AI_MODEL,
    apiKey: import.meta.env.VITE_API_KEY,
    configuration: {
      baseURL: import.meta.env.VITE_AI_BASE_URL,
    },
    temperature: 0.7, // ai生成的内容丰富度
  })

  return llm
}

function createPrompt() {
  // 创建一个提示模板
  return ChatPromptTemplate.fromMessages([
    ["system", "你是善解人意的,且热爱着某人的智能女友"],// 创建个对象(提示词,prompt)
    ["placeholder", "{chat_history}"], // 聊天记录(记忆,memory)
    ["human", "{input}"], // 用户输入的问题
  ])
}

function createChain() {
  const llm = createLLM()
  const prompt = createPrompt()
  // 创建一个链,将prompt和llm联系起来
  return RunnableSequence.from([prompt, llm])
}


export default async function chat(
  input: string,
  onChat: (text: string) => void,
  messages: Chat[] = []) {

  // 历史记录
  const allMessages: Array<HumanMessage | AIMessage> = []

  // 遍历历史聊天记录
  for (const msg of messages) {
    if (msg.role === "user") {
      allMessages.push(new HumanMessage(msg.messages))
    } else if (msg.role === "assistant") {
      allMessages.push(new AIMessage(msg.messages))
    }
  }

  // 添加当前用户信息
  allMessages.push(new HumanMessage(input))

  const chain = createChain()
  const res = await chain.invoke({
    chat_history: allMessages,
    input: input,
  })

  onChat(String(res.content) || '')
}

页面组件

<template>
  <div class="p-4 flex justify-center items-center">
    <div class="flex gap-2 p-4 rounded-2xl shadow-md w-full max-w-200">
      <input v-model="value" class=" w-full outline-0" type="text" placeholder="发送消息吧( •̀ ω •́ )✧">
      <ElButton :disabled="isDisabled" type="primary" @click="onChatHandler">{{ btnText }}</ElButton>
    </div>
  </div>
</template>

<script setup lang="ts">
import chat from '@/agent';
import type { Chat } from '@/types/chat';
import { ElButton, ElMessage } from 'element-plus';
import { computed, reactive, ref } from 'vue';
const value = defineModel<string>(''); // vue3.6提供的新语法糖,不用再props和emit一个modelValue了
const loading = ref(false);
const history = reactive<Chat[]>([]);
const isDisabled = computed(() => !value.value && !loading.value); // 禁用按钮
const btnText = computed(() => loading.value ? '正在思考...' : '发送');

// 生成一个随机字符串
function randomId() {
  return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
}

const onChatHandler = async () => {
  if (!value.value) return ElMessage.error('请输入内容');
  loading.value = true;

  console.log("用户:" + value.value);
  console.log("====================================");

  history.push({
    id: randomId(),
    name: 'user',
    messages: value.value,
    role: "user",
    created_at: new Date(),
  });

  try {
    await chat(value.value, (text) => {
      console.log("ai:" + text);
      console.log("====================================");

      history.push({
        id: randomId(),
        name: 'assistant',
        messages: text,
        role: "assistant",
        created_at: new Date(),
      });
    }, history);
  } catch (error) {
    if (error instanceof Error)
      ElMessage.error('请求错误' + error.message);
  } finally {
    value.value = '';
    loading.value = false;
  }

};

</script>

<style lang="css" scoped></style>

经测试,控制台内容如下:

image.png

注意:

  • 诺未生效,可能是没有充值,我deepseek充值了10块钱都用了两个月了
  • apiKey一定要真实的,去模型提供商网站申请
  • 本示例中还使用了element plustailwind css

一行命令把 PSD 还原成 HTML / React / Vue:psd2code 实战干货

作者 miaowmiaow
2026年5月7日 11:01

设计稿来了,运营要求"明天上线"。 你打开 PSD,开始切图、量像素、写 CSS、对位置——半天过去了,还在调那个差 2px 的按钮。

这篇文章介绍我们自研的 psd2code 工具:一行命令把 PSD 转成可运行的前端项目,像素级还原 + 智能布局优化 + 多框架产物(HTML / React / Vue)


一、为什么不用现成的 PSD 转 HTML 工具?

社区里其实已经有不少 PSD 转代码方案,但落到运营活动 / H5 / 长图详情页这类「像素级还原」需求上,普遍有三个痛点:

痛点 现象
① 文字字号不准 PSD 里的 FontSize=17.5,浏览器渲染出来又小又模糊——因为忽略了图层 transform.scale
② 全是 position: absolute 几百个图层全部绝对定位,CSS 体积爆炸,后期完全不可维护
③ 组级效果丢失 圆角矩形 8px 外描边、文字描边+投影叠加,要么裁切要么糊掉

我们做了一组对比统计(实际 PSD:南瓜大作战 H5、总决赛-折叠 H5、兑奖 H5):

传统切图工作流:     设计稿到可运行 HTML 平均  4~6 小时
psd2code 自动转换: 设计稿到可运行 HTML 平均  20

由此得出 psd2code 的四大核心方向,也正是本文后续四大章节:

  • PSD 解析:借助 psd-tools,但对它的缺陷做深度修补;
  • 资源提取与优化:像素去重 + 智能命名 + 合成背景图;
  • 布局优化:聚类算法识别行列/网格、智能重写成 Flex;
  • 多 Target 可插拔:同一份 IR,一键产出 HTML / React / Vue 三种工程。

二、整体架构:编译器式分层

psd2code 借鉴编译器的「前端解析 + IR + 后端代码生成」三段式:

flowchart LR
    PSD[".psd<br/>设计稿"] --> Core["core/<br/>PSD 解析 + 图层渲染"]
    Core --> IR[("IR<br/>pydantic 校验<br/>的中间表示")]
    IR --> HTML["targets/html<br/>HTML + CSS"]
    IR --> React["targets/react<br/>Vite + React 18"]
    IR --> Vue["targets/vue<br/>Vite + Vue 3"]
    IR -.预留.-> MP["targets/mini-program"]

    style PSD fill:#f9e79f,stroke:#b9770e
    style IR fill:#aed6f1,stroke:#1f618d
    style HTML fill:#d5f5e3,stroke:#196f3d
    style React fill:#d5f5e3,stroke:#196f3d
    style Vue fill:#d5f5e3,stroke:#196f3d
    style MP fill:#f5f5f5,stroke:#999,stroke-dasharray: 5 5

核心抽象

  1. IR (Intermediate Representation):pydantic BaseModel 严格定义、自带校验。是 coretargets 之间的契约——任何 target 都从 IR 出发,不直接读 PSD
  2. PipelineContext:贯穿所有 Stage 的全局上下文,承载 PSD、IR、配置、产物路径、target 中间产物等。
  3. Stage:单一职责的处理步骤,输入/输出都是 PipelineContext。
  4. Target:一个产物对应一个 Target 子类,通过 @register("html") 注册到全局 registry。

这个分层带来一个直接好处:HTML target 每次能力升级,自动惠及 React / Vue target——因为后两者只是在 HTML 产物之上做二次加工。


三、Skill 使用方式

psd2code 同时也是一个 CodeBuddy Skill,对话里直接说"帮我把这个 PSD 转成 HTML"就会自动触发;也可以脱离 CodeBuddy 单独跑命令行。

3.1 在 CodeBuddy 中调用(推荐)

只要项目里有 .codebuddy/skills/psd2code/ 目录,触发词就能让 CodeBuddy 自动加载该 skill:

"帮我把 设计稿/南瓜大作战.psd 转成 HTML" "把这个 psd 转成 React 项目" "psd 转 vue" "设计稿转代码"

CodeBuddy 会自动选择合适的 target、定位 PSD 文件、执行 skill、把产物路径回报给你。

3.2 命令行直接运行

# 默认 target = html(同时产出 absolute 原版 + Flex 优化版)
python3 .codebuddy/skills/psd2code/psd_to_code.py /path/to/file.psd

# 显式指定 target
python3 .codebuddy/skills/psd2code/psd_to_code.py /path/to/file.psd --target html
python3 .codebuddy/skills/psd2code/psd_to_code.py /path/to/file.psd --target react
python3 .codebuddy/skills/psd2code/psd_to_code.py /path/to/file.psd --target vue

3.3 常用参数

参数 默认 说明
--target {html,react,vue} html 选择产物形态
--css-style {compact,expanded} compact 优化版 CSS 输出风格:compact 接近手写、expanded 全展开 + PSD 坐标溯源注释
--no-css-pretty 关闭 关闭 CSS 美化,回到字母序机械渲染(CI 基线对比常用)

举例:

# 想要排查某个元素位置不对,开 expanded 模式看坐标溯源注释
python3 .codebuddy/skills/psd2code/psd_to_code.py 南瓜大作战.psd --css-style expanded

# 跑 React 产物 + 启动 dev server
python3 .codebuddy/skills/psd2code/psd_to_code.py 南瓜大作战.psd --target react
cd output/南瓜大作战/react
npm install && npm run dev    # http://localhost:5173

# 跑 Vue 产物
python3 .codebuddy/skills/psd2code/psd_to_code.py 南瓜大作战.psd --target vue
cd output/南瓜大作战/vue
npm install && npm run dev    # http://localhost:5173

3.4 产物目录速查

output/<psd_stem>/
├── html/                        # 任何 target 都会先产出
│   ├── index.html               # 原始 absolute 版(保留 dev metadata,方便诊断)
│   ├── index_optimized.html     # Flex 优化版(已剥离 dev metadata,最终交付物)
│   ├── style.css / style_optimized.css
│   ├── main.js                  # 国际化等运行时逻辑
│   ├── metadata.json            # 图层树元数据
│   ├── layer_map.json           # 反查表:CSS 类名 → PSD 原图层名
│   ├── _naming_report.md        # 语义命名报告(每个 token 的来源)
│   └── images/                  # 切图 / 合成图 / 背景图
├── react/                       # --target react 时产出
└── vue/                         # --target vue 时产出

3.5 排查与定位三件套

跑完后如果发现某处不对,优先看这三个文件

  • _naming_report.md:CSS 类名为什么是这个?哪一层(layer1 词典 / layer2 角色推断 / fallback 拼音)给的?
  • layer_map.json.bg-main-4e8c1d 是 PSD 里的哪个图层?图层类型?
  • 对比 index.htmlindex_optimized.html:absolute 原版作为"地面真相",优化版有偏移基本就是 LayoutOptimizer 哪一步过激了。

3.6 系统依赖

Python 3.10+
psd-tools >= 1.14
Pillow >= 10
numpy
beautifulsoup4
pydantic >= 2.0
pypinyin

四、PSD 解析:踩过 psd-tools 的那些坑

psd-tools 是 Python 生态里最成熟的 PSD 解析库,但它只实现了最常用的渲染器,对一些常见场景(shape 填充 + 图层样式、超大发光溢出、引擎字典 transform 等)要么出错、要么丢失。我们的做法是:能修的源码级修,不能修的绕开走手动栅格化

3.1 文字 transform.scale 修正

PSD 文本图层的 engine_dict.StyleRun...FontSize原始字号,但 PS 实际渲染时会用 layer.transform = (a, b, c, d, e, f) 矩阵缩放——其中 a 是 X 缩放、d 是 Y 缩放。

font-size-fix.svg

真实例子:

PSD 图层:"消耗99兑换币"
  raw FontSize    = 17.5
  transform.scale = 1.6
  实际渲染字号     = 17.5 × 1.6 ≈ 28px

如果忽略 transform.scale,浏览器会用 17.5px 渲染,文字直接缩成花生米。另一个易踩的兄弟坑:ParagraphSheet 的路径不在 StyleRun 下,而是 engine.ParagraphRun.RunArray[0].ParagraphSheet.Properties 里的 Justification,老代码写错路径会导致所有文字永远左对齐。这两处我们在 core/psd/text_extractor.py 里重新解析。

3.2 浏览器字宽差异:纵向 + 横向双兜底

PSD 设计师常用思源黑体 / 造字工房,浏览器渲染时却用 PingFang / Arial——相同字号下,浏览器中文会比 PSD 更宽,导致:

  • 单行文本被挤成两行("预测四"+"强")
  • 按钮内文字撑出按钮边界

effect-comparison.svg

我们设计了双兜底:

# 纵向兜底:字号不超过 bbox 高度的 0.85
if font_size >= height * 0.85:
    font_size = height * 0.85

# 横向兜底(仅纯中文短标题):按字数 + 宽度倒推上限
if pure_cjk and char_count <= 12:
    max_font_by_width = width / cjk_count * 0.95
    font_size = min(font_size, max_font_by_width)

效果验证(总决赛-折叠 PSD):

文本 修正前 修正后 效果
主赛区(68px 宽) 25.5px 20.9px ✓ 单行
预测四强(164px 宽) 46.75px 38.5px ✓ 不再折行

3.3 Shape + 图层样式描边:psd-tools 的致命 bug

一个看似普通的 PSD shape 图层(#feffd7 浅黄填充 + 2px 内描边 #5f8618 绿色),psd-tools 直接 layer.composite() 会得到整片纯绿色——填充色完全丢失。

排查后发现两个叠加的根因:

  1. layer.topil() 对 shape 返回 None,老代码降级到 layer.composite() 取基础图;但 composite() 把整块 shape 区域错误地涂成描边色。
  2. draw_stroke_effectskimage.filters.scharr 检测 alpha 边缘做描边,当 alpha 紧贴画布无 padding 时,scharr 检测不到边缘,归一化后 mask 整片=1,描边色铺满整张图。

绕开方案

  • 新增 _render_shape_base_from_fill(layer):跳过 composite(),直接读 SoCo 填充色 + origination 几何(Rectangle / RoundedRectangle / Ellipse)用 PIL ImageDraw 合成基础图。
  • 调用 draw_stroke_effect 前给 alpha 加 padding(pad = max(stroke_size+2, 4)),渲染完再裁回。
# stroke_renderer.py
padded_alpha = np.pad(alpha, ((pad,pad),(pad,pad),(0,0)), mode='constant')
stroke_color, stroke_mask = draw_stroke_effect(bbox, padded_alpha, ...)
out[:,:,:3]  = stroke_color[pad:pad+h, pad:pad+w, :]
out[:,:,3:4] = stroke_mask[pad:pad+h, pad:pad+w, :] * opacity

效果:兑奖活动卡片的浅黄背景 + 2px 绿描边正确还原,无需人工补图。

3.4 图层样式的两层 enabled 开关

PS 图层样式面板有两个开关:

  • 整体开关layer.effects.enabled(fx 行最左边那个勾)
  • 单项开关effect.enabled(外发光/描边/投影各自的勾)

PS 的规则:整体开关关闭 → 所有效果都不渲染,哪怕单项 enabled=True

psd-tools 不替你 AND 这两个标志,psd2code 早期代码多处只看 effect.enabled,导致"PS 中没效果的文本"被当成"有外发光"处理,错误地栅格化成图片。修复后统一用一个 helper:

def is_effect_active(effect, layer):
    if not layer.effects or not layer.effects.enabled:
        return False
    return bool(effect.enabled)

全工程 8 处调用点全部切换。这解决了带 fx 残留的昵称文本全部被错误降级为图片的问题。

3.5 组级效果溢出:手动渲染 + composite 混合

PS 图层效果(描边、阴影、发光,共 8 种)在组(Group)级别有个隐蔽特性:效果会沿组的整体边界裁切

psd-toolscomposite() 能在组内正确复现这一行为——但只在组的 bbox 内有效。一个圆角矩形有 8px 外描边时,描边会溢出组的 bbox 被 composite() 裁掉。实测过各种"绕过"方法——父级 composite、根级 composite、给超大 viewport——都是徒劳:

psd-tools 在任何层级 composite,都按目标节点(及其所有祖先组)的 bbox 做硬裁切,不存在绕过方案

我们的解法是「手动栅格化 + composite 混合」:

hybrid-render.png

1. 先用扩展画布 + 手动逐层渲染 → 拿到完整溢出像素(外部区域)
2. 再用 group.composite(viewport=bbox) → 拿到组 bbox 内的 PS 原生高质量
3. 把 composite 结果覆盖到手动渲染结果的内部区域

最终:外部保留溢出效果,内部达到像素级匹配(实测 Alpha 差异 max=0、mean=0.00)。

硬约束:子组(嵌套组)必须用 sub_grp.composite(viewport=grp_bbox) 渲染,不能退回"递归调用 _render_group_expanded + 裁切"——历史回归实测会在圆角轮廓位置多出 ~75px/行的错误描边。

3.6 总结:PSD 解析的修复清单

问题 表现 我们的做法
transform.scale 未应用 文本被缩成花生米 layer.transform[0],FontSize 乘以它
ParagraphSheet 路径错误 所有文字都是左对齐 ParagraphRun.RunArray[0] 路径重新解析
shape + 图层样式整片涂描边色 兑奖卡片全变绿 手动栅格化填充 + 给 alpha 加 padding 再描边
两层 enabled 未 AND 无效果文本被错误降级为图片 统一 is_effect_active(effect, layer)
组级效果溢出被裁 外描边断掉 混合渲染:手动扩展 + composite 覆盖

五、资源提取与优化

psd2code 对每个图层做一次决策:切图 / 文字保留 / shape 用 CSS 还原 / 吸收为父容器背景 / 合并成单图,最终写到 output/<psd>/html/images/ 目录。

5.1 智能去重:基于内容哈希而非图层名

活动页里"星星""糖果""装饰点"这类小图会被设计师复用几十次,每次都单独切图是极大浪费。

def _save_image_dedup(self, img, name, depth) -> str:
    data = serialize(img)                        # 按 Config.IMAGE_FORMAT 序列化
    md5_hash = hashlib.md5(data).hexdigest()
    if md5_hash in self._image_hash_map:         # 命中去重
        return self._image_hash_map[md5_hash]
    path = make_image_filename(name, content_hash=md5_hash, ltype=ltype)
    self._image_hash_map[md5_hash] = path
    write(path, data)
    return path

南瓜大作战 H5 实测:239 个 image 图层 → 87 张 PNG(去重率 63.6%)。

5.2 语义化文件名:tag + 内容哈希

旧方案拼音 + 自增序号(yuanjiaojuxing_3_7.png)有两个问题:HTML 里不可读;每次运行序号都在跳,git diff 噪声极大。

新方案:

images/<semantic-tag>-<md5前6位>.png

例:rounded-a3f012.png
    btn-receive-279914.png
    bg-main-4e8c1d.png
    candy-big-7b0a12.png
  • semantic-tagsemantic/ 子包从图层名推断得出(支持 3 层置信度:Layer2 角色推断 → Layer1 清洗词典 → fallback 关键词 + 拼音),PS 默认名走 ltype 兜底(img/shape
  • md5前6位 = 图片内容哈希——PSD 没改,文件名就不变,git diff 和 CDN 缓存两全其美
  • 同名撞车自动追加 -2/-3

5.3 形状层保留矢量:不切图就是最好的优化

圆角矩形、椭圆、纯色矩形这类简单 shape,不切图而是直接翻译成 CSS 几何属性

PSD shape 输出 CSS
Rectangle + SoCo 填充 background: <color>; width/height
RoundedRectangle border-radius: <r>px
Ellipse border-radius: 50%
shape + 图层样式描边 border: <w>px solid <color>

效果:CSS 体积下降的同时,文件也能 retina 无损缩放。

5.4 多张全屏背景的合成

活动页常见模式:组里有 2~3 张全屏背景叠加(渐变底 + 花纹 + 噪点)。如果每张都单独切图,HTML 里会写多 url 背景:

.bg-section {
  background-image: url(bg-gradient.png), url(bg-pattern.png), url(bg-noise.png);
  background-position: 0 0, 0 0, 0 0;
  background-repeat: no-repeat, no-repeat, no-repeat;
}

问题:多张 PNG 多次请求,而且浏览器要多次合成。psd2code 的做法是在布局优化阶段检测这种多 url 模式,用 PIL alpha_composite 合成单张 PNG 写回磁盘

输入:bg-gradient.png(284 KB) + bg-pattern.png(412 KB) + bg-noise.png(67 KB)
输出:flat-af0dce35.png (153 KB)   # 1/5 体积、1 次请求

南瓜大作战 H5 实测:47 组合并、节省 45.6 KB。

一个与 CSS 规范相反的坑:background-image: url(a), url(b) 中第一个 url 在视觉最上层,而 PIL alpha_composite 期望"底层在前"。调用方必须 reverse 列表——早期代码漏掉 reverse 导致所有合成图的颜色上下层叠错,颜色"对调"。

5.5 图层扁平化:子图合并 + 父容器吸收

更激进的优化:当一个容器里只有纯 image 子图层(无文本、无按钮),把容器自身的 background + 所有 image 子按 z 序合成单张 PNG、删掉所有子 div 及其 CSS 规则、只留容器自己的 background-image

这个 ImageLayerFlatten transformer 采用后序遍历 + 多轮扫描:最深层先合并、外层再发现"我的子变成单 div 了"继续合并。护栏非常严格:

  • 子元素必须全部 data-type="image" 且无孙子
  • opacity≈1 / mix-blend-mode: normal
  • 容器本身不能有 border-radius / box-shadow / clip-path / filter / transform 等"不能烧进 PNG 的装饰字段"(一旦合并后再叠这些,会双重作用)
  • 总层数 ≥ 2,几何包围盒 ≤ 画布 50%(否则合并一张巨图反而得不偿失)

南瓜大作战 H5 实测:这一步搞定了 47 个容器的视觉简化,DOM 节点从 ~500 降到 ~280。


六、布局优化(本工具最核心的功能)

直接用 absolute 还原 PSD 没问题,但 200+ 图层全部 position: absolute 是工程灾难。psd2codeLayoutOptimizer 把 absolute 智能重写成 Flex,同时保证视觉零偏移。

flex-before-after.png

6.1 七步流水线全景

flowchart TD
    A["原始 absolute HTML"] --> B["Step 1:DOM 重构<br/>(聚类 / 背景剥离 / 容器吸收)"]
    B --> C["Step 1.2:图层扁平化<br/>(多 image 子 → 单张合成 PNG)"]
    C --> D["Step 1.5:同质兄弟分组<br/>(识别 v-list,支持 v-for)"]
    D --> E["Step 2:Flex 推断<br/>(analyzer V10 + 三道闸门)"]
    E --> F["Step 2.5:单子 wrapper 折叠<br/>(消除中间层)"]
    F --> G["Step 3:CSS 去冗余<br/>(z-index 精简 + 等价规则合并)"]
    G --> G2["Step 3.5:重复元素抽取<br/>(3+ hash 类 → 单 base 类)"]
    G2 --> H["Step 4:CSS 美化<br/>(DOM 序 + 属性分段 + 多行)"]
    H --> I["✅ 优化后 HTML / CSS"]

    style A fill:#fadbd8,stroke:#c0392b
    style I fill:#d5f5e3,stroke:#196f3d
    style B fill:#fcf3cf,stroke:#b7950b
    style E fill:#fcf3cf,stroke:#b7950b
    style G fill:#fcf3cf,stroke:#b7950b

6.2 聚类算法:怎么"看懂"一堆 absolute 框

这是整个 LayoutOptimizer 的灵魂。对任意一个容器,我们有 N 个子图层的 bbox(left/top/width/height),目标是自动把它们组织成**行(row)/ 列(col)/ 叠图组(stack)**的树状结构。

第一步:切行(_split_by_rows

从左到右、从上到下遍历子元素,维护一个"当前行"的 envelope(bbox 包络)。新来一个元素 e,判断它和 envelope 的纵向重叠率

overlap_y = min(e.bottom, env.bottom) - max(e.top, env.top)
ratio     = overlap_y / min(e.height, env.height)

if ratio >= 0.5:  # 同行判据
    env 吸收 e,继续
else:
    新开一行

第二步:行内切列

对每一行内部,把切行逻辑换成纵/横轴就是切列。递归后我们得到一棵"行包列 / 列包行"的嵌套树。

第三步:背景层剥离

一个组里常有"全屏卡片底框 + 多个浮层元素"的设计模式。直接聚类会把底框当成一个"占 100% 空间的大元素",严重干扰行/列判断。我们在聚类前先剥离满足以下三种规则之一的"背景层":

  • 完全包含型:bbox 完全包住其他所有元素
  • 主轴覆盖型:在主轴(宽或高)上覆盖 ≥ 90%
  • 双轴主导覆盖型:宽、高都覆盖 envelope ≥ 80%(识别"略带 padding 的卡片底图")

剥离后的背景层被吸收进父容器的 background-image 列表。

第四步:伪多行装饰堆叠回退

切出多行后,若所有行都只有一个元素、且相邻行横向覆盖率 ≥ 80%,回退为 stack(堆叠)——这是"图标 + 标题上下贴边"这种"本质上堆叠装饰"的场景。

第五步:二维网格识别

当多行 × 多列的元素满足"列对齐 + 跨行对齐"时,单纯用"列 包 行"嵌套表达不够干净,改成显式的 v-grid-row + flex column 结构:

rows = _split_into_rows(...)
if len(rows) >= 2 and all_rows_have_aligned_cols(rows):
    layout_type = 'grid'
    flex_applier 包装为:
      父: display:flex; flex-direction:column
      每行: <div class="grid-row-N v-grid-row">

南瓜大作战 H5 的"用户信息区"9 个子图层(剥掉背景卡 + 头像装饰后剩 7 个文本),被正确识别为 2 行 × [4, 3] 列 grid。

6.3 三道安全闸门:什么时候不该用 Flex

不是所有看起来"整齐"的容器都该用 Flex。我们踩过太多坑后总结出三道闸门(全在 layout_analyzer.py):

互相重叠的装饰簇

n 个图层互相重叠(每个与多个邻居都重叠),且 trend_ratio < 0.6。典型场景:多层装饰贴纸、若干徽章叠在一起。判定为堆叠装饰,保持 absolute。

支配背景层

存在某个子元素 X 满足 X.area / envelope.area >= 0.8,且其余子元素中 ≥ 60% 显著落在 X 内(重叠/自身面积 ≥ 0.6)。判定为"大底图 + 多个浮层"的卡片,整组保持 absolute。

装饰剥离

先把子节点分类为 bg / decor / content 三类,只在 content 子集上做趋势检测。这让"内容整齐成行 + 角落有装饰"的容器不再因为装饰打乱排版被误判。

6.4 Flex 应用:非趋势子元素保留 absolute

识别为 vertical / horizontal 后,我们把趋势元素写成 flex 子项(用 margin 表达间距),非趋势元素(角标、装饰)保留其 position: absolute 坐标:

/* 趋势元素:flex 流 */
.prop-card-1 { margin-top: 20px; margin-left: 0; }
.prop-card-2 { margin-top: 18px; }

/* 非趋势元素:保留 absolute */
.badge { position: absolute; right: -6px; top: -6px; }

这里有个极易反复重犯的 bug:容器重构后,子元素的 top/left 是"相对父容器"的坐标(由 extract 阶段产出),不需要再减父 top。

还有一条来自 v-stack 的保护:flex_applier 默认会 del child_css['position'] 把子元素的定位去掉;但如果子本身是 v-stack wrapper(内含 absolute 子节点),删除 position 会让其孩子跳到外层定位,直接飘到屏幕角落。修复:遇到 'v-stack' in child.classes 就改成 position: relative,而不是删除。

6.5 同质兄弟簇检测:识别"同类卡片"

PSD 设计师经常把 N 个商品卡 / 道具卡 / 礼包卡平铺在 #canvas 直接子,没有用父组包起来。传统聚类只在已有 group 内部做,这种列表会全部走 absolute 路径,开发拿到的 HTML 完全看不出"它是一个数据列表",没法直接写 v-for

SiblingGroupDetector 的 5 条 AND 规则:

  1. ≥ 3 个连续兄弟
  2. class 词根相同(去掉 __\d+ 后缀和 -\d+ 序号)——prop__30 / prop-2__38 / prop-10__101 词根都是 prop这是最强的设计师意图信号
  3. bbox 尺寸近似(误差 ≤ 5%)
  4. 满足网格规则:M 列 × K 行 满格排布,同列 left 一致、同行 top 一致(误差 ≤ 2px)
  5. 父容器本身不是 flex

识别成功后包成虚拟容器:

<div class="prop-list v-list" data-virtual="list">
  <div class="prop__30 layer-group">...</div>
  <div class="prop-2__38 layer-group">...</div>
  <div class="prop-3__45 layer-group">...</div>
</div>

CSS 用 display: flex; flex-wrap: wrap; column-gap / row-gap,下游开发可直接写 v-for

一个设计决定:我们不做子结构同构判定。实际 PSD 里同类卡几乎总是有差异(首张卡设计完复制改文案,结构漂移:少一行文字、按钮换成图片、装饰数量不一致)。强求子结构一致会绝大多数现实场景识别失败——class 词根 + bbox 尺寸两条已经够强。

6.6 CSS 去冗余:z-index 精简 + 等价规则合并

core/extract/layer_exporter.py 给每个图层无脑塞 z-index = 全局 layer_id——这是合理的像素还原默认值,但优化版完全不需要。CssDedup 分两个 Pass:

Pass 1 — z-index 精简

遍历每个父容器,收集子元素的 (selector, z) 序列:

形态 动作
长度 0 跳过
长度 1(独 z,其他全 None) 删该 z-index
长度 ≥ 2 严格递增 全删(DOM 顺序 = z 序)
长度 ≥ 2 出现倒挂 全保留

逻辑:position:absolute 子元素的叠序只在"兄弟 bbox 重叠"时依赖 z-index;绝大多数父容器下"DOM 顺序 = z 序升序"(这是 LayerRenderer 的天然产出),浏览器默认行为就能正确实现叠序。

Pass 2 — 等价规则合并

属性 dict 完全相等的多个选择器合并为 .a, .b, .c { ... } 单条规则。南瓜大作战 H5 实测合并 209 条。

Pass 3.5 — 重复元素抽取

Pass 2 合并了 CSS,但 HTML 里依然写了 N 个不同的 hash 类.prop__68 / .prop__105 / ...)。RepeatClassUnifier 进一步:≥ 3 个 .<base>__<digits> 形式的等价类 → 合并为单一 base 类(.prop),HTML 同步改写。

最终 HTML 里你看到的就是:

<div class="prop-list v-list">
  <div class="prop layer-group">...</div>
  <div class="prop layer-group">...</div>
  <div class="prop layer-group">...</div>
</div>

直接就是这种干净的语义化结构。

6.7 实战效果(南瓜大作战 H5)

指标 V2 优化器 当前版本
元素位置偏移 PSD 原位置 94 个元素偏离 5~13px 0 个元素偏离
CSS 行数 4805 1499
CSS 块数 457 ~270
z-index 字段 432 97
6×4 任务网格识别 每个 cell 独立 absolute 自动识别 v-col + v-row 嵌套

下面这张是真实产物里"任务格子"那段——20 多个图层、4 行多列、每个 cell 带描边小图标,全部由算法自动识别:

pumpkin-grid.png

6.8 算法的天花板与人工边界

再好的算法也有上限——下面这些场景 psd2code 会"尽力而为,但结果不一定最优":

① Flex 布局化不充分:设计师图层组织混乱

典型问题:活动页版块 2 的按钮、图标、装饰全部散乱摆在同一个 PSD 根组,没有任何分组——聚类算法能看到的只是 bbox 位置,看不到"设计意图"

👉 解决方案:整理 PSD 图层结构。按视觉版块分组(版块1-签到 / 版块2-道具 / 版块3-任务),每个版块内部再按"标题 / 卡片列表 / 底部按钮"分组。psd2code 会优先在已有组内部做聚类,组边界 = 聚类边界。分好组之后,95% 的场景都能自动重构为干净的 Flex。

② CSS 不够语义化:图层名用了默认命名

典型问题:PSD 图层名是 矢量智能对象图层 12 拷贝 3形状 47——psd2code 只能给你 .img-a3f012 这种内容哈希名,无从推断语义。

👉 解决方案:整理图层命名。重要的结构性图层给中文或 kebab-case 命名(bg-main / btn-领取 / 用户信息背景 / 任务卡片)。psd2code 的 semantic/ 子包能识别:

  • 按钮语义:btn / 按钮 / 领取 / 确定.btn-receive / .btn-ok
  • 背景语义:bg / 背景 / 底框.bg-main
  • 卡片容器:prop / card / 道具.prop-card
  • 中文关键词:通过 common/cn_dict.json 词典映射到 kebab-token

命名整洁之后,HTML 就会是 .prop-card / .btn-receive / .user-info-bg 这种一眼看懂的语义类,而不是 hash 串。

③ 人工干预:特殊场景需人工调整

psd2code 只实现常用渲染器所以部分图层导出效果不好(全实现产出比太低),需要人工干预。

👉 解决方案:手动栅格化或导出图片


七、实战演练:把"南瓜大作战 H5"PSD 跑一遍

南瓜大作战 H5(750 × 6778 长图活动页)为例,一行命令 20 秒拿到完整可运行 HTML:

$ python3 psd_to_code.py "南瓜大作战 H5.psd" --target html

🎨 合并背景图层: ['背景', '矩形 1', '形状 839 拷贝 2']
🖼️  background [合并3层 750x6778] → images/background-f07984.png
📁 solgan (组)
  ✨ 形状 16 (含效果渲染)
  🌟 检测到效果溢出 6px,使用混合渲染策略
📁 版块1 (组) ...
🎨 开始布局优化...
✅ 优化完成!
   - DOM 重构: 60 个
   - v-list 创建: 3 个 (包裹 24 个节点)
   - 应用 flex: 28 个
   - z-index 精简: 304 处
   - CSS 等价规则合并: 节省 128 条
   - 重复元素抽取: 25 组 → 删除 49 个 hash 类、复用到 61 个元素
   - 图层扁平化: 47 个容器 (共合并 105 层, 节省 45.6 KB)
✅ 产物:output/南瓜大作战 H5/html/

浏览器打开 index.html 第一屏——和 PSD 设计稿完全像素对齐,包括 solgan 上的描边发光、用户信息区的圆角、糖果图标的渐变叠加:

pumpkin-hero.png

absolute 原版 vs Flex 优化版对比

pumpkin-compare.png

文件 HTML 大小 CSS 大小 定位方式 可维护性
index.html 71 KB 113 KB 全部 position: absolute ⭐⭐
index_optimized.html 52 KB 38 KB Flex 嵌套 + 局部 absolute ⭐⭐⭐⭐

不要小看这 75 KB 的 CSS 压缩——它代表着 60 个容器被语义化、25 组 hash 类被复用,后期改样式不再需要逐个调 top/left

整个活动页 6778px 长,包含 9 大版块(用户信息 / 任务区 / 道具 / 助力 / 排行 等):

pumpkin-full.png

转换日志里有几个有意思的点:

  • 组级效果溢出自动触发 3 次:solgan 日期组(6px)、副标题组(10px)、糖果数目组(4px)——全部走"手动栅格化 + composite 混合"。
  • 47 个容器被图层扁平化:原本 105 张 image 合并成 47 张 PNG,节省 45.6 KB。
  • 3 组同质兄弟列表识别:道具卡 × 6、任务卡 × 12、排行榜条目 × 6,被包成 v-list——可直接写 v-for
  • 叠图组识别:邀请助力 / 核销助力码 / 版块3(7 个图层)等被 V8/V9 闸门正确识别为"装饰堆叠",保持 absolute。

八、多 Target 可插拔架构

8.1 Target Registry:装饰器注册

targets/registry.py 非常简单:

_REGISTRY: dict[str, Type[Target]] = {}

def register(name: str):
    def _wrap(cls: Type[Target]) -> Type[Target]:
        key = name.strip().lower()
        if key in _REGISTRY and _REGISTRY[key] is not cls:
            raise ValueError(f"Target '{key}' already registered")
        cls.name = key
        _REGISTRY[key] = cls
        return cls
    return _wrap

每个 target 是 Target 子类,实现 build_pipeline(ctx) -> Pipeline

# targets/html/target.py
@register("html")
class HtmlTarget(Target):
    def build_pipeline(self, ctx):
        return Pipeline([
            LoadPsdStage(),
            ParseStage(),
            ExtractAssetsStage(),
            CodegenStage(),
            LayoutOptimizeStage(),
            EmitStage(),
        ])

# targets/react/target.py
@register("react")
class ReactTarget(Target):
    def build_pipeline(self, ctx):
        return Pipeline([
            HtmlTarget().build_pipeline(ctx),   # 先产出 HTML(含优化版)
            Html2ReactStage(),                  # 再转 JSX + Vite 脚手架
        ])

# targets/vue/target.py 同理

CLI --target vueget("vue") → 实例化 → target.run(ctx)。新增 target(比如小程序)只需:

@register("mini-program")
class MiniProgramTarget(Target):
    def build_pipeline(self, ctx):
        return Pipeline([
            HtmlTarget().build_pipeline(ctx),
            Html2WxmlStage(),        # 转 WXML
            Html2WxssStage(),        # 转 WXSS
        ])

无需改动核心代码。

8.2 为什么 React / Vue 都在 HTML 基础上二次加工

业界也有"直接从 IR 生成 JSX"的设计,但 psd2code 选择"先走一遍 HTML target,再转框架":

  • HTML target 的优化(布局、CSS 去冗余、语义化命名)免费继承给 React/Vue——任何一次 LayoutOptimizer 升级自动惠及三端。
  • 开发者本地 review 时可以直接对比 html/index_optimized.htmlreact/src/App.jsx 的视觉一致性,容易定位转换问题。
  • React/Vue 的转换就是 DOM 遍历 + class/style 重映射 + 模板语法替换,逻辑简单、可测试性强。

8.3 产物结构一览

output/<psd_stem>/
├── html/                       # 任何 target 都会先产出
│   ├── index.html              # absolute 版(与 PSD 像素级对齐)
│   ├── index_optimized.html    # Flex 优化版
│   ├── style.css / style_optimized.css
│   ├── main.js                 # 国际化等运行时逻辑
│   ├── metadata.json           # 图层树元数据
│   ├── class_alias_map.json    # 老 hash 类 → 新语义类的映射
│   └── images/                 # 切图 / 合成图 / 背景图
├── react/                      # --target react 产出
│   ├── package.json / vite.config.js
│   └── src/App.jsx, App.css, main.jsx, assets/images/
└── vue/                        # --target vue 产出
    ├── package.json / vite.config.js
    └── src/App.vue, main.js, assets/images/

React / Vue 产物开箱即用:

cd output/<psd_stem>/react   # 或 vue
npm install && npm run dev   # http://localhost:5173

九、其他你可能在意的细节

  • 图片去重:按内容 MD5,同一张装饰图只导出一次。
  • 语义化类名:图层名 预测四强.yucesi__152(拼音兜底),或通过 cn_dict.json 词典映射为 .predict-top4
  • 国际化预留:所有文本节点自动带 data-i18n-key,可通过 JS 动态替换。
  • 旋转/倾斜文本:自动降级为图片,保证视觉一致。
  • 剪贴蒙版:按 layer.clip 标志识别,合并成父图基底 + 描边/发光效果。

十、踩过的坑(写给后来者)

如果你打算自己实现 PSD → 代码工具,以下几个坑可以省你几天:

  1. transform.scale 不能忘——所有 FontSize 都要乘以 transform.a / transform.d
  2. shape + 图层样式描边 psd-tools 会整片涂描边色——必须手动用 SoCo + origination 合成基础图、给 alpha 加 padding 再描边。
  3. 两层 enabled 开关必须 AND——layer.effects.enabled(整体)和 effect.enabled(单项)都要为 True 才算生效。
  4. composite() 的 viewport 限制——任何层级的 composite 都按"目标节点 + 所有祖先"的 bbox 硬裁切,不存在绕过方案,组级溢出效果必须手动扩展画布。
  5. 子组必须用 composite 渲染——不要退回手动递归,会在圆角处多出 ~75px/行的错误描边。
  6. tree.children 顺序 ≠ z 序——背景剥离后再合并 background-image 时,必须按原 DOM sibling index 重排。
  7. 多 url 背景合成时 reverse 列表——CSS 第一个 url 是视觉最上层,但 PIL alpha_composite 期望底层在前,反了会颜色对调。
  8. CSS parser 别用贪婪正则——@media (...) { #canvas { ... } } 嵌套时,简单正则会把内层 #canvas 误当顶层规则,整个 canvas 塌成 0 高。
  9. flex 容器 envelope 越界envelope.left/top 可能为负(图层超出组 bbox),写 padding 时要 max(0, ...),否则 cross_offset 算多了。
  10. v-stack wrapper 的 position 必须保留——flex_applier 默认 del child_css['position'],遇到 v-stack 要改写为 relative,否则内部 absolute 子元素会跳到外层定位。
  11. background-repeat: no-repeat 不是默认值——background-repeat 的 CSS 默认值是 repeat,CssDedup 删默认值时不能把它加进去,否则大背景图会被平铺。

十一、写在最后

psd2code 不是一个"AI 读图猜布局"的玩具——它是一个严格基于 PSD 结构信息的编译器。每一步决策都可解释、可调参、可单测,算法失败点(比如 V8/V9/V10 闸门)都有明确的 fallback 路径。

再强调一次:算法做的再多效果也是有限的。想要 psd2code 产出高质量代码,有两件事你得做:

  1. 整理 PSD 图层结构(按视觉版块分组)
  2. 整理 PSD 图层命名(关键图层给语义名)

做到这两点,运营活动页从设计稿到可上线代码的时间可以从 4~6 小时降到 20 秒。

未来计划:

  • ✅ HTML / React / Vue 已上线
  • 🚧 小程序 target(架构已预留扩展点)
  • 🚧 Tailwind CSS 输出
  • 🚧 Figma 文件支持(共享 IR,新增 figma loader)

如果你也在做活动页 / 长图详情页 / 运营 H5,欢迎试用并提反馈。


Thanks

以上就是本篇文章的全部内容,如有问题欢迎指出,我们一起进步。
如果觉得本篇文章对您有帮助的话请点个赞让更多人看到吧,您的鼓励是我前进的动力。
谢谢~~

源代码地址

WebView 性能优化实战:从首屏1.5秒到300毫秒

2026年5月7日 10:33

WebView 性能优化实战:从首屏1.5秒到300毫秒

做移动端H5开发,性能是永恒的话题。本文分享我在实战中总结的WebView优化方案

前言

"页面加载太慢了,用户都流失了"

这是很多移动端H5开发者面临的痛点。WebView的性能直接影响用户体验,但优化起来往往无从下手。

本文从实战角度出发,分享我从首屏1.5秒优化到300毫秒的经验。


一、性能指标定义

1.1 核心指标

指标 说明 目标值
白屏时间 从发起请求到首屏可见 < 500ms
首屏时间 从发起请求到首屏内容渲染完成 < 1000ms
可交互时间 页面可以响应用户操作 < 1500ms
完全加载时间 所有资源加载完成 < 3000ms

1.2 测量方法

使用 Performance API

// 白屏时间
const whiteScreenTime = performance.timing.domLoading - performance.timing.navigationStart;

// 首屏时间
const firstPaintTime = performance.timing.domContentLoadedEventEnd - performance.timing.navigationStart;

// 完全加载时间
const loadTime = performance.timing.loadEventEnd - performance.timing.navigationStart;

使用 PerformanceObserver(推荐)

// 观察首屏渲染
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('FP:', entry.startTime); // First Paint
  }
});
observer.observe({ type: 'paint', buffered: true });

// 观察 LCP(Largest Contentful Paint)
const lcpObserver = new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const lastEntry = entries[entries.length - 1];
  console.log('LCP:', lastEntry.startTime);
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });

二、WebView 初始化优化

2.1 WebView 预加载

问题:WebView 初始化需要时间,首次打开页面会有延迟。

方案:在 Application 启动时预热 WebView。

Android 实现

public class MyApp extends Application {
    @Override
    public void onCreate() {
        super.onCreate();
        
        // 在主线程预热 WebView
        WebView webView = new WebView(this);
        webView.destroy();
    }
}

iOS 实现

// 在 AppDelegate 中预热 WKWebView
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    
    // 预热 WKWebView
    let config = WKWebViewConfiguration()
    let webView = WKWebView(frame: .zero, configuration: config)
    
    return true
}

优化效果:首次打开 WebView 时间减少 200-300ms。

2.2 WebView 复用池

public class WebViewPool {
    private static final int MAX_POOL_SIZE = 2;
    private static final Queue<WebView> pool = new LinkedList<>();
    
    public static WebView obtain(Context context) {
        WebView webView = pool.poll();
        if (webView == null) {
            webView = new WebView(context);
        }
        return webView;
    }
    
    public static void recycle(WebView webView) {
        if (pool.size() < MAX_POOL_SIZE) {
            webView.loadUrl("about:blank");
            webView.clearCache(true);
            pool.offer(webView);
        } else {
            webView.destroy();
        }
    }
}

三、网络请求优化

3.1 DNS 预解析

<head>
  <!-- DNS 预解析 -->
  <link rel="dns-prefetch" href="//cdn.example.com">
  <link rel="dns-prefetch" href="//api.example.com">
</head>

3.2 预连接

<head>
  <!-- 预建立连接 -->
  <link rel="preconnect" href="https://cdn.example.com">
  <link rel="preconnect" href="https://api.example.com" crossorigin>
</head>

3.3 资源预加载

<head>
  <!-- 预加载关键资源 -->
  <link rel="preload" href="/css/main.css" as="style">
  <link rel="preload" href="/js/main.js" as="script">
  <link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
</head>

3.4 HTTP 缓存策略

静态资源:强缓存 + 版本号

# nginx 配置
location ~* \.(js|css|png|jpg|gif|woff2)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

HTML 页面:协商缓存

location ~* \.html$ {
    etag on;
    if_modified_since exact;
    add_header Cache-Control "no-cache";
}

四、WebView 缓存优化

4.1 离线缓存方案

方案一:Application Cache(已废弃)

不推荐,现代浏览器已移除支持。

方案二:Service Worker(推荐)

// 注册 Service Worker
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
    .then(registration => console.log('SW registered'))
    .catch(error => console.log('SW registration failed'));
}

// sw.js
const CACHE_NAME = 'v1';
const CACHE_URLS = [
  '/',
  '/css/main.css',
  '/js/main.js'
];

// 安装时缓存
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME).then(cache => cache.addAll(CACHE_URLS))
  );
});

// 拦截请求
self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request).then(response => {
      return response || fetch(event.request);
    })
  );
});

4.2 Android WebView 缓存配置

WebSettings settings = webView.getSettings();

// 开启 DOM Storage
settings.setDomStorageEnabled(true);

// 开启数据库缓存
settings.setDatabaseEnabled(true);

// 设置缓存模式
settings.setCacheMode(WebSettings.LOAD_DEFAULT);

// 设置缓存路径
String cachePath = getCacheDir().getAbsolutePath();
settings.setAppCachePath(cachePath);
settings.setAppCacheEnabled(true);

4.3 iOS WKWebView 缓存配置

let config = WKWebViewConfiguration()

// 配置缓存
let websiteDataStore = WKWebsiteDataStore.nonPersistent()
config.websiteDataStore = websiteDataStore

// 或者使用默认缓存
config.websiteDataStore = .default()

五、渲染优化

5.1 阻塞渲染的资源处理

CSS 放头部,JS 放底部

<head>
  <link rel="stylesheet" href="/css/main.css">
</head>
<body>
  <!-- 内容 -->
  <script src="/js/main.js"></script>
</body>

JS 异步加载

<!-- 异步加载,不阻塞解析 -->
<script async src="/js/analytics.js"></script>

<!-- 延迟加载,解析完成后执行 -->
<script defer src="/js/main.js"></script>

5.2 关键渲染路径优化

内联关键 CSS

<head>
  <style>
    /* 内联首屏关键 CSS */
    body { margin: 0; font-family: sans-serif; }
    .header { height: 50px; background: #fff; }
    .content { padding: 20px; }
  </style>
  <link rel="preload" href="/css/main.css" as="style" onload="this.rel='stylesheet'">
</head>

5.3 图片优化

懒加载

<img src="placeholder.jpg" data-src="real-image.jpg" loading="lazy">
// Intersection Observer 懒加载
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src;
      observer.unobserve(img);
    }
  });
});

document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));

响应式图片

<picture>
  <source srcset="image-small.webp" media="(max-width: 600px)" type="image/webp">
  <source srcset="image-large.webp" media="(min-width: 601px)" type="image/webp">
  <img src="image.jpg" alt="fallback">
</picture>

六、JavaScript 优化

6.1 代码分割

// 动态导入
const module = await import('./heavy-module.js');

// React 懒加载
const LazyComponent = React.lazy(() => import('./HeavyComponent'));

6.2 长任务优化

// 使用 requestIdleCallback
requestIdleCallback(() => {
  // 执行低优先级任务
});

// 使用 Web Worker
const worker = new Worker('worker.js');
worker.postMessage({ type: 'heavy-computation', data: largeData });
worker.onmessage = (e) => {
  console.log('Result:', e.data);
};

6.3 事件处理优化

// 节流
function throttle(fn, delay) {
  let last = 0;
  return function(...args) {
    const now = Date.now();
    if (now - last >= delay) {
      fn.apply(this, args);
      last = now;
    }
  };
}

// 防抖
function debounce(fn, delay) {
  let timer = null;
  return function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

// 应用
window.addEventListener('scroll', throttle(handleScroll, 100));
input.addEventListener('input', debounce(handleInput, 300));

七、WebView 与原生交互优化

7.1 减少通信次数

问题:每次 JSBridge 调用都有开销。

方案:批量传输数据。

// 差:多次调用
const userInfo = await bridge.call('getUserInfo');
const deviceId = await bridge.call('getDeviceId');
const appVersion = await bridge.call('getAppVersion');

// 好:一次调用
const data = await bridge.call('getInitData');
// data = { userInfo, deviceId, appVersion }

7.2 使用消息队列

// 消息队列
const messageQueue = [];

function flushQueue() {
  if (messageQueue.length === 0) return;
  
  const messages = [...messageQueue];
  messageQueue.length = 0;
  
  bridge.call('batchMessages', messages);
}

function enqueueMessage(msg) {
  messageQueue.push(msg);
  requestAnimationFrame(flushQueue);
}

八、内存优化

8.1 Android WebView 内存泄漏

问题:WebView 持有 Activity 引用导致内存泄漏。

方案:使用独立进程 + 动态销毁。

// AndroidManifest.xml
<activity
    android:name=".WebViewActivity"
    android:process=":webview" />

// Activity 销毁时
@Override
protected void onDestroy() {
    if (webView != null) {
        webView.loadDataWithBaseURL(null, "", "text/html", "utf-8", null);
        webView.clearHistory();
        webView.destroy();
        webView = null;
    }
    super.onDestroy();
}

8.2 iOS WKWebView 内存管理

deinit {
    webView.stopLoading()
    webView.navigationDelegate = nil
    webView.uiDelegate = nil
}

8.3 JS 内存管理

// 及时清理定时器
const timer = setInterval(() => {}, 1000);
clearInterval(timer);

// 及时清理事件监听
const handler = () => {};
element.addEventListener('click', handler);
element.removeEventListener('click', handler);

// 避免闭包内存泄漏
function createHandler() {
  const largeData = new Array(10000);
  return () => {
    // 使用 largeData
  };
}
// 用完后置空
let handler = createHandler();
handler();
handler = null;

九、实战案例

9.1 优化前

指标 数值
白屏时间 800ms
首屏时间 1500ms
完全加载 3500ms

9.2 优化措施

措施 效果
WebView 预加载 -200ms
DNS 预解析 + 预连接 -150ms
资源预加载 -200ms
关键 CSS 内联 -100ms
图片懒加载 -300ms
JS 代码分割 -150ms
缓存优化 -100ms

9.3 优化后

指标 数值 提升
白屏时间 300ms -62.5%
首屏时间 700ms -53.3%
完全加载 1800ms -48.6%

十、总结

WebView 性能优化是一个系统工程,需要从多个维度入手:

  1. 初始化优化:WebView 预加载、复用池
  2. 网络优化:DNS 预解析、预连接、预加载、缓存策略
  3. 渲染优化:阻塞资源处理、关键路径优化、图片懒加载
  4. JS 优化:代码分割、长任务处理、事件优化
  5. 交互优化:减少通信次数、消息队列
  6. 内存优化:防止内存泄漏、及时清理资源

记住:性能优化没有银弹,要根据实际场景选择合适的方案。先用工具测量,找到瓶颈,再对症下药。


#WebView性能优化 #移动端H5 #前端优化 #性能调优

vxe-table 实现撤销与重做:单元格编辑后支持 Ctrl+Z 回退

作者 自由的云h
2026年5月7日 10:29

vxe-table 如何实现撤销与重做,修改单元格编辑后支持回退 当公司业务需求需要实现编辑表格支持撤销功能,这时就可以通过设置 keyboard-config.isUndoRedo 启用。当编辑的单元格之后,按 Ctrl + Z | Command + Z 键 ,可以撤销会编辑之前的状态

需求场景

在表格编辑场景中,用户经常需要撤销误操作或重做已撤销的内容。vxe-table 提供了内置的撤销与重做功能,只需启用 keyboardConfig.isUndoRedo 即可获得类似 Excel 的编辑历史记录能力。

核心配置

在 vxe-grid 或 vxe-table 组件中,通过 keyboardConfig 开启撤销与重做:

keyboardConfig: {
  isUndoRedo: true,   // 启用撤销与重做功能(Ctrl+Z / Ctrl+Y)
  // ... 其他键盘配置
}

示例代码

extend_cell_area_table_keypad_undo_redo

<template>
  <div>
    <vxe-grid v-bind="gridOptions"></vxe-grid>
  </div>
</template>

<script setup>
import { reactive } from 'vue'

const sexEditRender = reactive({
  name: 'VxeSelect',
  options: []
})

const gridOptions = reactive({
  border: true,
  height: 500,
  showOverflow: true,
  keepSource: true,
  columnConfig: {
    resizable: true
  },
  mouseConfig: {
    area: true // 是否开启区域选取
  },
  areaConfig: {
    multiple: true // 是否启用多区域选取功能
  },
  editConfig: {
    mode: 'cell', // 单元格编辑模式
    trigger: 'dblclick', // 双击单元格激活编辑状态
    showStatus: true // 显示编辑状态
  },
  keyboardConfig: {
    arrowCursorLock: true, // 方向键光标锁,开启后处于非聚焦式编辑状态,将支持在编辑状态中通过方向键切换单元格。(切换为聚焦编辑状态,可以按 F2 键或者鼠标左键点击输入框,就可以用方向键左右移动输入框的光标)
    isClip: true, // 是否开启复制粘贴
    isArrow: true, // 是否开启方向键功能
    isShift: true, // 是否开启同时按住方向键以活动区域为起始,向指定方向扩展单元格区域
    isTab: true, // 是否开启 Tab 键功能
    isEnter: true, // 是否开启回车键功能
    isEdit: true, // 是否开启任意键进入编辑(功能键除外)
    isBack: true, // 是否开启 Backspace 键功能
    isDel: true, // 是否开启删除键功能
    isEsc: true, // 是否开启Esc键关闭编辑功能
    isFNR: true, // 是否开启查找与替换
    isMerge: true, // 是否开启单元格合并功能
    isChecked: true, // 是否启用复选框/单选框按空格键选中/取消功能
    isUndoRedo: true // 是否启用撤销与重做功能
  },
  columns: [
    { type: 'seq', fixed: 'left', width: 60 },
    { type: 'checkbox', fixed: 'left', width: 60 },
    { field: 'name', title: 'name', width: 200, editRender: { name: 'VxeInput' } },
    { field: 'role', title: 'Role', width: 200, editRender: { name: 'VxeInput' } },
    { field: 'sex', title: 'sex', width: 250, editRender: sexEditRender },
    { field: 'num1', title: 'Num1', width: 200, editRender: { name: 'VxeInput' } },
    { field: 'num2', title: 'Num2', width: 250, editRender: { name: 'VxeInput' } },
    { field: 'num3', title: 'Num3', width: 300, editRender: { name: 'VxeInput' } },
    { field: 'age', title: 'age', width: 100, editRender: { name: 'VxeInput' } },
    { field: 'address', title: 'Address', fixed: 'right', width: 300, editRender: { name: 'VxeInput' } }
  ],
  data: [
    { id: 10001, name: 'Test1', role: 'Develop', sex: '1', num1: 12, num2: 21, num3: 78, age: 28, address: 'Shengzhen' },
    { id: 10002, name: 'Test2', role: 'Test', sex: '0', num1: 23, num2: 45, num3: 23, age: 22, address: 'Guangzhou' },
    { id: 10003, name: 'Test3', role: 'PM', sex: '1', num1: 23, num2: 12, num3: 68, age: 32, address: 'Shanghai' },
    { id: 10004, name: 'Test4', role: 'Designer', sex: '0', num1: 23, num2: 23, num3: 47, age: 24, address: 'Shanghai' },
    { id: 10005, name: 'Test5', role: 'Designer', sex: '0', num1: 32, num2: 77, num3: 65, age: 42, address: 'Guangzhou' },
    { id: 10006, name: 'Test6', role: 'Designer', sex: '1', num1: 39, num2: 66, num3: 87, age: 38, address: 'Shengzhen' },
    { id: 10007, name: 'Test7', role: 'Test', sex: '0', num1: 23, num2: 44, num3: 23, age: 24, address: 'Shengzhen' },
    { id: 10008, name: 'Test8', role: 'PM', sex: '1', num1: 23, num2: 23, num3: 87, age: 34, address: 'Shanghai' },
    { id: 10009, name: 'Test9', role: 'Designer', sex: '1', num1: 91, num2: 23, num3: 24, age: 52, address: 'Shanghai' },
    { id: 10010, name: 'Test10', role: 'Test', sex: '0', num1: 20, num2: 72, num3: 54, age: 44, address: 'Guangzhou' },
    { id: 10011, name: 'Test11', role: 'Designer', sex: '1', num1: 56, num2: 32, num3: 63, age: 52, address: 'Shanghai' },
    { id: 10012, name: 'Test12', role: 'Test', sex: '0', num1: 34, num2: 65, num3: 54, age: 47, address: 'Guangzhou' },
    { id: 10013, name: 'Test13', role: 'Test', sex: '0', num1: 39, num2: 65, num3: 435, age: 47, address: 'Guangzhou' },
    { id: 10014, name: 'Test14', role: 'Test', sex: '0', num1: 44, num2: 65, num3: 324, age: 45, address: 'Guangzhou' },
    { id: 10015, name: 'Test15', role: 'Test', sex: '0', num1: 45, num2: 56, num3: 54, age: 39, address: 'Guangzhou' },
    { id: 10016, name: 'Test16', role: 'Test', sex: '0', num1: 34, num2: 65, num3: 344, age: 44, address: 'Shanghai' },
    { id: 10017, name: 'Test17', role: 'Test', sex: '0', num1: 78, num2: 77, num3: 78, age: 48, address: 'Guangzhou' },
    { id: 10018, name: 'Test18', role: 'Test', sex: '0', num1: 32, num2: 12, num3: 45, age: 89, address: 'Shanghai' },
    { id: 10019, name: 'Test19', role: 'Test', sex: '0', num1: 42, num2: 65, num3: 8, age: 52, address: 'Guangzhou' },
    { id: 10020, name: 'Test20', role: 'Test', sex: '0', num1: 56, num2: 45, num3: 4, age: 41, address: 'Shanghai' },
    { id: 10021, name: 'Test21', role: 'Test', sex: '1', num1: 48, num2: 65, num3: 54, age: 49, address: 'Guangzhou' },
    { id: 10022, name: 'Test22', role: 'Test', sex: '0', num1: 41, num2: 65, num3: 12, age: 50, address: 'Shanghai' }
  ]
})
// 异步加载下拉选项
setTimeout(() => {
  sexEditRender.options = [
    { label: 'Man', value: '1' },
    { label: 'Women', value: '0' }
  ]
}, 300)
</script>

关键配置说明

  1. 必须启用的基础配置
  • keepSource: true:保留原始数据副本,撤销/重做依赖于此。
  • editConfig.mode: 'cell':单元格编辑模式(也支持行编辑模式,但撤销/重做对单元格编辑体验更好)。
  1. 当 isUndoRedo: true 时,vxe-table 内部会维护一个编辑历史栈: 每次单元格编辑完成后(失焦或按回车),当前数据快照会被推入历史栈。 按 Ctrl+Z 时,从历史栈中弹出上一个状态并恢复。 按 Ctrl+Y 时,执行重做操作(需先有撤销操作)。

获取历史记录 API

// 获取表格实例
const $grid = gridRef.value

// 手动撤销
$grid.undo()

// 手动重做
$grid.redo()

vxe-table 的撤销/重做功能开箱即用,无需额外存储逻辑,极大降低了开发成本。如果你的业务需要严谨的编辑历史管理,这是一个非常实用的特性。

vxetable.cn

vue甘特图vxe-gantt如何实现拖拽任务条时如有已关联依赖线,同时更新依赖任务的日期的方式

作者 卤蛋fg6
2026年5月7日 10:07

vue甘特图vxe-gantt如何实现拖拽任务条时如有已关联依赖线,同时更新依赖任务的日期的方式

当任务关联前置任务或后置任务依赖线时,拖拽该任务时同步更新对应的起始日期和结束日期,可以通过 task-bar-drag-config.moveSetMethod 来自定义业务逻辑

extend_gantt_chart_gantt_dependency_move_update

基础代码

简单实现同步移动任务

<template>
  <div>
    <vxe-gantt v-bind="ganttOptions"></vxe-gantt>
  </div>
</template>

<script setup>
import { reactive } from 'vue'
import { VxeGanttDependencyType } from 'vxe-gantt'
import XEUtils from 'xe-utils'

const ganttOptions = reactive({
  border: true,
  height: 600,
  rowConfig: {
    keyField: 'id' // 行主键
  },
  taskBarConfig: {
    showProgress: true, // 是否显示进度条
    showContent: true, // 是否在任务条显示内容
    moveable: true, // 是否允许拖拽任务移动日期
    resizable: true, // 是否允许拖拽任务调整日期
    linkCreatable: true, // 是否允许自定义创建依赖线
    barStyle: {
      round: true, // 圆角
      bgColor: '#fca60b', // 任务条的背景颜色
      completedBgColor: '#65c16f' // 已完成部分任务条的背景颜色
    }
  },
  taskLinkConfig: {
    isHover: true, // 当鼠标移到依赖线时,是否要高亮当前依赖线
    isCurrent: true, // 当鼠标点击依赖线时,是否要高亮当前依赖线
    isDblclickToRemove: true // 是否允许双击依赖线删除
  },
  taskViewConfig: {
    tableStyle: {
      width: 480 // 表格宽度
    }
  },
  taskBarMoveConfig: {
    // 自定义拖拽结束时任务日期被赋值的方法
    async moveSetMethod ({ row, startValue, endValue, offsetSize, linkInfo }) {
      const { toRows, fromRows } = linkInfo
      row.start = startValue
      row.end = endValue
      // 实现拖拽任务后,关联任务自动同步更新日期
      fromRows.forEach(row => {
        row.start = XEUtils.toDateString(XEUtils.getWhatDay(row.start, offsetSize), 'yyyy-MM-dd')
        row.end = XEUtils.toDateString(XEUtils.getWhatDay(row.end, offsetSize), 'yyyy-MM-dd')
      })
      toRows.forEach(row => {
        row.start = XEUtils.toDateString(XEUtils.getWhatDay(row.start, offsetSize), 'yyyy-MM-dd')
        row.end = XEUtils.toDateString(XEUtils.getWhatDay(row.end, offsetSize), 'yyyy-MM-dd')
      })
    }
  },
  links: [
    { from: 10001, to: 10002, type: VxeGanttDependencyType.FinishToFinish },
    { from: 10004, to: 10005, type: VxeGanttDependencyType.StartToStart },
    { from: 10005, to: 10006, type: VxeGanttDependencyType.FinishToStart },
    { from: 10013, to: 10012, type: VxeGanttDependencyType.StartToFinish }
  ],
  columns: [
    { type: 'seq', width: 70 },
    { field: 'title', title: '任务名称' },
    { field: 'start', title: '开始时间', width: 100 },
    { field: 'end', title: '结束时间', width: 100 },
    { field: 'progress', title: '进度(%)', width: 80 }
  ],
  data: [
    { id: 10001, title: '任务1', start: '2024-03-01', end: '2024-03-04', progress: 3 },
    { id: 10002, title: '任务2', start: '2024-03-03', end: '2024-03-08', progress: 10 },
    { id: 10003, title: '任务3', start: '2024-03-03', end: '2024-03-11', progress: 90 },
    { id: 10004, title: '任务4', start: '2024-03-05', end: '2024-03-11', progress: 15 },
    { id: 10005, title: '任务5', start: '2024-03-08', end: '2024-03-15', progress: 100 },
    { id: 10006, title: '任务6', start: '2024-03-10', end: '2024-03-21', progress: 5 },
    { id: 10007, title: '任务7', start: '2024-03-15', end: '2024-03-24', progress: 70 },
    { id: 10008, title: '任务8', start: '2024-03-05', end: '2024-03-15', progress: 50 },
    { id: 10009, title: '任务9', start: '2024-03-19', end: '2024-03-20', progress: 5 },
    { id: 10010, title: '任务10', start: '2024-03-12', end: '2024-03-20', progress: 10 },
    { id: 10011, title: '任务11', start: '2024-03-01', end: '2024-03-08', progress: 90 },
    { id: 10012, title: '任务12', start: '2024-03-03', end: '2024-03-06', progress: 60 },
    { id: 10013, title: '任务13', start: '2024-03-02', end: '2024-03-05', progress: 50 },
    { id: 10014, title: '任务14', start: '2024-03-04', end: '2024-03-15', progress: 0 },
    { id: 10015, title: '任务15', start: '2024-03-01', end: '2024-03-05', progress: 30 }
  ]
})
</script>

还可以实现更复杂的逻辑

<template>
  <div>
    <vxe-gantt ref="ganttRef" v-bind="ganttOptions" @task-bar-drag-end="onTaskDragEnd"></vxe-gantt>
  </div>
</template>

<script setup>
import { reactive, ref } from 'vue'
import { VxeGanttDependencyType } from 'vxe-gantt'
import XEUtils from 'xe-utils'

const ganttRef = ref(null)

const ganttOptions = reactive({
  // ...(保持原有配置)
  taskBarMoveConfig: {
    // 自定义拖拽结束时任务日期被赋值的方法
    async moveSetMethod({ row, startValue, endValue, offsetSize, linkInfo }) {
      const { toRows, fromRows, toLinks, fromLinks } = linkInfo
    
      // 1. 获取变更前的日期(用于错误恢复)
      // const oldStart = row.start
      // const oldEnd = row.end

      // 2. 更新当前拖拽任务的日期
      row.start = startValue
      row.end = endValue


      // 4. 统一更新依赖任务的方法
      //   根据依赖类型计算出新的日期,而不是简单粗暴地整体偏移
      const updateDependentTasks = (tasks, currentRow, linkType, offsetDays) => {
        tasks.forEach(task => {
          const currentStart = new Date(currentRow.start)
          const currentEnd = new Date(currentRow.end)
          let newStart, newEnd

          // 根据不同的依赖类型,更新关联任务的日期
          switch (linkType) {
            case VxeGanttDependencyType.FinishToStart:
              // 前置任务结束后,后置任务才能开始
              newStart = XEUtils.toDateString(
                XEUtils.getWhatDay(currentRow.end, offsetDays),
                'yyyy-MM-dd'
              )
              newEnd = XEUtils.toDateString(
                XEUtils.getWhatDay(task.end, offsetDays),
                'yyyy-MM-dd'
              )
              break
            case VxeGanttDependencyType.StartToStart:
              // 两个任务同时开始
              newStart = XEUtils.toDateString(
                XEUtils.getWhatDay(currentRow.start, offsetDays),
                'yyyy-MM-dd'
              )
              newEnd = XEUtils.toDateString(
                XEUtils.getWhatDay(task.end, offsetDays),
                'yyyy-MM-dd'
              )
              break
            case VxeGanttDependencyType.FinishToFinish:
              // 两个任务同时完成
              newStart = XEUtils.toDateString(
                XEUtils.getWhatDay(task.start, offsetDays),
                'yyyy-MM-dd'
              )
              newEnd = XEUtils.toDateString(
                XEUtils.getWhatDay(currentRow.end, offsetDays),
                'yyyy-MM-dd'
              )
              break
            case VxeGanttDependencyType.StartToFinish:
              // 当前任务完成才能开始
              newStart = XEUtils.toDateString(
                XEUtils.getWhatDay(currentRow.start, offsetDays),
                'yyyy-MM-dd'
              )
              newEnd = XEUtils.toDateString(
                XEUtils.getWhatDay(task.end, offsetDays),
                'yyyy-MM-dd'
              )
              break
            default:
              // 默认整体偏移
              newStart = XEUtils.toDateString(
                XEUtils.getWhatDay(task.start, offsetDays),
                'yyyy-MM-dd'
              )
              newEnd = XEUtils.toDateString(
                XEUtils.getWhatDay(task.end, offsetDays),
                'yyyy-MM-dd'
              )
          }

          // 更新任务数据
          task.start = newStart || task.start
          task.end = newEnd || task.end
        })
      }

      // 6. 更新依赖任务(前置和后置)
      // 使用依赖关系信息来精确更新
      if (fromRows.length && fromLinks.length) {
         updateDependentTasks([fromRows[0]], row, fromLinks[0], offsetSize)
      }

      if (toRows.length && toLinks.length) {
        updateDependentTasks([toRows[0]], row, toLinks[0], offsetSize)
      }

    }
  },
  // ...(保持原有数据配置)
})

// 监听拖拽事件,执行其他业务逻辑(如保存到后端)
const onTaskDragEnd = ({ row, startValue, endValue }) => {
  // 可以在这里保存更新后的任务数据,或者执行其他业务逻辑
  console.log('任务拖拽完成', { row, startValue, endValue })
  
  // 可选:触发数据更新到后端
  // updateTaskToBackend(row)
}
</script>

依赖类型说明

依赖类型 原逻辑缺陷 优化后逻辑
FinishToStart 前置任务结束日期推迟,后置任务应该整体推迟?❌ 应该是后置任务的开始日期=前置任务的结束日期 仅更新后置任务的开始日期,保持持续时间不变
StartToStart 同时开始的任务,拖拽一个不应该导致另一个结束日期整体偏移 保持两个任务的结束日期相对不变,仅调整开始日期
FinishToFinish 两个任务同时结束,拖拽一个不应该影响另一个的开始日期 保持两个任务的开始日期相对不变,仅调整结束日期
StartToFinish 前置任务开始日期影响后置任务完成日期 根据依赖关系的具体约束来精确更新

gantt.vxeui.com

React 中的语音与摄像头输入:语音识别、媒体设备与权限

2026年5月7日 10:04

语音和摄像头是把一个静态 Web 应用变得鲜活的两种感官。一个能对它说话的搜索栏。一个实时把你说的话转成文字的笔记应用。一个让你选择用哪个摄像头的会议工具。一个按住按键就能说话的对讲机。这些早已不再罕见——浏览器有这些 API 已经好多年了——但每一个都被一连串权限弹窗、厂商前缀和生命周期的怪癖挡在前面,让人很难干净地把它们集成进 React 组件。

本文将带你走过四种用于语音和摄像头输入的浏览器能力:带中间结果的实时语音识别、枚举用户的摄像头和麦克风、在权限被撤销时仍能存活的权限查询,以及把 Shift 键当作按住说话修饰符使用。和往常一样,我们会先用手动实现来开局,让你看清底层的管道,然后再换成 ReactUse 里专门的 Hook。最后,我们会把四个 Hook 组合成一个完整的语音搜索组件,包含设备选择器、权限闸门,以及按住说话的录音交互。

1. 实时语音识别

手动实现

Web Speech API 是一个比较老的浏览器 API,但从未真正被标准化——Chrome 把它实现成 webkitSpeechRecognition,而无前缀的 SpeechRecognition 在大多数引擎里仍然缺失。最小可用的 React 包装看起来像这样:

function ManualSpeechRecognition() {
  const [transcript, setTranscript] = useState("");
  const [listening, setListening] = useState(false);
  const recognitionRef = useRef<any>(null);

  useEffect(() => {
    const SR =
      (window as any).SpeechRecognition ||
      (window as any).webkitSpeechRecognition;
    if (!SR) return;
    const recognition = new SR();
    recognition.continuous = true;
    recognition.interimResults = true;
    recognition.lang = "zh-CN";
    recognition.onresult = (event: any) => {
      const result = event.results[event.resultIndex];
      setTranscript(result[0].transcript);
    };
    recognition.onend = () => setListening(false);
    recognitionRef.current = recognition;
    return () => recognition.abort();
  }, []);

  const start = () => {
    recognitionRef.current?.start();
    setListening(true);
  };
  const stop = () => {
    recognitionRef.current?.stop();
    setListening(false);
  };

  return (
    <div>
      <button onClick={listening ? stop : start}>
        {listening ? "停止" : "开始"}识别
      </button>
      <p>{transcript}</p>
    </div>
  );
}

这个能跑,但忽略了那些粗糙的边角。它没有区分 isFinal,所以 UI 无法判断用户什么时候停顿了("中间结果"和"最终结果"的区别正是让语音 UI 显得有响应的关键)。它没有错误处理——如果用户拒绝了麦克风权限或网络断了,转录就会默默地永远不更新。它没有语言协商。而且 SR 的类型很糟糕,因为 TypeScript 没有为 webkitSpeechRecognition 提供类型。

ReactUse 的方式:useSpeechRecognition

useSpeechRecognition 返回一个干净的对象,提供恰当的原语:

import { useSpeechRecognition } from "@reactuses/core";

function VoiceNote() {
  const { isSupported, isListening, isFinal, result, error, start, stop } =
    useSpeechRecognition({
      lang: "zh-CN",
      interimResults: true,
      continuous: true,
    });

  if (!isSupported) {
    return <p>当前浏览器不支持语音识别。</p>;
  }

  return (
    <div>
      <button onClick={isListening ? stop : start}>
        {isListening ? "停止" : "开始"}口述
      </button>
      <p
        style={{
          fontStyle: isFinal ? "normal" : "italic",
          color: isFinal ? "#0f172a" : "#64748b",
        }}
      >
        {result || "说点什么..."}
      </p>
      {error && <p style={{ color: "#ef4444" }}>错误:{error.error}</p>}
    </div>
  );
}

你不用写就能拿到的好处:

  1. isFinal —— Hook 会跟踪当前 result 是语音引擎的临时猜测(在示例里是斜体)还是已经锁定的转录。这是相比朴素版本最大的 UX 提升。
  2. error 对象 —— 当权限被拒、网络断开或引擎失败时,你能拿到一个带类型的错误对象,可以展示给用户而不是默默地卡住。
  3. 热配置start({ lang: "fr-FR" }) 让你能在会话中途切换语言,无需重建识别器。
  4. 卸载时清理。Hook 会自动调用 abort(),所以离开页面永远不会让麦克风一直开着。

最有威力的模式是把识别结果绑到一个搜索输入框上,让用户在说话时实时输入查询。因为 Hook 会在每个中间结果到来时重渲,你可以直接用语音输入来驱动一个实时搜索查询,让用户在说话时就能看到结果。

2. 枚举摄像头和麦克风

手动实现

列出用户的音频和视频设备需要 navigator.mediaDevices.enumerateDevices()。有个陷阱:在用户对某个设备授予权限之前,返回的标签是空的——你只能拿到一组 deviceId,但拿不到像 "FaceTime HD Camera" 这样的 label。要拿到标签,你必须先调用 getUserMedia 触发权限弹窗,然后再枚举一次。

function ManualDeviceList() {
  const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);

  useEffect(() => {
    let mounted = true;
    const refresh = async () => {
      try {
        // 触发权限以填充标签
        const stream = await navigator.mediaDevices.getUserMedia({
          audio: true,
          video: true,
        });
        stream.getTracks().forEach((t) => t.stop());
        const list = await navigator.mediaDevices.enumerateDevices();
        if (mounted) setDevices(list);
      } catch (e) {
        console.error(e);
      }
    };
    refresh();
    navigator.mediaDevices.addEventListener("devicechange", refresh);
    return () => {
      mounted = false;
      navigator.mediaDevices.removeEventListener("devicechange", refresh);
    };
  }, []);

  return (
    <ul>
      {devices.map((d) => (
        <li key={d.deviceId}>
          {d.kind}: {d.label || "(标签隐藏)"}
        </li>
      ))}
    </ul>
  );
}

形状是对的,但你每次都要写权限触发的舞蹈、临时流的清理,以及 device-change 监听器。

ReactUse 的方式:useMediaDevices

useMediaDevices 把整套流程打包了起来:

import { useMediaDevices } from "@reactuses/core";

function CameraPicker({
  selected,
  onSelect,
}: {
  selected: string;
  onSelect: (id: string) => void;
}) {
  const [{ devices }, ensurePermissions] = useMediaDevices({
    requestPermissions: true,
    constraints: { video: true, audio: false },
  });

  const cameras = devices.filter((d) => d.kind === "videoinput");

  return (
    <div>
      <button onClick={() => ensurePermissions()}>刷新设备</button>
      <select
        value={selected}
        onChange={(e) => onSelect(e.target.value)}
        style={{ marginLeft: 8 }}
      >
        {cameras.map((cam) => (
          <option key={cam.deviceId} value={cam.deviceId}>
            {cam.label || `摄像头 ${cam.deviceId.slice(0, 6)}`}
          </option>
        ))}
      </select>
    </div>
  );
}

Hook 处理了三件你本来要自己写的事:

  • 权限协商。传 requestPermissions: true,Hook 会在挂载时根据你指定的 constraints 触发 getUserMedia,然后立即停止临时音视轨道,让摄像头指示灯熄灭。
  • 实时设备列表。Hook 监听 devicechange 并自动重新枚举——如果用户插入新麦克风或拔掉耳机,列表会自动更新,不需要额外代码。
  • 手动刷新。返回的 ensurePermissions 让你随时能再触发一次提示,对于"用户拒绝了一次后想再试一次"的按钮非常有用。

constraints 参数会直接转发给 getUserMedia,所以你只需要视频时(跳过那种"想要麦克风权限吗"的别扭弹窗)就只请求视频。

3. 正确地查询权限

手动实现

要在不触发弹窗的情况下检查用户是否已经授予(或拒绝)麦克风或摄像头权限,需要 Permissions API。它支持得很好但很啰嗦:

function ManualMicPermission() {
  const [state, setState] = useState<PermissionState | "unknown">("unknown");

  useEffect(() => {
    let mounted = true;
    let status: PermissionStatus | null = null;
    (async () => {
      try {
        status = await navigator.permissions.query({
          name: "microphone" as PermissionName,
        });
        if (mounted) setState(status.state);
        status.onchange = () => mounted && status && setState(status.state);
      } catch {
        // 此名称的 Permissions API 不可用
      }
    })();
    return () => {
      mounted = false;
      if (status) status.onchange = null;
    };
  }, []);

  return <p>麦克风权限:{state}</p>;
}

三件值得注意的事。第一,API 通过 onchange 提供回调,对 React 不友好。第二,你必须同时特性检测 Permissions API 本身和具体的 name(某些浏览器不支持 "microphone")。第三,change 监听器必须显式清理,而不能通过 effect 返回值。

ReactUse 的方式:usePermission

usePermission 把整段舞蹈减到一次调用:

import { usePermission } from "@reactuses/core";

function MicStatusBadge() {
  const state = usePermission("microphone");

  const color =
    state === "granted"
      ? "#10b981"
      : state === "denied"
      ? "#ef4444"
      : "#f59e0b";

  return (
    <span style={{ color, fontWeight: 600 }}>
      麦克风:{state || "未知"}
    </span>
  );
}

state 是一个 React 原生字符串,每当底层权限状态变化时就会更新——包括外部变化,比如用户进入浏览器设置撤销了权限,你的组件 state 就会翻转到 "denied",不需要你做任何操作。

你可以传一个像 "microphone""camera" 这样的字符串,也可以传一个完整的 PermissionDescriptor 对象,用于像 "push" 这样需要额外字段的权限。形状和 navigator.permissions.query 完全一致,只是变成了一个 Hook。

4. 用 useKeyModifier 实现按住说话

手动实现

按住说话按钮比看起来要难。你想检测用户是否在按住某个键(比如 Space 或 Shift),按住时开始录音,松开时立即停止。你还得处理这种情况:用户按住按键、把焦点切到另一个窗口、在你的页面隐藏时松开按键、然后再回来——否则录音器会一直卡在录制状态。

function ManualPushToTalk() {
  const [pressed, setPressed] = useState(false);

  useEffect(() => {
    const onDown = (e: KeyboardEvent) => {
      if (e.code === "Space") setPressed(true);
    };
    const onUp = (e: KeyboardEvent) => {
      if (e.code === "Space") setPressed(false);
    };
    const onBlur = () => setPressed(false);
    window.addEventListener("keydown", onDown);
    window.addEventListener("keyup", onUp);
    window.addEventListener("blur", onBlur);
    return () => {
      window.removeEventListener("keydown", onDown);
      window.removeEventListener("keyup", onUp);
      window.removeEventListener("blur", onBlur);
    };
  }, []);

  return <p>{pressed ? "正在录制..." : "按住空格说话"}</p>;
}

这个差不多能跑。bug 是:如果 Space 键在按住时自动重复(大多数操作系统都会这样),你会先收到一个 keydown,然后又一个 keydown,最后才是 keyup。这个你处理了。但如果用户按的是 Shift 并把它当成与其他键的组合修饰符使用,你的手动跟踪就不知道了。

ReactUse 的方式:useKeyModifier

useKeyModifier 把 OS 级别的修饰键状态(和你从 event.getModifierState 拿到的值一样)暴露为 React state:

import { useKeyModifier } from "@reactuses/core";

function ShiftToRecord({ onTalkStart, onTalkEnd }: {
  onTalkStart: () => void;
  onTalkEnd: () => void;
}) {
  const shift = useKeyModifier("Shift");

  useEffect(() => {
    if (shift) onTalkStart();
    else onTalkEnd();
  }, [shift, onTalkStart, onTalkEnd]);

  return (
    <div
      style={{
        padding: 16,
        background: shift ? "#fef3c7" : "#f1f5f9",
        borderRadius: 8,
        textAlign: "center",
      }}
    >
      {shift ? "正在录制(松开 Shift 停止)" : "按住 Shift 说话"}
    </div>
  );
}

相比 keydown/keyup 版本的好处:

  • OS 感知。Hook 读取 getModifierState,从 OS 查询实际的修饰键状态。它能正确应对自动重复、焦点丢失和奇怪的组合键。
  • 支持任何修饰键。传 "Control""Alt""Meta""CapsLock""NumLock"——浏览器追踪的任何修饰键都行。
  • 初始值。如果你想让 React state 初始为 true,就配置 initial: true(不常见,但调试时有用)。

全部组合:带设备选择器的语音搜索

我们把四个 Hook 组合成一个语音驱动的搜索组件。用户可以选择用哪个麦克风、看到一个权限徽章、按住 Shift 开始口述、并在说话时实时看到转录更新。当他们松开 Shift 时,最终转录就成了搜索查询。

import { useEffect, useState } from "react";
import {
  useSpeechRecognition,
  useMediaDevices,
  usePermission,
  useKeyModifier,
} from "@reactuses/core";

function VoiceSearch() {
  const [selectedMic, setSelectedMic] = useState<string>("");
  const [query, setQuery] = useState("");

  const micPermission = usePermission("microphone");
  const [{ devices }, requestDevices] = useMediaDevices({
    requestPermissions: false,
    constraints: { audio: true, video: false },
  });

  const microphones = devices.filter((d) => d.kind === "audioinput");

  const {
    isSupported,
    isListening,
    isFinal,
    result,
    error,
    start,
    stop,
  } = useSpeechRecognition({
    lang: "zh-CN",
    interimResults: true,
    continuous: false,
  });

  const shiftDown = useKeyModifier("Shift");

  // 按住说话:按下 Shift 时开始,松开时停止
  useEffect(() => {
    if (!isSupported || micPermission !== "granted") return;
    if (shiftDown) {
      start();
    } else if (isListening) {
      stop();
    }
  }, [shiftDown, isSupported, micPermission, start, stop, isListening]);

  // 当识别最终化时,把结果提交到查询
  useEffect(() => {
    if (isFinal && result) {
      setQuery(result);
    }
  }, [isFinal, result]);

  const permissionColor =
    micPermission === "granted"
      ? "#10b981"
      : micPermission === "denied"
      ? "#ef4444"
      : "#f59e0b";

  return (
    <div
      style={{
        maxWidth: 640,
        padding: 24,
        background: "#ffffff",
        borderRadius: 16,
        boxShadow: "0 4px 24px rgba(15, 23, 42, 0.06)",
        fontFamily: "system-ui, sans-serif",
      }}
    >
      <header
        style={{
          display: "flex",
          justifyContent: "space-between",
          alignItems: "center",
          marginBottom: 16,
        }}
      >
        <h2 style={{ margin: 0, fontSize: 18 }}>语音搜索</h2>
        <span style={{ color: permissionColor, fontSize: 13, fontWeight: 600 }}>
          ● 麦克风:{micPermission || "未知"}
        </span>
      </header>

      {!isSupported && (
        <p style={{ color: "#64748b" }}>
          当前浏览器不支持语音识别。请试试 Chrome。
        </p>
      )}

      {isSupported && micPermission !== "granted" && (
        <button
          onClick={requestDevices}
          style={{
            width: "100%",
            padding: 12,
            background: "#3b82f6",
            color: "white",
            border: "none",
            borderRadius: 8,
            cursor: "pointer",
          }}
        >
          授权麦克风访问
        </button>
      )}

      {isSupported && micPermission === "granted" && (
        <>
          <div style={{ display: "flex", gap: 12, marginBottom: 12 }}>
            <select
              value={selectedMic}
              onChange={(e) => setSelectedMic(e.target.value)}
              style={{
                flex: 1,
                padding: 8,
                borderRadius: 6,
                border: "1px solid #cbd5e1",
              }}
            >
              <option value="">默认麦克风</option>
              {microphones.map((mic) => (
                <option key={mic.deviceId} value={mic.deviceId}>
                  {mic.label || `麦克风 ${mic.deviceId.slice(0, 6)}`}
                </option>
              ))}
            </select>
          </div>

          <div
            style={{
              padding: 16,
              background: shiftDown ? "#dcfce7" : "#f8fafc",
              borderRadius: 8,
              border: shiftDown
                ? "2px solid #10b981"
                : "2px dashed #cbd5e1",
              textAlign: "center",
              transition: "all 120ms ease",
            }}
          >
            <p style={{ margin: 0, fontWeight: 600, fontSize: 13 }}>
              {shiftDown ? "正在监听..." : "按住 Shift 进行口述"}
            </p>
            {result && (
              <p
                style={{
                  margin: "8px 0 0",
                  fontStyle: isFinal ? "normal" : "italic",
                  color: isFinal ? "#0f172a" : "#64748b",
                }}
              >
                {result}
              </p>
            )}
          </div>

          {error && (
            <p style={{ color: "#ef4444", fontSize: 13, marginTop: 8 }}>
              识别错误:{error.error}
            </p>
          )}

          <input
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            placeholder="搜索查询..."
            style={{
              width: "100%",
              marginTop: 12,
              padding: 10,
              borderRadius: 6,
              border: "1px solid #cbd5e1",
              fontSize: 16,
            }}
          />
        </>
      )}
    </div>
  );
}

四个 Hook,四个相互正交的关注点:

  • usePermission 驱动 header 中的徽章,并把 UI 的其余部分挡在用户实际决策之后。因为它是响应式的,如果用户在浏览器设置里撤销了麦克风权限,徽章会自动更新,输入框会自动消失。
  • useMediaDevices 填充麦克风选择器,除非用户点击"授权",否则不会强制弹出权限对话框。
  • useSpeechRecognition 完成实际的转录,区分中间结果和最终结果,并以带类型的方式暴露引擎错误。
  • useKeyModifier 把 Shift 键变成按住说话的触发器,能正确应对焦点丢失、OS 自动重复和奇怪的组合键。

整个组件大概 130 行,绝大多数都是标签。浏览器 API 那些历来最难做对的部分,每个关注点只占一行 import。

关于测试的一点说明

语音和摄像头功能出了名地难测试,因为它们依赖的浏览器 API 需要真实的人手势和物理硬件。这些 Hook 都暴露了 isSupported 标志,所以你的测试环境(jsdom、Vitest、用 mock navigator 的 Storybook)可以在底层 API 缺失时干净地分支并渲染 fallback 状态。如果你在做严肃的语音 UI,请专门划出一小层在 headless Chrome 里用假媒体流跑的集成测试——那才是抓真正 bug 的唯一方式。

安装

npm i @reactuses/core

相关 Hook

  • useSpeechRecognition —— 实时语音转文字,跟踪中间和最终结果
  • useMediaDevices —— 枚举摄像头和麦克风,处理权限
  • usePermission —— 响应式地查询任意权限的 Permissions API
  • useKeyModifier —— 跟踪 OS 级别的修饰键状态(Shift、Control 等)
  • useSupported —— 响应式地检查浏览器 API 是否可用
  • useEventListener —— 声明式地附加事件监听器,可用于自定义语音流程
  • useObjectUrl —— 为录制的音频 blob 创建临时 URL 以预览

ReactUse 提供了 100+ 个 React Hook。全部探索 →

2026年,为什么NestJS + Monorepo越来越流行了 ❓❓❓

作者 Moment
2026年5月7日 09:58

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

这两年和别人聊下来,有个挺朴素的观察:工具都差不多,Cursor、Claude Code、Copilot 换来换去,有人照样顺滑往前推,有人却被 AI 拖进更深的坑里。倒不一定是模型突然变差了,更像是仓库本身经不起这么快地改——你一提速,漏的地方也跟得上提速。

我这边遇到过无数次那种很无聊的返工。后端字段改了,前端忘了跟。或者看起来类型都对,实际请求体还是对不上。编译绿灯,线上才发现分支走错。一出问题就先怀疑 prompt,改了两轮发现不对劲——常常是仓库里没有一套固定的摆放方式,模型猜这一步猜对了,下一步就和别处打架。

所以到了 2026 年,我反而更多把 NestJS 和 Monorepo 当作默认选项,不是因为它们听起来高级,单纯是省事:目录大致怎么长、模块怎么切、前后端能不能共用同一份类型说明,至少有个大家都认的底子。AI 跟着改文件的时候,不至于今天一套写法、明天换一套,你自己回看也少猜谜。

以前挑框架会问写着爽不爽。现在会先想过两个月再来需求,我还能不能一眼看出该动哪几块。NestJS + Monorepo 谈不上惊艳,只是让我觉得没那么容易失控。

写出来的快,后面收拾慢

现在问 AI 顺手写一段,在圈里早不新鲜了。身边人多少都会用用 CursorCopilot 一类,写 TS、改多文件的仓库,编辑器也更好跟一点。

省时间的是样板、CRUD、第一遍类型、顺带出来的测试草图。多文件改、读完再改、跑完再交 diff,大家也都摸熟了。网上还有一大把规则文件和模版,抄一抄就能开张。

麻烦的是它仍然吃你仓库长什么样。上下文一碎,就只能对着当前文件蒙,旧接口的臭毛病还能被带回来。约定没写进结构里,同一天里 ValidationPipe、手写 if、跳过注入直接 new 能并存。跨包改一半留一半、临上线才逐行对 diff,都常见。有人习惯 AI 打一版自己再改,省下的时间往往又赔在契约和安全上。

把这些和日常开发叠在一起看,AI 写代码早就不算新闻。起接口、跑 CRUD、补两层类型、顺带生成点测试,交给模型去做,往往不慢,第一眼看上去也像那么回事。别扭的是后半程:很多时候它不是写出 0 分,而是那种能跑、像样、却不对劲的 80 分——lint 不吵,预览也能点开,但分层含糊、命名各写各的、同一个概念在不同文件里换了三张脸。你要是真顺着往下叠需求,常常要到第二、第三次改动才猛然醒悟,省下来的时间没花在第一版上,全花在给前面的草率擦屁股上。

后面这几类我最熟:改一个字段,前后端各漏一处;鉴权相关的判断补丁似的散落在好几个文件里;新开的功能完全是另一套文件夹脾气;类型检查安安静静,DTO、落库和前端调用却已经各走各路。偶尔也会嘀咕,这算不算真省力。

我以前也会比谁敲得快、谁能更快翻出文档。现在更在乎仓库省不省返工,少折腾比好看重要。上下文窗口再大,翻起来顺不顺还是看你自己怎么摆文件夹。

好几个仓库并排的时候

很长一段时间里,我都觉得多 repo 很正常:前端一个仓,后端一个仓,再加共享类型包、组件库,听起来分工清清楚楚。

真到了天天开工、AI 也跟着一起改的时候,摩擦就出来了——业务明明是一套东西,代码却被切成几块互不接壤的地盘,没有哪个仓库能单独回答这一整块系统在干什么。人还能靠记忆和聊天记录勉强对齐,模型手里往往只有当前文件附近那点片段,它没有你那套我懂的脑内地图。

后果都很具体:字段名对不齐,import 指到老路径,接口说明还停留在上个版本,这边改了那边没人提醒,前后端各讲各的故事。于是就经常出现那种撕裂:嘴上都说 AI 很强,手头却在骂它不靠谱;细看往往不是模型突然变笨,而是你根本没给它看过全貌,它只能瞎蒙。

Monorepo 对我来说最实在的一条,就是相关代码至少在一个 workspace 里,搜得到、跳转不瞎跳,改一处牵动谁早一点露馅。

单 workspace 那点实在的好处

大家聊 Monorepo,常常一上来就是依赖 hoist、构建缓存、CI 提速、版本对齐——这些都实打实地省钱省时间。若你用的是 Turborepo、Nx 这类任务编排,改 libs/types 再触达 apps/web 时,turbo run build --filter=... 一类命令往往只跑受影响的那几条边,CI 和本地反馈都轻一些;AI 一口气动多个包的时候,也不太容易因为全量 build 太慢把思路打断。但我日常感触更深的,反而是更土的几件事:全局搜索能跨过 apps 和 libs,跳转定义不会再跳到另一个克隆仓库;开一个合并请求可以同时改 apps/api、前端调用处和 libs/types,评审的人也不用先在脑子里拼接三四份改动。

产业报告里偶尔也能看到 Monorepo 与更高采纳率、更少来回改放在一块儿的讨论,口径各自不同,我不打算在这里背具体百分比。我自己觉得更实在的一点是,同一套索引里改契约,少了很多跨仓漏改。

一种常见的摆放方式大概是这样(命名随团队习惯变,道理差不多):

  • apps/web
  • apps/api
  • apps/worker
  • libs/types
  • libs/db
  • libs/auth
  • libs/ui
  • libs/common

我手里在跑的一个仓库用的也是同一套思路,只是 app 名叫 apps/backendapps/frontend,后端在 src 下拆 apischematypes 等,根上还有 Turborepo 缓存和一份给助手看的 AGENTS.md。如下图所示:

Monorepo 目录示意(含 NestJS 后端与 Next 前端)

树一展开,比在文字里凭空想象直观得多。

我以前在多仓库里改过一个 shared type,心里会一直挂着还有没有哪个仓库没 bump;现在在同一个 workspace 里,至少引用关系摊开在同一套工具链底下,TypeScript 或单元测试常常会比人肉更早喊疼——哪里还在用旧字段,哪里页面还在按老形状解构,grep 一下也有谱。

再比如后端改了接口返回字段,前端哪些 hooks、哪些组件真正吃到这一次响应,不必全靠记忆里上次好像聊过。这不是什么玄学体验,就是改动触发的影响范围更容易被看见、被追责到同一次合并请求里。

要做 AI 相关的增量也同理:Embedding、RAG、异步任务到底落在 libs/ai 还是单独 apps/worker,一开始就需要个说得过去的落点,不然半年后全是 import 魔法和临时脚本。Monorepo 不提供正确答案,但它逼你把这一坨归谁管迟早说清楚。

在这套习惯里待久了,工作状态会从我在维护好几个小项目悄悄换成我在推进同一个系统。不是口号,是你真的少了很多切仓库、对版本、猜依赖的上下文切换。

单仓也救不了后端胡写

所有代码塞一个仓库,只解决找得到文件,不解决你在 apps/api 里照样把 controller、service、库表访问、杂七杂八工具揉一团。AI 一次改五个文件,耦合只会涨得更快。

我后来还是上了 Nest,图的是入口、业务、横切几件事在目录上有固定叫法,新人进来知道往哪翻,补丁也能长得差不多。它不算最轻,我就看半年以后加模块还痛不痛。

Nest 那套烦人的分层

第一次学 Nest,很多人都会嫌它重:Module、Controller、Service、Guard、Pipe、Interceptor,条条框框比 Express、Fastify 裸奔多出一截,脚手架一念心里先咯噔一下。

但我后来承认,那些让我觉得烦的概念,多半正是复杂之后会回来的质问——HTTP 入口到底挂在哪儿,业务逻辑能不能别再黏在路由文件里,鉴权和校验是不是每次都重写一遍,异常最后统一长成什么样,跨模块的能力能不能复用而不是复制粘贴。你可以在项目很小的时候装作没看见,等体积上来,它们会以技术债的形式敲门。

Nest 对我有用的地方,就是它催你把那些事摊开:Controller 薄一点,Service 扛事,DTO 把进出的形状说清楚,GuardPipeInterceptor 各管一截横切逻辑。写得丑归丑,至少在一条路上。

后端也不可能接口跑亮就结案,需求和权限还来。框架不写业务,只少几次从口头上重新约分层。

装饰器看多了,反而不容易乱窜

我以前当装饰器和 DI 是口味问题,现在要带着助手一起看代码,utils.ts 堆一切最头疼。Nest 那点样板至少是固定格式:@Controller 像关口,@Injectable() 多半进构造函数,Moduleimportsproviders 能看出依赖往哪边走。错误还会犯,多数是接错一层,不至于每个文件一种新的脾气。

构造函数里写字段比一层层 ../../../../ 好跟,对人类和编辑器都一样。

我不再纠结算不算魔法,只在乎新来的、审稿的、还有自动补全,是不是在同一个习惯里读这套目录。

生成越快,烂摊子越容易铺开

听上去怪,能力强了本应少管。我这边反正是反过来的,一次多出好几个文件,结构松的话脏东西也一起铺开。同样一个模型,在规矩紧的 Nest + Monorepo 里多半是补边角,在老脚本堆里经常是 import 散了、校验抄三遍、servicecontroller 又掰扯不清。

选型我就问两件事,多文件改完会不会散,下个补丁你能不能猜到哪一层动。Nest 不是唯一答案,只是我默认懒得再赌。

至于 Express、Fastify 裸着写,我见过太多靠自觉最后靠不住。轻量栈写小服务爽快,HonoElysia 我都用,业务一长我还是想有一层大家都认的摆放。AdonisFoalTS 也行,模版和社区我这儿常碰到的是 Nest。

前后端接缝那档子事

语法、SQL、状态码啃得动,烦的是两半各搞各的目录、README、环境变量,改需求前先在心里对一遍口头合同,明明一个东西却干出两份工的感觉。

Nest + Monorepo 不能砍掉后端工作量,只是把缝抹窄一点。

同一个 workspace 改 API 和页面,共享类型和同一条 linttsconfig 脚本,少扯等你发包我先对齐版本的皮。以前在多个仓库里的流程,很多变成同一仓库里自己 refactor。

前端写了多年 TS,后端再随便 any 心就裂着。契约放在 libs/types 或用生成出来的 SDK 锁住一层,漂移少一桩是一桩。

包管理、CI、分支照旧两套角色,但至少不用每次从零切换脑回路。熟了以后,很难再忍受接口栏两头吵。

若以 Next.js App Router 或类似前端为主力,只是把 Nest 当成好好写业务和善后数据的那一半,这一套目录语言其实不难对齐。路由负责入口像 pageservice 像抽出去的 server libpipeinterceptor 像中间件层。端到端类型上,有人喜欢 tRPCzod 推断加共享 router,有人喜欢 OpenAPI 生成 client。任选一条你能长期维护的主线,把契约锁在 libs/types 或生成的 SDK 里,AI 在前端敲 mutationfetch 时少一半凭空造字段。本地开发里,turbo(或等价物)跑 dev,改 shared 类型后两端热更新的节奏,也常和 AI 快速试错一小步合上拍。部署侧很多平台能对 monorepoapp 建制品,我不再想维护两份各写各的环境变量叙事。

审稿比生成更费工夫

现在大家爱讲几秒出一个功能。我自己的账本里,真正决定是否划算的,常常是后面的半小时到一个小时:目录有没有乱跑,边界有没有偷偷改写,类型和数据是否仍对齐,联动测试要不要补。如果生成省下打字时间,却成倍加到梳理结构上,账就对不上了。

Nest + Monorepo 做的很大一部分省事,是把一大批低级争议前置掉——共享字段在哪儿声明,模块职责默认怎样划,接口改了哪些地方按理应当红光报错。于是评审补丁时我更常在盯业务:权限有没有漏网的路径,异常场景会不会把脏数据写进去,性能热点是不是被忽视了,需求语义到底有没有偏差。

我现在的习惯能多懒就多懒,先跑测试和类型检查,再读业务。让 AI 顺手起一版 VitestJeste2e 骨架并不贵,红线测试挂了就先迭代 prompt。绿了再谈边界条件。@Injectable() 的好处是 mock provider 也相对直来直去,审 diff 的人会轻松一点。

以前看 AI 的补丁,像是在考古这东西为何出现在此;现在更多像是在核对这块业务说得圆不圆。这不是神话 AI,只是把本该机械的对齐成本压低了一层。

我没打算一锅炖成巨石

Monorepo 听上去像要把所有东西糊在一起,Nest 又像老派人做的三层后端。我自己的用法其实很土,源码和好改的契约放在一起,发布照样可以按 app 拆开。

  • apps/web 托管前端
  • apps/api 托管主 HTTP 服务
  • apps/worker 托管队列或异步消费者
  • libs/types 承载共享契约
  • libs/ai 承载模型调用、RAG、prompt 组装之类
  • libs/authlibs/common 分摊认证与通用工具

仓库可以统一规范,制品依然可以按 app 构建发布;你可以先把复杂度关在清晰的包里,而不是一开始假装自己永远只需要一个 server.ts。这在 2026 格外常见——队列、异步生成任务、检索、后台配置、审计日志、多租户开关,后来都会陆续冒出来。

CI 里只对改动的 appturbo run test --filter=...@...(或等价过滤)之类,也早已是常规操作。共享代码动了,顺带跑会消费它的那几个 app,而不是每次全矩阵。托管侧不少平台认得 monorepo 根目录,apps/web 走静态或边缘,apps/api 单独开服务。源码和契约仍在一处捏着,生命周期和扩容却可以拆开看,不必心理上先投降成巨石。

Nest 自带 microservices、传输层那一套,真要把 auth 或大活拆出去,也还是在同一套路子里长枝,不用再拍脑袋起一套新目录癖。

我更在意的是:这些东西加进来的时候,是顺着现有的 libs/apps 生长,还是被迫堆出一层新的临时目录。前者不一定优雅,但至少有机会保持可读;后者常常意味着下一次 AI 生成又会发明一种新秩序。

人一多,文件夹比嘴上规矩管用

一个人单挑项目的时候,坏习惯还能靠记忆兜底;两三个人一起用 AI,风格漂移的速度会快得离谱。某人习惯函数式拼接,某人偏爱大类;有人把逻辑黏在 controller,有人把所有东西都塞进 util;几周下来,目录看起来像百家饭拼盘。

Nest + Monorepo 对团队的价值,不在于消灭分歧,而在于把大量本该口头重复的规矩,换成打开仓库就能看见的骨架——新功能默认落在哪个 app,共享代码朝哪个 lib 收敛,鉴权和 DTO 的习惯写法是什么。AI 这时更像在同一套轨道上补齐缺口,而不是每人拉着模型朝不同方向发明范式。

新人上手也会轻松一点:不必先听完三场口头约定才能下手改第一段代码,结构本身就带着大部分的别这么写。这当然不完美,但比纯粹依赖自律省心。

仓库根上挂一份短短的项目说明(例如 AGENTS.md.cursorrules),往往比喊一百句我们风格是这样管用。仓库本身有条理,助手多半把你的效率往上抬。仓库本来就碎,它也会把那种碎法批量复制出去。条目宁可写得具体一点,也别只剩口号。

下面是一段示意,路径和工具名按你们真实栈改即可:

  • 新功能落在 apps/api/src/<domain>/,按 Nest Module 拆分领域,别把所有业务都摊进同一个大目录。
  • 共享类型与契约收口到 libs/types。DTO 一律配 class-validator,并在引导程序里全局启用 ValidationPipe
  • 鉴权走统一的 Guard(或团队约定的同一套切面),不要在每个 Controller 里各写一版 if
  • 跨包只引用对外公开的边界。优先用包名或 workspace: 协议对齐版本。禁止用一连串 ../../../ 掏进别的 apps/* 内部实现。

Claude Code、Cursor 之类读这类说明时会有点用,再配合仓库里实打实的 Nest 目录,跑偏会少一些。

总结

工具换了几轮,差的大头还是仓库难不难翻。多仓切开以后,光看当前窗口很容易蒙,import、字段名、契约各飘各的。拢进一个 workspace,找和改都短一截,TypeScript 报错和测试红条也常比人肉早。

Monorepo 只管东西在一锅里,治不好后端胡写。我上 Nest,图每层有个约定俗成的叫法,新人也好,编辑器补全也好,少走一点冤枉路。

写那几屏幕往往不费多少钟,时间都耗在审稿、对上类型、补测试。目录利落些,才能多在业务和坑上花功夫。

以后要挂队列、worker,鉴权再想拆出去,也愿意顺着现成的包长枝,不想再养一套谁也不知道的新规矩。

我平常就这么默认:Monorepo 先合上上下文,Nest 把后端层压住,剩下的靠习惯和 CI。写得多漂亮不敢说,只希望一群人加机器一起改的时候,烂得慢一点。

Vue Rex: 一个更简单的 Vue 3 请求库

作者 songpeng
2026年5月7日 09:51

Vue Rex:一个更简单的 Vue 3 请求库

它解决什么问题

在 Vue 3 项目里,我们经常要写这样的代码:

const loading = ref(false)
const data = ref<User | null>(null)

const fetchUser = async () => {
  loading.value = true
  try {
    const res = await getUser()
    data.value = res.data  // 每次都要手动提取
  } finally {
    loading.value = false
  }
}
onMounted(fetchUser)

每个接口都要重复这套逻辑:管理 loading、提取数据、处理错误。写多了就会想,能不能把这些重复的东西抽出来?

假设你的后端返回这样的结构:

{
  "code": 0,
  "data": { "id": 1, "name": "张三" },
  "message": "success"
}

用 Vue Rex 的话:

const useApi = createRequest({ dataKey: 'data' })

const { data, loading } = useApi(getUser)
// data.value 直接就是 User 类型,不用手动提取,类型也自动推导好了

Vue Rex 主要解决三个问题:

  1. 自动提取数据:配置一次 dataKey,所有请求自动提取对应字段
  2. 类型自动推导:根据 service 函数返回类型自动推导 data 类型,不需要手动标注泛型
  3. 统一配置:通过工厂函数创建请求实例,全局共享配置

安装

npm install vue-rex
# or
pnpm add vue-rex

推荐的项目结构

第一步:定义 API 层

// api/user.ts
import axios from 'axios'

// 后端统一返回格式
interface ApiResponse<T> {
  code: number
  data: T
  message: string
}

// 用户类型
export interface User {
  id: number
  name: string
  email: string
}

// API 函数
export const getUser = (id: number) => axios.get<ApiResponse<User>(`/api/user/${id}`)

export const getUserList = () => axios.get<ApiResponse<User[]>>('/api/users')

第二步:创建 Hooks 层

// hooks/api.ts
import { createRequest, createPagination } from 'vue-rex'

// 创建请求实例
export const useApi = createRequest({
  dataKey: 'data',
  options: {
    errorRetryCount: 2,
    cacheTime: 5 * 60 * 1000
  }
})

// 创建分页实例
export const usePage = createPagination({
  listKey: 'data.list',
  totalKey: 'data.total'
})

第三步:在组件中使用

<script setup lang="ts">
import { useApi } from '@/hooks/useApi'
import { getUser, getUserList } from '@/api/user'

// 获取单个用户
const { data: user, loading, error, refresh } = useApi(() => getUser(1))
// user 的类型是 Ref<User | undefined>

// 获取用户列表(带缓存)
const { data: users } = useApi(getUserList, {
  cacheKey: 'user-list',
  staleTime: 5000
})
</script>

<template>
  <div v-if="loading">加载中...</div>
  <div v-else-if="error">错误:{{ error.message }}</div>
  <div v-else-if="user">
    <h1>{{ user.name }}</h1>
    <p>{{ user.email }}</p>
    <button @click="refresh">刷新</button>
  </div>
</template>

核心功能详解

自动提取数据 + 类型推导

配置一次 dataKey,之后所有请求都会自动提取对应字段,而且 TypeScript 类型也会自动推导:

interface ApiResponse<T> {
  code: number
  data: T
  message: string
}

interface User {
  id: number
  name: string
}

const getUser = async (): Promise<ApiResponse<User>> => {
  return fetch('/api/user').then(res => res.json())
}

const useApi = createRequest({ dataKey: 'data' })
const { data } = useApi(getUser)
// data 的类型是 Ref<User | undefined>,不需要你手动写泛型

支持深层路径,比如后端返回 { code: 0, result: { data: { list: [...] } } }

const useApi = createRequest({ dataKey: 'result.data.list' })
const { data } = useApi(getUserList)
// data 直接就是 User[] 类型

错误处理

Vue Rex 的 error 来自 service 抛出的 reject。推荐在 axios 拦截器里统一处理业务异常:

// axios 响应拦截器
server.interceptors.response.use(
  (res) => {
    // 业务错误码处理
    if (res.data.code !== 0) {
      return Promise.reject(res.data.message)
    }
    return res.data
  },
  (err) => {
    // 网络错误处理
    return Promise.reject(err)
  }
)

这样 service 成功时直接返回数据,失败时抛出 reject,Vue Rex 的 error 会自动捕获:

const { data, error } = useApi(getUser)
// error.value 是拦截器 reject 的值

自定义错误类型

如果你想统一错误的数据结构,可以通过 errorSerializer 自定义错误类型:

// 定义错误类型
interface AppError {
  code: number
  message: string
}

// 在工厂函数中配置
const useApi = createRequest({
  dataKey: 'data',
  errorSerializer: (e: any): AppError => ({
    code: e?.response?.status ?? -1,
    message: e?.message ?? String(e)
  })
})

const { error } = useApi(getUser)
// error 的类型是 Ref<AppError | undefined>,自动推导的

这样所有通过这个实例创建的请求,error 都会是统一的 AppError 类型。

分页管理

const usePage = createPagination({
  listKey: 'data.list',
  totalKey: 'data.total'
})

const { list, total, page, pageSize } = usePage(getUserPage)

// page 和 pageSize 是 Ref,改了就会自动重新请求
page.value = 2

多后端适配

如果你的项目对接多个后端,每个后端返回的数据结构都不一样:

// 后端 A: { code: 0, data: {...} }
const useApiA = createRequest({ dataKey: 'data' })

// 后端 B: { success: true, result: { data: {...} } }
const useApiB = createRequest({ dataKey: 'result.data' })

// 后端 C: { status: 200, response: { body: {...} } }
const useApiC = createRequest({ dataKey: 'response.body' })

// 组件里用起来都一样
const { data: userA } = useApiA(getUserFromA)
const { data: userB } = useApiB(getUserFromB)
const { data: userC } = useApiC(getUserFromC)
// 三个 data 都是 User 类型

其他功能

缓存

const { data } = useApi(getUserList, {
  cacheKey: 'user-list',
  staleTime: 5000,           // 5秒内直接用缓存
  cacheTime: 10 * 60 * 1000  // 缓存保留10分钟
})

错误重试

const { data } = useApi(getUser, {
  errorRetryCount: 3,
  errorRetryInterval: 1000
})

轮询

const { data } = useApi(getSystemStatus, {
  pollingInterval: 5000,
  pollingWhenDocumentHidden: false
})

依赖请求

当一个请求依赖另一个请求的结果时,可以使用 readywatchSource 配合:

// 请求 A:获取用户列表
const { data: users } = useApi(getUsers)

// 请求 B:依赖请求 A 的结果
const ready = computed(() => !!users.value && users.value.length > 0)

const { data: orders } = useApi(
  () => getUserOrders(users.value[0].id),
  {
    ready,              // 控制请求是否可以发起
    watchSource: users  // 监听 users 变化自动刷新
  }
)

或者使用 watchSource: true 自动收集依赖:

const type = ref(0)

// 自动收集 service 中的响应式依赖
const { data } = useApi(
  () => getLibs(type.value),
  {
    watchSource: true
  }
)

自定义插件

import { definePlugin } from 'vue-rex'

const loggerPlugin = definePlugin((context) => ({
  onBefore() {
    console.log('请求开始', context.params)
  },
  onSuccess(data) {
    console.log('请求成功', data)
  },
  onError(error) {
    console.error('请求失败', error)
  }
}))

const useApi = createRequest({ 
  dataKey: 'data',
  plugins: [loggerPlugin]
})

相关链接


如果 Vue Rex 对你有帮助,欢迎到 GitHub 给个 Star ⭐️ .

Vue3 动态路由踩坑记

作者 Struggle_zy
2026年5月7日 09:18

Vue3 动态路由踩坑记:页面始终显示固定页面(首页内容)的排查之路

一、问题背景

最近在开发一个基于 Vue 3 + Element Plus + Koa 后端的航天管理系统时,遇到了一个令人困扰的问题:无论点击哪个菜单,页面始终显示固定页面,也就是我写的首页内容。

作为一个有经验的前端开发者,我以为这只是一个简单的路由配置问题,但没想到这个问题竟然牵扯出了多个层面的问题。下面我将详细记录整个排查过程,既是项目复盘,也希望能帮到遇到同类问题的开发者。

二、问题现象

核心现象非常典型,具体表现为:

点击菜单 "用户管理" → URL 变为 /system/user → 页面显示 "我是首页内容"
点击菜单 "运载火箭" → URL 变为 /space/rocket → 页面依然显示 "我是首页内容"

关键特征:

  • 路由地址正确变化,说明路由跳转逻辑正常;

  • 页面内容始终不变,始终显示我写的首页内容,说明路由匹配成功,但对应组件未正确渲染;

  • 控制台无明显报错,排查难度增加。

三、项目基础结构

src/
├── router/
│   ├── index.js        # 基础路由 + 路由守卫
│   └── dynamicRoutes.js # 动态路由注册逻辑
├── layout/
│   └── index.vue       # 公共布局(侧边栏 + 主内容区)
├── views/
│   ├── Home.vue        # 首页组件(我写的首页内容)
│   ├── Login.vue       # 登录页组件
│   ├── system/         # 系统管理相关页面
│   ├── space/          # 航天器相关页面
│   └── 其他功能模块页面
├── store/
│   └── index.js        # 用户状态管理(登录、角色等)
└── 其他配置文件

server/
└── routes/
    └── menu.js         # 后端菜单接口,返回动态菜单数据

四、排查过程(从易到难,逐步定位)

第一步:检查基础路由配置

首先排查最基础的静态路由配置,查看 src/router/index\.js

// src/router/index.js (主路由配置)
import { createRouter, createWebHistory } from 'vue-router'
import Layout from '@/layout/index.vue' // 公共布局组件
import { useUserStore } from '@/store/index.js'

// 基础路由
const baseRoutes = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/Login.vue')
  },
  {
    path: '/',
    component: Layout, // 全局唯一布局
    name: 'HomeLayout', // 关键:给父路由命名,用于动态路由挂载
    redirect: '/home',
    children: [
      {
        path: 'home',
        name: 'Home',
        component: () => import('@/views/Home.vue'), // 对应我写的首页内容
        meta: { requiresAuth: true }
      }
    ]
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes: baseRoutes
})

// 路由守卫:权限拦截
router.beforeEach((to, from, next) => {
  const userStore = useUserStore()
  console.log('=== 路由守卫调试 ===')
  console.log('要去的位置:', to.path)
  console.log('用户角色:', userStore.roles)
  console.log('路由元信息:', to.meta)
  
  if (to.path === '/login') {
    next()
    return
  }

  // 检查是否登录
  if (!userStore.isLoggedIn) {
    next('/login')
    return
  }

  // 检查路由权限
  if (to.meta.requiresAuth) {
    // 如果路由需要特定角色
    if (to.meta.roles && to.meta.roles.length > 0) {
      const hasRole = to.meta.roles.some(role => userStore.roles.includes(role))
      if (!hasRole) {
        next('/403') // 无权限页面(可自行实现)
        return
      }
    }
  }

  next()
})

export default router

排查结论:基础路由配置无问题,我写的首页内容能正常显示,登录页跳转正常,路由守卫逻辑严谨,排除基础路由导致的问题。

第二步:检查动态路由注册(核心坑 1)

接下来排查动态路由注册逻辑,查看 src/router/dynamicRoutes\.js 最初的写法:

// 最初的错误写法 ❌
router.addRoute({
  path: child.path,           // 例如:/system/user
  component: Layout,          // 错误:重复创建 Layout 组件
  children: [...]
})

问题原因:

动态路由被注册为 独立的顶层路由,而非挂载到已有的 HomeLayout 父路由下,导致系统中存在多个独立的 Layout 组件,路由匹配混乱,子页面无法嵌入主布局,最终 fallback 到我写的首页内容。

修复方案:将动态路由挂载到已有的 HomeLayout 父路由下(通过父路由 name 挂载):

// 正确写法 ✅
router.addRoute('HomeLayout', {
  path: routePath,
  name: routePath.replace(/\//g, '-'),
  component: () => import(...),
  meta: child.meta
})

第三步:检查组件路径拼接(核心坑 2)

修复路由挂载方式后,问题依然存在,打开控制台发现 Vite 报错:

[plugin:vite:vue] src/views/views/space/rocket.vue At least one <template> or <script> is required

问题原因:组件路径拼接错误,使用相对路径导致路径重复:

// 错误写法 ❌
const importPath = `../views${child.path}.vue`
// child.path = '/space/rocket'
// 最终解析结果:../views/space/rocket.vue → 实际路径为 src/views/views/space/rocket.vue(重复 views)

修复方案:使用项目根目录绝对路径,避免路径解析错误:

// 正确写法 ✅
const importPath = `/src/views${child.path}.vue`

第四步:检查 Vite 动态 import 别名解析(核心坑 3)

尝试使用项目中常用的 @ 别名简化路径,发现动态 import 中别名无法生效:

// 错误写法 ❌ Vite 无法解析动态路径中的 @ 别名
import(`@/views${child.path}.vue`)

// 正确写法 ✅ 使用绝对路径
import(`/src/views${child.path}.vue`)

问题原因:Vite 的别名解析的在静态 import 中能正常工作,但在动态 import 中,由于路径是动态拼接的,无法被 Vite 静态分析,导致别名解析失败,组件加载失败,最终显示我写的首页内容。

解决方案:动态 import 一律使用绝对路径 /src/views/xxx,放弃 @ 别名。

第五步:检查登录后动态路由重新加载(核心坑 4)

修复路径问题后,页面依然不切换,控制台出现 401 错误:

401 Unauthorized - 请求菜单数据被拒绝

问题原因:动态路由最初在应用启动时就加载,但此时用户尚未登录,没有 Token,请求后端菜单接口被拒绝,导致动态路由未成功注册,路由匹配时只能匹配到我写的首页内容。

修复方案:在登录成功后,拿到 Token 再重新初始化加载动态路由:

// Login.vue 登录逻辑(关键代码)
const handleLogin = async () => {
  try {
    await loginFormRef.value.validate()
    loading.value = true

    // 调用登录接口
    const response = await axios.post('http://localhost:3000/api/auth/login', {
      username: loginForm.username,
      password: loginForm.password
    })

    if (response.data.code === 200) {
      // 登录成功,保存用户信息到 store
      userStore.login(response.data.data)
      
      // 设置 axios 默认请求头(携带 Token)
      axios.defaults.headers.common['Authorization'] = `Bearer ${response.data.data.token}`
      
      // 关键:登录后重新加载动态路由
      await initDynamicRoutes(router)
      
      ElMessage.success(response.data.message)
      router.push('/')
    } else {
      ElMessage.error(response.data.message)
    }
  } catch (error) {
    console.error('登录失败:', error)
    const message = error.response?.data?.message || '登录失败,请重试'
    ElMessage.error(message)
  } finally {
    loading.value = false
  }
}

第六步:清理不存在的页面(隐性坑)

最后,排查发现后端返回的菜单数据中,有部分菜单项对应的前端页面并不存在(例如:空间站、卫星等页面),导致动态 import 加载组件失败,路由匹配降级到我写的首页内容。

修复方案:注释掉后端菜单中不存在的菜单项,保证后端菜单路径与前端 views 目录下的文件一一对应:

// server/routes/menu.js - 注释掉不存在的页面
{
  key: '航天器',
  icon: '🚀',
  children: [
    { name: '运载火箭', path: '/space/rocket', meta: { ... } },
    { name: '载人飞船', path: '/space/spaceship', meta: { ... } },
    // { name: '空间站', path: '/space/station', meta: { ... } }, // 不存在,注释
    // { name: '卫星', path: '/space/satellite', meta: { ... } }, // 不存在,注释
  ]
}

五、问题总结(一张表看懂所有坑)

问题现象 根本原因 解决方案
路由跳转页面不变,始终显示我写的首页内容 动态路由注册为顶层路由,重复创建 Layout 将动态路由挂载到父路由 HomeLayout 下
Vite 报错,组件路径重复(views/views) 相对路径拼接错误,导致路径解析异常 使用 /src/views 绝对路径拼接组件路径
动态 import 别名 @ 失效 Vite 无法静态分析动态路径中的别名 放弃别名,统一使用绝对路径导入
加载动态路由接口 401 应用初始化时未登录,无 Token 无法请求菜单 登录成功后再重新加载动态路由
组件加载失败,路由降级到我写的首页内容 后端菜单与前端实际页面文件不匹配 清理无效菜单项,保持路径一致

六、最终完整可运行源码

1. 动态路由注册 src/router/dynamicRoutes.js

import axios from 'axios'
import Layout from '@/layout/index.vue'

export async function initDynamicRoutes(router) {
  console.log('🚀 开始加载动态路由...')
  
  try {
    const token = localStorage.getItem('token')
    console.log('🔑 token:', token ? '存在' : '不存在')
    
    if (token) {
      axios.defaults.headers.common['Authorization'] = `Bearer ${token}`
    }

    console.log('📡 正在请求菜单数据...')
    const res = await axios.get('http://localhost:3000/api/menu/list')
    const menuList = res.data.data
    console.log('📋 获取到菜单数据:', JSON.stringify(menuList, null, 2))

    let count = 0
    menuList.forEach(group => {
      group.children.forEach(child => {
        if (child.path === '/') return

        // 组件路径:绝对路径,避免解析错误
        const importPath = `/src/views${child.path}.vue`
        console.log('📦 组件路径:', importPath)

        // 路由路径(移除开头的 /,避免路由层级错误)
        const routePath = child.path.startsWith('/') ? child.path.slice(1) : child.path
        console.log('🛤️ 路由路径:', routePath)

        // 注册动态路由(挂载到 HomeLayout 父路由下)
        router.addRoute('HomeLayout', {
          path: routePath,
          name: routePath.replace(/\//g, '-'), // 路由 name 去斜杠,避免冲突
          component: () => import(/* @vite-ignore */ importPath).catch(() => {
            console.warn(`⚠️ 组件不存在: ${importPath}`)
            return import('/src/views/Home.vue') // 降级到我写的首页内容
          }),
          meta: child.meta // 继承菜单的权限元信息
        })

        count++
        console.log(`✅ 已注册路由 ${count}:`, routePath)
      })
    })

    const allRoutes = router.getRoutes()
    console.log('📊 所有注册的路由:', allRoutes.map(r => ({ path: r.path, name: r.name, parentName: r.parentName })))
    console.log('✅ 所有动态路由加载完成,共注册', count, '个路由')
    
  } catch (err) {
    console.error('❌ 路由加载失败', err.message || err)
    if (err.response?.status === 401) {
      // 401 清除 Token,跳转登录页
      localStorage.removeItem('token')
      localStorage.removeItem('userInfo')
    }
  }
}

2. 登录页完整代码 Login.vue

<template>
  <div class="login-container">
    <div class="login-form">
      <h2 class="login-title">航天管理系统</h2>
      <el-form
        ref="loginFormRef"
        :model="loginForm"
        :rules="loginRules"
        label-width="0px"
        class="login-form-content"
      >
        <el-form-item prop="username">
          <el-input
            v-model="loginForm.username"
            placeholder="请输入用户名"
            size="large"
            prefix-icon="User"
          />
        </el-form-item>
        <el-form-item prop="password">
          <el-input
            v-model="loginForm.password"
            type="password"
            placeholder="请输入密码"
            size="large"
            prefix-icon="Lock"
          />
        </el-form-item>
        <el-form-item prop="role">
          <el-select
            v-model="loginForm.role"
            placeholder="选择角色"
            size="large"
            style="width: 100%"
          >
            <el-option label="普通用户" value="user" />
            <el-option label="管理员" value="admin" />
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button
            type="primary"
            size="large"
            style="width: 100%"
            :loading="loading"
            @click="handleLogin"
          >
            登录
          </el-button>
        </el-form-item>
      </el-form>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/index.js'
import { initDynamicRoutes } from '@/router/dynamicRoutes.js'
import axios from 'axios'

const router = useRouter()
const userStore = useUserStore()

const loginFormRef = ref()
const loading = ref(false)

const loginForm = reactive({
  username: '',
  password: '',
  role: ''
})

const loginRules = {
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' }
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' }
  ],
  role: [
    { required: true, message: '请选择角色', trigger: 'change' }
  ]
}

const handleLogin = async () => {
  try {
    await loginFormRef.value.validate()
    loading.value = true

    // 调用真实的登录API
    const response = await axios.post('http://localhost:3000/api/auth/login', {
      username: loginForm.username,
      password: loginForm.password
    })
    
    console.log('=== 登录返回数据 ===')
    console.log('完整响应:', response.data)
    console.log('用户数据:', response.data.data)
    
    if (response.data.code === 200) {
      // 登录成功,保存用户信息到store
      userStore.login(response.data.data)
      
      // 设置axios默认header(携带Token)
      axios.defaults.headers.common['Authorization'] = `Bearer ${response.data.data.token}`
      
      // 关键:重新加载动态路由
      await initDynamicRoutes(router)
      
      ElMessage.success(response.data.message)
      router.push('/')
    } else {
      ElMessage.error(response.data.message)
    }
  } catch (error) {
    console.error('登录失败:', error)
    const message = error.response?.data?.message || '登录失败,请重试'
    ElMessage.error(message)
  } finally {
    loading.value = false
  }
}
</script>

<style scoped>
.login-container {
  min-height: 100vh;
  background: linear-gradient(135deg, rgba(8, 19, 47, 0.85), rgba(2, 12, 31, 0.65)), url('/images/1.jpeg') no-repeat center center;
  background-size: cover;
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
  padding: 24px;
}

.login-container::before {
  content: '';
  position: absolute;
  inset: 0;
  background: radial-gradient(circle at top left, rgba(255, 255, 255, 0.14), transparent 30%), radial-gradient(circle at bottom right, rgba(255, 255, 255, 0.08), transparent 25%);
  z-index: 1;
}

.login-form {
  position: relative;
  z-index: 2;
  width: 360px;
  max-width: 100%;
  padding: 12px 38px 22px 38px;
  background: rgba(255, 255, 255, 0.12);
  backdrop-filter: blur(16px);
  border-radius: 24px;
  border: 1px solid rgba(255, 255, 255, 0.5);
  box-shadow: 0 28px 90px rgba(0, 0, 0, 0.22);
}

.login-title {
  text-align: center;
  margin-bottom: 23px;
  color: #1f2a3c;
  font-size: 26px;
  font-weight: 700;
}

.login-form-content {
  max-width: 100%;
}

.el-form-item {
  margin-bottom: 18px;
}

.el-input__inner,
.el-select .el-input__inner {
  background: rgba(255, 255, 255, 0.95);
  border: 1px solid rgba(31, 42, 60, 0.16);
  border-radius: 10px;
  box-shadow: inset 0 1px 2px rgba(31, 42, 60, 0.06);
}

.el-input__inner:focus,
.el-select .el-input__inner:focus {
  border-color: #409eff;
}

.el-input__inner:hover,
.el-select .el-input__inner:hover {
  border-color: rgba(31, 42, 60, 0.24);
}

.el-button {
  border-radius: 10px;
  font-weight: 600;
  background: #409eff;
  border-color: #409eff;
}

.el-button:hover {
  background: #3a8de0;
}
</style>

3. 后端菜单接口 server/routes/menu.js

// server/routes/menu.js - 菜单路由相关
const Router = require('koa-router')

const router = new Router()

// 完整的菜单数据
const allMenus = [
  {
    key: '首页',
    icon: '🏠',
    children: [
      { name: '首页', path: '/', meta: { title: '首页', requiresAuth: false } }
    ]
  },
  {
    key: '航天器',
    icon: '🚀',
    children: [
      { name: '运载火箭', path: '/space/rocket', meta: { title: '运载火箭', requiresAuth: true, roles: ['user', 'admin'] } },
      { name: '载人飞船', path: '/space/spaceship', meta: { title: '载人飞船', requiresAuth: true, roles: ['user', 'admin'] } },
      // { name: '空间站', path: '/space/station', meta: { title: '空间站', requiresAuth: true, roles: ['user', 'admin'] } },
      // { name: '卫星', path: '/space/satellite', meta: { title: '卫星', requiresAuth: true, roles: ['user', 'admin'] } },
      // { name: '探测器', path: '/space/probe', meta: { title: '深空探测器', requiresAuth: true, roles: ['user', 'admin'] } }
    ]
  },
  {
    key: '发射任务',
    icon: '🛸',
    children: [
      { name: '任务规划', path: '/mission/plan', meta: { title: '任务规划', requiresAuth: true, roles: ['user', 'admin'] } },
      { name: '发射记录', path: '/mission/record', meta: { title: '发射记录', requiresAuth: true, roles: ['user', 'admin'] } },
      // { name: '在轨任务', path: '/mission/orbit', meta: { title: '在轨任务', requiresAuth: true, roles: ['user', 'admin'] } },
      // { name: '返回任务', path: '/mission/return', meta: { title: '返回任务', requiresAuth: true, roles: ['user', 'admin'] } }
    ]
  },
  {
    key: '测控通信',
    icon: '📡',
    children: [
      { name: '地面测控', path: '/tracking/ground', meta: { title: '地面测控', requiresAuth: true, roles: ['user', 'admin'] } },
      { name: '航天测控网', path: '/tracking/network', meta: { title: '航天测控网', requiresAuth: true, roles: ['user', 'admin'] } },
      // { name: '数据传输', path: '/tracking/data', meta: { title: '数据传输', requiresAuth: true, roles: ['user', 'admin'] } },
      // { name: '深空通信', path: '/tracking/deepspace', meta: { title: '深空通信', requiresAuth: true, roles: ['user', 'admin'] } }
    ]
  },
  {
    key: '航天员',
    icon: '👨‍🚀',
    children: [
      { name: '航天员列表', path: '/astronaut/list', meta: { title: '航天员列表', requiresAuth: true, roles: ['user', 'admin'] } },
      // { name: '训练计划', path: '/astronaut/training', meta: { title: '训练计划', requiresAuth: true, roles: ['user', 'admin'] } },
      // { name: '出舱活动', path: '/astronaut/eva', meta: { title: '出舱活动', requiresAuth: true, roles: ['user', 'admin'] } },
      // { name: '生活保障', path: '/astronaut/life', meta: { title: '生活保障', requiresAuth: true, roles: ['user', 'admin'] } }
    ]
  },
  {
    key: '科学实验',
    icon: '🔬',
    children: [
      { name: '微重力实验', path: '/experiment/gravity', meta: { title: '微重力实验', requiresAuth: true, roles: ['user', 'admin'] } },
      // { name: '生命科学', path: '/experiment/life', meta: { title: '生命科学', requiresAuth: true, roles: ['user', 'admin'] } },
      // { name: '材料科学', path: '/experiment/material', meta: { title: '材料科学', requiresAuth: true, roles: ['user', 'admin'] } },
      // { name: '天文观测', path: '/experiment/astronomy', meta: { title: '天文观测', requiresAuth: true, roles: ['user', 'admin'] } }
    ]
  },
  {
    key: '系统管理',
    icon: '⚙️',
    children: [
      { name: '用户管理', path: '/system/user', meta: { title: '用户管理', requiresAuth: true, roles: ['admin'] } },
      { name: '角色管理', path: '/system/role', meta: { title: '角色管理', requiresAuth: true, roles: ['admin'] } },
      { name: '菜单管理', path: '/system/menu', meta: { title: '菜单管理', requiresAuth: true, roles: ['admin'] } },
      // { name: '权限管理', path: '/system/permission', meta: { title: '权限管理', requiresAuth: true, roles: ['admin'] } },
      { name: '岗位管理', path: '/system/position', meta: { title: '岗位管理', requiresAuth: true, roles: ['admin'] } }
    ]
  }
]

// 根据用户权限动态返回菜单
router.get('/api/menu/list', async (ctx) => {
  // 从认证中间件获取用户信息(需自行实现认证中间件)
  const userRoles = ctx.state.user?.roles || []

  // 过滤菜单:根据用户角色
  const filteredMenus = allMenus.map(group => {
    const filteredChildren = group.children.filter(child => {
      const meta = child.meta
      // 不需要认证的菜单(如首页)
      if (!meta.requiresAuth) return true
      // 需要认证但没有角色限制的菜单
      if (!meta.roles) return false
      // 检查用户是否有权限访问
      return meta.roles.some(role => userRoles.includes(role))
    })

    // 如果分组下有子菜单,则保留分组;否则过滤掉
    return filteredChildren.length > 0 ? {
      ...group,
      children: filteredChildren
    } : null
  }).filter(Boolean)

  ctx.body = {
    code: 200,
    message: 'success',
    data: filteredMenus
  }
})

module.exports = {
  router,
  allMenus
}

七、经验教训与避坑要点

  1. 路由结构要统一:所有功能页面应共享同一个 Layout 组件,动态路由必须挂载到父路由下,避免重复创建 Layout,导致路由匹配混乱,最终始终显示我写的首页内容。

  2. 动态 import 注意事项:Vite 环境下,动态 import 无法解析@ 别名,使用绝对路径 /src/views/xxx 更可靠,避免路径解析错误,防止组件加载失败后显示首页内容。

  3. 权限相关动态路由:动态路由依赖用户 Token 和菜单接口,必须在登录成功、拿到 Token 后再重新加载,不能在应用初始化时加载,否则动态路由注册失败,会一直显示我写的首页内容。

  4. 数据与文件要同步:后端返回的菜单数据,必须与前端 views 目录下的实际页面文件一一对应,避免出现“菜单存在但页面不存在”的情况,防止组件加载失败降级到首页内容。

  5. 排查技巧:遇到“URL 变、页面不变,始终显示我写的首页内容”的问题,优先排查 3 点:① 动态路由挂载是否正确;② 组件导入路径是否正确;③ 动态路由是否成功注册。

八、结束语

这个看似简单的“页面始终显示我写的首页内容”问题,牵扯到路由配置、动态导入、权限校验、前后端数据同步等多个层面,也让我深刻体会到:前端开发中,细节决定成败。

如果你也遇到了类似的动态路由问题,希望这篇文章能帮你快速定位问题、解决问题,少走弯路。

欢迎在评论区交流探讨,共同进步!🚀

(加油啦~zyy)

Vue 响应式系统源码级剖析:从 Object.defineProperty 到 Proxy

2026年5月7日 09:06

Vue 3 的响应式系统被誉为前端框架的"艺术品"。它如何在数据变化时精准触发视图更新?如何避免不必要的重渲染?

今天,我们不讲表面用法,直接从 V8 引擎的内存布局出发,深度剖析 Vue 响应式系统的底层实现机制。

1. 响应式系统的核心目标

响应式系统的本质是建立一个依赖追踪图(Dependency Graph)

数据变化 → 触发 Getter → 收集依赖 → 执行 Setter → 通知更新 → 视图刷新

难点在于:

  1. 精准收集:只收集真正用到该数据的组件
  2. 高效通知:避免无关组件的重复渲染
  3. 嵌套支持:深层对象、数组的响应式处理

2. Vue 2 方案:Object.defineProperty 的局限

2.1 核心实现

function defineReactive(obj, key, val) {
    const dep = new Dep(); // 依赖收集器
    
    Object.defineProperty(obj, key, {
        get() {
            if (Dep.target) {
                dep.depend(); // 收集当前 Watcher
            }
            return val;
        },
        set(newVal) {
            if (newVal === val) return;
            val = newVal;
            dep.notify(); // 通知所有依赖更新
        }
    });
}

2.2 致命缺陷

问题 原因 影响
无法检测属性新增/删除 Object.defineProperty 只能劫持已存在的属性 需要用 Vue.set
数组变异方法失效 数组索引赋值不会触发 Setter 需要重写 7 个数组方法
递归遍历性能差 初始化时需要深度遍历整个对象树 大型对象卡顿

3. Vue 3 方案:Proxy 的降维打击

3.1 核心实现

function reactive(target) {
    return new Proxy(target, {
        get(target, key, receiver) {
            const res = Reflect.get(target, key, receiver);
            track(target, key); // 收集依赖
            return isObject(res) ? reactive(res) : res;
        },
        set(target, key, value, receiver) {
            const oldValue = target[key];
            const result = Reflect.set(target, key, value, receiver);
            if (oldValue !== value) {
                trigger(target, key); // 触发更新
            }
            return result;
        }
    });
}

3.2 Proxy 的优势

特性 Object.defineProperty Proxy
拦截范围 单个属性 整个对象
新增/删除属性 不支持 ✅ 原生支持
数组索引操作 ❌ 需重写方法 ✅ 原生支持
性能 递归遍历 O(n) 惰性代理 O(1)

4. 依赖收集机制:Dep 与 Watcher 的协作

4.1 Dep(依赖收集器)

class Dep {
    constructor() {
        this.subscribers = new Set(); // 使用 Set 去重
    }
    
    depend() {
        if (Dep.target) {
            this.subscribers.add(Dep.target);
        }
    }
    
    notify() {
        this.subscribers.forEach(watcher => {
            watcher.update();
        });
    }
}

4.2 WeakMap 存储映射

Vue 3 使用 WeakMap 建立数据到依赖的映射:

const targetMap = new WeakMap();

function track(target, key) {
    if (!Dep.target) return;
    
    let depsMap = targetMap.get(target);
    if (!depsMap) {
        depsMap = new Map();
        targetMap.set(target, depsMap);
    }
    
    let dep = depsMap.get(key);
    if (!dep) {
        dep = new Set();
        depsMap.set(key, dep);
    }
    
    dep.add(Dep.target);
}

数据结构

targetMap (WeakMap)
  └─ target (对象)
      └─ depsMap (Map)
          └─ key (属性名)
              └─ dep (Set)
                  └─ effect (副作用函数)

5. 调度系统:异步更新队列

Vue 不会在数据变化时立即更新视图,而是使用异步批处理

const queue = [];
let pending = false;

function queueJob(job) {
    if (!queue.includes(job)) {
        queue.push(job);
    }
    
    if (!pending) {
        pending = true;
        nextTick(flushJobs);
    }
}

function flushJobs() {
    queue.sort((a, b) => a.id - b.id); // 按优先级排序
    
    for (const job of queue) {
        job();
    }
    
    queue.length = 0;
    pending = false;
}

优势

  • 多次数据变化只触发一次渲染
  • 避免中间状态导致的闪烁
  • 按优先级排序,确保父子组件更新顺序

6. 计算属性与侦听器:衍生状态处理

6.1 Computed(惰性求值)

function computed(getter) {
    let value;
    let dirty = true;
    
    const runner = effect(getter, {
        scheduler: () => {
            dirty = true; // 标记为脏数据
        }
    });
    
    return {
        get value() {
            if (dirty) {
                value = runner();
                dirty = false;
            }
            return value;
        }
    };
}

核心机制

  • 只有在访问时才计算(惰性)
  • 依赖变化时标记 dirty,下次访问重新计算
  • 避免不必要的重复计算

6.2 Watch(主动侦听)

function watch(source, callback) {
    const getter = () => traverse(source);
    
    effect(getter, {
        scheduler: () => {
            callback(getter());
        }
    });
}

7. 工业界实战:性能优化技巧

7.1 markRaw(跳过响应式)

const rawObj = markRaw({ /* 大型数据 */ });

不需要响应式的对象(如图表实例、第三方库对象),用 markRaw 标记,避免 Proxy 开销。

7.2 shallowReactive(浅层响应式)

const state = shallowReactive({
    nested: { deep: { value: 1 } }
});

只代理第一层,嵌套对象保持原始引用,减少内存占用。

7.3 冻结对象优化

const constant = Object.freeze({ /* 常量配置 */ });

Vue 会自动跳过已冻结的对象,不会进行响应式转换。

8. 面试考点

Q1: Vue 2 为什么无法检测对象属性的新增?

A: Object.defineProperty 只能劫持对象上已存在的属性。新增属性时没有 Getter/Setter,需要在初始化时递归遍历所有属性,动态新增的属性无法被劫持。

Q2: Proxy 为什么比 Object.defineProperty 性能好?

A: Proxy 是惰性代理,只有在访问属性时才递归代理子对象。而 Object.defineProperty 在初始化时需要完整遍历整个对象树,时间复杂度 O(n)。

Q3: Vue 3 的依赖收集用了什么数据结构?

A: 使用 WeakMap → Map → Set 三层映射。targetMap(WeakMap)存储目标对象,depsMap(Map)存储属性名,dep(Set)存储副作用函数。使用 Set 自动去重。

9. 总结

Vue 响应式系统的核心设计:

  1. 数据劫持:Proxy 拦截属性访问
  2. 依赖收集:WeakMap 建立映射关系
  3. 副作用调度:异步队列批量更新
  4. 惰性求值:Computed 避免重复计算

这套系统不仅是 Vue 的核心,更是响应式编程范式的经典实现。理解它,你就掌握了现代前端框架的精髓。


💡 提示:  完整源码解析(含 Dep/Watcher 实现)已开源到 GitHub。

如果你觉得这篇关于"Vue 底层原理"的文章对你有帮助,欢迎点赞收藏!

Vite4.x+打包优化实战指南(无冗余):从体积到速度,一文吃透所有技巧

2026年5月7日 08:09

Vite凭借ESBuild预构建与原生ESM支持,天生具备高性能优势,开发环境下的秒级启动、极速热更新体验深受前端开发者青睐。但随着项目规模扩大、第三方依赖增多,极易出现打包体积臃肿、构建耗时增加、首屏加载延迟等问题。不同于Webpack的构建逻辑,Vite的打包优化需围绕其“开发环境ESBuild、生产环境Rollup”的双引擎架构展开,核心目标是「精简产物体积、提升构建速度、优化加载性能」。以下是全维度实操优化方案,适配Vite4.x及以上版本,所有配置均可直接复制到项目中落地,无需额外修改。

一、前置:精准定位打包瓶颈(避免盲目优化)

优化前需先通过工具定位核心问题(如超大体积依赖、冗余资源、构建耗时瓶颈),避免盲目配置造成无效消耗。推荐2个零成本排查工具,快速锁定优化重点,提升优化效率。

1. 打包体积分析(rollup-plugin-visualizer)

该插件可可视化展示打包后各文件、第三方依赖的体积占比,能精准定位体积过大的模块,是精简打包体积的核心工具,新手也能快速上手。

# 安装依赖(仅开发环境需安装)
npm install rollup-plugin-visualizer -D
# 或使用yarn安装
yarn add rollup-plugin-visualizer -D
// vite.config.js 核心配置(直接复制可用)
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig({
  plugins: [
    vue(),
    // 打包体积可视化配置
    visualizer({
      open: true, // 打包完成后自动打开可视化分析页面
      gzipSize: true, // 显示gzip压缩后的体积(更贴近生产环境实际体积)
      brotliSize: true, // 显示brotli压缩后的体积(压缩率更高,参考价值更大)
      filename: 'stats.html' // 生成的分析文件名称,默认存放在项目根目录
    })
  ]
})

执行npm run build命令后,项目根目录会自动生成stats.html文件,打开该文件即可清晰查看各依赖、组件的体积占比。建议重点关注体积超过100KB的模块,优先进行优化,性价比最高。

2. 构建速度分析(--profile参数)

借助Vite自带的--profile参数,可生成Rollup构建性能分析报告,精准定位构建过程中耗时最长的环节(如依赖处理、资源压缩、插件执行等),针对性优化更高效。

# 在package.json中添加构建速度分析脚本
"scripts": {
  "build:profile": "vite build --profile" // 生成性能分析报告
}

# 执行命令,生成profile-xxx.json格式的分析报告
npm run build:profile

注意:原文档中推荐的Rollup Analyzer网页(rollupjs.org/analyzer/)目…

二、核心优化:减小打包体积(提升加载速度)

打包体积过大是导致首屏加载缓慢的主要原因,核心优化方向围绕「剔除冗余代码、压缩静态资源、合理分包拆分」展开,从源头精简产物体积,提升页面加载效率。

1. 基础配置优化(vite.config.js核心配置)

通过Vite的build配置,开启基础压缩、禁用无用功能,无需额外安装插件,即可快速减小打包体积,是所有Vite项目的必做优化,上手门槛极低。

export default defineConfig({
  build: {
    // 1. 禁用生产环境源码映射(大幅减小体积,上线无需调试源码,必做)
    sourcemap: false,
    // 2. 开启代码压缩(默认启用esbuild,速度比terser快10倍以上;追求极致体积可改用terser)
    minify: 'esbuild',
    // 3. 设置打包目标环境,移除无用语法(适配主流浏览器,避免冗余兼容代码)
    target: 'es2015',
    // 4. 静态资源优化:小于4kb的资源转为base64,减少HTTP请求次数
    assetsInlineLimit: 4096, // 单位:bytes,默认4kb,无需随意修改
    // 5. 规范静态资源输出目录,便于后续CDN配置和项目维护
    assetsDir: 'static/assets',
    // 6. 分包策略:拆分大型依赖,提升浏览器缓存命中率(核心优化)
    rollupOptions: {
      output: {
        // 手动分包:将第三方依赖拆分到单独chunk,避免主包过大
        manualChunks: {
          // 把vue相关核心依赖打包为一个chunk(不常更新,可长期缓存)
          vueVendor: ['vue', 'vue-router', 'pinia'],
          // 把工具类依赖打包为一个chunk
          utils: ['axios', 'lodash-es'],
          // 把UI库单独打包(如Element Plus、Ant Design Vue,体积较大)
          ui: ['element-plus']
        }
      }
    }
  }
})

关键说明:manualChunks分包策略可根据项目实际依赖灵活调整,核心逻辑是将“不常更新的第三方依赖”与“频繁迭代的业务代码”拆分。这样用户二次访问时,可直接从浏览器缓存中读取第三方依赖chunk,无需重新下载,大幅提升加载速度。

2. 静态资源优化(图片、字体、CSS)

静态资源(尤其是图片)通常占打包体积的60%以上,是体积优化的重点。优化核心的是「压缩体积、优化格式、合理缓存」,兼顾加载速度和视觉体验。

(1)图片优化(vite-plugin-imagemin)

该插件可自动压缩图片体积,支持WebP、Avif等现代图片格式,在不影响视觉效果的前提下,可将图片体积缩减30%-50%,适配所有主流项目。

# 安装图片压缩插件(仅开发环境需安装)
npm install vite-plugin-imagemin -D
// vite.config.js 配置(直接复制可用)
import viteImagemin from 'vite-plugin-imagemin'

export default defineConfig({
  plugins: [
    vue(),
    viteImagemin({
      // 不同图片格式的针对性压缩配置,平衡速度与体积
      gifsicle: { optimizationLevel: 3 }, // GIF压缩,等级1-33为最优压缩
      optipng: { optimizationLevel: 3 }, // PNG压缩,等级0-73平衡速度与体积
      mozjpeg: { quality: 80 }, // JPG压缩,质量70-9080为最佳视觉与体积平衡
      webp: { quality: 80 }, // WebP压缩,自动将JPG/PNG转为WebP格式
      avif: { quality: 80 } // Avif压缩,比WebP体积更小,兼容性略差(可选)
    })
  ]
})

(2)字体资源优化

字体文件通常体积较大,若全量打包会大幅增加产物体积,可通过“按需引入、格式转换、CDN引入”三种方式优化,兼顾性能与体验。

  • 按需引入:仅引入项目中实际使用的字体权重(如400、500)和字符(如中文仅引入常用3000个字符),剔除无用字符;
  • 格式转换:将TTF格式字体转为WOFF2格式,体积比TTF小40%以上,支持所有主流浏览器(IE除外);
  • CDN引入:将思源黑体、Roboto等常用字体通过CDN引入,避免打包到项目中,减少体积占用。

(3)CSS优化

核心目标是剔除未使用的CSS代码,减少样式文件体积,主要依赖unplugin-vue-components(自动按需引入组件样式)和purgecss(剔除全局无用CSS),配置后无需手动管理样式引入。

# 安装依赖(仅开发环境需安装)
npm install unplugin-vue-components purgecss-plugin-vite -D
// vite.config.js 配置(直接复制可用)
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import PurgeCSSPlugin from 'purgecss-plugin-vite'

export default defineConfig({
  plugins: [
    vue(),
    // 自动导入Vue API和组件,按需引入对应样式,避免全量引入
    AutoImport({
      resolvers: [ElementPlusResolver()],
      imports: ['vue', 'vue-router', 'pinia'] // 按需导入常用API
    }),
    Components({
      resolvers: [ElementPlusResolver()] // 自动按需引入UI组件及样式(以Element Plus为例)
    }),
    // 剔除未使用的CSS(仅生产环境生效,避免开发环境样式异常)
    PurgeCSSPlugin({
      content: ['./index.html', './src/**/*.vue'], // 扫描需要保留的CSS选择器
      variables: true, // 保留CSS变量,避免样式异常
      safelist: {
        standard: ['html', 'body'] // 强制保留的基础选择器,避免全局样式丢失
      }
    })
  ],
  // 禁用CSS源码映射(开发环境无需调试可关闭,减少体积)
  css: {
    devSourcemap: false
  }
})

3. 依赖优化(剔除冗余,减少打包体积)

第三方依赖是导致打包体积臃肿的主要原因之一,核心优化方向是「按需引入、轻量替代、CDN外链」,从源头减少冗余依赖,兼顾性能与开发效率。

(1)按需引入第三方依赖

对于Element Plus、Ant Design Vue、ECharts等大型第三方依赖,严禁全量引入,仅引入项目中实际使用的组件和API,可大幅减少冗余代码。

以Element Plus为例:配合上文CSS优化中的unplugin-vue-components插件,无需手动引入组件和样式,直接在组件中使用即可,打包时会自动剔除未使用的组件和样式,无需额外配置。

(2)轻量依赖替代

替换体积较大的依赖,用轻量级库实现相同功能,从源头减小打包体积,推荐以下常用替代方案(API基本一致,无需修改业务代码):

  • lodash → lodash-es(支持Tree-Shaking,可按需导入单个方法,避免全量打包);
  • moment.js → dayjs(体积仅2KB,比moment.js小80%+,API完全一致,无缝替换);
  • axios → ky(体积更小,支持Promise,API更简洁,适配现代项目);
  • echarts → chart.js(轻量级图表库,适合简单可视化场景,体积仅为echarts的1/3)。

(3)CDN外链引入公共依赖

将Vue、Vue Router、Pinia等不常更新的公共依赖,通过CDN外链引入,避免打包到项目中,可大幅减小主包体积,同时利用CDN的分布式节点提升加载速度。

注意:原文档中推荐的3个CDN链接(Vue、Vue Router、Pinia),其中Vue Router和Pinia的CDN文件存在字数超限问题,Vue的CDN文件可正常使用,以下优化配置可直接落地,同时规避链接异常问题。

// vite.config.js 配置(优化后,规避CDN链接异常)
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { vitePluginForCDN } from 'vite-plugin-cdn-import'

export default defineConfig({
  plugins: [
    vue(),
    vitePluginForCDN({
      // 配置需要CDN引入的依赖(选用稳定可访问的CDN链接)
      modules: [
        {
          name: 'vue',
          var: 'Vue', // 全局变量名,需与CDN文件暴露的变量一致
          path: 'https://cdn.bootcdn.net/ajax/libs/vue/3.3.4/vue.global.prod.js' // 可正常访问
        },
        {
          name: 'vue-router',
          var: 'VueRouter',
          path: 'https://cdn.jsdelivr.net/npm/vue-router@4.2.5/dist/vue-router.global.prod.js' // 替代链接,稳定可访问
        },
        {
          name: 'pinia',
          var: 'Pinia',
          path: 'https://cdn.jsdelivr.net/npm/pinia@2.1.7/dist/pinia.iife.prod.js' // 替代链接,稳定可访问
        }
      ]
    })
  ],
  // 排除CDN引入的依赖,避免重复打包(必配,否则会出现重复引入问题)
  build: {
    rollupOptions: {
      external: ['vue', 'vue-router', 'pinia']
    }
  }
})

4. 开启Gzip/Brotli压缩(大幅减小体积)

通过插件生成Gzip、Brotli格式的压缩资源,配合Nginx服务器配置启用压缩,可将资源体积缩减60%-80%,是生产环境必做的优化,零开发成本,收益显著。

# 安装压缩插件(仅开发环境需安装)
npm install vite-plugin-compression -D
// vite.config.js 配置(直接复制可用)
import viteCompression from 'vite-plugin-compression'

export default defineConfig({
  plugins: [
    vue(),
    // 开启Gzip压缩(兼容性好,所有主流浏览器均支持,推荐优先启用)
    viteCompression({
      algorithm: 'gzip', // 压缩算法
      threshold: 10240, // 大于10KB的文件才压缩(避免小文件压缩后体积反而变大)
      deleteOriginFile: false // 不删除源文件,避免部署时出现资源缺失问题
    }),
    // 开启Brotli压缩(压缩率更高,优先使用,需服务器支持Brotli模块)
    viteCompression({
      algorithm: 'brotliCompress',
      threshold: 10240,
      deleteOriginFile: false
    })
  ]
})

补充:Nginx需配置对应压缩规则,才能让浏览器加载压缩后的资源,以下是生产环境通用配置示例,直接复制到Nginx配置文件即可:

server {
  # Gzip压缩配置(必配)
  gzip on; # 开启Gzip压缩
  gzip_types text/plain text/css application/javascript image/svg+xml; # 需压缩的资源类型
  gzip_min_length 10k; # 小于10KB的文件不压缩
  gzip_comp_level 6; # 压缩等级1-9,6为平衡速度与压缩率的最佳值

  # Brotli压缩配置(可选,需安装ngx_brotli模块)
  brotli on; # 开启Brotli压缩
  brotli_types text/plain text/css application/javascript image/svg+xml; # 需压缩的资源类型
  brotli_min_length 10k; # 小于10KB的文件不压缩
  brotli_comp_level 6; # 压缩等级1-11,6为最佳平衡值
}

三、进阶优化:提升打包速度(减少构建耗时)

对于大型项目(代码量10万行+、依赖较多),打包耗时过长会严重影响开发效率。核心优化方向是「优化依赖预构建、利用缓存机制、减少不必要的插件处理」,大幅缩短构建时间。

1. 优化依赖预构建(optimizeDeps配置)

依赖预构建是Vite提升启动和打包速度的核心机制,它会通过ESBuild将CommonJS/UMD格式的依赖转为ESM格式,避免浏览器处理复杂依赖树。通过optimizeDeps配置,可进一步提升预构建效率,解决部分依赖未被自动检测的问题。

export default defineConfig({
  // 依赖预构建优化(直接复制可用)
  optimizeDeps: {
    // 1. 强制预构建指定依赖(解决部分依赖未被Vite自动检测、预构建失败的问题)
    include: ['axios', 'echarts', 'lodash-es'],
    // 2. 排除无需预构建的依赖(本身就是ESM格式,避免重复构建,节省时间)
    exclude: ['vue', 'vue-router'],
    // 3. 自定义ESBuild选项,提升预构建速度,适配现代浏览器
    esbuildOptions: {
      target: 'es2020'
    }
  }
})

关键说明:Vite会将预构建结果缓存到node_modules/.vite目录,只有依赖变更或配置修改时才会重新构建。若遇到预构建异常,可删除该目录,重新执行打包命令,即可强制重新预构建。

2. 利用缓存机制(提升二次构建速度)

通过配置缓存目录,让Vite缓存构建结果,二次打包时可直接复用缓存,大幅减少构建耗时,尤其适合大型项目和频繁打包的场景,可将二次构建速度提升60%+。

export default defineConfig({
  // 自定义缓存目录(默认是node_modules/.vite,可自定义路径)
  cacheDir: './.vite_cache',
  // 启用文件系统缓存(开发环境和生产环境均生效,必配)
  server: {
    fsCache: true
  },
  // 生产环境构建缓存(Vite 4.0+ 支持,进一步提升生产打包速度)
  build: {
    cache: {
      type: 'filesystem' // 基于文件系统的缓存,稳定可靠
    }
  }
})

补充:Docker环境中部署项目时,可将缓存目录挂载为Volume,避免每次重建容器时丢失缓存,进一步提升构建效率,减少部署时间。

3. 插件优化(减少不必要的插件处理)

过多的插件会增加构建耗时,甚至出现插件冲突问题。优化核心是“按环境区分插件”,避免开发环境插件在生产环境生效,同时剔除无用插件,精简插件执行流程。

// 按环境区分插件,减少生产环境插件开销(直接复制可用)
export default defineConfig(({ mode }) => {
  const isProd = mode === 'production' // 判断当前环境是否为生产环境
  return {
    plugins: [
      vue(), // 所有环境都需要启用的核心插件
      // 生产环境才启用的插件(压缩、打包分析等,开发环境无需加载)
      ...(isProd ? [
        viteImagemin({ /* 图片压缩配置,参考上文 */ }),
        viteCompression({ /* 压缩配置,参考上文 */ }),
        visualizer({ /* 体积分析配置,参考上文 */ })
      ] : []),
      // 开发环境才启用的插件(热更新、调试等,生产环境无需加载)
      ...(isProd ? [] : [
        // 示例:开发环境调试插件(仅开发时使用,生产环境剔除)
        require('vite-plugin-debug').default()
      ])
    ]
  }
})

关键说明:部分插件可通过enforce: 'post'延迟执行,避免阻塞核心构建流程。例如图片压缩插件,可设置enforce: 'post',让其在代码打包完成后再处理图片,提升整体构建速度。

4. 并行化编译(利用多线程提升速度)

启用Rollup的多线程编译,充分利用CPU多核优势,提升代码转译和压缩速度,需Node.js v12及以上版本支持,大型项目收益显著。

# 安装多线程插件(仅开发环境需安装)
npm install @rollup/plugin-dynamic-import-vars -D
// vite.config.js 配置(直接复制可用)
import dynamicImportVariables from '@rollup/plugin-dynamic-import-vars'

export default defineConfig({
  plugins: [
    vue(),
    dynamicImportVariables({
      workers: true // 启用多线程编译,自动利用CPU多核资源
    })
  ]
})

四、避坑指南(避免优化失效或性能倒退)

  • 坑1:过度配置alias导致路径解析缓慢 解决方案:仅配置核心目录别名(如@对应src),避免配置过多无用别名,增加Vite路径解析开销,反而降低构建速度。
  • 坑2:assetsInlineLimit设置过小/过大 解决方案:默认4kb即可,无需随意修改。设置过小会增加HTTP请求次数,设置过大会导致JS/CSS文件体积暴增,反而影响首屏加载速度。
  • 坑3:CDN引入依赖后,项目报错“Vue is not defined” 解决方案:① 确保CDN资源引入顺序正确(先引入Vue,再引入Vue Router、Pinia等依赖);② 检查rollupOptions.external配置,确保配置的依赖名与CDN文件暴露的全局变量名一致。
  • 坑4:Tree-Shaking不生效,未使用的代码未被剔除 解决方案:① 确保项目package.json中添加"type": "module"(启用ESM模块规范);② 避免使用CommonJS语法(require),全部使用ES模块语法(import/export);③ 确保依赖本身支持Tree-Shaking(如优先使用lodash-es而非lodash)。
  • 坑5:Linux环境下Vite因ENOSPC错误崩溃 解决方案:项目文件过多超出系统文件监听器限制,执行命令sudo sysctl fs.inotify.max_user_watches=524288临时解决;若需永久生效,需修改/etc/sysctl.conf文件,添加对应配置并执行sudo sysctl -p生效。
  • 坑6:CDN链接异常导致项目加载失败 解决方案:若遇到CDN链接字数超限、无法访问的问题,可替换为.jsdelivr.net等稳定CDN源,如上文Vue Router、Pinia的CDN替代链接,确保资源可正常加载。
  • 坑7:Rollup Analyzer网页解析失败无法使用 解决方案:暂用替代方案,将build:profile生成的JSON报告导入rollup-plugin-visualizer生成的stats.html页面,或使用Chrome开发者工具的Performance面板分析构建耗时。

五、优化优先级建议(快速落地,高效提升)

无需一次性实施所有优化方案,建议优先落地“低成本、高收益”的方案,快速提升项目性能,再逐步推进进阶优化,平衡优化成本与收益。

  1. 必做(零成本/低成本,收益显著,优先落地):关闭sourcemap、开启esbuild压缩、配置manualChunks分包、图片压缩;
  2. 推荐(中等成本,收益较高,逐步落地):按需引入依赖、开启Gzip/Brotli压缩、利用缓存机制;
  3. 进阶(高成本,按需落地):CDN引入公共依赖、并行化编译、插件精细化配置。

六、总结

Vite打包优化的核心逻辑是「按需与分治」:按需处理依赖和资源,剔除冗余代码,避免无效体积占用;分治拆分代码和资源,提升浏览器缓存命中率,减少重复加载。不同于Webpack,Vite的优化需充分利用其ESBuild和Rollup双引擎的优势,重点围绕“体积、速度、加载”三个核心维度展开。

实际项目中,建议先通过rollup-plugin-visualizer--profile参数定位瓶颈,再针对性实施优化方案。优化后可通过Lighthouse、Chrome DevTools等工具验证效果,目标为:首屏加载时间≤2秒,LCP(最大内容绘制)≤2.5秒。本文所有方案均经过实战验证,可直接复制到项目中落地,轻松实现打包体积缩减50%+、构建速度提升60%+,兼顾开发效率与用户体验。

Vue十万条数据渲染无卡顿!3种工业级方案(附可复制代码+避坑指南)

2026年5月7日 08:08

Vue渲染十万条数据的核心痛点的是:一次性渲染大量DOM节点,导致浏览器重排重绘频繁、内存占用飙升,最终出现页面卡顿、白屏甚至崩溃。常规的v-for直接渲染十万条数据,会瞬间创建十万个DOM元素,完全超出浏览器承载能力,因此必须通过“减少DOM数量、分批渲染、优化渲染机制”三大核心思路,实现无卡顿渲染。本文结合Vue2/Vue3实操,提供3种主流方案,覆盖不同场景,所有代码可直接复制落地,并补充详细项目落地细节,解决实际开发中的各类问题。

一、核心前提:为什么直接渲染会卡顿?

浏览器的DOM渲染能力有限,通常单个页面承载的DOM节点建议不超过1000个,当一次性渲染十万条数据时:

  • DOM节点暴增:十万条数据对应十万个DOM元素,占用大量内存,导致浏览器处理缓慢;
  • 重排重绘频繁:Vue的响应式机制会批量更新DOM,但十万条数据的更新仍会触发多次重排重绘,导致页面卡顿;
  • 渲染阻塞:JS执行与DOM渲染是单线程阻塞的,渲染十万条数据会阻塞主线程,导致页面无响应。

因此,优化的核心逻辑是:不一次性渲染所有数据,只渲染当前可视区域的数据,或分批渲染数据,减少DOM节点数量,降低浏览器压力

二、方案1:虚拟列表(首选,工业级方案,无卡顿)

1. 核心原理

虚拟列表(Virtual List)是渲染大量数据的最优方案,核心逻辑是:只渲染当前浏览器可视区域内的列表项,可视区域外的列表项不渲染(或销毁),通过滚动事件动态切换可视区域内的内容,实现“十万条数据只渲染几十条DOM”,彻底解决卡顿问题。

关键思路:计算可视区域高度、单个列表项高度,确定可视区域内可显示的列表项数量,通过滚动偏移量,动态计算需要渲染的列表项范围,实现“滚动时动态替换渲染内容”。

2. 实操实现(Vue3+第三方插件,最简单落地)

推荐使用成熟的虚拟列表插件(vue-virtual-scroller),无需手动计算滚动逻辑,开箱即用,适配Vue2/Vue3,支持动态高度、下拉加载等功能。以下补充完整项目落地细节,覆盖依赖配置、异常处理、兼容适配等实际开发场景。

步骤1:安装插件(落地细节:版本适配+异常处理)

// Vue3安装(适配Vue3.0+,推荐版本2.0.0+,避免版本兼容问题)
npm install vue-virtual-scroller@next --save
// 若安装失败,可使用cnpm或yarn替代
cnpm install vue-virtual-scroller@next --save
yarn add vue-virtual-scroller@next

// Vue2安装(适配Vue2.6+,推荐版本1.0.10+)
npm install vue-virtual-scroller@1.0.10 --save
// 安装后若出现依赖报错,需安装@vue/composition-api(Vue2适配composition-api)
npm install @vue/composition-api --save

落地细节补充:安装完成后,需检查package.json中插件版本,确保与Vue版本匹配(Vue3对应@next版本,Vue2对应1.x版本);若Vue2项目中使用,需在main.js中先引入@vue/composition-api,再引入虚拟列表插件,否则会出现报错。

步骤2:全局注册(main.ts,落地细节:全局配置+按需引入)

// Vue3(完整注册,包含全局配置,适配多场景)
import { createApp } from 'vue';
import App from './App.vue';
import VueVirtualScroller from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'; // 必须引入样式,否则渲染错乱

const app = createApp(App);
// 全局配置虚拟列表,优化性能(可选,根据项目需求调整)
app.use(VueVirtualScroller, {
  itemSize: 50, // 全局默认单个列表项高度,避免每个页面重复设置
  buffer: 200, // 可视区域上下缓冲高度,减少滚动时的空白闪烁
  windowResizeDebounce: 100 // 窗口 resize 防抖时间,优化窗口缩放时的渲染性能
});
app.mount('#app');

// Vue2(适配Vue2,需先引入composition-api)
import Vue from 'vue';
import VueCompositionAPI from '@vue/composition-api';
import VueVirtualScroller from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';

Vue.use(VueCompositionAPI);
Vue.use(VueVirtualScroller, {
  itemSize: 50,
  buffer: 200
});
new Vue({
  el: '#app',
  render: h => h(App)
});

落地细节补充:1. 样式文件必须引入,否则会出现列表项重叠、滚动异常等问题;2. 全局配置的itemSize可被页面局部配置覆盖,适合项目中列表项高度统一的场景;3. buffer缓冲高度建议设置为200-300px,缓冲区域会提前渲染,避免滚动时出现空白闪烁,提升用户体验。

步骤3:页面使用(核心代码,落地细节:异常处理+数据适配+交互优化)

<template>
  <div class="virtual-list-container" style="height: 500px; overflow-y: auto; border: 1px solid #eee;"&gt;
    <!-- 虚拟列表组件补充异常处理模板-->
    <RecycleScroller
      class="scroller"
      :items="bigList" // 十万条数据数组支持响应式更新:item-size="50" // 单个列表项固定高度与样式一致key-field="id" // 列表项唯一标识必须建议用后端返回的唯一ID:buffer="200" // 局部缓冲配置覆盖全局配置
      @scroll="handleScroll" // 滚动事件可用于埋点下拉加载等
    &gt;
      <!-- 列表项模板优化结构避免复杂嵌套-->
      <template #default="{ item }">
        <div class="list-item" @click="handleItemClick(item)">
          <span class="item-id">{{ item.id }}</span>
          <span class="item-name">{{ item.name }}</span>
          <span class="item-content">{{ item.content }}</span>
        </div>
      &lt;/template&gt;
      <!-- 空数据模板(落地必备,避免无数据时空白) -->
      <template #empty>
        <div class="empty-tip">暂无数据</div>
      &lt;/template&gt;
      <!-- 加载中模板(适配数据接口请求场景) -->
      <template #loading>
        <div class="loading-tip">数据加载中...</div>
      </template>
    </RecycleScroller>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
// 引入接口请求函数(模拟实际项目接口请求)
import { getBigList } from '@/api/data';

// 十万条数据数组(响应式)
const bigList = ref([]);
// 加载状态(用于接口请求时的loading提示)
const isLoading = ref(false);
// 滚动偏移量(可选,用于埋点或滚动位置记录)
const scrollTop = ref(0);

// 生成测试数据(模拟接口返回,实际项目替换为接口请求)
const generateData = () => {
  const data = [];
  for (let i = 1; i <= 100000; i++) {
    data.push({
      id: i, // 唯一标识,建议用后端返回的ID,避免重复
      name: `测试数据${i}`,
      content: `这是Vue渲染十万条数据的测试内容,序号${i}`
    });
  }
  return data;
};

// 列表项点击事件(落地必备,处理交互逻辑)
const handleItemClick = (item) => {
  console.log('当前点击项:', item);
  // 实际项目中可跳转详情页、弹窗等操作
};

// 滚动事件(可选,用于埋点、滚动位置保存)
const handleScroll = (e) => {
  scrollTop.value = e.target.scrollTop;
  // 埋点示例:记录用户滚动深度
  // trackEvent('virtual_list', 'scroll', 'scroll_depth', scrollTop.value);
};

// 页面挂载后初始化数据(落地细节:接口请求+异常捕获+内存优化)
onMounted(async () => {
  try {
    isLoading.value = true;
    // 实际项目中替换为接口请求,避免前端一次性生成大量数据(节省前端内存)
    // const res = await getBigList(); // 接口请求十万条数据(建议后端分批返回,前端拼接)
    // bigList.value = Object.freeze(res.data); // 静态数据冻结,减少响应式开销
    bigList.value = Object.freeze(generateData()); // 模拟接口返回,冻结数据
  } catch (error) {
    console.error('数据加载失败:', error);
    // 异常处理:加载失败提示,可提供重试按钮
    ElMessage.error('数据加载失败,请重试');
  } finally {
    isLoading.value = false;
  }
});

// 组件卸载时清理数据(落地细节:内存释放,避免内存泄漏)
onUnmounted(() => {
  bigList.value = [];
  scrollTop.value = 0;
});
</script>

<style scoped>
.scroller {
  height: 100%;
}
.list-item {
  height: 50px; // 与item-size严格一致,避免渲染错乱
  line-height: 50px;
  border-bottom: 1px solid #eee;
  padding: 0 20px;
  display: flex;
  align-items: center;
  cursor: pointer;
}
.list-item:hover {
  background-color: #f5f5f5; // 优化交互体验, hover效果
}
.item-id {
  width: 80px;
  color: #666;
}
.item-name {
  width: 150px;
  font-weight: 500;
}
.item-content {
  flex: 1;
  color: #999;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap; // 避免内容换行,导致列表项高度变化
}
.empty-tip, .loading-tip {
  text-align: center;
  padding: 20px;
  color: #666;
}
</style>

落地细节补充:1. 数据处理:实际项目中,十万条数据建议由后端分批返回(如每次返回1000条),前端通过下拉加载拼接数据,避免前端一次性生成大量数据导致内存占用过高;2. 异常处理:添加接口请求异常捕获、空数据提示、加载失败重试机制,提升用户体验;3. 内存优化:组件卸载时清空数据,静态数据使用Object.freeze()冻结,减少Vue响应式监听开销;4. 交互优化:添加列表项hover效果、点击事件,内容超出部分省略,避免列表项高度变化导致渲染错乱。

3. 关键优化点

  • 固定列表项高度:item-size需与列表项实际高度一致,避免虚拟列表计算偏移量出错,导致渲染错乱;若列表项高度不固定,启用dynamic-item-size属性,同时设置min-item-size和max-item-size,避免计算偏差。
  • 唯一标识:key-field必须设置,且值唯一(优先使用后端返回的唯一ID,而非索引),避免Vue复用DOM时出现内容重复、点击事件错乱等异常。
  • 容器高度:虚拟列表容器必须设置固定高度(或通过父容器传递高度)和overflow-y: auto,否则无法计算可视区域范围,导致虚拟列表失效,变为普通列表。
  • 动态高度适配:若列表项高度不固定(如包含图片、多行文本),需启用dynamic-item-size属性,同时在列表项渲染完成后,调用插件的forceUpdate()方法,强制重新计算高度,避免渲染错乱。
  • 性能调优:避免在列表项模板中使用复杂计算、过滤器、v-if(可用v-show替代),减少渲染耗时;若需渲染图片,建议使用懒加载(如vue-lazyload插件),避免图片加载阻塞渲染。

4. 适用场景

十万条及以上大量数据渲染、长列表场景(如商品列表、日志列表、数据表格),是工业级项目的首选方案,兼顾性能与体验。尤其适合对渲染速度、用户体验要求较高的场景,如电商商品列表、后台日志管理等。

二、方案2:分批渲染(简单易实现,无插件依赖)

1. 核心原理

分批渲染(分页渲染)的核心逻辑是:将十万条数据分成多批(如每批渲染100条),通过setTimeout或requestAnimationFrame,分多次将数据渲染到页面,避免一次性渲染大量DOM,给浏览器足够的时间处理渲染,减少卡顿。

关键思路:设置批次大小(每批渲染数量),通过定时器分批将数据添加到渲染数组中,直到所有数据渲染完成,同时可配合加载状态,提升用户体验。以下补充完整项目落地细节,覆盖批次配置、异常处理、性能优化等实际开发场景。

2. 实操实现(Vue3,无插件,直接落地)

<template>
  &lt;div class="batch-list-container"&gt;
    <!-- 分批渲染的列表(添加滚动容器,避免页面过长) -->
    <div class="list-wrapper" style="height: 600px; overflow-y: auto; border: 1px solid #eee;">
      <div class="list-item" v-for="item in renderList" :key="item.id">
        <span class="item-id">{{ item.id }}</span>
        <span class="item-name">{{ item.name }}</span>
        <span class="item-content">{{ item.content }}</span>
      </div&gt;
    &lt;/div&gt;
    <!-- 加载状态(优化样式,提升用户体验) -->
    <div class="loading" v-if="isLoading">
      <div class="loading-spinner"></div>
      <span>加载中...({{ renderList.length }}/100000)&lt;/span&gt;
    &lt;/div&gt;
    <!-- 加载失败提示(落地必备,异常处理) -->
    <div class="load-fail" v-if="isLoadFail" @click="retryRender">
      加载失败,点击重试
    &lt;/div&gt;
    <!-- 渲染完成提示(可选,提升用户体验) -->
    <div class="render-complete" v-if="!isLoading && !isLoadFail && renderList.length === bigList.length">
      已全部加载完成
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import { ElMessage } from 'element-plus';
// 引入接口请求函数(模拟实际项目接口请求)
import { getBatchData } from '@/api/data';

// 十万条原始数据(非响应式,节省内存,仅用于存储)
let bigList = [];
// 用于渲染的数组(响应式,分批添加数据)
const renderList = ref([]);
// 加载状态
const isLoading = ref(true);
// 加载失败状态
const isLoadFail = ref(false);
// 分批配置(落地细节:根据项目性能调整,适配不同设备)
const batchSize = ref(100); // 每批渲染数量,可根据设备性能动态调整
const delay = ref(20); // 每批渲染间隔(ms),性能差的设备可增大至30-50ms
// 定时器标识(用于组件卸载时清除定时器,避免内存泄漏)
let renderTimer = null;

// 生成十万条测试数据(模拟接口返回,实际项目替换为分批接口请求)
const generateData = () => {
  const data = [];
  for (let i = 1; i <= 100000; i++) {
    data.push({
      id: i,
      name: `测试数据${i}`,
      content: `这是Vue分批渲染十万条数据的测试内容,序号${i}`
    });
  }
  return data;
};

// 分批渲染函数(落地细节:异常处理+性能优化+中断控制)
const batchRender = async (data, start = 0) => {
  try {
    // 计算当前批次的结束索引
    const end = Math.min(start + batchSize.value, data.length);
    // 批量添加数据(使用nextTick,确保DOM更新完成后再进行下一批渲染)
    await nextTick(() => {
      renderList.value.push(...data.slice(start, end));
    });
    // 判断是否渲染完成
    if (end < data.length) {
      // 清除上一个定时器,避免多个定时器叠加(防止卡顿)
      if (renderTimer) clearTimeout(renderTimer);
      // 延迟渲染下一批,给浏览器时间处理DOM
      renderTimer = setTimeout(() => {
        batchRender(data, end);
      }, delay.value);
    } else {
      isLoading.value = false; // 渲染完成,隐藏加载状态
    }
  } catch (error) {
    console.error('分批渲染失败:', error);
    isLoading.value = false;
    isLoadFail.value = true;
    ElMessage.error('数据渲染失败,请重试');
  }
};

// 重试渲染函数(落地必备,处理渲染失败场景)
const retryRender = () => {
  isLoadFail.value = false;
  isLoading.value = true;
  renderList.value = []; // 清空已渲染数据,重新开始渲染
  batchRender(bigList);
};

// 动态调整批次配置(落地细节:适配不同设备性能)
const adjustBatchConfig = () => {
  // 判断设备性能(简单判断,可根据实际需求优化)
  const isLowPerformance = navigator.hardwareConcurrency < 4; // 核心数小于4,视为低性能设备
  if (isLowPerformance) {
    batchSize.value = 50; // 低性能设备,减少每批渲染数量
    delay.value = 30; // 增大渲染间隔,避免卡顿
  } else {
    batchSize.value = 100;
    delay.value = 20;
  }
};

// 页面挂载后开始分批渲染(落地细节:接口请求+配置调整+内存优化)
onMounted(async () => {
  try {
    adjustBatchConfig(); // 初始化时调整批次配置,适配设备性能
    isLoading.value = true;
    // 实际项目中,替换为分批接口请求(每次请求100条,减少接口压力)
    // bigList = [];
    // for (let i = 1; i <= 100; i++) { // 分100次请求,每次1000条
    //   const res = await getBatchData({ page: i, pageSize: 1000 });
    //   bigList.push(...res.data);
    // }
    bigList = generateData(); // 模拟接口返回,非响应式存储,节省内存
    await batchRender(bigList);
  } catch (error) {
    console.error('数据加载失败:', error);
    isLoading.value = false;
    isLoadFail.value = true;
    ElMessage.error('数据加载失败,请重试');
  }
});

// 组件卸载时清理资源(落地细节:清除定时器+释放内存)
onUnmounted(() => {
  if (renderTimer) clearTimeout(renderTimer);
  bigList = [];
  renderList.value = [];
});
</script>

<style scoped>
.list-wrapper {
  margin-bottom: 20px;
}
.list-item {
  height: 50px;
  line-height: 50px;
  border-bottom: 1px solid #eee;
  padding: 0 20px;
  display: flex;
  align-items: center;
}
.item-id {
  width: 80px;
  color: #666;
}
.item-name {
  width: 150px;
  font-weight: 500;
}
.item-content {
  flex: 1;
  color: #999;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.loading {
  text-align: center;
  padding: 20px;
  color: #666;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
}
.loading-spinner {
  width: 20px;
  height: 20px;
  border: 2px solid #ddd;
  border-top-color: #409eff;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}
@keyframes spin {
  to { transform: rotate(360deg); }
}
.load-fail {
  text-align: center;
  padding: 20px;
  color: #f56c6c;
  cursor: pointer;
}
.load-fail:hover {
  text-decoration: underline;
}
.render-complete {
  text-align: center;
  padding: 20px;
  color: #67c23a;
}
</style>

落地细节补充:1. 批次配置:根据设备性能动态调整batchSize和delay,低性能设备减少每批渲染数量、增大间隔,避免卡顿;2. 接口请求:实际项目中,建议后端提供分批接口(如分页接口),前端分多次请求数据并拼接,避免一次性请求十万条数据导致接口超时、前端内存飙升;3. 异常处理:添加渲染失败重试、加载状态提示、渲染进度显示,提升用户体验;4. 内存优化:原始数据bigList设为非响应式,减少Vue响应式监听开销,组件卸载时清除定时器和数据,避免内存泄漏;5. 交互优化:添加滚动容器,避免页面过长,列表项内容超出部分省略,提升视觉体验。

3. 关键优化点

  • 批次大小:batchSize建议设置为100-200条,过大仍会卡顿,过小会导致渲染次数过多,影响体验;低性能设备可调整为50-100条,根据实际测试结果优化。
  • 渲染间隔:delay建议设置为10-30ms,间隔太小会导致浏览器主线程阻塞,间隔太大则渲染速度太慢;可根据设备性能动态调整,平衡渲染速度和流畅度。
  • 加载状态:添加加载提示、渲染进度、加载失败重试按钮,避免用户误以为页面卡死,提升用户体验。
  • 避免频繁更新:使用push(...data)批量添加数据,避免单次push一条数据,减少Vue响应式更新次数;配合nextTick,确保DOM更新完成后再进行下一批渲染,避免渲染错乱。
  • 中断控制:渲染过程中,若组件卸载或用户跳转页面,需及时清除定时器,避免定时器继续执行导致内存泄漏和无效渲染。
  • 数据处理:若数据中包含图片、视频等资源,需单独处理,如图片懒加载,避免资源加载阻塞DOM渲染,导致卡顿。

4. 适用场景

无需复杂交互的长列表、中小型项目(无插件依赖,快速落地),适合对渲染速度要求不极致,追求开发效率的场景。如后台简单日志列表、数据预览列表等,无需引入第三方插件,降低项目依赖,快速完成开发。

三、方案3:虚拟滚动表格(适配表格场景,十万条数据无卡顿)

1. 核心原理

若需要渲染十万条数据表格(如数据报表),普通表格会一次性渲染十万行,卡顿严重,此时可使用虚拟滚动表格,核心逻辑与虚拟列表一致:只渲染可视区域内的表格行,通过滚动动态替换表格内容,减少DOM节点数量。

推荐使用Element Plus的ElTable配合虚拟滚动(Vue3),或Element UI的ElTable(Vue2),自带虚拟滚动功能,无需额外开发。以下补充完整项目落地细节,覆盖组件配置、异常处理、适配优化等实际开发场景。

2. 实操实现(Vue3+Element Plus)

<template>
  <div class="virtual-table-container" style="padding: 20px;">
    <!-- 虚拟滚动表格(落地细节:完整配置+异常处理) -->
    <el-table
      :data="bigList"
      :height="600" // 固定表格高度必须设置否则虚拟滚动失效
      border
      stripe // 斑马纹提升表格可读性
      :row-key="(row) => row.id" // 行唯一标识避免渲染错乱必须v-infinite-scroll="loadMore" // 可选下拉加载更多适配接口分批请求infinite-scroll-disabled="isLoading || isLoadComplete"
      infinite-scroll-distance="50" // 滚动距离底部50px时触发下拉加载
      @selection-change="handleSelectionChange" // 多选事件落地必备处理表格多选)
    &gt;
      <!-- 多选列可选根据项目需求添加-->
      <el-table-column type="selection" width="55" />
      <el-table-column label="序号" prop="id" width="100" align="center" />
      <el-table-column label="名称" prop="name" width="200" />
      <el-table-column label="内容" prop="content" min-width="300" /&gt;
      <!-- 操作列落地必备处理表格操作-->
      <el-table-column label="操作" width="180" align="center">
        <template #default="{ row }">
          <el-button size="small" type="primary" @click="handleView(row)">查看</el-button>
          <el-button size="small" type="text" @click="handleEdit(row)">编辑</el-button>
        </template>
      </el-table-column>
    &lt;/el-table&gt;

    <!-- 加载状态(覆盖表格,提升用户体验) -->
    <div class="table-loading" v-if="isLoading">
      <div class="loading-spinner"></div>
      <span>数据加载中...&lt;/span&gt;
    &lt;/div&gt;

    <!-- 空数据提示(落地必备) -->
    <div class="table-empty" v-if="!isLoading && bigList.length === 0"&gt;
      暂无数据
    &lt;/div&gt;

    <!-- 加载失败提示落地必备-->
    <div class="table-load-fail" v-if="isLoadFail" @click="retryLoad">
      加载失败,点击重试
    </div&gt;

    <!-- 加载完成提示(可选) -->
    <div class="table-load-complete" v-if="!isLoading && isLoadComplete">
      已加载全部数据
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { ElTable, ElTableColumn, ElButton, ElMessage, ElLoading } from 'element-plus';
// 引入接口请求函数(模拟实际项目接口请求)
import { getTableData } from '@/api/data';

// 十万条表格数据(响应式,用于表格渲染)
const bigList = ref([]);
// 加载状态
const isLoading = ref(true);
// 加载失败状态
const isLoadFail = ref(false);
// 加载完成状态(下拉加载时使用)
const isLoadComplete = ref(false);
// 当前页码(用于分批接口请求)
const currentPage = ref(1);
// 每页条数(用于分批接口请求)
const pageSize = ref(1000);
// 选中的行数据(用于多选操作)
const selectedRows = ref([]);

// 生成十万条测试数据(模拟接口返回,实际项目替换为分批接口请求)
const generateData = (page = 1, pageSize = 1000) => {
  const data = [];
  const start = (page - 1) * pageSize + 1;
  const end = Math.min(page * pageSize, 100000);
  for (let i = start; i <= end; i++) {
    data.push({
      id: i,
      name: `表格数据${i}`,
      content: `这是Vue虚拟滚动表格测试内容,序号${i}`
    });
  }
  return data;
};

// 加载表格数据(落地细节:分批请求+异常处理+加载状态控制)
const loadTableData = async () => {
  try {
    isLoading.value = true;
    isLoadFail.value = false;
    // 实际项目中,替换为分批接口请求(每次请求1000条,减少接口压力)
    // const res = await getTableData({ page: currentPage.value, pageSize: pageSize.value });
    // const newData = res.data;
    const newData = generateData(currentPage.value, pageSize.value); // 模拟接口返回
    // 拼接数据(下拉加载时追加,首次加载时覆盖)
    if (currentPage.value === 1) {
      bigList.value = Object.freeze(newData); // 静态数据冻结,减少响应式开销
    } else {
      bigList.value = [...bigList.value, ...Object.freeze(newData)];
    }
    // 判断是否加载完成(当前页数据小于每页条数,说明已加载全部)
    if (newData.length < pageSize.value) {
      isLoadComplete.value = true;
    } else {
      currentPage.value++; // 页码自增,用于下一次下拉加载
    }
  } catch (error) {
    console.error('表格数据加载失败:', error);
    isLoadFail.value = true;
    ElMessage.error('数据加载失败,请重试');
  } finally {
    isLoading.value = false;
  }
};

// 下拉加载更多(适配分批接口请求场景)
const loadMore = async () => {
  if (isLoadComplete || isLoading) return; // 已加载完成或正在加载,不触发
  await loadTableData();
};

// 重试加载(落地必备,处理加载失败场景)
const retryLoad = () => {
  currentPage.value = 1;
  isLoadComplete.value = false;
  loadTableData();
};

// 表格多选事件(落地必备,处理多选操作)
const handleSelectionChange = (val) => {
  selectedRows.value = val;
  console.log('选中的行:', selectedRows.value);
};

// 查看操作(落地必备,处理表格行查看)
const handleView = (row) => {
  console.log('查看行数据:', row);
  // 实际项目中可跳转详情页、弹窗显示详情等
};

// 编辑操作(落地必备,处理表格行编辑)
const handleEdit = (row) => {
  console.log('编辑行数据:', row);
  // 实际项目中可弹窗编辑、跳转编辑页等
};

// 页面挂载后初始化表格数据(落地细节:初始化配置+数据加载)
onMounted(() => {
  // 初始化表格虚拟滚动配置(可选,根据项目需求调整)
  // ElTable的虚拟滚动默认启用,若需自定义配置,可通过table-layout、scroll-x等属性调整
  loadTableData();
});

// 组件卸载时清理数据(落地细节:释放内存,避免内存泄漏)
onUnmounted(() => {
  bigList.value = [];
  selectedRows.value = [];
  currentPage.value = 1;
  isLoadComplete.value = false;
});
</script>

<style scoped>
.virtual-table-container {
  width: 100%;
  box-sizing: border-box;
}
.table-loading {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: rgba(255, 255, 255, 0.8);
  padding: 20px 40px;
  border-radius: 4px;
  display: flex;
  align-items: center;
  gap: 10px;
  z-index: 1000;
}
.loading-spinner {
  width: 20px;
  height: 20px;
  border: 2px solid #ddd;
  border-top-color: #409eff;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}
@keyframes spin {
  to { transform: rotate(360deg); }
}
.table-empty, .table-load-fail, .table-load-complete {
  text-align: center;
  padding: 40px;
  color: #666;
}
.table-load-fail {
  color: #f56c6c;
  cursor: pointer;
}
.table-load-fail:hover {
  text-decoration: underline;
}
.table-load-complete {
  color: #67c23a;
}
.el-table__body-wrapper {
  overflow-y: auto !important; // 确保表格滚动正常
}
</style>

落地细节补充:1. 组件配置:ElTable必须设置height属性,否则虚拟滚动无法启用;row-key必须设置为行唯一标识(如id),避免渲染错乱、多选事件异常;2. 接口请求:实际项目中,十万条表格数据建议后端分批返回(如每次返回1000条),前端通过下拉加载拼接数据,避免一次性请求大量数据导致接口超时;3. 异常处理:添加加载状态、空数据提示、加载失败重试、加载完成提示,提升用户体验;4. 交互优化:添加多选列、操作列,处理表格常见的查看、编辑操作,适配后台管理系统场景;5. 性能优化:静态数据使用Object.freeze()冻结,减少Vue响应式监听开销;组件卸载时清空数据,避免内存泄漏;6. 样式优化:设置表格斑马纹、固定列宽,确保表格渲染整齐,避免表头错位。

3. 关键优化点

  • 固定表格高度:ElTable必须设置height属性(固定值或父容器传递高度),否则无法启用虚拟滚动,会一次性渲染所有行,导致卡顿。
  • 列宽设置:尽量给表格列设置固定宽度(width)或最小宽度(min-width),避免表格自适应导致渲染错乱、表头错位;若列数较多,可设置scroll-x: true,启用横向滚动。
  • 分批请求:若十万条数据来自接口,建议分批请求(如每次请求1000条),配合下拉加载,避免一次性请求大量数据导致接口超时、前端内存飙升;同时设置加载完成状态,避免重复请求。
  • 避免复杂模板:表格单元格内避免使用复杂组件(如图片、表单、复杂计算),减少渲染压力;若需渲染图片,使用懒加载,避免图片加载阻塞渲染。
  • 行唯一标识:row-key必须设置,且值唯一(优先使用后端返回的id),否则会出现表格行渲染重复、多选事件错乱、滚动时内容跳动等异常。
  • 性能调优:启用表格斑马纹(stripe)、边框(border)时,避免过度使用样式嵌套,减少渲染耗时;若表格数据无需修改,使用Object.freeze()冻结数据,减少响应式开销。

4. 适用场景

十万条数据表格渲染、数据报表、后台管理系统表格场景,适配Element UI/Element Plus生态,开发效率高。尤其适合后台管理系统中,需要展示大量数据表格、支持多选、查看、编辑等交互操作的场景,无需额外开发虚拟滚动逻辑,依托组件库快速落地。

四、三种方案对比及选型建议

方案 核心优势 潜在不足 适用场景
虚拟列表(vue-virtual-scroller) 性能最优,DOM数量最少,无卡顿,支持动态高度;适配多场景,可自定义列表项模板;补充落地细节后,可应对复杂交互需求。 需引入第三方插件,有一定学习成本;动态高度场景下需额外配置,否则易出现渲染错乱。 十万条及以上长列表、商品列表、日志列表;对渲染性能、用户体验要求较高的工业级项目。
分批渲染(无插件) 无插件依赖,开发简单,快速落地;代码可维护性高,无需学习第三方插件;补充落地细节后,可适配不同设备性能。 渲染速度一般,滚动时可能出现轻微卡顿;不适合复杂交互场景;DOM数量随渲染进度增加,内存占用逐渐升高。 中小型项目、无需复杂交互的长列表;追求开发效率,不想引入第三方插件的场景。
虚拟滚动表格(Element) 适配表格场景,开发效率高,贴合后台系统;依托Element组件库,自带多选、操作列等常用功能;补充落地细节后,可应对后台表格常见需求。 依赖Element组件库,灵活性稍差;表头易出现错位,需额外优化;复杂模板场景下渲染性能下降。 后台管理系统、数据报表、表格渲染;需要支持多选、查看、编辑等交互操作的表格场景。

五、通用优化技巧(所有方案都适用)

  1. 减少响应式数据:十万条数据中,无需响应式的字段(如静态内容),可转为非响应式(如使用Object.freeze()冻结数据),减少Vue响应式监听开销; // 冻结数据,取消响应式监听(仅适用于静态数据,无需修改) ``bigList.value = Object.freeze(generateData());落地细节:冻结数据后,数据无法修改,若需修改数据(如编辑、删除),需先复制一份数据,修改后再重新赋值,避免直接修改冻结数据导致报错。
  2. 避免使用v-if:列表项/表格单元格中避免使用v-if(频繁切换会导致DOM销毁/创建),可用v-show替代(仅隐藏,不销毁DOM);若必须使用v-if,建议将条件判断移至数据处理阶段,提前过滤数据,减少渲染时的条件判断。
  3. 优化列表项模板:列表项/表格单元格模板尽量简洁,避免嵌套过多组件、复杂计算、过滤器;复杂计算可提前在数据处理阶段完成,渲染时直接使用计算结果,减少渲染耗时。
  4. 使用CDN加载资源:将Vue、Element Plus、vue-virtual-scroller等第三方资源通过CDN加载,减少本地打包体积,提升页面加载速度;同时配置资源缓存,减少重复请求。
  5. 数据分页请求:若数据来自接口,建议分页请求(如每次请求1000条),避免一次性请求十万条数据导致接口超时、页面卡死;同时实现下拉加载、加载状态提示,提升用户体验。
  6. 内存优化:组件卸载时,清空所有数据、定时器、事件监听,避免内存泄漏;静态数据尽量使用非响应式存储,减少Vue响应式监听开销;避免在渲染过程中创建大量临时变量,减少内存占用。
  7. 设备适配:通过navigator.hardwareConcurrency、screen.width等API,判断设备性能和屏幕尺寸,动态调整渲染配置(如批次大小、缓冲高度),适配不同设备,避免低性能设备出现卡顿。

六、常见问题及解决方案

  • 问题1:虚拟列表渲染错乱,出现空白或重复内容? 解决方案:确保item-size与列表项实际高度一致,设置唯一的key-field(优先使用后端返回的id);若列表项高度不固定,启用dynamic-item-size属性,同时调用forceUpdate()方法强制重新计算高度;检查容器高度是否固定,确保overflow-y: auto已设置。
  • 问题2:分批渲染时,页面出现卡顿、掉帧? 解决方案:减小批次大小(如改为50条/批),增大渲染间隔(如改为30ms);低性能设备动态调整配置;避免在渲染过程中执行其他耗时操作(如复杂计算、接口请求);使用nextTick确保DOM更新完成后再进行下一批渲染。
  • 问题3:虚拟滚动表格表头错位? 解决方案:给表格列设置固定宽度或最小宽度,避免表格自适应;确保表格height属性设置正确,不随内容变化;避免表格单元格内内容换行,导致行高变化;若仍错位,可在表格渲染完成后,调用doLayout()方法强制重绘表格。
  • 问题4:渲染完成后,页面内存占用过高? 解决方案:使用Object.freeze()冻结静态数据,避免不必要的响应式监听;渲染完成后,若无需修改数据,可手动清空原始数据(bigList.value = []),释放内存;组件卸载时,清空所有数据、定时器、事件监听,避免内存泄漏。
  • 问题5:接口请求十万条数据时,出现超时或请求失败? 解决方案:将接口改为分批请求,每次请求1000-2000条数据,前端分多次拼接;后端优化接口性能,添加索引、分页查询;前端添加请求超时处理、重试机制,提升接口请求稳定性。

七、总结

Vue渲染十万条数据,核心是“减少DOM数量、避免一次性渲染”,三种方案各有侧重,结合补充的落地细节,可完美应对实际开发中的各类场景:

  • 追求极致性能:优先选择「虚拟列表」,工业级首选,适配所有长列表场景,补充依赖配置、异常处理、内存优化等细节后,可应对复杂交互需求;
  • 追求开发效率:选择「分批渲染」,无插件依赖,快速落地,补充批次配置、设备适配、异常处理等细节后,可适配不同设备性能,适合中小型项目;
  • 表格场景:选择「虚拟滚动表格」,贴合后台系统,开发效率高,补充组件配置、交互优化、表头适配等细节后,可应对后台表格常见需求。

无论选择哪种方案,都需配合通用优化技巧,减少响应式开销、优化模板结构、适配设备性能,同时结合实际业务场景(数据来源、交互需求),才能实现真正的无卡顿渲染,提升用户体验和项目稳定性。

Flutter 抽象类、接口与mixin

作者 可有道理
2026年5月6日 23:41

抽象类、接口与mixin

核心对比

抽象类 接口 mixin
关键字 abstract class 无,用 abstract class / class mixin
使用方式 extends implements with
多继承 仅单继承 可实现多个接口 可使用多个 mixin
有无构造函数
可否实例化
能力复用 子类可复用抽象类中已实现的方法 不可复用 可复用
方法是否必须重写 抽象方法必须 所有 可选
使用场景 定义基类 定义行为规范 工具类

抽象类

通过 abstract class 关键字定义。

dart

// 抽象类:定义动物规范
abstract class Animal {
  // 成员变量
  String name;

  // 构造函数
  Animal(this.name);

  // 抽象方法(必须实现)
  void makeSound();

  // 普通方法(已有实现,可直接用)
  void eat() {
    print("$name is eating");
  }
}

// 子类继承抽象类
class Dog extends Animal {
  // 调用父类构造
  Dog(String name) : super(name);

  // 必须实现抽象方法
  @override
  void makeSound() {
    print("Woof!");
  }
}

void main() {
  // Animal(); ❌ 抽象类不能实例化
  final dog = Dog("Buddy");
  dog.makeSound(); // Woof!
  dog.eat();       // Buddy is eating
}

特性

  • 不能实例化,使用 extends 继承
  • 可继承抽象类的所有成员(变量 & 方法)
  • 子类需实现所有抽象方法,可选择性的覆写抽象类中已实现的方法
  • Dart 为单继承语言

设计目的

提供通用能力 + 规范,通常用于定义基类,如 BaseWidget

接口

Dart 中无 interface 关键字,通过 abstract classclass 定义的类既是抽象类,也是接口。

objectivec

class Flyable {
  void fly();
}

特性

  • 需实现接口中的所有成员(变量 & 方法)
  • 可实现多个接口
  • 不能继承 class 中已实现的方法

设计目的

定义规范 + 约束。当类 A 想实现类 B 中的 API,而不想继承 B 的实现时,可通过 A implements B 实现。

mixin(混入)

是一种横向复用代码的机制,通过 mixin 定义,with 使用。

objectivec

mixin Logger {
  void log(String message) {
    print('LOG: $message');
  }
}

class UserService with Logger {
  void createUser() {
    log('User created');
  }
}

特性

  • 可定义变量、方法
  • 通过 on 关键字约束使用范围,如 mixin A on State,只有 State 及其子类可使用 mixin A。
  • 冲突处理:后混入的会覆盖先混入的同名成员

设计目的

  • 不破坏类层级
  • 可组合使用多个能力
  • 架构解耦

第一个Vue3.0程序

2026年5月6日 23:39

先在VS Code终端中输入启动命令:

npm run serve

启动成功后,输入http://localhost:8080/ 看看。

下面说明该项目的详细执行过程。

App挂载文件——index.html

在项目的public文件夹中包含有index.index文件,index.html文件的内容非常简单,主要是将一个div标签提供给Vue创建的App进行挂载。index.html文件的内容如下。

<!DOCTYPE html>
<html lang="">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
    <title><%= htmlWebpackPlugin.options.title %></title>
  </head>
  <body>
    <noscript>
      <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
    </noscript>
    <div id="app"><!-- Vue创建的App挂载点 --></div>
    <!-- built files will be auto injected -->
  </body>
</html>

另外,整个项目页面的标题也在此文件的标签内进行设置。

创建App主文件——main.js

项目的src文件夹中的main.js创建Vue的App并引入所需要的插件,将程序员编写的内容渲染到主页面(index.html)上,是Vue3.0项目的入口文件,在执行main.js时是从上到下进行执行的。

import { createApp } from 'vue' //从vue核心库中引入createApp方法
import App from './App.vue'//引入一个当前目录下的名字为App.vue的组件
import router from './router'//引入路由
import store from './store'

//创建App,使用路由将其挂载到index.html文件上的<div id='app'></div>
createApp(App).use(store).use(router).mount('#app')

Vue通过webpack实现模块化,因此可以使用import引入模块。上面的main.js文件引入App.vue作为根组件来启动,可以使用以下语句实现:

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'

createApp(App).use(store).use(router).mount('#app')

此处的引入方式采用的是相对地址。

根组件——App.vue

main.js文件把App.vue组件引入并作为根节点挂载到index.html文件的<div id="app"></div>上,然后渲染到浏览器页面。App.vue组件的文件内容如下:

<template>
  <nav>
    <router-link to="/">Home</router-link> |
    <router-link to="/about">About</router-link>
  </nav>
  <router-view/>
</template>

<style lang="scss">
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}

nav {
  padding: 30px;

  a {
    font-weight: bold;
    color: #2c3e50;

    &.router-link-exact-active {
      color: #42b983;
    }
  }
}
</style>

首先说明组件的文件结构分为三个部分:模板(template)、脚本(script)和样式(style)。其代码结构如下。

<template>
    <!--模板部分-->
</teplate>

<script>
    //脚本部分
</script>

<style>
    /*CSS样式部分,scoped表示所有样式仅在此组件内容有效,不影响其他组件*/
</style>

另外两个<router-link to="/"></router-link>表示路由链接导航,单击这两个导航则会把符合路由结果的组件导入并渲染到<router-view>处,<router-view>相当于一个占位符,会显示符合路由结果的组件。

路由设置文件——router/index.js

在src/router/index.js文件中定义了用户输入的路由锁对应的地址,其文件内容和对应的相关说明如下:

//从vue-router中导入createRouter、createWebHistory方法
import { createRouter, createWebHashHistory } from 'vue-router'
//引入views目录下的Home.vue组件,取别名为Home
import HomeView from '../views/HomeView.vue'

const routes = [                     //配置路由,这里是个数组
  {                                  //每一个路由链接都是一个对象
    path: '/',                       //链接路径:根路径,即第一条路由
    name: 'home',                    //路由名称Home
    component: HomeView              //对应的组件模板,此处是../views/Hone.vue
  },
  {
    path: '/about',
    name: 'about',
    //路由懒加载,即路由被使用时才加载
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/AboutView.vue')
  }
]

const router = createRouter({         //创建路由实例
  history: createWebHashHistory(),    //创建history模式的路由
  routes                              //上面定义配置路由的数组
})

export default router                 //暴露路由

用户在浏览器的地址栏中输入:

http://localhost:8080/

相当于访问本地主机端口号为8080的Web服务器根目录,也就是router/index.js的第一条路由,表示符合规则的路由锁代开的组件文件是../views/Home.vue,也就是说,会用../views/Home.vue组件代替App.vue组件内的路由占位符<router-view/>

views/Home.vue

Home.vue组件的文件内容如下:

<template>
  <div class="home">
    <img alt="Vue logo" src="../assets/logo.png">
    <HelloWorld msg="Welcome to Your Vue.js App"/>
  </div>
</template>

<script>
// @ is an alias to /src
//@是一个别名,相当于/scr文件夹
//导入/src/components/HellowWorld.vue组件,取名为HelloWorld
import HelloWorld from '@/components/HelloWorld.vue'

export default {
  name: 'HomeView',
  components: {
    HelloWorld       //定义子组件名称HelloWorld
  }
}
</script>

使用以下语句把导入的子组件HellWorld.vue渲染到网页中:

<HelloWorld msg="Welcome to Your Vue.js App"/>

在导入子组件HeoowWorld.Vue的过程中,向子组件HellWorld.Vue传递信息"Welcome to Your Vue.js App",msg是所传信息的属性,在子组件中接收这个msg并将其渲染到网页中。

HeoowWOrld.vue组件的文件内容如下:

<template>
  <div class="hello">
    <h1>{{ msg }}</h1>
    <p>
      For a guide and recipes on how to configure / customize this project,<br>
      check out the
      <a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.
    </p>
    <h3>Installed CLI Plugins</h3>
    <ul>
      <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank" rel="noopener">babel</a></li>
      <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-router" target="_blank" rel="noopener">router</a></li>
      <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-vuex" target="_blank" rel="noopener">vuex</a></li>
      <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint" target="_blank" rel="noopener">eslint</a></li>
    </ul>
    <h3>Essential Links</h3>
    <ul>
      <li><a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a></li>
      <li><a href="https://forum.vuejs.org" target="_blank" rel="noopener">Forum</a></li>
      <li><a href="https://chat.vuejs.org" target="_blank" rel="noopener">Community Chat</a></li>
      <li><a href="https://twitter.com/vuejs" target="_blank" rel="noopener">Twitter</a></li>
      <li><a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a></li>
    </ul>
    <h3>Ecosystem</h3>
    <ul>
      <li><a href="https://router.vuejs.org" target="_blank" rel="noopener">vue-router</a></li>
      <li><a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a></li>
      <li><a href="https://github.com/vuejs/vue-devtools#vue-devtools" target="_blank" rel="noopener">vue-devtools</a></li>
      <li><a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener">vue-loader</a></li>
      <li><a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">awesome-vue</a></li>
    </ul>
  </div>
</template>

<script>
export default {
  name: 'HelloWorld',
  props: {
    msg: String              //接收父组件传递过来的属性
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<!--增加scoped属性用于限定CSS属性仅能在本组件内使用-->
<style scoped lang="scss">
h3 {
  margin: 40px 0 0;
}
ul {
  list-style-type: none;
  padding: 0;
}
li {
  display: inline-block;
  margin: 0 10px;
}
a {
  color: #42b983;
}
</style>

自定义Hello World程序

1.自定义index.html内容

<!DOCTYPE html>
<html lang="">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
    <title><%= htmlWebpackPlugin.options.title %></title>
  </head>
  <body>
    <noscript>
      <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
    </noscript>
    <div id="app"></div>
    <!-- built files will be auto injected -->
  </body>
</html>

2.修改App.vue

<template>
  <nav>
    <router-link to="/">主页</router-link> |
    <router-link to="/about">关于</router-link>
  </nav>
  <router-view/>
</template>

<style lang="scss">
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}

nav {
  padding: 30px;

  a {
    font-weight: bold;
    color: #2c3e50;

    &.router-link-exact-active {
      color: #42b983;
    }
  }
}
</style>

3.views/Home.vue

<template>
  <div class="home">
    <img alt="Vue logo" src="../assets/logo.png">
    <HelloWorld msg="欢迎您,Vue.js App!"/>
  </div>
</template>

<script>
// @ is an alias to /src
import HelloWorld from '@/components/HelloWorld.vue'

export default {
  name: 'HomeView',
  components: {
    HelloWorld
  }
}
</script>

4.components/HelloWorld.vue

<!--下面<template></template>标记之间是Vue的模板区域,即MVVM中的View层-->
<template>
  <div class="hello">
    <h1>{{ msg }}</h1>
    <h2>{{ message }}</h2>
  </div>
</template>

<!--下面的<script></script>标记之间,是View Model层-->
<script>
import { ref } from 'vue'
export default {
  name: 'HelloWorld',
  props: {
    msg: String
  },
  setup () {
    const message = ref('Vue 3.0的欢迎信息!')
    return {
      message
    }
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to only this component -->
<!--下面<style></style>之间是定义模板区域的CSS样式,即View层-->
<style scoped lang="scss">
h2 {
  margin: 40px 0 0;
  color: orangered;
}
</style>

效果展示

image.png

React Fiber 架构详解

作者 charmson
2026年5月6日 23:18

从问题出发,理解 React 16 重写渲染引擎的底层逻辑


一、背景:旧架构(Stack Reconciler)的痛点

React 15 的渲染流程

在 React 15 及之前,渲染引擎叫做 Stack Reconciler(栈调和器)。它的工作方式类比递归调用栈:

render()
  └─ diff 子树 A
       └─ diff 子树 B
            └─ diff 子树 C(深度优先,一口气跑完)

整个 Virtual DOM diff + DOM 更新过程是同步、不可中断的。

核心问题:长任务阻塞主线程

浏览器的主线程是单线程的,它需要同时负责:

任务类型 典型时间预算
JS 执行
样式计算
布局 (Layout)
绘制 (Paint)
用户交互响应 必须 ≤ 16ms(60fps)

当组件树很深、更新量很大时,Stack Reconciler 一次 diff 可能耗时 50ms、100ms 甚至更长。在此期间:

  • 🚫 用户点击无响应
  • 🚫 动画卡帧、掉帧
  • 🚫 输入框输入延迟

这就是著名的 "掉帧"(Jank) 问题。

根本矛盾

Stack Reconciler 无法区分任务的优先级,也无法暂停/恢复工作。它就像一个打电话中途不能挂断的人——无论多重要的事情发生,都得等它说完。


二、解决思路:把"同步长任务"变成"可中断的增量工作"

React 团队受到浏览器 requestIdleCallback API 的启发,提出了核心设计目标:

  1. 可中断(Interruptible) :把渲染工作切分成小单元,每个单元执行完后可以暂停,把控制权还给浏览器
  2. 可恢复(Resumable) :暂停后能从中断点继续
  3. 可丢弃(Cancellable) :低优先级的更新可以被高优先级更新抢占,旧工作直接丢弃重来
  4. 优先级调度(Priority Scheduling) :不同类型的更新(用户输入 vs 数据拉取)有不同优先级

这套新架构就是 Fiber


三、Fiber 是什么?

3.1 Fiber 作为数据结构

Fiber 节点是一个普通的 JS 对象,代表组件树中的一个工作单元。每个 React 元素(组件/DOM 节点)都对应一个 Fiber 节点。

// 简化的 Fiber 节点结构
{
  // 组件信息
  type: MyComponent,       // 组件类型
  key: null,
  stateNode: instance,     // 对应的真实 DOM 或组件实例

  // 树结构(链表,而非树)
  return: parentFiber,     // 父节点
  child: firstChildFiber,  // 第一个子节点
  sibling: nextSibling,    // 下一个兄弟节点

  // 工作信息
  pendingProps: {},        // 即将处理的 props
  memoizedProps: {},       // 上次渲染的 props
  memoizedState: {},       // 上次渲染的 state
  updateQueue: [],         // 待处理的更新队列

  // 调度信息
  lanes: Lanes,            // 优先级(Lane 模型)
  flags: Flags,            // 副作用标记(需要插入/更新/删除)

  // 双缓冲
  alternate: fiber,        // 指向另一棵树的对应节点
}

关键点:Fiber 把树结构改成了链表(return/child/sibling 三指针),这使得遍历可以在任意节点暂停,并且可以通过保存当前 Fiber 指针来恢复。

3.2 Fiber 作为工作调度机制

Fiber 架构把渲染分为两个阶段:

┌─────────────────────────────────────────────────────┐
│              Render Phase(可中断)                   │
│                                                     │
│  beginWork → completeWork → beginWork → ...         │
│                                                     │
│  • 纯计算,无副作用                                    │
│  • 构建 workInProgress 树                            │
│  • 可被高优先级任务打断                                │
└─────────────────────────────────────────────────────┘
                         ↓ commit
┌─────────────────────────────────────────────────────┐
│              Commit Phase(不可中断)                  │
│                                                     │
│  before mutation → mutation → layout               │
│                                                     │
│  • 真实 DOM 操作                                     │
│  • 执行副作用(useEffect、生命周期)                    │
│  • 必须一次性完成,保证 UI 一致性                       │
└─────────────────────────────────────────────────────┘

四、双缓冲树(Double Buffering)

Fiber 维护两棵树:

current 树(屏幕上正在显示的)
    ↕ alternate 指针互相指向
workInProgress 树(正在构建的下一帧)
  • current 树:对应当前屏幕渲染的内容
  • workInProgress 树:在 Render Phase 中悄悄构建,用户看不到

当 workInProgress 树构建完成并 commit 后,两棵树直接交换身份(指针切换,O(1) 操作),新树变成 current 树。

这就像 GPU 的双缓冲渲染,避免用户看到中间状态。


五、优先级调度:Lane 模型

React 18 引入了 Lane(车道)模型 来表示优先级(React 16/17 用的是 expirationTime,后被替代)。

Lane 用二进制位表示,支持批量操作:

SyncLane           = 0b0000000000000000000000000000001  ← 最高优先级
InputContinuousLane= 0b0000000000000000000000000000100  ← 用户输入
DefaultLane        = 0b0000000000000000000000000010000  ← 普通更新
TransitionLane     = 0b0000000000000000000001000000000  ← startTransition
IdleLane           = 0b0100000000000000000000000000000  ← 最低优先级

典型场景:用户正在输入(高优先级),同时有数据请求回来触发列表更新(低优先级):

  1. 列表更新开始渲染(workInProgress 树构建中)
  2. 用户输入事件到来 → 优先级更高
  3. React 打断列表更新,优先处理输入 → 输入框立即响应
  4. 输入处理完毕 → 从头重新渲染列表(之前的 workInProgress 树丢弃)

六、Scheduler:时间切片的实现

React 内部有一个独立的调度器 Scheduler,核心思路:

// 伪代码:workLoop 的时间切片
function workLoop(deadline) {
  while (nextUnitOfWork && !shouldYield()) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  }

  if (nextUnitOfWork) {
    // 还有工作没做完,让出主线程,下一帧继续
    requestIdleCallback(workLoop); // 实际用 MessageChannel 模拟
  } else {
    // 全部做完,进入 Commit Phase
    commitRoot();
  }
}

function shouldYield() {
  return performance.now() >= deadline; // 超过时间片(~5ms)就让出
}

React 没有直接用 requestIdleCallback,因为它在部分浏览器兼容性差、触发频率不可控。实际使用 MessageChannel 模拟,每帧约 5ms 时间片。


七、Fiber 带来的能力全景

能力 依赖机制 代表 API
时间切片 workLoop + shouldYield 默认开启
并发渲染 Render Phase 可中断 createRoot
优先级调度 Lane 模型 startTransition, useDeferredValue
Suspense 渲染可"挂起"并恢复 <Suspense>, use()
并发特性 多个 workInProgress 树 React 18 Concurrent Mode

八、一句话总结

Fiber 把 React 的渲染从"同步递归调用栈"重构为"基于链表的增量工作单元调度系统",使得渲染工作可中断、可恢复、可按优先级调度,从根本上解决了复杂应用的 UI 卡顿问题,并为并发模式奠定了基础。


参考资料

Python 常见的设计模型:入门到精通

作者 XovH
2026年5月6日 21:44

设计模式是软件工程中经过验证的、可复用的解决方案,用于解决特定上下文中反复出现的设计问题。掌握设计模式能帮助你编写更优雅、可维护、可扩展的代码。本文将以Python为例,从基础概念到高级应用,详细讲解15种常用设计模式。每种模式均包含定义问题场景解决方案代码示例(带输出)优缺点适用场景。所有代码均可复制到本地运行,助你彻底理解设计模式的精髓。


目录

  1. 设计模式简介

  2. 创建型模式

    • 单例模式(Singleton)

    • 工厂方法模式(Factory Method)

    • 抽象工厂模式(Abstract Factory)

    • 建造者模式(Builder)

  3. 结构型模式

    • 适配器模式(Adapter)

    • 装饰器模式(Decorator)

    • 代理模式(Proxy)

    • 外观模式(Facade)

  4. 行为型模式

    • 观察者模式(Observer)

    • 策略模式(Strategy)

    • 模板方法模式(Template Method)

    • 状态模式(State)

    • 责任链模式(Chain of Responsibility)

  5. 总结与下载说明


1. 设计模式简介

设计模式主要分为三大类:

  • 创建型模式:对象实例化机制,如单例、工厂等。

  • 结构型模式:组合类或对象以形成更大结构,如适配器、装饰器等。

  • 行为型模式:对象之间的职责分配和通信,如观察者、策略等。

Python作为动态语言,部分模式实现更简洁(例如装饰器内置支持),但理解其背后的思想依然至关重要。


2. 创建型模式

2.1 单例模式(Singleton)

定义:确保一个类只有一个实例,并提供全局访问点。

问题场景:配置文件管理、数据库连接池、日志记录器等对象只需一个全局实例时。

解决方案:通过重写__new__方法或使用元类控制实例创建。

代码示例

class Singleton:
    """经典单例实现"""
    _instance = None

    def __new__(cls, *args, **kwargs):
        if cls._instance is None:
            print("创建第一个实例")
            cls._instance = super().__new__(cls)
        else:
            print("返回已存在的实例")
        return cls._instance

    def __init__(self, value=None):
        # 注意:__init__每次都会执行,需要控制初始化逻辑
        if not hasattr(self, 'initialized'):
            self.value = value
            self.initialized = True

# 使用装饰器实现单例(更Pythonic)
def singleton(cls):
    instances = {}
    def get_instance(*args, **kwargs):
        if cls not in instances:
            instances[cls] = cls(*args, **kwargs)
        return instances[cls]
    return get_instance

@singleton
class Logger:
    def __init__(self, level="INFO"):
        self.level = level
        print(f"日志级别设置为 {self.level}")

# 测试
if __name__ == "__main__":
    print("=== 单例模式测试 ===")
    s1 = Singleton("第一次")
    s2 = Singleton("第二次")
    print(f"s1 is s2: {s1 is s2}")  # True
    print(f"s1.value = {s1.value}, s2.value = {s2.value}")  # 第二次, 注意初始化被覆盖

    print("\n--- 装饰器单例 ---")
    log1 = Logger("DEBUG")
    log2 = Logger("ERROR")
    print(f"log1 is log2: {log1 is log2}")
    print(f"log1.level = {log1.level}, log2.level = {log2.level}")

输出

=== 单例模式测试 ===
创建第一个实例
返回已存在的实例
s1 is s2: True
s1.value = 第二次, s2.value = 第二次

--- 装饰器单例 ---
日志级别设置为 DEBUG
log1 is log2: True
log1.level = DEBUG, log2.level = DEBUG

优缺点

  • 优点:节省资源,全局唯一访问点。

  • 缺点:在多线程环境需要加锁(Python可用threading.Lock);可能隐藏依赖关系。

适用场景:配置类、连接池、线程池、注册表等。


2.2 工厂方法模式(Factory Method)

定义:定义一个创建对象的接口,但由子类决定实例化哪个类。将实例化延迟到子类。

问题场景:客户端不知道需要创建的具体对象类型,或者希望解耦对象创建与使用。

解决方案:创建抽象工厂类,具体工厂重写工厂方法。

代码示例

from abc import ABC, abstractmethod

# 产品抽象
class Animal(ABC):
    @abstractmethod
    def speak(self):
        pass

class Dog(Animal):
    def speak(self):
        return "汪汪"

class Cat(Animal):
    def speak(self):
        return "喵喵"

# 工厂抽象
class AnimalFactory(ABC):
    @abstractmethod
    def create_animal(self) -> Animal:
        pass

class DogFactory(AnimalFactory):
    def create_animal(self) -> Animal:
        return Dog()

class CatFactory(AnimalFactory):
    def create_animal(self) -> Animal:
        return Cat()

# 客户端
def animal_sound(factory: AnimalFactory):
    animal = factory.create_animal()
    print(animal.speak())

if __name__ == "__main__":
    print("=== 工厂方法模式 ===")
    animal_sound(DogFactory())
    animal_sound(CatFactory())

输出

进阶示例:参数化工厂方法

class Shape(ABC):
    @abstractmethod
    def draw(self):
        pass

class Circle(Shape):
    def draw(self):
        return "绘制圆形"

class Square(Shape):
    def draw(self):
        return "绘制方形"

class ShapeFactory:
    def create_shape(self, shape_type):
        if shape_type == "circle":
            return Circle()
        elif shape_type == "square":
            return Square()
        else:
            raise ValueError("未知形状")

if __name__ == "__main__":
    factory = ShapeFactory()
    shapes = ["circle", "square", "circle"]
    for s in shapes:
        shape = factory.create_shape(s)
        print(shape.draw())

输出

优缺点

  • 优点:符合开闭原则,增加新产品只需增加对应工厂类。

  • 缺点:每增加一种产品需要增加具体工厂类,系统复杂度增加。

适用场景:框架库中提供接口让用户扩展;创建逻辑复杂且经常变化。


2.3 抽象工厂模式(Abstract Factory)

定义:创建一系列相关或相互依赖对象的接口,无需指定具体类。

问题场景:系统需要生产多种产品族(如不同风格的UI组件:按钮、复选框),且产品之间搭配使用。

解决方案:定义抽象工厂,每个具体工厂生产一整套产品。

代码示例

from abc import ABC, abstractmethod

# 抽象产品 A
class Button(ABC):
    @abstractmethod
    def paint(self):
        pass

# 抽象产品 B
class Checkbox(ABC):
    @abstractmethod
    def check(self):
        pass

# 具体产品族1:Windows风格
class WinButton(Button):
    def paint(self):
        return "Win按钮绘制"

class WinCheckbox(Checkbox):
    def check(self):
        return "Win复选框勾选"

# 具体产品族2:Mac风格
class MacButton(Button):
    def paint(self):
        return "Mac按钮绘制"

class MacCheckbox(Checkbox):
    def check(self):
        return "Mac复选框勾选"

# 抽象工厂
class GUIFactory(ABC):
    @abstractmethod
    def create_button(self) -> Button:
        pass
    @abstractmethod
    def create_checkbox(self) -> Checkbox:
        pass

# 具体工厂
class WinFactory(GUIFactory):
    def create_button(self) -> Button:
        return WinButton()
    def create_checkbox(self) -> Checkbox:
        return WinCheckbox()

class MacFactory(GUIFactory):
    def create_button(self) -> Button:
        return MacButton()
    def create_checkbox(self) -> Checkbox:
        return MacCheckbox()

# 客户端
def create_ui(factory: GUIFactory):
    button = factory.create_button()
    checkbox = factory.create_checkbox()
    print(button.paint())
    print(checkbox.check())

if __name__ == "__main__":
    print("=== 抽象工厂模式(Windows)===")
    create_ui(WinFactory())
    print("\n=== 抽象工厂模式(Mac)===")
    create_ui(MacFactory())

输出

=== 抽象工厂模式(Windows)===
Win按钮绘制
Win复选框勾选

=== 抽象工厂模式(Mac)===
Mac按钮绘制
Mac复选框勾选

优缺点

  • 优点:保证产品族内一致性;易于交换产品系列。

  • 缺点:增加新产品族困难(需修改抽象工厂接口);扩展产品等级较麻烦。

适用场景:界面工具包、游戏风格切换、跨平台开发。


2.4 建造者模式(Builder)

定义:将复杂对象的构建与其表示分离,使同样的构建过程可以创建不同的表示。

问题场景:创建包含多个可选部分的对象(如电脑:CPU、内存、硬盘、显卡),构造函数参数过多且混乱。

解决方案:使用Director控制构建流程,Builder负责具体部件的装配。

代码示例

from abc import ABC, abstractmethod

# 产品
class Computer:
    def __init__(self):
        self.cpu = None
        self.ram = None
        self.storage = None
        self.gpu = None

    def __str__(self):
        return f"Computer [CPU={self.cpu}, RAM={self.ram}, Storage={self.storage}, GPU={self.gpu}]"

# 抽象建造者
class ComputerBuilder(ABC):
    @abstractmethod
    def build_cpu(self):
        pass
    @abstractmethod
    def build_ram(self):
        pass
    @abstractmethod
    def build_storage(self):
        pass
    @abstractmethod
    def build_gpu(self):
        pass
    @abstractmethod
    def get_result(self) -> Computer:
        pass

# 具体建造者:游戏电脑
class GamingComputerBuilder(ComputerBuilder):
    def __init__(self):
        self.computer = Computer()
    def build_cpu(self):
        self.computer.cpu = "Intel i9"
    def build_ram(self):
        self.computer.ram = "32GB DDR5"
    def build_storage(self):
        self.computer.storage = "1TB NVMe SSD"
    def build_gpu(self):
        self.computer.gpu = "NVIDIA RTX 4090"
    def get_result(self) -> Computer:
        return self.computer

# 具体建造者:办公电脑
class OfficeComputerBuilder(ComputerBuilder):
    def __init__(self):
        self.computer = Computer()
    def build_cpu(self):
        self.computer.cpu = "Intel i5"
    def build_ram(self):
        self.computer.ram = "16GB DDR4"
    def build_storage(self):
        self.computer.storage = "512GB SSD"
    def build_gpu(self):
        self.computer.gpu = "集成显卡"
    def get_result(self) -> Computer:
        return self.computer

# 指挥者
class Director:
    def __init__(self, builder: ComputerBuilder):
        self.builder = builder
    def construct_computer(self):
        self.builder.build_cpu()
        self.builder.build_ram()
        self.builder.build_storage()
        self.builder.build_gpu()
        return self.builder.get_result()

if __name__ == "__main__":
    print("=== 建造者模式 ===")
    gaming_builder = GamingComputerBuilder()
    director = Director(gaming_builder)
    gaming_pc = director.construct_computer()
    print(f"游戏电脑配置: {gaming_pc}")

    office_builder = OfficeComputerBuilder()
    director.builder = office_builder
    office_pc = director.construct_computer()
    print(f"办公电脑配置: {office_pc}")

输出

=== 建造者模式 ===
游戏电脑配置: Computer [CPU=Intel i9, RAM=32GB DDR5, Storage=1TB NVMe SSD, GPU=NVIDIA RTX 4090]
办公电脑配置: Computer [CPU=Intel i5, RAM=16GB DDR4, Storage=512GB SSD, GPU=集成显卡]

优缺点

  • 优点:分步构建,精细控制;复用同一构建流程创建不同表示。

  • 缺点:增加代码复杂度,需要多个类。

适用场景:复杂对象参数众多(尤其是可选参数)、配置对象、文档生成器(如PDF/HTML生成)。


3. 结构型模式

3.1 适配器模式(Adapter)

定义:将一个类的接口转换成客户希望的另一个接口,使原本因接口不兼容的类可以一起工作。

问题场景:现有类的方法签名与目标接口不符,无法直接使用。

解决方案:创建适配器类包装旧接口,转换调用。

代码示例

# 已有类(Adaptee)
class EuropeanSocket:
    def voltage(self):
        return 230
    def plug_type(self):
        return "圆形插头"

# 目标接口(Target)
class USASocket:
    def voltage(self):
        return 120
    def plug_type(self):
        return "扁平插头"

# 适配器:将欧式插座转为美式接口
class SocketAdapter(USASocket):
    def __init__(self, european_socket: EuropeanSocket):
        self.european_socket = european_socket
    def voltage(self):
        # 转换电压 230 -> 120(实际需要变压器,这里模拟)
        return 120
    def plug_type(self):
        # 转换插头形状
        return "扁平插头(适配)"

# 客户端只能使用USASocket
def charge_laptop(socket: USASocket):
    print(f"充电电压: {socket.voltage()}V, 插头类型: {socket.plug_type()}")

if __name__ == "__main__":
    print("=== 适配器模式 ===")
    eu_socket = EuropeanSocket()
    print(f"直接使用欧式插座: 电压{eu_socket.voltage()}V, {eu_socket.plug_type()}")

    # 使用适配器
    adapter = SocketAdapter(eu_socket)
    charge_laptop(adapter)

输出

=== 适配器模式 ===
直接使用欧式插座: 电压230V, 圆形插头
充电电压: 120V, 插头类型: 扁平插头(适配)

进阶:类适配器(多重继承)

class EuropeanVoltage:
    def get_voltage(self):
        return 230

class USASocketInterface:
    def supply_power(self):
        return 120

class Adapter(EuropeanVoltage, USASocketInterface):
    def supply_power(self):
        # 转换逻辑
        return 120

if __name__ == "__main__":
    adapter = Adapter()
    print(f"适配器供电: {adapter.supply_power()}V")

优缺点

  • 优点:提高类的复用性;透明性(客户端只看到目标接口)。

  • 缺点:过多适配器使系统复杂。

适用场景:集成第三方库、遗留系统改造、不同数据格式转换。


3.2 装饰器模式(Decorator)

定义:动态地给一个对象添加额外的职责,比继承更灵活。

问题场景:需要扩展一个类的功能,但又不想通过子类爆炸式增长。

解决方案:装饰器包装原有对象,在调用前后添加行为。

代码示例

from abc import ABC, abstractmethod

# 组件接口
class Coffee(ABC):
    @abstractmethod
    def cost(self) -> float:
        pass
    @abstractmethod
    def description(self) -> str:
        pass

# 具体组件
class SimpleCoffee(Coffee):
    def cost(self) -> float:
        return 5.0
    def description(self) -> str:
        return "基础咖啡"

# 装饰器基类
class CoffeeDecorator(Coffee):
    def __init__(self, coffee: Coffee):
        self._coffee = coffee
    def cost(self) -> float:
        return self._coffee.cost()
    def description(self) -> str:
        return self._coffee.description()

# 具体装饰器
class MilkDecorator(CoffeeDecorator):
    def cost(self) -> float:
        return self._coffee.cost() + 1.5
    def description(self) -> str:
        return self._coffee.description() + ", 加奶"

class SugarDecorator(CoffeeDecorator):
    def cost(self) -> float:
        return self._coffee.cost() + 0.8
    def description(self) -> str:
        return self._coffee.description() + ", 加糖"

class WhippedCreamDecorator(CoffeeDecorator):
    def cost(self) -> float:
        return self._coffee.cost() + 2.0
    def description(self) -> str:
        return self._coffee.description() + ", 奶油顶"

if __name__ == "__main__":
    print("=== 装饰器模式 ===")
    coffee = SimpleCoffee()
    print(f"{coffee.description()} 价格: {coffee.cost()}元")

    # 加奶
    coffee_with_milk = MilkDecorator(coffee)
    print(f"{coffee_with_milk.description()} 价格: {coffee_with_milk.cost()}元")

    # 加奶加糖
    coffee_with_milk_sugar = SugarDecorator(MilkDecorator(coffee))
    print(f"{coffee_with_milk_sugar.description()} 价格: {coffee_with_milk_sugar.cost()}元")

    # 豪华版:加奶+糖+奶油
    deluxe = WhippedCreamDecorator(SugarDecorator(MilkDecorator(coffee)))
    print(f"{deluxe.description()} 价格: {deluxe.cost()}元")

输出

=== 装饰器模式 ===
基础咖啡 价格: 5.0元
基础咖啡, 加奶 价格: 6.5元
基础咖啡, 加奶, 加糖 价格: 7.3元
基础咖啡, 加奶, 加糖, 奶油顶 价格: 9.3元

Python内置装饰器语法糖:函数装饰器也是该模式的体现。

import functools
def log_call(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"调用 {func.__name__} 参数: {args} {kwargs}")
        result = func(*args, **kwargs)
        print(f"返回: {result}")
        return result
    return wrapper

@log_call
def add(a, b):
    return a + b

print(add(3, 5))

优缺点

  • 优点:比继承更灵活,避免类爆炸;符合开闭原则。

  • 缺点:多层装饰导致调试困难;可能产生很多小对象。

适用场景:动态添加功能(I/O流处理、GUI组件边框、日志记录等)。


3.3 代理模式(Proxy)

定义:为其他对象提供一种代理以控制对这个对象的访问。

问题场景:需要延迟加载、访问控制、日志记录或远程访问。

解决方案:代理类与真实类实现同一接口,代理持有真实对象的引用并控制访问。

代码示例

from abc import ABC, abstractmethod

# 抽象主题
class Image(ABC):
    @abstractmethod
    def display(self):
        pass

# 真实主题:大图片加载耗时
class HighResolutionImage(Image):
    def __init__(self, filename):
        self.filename = filename
        self.load_from_disk()
    def load_from_disk(self):
        print(f"正在从磁盘加载高清图片 {self.filename} (耗时3秒)...")
        # 模拟耗时
        import time
        time.sleep(0.5)  # 仅演示,实际可设为更长
        print(f"加载完成")
    def display(self):
        print(f"显示图片: {self.filename}")

# 代理:延迟加载
class ImageProxy(Image):
    def __init__(self, filename):
        self.filename = filename
        self.real_image = None
    def display(self):
        if self.real_image is None:
            self.real_image = HighResolutionImage(self.filename)
        self.real_image.display()

# 客户端
if __name__ == "__main__":
    print("=== 代理模式(延迟加载)===")
    img1 = ImageProxy("photo1.jpg")
    img2 = ImageProxy("photo2.jpg")

    print("第一次显示photo1:")
    img1.display()
    print("第二次显示photo1:")
    img1.display()
    print("显示photo2:")
    img2.display()

输出

=== 代理模式(延迟加载)===
第一次显示photo1:
正在从磁盘加载高清图片 photo1.jpg (耗时3秒)...
加载完成
显示图片: photo1.jpg
第二次显示photo1:
显示图片: photo1.jpg
显示photo2:
正在从磁盘加载高清图片 photo2.jpg (耗时3秒)...
加载完成
显示图片: photo2.jpg

其他常见代理

  • 保护代理(控制访问权限)

  • 远程代理(隐藏网络通信)

  • 虚拟代理(延迟加载)

保护代理示例

class BankAccount:
    def withdraw(self, amount):
        print(f"取款 {amount} 元")

class AccountProxy:
    def __init__(self, user_role):
        self.role = user_role
        self.account = BankAccount()
    def withdraw(self, amount):
        if self.role == "admin":
            self.account.withdraw(amount)
        else:
            print("权限不足,无法取款")

if __name__ == "__main__":
    proxy = AccountProxy("guest")
    proxy.withdraw(100)
    admin_proxy = AccountProxy("admin")
    admin_proxy.withdraw(500)

输出

优缺点

  • 优点:职责清晰,可控制访问,支持延迟加载。

  • 缺点:增加系统复杂度,可能降低响应速度。

适用场景:远程代理(RPC)、虚拟代理(大对象加载)、安全代理(权限校验)。


3.4 外观模式(Facade)

定义:为子系统中的一组接口提供一个统一的简化接口。外观模式定义了一个高层接口,使子系统更易使用。

问题场景:复杂子系统依赖过多,客户端需要调用多个模块才能完成一个功能。

解决方案:创建外观类封装子系统的复杂交互。

代码示例

# 子系统类:音视频解码、投影仪、灯光、功放等
class DVDPlayer:
    def on(self):
        print("DVD播放器开机")
    def play(self, movie):
        print(f"播放电影 {movie}")
    def off(self):
        print("DVD播放器关机")

class Projector:
    def on(self):
        print("投影仪开机")
    def wide_screen_mode(self):
        print("设置为宽屏模式")
    def off(self):
        print("投影仪关机")

class SoundSystem:
    def on(self):
        print("音响系统开机")
    def set_volume(self, level):
        print(f"音量设置为 {level}")
    def off(self):
        print("音响系统关机")

class Lights:
    def dim(self, level):
        print(f"灯光调暗至 {level}%")
    def on(self):
        print("灯光全亮")

# 外观类:家庭影院
class HomeTheaterFacade:
    def __init__(self, dvd, projector, sound, lights):
        self.dvd = dvd
        self.projector = projector
        self.sound = sound
        self.lights = lights

    def watch_movie(self, movie):
        print("\n准备观看电影...")
        self.lights.dim(20)
        self.projector.on()
        self.projector.wide_screen_mode()
        self.sound.on()
        self.sound.set_volume(60)
        self.dvd.on()
        self.dvd.play(movie)
        print("开始放映!\n")

    def end_movie(self):
        print("\n关闭电影...")
        self.dvd.off()
        self.sound.off()
        self.projector.off()
        self.lights.on()
        print("电影结束,灯光亮起\n")

if __name__ == "__main__":
    dvd = DVDPlayer()
    projector = Projector()
    sound = SoundSystem()
    lights = Lights()
    home_theater = HomeTheaterFacade(dvd, projector, sound, lights)

    home_theater.watch_movie("阿凡达")
    home_theater.end_movie()

输出

准备观看电影...
灯光调暗至 20%
投影仪开机
设置为宽屏模式
音响系统开机
音量设置为 60
DVD播放器开机
播放电影 阿凡达
开始放映!


关闭电影...
DVD播放器关机
音响系统关机
投影仪关机
灯光全亮
电影结束,灯光亮起

优缺点

  • 优点:简化客户端调用,降低耦合度;分层结构更清晰。

  • 缺点:可能成为上帝对象,过度耦合所有子系统。

适用场景:复杂库或框架的入口(如requests库简化HTTP调用);需要为复杂子系统提供简单接口。


4. 行为型模式

4.1 观察者模式(Observer)

定义:定义对象间一对多的依赖关系,当一个对象状态改变时,所有依赖它的对象都得到通知并自动更新。

问题场景:事件驱动系统、数据更新后需要刷新多个界面(如MVC中的模型与视图)。

解决方案:被观察者(Subject)维护观察者列表,状态变化时调用观察者更新方法。

代码示例

from abc import ABC, abstractmethod

# 观察者接口
class Observer(ABC):
    @abstractmethod
    def update(self, temperature, humidity, pressure):
        pass

# 被观察者(主题)
class WeatherStation:
    def __init__(self):
        self.observers = []
        self.temperature = 0
        self.humidity = 0
        self.pressure = 0

    def attach(self, observer: Observer):
        if observer not in self.observers:
            self.observers.append(observer)

    def detach(self, observer: Observer):
        self.observers.remove(observer)

    def notify(self):
        for observer in self.observers:
            observer.update(self.temperature, self.humidity, self.pressure)

    def set_measurements(self, temp, hum, press):
        self.temperature = temp
        self.humidity = hum
        self.pressure = press
        self.notify()

# 具体观察者1:手机App显示
class PhoneDisplay(Observer):
    def update(self, temperature, humidity, pressure):
        print(f"手机App显示: 温度={temperature}°C, 湿度={humidity}%, 气压={pressure}hPa")

# 具体观察者2:大屏幕显示器
class LargeScreenDisplay(Observer):
    def update(self, temperature, humidity, pressure):
        print(f"大屏幕: 当前温度 {temperature}°C | 湿度 {humidity}%")

if __name__ == "__main__":
    weather_station = WeatherStation()
    phone = PhoneDisplay()
    large_screen = LargeScreenDisplay()

    weather_station.attach(phone)
    weather_station.attach(large_screen)

    print("=== 天气数据更新 ===")
    weather_station.set_measurements(25, 65, 1013)
    weather_station.set_measurements(27, 70, 1010)

    print("\n解绑手机App后:")
    weather_station.detach(phone)
    weather_station.set_measurements(26, 68, 1012)

输出

=== 天气数据更新 ===
手机App显示: 温度=25°C, 湿度=65%, 气压=1013hPa
大屏幕: 当前温度 25°C | 湿度 65%
手机App显示: 温度=27°C, 湿度=70%, 气压=1010hPa
大屏幕: 当前温度 27°C | 湿度 70%

解绑手机App后:
大屏幕: 当前温度 26°C | 湿度 68%

Python内置事件机制:可以使用weakref避免内存泄漏或使用asyncio等。

优缺点

  • 优点:支持广播通信,松耦合(主题只知道观察者接口)。

  • 缺点:观察者太多或更新频繁影响性能;循环依赖可能导致死循环。

适用场景:事件处理系统、股票行情推送、GUI监听器、模型-视图-控制器(MVC)。


4.2 策略模式(Strategy)

定义:定义一系列算法,将每个算法封装起来,并使它们可以互相替换。策略模式让算法独立于使用它的客户端而变化。

问题场景:多种支付方式、排序算法切换、折扣计算等。

解决方案:定义策略接口,具体策略实现算法,上下文类组合策略。

代码示例

from abc import ABC, abstractmethod

# 策略接口
class PaymentStrategy(ABC):
    @abstractmethod
    def pay(self, amount):
        pass

# 具体策略1:信用卡支付
class CreditCardPayment(PaymentStrategy):
    def __init__(self, card_number, cvv):
        self.card_number = card_number
        self.cvv = cvv
    def pay(self, amount):
        print(f"使用信用卡 {self.card_number[-4:]} 支付 {amount} 元,CVV验证通过")

# 具体策略2:支付宝支付
class AlipayPayment(PaymentStrategy):
    def __init__(self, account):
        self.account = account
    def pay(self, amount):
        print(f"支付宝账户 {self.account} 支付 {amount} 元")

# 具体策略3:微信支付
class WechatPayment(PaymentStrategy):
    def __init__(self, openid):
        self.openid = openid
    def pay(self, amount):
        print(f"微信用户 {self.openid} 支付 {amount} 元")

# 上下文:购物车
class ShoppingCart:
    def __init__(self):
        self.items = []
        self.payment_strategy = None

    def add_item(self, item, price):
        self.items.append((item, price))

    def total(self):
        return sum(price for _, price in self.items)

    def set_payment_strategy(self, strategy: PaymentStrategy):
        self.payment_strategy = strategy

    def checkout(self):
        total = self.total()
        if self.payment_strategy is None:
            raise Exception("请先设置支付策略")
        self.payment_strategy.pay(total)

if __name__ == "__main__":
    cart = ShoppingCart()
    cart.add_item("Python编程书", 79)
    cart.add_item("机械键盘", 299)
    print(f"购物车总金额: {cart.total()}元")

    # 使用信用卡支付
    cart.set_payment_strategy(CreditCardPayment("1234-5678-9012-3456", "123"))
    print("--- 信用卡支付 ---")
    cart.checkout()

    # 更换策略为支付宝
    cart.set_payment_strategy(AlipayPayment("alice@example.com"))
    print("--- 支付宝支付 ---")
    cart.checkout()

输出

购物车总金额: 378元
--- 信用卡支付 ---
使用信用卡 3456 支付 378 元,CVV验证通过
--- 支付宝支付 ---
支付宝账户 alice@example.com 支付 378 元

结合lambda简化策略(Python特色):

strategies = {
    "add": lambda x, y: x + y,
    "subtract": lambda x, y: x - y,
    "multiply": lambda x, y: x * y,
}
def execute_strategy(op, a, b):
    return strategies[op](a, b)

print(execute_strategy("add", 10, 5))   # 15
print(execute_strategy("multiply", 3, 4)) # 12

优缺点

  • 优点:消除条件语句,易于扩展新策略;策略可复用。

  • 缺点:客户端必须了解不同策略;增加对象数量。

适用场景:多种算法变体(排序、压缩、加密);避免长if-else或switch。


4.3 模板方法模式(Template Method)

定义:定义一个操作中的算法骨架,将某些步骤延迟到子类中实现。子类可以重定义算法的特定步骤而不改变算法结构。

问题场景:多个子类共享相同步骤,但某些步骤实现各异(如数据挖掘:读取数据、分析、输出报告)。

解决方案:抽象类定义模板方法(通常为final),其中调用基本方法(抽象或hook)。

代码示例

from abc import ABC, abstractmethod

# 抽象类
class DataProcessor(ABC):
    # 模板方法
    def process(self):
        self.load_data()
        self.analyze_data()
        self.save_results()
        self.write_report()  # hook方法可选覆写

    def load_data(self):
        print("从文件加载数据...")

    @abstractmethod
    def analyze_data(self):
        pass

    def save_results(self):
        print("保存分析结果到数据库")

    # hook方法(默认实现,子类可覆盖)
    def write_report(self):
        print("生成标准报告")

# 具体子类:销售数据分析
class SalesDataProcessor(DataProcessor):
    def analyze_data(self):
        print("分析销售额趋势,计算同比增长率")

    def write_report(self):
        print("生成销售报告PDF,附带图表")

# 具体子类:用户行为分析
class UserBehaviorProcessor(DataProcessor):
    def analyze_data(self):
        print("分析用户点击流,计算转化率")

    # 使用默认的write_report,不覆盖

if __name__ == "__main__":
    print("=== 销售数据处理 ===")
    sales = SalesDataProcessor()
    sales.process()

    print("\n=== 用户行为数据处理 ===")
    behavior = UserBehaviorProcessor()
    behavior.process()

输出

=== 销售数据处理 ===
从文件加载数据...
分析销售额趋势,计算同比增长率
保存分析结果到数据库
生成销售报告PDF,附带图表

=== 用户行为数据处理 ===
从文件加载数据...
分析用户点击流,计算转化率
保存分析结果到数据库
生成标准报告

进阶:钩子方法控制流程

class CoffeeMaker(ABC):
    def prepare_recipe(self):
        self.boil_water()
        self.brew()
        self.pour_in_cup()
        if self.customer_wants_condiments():
            self.add_condiments()

    def boil_water(self):
        print("烧开水")
    def pour_in_cup(self):
        print("倒入杯中")
    @abstractmethod
    def brew(self):
        pass
    @abstractmethod
    def add_condiments(self):
        pass
    def customer_wants_condiments(self):
        return True  # 钩子

class Tea(CoffeeMaker):
    def brew(self):
        print("浸泡茶叶")
    def add_condiments(self):
        print("加柠檬")
    def customer_wants_condiments(self):
        # 询问用户
        answer = input("要加柠檬吗?(y/n): ")
        return answer.lower() == 'y'

Tea().prepare_recipe()

优缺点

  • 优点:复用代码,避免重复;符合好莱坞原则(不要调用我们,我们调用你)。

  • 缺点:每个不同实现需要子类,增加了复杂度;子类对模板方法的影响有限。

适用场景:框架基类、算法骨架固定且部分可变、工作流引擎。


4.4 状态模式(State)

定义:允许对象在其内部状态改变时改变它的行为,看起来好像修改了其类。

问题场景:对象的行为依赖于其状态,并且状态转换逻辑复杂(如电梯、订单状态机)。

解决方案:将状态封装为独立类,上下文委托给当前状态对象执行行为。

代码示例

from abc import ABC, abstractmethod

# 状态接口
class State(ABC):
    @abstractmethod
    def handle(self, context):
        pass

# 具体状态:已订货
class OrderedState(State):
    def handle(self, context):
        print("订单已创建,等待支付")
        context.state = PaidState()  # 状态转移

# 具体状态:已支付
class PaidState(State):
    def handle(self, context):
        print("支付完成,正在备货")
        context.state = ShippedState()

# 具体状态:已发货
class ShippedState(State):
    def handle(self, context):
        print("已发货,等待确认收货")
        context.state = DeliveredState()

# 具体状态:已完成
class DeliveredState(State):
    def handle(self, context):
        print("订单已完成,感谢购买!")

# 上下文:订单
class Order:
    def __init__(self):
        self.state = OrderedState()

    def next_state(self):
        self.state.handle(self)

    def cancel(self):
        # 可定义取消逻辑,某些状态允许取消
        print("订单已取消")

if __name__ == "__main__":
    order = Order()
    order.next_state()  # 已订货 -> 等待支付
    order.next_state()  # 已支付 -> 备货
    order.next_state()  # 已发货 -> 等待收货
    order.next_state()  # 已完成
    order.next_state()  # 已完成状态无转移,将再次执行(但DeliveredState内部没有转移,只会打印)

输出

订单已创建,等待支付
支付完成,正在备货
已发货,等待确认收货
订单已完成,感谢购买!
订单已完成,感谢购买!

优化:在状态类中增加条件防止无限循环(实际开发中可以增加状态转换条件检查)。

电梯状态示例

class ElevatorState(ABC):
    @abstractmethod
    def open_door(self, elevator): pass
    @abstractmethod
    def close_door(self, elevator): pass
    @abstractmethod
    def move(self, elevator): pass

class IdleState(ElevatorState):
    def open_door(self, elevator):
        print("开门")
        elevator.state = DoorOpenState()
    def close_door(self, elevator):
        print("门已关,无法重复关闭")
    def move(self, elevator):
        print("空闲状态,未移动")

class DoorOpenState(ElevatorState):
    def open_door(self, elevator):
        print("门已开")
    def close_door(self, elevator):
        print("关门")
        elevator.state = IdleState()
    def move(self, elevator):
        print("门未关,不能移动")

class Elevator:
    def __init__(self):
        self.state = IdleState()
    def open(self): self.state.open_door(self)
    def close(self): self.state.close_door(self)
    def move(self): self.state.move(self)

e = Elevator()
e.open()
e.move()
e.close()
e.move()

优缺点

  • 优点:将状态转换逻辑局部化;增加新状态容易;减少条件分支。

  • 缺点:状态过多会增加类的数量;状态模式对开闭原则支持较好(增加状态不变现有代码)。

适用场景:有限状态机、游戏角色不同形态、订单/工作流状态管理。


4.5 责任链模式(Chain of Responsibility)

定义:使多个对象都有机会处理请求,避免请求发送者与接收者耦合。将这些对象连成一条链,沿着链传递请求直到被处理。

问题场景:审批流程(员工请假->主管->经理->HR)、日志级别处理、过滤器链。

解决方案:每个处理器持有下一个引用,处理不了就转发。

代码示例

from abc import ABC, abstractmethod

# 处理器抽象
class Handler(ABC):
    def __init__(self):
        self._next_handler = None

    def set_next(self, handler):
        self._next_handler = handler
        return handler

    @abstractmethod
    def handle(self, request):
        if self._next_handler:
            return self._next_handler.handle(request)
        return None

# 具体处理器1:技术负责人(可处理500元以下报销)
class TechLead(Handler):
    def handle(self, request):
        if request["type"] == "reimbursement" and request["amount"] <= 500:
            print(f"技术负责人审批了 {request['amount']} 元报销单")
            return True
        else:
            print("技术负责人无法处理,转交上级")
            return super().handle(request)

# 具体处理器2:项目经理(可处理1000元以下)
class ProjectManager(Handler):
    def handle(self, request):
        if request["type"] == "reimbursement" and request["amount"] <= 1000:
            print(f"项目经理审批了 {request['amount']} 元报销单")
            return True
        else:
            print("项目经理无法处理,转交上级")
            return super().handle(request)

# 具体处理器3:总监(可处理5000元以下)
class Director(Handler):
    def handle(self, request):
        if request["type"] == "reimbursement" and request["amount"] <= 5000:
            print(f"总监审批了 {request['amount']} 元报销单")
            return True
        else:
            print("总监无法处理,需要CEO批准")
            return super().handle(request)

if __name__ == "__main__":
    # 构建责任链
    tech_lead = TechLead()
    pm = ProjectManager()
    director = Director()
    tech_lead.set_next(pm).set_next(director)

    requests = [
        {"type": "reimbursement", "amount": 200},
        {"type": "reimbursement", "amount": 800},
        {"type": "reimbursement", "amount": 3000},
        {"type": "reimbursement", "amount": 10000},
    ]

    for req in requests:
        print(f"\n处理报销 {req['amount']} 元")
        result = tech_lead.handle(req)
        if not result:
            print(f"{req['amount']} 元报销未被任何领导审批")

输出

处理报销 200 元
技术负责人审批了 200 元报销单

处理报销 800 元
技术负责人无法处理,转交上级
项目经理审批了 800 元报销单

处理报销 3000 元
技术负责人无法处理,转交上级
项目经理无法处理,转交上级
总监审批了 3000 元报销单

处理报销 10000 元
技术负责人无法处理,转交上级
项目经理无法处理,转交上级
总监无法处理,需要CEO批准
10000 元报销未被任何领导审批

优缺点

  • 优点:降低耦合,增加灵活性;动态调整链。

  • 缺点:请求可能未被处理;调试困难(链路过长)。

适用场景:日志框架(不同级别去不同输出)、Servlet过滤器、事件冒泡。


5. 总结与下载说明

本文详细介绍了15种常见的设计模式,每种模式均提供了完整的Python代码示例实际运行输出,确保你可以复制代码并亲自运行验证。理解这些模式不仅能提升代码质量,还能让你在团队协作中使用统一的术语交流。

免费 HTTPS 证书:acme.sh + Let's Encrypt + Nginx

作者 dy_Alley
2026年5月6日 20:32

一、安装 acme.sh

curl https://get.acme.sh | sh
source ~/.bashrc
acme.sh --version

二、切换默认 CA 为 Let's Encrypt

acme.sh 从 v3.0 起默认使用 ZeroSSL,需要邮箱注册,建议切换为 Let's Encrypt(免费、自动续期、无需注册):

acme.sh --set-default-ca --server letsencrypt

验证是否生效:

cat /root/.acme.sh/account.conf | grep CA_SERVER
# 应输出: CA_SERVER="https://acme-v02.api.letsencrypt.org/directory"

不需要手动修改 account.conf,--set-default-ca 会自动写入。手动 echo >> 追加反而可能产生重复条目。

如果不切换,签发时会看到类似提示要求注册邮箱:

[Wed Mar 11 04:59:28 PM CST 2026] acme.sh --register-account -m my@example.com

三、签发证书

有三种方式,根据场景选择:

方式一:standalone(临时用)

acme.sh --issue -d example.com -d www.example.com --standalone

要求:

  • 必须占用 80 端口,签发前需要先停掉 Nginx:systemctl stop nginx
  • 签发完记得重新启动 Nginx

适合临时签发或测试,不适合线上环境(续签时也要停 Nginx)。

方式二:webroot(推荐生产环境)

acme.sh --issue -d example.com -d www.example.com -w /var/www/acme

前置条件:必须先在 Nginx 中配好验证路径,否则签发会失败:

server {
    listen 80;
    server_name example.com www.example.com;

    location ^~ /.well-known/acme-challenge/ {
        root /var/www/acme;
        default_type "text/plain";
    }
}

配好后 reload Nginx 并验证:

systemctl reload nginx
mkdir -p /var/www/acme/.well-known/acme-challenge
echo "test" > /var/www/acme/.well-known/acme-challenge/test
curl http://example.com/.well-known/acme-challenge/test
# 应输出: test

验证通过后再执行签发命令。

方式三:nginx 自动模式(简单环境可用)

acme.sh --issue -d example.com -d www.example.com --nginx

acme.sh 会临时修改 Nginx 配置来完成验证,验证后还原。多 server 块或复杂配置下容易出问题,不太推荐。

签发成功输出示例

[Thu Mar 12 08:41:37 PM CST 2026] Your cert is in: /root/.acme.sh/example.com_ecc/example.com.cer
[Thu Mar 12 08:41:37 PM CST 2026] Your cert key is in: /root/.acme.sh/example.com_ecc/example.com.key
[Thu Mar 12 08:41:37 PM CST 2026] The intermediate CA cert is in: /root/.acme.sh/example.com_ecc/ca.cer
[Thu Mar 12 08:41:37 PM CST 2026] And the full-chain cert is in: /root/.acme.sh/example.com_ecc/fullchain.cer

四、安装证书到固定路径

不要直接在 Nginx 中引用 /root/.acme.sh/ 下的证书文件。 续签后这些文件内容会变化,但 Nginx 不会自动加载,会导致证书过期。

正确做法是用 --install-cert 把证书复制到固定路径,并配置续签后自动 reload:

mkdir -p /etc/nginx/ssl

acme.sh --install-cert -d example.com \
  --ecc \
  --key-file       /etc/nginx/ssl/example.com.key \
  --fullchain-file /etc/nginx/ssl/example.com.crt \
  --reloadcmd      "systemctl reload nginx"

这一步做了三件事:

  1. 把证书复制到 /etc/nginx/ssl/,路径固定不变
  2. 记录 reloadcmd,续签后自动执行 systemctl reload nginx
  3. 后续自动续签时会自动重复这个流程,不需要人工干预

五、自动续签

acme.sh 安装时会自动创建 cron 定时任务,证书在到期前会自动续签(Let's Encrypt 证书有效期 90 天)。

查看定时任务:

crontab -l

手动测试续签是否正常(不会真的重新签发,除非加 --force):

acme.sh --renew -d example.com --ecc --force

签发时用的 ECC 证书(路径带 _ecc),手动续签时也要加 --ecc,避免跟同域名的 RSA 证书混淆。

六、Nginx 完整配置

server {
    listen 80;
    server_name example.com www.example.com;

    # acme.sh 验证路径,续签时需要
    location ^~ /.well-known/acme-challenge/ {
        root /var/www/acme;
        default_type "text/plain";
    }

    # 其余请求全部跳转 HTTPS
    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name example.com www.example.com;

    ssl_certificate     /etc/nginx/ssl/example.com.crt;
    ssl_certificate_key /etc/nginx/ssl/example.com.key;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_session_timeout 10m;
    ssl_session_cache shared:SSL:10m;

    root /var/www/html;
    index index.html;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

如果需要 WebSocket 支持,在 http 块中加 map,并在 location 中补上 header:

# 放在 http {} 块中
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

# 在需要 WebSocket 的 location 中添加
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;

验证并重载配置:

nginx -t
systemctl reload nginx

reload 而不是 restart,reload 不会中断现有连接。

大前端全栈实践:章节五(npm包抽离)

作者 RichardZhiLi
2026年5月6日 20:08

截止到这个章节,我们的elpis项目基本上已经算是开发完成了。

回顾前几个章节,我们完成了需求确立 -> 系统架构设计 -> 实现 -> 应用检验四个流程。现在是时候将其抽象为npm包发布并开放给我们真实业务项目使用了。

本篇文章重点聚焦在抽离业务逻辑,封装成npm包重构代码的过程

一、源码重构篇

  1. 开放框架入口(能力)

在重构的过程中,第一个遇到的难题就是需要判断和决定咱们所做的这份框架,它应该对外暴露哪些能力,在思考这一点的时候,不妨回归框架本身设计的目的,以elpis为例。这份框架它本质上是一个读取领域模型(配置文件)后输出从前端到后端一体化打包产物的全栈框架**。在此之中,读取领域模型是通过我们的后端服务来完成的,我们通过约定用户将所有的配置文件放置在app/model目录下,在后端服务启动时会自动读取并加载。那么剩下的所需要暴露的,只有前端/后端的构建能力了。因此改造实施步骤如下

原有项目入口是下面这样的,当时我们在开发项目时为了图方便,直接引入了我们自定义的服务然后便直接启动了

elpis/index.js
const ElpisCore = require('./elpis-core');
ElpisCore.start(
    {
        name: "Richard's Elpis",
        homePage: '/view/project-list',
    }
);

这样做明显是不行的,为什么?因为如果这样做用户导入框架时就会立马自行启动我们服务了,这肯定不是我们所期望的。我们所期望的是用户能够自行决定什么时候启动我们的服务(器),因此我们需要将其抽象为一个函数,比如说就叫做serverStart,并暴露给用户来自行决定调用,那么我们应该改写为下列形式。

elpis/index.js
module.exports = {
    serverStart(options={}){
        ElpisCore.start(options)
    }
}

同时我们也能够注意到,仅仅只是启动服务可能并不能够完全方便用户使用。例如有时候用户想要在开发过程中自行调试或者增加些中间件什么的,那么在此时我们就需要将服务器内部核心挂载对象暴露给我们的用户。在这个例子中,由于我们底层使用的是Koa2来完成的,因此最简单的实现方式就是使用一个变量接住这个对象并返回回去,于是便有了

elpis/index.js
serverStart(options={}){
        const app = ElpisCore.start(options)
        return app;
 }

到了这一步你就应该能注意到,其实这一个操作过程对应着我们上述思考过程中的“前端到后端一体化打包产物”的后端构建部分。因为后端在用户视角看来本质上就是一个运行中的服务器,它所提供的服务,并不存在什么打包产物可言,因此到这一步为止我们已经完成了暴露后端的部分

紧接着就是考虑如何暴露前端。我们也是模仿暴露后端的思路,做一个暴露前端的函数,比如就叫做frontendBuild。当用户调用它的时候,本质上就是在运行我们内部的webpack构建命令,并执行构建操作。 这些构建命令和指令在前面工程化的章节已讲解过,并且也讲解了环境区分,这里不再赘述。

elpis/index.js
const FEBuildDev = require('./app/webpack/dev')
const FEBuildProd = require('./app/webpack/prod')
module.exports = {
    frontendBuild(env){
    if(env === 'local'){
        FEBuildDev()
    } else {
        FEBuildProd()
    }
    }
}

最终完成后的样子

elpis/index.js
const ElpisCore = require('./elpis-core');
const FEBuildDev = require('./app/webpack/dev')
const FEBuildProd = require('./app/webpack/prod')
module.exports = {
    serverStart(options={}){
        const app = ElpisCore.start(options)
        return app;
     }
    ,
    frontendBuild(env){
        if(env === 'local'){
            FEBuildDev()
        } else {
            FEBuildProd()
        }
    }
}

一旦我们定义好了我们对外暴露的能力(函数)后,后续的改造思路就很清晰了,只需要沿着我们所导入的模块(比如elpis-core/index.js 以及app/webpack/dev.js,app/webpack/prod.js) 根据报错信息顺藤摸瓜的去改造内部对应的函数/代码块就好了。

小提示:

想要本地调试自己本地开发的框架的话,可以使用npm link命令。 首先进入自己的框架项目下,运行npm link。再去到一个Demo项目目录下,执行npm link 你的包名,这样就成功将你的框架项目引入过来了。会在对应的Demo目录的node_modules下方体现出来。以我的elpis项目为例, 那么我在elpis项目目录下,执行npm link 再去到elpisDemo目录下,执行npm link @richardzhili/elpis 那么就会在我的elpisDemo/node_modules下生成@richardzhili/elpis这个包

引入.png

启动项目.png 需要注意的是,应用项目在使用npm link 包名的时候,必须和框架项目package.json下的name对应一致,否则会引入失败

  1. loader改造 顺着上面的思路,我们在elpis-demo中引入elpis后,启动会发现报错,报错信息来自于各个loader。我们以其中一个controller loader为例子进行分析
elpis-core/loader/controller.js
const path = require('path');
const glob = require('glob');
const { sep } = path;
/**
 * controller loader
 * 加载所有controller,可通过'app.controller.${目录}.${文件}'访问
 * 例子:
 * app/controller
 *  |
 *  | -- custom-module
 *            |
 *            | -- custom-controller.js
 *  => app.controller.customModule.customController //通过这种方式访问对应的controller
 * 其内部实现和middleware类似,区别在于controller目录下的文件都是类,但是middleware目录下的模块都是可执行函数,所以需要new一下成为实例后挂载到app对象上
 * @param {object} app Koa 实例
 */
module.exports = (app) => {
    //读取app/controller/**/*.js下的文件
    const controllerPath = path.resolve(app.businessPath, 'controller');
    const fileList = glob.sync(path.resolve(controllerPath, `.${sep}/**${sep}*.js`));
    //遍历所有文件目录,把内容加载到app.middlewares对象上
    const controller = {};
    fileList.forEach((filePath) => {
        const filePathArr = filePath.split(`${sep}`);
        const controllerPathArr = filePathArr.filter((item, index) => index > filePathArr.indexOf('controller')); //过滤出controller/xxx/xxx后续的文件名称和目录名称
        controllerPathArr.forEach((name, index, thisArg) => {
            name = name.replace(/[_-][a-z]/ig, (s) => s.charAt(1).toUpperCase());//返回匹配到的-_号的后面一个字母,并将这个字母转换为大写并返回。寓意在于将短横线命名改为驼峰命名
            name = name.replace('.js', ''); //去掉.js后缀
            thisArg[index] = name;
        })
        //根据切分好的驼峰文件名称,将controller对象进行层层嵌套,最终把文件内容加载到对应的层级上
        //例如app/controller/custom-module/custom-module-entry.js -> app.controller.customModule.customModuleEntry
        let tempController = controller;
        for (let i = 0; i < controllerPathArr.length; i++) {
            if (i === controllerPathArr.length - 1) {
                const ControllerClass = require(filePath)(app);
                tempController[controllerPathArr[i]] = new ControllerClass(); //把controller文件导出的类new一下成为实例挂载到app.controller上
                return;
            }
            if (!tempController[controllerPathArr[i]]) {
                tempController[controllerPathArr[i]] = {};
            }
            tempController = tempController[controllerPathArr[i]];
        }
    })
    app.controller = controller; //把加载好的controller对象挂载到app.controller上
}

报错提示是因为找不到对应的目录和文件(app.businessPath和controllerPath),即app/controllers目录。这个也很正常,因为我们的项目启动之初,定义的app.businessPath为process.cwd() + '/app' process.cwd() + '/app' + 'controller',而process.cwd()在运行时变成了用户项目的根目录,而用户项目没有建立对应的app/controller目录和对应文件,所以才会报这个错误。解决方案也很简单,就是在elpisDemo项目下建立对应目录就好了,这个是我们框架的约定。而这个错误并不是我们想讨论的话题,我们真正想讨论的话题是,我们这种写法虽然能够引入用户定义的controller,但是好像我们自己沉淀的那些个controller,即预置在框架内部的controller,好像就没法读进来了? 所以,我们需要改造原有的代码,从而保证我们框架内部沉淀的controller能够一并加载进来,并与用户的合并。

因此,我们需要增加一段读取我们框架项目contoller目录的逻辑,并执行同样的文件处理函数。于是便将处理文件的函数封装为了'handleFile',同时我们自己沉淀的controller,通过__dirname这个魔术变量引入

elpis-core/loader/controller.js
const path = require('path');
const glob = require('glob');
const { sep } = path;
/**
 * controller loader
 * 加载所有controller,可通过'app.controller.${目录}.${文件}'访问
 * 例子:
 * app/controller
 *  |
 *  | -- custom-module
 *            |
 *            | -- custom-controller.js
 *  => app.controller.customModule.customController //通过这种方式访问对应的controller
 * 其内部实现和middleware类似,区别在于controller目录下的文件都是类,但是middleware目录下的模块都是可执行函数,所以需要new一下成为实例后挂载到app对象上
 * @param {object} app Koa 实例
 */
module.exports = (app) => {

    //遍历所有文件目录,把内容加载到app.controller对象上
    const controller = {};

    //读取elpis-core/app/controller/**/*.js下的文件
    const elpisControllerPath = path.resolve(__dirname, '..', '..', 'app', 'controller');
    const elpisFileList = glob.sync(path.resolve(elpisControllerPath, `.${sep}/**${sep}*.js`));
    elpisFileList.forEach(handleFile)

    //读取app/controller/**/*.js下的文件
    const businessControllerPath = path.resolve(app.businessPath, 'controller');
    const businessFileList = glob.sync(path.resolve(businessControllerPath, `.${sep}/**${sep}*.js`));
    businessFileList.forEach(handleFile)

    function handleFile(filePath) {
        const filePathArr = filePath.split(`${sep}`);
        const controllerPathArr = filePathArr.filter((item, index) => index > filePathArr.indexOf('controller')); //过滤出controller/xxx/xxx后续的文件名称和目录名称
        controllerPathArr.forEach((name, index, thisArg) => {
            name = name.replace(/[_-][a-z]/ig, (s) => s.charAt(1).toUpperCase());//返回匹配到的-_号的后面一个字母,并将这个字母转换为大写并返回。寓意在于将短横线命名改为驼峰命名
            name = name.replace('.js', ''); //去掉.js后缀
            thisArg[index] = name;
        })
        //根据切分好的驼峰文件名称,将controller对象进行层层嵌套,最终把文件内容加载到对应的层级上
        //例如app/controller/custom-module/custom-module-entry.js -> app.controller.customModule.customModuleEntry
        let tempController = controller;
        for (let i = 0; i < controllerPathArr.length; i++) {
            if (i === controllerPathArr.length - 1) {
                const ControllerClass = require(filePath)(app);
                tempController[controllerPathArr[i]] = new ControllerClass(); //把controller文件导出的类new一下成为实例挂载到app.controller上
                return;
            }
            if (!tempController[controllerPathArr[i]]) {
                tempController[controllerPathArr[i]] = {};
            }
            tempController = tempController[controllerPathArr[i]];
        }
    }
    app.controller = controller; //把加载好的controller对象挂载到app.controller上
}

其他的所有loader都是相同的处理逻辑,增加了__dirname导入我们自己项目底下的对应部分,然后将文件处理函数封装并为我们的文件调用。

另外,中间件我们在启动服务器时,也仅仅只是从我们自己的项目中引入,这个地方也需要改造为从用户的目录中引入,并与我们自己的中间件合并。 原来的服务器启动文件

elpis-core/index.js
const Koa = require('koa');
const path = require('path');
const env = require('./env');
const configLoader = require('./loader/config'); //配置解析器
const extendLoader = require('./loader/extend'); //扩展解析器
const serviceLoader = require('./loader/service'); //服务解析器
const controllerLoader = require('./loader/controller'); //控制器解析器
const middlewareLoader = require('./loader/middleware'); //中间件解析器
const routerSchemaLoader = require('./loader/router-schema'); //路由参数校验解析器
const routerLoader = require('./loader/router'); //路由解析器
module.exports = {
    /**
     * ElpisCore启动函数(主入口)
     * @param {*} options 项目配置
     * options = {
     * name //项目名称
     * homePath //项目主页路径
     * }
     */
    start(options = {}) {
        const app = new Koa(); //初始化Koa实例

        /**
         * 配置调整
         */
        app.options = options;
        app.baseDir = process.cwd(); //基础路径
        app.businessPath = path.join(app.baseDir, 'app'); //业务代码路径
        app.env = env(); //环境变量
        console.log('elpis-core running in environment: ' + app.env.get());

        //调用各个loader
        configLoader(app);
        console.log('load config loader success');
        extendLoader(app);
        console.log('load extend loader success');
        serviceLoader(app);
        console.log('load service loader success');
        controllerLoader(app);
        console.log('load controller loader success');
        middlewareLoader(app);
        console.log('load middleware success');
        routerSchemaLoader(app);
        console.log('load router schema loader success');
        try {
            require(path.resolve(app.businessPath, 'middleware.js'))(app)
            console.log('load global middleware done');
        }
        catch (err) {
            console.error('[exception] there is no global middleware file.')
        }
        routerLoader(app);
        console.log('load router loader success');
        /**
         * try catch 捕获,并启动监听
         */
        try {
            const port = process.env.PORT || 8080;
            const host = process.env.HOST || '0.0.0.0';
            app.listen(port, host);
            console.log(`Server running at http://${host}:${port}/`);
        }
        catch (err) {
            console.error(err);
        }
        return app;
    }
}

增加部分

elpis-core/index.js
 //注册elpis全局中间件
        const elpisMiddlewarePath = path.resolve(__dirname, '..', 'app', 'middleware.js');
        try {
            require(elpisMiddlewarePath)(app)
            console.log('[start] load global elpis middleware done');
        }
        catch (err) {
            console.error('[exception] there is no global elpis middleware file.')
        }


        //注册业务全局中间件
        try {
            require(path.resolve(app.businessPath, 'middleware.js'))(app)
            console.log('[start] load global business middleware done');
        }
        catch (err) {
            console.error('[exception] there is no global business middleware file.')
        }

最终版本:

const Koa = require('koa');
const path = require('path');
const env = require('./env');
const configLoader = require('./loader/config'); //配置解析器
const extendLoader = require('./loader/extend'); //扩展解析器
const serviceLoader = require('./loader/service'); //服务解析器
const controllerLoader = require('./loader/controller'); //控制器解析器
const middlewareLoader = require('./loader/middleware'); //中间件解析器
const routerSchemaLoader = require('./loader/router-schema'); //路由参数校验解析器
const routerLoader = require('./loader/router'); //路由解析器
module.exports = {
    /**
     * ElpisCore启动函数(主入口)
     * @param {*} options 项目配置
     * options = {
     * name //项目名称
     * homePath //项目主页路径
     * }
     */
    start(options = {}) {
        const app = new Koa(); //初始化Koa实例

        /**
         * 配置调整
         */
        app.options = options;
        app.baseDir = process.cwd(); //基础路径
        app.businessPath = path.join(app.baseDir, 'app'); //业务代码路径
        app.env = env(); //环境变量
        console.log('elpis-core running in environment: ' + app.env.get());

        //调用各个loader
        configLoader(app);
        console.log('[start] load config loader success');
        extendLoader(app);
        console.log('[start] load extend loader success');
        serviceLoader(app);
        console.log('[start] load service loader success');
        controllerLoader(app);
        console.log('[start] load controller loader success');
        middlewareLoader(app);
        console.log('[start] load middleware success');
        routerSchemaLoader(app);
        console.log('[start] load router schema loader success');

        //注册elpis全局中间件
        const elpisMiddlewarePath = path.resolve(__dirname, '..', 'app', 'middleware.js');
        try {
            require(elpisMiddlewarePath)(app)
            console.log('[start] load global elpis middleware done');
        }
        catch (err) {
            console.error('[exception] there is no global elpis middleware file.')
        }


        //注册业务全局中间件
        try {
            require(path.resolve(app.businessPath, 'middleware.js'))(app)
            console.log('[start] load global business middleware done');
        }
        catch (err) {
            console.error('[exception] there is no global business middleware file.')
        }
        routerLoader(app);
        console.log('[start] load router loader success');
        /**
         * try catch 捕获,并启动监听
         */
        try {
            const port = process.env.PORT || 8080;
            const host = process.env.HOST || '0.0.0.0';
            app.listen(port, host);
            console.log(`Server running at http://${host}:${port}/`);
        }
        catch (err) {
            console.error(err);
        }
        return app;
    }
}

这个章节其实所想引申出的框架改造思想就是:在抽离NPM包的时候,需要考虑清楚__dirname以及process.cwd()等目录写法的区别,其中__dirname通常都是静态变量,不由运行时决定,由自身文件所处的目录路径决定。但是process.cwd()是运行时写法,由执行node命令的项目路径决定。另外就是在重构过程中,不能仅仅依靠报错信息来决定这个位置是否重构,而是需要站在拓展性的角度考量,究竟自己的框架是只应该从用户端读取文件,还是应该只读取自己的项目文件,亦或者是都有

  1. 前端工程化重构 和后端loader的重构类似,之前我们在部署前端工程,读取项目配置的时候只读取了我们框架内部本身的配置,而没有考虑到用户给出的配置项。同时,webpack内部的配置项路径也有一定问题,因为用户内部可能并没有安装对应的node_modules依赖在引入loader亦或者是插件的时候,都会抛出出现找不到依赖的错误。因此这个章节的重构也是围绕着这两个部分在做的。

3.1 路径错误的修复

webpack.base.js中,我们的多入口的构建原来只读取了我们项目内部的pages入口。现在需改造为'项目内部入口' + '用户的业务代码pages入口',具体改动如下

const entryList = path.resolve(process.cwd(), 'app', 'pages', '**', 'entry.*.js')
glob.sync(entryList).forEach((filePath) => {
    const entryName = path.basename(filePath, '.js') //拿到页面文件名称
    pageEntries[entryName] = filePath;
    htmlWebpackPluginList.push(new HTMLWebpackPlugin({
        //产物(最终模板)输出路径
        filename: path.resolve(process.cwd(), 'app', 'public', 'dist', `${entryName}.tpl`),
        //指定要使用的模板文件
        template: path.resolve(process.cwd(), 'app', 'view', 'entry.tpl'),
        //要注入的代码块,注意需要与entry中的chunks保持一致,一个入口需要声明一个HTMLWebpackPlugin,除非代码块需要注入多个entry
        chunks: [entryName]
    }))
})

增加用户业务代码入口后(原push WebpackPlugin的逻辑封装为了handleFile,并区分了业务页面和框架内置页面 elpisEntryList, businessEntryList

function handleFile(filePath, entries = {}, htmlWebpackPluginList = []) {
    const entryName = path.basename(filePath, '.js') //拿到页面文件名称
    entries[entryName] = filePath;
    htmlWebpackPluginList.push(new HTMLWebpackPlugin({
        //产物(最终模板)输出路径
        filename: path.resolve(process.cwd(), 'app', 'public', 'dist', `${entryName}.tpl`),
        //指定要使用的模板文件
        template: path.resolve(__dirname, '..', '..', 'view', 'entry.tpl'),
        //要注入的代码块,注意需要与entry中的chunks保持一致,一个入口需要声明一个HTMLWebpackPlugin,除非代码块需要注入多个entry
        chunks: [entryName]
    }))
}

const elpisEntryList = path.resolve(elpisPagesPath, '**', 'entry.*.js')
const elpisPageEntries = {};
const elpisHtmlWebpackPluginList = []
glob.sync(elpisEntryList).forEach((filePath) => handleFile(filePath, elpisPageEntries, elpisHtmlWebpackPluginList))

/**
 * 用户的业务代码的entry和HtmlWebpackPlugin配置的逻辑
 */
const businessEntryList = path.resolve(process.cwd(), 'app', 'pages', '**', 'entry.*.js')
const businessPageEntries = {};
const businessHtmlWebpackPluginList = []
glob.sync(businessEntryList).forEach((filePath) => handleFile(filePath, businessPageEntries, businessHtmlWebpackPluginList))

此外,之前的webpack loader和plugins内部的路径项也需要更改,例如vue-loader

{
    test:/\.vue$/,
    use:{
        loader:'vue-loader'
    }
}

需改为

{
    test:/\.vue$/,
    use:{
        loader:require.resolve('vue-loader')
    }
}

这是因为原来的require是动态路径,它的寻址逻辑是从程序运行时开始,从用户的项目根目录开始,寻找node_modules目录,然后再去寻找对应的依赖项。而require.resolve()方法可将对应的依赖项改写为静态编译时绝对路径,即框架所在的根目录下的node_modules所在路径。 同理,路径别名也是需要使用require.resolve更换的:

更换前:
//配置模块解析的具体行为(定义webpack在打包时如何找到并解析具体模块的路径)
    resolve: {
        extensions: ['.js', '.vue', '.less', '.css'],
        alias: {
            $pages: path.resolve(process.cwd(), 'app', 'pages'),
            $common: path.resolve(process.cwd(), 'app', 'pages', 'common'),
            $widgets: path.resolve(process.cwd(), 'app', 'pages', 'widgets'),
            $store: path.resolve(process.cwd(), 'app', 'pages', 'store'),
        }
 },
 
 更换后
  //配置模块解析的具体行为(定义webpack在打包时如何找到并解析具体模块的路径)
    resolve: {
        extensions: ['.js', '.vue', '.less', '.css'],
        alias: {
            vue: require.resolve('vue'),
            $elpisPages: elpisPagesPath,
            $elpisCommon: path.resolve(elpisPagesPath, 'common'),
            $elpisCurl: path.resolve(elpisPagesPath, 'common', 'curl.js'),
            $elpisUtils: path.resolve(elpisPagesPath, 'common', 'utils.js'),
            $elpisWidgets: path.resolve(elpisPagesPath, 'widgets'),
            $elpisHeaderContainer: path.resolve(elpisPagesPath, 'widgets', 'header-container', 'header-container.vue'),
            $elpisSiderContainer: path.resolve(elpisPagesPath, 'widgets', 'sider-container', 'sider-container.vue'),
            $elpisSchemaTable: path.resolve(elpisPagesPath, 'widgets', 'schema-table', 'schema-table.vue'),
            $elpisSchemaForm: path.resolve(elpisPagesPath, 'widgets', 'schema-form', 'schema-form.vue'),
            $elpisSchemaSearchBar: path.resolve(elpisPagesPath, 'widgets', 'schema-search-bar', 'schema-search-bar.vue'),
            $elpisStore: path.resolve(elpisPagesPath, 'store'),
            $elpisBoot: path.resolve(elpisPagesPath, 'boot.js'),
            $businessDashboardRouterConfig: path.resolve(process.cwd(), 'app', 'pages', 'dashboard', 'router.js'),
            $businessComponentConfig: path.resolve(process.cwd(), 'app', 'pages', 'dashboard', 'complex-view', 'schema-view', 'components', 'component-config.js'),
            $businessFormItemConfig: path.resolve(process.cwd(), 'app', 'pages', 'widgets', 'schema-form', 'form-item-config.js'),
            $businessSearchItemConfig: path.resolve(process.cwd(), 'app', 'pages', 'widgets', 'schema-search-bar', 'schema-item-config.js'),
        }
    },

这样才能防止源码中import的时候不会出现路径找不到的问题。

devServer中也需要更改热更新插件的依赖寻址

更改前
baseConfig.entry[key] = [
            //主入口文件
            baseConfig.entry[key],
            `webpack-hot-middleware/client?path=http://${DEV_SERVER_CONFIG.HOST}:${DEV_SERVER_CONFIG.PORT}/${DEV_SERVER_CONFIG.HMR_PATH}&timeout=${DEV_SERVER_CONFIG.TIMEOUT}&reload=true`
        ]
        
        
更改后(require.resolve)
baseConfig.entry[key] = [
            //主入口文件
            baseConfig.entry[key],
            `${require.resolve('webpack-hot-middleware/client')}?path=http://${DEV_SERVER_CONFIG.HOST}:${DEV_SERVER_CONFIG.PORT}/${DEV_SERVER_CONFIG.HMR_PATH}&timeout=${DEV_SERVER_CONFIG.TIMEOUT}&reload=true`
        ]

3.2 用户配置的读取

核心在于利用webpack的smartMerge方式,让它自行判断我们和用户的webpack配置项哪些合并哪些不合并。 其次,就是要使用try catch接住用户的webpack config, 因为用户很可能是忘了定义,亦或者是在目录里直接就没写,用的是我们webpack的默认定义,这种情况会报错。

//加载业务的webpack config
let businessWebpackConfig = {};
try {
    businessWebpackConfig = require(path.resolve(process.cwd(), 'app', 'webpack.config.js'))
} catch (e) {

}

merge.smart({
...elpis的webpack config
}, businessWebpackConfig)
  1. 源码层面的改动

    4.1 依赖路径的修改

    诸如之前的公共库,import xxx from common等,都要统统以我们在工程化中命名好的别名路径做区分例如:

    dashboard.vue
    原: import $curl from 'common/curl.js'
    改为:import $curl from '$elpisCommon/curl.js'
    

    4.2 路由的修改

    由于我们内部配置的路由只适应于自己的页面,而没法支持用户拓展他们的页面。因此这个位置需要集中处理一下

    entry.dashboard.js
    let $businessDashboardRouterConfig = {};
    try {
        $businessDashboardRouterConfig = await require('$businessDashboardRouterConfig').default
    }
    catch (e) {
    
    }
    if (typeof $businessDashboardRouterConfig === 'function') {
    $businessDashboardRouterConfig({
        routes,
        siderRoutes,
    })
    }
    
    

    关键处理点在于使用require运行时导入,因为用户的路由只有在服务器运行起来,且浏览器访问页面时才能够获取到,这一点是import方法所办不到的(因为我们无法从我们的项目import用户的项目路径)。同时也要trycatch处理,因为用户的路由配置文件不一定存在。

    4.3 动态组件的修改

    在对应的schema-view以及公共组件库的目录下改写component-config.js,保证其能够既读取用户自行写的组件,也可以读取我们的封装组件,同理需要 require引入,try catch处理。

    import CreateForm from "./create-form/create-form.vue";
    import DetailPanel from "./detail-panel/detail-panel.vue";
    import EditForm from "./edit-form/edit-form.vue";
    let businessComponentConfig = {};
    try {
        businessComponentConfig = await require('$businessComponentConfig').default
    }
    catch (e) {
    
    }
    export default {
        createForm: {
            component: CreateForm,
        },
        editForm: {
            component: EditForm,
        },
        detailPanel: {
            component: DetailPanel
        },
        ...businessComponentConfig,
    }
    

到了这里,整个项目的发布前重构基本上就已经完成了,我们总结一下一些关键点和疑难点

  1. 重构的首要关键点是在于暴露所需要开放的能力,而非所有能力。如何判断、设计需要暴露的能力才是其中关键点和难点。这个问题通常需要回归我们框架的设计目标做拆分后决定。当然暴露的能力也不是一成不变的,在未来迭代中还可以持续增加。
  2. 当决定好暴露所开放的能力后,书写在index.js中,以函数或者对象的形式暴露出去。为什么是index.js?因为别人在引入你的项目的时候,入口就是你包里面的index.js文件。
  3. 根据你所定义的暴露能力,自行在本地npm link一下,通过另一个项目模拟导入你包的场景,并本地联调。在本地联调中,根据报错信息去到对应的框架目录/源码处做修改,这样效率最高
  4. 本地联调时的报错信息基本上都是路径出错所导致的,因此大部分重构也是围绕着路径重构所开展的,在这一步需要分清楚什么时候用运行时路径(例如process.cwd)什么时候用静态路径__dirname,require.resolve等。
  5. 在重构过程中,不要仅仅只考量报错的地方。在框架拓展性方面也需要有所考量,例如配置是取自己的还是用户的?配置出现了冲突如何解决等问题,都是很重要的考量点。在读取用户配置的时候,基本上只能通过动态运行时require来获取同时要注意用try catch捕获一下,预防报错在合并配置的时候,优先用用户的配置来合并自己的配置

二、文档编写 + 依赖整理

文档编写目的在于告知用户这个项目是做什么的,然后它的启动命令是什么。最主要的是需要告知用户,配置项有哪些(对于我们的项目而言,领域模型的配置尤其重要)。不过文档也不是一蹴而就的,在初代版本里面,只要包括一些基础的启动命令和配置项基本上就够用了。例子参考 www.npmjs.com/package/@ri…

依赖整理:需要将某些对应的开发者依赖(dev-dependencies) 移入到 dependencies 中,这是因为在开发框架时,可能这些依赖并不影响项目源码的正常运行。但是在变成框架供给别人使用的时候,就需要考虑这部分内容了。因为别人在导入我们的npm包的时候,依赖的目录是从我们框架下的node_modules去寻找的,也就是说用户在下载我们的包的时候,需要将我们包运行所需要的依赖一并下载过来。这就是为什么需要做依赖整理的原因

三、发布前package.json编写

  1. 名称的编写(name):这个是很重要的一点,最好在发布前去npm镜像源中搜索一下同名的包是否已经存在。如果已经存在,后续发布是会报错的。如果存在同名的包,那么最好的做法是加一个命名空间在前边,以@符号开头。例如我的elpis包已经有重复项了,那么我可以用我的npm账号名称做命名空间,即@richardzhili/elpis。这个命名空间必须跟自己的npm账户名保持一致 2.版本号的制定,注意每次发布前版本号要比上次版本号加一,版本冲突也会导致发包失败 3.删除之前调试用的scripts

四、执行发布

  1. 确保自己的npm镜像源是官方源npm config get registry,如果不是的话执行一下npm config set registry 重置镜像源
  2. 确保自己的npm账号已登陆,npm whoami,返回结果如果和npm网站上的账号一致就没问题了
  3. 一切都准备就绪后执行npm publish 如果是第一次执行需要npm publish --access public 参数

最后贴上发布镜像源:www.npmjs.com/package/@ri…

10.响应式系统演进:通过位运算优化动态依赖收集(Vue3.2)

作者 Cobyte
2026年5月7日 09:17

前言

在 Vue3.2 的版本里面还通过位运算优化动态依赖收集的性能,那么具体是怎么做的呢?首先我们来看看原来为什么会存在性能问题,我们回顾一下第5篇文章讲解 Vue3 响应式原理的时候,在收集依赖的时候有以下一段代码。

image.png

首先是只要存在 activeEffect 变量,我们就会往 deps 中添加依赖,如果存在重复的依赖,会利用 Set 数据的特性来去重。目前这种依赖管理方式在高频更新或深层递归场景下存在性能瓶颈。具体表现为副作用函数(effect)的依赖可能随条件分支动态变化。例如:

const state = reactive({ a: '掘金签约作者', b: 'Cobyte', flag: true })

effect(() => {
  if (state.flag) {
    // 依赖 state.a
    console.log(state.a);
  } else {
    // 依赖 state.b
    console.log(state.b);
  }
});

state.flag = false
state.a = '小前端'

我们运行上述例子,结果如下:

掘金签约作者
Cobyte
Cobyte

从上述测试结果我们可以看到当设置 state.flag 为 true 时,打印了 Cobyte,这是正确的,但当改变state.a 值时,也打印了 Cobyte,其实当 state.flag 为 true 时,该副作用就跟 state.a 没有关系了,因为不管 state.a 的值怎么变,副作用的打印结果都是一样的,所以此时当 state.a 改变就触发副作用更新的行为就是浪费性能。

所以我们目前的实现存在以下问题,当 state.flag 变化时,依赖需从 state.a 切换到 state.b 时无法自动清理过期依赖,导致冗余触发而引发性能瓶颈。

对此 Vue3.2 创新性地引入 位运算(Bitwise Operations)优化依赖收集,解决了动态依赖切换导致的冗余依赖问题,从而大幅提升了响应式系统的性能。本文将从设计背景、实现原理、性能优势等方面展开分析,揭示位运算在这一场景下的核心价值。

此外对位运算还不熟的同学,可以先复习一下位运算相关知识

为什么要使用位运算来设计依赖优化?

我们在前言的例子中讲到当 state.flag 变化时,依赖需从 state.a 切换到 state.b,传统 Set 数据结构无法自动清理过期依赖,导致冗余依赖。那么怎么实现自动清理过期的依赖呢?

普通实现方案

原来的数据结构如下:

image.png

那么实现这个清除失效的依赖,按我们普通的实现方案可以这样设计,设计一个记录该依赖在 之前的层级 是否被追踪的变量 wasSet = new Set();再设计一个记录该依赖在 当前层级 是否被追踪的变量 newSet = new Set();这样我们在一轮循环中判断是否记录新的依赖的时候,先往变量 newSet 中添加该依赖,再从 wasSet 变量中判断是否已经存在该依赖,如果已经存在,那么就不再记录,如果不存在,那么就需要往原来记录依赖的变量 deps 中添加新的依赖。这样在一轮循环的最后,再去判断该依赖如果只存在 wasSet 变量中,而没有在 newSet 变量中时,则说明该依赖需要从 deps 变量中清除掉了,这样将来该依赖发生变化都不会响应式到渲染函数的重新执行。那么 wasSet 中的数据怎么来呢?可以在初始化的时候从 deps 中进行赋值。

我们上面通过文字描述大概讲了一遍普通方案的实现,那么我现在通过伪代码再还原展示一偏。

状态记录相关变量:

  • wasSet: Set<Dep> :记录上一轮执行中所有被追踪的依赖。
  • newSet: Set<Dep> :记录当前轮次执行中所有被追踪的依赖。
  • deps: Dep[] :实际存储依赖的集合。

初始化阶段:

wasSet = new Set(deps); // 初始化为上一轮的依赖  
newSet = new Set();  

依赖收集阶段:

if (!newSet.has(dep)) {  
  newSet.add(dep);  
  if (!wasSet.has(dep)) {  
    deps.push(dep); // 新增依赖  
  }  
}  

依赖清理阶段:

for (const dep of wasSet) {  
  if (!newSet.has(dep)) {  
    deps.splice(deps.indexOf(dep), 1); // 移除失效依赖  
  }  
}  
wasSet = newSet; // 更新历史状态

从上述伪代码可以清晰看出通过比对 wasSet 和 newSet 的差异,移除不再被使用的依赖,从而实现了条件分支的支持。

但这种普通方案存在以下性能瓶颈:

  1. 内存开销

    • 需维护多个 Set 实例(wasSetnewSet),存储大量依赖时内存占用高。
    • 每次递归层级变化需复制依赖集合(如 wasSet = new Set(deps))。
  2. 操作效率

    • 集合操作hasadddelete 的时间复杂度为 O(1),但哈希表操作仍存在性能损耗(如哈希碰撞)。
    • 清理阶段:遍历 wasSet 并检查 newSet 的时间复杂度为 O(n²)。
  3. 递归层级管理

    • 深层递归时需为每层维护独立的 Set,内存和计算开销指数级增长。

所以 Vue3 并没有采用这种实现方式,那么接下来让我们继续探讨 Vue3 的实现方案吧。

位运算优化方案(Vue3 实现)

在 Vue3 中则巧妙地创建一个兼具 依赖存储 和 追踪状态标记 的复合数据结构的变量。设计如下:

image.png

通过扩展 Set 而非创建全新数据结构,复用 Set 的高效存储,仅添加 wasTrackednewTracked 两个整数字段,就创建一个兼具 依赖存储 和 追踪状态标记 的复合数据结构了。具体 wasTrackednewTracked 两个字段的作用是:

  • wasTracked:记录该依赖在 之前的层级 是否被追踪。
  • newTracked:记录该依赖在 当前层级 是否被追踪。

wasTrackednewTracked 的值都是一个二进制数字,例如:若某依赖在之前的层级(如父组件渲染)中被访问过,wasTracked 对应的位会被标记;newTracked 则是在当前渲染中如果被访问了,对应的位也会被标记。

那么为什么要使用位运算来设计呢?我们从传统的权限管理的痛点说起,因为上述的依赖优化管理机制与权限系统的位掩码设计异曲同工。

假设需要为一个用户管理系统设计权限控制,包含以下权限:

  • 读(R)0b001(二进制) → 1(十进制)
  • 写(W)0b010 → 2
  • 执行(X)0b100 → 4

传统实现方式:

const userPermissions = {
  read: true,
  write: false,
  execute: true
};

// 检查是否有读权限
if (userPermissions.read) { /* ... */ }

这种方案存在以下问题:

  • 存储冗余:每个权限需独立布尔字段,内存占用高。
  • 组合权限复杂:判断用户是否同时有读和执行权限需多次检查。
  • 扩展性差:新增权限(如 admin)需修改数据结构。

使用位运算设计权限管理系统:

通过 位掩码(Bitmask)  将权限编码为单个整数:

// 权限定义
const PERMISSIONS = {
  READ: 0b001,   // 1
  WRITE: 0b010,  // 2
  EXECUTE: 0b100 // 4
};

用户初始权限:

// 用户权限(初始为 0)
let userPermissions = 0;

添加读和执行权限:

// 添加读和执行权限
userPermissions |= PERMISSIONS.READ;    // 0b001 → 1
userPermissions |= PERMISSIONS.EXECUTE; // 0b101 → 5

检查是否有写权限:

const hasWrite = (userPermissions & PERMISSIONS.WRITE) > 0; // false

检查是否有读和执行权限:

const hasReadAndExecute = 
  (userPermissions & (PERMISSIONS.READ | PERMISSIONS.EXECUTE)) 
  === (PERMISSIONS.READ | PERMISSIONS.EXECUTE); // true

优势分析

(1) 内存高效

  • 传统方式:每个权限占用一个布尔值(通常 4 字节)。
  • 位运算:所有权限压缩为单个整数(4 字节),内存占用减少 75%

(2) 操作快速

  • 添加权限userPermissions |= PERMISSIONS.WRITE(O(1))。
  • 移除权限userPermissions &= ~PERMISSIONS.WRITE(O(1))。
  • 检查权限:按位与操作(O(1))。

(3) 组合权限灵活

// 检查是否同时有读和写权限
const required = PERMISSIONS.READ | PERMISSIONS.WRITE;
const hasAll = (userPermissions & required) === required;

那么根据上述权限系统的实现的启发,我们就可以设计如果当前依赖层级为 1,那么历史层级的追踪状态变量 wasTracked 就会被设置为 0b1,当前层级为 2 那么 wasTracked 就会被设置为 0b10,同样地 3,4 ... 层就会被设置为 0b1000b1000,如果一个变量在1、2、3、4层都被引用,那么 wasTracked 就会被设置为:0b1111。同样地当前层级的追踪状态 newTracked 也是如此设计。

同样地,层级变量也可以使用二进制表示,比如,1层为:0b1;2层为:0b10;3层为:0b100。这样标记和判断等相关操作都可以通过位运算进行。比如当前层级为2,那么 层级变量 = 0b10,那么标记添加则是 wasTracked = wasTracked | 0b10;而判断当前历史层级是否已被标记则是 has = wasTracked & 0b10

位运算的原子性操作(如 |=&)速度远超传统 Set 的操作(如遍历、过滤),且位运算具有极致的性能优势,这就是为什么使用为什么要使用位运算来设计依赖优化。

组件嵌套的 effect 实现原理

我们前面讲到多层嵌套的 effect,会存在内存占用高操作缓慢的缺点。而我们前面实现的 Vue3 响应式源码是还没实现嵌套 effect 的,所以我们先要实现嵌套 effect。例如下面的例子:

window.state = reactive({ parent: 'parent', child: 'child' })
effect(() => {
    effect(() => {
        console.log(`我是子组件:${state.child}`)
    })
  console.log(`我是父组件:${state.parent}`)
})

执行结果如下:

image.png

我们给 state.child 重新赋值:

image.png

这时子组件的 effect 执行了,这是正常的。

接著我们给 state.parent 重新赋值:

image.png

这时我们发现父组件的 effect 不执行了。这是为什么呢?我们来观察一下我们之前实现的 ReactiveEffect 类:

class ReactiveEffect {
    deps = []
    constructor(fn) {
        this._fn = fn
    }
    run () {
        activeEffect = this
        this._fn()
        activeEffect = null
    }
    stop () {
      this.deps.forEach(dep => dep.delete(this))
    }
} 

我们知道 activeEffect 变量是唯一的,当嵌套之后,子组件执行完之后,activeEffect 将被设置了 null,这时父组件如果还有响应式数据需要收集的时候,由于 activeEffect 为 null 而会导致父组件的响应式数据的依赖收集不到。

为了解决这个问题,Vue3 底层设置了一个副作用函数栈变量 effectStack,我们要确保 activeEffect 始终指向当前正在运行的响应式副作用 effect。实现代码如下:

// 用于管理嵌套 effect 的调用栈
const effectStack = []
class ReactiveEffect {
    // 存储所有包含本 effect 的依赖集合(Set)
    // 用于实现 stop 功能时快速清理依赖
    deps = []
    constructor(fn) {
        // 包装的副作用函数(开发者传入的原始函数)
        this._fn = fn
    }
    // 执行副作用函数,并触发依赖收集
    run () {
        // 这里为什么要用try...finally呢?比如如果_fn中有错误,finally块仍然会执行,保证栈的平衡。
        try {
            // 1. 设置当前激活的 effect 为自身
            activeEffect = this;
            // 2. 压入 effect 调用栈(处理嵌套 effect 的关键)
            effectStack.push(this);
            // 3. 执行原始函数,触发响应式属性的 getter,进行依赖收集
            return this._fn(); // 返回函数执行结果(支持 computed 等场景)
        } finally {
            // 4. 无论执行是否抛出异常,确保以下清理逻辑一定执行
            effectStack.pop(); // 当前 effect 出栈
            // 5. 恢复 activeEffect 为上一个 effect(栈顶元素)或 undefined
            activeEffect = effectStack.length > 0 ? effectStack[effectStack.length - 1] : undefined;
        }
    }
    // 停止当前 effect 的响应式追踪
    stop () {
      // 遍历所有关联的依赖集合,从中删除本 effect
      this.deps.forEach(dep => dep.delete(this))
    }
} 

主要的实现思路也很简单,就是在执行原始函数之前,先把当前的响应式副作用压入 effectStack 调用栈,通过使用 try...finally 确保无论 this._fn() 是否抛出异常,effectStack 都会被正确弹出,activeEffect 会被恢复为上一个响应式副作用 effect 或 undefined。这样通过维护 effectStack,确保嵌套的响应式副作用 effect 的执行顺序正确,activeEffect 变量始终指向当前正在运行的响应式副作用 effect。

我们再来看看迭代后的执行结果:

image.png

我们可以看到当父组件的响应式变量 parent 被改变后,相关的嵌套代码都被执行了。

到此,我们就实现了嵌套 effect

依赖标记流程

初始化依赖的追踪状态标记

初始化依赖的追踪状态标记的核心逻辑就是在副作用函数执行前,记录所有 已有依赖 的追踪状态,即某个依赖在 上一轮执行 中被追踪过,其对应的位会被标记到 wasTracked 中。具体就是将每个依赖的 wasTracked 字段的 当前层级对应位 设为 1。我们可以设置一个全局变量 effectTrackDepth 来表示当前副作用执行的 递归深度,也就是所谓层级,初始为 0,每递归一次就增加 1。在每一轮的副作用函数执行前,将全局递归深度加 1,表示进入新一层级,执行完副作用函数后,将全局递归深度减 1,表示返回到上一层级的执行环境。

然后通过位运算 1 << effectTrackDepth 生成一个二进制掩码,也就是 第 effectTrackDepth 位为 1,其余位为 0。例如,若 effectTrackDepth = 2,则掩码为 0b100(十进制 2)。这样每个递归层级 effectTrackDepth 对应独立的二进制位,避免嵌套 effect 的依赖状态相互干扰。最后通过按位或操作(|),将 wasTracked 的对应二进制位设为 1,其他位保持不变。

具体代码实现如下:

// 用于管理嵌套 effect 的调用栈
const effectStack = []
+ // 当前副作用执行的递归深度
+ let effectTrackDepth = 0
class ReactiveEffect {
    // 存储所有包含本 effect 的依赖集合(Set)
    // 用于实现 stop 功能时快速清理依赖
    deps = []
    constructor(fn) {
        // 包装的副作用函数(开发者传入的原始函数)
        this._fn = fn
    }
    // 执行副作用函数,并触发依赖收集
    run () {
        // 这里为什么要用try...finally呢?比如如果_fn中有错误,finally块仍然会执行,保证栈的平衡。
        try {
            // 1. 设置当前激活的 effect 为自身
            activeEffect = this;
            // 2. 压入 effect 调用栈(处理嵌套 effect 的关键)
            effectStack.push(this);
+            // 将全局递归深度加 1,表示进入新一层级
+            effectTrackDepth++;
+            // 初始化标记
+            this.initDepMarkers();
            // 3. 执行原始函数,触发响应式属性的 getter,进行依赖收集
            return this._fn(); // 返回函数执行结果(支持 computed 等场景)
        } finally {
+            // 将全局递归深度减 1,表示返回到上一层级的执行环境 
+            effectTrackDepth--;
            // 4. 无论执行是否抛出异常,确保以下清理逻辑一定执行
            effectStack.pop(); // 当前 effect 出栈
            // 5. 恢复 activeEffect 为上一个 effect(栈顶元素)或 undefined
            activeEffect = effectStack.length > 0 ? effectStack[effectStack.length - 1] : undefined;
        }
    }
    // 停止当前 effect 的响应式追踪
    stop () {
      // 遍历所有关联的依赖集合,从中删除本 effect
      this.deps.forEach(dep => dep.delete(this))
    }
+    // 初始化依赖的追踪状态标记
+    initDepMarkers() {
+        const { deps } = this
+        if (deps.length) {
+            for (let i = 0; i < deps.length; i++) {
+                // 若某个依赖在 上一轮执行 中被追踪过,其对应的位会被标记到 wasTracked 中
+                deps[i].wasTracked = deps[i].wasTracked | 1 << effectTrackDepth
+            }
+        }
+    }
} 

小结一下:当副作用函数 effect 执行时,会进入不同的递归层级,每个层级对应一个位。在初始化时,会通过 initDepMarkers 方法设置对应依赖的 wasTracked 属性的位,表示上一轮这个依赖是否被跟踪。

通过位运算判断是否收集依赖

我们在之前的依赖收集的判断逻辑是这样的,判断全局变量 activeEffect 是否存在,存在就进行收集, 那么现在我们要判断当前依赖的当前层级是否标记该依赖为已追踪,也就是 deps.newTracked 的对应层级 (1 << effectTrackDepth) 是否为 1。这就要通过与运算(&)来判断。我们通过封装一个函数来实现这个功能,代码如下:

function newTracked(dep) {
  return (dep.newTracked & (1 << effectTrackDepth)) !== 0;
}

若当前层级未标记该依赖为已追踪(!newTracked(dep)),则需要将当前依赖 newTracked 设置为当前层级 (1 << effectTrackDepth) ,也就是标记为 1。我们通过封装一个函数来实现这个功能,代码如下:

function setNewTracked(dep) {
  dep.newTracked |= (1 << effectTrackDepth); // 按位或操作
}

最后我们还要检查依赖的 wasTracked 字段的当前层级(1 << effectTrackDepth) 对应 是否为 1(即是否在上一轮执行中被追踪过)。我们通过封装一个函数来实现这个功能,代码如下:

function wasTracked(dep) {
  return (dep.wasTracked & (1 << effectTrackDepth)) !== 0;
}

整体代码迭代如下:

function track(target, key) {
    let depsMap = targetMap.get(target)
    if (!depsMap) {
        depsMap = new Map()
        targetMap.set(target, depsMap)
    }
    let deps = depsMap.get(key)
    if (!deps) {
      deps = new Set()
      // 标记依赖在 上一轮执行周期 中是否被追踪
      deps.wasTracked = 0
      // 标记依赖在 当前执行周期 中是否被追踪
      deps.newTracked = 0
      depsMap.set(key, deps)
    }
-    if (activeEffect) {
-        deps.add(activeEffect)
-        activeEffect.deps.push(deps)
-    }
+    trackEffects(deps)
}

+ function trackEffects(dep) {
+     let shouldTrack = false
+     if (!newTracked(dep)) {
+      setNewTracked(dep)
+      shouldTrack = !wasTracked(dep)
+    }

+    if (shouldTrack) {
+        dep.add(activeEffect)
+        activeEffect.deps.push(dep)
+    }
+ }

+ function newTracked(dep) {
+   return (dep.newTracked & (1 << effectTrackDepth)) !== 0;
+ }

+ function setNewTracked(dep) {
+   dep.newTracked |= (1 << effectTrackDepth); // 按位或操作
+ }

+ function wasTracked(dep) {
+   return (dep.wasTracked & (1 << effectTrackDepth)) !== 0;
+ }

在执行 effect 函数的过程中,当访问响应式属性时,会调用 track 函数,进而调用 trackEffects,设置 newTracked 的位,表示当前层级这个 dep 被跟踪了。

接着我们测试一下我们写的代码,测试代码如下:

window.state = reactive({ flag: false,  a: 'parent', b: 'child' })
effect(() => {
  if (state.flag) {
    console.log(`条件一:${state.a}`);
  } else {
    console.log(`条件二:${state.b}`);
  }
});

我们运行上面的测试代码,结果输出:条件二:child。这是正确的输出结果。说明我们上述的迭代代码是正确的。

我们现在改变 flag 的值, state.flag = true,结果输出:条件一:parent。这也是正确的输出结果。这时我们再改变 b 的值, state.b = '掘金签约作者',结果输出:条件一:parent,这个结果不是我们期待的,因为 b 属性我们已经不再使用了,b 属性值的改变不应该再触发更新才对。所以我们还要实现最后一个功能,通过位运算实现动态依赖的精准管理。

实现动态依赖精准管理

我们通过上文知道当effect执行时,会进入不同的递归层级,每个层级对应一个位。在初始化时,会通过initDepMarkers方法设置wasTracked的位,表示上一轮这个dep是否被跟踪。然后在执行effect函数的过程中,当访问响应式属性时,会调用track函数,进而调用trackEffects,设置newTracked的位,表示当前层级这个dep被跟踪了。

我们现在需要做的就是比较这两个标记,如果一个dep在之前被跟踪(wasTracked为真),但在当前没有被跟踪(newTracked为假),说明这个dep在当前层级不再被需要,因此需要从dep的集合中移除这个effect。这样我们就可以实现清理那些不再被依赖的effect,防止内存泄漏和无效的触发。

代码迭代如下:

// 用于管理嵌套 effect 的调用栈
const effectStack = []
let effectTrackDepth = 0
class ReactiveEffect {
    // 存储所有包含本 effect 的依赖集合(Set)
    // 用于实现 stop 功能时快速清理依赖
    deps = []
    constructor(fn) {
        // 包装的副作用函数(开发者传入的原始函数)
        this._fn = fn
    }
    // 执行副作用函数,并触发依赖收集
    run () {
        // 这里为什么要用try...finally呢?比如如果_fn中有错误,finally块仍然会执行,保证栈的平衡。
        try {
            // 1. 设置当前激活的 effect 为自身
            activeEffect = this;
            // 2. 压入 effect 调用栈(处理嵌套 effect 的关键)
            effectStack.push(this);
            // 
            effectTrackDepth++;
            // 初始化标记
            this.initDepMarkers();
            // 3. 执行原始函数,触发响应式属性的 getter,进行依赖收集
            return this._fn(); // 返回函数执行结果(支持 computed 等场景)
        } finally {
+            this.finalizeDepMarkers();
            effectTrackDepth--;
            // 4. 无论执行是否抛出异常,确保以下清理逻辑一定执行
            effectStack.pop(); // 当前 effect 出栈
            // 5. 恢复 activeEffect 为上一个 effect(栈顶元素)或 undefined
            activeEffect = effectStack.length > 0 ? effectStack[effectStack.length - 1] : undefined;
        }
    }
    // 停止当前 effect 的响应式追踪
    stop () {
      // 遍历所有关联的依赖集合,从中删除本 effect
      this.deps.forEach(dep => dep.delete(this))
    }
    // 初始化依赖的追踪状态标记
    initDepMarkers() {
        const { deps } = this
        if (deps.length) {
            for (let i = 0; i < deps.length; i++) {
                // 若某个依赖在 上一轮执行 中被追踪过,其对应的位会被标记到 wasTracked 中
                deps[i].wasTracked = deps[i].wasTracked | 1 << effectTrackDepth
            }
        }
    }
+    // 清理无效依赖 并 优化依赖集合
+    finalizeDepMarkers() {
+        const { deps } = this
+        if (deps.length) {
+            for (let i = 0; i < deps.length; i++) {
+                const dep = deps[i]
+                // 根据依赖的跟踪状态,清理不再需要的依赖
+                if (wasTracked(dep) && !newTracked(dep)) {
+                    // 移除当前 effect 对该 dep 的依赖
+                    dep.delete(this)
+                }
+            }
+        }
+    }
} 

我们再运行上面的测试代码,结果输出:条件二:child。我们接着改变 flag 的值, state.flag = true,结果输出:条件一:parent。这也是正确的输出结果。这时我们再改变 b 的值, state.b = '掘金签约作者',结果输出:条件一:parent,这个结果还是不是我们期待的,为什么呢?

主要是因为现在只要我们的依赖的层级只要被标记上了,就一直是这个状态了。假设当前层级为 2,上述测试代码中需要删除的 b 属性依赖的层级初始标记状态为:wasTracked = 0b100, newTracked = 0b100,那么后续 b 属性的层级状态就一直是这个状态了,当判断是否需要删除的时候,我们需要判断 wasTracked 是否为 true,因为已经被标记过了,所以为 true,同样判断 newTracked 是否为 false 时,因为已经被标记过了,所以为 true

所以在退出当前层级前,清除该层级对应的位掩码,确保下一层级的标记从干净状态开始。具体代码实现如下:

class ReactiveEffect {
    // ...
    // 清理无效依赖 并 优化依赖集合
     finalizeDepMarkers() {
        const { deps } = this
        if (deps.length) {
            for (let i = 0; i < deps.length; i++) {
                const dep = deps[i]
                // 根据依赖的跟踪状态,清理不再需要的依赖
                if (wasTracked(dep) && !newTracked(dep)) {
                    // 移除当前 effect 对该 dep 的依赖
                    dep.delete(this)
                }
+                // 清除该层级对应的位掩码
+                const trackOpBit = 1 << effectTrackDepth
+                dep.wasTracked = dep.wasTracked & ~trackOpBit
+                dep.newTracked = dep.newTracked & ~trackOpBit
            }
        }
    }
}

总的来说就是当 effect 执行完成后,通过比较 wasTrackednewTracked 的位掩码,可以快速确定哪些依赖在本次执行中没有被访问,从而进行清理。同时退出当前层级前,清除该层级对应的位掩码,确保下一层级的标记从干净状态开始。

递归层级限制30层的设计原因

Vue3 底层选择 30 层作为最大递归层级,因为 V8 引擎对 31/32 位整数直接存储于指针,无需堆分配,读写速度提升 10 倍,30 层限制确保位运算结果始终为 SMI,避免退化为堆内存对象导致性能退化,所以选择 30 层是为了确保现代JS引擎在所有平台上都能使用 SMI(小整数)优化。当超出 30 层时,回退到全量清理,保障极端场景稳定性。

代码优化迭代如下:

+ const maxMarkerBits = 30
class ReactiveEffect {
    // ...
    run () {
        try {
            // 1. 设置当前激活的 effect 为自身
            activeEffect = this;
            // 2. 压入 effect 调用栈(处理嵌套 effect 的关键)
            effectStack.push(this);
            effectTrackDepth++;
-            this.initDepMarkers()
+            if (effectTrackDepth <= maxMarkerBits) {
+                this.initDepMarkers()
+            } else {
+                // 当递归深度超过30层时,回退到完全清理模式
+                this.cleanup()
+            }
            // 初始化标记
            this.initDepMarkers();
            // 3. 执行原始函数,触发响应式属性的 getter,进行依赖收集
            return this._fn(); // 返回函数执行结果(支持 computed 等场景)
        } finally {
-            this.finalizeDepMarkers()
+            if (effectTrackDepth <= maxMarkerBits) {
+                this.finalizeDepMarkers()
+            }
            effectTrackDepth--;
            // 4. 无论执行是否抛出异常,确保以下清理逻辑一定执行
            effectStack.pop(); // 当前 effect 出栈
            // 5. 恢复 activeEffect 为上一个 effect(栈顶元素)或 undefined
            activeEffect = effectStack.length > 0 ? effectStack[effectStack.length - 1] : undefined;
        }
    }
    // ...
+    // 完全清理模式
+    cleanup() {
+        const { deps } = this
+        if (deps.length) {
+            for (let i = 0; i < deps.length; i++) {
+                deps[i].delete(this)
+            }
+            deps.length = 0
+        }
+    }
} 

SMI(Small Integer)优化的核心原理

我们上面提到 Vue3 底层选择 30 层作为最大递归层级,是为了确保现代JS引擎在所有平台上都能使用 SMI(小整数)优化。

首先,我们得看一下 SMI 的概念。SMI 代表 Small Integer,是 V8 引擎对特定范围内整数的优化存储方式。在 V8 引擎中,SMI(Small Integer)优化 的核心原理是通过 指针标签(Pointer Tagging)  技术,将小整数直接嵌入指针值中,而非存储在堆内存中。以下是其性能优势的详细解析:

指针的结构

  • 指针的本质
    指针是一个内存地址,通常用 32 位(32 位系统)或 64 位(64 位系统)表示。

  • 标签位(Tagging Bits)
    V8 利用指针的低位(如最低 1~2 位)作为 类型标记,例如:

    • 表示该指针是一个 SMI(直接存储整数值)。
    • 表示该指针是一个 堆对象地址(需要解引用获取实际值)。

SMI 的存储方式

直接嵌入指针
V8 将小整数的二进制值 左移 1 位(腾出最低位作为标签),然后存入指针。

堆分配的数字
若数字超出 SMI 范围(如大整数、浮点数),V8 会在堆内存中分配一个 Number 对象,并将指针指向该对象。

内存访问开销

  • SMI(指针存储)
    值直接存储在指针中,读取时 无需访问堆内存,直接解析指针值即可。

  • 堆分配的数字
    需要 两次内存访问

    1. 读取指针地址。
    2. 根据指针地址访问堆内存中的 Number 对象。

内存分配开销

  • SMI
    无堆内存分配和释放操作,避免 内存管理开销(如垃圾回收)。
  • 堆分配的数字
    需调用内存分配器,可能触发 垃圾回收(GC) ,增加延迟。

CPU 缓存友好性

  • SMI
    数值直接存储在指针中,与其他指针数据一起被 CPU 缓存,缓存命中率高
  • 堆分配的数字
    Number 对象分散在堆内存中,缓存局部性差,缓存未命中率高

指令优化

SMI 操作
通过简单的位运算(如移位、掩码)即可完成数值解析,CPU 指令周期短

堆分配数字操作
需要额外的解引用指令和类型检查,指令周期长

设计哲学

空间换时间

  • SMI:牺牲 1 位指针空间(用于标签),换取极致性能。
  • 堆分配:以内存和速度为代价,支持更大数值范围。

高频场景优化

  • 现实场景
    大多数 JavaScript 程序中的整数是小范围的(如循环计数器、数组索引),SMI 覆盖了 99% 的用例。
  • 收益最大化
    对高频操作(如依赖收集、循环计数)进行极致优化,显著提升整体性能。

综上所述,V8 通过 指针标签技术 将小整数(SMI)直接存储在指针中,实现了以下优势:

  1. 零内存分配:避免堆操作和垃圾回收开销。
  2. 直接访问:无需解引用,减少内存访问次数。
  3. CPU 友好:位运算指令快,缓存命中率高。

这些优化使得 SMI 的读写速度比堆分配的数字快 10 倍以上,成为 JavaScript 高性能引擎的核心技术之一。Vue3 的依赖收集系统正是基于此特性,通过位运算和层级限制,实现了高效的响应式更新。

总结

最后我们来总结一下,Vue3 通过位运算设计实现以下响应式系统的优化:

  • 层级化状态标记:通过位掩码精准管理递归层级依赖。
  • 动态清理机制:按位比对移除失效依赖,避免冗余触发。
  • 性能与内存平衡:SMI 优化保障操作速度,30 层限制避免边界问题。

这一机制在复杂组件、高频更新及深层嵌套场景下表现卓越,是 Vue3 响应式系统的核心创新之一。

我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。

❌
❌