普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月12日掘金 前端

【有奖】VChart & VTable Skill 使用体验有奖征文

作者 玄魂
2026年2月12日 16:10

一、活动简介

VChart 和 VTable 作为 VisActor 数据可视化解决方案的核心组件,为了使用户能够通过 AI 技术提升开发者的工作效率,降低使用门槛,我们发布了开发者skill。为进一步收集用户使用体验,分享开发心得,持续完善skill。VisActor 团队发起本次有奖征文活动。

二、skill 安装与简单 Demo

VChart 使用

VChart Skill安装

VChart 开发 Skill 已发布至 GitHub(欢迎star: github.com/VisActor/VC…):github.com/VisActor/VC…,可以手动添加也可以可通过

npx skills add VisActor/VChart

进行安装,在 Cursor、Trae等支持 skills 的 AI 编辑器中使用。

Trae参考文档: docs.trae.ai/ide/skills?…

Cursor参考文档:cursor.com/cn/docs/con…

将技能安装到项目的 .``XXX``/skills 目录下,如下图

示例

  • 生成简单图表

  • 调整图表样式

  • 修复配置问题

VTable

VTable Skill安装

VTable 开发 Skill 已发布至 GitHub(欢迎star: github.com/VisActor/VT…github.com/VisActor/VT…

npx skills add VisActor/VTable

进行安装,在 Cursor、Trae等支持 skills 的 AI 编辑器中使用。

Trae参考文档: docs.trae.ai/ide/skills?…

Cursor参考文档:cursor.com/cn/docs/con…

将技能安装到项目的 .XXX/skills 目录下,如下图

示例

  • VTable Skill 生成基础表格 Demo

发现页面表格中字段显示问题:

在agent中直接解决附上有问题截图让其解决!

解决后页面效果:

三、征文形式与内容要求

  1. 征文形式

本次征文接受以下三种形式的作品:

  • 技术文章 :发布于稀土掘金、InfoQ、CSDN 等至少2个平台

  • 视频教程 :制作 VChart/VTable Skill 使用基础视频教程,发布到抖音和B站

  1. 内容要求

  • 描述真实的业务场景(保密项目可酌情隐藏敏感信息)
  • 描述使用skill 的过程,遇到的问题,解决方案
  • 总结skill 的优缺点,希望改进或者提供的能力

四、奖品设置

  1. 优秀文章

名次 奖品 名额
一等奖 蓝牙耳机 1
二等奖 定制背包 3
三等奖 定制保温杯 若干
  1. 优秀视频

名次 奖品 名额
一等奖 红米手表 1
二等奖 定制背包 3
三等奖 定制保温杯 若干

五、活动时间安排

根据内部讨论,本次征文活动预计时间安排如下:

  1. 征文启动 :2026 年 2 月12日
  2. 作品提交 :截止至 2026 年 3 月15日
  3. 作品评审 :2026 年 3 月16日
  4. 结果公布 :2026 年 3 月20日
  5. 奖品发放 :2026 年 3月20日

六、参与方式

  1. 加入官方活动群(飞书)

点击 applink.larkoffice.com/client/chat…

或扫码

  1. 将作品链接发送至活动群内
  2. 等待评审结果公布

我理解的Agent(智能体)开发

2026年2月12日 16:02

简单来说,Agent(智能体)开发不再是让 AI 仅仅作为一个“问答机器人”,而是把它变成一个 “有手有脚、能思考、能干活” 的数字员工。

如果把大模型(LLM)比作一个“聪明的大脑”,那么 Agent 就是给这个大脑接上了身体(工具)和逻辑框架(规划)

1. Agent 的核心公式

一个成熟的 Agent 通常包含以下四个要素:

  • 大脑(LLM): 负责核心推理、决策和语言理解。
  • 规划(Planning): 它能把复杂任务拆解成步骤(比如:先查资料,再写草稿,最后发邮件)。
  • 工具使用(Tool Use): 这是 Agent 的灵魂。它能自主调用外部工具,比如搜索网页、运行 Python/Typescript 代码、操作 Excel 或调用 API。
  • 记忆(Memory): 短期记忆: 当前对话的上下文,长期记忆: 能够从数据库(如向量数据库)中检索过去的经验。

2. 传统开发 vs. Agent 开发

特性 传统程序 / 普通 AI 对话 Agent 开发
执行逻辑 硬编码(If-Else),逻辑固定 自主决策,根据目标动态调整路径
交互能力 你问,它答 你给目标,它去执行并反馈结果
闭环能力 需要人工干预下一步 遇到错误会自动尝试修正(自我反思)

3. Agent 工作的典型流程

想象你让 Agent “帮我调研 2024 年最火的 3 款国产电车并做成对比表格”:

  1. 拆解: “我需要先搜索关键词,选出 3 款车,再分别查它们的参数。”
  2. 行动: 调用搜索工具获取网页内容。
  3. 观察: 发现其中一款车没价格,重新搜索价格信息。
  4. 整合: 将所有信息汇总,格式化为表格输出。

4. 目前主流的开发框架

如果你想尝试开发 Agent,通常会用到这些“脚手架”:

  • LangChain / LangGraph: 目前最流行的 Agent 编排框架。
  • AutoGPT / BabyAGI: 早期火遍全网的自主执行代理实验。
  • CrewAI / Multi-On: 专注于“多智能体协作”(让好几个 AI 像一个团队一样分工合作)。
  • OpenAI Assistants API: 官方提供的低门槛 Agent 构建工具。

5. 那Agent开发是不是就是编排工作流?

是不是可以这样理解,Agent(智能体) 开发,就是编排智能体,按照编排的顺序去执行,不再是一问一答?如果这样理解已经抓住了 Agent (智能体)开发的核心,但我们可以把这个理解再往深推一步。

真正的 Agent(智能体) 相比于单纯的顺序执行,多了一层最关键的东西:自主决策(Autonomy)

它更像一个有经验的员工。不再是简单的顺序: 假设你只给它一个模糊的目标(比如“帮我买一张最划算的进京机票”)。

  • 动态调整: 它先去查票,发现没票了,它不会报错退出,而是会自己思考:“我是不是该换个机场?或者换个日期?”然后重新执行。
  • 闭环决策: 它拥有 Reasoning(推理)和 Acting(行动)的循环(学术上常称为 ReAct 模式)。

6. 大模型负责“想”,而 Agent (智能体)(你写的代码)负责“做”

1. 角色分工:大脑 vs. 系统

我们可以把这个过程拆解开来看:

  • 大模型(LLM): 扮演的是决策中心。它通过文字告诉系统:“我现在需要搜索周杰伦的演唱会信息,请调用搜索工具。”
  • Agent 框架(如 LangChain, Coze, Dify): 扮演的是执行系统。它看到大模型发出的指令后,真的去运行搜索插件,拿到结果,再把结果喂回给大模型。

2. 核心机制:ReAct 循环

这个“思考 -> 行动 -> 观察”的过程在技术上通常被称为 ReAct (Reason + Act)

\text{Input} \rightarrow \underbrace{\text{Thought} \rightarrow \text{Action} \rightarrow \text{Observation}}_{\text{Loop}} \rightarrow \text{Final Answer}

我们可以通过一个具体的例子来看看它们是怎么配合的:

步骤 谁在执行? 执行了什么内容?
思考 (Thought) 大模型 生成一段文本:“用户想买票,我得先查查今天几号,再查票务接口。”
行动 (Action) 框架/代码 识别到大模型想调用 get_tickets 函数,代码自动去运行这个函数
观察 (Observation) 框架/代码 函数返回了结果:“余票为 0”。框架把这行字传回给大模型。
调整计划 (Adjust) 大模型 看到“余票 0”,思考:“既然没票了,我应该建议用户买下一场。”

3. “大脑”是怎么控制“手脚”的?

你可能会好奇,大模型只是一段文本预测模型,它是怎么命令代码去执行操作的?

这主要靠一种叫 Function Calling(函数调用) 的技术:

  1. 你先告诉大模型:我有两个工具,一个是“搜索”,一个是“订票”。
  2. 大模型会根据你的要求,输出一段特定格式的代码或 JSON(例如:{"tool": "search", "query": "上海天气"})。
  3. 你的程序检测到这个 JSON,暂停生成,去跑搜索脚本,拿到结果。
  4. 你的程序把结果包装成“背景资料”,再次发给大模型。

4. 谁在维持这个循环?

大模型本身是没有状态的,它记不住刚才发生了什么。 是 Agent 框架(或者说你写的 Python/Node.js 代码)在维持一个 While 循环

代码逻辑大致如下:

while 任务没完成: 
1. 把所有记录发给大模型,问它下一步做什么。 
2. 如果大模型说“我要调用工具”,代码就去执行工具。 
3. 如果大模型说“我做完了”,循环结束,给用户回话。

7. 在 Node.js 中实现一个简单的 Agent

在 Node.js 中实现一个简单的 Agent,最直观的方法是利用 OpenAI SDK 的 Function Calling (工具调用) 功能。

我们可以手动编写一个“思考-行动-观察”的循环(Loop),这样我们就能清晰地看到 Agent 是如何工作的。

1. 核心逻辑架构

假设我们要实现的 Agent 逻辑如下:

  1. 用户提问: “上海今天天气怎么样?”
  2. 大模型思考: “我没有实时数据,但我有个 get_weather 工具可以查,我应该调用它。”
  3. 代码执行: 你的 Node.js 代码运行真实的查询函数,获取结果(如“2°C,晴”)。
  4. 大模型总结: 看到结果后,最后一次回答用户。

2. 极简代码实现(基于 OpenAI SDK)

你需要先安装依赖:npm install openai

import OpenAI from "openai";

const openai = new OpenAI({ apiKey: '你的API_KEY' });

// --- 第一步:定义 Agent 可以使用的“手脚”(工具) ---
const tools = [
  {
    type: "function",
    function: {
      name: "get_weather",
      description: "获取指定城市的天气信息",
      parameters: {
        type: "object",
        properties: {
          city: { type: "string", description: "城市名称,如:上海" },
        },
        required: ["city"],
      },
    },
  },
];

// 模拟一个真实的工具执行函数
async function get_weather(args) {
  console.log(`[工具执行] 正在查询 ${args.city} 的天气...`);
  // 在实际应用中,这里会调用天气 API
  return `北京今天晴朗,气温 5°C 到 -5°C。`;
}

// --- 第二步:实现“思考-行动”循环 ---
async function runAgent(userInput) {
  let messages = [
    { role: "system", content: "你是一个乐于助人的助手。如果需要,请使用工具获取最新信息。" },
    { role: "user", content: userInput }
  ];

  while (true) {
    // 1. 让大脑(LLM)思考下一步
    const response = await openai.chat.completions.create({
      model: "gpt-4o",
      messages: messages,
      tools: tools,
    });

    const message = response.choices[0].message;
    messages.push(message); // 将 AI 的思考记录存入上下文

    // 2. 判断是否需要执行工具
    if (message.tool_calls) {
      for (const toolCall of message.tool_calls) {
        const functionName = toolCall.function.name;
        const args = JSON.parse(toolCall.function.arguments);
        
        // 3. 物理执行(Action)
        const observation = await get_weather(args);

        // 4. 将观察到的结果(Observation)反馈给大脑
        messages.push({
          role: "tool",
          tool_call_id: toolCall.id,
          content: observation,
        });
      }
      // 继续循环,让 AI 根据工具结果进行下一次思考
      continue; 
    }

    // 如果 AI 不再需要调用工具,说明它已经有了最终答案
    console.log("Agent 回答:", message.content);
    break;
  }
}

runAgent("上海天气怎么样?");

3. 进阶:为什么要用框架?

当你手写上述 while(true) 循环时,你会发现处理起来很麻烦:

  • 多轮对话: 怎么管理长对话的内存(Memory)?
  • 错误处理: 如果工具执行失败了,AI 怎么自我修复?
  • 多智能体: 如果让一个 Agent 去改另一个 Agent 的代码怎么办?

这时候,你可以尝试使用成熟的 Node.js Agent 框架

  1. LangChain.js / LangGraph: 最老牌且功能最全,适合构建极其复杂的逻辑图。
  2. Vercel AI SDK: 非常现代化,和 React/Next.js 结合极佳,通过 generateText 几行代码就能搞定上述循环。
  3. OpenAI Assistants API: 官方托管的 Agent 服务。你只需要在后台配置好工具,OpenAI 的服务器会自动帮你跑那个 while 循环和存储记忆,你只需调一个接口。

总结

大模型执行的是“逻辑执行”,而你的代码(Agent 智能体)执行的是“物理执行”。

小米Android 手机连接PC Local调试

作者 赵_叶紫
2026年2月12日 15:14

手机设置

  1. 进入设置界面 => 打开 我的设备
  2. 进入 全部参数与信息 => 多次点击点击 OS版本(1.0.4xxx) ,直到显示**你已处于开发者模式*
  3. 返回主菜单,进入 更多设置, 点击**开发者选项 => 打开 USB调试

电脑使用

  1. chrome打开 chrome://inspect/#devices
  2. 手机chrome访问 www.baidu.com
  3. 可在电脑上查看到,devices的远程设备

image-2024-11-26_8-55-14.png

  1. 点击 inspect 选项,查看到手机访问的内容 image-2024-11-26_8-56-59.png

安装fiddler并开启,引起https网页打不开

  1. tools=> Options=> https => Decrypt HTTPS traffic  勾选
  2. 点击右侧Actions =>  Reset All Certificates 
  3. 参考blog.csdn.net/xiaona0523/…

手机连接local调试

1.设置wify 代理到本地

2.chrome中访问local 地址,即可开始调试

【LangChain.js学习】调用大模型

2026年2月12日 15:12

一、调用通义千问大模型

方式1:使用 @langchain/community 专属适配器

依赖安装

pnpm add @langchain/community

调用代码

import { ChatAlibabaTongyi } from "@langchain/community/chat_models/alibaba_tongyi";

// 初始化通义千问专属模型实例
const model = new ChatAlibabaTongyi({
    model: "qwen-plus",
    // 替换为个人有效阿里百炼API Key
    alibabaApiKey: "[你的阿里百炼API Key]",
});

// 注意:需在async函数中执行await调用
async function callTongyiModel() {
    const response = await model.invoke("为什么鹦鹉会说话?");
    console.log(response.content);
}

callTongyiModel();

方式2:使用 @langchain/openai 兼容接口

依赖安装

pnpm add @langchain/openai

调用代码

import { ChatOpenAI } from "@langchain/openai"

// 基于OpenAI兼容接口初始化通义千问模型
const model = new ChatOpenAI({
    model: "qwen-plus",
    configuration: {
        // 阿里百炼OpenAI兼容接口固定地址,不可修改
        baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
        // 替换为个人有效阿里百炼API Key
        apiKey: "[你的阿里百炼API Key]",
    }
});

// 注意:需在async函数中执行await调用
async function callQwenByOpenAI() {
    const response = await model.invoke(`1+1=?`);
    console.log("单文本调用响应:", response.content); // 输出示例:1+1=2
}

callQwenByOpenAI();

二、调用 Ollama 本地模型

1. 前置条件

  • 安装 Ollama:官网下载
  • 拉取目标模型:ollama pull qwen3-vl:8b
  • 启动 Ollama 服务:ollama serve(默认端口 11434)

2. 依赖安装

pnpm add @langchain/ollama

3. 核心调用代码

import { ChatOllama } from "@langchain/ollama"

// 初始化Ollama本地模型
const model = new ChatOllama({
    // 需与本地拉取的模型名称一致(执行ollama list查看)
    model: "qwen3-vl:8b",
    // Ollama默认本地地址,无需修改
    baseUrl: "http://localhost:11434"
});

// 异步调用函数
async function callOllamaModel() {
    const response = await model.invoke(`1+1=?`);
    console.log("Ollama本地模型响应:", response.content); // 输出示例:1+1=2
}

callOllamaModel();

三、核心对比与选型建议

调用方式 优点 缺点 适用场景
阿里云通义千问 模型能力强、无需本地算力、支持多模态/复杂推理 需联网、消耗 API 额度、有调用频率限制 生产环境、复杂业务逻辑、多轮对话
Ollama 本地模型 无网络依赖、隐私性高、免费使用、调试便捷 占用本地CPU/GPU、模型能力相对弱、不支持部分高级功能 本地开发调试、隐私敏感场景、轻量计算/问答

WebGL三角形绘制:掌握缓冲区与基本图元

2026年2月12日 14:56

三角形图元的三种类型

在WebGL中,三角形是最基本的图元之一,但你知道吗?三角形有三种不同的绘制模式,每种都有其独特用途:

1. 基本三角形(TRIANGLES)

这是最常用的三角形绘制方式。每3个顶点构成一个独立的三角形,互不干扰。

// 6个顶点绘制2个三角形
// [v1, v2, v3] 构成第一个三角形
// [v4, v5, v6] 构成第二个三角形
gl.drawArrays(gl.TRIANGLES, 0, 6); // 绘制2个三角形

绘制三角形数量 = 顶点数 ÷ 3

2. 三角带(TRIANGLE_STRIP)

相邻的三角形共享边,效率更高。

// 6个顶点可以绘制4个三角形
// [v1, v2, v3], [v3, v2, v4], [v3, v4, v5], [v5, v4, v6]
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 6); // 绘制4个三角形

绘制三角形数量 = 顶点数 - 2

3. 三角扇(TRIANGLE_FAN)

所有三角形都以第一个顶点为公共顶点。

// 适用于绘制扇形或圆形
gl.drawArrays(gl.TRIANGLE_FAN, 0, 6); // 绘制4个三角形

绘制三角形数量 = 顶点数 - 2

绘制固定三角形

让我们从最简单的固定三角形开始:

着色器程序

顶点着色器:

// 设置浮点数据类型为中级精度
precision mediump float;
// 接收顶点坐标 (x, y)
attribute vec2 a_Position;

void main(){
   gl_Position = vec4(a_Position, 0, 1);
}

片元着色器:

// 设置浮点数据类型为中级精度
precision mediump float;
// 接收 JavaScript 传过来的颜色值(rgba)
uniform vec4 u_Color;

void main(){
   vec4 color = u_Color / vec4(255, 255, 255, 1);
   gl_FragColor = color;
}

JavaScript核心代码

// 定义三角形的三个顶点(右下角、左上角、左下角)
var positions = [1, 0, 0, 1, 0, 0]; // 每两个数字代表一个顶点的x、y坐标

// 获取着色器变量位置
var a_Position = gl.getAttribLocation(program, 'a_Position');
var u_Color = gl.getUniformLocation(program, 'u_Color');

// 创建缓冲区
var buffer = gl.createBuffer();

// 绑定缓冲区为当前操作对象
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);

// 将顶点数据写入缓冲区
// 注意:必须使用类型化数组(Float32Array)传递给WebGL
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);

// 启用顶点属性
gl.enableVertexAttribArray(a_Position);

// 配置顶点属性如何从缓冲区读取数据
var size = 2;        // 每次读取2个数据(x, y)
var type = gl.FLOAT; // 数据类型为浮点型
var normalize = false; // 不需要标准化
var stride = 0;      // 步长为0,表示数据连续存放
var offset = 0;      // 从缓冲区开始位置读取
gl.vertexAttribPointer(a_Position, size, type, normalize, stride, offset);

// 设置颜色并绘制
gl.uniform4f(u_Color, 255, 0, 0, 255); // 设置为红色

// 绘制三角形
gl.drawArrays(gl.TRIANGLES, 0, 3); // 绘制3个顶点,即1个三角形

缓冲区操作详解

WebGL中的缓冲区是向GPU传递数据的关键工具,以下是其工作流程:

1. 创建和绑定缓冲区

// 创建缓冲区对象
var buffer = gl.createBuffer();

// 将缓冲区绑定到ARRAY_BUFFER目标
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);

2. 向缓冲区写入数据

// 使用类型化数组确保数据格式正确
var typedArray = new Float32Array([x1, y1, x2, y2, x3, y3]);
gl.bufferData(gl.ARRAY_BUFFER, typedArray, gl.STATIC_DRAW);

3. 配置顶点属性指针

gl.enableVertexAttribArray(attributeLocation);
gl.vertexAttribPointer(
    attributeLocation, // attribute变量位置
    size,              // 每个顶点包含的分量数(2=x,y; 3=x,y,z)
    type,              // 数据类型
    normalize,         // 是否标准化
    stride,            // 步长
    offset             // 偏移量
);

重要提示: WebGL要求强类型数据,JavaScript中的普通数组必须转换为类型化数组(如Float32Array)才能传递给GPU。

动态绘制三角形

现在让我们实现一个交互式功能:点击三次鼠标绘制一个三角形。

screen_recording_2026-02-12_14-54-03.gif

增强版顶点着色器

// 设置浮点数精度为中等精度
precision mediump float;
// 接收顶点坐标 (x, y)
attribute vec2 a_Position;
// 接收 canvas 的尺寸(width, height)
attribute vec2 a_Screen_Size;

void main(){
    // 将canvas坐标转换为NDC坐标(-1到1的范围)
    vec2 position = (a_Position / a_Screen_Size) * 2.0 - 1.0;
    position = position * vec2(1.0, -1.0); // 翻转Y轴
    gl_Position = vec4(position, 0, 1);
}

交互式JavaScript实现

// 存储点击位置的数组
var positions = [];

// 绑定鼠标点击事件
canvas.addEventListener('mouseup', function(e) {
    var x = e.offsetX; // 相对于canvas的X坐标
    var y = e.offsetHeight - e.offsetY; // 转换Y坐标系统
    positions.push(x, y);
    
    // 当顶点数是6的倍数时(即3个点),绘制一个三角形
    if (positions.length % 6 === 0) {
        // 更新缓冲区数据
        gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.DYNAMIC_DRAW);
        
        // 重新绘制所有三角形
        redraw();
    }
});

function redraw() {
    // 清空画布
    gl.clearColor(0, 0, 0, 1.0);
    gl.clear(gl.COLOR_BUFFER_BIT);
    
    // 绘制所有三角形
    gl.drawArrays(gl.TRIANGLES, 0, positions.length / 2);
}

核心概念总结

缓冲区操作流程

  1. gl.createBuffer() - 创建缓冲区对象
  2. gl.bindBuffer() - 绑定为当前操作缓冲区
  3. gl.bufferData() - 向缓冲区写入数据
  4. gl.enableVertexAttribArray() - 启用顶点属性
  5. gl.vertexAttribPointer() - 配置属性读取方式
  6. gl.drawArrays() - 执行绘制

类型化数组的重要性

  • JavaScript普通数组无法直接传递给WebGL
  • 必须使用Float32Array等类型化数组
  • 确保数据格式与GPU要求一致

坐标转换

  • Canvas坐标系:左上角(0,0),Y轴向下
  • WebGL坐标系:中心(0,0),Y轴向上
  • 需要进行坐标转换才能正确显示

掌握了三角形绘制和缓冲区操作,你就迈出了WebGL高级渲染的第一步!接下来可以尝试绘制更多有趣的图形。

接手老 Flutter 项目踩坑指南:从环境到调试的实际经验

2026年2月12日 14:36

作为一个传统web前端开发者,接手一个老的 Flutter 项目,我必须承认,这个过程比我想象的要复杂很多。Flutter 不仅仅是一个 UI 框架,它涉及到原生开发的诸多细节,构建流程、平台兼容性和调试方式都与传统前端开发有很大不同,特别是如何应对环境配置、依赖问题、构建错误、真机调试等挑战。

一、兼容性问题:为什么特定版本的工具链如此重要?

Flutter 开发中,兼容性问题往往是许多开发者在接手老项目时面临的主要挑战。因为 Flutter 涉及到多个工具和 SDK 的集成,而这些工具(如 Android StudioFlutter SDKDartGradle 等)版本的不同可能会导致不兼容的情况,进而影响到应用的构建、调试和发布。

1. 为什么需要特定版本的工具链?

工具链的版本差异

  • Flutter SDK 和 Dart 版本:每个 Flutter 版本都绑定着一个 Dart 版本。如果项目中的 Flutter SDK 版本不匹配 Dart 版本,可能导致 API 不兼容,甚至在编译时出现错误。
  • Android Studio 和插件:Flutter 开发过程中, Android Studio 是不可或缺的开发环境,许多 Flutter 项目的构建都依赖于 Android Studio 和其相关插件。不同版本的 Android Studio 可能与 Flutter 的插件版本不兼容,导致构建和调试功能无法正常使用。
  • 原生平台差异:Flutter 项目不仅需要支持 Web 端,还需要兼容 AndroidiOS 平台。不同的 Android SDKXcode 版本可能导致原生构建过程中的错误和差异。

兼容性带来的问题

  1. API 不兼容:当使用不匹配的 FlutterDart 版本时,项目中的插件可能无法正常工作,导致一些 API 在新版本中无法调用或行为不一致。
  2. 构建失败或运行异常:在使用不兼容版本的 Android StudioFlutter SDK 时,构建过程可能会中断,或者即使构建成功,应用也可能无法正确运行。
  3. 调试工具不兼容:不同版本的 Flutter DevToolsAndroid Studio 插件可能会导致调试信息不完整,无法跟踪和修复运行时问题。

2. 如何解决兼容性问题?

保持工具链的一致性

  • 在开发团队中保持 Flutter SDKDartAndroid Studio 等工具的版本一致性非常重要。特别是在多人的项目中,如果每个人使用不同的版本,可能会导致不同的构建结果和调试问题。
  • 使用项目中的 .flutter-version.tool-version 文件来锁定 Flutter SDK 版本,确保团队成员使用相同版本。

定期更新依赖和工具

  • 使用 flutter pub outdatedflutter pub upgrade 检查依赖是否过时,并保持插件和 SDK 的更新。
  • 定期检查 Android StudioXcode 是否需要更新,确保它们与 Flutter SDK 版本兼容。

回退到兼容版本

  • 如果新版本的 Flutter SDKAndroid Studio 导致构建问题或不兼容,可以考虑回退到一个更稳定的版本,尤其是在使用过某些插件时,回退到兼容版本能有效避免兼容性问题。

测试兼容性

  • 在开始使用新版本的工具链前,确保在一个 独立的测试环境 中进行兼容性测试,检查构建、调试和发布流程是否正常工作。

3. 项目中常见的版本兼容问题举例

问题1:Flutter 插件与 Android Studio 版本不兼容

某些 Flutter 插件在 Android Studio 更新后可能无法与新版本兼容,导致无法正确构建或调试应用。开发团队通常需要回滚 Android Studio 到某个版本以避免这些问题。

问题2:Dart 和 Flutter 版本不兼容

Flutter SDK 和 Dart 版本之间有严格的依赖关系。如果项目使用了 Flutter 3.7,但 Dart 升级到 2.20 版本,则可能出现不兼容的 API,导致构建失败。

问题3:Gradle 和 Android SDK 版本冲突

如果项目使用了较旧版本的 GradleAndroid SDK,更新到新版本的 Android Studio 后,可能导致构建失败或无法正确运行。

总结:确保兼容性,保障项目稳定性

Flutter 项目开发过程中,保持 工具链依赖的兼容性 至关重要。随着 Flutter 不断更新迭代,适应新版本的工具链和 SDK 可能会带来新的挑战和问题,但通过合理的版本管理和工具选择,可以确保项目的稳定性。了解兼容性问题,并及时解决这些问题,将帮助你减少开发过程中的不必要麻烦,提升团队的开发效率。

二、环境和依赖问题:从前端到 Flutter 的第一步

问题1:依赖安装成功,但 flutter runassets 目录不存在

在运行 flutter run 时,我遇到一个让人头痛的问题:虽然我已经安装了所有依赖,但还是报出“assets 目录不存在”的错误。原以为是依赖的问题,但实际上是因为 pubspec.yaml 中声明的某个目录并没有对应的文件。

解决方法:

  • 检查 pubspec.yaml 文件中是否有正确的 assets 声明。确保声明的目录存在,如果没有,就需要手动补充或者删除不必要的目录声明。
  • 重新运行 flutter pub get 确保所有依赖都安装正确。
flutter:
  assets:
    - assets/images/

问题2:Web 插件 API 版本不兼容(如 mobile_scanner

在 Web 环境下运行时,插件可能会报出 API 不兼容的错误,尤其是像 mobile_scanner 这种插件,新旧版本之间差异较大。经过一些排查,我发现版本不兼容是导致问题的根本原因。

解决方法:

  • 通过命令 flutter pub outdated 查看插件的过期情况,确认当前 Flutter 版本支持的插件版本。
  • 降级到与 Flutter 兼容的插件版本,或者查阅插件的官方文档,确认是否支持 Web 平台。
dependencies:
  mobile_scanner: 3.0.0-beta.1  # 降级到兼容版本

问题3:Web 报 Unsupported operation: Platform._operatingSystem

另一个常见的问题是在 Web 环境中调用 dart:io 中的 Platform 类时,遇到“Unsupported operation: Platform._operatingSystem”的报错。

解决方法:

  • 在 Web 环境下,dart:io 库是不被支持的,因此我们需要使用 kIsWeb 来判断平台类型,避免在 Web 上使用不支持的功能。
import 'package:flutter/foundation.dart';

if (kIsWeb) {
  // 针对 Web 的代码
} else {
  // 针对 Android/iOS 的代码
}

三、Android 构建问题:打包和签名的麻烦

问题4:Android SDK 组件缺失

在构建 Android 应用时,遇到了 SDK 组件缺失的情况,报错提示缺少 build-toolsplatforms;android-31 等。原来是我的 Android SDK 没有更新到最新版本,导致了一些构建依赖的缺失。

解决方法:

  • 打开 Android Studio,前往 SDK Manager,确保所有必须的 SDK 组件(如 build-tools 和 Android API)都已经安装。
  • 如果你在公司内网环境下工作,注意解决代理问题,确保可以正常下载 SDK 组件。

问题5:签名文件缺失

在构建发布版 APK 时,我遇到了一个报错:“debug.keystore not found for signing config 'debug'”。这个问题源于 签名配置 缺失或配置不当。

解决方法:

  • build.gradle 文件中配置好签名文件,确保 Flutter 在构建时使用正确的签名配置。可以使用 debug.keystore 进行调试,发布时则需要使用 release.keystore
android {
    signingConfigs {
        release {
            storeFile file('path_to_your_keystore_file/release.keystore')
            storePassword 'your_keystore_password'
            keyAlias 'your_key_alias'
            keyPassword 'your_key_password'
        }
    }

    buildTypes {
        release {
            signingConfig signingConfigs.release
        }
    }
}

问题6:无法连接 Android 设备

在调试 Android 真机时,adb devices 返回了空设备列表,导致无法进行真机调试。尝试了几次后,发现问题出在设备的 USB 调试 设置。

解决方法:

  • 确保设备开启 开发者选项USB 调试,并且授权了计算机的 USB 调试 连接。
  • 使用 USB 数据线 检查连接是否正常,避免使用仅支持充电的线缆。

五、真机调试:常见的调试陷阱和解决方案

问题7:磁盘空间不足,构建失败

在构建过程中,我遇到了“No space left on device”的错误。检查了磁盘空间,发现项目的缓存和构建文件占用了大量空间。

解决方法:

  • 运行 flutter clean 清理缓存,并手动删除一些无用的构建文件(如 build/.dart_tool/android/.gradle)。
flutter clean
  • 定期清理不再需要的缓存文件和构建文件,保持开发环境的整洁。

问题8:安装失败,但编译成功

最后,虽然应用成功编译,但安装时遇到了“INSTALL_FAILED_ABORTED: User rejected permissions”错误。这是因为 Android 设备 没有授予正确的安装权限。

解决方法:

  • 在 Android 手机上,确保允许通过 USB 安装应用,并确认授权了安装权限。

六、总结与建议

通过这些踩坑经历,我总结出以下几点建议,帮助你更轻松地接手 Flutter 项目:

  1. 先确认环境:在运行 flutter run 之前,确保所有的依赖、SDK 和构建工具都已正确安装,并且 Android/iOS 环境已经配置好。
  2. 逐步排障:遇到问题时,按步骤排查,不要一下子改多个配置,避免造成更多问题。
  3. Web 与原生平台差异:在开发中,要时刻考虑 Web 和原生平台的差异,避免直接使用不支持的 API。
  4. 使用构建工具:Flutter 提供了方便的构建命令,确保正确配置签名和构建命令,避免构建失败。

React 核心原理完全解析:从组件化、虚拟DOM到声明式编程

作者 Oscarzhang
2026年2月12日 14:35

一、什么是 React?

React 是一个用于构建用户界面的 JavaScript 库,由 Facebook 开发。它采用组件化开发模式,通过声明式编程让 UI 开发更简单高效。

1. React 核心特点

  1. 组件化(Component) 把页面拆成按钮、卡片、列表等小模块,可复用、可组合。React 是组件驱动的。
  2. 虚拟 DOM(Virtual DOM) 不直接操作真实 DOM,先在内存对比差异,只更新变化部分,速度更快
  3. JSX JSX = JavaScript + XML,它不是字符串,也不是模板语法。它是JavaScript 的语法糖。
  4. 声明式 你只描述“要什么结果”,不关心“怎么实现”
  5. 单向数据流 数据只能从父组件流向子组件,不能反过来。

1. 组件化

1.1 组件化是什么?

组件化是将页面拆分为独立、可复用、可组合的代码单元的开发模式。每个组件负责渲染 UI 的特定部分,并管理自己的状态和行为。

React组件化思维

传统开发:一个页面 = 一个 HTML 文件
组件化开发:一个页面 = 多个组件的组合

[App 根组件]

┌────┴────┐
[Header]  [Content]
↓          ↓
[Logo]   [Sidebar] [Main]

1.2 为什么需要组件化?

传统开发的痛点

<!-- 2000 行代码的单个 HTML -->
<div id="app">
  <!-- 头部 -->
  <div class="header">...</div>
  <!-- 侧边栏 -->
  <div class="sidebar">...</div>
  <!-- 内容区 -->
  <div class="content">
    <!-- 混杂着 JS 逻辑 -->
    <script>
      // 代码难以维护
    </script>
  </div>
  <!-- 更多代码... -->
</div>

问题:

  • 代码耦合严重,牵一发而动全身
  • 难以复用,只能复制粘贴
  • 团队协作困难,容易冲突
  • 测试困难,必须测试整个页面
1.3 组件化的优势

高内聚低耦合 - 组件内部紧密相关,组件间相互独立
可复用性 - 一次编写,多处使用
可维护性 - 每个组件独立维护,职责单一
可测试性 - 可以单独测试每个组件
团队协作 - 并行开发不同组件

函数组件(主流)

function App() {
  return <div>Hello</div>;
}

类组件(旧写法)

class App extends React.Component {
  render() {
    return <div>Hello</div>;
  }
}

现在基本都用函数组件 + Hooks。

2.虚拟DOM

要理解虚拟 DOM,最直接的方式是回答三个问题:它是什么?它解决了什么问题?它是怎么做的?

2.1 什么是虚拟DOM?

虚拟 DOM(Virtual DOM)本质就是:用 JavaScript 对象来描述真实 DOM

真实DOM:

<div id="app">
  <h1>Hello</h1>
</div>

在 React 里会变成类似这样的 JS 对象:

{
  type: 'div',
  props: {
    id: 'app',
    children: [
      {
        type: 'h1',
        props: {
          children: 'Hello'
        }
      }
    ]
  }
}

它不是浏览器的真实节点,只是一个描述结构的 JS 对象。

2.2 为什么需要虚拟 DOM?

核心原因只有一个:

直接操作真实 DOM 性能开销大

真实 DOM 操作很慢,因为:

  • DOM 是浏览器 API
  • DOM 操作会引起重排(reflow)
  • 会触发布局和绘制

React 解决方案:

👉 先在内存中计算出变化
👉 再一次性更新真实 DOM

这就是虚拟 DOM 的意义。

2.3 React 更新流程(重点)

初次当你调用的时候

setState()

1. 生成虚拟DOM

render() -> new Virtual DOM

2. 和旧的虚拟 DOM 做对比(Diff 算法)

React会比较

oldVDOM vs newVDOM

找出差异。

3. 生成最小更新补丁(Patch)

例如:

  • 文本变了 → 只改文本
  • class 变了 → 只改 class
  • 子节点变了 → 局部替换
4. 批量更新真实 DOM

React 会统一执行最少的 DOM 操作。

总结

第一步:初次渲染

  • 组件返回 JSX,React 将其转为虚拟 DOM 树。
  • React 根据虚拟 DOM 树创建真实 DOM,插入页面。

第二步:状态更新

  • 状态变化,组件重新执行,生成新的虚拟 DOM 树
  • React 把新树旧树传给 Diffing 算法进行比较。

第三步:计算差异并批量更新

  • Diff 算法找出两棵树的最小差异。
  • React 把这些差异批量更新到真实 DOM 上,只操作必要节点。

关键洞察:虚拟 DOM 快的不是“比较”这个过程,而是它让开发者能用声明式的写法,同时通过批量更新避免了频繁操作 DOM

2.4 React Diff 算法原理(面试必问)

React 的 Diff 有 3 个核心假设:

2.4.1 同层比较(不会跨层比较)

只比较

div
 ├─ p
 └─ span

不会把 p 和 div 比。

这样把复杂度从:

O(n³)

降到

O(n)

2.4.2 不同类型直接替换
 

React 直接销毁重建。

2.4.3 key 是优化列表更新的关键

错误写法:

{list.map((item, index) =>
  <li key={index}>{item}</li>
)}

正确写法

{list.map((item) =>
  <li key={item.id}>{item.name}</li>
)}

因为:

   key 用来判断节点是否“同一个”

否则 React 会误判,导致:

  • 组件状态错乱
  • 性能变差
2.5 虚拟 DOM ≠ 性能一定更快

很多人误解:

React 比原生 DOM 快,因为虚拟 DOM

❌ 不完全对

真实情况是:

  • 少量 DOM 操作 → 原生更快
  • 复杂 UI 更新 → React 更优

虚拟 DOM优势在:

  • 批量更新
  • 可预测更新
  • 组件化管理
2.6 虚拟 DOM 的本质一句话

虚拟 DOM 是一个“状态到 UI 的映射缓存层”

UI = f(state)

React 通过虚拟 DOM保证:

state 改变 → 自动算出最小 DOM 更新

2.7 简单对比
方案 更新方式 性能
原生 DOM 手动操作 容易频繁重排
jQuery 直接操作 DOM 逻辑复杂
React 状态驱动 + 虚拟 DOM 自动最小更新
2.8 面试标准回答模板

如果面试问:

什么是虚拟 DOM?

你可以回答:

虚拟 DOM 是 React 用 JavaScript 对象表示真实 DOM 的一种机制。每次状态更新时,React 会生成新的虚拟 DOM,与旧虚拟 DOM 进行 Diff 算法比较,计算出最小的 DOM 变更,然后批量更新真实 DOM,从而提升性能和开发效率。

3.JSX语法

官方定义:JSX 是 JavaScript 的语法扩展,它类似于模板语言,但具有 JavaScript 的全部能力

本质:JSX 不是模板引擎,不是 HTML,它是 React.createElement 的语法糖。

function App() {
  return <h1>Hello React</h1>
}

JSX 是 JavaScript 的语法扩展,本质会被编译成:

React.createElement(type, props, children)

例如:

const element = <h1>Hello</h1>

会变成

const element = React.createElement("h1", null, "Hello");

本质:JSX 只是语法糖。

3.1 JSX 必须有一个根节点

❌ 错误:

return (
  <h1>Hello</h1>
  <p>World</p>
);

✅ 正确:

return (
  <div>
    <h1>Hello</h1>
    <p>World</p>
  </div>
);

或者使用 Fragment:

return (
  <>
    <h1>Hello</h1>
    <p>World</p>
  </>
);

3.2 JSX 中如何写 JS 表达式?

{}

const name = "Jake";

<h1>Hello {name}</h1>

也可以写表达式:

<h1>{1 + 2}</h1>
<h1>{isLogin ? "已登录" : "未登录"}</h1>

⚠️ 只能写表达式,不能写语句:

❌ 错误:

{ if (true) { ... } }

✅ 正确:

{ condition && <div>显示</div> }

3.3 JSX 中的属性写法
3.3.1 class 要写成 className
<div className="box"></div>

3.3.2 for 要写成 htmlFor
<label htmlFor="name">姓名</label>

因为 for 是 JS 关键字。

3.3.3 事件使用驼峰
<button onClick={handleClick}></button>

不是:

onclick

3.3.4 JSX 中写样式
<div style={{ color: "red", fontSize: "20px" }}>

注意:

  • 外层 {} 是 JS
  • 内层 {} 是对象
3.3.5 JSX 渲染列表
const list = [1,2,3];

<ul>
  {list.map(item =>
    <li key={item}>{item}</li>
  )}
</ul>

必须有 key。

3.3.6 JSX 条件渲染

三元表达式

{isLogin ? <Home /> : <Login />}

逻辑与

{isShow && <div>显示</div>}

3.3.7 JSX 组件写法

函数组件:

function Hello(props) {
  return <h1>Hello {props.name}</h1>;
}

使用:

<Hello name="Jake" />

3.4 总结(面试标准回答)

如果问:

JSX 是什么?

你可以回答:

JSX 是 JavaScript 的语法扩展,本质是 React.createElement 的语法糖。它允许我们用类似 HTML 的写法描述 UI,最终会被 Babel 编译成 JavaScript 对象,也就是虚拟 DOM。

3.5 常见的易错点
  • JSX 必须有根节点
  • 不能写 if 语句
  • 必须写 key
  • className 而不是 class
  • style 是对象

4.声明式

4.1 什么是声明式

声明式编程关注的是"做什么"(what),而不是"怎么做"(how)。你只需要描述你想要的UI状态,React会自动处理DOM更新。

4.2 命令式 vs 声明式
4.2.1 命令式(Imperative)

你一步一步告诉程序怎么做。

例如操作 DOM:

const btn = document.getElementById("btn");
btn.addEventListener("click", function() {
  const box = document.getElementById("box");
  box.style.display = "none";
});

特点:

  • 手动找 DOM
  • 手动改样式
  • 手动控制流程

👉 你在控制“过程”

4.2.2 声明式(Declarative)

你只告诉它:

当状态变成什么样,UI 应该是什么样

React 写法:

function App() {
  const [show, setShow] = React.useState(true);

  return (
    <>
      <button onClick={() => setShow(false)}>隐藏</button>
      {show && <div id="box">内容</div>}
    </>
  );
}

你没有:

  • 手动 getElementById
  • 手动修改 style
  • 手动删除节点

你只是声明

UI = f(state)

4.3 React 为什么是声明式?

因为:

React 只关心 state → UI 的映射关系

当 state 改变:

setShow(false)

React 自动:

  1. 重新执行函数组件
  2. 生成新虚拟 DOM
  3. Diff
  4. 更新真实 DOM

你不需要关心更新过程。

4.4 核心公式

React 的本质就是:

UI = f(state)

state 是数据
UI 是结果

你只描述这个函数关系。

4.5 生活中的例子
4.5.1 命令式(做饭)
  1. 洗菜
  2. 切菜
  3. 开火
  4. 放油

你控制步骤。

4.5.2 声明式(点外卖)

   你只说

我要一份牛肉面

怎么做你不关心。

4.6 代码中的例子
4.6.1 命令式循环
const arr = [1,2,3];
const result = [];

for(let i = 0; i < arr.length; i++){
  result.push(arr[i] * 2);
}

4.6.2 声明式
const result = arr.map(item => item * 2);

你没有说怎么循环,只声明转换规则。

4.7 声明式的优缺点
优点

1️⃣ 代码更简洁
2️⃣ 更易维护
3️⃣ 状态可预测
4️⃣ 更适合复杂 UI

缺点

1️⃣ 抽象层高
2️⃣ 不易调试底层
3️⃣ 性能优化要理解内部机制

4.8 总结(面试版)

如果问:

什么是声明式编程?

可以这样答:

声明式编程是一种只描述结果而不关心实现过程的编程方式。在 React 中,我们通过描述 state 和 UI 的映射关系,让框架自动处理 DOM 更新逻辑。

5.单向数据流

5.1 什么是单向数据流?

单向数据流(Unidirectional Data Flow)是指:数据在应用中只有一个方向流动——从父组件流向子组件,数据的变化只能通过特定的方式触发,不能直接修改祖先组件的数据。

核心公式

数据(State)→ 视图(UI)→ 行为(Action)→ 新数据(New State)→ 新视图(New UI)

数据永远是单向的,没有环路。

5.2 React例子

父组件

function Parent() {
  const [count, setCount] = React.useState(0);

  return (
    <>
      <h1>{count}</h1>
      <Child count={count} />
    </>
  );
}

子组件

function Child(props) {
  return <div>{props.count}</div>;
}

这里:

count 从 Parent 传给 Child

这就是单向数据流。

5.3 子组件能不能改父组件数据?

❌ 不能直接改

错误:

props.count = 100

React 不允许。

✅ 正确方式:父组件把“修改函数”传下去

父组件 - 银行(掌握钱)

function Bank() {
  const [money, setMoney] = useState(1000);

  return (
    <div>
      <h2>银行总资产:¥{money}</h2>
      {/* 数据向下流:银行给ATM机钱,但ATM机不能直接改银行余额 */}
      <ATM 
        currentMoney={money}      // ✅ 数据向下银行ATM
        withdraw={setMoney}       // ✅ 回调向下银行给ATM取款权限
      />
    </div>
  );
}

子组件 - ATM机(只能取钱,不能直接改银行余额)

function ATM({ currentMoney, withdraw }) {
  return (
    <button onClick={() => withdraw(currentMoney - 100)}>
      💰 取100元(当前余额:¥{currentMoney})
    </button>
  );
}

单向数据流路线图:

银行(数据所有者)
↓ 把钱给ATM机(props)
↓ ATM机显示余额(只读)
↓ 用户点取款(事件)
↓ ATM机向银行发信号(回调)
↓ 银行自己改余额(setState)
↓ 银行重新给ATM机新余额(props)
↓ 界面更新

关键区别:

  • ✅ 你的例子:父传了 setCount,子直接调用——这是允许的,但不够直观
  • ✅ 这个例子:父既传数据(只读)、又传修改方法(回调),清楚看到数据从哪里来、修改权限在哪里

本质一句话:

钱在银行手里,ATM机只能请求取钱,不能自己打开金库改数字。

5.4 为什么要单向数据流?

如果是双向数据流会发生什么?

比如 A 改 B
B 又改 C
C 又改 A

你会发现:

  • 数据来源变得不可追踪
  • 状态错乱
  • 难以维护

单向数据流的优点:

  • 数据来源清晰
  • 状态可预测
  • 更容易调试
  • 更适合大型应用
5.5 单向数据流 + 声明式

React 的核心公式:

UI = f(state)

单向数据流保证:

state 改变 → UI 自动更新

而不是:

UI 改变 → 影响 state → 再影响别的 UI

5.6 和 Vue 的区别

Vue 2 有双向绑定:

<input v-model="msg">

这属于:

 双向数据绑定(本质还是单向数据流 + 语法糖)

React 则强调:

  • 数据只往下流
  • 表单也是受控组件
5.7 总结(面试回答)

如果问:

什么是单向数据流?

可以这样回答:

单向数据流指数据只能从父组件流向子组件,子组件不能直接修改父组件的数据。当数据发生变化时,会重新渲染 UI,从而保证状态来源清晰、可预测,便于维护和调试。

5.8 核心思想

数据只能自上而下流动,所有状态修改必须在源头发生。

🎯 共同进步
作为技术路上的同行者,我深知自己的理解可能还不够完善。
如果文章中有任何疏漏或可以改进的地方,恳请不吝指教。你的每一次反馈,都是我进步的动力。
一起加油,成为更好的开发者!

react中的filble架构和diffes算法如何实现的

作者 光影少年
2026年2月12日 14:34

一、先给面试官的“王炸开场白”

你可以先来这一句 👇

React Fiber 是为了解决旧 Diff 递归不可中断的问题,引入的可中断、可优先级调度的架构;Diff 算法本身没变,但执行方式变了。

⚠️ 这一句,直接把你和“只背概念的人”区分开。


二、为什么 React 要引入 Fiber?

1️⃣ 旧架构的问题(Stack Reconciler)

React 16 之前:

更新 → 递归 diff → 一口气算完 → commit

致命问题

  • 不可中断
  • ❌ 大组件树会卡主线程
  • ❌ 无法区分优先级(动画、输入)

👉 用户体验:掉帧、卡顿


2️⃣ Fiber 解决了什么?

一句话总结 👇

把“递归不可中断” → 拆成“可中断的任务单元”


三、Fiber 是什么?(一定要讲清楚)

1️⃣ Fiber 本质不是算法,是数据结构

interface Fiber {
  type
  key
  stateNode

  child
  sibling
  return

  pendingProps
  memoizedProps
  memoizedState

  flags
}

核心点

  • 每一个组件 = 一个 Fiber 节点
  • Fiber 是 链表结构,不是树递归

2️⃣ Fiber 结构长什么样?

parent
  |
 child —— sibling —— sibling

👉 用 child / sibling / return 模拟树

为什么不用数组?

  • 链表更容易拆分、暂停、恢复

四、Fiber 架构是怎么“跑”起来的?

React 更新分两大阶段(必考)

Render 阶段(可中断)
Commit 阶段(不可中断)

1️⃣ Render 阶段(核心)

做什么?

  • Diff
  • 生成 Fiber 树
  • 标记副作用(flags)

特点:

  • ✅ 可中断
  • ✅ 可恢复
  • ❌ 不操作 DOM
workLoop
  ↓
performUnitOfWork
  ↓
next Fiber

2️⃣ Commit 阶段

做什么?

  • 操作 DOM
  • 执行 useEffect

特点:

  • ❌ 不可中断
  • ✅ 很快

3️⃣ 面试话术

Fiber 通过把 Render 阶段拆成小任务,配合浏览器空闲时间执行,实现了时间分片。


五、什么是时间分片(Time Slicing)?

核心 API

requestIdleCallback

React 内部思想:

while (有任务 && 有空闲时间) {
  执行一个 Fiber
}

👉 不卡 UI 的关键


六、Diff 算法到底是怎么做的?

⚠️ 重点来了:
Diff 算法思想没变,执行模型变了


React Diff 的三大假设(必背)

1️⃣ 不同类型节点,直接删

<div /> → <span />

👉 不复用,整棵子树重建


2️⃣ 同一层级比较(O(n))

React 不会跨层 diff

只比较兄弟节点

3️⃣ key 决定节点复用

{list.map(item => (
  <Item key={item.id} />
))}

Diff 的真实过程(列表为例)

无 key(危险)

A B C
↓
B C D

👉 全部错位复用,性能差


有 key(正确)

A(key1) B(key2) C(key3)
↓
B(key2) C(key3) D(key4)

👉 复用 B、C,只增删 A / D


面试话术

React Diff 是基于同层比较和 key 的启发式算法,把复杂度从 O(n³) 降到 O(n)。


七、Fiber 和 Diff 的关系(面试官最爱问)

错误理解 ❌

Fiber = 新 Diff

正确理解 ✅

Diff 算法没变,Fiber 改变的是 Diff 的执行方式和调度模型。


八、为什么 Fiber 能“中断”,递归不行?

递归的问题

diff(node) {
  diff(node.child)
}
  • 调用栈一旦开始,停不了

Fiber 的优势

一个 Fiber = 一个工作单元
  • 执行完一个就可以停
  • 下次从 nextFiber 继续

九、面试官常见追问 & 满分回答

Q1:Fiber 是不是双缓存?

✅ 是

current Fiber Tree
workInProgress Fiber Tree

提交后互换引用。


Q2:useEffect 在哪执行?

👉 Commit 阶段之后


Q3:为什么 Render 阶段不能操作 DOM?

👉 因为可能被中断,多次执行会出问题。

闭包的“连坐”悬案:一个内存泄漏引发的血案

2026年2月12日 14:20

第一章:案发现场

夜深了,我还在吭哧吭哧地写代码。突然,监控系统发来警报:内存占用率飙升!

我心里一惊,赶紧冲到案发现场。经过一番排查,我把嫌疑锁定在了一段看似人畜无害的代码上:

function demo() {
  // 一个 100MB 的大胖子
  const bigArrayBuffer = new ArrayBuffer(100_000_000);

  // 一个 1 秒后执行的定时器,用到了大胖子
  const id = setTimeout(() => {
    console.log(bigArrayBuffer.byteLength);
  }, 1000);

  // 返回一个清理函数,用来取消定时器
  return () => clearTimeout(id);
}

// 把清理函数挂到全局,以便随时调用
globalThis.cancelDemo = demo();

我百思不得其解。这段代码的逻辑很清晰:

  1. demo 函数执行,创建了一个 100MB 的大胖子 bigArrayBuffer
  2. 一个 setTimeout 在 1 秒后会用一下这个大胖子。
  3. demo 函数返回了一个 cancelDemo 函数,这个函数只认识 id,根本不认识 bigArrayBuffer

我的推理是:1 秒钟之后,setTimeout 的回调执行完毕,再也没有人认识 bigArrayBuffer 了。它应该被垃圾回收(GC)大叔带走,释放那 100MB 的宝贵内存。

但现实是残酷的。bigArrayBuffer 像个钉子户,永远地赖在了内存里!

为什么?难道是 GC 大叔偷懒了?还是 V8 引擎出了 Bug?

为了搞清楚真相,我决定从头开始,审问每一个嫌疑人。

第二章:排除嫌疑

我写了几个简单的变种,想看看 GC 大叔到底是怎么想的。

嫌疑人 A:最简单的函数

function demo() {
  const bigArrayBuffer = new ArrayBuffer(100_000_000);
  console.log(bigArrayBuffer.byteLength);
}

demo();

结果demo 函数一执行完,bigArrayBuffer 立刻被回收。内存瞬间恢复正常。

结论:GC 大叔很敬业,人走茶凉,绝不含糊。嫌疑人 A 无罪释放。

嫌疑人 B:只有 setTimeout

function demo() {
  const bigArrayBuffer = new ArrayBuffer(100_000_000);

  setTimeout(() => {
    console.log(bigArrayBuffer.byteLength);
  }, 1000);
}

demo();

结果demo 函数执行完后,bigArrayBuffer 并没有马上被回收。GC 大叔很有耐心,它知道 1 秒后还有人要用它。等到 setTimeout 的回调执行完毕,bigArrayBuffer 才被带走。

结论:GC 大叔不仅敬业,还很智能,能预判未来的使用情况。嫌疑人 B 也无罪。

嫌疑人 C:返回的函数不引用任何东西

function demo() {
  const bigArrayBuffer = new ArrayBuffer(100_000_000);

  const id = setTimeout(() => {
    console.log("hello"); // 注意,这里没用大胖子
  }, 1000);

  return () => clearTimeout(id);
}

globalThis.cancelDemo = demo();

结果bigArrayBufferdemo 函数执行完后立刻被回收!

结论:GC 大叔简直是火眼金睛!它通过静态分析发现,虽然 demo 函数里有一堆内部函数,但没有一个真正用到了 bigArrayBuffer。于是它大笔一挥:“此物无用,收走!”

到这里,我更糊涂了。GC 大叔明明这么聪明,为什么在最初的案发现场就“失手”了呢?

第三章:真相大白

我把最初的案发现场代码又看了一遍,感觉自己漏掉了什么关键线索。

function demo() {
  // 作用域开始
  const bigArrayBuffer = new ArrayBuffer(100_000_000);
  const id = setTimeout(/* ... */);
  return () => clearTimeout(id);
  // 作用域结束
}

问题就出在 作用域(Scope)上。

在 JavaScript 的世界里,当 demo 函数被调用时,它会创建一个自己的“小世界”,也就是它的作用域。这个小世界里住着它所有的孩子:bigArrayBufferidsetTimeout 的回调函数,还有那个被 return 出去的匿名函数。

GC 大叔的回收原则,和我们想的不太一样。它不是一个一个地检查变量是否需要回收,而是以“作用域”为单位进行回收

你可以把一个作用域想象成一个“家庭”。GC 大叔的规则是:

只要这个家庭里还有任何一个成员(函数)在外面有“关系”(能被外界访问到),那整个家庭(作用域)就得给我好好地待在内存里,一个都不能少!

这就是闭包的“连坐”制度!

现在,我们再来看看案发现场:

  1. demo 函数执行,创建了一个作用域“家庭”。家庭成员有:bigArrayBufferid、定时器回调、返回的清理函数。
  2. 定时器回调函数引用了 bigArrayBuffer,所以 bigArrayBuffer 被留在了这个家庭里。
  3. demo 函数把“清理函数” () => clearTimeout(id) 返回给了外界,并赋值给了 globalThis.cancelDemo。这意味着,这个清理函数在外面有了“关系”,它还活着!
  4. GC 大叔来检查了。它发现 cancelDemo 这个家庭成员还活着,于是它大手一挥:“这个家庭不能动!所有人原地待命!”

于是,整个 demo 函数的作用域都被保留了下来。

bigArrayBuffer 就这样,被无情地“连坐”了。即使 1 秒后,那个唯一引用它的定时器回调已经执行完毕,变成了“死人”,但只要 cancelDemo 这个“活人”还在,bigArrayBuffer 作为它的家庭成员,就必须陪着它一起留在内存里。

它就像一个无辜的路人,只是因为和某个“大人物”住在同一个小区,结果整个小区都被保护了起来,它也出不去了。

第四章:一荣俱荣,一损俱损

为了验证这个“连坐”理论,我又设计了一个更极端的实验:

function demo() {
  const bigArrayBuffer = new ArrayBuffer(100_000_000);

  // 大儿子,认识大胖子
  globalThis.innerFunc1 = () => {
    console.log(bigArrayBuffer.byteLength);
  };

  // 二儿子,不认识大胖子
  globalThis.innerFunc2 = () => {
    console.log("hello");
  };
}

demo();

现在,demo 家庭有两个儿子被送到了外面,都还活着。

接着,我把大儿子干掉:

globalThis.innerFunc1 = undefined;

现在,唯一认识 bigArrayBuffer 的函数已经没了。按理说,bigArrayBuffer 应该可以被回收了吧?

并不会!

因为二儿子 innerFunc2 还活着!GC 大叔一看,这个家庭还有后代在外面,于是整个家庭继续保留。bigArrayBuffer 再次被“连坐”。

只有当我把二儿子也干掉时:

globalThis.innerFunc2 = undefined;

现在,demo 家庭在外面已经没有任何“关系”了。GC 大叔终于可以放心地把整个作用域连锅端了。bigArrayBuffer 这才得以解脱。

这个发现令人震惊:闭包变量的生命周期,取决于“最后一个兄弟”的生命周期,而不是它自己的!

第五章:引擎的辩护

你可能会问:V8 引擎为什么设计得这么“蠢”?为什么不能更智能一点,只保留那些被真正引用的变量呢?

这是一个跨浏览器都存在的问题,而且很可能不会被修复。原因很简单:性能

要做得更精细,就意味着 GC 大叔的工作量会大大增加。它不仅要检查哪个家庭还有后代,还得去调查每个后代到底和家里的哪些东西有联系。这个“尽职调查”的成本太高了,会让整个 JavaScript 的执行效率变慢。

所以,引擎的设计者们做了一个权衡:用一个简单粗暴但高效的“连坐”规则,换取整体的性能。在大多数情况下,这个规则都没问题。只是在某些特定的闭包场景下,会造成意想不到的内存泄漏。

第六章:如何破解“连坐”?

既然我们知道了“连坐”的规则,那破解它也就有了思路。

方案一:斩草除根

既然问题是 cancelDemo 这个“活口”导致的,那我们就在用完它之后,把它干掉!

// 用完了,或者确定不需要了
globalThis.cancelDemo = null;

一旦 cancelDemo 被设置为 nulldemo 家庭在外面就再也没有任何“关系”了。GC 大叔会立刻把整个作用域回收,bigArrayBuffer 自然也就被释放了。

方案二:分家!

既然“连坐”是按“家庭”来的,那我们就把它们分成不同的家庭!

function demo() {
  let cancel;

  // 家庭 A:只负责定时器
  {
    const id = setTimeout(() => {
      console.log("hello");
    }, 1000);
    cancel = () => clearTimeout(id);
  }

  // 家庭 B:只负责大胖子
  {
    const bigArrayBuffer = new ArrayBuffer(100_000_000);
    console.log(bigArrayBuffer.byteLength);
  }

  return cancel;
}

globalThis.cancelDemo = demo();

在这个版本里,我们用 {} 创建了两个独立的块级作用域。

  • bigArrayBuffer 住在“家庭 B”。这个家庭没有任何成员被暴露到外面,所以 demo 函数一执行完,家庭 B 就被整个回收了。
  • cancel 函数来自“家庭 A”。它虽然活了下来,但它的家庭成员里根本没有 bigArrayBuffer

这样一来,bigArrayBuffer 就不会被“连坐”了。

尾声:闭包的江湖

在 JavaScript 的江湖里,闭包是一个神奇的存在。它赋予了函数记忆的能力,但也带来了复杂的“社会关系”。

今天这个悬案告诉我们:

在闭包的世界里,一个变量能否被释放,不仅取决于它自己是否还在被使用,更取决于和它“同住一个屋檐下”的兄弟姐妹们,是否都已经“功成身退”。

所以,下次当你创建一个闭包时,特别是当它要和一个大对象共处一室时,请多留一个心眼。问问自己:那个即将被你 return 出去的函数,它的兄弟姐妹们都安排好了吗?

否则,下一个内存泄漏的案发现场,可能就在你的代码里。


Vue2跨组件通信方案:全局事件总线与Vuex的灵活结合

作者 青屿ovo
2026年2月12日 14:17

Vue2跨组件通信方案:全局事件总线与Vuex的灵活结合

前端高频面试/开发考点!一文吃透Vue2跨组件通信核心,Bus+Vuex结合用法拆解,代码可直接复制复用,新手也能快速避坑,收藏备用~

📋 目录

  • 一、核心前言(为什么需要两种方案结合?)

  • 二、全局事件总线(Bus)详解(3步落地+避坑)

  • 三、Vuex详解(Vue2状态管理核心,5大模块+4步实战)

  • 四、Bus与Vuex灵活结合(实战场景+核心优势)

  • 五、高频避坑指南(面试常考)

  • 六、核心总结(快速回顾重点)


一、核心前言

Vue2开发中,跨组件通信是绕不开的高频需求,不同组件层级(父子、兄弟、隔代、无关联)对应不同解决方案,单一方案往往有局限性:

  • props/emit:仅适合父子组件,层级嵌套多时会出现“props drilling”(props穿透),代码冗余;

  • 全局事件总线(Bus):轻量高效,但无状态管理,复杂场景难以维护;

  • Vuex:集中管理状态,适合复杂场景,但配置繁琐,简单通信成本高。

核心原则:简单通信用Bus(轻量高效),复杂状态用Vuex(统一管理),两者灵活结合,可高效解决99%的Vue2跨组件通信需求。


二、全局事件总线(Bus)详解

1. 什么是全局事件总线?

本质:通过Vue实例作为“中间桥梁”,实现任意组件间的事件传递(触发+监听),无需层层传递,轻量无依赖、无需额外安装,是简单跨组件通信的最优解。

适用场景:兄弟组件通信、隔代组件简单通信、无关联组件单次通信(如弹窗关闭、通知提示、页面刷新通知)。

2. 实现步骤(3步落地,代码可直接复制)

步骤1:创建全局Bus实例(main.js配置)
// main.js(Vue2项目)
import Vue from 'vue'
import App from './App.vue'

// 创建全局事件总线,挂载到Vue原型,所有组件可直接访问
Vue.prototype.$Bus = new Vue()

new Vue({
  el: '#app',
  render: h => h(App)
})
步骤2:发送事件(触发方组件)

通过 this.$Bus.$emit('事件名', 传递的数据) 发送事件,支持任意类型数据(对象、数组、基本类型)。

<template>
  <button @click="sendMsg" style="padding: 8px 16px; cursor: pointer;">发送消息给兄弟组件</button>
</template>

<script>
export default {
  methods: {
    sendMsg() {
      // 事件名建议语义化,避免冲突(可加组件前缀,如brother-msg)
      this.$Bus.$emit('brotherMsg', {
        content: 'Hello,兄弟组件!',
        time: new Date().toLocaleString()
      })
    }
  }
}
</script>
步骤3:监听事件(接收方组件)

通过 this.$Bus.$on('事件名', 回调函数) 监听事件,重点:必须在beforeDestroy中销毁监听,避免内存泄漏和事件多次触发。

<template>
  <div class="brother-component">
    <h4>接收兄弟组件消息:</h4>
    <p v-if="msg">{{ msg.content }}({{ msg.time }})</p>
  </div>
</template>

<script>
export default {
  data() {
    return {
      msg: null
    }
  },
  mounted() {
    // 监听事件,与发送方事件名保持一致
    this.$Bus.$on('brotherMsg', (data) => {
      this.msg = data
    })
  },
  beforeDestroy() {
    // 销毁监听,避免内存泄漏(必写!面试常考)
    this.$Bus.$off('brotherMsg')
  }
}
</script>

3. Bus核心方法速查(表格清晰记)

方法名 说明 使用示例
$emit 发送事件,传递数据 this.Bus.Bus.emit('name', data)
$on 监听事件,接收数据 this.Bus.Bus.on('name', (data)=>{})
$off 销毁监听,避免泄漏 this.Bus.Bus.off('name')

4. Bus优缺点(辩证看待)

✅ 优点

  • 轻量、简单、无依赖,接入成本极低

  • 无需额外配置,开箱即用

  • 适合简单通信场景,效率高

❌ 缺点

  • 无状态管理,无法追踪数据来源

  • 事件名易冲突,维护成本随项目变大升高

  • 不适合多组件共享、频繁修改的复杂状态


三、Vuex详解(Vue2状态管理核心)

1. 什么是Vuex?

Vue2官方状态管理库,用于集中管理所有组件的共享状态(如用户信息、购物车数据、全局设置),实现组件间状态共享和统一修改,可追踪状态变化,是中大型Vue2项目的首选方案。

适用场景:多组件共享状态、需频繁修改/追踪的复杂状态、全局状态管理(如用户登录状态、主题切换)。

2. Vuex核心概念(5大模块,面试必背)

记牢这5个模块,即可掌握Vuex核心用法,面试高频提问!

  • state:存储全局状态(类似组件的data),唯一数据源,所有组件共享;

  • mutations:修改state的唯一方式(仅支持同步操作),禁止写异步代码;

  • actions:处理异步操作(如接口请求),不能直接修改state,需通过commit调用mutations;

  • getters:对state进行加工处理(类似组件的computed),可缓存结果,避免重复计算;

  • modules:拆分模块(大型项目用),避免state过于臃肿,每个模块可拥有独立的state、mutations等。

3. 使用步骤(4步落地,实战可直接复用)

步骤1:安装Vuex(Vue2专属版本,避坑关键)

Vue2必须安装3.x版本,4.x版本仅适配Vue3,装错会直接报错!

# Vue2项目安装命令(固定版本,避免兼容问题)
npm install vuex@3.6.2 --save
步骤2:创建Vuex实例(src/store/index.js)
// src/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'

// 安装Vuex插件
Vue.use(Vuex)

// 创建Vuex实例
const store = new Vuex.Store({
  // 存储全局状态
  state: {
    userInfo: null, // 多组件共享:用户信息
    count: 0 // 示例:简单共享计数
  },
  // 同步修改state(仅同步操作)
  mutations: {
    setUserInfo(state, data) {
      state.userInfo = data // 只能通过mutation修改state
    },
    increment(state) {
      state.count++
    }
  },
  // 处理异步操作(如接口请求)
  actions: {
    // 模拟异步获取用户信息(实际项目替换为接口请求)
    getUserInfoAsync({ commit }, data) {
      setTimeout(() => {
        // 异步操作完成后,通过commit调用mutation修改state
        commit('setUserInfo', data)
      }, 1000)
    }
  },
  // 加工state,缓存结果
  getters: {
    // 判断用户是否登录
    isLogin(state) {
      return !!state.userInfo
    },
    // 获取计数的2倍(缓存结果,避免重复计算)
    doubleCount(state) {
      return state.count * 2
    }
  }
})

export default store
步骤3:挂载Vuex到Vue实例(main.js)
// main.js
import Vue from 'vue'
import App from './App.vue'
import store from './store' // 引入store实例
import Vuex from 'vuex'

Vue.use(Vuex)

new Vue({
  el: '#app',
  render: h => h(App),
  store // 挂载后,所有组件可通过this.$store访问Vuex
})
步骤4:组件中使用Vuex(读取/修改状态)
<template>
  <div class="vuex-demo">
    <h4>Vuex状态使用示例</h4>
    <p>当前计数:{{ $store.state.count }}</p>
    <p>计数的2倍:{{ $store.getters.doubleCount }}</p>
    <p>用户是否登录:{{ $store.getters.isLogin ? '已登录' : '未登录' }}</p>
    
    <button @click="addCount" style="margin-right: 10px; padding: 8px 16px;">增加计数</button>
    <button @click="getUserInfo" style="padding: 8px 16px;">模拟登录</button>
  </div>
</template>

<script>
export default {
  methods: {
    // 同步修改state:调用mutation(唯一方式)
    addCount() {
      this.$store.commit('increment')
    },
    // 异步修改state:调用action,由action触发mutation
    getUserInfo() {
      this.$store.dispatch('getUserInfoAsync', {
        username: 'vue2demo',
        age: 22
      })
    }
  }
}
</script>

4. Vuex优缺点(辩证看待)

✅ 优点

  • 集中管理共享状态,可追踪状态变化(调试方便);

  • 规范组件通信,避免数据混乱;

  • 适合复杂场景,维护成本低,扩展性强。

❌ 缺点

  • 配置繁琐,简单通信场景(如单次弹窗)使用成本高;

  • 小型项目无需使用,过度封装会增加冗余。


四、Bus与Vuex的灵活结合(核心重点)

1. 结合原则(实战核心)

记住一句话:简单通信用Bus,复杂状态用Vuex,两者互补,避开单一方案的弊端,提升开发效率。

  • 用Bus的场景:一次性通信、无状态依赖通信(弹窗关闭、兄弟组件单次消息、页面刷新通知);

  • 用Vuex的场景:多组件共享状态、需频繁修改/追踪的复杂状态(用户信息、购物车、全局设置)。

2. 实战结合示例(面试常考场景)

场景:用户登录成功后,用Vuex同步全局用户状态,用Bus通知所有相关组件(导航栏、个人中心)刷新页面。

// 1. 登录组件(触发登录,调用Vuex action + 发送Bus事件)
export default {
  methods: {
    login() {
      // 模拟接口请求登录,获取用户数据
      const userData = { username: 'vue2demo', role: 'admin' }
      // ① 调用Vuex action,同步用户状态到全局(复杂状态管理)
      this.$store.dispatch('getUserInfoAsync', userData)
      // ② 发送Bus事件,通知其他组件刷新(简单一次性通信)
      this.$Bus.$emit('userLoginSuccess', userData)
    }
  }
}

// 2. 导航栏组件(监听Bus事件 + 读取Vuex状态)
export default {
  data() {
    return {
      userInfo: null
    }
  },
  mounted() {
    // 监听Bus事件,接收登录成功通知,局部更新
    this.$Bus.$on('userLoginSuccess', (data) => {
      this.userInfo = data
    })
    // 初始化时,读取Vuex中的全局用户状态
    this.userInfo = this.$store.state.userInfo
  },
  beforeDestroy() {
    // 销毁Bus监听,避免内存泄漏
    this.$Bus.$off('userLoginSuccess')
  }
}

3. 结合优势(为什么要这么用?)

  • ✅ 高效:简单场景无需配置复杂Vuex,降低开发成本;复杂场景用Vuex,保证状态规范;

  • ✅ 灵活:按需选择方案,避免“一刀切”(不用为了简单通信写一堆Vuex配置);

  • ✅ 易维护:状态集中管理(Vuex),单次通信解耦(Bus),代码清晰,后期好维护。


五、高频避坑指南(面试常考,必看!)

这些坑90%的新手都会踩,收藏起来,避免踩坑!

1. Bus避坑(2个核心)

  • 事件名必须语义化,可加组件前缀(如header-close、brother-msg),避免冲突;

  • 必须在beforeDestroy中销毁监听(this.Bus.Bus.off('事件名')),否则会导致内存泄漏、事件多次触发。

2. Vuex避坑(3个核心)

  • Vue2必须安装Vuex@3.x版本,4.x仅适配Vue3,装错会直接报错;

  • mutations只能写同步代码,异步操作(如接口请求)必须放在actions中,否则无法追踪状态变化;

  • 禁止直接修改state(如this.$store.state.count = 1),必须通过mutation修改(面试高频考点)。

3. 结合避坑(2个核心)

  • 不滥用Vuex,简单通信用Bus即可,避免过度封装;

  • Bus仅用于“通知”,不传递大量复杂数据(复杂数据用Vuex存储),避免数据混乱。


六、核心总结

本文核心是「Bus+Vuex灵活结合」,记住以下4点,轻松应对Vue2跨组件通信所有场景:

  1. 全局事件总线(Bus):Vue实例作为桥梁,轻量简单,适合简单通信,重点是销毁监听

  2. Vuex:Vue2官方状态管理库,集中管理共享状态,适合复杂场景,核心是5大模块,禁止直接修改state

  3. 结合逻辑:简单通信用Bus,复杂状态用Vuex,互补使用,提升开发效率和代码可维护性;

  4. 避坑关键:Bus销毁监听、Vuex版本适配、不直接修改state、事件名语义化。

你在Vue2跨组件通信中还遇到过哪些坑?欢迎在评论区留言交流,一起避坑成长~

Javascript 闭包与高阶函数

作者 Oscarzhang
2026年2月12日 14:13

一、闭包(Closure)

1.什么是闭包?

闭包是指函数能够记住并访问其词法作用域,即使函数在其词法作用域之外执行。简单说,就是内部函数可以访问外部函数的变量。

2.理解作用域

JavaScript 是 词法作用域(Lexical Scope)

变量的作用域在定义时决定,而不是在调用时决定。

function outer() {
  let a = 10;

  function inner() {
    console.log(a);
  }

  inner();
}

outer(); // 10

inner 能访问 outer 里的 a,因为它定义在 outer 里面。

闭包的基本示例

function createCounter() {
  let count = 0; // 私有变量
  
  return function inner() {
    count++;
    return count;
  };
}

const counter1 = createCounter();
console.log(counter1()); // 1
console.log(counter1()); // 2
console.log(counter1()); // 3

const counter2 = createCounter();
console.log(counter2()); // 1 (独立的计数器)

打印结果

发生了什么?

当 createCounter() 执行结束后:

  • 按理说 count 应该被销毁
  • 但是 inner 还在引用它
  • JS 引擎不会回收这个变量

👉 因为 inner 形成了闭包

3. 闭包的执行过程(原理)

当函数创建时,会:

  1. 创建一个执行上下文
  2. 生成一个词法环境
  3. 内部函数会保存对这个词法环境的引用

只要内部函数还存在,这个环境就不会被垃圾回收。

4. 闭包的关键特征

  • 函数嵌套函数:通常是一个外层函数包裹一个内层函数。
  • 内层函数引用外层函数的变量:这是形成闭包的必要条件。
  • 外层函数将内层函数返回(或传递出去) :使得内层函数可以在其原始作用域之外被调用。

5. 闭包的常见用途

1.数据私有化(封装)

function createUser() {
  let password = "123456";

  return {
    checkPassword(input) {
      return input === password;
    }
  };
}

const user = createUser();
console.log(user.checkPassword("123456")); // true

打印结果:

外部访问不到 password

2. 防抖/节流

function debounce(fn, delay) {
  let timer;

  return function () {
    clearTimeout(timer);
    timer = setTimeout(fn, delay);
  };
}

timer就是被闭包保存的

3.循环中的经典坑(经典面试题)

for (var i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i);
  }, 1000);
}

打印结果:

原因:
var 声明的 i 是函数作用域(或全局作用域),3个定时器回调共享同一个 i。当循环结束时,i 已经是 3,所以每个回调都打印 3。

解决方案:

for (let i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i);
  }, 1000);
}

使用let:

let 声明的变量是块级作用域,每次循环都会创建一个新的 i 绑定,效果等同于用闭包保存了状态。

或者使用

for (var i = 0; i < 3; i++) {
  (function (i) {
    setTimeout(() => {
      console.log(i);
    }, 1000);
  })(i);
}

打印结果:

这个就是利用闭包锁住变量

6.闭包的优缺点

优点
  • 数据私有化
  • 模块化开发
  • 避免全局变量污染
  • 实现函数式编程模式
缺点
  • 容易造成内存泄漏(长期引用大对象)
  • 不合理使用会影响性能
  • 变量长期存在难以调试

7.面试总结版

  • 闭包是指函数可以访问其定义时的词法作用域,即使外部函数已经执行结束。
  • 本质是函数持有对外部变量的引用。
  • 常见应用场景包括数据私有化、计数器、防抖节流等。

二、高阶函数

1.什么是高阶函数?

在 JavaScript 里,函数是一等公民(First-Class Function)

  • 可以赋值给变量
  • 可以作为参数传入
  • 可以作为返回值返回

高阶函数是指至少满足以下条件之一的函数:

  1. 接受一个或多个函数作为参数
  2. 返回一个函数作为结果

简单说:操作其他函数的函数

2. 常见的高阶函数示例

示例1:函数作为参数(回调函数)

function greet(name) {
  return "Hello " + name;
}

function processUserInput(callback) {
  const name = "Mike";
  console.log(callback(name));
}

processUserInput(greet);

打印结果:

示例2:函数作为返回值

function multiplier(factor) {
  console.log("打印factor:", factor); //打印 2 3
  return function (number) {
    console.log("打印number:", number); //打印 5 6
    return number * factor;
  };
}

const double = multiplier(2);
const triole = multiplier(3);

console.log(double(5)); //10
console.log(triole(6)); //18

打印结果:

示例3.经典数组方法

map() - 映射/转换
const numbers = [1, 2, 3, 4, 5];

// 每个元素乘以2
const doubled = numbers.map(num => num * 2);
console.log(doubled); // [2, 4, 6, 8, 10]

// 转换为对象数组
const objects = numbers.map(num => ({ value: num }));
console.log(objects);
// [{value: 1}, {value: 2}, {value: 3}, {value: 4}, {value: 5}]

filter() - 过滤
const numbers = [1, 2, 3, 4, 5, 6];

// 获取偶数
const evens = numbers.filter(num => num % 2 === 0);
console.log(evens); // [2, 4, 6]

// 获取大于3的数
const greaterThan3 = numbers.filter(num => num > 3);
console.log(greaterThan3); // [4, 5, 6]

reduce() - 归约/累积
const numbers = [1, 2, 3, 4, 5];

// 求和
const sum = numbers.reduce((accumulator, current) => accumulator + current, 0);
console.log(sum); // 15

// 求最大值
const max = numbers.reduce((acc, curr) => Math.max(acc, curr), -Infinity);
console.log(max); // 5

// 数组转对象
const users = [
  { id: 1, name: 'Alice' },
  { id: 2, name: 'Bob' }
];

const usersById = users.reduce((acc, user) => {
  acc[user.id] = user;
  return acc;
}, {});

console.log(usersById);
// {
//   1: { id: 1, name: 'Alice' },
//   2: { id: 2, name: 'Bob' }
// }

forEach() - 遍历
const fruits = ['apple', 'banana', 'orange'];

fruits.forEach((fruit, index) => {
  console.log(`${index + 1}. ${fruit}`);
});
// 1. apple
// 2. banana
// 3. orange

find() / findIndex() - 查找
const users = [
  { id: 1, name: 'Alice' },
  { id: 2, name: 'Bob' },
  { id: 3, name: 'Alice' }
];

const alice = users.find(user => user.name === 'Alice');
console.log(alice); // { id: 1, name: 'Alice' }

const aliceIndex = users.findIndex(user => user.name === 'Alice');
console.log(aliceIndex); // 0

some() / every() - 条件检查
const numbers = [1, 2, 3, 4, 5];

// 是否有偶数?
const hasEven = numbers.some(num => num % 2 === 0);
console.log(hasEven); // true

// 是否所有数都大于0?
const allPositive = numbers.every(num => num > 0);
console.log(allPositive); // true

3.自定义高阶函数

1.什么是自定义高阶函数

自定义高阶函数,本质就是:

用函数去增强、控制、复用另一个函数

一般分三类:

  1. 函数增强(装饰)
  2. 行为抽象(解耦)
  3. 执行控制(节流、防抖等)

2.最经典:函数增强(装饰器思想)

function before(fn, beforeFn) {
  return function (...args) {
    beforeFn.apply(this, args);  // 先执行 beforeFn
    return fn.apply(this, args);  // 再执行原始函数 fn
  };
}

function after(fn, afterFn) {
  return function (...args) {
    const result = fn.apply(this, args);  // 先执行原始函数 fn
    afterFn.apply(this, args);  // 再执行 afterFn
    return result;  // 返回原始函数的结果
  };
}

function say(name) {
  console.log("Hello " + name);
}

const newSay = before(say, function () {
  console.log("准备执行...");
});

newSay("Jake");

Before函数:

**调用 newSay("Jake") 时:

  1. 执行 beforeFn() → "准备执行..."
  2. 执行 say("Jake") → "Hello Jake"
  3. 返回 say 的返回值(undefined)**

After 函数:

**调用 afterFn 装饰的函数时:

  1. 执行原始函数 fn → 得到结果
  2. 执行 afterFn()
  3. 返回 fn 的结果**

打印结果:

3.执行控制型高阶函数

执行控制型高阶函数是指那些能够控制函数执行时机、频率或条件的高阶函数,它们在前端开发中非常实用。

3.1 自定义节流函数
function throttle(fn, delay) {
  let lastTime = 0;

  return function (...args) {
    const now = Date.now();

    if (now - lastTime > delay) {
      lastTime = now;
      fn.apply(this, args);
    }
  };
}

window.addEventListener(
  "scroll",
  throttle(() => {
    console.log("滚动触发");
  }, 1000)
);

3.2 自定义防抖函数
function debounce(fn, delay) {
  let timer = null;

  return function (...args) {
    clearTimeout(timer);

    timer = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

4.行为抽象型高阶函数

指将程序中具体的操作逻辑(行为)提取出来,使其不依赖于具体的数据,而是通过参数(特别是函数参数)来定义。这使得代码更通用、更可复用。

4.1 抽象重复逻辑
function withLoading(fn) {
  return async function (...args) {
    console.log("loading...");
    try {
      const result = await fn(...args);
      return result;
    } finally {
      console.log("loading end");
    }
  };
}


async function fetchData() {
  return "数据";
}

const newFetch = withLoading(fetchData);

newFetch();

打印结果:

5.函数柯里化(进阶自定义高阶函数)

函数柯里化是一种将接受多个参数的函数转换为接受单个参数(或更少参数)的函数序列的技术。

function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn(...args);
    } else {
      return function (...nextArgs) {
        return curried(...args, ...nextArgs);
      };
    }
  };
}


function add(a, b, c) {
  return a + b + c;
}

const curriedAdd = curry(add);

console.log(curriedAdd(1)(2)(3)); // 6

打印结果:

6.链式增强

链式增强是 JavaScript 中一种强大的编程模式,它通过方法链(Method Chaining)让代码更加流畅、可读。这种模式特别适用于数据处理、DOM 操作和构建复杂配置。

Function.prototype.before = function (beforeFn) {
  const self = this;
  return function (...args) {
    beforeFn.apply(this, args);
    return self.apply(this, args);
  };
};


function say() {
  console.log("hello");
}

say = say.before(function () {
  console.log("before...");
});

say();

打印结果:

7.自定义高阶函数的本质结构

function higherOrder(fn) {
  return function (...args) {
    // 1. 执行前逻辑

    const result = fn.apply(this, args);

    // 2. 执行后逻辑

    return result;
  };
}

8.作为前端开发你必须掌握的几个自定义高阶函数

  • before / after
  • throttle
  • debounce
  • curry
  • once(只执行一次)
  • memoize(缓存结果)

比如 memoize:

function memoize(fn) {
  const cache = {};

  return function (...args) {
    const key = JSON.stringify(args);

    if (cache[key]) {
      return cache[key];
    }

    const result = fn.apply(this, args);
    cache[key] = result;

    return result;
  };
}

9.终极理解(非常重要)

高阶函数解决的问题是:

  • 不修改原函数
  • 不侵入原逻辑
  • 动态增强功能

这就是:

  • AOP
  • 装饰器模式
  • 中间件机制
  • React HOC
  • Vue 插件机制

的底层原理。

🌱 写在最后
写作这些技术文章的过程,也是我重新梳理知识体系的过程。
编程不仅是实现功能,更是一种思考方式。希望这篇文章不仅能帮你解决眼前的问题,更能激发你对[某个技术点]的更深层思考。
最后:保持好奇,保持热爱。

vue-esign 用途及使用教程

作者 青屿ovo
2026年2月12日 14:06

vue-esign 用途及使用教程

一、✍️ vue-esign 核心用途

vue-esignVue项目专属轻量级电子签名插件,不用依赖后端,纯前端就能实现手写签名、导出保存,体积小、易接入,解决前端手写签名的核心需求。

📌 常见使用场景(手绘重点标注)

  • 📋 办公场景:电子合同签署、审批流程手写确认、报销单/表单签名(高频使用!)

  • 📝 教育场景:在线作业手写答题、试卷批注、课堂笔记手绘、学生签名确认

  • ✨ 通用场景:用户手写昵称、留言板手绘、实名认证手写、小程序签名

🌟 核心优点(手绘小亮点)

  • 轻量无依赖,接入成本极低,不用额外装其他插件

  • 兼容 Vue2、Vue3 双版本,适配绝大多数Vue项目

  • 可自定义:笔迹粗细、笔迹颜色、画板背景(纯色/图片都可)

  • 签名可导出为base64格式,方便传给后端保存或本地预览

二、📝 手把手使用教程(新手友好·一步一标)

⚠️ 前置小提醒

✅ Vue3 项目 → 安装 vue-esign@2.x 版本(推荐)

✅ Vue2 项目 → 安装 vue-esign@1.x 版本(别装错哦!)

✅ 无需额外依赖,安装完成直接使用,不用配置复杂环境

步骤1:安装依赖 📦(复制命令直接执行)

打开项目终端,根据自己的Vue版本,粘贴对应命令,回车安装即可:

# Vue3 项目(推荐,适配最新Vue版本)
npm install vue-esign --save

# Vue2 项目(指定1.x版本,避免兼容问题)
npm install vue-esign@1.0.10 --save

步骤2:引入插件 ✨(两种方式,选一种就好)

方式1:全局引入(项目通用,推荐!)

在main.js/ main.ts中引入,全局注册后,所有组件都能直接使用:

// Vue3 项目(main.js / main.ts)
import { createApp } from 'vue'
import App from './App.vue'
import vueEsign from 'vue-esign'

const app = createApp(App)
app.use(vueEsign) // 全局注册签名组件
app.mount('#app')
// Vue2 项目(main.js)
import Vue from 'vue'
import App from './App.vue'
import vueEsign from 'vue-esign'

Vue.use(vueEsign) // 全局注册签名组件
new Vue({
  el: '#app',
  render: h => h(App)
})
方式2:局部引入(仅单个组件使用,节省资源)

如果只有一个组件需要用签名,直接在该组件内引入即可:

<template&gt;
  <!-- 签名组件,直接使用 -->
  <vue-esign ref="esign" :width="800" :height="400" />
</template>

<script setup>
// Vue3 局部引入
import { ref } from 'vue'
import vueEsign from 'vue-esign'

const esign = ref(null) // 获取签名组件实例(必须!)
&lt;/script&gt;

<!-- Vue2 局部引入 -->
<script>
import vueEsign from 'vue-esign'
export default {
  components: {
    vueEsign // 局部注册组件
  },
  data() {
    return {
      esign: null // 获取签名组件实例
    }
  }
}
</script>

步骤3:基础使用示例 ✍️(复制即用,新手必看)

以下是Vue3完整可运行示例,包含「签名画板+清空+保存+预览」,粘贴到组件中就能直接测试:

<template>
  <div class="esign-container" style="padding: 20px;">
    <!-- 签名画板(核心组件) -->
    <vue-esign
      ref="esign"
      :width="800"       <!-- 画板宽度(px),可自定义 -->
      :height="400"      <!-- 画板高度(px),可自定义 -->
      :lineWidth="6"     <!-- 笔迹宽度,越大数据越粗 -->
      :lineColor="#000"  <!-- 笔迹颜色,支持十六进制(如#ff6600) -->
      :bgColor="#f5f5f5" <!-- 画板背景色,浅灰色更显笔迹 -->
      style="border: 1px solid #ddd; border-radius: 8px;"
    />

    <!-- 操作按钮(清空+保存) -->
    <div style="margin-top: 20px;">
      <button @click="handleReset" style="margin-right: 10px; padding: 8px 16px; cursor: pointer;">🗑️ 清空签名</button>
      <button @click="handleSave" style="padding: 8px 16px; background: #409eff; color: #fff; border: none; border-radius: 4px; cursor: pointer;">💾 保存签名</button>
    </div>

    <!-- 签名预览(保存后显示) -->
    <div v-if="signImg" style="margin-top: 20px;">
      <h4>📸 签名预览:</h4>
      <img :src="signImg" alt="签名图片" style="max-width: 800px; border: 1px solid #eee;">
    </div>
  </div>
</template>

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

// 签名组件实例(必须通过ref获取,才能调用内置方法)
const esign = ref(null)
// 保存的签名图片(base64格式,可用于预览、上传)
const signImg = ref('')

// 清空签名(调用插件内置reset方法)
const handleReset = () => {
  esign.value.reset() // 清空画板
  signImg.value = '' // 清空预览图
}

// 保存签名(转为base64格式)
const handleSave = async () => {
  try {
    // 调用插件内置generate方法,生成base64图片
    const base64 = await esign.value.generate()
    if (!base64) {
      alert('✍️ 请先完成签名再保存哦!')
      return
    }
    signImg.value = base64 // 显示预览图
    // 后续可将base64传给后端保存,或转为文件上传
    console.log('签名base64:', base64)
  } catch (error) {
    console.error('保存签名失败:', error)
    alert('保存签名失败,请重试!')
  }
}
</script>

步骤4:自定义配置项 ⚙️(按需调整,手绘表格)

根据项目需求,自定义画板样式,所有配置项整理如下(直接复制使用):

配置项 类型 默认值 手绘说明(重点)
width Number 800 画板宽度(单位px,不可写百分比)
height Number 400 画板高度(单位px,按需调整)
lineWidth Number 4 笔迹宽度,数值越大越粗(推荐5-8)
lineColor String #000000 笔迹颜色,支持十六进制(如#ff0000红色)
bgColor String #ffffff 画板背景色,纯色即可(推荐#f5f5f5浅灰)
bgImg String '' 画板背景图片(url地址,注意跨域问题)
disabled Boolean false 是否禁用签名(true=禁用,false=可签名)
自定义示例(带背景图的签名板)
<vue-esign
  ref="esign"
  :width="800"
  :height="400"
  :lineWidth="8"       <!-- 粗笔迹 -->
  :lineColor="#ff6600" <!-- 橙色笔迹,更醒目 -->
  bgImg="https://xxx.com/sign-bg.png" <!-- 自定义背景图URL -->
/>

步骤5:常见问题 & 注意事项 ⚠️(手绘避坑指南)

  • 📱 移动端适配:画板宽度别写固定值,用屏幕宽度计算,避免溢出// Vue3 适配移动端(获取屏幕宽度,减去边距) const width = ref(document.documentElement.clientWidth - 40)

  • 📦 base64体积过大:生成的签名base64可能偏长,可安装image-conversion库压缩后再上传后端 npm install image-conversion --save

  • ✍️ 签名为空提示:调用generate()时,如果没签名,会返回false,需先判断再保存(示例中已包含)

  • 🌐 跨域问题:bgImg(背景图)需确保支持跨域,否则会报错,优先用本地图片

  • 🔍 版本兼容:Vue2和Vue3版本不能混装,装错会导致组件无法显示,严格按照步骤1安装

三、📌 核心方法速查(手绘小卡片)

插件只有2个核心方法,记牢就能满足99%的需求,不用记复杂语法:

方法名 手绘说明 使用方式(必记)
reset() 清空当前签名,重置画板 esign.value.reset()
generate() 生成签名base64图片(异步) await esign.value.generate()

四、💾 MD文档保存方法(手绘步骤)

复制本文档全部内容,按照以下步骤保存,即可下载使用:

  1. 新建一个文本文档(.txt),打开后粘贴全部内容

  2. 重命名文件,将后缀从「.txt」改为「.md」(例:vue-esign手绘笔记.md)

  3. 双击文件,用Markdown编辑器(Typora、VS Code等)打开,即可查看/编辑

五、✍️ 笔记总结(手绘重点)

  1. vue-esign 是Vue专属轻量级电子签名插件,纯前端实现,无依赖、易接入;

  2. 核心流程:安装依赖 → 全局/局部引入 → 配置画板 → 调用方法(保存/清空);

  3. 关键注意:版本别装错、移动端适配、签名为空判断,避开这3个坑就能顺利使用。

使用Cursor 完成 Vike + Vue 3 + Element Plus 管理后台 — 从 0 到 1 (实例与文档)

作者 赵_叶紫
2026年2月12日 14:03

目录

  1. 项目概述
  2. 技术栈
  3. 项目初始化
  4. 目录结构
  5. 核心配置文件
  6. 服务端 — Express 服务器
  7. Vike 页面约定与 Hook 体系
  8. 状态管理 — Pinia
  9. 国际化 — Vue I18n
  10. API 层 — Alova + Axios
  11. Layout 系统
  12. Element Plus 集成(SSR 兼容)
  13. 权限系统
  14. 路由与导航
  15. 业务页面示例
  16. SSR 与 CSR 策略
  17. 关键踩坑与解决方案
  18. 开发与构建命令
  19. 生产部署

1. 项目概述

本项目是一个基于 Vike(前 vite-plugin-ssr)+ Vue 3 的企业级管理后台模板。核心思路是利用 Vike 框架的原生 Hook 体系(+config.ts+guard.ts+data.ts+Layout.vue+onCreateApp.ts)替代传统 Vue Router 的路由守卫和路由配置方式,实现:

  • SSR 首屏渲染 — 首屏数据通过 +data.ts 在服务端预取,直接输出到 HTML
  • 统一权限验证 — 通过 +guard.ts 在 SSR 阶段调用后端权限接口,无权限直接渲染 403 页面
  • 公共 Layout 可定制 — 每个页面可通过 Pinia Store 方法动态修改 Layout 标题、面包屑、顶部按钮等
  • 国际化 — Vue I18n 支持中英文切换,菜单、标题、错误页均支持多语言
  • UI 组件库 — Element Plus 全量引入,SSR 兼容

2. 技术栈

类别 技术 版本 说明
框架 Vue 3 ^3.5 Composition API
元框架 Vike ^0.4.252 SSR / 文件系统路由
Vue 适配 vike-vue ^0.9.10 Vike 的 Vue 3 适配器
UI 组件库 Element Plus ^2.9 管理后台 UI 组件
状态管理 Pinia ^3.0 Vue 3 官方状态管理
国际化 Vue I18n ^11.1 多语言支持
HTTP 请求 Alova + Axios ^3.2 / ^1.9 请求策略库 + HTTP 客户端
服务端 Express 5 ^5.2 Node.js HTTP 服务器
构建工具 Vite 7 ^7.3 开发服务器 + 打包
语言 TypeScript ^5.9 类型安全
CSS 预处理 SCSS ^1.87 样式预处理
代码规范 ESLint + typescript-eslint ^9.39 代码质量保障

3. 项目初始化

3.1 创建项目

# 创建目录
mkdir vike-zyh-test && cd vike-zyh-test

# 初始化 package.json
npm init -y

3.2 安装依赖

运行时依赖:

npm install vue vike vike-vue express compression cookie-parser sirv \
  pinia vue-i18n element-plus alova @alova/adapter-axios axios

开发依赖:

npm install -D vite @vitejs/plugin-vue typescript tsx sass \
  unplugin-auto-import unplugin-vue-components \
  @intlify/unplugin-vue-i18n cross-env \
  eslint @eslint/js eslint-plugin-vue typescript-eslint vue-eslint-parser globals \
  @types/express @types/compression @types/cookie-parser

3.3 设定 package.json Scripts

{
  "type": "module",
   "scripts": {
    "dev": "tsx server/server.ts",
    "build": "vike build",
    "preview": "vike build && cross-env NODE_ENV=production tsx server/server.ts",
    "lint": "eslint .",
    "fix": "eslint . --fix"
  },
}

关键点:开发模式使用 tsx 直接运行 TypeScript 编写的 Express 服务器,而非 vite dev。这允许我们完全掌控服务端中间件、Mock API 和渲染流程。


4. 目录结构

vike-zyh-test/
├── server/                          # Express 服务端
│   └── server.ts                    # 入口:中间件 + Mock API + Vike 渲染
├── src/
│   ├── api/                         # API 层
│   │   ├── alovaInstance.ts         # Alova 实例管理 + apiCreator 统一请求工厂
│   │   ├── createClientApi.ts       # 客户端 Alova 实例创建
│   │   ├── createServerApi.ts       # 服务端 Alova 实例创建(用于 +data.ts / +guard.ts)
│   │   ├── dashboardApi.ts          # Dashboard 业务 API
│   │   └── permissionApi.ts         # 权限业务 API
│   ├── composables/                 # 组合式函数
│   │   ├── useLayout.ts             # Layout 控制接口(setTitle / setBreadcrumbs / setHeaderActions ...)
│   │   ├── usePagination.ts         # 分页逻辑封装
│   │   └── usePermission.ts         # 权限检查(hasPermission)
│   ├── constants/                   # 常量
│   │   ├── constants.ts             # 通用常量(分页默认值、枚举等)
│   │   ├── menu.ts                  # 侧边栏菜单配置
│   │   └── permissionApis.ts        # 权限 API URL 常量(统一管理)
│   ├── directive/                   # 自定义指令
│   │   └── directive.ts             # 指令注册入口(如权限指令 v-permission)
│   ├── i18n/                        # 国际化
│   │   ├── i18n.ts                  # createI18n 工厂函数
│   │   ├── zh-CN.json               # 中文语言包
│   │   └── en-US.json               # 英文语言包
│   ├── layout/                      # Layout 组件
│   │   ├── AppSidebar.vue           # 侧边栏
│   │   └── AppHeader.vue            # 顶部导航栏
│   ├── pages/                       # Vike 文件系统路由 ★
│   │   ├── +config.ts               # 全局页面配置
│   │   ├── +onCreateApp.ts          # Vue App 创建钩子(注册 Pinia/I18n/ElementPlus)
│   │   ├── +guard.ts                # 全局路由守卫(权限验证)
│   │   ├── +Layout.vue              # 全局 Layout
│   │   ├── +Head.vue                # 全局 HTML <head>
│   │   ├── _error/                  # 错误页面(401/403/404/500)
│   │   │   └── +Page.vue
│   │   ├── index/                   # 首页 /
│   │   │   ├── +config.ts
│   │   │   ├── +data.ts             # SSR 数据预取
│   │   │   └── +Page.vue
│   │   └── permission/              # 权限管理模块
│   │       ├── +config.ts
│   │       ├── +data.ts             # SSR 数据预取(权限列表)
│   │       ├── +Page.vue            # 权限列表页
│   │       ├── add/                 # 新增权限 /permission/add
│   │       │   ├── +config.ts
│   │       │   ├── +data.ts         # 空 data,阻止继承父级
│   │       │   └── +Page.vue
│   │       └── @id/                 # 动态路由 /permission/:id
│   │           └── edit/            # 编辑权限 /permission/:id/edit
│   │               ├── +config.ts
│   │               ├── +data.ts     # 空 data,阻止继承父级
│   │               └── +Page.vue
│   ├── scss/                        # 全局样式
│   │   └── common.scss
│   ├── stores/                      # Pinia 状态管理
│   │   ├── global.ts                # 全局状态(env/lang/user)
│   │   └── layout.ts                # 布局状态(title/breadcrumbs/headerActions/sidebar)
│   └── viewComponents/              # 页面级可复用组件
│       └── permission/
│           └── PermissionForm.vue   # 权限表单组件(新增/编辑复用)
├── vite.config.ts                   # Vite 配置
├── tsconfig.json                    # TypeScript 根配置(引用子配置)
├── tsconfig.app.json                # 前端 TS 配置
├── tsconfig.node.json               # Vite 配置用 TS 配置
├── tsconfig.server.json             # 服务端 TS 配置
├── eslint.config.ts                 # ESLint 配置
└── package.json

约定说明pages/ 目录下以 + 开头的文件是 Vike 框架约定文件,分别承担配置、数据预取、守卫、布局、渲染等职责。@id 目录名表示动态路由参数。_error 为 Vike 约定的错误页面目录。


5. 核心配置文件

5.1 package.json

{
  "type": "module",
  "imports": {
    "#*": "./*",
    "#server/*": "./server/*"
  }
}
  • "type": "module" — 启用 ESM
  • "imports" — Node.js 原生子路径导入映射,配合 tsconfig.jsonpaths 实现统一的 # 前缀路径别名

5.2 vite.config.ts

import { fileURLToPath, URL } from 'node:url';
import { readdir } from 'node:fs/promises';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import vike from 'vike/plugin';
import AutoImport from 'unplugin-auto-import/vite';
import Components from 'unplugin-vue-components/vite';
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';
import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite';

// 自动扫描 src/ 下的子目录,生成路径别名
const srcSubDirs = (
  await readdir(new URL('./src', import.meta.url), { withFileTypes: true })
)
  .filter((d) => d.isDirectory())
  .map(({ name }) => name);

export default defineConfig({
  plugins: [
    vue(),
    vike(),
    AutoImport({
      resolvers: [ElementPlusResolver({ importStyle: false })],
    }),
    Components({
      resolvers: [ElementPlusResolver({ importStyle: false })],
    }),
    VueI18nPlugin({ ssr: true, strictMessage: false }),
  ],
  resolve: {
    alias: {
      '#': fileURLToPath(new URL('./', import.meta.url)),
      '#src': fileURLToPath(new URL('./src', import.meta.url)),
      '#server': fileURLToPath(new URL('./server', import.meta.url)),
      // 自动生成: #api, #composables, #stores, #i18n, #layout, #pages ...
      ...Object.fromEntries(
        srcSubDirs.map((name) => [
          `#${name}`,
          fileURLToPath(new URL(`./src/${name}`, import.meta.url)),
        ]),
      ),
    },
  },
  build: { target: 'es2022' },
});

关键设计点:

配置项 说明
vike() 启用 Vike 插件,提供 SSR + 文件系统路由
ElementPlusResolver({ importStyle: false }) 禁用 样式自动导入,避免 SSR 中加载 CSS 文件报错。样式改为在 +Layout.vue 中手动 import 'element-plus/dist/index.css'
VueI18nPlugin({ ssr: true }) 开启 i18n 的 SSR 优化,编译时处理 <i18n>
路径别名自动扫描 自动读取 src/ 子目录,无需手动逐个配置别名

5.3 TypeScript 配置

项目采用三配置策略

文件 作用 module
tsconfig.app.json 前端源码 (src/) ES2022 / Bundler
tsconfig.node.json Vite 配置文件 ES2022 / Bundler
tsconfig.server.json 服务端代码 (server/) Node16 / Node16

tsconfig.app.json 中配置了所有 # 前缀的路径映射:

{
  "compilerOptions": {
    "paths": {
      "#*": ["./*"],
      "#src/*": ["./src/*"],
      "#api/*": ["./src/api/*"],
      "#stores/*": ["./src/stores/*"],
      "#i18n/*": ["./src/i18n/*"],
      "#layout/*": ["./src/layout/*"],
      "#composables/*": ["./src/composables/*"],
      "#constants/*": ["./src/constants/*"],
      "#directive/*": ["./src/directive/*"],
      "#viewComponents/*": ["./src/viewComponents/*"],
      "#server/*": ["./server/*"]
    }
  }
}

5.4 ESLint 配置

使用 ESLint 9 Flat Config,集成 typescript-eslinteslint-plugin-vue

// eslint.config.ts
import eslint from '@eslint/js';
import pluginVue from 'eslint-plugin-vue';
import tseslint from 'typescript-eslint';
import vueParser from 'vue-eslint-parser';

export default tseslint.config(
  eslint.configs.recommended,
  ...tseslint.configs.recommended,
  // Vue 文件使用 vue-eslint-parser 嵌套 typescript parser
  {
    files: ['**/*.vue'],
    languageOptions: {
      parser: vueParser,
      parserOptions: { parser: tseslint.parser },
    },
  },
  ...pluginVue.configs['flat/recommended'],
);

6. 服务端 — Express 服务器

server/server.ts 是项目入口,使用 Express 5 搭建 HTTP 服务器:

import express from 'express';
import compression from 'compression';
import cookieParser from 'cookie-parser';
import { renderPage, createDevMiddleware } from 'vike/server';

async function startServer() {
  const app = express();

  // 1. 基础中间件
  app.use(compression());       // Gzip 压缩
  app.use(cookieParser());      // Cookie 解析
  app.disable('x-powered-by');  // 隐藏 Express 标识

  // 2. 静态文件 / Vite 开发中间件
  if (isProd) {
    app.use(sirv('dist/client'));  // 生产环境:静态文件
  } else {
    const { devMiddleware } = await createDevMiddleware({ root });
    app.use(devMiddleware);        // 开发环境:Vite HMR
  }

  // 3. Mock API(开发阶段可替换为真实后端代理)
  app.use(express.json());
  app.get('/api/v1/dashboard/stats', ...);
  app.get('/api/v1/permissions', ...);
  app.post('/api/v1/permission/check', ...);

  // 4. Vike 页面渲染 — 所有未匹配的 GET 请求
  app.get('/{*path}', async (req, res, next) => {
    const pageContext = await renderPage({
      urlOriginal: req.originalUrl,
      headersOriginal: req.headers,
      cookies: req.cookies,
    });

    if (!pageContext.httpResponse) return next();

    const { body, statusCode, headers } = pageContext.httpResponse;
    headers.forEach(([name, value]) => res.setHeader(name, value));
    res.status(statusCode).send(body);
  });

  app.listen(3000);
}

重点说明:

  1. Express 5 路由语法app.get('/{*path}', ...) — Express 5 使用命名通配符,不再支持 app.get('*', ...)
  2. pageContext 初始化headersOriginalcookies 被传入 pageContext,供 +guard.ts+data.ts 中的 SSR API 调用使用(转发原始请求头实现登录态传递)
  3. Mock API 位于 Vike 渲染之前:确保 API 请求不会被 Vike 拦截

7. Vike 页面约定与 Hook 体系

Vike 的核心理念:通过 + 前缀文件约定替代路由配置。每个约定文件承担特定职责,按以下顺序执行:

请求进入 → +guard.ts(权限验证)→ +data.ts(数据预取)→ +Page.vue(页面渲染)
                                                          ↑
                                              +Layout.vue 包裹
                                              +Head.vue 注入 <head>

7.1 +config.ts — 全局/页面级配置

全局配置 src/pages/+config.ts

import vikeVue from 'vike-vue/config';
import type { Config } from 'vike/types';

export default {
  extends: [vikeVue],   // 继承 vike-vue 默认行为
  title: 'Admin',
  passToClient: ['user', 'locale', 'permissionResult', 'routeName'],
  meta: {
    permissionUrls: {
      env: { server: true, client: true },  // 自定义配置项,服务端和客户端均可访问
    },
  },
} satisfies Config;
  • passToClient — 指定哪些 pageContext 属性传递到客户端(SSR → CSR 数据桥接)
  • meta.permissionUrls — 声明自定义页面配置项,用于权限验证

页面级配置 src/pages/permission/+config.ts

import { PERMISSION_APIS } from '../../constants/permissionApis';

export default {
  title: '权限列表',
  permissionUrls: [
    PERMISSION_APIS.LIST,
    PERMISSION_APIS.CREATE,
    PERMISSION_APIS.UPDATE,
    PERMISSION_APIS.DELETE,
  ],
};

每个页面的 +config.ts 中的 permissionUrls 会被 +guard.ts 读取,用于权限验证。权限 URL 常量统一定义在 src/constants/permissionApis.ts 中。

7.2 +onCreateApp.ts — Vue 应用创建钩子

每次渲染(SSR 和 CSR)都会执行此钩子,用于注册全局插件和指令:

import type { OnCreateAppSync } from 'vike-vue/types';
import { createPinia } from 'pinia';
import { ID_INJECTION_KEY, ZINDEX_INJECTION_KEY } from 'element-plus';
import { createI18n } from '#i18n/i18n';
import directives from '#directive/directive';

const onCreateApp: OnCreateAppSync = (pageContext) => {
  const { app } = pageContext;

  // 1. Pinia 状态管理
  app.use(createPinia());

  // 2. Vue I18n 国际化
  app.use(createI18n());

  // 3. Element Plus SSR 兼容 — 必须 provide ID 和 ZIndex
  app.provide(ID_INJECTION_KEY, { prefix: 1024, current: 0 });
  app.provide(ZINDEX_INJECTION_KEY, { current: 0 });

  // 4. 自定义指令
  Object.entries(directives).forEach(([name, directive]) => {
    app.directive(name, directive);
  });
};

export default onCreateApp;

7.3 +Layout.vue — 全局布局

公共 Layout 包裹所有页面,集成侧边栏、顶部导航、Element Plus 配置提供者:

<template>
  <el-config-provider :locale="elementLocale">
    <div class="app-layout">
      <aside v-if="layoutStore.showSidebar" :class="['app-sidebar', { collapsed: layoutStore.sidebarCollapsed }]">
        <AppSidebar :menus="defaultMenus" :collapsed="layoutStore.sidebarCollapsed" />
      </aside>
      <div class="app-main">
        <AppHeader
          v-if="layoutStore.showHeader"
          :breadcrumbs="layoutStore.breadcrumbs"
          :header-actions="layoutStore.headerActions"
          @toggle-sidebar="layoutStore.toggleSidebar()"
        />
        <main class="app-content">
          <slot />  <!-- 页面内容插入点 -->
        </main>
      </div>
    </div>
  </el-config-provider>
</template>

<script lang="ts" setup>
import 'element-plus/dist/index.css';   // 手动引入样式(SSR 兼容)
import '#scss/common.scss';

// ...组件引入与状态管理
</script>

7.4 +Head.vue — 全局 HTML Head

<template>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
</template>

7.5 +guard.ts — 路由守卫(权限验证)

核心权限验证机制,在 SSR 阶段拦截请求:

import type { GuardAsync } from 'vike/types';
import { render } from 'vike/abort';

const guard: GuardAsync = async (pageContext) => {
  const permissionUrls = (pageContext.config as any).permissionUrls;

  // 没有配置权限 URL 的页面,直接放行
  if (!permissionUrls || permissionUrls.length === 0) return;

  // SSR 时调用后台权限验证接口
  if (typeof window === 'undefined') {
    try {
      const { createDefaultAPI } = await import('#api/createServerApi');
      const port = process.env.PORT || 3000;
      const alova = createDefaultAPI({
        baseURL: `http://localhost:${port}/api/v1`,
        headers: (pageContext as any).headersOriginal,  // 转发原始请求头
      });

      const result = await alova.Post('/permission/check', {
        urls: permissionUrls,
        pagePath: pageContext.urlPathname,
      });

      if (!result?.data?.allowed) {
        throw render(403);  // 渲染 403 错误页
      }

      // 权限结果存入 pageContext,传到客户端
      (pageContext as any).permissionResult = result.data;
    } catch (error) {
      if ((error as any)?.isAbort) throw error;  // 已是 abort 直接抛出
      throw render(403);  // 异常也视为无权限
    }
  }
};

7.6 +data.ts — SSR 数据预取

在服务端获取数据,通过 useData() 在页面组件中使用:

// src/pages/index/+data.ts
import type { PageContextServer } from 'vike/types';
import { createDefaultAPI } from '#api/createServerApi';

const SSR_API_BASE = `http://localhost:${process.env.PORT || 3000}/api/v1`;

export type Data = DashboardStats;

export async function data(_pageContext: PageContextServer): Promise<Data> {
  const alova = createDefaultAPI({
    baseURL: SSR_API_BASE,
    headers: (_pageContext as any).headersOriginal,
  });

  const res = await alova.Get('/dashboard/stats');
  return res.data;
}

注意+data.ts 的继承问题 — 子路由会继承父目录的 +data.ts。如果子页面不需要父级数据,需要创建空的 +data.ts 来阻止继承:

// src/pages/permission/add/+data.ts
export type Data = Record<string, never>;
export async function data() { return {}; }

7.7 +Page.vue — 页面组件

每个目录下的 +Page.vue 即该路由对应的页面组件。通过 useData() 获取 SSR 预取数据:

<script lang="ts" setup>
import { useData } from 'vike-vue/useData';
import type { Data } from './+data';

const data = useData<Data>();  // 类型安全地获取 SSR 数据
</script>

7.8 _error/+Page.vue — 错误页面

统一的错误页面,支持 401/403/404/500:

<script lang="ts" setup>
import { usePageContext } from 'vike-vue/usePageContext';

const pageContext = usePageContext();

const errorCode = computed(() => {
  return pageContext.is404 ? 404 : (pageContext.abortStatusCode || 500);
});
</script>

+guard.tsthrow render(403) 时,Vike 会自动渲染 _error/+Page.vue 并传递 abortStatusCode: 403


8. 状态管理 — Pinia

8.1 全局状态 (global.ts)

// src/stores/global.ts
import { defineStore } from 'pinia';

export const useGlobalStore = defineStore('global', {
  state: () => ({
    env: '',
    lang: 'zh-CN',
    user: null as null | { name: string; role: string },
  }),
  actions: {
    updateEnv(env: string) { this.env = env; },
    updateLang(lang: string) { this.lang = lang; },
    updateUser(user: { name: string; role: string } | null) { this.user = user; },
  },
});

8.2 布局状态 (layout.ts)

// src/stores/layout.ts
export const useLayoutStore = defineStore('layout', {
  state: () => ({
    title: '',
    breadcrumbs: [] as BreadcrumbItem[],
    sidebarMenus: [] as MenuItem[],
    showSidebar: true,
    showHeader: true,
    sidebarCollapsed: false,
    headerActions: [] as HeaderAction[],
  }),
  actions: {
    setTitle(title: string) { this.title = title; },
    setHeaderActions(actions: HeaderAction[]) { this.headerActions = actions; },
    clearHeaderActions() { this.headerActions = []; },
    setBreadcrumbs(items: BreadcrumbItem[]) { this.breadcrumbs = items; },
    toggleSidebar() { this.sidebarCollapsed = !this.sidebarCollapsed; },
    resetLayout() {
      this.title = '';
      this.breadcrumbs = [];
      this.headerActions = [];
      this.showSidebar = true;
      this.showHeader = true;
    },
  },
});

类型定义:

export interface BreadcrumbItem {
  label: string;
  path?: string;
}

export interface MenuItem {
  label: string;
  path: string;
  icon?: string;
  children?: MenuItem[];
}

export interface HeaderAction {
  key: string;
  label: string;
  icon?: string;
  type?: 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'default';
  handler: () => void;
}

9. 国际化 — Vue I18n

9.1 创建 I18n 实例

// src/i18n/i18n.ts
import { createI18n as _createI18n } from 'vue-i18n';
import zhCN from '#i18n/zh-CN.json';
import enUS from '#i18n/en-US.json';

export const LANGUAGE = {
  ZH_CN: 'zh-CN',
  EN_US: 'en-US',
} as const;

export function createI18n() {
  return _createI18n({
    legacy: false,          // 使用 Composition API
    locale: LANGUAGE.ZH_CN, // 默认中文
    fallbackLocale: LANGUAGE.ZH_CN,
    messages: {
      [LANGUAGE.ZH_CN]: zhCN,
      [LANGUAGE.EN_US]: enUS,
    },
  });
}

9.2 语言包结构

// zh-CN.json
{
  "app": { "title": "管理后台" },
  "error": {
    "unauthorized": "登录已过期,请重新登录",
    "forbidden": "暂无权限访问此页面",
    "notFound": "页面不存在",
    "serverError": "服务器内部错误,请稍后重试"
  },
  "menu": {
    "home": "首页",
    "permission": "权限管理",
    "permissionList": "权限列表",
    "permissionAdd": "新增权限"
  },
  "common": { "add": "新增", "edit": "编辑", "delete": "删除", ... },
  "permission": { "name": "权限名称", "code": "权限编码", ... },
  "dashboard": { "totalPermissions": "总权限数", ... }
}

9.3 在组件中使用

<script setup>
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>

<template>
  <span>{{ t('app.title') }}</span>
  <span>{{ t('menu.home') }}</span>
</template>

9.4 菜单配置与 i18n

菜单的 label 字段使用 i18n key,在渲染时通过 t() 翻译:

// src/constants/menu.ts
export const SIDEBAR_MENUS: MenuItem[] = [
  { label: 'menu.home', path: '/', icon: 'House' },
  {
    label: 'menu.permission', path: '/permission', icon: 'Lock',
    children: [
      { label: 'menu.permissionList', path: '/permission' },
      { label: 'menu.permissionAdd', path: '/permission/add' },
    ],
  },
];

10. API 层 — Alova + Axios

项目使用 Alova 作为请求策略层,底层适配 Axios。分为客户端和服务端两套实例。

10.1 核心实例管理 (alovaInstance.ts)

// src/api/alovaInstance.ts

// API 类型枚举
export const API_TYPE = { DEFAULT: 'default', LOCAL: 'local' } as const;

// 基础 URL 映射
export const API_BASE_URL = {
  [API_TYPE.DEFAULT]: '/api/v1',
  [API_TYPE.LOCAL]: '/local-api',
};

// 统一请求工厂
export function apiCreator(options: ApiOption, data?: any, customInstances?: AlovaInstances) {
  const { method = 'get', type = API_TYPE.DEFAULT, pathVariable, ...restOptions } = options;
  const instance = getAlovaInstance(customInstances, type);

  let { url = '' } = restOptions;
  if (pathVariable) url = templateUrl(url, pathVariable);  // URL 模板变量替换

  const methodName = method.charAt(0).toUpperCase() + method.slice(1);

  if (['Post', 'Put', 'Patch', 'Delete'].includes(methodName)) {
    return instance[methodName](url, data, restOptions);
  }
  return instance[methodName](url, { params: data, ...restOptions });
}

10.2 客户端 API (createClientApi.ts)

import { createAlova } from 'alova';
import VueHook from 'alova/vue';
import { axiosRequestAdapter } from '@alova/adapter-axios';

export function createClientAlova({ baseURL, timeout = 30000 }) {
  return createAlova({
    baseURL,
    timeout,
    cacheFor: null,        // 禁用缓存
    statesHook: VueHook,   // 绑定 Vue 响应式
    requestAdapter: axiosRequestAdapter(),
    responded: {
      onSuccess: async (response) => response.data,  // 自动解包 Axios 响应
      onError: (error) => { throw error; },
    },
  });
}

10.3 服务端 API (createServerApi.ts)

export function createServerAlova({ baseURL, headers, timeout = 30000 }) {
  return createAlova({
    baseURL,
    timeout,
    cacheFor: null,
    statesHook: VueHook,
    requestAdapter: axiosRequestAdapter(),
    beforeRequest(method) {
      // 转发原始请求头(携带 Cookie/Authorization 等)
      if (headers) {
        Object.assign(method.config, {
          headers: { ...method.config.headers, ...headers },
        });
      }
    },
    responded: {
      onSuccess: async (response) => response.data,
      onError: (error) => { throw error; },
    },
  });
}

客户端 vs 服务端的关键差异:服务端实例在 beforeRequest 中转发原始请求头(headersOriginal),用于传递登录态(Cookie、Token)。服务端还需要使用绝对 URLhttp://localhost:3000/api/v1)而非相对路径。

10.4 业务 API 定义

业务 API 通过 apiCreator 统一创建,例如权限 API:

// src/api/permissionApi.ts
import { apiCreator, API_TYPE } from '#api/alovaInstance';

export function fetchPermissionList(params, options?, customInstances?) {
  return apiCreator(
    { ...options, method: 'get', url: '/permissions', type: API_TYPE.DEFAULT },
    params, customInstances,
  );
}

export function createPermission(data, options?, customInstances?) {
  return apiCreator(
    { ...options, method: 'post', url: '/permissions', type: API_TYPE.DEFAULT },
    data, customInstances,
  );
}

11. Layout 系统

11.1 公共布局与页面自定义

设计理念:Layout 是全局公共的,但每个页面可以通过 Pinia Store 暴露的方法来修改布局状态。

+Layout.vue(全局布局)
    ├── AppSidebar(侧边栏 — 读取 layoutStore.sidebarMenus)
    ├── AppHeader(顶部栏 — 读取 layoutStore.breadcrumbs / headerActions)
    └── <slot />(页面内容)
            ↑
    页面在 onMounted 中调用 useLayout() 设置标题、面包屑、按钮等

11.2 useLayout 组合式函数

// src/composables/useLayout.ts
export function useLayout() {
  const layoutStore = useLayoutStore();

  onMounted(() => {
    layoutStore.resetLayout();  // 每次页面挂载时重置布局状态
  });

  return {
    setTitle(title: string) { layoutStore.setTitle(title); },
    setBreadcrumbs(items: BreadcrumbItem[]) { layoutStore.setBreadcrumbs(items); },
    setHeaderActions(actions: HeaderAction[]) { layoutStore.setHeaderActions(actions); },
    setShowSidebar(show: boolean) { layoutStore.setShowSidebar(show); },
    setShowHeader(show: boolean) { layoutStore.setShowHeader(show); },
    toggleSidebar() { layoutStore.toggleSidebar(); },
    clearHeaderActions() { layoutStore.clearHeaderActions(); },
  };
}

页面中使用示例:

<script lang="ts" setup>
import { onMounted } from 'vue';
import { useLayout } from '#composables/useLayout';
import { useI18n } from 'vue-i18n';

const { t } = useI18n();
const layout = useLayout();

onMounted(() => {
  layout.setTitle(t('menu.home'));
  layout.setBreadcrumbs([{ label: t('menu.home') }]);
  layout.setHeaderActions([
    { key: 'refresh', label: '刷新', type: 'primary', handler: () => loadData() },
  ]);
});
</script>

11.3 AppSidebar 组件

<!-- src/layout/AppSidebar.vue -->
<template>
  <div class="sidebar-menu">
    <div class="logo">
      <span class="logo-text">{{ t('app.title') }}</span>
    </div>
    <el-menu :default-active="activePath" :collapse="collapsed" @select="handleSelect">
      <template v-for="item in menus" :key="item.path">
        <el-sub-menu v-if="item.children?.length" :index="item.path">
          <template #title>
            <el-icon v-if="item.icon"><component :is="item.icon" /></el-icon>
            <span>{{ t(item.label) }}</span>
          </template>
          <el-menu-item v-for="child in item.children" :key="child.path" :index="child.path">
            {{ t(child.label) }}
          </el-menu-item>
        </el-sub-menu>
        <el-menu-item v-else :index="item.path">
          <el-icon v-if="item.icon"><component :is="item.icon" /></el-icon>
          <span>{{ t(item.label) }}</span>
        </el-menu-item>
      </template>
    </el-menu>
  </div>
</template>

<script lang="ts" setup>
import { navigate } from 'vike/client/router';

function handleSelect(index: string) {
  navigate(index);  // 使用 Vike 的 navigate 进行客户端路由跳转
}
</script>

重要:不能使用 Element Plus 的 router prop,因为它依赖 Vue Router。Vike 项目中应使用 @select 事件 + navigate() 手动导航。

11.4 AppHeader 组件

<!-- src/layout/AppHeader.vue -->
<template>
  <div class="app-header">
    <div class="header-left">
      <el-icon class="toggle-btn" @click="emit('toggle-sidebar')">
        <Fold v-if="!collapsed" /><Expand v-else />
      </el-icon>
      <el-breadcrumb separator="/">
        <el-breadcrumb-item v-for="item in breadcrumbs" :key="item.label" :to="item.path">
          {{ item.label }}
        </el-breadcrumb-item>
      </el-breadcrumb>
    </div>
    <div class="header-right">
      <!-- 页面自定义按钮区域 -->
      <el-button v-for="action in headerActions" :key="action.key" :type="action.type" @click="action.handler">
        {{ action.label }}
      </el-button>
      <!-- 用户信息 -->
      <el-dropdown>
        <span class="user-info">
          <el-icon><User /></el-icon> {{ user?.name || '未登录' }}
        </span>
      </el-dropdown>
    </div>
  </div>
</template>

12. Element Plus 集成(SSR 兼容)

在 SSR 项目中集成 Element Plus 需要解决三个问题:

12.1 CSS 加载问题

问题unplugin-vue-components 默认会自动导入组件对应的 CSS 文件,但 SSR 时 Node.js 无法处理 .css 文件。

解决方案

// vite.config.ts
Components({
  resolvers: [ElementPlusResolver({ importStyle: false })],  // 禁用自动导入样式
}),
<!-- +Layout.vue 中手动全量引入 -->
<script setup>
import 'element-plus/dist/index.css';
</script>

12.2 ID 注入问题

问题ElementPlusError: [IdInjection] Looks like you are using server rendering, you must provide a id provider

解决方案

// +onCreateApp.ts
import { ID_INJECTION_KEY, ZINDEX_INJECTION_KEY } from 'element-plus';

app.provide(ID_INJECTION_KEY, { prefix: 1024, current: 0 });
app.provide(ZINDEX_INJECTION_KEY, { current: 0 });

12.3 Locale 国际化

<!-- +Layout.vue -->
<template>
  <el-config-provider :locale="elementLocale">
    <!-- ... -->
  </el-config-provider>
</template>

<script setup>
import zhCN from 'element-plus/es/locale/lang/zh-cn';
import enUS from 'element-plus/es/locale/lang/en';

const elementLocale = computed(() => locale.value === 'en-US' ? enUS : zhCN);
</script>

13. 权限系统

13.1 权限 URL 统一管理

所有需要权限验证的 API URL 统一在 src/constants/permissionApis.ts 中管理:

// src/constants/permissionApis.ts
export const PERMISSION_APIS = {
  /** 查询权限列表 */
  LIST: 'GET /api/v1/permissions',
  /** 新增权限 */
  CREATE: 'POST /api/v1/permissions',
  /** 编辑权限 */
  UPDATE: 'PUT /api/v1/permissions',
  /** 删除权限 */
  DELETE: 'DELETE /api/v1/permissions',
} as const;

注意+config.ts 文件由 vike 的 esbuild 插件编译,不支持 Vite 路径别名(#constants/...)。因此在 +config.ts 中必须使用相对路径导入常量,而在 +Page.vue 中可正常使用 # 别名。

各页面 +config.ts 中按需声明所需的权限 URL:

// src/pages/permission/+config.ts(列表页 — 需要所有操作权限)
import { PERMISSION_APIS } from '../../constants/permissionApis';

export default {
  title: '权限列表',
  permissionUrls: [
    PERMISSION_APIS.LIST,
    PERMISSION_APIS.CREATE,
    PERMISSION_APIS.UPDATE,
    PERMISSION_APIS.DELETE,
  ],
};
// src/pages/permission/add/+config.ts(新增页 — 只需 CREATE 权限)
import { PERMISSION_APIS } from '../../../constants/permissionApis';

export default {
  title: '新增权限',
  permissionUrls: [PERMISSION_APIS.CREATE],
};

13.2 页面级权限 — +guard.ts

流程:

  1. 页面在 +config.ts 中声明 permissionUrls(引用统一常量)
  2. +guard.ts 读取该配置,在 SSR 阶段调用后端 POST /api/v1/permission/check
  3. 后端返回 { allowed: true/false, urlPermissions: { [url]: boolean } }
  4. allowed: falsethrow render(403),整页渲染错误页(如新增权限页无 CREATE 权限)
  5. allowed: true 时将 urlPermissions 写入 pageContext.permissionResult,通过 passToClient 传到客户端

13.3 按钮级权限 — usePermission

通过 usePermission() 组合式函数在组件中检查单个 URL 的权限,控制按钮 disabled 状态:

// src/composables/usePermission.ts
export function usePermission() {
  const pageContext = usePageContext();
  const permissionResult = computed(() => (pageContext as any).permissionResult || {});

  function hasPermission(url: string): boolean {
    return permissionResult.value?.urlPermissions?.[url] ?? true;
  }

  return { permissionResult, hasPermission };
}

列表页使用示例(控制添加/编辑/删除按钮):

<script setup>
import { usePermission } from '#composables/usePermission';
import { PERMISSION_APIS } from '#constants/permissionApis';

const { hasPermission } = usePermission();
const canCreate = hasPermission(PERMISSION_APIS.CREATE);
const canUpdate = hasPermission(PERMISSION_APIS.UPDATE);
const canDelete = hasPermission(PERMISSION_APIS.DELETE);
</script>

<template>
  <el-button type="primary" :disabled="!canCreate" @click="handleAdd">新增</el-button>
  <!-- 表格操作列 -->
  <el-button :disabled="!canUpdate" @click="handleEdit(row)">编辑</el-button>
  <el-button :disabled="!canDelete" @click="handleDelete(row)">删除</el-button>
</template>

编辑页使用示例(通过 canSubmit prop 控制表单保存按钮):

<PermissionForm
  :initial-data="detail"
  :is-sending="isSending"
  :can-submit="canUpdate"
  @submit="submit"
  @cancel="goBack"
/>

PermissionForm.vue 中保存按钮根据 canSubmit 属性禁用:

<el-button type="primary" :loading="isSending" :disabled="canSubmit === false" @click="submit">
  {{ t('common.save') }}
</el-button>

13.4 Mock 权限验证(server/server.ts)

开发阶段通过 Mock 接口模拟权限检查:

// 模拟无权限的 URL 列表
const DENIED_URLS = new Set([
  'POST /api/v1/permissions',   // 新增权限
  'PUT /api/v1/permissions',    // 编辑权限
]);

// pagePath + URL 命中时整页拒绝(403)
const PAGE_BLOCKED_RULES = [
  { pathPattern: /^\/permission\/add$/, url: 'POST /api/v1/permissions' },
];

app.post('/api/v1/permission/check', (req, res) => {
  const { urls = [], pagePath = '' } = req.body;
  const urlPermissions = {};
  urls.forEach((url) => { urlPermissions[url] = !DENIED_URLS.has(url); });

  // 命中 PAGE_BLOCKED_RULES 则整页拒绝
  const allowed = !PAGE_BLOCKED_RULES.some(
    (rule) => rule.pathPattern.test(pagePath) && urls.includes(rule.url) && DENIED_URLS.has(rule.url),
  );

  res.json({ code: 0, data: { allowed, urlPermissions } });
});
  • DENIED_URLS — 控制哪些 URL 返回无权限(按钮 disabled)
  • PAGE_BLOCKED_RULES — 当特定页面路径命中被拒绝的 URL 时,整页返回 403

13.5 权限流程图

用户请求页面
    │
    ▼
+guard.ts 读取 +config.ts 中的 permissionUrls(引用 PERMISSION_APIS 常量)
    │
    ├── 未配置 → 直接放行
    │
    └── 已配置 → SSR 调用 POST /api/v1/permission/check { urls, pagePath }
                    │
                    ├── allowed: false → throw render(403) → 渲染 _error/+Page.vue
                    │   (如: /permission/add 页面无 CREATE 权限 → 整页 403)
                    │
                    └── allowed: true → permissionResult 存入 pageContext
                            │
                            └── 组件中通过 usePermission().hasPermission(url) 判断
                                    │
                                    ├── true  → 按钮正常可用
                                    └── false → 按钮 disabled
                                        (如: 编辑页无 UPDATE 权限 → 保存按钮禁用)

14. 路由与导航

14.1 文件系统路由

Vike 根据 src/pages/ 目录结构自动生成路由:

目录结构 路由路径 说明
pages/index/+Page.vue / 首页
pages/permission/+Page.vue /permission 权限列表
pages/permission/add/+Page.vue /permission/add 新增权限
pages/permission/@id/edit/+Page.vue /permission/:id/edit 编辑权限(动态路由)
pages/_error/+Page.vue 错误页面 401/403/404/500

@id 是 Vike 的动态路由语法,等效于 Vue Router 的 :id。通过 pageContext.routeParams.id 获取。

14.2 客户端导航

Vike 提供 navigate 函数实现客户端路由跳转(无刷新):

import { navigate } from 'vike/client/router';

// 跳转到指定页面
navigate('/permission');

// 跳转并替换历史记录
navigate('/permission', { overwriteLastHistoryEntry: true });

+config.ts 中已设置 clientRouting: true(由 vike-vue 默认配置),启用客户端路由。


15. 业务页面示例

15.1 Dashboard 首页

文件src/pages/index/

文件 作用
+config.ts 配置标题 '首页'
+data.ts SSR 调用 /api/v1/dashboard/stats 预取统计数据
+Page.vue 通过 useData() 获取数据,展示统计卡片和操作日志表格
<script setup>
const data = useData<Data>();  // SSR 预取的数据,无需 onMounted 加载
const statCards = computed(() => [
  { key: 'total', label: t('dashboard.totalPermissions'), value: data.totalPermissions },
  // ...
]);
</script>

15.2 权限列表页

文件src/pages/permission/

文件 作用
+config.ts 配置标题 + permissionUrls(启用权限验证)
+data.ts SSR 预取第一页权限列表
+Page.vue 展示列表 + 搜索 + 分页

SSR + CSR 混合:首页数据通过 SSR 预取,后续翻页/搜索通过客户端 Alova 调用。

15.3 新增权限页

文件src/pages/permission/add/

文件 作用
+config.ts 配置标题 + permissionUrls
+data.ts 空 data 文件(阻止继承父级的 +data.ts)
+Page.vue 使用 PermissionForm 组件

关键:必须创建空的 +data.ts,否则会继承 permission/+data.ts 的数据加载逻辑,导致不需要的 API 调用甚至报错。

15.4 编辑权限页

文件src/pages/permission/@id/edit/

与新增页类似,额外通过 pageContext.routeParams.id 获取路由参数,在 onMounted 中加载详情数据:

<script setup>
const pageContext = usePageContext();
const routeParams = pageContext.routeParams as { id: string };

onMounted(() => {
  fetchDetail(routeParams.id);
});
</script>

15.5 可复用组件 — PermissionForm

src/viewComponents/permission/PermissionForm.vue 同时服务于新增和编辑页面:

<script setup>
const props = defineProps<{
  initialData?: Record<string, any>;  // 编辑时传入已有数据
  isSending?: boolean;                // 提交中状态
  canSubmit?: boolean;                // 是否有提交权限(false 时禁用保存按钮)
}>();

const emit = defineEmits<{
  submit: [data: Record<string, any>];
  cancel: [];
}>();

// 表单验证规则
const rules: FormRules = {
  name: [{ required: true, message: '请输入权限名称', trigger: 'blur' }],
  code: [{ required: true, message: '请输入权限编码', trigger: 'blur' }],
  type: [{ required: true, message: '请选择权限类型', trigger: 'change' }],
};
</script>

16. SSR 与 CSR 策略

场景 策略 实现方式
首屏数据 SSR +data.tsuseData()
权限验证 SSR +guard.tsthrow render(403)
翻页/搜索 CSR 组件内直接使用客户端 Alova
表单提交 CSR 组件内调用 API 后 navigate()
页面跳转 CSR navigate() 客户端路由
初始页面加载 SSR Express → renderPage() → HTML

数据流:

SSR 阶段:
  Express → renderPage() → +guard.ts → +data.ts → +Layout.vue + +Page.vue → HTML

CSR 阶段 (客户端路由):
  navigate() → +guard.ts (client) → +data.ts → 组件更新

17. 关键踩坑与解决方案

17.1 Express 5 路由语法变更

问题app.get('*', ...) 报错 Missing parameter name

原因:Express 5 使用新版 path-to-regexp,不再支持裸通配符

解决:改为命名通配符 app.get('/{*path}', ...)

17.2 Element Plus CSS SSR 加载失败

问题TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".css"

原因:Node.js SSR 环境无法处理 CSS 文件

解决

  • ElementPlusResolver({ importStyle: false }) 禁用自动导入样式
  • +Layout.vueimport 'element-plus/dist/index.css'(Vite 会正确处理)

17.3 Element Plus SSR ID/ZIndex 注入

问题:Hydration 失败,控制台报 IdInjectionZIndexInjection 错误

解决:在 +onCreateApp.tsapp.provide(ID_INJECTION_KEY, ...)app.provide(ZINDEX_INJECTION_KEY, ...)

17.4 服务端 API 调用使用相对 URL

问题+data.ts+guard.ts 中使用 /api/v1/xxx 相对路径在 SSR 中无法工作

原因:Node.js 中没有浏览器的 location.origin,相对 URL 无法解析

解决:SSR 中使用绝对 URL http://localhost:${process.env.PORT || 3000}/api/v1

17.5 +data.ts 的继承问题

问题/permission/add 页面继承了 /permission/+data.ts 的数据加载,导致不必要的 API 调用

原因:Vike 的 +data.ts 会沿目录树向上继承

解决:在子目录创建空的 +data.ts

export type Data = Record<string, never>;
export async function data() { return {}; }

17.6 El-Menu 的 router prop 不兼容 Vike

问题:侧边栏菜单点击无反应或报错

原因:Element Plus 的 el-menu router prop 依赖 Vue Router,Vike 项目不使用 Vue Router

解决:移除 router prop,使用 @select 事件 + navigate():

<el-menu @select="handleSelect">
  <!-- ... -->
</el-menu>

<script setup>
import { navigate } from 'vike/client/router';
function handleSelect(index: string) {
  navigate(index);
}
</script>

17.7 process.env 在客户端不可用

问题ReferenceError: process is not defined

原因+guard.ts 在客户端也会执行,但 process.env 仅在 Node.js 中可用

解决:将 process.env 访问放在 if (typeof window === 'undefined') 分支内


18. 开发与构建命令

# 开发(启动 Express + Vite HMR)
npm run dev

# 构建(生成 dist/client + dist/server)
npm run build

# 生产预览
npm run preview

# 代码检查
npm run lint

# 自动修复
npm run fix

开发环境tsx server/server.ts → Express 启动 → createDevMiddleware 注入 Vite HMR → 访问 http://localhost:3000

生产构建vike build → 输出 dist/client(静态资源)+ dist/server(SSR Bundle)


19. 生产部署

19.1 构建产物结构

执行 npm run build(即 vike build)后生成 dist/ 目录:

dist/
├── assets.json                    # 资源映射文件(Vike 内部使用)
├── client/                        # 静态资源(浏览器端)
│   └── assets/
│       ├── chunks/                # JS 代码分割块
│       ├── entries/               # 各页面入口 JS
│       └── static/               # CSS 文件
└── server/                        # SSR 服务端代码
    ├── entry.mjs                  # SSR 入口(Vike renderPage 用)
    ├── entries/                   # 各页面的 SSR 渲染逻辑
    ├── chunks/                    # 服务端公共模块
    └── package.json               # { "type": "module" }

19.2 部署方式

本项目使用 Express 作为生产服务器server/server.ts 同时处理静态文件托管和 SSR 渲染。部署步骤:

1. 构建

npm run build

2. 部署所需文件

将以下文件/目录上传到服务器:

dist/                # 构建产物(client + server)
server/server.ts     # Express 服务器入口
package.json         # 依赖声明
node_modules/        # 或在服务器上 npm install

3. 启动服务

# 方式一:直接用 tsx 运行 TypeScript(需安装 tsx)
cross-env NODE_ENV=production tsx server/server.ts

# 方式二:用 PM2 管理进程(推荐)
pm2 start "cross-env NODE_ENV=production tsx server/server.ts" --name vike-admin

# 自定义端口
cross-env NODE_ENV=production PORT=8080 tsx server/server.ts

运行原理: server/server.ts 中根据 NODE_ENV 自动切换行为:

if (isProd) {
  // 生产环境:sirv 托管 dist/client 静态文件
  const sirv = (await import('sirv')).default;
  app.use(sirv(`${root}/dist/client`));
} else {
  // 开发环境:Vite HMR 开发中间件
  const { devMiddleware } = await createDevMiddleware({ root });
  app.use(devMiddleware);
}

Vike 的 renderPage() 在生产环境会自动加载 dist/server/entry.mjs 进行 SSR 渲染。

19.3 Nginx 反向代理(可选)

如果需要通过 Nginx 暴露服务:

server {
    listen 80;
    server_name your-domain.com;

    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;
    }
}

19.4 Docker 部署(可选)

FROM node:20-alpine
WORKDIR /app

COPY package.json yarn.lock ./
RUN yarn install --production=false

COPY . .
RUN yarn build

ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000

CMD ["npx", "tsx", "server/server.ts"]
docker build -t vike-admin .
docker run -d -p 3000:3000 vike-admin

19.5 注意事项

事项 说明
NODE_ENV 必须设为 production,否则会尝试启动 Vite 开发中间件
Mock API 生产环境应替换为真实后端 API 代理,移除 Mock 路由
tsx 生产环境仍需 tsx 来运行 TypeScript 的 server.ts,也可预编译为 JS
端口 默认 3000,可通过 PORT 环境变量修改
dist/ 路径 server.ts 通过 __dirname + '/.. 定位 dist,部署时保持目录相对关系

本文档对应项目版本:2026-02-12 · Vike 0.4.252 · Vue 3.5 · Element Plus 2.9 · Express 5.2

Nuxt3 与官网 SEO:从 TDK 配置到搜索引擎收录

作者 唐诗
2026年2月12日 13:59

本文适合 SEO 初学者快速上手 Nuxt3 项目的搜索引擎优化

使用 nuxt3(本文写于一年前) 做了个官网, 第一次做官网也没啥经验一切都是摸着石头过河! 开发完成后做了一些 SEO 的相关的优化,这里做一个记录

如果之前做过网站的 seo 那这篇文章对你可能没什么帮助,因为这里的目标是 谷歌、必应 搜索框输入指定关键字,可以被搜索到。

为什么使用 nuxt3?

  1. 支持服务端渲染(SSR)
  2. 支持静态站点生成(SSG)
  3. 自动生成 <meta> 标签
  4. 提供开箱即用的 SEO 优化工具
  5. 支持预渲染(Prerendering)

seo 绕不开的 TDK

TDK 分别对应标题(Title)、描述(Description)和关键词(Keywords)

  • <title>标签包含了网页的标题和主要关键词。
  • <meta name="description">标签提供了网页的描述。
  • <meta name="keywords">标签列出了相关的关键词。

这些元素对于搜索引擎优化(SEO)非常重要它们帮助搜索引擎理解网页的内容,并影响搜索结果中的显示。

一个简单的🌰

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>网页标题 - 主要关键词</title>
    <meta name="description" content="这是一个关于主要关键词的网页描述。">
    <meta name="keywords" content="主要关键词, 相关关键词, 其他关键词">
</head>
<body>
    <h1>欢迎访问</h1>
    <p>这里是我们关于主要关键词的内容。</p>
</body>
</html>

nuxt3 中如何设置 TDK

nuxt.config.ts 文件中进行配置

nuxt 3.15 不支持直接在 app 配置 seoMeta 改为在 app.vue 中使用 useSeoMeta 实现

// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
  devtools: { enabled: true },
  app: {
    head: {
      title: '爱中文',
      titleTemplate: '%s',
      meta: [
        { name: 'keywords', content: '爱中文' },
        { name: 'keywords', content: '上海爱中文数字' },
        { name: 'keywords', content: '中文教育' },
        { name: 'keywords', content: '国际中文智慧教育品牌' },
        { name: 'keywords', content: '智慧教育解决方案' },
      ],
    },
  },
})

在 app.vue 中配置 seoMeta

useSeoMeta({
  title: '上海爱中文数字信息技术有限公司',
  description: '上海爱中文数字信息技术有限公司,作为一家国际中文智慧教育企业,我们致力于成为“知识服务提供商与学习技术领导者”。依托先进的数字教育技术与深厚的教学设计实力,我们携手全球中文教育领域的专家,汇聚优质中文教学资源,创新探索适应不同地域特色的海外中文学习新范式。',
})

配置完渲染出来大概是这样

image.png

上边的配置中 title 字段十分重要直接影响搜索结果

这里提一下 titleTemplate: '%s' 这样写渲染后的 title 字段会原样渲染

image.png

如果去除 titleTemplate: '%s' 则会显示,把 package.json 文件的 name 字段拼接在后边这不是咱想要的

image.png

可能你还想为每个页面单独设置

这当然是可以的而且也很简单使用内置的 useHead 或者 useSeoMeta 即可

两者看情况使用或结合一起使用

useHead

image.png

useSeoMeta

image.png

nuxt seo 利器

nuxt3 提供对应的解决方案直接引入 @nuxtjs/seo 即可,号称是 Nuxt 的完整 SEO 解决方案。

image.png

SEO 小白狂喜

image.png

该有的都有了,这里只看 sitemap.xml、robots.txt

sitemap.xml

开发环境会比线上环境多一些调试信息

线上环境 https://xxxx/sitemap.xml

image.png

开发环境 http://localhost:3000/sitemap.xml

image.png

robots.txt

线上生成的文件与本地有一定区别线上全部允许、本地全部不允许

线上环境 https://xxxx/robots.txt

image.png

开发环境 http://localhost:3000/robots.txt

image.png

自动生成 OgImage

猛击查看官网文档

defineOgImageComponent('NuxtSeo', {
  title: '全球中文教育领导者 👋',
  description: '我们携手全球中文教育领域的专家,汇聚优质中文教学资源,创新探索适应不同地域特色的海外中文学习新范式。',
  theme: '#C74043',
  colorMode: 'dark',
  siteLogo: '/img/logo/logo1.jpg', // public 目录
})

中文需要配置字体,修改 nuxt.config.ts

ogImage: {
  googleFontMirror: true, // 开启谷歌字体景象-不挂代理感觉很难下载
  fonts: [
    'Noto+Sans+SC:400',
  ],
},

使用本地字体 本地字体文件必须是 .otf 、 ttf 、 .woff ,并且位于 public 目录内。

ogImage: {
  fonts: [
    {
      name: 'AlibabaPuHuiTi',
      weight: 500,
      path: '/fonts/AlibabaPuHuiTi/AlibabaPuHuiTi-3-65-Medium.woff',
    },
  ],
},

image.png

如何在 谷歌、必应 中可以被搜索到呢?

通过各大搜索引擎提供的站点工具提交网站,可以加快网站被索引的速度

谷歌

猛击访问

image.png

添加站点时是会验证所有权,选择一种验证即可

image.png

image.png

检查下网址是否被收录,只有被收录才可以搜索到

image.png

提交下站点地图

image.png

然后等着被收录就好了,快的话 48 小时,慢的话一个周左右

必应

猛击进入必应站点管理员页面

必应可以直接拉取谷歌已验证的站点,使用谷歌登录即可!

image.png

提交下网站地图

image.png

把网站地图的网址这里在提交一次(感觉会有点用)

image.png

然后等收录,快的话48小时,慢的话一个周左右

最终结果

谷歌搜索有时会靠前有时会靠后🤪

image.png

image.png

必应搜索

image.png

总结

本文从实战角度出发,系统介绍了 Nuxt3 网站的 SEO 优化方案:

  1. TDK 配置:通过 nuxt.config.ts 和 useSeoMeta 设置标题、描述、关键词
  2. SEO 工具:使用 @nuxtjs/seo 自动生成 sitemap.xml、robots.txt 和 OgImage
  3. 搜索引擎收录:通过 Google Search Console 和 Bing Webmasters 提交站点地图,完成网站收录

踩坑实录:设计图上的两个小圆角,我试了3种方法才搞定✨

作者 码叙
2026年2月12日 13:22

做前端开发最常遇到的情况就是:设计图看着简单,实际写起来全是坑!今天就跟大家分享一个不起眼但折腾我半天的小问题——右侧遮罩的圆角实现,全程真实踩坑,新手朋友可以避避雷~

## 先看需求:设计图长这样

核心很简单:一张背景图左侧正常显示,右侧用遮罩覆盖,重点是遮罩的上下两个角,要做出贴合设计的圆润效果(不是常规的border-radius能搞定的那种)。 c856d811-5952-4031-bd49-7a705ffcec20.png

初期思路:想当然的“简单方案”

第一眼看到这张设计图,这不就是一张背景图,右侧用一个div遮罩盖住就完事了?思路简单直接,立马动手写代码实现。

初期实现效果如下,遮罩是加上了,但问题也随之而来... 4a6f870c-1a13-43ec-9371-69aab195cf9e.png

踩坑开始:两个圆角难住我了

最棘手的问题来了——右侧遮罩的上下两个圆角,怎么都做不出设计图的效果。

我先试着用「伪元素+border-radius」做遮挡,想模拟出圆角效果,但要么圆角生硬,要么会露出背景图的边缘,衔接不自然,效果一直不尽如人意。 接着又去查了资料,尝试用网上说的「CSS挖洞技巧」实现,折腾了半天,还是没能解决问题。

期间还试过给左侧标题部分也用div包裹,再添加圆角,但这样会露出底下的背景色,完全不符合设计需求,只能放弃。

CSS径向渐变(radial-gradient)救场

突然想到CSS径向渐变(radial-gradient)的一个特性——它可以设置透明色!

background-image: radial-gradient(shape size at position, start-color, ..., last-color);

radial-gradient的核心用法

1. 定义渐变的形状(shape)

  • circle:圆形径向渐变(我这次用到的就是这个)
  • ellipse(默认值):椭圆形径向渐变

2. 定义渐变大小(size)

size决定了渐变的半径范围,也就是渐变“扩散”到多远,常用值有4个:

  • closest-side:从中心点到离它最近的边(推荐,贴合需求)
  • closest-corner:从中心点到离它最近的角(默认值)
  • farthest-side:从中心点到离它最远的边
  • farthest-corner:从中心点到离它最远的角

3. 定义渐变位置(position)

position用来定义渐变的中心点,默认是容器中心(center),常用取值方式有3种:

  • 关键字:top、bottom、left、right、center(可组合,比如right top就是右上角)
  • 百分比:50% 50%(中心)、20% 80%(偏右下)
  • 长度值:10px 20px(距左上角的水平、垂直偏移)

4. 颜色节点(color-stop)

至少需要两个颜色值,用来定义渐变的起始和结束颜色,也可以添加多个中间色,可选设置颜色位置:

  • 带位置:red 0%、blue 100%、green 50%(颜色在指定位置切换)
  • 不带位置:颜色会均匀分布在渐变范围内

关键技巧:利用透明和圆形渐变色实现

radial-gradient最关键的能力,也是我这次解决问题的核心——可以设置透明色(transparent)

### 最终实现效果

直接上最终效果图,圆角顺滑

image.png

核心代码片段

.container{
    background-image: url("./你的背景图");
    background-size: 100% 100%;
    width: 900px;
    height: 300px;
    position: relative;
}
.container::before{
    content: '';
    position: absolute;
    top: 80px;
    right: 0;
    width: 40px;
    height: 40px;
    color: #fff;
    /* 右上不透明,左下透明的渐变遮罩 */
    background: radial-gradient(circle at bottom left, transparent 70.7%, #fff 70.8%);
}
.head{
    display: flex;
    height: 80px;
}
.null-box{
    flex: 1;
    height: 80px;
    background: #ffffff;
}
.title {
    width: 200px;
    height: 80px;
    position: relative;
    overflow: hidden;
    background: transparent;
    color: #fff;
    text-align: center;
    line-height: 80px;
}
.title::before {
    content: '';
    position: absolute;
    top: 0;
    right: 0;
    width: 20px;
    height: 20px;
    color: #fff;
    /* 右上不透明,左下透明的渐变遮罩 */
    background: radial-gradient(circle at bottom left, transparent 70.7%, #fff 70.8%);
}

总结

其实这个需求本身不难,难的是我一开始陷入了「遮罩只能用纯色+border-radius」的固定思维,忽略了CSS径向渐变的透明特性。 如果大家也遇到过类似的CSS小坑,或者有更好的实现方案,欢迎在评论区交流讨论哦!

Axios Params 序列化解决方案

作者 Renouc
2026年2月12日 12:34

Axios Params 序列化解决方案(paramsSerializer

这篇笔记解决什么问题

当你遇到下面任一情况时,这篇笔记可以直接给出方案:

  • 前端传了数组/对象,后端却“收不到”或结构不对
  • 同一个参数在不同接口上格式要求不一致([]、下标、重复 key)
  • 你不确定该用 Axios 内置配置,还是引入 qs

30 秒选型(先看这里)

  • 只用扁平对象 + 普通数组:用 Axios 内置 paramsSerializer.indexes
  • 有复杂嵌套对象、严格后端协议:用 qs.stringify
  • 想团队统一风格:在 axios 实例里一次性配置,不要散落在每个接口

paramsSerializer 是什么(定义)

paramsSerializer 用来控制 Axios 如何把 params 序列化成 URL query string

  • 作用对象:params
  • 不作用于:data(请求体)、路径参数(如 /users/:id
  • 即使是 POST/PUT,只要传了 params,它也会生效

示例:

axios.post('/users', data, {
  params: { page: 1 },
})

Axios 内置三种序列化方式(数组)

假设:

params = { tags: ['red', 'blue'] }
配置 结果 适用场景
indexes: false tags[]=red&tags[]=blue 常见 PHP/部分后端习惯(Axios 默认)
indexes: true tags[0]=red&tags[1]=blue 后端需要明确索引
indexes: null tags=red&tags=blue 后端按重复 key 解析数组

示例:

axios.get('/users', {
  params: { tags: ['red', 'blue'] },
  paramsSerializer: { indexes: null },
})

什么时候需要 qs

如果你有以下需求,建议直接用 qs

  • 嵌套对象较深(如 filter[name]=tom
  • 后端对参数格式有明确规范
  • 需要统一控制空值、编码、数组格式、键排序

示例:

import qs from 'qs'

axios.get('/users', {
  params: { filter: { name: 'tom' }, tags: ['a', 'b'] },
  paramsSerializer: {
    serialize: (params) => qs.stringify(params, { arrayFormat: 'repeat' }),
  },
})

推荐落地方式(项目级)

把策略放到请求实例,统一生效:

const request = axios.create({
  baseURL: '/api',
  paramsSerializer: {
    indexes: null,
  },
})

一句话结论

paramsSerializer 只负责“params 怎么拼到 URL 上”;内置够用就别加库,复杂协议再用 qs

🎨 Three.js 自定义材质贴图偏暗?一文搞懂颜色空间与手动 Gamma 矫正

2026年2月12日 12:20

在做智驾标注工具时踩了个坑:为什么我写的 Shader 贴图总是比原图暗?深入探究颜色空间的奥秘!


📌 问题引入:贴图怎么变暗了?

最近在开发一个基于 Three.js 的 3D 标注工具,需要自定义着色器来实现一些特殊的贴图效果。写了个最基础的贴图材质:

varying vec2 vUv;
uniform sampler2D texture;

void main() {
  vec4 texColor = texture2D(texture, vUv);
  gl_FragColor = texColor;
}

结果傻眼了:渲染出来的贴图比原图明显偏暗!

这不对啊!我明明就是直接采样贴图,怎么就变暗了?难道是纹理加载的问题?还是我的光照设置有问题?


🔍 问题根源:颜色空间的"暗箱操作"

经过一番排查,终于找到了罪魁祸首:颜色空间(Color Space)自动转换

🎯 Three.js 的默认行为

Three.js 为了保证物理正确的光照计算,默认会对纹理做这样的处理:

const texture = new THREE.TextureLoader().load('image.jpg');
// Three.js 自动设置:
texture.colorSpace = THREE.SRGBColorSpace; // r152+ 旧版用 encoding

这意味着:

  1. 图片存储格式:JPG/PNG 是 sRGB 编码(gamma ≈ 2.2)
  2. Three.js 自动转换:采样时自动将 sRGB → Linear
  3. Shader 中拿到的:已经是线性空间的颜色值

📐 什么是 sRGB 和 Linear?

颜色空间 说明 特点
sRGB 人眼感知的颜色空间 非线性,暗部压缩,亮部展开
Linear 物理光强的线性空间 线性,适合光照计算

关键点:人眼对亮度的感知是非线性的(史蒂文斯幂定律),而物理光照计算需要在线性空间进行。

🔄 颜色空间转换公式

// sRGB → Linear (解码)
vec3 linear = pow(srgb.rgb, vec3(2.2));

// Linear → sRGB (编码)
vec3 srgb = pow(linear.rgb, vec3(1.0/2.2));

💡 为什么贴图会偏暗?

场景还原

// Three.js 内部做了这件事:
vec4 sampled = texture2D(texture, vUv);  // 返回的是 Linear 空间颜色

// 然后我们直接输出:
gl_FragColor = sampled;  // ❌ 问题:Linear 颜色直接输出到 sRGB 帧缓冲

结果:线性空间的颜色值(如 0.5)直接显示在屏幕上,人眼感知会比预期的暗很多。

🧪 数值对比

原图 sRGB Linear 空间 直接输出到屏幕(错误) 正确输出到屏幕
0.5 (中灰) 0.217 显示为 0.217 (很暗) 应显示为 0.5
0.8 (亮灰) 0.578 显示为 0.578 (偏暗) 应显示为 0.8

看到了吗? 0.5 的中灰色在 Linear 空间只有 0.217,直接输出就变成了"深灰"!


✅ 解决方案

手动 Gamma 编码(推荐)

保留 Three.js 的自动转换,在 Shader 中手动做 Gamma 编码:

uniform sampler2D texture;
varying vec2 vUv;

void main() {
  vec4 color = texture2D(texture, vUv);      // Linear 空间
  color.rgb = pow(color.rgb, vec3(1.0/2.2)); // Linear → sRGB
  gl_FragColor = color;
}

或者封装成函数更清晰:

vec3 linearToSRGB(vec3 linear) {
  return pow(linear, vec3(1.0/2.2));
}

void main() {
  vec4 color = texture2D(texture, vUv);
  color.rgb = linearToSRGB(color.rgb);
  gl_FragColor = color;
}

优点:符合图形学最佳实践,后续加光照也方便
缺点:需要理解颜色空间概念


方案 3:使用内置工具函数(Three.js r152+)

Three.js 提供了内置的颜色空间转换函数:

#include <color_space_pars_fragment>

uniform sampler2D texture;
varying vec2 vUv;

void main() {
  vec4 color = texture2D(texture, vUv);
  color = SRGBToLinear(color);  // 如果需要在线性空间计算
  // ... 光照计算 ...
  color = LinearToSRGB(color);  // 最后转回 sRGB
  gl_FragColor = color;
}

🎯 实战:完整的贴图材质

import * as THREE from 'three';

const vertexShader = `
  varying vec2 vUv;
  
  void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

const fragmentShader = `
  uniform sampler2D texture;
  varying vec2 vUv;
  
  // Linear → sRGB
  vec3 linearToSRGB(vec3 linear) {
    return pow(linear, vec3(1.0/2.2));
  }
  
  void main() {
    vec4 color = texture2D(texture, vUv);      // Three.js 已自动转为 Linear
    color.rgb = linearToSRGB(color.rgb);       // 手动转回 sRGB
    gl_FragColor = color;
  }
`;

const texture = new THREE.TextureLoader().load('image.jpg');
// texture.colorSpace = THREE.SRGBColorSpace; // 默认就是这个,不用设置

const material = new THREE.ShaderMaterial({
  uniforms: {
    texture: { value: texture }
  },
  vertexShader,
  fragmentShader,
  transparent: true
});

const plane = new THREE.Mesh(
  new THREE.PlaneGeometry(10, 10),
  material
);
scene.add(plane);

🤔 为什么不用乘法,而用幂函数?

这是个好问题!为什么 Gamma 矫正要用 pow(x, 1/2.2) 而不是简单的 x * 0.5

人眼感知的非线性

人眼对亮度的感知符合史蒂文斯幂定律

主观亮度 ∝ (物理光强)^0.4~0.5

这意味着:

  • 物理光强增加 4 倍,人眼只感觉"亮了约 2 倍"
  • 暗部的变化人眼更敏感,亮部的变化相对迟钝

线性乘法的灾难

// ❌ 线性乘法:所有亮度等比例压缩
vec3 dark = color * 0.5;

// ✅ Gamma:非线性压缩,匹配人眼感知
vec3 gamma = pow(color, vec3(1.0/2.2));
操作 暗部 (0.1) 中灰 (0.5) 亮部 (0.9) 人眼感知
×0.5 0.05 0.25 0.45 暗部细节丢失严重 ❌
^0.45 0.28 0.71 0.95 均匀压缩感知亮度 ✅

结论:幂函数是对人眼生物特性的数学拟合,不是随便选的!


📊 方案对比总结

方案 适用场景 优点 缺点
手动 Gamma 编码 通用推荐 符合图形学规范,灵活 需要理解概念
内置工具函数 Three.js r152+ 官方支持,代码简洁 版本限制

💡 实用建议

1. 调试技巧

在 Shader 中临时验证:

// 输出纯中灰(应该看起来是 50% 灰)
gl_FragColor = vec4(0.5, 0.5, 0.5, 1.0);  // ❌ 线性 0.5 会很暗

// 正确的 50% 视觉灰
gl_FragColor = vec4(0.73, 0.73, 0.73, 1.0); // ✅ ≈ pow(0.5, 1.0/2.2)

2. 标注工具场景

如果你在做标注工具,只是显示贴图:

// 推荐:禁用自动转换,简单高效
texture.colorSpace = THREE.NoColorSpace;

3. 可视化场景

如果需要光照、阴影等效果:

// 推荐:手动 Gamma 编码
vec4 color = texture2D(texture, vUv);
color.rgb = pow(color.rgb, vec3(1.0/2.2));

🎓 总结

贴图偏暗的问题,本质是颜色空间转换的理解问题:

  1. Three.js 默认:sRGB → Linear(自动)
  2. 自定义 Shader:需要手动将 Linear → sRGB
  3. Gamma 矫正:用幂函数 pow(x, 1/2.2) 而不是乘法,因为人眼感知是非线性的

理解了这个原理,以后写自定义材质就不会再踩坑了!


📚 延伸阅读


💬 互动时间:你在 Three.js 开发中还遇到过哪些"坑"?欢迎在评论区分享!

👍 如果觉得有用,记得点赞收藏,关注我获取更多图形学干货!


本文作者:红波 | 专注 WebGL/Three.js/可视化开发 | 智驾标注工具开发者

为何在 Vue3 setup() 中直接解构 props 会丢失响应性?

2026年2月12日 12:20

很多使用 Vue3 的朋友都遇到过一个问题:在 setup() 函数里直接解构 props,数据就不再响应了。这是为什么呢?

我们先看一个例子。假设我们有一个组件,它接收一个 user 属性。在 setup() 里我们可能会这样写:

export default {
  props: ['user'],
  setup(props) {
    const { name } = props.user
    return { name }
  }
}

这样写看起来没问题,但实际上,name 已经失去了响应性。父组件更新 user 时,这里的 name 不会跟着变。


原因:理解 Vue3 的响应式原理

要明白原因,得先知道 Vue3 的响应式原理。Vue3 用 Proxy 来追踪数据变化。props 本身是响应式的,但当你解构出 props.user.name 时,你拿到的是一个普通的值。这个值和原来的响应式数据断开了联系。

一个比喻

就像你有一根水管,水在水管里流动。你把水接出来放到杯子里,水还在杯子里。但水管里再流动的水,就和杯子里的水没关系了。

Vue3 的响应式系统也是这样工作的。它只能追踪那些被 reactive 或 ref 包裹的数据。直接解构出来的值,只是一个普通的 JavaScript 值,系统不知道这个值需要被追踪。

正确的做法是什么?

那么,正确的做法是什么呢?有两种方法。

方法一:避免提前解构

不要提前解构,在模板里直接使用 props.user.name。这样就能保持响应性。

方法二:使用 toRefs

import { toRefs } from 'vue'

export default {
  props: ['user'],
  setup(props) {
    const { user } = toRefs(props)
    const name = user.value.name
    return { name }
  }
}

toRefs 会把响应式对象的每个属性都转成 ref。这样解构出来的属性还是响应式的。

注意细节

toRefs 处理的是 props 本身,不是 props.user。因为 props 才是响应式对象,props.user 可能只是一个普通对象。

如果你需要解构 props.user 里的属性,可以这样写:

import { toRefs, reactive } from 'vue'

export default {
  props: ['user'],
  setup(props) {
    const user = reactive({ ...props.user })
    const { name } = toRefs(user)
    return { name }
  }
}

先用 reactive 包裹,再用 toRefs 解构。这样 name 就是响应式的了。


特殊情况:将 props 值传递给函数

还有一种情况要注意。有些时候,你解构 props 是为了传值给其他函数。比如:

setup(props) {
  const { id } = props
  fetchData(id)
}

这样写,id 变化时,fetchData 不会重新执行,因为这里的 id 只是一个普通值。

正确的写法是用 watch 或 watchEffect

import { watch } from 'vue'

setup(props) {
  watch(
    () => props.id,
    (newId) => {
      fetchData(newId)
    }
  )
}

这样,props.id 变化时,fetchData 就会用新的 id 重新执行。

总结

直接解构 props 会丢失响应性,因为解构出来的是普通值。要保持响应性,可以:

  1. 在模板里直接使用props.xxx
  2. 用 toRefs 处理后再解构
  3. 需要响应式地使用 props 值时,记得用 watch 或 watchEffect

React性能优化三剑客:useEffect、useMemo与useCallback实战手册

作者 QLuckyStar
2026年2月12日 11:48

废话不多说,直接上干货,兄弟们,注意了,我要开始装逼了:

一、useEffect:副作用管理

核心作用

处理与渲染无关的副作用(如数据获取、订阅事件、定时器、DOM 操作等),并支持清理逻辑。

基本语法

useEffect(() => {
  // 副作用逻辑
  return () => {
    // 清理逻辑(可选)
  };
}, [依赖项]);

使用场景

  1. 数据获取

    useEffect(() => {
      fetch("/api/data")
        .then(res => setData(res));
    }, []); // 仅在挂载时获取
    
  2. 事件监听

    useEffect(() => {
      const handleResize = () => setWidth(window.innerWidth);
      window.addEventListener("resize", handleResize);
      return () => window.removeEventListener("resize", handleResize);
    }, []); // 清理监听器
    
  3. 定时器

    useEffect(() => {
      const timer = setInterval(() => setCount(c => c + 1), 1000);
      return () => clearInterval(timer);
    }, []); // 清理定时器
    

最佳实践

  • 依赖数组:精确控制执行时机,避免遗漏依赖导致闭包问题。
  • 清理函数:移除订阅、清除定时器,防止内存泄漏。
  • 避免无限循环:若副作用中修改状态,使用函数式更新(如 setCount(prev => prev + 1))。

二、useMemo:计算结果缓存

核心作用

缓存复杂计算结果,避免重复执行高开销操作(如排序、过滤、深拷贝)。

基本语法

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

使用场景

  1. 复杂计算

    const filteredList = useMemo(() => 
      list.filter(item => item.price > 100), 
      [list] // 仅当 list 变化时重新计算
    );
    
  2. 避免重复渲染

    const userInfo = useMemo(() => ({ name, age }), [name, age]);
    return <Child user={userInfo} />; // 稳定引用,避免子组件重渲染
    

最佳实践

  • 依赖项完整:包含所有影响计算结果的变量。
  • 避免滥用:简单计算无需缓存,直接执行即可。
  • **配合 React.memo**:缓存子组件,提升渲染性能。

三、useCallback:函数引用缓存

核心作用

缓存函数引用,避免因父组件重新渲染导致子组件不必要的重渲染。

基本语法

const memoizedFn = useCallback(() => {
  doSomething(a, b);
}, [a, b]);

使用场景

  1. 传递回调函数

    const handleClick = useCallback(() => {
      console.log("Clicked");
    }, []);
    return <Button onClick={handleClick} />;
    
  2. **配合 useEffect**

    const fetchData = useCallback(async () => {
      const data = await fetch("/api");
      setData(data);
    }, []);
    useEffect(() => fetchData(), [fetchData]);
    

最佳实践

  • 依赖项精准:确保函数内部使用的变量均包含在依赖数组中。
  • 避免过度优化:仅在需要稳定引用时使用(如传递给 React.memo 子组件)。

四、三者的核心区别

Hook 核心用途 返回值 典型场景
useEffect 处理副作用(数据获取、订阅) 无(返回清理函数) API 请求、事件监听
useMemo 缓存计算结果 计算后的值 复杂计算、避免重复渲染
useCallback 缓存函数引用 函数 传递回调、优化子组件渲染

五、useEffect 最佳实践与闭包陷阱规避

1. 依赖项管理

  • 核心原则:依赖数组必须包含所有在 effect 中使用的 外部变量(包括 state、props、上下文等)。

    // 错误示例:未包含依赖项,导致闭包捕获旧值
    const [count, setCount] = useState(0);
    useEffect(() => {
      const timer = setInterval(() => {
        console.log(count); // 始终输出初始值 0
      }, 1000);
      return () => clearInterval(timer);
    }, []); // ❌ 依赖数组为空
    
    // 正确做法:将 count 加入依赖数组
    useEffect(() => {
      const timer = setInterval(() => {
        console.log(count); // 实时输出最新值
      }, 1000);
      return () => clearInterval(timer);
    }, [count]); // ✅
    

    问题根源:未声明依赖项时,effect 仅在挂载时执行一次,闭包中捕获的 count 是初始值。

2. 清理函数

  • 场景:定时器、事件监听、DOM 操作等需在组件卸载或依赖变化时清理。

    useEffect(() => {
      const handleResize = () => console.log("窗口大小变化");
      window.addEventListener("resize", handleResize);
      return () => window.removeEventListener("resize", handleResize); // ✅ 清理监听器
    }, []);
    

3. 避免无限循环

  • 问题:effect 中修改状态且依赖该状态。

    // 错误示例:依赖项包含 state,导致循环触发
    const [data, setData] = useState([]);
    useEffect(() => {
      fetchData().then((res) => setData(res)); // 触发重新渲染 → effect 重新执行
    }, [data]); // ❌
    

    解决方案:移除冗余依赖,或使用函数式更新:

    useEffect(() => {
      fetchData().then((res) => setData((prev) => [...prev, ...res])); // ✅ 无依赖
    }, []);
    

4. 复杂异步操作

  • 最佳实践:在 effect 内部定义异步函数,避免直接 async 修饰回调。

    useEffect(() => {
      let isMounted = true; // 防止卸载后更新状态
      const fetchData = async () => {
        const res = await api.getData();
        if (isMounted) setData(res);
      };
      fetchData();
      return () => { isMounted = false; }; // 清理标志位
    }, []);
    

六、useMemo 和 useCallback 的真正使用场景

1. useMemo:缓存计算结果

  • 适用场景

    • 高开销计算:如大数据排序、复杂公式、深拷贝。
    • 派生状态:根据 state/props 生成新对象或数组。
    • DOM 节点引用:如 useRef 存储 DOM 元素(需配合 useLayoutEffect)。
  • 避免滥用

    • 简单计算(如 a + b)无需缓存。
    • 频繁变化的依赖项(如 useMemo 依赖项变化频繁时反而降低性能)。
    // 正确使用:缓存复杂计算
    const filteredData = useMemo(() => {
      return data.filter(item => item.price > 100);
    }, [data]);
    
    // 错误使用:缓存简单值
    const simpleValue = useMemo(() => 42, []); // ❌ 无意义
    

2. useCallback:缓存函数引用

  • 适用场景

    • 传递回调给子组件:子组件使用 React.memo 时,避免因父组件重渲染导致子组件无效更新。
    • 依赖外部变量的函数:如事件处理器中依赖 state/props。
  • 避免滥用

    • 仅在需要稳定引用时使用(如传递给 useEffect 或子组件)。
    • 内部函数无需缓存(如仅在组件内部使用的函数)。
    // 正确使用:缓存事件处理器
    const handleClick = useCallback(() => {
      console.log("Clicked");
    }, []);
    
    // 错误使用:缓存内部函数
    const internalFn = useCallback(() => {
      // 仅在组件内部使用,无需缓存
    }, []);
    

七、避免过度优化的关键原则

1. 性能问题驱动优化

  • 先定位瓶颈:使用 React DevTools 的 Profiler 分析组件渲染开销。
  • 针对性优化:仅在渲染耗时或计算密集型场景使用 useMemo/useCallback

2. 依赖项管理

  • 完整声明:使用 ESLint 的 react-hooks/exhaustive-deps 规则强制检查。
  • 避免冗余依赖:如 useMemo 依赖项中包含未使用的变量。

3. 简单场景优先

  • 优先使用普通变量:如 const value = someData; 而非 useMemo(() => someData, [])
  • 函数内部使用:无需缓存仅在组件内部调用的函数。

八、实战案例对比

场景:商品列表筛选

  • 未优化版本(频繁重渲染):

    const ProductList = ({ products, filter }) => {
      const filtered = products.filter(/* 复杂逻辑 */);
      return <List items={filtered} />;
    };
    
  • 优化后版本

    const ProductList = ({ products, filter }) => {
      const filtered = useMemo(() => {
        return products.filter(/* 复杂逻辑 */);
      }, [products, filter]); // 仅当数据或筛选条件变化时重新计算
      return <List items={filtered} />;
    };
    

九、总结

Hook 核心用途 避坑指南
useEffect 副作用管理(数据获取、订阅) 依赖项完整、清理函数、避免循环
useMemo 缓存计算结果 仅高开销计算、避免简单值缓存
useCallback 缓存函数引用 仅传递给 memo 子组件、避免内部函数

关键口诀
“无必要,不优化;有性能问题,再针对性解决”。过度使用 Hooks 反而会增加代码复杂度和内存开销。

❌
❌