普通视图

发现新文章,点击刷新页面。
昨天 — 2026年3月20日掘金 前端

从动漫水面到赛博飞船:这位开发者的Three.js作品太惊艳了

作者 柳杉
2026年3月20日 20:16

不是游戏引擎做不起,而是 React Three Fiber更有性价比。

今天给大家安利一个宝藏开发者 Christian Ortiz,以及他的两个开源项目——看完你会明白,用Web技术做3D视觉效果,已经卷到什么程度了。


项目一:Anime Water Scene —— 动漫风格水面场景

GitHub: github.com/cortiz2894/…

先看效果:

  • 类似《海贼王》《鬼灭之刃》那种手绘风格的动漫水面
  • 物体入水时有经典的动漫式涟漪圆环
  • 水下有随水流波动的Voronoi纹理海底
  • 水面与物体交界处有发光轮廓线

技术亮点拆解:

1. 多层渲染管线(6层叠加)

SeabedFloor(海底纹理)
  ↓
WaterFloor(水面着色)
  ↓
WaterDepthIntersection(深度发光)
  ↓
WaterWaveSimulation(波浪模拟)
  ↓
WaterSparkles(水面闪光粒子)
  ↓
Ripple System(涟漪系统)

这不是简单的"贴图+水面",而是真正的分层合成渲染

2. 自定义GLSL着色器 —— 核心黑科技

Voronoi Cel-Shading(赛璐珞着色)

// 简化版核心逻辑
float voronoi = voronoiF1(pos) - smoothVoronoiF1(pos);
vec3 waterColor = mix(deepColor, highlightColor, voronoi);

Voronoi F1 − SmoothF1 算法,复刻了Blender的动态绘画效果,实现了那种"一块一块"的动漫水面质感。

3. GPU物理波浪模拟(PDE方程)

不是简单的正弦波,而是真正的偏微分方程 模拟

h_next = 2·h_cur − h_prev + c²·∇²h

每帧三次渲染通道:

  1. Injection —— 检测物体入水形状
  2. Wave Update —— 求解波浪方程(ping-pong双缓冲)
  3. Display —— 根据高度梯度渲染波纹

4. 屏幕空间深度检测

物体与水面交界处的效果,用深度图比较实现:

  • 渲染一遍场景深度到纹理
  • 水面像素对比自身深度和场景深度
  • 差值越小 → 发光越强

这技术在各种3A游戏里都在用,现在Web端也能跑了。


项目二:Ship Selection Page —— 赛博飞船选择界面

GitHub: github.com/cortiz2894/…

这是游戏《Laser Drift: Neon Blast》的飞船选择界面,有完整的YouTube教程系列。

核心效果:

截屏2026-03-20 19.44.22.png

截屏2026-03-20 19.45.36.png

  • 蒸汽波(Vaporwave)美学风格
  • 飞船线框揭示动画(Wireframe Reveal)
  • 3D飞船展示 + 属性面板
  • 粒子背景系统
  • 手势控制支持

技术亮点:

1. 线框揭示动画(Wireframe Reveal)

不是简单的淡入淡出,而是从线框到实体的渐变:

  • 先用GLSL把模型渲染成线框
  • 通过shader的discard逻辑,控制像素显示/隐藏
  • 配合GSAP动画,实现"绘制出来"的效果

2. GLB模型烘焙纹理

  • 从Blender导出GLB格式
  • 烘焙光照贴图(Lightmap)
  • 在Web端还原高质量的静态光照

3. 完整的UI+3D融合

ShipSelection/
├── BaseModel/      # 3D展示平台
├── Ships/          # 飞船模型数据
├── ShipGrid/       # 选择网格UI
├── ShipStats/      # 属性面板
└── ShipDescription/# 描述面板

3D场景和React UI组件完美融合,不是"3D画布上面盖一层HTML"的简单做法。


两个项目的共同技术栈

技术 用途 学习价值
Next.js 15 框架 App Router + 服务端渲染
React Three Fiber 3D渲染 React式声明化3D开发
Drei R3F辅助库 常用3D组件开箱即用
GSAP 动画 时间轴控制、缓动函数
Leva GUI调试 实时参数调节
Tailwind CSS 样式 快速UI开发
TypeScript 类型 大型3D项目必备

你可以从中学到什么?

1. 动漫风格渲染的秘密

  • Cel-Shading(赛璐珞着色)不是"卡通材质"那么简单
  • Voronoi噪声可以实现手绘质感的纹理
  • 多层合成比单一大shader更可控

2. 物理模拟不用全靠库

  • 自己写PDE求解器,理解GPU计算的本质
  • Ping-pong双缓冲是实现反馈效果的关键
  • WebGL的FrameBuffer对象可以玩出很多花样

3. 3D项目工程化

  • 用React组件化思维组织3D代码
  • Store模式管理跨组件的3D状态
  • 自定义Hook封装可复用的3D逻辑

4. 性能优化技巧

  • DPR-aware渲染(适配高分辨率屏)
  • GPU粒子系统(gl_PointCoord)
  • 深度图复用(避免重复渲染)

如何运行这两个项目

# 项目一:动漫水面
git clone https://github.com/cortiz2894/water-anime-shader.git
cd water-anime-shader
pnpm install
pnpm dev

# 项目二:飞船选择
git clone https://github.com/cortiz2894/ship-selection-page.git
cd ship-selection-page
npm install
npm run dev

注意:都需要Node 18+,推荐用pnpm(项目一作者用的pnpm)。


适合谁学?

人群 建议重点看
前端开发者 React Three Fiber的组件化思维
Three.js初学者 两个项目的shader入门
创意开发者 视觉效果实现思路
游戏开发者 UI与3D场景融合方案
设计师 技术可行性参考

写在最后

Christian Ortiz 的作品最打动我的地方:他把Blender的动态绘画、3A游戏的深度检测、物理模拟的 PDE 方程,全部搬进了Web端

而且代码组织得非常干净——不是那种"shader写2000行"的硬核风格,而是组件化、模块化、React化的现代前端工程实践。

如果你想:

  • 做创意视觉网站
  • 做游戏风格的3D交互
  • 深入理解WebGL shader
  • 看如何用React做3D工程

这两个项目都值得clone下来,一行行啃。


项目链接:


如果对你有帮助,点个关注呗!

Ant Design Vue 表格组件空数据统一处理 踩坑

作者 28256_
2026年3月20日 18:06

transformCellText

提供 transformCellText 这个表格属性来做数据的处理

transformCellText 数据渲染前可以再次改变,一般用于空数据的默认配置 Function({ text, column, record, index }) => any,此处的 text 是经过其它定义单元格 api 处理后的数据,有可能是 VNode/string/number 类型

数据处理时,都是用text这个属性

划重点

text会有两种情况,这个才是坑的地方

  • 非数组(直接就是要展示的数据)
  • 是个数组(要展示的数据被数组包裹了一层)

text非数组情况


<a-table :dataSource="dataSource" :columns="columns" />

直接简单使用,不使用table组件的插槽,这个时候返回的就是要展示的数据

image.png 可以从图上看出,打印的text的结果

text是个数组


<template>
  <a-table :dataSource="dataSource" :columns="columns" :transformCellText="ssss">
    <template #bodyCell="{ column, record }">
      <template v-if="column.key === 'avatar'">
        <a-avatar :src="record.avatar" :style="{ backgroundColor: '#1890ff' }">
          {{ record.name?.charAt(0) }}
        </a-avatar>
      </template>
    </template>
  </a-table>
</template>

使用了table组件的bodyCell插槽,这个时候要展示的数据被数组包裹了一层

image.png 可以从图上看出,打印的text被数组包裹了一层

实践方案

既然text会有两种情况,就可以从两种情况下手,完成我们的需求

// 当返回的类型是VNode时,不用特殊处理,因为VNode是自定义的dom 直接渲染
const handleTransform = ({ text }) => {

  const isEmpty = val => val === null || val === undefined || val === ''

  const target = Array.isArray(text) ? (text.length > 0 ? text[0] : undefined) : text

  return isEmpty(target) ? '--' : text
}

前端安全通信方案:RSA + AES 混合加密

作者 井川不擦
2026年3月20日 17:52

1. 背景与意义

在现代 Web 应用中,数据传输安全是至关重要的环节。传统的 HTTPS 协议虽然提供了基础的安全保障,但在某些高安全要求的场景(如金融交易、敏感信息传输)下,需要对业务数据进行端到端的二次加密,确保即使 HTTPS 通道被突破,数据内容仍然保持机密性。

本文介绍的 RSA + AES 混合加密方案,结合了非对称加密和对称加密的优势,既能保证密钥安全分发,又能兼顾大数据量的加密性能。

二、加密方案概述

2.1 为什么选择混合加密?

加密类型 优点 缺点 适用场景
RSA(非对称) 安全性高,无需预共享密钥 加密速度慢,有长度限制 密钥分发、数字签名
AES(对称) 加密速度快,适合大数据量 需要安全传输密钥 业务数据加密

混合加密方案:用 RSA 加密 AES 密钥,用 AES 加密业务数据,兼顾安全性与性能。

2.2 整体架构图

┌─────────────────────────────────────────────────────────────────┐
│                         前端(浏览器)                          │
├─────────────────────────────────────────────────────────────────┤
│  1. 获取后端RSA公钥并验证其哈希值(保证哈希算法与后端一致)         │
│  2. 组装业务 JSON                                               │
│  3. 生成随机 AES 密钥                                           │
│  4.AES 密钥加密 JSON → 密文 C                               │
│  5.RSA 公钥加密 AES 密钥 → encryptedKey                     │
│  6. 发送 { cipherText: C, encryptedKey: xxx }                  │
└─────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────┐
│                         后端(服务器)                          │
├─────────────────────────────────────────────────────────────────┤
│  7.RSA 私钥解密 encryptedKey → AES 密钥                     │
│  8.AES 密钥解密密文 C → 业务 JSON                           │
│  9. 处理业务逻辑                                                │
│ 10. 响应数据(可选择加密返回)                                  │
└─────────────────────────────────────────────────────────────────┘

流程图

image.png

image.png

三、前端实现详解

3.1 获取RSA公钥+公钥校验

// utils/RSA.ts
// 获取公钥+公钥校验(PCI Req 8:公钥校验)
export const getRsaPublicKeyFn = async (): Promise<string> => {
  // 1. 基础数据准备(Web环境)
  // 从后端获取RSA公钥(Web场景:页面初始化时拉取,缓存到内存,禁止持久化)
  const { public_key } = await getRsaPublicKey()
  // 计算获取到的公钥的哈希值
  const receivedPublicKeyHash = await calculateSHA256Hash(public_key);
  console.log("前端计算的公钥哈希值:", receivedPublicKeyHash);
  console.log("环境变量中的公钥哈希值:", import.meta.env.VITE_PUBLIC_KEY_HASH);

  // 4. 对比哈希值,验证公钥是否被篡改
  if (receivedPublicKeyHash === import.meta.env.VITE_PUBLIC_KEY_HASH) {
    console.log("✅ 公钥校验通过,未被篡改!");
    // 校验通过后,才能使用该公钥进行后续的AES密钥加密
    return public_key;
  } else {
    console.error("❌ 公钥哈希值不匹配,公钥可能被篡改!");
    throw new Error("公钥校验失败,拒绝使用");
  }
};

// 计算字符串的SHA256哈希值(转为十六进制)
export async function calculateSHA256Hash(publicKeyPem: string): Promise<string> {
  try {
    // 1. 将字符串转为 UTF-8 二进制(Go 的 []byte(str) 等价 UTF-8 编码)
    const encoder = new TextEncoder();
    const binary = encoder.encode(publicKeyPem.replace(/\r\n/g, '\n').trim());

    // 2. 计算 SHA256 哈希
    const hashBuffer = await crypto.subtle.digest("SHA-256", binary);

    // 3. 转换为十六进制字符串(补零确保两位)
    const hashArray = Array.from(new Uint8Array(hashBuffer));
    const hashHex = hashArray.map(b => b.toString(16).padStart(2, "0")).join("");

    return hashHex;
  } catch (error) {
    console.error("SHA256 计算失败:", error);
    throw new Error("哈希计算失败,请检查输入");
  }
}


// 页面操作
//  页面加载时获取RSA公钥
onMounted(async () => {
    publicKey.value = await getRsaPublicKeyFn();
})

3.2 生成随机 AES 密钥

// utils/AES.ts

/**
 * 生成合规的256位AES密钥(含内存清空)
 * @returns {Object} aesKey(CryptoKey) + aesKeyBase64(Base64)
 */
export const generateCompliantAes256Key = async (): Promise<GenerateAes256KeyResult> => {
  let aesKey: CryptoKey | null = null;
  let aesKeyRaw: ArrayBuffer | null = null;
  let aesKeyUint8: Uint8Array | null = null;
  let aesKeyBase64: string | null = null;
  let decodedBase64: Uint8Array | null = null;

  try {
    // 1. 生成256位AES-GCM密钥
    aesKey = await crypto.subtle.generateKey(
      { name: "AES-GCM", length: 256 },
      true,
      ["encrypt", "decrypt"],
    );

    // 2. 提取密钥为原始二进制
    aesKeyRaw = await crypto.subtle.exportKey("raw", aesKey);
    aesKeyUint8 = new Uint8Array(aesKeyRaw);

    // 3. PCI合规校验:密钥长度必须为32字节
    if (aesKeyUint8.length !== 32) {
      throw new Error(`[PCI合规] AES-256密钥长度必须为32字节,当前:${aesKeyUint8.length}`);
    }

    // 4. 转换为Base64格式
    aesKeyBase64 = btoa(String.fromCharCode(...aesKeyUint8));

    // 5. 二次校验
    decodedBase64 = Uint8Array.from(atob(aesKeyBase64), (c) => c.charCodeAt(0));
    if (decodedBase64.length !== 32) {
      throw new Error(`[PCI合规] AES密钥Base64转换后长度异常,当前:${decodedBase64.length}`);
    }

    console.log("✅ AES-256密钥生成成功(符合PCI DSS 4.0要求)");
    return {
      aesKey: aesKey as CryptoKey,
      aesKeyBase64: aesKeyBase64 as string
    };
  } catch (error) {
    console.error("❌ AES-256密钥生成失败(PCI合规校验不通过):", error);
    throw error;
  } finally {
    // ------------------------------
    // 清空密钥相关内存(PCI核心要求)
    // ------------------------------
    if (aesKeyRaw) clearMemory(new Uint8Array(aesKeyRaw));
    if (aesKeyUint8) clearMemory(aesKeyUint8);
    if (decodedBase64) clearMemory(decodedBase64);

    // 清空临时变量
    aesKeyRaw = null;
    aesKeyUint8 = null;
    decodedBase64 = null;
  }
};

3.2 AES 加密业务数据

/**
 * AES加密敏感数据(卡号/CVV2等)- GCM模式(PCI 4.0推荐)
 * @param formData 明文敏感数据
 * @param aesKey 前端生成的AES密钥(CryptoKey对象,256位)
 * @param aesKeyBase64 (冗余参数,仅兼容调用逻辑)
 * @returns { encryptedData: 密文(Base64), iv: 向量(Base64), authTag: 认证标签(Base64) }
 */
export const aesEncrypt = async (formData: PaymentFormData, aesKey: CryptoKey,): Promise<AESEncryptResult> => {
  let pureData: PaymentFormData = {
    trade_sn: '',
    card_num: '',
    holder_name: '',
    expiry_year: '',
    expiry_month: '',
    cvv: ''
  };
  let iv: Uint8Array<ArrayBuffer> | null = null;
  let ivBase64: string | null = null;
  let formDataBuffer: Uint8Array<ArrayBuffer> | null = null;
  let encryptedBuffer: ArrayBuffer | null = null;
  let encryptedDataBuffer: Uint8Array | null = null;
  let authTagBuffer: Uint8Array | null = null;
  let encryptedDataBase64: string | null = null;
  let authTagBase64: string | null = null;

  if (!formData.trade_sn || !formData.card_num || !formData.holder_name || !formData.expiry_year || !formData.expiry_month || !formData.cvv) {
    throw new Error(`[PCI合规] 敏感数据不能为空`);
  }

  try {
    // 1. 双重XSS过滤:确保数据纯净
    // 直接调用分类型xssFilter,无需正则判断(更精准)
    pureData.trade_sn = xssFilter(formData.trade_sn, 'trade_sn');
    pureData.card_num = xssFilter(formData.card_num, 'card_num');
    pureData.holder_name = xssFilter(formData.holder_name, 'holder_name');
    pureData.expiry_year = xssFilter(formData.expiry_year, 'expiry_year');
    pureData.expiry_month = xssFilter(formData.expiry_month, 'expiry_month');
    pureData.cvv = xssFilter(formData.cvv, 'cvv');

    // 空值校验:任一核心字段过滤后为空则抛错
    if (!pureData.card_num || !pureData.cvv) {
      throw new Error('[PCI合规] 卡号/CVV过滤后为空,无法加密');
    }

    // 2. 生成12字节随机IV(PCI合规)
    iv = crypto.getRandomValues(new Uint8Array(12));
    ivBase64 = btoa(String.fromCharCode(...iv));
    console.log("🚀 ~ aesEncrypt ~ ivBase64:", ivBase64)

    // 3. AES-GCM加密(原生API)
    const formDataStr = JSON.stringify(pureData);
    console.log("🚀 ~ aesEncrypt ~ formDataStr:", formDataStr)
    const encoder = new TextEncoder();
    formDataBuffer = encoder.encode(formDataStr);
    encryptedBuffer = await crypto.subtle.encrypt(
      { name: "AES-GCM", iv: iv, tagLength: 128 },
      aesKey,
      formDataBuffer
    );

    // 4. 分离密文和authTag
    encryptedDataBuffer = new Uint8Array(encryptedBuffer.slice(0, encryptedBuffer.byteLength - 16));
    authTagBuffer = new Uint8Array(encryptedBuffer.slice(encryptedBuffer.byteLength - 16));

    // 5. 转换为Base64格式
    encryptedDataBase64 = btoa(String.fromCharCode(...encryptedDataBuffer));
    authTagBase64 = btoa(String.fromCharCode(...authTagBuffer));
    console.log("🚀 ~ aesEncrypt ~ encryptedDataBase64:", encryptedDataBase64)
    console.log("🚀 ~ aesEncrypt ~ authTagBase64:", authTagBase64)

    // 返回加密结果(仅返回必要的Base64字符串,不返回原始二进制)
    return {
      encryptedData: encryptedDataBase64,
      iv: ivBase64,
      authTag: authTagBase64
    };
  } catch (error) {
    console.error('[PCI合规] AES加密失败:', error);
    throw new Error('敏感数据加密失败,请重试');
  } finally {
    // ------------------------------
    // 核心:清空所有敏感内存(PCI DSS 4.0强制要求)
    // ------------------------------
    // 清空明文/过滤后数据
    pureData = clearMemory(pureData);

    // 清空二进制数据(逐字节置0,最关键)
    if (iv) clearMemory(iv);
    if (formDataBuffer) clearMemory(formDataBuffer);
    if (encryptedBuffer) clearMemory(new Uint8Array(encryptedBuffer));
    if (encryptedDataBuffer) clearMemory(encryptedDataBuffer);
    if (authTagBuffer) clearMemory(authTagBuffer);

    // 清空Base64临时变量
    ivBase64 = clearMemory(ivBase64);
    encryptedDataBase64 = clearMemory(encryptedDataBase64);
    authTagBase64 = clearMemory(authTagBase64);

    console.log("✅ 敏感数据内存已清空(符合PCI DSS 4.0要求)");
  }
};

3.3 RSA 公钥加密 AES 密钥

/**
 * 4. RSA加密AES密钥(防止密钥明文传输,符合PCI Req 3.6)
 * @param aesKey 前端生成的AES密钥(Base64格式)
 * @param publicKey 后端下发的RSA公钥(2048位,PEM格式)
 * @returns 加密后的AES密钥
 */
export const rsaEncryptAesKey = (aesKey: string, publicKey: string): string => {
  // 声明所有敏感变量(便于finally块统一清空)
  let keyBuffer: any = null;
  let encryptor: any = null;
  let encryptedKey: string | false | null = null;
  let tempAesKey: string | null = aesKey; // 临时引用明文AES密钥
  let tempPublicKey: string | null = publicKey; // 临时引用公钥

  // 前置校验:避免无效加密
  if (!aesKey || !publicKey) throw new Error('AES密钥/公钥不能为空');

  try {
    // ========== 原有PCI合规校验逻辑(保留) ==========
    // 校验1:AES密钥必须为16字节(128位)/32字节(256位,兼容你之前的256位密钥)
    keyBuffer = CryptoJS.enc.Base64.parse(aesKey);
    if (![16, 32].includes(keyBuffer.sigBytes)) { // 兼容128/256位密钥
      throw new Error(`[PCI合规] AES密钥必须为16字节(128位)或32字节(256位),当前:${keyBuffer.sigBytes}字节`);
    }

    // 严格校验RSA公钥格式(2048位PEM格式,PCI Req 3.5)
    if (
      !publicKey.includes("-----BEGIN PUBLIC KEY-----") ||
      !publicKey.includes("-----END PUBLIC KEY-----") ||
      publicKey.length < 200
    ) {
      throw new Error("[PCI合规] RSA公钥必须为2048位PEM格式");
    }

    // ========== RSA加密核心逻辑(保留) ==========
    const encryptor = new JSEncrypt({ default_key_size: "2048" });
    encryptor.setPublicKey(publicKey);
    encryptedKey = encryptor.encrypt(aesKey);

    if (!encryptedKey) {
      throw new Error("RSA加密AES密钥失败(PCI合规校验失败)");
    }

    return encryptedKey;
  } catch (error) {
    console.error('[PCI合规] RSA加密AES密钥失败:', error);
    throw new Error('RSA加密密钥失败,请检查公钥格式或密钥长度');
  } finally {
    // ========== 核心改造:彻底清空所有敏感内存(PCI核心要求) ==========
    // 1. 清空明文AES密钥(最核心:切断引用+覆盖)
    tempAesKey = clearMemory(tempAesKey);
    aesKey = clearMemory(aesKey); // 直接清空入参的明文密钥

    // 2. 清空Base64解析后的密钥二进制(逐字节置0)
    if (keyBuffer && keyBuffer.words) {
      // CryptoJS WordArray:覆盖内部存储的密钥数据
      keyBuffer.words.fill(0);
      keyBuffer.sigBytes = 0;
    }

    // 3. 清空RSA公钥(切断引用)
    tempPublicKey = clearMemory(tempPublicKey);
    publicKey = clearMemory(publicKey);

    // 4. 清空加密器对象(切断引用,防止残留密钥)
    if (encryptor) {
      encryptor = clearMemory(encryptor);
    }

    // 5. 清空临时变量(切断所有引用)
    keyBuffer = null;
    encryptedKey = null;

    console.log("✅ RSA加密环节敏感内存已清空(符合PCI DSS 4.0要求)");
  }
};

3.4 xss过滤

import type { PaymentFormData } from '@/api/pay';
import DOMPurify from 'dompurify';

/**
 * 通用XSS过滤核心函数(仅做输入清洗,不做业务校验)
 * @param input 原始输入
 * @param filterType 数据类型:cardNumber/cardName/year/month/cvv
 * @returns 过滤后的干净数据(仅移除危险字符,保留基础格式)
 */
export const xssFilter = (
    input: string,
    filterType: keyof PaymentFormData
): string => {
    // 1. 空值/非字符串兜底
    if (typeof input !== 'string' || input.trim() === '') {
        console.warn(`[PCI合规] XSS过滤:${filterType}输入为空或非字符串`);
        return '';
    }

    // 2. 预处理:移除Unicode危险字符、控制字符
    const preProcessed = input
        .replace(/[\u2000-\u200F\u2028-\u202F\u3000]/g, '') // 移除Unicode空白符
        .replace(/[\xFF\xFE\x00-\x1F]/g, '') // 移除控制字符/不可打印字符
        .trim();

    // 3. 基础XSS净化(禁用所有HTML标签/属性,仅保留纯文本)
    const pureInput = DOMPurify.sanitize(preProcessed, {
        USE_PROFILES: { html: false, svg: false, mathMl: false },
        FORBID_TAGS: ['*'],
        FORBID_ATTR: ['*'],
        ALLOWED_TAGS: [],
        ALLOWED_ATTR: [],
        RETURN_TRUSTED_TYPE: false,
    });

    // 4. 分类型过滤(仅保留该类型允许的字符,不做长度/范围校验)
    let filteredInput = '';
    switch (filterType) {
        case 'trade_sn':
            // 仅保留半角数字和英文字母(移除空格/分隔符/全角字符/特殊符号等)
            filteredInput = pureInput.replace(/[^a-zA-Z0-9]/g, '');
            break;

        case 'card_num':
            // 仅保留半角数字(移除空格/分隔符/全角数字等),不校验长度
            filteredInput = pureInput.replace(/[^\d]/g, '');
            break;

        case 'holder_name':
            // 保留:中英文、空格、点号,移除危险符号,不校验长度
            filteredInput = pureInput
                .replace(/[<>"'&;()\\/`$%@*=+{}[\]|~^]/g, '')
                .replace(/[^\u4e00-\u9fa5a-zA-Z\s.]/g, '');
            break;

        case 'expiry_year':
            // 仅保留数字(移除分隔符),不校验长度/范围
            filteredInput = pureInput.replace(/[^\d]/g, '');
            break;

        case 'expiry_month':
            // 仅保留数字(移除分隔符),不校验长度/范围
            filteredInput = pureInput.replace(/[^\d]/g, '');
            break;

        case 'cvv':
            // 仅保留半角数字,不校验长度
            filteredInput = pureInput.replace(/[^\d]/g, '');
            break;
    }

    // 5. 审计日志(脱敏展示)
    if (input !== filteredInput) {
        console.info(
            `[PCI合规] XSS过滤:${filterType}已净化`,
            { original: input.slice(0, 20), filtered: filteredInput.slice(0, 20) }
        );
    }

    return filteredInput;
};

3.5 添加防重放 + 内存清空函数

// utils/index.ts

import type { AntiReplayParams } from "@/types/crypto";
import CryptoJS from "crypto-js";


/**
 * 内存清空工具函数(PCI DSS 4.0核心要求)
 * 覆盖敏感数据所在的变量/数组,防止内存驻留泄露
 * @param target 待清空的目标(字符串/数组/Uint8Array等)
 */
export const clearMemory = (target: any): any => {
    if (typeof target === 'string') {
        // 字符串:用空字符覆盖(JS字符串不可变,需重新赋值)
        return '';
    } else if (target instanceof Uint8Array) {
        // Uint8Array(密钥/IV/密文):逐字节置0(核心清空逻辑)
        for (let i = 0; i < target.length; i++) {
            target[i] = 0;
        }
    } else if (Array.isArray(target)) {
        // 数组:清空并填充空值
        target.length = 0;
        target.fill(null);
    } else if (typeof target === 'object' && target !== null) {
        // 对象:遍历属性置空
        for (const key in target) {
            if (target.hasOwnProperty(key)) {
                target[key] = null;
            }
        }
    }
};

/**
 * 5. 生成Web端设备指纹(适配无POS硬件的场景)
 * 基于浏览器特征生成唯一标识(非绝对唯一,但满足PCI轻量鉴权)
 */
const generateDeviceFingerprint = (): string => {
    const navigatorInfo = [
        navigator.userAgent,
        navigator.language,
        navigator.platform,
        navigator.hardwareConcurrency,
        screen.width,
        screen.height,
        screen.colorDepth,
    ].join("_");
    // 哈希处理:避免明文传输设备信息(PCI Req 6.2)
    return CryptoJS.SHA256(navigatorInfo).toString();
};

/**
 * 6:生成防重放参数(独立抽离,适配前端生成AES密钥场景)
 * 保留PCI合规要求的核心参数:会话ID+设备指纹+随机数+时间戳
 */
export const generateAntiReplayParams = (): AntiReplayParams => {
    // 单次会话唯一ID(防重放核心:每个请求生成唯一值)
    let sessionId: string = CryptoJS.lib.WordArray.random(32).toString();
    // 设备指纹(绑定请求来源,防止跨设备重放)
    let deviceFingerprint: string = generateDeviceFingerprint();
    // 随机数(不可预测性,防止按规律伪造)
    let nonce: string = CryptoJS.lib.WordArray.random(16).toString();
    // 时间戳(后端校验有效期,比如5分钟内有效)
    const timestamp = Date.now();

    // 缓存供提交时使用
    (window as any).PCI_SESSION_ID = sessionId;
    (window as any).PCI_DEVICE_FP = deviceFingerprint;

    // 返回前清空临时变量(防重放参数本身非敏感,但缓存需管控)
    const returnParams = { sessionId, deviceFingerprint, nonce, timestamp };
    // 切断临时变量引用
    sessionId = '';
    deviceFingerprint = '';
    nonce = '';

    return returnParams;
};

四、完整的代码

1. AES.ts

import type { AESEncryptResult, GenerateAes256KeyResult } from "@/types/crypto";
import { clearMemory } from ".";
import { xssFilter } from "./xssFilter";
import type { PaymentFormData } from "@/api/pay";

/**
 * 生成合规的256位AES密钥(含内存清空)
 * @returns {Object} aesKey(CryptoKey) + aesKeyBase64(Base64)
 */
export const generateCompliantAes256Key = async (): Promise<GenerateAes256KeyResult> => {
  let aesKey: CryptoKey | null = null;
  let aesKeyRaw: ArrayBuffer | null = null;
  let aesKeyUint8: Uint8Array | null = null;
  let aesKeyBase64: string | null = null;
  let decodedBase64: Uint8Array | null = null;

  try {
    // 1. 生成256位AES-GCM密钥
    aesKey = await crypto.subtle.generateKey(
      { name: "AES-GCM", length: 256 },
      true,
      ["encrypt", "decrypt"],
    );

    // 2. 提取密钥为原始二进制
    aesKeyRaw = await crypto.subtle.exportKey("raw", aesKey);
    aesKeyUint8 = new Uint8Array(aesKeyRaw);

    // 3. PCI合规校验:密钥长度必须为32字节
    if (aesKeyUint8.length !== 32) {
      throw new Error(`[PCI合规] AES-256密钥长度必须为32字节,当前:${aesKeyUint8.length}`);
    }

    // 4. 转换为Base64格式
    aesKeyBase64 = btoa(String.fromCharCode(...aesKeyUint8));

    // 5. 二次校验
    decodedBase64 = Uint8Array.from(atob(aesKeyBase64), (c) => c.charCodeAt(0));
    if (decodedBase64.length !== 32) {
      throw new Error(`[PCI合规] AES密钥Base64转换后长度异常,当前:${decodedBase64.length}`);
    }

    console.log("✅ AES-256密钥生成成功(符合PCI DSS 4.0要求)");
    return {
      aesKey: aesKey as CryptoKey,
      aesKeyBase64: aesKeyBase64 as string
    };
  } catch (error) {
    console.error("❌ AES-256密钥生成失败(PCI合规校验不通过):", error);
    throw error;
  } finally {
    // ------------------------------
    // 清空密钥相关内存(PCI核心要求)
    // ------------------------------
    if (aesKeyRaw) clearMemory(new Uint8Array(aesKeyRaw));
    if (aesKeyUint8) clearMemory(aesKeyUint8);
    if (decodedBase64) clearMemory(decodedBase64);

    // 清空临时变量
    aesKeyRaw = null;
    aesKeyUint8 = null;
    decodedBase64 = null;
  }
};

/**
 * AES加密敏感数据(卡号/CVV2等)- GCM模式(PCI 4.0推荐)
 * @param formData 明文敏感数据
 * @param aesKey 前端生成的AES密钥(CryptoKey对象,256位)
 * @param aesKeyBase64 (冗余参数,仅兼容调用逻辑)
 * @returns { encryptedData: 密文(Base64), iv: 向量(Base64), authTag: 认证标签(Base64) }
 */
export const aesEncrypt = async (formData: PaymentFormData, aesKey: CryptoKey,): Promise<AESEncryptResult> => {
  let pureData: PaymentFormData = {
    trade_sn: '',
    card_num: '',
    holder_name: '',
    expiry_year: '',
    expiry_month: '',
    cvv: ''
  };
  let iv: Uint8Array<ArrayBuffer> | null = null;
  let ivBase64: string | null = null;
  let formDataBuffer: Uint8Array<ArrayBuffer> | null = null;
  let encryptedBuffer: ArrayBuffer | null = null;
  let encryptedDataBuffer: Uint8Array | null = null;
  let authTagBuffer: Uint8Array | null = null;
  let encryptedDataBase64: string | null = null;
  let authTagBase64: string | null = null;

  if (!formData.trade_sn || !formData.card_num || !formData.holder_name || !formData.expiry_year || !formData.expiry_month || !formData.cvv) {
    throw new Error(`[PCI合规] 敏感数据不能为空`);
  }

  try {
    // 1. 双重XSS过滤:确保数据纯净
    // 直接调用分类型xssFilter,无需正则判断(更精准)
    pureData.trade_sn = xssFilter(formData.trade_sn, 'trade_sn');
    pureData.card_num = xssFilter(formData.card_num, 'card_num');
    pureData.holder_name = xssFilter(formData.holder_name, 'holder_name');
    pureData.expiry_year = xssFilter(formData.expiry_year, 'expiry_year');
    pureData.expiry_month = xssFilter(formData.expiry_month, 'expiry_month');
    pureData.cvv = xssFilter(formData.cvv, 'cvv');

    // 空值校验:任一核心字段过滤后为空则抛错
    if (!pureData.card_num || !pureData.cvv) {
      throw new Error('[PCI合规] 卡号/CVV过滤后为空,无法加密');
    }

    // 2. 生成12字节随机IV(PCI合规)
    iv = crypto.getRandomValues(new Uint8Array(12));
    ivBase64 = btoa(String.fromCharCode(...iv));
    console.log("🚀 ~ aesEncrypt ~ ivBase64:", ivBase64)

    // 3. AES-GCM加密(原生API)
    const formDataStr = JSON.stringify(pureData);
    console.log("🚀 ~ aesEncrypt ~ formDataStr:", formDataStr)
    const encoder = new TextEncoder();
    formDataBuffer = encoder.encode(formDataStr);
    encryptedBuffer = await crypto.subtle.encrypt(
      { name: "AES-GCM", iv: iv, tagLength: 128 },
      aesKey,
      formDataBuffer
    );

    // 4. 分离密文和authTag
    encryptedDataBuffer = new Uint8Array(encryptedBuffer.slice(0, encryptedBuffer.byteLength - 16));
    authTagBuffer = new Uint8Array(encryptedBuffer.slice(encryptedBuffer.byteLength - 16));

    // 5. 转换为Base64格式
    encryptedDataBase64 = btoa(String.fromCharCode(...encryptedDataBuffer));
    authTagBase64 = btoa(String.fromCharCode(...authTagBuffer));
    console.log("🚀 ~ aesEncrypt ~ encryptedDataBase64:", encryptedDataBase64)
    console.log("🚀 ~ aesEncrypt ~ authTagBase64:", authTagBase64)

    // 返回加密结果(仅返回必要的Base64字符串,不返回原始二进制)
    return {
      encryptedData: encryptedDataBase64,
      iv: ivBase64,
      authTag: authTagBase64
    };
  } catch (error) {
    console.error('[PCI合规] AES加密失败:', error);
    throw new Error('敏感数据加密失败,请重试');
  } finally {
    // ------------------------------
    // 核心:清空所有敏感内存(PCI DSS 4.0强制要求)
    // ------------------------------
    // 清空明文/过滤后数据
    pureData = clearMemory(pureData);

    // 清空二进制数据(逐字节置0,最关键)
    if (iv) clearMemory(iv);
    if (formDataBuffer) clearMemory(formDataBuffer);
    if (encryptedBuffer) clearMemory(new Uint8Array(encryptedBuffer));
    if (encryptedDataBuffer) clearMemory(encryptedDataBuffer);
    if (authTagBuffer) clearMemory(authTagBuffer);

    // 清空Base64临时变量
    ivBase64 = clearMemory(ivBase64);
    encryptedDataBase64 = clearMemory(encryptedDataBase64);
    authTagBase64 = clearMemory(authTagBase64);

    console.log("✅ 敏感数据内存已清空(符合PCI DSS 4.0要求)");
  }
};

2. RSA.ts

import { getRsaPublicKey } from "@/api/pay";
import { clearMemory } from ".";
import CryptoJS from "crypto-js";
import JSEncrypt from "jsencrypt";

// 计算字符串的SHA256哈希值(转为十六进制)
export async function calculateSHA256Hash(publicKeyPem: string): Promise<string> {
  try {
    // 1. 将字符串转为 UTF-8 二进制(Go 的 []byte(str) 等价 UTF-8 编码)
    const encoder = new TextEncoder();
    const binary = encoder.encode(publicKeyPem.replace(/\r\n/g, '\n').trim());

    // 2. 计算 SHA256 哈希
    const hashBuffer = await crypto.subtle.digest("SHA-256", binary);

    // 3. 转换为十六进制字符串(补零确保两位)
    const hashArray = Array.from(new Uint8Array(hashBuffer));
    const hashHex = hashArray.map(b => b.toString(16).padStart(2, "0")).join("");

    return hashHex;
  } catch (error) {
    console.error("SHA256 计算失败:", error);
    throw new Error("哈希计算失败,请检查输入");
  }
}

// 3. 获取公钥+公钥校验(PCI Req 8:公钥校验)
export const getRsaPublicKeyFn = async (): Promise<string> => {
  // 1. 基础数据准备(Web环境)
  // 从后端获取RSA公钥(Web场景:页面初始化时拉取,缓存到内存,禁止持久化)
  const { public_key } = await getRsaPublicKey()
  // 计算获取到的公钥的哈希值
  const receivedPublicKeyHash = await calculateSHA256Hash(public_key);
  console.log("前端计算的公钥哈希值:", receivedPublicKeyHash);
  console.log("环境变量中的公钥哈希值:", import.meta.env.VITE_PUBLIC_KEY_HASH);

  // 4. 对比哈希值,验证公钥是否被篡改
  if (receivedPublicKeyHash === import.meta.env.VITE_PUBLIC_KEY_HASH) {
    console.log("✅ 公钥校验通过,未被篡改!");
    // 校验通过后,才能使用该公钥进行后续的AES密钥加密
    return public_key;
  } else {
    console.error("❌ 公钥哈希值不匹配,公钥可能被篡改!");
    throw new Error("公钥校验失败,拒绝使用");
  }
};

/**
 * 4. RSA加密AES密钥(防止密钥明文传输,符合PCI Req 3.6)
 * @param aesKey 前端生成的AES密钥(Base64格式)
 * @param publicKey 后端下发的RSA公钥(2048位,PEM格式)
 * @returns 加密后的AES密钥
 */
export const rsaEncryptAesKey = (aesKey: string, publicKey: string): string => {
  // 声明所有敏感变量(便于finally块统一清空)
  let keyBuffer: any = null;
  let encryptor: any = null;
  let encryptedKey: string | false | null = null;
  let tempAesKey: string | null = aesKey; // 临时引用明文AES密钥
  let tempPublicKey: string | null = publicKey; // 临时引用公钥

  // 前置校验:避免无效加密
  if (!aesKey || !publicKey) throw new Error('AES密钥/公钥不能为空');

  try {
    // ========== 原有PCI合规校验逻辑(保留) ==========
    // 校验1:AES密钥必须为16字节(128位)/32字节(256位,兼容你之前的256位密钥)
    keyBuffer = CryptoJS.enc.Base64.parse(aesKey);
    if (![16, 32].includes(keyBuffer.sigBytes)) { // 兼容128/256位密钥
      throw new Error(`[PCI合规] AES密钥必须为16字节(128位)或32字节(256位),当前:${keyBuffer.sigBytes}字节`);
    }

    // 严格校验RSA公钥格式(2048位PEM格式,PCI Req 3.5)
    if (
      !publicKey.includes("-----BEGIN PUBLIC KEY-----") ||
      !publicKey.includes("-----END PUBLIC KEY-----") ||
      publicKey.length < 200
    ) {
      throw new Error("[PCI合规] RSA公钥必须为2048位PEM格式");
    }

    // ========== RSA加密核心逻辑(保留) ==========
    const encryptor = new JSEncrypt({ default_key_size: "2048" });
    encryptor.setPublicKey(publicKey);
    encryptedKey = encryptor.encrypt(aesKey);

    if (!encryptedKey) {
      throw new Error("RSA加密AES密钥失败(PCI合规校验失败)");
    }

    return encryptedKey;
  } catch (error) {
    console.error('[PCI合规] RSA加密AES密钥失败:', error);
    throw new Error('RSA加密密钥失败,请检查公钥格式或密钥长度');
  } finally {
    // ========== 核心改造:彻底清空所有敏感内存(PCI核心要求) ==========
    // 1. 清空明文AES密钥(最核心:切断引用+覆盖)
    tempAesKey = clearMemory(tempAesKey);
    aesKey = clearMemory(aesKey); // 直接清空入参的明文密钥

    // 2. 清空Base64解析后的密钥二进制(逐字节置0)
    if (keyBuffer && keyBuffer.words) {
      // CryptoJS WordArray:覆盖内部存储的密钥数据
      keyBuffer.words.fill(0);
      keyBuffer.sigBytes = 0;
    }

    // 3. 清空RSA公钥(切断引用)
    tempPublicKey = clearMemory(tempPublicKey);
    publicKey = clearMemory(publicKey);

    // 4. 清空加密器对象(切断引用,防止残留密钥)
    if (encryptor) {
      encryptor = clearMemory(encryptor);
    }

    // 5. 清空临时变量(切断所有引用)
    keyBuffer = null;
    encryptedKey = null;

    console.log("✅ RSA加密环节敏感内存已清空(符合PCI DSS 4.0要求)");
  }
};

3. xssFilter.ts

import type { PaymentFormData } from '@/api/pay';
import DOMPurify from 'dompurify';

/**
 * 通用XSS过滤核心函数(仅做输入清洗,不做业务校验)
 * @param input 原始输入
 * @param filterType 数据类型:cardNumber/cardName/year/month/cvv
 * @returns 过滤后的干净数据(仅移除危险字符,保留基础格式)
 */
export const xssFilter = (
    input: string,
    filterType: keyof PaymentFormData
): string => {
    // 1. 空值/非字符串兜底
    if (typeof input !== 'string' || input.trim() === '') {
        console.warn(`[PCI合规] XSS过滤:${filterType}输入为空或非字符串`);
        return '';
    }

    // 2. 预处理:移除Unicode危险字符、控制字符
    const preProcessed = input
        .replace(/[\u2000-\u200F\u2028-\u202F\u3000]/g, '') // 移除Unicode空白符
        .replace(/[\xFF\xFE\x00-\x1F]/g, '') // 移除控制字符/不可打印字符
        .trim();

    // 3. 基础XSS净化(禁用所有HTML标签/属性,仅保留纯文本)
    const pureInput = DOMPurify.sanitize(preProcessed, {
        USE_PROFILES: { html: false, svg: false, mathMl: false },
        FORBID_TAGS: ['*'],
        FORBID_ATTR: ['*'],
        ALLOWED_TAGS: [],
        ALLOWED_ATTR: [],
        RETURN_TRUSTED_TYPE: false,
    });

    // 4. 分类型过滤(仅保留该类型允许的字符,不做长度/范围校验)
    let filteredInput = '';
    switch (filterType) {
        case 'trade_sn':
            // 仅保留半角数字和英文字母(移除空格/分隔符/全角字符/特殊符号等)
            filteredInput = pureInput.replace(/[^a-zA-Z0-9]/g, '');
            break;

        case 'card_num':
            // 仅保留半角数字(移除空格/分隔符/全角数字等),不校验长度
            filteredInput = pureInput.replace(/[^\d]/g, '');
            break;

        case 'holder_name':
            // 保留:中英文、空格、点号,移除危险符号,不校验长度
            filteredInput = pureInput
                .replace(/[<>"'&;()\\/`$%@*=+{}[\]|~^]/g, '')
                .replace(/[^\u4e00-\u9fa5a-zA-Z\s.]/g, '');
            break;

        case 'expiry_year':
            // 仅保留数字(移除分隔符),不校验长度/范围
            filteredInput = pureInput.replace(/[^\d]/g, '');
            break;

        case 'expiry_month':
            // 仅保留数字(移除分隔符),不校验长度/范围
            filteredInput = pureInput.replace(/[^\d]/g, '');
            break;

        case 'cvv':
            // 仅保留半角数字,不校验长度
            filteredInput = pureInput.replace(/[^\d]/g, '');
            break;
    }

    // 5. 审计日志(脱敏展示)
    if (input !== filteredInput) {
        console.info(
            `[PCI合规] XSS过滤:${filterType}已净化`,
            { original: input.slice(0, 20), filtered: filteredInput.slice(0, 20) }
        );
    }

    return filteredInput;
};

4. index.ts

import type { AntiReplayParams } from "@/types/crypto";
import CryptoJS from "crypto-js";


/**
 * 内存清空工具函数(PCI DSS 4.0核心要求)
 * 覆盖敏感数据所在的变量/数组,防止内存驻留泄露
 * @param target 待清空的目标(字符串/数组/Uint8Array等)
 */
export const clearMemory = (target: any): any => {
    if (typeof target === 'string') {
        // 字符串:用空字符覆盖(JS字符串不可变,需重新赋值)
        return '';
    } else if (target instanceof Uint8Array) {
        // Uint8Array(密钥/IV/密文):逐字节置0(核心清空逻辑)
        for (let i = 0; i < target.length; i++) {
            target[i] = 0;
        }
    } else if (Array.isArray(target)) {
        // 数组:清空并填充空值
        target.length = 0;
        target.fill(null);
    } else if (typeof target === 'object' && target !== null) {
        // 对象:遍历属性置空
        for (const key in target) {
            if (target.hasOwnProperty(key)) {
                target[key] = null;
            }
        }
    }
};

/**
 * 5. 生成Web端设备指纹(适配无POS硬件的场景)
 * 基于浏览器特征生成唯一标识(非绝对唯一,但满足PCI轻量鉴权)
 */
const generateDeviceFingerprint = (): string => {
    const navigatorInfo = [
        navigator.userAgent,
        navigator.language,
        navigator.platform,
        navigator.hardwareConcurrency,
        screen.width,
        screen.height,
        screen.colorDepth,
    ].join("_");
    // 哈希处理:避免明文传输设备信息(PCI Req 6.2)
    return CryptoJS.SHA256(navigatorInfo).toString();
};

/**
 * 6:生成防重放参数(独立抽离,适配前端生成AES密钥场景)
 * 保留PCI合规要求的核心参数:会话ID+设备指纹+随机数+时间戳
 */
export const generateAntiReplayParams = (): AntiReplayParams => {
    // 单次会话唯一ID(防重放核心:每个请求生成唯一值)
    let sessionId: string = CryptoJS.lib.WordArray.random(32).toString();
    // 设备指纹(绑定请求来源,防止跨设备重放)
    let deviceFingerprint: string = generateDeviceFingerprint();
    // 随机数(不可预测性,防止按规律伪造)
    let nonce: string = CryptoJS.lib.WordArray.random(16).toString();
    // 时间戳(后端校验有效期,比如5分钟内有效)
    const timestamp = Date.now();

    // 缓存供提交时使用
    (window as any).PCI_SESSION_ID = sessionId;
    (window as any).PCI_DEVICE_FP = deviceFingerprint;

    // 返回前清空临时变量(防重放参数本身非敏感,但缓存需管控)
    const returnParams = { sessionId, deviceFingerprint, nonce, timestamp };
    // 切断临时变量引用
    sessionId = '';
    deviceFingerprint = '';
    nonce = '';

    return returnParams;
};

5. crypto.ts

/**
 * PCI SQD-D 合规 前后端加密传输工具类
 * 适配Web网页无token场景:移除token依赖,改用会话ID+设备指纹鉴权
 * 符合PCI DSS 4.0要求:AES-128-GCM + RSA-2048 + 严格XSS过滤 + 密钥管控
 * 依赖:crypto-js jsencrypt dompurify
 */
import { rsaEncryptAesKey } from "./RSA";
import { aesEncrypt, generateCompliantAes256Key } from "./AES";
import { clearMemory, generateAntiReplayParams } from ".";
import type { AESEncryptResult, AntiReplayParams, EncryptedData } from "@/types/crypto";
import type { PaymentFormData } from "@/api/pay";

/**
 * 完整加密流程(前端生成AES密钥 + 防重放参数)
 */
export const pciEncrypt = async (sensitiveData: PaymentFormData, publicKey: string): Promise<EncryptedData> => {
  // 声明所有敏感变量(便于finally块统一清空)
  let antiReplayParams: AntiReplayParams | null = null;
  let aesKey: CryptoKey | null = null;
  let aesKeyBase64: string | null = null;
  let tempSensitiveData: PaymentFormData | null = sensitiveData;
  let encryptedResult: AESEncryptResult | null = null;
  let encryptedAesKey: string | null = null;

  try {
    // 1. 生成防重放参数(核心:保留PCI要求的唯一性校验)
    antiReplayParams = generateAntiReplayParams();

    // 2. 前端生成合规AES密钥(替代后端获取)
    const keyResult = await generateCompliantAes256Key();
    aesKey = keyResult.aesKey;
    aesKeyBase64 = keyResult.aesKeyBase64;
    console.log("🚀 ~ aesKey:", aesKey)
    console.log("🚀 ~ aesKeyBase64:", aesKeyBase64)

    // 3. AES加密数据(仅传aesKey,移除冗余的aesKeyBase64参数)
    console.log('加密前', tempSensitiveData);
    encryptedResult = await aesEncrypt(
      tempSensitiveData as PaymentFormData,
      aesKey as CryptoKey
    );
    console.log("🚀 ~ tempSensitiveData:", tempSensitiveData)
    console.log("🚀 ~ aesKey:", aesKey)
    console.log("🚀 ~ encryptedResult:", encryptedResult)

    // 4. RSA加密AES密钥(符合PCI Req 3.6,防止密钥明文传输)
    encryptedAesKey = rsaEncryptAesKey(aesKeyBase64 as string, publicKey);

    // 5. 组装返回结果
    return {
      value1: encryptedAesKey,
      value2: encryptedResult.iv + encryptedResult.encryptedData,
      value3: encryptedResult.authTag,
      value4: JSON.stringify({
        ...antiReplayParams,
      }),
    };
  } catch (error) {
    console.error('[PCI合规] 完整加密流程失败:', error);
    throw new Error('敏感数据加密失败,请检查参数或重试');
  } finally {
    // ========== 核心:全链路清空敏感内存(PCI DSS 4.0强制要求) ==========
    // 1. 清空明文敏感数据(字符串直接置空)
    tempSensitiveData = null;
    // 入参sensitiveData是函数参数,执行完自动销毁,无需处理

    // 2. 清空AES密钥(CryptoKey对象+Base64格式)
    if (aesKey) {
      clearMemory(aesKey); // 清空CryptoKey对象属性
      aesKey = null; // 切断引用
    }
    if (aesKeyBase64) {
      aesKeyBase64 = ''; // Base64密钥置空
    }

    // 3. 清空加密结果临时变量(切断引用)
    if (encryptedResult) {
      encryptedResult.encryptedData = '';
      encryptedResult.iv = '';
      encryptedResult.authTag = '';
      encryptedResult = null;
    }

    // 4. 清空RSA加密后的密钥(仅临时变量,返回值保留)
    if (encryptedAesKey) {
      encryptedAesKey = '';
    }

    // 5. 清空防重放参数临时变量(返回值保留,仅清空引用)
    if (antiReplayParams) {
      antiReplayParams.sessionId = '';
      antiReplayParams.deviceFingerprint = '';
      antiReplayParams.nonce = '';
      antiReplayParams = null;
    }

    // 6. 清空window缓存的防重放参数(PCI要求:不长期留存)
    (window as any).PCI_SESSION_ID = '';
    (window as any).PCI_DEVICE_FP = '';

    console.log("✅ 完整加密流程敏感内存已清空(符合PCI DSS 4.0要求)");
  }
};

七、总结

RSA + AES 混合加密方案的核心优势:

优势 说明
安全性 即使 RSA 私钥泄露,历史数据也无法被解密(前向安全)
性能 AES 对称加密处理大数据量,性能优异
易用性 前端只需持有公钥,无需管理私钥
灵活性 可扩展支持签名、防重放等安全特性

适用场景

  • 金融交易数据(支付信息、账户信息)
  • 医疗健康数据(患者隐私信息)
  • 企业核心业务数据(财务报表、客户资料)
  • 任何需要端到端加密的高安全场景

注意事项

  • RSA 密钥长度建议 2048 位以上
  • AES 密钥长度建议 256 位
  • 使用安全的随机数生成器
  • 定期轮换 RSA 密钥对
  • 在生产环境使用 HTTPS + 混合加密双重保障

通过这套方案,可以确保业务数据在全链路传输过程中的机密性,满足 PCI-DSS、HIPAA 等高安全标准的要求。

零经验学 react 的第6天 - 循环渲染和条件渲染

作者 somebody
2026年3月20日 17:47

一、循环渲染和条件渲染要点

  • 使用 map 或者 filter 来循环渲染, 循环项绑定 key,提高性能
  • 使用 三元表达式、&&、if..else 来进行条件渲染

二、 注意点

  • 条件渲染中,如果不需要返回一些东西,可以 return null
  • 条件渲染中 num && message, 当 num=0 最终效果会渲染 0, 所以应该写成 num>0 && message

三、示例代码

函数组件示例代码

import { useState } from "react";

function Test1 () {
    const [a, setA] = useState(1);
    const [b, setB] = useState(11);
    const [arr, setArr] = useState([1, 2]);
    const [arr1, setArr1] = useState([22, 33]);
    const [obj, setObj] = useState({x: 1, y: 2});
    const [show, setShow] = useState(true);
    
    // 循环渲染
    const renderList = () => {
        let list = [];
        arr1.forEach((item, index) => {
            list.push(<p key={index}>{index}---{item}</p>);
        });
        return list;
    }
    // 过滤循环渲染
    const renderFilteredList = () => {
        let list = [];
        arr1.filter(item => item % 2 === 0).forEach((item, index) => {
            list.push(<p key={index}>{index}---{item}</p>);
        });
        return list;
    }
    // 条件渲染
    const renderContent = () => {
        if(show) {
            return <p>使用if else1111</p>;
        } else {
            return <p>使用if else2222</p>;
        }
    }
    return (
        <div className="test1-box">
            <p>Test1</p>
            <div>
                {/* 循环渲染、过滤循环渲染 */}
                <div>
                    <p>循环渲染</p>
                    <div>
                        {
                            arr.map((item, index) => (
                                <p key={index}>{index}---{item}</p>
                            ))
                        }
                    </div>
                    <p>使用函数来处理循环渲染的逻辑</p>
                    <div> {renderList()}</div>
                    <p>过滤循环渲染</p>
                    <div>
                        {
                            arr.filter(item => item % 2 === 0).map((item,index) => {
                                return <p key={index}>{index}==={item}</p>;
                            })
                        }
                    </div>
                    <p>使用函数来处理过滤循环渲染的逻辑</p>
                    <div>{renderFilteredList()}</div>
                </div>
                
                
                {/* 条件渲染:使用三元表达式、&&、if else */}
                <div>
                    <p>条件渲染</p>
                    <div>
                        <button type="button" onClick={() => setShow(!show)}>切换显示/隐藏</button>
                        {/* 使用三元表达式 */}
                        <div>{show ? <p>使用三元表达式1111</p> : <p>使用三元表达式2222</p>}</div>
                        {/* 使用&& */}
                        <div>{show && <p>使用&&1111</p>}</div>
                        {/* 使用if else */}
                        <div>{renderContent()}</div>
                    </div>
                </div>
            </div>
        </div>
    )
}
export default Test1;

类组件示例代码

import React from 'react';

class Test2 extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            a: 1,
            b: 11,
            arr: [1,2],
            arr1: [22,33],
            obj: {x: 1, y: 2},
            show: true
        }
    }
    // 循环渲染
    renderList() {
        let list = [];
        this.state.arr1.forEach((item, index) => {
            list.push(<p key={index}>{index}---{item}</p>);
        });
        return list;
    }
    // 过滤循环渲染
    renderFilteredList() {
        let list = [];
        this.state.arr1.filter(item => item % 2 === 0).forEach((item, index) => {
            list.push(<p key={index}>{index}---{item}</p>);
        });
        return list;
    }
    renderContent() {
        if(this.state.show) {
            return <p>使用if else1111</p>;
        } else {
            return <p>使用if else2222</p>;
        }
    }
    render() {
        return(
            <div className="test2-box">
                <p>Test2</p>
                <div>
                    {/* 循环渲染、过滤循环渲染 */}
                    <div>
                        <p>循环渲染</p>
                        <div>
                            {
                                this.state.arr.map((item, index) => {
                                    return <p key={index}>{index}---{item}</p>
                                })
                            }
                        </div>
                        <p>使用函数来处理循环渲染的逻辑</p>
                        <div>{this.renderList()}</div>
                        <p>过滤循环渲染</p>
                        <div>
                            {
                                this.state.arr.filter((item) => item % 2 === 0).map((item,index) => {
                                    return <p key={index}>{index}---{item}</p>
                                })
                            }
                        </div>
                        <p>使用函数来处理过滤循环渲染的逻辑</p>
                        <div>{this.renderFilteredList()}</div>
                    </div>
                    {/* 条件渲染:使用三元表达式、&& if..else */}
                    <div>
                        <p>条件渲染</p>
                        <button type="button" onClick={() => this.setState({show: !this.state.show})}>切换显示/隐藏</button>
                        {/* 使用三元表达式 */}
                        <div>{this.state.show ? <p>使用三元表达式1111</p> : <p>使用三元表达式2222</p>}</div>
                        {/* 使用&& */}
                        <div>{this.state.show && <p>使用&& 1111</p>}</div>
                        {/* if..else */}
                        <div>{this.renderContent()}</div>
                    </div>
                </div>
            </div>
        )
    }
}
export default Test2;

不使用微前端:如何实现主应用和子模块动态管理与通信实现

作者 墨鱼笔记
2026年3月20日 17:28

1. 架构概述

moyu 采用 主应用 + 子模块 的微前端架构模式,通过 MoyuConfig 全局配置中心实现模块间的解耦和通信。整个架构支持动态模块加载、按需启用、跨模块组件调用等功能。

exported_image.png

2. 项目结构

web_master/
├── src/                    # 主应用代码
├── modules/                # 子模块目录
│   ├── moyu-systemset-page/    # 系统设置模块
│   ├── moyu-assetmanage-page/  # 资产管理模块
│   └── ...                 # 其他子模块
├── static/js/config.js     # 全局配置中心
├── childModule.json        # 模块配置清单
├── src/development_extension.js # 开发环境模块导入文件
└── build/                  # 构建配置

3. 子项目创建流程

3.1 模块目录结构

每个子模块遵循统一的目录结构:

modules/moyu-xxx-page/
├── src/
│   ├── index.js            # 模块入口文件
│   ├── router.js           # 路由配置
│   ├── components/         # 组件目录
│   └── pages/              # 页面目录
└── package.json            # 模块包配置

3.2 模块入口文件 (index.js)

js// modules/moyu-systemset-page/src/index.js
import { pushComponent } from 'MoyuConfig'
import store from './store.js'
import router from './router.js'
import customSetMixin from './home/CustomSetMixin.js'

const components = {
  sysOverview: () => import('./home/HomeCardPage.vue'),
  // ... 其他组件
};

pushComponent({
  key: 'systemSet',           // 模块唯一标识
  components: components,     // 组件集合
  router: router,             // 路由配置
  store: store                // 子模块状态
});

3.3 模块注册机制

  • key: 模块唯一标识,在全局配置中作为命名空间
  • components: 所有可被其他模块调用的组件
  • router: 模块路由配置
  • store: 子模块状态

4. 模块导入机制

4.1 配置驱动导入

childModule.json 配置文件

json{
  "sicap-systemset-page": {
    "open": true,
    "desc": "系统设置"
  },
  "sicap-assetmanage-page": {
    "open": false,
    "desc": "资产管理中心"
  }
}

自动化生成导入文件

js// build/service/developConfig.js
function createFile(allModulesData) {
    const modules = [];
    let allModules = {};
    if (!allModulesData) {
        const configContent = fs.readFileSync(path.resolve(__dirname, '../../childModule.json'), 'utf-8')
        allModules = JSON.parse(configContent);
    } else {
        allModules = allModulesData;
    }
    Object.keys(allModules).forEach(moduleName => {
        if (allModules[moduleName].open) {
            modules.push(`import '${moduleName}'`);   // 启用模块
        } else {
            modules.push(`// import '${moduleName}'`); // 注释禁用模块
        }
    });
    const developContent = modules.join('\n');
    fs.writeFileSync(path.resolve(__dirname, '../../src/development_extension.js'), developContent);
    console.log('子项目扩展已生成');
}

生成的导入文件

js// src/development_extension.js
import 'sicap-systemset-page'
// import 'sicap-assetmanage-page'
import 'sicap-operationaudit-page'

4.2 Webpack 别名配置

js// webpack.dev.conf.js
function getModuleAlias() {
  Object.keys(allModules).forEach(moduleName => {
    moduleAlias[moduleName] = path.resolve(__dirname, `../modules/${moduleName}/src/index.js`)
  });
  return moduleAlias;
}

resolve: {
  alias: webpackAlias  // 模块名 → 文件路径映射
}

5. 模块关联与通信

5.1 跨模块组件调用

使用 getComponentByName 调用其他模块组件

js// modules/sicap-identityauthe-page/src/pages/statisticsreport/CustomReport/CustomSetMixin.js
import { getComponentByName } from 'MoyuConfig';

// 从 operationAudit 模块获取 viewType1 组件
const viewType = getComponentByName('operationAudit', 'viewType'); // 运维审计中心
const AlarmView = getComponentByName('systemSet', 'AlarmView'); // 系统设置

export default {
  components: {
    viewType,
    AlarmView,
    // ... 其他组件
  }
};

getComponentByName 实现原理

js// static/js/config.js
exports.getComponentByName = function(moduleName, componentName, flag) {
  try {
    let module = getModuleByName(moduleName);  // 获取模块组件集合
    if(module[componentName]) {
      return module[componentName];            // 返回具体组件
    } else {
      throw new Error('Can not find component by name ' + componentName);
    }
  } catch (error) {
    if (flag) {
      // 返回错误提示组件
      return { template: '<s-alert title="请安装'+ moduleName + '模块,并导出' + componentName + '组件" type="error"></s-alert>' };
    } else {
      console.error(error.message)
    }
  }
}

5.2 模块数据存储结构

js// static/js/config.js 内部变量
var modules = {};  // 存储格式: { 'systemSet': { sysOverview: Component, ... }, 'operationAudit': { viewType1: Component, ... } }

6. Store 通信机制

6.1 子模块 Store 注册

子模块在 index.js 中可以提供 store 配置:

js// 子模块 index.js
const store = {
  assetStore: {
    state: { /* ... */ },
    mutations: { /* ... */ },
    actions: { /* ... */ }
  }
};

pushComponent({
  key: 'assetmanage',
  components: components,
  router: router,
  store: store  // 提供 store 配置
});

6.2 Store 自动注册到主应用

js// static/js/config.js - pushComponent 方法

// 创建全局对象
global.MoyuConfig = global.MoyuConfig || {}
factory(global.MoyuConfig)


if (component.store) {
  let stores = component.store;
  for (var key in stores) {
    if (Object.prototype.hasOwnProperty.call(stores, key))
      childStores.push({
        'key': key,           // store 模块名
        'value': stores[key]  // store 配置对象
      });
  }
}

6.3 主应用初始化 Store

js// src/main.js
const store = new Vuex.Store(rootStore);
const childStores = getChildStores();
for (let i = 0; i < childStores.length; i++) {
  store.registerModule(childStores[i].key, childStores[i].value);
}

6.4 跨模块 Store 访问

由于所有子模块的 store 都注册到了同一个 Vuex 实例中,因此可以在任意组件中访问:

js// 在任何组件中
this.$store.state.assetStore.someState
this.$store.dispatch('assetStore/someAction')

7. 路由集成机制

7.1 子模块路由配置

js// modules/sicap-logaudit-page/src/router.js
const rootRouter = [
  {
    path: '/logAudit',
    component: 'Home',
    name: 'logAudit',
    children: [],
    meta: { /* ... */ }
  }
];

const logAudit = [ /* 子路由配置 */ ];

export default {
  rootRouter,
  childRouter: {
    logAudit
  }
}

7.2 路由自动收集

js// static/js/config.js - pushComponent 方法
if (component.router) {
  if (component.router.rootRouter && component.router.rootRouter.length) {
    rootRouters = rootRouters.concat(component.router.rootRouter);
  }
  if (component.router.childRouter) {
    var childRouter = component.router.childRouter;
    for (var key in childRouter) {
      if (Object.prototype.hasOwnProperty.call(childRouter, key))
        childRouters[component.key] = childRouter[key];
    }
  }
}

7.3 主应用路由初始化

js// src/main.js
const { router, asyncRouter, asyncRouterConfigCenter } = initRouter();
store.commit('SET_ASYNCROUTER', { asyncRouter });
store.commit('SET_ASYNCROUTERCONFIGCENTER', { asyncRouterConfigCenter });

8. 打包构建流程

8.1 开发环境构建

  1. 启动构建脚本: npm run dev
  2. 生成模块导入文件: prepare.createFile()
  3. 监听配置变化: prepare.watchConfig()
  4. Webpack 别名配置: 模块名映射到实际路径
  5. 热重载: 修改代码自动刷新

8.2 生产环境构建

  1. 读取 childModule.json: 获取所有启用的模块
  2. 静态分析依赖: Webpack 分析模块依赖关系
  3. 代码分割: 按模块进行代码分割
  4. 生成最终包: 包含主应用和所有启用的子模块

8.3 模块下载与同步

js// build/blow.js - 子项目下载脚本
async function getAllProject() {
  // 获取主项目分支和地址
  const branchName = execSync('git symbolic-ref --short HEAD');
  const stdout = execSync('git config --get remote.origin.url');
  
  // 下载所有子模块到对应分支
  for (let moduleName of Object.keys(allModules)) {
    await gitClone(`${baseDir}/${moduleName}.git`, `./modules/${moduleName}`, {
      checkout: branchName.trim()
    });
  }
}

9. 完整调用链路示例

getComponentByName('operationAudit', 'viewType1') 为例:

┌─────────────────────────────────────────────────────────────────────────┐
│  1. 调用 getComponentByName('operationAudit', 'viewType')               │
└─────────────────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────────────────┐
│  2. getModuleByName('operationAudit')                                   │
│     → 从 modules['operationAudit'] 获取组件集合                          │
└─────────────────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────────────────┐
│  3. 返回 modules['operationAudit']['viewType']                         │
│     → 对应的 Vue 组件                                                    │
└─────────────────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────────────────┐
│  4. 组件在当前页面中正常使用                                            │
└─────────────────────────────────────────────────────────────────────────┘

10. 关键优势

特性 说明
模块解耦 子模块独立开发、测试、部署
按需加载 通过配置控制模块启用状态
跨模块通信 统一的组件调用和 Store 访问机制
开发友好 配置驱动,无需手动维护导入语句
版本同步 自动下载对应分支的子模块
错误处理 组件缺失时提供友好的错误提示

11. 最佳实践

11.1 组件导出规范

  • 所有需要被其他模块调用的组件必须在 index.js 的 components 对象中导出
  • 组件命名应具有描述性,避免冲突

11.2 Store 命名规范

  • Store 模块名应与组件 key 保持一致或具有明确关联
  • 避免不同模块使用相同的 Store 名称

11.3 路由命名规范

  • 路由名称应包含模块前缀,如 assetmanage_assetList
  • 避免路由名称冲突

11.4 模块依赖管理

  • 尽量减少模块间的强依赖
  • 使用 getComponentByNameflag 参数处理可选依赖

这个架构设计使得 moyu 能够支持大型企业级应用的模块化开发,同时保持良好的开发体验和运行时性能。

AI 时代前端还要学 Docker & K8s 吗?我用一次真实部署经历说清楚

作者 青晚舟
2026年3月20日 17:25

AI时代前端也能搞定部署!从CI/CD到Docker+K8s部署全流程实操(附真实项目经历)

最近跟不少前端朋友聊天,大家都有个共同的困惑:AI都能写代码、改Bug、甚至生成配置了,我们除了写页面,还能靠什么提升竞争力?

以前我也觉得,部署、运维都是后端或运维的活儿,前端只要把页面写漂亮、交互做流畅就够了。直到最近做项目,遇到了频繁上线出错、环境不一致的坑,才下定决心自己动手,从0到1配置CI/CD、编写Dockerfile、打包镜像,最后通过K8s部署成功。

全程踩了不少坑,但也真正明白:前端懂一点部署,不仅能解决工作中的实际麻烦,还能让自己的竞争力上一个台阶。今天就把我这段真实项目经历分享出来,手把手教大家前端如何搞定完整部署链路,新手也能跟着学、跟着做。

一、项目背景:为什么前端要自己搞部署?

这次做的是一个基于MonoRepo架构的Vue3+TS项目,团队不大,没有专门的运维,之前的部署流程全靠手动:

  1. 本地npm run build:console打包产物;2. 手动把dist文件夹上传到服务器;3. 后端帮忙配置nginx,重启服务。

看似简单,但问题越来越多:每次上线都要等后端有空,效率极低;本地打包正常,上传到服务器就报错(环境依赖不一致);偶尔手抖传错文件,还得重新上传,特别麻烦。

加上项目迭代越来越快,几乎每天都要测试、上线,手动部署已经拖慢了进度。思来想去,与其一直依赖别人,不如自己搞定一套自动化部署流程——这就是我接触Docker、K8s和CI/CD的初衷。

先跟大家说句实话:不用怕,前端搞部署,不用精通运维知识,只要掌握核心流程和常用操作,就能轻松上手,AI还能帮我们省不少事。

二、第一步:编写Dockerfile,把前端项目“打包”成镜像

Docker的核心作用,就是把项目和它依赖的环境一起打包成一个“镜像”,不管是本地、测试服务器还是线上服务器,只要有Docker,就能一键运行,从根源上解决“我本地好好的,一上线就崩”的问题。

结合我这个Vue3项目,给大家分享一下我写的Dockerfile,以及编写时踩过的坑,新手可以直接参考套用。

2.1 编写Dockerfile(附详细注释)

在项目根目录下新建一个名为Dockerfile的文件(无后缀),内容如下,每一行都加了注释,大家一看就懂:

# 第一步:选择基础镜像,这里用的是我们之前已经提前做好的一个node镜像里面已经安装好了node并发布到了Docker上
# as builder 的意思是为这个镜像启一个别名
FROM registry.cn-hangzhou.aliyuncs.com/xxx/frontend-project.aliyuncs.com/library/node:22.14.0.ossutil as builder

# 第二步:在 Docker 容器内部,创建并进入 `/code` 这个目录。 
# 后面所有命令(COPY、RUN、CMD)都会**默认在这个目录下执行**
WORKDIR /code 

# 把你本机项目根目录的所有文件,复制到容器里的 /code 目录。
COPY . /code  

# 第三步:设置git的代理并进行打包 安装pnpm 并进行打包

RUN npm config set registry https://registry.npmmirror.com && \
    npm install pnpm@10 -g && \
    pnpm i && \
    pnpm run build:console

# 第四步:创建一个基于nginx的容器
FROM registry.cn-hangzhou.aliyuncs.com/xxx/frontend-project.aliyuncs.com/library/nginx:1.27.4

    
# 注意:这里的builder就是第一个容器的别名 两个阶段的文件系统在 build 期间 Docker 都持有,所以可以互相取文件。第一阶段构建完之后不会立刻销毁,等第二阶段用完 COPY --from 之后才丢弃。这样做的好处是最终镜像里只有 nginx + 静态文件,没有 Node.js、源码、node_modules 这些东西,镜像体积会小很多。
COPY --from=builder /code/apps/dft-console-front/dist /usr/share/nginx/html




# 第五步:修改nginx镜像中的配置 因为现在的nginx的配置是默认的配置 如果你要设置代理,配置证书,负载均衡、动静分离等需要重写对应的配置 这里举例说明配置nginx的代理

RUN echo 'server {\n\
    listen       80;\n\
    listen  [::]:80;\n\
    server_name  localhost;\n\
\n\
    #access_log  /var/log/nginx/host.access.log  main;\n\
\n\
    # 代理后端接口\n\
    location /draftingee-structure {\n\
        proxy_pass http://xxx.xxx.xxx.xx:xxxx;\n\
        proxy_set_header Host $host;\n\
        proxy_set_header X-Real-IP $remote_addr;\n\
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\
    }\n\
\n\
\n\
    # 前端静态资源(支持 history 模式路由)\n\
    location / {\n\
        root   /usr/share/nginx/html;\n\
        index  index.html index.htm;\n\
        try_files $uri $uri/ /index.html;\n\
    }\n\
\n\
    error_page   500 502 503 504  /50x.html;\n\
    location = /50x.html {\n\
        root   /usr/share/nginx/html;\n\
    }\n\
}' > /etc/nginx/conf.d/default.conf



# 暴露80端口(nginx默认端口)
EXPOSE 80

# 启动nginx服务
CMD ["nginx", "-g", "daemon off;"]

2.2 本地测试Docker镜像(关键一步,避免后续踩坑)

编写完Dockerfile和nginx.conf后,先在本地测试一下镜像能不能正常运行,避免上传到仓库后才发现问题。

步骤很简单,打开终端,进入项目根目录,执行以下命令(新手记这3条就够了):

  1. 本地打包项目:npm run build:console(确保dist文件夹正常生成);

  2. 构建Docker镜像:docker build -t frontend-project:v1.0 . (frontend-project是镜像名,v1.0是版本号,可自定义);

  3. 运行镜像:docker run -d -p 8080:80 frontend-project:v1.0 (把容器的80端口映射到本地8080端口);

运行成功后,打开浏览器访问http://localhost:8080,如果能正常看到项目页面,说明Dockerfile和nginx配置没问题!

这里需要注意的一个点是 如果你的Dockerfile里面配置的是公司自己私有的仓库地址 那么需要鉴权 比如我们是阿里云的私有仓库 那么就需要登录到阿里云上 找到下面这个图片的地方 根据提示先在自己的本地登录一下授权后在进行拉取

image.png

三、第二步:配置CI/CD,实现代码提交自动构建镜像

搞定了Docker镜像,接下来就是配置CI/CD——简单说,就是我们提交代码到Git仓库后,系统会自动帮我们打包项目、构建Docker镜像,然后推送到镜像仓库,不用再手动执行命令,彻底解放双手。

我用的是GitLab CI/CD(如果你们用GitHub,流程类似,用GitHub Actions即可),结合自己的项目,给大家分享具体配置步骤。

3.1 准备工作:注册镜像仓库

我们需要一个镜像仓库,用来存放构建好的Docker镜像(类似代码仓库存放代码),常用的有阿里云容器镜像服务、Docker Hub、GitLab自带的镜像仓库。

我用的是阿里云容器镜像服务,步骤很简单:注册阿里云账号,创建一个命名空间,再创建一个镜像仓库(比如命名为frontend-project),记录下仓库地址(比如registry.cn-hangzhou.aliyuncs.com/xxx/frontend-project),后续会用到。

3.2 配置GitLab CI/CD(核心步骤)

在项目根目录下新建.gitlab-ci.yml文件,这是CI/CD的配置文件,里面定义了代码提交后,系统要执行的一系列操作。

以下是我的配置文件,同样带详细注释,新手可以直接修改使用:

# 定义CI/CD流水线的阶段(顺序执行)
stages:
  - build # 第一步:构建Docker镜像

# 第一步:构建Docker镜像
docker_build:
  stage: docker_build
  image: docker:20.10.17 # 使用docker镜像,用于构建镜像
  services:
    - docker:20.10.17-dind # 启动docker服务
  dependencies:
    - build # 依赖build阶段的dist文件夹
  script:
    # 登录阿里云镜像仓库(用户名、密码在GitLab仓库设置中配置为环境变量,避免明文泄露)
    - docker login --username=$DOCKER_USERNAME --password=$DOCKER_PASSWORD registry.cn-hangzhou.aliyuncs.com
    # 构建镜像,镜像名要和镜像仓库地址一致,版本号用当前提交的commit哈希,避免重复
    - docker build -t registry.cn-hangzhou.aliyuncs.com/xxx/frontend-project:${CI_COMMIT_SHORT_SHA} .
  only:
    - master

# 第三步:推送镜像到镜像仓库
docker_push:
  stage: docker_push
  image: docker:20.10.17
  services:
    - docker:20.10.17-dind
  dependencies:
    - docker_build
  script:
    # 推送镜像到仓库
    - docker push registry.cn-hangzhou.aliyuncs.com/xxx/frontend-project:${CI_COMMIT_SHORT_SHA}
    # 可选:推送一个latest版本,方便后续部署时拉取最新镜像
    - docker tag registry.cn-hangzhou.aliyuncs.com/xxx/frontend-project:${CI_COMMIT_SHORT_SHA} registry.cn-hangzhou.aliyuncs.com/xxx/frontend-project:latest
    - docker push registry.cn-hangzhou.aliyuncs.com/xxx/frontend-project:latest
  only:
    - master

3.3 关键注意点(避坑必看)

  1. 环境变量配置:Docker仓库的用户名(DOCKER_USERNAME)和密码(DOCKER_PASSWORD),不要明文写在配置文件里,要在GitLab仓库的“设置→CI/CD→环境变量”中添加,这样更安全;

  2. 镜像版本号:我用的是Git提交的commit哈希(${CI_COMMIT_SHORT_SHA}),这样每个提交对应一个唯一的镜像版本,方便回滚;

  3. 测试CI/CD:配置完成后,提交一次代码到master分支,然后在GitLab的“CI/CD→流水线”中查看进度,如果所有阶段都显示“成功”,说明CI/CD配置没问题,镜像已经成功推送到仓库了!

四、第三步:通过K8s拉取镜像,完成最终部署

搞定了Docker镜像和CI/CD,最后一步就是通过K8s拉取镜像,完成项目部署。很多前端朋友一听到K8s就觉得复杂,其实对前端来说,我们不用掌握K8s的全部功能,只要会编写简单的配置文件,能拉取镜像、启动服务就够了。

前提:你的服务器已经部署好了K8s集群(如果是测试,也可以搭建k3s配置文件跟k8s一样没有什么区别 k3s安装地址 )。

4.1 编写K8s部署配置文件(deployment.yaml)

在项目根目录下新建deployment.yaml文件,用于定义K8s如何拉取镜像、启动容器,内容如下(带注释):

apiVersion: apps/v1
kind: Deployment # 资源类型:Deployment,用于管理Pod的创建和更新
metadata:
  name: dft-console-front-712  # Deployment名称,可自定义
  namespace: zhip # 命名空间,默认用default即可 (一般情况每个人都有单独的分支 默认的分支为default )
spec:
  replicas: 1 # 启动1个Pod(容器实例),可根据需求增加
  selector:
    matchLabels:
      app: dft-console-front-712 # 匹配Pod的标签,和下面的template.metadata.labels一致
  template:
    metadata:
      labels:
        app: dft-console-front-712
    spec:
      containers:
        - name: dft-console-front-712 # 容器名称,可自定义
          image: fm-container-registry.cn-beijing.cr.aliyuncs.com/draftingee/console-front:v3.0.90292359 # 镜像地址,和我们推送到仓库的一致
          ports:
            - containerPort: 80 # 容器内部端口,和Dockerfile中暴露的80端口一致
      imagePullSecrets:
        - name: registry-secret
# 由于我这是纯前端的容器就不需要磁盘映射了 更新的话直接替换镜像即可

4.2 编写K8s服务配置文件(service.yaml)

光有Deployment还不够,我们需要通过Service暴露服务,让外部能访问到K8s集群中的容器,新建service.yaml文件:

apiVersion: v1
kind: Service # 资源类型:Service
metadata:
  name: frontend-project-service # Service名称,可自定义
  namespace: default
spec:
  type: NodePort # 暴露服务的方式,NodePort适合测试,生产环境可用LoadBalancer
  selector:
    app: frontend-project # 匹配Deployment中的Pod标签
  ports:
  - port: 80 # Service端口
    targetPort: 80 # 映射到容器的80端口
    nodePort: 30080 # 外部访问端口(范围30000-32767,可自定义)

4.3 执行K8s命令,完成部署

将deployment.yaml和service.yaml两个文件上传到K8s集群的服务器(或本地Minikube环境),然后执行以下命令,一步步完成部署:

  1. 部署Deployment:kubectl apply -f deployment.yaml;

  2. 部署Service:kubectl apply -f service.yaml;

  3. 查看Deployment状态:kubectl get deployments,看到READY为1/1,说明部署成功;

  4. 查看Pod状态:kubectl get pods,看到STATUS为Running,说明容器正常运行; image.png

  5. 查看Service状态:kubectl get services,看到frontend-project-service的EXTERNAL-IP为,NodePort为30080;

image.png

最后,打开浏览器,访问http://服务器IP:30080,就能看到我们部署好的前端项目了!

4.4 部署过程中踩过的坑

  1. 镜像拉取失败:一开始忘记给K8s配置阿里云镜像仓库的凭证,导致拉取镜像时提示权限不足。解决方法:在K8s中创建secret,存储Docker仓库的用户名和密码,然后在deployment.yaml中引用secret;

    • 更新srcret命令 kubectl create secret docker-registry registry-secret --docker-server=Docker地址 --docker-username=用户名 --docker-password=密码
  2. 端口冲突:一开始把nodePort设为80,导致和服务器上的nginx端口冲突,改成30080后正常

  3. 如何在k8s更新镜像

    • kubectl set image deployment/dft-console-front-712 dft-console-front-712=fm-container-registry.cn-beijing.cr.aliyuncs.com/draftingee/console-front:v3.0.b01bd50f

五、总结:前端搞部署,收获的不只是技能

从配置CI/CD、编写Dockerfile,到打包镜像、K8s部署成功,整个过程我用了3天时间,踩了不少坑,但当最后在浏览器中看到部署好的项目时,那种成就感真的难以形容。

很多前端朋友会问:AI都能生成Dockerfile、YAML配置了,我们还有必要自己学吗?

我的答案是:有必要,但不用精通。AI能帮我们写配置、拼命令,但它替代不了我们对整个部署链路的理解,替代不了我们排查问题的能力。就像这次部署,AI能生成配置文件,但当出现镜像拉取失败、端口冲突时,还是需要我们自己去分析问题、解决问题。

对前端来说,Docker和K8s不是必学的“硬技能”,但却是能让你脱颖而出的“加分项”:

  1. 不用再依赖后端/运维部署,自己就能掌控项目上线,效率大幅提升;

  2. 理解了从开发到上线的完整链路,考虑问题会更全面,写代码时也会更注重环境兼容性;

  3. 在团队中,能独立搞定部署,会让你更有话语权,也能为自己的职业发展拓宽道路。

最后想跟大家说:不用害怕接触自己不熟悉的领域,前端的边界从来不是写页面,而是不断学习、不断突破。AI时代,我们要做的不是被工具替代,而是学会利用工具,提升自己的核心竞争力。

如果你也想尝试前端部署,不妨从编写第一个Dockerfile开始,一步步来,你会发现,原来部署也没那么难~

RN 的新通信模型 JSI

作者 Joyee691
2026年3月20日 17:21

本文所有代码如果没有特别标注的话,默认用的都是 v0.76.0 的 RN 代码

JSI 是 React Native 新架构中的 JS ↔ Native 通信模型

JSI 解决了过去 Bridge 架构中 JSON 序列化开销强制异步带来的一系列问题,为后续的 Turbo module、Fabric、RuntimeScheduler 系统的实现奠定了基础

本文将从其的设计理念,逐步深入到源码细节,跟读者一同探究 JSI 背后的原理

设计理念

JSI 全名 JavaScript Interface,直译为 JS 接口,它既不是系统,也不是通信协议,而是一个位于 C++ 层,面向 JS 的抽象接口

JSI 的设计理念用一句话总结的话就是:JSI 是一层 JS runtime 抽象,使 JS 与 C++ 可以直接共享对象与函数调用,从而实现无 Bridge 的高性能通信,并支持多 JS 引擎。

这句话引出了 JSI 的三个设计目标:

  1. 去 Bridge
  2. JS 与 C++ 能够直接互相操作
  3. JSI 与引擎无关,且可以替换引擎

JSI 是怎么实现这三个目标的呢?如果我们将 RN 中 JS 与 Native 的通信进行抽象,其中主要有 4 名角色,他们的关系如下:

Native <---通信---> C++ ---编写---> JS 引擎 ---创建---> JS runtime

其中 Native 与 C++ 的通信在本专栏的 RN 通信机制已经聊完了,这里就不赘述

我们重点看看 C++ -> JS 引擎 -> JS runtime 这条线:

  1. JS 引擎会提供 C++ embedding API,使我们能够在 C++ 中访问和操作 JS runtime 中的对象
  2. JS 引擎创建了 JS runtime,且对 JS 内部的变量、对象有完全的控制权
  3. 如果我们让 JS 引擎暴露 JS 对象的句柄,提供对象访问、函数调用、生命周期管理等接口,就可以实现 C++ 与 JS 在一个 Runtime 中共享对象的操作

了解了 JS 与 C++ 共享对象的原理后,我们知道如果要实现这一套通信机制,需要在 C++ 层面针对特定引擎提供的 embedding API 实现一个胶水层来管理,但我们也可以更进一步:提供一套接入标准(Interface),让不同的引擎来适配这套标准

如此一来,我们就可以达成第三个设计目标:引擎无关

这也是 JSI 名字的由来

JSI 的三个层级

接下来我们来聊聊 JSI 实现过程中的三个层级:

  1. 抽象层:JSI 中最接近引擎的层级,负责定义一套统一的接口让引擎接入,也可以当成 JSI 的 “能力清单”
  2. 服务层:负责在应用初始化期间协调需要 “安装” 在 runtime 中的能力
  3. 应用层:负责往 Runtime 中绑定具体的能力(bindings)

下面我们分别详细介绍三个层级具体做了什么

抽象层:JSI 的“能力清单”

如果把 JSI 比喻成一个工具的话,抽象层就是这个工具的 “使用说明书”

在抽象层中,最重要的入口文件就是 packages/react-native/ReactCommon/jsi/jsi/jsi.h

这个文件定义了两件事:

  1. JS 引擎需要实现的一组接口
  2. JS 值模型的 C++ 包装

文件所有类的分类如下:

// in jsi.h

// 1. 运行时与执行上下文 (Runtime & Context)
// 负责管理 JS 引擎实例及整体生命周期
class Runtime;          // 核心引擎接口,所有 JS 操作都必须通过 Runtime 实例执行
class PreparedJavaScript; // 已编译/预处理的 JS 代码块,用于提高重复执行效率

// 2. JS 基础类型包装 (Base Value Types)
// JS 数据在 C++ 层的通用表示
class Value;            // 顶层包装类,可表示 null, undefined, boolean, number, symbol, string, object
class Pointer;          // 堆中 JS 对象的引用基类(所有受 GC 管理的对象基类)

// 3. 引用类型 (Reference Types)
// 继承自 Pointer,对应 JS 中的非原始类型
class PropNameID : public Pointer;       // 属性名标识符,用于高效的属性访问
class Symbol : public Pointer;           // JS Symbol 类型
class String : public Pointer;           // JS String 类型
class BigInt : public Pointer;// JS BigInt 类型
class Object : public Pointer;           // JS Object 类型
class Array : public Object;            // JS Array 类型
class ArrayBuffer : public Object;      // JS ArrayBuffer 类型
class Function : public Object;         // JS Function 类型,支持从 C++ 调用 JS 函数

// 4. Buffer 类
// 用于处理原始字节序列
class Buffer;// 只读,用于传递脚本源码、静态资源
class StringBuffer : public Buffer;// 只读,通常用于将 C++ 字符串转换为 Buffer
class MutableBuffer;// 可写原始字节内存

// 5. 宿主扩展接口 (Host Interaction)
// 用于在 C++ 中实现 JS 可访问的对象或函数
class HostObject;       // 接口类:继承此类可在 C++ 中自定义 JS 对象的属性拦截逻辑 (get/set)
class NativeState;// 挂在某个 JS Object 上的 Native 属性
HostFunctionType; // 类型别名:定义 C++ 函数如何被 JS 调用 (std::function 包装)

// 6. 异常与错误处理 (Error Handling)
// 处理 JS 与 C++ 边界处的异常
class JSIException;// JSI 异常的基类
class JSError : public JSIException;// 表示 JS 运行时的异常(包含堆栈信息,能被 JS 的 try...catch 捕获)
class JSINativeException : public JSIException;// JSI 宿主环境(C++ 侧)发生非 JS 逻辑导致的错误时抛出(不一定有 JS 堆栈)

// 7. 辅助与生命周期管理 (Utilities & RAII)
// 确保 C++ 与 JS 交互过程中的内存与逻辑安全
class Scope;            // RAII 风格的作用域管理,批量释放局部引用
class WeakObject : public Pointer;       // 对 JS 对象的弱引用,不阻止 GC 回收
class Instrumentation;      // 提供运行时性能指标和内存使用情况的接口

如果一个 JS 引擎想要嵌入 RN ,它至少需要提供与这份 “能力清单” 兼容的 Runtime 实现,具体的引擎侧实现可以参考 hermes 代码库API/hermes/hermes.cpp 文件(引擎侧的内容超过了本专栏的范围,这里就不展开了)

服务层:JSI 的管家 ReactInstance

如果把 JSI 比喻成工具的话,服务层就是该工具的管理员

一旦引擎实现了所有 JSI 要求的能力并且成功在 RN 中创建了 Runtime 实例后,下一步就是根据这个 Runtime 搭建出可以由 RN 调度且可以安全调用的执行环境

负责搭建执行环境的类叫做 ReactInstance,它就像 RN 中负责协调 Runtime 的管家,它的主要方法有:

  • 构造函数 ReactInstance::ReactInstance,负责创建 4 个关键角色:

    • RuntimeExecutor:负责在 Runtime 中运行一些任务;确保所有 JS 调用都在 JS thread 中执行;负责把 runtime 接进异常处理

    • runtimeExecutorThatWaitsForInspectorSetup:负责把 runtime 接进 inspector

    • RuntimeScheduler:负责管理 Runtime 的事件循环,这个后面会有文章专门讲解

    • BufferedRuntimeExecutor:带有优先级调度的 RuntimeExecutor

  • ReactInstance::initializeRuntime:负责初始化 Runtime;调度 JSI 应用层 binding 所需的功能

  • ReactInstance::callFunctionOnModule:Native 侧调用 JS 方法的入口

  • ReactInstance::loadScript:加载 JS 代码,并执行积压的 JS 调用

下面是详细的代码解释:

ReactInstance::ReactInstance

// in packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp

ReactInstance::ReactInstance(
  // 接收的参数,主要看这个 runtime 就好,runtime 是被创建好了之后传入的,ReactInstance 并不负责创建 runtime
    std::unique_ptr<JSRuntime> runtime,
    std::shared_ptr<MessageQueueThread> jsMessageQueueThread,
    std::shared_ptr<TimerManager> timerManager,
    JsErrorHandler::OnJsError onJsError,
    jsinspector_modern::HostTarget* parentInspectorTarget)
    : runtime_(std::move(runtime)),
      jsMessageQueueThread_(jsMessageQueueThread),
      timerManager_(std::move(timerManager)),
      jsErrorHandler_(std::make_shared<JsErrorHandler>(std::move(onJsError))),
      parentInspectorTarget_(parentInspectorTarget) {
        // 这里的 runtimeExecutor 是一个 C++ 的 Lambda 表达式
        // 它的主要目标有 2:
        // 1. 确保交给他执行的 callback 都在 JS 线程中执行
        // 2. 确保 JS 线程正常运行且能捕获运行期间发生的错误
  RuntimeExecutor runtimeExecutor = [weakRuntime = std::weak_ptr(runtime_),
                                     weakTimerManager =
                                         std::weak_ptr(timerManager_),
                                     weakJsThread =
                                         std::weak_ptr(jsMessageQueueThread_),
                                     jsErrorHandler =
                                         jsErrorHandler_](auto callback) {
    // 如果 Runtime 没了,直接返回
    if (weakRuntime.expired()) {
      return;
    }

    // 如果当前有一个致命的 JS error,禁止执行其他代码
    if (!jsErrorHandler->isRuntimeReady() &&
        jsErrorHandler->hasHandledFatalError()) {
      LOG(INFO)
          << "RuntimeExecutor: Detected fatal error. Dropping work on non-js thread."
          << std::endl;
      return;
    }

    // 确保 JS 线程还存在
    if (auto jsThread = weakJsThread.lock()) {
      // 将另一个 Lambda 投入到 JS 线程执行
      jsThread->runOnQueue([jsErrorHandler,
                            weakRuntime,
                            weakTimerManager,
                            callback = std::move(callback)]() {
        // 由于这个 Lambda 在进入线程被执行前需要在 queue 中等待
        // 所以我们在执行之前需要再确认一下这个 Runtime 是否还在运行
        auto runtime = weakRuntime.lock();
        if (!runtime) {
          return;
        }

        // 这里的 runtime 其实是 C++ 的一层 wrapper,实际上执行 js 代码的是 jsiRuntime
        jsi::Runtime& jsiRuntime = runtime->getRuntime();
        SystraceSection s("ReactInstance::_runtimeExecutor[Callback]");
        try {
          // 真正执行 callback 的代码
          callback(jsiRuntime);

          // 在默认的 0.76.0 + hermes 下,这个 flag 始终为 true,代表由引擎来处理微任务的调度
          // 如果是从其他版本升级上来的话,需要用到这段代码,这本质就是把之前用 setImmediate 模拟微任务那一套包装成 timerManager,然后在这里清空一下模拟的 “微任务队列”
          if (!ReactNativeFeatureFlags::enableMicrotasks()) {
            if (auto timerManager = weakTimerManager.lock()) {
              timerManager->callReactNativeMicrotasks(jsiRuntime);
            }
          }
        } catch (jsi::JSError& originalError) {
          // 如果执行过程中有错,这里需要兜住
          jsErrorHandler->handleFatalError(jsiRuntime, originalError);
        }
      });
    }
  };

  // 如果需要接入 inspector 才进入这段逻辑
  if (parentInspectorTarget_) {
    auto executor = parentInspectorTarget_->executorFromThis();

    // 上面 runtimeExecutor 的装饰器,主要作用就是在下面 executor 方法执行完之前不要执行传进来的 callback
    auto runtimeExecutorThatWaitsForInspectorSetup =
        std::make_shared<BufferedRuntimeExecutor>(runtimeExecutor);

    // 核心逻辑
    executor([this, runtimeExecutor, runtimeExecutorThatWaitsForInspectorSetup](
                 jsinspector_modern::HostTarget& hostTarget) {
      // 把当前的 ReactInstance 绑定到 hostTarget
      // hostTarget 负责管理调试会话,把当前 ReactInstance 跟 Runtime 暴露给基于 CDP(Chrome DevTools Protocol)的调试工具
      inspectorTarget_ = &hostTarget.registerInstance(*this);
      // 绑定当前 Runtime
      runtimeInspectorTarget_ = &inspectorTarget_->registerRuntime(
          runtime_->getRuntimeTargetDelegate(), runtimeExecutor);
      // 把积压的 callback 一次 flush 了
      runtimeExecutorThatWaitsForInspectorSetup->flush();
    });

    // 用当前的 runtimeExecutorThatWaitsForInspectorSetup 替代上面的 runtimeExecutor
    // 主要目的就是为了等上面的 executor 执行完,一旦执行完,其他行为与之前的 runtimeExecutor 没有区别
    runtimeExecutor =
        [runtimeExecutorThatWaitsForInspectorSetup](
            std::function<void(jsi::Runtime & runtime)>&& callback) {
          runtimeExecutorThatWaitsForInspectorSetup->execute(
              std::move(callback));
        };
  }

// 构造 RuntimeScheduler
  runtimeScheduler_ = std::make_shared<RuntimeScheduler>(
      runtimeExecutor,
      RuntimeSchedulerClock::now,
      [jsErrorHandler = jsErrorHandler_](
          jsi::Runtime& runtime, jsi::JSError& error) {
        jsErrorHandler->handleFatalError(runtime, error);
      });
// 用来监控一些性能指标
  runtimeScheduler_->setPerformanceEntryReporter(
      PerformanceEntryReporter::getInstance().get());

// 构造 BufferedRuntimeExecutor
// 本质上也是 runtimeExecutor 的装饰器,区别在于它用上了 runtimeScheduler 提供的能力
// 所以他可以进行优先级调度
  bufferedRuntimeExecutor_ = std::make_shared<BufferedRuntimeExecutor>(
      [runtimeScheduler = runtimeScheduler_.get()](
          std::function<void(jsi::Runtime & runtime)>&& callback) {
        runtimeScheduler->scheduleWork(std::move(callback));
      });
}

ReactInstance::initializeRuntime

// in packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp

void ReactInstance::initializeRuntime(
    JSRuntimeFlags options,
    BindingsInstallFunc bindingsInstallFunc) noexcept {
  // 借助 runtimeScheduler_ 来执行这个 lambda
  runtimeScheduler_->scheduleWork([this, options, bindingsInstallFunc](
                                      jsi::Runtime& runtime) {
    SystraceSection s("ReactInstance::initializeRuntime");

    // 在 runtime 的 global 上绑定一个 native 的高精度时间能力
    bindNativePerformanceNow(runtime);

    // 在 runtime 的 global 上绑定 RuntimeScheduler 需要的能力
    RuntimeSchedulerBinding::createAndInstallIfNeeded(
        runtime, runtimeScheduler_);

    // 给当前 runtime 注册 profiler 并绑定当前线程,用于性能分析
    runtime_->unstable_initializeOnJsThread();

    // 把一些 Native 的 flag 挂到 global 上
    defineReactInstanceFlags(runtime, options);

// 把异常处理相关方法挂到 global
    defineReadOnlyGlobal(
        runtime,
        "RN$handleException",
        jsi::Function::createFromHostFunction(
            runtime,
            jsi::PropNameID::forAscii(runtime, "handleException"),
            2,
            [jsErrorHandler = jsErrorHandler_](
                jsi::Runtime& runtime,
                const jsi::Value& /*unused*/,
                const jsi::Value* args,
                size_t count) {
              // 省略部分代码
            }));

    // 用来让 JS 侧注册可以被 Native 调用的 module
    // 这样 Native 就可以调用 JS 的 module 了
    defineReadOnlyGlobal(
        runtime,
        "RN$registerCallableModule",
        jsi::Function::createFromHostFunction(
            runtime,
            jsi::PropNameID::forAscii(runtime, "registerCallableModule"),
            2,
            [this](
                jsi::Runtime& runtime,
                const jsi::Value& /*unused*/,
                const jsi::Value* args,
                size_t count) {
              // 省略部分代码
              
              // 这里有个细节,callableModules_ 中的 value(第二个参数)并不是模块方法本身
// 它代表的是一个 return 模块方法的方法
              // 这个做法可以实现模块的懒加载
              callableModules_.emplace(
                  std::move(name),
                  args[1].getObject(runtime).getFunction(runtime));
              return jsi::Value::undefined();
            }));

    // 把 setTimeout、clearTimeout、setInterval、
// clearInterval、requestAnimationFrame、cancelAnimationFrame
    // 这些方法挂到 global 上,让 JS 可以调用,这些方法实现都在
    // packages/react-native/ReactCommon/react/runtime/TimerManager.cpp
    timerManager_->attachGlobals(runtime);

    // 最后绑定平台自己的 bindings
    bindingsInstallFunc(runtime);
  });
}

ReactInstance::callFunctionOnModule

// in packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp

void ReactInstance::callFunctionOnModule(
    const std::string& moduleName,
    const std::string& methodName,
    folly::dynamic&& args) {
  // 用 bufferedRuntimeExecutor_ 来调度
  bufferedRuntimeExecutor_->execute([this,
                                     moduleName = moduleName,
                                     methodName = methodName,
                                     args = std::move(args)](
                                        jsi::Runtime& runtime) {
    SystraceSection s(
        "ReactInstance::callFunctionOnModule",
        "moduleName",
        moduleName,
        "methodName",
        methodName);
    auto it = callableModules_.find(moduleName);
    // 处理找不到 moduleName 的情况
    if (it == callableModules_.end()) {
      std::ostringstream knownModules;
      int i = 0;
      for (it = callableModules_.begin(); it != callableModules_.end();
           it++, i++) {
        const char* space = (i > 0 ? ", " : " ");
        knownModules << space << it->first;
      }
      throw jsi::JSError(
          runtime,
          "Failed to call into JavaScript module method " + moduleName + "." +
              methodName +
              "(). Module has not been registered as callable. Registered callable JavaScript modules (n = " +
              std::to_string(callableModules_.size()) +
              "):" + knownModules.str() +
              ". Did you forget to call `registerCallableModule`?");
    }

    // 如果当前模块没有被初始化过(第一次调用),需要加载该模块
    if (std::holds_alternative<jsi::Function>(it->second)) {
      auto module =
          std::get<jsi::Function>(it->second).call(runtime).asObject(runtime);
      it->second = std::move(module);
    }

    // 取得调用模块名字
    auto& module = std::get<jsi::Object>(it->second);
    // 取得调用模块方法
    auto method = module.getPropertyAsFunction(runtime, methodName.c_str());

    // 构造参数
    std::vector<jsi::Value> jsArgs;
    for (auto& arg : args) {
      jsArgs.push_back(jsi::valueFromDynamic(runtime, arg));
    }
    // 调用!
    method.callWithThis(
        runtime, module, (const jsi::Value*)jsArgs.data(), jsArgs.size());
  });
}

ReactInstance::loadScript

// in packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp

void ReactInstance::loadScript(
    std::unique_ptr<const JSBigString> script,
    const std::string& sourceURL) {
  auto buffer = std::make_shared<BigStringBuffer>(std::move(script));
  std::string scriptName = simpleBasename(sourceURL);
// 用 runtimeScheduler_ 来调度
  runtimeScheduler_->scheduleWork(
      [this,
       scriptName,
       sourceURL,
       buffer = std::move(buffer),
       // 这里用 weak_ptr 是不希望这一个 lambda 延长 weakBufferedRuntimeExecuter 的生命周期
       // 不然可能出现 runtime 没了,但是 Executer 还在的尴尬情况
       weakBufferedRuntimeExecuter = std::weak_ptr<BufferedRuntimeExecutor>(
           bufferedRuntimeExecutor_)](jsi::Runtime& runtime) {
        SystraceSection s("ReactInstance::loadScript");
        // 省略部分代码

        // 核心代码!执行 JS bundle
        runtime.evaluateJavaScript(buffer, sourceURL);

        // 处理异常情况
        if (!jsErrorHandler_->hasHandledFatalError()) {
          jsErrorHandler_->setRuntimeReady();
        }

        // 省略部分代码
        
        // 判断 runtime 是否还在
        // 如果是则调度执行 JS bundle 期间积压的任务
        if (auto strongBufferedRuntimeExecuter =
                weakBufferedRuntimeExecuter.lock()) {
          strongBufferedRuntimeExecuter->flush();
        }
      });
}

应用层:JSI 的消费者

如果把 JSI 比喻为工具的话,应用层就是该工具的使用者

JSI 的抽象层定义跨引擎统一的 JavaScript 运行时接口;服务层基于这些接口为 React Native 提供调度、模块和 UI 等运行时能力;应用层则通过 binding 建立 JS Runtime 与 Host(Native) Runtime 之间能力暴露机制,是的双方都可以互相调用彼此的能力

上面这句话可能有点抽象,我们用一个 binding 例子来说明一下

还记得我们上一小节讨论的 ReactInstance::initializeRuntime 的方法吗?在第 15 行,它调用了 RuntimeSchedulerBinding::createAndInstallIfNeeded 方法

这个方法就是一个 binding,让我们来看看它 bind 了什么:

// in packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeSchedulerBinding.cpp

std::shared_ptr<RuntimeSchedulerBinding>
RuntimeSchedulerBinding::createAndInstallIfNeeded(
    jsi::Runtime& runtime,
    const std::shared_ptr<RuntimeScheduler>& runtimeScheduler) {
  auto runtimeSchedulerModuleName = "nativeRuntimeScheduler";

  // 试图从 runtime 的 global 中拿到 nativeRuntimeScheduler 对象
  auto runtimeSchedulerValue =
      runtime.global().getProperty(runtime, runtimeSchedulerModuleName);
  // 如果不成功,就自己创建一个
  if (runtimeSchedulerValue.isUndefined()) {
    // 创建了一个 RuntimeSchedulerBinding 类的实例
    auto runtimeSchedulerBinding =
        std::make_shared<RuntimeSchedulerBinding>(runtimeScheduler);
    // 通过 JSI 的 createFromHostObject 方法把 runtimeSchedulerBinding 包装成一个 JS 可以调用的对象
    auto object =
        jsi::Object::createFromHostObject(runtime, runtimeSchedulerBinding);
    // 把 JS 可以调用的对象挂到 global 的 nativeRuntimeScheduler 对象下
    runtime.global().setProperty(
        runtime, runtimeSchedulerModuleName, std::move(object));

    return runtimeSchedulerBinding;
  }
// 省略部分代码
}

可以看到,这个 RuntimeSchedulerBinding 本质上就是把与自己同名的类实例化后 “绑定” 到 JS Runtime 的 global 对象上

正常来说 C++ 的对象并不能直接挂到 JS 的对象上,但是通过 jsi::Object::createFromHostObject 方法的封装,object 对象得以顺利把自己 “绑定” 到 JS Runtime 的 global 对象上

jsi::Object::createFromHostObject 就是 JSI 定义的其中一个能力,它由需要接入的引擎自己实现,而 binding 是基于这一底层能力,做了上层的具体能力封装(在 RuntimeSchedulerBinding 例子中是任务调度能力的封装)

RuntimeSchedulerBinding 这个例子里,它对 JS 暴露了自己类里的方法;与之相对的,JS 也可以通过类似方法向 Native 暴露自己的能力,所以我才说 binding 是 JS Runtime 与 Host(Native) Runtime 之间能力暴露机制

至于 RuntimeSchedulerBinding 具体暴露了哪些能力,本专栏的下一篇关于 RuntimeScheduler 的文章会有详细说明,本文就不展开了

总结

JSI 通过分层的方式构建了 React Native 新架构中的 JavaScript 运行时体系:抽象层定义了跨引擎统一的运行时能力接口,服务层在这些能力之上构建并管理 React Native 的调度、模块与渲染等核心运行机制,而应用层则通过各类 bindings 使用这些能力来实现具体的 UI 和业务逻辑

三者共同构成了一条从底层运行时能力到上层应用功能的完整链路,使 JS 与 Native 能够在同一运行时环境中高效协作

大前端全栈实践课程:章节二(前端工程化建设)

作者 RichardZhiLi
2026年3月20日 17:12

在建立我们的BFF层后,我们其实就有了服务端渲染的能力。其实服务端渲染说白了也就是在做一件事情:获取用户所请求的页面模板文件,将内部所需要的数据填充好以后,汇总成HTML字符串然后发送给前端浏览器

但是单纯的依靠服务端渲染前端模板本身是有限制的,其中第一个限制就是它占用服务器资源:设想一下每个请求我们都需要单独进行IO操作读写模板,填写数据;一旦访问量过大的时候,后面的请求就会受到前面请求IO读写占用时间的影响导致响应时间过长,甚至是请求超时因此这种方式存在性能瓶颈。第二个限制就是它没有我们CSR(客户端渲染)那么灵活,CSR的页面存在各种各样前端页面级别的路由(即我们所熟知的SPA应用),它可以通过页面Hash或者浏览器History对象的状态扭转模拟多个页面,从而创造虽然只有一个静态文件(页面)但是却有无数个页面的体验感,虽然按道理来说可以靠多个静态文件(MPA的方式)来实现这一点,不过在服务器这种存储资源寸土寸金的地方,不建议这么做。最后就是CSR具有SSR不存在的某些能力,比如与浏览器交互,在CSR中由于JS代码运行环境是在浏览器中,这就决定了它能够调用DOM和BOM等等完成对一些特定事件的交互,例如更改页面布局,调用浏览器缓存等等操作。这些能力也只有浏览器能够提供而SSR由于运行在Node环境,也就无法做到。因此综上所述,前后端分离的架构对于该项目而言是有必要实现的

这也就引出了这篇文章的核心,如何将前后端架构分离,如何构建前端?

这些问题概括起来为一个问题,就是前端工程化

那有人可能会问了,为什么需要前端工程化,前端本身就是html,css,js这三个文件,全部放在一起不就行了吗?是的,搁在十几二十年前,静态网页盛行的那个年代这么做完全行得通。但是随着时代在变化,技术在发展,静态网页远不能满足现代社会的需求。现代网页为了能将产品的效果展示出来,会大量的运用到图片,有的甚至会上视频音频等等,从多个感官传达给用户。同时前端开发者为了减轻开发负担,会引入各式各样的前端库,例如常见的UI库Vue、React等等,状态管理库Pinia、Redux等等。这些各种各样的文件类型,库文件等等如果不集中起来管理,最直接导致的结果就是代码难以维护。到时候HTML文件里这里一个script标签引入这个库,那里一个引入那个库,最关键的是如果库废弃了怎么办?这些都无疑增加了维护成本。其次,浏览器只认识HTML、CSS和JS三种 文件类型啊,你库里的.vue文件怎么让浏览器运行起来?因此前端工程化主要就是为了解决这两个问题而存在的

  • 将前端所用到的库、静态文件等等统一管理,进而方便后续的代码维护
  • 将各式各样不同类型的文件全部集中在一起编译,输出成HTML文件能识别出来的文件类型:html、js、css

知道了目的以后,接下来就是怎么做?在我们项目里如何落地? 我们不妨从之前BFF层的构建开始寻求灵感,既然后端的核心架构是利用解析引擎将项目中各个业务模块加载并收拢在一起运行在内存中进而提供服务的这种模式,那么前端能不能也借鉴这样的思想来完成这个工作呢? 可以的兄弟,一定是可以的,不然我也没必要花那么大精力来写这篇文章了。其实这个事情在前端术语中就叫做打包,如果你用过webpack、vite这类型打包工具那一定不陌生,它就是前端工程化中一个最重要的环节

其实打包工具所做的事情就是如此,它的输入为:若干种不同类型的文件,输出为:浏览器所认识类型的文件。打包的过程其实对应的就是解析引擎所做的事情,它加载并读取各类型不同的文件,将其按照某些特定的规则,将其转化为我们 HTML、CSS、JS文件

那解析引擎是如何实现这点的? 首先是寻址,解析引擎需要先找到项目代码中所有所使用到的库和工具、代码、图片静态资源等等。这一点它是通过源代码中的import语句完成的,顺着每一行import递归去寻找,就能够把所有的项目依赖绘制成一张图。

紧接着,就是根据绘制的依赖图,一个一个的去进行文件类型转换。这个转换的过程也有专门的工具在做,这个工具叫做解析器(loader),如果你配置过Webpack估计你也不会对这个工具陌生。至于解析器是如何将对应的文件转换为HTML、JS、CSS的呢?这里就不拓展开来描述了,有兴趣的就自行百度一下。

最后就是将这些全部转换好的文件汇总并输出成为一个总的HTML、CSS、JS文件

这就是一个解析引擎的主要工作流程,但是注意它不是所有流程。市面上大部分的解析引擎都会对这个工作流程做优化,例如模块拆分、环境分流等等。

因此回到我们项目,为了实现这个打包过程,我们直接搬来webpack,就不手搓了,手搓虽然可以但是没必要。 Webpack主要配置如下

{
    entry:项目入口,即前端源代码入口
    output:项目输出,即打包好的产物放置的路径
    modules:块加载规则,例如遇到了哪种类型的文件采用哪种类型的解析器来解析
    plugins:webpack运行中的一些额外功能的拓展,贯穿着webpack项目从启动时到项目停止时的所有生命周期
    optimization:打包过程中的一些优化项
}

如果你毫不在意配置出来的打包性能和拓展能力,仅仅只想维持能够打包这种程度,那么plugins和optimization不要也是可以的。甚至如果你的项目中只有HTML、CSS、JS这三种文件类型,那么你甚至可以不要modules

言归正传,因为这个项目成立之初对标的是企业级的项目,因此这里配置还是得详尽一些。项目中所有的前端页面都是由Vue所编写,因此使用了Vue-loader和Plugins

const path = require('path')
const glob = require('glob')
const { VueLoaderPlugin } = require('vue-loader')
const { ProvidePlugin, DefinePlugin } = require('webpack')
const HTMLWebpackPlugin = require('html-webpack-plugin')
const pageEntries = {};
const htmlWebpackPluginList = []


/**
 * 我们约定所有页面的源代码都全部存放在app/pages下方,并且以entry.xxx.js的方式命名
 * 那么我们可以依靠glob这个包来读取app/pages下方所有的目录,也就能够拿到所有的页面文件了
 * 主要目的是为了快速构造webpack配置中的entry以及HtmlWebpackPlugin配置
 * 因为未来我们的入口可能有一个或者多个页面,相对应的就需要配置一个或者多个HtmlwebpackPlugin。所以我们就统一在这里动态生成
 */
const entryList = path.resolve(process.cwd(), 'app', 'pages', '**', 'entry.*.js')
glob.sync(entryList).forEach((filePath) => {
    const entryName = path.basename(filePath, '.js') //拿到页面文件名称
    pageEntries[entryName] = filePath;
    htmlWebpackPluginList.push(new HTMLWebpackPlugin({
        //产物(最终模板)输出路径
        filename: path.resolve(process.cwd(), 'app', 'public', 'dist', `${entryName}.tpl`),
        //指定要使用的模板文件
        template: path.resolve(process.cwd(), 'app', 'view', 'entry.tpl'),
        //要注入的代码块,注意需要与entry中的chunks保持一致,一个入口需要声明一个HTMLWebpackPlugin,除非代码块需要注入多个entry
        chunks: [entryName]
    }))
})
/**
 * webpack基础配置
 */
module.exports = {
    //入口
    entry: pageEntries,
    //模块解析配置(决定了要加载解析哪些模块,以及用什么方式去解析)
    module: {
        rules: [
            {
                test: /\.vue$/,
                use: {
                    loader: 'vue-loader'
                }
            },
            {
                test: /\.js$/,
                include: [path.resolve(process.cwd(), 'app', 'pages')], //只对业务代码进行babel-loader的编译解析
                use: {
                    loader: 'babel-loader',
                }
            },
            {
                test: /\.(png | jpe?g| gif)(\?.+)?$/,
                use: {
                    loader: 'url-loader',
                    options: {
                        limit: 300,
                        esModule: false
                    }
                }
            },
            {
                test: /\.css$/,
                use: ['style-loader', 'css-loader']
            },
            {
                test: /\.less$/,
                use: ['style-loader', 'css-loader', 'less-loader']
            },
            {
                test: /\.(eot | svg | ttf | woff | woff2)(\?\S*)?$/,
                use: 'file-loader',
            }
        ]
    },
    //输出目录,留空,由环境配置自行决定(生产/开发)
    output: {

    },
    //配置模块解析的具体行为(定义webpack在打包时如何找到并解析具体模块的路径)
    resolve: {
        extensions: ['.js', '.vue', '.less', '.css'],
        alias: {
            $pages: path.resolve(process.cwd(), 'app', 'pages'),
            $common: path.resolve(process.cwd(), 'app', 'pages', 'common'),
            $widget: path.resolve(process.cwd(), 'app', 'pages', 'widgets'),
            $store: path.resolve(process.cwd(), 'app', 'pages', 'store'),
        }
    },
    //webpack插件
    plugins: [
        /**
         * 处理.vue文件,这个插件是必须的
         * 它的职能是将你定义过的其他规则复制并应用到.vue文件里
         * 例如,如果有一条匹配规则/\.js/的规则,那么它也会应用到.vue文件中的script板块中
         */
        new VueLoaderPlugin(),
        //把第三方库暴露到window context下,这个插件的主要作用是将我们指定的模块暴露给源代码使用
        //例如我们在下方指定了Vue这个模块,那么这也就意味着所有源代码文件都不需要再import Vue from 'vue'了;相当于让这个模块变为了全局共享模块
        new ProvidePlugin({
            Vue: 'vue',
            axios: 'axios',
            _: 'lodash',
        }),
        //定义全局常量,这些全局常量在源代码中是能直接访问到的。例如在源代码entry.page1.vue中,可以直接在script标签中console.log(__VUE_OPTIONS_API__),结果为true
        new DefinePlugin({
            __VUE_OPTIONS_API__: 'true',//支持VUE解析options API
            __VUE_PROD_DEVTOOOLS__: 'false',//禁用VUE的调试工具
            __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false', //生产环境水合信息
        }),
        //构造最终渲染的页面模板
        ...htmlWebpackPluginList,
    ],
    //打包输出优化(代码分割、代码合并、缓存、Treeshaking、压缩优化策略等等)
    optimization: {
        //分包配置
        /**
         * 分包配置应该自行根据项目配置和项目经验来进行,这里展示的仅仅只是演示策略
         * 1.vendor:第三方lib库,基本不会改动,除非依赖版本升级
         * 2.common: 业务组件代码的公共部分抽取出来,改动较少
         * 3.entry.{page}:不同页面里的entry里的业务组件代码的差异部分,会经常改动
         * 目的:把改动和引用频率不一样的js区分出来,以达到更好利用浏览器缓存的效果
         */
        splitChunks: {
            chunks: 'all', //对同步和异步模块都进行分割
            maxAsyncRequests: 10, //每次异步加载的最大并行请求数
            maxInitialRequests: 10, //入口点的最大并行请求数
            cacheGroups: {
                //第三方依赖库。
                /**
                 * 注意如果有些module同时被多个分包策略所命中,那么这个module会被分至优先级更高的包里面。
                 * 假如Vue这种第三方库,被我们多个页面所引用,但是如果我们有两个分包策略,一个包vendor匹配node_modules中的模块,一个包common匹配引用次数大于等于2的模块;
                 * 那在这种场景下,由于Vue既属于node_modules中的包,同时也属于引用次数大于等于2的包,那么最终打包以后,生成的包会被放置在优先级更高的包里,即common包中(因为下面的配置common包优先级为-10,node_modules优先级为-20,common包更高)
                 */
                vendor: {
                    test: /[\\/]node_modules[\\/]/, //将node_modules这种第三方库单独分包到vendor中
                    name: 'vendor', //模块名称
                    priority: 20, //优先级,数字越大优先级越高
                    enforce: true, //是否强制执行
                    reuseExistingChunk: true //是否复用公共的chunks
                },
                //公共模块
                common: {
                    name: 'common',
                    minChunks: 2, //指定最少引用的chunks,也就是说至少要有两个Chunks引用同一个代码片段,那么这个代码片段会被拆分为新包
                    minSize: 1, //最小分割文件大小(1 byte)
                    priority: 10,
                    reuseExistingChunk: true
                },
            }
        },
        //将webpack运行时的注入代码单独输出到一个bundle中,即runtime.js
        runtimeChunk: true
    }
}

这里着重讲解几个配置项和插件吧

  • entry: 因为项目是多页面MPA模型,所以入口可能是多个存放在app/pages下方的模板文件,因此这里在开头就使用JS函数动态组装成了一个对象
  • output:留空,因为最终产物生成路径跟你打生产包/测试包/开发包是有很大的区别的,这些个配置单独放在对应的配置文件中,这里就不展开了
  • ProvidePlugin, DefinePlugins:为了开发体验加入的。ProvidePlugin用于给前端源代码全局注入一些第三方依赖,例如Vue和Axios。这样子书写源代码时就再也不用import xxx from 'vue' 或者 import xx from 'axios'了。DefinePlugins则是定义一些全局常量,禁用一下Vue的调试工具等等,同时也提供给源代码访问,即便未来要改也可以在源代码运行时中更改。
  • HTMLWebpackPlugin:这个插件是重中之重,因为它决定了我们最终生成的HTML文件,包括它生成前模板从哪里来,最终生成的产物模板又该注入什么第三方库等等。按照使用规则来说,一个入口需要对应一个插件实例,因此这里也给它调整成动态生成了
  • optimiation.splitChunks:这个是优化打包过程的重中之重,它决定了你代码将被拆分为几个包。在我的项目里面,我是给他拆分为了三个,分别是业务代码包、vendor(第三方库包)、common(公共模块包),如果有更好的拆分方式欢迎分享。
    • 另外科普一下为什么要拆分包?因为在不拆分的情况下,你的业务代码和你的一些公共库代码是全部融合在一起的,那也就是说一旦你的业务代码发生了变更,这些公共库代码就会跟随着你的业务代码重新打一遍进而生成一个新包,这对于打包过程而言造成了性能浪费,其次最后生成的包体积也会异常庞大,最终结局使用浏览器访问你的包会加载时间特长。因此为了减少包体积,同时也为了提升打包的性能,分包是必须的。此外分包还有另一个好处,在我的项目里由于是MPA模型,多个页面间使用的都是同一个公共库包。公共库拆分出来,一旦有一个页面加载好了公共库包,其他的页面都可以共享,就不会再重新单独随着业务代码加载一遍。因为这个公共库包在第一个页面加载完成时就已经存在浏览器缓存中了,其他页面若需要使用直接从缓存中读取,不需要再单独建立HTTP请求拉取。
    • 分包配置中一项重要配置为priority。这个配置决定了此项分包规则的优先级,也就继而影响我们的分包结果。例如Vue这个公共库在项目中既命中了vendor包的分包规则,同时也命中了common公共模块的分包策略:即只要两个模块以上引用了这个模块那么就会被拆分成单独的包。因此在这种情况下,优先级就很重要了,这里因为考虑到node_modules这个包分出去会复用率更高因此调高了node_modules的优先级。

生产环境配置

  mode: 'production',//指定开发模式为生产环境
    //产物输出路径配置,因为开发和生产环境输出不一致,所以在各自环境中进行配置
    output: {
        filename: 'js/[name]_[chunkhash:8].bundle.js',
        path: path.join(process.cwd(), './app/public/dist/prod'), //决定了打包后产物所在的根目录;
        /**
         * 注意path + filename才是最终打包后js生成的目录,比如path定义为/dist,filename定义为js/xxx.js,那么最终打包后生成的js文件将落地在dist/js/xxx.js处
         * 最终生成的js文件的所在路径就是代码中资源寻址的根路径,比如我们源代码中有一行代码为<img src='/assets/xxx.png'>,那么打包后,这份代码将从dist/js/xxx.js处出发去寻找/assets/xxx.png
         * 通常情况下这样子是肯定寻址寻不到对应的静态资源的,所以我们会配置publicPath这么一个属性,指定代码中的静态资源的寻址根路径
         * 例如指定的是/dist,那么代码中静态资源的寻址就会变成<img src="/dist/assets/xxx.png">,也就是说有了这个配置项后webpack会为我们自动补齐这个寻址前缀,因此代码会从/dist出发去寻找/assets/xxx.png
         */
        publicPath: '/dist/prod',
        crossOriginLoading: 'anonymous',
    },
    module: {
        rules: [
            {
                test: /\.css$/,
                use: [
                    MiniCssExtractPlugin.loader,
                    'happypack/loader?id=css'
                ]
            },
            {
                test: /\.js$/,
                include: [path.resolve(process.cwd(), 'app', 'pages')], //只对业务代码进行babel-loader的编译解析
                use: 'happypack/loader?id=js'
            }
        ]
    },
    performance: {
        hints: false
    },
    plugins: [
        //每次build前,清空public/dist目录
        new CleanWebpackPlugin(['public/dist'], {
            root: path.resolve(process.cwd(), 'app'),
            exclude: [],
            verbose: true,
            dry: false,
        }),
        //提取css的公共部分,有效利用缓存,(非公共部分使用inline)
        new MiniCssExtractPlugin({
            chunkFilename: 'css/[name]_[contenthash:8].bundle.css',
        }),
        //优化并压缩CSS资源
        new CSSMinimizerPlugin(),
        //多进程打包JS,加快打包速度
        new HappyPack({
            ...happyPackCommonConfig,
            id: 'js',
            loaders: [`babel-loader?${JSON.stringify({
                presets: ['@babel/preset-env'],
                plugins: ['@babel/plugin-transform-runtime']
            })}`]
        }),
        //多进程打包CSS,加快打包速度
        new HappyPack({
            ...happyPackCommonConfig,
            id: 'css',
            loaders: [{
                path: 'css-loader',
                options: {
                    importLoaders: 1
                }
            }]
        }),
        //浏览器在请求资源时不发送用户的身份凭证
        new HtmlWebpackInjectAttributesPlugin({
            crossorigin: 'anonymous',
        })
    ],
    optimization: {
        minimize: true,
        minimizer: [
            //使用TerserPlugin的并发和缓存,提升压缩阶段的性能,并且清除console.log
            new TerserWebpackPlugin({
                cache: true, //启用缓存来加速构建过程
                parallel: true, //利用多核CPU优势来加快压缩速度
                terserOptions: {
                    compress: {
                        drop_console: true
                    }
                }
            })
        ]
    }

生产环境和默认配置相比较下大同小异,就是一些优化的细微区别

  • HappyPack引入进行多进程打包加速
  • TerserWebpackPlugin用于混淆、压缩JS文件,同时用来删除JS中的一些console.log调试代码
  • MiniCssExtractPlugin用于提取公共的CSS代码,例如一个模块a引入了custom.css,另一个模块b也引入了这个css文件,那么这个css文件在打包后会被单独提取出来为一个css文件。

开发环境和热更新 开发环境相对于生产环境和默认配置的配置来说,其实也大同小异,要区别在于开发环境加入了热更新的功能。那么什么是热更新呢?简单点来说就是本地代码一旦产生了修改,网页就会自动刷新并应用上更改后的代码的能力。举个例子就是:当我们修改了一行代码以后(比如将页面字体颜色改为红色),那么在没有热更新的功能的时候,你需要手动重新编译这份代码,然后刷新浏览器才能看到上述效果。而有了热更新以后,这个手动的过程就变成全自动了,本地代码会在你产生变更后自动重新编译,并且编译完成后自动通知浏览器刷新。热更新对于我们开发环境这种经常发生代码变动的环境来说,是一个特别实用且方便的功能。

那么如何增加热更新功能呢? 在webpack项目里,最简单的一种方式就是配置devServer这个配置项。同时呢需要安装一个webpack-dev-server这么一个依赖包。但是在这里为了理解其原理,我决定利用express手搓一个。 首先一句话来了解一下热更新原理:将打包生成的文件部署到一台服务器上,让这个服务器随时监控、监听着本地代码变更,一旦有变更后,重新编译并在编译完成后通知浏览器。这么说可能有点抽象,但其实总结成一张图也就是这样:

hmr_three_actors.svg

首先,我们的本地源码编译、打包后不再生成为真实的物理文件,而是变成了常驻在开发(Dev服务器)里的内存程序。当浏览器访问页面时,其实这个页面内容是由开发服务器所提供的,业务服务器此时只是单纯的变成了一个接口服务器,不再承载静态资源。这个Dev服务器内部实现了对我们本地源码文件的监听(node:fs.watch模块),同时它与浏览器建立了双向通信机制(在这张图里体现为Websocket协议即WS)。因此每当我们更改了源码文件后,监听器就会触发,随之而来的就是重新编译。当编译完成后这个开发服务器会以WS协议告知浏览器内容发生了变更,因此需要即时刷新。

  • 为什么非得成立双向通信?这个图中看起来浏览器并没有向Dev服务器推送任何资源啊?使用HTTP单向协议不就行了吗?原因在于服务器不通过WS协议没法主动向浏览器推送内容,因为HTTP协议是单向的。如果使用单向协议,浏览器就必须设置轮询从而知晓Dev服务器内容是否有变动,这既会造成性能浪费,同时效果也不太好。轮询了必然有时间间隔,有时间间隔就意味着没法做到实时更新

言归正传,实现热更新的关键点就是在于实现一台Dev服务器,这台Dev服务器既能够有监控本地文件变更的能力,同时也要有与浏览器建立双向通信的能力。依赖于Express的中间件,我们很容易能实现这一点,前者对应webpack-dev-middleware,后者对应的是webpack-hot-middleware。因此结合两个中间件,就有了下述代码

//本地开发启动devServer
const express = require('express');
const path = require('path');
const webpack = require('webpack');
const consoler = require('consoler')
const devMiddleware = require('webpack-dev-middleware');
const hotMiddleware = require('webpack-hot-middleware');
const app = express();
//从 webpack.dev.js 获取 webpack配置和 devServer 配置
const { webpackConfig, DEV_SERVER_CONFIG } = require('./config/webpack.dev')
const compiler = webpack(webpackConfig);

//指定静态文件目录
app.use(express.static(path.resolve(process.cwd(), 'app', 'public', 'dist')))

// 引用 devMiddleware中间件,监控文件改动
app.use(devMiddleware(compiler, {
    //落地文件
    writeToDisk: filePath => filePath.endsWith('.tpl'),
    //资源路径
    publicPath: webpackConfig.output.publicPath,
    //headers配置
    headers: {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,PATCH,OPTIONS',
        'Access-Control-Allow-Headers': 'X-Requested-With, Content-Type,Authorization',
    },
    stats: {
        colors: true
    }
}))

// 引用hotMiddleware中间件(实现热更新)
app.use(hotMiddleware(compiler, {
    path: `/${DEV_SERVER_CONFIG.HMR_PATH}`,
    log: () => { }
}))


consoler.info('请等待webpack初次构建完成提示...')
//启动 dev-server
const port = DEV_SERVER_CONFIG.PORT;
app.listen(port, DEV_SERVER_CONFIG.HOST)

同时呢为了让产物文件产出并生成在dev服务器的内存中,我们还得对webpack dev环境做出如下配置

const path = require('path')
const merge = require('webpack-merge');
//基类配置
const baseConfig = require('./webpack.base.js');
const { HotModuleReplacementPlugin } = require('webpack');
//dev-server配置
const DEV_SERVER_CONFIG = {
    HOST: '127.0.0.1',
    PORT: 9002,
    HMR_PATH: '__webpack_hmr',//官方规定
    TIMEOUT: 20000,
}

//开发阶段的 entry 配置需要加入 hmr
Object.keys(baseConfig.entry).forEach(key => {
    //第三方包不作为HMR入口
    if (key !== 'vendor') {
        baseConfig.entry[key] = [
            //主入口文件
            baseConfig.entry[key],
            `webpack-hot-middleware/client?path=http://${DEV_SERVER_CONFIG.HOST}:${DEV_SERVER_CONFIG.PORT}/${DEV_SERVER_CONFIG.HMR_PATH}&timeout=${DEV_SERVER_CONFIG.TIMEOUT}&reload=true`
        ]
    }
})


//生产环境webpack配置
const webpackConfig = merge.smart(baseConfig, {
    mode: 'development',//指定开发模式为生产环境
    //产物输出路径配置,因为开发和生产环境输出不一致,所以在各自环境中进行配置
    output: {
        filename: 'js/[name]_[chunkhash:8].bundle.js',
        path: path.join(process.cwd(), './app/public/dist/dev'), //决定了打包后产物所在的根目录;
        publicPath: `http://${DEV_SERVER_CONFIG.HOST}:${DEV_SERVER_CONFIG.PORT}/public/dist/dev/`,
        globalObject: 'this',
    },
    devtool: 'source-map',
    //开发阶段插件
    plugins: [
        //用于实现热模块替换,模块热替换允许在应用程序运行时替换模块,极大的提升开发效率,因为能让应用程序一直保持运行状态
        new HotModuleReplacementPlugin({
            multiStep: false
        })
    ]
})

module.exports = {
    //webpack配置
    webpackConfig,
    //devServer配置,暴露给dev.js使用
    DEV_SERVER_CONFIG,
}

这样,一个简洁的具有热更新能力的webpack开发环境配置就完成了。到此为止前端工程化的内容基本上就完成了。

接下来就是一些前端的基建,都是偏向于业务代码一侧的开发了,包含MPA源码入口的书写,前端库的引入、请求库的封装等等一系列事情

app/pages/boot.js MPA的启动入口
import { createApp } from 'vue';
import Pinia from '$store'
import { createWebHashHistory, createRouter } from 'vue-router';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import './assets/custom/index.css'

/**
 * vue 页面主入口 ,用于启动Vue
 * @params pageComponent vue 入口组件
 * @params {object} config 对应入口页面的配置项,包含routes和libs,routes表示这个页面的路由结构,libs是这个页面用到的三方包
 */
export default (pageComponent, {
    routes = [], //这个页面的路由
    libs = [], //用到的包
} = {}) => {
    const app = createApp(pageComponent);
    //引入Element-plus
    app.use(ElementPlus);
    //引入Pinia
    app.use(Pinia);

    //根据config引入三方包
    if (libs?.length) {
        for (let i = 0; i < libs.length; i++) {
            app.use(libs[i]);
        }
    }

    //引入路由,如果没有给定入参路由的话,那么直接挂载就行
    if (!routes?.length) {
        app.mount('#root');
        return;
    }
    const router = createRouter({
        history: createWebHashHistory(), //采用Hash模式,
        routes,
    })
    app.use(router);
    router.isReady().then(app.mount('#root'));
}
axios 的封装 app/pages/common/curl.js
const md5 = require('md5')
import { ElMessage } from 'element-plus';
const responseCodeHandlerMap = {
    442: () => ElMessage.error('请求参数异常'),
    445: () => ElMessage.error('请求不合法'),
    50000: (message) => ElMessage.error(message),
}
/**
 * 前端封装的 curl 方法
 * @params options 请求参数
 */
export default ({
    url = '',
    method = 'post',
    headers = {},
    query = {}, //url query
    data = {}, //post body
    responseType = 'json',
    timeout = 60000, //超时时间
    errorMessage = '网络异常'
}) => {
    //为接口做签名处理
    const signKey = 'xxx';
    const st = Date.now();
    const ajaxSetting = {
        url,
        method,
        params: query,
        data,
        responseType,
        timeout,
        headers: {
            ...headers,
            s_t: st,
            s_sign: md5(`${signKey}_${st}`),
        }
    }

    //构造请求参数(把输入参数转为axios的配置参数)
    return axios.request(ajaxSetting).then((res = {}) => {
        const { data: axiosResponseDataObj } = res || {}
        //后端返回API格式
        const { success = false, code = 200, message = '' } = axiosResponseDataObj || {};
        //失败时,根据响应代码找到对应的handler执行。同时将失败原因返回给调用方
        if (!success && typeof responseCodeHandlerMap[code] === 'function') {
            responseCodeHandlerMap[code](message);
            return Promise.resolve({
                success,
                code,
                message
            })
        }
        //成功的时候,将响应数据返回给调用方
        return Promise.resolve({
            data: axiosResponseDataObj.data,
            metadata: axiosResponseDataObj.metadata,
            success
        })
    }).catch(err => {
        const { message } = err;
        if (message.match(/timeout/)) {
            return Promise.resolve({
                message: 'Request Timeout',
                code: 504
            })
        }
        return Promise.resolve(error);
    })
}

app/pages/store/index.js 状态库
import { createPinia } from 'pinia';
const pinia = createPinia();
export default pinia;
测试页面: app/pages/page1.vue
<template>
  <h1>page1</h1>
  <el-input
    v-model="content"
    style="width:300px;"
  />
  <el-table
    :data="tableData"
    style="width:100%"
  >
    <el-table-column
      prop="name"
      label="name"
      width="180"
    />
    <el-table-column
      prop="desc"
      label="desc"
    />
  </el-table>
</template>

<script setup>
import {ref} from 'vue';
import $curl from '$common/curl'
console.log('page1 init')
const content = ref('');
const tableData = ref([{
    name:'Richard',
    desc:'desc'
}]);
const fetchProjectList = async () => {
    try {
        const {data, success= false, message = '' } = await $curl({
            url:'/api/project/list',
            method:'post',
            data:{
                proj_key:'22222'
            }
        })
        if(!success) {
            throw Error(message);
        }
        tableData.value = data;
    }
    catch(err){
        console.error(err?.message ?? err)
    }
}
fetchProjectList()
</script>

<style lang="less" scoped>
h1 {
    color:red;
}
</style>

page1.entry.js
import boot from '$pages/boot.js';
import page1 from './page1.vue';
boot(page1);

总结:

  1. 服务端渲染因为相比较于客户端渲染更加浪费服务器性能不具备操作浏览器能力,因此在制作前端页面时只能倾向于做一些交互性不强并且页面结构比较简单的页面。在这种前提背景下,将服务端渲染模式分离为客户端渲染的模式的需求也就应运而生(针对于elpis这个项目而言)
  2. 为了分离服务端渲染的模式为客户端渲染模式,因此需要做前端工程化建设。前端工程化建设主要解决两个痛点:一是统一管理前端所用到的库和静态资源(例如图片、CSS文件等等),这有利于未来项目扩张后的代码维护。二是确保各式各样类型的文件(例如浏览器无法识别的vue文件、scss文件、图片等)能够在浏览器里运行。前端工程化中重点之一就是打包这个环节。打包的定义是接收各种类型的文件作为输入,经过解析引擎解析后,输出为HTML、JS、CSS文件的过程。解析引擎的主要任务包含依赖寻找、解析器编译、汇总并输出解析文件三件事。在项目中我们使用了Webpack作为例子举例
  3. 由于该项目成立之初是对标企业级项目,因此webpack配置被拆分为了默认配置、开发环境配置以及生产环境配置三种配置。其中着重讲解了默认配置中的splitChunks分包策略以及开发环境配置项中的热更新原理。
  4. 业务层面上,封装了MPA项目的总入口以及axios请求器及引入了状态管理工具、路由管理工具等

5分钟跑通 LangChain,第一个 AI Demo(超详细)

作者 赵小川
2026年3月19日 16:35

本文主要跑通一个基本langchain 的Model I/O(Model Input / Output):也就是 你如何把数据“送进模型”(Input),以及如何从模型“拿出结果”(Output)。

会先以最基本的 初始化模型 -> 用户输入 -> 模型调用。完成最小闭环。至于提示词工程,结构化输出,消息类型,会在后面做补充

以下是Lanchain Model I/O的示意图: 模型调用的“输入输出协议层”

image.png

文章目录

  • 为什么要用 LangChain 开发ai 应用
  • apiKey的申请 及开发环境准备
  • 模型初始化 initChatModel 方法
  • 模型调用 invock 方法
  • 细节补充及总结

为什么要用 LangChain 开发ai 应用?

  • 抹平模型厂商差异

现在 AI 模型厂商层出不穷,这对整个行业来说是好事,推动了 AI 的快速发展。
但对于开发者来说,其实并不那么友好。

在实际项目中,我们往往不会只使用一家模型厂商的服务,比如可能同时接入:

  • OpenAI
  • 阿里百炼
  • 智谱 AI
  • 甚至一些本地模型

这时候问题就来了:
👉 不同厂商的 API 调用方式、参数格式、返回结构都不一样

如果每接一个模型就写一套适配逻辑:

  • 代码会变得非常混乱
  • 后期维护成本极高

LangChain 的价值就在这里:

👉 统一模型调用方式,屏蔽底层差异

你可以把它理解为:

LangChain ≈ Java 里的 JDBC

只需要面向统一接口开发,就可以灵活切换不同模型厂商。

来看图示

image.png

langchain 源码体现:

👉 枚举各个厂商,然后通过 动态 import + 统一的 ConfigurableModel 代理实现(具体不细讲)

image.png

  • 提供丰富的组件能力

LangChain 不只是“统一调用模型”这么简单,它本质是一个完整的 AI 应用开发框架,内置了很多核心能力,比如:

  • Prompt 模板(PromptTemplate)
  • 输出解析(OutputParser)
  • 工具调用(Tools)
  • 记忆管理(Memory)
  • Agent 能力

不过说实话,对于大多数前端/应用开发者来说:

👉 日常用到最多的,其实还是:

  • 聊天能力(LLM 调用)
  • 简单的 Prompt 组织
  • AI 工具接入 (cursor, Cloud Code)

大模型 ≠ 完整 AI 应用

很多人会有一个误区:

“有了大模型,就等于有了 AI 应用”

其实不是。

你可以把大模型理解为一个“超级大脑”,它具备:

  • 强大的知识能力
  • 推理能力

但它本身不具备

  • 长期记忆
  • 行动能力
  • 任务规划能力

所以,一个完整的 AI 应用通常是这样的:

AI Agent = LLM(大脑) + Memory(记忆) + Planning(规划) + Tools(工具)

👉 而 LangChain,正是帮你把这些能力“拼装起来”的工具。

下面就让我们来开始一个demo 尝尝鲜 😄😄😄

模型申请(阿里云百炼:有免费额度)

点击下方链接,阅读文档,根据文档指引,申请API Key

image.png

image.png

  • 在模型广场选一个合适的模型即可,这里我们使用 qwen-coder-turbo

image.png

开发环境准备

  • 创建项目
mkdir langchain-project
cd langchain-project
npm init -y
  • 创建文件入口
src/langchain-invoke.mjs

说明:

  • .mjsESM 模块格式
  • 支持 import / export
  • 方便我们快速调试

👉 后续工程化后,直接使用 .js 也是完全没问题的

初始化模型

  • 安装依赖
pnpm install langchain
  • 使用 initChatModel 初始化模型
import { initChatModel } from 'langchain';
const model = initChatModel(
   'qwen-coder-turbo' // 模型名
    {
        modelProvider: "openai", // 告诉工厂:虽然是 xxx服务,但请用 OpenAI 的 SDK 逻辑
        baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1" // 对应SDK里的字段
        apiKey: 'sk-d69fd787d37241fxxxxe8bb914292108'  // 对应SDK里的字段
    }
)

模型调用

我们这里使用的是 ChatModel(对话模型),在初始化模型处 initChatModel

👉 特点:

  • 输入:单条消息 / 消息列表
  • 输出:一条 AIMessage
使用invoke
const response = await model.invoke("为什么鹦鹉有彩色的羽毛?");
console.log(response);
// console.log(response.content);

image.png

返回结果说明

返回值是一个 AIMessage 对象,例如:

{
  content: "因为羽毛中含有色素和结构色...",
  ...
}

👉 如果你只关心文本内容:

console.log(response.content);

🎉 小结

到这里,我们已经完成了:

  • 模型初始化
  • 模型调用
  • 获取返回结果

👉 一个最基础的 LangChain Demo 就跑通了,是不是很简单?

进阶补充

❗ 别让你的 API Key “裸奔” —— 使用.env

直接把 API Key 写死在代码里:

apiKey: 'sk-xxx'

👉 只适用于临时测试
👉 在生产环境中是非常危险的(会泄露密钥)

使用 dotenv 管理环境变量

  • 安装:
pnpm install dotenv
  • 创建 .env 文件:
# OpenAI API 配置

# OpenAI API 密钥
OPENAI_API_KEY=sk-d69fd787d37241xxxf9e8bb914292108
# OpenAI API 地址
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
# 模型名称
MODEL_NAME=qwen-coder-turbo

image.png

  • 在代码中使用
// import dotenv from "dotenv";
// dotenv.config();
import 'dotenv/config';
import { initChatModel } from 'langchain';

const model = await initChatModel(process.env.MODEL_NAME, {
  modelProvider: 'openai',
  apiKey: process.env.OPENAI_API_KEY,
  baseUrl: process.env.OPENAI_BASE_URL,
});

const result = await model.invoke("为什么鹦鹉有彩色的羽毛?");
console.log(result);
  • .gitignore 忽略敏感信息
node_modules
.env
package-lock.json
.DS_Store
为什么有时读取不到 .env ?

我一开始为了图省事,直接 cdsrc 目录,然后执行:

node ./langchain-invoke.mjs

结果直接报错:
👉 提示找不到 API Key

🧠 根本原因

问题其实出在这一点:

dotenv 默认是基于 当前执行目录(process.cwd) 去查找 .env 文件

也就是说:

👉 Node 并不知道你的“项目根目录”在哪里

👉 它只知道:你是从哪个目录执行 node 命令的

👉 src 下没有.env, 自然就读取不到了

初始化模型的另一种方式:ChatOpenAI

在早期 LangChain 使用中,我们通常直接使用 ChatOpenAI 这样的模型类。但随着多模型时代的到来,LangChain 提供了 initChatModel 作为统一入口,使开发者可以在不修改业务代码的情况下切换不同模型厂商,从而实现真正的模型解耦。

  • 安装
pnpm install @langchain/openai
  • 使用
import { ChatOpenAI } from '@langchain/openai';
const model = new ChatOpenAI({
  model: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
});
const result = await model.invoke("为什么鹦鹉有彩色的羽毛?");
console.log(result);
initChatModel 和 ChatOpenAI 的区别

👉 ChatOpenAI = 面向 **OpenAI 的模型 SDK(具体实现)
👉 initChatModel = 模型统一入口(抽象层 + 工厂模式)

架构对比

# ChatOpenAI(单一厂商)
你的代码 → ChatOpenAI → OpenAI API

# initChatModel(多模型架构)
你的代码 → initChatModel → Provider Adapter → 任意模型
消息类型

在上面我们已经接触到了 AIMessage,顾名思义,它表示 AI 返回的消息
除此之外,LangChain 中还定义了一系列标准消息类型:

  • HumanMessage:用户输入
  • SystemMessage:系统指令(用于控制 AI 行为)
  • AIMessage:模型输出
  • ToolMessage:工具执行结果
demo
  • 对象列表 (new xxx)
import { SystemMessage, HumanMessage, AIMessage } from "langchain";

const messages = [
  new SystemMessage("You are a poetry expert"),
  new HumanMessage("Write a haiku about spring"),
  new AIMessage("Cherry blossoms bloom..."),
];
const response = await model.invoke(messages);
  • 字典格式 (k-v)
const messages = [
  { role: "system", content: "You are a poetry expert" },
  { role: "user", content: "Write a haiku about spring" },
  { role: "assistant", content: "Cherry blossoms bloom..." },
];
const response = await model.invoke(messages);

他的demo相对比较简单,看官方文档即可 docs.langchain.com/oss/javascr…

🤔 为什么不直接返回字符串?

你可能会有一个疑问:

👉 “我问 AI 一个问题,它直接返回字符串不就行了吗?为什么还要包一层 AIMessage?”

🤔 先说结论

Message 的存在,不是为了“多包一层”,
而是为了让 AI 的输出,从“文本”升级为“可编排的数据结构”。

Message 解决的 4 个真实问题(重点)

Message 是AI 执行过程的“状态载体”,会贯穿后面整个学习流程,所以是至关重要的!!!

1️⃣ 让 AI 可以“调用系统能力”
AIMessage.tool_calls

👉 用于:

  • 查天气
  • 查数据库
  • 调接口
  • 调用你写的函数

👉 没有 Message:

AI 只能“建议你去做”

👉 有 Message:

AI 可以“驱动系统帮你做”

2️⃣ 让 AI 输出“结构化数据”

比如你做电商(你现在就在做)👇

用户说:

帮我生成一个商品标题

👉 string:

"2024新款男士运动鞋,透气舒适"

👉 你还得自己解析 😓


👉 Message + 结构化:

{
  content: "",
  additional_kwargs: {
    title: "2024新款男士运动鞋",
    keywords: ["透气", "舒适"],
    category: "运动鞋"
  }
}

💥 第三个价值:

天然支持结构化输出(适合业务系统)


3️⃣ 支持多轮对话(上下文)
[
  new SystemMessage("你是电商运营助手"),
  new HumanMessage("帮我优化标题"),
  new AIMessage("请提供商品信息"),
  new HumanMessage("男士跑鞋")
]

👉 如果只是 string:

❌ 上下文全丢


💥 第四个价值:

Message = 对话上下文容器



4️⃣ 支持“中间状态”(非常关键)

在复杂流程里,AI 不止返回最终结果:


👉 比如:

思考 → 调工具 → 再思考 → 输出结果

👉 Message 可以承载:

  • 中间推理(部分可见)
  • tool 调用
  • token 消耗
  • trace 信息

模型的其他调用方式

上面我们使用 invoke 来调用模型,可以发现它会在生成完整结果后一次性返回,因此往往需要等待一段时间。

如果希望像“打字机”一样实时看到模型的输出(流式返回),可以使用 stream 方法,让结果边生成边输出,提升交互体验。 除此之外,还有批量处理模型请求的batch

// stream
const stream = await model.stream("帮我生成一个比亚迪商品标题"); 
for await (const chunk of stream) { 
    // console.log('stream:', chunk);
    // console.log(chunk.text) 
    console.log(chunk.content) 
}

image.png

🎉 总结

本文围绕 LangChain 的 Model I/O,完成了从 模型初始化 → 输入构建 → 模型调用 → 输出解析 的最小闭环。

主要内容包括:

  1. 为什么使用 LangChain
    通过统一接口抹平不同模型厂商之间的差异,同时提供丰富的组件能力,让我们可以专注于业务开发,而不是处理各种兼容问题。

  2. 模型初始化
    以最常见的 ChatModel 为例,介绍了如何使用 initChatModel 初始化模型,以及 apiKeybaseUrl 等核心参数的配置方式,并补充了 dotenv 环境变量的使用实践。

  3. 消息类型(Message)
    通过模型返回值 AIMessage,引出了 SystemMessageHumanMessage 等消息类型,并讲解了对象形式与字典形式的写法。同时也解释了:
    👉 为什么模型不直接返回字符串,而是使用 Message 结构 —— 因为 Message 是 AI 执行过程的“状态载体”,能够支持工具调用、多轮对话、中间状态以及结构化输出等能力。

  4. 模型调用方式
    对比了 invokestreambatch 三种调用方式:

    • invoke:一次性返回,适合调试
    • stream:流式输出,更适合实际交互场景(主流方式)
    • batch:批量处理请求

至此,一个最基础的 AI Demo 已经完成。相信你已经对 LangChain 的 Model I/O 有了整体认知。

不过,关于 Message 的理解目前还停留在“能用”的阶段。
👉 在下一篇中,我们将深入分析 LangChain 的消息机制,以及它是如何驱动 Tool 和 Agent 执行的。

为了语句通顺,加入了很多ai 代写,希望大家谅解 🐶🐶🐶

入门文章,如有遗漏,还请多多指教 🤝🤝🤝

吃龙虾🦞咯!万字拆解OpenClaw的架构与设计 | 掘金一周 3.19

作者 掘金一周
2026年3月19日 16:11

本文字数1400+ ,阅读时间大约需要 5分钟。

【掘金一周】本期亮点:

上榜规则:文章发布时间在本期「掘金一周」发布时间的前一周内;且符合各个栏目的内容定位和要求。 如发现文章有抄袭、洗稿等违反社区规则的行为,将取消当期及后续上榜资格。

一周“金”选

image.png

内容评审们会在过去的一周内对社区深度技术好文进行挖掘和筛选,优质的技术文章有机会出现在下方榜单中,排名不分先后。

前端

用 Three.js 写了一个《我的世界》,结果老外差点给我众筹做游戏? @何贤

SimplexNoise + fbm 构建的地形则显得更加真实,这里可以简单的理解为 SimplexNoise 负责的是粗略的山峰和山谷的构建,而 fbm 则负责在这个大体基础上构建更加真实的地理细节

嗯…微信小程序主包又双叒叕不够用了!!! @古茗前端团队

随着移动设备硬件性能的提升以及微信版本的不断升级,用户设备对ES6及以上语法的支持度已显著提高。在这一背景下,大量为兼容ES5而引入的降级与垫片代码逐渐失去必要性,反而成为包体体积的负担,具备明确的优化空间。

断网也能装包? 我在物理隔离内网搭了一套完整的私有npm仓库 @LiuMingXin

下一步需要做的就是怎么把verdaccio整体部署到内网机器了,只要把verdaccio移入到内网机器,启动起来,后续只需要 更新storage和.verdaccio-db.json就可以实现依赖的更新了。

后端

用这个框架彻底摆脱Controller,从此专注业务——ArcRoute @一只叫煤球的猫

ArcRoute 内置了一条统一调用链:参数解析 → 校验 → 前置处理 → 业务调用 → 后置处理 → 响应包装。这意味着,很多原本散落在各个 Controller / Advice / Interceptor 里的重复逻辑,可以被整合成一条清晰、可插拔的管道。

Android

你还用 IDE 吗? AI 狂欢时代下 Cursor 慌了, JetBrains 等 IDE 的未来是什么? @恋猫de小郭

IDE 不再是开发中的关键环境,它的作用越来越弱,而强大的 Agent 重要性也越来越明显,你的产品除了要有优秀的模式,还需要有更前沿的 Agent 才能留得住用户。

谷歌 Genkit Dart 正式发布:现在可以使用 Dart 和 Flutter 构建全栈 AI 应用 @恋猫de小郭

Genkit 内置支持 LLM 工具调用,自带了 Agent 能力的适配场景,也是用一个 Agent 开发框架 ,通过 Action 和 Tool 的抽象,你可以定义一系列函数(比如查询数据库、发邮件、搜索网页),模型可以根据用户意图自主决定调用哪些工具

从零构建用于 Android 开发的 MCP 服务:原理、实践与工程思考 @fundroid

我们将在 main.py 中继续添加代码,实现所有工具。所有工具都将定义在 start_server 函数内部,以便访问 mcp 实例和共享的 temp_dir

详解 Compose background 的重组陷阱 @RockByte

在 Kotlin 中,给函数传递一个普通参数(如 Modifier.background(color)),参数的值必须在函数调用时(即 Composition 阶段)就被计算出来。这就迫使你在重组的时候读取了状态。这也会导致状态的变更会发生重组。

人工智能

吃龙虾🦞咯!万字拆解OpenClaw的架构与设计 @摸鱼的春哥

Gateway(网关层):作为整个系统的控制大脑。它负责维护 WebSocket 控制平面,进行全局的会话管理(Session),并决定消息如何被路由(Routing)到正确的目的地。

OpenClaw 完全指南:这可能是全网最新最全的系统化教程了! @ConardLi

OpenClaw 内置了持久化记忆系统,通过 Markdown 文件和向量数据库存储长期记忆。它采用 “向量 + 关键词” 的混合检索策略,既能通过语义匹配召回久远对话,也能精确提取实体信息,并支持跨会话、跨项目的记忆延续。

OpenClaw macOS 完整安装与本地模型配置教程(实战版) @吴佳浩

Skills 安装失败不影响主程序正常聊天。如果不需要这些特定功能,直接跳过即可。需要的话去 App Store 把 Xcode 更新到 16.4+ 再重新运行 openclaw skills install <n>

📖 投稿专区

大家可以在评论区推荐认为不错的文章,并附上链接和推荐理由,有机会呈现在下一期。文章创建日期必须在下期掘金一周发布前一周以内;可以推荐自己的文章、也可以推荐他人的文章。

【uniapp】小程序支持分包引用分包 node_modules 依赖产物打包到分包中

2026年3月19日 15:31

前言

5.04 版本之前的 uniapp 和 uniappx,小程序端不支持分包引用的 node_modules 依赖打包到分包中,这对于很多备受小程序主包体积超出困扰的开发者来说,显然不是一个好消息。为了解决这一问题,5.04 版本开始,hx项目或者 cli 项目支持分包引用的 node_modules 依赖打包到分包中。下面介绍下具体的操作步骤,示例项目请点击 ask.dcloud.net.cn/article/424…

分包优化

首先,需要在 mainfest.json 指定小程序节点下添加如下配置,例如:

{
  "mp-weixin": {
         "optimization": {
            "subPackages": true
          }
   }
}

筛选分包用的依赖

这一步尤为重要,要先梳理出哪些依赖是分包用到的,哪些是主包用到的,以及你期望的主包分包产物引用关系。

我们举一个简单的例子,主包用到了 lodash-esaddsubtract 函数,分包 sub 用到了 lodash-esmultiply 函数,这种分包用到的内容主包没用,就可以考虑使用这种策略,把 分包 sub 用到的 lodash-esmultiply 函数打包到 分包 sub 下,我们来看下 5.04 版本之前的效果

首先是项目结构

project.jpg

打包的产物体积

before.jpg

可以看到,用到的 lodash-es 的三个函数都被打包到了主包的 vendor.js 文件中。下面我们看下 5.04 如何解决这种问题

首先进入到分包的根目录,创建一个 package.json 文件,这里写分包需要用到的依赖,然后安装依赖

sub_node_modules.jpg

然后重新打包即可。

可以看到 分包 sub 根目录下面多了 vendor.js 文件,里面就是 lodash-esmultiply 函数

sub_vendor.jpg

after.jpg

注意事项

  • 该优化只对 vue3 项目生效
  • 支持 uniapp 和 uniappx 的小程序项目
  • 支持 hx 项目和 cli 项目,测试项目是 hx 项目,cli 项目同理
  • 仅支持 node_modules 中的 js 相关文件,不支持其他文件
  • 测试项目为附件六
  • 5.04 是指 hx 的版本号,uniapp 对应的依赖版本为 3.0.0-5000420260318001

Vite 核心原理:ESM 带来的开发时“瞬移”体验

作者 wuhen_n
2026年3月19日 10:18

前言

还记得用Webpack开发时的日常吗? 控制台输入 npm run dev ,等待 30 秒后项目终于启动了 ;过了一会儿,修改了一个文件,保存,等待 10 秒之后热更新完成;后来项目变大了,每次保存要等 20 秒以上...

这是 Webpack 时代的真实写照,而 Vite 的出现,彻底改变了这一切: 控制台输入 npm run dev ,1 秒后项目就启动了;修改了一个文件,保存,50ms 页面就更新了。

Vite是怎么做到的? 它不是魔法,而是巧妙地利用了现代浏览器的原生能力。本文将从最基础的概念讲起,带领我们一步步理解 Vite 的核心原理。

为什么传统构建工具这么慢?

Webpack的工作方式

Webpack 就像我们去参加宴席,必须要等酒店把所有的菜品都准备好,再一次性全部端上来;如果有一道菜没做好,我们就全部得等着:

Webpack的打包过程:
1. 找到入口文件 (main.js)
2. 解析import语句,找出所有依赖
3. 递归解析所有依赖的依赖
4. 把所有文件打包成一个bundle.js
5. 启动开发服务器
6. 浏览器加载bundle.js

随着项目越大,依赖越多,打包就会越慢。

为什么Webpack会越来越慢?

假如我们有这样一个项目结构:

project
├── vue (100个文件)
├── vue-router (50个文件)
├── pinia (30个文件)
├── element-plus (500个文件)
├── 你自己的组件 (200个文件)
└── 各种第三方库 (300个文件)

Webpack 启动时要处理 1180 个文件,并全部打包成一个文件,才能启动开发服务器。

ESM 基础:现代浏览器的模块系统

什么是ES Module?

在 ES Module 出现之前,我们是这样引入 JavaScript 的:

<!-- 老方式:必须按顺序,否则报错 -->
<script src="jquery.js"></script>
<script src="lodash.js"></script>
<script src="app.js"></script>

有了 ES Module 之后,我们可以这样写:

<script type="module">
  // 浏览器会自动加载这些依赖
  import $ from 'https://unpkg.com/jquery'
  import _ from 'https://unpkg.com/lodash'
  import app from './app.js'
</script>

浏览器如何加载ES Module?

// main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

createApp(App).use(router).mount('#app')

当浏览器遇到这个脚本时,会进行以下操作:

第1步:下载 main.js
     ↓
第2步:解析 main.js,发现需要 vue、App.vue、router
     ↓
第3步:同时下载 vue、App.vue、router (并行下载)
     ↓
第4步:解析 router.js,发现新的依赖
     ↓
第5步:继续下载新的依赖
     ↓
直到所有依赖都加载完成

而且,浏览器可以并行下载多个文件,互不影响。

ESM的核心特性

特性1:静态导入(编译时确定依赖)

import { ref } from 'vue'  // 打包工具可以静态分析

特性2:动态导入(运行时加载)

if (user.isAdmin) {
  const adminPanel = await import('./AdminPanel.vue')
  // 只有在需要时才加载
}

特性3:模块作用域

// a.js
const name = 'module-a'
export { name }

// b.js
const name = 'module-b'  // 同名变量,互不干扰
export { name }

Vite 的核心思想 - 让浏览器做它擅长的事

Vite 的开发服务器

Vite 的开发服务器做了什么?

// 简化的Vite服务器
class ViteDevServer {
  constructor() {
    this.app = require('koa')()  // HTTP服务器
    this.watcher = require('chokidar').watch('src')  // 文件监听
  }
  
  async start() {
    // 1. 启动HTTP服务器
    this.app.listen(3000)
    
    // 2. 注册中间件
    this.app.use(this.transformMiddleware())
    
    // 3. 开始监听文件变化
    this.watcher.on('change', this.handleFileChange.bind(this))
  }
  
  // 处理文件请求
  async transformMiddleware(ctx, next) {
    if (ctx.path.endsWith('.vue')) {
      // 当浏览器请求 .vue 文件时,才进行编译
      const code = await compileVueFile(ctx.path)
      ctx.body = code
    }
  }
}

Vite的启动流程

传统方式(Webpack):
启动 → 打包所有文件 → 启动服务器 → 浏览器请求 → 返回打包后的文件

Vite方式:
启动 → 启动服务器 → 浏览器请求 → 按需编译 → 返回单个文件

还是用餐厅来比喻:

  • Webpack:客人来之前做好所有菜;如果菜没做好,所有客人都得等着
  • Vite:客人点一道,做一道;做好一道,上一道

一个完整的请求流程

假设我们的项目结构是这样的:

src/
├── main.js
├── App.vue
└── components/
    └── HelloWorld.vue

浏览器访问页面的过程如下:

// 第1步:浏览器请求 index.html
GET /index.html

// index.html 内容
<!DOCTYPE html>
<html>
  <head>
    <script type="module" src="/src/main.js"></script>
  </head>
</html>

// 第2步:浏览器发现需要 main.js
GET /src/main.js

// main.js 内容
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

// 第3步:浏览器发现需要 vue 和 App.vue
GET /@modules/vue  // Vite 特殊处理
GET /src/App.vue

// 第4步:App.vue 中又引用了 HelloWorld.vue
GET /src/components/HelloWorld.vue

// 第5步:全部加载完成,页面显示

依赖预构建 - 解决性能瓶颈

如果没有预构建,会有什么问题?

问题1:CommonJS 模块无法在浏览器直接运行

import _ from 'lodash'  // lodash 是 CommonJS 格式,浏览器不认识

问题2:大量小文件请求

import { debounce } from 'lodash-es'
// lodash-es 有 600 多个文件!
// 浏览器要发 600 多个请求!

问题3:深度嵌套的依赖

import A from 'package-a'
// package-a 依赖 package-b
// package-b 依赖 package-c
// 每个包都要单独请求

预构建做了什么?

  1. 扫描项目中的所有 import
  2. 找出第三方依赖(不是相对路径的)
  3. esbuild 打包成单个文件
  4. 存到 node_modules/.vite/
  5. 下次直接使用打包后的文件

esbuild 为什么这么快?

  1. 用 Go 语言写的(直接编译成机器码)
  2. 充分利用 CPU 多核
  3. 一切从零设计,没有历史包袱
  4. 高度并行化

热更新 - 瞬间响应的秘密

热更新模式

修改代码 → 页面自动更新 → 状态保持不变 → 继续工作

热更新的工作原理

我们修改了一个文件
    ↓
Vite 监听到文件变化
    ↓
重新编译这个文件
    ↓
通过 WebSocket 通知浏览器
    ↓
浏览器请求更新的文件
    ↓
执行热更新回调
    ↓
页面局部更新,状态保留

WebSocket 通信

// 服务器端
class HMRServer {
  constructor(server) {
    // 创建 WebSocket 服务
    this.ws = new WebSocket.Server({ server })
    
    // 所有连接的客户端
    this.clients = new Set()
    
    this.ws.on('connection', (socket) => {
      this.clients.add(socket)
      
      socket.on('close', () => {
        this.clients.delete(socket)
      })
    })
  }
  
  // 文件变化时通知所有客户端
  sendUpdate(file) {
    const message = JSON.stringify({
      type: 'update',
      file: file,
      timestamp: Date.now()
    })
    
    this.clients.forEach(client => {
      client.send(message)
    })
  }
}

// 浏览器端
const socket = new WebSocket(`ws://${location.host}`)

socket.onmessage = async ({ data }) => {
  const { type, file, timestamp } = JSON.parse(data)
  
  if (type === 'update') {
    // 重新加载修改的文件
    const module = await import(`${file}?t=${timestamp}`)
    
    // 执行热更新
    if (import.meta.hot) {
      import.meta.hot.accept(file, module)
    }
  }
}

Vue 组件的热更新

// Vue 组件的热更新实现
if (import.meta.hot) {
  import.meta.hot.accept((newModule) => {
    // 更新组件
    const { render, data } = newModule
    
    // 保留当前组件的状态
    const oldData = instance.data
    
    // 应用新的渲染函数
    instance.render = render
    
    // 重新渲染
    instance.update()
  })
}

插件系统:Vite 的扩展能力

插件的工作流程

请求进入
    ↓
resolveId(解析模块 ID)
    ↓
load(加载模块内容)
    ↓
transform(转换代码)
    ↓
返回给浏览器

插件的钩子函数

// 一个完整的 Vite 插件
const myPlugin = {
  name: 'vite:my-plugin',
  
  // 构建阶段钩子
  options(options) {
    // 修改或扩展配置
    return options
  },
  
  buildStart() {
    // 构建开始时调用
    console.log('构建开始')
  },
  
  // 解析模块 ID
  resolveId(source, importer) {
    if (source === 'virtual-module') {
      return '\0virtual-module' // \0 标记为虚拟模块
    }
  },
  
  // 加载模块
  load(id) {
    if (id === '\0virtual-module') {
      return 'export default "virtual module content"'
    }
  },
  
  // 转换代码
  async transform(code, id) {
    if (id.endsWith('.special')) {
      // 转换特殊文件格式
      const result = await compileSpecial(code)
      return {
        code: result.js,
        map: result.sourcemap
      }
    }
  },
  
  // 配置解析完成后
  configResolved(config) {
    console.log('配置已解析', config)
  },
  
  // 热更新处理
  handleHotUpdate(ctx) {
    // 自定义热更新逻辑
  },
  
  // 构建结束
  buildEnd() {
    console.log('构建结束')
  },
  
  // 关闭服务
  closeBundle() {
    console.log('服务关闭')
  }
}

常用插件示例

// 环境变量注入插件
function injectEnvPlugin(env: Record<string, string>) {
  return {
    name: 'vite:inject-env',
    
    transform(code, id) {
      if (id.includes('node_modules')) return
      
      // 替换环境变量
      return code.replace(
        /import\.meta\.env\.(\w+)/g,
        (_, key) => JSON.stringify(env[key])
      )
    }
  }
}

// 文件大小监控插件
function sizeMonitorPlugin() {
  return {
    name: 'vite:size-monitor',
    
    generateBundle(_, bundle) {
      Object.entries(bundle).forEach(([name, asset]) => {
        if (asset.type === 'chunk') {
          const size = asset.code.length
          const kb = (size / 1024).toFixed(2)
          
          if (size > 100 * 1024) {
            console.warn(`⚠️ 大文件警告: ${name} (${kb}KB)`)
          } else {
            console.log(`✅ ${name}: ${kb}KB`)
          }
        }
      })
    }
  }
}

Vite vs Webpack

启动时间对比

项目规模 Webpack Vite 差距
小项目(50组件) 8.5秒 1.2秒 Vite快7倍
中项目(200组件) 22秒 2.1秒 Vite快10倍
大项目(1000组件) 58秒 3.8秒 Vite快15倍

热更新时间对比

操作 Webpack Vite 差距
修改一个组件 2.8秒 45ms Vite快62倍
修改CSS 1.5秒 8ms Vite快187倍
保存后恢复 3.1秒 60ms Vite快52倍

资源消耗对比

指标 Webpack Vite 差距
CPU占用 45% 18% 降低60%
内存占用 1.8GB 420MB 降低77%
电池消耗 延长2-3倍

常见问题与优化技巧

问题一:依赖预构建失效

修改了 node_modules 里的代码,但是不生效:

解决方案1:强制重新预构建

// vite.config.ts
export default {
  optimizeDeps: {
    // 强制重新预构建
    force: true
  }
}

解决方案2:删除缓存目录

$ rm -rf node_modules/.vite

解决方案3:重启开发服务器

npm run dev

问题二:热更新不生效

修改了文件,但页面不更新,可以按以下步骤排查:

步骤1:检查 WebSocket 连接

打开浏览器控制台,看是否有 WebSocket 连接。

步骤2:检查文件监听配置

export default {
  server: {
    watch: {
      // 确保没有忽略我们的文件
      ignored: ['!**/node_modules/**']
    }
  }
}

步骤3:手动触发更新

if (import.meta.hot) {
  import.meta.hot.accept()
}

问题三:首次加载慢

第一次打开页面要等很久。

解决方案:预加载关键路由

export default {
  optimizeDeps: {
    include: [
      // 预构建这些依赖
      'vue',
      'vue-router',
      'pinia',
      // 你的常用组件
      'src/components/Button.vue',
      'src/components/Modal.vue'
    ]
  }
}

问题四:内存占用过高

// vite.config.ts
export default {
  server: {
    // 限制缓存大小
    moduleCache: {
      maxSize: 100 * 1024 * 1024 // 100MB
    },
    
    // 清理未使用的模块
    moduleGraph: {
      pruneInterval: 60000 // 每 60 秒清理一次
    }
  }
}

Vite 的最佳实践

Vite 配置文件模板

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'

export default defineConfig({
  // 插件
  plugins: [vue()],
  
  // 开发服务器配置
  server: {
    port: 3000,
    open: true,  // 自动打开浏览器
    proxy: {
      '/api': 'http://localhost:8080'  // 代理
    }
  },
  
  // 构建配置
  build: {
    target: 'es2020',
    outDir: 'dist',
    assetsDir: 'assets',
    sourcemap: true
  },
  
  // 依赖优化
  optimizeDeps: {
    include: ['vue', 'vue-router', 'pinia']
  },
  
  // 别名
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src')
    }
  }
})

性能优化清单

  • 依赖预构建:配置 optimizeDeps.include 预构建常用依赖
  • 路由懒加载:使用动态 import() 分割代码
  • 图片优化:使用 vite-plugin-image-optimizer
  • CSS 提取:生产环境提取独立 CSS 文件
  • Gzip 压缩:使用 vite-plugin-compression

学习要点

  1. 理解 ESM 的核心特性:静态导入、模块作用域、浏览器加载机制
  2. 掌握依赖预构建的作用:解决 CommonJS 兼容性、减少请求数
  3. 熟悉热更新的工作流程:WebSocket 通信、模块边界、HMR API
  4. 学会编写 Vite 插件:钩子函数、虚拟模块、代码转换
  5. 能够诊断和优化性能问题:预构建失效、热更新慢、内存占用高

结语

Vite 的出现,标志着前端构建工具从打包时代进入了原生 ESM 时代。理解它的核心原理,不仅能让我们更高效地使用它,更能让我们对现代前端开发有更深的理解。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

Cesium 海量点位不卡顿!图标动态聚合效果深度解析,看完直接抄代码!

作者 李剑一
2026年3月19日 09:45

接上文# 告别冗余代码!Cesium点位图标模糊、重叠?自适应参数调优攻略,一次封装终身复用!,在地图上创建图标是基础操作,但是当地图上的图标过多的时候展示效果其实并不好。

毕竟谁也不想看到密密麻麻的图标,所以部分距离相近的图标应该聚合在一起,形成一个聚合图标展示出来。

image.png

在Cesium开发中,图标聚合能够解决海量图标重叠、界面杂乱、性能卡顿等问题。

尤其在智慧安防、智慧园区、设备监控等场景,几十个甚至上百个摄像头/设备图标挤在一块,不仅看不清,还会严重影响地图流畅度。

解决方案

通过监听相机高度,高度超过阈值,自动开启聚合。

根据计算屏幕像素距离,把三维坐标转成屏幕坐标,算两点多远,距离小于设定值,归为一组。

image.png

这时候隐藏原始图标,只显示聚合图标。

生成聚合点:显示图标+数量,拉近后自动散开。

实现代码

计算屏幕距离 + 判断是否在屏幕内。是聚合的核心基础:把三维坐标转屏幕坐标,再算距离。

/**
 * 计算两点在屏幕上的像素距离
 */
const calculateScreenDistance = (pos1, pos2) => {
    if (!viewer.value || !viewer.value.scene) return Infinity
    
    const scene = viewer.value.scene
    try {
        // 世界坐标 → 屏幕坐标
        const screenPos1 = Cesium.SceneTransforms.worldToWindowCoordinates(scene, pos1)
        const screenPos2 = Cesium.SceneTransforms.worldToWindowCoordinates(scene, pos2)
        
        if (!screenPos1 || !screenPos2) return Infinity
        
        // 勾股定理算像素距离
        const dx = screenPos1.x - screenPos2.x
        const dy = screenPos1.y - screenPos2.y
        return Math.sqrt(dx * dx + dy * dy)
    } catch (error) {
        return Infinity
    }
}

/**
 * 检查点是否在屏幕上可见
 */
const isPositionOnScreen = (position) => {
    if (!viewer.value || !viewer.value.scene) return false
    try {
        const screenPos = Cesium.SceneTransforms.worldToWindowCoordinates(viewer.value.scene, position)
        return screenPos != null
    } catch (error) {
        return false
    }
}

生成聚合点,图标更大、创建label显示当前标签数量更明显。

/**
 * 创建聚合图标
 */
const createClusterIcon = (clusterData) => {
    if (!viewer.value) return null
    const { icons, type, center } = clusterData
    const count = icons.length

    // 坐标转换
    const cartographic = Cesium.Cartographic.fromCartesian(center)
    const longitude = Cesium.Math.toDegrees(cartographic.longitude)
    const latitude = Cesium.Math.toDegrees(cartographic.latitude)

    // 创建聚合实体
    const clusterId = `cluster_${type}_${Date.now()}`
    const entity = viewer.value.entities.add({
        id: clusterId,
        position: center,
        billboard: {
            image: getClusterIconUrl(type),
            scale: 1.2,
            width: 40,
            height: 40,
            verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
            disableDepthTestDistance: Number.POSITIVE_INFINITY
        }
    })

    // 聚合数量标签
    const typeName = getTypeDisplayName(type)
    entity.label = {
        text: `${typeName} ${count}个`,
        font: '14px sans-serif',
        fillColor: Cesium.Color.WHITE,
        outlineColor: Cesium.Color.BLACK,
        outlineWidth: 2,
        pixelOffset: new Cesium.Cartesian2(0, -50),
        showBackground: true,
        disableDepthTestDistance: Number.POSITIVE_INFINITY
    }

    // 存入聚合列表
    clusterEntities.set(clusterId, { entity, icons, type, center })
    return entity
}

动态计算聚合阈值,通过遍历图标 → 分组 → 合并/显示,自动隐藏原始图标,显示聚合点。

/**
 * 更新图标聚合状态
 */
const updateClustering = () => {
    if (!viewer.value || iconEntities.size === 0) return
    clearClusters()

    // 关闭聚合 = 显示全部
    if (!isClusteringEnabled.value) {
        showAllIcons()
        return
    }

    // 动态阈值:相机越高,聚合越明显
    const cameraHeight = viewer.value.camera.positionCartographic.height
    const dynamicClusterDistance = Math.min(
        MAX_SCREEN_CLUSTER_DISTANCE,
        SCREEN_CLUSTER_DISTANCE + (cameraHeight - CLUSTER_THRESHOLD) / 50
    )

    // 收集所有图标
    const allIcons = []
    iconEntities.forEach((iconData, id) => {
        const position = iconData.entity.position.getValue(Cesium.JulianDate.now())
        allIcons.push({ id, entity: iconData.entity, position, type: iconData.type })
    })

    // 先隐藏所有图标
    allIcons.forEach(icon => icon.entity.show = false)

    // 聚类算法
    const clusters = []
    const visited = new Set()

    for (let i = 0; i < allIcons.length; i++) {
        if (visited.has(i)) continue
        const current = allIcons[i]
        if (!isPositionOnScreen(current.position)) continue

        const cluster = [current]
        visited.add(i)

        // 寻找附近图标
        for (let j = i + 1; j < allIcons.length; j++) {
            if (visited.has(j)) continue
            const other = allIcons[j]
            if (!isPositionOnScreen(other.position)) continue

            const dist = calculateScreenDistance(current.position, other.position)
            if (dist <= dynamicClusterDistance) {
                cluster.push(other)
                visited.add(j)
            }
        }
        clusters.push(cluster)
    }

    // 生成聚合点 / 显示单个图标
    clusters.forEach(cluster => {
        if (cluster.length === 1) {
            cluster[0].entity.show = true
        } else {
            // 计算中心点
            let centerX = 0, centerY = 0, centerZ = 0
            cluster.forEach(icon => {
                centerX += icon.position.x
                centerY += icon.position.y
                centerZ += icon.position.z
            })
            const center = new Cesium.Cartesian3(
                centerX / cluster.length,
                centerY / cluster.length,
                centerZ / cluster.length
            )

            createClusterIcon({
                icons: cluster.map(c => c.id),
                type: 'camera',
                center
            })
        }
    })
}

总结

Cesium 图标聚合原理上很简单:

算距离 → 分组 → 隐藏/显示 → 生成聚合点

在园区级别的模型上其实启不启用影响不大,但是在城市级别,或者是多地区复杂情况的模型上还是有必要的。

能够极大的提升加载的流畅度,减少操作的卡顿。

前端工程师转型 AI Agent 工程师:后端能力补全指南

作者 兆子龙
2026年3月19日 07:00

一、为什么前端工程师需要补后端能力

AI Agent 正在成为软件开发的新范式。作为前端工程师,我们天然具备用户界面、交互体验方面的优势,但 AI Agent 的开发远不止于前端界面。一个完整的 AI Agent 系统需要:

  • 后端服务:承载模型推理、任务调度、数据存储
  • API 设计:定义 Agent 与外部系统的交互协议
  • 数据库设计:管理对话历史、知识库、用户配置
  • 安全认证:保护 API 密钥、用户数据

本文系统梳理前端工程师转型 AI Agent 开发时需要补齐的后端能力,助你建立完整的全栈视角。

二、HTTP 与 RESTful API 深度掌握

2.1 HTTP 协议核心概念

理解 HTTP 是后端开发的基础。AI Agent 需要与各种服务交互:

// 使用 fetch 发送请求
const response = await fetch('https://api.openai.com/v1/chat/completions', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${API_KEY}`
  },
  body: JSON.stringify({
    model: 'gpt-4',
    messages: [{ role: 'user', content: 'Hello' }],
    temperature: 0.7
  })
});

const data = await response.json();
console.log(data.choices[0].message.content);

2.2 RESTful API 设计规范

设计良好的 API 是 Agent 与服务交互的基础:

// RESTful API 设计示例
// 资源命名使用名词复数
GET    /api/agents          // 获取 Agent 列表
GET    /api/agents/:id      // 获取单个 Agent
POST   /api/agents          // 创建 Agent
PUT    /api/agents/:id      // 更新 Agent
DELETE /api/agents/:id      // 删除 Agent

// 嵌套资源
GET    /api/agents/:id/memory      // 获取 Agent 的记忆
POST   /api/agents/:id/memory      // 添加记忆
DELETE /api/agents/:id/memory/:mid // 删除记忆

2.3 状态码与错误处理

// 合理使用 HTTP 状态码
200 OK                    // 成功
201 Created               // 创建成功
204 No Content            // 删除成功,无返回内容

400 Bad Request           // 参数错误
401 Unauthorized          // 未认证
403 Forbidden             // 无权限
404 Not Found             // 资源不存在
422 Unprocessable Entity  // 业务逻辑错误
429 Too Many Requests     // 限流

500 Internal Server Error // 服务器错误
503 Service Unavailable   // 服务不可用

三、Node.js 服务端开发

3.1 Express 框架快速入门

const express = require('express');
const app = express();

// 中间件
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// 路由
app.get('/api/agents', (req, res) => {
  res.json({ agents: [] });
});

app.post('/api/agents', async (req, res) => {
  try {
    const { name, systemPrompt } = req.body;
    const agent = await createAgent({ name, systemPrompt });
    res.status(201).json(agent);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

app.listen(3000, () => {
  console.log('Server running on port 3000');
});

3.2 中间件设计模式

中间件是 Express 的核心概念:

// 日志中间件
app.use((req, res, next) => {
  console.log(`${new Date().toISOString()} ${req.method} ${req.path}`);
  next();
});

// 认证中间件
const authMiddleware = (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) {
    return res.status(401).json({ error: 'No token provided' });
  }
  try {
    req.user = verifyToken(token);
    next();
  } catch (error) {
    res.status(401).json({ error: 'Invalid token' });
  }
};

// 限流中间件
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 分钟
  max: 100 // 限制 100 次请求
});
app.use('/api/', limiter);

3.3 异步处理与错误捕获

// 异步路由处理
app.get('/api/agents/:id', async (req, res) => {
  try {
    const agent = await database.agents.findById(req.params.id);
    if (!agent) {
      return res.status(404).json({ error: 'Agent not found' });
    }
    res.json(agent);
  } catch (error) {
    console.error('Database error:', error);
    res.status(500).json({ error: 'Internal server error' });
  }
});

// 全局错误处理
app.use((err, req, res, next) => {
  console.error('Error:', err);
  res.status(500).json({
    error: process.env.NODE_ENV === 'production'
      ? 'Internal server error'
      : err.message
  });
});

四、数据库设计与操作

4.1 数据库选型

AI Agent 项目常用数据库:

数据库 适用场景 优点
PostgreSQL 关系型数据、事务 可靠性强、JSON 支持
MongoDB 文档存储、灵活结构 开发快、易扩展
Redis 缓存、会话、消息队列 速度快、功能丰富
Elasticsearch 全文搜索、知识检索 搜索能力强
Pinecone/Weaviate 向量存储、语义搜索 专为 AI 设计

4.2 PostgreSQL 基础操作

const { Pool } = require('pg');
const pool = new Pool({
  connectionString: process.env.DATABASE_URL
});

// 创建表
await pool.query(`
  CREATE TABLE IF NOT EXISTS agents (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    system_prompt TEXT,
    model VARCHAR(100),
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
  )
`);

// 插入数据
const result = await pool.query(
  'INSERT INTO agents (name, system_prompt, model) VALUES ($1, $2, $3) RETURNING *',
  ['ChatAgent', 'You are a helpful assistant.', 'gpt-4']
);
console.log('Created:', result.rows[0]);

// 查询数据
const agents = await pool.query(
  'SELECT * FROM agents WHERE model = $1 ORDER BY created_at DESC',
  ['gpt-4']
);
console.log('Agents:', agents.rows);

// 更新数据
await pool.query(
  'UPDATE agents SET system_prompt = $1, updated_at = NOW() WHERE id = $2',
  ['You are a coding expert.', agentId]
);

// 删除数据
await pool.query('DELETE FROM agents WHERE id = $1', [agentId]);

4.3 ORM 使用:Prisma

// schema.prisma
model Agent {
  id           String   @id @default(uuid())
  name         String
  systemPrompt String
  model        String
  memories     Memory[]
  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt
}

model Memory {
  id        String   @id @default(uuid())
  content   String
  type      String
  agentId   String
  agent     Agent    @relation(fields: [agentId], references: [id])
  createdAt DateTime @default(now())
}

// 代码中使用
const agent = await prisma.agent.create({
  data: {
    name: 'ChatAgent',
    systemPrompt: 'You are helpful.',
    model: 'gpt-4'
  }
});

const agents = await prisma.agent.findMany({
  where: { model: 'gpt-4' },
  include: { memories: true },
  orderBy: { createdAt: 'desc' }
});

4.4 向量数据库:Pinecone

AI Agent 需要存储和检索向量:

const { Pinecone } = require('@pinecone-database/pinecone');
const pinecone = new Pinecone({ apiKey: process.env.PINECONE_API_KEY });

// 创建索引
await pinecone.createIndex({
  name: 'agent-memories',
  dimension: 1536, // OpenAI ada-002 维度
  metric: 'cosine'
});

// 存储向量
const index = pinecone.index('agent-memories');
await index.upsert([
  {
    id: 'memory-1',
    values: [0.1, 0.2, 0.3, ...], // 1536 维向量
    metadata: { type: 'conversation', topic: 'React' }
  }
]);

// 相似性检索
const queryResponse = await index.query({
  vector: [0.1, 0.2, 0.3, ...],
  topK: 5,
  includeMetadata: true
});
console.log('Similar memories:', queryResponse.matches);

五、认证与安全

5.1 JWT 认证

const jwt = require('jsonwebtoken');

// 生成 Token
function generateToken(user) {
  return jwt.sign(
    { userId: user.id, email: user.email },
    process.env.JWT_SECRET,
    { expiresIn: '7d' }
  );
}

// 验证 Token
function verifyToken(token) {
  return jwt.verify(token, process.env.JWT_SECRET);
}

// 中间件
const authenticate = (req, res, next) => {
  const authHeader = req.headers.authorization;
  if (!authHeader) {
    return res.status(401).json({ error: 'No token' });
  }
  
  const token = authHeader.split(' ')[1];
  try {
    req.user = verifyToken(token);
    next();
  } catch (error) {
    res.status(401).json({ error: 'Invalid token' });
  }
};

5.2 API 密钥管理

// 安全的 API 密钥存储
const crypto = require('crypto');

// 生成 API 密钥
function generateApiKey() {
  return `sk_${crypto.randomBytes(32).toString('hex')}`;
}

// 密钥哈希存储
function hashKey(key) {
  return crypto.createHash('sha256').update(key).digest('hex');
}

// 验证密钥
async function validateApiKey(key) {
  const hashed = hashKey(key);
  const stored = await database.apiKeys.findOne({ hashed });
  return stored;
}

5.3 CORS 与安全头

const cors = require('cors');
const helmet = require('helmet');

app.use(helmet());
app.use(cors({
  origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization']
}));

// 输入验证
const Joi = require('joi');
const schema = Joi.object({
  name: Joi.string().min(1).max(100).required(),
  systemPrompt: Joi.string().max(10000),
  model: Joi.string().valid('gpt-3.5-turbo', 'gpt-4')
});

app.post('/api/agents', async (req, res) => {
  const { error, value } = schema.validate(req.body);
  if (error) {
    return res.status(400).json({ error: error.details[0].message });
  }
  // ...
});

六、消息队列与任务调度

6.1 BullMQ 任务队列

const { Queue, Worker } = require('bullmq');
const IORedis = require('ioredis');

// 创建队列
const agentQueue = new Queue('agent-tasks', {
  connection: new IORedis(process.env.REDIS_URL)
});

// 添加任务
await agentQueue.add('process-message', {
  agentId: 'agent-123',
  message: 'Hello',
  userId: 'user-456'
});

// 处理任务
const worker = new Worker('agent-tasks', async job => {
  const { agentId, message, userId } = job.data;
  
  // 调用 AI 模型
  const response = await callAgent(agentId, message);
  
  // 存储结果
  await saveMessage({ agentId, userId, message, response });
  
  return response;
}, { connection: new IORedis(process.env.REDIS_URL) });

worker.on('completed', job => {
  console.log(`Job ${job.id} completed`);
});

6.2 定时任务

const cron = require('node-cron');

// 每天凌晨清理过期会话
cron.schedule('0 0 * * *', async () => {
  await cleanupExpiredSessions();
  console.log('Cleaned up expired sessions');
});

// 每小时同步数据
cron.schedule('0 * * * *', async () => {
  await syncData();
  console.log('Data synced');
});

七、WebSocket 实时通信

7.1 Socket.io 基础

const { Server } = require('socket.io');
const io = new Server(3000, {
  cors: { origin: '*' }
});

io.on('connection', socket => {
  console.log('User connected:', socket.id);
  
  // 加入 Agent 房间
  socket.on('join-agent', ({ agentId, userId }) => {
    socket.join(`agent:${agentId}`);
    console.log(`User ${userId} joined agent ${agentId}`);
  });
  
  // 发送消息
  socket.on('send-message', async ({ agentId, message, userId }) => {
    // 广播消息给房间内所有人
    io.to(`agent:${agentId}`).emit('message', {
      role: 'user',
      content: message,
      userId
    });
    
    // 调用 Agent 处理
    const response = await callAgent(agentId, message);
    
    // 返回 Agent 响应
    io.to(`agent:${agentId}`).emit('message', {
      role: 'assistant',
      content: response,
      agentId
    });
  });
  
  socket.on('disconnect', () => {
    console.log('User disconnected:', socket.id);
  });
});

7.2 客户端使用

import { io } from 'socket.io-client';

const socket = io('https://api.example.com');

socket.on('connect', () => {
  console.log('Connected to server');
  
  socket.emit('join-agent', { agentId: 'agent-123', userId: 'user-456' });
});

socket.on('message', message => {
  console.log('Received:', message);
  // 更新 UI
});

function sendMessage(content) {
  socket.emit('send-message', {
    agentId: 'agent-123',
    message: content,
    userId: 'user-456'
  });
}

八、Docker 容器化部署

8.1 Dockerfile 编写

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

# 设置工作目录
WORKDIR /app

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

# 安装依赖
RUN npm ci --only=production

# 复制源代码
COPY . .

# 构建
RUN npm run build

# 暴露端口
EXPOSE 3000

# 启动命令
CMD ["node", "dist/index.js"]

8.2 Docker Compose

version: '3.8'

services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgresql://user:pass@db:5432/agentdb
      - REDIS_URL=redis://cache:6379
      - OPENAI_API_KEY=${OPENAI_API_KEY}
    depends_on:
      - db
      - cache
    networks:
      - agent-network

  db:
    image: postgres:15
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=pass
      - POSTGRES_DB=agentdb
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - agent-network

  cache:
    image: redis:7-alpine
    networks:
      - agent-network

networks:
  agent-network:
    driver: bridge

volumes:
  postgres_data:

九、监控与日志

9.1 结构化日志

const winston = require('winston');

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [
    new winston.transports.File({ filename: 'error.log', level: 'error' }),
    new winston.transports.File({ filename: 'combined.log' })
  ]
});

logger.info('Agent created', { agentId: 'agent-123', model: 'gpt-4' });
logger.error('API call failed', { error: error.message, agentId: 'agent-123' });

9.2 健康检查

const express = require('express');
const { Pool } = require('pg');

const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

app.get('/health', async (req, res) => {
  try {
    // 检查数据库连接
    await pool.query('SELECT 1');
    res.json({ status: 'healthy', timestamp: new Date().toISOString() });
  } catch (error) {
    res.status(503).json({ status: 'unhealthy', error: error.message });
  }
});

app.get('/ready', (req, res) => {
  // 检查所有依赖是否就绪
  const checks = [
    { name: 'database', ready: true },
    { name: 'redis', ready: true }
  ];
  
  const allReady = checks.every(c => c.ready);
  res.status(allReady ? 200 : 503).json({ ready: allReady, checks });
});

十、学习路径建议

10.1 分阶段学习计划

第一阶段(1-2 周):Node.js 基础

  • Express 框架
  • 中间件机制
  • 路由设计

第二阶段(2-3 周):数据库

  • PostgreSQL 基础
  • Prisma ORM
  • Redis 缓存

第三阶段(1-2 周):认证与安全

  • JWT 认证
  • API 密钥管理
  • 安全最佳实践

第四阶段(1-2 周):消息队列

  • BullMQ 任务队列
  • 定时任务
  • WebSocket 实时通信

第五阶段(1 周):部署运维

  • Docker 容器化
  • 监控日志
  • CI/CD 流程

10.2 推荐资源

  • Node.js 官方文档
  • Express.js 指南
  • Prisma 文档
  • PostgreSQL 教程
  • Docker 官方文档

总结

前端工程师转型 AI Agent 开发,后端能力是必经之路。核心技能包括:

  • HTTP 与 API 设计:理解请求响应模型
  • Node.js 服务端开发:构建 API 服务
  • 数据库操作:存储和检索数据
  • 认证与安全:保护系统安全
  • 消息队列:处理异步任务
  • 实时通信:支持交互式对话
  • 容器化部署:实现可重复部署

掌握这些技能后,你将能够独立构建完整的 AI Agent 系统,从前端界面到后端服务,从数据存储到部署运维,真正成为 AI Agent 工程师。

如果这篇文章对你有帮助,欢迎点赞收藏,也欢迎在评论区分享你的学习心得。

CSS Modules完全指南:CSS模块化的特性,生态工具和实践

作者 漂流瓶jz
2026年3月19日 00:39

简介

在之前的文章中,我们了解了很多CSS命名规范:BEM、OOCSS、SMACSS、ITCSS、AMCSS、SUITCSS:CSS命名规范简介。它们可以解决CSS样式全局生效容易引发污染和冲突的问题。但这些方案基本都是写一个前缀或后缀,通过手写命名的方式避免类名重复。但这在多人协作或引入大量外部库时,依然不能完全避免问题,还需依赖团队规范管理。那么,是否有工具可以自动做这件事,而且完全避免组件内的类名与其它组件重复?有的,这就是CSS Modules。

CSS Modules中文叫做CSS模块。默认情况下,我们定义的CSS类名标识符是全局的。使用CSS Modules之后,每个类名将变为唯一的全局名称,包含不会重复的哈希值。引入CSS文件时,我们可以拿到CSS文件导出的类名到全局名称的对应关系,从而在HTML中提供相应的类名。

对于希望共享的类名,CSS Modules也提供了方案使其全局生效。同时CSS Modules还提供了定制标识符,class组合等功能。要想实现CSS Module的功能,代码需要经过打包,而且由于不同前端框架代码的组织方式不一样,CSS Module的具体使用也有区别,我们逐一介绍一下。

纯JavaScript使用方式

首先我们抛开各种前端框架,在纯粹的JavaScript代码中演示CSS Modules的效果。这里选用Vite,首先命令行执行代码,创建工程:

npm init -y
npm add -D vite

然后在package.json的scripts中增加几个构建相关命令。dev开发模式,build生产模式构建,preview生产模式预览。

{
  "dev": "vite",
  "build": "vite build",
  "preview": "vite preview"
}

然后创建index.html,为浏览器入口文件,里面引入index.js。

<html>
  <script src="./index.js" type="module"></script>
  <body>
    <div>jsplp CSS Modules</div>
  </body>
</html>

然后创建两个CSS文件,分别是使用CSS Modules的index.module.css和没有使用的index.css:

/* index.css */
.class1 {
  color: red;
}

/* index.module.css */
.class2 {
  background-color: yellow;
}
.abcDef {
  background-color: yellow;
  .qazwsx {
    background-color: yellow;
  }
}
#id1 {
  background-color: yellow;
}

然后是index.js文件,引入这两个CSS文件,并在DOM中增加几个div元素,使用这些CSS类:

import './index.css';
import styles from './index.module.css';

console.log(styles)

const test1 = document.createElement('div');
test1.textContent = 'test1';
document.body.appendChild(test1);

const test2 = document.createElement('div');
test2.className = 'class1';
test2.textContent = 'test2';
document.body.appendChild(test2);

const test3 = document.createElement('div');
test3.className = styles.class2;
test3.textContent = 'test3';
document.body.appendChild(test3);

const test4 = document.createElement('div');
test4.className = 'class1' + " " + styles.class2;
test4.textContent = 'test4';
document.body.appendChild(test4);

/* 输出结果
{
  abcDef: "_abcDef_1wjui_7",
  class2: "_class2_1wjui_1",
  id1: "_id1_1wjui_1",
  qazwsx: "_qazwsx_1wjui_11",
}
*/

我们在index.module.css中列举了几个场景,分别是class名,嵌套class名,id名。将其引入为一个对象并输出结果,发现它是一个key为原来的标识符名称,value为包含哈希值的新标识符的对象。要使用类名时,需要将这个新标识符提供给DOM进行渲染。而对比普通CSS文件,只需要简单引入即可生效。在index.js中我们还创建了四个场景,分别是:

  • test1 没有类名
  • test2 普通CSS类名 class1
  • test3 CSS Modules类名 class2
  • test4 普通 class1 + 模块的 class2

因为新标识符实际上也是个字符串,因此可以和普通类名结合使用,中间加个空格即可。当然也可以使用classnames等辅助工具组合类名。执行 npm run dev,看一下效果:

css-modules-1.png

通过浏览器可以看到,CSS Modules类名不仅成功作为CSS类名,而且还能和对应的CSS文件里面的规则对应上。test4这种结合class属性也可以生效。我们再执行npm run build,看一下打包后的文件内容:

css-modules-2.png

查看dist目录中打包后的文件内容,可以看到CSS文件和JS文件被分开单独引入到HTML文件中。两个CSS文件被合并为一个,普通CSS文件还是维持原来的类名,index.module.css则变为了带哈希值的新标识符名。对应的JS文件中引入的styles变为了常量对象,内容也是新标识符的映射关系。

通过上面的代码演示,我们能够了解CSS Modules的核心思路,即改变CSS标识符的名称,使其不会重复;需要使用对应标识符的地方要用JavaScript手动引入;同时更改CSS文件中的标识符以匹配新的名称。

CSS Modules特性

CSS Modules除了上面的核心特性之外,还包含一些特性。这里我们介绍和尝试一下它的主要特性:

多文件引用CSS模块

前面我们在同一个JavaScript文件中引入了CSS Modules的CSS文件,多次使用引入的标识符,发现值实际是一样的。那么如果在不同的文件中引入CSS Modules的CSS文件,新标识符会一样么?这里来试一下。首先创建两个CSS文件:

/* index1.module.css */
.class1 {
  color: red;
}

/* index2.module.css */
.class1 {
  color: yellow;
}

可以看到两个CSS文件中类名标识符是一致的,都是class1。然后是两个JavaScript文件index1.js和index2.js,里面总共举了三个例子:

// index1.js
import styles1 from "./index1.module.css";
import styles2 from "./index2.module.css";

const test1 = document.createElement("div");
test1.className = styles1.class1;
test1.textContent = "test1";
document.body.appendChild(test1);

const test2 = document.createElement("div");
test2.className = styles2.class1;
test2.textContent = "test2";
document.body.appendChild(test2);

//index2.js
import styles1 from "./index1.module.css";

const test3 = document.createElement("div");
test3.className = styles1.class1;
test3.textContent = "test3";
document.body.appendChild(test3);

最后是index.html,引入两个JavaScript文件:

<html>
  <script src="./index1.js" type="module"></script>
  <script src="./index2.js" type="module"></script>
  <body>
    <div>jsplp CSS Modules</div>
  </body>
</html>

经过Vite打包后,在浏览器看下输出结果:

css-modules-3.png

  • test1和test3对比,分别在两个JavaScript文件中引入了同一个CSS模块文件index1.module.css,最后生成的类标识符是一致的,样式效果也一致。这是因为CSS文件只有一个,最后只会生成一份CSS规则。而且既然引入同一文件,规则肯定是一样的,没有必要分开两个类名。
  • test1和test2对比,在同一个JavaScript文件中引入了两个CSS模块文件,虽然各自CSS文件中类名是一样的,但因为所属文件不同,因此生成的新类名不一样,这样有效避免了同名的样式冲突问题。
  • test2和test3对比,分别在两个JavaScript文件中引入了两个CSS模块文件,生成的新类名也不一样,也避免了同名的样式冲突问题。

global全局规则

通过前面的例子可以看到,使用CSS Modules之后,所有自定义标识符名都变成了新的,只有引用才能生效的局部CSS规则。如果希望在这个CSS文件内定义部分全局都能生效的规则,CSS Modules也给出了方法,而且允许全局规则和局部规则混合嵌套使用。使用:global,就可以在CSS模块文件中使用全局规则。我们来看下例子。首先是index.module.css文件:

/* index.module.css */
.class1 {
    color: red;
}
:global(.class1) {
    color: blue;
}
:global(.class2) {
    .class3 {
        color: yellow;
    }
}
:local(.class4) {
    color: grey;
}

然后是index.js,这里给出了四种情况。可以看到带:global的标识符是不会被JavaScript文件导入的:

import styles from "./index.module.css";

console.log(styles);

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}
genEle("test1", styles.class1);
genEle("test2", "class1");

const div = document.createElement("div");
div.className = "class2";
div.innerHTML = `<div class='${styles.class3}'>test3</div>`;
document.body.appendChild(div);

genEle("test4", styles.class4);

/* 输出结果
{
  class1: '_class1_8tmyt_1',
  class3: '_class3_8tmyt_8',
  class4: '_class4_8tmyt_12'
}
*/

css-modules-4.png

  • test1: 正常的模块化CSS规则,做对比用
  • test2: 与test1一样都用class1做类名,但这里没有使用导出的新类名,因此匹配到了带:global的全局CSS规则
  • test3: 外层class2是全局类名,里面的class3没有用:global,因此还是局部规则。这是一个混合使用的例子,在CSS模块文件中,只有包裹在:global里面的类名才是全局规则,嵌套选择器和组合选择器需要单独包裹, 或者这样包裹在一起也可以::global(.cls1 + .cls2)
  • test4: :local表示模块化的CSS规则,与不增加标识效果一致。一般为了强调才使用。

composes组合规则

使用CSS Modules,使用composes属性,在规则中可以组合另一个类选择器的规则。这里举个例子看一下是如何组合的。首先是index.module.css文件:

.class1 {
  background: yellow;
}
.class2 {
  color: red;
  composes: class1;
}
.class1:hover {
  border: 1px solid blue;
}

然后是index.js中引入CSS文件,这里仅使用class2做类名:

import styles from "./index.module.css";

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}
genEle("test1", styles.class2);

css-modules-5.png

我们的test1元素只定义了class2这个类名,但在浏览器中,却同时有了class1的类名。这时因为在CSS文件中定义class2的规则时,增加了composes属性,值为class1的类名。这相当于让class2继承class1,因此元素也具有了class1的类名和样式。同时还举了一个伪类的例子,这个组合规则对于伪类/为元素和选择器组合等都可以生效。composes属性也支持全局规则和跨文件引用,这里也举下例子:

/* index.module.css */
:global(.class1) {
  background: yellow;
}
.class2 {
  color: red;
  composes: class1 from global;
  composes: class3 from './index2.module.css';
}

/* index2.module.css */
.class3 {
  border: 1px solid blue;
}

这里仅更改了index.module.css文件,新增了index2.module.css文件,index.js文件内容没有变化。然后我们查看浏览器效果:

css-modules-6.png

通过这个例子我们发现,CSS Modules可以组合全局规则,composes的类名后面加from global即可。同时composes可以在同一个类中使用很多次,都会生效。另外composes也可以跨文件组合,直接from文件名即可。

实现主题功能

使用CSS Modules主动引入类名的特性,通过不同场景下的类名切换,可以实现主题功能。首先定义两个CSS文件,其中的选择器一致,但是主题不一样:

/* red.module.css */
.class1 {
  color: red;
}
.class2 {
  border: 1px solid red;
}

/* blue.module.css */
.class1 {
  color: blue;
}
.class2 {
  border: 1px solid blue;
}

然后是index.js文件:

import styleRed from "./red.module.css";
import styleBlue from "./blue.module.css";

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

function componentJz(styles) {
  genEle("test1", styles.class1);
  genEle("test2", styles.class2);
}

// 渲染红色主题
componentJz(styleRed);
// 渲染蓝色主题
componentJz(styleBlue);

可以看到,将引入的CSS Modules标识符对象传递给组件,组件中的元素使用这个对象作为类名。这样可以实现根据不同的条件传入不同的CSS文件对象,页面主题样式也随之变化。这里其实使用React组件举例更合适,但React使用方式要留到下面介绍,因此先使用纯JS示意。

React使用方式

React中使用CSS Modules与纯JavaScript使用基本一致。这里我们使用Vite创建一个React工程,展示在React中使用CSS Modules。首先执行命令行:

# 提示中选择React
npm create vite
# 进入工程
cd vite-react
# 安装依赖
npm install
# 增加依赖
npm add -S classnames 
# 开发模式运行工程
npm run dev

创建App.module.css文件,内容如下:

.class1 {
  color: red;
}
.class2 {
  color: blue;
}
:global(.class3) {
  border: 1px solid yellow;
}

然后将App.jsx中的内容删掉,替换为下面的代码。这就是React中的使用方式,CSS文件引入的标识符对象作为className属性。同时这里演示了classnames的用法,可以方便的组合多种类名。

import styles from './App.module.css';
import cn from 'classnames';

export default function App() {
  return (
    <div>
      <div className={styles.class1}>test1</div>
      <div className='class3'>test2</div>
      <div className={cn(styles.class2, 'class3')}>test3</div>
    </div>
  )
}

css-modules-7.png

Vue使用方式

Vue框架对于组件的组织方式比较特别,使用一个“单文件组件”的方式来组织代码,将所属同一个组件的HTML模板,JavaScript代码和CSS样式同时写到一个组件中。而且单文件组件中最流行的写法是“组件作用域CSS”,不是CSS Modules。下面我们分别介绍一下。

组件作用域CSS

组件作用域CSS,可以做到本组件的CSS样式就只影响本组件,不会影响别的组件;即使非类名选择器,例如标签选择器,属性选择器等,都仅限在本组件范围内生效。注意组件作用域CSS并不是CSS Modules,只不过功能上有部分相似之处。我们看一下例子,首先使用命令行创建Vue工程:

# 根据提示创建Vue工程
npm create vue@latest
# 进入工程
cd vite-vue
# 安装依赖
npm install
# 开发模式运行工程
npm run dev

然后我们删除App.vue中的内容,填充下面的代码。作为父组件。

<script setup>
import Comp1 from './comp1.vue'
import Comp2 from './comp2.vue'
</script>

<template>
  <div>
    父组件
    <div class="class1"> 父组件元素 </div>
    <p> 父组件p元素 </p>
  </div>
  <Comp1 />
  <Comp2 />
</template>

<style scoped>
p {
  color: yellow;
}
.class1 {
  background-color: aqua;
}
</style>

然后是样式选择器与父组件一致的子组件comp1.vue:

<template>
  <div>
    子组件1
    <div class="class1"> 子组件1元素 </div>
    <p> 子组件1p元素 </p>
  </div>
</template>

<style scoped>
p {
  color: red;
}
.class1 {
  background-color: blue;
}
</style>

最后是没有开启组件作用域CSS的comp2.vue组件:

<template>
  <div>
    子组件2
    <div class="class1"> 子组件2元素 </div>
    <p> 子组件2p元素 </p>
  </div>
</template>

<style>
div {
  border: 1px solid brown;
}
.class1 {
  color: brown;
}
</style>

在代码中可以看出,单文件组件将<template> <script> <style>在同一个vue文件中封装。如果使用作用域CSS,就在style标签上加scoped属性。有什么效果呢?我们看下浏览器截图:

css-modules-8.png

可以看到,在设置了scoped属性之后,组件生成的HTML代码中便会多了dat-v-xxxx的属性,每个组件的属性是单独的不会重复。对应的CSS选择器中也添加了属性选择器的条件。这样不管是类选择器还是标签选择器等,都只有匹配到了对应的data-v属性才会生效。

对于组件内CSS样式污染全局的问题,组件作用域CSS比CSS Modules的隔离更全面,基本可以做到完全不污染全局。例如App.vue组件和comp1.vue组件,两个选择器一致,但是样式却没有被污染。不过要注意,在父组件中引入子组件,子组件的根元素会同时被附加上父组件和子组件的data-v属性,例如comp1.vue组件的根结点。

comp2.vue组件没有使用组件作用域CSS,因此它的CSS能影响全局。包括使用scoped属性的组件内部,如果符合规则也能匹配上。这与CSS Modules不一致,因为CSS Modules修改了类名,因此源码中的符合规则的元素类名,生成代码中就不符合规则了。

特殊选择器

与CSS Modules一样,组件作用域CSS也有一些特殊的选择器用于处理一些特殊场景,主要有这几个:

  • :deep() 深度选择器 样式可以影响子组件
  • :slotted() 插槽选择器 样式可以影响插槽内容
  • :global() 全局选择器 样式可以影响全局

下面我们举个例子演示一下选择器的使用方法。首先是父组件App.vue文件:

<script setup>
import Comp1 from './comp1.vue'
</script>

<template>
  <div>
    父组件
    <div class="class1"> 父组件元素 </div>
    <Comp1>
      <div class="class2"> slot元素 </div>
    </Comp1>
  </div>
  <Comp1 />
</template>

<style scoped>
:global(.class1) {
  color: red;
}
:deep(.class1) {
  background-color: aqua;
}
</style>

然后是子组件comp1.vue:

<template>
  <div>
    子组件1
    <div class="class1"> 子组件1元素 </div>
    <slot> </slot>
  </div>
</template>

<style scoped>
:slotted(.class2) {
  background-color: blue;
}
</style>

css-modules-9.png

上面例子中展示了三种选择器的使用方式,其中全局选择器的效果和CSS Modules基本一致;深度选择器只能影响自己和子组件;插槽选择器影响的父组件中被包括在子组件插槽中的部分。通过对于组件作用域CSS的介绍,可以发现它虽然实现原理与CSS Modules不一致,但作用却有些相似,而且扩展了CSS Modules的作用范围。

Vue与CSS Modules

Vue中不仅有组件作用域CSS,单文件组件也可以直接集成CSS Modules开发。在style标签上加module属性,即可开启CSS Modules。我们继续举个例子演示用法,首先是父组件App.vue:

<script setup>
import Comp1 from './comp1.vue'
</script>

<template>
  <div>
    父组件
    <div :class="$style.class1"> 父组件元素 </div>
    <Comp1>
      <div :class="$style.class2"> slot元素 </div>
    </Comp1>
  </div>
</template>

<style module>
.class1 {
  color: red;
}
.class2 {
  background: yellow;
  composes: class1;
}
</style>

然后是子组件comp1.vue:

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

const styles = useCssModule();
console.log(styles);
</script>

<template>
  <div>
    子组件1
    <div class="class1"> 子组件1元素 </div>
    <slot> </slot>
    <div :class="styles.class3"> 子组件1元素2 </div>
  </div>
</template>

<style module>
.class3 {
  border: 1px solid blue;
}
</style>

<!-- 输出结果
{ class3: "_class3_1wpng_3" }
-->

css-modules-10.png

在父组件的template中,使用$style就可以拿到CSS Modules引入的标识符映射对象。如果希望在JavaScript文件中使用,则可以参考子组件的方式,使用useCssModule拿到标识符映射对象,进行处理后再提供给template。

Webpack使用方式

前面介绍了两种前端框架中CSS Modules的使用方式。因为它最终还需要构建工具进行处理才能生效,因此我们再关注一下它在不同构建工具中的使用方式。首先看下在Webpack中的使用。

接入CSS Modules

先创建一个Webapck工程,执行如下命令行:

npm init -y
npm install webpack webpack-cli style-loader css-loader html-webpack-plugin --save-dev

然后修改package.json中的scripts,增加"build": "webpack",后面执行npm run build即可构建结果。然后创建src/index.js,内容如下:

import * as styles from "./index.module.css";

console.log(styles);

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

genEle("test1", styles.class1);
genEle("test2", styles.class2);

然后创建index.module.css,包含内容如下:

.class1 {
    color: red;
}
.class2 {
    background: yellow;
    composes: class1;
}

然后创建webpack.config.js配置文件,内容如下:

const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
  mode: "production",
  entry: "./src/index.js",
  module: {
    rules: [
      {
        test: /\.css$/i,
        use: ["style-loader", "css-loader"],
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: "管理输出",
    }),
  ],
  output: {
    path: path.resolve(__dirname, "dist"),
  },
};

/* 输出结果
{
  class1:  "qbnq8h84_UYLiRusvV1S",
  class2:  "cofz5uKLVjU6TCKJqeBc qbnq8h84_UYLiRusvV1S",
  default: undefined,
  __esModule: true
  ...其它内容
}
*/

然后执行构建命令,生成dist目录。在浏览器中打开dist/index.html,可以看到CSS Modules已经接入成功,类名变成了带哈希的标识符,test2元素因为使用了composes特性,还包含了两个类名,在console输出时也带着。

css-modules-11.png

提供CSS Modules功能的,实际上是css-loader。默认情况下,当CSS文件的中间包含.module或者.icss时,css-loader会将其识别为CSS模块处理。可以看到JavaScript代码中引入CSS标识符时使用的是import * as,这也是因为css-loader并不支持默认导出所有标识符到一个对象中,而是只能单个导入,类似于import { class1 } from "./index.module.css";

css-loader可以配置全部CSS文件开启CSS模块,配置modules选项为true即可。然后将index.module.css改为index.css也能使用CSS Modules功能。这里展示webpack.config.js中的配置改动:

const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
  mode: "production",
  entry: "./src/index.js",
  module: {
    rules: [
      {
        test: /\.css$/i,
        use: [
          "style-loader",
          {
            loader: "css-loader",
            options: {
              modules: true,
            },
          },
        ],
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: "管理输出",
    }),
  ],
  output: {
    path: path.resolve(__dirname, "dist"),
  },
};

value变量

在css-loader文档中描述了开启CSS模块后,可以使用value变量的功能。虽然CSS Modules自己的主文档中没写,但是Webpack和Vite实际上都支持。value变量有点像CSS变量,一次定义,多处使用。这里我们举个例子,直接在前面的基础上修改CSS文件:

@value varRed red;
@value borderBlue 1px solid blue;

.class1 {
  color: varRed;
}
.class2 {
  background: varRed;
  border: borderBlue;
}

css-modules-12.png

可以看到,使用方式和CSS变量类似,预先定义变量,随后引用标识符。但CSS变量是运行时,CSS Modules的变量是编译时。由于没有CSS变量好用,而且LESS和SCSS等都提供了更完善的编译时变量功能,因此CSS Modules的变量功能好像用的人不多。

模式

css-loader的modules配置表示CSS Modules相关配置,其中有一个mode配置,表示模式,有四种选项值,这里列举一下:

  • local 将所有标识符编译为局部规则标识符
  • global 将所有标识符编译为全局标识符,相当于所有标识符使用:global
  • pure 与local模式一致,但是检测每一个CSS选择器是否存在局部标识符,如果不存在报错
  • icss 仅处理composes特性,但是不转换局部标识符

local模式既是默认模式,与上面正常使用CSS Modules一致。global标识符会处理value变量,但不会转换成局部标识符。icss模式处理composes特性,但是也不转换局部标识符。pure模式则在普通local模式的基础上,增加了对于每个选择器的判断,这么判断的原因为:检测CSS模块文件中是否存在可以全局生效的CSS选择器。这里设置pure模式试一下,我们修改webpack.config.js中的css-loader配置:

{
  loader: "css-loader",
  options: {
    modules: {
      mode: 'pure'
    },
  },
}

然后创建修改index.module.scss文件:

.class1 {
  color: red;
}
.class2 {
  background: blue;
}
div {
  color: yellow;
}

执行npm run build,结果失败,命令行输出结果如下:

css-modules-13.png

可以看到选择器div被标出来了,说它不“pure”,应该至少包含一个局部class或者id。这是因为div属性选择器,无法对属性选择器进行局部标识符的处理,因此这个选择器会全局生效,使用pure模式后,css-loader会寻找CSS模块文件中的全局选择器并报错,防止意外影响全局。

自定义标识符格式

在前面用Vite尝试CSS Modules时生成的新标识符格式,与Webpack生成的新标识符格式,看起来有点区别:Vite生成的带原来的类名,但是Webpack不带。这种标识符格式,实际上是可以更改的,使用localIdentName配置项即可:

{
  loader: "css-loader",
  options: {
    modules: {
      localIdentName: '[hash:base64]'
    },
  },
}

默认配置是[hash:base64],即原标识符的哈希值。还可以扩展其他格式:

  • [name] 源文件名称
  • [path] 目录名称
  • [file] 目录和文件名
  • [ext] 文件拓展名
  • [hash] 原标识符的哈希值
  • [local] 原来的标识符名
  • [hash:base64] 将hash做Base64处理
  • [hash:5] hash的长度限制为5

这些配置可以组合成模板字符串,例如[name]_[ext]_[hash]。这里举几个配置和对应的生成标识符的例子:

配置项 举例1 举例2
[name]_[ext]_[hash:7] index-module_-css_a9b9eaf index-module_-css_f3e6e28
[path]_[local]_[hash:base64:5] src-_class1_qbnq8 src-_class2_cofz5

使用postcss-modules

Webpack使用css-loader来支持CSS Modules,Vite背后则采用postcss-modules来支持CSS Modules。如果开启了lightningcss,则使用Lightning CSS来支持CSS Modules。postcss-modules是一个PostCSS插件,这里我们介绍一下。

引入postcss-modules

首先我们创建一个工程,引入PostCSS和postcss-modules,使其可以成功编译CSS Modules。首先执行命令行:

npm init -y
npm add -D postcss postcss-cli postcss-modules
# 后面执行下面命令行,可以编译CSS
# src 源文件目录 output 生成文件目录
npx postcss src -d output --no-map

创建PostCSS的配置文件postcss.config.js,里面引入postcss-modules插件。

const postcssModules = require("postcss-modules");
module.exports = {
  plugins: [postcssModules],
};

创建src/index.module.css,里面包含如下内容:

.class1 {
  color: red;
}
.class2 {
  composes: class1;
  background: blue;
}

css-modules-14.png

执行编译后,生成结果如上图。首先看左边的目录树,生成了output/index.module.css,内容是标识符改变后的CSS规则。但是在src目录中却还生成了一个index.module.css.json文件,内容为原标识符和新标识符的映射关系。因为PostCSS没有编译JavaScript文件的能力,因此这个映射关系作为额外的JSON文件导出了。

获取标识符映射数据

前面引入postcss-modules中,我们发现生成的标识符映射关系文件被放到了src源文件目录中。这有点让人不适,源代码目录不应该被生成的内容污染。postcss-modules提供了我们自行控制标识符映射数据的方式,需要修改配置文件postcss.config.js:

const path = require("path");
const fs = require("fs");
const postcssModules = require("postcss-modules");

module.exports = {
  plugins: [
    postcssModules({
      getJSON: (cssFileName, json, outputFileName) => {
        // 源CSS文件路径
        console.log(cssFileName);
        // 标识符映射数据
        console.log(json);
        // 默认要输出的文件路径 可以弃用
        console.log(outputFileName);
        console.log("---");

        // 创建目录 如果已经创建则静默成功
        fs.mkdirSync(path.resolve("./classMap"), { recursive: true });
        // 获取源文件名
        const cssName = path.basename(cssFileName);
        // 拼合新的路径
        const jsonFileName = path.resolve("./classMap/" + cssName + ".json");
        // 文件写入新路径
        fs.writeFileSync(jsonFileName, JSON.stringify(json));
      },
    }),
  ],
};

可以看到,对getJSON配置项传入函数,可以拿到文件路径和标识符数据,可以对它进行任意处理。上面给出了一个写入其它目录的例子。这里创建两个CSS文件index.module.css和index2.module.css,执行编译输出结果如下:

css-modules-15.png

自定义标识符格式

在介绍Webpack使用方式时,我们提到css-loader支持自定义标识符格式,同样的postcss-modules也支持,而且格式也一样,通过修改generateScopedName配置:

const postcssModules = require("postcss-modules");
module.exports = {
  plugins: [
    postcssModules({
      generateScopedName: '[name]_[ext]_[hash:base64:5]'
    }),
  ],
};

因此,我们可以参考上面css-loader的文档来修改generateScopedName配置。generateScopedName还支持自定义函数,可以随心所欲的配置:

const path = require("path");
const postcssModules = require("postcss-modules");
module.exports = {
  plugins: [
    postcssModules({
      generateScopedName: (name, filename, css) => {
        // name 文件路径 filename 文件路径
        console.log(name, filename);
        // CSS文件内容
        console.log(css);
        console.log('-----');
        return `${path.basename(filename).replaceAll('.', '-')}_${name}`;
      }
    }),
  ],
};

在上面的例子中,我们将class名改为文件名+class名(仅供示例,实际使用还是会重复)。我们看下效果,成功的将class类名改为了我们自定义的格式。

css-modules-16.png

原始标识符转换

CSS Modules推荐使用camelCase驼峰命名法来写CSS标识符,因为可以轻松在JavaScript中使用,例如styles.abcDef。但如果用kebab-case中划线命名法,在JavaScript中使用就稍微麻烦一些,但也不是不能用,例如:styles['abc-def']。但如果已有的CSS规则,postcss-modules提供了localsConvention配置项,可以让我们将原始标识符转换为驼峰命名法的形式。这样即使我们在CSS文件中使用中划线命名法,在JavaScript代码中也能用驼峰命名法引入。它共有四个选项可以配置。

  • camelCase 输出为骆驼命名法,输出保留原标识符
  • camelCaseOnly 输出为骆驼命名法,不保留原标识符
  • dashes 仅转换中划线为骆驼命名法,输出保留原标识符
  • dashesOnly 仅转换中划线为骆驼命名法,不保留原标识符

这里我们构造一个CSS文件,带有几种命名法:

.abcDef {
  color: red;
}
.bcd-efg {
  color: red;
}
.cde_fgh {
  color: red;
}

对于不同配置项,我们看一下输出结果:

// camelCase
{
  "abcDef": "_abcDef_cmy82_1",
  "bcd-efg": "_bcd-efg_cmy82_7",
  "bcdEfg": "_bcd-efg_cmy82_7",
  "cde_fgh": "_cde_fgh_cmy82_13",
  "cdeFgh": "_cde_fgh_cmy82_13"
}

// camelCaseOnly
{
  "abcDef": "_abcDef_cmy82_1",
  "bcdEfg": "_bcd-efg_cmy82_7",
  "cdeFgh": "_cde_fgh_cmy82_13"
}

// dashes
{
  "abcDef": "_abcDef_cmy82_1",
  "bcd-efg": "_bcd-efg_cmy82_7",
  "bcdEfg": "_bcd-efg_cmy82_7",
  "cde_fgh": "_cde_fgh_cmy82_13"
}

// dashesOnly
{
  "abcDef": "_abcDef_cmy82_1",
  "bcdEfg": "_bcd-efg_cmy82_7",
  "cde_fgh": "_cde_fgh_cmy82_13"
}

localsConvention配置还可以接受函数入参,这样我们可以自定义JavaScrript引入的标识符名,这里我们举了个例子,将标识符后面加了固定的后缀。

const postcssModules = require("postcss-modules");
module.exports = {
  plugins: [
    postcssModules({
      localsConvention: (originalClassName, generatedClassName, filPath) => {
        // 原标识符 转换后标识符
        console.log(originalClassName, generatedClassName);
        // CSS文件路径
        console.log(filPath);
        console.log("-----");
        return originalClassName + "_jzplp";
      },
    }),
  ],
};

/* 输出结果
{
  "abcDef_jzplp": "_abcDef_cmy82_1",
  "bcd-efg_jzplp": "_bcd-efg_cmy82_7",
  "cde_fgh_jzplp": "_cde_fgh_cmy82_13"
}
*/

处理路径别名

在CSS Modules的composes特性中,可以引入其它CSS文件中选择器的规则。很多人喜欢使用路径别名,例如@common/css等,postcss-modules也提供了resolve这个配置项,可以让我们自行控制别名,甚至修改路径。首先我们构造一个文件路径的例子:

/* src/common/com.css */
.classCommon {
  color: red;
}

/* src/copm1/index.module.css */
.class1 {
  background: yellow;
  composes: classCommon from '@common/com.css';
}

可以看到,我们先在common目录中设置了CSS文件,然后在copm1目录中,使用@common将其引入。如果不处理,@common这个路径CSS Module肯定是识别不了的。这里我们设置postcss.config.js:

const path = require("path");
const postcssModules = require("postcss-modules");
module.exports = {
  plugins: [
    postcssModules({
      resolve: (file, importer) => {
        // composes特性引入的文件路径
        console.log(file);
        // 当前处理的CSS文件
        console.log(importer);
        const newPath = path.resolve(
          process.cwd() + file.replace("@common", "/src/common"),
        );
        return newPath;
      },
    }),
  ],
};

/*
命令行输出结果
@common/com.css
E:\testProj\css-modules\postcss-proj\src\comp1\index.module.css

index.module.css.json输出结果
{"class1":"_class1_u2c4w_1 _classCommon_pc5si_1"}
*/

可以看到,提供自定义的resolve函数,可以接收composes中的路径与当前处理的文件路径,然后将我们的路径别名转化为真正的路径名。

解析PostHTML模板

postcss-modules导出的JSON文件,可以被posthtml-css-modules使用,用来提供给PostHTML模板提供新的标识符。首先我们将postcss-modules中的所有选项删除(为了不干扰这个例子)。然后创建一个CSS文件src/index.module.css:

.class1 {
  background: yellow;
}
.class2 {
  color: red;
  composes: class1;
}

然后创建index.js,处理PostHTML模板:

const posthtml = require('posthtml');
const posthtmlCssModules = require("posthtml-css-modules");

const template = `
<div css-module="class1">test1</div>
<div css-module="class2">test2</div>
`
posthtml([posthtmlCssModules("./src/index.module.css.json")])
.process(template)
.then(function (result) {
console.log(result.html);
});

我们还没安装新依赖呢。执行如下命令行,安装依赖和拿到编译结果:

# 安装依赖
npm add posthtml posthtml-css-modules
# 编译CSS 处理CSS Modules
postcss src -d output --no-map
# 执行上面代码,处理PostHTML模板
node index.js

最后输出结果如下。可以看到关键在于css-module属性,我们将其设置为原始的标识符,经过处理后就变为了转换后的标识符。

<div class="_class1_go5lk_1">test1</div>
<div class="_class2_go5lk_7 _class1_go5lk_1">test2</div>

posthtml-css-modules还支持传入目录,可以处理多个文件。同时在css-module中使用点符号分隔文件和属性。但由于postcss-modules默认生成的文件中肯定会出现点,例如 index1.css -> index1.css.json,因此我们先要处理postcss.config.js:

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

const postcssModules = require("postcss-modules");
module.exports = {
  plugins: [
    postcssModules({
      getJSON: (cssFileName, json) => {
        // 创建目录 如果已经创建则静默成功
        fs.mkdirSync(path.resolve("./classMap"), { recursive: true });
        // 获取源文件名
        const cssName = path.basename(cssFileName, ".css");
        // 拼合新的路径
        const jsonFileName = path.resolve("./classMap/" + cssName + ".json");
        // 文件写入新路径
        fs.writeFileSync(jsonFileName, JSON.stringify(json));
      },
    }),
  ],
};

例如我们有src目录,里面有两个JSON文件insex1.json和insex2.json,此时处理模板的代码修改为:

const posthtml = require('posthtml');
const posthtmlCssModules = require("posthtml-css-modules");

// 文件名.标识符
const template = `
<div css-module="index1.class1">test1</div>
<div css-module="index2.class2">test2</div>
`
posthtml([posthtmlCssModules("./classMap/")])
.process(template)
.then(function (result) {
console.log(result.html);
});

/* 输出结果
<div class="_class1_1c28r_1">test1</div>
<div class="_class2_10f6k_1">test2</div>
*/

Lightning CSS

Lightning CSS是一个用Rust编写的CSS编译工具,可以解析,编译,打包和压缩CSS代码,性能比用JavaScript 的同类工具要强很多。Lightning CSS也支持CSS modules,这里我们简单讲一下用法。

接入Lightning CSS

首先执行命令行新建工程,安装lightningcss依赖。然后创建index.mjs,内容如下:

import { transform } from 'lightningcss';

const cssData = `
.class1 { color: red }
.class2 { color: blue }
`;
let { code, exports } = transform({
  cssModules: true,
  code: Buffer.from(cssData),
});

console.log(code.toString());
console.log('-----');
console.log(exports);

/* 输出结果
._8Z4fiW_class1 {
  color: red;
}

._8Z4fiW_class2 {
  color: #00f;
}

-----
{
  class1: { name: '_8Z4fiW_class1', composes: [], isReferenced: false },
  class2: { name: '_8Z4fiW_class2', composes: [], isReferenced: false }
}
*/

从上面代码可以看到,Lightning CSS接收和返回的都是Buffer对象;设置cssModules为true可以处理CSS modules。其中code是返回代码,exports是返回的映射关系。Lightning CSS返回的映射关系格式与其它工具不同,它一个标识符为一个对象,其中的name为转换后的标识符。

composes特性的映射关系

与其它工具不一样,Lightning CSS处理composes特性并不将其直接作为name,而是单独放到composes数组中。这里我们举个例子试一下:

.class1 { color: red }
.class2 {
  color: blue;
  composes: class1;
  composes: jzplp from global;
  composes: abc from './style.css';
}

/* 输出结果
._8Z4fiW_class1 {
  color: red;
}

._8Z4fiW_class2 {
  color: #00f;
}

-----
{
  class1: { name: '_8Z4fiW_class1', composes: [], isReferenced: false },
  class2: {
    name: '_8Z4fiW_class2',
    composes: [
      { type: 'local', name: '_8Z4fiW_class1' },
      { type: 'global', name: 'jzplp' },
      { type: 'dependency', name: 'abc', specifier: './style.css' }
    ],
    isReferenced: false
  }
}
*/

这里举了三种composes例子,有不同的类型:

  • local 本文件中的标识符
  • global 全局标识符
  • dependency 其它文件的标识符

注意dependency类型,这里没有实际读取另一个CSS文件(因为我们使用transform来编译,不能读取其它文件),而且这个name值也并没有变成转换后的值。Lightning CSS的文档中要求调用方自行处理。

使用bundle方法

前面我们提到,在使用composes特性引入另一个文件的标识符,最后映射关系中只给我们返回了文件路径,没有帮我们实际引入。这时候我们不用transform方法,转为使用bundle,Lightning CSS就可以帮我们读取文件了。我们构造一个例子。首先创建两个CSS文件:

/* index.css */
.class2 {
  background-color: red;
  composes: abc from './style.css';
}

/* style.css */
.abc {
  color: blue;
}
.bcd {
  font-size: 14px;
}

然后修改index.mjs:

import { bundle } from "lightningcss";

let { code, exports } = bundle({
  cssModules: true,
  filename: "./index.css",
});

console.log(code.toString());
console.log("-----");
console.dir(exports, { depth: null });

/* 输出结果
.Zvw1Mq_abc {
  color: #00f;
}

.Zvw1Mq_bcd {
  font-size: 14px;
}

.vkZoAa_class2 {
  background-color: red;
}

-----
{
  class2: {
    name: 'vkZoAa_class2',
    composes: [ { type: 'local', name: 'Zvw1Mq_abc' } ],
    isReferenced: false
  }
}
*/

可以看到,虽然我们只输入了一个index.css文件,但两个CSS文件实际上都被编译了。但映射关系还是只输出了index.css。之前的dependency类型消失了,因为拿到了转换后的类名,所以类型也被转为了local。

模块化CSS变量

Lightning CSS并不提供value变量功能,理由是CSS本身已经支持变量了。但Lightning CSS支持了将CSS变量标识符转换为hash标识符的功能,即局部CSS变量。这里我们试一下:

import { transform } from "lightningcss";

const cssData = `
.root {
  --abc: red;
}
.class1 {
  color: var(--abc);
  background: var(--bcd from global);
  font-size: var(--def from './style.css');
}
`;
let { code, exports } = transform({
  cssModules: {
    dashedIdents: true,
  },
  code: Buffer.from(cssData),
});

console.log(code.toString());
console.log("-----");
console.log(exports);

/* 输出结果
._8Z4fiW_root {
  --_8Z4fiW_abc: red;
}

._8Z4fiW_class1 {
  color: var(--_8Z4fiW_abc);
  background: var(--bcd);
  font-size: var(--tCZyqW);
}

-----
{
  class1: { name: '_8Z4fiW_class1', composes: [], isReferenced: false },
  '--abc': { name: '--_8Z4fiW_abc', composes: [], isReferenced: true },
  root: { name: '_8Z4fiW_root', composes: [], isReferenced: false }
}
*/

开启dashedIdents选项才能够局部化CSS变量。 这里列举了三个情形,与composes非常类似:

  • 本文件的CSS变量 使用方法与CSS写法一致,工具会自己转换。exports中也导出了对应标识符。
  • 全局CSS变量 使用from global可以设置为全局变量
  • 从其它文件引入局部CSS变量

注意看从其它文件引入的情形,exports中并没有导出这个标识符,但是CSS文件却被转换了。这也是因为transform方法没有访问文件的能力,但这种“自行转换”的形式有些不妥,如果没有和另一个文件匹配,这个CSS变量引入就无法生效。这里我们换成bundle方法试一下。还是创建两个CSS文件:

/* index.css */
.root {
  --abc: red;
}
.class1 {
  color: var(--abc);
  background: var(--bcd from global);
  font-size: var(--def from './style.css');
}

/* style.css */
.root {
  --def: 14px;
}

然后修改index.mjs。通过执行结果可以看到,bundle方法将两个文件一起编译,保证结果的正确性。

import { bundle } from "lightningcss";

let { code, exports } = bundle({
  cssModules: {
    dashedIdents: true,
  },
  filename: "./index.css",
});

console.log(code.toString());
console.log("-----");
console.dir(exports, { depth: null });

/* 
.Zvw1Mq_root {
  --Zvw1Mq_def: 14px;
}

.vkZoAa_root {
  --vkZoAa_abc: red;
}

.vkZoAa_class1 {
  color: var(--vkZoAa_abc);
  background: var(--bcd);
  font-size: var(--Zvw1Mq_def);
}

-----
{
  root: { name: 'vkZoAa_root', composes: [], isReferenced: false },
  '--abc': { name: '--vkZoAa_abc', composes: [], isReferenced: true },
  class1: { name: 'vkZoAa_class1', composes: [], isReferenced: false }
}
*/

Lightning CSS的CSS Modules还支持一些其它功能,例如自定义标识符,自定义标识符转换范围,pure模式等,这里就不赘述了。

背后的Postcss插件

观察css-loader和postcss-modules的依赖,发现它们都引用了四个前缀一致的PostCSS插件:

  • postcss-modules-local-by-default
  • postcss-modules-scope
  • postcss-modules-extract-imports
  • postcss-modules-values

这四个插件名称都以postcss-modules-开头,都是实现CSS Modules相关的插件,而且也在css-modules自己的仓库列表中。我们逐一介绍一下这几个插件。

postcss-modules-local-by-default

postcss-modules-local-by-default插件的作用是将标识符增加:local(),同时将:global()去掉。这样所有应该被局部化处理的标识符都有:local()标志,没有标志则说明无需处理。首先修改postcss.config.js,引入插件:

const postcssModules = require("postcss-modules-local-by-default");
module.exports = {
  plugins: [postcssModules],
};

然后是要编译的CSS文件和编译结果:

.class1 {
  color: red;
}
.class2:hover {
  color: blue;
}
:global(.class3) {
  color: blue;
}

/* 输出结果
:local(.class1) {
  color: red;
}
:local(.class2):hover {
  color: blue;
}
.class3 {
  color: blue;
}
*/

postcss-modules-scope

postcss-modules-scope插件的作用是将带:local()的CSS标识符转换成新标识符,同时输出一个:export,里面包含标识符转换关系。首先修改postcss.config.js,引入插件:

const postcssModules = require("postcss-modules-scope");
module.exports = {
  plugins: [postcssModules],
};

通过功能说明,我们发现postcss-modules-scope插件的输入实际上就是postcss-modules-local-by-default插件的输出,因此我们把前面的结果拿过来继续编译。可以看到输出的转换关系还是放在CSS文件中。

:local(.class1) {
  color: red;
}
:local(.class2):hover {
  color: blue;
}
.class3 {
  color: blue;
}
/* 输出结果
._E_testProj_css_modules_postcss2_proj_src_index__class1 {
  color: red;
}
._E_testProj_css_modules_postcss2_proj_src_index__class2:hover {
  color: blue;
}
.class3 {
  color: blue;
}
:export {
  class1: _E_testProj_css_modules_postcss2_proj_src_index__class1;
  class2: _E_testProj_css_modules_postcss2_proj_src_index__class2;
}
*/

postcss-modules-extract-imports

postcss-modules-extract-imports插件的作用是处理跨文件的composes,但并不是实际引入文件。首先修改配置文件postcss.config.js:

const postcssModules = require("postcss-modules-extract-imports");
module.exports = {
  plugins: [postcssModules],
};

然后是要编译的CSS文件和编译结果。通过结果可以看到,插件将跨文件的composes输出为了一个特殊标识符,然后在文件上面增加了:import,给出了对应CSS文件中文件标识符的映射关系。

.class1 {
  color: red;
  composes: abc from './style.css';
}
.class2 {
  color: red;
  composes: class1;
}

/* 输出结果
:import("./style.css") {
  i__imported_abc_0: abc;
}
.class1 {
  color: red;
  composes: i__imported_abc_0;
}
.class2 {
  color: red;
  composes: class1;
}
*/

postcss-modules-values

postcss-modules-values插件的作用是处理value变量特性的。首先修改配置文件postcss.config.js:

const postcssModules = require("postcss-modules-values");
module.exports = {
  plugins: [postcssModules],
};

然后是要编译的CSS文件和编译结果。通过结果可以看到,本文件的value变量直接替换成对应值,跨文件的处理方式则类似于postcss-modules-extract-imports,在文件上面增加了:import,标识出了对应关系。

@value colorRed: red;
@value colorBlue: from './styles.css';

.class1 {
  color: colorRed;
  background-color: colorBlue;
}

/* 输出结果
:import('./styles.css') {
  i__const_colorBlue_0: colorBlue;
}
:export {
  colorRed: red;
  colorBlue: i__const_colorBlue_0;
}
.class1 {
  color: red;
  background-color: i__const_colorBlue_0;
}
*/

总结

CSS Modules是非常流行的CSS工具,大部分构建工具都提供了对它的支持。这是因为:

  1. 较好的解决了前端组件化中CSS的问题,避免了CSS污染和冲突问题。
  2. 写法上就是普通CSS文件,不需要修改语法,容易被大众接收。
  3. 与现代前端框架开发集成较好,尤其是React。
  4. 与SCSS和Less等CSS预处理工具都兼容。

相对的,像是一些和CSS语法有区别的特性,例如value变量等,使用的人就少了。甚至新的Lightning CSS干脆不支持。

通过【背后的Postcss插件】这部分,我们了解到CSS Modules功能的实现是由css-modules提供的PostCSS插件实现的。但这些插件不能读取文件,也不能实现将CSS文件导出为JavaScript对象。处理文件和打包是构建工具负责的内容,因此还要构建工具自行适配,完成整个功能的开发提供给使用者。

参考

作用域与作用域链:JS 的“找东西”逻辑,闭包到底是个啥?

作者 kyriewen
2026年3月20日 11:28

为什么有的变量在函数里能用,在外面却报错?为什么循环里的i总是最后一个值?今天我们就来聊聊JavaScript的作用域和作用域链,顺便揭开闭包的神秘面纱。保证你看完之后,再也不用背面试题了。

前言

想象一下这样的场景:你在自己房间里找手机,找不到就去客厅找,再找不到就去邻居家借手机打电话。如果所有地方都找不到,那就只能放弃——手机丢了。

JavaScript在查找变量时,也是这么个流程。这个“找东西”的规则,就是作用域链。而变量能在哪些地方被找到,由它的作用域决定。

今天我们就来把这件事彻底捋清楚。

一、作用域:变量的“活动范围”

作用域就是变量能够被访问到的范围。JS中有三种主要作用域:

1. 全局作用域:公共场所

在函数外面定义的变量,或者没加任何关键字直接写的变量(严格模式会报错),都属于全局作用域。

var globalVar = '我是全局的';
let alsoGlobal = '我也是全局的';

function sayHello() {
  console.log(globalVar); // 能访问
}

全局变量就像公共场所的设施,谁都能用,但正因为谁都能改,所以容易出问题。而且全局变量会一直存在,直到页面关闭。

2. 函数作用域:自己家

在函数内部用var声明的变量,只能在这个函数内部访问。外面进不去,里面可以出去(找外面的变量)。

function myHouse() {
  var secret = '我藏起来的零食';
  console.log(secret); // 能访问
}
console.log(secret); // 报错:secret is not defined

函数作用域像自己家,外人不能随便进,但你可以从家里出去(访问全局)。

3. 块级作用域:卧室里的保险柜

ES6新增的letconst带来了块级作用域。块就是大括号{}包起来的地方,比如ifforwhile里面。

if (true) {
  let blockVar = '我只能在块里用';
  var functionVar = '我可以在整个函数用'; // var没有块级作用域
}
console.log(blockVar); // 报错
console.log(functionVar); // 能访问,因为var只有函数作用域

块级作用域就像卧室里的保险柜,只有在这个房间里才能打开。var则像家里的公共区域,虽然写在卧室里,但实际还是公共的。

二、作用域链:找变量的路径

当你在一个作用域里使用变量时,JS引擎会按照这个顺序找:

  1. 当前作用域:先看自己家里有没有。
  2. 外层作用域:没有就去上一层找。
  3. 继续往外:一层一层往上,直到全局作用域。
  4. 全局也没有:那就报错not defined

这种嵌套的作用域形成的链条,就是作用域链

来看个例子:

var global = '全球通';

function outer() {
  var outerVar = '外层的';
  
  function inner() {
    var innerVar = '内层的';
    console.log(innerVar); // 找到自己家的
    console.log(outerVar); // 自己家没有,去外层找
    console.log(global);   // 自己家没有,外层没有,再去全局
  }
  
  inner();
}

outer();

这个过程就像你在家找东西:先翻自己口袋,没有就去客厅找,还没有就去小区便利店,再没有就只能放弃了。

三、闭包:虽然离开了,但我还记得

闭包是JS里一个常考常新、常学常忘的概念。简单来说:闭包就是函数记住了它定义时的作用域,即使这个函数在其他地方执行,也能访问那个作用域里的变量

举个例子:

function createCounter() {
  let count = 0; // count 被闭包记住了
  
  return function() {
    count++;
    console.log(count);
  };
}

const counter = createCounter();
counter(); // 1
counter(); // 2
counter(); // 3

这里createCounter执行后返回了一个函数,按说count应该被销毁了,但返回的函数依然能访问count——这就是闭包的力量。

闭包的生活比喻

想象你从小长大的家,后来搬走了,但你还记得家里的WiFi密码。每次你路过楼下,还能连上那个WiFi。这个“记住密码”的能力,就是闭包。

闭包的用途:

  • 数据私有化(比如上面的计数器,外部无法直接修改count)
  • 函数工厂(生成特定功能的函数)
  • 回调函数中保持状态(比如事件监听)

闭包的坑

闭包虽然好用,但也要注意内存问题。因为被记住的变量不会释放,如果闭包一直存在,这些变量就会一直占用内存。比如上面例子,只要counter这个函数还在,count就不会被垃圾回收。

四、经典面试题:循环中的var

这是JS初学者最容易踩的坑之一:

for (var i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i);
  }, 100);
}

你期望输出0,1,2,3,4,但实际输出5,5,5,5,5。为什么?

因为var没有块级作用域,循环里的i其实是全局(或函数级)的同一个变量。循环结束后i变成了5,然后setTimeout的回调执行时,访问的都是同一个i,所以全是5。

解决方式:

  1. 用let:let有块级作用域,每次循环都会创建一个新的变量。
for (let i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i); // 0,1,2,3,4
  }, 100);
}
  1. 用闭包(老办法):
for (var i = 0; i < 5; i++) {
  (function(j) {
    setTimeout(function() {
      console.log(j);
    }, 100);
  })(i);
}

用立即执行函数创建新的作用域,把每次的i传进去保存下来。

五、词法作用域:写在哪就在哪找

JS采用的是词法作用域(也叫静态作用域),也就是说变量的查找范围在代码编写时就决定了,而不是在运行时。

var value = 1;

function foo() {
  console.log(value);
}

function bar() {
  var value = 2;
  foo(); // 输出什么?
}

bar(); // 输出1

这里foo定义在全局,所以它访问的value是全局的1,而不是bar里的2。因为作用域由函数定义的位置决定,而不是调用位置。

这个特性是闭包能工作的基础。

六、执行上下文:运行时的小剧场

作用域是静态的规则,而执行上下文是运行时动态的环境。每当函数执行,都会创建自己的执行上下文,里面包含了变量、参数、以及对外部作用域的引用。

执行上下文有点像每次进家门时拿的钥匙串,上面有自己家的钥匙,还有父母家的钥匙(通过作用域链)。

七、总结:今天你学到了什么?

  • 作用域就是变量的可见范围:全局(公共场所)、函数(自己家)、块级(卧室保险柜)。
  • 作用域链就是找变量的路径:当前 → 外层 → 全局,找不到就报错。
  • 闭包是函数记住了它出生时的环境,即使离开了也能访问那些变量。用途广泛,但要注意内存。
  • 词法作用域意味着变量的查找在写代码时决定,和运行位置无关。
  • 循环中用var容易踩坑,用let或闭包解决。

现在你再看到作用域相关的问题,应该能像老司机一样游刃有余了。明天我们将继续深入,聊聊JavaScript里最让人迷惑的概念之一:闭包的应用场景和内存管理,看看闭包在实际项目中到底怎么用,怎么避免内存泄漏。

如果你觉得今天的文章对你有帮助,点个赞让更多人看到,也欢迎在评论区聊聊你遇到过的作用域坑。我们明天见!

nestjs学习 - 拦截器(intercept)

作者 web_bee
2026年3月20日 11:04

拦截器是使用 @Injectable() 装饰器注解的类。拦截器应该实现 NestInterceptor 接口。

img

一、它是什么

拦截器(Interceptor) 是一个基于 面向切面编程(AOP) 思想的强大功能。它允许你在请求到达控制器(Controller)之前或之后,以及响应返回给客户端之前,插入自定义逻辑。

白话:

从上图可以看出,拦截器就是可以在 到达请求前请求返回结果后 进行拦截,做一些你想做的事情;

前端开发同学可以结合 axios 的拦截器理解,几乎就是同一个模式;

简单说:拦截器就是请求和响应路上的“把关人”,能在不修改核心业务代码的情况下,统一处理一些公共逻辑。

在下文中主要关注它的使用场景;

在框架生命周期中,它的执行时机是:

请求进入 → 中间件 → 守卫 → 拦截器 → 管道 → 控制器 → 服务 → 拦截器 → 异常过滤器 → 服务器响应

二、使用方法

在使用拦截器之前,需了解 RxJS(响应式编程库) 的使用

它底层严重依赖 RxJS,因为 intercept() 方法返回的是一个 Observable(可观察对象)。这意味着你需要对 RxJS 的操作符(如 map, tap, catchError 等)有一定了解。

1. 创建

统一响应数据格式demo:

import { NestInterceptor, CallHandler, ExecutionContext, Injectable } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';


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

/**
 * 响应拦截器
 * 用于处理响应数据
 * 可以用于处理响应数据,如添加响应头,添加响应体等
 */
@Injectable()
export class ResponseInterceptor<T> implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<Data<T>> {
    // ==========================
    // 【阶段 1:控制器执行之前】
    // ==========================
    // 这里的代码会立即同步执行。
    // 此时请求刚到达拦截器,还没进控制器。
    console.log('❤️ [之前] 请求已到达拦截器');
    const startTime = Date.now();
    
    // 可以在这里做:权限预检、记录开始时间、修改请求参数等。
    // 如果在这里直接 return 一个 Observable (例如 return of({error: 'blocked'})) 
    // 而不调用 next.handle(),控制器将永远不会执行(短路)。

    // 调用 next.handle() 启动控制器逻辑
    // 它返回一个 Observable,代表控制器未来的执行结果(流)
    const response$ = next.handle(); 
    
    // ==========================
    // 【阶段 2:控制器执行之后】
    // ==========================
    // 这里的代码不会立即执行!
    // 它们被注册为 RxJS 的“操作符”,只有当控制器执行完毕并产生数据时,流才会流动到这里。
    return response$.pipe(
      map(data => {
        return {
          code: 200,
          message: 'success',
          data,
        };
      }),
    );
  }
}

2. 注册

有三种注册方式,作用范围依次扩大:

方法级别:

仅针对某个特定路由

@Get('users')
@UseInterceptors(LoggingInterceptor)
findAll() {
  return this.userService.findAll();
}

控制器级别:

针对该控制器下的所有路由

@Controller('users')
@UseInterceptors(LoggingInterceptor)
export class UsersController {
  // ...
}

全局级别

针对整个应用的所有路由,在 main.ts 中注册:

const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new LoggingInterceptor());
await app.listen(3000);

三、使用场景:

  1. 统一响应格式格式化(正常数据、错误数据)

  2. 响应缓存

    对于不经常变动的数据(如配置信息、列表页),可以在拦截器中检查缓存。

    • 如果缓存命中,直接 return of(cachedData)不调用 next.handle(),从而跳过控制器逻辑,极大提升性能。
    • 如果未命中,正常执行并写入缓存。
  3. 超时处理

    如果某个请求处理时间过长,可以强制中断。

    import { timeout } from 'rxjs/operators';
    
    // 在 intercept 方法中
    return next.handle().pipe(
      timeout(5000), // 5秒无响应则抛出异常
    );
    
  4. 数据序列化/脱敏

    在返回给用户之前,动态修改敏感字段。

    • 例如:将用户列表中的 password 字段移除,或将手机号中间四位替换为 ****
    • 通过 map 操作符遍历返回数据并进行清洗。

四、总结:

  • 基于 AOP 思想,利用 RxJS 在请求/响应生命周期中插入逻辑的机制。
  • 它本质上是一个强大的“切面”工具,用于处理那些横跨整个应用程序的、与核心业务逻辑无关的公共关注点。

    它的精髓在于:你可以在不侵入、不修改任何一个现有控制器方法的情况下,为整个应用或特定接口批量添加上述各种功能。 这使得你的代码更加干净、可维护,并且这些横切关注点可以被轻松地复用和组合。

  • 统一返回格式、日志记录、性能监控、缓存、数据转换、超时控制。
  • 区别: 比中间件更灵活,能操作返回值;比守卫(Guard)更侧重于数据转换而非权限决策。

生产环境极致优化:拆包、图片压缩、Gzip/Brotli 完全指南

作者 wuhen_n
2026年3月20日 10:53

前言

当我们的应用从开发环境走向生产环境,真正的挑战才刚刚开始。用户不会关心我们的代码写得多么优雅,他们只关心页面加载快不快、交互流不流畅。一个未经优化的生产构建,可能让我们的用户在第一秒就流失。

为什么要优化生产构建?

一个真实的反面教材

我们先来看一个系统打包后的产物:

dist/
├── index.html                5KB
├── assets/index.abc123.js    2.8MB  ← 一个文件包含了所有代码
├── assets/vendor.def456.js   1.2MB  ← 第三方库
├── assets/style.ghi789.css   180KB
└── images/
    ├── logo.png              120KB  ← 未压缩
    ├── banner.jpg            850KB  ← 巨大
    └── ...

当用户访问这个系统时:

  • 下载 2.8MB + 1.2MB + 180KB + 970KB = 约 5MB
  • 4G 网络下需要 2 秒;3G 网络会更慢
  • 用户早跑了

构建优化的核心目标

优化维度 目标 收益
拆包优化 分离业务代码和第三方库 利用浏览器缓存,二次访问提速
图片压缩 减少图片体积 平均减少 60-80% 体积
Gzip/Brotli 压缩文本资源 减少 70-90% 传输体积
长期缓存 文件名哈希,内容变化才更新 最大化缓存利用率

优化能带来什么?

指标 优化前 优化后 提升
首屏 JS 体积 4.2 MB 2.1 MB 50%
图片总体积 2.8 MB 0.6 MB 78%
传输体积(Gzip后) 3.2 MB 0.8 MB 75%
首次加载时间 3.2 秒 1.1 秒 65%
二次加载时间 2.1 秒 0.3 秒 85%

先诊断,后开药 - 构建分析工具

为什么要先分析?

就像医生看病要先做检查一样,优化构建也要先找到问题在哪。在主观上,我们可能会觉得是不是某个依赖太大了?但实际上可能是另一个我们没想到的库!

使用 rollup-plugin-visualizer 分析

安装

npm install --save-dev rollup-plugin-visualizer

配置

// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'

export default {
  plugins: [
    visualizer({
      filename: 'dist/stats.html',  // 输出文件
      open: true,                   // 构建后自动打开
      gzipSize: true,                // 显示 gzip 后大小
      brotliSize: true,              // 显示 brotli 后大小
      template: 'treemap'            // 图表类型: treemap, sunburst, network
    })
  ]
}

运行构建

npm run build
// 浏览器会自动打开一个酷炫的图表
// 一眼就能看出哪些文件最大

使用 vite-bundle-visualizer 分析

安装

npm install --save-dev vite-bundle-visualizer

运行分析

npx vite-bundle-visualizer

输出示例

┌───────────────────────┬─────────────┬──────────┬───────┐
│       Module          │    Size     │  Gzip    │ Brotli│
├───────────────────────┼─────────────┼──────────┼───────┤
│ node_modules/         │ 2.3 MB      │ 680 KB   │ 520 KB│
│   vue/                │ 680 KB      │ 210 KB   │ 160 KB│
│   element-plus/       │ 890 KB      │ 280 KB   │ 210 KB│
│   echarts/            │ 520 KB      │ 150 KB   │ 115 KB│
│   lodash-es/          │ 210 KB      │ 62 KB    │ 48 KB │
│ src/                  │ 1.8 MB      │ 480 KB   │ 360 KB│
└───────────────────────┴─────────────┴──────────┴───────┘

自定义分析脚本

// scripts/analyze.js
import fs from 'fs'
import path from 'path'
import { gzipSizeSync } from 'gzip-size'
import { brotliSizeSync } from 'brotli-size'

function analyzeDist() {
  const distDir = path.resolve('./dist/assets')
  const files = fs.readdirSync(distDir)
  
  let totalSize = 0
  let totalGzip = 0
  let totalBrotli = 0
  
  console.log('📦 构建产物分析\n')
  
  files
    .filter(f => f.endsWith('.js') || f.endsWith('.css'))
    .forEach(file => {
      const filePath = path.join(distDir, file)
      const content = fs.readFileSync(filePath)
      const size = content.length
      const gzip = gzipSizeSync(content)
      const brotli = brotliSizeSync(content)
      
      totalSize += size
      totalGzip += gzip
      totalBrotli += brotli
      
      console.log(`${file}:`)
      console.log(`  Raw:    ${(size / 1024).toFixed(2)} KB`)
      console.log(`  Gzip:   ${(gzip / 1024).toFixed(2)} KB (${(gzip/size*100).toFixed(0)}%)`)
      console.log(`  Brotli: ${(brotli / 1024).toFixed(2)} KB (${(brotli/size*100).toFixed(0)}%)\n`)
    })
  
  console.log('📊 总计:')
  console.log(`  Raw:    ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
  console.log(`  Gzip:   ${(totalGzip / 1024 / 1024).toFixed(2)} MB`)
  console.log(`  Brotli: ${(totalBrotli / 1024 / 1024).toFixed(2)} MB`)
}

analyzeDist()

看懂分析结果

分析结果能告诉我们什么?

1. 找出最大的依赖

  • echarts: 520KB → 考虑按需加载
  • monaco-editor: 2.8MB → 考虑动态导入

2. 找出重复的依赖

  • lodash 和 lodash-es 同时存在? → 统一用 lodash-es
  • moment 和 dayjs 同时存在? → 用 dayjs 替代 moment

3. 找出可以拆分的点

  • node_modules 打包在一起太大了 → 拆成多个 chunk
  • 所有页面代码都在一个文件里 → 按路由拆分

拆包策略 - 把大象放进冰箱

为什么要拆包?

用一个比喻来解释

不拆包:把所有东西都塞进一个行李箱
├─ 想拿牙刷 → 要翻遍整个箱子
├─ 箱子破了 → 所有东西都掉出来
└─ 箱子太大 → 搬不动

拆包:分成多个小包
├─ 洗漱包:牙刷、牙膏、毛巾
├─ 衣物包:衣服、裤子、袜子
├─ 电子包:充电器、数据线
├─ 哪个包破了 → 只损失那部分
└─ 每个包都很轻 → 好搬

技术层面的好处

不拆包:
├─ 修改一行代码 → 整个大文件缓存失效
└─ 用户每次更新都要重新下载所有代码

拆包后:
├─ 第三方库独立 → 几乎不变,长期缓存
├─ 业务代码拆分 → 只下载修改的部分
└─ 多个小文件可以并行下载

基础拆包配置

// vite.config.ts
export default {
  build: {
    rollupOptions: {
      output: {
        // 最基本的拆包策略
        manualChunks: {
          // 将 Vue 全家桶打包在一起
          'vendor-vue': ['vue', 'vue-router', 'pinia', 'vuex'],
          
          // 将 UI 库打包在一起
          'vendor-ui': ['element-plus', '@element-plus/icons-vue', 'ant-design-vue'],
          
          // 将工具库打包在一起
          'vendor-utils': ['lodash-es', 'dayjs', 'axios', 'date-fns'],
          
          // 将图表库打包在一起
          'vendor-charts': ['echarts', 'd3', 'chart.js']
        }
      }
    }
  }
}

智能拆包:根据依赖关系自动拆分

// vite.config.ts
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks(id: string) {
          // node_modules 中的依赖
          if (id.includes('node_modules')) {
            // 按包名拆分
            if (id.includes('vue')) {
              return 'vendor-vue'  // 所有 vue 相关
            }
            
            if (id.includes('element-plus') || id.includes('antd')) {
              return 'vendor-ui'   // UI 库
            }
            
            if (id.includes('echarts') || id.includes('d3')) {
              return 'vendor-charts' // 图表库
            }
            
            if (id.includes('lodash') || id.includes('dayjs')) {
              return 'vendor-utils'  // 工具库
            }
            
            if (id.includes('monaco-editor')) {
              return 'vendor-monaco'  // 编辑器单独打包
            }
            
            // 其他依赖打包在一起
            return 'vendor-other'
          }
          
          // 业务代码按页面拆分
          if (id.includes('/src/views/')) {
            const match = id.match(/\/src\/views\/([^\/]+)/)
            if (match) {
              return `page-${match[1]}` // 按页面拆分
            }
          }
          
          // 公共组件按模块拆分
          if (id.includes('/src/components/')) {
            const match = id.match(/\/src\/components\/([^\/]+)/)
            if (match) {
              return `components-${match[1]}`
            }
          }
        }
      }
    }
  }
}

高级拆包:基于大小的自动拆分

// vite.config.ts
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks(id: string, { getModuleInfo }) {
          // 如果模块大于 500KB,单独拆包
          const moduleInfo = getModuleInfo(id)
          if (moduleInfo && moduleInfo.code) {
            const size = Buffer.byteLength(moduleInfo.code, 'utf8')
            if (size > 500 * 1024) { // 500KB
              const name = id.match(/[^/]+\.(js|ts|vue)$/)?.[0]
              return `large-${name}`  // 大文件单独打包
            }
          }
          
          // 继续其他拆分逻辑
          if (id.includes('node_modules')) {
            if (id.includes('vue')) return 'vendor-vue'
            if (id.includes('element-plus')) return 'vendor-ui'
          }
        }
      }
    }
  }
}

异步 chunk 的命名优化

// vite.config.ts
export default {
  build: {
    rollupOptions: {
      output: {
        // 异步 chunk 命名
        chunkFileNames: 'assets/chunks/[name]-[hash].js',
        
        // 入口文件命名
        entryFileNames: 'assets/[name]-[hash].js',
        
        // 资源文件命名
        assetFileNames: 'assets/[ext]/[name]-[hash].[ext]',
        
        manualChunks: {
          // ... 拆包配置
        }
      }
    }
  }
}

// 输出结果:
// assets/index-abc123.js                (入口)
// assets/chunks/vendor-vue-def456.js    (Vue 相关)
// assets/chunks/page-dashboard-ghi789.js (页面)
// assets/images/logo-jkl012.png         (图片)

拆包后的效果

拆包方式 文件数量 缓存利用率 适用场景
不拆包 1个 极低 小项目
按依赖拆分 5-10个 中大型项目
按页面拆分 10-50个 较高 多页面应用
按大小拆分 可变 中等 有大文件的项目

图片压缩 - 看不见的优化

为什么图片是优化重点?

我们先来看一个典型的页面资源分布:

const pageResources = {
  js: '2.8MB (40%)',
  css: '180KB (3%)',
  images: '3.5MB (50%)',  // 图片占了一半!
  fonts: '500KB (7%)'
}

在页面中,图片通常占页面总体积的 50-70%,因此优化图片是最容易见效的!

vite-plugin-image-optimizer 配置

安装

npm install --save-dev vite-plugin-image-optimizer

配置

// vite.config.ts
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'

export default {
  plugins: [
    ViteImageOptimizer({
      // 配置文件类型和压缩参数
      png: {
        quality: 80,  // PNG 质量 0-100
        compressionLevel: 9, // 压缩级别 0-9
      },
      jpeg: {
        quality: 75,  // JPEG 质量
        progressive: true, // 渐进式 JPEG
      },
      jpg: {
        quality: 75,
      },
      webp: {
        quality: 75,  // WebP 质量
        lossless: false, // 是否无损
      },
      avif: {
        quality: 60,  // AVIF 质量
        lossless: false,
      },
      svg: {
        // SVG 优化选项
        plugins: [
          {
            name: 'preset-default',
            params: {
              overrides: {
                removeViewBox: false, // 保留 viewBox
                cleanupIds: false,     // 保留 ID
              },
            },
          },
        ],
      },
      tiff: {
        quality: 70,
      },
      gif: {
        optimizationLevel: 3, // 优化级别 1-3
      },
    })
  ]
}

不同图片类型的优化策略

// vite.config.ts
export default {
  plugins: [
    ViteImageOptimizer({
      // 根据不同用途设置不同参数
      
      // 1. 图标类:需要清晰,适当压缩
      'src/assets/icons/**/*': {
        png: { quality: 90 },
        svg: { plugins: ['preset-default'] }
      },
      
      // 2. 背景图:可以牺牲一些质量换取体积
      'src/assets/backgrounds/**/*': {
        jpeg: { quality: 65 },
        webp: { quality: 60 }
      },
      
      // 3. 产品图:平衡质量和体积
      'src/assets/products/**/*': {
        jpeg: { quality: 80 },
        webp: { quality: 75 }
      },
      
      // 4. 用户上传:保持较好质量
      'src/assets/uploads/**/*': {
        jpeg: { quality: 85 },
        png: { quality: 85 }
      }
    })
  ]
}

使用现代图片格式

配置

// vite.config.ts
export default {
  plugins: [
    ViteImageOptimizer({
      // 生成 WebP 版本(浏览器支持更好)
      webp: {
        quality: 75
      },
      
      // 生成 AVIF 版本(压缩率更高)
      avif: {
        quality: 60
      }
    })
  ]
}

在组件中配合使用

<template>
  <!-- picture 元素让浏览器选择最佳格式 -->
  <picture>
    <!-- 现代浏览器优先使用 AVIF -->
    <source srcset="/image.avif" type="image/avif">
    <!-- 其次使用 WebP -->
    <source srcset="/image.webp" type="image/webp">
    <!-- 降级到 JPEG -->
    <img src="/image.jpg" alt="图片" loading="lazy">
  </picture>
</template>

懒加载与图片优化结合

<template>
  <img 
    v-lazy="optimizedImageUrl"
    :data-srcset="`
      ${smallImage} 400w,
      ${mediumImage} 800w,
      ${largeImage} 1200w
    `"
    sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px"
    loading="lazy"
    :alt="alt"
  >
</template>

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

const props = defineProps<{ 
  imagePath: string,
  alt?: string 
}>()

// 根据视图宽度选择合适大小的图片
const optimizedImageUrl = computed(() => {
  // 假设构建时生成了不同尺寸的图片
  // logo-small.jpg, logo-medium.jpg, logo-large.jpg
  const width = typeof window !== 'undefined' ? window.innerWidth : 1200
  
  if (width < 600) {
    return props.imagePath.replace(/\.(jpg|png)$/, '-small.$1')
  }
  if (width < 1200) {
    return props.imagePath.replace(/\.(jpg|png)$/, '-medium.$1')
  }
  return props.imagePath.replace(/\.(jpg|png)$/, '-large.$1')
})
</script>

图片优化的效果

图片类型 优化前 优化后 节省
PNG 图标 120KB 35KB 71%
JPG 产品图 850KB 180KB 79%
WebP 背景 650KB 110KB 83%
SVG 矢量 15KB 8KB 47%
总体积 2.8MB 0.6MB 78%

Gzip/Brotli 压缩 - 让传输更轻盈

什么是 Gzip/Brotli?

我们可以用快递来比喻,比如我们有一件很大的“羽绒服”要邮寄给浏览器:

  • 原始文件:一件羽绒服(很大,但很轻)
  • Gzip:真空压缩袋,把羽绒服压扁
  • Brotli:更好的真空压缩袋,压得更扁

当浏览器收到压缩后的文件,它只需要打开压缩袋,羽绒服(文件)就可以恢复原状!

压缩算法的对比

算法 压缩率 压缩速度 解压速度 浏览器支持
Gzip 中等 所有浏览器
Brotli 中等 现代浏览器 (92%)
Deflate 极快 极快 所有浏览器

相同文件对比

  • 原始 JS: 1000 KB
  • Gzip: 280 KB (72% 减少)
  • Brotli: 220 KB (78% 减少)
  • Brotli 比 Gzip 再减少 21% 体积

使用 vite-plugin-compression 配置

安装

npm install --save-dev vite-plugin-compression

配置

// vite.config.ts
import compression from 'vite-plugin-compression'

export default {
  plugins: [
    // Gzip 压缩
    compression({
      algorithm: 'gzip',
      ext: '.gz',
      threshold: 10240, // 10KB 以上才压缩
      deleteOriginFile: false, // 保留原文件
      verbose: true, // 输出压缩信息
      filter: /\.(js|css|html|svg)$/ // 只压缩文本文件
    }),
    
    // Brotli 压缩
    compression({
      algorithm: 'brotliCompress',
      ext: '.br',
      threshold: 10240,
      deleteOriginFile: false,
      verbose: true,
      filter: /\.(js|css|html|svg)$/
    })
  ]
}

// 构建结果:
// index.abc123.js
// index.abc123.js.gz    (Gzip)
// index.abc123.js.br    (Brotli)

智能压缩策略 - 多算法混合策略

// vite.config.ts
import compression from 'vite-plugin-compression'

export default {
  plugins: [
    // 对不同的资源使用不同的策略
    
    // 1. HTML: 使用 Brotli(最高压缩率)
    compression({
      algorithm: 'brotliCompress',
      ext: '.br',
      filter: /\.html$/,
      threshold: 1024
    }),
    
    // 2. JS/CSS: 同时生成 Gzip 和 Brotli
    compression({
      algorithm: 'gzip',
      ext: '.gz',
      filter: /\.(js|css)$/,
      threshold: 10240
    }),
    compression({
      algorithm: 'brotliCompress',
      ext: '.br',
      filter: /\.(js|css)$/,
      threshold: 10240
    }),
    
    // 3. 大文件用 Brotli,小文件用 Gzip
    compression({
      algorithm: 'brotliCompress',
      ext: '.br',
      filter: /\.(js|css)$/,
      threshold: 51200 // 50KB 以上用 Brotli
    }),
    compression({
      algorithm: 'gzip',
      ext: '.gz',
      filter: /\.(js|css)$/,
      threshold: 10240, // 10-50KB 用 Gzip
      deleteOriginFile: true // 小文件可以删除原文件
    })
  ]
}

Nginx 配置示例

# nginx.conf
server {
  listen 80;
  server_name example.com;
  root /usr/share/nginx/html;
  
  # 开启 Gzip
  gzip on;
  gzip_vary on;
  gzip_min_length 10240;
  gzip_types text/plain text/css text/xml text/javascript 
             application/javascript application/x-javascript 
             application/xml application/json;
  gzip_comp_level 6;
  gzip_buffers 16 8k;
  gzip_http_version 1.1;
  
  # Brotli 支持(需要编译 brotli 模块)
  brotli on;
  brotli_min_length 10240;
  brotli_types text/plain text/css text/xml text/javascript 
               application/javascript application/x-javascript 
               application/xml application/json;
  brotli_comp_level 6;
  
  location / {
    try_files $uri $uri/ /index.html;
    
    # 尝试 Brotli,然后是 Gzip,最后是原始文件
    location ~* \.(js|css)$ {
      try_files $uri.br $uri.gz $uri =404;
      
      # 根据 Accept-Encoding 设置正确的 Content-Encoding
      if ($http_accept_encoding ~* br) {
        add_header Content-Encoding br;
        add_header Content-Type $content_type;
      }
      if ($http_accept_encoding ~* gzip) {
        add_header Content-Encoding gzip;
        add_header Content-Type $content_type;
      }
      
      # 长期缓存
      expires 1y;
      add_header Cache-Control "public, immutable";
      add_header Vary Accept-Encoding;
    }
    
    # 图片缓存
    location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|avif)$ {
      expires 30d;
      add_header Cache-Control "public";
    }
  }
}

验证压缩效果

# 使用 curl 验证压缩

# 查看是否支持压缩
curl -H "Accept-Encoding: gzip, br" -I https://example.com/app.js

# 响应头应该包含
Content-Encoding: br
Content-Type: application/javascript
Content-Length: 220000

# 下载并解压验证
curl -H "Accept-Encoding: br" https://example.com/app.js | brotli -d

# 或者使用 httpie
http https://example.com/app.js Accept-Encoding:br

长期缓存策略:让缓存最大化

文件名哈希的原理

// 构建后的文件名
// index.[hash].js

// 哈希是基于文件内容生成的
// 内容不变 → 哈希不变 → 缓存有效
// 内容变化 → 哈希变化 → 重新下载

dist/
├── index.abc123.js    // 哈希基于内容生成
├── index.def456.js    // 内容变化,哈希变化
├── vendor-vue.123abc.js // 第三方库几乎不变
└── vendor-ui.456def.js   // UI 库偶尔更新

配置文件名哈希

// vite.config.ts
export default {
  build: {
    rollupOptions: {
      output: {
        // 入口文件
        entryFileNames: 'assets/[name].[hash].js',
        
        // 异步 chunk
        chunkFileNames: 'assets/chunks/[name].[hash].js',
        
        // 资源文件
        assetFileNames: 'assets/[ext]/[name].[hash].[ext]',
        
        manualChunks: {
          // 稳定的第三方库单独打包(几乎不变)
          'vendor-stable': [
            'vue',
            'vue-router',
            'pinia',
            'vuex'
          ],
          
          // 可能更新的 UI 库单独打包
          'vendor-ui': [
            'element-plus',
            '@element-plus/icons-vue',
            'ant-design-vue'
          ],
          
          // 可能更新的工具库
          'vendor-utils': [
            'lodash-es',
            'dayjs',
            'axios'
          ]
        }
      }
    },
    
    // 生成 manifest.json
    manifest: true
  }
}

Nginx 缓存配置

# nginx.conf
server {
  # 静态资源缓存配置
  
  # JS/CSS 长期缓存(带 hash 的文件)
  location ~* \.(js|css)$ {
    # 匹配带 hash 的文件
    if ($uri ~* "\.[a-f0-9]{8,20}\.(js|css)$") {
      expires 1y;
      add_header Cache-Control "public, immutable";
    }
    
    # 如果不带 hash,短时间缓存
    expires 1h;
    add_header Cache-Control "public";
    
    # 尝试压缩版本
    try_files $uri.br $uri.gz $uri =404;
    add_header Vary Accept-Encoding;
  }
  
  # 图片等资源
  location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|avif)$ {
    expires 30d;
    add_header Cache-Control "public";
  }
  
  # 字体文件
  location ~* \.(woff2?|ttf|eot)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
    add_header Access-Control-Allow-Origin "*";
  }
  
  # HTML 文件不缓存
  location ~* \.html$ {
    expires -1;
    add_header Cache-Control "no-cache, must-revalidate";
  }
}

Service Worker 缓存策略

// sw.js
const CACHE_NAME = 'v1'
const CACHE_URLS = [
  '/',
  '/index.html',
  '/manifest.json'
]

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

// 缓存策略:缓存优先,网络回退
self.addEventListener('fetch', event => {
  const url = new URL(event.request.url)
  
  // 静态资源使用 Cache First 策略
  if (url.pathname.match(/\.(js|css|png|jpg|webp)$/)) {
    event.respondWith(
      caches.match(event.request)
        .then(response => {
          // 缓存命中直接返回
          if (response) return response
          
          // 未命中则请求网络并缓存
          return fetch(event.request).then(response => {
            const clone = response.clone()
            caches.open(CACHE_NAME).then(cache => {
              cache.put(event.request, clone)
            })
            return response
          })
        })
    )
  } 
  // HTML 使用 Network First 策略
  else if (url.pathname.endsWith('.html') || url.pathname === '/') {
    event.respondWith(
      fetch(event.request)
        .then(response => {
          const clone = response.clone()
          caches.open(CACHE_NAME).then(cache => {
            cache.put(event.request, clone)
          })
          return response
        })
        .catch(() => caches.match(event.request))
    )
  }
})

缓存命中率的提升

文件类型 更新频率 缓存策略 命中率
vendor-vue.js 几乎不变 永久缓存 99%
vendor-ui.js 偶尔更新 永久缓存 92%
page-*.js 经常更新 永久缓存 65%
图片 很少更新 30天缓存 95%
字体 从不更新 永久缓存 99%

实战案例:一个中大型项目的构建优化

优化前的状态

// 项目信息
// - 页面数量:45 个
// - 组件数量:850 个
// - 第三方依赖:230 个
// - 图片数量:1200 张

// 构建产物
dist/ 总大小: 45 MB
├── js/      28 MB
├── css/     2.5 MB
├── images/  14 MB
└── others/  0.5 MB

// 性能指标
// - 构建时间:3 分 45 秒
// - 首屏体积:4.2 MB
// - 加载时间:3.2 秒

优化步骤

第一步:分析找出问题

# 运行分析
npx vite-bundle-visualizer

# 发现问题
echarts: 1.2MB        ← 太大
monaco-editor: 2.8MB  ← 巨大!
lodash-es: 210KB      ← 还好
moment: 450KB         ← 可以用 dayjs 替代

第二步:优化拆包

// vite.config.js
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules')) {
            // 把 echarts 单独打包
            if (id.includes('echarts')) {
              return 'vendor-echarts'
            }
            
            // 把 monaco-editor 单独打包
            if (id.includes('monaco-editor')) {
              return 'vendor-monaco'
            }
            
            // 其他分组
            if (id.includes('vue')) return 'vendor-vue'
            if (id.includes('element-plus')) return 'vendor-ui'
            if (id.includes('lodash') || id.includes('dayjs')) {
              return 'vendor-utils'
            }
            
            return 'vendor-other'
          }
          
          // 按页面拆分
          if (id.includes('/src/views/')) {
            const match = id.match(/\/src\/views\/([^\/]+)/)
            if (match) return `page-${match[1]}`
          }
        }
      }
    }
  }
}

第三步:图片压缩

// vite.config.js
export default {
  plugins: [
    ViteImageOptimizer({
      png: { quality: 75 },
      jpeg: { quality: 70 },
      webp: { quality: 70 },
      avif: { quality: 60 }
    })
  ]
}

第四步:开启压缩

// vite.config.js
export default {
  plugins: [
    compression({
      algorithm: 'brotliCompress',
      threshold: 10240
    })
  ]
}

第五步:按需加载

// 大组件使用动态导入
const MonacoEditor = defineAsyncComponent(() => 
  import('monaco-editor')
)

// 路由懒加载
const routes = [
  {
    path: '/dashboard',
    component: () => import('./views/Dashboard.vue')  // 按需加载
  }
]

优化后的结果

指标 优化前 优化后 提升
构建时间 3 分 45 秒 2 分 20 秒 38%
总大小 45 MB 18 MB 60%
首屏 JS 体积 4.2 MB 1.8 MB 57%
图片体积 14 MB 3.5 MB 75%
传输体积 3.2 MB 0.8 MB 75%
加载时间 3.2 秒 1.1 秒 65%

常见问题与解决方案

问题一:拆包过多导致请求数爆炸

// ❌ 错误:拆得太细
manualChunks(id) {
  // 每个依赖都单独打包
  return id.match(/node_modules\/([^\/]+)/)?.[1]
}
// 结果:产生 200+ 个文件,HTTP/1.1 下性能差

// ✅ 正确:合理分组
manualChunks(id) {
  if (id.includes('node_modules')) {
    if (id.includes('vue')) return 'vendor-vue'
    if (id.includes('lodash')) return 'vendor-utils'
    if (id.includes('echarts')) return 'vendor-charts'
    if (id.includes('monaco')) return 'vendor-monaco'
    return 'vendor-other' // 其他合并
  }
}

问题二:图片压缩后质量下降

// 解决方案:选择性压缩
ViteImageOptimizer({
  // 图标保留较高品质
  'src/assets/icons/**/*': {
    png: { quality: 90 },
    svg: { plugins: ['preset-default'] }
  },
  
  // 背景图可以接受较低品质
  'src/assets/backgrounds/**/*': {
    jpeg: { quality: 65 },
    webp: { quality: 60 }
  },
  
  // 产品图需要平衡
  'src/assets/products/**/*': {
    jpeg: { quality: 80 },
    webp: { quality: 75 }
  }
})

// 或者使用图片 CDN 动态处理
<img src="https://cdn.example.com/image.jpg?x-oss-process=image/resize,w_400/quality,q_80">

问题三:Brotli 压缩太慢

// ✅ 解决方案:选择性使用 Brotli
compression({
  algorithm: 'brotliCompress',
  threshold: 50000,  // 50KB 以上才用 Brotli
  filter: /\.(js|css)$/
})

// 小文件继续用 Gzip
compression({
  algorithm: 'gzip',
  threshold: 10240,  // 10-50KB 用 Gzip
  filter: /\.(js|css)$/
})

问题四:CDN 不支持 Brotli

# ✅ 解决方案:同时生成 Gzip 和 Brotli
location /assets {
    # 优先尝试 Brotli
    try_files $uri.br $uri.gz $uri =404;
    
    # 根据 Accept-Encoding 返回正确的 Content-Encoding
    if ($http_accept_encoding ~* br) {
        add_header Content-Encoding br;
    }
    if ($http_accept_encoding ~* gzip) {
        add_header Content-Encoding gzip;
    }
}

生产环境优化的最佳实践

优化检查清单

  • 使用 visualizer 分析构建产物
  • 配置 manualChunks 合理拆包
  • 图片资源压缩优化
  • 启用 Gzip/Brotli 压缩
  • 配置长期缓存策略
  • 设置性能预算
  • 在 CI/CD 中集成检查
  • 定期监控 Web Vitals

配置文件模板

// vite.config.ts - 生产环境优化完整配置
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { visualizer } from 'rollup-plugin-visualizer'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import compression from 'vite-plugin-compression'

export default defineConfig(({ mode }) => ({
  plugins: [
    vue(),
    
    // 图片压缩
    ViteImageOptimizer({
      png: { quality: 75 },
      jpeg: { quality: 70 },
      webp: { quality: 70 },
      avif: { quality: 60 }
    }),
    
    // Gzip 压缩
    compression({
      algorithm: 'gzip',
      ext: '.gz',
      threshold: 10240
    }),
    
    // Brotli 压缩
    compression({
      algorithm: 'brotliCompress',
      ext: '.br',
      threshold: 10240
    }),
    
    // 构建分析(只在需要时开启)
    process.env.ANALYZE && visualizer({
      open: true,
      filename: 'dist/stats.html',
      gzipSize: true,
      brotliSize: true
    })
  ].filter(Boolean),
  
  build: {
    target: 'es2015',
    minify: 'terser',
    terserOptions: {
      compress: {
        drop_console: mode === 'production',
        drop_debugger: true
      }
    },
    
    rollupOptions: {
      output: {
        entryFileNames: 'assets/[name].[hash].js',
        chunkFileNames: 'assets/chunks/[name].[hash].js',
        assetFileNames: 'assets/[ext]/[name].[hash].[ext]',
        
        manualChunks(id) {
          if (id.includes('node_modules')) {
            if (id.includes('vue')) return 'vendor-vue'
            if (id.includes('element-plus') || id.includes('antd')) {
              return 'vendor-ui'
            }
            if (id.includes('echarts') || id.includes('d3')) {
              return 'vendor-charts'
            }
            if (id.includes('lodash') || id.includes('dayjs')) {
              return 'vendor-utils'
            }
            if (id.includes('monaco-editor')) {
              return 'vendor-monaco'
            }
            return 'vendor-other'
          }
          
          if (id.includes('/src/views/')) {
            const match = id.match(/\/src\/views\/([^\/]+)/)
            if (match) return `page-${match[1]}`
          }
        }
      }
    },
    
    chunkSizeWarningLimit: 500,
    sourcemap: mode !== 'production',
    manifest: true
  }
}))

性能目标参考

指标 优秀 一般
首屏 JS 体积 < 200KB 200-500KB > 500KB
总构建体积 < 2MB 2-5MB > 5MB
图片体积占比 < 30% 30-50% > 50%
压缩率 > 70% 50-70% < 50%
缓存命中率 > 80% 50-80% < 50%
FCP < 1.5s 1.5-2.5s > 2.5s
LCP < 2.5s 2.5-4s > 4s

三个核心原则

  1. 测量优先:没有数据的优化是盲目的
  2. 渐进改进:每次只优化一个指标
  3. 用户优先:始终以用户体验为导向

结语

优化的终极目标是让用户感受不到加载的存在。当用户打开我们的应用时,内容瞬间呈现,交互立即响应,这就说明我们的优化成功了!

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

组件设计模式(上) 受控/非受控组件与容器组件

作者 Csvn
2026年3月20日 10:42

📚 概述

组件设计模式是 React 开发中的核心概念。理解受控/非受控组件以及容器组件模式,能帮助你写出更清晰、更可维护的代码。


1️⃣ 受控组件(Controlled Components)

受控组件是指表单数据由 React 状态管理的组件。

核心特点

  • ✅ 单一数据源:表单值存储在 state 中
  • ✅ 实时验证:可以在输入时进行验证
  • ✅ 强制格式:可以控制输入格式
  • ✅ 条件禁用:可以根据条件禁用提交按钮

代码示例:表单验证

import { useState } from 'react';

function ControlledForm() {
  const [formData, setFormData] = useState({
    username: '',
    email: '',
    password: ''
  });
  const [errors, setErrors] = useState({});

  const validateField = (name, value) => {
    switch (name) {
      case 'email':
        return /^[^\s@]+@[^\s@]+.[^\s@]+$/.test(value) 
          ? '' : '请输入有效的邮箱地址';
      case 'password':
        return value.length >= 8 
          ? '' : '密码至少需要 8 个字符';
      default:
        return '';
    }
  };

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({ ...prev, [name]: value }));
    
    // 实时验证
    const error = validateField(name, value);
    setErrors(prev => ({ ...prev, [name]: error }));
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    // 提交前验证所有字段
    const newErrors = {};
    Object.keys(formData).forEach(key => {
      const error = validateField(key, formData[key]);
      if (error) newErrors[key] = error;
    });

    if (Object.keys(newErrors).length === 0) {
      console.log('提交数据:', formData);
    } else {
      setErrors(newErrors);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>用户名:</label>
        <input
          name="username"
          value={formData.username}
          onChange={handleChange}
        />
      </div>
      <div>
        <label>邮箱:</label>
        <input
          name="email"
          value={formData.email}
          onChange={handleChange}
        />
        {errors.email && <span style={{color: 'red'}}>{errors.email}</span>}
      </div>
      <div>
        <label>密码:</label>
        <input
          name="password"
          type="password"
          value={formData.password}
          onChange={handleChange}
        />
        {errors.password && <span style={{color: 'red'}}>{errors.password}</span>}
      </div>
      <button 
        type="submit"
        disabled={Object.values(errors).some(e => e) || !formData.username}
      >
        提交
      </button>
    </form>
  );
}

2️⃣ 非受控组件(Uncontrolled Components)

非受控组件是指表单数据由 DOM 自身管理的组件,使用 ref 来访问表单值。

适用场景

  • 📁 文件输入(<input type="file" />
  • 🔌 第三方库集成(不兼容 React 状态管理)
  • ⚡ 简单表单(不需要实时验证)
  • 📊 性能优化(避免频繁重渲染)

代码示例:使用 useRef

import { useRef } from 'react';

function UncontrolledForm() {
  const formRef = useRef(null);
  const fileInputRef = useRef(null);

  const handleSubmit = (e) => {
    e.preventDefault();
    
    // 通过 ref 获取表单数据
    const formData = new FormData(formRef.current);
    const data = Object.fromEntries(formData.entries());
    
    console.log('表单数据:', data);
    
    // 访问文件输入
    const file = fileInputRef.current.files[0];
    if (file) {
      console.log('选中的文件:', file.name);
    }
  };

  return (
    <form ref={formRef} onSubmit={handleSubmit}>
      <div>
        <label>用户名:</label>
        <input name="username" defaultValue="" />
      </div>
      <div>
        <label>邮箱:</label>
        <input name="email" type="email" defaultValue="" />
      </div>
      <div>
        <label>上传文件:</label>
        <input 
          ref={fileInputRef}
          name="file" 
          type="file" 
        />
      </div>
      <button type="submit">提交</button>
    </form>
  );
}

3️⃣ 容器组件模式(Container Component Pattern)

容器组件负责数据获取和业务逻辑,展示组件负责 UI 渲染。

核心思想

  • 🧠 容器组件:处理数据、状态、业务逻辑
  • 🎨 展示组件:只负责接收 props 并渲染 UI
  • 🔄 关注点分离:逻辑与视图解耦

代码示例:用户列表

// UserList.jsx - 展示组件(纯 UI)
function UserList({ users, loading, error, onRefresh }) {
  if (loading) return <div>加载中...</div>;
  if (error) return <div>错误:{error}</div>;

  return (
    <div>
      <button onClick={onRefresh}>刷新</button>
      <ul>
        {users.map(user => (
          <li key={user.id}>
            {user.name} - {user.email}
          </li>
        ))}
      </ul>
    </div>
  );
}

// UserListContainer.jsx - 容器组件(数据逻辑)
import { useState, useEffect } from 'react';
import UserList from './UserList';

function UserListContainer() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  const fetchUsers = async () => {
    try {
      setLoading(true);
      const response = await fetch('/api/users');
      const data = await response.json();
      setUsers(data);
      setError(null);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    fetchUsers();
  }, []);

  return (
    <UserList
      users={users}
      loading={loading}
      error={error}
      onRefresh={fetchUsers}
    />
  );
}

export default UserListContainer;

4️⃣ 现代替代方案:自定义 Hooks

随着 Hooks 的普及,自定义 Hooks 成为容器组件的现代替代方案。

代码示例:useUsers Hook

// hooks/useUsers.js
import { useState, useEffect } from 'react';

export function useUsers() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  const fetchUsers = async () => {
    try {
      setLoading(true);
      const response = await fetch('/api/users');
      const data = await response.json();
      setUsers(data);
      setError(null);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    fetchUsers();
  }, []);

  return { users, loading, error, refetch: fetchUsers };
}

// 使用 Hook 的组件
import { useUsers } from './hooks/useUsers';

function UserPage() {
  const { users, loading, error, refetch } = useUsers();

  if (loading) return <div>加载中...</div>;
  if (error) return <div>错误:{error}</div>;

  return (
    <div>
      <button onClick={refetch}>刷新</button>
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name} - {user.email}</li>
        ))}
      </ul>
    </div>
  );
}

💡 模式对比

特性 受控组件 非受控组件 容器组件 自定义 Hooks
数据源 React state DOM React state React state
实时验证
性能 中等 中等
代码复用 非常高
推荐场景 表单验证 文件输入/简单表单 数据获取 数据获取(现代)

⚠️ 选择建议

  1. 需要实时验证 → 受控组件
  2. 集成第三方库 → 非受控组件
  3. 复杂数据逻辑 → 自定义 Hooks(优先)或容器组件
  4. 简单表单 → 非受控组件(性能更好)
❌
❌