普通视图

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

从 nvm 到 fnm:一个前端老兵的版本管理工具迁移实录

2025年12月10日 11:54

从 nvm 到 fnm:一个前端老兵的版本管理工具迁移实录

引子:那个让我崩溃的早晨

某个周一早晨,我像往常一样打开终端准备开始工作。

# 打开 iTerm2... 等待... 等待...
# 终于出现命令提示符,耗时 2.3 秒

接着切换到一个需要 Node 16 的老项目:

$ nvm use 16
Now using node v16.20.2 (npm v8.19.4)
# 又是 0.5 秒过去了

然后跳到另一个 Node 20 的新项目:

$ cd ../new-project
$ nvm use 20
Now using node v20.19.6 (npm v10.8.2)
# 再等 0.5 秒

一天下来,在 5-6 个项目间来回切换,光是等 nvm 响应就浪费了不知道多少时间。更别提有时候忘记 nvm use,直接 npm install 导致 node_modules 版本混乱的惨剧了。

是时候做出改变了。

为什么要离开 nvm?

痛点一:Shell 启动慢得令人发指

nvm 是纯 Bash 脚本实现的。每次打开新终端,它都要:

  1. 加载 nvm.sh 脚本(几千行)
  2. 解析已安装的 Node 版本
  3. 设置环境变量
  4. 初始化自动补全

我用 time 测了一下我的 .zshrc 加载时间:

# 有 nvm
$ time zsh -i -c exit
zsh -i -c exit  0.42s user 0.23s system 95% cpu 0.678 total

# 注释掉 nvm 后
$ time zsh -i -c exit
zsh -i -c exit  0.08s user 0.05s system 92% cpu 0.142 total

nvm 让我的终端启动慢了近 5 倍!

痛点二:版本切换不够智能

每次进入不同项目都要手动 nvm use,太原始了。虽然有 avnnvm-auto 等插件可以实现自动切换,但:

  • 又增加了一层依赖
  • 又拖慢了 Shell 速度
  • 配置起来也挺麻烦

痛点三:Windows 支持是个笑话

nvm 官方根本不支持 Windows。nvm-windows 是另一个独立项目,命令和行为都有差异。团队里用 Windows 的同事经常遇到各种奇怪问题。

痛点四:偶发的诡异 Bug

用了几年 nvm,遇到过各种奇怪问题:

  • 全局安装的包莫名消失
  • npm prefix 路径错乱
  • 多个终端窗口版本不同步
  • .nvmrc 有时候不生效

遇见 fnm:Rust 带来的性能革命

fnm(Fast Node Manager)是用 Rust 写的 Node.js 版本管理器。第一次用的时候,我的反应是:

"就这?结束了?怎么这么快?"

性能实测对比

我在自己的 M1 MacBook Pro 上做了详细测试:

终端启动时间
# nvm
$ time zsh -i -c exit
0.678 total

# fnm
$ time zsh -i -c exit
0.089 total

# 提升:7.6 倍
版本切换时间
# nvm
$ time nvm use 20
Now using node v20.19.6
real    0m0.347s

# fnm
$ time fnm use 20
Using Node v20.19.6
real    0m0.012s

# 提升:29 倍
安装新版本
# nvm install 22
# 总耗时约 45 秒(包含下载)

# fnm install 22
# 总耗时约 38 秒(包含下载)

# 下载速度差不多,但 fnm 的解压和配置更快

为什么 fnm 这么快?

  1. 原生二进制:Rust 编译成机器码,不需要解释器
  2. 并行处理:充分利用多核 CPU
  3. 惰性加载:只在需要时才读取版本信息
  4. 高效的文件操作:Rust 的 I/O 性能本就出色

真实场景:fnm 如何改变我的工作流

场景一:多项目并行开发

我同时维护着这些项目:

项目 Node 版本 原因
老后台系统 14 历史包袱,依赖不支持高版本
主站前端 18 稳定的 LTS
新管理后台 20 需要新特性
实验性项目 22 尝鲜最新 API
Electron 应用 18 Electron 版本限制

以前用 nvm:

$ cd legacy-admin
$ nvm use  # 等待...
Found '/Users/me/legacy-admin/.nvmrc' with version <14>
Now using node v14.21.3

$ cd ../main-site
$ nvm use  # 又等待...
Found '/Users/me/main-site/.nvmrc' with version <18>
Now using node v18.20.8

# 经常忘记 nvm use,然后...
$ npm install
# 装了一堆错误版本的依赖 💥

现在用 fnm:

$ cd legacy-admin
Using Node v14.21.3  # 自动切换,瞬间完成

$ cd ../main-site
Using Node v20.19.6  # 无感切换

# 永远不会忘记切换版本,因为是自动的

场景二:CI/CD 环境统一

我们团队的 CI 配置以前是这样的:

# .gitlab-ci.yml (使用 nvm)
before_script:
  - curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
  - export NVM_DIR="$HOME/.nvm"
  - . "$NVM_DIR/nvm.sh"
  - nvm install
  - nvm use

换成 fnm 后:

# .gitlab-ci.yml (使用 fnm)
before_script:
  - curl -fsSL https://fnm.vercel.app/install | bash
  - eval "$(fnm env)"
  - fnm install
  - fnm use

CI 构建时间减少了约 15 秒(主要是 nvm 初始化太慢)。

场景三:团队协作与跨平台

我们团队成员使用的系统:

  • 60% macOS
  • 30% Windows
  • 10% Linux

nvm 时代的痛苦:

# macOS/Linux 同事
$ nvm use 20

# Windows 同事(nvm-windows)
$ nvm use 20.19.6  # 必须写完整版本号!
# 而且 .nvmrc 经常不生效

fnm 时代的统一:

# 所有平台,相同命令,相同行为
$ fnm use 20

Windows 同事终于不用单独维护一套文档了。

场景四:Docker 开发环境

在 Dockerfile 中安装 Node:

# 以前用 nvm(不推荐,但有人这么干)
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash \
    && . ~/.nvm/nvm.sh \
    && nvm install 20 \
    && nvm alias default 20
# 镜像体积大,层数多

# 现在用 fnm
RUN curl -fsSL https://fnm.vercel.app/install | bash -s -- --install-dir /usr/local/bin \
    && fnm install 20 \
    && fnm default 20
# 更简洁,体积更小

场景五:Monorepo 中的多版本需求

我们有一个 Monorepo,不同 package 需要不同 Node 版本:

monorepo/
├── packages/
│   ├── legacy-sdk/        # 需要 Node 14(兼容老用户)
│   │   └── .node-version  # 14
│   ├── web-app/           # 需要 Node 20
│   │   └── .node-version  # 20
│   └── cli-tool/          # 需要 Node 18
│       └── .node-version  # 18
└── .node-version          # 20(默认)

fnm 的 --use-on-cd 让我在不同 package 间跳转时完全无感:

$ cd packages/legacy-sdk
Using Node v14.21.3

$ cd ../web-app
Using Node v20.19.6

$ cd ../cli-tool
Using Node v18.20.8

完整迁移指南

第一步:安装 fnm

macOS (Homebrew):

brew install fnm

Windows (Scoop):

scoop install fnm

Windows (Chocolatey):

choco install fnm

Linux/macOS (curl):

curl -fsSL https://fnm.vercel.app/install | bash

Cargo (Rust 用户):

cargo install fnm

第二步:配置 Shell

这是最关键的一步,配置正确才能享受自动切换的便利。

Zsh (~/.zshrc):

# fnm - Fast Node Manager
eval "$(fnm env --use-on-cd --shell zsh)"

Bash (~/.bashrc):

# fnm - Fast Node Manager
eval "$(fnm env --use-on-cd --shell bash)"

Fish (~/.config/fish/config.fish):

# fnm - Fast Node Manager
fnm env --use-on-cd --shell fish | source

PowerShell ($PROFILE):

# fnm - Fast Node Manager
fnm env --use-on-cd --shell powershell | Out-String | Invoke-Expression

参数说明:

参数 作用
--use-on-cd 进入目录时自动读取 .node-version.nvmrc 并切换
--shell <shell> 指定 Shell 类型,生成对应的环境变量设置命令
--version-file-strategy recursive 向上递归查找版本文件(可选)
--corepack-enabled 自动启用 Corepack(可选)

第三步:迁移已安装的 Node 版本

查看 nvm 安装了哪些版本:

ls ~/.nvm/versions/node/
# v10.24.1 v14.21.3 v16.20.2 v18.20.8 v20.19.6 v22.21.0

在 fnm 中安装对应版本:

# 方式一:通过 LTS 代号安装(推荐,自动获取最新补丁版本)
fnm install lts/fermium   # v14.x
fnm install lts/gallium   # v16.x
fnm install lts/hydrogen  # v18.x
fnm install lts/iron      # v20.x
fnm install lts/jod       # v22.x

# 方式二:通过大版本号安装
fnm install 14
fnm install 16
fnm install 18
fnm install 20
fnm install 22

# 方式三:安装精确版本
fnm install 18.20.8
fnm install 20.19.6

设置默认版本:

fnm default 22

第四步:处理全局 npm 包

这是很多人忽略的一步!nvm 下安装的全局包不会自动迁移。

查看 nvm 中的全局包:

# 切换到 nvm 的某个版本
export PATH="$HOME/.nvm/versions/node/v20.19.6/bin:$PATH"
npm list -g --depth=0

在 fnm 的对应版本中重新安装:

fnm use 20
npm install -g typescript ts-node nodemon pm2 # 你需要的包

Pro Tip: 可以写个脚本批量处理:

#!/bin/bash
# migrate-global-packages.sh

# 你常用的全局包
PACKAGES="typescript ts-node nodemon pm2 pnpm yarn"

for version in 18 20 22; do
  echo "Installing global packages for Node $version..."
  fnm use $version
  npm install -g $PACKAGES
done

第五步:注释掉 nvm 配置

编辑你的 Shell 配置文件:

# ~/.zshrc 或 ~/.bashrc

# nvm - 已迁移到 fnm,注释掉避免冲突
# export NVM_DIR="$HOME/.nvm"
# [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
# [ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"

第六步:验证迁移结果

# 重新加载配置
source ~/.zshrc  # 或 source ~/.bashrc

# 验证 fnm 工作正常
fnm list
fnm current
node -v
npm -v
which node

# 测试自动切换
cd /path/to/project-with-nvmrc
# 应该看到 "Using Node vX.X.X"
node -v

第七步:删除 nvm(可选但推荐)

确认一切正常后,可以删除 nvm 释放磁盘空间:

rm -rf ~/.nvm

# 如果遇到权限问题
sudo rm -rf ~/.nvm

命令速查表

功能 nvm 命令 fnm 命令
安装指定版本 nvm install 20 fnm install 20
安装最新 LTS nvm install --lts fnm install --lts
安装指定 LTS nvm install --lts=iron fnm install lts/iron
切换版本 nvm use 20 fnm use 20
设置默认版本 nvm alias default 20 fnm default 20
查看已安装版本 nvm ls fnm list
查看远程可用版本 nvm ls-remote fnm list-remote
查看当前版本 nvm current fnm current
卸载版本 nvm uninstall 18 fnm uninstall 18
在指定版本执行命令 nvm exec 18 node -v fnm exec --using=18 node -v

LTS 版本代号参考

代号 版本 发布日期 LTS 开始 维护结束 状态
Jod v22.x 2024-04 2024-10 2027-04 ✅ Active LTS
Iron v20.x 2023-04 2023-10 2026-04 ✅ Active LTS
Hydrogen v18.x 2022-04 2022-10 2025-04 ⚠️ Maintenance
Gallium v16.x 2021-04 2021-10 2024-09 ❌ End-of-Life
Fermium v14.x 2020-04 2020-10 2023-04 ❌ End-of-Life

建议:新项目使用 v20 或 v22,老项目尽快升级到至少 v18。

进阶技巧

1. 配置版本文件查找策略

默认情况下,fnm 只在当前目录查找 .node-version.nvmrc。如果你的项目结构比较深,可以启用递归查找:

eval "$(fnm env --use-on-cd --version-file-strategy recursive)"

2. 启用 Corepack

Corepack 是 Node.js 内置的包管理器版本管理工具,可以锁定 pnpm/yarn 版本:

eval "$(fnm env --use-on-cd --corepack-enabled)"

3. 自定义安装目录

默认安装在 ~/.local/share/fnm,可以自定义:

export FNM_DIR="/path/to/custom/fnm"
eval "$(fnm env --use-on-cd)"

4. 使用国内镜像加速

# 临时使用
fnm install 20 --node-dist-mirror=https://npmmirror.com/mirrors/node

# 永久配置
export FNM_NODE_DIST_MIRROR="https://npmmirror.com/mirrors/node"

5. 在脚本中使用 fnm

#!/bin/bash
# 确保 fnm 环境已加载
eval "$(fnm env)"

# 使用指定版本执行
fnm use 20
node your-script.js

# 或者用 exec
fnm exec --using=20 node your-script.js

常见问题 FAQ

Q: fnm 安装的 Node 在哪里?

~/.local/share/fnm/node-versions/

每个版本是一个独立目录,结构清晰。

Q: 为什么 which node 显示的路径很奇怪?

fnm 使用"多 Shell"机制,每个 Shell 会话有独立的 PATH:

$ which node
/Users/xxx/.local/state/fnm_multishells/12345_1234567890/bin/node

这是正常的,确保了不同终端窗口可以使用不同版本。

Q: 如何在 VS Code 中使用 fnm 管理的 Node?

VS Code 会自动检测 fnm。如果遇到问题,可以在 settings.json 中配置:

{
  "terminal.integrated.env.osx": {
    "PATH": "${env:PATH}"
  }
}

或者在项目根目录创建 .vscode/settings.json

{
  "eslint.runtime": "node"
}

Q: fnm 支持 .nvmrc 吗?

完全支持!fnm 会按以下顺序查找版本文件:

  1. .node-version
  2. .nvmrc
  3. package.jsonengines.node 字段

Q: 如何回退到 nvm?

如果你想回退(虽然我不建议),只需:

  1. 注释掉 fnm 配置
  2. 取消注释 nvm 配置
  3. 重新加载 Shell

你的 nvm 数据(如果没删)还在 ~/.nvm

总结:值得迁移吗?

绝对值得。

迁移成本:

  • 时间:约 15-30 分钟
  • 学习曲线:几乎为零(命令高度相似)
  • 风险:极低(可随时回退)

获得收益:

  • 终端启动快 5-10 倍
  • 版本切换快 20-30 倍
  • 自动切换版本,告别手动 nvm use
  • 跨平台一致性,团队协作更顺畅
  • 更少的 Bug 和怪异行为

如果你每天要打开几十次终端、在多个项目间切换,fnm 节省的时间累积起来是非常可观的。更重要的是,那种丝滑无感的体验,会让你的开发心情都变好。


参考资料:


如果这篇文章对你有帮助,欢迎点赞收藏。有问题可以在评论区交流!

JavaScript call、apply、bind 方法解析

2025年12月10日 11:42

JavaScript call、apply、bind 方法解析

在 JavaScript 中,callapplybind 都是用来**this** 改变函数执行时 指向 的核心方法,它们的核心目标一致,但使用方式、执行时机和传参形式有明显区别。

const dog = {
  name: "旺财",
  sayName() {
    console.log(this.name);
  },
  eat(food) {
    console.log(`${this.name} 在吃${food}`);
  },
  eats(food1, food2) {
    console.log(`${this.name} 在吃${food1}${food2}`);
  },
};

const cat = {
  name: "咪咪",
};
// call 会立即执行函数,并且改变 this 指向
dog.sayName.call(cat); // 输出 '咪咪'
dog.eat.call(cat, "🐟"); // 输出 '咪咪 在吃🐟'

dog.sayName.apply(cat); // 输出 '咪咪'

dog.eats.call(cat, "🐟", "🐔"); // 输出 '咪咪 在吃🐟和🐔'

dog.eats.apply(cat, ["🐟", "🐔"]); // 输出 '咪咪 在吃🐟和🐔'

const boundEats = dog.eats.bind(cat);
boundEats("🐟", "🐔"); // 输出 '咪咪 在吃🐟和🐔'

一、核心共性

三者的核心作用:this 手动指定函数执行时的 指向,突破函数默认的 this 绑定规则(比如对象方法的 this 原本指向对象本身,通过这三个方法可以强制指向其他对象)。

以示例中的 dog.sayName() 为例,默认执行时 this 指向 dog,但通过 call/apply/bind 可以让 this 指向 cat,从而输出 咪咪 而非 旺财

二、逐个解析

1. call

  • 执行时机立即执行 函数

  • 传参方式:第一个参数是 this 要指向的目标对象,后续参数逐个单独传递(逗号分隔)

  • 语法函数.call(thisArg, arg1, arg2, ...)

示例解析:
// this 指向 cat,无额外参数,立即执行 sayName
dog.sayName.call(cat); // 输出 '咪咪'

// this 指向 cat,额外参数 '🐟' 逐个传递,立即执行 eat
dog.eat.call(cat, "🐟"); // 输出 '咪咪 在吃🐟'

// 多参数场景:参数逐个传递,立即执行 eats
dog.eats.call(cat, "🐟", "🐔"); // 输出 '咪咪 在吃🐟和🐔'

2. apply

  • 执行时机立即执行 函数(和 call 一致)

  • 传参方式:第一个参数是 this 要指向的目标对象,后续参数必须放在一个数组(或类数组)中传递

  • 语法函数.apply(thisArg, [arg1, arg2, ...])

示例解析:
// 无额外参数,数组可以为空(或不传),立即执行 sayName
dog.sayName.apply(cat); // 输出 '咪咪'

// 多参数场景:参数放在数组中传递,立即执行 eats
dog.eats.apply(cat, ["🐟", "🐔"]); // 输出 '咪咪 在吃🐟和🐔'

注意:apply 适合参数数量不固定、或参数已存在于数组中的场景(比如 Math.max.apply(null, [1,2,3]) 求数组最大值)。

3. bind

  • 执行时机不立即执行 函数,而是返回一个绑定了新 this 指向的新函数,后续需要手动调用这个新函数才会执行

  • 传参方式:第一个参数是 this 要指向的目标对象,后续参数可以提前绑定(柯里化),也可以在调用新函数时补充

  • 语法const 新函数 = 函数.bind(thisArg, arg1, arg2, ...); 新函数(剩余参数);

示例解析:
// 第一步:bind 不执行,仅绑定 this 为 cat,返回新函数 boundEats(原变量名 boundSayName 已修改)
const boundEats = dog.eats.bind(cat);

// 第二步:手动调用新函数,传递参数 '🐟' 和 '🐔',此时才执行 eats
boundEats("🐟", "🐔"); // 输出 '咪咪 在吃🐟和🐔'
进阶用法:
// 提前绑定部分参数(柯里化),this 仍指向 cat
const boundEatWithFish = dog.eats.bind(cat, "🐟");
// 调用时补充剩余参数,同样输出目标结果
boundEatWithFish("🐔"); // 输出 '咪咪 在吃🐟和🐔'

三、核心区别总结

特性 call apply bind
执行时机 立即执行 立即执行 不立即执行,返回新函数
传参形式 逐个传递(逗号分隔) 数组/类数组传递 可提前绑定,也可调用时传
返回值 函数执行结果 函数执行结果 绑定 this 后的新函数

四、常见使用场景

  1. call:适用于参数数量明确、需要立即执行的场景(比如继承:Parent.call(this, arg1));

  2. apply:适用于参数是数组/类数组的场景(比如求数组最大值:Math.max.apply(null, arr));

  3. bind:适用于需要延迟执行、或需要重复使用绑定 this 后的函数的场景(比如事件回调、定时器:btn.onclick = fn.bind(obj))。

五、补充注意点

  • 如果第一个参数传 null/undefined,在非严格模式下,this 会指向全局对象(浏览器中是 window,Node 中是 global);严格模式下 thisnull/undefined

  • bind 返回的新函数不能通过 call/apply 再次修改 this 指向(bind 的绑定是永久的)。

用户说卡但我测不出来?RUM 监控:直接去 “用户手机里” 抓薛定谔的 Bug

2025年12月10日 11:36

⚡️ 告别“薛定谔的 Bug”:RUM 如何精准捕获线上卡顿,让性能优化不再靠玄学?

前端性能优化专栏 - 第二篇

在性能优化的路上,我们总会遇到一个让人抓狂的“灵异事件”:用户反馈页面卡得像幻灯片,但你在本地、在公司、在高速网络下测试,它却流畅得像德芙巧克力。

我们把这种现象戏称为“薛定谔的 Bug”——你打开看的时候,它就消失了。那么,如何才能打破这个魔咒,让性能优化从“玄学”变成“科学”呢?答案就是:RUM(真实用户监控)

⚠️ “薛定谔的 Bug”:线上卡顿但无法复现

想象一下这个场景:

  1. 用户反馈: “你们的页面太卡了,点个按钮要等半天!”
  2. 你测试: 刷新、点击、滚动,一切丝滑流畅,耗时不到 100ms。
  3. 你的内心: “是不是用户手机太烂了?”

这种线上卡顿,本地流畅的差异,往往让开发者陷入深深的自我怀疑。问题根源可能隐藏在资源加载、渲染或复杂的交互延迟中,而这些问题,在你的“完美”开发环境中根本无从察觉。

✨ 环境差异的根源:性能的“黑洞”

为什么你的环境是“天堂”,用户的环境却是“地狱”?因为你们的环境差异太大了!

差异维度 你的环境(开发/测试) 用户的环境(真实线上) 性能影响
网络条件 稳定 Wi-Fi / 专线 3G/4G 切换、地铁弱信号 资源加载耗时、TTFB
设备性能 高配笔电、旗舰手机 低端手机、老旧平板 JS 执行速度、渲染速度
浏览器版本 最新 Chrome/Safari 各种版本、不同内核 API 支持、渲染机制差异
地理位置 靠近服务器的 CDN 节点 偏远地区的 CDN 节点 首字节时间(TTFB)

与其在本地一遍遍地尝试“复现问题”,不如换个思路:直接去用户的“案发现场”收集证据! 这正是 RUM(Real User Monitoring) 的核心思想。

环境差异对比图

🔧 什么是 RUM(真实用户监控)?

RUM,全称 Real User Monitoring,顾名思义,就是一套采集真实用户访问数据的监控体系。

它就像一个潜伏在用户浏览器中的“性能侦探”,默默地捕获并回传用户的性能指标、环境信息和异常日志

专业名词解释:RUM 是一种被动式的性能监控方法,它通过在用户浏览器中植入一段 JavaScript 代码,来实时收集用户在页面上的各种性能数据和行为数据,并将数据上报到服务器进行分析。

RUM 的目标非常明确: 帮助开发者还原现场、定位瓶颈、验证优化效果。它将“用户说卡”这个模糊的定性描述,转化为可量化的数据指标。

🚀 RUM 的核心组成:三大法宝

一个完整的 RUM 体系,通常由以下三个核心部分组成:

1. 性能数据采集:量化“卡顿”的体感

“卡顿”是一种主观感受,但 RUM 能用客观指标来量化它。我们主要关注 Google 推荐的 Core Web Vitals(核心 Web 指标)以及其他关键指标:

指标名称 英文缩写 衡量目标 对应“卡顿”体感
最大内容绘制 LCP 页面加载速度(最大元素出现时间) “页面白屏很久”
首次输入延迟 FID 页面交互响应速度(首次点击到响应) “点按钮没反应”
累积布局偏移 CLS 页面视觉稳定性 “页面元素乱跳”
首次绘制 FP/FCP 页面开始渲染的时间 “页面开始有东西了”
首字节时间 TTFB 服务器响应速度 “网络慢不慢”

技术实现:PerformanceObserver API

现代浏览器提供了强大的 PerformanceObserver API,让我们能够实时监听这些关键性能指标的变化,并将其上报。

const observer = new PerformanceObserver((list) => {
  list.getEntries().forEach(entry => {
    // 收集 LCP, FID, CLS 等数据
    console.log(entry.name, entry.startTime, entry.duration)
    // report(entry) // 上报到 RUM 服务器
  })
})

// 监听关键指标
observer.observe({ entryTypes: ['largest-contentful-paint', 'first-input', 'layout-shift'] })

2. 异常日志收集:捕获“意外”现场

性能问题不只是慢,还包括“崩”。RUM 体系必须能捕获各种意料之外的错误,防止用户体验彻底中断。

  • JS 执行错误: 通过 window.onerror 捕获未被 try...catch 的同步错误。
  • 资源加载失败: 监听全局的 error 事件,捕获图片、CSS、JS 等资源加载失败的情况。
  • Promise 未处理异常: 通过 unhandledrejection 捕获 Promise 链中没有 catch 的错误。
// 捕获 JS 错误
window.addEventListener('error', e => {
  report({ type: 'js-error', message: e.message })
})

// 捕获 Promise 异常
window.addEventListener('unhandledrejection', e => {
  report({ type: 'promise-rejection', reason: e.reason })
})

3. 环境信息:还原用户的“案发现场”

光有性能数据还不够,我们还需要知道是谁、在哪里、用什么遇到了问题。这些环境信息是数据聚合分析的关键,能帮助我们快速定位共性问题(比如“所有华为手机用户都卡顿”)。

  • 🖥️ 设备信息: 设备型号、内存、屏幕分辨率。
  • ⏱️ 操作系统: 操作系统类型与版本(如 iOS 17.0, Android 14)。
  • 🌐 浏览器信息: 浏览器类型与版本(如 Chrome 120, Safari 17)。
  • 📊 网络类型: 用户的网络连接类型(如 4G, Wi-Fi)。
  • 🗺️ 地理位置: 用户的国家/城市,用于分析地域性 CDN 差异。

RUM 数据流示意图

💡 总结:让数据成为你最可靠的依据

“薛定谔的 Bug”并不可怕,可怕的是我们没有工具去揭开它的面纱。

无法复现 ≠ 无法解决。

RUM 体系将性能优化从“凭感觉”和“靠运气”的阶段,带入了数据驱动的科学时代。建立一个系统性的 RUM 体系,让每一次“卡顿反馈”都能被精准捕获、分析和优化。

性能优化是一场持久战,而 RUM 就是我们最可靠的雷达。


下一篇预告: 既然我们已经了解了 RUM 的重要性,那么下一篇我们将深入讲解 RUM 的核心采集利器——PerformanceObserver API,手把手教你如何用它来精准监控页面性能指标。敬请期待!

JavaScript 内存机制与闭包:从栈到堆的深入浅出

作者 ohyeah
2025年12月10日 11:35

在前端开发中,JavaScript 是我们最常用的编程语言之一。然而,很多人在学习 JS 的过程中,常常忽略了它背后运行的底层机制——内存管理。今天我们就结合几段代码和图示,来聊聊 JavaScript 中最重要的两个概念:内存机制闭包


一、JS 的执行环境:三大内存空间

在 JavaScript 执行过程中,程序会使用三种主要的内存空间:

  1. 代码空间
  2. 栈内存(Stack)
  3. 堆内存(Heap)

lQLPJxDjlfllrWfNBJ_NBHawALUXmftxe5cJEmLLH90MAA_1142_1183.png

图1:JavaScript 的三大内存空间

1. 代码空间

这是存放源代码的地方。当浏览器加载 HTML 文件时,会把 <script> 标签中的代码从硬盘读取到内存中,形成“代码空间”。这部分内容不会直接参与运行,而是供引擎解析和编译用。

2. 栈内存

栈内存是 JS 执行的主角,用于维护函数调用过程中的执行上下文。它的特点是:

  • 空间小、速度快
  • 连续存储,便于快速切换
  • 每次函数调用都会创建一个执行上下文并压入栈顶
function foo() {
  var a = 1;
  var b = a;
  a = 2;
  console.log(a); // 2
  console.log(b); // 1
}
foo();

这段代码执行时,foo() 被调用,会生成一个新的执行上下文,并压入调用栈。这个上下文包含变量 ab,它们都是简单数据类型,直接存储在栈中。

3. 堆内存

堆内存用来存储复杂数据类型,比如对象、数组等。这些数据体积大、结构复杂,并且是动态的,不能放在栈里,所以被分配到堆中。 它的特点是:

  • '辅助栈内存'
  • 空间大 不连续
  • 存储复杂数据类型 对象
function foo() {
  var a = { name: '极客时间' };
  var b = a; // 引用拷贝
  a.name = '极客邦';
  console.log(a); // {name: "极客邦"}
  console.log(b); // {name: "极客邦"}
}
foo();

这里 a 是一个对象,它实际存储在堆内存中,而 a 变量只是保存了该对象的地址(引用)。b = a 并不是复制对象,而是让 b 也指向同一个地址。因此修改 a.name 后,b 也能看到变化。

为什么堆内存是不连续的?

  • 因为对象是动态的 可以去给它添加属性或方法 如果是连续的情况下 显然就不好进行操作了

二、简单 vs 复杂 数据类型:存储方式不同

JavaScript 有八种原始数据类型:

undefined, null, boolean, string, number, symbol, bigint, object

其中前七种是简单数据类型,最后一个是复杂数据类型

类型 存储位置
简单类型(如 number, string) 栈内存
复杂类型(如 object, array) 堆内存

示例说明

var a = '极客时间'; // 字符串 → 栈内存
var b = a;         // 拷贝值 → b 也是 '极客时间'
a = '极客邦';      // 修改 a 不影响 b
console.log(a); // 极客邦
console.log(b); // 极客时间
var c = { name: '极客时间' }; // 对象 → 堆内存
var d = c;                  // 引用拷贝 → d 指向同一地址
c.name = '极客邦';          // 修改共享对象
console.log(c.name); // 极客邦
console.log(d.name); // 极客邦

✅ 结论:

  • 简单类型是“值传递”,每个变量独立存在
  • 复杂类型是“引用传递”,多个变量可能指向同一块堆内存

lQLPJxNqPKGZwofNAifNBHawc4pej93FtxkJEmmk8M9qAA_1142_551.png

图2:变量 c 存储的是堆内存中对象的地址(1003)


三、执行上下文与调用栈

JavaScript 的执行流程依赖于调用栈(Call Stack),它是函数调用的“记录本”。

每当一个函数被执行,就会创建一个执行上下文(Execution Context),包括:

  • 变量环境(Variable Environment)
  • 词法环境(Lexical Environment)
  • outer(外层作用域链)

执行上下文会被压入调用栈顶部。当函数执行完毕后,该上下文就会被弹出并回收。

示例:函数调用前后

function foo() {
  var a = 1;
  var b = 2;
}

foo(); // 调用 foo

执行过程如下:

  1. 创建全局执行上下文(已存在)
  2. 调用 foo(),创建新的执行上下文,压入调用栈
  3. 执行完 foo(),将其执行上下文弹出,指针回到全局上下文

lQLPJw1WF0yif-fNAhTNBHawwsioxubOAzAJEmTtM6BTAA_1142_532.png

图3:foo() 执行前后调用栈的变化

注意:栈顶指针移动非常快,因为栈是连续内存,只需要改变指针位置即可完成上下文切换(栈顶指针的切换通过一个机制,做内存的减法)。如果将复杂对象也放在栈中,会导致栈空间过大、不连续,严重影响性能。


四、动态弱类型语言:JS 的灵活性

JavaScript 是一种动态弱类型语言,这意味着:

  • 动态:变量可以在运行时改变类型
  • 弱类型:不同类型之间可以自动转换
var bar;
console.log(typeof bar); // undefined

bar = 12;
console.log(typeof bar); // number

bar = '极客时间';
console.log(typeof bar); // string

bar = true;
console.log(typeof bar); // boolean

bar = null;
console.log(typeof bar); // object(JS 设计缺陷)

bar = { name: '极客时间' };
console.log(typeof bar); // object

这正是 JS 的魅力所在:无需提前声明类型,灵活自由。但这也带来了潜在问题,比如 typeof null === 'object' 就是一个经典 bug。


五、闭包的本质:延长变量生命周期

现在我们来看一个关键概念——闭包

什么是闭包?

闭包是指:内部函数能够访问外部函数的变量,即使外部函数已经执行完毕。

<script>
function foo() {
  var myName = "极客时间";
  let test1 = 1;
  const test2 = 2;

  var innerBar = {
    setName: function (newName) {
      myName = newName;
    },
    getName: function () {
      console.log(test1);
      return myName;
    }
  };

  return innerBar;
}

var bar = foo();
bar.setName("极客邦");
bar.getName(); // 输出:1, 极客邦
</script>

在这个例子中,innerBar 返回了一个对象,其方法 setNamegetName 都能访问 myNametest1。但 foo() 已经执行完了,按理说这些变量应该被销毁了,为什么还能访问?

这就是闭包的作用!


六、闭包背后的内存机制

闭包是如何工作的?答案就在堆内存中。

步骤解析:

  1. foo() 被调用时,V8 引擎会扫描内部函数 setNamegetName
  2. 发现这两个函数引用了外部变量 myNametest1
  3. 引擎判断:这是一个闭包!需要保留这些变量
  4. 堆内存中创建一个特殊的对象:closure(foo)
  5. myNametest1 的值保存到这个对象中
  6. closure(foo) 的地址存入栈中(作为 innerBar 的一部分)

lQLPJxNqPKGZwofNAifNBHawc4pej93FtxkJEmmk8M9qAA_1142_551.png

图4:闭包 closure(foo) 存储在堆内存中,栈中只保存地址

关键点总结:

  • 闭包本质:通过堆内存延长了外部变量的生命周期
  • 栈中保存的是地址,堆中保存的是真实数据
  • 执行上下文出栈 ≠ 变量消失,只要还有引用,就继续存活

七、为什么 JS 不需要手动管理内存?

像 C/C++ 这样的语言,开发者必须使用 mallocfree 来手动分配和释放内存:

int main(){
  int a = 1;
  char* b = '极客时间';
  bool c = true;

  c = a;
  return 0;
}

而在 JavaScript 中,你完全不需要关心这些。V8 引擎自动完成内存分配和垃圾回收

  • 栈内存:执行上下文结束 → 自动弹出 → 快速回收
  • 堆内存:没有变量引用的对象 → 垃圾回收器(GC)定期清理

✅ 所以 JS 开发者可以专注于业务逻辑,而不必担心内存泄漏(虽然也要注意,比如事件监听器未解绑可能导致内存泄漏)


八、总结:JS 内存机制核心要点

内容 说明
栈内存 调用栈在其内部,存放执行上下文、简单数据类型,速度快,连续
堆内存 存放复杂对象,空间大,不连续
变量提升 编译阶段为变量预留空间,初始值为 undefined
引用传递 对象赋值是地址拷贝,多个变量共享同一对象
闭包 内部函数引用外部变量 → 堆内存保存自由变量 → 延长生命周期
栈顶指针 函数调用时移动,上下文切换高效

九、写在最后

理解 JavaScript 的内存机制,不仅能帮助我们写出更高效的代码,还能更好地掌握闭包、作用域、垃圾回收等高级概念。虽然我们不需要像 C++ 那样手动操作内存,但了解背后的原理,会让你的代码更加稳健。

💡 提醒:不要滥用闭包,因为它会让变量长期驻留内存,增加 GC 压力。合理使用,才是高手之道。


📌 如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、转发!关注我,带你深入浅出地学透前端技术!

📸 图片来源:本文所有图片均为原创示意图,用于辅助理解 JS 内存模型。

Node项目部署到阿里云服务器

作者 南游
2025年12月10日 11:19

一、服务器基础准备

1. 服务器配置

  • 购买云服务器(阿里云)
  • 选择适合的操作系统(CentOS 7.9)
  • 开放安全组端口:22(SSH)、80(HTTP)、443(HTTPS)、你的应用端口(如3000)

2. 本地项目准备

  • 确保项目在本地运行正常
  • 清理 node_modules 和测试文件

二、环境安装与配置

1. 安装Node.js

在Linux上部署Node.js,本文选择使用NVM(Node Version Manager)。与包管理器安装相比,NVM不受系统仓库版本限制,确保获取最新Node.js版本;与下载预编译二进制包相比,NVM省去了繁琐的环境变量配置;与从源代码编译安装相比,NVM大大缩短了安装时间,且对用户编译技能无要求。更重要的是,NVM支持多版本管理,方便切换,且安装的Node.js位于用户家目录,无需sudo权限,有效降低了安全风险。

  1. 安装分布式版本管理系统Git。

    Alibaba Cloud Linux 3/2、CentOS 7.x

    sudo yum install git -y
    
  2. 使用Git将NVM的源码克隆到本地的~/.nvm目录下,并检查最新版本。

    git clone https://gitee.com/mirrors/nvm.git ~/.nvm && cd ~/.nvm && git checkout `git describe --abbrev=0 --tags`
    
  3. 依次运行以下命令,配置NVM的环境变量。

    sudo sh -c 'echo ". ~/.nvm/nvm.sh" >> /etc/profile'
    source /etc/profile
    
  4. 运行以下命令,修改npm镜像源为阿里云镜像,以加快Node.js下载速度。

    export NVM_NODEJS_ORG_MIRROR=https://npmmirror.com/mirrors/node
    
  5. 运行以下命令,查看Node.js版本。

    nvm list-remote
    
  6. 安装多个Node.js版本。

    Alibaba Cloud Linux 2 和 CentOS 7.x 仅支持 Node.js 17.x 及以下版本,例如需要安装 v17.9.1,则执行nvm install v17.9.1。

    nvm install v17.9.1
    

    image.png

    image.png

  7. 查看已安装的Node.js版本。

    nvm ls
    

    返回结果如下所示,表示当前已安装v22.11.0、v23.3.0两个版本,正在使用的是v22.11.0版本。

    image.png

  8. 切换版本

    您可以使用nvm use <版本号>命令切换Node.js的版本。 例如,切换至Node.js v23.3.0版本的命令为nvm use v23.3.0。

2. 安装PM2(进程管理)

sudo npm install -g pm2

3. 安装Nginx(反向代理)

sudo yum install -y nginx

# 启动Nginx
sudo systemctl start nginx
sudo systemctl enable nginx #开机自动启动

三、部署Node.js项目

假设有两个项目:

  • 项目1:端口3000,子域名api1.example.com
  • 项目2:端口4000,子域名api2.example.com

1. 上传项目代码

# 创建项目目录
mkdir -p /var/www/api1 /var/www/api2

# 上传代码(使用git)

cd /var/www/api1
git clone your-repo-url
npm install

cd /var/www/api2
git clone your-second-repo-url
npm install

2. 使用PM2启动项目

# 启动项目1
cd /var/www/api1
pm2 start app.js --name "api1" -i max --watch

# 启动项目2
cd /var/www/api2
pm2 start app.js --name "api2" -i max --watch

# 保存PM2配置
pm2 save

# 设置PM2开机启动
pm2 startup
# 执行输出的命令(会显示类似下面的命令)
# sudo env PATH=$PATH:/usr/bin /usr/lib/node_modules/pm2/bin/pm2 startup centos -u root --hp /root

四、配置子域名和Nginx反向代理

1. 域名解析准备

在阿里云DNS解析控制台添加子域名解析:

  • api1.example.com → 服务器IP
  • api2.example.com → 服务器IP

2. 配置Nginx反向代理

为项目1创建配置 (api1.example.com)

server {
    listen 80;
    server_name api1.example.com;

    location / {
        proxy_pass http://localhost:3000;  # 假设项目1运行在3000端口
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

为项目2创建配置 (api2.example.com)

server {
    listen 80;
    server_name api2.example.com;

    location / {
        proxy_pass http://localhost:4000;  # 假设项目2运行在4000端口
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

3. 测试并重启Nginx

nginx -t
systemctl restart nginx

五、维护与监控

1. PM2常用命令

pm2 list          # 查看所有应用
pm2 restart api1  # 重启特定应用
pm2 stop api2     # 停止应用
pm2 delete api1   # 删除应用

2. Nginx常用命令

systemctl restart nginx  # 重启Nginx
nginx -t                # 测试配置
journalctl -u nginx     # 查看Nginx系统日志

3. 查看服务器资源

top
htop
df -h
free -m

通过以上步骤,你可以在CentOS 7.9服务器上部署多个Node.js项目,并通过不同的子域名访问它们。每个项目都运行在独立的端口上,通过Nginx反向代理和PM2进程管理实现稳定运行。

前端怎么防止用户复制?这10种方法让你的内容更安全

作者 刘大华
2025年12月10日 10:54

大家好,我是大华!在我们写前端的时候,有时候会遇到这种禁止用户复杂网页内容的需求,这里来分享几种常见的方法,但是这些方法也不能完全阻止内容被复制。

因为用户还是可以通过开发者工具,截图提取文字等等这些方式来进行复制。不过我们也可以在一定程度上提升复制的门槛,防止普通用户随意复制。


以下是几种常用方案:

1. 禁用文本选择

使用 CSS 禁止用户选中文本:

body {
  -webkit-user-select: none; /* Safari */
  -moz-user-select: none;    /* Firefox */
  -ms-user-select: none;     /* IE/Edge */
  user-select: none;         /* 标准语法 */
}

也可以针对特定元素设置:

<div style="user-select: none;">这段文字无法被选中</div>

用户仍可查看源代码或使用其他手段复制。


2. 监听并阻止复制、剪切、粘贴事件

通过 JavaScript 拦截相关键盘和剪贴板事件:

document.addEventListener('copy', (e) => {
  e.preventDefault();
  alert('复制已被禁止');
});

document.addEventListener('cut', (e) => {
  e.preventDefault();
  alert('剪切已被禁止');
});

document.addEventListener('paste', (e) => {
  e.preventDefault();
  alert('粘贴已被禁止');
});

用户还是可以按下F12禁用 JavaScript 或绕过事件监听。


3. 将内容嵌入图片或 Canvas

将关键内容渲染为图片或使用 <canvas> 绘制,使文本不可直接选中:

<img src="text-as-image.png" alt="不可复制的文字">

或使用 canvas 动态绘制:

<canvas id="myCanvas"></canvas>
<script>
  const ctx = document.getElementById('myCanvas').getContext('2d');
  ctx.font = '20px Arial';
  ctx.fillText('这段文字无法复制', 10, 50);
</script>

不利于 SEO、无障碍访问(屏幕阅读器无法读取),且加载慢。


4.用户按F12就出发debug功能

当用户按下F12时,触发debug调试,让用户无法选中页面内容。

<script>
(function antiDebug() {
  let devOpen = false;
  const threshold = 100;

  function check() {
    const start = performance.now();
    debugger;
    const end = performance.now();

    if (end - start > threshold) {
      if (!devOpen) {
        devOpen = true;
        document.body.innerHTML = '<h2>检测到开发者工具,请关闭后重试。</h2>';
        // 可选:上报用户行为
        // fetch('/log-devtools', { method: 'POST' });
      }
    } else {
      devOpen = false;
    }

    setTimeout(check, 1000);
  }

  check();
})();
</script>

5. 动态文本拆分与重组

将文本内容拆分成多个DOM节点,增加复制难度:

<div id="protected-text">
  <!-- 文本将被JavaScript拆分并插入 -->
</div>

<script>
  const text = "这是一段需要保护的机密内容,不能被轻易复制";
  const container = document.getElementById('protected-text');
  
  // 将每个字符用span包裹
  text.split('').forEach(char => {
    const span = document.createElement('span');
    span.textContent = char;
    span.style.display = 'inline-block'; // 增加选择难度
    container.appendChild(span);
  });
</script>

进阶版:随机插入不可见字符或零宽字符:

function obfuscateText(text) {
  const zeroWidthSpace = '\u200B'; // 零宽空格
  return text.split('').map(char => 
    char + zeroWidthSpace.repeat(Math.floor(Math.random() * 3))
  ).join('');
}

const originalText = "保护内容";
document.getElementById('text').textContent = obfuscateText(originalText);

6. 使用CSS伪元素显示内容

通过CSS的::before::after伪元素显示文本:

<style>
.protected-content::before {
  content: "这段文字通过CSS生成,无法直接选中和复制";
}
</style>

<div class="protected-content"></div>

7. 字体反爬虫技术

使用自定义字体文件,将字符映射关系打乱:

@font-face {
  font-family: 'CustomFont';
  src: url('custom-font.woff2') format('woff2');
}

.protected-text {
  font-family: 'CustomFont';
}

在字体文件中,将实际字符与显示字符的映射关系打乱,比如:

  • 字符a在字体中实际显示为b
  • 字符b显示为c,以此类推

后端配合:服务器动态生成字体文件,定期更换映射关系。


8. Canvas + WebGL 渲染文本

使用更复杂的图形渲染技术:

<canvas id="textCanvas" width="800" height="100"></canvas>
<script>
  const canvas = document.getElementById('textCanvas');
  const ctx = canvas.getContext('2d');
  
  // 设置文字样式
  ctx.font = '24px Arial';
  ctx.fillStyle = '#333';
  
  // 绘制干扰元素
  ctx.fillText('保护内容', 50, 50);
  
  // 添加噪声干扰
  for(let i = 0; i < 100; i++) {
    ctx.fillRect(
      Math.random() * 800, 
      Math.random() * 100, 
      1, 1
    );
  }
</script>

9. SVG 文本渲染

使用SVG渲染文本,增加选择难度:

<svg width="400" height="100">
  <text x="10" y="30" font-family="Arial" font-size="20" 
        fill="black" style="user-select: none;">
    这段SVG文本难以复制
  </text>
  <!-- 添加干扰路径 -->
  <path d="M10,40 L390,40" stroke="#eee" stroke-width="1"/>
</svg>

10. 实时DOM监控与修复

监控DOM变化,防止用户通过开发者工具修改内容:

const contentElement = document.getElementById('protected-content');
const originalContent = contentElement.innerHTML;

// 定时检查内容完整性
setInterval(() => {
  if (contentElement.innerHTML !== originalContent) {
    contentElement.innerHTML = originalContent;
    console.log('检测到内容篡改,已恢复');
  }
}, 500);

// 使用MutationObserver更精确的监控
const observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    if (mutation.type === 'childList' || mutation.type === 'characterData') {
      contentElement.innerHTML = originalContent;
    }
  });
});

observer.observe(contentElement, {
  childList: true,
  characterData: true,
  subtree: true
});

综合防御策略建议

对于高安全要求的场景,建议采用分层防御

class ContentProtector {
  constructor() {
    this.init();
  }
  
  init() {
    this.disableSelection();
    this.bindEvents();
    this.startMonitoring();
    this.obfuscateContent();
  }
  
  disableSelection() {
    document.head.insertAdjacentHTML('beforeend', `
      <style>
        body { -webkit-user-select: none; user-select: none; }
        .protected { cursor: default; }
      </style>
    `);
  }
  
  bindEvents() {
    // 绑定所有阻止事件
    ['copy', 'cut', 'paste', 'contextmenu', 'keydown'].forEach(event => {
      document.addEventListener(event, this.preventDefault);
    });
  }
  
  preventDefault(e) {
    e.preventDefault();
    return false;
  }
  
  startMonitoring() {
    // 启动各种监控
    this.monitorDevTools();
    this.monitorDOMChanges();
  }
  
  obfuscateContent() {
    // 内容混淆处理
    // ...
  }
  
  monitorDevTools() {
    // 开发者工具检测
    // ...
  }
  
  monitorDOMChanges() {
    // DOM变化监控
    // ...
  }
}

// 初始化保护
new ContentProtector();

总结

这些方法只能防普通用户,防不住真正想复制的人。

因为别人还是可以通过看源码、截图、关掉 JS 等方式绕过限制。

所以建议:

  • 别过度防护,以免影响正常用户和 SEO;
  • 重要内容靠后端控制(比如登录才能看全文);
  • 组合使用几种方法,提高门槛就好,别追求绝对安全。

毕竟前端展示的内容,就默认是能被看到的,也就能被复制的。

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《async/await 到底要不要加 try-catch?异步错误处理最佳实践》

《都在用 Java8 和 Java17,那 Java9 到 16 呢?他们真的没用吗?》

《Java 开发必看:什么时候用 for,什么时候用 Stream?》

《这 10 个 MySQL 高级用法,让你的代码又快又好看》

【Virtual World 005】上帝之眼

作者 大怪v
2025年12月10日 10:54

这是纯前端手搓虚拟世界第五篇。

高瞻远瞩

前端佬们,上一篇我们基本实现了“一个无限空间”的canvas,好虽然是好,但这种方式,你的视野被锁死了。这就好比你在看地图时,却被禁用了“缩放”功能一样。

这完全丧失了那种高瞻远瞩的感觉。

image.png

本篇要给加这套逻辑,核心交互是:鼠标滚轮滚动时,以鼠标当前位置为中心,进行放大或缩小。

别小看这个“以鼠标为中心”,很多初学者写出来的缩放,一滚轮下去,画面直接飞到九霄云外去了(通常是缩放到 (0,0) 原点去了)。我们的目标,是丝般顺滑的专业级缩放。


战略思考

还是老规矩,怎么去实现这玩意呢??

应该有前端佬的第一想法,是canvas的缩放 scale 功能。

bingo!!优秀如你!

就是它。

image.png


战术规划

  • 第一步:数学原理修正。引入缩放(Scale)后,屏幕上的 100px 可能只代表世界里的 50px(放大 2 倍)。之前的公式要改。

  • 第二步:实现“定点缩放”

    • 原理:在缩放发生,记下鼠标指向的世界坐标 P1
    • 执行缩放(改变 zoom 值)。
    • 在缩放发生,计算鼠标现在指向的世界坐标 P2
    • 计算偏移量 delta = P2 - P1,并把它加回到视口的 offset 里。
    • 一句话解释:如果画面因为缩放跑偏了,我们就把它拽回来。
  • 第三步:渲染层应用translatescale 的顺序至关重要。


上代码

更改Viewport

src/view/viewport.js 动大手术。

注意: 这里我们要修正一下上一篇的 getMouse 逻辑。标准的图形学做法是:放大 = 视野变小 = 坐标除以缩放系数

JavaScript

// src/view/viewport.js
import Point2D from "../primitives/point2D.js";

export default class Viewport {
  constructor(canvas) {
    this.canvas = canvas;
    this.ctx = canvas.getContext("2d");

    this.zoom = 1; // 1 = 100%, 2 = 200%
    this.center = new Point2D(canvas.width / 2, canvas.height / 2);
    // offset 表示摄像机的平移位置
    this.offset = new Point2D(0, 0);

    this.drag = {
      start: new Point2D(0, 0),
      end: new Point2D(0, 0),
      offset: new Point2D(0, 0),
      active: false
    };

    this.#addEventListeners();
  }

  // 【核心数学升级】
  // 屏幕坐标 -> 世界坐标
  getMouse(evt, subtractDragOffset = false) {
    // 1. 减去屏幕中心(把原点挪到屏幕中间)
    // 2. 除以缩放(反向映射:屏幕像素 / 缩放 = 世界距离)
    // 3. 减去摄像机偏移
    const p = new Point2D(
      (evt.offsetX - this.center.x) / this.zoom - this.offset.x,
      (evt.offsetY - this.center.y) / this.zoom - this.offset.y
    );
    return p;
  }

  // 获取计算后的偏移量(给 Canvas 渲染用)
  getOffset() {
      return new Point2D(
          this.offset.x + this.center.x,
          this.offset.y + this.center.y
      );
  }

  #addEventListeners() {
    // ... (保留上一篇的空格键监听代码) ...
    // ... (保留上一篇的 mousedown 监听代码) ...
    // ... (保留上一篇的 mouseup 监听代码) ...
    
    // 【新增】鼠标滚轮监听
    this.canvas.addEventListener("wheel", (evt) => this.#handleWheel(evt));
    
    // ... (保留 mousemove,但要注意里面 getMouse 的调用) ...
    // 稍微修正一下 mousemove 里的逻辑,确保它是基于最新的 zoom 计算的
     this.canvas.addEventListener("mousemove", (evt) => {
      if (this.drag.active) {
        this.drag.end = this.getMouse(evt);
        this.drag.offset = new Point2D(
            this.drag.end.x - this.drag.start.x,
            this.drag.end.y - this.drag.start.y
        );
        // 累加
        this.offset.x += this.drag.offset.x;
        this.offset.y += this.drag.offset.y;
        
        // 重置 start
        this.drag.start = this.getMouse(evt); 
      }
    });
  }

  // 【新增】处理滚轮缩放
  #handleWheel(evt) {
    // 1. 判断滚轮方向 (向上滚是负数-放大,向下滚是正数-缩小)
    const dir = Math.sign(evt.deltaY); 
    // 2. 定义缩放步长 (每次滚大概变 10%)
    const step = 0.1;
    
    // 此处应用“定点缩放”策略:
    // 第一步:缩放前,鼠标在世界里的哪里?
    // (注意:这里直接透传 evt 进去算,不需要 subtractDragOffset)
    const mouseBefore = this.getMouse(evt);

    // 第二步:执行缩放
    this.zoom += dir * step;
    // 限制一下缩放范围,别缩没了或者放太大
    this.zoom = Math.max(1, Math.min(5, this.zoom));

    // 第三步:缩放后,鼠标在世界里的哪里?
    const mouseAfter = this.getMouse(evt);

    // 第四步:计算偏差,修正 offset
    // 既然鼠标不应该动,那如果 mouseAfter 变了,说明世界“滑”走了
    // 我们要把世界拽回来
    this.offset.x += (mouseAfter.x - mouseBefore.x);
    this.offset.y += (mouseAfter.y - mouseBefore.y);
    
    // 阻止浏览器默认的滚动页面行为
    evt.preventDefault();
  }
}

补充说明:你可能注意到 mousemove 里我没怎么改。因为 getMouse 内部已经应用了 this.zoom。只要 getMouse 公式是对的,拖拽平移逻辑就能自动适配缩放(比如放大后,拖拽会变慢,这是符合物理直觉的,因为你视野变小了)。


Again And Again

现在数据层对了,但现在运行代码,会发现画面没有任何变化。

憋急,因为 Canvas 还不知道要缩放。

src/index.js,需要调整 ctx 的变换顺序。

脑子里记着:先平移到中心,再缩放,再平移回视口偏移。

JavaScript

// src/index.js
// ... imports

export default class World {
  // ... constructor 不变

  animate() {
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);

    this.ctx.save();
    
    // 1. 把坐标原点移到屏幕中心
    // 为什么要这么做?因为 scale 默认是基于 (0,0) 左上角缩放的。
    // 我们希望基于屏幕中心缩放,这样更符合直觉。
    this.ctx.translate(this.canvas.width / 2, this.canvas.height / 2);
    
    // 2. 应用缩放
    this.ctx.scale(this.viewport.zoom, this.viewport.zoom);
    
    // 3. 应用视口偏移 (Viewport Offset)
    // 这里的 offset 已经在 Viewport 类里处理得很好了
    const offset = this.viewport.getOffset();
    // 这里的 translate 是为了让 (0,0) 回到它该去的地方
    // 注意:因为上面已经把原点移到了中心,这里要减去中心吗?
    // 不,因为 Viewport.getOffset 已经包含了 center 的逻辑?
    // 让我们看一眼 Viewport.js 的 getOffset: return this.offset + this.center;
    
    // 等等,这里的数学有点绕。让我们用最简单的推导:
    // 我们希望 point.x 最终渲染在: (point.x + offset.x) * zoom + center.x
    // Canvas 的变换栈是反向生效的(或者说矩阵乘法):
    
    // 修正后的渲染逻辑:
    // 我们在 Viewport.js 的 getMouse 里用的公式是: (Screen - Center) / Zoom - Offset
    // 逆推回 Screen: Screen = (World + Offset) * Zoom + Center
    
    // 所以 Canvas 代码应该是:
    // Translate(Center) -> Scale(Zoom) -> Translate(Offset)
    
    // 这里的 offset 只需要 viewport.offset,不需要加 center
    // 让我们回去微调一下 Viewport.js 的 getOffset 方法,让它只返回纯粹的 offset
    
    const viewportOffset = this.viewport.getOffset(); 
    this.ctx.translate(this.viewport.offset.x, this.viewport.offset.y);

    // 画它!
    this.editor.display();
    
    this.ctx.restore();

    requestAnimationFrame(() => this.animate());
  }
}

为了配合上面 World 的清晰逻辑,我把 src/view/viewport.js 里的 getOffset 删掉,或者直接访问 this.viewport.offset

为了代码整洁,在 Viewport 里加个 getter,只返回 offset 属性即可,不需要加 center

JavaScript

// 修改 src/view/viewport.js
  getOffset() {
     return this.offset;
  }

完美

现在运行代码,你会发现:

  • 对着某个点放大,那个点会稳稳地停在鼠标指尖下,周围的世界向外扩散。
  • 线条会随着放大变粗(因为 ctx.lineWidth 也被放大了)。这其实挺好的,细节看得更清楚。如果你不希望线变粗,那就是另外一个进阶话题(逆缩放线宽)。

现在虚拟世界已经初具规模,已经实现类的如下:

  • 原子层:点、线(Point2D/Segment)。
  • 数据层:图结构(Graph)。
  • 交互层:编辑器(Editor)。
  • 视觉层:无限画布 + 自由缩放(Viewport)。

完全可以毫不吹水的说,我们已经构建了一个类似于 AutoCAD 或 Google Maps 的基础引擎。

嘎嘎~

另,这个专栏打算放一放了,好像没啥人关注,实在没啥动力了~

Vite 8 Beta:由 Rolldown 驱动的 Vite

2025年12月10日 10:53

原文:Vite 8 Beta: The Rolldown-powered Vite

作者:Vite Team

翻译:TUARAN

欢迎关注 前端周刊,每周更新国外论坛的前端热门文章,紧跟时事,掌握前端技术动态。

Vite 8 Beta Announcement Cover Image

简述:由 Rolldown 驱动的 Vite 8 首个 Beta 版本现已发布。Vite 8 带来了显著更快的生产构建速度,并开启了未来改进的可能性。你可以通过将 vite 升级到 8.0.0-beta.0 版本并阅读迁移指南来尝试新版本。

我们很高兴发布 Vite 8 的首个 Beta 版本。此版本统一了底层工具链,带来了更好的一致性行为,以及显著的构建性能提升。Vite 现在使用 Rolldown 作为其打包器,取代了之前 esbuild 和 Rollup 的组合。

面向 Web 的新打包器

Vite 之前依赖两个打包器来满足开发和生产构建的不同需求:

  • esbuild 用于开发期间的快速编译
  • Rollup 用于生产构建的打包、分块和优化

这种方法让 Vite 专注于开发者体验和编排,而不是重新发明解析和打包。然而,维护两个独立的打包管道引入了不一致性:独立的转换管道、不同的插件系统,以及为了保持开发和生产之间打包行为一致而不断增加的胶水代码。

为了解决这个问题,VoidZero 团队构建了 Rolldown,这是下一代打包器,目标是在 Vite 中使用。它的设计宗旨是:

  • 性能:Rolldown 用 Rust 编写,以原生速度运行。它达到了 esbuild 的性能水平,比 Rollup 快 10–30 倍。
  • 兼容性:Rolldown 支持与 Rollup 和 Vite 相同的插件 API。大多数 Vite 插件在 Vite 8 中开箱即用。
  • 更多功能:Rolldown 为 Vite 解锁了更多高级功能,包括全打包模式(full bundle mode)、更灵活的分块控制、模块级持久缓存、模块联邦(Module Federation)等。

统一工具链

Vite 更换打包器的影响不仅仅在于性能。打包器利用了解析器、解析器、转换器和压缩器。Rolldown 使用 Oxc(另一个由 VoidZero 领导的项目)来实现这些目的。

这使得 Vite 成为由同一团队维护的端到端工具链的入口点:构建工具 (Vite)、打包器 (Rolldown) 和编译器 (Oxc)。

这种对齐确保了整个技术栈的行为一致性,并允许我们在 JavaScript 继续演进时快速采用并与新语言规范保持一致。它还解锁了以前仅靠 Vite 无法实现的广泛改进。例如,我们可以利用 Oxc 的语义分析在 Rolldown 中执行更好的 Tree-shaking。

Vite 如何迁移到 Rolldown

迁移到由 Rolldown 驱动的 Vite 是一个根本性的变化。因此,我们的团队采取了审慎的步骤来实施它,而不牺牲稳定性或生态系统兼容性。

首先,发布了一个独立的 rolldown-vite 包作为技术预览。这使我们能够在不影响 Vite 稳定版本的情况下与早期采用者合作。早期采用者受益于 Rolldown 的性能提升,同时提供了宝贵的反馈。亮点包括:

  • Linear 的生产构建时间从 46 秒减少到 6 秒
  • Ramp 的构建时间减少了 57%
  • Mercedes-Benz.io 的构建时间减少了高达 38%
  • Beehiiv 的构建时间减少了 64%

接下来,我们建立了一个测试套件,用于针对 rolldown-vite 验证关键的 Vite 插件。这个 CI 任务帮助我们尽早发现回归和兼容性问题,特别是对于 SvelteKit、react-router 和 Storybook 等框架和元框架。

最后,我们构建了一个兼容层,以帮助开发者从 Rollup 和 esbuild 选项迁移到相应的 Rolldown 选项。

结果是,每个人都有了一条通往 Vite 8 的平滑迁移路径。

迁移到 Vite 8 Beta

由于 Vite 8 触及核心构建行为,我们专注于保持配置 API 和插件钩子不变。我们创建了一个迁移指南来帮助你升级。

有两种可用的升级路径:

  1. 直接升级:在 package.json 中更新 vite 并运行通常的开发和构建命令。
  2. 逐步迁移:从 Vite 7 迁移到 rolldown-vite 包,然后再迁移到 Vite 8。这允许你在不更改 Vite 其他部分的情况下,识别仅与 Rolldown 相关的兼容性问题或故障。(推荐用于大型或复杂项目)

重要提示

如果你依赖特定的 Rollup 或 esbuild 选项,你可能需要对 Vite 配置进行一些调整。请参阅迁移指南以获取详细说明和示例。与所有非稳定主要版本一样,建议在升级后进行彻底测试,以确保一切按预期工作。请务必报告任何问题。

如果你使用的框架或工具将 Vite 作为依赖项(例如 Astro、Nuxt 或 Vitest),你必须在 package.json 中覆盖 vite 依赖项,根据你的包管理器,操作略有不同:

npm / Yarn / pnpm / Bun

{
  "overrides": {
    "vite": "8.0.0-beta.0"
  }
}

添加这些覆盖后,重新安装依赖项并像往常一样启动开发服务器或构建项目。

Vite 8 的其他功能

除了搭载 Rolldown 之外,Vite 8 还带来了:

  • 内置 tsconfig paths 支持:开发者可以通过将 resolve.tsconfigPaths 设置为 true 来启用它。此功能有较小的性能开销,默认不启用。
  • emitDecoratorMetadata 支持:Vite 8 现在内置了对 TypeScript emitDecoratorMetadata 选项的自动支持。有关更多详细信息,请参阅功能页面。

展望未来

速度一直是 Vite 的标志性特征。与 Rolldown 以及 Oxc 的集成意味着 JavaScript 开发者将受益于 Rust 的速度。升级到 Vite 8 应该仅仅因为使用 Rust 就能带来性能提升。

我们也为即将发布 Vite 的**全打包模式(Full Bundle Mode)**感到兴奋,这将极大地提高大型项目的 Vite 开发服务器速度。初步结果显示,开发服务器启动速度提高了 3 倍,完整重新加载速度提高了 40%,网络请求减少了 10 倍。

Vite 的另一个标志性特征是插件生态系统。我们希望 JavaScript 开发者继续使用他们熟悉的 JavaScript 语言扩展和定制 Vite,同时受益于 Rust 的性能提升。我们的团队正在与 VoidZero 团队合作,加速这些基于 Rust 的系统中的 JavaScript 插件使用。

目前处于实验阶段的即将到来的优化:

  • 原始 AST 传输:允许 JavaScript 插件以最小的开销访问 Rust 生成的 AST。
  • 原生 MagicString 转换:简单的自定义转换,逻辑在 JavaScript 中,但计算在 Rust 中。

联系我们

如果你尝试了 Vite 8 beta,我们很乐意听到你的反馈!请报告任何问题或分享你的经验:

  • Discord:加入我们的社区服务器进行实时讨论
  • GitHub:在 GitHub discussions 上分享反馈
  • Issues:在 rolldown-vite 仓库报告错误和回归问题
  • Wins:在 rolldown-vite-perf-wins 仓库分享你改进的构建时间

我们感谢所有的报告和复现案例。它们有助于指导我们发布稳定的 8.0.0 版本。

深入 JavaScript 内存机制:从调用栈到闭包,一文讲透!

2025年12月10日 10:52

在日常开发中,我们写 JavaScript 代码时几乎不需要关心内存分配、回收这些底层细节。但你是否曾好奇过:

  • 为什么 let a = 1; let b = a; a = 2 后,b 还是 1?
  • 为什么对象赋值后修改一个会影响另一个?
  • 闭包到底是怎么“记住”外部变量的?
  • JS 是如何做到“不用手动释放内存”的?

今天,我们就结合 V8 引擎的实现原理,深入浅出地聊聊 JavaScript 的内存机制与执行模型,揭开这些常见现象背后的真相。


一、JS 是什么语言?动态弱类型 ≠ 不严谨

先来看一段代码:

var bar;
console.log(typeof bar); // "undefined"
bar = 12;
console.log(typeof bar); // "number"
bar = "极客时间";
console.log(typeof bar); // "string"
bar = true;
console.log(typeof bar); // "boolean"
bar = null;
console.log(typeof bar); // "object" ← JS 的历史性 bug!
bar = {name: "极客时间"};
console.log(typeof bar); // "object"

这段代码展示了 JavaScript 的两个关键特性:

  • 动态类型:变量类型在运行时确定,无需提前声明。
  • 弱类型:不同类型之间可以隐式转换(如 true == 1)。

但这并不意味着 JS “不严谨”。恰恰相反,它的设计让开发者更聚焦于逻辑而非类型系统。而这一切的背后,离不开一套精密的 内存管理机制


二、内存分两块:栈 vs 堆

JavaScript 引擎(如 V8)将内存分为两类:

内存区域 存储内容 特点
栈内存(Stack) 简单数据类型(Number, String, Boolean, undefined, Symbol, BigInt) 快速分配/释放,连续空间,大小固定
堆内存(Heap) 复杂数据类型(Object, Array, Function 等) 空间大但分配/回收慢,不连续

✅ 示例 1:基本类型 —— 栈内存中的独立拷贝

function foo() {
  var a = 1;
  var b = a; // 拷贝值
  a = 2;
  console.log(a); // 2
  console.log(b); // 1 ← 不受影响
}

因为 ab 都是基本类型,它们各自在栈中拥有独立的存储空间。赋值是值拷贝,互不影响。

✅ 示例 2:引用类型 —— 堆内存中的共享指针

function foo() {
  var a = { name: "极客时间" };
  var b = a; // 拷贝的是“引用”(指针)
  a.name = '极客帮';
  console.log(a); // { name: "极客帮" }
  console.log(b); // { name: "极客帮" } ← 被同步修改!
}

这里 ab 在栈中只保存了指向堆中同一个对象的引用地址。修改对象内容,所有引用都会看到变化。

💡 关键理解:JS 中没有“对象变量”,只有“对象引用”。


三、执行上下文与调用栈:程序运行的舞台

每当 JS 执行一段代码,引擎会创建一个 执行上下文(Execution Context) ,包含:

  • 变量环境(Variable Environment) :存放 var、函数声明等
  • 词法环境(Lexical Environment) :存放 letconst、块级作用域等
  • this 绑定
  • outer 引用:指向外层作用域(用于构建作用域链)

这些上下文被压入 调用栈(Call Stack) —— 一个 LIFO(后进先出)的结构。

📌 为什么用栈?
因为函数调用频繁,栈的“压入/弹出”操作极快,且内存连续,适合快速切换上下文。

一旦函数执行完毕,其上下文从栈顶弹出,栈内变量(基本类型)瞬间释放;而堆中的对象,只有当没有任何引用指向它时,才会被垃圾回收器(GC)慢慢清理。


四、闭包:自由变量的“时光胶囊”

闭包是 JS 最强大也最易误解的特性之一。看下面这个经典例子:

function foo() {
    var myName = "极客时间"
    let test1 = 1
    const test2 = 2
    var innerBar = { 
        setName:function(newName){
            myName = newName
        },
        getName:function(){
            console.log(test1)
            return myName
        }
    }
    return innerBar
}
var bar = foo()
bar.setName("极客邦")
bar.getName()
console.log(bar.getName())

闭包是如何工作的?

  1. 编译阶段:V8 扫描 foo 内部函数,发现 getName/setName 引用了 myName
  2. 判断闭包:只要有内部函数引用了外部变量,就判定存在闭包。
  3. 创建 closure 对象:在堆内存中创建一个 closure(foo) 对象,专门保存被引用的自由变量(如 myName)。
  4. 内部函数绑定getNamesetName[[Scope]] 指向这个 closure(foo)

d988c42838314c078c1a17b622dade56.png

🔥 核心突破
闭包的本质,是把本该随栈销毁的变量,提升到堆中长期保存,并通过作用域链维持访问能力。

这解释了为什么即使 foo() 执行完毕,返回的函数仍能读写 myName —— 它们共享同一个堆中的闭包环境。


五、对比 C 语言:JS 为何不用手动管理内存?

看看 C 代码:

int main() {
  int a = 1;
  char* b = "极客时间";
  bool c = true;
  c = a; // 隐式转换(危险!)
  return 0;
}

C/C++ 需要手动 malloc / free,稍有不慎就会内存泄漏或野指针。而 JS:

  • 自动分配:声明变量时自动决定放栈 or 堆
  • 自动回收:通过引用计数 + 标记清除算法回收无用对象
  • 开发者无感:你只需关注逻辑,引擎处理一切

当然,这也带来性能权衡:堆操作比栈慢,GC 可能造成卡顿。但对大多数 Web 应用来说,这是值得的抽象。


六、总结:一张图看懂 JS 内存模型

f1515078f7c243d6847e9eb9c5bef712.png

  • :轻量、快速、自动释放
  • :重量、灵活、GC 回收
  • 闭包:通过堆保存自由变量,打破栈的生命周期限制

七、结语

理解 JavaScript 的内存机制,不仅能写出更健壮的代码,还能在面试中脱颖而出。真正的高手,既会写业务,也懂底层原理

微前端:从“大前端”到“积木式开发”的架构演进

作者 扑棱蛾子
2025年12月10日 10:52

记得那些年我们维护的“巨石应用”吗?一个package.json里塞满了几百个依赖,每次npm install都像是一场赌博;团队协作时,git merge冲突解决到怀疑人生;技术栈升级?那意味着“全盘推翻重来”……

随着前端复杂度的爆炸式增长,传统单体架构已不堪重负。而微前端,正是为了解决这些痛点而生的一种架构范式。本文将以qiankun为切入点,学习一下微前端的模式。

基础概念

微前端是什么?

微前端不是框架,而是一种架构理念 ——将大型前端应用拆分为多个独立开发、独立部署、技术栈无关的小型应用,再将其组合为一个完整的应用。

一句话,它让前端开发从“造大楼”变成了 “搭乐高”

为什么需要微前端?

痛点真实存在:

  • 🐌 开发效率低下:几百人维护一个仓库,每次上线都需全量回归
  • 🔒 技术栈锁定:三年前选的框架,现在想升级?代价巨大
  • 👥 团队协作困难:功能边界模糊,代码相互渗透
  • 🚢 部署风险高:一个小改动,可能导致整个系统崩溃

微前端带来的改变:

  • ✅ 独立自治:每个团队负责自己的“微应用”,从开发到部署全流程自主
  • ✅ 技术栈自由:React、Vue、Angular、甚至jQuery,和平共处
  • ✅ 增量升级:老系统可以一点点替换,而不是“一夜重构”
  • ✅ 容错隔离:一个子应用崩溃,不影响其他功能

微前端的核心思想:

  • 拆分:将大型前端应用拆分为多个独立的小型应用。
  • 集成:通过某种方式将这些小型应用集成在一起,形成一个整体。
  • 自治:每个小型应用都可以独立开发、测试、部署。
// 微前端架构
├── container/      // 主应用(基座)
├── app-react/      // React子应用(团队A)
├── app-vue/        // Vue子应用(团队B)
├── app-angular/    // Angular子应用(团队C)
└── app-legacy/     // 老系统(jQuery)

// 优势:
// 1. ✅ 技术栈无关
// 2. ✅ 独立开发、独立部署
// 3. ✅ 增量更新
// 4. ✅ 容错性高(一个子应用挂了不影响其他)

应用场景

渐进式重构:对于一个老项目一点点进行架构的升级

老系统(jQuery + PHP) → 逐步替换为现代框架
   ↓
保留核心业务模块 + 逐步添加React/Vue新模块

多团队协作:不同部门人员之间技术栈存在差异,需要单独开发

团队A(React专家) → 负责电商商品模块
团队B(Vue专家)   → 负责购物车模块
团队C(Angular专家)→ 负责用户中心
主应用协调所有模块

中后台系统:复杂系统的功能拆分

一个后台管理系统包含:
- 权限管理(React)
- 数据报表(Vue + ECharts)
- 工作流(Angular)
- 监控面板(React + Three.js)

四种架构模式

基座模式(也称为中心化路由模式)

  • 基座模式是最常见的微前端架构。它有一个主应用(通常称为基座或容器),负责整个应用的布局、路由和公共逻辑。子应用根据路由被动态加载和卸载。
  ┌─────────────────────────────────────────┐
  │            主应用(Container)           │
  │ 负责:路由、鉴权、布局、共享状态、公共依赖   │
  ├─────────────────────────────────────────┤
  │  ┌──────────┐  ┌──────────┐  ┌──────────┐ 
  │  │ 子应用A  │  │ 子应用B  │  │ 子应用C  │ │
  │  │ (React)  │  │  (Vue)   │  │(Angular) │ 
  │  └──────────┘  └──────────┘  └──────────┘ 
  └─────────────────────────────────────────┘

工作流程

graph TD
用户访问主应用-->主应用根据当前URL匹配子应用--> A["加载对应子应用的资源(JS、CSS)"]-->将子应用渲染到指定容器中-->子应用运行并处理自己的内部路由和逻辑

优点

  • 集中控制,易于管理
  • 路由逻辑清晰
  • 公共依赖容易处理(基座可提供共享库)
  • 子应用间隔离性好

缺点

  • 主应用成为单点故障
  • 基座和子应用耦合(通过协议通信)
  • 基座需要知道所有子应用的信息

适用场景

  • 企业级中后台系统
  • 需要统一导航和布局的应用
  • 子应用技术栈差异大

自组织模式(也称为去中心化模式)

  • 在自组织模式中,没有中心化的基座。每个微前端应用都是独立的,它们通过某种通信机制(如自定义事件、消息总线)来协调。通常,每个应用都可以动态发现和加载其他应用。
┌──────────┐    ┌──────────┐    ┌──────────┐
│  应用A   │     │  应用B   │    │  应用C    │
│ (React)  │    │  (Vue)   │    │(Angular) │
└────┬─────┘    └────┬─────┘    └────┬─────┘
     │               │               │
     └───────────────┼───────────────┘
                     │
            ┌────────┴─────────┐
            │  运行时协调器     │
            │  (Runtime Bus)   │
            └──────────────────┘
graph TD
1["应用A启动,并注册到消息总线"]
-->2["应用B启动,并注册到消息总线"]
-->用户操作触发应用A需要应用B的某个功能
-->应用A通过消息总线请求应用B的资源
-->3["应用B响应请求,提供资源(或直接渲染)"]

优点

  • 去中心化,避免单点故障
  • 应用之间完全解耦
  • 更灵活的通信方式

缺点

  • 通信复杂,容易混乱
  • 难以统一管理(如路由、权限)
  • 依赖公共协议,版本更新可能破坏通信

适用场景

  • 高度自治的团队
  • 应用间功能相对独立
  • 需要动态组合的页面

微件模式(也称为组合式模式)

  • 微件模式类似于传统门户网站,页面由多个独立的微件(Widget)组成。每个微件都是一个独立的微前端应用,可以独立开发、部署,然后动态组合到页面中。
┌───────────────────────────────────┐
│          Dashboard页面            │
│  ┌────────┬────────┬─────────┐    │
│  │ 天气    │ 新闻   │ 股票    │     │
│  │ Widget │ Widget │ Widget  │    │
│  ├────────┼────────┼─────────┤    │
│  │ 待办    │ 日历   │ 邮件    │     │
│  │ Widget │ Widget │ Widget  │    │
│  └────────┴────────┴─────────┘    │
└───────────────────────────────────┘
graph TD
用户访问页面
    -->
页面布局引擎根据配置加载微件
    -->
每个微件独立加载资源并渲染
    -->
微件之间通过预定义的接口通信

优点

  • 组件可以复用
  • 用户可以自定义布局
  • 所有widget在同一个页面
  • 可以按需加载widget

缺点

  • 样式管理复杂,需要处理widget间样式冲突
  • 通信限制,widget间通信需要经过主应用
  • 版本管理,大量widget的版本管理困难
  • 性能问题,太多widget可能影响性能

适用场景

  1. 数据可视化大屏
  2. 门户网站首页
  3. 个人工作台
  4. 可配置的管理后台

混合模式(实战中最常见)

  • 在实际项目中,我们常常根据需求混合使用以上模式。例如,在基座模式中,某个子应用内部使用微件模式来组合多个微前端模块。
  • 比如一个电商系统的架构
主应用(基座模式)
    ├── 商品管理(React子应用)
    ├── 订单管理(Vue子应用)
    └── 用户管理(Angular子应用)
        在用户管理内部,使用微件模式:
            ├── 用户统计(微件A)
            ├── 用户列表(微件B)
            └── 用户权限(微件C)
┌─────────────────────────────────────────────────┐
│                主应用(基座模式)                 │
│   统一路由、权限、用户中心、消息中心、全局状态       │
└─────────────────┬───────────────────────────────┘
                  │
    ┌─────────────┼─────────────┐
    │             │             │
┌───▼───┐   ┌────▼────┐   ┌────▼────┐
│订单中心│   │商品管理 │   │用户管理   │
│(React)│   │ (Vue)   │   │(Angular)│
└───┬───┘   └────┬────┘   └────┬────┘
    │            │             │
    └────────────┼─────────────┘
                 │
          ┌──────▼──────┐
          │ 数据分析模块 │
          │ (微件模式)   │
          │┌───┬───┬───┐│
          ││图表│地图│报表│
          │└───┴───┴───┘│

快速上手

  • 新建三个项目,分别为main-app,sub-app1,sub-app2,项目结构一目了然:
   ├── main-app/      // 主应用(基座)
   ├── sub-app1/      // vue3子应用(团队A)
   ├── app-vue/        // vue3子应用(团队B)

安装qiankun

yarn add qiankun # 或者 npm i qiankun -S

主项目中注册微应用

// 主应用main-app/main.js
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { registerMicroApps, start } from 'qiankun'

createApp(App).mount('#app1')

registerMicroApps(
  [
    {
      name: 'sub-app1', // app name registered
      entry: 'http://localhost:5175',
      container: '#micro-app-container',
      activeRule: (location) => location.hash.startsWith('#/app-a'),
      props: {
        name: 'kuitos'
      }
    }
  ],
  {
    beforeLoad: (app) => console.log('before load', app.name),
    beforeMount: [(app) => console.log('before mount', app.name)]
  }
)
// start()

// 启动 qiankun,配置沙箱模式
start({
  sandbox: {
    strictStyleIsolation: true,
  },
})

微应用导出钩子

  • 由于qiankun不支持module,所以对于vue3项目,需要使用vite-plugin-qiankun来集成
  • renderWithQiankun用来对外暴露钩子
  • qiankunWindow替代window变量
// 子应用 sub-app1/mian.js
import { createApp } from 'vue'
import {
  renderWithQiankun,
  qiankunWindow
} from 'vite-plugin-qiankun/dist/helper'

import './style.css'
import App from './App.vue'
let instance = null

function render(props = {}) {
  const container = props.container || '#app'
  console.log('子应用挂载容器:', container)

  instance = createApp(App)
  instance.mount(container)
}
console.log('qiankunWindow',qiankunWindow);
console.log('window.__POWERED_BY_QIANKUN__',window.__POWERED_BY_QIANKUN__);

// 独立运行时,直接渲染
if (!qiankunWindow.__POWERED_BY_QIANKUN__) {
  console.log('独立运行时,直接渲染')
  render()
}

renderWithQiankun({
  mount(props) {
    console.log(props)
    render(props)
  },
  bootstrap() {
    console.log('bootstrap')
  },
  unmount(props) {
    console.log('unmount', props)
  },
  update(props) {
    console.log('update', props)
  }
})

在子应用的vite.config.js中注册插件

// 子应用 sub-app1/vite.config.js
plugins: [
    vue(),
    qiankun('sub-app1', {
      useDevMode: true,
    })
],

进阶场景

应用通信

应用拆分后,不可避免的会涉及到通信问题,那么如何让它们“愉快地对话”?

props

  • 最简单的方式,正如目前的主流框架,qiankun也提供了一个props属性,可以实现父->子之间的数据通信,当主应用注册registerMicroApps子应用的时候,利用props传递
// 主应用 main-app/main.js
registerMicroApps(
  [
    {
      name: 'sub-app1', // app name registered
      entry: 'http://localhost:5175',
      container: '#micro-app-container',
      activeRule: (location) => location.hash.startsWith('#/app-a'),
      props: {
        // name: 'kuitos' //该属性会被覆盖?
        count: 100,
        time: new Date().getTime()
      }
    }
  ],
  {
    beforeLoad: (app) => console.log('before load', app.name),
    beforeMount: [(app) => console.log('before mount', app.name)]
  }
)

// 子应用 sub-app1/main.js
renderWithQiankun({
  mount(props) {
    render(props)
  },
})
// 子应用 sub-app1/main.js
function render(props = {}) {
  const container = props.container || '#app'
  console.log('子应用挂载容器:', container)

  instance = createApp(App)
  instance.config.globalProperties.__SUBAPP__ = props //vue3的globalProperties全局挂载
  window.__SUBAPP__ = props //挂载到子应用的window对象

  instance.mount(container)
}

子应用的其他组件使用时

//子应用 sub-app1/src/components/HelloWord.vue
<script setup>
    import { ref,getCurrentInstance } from 'vue'

    defineProps({
      msg: String,
    })

    const count = ref(0)
    console.log('window方式获取数据',window.__SUBAPP__)
    console.log('getCurrentInstance方式获取数据', getCurrentInstance().proxy.__SUBAPP__)
</script>

image-20251208221830086

initGlobalState

父->子传递

首先在父应用中创建一个globalState.js初始化一下state

//main-app/globalState.js
import { initGlobalState } from 'qiankun';

// 定义初始状态
export const initialState = {
  user: { id: null, name: '', token: '' },
  globalConfig: { theme: 'light', language: 'zh-CN' },
  sharedData: {},
  currentRoute: {}
};

// 当前全局状态
export let currentGlobalState = { ...initialState };

// 全局状态管理器实例
export let globalActions = null;

// 初始化全局状态管理
export const initGlobalStateManager = () => {
  // 初始化 state
  const actions = initGlobalState(initialState);
  
  // 监听状态变更
  actions.onGlobalStateChange((state, prev) => {
    currentGlobalState = { ...state };
    console.log('主应用:全局状态变更', { newState: state, prevState: prev });
  });
  
  // 设置初始状态
  actions.setGlobalState(initialState);
  
  globalActions = actions;
  return actions;
};

// 更新全局状态
export const updateGlobalState = (newState) => {
  if (!globalActions) {
    globalActions = initGlobalStateManager();
  }
  globalActions.setGlobalState(newState);
};


其中关键方法:

// 定义初始状态
export const initialState = {
  user: { id: null, name: '', token: '' },
  globalConfig: { theme: 'light', language: 'zh-CN' },
  sharedData: {},
  currentRoute: {}
};
//初始化 state
const actions = initGlobalState(initialState);
// 监听状态变更
actions.onGlobalStateChange((state, prev) => {
    currentGlobalState = { ...state };
    console.log('主应用:全局状态变更', { newState: state, prevState: prev });
});
// 更新全局状态
actions.setGlobalState(newState);
// 取消监听
actions.offGlobalStateChange();

// main-app/login.vue
import { updateGlobalState } from './globalState'
const handleLogin =()=>{
  // 。。。主应用的业务逻辑
  // 更新state  
  updateGlobalState({
      isLoggedIn: true,
    });
}

在子应用中监听

// sub-app1/main.js
function render(props = {}) {
  const container = props.container || '#app'

  instance = createApp(App)
  
  // 监听全局状态变化
  //props 里面有setGlobalState和onGlobalStateChange 方法,可用于监听和修改状态 
  if (props.onGlobalStateChange) {
    props.onGlobalStateChange((state, prev) => {
      console.log('子变更后的状态', state, '子变更前的状态', prev);
    });
  }
  // 挂载一下props,以便于在其他组件中使用setGlobalState和onGlobalStateChange
  // 挂载的方式有很多, pinia等,总之其他地方能获取到props对象就行  
  window.__SUBAPP__ = props
  pinia = createPinia()
  instance.use(pinia)
  instance.mount(container)
}
image-20251209221654305
子->父传递

在子应用创建的时候,已经将props保存了window.__SUBAPP__ = props,在子应用的任何组件中都可以使用

所以只需要在某个组件中调用setGlobalState方法就可

// sub-app1/HelloWord.vue
// 获取全局状态管理方法
const { setGlobalState } = window.__SUBAPP__ || {}
if (setGlobalState) {
// 更新全局状态
    setGlobalState({
      sharedData: {
        count: newValue
      }
    })
}

image-20251209222325435

微前端选型指南:何时用?用哪个?

适合场景 ✅

  • 大型企业级应用(100+页面)
  • 多团队协作开发(3+前端团队)
  • 老系统渐进式重构
  • 需要支持多技术栈
  • 独立部署需求强烈

不适合场景 ❌

  • 小型项目(页面<20)
  • 单人/小团队开发
  • 对性能要求极致(首屏加载时间<1s)
  • 无技术栈异构需求

结语

千万不要手里攥着锤子看啥都像钉子。 微前端不是银弹,而是一种架构选择。它用复杂度换来了灵活性、独立性和可维护性。就像乐高积木,单个模块简单,但组合起来却能构建出无限可能的世界。

后续有时间将继续深入学习一下微前端的生命周期、样式隔离、部署发布这几个部分。

最后,觉得有用的话三连一下~

canvas的研究

作者 逍遥江湖
2025年12月10日 10:45

一、Canvas 上下文获取与基础

1. 获取绘图上下文

javascript

const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');  // 获取2D上下文
// const ctx3d = canvas.getContext('webgl');  // 获取3D上下文(WebGL)

2. Canvas 元素属性

javascript

canvas.width = 800;      // 设置Canvas宽度(像素)
canvas.height = 600;     // 设置Canvas高度(像素)
const width = canvas.width;   // 读取宽度
const height = canvas.height; // 读取高度

🎨 二、基本图形绘制API

1. 矩形绘制

javascript

// 填充矩形
ctx.fillRect(x, y, width, height);

// 描边矩形
ctx.strokeRect(x, y, width, height);

// 清除矩形区域
ctx.clearRect(x, y, width, height);

2. 路径绘制

javascript

// 开始新路径
ctx.beginPath();

// 移动笔触
ctx.moveTo(x, y);

// 绘制直线
ctx.lineTo(x, y);

// 绘制圆弧
ctx.arc(x, y, radius, startAngle, endAngle, anticlockwise);
// anticlockwise: true逆时针,false顺时针

// 绘制圆弧(通过控制点)
ctx.arcTo(x1, y1, x2, y2, radius);

// 绘制二次贝塞尔曲线
ctx.quadraticCurveTo(cp1x, cp1y, x, y);

// 绘制三次贝塞尔曲线
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);

// 绘制矩形路径
ctx.rect(x, y, width, height);

// 闭合路径
ctx.closePath();

// 填充路径
ctx.fill();

// 描边路径
ctx.stroke();

// 判断点是否在路径内
const isInPath = ctx.isPointInPath(x, y);

3. 复杂路径

// 椭圆
ctx.ellipse(x, y, radiusX, radiusY, rotation, startAngle, endAngle, anticlockwise);

// 圆角矩形(需要自定义函数或较新浏览器)
ctx.roundRect(x, y, width, height, [radius]);

 三、样式与颜色API

1. 填充与描边样式

javascript

// 填充颜色
ctx.fillStyle = 'color';  // 颜色值、渐变或图案
ctx.fillStyle = '#FF0000';
ctx.fillStyle = 'rgba(255, 0, 0, 0.5)';
ctx.fillStyle = 'red';

// 描边颜色
ctx.strokeStyle = 'color';

// 透明度(全局)
ctx.globalAlpha = 0.5;

2. 渐变

javascript

// 线性渐变
const linearGrad = ctx.createLinearGradient(x1, y1, x2, y2);
linearGrad.addColorStop(0, 'red');
linearGrad.addColorStop(1, 'blue');
ctx.fillStyle = linearGrad;

// 径向渐变
const radialGrad = ctx.createRadialGradient(x1, y1, r1, x2, y2, r2);
radialGrad.addColorStop(0, 'white');
radialGrad.addColorStop(1, 'black');

3. 图案

javascript

const img = new Image();
img.src = 'pattern.png';
img.onload = function() {
    const pattern = ctx.createPattern(img, 'repeat');
    ctx.fillStyle = pattern;
};
// repeat模式: 'repeat', 'repeat-x', 'repeat-y', 'no-repeat'

4. 线条样式

javascript

ctx.lineWidth = 5;           // 线条宽度(像素)
ctx.lineCap = 'butt';        // 端点样式: 'butt', 'round', 'square'
ctx.lineJoin = 'miter';      // 交点样式: 'miter', 'round', 'bevel'
ctx.miterLimit = 10;         // 斜接限制
ctx.setLineDash([5, 15]);    // 设置虚线样式
ctx.getLineDash();           // 获取虚线样式
ctx.lineDashOffset = 5;      // 虚线偏移量

四、文本绘制API

1. 文本绘制

javascript

// 填充文本
ctx.fillText(text, x, y [, maxWidth]);

// 描边文本
ctx.strokeText(text, x, y [, maxWidth]);

// 测量文本
const metrics = ctx.measureText(text);
metrics.width;      // 文本宽度
metrics.actualBoundingBoxLeft;   // 左边界距离
metrics.actualBoundingBoxRight;  // 右边界距离

2. 文本样式

javascript

ctx.font = 'italic bold 20px Arial';  // 字体样式
ctx.textAlign = 'center';     // 对齐: 'left', 'center', 'right', 'start', 'end'
ctx.textBaseline = 'middle';  // 基线: 'top', 'hanging', 'middle', 'alphabetic', 'ideographic', 'bottom'
ctx.direction = 'ltr';        // 方向: 'ltr', 'rtl', 'inherit'

五、图像操作API

1. 绘制图像

javascript

const img = new Image();
img.src = 'image.jpg';

// 基本绘制
ctx.drawImage(img, dx, dy);

// 缩放绘制
ctx.drawImage(img, dx, dy, dWidth, dHeight);

// 切片绘制
ctx.drawImage(img, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);
// sx,sy: 源图像起始坐标
// sWidth,sHeight: 源图像裁切尺寸
// dx,dy: 目标位置
// dWidth,dHeight: 目标尺寸

2. 图像数据操作

javascript

// 获取图像数据
const imageData = ctx.getImageData(sx, sy, sw, sh);
// imageData.data: Uint8ClampedArray [R,G,B,A,R,G,B,A,...]

// 放置图像数据
ctx.putImageData(imageData, dx, dy);

// 创建空白图像数据
const newImageData = ctx.createImageData(width, height);
const newImageData2 = ctx.createImageData(imageData);  // 相同尺寸

六、变形与合成API

1. 坐标变换

javascript

// 平移
ctx.translate(x, y);

// 旋转(弧度)
ctx.rotate(angle);

// 缩放
ctx.scale(x, y);

// 变换矩阵
ctx.transform(a, b, c, d, e, f);
// a: 水平缩放  b: 水平倾斜
// c: 垂直倾斜  d: 垂直缩放
// e: 水平移动  f: 垂直移动

// 设置变换矩阵
ctx.setTransform(a, b, c, d, e, f);

// 重置变换
ctx.resetTransform();

2. 状态保存与恢复

javascript

// 保存当前状态(样式、变形等)
ctx.save();

// 恢复上次保存的状态
ctx.restore();

3. 合成与裁剪

javascript

// 全局合成操作
ctx.globalCompositeOperation = 'source-over';
// 常用值: 'source-over', 'source-in', 'source-out', 
// 'destination-over', 'destination-in', 'destination-out',
// 'lighter', 'copy', 'xor', 'multiply', 'screen', 'overlay',
// 'darken', 'lighten', 'color-dodge', 'color-burn', 
// 'hard-light', 'soft-light', 'difference', 'exclusion',
// 'hue', 'saturation', 'color', 'luminosity'

// 创建裁剪区域
ctx.clip();  // 基于当前路径创建裁剪区域

// 判断点是否在裁剪区域内
const isInClip = ctx.isPointInStroke(x, y);

从原理到实现:基于 Y.js 和 Tiptap 的实时在线协同编辑器全解析

2025年12月10日 10:35

引言

在现代办公和学习场景中,多人实时协同编辑变得越来越重要。想象一下,团队成员可以同时编辑同一份文档,每个人的光标和输入都实时可见,就像坐在同一个会议室里一样。这种功能在 Google Docs、Notion 等应用中已经变得司空见惯。今天,我将带你深入剖析如何基于 Y.js、WebRTC 和 Tiptap 构建一个完整的实时协同编辑器。

技术架构概览

我们的协同编辑系统主要由三部分组成:

  1. 前端编辑器 (TiptapEditor.vue) - 基于 Vue 3 和 Tiptap 的富文本编辑器
  2. 协同框架 (Y.js) - 负责文档状态同步和冲突解决
  3. 信令服务器 (signaling-server.js) - WebRTC 连接的中介服务
用户A浏览器 ↔ WebRTC ↔ 用户B浏览器
      ↑                       ↑
     Y.js ←→ 协同状态 ←→ Y.js
      ↓                       ↓
   Tiptap编辑器           Tiptap编辑器

核心原理深度解析

1. Y.js 的 CRDT 算法

Y.js 之所以能实现无冲突的实时协同,是因为它采用了 CRDT(Conflict-Free Replicated Data Types,无冲突复制数据类型) 算法。

传统方案的问题:

  • 如果两个用户同时编辑同一位置,传统方案需要通过锁机制或最后写入者胜出的策略
  • 这些方案要么影响用户体验,要么可能导致数据丢失

CRDT 的解决方案:

  • 每个操作都有唯一的标识符(时间戳 + 客户端ID)
  • 操作是 可交换、可结合、幂等
  • 无论操作以什么顺序到达,最终状态都是一致的
// 示例:Y.js 如何解决冲突
用户A: 在位置2插入"X" → 操作ID: [时间A, 客户端A]
用户B: 在位置2插入"Y" → 操作ID: [时间B, 客户端B]

// 即使两个操作同时发生,最终文档会变成"YX"或"XY"
// 具体顺序由操作ID决定,但所有客户端都会得到相同的结果

2. WebRTC 的 P2P 通信

WebRTC(Web Real-Time Communication)允许浏览器之间直接通信,无需通过中心服务器转发数据。

关键优势:

  • 低延迟:数据直接在浏览器间传输
  • 减轻服务器压力:服务器只负责建立连接(信令)
  • 去中心化:更健壮的系统架构

建立连接的三个步骤:

  1. 信令交换:通过信令服务器交换SDP和ICE候选
  2. NAT穿透:使用STUN/TURN服务器建立直接连接
  3. 数据传输:直接传输Y.js的更新数据

3. 文档模型映射

Tiptap(基于 ProseMirror)使用树状结构表示文档,而Y.js使用线性结构。这两者之间需要建立映射关系:

ProseMirror文档树:
document
├─ paragraph
│  ├─ text "Hello"
│  └─ text(bold) "World"
└─ bullet_list
   └─ list_item
      └─ paragraph "Item 1"

Y.js XML Fragment:
<document>
  <paragraph>Hello<bold>World</bold></paragraph>
  <bullet_list>
    <list_item><paragraph>Item 1</paragraph></list_item>
  </bullet_list>
</document>

实现细节剖析

1. 协同状态管理

让我们看看如何在 Vue 组件中管理协同状态:

// 用户信息管理
const userInfo = ref({
  name: `用户${Math.floor(Math.random() * 1000)}`,
  color: getRandomColor() // 每个用户有独特的颜色
})

// 在线用户列表
const onlineUsers = ref<any[]>([])

// 更新用户列表的函数
const updateOnlineUsers = () => {
  if (!provider.value || !provider.value.awareness) return
  
  const states = Array.from(provider.value.awareness.getStates().entries())
  const users: any[] = []
  
  states.forEach(([clientId, state]) => {
    if (state && state.user) {
      users.push({
        clientId,
        ...state.user,
        isCurrentUser: clientId === provider.value.awareness.clientID
      })
    }
  })
  
  onlineUsers.value = users
}

Awareness 系统是Y.js的一个关键特性:

  • 跟踪每个用户的 状态(姓名、颜色、光标位置等)
  • 实时广播状态变化
  • 处理用户加入/离开事件

2. 编辑器的双重模式

我们的编辑器支持两种模式,需要平滑切换:

// 单机模式初始化
const reinitEditorWithoutCollaboration = () => {
  editor.value = new Editor({
    extensions: [StarterKit, Bold, Italic, Heading, ...],
    content: '<h1>欢迎使用编辑器</h1>...' // 静态内容
  })
}

// 协同模式初始化
const reinitEditorWithCollaboration = () => {
  // 关键:协同模式下不设置初始内容
  editor.value = new Editor({
    extensions: [
      Collaboration.configure({ // 协同扩展必须放在最前面
        document: ydoc.value,
        field: 'prosemirror',
      }),
      StarterKit.configure({ history: false }), // 禁用内置历史
      Bold, Italic, Heading, ...
    ],
    // 不设置 content,由Y.js提供
  })
}

关键区别:

  • 协同模式使用 Collaboration 扩展,禁用 history
  • 内容从 Y.Doc 加载,而不是静态设置
  • 所有操作通过Y.js同步

3. WebRTC 连接的生命周期

const initCollaboration = () => {
  // 1. 创建Y.js文档
  ydoc.value = new Y.Doc()
  
  // 2. 创建WebRTC提供者
  provider.value = new WebrtcProvider(roomId.value, ydoc.value, {
    signaling: ['ws://localhost:1234'], // 信令服务器地址
    password: null,
  })
  
  // 3. 设置用户awareness
  provider.value.awareness.setLocalStateField('user', userInfo.value)
  
  // 4. 监听连接状态
  provider.value.on('status', (event) => {
    isConnected.value = event.status === 'connected'
  })
  
  // 5. 监听同步完成
  provider.value.on('synced', (event) => {
    console.log('文档同步完成:', event.synced)
  })
}

4. 信令服务器的实现

信令服务器虽然简单,但至关重要:

// 房间管理
const rooms = new Map() // roomId -> Set of WebSocket connections

wss.on('connection', (ws) => {
  ws.on('message', (message) => {
    const data = JSON.parse(message.toString())
    
    if (data.type === 'subscribe') {
      // 客户端加入房间
      const topic = data.topic
      if (!rooms.has(topic)) rooms.set(topic, new Set())
      rooms.get(topic).add(ws)
    }
    else if (data.type === 'publish') {
      // 转发消息给房间内其他客户端
      const roomClients = rooms.get(data.topic)
      roomClients.forEach((client) => {
        if (client !== ws) { // 不转发给自己
          client.send(JSON.stringify(data))
        }
      })
    }
  })
})

信令服务器的作用:

  1. 房间管理:维护哪些客户端在哪个房间
  2. 消息转发:将SDP和ICE候选转发给对等方
  3. 连接建立:帮助WebRTC建立P2P连接

实时协同的工作流程

让我们通过一个具体场景来看系统如何工作:

场景:用户A和用户B协同编辑

1. 用户A打开编辑器
   ├─ 初始化Y.js文档
   ├─ 创建WebRTC提供者
   ├─ 连接信令服务器
   └─ 加入房间"room-abc123"

2. 用户B通过链接加入同一房间
   ├─ 初始化Y.js文档(相同roomId)
   ├─ WebRTC通过信令服务器发现用户A
   └─ 建立直接P2P连接

3. 用户A输入文字"Hello"
   ├─ Tiptap生成ProseMirror事务
   ├─ Collaboration扩展转换为Y.js操作
   ├─ Y.js操作通过WebRTC发送给用户B
   └─ 用户B的Y.js应用操作,更新Tiptap

4. 用户B同时输入"World"
   ├─ 同样流程反向进行
   ├─ Y.js的CRDT确保顺序一致性
   └─ 最终双方都看到"HelloWorld"

视觉反馈的实现

为了让用户感知到其他协作者的存在:

/* 其他用户的光标样式 */
.ProseMirror-y-cursor {
  border-left: 2px solid; /* 使用用户颜色 */
}

.ProseMirror-y-cursor > div {
  /* 显示用户名的标签 */
  background-color: var(--user-color);
  color: white;
  padding: 2px 6px;
  border-radius: 3px;
}
// 用户状态显示
<div v-for="user in onlineUsers" :key="user.clientId" 
     class="user-tag"
     :style="{
       backgroundColor: user.color + '20',
       borderColor: user.color,
       color: user.color
     }">
  <span class="user-avatar" :style="{ backgroundColor: user.color }"></span>
  {{ user.name }}
</div>

性能优化与注意事项

1. 延迟优化

// 批量更新,减少网络传输
provider.value.awareness.setLocalState({
  user: userInfo.value,
  cursor: editor.value.state.selection.from,
  // 其他状态...
})

// 节流频繁更新
let updateTimeout
const throttledUpdate = () => {
  clearTimeout(updateTimeout)
  updateTimeout = setTimeout(updateOnlineUsers, 100)
}

2. 错误处理与降级

try {
  // 尝试WebRTC连接
  provider.value = new WebrtcProvider(roomId.value, ydoc.value, config)
} catch (error) {
  console.error('WebRTC连接失败,降级到模拟模式:', error)
  
  // 降级策略:模拟协同,实际为单机
  isConnected.value = true
  onlineUsers.value = [{
    clientId: 1,
    ...userInfo.value,
    isCurrentUser: true
  }]
  
  // 提示用户
  showToast('协同模式不可用,已切换到单机模式')
}

3. 内存管理

// 组件卸载时清理
onBeforeUnmount(() => {
  if (editor.value) editor.value.destroy()
  if (provider.value) {
    provider.value.disconnect()
    provider.value.destroy()
  }
  if (ydoc.value) ydoc.value.destroy()
})

最终效果

在这里插入图片描述 两个用户同时编辑,各在互不影响 在这里插入图片描述

部署与扩展

1. 生产环境部署

// 生产环境信令服务器配置
const provider = new WebrtcProvider(roomId, ydoc, {
  signaling: [
    'wss://signaling1.yourdomain.com',
    'wss://signaling2.yourdomain.com' // 多节点冗余
  ],
  password: 'secure-room-password', // 房间密码保护
  maxConns: 20, // 限制最大连接数
})

2. 扩展功能

  • 离线支持:使用 IndexedDB 存储本地副本
  • 版本历史:利用 Y.js 的快照功能
  • 权限控制:不同用户的不同编辑权限
  • 插件系统:扩展编辑器功能

总结

构建实时协同编辑器是一个复杂的系统工程,涉及多个技术栈:

  1. Y.js 提供了理论基础(CRDT算法)和核心同步能力
  2. WebRTC 实现了高效的P2P数据传输
  3. Tiptap 提供了优秀的编辑器体验和扩展性
  4. Vue 3 构建了响应式的用户界面

这个项目的关键成功因素在于各个组件之间的无缝集成。Y.js处理数据一致性,WebRTC处理网络通信,Tiptap处理用户交互,而Vue将它们有机地组合在一起。

完整代码联系作者获取!

将html转化成图片

2025年12月10日 10:33

如何将指定html内容转化成图片保存?这个问题很值得深思,实际应用中也很有价值。最直接的想法就是使用canvas,熟悉canvas的同学可以尝试一下。这里不做太多的说明,本文采用html2canvas库来实现。

html2canvas库的使用非常简单,只需要引入html2canvas库,然后调用html2canvas方法即可,官方地址

接下来说一下简单的使用,以react项目为例。

获取整个页面截图,可以使用底层IDroot,这样下载的就是root下的所有元素。

import html2canvas from "html2canvas";

const saveCanvas = () => {
    // 画布基础元素,要绘制的元素
    const canvas: any = document.getElementById("root");
    const options: any = { scale: 1, useCORS: true };
    html2canvas(canvas, options).then((canvas) => {
      const type = "png";
      // 返回值是一个数据url,是base64组成的图片的源数据
      let imgDt = canvas.toDataURL(type);
      let fileName = "img" + "." + type;
      // 保存为文件
      let a = document.createElement("a");
      document.body.appendChild(a);
      a.href = imgDt;
      a.download = fileName;
      a.click();
    });
  };

图片的默认背景色是#ffffff,如果想要透明色可设置为null,比如设置为红色。

const options: any = { scale: 1, useCORS: true, backgroundColor: "red" };

正常情况下网络图片是无法渲染的,可以使用useCORS属性,设置为true即可。

const options: any = { scale: 1, useCORS: true };

保存某块元素的截图

const canvas: any = document.getElementById("swiper");

如果希望将某些元素排除,可以将data-html2canvas-ignore属性添加到这些元素中,html2canvas将从渲染中排除这些元素。

<Button
  data-html2canvas-ignore
  color="primary"
  fill="solid"
  onClick={saveCanvas}
>
  download
</Button>

完整代码

npm install html2canvas
// demo.less
.contentSwiper {
  width: 710px;
  height: 375px;
  color: #ffffff;
  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 48px;
  user-select: none;
}
.swiper {
  padding: 0 20px;
}
import React from "react";
import { Button, Space, Swiper } from "antd-mobile";
import html2canvas from "html2canvas";
import styles from "./demo.less";

export default () => {
  const saveCanvas = () => {
    // 画布基础元素,要绘制的元素
    const canvas: any = document.getElementById("root");
    const options: any = { scale: 1, useCORS: true, backgroundColor: "red"
  };
    html2canvas(canvas, options).then((canvas) => {
      const type = "png";
      // 返回值是一个数据url,是base64组成的图片的源数据
      let imgDt = canvas.toDataURL(type);
      let fileName = "img" + "." + type;
      // 保存为文件
      let a = document.createElement("a");
      document.body.appendChild(a);
      a.href = imgDt;
      a.download = fileName;
      a.click();
    });
  };
  const colors: string[] = ["#ace0ff", "#bcffbd", "#e4fabd", "#ffcfac"];
  const items = colors.map((color, index) => (
    <Swiper.Item key={index}>
      <div className={styles.contentSwiper} style={{ background: color }}>
        {index + 1}
      </div>
    </Swiper.Item>
  ));
  return (
    <div className="content">
      <div id="swiper" className={styles.swiper}>
        <Swiper
          style={{
            "--track-padding": " 0 0 16px",
          }}
          defaultIndex={1}
        >
          {items}
        </Swiper>
      </div>
      <div>
        <img
          width={200}
          src="https://t7.baidu.com/it/u=2621658848,3952322712&fm=193&f=GIF"
        />
      </div>
      <Space>
        <Button
          data-html2canvas-ignore
          color="primary"
          fill="solid"
          onClick={saveCanvas}
        >
          download
        </Button>
        <Button color="primary" fill="solid">
          Solid
        </Button>
        <Button color="primary" fill="outline">
          Outline
        </Button>
        <Button color="primary" fill="none">
          None
        </Button>
      </Space>
    </div>
  );
};

前端数据字典技术方案实战

作者 树深遇鹿
2025年12月10日 10:24

前言

在后台与中台系统开发领域,数据字典是极为常见且至关重要的概念,相信大多数从事相关开发工作的朋友都对其耳熟能详。几乎每一个成熟的项目,都会专门设置一个字典模块,用于精心维护各类字典数据。这些字典数据在系统中扮演着举足轻重的角色,为下拉框、转义值、单选按钮等组件提供了不可或缺的基础数据支撑。

我自工作以来参与过很多个项目,既有从零开始搭建的,也有接手他人项目的。在实践过程中,我发现不同项目对字典的实现方式各不相同,且各有侧重。例如,对于项目中的字典基本不会发生变化的,项目通常会采用首次全部加载到本地缓存的方式。这种方式能显著节省网络请求次数,提升系统响应速度。然而,对于项目中的字典经常变动的,项目则会采用按需加载的方式,即哪里需要使用字典值,就在哪里进行加载。但这种方式也存在弊端,当某个页面需要使用十多个字典值时,首次进入页面会一次性发出十多个请求来获取这些字典值,影响用户体验。

常见字典方案剖析

在当下,数据字典的实现方案丰富多样,各有优劣。下面将详细介绍几种常见的方案,并分析其特点。我将详细介绍几种常见的方案,并深入剖析其特点。这几种方案皆是我通过实践精心总结而来,其中方案四的思路是由我不爱吃鱼啦提供。

方案一:首次全部加载到本地进行缓存

方案描述

系统启动或用户首次访问时,将所有字典数据一次性加载到本地缓存中。后续使用过程中,直接从缓存中获取所需字典数据,无需再次向服务器发起请求。

优点

  • 访问速度快:后续访问时直接从本地缓存读取数据,无需等待网络请求,响应速度极快。
  • 减少网络请求:一次性加载后,后续使用无需频繁发起网络请求,降低了网络开销。
  • 网络依赖小:即使在网络不稳定的情况下,也能正常使用已缓存的字典数据,保证了系统的稳定性。

缺点

  • 首次加载时间长:若字典数据量较大,首次加载时可能需要较长时间,影响用户体验。
  • 占用存储空间:将所有字典数据存储在本地,会占用较多的本地存储空间,尤其是当字典数据量庞大时。
  • 缓存更新复杂:若字典数据频繁更新,需要设计复杂的缓存同步和更新机制,否则容易出现数据不一致的问题。

方案二:按需加载不缓存

方案描述

当用户触发特定操作,需要使用字典数据时,才从后端实时加载所需数据,且不进行本地缓存。每次使用字典数据时,都重新从服务器获取最新数据。

优点

  • 节省存储空间:不进行本地缓存,节省了本地存储空间,尤其适用于存储资源有限的设备。
  • 数据实时性高:每次获取的数据都是最新的,不存在缓存数据与后端不一致的问题,保证了数据的准确性。

缺点

  • 网络请求频繁:每次使用都需要发起网络请求,在网络状况不佳时,会导致加载时间变长,影响用户体验。
  • 增加服务器负担:频繁的网络请求会增加服务器的负担,尤其是在高并发场景下,可能影响服务器的性能。

方案三:首次按需加载并缓存

方案描述

用户首次访问某个字典数据时,从后端加载该数据并缓存到本地。后续再次访问该字典数据时,直接从缓存中读取,无需再次向服务器发起请求。

优点

  • 减少网络请求:结合了前两种方案的部分优点,既在一定程度上减少了网络请求次数,又不会一次性加载过多数据。
  • 节省存储空间:相较于首次全部加载到本地缓存的方式,不会一次性占用大量本地存储空间,节省了部分存储资源。

缺点

  • 缓存管理复杂:需要记录哪些数据已缓存,以便后续判断是否需要从缓存中读取或重新加载,增加了缓存管理的复杂度。
  • 缓存占用问题:对于不常使用的字典数据,缓存可能会占用不必要的存储空间,造成资源浪费。
  • 缓存更新难题:同样面临缓存更新的问题,需要设计合理的缓存更新策略,以保证数据的准确性和一致性。

方案四:按需加载 + 版本校验更新缓存

方案描述

用户按需发起字典数据请求,首次访问某个字典数据时,从后端加载并缓存到本地。在后端响应头中携带该字典数据的版本信息,后续每次请求该字典数据时,前端对比本地缓存的版本信息和响应头中的版本信息。若版本信息不一致,则清除本地缓存中对应的字典数据,并重新从后端加载最新数据;若版本信息一致,则直接使用本地缓存的数据。

优点

  • 数据实时性有保障:通过版本校验机制,能够及时获取到字典数据的更新,确保前端使用的数据与后端保持一致,避免了因缓存数据未及时更新而导致的业务问题。
  • 减少不必要的网络请求:在字典数据未更新时,直接使用本地缓存,无需发起网络请求,节省了网络带宽和服务器资源。
  • 平衡存储与性能:既不会像首次全部加载那样占用大量本地存储空间,又能在一定程度上减少网络请求,在存储和性能之间取得了较好的平衡。

缺点

  • 版本管理复杂:后端需要维护字典数据的版本信息,并且要确保版本号的准确性和唯一性,这增加了后端开发的复杂度和维护成本。
  • 额外开销:每次请求都需要进行版本信息对比操作,虽然开销较小,但在高并发场景下,可能会对系统性能产生一定影响。
  • 首次加载体验:首次加载字典数据时,依然需要从后端获取数据,若数据量较大或网络状况不佳,可能会影响用户体验。

方案选型建议

建议根据项目特性选择方案,没有最好的技术方案,只有最适合项目的技术方案:

  • 字典稳定且量小:方案一全量缓存
  • 字典频繁更新:方案四版本校验缓存
  • 存储敏感场景:方案三按需缓存
  • 实时性要求极高:方案二无缓存方案

ps:如果大家有更好的方案,也可以在评论区提出,让我们大家一起学习成长

代码实现(方案四)

下述代码的实现基于vue3+pinia,该代码实现了统一管理全局字典数据,支持按需加载、缓存复用、版本控制、动态更新、批量处理字典数据等功能。

pinia store的实现

import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getDictDetails, type Details } from '@/api/system/dict'

export const useDictStore = defineStore('dict', () => {
    // 存储字典数据,键为字典名称,值为字典详情数组
    const dictData = ref<Record<string, Details[]>>({})
    // 存储字典版本信息,键为字典名称,值为版本号
    const dictVersions = ref<string>('')

    /**
     * 更新字典版本信息
     * @param version 新的字典版本号
     */
    const updateDictVersion = (version: string) => {
        dictVersions.value = version
    }

    /**
     * 获取字典版本
     * @returns 字典版本号
     */
    const getDictVersion = () => {
        return dictVersions.value || ''
    }

    /**
     * 加载字典数据
     * @param dictNames 字典名称数组
     * @returns 加载的字典数据对象
     */
    const getDicts = async (dictNames: string[]) => {
        try {
            if (!Array.isArray(dictNames)) {
                return {};
            }
            // 过滤并去重有效字典名称
            const uniqueNames = [...new Set(dictNames.filter(name => 
                typeof name === 'string' && name.trim()
            ))];
            
            if (uniqueNames.length === 0) {
                return {};
            }

            const result: Record<string, Details[]> = {};
            const unloadedDicts: string[] = [];

            // 分离已加载和未加载的字典
            dictNames.forEach(name => {
                if (dictData.value[name]) {
                    result[name] = dictData.value[name];
                } else {
                    unloadedDicts.push(name);
                }
            });

            // 如果有未加载的字典,从接口请求获取
            if (unloadedDicts.length > 0) {
                const { data } = await getDictDetails(unloadedDicts);

                // 合并新加载的数据到结果
                Object.assign(result, data);

                // 更新全局字典缓存
                Object.assign(dictData.value, data);
            }

            return result;
        } catch (error) {
            console.error('加载字典数据失败:', error);
            return {};
        }
    };

    /**
     * 根据字典名称获取字典数据
     * @param name 字典名称
     * @returns 字典详情数组
     */
    const getDict = (name: string) => {
        return dictData.value[name] || []
    }

    /**
     * 根据字典名称和值获取字典标签
     * @param name 字典名称
     * @param value 字典值
     * @returns 字典标签
     */
    const getDictLabel = (name: string, value: string) => {
        const dict = getDict(name)
        const item = dict.find(item => item.value === value)
        return item?.label || ''
    }

    /**
     * 根据字典名称和标签获取字典值
     * @param name 字典名称
     * @param label 字典标签
     * @returns 字典值
     */
    const getDictValue = (name: string, label: string) => {
        const dict = getDict(name)
        const item = dict.find(item => item.label === label)
        return item?.value || ''
    }

    /**
     * 清除指定字典数据
     * @param names 字典名称
     */
    const clearDicts = (names: string[]) => {
        names.forEach(name => {
            clearDict(name)
        })
    }


    /**
     * 清除指定字典数据
     * @param name 字典名称
     */
    const clearDict = (name: string) => {
        delete dictData.value[name]
    }

    /**
     * 清除所有字典数据
     */
    const clearAllDict = () => {
        dictData.value = {}
    }

    return {
        dictData,
        updateDictVersion,
        getDictVersion,
        getDict,
        getDicts,
        getDictLabel,
        getDictValue,
        clearDict,
        clearDicts,
        clearAllDict
    }
})

useDict 实现

为组件提供字典数据的统一访问入口,封装了字典数据的初始化加载、详情查询、标签/值转换等高频操作,简化组件层对字典数据的调用逻辑。

import { type Details } from '@/api/system/dict'
import { useDictStore } from '@/store/dict'

// 根据字典值的name获取字典详情
export const useDict = (params: string[] = []) => {

  const dict = ref<Record<string, Details[]>>()
  const dictStore = useDictStore()

  const getDicts = async () => {
    dict.value = await dictStore.getDicts(params)
  }

  // 初始化字典数据
  getDicts()

  // 根据字典名称获取字典数据
  const getDict = (name: string) => {
    return dictStore.getDict(name)
  }

  // 根据字典值获取字典label
  const getDictLabel = (name: string, value: string) => {
    return dictStore.getDictLabel(name, value)
  }

  return {
    dict,
    getDict,
    getDictLabel
  }
}

响应拦截

主要用于获取字典的版本信息,通过对比版本信息,从而确定是否清除本地的字典缓存数据,并更新本地缓存的版本信息

// 响应拦截器
service.interceptors.response.use(
  // AxiosResponse
  (response: AxiosResponse) => {
    const dictVersion = response.headers['x-dictionary-version']
    if (dictVersion) {
      const dictStore = useDictStore()
      // 对比版本是否有更新
      if (dictStore.getDictVersion() !== dictVersion) {
        dictStore.clearAllDict()
        dictStore.updateDictVersion(dictVersion || '')
      }
    }
    // ...项目中的业务逻辑
  }
)

项目中的具体使用

下述的怎么使用封装的字典管理的简单demo

<script setup lang="ts">
import { useDict } from '@/hooks/useDict'
// 获取dict
const { dict, getDictLabel } =  useDict(['status', 'sex'])
console.log(dict.status, dict.sex)
</script>

项目源码地址

nest后端

Unusual-Server (github)

Unusual-Server (gitee)

vue3前端

Unusual-Admin (github)

Unusual-Admin (gitee)

结语

本文介绍了四种主流的数据字典实现方案,从全量加载到按需加载,从无缓存到版本校验缓存,每种方案都展现了其独特的优势与缺点。通过对比分析,我们不难发现,没有一种方案能够适用于所有场景,而是需要根据项目的具体特性进行灵活选择。对于字典稳定且量小的项目,全量缓存方案能够带来极致的响应速度;对于字典频繁更新的场景,版本校验缓存方案则能在保障数据实时性的同时,实现存储空间与网络请求的平衡优化。未来,随着技术的不断进步与应用场景的不断拓展,数据字典的实现方案也将持续演进。

博客主要记录一些学习的文章,如有不足,望大家指出,谢谢。

Next.js 近期高危漏洞完整指南:原理 + 攻击示例(前端易懂版)

作者 鲫小鱼
2025年12月10日 10:20

Next.js 近期高危漏洞完整指南:原理 + 攻击示例(前端易懂版)

一、漏洞核心信息速览(前端必知)

1. 事件背景(时间 + 起因)

  • CVE-2025-29927(身份验证绕过):2025 年 3 月公开,实际隐藏 4 年!影响 Next.js 11.1.4 ~ 15.2.2 版本,起因是中间件对 x-middleware-subrequest 请求头校验太宽松,外部可伪造。

  • CVE-2025-66478(远程代码执行 RCE):2025 年 12 月 4 日披露,CVSS 10.0(最高危),影响 Next.js 15/16 及部分 14.x 测试版,源于 React Server Components(RSC)协议的反序列化漏洞,服务器会执行恶意请求里的命令。

2. 漏洞原理(通俗解释)

  • 绕过漏洞:Next.js 中间件把 x-middleware-subrequest 当作 “内部请求标识”,但没验证是否真的是内部发的,攻击者拼几个合法值就能骗中间件放行。

  • RCE 漏洞:App Router 用 RSC 协议传输组件数据,服务器接收数据时没校验,直接执行里面的命令,相当于给攻击者开了 “服务器操作权限”。

3. 核心危害(前端能感知的影响)

  • 绕过漏洞:别人不用登录就能进后台(如 /admin),偷敏感数据、篡改内容。

  • RCE 漏洞:最致命!攻击者能操控服务器,偷数据库密码、植入挖矿程序(让服务器变 “肉鸡”)、删除业务数据,甚至瘫痪服务。

4. 快速预防方案(前端直接能用)

  1. 优先升级:按版本对应升级(15.x→≥15.3.6;14.x→≥14.2.25;13.x→≥13.5.9)。

  2. 临时防护:用 Nginx/Cloudflare 拦截 x-middleware-subrequest 请求头(禁止外部提交)。

  3. 代码层面:关键路由(如登录、支付)不要只靠中间件校验,加二次验证(如接口层查 token 有效性)。

5. 涉及核心知识点(前端关联技能)

  • Next.js 中间件:运行在请求最前面的校验逻辑,不是前端代码,是服务端轻量函数。

  • RSC(React Server Components):App Router 核心,服务端渲染组件的协议,数据传输要校验。

  • 请求伪造:客户端可篡改请求头 / 请求体,服务端不能 “无条件信任”。

  • 依赖安全:框架底层漏洞只能靠升级修复,定期用 npm audit 查漏洞。


二、攻击代码示例(仅用于学习,严禁非法使用)

⚠️ 注意事项:

  1. 以下代码仅用于 漏洞原理学习,严禁用于攻击真实网站,否则需承担法律责任!

  2. 测试仅允许在自己搭建的漏洞环境(如本地部署受影响版本的 Next.js 项目)中进行。

  3. 演示脚本聚焦 “攻击核心逻辑”,省略了真实攻击中的扫描、持久化等步骤,突出 “如何触发漏洞”。

(一)CVE-2025-29927(身份验证绕过漏洞)攻击示例

漏洞核心

伪造 x-middleware-subrequest 请求头,绕过中间件的登录校验,直接访问受保护路由(如 /admin)。

攻击脚本:bypass-auth.js
// 需先执行:npm install axios

const axios = require('axios');

// 目标地址(自己搭建的测试项目)

const targetUrl = 'http://localhost:3000/admin';

// 核心:伪造请求头,触发中间件绕过

const attackConfig = {

   headers: {

     // 关键:拼接 5 次合法标识,触发新版本漏洞绕过

     'x-middleware-subrequest': 'src/middleware:src/middleware:src/middleware:src/middleware:src/middleware',

     'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' // 伪装浏览器

   }

};

// 发送攻击请求

axios.get(targetUrl, attackConfig)

   .then(response => {

     console.log('攻击结果:');

     console.log('状态码:', response.status);

     console.log('响应内容(前500字符):', response.data.slice(0, 500));

     if (response.status === 200) {

       console.log('✅ 身份验证绕过成功!已访问受保护的 /admin 路由');

     }

   })

   .catch(error => {

     console.log('攻击失败:', error.message);

   });
测试环境搭建(本地复现用)
  1. 新建受影响版本的 Next.js 项目(如 15.2.2):
npx create-next-app@15.2.2 test-vuln-project

cd test-vuln-project
  1. 创建中间件 src/middleware.js(模拟登录校验):
import { NextResponse } from 'next/server';

export function middleware(req) {

   // 简单校验:没有登录 cookie 就拦截跳转到登录页

   if (!req.cookies.token) {

     return NextResponse.redirect(new URL('/login', req.url));

   }

   return NextResponse.next();

}

// 仅对 /admin 路由生效

export const config = { matcher: '/admin' };
  1. 创建 /admin 页面 app/admin/page.js
export default function AdminPage() {

   return 后台(仅登录可见)\</h1>;

}
运行步骤
  1. 启动测试项目:npm run dev

  2. 运行攻击脚本:node bypass-auth.js

  3. 预期结果:无需登录 cookie,控制台输出 200 状态码和 admin 页面内容,绕过成功。


(二)CVE-2025-66478(RSC 远程代码执行漏洞)攻击示例

漏洞核心

构造符合 RSC 协议格式的 POST 请求体,注入系统命令(如 whoami),服务器会直接执行该命令。

攻击脚本:rce-attack.js
// 需先执行:npm install axios

const axios = require('axios');

// 目标地址(启用 App Router 的受影响版本项目)

const targetUrl = 'http://localhost:3000';

// 核心:构造 RSC 协议的恶意请求体,注入系统命令

const maliciousBody = {

   // RSC 协议固定格式字段(简化版,模拟合法请求)

   id: '123',

   chunks: \[

     // 注入恶意命令:Windows 用 "whoami",Linux/Mac 用 "id"

     '{"type":"invoke","args":\[";whoami;"]}'

   ],

   version: '0.1',

   mode: 'server-component'

};

// 发送攻击请求(RSC 漏洞通过 POST / 触发)

axios.post(targetUrl, maliciousBody, {

   headers: {

     'Content-Type': 'application/json',

     'Accept': 'text/x-component' // RSC 协议要求的 Accept 头

   }

})

   .then(response => {

     console.log('攻击结果:');

     console.log('状态码:', response.status);

     console.log('响应内容:', response.data);

     // 校验命令执行结果(包含路径分隔符即为成功)

     if (response.data.includes('/') || response.data.includes('\\\\')) {

       console.log('✅ 远程代码执行成功!已获取服务器用户信息');

     }

   })

   .catch(error => {

     console.log('攻击失败:', error.response?.data || error.message);

   });
测试环境搭建(本地复现用)
  1. 新建受影响版本的 Next.js 项目(如 15.3.3):
npx create-next-app@15.3.3 test-rce-project

cd test-rce-project
  1. 启用 App Router(默认已启用,确保 app/page.js 存在):
// app/page.js

export default function Home() {

   return >Next.js App Router 测试页;

}
运行步骤
  1. 启动测试项目:npm run dev

  2. 运行攻击脚本:node rce-attack.js

  3. 预期结果:

  • Windows 系统:响应中返回 DESKTOP-XXX\用户名

  • Linux/Mac 系统:响应中返回 www-data 或当前用户身份

  • 控制台输出 “远程代码执行成功”

📌 注:真实攻击中,攻击者会将

whoami

替换为恶意命令,例如:

'{"type":"invoke","args":\[";wget http://恶意地址/挖矿脚本.sh && chmod +x 挖矿脚本.sh && ./挖矿脚本.sh;"]}'

本示例仅用 whoami 演示原理,切勿用于非法用途。


三、前端关键提醒

  1. 漏洞本质:都是 “信任了客户端输入”—— 中间件信了伪造的请求头,服务器信了伪造的 RSC 数据,核心是 “服务端未做严格校验”。

  2. 修复原则:框架底层漏洞,前端代码无法直接防御!必须升级 Next.js 到安全版本(这是最根本、最靠谱的方案)。

  3. 额外防护

  • 关键路由(登录、支付、后台)必须加 “双重校验”(中间件 + 接口层 token 验证),不要单靠中间件。

  • 自托管项目需在 Nginx/Cloudflare 层拦截恶意请求头(如 x-middleware-subrequest)。

  1. 日常习惯
  • 定期执行 npm audit 扫描依赖漏洞,及时更新补丁版本。

  • 关键业务优先使用 Vercel 托管(官方自带漏洞防护,自动屏蔽部分攻击)。

  • 服务器启用主机安全工具,监控异常进程和文件下载。

最后感谢阅读!欢迎关注我,微信公众号:《鲫小鱼不正经》。欢迎点赞、收藏、关注,一键三连!!!

UniApp H5 代理失效的终极替代方案

作者 Harry技术
2025年12月10日 10:05

UniApp H5 代理失效的终极替代方案

放弃 manifest.json,用 vite.config.js 配置(推荐,100% 生效)

在 UniApp(Vue3)H5 端配置代理不生效是高频问题,核心原因是 UniApp 对 H5 端 devServer 代理的解析规则、配置格式与纯 Vue CLI/Vite 项目存在差异,且容易忽略多环境、路径匹配、编译模式等细节。以下是针对性的排查和修复方案:

一、先确认核心前提(90% 问题出在这里)

UniApp 的 H5 端代理仅在 H5 开发模式(npm run dev:h5 下生效,且需满足:

  1. 配置文件是 manifest.json(根目录),且修改后必须重启 H5 开发服务(停止 dev:h5 后重新启动);
  2. 前端请求必须是相对路径(不能写 http://localhost:8080/dev-api/xxx 这类绝对路径,需直接写 /dev-api/xxx);
  3. 仅 H5 端生效,小程序 / APP 端不支持 devServer 代理(需用真机调试或配置跨域白名单)。

二、修正 manifest.json 代理配置格式(关键)

UniApp 对 h5.devServer.proxy 的配置格式有严格要求,你的配置看似正确,但需确认以下细节:

正确的 manifest.json 配置示例(JSON 格式严格)
{
  "vueVersion": "3",
  "h5": {
    "devServer": {
      "proxy": {
        "/dev-api": {
          "target": "http://192.168.31.24:9999",
          "changeOrigin": true,
          "pathRewrite": {
            "^/dev-api": ""
          },
          // 新增:UniApp 部分版本需显式开启 secure(非 HTTPS 目标设为 false)
          "secure": false,
          // 可选:开启日志,排查代理是否命中
          "logLevel": "debug"
        }
      },
      "https": false,
      // 可选:指定 H5 开发服务端口,避免端口冲突
      "port": 8080
    }
  }
}

三、代理不生效的高频排查点(逐一验证)

1. 检查请求路径是否为相对路径(最常见)

错误示例(绝对路径,不走代理):

// ❌ 绝对路径会绕过代理,直接请求 localhost:8080
axios.get('http://localhost:8080/dev-api/user/info')

正确示例(相对路径,触发代理):

// ✅ 相对路径,会被代理转发到 192.168.31.24:9999/user/info
axios.get('/dev-api/user/info')

// 或配置 axios baseURL(推荐)
const request = axios.create({
  baseURL: '/dev-api', // 开发环境
  timeout: 5000
})
request.get('/user/info')
2. 确认 UniApp 编译模式(Vue3 需匹配 Vite 规则)

UniApp Vue3 版本默认使用 Vite 编译,而 Vite 的代理配置中,pathRewrite 需替换为 rewrite(部分 UniApp 版本未兼容旧字段)。

修改为 Vite 兼容的代理配置:

{
  "h5": {
    "devServer": {
      "proxy": {
        "/dev-api": {
          "target": "http://192.168.31.24:9999",
          "changeOrigin": true,
          // Vite 中用 rewrite 替代 pathRewrite(UniApp Vue3 需此写法)
          "rewrite": "(path) => path.replace(/^/dev-api/, '')"
        }
      }
    }
  }
}
3. 检查后端是否限制跨域(代理生效但后端拦截)

即使代理配置正确,若后端未配置跨域允许,仍会报错:

  • 后端需添加跨域响应头(以 SpringBoot 为例):

    @Configuration
    public class CorsConfig implements WebMvcConfigurer {
        @Override
        public void addCorsMappings(CorsRegistry registry) {
            registry.addMapping("/**")
                    .allowedOrigins("http://localhost:8080") // H5 开发地址
                    .allowedMethods("GET", "POST", "PUT", "DELETE")
                    .allowCredentials(true)
                    .maxAge(3600);
        }
    }
    
  • 若后端用 Nginx 代理,需添加:

    add_header Access-Control-Allow-Origin http://localhost:8080;
    add_header Access-Control-Allow-Credentials true;
    
4. 排查 UniApp 版本兼容问题

部分 UniApp 版本(如 3.0.0~3.2.0)存在 H5 代理解析 Bug,可:

  • 升级 UniApp 核心版本:

    npm update @dcloudio/uni-app @dcloudio/uni-cli-shared
    
  • 或降级到稳定版本(如 3.4.0+):

    npm install @dcloudio/uni-app@3.4.0
    
5. 验证代理是否命中(开启日志)

devServer 中添加 logLevel: "debug",重启 H5 服务后,查看终端日志:

  • 若日志中出现 [proxy] proxying request /dev-api/user/info to http://192.168.31.24:9999,说明代理已命中;
  • 若无此日志,说明路径匹配失败(检查请求路径是否以 /dev-api 开头)。

四、UniApp H5 代理失效的终极替代方案

若以上配置仍不生效,可绕过 manifest.json,直接修改 Vite 配置(UniApp Vue3 基于 Vite 编译):

  1. 在项目根目录创建 vite.config.js(UniApp 会自动识别);
  2. 写入 Vite 原生代理配置:
import { defineConfig } from 'vite';
import uni from '@dcloudio/vite-plugin-uni';

export default defineConfig({
  plugins: [uni()],
  server: {
    // 直接配置 Vite 代理(优先级高于 manifest.json)
    proxy: {
      '/dev-api': {
        target: 'http://192.168.31.24:9999',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^/dev-api/, ''),
        logLevel: 'debug'
      }
    },
    // 确保端口与 manifest.json 一致
    port: 8080,
    https: false
  }
});
  1. 重启 npm run dev:h5,此方案优先级高于 manifest.json 中的 h5.devServer,几乎 100% 生效。

五、小程序 / APP 端的替代方案(重要)

UniApp 的 devServer 代理仅对 H5 端生效,小程序 / APP 端无法使用:

  • 小程序端:需在微信开发者工具中配置 “不校验合法域名”(仅调试),或在微信公众平台添加后端域名白名单;
  • APP 端:无需跨域(APP 无同源策略),直接请求后端真实地址 http://192.168.31.24:9999/xxx 即可。

总结

UniApp H5 端代理不生效的核心修复步骤:

  1. 确保请求为相对路径/dev-api/xxx);
  2. 重启 H5 开发服务(修改配置后必须重启);
  3. 优先使用 vite.config.js 配置 Vite 原生代理(替代 manifest.json);
  4. 验证后端跨域配置,避免代理生效但后端拦截。

按以上步骤配置后,请求 /dev-api/user/info 会被正确代理到 http://192.168.31.24:9999/user/info(移除 /dev-api 前缀)。

放弃 manifest.json,用 vite.config.js 配置(推荐,100% 生效)

UniApp Vue3 底层基于 Vite 编译,直接在 vite.config.js 中写 Vite 原生代理配置(支持 JS 函数),优先级高于 manifest.json,且不会有格式兼容问题:

1. 根目录创建 / 修改 vite.config.js
import { defineConfig } from 'vite';
import uni from '@dcloudio/vite-plugin-uni';

export default defineConfig({
  plugins: [uni()],
  server: {
    // H5 开发服务器代理配置(Vite 原生语法,支持函数)
    proxy: {
      '/dev-api': {
        target: 'http://192.168.31.24:9999',
        changeOrigin: true,
        // Vite 原生 rewrite 配置(函数形式,正确写法)
        rewrite: (path) => path.replace(/^/dev-api/, ''), 
        secure: false, // 非 HTTPS 目标需设为 false
        logLevel: 'debug' // 开启日志,便于排查
      }
    },
    port: 8080, // 与 manifest.json 保持一致(可选)
    https: false
  }
});
2. 清理 manifest.json 中的无效代理配置

manifest.jsonh5.devServer.proxy 部分删除(避免冲突),保留必要配置即可:

{
  "vueVersion": "3",
  "h5": {
    "devServer": {
      "https": false,
      "port": 8080 // 仅保留端口/HTTPS 配置,代理交给 vite.config.js
    }
  }
}
3. 重启 H5 开发服务

停止当前 npm run dev:h5,重新执行命令,代理即可生效。

方案二:强制适配 manifest.json(仅 JSON 格式,兼容旧版本)

如果必须在 manifest.json 中配置,需避开 JS 函数,改用 UniApp 兼容的 字符串替换规则(部分 UniApp 版本支持 pathRewrite 字符串格式):

{
  "vueVersion": "3",
  "h5": {
    "devServer": {
      "proxy": {
        "/dev-api": {
          "target": "http://192.168.31.24:9999",
          "changeOrigin": true,
          // 改用 pathRewrite(JSON 字符串格式,UniApp 可解析)
          "pathRewrite": {
            "^/dev-api": ""
          },
          "secure": false
        }
      },
      "https": false
    }
  }
}
关键说明:
  • manifest.json 是纯 JSON 文件,只能写键值对 / 字符串 / 数字 / 布尔值,不能写函数、正则表达式(/^/dev-api/ 这种正则在 JSON 中会被解析为字符串,部分版本 UniApp 能兼容);
  • 此方案仅适配 UniApp 对 pathRewrite 的兼容解析,若仍报错,优先用方案一(vite.config.js)。

报错原因补充

你之前写的 "rewrite": "(path) => path.replace(/^/dev-api/, '')" 存在两个问题:

  1. JSON 中无法解析箭头函数,最终 rewrite 是字符串类型,而非函数,导致 UniApp/Vite 执行 opts.rewrite(path) 时报错;
  2. 正则表达式写法错误:/^/dev-api/ 应为 /^/dev-api/(JSON 中需转义反斜杠,或在 JS 中直接写)。

验证是否生效

重启 npm run dev:h5 后,前端发送请求:

// 示例:用 axios 发送请求
axios.get('/dev-api/user/info')

查看终端日志(开启 logLevel: 'debug' 后),若出现:

[proxy] proxying request /dev-api/user/info to http://192.168.31.24:9999/user/info

说明代理已成功移除 /dev-api 前缀,配置生效。

最终建议

UniApp Vue3 项目优先使用 vite.config.js 配置代理:

  • 兼容 Vite 原生语法,支持函数 / 正则,无格式限制;
  • 避开 manifest.json 的 JSON 格式约束;
  • 配置逻辑与纯 Vite 项目一致,维护成本更低。

JavaScript 中为何未定义变量在 typeof 与 delete 中不会报错?——原理、示例与最佳实践

作者 excel
2025年12月10日 09:54

一、背景与概念

在 JavaScript 中,如果直接访问未声明的变量,如:

console.log(name);

会抛出:

ReferenceError: name is not defined

但是,使用:

typeof name;
delete name;

却不会报错,这给了我们一种“安全探测变量是否存在”的能力。
本文将深入解释原因、原理,并提供最佳实践示例。


二、语言设计原理:为什么 typeof 不会抛 ReferenceError?

1. JavaScript 初期的设计目标

JS 在 1995 年设计时,需要:

  • 允许“弱错误”并继续执行
  • 非专业程序员也能上手
  • 有利于容错(浏览器脚本不能轻易崩溃整个页面)

因此 typeof 被特意设计为 永远不会因为未声明变量而抛错


三、typeof 的底层行为机制

1. ECMAScript 规范:检查变量之前不触发 ReferenceError

伪流程:

if (变量声明存在)
    返回其类型字符串
else
    返回 "undefined"

这意味着:

typeof name; // "undefined",即使 name 未声明,也不会报错

示例(带逐行注释):

// 示例:检测一个可能不存在的变量
if (typeof userProfile !== "undefined") {
    console.log("变量存在,可使用:", userProfile);
} else {
    console.log("变量不存在");
}

逐行解释:

  • typeof userProfile 不会触发错误
  • 若变量未声明 → 返回字符串 "undefined"
  • 因此条件判断安全可靠

四、delete 为什么也不会报错?

delete 的主要作用是删除对象属性,而不是变量。

例:

delete window.a;
delete obj.key;

如果删除一个不存在的变量或属性,规范要求:

删除失败 → 返回 false(严格模式报错)
删除成功 → 返回 true

但非严格模式下:

delete name; // name 未声明 → 返回 true,不报错

这是为了浏览器脚本的容错设计。


五、如何利用 typeof 判断变量是否存在?(推荐用法)

通用写法

if (typeof someVar !== "undefined") {
    // 安全使用该变量
}

示例:根据全局变量切换运行模式

// 若 globalConfig 存在,优先使用
const config = (typeof globalConfig !== "undefined")
    ? globalConfig
    : { debug: false, mode: "default" };

console.log(config);

逐行解释:

  • 安全检查变量是否声明
  • 若存在则使用
  • 若不存在不报错并使用默认配置

六、不要使用 try...catch 判断变量是否存在(反例)

错误示例:

let exists;

try {
    name; // name 未声明
    exists = true;
} catch {
    exists = false;
}

虽然可行,但效率低、不优雅,也不符合 JS 设计初衷。
typeof 才是官方推荐的方式。


七、对比:声明但值为 undefined 与未声明变量的区别

1. 变量声明但未赋值

let a;
typeof a; // "undefined"

2. 根本未声明变量

typeof b; // "undefined" —— 不报错
b;        // ReferenceError —— 报错

表格区分:

情况 typeof 结果 直接访问
已声明但未赋值 "undefined" 值为 undefined
未声明变量 "undefined" ReferenceError

因此,只有 typeof 能区分安全访问与直接访问的区别


八、再扩展:检测全局变量的另一种安全方式

在浏览器全局作用域中:

if ("Vue" in window) {
    console.log("Vue 已加载");
}

但是此方法不能判断局部变量是否存在,因此
typeof 是最万能、适用所有作用域的方式


九、潜在问题与注意事项

❌ 不要用 typeof null 判断对象类型

typeof null; // "object" —— 历史遗留 bug

❌ typeof 不能判断变量是否已初始化(TDZ 问题)

在 ES6 的块级作用域中:

console.log(typeof x); // ❌ ReferenceError (在 TDZ 中)
let x = 10;

只有完全未声明才不会报错。

❌ delete 不适合作为变量存在性检查

delete 的语义是删除属性,不是检测变量,也不保证跨作用域一致性。


十、总结要点

  • typeof 永远不会因为未声明变量而报错
  • 它是 唯一安全判断变量是否存在的方式
  • delete 删除不存在的变量在非严格模式下不报错
  • 推荐检查变量存在性的方式:
if (typeof someVar !== "undefined") {
    // safe
}

完整示例:可直接运行

function checkVar(name) {
    // 安全探测变量是否存在
    if (typeof window[name] !== "undefined") {
        console.log(`变量 ${name} 存在,值为:`, window[name]);
    } else {
        console.log(`变量 ${name} 不存在`);
    }
}

// 测试
checkVar("abc");  // 未声明变量,不报错
window.abc = 123;
checkVar("abc");  // 变量已经存在

✨ 本文结语

本文部分内容借助 AI 辅助生成,并由作者整理审核。

基于SpreadJS的协同填报应用 | 葡萄城技术团队

2025年12月10日 09:54

基于SpreadJS的协同填报应用

协同电子表格带来的效率革命

在数字化转型的浪潮中,企业对高效协作和数据处理的需求日益增长。以 Microsoft 365、飞书多维表格等为代表的协同电子表格工具,凭借其实时编辑、多方共享的特性,极大地革新了传统基于本地Excel分享的工作模式。

协同电子表格的普及,显著提升了日常办公的效率,它成功解决了以下关键问题:

  • 文件版本混乱问题: 彻底告别“最终版-最终版-最终版-v2”的困境,保证所有参与者始终在同一个最新的文档上工作。
  • 数据孤岛与传输延迟: 实现了数据的集中管理和实时同步,从而无需通过邮件或即时通讯工具反复发送文件,有效加快了业务流转速度。
  • 基础协作门槛高: 提供了在线评论、权限管理等功能,让团队协作更加便捷和透明。

传统协同电子表格在企业级填报场景的局限性

尽管主流协同电子表格在通用协作方面表现出色,但在企业级数据填报这一核心场景中,其局限性也日益凸显:

  1. 数据安全与私有化挑战: 大多采用SaaS模式,难以满足金融、政府等行业对核心业务数据进行私有化部署和保证严格安全合规的要求。
  2. 系统集成度低: 缺乏作为底层组件嵌入企业现有 ERP、OA 等业务系统的能力,导致数据在应用间形成“数据烟囱”。
  3. 高昂的部署成本: 商业协同工具的私有化版本通常费用高昂,且定制化难度大,维护成本高。
  4. 数据交互受限: 难以灵活地进行结构化数据的提取和回写,阻碍了表格数据与企业数据库之间的无缝连接。

SpreadJS 协同插件:专为企业级协同填报设计的解决方案

SpreadJS的协同能力并非简单的“黑盒”功能,而是采用了多层、解耦的中间件架构。这种架构设计赋予了企业极高的部署灵活性和定制化空间。

  • 灵活的私有化部署方式,可选择将协同服务和业务系统共同部署,也可部署独立的微服务,通过API为多个业务系统提供填报协作能力,并支持docker、负载均衡等技术。
  • 多层次的定制化空间,从前端页面、冲突处理到用户鉴权,数据存储等各个环节,均可以通过中间件的方式二开处理,从而开发满足个性化需求的系统。

例如SpreadJS协同文档服务不仅支持自定义数据库配置,同时可配置快照存取规则,同时也可以使用use中间件和on注册钩子自定义处理逻辑注册中间件和on注册钩子自定义处理逻辑,在自定义逻辑中记录额外日志或者添加业务相关操作。

img

基于“数据区域管理器”实现业务解耦

在协同填报场景中,一个核心挑战是如何既支持业务数据的按权限展示填报,又可以实现文档的多人共享协同操作。SpreadJS的数据区域管理器(Data Range Provider)可以结合协同插件共同解决这个问题。

  • 独立的数据区域,SpreadJS可以在客户端创建客户独享的“数据区域”,结合业务、用户权限单独对区域进行配置,实现每个用户拥有个性化的表格。
  • 业务数据和文档分离,通过数据区域指定业务数据,使这些业务数据可以通过数据区域与业务系统同步,而其他区域内容则有协同服务来处理
  • 业务与协同解耦,大大简化了系统设计的复杂度,无需考虑如何从协同的文档中抽取业务数据。

在填报数据区域内,每个客户端可独立控制数据区域内的数据存取校验、单元格样式以及编辑权限等电子表格特性,当校验通过或存储成功后,交由协同层同步。区域以外由协同层直接同步共享。

img

总结:企业级协同填报新基座

在数字化转型浪潮中,尽管传统协同电子表格提升了日常办公效率 ,但 SpreadJS 通过组件化特性、多层解耦的中间件架构,特别是独有的“数据区域管理器”,成功弥补了主流工具在企业级应用中的局限 。该方案支持灵活的私有化部署,满足金融、政府等行业对核心业务数据的严格安全合规要求 。同时,它允许作为底层组件嵌入企业现有系统,打破了系统集成度低的挑战 ,并实现了业务数据与文档内容的分离,有效解决业务与协同的解耦问题 。最终,SpreadJS 为企业提供了一个强大、灵活且安全的新基座,赋能企业级数据协作新模式 。

扩展链接

硬核干货 | Excel 文件到底是怎么坏掉的?深入 OOXML 底层原理讲解修复策略

企业级RBAC 实战(八)手撸后端动态路由,拒绝前端硬编码

作者 小胖霞
2025年12月10日 09:29

在企业级后台中,硬编码路由(写死在 router/index.js)是维护的噩梦。本文将深入讲解如何根据用户权限动态生成侧边栏菜单。我们将构建后端的 getRouters 递归接口,并在前端利用 Vite 的 import.meta.glob 实现组件的动态挂载,最后彻底解决路由守卫中经典的“死循环”和“刷新白屏”问题。

学习之前先浏览 前置专栏文章

一、 引言:为什么要做动态路由?

在简单的后台应用中,我们通常会在前端 router/routes.ts 中写死所有路由。但在 RBAC(基于角色的权限控制)模型下,这种做法有两个致命缺陷:

  1. 安全性低:普通用户虽然看不到菜单,但如果在浏览器地址栏手动输入 URL,依然能进入管理员页面。
  2. 维护成本高:每次新增页面都要修改前端代码并重新打包部署。

目标:前端只保留“登录”和“404”等基础页面,其他所有业务路由由后端根据当前用户的角色权限动态返回。

二、 后端实现:构建路由树 (getRouters)

后端的核心任务是:查询当前用户的菜单 -> 过滤掉隐藏的/无权限的 -> 组装成 Vue Router 需要的 JSON 树。

1. 数据结构转换

我们在上一篇设计了 sys_menus 表。Vue Router 需要的结构包含 path, component, meta 等字段。我们需要一个递归函数将扁平的数据库记录转为树形结构。

文件:routes/menu.js

// 辅助函数:将数据库扁平数据转为树形结构
function buildTree(items, parentId = 0) {
  const result = []
  for (const item of items) {
    // 兼容字符串和数字的 ID 对比
    if (item.parent_id == parentId) {
      // 组装 Vue Router 标准结构
      const route = {
        name: toCamelCase(item.path), // 自动生成驼峰 Name
        path: item.path,
        hidden: item.hidden === 1,    // 数据库 1/0 转布尔
        component: item.component,    // 此时还是字符串,如 "system/user/index"
         // 只有当 redirect 有值时才添加该字段
        ...(item.redirect && { redirect: item.redirect }),
        // 只有当 always_show 为 1 时才添加,并转为布尔
        ...(item.alwaysShow === 1 && { alwaysShow: true }),
        meta: {
          title: item.menu_name,
          icon: item.icon,
          noCache: item.no_cache === 1
        }
      }
      
      const children = buildTree(items, item.id)
      if (children.length > 0) {
        route.children = children
      }
      result.push(route)
    }
  }
  return result
}

2. 接口实现

这里有一个关键逻辑:上帝模式普通模式的区别。

  • Admin:直接查 sys_menus 全表(排除被物理删除的)。
  • 普通用户:通过 sys_users -> sys_roles -> sys_role_menus -> sys_menus 进行四表联查,只获取拥有的权限。

文件 route/menu.js

router.get('/getRouters', authMiddleware, async (req, res, next) => {
  try {
    const userId = req.user.userId
    const { isAdmin } = req.user

    let sql = ''
    let params = []

    const baseFields = `m.id, m.parent_id, m.menu_name, m.path, m.component, m.icon, m.hidden`

    if (isAdmin) {
      // 管理员:看所有非隐藏菜单
      sql = `SELECT ${baseFields} FROM sys_menus m WHERE m.hidden = 0 ORDER BY m.sort ASC`
    } else {
      // 普通用户:通过中间表关联查询
      sql = `
        SELECT ${baseFields} 
        FROM sys_menus m
        LEFT JOIN sys_role_menus rm ON m.id = rm.menu_id
        LEFT JOIN sys_users u ON u.role_id = rm.role_id
        WHERE u.id = ? AND m.hidden = 0
        ORDER BY m.sort ASC
      `
      params.push(userId)
    }

    const [rows] = await pool.query(sql, params)
    const menuTree = buildTree(rows)

    res.json({ code: 200, data: menuTree })
  } catch (err) {
    next(err)
  }
})

三、 前端实现:组件动态加载

前端拿到后端的 JSON 后,最大的难点在于:后端返回的 component 是字符串 "system/user/index",而 Vue Router 需要的是一个 Promise 组件对象 () => import(...)

在 Webpack 时代我们要用 require.context,而在 Vite 中,我们要用 import.meta.glob。

1. Store 逻辑 (store/modules/permission.ts)

import { defineStore } from 'pinia'
import { constantRoutes } from '@/router'
import { getRouters } from '@/api/menu'
import Layout from '@/layout/index.vue'

// 1. Vite 核心:一次性匹配 views 目录下所有 .vue 文件
// 结果类似: { '../../views/system/user.vue': () => import(...) }
const modules = import.meta.glob('../../views/**/*.vue')

export const usePermissionStore = defineStore('permission', {
  state: () => ({
    routes: [],        // 完整路由(侧边栏用)
    addRoutes: [],     // 动态路由(router.addRoute用)
    sidebarRouters: [] // 侧边栏菜单
  }),
  
  actions: {
    async generateRoutes() {
      // 请求后端
      const res: any = await getRouters()
      const sdata = JSON.parse(JSON.stringify(res.data))
      
      // 转换逻辑
      const rewriteRoutes = filterAsyncRouter(sdata)
      
      this.addRoutes = rewriteRoutes
      this.sidebarRouters = constantRoutes.concat(rewriteRoutes)
      
      return rewriteRoutes
    }
  }
})

// 遍历后台传来的路由字符串,转换为组件对象
function filterAsyncRouter(asyncRouterMap: any[]) {
  return asyncRouterMap.filter(route => {
    if (route.component) {
      if (route.component === 'Layout') {
        route.component = Layout
      } else {
        // 核心:根据字符串去 modules 里找对应的 import 函数
        route.component = loadView(route.component)
      }
    }
    // ... 递归处理 children
    if (route.children && route.children.length) {
      route.children = filterAsyncRouter(route.children)
    }
    return true
  })
}

export const loadView = (view: string) => {
  let res
  for (const path in modules) {
    // 路径匹配逻辑:从 ../../views/system/user.vue 中提取 system/user
    const dir = path.split('views/')[1].split('.vue')[0]
    if (dir === view) {
      res = () => modules[path]()
    }
  }
  return res
}

四、 核心难点:路由守卫与“死循环”

在 src/permission.ts 中,我们需要拦截页面跳转,如果是第一次进入,则请求菜单并添加到路由中。

1. 经典死循环问题

很多新手会这样写判断:

// ❌ 错误写法
if (userStore.roles.length === 0) {
  // 去拉取用户信息 -> 生成路由 -> next()
}

Bug 场景:如果新建了一个没有任何角色的用户 user01,后端返回的 roles 是空数组。

  1. roles.length 为 0,进入 if。
  2. 拉取信息,发现还是空数组。
  3. next(to) 重定向,重新进入守卫。
  4. roles.length 依然为 0... 死循环,浏览器崩溃

2. 解决方案:引入 isInfoLoaded 状态位

我们在 userStore 中增加一个 isInfoLoaded 布尔值,专门标记“是否已经尝试过拉取用户信息”

文件:src/permission.ts

import router from '@/router'
import { useUserStore } from '@/store/modules/user'
import { usePermissionStore } from '@/store/modules/permission' // 引入新的 store
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'

NProgress.configure({ showSpinner: false })

const whiteList = ['/login', '/404']

router.beforeEach(async (to, from, next) => {
  NProgress.start()
  const userStore = useUserStore()
  const permissionStore = usePermissionStore()

  if (userStore.token) {
    if (to.path === '/login') {
      next({ path: '/' })
      NProgress.done()
    } else {
      // 判断当前用户是否已拉取完 user_info 信息
      // 这里我们可以简单判断:如果 userStore.userInfo.roles.length==0 动态添加的菜单长度为0,说明还没请求菜单,但似乎 这么写  如果 用户没角色  会陷入死循环
      if (!userStore.isInfoLoaded) {
        try {
          // 2. 生成动态路由 (后端请求)
          const userInfo = await userStore.getInfo()
          console.log('userInfo--', userInfo)
          if (userInfo && userInfo.data.id) {
            const accessRoutes = await permissionStore.generateRoutes()
            // // 3. 后端返回的路由
            console.log('accessRoutes', accessRoutes)
            // 4. 动态添加路由
            accessRoutes.forEach((route) => {
              router.addRoute(route)
            })
          }
          // 4. 确保路由添加完成 (Hack方法)
          next({ path: to.path, query: to.query, replace: true })
        } catch (err) {
          console.log('userinfo -err', err)
          userStore.logout()
          next(`/login?redirect=${to.path}`)
          NProgress.done()
        }
      } else {
        next()
      }
    }
  } else {
    if (whiteList.includes(to.path)) {
      console.log('to.path', to.path)
      next()
    } else {
      next(`/login?redirect=${to.path}`)
      NProgress.done()
    }
  }
})

router.afterEach(() => {
  NProgress.done()
})

五、 侧边栏递归渲染

最后一步是将 sidebarRouters 渲染到左侧菜单. 这里需要注意一个细节:el-tree 或 el-menu 的父节点折叠问题
如果一个目录只有一个子菜单(例如“首页”),我们通常希望直接显示子菜单,不显示父目录。

文件 layout/components/slideBar/index.vue

<template>
  <div :class="classObj">
    <Logo v-if="showLogo" :collapse="isCollapse" />
    <el-scrollbar wrap-class="scrollbar-wrapper" :class="sideTheme">
      <el-menu
        router
        :default-active="activeMenu"
        :collapse="isCollapse"
        :background-color="
          settingsStore.sideTheme === 'dark'
            ? variables.menuBackground
            : variables.menuLightBackground
        "
        :text-color="
          settingsStore.sideTheme === 'dark'
            ? variables.menuColor
            : variables.menuLightColor
        "
        :active-text-color="theme"
        :unique-opened="true"
        :collapse-transition="false"
      >
        <template v-for="item in slidebarRouters" :key="item.path">
          <!-- 如果只有一个子菜单,直接显示子菜单 -->
          <el-menu-item
            v-if="item.children && item.children.length === 1"
            :index="item.path + '/' + item.children[0].path"
          >
            <el-icon v-if="item.children[0].meta?.icon">
              <component :is="item.children[0].meta.icon" />
            </el-icon>
            <span>{{ item.children[0].meta.title }}</span>
          </el-menu-item>

          <!-- 如果有多个子菜单,显示下拉菜单 -->
          <el-sub-menu
            v-else-if="item.children && item.children.length > 1"
            :index="item.path"
          >
            <template #title>
              <el-icon v-if="item.meta?.icon">
                <component :is="item.meta.icon" />
              </el-icon>
              <span>{{ item.meta.title }}</span>
            </template>
            <el-menu-item
              v-for="subItem in item.children"
              :key="subItem.path"
              :index="item.path + '/' + subItem.path"
            >
              <el-icon v-if="subItem.meta?.icon">
                <component :is="subItem.meta.icon" />
              </el-icon>
              <span>{{ subItem.meta.title }}</span>
            </el-menu-item>
          </el-sub-menu>

          <!-- 如果没有子菜单,直接显示当前菜单 -->
          <el-menu-item v-else :index="item.path">
            <el-icon v-if="item.meta?.icon">
              <component :is="item.meta.icon" />
            </el-icon>
            <span>{{ item.meta.title }}</span>
          </el-menu-item>
        </template>
      </el-menu>
    </el-scrollbar>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import Logo from './Logo.vue'
import { useRoute } from 'vue-router'
import variables from '@/assets/styles/var.module.scss'
const route = useRoute()

import { useSettingsStore } from '@/store/modules/settings'
import { useAppStore } from '@/store/modules/app'
import { usePermissionStore } from '@/store/modules/permission'
const appStore = useAppStore()
const settingsStore = useSettingsStore()
const permissionStore = usePermissionStore()
const sideTheme = computed(() => settingsStore.sideTheme)
const theme = computed(() => settingsStore.theme)
const showLogo = computed(() => settingsStore.showLogo)
const isCollapse = computed(() => !appStore.sidebar.opened)
const classObj = computed(() => ({
  dark: sideTheme.value === 'dark',
  light: sideTheme.value === 'light',
  'has-logo': settingsStore.showLogo,
}))

const slidebarRouters = computed(() =>
  permissionStore.sidebarRouters.filter((item) => {
    return !item.hidden
  })
)

console.log('slidebarRouters', slidebarRouters.value)

// 激活菜单
const activeMenu = computed(() => {
  const { path } = route
  return path
})
</script>

<style scoped lang="scss">
#app {
  .main-container {
    height: 100%;
    transition: margin-left 0.28s;
    margin-left: $base-sidebar-width;
    position: relative;
  }

  .sidebarHide {
    margin-left: 0 !important;
  }

  .sidebar-container {
    -webkit-transition: width 0.28s;
    transition: width 0.28s;
    width: $base-sidebar-width !important;
    background-color: $base-menu-background;
    height: 100%;
    position: fixed;
    font-size: 0px;
    top: 0;
    bottom: 0;
    left: 0;
    z-index: 1001;
    overflow: hidden;
    -webkit-box-shadow: 2px 0 6px rgba(0, 21, 41, 0.35);
    box-shadow: 2px 0 6px rgba(0, 21, 41, 0.35);

    // reset element-ui css
    .horizontal-collapse-transition {
      transition:
        0s width ease-in-out,
        0s padding-left ease-in-out,
        0s padding-right ease-in-out;
    }

    .scrollbar-wrapper {
      overflow-x: hidden !important;
    }

    .el-scrollbar__bar.is-vertical {
      right: 0px;
    }

    .el-scrollbar {
      height: 100%;
    }

    &.has-logo {
      .el-scrollbar {
        height: calc(100% - 50px);
      }
    }

    .is-horizontal {
      display: none;
    }

    a {
      display: inline-block;
      width: 100%;
      overflow: hidden;
    }

    .svg-icon {
      margin-right: 16px;
    }

    .el-menu {
      border: none;
      height: 100%;
      width: 100% !important;
    }

    .el-menu-item,
    .menu-title {
      overflow: hidden !important;
      text-overflow: ellipsis !important;
      white-space: nowrap !important;
    }

    .el-menu-item .el-menu-tooltip__trigger {
      display: inline-block !important;
    }

    // menu hover
    .sub-menu-title-noDropdown,
    .el-sub-menu__title {
      &:hover {
        background-color: rgba(0, 0, 0, 0.06) !important;
      }
    }

    & .theme-dark .is-active > .el-sub-menu__title {
      color: $base-menu-color-active !important;
    }

    & .nest-menu .el-sub-menu > .el-sub-menu__title,
    & .el-sub-menu .el-menu-item {
      min-width: $base-sidebar-width !important;

      &:hover {
        background-color: rgba(0, 0, 0, 0.06) !important;
      }
    }

    & .theme-dark .nest-menu .el-sub-menu > .el-sub-menu__title,
    & .theme-dark .el-sub-menu .el-menu-item {
      background-color: $base-sub-menu-background !important;

      &:hover {
        background-color: $base-sub-menu-hover !important;
      }
    }
  }

  .hideSidebar {
    .sidebar-container {
      width: 54px !important;
    }

    .main-container {
      margin-left: 54px;
    }

    .sub-menu-title-noDropdown {
      padding: 0 !important;
      position: relative;

      .el-tooltip {
        padding: 0 !important;

        .svg-icon {
          margin-left: 20px;
        }
      }
    }

    .el-sub-menu {
      overflow: hidden;

      & > .el-sub-menu__title {
        padding: 0 !important;

        .svg-icon {
          margin-left: 20px;
        }
      }
    }

    .el-menu--collapse {
      .el-sub-menu {
        & > .el-sub-menu__title {
          & > span {
            height: 0;
            width: 0;
            overflow: hidden;
            visibility: hidden;
            display: inline-block;
          }
          & > i {
            height: 0;
            width: 0;
            overflow: hidden;
            visibility: hidden;
            display: inline-block;
          }
        }
      }
    }
  }

  .el-menu--collapse .el-menu .el-sub-menu {
    min-width: $base-sidebar-width !important;
  }

  // mobile responsive
  .mobile {
    .main-container {
      margin-left: 0px;
    }

    .sidebar-container {
      transition: transform 0.28s;
      width: $base-sidebar-width !important;
    }

    &.hideSidebar {
      .sidebar-container {
        pointer-events: none;
        transition-duration: 0.3s;
        transform: translate3d(-$base-sidebar-width, 0, 0);
      }
    }
  }

  .withoutAnimation {
    .main-container,
    .sidebar-container {
      transition: none;
    }
  }
}

// when menu collapsed
.el-menu--vertical {
  & > .el-menu {
    .svg-icon {
      margin-right: 16px;
    }
  }

  .nest-menu .el-sub-menu > .el-sub-menu__title,
  .el-menu-item {
    &:hover {
      // you can use $sub-menuHover
      background-color: rgba(0, 0, 0, 0.06) !important;
    }
  }

  // the scroll bar appears when the sub-menu is too long
  > .el-menu--popup {
    max-height: 100vh;
    overflow-y: auto;

    &::-webkit-scrollbar-track-piece {
      background: #d3dce6;
    }

    &::-webkit-scrollbar {
      width: 6px;
    }

    &::-webkit-scrollbar-thumb {
      background: #99a9bf;
      border-radius: 20px;
    }
  }
}

.dark {
  background-color: $base-menu-background !important;
}
.light {
  background-color: $base-menu-light-background !important;
}
.scrollbar-wrapper {
  overflow-x: hidden !important;
}
.has-logo {
  .el-scrollbar {
    height: calc(100% - 50px);
  }
}
</style>

六、 总结与下篇预告

通过本篇实战,我们实现了:

  1. 后端:根据角色权限过滤菜单数据。
  2. 前端:利用 Vite 的 glob 导入特性,将后端字符串动态转为前端组件。
  3. 守卫:通过 isInfoLoaded 状态位完美解决了空权限用户的死循环问题。

现在的系统已经具备了动态菜单的能力。但是,如何更方便地管理这些用户和角色呢?  如果用户很多,列表怎么分页?怎么模糊搜索?

下一篇:《企业级全栈 RBAC 实战 (九):用户管理与 SQL 复杂查询优化》,我们将深入 Element Plus 表格组件与 MySQL 分页查询的结合。

❌
❌