阅读视图

发现新文章,点击刷新页面。

让 AI 学会"组队打怪"——聊聊微软的 AutoGen 框架

你有没有想过,一个 AI 助手再聪明,终究也是一个人在战斗。它写完代码没人 review,它做完分析没人挑刺,它回答问题也没人帮忙查漏补缺。

但如果让好几个 AI 坐在一起,各管一摊,一个写、一个审、一个改,会怎样?

这就是微软开源的 AutoGen 在做的事情。

一句话说清楚它是什么

AutoGen 是一个用 Python 搭建多智能体应用的框架。说白了,它让你可以创建多个各有分工的 AI 角色,让它们自己聊着把活儿干了。

比如你可以安排一个"写手"负责起草文案,再安排一个"编辑"负责提修改意见,写手改完再让编辑看,来回几轮直到编辑满意为止。整个过程不需要你盯着,它们自己就能协商完成。

这不是什么遥远的概念,装两个包就能跑起来。

怎么装?怎么用?

环境要求很简单:Python 3.10 以上就行。打开终端敲一行命令:

pip install -U "autogen-agentchat" "autogen-ext[openai]"

装好之后,最基础的用法是创建一个带工具的 AI 助手。举个例子,做一个能查天气的智能体:

import asyncio
from autogen_agentchat.agents import AssistantAgent
from autogen_ext.models.openai import OpenAIChatCompletionClient

async def get_weather(city: str) -> str:
    return f"{city}今天 25°C,晴。"

model_client = OpenAIChatCompletionClient(model="gpt-4o")
agent = AssistantAgent(
    name="weather_agent",
    model_client=model_client,
    tools=[get_weather],
)

async def main():
    result = await agent.run(task="北京今天天气怎么样?")
    print(result.messages[-1].content)

asyncio.run(main())

你定义一个普通的 Python 函数,把它扔进 tools 参数里,AI 就自动知道什么时候该调用它。函数签名和注释会被自动转成工具描述,不需要你手动写 JSON schema。

真正有意思的部分:多个 AI 协作

单个智能体只是开胃菜。AutoGen 真正的看家本领是让多个智能体组队工作。

框架内置了几种编排模式,最常用的是 RoundRobinGroupChat——大家轮流发言。你可以设一个终止条件,比如当某个角色说出"通过"这个词就停下来:

from autogen_agentchat.teams import RoundRobinGroupChat
from autogen_agentchat.conditions import TextMentionTermination

team = RoundRobinGroupChat(
    [writer_agent, reviewer_agent],
    termination_condition=TextMentionTermination("通过"),
)
result = await team.run(task="写一段产品介绍文案")

除了轮流发言,还有 SelectorGroupChat,由一个 LLM 来判断下一个该谁说话,适合角色更多、分工更复杂的场景。还有 Swarm 模式,智能体之间可以主动"转交"任务,像客服系统的分级处理一样。

它的架构长什么样

AutoGen 分三层。最底下的 Core 层负责消息传递和运行时调度,属于基础设施。中间的 AgentChat 层是大多数人打交道的地方,预设智能体、团队编排、终止条件都在这一层。最外面的 Extensions 层负责对接各种外部服务,比如 OpenAI 的模型、Docker 代码执行器,以及通过 MCP 协议接入 Jira、Slack 等工具。

这种分层的好处是,你可以只用上层 API 快速出原型,也可以深入底层做精细控制。

还有个不用写代码的选项

如果你不想写代码,AutoGen 还提供了一个叫 AutoGen Studio 的可视化工具。装好之后一行命令启动:

pip install -U autogenstudio
autogenstudio ui --port 8080

打开浏览器就能拖拖拽拽搭建多智能体工作流,适合做快速验证和演示。

适合什么场景

说实话,不是所有任务都需要多智能体。一个简单的问答,一个 AI 就够了,上多智能体反而是大炮打蚊子。

但有些场景确实适合:需要反复打磨的内容创作、多角度的分析研判、有明确流程的任务处理(比如先分类再路由再执行),以及需要调用多种外部工具协同完成的复杂工作流。

核心判断标准就一个:如果你发现自己在不断地把一个 AI 的输出复制粘贴给另一个 AI 让它接着处理,那多半可以用 AutoGen 把这个链路自动化掉。


AutoGen 当前稳定版本为 v0.7.5,项目地址:github.com/microsoft/autogen

手写一个无限画布 #1:坐标系的谎言

一个你每天都在做的操作

打开 Figma,或者启动蓝湖(Lanhu)查看标注,又或是打开 Lovart 开始绘图。

双指捏合,画布缩小了。两指一推,画布滑到了左边。点一下那个蓝色的矩形,选中了。

你每天都在做这三步。你从来没有觉得它们有什么值得思考的。

但我想请你回答一个问题:

这三步操作里,有几个坐标系在同时工作?

你大概会说:一个吧。就是画布的坐标系嘛,X 和 Y,所有东西都在里面。

不对。

答案是至少三个。而正是这个"至少三个",分开了"会用画布"和"理解画布"的两种人。


画布没有动过

我们来思考一个问题。

你在 Figma 里按住空格键拖动,画面跟着你的手指在移动。你管这叫"拖动画布"。

但请你换一个视角想想——

如果画布真的在动,画布上的那些矩形、文字、线条,它们的坐标变了吗?

答案是: 没有。

你给一个矩形定的位置是 (200, 300),无论你怎么拖、怎么缩放,这个矩形在画布世界里的坐标始终是 (200, 300)。没有任何东西移动了。

那到底什么在动?

你在动。

或者更准确地说——你的"摄像机"在动。

这不是一个比喻。这就是无限画布的字面原理。你的屏幕是一个取景框,画布世界是一个无限延伸的平面,你的每一次拖拽,不是在推动世界,而是在推动自己的视口。

如果你玩过超级马里奥 ——你对这件事不会陌生。马里奥往前跑的时候,屏幕跟着移动,但世界并没有向后走。世界始终在那里,移动的是摄像机。

无限画布的逻辑完全一样。

Coordinate System Animation

Google Maps 更直观。你在手机上滑动地图,你管它叫"移动地图",但地球显然没有动。你移动的是你的观测位置。

现在你可以理解为什么答案是"至少三个坐标系"了:

世界坐标系(World Space) :画布世界本身的坐标。一个矩形在 (200, 300),它就永远在那里,跟你的拖拽无关。这是物体存在的坐标。

屏幕坐标系(Screen Space) :你的显示器上的像素坐标。鼠标在屏幕上的 (500, 400),这是你手指触达的坐标。

局部坐标系(Local Space) :一个对象相对于它父级的坐标。一个文字节点在某个分组(Group)里面,它的位置是相对于那个分组的。这是对象相对存在的坐标。

三个坐标系,三套数字,每一次用户操作都要在它们之间做转换。

这才是无限画布做的事。


摄像机:一个被隐藏的核心概念

如果你做过 Canvas 白板开发,你可能写过类似这样的逻辑:

当用户拖拽时,你维护一对 offsetX / offsetY,然后在每帧渲染时,把所有元素的坐标都减去这个 offset 再画。

这就是一个摄像机。只不过你可能从来没有这么叫过它。

当用户缩放时,你维护一个 scale 变量,渲染时把所有坐标乘以这个 scale。

这也是摄像机的一部分——焦距。

把这些变量合在一起看,你有的是:

12345

Camerax: number       // 摄像机在世界中的 X 位置  y: number       // 摄像机在世界中的 Y 位置  zoom: number    // 缩放比例}

这三个数字,定义了"你在世界中站在哪里、看多大范围"。

可能你之前从没有把 offsetX / offsetY / scale 想象成一台摄像机。但一旦你这么理解了,很多事情会突然变得清晰。

缩放不是把对象放大,是把摄像机推近了。

这个区别不是语言游戏。当你"放大一个矩形"时,矩形的世界坐标和宽高没有变——(200, 300) 和 100×100 还是那些数字。改变的是摄像机的 zoom 值。当 zoom 从 1 变成 2,每个世界坐标映射到屏幕上的像素数翻倍了,所以你"看起来"矩形变大了。

这就是为什么缩放时所有元素保持相对位置不变——因为它们根本没动。动的是你看它们的方式。

如果你用 Fabric.js,你调用的 canvas.setViewportTransform() 本质上就在设置这台摄像机的参数。如果你用 Canvas 2D 原生 API,你调用的 ctx.setTransform() 或 ctx.translate() + ctx.scale() 也是在操纵这台摄像机。

你一直在用摄像机。只是没有人告诉过你。


坐标变换:整个系统的地基

有了摄像机的概念,一个核心问题浮现了:

用户点击屏幕上的 (500, 400),他到底点中了世界里的谁?

这是每一个画布系统都必须回答的问题。它的学名叫 Hit Testing(命中测试),但本质上,它是一个坐标变换问题。

从屏幕坐标到世界坐标的转换公式概念上很简单:

12

worldX = (screenX - camera.x) / camera.zoomworldY = (screenY - camera.y) / camera.zoom

屏幕上的点,减去摄像机的偏移量,再除以缩放比例,就得到了这个点在世界中的真实位置。

反过来也成立。你有一个世界坐标 (200, 300),想知道它在屏幕上画在哪里:

12

screenX = worldX * camera.zoom + camera.xscreenY = worldY * camera.zoom + camera.y

这两个公式是无限画布的心脏。  你做的每一件事——选中、拖动、缩放、对齐、吸附——都在使用它们的某种变体。

如果你用矩阵来表达,这就是一个仿射变换矩阵的正变换和逆变换。但关键不在于数学形式,而在于你是否意识到:

你的画布系统里,每一次用户输入和每一次渲染输出,中间都隔着一层坐标变换。  不理解它,你写的代码就是在黑暗中摸索;理解了它,所有后续的工程决策都有了一个统一的锚点。


这个模型解释一切

一旦你接受了"世界 + 摄像机"的理解模型,很多看似复杂的特性都变得顺理成章。

多人协作

Figma 的多人协同是怎么回事?多个用户在同一个画布上编辑,你能看到其他人的光标在你的屏幕上移动。

用摄像机模型来理解:世界只有一个,但每个用户有自己的摄像机。

用户 A 可能正在看世界的左上角,zoom 是 1.5;用户 B 正在看右下角,zoom 是 0.5。他们操作的是同一个世界里的同一批对象,但"看到的画面"完全不同——因为他们的摄像机位置和焦距不同。

要在你的屏幕上显示其他人的光标,你需要做一步坐标转换:

把对方的世界坐标位置,经过你自己的摄像机变换,映射到你的屏幕上。

这就是为什么当你缩放自己的画布时,其他人的光标也会跟着缩放——因为你改变了自己的摄像机参数,同一个世界坐标映射到屏幕上的位置变了。

缩放到指定位置(Zoom to Point)

你有没有注意过:在 Figma 里双指缩放时,画布是围绕你手指中心点缩放的,而不是围绕画布中心?

这不是简单地修改 zoom 值。如果你只改 zoom,画面会围绕摄像机的原点缩放,效果会很怪。正确的做法是:在改变 zoom 的同时,调整摄像机的 x 和 y,使得手指所在的那个世界坐标点在缩放前后映射到屏幕上的同一个像素。

这又是一个坐标变换问题。如果你没有摄像机模型,你很可能会写一大堆看起来能用但自己也说不清为什么的补偿代码。有了这个模型,逻辑是干净的。

图层(Layer)

你可能习惯了 CSS 的 z-index。在画布世界里,图层是另一件事。

画布中的图层不是 DOM 层级的概念。它是世界空间中的 Z 轴排序——哪个对象"在上面",哪个"在下面"。这个顺序是画布世界数据的一部分,跟渲染层级没有直接关系。

Canvas 2D 的渲染是画家算法(Painter's Algorithm):后画的覆盖先画的。所以图层顺序直接决定了你的绘制顺序。你改变了图层,不是在改变某个 CSS 属性——你在改变世界的一部分。

视口裁剪(Viewport Culling)

当你的画布上有 10 万个元素,但你的屏幕只能看到其中 200 个时,你不需要渲染全部 10 万个。

你需要做的是:用摄像机的位置和 zoom 计算出当前视口在世界空间中覆盖的矩形范围,然后只渲染落在这个范围内的元素。

这就是视口裁剪。又是一个纯坐标变换问题。没有摄像机模型,你甚至不知道从哪里开始思考这个优化。


你以为的 vs 实际的

让我把这些放在一起,做一个对比:

你以为的 实际上
拖动了画布 移动了摄像机
放大了元素 改变了摄像机焦距
画布是有边界的 世界是无限的,有限的是视口
图层是 CSS 概念 图层是世界空间的 Z 排序
点击选中了某个元素 屏幕坐标经坐标逆变换后做命中测试
多人协作是同步画面 多台摄像机观察同一个世界
缩放是视觉效果 缩放是摄像机的空间变换

如果你做过画布项目,你现在可以回头重新审视自己的代码。那些 offsetXoffsetYscaletranslateX ——它们不是零散的状态变量。它们是一台摄像机的参数。你的渲染函数不是在"画东西",它是在"拍摄世界"。

这不是一个更好的术语,而是一个更好的心智模型。  心智模型决定了你能优雅地解决多少问题,以及在多少问题前一脸茫然。


为什么这很重要

你可能会想:这就是个模型上的区别,代码还是那些代码啊。

不一样。

当你用 DOM 思维去做画布,你会不自觉地把每一个需求当成独立问题去"打补丁":这里加个 offset,那里乘个 scale,在某个角落补一个不知道为什么 work 的反向偏移。

但当你用"世界 + 摄像机"思维做画布,你有一个统一的框架可以推导:

  • • 需要做选中?→ 先把屏幕坐标变换到世界坐标,再做碰撞检测。
  • • 需要做缩放到点?→ 求解摄像机参数在变换前后的约束方程。
  • • 需要做多人协作?→ 不同摄像机看同一个世界,坐标变换是桥梁。
  • • 需要做视口裁剪?→ 用摄像机参数计算视口在世界中的矩形范围。

每一个问题都回到同一个地基。  这就是心智模型的威力——它不给你答案,但它给你一致的推理起点。

这也是为什么有些人做画布做了两年,代码里还全是看不懂的 magic number 和 workaround;而另一些人做了三个月,系统干净得像教科书。区别不在编码水平,在于脑子里有没有一个足够好的模型。

不理解坐标系统的人,写出的画布代码永远是在打补丁。


无限画布的一句话定义

写到这里,我可以给你一个你在别处看不到的定义:

无限画布 = 一个无限世界 + 一台摄像机 + 一套坐标变换规则

世界负责"有什么"——对象、位置、层级。摄像机负责"看什么"——偏移、缩放。坐标变换负责"如何翻译"——用户输入到世界位置,世界位置到屏幕像素。

这三个部分耦合得极紧,但职责完全不同。理解了这个结构,你才算理解了无限画布的第零步——在谈论渲染技术、交互系统、协同架构之前,你首先需要理解的那个东西。


下一篇

如果你接受了"画布世界 + 摄像机" 这个理解模型,下一个问题是:

谁来负责渲染这个世界?

下一篇,我们来聊无限画布的渲染技术。

深入React19任务调度器Scheduler

Scheduler = 最小堆 + MessageChannel + 时间片检查

它的目标只有一个:推进任务,但永远别饿死浏览器。

调度任务

React 19 最新 Scheduler 源码**(packages/scheduler)** 中,普通任务(非 Immediate,同步优先级之外的任务)的完整调度链:

unstable_scheduleCallback
  ↓
requestHostCallback
  ↓
schedulePerformWorkUntilDeadline
  ↓
performWorkUntilDeadline
  ↓
flushWork
  ↓
workLoop
  ↓
shouldYieldToHost
  ↓
advanceTimers

Scheduler 本质是一个“带时间片控制的最小堆 + 宏任务驱动循环”调度器。

一、调度的起点:unstable_scheduleCallback

源码位置(React 19):

/packages/scheduler/src/forks/Scheduler.js
let getCurrentTime = () => performance.now();

// 为所有任务维护了连个最小堆(每次从队列里面取出来的都是优先级最高(时间即将过期))
// timerQueue     // 延时任务队列(task.startTime > now) —— 按 startTime 排序(延迟最小的先看)
// taskQueue      // 立即可执行的任务(task.startTime <= now)—— 按 expirationTime 排序(最紧急的先执行)

// Timeout 对应的值
var IMMEDIATE_PRIORITY_TIMEOUT = -1;
var USER_BLOCKING_PRIORITY_TIMEOUT = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;
var IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt; // 1073741823

/**
 * 调度任务的入口
 * @param {*} priorityLevel 优先级等级
 * @param {*} callback 任务回调函数
 * @param {*} options { delay: number } 该对象有 delay 属性,表示要延迟的时间(决定 expirationTime)
 * @returns
 */
 function unstable_scheduleCallback(priorityLevel, callback, options) {
  // 获取当前的时间
  var currentTime = getCurrentTime();

  var startTime;
  // 设置起始时间 startTime:如果有延时 delay,起始时间需要添加上这个延时,否则起始时间就是当前时间
  if (typeof options === "object" && options !== null) {
    var delay = options.delay;
    if (typeof delay === "number" && delay > 0) {
      startTime = currentTime + delay;
    } else {
      startTime = currentTime;
    }
  } else {
    startTime = currentTime;
  }

  var timeout;
  // 根据传入的优先级等级来设置不同的 timeout
  switch (priorityLevel) {
    case ImmediatePriority:
      timeout = IMMEDIATE_PRIORITY_TIMEOUT; // -1
      break;
    case UserBlockingPriority:
      timeout = USER_BLOCKING_PRIORITY_TIMEOUT; // 250
      break;
    case IdlePriority:
      timeout = IDLE_PRIORITY_TIMEOUT; // 1073741823
      break;
    case LowPriority:
      timeout = LOW_PRIORITY_TIMEOUT; // 10000
      break;
    case NormalPriority:
    default:
      timeout = NORMAL_PRIORITY_TIMEOUT; // 5000
      break;
  }
  // 接下来就计算出过期时间
  // 只有 ImmediatePriority 任务 比当前时间要早,其他任务都会不同程度的延迟
  var expirationTime = startTime + timeout;

  // 创建一个新的任务
  var newTask = {
    id: taskIdCounter++, // 任务 id
    callback, // 执行任务回调函数 export type Callback = boolean => ?Callback;
    priorityLevel, // 任务的优先级
    startTime, // 任务开始时间
    expirationTime, // 任务的过期时间
    sortIndex: -1, // 用于小顶堆优先级排序,始终从任务队列中拿出最优先的任务
  };
  if (enableProfiling) {
    newTask.isQueued = false;
  }

  if (startTime > currentTime) {
    // 说明这是一个延时任务
    newTask.sortIndex = startTime;
    // 将该任务推入到 timerQueue 的任务队列中
    push(timerQueue, newTask);
    if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
      // 说明 taskQueue 里面的任务已经全部执行完毕,然后从 timerQueue 里面取出一个优先级最高的任务作为此时的 newTask
      if (isHostTimeoutScheduled) {
        cancelHostTimeout();
      } else {
        isHostTimeoutScheduled = true;
      }
      // 如果是延时任务,调用 requestHostTimeout 进行延时任务的调度
      requestHostTimeout(handleTimeout, startTime - currentTime);
    }
  } else {
    // 说明不是延时任务
    newTask.sortIndex = expirationTime; // 设置了 sortIndex 后,在任务队列里面进行一个排序
    push(taskQueue, newTask);
    if (enableProfiling) {
      markTaskStart(newTask, currentTime);
      newTask.isQueued = true;
    }
    // 最终调用 requestHostCallback 进行普通任务的调度
    if (!isHostCallbackScheduled && !isPerformingWork) {
      isHostCallbackScheduled = true;
      requestHostCallback();
    }
  }

  // 向外部返回任务
  return newTask;
}

它的职责是:

  1. 根据优先级计算 timeout
  2. 构造一个 Task 对象
  3. 放入任务到对应的最小堆
  4. 请求宿主开始调度(requestHostCallback 普通任务 / requestHostTimeout 延时任务)

任务执行时刻:

IMMEDIATE_PRIORITY_TIMEOUT -> currentTime -> USER_BLOCKING_PRIORITY_TIMEOUT -> IDLE_PRIORITY_TIMEOUT

二、普通任务调用 requestHostCallback

当普通任务进入 taskQueue 后,调用 requestHostCallback,然后调用 schedulePerformWorkUntilDeadline

/**
 * 
 * @param {*} callback 是在调用的时候传入的 flushWork
 * requestHostCallback 这个函数没有做什么事情,主要就是调用 schedulePerformWorkUntilDeadline
 */
 function requestHostCallback() {
  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true;
    schedulePerformWorkUntilDeadline();// 实例化 MessageChannel 进行后面的调度
  }
}

let schedulePerformWorkUntilDeadline; // undefined
if (typeof localSetImmediate === 'function') {
  // Node.js and old IE.
  schedulePerformWorkUntilDeadline = () => {
    localSetImmediate(performWorkUntilDeadline);
  };
} else if (typeof MessageChannel !== 'undefined') {
  // 大多数情况下,使用的是 MessageChannel
  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = performWorkUntilDeadline;
  schedulePerformWorkUntilDeadline = () => {
    port.postMessage(null);
  };
} else {
  // setTimeout 进行兜底
  schedulePerformWorkUntilDeadline = () => {
    localSetTimeout(performWorkUntilDeadline, 0);
  };
}

schedulePerformWorkUntilDeadline 根据不同的环境选择不同的生成宏任务的方式。大多数都是 MessageChannel :

  • 每一次调度推进
  • 都是一个新的宏任务
  • 浏览器中间有机会 paint

三、延时任务调用 requestHostTimeout & handleTimeout

React Scheduler 不直接使用 setTimeout,而是抽象成 HostConfig,可在不同环境实现。

requestHostTimeout(callback, ms) 这个函数会安排一个底层超时回调。

在浏览器环境下它一般等价于:

const timeoutID = setTimeout(callback, ms);

注意:这是 严格意义上的延时调度,用于把 timerQueue 的任务唤醒进 taskQueue。

requestHostTimeout 实际上就是调用 setTimoutout,然后在 setTimeout 中,调用传入的 handleTimeout。

注意:延时任务只需要“不会提前执行”,而不需要“精准执行”,所以这里使用了 setTimeout,setTimeout 只是负责“唤醒” Scheduler,不负责精度和优先级(expirationTime 控制)控制,鉴于负责延时和必须是宏任务的特性,这里使用 setTimeout 最合适。

React 的调度精度来自:MessageChannel + 时间片检查 + expirationTime 。

handleTimeout 是真正被 setTimeout 调用的函数:

function handleTimeout(currentTime) {
  // 遍历 timerQueue,将时间已经到了的延时任务放入到 taskQueue
  advanceTimers(currentTime);
  if (!isHostCallbackScheduled) {
    if (taskQueue.length > 0) {
      // 采用调度普通任务的方式进行调度
      requestHostCallback(flushWork);
    } else {
      // 如果任务仍然是延时,继续设置 HostTimeout
      const firstTimer = peek(timerQueue);
      if (firstTimer !== null) {
        const nextDelay = firstTimer.startTime - currentTime;
        requestHostTimeout(handleTimeout, nextDelay);
      }
    }
  }
}

四、performWorkUntilDeadline

这是 Scheduler 的“驱动心跳”。

let startTime = -1;
const performWorkUntilDeadline = () => {
  if (enableRequestPaint) {
    needsPaint = false;
  }
  if (isMessageLoopRunning) {
    const currentTime = getCurrentTime();
    // 这里的 startTime 并非 unstable_scheduleCallback 方法里面的 startTime
    // 而是一个全局变量,默认值为 -1
    // 用来测量任务的执行时间,从而能够知道主线程被阻塞了多久
    startTime = currentTime;
    let hasMoreWork = true;
    try {
      // flushWork 为任务中转,本质上内部继续调用 workLoop 判断任务执行情况
      hasMoreWork = flushWork(currentTime);
    } finally {
      if (hasMoreWork) {
        // 上面刚刚讲过的方法,根据不同的环境选择不同的生成宏任务的方式(MessageChannel)
        schedulePerformWorkUntilDeadline();
      } else {
        isMessageLoopRunning = false;
      }
    }
  }
};

它做三件事:

  1. 设置本次调度的时间起点
  2. 调用 flushWork
  3. 如果还有任务 → 再发一个 MessageChannel

五、flushWork 和 workLoop 执行任务循环

// flushWork 是个中转,它真正执行的是  workLoop
function flushWork(initialTime) {
  // ...
  // 各种开关、报错捕获...
  // 其实核心就是 workLoop
  return workLoop(initialTime);
}

// 不断从任务队列中取出任务执行
function workLoop(initialTime: number) {
  // initialTime 开始执行任务的时间
  let currentTime = initialTime;
  // advanceTimers 是用来遍历 timerQueue,判断是否有已经到期的任务
  // 如果有,将这个任务放入到 taskQueue
  advanceTimers(currentTime);
  currentTask = peek(taskQueue);
  while (currentTask !== null) {
    if (!enableAlwaysYieldScheduler) {
      if (currentTask.expirationTime > currentTime && shouldYieldToHost()) {
        // 任务没有过期,并且需要中断任务,归还主线程
        break;
      }
    }
    // 返回任务本身,致使任务之后可以接着继续执行
    const callback = currentTask.callback;
    if (typeof callback === 'function') {
      currentTask.callback = null;
      currentPriorityLevel = currentTask.priorityLevel;
      const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
      if (enableProfiling) {
        markTaskRun(currentTask, currentTime);
      }
      // 执行任务,其他判断都是做“阶段过程资料收集”,无需关注
      const continuationCallback = callback(didUserCallbackTimeout);
      currentTime = getCurrentTime();
      if (typeof continuationCallback === 'function') {
        currentTask.callback = continuationCallback;
        if (enableProfiling) {
          markTaskYield(currentTask, currentTime);
        }
        advanceTimers(currentTime);
        return true;
      } else {
        if (enableProfiling) {
          markTaskCompleted(currentTask, currentTime);
          currentTask.isQueued = false;
        }
        if (currentTask === peek(taskQueue)) {
          pop(taskQueue);
        }
        advanceTimers(currentTime);
      }
    } else {
      pop(taskQueue);
    }
    // 执行完,再从 taskQueue 中取出一个任务
    currentTask = peek(taskQueue);
    if (enableAlwaysYieldScheduler) {
      if (currentTask === null || currentTask.expirationTime > currentTime) {
        break;
      }
    }
  }
  // 如果任务不为空,那么还有更多的任务,hasMoreTask 为 true
  if (currentTask !== null) {
    return true;
  } else {
    // taskQueue 这个队列空了,那么我们就从 timerQueue 里面去看延时任务
    const firstTimer = peek(timerQueue);
    if (firstTimer !== null) {
      requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
    }
    // 没有进入上面的 if,说明 timerQueue 里面的任务也完了,返回 false,外部的 hasMoreWork 拿到的就也是 false
    return false;
  }
}

任务是否可以被打断?

shouldYieldToHost()

如果返回 true:

  • 当前宏任务结束
  • 重新发 MessageChannel
  • 浏览器可以渲染

六、shouldYieldToHost —— 时间片控制核心

源码核心:

function shouldYieldToHost() {
  const timeElapsed = getCurrentTime() - startTime;

  if (timeElapsed < frameInterval) {
    return false;
  }

  return true;
}

其中:

frameInterval = 5ms

注意:

React 不等 16ms 一整帧

而是:

每 5ms 检查一次

为什么?

  • 预留给浏览器 layout + paint
  • 预留给输入事件

七、advanceTimers —— 延迟任务晋升机制

每次 workLoop 结束后,会调用 advanceTimers(currentTime);

它会:

  • 检查 timerQueue
  • 把到时间的任务移动到 taskQueue
function advanceTimers(currentTime) {
  // 取出 startTime <= currentTime 的 timer
  let timer = peek(timerQueue);
  while (timer !== null && timer.startTime <= currentTime) {
    pop(timerQueue);
    timer.sortIndex = timer.expirationTime;
    push(taskQueue, timer);
    timer = peek(timerQueue);
  }
}

这保证:延迟任务不会饿死

八、Scheduler 与 Fiber 的关系

Scheduler 不知道 Fiber。

它只负责:“什么时候调用 callback”

而 callback 通常是:

performConcurrentWorkOnRoot

那里面才是:

  • beginWork
  • completeWork
  • commitRoot

九、任务拆分的思路来源

假设有这样一段源码:

unstable_scheduleCallback(NormalPriority, () => {
  heavyWork(); // 10ms
});

如果 heavyWork() 是同步执行 10ms:,Scheduler 无法中断它,因为 JS 是单线程的,函数执行过程中无法被抢占。

来看源码核心逻辑:

const continuation = callback();

if (typeof continuation === 'function') {
  currentTask.callback = continuation;
} else {
  pop(taskQueue);
}

如果 callback 返回一个函数,Scheduler 会把它当成“任务的下一段”。

举一个最小可理解示例:

// 假设这是一个“大”任务
function createBigTask(total) {
  let i = 0;

  function work() {
    while (i < total) {
      console.log(i++);
      
      if (shouldStopManually()) {
        return work; // 👈 返回自身,表示还有后续
      }
    }

    return null; // 完成
  }

  return work;
}

调度:

unstable_scheduleCallback(
  NormalPriority,
  createBigTask(1000)
);

执行:

workLoop()
  ↓
执行 work()
  ↓
执行到一部分
  ↓
返回 work (continuation)
  ↓
Scheduler 保存 callback = work
  ↓
shouldYieldToHost() 为 true
  ↓
break
  ↓
下一帧继续执行

在 React 里,被拆分的任务不是“普通函数”,而是:performConcurrentWorkOnRoot。

它内部会调用:workLoopConcurrent() 。

核心逻辑(简化版):

while (
  workInProgress !== null &&
  !shouldYield()
) {
  performUnitOfWork(workInProgress);
}

performUnitOfWork 只做一个 Fiber 节点:

function performUnitOfWork(unit) {
  const next = beginWork(unit);

  if (next === null) {
    completeUnitOfWork(unit);
  }

  return next;
}

也就是说:React 把整棵 Fiber 树拆成一个个“节点单位”/“任务切片”。

更形象的可以表示为:

一个组件树:

App
 ├─ Header
 ├─ Content
 │    ├─ List
 │    └─ Sidebar
 └─ Footer

会被拆成:

performUnitOfWork(App)
performUnitOfWork(Header)
performUnitOfWork(Content)
performUnitOfWork(List)
performUnitOfWork(Sidebar)
performUnitOfWork(Footer)

每个 Fiber 节点就是一个小任务。

回到 Scheduler 这段判断:

if (
  currentTask.expirationTime > currentTime &&
  shouldYieldToHost()
) {
  break;
}

当时间片用完时:break 。

此时:

  • workLoop 停止
  • 任务还没完成
  • currentTask.callback 仍然是 performConcurrentWorkOnRoot

下一帧:

MessageChannel
  ↓
performWorkUntilDeadline
  ↓
flushWork
  ↓
workLoop
  ↓
继续执行 performConcurrentWorkOnRoot

延时任务并不会立刻进入 taskQueue,而是先进入 timerQueue,等待被“唤醒”然后再调度。

十、完整调度流程

下面是一个完整的流程图:

unstable_scheduleCallback()
            ↓
            是否延时? (startTime > now)
           / \
        是     否
         ↓      ↓
  push(timerQueue) push(taskQueue)
         ↓      ↓
requestHostTimeout(requestHostCallback)
         ↓      ↓
       等待 Timer   requestHostCallback
         ↓              ↓
timeout 到时           MessageChannel
  ↓                   ↓
handleTimeout       performWorkUntilDeadline
  ↓                   ↓
advanceTimers         flushWork
  ↓                  ↓
timerQueue → taskQueue
                    ↓
                 workLoop
                    ↓
            shouldYieldToHost?
                    ↓
       是                      否
 notifyBrowserPaint          执行 Callback
                             ↓
                       callback 返回 continuation?
                             ↓
            是                               否
    更新 Task.callback                      pop(taskQueue)
                             ↓
                     可能再次调度

示例理解

示例 1:普通任务(正常进入队列)

unstable_scheduleCallback(
  NormalPriority,
  () => console.log("normal")
);

步骤:

  1. now <= startTime (0 delay)
  2. 放入 taskQueue
  3. requestHostCallback(flushWork)
  4. MessageChannel 触发 performWorkUntilDeadline
  5. flushWork → workLoop
  6. 执行 task
  7. taskQueue 空 → 调度结束

示例 2:延时任务(未来才执行)

unstable_scheduleCallback(
  NormalPriority,
  () => console.log("delayed"),
  { delay: 1000 }
);

步骤:

t=0
↓
startTime = now + 1000
push(timerQueue)
requestHostTimeout(handleTimeout, 1000)

1s 后:

handleTimeout 被 setTimeout 调用
↓
advanceTimers
  → 取出 timerQueue 里所有 startTime <= 1s 的任务
  → 推到 taskQueue
taskQueue 变非空
↓
requestHostCallback(flushWork)
↓
MessageChannel 回调
↓
flushWork → workLoop → task 执行

示例 3:延时任务到了中间又被打断

unstable_scheduleCallback(
  NormalPriority,
  step1,
  { delay: 1000 }
);

function step1() {
  console.log("step1");
  return step2;
}

function step2() {
  console.log("step2");
}

执行过程:

t=0 → timerQueue 入队
t=1000  → handleTimeout
  → advanceTimers → taskQueue
↓
MessageChannel
↓
workLoop 执行 step1 延时任务
↓
step1 返回 continuation step2 延时任务
↓
此时
  workLoop 检查 shouldYieldToHost
  如果 time片到 → 中断
  → callback (continuation) 留在 taskQueue
  → requestHostCallback 执行普通任务
下次继续执行 step2 延时任务

设计哲学

① 延时任务不能抢占浏览器渲染

如果立即调度:

setTimeout(() => {}), Promise.then(...)

React 可能会阻塞页面渲染

所以必须分开:

先 timerQueue 等待
再 taskQueue 才跑

② 延时任务按照优先级

即使 delay 到了:

expirationTime = startTime + timeout

如果更高优先级任务进入 taskQueue:

React 会先执行高优先级的。

delay 控制什么时候进入 taskQueue,而 expirationTime 控制进入 taskQueue 之后的优先级排序。

③ extendable 继续推进任务

一个任务返回 continuation 时:

currentTask.callback = continuation;

并且还可能继续 schedule。

Vue3 + Pinia 状态管理,从入门到模块化

前言

Pinia 是 Vue3 标配状态管理。简单、轻量、易用。

一、创建 Store

// store/modules/user.js
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({
    token: '',
    userInfo: null
  }),

  actions: {
    setToken(token) {
      this.token = token
    },
    setUserInfo(info) {
      this.userInfo = info
    }
  }
})

二、在组件中使用

<script setup>
import { useUserStore } from '@/store/modules/user'
const userStore = useUserStore()

// 获取数据
console.log(userStore.token)

// 修改数据
userStore.setToken('xxxx')
</script>

三、Getters 计算

getters: {
  isLogin: (state) => !!state.token
}

四、数据持久化(常用)

安装插件:

npm install pinia-plugin-persistedstate

在 store 中启用:

export const useUserStore = defineStore('user', {
  // ...
  persist: true
})

数据自动存在 localStorage。

五、模块化最佳实践

  • store/modules/xxx.js 按业务拆分
  • 命名:useXxxStore
  • 页面直接引入使用

为什么我的 Auth Token 藏在了 Network 面板的 Doc 里?

最近在调试一个第三方登录功能时,我遇到了一个奇怪的现象:明明看到页面跳转了,用户也成功登录了,但我在 Network 面板里怎么都找不到那个关键的 auth code。习惯性地勾选了 Fetch/XHR 过滤器,翻遍了所有请求,就是没有🤔。

直到我取消过滤,切换到 Doc 类型,才在 URL 参数里看到了 ?code=abc123&state=xyz

这次经历让我开始思考:Network 面板里的 Doc 到底是什么?为什么有些认证信息会出现在这里而不是 Fetch/XHR 里?这背后有什么机制?

问题的起源

一次真实的调试经历

场景是这样的:我在接入 GitHub OAuth 登录,流程看起来很顺利——用户点击"使用 GitHub 登录",跳转到 GitHub 授权页面,授权后跳回我的应用。但问题来了,我需要在回调中获取 GitHub 返回的 authorization code,然后用这个 code 去换取 access token。

按照惯例,我打开 Chrome DevTools,勾选 Network 面板的 Fetch/XHR 过滤器,准备查看 API 请求。结果——什么都没有。没有看到任何包含 code 参数的请求。

困惑了好一会儿,我才想起取消过滤,查看所有类型的请求。这时我注意到有个 Type 为 document 的请求,点开一看,Request URL 是:

https://myapp.com/callback?code=gho_xxxxxxxxxxxx&state=random_string

原来 code 一直在这里!只是它不是通过 Fetch/XHR 发送的,而是作为页面跳转(重定向)的一部分。

这背后的几个疑问

这次经历让我产生了几个问题:

  1. Network 面板里的 Doc 是什么?和 Fetch/XHR 有什么区别?
  2. 为什么 OAuth 的认证信息会出现在 Doc 请求里?
  3. 什么时候我应该去查看 Doc 类型的请求?
  4. Network 面板里其他的过滤类型都代表什么?

接下来,让我们一个个搞清楚。

认识 Network 面板中的请求分类

什么是 Doc 请求?

Doc(Document)请求,指的是 HTML 文档请求,也就是浏览器加载的页面本身。

这个定义听起来有点抽象,我们用代码来看什么情况下会产生 Doc 请求:

// 环境: 浏览器
// 场景: 各种触发 Doc 请求的方式

// 1. 直接在地址栏输入 URL
// https://example.com
// → Type: document

// 2. 点击链接跳转
const link = document.createElement('a');
link.href = '/dashboard';
link.click();
// → Type:document

// 3. JavaScript 页面跳转
window.location.href = '/dashboard';
// → Type:document

// 4. 表单提交(非 AJAX)
const form = document.createElement('form');
form.action = '/login';
form.method = 'POST';
form.submit();
// → Type:document

// 5. 服务端 HTTP 重定向
// Server Response:
// HTTP/1.1 302 Found
// Location:/callback?code=xxx
// → 浏览器自动发起新的 document 请求

相对的,下面这些操作不会产生 Doc 请求:

// 环境: 浏览器
// 场景: 这些是 Fetch/XHR 请求,不是 Doc

// 使用 fetch API
fetch('/api/user')
  .then(res => res.json())
// → Type: fetch

// 使用 XMLHttpRequest
const xhr = new XMLHttpRequest();
xhr.open('GET''/api/data');
xhr.send();
// → Type: xhr

// 使用 axios 等库
axios.get('/api/posts');
// → Type: xhr

Doc 请求的关键特征:

  • ✅ 会触发页面导航(浏览器地址栏的 URL 会改变)
  • ✅ 浏览器会解析返回的 HTML 并渲染页面
  • ✅ 可能伴随着 Cookie 的设置和自动携带
  • ✅ 会刷新整个页面(除非是 iframe)

Network 面板的过滤类型全解

为了更清楚地理解 Doc 在整个 Network 面板中的位置,我整理了一个对比表:

类型 含义 何时查看 典型例子
All 所有请求 排查复杂问题,需要看完整链路 -
Doc HTML 文档 页面跳转、重定向、认证回调 登录重定向、OAuth 回调
Fetch/XHR AJAX 请求 API 调用、异步数据加载 fetch('/api/user')
JS JavaScript 文件 脚本加载问题、404 错误 <script src="app.js">
CSS 样式表 样式未生效、加载失败 <link href="style.css">
Img 图片 图片显示异常、加载慢 <img src="photo.jpg">
Media 音视频 媒体播放问题 <video src="movie.mp4">
Font 字体文件 字体显示异常、图标不显示 @font-face 引用的字体
WS WebSocket 实时通信连接问题 new WebSocket(url)

一个简单的记忆方法:

  • Doc = 页面本身(会导致页面刷新或跳转)
  • Fetch/XHR = 页面背后的数据请求(页面不刷新)
  • 其他类型 = 页面加载需要的各种资源

什么时候需要查看 Doc?

根据我的经验,以下场景必须查看 Doc 类型的请求:

1. 调试页面跳转流程

用户登录 → 重定向到首页 → 重定向到之前访问的页面

每一次重定向都是一个 Doc 请求,需要追踪完整链路。

2. 排查认证问题

  • OAuth/SAML 等第三方登录的回调
  • Cookie 是否正确设置(查看 Response Headers 的 Set-Cookie)
  • URL 参数中的临时 token 或 code

3. 追踪 HTTP 重定向链路

  • 服务端返回 301/302/303/307/308 状态码
  • 需要查看 Location 头部,了解重定向目标
  • 排查重定向循环问题

4. 分析页面加载性能

  • 首次加载时间(TTFB、DOM 解析时间)
  • 服务端渲染(SSR)的响应时间

调试技巧:

在 Chrome DevTools 的 Network 面板中:

  1. ✅ 勾选 "Preserve log"(保留日志)

    • 页面跳转后不会清空请求记录
    • 可以看到完整的重定向链路
  2. ✅ 取消过滤或选择 "All"

    • 看到所有类型的请求
    • 不会漏掉关键信息
  3. ✅ 关注 Status 列的 3xx 状态码

    • 302 Found, 301 Moved Permanently 等
    • 这些都是重定向请求

为什么需要 Doc 分类?

浏览器的两种数据获取方式

理解 Doc 和 Fetch/XHR 的区别,关键在于理解浏览器获取数据的两种不同方式。

方式 1:页面导航(Doc 请求)

这是浏览器的原生机制,从 Web 诞生之初就存在:

<!-- 环境: 浏览器 -->
<!-- 场景: 传统的页面导航 -->

<!-- 点击链接 -->
<a href="/products">查看产品</a>

<!-- 表单提交 -->
<form action="/login" method="POST">
  <input type="text" name="username">
  <input type="password" name="password">
  <button type="submit">登录</button>
</form>

特点:

  • ✅ 浏览器自动处理 Cookie、缓存、重定向
  • ✅ 不需要写 JavaScript 代码
  • ✅ 天然支持跨域(不受 CORS 限制)
  • ❌ 会刷新整个页面,用户体验较差

方式 2:异步请求(Fetch/XHR 请求)

这是 AJAX 时代引入的技术,由 JavaScript 控制:

// 环境: 浏览器
// 场景: 现代单页应用的数据获取

// 使用 fetch API
async function login(username, password) {
  const response = await fetch('/api/login', {
    method: 'POST'headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ username, password })
  });
  
  const data = await response.json();
  return data.token;
}

// 后续请求手动携带 token
async function getUserInfo(token) {
  const response = await fetch('/api/user', {
    headers: {
      'Authorization': `Bearer ${token}`
    }
  });
  
  return response.json();
}

特点:

  • ✅ 无需刷新页面,用户体验好
  • ✅ 完全由 JavaScript 控制,灵活性高
  • ❌ 需要手动处理认证信息(token)
  • ❌ 受 CORS 限制,跨域需要服务端配合

为什么要区分这两种方式?

因为它们的调试方法安全模型适用场景都不同:

特性 Doc(页面导航) Fetch/XHR(异步请求)
触发方式 链接点击、表单提交、重定向 JavaScript 代码
页面刷新
Cookie 携带 自动 需配置 credentials
跨域限制 无(但有其他安全机制) 严格的 CORS 检查
调试位置 Network → Doc Network → Fetch/XHR
适用场景 传统 Web、SSO、OAuth 单页应用、REST API

真实案例:OAuth 认证为什么用 Doc?

现在让我们回到文章开头的问题:为什么 OAuth 的 auth code 会出现在 Doc 请求里?

让我用一个完整的 OAuth 流程来说明:

sequenceDiagram
    participant User as 用户浏览器
    participant App as 你的应用
    participant GitHub as GitHub

    User->>App: 1. 点击"Login with GitHub"
    Note over User,App: Fetch/XHR: GET /api/auth/github
    App->>User: 2. 返回授权 URL
    
    User->>GitHub: 3. 重定向到 GitHub
    Note over User,GitHub: Doc 请求: 页面跳转
    GitHub->>User: 4. 返回登录页面 HTML
    
    User->>GitHub: 5. 用户输入账号密码
    GitHub->>User: 6. 302 重定向回你的应用
    Note over User,App: Doc 请求: HTTP 重定向
    
    User->>App: 7. GET /callback?code=xxx&state=yyy
    Note over User,App: ⭐ code 在这个 Doc 请求的 URL 里
    
    App->>GitHub: 8. 用 code 换 token(服务端)
    Note over App,GitHub: 这个请求不在浏览器里
    GitHub->>App: 9. 返回 access_token

关键时刻分解:

步骤 3:跳转到 GitHub(Doc 请求)

// 环境: 浏览器
// 场景: 用户点击登录按钮后

// 前端构造 GitHub 授权 URL
const authUrl = 'https://github.com/login/oauth/authorize?' + 
  'client_id=your_client_id&' +
  'redirect_uri=https://yourapp.com/callback&' +
  'state=random_string';

// 页面跳转(产生 Doc 请求)
window.location.href = authUrl;

// Network 面板会看到:
// Type: document
// URL: https://github.com/login/oauth/authorize?...
// Status: 200

步骤 6-7:GitHub 重定向回你的应用(Doc 请求)

# GitHub 服务器的响应
HTTP/1.1 302 Found
Location: https://yourapp.com/callback?code=gho_xxxx&state=random_string

# 浏览器自动发起新的请求
GET /callback?code=gho_xxxx&state=random_string HTTP/1.1
Host: yourapp.com

# Network 面板会看到:
# Type: document
# URL: https://yourapp.com/callback?code=gho_xxxx&state=random_string
# Status: 200

这就是你在 Doc 里找到 auth code 的原因!

为什么 OAuth 必须用重定向(Doc)?

可能你会想:为什么不用 Fetch/XHR 来实现 OAuth?这样就不用刷新页面了。

让我解释一下为什么这样做不通:

原因 1:安全性——用户密码不能经过第三方应用

问题情境: 用户(你)想让你的应用(比如 Notion)访问你的 Google Drive

❌ 错误做法:

Notion 让你在 Notion 页面输入 Google 密码

→ Notion 拿到了你的 Google 密码

→ 风险:Notion 可以随意访问你的 Google 账户

✅ 正确做法(OAuth):

Notion 把你重定向到 Google 登录页面

→ 你直接在 Google 页面输入密码

→ Notion 永远拿不到你的密码

→ Google 只给 Notion 一个有限权限的 token

重定向保证了用户直接在资源提供方(Google)的页面输入密码,密码不会经过第三方应用。

原因 2:跨域限制——CORS 会阻止 AJAX 请求

// 环境: 浏览器
// 场景: 如果尝试用 AJAX 请求 GitHub 授权页面

// ❌ 这样做不行
fetch('https://github.com/login/oauth/authorize?...')
  .then(res => res.text())
  .then(html => {
    // 想法:拿到 GitHub 登录页面的 HTML,显示在我的页面里
  });

// 会遇到的问题:
// 1. CORS 错误:GitHub 不允许你的域名跨域请求
// 2. 即使能拿到 HTML,用户在你的页面输入密码也不安全
// 3. 无法获取 GitHub 的 Cookie,登录状态无法维护

原因 3:浏览器的自动行为——重定向无需编写代码

# HTTP 重定向是浏览器的标准功能
# 服务器只需要返回一个响应头:

HTTP/1.1 302 Found
Location: https://yourapp.com/callback?code=xxx

# 浏览器会自动:
# 1. 提取 Location 头的 URL
# 2. 发起新的请求(Doc 请求)
# 3. 更新地址栏 URL
# 4. 渲染新页面

# 不需要写任何 JavaScript

Cookie 认证为什么也在 Doc 里?

除了 OAuth,传统的 Cookie 认证也主要依赖 Doc 请求。让我们看一个例子:

// 环境: 浏览器
// 场景: 传统的表单登录

// HTML 表单
/*
<form action="/login" method="POST">
  <input type="text" name="username">
  <input type="password" name="password">
  <button type="submit">登录</button>
</form>
*/

// 用户提交表单时发生的事情:

// 1. 浏览器发送 Doc 请求
// POST /login HTTP/1.1
// Content-Type: application/x-www-form-urlencoded
// 
// username=alice&password=secret123

// 2. 服务器验证成功,返回重定向 + Set-Cookie
// HTTP/1.1 302 Found
// Set-Cookie: session_id=abc123xyz; HttpOnly; Secure; SameSite=Strict
// Location: /dashboard

// 3. 浏览器自动:
//    - 保存 Cookie
//    - 发起新的 Doc 请求到 /dashboard
//    - 自动携带 Cookie

// GET /dashboard HTTP/1.1
// Cookie: session_id=abc123xyz

为什么是 Doc 请求?

  1. Set-Cookie 在响应头中 → 只有 Doc 请求会自动处理 Set-Cookie
  2. Cookie 自动携带 → Doc 请求会自动带上同域的 Cookie
  3. 表单提交 → 传统 HTML form 产生的就是 Doc 请求

对比现代方式(Token 认证) :

// 环境: 浏览器
// 场景: 现代单页应用的 Token 认证

// 1. 用户登录(Fetch/XHR 请求)
async function login(username, password) {
  const response = await fetch('/api/login', {
    method: 'POST'headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username, password })
  });
  
  const data = await response.json();
  // { access_token: "eyJhbG...", refresh_token: "..." }
  
  // 手动存储 token
  localStorage.setItem('access_token', data.access_token);
}

// 2. 后续请求手动携带 token(Fetch/XHR 请求)
async function getUserInfo() {
  const token = localStorage.getItem('access_token');
  
  const response = await fetch('/api/user', {
    headers: {
      'Authorization': `Bearer ${token}`
    }
  });
  
  return response.json();
}

两种方式对比:

特性 Cookie + Doc Token + Fetch/XHR
请求类型 Doc(页面跳转) Fetch/XHR(异步)
认证信息位置 Cookie(Response Headers) JSON 响应体
携带方式 浏览器自动 手动添加到 Headers
用户体验 页面刷新 无刷新,流畅
调试位置 Doc 请求 Fetch/XHR 请求
典型应用 传统 Web 应用、企业内部系统 单页应用、移动 App API

实战调试技巧

案例:找不到 OAuth 的 auth code

让我用实际的调试步骤演示一遍:

问题场景:

  • 接入 GitHub OAuth 登录
  • 用户点击登录,跳转到 GitHub,授权后跳回应用
  • 需要获取 URL 中的 code 参数

错误的调试方法:

  1. 打开 DevTools → Network 面板
  2. 勾选 Fetch/XHR 过滤器
  3. 刷新页面,点击登录
  4. 找不到包含 code 参数的请求 ❌

正确的调试步骤:

✅ Step 1: 取消所有过滤,选择 "All" → 看到所有类型的请求

✅ Step 2: 勾选 "Preserve log"(保留日志) → 防止页面跳转后记录被清空

✅ Step 3: 点击登录,完成授权流程

✅ Step 4: 在 Network 面板中,找 Type 为 "document" 的请求 → 按时间顺序,找最后几个 Doc 请求

✅ Step 5: 点击 Doc 请求,查看详情 → Request URL: yourapp.com/callback?co… → code 就在这里!

✅ Step 6: 查看 Headers 标签

→ Request Headers 可以看到 Cookie、Referer

→ Response Headers 可以看到 Set-Cookie

在 Console 中提取 code:

// 环境: 浏览器 Console
// 场景: 在回调页面提取 URL 参数

// 方法 1: 使用 URLSearchParams
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');

console.log('Auth code:', code);
console.log('State:', state);

// 方法 2: 手动解析(不推荐,但可以理解原理)
const queryString = window.location.search; // "?code=xxx&state=yyy"
const pairs = queryString.substring(1).split('&');

const result = {};
pairs.forEach(pair => {
  const [key, value] = pair.split('=');
  result[key] = decodeURIComponent(value);
});

console.log(result);
// { code: "gho_xxxx", state: "yyyy" }

调试清单

当你需要排查认证或重定向问题时,参考这个清单:

查看 Doc 请求时,重点关注:

位置 关键信息 用途
Request URL URL 参数 ?code=xxx&state=yyy OAuth code、查询参数
Status 3xx 状态码(301/302/303) 识别重定向链路
Request Headers Cookie 字段 检查认证信息是否携带
Response Headers Set-Cookie 字段 检查 Cookie 是否正确设置
Response Headers Location 字段 查看重定向目标地址

常见问题排查:

  1. ❓ 为什么看不到某些请求?

    • → 检查是否勾选了 "Preserve log"
    • → 取消类型过滤,选择 "All"
  2. ❓ 为什么 Cookie 没有携带?

    • → 检查 Cookie 的 Domain 是否匹配
    • → 检查 Cookie 的 Path 是否正确
    • → 检查 SameSite 属性(Strict/Lax/None)
  3. ❓ 为什么一直重定向循环?

    • → 启用 "Preserve log",查看完整的重定向链
    • → 找出循环的起点和终点
    • → 检查服务端的重定向逻辑
  4. ❓ 为什么 OAuth code 参数没有?

    • → 检查 state 参数是否匹配(CSRF 防护)
    • → 查看 GitHub 的错误提示(可能在 URL 参数里)
    • → 确认 redirect_uri 配置是否正确

Chrome DevTools 实用技巧

1. 复制请求为 cURL

# 在 Network 面板中:
# 1. 右键请求
# 2. Copy → Copy as cURL

# 得到类似这样的命令:
curl 'https://yourapp.com/callback?code=gho_xxxx&state=yyyy' \
  -H 'Accept: text/html' \
  -H 'Cookie: session_id=abc123' \
  -H 'Referer: https://github.com/login'

# 可以在终端中重放这个请求

2. 保存网络日志(HAR 文件)

右键 Network 面板 → Save all as HAR with content

用途:
- 保存完整的请求/响应记录
- 可以导入到其他工具分析
- 分享给同事协助调试

⚠️ 注意:HAR 文件包含敏感信息(Cookie、Token),不要随意分享

3. 过滤和搜索

在 Network 面板的 Filter 输入框中:

# 按域名过滤
domain:github.com

# 按状态码过滤
status-code:302

# 按请求方法过滤
method:POST

# 按资源大小过滤
larger-than:1M

# 组合使用
domain:api.example.com status-code:200

小结

通过这次对 Network 面板 Doc 类型的探索,我理清了几个关键点:

核心要点

1. Doc 是什么?

  • HTML 文档请求,即浏览器加载的页面本身
  • 所有触发页面导航的操作(链接点击、表单提交、重定向)都会产生 Doc 请求
  • 会刷新页面,会自动处理 Cookie 和重定向

2. 什么时候需要看 Doc?

  • 调试页面跳转重定向流程
  • 排查认证问题(OAuth 回调、Cookie 设置)
  • 追踪 URL 参数中的临时信息(code、state、token)
  • 分析完整的请求链路(配合 Preserve log)

3. 为什么需要 Doc 分类?

  • 浏览器有两种数据获取方式:页面导航(Doc)和异步请求(Fetch/XHR)
  • OAuth/SSO 等认证流程必须用重定向(安全性 + 跨域限制)
  • Cookie 认证依赖浏览器的自动行为(自动携带、自动处理 Set-Cookie)
  • 不同的机制需要不同的调试方法

4. Network 分类速查

  • Doc → 页面跳转、重定向、认证回调
  • Fetch/XHR → API 调用、异步数据加载
  • JS/CSS/Img → 页面资源加载
  • WS → WebSocket 实时通信

一个类比帮助记忆

把 Network 面板想象成一个物流追踪系统:

  • Doc = 你本人去取件(需要走到快递点,拿到包裹后回家)
  • Fetch/XHR = 快递送上门(你在家里,东西直接送到)
  • JS/CSS/Img = 包裹里的物品(笔记本、衣服、配件等)

当你需要亲自去某个地方完成某件事(比如银行签字、OAuth 授权),就是 Doc 请求。当你只需要获取数据(API 调用),就是 Fetch/XHR 请求。

一个调试口诀

看不到数据?先别慌

取消过滤查 All 类型

重定向认证看 Doc

API 数据看 XHR

保留日志 Preserve log

完整链路不会漏

参考资料

从原子CSS到TailwindCSS:现代前端样式解决方案全解析

从原子CSS到TailwindCSS:现代前端样式解决方案全解析

引言

在长期的前端开发中,我们经常面临这样的困扰:

  • 写了一个按钮样式,想在另一个地方复用,却发现类名冲突、样式覆盖难以维护。
  • 为了一个细微的样式调整,不得不新建一个CSS类,导致CSS文件迅速膨胀。
  • 在HTML和CSS文件之间来回切换,打断开发心流。
  • 响应式设计需要写多套媒体查询,代码臃肿。

直到我遇到了原子CSS(Atomic CSS)和它的集大成者——TailwindCSS,这些问题迎刃而解。本文将结合代码实例,带你从零开始,循序渐进地掌握TailwindCSS,理解它的设计哲学,并能在实际项目中熟练运用。

第一章 从传统CSS到原子CSS——思想演变

1.1 传统CSS的痛点

回顾我们早期的写法(参考 1.html 中的注释部分):

<style>
.primary-btn {
  padding: 8px 16px;
  background: blue;
  color: white;
  border-radius: 6px;
}
.default-btn {
  padding: 8px 16px;
  background: #ccc;
  color: #000;
  border-radius: 6px;
}
</style>

<button class="primary-btn">提交</button>
<button class="default-btn">默认</button>

效果图

image.png 每个类都包含了大量的样式规则,虽然可以工作,但存在明显的缺点:

  • 样式冗余.primary-btn.default-btn 都定义了相同的 paddingborder-radius,重复代码。
  • 难以复用:假如我想实现一个带圆角的卡片,无法直接使用 .primary-btn,必须新建类。
  • 命名困难:类名需要反映用途,随着项目变大,命名变得困难且容易冲突。

1.2 面向对象的CSS(OOCSS)思想

OOCSS 提倡将可复用的样式拆分成独立的“基类”,再通过组合的方式实现具体样式(参考 1.html 中的改进部分):

<style>
.btn {
  padding: 8px 16px;
  border-radius: 6px;
  cursor: pointer;
}
.btn-primary {
  background: blue;
  color: white;
}
.btn-default {
  background: #ccc;
  color: #000;
}
</style>

<button class="btn btn-primary">提交</button>
<button class="btn btn-default">默认</button>

效果图

image.png 这里 .btn 封装了按钮的基础样式(内边距、圆角、指针),.btn-primary.btn-default 只负责颜色主题的变化。这种“组合类”的方式,就是原子CSS的雏形。

1.3 原子CSS的诞生

原子CSS将每一个独立的样式属性(如 padding: 8px 16pxcolor: white)都拆分成一个单独的类,开发者通过组合这些类来构建界面。例如:

<button class="p-2 bg-blue-500 text-white rounded">提交</button>

这就是TailwindCSS的写法。它的优点显而易见:

  • 高度复用:所有类都是单一职责,可以在任何地方组合。
  • 无需命名:不用再苦思冥想类名,直接用功能类描述样式。
  • 可预测:类名和样式一一对应,没有副作用。
  • 易于维护:修改样式只需调整HTML中的类名,无需修改CSS文件。

第二章 快速搭建TailwindCSS开发环境

TailwindCSS 可以集成到任何前端项目中。这里我们以 Vite + React 为例,演示如何搭建环境。

2.1 创建项目

npm create vite@latest tailwind-demo -- --template react
cd tailwind-demo
npm install

2.2 安装TailwindCSS及相关插件

根据官方文档,我们需要安装 tailwindcss@tailwindcss/vite 插件以及 postcss(Vite 已内置支持):

npm install tailwindcss @tailwindcss/vite

2.3 配置Vite插件

修改 vite.config.js(参考你提供的文件):

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'

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

2.4 引入TailwindCSS

在项目的主CSS文件(如 index.css)中,只需要一行代码:

@import 'tailwindcss';

TailwindCSS 会自动注入所有基础样式和工具类。

2.5 验证环境

修改 App.jsx,写入一些Tailwind类:

export default function App() {
  return (
    <div className="text-center p-4 text-blue-600">
      Hello TailwindCSS!
    </div>
  )
}

运行 npm run dev,如果看到蓝色居中文字,说明环境搭建成功。

效果图

image.png

第三章 基础实用工具类(Utilities)

TailwindCSS 提供了数千个实用类,覆盖了 CSS 的方方面面。我们通过一个按钮和卡片示例来快速掌握最常用的类。

3.1 盒模型与排版

参考 我的代码中App2.jsx 中的按钮:

<button className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
  提交
</button>
  • 内边距px-4 表示左右内边距为 1rem(默认单位),py-2 表示上下内边距为 0.5rem。
  • 背景色bg-blue-600 使用预定义的蓝色色阶。
  • 文字颜色text-white 白色文字。
  • 圆角rounded-md 中等圆角。
  • 悬停效果hover:bg-blue-700 表示鼠标悬停时背景色变深。

3.2 字体与尺寸

另一个按钮示例:

<button className="px-4 py-2 bg-gray-300 text-black rounded-md hover:bg-gray-400">
  默认
</button>
  • bg-gray-300text-black 控制背景和文字颜色。
  • 字体大小可以通过 text-smtext-lg 等控制,权重用 font-bold

3.3 卡片组件演示

再看一个文章卡片(来自 App2.jsx 中的 ArticleCard):

const ArticleCard = () => {
  return (
    <div className="p-4 bg-white rounded-xl shadow hover:shadow-lg transition">
      <h2 className="text-lg font-bold">Tailwindcss</h2>
      <p className="text-gray-500 mt-2">
        用utility class快速构建UI
      </p>
    </div>
  )
}
  • shadow 添加默认阴影,hover:shadow-lg 悬停时阴影变大,transition 让变化平滑。
  • mt-2 添加上边距。
  • 整体卡片通过组合类快速实现,完全不需要写一行自定义CSS。

效果图

image.png

第四章 布局利器——Flexbox与Grid

Tailwind 提供了完备的 Flexbox 和 Grid 工具类,可以轻松构建各种布局。

4.1 Flex 基础

App.jsx 中的响应式布局示例:

<div className="flex flex-col md:flex-row gap-4">
  <main className="bg-blue-100 p-4 md:w-2/3">主内容</main>
  <aside className="bg-green-100 p-4 md:w-1/3">侧边栏</aside>
</div>
  • flex 开启 Flex 布局。
  • flex-col 设置主轴方向为列(垂直排列),默认是 flex-row
  • gap-4 设置子元素之间的间距。
  • md:flex-row 表示在中等屏幕以上(≥768px)时改为行排列。
  • md:w-2/3md:w-1/3 分别设置宽度为父容器的 2/3 和 1/3。

4.2 深入理解移动优先(Mobile First)设计

细心的读者可能会问:为什么没有 md: 前缀时,布局是垂直的?这正是 Tailwind 移动优先设计思想的体现。

在移动优先的策略下,所有不带断点前缀的实用类默认应用于所有屏幕尺寸,即从最小屏开始生效。然后通过 sm:md:lg: 等带前缀的类在更大的屏幕上去覆盖添加样式。

去除md效果图

image.png

当我们把浏览器界面模式切换成moblie形式时

  • 不清楚的点击按F12然后点击这个按钮 image.png
  • 效果图

image.png 我们可以看到此时又

加上md效果图

image.png

当我们把浏览器界面模式切换成moblie形式时

  • 不清楚的点击按F12然后点击这个按钮 image.png
  • 效果图

image.png 我们可以看到此时又变成了上下式布局

所以在上面的例子中:

  • flex flex-col 是基础样式,对所有设备生效,因此移动端自然是垂直排列。
  • md:flex-row 是一个条件覆盖:当屏幕宽度达到 md 断点(768px)及以上时,将 flex-col 覆盖为 flex-row,从而变成水平排列。

这种模式非常符合现代Web开发“内容优先,移动先行”的理念。开发者只需先为小屏写好布局,再逐步为大屏添加增强样式,无需编写复杂的媒体查询。

如果不加 md:flex-row,那么所有屏幕上都会保持垂直排列,也就实现了单纯的移动端布局。通过添加断点前缀,我们可以精确控制布局在哪个尺寸发生变化。

4.3 Flex 对齐与分布

常用的对齐类:

  • justify-center:主轴居中
  • items-center:交叉轴居中
  • justify-between:两端对齐
  • self-start:单个项目对齐到起点

例如一个居中的容器:

<div className="flex justify-center items-center h-screen">
  <div className="bg-red-500 p-8">居中块</div>
</div>

4.4 Grid 布局

Tailwind 也支持 Grid,例如一个三列网格:

<div className="grid grid-cols-3 gap-4">
  <div>1</div>
  <div>2</div>
  <div>3</div>
</div>

通过 grid-cols-3 快速创建三列,gap-4 设置间距。

第五章 响应式设计

Tailwind 采用移动优先的响应式策略,内置了五个断点:

断点 最小宽度 说明
sm 640px 小屏
md 768px 中屏
lg 1024px 大屏
xl 1280px 超大
2xl 1536px 2倍超大

5.1 响应式前缀

在任意实用类前加上 sm:md: 等前缀,即可指定该样式生效的最小断点。例如:

<div className="text-sm md:text-base lg:text-lg">
  响应式字体
</div>

在移动端字体为 sm,中屏及以上变为 base,大屏及以上变为 lg

5.2 响应式布局实战

回顾 App.jsx 中的布局:

<div className="flex flex-col md:flex-row gap-4">
  <main className="bg-blue-100 p-4 md:w-2/3">主内容</main>
  <aside className="bg-green-100 p-4 md:w-1/3">侧边栏</aside>
</div>

移动端:上下排列(flex-col),主内容和侧边栏各占100%宽度。
平板及以上:左右排列(md:flex-row),主内容占2/3,侧边栏占1/3。
这种写法简洁且符合移动优先的设计原则。

5.3 自定义断点

如果内置断点不满足需求,可以在 tailwind.config.js 中自定义:

module.exports = {
  theme: {
    screens: {
      'tablet': '640px',
      'laptop': '1024px',
      'desktop': '1280px',
    },
  },
}

第六章 状态与交互

Tailwind 提供了多种状态变体,让我们能轻松处理交互样式。

6.1 常用的伪类变体

  • hover: 鼠标悬停
  • focus: 元素获得焦点
  • active: 元素被激活(如点击时)
  • disabled: 禁用状态

示例(来自 App2.jsx 按钮):

<button className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-300">
  提交
</button>
  • hover:bg-blue-700:悬停时背景变深。
  • focus:outline-none:移除焦点轮廓。
  • focus:ring-2 focus:ring-blue-300:焦点时显示蓝色外发光。

6.2 组悬停(Group Hover)

当需要根据父容器悬停来改变子元素样式时,可以使用 groupgroup-hover

<div className="group p-4 border rounded hover:bg-gray-50">
  <h3 className="text-lg group-hover:text-blue-600">标题</h3>
  <p className="text-gray-500 group-hover:text-gray-700">描述</p>
</div>

父容器添加 group 类,子元素使用 group-hover: 前缀,即可在父容器悬停时改变子元素样式。

第七章 组件化开发与TailwindCSS

在实际 React 项目中,我们通常将 UI 拆分为可复用的组件,TailwindCSS 在这种模式下表现优异。

7.1 在组件中使用Tailwind

创建一个 Button 组件,接收 variant 参数来改变样式:

function Button({ children, variant = 'primary' }) {
  const baseClasses = 'px-4 py-2 rounded-md font-semibold focus:outline-none';
  const variants = {
    primary: 'bg-blue-600 text-white hover:bg-blue-700',
    secondary: 'bg-gray-300 text-black hover:bg-gray-400',
  };
  return (
    <button className={`${baseClasses} ${variants[variant]}`}>
      {children}
    </button>
  );
}

这样既保持了灵活性,又复用了 Tailwind 的类。

7.2 性能优化:Fragment 的妙用

在 React 中,组件必须返回单个根元素。如果直接返回多个并列元素,会导致语法错误。传统做法是用一个额外的 <div> 包裹,但这会引入不必要的 DOM 节点,可能破坏布局(尤其是使用 Flexbox 或 Grid 时)并增加渲染负担。

React 提供了 <Fragment> 组件(简写为 <>...</>)来解决这个问题:它允许你组合多个子元素而不在 DOM 中添加额外节点。

例如 下面的写法:

export default function App() {
  return (
    <>
      <h1>111</h1>
      <h2>222</h2>
      <Button>提交</Button>
      <ArticleCard />
    </>
  )
}

编译后,这些元素会直接作为父容器的子元素,中间没有多余的包裹层。

7.3 Fragment 的灵感来源:DocumentFragment

你可能好奇:为什么 React 会设计这样一个特殊组件?其实,它的思想直接来源于浏览器原生的 DocumentFragment

让我们看我的 2.html 中的代码:

<script>
  const container = document.querySelector('.container');
  const p1 = document.createElement('p');
  p1.textContent = '111';
  const p2 = document.createElement('p');
  p2.textContent = '222';
  
  const fragment = document.createDocumentFragment();
  fragment.appendChild(p1);
  fragment.appendChild(p2);
  container.appendChild(fragment); // 一次性添加,只触发一次重绘
</script>

效果图

image.pngDocumentFragment 是一个轻量的文档片段,它就像是一个虚拟的“临时容器”。我们将多个新节点先放入这个片段中,然后将整个片段添加到 DOM 树,这样只会触发一次重绘/重排,显著提升性能。更重要的是,片段本身不会出现在最终 DOM 中,它的子节点被直接移入目标容器。

React 的 Fragment 正是借鉴了这一理念:

  • 它充当一个虚拟的父节点,允许组件返回多个元素。
  • 在渲染时,这些元素会被直接展开到父组件中,不产生额外 DOM 节点。
  • 它也隐式地提供了性能优化:避免了额外 div 带来的嵌套层级和样式干扰。

可以说,DocumentFragment 为 React 的组件化设计提供了重要的思路。理解这一点,能让我们更深刻地认识到 React 对原生 DOM 操作的抽象和优化。

7.4 提取重复的类组合

如果一个组件的类名组合经常重复,可以提取为一个新的组件或使用 @apply 指令在 CSS 中定义复合类(但官方更推荐组件化方案)。

第八章 进阶技巧与优化

8.1 使用 @apply 提取自定义样式

如果你希望在某些场景下复用一组 Tailwind 类,但又不想在 HTML 中写一长串,可以使用 @apply 在 CSS 中组合:

.btn-primary {
  @apply px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700;
}

然后在 HTML 中直接使用 btn-primary 类。但注意:过度使用 @apply 会让你回到传统 CSS 的命名和维护困境,因此官方建议仅在必要时(如第三方库限制)使用。

8.2 配置自定义主题

Tailwind 允许在 tailwind.config.js 中自定义颜色、间距、字体等。例如添加自定义颜色:

module.exports = {
  theme: {
    extend: {
      colors: {
        brand: '#ff6600',
      },
    },
  },
}

之后就可以使用 bg-brandtext-brand 等类。

8.3 生产环境优化

Tailwind 内置了 PurgeCSS 机制,通过扫描你的文件,只保留用到的类,从而大幅减小 CSS 体积。在 tailwind.config.js 中配置 content 选项:

module.exports = {
  content: ['./index.html', './src/**/*.{js,jsx,ts,tsx}'],
  // ...
}

构建时,未使用的类会被自动移除。

第九章 总结与展望

通过本文的学习,我们见证了从传统 CSS 到原子 CSS 的思想演进,掌握了 TailwindCSS 的安装配置、基础实用类、布局响应式、状态交互以及组件化开发的最佳实践。

TailwindCSS 之所以流行,不仅因为它提升了开发效率,更因为它改变了我们编写样式的方式——把关注点从“给这个元素起什么类名”转移到“这个元素应该有什么样式”,让开发者更专注于 UI 本身。

值得一提的是,随着 AI 生成代码的兴起,TailwindCSS 的类名语义化、原子化的特点使其成为 AI 生成界面的绝佳选择(如参考笔记中提到的)。通过简单的 prompt 描述,AI 就能生成带有 Tailwind 类的 HTML 结构,极大加速原型开发。

当然,TailwindCSS 也有学习曲线,但一旦熟悉,你会发现它带来的愉悦感是传统 CSS 无法比拟的。希望本文能帮助你开启高效、愉悦的样式开发之旅。


参考资料


如果你觉得本文对你有帮助,欢迎点赞、收藏、关注,也欢迎在评论区交流你的 Tailwind 使用心得!

一文吃透 Nestjs 动态模块之 register、forRoot、forFeature

读完此文,你能更准确的理解动态模块以及清楚register、forRoot、forFeature三者作用,何时使用它们

什么是动态模块

Dynamic modules 官方文档(英文)

Dynamic modules 官方文档(中文镜像)

在 Nest 里,普通(静态)模块可以理解为“写死的一份模块定义”,你只能在 imports: [] 里直接引入模块类;而动态模块允许你在“导入模块时传参”,由模块的静态方法(如 register / forRoot / forFeature返回一个 DynamicModule 对象,Nest 会把这个对象当成模块元数据来编译,从而按你的参数动态生成 providers / exports / imports 等配置。

一句话总结:动态模块 = 可传参的模块工厂,返回 DynamicModule 来决定模块最终提供什么能力。

动态模块作用

动态模块的核心价值是:把“可配置性”变成模块 API 的一部分。也就是说,你不再只能 imports: [SomeModule] 这样“死引入”,而是可以通过 SomeModule.forRoot(...) / forFeature(...) / register(...) 传入参数,让模块在“被导入时”就完成:

  • 根据不同环境/业务场景生成不同的 Provider(例如不同的连接串、不同的开关、不同的策略实现)
  • 导出一组“已经配置好”的能力(让使用方只管注入,不用关心怎么组装)
  • 把模块的配置约束收口到一个入口(避免到处散落 process.env 或重复 new 客户端)

从官方定义看,动态模块本质上就是:一个模块提供一个静态方法,返回 DynamicModule 对象(包含 module / imports / providers / exports / global 等元数据),Nest 会把它当成“模块定义”来编译。

你可以把动态模块理解为:“返回模块定义的工厂函数”,只不过它被约定写成模块类上的静态方法。


什么时候适合用动态模块

动态模块并不是“写 Nest 就必须用”的东西,通常在下面这些场景才值得上:

  • 需要配置且配置会变:例如 JWT、缓存、HTTP 客户端、消息队列、数据库连接等。
  • 需要多实例:同一种能力要按不同名字/用途创建多个实例(例如两个 Redis、两个第三方 API client)。
  • 需要隐藏复杂装配:使用方只想 imports: [...],不想了解内部 provider 如何拼装、如何选择实现。
  • 希望统一约束与默认值:集中处理 option 校验、默认值合并、token 命名、导出策略等。

不适合的情况:

  • 没有任何配置差异,普通静态模块就够了。
  • 配置只在单个业务模块里用且很简单,直接在该模块里写 providers 可能更清晰。

register、forRoot、forFeature:它们是什么关系

先说结论:它们不是语法关键字,只是 Nest 生态里长期形成的命名约定,目的是让人一眼看懂“这个动态模块方法在语义上做什么”。

  • register(...):更通用的命名,表示“注册/配置一次模块”。常见于需要传入 options 的模块(例如 ClientsModule.register(...)JwtModule.register(...)MulterModule.register(...))。
  • forRoot(...):强调“根级别(应用级/全局级)配置”,通常只需要做一次,影响整个应用的默认行为或单例资源(例如 ConfigModule.forRoot(...)TypeOrmModule.forRoot(...))。
  • forFeature(...):强调“按功能域(feature)扩展/注册一小部分能力”,往往会被多次调用,每个业务模块各取所需(例如 TypeOrmModule.forFeature([Entity...])MongooseModule.forFeature([{ name, schema }...]))。

一个很好理解的类比是:

  • forRoot:建“基础设施”(连接、全局配置、默认客户端)
  • forFeature:在某个业务域里“挂载资源”(实体仓库、某些模型、某些订阅)
  • register:没有明确 root/feature 分层时的“通用注册入口”

使用方法(从 DynamicModule 结构看懂一切)

动态模块方法最终都要返回一个 DynamicModule 对象(官方文档的核心点之一)。你需要理解这些字段各自解决什么问题:

  • module:必须指向当前模块类本身(Nest 用它做标识与元数据合并)。
  • imports:该动态模块额外依赖的模块(例如需要先导入 ConfigModule 才能注入 ConfigService)。
  • providers:根据 options 生成/选择出来的 provider(通常包含 options provider、核心服务、工厂 provider 等)。
  • exports:允许外部模块使用的 provider(不导出就无法在外部注入)。
  • global(可选):设为 true 后,该模块导出的 provider 在整个应用可见(减少重复 imports,但要谨慎使用)。

下面用“伪代码”把三类方法串起来看。


register(...):通用注册入口

概念

register 通常用于:模块需要 options 才能工作,并且这个模块既可能全局用一次,也可能按需在少数地方导入,但作者不想强行区分 root/feature。

作用

  • 把调用方传入的 options 固化为一个可注入的 provider(常用做法是 useValue
  • 用这些 options 组装出真正的客户端/服务 provider(常用做法是 useFactory
  • 决定导出哪些 token 给外部模块使用

伪代码示例

// 伪代码:不依赖具体业务库,展示结构与思路
type FooModuleOptions = { baseUrl: string; timeoutMs?: number };

const FOO_OPTIONS = Symbol('FOO_OPTIONS');
const FOO_CLIENT = Symbol('FOO_CLIENT');

@Module({})
export class FooModule {
  static register(options: FooModuleOptions): DynamicModule {
    const optionsProvider = {
      provide: FOO_OPTIONS,
      useValue: { timeoutMs: 3000, ...options },
    };

    const clientProvider = {
      provide: FOO_CLIENT,
      useFactory: (opts: FooModuleOptions) => {
        // 这里可以 new 一个 HTTP client / SDK client
        return createFooClient(opts.baseUrl, opts.timeoutMs);
      },
      inject: [FOO_OPTIONS],
    };

    return {
      module: FooModule,
      providers: [optionsProvider, clientProvider],
      exports: [clientProvider],
    };
  }
}

何时使用

  • 你在写一个可复用模块:需要 options,但不想强制区分“全局/feature”两套 API。
  • 你希望调用方语义简单:FooModule.register({ ... }) 一眼看懂“我在配置这个模块”。

forRoot(...):应用级(根级别)初始化

概念

forRoot 的关键词是“根”。它通常承担两类职责:

  • 初始化一次:创建单例连接/单例客户端/全局默认配置。
  • 定义默认行为:例如全局中间件、全局拦截器/管道依赖的配置,或某个模块的“默认实例”。

在 Nest 的常见用法里:你会在 AppModule(或根模块)里调用 forRoot,其他业务模块不再重复调用,而是通过注入来使用它导出的 provider。

作用

  • 建立“全局共享的底座”:连接池、客户端单例、全局配置 provider 等。
  • 明确生命周期:避免每个 feature 模块都 new 一个连接或重复注册同一份全局配置。

伪代码示例

type FooRootOptions = { url: string };
const FOO_CONNECTION = Symbol('FOO_CONNECTION');

@Module({})
export class FooModule {
  static forRoot(options: FooRootOptions): DynamicModule {
    const connectionProvider = {
      provide: FOO_CONNECTION,
      useFactory: async () => {
        // 连接通常是 async 初始化
        return await connectFoo(options.url);
      },
    };

    return {
      module: FooModule,
      providers: [connectionProvider],
      exports: [connectionProvider],
      // 可选:如果你希望全局可见(谨慎)
      // global: true,
    };
  }
}

// AppModule 里只做一次 root 初始化
@Module({
  imports: [FooModule.forRoot({ url: '...' })],
})
export class AppModule {}

何时适合用 forRoot

  • 需要“只初始化一次”的资源:数据库连接、MQ 连接、缓存连接、全局配置加载等。
  • 你希望模块 API 语义明确:forRoot 让读代码的人直接知道“这是应用级初始化”。

forFeature(...):按业务域挂载/扩展能力

概念

forFeature 的关键词是“feature”。它解决的问题通常是:某个模块已经通过 forRoot 建好了底座,但不同业务模块只需要其中一部分资源,或者需要在该模块下再注册一批与业务相关的 provider。

经典例子(官方生态里最常见的理解方式):

  • ORM/ODM 模块在 root 初始化连接后,feature 模块再声明“我需要这些实体/模型”,框架据此生成仓库/模型 provider 并导出给当前业务模块使用。

作用

  • 把“业务域的声明”放在业务模块里:可读性强、边界清晰。
  • 支持多次调用:每个业务模块可以传不同的 feature 元数据。
  • 避免全量导出:只为当前 feature 生成它需要的 providers。

伪代码示例

type FooFeature = { name: string };
const fooFeatureToken = (name: string) => `FOO_FEATURE_${name}`;

@Module({})
export class FooModule {
  static forRoot(options: { url: string }): DynamicModule {
    // 省略:创建连接并导出
    return { module: FooModule, providers: [...], exports: [...] };
  }

  static forFeature(features: FooFeature[]): DynamicModule {
    const featureProviders = features.map((f) => ({
      provide: fooFeatureToken(f.name),
      useFactory: (conn: unknown) => {
        // conn 来自 forRoot 导出的连接 token
        return connCreateFeatureHandle(conn, f.name);
      },
      inject: [/* FOO_CONNECTION */],
    }));

    return {
      module: FooModule,
      providers: featureProviders,
      exports: featureProviders,
    };
  }
}

// 某个业务模块按需声明它要哪些 feature
@Module({
  imports: [FooModule.forFeature([{ name: 'User' }, { name: 'Order' }])],
})
export class UserDomainModule {}

何时适合用 forFeature

  • 你已经有一个“root 级底座”,但需要在不同业务模块里分别声明不同资源集合。
  • 你希望业务模块的依赖可读:打开模块文件就能看到它依赖了哪些实体/模型/功能片段。

三者在真实项目里的组合方式(推荐理解)

常见的组合模式是:

  • 基础设施模块XxxModule.forRoot(...)(只在根模块调用一次)
  • 业务域模块XxxModule.forFeature(...)(每个业务域各自声明所需)
  • 不分层或轻量模块XxxModule.register(...)(直接配置即可用)

如果你在某个三方库里同时看到 registerforRoot/forFeature

  • 通常意味着作者提供了多种入口,方便不同使用习惯;
  • 但底层本质仍然是返回 DynamicModule,差异更多在“语义分层”和“推荐调用位置”。

注意事项(容易踩坑但官方语义允许你避免)

  • 不要把 forRoot 到处调用:如果它创建的是连接/单例资源,多次调用往往意味着多份实例(开销大、难排查)。更稳妥的模式是 root 初始化一次,feature 按需挂载。
  • 导出策略要克制exports 只导出真正需要给外部用的 provider。导出太多会让依赖边界变模糊,也会增加误用概率。
  • token 设计要稳定:options provider、客户端 provider、feature provider 的 token 一旦对外暴露,后续变更会影响大量模块。推荐用常量/Symbol/统一工厂函数生成 token,避免字符串散落。
  • global: true 谨慎使用:全局模块能减少 imports,但也会让依赖变“隐式”。团队协作里,显式 imports 往往更可维护。
  • 考虑异步配置:如果 options 依赖配置中心/远程拉取/ConfigService,一般会需要 registerAsync / forRootAsync 这一类异步变体(很多官方生态模块也提供同名 Async 方法)。

小结

动态模块的本质是:用一个静态方法返回 DynamicModule,把“模块如何被配置、生成哪些 provider、导出哪些能力”收敛为一个清晰入口register / forRoot / forFeature 是社区约定的命名语义:

  • forRoot:做应用级初始化(通常一次),建立底座与默认能力
  • forFeature:按业务域扩展/声明所需资源(可多次),只生成当前 feature 需要的 providers
  • register:通用注册入口(语义不分层),把 options 转成可注入能力即可用

掌握这三者,你读三方模块源码时会更快看懂“哪里初始化一次、哪里按需扩展、哪些 provider 会被导出”,写自己的可复用模块时也能把配置与依赖边界做得更清楚。

2026 年 JavaScript 框架 3 大趋势

2026 年 JavaScript 框架 3 大趋势

你好,我是冴羽。

Solid.js 的作者 Ryan Carniato 每年都会写一篇 JavaScript 框架发展趋势的文章,今年也不例外。

你可能会想,现在不都是 AI 写了吗?哪还有人讨论新的 JavaScript 框架或特性?但这并不意味着事物本身没有在发展。

事实上在 AI 时代,我们已经到了愿景比执行更为重要的阶段。

比如以前很多框架为了性能会采用 Signals 技术,如今已经让位于更具战略性的思考

本篇就和你深入探讨这些思考。

趋势一:AI优先

你可能以为,AI 对 JavaScript 框架本身没有影响,毕竟 AI 只是用框架生成业务代码。

但当越来越多原本不会接触框架的人使用框架,事情开始发生了改变。

想象一下,你现在有一个 AI 助手,但你只能用一种复杂的指令来指挥它。

比如你不能说“把桌子上的书放到书架第二层”,而是要说“执行文件迁移协议,源目录:桌面,目标:书架,层级:2”。0

这就是当前 JavaScript 框架的问题:API 设计太复杂,连 AI 都看不懂

于是一些框架发生了改变。

比如 Remix 3,这个框架做了一个大胆的决定:完全脱离 React,从零开始重新思考全栈 Web 开发。

Remix 3 的作者表示,他们的目标是减少特定领域语言,让 AI 能够更容易地生成通用解决方案。

一个例子就是他们在演示中展示了让 AI 生成框架无关的代码,然后轻松集成到项目中的能力。

这就是在从“专业术语对话”转向“自然语言交流”。这种变化将极大降低学习成本,提高开发效率。

趋势二:同构优先

前后端分离时代,客户端和服务端使用完全不同的语言,但随着服务端渲染技术的发展,如今前后端已经可以使用同一种语言。

在“同构优先”架构下,可以进行服务器端渲染,但应用程序的核心代码同时运行在两个环境中。这极大地降低了开发成本。

比如 Tanstack Start 和 SvelteKit 已经将乱序流式传输、服务端渲染, 细粒度乐观更新、单次变更等模式引入到各自的生态系统中。

新的一年,相关技术依然会持续升级。

趋势三:异步优先

如果让我说 2025 年 JavaScript 框架领域,思维方式发生的最大变革,那绝对是异步编程。

传统开发中,异步更新通常被认为是“特殊处理”情况,但在2026年,这将成为常态。

React 的 useOptimistic 和 Actions 模式代表了这种转变。它们将每个用户交互都包装在Transition中,确保显示更新与数据可用性协调一致。

想象一下,当你在社交媒体上点赞时,不再需要等待服务器响应才能看到 UI 更新,但又能保证最终状态的一致性。

这种“既快又准”的体验将重新定义用户对 Web 应用的期望。

这种变革影响深远,而且基础性极强,不出几年,就会成为各种框架的基本要求。

结语:迎接更智能的前端时代

2026年,JavaScript框架的发展将进入一个全新的阶段。

这不是简单的技术升级,而是开发思维的深刻变革。

3 个关键点值得记住:

  1. AI优先不是噱头,而是重新定义框架设计的基础理念

  2. 同构架构体现了"大道至简"的技术哲学

  3. 异步优先将用户体验提升到了前所未有的高度

面对这些变化,我们需要的不仅仅是技术准备,更是思维方式的调整。

未来的前端开发将更加智能、更加自然,也更加专注于解决真正的用户问题。

我是冴羽,10 年笔耕不辍,专注前端领域,更新了 10+ 系列、300+ 篇原创技术文章,翻译过 Svelte、Solid.js、TypeScript 文档,著有小册《Next.js 开发指南》、《Svelte 开发指南》、《Astro 实战指南》。

欢迎围观我的“网页版朋友圈”,关注我的公众号:冴羽(或搜索 yayujs),每天分享前端知识、AI 干货。

手把手系列之——前端工程化

前言在公司,每天的工作都离不开一个内部平台,它的名字叫做devops(就是这么直白哈哈哈),这个系统包含了公司从商机、需求、开发、测试到部署的全流程。那什么是devops呢,本文将由此引入CI/CD前端自动化等一系列概念,并手把手带你进行实战!

什么是 DevOps?

image.png

DevOps 是 Development(开发)与 Operations(运维)的合成词(面试有被问到过,还好我会!),代表的是一套文化、实践与工具的集合,核心目标是打破开发与运维之间的壁垒,让软件从需求到上线的全流程更短、更稳、更可重复。

什么是 CI/CD?

CI/CD 是持续集成与持续交付/部署的缩写,是 DevOps 在「构建与发布」环节的具体实现方式

  • CI(Continuous Integration,持续集成)
    开发者频繁将代码合入主干(或主分支),每次合入后自动触发:拉取代码、安装依赖、执行 Lint、跑单测/集成测试、打包构建。目的是尽早发现集成错误,避免分支长期不合并带来的「大爆炸」式合并问题。

  • CD(Continuous Delivery / Deployment)

    • 持续交付:CI 通过后,产物处于「随时可发布」的状态,发布到生产支持人工审批或手动触发。
    • 持续部署:在持续交付的基础上再进一步,通过流水线自动将通过测试的版本部署到测试/预生产/生产环境。

简单来说就是:CI 负责「每次提交都能被自动验证」;CD 负责「通过验证的版本能快速、可控地发布出去」。二者通常由同一套流水线串联起来。

什么是前端自动化?

前端自动化 指的是在前端工程中,把原本依赖人工、重复性高的动作交给脚本或流水线自动完成,主要包括:

  • 代码质量:提交前/合入后自动执行 ESLint、Prettier、类型检查(TypeScript)等。
  • 测试:单元测试(Jest)、组件测试、E2E 测试在流水线中自动运行。
  • 构建与产物:根据分支或标签自动执行 npm/pnpm build,生成 dist 等产物,并归档或打镜像。
  • 部署:将构建产物自动同步到静态服务器、CDN,或构建成 Docker 镜像并推送到仓库、触发 K8s 等部署。

前端自动化的落地形式往往就是:在 CI/CD 流水线里为前端项目配置「安装依赖 → Lint → 测试 → 构建 → 部署」等步骤,从而减少人工操作、统一环境、提高发布频率与可靠性。

三者的联系

概念 定位 与另外两者的关系
DevOps 文化与流程层面的指导思想 强调「自动化、协作、快速反馈」;CI/CD 与前端自动化是其实践手段。
CI/CD 构建与发布环节的技术实践 在 DevOps 理念下,用流水线把「集成 → 测试 → 部署」自动化;前端项目参与其中即是在做「前端自动化」。
前端自动化 在前端域内的具体落地 通过接入 CI/CD(如 Jenkins、GitLab CI),把前端的检查、构建、部署纳入统一流水线,是 DevOps 在前端侧的体现。

总结
DevOps 解决「为什么要这样做」和「怎样组织」,是一个指导思想;
CI/CD 解决「用流水线把构建和发布自动化」;
前端自动化则是在前端项目里,用 CI/CD 和脚本把检查、构建、部署都跑起来

梳理完概念,可以回答这个问题了:为什么需要 DevOps 与 CI/CD呢?

传统研发的痛点

  • 发布慢、手工多:打包、上传、部署全靠人工,易出错且耗时长。像我们前端团队没有工程化之前需要手动维护十几个省市的社保渠道验证环境,要和服务器打交道不说,每次改完bug都需要手动上传到验证环境上,难受的要死

  • 环境不一致:本地、测试、生产环境差异大,开发动不动就说:在我本地好好的呀......

  • 反馈滞后:代码合并后很久才部署,问题发现晚、修复成本高

DevOps 与 CI/CD 能带来什么?

  • DevOps:开发与运维协作、流程自动化、基础设施即代码,缩短从需求到上线的周期。
  • CI(持续集成):代码合入后自动构建、测试,尽早发现集成问题。
  • CD(持续交付/部署):在 CI 通过后,自动或一键将产物部署到各类环境。

对前端而言,典型收益包括:提交即触发构建、自动跑 Lint/单测、自动打包并部署到测试/生产,减少重复劳动。


一、技术选型与整体架构

1.1 核心组件

角色 选型 说明
CI/CD 引擎 Jenkins 成熟、插件丰富,适合自定义流水线
代码仓库 GitLab 自带 CI/CD(GitLab CI),与 Jenkins 也可配合
容器化 Docker 统一构建与运行环境,便于「一次构建,到处运行」
私有仓库 Docker Registry / Harbor、Verdaccio(npm) 镜像与前端包私有化

说明:Gogs 仅适合做轻量 Git,不支持 CI/CD;若要做自动化构建与部署,建议用 GitLab(企业一般都本地部署) + 第三方 CI。

1.2 前端在流水线中的流程

代码提交 → 拉取代码 → 安装依赖 → Lint/测试 → 构建(dist) → 产物归档/镜像构建 → 部署

二、Jenkins 安装与基础配置

2.1 两种安装方式

  • 直装:在宿主机装 Java + Jenkins,与系统耦合,升级、迁移不便。
  • Docker 安装(推荐):隔离环境、易迁移、易复现。

单容器快速启动示例:

docker run -u root -d -p 10050:8080 -p 50000:50000 \
  -v /var/run/docker.sock:/var/run/docker.sock \
  jenkins/jenkins:2.479

验证容器是否运行:

docker ps | grep jenkins

若希望 在 Jenkins 里执行 Docker 命令(例如在流水线中 docker build / docker push),有两种常见做法:

  1. 挂载宿主机 Docker Socket:Jenkins 容器通过 /var/run/docker.sock 使用宿主机 Docker(上面命令已挂载)。
  2. Docker-in-Docker(DinD):在独立容器中跑 Docker 守护进程,Jenkins 通过 TCP 连接过去,与宿主机 Docker 隔离,避免版本冲突,更贴近「在 Jenkins 里跑 Docker」的直觉,有点那个俄罗斯套娃的感觉。

下面采用 DinD + 自定义 Jenkins 镜像 的方式,便于在流水线中稳定的使用 Docker。

2.2 自定义 Jenkins 镜像(内建 Docker CLI + 访问 Socket)

思路:基于官方 Jenkins 镜像,安装 Docker CLI,并让 Jenkins 用户能访问挂载的 docker.sock(或后续在 DinD 中通过 TCP 访问)。

示例 Dockerfile(使用腾讯/阿里镜像加速,便于国内环境):

ARG JENKINS_VERSION=2.479.1

FROM jenkins/jenkins:${JENKINS_VERSION}

USER root

# 使用国内镜像源
RUN apt-get install -y apt-transport-https \
    && if [ -f /etc/apt/sources.list ]; then sed -i "s@http://\\(deb\\|security\\).debian.org@https://mirrors.tencent.com@g" /etc/apt/sources.list; else echo "deb https://mirrors.tencent.com/debian $(. /etc/os-release && echo "$VERSION_CODENAME") main" > /etc/apt/sources.list && echo "deb https://mirrors.tencent.com/debian-security $(. /etc/os-release && echo "$VERSION_CODENAME")-security main" >> /etc/apt/sources.list; fi

RUN apt-get update

# 安装 Docker CLI(使用阿里云 Docker CE 源)
RUN apt-get install -y ca-certificates curl gnupg \
    && curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
    && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://mirrors.aliyun.com/docker-ce/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
    | tee /etc/apt/sources.list.d/docker.list > /dev/null \
    && apt-get update \
    && apt-get install -y --no-install-recommends docker-ce-cli

# 使 jenkins 用户能访问 docker.sock(宿主机 docker 组 GID 多为 999,按需调整)
RUN groupadd -g 999 docker 2>/dev/null || true && usermod -aG docker jenkins

USER jenkins

构建:

docker build -t jenkins:2.479.1-docker .

测试(宿主机 socket 模式):

docker run --rm --name=test -v /var/run/docker.sock:/var/run/docker.sock \
  jenkins:2.479.1-docker docker -H unix:///var/run/docker.sock ps

通过 .sock 或 TCP 与 Docker 通信,可以避免在 Jenkins 容器内再装一套 Docker 引擎带来的版本与权限问题。

2.3 使用 Docker Compose 组网:Jenkins + DinD

下面用 Compose 把「Jenkins 容器」和「DinD 容器」组网,Jenkins 通过 TLS 的 TCP 连接 DinD,实现「在 Jenkins 里执行 Docker 命令」:
新建docker-compose.yml

services:

  jenkins-docker:
    image: arm64v8/docker:dind
    container_name: jenkins-docker
    privileged: true
    networks:
      jenkins:
        aliases:
          - docker
    environment:
      - DOCKER_TLS_CERTDIR=/certs
    volumes:
      - jenkins-docker-certs:/certs/client
      - jenkins-data:/var/jenkins_home

  jenkins-blueocean:
    image: jenkins:2.479.1-docker
    container_name: jenkins-blueocean
    restart: on-failure
    networks:
      - jenkins
    environment:
      - DOCKER_HOST=tcp://docker:2376
      - DOCKER_CERT_PATH=/certs/client
      - DOCKER_TLS_VERIFY=1
    volumes:
      - jenkins-data:/var/jenkins_home
      - jenkins-docker-certs:/certs/client:ro
      - /tmp:/tmp
    ports:
      - "10050:8080"
      - "50000:50000"

networks:
  jenkins:
    name: jenkins
    driver: bridge

volumes:
  jenkins-docker-certs:
  jenkins-data:
    driver: local
    driver_opts:
      type: none
      device: /home/jenkins/data

说明:

  • jenkins-docker:DinD 服务,暴露 2376(TLS)。
  • jenkins-blueocean:Jenkins 容器,通过 DOCKER_HOST=tcp://docker:2376 使用 DinD。
  • 证书与数据卷保证重启后 Jenkins 仍能连上 DinD,且数据持久化。

启动:

docker compose up -d

进入 Jenkins 容器排查时可用:

docker exec -it jenkins-blueocean /bin/sh

至此 Jenkins 安装与 Docker 环境就绪,可用于后续流水线中的镜像构建与推送。

2.4 基础配置(插件与镜像)

  • 访问:http://<宿主机IP>:10050(或你映射的端口)。
    image.png
  • 首次可先「选择插件来安装」,若部分插件安装失败可先跳过,稍后在「插件管理」中重试或更换更新中心。
  • 更新中心:若默认源慢,可在「Manage Jenkins → Plugin Manager → Advanced」中把 Update Site 换成国内镜像(如华为云,如果ping不通可以再换其他源),再安装 Node.js、Pipeline、Git、SSH 等插件。 image.pngimage.png
  • 权限:安装 Role-based Authorization Strategy,便于按项目/角色做细粒度控制。

三、GitLab 安装

若尚未安装 GitLab,可用 Docker 快速起一个(注意资源:建议 4 核 8GB 以上,避免 OOM):

  1. 新建目录,例如 gitlab

  2. 新建 .env

    GITLAB_HOME=/home/gitlab
    
  3. 新建 docker-compose.yml(示例为 ARM64,x86 可去掉 platform):

    services:
      web:
        image: 'gitlab/gitlab-ce:18.2.0-ce.0'
        platform: linux/arm64
        restart: always
        hostname: '10.211.55.4'
        environment:
          GITLAB_OMNIBUS_CONFIG: |
            external_url 'http://10.211.55.4:10082'
            gitlab_rails['gitlab_shell_ssh_port'] = 10083
        env_file:
          - .env
        ports:
          - '10082:10082'
          - '10083:22'
        volumes:
          - '$GITLAB_HOME/config:/etc/gitlab'
          - '$GITLAB_HOME/logs:/var/log/gitlab'
          - '$GITLAB_HOME/data:/var/opt/gitlab'
        shm_size: '512m'
    
  4. 执行 docker compose up -d 启动后,访问 http://<host>:10082/users/sign_in,即表示安装成功。 image.png


四、Jenkins 与 GitLab 打通(SSH + Webhook)

4.1 在 Jenkins 中配置 Git 凭据

image.png

  • 在 Jenkins「源码管理」中需要拉取 GitLab 仓库时,添加 Credentials

  • 类型选择 SSH Username with private key

  • 在宿主机或某台机器上生成密钥(若未使用默认路径,需在 Jenkins 中指定私钥路径):

    ssh-keygen -t ed25519 -C YourEmail
    cat <私钥路径>   # 将内容粘贴到 Jenkins 的 Key 中
    
  • Username 可填 git 或 GitLab 上对应用户名。

4.2 在 GitLab 中配置 Deploy Key

  • 进入项目:Settings → Repository → Deploy Keys
  • 新建部署密钥,把上面生成的 公钥 粘贴进去,勾选「公开访问」等按需勾选。
  • 这样 Jenkins 即可用该私钥拉取代码,无需个人账号密码。

4.3 Webhook(代码推送触发构建)

  • 在 Jenkins 任务中启用「构建触发器」:Build when a change is pushed to GitLab(需安装 GitLab 插件)。
  • 在 GitLab 项目:Settings → Webhooks,URL 填 Jenkins 的 GitLab webhook 地址,并设置与 Jenkins 中一致的 Secret 令牌
  • 保存后可发送 Test 请求验证,确保 Jenkins 能收到推送事件并触发任务。

这样可实现:git push → GitLab → Webhook → Jenkins 自动构建,符合 CI 的「提交即构建」理念。


五、前端流水线

5.1 手动构建与部署

在 Jenkins 任务中增加「执行 shell」步骤,例如:

pnpm i
pnpm build

建议:在 Jenkins 中配置 Node.js 环境(Node 插件)并指定版本;设置 淘宝源 或项目级 .npmrc 加速依赖安装。构建完成后,前端产物一般在 dist,可在 Workspace 中查看。

部署方式二选一或组合使用:

  • Publish over SSH:在 Manage Jenkins → System 中配置 SSH 服务器,在任务的 Build Steps 中增加「Send files or execute commands over SSH」,指定源为 dist/**、远程目录(如 /var/www/my-app),并可增加「在远程执行命令」(如重载 Nginx),完成「构建 → 上传到目标机」的简单 CD。
  • Docker 镜像:在仓库中准备 Dockerfile(多阶段构建:先 pnpm build,再只保留 dist + Nginx),在任务中执行 docker build -t my-app:${BUILD_NUMBER} .docker push <私有仓库>/my-app:${BUILD_NUMBER}
  • 私有仓库可选阿里云、自建 Registry、Harbor(详见第六节「前端私有化」),从而在 Jenkins 内完成「构建 → 打镜像 → 推仓库 → 在 K8s 或 Docker 主机上拉取并部署」的完整 CD。

5.2 Jenkins Pipeline

把「拉代码 → 装依赖 → 构建 → 测试 → 归档/镜像 → 部署」写成 Pipeline 脚本(Declarative 或 Scripted),可以实现版本化管理、复用、审查。

  • 在任务类型中选择 Pipeline
  • SCM 读取 Jenkinsfile,或直接在任务里写 Pipeline 脚本。
  • 使用 node { ... }stagesharchiveArtifactsdocker.build 等步骤编排前端流水线。

流水线即代码,便于版本管理与审查,也可与 GitLab 分支策略配合(例如仅对 main 分支执行生产部署)。

5.3 GitLab CI/CD

image.png

若希望「流水线跟着仓库走」,也可采用 GitLab CI

  • 在仓库根目录添加 .gitlab-ci.yml
  • 定义 stages(如 buildtestdeploy)和 jobs
  • 每个 job 指定 image(如 node:20)、script(如 pnpm i && pnpm build)、artifacts(保存 dist)。
  • 通过 GitLab Runner 在 Docker 或宿主机上执行,无需单独维护 Jenkins。

Jenkins 与 GitLab CI 也可并存:例如用 GitLab CI 做 MR 内的构建与测试,用 Jenkins 做发布与对接内部部署系统。


六、前端私有化

前端链路中常涉及两类私有仓库:Docker 镜像(用于部署)与 npm 包(组件库、工具包)。私有化后可实现内网构建、权限管控与版本的统一。

6.1 Docker 镜像私有化

构建好的前端镜像需要推送到私有仓库,供内网或 K8s 拉取部署,常见方案如下:

  • 阿里云容器镜像服务:托管在云上,开箱即用,免费额度有限(如 300 个命名空间后要收费),适合小团队或试用。
  • 自建 Docker Registry:官方 registry 镜像即可搭建,无图形界面,适合仅需「能推能拉」的场景。
    docker run -d -p 5000:5000 --restart=always -v /opt/registry:/var/lib/registry registry:2
    
    使用前需在 Jenkins 或本机 docker login < registry 地址> 配置账号。
  • Harbor:企业级镜像仓库,提供 Web 管理、权限、镜像扫描、多仓库复制等功能,适合规范化和多环境同步,需单独部署与维护。

在流水线中于 docker build 之后执行 docker push <私有仓库地址>/<镜像名>:<标签>,即可将前端镜像推送到上述任一类仓库。

6.2 npm 包私有化(Verdaccio)

团队内部组件库、工具包希望私有发布时,可用 Verdaccio 搭建 npm 私有源:

npm install --global verdaccio
npm i -g nrm
verdaccio
nrm add private http://localhost:4873/
nrm use private
npm adduser
npm login

发布:

npm init -y
npm publish

image.png

若流水线需从私有源安装依赖,在 Jenkins 中配置 .npmrc 或相应环境变量即可。


七、小结

环节 建议
代码仓库 GitLab(或 GitHub),支持 Webhook、Deploy Key。
CI Jenkins 或 GitLab CI,提交/合并后自动安装依赖、Lint、测试、构建。
构建环境 Node 版本固定,依赖缓存(如 pnpm store)、国内镜像加速。
产物 归档 dist 或构建镜像,便于追溯与回滚。
部署 通过 SSH 传文件或推送 Docker 镜像至服务器/K8s 供拉取部署。
私有化 Docker 用 Registry/Harbor,npm 用 Verdaccio。
流水线形态 Pipeline as Code(Jenkinsfile / .gitlab-ci.yml),可版本化、可复用。

按上述步骤一步步走下来,即可实现「手工打包上传」升级为「提交即构建、构建即可部署」的前端 DevOps 闭环;再结合分支策略、环境隔离与权限控制,就可以在保证质量的前提下提升发布效率与可重复性。
说回我们公司的devops,开发部分支持新建流水线,实现了新建分支 => 分支合并 => 打包部署(docker) => 成果上传的开发闭环,大大节省了运维时间和重复劳动,也推荐大家搞台服务器或者本地搞个虚拟机试试看!

VSCode Git Bash 终端:告别内置vi,直接用VSCode编辑交互内容

在使用VSCode搭配Git Bash终端时,很多开发者会遇到一个小困扰:执行需要交互编辑的Git命令(如无-m参数的git commit)时,终端会自动打开内置的vi编辑器,操作起来不够便捷,且与VSCode主编辑器的使用习惯脱节。其实,我们只需简单配置,就能让Git交互编辑直接调用VSCode主编辑器,提升开发效率,本文将详细梳理配置过程及注意事项。

一、问题背景

默认情况下,在VSCode的Git Bash终端中执行需要交互编辑的命令(例如git commit,未添加-m参数指定提交信息时),Git会调用终端内置的vi编辑器,用于编写提交信息等内容。但vi编辑器的操作逻辑与VSCode差异较大,对于习惯了VSCode编辑体验的开发者来说,频繁切换操作方式会影响效率,因此需要将Git的默认编辑器替换为VSCode。

二、核心实现思路

实现的核心的是两步:一是让Git Bash终端能够识别VSCode的命令行指令,二是将Git的默认编辑器配置为VSCode,并让Git等待编辑完成后再继续执行命令。其中,关键参数--wait不可或缺,它能确保Git不会在VSCode打开后立即结束交互,而是等待我们编辑并关闭文件后再继续执行后续操作。

三、详细配置步骤

步骤1:安装VSCode命令行工具(code命令)

要让Git Bash能够调用VSCode,首先需要将VSCode的code命令添加到系统环境变量中,具体操作如下:

  1. 打开VSCode编辑器;
  2. 按下Ctrl+Shift+P组合键,打开命令面板;
  3. 在命令面板中输入并选择「Shell Command: Install 'code' command in PATH」;
  4. 等待系统提示安装成功即可(Windows系统下,Git Bash会自动识别该命令,无需额外配置环境变量)。

步骤2:配置Git默认编辑器为VSCode

打开VSCode中的Git Bash终端,根据需求选择以下配置方式(推荐全局配置,一次配置永久生效):

方式1:全局配置(推荐)

执行以下命令,全局设置Git的默认编辑器为VSCode,所有Git仓库都会生效:

git config --global core.editor "code --wait"

方式2:临时配置(仅当前终端会话)

如果仅需要在当前终端会话中生效,执行以下命令(关闭终端后配置失效):

git config core.editor "code --wait"

四、配置验证方法

配置完成后,建议通过以下步骤验证是否生效,避免后续使用时出现问题:

  1. 检查配置是否成功:在Git Bash终端中执行以下命令,查看当前Git默认编辑器配置;
git config --global --get core.editor

若输出为code --wait,则说明全局配置成功;若未配置全局,可去掉--global参数查看当前仓库配置。

  1. 测试交互编辑功能:进入任意一个Git仓库,修改一个文件后,执行以下命令触发交互编辑;
git add .
git commit  # 不添加-m参数,触发提交信息编辑

正常情况下,VSCode会自动打开一个名为「COMMIT_EDITMSG」的文件,此时可在VSCode中直接编写提交信息,保存文件并关闭该标签页后,Git会自动完成commit操作,说明配置生效。

五、常见问题及解决方案

配置过程中或测试时,可能会遇到一些问题,以下是常见问题及对应的解决方法:

  • 问题1:执行git commit后,未打开VSCode,仍使用vi编辑器或报错; 解决方案:重启Git Bash终端(让code命令的环境变量生效),重新执行步骤1安装code命令,或检查VSCode是否为最新版本,更新后重试。
  • 问题2:打开VSCode编辑后,Git未继续执行操作; 解决方案:确认编辑完成后,保存并关闭VSCode中的「COMMIT_EDITMSG」标签页(仅保存不关闭无效),Git会在标签页关闭后继续执行。
  • 问题3:Git Bash中提示「code: command not found」; 解决方案:重新执行步骤1,确保「Shell Command: Install 'code' command in PATH」操作成功,若仍失败,可手动将VSCode安装目录下的「bin」文件夹添加到系统环境变量PATH中。

六、总结

通过以上简单两步配置,就能彻底解决VSCode Git Bash终端中交互编辑依赖vi的问题,让Git交互操作与VSCode编辑体验无缝衔接。核心要点如下:

  1. 必须先安装VSCode的code命令行工具,确保Git Bash能识别并调用VSCode;
  2. 配置Git默认编辑器时,--wait参数是关键,用于让Git等待编辑完成;
  3. 测试时,编辑完成后需关闭VSCode中的目标文件标签页,Git才会继续执行后续命令。

该配置适用于所有需要Git交互编辑的场景(如git rebase -i等),配置完成后,能有效提升开发过程中的操作流畅度,尤其适合习惯VSCode编辑环境的开发者。如果在配置过程中遇到其他问题,欢迎在评论区留言交流~

LeetCode 102. 二叉树的层序遍历:图文拆解+代码详解

LeetCode经典二叉树题目——102. 二叉树的层序遍历,这道题是二叉树广度优先搜索(BFS)的入门必刷题,也是面试中高频出现的基础题,不管是新手还是复盘,都值得好好吃透。

话不多说,先看题目本身,帮大家理清题意、拆解思路,再逐行解析代码,最后总结易错点,确保看完就能上手写对。

一、题目解读:什么是层序遍历?

题目给出二叉树的根节点root,要求返回其节点值的层序遍历,核心要求是「逐层地,从左到右访问所有节点」。

举个简单例子帮助理解:

如果二叉树是:

    3
   / \
  9  20
    /  \
   15   7

那么层序遍历的结果就是 [[3], [9,20], [15,7]] —— 第一层只有根节点3,第二层从左到右是9和20,第三层是15和7,每一层的节点值单独作为一个数组,最终组成二维数组返回。

这里要注意和「前中后序遍历」区分开:前中后序是深度优先(DFS),沿着一条路径走到底再回溯;而层序遍历是广度优先(BFS),先遍历完当前层的所有节点,再进入下一层,像“剥洋葱”一样逐层推进。

二、核心思路:用队列实现BFS

层序遍历的核心难点是「区分每一层的节点」—— 如何知道当前遍历的是哪一层,什么时候结束当前层、进入下一层?

解决这个问题的关键工具是「队列」(先进先出,FIFO),队列的特性刚好契合层序遍历“先遍历当前层所有节点,再处理下一层”的逻辑,具体思路分4步:

  1. 边界处理:如果根节点root为null,直接返回空数组(空树没有节点可遍历);

  2. 初始化:创建一个队列,将根节点入队(队列存储当前待处理的节点);创建一个结果数组,用于存储每一层的节点值;

  3. 循环处理(直到队列为空):

    • 记录当前队列的长度(也就是当前层的节点个数,记为levelSize),这个长度是区分层的关键;

    • 创建一个临时数组level,用于存储当前层的所有节点值;

    • 循环levelSize次,每次从队列头部取出一个节点:

      • 将该节点的值加入临时数组level;

      • 如果该节点有左孩子,将左孩子入队(为下一层遍历做准备);

      • 如果该节点有右孩子,将右孩子入队(同样为下一层遍历做准备);

    • 当前层遍历完成,将临时数组level加入结果数组;

  4. 循环结束,返回结果数组。

这里的核心技巧是「记录当前层的节点个数」—— 因为队列中会不断加入下一层的节点,只有通过levelSize限制循环次数,才能确保每次循环只处理当前层的节点,不会混入下一层的节点,从而正确区分每一层。

三、完整代码+逐行解析

先给出完整可运行的TypeScript代码(题目已提供TreeNode类,直接复用即可),再逐行拆解关键逻辑,新手也能看懂每一步的作用。

完整代码

/**
 * Definition for a binary tree node.
 * class TreeNode {
 *     val: number
 *     left: TreeNode | null
 *     right: TreeNode | null
 *     constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
 *         this.val = (val===undefined ? 0 : val)
 *         this.left = (left===undefined ? null : left)
 *         this.right = (right===undefined ? null : right)
 *     }
 * }
 */

class TreeNode {
  val: number
  left: TreeNode | null
  right: TreeNode | null
  constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
    this.val = (val === undefined ? 0 : val)
    this.left = (left === undefined ? null : left)
    this.right = (right === undefined ? null : right)
  }
}

function levelOrder(root: TreeNode | null): number[][] {
  // 边界处理:空树返回空数组
  if (!root) {
    return []
  }
  // 队列:存储当前待处理的节点,初始入队根节点
  const queue: TreeNode[] = [root]
  // 结果数组:存储每一层的节点值
  const result: number[][] = []
  // 队列不为空,说明还有节点未处理
  while (queue.length) {
    // 记录当前层的节点个数(关键:区分每一层)
    const levelSize = queue.length
    // 临时数组:存储当前层的节点值
    const level: number[] = []
    // 循环处理当前层的所有节点(循环次数 =  当前层节点数)
    for (let i = 0; i < levelSize; i++) {
      // 从队列头部取出节点(先进先出)
      const node = queue.shift()
      // 防止node为null(理论上不会出现,严谨性处理)
      if (!node) continue;
      // 将当前节点值加入当前层数组
      level.push(node.val)
      // 左孩子存在,入队(下一层节点)
      if (node.left) {
        queue.push(node.left)
      }
      // 右孩子存在,入队(下一层节点)
      if (node.right) {
        queue.push(node.right)
      }
    }
    // 当前层处理完毕,加入结果数组
    result.push(level)
  }
  // 返回最终结果
  return result;
};

逐行关键解析

  1. 边界处理 if (!root) return []: 如果根节点为null,说明是一棵空树,没有任何节点,直接返回空数组,避免后续队列操作报错。

  2. 队列初始化 const queue: TreeNode[] = [root]: 队列是核心数据结构,初始时只有根节点,因为层序遍历从根节点开始。

  3. 循环条件 while (queue.length): 只要队列不为空,就说明还有节点没处理(可能是当前层剩余节点,也可能是下一层节点),循环继续。

  4. 记录当前层节点数 const levelSize = queue.length: 这是最关键的一步!假设当前队列中有3个节点,说明当前层有3个节点,后续循环3次,刚好把这3个节点处理完,不会混入下一层的节点。

  5. 临时数组 const level: number[] = []: 每一层都需要一个临时数组存储节点值,遍历完当前层后,将这个数组加入结果数组,保证结果的二维结构。

  6. 取出节点 const node = queue.shift(): shift()方法会删除并返回队列的第一个元素,契合队列“先进先出”的特性,确保先处理当前层的第一个节点(从左到右)。

  7. 左、右孩子入队: 处理完当前节点后,将其左、右孩子(如果存在)入队,这些孩子是下一层的节点,会在后续循环中被处理,从而实现“逐层遍历”。

  8. 加入结果数组 result.push(level): 当前层的所有节点都处理完毕后,将临时数组level加入result,完成一层的遍历。

四、易错点提醒(避坑必看)

这道题看似简单,但新手很容易踩坑,总结3个最常见的错误,帮大家避开:

  1. 忘记记录levelSize,直接循环queue.length: 错误原因:队列在循环中会不断加入下一层的节点,queue.length会动态变化,导致循环次数不对,无法区分层。正确做法:必须在每次while循环开始时,记录当前queue的长度(即levelSize),循环次数固定为levelSize。

  2. 忽略node为null的情况: 错误原因:虽然题目中root是TreeNode|null,但queue中理论上不会有null,但shift()方法在队列空时会返回undefined,加上if (!node) continue是严谨性处理,避免报错。

  3. 左、右孩子入队顺序错误: 错误原因:题目要求“从左到右”访问,若先入队右孩子、再入队左孩子,会导致当前层节点顺序颠倒。正确做法:先入队左孩子,再入队右孩子,确保左在前、右在后。

五、题目延伸与思考

这道题是层序遍历的基础版本,掌握后可以尝试它的变形题,巩固BFS思路:

  • LeetCode 107. 二叉树的层序遍历II:要求自底向上层序遍历(从最下层到最上层),只需将result数组反转即可;

  • LeetCode 199. 二叉树的右视图:层序遍历中,每次只保留当前层的最后一个节点值;

  • LeetCode 637. 二叉树的层平均值:层序遍历中,计算每一层的节点值平均值。

其实这些变形题的核心思路和本题一致,都是用队列实现BFS,只是在处理“每一层结果”时做了不同的操作,掌握了基础,变形题就能迎刃而解。

六、总结

LeetCode 102题的核心是「用队列实现BFS,通过记录当前层节点数区分每一层」,整体难度中等,是二叉树BFS的入门题。

关键记住3点:队列存节点、levelSize分层次、左孩子先入队,再结合边界处理和严谨性判断,就能轻松AC。

如果是新手,建议自己手动模拟一遍队列的操作过程(比如用上面举的例子,一步步推进队列、处理节点),能更直观地理解层序遍历的逻辑。

Nestjs 中 Provider 的注入方式扫盲,解决你的选择困难症

Providers 是 Nest 中的一个核心概念。许多基础的 Nest 类,如服务、仓库、工厂和辅助工具,都可以被视为提供者。提供者的核心思想是它可以作为依赖被注入,从而允许对象之间形成各种关系。“连接”这些对象的责任在很大程度上由 Nest 运行时系统处理。

但是 Providers 的注入方式有很多种,对此不了解的同学在开发中遇到时,可能难以选择该用哪一种方式,这篇文章就针对这一点做一个详细的阐述


0. 先把话说清楚:你纠结的其实是两件事

在 Nest 里,“我想用一个 Provider”通常包含两步:

  • 注册(registration):把某个 token 和“怎么得到这个值/实例”的规则,交给 Nest 的 IoC 容器管理(通常写在 @Module({ providers: [...] }) 里)。
  • 注入(injection):在需要它的地方声明依赖,让 Nest 在创建类实例时把它“塞进来”(最常见是构造函数注入)。

另外要记住一个关键词:token

  • 最常用的 token:类本身(例如 CatsService)。
  • 也可以用:字符串、Symbol、TypeScript enum(官方明确提到可以用这些)。
  • 不建议/不能直接用:TypeScript interface(运行时不存在,容器没法拿它当 token 匹配)。

接下来所有“方式”,本质都是围绕 token 在做文章:要么变更“这个 token 对应哪个实现/值”,要么变更“这个实例的创建时机与生命周期”。


1. 方式一(默认首选):按类名 token 的构造器注入(Standard provider)

何时使用

  • 绝大多数业务场景的默认选择:Service / Repository / Helper 这类“可复用、可测试”的逻辑单元。
  • 当你不需要动态切换实现、不需要注入常量/第三方实例时,用它最省心。

典型场景

  • Controller 调 Service,Service 调 Repository。
  • 业务逻辑都在 class 里,依赖关系清晰。

注意事项

  • 别忘了注册:类写了 @Injectable() 只是“允许被容器管理”,但你仍要把它放进某个模块的 providers(或被某个模块导入后可见)。
  • 跨模块要 export/import:Provider 默认只在声明它的模块内部可见;要给别的模块用,需要 exports

伪代码

// cats.module.ts
@Module({
  providers: [CatsService], // 这是最常见的“短写”
  exports: [CatsService],   // 需要给别的模块用就导出
})
class CatsModule {}

// cats.controller.ts
@Controller('cats')
class CatsController {
  constructor(private readonly catsService: CatsService) {}
}

小知识:providers: [CatsService] 其实是下面这种“长写”的语法糖:{ provide: CatsService, useClass: CatsService }。理解这个等价关系,会让你更容易看懂后面的自定义 Provider。


2. 方式二:自定义 token + @Inject(token) 注入(字符串 / Symbol / enum)

何时使用

  • 你要注入的东西不是一个 class:常量、配置对象、第三方库实例(DB 连接、Redis client、SDK)。
  • 你想用一个“抽象 token”来隔离实现:例如用 CONFIG/CONNECTION 这类 token,让依赖方不直接 import 具体实现文件。

典型场景

  • 数据库连接、消息队列 client、第三方 SDK 实例。
  • 为了避免“魔法字符串”到处飘,集中管理 token。

注意事项

  • 尽量别直接散落字符串 token:官方建议把 token 放到独立文件(如 constants.ts)统一导出,避免冲突和拼写错误。
  • 更推荐 Symbol:字符串容易撞名;Symbol('CONNECTION') 更不容易冲突。

伪代码

// constants.ts
export const CONNECTION = Symbol('CONNECTION');

// db.module.ts
@Module({
  providers: [
    { provide: CONNECTION, useValue: connectionInstance },
  ],
  exports: [CONNECTION],
})
class DbModule {}

// cats.repository.ts
@Injectable()
class CatsRepository {
  constructor(@Inject(CONNECTION) private readonly conn: Connection) {}
}

3. 方式三:useValue(值提供者 Value provider)

何时使用

  • 注入常量值、配置对象、已经创建好的实例。
  • 测试/本地调试时,用 mock 替换真实实现(官方也拿它举例)。

典型场景

  • useValue: mockService 做单元测试替身。
  • 注入某个第三方库的“现成对象”(例如 logger、连接句柄)。

注意事项

  • useValue 直接把一个值交给容器:不会由 Nest new,也不会帮你管理它的内部依赖。
  • 如果你用它替换一个 class provider,确保这个值的“形状”能满足调用方需要(在 TS 里通常靠结构化类型兼容)。

伪代码

const mockCatsService = { findAll: () => [] };

@Module({
  providers: [
    { provide: CatsService, useValue: mockCatsService },
  ],
})
class TestModule {}

4. 方式四:useClass(类提供者 Class provider)

何时使用

  • 你想让一个 token 在不同环境/条件下解析到不同的实现类
  • 例如开发环境用 DevConfigService,生产环境用 ProdConfigService

典型场景

  • 多套实现按环境切换(dev/prod)。
  • 同一抽象能力的多实现(例如不同供应商的短信服务)。

注意事项

  • 依赖方注入的是 token(通常是一个“抽象入口”),不要在依赖方写 if/else 去挑实现,把选择逻辑放在 provider 注册处。

伪代码

const configProvider = {
  provide: ConfigService,
  useClass: isDev ? DevConfigService : ProdConfigService,
};

@Module({ providers: [configProvider] })
class AppModule {}

5. 方式五:useFactory(工厂提供者 Factory provider)

何时使用

  • 你需要“动态创建”一个实例:创建过程要读配置、组合参数、甚至依赖别的 Provider。
  • 你需要“异步初始化”后才允许系统启动(比如先连上数据库再接请求)。

典型场景

  • DB 连接创建、缓存 client 创建、按配置生成 SDK 实例。
  • 一部分依赖可选:没有就用默认行为。

注意事项

  • inject 数组的顺序要和工厂函数参数一一对应(官方明确说明会按顺序传参)。
  • inject 里可以声明可选依赖:{ token: XXX, optional: true },工厂函数就要能处理 undefined
  • 异步 provider:工厂返回 Promise 时,Nest 会等待它 resolve 后,才会实例化依赖它的类(官方在“Async providers”章节强调这一点)。

伪代码(同步 + 可选依赖)

const connectionProvider = {
  provide: CONNECTION,
  useFactory: (options: OptionsProvider, maybePrefix?: string) => {
    const opts = options.get();
    return new DatabaseConnection({ ...opts, prefix: maybePrefix });
  },
  inject: [
    OptionsProvider,
    { token: 'SOME_OPTIONAL', optional: true },
  ],
};

伪代码(异步初始化)

const asyncConnectionProvider = {
  provide: 'ASYNC_CONNECTION',
  useFactory: async () => {
    const conn = await createConnection(options);
    return conn;
  },
};

注入时和普通 provider 一样,只是 token 不同:

constructor(@Inject('ASYNC_CONNECTION') conn: Connection) {}

6. 方式六:useExisting(别名提供者 Alias provider)

何时使用

  • 你想让两个 token 指向同一个 Provider 实例(官方称之为 alias)。
  • 常见于迁移期:旧代码用旧 token,新代码用新 token,但底层实现先共用一份。

典型场景

  • 日志服务从 LoggerService 迁到 'LOGGER',但一段时间内两种写法都得兼容。

注意事项

  • useExisting 不是创建新实例,而是“多一个入口指向同一个实例”。
  • 在默认单例(DEFAULT)下,两边拿到的是同一对象;如果你用了请求级/瞬态作用域,要更小心理解生命周期(见后文“作用域”)。

伪代码

const loggerAliasProvider = {
  provide: 'AliasedLoggerService',
  useExisting: LoggerService,
};

@Module({ providers: [LoggerService, loggerAliasProvider] })
class AppModule {}

7. 跨模块使用:导出(export)自定义 Provider

何时使用

  • 你的 Provider 定义在 DbModuleConfigModule 里,但别的模块要注入它。

典型场景

  • DbModule 里创建连接 provider,在 UserModule / OrderModule 注入使用。

注意事项

  • 自定义 Provider 默认只在本模块可见,要给别人用必须导出。
  • 官方给了两种导出方式:
    • exports: [TOKEN](导出 token)
    • exports: [providerObject](导出整个 provider 定义)

伪代码

const connectionFactory = { provide: 'CONNECTION', useFactory: ..., inject: [...] };

@Module({
  providers: [connectionFactory],
  exports: ['CONNECTION'], // 或 exports: [connectionFactory]
})
class DbModule {}

8. “可选依赖”到底怎么写?

官方文档里最直接、最可控的一种可选依赖写法,是在 useFactoryinject 里声明 optional: true

inject: [MyOptionsProvider, { token: 'SomeOptionalProvider', optional: true }]

这会让工厂函数对应参数可能为 undefined使用场景通常是“有则增强、无则降级”的依赖,比如可选的前缀、可选的扩展配置、可选的监控上报器等。

注意事项很朴素:你必须把 undefined 当成合法输入处理掉,否则等同于把问题从“容器解析阶段”推迟到“运行时崩溃阶段”。


9. 属性注入(Property-based injection)要不要用?

Nest 支持用 @Inject(token) 在属性上注入,但官方长期更强调构造器注入这条主路径。实际工程里,一般建议把属性注入当成“应急方案”:

何时使用

  • 你在做一些元编程/基类封装,构造器签名不方便改动。
  • 你非常明确这不会让依赖关系变得隐蔽(例如只在框架层封装里用)。

不太建议的原因

  • 依赖不在构造器里显式声明,阅读类定义时更难一眼看出“需要哪些东西”。
  • 测试替换与重构成本更高,容易留下隐性依赖。

伪代码

@Injectable()
class CatsRepository {
  @Inject(CONNECTION)
  private readonly conn: Connection;
}

10. 循环依赖:forwardRef()ModuleRef 的取舍

循环依赖指 A 依赖 B、B 也依赖 A。Nest 官方给了两条路:

方式 A:forwardRef()(最常用)

何时使用

  • 两个 Provider 真的是互相需要,而且短期内不好拆。

注意事项(官方强调的坑)

  • 实例化顺序不确定,代码不要依赖“谁先构造”。
  • 如果循环依赖链上出现 Scope.REQUEST 的 provider,可能导致依赖变成 undefined(官方给了明确 warning)。
  • 还有一种“看似 DI 的循环依赖”,其实是 barrel file(index.ts 聚合导出)导致的 import 循环;官方建议在模块/Provider 类上尽量避免 barrel file。

伪代码:

@Injectable()
class AService {
  constructor(@Inject(forwardRef(() => BService)) private b: BService) {}
}

@Injectable()
class BService {
  constructor(@Inject(forwardRef(() => AService)) private a: AService) {}
}

模块之间循环 import 也同理:

@Module({ imports: [forwardRef(() => BModule)] })
class AModule {}

@Module({ imports: [forwardRef(() => AModule)] })
class BModule {}

方式 B:ModuleRef(重构友好)

何时使用

  • 你想把循环依赖“断开一边”,让其中一方在运行时按需从容器取实例(而不是在构造器里硬绑死)。

注意事项

  • 这通常意味着你在改设计:把“必须在构造器里就拿到依赖”变成“需要时再取”,要保证调用路径上能接受这种变化。

11. 作用域(Injection scopes):默认单例、请求级、瞬态

Nest 官方把 Provider 生命周期分为三类:

  • DEFAULT(默认):全局单例,应用生命周期内共享一份实例。官方也明确说:大多数场景推荐单例
  • REQUEST:每个请求一份实例,请求结束后释放。适合“按请求隔离状态”的边界场景。
  • TRANSIENT:每个注入点(每个消费者)都会拿到一份新实例。

何时使用 REQUEST

  • GraphQL 的按请求缓存、请求链路追踪、多租户(根据请求头选择租户上下文)等官方列出的典型例子。

注意事项

  • 性能影响:请求级 provider 会让 DI 子树在每个请求都创建实例,官方建议除非必须,否则优先单例。
  • 作用域会沿依赖链“向上冒泡”:Controller 依赖了 request-scoped provider,那么 Controller 自己也会变成 request-scoped。
  • WebSocket Gateway 不应使用 request-scoped:官方明确指出它们必须是单例;Passport strategy、Cron 等也有类似限制。

伪代码

@Injectable({ scope: Scope.REQUEST })
class RequestCacheService {}

// 或者在自定义 provider 上设置 scope
{ provide: 'CACHE_MANAGER', useClass: CacheManager, scope: Scope.TRANSIENT }

小结

  • 能用构造器注入 + 类 token 就别复杂化providers: [MyService] + constructor(private my: MyService) 是默认正确答案。
  • 要注入“不是 class 的东西”:用自定义 token(优先 Symbol)+ @Inject(token)
  • 要替换实现 / mock / 常量useValue
  • 要按环境/条件切换实现useClass
  • 要动态创建/组合依赖/异步初始化useFactory(需要 async 就直接返回 Promise)。
  • 要做兼容/迁移/多入口同实例useExisting
  • 遇到循环依赖:优先重构拆分;确实拆不开再用 forwardRef(),并避开 request-scoped 组合的坑。
  • 作用域:默认单例最香;REQUEST/TRANSIENT 是为边界问题准备的“手术刀”,别当“菜刀”乱用。

参考(官方文档)

PHP Error Reporting: Enable, Display, and Log Errors

PHP error reporting controls which errors are shown, logged, or silently ignored. Configuring it correctly is essential during development — where you want full visibility — and on production servers — where errors should be logged but never displayed to users.

This guide explains how to configure PHP error reporting using php.ini, the error_reporting() function, and .htaccess. If you are not sure which PHP version is active, first check it with the PHP version guide .

Quick Reference

Task Setting / Command
Enable all errors in php.ini error_reporting = E_ALL
Display errors on screen display_errors = On
Hide errors from users (production) display_errors = Off
Log errors to a file log_errors = On
Set custom log file error_log = /var/log/php/error.log
Enable all errors at runtime error_reporting(E_ALL);
Suppress all errors at runtime error_reporting(0);

PHP Error Levels

PHP categorizes errors into levels. Each level has a name (constant) and a numeric value. You can combine levels using bitwise operators to control exactly which errors are reported.

The most commonly used levels are:

  • E_ERROR — fatal errors that stop script execution (undefined function, missing module)
  • E_WARNING — non-fatal errors that allow execution to continue (wrong function argument, missing include)
  • E_PARSE — syntax errors detected at parse time; always stop execution
  • E_NOTICE — minor issues such as using an undefined variable; useful during development
  • E_DEPRECATED — warnings about features that will be removed in future PHP versions
  • E_ALL — all errors and warnings; recommended during development

For a full list of error constants, see the PHP error constants documentation .

Configure Error Reporting in php.ini

The php.ini file is the main configuration file for PHP. Changes here apply globally to all PHP scripts on the server. After editing php.ini, restart the web server for the changes to take effect.

To find the location of your active php.ini file, run:

Terminal
php --ini

Enable Error Reporting

Open php.ini and set the following directives:

ini
; Report all errors
error_reporting = E_ALL

; Display errors on the page (development only)
display_errors = On

; Display startup sequence errors
display_startup_errors = On
Warning
Never enable display_errors on a production server. Displaying error messages to users exposes file paths, database credentials, and application logic that can be exploited.

Log Errors to a File

To write errors to a log file instead of displaying them, set:

ini
; Disable displaying errors
display_errors = Off

; Enable error logging
log_errors = On

; Set the path to the log file
error_log = /var/log/php/error.log

Make sure the directory exists and is writable by the web server user. To create the directory:

Terminal
sudo mkdir -p /var/log/php
sudo chown www-data:www-data /var/log/php

Recommended Development Configuration

ini
error_reporting = E_ALL
display_errors = On
display_startup_errors = On
log_errors = On
error_log = /var/log/php/error.log

Recommended Production Configuration

ini
error_reporting = E_ALL & ~E_DEPRECATED
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /var/log/php/error.log

This reports all serious errors while suppressing deprecation notices, and logs everything without displaying anything to users.

Configure Error Reporting at Runtime

You can override php.ini settings within a PHP script using the error_reporting() function and ini_set(). Runtime changes apply only to the current script.

To enable all errors at the top of a script:

php
<?php
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
error_reporting(E_ALL);

To report all errors except notices:

php
<?php
error_reporting(E_ALL & ~E_NOTICE);

To suppress all error output (not recommended for debugging):

php
<?php
error_reporting(0);

Runtime configuration is useful during development when you do not have access to php.ini, but it should not replace proper server-level configuration on production systems.

Configure Error Reporting in .htaccess

If you are on a shared hosting environment without access to php.ini, you can configure PHP error reporting via .htaccess (when PHP runs as an Apache module):

apache
php_flag display_errors On
php_value error_reporting -1
php_flag log_errors On
php_value error_log /var/log/php/error.log

Using -1 for error_reporting enables all possible errors, including those added in future PHP versions.

Info
.htaccess directives only work when PHP runs as an Apache module (mod_php). They have no effect with PHP-FPM.

View PHP Error Logs

Once log_errors is enabled, errors are written to the file specified by error_log. To monitor the log in real time, use the tail -f command:

Terminal
tail -f /var/log/php/error.log

If no error_log path is set, PHP writes errors to the web server error log:

  • Apache: /var/log/apache2/error.log
  • Nginx + PHP-FPM: /var/log/nginx/error.log and /var/log/php/php-fpm.log

For related service commands, see how to start, stop, or restart Apache and how to start, stop, or restart Nginx .

Troubleshooting

Errors are not displayed even with display_errors = On
Confirm you are editing the correct php.ini file. Run php --ini or call phpinfo() in a script to see which configuration file is loaded and the current value of display_errors.

Changes to php.ini have no effect
The web server must be restarted after editing php.ini. For Apache, run sudo systemctl restart apache2. For Nginx + PHP-FPM, restart Nginx and your versioned PHP-FPM service (for example, sudo systemctl restart nginx php8.3-fpm).

No errors appear in the log file
Check that the log file path is writable by the web server user (www-data or nginx), and that log_errors = On is set in the active php.ini.

ini_set('display_errors', '1') has no effect
Fatal parse errors (E_PARSE) occur before any PHP code runs, so ini_set() cannot catch them. Enable display_errors in php.ini or .htaccess to see parse errors.

Error suppression operator @ hides errors
PHP’s @ prefix suppresses errors for a single expression. If errors from a specific function are missing from logs, check whether the call is prefixed with @.

FAQ

What is the difference between display_errors and log_errors?
display_errors controls whether errors are output directly to the browser or CLI. log_errors controls whether errors are written to the error log file. On production, always set display_errors = Off and log_errors = On.

Which error_reporting level should I use?
Use E_ALL during development to catch every possible issue. On production, use E_ALL & ~E_DEPRECATED to log serious errors while suppressing deprecation notices that do not require immediate action.

How do I check the current error reporting level?
Call error_reporting() with no arguments: it returns the current bitmask as an integer. You can also check it in the output of phpinfo().

Can I log errors to a database instead of a file?
Not directly via php.ini. Use a custom error handler registered with set_error_handler() and set_exception_handler() to send errors to a database, email, or external logging service.

Where is php.ini located?
Run php --ini from the command line or add <?php phpinfo(); ?> to a script and load it in a browser. The “Loaded Configuration File” row shows the active path. Common locations are /etc/php/8.x/cli/php.ini (CLI) and /etc/php/8.x/apache2/php.ini (Apache).

Conclusion

During development, set error_reporting = E_ALL and display_errors = On in php.ini to see all errors immediately. On production, set display_errors = Off and log_errors = On to log errors silently without exposing details to users.

Make changes in php.ini for permanent server-wide configuration, use error_reporting() and ini_set() for per-script overrides, or use .htaccess on shared hosting without php.ini access.

If you have any questions, feel free to leave a comment below.

PM2完全指南:从入门到精通

引言:为什么需要PM2?

在Node.js生态中,进程管理是保障服务稳定性和高可用性的核心环节。随着业务复杂度的提升,开发者需要面对多进程调度、异常重启、日志收集、性能监控等挑战。传统的手动管理方式(如通过node app.js直接启动)在生产环境中显得力不从心。

PM2(Process Manager 2) 作为一款功能全面的进程管理工具,通过自动化和智能化的机制,为Node.js应用提供了从开发到部署的全生命周期支持。对于Vue3项目而言,虽然前端项目本身是静态资源,但在生产环境中同样需要稳定的服务进程来提供访问。

一、PM2核心功能详解

1.1 进程守护与自动重启

PM2最核心的功能之一是进程守护。当服务崩溃或意外退出时,PM2会自动重启应用,无需手动干预,保证服务7x24小时运行。这对于生产环境至关重要,避免了因单点故障导致的服务中断。

1.2 集群模式与负载均衡

PM2支持集群模式,可以自动利用多核CPU资源,将请求分发到多个进程,显著提升服务的并发处理能力。通过简单的配置,就能实现多实例负载均衡。

1.3 日志统一管理

PM2会自动记录服务的标准输出和错误日志,方便排查问题。你可以自定义日志路径,还可以安装pm2-logrotate插件实现日志自动切割,防止磁盘被日志文件撑满。

1.4 监控与性能统计

通过命令行或可视化工具,你可以实时查看服务的CPU、内存使用情况,掌握运行状态。pm2 monit命令提供了交互式监控界面,pm2 show可以查看单个服务的详细信息。

1.5 系统自启动

PM2支持设置开机自启动,服务器重启后服务自动恢复,无需重新手动启动。通过pm2 startuppm2 save命令即可实现。

二、PM2安装与基本使用

2.1 安装PM2

# 全局安装PM2
npm install pm2 -g

# 验证安装
pm2 -v

2.2 常用命令速查表

命令 说明 示例
pm2 start [文件/配置] 启动服务 pm2 start app.js
pm2 stop [进程名/ID] 停止服务 pm2 stop my-app
pm2 restart [进程名/ID] 重启服务 pm2 restart my-app
pm2 delete [进程名/ID] 删除服务 pm2 delete my-app
pm2 list 查看所有进程状态 pm2 list
pm2 logs 查看日志 pm2 logs my-app
pm2 monit 实时监控 pm2 monit
pm2 save 保存当前进程列表 pm2 save
pm2 startup 设置开机自启 pm2 startup

三、Vue3项目中使用PM2的完整指南

3.1 构建Vue3项目

在使用PM2管理Vue3项目之前,首先需要构建项目生成静态文件:

# 进入Vue3项目目录
cd your-vue3-project

# 安装依赖(如果尚未安装)
npm install

# 构建项目
npm run build

构建完成后会生成dist文件夹,其中包含了所有静态资源文件。

3.2 创建PM2配置文件

PM2推荐使用ecosystem.config.js配置文件来管理应用,这种方式更易于维护和版本控制。在项目根目录创建该文件:

// ecosystem.config.js
module.exports = {
  apps: [{
    name: 'my-vue3-app',           // 应用名称
    script: 'serve',               // 使用serve启动静态服务器
    args: 'dist',                  // 指定dist目录
    exec_mode: 'fork',             // 执行模式:fork或cluster
    instances: 1,                  // 实例数量
    autorestart: true,             // 自动重启
    watch: false,                  // 生产环境关闭监听
    max_memory_restart: '1G',      // 内存超过1G自动重启
    env: {
      NODE_ENV: 'development',
      PM2_SERVE_PATH: './dist',    // 服务路径
      PM2_SERVE_PORT: 3000,        // 服务端口
      PM2_SERVE_SPA: 'true'        // 支持SPA路由
    },
    env_production: {
      NODE_ENV: 'production',
      PM2_SERVE_PATH: './dist',
      PM2_SERVE_PORT: 8080,
      PM2_SERVE_SPA: 'true'
    },
    error_file: './logs/error.log',   // 错误日志路径
    out_file: './logs/out.log',       // 输出日志路径
    log_date_format: 'YYYY-MM-DD HH:mm:ss'  // 日志时间格式
  }]
};

3.3 使用Express服务器方案(备选)

如果你需要更灵活的控制,可以使用Express创建自定义服务器:

// server.js
const express = require('express');
const path = require('path');
const app = express();

// 设置静态文件目录
app.use(express.static(path.join(__dirname, 'dist')));

// 支持SPA路由
app.get('*', (req, res) => {
  res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Vue3应用运行在端口 ${PORT}`);
});

然后在PM2配置中使用这个服务器文件:

// ecosystem.config.js
module.exports = {
  apps: [{
    name: 'vue3-express-app',
    script: './server.js',
    // ...其他配置
  }]
};

3.4 启动Vue3项目

# 使用配置文件启动
pm2 start ecosystem.config.js

# 或者指定生产环境
pm2 start ecosystem.config.js --env production

# 查看运行状态
pm2 status

# 查看实时日志
pm2 logs my-vue3-app

四、高级配置与最佳实践

4.1 集群模式配置

对于高并发场景,可以启用集群模式充分利用多核CPU:

module.exports = {
  apps: [{
    name: 'vue3-cluster-app',
    script: 'serve',
    args: 'dist',
    instances: 'max',      // 根据CPU核心数启动最大实例
    exec_mode: 'cluster',  // 集群模式
    // ...其他配置
  }]
};

4.2 环境变量管理

PM2支持多环境配置,便于开发、测试、生产环境的切换:

module.exports = {
  apps: [{
    name: 'vue3-app',
    script: 'serve',
    args: 'dist',
    env: {
      NODE_ENV: 'development',
      API_BASE_URL: 'http://localhost:3000/api',
      PORT: 3000
    },
    env_staging: {
      NODE_ENV: 'staging',
      API_BASE_URL: 'https://staging-api.example.com',
      PORT: 8080
    },
    env_production: {
      NODE_ENV: 'production',
      API_BASE_URL: 'https://api.example.com',
      PORT: 80
    }
  }]
};

启动时指定环境:

pm2 start ecosystem.config.js --env staging

4.3 日志管理与切割

生产环境中,日志管理至关重要:

# 安装日志切割插件
pm2 install pm2-logrotate

# 配置日志切割
pm2 set pm2-logrotate:max_size 50M    # 单个日志文件最大50MB
pm2 set pm2-logrotate:retain 10       # 保留10个日志文件
pm2 set pm2-logrotate:compress true   # 压缩旧日志
pm2 set pm2-logrotate:dateFormat 'YYYY-MM-DD_HH-mm-ss'  # 日志文件名格式

4.4 开机自启动配置

确保服务器重启后应用自动恢复:

# 生成启动脚本(根据系统提示执行相应命令)
pm2 startup

# 保存当前进程列表
pm2 save

# 重启后自动恢复
pm2 resurrect

4.5 监控与告警

PM2提供了丰富的监控功能:

# 实时监控界面
pm2 monit

# 查看应用详细信息
pm2 show vue3-app

# 生成系统报告
pm2 report

# 以JSON格式查看状态
pm2 jlist

五、Vue3项目部署完整流程

5.1 本地开发环境

# 1. 开发阶段使用Vue CLI
npm run serve

# 2. 构建生产版本
npm run build

# 3. 本地测试构建结果
npx serve dist

5.2 服务器部署流程

# 1. 上传代码到服务器
scp -r dist user@server:/path/to/project/

# 2. 在服务器上安装PM2(如果尚未安装)
npm install pm2 -g

# 3. 上传PM2配置文件
scp ecosystem.config.js user@server:/path/to/project/

# 4. 在服务器上启动应用
cd /path/to/project
pm2 start ecosystem.config.js --env production

# 5. 设置开机自启
pm2 startup
pm2 save

5.3 结合Nginx反向代理

对于生产环境,建议使用Nginx作为反向代理:

# /etc/nginx/sites-available/vue3-app
server {
    listen 80;
    server_name your-domain.com;
    
    location / {
        proxy_pass http://localhost:3000;  # PM2运行的端口
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }
    
    # 静态文件缓存
    location ~* .(jpg|jpeg|png|gif|ico|css|js)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

六、常见问题与解决方案

6.1 端口占用问题

如果端口已被占用,PM2会自动尝试其他端口,但最好明确指定:

env: {
  PORT: 3000,
  PM2_SERVE_PORT: 3000
}

6.2 内存泄漏监控

设置内存限制,超过阈值自动重启:

max_memory_restart: '512M'  // 内存超过512MB自动重启

6.3 文件变化监听(开发环境)

开发环境下可以启用文件监听:

watch: true,
ignore_watch: [
  'node_modules',
  'logs',
  '.git'
]

6.4 多应用管理

PM2可以同时管理多个应用:

module.exports = {
  apps: [
    {
      name: 'vue3-frontend',
      script: 'serve',
      args: 'dist',
      // ...前端配置
    },
    {
      name: 'node-backend',
      script: './server/api.js',
      // ...后端配置
    }
  ]
};

PM2作为Node.js生态中最流行的进程管理工具,为Vue3项目的生产部署提供了完整的解决方案。通过本文的介绍,你应该已经掌握了:

  1. PM2的核心功能:进程守护、集群模式、日志管理、监控等
  2. Vue3项目配置:如何通过ecosystem.config.js文件管理Vue3应用
  3. 生产环境最佳实践:集群配置、日志切割、开机自启等
  4. 完整部署流程:从本地开发到服务器部署的全过程

在实际项目中,建议始终使用配置文件而非命令行参数,这样配置更易于维护和版本控制。同时,结合Nginx等Web服务器,可以构建更加稳定和高效的生产环境。

PM2的学习曲线平缓,但功能强大,是每个Node.js和Vue开发者都应该掌握的工具。开始在你的Vue3项目中使用PM2,享受更加稳定和高效的部署体验吧!

提示:本文所有配置示例都经过实际测试,你可以根据项目需求进行调整。更多高级功能请参考PM2官方文档

Vue3 组合式 API(setup + script setup)实战

前言

Vue3 的 <script setup> 是官方推荐写法,代码更简洁、逻辑更聚合。本文带你真正用好组合式 API。

一、script setup 基本写法

<script setup>
// 直接写逻辑,无需 export default
import { ref, reactive, computed } from 'vue'

const msg = ref('Hello Vue3')
</script>

<template>
  <div>{{ msg }}</div>
</template>

二、响应式数据

  • ref:基础类型(string/number/boolean)

  • reactive:对象 / 数组

    const num = ref(0) const user = reactive({ name: '张三', age: 20 })

三、计算属性 computed

import { computed } from 'vue'

const doubleNum = computed(() => num.value * 2)

四、方法与事件

<button @click="add">+1</button>

<script setup>
const add = () => {
  num.value++
}
</script>

五、生命周期

import { onMounted, onUpdated, onUnmounted } from 'vue'

onMounted(() => {
  console.log('组件挂载')
})

六、父传子 props

// 子组件
<script setup>
import { defineProps } from 'vue'
const props = defineProps({
  title: String
})
</script>

七、子传父 emit

// 子组件
const emit = defineEmits(['change'])
const handleChange = () => {
  emit('change', '新数据')
}

八、获取 DOM:ref

<div ref="box"></div>

<script setup>
import { ref } from 'vue'
const box = ref(null)

onMounted(() => {
  console.log(box.value)
})
</script>

总结

<script setup> 优点:

  • 代码更少
  • 无需 return
  • 更好的 TS 支持
  • 逻辑更清晰

🚀 从零到一实战:基于 Taro 构建纯血鸿蒙 (HarmonyOS NEXT) 应用踩坑全指南

随着纯血鸿蒙 (HarmonyOS NEXT) 逐渐成为全球主流操作系统,将现有的前端业务延展至鸿蒙生态成为了许多开发团队的核心诉求。根据官方说明,Taro 从 v4.1.0 开始,已经正式支持打包纯血鸿蒙应用。如果你想了解详细的底层架构与特性,可以随时查阅官方文档

在最新的 Taro 架构中,官方主推基于 C-API 的原生混合渲染模式(Harmony-CPP),它能够突破传统 JS 桥接的性能瓶颈,带来媲美原生的流畅体验。但在实际落地的过程中,从脚手架初始化到最终在 DevEco Studio 中点亮那颗“绿色运行按钮”,隐藏着不少工程化陷阱。本文将以真实的第一视角,带你一步步走完这个“从0到1”的实战全流程。

一、 初始化 Taro 脚手架:关键选项避坑

首先,我们需要全局安装 Taro CLI 并初始化项目。建议使用 yarnpnpm 来管理跨端巨石应用:

npm install -g @tarojs/cli
taro init my-demo

在交互式问询界面中,有几个针对鸿蒙生态的必选项千万不能选错:

  1. 框架:推荐选择 React
  2. 编译工具必须选择 Vite。目前 Taro 明确规定,当前仅支持使用 Vite 编译鸿蒙应用。
  3. 是否需要使用 TypeScript?Yes。鸿蒙底层的 ArkTS 本质上是 TS 的超集,使用 TS 能让后续的原生混编更顺滑。
  4. 是否需要编译为 ES5?No。Vite 基于原生 ESM 构建,强行降级 ES5 会破坏编译性能且毫无必要。
  5. 模板选择:选择默认模板 (default) ,保持代码结构的纯粹,最适合用来跑通基础链路。

二、 核心编译插件的安装与“隐形依赖”陷阱

初始化完成后,进入项目目录,我们需要安装鸿蒙 C-API 的专属编译插件:

yarn add @tarojs/plugin-platform-harmony-cpp

⚠️ 踩坑警告:

如果你仅仅安装了 harmony-cpp,在后续执行编译时,大概率会遇到如下报错:

Error: Cannot find module '@tarojs/plugin-platform-harmony-ets/dist'

原因与解法:Taro 的 cpp 插件在底层强依赖了 ets 插件内部的公共构建脚本与类型定义。因此,你必须手动补齐这个隐形依赖:

yarn add @tarojs/plugin-platform-harmony-ets

三、 在 DevEco Studio 中创建鸿蒙原生工程

在将 Taro 前端代码编译并注入之前,我们必须先通过华为开发者工具准备一个鸿蒙原生空壳工程。

  1. 启动创建:打开 DevEco Studio。如果是首次打开,点击欢迎页面的 Create Project;如果已有项目打开,则从顶部菜单栏选择 File > New > Create Project
  2. 选择模板:在“Choose Your Ability Template”页面中,选择 Application,然后选中 Empty Ability 基础模板,点击 Next。
  3. 配置工程:填写你的项目名称(Project name)和包名(Bundle name),并选择代码的保存目录(Save location)。请务必牢记这个保存目录的绝对路径,我们在下一步的 Taro 配置中马上会用到它
  4. 选择版本:Compatible SDK 建议选择匹配你当前开发环境的较新版本(例如推荐的 API 12 及其对应版本)。
  5. 完成创建:点击 Finish。IDE 会自动为你生成工程的基础代码结构与相关资源,等待项目初始化加载完成即可。

四、 配置鸿蒙本地工程路径

现在回到前端项目中,打开根目录下的 config/index.ts(或 index.js),注册鸿蒙插件并把你刚刚在上一步创建的底层空壳工程绝对路径配置进去:

const config = {
  //... 其他基础配置
  plugins: ['@tarojs/plugin-platform-harmony-cpp'],
  harmony: {
    compiler: 'vite', // 当前仅支持使用 Vite 编译鸿蒙应用
    // 注意:这里填写你刚才通过 DevEco Studio 创建的鸿蒙空壳工程的绝对路径
    projectPath: '/Users/你的用户名/DevEcoStudioProjects/MyApplication',
    hapName: 'entry',
  },
}

五、 编译并注入产物

配置无误后,在终端执行跨端编译指令:

taro build --type harmony_cpp

此时终端可能会在最后抛出一个警告:/bin/sh: ohpm: No such file or directory

不用慌,这仅仅是因为你的电脑终端没有配置鸿蒙包管理器 ohpm 的全局环境变量。只要看到 ✓ built in xxx ms 就证明前端代码已经成功转换为 ArkTS 产物,并注入到了你指定的底层鸿蒙工程中。

六、 DevEco Studio 终极排错:点亮灰色的运行按钮

再次打开你的 DevEco Studio,回到刚才创建的空壳工程。此时你可能会发现右上角的绿色运行按钮变成了灰色不可用的状态,并且点击 Sync 还会报错。我们需要进行以下两步“清扫”工作来激活它:

1. 解决 ohpm install 同步问题

打开鸿蒙工程左侧目录树中的 oh-package.json5,点击编辑器右上角弹出的 Sync Now 按钮。这会接替终端,由 IDE 来完成所有依赖库的拉取。

2. 解决 EntryBackupAbility.ets Not Found 构建崩溃

当你尝试构建或同步时,底层的 Hvigor 构建引擎可能会抛出致命错误:

hvigor ERROR: 00304012 Not Found... Module-srcEntry./ets/entrybackupability/EntryBackupAbility.ets not found.

原因解析:DevEco Studio 创建新项目时,默认在 module.json5 中注册了用于备份与恢复数据的 entrybackupability 入口文件。但 Taro 注入产物时重写了目录结构,导致该文件丢失,从而引发构建配置的不匹配错误。

终极解法

展开 entry -> src -> main -> module.json5,找到 "extensionAbilities" 节点,直接删除包含 backup 类型的整个对象配置。保存后,点击顶部菜单栏的 File -> Sync and Refresh Project

3. 配置自动签名 (如果运行按钮依然灰色)

点击顶部 File -> Project Structure... -> Signing Configs,勾选 Automatically generate signature 并登录华为开发者账号,让 IDE 自动完成开发证书的签名配置。

七、 大功告成

完成以上所有步骤后,DevEco Studio 重新建立索引,右上角的绿色三角形运行按钮终于亮起!点击 Run,启动模拟器,你就会看到由 Taro 跨端编译出的 React 页面完美地运行在了纯血鸿蒙系统上!

结语

跨端框架适配纯血鸿蒙的过程,本质上是一场前端编译工具链与底层系统原生规则的“握手”。掌握了这套排错逻辑,你就可以毫无负担地在全场景跨端的广阔天地中尽情施展了。祝大家代码无 Bug,编译一遍过!

从“必选项”到“性能包袱”:为什么现代框架开始“抛弃”虚拟 DOM?

一、 回顾历史:虚拟 DOM 到底解决了什么痛点?

在 2013 年 React 带着虚拟 DOM (Virtual DOM) 出来之前,我们操作网页的方式是极其原始的。

1.1 被 jQuery 支配的年代

那时候我们要改一个列表,流程通常是这样的:

  1. 拿到数据。
  2. 找到对应的 DOM 节点。
  3. 手动拼接字符串或者操作 appendChild
  4. 还要小心翼翼地处理事件解绑,否则内存就泄露了。

这种模式最大的问题不是“慢”,而是状态不可控。当页面逻辑复杂到一定程度,你根本不知道是哪一段脚本改了哪一个按钮。UI 和数据完全脱节,维护起来就像在代码里拆地雷。

1.2 虚拟 DOM 并不是为了追求绝对速度

这里必须纠正一个流传很久的误区:虚拟 DOM 比原生 DOM 快。

这个结论在底层逻辑上就是错的。

虚拟 DOM 本质上是一个普通的 JavaScript 对象(Plain Object)。当你修改数据时,框架会在内存里重新创建一个 JS 对象树,然后把新树和旧树对比(Diff),算出差异,最后再去调用原生 API(如 appendChild, remove, setAttribute)来更新网页。

你看,虚拟 DOM 多做了一步 JS 计算,它怎么可能比直接操作原生 DOM 更快?

1.3 虚拟 DOM 的真正功劳:性能兜底与开发范式

虚拟 DOM 解决的是两个核心问题:

  • 研发效率:它让前端开发进入了“声明式”时代。你只需要告诉框架“我要的长相是什么样”,剩下的脏活累活(对比差异、局部更新)框架全包了。
  • 防止低质量代码:新手开发者如果直接操作 DOM,很可能在循环里触发频繁的回流(Reflow),导致页面卡顿。虚拟 DOM 通过内部的缓冲合并机制,强行保证了即使你乱写,性能也不会掉出及格线。

二、 既然它这么好,为什么现在要“移除”它?

技术的发展总是伴随着成本。当我们的应用从简单的管理后台变成了像飞书、Figma 这样复杂的巨型 Web 应用时,虚拟 DOM 的副作用就开始显现了。

2.1 运行时计算的“天花板”

虚拟 DOM 的核心是 Diff 算法。无论算法怎么优化(比如 React 的 Fiber 架构),它始终避不开一个逻辑:当数据变化时,我要通过遍历树来“猜”哪里变了。

在一个有 5000 个节点的长列表里,即使你只是改了一个复选框的状态,框架依然要递归遍历这 5000 个虚拟节点,去确认其他 4999 个节点没变。这种计算开销是随着节点数量线性增长的。在主线程处理高频交互(如拖拽、输入)时,这种 Diff 耗时会导致明显的丢帧。

2.2 内存的沉重代价

虚拟 DOM 节点在内存里是很重的。除了节点类型、属性,还要存储各种 Hooks 状态、指向真实 DOM 的引用等。

对于内存受限的移动端设备,浏览器要同时维护一份真实 DOM 和一份(甚至两份)虚拟 DOM 镜像。这不仅占用了宝贵的内存空间,还会导致频繁的 GC (垃圾回收) 触发。每当 GC 扫描这些海量的小对象时,主线程就会瞬间停顿,造成页面微小的卡死。

2.3 SSR 场景下的冗余

现在的 Web 应用很看重首屏速度(LCP),通常会用服务器端渲染(SSR)。服务器生成了 HTML,发给浏览器显示。

但问题来了:为了让这个页面能交互,客户端的 JS 必须再跑一遍,在内存里构建一棵一模一样的虚拟 DOM 树,并跟真实 DOM 对接(这个过程叫 Hydration)。

既然 HTML 都已经渲染好了,为什么我还要在客户端重新算一遍 VDOM? 这就是目前 VDOM 架构在性能优化上的一个死结。

三、 现代框架的进化:从“对比”转向“精准定位”

为了解决上述问题,Svelte、Solid.js 以及 Vue 3 的 Vapor Mode 开始尝试抛弃虚拟 DOM。它们的思路非常直白:既然运行时 Diff 太慢,那我就在编译阶段搞定。

3.1 编译器变得更聪明了

以前的编译器(如 Babel)只是把 JSX 翻译成 React.createElement。现在的编译器(如 Svelte 的编译器或 Vue 的模板分析器)在编译代码时,就能通过静态分析识别出:

  • <div>姓名:{{ name }}</div> 这一行,只有 name 是动态的。
  • 旁边的 <div>公司:字节跳动</div> 是静态的,一辈子都不会变。

3.2 细粒度更新:绕过 Diff

基于这种分析,编译器直接生成了原生 JS 代码,不再生成 VDOM。

当你修改 name 这个变量时,程序内部会直接执行:

textNode.data = newName;

没有 Diff 过程,没有树的遍历。 这就是所谓的“细粒度更新”。它就像是在代码里埋了一枚枚精准的雷管,哪里数据变了,就直接爆破哪里的 DOM 属性。

3.3 内存与运行时的精简

因为不需要在运行时存储虚拟树,也不需要内置复杂的 Diff 算法包,这些框架生成的产物体积更小,运行时的内存占用也极低。这在极致性能优化的场景下,简直是降维打击。

四、 对比:三种渲染方案的底层实现逻辑

为了让大家看得更透彻,我们用一段伪代码模拟三种方案的更新过程。

方案 A:原生命令式(jQuery)

JavaScript

// 数据变了
data.name = '张三';
// 开发者手动更新
$('#name-label').text('张三');
  • 优点:最快。
  • 缺点:当页面有 100 个地方要改,开发者会疯掉,代码没法维护。

方案 B:虚拟 DOM 模式(React/Vue2)

JavaScript

// 数据变了
state.name = '张三';
// 框架开始工作
let newVNode = render(state); // 重新生成整棵树
let patches = diff(oldVNode, newVNode); // 递归遍历对比差异
apply(realDOM, patches); // 把差异补丁打到真实 DOM 上
  • 优点:开发爽,性能有保底。
  • 缺点:数据变动越大,树越深,Diff 越累。

方案 C:编译时无虚拟 DOM 模式(Solid/Vapor)

JavaScript

// 编译阶段生成的代码(伪代码)
const name_updater = (val) => textNode1.data = val;

// 运行阶段数据变了
name.set('张三'); // 触发订阅函数
name_updater('张三'); // 直接定位更新,不经过对比
  • 优点:速度接近原生操作,内存极省。
  • 缺点:对编译器的依赖极强,目前动态性不如 VDOM。

五、 如何看待 VDOM 的未来?

虽然“去 VDOM 化”是目前的趋势,但作为资深前端,我们不能无脑跟风。VDOM 在未来相当长的一段时间内依然会有其独特的生态位。

5.1 跨平台场景的“万能胶水”

如果你的业务不只是 Web,还要出移动端 App(React Native)、小程序、甚至车载屏幕系统,虚拟 DOM 依然是最好的选择。

因为它本质上是一层抽象协议。它把 UI 变成了一个普通的 JS 对象。这个对象发给浏览器,浏览器能渲染成 HTML;发给 iOS,iOS 就能渲染成原生 View。这种解耦能力,目前“编译时直出真实 DOM”的方案还很难完美替代。

2.2 极致动态性的需求

有些业务需要从后台下发一份复杂的 JSON 布局,然后前端动态渲染。在这种完全依赖运行时的场景下,VDOM 的灵活性是非常强大的。

2.3 工业级平衡点:Vue 3 的策略

Vue 3 其实走了一条非常“中庸”且聪明的路。它保留了虚拟 DOM,但引入了 Patch Flags (静态标记)

它在编译模板时,会给动态节点打上标记(比如“这个节点只有 class 会变”)。在运行时 Diff 的时候,它会跳过所有静态节点,直接去跳到那个有标记的节点上操作。这本质上是带了“导航”的虚拟 DOM,在灵活性和性能之间找到了一个绝佳的平衡点。

六、 总结:我们该如何准备?

前端技术的演进不是为了“推翻”,而是为了“更高效地解决具体问题”。

  • 对于极致性能追求的应用(如编辑器、大型看板、低功耗移动端):你应该关注 Solid.jsVue 的 Vapor Mode,去理解那种不需要 Diff 的精准更新思想。
  • 对于通用型业务、多端复用的项目:成熟的 VDOM 框架(React, Vue 3)依然是目前工程化最稳妥、生态最丰富的选择。

我们没必要为了“虚拟 DOM 消失了”而感到焦虑。我们要关注的是渲染效率的本质:如何用更少的 JS 计算、更小的内存消耗,去实现更流畅的用户交互。

结论很简单:

过去,我们用 VDOM 来抹平 DOM 操作的复杂性;

现在,我们用更强大的编译器来抹平 VDOM 的额外开销。

前端渲染的尽头,始终是高效、精准、简洁的真实 DOM 操作。

过程即奖励|前端转后端经验分享

转岗动机

先简单介绍一下我的背景:通信专业,秋招前自学前端,21 年 7 月校招进入某教育公司做前端开发。刚毕业就赶上行业寒冬,那会儿“双减”政策落地,教育行业整体受挫,我们组的业务也大受影响,年底我就有了准备跳槽的念头。

22 年 5 月,我加入字节,做了两年的前端开发。24 年 3 月,我们团队有一轮调整,当时前端人力有点冗余,后端则比较稀缺。当时的 +1 找我聊,问我愿不愿意试试转岗做后端。我没有纠结太久,原因很简单:换一个岗位,相当于多了一种视角,我会接触到完全不一样的一套知识体系,就算未来不继续做后端,了解后端体系对前端工作也是加分项。

所以当时的我抱着非常明确的“学习型心态”,接受了 +1 的提议。

转岗阵痛期

和 +1 沟通确认之后,我就开始正式接触团队的后端项目了。团队统一用 Go,所以技术栈没什么选择余地。

我的入门路线是:

第一步:搞定环境配置。安装 Go 环境、配置 IDE;快速过一遍 Go 基础语法;把项目跑起来,能在本地看到服务正常启动。得益于公司完善的文档体系,这一步没什么太大难度。

第二步:熟悉项目代码。从入口开始顺藤摸瓜,找逻辑简单的接口,看一看处理链路。

第三步:开始上手需求,在干中学。我写的第一个后端功能是数据导出,在那个需求里,我一边写一边学到了 Go 协程的用法、操作系统和内存管理以及 MongoDB 的数据存储和处理。

为了不让自己“只停留在能写”的状态,周末我会给自己留一点“作业”:研究项目里用到的框架是怎么组织代码的;熟悉各种数据库的常见用法,学习该怎么选型;内网搜罗各种“后端扫盲手册”,一点点补课。大概一个月之后,回头看自己写的第一个功能,我已经能发现问题并且知道怎么去优化了。那一刻还蛮有成就感的:我在进步。

但没高兴几天,真正的考验来了。

24 年 5 月,带我 landing 的后端同事转岗走了。在只学了个大概、刚能磕磕绊绊写需求的情况下,我被迫成了那个模块的“后端负责人”。这意味着我需要自己去拆解需求、写方案,自己接 oncall、处理用户问题,还要扛线上问题。

那段时间是我转岗后最痛苦的时期:我还不太会处理线上数据,怕操作失误没法回滚,遇到问题的第一反应甚至是“打不过就跑”。但人在压力下的成长往往是加速的,我比我想象的要更抗压更坚韧。周末打黑工 review 技术方案,处理用户问题到凌晨 —— 就这样硬着头皮扛了两三个月,直到组里招到了新的资深后端,我才松了口气。

那一阵过去之后,我拿到了那个季度的 spot bonus,+1 也非常肯定那段时间我的撑场表现。那一刻的心态变化很微妙:原本我以为自己不行的事情,其实也能撑下来;后端这条路,好像还可以再走远一点。

渐入佳境

24 年下半年(7月 - 12月),我持续做后端需求,同时有计划地补课:从数据存储、服务搭建,到中间件的使用,再到操作系统、并发控制、公司各种基建。

如果按季度拆解,大概是这样一个过程:Q3 能 cover 日常需求,线上有报警能第一时间看日志、查监控定位问题;遇到复杂问题不再“完全没头绪”。Q4 可以独立负责一些模块, 能从 0 到 1 设计技术方案;开始考虑性能和扩展性,而不仅仅是“先实现再说”。

回头看 24 年,我的收获远远超出了我的预期:我不仅完成了从前端到后端的角色转换,更重要的是,我开始有能力独立负责一个模块从设计到上线的全流程。到了 25 年,工作状态逐渐变得“得心应手”:独立完成项目,主动做性能优化,日常工作能从容应对。

回看这段转岗之路,也是我慢慢读懂并实践毛选智慧的过程。《实践论》教会我“干中学”,边干边学习,边学习边完善,循环往复,螺旋上升;《矛盾论》教会我“抓重点”,找准当前阶段最关键的问题,集中精力解决它,其他的也会随之理顺。

经验总结

如果只用一句话来总结我的体会,那就是:后端不用关注那么琐碎的交互和 UI,真好。当然这是半开玩笑,但也是真实的感受。

做前端时,习惯看交互反馈、动画细节、兼容性,各种像素级的“抠”。做后端之后,关注点转移到了业务逻辑、数据存储、服务稳定性。后端的世界有一种“更纯粹”的感觉。但这并不是说前端不重要 —— 前端承载了用户最直观的体验和感受,后端更像是系统的“地基和管道”,问题不显眼,但影响很大。

回头再看这段经历,我想说:转岗是一条没那么难的路,只要你会写代码,你就可以转岗。甚至在这个 Vibe coding 的时代,会不会写代码都已经不是最重要的事了。

重要的是,你是否愿意从头开始学习一套新体系、接受短期内变回“新人”的落差感、在一段时间里承受不确定性和压力。

对我自己来说,支撑我走过这段路的几个关键词是:

  • 学习心态:把转岗当作一次进阶,不是“被动调岗”,而是“主动拓展边界”;

  • 不畏难:遇到不懂的东西,不急着给自己贴“我不行”的标签,而是拆解问题一个个啃;

  • 不给自己设限:有些事没做过,不代表做不了,试试又不会怎么样。

我的飞书签名一直是乔布斯的那句格言:过程即奖励。在这段经历里,我发现自己比想象中更能抗压,那些硬着头皮撑下来的日子,回过头看,恰恰是成长最快的时候。曾国藩说“吾生平长进,全在受挫受辱之时” —— 大概就是这个意思。

总而言之,如果你也有过类似的念头 —— 想换个方向,想看一看系统的另一面,或者单纯想跳出舒适区,那我真诚地献上一句来自“过来人”的鼓励:

你可以的。

只要你愿意试,愿意学,你肯定会有所收获。

以上,希望对你有点帮助:)

Vue3 + Vite 从零搭建项目,超详细入门指南

前言

Vue3 搭配 Vite 构建工具,开发速度飞起。这篇文章带你从 0 到 1 搭建一个标准 Vue3 工程化项目。

一、环境准备

确保你已安装:

  • Node.js 16+
  • npm / yarn / pnpm

二、使用 Vite 创建项目

npm create vite@latest

步骤:

  1. 输入项目名
  2. 选择 Vue
  3. 选择 JavaScriptTypeScript

进入项目:

cd 项目名
npm install
npm run dev

打开浏览器即可看到 Vue3 欢迎页面。

三、项目结构说明

  • main.js:入口文件
  • App.vue:根组件
  • components/:公共组件
  • views/:页面组件
  • router/:路由
  • store/:状态管理
  • assets/:静态资源

四、配置 Vue Router

安装:

npm install vue-router@4

新建 router/index.js

import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  }
]

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

export default router

main.js 挂载:

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

createApp(App).use(router).mount('#app')

五、配置 Pinia 状态管理

安装:

npm install pinia

新建 store/index.js

import { createPinia } from 'pinia'
const pinia = createPinia()
export default pinia

main.js 挂载:

import store from './store'
createApp(App).use(router).use(store).mount('#app')

总结

本文完成了:

  • Vite + Vue3 项目创建
  • Vue Router 4 路由配置
  • Pinia 状态管理配置

下一篇我们讲 Vue3 组合式 API 最佳实践。

❌