阅读视图

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

每日一题-垂直翻转子矩阵🟢

给你一个 m x n 的整数矩阵 grid,以及三个整数 xyk

整数 xy 表示一个 正方形子矩阵 的左上角下标,整数 k 表示该正方形子矩阵的边长。

你的任务是垂直翻转子矩阵的行顺序。

返回更新后的矩阵。

 

示例 1:

输入: grid = [[1,2,3,4],[5,6,7,8],[9,10,11,12],[13,14,15,16]], x = 1, y = 0, k = 3

输出: [[1,2,3,4],[13,14,15,8],[9,10,11,12],[5,6,7,16]]

解释:

上图展示了矩阵在变换前后的样子。

示例 2:

输入: grid = [[3,4,2,3],[2,3,4,2]], x = 0, y = 2, k = 2

输出: [[3,4,4,2],[2,3,2,3]]

解释:

上图展示了矩阵在变换前后的样子。

 

提示:

  • m == grid.length
  • n == grid[i].length
  • 1 <= m, n <= 50
  • 1 <= grid[i][j] <= 100
  • 0 <= x < m
  • 0 <= y < n
  • 1 <= k <= min(m - x, n - y)

git clone: Clone a Repository

git clone copies an existing Git repository into a new directory on your local machine. It sets up tracking branches for each remote branch and creates a remote called origin pointing back to the source.

This guide explains how to use git clone with the most common options and protocols.

Syntax

The basic syntax of the git clone command is:

txt
git clone [OPTIONS] REPOSITORY [DIRECTORY]

REPOSITORY is the URL or path of the repository to clone. DIRECTORY is optional; if omitted, Git uses the repository name as the directory name.

Clone a Remote Repository

The most common way to clone a repository is over HTTPS. For example, to clone a GitHub repository:

Terminal
git clone https://github.com/user/repo.git

This creates a repo directory in the current working directory, initializes a .git directory inside it, and checks out the default branch.

You can also clone over SSH if you have an SSH key configured :

Terminal
git clone git@github.com:user/repo.git

SSH is generally preferred for repositories you will push to, as it does not require entering credentials each time.

Clone into a Specific Directory

By default, git clone creates a directory named after the repository. To clone into a different directory, pass the target path as the second argument:

Terminal
git clone https://github.com/user/repo.git my-project

To clone into the current directory, use . as the target:

Terminal
git clone https://github.com/user/repo.git .
Warning
Cloning into . requires the current directory to be empty.

Clone a Specific Branch

By default, git clone checks out the default branch (usually main or master). To clone and check out a different branch, use the -b option:

Terminal
git clone -b develop https://github.com/user/repo.git

The full repository history is still downloaded; only the checked-out branch differs. To limit the download to a single branch, combine -b with --single-branch:

Terminal
git clone -b develop --single-branch https://github.com/user/repo.git

Shallow Clone

A shallow clone downloads only a limited number of recent commits rather than the full history. This is useful for speeding up the clone of large repositories when you do not need the full commit history.

To clone with only the latest commit:

Terminal
git clone --depth 1 https://github.com/user/repo.git

To include the last 10 commits:

Terminal
git clone --depth 10 https://github.com/user/repo.git

Shallow clones are commonly used in CI/CD pipelines to reduce download time. To convert a shallow clone to a full clone later, run:

Terminal
git fetch --unshallow

Clone a Local Repository

git clone also works with local paths. This is useful for creating an isolated copy for testing:

Terminal
git clone /path/to/local/repo new-copy

Troubleshooting

Destination directory is not empty
git clone URL . works only when the current directory is empty. If files already exist, clone into a new directory or move the existing files out of the way first.

Authentication failed over HTTPS
Most Git hosting services no longer accept account passwords for Git operations over HTTPS. Use a personal access token or configure a credential manager so Git can authenticate securely.

Permission denied (publickey)
This error usually means your SSH key is missing, not loaded into your SSH agent, or not added to your account on the Git hosting service. Verify your SSH setup before retrying the clone.

Host key verification failed
SSH could not verify the identity of the remote server. Confirm you are connecting to the correct host, then accept or update the host key in ~/.ssh/known_hosts.

Quick Reference

For a printable quick reference, see the Git cheatsheet .

Command Description
git clone URL Clone a repository via HTTPS or SSH
git clone URL dir Clone into a specific directory
git clone URL . Clone into the current (empty) directory
git clone -b branch URL Clone and check out a specific branch
git clone -b branch --single-branch URL Clone only a specific branch
git clone --depth 1 URL Shallow clone: latest commit only
git clone --depth N URL Shallow clone: last N commits
git clone /path/to/repo Clone a local repository

FAQ

What is the difference between HTTPS and SSH cloning?
HTTPS cloning works out of the box but prompts for credentials unless you use a credential manager. SSH cloning requires a key pair to be configured but does not prompt for a password on each push or pull.

Does git clone download all branches?
Yes. All remote branches are fetched, but only the default branch is checked out locally. Use git branch -r to list all remote branches, or git checkout branch-name to switch to one.

How do I clone a private repository?
For HTTPS, provide your username and a personal access token when prompted. For SSH, ensure your public key is added to your account on the hosting service.

Can I clone a specific tag instead of a branch?
Yes. Use -b with the tag name: git clone -b v1.0.0 https://github.com/user/repo.git. Git checks out that tag in a detached HEAD state, so create a branch if you plan to make commits.

Conclusion

git clone is the starting point for working with any existing Git repository. After cloning, you can inspect the commit history with git log , review changes with git diff , and configure your username and email if you have not done so already.

df Cheatsheet

Basic Usage

Common ways to check disk space.

Command Description
df Show disk usage for all mounted filesystems
df /path Show filesystem usage for the given path
df -h Human-readable sizes (K, M, G — powers of 1024)
df -H Human-readable sizes (K, M, G — powers of 1000)
df -a Include all filesystems (including pseudo and duplicate)
df --total Add a grand total row at the bottom

Size Formats

Control how sizes are displayed.

Option Description
-h Auto-scale using powers of 1024 (K, M, G)
-H Auto-scale using powers of 1000 (SI units)
-k Display sizes in 1K blocks (default on most systems)
-m Display sizes in 1M blocks
-BG Display sizes in 1G blocks
-B SIZE Use SIZE-byte blocks (e.g., -BM, -BG, -B512)

Output Columns

What each column in the default output means.

Column Description
Filesystem Device or remote path of the filesystem
1K-blocks / Size Total size of the filesystem
Used Space currently in use
Available Space available for use
Use% Percentage of space used
Mounted on Directory where the filesystem is mounted

Filesystem Type

Show or filter by filesystem type.

Option Description
-T Show filesystem type column
-t TYPE Only show filesystems of the given type
-x TYPE Exclude filesystems of the given type
-l Show only local filesystems (exclude network mounts)

Examples:

Command Description
df -t ext4 Show only ext4 filesystems
df -x tmpfs Exclude tmpfs from output
df -Th Show type column with human-readable sizes

Inode Usage

Show inode counts instead of block usage.

Command Description
df -i Show inode usage for all filesystems
df -ih Show inode usage with human-readable counts
df -i /path Show inode usage for a specific filesystem
Column Description
Inodes Total number of inodes
IUsed Inodes in use
IFree Inodes available
IUse% Percentage of inodes used

Custom Output Fields

Select specific fields with --output.

Field Description
source Filesystem device
fstype Filesystem type
size Total size
used Space used
avail Space available
pcent Percentage used
iused Inodes used
iavail Inodes available
ipcent Inode percentage used
target Mount point

Example: df --output=source,size,used,avail,pcent,target -h

Related Guides

Use these references for deeper disk usage workflows.

Guide Description
How to Check Disk Space in Linux Using the df Command Full df guide with practical examples
Du Command in Linux Check disk usage for directories and files

3643. 垂直翻转子矩阵

Problem: 3643. 垂直翻转子矩阵

[TOC]

思路

模拟

Code

###Rust

impl Solution {
    pub fn reverse_submatrix(mut grid: Vec<Vec<i32>>, x: i32, y: i32, k: i32) -> Vec<Vec<i32>> {
        let (x, y, k) = (x as usize, y as usize, k as usize);
        for u in x..x + k / 2 {
            let d = x + k - 1 - u + x;
            for i in y..y + k {
                let p1 = &mut grid[u][i] as *mut i32;
                let p2 = &mut grid[d][i] as *mut i32;
                unsafe { std::ptr::swap(p1, p2); }
            }
        }

        grid
    }
}

双指针(Python/Java/C++/Go)

根据题意,交换的范围是行号 $[x,x+k-1]$,列号 $[y,y+k-1]$。

类似 344. 反转字符串,用双指针实现:

  • 初始化 $l=x$,$r=x+k-1$。
  • 循环直到 $l\ge r$。
  • 每次循环,对于 $[y,y+k-1]$ 中的每个整数 $j$,交换 $\textit{grid}[l][j]$ 和 $\textit{grid}[r][j]$。

具体请看 视频讲解,欢迎点赞关注~

###py

class Solution:
    def reverseSubmatrix(self, grid: List[List[int]], x: int, y: int, k: int) -> List[List[int]]:
        l, r = x, x + k - 1
        while l < r:
            for j in range(y, y + k):
                grid[l][j], grid[r][j] = grid[r][j], grid[l][j]
            l += 1
            r -= 1
        return grid

###py

class Solution:
    def reverseSubmatrix(self, grid: List[List[int]], x: int, y: int, k: int) -> List[List[int]]:
        l, r = x, x + k - 1
        while l < r:
            grid[l][y: y + k], grid[r][y: y + k] = grid[r][y: y + k], grid[l][y: y + k]
            l += 1
            r -= 1
        return grid

###java

class Solution {
    public int[][] reverseSubmatrix(int[][] grid, int x, int y, int k) {
        int l = x;
        int r = x + k - 1;
        while (l < r) {
            for (int j = y; j < y + k; j++) {
                int tmp = grid[l][j];
                grid[l][j] = grid[r][j];
                grid[r][j] = tmp;
            }
            l++;
            r--;
        }
        return grid;
    }
}

###cpp

class Solution {
public:
    vector<vector<int>> reverseSubmatrix(vector<vector<int>>& grid, int x, int y, int k) {
        int l = x, r = x + k - 1;
        while (l < r) {
            for (int j = y; j < y + k; j++) {
                swap(grid[l][j], grid[r][j]);
            }
            l++;
            r--;
        }
        return grid;
    }
};

###go

func reverseSubmatrix(grid [][]int, x, y, k int) [][]int {
l, r := x, x+k-1
for l < r {
for j := y; j < y+k; j++ {
grid[l][j], grid[r][j] = grid[r][j], grid[l][j]
}
l++
r--
}
return grid
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(k^2)$。
  • 空间复杂度:$\mathcal{O}(1)$。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

模拟

解法:模拟

按题意模拟即可。复杂度 $\mathcal{O}(nm)$。

参考代码(c++)

class Solution {
public:
    vector<vector<int>> reverseSubmatrix(vector<vector<int>>& grid, int x, int y, int k) {
        for (int i = x, ii = x + k - 1; i < ii; i++, ii--) for (int j = y; j < y + k; j++)
            swap(grid[i][j], grid[ii][j]);
        return grid;
    }
};

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

不是游戏引擎做不起,而是 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 表格组件空数据统一处理 踩坑

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 混合加密

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天 - 循环渲染和条件渲染

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

  • 使用 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;

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

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 吗?我用一次真实部署经历说清楚

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

本文所有代码如果没有特别标注的话,默认用的都是 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 能够在同一运行时环境中高效协作

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

在建立我们的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(超详细)

本文主要跑通一个基本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

本文字数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 依赖产物打包到分包中

前言

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 带来的开发时“瞬移”体验

前言

还记得用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 海量点位不卡顿!图标动态聚合效果深度解析,看完直接抄代码!

接上文# 告别冗余代码!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 图标聚合原理上很简单:

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

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

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

❌