普通视图

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

PM2完全指南:从入门到精通

作者 NolanKy
2026年2月25日 17:22

引言:为什么需要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)实战

作者 冒泡汽水
2026年2月25日 17:10

前言

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) 应用踩坑全指南

2026年2月25日 16:57

随着纯血鸿蒙 (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?

2026年2月25日 16:50

一、 回顾历史:虚拟 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 操作。

过程即奖励|前端转后端经验分享

作者 禾味
2026年2月25日 16:43

转岗动机

先简单介绍一下我的背景:通信专业,秋招前自学前端,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 从零搭建项目,超详细入门指南

作者 冒泡汽水
2026年2月25日 15:56

前言

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 最佳实践。

一次讲透 NestJS 里“绑定”(全局 vs 局部)

作者 Mr_li
2026年2月25日 15:53

你在 NestJS 里看到的 @UseGuards()@UsePipes()app.useGlobalInterceptors() 这些,本质上都在做一件事:

  • 把一段“横切逻辑”挂到请求处理链上
    比如:鉴权、参数校验、日志、统一返回体、统一异常格式……

这篇就用“人话”把三个问题讲清楚:

  • NestJS 里可绑定的【元素】有哪些
  • 全局绑定 vs 局部绑定:作用与区别
  • 全局绑定的多种形式:各自原理/传参/差异/注意点,以及怎么选

本文所有结论都以 NestJS 官方文档为准(会在对应小节标注链接)。

目录

  1. NestJS 里能“绑定”的【元素】有哪些?
  2. 全局绑定 vs 局部绑定:作用与区别
  3. 全局绑定的多种形式:到底差在哪?
  4. 五类元素分别怎么绑、怎么传参、有哪些坑?
  5. 选型:什么时候用哪种绑定方式?
  6. 总结

1. NestJS 里能“绑定”的【元素】有哪些?

日常开发里,最常说的“绑定”,基本就这五类(也是官方文档重点讲的五条链路):

  • Middleware(中间件):在路由处理前跑的一段函数/类,能拿到 req/res/next
    参考:Middleware
  • Guard(守卫):决定“这次请求到底能不能进到 handler”。典型用来做鉴权/权限。
    参考:Guards
  • Pipe(管道):对入参做校验转换(字符串转数字、DTO 校验等),发生在方法调用前。
    参考:Pipes
  • Interceptor(拦截器):更像 AOP,能在 handler 前后插逻辑、改返回值、做缓存、把异常映射成别的异常等。
    参考:Interceptors
  • Exception Filter(异常过滤器):专门兜异常,统一格式、打日志、屏蔽敏感信息等。
    参考:Exception filters

如果你要一个“背诵版”的链路顺序,官方明确写过的一句是:

  • Guard 在所有 Middleware 之后执行,并且在任何 Interceptor 或 Pipe 之前执行
    参考:Guards - Hint

2. 全局绑定 vs 局部绑定:作用与区别(别背概念,直接按场景理解)

先把“范围”说清楚,后面选型才不容易绕晕。

  • 局部绑定(Local / Scoped):只影响“某个控制器 / 某个路由方法 / 某个参数”。
    典型写法:@UseGuards()@UsePipes()@UseInterceptors()@UseFilters(),以及 Pipe 还能绑到参数上。
  • 全局绑定(Global):影响“整个应用里所有 controller + 所有 route handler”。
    典型写法:app.useGlobalXxx(...)、模块 providers 里用 APP_XXX、Middleware 的 app.use(...) / forRoutes('*')

一句话区分:

  • 局部绑定:像“给某个接口/模块单独加一条规则”
  • 全局绑定:像“把规则写进公司制度,所有人默认都得遵守”

3. 全局绑定不止一种写法:到底差在哪?

这个是很多人纠结的核心:为什么全局还能写出两三种形式?我该用哪个?

3.1 main.tsapp.useGlobalXxx(new ...):直给、简单,但 DI 有坑

以 Pipe 为例,官方给过最直观的全局写法:

// main.ts
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe());

参考:Pipes - Global scoped pipes

这种写法的本质是:你自己把实例 new 出来,挂到应用上

还有个容易被忽略的“覆盖范围”问题:在混合应用(HTTP + WS/微服务)里,useGlobalPipes() / useGlobalGuards() 默认不一定覆盖网关/微服务。官方在 pipes/guards 里都有提醒。
参考:

3.2 模块里用 APP_PIPE / APP_GUARD / APP_INTERCEPTOR / APP_FILTER:更“框架化”,DI 友好

官方给的“解决 DI 问题”的标准姿势,就是把它注册成 provider:

// app.module.ts(示例:全局 Pipe)
import { APP_PIPE } from '@nestjs/core';

@Module({
  providers: [
    { provide: APP_PIPE, useClass: ValidationPipe },
  ],
})
export class AppModule {}

参考:Pipes - Global scoped pipes(APP_PIPE)

Guard / Interceptor / Filter 的写法完全一样,只是 token 变了:

这种写法的本质是:交给 Nest DI 容器来创建实例

  • 优点:能注入依赖;更容易做可测试的设计;在复杂业务里更推荐
  • 注意:官方也强调——不管你在哪个 module 里写,它都是“真的全局”,建议放在“该类定义所在的 module”
    参考同上各章的 Hint(都提到了“choose the module where X is defined”)

3.3 装饰器里传“类” vs 传“new 出来的实例”:你其实是在选“谁来创建对象”

官方在多个章节都写过:装饰器里你可以传,也可以传实例

以 Guard 为例:

@UseGuards(RolesGuard)       // 传类:Nest 来实例化,可 DI
@UseGuards(new RolesGuard()) // 传实例:你来实例化,一般就别指望 DI 了

参考:Guards - Binding guards

Pipe/Interceptor/Filter 也是同理(官方都写了“pass class enables dependency injection / pass in-place instance for customization”那套逻辑)。

简单粗暴的结论:

  • 想要 DI:尽量传类(或用 APP_XXX
  • 想要按接口定制参数(比如某个 ParseIntPipe 想改 errorHttpStatusCode):就传 new Xxx(options)

3.4 Middleware 的全局绑定更“特别”:app.use() 很香,但它根本进不了 DI

官方对 middleware 的说明更直白:

  • app.use(logger) 能一次绑到所有路由,但无法访问 DI 容器
    参考:Middleware - Global middleware
  • 如果你需要 DI,就别用 app.use();改用 class middleware + .forRoutes('*')(它运行在 module 里,能注入)
    参考同上(官方也给了替代方案)

4. 逐个元素讲清楚:怎么绑、怎么传参、有哪些坑

下面每个元素我都给你:能绑在哪些层级 + 全局的几种写法 + 需要注意的点 + 伪代码

4.1 Middleware(中间件)

能绑在哪些层级

  • 模块/路由级consumer.apply(...).forRoutes(...)(最常用)
  • 全局app.use(...)(但不走 DI)

绑定伪代码

// 1) 模块内绑定(推荐:可 DI)
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(LoggerMiddleware).forRoutes('cats'); // 只绑 /cats

    consumer
      .apply(LoggerMiddleware)
      .forRoutes({ path: 'cats', method: RequestMethod.GET }); // 只绑 GET /cats

    consumer
      .apply(LoggerMiddleware)
      .exclude(
        { path: 'cats', method: RequestMethod.GET },
        'cats/{*splat}',
      )
      .forRoutes(CatsController); // 除了排除的,其他都绑
  }
}

// 2) 全局绑定(简单,但 DI 不可用)
const app = await NestFactory.create(AppModule);
app.use(logger); // logger 是 functional middleware

参考:Middleware - Applying middleware / Excluding routes / Global middleware

特别注意

  • 不调用 next() 请求会挂住(官方原话就是“request will be left hanging”)
    参考:Middleware
  • app.use() 的全局 middleware 拿不到 DI(要 DI 就用 .forRoutes('*') 那套)
    参考:Middleware - Global middleware
  • Express 与 Fastify 的 middleware 签名不一样(官方有 warning)
    参考同上:Middleware - Warning

4.2 Guard(守卫)

能绑在哪些层级

  • Controller 级@UseGuards() 写在类上
  • Method 级@UseGuards() 写在方法上
  • 全局app.useGlobalGuards(...)APP_GUARD

绑定伪代码

// 局部:controller 级
@Controller('cats')
@UseGuards(RolesGuard)
export class CatsController {}

// 局部:method 级
@Post()
@UseGuards(RolesGuard)
create() {}

// 全局:main.ts(不走 DI)
const app = await NestFactory.create(AppModule);
app.useGlobalGuards(new RolesGuard());

// 全局:APP_GUARD(走 DI,推荐)
@Module({
  providers: [{ provide: APP_GUARD, useClass: RolesGuard }],
})
export class AppModule {}

参考:Guards - Binding guards

特别注意

  • 执行顺序:Guard 在 middleware 之后,在 interceptor/pipe 之前
    参考:Guards - Hint
  • 混合应用覆盖范围useGlobalGuards() 在 hybrid app 默认不覆盖网关/微服务(官方 Notice)
    参考:Guards - Binding guards
  • 全局 + DI:要 DI 就别在 main.tsnew,用 APP_GUARD
    参考同上(官方写得很明确)

4.3 Pipe(管道)

Pipe 这块“绑的层级”最多,也是最容易写出花的。

能绑在哪些层级

  • 参数级@Param('id', ParseIntPipe) / @Body(new ValidationPipe())
  • 方法级@UsePipes(...)
  • Controller 级@UsePipes(...) 写在类上
  • 全局useGlobalPipes()APP_PIPE

绑定伪代码

// 参数级:把 id 转成 number,不行就直接 400
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {}

// 参数级:定制 options,就 new 一个实例
@Get(':id')
findOne(
  @Param('id', new ParseIntPipe({ errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE }))
  id: number,
) {}

// 方法级:按接口传 schema(典型“每个接口一套校验规则”)
@Post()
@UsePipes(new ZodValidationPipe(createCatSchema))
create(@Body() dto: CreateCatDto) {}

// 全局:main.ts(不走 DI)
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe());

// 全局:APP_PIPE(走 DI,推荐)
@Module({
  providers: [{ provide: APP_PIPE, useClass: ValidationPipe }],
})
export class AppModule {}

参考:Pipes - Binding pipes / Global scoped pipes

特别注意

  • Pipe 抛异常会进入异常层处理(官方叫 exceptions zone),抛了异常 handler 就不会执行
    参考:Pipes - Hint
  • 混合应用覆盖范围useGlobalPipes() 在 hybrid app 下默认不覆盖网关/微服务(官方 Notice)
    参考:Pipes - Global scoped pipes
  • 全局 + DI:同样要用 APP_PIPE(官方直接点名)
    参考同上

4.4 Interceptor(拦截器)

能绑在哪些层级

  • Controller / Method 级@UseInterceptors(...)
  • 全局useGlobalInterceptors()APP_INTERCEPTOR

绑定伪代码

// 局部:controller 级
@UseInterceptors(LoggingInterceptor)
export class CatsController {}

// 全局:main.ts(不走 DI)
const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new LoggingInterceptor());

// 全局:APP_INTERCEPTOR(走 DI,推荐)
@Module({
  providers: [{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor }],
})
export class AppModule {}

参考:Interceptors - Binding interceptors

特别注意

4.5 Exception Filter(异常过滤器)

能绑在哪些层级

  • Method / Controller 级@UseFilters(...)
  • 全局useGlobalFilters()APP_FILTER

绑定伪代码

// 局部:method 级
@Post()
@UseFilters(HttpExceptionFilter) // 推荐传类,让 Nest 复用实例
create() {}

// 全局:main.ts(不走 DI)
const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new HttpExceptionFilter());

// 全局:APP_FILTER(走 DI,推荐)
@Module({
  providers: [{ provide: APP_FILTER, useClass: HttpExceptionFilter }],
})
export class AppModule {}

参考:Exception filters - Binding filters

特别注意


5. 到底怎么选?给你一套“能直接落地”的决策规则

你可以按这几个问题来选,基本不踩坑。

5.1 这段逻辑是不是“所有接口都必须有”?

  • (比如统一校验/统一返回体/统一异常格式/全局鉴权):倾向全局
  • 不是(只对某几个接口/某个模块生效):局部绑定,别污染全局

5.2 这段逻辑要不要注入 Service / Config / DB / Cache?

  • 要 DI
    • Guard/Pipe/Interceptor/Filter:优先用 APP_GUARD / APP_PIPE / APP_INTERCEPTOR / APP_FILTER
    • Middleware:优先用 class middleware + consumer.apply(...).forRoutes('*')
  • 不要 DI
    • 你就可以用 main.tsuseGlobalXxx(new ...)app.use(...),写起来最快

(这些 DI 限制和替代方案,官方都写在对应章节里了:
PipesGuardsInterceptorsException filtersMiddleware

5.3 你需要“每个接口参数不一样”吗?

  • 需要(比如某个 Pipe 要带不同 options、或者每个接口校验 schema 不一样):局部 new Xxx(options) 更合适
  • 不需要(全站统一同一套配置):全局注册一次,别每个方法都写一遍

5.4 你项目是不是 hybrid(HTTP + WS/微服务)?

如果是,别默认以为 useGlobalXxx() 就全覆盖。官方在 pipes/guards/filters 里都写了“hybrid app”的注意点,建议你在项目里明确验证一下覆盖范围:


6. 总结

  • 能绑定的核心元素就 5 个:Middleware、Guard、Pipe、Interceptor、Exception Filter。
  • 局部绑定解决“精准控制”:只对某个 controller / method / param 生效,最不容易“误伤”别的模块。
  • 全局绑定解决“统一规则”:所有接口默认生效,但你要对“DI 能不能用、hybrid 覆盖范围”保持敏感。
  • 全局绑定最重要的分水岭是 DI
    • main.tsuseGlobalXxx(new ...):快,但基本不走 DI
    • module 里的 APP_XXX:更工程化,DI 友好,复杂项目更推荐
    • middleware 的 app.use():最简单,但拿不到 DI;要 DI 就 .forRoutes('*')
  • 装饰器传“类”还是“实例”:你其实是在决定“让 Nest 创建对象(可 DI、可复用)”还是“自己 new(方便定制参数)”。

Docker 从入门到部署实战

作者 赵_叶紫
2026年2月25日 15:31

Docker 从入门到部署实战


1. Docker基础

1.1 Docker 基础概念

1.1.1 容器化

容器化是一种轻量级的虚拟化技术,将应用程序及其所有依赖项打包到一个**标准化的单元(容器)**中,确保应用在任何环境中都能一致运行。

核心思想:"Build once, Run anywhere"

graph LR
    A[开发者编写代码] --> B[打包为容器镜像]
    B --> C[开发环境运行]
    B --> D[测试环境运行]
    B --> E[生产环境运行]
    style B fill:#0db7ed,color:#fff

1.1.2 容器 vs 虚拟机

graph TB
    subgraph 虚拟机架构
        direction TB
        H1[物理硬件]
        HV[Hypervisor 虚拟机管理程序]
        subgraph VM1[虚拟机1]
            G1[Guest OS]
            B1[Bins/Libs]
            A1[App A]
        end
        subgraph VM2[虚拟机2]
            G2[Guest OS]
            B2[Bins/Libs]
            A2[App B]
        end
        H1 --> HV
        HV --> VM1
        HV --> VM2
    end

    subgraph 容器架构
        direction TB
        H2[物理硬件]
        OS[Host OS 宿主操作系统]
        DE[Docker Engine]
        subgraph C1[容器1]
            CB1[Bins/Libs]
            CA1[App A]
        end
        subgraph C2[容器2]
            CB2[Bins/Libs]
            CA2[App B]
        end
        H2 --> OS
        OS --> DE
        DE --> C1
        DE --> C2
    end
特性 容器 虚拟机
启动速度 秒级 分钟级
体积 MB 级 GB 级
性能 接近原生 有损耗
隔离性 进程级隔离 完全隔离
操作系统 共享宿主内核 独立 Guest OS
资源占用 极少 较多
单机数量 数百个 通常几十个

1.1.3 Docker 架构

Docker 采用 C/S(客户端-服务端)架构

graph LR
    subgraph Client[Docker Client 客户端]
        CMD["docker build<br>docker pull<br>docker run<br>docker ps<br>..."]
    end

    subgraph Host[Docker Host 宿主机]
        DAEMON[Docker Daemon<br>dockerd]
        subgraph Images[镜像]
            IMG1[nginx:latest]
            IMG2[node:18]
            IMG3[mysql:8.0]
        end
        subgraph Containers[容器]
            CON1[web-server]
            CON2[api-app]
        end
        DAEMON --> Images
        DAEMON --> Containers
    end

    subgraph Registry[Registry 镜像仓库]
        DH[Docker Hub]
        PR[私有仓库]
    end

    Client -->|REST API| DAEMON
    DAEMON -->|pull / push| Registry

三大核心组件:

组件 说明
Docker Client 用户与 Docker 交互的命令行工具,发送命令给 Daemon
Docker Daemon 后台守护进程,负责构建、运行和管理容器
Docker Registry 存储和分发镜像的仓库服务

1.1.4 核心概念

1.1.4.1 镜像(Image)

镜像是一个只读模板,包含运行应用所需的一切:代码、运行时、库、环境变量、配置文件。

graph TB
    subgraph 镜像分层结构
        direction TB
        L1[Layer 4: 应用代码 COPY . /app]
        L2[Layer 3: 安装依赖 RUN npm install]
        L3[Layer 2: 设置工作目录 WORKDIR /app]
        L4[Layer 1: 基础镜像 FROM node:18-alpine]
        L1 --> L2 --> L3 --> L4
    end
    style L1 fill:#0db7ed,color:#fff
    style L2 fill:#1a9bd7,color:#fff
    style L3 fill:#2980b9,color:#fff
    style L4 fill:#3867a8,color:#fff

关键特性:

  • 分层存储:每一层都是只读的,层与层之间可复用,节省磁盘空间
  • 写时复制(Copy-on-Write):容器运行时在最上层添加可写层
  • 通过 Dockerfile 构建:使用声明式语法定义镜像内容

常用命令:

docker images                  # 列出本地镜像
docker pull nginx:latest       # 从仓库拉取镜像
docker rmi nginx:latest        # 删除镜像
docker build -t myapp:1.0 .    # 构建镜像
docker tag myapp:1.0 myapp:v1  # 为镜像创建新的标签引用,不会复制镜像,两个标签指向同一镜像 ID
docker image prune             # 删除所有无标签(<none>)的悬空镜像,加 -a 可删除所有未被容器引用的镜像
1.1.4.2 容器(Container)

容器是镜像的运行实例,拥有自己的文件系统、网络、进程空间。

stateDiagram-v2
    [*] --> Created: docker create
    Created --> Running: docker start
    Running --> Paused: docker pause
    Paused --> Running: docker unpause
    Running --> Stopped: docker stop
    Stopped --> Running: docker start
    Running --> Stopped: docker kill
    Stopped --> Deleted: docker rm
    Deleted --> [*]

常用命令:

docker run -d --name web -p 80:80 nginx    # 基于 nginx 镜像创建名为 web 的容器,后台运行并将宿主机 80 端口映射到容器 80 端口
docker ps                                   # 查看运行中的容器
docker ps -a                                # 查看所有容器(含停止的)
docker stop web                             # 停止容器
docker start web                            # 启动容器
docker restart web                          # 重启容器
docker rm web                               # 删除容器
docker exec -it web bash                    # 进入容器交互终端
docker logs -f web                          # 查看容器日志(持续输出)

docker run 常用参数:

参数 说明 示例
-d 后台运行 docker run -d nginx
-p 端口映射 -p 8080:80(宿主机:容器)
--name 容器名称 --name my-nginx
-v 挂载数据卷:将宿主机目录映射到容器内目录,实现数据持久化和文件共享(容器删除后数据不丢失) -v /host/path:/container/path
-e 设置容器内的环境变量,容器内的应用可通过 process.env(Node)或 os.environ(Python)等方式读取;常用于传递数据库密码、端口号等配置,无需修改镜像即可改变应用行为 -e MYSQL_ROOT_PASSWORD=123
--rm 容器停止后自动删除该容器(不会删除镜像),常用于临时测试场景 docker run --rm nginx
--network 将容器加入指定的 Docker 网络,同一网络内的容器可通过容器名互相访问(如 web 容器访问 db 容器) --network my-net
-it -i 保持标准输入打开 + -t 分配伪终端,组合使用可进入容器内的交互式命令行(类似 SSH 进入服务器) docker run -it ubuntu bash
1.1.4.3 仓库(Registry)

Registry 是集中存储和分发镜像的服务。

graph TB
    subgraph 公共仓库
        DH[Docker Hub<br>hub.docker.com]
        GH[GitHub Container Registry<br>ghcr.io]
        AL[阿里云镜像仓库<br>registry.cn-hangzhou.aliyuncs.com]
    end
    subgraph 私有仓库
        HR[Harbor]
        NX[Nexus]
        RE[Docker Registry]
    end

    DEV[开发者] -->|docker push| DH
    DEV -->|docker pull| DH
    DEV -->|docker push| HR
    DEV -->|docker pull| HR

镜像命名规范:

[仓库地址/]命名空间/镜像名:标签

示例:
nginx:latest                                       # 官方镜像,默认 Docker Hub
library/nginx:1.25                                 # 完整官方路径
registry.cn-hangzhou.aliyuncs.com/myns/myapp:v1.0  # 阿里云私有仓库

常用命令:

docker login                        # 登录 Docker Hub
docker login registry.example.com   # 登录私有仓库
docker push myrepo/myapp:1.0       # 推送镜像到仓库
docker pull myrepo/myapp:1.0       # 从仓库拉取镜像
docker search nginx                 # 搜索 Docker Hub 上的镜像

1.2 环境搭建(Windows + WSL Ubuntu)

1.2.1 整体架构

graph TB
    subgraph Windows[Windows 宿主机]
        PS[PowerShell / Terminal]
    end
    subgraph WSL2[WSL 2 - Ubuntu]
        subgraph DockerEngine[Docker Engine]
            CLI[Docker Client<br>docker CLI 命令行工具]
            DAEMON[Docker Daemon<br>dockerd 后台守护进程]
            RT[containerd + runc<br>容器运行时]
            CLI -->|REST API| DAEMON
            DAEMON --> RT
        end
        subgraph Containers[运行中的容器]
            C1[nginx]
            C2[mysql]
            C3[node-app]
        end
        RT --> Containers
    end
    PS -->|wsl 命令进入| WSL2

不安装 Docker Desktop,直接在 WSL 2 Ubuntu 中安装 Docker Engine,轻量且免费。

Docker Engine 包含的组件:

组件 包名 说明
Docker Client docker-ce-cli 命令行工具,用户输入 docker 命令时调用的就是它
Docker Daemon docker-ce 后台守护进程 dockerd,负责管理镜像、容器、网络、存储卷
containerd containerd.io 容器运行时,负责容器的生命周期管理(创建、启动、停止)
BuildKit docker-buildx-plugin 新一代镜像构建引擎
Compose docker-compose-plugin 多容器编排工具,通过 docker compose 命令使用

1.2.2 Docker 安装

步骤一:启用 WSL 2

管理员身份打开 PowerShell:

# 启用 WSL 功能
dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart

# 启用虚拟机平台
dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart

# 重启电脑后,设置 WSL 默认版本为 2
wsl --set-default-version 2

# 安装 Ubuntu(如果尚未安装)
wsl --install -d Ubuntu

# 查看已安装的发行版及 WSL 版本(确认 VERSION 为 2)
wsl -l -v
步骤二:在 WSL Ubuntu 中安装 Docker Engine
# 进入 WSL Ubuntu
wsl -d Ubuntu

# 卸载旧版本(如有)
sudo apt-get remove docker docker-engine docker.io containerd runc

# 更新本地包索引(从软件源同步最新的可用包列表)
sudo apt-get update

# 安装添加 Docker 仓库源所需的依赖工具,-y 表示自动确认安装
sudo apt-get install -y \
    ca-certificates \  # CA 根证书,用于 HTTPS 请求时验证服务器证书的合法性
    curl \             # HTTP 命令行工具,用于下载 Docker 的 GPG 密钥
    gnupg \            # GPG 加密工具,用于验证下载的软件包签名是否可信
    lsb-release        # 提供 Linux 发行版信息(如版本代号),用于拼接正确的仓库地址

# 添加 Docker 官方 GPG 密钥
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg

# 添加 Docker 仓库源
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

# 安装 Docker Engine
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

# 将当前用户加入 docker 组(免 sudo 执行 docker 命令)
sudo usermod -aG docker $USER

# 重新登录 WSL 使用户组权限生效
exit
wsl -d Ubuntu
步骤三:设置 Docker 服务自动启动

WSL 2 默认不使用 systemd,需要手动启动 Docker 服务或开启 systemd。

systemd 是 Linux 的系统和服务管理器,负责在系统启动时自动拉起各种后台服务(如 Docker Daemon、网络、日志等)。类似 Windows 的"服务管理器"(services.msc),开启后 Docker 服务会随系统启动自动运行,无需每次手动执行 sudo service docker start

方式一:开启 WSL systemd 支持(推荐)

# 编辑 WSL 配置文件
sudo tee /etc/wsl.conf <<-'EOF'
[boot]
systemd=true
EOF

然后在 PowerShell 中重启 WSL:

wsl --shutdown
wsl -d Ubuntu

重启后 Docker 会随 systemd 自动启动。

方式二:每次手动启动

# 启动 Docker 服务
sudo service docker start

1.2.3 镜像加速配置

国内访问 Docker Hub 较慢,需要配置镜像加速器。

在 WSL Ubuntu 中配置:

# 创建或编辑 daemon.json
sudo mkdir -p /etc/docker
sudo tee /etc/docker/daemon.json <<-'EOF'
{
  "registry-mirrors": [
    "https://docker.1ms.run",
    "https://docker.xuanyuan.me",
    "https://docker.rainbond.cc"
  ]
}
EOF

# 重启 Docker 服务
sudo systemctl daemon-reload
sudo systemctl restart docker

提示:镜像加速地址可能会变化失效,可关注 docker-practice/docker-registry-cn-mirror-test 获取最新可用地址。

1.2.4 验证安装

基本验证
# 查看 Docker 版本
docker --version
# 输出示例:Docker version 27.x.x, build xxxxxxx

# 查看详细版本信息(Client + Server)
docker version

# 查看 Docker 系统信息
docker info
运行测试容器
# 运行 hello-world 测试镜像
docker run hello-world

看到以下输出说明安装成功:

Hello from Docker!
This message shows that your installation appears to be working correctly.
...
验证镜像加速是否生效
# 查看 docker info 中的 Registry Mirrors 字段
docker info | grep -A 5 "Registry Mirrors"

# 输出示例:
#  Registry Mirrors:
#   https://docker.1ms.run/
#   https://docker.xuanyuan.me/
#   https://docker.rainbond.cc/
运行一个实际容器验证
# 运行 nginx 并映射端口
docker run -d --name test-nginx -p 8080:80 nginx

# 访问测试
curl http://localhost:8080
# 应返回 nginx 欢迎页 HTML

# 查看运行中的容器
docker ps

# 清理测试容器
docker stop test-nginx && docker rm test-nginx

1.3 基础命令

1.3.1 镜像管理

命令 说明 示例
docker search 搜索镜像 docker search nginx
docker pull 拉取镜像(默认 latest) docker pull nginx:1.25-alpine
docker images 列出本地镜像 docker images -q(仅显示 ID)
docker inspect 查看镜像详细信息 docker inspect nginx:latest
docker history 查看镜像构建历史(各层信息) docker history nginx:latest
docker tag 给镜像打标签 docker tag nginx:latest myrepo/nginx:v1
docker push 推送镜像到仓库 docker push myrepo/nginx:v1
docker save 导出镜像为 tar 文件 docker save -o nginx.tar nginx:latest
docker load 从 tar 文件加载镜像 docker load -i nginx.tar
docker rmi 删除镜像 docker rmi nginx:latest
docker image prune 清理悬空镜像(无标签 <none> docker image prune -a(清理所有未引用镜像)

1.3.2 容器管理

命令 说明 示例
docker run 创建并启动容器 docker run -d --name web -p 8080:80 nginx
docker create 仅创建容器(不启动) docker create --name web nginx
docker ps 查看容器 docker ps -a(含停止的)
docker ps -q(仅 ID)
docker start 启动容器 docker start web
docker stop 停止容器 docker stop web
docker restart 重启容器 docker restart web
docker kill 强制停止容器 docker kill web
docker pause 暂停容器 docker pause web
docker unpause 恢复容器 docker unpause web
docker inspect 查看容器详细信息 docker inspect web
docker logs 查看容器日志 docker logs -f --tail 100 web
docker top 查看容器内进程 docker top web
docker stats 查看容器资源使用 docker stats web
docker cp 容器与宿主机复制文件 docker cp web:/etc/nginx/nginx.conf ./(容器→宿主机)
docker cp ./index.html web:/usr/share/nginx/html/(宿主机→容器)
docker rm 删除容器 docker rm -f web(强制删除)
docker container prune 清理所有已停止的容器 docker container prune

1.3.3 容器交互

命令 说明 示例
docker exec -it 进入运行中的容器(推荐) docker exec -it web bash
docker exec -it web sh(alpine 等轻量镜像)
docker exec -it -u root 以 root 身份进入容器 docker exec -it -u root web bash
docker exec 在容器中执行单条命令 docker exec web cat /etc/nginx/nginx.conf
docker exec -e 设置环境变量并执行命令 docker exec -e MY_VAR=hello web env
docker run -it --rm 交互模式启动临时容器(退出自动删除) docker run -it --rm ubuntu bash
docker attach 连接容器主进程 docker attach web(Ctrl+P Ctrl+Q 分离)
docker logs -f 持续查看容器日志 docker logs -f --tail 50 web

exec vs attach exec 在容器内启动新进程,退出不影响容器运行;attach 连接到容器主进程(PID 1),退出可能导致容器停止。推荐使用 exec

1.3.4 资源限制

Docker 默认不对容器做资源限制,容器可消耗宿主机全部可用资源。在生产环境中,必须通过以下参数限制资源,防止单个容器耗尽资源导致宿主机或其他容器不可用。

参数 说明 示例
--memory / -m 容器可使用的最大物理内存。超出限制时容器内进程会被 OOM Killer 杀掉。支持单位:bkmg docker run -d --memory 512m nginx
--memory-swap 内存 + swap 总上限(须 ≥ --memory)。swap 是磁盘上的虚拟内存空间,物理内存不足时系统将部分不活跃数据暂存到磁盘,速度远慢于内存但可防止进程被 OOM 杀掉。设为与 --memory 相同值则禁用 swap docker run -d --memory 512m --memory-swap 1g nginx(物理内存 512m + swap 512m)
--cpus 容器最多可使用的 CPU 核数。1.5 表示最多使用 1.5 个核心的计算能力 docker run -d --cpus 1.5 nginx
--cpu-shares CPU 时间片的相对权重(默认 1024)。仅在 CPU 资源争抢时生效:权重 512 的容器获得的 CPU 时间是 1024 的一半。CPU 空闲时不受此限制 docker run -d --cpu-shares 512 nginx
--pids-limit 容器内最大进程数,防止 fork 炸弹(恶意或意外无限创建进程)耗尽系统进程表 docker run -d --pids-limit 100 nginx

组合使用示例:

docker run -d --name web \
    --memory 512m \
    --cpus 1.5 \
    --pids-limit 100 \
    -p 8080:80 \
    nginx

运行时更新资源限制(无需重启容器):

docker update --memory 1g --cpus 2 web

2. Docker 镜像与容器深入

2.1 镜像原理

2.1.1 镜像分层结构

Docker 镜像由多个**只读层(Layer)**叠加而成,每层对应 Dockerfile 中的一条指令。层之间共享复用,极大节省磁盘空间和传输时间。

graph TB
    subgraph 镜像分层示意
        direction TB
        W[可写层 Container Layer]
        L4[Layer 4: CMD / EXPOSE]
        L3[Layer 3: COPY 应用代码]
        L2[Layer 2: RUN npm install]
        L1[Layer 1: FROM node:18-alpine]
        W -->|写时复制 CoW| L4
        L4 --> L3 --> L2 --> L1
    end
    style W fill:#e74c3c,color:#fff
    style L4 fill:#0db7ed,color:#fff
    style L3 fill:#1a9bd7,color:#fff
    style L2 fill:#2980b9,color:#fff
    style L1 fill:#3867a8,color:#fff

核心机制:

概念 说明
联合文件系统(UnionFS) 将多个只读层合并为一个统一的文件系统视图,容器看到的是完整的目录结构
写时复制(Copy-on-Write) 容器运行时在最上层添加可写层;修改文件时先从只读层复制到可写层再修改,不影响原始镜像
层缓存 构建镜像时,未变化的层直接使用缓存,只重建变化的层及其之后的层
内容寻址 每层通过 SHA256 哈希标识,相同内容的层在不同镜像间共享
# 查看镜像分层信息
docker history nginx:latest

# 查看镜像详细元数据(含每层 diff ID)
docker inspect nginx:latest

# 查看镜像实际磁盘占用(含共享层)
docker system df -v
2.1.2 镜像标签

标签(Tag)用于标识镜像的不同版本,同一镜像可以有多个标签。

镜像全名格式:[仓库地址/]命名空间/镜像名:标签

示例:
nginx:latest          # latest 是默认标签,指向最新版本
nginx:1.25-alpine     # 指定版本 + 变体
node:18-slim          # slim 变体,精简版
python:3.12-bookworm  # 基于 Debian Bookworm

常见标签约定:

标签格式 说明
latest 最新版本(不建议生产使用,内容可能随时变化)
1.25 / 18 主版本号,会随小版本更新
1.25.3 精确版本号(生产推荐)
alpine 基于 Alpine Linux,体积极小(约 5MB)
slim 精简版 Debian,移除了不常用的包
bookworm / bullseye 基于特定 Debian 版本代号
# 给镜像打标签
docker tag nginx:latest myrepo/nginx:v1.0
docker tag nginx:latest myrepo/nginx:production

# 查看本地所有标签
docker images nginx

# 删除特定标签(不删除镜像本身,除非是最后一个标签)
docker rmi myrepo/nginx:v1.0
2.1.3 镜像仓库
graph LR
    DEV[开发者] -->|docker build| LOCAL[本地镜像]
    LOCAL -->|docker tag + push| REG[远程仓库]
    REG -->|docker pull| SERVER[服务器]

    subgraph 仓库类型
        PUB[公共仓库<br>Docker Hub / 阿里云]
        PRI[私有仓库<br>Harbor / Registry]
    end

常用公共仓库:

仓库 地址 说明
Docker Hub hub.docker.com 全球最大,官方镜像仓库
阿里云 ACR cr.console.aliyun.com 国内访问快,免费个人版
GitHub GHCR ghcr.io 与 GitHub 深度集成
腾讯云 TCR cloud.tencent.com/product/tcr 腾讯云容器镜像服务
# 登录 Docker Hub
docker login

# 登录私有仓库
docker login registry.cn-hangzhou.aliyuncs.com

# 推送镜像(需先 tag 为仓库地址前缀)
docker tag myapp:v1 registry.cn-hangzhou.aliyuncs.com/mynamespace/myapp:v1
docker push registry.cn-hangzhou.aliyuncs.com/mynamespace/myapp:v1

# 从私有仓库拉取
docker pull registry.cn-hangzhou.aliyuncs.com/mynamespace/myapp:v1

# 登出
docker logout registry.cn-hangzhou.aliyuncs.com
2.1.4 镜像导入导出

在无法访问外网或需要离线部署时,可通过导入导出在不同机器间传输镜像。

# 导出镜像为 tar 文件
docker save -o nginx.tar nginx:latest

# 导出多个镜像到一个文件
docker save -o images.tar nginx:latest mysql:8.0 redis:7

# 导入镜像
docker load -i nginx.tar

# 从容器导出文件系统(不含镜像元数据,仅文件系统快照)
docker export -o container-fs.tar my-container

# 从文件系统快照导入为新镜像
docker import container-fs.tar myimage:snapshot

save/load vs export/import save/load 操作的是镜像,保留完整的分层和元数据,用于镜像迁移;export/import 操作的是容器文件系统,会丢失分层和历史记录,合并为单层镜像。

2.2 容器生命周期

2.2.1 容器状态
stateDiagram-v2
    [*] --> Created: docker create / docker run
    Created --> Running: docker start
    Running --> Paused: docker pause
    Paused --> Running: docker unpause
    Running --> Stopped: docker stop(优雅停止,先 SIGTERM 后 SIGKILL)
    Running --> Stopped: docker kill(强制停止,直接 SIGKILL)
    Stopped --> Running: docker start
    Stopped --> Deleted: docker rm
    Running --> Deleted: docker rm -f
    Deleted --> [*]
状态 说明
Created 容器已创建但未启动,分配了文件系统和网络配置
Running 容器主进程(PID 1)正在运行
Paused 容器进程被挂起(SIGSTOP),冻结在内存中不消耗 CPU
Stopped 容器主进程已退出,文件系统和配置仍保留
Deleted 容器被移除,所有资源释放
# 查看容器当前状态
docker inspect --format='{{.State.Status}}' my-container

# 查看容器启动时间、退出码等
docker inspect --format='{{json .State}}' my-container | python3 -m json.tool
2.2.2 容器操作

创建与启动:

# 创建并启动(最常用)
docker run -d --name web -p 8080:80 nginx

# 仅创建(稍后启动)
docker create --name web -p 8080:80 nginx
docker start web

# 启动时指定重启策略
docker run -d --name web --restart=unless-stopped -p 8080:80 nginx

重启策略:

策略 说明
no 默认值,不自动重启
on-failure[:max-retries] 非正常退出时重启,可限制最大重试次数
always 无论退出码如何都重启,Docker Daemon 启动时也会拉起
unless-stopped 类似 always,但手动 docker stop 后不会被 Daemon 自动拉起

停止与删除:

# 优雅停止(先发 SIGTERM,默认等待 10 秒后发 SIGKILL)
docker stop web

# 指定等待时间
docker stop -t 30 web

# 强制停止
docker kill web

# 删除已停止的容器
docker rm web

# 强制删除运行中的容器
docker rm -f web

# 批量删除所有已停止的容器
docker container prune -f
2.2.3 容器日志
# 查看全部日志
docker logs web

# 持续输出日志(类似 tail -f)
docker logs -f web

# 查看最近 100 行
docker logs --tail 100 web

# 显示时间戳
docker logs -t web

# 查看指定时间段的日志
docker logs --since 2024-01-01T00:00:00 --until 2024-01-02T00:00:00 web

# 查看最近 30 分钟的日志
docker logs --since 30m web

日志驱动:

Docker 支持多种日志驱动,默认使用 json-file,日志存储在宿主机的 /var/lib/docker/containers/<container-id>/ 目录下。

# 查看容器使用的日志驱动
docker inspect --format='{{.HostConfig.LogConfig.Type}}' web

# 启动时指定日志驱动和配置(限制单个日志文件最大 10MB,最多保留 3 个文件)
docker run -d --name web \
    --log-driver json-file \
    --log-opt max-size=10m \
    --log-opt max-file=3 \
    nginx
2.2.4 资源监控
# 实时查看容器资源使用(CPU、内存、网络 I/O、磁盘 I/O)
docker stats

# 查看指定容器
docker stats web

# 仅输出一次(非实时,适合脚本采集)
docker stats --no-stream

# 自定义输出格式
docker stats --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"

# 查看容器内进程
docker top web

# 查看容器文件系统变更(相对于镜像)
docker diff web
# A = 新增,C = 修改,D = 删除

2.3 容器与宿主机交互

2.3.1 端口映射

将宿主机端口映射到容器端口,使外部可以访问容器内的服务。

graph LR
    USER[外部用户] -->|访问 宿主机:8080| HOST[宿主机]
    HOST -->|转发到 容器:80| CONTAINER[容器 nginx]
# 映射指定端口
docker run -d -p 8080:80 nginx
# 宿主机 8080 → 容器 80

# 映射多个端口
docker run -d -p 8080:80 -p 8443:443 nginx

# 映射到指定 IP(仅本机可访问)
docker run -d -p 127.0.0.1:8080:80 nginx

# 随机映射端口(宿主机自动分配空闲端口)
docker run -d -P nginx

# 查看端口映射关系
docker port web
2.3.2 文件拷贝
# 容器 → 宿主机
docker cp web:/etc/nginx/nginx.conf ./nginx.conf

# 宿主机 → 容器
docker cp ./index.html web:/usr/share/nginx/html/index.html

# 拷贝整个目录
docker cp web:/var/log/nginx ./nginx-logs
docker cp ./config/ web:/app/config/

注意: docker cp 不支持容器间直接拷贝,需以宿主机为中转。

2.3.3 进入容器
# 推荐方式:exec 启动新进程
docker exec -it web bash        # Bash shell
docker exec -it web sh          # Alpine 等轻量镜像用 sh
docker exec -it -u root web bash  # 以 root 身份进入

# 执行单条命令(不进入交互模式)
docker exec web cat /etc/nginx/nginx.conf
docker exec web ls -la /app

# 传递环境变量
docker exec -e DEBUG=true web node /app/script.js

# attach 方式(连接主进程,不推荐)
docker attach web
# Ctrl+P Ctrl+Q 可安全分离,不停止容器
# 直接 Ctrl+C 会停止容器主进程
2.3.4 容器网络基础

每个容器默认分配独立的网络命名空间和 IP 地址。

# 查看容器 IP 地址
docker inspect --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' web

# 查看容器网络详情
docker inspect web | grep -A 20 "Networks"

# 从宿主机 ping 容器(需在同一网络)
ping $(docker inspect --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' web)

# 查看容器 DNS 配置
docker exec web cat /etc/resolv.conf

# 查看容器 hosts 文件
docker exec web cat /etc/hosts

3. Docker 网络与数据卷

3.1 Docker 网络

3.1.1 网络模式

Docker 提供多种网络模式,适用于不同场景。

graph TB
    subgraph Bridge模式[bridge 默认模式]
        BR[docker0 网桥<br>172.17.0.1]
        C1B[容器1<br>172.17.0.2]
        C2B[容器2<br>172.17.0.3]
        BR --- C1B
        BR --- C2B
    end

    subgraph Host模式[host 模式]
        HN[宿主机网络栈]
        C1H[容器<br>共享宿主机 IP 和端口]
        HN --- C1H
    end

    subgraph None模式[none 模式]
        C1N[容器<br>无网络,仅 loopback]
    end
网络模式 说明 使用场景
bridge 默认模式,容器连接到 docker0 虚拟网桥,通过 NAT 访问外网 大多数单机场景
host 容器直接使用宿主机网络栈,无网络隔离,性能最好 对网络性能要求极高的场景
none 容器无网络接口(仅 loopback),完全隔离 安全敏感的离线计算任务
container 与指定容器共享网络命名空间(IP 和端口) Sidecar 模式,如日志收集器
# 默认 bridge 模式
docker run -d --name web nginx

# host 模式
docker run -d --network host --name web nginx

# none 模式
docker run -d --network none --name isolated-app myapp

# 与另一个容器共享网络
docker run -d --name app1 nginx
docker run -d --network container:app1 --name app2 busybox
3.1.2 自定义网络

自定义网络提供比默认 bridge 更好的隔离性和 DNS 自动解析(容器间可通过容器名互相访问)。

# 创建自定义 bridge 网络
docker network create my-network

# 指定子网和网关
docker network create --subnet 172.20.0.0/16 --gateway 172.20.0.1 my-network

# 在自定义网络中启动容器
docker run -d --name web --network my-network nginx
docker run -d --name api --network my-network node:18

# 容器间通过名称互访(自定义网络自动提供 DNS)
docker exec api ping web    # ✅ 可直接用容器名
docker exec api curl http://web:80  # ✅ HTTP 访问

# 查看网络列表
docker network ls

# 查看网络详情(含已连接的容器)
docker network inspect my-network

# 删除网络
docker network rm my-network

# 清理所有未使用的网络
docker network prune
3.1.3 容器互联
graph TB
    subgraph 自定义网络 my-network
        WEB[web 容器<br>nginx<br>172.20.0.2]
        API[api 容器<br>node-app<br>172.20.0.3]
        DB[db 容器<br>mysql<br>172.20.0.4]
        WEB -->|curl http://api:3000| API
        API -->|mysql -h db| DB
    end
    USER[外部用户] -->|宿主机:8080| WEB

推荐方式:使用自定义网络

# 创建应用网络
docker network create app-network

# 启动数据库
docker run -d --name db \
    --network app-network \
    -e MYSQL_ROOT_PASSWORD=123456 \
    -e MYSQL_DATABASE=mydb \
    mysql:8.0

# 启动后端 API(通过容器名 db 访问数据库)
docker run -d --name api \
    --network app-network \
    -e DB_HOST=db \
    -e DB_PORT=3306 \
    -p 3000:3000 \
    my-api-app

# 启动前端(通过容器名 api 访问后端)
docker run -d --name web \
    --network app-network \
    -p 8080:80 \
    my-web-app

运行中的容器加入 / 断开网络:

# 将已运行的容器连接到网络
docker network connect app-network existing-container

# 断开网络
docker network disconnect app-network existing-container
3.1.4 网络驱动
驱动 说明 适用场景
bridge 单机桥接网络,容器通过虚拟网桥通信 单机开发和测试
host 移除容器网络隔离,直接使用宿主机网络 高性能网络需求
overlay 跨主机网络,多个 Docker 主机上的容器互通 Docker Swarm / 集群
macvlan 为容器分配真实 MAC 地址,使其在物理网络中可见 需要容器直连物理网络
none 禁用网络 安全隔离场景
ipvlan 类似 macvlan,但共享宿主机 MAC 地址 对 MAC 地址有限制的环境
# 创建 overlay 网络(需先初始化 Swarm)
docker network create --driver overlay my-overlay

# 创建 macvlan 网络
docker network create --driver macvlan \
    --subnet 192.168.1.0/24 \
    --gateway 192.168.1.1 \
    -o parent=eth0 \
    my-macvlan

3.2 Docker 数据卷

3.2.1 数据卷概念

容器的文件系统是临时的,容器删除后数据会丢失。**数据卷(Volume)**是 Docker 管理的持久化存储机制,独立于容器生命周期。

graph LR
    subgraph 容器
        APP[应用进程]
        RW["可写层<br>容器删除即丢失"]
        VOL["/data<br>挂载点"]
    end
    subgraph 宿主机
        DISK["Docker 管理的卷<br>/var/lib/docker/volumes/"]
    end
    APP --> RW
    APP --> VOL
    VOL ---|数据卷| DISK

数据卷 vs 绑定挂载:

特性 数据卷(Volume) 绑定挂载(Bind Mount)
管理方式 Docker 管理 用户指定宿主机路径
存储位置 /var/lib/docker/volumes/ 宿主机任意路径
可移植性 高,不依赖宿主机目录结构 低,依赖宿主机具体路径
权限控制 Docker 自动处理 需手动处理权限
性能 最优(Linux 原生) 依赖宿主机文件系统
备份迁移 docker volume 命令管理 需手动操作文件
推荐场景 数据库存储、持久化数据 开发环境代码同步、配置文件
3.2.2 数据卷管理
# 创建数据卷
docker volume create my-data

# 查看所有数据卷
docker volume ls

# 查看数据卷详情(存储路径、创建时间等)
docker volume inspect my-data

# 删除数据卷
docker volume rm my-data

# 清理所有未被容器使用的数据卷
docker volume prune -f
3.2.3 挂载数据卷

使用 -v--mount 参数挂载数据卷到容器中。

# -v 简写语法:卷名:容器路径[:选项]
docker run -d --name db \
    -v mysql-data:/var/lib/mysql \
    mysql:8.0

# --mount 详细语法(推荐,语义更清晰)
docker run -d --name db \
    --mount source=mysql-data,target=/var/lib/mysql \
    mysql:8.0

# 只读挂载
docker run -d --name web \
    -v nginx-conf:/etc/nginx/conf.d:ro \
    nginx

# 卷不存在时自动创建
docker run -d -v auto-created-vol:/data busybox

参数说明: source 是数据卷的名称(即 docker volume create 创建的卷名);target 是容器内的挂载路径,可以是任意路径,但通常需要指定为应用实际存储数据的目录才能实现持久化。例如 MySQL 的数据目录是 /var/lib/mysql,Nginx 日志目录是 /var/log/nginx,这些路径由镜像内的应用决定,可通过镜像文档或 docker inspect 查看。

3.2.4 绑定挂载

将宿主机目录直接挂载到容器,适合开发环境中实时同步代码。

# 绑定挂载宿主机目录
docker run -d --name web \
    -v /home/user/website:/usr/share/nginx/html \
    -p 8080:80 \
    nginx

# --mount 语法
docker run -d --name web \
    --mount type=bind,source=/home/user/website,target=/usr/share/nginx/html \
    -p 8080:80 \
    nginx

# 只读绑定挂载(容器内无法修改)
docker run -d --name web \
    -v /home/user/nginx.conf:/etc/nginx/nginx.conf:ro \
    nginx

# 开发环境:实时同步代码
docker run -d --name dev-app \
    -v $(pwd)/src:/app/src \
    -p 3000:3000 \
    node:18 npm run dev

路径规则: -v 参数中,以 /./ 开头的为绑定挂载(宿主机路径),否则为命名数据卷。

3.2.5 数据卷容器

数据卷容器是一个专门提供数据卷给其他容器共享的容器,适用于多个容器间共享数据。

# 创建数据卷容器(不需要运行)
docker create --name data-store \
    -v shared-data:/data \
    busybox

# 其他容器通过 --volumes-from 共享数据卷
docker run -d --name app1 \
    --volumes-from data-store \
    my-app

docker run -d --name app2 \
    --volumes-from data-store \
    my-app

# app1 和 app2 都可以读写 /data 目录,数据实时共享

3.3 数据持久化实战

3.3.1 MySQL 数据持久化
# 创建专用数据卷
docker volume create mysql-data
docker volume create mysql-conf

# 启动 MySQL 并挂载数据卷
docker run -d --name mysql \
    -v mysql-data:/var/lib/mysql \
    -v mysql-conf:/etc/mysql/conf.d \
    -e MYSQL_ROOT_PASSWORD=123456 \
    -e MYSQL_DATABASE=mydb \
    -e MYSQL_USER=app \
    -e MYSQL_PASSWORD=app123 \
    -p 3306:3306 \
    mysql:8.0

# 验证持久化:删除容器后数据不丢失
docker rm -f mysql

# 使用相同的数据卷重新创建容器,数据完整保留
docker run -d --name mysql \
    -v mysql-data:/var/lib/mysql \
    -v mysql-conf:/etc/mysql/conf.d \
    -e MYSQL_ROOT_PASSWORD=123456 \
    -p 3306:3306 \
    mysql:8.0
3.3.2 Nginx 配置持久化
# 先启动临时容器,拷贝默认配置到宿主机
docker run -d --name tmp-nginx nginx
docker cp tmp-nginx:/etc/nginx/nginx.conf ./nginx.conf
docker cp tmp-nginx:/etc/nginx/conf.d ./conf.d
docker cp tmp-nginx:/usr/share/nginx/html ./html
docker rm -f tmp-nginx

# 使用绑定挂载启动 Nginx,方便修改配置
docker run -d --name nginx \
    -v $(pwd)/nginx.conf:/etc/nginx/nginx.conf:ro \
    -v $(pwd)/conf.d:/etc/nginx/conf.d:ro \
    -v $(pwd)/html:/usr/share/nginx/html \
    -v nginx-logs:/var/log/nginx \
    -p 80:80 -p 443:443 \
    nginx

# 修改配置后重载(无需重启容器)
docker exec nginx nginx -s reload
3.3.3 数据备份与恢复

备份数据卷:

# 使用临时容器将数据卷内容打包到宿主机
docker run --rm \
    -v mysql-data:/source:ro \
    -v $(pwd):/backup \
    busybox tar czf /backup/mysql-backup-$(date +%Y%m%d).tar.gz -C /source .

# 说明:
# -v mysql-data:/source:ro  将数据卷只读挂载到临时容器 /source
# -v $(pwd):/backup         将宿主机当前目录挂载到 /backup
# tar czf ...               将 /source 内容压缩到 /backup

恢复数据卷:

# 创建新数据卷
docker volume create mysql-data-restored

# 使用临时容器将备份文件解压到新数据卷
docker run --rm \
    -v mysql-data-restored:/target \
    -v $(pwd):/backup:ro \
    busybox tar xzf /backup/mysql-backup-20240101.tar.gz -C /target

# 使用恢复后的数据卷启动容器
docker run -d --name mysql-restored \
    -v mysql-data-restored:/var/lib/mysql \
    -e MYSQL_ROOT_PASSWORD=123456 \
    -p 3307:3306 \
    mysql:8.0

定时备份脚本示例:

#!/bin/bash
# backup-volumes.sh
BACKUP_DIR="/home/user/backups"
DATE=$(date +%Y%m%d_%H%M%S)

# 备份 MySQL 数据
docker run --rm \
    -v mysql-data:/source:ro \
    -v $BACKUP_DIR:/backup \
    busybox tar czf /backup/mysql-$DATE.tar.gz -C /source .

# 保留最近 7 天的备份
find $BACKUP_DIR -name "mysql-*.tar.gz" -mtime +7 -delete

echo "[$DATE] Backup completed."

4. Dockerfile 最佳实践

4.1 Dockerfile 基础

4.1.1 Dockerfile 概念

Dockerfile 是一个文本文件,包含一系列指令(Instruction),用于自动化构建 Docker 镜像。每条指令描述镜像构建的一个步骤,Docker 引擎按顺序执行这些指令,最终生成可运行的镜像。

核心理念:基础设施即代码(Infrastructure as Code)

graph LR
    A[Dockerfile] -->|docker build| B[Docker 镜像]
    B -->|docker run| C[容器实例]
    A -->|版本管理| D[Git 仓库]
    style A fill:#0db7ed,color:#fff
    style B fill:#384d54,color:#fff

Dockerfile 基本结构:

# 基础镜像
FROM node:18-alpine

# 元数据
LABEL maintainer="dev@example.com"
LABEL version="1.0"

# 设置工作目录
WORKDIR /app

# 复制依赖文件
COPY package*.json ./

# 安装依赖
RUN npm install --production

# 复制源代码
COPY . .

# 暴露端口
EXPOSE 3000

# 启动命令
CMD ["node", "server.js"]

4.1.2 基础指令

指令速查表:

指令 作用 语法 是否产生新层
FROM 指定基础镜像,所有指令在此基础上构建;支持多阶段构建中使用 AS 命名阶段 FROM image:tag [AS name]
WORKDIR 设置后续 RUN/CMD/COPY/ADD 等指令的工作目录,目录不存在时自动创建,支持相对路径叠加 WORKDIR /path
COPY 将宿主机构建上下文中的文件/目录复制到镜像中,支持 --chown 指定归属、通配符匹配 COPY [--chown=user] src dest
ADD 功能同 COPY,额外支持自动解压本地 tar 压缩包和从远程 URL 下载文件(推荐优先用 COPY ADD src dest
RUN 构建阶段执行 Shell 命令(如安装依赖、编译代码),每条 RUN 产生一个新的镜像层 RUN command
CMD 设置容器启动时的默认命令和参数,仅最后一条生效;运行时可被 docker run 参数覆盖 CMD ["exec", "arg"]
ENTRYPOINT 设置容器的固定入口命令,与 CMD 配合可实现"命令+默认参数"模式;覆盖需加 --entrypoint ENTRYPOINT ["exec"]
EXPOSE 声明容器运行时监听的网络端口,仅起文档说明作用,不会自动映射;实际映射需 -p 参数 EXPOSE port[/protocol]
LABEL 为镜像添加键值对形式的元数据(如作者、版本、描述),可通过 docker inspect 查看 LABEL key=value
ENV 设置环境变量,构建阶段和容器运行时均可用;运行时可通过 docker run -e 覆盖 ENV key=value
ARG 定义仅在构建阶段有效的变量,通过 --build-arg 传入;不会持久化到最终镜像中 ARG name[=default]
VOLUME 声明匿名卷挂载点,容器启动时自动创建匿名卷;该指令之后对该目录的 RUN 修改不会保存 VOLUME ["/path"]
USER 切换后续指令和容器运行时的用户身份,提升安全性;切换前需确保用户已创建 USER username
HEALTHCHECK 定义容器健康检查命令,Docker 定期执行并标记容器状态为 healthy/unhealthy HEALTHCHECK CMD command

FROM — 指定基础镜像

每个 Dockerfile 必须FROM 开头(ARG 除外),指定构建的基础镜像。

# 使用官方 Node.js 镜像
FROM node:22-alpine

# 使用精简版 Debian 基础镜像
FROM node:22-slim

# 从零开始构建(用于静态编译的二进制)
FROM scratch
WORKDIR — 设置工作目录

设置后续指令的工作目录,目录不存在时自动创建。

WORKDIR /app

# 支持多次切换
WORKDIR /app/src
WORKDIR ../config   # 相对路径,结果为 /app/config
COPY — 复制文件

将宿主机文件/目录复制到镜像中。

# 复制单个文件
COPY package.json /app/

# 复制多个文件
COPY package.json package-lock.json ./

# 复制目录
COPY src/ /app/src/

# 使用通配符
COPY *.conf /etc/nginx/

# --chown 指定文件归属
COPY --chown=node:node . /app/
ADD — 复制并解压

功能类似 COPY,但额外支持自动解压压缩包远程 URL 下载

# 自动解压 tar 文件到目标目录
ADD app.tar.gz /app/

# 下载远程文件(不推荐,建议用 RUN curl)
ADD https://example.com/file.txt /app/

⚠️ 最佳实践:除非需要自动解压,否则优先使用 COPY,语义更明确。

RUN — 执行命令

在构建过程中执行命令,每条 RUN 生成一个新的镜像层。

# Shell 形式
RUN apt-get update && apt-get install -y curl

# Exec 形式
RUN ["apt-get", "install", "-y", "vim"]

# 多行书写(推荐)
RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        curl \
        wget \
        git \
    && rm -rf /var/lib/apt/lists/*
CMD — 容器启动命令

指定容器启动时的默认命令,只有最后一条 CMD 生效

# Exec 形式(推荐)
CMD ["node", "server.js"]

# Shell 形式
CMD node server.js

# 作为 ENTRYPOINT 的默认参数
CMD ["--help"]
ENTRYPOINT — 入口点

设置容器启动时执行的主命令,与 CMD 配合使用。

# 固定入口
ENTRYPOINT ["node", "server.js"]

# ENTRYPOINT + CMD 组合
ENTRYPOINT ["node"]
CMD ["server.js"]
# 运行时可覆盖 CMD:docker run myimage app.js

CMD 与 ENTRYPOINT 对比:

特性 CMD ENTRYPOINT
用途 默认命令/参数 固定入口命令
可覆盖 docker run 参数直接覆盖 --entrypoint 覆盖
多条指令 仅最后一条生效 仅最后一条生效
组合使用 作为 ENTRYPOINT 的默认参数 接收 CMD 作为参数
EXPOSE — 声明端口

声明容器运行时监听的端口(仅作文档说明,不会自动映射)。

EXPOSE 80
EXPOSE 443
EXPOSE 3000/tcp
EXPOSE 5000/udp
LABEL — 元数据

为镜像添加键值对形式的元数据。

LABEL maintainer="dev@example.com"
LABEL version="1.0"
LABEL description="My application"

# 单行写法
LABEL maintainer="dev@example.com" version="1.0"

4.1.3 构建镜像

基本构建命令
# 在 Dockerfile 所在目录构建
docker build -t myapp:1.0 .

# 指定 Dockerfile 路径
docker build -f docker/Dockerfile.prod -t myapp:prod .

# 构建时传入参数
docker build --build-arg NODE_ENV=production -t myapp:prod .

# 不使用缓存
docker build --no-cache -t myapp:1.0 .
构建上下文

docker build 命令末尾的 . 表示构建上下文(Build Context),Docker 会将该目录下所有文件发送给 Docker 引擎。

graph LR
    A[客户端 CLI] -->|发送构建上下文| B[Docker 引擎]
    B -->|逐条执行指令| C[生成镜像层]
    C --> D[最终镜像]
    style B fill:#0db7ed,color:#fff
# 当前目录作为上下文
docker build -t myapp .

# 指定其他目录作为上下文
docker build -t myapp /path/to/context

# 使用 URL 作为上下文
docker build -t myapp https://github.com/user/repo.git
查看构建过程
# 查看镜像构建历史
docker history myapp:1.0

# 输出示例
IMAGE          CREATED        CREATED BY                                      SIZE
a1b2c3d4e5f6   2 hours ago   CMD ["node" "server.js"]                         0B
b2c3d4e5f6a1   2 hours ago   EXPOSE map[3000/tcp:{}]                          0B
c3d4e5f6a1b2   2 hours ago   COPY dir:xxx in /app                             5.2MB
d4e5f6a1b2c3   2 hours ago   RUN npm install --production                     45MB
e5f6a1b2c3d4   2 hours ago   COPY file:xxx in /app/package*.json              120kB
f6a1b2c3d4e5   2 hours ago   WORKDIR /app                                     0B

4.1.4 镜像标签

标签用于标识镜像的不同版本,格式为 仓库名:标签

# 构建时指定标签
docker build -t myapp:1.0 .
docker build -t myapp:latest .

# 给已有镜像打标签
docker tag myapp:1.0 myapp:stable
docker tag myapp:1.0 registry.example.com/myapp:1.0

# 同时打多个标签
docker build -t myapp:1.0 -t myapp:latest .

标签策略建议:

策略 示例 适用场景
语义化版本 myapp:1.2.3 正式发布
Git 提交哈希 myapp:a1b2c3d CI/CD 自动构建
日期标签 myapp:20240101 定期构建
环境标签 myapp:prod, myapp:dev 多环境部署
latest myapp:latest 默认标签(谨慎使用)

⚠️ 注意latest 标签不代表"最新版本",它只是默认标签名。建议始终使用明确的版本标签。


4.2 Dockerfile 进阶

4.2.1 ENV / ARG — 变量管理

ENV — 环境变量

设置容器运行时的环境变量,在构建阶段和容器运行时均可用。

# 设置单个变量
ENV NODE_ENV=production

# 设置多个变量
ENV APP_HOME=/app \
    APP_PORT=3000 \
    LOG_LEVEL=info

# 在后续指令中引用
WORKDIR $APP_HOME
EXPOSE $APP_PORT
# 运行时覆盖环境变量
docker run -e NODE_ENV=development myapp
docker run --env-file .env myapp
ARG — 构建参数

定义构建时使用的变量,仅在 docker build 过程中有效,不会保留到运行时。

# 定义构建参数(可设默认值)
ARG NODE_VERSION=18
ARG APP_ENV=production

# 在 FROM 之前使用 ARG
ARG BASE_IMAGE=node:18-alpine
FROM $BASE_IMAGE

# 在构建过程中引用
ARG APP_ENV
RUN echo "Building for: $APP_ENV"
# 构建时传入参数
docker build --build-arg NODE_VERSION=20 --build-arg APP_ENV=staging -t myapp .

ENV vs ARG 对比:

特性 ENV ARG
构建时可用
运行时可用
可被 docker run -e 覆盖
可被 --build-arg 覆盖
持久化到镜像

⚠️ 安全提示:不要用 ARG 传递密码等敏感信息,docker history 可以查看到 ARG 的值。

4.2.2 VOLUME — 数据卷声明

在 Dockerfile 中声明匿名卷挂载点,容器启动时自动创建匿名卷。

# 声明单个卷
VOLUME /data

# 声明多个卷
VOLUME ["/data", "/logs", "/config"]

VOLUME 指令的行为:

FROM mysql:8.0

# 声明数据目录为卷
VOLUME /var/lib/mysql

# 注意:VOLUME 之后对该目录的修改不会生效!
RUN echo "test" > /var/lib/mysql/test.txt  # ❌ 无效
# 运行时会自动创建匿名卷
docker run -d mysql:8.0
docker volume ls
# DRIVER    VOLUME NAME
# local     a1b2c3d4e5f6...  (自动生成的匿名卷)

# 推荐在运行时显式挂载命名卷
docker run -d -v mysql-data:/var/lib/mysql mysql:8.0

⚠️ 注意VOLUME 指令之后,对该目录的 RUN 操作都不会被持久化到镜像中。

4.2.3 HEALTHCHECK — 健康检查

定义容器的健康检查机制,Docker 会定期执行检查命令判断容器是否健康。

# 基本用法
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
    CMD curl -f http://localhost:3000/health || exit 1

参数说明:

参数 默认值 说明
--interval 30s 检查间隔时间
--timeout 30s 单次检查超时时间
--start-period 0s 容器启动初始化等待时间
--retries 3 连续失败多少次标记为 unhealthy

不同应用类型的健康检查示例:

# Web 应用
HEALTHCHECK --interval=15s --timeout=3s --retries=3 \
    CMD curl -f http://localhost:8080/health || exit 1

# 数据库
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \
    CMD mysqladmin ping -h localhost -u root -p$MYSQL_ROOT_PASSWORD || exit 1

# Redis
HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
    CMD redis-cli ping | grep -q PONG || exit 1

# 无 curl 环境,使用 wget
HEALTHCHECK --interval=15s --timeout=3s \
    CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1

# 禁用健康检查(如果基础镜像已定义)
HEALTHCHECK NONE

查看健康状态:

# 查看容器健康状态
docker ps
# STATUS 列会显示 (healthy) 或 (unhealthy)

# 查看详细健康检查日志
docker inspect --format='{{json .State.Health}}' my-container | jq

4.2.4 多阶段构建

多阶段构建允许在一个 Dockerfile 中使用多个 FROM 指令,每个 FROM 开启一个新的构建阶段。最终镜像只包含最后一个阶段的内容,从而大幅减小镜像体积

graph LR
    subgraph 阶段1-构建
        A[完整 SDK/编译工具] --> B[编译产物]
    end
    subgraph 阶段2-运行
        C[精简运行时] --> D[仅复制产物]
    end
    B -->|COPY --from| D
    style A fill:#e74c3c,color:#fff
    style C fill:#27ae60,color:#fff
Node.js 前端应用示例
# ========== 阶段1:构建 ==========
FROM node:18-alpine AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# ========== 阶段2:运行 ==========
FROM nginx:alpine AS production

# --from=builder 表示从名为 "builder" 的构建阶段(即上方 FROM ... AS builder)中复制文件
# 只取构建产物,不携带源码和 node_modules,大幅缩减最终镜像体积
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

多阶段构建体积对比(Node.js):

应用类型 单阶段镜像 多阶段镜像 说明
纯前端 SPA/SSG ~1.2GB ~25MB 最终只需 nginx + 静态文件,无需 Node 运行时
SSR 应用(如 Vike) ~1.2GB ~200-300MB 仍需 Node 运行时 + 生产依赖,但去掉了 devDependencies 和源码

4.3 Dockerfile 最佳实践

4.3.1 减少镜像层数

每条 RUNCOPYADD 指令都会创建一个新的镜像层。合并指令可以减少层数,缩小镜像体积。

❌ 不推荐 — 过多层数:

RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y wget
RUN apt-get install -y git
RUN rm -rf /var/lib/apt/lists/*

✅ 推荐 — 合并为单层:

RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        curl \
        wget \
        git \
    && rm -rf /var/lib/apt/lists/*

其他减少层数的技巧:

# ❌ 多条 ENV
ENV APP_NAME=myapp
ENV APP_PORT=3000
ENV LOG_LEVEL=info

# ✅ 合并 ENV
ENV APP_NAME=myapp \
    APP_PORT=3000 \
    LOG_LEVEL=info

# ❌ 多条 LABEL
LABEL maintainer="dev@example.com"
LABEL version="1.0"

# ✅ 合并 LABEL
LABEL maintainer="dev@example.com" \
      version="1.0"

4.3.2 利用构建缓存

Docker 按指令顺序构建,如果某层的输入未改变,会直接使用缓存。将变化频率低的指令放在前面,变化频率高的放在后面

❌ 不推荐 — 源码变动导致依赖重装:

FROM node:18-alpine
WORKDIR /app
COPY . .                  # 任何文件改变都会使缓存失效
RUN npm install           # 每次都要重新安装依赖
CMD ["node", "server.js"]

✅ 推荐 — 分离依赖安装与源码复制:

FROM node:22-alpine
WORKDIR /app

# 第1步:仅复制依赖描述文件(变化少)
COPY package.json ./

# 第2步:安装依赖(仅依赖文件变化时重新执行)
# 注意:跨平台(如 Windows 开发 → Linux 容器)时不要复制 package-lock.json
# 否则 npm ci 会按 lock 文件安装,导致缺少 Linux 平台的可选依赖
RUN npm install --production

# 第3步:复制源代码(变化频繁,放最后)
COPY . .

CMD ["node", "server.js"]
graph TB
    A["COPY package.json(缓存命中 ✅)"] --> B["RUN npm install(缓存命中 ✅)"]
    B --> C["COPY . .(代码改变,缓存失效 ❌)"]
    C --> D["CMD ...(重新执行)"]
    style A fill:#27ae60,color:#fff
    style B fill:#27ae60,color:#fff
    style C fill:#e74c3c,color:#fff

4.3.3 .dockerignore

.dockerignore 文件用于排除不需要发送到构建上下文的文件,减小上下文体积,加快构建速度,避免敏感信息泄露

# .dockerignore

# 版本控制
.git
.gitignore

# 依赖目录
node_modules
vendor
__pycache__

# 构建产物
dist
build
*.jar
target

# IDE 与编辑器
.vscode
.idea
*.swp
*.swo

# 环境与配置
.env
.env.local
.env.*.local
*.pem
*.key

# Docker 相关
Dockerfile*
docker-compose*.yml
.dockerignore

# 文档与测试
README.md
docs/
test/
tests/
coverage/

# 系统文件
.DS_Store
Thumbs.db

# 日志
*.log
logs/

效果对比:

# 无 .dockerignore
Sending build context to Docker daemon  450MB  ← 包含 node_modules 等

# 有 .dockerignore
Sending build context to Docker daemon  2.5MB  ← 仅必要文件

4.3.4 选择基础镜像

选择合适的基础镜像对镜像体积和安全性影响巨大。

常见基础镜像对比:

基础镜像 体积 包管理器 适用场景
ubuntu:22.04 ~77MB apt 需要完整 Linux 环境
debian:bookworm-slim ~74MB apt 通用场景(精简版)
alpine:3.19 ~7MB apk 追求极致精简
distroless ~2-20MB 仅运行时,安全优先
scratch 0MB 静态编译的二进制

Node.js 镜像变体:

node:18          # ~1GB,包含完整开发工具
node:18-slim     # ~200MB,精简版 Debian
node:18-alpine   # ~170MB,基于 Alpine Linux

选择建议:

# 开发/调试阶段 — 功能完整
FROM node:18

# 生产环境 — 推荐 slim
FROM node:18-slim

# 极致精简 — 使用 Alpine(注意 musl libc 兼容性)
FROM node:18-alpine

# Go/Rust 等静态编译 — 使用 scratch
FROM scratch

4.3.5 使用非 root 用户

默认情况下容器以 root 用户运行,这是一个安全隐患。应创建并切换到普通用户。

FROM node:18-alpine

# 创建应用用户和组
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app

COPY package*.json ./
RUN npm ci --production

# 复制文件并指定归属
COPY --chown=appuser:appgroup . .

# 切换到非 root 用户
USER appuser

EXPOSE 3000
CMD ["node", "server.js"]

验证用户身份:

# 进入容器查看当前用户
docker exec -it my-container whoami
# appuser

# 查看进程运行用户
docker exec -it my-container ps aux

⚠️ 注意USER 指令之前的 RUN 命令仍以 root 执行,因此安装软件包等操作应在 USER 之前完成。


4.4 镜像优化

4.4.1 镜像体积优化

清理不必要的文件
# ❌ 残留缓存
RUN apt-get update && apt-get install -y curl

# ✅ 同一层中清理
RUN apt-get update \
    && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*

# ✅ npm 清理缓存
RUN npm install --production && npm cache clean --force

# ✅ apk 不保留缓存
RUN apk add --no-cache curl wget
使用 .dockerignore 排除无关文件

参见 4.3.3 .dockerignore

多阶段构建

参见 4.2.4 多阶段构建

压缩与合并层
# 使用 --squash 合并层(实验性功能)
docker build --squash -t myapp:squashed .

# 导出再导入压缩镜像
docker export $(docker create myapp:1.0) | docker import - myapp:compressed

优化效果示例:

优化手段 原始体积 优化后 减少
slim 替代完整镜像 1.0GB 200MB 80%
alpine 替代 slim 200MB 170MB 15%
多阶段构建 1.2GB 25MB 98%
清理包管理缓存 350MB 280MB 20%
.dockerignore 构建上下文 450MB 2.5MB 99%

4.4.2 镜像安全扫描

定期扫描镜像中的已知漏洞,是生产环境的必要步骤。

Docker Scout(官方工具)
# 扫描本地镜像
docker scout cves myapp:1.0

# 快速概览
docker scout quickview myapp:1.0

# 查看改进建议
docker scout recommendations myapp:1.0
Trivy(开源工具,推荐)
# 安装 Trivy
docker run --rm aquasec/trivy --version

# 扫描镜像漏洞
docker run --rm \
    -v /var/run/docker.sock:/var/run/docker.sock \
    aquasec/trivy image myapp:1.0

# 仅显示高危和严重漏洞
docker run --rm \
    -v /var/run/docker.sock:/var/run/docker.sock \
    aquasec/trivy image --severity HIGH,CRITICAL myapp:1.0

# 输出 JSON 格式(适合 CI/CD)
docker run --rm \
    -v /var/run/docker.sock:/var/run/docker.sock \
    aquasec/trivy image -f json -o results.json myapp:1.0
CI/CD 集成示例
# GitHub Actions 示例
- name: Run Trivy vulnerability scanner
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: myapp:${{ github.sha }}
    format: 'table'
    exit-code: '1'                # 发现漏洞时失败
    severity: 'CRITICAL,HIGH'

安全最佳实践清单:

  • ✅ 使用官方/可信基础镜像
  • ✅ 固定镜像版本标签(不用 latest
  • ✅ 定期更新基础镜像
  • ✅ 使用非 root 用户运行
  • ✅ 不在镜像中存储敏感信息
  • ✅ 使用多阶段构建减少攻击面
  • ✅ 在 CI/CD 中集成安全扫描

4.4.3 镜像分层优化

理解 Docker 镜像的分层机制,有助于写出高效的 Dockerfile。

镜像层原理
graph TB
    subgraph 镜像分层
        L1["Layer 1: FROM alpine(基础镜像层)"]
        L2["Layer 2: RUN apk add...(安装依赖)"]
        L3["Layer 3: COPY package.json(依赖描述)"]
        L4["Layer 4: RUN npm install(安装依赖)"]
        L5["Layer 5: COPY . .(应用代码)"]
        L1 --> L2 --> L3 --> L4 --> L5
    end
    style L1 fill:#384d54,color:#fff
    style L5 fill:#e74c3c,color:#fff
  • 每一层都是只读的,通过联合文件系统(UnionFS)叠加
  • 容器运行时在最顶层添加一个可写层
  • 层是可共享的:多个镜像可复用相同的底层
查看镜像层
# 查看各层大小
docker history myapp:1.0

# 使用 dive 工具深入分析(推荐)
docker run --rm -it \
    -v /var/run/docker.sock:/var/run/docker.sock \
    wagoodman/dive myapp:1.0

dive 可以交互式地浏览每一层的文件变化,发现不必要的大文件。

分层优化策略

1. 将变化频率相同的操作放在同一层:

# ✅ 系统依赖(变化很少)— 放在前面
RUN apt-get update && apt-get install -y --no-install-recommends \
    curl ca-certificates \
    && rm -rf /var/lib/apt/lists/*

# ✅ 应用依赖(偶尔变化)— 放在中间
COPY package.json package-lock.json ./
RUN npm ci --production

# ✅ 应用代码(经常变化)— 放在最后
COPY . .

2. 避免在高层删除低层文件:

# ❌ 文件仍保留在 Layer 2 中,镜像体积不会减小
COPY large-file.tar.gz /tmp/       # Layer 2: +500MB
RUN tar xzf /tmp/large-file.tar.gz && rm /tmp/large-file.tar.gz  # Layer 3

# ✅ 在同一层完成下载、解压、清理
RUN curl -O https://example.com/large-file.tar.gz \
    && tar xzf large-file.tar.gz \
    && rm large-file.tar.gz

3. 合理利用层共享:

# 团队多个项目使用相同基础层
# Dockerfile.base
FROM node:22-alpine
RUN apk add --no-cache make g++

# Dockerfile.app1 / Dockerfile.app2
FROM myteam/base:1.0    # 共享基础层
COPY . /app

完整优化示例对比:

# ========== 优化前 ==========
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]
# 镜像体积:~1.4GB

# ========== 优化后 ==========
FROM node:18-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:18-alpine
WORKDIR /app
RUN addgroup -S app && adduser -S app -G app
COPY --from=builder --chown=app:app /app/dist ./dist
COPY --from=builder --chown=app:app /app/node_modules ./node_modules
COPY --from=builder --chown=app:app /app/package.json ./
USER app
EXPOSE 3000
HEALTHCHECK --interval=15s --timeout=3s \
    CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/server.js"]
# 镜像体积:~180MB

5. Docker Compose

5.1 Docker Compose 基础

5.1.1 Compose 概念

Docker Compose 是一个用于定义和运行多容器应用的工具。通过一个 YAML 文件描述所有服务、网络和数据卷,一条命令即可启动整个应用栈。

核心价值:

  • 一个文件定义整个应用架构
  • 一条命令启动/停止所有服务
  • 服务间自动网络互通(通过服务名访问)
  • 环境一致性(开发、测试、生产)
graph TB
    A[docker-compose.yml] -->|docker compose up| B[Docker Compose]
    B --> C[Service: app]
    B --> D[Service: nginx]
    B --> E[Service: db]
    B --> F[Network: 自动创建]
    B --> G[Volume: 数据持久化]
    style A fill:#0db7ed,color:#fff
    style B fill:#384d54,color:#fff

5.1.2 docker-compose.yml

docker-compose.yml 是 Compose 的核心配置文件,使用 YAML 格式描述应用的服务、网络和数据卷。

基本结构:

# 服务定义
services:
  # 服务名(自定义,同时作为容器间的 DNS 名称)
  web:
    image: nginx:alpine          # 使用镜像
    ports:
      - "80:80"                  # 端口映射 宿主机:容器

  app:
    build: .                     # 从 Dockerfile 构建
    ports:
      - "3000:3000"
    environment:                 # 环境变量
      - NODE_ENV=production

  db:
    image: mysql:8.0
    volumes:                     # 数据卷挂载
      - db-data:/var/lib/mysql
    environment:
      - MYSQL_ROOT_PASSWORD=123456

# 数据卷定义
volumes:
  db-data:

# 网络定义(可选,Compose 会自动创建默认网络)
networks:
  app-net:

常用配置项速查:

配置项 作用 示例
image 指定使用的 Docker 镜像,Compose 直接拉取该镜像创建容器,不需要本地 Dockerfile image: nginx:alpine
build 从本地 Dockerfile 构建镜像;可指定构建上下文目录和 Dockerfile 文件名 build: .build: { context: ., dockerfile: Dockerfile }
ports 将容器端口映射到宿主机,格式 宿主机端口:容器端口,外部可通过宿主机端口访问服务 ports: ["80:80", "443:443"]
expose 仅在 Compose 内部网络暴露端口,供其他服务访问,不映射到宿主机,外部无法直接访问 expose: ["3000"]
environment 直接定义容器的环境变量,容器内应用可通过 process.env 读取 environment: [NODE_ENV=production]
env_file 从外部 .env 文件批量加载环境变量,适合管理大量配置项,避免在 yml 中硬编码敏感信息 env_file: .env
volumes 挂载数据卷或宿主机目录到容器内,实现数据持久化;容器删除后数据仍保留 volumes: [./data:/app/data]
depends_on 声明服务间的启动依赖关系,确保被依赖的服务先启动;可配合 condition 等待服务健康 depends_on: [db, redis]
restart 定义容器异常退出时的重启策略:no(不重启)、always(总是重启)、unless-stopped(除手动停止外自动重启) restart: unless-stopped
networks 将服务加入指定网络,同一网络内的服务可通过服务名互相访问;不同网络间相互隔离 networks: [app-net]
deploy 部署相关配置,如副本数(replicas)、资源限制(resources)、更新策略(update_config deploy: { replicas: 3 }
command 覆盖 Dockerfile 中定义的 CMD,指定容器启动时执行的命令 command: ["node", "server.js"]
healthcheck 定义容器的健康检查命令,Docker 定期执行并标记容器状态,可配合 depends_on.condition 实现依赖等待 healthcheck: { test: ["CMD", "curl", "-f", "http://localhost"] }

5.1.3 基本命令

# ========== 启动与停止 ==========

# 启动所有服务(后台运行)
docker compose up -d

# 构建并启动(代码有改动时加 --build)
docker compose up -d --build

# 停止所有服务
docker compose stop

# 停止并删除容器、网络
docker compose down

# 停止并删除容器、网络、数据卷(⚠️ 会删除持久化数据)
docker compose down -v

# ========== 查看状态 ==========

# 查看运行中的服务
docker compose ps

# 查看服务日志
docker compose logs

# 实时跟踪日志
docker compose logs -f

# 查看指定服务日志
docker compose logs -f app

# ========== 服务管理 ==========

# 重启指定服务
docker compose restart app

# 进入服务容器
docker compose exec app sh

# 在服务中执行一次性命令
docker compose run --rm app node -v

# 水平扩展服务实例
docker compose up -d --scale app=5

# ========== 构建相关 ==========

# 仅构建镜像(不启动)
docker compose build

# 不使用缓存重新构建
docker compose build --no-cache

# 拉取配置中的镜像
docker compose pull

常用命令速查表:

命令 作用
docker compose up -d 后台启动所有服务
docker compose up -d --build 重新构建并启动
docker compose down 停止并清理容器和网络
docker compose ps 查看服务状态
docker compose logs -f 实时查看日志
docker compose exec <服务名> sh 进入容器
docker compose restart <服务名> 重启指定服务
docker compose --scale <服务名>=N 扩展到 N 个实例

5.2 服务编排

5.2.1 多服务应用

一个典型的 Web 应用通常包含多个服务协同工作:

graph LR
    User[用户] --> Nginx[Nginx 反向代理]
    Nginx --> App1[App 实例1]
    Nginx --> App2[App 实例2]
    App1 --> DB[(MySQL)]
    App2 --> DB
    App1 --> Cache[(Redis)]
    App2 --> Cache
    style Nginx fill:#009639,color:#fff
    style DB fill:#4479A1,color:#fff
    style Cache fill:#DC382D,color:#fff
services:
  # 反向代理
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - app
    restart: unless-stopped

  # 应用服务
  app:
    build: .
    expose:
      - "3000"
    environment:
      - NODE_ENV=production
      - DB_HOST=db
      - REDIS_HOST=cache
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    restart: unless-stopped

  # 数据库
  db:
    image: mysql:8.0
    volumes:
      - db-data:/var/lib/mysql
    environment:
      - MYSQL_ROOT_PASSWORD=123456
      - MYSQL_DATABASE=myapp
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  # 缓存
  cache:
    image: redis:7-alpine
    volumes:
      - cache-data:/data
    restart: unless-stopped

volumes:
  db-data:
  cache-data:

5.2.2 服务依赖

depends_on 控制服务启动顺序,支持两种写法:

简单依赖(仅控制启动顺序):

services:
  app:
    depends_on:
      - db        # db 先启动,但不等它就绪
      - cache

条件依赖(等待服务就绪):

services:
  app:
    depends_on:
      db:
        condition: service_healthy    # 等 db 健康检查通过
      cache:
        condition: service_started    # 等 cache 容器启动即可
条件 说明
service_started 容器启动即满足(默认)
service_healthy 容器健康检查通过(需配置 healthcheck)
service_completed_successfully 容器执行完毕且退出码为 0

⚠️ 注意depends_on 只控制启动顺序,不保证服务内的应用已完全就绪。建议配合 healthcheck 使用 condition: service_healthy

5.2.3 环境变量

Compose 支持多种方式注入环境变量:

方式一:直接在 yml 中定义

services:
  app:
    environment:
      - NODE_ENV=production
      - PORT=3000
      - DB_HOST=db

方式二:使用 .env 文件

# .env(与 docker-compose.yml 同目录)
NODE_ENV=production
PORT=3000
DB_HOST=db
DB_PASSWORD=secret123
services:
  app:
    env_file:
      - .env

方式三:在 yml 中引用宿主机环境变量

services:
  app:
    environment:
      - NODE_ENV=${NODE_ENV:-production}    # 默认值 production
      - DB_PASSWORD=${DB_PASSWORD}          # 从宿主机环境变量读取

优先级(高 → 低):

  1. docker compose run -e 命令行参数
  2. environment 字段直接定义
  3. env_file 文件
  4. Dockerfile 中的 ENV

5.2.4 网络和数据卷

网络

Compose 会自动为项目创建一个默认网络,所有服务自动加入,可通过服务名互相访问。

services:
  app:
    # 可通过 "db" 访问数据库,如 mysql://db:3306
    # 可通过 "cache" 访问 Redis,如 redis://cache:6379
    environment:
      - DB_HOST=db
      - REDIS_HOST=cache

  db:
    image: mysql:8.0

  cache:
    image: redis:7-alpine

自定义网络(隔离不同服务组):

services:
  nginx:
    networks:
      - frontend        # nginx 只在前端网络

  app:
    networks:
      - frontend        # app 同时在前端和后端网络
      - backend

  db:
    networks:
      - backend          # db 只在后端网络(nginx 无法直接访问)

networks:
  frontend:
  backend:
graph TB
    subgraph frontend 网络
        Nginx[nginx]
        App[app]
    end
    subgraph backend 网络
        App2[app]
        DB[(db)]
    end
    Nginx --> App
    App2 --> DB
    style Nginx fill:#009639,color:#fff
    style DB fill:#4479A1,color:#fff
数据卷
services:
  db:
    volumes:
      # 命名卷 — 由 Docker 管理,持久化存储
      - db-data:/var/lib/mysql

      # 绑定挂载 — 映射宿主机目录
      - ./init-sql:/docker-entrypoint-initdb.d:ro

      # 只读挂载(:ro)
      - ./config/my.cnf:/etc/mysql/conf.d/my.cnf:ro

volumes:
  db-data:              # 声明命名卷
    driver: local       # 默认驱动
挂载类型 语法 适用场景
命名卷 vol-name:/container/path 数据库数据、持久化文件
绑定挂载 ./host/path:/container/path 配置文件、初始化脚本
只读挂载 ./path:/container/path:ro 配置文件(防止容器修改)

5.3 Compose 实战

5.3.1 vike-zyh-test 项目部署

以当前项目为例,使用 Docker Compose 部署 Vike SSR 应用 + Nginx 负载均衡

项目架构:

graph LR
    User[用户 :80] --> Nginx[Nginx 反向代理]
    Nginx --> App1[vike-app 实例1 :3000]
    Nginx --> App2[vike-app 实例2 :3000]
    Nginx --> App3[vike-app 实例3 :3000]
    style Nginx fill:#009639,color:#fff
    style App1 fill:#0db7ed,color:#fff
    style App2 fill:#0db7ed,color:#fff
    style App3 fill:#0db7ed,color:#fff

docker-compose.yml:

services:
  # 应用服务(可水平扩展多个实例)
  app:
    build: .
    environment:
      - NODE_ENV=production
      - PORT=3000
    # 不对外暴露端口,由 nginx 统一转发
    expose:
      - "3000"
    restart: unless-stopped
    deploy:
      replicas: 3  # 启动 3 个容器实例

  # Nginx 负载均衡
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - app
    restart: unless-stopped

nginx.conf:

events {
    worker_connections 1024;
}

http {
    # 上游服务组 — Docker Compose DNS 自动解析所有 app 实例
    upstream app_servers {
        # 默认轮询策略,请求依次分配到每个容器
        server app:3000;
    }

    server {
        listen 80;

        location / {
            proxy_pass http://app_servers;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }
    }
}

关键点upstreamserver app:3000app 是 Compose 服务名。Docker 内置 DNS 会将 app 解析为所有 app 容器的 IP,Nginx 自动轮询分发请求。

部署与管理:

# 1. 构建并启动
docker compose up -d --build

# 2. 查看服务状态
docker compose ps
# NAME            SERVICE   STATUS    PORTS
# project-app-1   app      running   3000/tcp
# project-app-2   app      running   3000/tcp
# project-app-3   app      running   3000/tcp
# project-nginx-1 nginx    running   0.0.0.0:80->80/tcp

# 3. 访问应用
# 浏览器打开 http://localhost

# 4. 查看日志(观察请求分配到不同实例)
docker compose logs -f app

# 5. 动态扩缩容
docker compose up -d --scale app=5    # 扩展到 5 个实例
docker compose up -d --scale app=2    # 缩减到 2 个实例

# 6. 更新部署(代码改动后)
docker compose up -d --build

# 7. 停止并清理
docker compose down

5.3.2 GitHub Actions + Docker Compose 自动部署

推送代码 → GitHub Actions 构建镜像并推送到本地 Registry → 服务器 Docker Compose 拉取部署。

graph LR
    A[git push] --> B[GitHub Actions]
    B --> C[构建镜像]
    C --> D[推送到本地 Registry :5000]
    D --> E[SSH 服务器]
    E --> F["docker compose pull & up"]
    style B fill:#2088FF,color:#fff
    style D fill:#0db7ed,color:#fff

前置条件:服务器上启动本地 Registry(本地启动测试的镜像仓库,用于模拟)

docker run \
    -d \                          # 后台运行容器
    -p 5000:5000 \                # 将宿主机 5000 端口映射到容器 5000 端口(Registry 默认端口)
    --name registry \             # 容器命名为 registry,方便后续管理
    --restart unless-stopped \    # 异常退出自动重启,手动 stop 除外
    registry:2                    # 使用官方 Registry v2 镜像

步骤一:服务器上的 docker-compose.prod.yml

# /opt/vike-zyh-test/docker-compose.prod.yml
services:
  app:
    image: localhost:5000/vike-zyh-test:latest
    environment:
      - NODE_ENV=production
      - PORT=3000
    expose:
      - "3000"
    restart: unless-stopped
    deploy:
      replicas: 3

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - app
    restart: unless-stopped

步骤二:GitHub Actions 工作流

# .github/workflows/deploy.yml
name: Build and Deploy

on:
  push:
    branches: [main]
  workflow_dispatch:       # 手动触发(Actions 页面点击 "Run workflow")

env:
  REGISTRY: ${{ secrets.SERVER_HOST }}:5000
  IMAGE_NAME: vike-zyh-test

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      # 配置 Docker 允许推送到 HTTP 仓库(本地 Registry 无 HTTPS)
      - name: Configure insecure registry
        run: |
          echo '{ "insecure-registries": ["${{ env.REGISTRY }}"] }' | sudo tee /etc/docker/daemon.json
          sudo systemctl restart docker

      - name: Build image
        run: docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest .

      - name: Push to registry
        run: docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

      - name: Deploy to server
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: |
            cd /opt/vike-zyh-test
            docker compose -f docker-compose.prod.yml pull
            docker compose -f docker-compose.prod.yml up -d
            docker image prune -f

步骤三:配置 GitHub Secrets

在仓库 Settings → Secrets and variables → Actions 中添加:

Secret 名称 说明
SERVER_HOST 192.168.1.100 部署服务器的公网/局域网 IP(同时运行 Registry),不是 GitHub 账号
SERVER_USER Administrator 部署服务器的 SSH 登录用户名,不是 GitHub 用户名
SERVER_SSH_KEY 私钥文件内容 用于 SSH 登录服务器的私钥(cat ~/.ssh/id_rsa),不是 GitHub 的 key

本地模拟推送并启动服务 原本正常应该使用action进行推送,但是因为没有测试的镜像仓库,因此本地启动了一个测试镜像仓库,并模拟action中的构建和推送

# 模拟:
# 1. 构建镜像
docker build -t localhost:5000/vike-zyh-test:latest .
# 2. 推送到本地 Registry
docker push localhost:5000/vike-zyh-test:latest


# 到服务器上部署:
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d

完结撒花~~~

前端签章数据的模板处理

2026年2月25日 15:27

在开发新项目中。我们面临一个具体场景:纯预览展示的协议文档需要动态填充用户数据。这些协议本身是静态HTML模板,但其中包含大量需要根据具体业务数据动态替换的字段,如客户姓名、贷款金额、银行信息等。

技术挑战

  1. 多环境适配:协议模板需要在开发、测试、生产不同环境中运行
  2. 数据安全:敏感数据(如银行卡号、身份证号)需要安全获取和展示
  3. 动态替换:协议中的占位符需要精准、高效地被实际数据替换
  4. 兼容性:方案需要支持多种浏览器环境和模块系统

解决方案架构

核心设计理念

我们设计了一个SignatureDataService类,作为数据获取和模板填充的核心服务。该方案采用以下关键设计:

  1. 环境智能判断:自动识别运行环境,配置对应的API端点
  2. 数据隔离存储:使用本地存储管理业务入口标识(entryCode)
  3. 模板引擎:轻量级占位符替换机制
  4. 单例模式:确保全局唯一的数据服务实例

环境配置策略

javascript

function getEnvConfig() {
  const hostname = window.location.hostname;
  const href = window.location.href;

  // 开发环境
  if (hostname === 'localhost') {
    return {
      apiBaseUrl: 'http://localhost:8080/api',
      env: 'development'
    };
  }

  // 测试环境
  if (href.includes('.test.') || hostname.includes('test')) {
    return {
      apiBaseUrl: '测试环境地址',
      env: 'testcloud'
    };
  }

  // 生产环境
  return {
    apiBaseUrl: ' 生产环境地址',
    env: 'prodcloud'
  };
}

核心服务类

SignatureDataService类封装了完整的数据处理流程:

1. 数据获取

javascript

async fetchSignatureData() {
  const entryCode = this.getEntryCode();
  if (!entryCode) return null;
  
  const apiUrl = `${this.config.apiBaseUrl}/signature/getSignatureParams`;
  const response = await fetch(`${apiUrl}?entryCode=${encodeURIComponent(entryCode)}`);
  // ... 处理响应
}

2. 模板填充

javascript

fillTemplate(data) {
  if (!data) return;
  
  let content = document.body.innerHTML;
  const fields = {
    'bankName': data.bankName || '',
    'bankCardNo': data.bankCardNo || '',
    // ... 其他30+字段
  };
  
  // 正则表达式替换所有${fieldName}占位符
  Object.keys(fields).forEach(key => {
    const placeholder = `${${key}}`;
    const regex = new RegExp(
      placeholder.replace(/$/g, '\$').replace(/{/g, '\{').replace(/}/g, '\}'),
      'g'
    );
    content = content.replace(regex, this.escapeHtml(fields[key]));
  });
  
  document.body.innerHTML = content;
}

3. 安全防护

javascript

escapeHtml(text) {
  const div = document.createElement('div');
  div.textContent = text;
  return div.innerHTML;
}

支持的数据字段

系统支持超过30个业务字段的自动填充,涵盖:

  • 客户信息:customerName, cardNum, phoneNum
  • 贷款信息:loanAmount, period, totalInterest
  • 车辆信息:carModel, chassisNo, carColor
  • 公司信息:companyName, companyUsci, companyAddress
  • 金额相关:所有金额字段同时提供数字和中文大写形式

使用方式

1. HTML模板准备

协议模板中使用${fieldName}格式的占位符:

html

<div class="contract-section">
  <p>甲方(借款人):${customerName}</p>
  <p>身份证号:${cardNum}</p>
  <p>贷款金额:${loanAmount}元(${loanAmountWord})</p>
</div>

2. 页面初始化

javascript

// 方式一:使用默认配置
const service = window.SignatureDataService.getSignatureService();
service.initPage();

// 方式二:自定义配置
const service = new window.SignatureDataService.SignatureDataService({
  storageKey: 'customEntryCode'
});
service.initPage();

// 方式三:直接获取数据
const data = await window.SignatureDataService.fetchSignatureData();

技术优势

1. 无框架依赖

纯JavaScript实现,不依赖React、Vue等框架,适用于各种传统项目

2. 性能优化

  • 单例模式减少内存开销
  • 批量正则替换提升渲染效率
  • 按需加载数据

3. 安全考虑

  • HTML转义防止XSS攻击
  • 敏感数据本地存储隔离
  • HTTPS环境自动适配

4. 扩展性

  • 模块化设计,易于添加新字段
  • 支持CommonJS和浏览器全局两种导出方式
  • 配置可覆盖,便于定制

5. 错误处理

完善的异常捕获机制:

javascript

try {
  const data = await this.fetchSignatureData();
  if (!data) {
    console.error('数据加载失败,请稍后重试');
    return false;
  }
  this.fillTemplate(data);
  return true;
} catch (error) {
  console.error('初始化失败:', error);
  return false;
}

前端知识体系总结-前端工程化(Babel篇)

2026年2月25日 15:22

Babel

手写一个简易编译器

Babel本质上就是一个编译器。把一种代码变成另一种代码。
我们将要实现一个最简单的Babel核心功能:将ES6的箭头函数转换为ES5的普通函数
我们不要去背那些复杂的概念,编译器的工作流程在任何语言里都是一样的,只有三个阶段:

  1. 解析(Parse):把代码字符串变成树结构(AST)。
  2. 转换(Transform):在树上修修补补,把“箭头函数节点”改成“普通函数节点”。
  3. 生成(Generate):把改好的树重新变回代码字符串。

一、为什么需要将代码解析为 AST

我们先看一个简单的代码:

const add = (a, b) => a + b;

如果不生成AST,直接用正则替换,你可能会写出 code.replace('=>', 'function')。 但如果代码是这样的:

const str = "这个箭头 => 是字符串不是代码";
const func = () => { return "=>"; };

正则就不管用了。它分不清哪个是语法,哪个是字符串内容。
只有通过某种方式把代码拆解成 树状结构 去进行表示,我们才能精准地知道每行代码的实际含义,比如这是一个变量声明,那是一个函数表达式。
这里我们使用 @babel/parser 来生成AST(因为手写词法分析器和语法分析器通过大量switch-case处理字符,逻辑虽简单但代码量太大,这里我们聚焦于核心的转换逻辑)。

二、AST长什么样

我们先看看上面那句 const add = (a, b) => a + b; 解析出来是什么东西。

{
  "type": "VariableDeclaration", // 变量声明
  "kind": "const",
  "declarations": [
    {
      "type": "VariableDeclarator",
      "id": { "type": "Identifier", "name": "add" },
      "init": {
        "type": "ArrowFunctionExpression", // 重点在这里:箭头函数表达式
        "params": [
          { "type": "Identifier", "name": "a" },
          { "type": "Identifier", "name": "b" }
        ],
        "body": {
          "type": "BinaryExpression", // 二进制表达式 (a + b)
          "left": { "type": "Identifier", "name": "a" },
          "operator": "+",
          "right": { "type": "Identifier", "name": "b" }
        }
      }
    }
  ]
}

转换的目标很明确:找到 ArrowFunctionExpression 类型的节点,把它替换成 FunctionExpression 类型的节点,同时处理一下函数体。

三、实现核心:遍历器(Traverser)

Babel最核心的部分不是解析,而是如何遍历这棵树。我们需要写一个函数,它能递归地访问树的每一个节点。当它遇到我们需要处理的节点时,调用我们提供的插件方法。 这是一个最基础的遍历器实现:

function traverse(ast, visitor) {
  // 遍历数组类型的属性(比如 body 里的多行代码)
  function traverseArray(array, parent) {
    array.forEach(child => traverseNode(child, parent));
  }

  // 遍历单个节点
  function traverseNode(node, parent) {
    if (!node || typeof node !== 'object') return;

    // 1. 如果visitor里定义了当前节点类型的处理函数,就执行它
    // 比如 visitor.ArrowFunctionExpression(node, parent)
    const method = visitor[node.type];
    if (method) {
      method(node, parent);
    }

    // 2. 递归遍历当前节点的所有属性
    // 比如遍历 body, params, left, right 等属性
    Object.keys(node).forEach(key => {
      const child = node[key];
      if (Array.isArray(child)) {
        traverseArray(child, node);
      } else {
        traverseNode(child, node);
      }
    });
  }

  traverseNode(ast, null);
}

这段代码的逻辑是:从根节点开始,先检查有没有对应的插件函数要执行,执行完后,继续递归找它的子节点。只要树没走完,就一直递归下去。

四、实现插件:转换箭头函数

现在我们有了遍历器,就可以写“插件”了。插件就是定义由于怎么修改节点。 我们要把箭头函数:

(a, b) => a + b

变成普通函数:

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

转换逻辑的具体步骤:

  1. 找到 ArrowFunctionExpression 节点。
  2. 保留它的 params (参数)。
  3. 处理 body。箭头函数如果直接返回表达式(没有花括号),变成普通函数时需要加 { return ... }
  4. 把节点类型改为 FunctionExpression
const transformer = {
  ArrowFunctionExpression(node) {
    // 1. 修改节点类型
    node.type = 'FunctionExpression';
    
    // 2. 处理函数体
    // 如果原体不是块语句(比如是 x => x + 1 这种直接返回的)
    // 我们需要把它包装成 { return x + 1; }
    if (node.body.type !== 'BlockStatement') {
      node.body = {
        type: 'BlockStatement',
        body: [{
          type: 'ReturnStatement',
          argument: node.body
        }]
      };
    }
    
    // 普通函数通常不需要 generator 或 async 属性,除非原样保留
    node.expression = false; 
  }
};

这里我们直接修改了 node 对象。因为AST本质上就是对象引用,直接修改树上的属性,整棵树的结构就变了。

五、代码生成(Generator)

树修改完了,最后一步是把树变回字符串。 这一步通常很繁琐,因为要处理缩进、括号、分号。为了演示核心逻辑,我们手写一个极简版的生成器,只处理我们涉及到的几种节点。

function generate(node) {
  switch (node.type) {
    case 'Program':
      return node.body.map(generate).join('\n');
      
    case 'VariableDeclaration':
      return `${node.kind} ${node.declarations.map(generate).join(', ')};`;
      
    case 'VariableDeclarator':
      return `${generate(node.id)} = ${generate(node.init)}`;
      
    case 'Identifier':
      return node.name;
      
    case 'FunctionExpression':
      // 组装函数字符串:function(参数) { 函数体 }
      const params = node.params.map(generate).join(', ');
      const body = generate(node.body);
      return `function(${params}) ${body}`;
      
    case 'BlockStatement':
      return `{\n${node.body.map(generate).join('\n')}\n}`;
      
    case 'ReturnStatement':
      return `return ${generate(node.argument)};`;
      
    case 'BinaryExpression':
      return `${generate(node.left)} ${node.operator} ${generate(node.right)}`;
      
    default:
      throw new Error(`Unknown node type: ${node.type}`);
  }
}

生成器逻辑:递归地拼接字符串。遇到 BinaryExpression 就拼左右两边,遇到 FunctionExpression 就拼关键字和参数。

六、串联整个流程(Compiler)

最后,我们把解析、转换、生成串起来,就是一个迷你版的 Babel。

const parser = require('@babel/parser'); // 借用parser,专注转换逻辑

function myBabelCompiler(code) {
  // 1. 解析 (Code -> AST)
  const ast = parser.parse(code);

  // 2. 转换 (AST -> New AST)
  // 传入我们的访问器对象
  traverse(ast, transformer);

  // 3. 生成 (New AST -> New Code)
  const output = generate(ast);

  return output;
}

// 测试
const sourceCode = "const add = (a, b) => a + b;";
const targetCode = myBabelCompiler(sourceCode);

console.log(targetCode);
// 输出结果:
// const add = function(a, b) {
// return a + b;
// };

总结

实现一个Babel,不要把问题想得太复杂,其实就是三个步骤:

  1. 对象化:代码是字符串,没法改,先变成对象(AST)。
  2. 递归:对象嵌套太深,必须用递归函数(Visitor)去一层层找。
  3. 还原:改完对象属性后,按照语法规则把字符串拼回去。 真正的Babel虽然庞大,因为它要处理几百种语法节点,还要处理作用域(Scope)和引用关系,但核心骨架就是上面这几十行代码。当你写Babel插件时,你其实就是在写那个 transformer 对象里的函数。

Babel工程化配置与使用

刚才我们手写了一个微型编译器,搞懂了原理。但在实际工作中,我们不可能自己去写AST遍历器和生成器。我们直接使用Babel官方提供的工具链。

这里有一个非常反直觉的事实:Babel本身什么都不做

如果你只安装 @babel/core 然后运行它,你把 ES6 代码丢进去,出来的还是 ES6 代码。它只是把代码解析成AST,然后又打印出来,中间没有任何修改。它不知道你要干什么。

要让它干活,必须明确告诉它:我要转换箭头函数,或者我要转换类(Class)。这些具体的转换功能,就是 Plugin(插件);而为了方便,把一堆常用的插件打包在一起,就是 Preset(预设)

一、基础配置:从零开始搭建

我们不讲虚的,直接看在一个空文件夹里怎么把 Babel 跑起来。

1. 初始化项目与安装核心库

你需要安装三个最基础的包:

  • @babel/core: 编译器核心,负责解析和生成。
  • @babel/cli: 命令行工具,让我们能在终端里运行 babel 命令。
  • @babel/preset-env: 这是一个智能预设,包含了所有现代 JS 语法的转换插件。
npm init -y
npm install --save-dev @babel/core @babel/cli @babel/preset-env

2. 编写配置文件

在项目根目录创建一个 babel.config.json 文件。这是控制 Babel 行为的大脑。最简单的配置只需要一行:告诉 Babel 使用 preset-env

{
  "presets": ["@babel/preset-env"]
}

3. 运行测试

创建一个 src/index.js,写点 ES6 代码:

const sayHello = () => console.log("Hello");

在终端运行编译命令:

npx babel src --out-dir dist

打开生成的 dist/index.js,你会发现箭头函数变成了 functionconst 变成了 var。这就是 preset-env 在起作用。它默认把所有新语法都转成了 ES5。

二、按需编译:Targets 的重要性

上面的默认配置有一个大问题:它太“笨”了。

它把所有代码都转成了 ES5,哪怕你只是跑在最新的 Chrome 浏览器上。现代浏览器原生支持 const 和箭头函数,强行转换只会让代码体积变大,运行变慢。

我们需要告诉 Babel 我们的代码要在什么环境下运行。

修改 babel.config.json

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "chrome": "88",
          "ie": "11"
        }
      }
    ]
  ]
}

这里我们配置了 targets。 如果你把 ie: "11" 去掉,只保留 chrome: "88",再次编译,你会发现 const 和箭头函数被保留了,没有被转换。

这是因为 Babel 查表发现 Chrome 88 原生支持这些语法,所以它直接跳过了转换步骤。这是 Babel 配置中最核心的优化点:只转换目标环境不支持的语法

三、处理API:Polyfill (垫片)

这是新手最容易混淆的地方。Babel 有两类转换:

  1. 语法转换 (Syntax Transform):比如 => 转成 functionclass 转成 prototype。这是 preset-env 擅长的。
  2. API 添加 (Polyfill):比如 Array.fromnew Promise()Map

如果你在代码里写 new Promise(),Babel 默认是不处理的。因为从语法角度看,这就是创建了一个对象,语法没问题。但在 IE11 里运行会直接报错 Promise is not defined

我们需要引入 core-js 来实现这些缺少的 API。

不要全量引入,那样包会很大。我们要配置 Babel 自动按需引入。

首先安装 core-js:

npm install core-js

修改 babel.config.json,开启 useBuiltIns: "usage"

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "chrome": "58",
          "ie": "11"
        },
        "useBuiltIns": "usage", // 关键配置:按需引入
        "corejs": 3             // 指定 core-js 版本
      }
    ]
  ]
}

现在,如果在你的代码里写了 new Promise(),Babel 编译时会自动在文件头部加上一句: require("core-js/modules/es.promise.js")

如果你没用到 Promise,它就不加。这就是 usage 模式的威力。

四、在 Webpack 中集成

在实际开发中,我们很少直接运行 npx babel。通常是配合 Webpack 打包时自动转换。这需要用到 babel-loader

这是 Webpack 和 Babel 的连接桥梁。Webpack 负责读取文件,发现是 .js 后,交给 babel-loaderbabel-loader 调用 @babel/core 进行转换,转换完把代码还给 Webpack。

webpack.config.js 配置示例:

module.exports = {
  mode: 'development',
  entry: './src/index.js',
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/, // 极其重要:千万别编译 node_modules,慢且容易出错
        use: {
          loader: 'babel-loader',
          // options 可以在这里写,也可以直接读取 babel.config.json
          // 推荐使用独立配置文件,更清晰
        }
      }
    ]
  }
};

只要项目根目录下有 babel.config.jsonbabel-loader 会自动读取它,不需要重复配置。

总结 Babel 使用的核心逻辑

  1. Babel 核心只是空壳,必须通过配置文件告诉它用什么插件。
  2. Preset-env 是万能钥匙,它根据 targets 决定要转换哪些语法,避免过度编译。
  3. 语法 != API=> 是语法,Promise 是 API。处理 API 需要配置 core-jsuseBuiltIns: "usage"
  4. exclude node_modules。在使用 Webpack 时,永远记得排除 node_modules,第三方包通常已经是编译好的,重复编译纯属浪费时间。

手写 Babel 插件:复刻 babel-plugin-import

我们要写一个真的能用的、在生产环境中极其常见的插件。

很多 UI 组件库(比如 Ant Design)或者工具库(比如 Lodash),都有一个痛点:文件太大。

当你写下这行代码时:

import { Button, Alert } from 'antd';

在没有优化的情况下,Webpack 会把整个 antd 库(几百个组件)全打包进去,哪怕你只用了两个组件。我们要写的插件,就是要把上面那一行代码,在编译时自动转换成:

import Button from 'antd/lib/button';
import Alert from 'antd/lib/alert';

这样就能按需加载,体积瞬间变小。这个插件逻辑非常经典,涉及了节点查找节点替换多节点生成这几个 Babel 插件最核心的操作。

一、准备工作

写插件的第一步永远不是写代码,而是对比 AST。我们要搞清楚,处理前的 AST 长什么样,处理后长什么样。

处理前 (import { Button } from 'antd'): 它是一个 ImportDeclaration 节点。

  • source: 值是 'antd'
  • specifiers: 这是一个数组。里面有一个 ImportSpecifier,它的 imported 属性是 Button(引入的名字),local 属性也是 Button(本地使用的名字)。

处理后 (import Button from 'antd/lib/button'): 变成了两个(或多个)ImportDeclaration 节点。

  • 每个节点都是 ImportDefaultSpecifier(注意这里变成了默认导入,因为具体的组件文件通常是 export default)。
  • source: 值变成了 'antd/lib/button'

处理方案:

  1. 监听:专门盯着 ImportDeclaration 类型的节点。
  2. 检查:看它的来源库是不是我们要优化的库(比如 'antd')。
  3. 提取:如果是,就把里面的 ButtonAlert 这些名字取出来。
  4. 构造:用这些名字生成新的 import 语句。
  5. 替换:用新生成的数组,替换掉原来那一个老节点。

二、开始编写插件代码

创建一个 my-import-plugin.js 文件。

Babel 插件的标准写法是一个函数,它接受一个 babel 对象作为参数。我们需要从这个对象里拿出 types,这是 Babel 提供的节点构造工厂。你可以把它想象成乐高积木的模具,用来生成新的 AST 节点。

module.exports = function(babel) {
  const { types: t } = babel; // 这是我们的工厂

  return {
    visitor: {
      // 我们只关心 import 语句
      ImportDeclaration(path, state) {
        const { node } = path;

        // 1. 检查:如果引入的库不是 'antd',直接跳过,不做处理
        // state.opts 是我们在配置文件里传给插件的参数
        // 这样插件就不仅仅能处理 antd,也能处理 lodash 等其他库
        const libraryName = state.opts.libraryName || 'antd';
        if (node.source.value !== libraryName) {
          return;
        }

        // 2. 检查:如果是默认导入 (import Antd from 'antd'),不仅没法按需加载,还说明用户可能真想引入全量
        // 我们只处理 { Button } 这种命名导入 (ImportSpecifier)
        if (!t.isImportSpecifier(node.specifiers[0])) {
          return;
        }

        // 3. 核心逻辑:遍历原来的 specifiers,生成新的 import 节点数组
        const newImports = node.specifiers.map(specifier => {
          // specifier.imported.name 是 "Button"
          // specifier.local.name 是我们代码里用的变量名 (通常也是 "Button")
          const componentName = specifier.imported.name;
          const localName = specifier.local.name;

          // 构造新的路径: 'antd/lib/button'
          // 这里简单的转成小写,实际工程中可能需要驼峰转连字符
          const newPath = `${libraryName}/lib/${componentName.toLowerCase()}`;

          // 使用 Babel 的 types 工具创建新节点
          // 生成: import localName from 'newPath'
          return t.importDeclaration(
            [t.importDefaultSpecifier(t.identifier(localName))],
            t.stringLiteral(newPath)
          );
        });

        // 4. 替换:用新的节点数组替换原来的一个节点
        // replaceWithMultiple 专门用来把一个节点变成一堆节点
        path.replaceWithMultiple(newImports);
      }
    }
  };
};

这段代码虽然短,但它展示了 Babel 插件最核心的逻辑:Path(路径)操作path 对象非常强大,它不只是当前节点,还包含了父节点、兄弟节点的信息,以及最重要的操作方法(比如 replaceWithMultiple, remove, insertBefore)。

三、调试与运行

插件写好了,怎么用呢?我们不需要把它发布到 npm,直接在本地引用测试。

在项目根目录下创建一个 .babelrc 或者 babel.config.json,配置上我们刚写的插件:

{
  "presets": ["@babel/preset-env"],
  "plugins": [
    [
      "./my-import-plugin.js", 
      {
        "libraryName": "antd" 
      }
    ]
  ]
}

这里我们用了相对路径 ./my-import-plugin.js,并且传入了参数 libraryName: "antd"

验证效果

创建一个 test.js

import { Button, Modal } from 'antd';
console.log(Button, Modal);

然后运行 Babel 编译(假设你已经安装了 @babel/cli):

npx babel test.js

你的控制台输出应该会变成这样:

import Button from "antd/lib/button";
import Modal from "antd/lib/modal";
console.log(Button, Modal);

四、进阶思考:为什么说这有难度?

刚才的代码是一个“乞丐版”实现。在真实场景中,情况会复杂得多,这也是为什么 babel-plugin-import 源码有几百行的原因。

1. 样式的处理 真正的按需加载,不仅仅是加载 JS,还要加载对应的 CSS。 你需要不仅生成 import Button from ...,还要顺便生成 import 'antd/lib/button/style/css'。这需要在 map 循环里多生成一个 importDeclaration 节点。

2. 作用域冲突 如果你在代码里已经定义了一个叫 Button 的变量,然后再 import { Button } from 'antd',Babel 插件如果不小心处理,可能会导致变量名冲突。虽然在这个场景下概率不大,但写通用插件时,通常需要用 path.scope.generateUidIdentifier 来生成唯一的变量名。

3. 路径转换规则 我们只用了简单的 .toLowerCase()。但有的组件叫 DatePicker,文件路径可能是 date-picker。这时候就需要引入更复杂的命名转换算法(Kebab Case)。

总结

写好一个 Babel 插件,其实就是三个步骤的循环:

  1. 看 AST:用 AST Explorer 这种在线工具,把你的源代码放进去,看它是怎么被解析的。
  2. 造节点:利用 babel.types (t) 构建你想要的新结构。
  3. 换节点:利用 path 提供的 API,把旧的换成新的。

当你掌握了 visitor 模式和 types 构建器,你就掌握了修改 JavaScript 语言本身的权力。

前端知识体系总结-前端工程化(Webpack篇)

2026年2月25日 15:20

Wepack实现

webpack打包功能实现

webpack打包与模块加载原理(从JS入口文件出发如何进行简单打包 -> __webpack_require__具体实现 -> 一个最基础的bundle.js至少具备的内容 -> 实现一个基本的webpack打包功能)

一、从JS文件打包说起

1.1 基本打包过程

当我们有以下文件结构时:

src/
  ├── a.js (入口文件)
  └── b.js (依赖文件)

a.js (入口文件):

import { getValue } from './b.js';
console.log(getValue());

b.js (依赖文件):

export function getValue() {
  return 'Hello from b.js';
}

1.2 打包后的结果(自测:请说出打包后的代码形式)

以a.js为入口进行打包后,生成的bundle.js会将每个模块包装成函数形式:

// 简化版的打包结果
{
  "./src/a.js": function(module, exports, __webpack_require__) {
    eval(`
      const { getValue } = __webpack_require__("./src/b.js");
      console.log(getValue());
    `);
  },
  "./src/b.js": function(module, exports, __webpack_require__) {
    eval(`
      function getValue() {
        return 'Hello from b.js';
      }
      exports.getValue = getValue;
    `);
  }
}

关键变化:

  • 原本的 import { getValue } from './b.js' 被转换为 __webpack_require__("./src/b.js")
  • 每个模块被包装在函数中,接收 module, exports, __webpack_require__ 参数

二、webpack_require 的实现原理(自测:说出核心代码实现)

2.1 函数签名与作用

function __webpack_require__(moduleId) {
  // 参数:moduleId - 模块的路径标识符(如 "./src/b.js")
  // 返回值:该模块的所有导出内容(exports对象)
}

2.2 完整实现过程

// 模块缓存对象
var __webpack_module_cache__ = {};

// 主要的模块加载函数
function __webpack_require__(moduleId) {
  // 1. 检查缓存,避免重复加载
  var cachedModule = __webpack_module_cache__[moduleId];
  if (cachedModule !== undefined) {
    return cachedModule.exports;
  }
  
  // 2. 创建新的模块对象并缓存
  var module = __webpack_module_cache__[moduleId] = {
    exports: {}
  };
  
  // 3. 执行模块函数,填充exports
  __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
  
  // 4. 返回模块的导出内容
  return module.exports;
}

2.3 模块执行机制

关键在于这一行:

__webpack_modules__[moduleId](module, module.exports, __webpack_require__);

执行过程:

  1. __webpack_modules__ 对象中获取对应的模块函数
  2. 传入三个参数:module(模块对象)、module.exports(导出对象)、__webpack_require__(加载函数)
  3. 模块函数内部通过修改 module.exports 来导出内容
  4. 执行完成后返回填充好的 module.exports

三、Bundle.js的基本结构(自测:说出结构是什么以及为什么)

一个完整的bundle.js至少需要包含以下内容:

3.1 核心组件

// 1. 模块存储对象 - 存放所有模块函数
var __webpack_modules__ = {
  "./src/a.js": function(module, exports, __webpack_require__) { /* ... */ },
  "./src/b.js": function(module, exports, __webpack_require__) { /* ... */ }
};

// 2. 模块缓存对象
var __webpack_module_cache__ = {};

// 3. 模块加载函数
function __webpack_require__(moduleId) { /* ... */ }

// 4. 启动应用程序
__webpack_require__("./src/a.js");

3.2 完整示例

(function() {
  "use strict";
  
  var __webpack_modules__ = {
    "./src/a.js": function(module, exports, __webpack_require__) {
      eval(`
        const { getValue } = __webpack_require__("./src/b.js");
        console.log(getValue());
      `);
    },
    "./src/b.js": function(module, exports, __webpack_require__) {
      eval(`
        function getValue() {
          return 'Hello from b.js';
        }
        exports.getValue = getValue;
      `);
    }
  };
  
  var __webpack_module_cache__ = {};
  
  function __webpack_require__(moduleId) {
    var cachedModule = __webpack_module_cache__[moduleId];
    if (cachedModule !== undefined) {
      return cachedModule.exports;
    }
    
    var module = __webpack_module_cache__[moduleId] = {
      exports: {}
    };
    
    __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
    
    return module.exports;
  }
  
  // 启动入口模块
  __webpack_require__("./src/a.js");
})();

总结

Webpack的核心打包原理:

  1. 模块化处理:将每个文件包装成函数,统一模块接口,并存储在全局webpack——modules中。
  2. 依赖管理:通过__webpack_require__实现模块间的加载和缓存,获取文件导出内容,并且缓存导出结果下次复用。
  3. 代码整合:将所有模块函数和运行时代码组装成单一文件 bundle.js 用立即执行函数进行运行。

这种设计让浏览器能够执行原本不支持的ES6模块语法,同时实现了高效的模块缓存和按需加载机制。

实现Webpack依赖分析(自测:如何实现分析依赖,两种优缺点)

实现步骤

  1. 依赖分析:从入口文件开始,递归找到所有依赖的文件
  2. 代码转换:将每个文件转换为模块函数格式
  3. 生成bundle:将所有模块函数组装成最终的bundle.js

依赖分析的两种方法

方法一:正则表达式

function findDependenciesByRegex(code) {
  const importRegex = /import\s+.*?\s+from\s+['"](.*?)['"];?/g;
  const dependencies = [];
  let match;
  
  while ((match = importRegex.exec(code)) !== null) {
    dependencies.push(match[1]);
  }
  
  return dependencies;
}

优点:

  • 实现简单,代码量少
  • 执行速度快

缺点:

  • 容易误匹配字符串中的内容
  • 无法处理复杂的import语法
  • 不够准确和可靠

问题示例:

// 这种情况会被错误匹配
const code = `
  console.log("import something from 'fake-module'");
  import { real } from './real-module';
`;

方法二:抽象语法树(AST)

const babel = require('@babel/core');
const traverse = require('@babel/traverse').default;

function findDependenciesByAST(code) {
  const dependencies = [];
  
  // 将代码解析为AST
  const ast = babel.parse(code, {
    sourceType: 'module'
  });
  
  // 遍历AST节点找到import的路径
  traverse(ast, {
    ImportDeclaration(path) {
      dependencies.push(path.node.source.value);
    }
  });
  
  return dependencies;
}

优点:

  • 精确解析,不会误匹配字符串
  • 能处理各种复杂的import语法
  • 提供完整的语法信息

缺点:

  • 实现复杂度较高
  • 需要引入额外的解析库
  • 执行速度相对较慢

为什么AST更准确:

  • AST将代码解析为树形结构,每个import语句会生成专门的ImportDeclaration节点
  • 字符串内容不会被解析为import节点,从根本上避免了误匹配
  • 能够准确识别import语句的各个组成部分(导入内容、来源路径等)

手写实现抽象语法树与完整模块打包工具

一、获取JS文件依赖信息,获取依赖文件绝对路径:如何将代码解析为抽象语法树(AST)

1.1 使用@babel/parser解析代码

抽象语法树(Abstract Syntax Tree, AST)是源代码的抽象语法结构的树状表示。我们可以使用 @babel/parser(原名 Babylon)将 JavaScript 代码字符串解析为 AST 对象。

const parser = require('@babel/parser');

const code = `import React from 'react';`;
const ast = parser.parse(code, {
  sourceType: 'module' // 指定代码为模块化代码
});

console.log(ast);

解析后的 AST 本质上是一个 JavaScript 对象,其中包含描述代码结构的各种节点。当打印 AST 时,某些嵌套较深的属性会以其类型(如 Node、Position)代替显示,但直接转换为字符串可以看到完整结构。

1.2 手动遍历AST获取依赖

AST 的 program.body 属性是一个数组,包含了当前文件的所有顶级语句。我们可以遍历这个数组,找到所有类型为 ImportDeclaration 的节点,然后从中提取导入路径。

const dependencies = [];
ast.program.body.forEach(node => {
  if (node.type === 'ImportDeclaration') {
    dependencies.push(node.source.value);
  }
});

console.log(dependencies); // ['react']

这种方法虽然可行,但手动遍历 AST 结构繁琐且容易出错。

1.3 使用@babel/traverse简化遍历

@babel/traverse 提供了一个更便捷的方式来遍历 AST。我们可以使用它来查找特定类型的节点。

const traverse = require('@babel/traverse').default;

const dependencies = [];
traverse(ast, {
  ImportDeclaration(path) {
    dependencies.push(path.node.source.value);
  }
});

console.log(dependencies); // ['react']

这种方法更加简洁和可靠,我们只需要定义对特定节点类型的处理函数即可。

二、如何实现从入口文件开始自动化依赖分析所有依赖文件

2.1 单文件依赖分析

我们可以封装一个函数来分析单个文件的依赖:

const fs = require('fs');
const path = require('path');

function getDependencies(filename) {
  const content = fs.readFileSync(filename, 'utf-8');
  const ast = parser.parse(content, { sourceType: 'module' });

  const dependencies = [];
  traverse(ast, {
    ImportDeclaration(path) {
      const importPath = path.node.source.value;
      // 将相对路径转换为绝对路径
      const absolutePath = path.resolve(path.dirname(filename), importPath);
      dependencies.push(absolutePath);
    }
  });

  return {
    filename,
    dependencies
  };
}

2.2 广度优先搜索分析所有依赖

从入口文件开始,我们可以使用广度优先搜索(BFS)来分析整个项目的所有依赖:

function analyzeDependencies(entryFile) {
  const queue = [entryFile];
  const allDependencies = new Set();
  const dependencyGraph = new Map();

  while (queue.length > 0) {
    const currentFile = queue.shift();

    if (allDependencies.has(currentFile)) continue;
    allDependencies.add(currentFile);

    const { dependencies } = getDependencies(currentFile);
    dependencyGraph.set(currentFile, dependencies);

    dependencies.forEach(dep => {
      if (!allDependencies.has(dep)) {
        queue.push(dep);
      }
    });
  }

  return dependencyGraph;
}

这样我们就得到了一个包含所有模块及其依赖关系的映射表。

三、ES模块语法转换为CommonJS形式

为了使模块代码能在打包环境中运行,我们需要将 ES 模块语法转换为 CommonJS 形式。这包括处理 import 和 export 语句。

3.1 ImportDeclaration转换

对于不同类型的 import 语法,我们进行不同的转换:

const { transformFromAst } = require('@babel/core');
const t = require('@babel/types');

function transformImportDeclaration(ast, moduleIdMap) {
  traverse(ast, {
    ImportDeclaration(path) {
      const source = path.node.source.value;
      const absolutePath = path.resolve(path.dirname(path.hub.file.opts.filename), source);

      // 生成模块ID
      const moduleId = moduleIdMap.get(absolutePath) || generateModuleId(absolutePath);
      moduleIdMap.set(absolutePath, moduleId);

      const specifiers = path.node.specifiers;
      const imports = specifiers.map(spec => {
        if (t.isImportDefaultSpecifier(spec)) {
          // 默认导入:import foo from 'module' → const foo = webpack_require(moduleId)
          return t.variableDeclarator(
            t.identifier(spec.local.name),
            t.callExpression(t.identifier('webpack_require'), [t.numericLiteral(moduleId)])
          );
        } else if (t.isImportSpecifier(spec)) {
          // 命名导入:import { foo } from 'module' → const foo = webpack_require(moduleId).foo
          return t.variableDeclarator(
            t.identifier(spec.local.name),
            t.memberExpression(
              t.callExpression(t.identifier('webpack_require'), [t.numericLiteral(moduleId)]),
              t.identifier(spec.imported.name)
            )
          );
        }
      }).filter(Boolean);

      // 替换 import 语句为 const 声明
      path.replaceWith(t.variableDeclaration('const', imports));
    }
  });
}

3.2 ExportDefaultDeclaration转换

将 export default 语句转换为 CommonJS 形式:

function transformExportDefaultDeclaration(ast) {
  traverse(ast, {
    ExportDefaultDeclaration(path) {
      // 替换 export default foo 为 module.exports = foo
      path.replaceWith(
        t.expressionStatement(
          t.assignmentExpression(
            '=',
            t.memberExpression(t.identifier('module'), t.identifier('exports')),
            path.node.declaration
          )
        )
      );
    }
  });
}

3.3 ExportNamedDeclaration转换

将命名导出语句转换为 CommonJS 形式:

function transformExportNamedDeclaration(ast) {
  traverse(ast, {
    ExportNamedDeclaration(path) {
      // 替换 export { foo } 为 module.exports.foo = foo
      if (path.node.specifiers.length) {
        const exports = path.node.specifiers.map(spec => {
          return t.expressionStatement(
            t.assignmentExpression(
              '=',
              t.memberExpression(
                t.memberExpression(t.identifier('module'), t.identifier('exports')),
                t.identifier(spec.exported.name)
              ),
              t.identifier(spec.local.name)
            )
          );
        });
        path.replaceWithMultiple(exports);
      }
    }
  });
}

3.4 模块ID生成

我们使用一个简单的自增 ID 来标识每个模块:

const moduleIdMap = new Map();
let nextModuleId = 0;

function generateModuleId(modulePath) {
  if (!moduleIdMap.has(modulePath)) {
    moduleIdMap.set(modulePath, nextModuleId++);
  }
  return moduleIdMap.get(modulePath);
}

实际 Webpack 会使用更复杂的哈希算法生成模块 ID,以实现更好的缓存效果。

四、打包产物(bundle.js)工作原理,核心概念和结构

4.1 模块打包的核心概念

打包工具的核心功能包括:

  • 模块作用域隔离:通过函数作用域将每个模块封装
  • 模块导入导出:实现模块间的引用关系
  • 模块缓存:避免重复执行模块代码
  • 入口执行:从入口文件开始执行整个应用

4.2 简化版打包产物结构

一个简化版的打包产物(bundle.js)通常包含以下部分:

(function(modules) {
  // 模块缓存
  const installedModules = {};

  // 模拟webpack_require函数
  function webpack_require(moduleId) {
    // 检查缓存
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }

    // 创建新模块
    const module = installedModules[moduleId] = {
      exports: {}
    };

    // 执行模块函数
    modules[moduleId].call(
      module.exports,
      module,
      module.exports,
      webpack_require
    );

    return module.exports;
  }

  // 执行入口模块
  return webpack_require('<%= entryModuleId %>');
})({
  <% modules.forEach((module) => { %>
    '<%= module.id %>': function(module, exports, webpack_require) {
        <%= module.code %>
     },
  <% }); %>
});

注意:这里使用的是 webpack_require 而不是 require,以避免与 Node.js 的原生 require 混淆,他们不是一个函数

五、使用EJS动态生成打包产物

5.1 EJS模板基础

EJS 是一个简单的模板引擎,可以让我们用 JavaScript 生成 HTML 或其他文本格式。基本语法:

  • <%= variable %>:输出变量值
  • <% code %>:执行 JavaScript 代码

5.2 创建打包模板

我们可以创建一个 EJS 模板来动态生成打包产物:

const ejs = require('ejs');

const template = `
(function(modules) {
  const installedModules = {};

  function webpack_require(moduleId) {
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }

    const module = installedModules[moduleId] = {
      exports: {}
    };

    modules[moduleId].call(
      module.exports,
      module,
      module.exports,
      webpack_require
    );

    return module.exports;
  }

  return webpack_require(<%= entryModuleId %>);
})({
  <% modules.forEach((module) => { %>
    <%= module.id %>: function(module, exports, webpack_require) {
      <%= module.code %>
    },
  <% }); %>
});
`;

5.3 渲染打包产物

使用 EJS 渲染模板并生成最终的打包文件:

function generateBundle(modules, entryId) {
  const moduleList = Array.from(modules.values()).map(mod => ({
    id: mod.id,
    code: mod.code
  }));

  const bundleCode = ejs.render(template, {
    entryModuleId: entryId,
    modules: moduleList
  });

  return bundleCode;
}

六、完整的打包流程实现

整合所有步骤,实现完整的打包流程:

const fs = require('fs');
const path = require('path');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const t = require('@babel/types');

// 模块 ID 映射表
const moduleIdMap = new Map();
let nextModuleId = 0;

// 生成模块 ID
function generateModuleId(modulePath) {
  if (!moduleIdMap.has(modulePath)) {
    moduleIdMap.set(modulePath, nextModuleId++);
  }
  return moduleIdMap.get(modulePath);
}

// 解析模块内容,提取依赖并转换代码
function parseModule(modulePath) {
  const filename = path.resolve(modulePath);
  const content = fs.readFileSync(filename, 'utf-8');

  // 解析 AST
  const ast = parser.parse(content, {
    sourceType: 'module',
  });

  const dependencies = [];

  // 遍历 AST,提取 import 依赖并转换为 webpack_require
  traverse(ast, {
    ImportDeclaration(p) {
      const source = p.node.source.value;
      const absolutePath = path.resolve(path.dirname(filename), source);

      // 记录依赖
      dependencies.push(absolutePath);

      // 生成模块 ID
      const moduleId = generateModuleId(absolutePath);

      // 替换 import 语句为 webpack_require
      const specifiers = p.node.specifiers;
      const imports = specifiers.map(spec => {
        if (t.isImportDefaultSpecifier(spec)) {
          // 默认导入:import foo from 'module' → const foo = webpack_require(moduleId)
          return t.variableDeclarator(
            t.identifier(spec.local.name),
            t.callExpression(t.identifier('webpack_require'), [t.numericLiteral(moduleId)])
          );
        } else if (t.isImportSpecifier(spec)) {
          // 命名导入:import { foo } from 'module' → const foo = webpack_require(moduleId).foo
          return t.variableDeclarator(
            t.identifier(spec.local.name),
            t.memberExpression(
              t.callExpression(t.identifier('webpack_require'), [t.numericLiteral(moduleId)]),
              t.identifier(spec.imported.name)
            )
          );
        }
      }).filter(Boolean);

      // 替换 import 语句为 const 声明
      p.replaceWith(t.variableDeclaration('const', imports));
    },

    ExportDefaultDeclaration(p) {
      // 替换 export default 为 module.exports
      p.replaceWith(
        t.expressionStatement(
          t.assignmentExpression(
            '=',
            t.memberExpression(t.identifier('module'), t.identifier('exports')),
            p.node.declaration
          )
        )
      );
    },

    ExportNamedDeclaration(p) {
      // 替换 export { foo } 为 module.exports.foo = foo
      if (p.node.specifiers.length) {
        const exports = p.node.specifiers.map(spec => {
          return t.expressionStatement(
            t.assignmentExpression(
              '=',
              t.memberExpression(
                t.memberExpression(t.identifier('module'), t.identifier('exports')),
                t.identifier(spec.exported.name)
              ),
              t.identifier(spec.local.name)
            )
          );
        });
        p.replaceWithMultiple(exports);
      }
    },
  });

  // 生成转换后的代码
  const { code } = generate(ast);

  return {
    id: generateModuleId(filename),
    filename,
    dependencies,
    code,
  };
}

// 递归分析所有依赖
function analyzeDependencies(entry) {
  const entryModule = parseModule(entry);
  const queue = [entryModule];
  const modules = new Map();

  modules.set(entryModule.id, entryModule);

  while (queue.length > 0) {
    const currentModule = queue.shift();

    currentModule.dependencies.forEach(depPath => {
      const depModule = parseModule(depPath);

      if (!modules.has(depModule.id)) {
        modules.set(depModule.id, depModule);
        queue.push(depModule);
      }
    });
  }

  return modules;
}

// 生成打包后的代码
function generateBundle(modules, entryId) {
  const moduleList = Array.from(modules.values()).map(mod => `
  ${mod.id}: function(module, exports, webpack_require) {
    ${mod.code}
  },
`).join('\n');

  return `
(function(modules) {
  const installedModules = {};

  function webpack_require(moduleId) {
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }

    const module = installedModules[moduleId] = {
      exports: {}
    };

    modules[moduleId].call(
      module.exports,
      module,
      webpack_require
    );

    return module.exports;
  }

  return webpack_require(${entryId});
})({
${moduleList}
});
`;
}

// 主打包函数
function bundle(entryFile, outputFile) {
  const modules = analyzeDependencies(entryFile);
  const entryModule = Array.from(modules.values()).find(mod => mod.filename === path.resolve(entryFile));
  const bundleCode = generateBundle(modules, entryModule.id);

  fs.writeFileSync(outputFile, bundleCode);
  console.log(`✅ 打包完成: ${outputFile}`);
}

// 使用示例
bundle('./src/index.js', './dist/bundle.js');

七、总结

通过以上步骤,我们实现了一个简化版的模块打包工具,核心流程包括:

  1. 使用 @babel/parser 将代码解析为 AST
  2. 使用 @babel/traverse 遍历 AST 提取依赖关系
  3. 将 ES 模块语法转换为 CommonJS 形式
    • 处理默认导入:import foo from 'module'const foo = webpack_require(moduleId)
    • 处理命名导入:import { foo } from 'module'const foo = webpack_require(moduleId).foo
    • 处理默认导出:export default foomodule.exports = foo
    • 处理命名导出:export { foo }module.exports.foo = foo
  1. 通过广度优先搜索分析整个项目的依赖图
  2. 使用模块 ID 优化和代码转换完善打包产物
  3. 动态生成最终的打包代码

Webpack Loader实现

一、Loader的基本概念

1.1 什么是Loader

Loader是Webpack的核心功能之一,它的作用是将非JavaScript文件转换为JavaScript模块,使得Webpack能够处理除了JS之外的各种类型的文件。

1.2 为什么需要Loader

原生Webpack的局限性

  • Webpack原生只能理解JavaScriptJSON文件
  • 当遇到其他格式文件时,需要转换为JavaScript语法才能被解析为AST(进行依赖分析也就是寻找import的子文件路径)

问题示例

// 以下代码会导致解析失败
import './styles.css';        // CSS文件不符合JS语法
import data from './data.json'; // JSON需要特殊处理

解析失败的原因

  • CSS文件内容如 .button { color: red; } 不符合JavaScript语法规范
  • 直接解析会在AST生成阶段报错
  • 需要先转换为有效的JavaScript导出语句

1.3 Loader的工作原理

Loader本质上是一个转换函数,它接收源文件内容,返回转换后的JavaScript代码:

// Loader的基本结构
module.exports = function(source) {
  // source: 原始文件内容字符串
  // 返回: 转换后的JavaScript代码字符串
  return `export default ${JSON.stringify(source)}`;
};

二、Loader的配置与执行机制

2.1 Webpack配置中的Loader

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /.json$/,           // 正则匹配文件类型
        use: ['json-loader']       // 使用的loader数组
      },
      {
        test: /.css$/,
        use: ['style-loader', 'css-loader'] // 多个loader的执行顺序
      }
    ]
  }
};

2.2 Loader的执行顺序

关键特性:Loader从右到左(从后到前)执行

use: ['style-loader', 'css-loader']
// 执行顺序:
// 1. css-loader 处理 .css 文件
// 2. style-loader 处理 css-loader 的输出结果

执行流程图

原始CSS文件 → css-loader → JavaScript字符串 → style-loader → 最终JavaScript模块

2.3 在打包器中集成Loader机制

修改文件分析逻辑

class SimpleWebpack {
  constructor(entry, output, config = {}) {
    this.entry = entry;
    this.output = output;
    this.loaders = config.module?.rules || []; // 获取loader配置
    // ... 其他属性
  }

  /**
   * 应用匹配的loaders处理文件内容
   */
  applyLoaders(filePath, source) {
    let transformedSource = source;

    // 遍历所有loader规则
    for (const rule of this.loaders) {
      // 检查文件是否匹配当前规则
      if (rule.test.test(filePath)) {
        // 从右到左执行loaders
        const loaders = Array.isArray(rule.use) ? [...rule.use].reverse() : [rule.use];
        
        for (const loaderName of loaders) {
          const loader = this.loadLoader(loaderName);
          transformedSource = loader(transformedSource);
        }
        break; // 匹配到规则后停止检查其他规则
      }
    }

    return transformedSource;
  }

  /**
   * 加载并返回loader函数
   */
  loadLoader(loaderName) {
    // 在实际应用中,这里会从node_modules加载loader
    // 为了演示,我们使用内置的loader映射
    const builtinLoaders = {
      'json-loader': this.jsonLoader,
      'css-loader': this.cssLoader,
      'style-loader': this.styleLoader
    };

    return builtinLoaders[loaderName] || ((source) => source);
  }

  /**
   * 修改后的文件分析方法
   */
  analyzeFile(filePath) {
    if (this.modules.has(filePath)) {
      return this.modules.get(filePath);
    }

    let sourceCode = fs.readFileSync(filePath, 'utf-8');
    
    // 关键步骤:在AST解析前应用loaders
    sourceCode = this.applyLoaders(filePath, sourceCode);
    
    // 现在sourceCode已经是有效的JavaScript代码,可以安全解析为AST
    const ast = parser.parse(sourceCode, {
      sourceType: 'module'
    });

    // ... 后续AST分析逻辑
  }
}

三、简单实现json-loader

3.1 JSON文件处理需求

// 原始JSON文件 data.json
{
  "name": "webpack-demo",
  "version": "1.0.0"
}

// 期望的转换结果(JavaScript模块)
export default {
  "name": "webpack-demo", 
  "version": "1.0.0"
};

3.2 json-loader实现

/**
 * JSON Loader实现
 * 将JSON文件内容转换为JavaScript默认导出
 */
function jsonLoader(source) {
  // 验证JSON格式
  try {
    JSON.parse(source);
  } catch (error) {
    throw new Error(`Invalid JSON file: ${error.message}`);
  }

  // 转换为JavaScript模块导出语法
  return `export default ${source};`;
}

3.3 使用示例

// webpack配置
{
  test: /.json$/,
  use: ['json-loader']
}

// 在JavaScript中使用
import config from './config.json';
console.log(config.name); // "webpack-demo"

四、手写实现简易style-loader与css-loader

4.1 CSS文件处理的挑战

CSS文件无法直接被JavaScript引擎执行,需要通过DOM操作将样式注入到页面中。

处理策略

  1. css-loader:读取CSS内容并返回字符串
  2. style-loader:将CSS字符串通过DOM操作插入到页面

4.2 css-loader实现

/**
 * CSS Loader实现
 * 将CSS文件内容转换为JavaScript字符串导出
 */
function cssLoader(source) {
  // 简单版本:直接返回CSS内容作为字符串
  const cssString = JSON.stringify(source);
  return `export default ${cssString};`;
}

4.3 style-loader实现

/**
 * Style Loader实现  
 * 将CSS字符串通过DOM操作注入到页面中
 */
function styleLoader(source) {
  // 从css-loader的输出中提取CSS内容
  // css-loader输出格式:export default "css content here";
  
  return `
    // 从css-loader获取CSS内容
    ${source}
    
    // 创建并插入style标签的函数
    function insertCSS(css) {
      if (typeof document === 'undefined') return;
      
      const style = document.createElement('style');
      style.type = 'text/css';
      
      if (style.styleSheet) {
        // IE8及以下版本
        style.styleSheet.cssText = css;
      } else {
        // 现代浏览器
        style.innerHTML = css;
      }
      
      document.head.appendChild(style);
    }
    
    // 立即执行:将CSS插入页面
    insertCSS(__webpack_require__.default || __webpack_require__);
  `;
}

4.4 更完善的style-loader实现

function styleLoader(source) {
  return `
    ${source}
    
    (function() {
      // 获取CSS内容(来自css-loader的输出)
      const css = typeof exports === 'object' && exports.default || exports;
      
      if (typeof css === 'string') {
        // 创建style标签
        const style = document.createElement('style');
        style.type = 'text/css';
        
        // 添加CSS内容
        if (style.styleSheet) {
          style.styleSheet.cssText = css;
        } else {
          style.appendChild(document.createTextNode(css));
        }
        
        // 插入到head中
        document.head.appendChild(style);
        
        // 支持热更新时的样式移除
        if (module.hot) {
          module.hot.dispose(function() {
            document.head.removeChild(style);
          });
        }
      }
    })();
    
    // 导出空对象(CSS不需要导出内容)
    export default {};
  `;
}

4.5 CSS处理流程梳理

完整处理流程

1. 遇到 import './styles.css'
2. 匹配到 test: /.css$/, use: ['style-loader', 'css-loader']
3. 执行顺序(右到左):
   
   原始CSS文件内容:
   ".button { color: red; background: blue; }"
   
   ↓ css-loader处理
   
   "export default ".button { color: red; background: blue; }";"
   
   ↓ style-loader处理  
   
   "// 插入CSS到DOM的JavaScript代码
    const css = ".button { color: red; background: blue; }";
    const style = document.createElement('style');
    style.innerHTML = css;
    document.head.appendChild(style);
    export default {};"
    
4. 生成的JavaScript代码被webpack打包
5. 运行时执行,CSS被注入到页面中

五、总结与扩展

5.1 Loader机制的核心价值

  1. 扩展性:让Webpack能够处理任意类型的文件
  2. 模块化:每个Loader职责单一,可组合使用
  3. 标准化:统一的接口规范,便于开发和维护

5.2 常见Loader类型

  • 转译类:babel-loader, typescript-loader
  • 样式类:css-loader, style-loader, sass-loader
  • 文件类:file-loader, url-loader
  • 代码检查:eslint-loader
  • 模板类:html-loader, vue-loader

5.3 开发Loader的最佳实践

  1. 单一职责:每个Loader只做一件事
  2. 链式调用:设计时考虑与其他Loader的配合
  3. 错误处理:提供清晰的错误信息
  4. 性能优化:缓存计算结果,避免重复处理
  5. 选项支持:通过loader-utils获取用户配置

5.4 实际应用场景

  • 组件化开发:CSS Modules解决样式隔离问题
  • 预处理器:Sass/Less编译为CSS
  • 代码转换:ES6+转换为ES5兼容代码
  • 资源优化:图片压缩、文件合并

Webpack热更新(HMR)原理与实现(自测:说出具体原理和实现流程)

一、HMR解决的具体问题

在没有HMR(Hot Module Replacement)时,修改代码后的开发体验如下:

全量刷新 (Live Reload) :修改代码 -> Webpack重新打包 -> 浏览器自动刷新页面 (window.location.reload())。
问题: 重新打包所有资源并在浏览器重新加载以及状态丢失

HMR的效果
修改代码 -> 浏览器不刷新 -> 仅替换修改的模块代码 -> 保持当前页面状态不变。

二、HMR核心流程拆解

HMR不是单一功能,而是Webpack编译器(服务端)与浏览器运行时(客户端)配合的结果。

涉及的四个核心角色

  1. Webpack Compiler:负责监听文件,编译代码。
  2. HMR Server (通常集成在webpack-dev-server中):建立WebSocket连接,负责将更新通知推送到浏览器。
  3. Bundle Server:提供文件访问服务(http://localhost:8080/bundle.js)。
  4. HMR Runtime:注入到打包后的bundle.js中的一段JS代码,负责在浏览器端接收WebSocket消息,并执行代码替换。

完整更新流程

  1. 监听:Webpack Compiler 监听到文件变化(如 style.cssmath.js)。
  2. 增量编译:Webpack 不会重新打包所有文件,而是生成两个补丁文件:
  • Manifest (JSON) :描述哪些模块变了,新的hash值是多少。
  • Update Chunk (JS) :包含被修改模块的具体代码。
  1. 推送消息:HMR Server 通过 WebSocket 向浏览器发送消息:{"type": "hash", "data": "新的hash值"}{"type": "ok"}
  2. 检查更新:浏览器端的 HMR Runtime 收到消息,对比上一次的 hash,发现有更新。
  3. 请求补丁:Runtime 发起 AJAX 请求获取 Manifest,再通过 JSONP 请求获取 Update Chunk。
  4. 代码替换:Runtime 执行新下载的代码,替换掉 __webpack_modules__ 中对应的旧函数。 image.png

三、手写简易HMR实现逻辑

这里不展示完整的Webpack源码,而是实现HMR最核心的通信模块替换逻辑。

服务端:监听编译与WebSocket通知

在开发服务器启动时,需要注入WebSocket服务。

// server.js (模拟 webpack-dev-server)
const WebSocket = require('ws');
const webpack = require('webpack');
const config = require('./webpack.config.js');

const compiler = webpack(config);
const app = require('express')();
const server = require('http').createServer(app);

// 1. 启动 WebSocket 服务器
const wss = new WebSocket.Server({ server });

// 2. 监听 Webpack 编译完成钩子
compiler.hooks.done.tap('HMRPlugin', (stats) => {
  // 获取新生成的 hash
  const hash = stats.hash;
  
  // 3. 向所有连接的客户端广播消息
  wss.clients.forEach(client => {
    client.send(JSON.stringify({
      type: 'hash',
      data: hash
    }));
    client.send(JSON.stringify({
      type: 'ok'
    }));
  });
});

// 启动编译监视
compiler.watch({}, (err) => {
  console.log('Webpack is watching files...');
});

server.listen(8080);

客户端:Runtime代码注入

Webpack打包时,会将以下代码注入到 bundle.js 的入口处。

// bundle.js 中的注入代码 (简化版)

// 1. 建立连接
const socket = new WebSocket('ws://localhost:8080');
let currentHash = 'old_hash_value';

// 2. 监听消息
socket.onmessage = function(event) {
  const msg = JSON.parse(event.data);
  
  if (msg.type === 'hash') {
    currentHash = msg.data;
  } else if (msg.type === 'ok') {
    // 收到更新完成信号,开始热更新逻辑
    hotCheck();
  }
};

function hotCheck() {
  console.log('检测到更新,准备拉取新代码...');
  // 实际 Webpack 会在这里:
  // 1. fetch('/hash.hot-update.json') -> 拿到变动的模块ID
  // 2. loadScript('/hash.hot-update.js') -> 拿到新模块代码
  // 3. hotApply() -> 执行替换
  
  // 模拟热更新操作
  hotDownloadManifest().then(hotDownloadUpdateChunk);
}

核心:如何在浏览器端替换代码

这是HMR最关键的一步。回顾之前的打包结构,所有模块都存在 __webpack_modules__ 对象中。热更新的本质就是修改这个对象的键值对

假设更新前的 bundle.js 运行时结构:

var __webpack_modules__ = {
  "./src/title.js": function(module, exports) {
    module.exports = "Old Title";
  }
};
// 缓存
var __webpack_module_cache__ = {
  "./src/title.js": { exports: "Old Title", loaded: true }
};

更新发生时 (hotApply 的简化逻辑):

// 这是一个由 JSONP 加载的新代码块
function webpackHotUpdateCallback(chunkId, moreModules) {
  // moreModules 包含了新的模块代码
  // 例如: { "./src/title.js": function() { module.exports = "New Title"; } }
  
  for (let moduleId in moreModules) {
    // 1. 覆盖旧的模块定义
    __webpack_modules__[moduleId] = moreModules[moduleId];
    
    // 2. 删除旧的缓存(关键)
    // 下次 require 这个模块时,会重新执行新函数
    delete __webpack_module_cache__[moduleId];
    
    // 3. 执行 accept 回调(如果有)
    if (hot._acceptedDependencies[moduleId]) {
      hot._acceptedDependencies[moduleId]();
    }
  }
}

总结操作:

  1. 覆盖:用新函数覆盖 __webpack_modules__ 中的旧函数。
  2. 清缓存:删除 __webpack_module_cache__ 中的缓存。
  3. 重执行:当父模块再次执行 __webpack_require__('./src/title.js') 时,会拿到最新的代码。

四、module.hot.accept 与 冒泡机制

仅仅替换模块定义是不够的,如果页面已经渲染了 "Old Title",仅仅替换函数的定义,页面文字不会自动变。需要代码主动响应这个变化。

开发者代码中的设置

在入口文件(如 index.js)中:

import title from './title.js';

document.body.innerText = title;

// 必须添加这段代码才能实现 HMR,否则会回退到整页刷新
if (module.hot) {
  // 注册回调:当 title.js 发生变化时执行
  module.hot.accept(['./title.js'], () => {
    // 重新获取新内容
    const newTitle = require('./title.js'); 
    // 执行具体的 DOM 更新逻辑
    document.body.innerText = newTitle; 
  });
}

冒泡机制 (Bubbling)

如果 title.js 变了,但 title.js 没有 module.hot.accept,Webpack 会怎么做?

  1. 检查自身title.jsaccept 吗?没有。
  2. 向上查找:谁引用了 title.js?是 index.js
  3. 检查父级index.js 有没有 accept('./title.js')
    • :执行 index.js 中定义的回调。更新结束。
    • 没有:继续向上查找 index.js 的父级。
  1. 顶层失败:如果一直冒泡到入口文件(Entry)都没有被 accept 捕获,HMR 宣告失败,触发 window.location.reload() 进行全量刷新。

4.3 为什么Vue/React开发时不需要手写accept?

因为 vue-loaderreact-refresh 自动在编译时注入了 module.hot.accept 代码。

例如 vue-loader 转换后的代码大致如下:

// vue-loader 自动注入的代码
import { render } from './App.vue?vue&type=template';
// ...
export default component.exports;

if (module.hot) {
  module.hot.accept(); // 接受自身更新
  module.hot.accept('./App.vue?vue&type=template', () => {
    // 当模板更新时,重新渲染组件,保留状态
    api.rerender('component-id', render); 
  });
}

image.png

五、总结 Webpack HMR 实现链

  1. 监听:Compiler 监听到文件修改。
  2. 生成:Compiler 生成 Manifest 和 Update Chunk。
  3. 通知:Server 通过 WebSocket 通知 Client "有新 Hash"。
  4. 下载:Client 通过 JSONP 下载新代码块。
  5. 替换:Client 运行时更新 __webpack_modules__ 并清除缓存。
  6. 响应:通过 module.hot.accept 定义的回调函数,执行具体的业务逻辑更新(如重绘 DOM)。

Webpack Plugin实现

一、Plugin的核心作用与Loader的区别

1.1 什么是Plugin

Plugin不处理具体的模块内容,而是监听Webpack构建过程中的生命周期事件(Hooks),在特定的时刻执行特定的逻辑,从而改变构建结果。

1.2 Plugin与Loader的直观对比

特性 Loader Plugin
作用对象 单个文件 (如 .css, .vue) 整个构建过程 (Compiler)
功能 转换代码 (less -> css) 打包优化、资源管理、环境变量注入
运行时机 解析模块依赖时 构建流程的任意时刻 (启动、编译、发射、结束)
配置方式 module.rules 数组 plugins 数组

1.3 常见的Plugin功能

  • 打包前:清除 dist 目录 (CleanWebpackPlugin)。
  • 编译中:定义全局变量 (DefinePlugin)。
  • 打包后:生成 index.html 并自动插入JS脚本 (HtmlWebpackPlugin)。
  • 结束时:压缩CSS/JS代码,上传资源到CDN。

二、Plugin的基本结构(自测:说出Plugin的固定格式)

2.1 基础代码结构

Webpack的Plugin是一个类(Class),它必须包含一个 apply 方法。

class MyPlugin {
  // 1. 接收配置参数
  constructor(options) {
    this.options = options;
  }

  // 2. 必须包含 apply 方法,接收 compiler 对象
  apply(compiler) {
    // 3. 注册钩子,监听事件 (例如 'done' 表示构建完成)
    compiler.hooks.done.tap('MyPlugin', (stats) => {
      console.log('构建完成!');
    });
  }
}

module.exports = MyPlugin;

2.2 使用方式

// webpack.config.js
const MyPlugin = require('./MyPlugin');

module.exports = {
  plugins: [
    new MyPlugin({ param: 'value' }) // 实例化插件
  ]
};

三、两个核心对象:Compiler与Compilation

在编写Plugin时,必须区分两个对象:

3.1 Compiler (编译器)

  • 定义:代表了完整的 Webpack 环境配置。
  • 生命周期:Webpack 启动时创建,直到进程结束。它是全局唯一的。
  • 作用:可以访问所有的配置信息(entry, output, loaders等),用于注册全局级别的钩子。

3.2 Compilation (编译过程)

  • 定义:代表了一次具体的构建过程
  • 生命周期:每次检测到文件变化(热更新)时,都会创建一个新的 compilation 对象。
  • 作用:包含了当前的模块资源、编译生成的文件(assets)、依赖关系图。如果要修改打包输出的内容,必须操作 compilation。

四、手写实现一个文件清单插件 (FileListPlugin)

4.1 需求描述

我们需要实现一个插件,在打包生成文件之前,自动生成一个 file-list.md 文件。 该文件记录所有打包输出的文件名和文件大小。

4.2 实现步骤

  1. 监听钩子:使用 emit 钩子。这个时刻编译已完成,文件即将输出到磁盘,但还未输出。这是修改输出资源的最后机会。
  2. 获取资源:从 compilation.assets 获取所有待输出的文件。
  3. 生成内容:遍历资源,拼接文件名和大小。
  4. 添加资源:将新生成的 file-list.md 添加到 compilation.assets 中。

4.3 代码实现

class FileListPlugin {
  constructor(options) {
    // 允许用户配置输出的文件名,默认为 'file-list.md'
    this.filename = options && options.filename ? options.filename : 'file-list.md';
  }

  apply(compiler) {
    // 1. 注册 emit 钩子(这是一个异步钩子,使用 tapAsync)
    compiler.hooks.emit.tapAsync('FileListPlugin', (compilation, callback) => {
      
      let fileList = '# Bundled Files

';

      // 2. 遍历 compilation.assets (包含所有即将输出的文件)
      for (let filename in compilation.assets) {
        // 获取文件来源对象
        const source = compilation.assets[filename];
        // 获取文件大小
        const size = source.size();
        
        fileList += `- ${filename}: ${size} bytes
`;
      }

      // 3. 将生成的内容添加到输出资源列表
      compilation.assets[this.filename] = {
        // 返回文件内容
        source: function() {
          return fileList;
        },
        // 返回文件大小
        size: function() {
          return fileList.length;
        }
      };

      // 4. 异步处理完成,必须调用 callback 告诉 Webpack 继续执行
      callback();
    });
  }
}

module.exports = FileListPlugin;

4.4 模拟运行效果

假设打包输出了 bundle.js (1000 bytes) 和 style.css (500 bytes),配置插件后,dist 目录下会多出一个 file-list.md

# Bundled Files

- bundle.js: 1000 bytes
- style.css: 500 bytes

五、常用生命周期钩子(Hooks)一览

Webpack 基于 Tapable 库实现了事件流。以下是开发 Plugin 最常用的几个钩子:

钩子名称 归属对象 时机 常用场景 同步/异步
entryOption compiler 初始化配置后 读取或修改 Entry 配置 Sync
compile compiler 开始编译前 提示“开始构建” Sync
compilation compiler 编译过程创建时 注册更细粒度的 compilation 钩子 Sync
emit compiler 生成资源到目录前 修改文件内容、添加新文件 (最常用) Async
done compiler 编译完成 提示构建结束、上传资源、分析耗时 Async

注册方式的区别:

  • 同步钩子tap('PluginName', (params) => { ... })
  • 异步钩子
    • tapAsync('PluginName', (params, callback) => { ... callback(); })
    • tapPromise('PluginName', (params) => { return Promise.resolve(); })

六、总结

Webpack Plugin 的实现核心链条:

  1. 类结构:定义一个类,包含 apply(compiler) 方法。
  2. 事件监听:通过 compiler.hooks 监听 Webpack 的生命周期事件。
  3. 资源操作
    • 如果只关注流程监控(如 build 进度),操作 compiler
    • 如果要修改产物(如添加文件、压缩代码),操作 compilation.assets
  4. 流程控制:如果是异步钩子,处理完逻辑后必须调用 callback 或返回 Promise,否则构建会卡死。

Webpack 模块联邦 (Module Federation) 实现

一、解决的具体问题

在模块联邦出现之前,跨项目共享代码主要有两种方式,各有明显的弊端:

  1. NPM 包模式

    • 流程:项目 B 修改组件 -> 打包发布到 NPM -> 项目 A 更新 package.json -> 项目 A 重新安装依赖 -> 项目 A 重新打包发布。
    • 缺点:更新流程长,无法实现热插拔,所有依赖在构建时必须确定。
  2. Iframe 或 Script 标签引入

    • 流程:项目 A 直接加载项目 B 的打包文件。
    • 缺点:完全隔离(Iframe)导致上下文不通;或者没有依赖共享机制(Script 标签),导致项目 A 和项目 B 各自加载了一份 React,页面体积倍增,且可能导致 React 实例冲突(Hooks 报错)。

模块联邦解决的问题: 在浏览器运行时,项目 A 可以直接引用 项目 B 构建好的代码,并且双方共享底层的依赖(如 React),避免重复加载。

二、基础配置与概念

模块联邦引入了三个核心概念:Host(消费者)Remote(提供者)Shared(共享依赖)

假设场景:

  • App 1 (Remote): 端口 3001,提供一个 Button 组件。
  • App 2 (Host): 端口 3002,想要使用 App 1 的 Button

2.1 提供方 (App 1) 配置

// webpack.config.js (App 1)
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  // ...其他配置
  plugins: [
    new ModuleFederationPlugin({
      name: 'app1',                  // 唯一标识,对应全局变量 window.app1
      filename: 'remoteEntry.js',    // 暴露出的入口文件名称
      exposes: {
        './Button': './src/Button',  // 映射:外部引入路径 -> 内部文件路径
      },
      shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
    }),
  ],
};

2.2 消费方 (App 2) 配置

// webpack.config.js (App 2)
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'app2',
      remotes: {
        // 键名 'app1':在代码中 import 的前缀
        // 键值 'app1@...':远程应用的 name + 远程应用的地址
        app1: 'app1@http://localhost:3001/remoteEntry.js',
      },
      shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
    }),
  ],
};

2.3 消费方代码使用

// App 2 的业务代码
import React, { Suspense } from 'react';

// 像引入本地模块一样引入远程模块
// 'app1' 对应配置中的 remotes 键名
// 'Button' 对应 App 1 exposes 的键名
const RemoteButton = React.lazy(() => import('app1/Button'));

function App() {
  return (
    <Suspense fallback="Loading...">
      <RemoteButton />
    </Suspense>
  );
}

三、核心原理:remoteEntry.js 是什么?

当 App 1 构建时,Webpack 会生成一个特殊的入口文件 remoteEntry.js。这是模块联邦通信的桥梁。

这个文件包含三个主要部分:

  1. 模块映射表 (Module Map):记录了 ./Button 对应的是哪个 chunk 文件(例如 src_Button_js.js)。
  2. 获取函数 (Get):用于根据路径加载对应的模块。
  3. 初始化函数 (Init):用于接收 Host 传递过来的共享依赖(Shared Scope)。

浏览器运行时流程:

  1. App 2 加载 http://localhost:3001/remoteEntry.js
  2. remoteEntry.js 执行,在全局 window 上挂载一个变量 app1
  3. App 2 调用 window.app1.init(),将自己(App 2)的 React 版本放入共享作用域。
  4. App 2 调用 window.app1.get('./Button')
  5. App 1 检查共享作用域,发现已有 React,便不再加载自己的 React,而是直接下载 Button 的代码并返回。

四、手写简易模块联邦实现

为了理解 Webpack 内部是如何实现的,我们模拟一下 Host 和 Remote 在浏览器端的交互逻辑。

4.1 模拟 Remote (App 1) 的 remoteEntry.js

这是一个立即执行函数,目的是在全局注册接口。

// 模拟 app1/remoteEntry.js
var app1_modules = {
  './Button': () => {
    // 实际场景这里是通过 JSONP 加载真实文件
    console.log("加载 App1 的 Button 组件");
    return {
      default: "我是来自 App1 的按钮"
    };
  }
};

// 共享作用域容器
var sharedScope = {};

// 在 window 上挂载全局对象
window.app1 = {
  // 1. get: 供 Host 获取模块
  get: function(moduleName) {
    return new Promise((resolve) => {
      if (app1_modules[moduleName]) {
        // 返回模块的工厂函数
        resolve(() => app1_modules[moduleName]());
      } else {
        resolve(null);
      }
    });
  },

  // 2. init: 供 Host 初始化共享依赖
  init: function(scope) {
    // 将 Host 传来的 scope 合并到自己的 scope 中
    sharedScope = scope;
    console.log("App1 初始化完成,已接收共享依赖", scope);
    return Promise.resolve();
  }
};

4.2 模拟 Host (App 2) 的加载逻辑

Host 需要先加载远程脚本,然后按顺序调用 initget

// 模拟 Webpack 内部加载远程模块的逻辑

// 1. 定义加载脚本的辅助函数
function loadScript(url) {
  return new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.src = url;
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
  });
}

// 2. 主流程
(async function() {
  // 步骤 A: 初始化 Host 自身的共享作用域
  const hostSharedScope = {
    react: { version: '17.0.2', loaded: true }
  };
  
  // 步骤 B: 加载 Remote 的入口文件
  await loadScript('http://localhost:3001/remoteEntry.js');
  
  // 此时 window.app1 已经存在
  const container = window.app1;
  
  // 步骤 C: 初始化容器 (交换共享依赖)
  // 告诉 app1:"我有这些依赖,你看看能不能用,别自己重复加载了"
  await container.init(hostSharedScope);
  
  // 步骤 D: 获取组件
  const factory = await container.get('./Button');
  const module = factory();
  
  console.log("最终获取到的模块:", module.default);
})();

五、依赖共享的具体逻辑 (Singleton)

shared 配置中,最关键的是版本控制。Webpack 运行时会进行如下判断:

  1. Host 端:我有 React 17.0.2。
  2. Remote 端:我需要 React ^16.8.0。
  3. 握手阶段 (init):Remote 检查 Host 提供的 React 17.0.2 是否满足 ^16.8.0
    • 满足:Remote 丢弃自己的 React 依赖,使用 Host 提供的全局 React 对象。
    • 不满足:Remote 坚持加载自己打包的 React 副本(除非配置了 singleton: truestrictVersion: true,此时会报错)。

实现简述: Webpack 维护了一个全局对象 __webpack_share_scopes__init 函数的本质就是把不同应用的依赖对象合并到这个全局对象中,通过语义化版本(SemVer)比较函数来决定使用哪一个版本的库。

六、总结模块联邦

  1. 去中心化:没有所谓的“主应用”,任何应用都可以同时是 Host 和 Remote。
  2. 运行时加载:不同于 NPM 的构建时集成,WMF 是在页面打开时动态下载代码。
  3. 双向接口
    • init(scope):输入接口,接收外部环境的共享依赖。
    • get(path):输出接口,向外部暴露内部模块。
  4. 本质:通过全局变量(window.app_name)建立通信协议,实现不同构建产物之间的互操作。

一文讲清 NestJS 中 IoC、DI、AOP、DTO、Entity 等名词

作者 Mr_li
2026年2月25日 15:18

上一篇《构建一个 NestJS 应用程序需要具备哪些基础元素?》里,我们把 Module / Controller / Provider / Guard / Pipe… 这些“组件角色”捋了一遍。

但很多人(包括我一开始)真正卡住的,其实是另一层:这些角色里面提到的名词到底表示啥?

这篇就专门把 NestJS 里常见的英文缩写/名词一次性讲清楚

先给一张速查表(用来对号入座)

缩写/名词 中文名 一句话记住 在 NestJS 里常落到哪
IoC (Inversion of Control) 控制反转 “对象怎么创建/怎么组合”交给框架 @nestjs/core 的容器/加载流程
DI (Dependency Injection) 依赖注入 “依赖不要自己 new,框架帮你注入” Provider 构造函数注入、@Inject()
AOP (Aspect-Oriented Programming) 面向切面编程 把日志/鉴权/校验这类横切逻辑“抽出去” Middleware / Guard / Pipe / Interceptor / Filter
DTO (Data Transfer Object) 数据传输对象 传输用的数据结构(尤其是入参) @Body() + DTO class + ValidationPipe
Entity 实体(实体类) 持久化模型(数据库表/集合的映射) TypeORM/Prisma/Mongoose 各自的实体/模型
ORM 对象关系映射 “对象 ↔ 表”映射(更广义:把数据库访问抽象成模型/接口) TypeORM / Prisma(Prisma 更像 ORM-like 的类型安全 DB Client)
ODM 对象文档映射 “对象 ↔ 文档”映射(Mongo) Mongoose
CRUD 增删改查 最常见的接口形态(创建/查询/更新/删除) Controller + Service + Repository
REST 表述性状态转移(常说 RESTful) 用“资源 + HTTP 方法”来组织 API Controller 的路由设计
HTTP 超文本传输协议 Web 接口最常见的传输方式 @Controller() / @Get() / @Post()
RPC 远程过程调用 更像“调用方法”,常见于服务间通信 @nestjs/microservices
RxJS 响应式扩展 响应式编程库(NestJS 部分链路会用到) Interceptor 里的 pipe(map(...))
Observable 可观察对象/流 RxJS 的核心类型(可订阅的异步流) CallHandler.handle() 返回值
JWT JSON Web Token(JSON Web 令牌) 常见的无状态 token 方案 Guard + Passport Strategy
RBAC 基于角色的访问控制 “角色 → 权限”的经典权限模型 Guard(配合装饰器/元数据)
CQRS 命令查询职责分离 复杂业务下的读写分离组织方式 @nestjs/cqrs(可选,不是必需品)
CLI 命令行接口(脚手架) 生成模板代码、统一项目结构 @nestjs/cli
CORS 跨域资源共享 浏览器跨域访问控制 app.enableCors()
CSRF 跨站请求伪造 利用 Cookie 自动携带发起伪造请求 结合鉴权方式 + 中间件/策略设计

IoC:Inversion of Control(控制反转)

是什么

在软件工程里,IoC 指把“控制权”(对象创建、生命周期管理、依赖装配、调用时机等)从业务代码转移给框架/容器来统一管理。
它被提出的目的,是降低耦合集中扩展点、让项目变大后依赖关系仍然可控(而不是到处 new、到处传参)。

一句话:对象的创建与组装不由你手写流程控制,而由框架在启动时统一完成。

适合干啥

  • 让项目变大后还能“有秩序地装配依赖”(否则满世界 import/new/传参)
  • 让模块边界更清晰:你用“声明”代替“到处调用”

何时使用

在 NestJS 里你基本“自动就在用 IoC”了,因为 NestJS 本身就是基于容器的框架。你需要做的更多是:别破坏它(比如绕过容器到处 new)。

原理(抓重点就行)

  • 启动时,NestJS 会扫描模块(Module)、收集 Provider、构建依赖图(谁依赖谁)
  • 需要实例化某个类时,由容器按依赖图“自底向上”创建并缓存(默认单例)

伪代码示例

// 你只声明“我需要什么”
@Injectable()
class UserService {
  constructor(private repo: UserRepo) {}
}

// 你只声明“这个模块包含哪些”
@Module({ providers: [UserRepo, UserService] })
class UserModule {}

// 剩下的“怎么创建 repo 再创建 service”,框架搞定(IoC)

DI:Dependency Injection(依赖注入)

是什么

DI 是 IoC 的一种常见实现方式,它强调“依赖从外部注入”,而不是在类内部主动创建依赖。
目的很直接:让代码更解耦、依赖更可替换(方便测试/替身实现)、也更容易在大型项目里统一管理。

一句话:把“依赖关系”从代码里的 new,变成声明式注入。

适合干啥

  • 让 service 更容易测试(可以替换依赖、注入 mock)
  • 避免强耦合(service 不需要知道 repo 如何创建)

何时使用

你写 NestJS 业务时,绝大多数依赖都推荐走 DI:

  • service 依赖 repo / http client / config / logger
  • guard 依赖 auth service
  • interceptor 依赖 cache service

原理

DI 的核心是“token → provider”的映射:

  • 最常见 token 就是 class 本身(UserService
  • 容器根据构造函数参数的类型/注入 token,找到对应 provider,注入实例

伪代码示例

@Injectable()
class UserService {
  constructor(private readonly repo: UserRepo) {}
}

// 自定义 token(比如注入第三方库实例)
const REDIS = Symbol('REDIS');

@Module({
  providers: [
    { provide: REDIS, useValue: /* redisClient */ {} },
    { provide: UserRepo, useClass: UserRepo },
  ],
})
class InfraModule {}

@Injectable()
class CacheService {
  constructor(@Inject(REDIS) private redis: any) {}
}

AOP:Aspect-Oriented Programming(面向切面)

是什么

AOP 是一种把“横切关注点”(logging、auth、validation、metrics 等)从核心业务逻辑中分离出来的思想,常见手段是拦截/代理,在函数执行前后“织入”通用逻辑。
提出它的目的,是减少重复代码、统一策略,让业务代码更专注在“做业务”。

一句话:把“到处都要做”的横切逻辑(日志/鉴权/校验/统一返回)从业务代码里抽出来。

适合干啥

  • 统一做日志、耗时统计、异常格式、权限校验
  • 让 controller/service 更“干净”,专注业务

何时使用(别上来就 AOP 过度)

当你发现同一段逻辑在 N 个接口里重复出现时,AOP 才开始值钱。
如果只是一个接口的特殊处理,直接写在 handler 里往往更直观。

原理(用 NestJS 的话来讲)

NestJS 不是“只有一种 AOP”,它把 AOP 拆成几类工具,各司其职:

  • Middleware:请求刚进门(路由前)
  • Guard:能不能进(鉴权/权限)
  • Pipe:入参校验/转换
  • Interceptor:前后包一层(统一返回/缓存/耗时)
  • Exception Filter:统一错误输出

它们共同点是:不改业务函数签名,也能在请求链路上插入逻辑。

伪代码示例

// 统一返回结构:{ code, data, traceId }
@Injectable()
class WrapResponseInterceptor implements NestInterceptor {
  intercept(ctx, next) {
    const traceId = /* get from request */ 'xxx';
    return next.handle().pipe(map(data => ({ code: 0, data, traceId })));
  }
}

// 鉴权:没 token 就不让进
@Injectable()
class AuthGuard implements CanActivate {
  canActivate(ctx) {
    const req = ctx.switchToHttp().getRequest();
    return Boolean(req.headers.authorization);
  }
}

DTO:Data Transfer Object(数据传输对象)

是什么

DTO 常用于分层架构/分布式系统的“边界处”,用来描述数据在层与层(或服务与服务)之间传输的结构。它强调“只承载数据”,不强调业务行为。
提出它的目的,是把外部输入/输出与内部模型隔离开:API 契约清晰不把内部 Entity/领域对象直接暴露出去

一句话:专门用来“接收/传输”的数据结构(尤其是“请求入参”)。

适合干啥

  • 把“接口入参长啥样”讲清楚(更容易维护)
  • 配合校验:让脏数据在进业务之前就被拦住

何时使用

只要是对外接口(HTTP/RPC/GraphQL)基本都建议用 DTO:

  • CreateUserDtoUpdateUserDtoQueryUserDto

原理(别纠结细节,抓住链路)

典型链路是:@Body() 拿到原始对象 → ValidationPipe 校验/转换 → 传入 controller 方法参数。
常见组合是 class-validator + class-transformer(你会在项目里看到它们)。

伪代码示例

class CreateUserDto {
  // @IsEmail()
  email: string;

  // @MinLength(8)
  password: string;
}

@Controller('users')
class UserController {
  @Post()
  create(@Body() dto: CreateUserDto) {
    return this.userService.create(dto);
  }
}

// main.ts 里常见开启方式
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));

Entity:实体(通常指持久化实体)

是什么

在不同语境里 Entity 有两层含义:

  • 在 DDD 里,Entity 是“有持续身份(identity)的对象”,即便属性变了,它仍然是同一个实体
  • 在 ORM 里,Entity 往往指“表/集合的映射模型”(实体类/模型定义)
    提出它的目的,是用更结构化的方式表达持久化数据(字段/关系/约束),让数据访问与演进更可维护。

一句话:“数据库里的数据结构”在代码里的对应物
注意:Entity 本身不是 NestJS 独有,它更多来自你选的 ORM/ODM。

适合干啥

  • 在数据库层做结构化建模(表字段、索引、关系)
  • 让查询/写入更可维护(至少比字符串拼 SQL 好维护一些)

何时使用

取决于你选型:

  • 用 TypeORM:你会写 @Entity() 这类实体类
  • 用 Prisma:你写的是 schema.prisma 的模型(更像“模型定义 + 类型安全访问层”,不一定叫 Entity,但在项目里的角色类似)
  • 用 Mongoose:你会写 Schema/Model(更偏 ODM)

原理(大方向)

Entity/Model 提供“结构 + 映射 + 生命周期(可选)”,最终目标是:把 DB 操作封装成更可控的接口。

伪代码示例(以 TypeORM 思路举例)

@Entity('users')
class UserEntity {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  email: string;
}

@Injectable()
class UserRepo {
  constructor(/* inject repository */) {}
}

ORM(对象关系映射)/ ODM(对象文档映射):到底差在哪?

ORM(Object-Relational Mapping,对象关系映射)

ORM 通过把“关系型数据库的表/行/关系”映射为代码里的对象/模型,让你用更高层的接口去读写数据。
提出它的目的,是减少手写 SQL 的重复劳动,让数据访问更结构化、更易演进(当然也会带来一定抽象成本)。

  • 面向关系型数据库(MySQL/PostgreSQL…)
  • 关键词:表、行、关联、事务

ODM(Object-Document Mapping,对象文档映射)

ODM 把“文档数据库的文档/集合/嵌套结构”映射为代码里的对象/模型,帮助你更一致地读写 MongoDB 等文档型存储。
目的类似 ORM:让数据结构和访问方式更可控,只是底层存储模型不同。

  • 面向文档型数据库(MongoDB…)
  • 关键词:文档、集合、嵌套结构

何时用哪一个

别纠结名词,按存储选型走:你用 MySQL/PG 就看 ORM,你用 Mongo 就看 ODM。


CRUD(增删改查)/ REST(表述性状态转移):为什么老看到它俩一起出现?

CRUD 是什么

CRUD 是持久化系统里最常见的一组基础操作集合(Create / Read / Update / Delete)。
提出它的目的,是用一套通用词汇把“增删改查”这类需求说清楚,方便分工、设计接口与评审。

一句话:Create / Read / Update / Delete,增删改查。

REST 是什么

REST(Representational State Transfer)最初是 Roy Fielding 在博士论文里提出的一种架构风格,用“资源 + 表述 + 约束”来组织系统交互。
它的目的,是让 API 的语义更统一、可被 HTTP 的缓存/状态码等机制更好地利用。

一句话:用“资源 + HTTP 方法”来组织 API(比如 /users/:id + GET/POST/PUT/DELETE)。

在 NestJS 里怎么体现

CRUD 更像“你要做的事”,REST 更像“你怎么设计接口”。
NestJS 的 Controller 很适合写 REST 风格的 CRUD(但你也可以写 RPC 风格,不强制)。

@Controller('users')
class UserController {
  @Post() create(@Body() dto) {}
  @Get(':id') findOne(@Param('id') id) {}
  @Put(':id') update(@Param('id') id, @Body() dto) {}
  @Delete(':id') remove(@Param('id') id) {}
}

JWT(JSON Web Token)/ RBAC(基于角色的访问控制):鉴权和权限常见两套缩写

JWT(JSON Web Token,JSON Web 令牌)

JWT 是一种开放标准(RFC 7519)定义的令牌格式,用 JSON 结构承载声明(claims),常见形式是 header.payload.signature
它被广泛使用的目的,是让身份信息在服务之间可传递、可验证(尤其适合前后端分离/多服务场景)。

一句话:一种无状态 token(常用来承载登录态/身份声明)。

什么时候用:前后端分离、移动端、需要跨服务验证身份时很常见。
什么时候别硬上:你如果只是内部小系统、单体应用,Session 也完全可以。

伪代码(NestJS 常见落点):

// 认证:验证 token 的逻辑一般放 Guard / Strategy
@UseGuards(JwtAuthGuard)
@Get('me')
me(@Req() req) {
  return req.user;
}

RBAC(Role-Based Access Control,基于角色的访问控制)

RBAC 是经典的访问控制模型,用“角色(Role)”作为权限分配与授权的中间层(用户 ↔ 角色 ↔ 权限)。
目的,是让权限管理更可维护:你通常给用户分配角色,而不是给每个用户逐条配置权限。

一句话:按角色控制权限(admin/user…)。

落点通常是 Guard + 装饰器元数据:

@SetMetadata('roles', ['admin'])
@UseGuards(AuthGuard, RolesGuard)
@Delete(':id')
remove() {}

CQRS:Command Query Responsibility Segregation(命令查询职责分离,常被简化成“读写分离”)

是什么

CQRS 提倡把“改变系统状态的命令(Command)”与“读取系统状态的查询(Query)”分离成不同的模型/路径。
提出它的目的,是在复杂业务里隔离读写关注点:写侧强调规则与一致性,读侧强调展示与查询效率(但也会增加架构复杂度)。

一句话:把“写操作(Command)”和“读操作(Query)”拆成不同模型/流程

适合干啥

  • 复杂业务、读写模型差异巨大时,能让代码更清晰
  • 事件驱动/审计需求强时更常见

何时使用

别上来就 CQRS。通常是项目复杂到:

  • service 越写越像一坨“超级函数”
  • 写操作需要强规则/强审计
  • 读操作需要高度定制的视图模型 再考虑引入(NestJS 有 @nestjs/cqrs 可选)。

HTTP(超文本传输协议)/ RPC(远程过程调用):请求进来到底是哪条路?

是什么

HTTP 是应用层网络协议,用于客户端与服务端之间的请求/响应通信;RPC 是一种远程调用模型,强调“像调用本地函数一样调用远端能力”。
把它们放在一起讲的目的,是让你明确 NestJS 不只做 HTTP:它既能做 Web API,也能做微服务通信。

  • HTTP:最常见的 Web 接口方式(REST/JSON 都算在这里面)
  • RPC:更像“调用一个方法”,不强制资源风格(常见于微服务通信)

适合干啥

  • 你做 BFF / Web API:基本就是 HTTP
  • 你做服务拆分、服务间通信:RPC(或者消息队列)会更常见

何时使用(说人话)

小中型系统,先把 HTTP 写顺就行;
只有当你确实遇到“服务间调用多、边界清晰、协议要统一”时,再去考虑 RPC/Microservices 那套。

原理(在 NestJS 里怎么体现)

  • HTTP:@Controller() + @Get()/@Post() 这一套装饰器,走的是平台适配层(Express/Fastify)
  • RPC:通常用 @nestjs/microservices,通过 transport(TCP/Redis/NATS/Kafka…)收发消息

伪代码示例

// HTTP(你已经很熟了)
@Controller('users')
class UserController {
  @Get(':id')
  findOne(@Param('id') id: string) {}
}

// RPC(示意):message pattern 触发一个 handler
// @MessagePattern({ cmd: 'user.findOne' })
// findOne(payload) {}

RxJS(响应式扩展)/ Observable(可观察对象):为啥 NestJS 老出现 rxjs?

是什么

RxJS 是基于 Observable 的响应式编程库,用来表达“时间维度上的数据流”;Observable 则是可订阅的异步流抽象。
它们出现的目的,是更自然地处理“流式、可组合、可取消/重试”的异步场景(NestJS 的部分执行链路也选择了它作为抽象)。

  • RxJS:响应式编程库
  • Observable:RxJS 的核心数据类型(可以理解成“可订阅的异步流”)

适合干啥

你不用为了 NestJS 去“强行学响应式编程”。它主要在两块很常见:

  • 拦截器/管道链路next.handle().pipe(map(...)) 这种写法(你在 Interceptor 里已经见过)
  • 流式/事件式场景:SSE、WebSocket、某些需要持续推送的接口

何时使用

  • 你只写普通 CRUD:Promise/async-await 够用,别硬上 Observable
  • 你需要“组合多个异步来源”“做流式处理/取消/重试”:Observable 就很香

原理(够用版)

NestJS 允许 controller 返回:

  • primitive / object(直接返回)
  • Promise<T>(等 promise resolve)
  • Observable<T>(内部订阅,取最终值/流)

Interceptor 里之所以经常用 RxJS,是因为 CallHandler.handle() 返回的就是 Observable。

伪代码示例

// 典型 interceptor:对返回值做 map
intercept(ctx, next) {
  return next.handle().pipe(
    map(data => ({ code: 0, data })),
  );
}

// controller 返回 Observable(示意)
@Get()
list() {
  // return from([1, 2, 3]);
}

CLI:Command Line Interface(命令行接口/脚手架)

是什么

CLI(Command Line Interface)是通过命令行与工具交互的一种方式;在 NestJS 语境下通常特指官方脚手架,用于生成模板代码与维护约定结构。
它的目的,是减少重复劳动、统一项目结构,让团队协作更顺。

一句话:帮你生成模板代码、少手敲一些重复文件

适合干啥

  • 新建 module/controller/service
  • 一键生成资源(CRUD 模板),并把文件结构按约定摆好

何时使用

你如果是团队项目,建议统一用 CLI 生成骨架,代码风格更一致;
个人练手也可以不用,但熟悉一下常用命令挺省事。

原理(不深究)

CLI 本质是代码生成器(schematics),按模板生成文件 + 更新模块引用。

伪代码示例(命令示意)

nest g module user
nest g controller user
nest g service user
# nest g resource user  # 想要 CRUD 模板时再用

CORS(跨域资源共享)/ CSRF(跨站请求伪造):Web 安全里最常被混淆的两个缩写

它们是什么

CORS 是浏览器同源策略下的一套“跨域放行机制”(通过响应头协商);CSRF 是一种利用浏览器自动携带 Cookie 的攻击方式。
把它俩放一起的目的,是提醒你:跨域(CORS)和伪造请求(CSRF)是两件事,不要混着处理。

  • CORS(Cross-Origin Resource Sharing):浏览器的跨域访问控制(“能不能从别的域来调用我”)
  • CSRF(Cross-Site Request Forgery):利用浏览器自动带 Cookie 的特性发起伪造请求(“我是不是被借刀了”)

适合干啥

  • CORS:前后端分离、不同域名端口开发时必须处理
  • CSRF:当你用 Cookie 维持登录态 且接口会产生副作用(转账/下单/改资料)时,需要重点考虑

何时使用(简化判断)

  • 你用 Authorization: Bearer <token>(JWT)这类 header 携带 token:CSRF 风险通常更低(但不代表“完全不用管安全”)
  • 你用 Cookie + Session:CSRF 基本要纳入设计(同站策略、token、双重提交等)

原理(够用版)

  • CORS:是浏览器限制,你服务器得返回合适的响应头
  • CSRF:是攻击方式,你得让“跨站伪造请求”失效

伪代码示例

// CORS:NestJS 常见开启方式(示意)
app.enableCors({
  origin: ['https://your-frontend.example'],
  credentials: true,
});

最后

如果你现在只想快速上手 NestJS,我建议按这个顺序消化:

  1. 先把 IoC / DI 吃透(这决定你后面写代码是不是舒服)
  2. 再把 AOP 在 NestJS 的落点(Guard/Pipe/Interceptor/Filter)对上请求链路
  3. 然后用 DTO + ValidationPipe 把“入参质量”先稳住
  4. 至于 Entity/ORM/ODM、JWT/RBAC、CQRS,就按项目需要逐步加,不用一次性把工具箱搬回家

以上是我学习 NestJS 过程中的一些整理与理解,欢迎在评论区补充/讨论;如果哪里有偏差,也欢迎直接指出,我会及时修正。

前端知识体系总结-前端工程化(Vite篇)

2026年2月25日 15:16

实现 Vite 核心功能(自测:Vite 核心功能和运行原理有哪些,由最简讲起,具体是怎么实现的)

Webpack 是先打包好文件再放到 dev server 运行

而 Vite 是先运行 dev server ,之后浏览器请求什么文件就在 dev server 中动态编译后再返回。核心是基于浏览器原生支持的 ES Modules (<script type="module">),当浏览器解析到 import 语句时,会向服务器发送 HTTP 请求,服务器拦截这些请求并实时编译文件与响应。

一、搭建服务返回index.html与编译js文件

1.1 搭建基础开发服务器

我们需要一个能拦截请求的 HTTP 服务器。这里使用 Koa (Vite 内部使用 connect,逻辑类似)。

目录结构:

mini-vite/
  ├── src/
  │   ├── main.js
  │   └── App.vue
  ├── index.html
  ├── server.js  (我们将编写的代码)
  └── package.json

index.html: 关键在于 type="module",这告诉浏览器直接以 ES 模块方式加载 js。

<!DOCTYPE html>
<html lang="en">
<body>
  <div id="app"></div>
  <!-- 浏览器会发起 GET /src/main.js 请求 -->
  <script type="module" src="/src/main.js"></script>
</body>
</html>

server.js (第一步:静态文件服务): 浏览器请求 / 返回 HTML,请求 /src/main.js 返回 JS 内容。

const Koa = require('koa');
const fs = require('fs');
const path = require('path');

const app = new Koa();

app.use(async (ctx) => {
  const url = ctx.request.url;
  
  // 1. 根路径返回 index.html
  if (url === '/') {
    ctx.type = 'text/html';
    ctx.body = fs.readFileSync('./index.html', 'utf-8');
    return;
  }
  
  // 2. JS文件请求处理 (如 /src/main.js)
  if (url.endsWith('.js')) {
    const p = path.join(__dirname, url);
    ctx.type = 'application/javascript';
    ctx.body = fs.readFileSync(p, 'utf-8');
    return;
  }
});

app.listen(3000, () => {
  console.log('Vite dev server running at http://localhost:3000');
});

二、实现第三方库导入处理

2.1 问题描述

src/main.js 中,我们通常这样写:

import { createApp } from 'vue'; // ❌ 浏览器报错
import App from './App.vue';

浏览器遇到 import ... from 'vue' 时会报错,因为它不知道 'vue' 在哪里。浏览器只认识相对路径 (./, ../) 或绝对路径 (/)。

2.2 解决方案:路径重写

服务器需要在返回 JS 文件内容给浏览器之前,把内容里的 'vue' 替换成 '/@modules/vue',给它一个特殊标识。

修改 server.js:

// 工具函数:把文件流转成字符串
function readStream(stream) {
  return new Promise((resolve, reject) => {
    let data = '';
    stream.on('data', chunk => data += chunk);
    stream.on('end', () => resolve(data));
  });
}

// 路径重写逻辑
function rewriteImport(content) {
  // 正则匹配: from 'vue' -> from '/@modules/vue'
  // s0: 匹配到的完整字符串
  // s1: 捕获组,即包名 'vue'
  return content.replace(/ from ['"](.*)['"]/g, (s0, s1) => {
    // 如果是相对路径 ./ 或 ../ 或 / 开头,不处理
    if (s1.startsWith('.') || s1.startsWith('/')) {
      return s0;
    }
    // 否则加上 /@modules/ 前缀
    return ` from '/@modules/${s1}'`;
  });
}

app.use(async (ctx) => {
  const url = ctx.request.url;

  if (url.endsWith('.js')) {
    const p = path.join(__dirname, url);
    const content = fs.readFileSync(p, 'utf-8');
    ctx.type = 'application/javascript';
    // 返回修改后的内容
    ctx.body = rewriteImport(content); 
    return;
  }
});

经过这一步,浏览器收到的代码变成了:

import { createApp } from '/@modules/vue'; // ✅ 浏览器会发起新请求
import App from './App.vue';

2.3 获取真实文件路径

当浏览器请求 http://localhost:3000/@modules/vue 时,服务器需要去 node_modules 里找到 vue 的入口文件。

查找步骤:

  1. 找到 node_modules/vue 文件夹。
  2. 读取 package.jsonmodule 字段 (ESM 入口) 或 main 字段。
  3. 读取该入口文件的内容返回。

2.4 server.js 新增逻辑

app.use(async (ctx) => {
  const url = ctx.request.url;

  // 3. 处理第三方模块请求
  if (url.startsWith('/@modules/')) {
    // 提取模块名,例如 'vue'
    const moduleName = url.replace('/@modules/', '');
    
    // 在 node_modules 中找到该模块文件夹
    const prefix = path.join(__dirname, './node_modules', moduleName);
    
    // 读取 package.json
    const packageJSON = require(path.join(prefix, 'package.json'));
    
    // 获取入口文件路径 (优先使用 module 字段,因为是 ESM)
    const entryPath = path.join(prefix, packageJSON.module);
    
    // 读取文件内容
    const content = fs.readFileSync(entryPath, 'utf-8');
    
    ctx.type = 'application/javascript';
    // 第三方库内部可能也引用了其他库,也需要重写路径
    ctx.body = rewriteImport(content);
    return;
  }
  
  // ... 其他逻辑
});

三、处理 .vue 单文件组件 (SFC)

3.1 浏览器不认识 .vue

浏览器请求 App.vue 时,服务器不能直接返回 Vue 源码,需要把 .vue 编译成 JS。

Vite 使用 vue 官方提供的 @vue/compiler-sfc 进行编译。

3.2 server.js 新增 Vue 处理逻辑

const compilerSfc = require('@vue/compiler-sfc');

app.use(async (ctx) => {
  const url = ctx.request.url;
  
  // 4. 处理 .vue 文件
 if (ctx.url.endsWith(".vue")) {
ctx.type = "application/javascript; utf-8";
const content = fs.readFileSync(path.join(__dirname, ctx.url), "utf-8");
const { descriptor } = compilerSfc.parse(content);

// 使用 inlineTemplate 选项,让 compileScript 直接生成包含 render 的完整组件
const compiled = compilerSfc.compileScript(descriptor, {
id: ctx.url,
inlineTemplate: true, // 关键:内联编译模板,setup 直接返回 render 函数
});

ctx.body = rewriteImport(compiled.content);
return;
}
});

四、Vite 核心功能总结

实现一个简易 Vite 只需要解决三个问题:

  1. 服务器:用 Koa 拦截浏览器发起的文件获取 HTTP 请求并实时编译与返回。
  2. JS 处理:遇到 import 'vue' 这种裸模块导入,重写路径为 /@modules/vue,并去 node_modules 里找文件返回。
  3. Vue 处理:遇到 .vue 文件,使用 compiler-sfc 编译。先把 Script 发给浏览器,再让浏览器回头取 Template 的编译结果,最后拼在一起。

这种模式下,开发环境启动速度与项目大小无关,因为只有当你点击了某个页面,浏览器发起了请求,服务器才开始编译那个页面用到的文件。

Vite HMR实现原理(自测:更新一个文件后,wbp和vite分别会经过什么流程进行网页的热更新)

一、先回顾 Webpack 热更新原理

假如你的项目有1000个JS模块,你修改了其中一个文件 src/components/Header.vue

Webpack的处理方式

  1. Wepack Compiler 监听工作区:Webpack监听到文件保存动作。
  2. 重新构建被修改的模块:loader 链转换文件为 JS 可执行代码 -> AST 解析代码并识别 import、export 代码进行依赖图的增加或删除 -> 对新发现的依赖进行递归处理
  3. 打包:生成 Manifest JSON 文件,告诉浏览器这次更新涉及哪些模块;生成 Update Chunk JS 文件,包含被修改那个模块的新代码。
  4. 推送:HMR Server通过WebSocket推送更新通知给浏览器。
  5. 替换:浏览器的 HMR Runtime 请求清单文件,并根据清单文件请求被更新的模块代码,接着找到模块是否有自己的 module.hot.accept ,否则冒泡沿着依赖图向上查找,在 accept 回调中 import 并执行新的JS代码进行视图的更新。

三、Vite HMR 具体工作流程

1. 建立连接

客户端(浏览器)连接 Vite 开发服务器的 WebSocket。

2. 文件修改与通知

当你保存 Header.vue 时:

  1. Vite 文件监听器检测到变化。
  2. 解析该文件导出内容,确定它是Vue组件。
  3. 通过 WebSocket 向客户端发送一段JSON消息。

消息内容示例:

{
  "type": "update",
  "updates": [
    {
      "type": "js-update",
      "timestamp": 1678888888,
      "path": "/src/components/Header.vue",
      "acceptedPath": "/src/components/Header.vue"
    }
  ]
}

3. 浏览器重新请求

Vite 在浏览器端注入的客户端代码(vite/client)收到消息。它不会像 Webpack 那样去执行一段新推过来的 JS 代码块,而是利用浏览器动态导入功能

具体操作: 浏览器构造一个新的 import URL,带上时间戳以强制让浏览器认为这是一个新文件,从而避开缓存。

// 浏览器端逻辑模拟
import('/src/components/Header.vue?t=1678888888')
  .then((newModule) => {
    // 获取到新的模块内容,进行替换
  });

4. 模块替换

对于Vue组件,Vite使用了 vue-loader 类似的逻辑(vite-plugin-vue)。

  • 旧的 Header.vue 组件实例还保留在内存中。
  • 新的模块加载后,框架(Vue/React)利用 HMR API 重新渲染该组件,保留组件内的 data/state 状态,仅更新 render 函数或样式。

四、Vite HMR API:import.meta.hot

Webpack使用 module.hot,而 Vite 使用 ESM 标准的 import.meta.hot

开发者的代码(通常由插件自动注入):

// src/components/Header.vue 编译后的JS代码
// ... 组件代码 ...

export default _sfc_main;

// HMR 逻辑
if (import.meta.hot) {
  // 接受自身更新
  import.meta.hot.accept((newModule) => {
    if (newModule) {
      // 执行组件重渲染逻辑
      __VUE_HMR_RUNTIME__.reload('组件HashID', newModule.default);
    }
  });
}

实现逻辑:

  1. import.meta.hot.accept:告诉 Vite,如果这个文件变了,不需要刷新页面,我自己能处理。
  2. 回调函数:当新文件被 import(...) 加载成功后,执行这个回调,传入新模块内容。

五、所以为什么 Vite HMR 速度快

  1. 无需重构依赖图:文件保存后无需重新分析依赖图的更改,本质是因为 Vite 不需要构建依赖图去生成 bundle,而是通过浏览器 ESM 能力提供所需文件即可。
  2. 无需打包:Vite 只需编译一次文件,而 Webpack 需要将受影响的模块及其相关依赖(修改模块本身、父节点可能更新对子模块的Module ID引用代码、所属Chunk)重新打包与合并,涉及 n 个文件的修改。
  3. 全量代码下发:Webpack 下发包含新代码的 HMR 更新包,而 Vite 只发送一个指向修改该文件的 HTTP 请求,由浏览器重新请求。

Vite Plugin 实现原理与实战(自测:实现一个vite-plugin-svg-icons)

在前文中,我们了解了 Webpack 的打包流程:读取入口 -> 分析 AST -> 递归依赖 -> 转换代码 -> 生成 Bundle

Vite 的工作方式完全不同。在开发环境下,Vite 不打包。它利用浏览器对 ES Modules 的原生支持。当浏览器发起请求(如 GET /src/main.js)时,Vite 服务器拦截请求,进行必要的代码转换,然后直接返回 JS 内容。

Vite 插件 就是用来拦截处理这些请求的工具。

Vite 插件基于 Rollup 的插件接口设计,同时扩展了一些 Vite 独有的钩子(Hooks)。

一、Vite 插件的核心钩子 (Hooks)

Webpack 将功能分为 Loader(转换文件)和 Plugin(监听构建生命周期)。Vite 将这两者合并了。一个 Vite 插件本质上是一个返回配置对象的函数

处理一个文件请求时,主要经过以下三个核心钩子:

  1. resolveId(source, importer): 找文件
    • 输入: 代码中的导入路径(如 import x from './a' 中的 './a')。
    • 作用: 告诉 Vite 这个文件的绝对路径在哪里,或者标记这是一个“虚拟模块”。
    • 返回: 文件的绝对路径或 ID。
  2. load(id): 读文件
    • 输入: resolveId 返回的绝对路径或 ID。
    • 作用: 读取文件内容。通常用于加载磁盘文件或生成虚拟文件内容。
    • 返回: 文件内容的字符串。
  3. transform(code, id): 改代码(相当于 Webpack Loader)
    • 输入: load 返回的代码字符串,以及文件 ID。
    • 作用: 将非 JS 代码(如 Vue, CSS, TS)转换为浏览器能识别的 JS 代码。
    • 返回: 转换后的 JS 代码。

二、实战:实现一个虚拟模块插件

场景:你需要在一个项目中引入一个并不存在于磁盘上的文件,比如构建时的环境变量信息。

目标代码

// main.js
import env from 'virtual:env'; // 这个文件在磁盘上不存在
console.log(env); 

插件实现

export default function myVirtualPlugin() {
  const virtualModuleId = 'virtual:env';
  const resolvedVirtualModuleId = '\0' + virtualModuleId; // \0 是 Rollup 的约定,表示这是一个虚拟模块,不要去磁盘找

  return {
    name: 'my-virtual-plugin', // 插件名称,必填

    // 1. 拦截 import
    resolveId(source) {
      if (source === virtualModuleId) {
        // 如果 import 的是 'virtual:env',返回我们自定义的 ID
        return resolvedVirtualModuleId;
      }
      return null; // 其他文件不管,交给 Vite 处理
    },

    // 2. 加载内容
    load(id) {
      if (id === resolvedVirtualModuleId) {
        // 匹配到自定义 ID,直接返回一段 JS 代码
        return `export default { 
            user: "admin", 
            buildTime: "${new Date().toISOString()}" 
        }`;
      }
      return null; // 其他文件不管,读取磁盘
    }
  };
}

配置 vite.config.js:

import myVirtualPlugin from './plugins/myVirtualPlugin';

export default {
  plugins: [myVirtualPlugin()]
};

三、实战:实现一个 vite-plugin-svg-icons

首先明确插件功能:扫描指定目录下的 SVG 文件 -> 转换<symbol> 标签并合并 -> 提供虚拟模块 virtual:svg-register import 注入页面 -> 支持 HMR 热更新。

为了更好理解插件功能,我们看看在实际场景中它的作用:

你正在开发一个企业级后台管理系统,设计师提供了一套自定义 SVG 图标(如 nav-order.svg, action-edit.svg),要求图标颜色能随文字颜色变化(如菜单 Hover 时变蓝),且会有数十个图标散落在各个页面。

使用img标签,第一个是无法改变颜色需要重新提供另一版本svg,并且还需要根据hover事件动态切换src,非常麻烦;使用内联svg代码,代码很臃肿,可读性差;使用手动import,若一个页面需要的svg很多,会产生大量import语句

我们的插件目标:

  1. 零配置引用:只需将 SVG 文件丢入 src/icons 文件夹,无需任何 import 语句,直接通过文件名即可使用。
  2. CSS 样式控制:插件生成的 SVG Sprite 支持 currentColor,图标就像文字一样,可以用 CSS 随意控制颜色大小
  3. 高性能:所有图标被合并成一段 JS 注入 HTML,零 HTTP 请求,且按需加载。

使用效果演示:

// main.ts
import 'virtual:svg-register' // 一行代码,所有图标自动打包注入
<!-- 无需 import,直接使用 -->
<svg class="icon" aria-hidden="true">
  <use xlink:href="#icon-nav-order" />
</svg>

<style>
.icon {
  color: grey;       /* 默认灰色 */
  font-size: 20px;   /* 控制大小 */
}
.icon:hover {
  color: blue;       /* 悬停自动变蓝,无需 JS */
}
</style>

可以封装为一个组件:

<!-- src/components/SvgIcon.vue -->
<template>
  <svg class="svg-icon" aria-hidden="true">
    <use :xlink:href="symbolId" />
  </svg>
</template>

<script setup lang="ts">
import { computed } from 'vue'

const props = defineProps({
  name: { type: String, required: true }, // 传入图标文件名,如 'truck'
  prefix: { type: String, default: 'icon' }
})

const symbolId = computed(() => `#${props.prefix}-${props.name}`)
</script>

<style scoped>
.svg-icon {
  width: 1em; height: 1em; /* 默认跟随字体大小 */
  vertical-align: -0.15em;
  fill: currentColor; /* 关键:让图标颜色跟随文字颜色 */
  overflow: hidden;
}
</style>

现在我们来实现这个插件功能

首先理解“虚拟模块”

你可以在浏览器端 import 一个不存在于文件系统中的文件

目标:用户在代码里写 import 'virtual:svg-register',插件可以正确识别和拦截。

具体实现:使用 resolvedId 属性进行配置,当文件路径是我们的虚拟模块时,直接返回不需要解析,并且在 load 阶段返回我们自定义的代码交给程序执行

第二步:实战代码编写

新建一个 my-svg-plugin.js

我们可以安装一个依赖来方便找文件:npm install fast-glob

// my-svg-plugin.js
import path from 'path'
import fs from 'fs'
import fg from 'fast-glob'

export default function mySvgPlugin(options) {
  // 1. 配置虚拟模块 ID
  const VIRTUAL_MODULE_ID = 'virtual:svg-register'
  const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID

  return {
    name: 'vite-plugin-my-svg-sprite', // 插件名称

    // 2. resolveId: 告诉 Vite 这个 import 归我管
    resolveId(id) {
      if (id === VIRTUAL_MODULE_ID) {
        return RESOLVED_VIRTUAL_MODULE_ID
      }
    },

    // 3. load: 返回这个虚拟模块的具体代码
    async load(id) {
      if (id === RESOLVED_VIRTUAL_MODULE_ID) {
        
        // --- 核心逻辑开始 ---
        
        // A. 找到所有 SVG 文件
        const { iconDir } = options
        const svgFiles = await fg('**/*.svg', { cwd: iconDir, absolute: true })

        // B. 遍历并读取内容,拼接成 Symbol 字符串
        let symbols = ''
        
        svgFiles.forEach((file) => {
         if (file.endsWith(".svg")) {
 const content = fs.readFileSync(path.join(iconDir, file), "utf-8");
 const viewBox = content.match(/viewBox="([^"]+)"/)?.[1] || "0 0 24 24";
 const pathContent = content.match(/<svg[^>]*>(.*)<\/svg>/s)?.[1] || "";
 const iconName = file.replace(".svg", "");

 symbols += `<symbol id="icon-${iconName}" viewBox="${viewBox}">${pathContent}</symbol>`;
}
});

        // C. 构造最终的 JS 代码
        // 返回在页面中注入 SVG sprite 的代码
return `
                const svgSprite = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
     svgSprite.style.position = 'absolute';
     svgSprite.style.width = '0';
     svgSprite.style.height = '0';
     svgSprite.innerHTML = \`${symbols}\`;
     document.body.insertBefore(svgSprite, document.body.firstChild);
`;
        // --- 核心逻辑结束 ---
      }
    }
  }
}
第三步:在项目中使用 (验证效果)
  1. 配置 vite.config.js:

    import { defineConfig } from 'vite'
    import vue from '@vitejs/plugin-vue'
    import path from 'path'
    import mySvgPlugin from './my-svg-plugin' // 引入你写的插件
    
    export default defineConfig({
      plugins: [
        vue(),
        mySvgPlugin({ 
            iconDir: path.resolve(__dirname, 'src/icons') // 假设你的图标都在这里
        })
      ]
    })
    
  2. 准备素材: 在 src/icons 下放几个 svg 文件,比如 vue.svgreact.svg

  3. 引入注册: 在 src/main.js (或 main.ts) 中引入虚拟模块:

    import { createApp } from 'vue'
    import App from './App.vue'
    
    // 这一行会触发你插件的 resolveId -> load,
    // 然后在浏览器执行那段插入 DOM 的 JS 代码
    import 'virtual:svg-register' 
    
    createApp(App).mount('#app')
    
  4. 组件使用: 在 Vue 组件里写:

    <template>
      <div>
        <!-- 使用图标 -->
        <svg style="width: 50px; height: 50px; fill: red;">
          <use xlink:href="#icon-vue"></use>
        </svg>
        <svg style="width: 50px; height: 50px; fill: blue;">
          <use xlink:href="#icon-react"></use>
        </svg>
      </div>
    </template>
    

四、Vite 独有的钩子:configureServer

Vite 插件不仅仅是构建工具,还是一个开发服务器。configureServer 钩子允许我们在 Vite 的 Node.js 服务器(基于 connect 库)中添加中间件。这在 Webpack Plugin 中很难直接做到。

场景:实现一个简易的 API Mock 功能。当请求 /api/user 时,拦截请求并返回假数据,不经过后端。

插件实现

export default function myMockPlugin() {
  return {
    name: 'my-mock-plugin',

    configureServer(server) {
      // server 是 Vite 开发服务器实例
      // server.middlewares 是一个 connect 实例,用法类似 Express
      
      server.middlewares.use((req, res, next) => {
        // 拦截 /api/user 请求
        if (req.url === '/api/user') {
          res.setHeader('Content-Type', 'application/json');
          res.end(JSON.stringify({ id: 1, name: 'Mock User' }));
          return; // 结束请求
        }
        
        // 其他请求放行
        next();
      });
    }
  };
}

五、Vite 的热更新 (HMR) 钩子:handleHotUpdate

在 Webpack 中实现 HMR 需要修改打包逻辑。在 Vite 中,插件可以直接介入 HMR 流程。

场景:当 .txt 文件修改时,不刷新页面,只通过自定义事件通知浏览器更新。

插件实现 (服务端)

export default function myHmrPlugin() {
  return {
    name: 'my-hmr-plugin',

    handleHotUpdate({ file, server, modules }) {
      if (file.endsWith('.txt')) {
        // 1. 读取更新后的文件内容
        const content = require('fs').readFileSync(file, 'utf-8');

        // 2. 向浏览器发送自定义 Websocket 消息
        server.ws.send({
          type: 'custom',
          event: 'txt-update',
          data: { file, content } // 发送新内容
        });

        // 3. 返回空数组,告诉 Vite:这个文件我处理了,你不需要执行默认的 HMR 逻辑(默认逻辑通常是重新加载模块)
        return [];
      }
    }
  };
}

客户端代码 (Client)

// 在 main.js 中接收消息
if (import.meta.hot) {
  import.meta.hot.on('txt-update', (data) => {
    console.log(`文件 ${data.file} 变了,新内容是: ${data.content}`);
    // 在这里手动更新 DOM
    document.querySelector('#app').innerText = data.content;
  });
}

六、总结:Webpack vs Vite 插件开发对比

功能点 Webpack 实现方式 Vite 实现方式
引入非 JS 文件 Loader (如 css-loader) Plugintransform 钩子
寻找模块路径 resolve.alias 配置或 Resolver 插件 PluginresolveId 钩子
读取文件内容 Loader 读取 Pluginload 钩子
开发服务器拦截 devServer.before 配置 PluginconfigureServer 钩子
热更新控制 注入 Runtime 代码,较复杂 PluginhandleHotUpdate + import.meta.hot

开发思维转变:

  • Webpack 插件像是在一条已经铺好的流水线(Compiler Hooks)上安装传感器和机械臂。
  • Vite 插件更像是拦截器。浏览器请求文件 -> 你的插件拦截 -> 告诉你 ID -> 你给它内容 -> 你转换内容 -> 返回给浏览器。

⏰前端周刊第 454 期(2026年2月16日-2月22日)

2026年2月25日 15:11

📢 宣言每周更新国外论坛的前端热门文章,推荐大家阅读/翻译,紧跟时事,掌握前端技术动态,也为写作或突破新领域提供灵感~

欢迎大家访问:github.com/TUARAN/fron… 顺手点个 ⭐ star 支持,是我们持续输出的续航电池🔋✨!

在线网址:frontendweekly.cn/

bannerv2.png


💬 推荐语

本期围绕“AI First 前端架构 + WebMCP 与 Agent 生态 + 大规模性能与工具演进”展开:从“纯前端开发者之死”与 AI-First 前端架构出发,讨论前端角色边界与 AI 协作的新范式;WebMCP 相关多篇文章聚焦“让 Agent 与 Web 协作而非对抗”;虚拟滚动、高表格等实践则展示了在海量数据场景下的前端工程挑战。CSS 板块既有 Chrome CSS 零日漏洞的安全复盘,也有 border-shape、锚点定位、:near()、列表与排版刻度等一系列现代特性;JavaScript 与生态部分涵盖轻量状态管理、循环依赖治理、JS-heavy 性能反思,以及 React Doctor、vue-ecosystem-skills 与网络模拟器等新工具,帮助我们在 AI 与新工具浪潮下重新审视工程实践。


🗂 本期精选目录

🧭 Web 开发

🛠 工具

✨ 演示/特效

🎨 CSS

💡 JavaScript

别再写换皮 Options 了!Vue3 Setup 真正的用法的是这3步升级

2026年2月25日 14:52

很多人迁移 Vue3 后,写的 Setup 只是 Options API 的换皮——看似整洁,实则依然无序膨胀。其实 Composition API 的核心不是 Setup 语法,而是按功能组织代码!今天从一个详情页出发,带你3步升级,真正吃透它。

从简单的详情页开始

假设我们有一个页面:

  • 展示 业务详情
  • 展示 用户信息
  • 有一个 确认操作(confirm)

在 Vue2 里我们会这样写:

  • mounted 里拉数据
  • methods 里写 confirm
  • data 里存 loading

迁移到 Vue.js 3 后,很多人会写成这样:

setup() {
  const route = useRoute()

  const detail = ref(null)
  const queryDetailLoading = ref(false)
  const user = ref(null)
  const confirmLoading = ref(false)

  async function queryDetail(id: string) {
    queryDetailLoading.value = true
    detail.value = await api.getDetail(id)
    queryDetailLoading.value = false
  }

  async function queryUser(id: string) {
    user.value = await api.getUser(id)
  }

  async function confirm(id: string) {
    confirmLoading.value = true
    await api.confirm(id)
    confirmLoading.value = false
  }

  onMounted(() => {
    queryDetail(route.params.id)
    queryUser(route.params.id)
  })

  return {
    detail,
    queryDetailLoading,
    user,
    confirmLoading,
    confirm
  }
}

看起来没问题,甚至代码还很整洁:

  • 定义外部变量
  • 定义内部变量
  • 定义内部方法
  • 在生命周期中触发初始化操作

但是,代码的组织度好像没有变化?上面的代码好像还是很容易无序膨胀,出现几千行的 .vue 文件?应该怎么抽象?

Options 到 Composition 的优势在哪?

Vue 官网有张对比图:

image.png

很多人以为它表达的是:

代码更清晰了。

但它真正表达的是:

代码组织维度发生了变化。

Options API 是:

  • data
  • computed
  • methods
  • watch

按类型组织。

而 Composition API,可以按照:

  • 详情模块
  • 用户模块
  • 操作模块

功能模块组织代码。

升级1:按功能模块组织代码

先把同一职责的代码写在一起。

setup() {
  const route = useRoute()

  // ===== 详情模块 =====
  const detail = ref(null)
  const queryDetailLoading = ref(false)

  async function queryDetail(id: string) {
    queryDetailLoading.value = true
    detail.value = await api.getDetail(id)
    queryDetailLoading.value = false
  }

  onMounted(() => {
    queryDetail(route.params.id)
  })

  // ===== 用户模块 =====
  const user = ref(null)

  async function queryUser(id: string) {
    user.value = await api.getUser(id)
  }

  onMounted(() => {
    queryUser(route.params.id)
  })

  // ===== confirm 模块 =====
  const confirmLoading = ref(false)

  async function confirm(id: string) {
    confirmLoading.value = true
    await api.confirm(id)
    confirmLoading.value = false
  }

  return {
    detail,
    queryDetailLoading,
    user,
    confirmLoading,
    confirm
  }
}

这里有个 Vue 2 很容易忽略的思维定式:

在 Vue 3 中,可以有多个生命周期钩子,可以写多个 onMounted

这一刻,setup 不再是一个“大仓库”,而是一个“功能组合器”。

生命周期不再是“一个入口”,它可以属于不同功能块。

这样的代码组织,才是官网对比图中的样子。

升级2:拆分 Setup,useXxx 的诞生

在 setup 中拆分功能块后,可以很自然地将各个功能块拆分出 setup。

比如对于详情模块,输入是 route.params.id,输出是 detailqueryDetailLoading

  // ===== 详情模块 =====
  const detail = ref(null)
  const queryDetailLoading = ref(false)

  async function queryDetail(id: string) {
    queryDetailLoading.value = true
    detail.value = await api.getDetail(id)
    queryDetailLoading.value = false
  }

  onMounted(() => {
    queryDetail(route.params.id)
  })

重构为

function useDetail(id: string) {  
  const detail = ref(null)
  const queryDetailLoading = ref(false)
  
  async function queryDetail(id: string) {
    queryDetailLoading.value = true
    detail.value = await api.getDetail(id)
    queryDetailLoading.value = false
  }

  onMounted(() => queryDetail(id))
  
  return {  
    detail,  
    queryDetailLoading  
  }
}

注意不要将 const route = useRoute() 抽到 useDetail 中,保持 useDetail 依据 id 获取数据的单一职责。 Composition 的抽象边界应该围绕“数据输入输出”,而不是围绕“框架能力”。

于是 setup 成为

setup() {
  const route = useRoute()

  // ===== 详情模块 =====
  const { detail, queryDetailLoading } = useDetail(route.params.id)
  
  // ===== 用户模块 =====
  const { user } = useUser(route.params.id)

  // ===== confirm 模块 =====
  const { confirm, confirmLoading } = useConfirm()

  return {
    detail,
    queryDetailLoading,
    user,
    confirmLoading,
    confirm
  }
}

通过这种方式,各个 composition 有自己的职责,setup 负责视图层数据的聚合,你再不会写出流水账式的代码。

升级3:从生命周期驱动到数据驱动

到这里,其实我们已经完成了“功能拆分”。

但还有一个更重要的转变:setup 不应该围绕生命周期组织,而应该围绕数据变化组织。

比如经常遇到的问题:如果路由参数变化要怎么做呢?

实际这里的逻辑是,当 id 变化时,重新获取 detail

function useDetail(id: MaybeRefOrGetter<string>) {  
  // ...

  watch(() => toValue(id), () => queryDetail(toValue(id)), { immediate: true })
  
  return {  
    detail,  
    queryDetailLoading  
  }
}
setup() {
  const route = useRoute()

  // ===== 详情模块 =====
  const { detail, queryDetailLoading } = useDetail(() => route.params.id)
  
  // ...

  return {
    detail,
    queryDetailLoading,
    // ...
  }
}

如果使用了 将 props 传递给路由组件,还可以将 route 统一到组件标准的 props 操作:

setup(props) {
  // ===== 详情模块 =====
  const { detail, queryDetailLoading } = useDetail(() => props.id)
  
  // ...

  return {
    detail,
    queryDetailLoading,
    // ...
  }
}

回顾:Composition 的升级到底在哪?

升级路径其实很自然:

  1. 在 setup 内按功能组织
  2. 将功能抽离 setup 方便共享
  3. 通过 watch 将生命周期驱动转向数据驱动

Vue3 没让代码变乱。它提供了按功能组织代码的能力。

它的真正价值,不在于 setup,而在于它允许我们按“业务模型”组织代码,而不是按“框架结构”组织代码。

这可以让我们写出更内聚,更单一职责的代码。

在 Vue 2 中想达成这种能力需要通过 mixin 的方式,但 mixin 是通过在 this 上动态添加属性的方式进行的,这导致 mixin 在类型推导上极其困难,极度依赖对实现细节的了解。

升级加餐:不拆 setup,也能简单

如果你已经接受“按功能组织 + 数据驱动副作用”这个思路,那么其实可以再进一步,把这些模式固化下来。

在很多场景下,setup 中的内容没有复用的必要,单独抽到其它文件中有点大材小用。

有没有不拆分,还能保证各功能块高度内聚的写法?我写了 vue-asyncx 用于解决这个问题。

setup(props) {
  // ===== 详情模块 =====
  const detail = ref(null)
  const queryDetailLoading = ref(false)

  async function queryDetail(id: string) {
    queryDetailLoading.value = true
    detail.value = await api.getDetail(id)
    queryDetailLoading.value = false
  }

  watch(() => props.id, () => queryDetail(props.id), { immediate: true })

  // ===== 用户模块 =====
  const user = ref(null)

  async function queryUser(id: string) {
    user.value = await api.getUser(id)
  }

  watch(() => props.id, () => queryUser(props.id), { immediate: true })

  // ===== confirm 模块 =====
  const confirmLoading = ref(false)

  async function confirm(id: string) {
    confirmLoading.value = true
    await api.confirm(id)
    confirmLoading.value = false
  }

  return {
    detail,
    queryDetailLoading,
    user,
    confirmLoading,
    confirm
  }
}

可以重构为

import { useAsyncData, useAsync } from 'vue-asyncx'

setup(props) {
  // ===== 详情模块 =====
  const { 
    detail, 
    queryDetailLoading 
  } = useAsyncData('detail', () => api.getDetail(props.id), {
    watch: () => props.id, immediate: true
  })

  // ===== 用户模块 =====
  const { user } = useAsyncData('user', () => api.getUser(props.id), {
    watch: () => props.id, immediate: true
  })

  // ===== confirm 模块 =====
  const { confirm, confirmLoading } = useAsync('confirm', (id: string) => api.confirm(id))

  return {
    detail,
    queryDetailLoading,
    user,
    confirmLoading,
    confirm
  }
}

核心代码从19行减少到10行,代码量减少接近 50%,而语义化、组织性不丢失。

更多详细用法,见:早点下班:在 Vue3 中少写 40%+ 的异步代码

Chrome 插件开发入门

作者 day1
2026年2月25日 14:50

你能用插件做什么

丰富的 Chrome 插件能显著提升效率,比如广告屏蔽、长截图、网页收藏等。大多数插件的核心原理可以概括为一句话:

把 JavaScript 注入到网页里,对页面进行处理(改样式、加 UI、提取信息、自动化操作等),再配合插件自身的 UI 与后台能力,完成闭环。

插件由哪些部分组成

Chrome 插件通常由以下几部分组成(除 manifest.json 外其余都可选,按需求取用):

  • manifest.json:插件的“入口与权限说明”,包含名称、版本、图标、页面入口、脚本声明、权限等
  • Service Worker(后台脚本):可以调用更多扩展 API,适合做消息中转、统一网络请求、任务调度、统一存储等(MV3 下不常驻)
  • 功能页面(扩展自己的页面):
    • Popup:点击工具栏图标弹出的页面
    • Options:插件配置页
  • Content Script(内容脚本):注入到目标网页的脚本,可操作 DOM,但与网页自身脚本相互隔离,能调用的扩展 API 也有限

你可以把它们理解成 3 类“进程/页面”:

用户点击
  |
  v
[Popup / Options]  <--- 扩展自己的页面(有独立 DOM)
  |
  |  消息(runtime.sendMessage / tabs.sendMessage)
  v
[Service Worker]   <--- 后台中枢(事件驱动,非长驻)
  |
  |  注入 / 消息
  v
[Content Script]   <--- 运行在目标网页(能改 DOM)

运行环境差异

如果你只记住两点,会少走很多弯路:

  1. Popup/Options 不能直接操作目标网页 DOM 它们是扩展自己的页面,跟目标网页是两套 DOM。

  2. 真正“改网页”的代码应该放在 Content Script Content Script 运行在目标网页里,天然适合做 DOM 操作与网页增强。

因此常见模式是:

  • Popup 负责 UI 与触发动作
  • Content Script 负责对当前页面做实际处理
  • Service Worker 负责更“系统级”的能力(统一请求、存储、调度、跨页面协作)

manifest.json:重点字段与读法

manifest.json 相当于插件的“声明式配置”。入门阶段重点关注这些字段:

  • manifest_version:现在都用 3
  • name / version / description:基本信息
  • action:工具栏图标与 popup
  • permissions:扩展 API 权限(例如 storagescripting 等)
  • host_permissions:网站访问范围(尽量最小化)
  • content_scripts:声明式注入内容脚本
  • background.service_worker:后台入口
  • options_page:配置页入口

一个“结构示意”式的 MV3 manifest 片段如下:

{
  "manifest_version": 3,
  "name": "Your Extension",
  "version": "1.0.0",
  "action": { "default_popup": "popup.html" },
  "background": {
    "service_worker": "sw.js",
    "type": "module"
  },
  "options_page": "options.html",
  "permissions": ["storage", "scripting", "alarms"],
  "host_permissions": ["*://example.com/*"],
  "content_scripts": [
    {
      "matches": ["*://example.com/*"],
      "run_at": "document_idle",
      "js": ["content.js"],
      "css": ["style.css"]
    }
  ],
  "web_accessible_resources": [
    {
      "resources": ["logo.png", "inject.html"],
      "matches": ["*://example.com/*"]
    }
  ]
}

(清单字段、示例与约束):developer.chrome.com/docs/extens…

Content Script 注入:声明式 vs 程序式

内容脚本的注入常见有两种方式,可以组合使用:

1)声明式注入(manifest 里写 content_scripts

特点:

  • 优点:稳定,浏览器按规则自动注入
  • 缺点:如果用户在安装插件前已经打开了一堆网页,这些已打开页面通常不会自动注入,需要刷新或重新打开页面才生效

关键字段含义:

  • matches:哪些 URL 才注入
  • run_at:注入时机(常用 document_idle,减少对页面加载的影响)

2)程序式注入(用户触发时动态注入)

特点:

  • 优点:不用要求用户刷新页面;可以“按需注入”,权限更可控
  • 缺点:需要处理“重复注入”与“注入时机”问题;MV3 推荐用 chrome.scripting.executeScript

需要注意的坑:

  • 动态注入如果每次点击都注一次,可能会导致重复执行带来的副作用(例如重复绑定事件、重复插入 UI)
  • 可以在 content script 的作用域内做“是否已初始化”的标记,避免重复初始化(标记建议挂在页面侧或脚本侧可控位置,避免污染页面逻辑)

三者如何通信

插件开发的“工程感”大多来自通信:Popup、Content Script、Service Worker 之间需要消息传递。

你可以把消息理解为前端里的“事件总线 + RPC”的简化版:

  • Popup → Content Script:对当前页面发指令
  • Content Script → Service Worker:请求后台能力(存储、网络、跨页面协作)
  • Service Worker → Popup:推送状态或响应结果(必要时)

消息的形态建议遵循两个原则:

  • 有明确的 type(最好是 namespace:action 形式,避免冲突)
  • 有明确的请求/响应结构(避免用“随便塞一个对象”的方式扩展到后期无法维护)

示意(仅展示结构,不作为完整例子):

// request
{ type: 'devtools:requestList', payload: { ... } }

// response
{ ok: true, data: { ... } }

权限与安全

权限是插件的“安全边界”,也是上架审核与用户信任的关键。

建议:

  • 能不用 "<all_urls>" 就不用,优先指定域名或路径
  • 能用 activeTab 的场景不要扩大 host 权限
  • 不要记录或上报敏感信息(URL query、cookie、token、响应体默认都应视为敏感)
  • 权限变化要在文档/更新日志里说清楚“为什么需要”

常见权限分工理解:

  • permissions:你要调用哪些扩展 API(比如 storagescripting
  • host_permissions:你要访问哪些站点(尽量最小化) (permissions / host_permissions / optional_*):developer.chrome.com/docs/extens…

调试指南:你应该打开哪一个 DevTools

“我看不到 console”是入门阶段最常见的问题。按运行环境分别打开:

  • Popup:在弹窗里右键 → 检查(Inspect)
  • Content Script:在目标网页按 F12 → Console/Sources
  • Service Worker:chrome://extensions/ → 找到扩展 → Service Worker 的 Inspect

定位顺序建议:

  1. 先确认 manifest 是否生效(扩展是否加载成功、权限是否正确)
  2. 再确认 content script 是否注入(在目标网页 Console 看是否有日志/断点)
  3. 再确认消息是否到达(发送/接收两端分别打印)
  4. 最后再看是否是权限/注入时机/重复执行导致的问题

常见坑

  • MV3 后台不常驻:不要把关键状态只放内存,需要持久化用 chrome.storage
  • 已打开页面没注入:声明式注入下,安装/更新后往往要刷新页面才生效
  • 重复注入/重复初始化:动态注入时要做好“只初始化一次”的机制
  • Content Script 与页面脚本隔离:访问不到网页脚本里定义的变量/函数(别用“我在页面 console 能跑”为标准)
  • 网络请求同源/权限问题:需要弄清请求发生在 content script 还是后台,以及对应的权限策略

实战演练:写一个“网页背景色修改器”

光说不练假把式。我们来写一个最小闭环的 MV3 插件:Focus Mode功能:点击插件图标,弹窗选择背景色,点击按钮将当前网页背景变色,并记住这个颜色。

1. 目录结构

创建一个文件夹 my-focus-extension,放入以下 4 个文件:

my-focus-extension/
├── manifest.json
├── popup.html
├── popup.js
└── content.js

2. manifest.json (核心配置)

{
  "manifest_version": 3,
  "name": "Focus Mode",
  "version": "1.0",
  "description": "Change page background color",
  "action": {
    "default_popup": "popup.html"
  },
  "permissions": ["storage", "activeTab"],
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"]
    }
  ]
}

注意:这里为了演示方便用了 <all_urls>,实际发布时请尽量缩小范围。

3. popup.html (界面)

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <style>
    body {
      width: 200px;
      padding: 10px;
      font-family: sans-serif;
    }

    button {
      width: 100%;
      margin-top: 10px;
      padding: 5px;
      cursor: pointer;
    }
  </style>
</head>

<body>
  <h3>选择背景色</h3>
  <!-- 颜色选择器 -->
  <input type="color" id="colorPicker" value="#f0f0f0" style="width: 100%;">
  <button id="btnApply">应用背景色</button>
  <script src="popup.js"></script>
</body>

</html>

4. popup.js

document.addEventListener('DOMContentLoaded', async () => {
  const btn = document.getElementById('btnApply')
  const picker = document.getElementById('colorPicker')

  // 1. 回显上次保存的颜色 (Storage API)
  // MV3 支持 Promise,不需要回调地狱
  const data = await chrome.storage.sync.get('focusColor')
  if (data.focusColor) {
    picker.value = data.focusColor
  }

  btn.addEventListener('click', async () => {
    const color = picker.value

    // 2. 保存颜色
    await chrome.storage.sync.set({ focusColor: color })

    // 3. 获取当前标签页
    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })

    // 4. 发送消息给 Content Script
    if (tab.id) {
      // 这里的 type 建议加上命名空间防止冲突
      chrome.tabs.sendMessage(tab.id, {
        type: 'FOCUS_MODE:CHANGE_COLOR',
        color
      })
    }
  })
})

5. content.js (网页操作)

// 监听来自 Popup 的消息
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  if (request.type === 'FOCUS_MODE:CHANGE_COLOR') {
    document.body.style.backgroundColor = request.color
    console.log('背景色已修改为:', request.color)

    // 可选:给发送方回执
    sendResponse({ status: 'done' })
  }
})

总结

入门 Chrome 插件,不要先写功能,而是先建立结构:

  • 你有哪些运行环境(Popup/Content/Service Worker)
  • 它们分别能做什么、不能做什么
  • 你的代码是如何注入网页的(声明式/程序式)
  • 你的数据与指令如何在它们之间流动(消息)
  • 你的权限如何做到“够用但克制”

有了这套骨架,再回头做任何具体功能,都会更顺畅也更好维护。

前端布局指南

作者 ElevenSylvia
2026年2月25日 14:34

前端布局完全指南

布局技术全解析 + 场景搭配推荐


目录

  1. CSS 基础布局属性
  2. Flexbox 弹性布局
  3. Grid 网格布局
  4. 响应式布局
  5. 常见布局模式
  6. CSS 框架布局方案
  7. 主流 UI 框架布局
  8. 布局搭配推荐
  9. 现代布局技巧
  10. 布局最佳实践

1. CSS 基础布局属性

1.1 display 属性

控制元素的显示类型

/* 外部显示类型 - 决定元素如何参与文档流 */
display: block;        /* 块级元素,独占一行,可设宽高 */
display: inline;       /* 行内元素,不换行,宽高无效 */
display: inline-block; /* 行内块,不换行,可设宽高 */
display: run-in;       /* 嵌入块(浏览器支持有限) */

/* 内部显示类型 - 决定元素内部布局方式 */
display: flow;         /* 常规文档流 */
display: flow-root;   /* 生成新的 BFC 容器 */
display: flex;         /* 弹性盒子 */
display: grid;         /* 网格布局 */
display: subgrid;      /* 继承父级网格 */

/* 表格布局 */
display: table;
display: inline-table;
display: table-row;
display: table-cell;
display: table-row-group;
display: table-header-group;
display: table-footer-group;
display: table-column;
display: table-column-group;
display: table-caption;

/* 列表布局 */
display: list-item;

/* 金属性布局 */
display: ruby;
display: ruby-base;
display: ruby-text;
display: ruby-base-container;
display: ruby-text-container;

/* 动画 */
display: contents;     /* 纯容器,不渲染自身 */
display: none;         /* 隐藏,不渲染 */

1.2 position 定位

/* 静态定位 - 默认值,不定位 */
position: static;

/* 相对定位 - 相对于自身原位置定位 */
position: relative;
top: 10px; right: 20px; bottom: 10px; left: 20px;

/* 绝对定位 - 相对于最近定位祖先元素 */
position: absolute;
top: 0; right: 0; bottom: 0; left: 0;

/* 固定定位 - 相对于视口定位 */
position: fixed;
top: 20px; right: 20px;

/* 粘性定位 - 混合定位方式 */
position: sticky;
top: 0;  /* 到达此位置时变为固定定位 */

/* z-index 层级 */
z-index: 1;      /* 数值越大越上层 */
z-index: auto;   /* 与父元素同层级 */
z-index: -1;     /* 在内容后面 */

1.3 float 浮动

⚠️ 已不推荐使用,仅作了解

float: left;   /* 左浮动 */
float: right;  /* 右浮动 */
float: none;   /* 不浮动 */

/* 清除浮动 */
clear: left;   /* 清除左浮动 */
clear: right;  /* 清除右浮动 */
clear: both;   /* 清除所有浮动 */

/* 清除浮动 hack */
.clearfix::after {
  content: '';
  display: block;
  clear: both;
}

/* BFC 清除浮动 */
.clearfix {
  overflow: hidden; /* 或 auto */
}

1.4 BFC (Block Formatting Context)

块级格式化上下文 - 独立的渲染区域

/* 触发 BFC 的方式 */
display: flow-root;
display: flex;
display: grid;
position: absolute;
position: fixed;
float: left/right;
overflow: hidden/auto/scroll;

/* BFC 特性 */
.bfc {
  display: flow-root;
  /* 内部的 Box 会在垂直方向依次排列 */
  /* 垂直方向的距离由 margin 决定 */
  /* 不会与浮动元素重叠 */
  /* 计算 BFC 高度时包含浮动元素 */
}

1.5 IFC (Inline Formatting Context)

行内格式化上下文

/* IFC 特性 */
.ifc {
  /* 水平方向排列 */
  /* 垂直方向可以设置 line-height */
  /* vertical-align 影响垂直对齐 */
  vertical-align: baseline;   /* 默认基线对齐 */
  vertical-align: top;        /* 与行中最高元素顶部对齐 */
  vertical-align: middle;    /* 居中对齐 */
  vertical-align: bottom;    /* 与行中最低元素底部对齐 */
  vertical-align: sub;       /* 下标 */
  vertical-align: super;     /* 上标 */
  vertical-align: 10px;      /* 数值偏移 */
  vertical-align: 50%;       /* 百分比偏移 */
}

1.6 多列布局 (Multi-column)

/* 列数 */
column-count: 3;           /* 固定列数 */
column-count: auto;        /* 根据宽度自动 */

/* 列宽 */
column-width: 200px;       /* 固定列宽 */
column-width: auto;

/* 简写 */
columns: 200px 3;          /* 列宽 列数 */

/* 列间距 */
column-gap: 20px;
column-gap: normal;        /* 默认 1em */

/* 列边框 */
column-rule: 2px solid #ccc;
column-rule-width: 2px;
column-rule-style: solid;
column-rule-color: #ccc;

/* 列高度平衡 */
column-fill: balance;      /* 各列高度尽量平衡 */
column-fill: auto;         /* 按顺序填充 */

/* 跨列 */
column-span: all;          /* 跨越所有列 */
column-span: 1;            /* 只在一列中 */

1.7 书写模式 (Writing Mode)

/* 水平书写模式 - 默认 */
writing-mode: horizontal-tb;

/* 垂直书写模式 */
writing-mode: vertical-rl;  /* 从右到左 */
writing-mode: vertical-lr;  /* 从左到右 */

/* 文字方向 */
direction: ltr;             /* 从左到右 */
direction: rtl;             /* 从右到左 */

/* 文本对齐 */
text-align: left;
text-align: right;
text-align: center;
text-align: justify;       /* 两端对齐 */

/* 混合模式 */
text-combine-upright: all; /* 组合竖排数字 */
text-orientation: mixed;    /* 混合方向 */
text-orientation: upright; /* 直立方向 */

1.8 表格显示布局

/* 表格布局算法 */
table-layout: auto;      /* 自动布局(默认) */
table-layout: fixed;     /* 固定布局,性能更好 */

/* 边框折叠 */
border-collapse: collapse;    /* 合并边框 */
border-collapse: separate;   /* 分离边框 */

/* 边框间距 */
border-spacing: 10px;
border-spacing: 10px 5px;

/* 空单元格 */
empty-cells: show;       /* 显示 */
empty-cells: hide;       /* 隐藏 */

/* 表格标题位置 */
caption-side: top;
caption-side: bottom;

2. Flexbox 弹性布局

2.1 核心概念

主轴 (Main Axis)     - 弹性项目排列的方向
交叉轴 (Cross Axis) - 垂直于主轴的方向
主尺寸 (Main Size)  - 主轴方向的尺寸
交叉尺寸            - 交叉轴方向的尺寸

2.2 容器属性

/* 主轴方向 */
flex-direction: row;              /* 从左到右(默认) */
flex-direction: row-reverse;     /* 从右到左 */
flex-direction: column;           /* 从上到下 */
flex-direction: column-reverse;   /* 从下到上 */

/* 换行行为 */
flex-wrap: nowrap;         /* 不换行(默认) */
flex-wrap: wrap;          /* 换行,第一行在上 */
flex-wrap: wrap-reverse;  /* 换行,第一行在下 */

/* 方向 + 换行简写 */
flex-flow: row wrap;

/* 主轴对齐方式 */
justify-content: flex-start;     /* 起始对齐 */
justify-content: flex-end;       /* 末尾对齐 */
justify-content: center;         /* 居中对齐 */
justify-content: space-between;  /* 两端对齐,项目间距相等 */
justify-content: space-around;   /* 项目两侧间距相等 */
justify-content: space-evenly;    /* 项目之间间距相等 */

/* 交叉轴对齐方式 */
align-items: stretch;       /* 拉伸填满(默认) */
align-items: flex-start;    /* 起始对齐 */
align-items: flex-end;       /* 末尾对齐 */
align-items: center;        /* 居中对齐 */
align-items: baseline;      /* 基线对齐 */

/* 多行对齐(flex-wrap: wrap 时有效) */
align-content: stretch;       /* 拉伸行 */
align-content: flex-start;     /* 起始对齐 */
align-content: flex-end;       /* 末尾对齐 */
align-content: center;         /* 居中对齐 */
align-content: space-between;  /* 两端对齐 */
align-content: space-around;   /* 行间距相等 */
align-content: space-evenly;   /* 行间距相等 */

2.3 项目属性

/* 放大比例 */
flex-grow: 0;    /* 不放大(默认) */
flex-grow: 1;    /* 占据剩余空间 */

/* 缩小比例 */
flex-shrink: 1;  /* 必要时缩小(默认) */
flex-shrink: 0;  /* 不缩小 */

/* 基础尺寸 */
flex-basis: auto;      /* 自动(默认) */
flex-basis: 200px;     /* 固定宽度 */
flex-basis: 50%;       /* 百分比 */
flex-basis: 10vw;

/* flex 简写 */
/* flex: flex-grow flex-shrink flex-basis */
flex: 0 1 auto;     /* 默认值 */
flex: 1;             /* flex: 1 1 0% - 放大,缩小,0基础 */
flex: auto;          /* flex: 1 1 auto */
flex: none;          /* flex: 0 0 auto - 不放大不缩小 */
flex: 0 0 200px;     /* 固定尺寸 */

/* 单个项目交叉轴对齐 */
align-self: auto;      /* 继承 align-items */
align-self: stretch;
align-self: flex-start;
align-self: flex-end;
align-self: center;
align-self: baseline;

/* 排列顺序 */
order: 0;      /* 默认顺序 */
order: -1;    /* 排在最前面 */
order: 1;     /* 排到最后面 */

2.4 Flexbox 布局模式

水平居中
.center-x {
  display: flex;
  justify-content: center;
}
垂直居中
.center-y {
  display: flex;
  align-items: center;
}
水平垂直居中
.center-xy {
  display: flex;
  justify-content: center;
  align-items: center;
}
两端对齐
.space-between {
  display: flex;
  justify-content: space-between;
}
等间距
.space-evenly {
  display: flex;
  justify-content: space-evenly;
}
响应式换行
.wrap {
  display: flex;
  flex-wrap: wrap;
  gap: 20px;
}
底部对齐
.align-bottom {
  display: flex;
  align-items: flex-end;
}
菜单导航
.nav {
  display: flex;
  justify-content: space-between;
  align-items: center;
}
列表项
.list {
  display: flex;
  flex-direction: column;
}
等高列
.equal-height {
  display: flex;
}
.equal-height > * {
  flex: 1;
}

3. Grid 网格布局

3.1 核心概念

  • 网格容器 - 应用 display: grid 的元素
  • 网格项目 - 网格容器的直接子元素
  • 网格线 - 划分网格的线
  • 网格轨道 - 相邻网格线之间的区域
  • 网格单元格 - 网格的最小单位
  • 网格区域 - 多个单元格组成的矩形区域

3.2 容器属性

/* 定义网格轨道 */
grid-template-columns: 100px 100px 100px;           /* 3列固定宽度 */
grid-template-columns: 1fr 2fr 1fr;                  /* 比例分配 */
grid-template-columns: repeat(3, 1fr);              /* 重复3次 */
grid-template-columns: repeat(auto-fit, 200px);      /* 自适应列数 */
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); /* 最小最大 */
grid-template-columns: [start] 100px [line2] 1fr [end];

grid-template-rows: 100px auto 1fr;                   /* 3行 */
grid-template-rows: repeat(3, 1fr);

/* 隐式网格 */
grid-auto-rows: 100px;                    /* 自动行高 */
grid-auto-columns: 1fr;                   /* 自动列宽 */
grid-auto-flow: row;                      /* 按行填充(默认) */
grid-auto-flow: column;                   /* 按列填充 */
grid-auto-flow: dense;                     /* 填充空白 */

/* 简写 */
grid-template: 100px auto / 1fr 1fr 1fr;   /* 行 / 列 */

/* 间距 */
gap: 20px;
row-gap: 10px;
column-gap: 20px;

/* 网格区域 */
grid-template-areas: 
  "header header header"
  "sidebar main aside"
  "footer footer footer";

3.3 对齐属性

/* 整个网格在容器中的对齐 */

/* 行方向对齐(水平) */
justify-content: start;      /* 起始对齐 */
justify-content: end;        /* 末尾对齐 */
justify-content: center;     /* 居中 */
justify-content: stretch;    /* 拉伸填满 */
justify-content: space-around;
justify-content: space-between;
justify-content: space-evenly;

/* 列方向对齐(垂直) */
align-content: start;
align-content: end;
align-content: center;
align-content: stretch;
align-content: space-around;
align-content: space-between;
align-content: space-evenly;

/* 单元格内容对齐 */

/* 水平对齐 */
justify-items: start;
justify-items: end;
justify-items: center;
justify-items: stretch;

/* 垂直对齐 */
align-items: start;
align-items: end;
align-items: center;
align-items: stretch;

/* place-items 简写 */
place-items: center center;

3.4 项目属性

/* 网格定位 */
grid-column-start: 1;
grid-column-end: 3;
grid-row-start: 1;
grid-row-end: 4;

/* 简写 */
grid-column: 1 / 3;      /* 开始 / 结束 */
grid-column: 1 / span 2; /* 开始 / 跨越数 */
grid-row: 1 / 4;

/* 网格区域 */
grid-area: header;
grid-area: 1 / 1 / 3 / 4; /* 行开始 / 列开始 / 行结束 / 列结束 */

/* 单个项目对齐 */
justify-self: start;
justify-self: end;
justify-self: center;
justify-self: stretch;

align-self: start;
align-self: end;
align-self: center;
align-self: stretch;

place-self: center center;

3.5 Grid 布局模式

基础网格
.basic-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 20px;
}
自适应网格
.auto-fit-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
  gap: 20px;
}
圣杯布局
.holy-grail {
  display: grid;
  grid-template-columns: 200px 1fr 200px;
  grid-template-rows: auto 1fr auto;
  min-height: 100vh;
  grid-template-areas: 
    "header header header"
    "sidebar main aside"
    "footer footer footer";
}
.header  { grid-area: header; }
.sidebar { grid-area: sidebar; }
.main    { grid-area: main; }
.aside   { grid-area: aside; }
.footer  { grid-area: footer; }
12栏栅格系统
.grid-12 {
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  gap: 20px;
}

.col-1  { grid-column: span 1; }
.col-2  { grid-column: span 2; }
.col-3  { grid-column: span 3; }
.col-4  { grid-column: span 4; }
.col-5  { grid-column: span 5; }
.col-6  { grid-column: span 6; }
.col-7  { grid-column: span 7; }
.col-8  { grid-column: span 8; }
.col-9  { grid-column: span 9; }
.col-10 { grid-column: span 10; }
.col-11 { grid-column: span 11; }
.col-12 { grid-column: span 12; }

/* 偏移 */
.offset-1  { grid-column-start: 2; }
.offset-2  { grid-column-start: 3; }
瀑布流
.masonry {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  grid-auto-rows: 10px;
}

.masonry-item:nth-child(1) { grid-row-end: span 35; }
.masonry-item:nth-child(2) { grid-row-end: span 15; }
.masonry-item:nth-child(3) { grid-row-end: span 25; }
/* 实际使用需配合 JS 计算高度 */

4. 响应式布局

4.1 媒体查询

/* 媒体类型 */
@media screen { }
@media print { }
@media speech { }
@media all { }

/* 媒体特性 */
@media (width: 768px) { }
@media (min-width: 768px) { }
@media (max-width: 768px) { }
@media (orientation: portrait) { }   /* 竖屏 */
@media (orientation: landscape) { }  /* 横屏 */
@media (aspect-ratio: 16/9) { }
@media (prefers-color-scheme: dark) { }
@media (prefers-reduced-motion: reduce) { }

/* 逻辑运算符 */
@media (min-width: 768px) and (max-width: 1024px) { }
@media (min-width: 768px), (orientation: portrait) { }
@media not (min-width: 768px) { }

/* 组合媒体查询 */
@media (min-width: 640px) { }
@media (min-width: 768px) { }
@media (min-width: 1024px) { }
@media (min-width: 1280px) { }

4.2 断点策略

/* 移动优先 - 在小屏幕上写默认样式 */

:root {
  --breakpoint-sm: 640px;
  --breakpoint-md: 768px;
  --breakpoint-lg: 1024px;
  --breakpoint-xl: 1280px;
  --breakpoint-2xl: 1536px;
}

/* 小平板 */
@media (min-width: 640px) { }

/* 平板 */
@media (min-width: 768px) { }

/* 小桌面 */
@media (min-width: 1024px) { }

/* 桌面 */
@media (min-width: 1280px) { }

/* 大桌面 */
@media (min-width: 1536px) { }

4.3 常见框架断点

框架 手机 平板 桌面
Bootstrap <576px ≥576px ≥768px
Tailwind <640px ≥640px ≥1024px
Material UI 0 600px 960px
Ant Design <576px ≥576px ≥992px

5. 常见布局模式

5.1 垂直居中

/* 方法1: Flexbox */
.center-flex {
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 100vh;
}

/* 方法2: Grid */
.center-grid {
  display: grid;
  place-items: center;
  min-height: 100vh;
}

/* 方法3: absolute + transform */
.center-absolute {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}

/* 方法4: grid + margin */
.center-margin {
  display: grid;
  justify-content: center;
  align-content: center;
  min-height: 100vh;
}

/* 方法5: table-cell (古老方法) */
.center-table {
  display: table-cell;
  vertical-align: middle;
  text-align: center;
  width: 100%;
  height: 100vh;
}

5.2 Sticky Footer

/* Flexbox 方案 */
.sticky-footer {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
}
.sticky-footer > header {
  flex: 0;
}
.sticky-footer > main {
  flex: 1;
}
.sticky-footer > footer {
  flex: 0;
}

/* Grid 方案 */
.sticky-footer-grid {
  display: grid;
  grid-template-rows: auto 1fr auto;
  min-height: 100vh;
}

/* calc 方案 */
.sticky-footer-calc {
  min-height: 100vh;
  padding-bottom: 60px; /* footer 高度 */
}
.sticky-footer-calc footer {
  height: 60px;
  margin-top: -60px;
}

5.3 圣杯布局

/* Flexbox 方案 */
.holy-grail {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
}
.holy-grail-body {
  display: flex;
  flex: 1;
}
.holy-grail-main {
  flex: 1;
  order: 2;
}
.holy-grail-sidebar {
  width: 200px;
  order: 1;
}
.holy-grail-aside {
  width: 200px;
  order: 3;
}

/* 响应式 */
@media (max-width: 768px) {
  .holy-grail-body {
    flex-direction: column;
  }
  .holy-grail-sidebar,
  .holy-grail-aside {
    width: 100%;
  }
}

5.4 双飞翼布局

/* 特点:中间栏优先渲染 */
.double-wing {
  display: flex;
}
.double-wing .main {
  flex: 1;
  min-width: 0;
}
.double-wing .left,
.double-wing .right {
  width: 200px;
}
.double-wing .left {
  order: 1;
}
.double-wing .main {
  order: 2;
}
.double-wing .right {
  order: 3;
}

5.5 等高布局

/* Flexbox - 天然等高 */
.equal-height {
  display: flex;
}
.equal-height > * {
  flex: 1;
}

/* Grid - 天然等高 */
.equal-height-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
}

/* table - 天然等高 */
.equal-height-table {
  display: table;
  width: 100%;
}
.equal-height-table > * {
  display: table-cell;
}

5.6 瀑布流布局

/* CSS Columns */
.masonry-columns {
  column-count: 3;
  column-gap: 20px;
}
.masonry-columns > .item {
  break-inside: avoid;
  margin-bottom: 20px;
}

/* Grid - 需配合 JS */
.masonry-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 20px;
}

/* Flexbox - 有限支持 */
.masonry-flex {
  display: flex;
  flex-wrap: wrap;
}
.masonry-flex > .item {
  width: calc(33.333% - 14px);
  margin-bottom: 20px;
}

5.7 侧边栏布局

/* 左侧固定,右侧自适应 */
.sidebar-main {
  display: flex;
}
.sidebar-main .sidebar {
  width: 250px;
  flex-shrink: 0;
}
.sidebar-main .main {
  flex: 1;
  min-width: 0;
}

/* 右侧固定,左侧自适应 */
.main-sidebar {
  display: flex;
}
.main-sidebar .main {
  flex: 1;
  min-width: 0;
}
.main-sidebar .sidebar {
  width: 250px;
  flex-shrink: 0;
}

5.8 两栏布局

/* 左宽右窄 */
.two-col {
  display: flex;
}
.two-col .left {
  flex: 2;
}
.two-col .right {
  flex: 1;
}

/* 各占一半 */
.half-half {
  display: flex;
}
.half-half > * {
  flex: 1;
}

5.9 三栏布局

/* 两侧固定,中间自适应 */
.three-col {
  display: flex;
}
.three-col .left,
.three-col .right {
  width: 200px;
  flex-shrink: 0;
}
.three-col .center {
  flex: 1;
  min-width: 0;
}

/* Grid 版本 */
.three-col-grid {
  display: grid;
  grid-template-columns: 200px 1fr 200px;
}

5.10 流式布局

/* 百分比宽度 */
.fluid {
  width: 100%;
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 20px;
  box-sizing: border-box;
}

/* calc 响应式 */
.fluid-calc {
  width: calc(100% - 40px);
  margin: 0 auto;
}

6. CSS 框架布局方案

6.1 Tailwind CSS

/* Flexbox */
.flex { display: flex; }
.flex-row { flex-direction: row; }
.flex-col { flex-direction: column; }
.flex-wrap { flex-wrap: wrap; }
.justify-start { justify-content: flex-start; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.items-start { align-items: flex-start; }
.items-center { align-items: center; }
.items-stretch { align-items: stretch; }

/* Grid */
.grid { display: grid; }
.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.gap-1 { gap: 0.25rem; }
.gap-2 { gap: 0.5rem; }
.gap-4 { gap: 1rem; }
.gap-8 { gap: 2rem; }

/* 响应式 */
@media (min-width: 640px) {
  .sm\:flex-row { flex-direction: row; }
  .sm\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (min-width: 768px) {
  .md\:flex-row { flex-direction: row; }
  .md\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (min-width: 1024px) {
  .lg\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}

/* 间距 */
.p-1 { padding: 0.25rem; }
.p-2 { padding: 0.5rem; }
.p-4 { padding: 1rem; }
.m-auto { margin: auto; }
.mx-auto { margin-left: auto; margin-right: auto; }

/* 宽高 */
.w-full { width: 100%; }
.h-full { height: 100%; }
.min-h-screen { min-height: 100vh; }

/* 定位 */
.absolute { position: absolute; }
.relative { position: relative; }
.fixed { position: fixed; }
.inset-0 { top: 0; right: 0; bottom: 0; left: 0; }

6.2 Bootstrap 5

/* 容器 */
.container { max-width: 540px; }
.container-sm { max-width: 540px; }
.container-md { max-width: 720px; }
.container-lg { max-width: 960px; }
.container-xl { max-width: 1140px; }
.container-xxl { max-width: 1320px; }
.container-fluid { width: 100%; }

/* 12栏栅格 */
.row { display: flex; flex-wrap: wrap; margin: -0.75rem; }
.col { flex: 1 0 0%; }
.col-1 { flex: 0 0 auto; width: 8.333333%; }
.col-2 { flex: 0 0 auto; width: 16.666667%; }
.col-3 { flex: 0 0 auto; width: 25%; }
.col-4 { flex: 0 0 auto; width: 33.333333%; }
.col-6 { flex: 0 0 auto; width: 50%; }
.col-12 { flex: 0 0 auto; width: 100%; }

/* 响应式栅格 */
@media (min-width: 576px) {
  .col-sm { flex: 1 0 0%; }
}
@media (min-width: 768px) {
  .col-md { flex: 1 0 0%; }
}
@media (min-width: 992px) {
  .col-lg { flex: 1 0 0%; }
}
@media (min-width: 1200px) {
  .col-xl { flex: 1 0 0%; }
}

/* Flex */
.d-flex { display: flex; }
.d-inline-flex { display: inline-flex; }
.flex-row { flex-direction: row; }
.flex-column { flex-direction: column; }
.flex-wrap { flex-wrap: wrap; }
.flex-nowrap { flex-wrap: nowrap; }
.justify-content-start { justify-content: flex-start; }
.justify-content-center { justify-content: center; }
.justify-content-between { justify-content: space-between; }
.align-items-start { align-items: flex-start; }
.align-items-center { align-items: center; }
.align-items-stretch { align-items: stretch; }

/* 间距 */
.g-0 { gap: 0; }
.g-1 { gap: 0.25rem; }
.g-2 { gap: 0.5rem; }
.g-3 { gap: 1rem; }
.g-4 { gap: 1.5rem; }
.g-5 { gap: 3rem; }
.p-0 { padding: 0; }
.p-1 { padding: 0.25rem; }
.p-2 { padding: 0.5rem; }
.p-3 { padding: 1rem; }
.p-4 { padding: 1.5rem; }
.p-5 { padding: 3rem; }
.m-0 { margin: 0; }
.mx-auto { margin-left: auto; margin-right: auto; }
.mt-2 { margin-top: 0.5rem; }
.mb-2 { margin-bottom: 0.5rem; }

7. 主流 UI 框架布局

7.1 Ant Design (React)

// 基础布局组件
import { Layout, Header, Sider, Content, Footer } from 'antd';

<Layout>
  <Header>Header</Header>
  <Layout>
    <Sider width={200}>Sider</Sider>
    <Content>Content</Content>
  </Layout>
  <Footer>Footer</Footer>
</Layout>

// 响应式布局
<Layout>
  <Sider
    breakpoint="lg"
    collapsedWidth="0"
    onBreakpoint={(broken) => {}}
    onCollapse={(collapsed, type) => {}}
  >
    Sider
  </Sider>
  <Layout>
    <Header />
    <Content />
    <Footer />
  </Layout>
</Layout>

// Grid 栅格
import { Row, Col } from 'antd';

<Row gutter={[16, 16]}>
  <Col xs={24} sm={12} md={8} lg={6}>Col</Col>
  <Col xs={24} sm={12} md={8} lg={6}>Col</Col>
  <Col xs={24} sm={12} md={8} lg={6}>Col</Col>
</Row>

// Flex 布局
<div className="flex-center">...</div>
<div className="flex-between">...</div>

7.2 Element UI (Vue)

<!-- 基础布局 -->
<el-container>
  <el-header>Header</el-header>
  <el-container>
    <el-aside width="200px">Aside</el-aside>
    <el-main>Main</el-main>
  </el-container>
  <el-footer>Footer</el-footer>
</el-container>

<!-- 响应式布局 -->
<el-container>
  <el-aside :width="isCollapse ? '64px' : '200px'">...</el-aside>
  <el-container>
    <el-header>...</el-header>
    <el-main>...</el-main>
  </el-container>
</el-container>

<!-- Row / Col 栅格 -->
<el-row :gutter="20">
  <el-col :span="6" :xs="24" :sm="12" :md="8" :lg="6">
    <div class="grid-content"></div>
  </el-col>
</el-row>

<!-- Flex 布局 -->
<el-row type="flex" justify="center" align="middle">
  <el-col>...</el-col>
</el-row>

7.3 Material UI (React)

import { Box, Grid, Container, Stack } from '@mui/material';
import { AppBar, Toolbar } from '@mui/material';

// 基础布局
<Box sx={{ display: 'flex' }}>
  <AppBar position="fixed">...</AppBar>
  <Box component="main" sx={{ flexGrow: 1, p: 3 }}>
    Content
  </Box>
</Box>

// Container 居中容器
<Container maxWidth="sm">
  <Box>Content</Box>
</Container>

// Grid 栅格系统
<Grid container spacing={2}>
  <Grid item xs={12} sm={6} md={4}>Item</Grid>
  <Grid item xs={12} sm={6} md={4}>Item</Grid>
  <Grid item xs={12} sm={6} md={4}>Item</Grid>
</Grid>

// Stack 堆叠布局
<Stack direction="row" spacing={2}>
  <Item>Item 1</Item>
  <Item>Item 2</Item>
  <Item>Item 3</Item>
</Stack>

7.4 Vuetify (Vue)

<!-- 布局系统 -->
<v-app>
  <v-app-bar>...</v-app-bar>
  <v-navigation-drawer>...</v-navigation-drawer>
  <v-main>
    <v-container>Content</v-container>
  </v-main>
  <v-footer>...</v-footer>
</v-app>

<!-- 栅格系统 -->
<v-row>
  <v-col cols="12" sm="6" md="4" lg="3">Col</v-col>
  <v-col cols="12" sm="6" md="4" lg="3">Col</v-col>
</v-row>

<!-- 间隙 -->
<v-row no-gutters>...</v-row>
<v-row justify="center">...</v-row>

7.5 其他框架

框架 布局组件 栅格系统 特点
Radix UI 无官方 无官方 Headless 组件
Chakra UI Box, Flex, Grid 响应式样式 主题化能力强
Mantine AppShell 12栏 现代 React 栈
Arco Design Layout Row/Col 企业级
TDesign Layout Row/Col 腾讯设计体系
Semi Design Layout Row/Col 抖音设计体系

8. 布局搭配推荐

8.1 页面整体结构

推荐: Grid + Flexbox 组合
/* 页面结构 - Grid */
.page {
  display: grid;
  grid-template-rows: auto 1fr auto; /* header main footer */
  grid-template-columns: auto 1fr;     /* sidebar main */
  min-height: 100vh;
  grid-template-areas: 
    "header header"
    "sidebar main"
    "footer footer";
}

/* 组件内部 - Flexbox */
.page > nav {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.page > aside {
  display: flex;
  flex-direction: column;
}

8.2 导航栏

推荐: Flexbox
/* 顶部导航 */
.navbar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  height: 60px;
  padding: 0 20px;
}

.navbar-menu {
  display: flex;
  gap: 20px;
  list-style: none;
}

/* 移动端导航 */
@media (max-width: 768px) {
  .navbar {
    flex-wrap: wrap;
  }
  .navbar-menu {
    display: none; /* 或使用 hamburger menu */
  }
}

8.3 卡片列表

推荐: Grid + auto-fit
.card-list {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  gap: 24px;
}

.card {
  display: flex;
  flex-direction: column;
  /* 卡片内部等高 */
}
.card-content {
  flex: 1;
}

8.4 表单布局

推荐: Grid (对齐) + Flexbox (排列)
.form-grid {
  display: grid;
  gap: 20px;
  max-width: 600px;
}

.form-row {
  display: flex;
  gap: 16px;
}

.form-row > * {
  flex: 1;
}

@media (max-width: 600px) {
  .form-row {
    flex-direction: column;
  }
}

8.5 详情页

推荐: Grid 2列 + Flexbox
.detail-page {
  display: grid;
  grid-template-columns: 1fr 300px;
  gap: 40px;
}

@media (max-width: 768px) {
  .detail-page {
    grid-template-columns: 1fr;
  }
}

/* 左侧内容区域 */
.detail-content {
  display: flex;
  flex-direction: column;
  gap: 24px;
}

/* 右侧侧边栏 */
.detail-sidebar {
  display: flex;
  flex-direction: column;
  gap: 20px;
  position: sticky;
  top: 20px;
}

8.6 仪表盘

推荐: Grid 网格 + Flexbox 组件
.dashboard {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  grid-template-rows: auto auto 1fr;
  gap: 24px;
  grid-template-areas: 
    "stat1 stat2 stat3 stat4"
    "chart1 chart1 chart2 chart2"
    "table table table table";
}

.stat-card {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}

@media (max-width: 1024px) {
  .dashboard {
    grid-template-columns: repeat(2, 1fr);
  }
}

@media (max-width: 640px) {
  .dashboard {
    grid-template-columns: 1fr;
  }
}

8.7 聊天/消息界面

推荐: Grid + Flexbox 组合
.chat-layout {
  display: grid;
  grid-template-columns: 280px 1fr;
  height: 100vh;
}

.chat-list {
  display: flex;
  flex-direction: column;
  border-right: 1px solid #eee;
}

.chat-message {
  display: flex;
  gap: 12px;
  margin-bottom: 16px;
}

.chat-message.me {
  flex-direction: row-reverse;
}

@media (max-width: 768px) {
  .chat-layout {
    grid-template-columns: 1fr;
  }
}

8.8 电商商品页

推荐: Grid 2列 + Flexbox
.product-page {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 40px;
  max-width: 1200px;
  margin: 0 auto;
}

.product-gallery {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.product-thumbnails {
  display: flex;
  gap: 12px;
}

.product-info {
  display: flex;
  flex-direction: column;
  gap: 20px;
}

.product-actions {
  display: flex;
  gap: 16px;
}

@media (max-width: 768px) {
  .product-page {
    grid-template-columns: 1fr;
  }
}

8.9 文章/博客

推荐: 居中布局 + Grid
.article-layout {
  display: grid;
  grid-template-columns: 1fr min(700px, 100%) 1fr;
}

.article-layout > * {
  grid-column: 2;
}

.article-layout > .full-width {
  grid-column: 1 / -1;
}

.article-content {
  font-size: 18px;
  line-height: 1.8;
}

.article-content p {
  margin-bottom: 1.5em;
}

8.10 登录/注册页

推荐: Flexbox 居中 或 Grid 居中
/* 方案1: Flexbox */
.login-page {
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 100vh;
  background: #f5f5f5;
}

.login-card {
  width: 100%;
  max-width: 400px;
  padding: 40px;
  background: white;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}

/* 方案2: Grid */
.login-page-grid {
  display: grid;
  place-items: center;
  min-height: 100vh;
}

9. 现代布局技巧

9.1 clamp() 响应式尺寸

/* 响应式字体 */
h1 {
  font-size: clamp(1.5rem, 5vw, 3rem);
}

h2 {
  font-size: clamp(1.25rem, 4vw, 2rem);
}

/* 响应式宽度 */
.container {
  width: clamp(300px, 80%, 1200px);
}

/* 响应式内边距 */
.box {
  padding: clamp(10px, 5vw, 40px);
}

9.2 aspect-ratio 比例

/* 16:9 视频 */
.video {
  aspect-ratio: 16 / 9;
  width: 100%;
}

/* 1:1 正方形 */
.avatar {
  aspect-ratio: 1;
  width: 100px;
}

/* 4:3 图片 */
.photo {
  aspect-ratio: 4 / 3;
}

/* 响应式比例 */
.card-image {
  aspect-ratio: 4/3;
}

@media (min-width: 768px) {
  .card-image {
    aspect-ratio: 16/9;
  }
}

9.3 container 查询

/* 定义容器 */
.card-container {
  container-type: inline-size;
  container-name: card;
}

/* 容器查询 */
@container card (min-width: 400px) {
  .card {
    display: grid;
    grid-template-columns: 1fr 2fr;
  }
  
  .card-title {
    font-size: 1.25rem;
  }
}

@container card (min-width: 600px) {
  .card {
    grid-template-columns: 1fr 3fr;
  }
}

9.4 逻辑属性

/* 物理属性 */
margin-left: 10px;
padding-top: 20px;

/* 逻辑属性 - 适应书写模式 */
margin-inline-start: 10px;  /* 左边距/右边距(取决于方向) */
margin-block-start: 20px;  /* 上边距/下边距(取决于方向) */

padding-inline: 16px;      /* 内联方向 */
padding-block: 16px;      /* 块方向

width: 100%;
min-inline-size: 100%;     /* 逻辑宽度 */

/* 边框 */
border-inline-start: 1px solid #ccc;
border-block-end: 2px solid blue;

/* 定位 */
inset-inline-start: 10px;
inset-block-end: 10px;

9.5 subgrid 子网格

.parent-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 20px;
}

.child-grid {
  display: grid;
  grid-template-columns: subgrid;  /* 继承父网格 */
  grid-column: 1 / -1;
}

9.6 is() :has() 选择器

/* is() - 简化选择器 */
:is(header, footer) {
  background: #f5f5f5;
}

/* has() - 父选择器 */
.card:has(.badge) {
  position: relative;
}

.card:has(img) {
  padding-top: 0;
}

/* 布局中的应用 */
.grid:has(.featured) {
  grid-template-columns: 2fr 1fr;
}

10. 布局最佳实践

10.1 技术选型原则

场景 推荐技术 原因
单行/单列排列 Flexbox 简单直观
二维布局 Grid 行列同时控制
整体页面骨架 Grid 结构清晰
组件内部 Flexbox 灵活方便
居中 Grid place-items 最简代码
响应式网格 Grid + auto-fit 智能列数
均匀间距 Flexbox + gap 统一管理
等高列 Flexbox / Grid 天然支持
表单布局 Grid 对齐控制
复杂对齐 Grid 精确定位

10.2 性能优化

/* 避免布局抖动 */
.card {
  /* 明确指定尺寸 */
  min-height: 100px;
}

/* 使用 will-change 优化动画 */
.animated {
  will-change: transform;
  transform: translateZ(0); /* 硬件加速 */
}

/* 减少重排 */
.bad {
  width: calc(100% - 20px);  /* 可能引发重排 */
}

.good {
  padding: 0 10px;           /* 更好 */
}

/* contain 属性 */
.isolate {
  contain: layout style paint;
}

10.3 可访问性

/* 焦点可见 */
:focus-visible {
  outline: 2px solid blue;
  outline-offset: 2px;
}

/* 减少动画 */
@media (prefers-reduced-motion: reduce) {
  * {
    animation: none !important;
    transition: none !important;
  }
}

/* 暗色模式 */
@media (prefers-color-scheme: dark) {
  :root {
    --bg-color: #1a1a1a;
    --text-color: #ffffff;
  }
}

/* 高对比度 */
@media (prefers-contrast: more) {
  .card {
    border: 2px solid currentColor;
  }
}

10.4 CSS 变量

:root {
  /* 布局变量 */
  --spacing-xs: 4px;
  --spacing-sm: 8px;
  --spacing-md: 16px;
  --spacing-lg: 24px;
  --spacing-xl: 32px;
  
  /* 栅格 */
  --grid-gap: 20px;
  --grid-columns: 12;
  
  /* 容器 */
  --container-sm: 540px;
  --container-md: 720px;
  --container-lg: 960px;
  --container-xl: 1140px;
  
  /* 颜色 */
  --color-bg: #ffffff;
  --color-text: #333333;
}

.container {
  max-width: var(--container-xl);
  margin: 0 auto;
  padding: 0 var(--spacing-md);
}

.grid {
  display: grid;
  gap: var(--grid-gap);
  grid-template-columns: repeat(var(--grid-columns), 1fr);
}

附录

常用工具

浏览器支持

技术 Chrome Firefox Safari Edge
Flexbox 29+ 28+ 9+ 12+
Grid 57+ 52+ 10.1 16+
gap (flex) 84+ 63+ 14.1 84+
subgrid 117+ 71+ 16+ 117+
container 105+ 110+ 16+ 105+
clamp() 79+ 75+ 13.1 79+
aspect-ratio 84+ 82+ 15 84+

基于cursor 的自用专家系统v0.2

作者 潜水豆
2026年2月25日 12:40

记录一下基于cursor 专家系统的构成

背景: 前面使用mcp 构建的专家系统,存在上下文填充过快、且记忆能力不及预期、复用的时候比较繁琐的问题。再学习了一些AI流水线的写法之后,尝试优化一下,后续在工作中使用。

Cursor 多专家协作系统

一套运行在 Cursor IDE 中的多智能体协作框架,让 AI 在复杂任务中像专业团队一样分工、协作、互审。


一、设计理念

传统 AI 编程助手面临两个根本问题:

  1. 单点失效:一个 AI 同时负责需求理解、架构设计、代码实现、质量审查,容易在某个环节出现盲点却无人纠偏
  2. 上下文遗忘:对话越长越失控,跨会话几乎无法继续复杂任务

本系统的核心思路:专家分工 + 文件持久化 + 置信度门控

  • 每个专家只做自己擅长的一件事,不越界
  • 所有中间产物写入文件,跨会话可恢复
  • 每个环节输出置信度分数,低于阈值自动触发补救机制

二、系统架构全貌

用户指令
  │
  ▼
┌─────────────────────────────────────────────────────────┐
│                    [前置工具层]                           │
│  analyze-mode(并行上下文收集 + 路由建议)                  │
└─────────────────────┬───────────────────────────────────┘
                      │ analyze_summary(可选,自动消费)
                      ▼
┌─────────────────────────────────────────────────────────┐
│                  [统筹专家 Orchestrator]                  │
│                                                         │
│  Step 1.5 路由判断(自动门控)                             │
│    全部低 → 路径A: 直接实施                               │
│    1项高  → 路径B: 方案+实施                              │
│    2+项高 → 路径C: 完整流程                               │
│                                                         │
│  Brief 生成 → Task 派发 → Report 收集 → 产物整合           │
└──┬──────────┬──────────┬──────────┬─────────────────────┘
   │          │          │          │
   ▼          ▼          ▼          ▼
[需求专家] [方案专家] [实施专家] [审查专家]
   │          │          │          │
   │    discussion_modeStep 4.5
   │    自我质疑循环      │     修复闭环
   │          │          │     (Critical→修复→重审)
   │          │    [Git专家]    │
   │          │    每步提交      │
   ▼          ▼          ▼          ▼
  PRD       方案文档   代码变更   审查报告
   └──────────┴──────────┴──────────┘
                    │
              .expert/ 文件系统
                    │
              cursor-mem 持久记忆

三、核心组件

3.1 专家角色一览

专家 文件 职责 输出产物
统筹专家 orchestrator 路由判断、任务分解、Brief 派发、进度汇报 plan.jsonstatus.jsoncheckpoint.md
需求专家 requirements-expert 需求分析、PRD 编写、验收标准制定 prd-v{N}.md
方案专家 solution-expert 技术方案设计、自评迭代、架构决策 solution-v{N}.md
实施专家 impl-expert 原子步骤执行、逐步验证、修复 代码文件
审查专家 review-expert 代码审查、PRD 验收、问题分级 review-report.md
Git 专家 git-expert 临时分支管理、每步提交、变更溯源 expert/* 分支
UI 专家 ui-expert 界面一致性检查、组件规范审查 UI 审查报告
前置分析 analyze-mode 并行上下文收集、复杂度评估、路由建议 analyze_summary
会话恢复 resume-session 从检查点重建上下文、无缝继续任务
系统初始化 init-expert 创建 .expert/ 目录结构和项目规则模板 .expert/ 目录

3.2 统筹专家(Orchestrator)——控制中枢

统筹专家是整个系统的大脑,负责把用户的一句话变成多专家的协作任务。

工作流程:

Step 1   读取项目上下文(CONTEXT.md、registry.json、global.md)
   ↓
Step 1.5 轻量路由判断(改动范围 × 需求清晰度 × 架构影响)
   ↓
Step 2   任务分解(按路由结果选择专家组合)
   ↓
Step 2.5 双轨 Skill 探测(本地流程专家 + 社区技术增强)
   ↓
Step 3   生成 Brief 文件,通过 Task 工具派发专家
   ↓
Step 4   监控进度,定期汇报 status.json
   ↓
Step 4.5 审查闭环(若有 Critical,触发修复循环,最多 2 轮)
   ↓
Step 5   整合产物,更新 CONTEXT.md,写入最终检查点

路由判断示例:

路由判断:路径 B1 项高:架构影响)
计划专家:方案专家 → 实施专家

3.3 方案专家(Solution Expert)——自我质疑机制

方案专家有两种工作模式:

标准模式:输出方案 → 四维自评(可行性/完整性/风险控制/可维护性)→ 低于 75 分自动修订(最多 2 轮)

Discussion Mode(高架构影响任务自动开启):

初稿
  ↓
从反对角度提出 3 个最强质疑
  ↓
逐一答辩并修订
  ↓
终稿(质量显著提升)

Discussion Mode 不需要第二个 AI,同一个专家用不同角色质疑自己,是成本最低的质量保障机制。


3.4 审查闭环(Step 4.5)

审查专家发现 🔴 Critical 问题后,系统自动进入修复循环:

审查专家 Report(有 Critical)
  ↓
生成修复 Brief → 实施专家修复
  ↓
重审 Brief → 审查专家重新验证
  ↓
若无 Critical → 通过
若仍有 Critical 且已达 2 轮 → 上报用户决策

问题严重程度分级:

级别 含义 处理要求
🔴 Critical 功能缺失/逻辑错误/安全漏洞 必须修复,触发闭环
🟡 Warning 质量问题/边界未处理/潜在风险 记录 issues,建议修复
🟢 Suggestion 可读性/性能优化/最佳实践 可选

3.5 Git 专家——安全边界

Git 专家有一条不可逾越的核心原则:

所有变更只落到 expert/ 临时分支,绝不直接提交到主分支。

工作时机:

  • 任务开始 → 创建 expert/YYYYMMDD-task-slug 分支
  • 每个实施步骤验证通过 → 立即提交(可溯源到原子步骤)
  • 任务完成 → 输出分支摘要,提示用户选择后续操作

绝对禁止:

  • --no-verify(跳过 pre-commit hook)
  • push --force
  • 自动合并到主分支

四、通信协议

4.1 Brief / Report 文件系统

专家之间通过文件传递任务和结果,不依赖对话上下文:

.expert/sessions/{session_id}/
├── plan.json              ← 统筹专家的任务计划
├── status.json            ← 实时进度状态
├── checkpoint.md          ← 最新检查点(每次覆盖)
├── briefs/
│   ├── requirements-expert.json
│   ├── solution-expert.json
│   └── impl-expert.json
└── reports/
    ├── requirements-expert.json
    ├── solution-expert.json
    └── review-report.md

Brief 关键字段:

{
  "objective": "任务目标",
  "context_brief": "项目背景摘要(来自 analyze-mode 或 CONTEXT.md)",
  "input_artifacts": ["输入文件路径列表"],
  "suggested_skills": ["推荐加载的 SKILL.md 路径"],
  "project_rules": {
    "global": "global.md 内容",
    "expert_specific": "专家专属规则内容"
  },
  "constraints": {
    "discussion_mode": true
  },
  "output_spec": { "path": "产物输出路径" }
}

Report 关键字段:

{
  "expert": "solution-expert",
  "status": "completed | needs_review | failed",
  "confidence": 82,
  "artifacts": ["方案文档路径"],
  "issues": ["遗留问题描述"]
}

4.2 置信度(Confidence)处理规则

专家 置信度 < 阈值 处理方式
方案专家 < 75 自动触发修订,最多 2 轮;仍低则开启 discussion_mode
实施专家 < 70 统筹专家派发修复任务
审查专家 < 60 触发 Step 4.5 修复闭环

五、双轨 Skill 系统

这是本系统的独特设计,两类 Skill 定位不同:

┌─────────────────────────────────────────────────────┐
│  Track A:流程控制层(~/.cursor/skills/)             │
│  专家系统角色:需求专家、方案专家、实施专家...           │
│  作用:定义谁在什么时候做什么                          │
│  管理:Cursor 原生,Glob 工具扫描                     │
├─────────────────────────────────────────────────────┤
│  Track B:技术增强层(~/.claude/skills/)             │
│  社区最佳实践:Next.js 规范、测试标准、API 设计...      │
│  作用:增强专家在特定技术域的能力                      │
│  管理:npx openskills CLI                           │
└─────────────────────────────────────────────────────┘

统筹专家在派发任务前,会同时扫描两个轨道,将匹配结果写入 Brief 的 suggested_skills 字段,让执行专家按需加载。


六、持久化记忆层

系统通过三层机制保证跨会话连续性:

┌──────────────────────────────────────────┐
│  cursor-mem MCP                          │
│  存储:关键决策、错误记录、重要观察          │
│  特点:语义检索,轻量,适合小型状态          │
├──────────────────────────────────────────┤
│  CONTEXT.md                              │
│  存储:项目背景、技术栈、完成任务历史        │
│  特点:人类可读,Git 追踪,项目级           │
├──────────────────────────────────────────┤
│  .expert/sessions/*/checkpoint.md        │
│  存储:任务进度、待办、中断位置             │
│  特点:精确到步骤,/resume 时直接恢复       │
└──────────────────────────────────────────┘

自动写入检查点的时机:

  • 每个专家任务完成后
  • 对话轮次超过 15 轮
  • 遇到大量工具输出(预防上下文爆满)
  • 用户说"暂停"、"先记一下"

七、项目文件结构

{your-project}/
├── CONTEXT.md                    ← 项目记忆锚点(手动维护)
├── .expert/
│   ├── registry.json             ← 可用专家注册表
│   ├── rules/
│   │   ├── global.md             ← 所有专家共用的项目规则
│   │   ├── impl-expert.md        ← 实施专家专属规则
│   │   ├── ui-expert.md          ← UI 专家专属规则
│   │   └── review-expert.md      ← 审查专家专属规则
│   ├── materials/                ← 放需求文档、设计稿等输入物料
│   ├── sessions/                 ← 运行时状态(.gitignore 忽略)
│   └── reports/                  ← 专家输出产物
└── .gitignore                    ← 已自动添加 .expert/sessions/

八、快速上手

8.1 初始化

/init-expert

自动检测项目信息,创建 .expert/ 目录结构和规则模板。

8.2 启动任务

启动专家,帮我实现用户登录功能

统筹专家自动完成路由判断 → 选择专家组合 → 开始协作。

8.3 查看进度

/status

展示当前所有专家的状态和置信度。

8.4 恢复会话

/resume

从最新检查点重建上下文,继续中断的任务。

8.5 手动分析(可选前置)

[analyze-mode] 帮我重构数据层

并行收集上下文,输出复杂度评估和执行路径建议,统筹专家启动时自动消费此结果。


九、核心设计原则

原则 体现
单一职责 每个专家只做一件事,边界在 SKILL.md 的"不做"列表中明确声明
最小信任 Git 专家只操作 expert/ 分支,从不自动合并;合并需用户授权
可观测性 每步有置信度分数,每个会话有 checkpoint,全程可追溯
优雅降级 无检查点用 CONTEXT.md,无 CONTEXT.md 用 cursor-mem,三者均无提示用户
规则优先级 project_rules > SKILL.md 全局规则,项目级约束始终生效
并行优化 只读的分析/搜索任务并行,写文件的实施任务串行,避免冲突

十、与 Claude Code 的设计差异

维度 Claude Code 本系统(Cursor)
并行机制 真正后台 agent(run_in_background) 同一轮多工具调用
并发写文件 worktree 物理隔离 串行实施,避免冲突
工具调用 完整 bash 环境 Cursor Tool 套件
记忆持久化 无内置机制 cursor-mem + 文件系统双保险
自我质疑 需要两个 LLM discussion_mode 单 LLM 自我质疑

看完就懂 useLayoutEffect

作者 ssshooter
2026年2月25日 11:27

差异

useLayoutEffect 与大家熟悉的 useEffect 语法完全一致,从产生副作用的角度上看,功能上也是一样的,唯一差别就是调用时机。

useEffect 会在画面绘制后异步执行,而 useLayoutEffect 会在画面绘制前同步执行。为了讲清楚这个时机的具体区别,得先复习一下浏览器渲染页面的过程。

浏览器渲染流程

注意最后 js 运行的那一块,useLayoutEffect 和 useEffect 就分别位于 paint 之前和之后。

执行的顺序是:

  • useLayoutEffect
  • 画面绘制
  • 下一轮 js 运行 useEffect

顺便我们也能看出来,useLayoutEffect 之所以叫 useLayoutEffect 就是因为它的运行时间点沾着 layout。

使用场景

知道这两个函数的区别,我们还需要知道,到底什么时候用 useLayoutEffect 呢?

答案是,如果进行了 DOM 操作,且这个 DOM 操作会引起回流(reflow)、重绘(repaint),那么就应该使用 useLayoutEffect,例如:

function Tooltip() {
  const ref = useRef<HTMLDivElement>(null);
  const [pos, setPos] = useState({ top: 0, left: 0 });

  // 如果用 useEffect,这里会先渲染一次默认位置,再跳到正确位置 → 可能会造成闪烁
  useLayoutEffect(() => {
    const rect = ref.current!.getBoundingClientRect();
    setPos({
      top: rect.top + rect.height + 8,
      left: rect.left + rect.width / 2,
    });
  }, []);

  return (
    <>
      <div ref={ref}>hover me</div>
      <div style={{ position: 'fixed', top: pos.top, left: pos.left }}>tooltip</div>
    </>
  );
}

因为如果你用 useEffect,在浏览器绘制之后又要重新跑一遍 reflow、repaint,用户可能会看到画面“闪烁”。

如果你有代码洁癖,想要一个最优解,那么你确实该按上面说的这么做,但是事实上在这个场景使用 useEffect 可能也不会有很明显的问题。

其实即使是官网的例子里,作为反模式使用 useEffect,用户也不会感知到明显的“闪烁”,因为两次渲染的时间其实是快到肉眼看不清的,为了确定真的存在区别你还要故意写个 while 循环卡一下主进程。

既然一般情况下无论 useEffect 和 useLayoutEffect 都不会有明显区别,那么我觉得,作为一个有专业素养的 React 开发者,应该优先使用 useEffect,只在 reflow、repaint 造成闪烁的场景下,使用 useLayoutEffect。

当然,useEffect本身也不能乱用,之前在useEffect 清除计划里已经讲述了它的必要使用场景。

总结

useLayoutEffect 适用于“需要在浏览器绘制前同步完成的副作用”,典型场景是读取布局信息并立即修改 DOM,避免视觉闪动。

但因其会阻塞浏览器绘制,影响性能,因此不应滥用。在绝大多数副作用场景下,优先使用 useEffect,只有在感知到闪动才改为使用 useLayoutEffect。

❌
❌