普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月12日技术

ecahrts图形多的页面,怎么解决数据量大的渲染问题?

2026年3月12日 11:13

在一个页面上使用多个 ECharts 图表并处理大量数据时,性能问题确实是个不小的挑战。这主要是因为浏览器既要处理庞大的数据量,又要同时渲染和更新多个图表,很容易导致页面卡顿甚至崩溃。

解决这个问题需要一个系统性的策略,从数据处理、渲染配置、计算优化资源管理这四个核心层面入手。下面是一个清晰的解决框架:

图片.png

🧮 数据层面:从源头"减负"

这是最有效的一步,直接从数据量上下手,减轻前端的处理压力。

  • 数据降采样:当数据点超过屏幕像素时,很多点是“画不出来的”。可以通过算法保留关键特征点(如峰值、趋势),同时大幅减少数据量。推荐使用 LTTB (最大三角形三桶) 算法,它能在保留数据轮廓的同时将10万条数据压缩至千条以内,渲染效率提升90%以上。ECharts 也内置了sampling: 'lttb'配置。
  • 数据聚合:在后端或前端对数据进行预处理,比如将每秒的数据聚合成每分钟的平均值、总和等。例如,展示全年订单趋势时,将每日数据聚合为周或月数据,数据量可减少80%以上。
  • 数据分片与懒加载:不要一次性请求所有数据。可以先加载概览数据,当用户通过dataZoom组件缩放或滚动时,再按需加载更精细的数据。ECharts 的appendData方法可以实现增量渲染。

⚙️ 配置层面:让渲染更"轻松"

通过调整ECharts的配置项,可以显著降低图表的渲染计算量。

  • 开启“大型数据”模式:在series中设置large: true,ECharts会启用内部的优化算法进行批量绘制,这对于超过1-2千的数据点非常有效。同时可以设置largeThreshold来手动触发阈值。
  • 关闭非必要的视觉效果
    • 动画:设置 animation: false,这在大量数据下可节省约30%的渲染时间。
    • 数据点标记:设置 symbol: 'none',隐藏折线上的小圆圈。
    • 平滑曲线:设置 smooth: false,因为计算贝塞尔曲线非常消耗CPU。
    • 阴影和渐变:简化itemStyle中的复杂样式。
  • 启用渐进式渲染:通过progressive(渐进渲染步长)和progressiveThreshold(启用渐进渲染的阈值)配置,让图表分批次渲染数据点,避免一次性绘制大量图形造成的卡顿。
  • 优化交互:将tooltip的触发方式从默认的'mousemove'改为'click',或者使用tooltip.delay来减少高频计算。

🧠 计算层面:把"重活"放后台

JavaScript是单线程的,复杂的计算会阻塞UI更新。使用Web Worker可以将耗时任务移到后台线程。

  • 使用 Web Worker 处理数据:将获取数据、解析数据、执行LTTB降采样算法等CPU密集型任务放在Worker线程中执行。这样,即使后台在处理5万以上的数据点,页面主线程依然可以流畅响应用户的滚动和点击。
    // 主线程代码
    const worker = new Worker('/path/to/lttb-worker.js');
    worker.postMessage({ data: rawData, threshold: 2000 });
    worker.onmessage = (event) => {
      const sampledData = event.data;
      myChart.setOption({ series: [{ data: sampledData }] });
    };
    
  • 事件节流与防抖:对window.resizemousemove等高频率事件进行节流(throttle)或防抖(debounce)处理,避免频繁调用chart.resize()或触发重绘。

🧹 工程与资源层面:做好"大管家"

良好的编码习惯和资源管理是页面长期稳定运行的保障。

  • 按需引入ECharts模块:不要全量引入ECharts。只引入项目实际用到的图表类型和组件(如LineChart, BarChart, Grid等),可以有效减少打包后的体积,加快页面加载速度。
  • 及时销毁图表实例:在组件卸载或页面隐藏时,务必调用chart.dispose()方法来释放图表占用的内存和监听器,防止内存泄漏。
  • 使用高效的数据格式:对于纯数值型数据,考虑使用Typed Array(类型数组,如Float32Array)来代替普通的JavaScript数组。它的解析速度更快,内存占用更低,ECharts可以直接消费这类数据。
  • 选择正确的渲染器:对于大多数大数据量场景(万级以上),Canvas渲染器是首选,因为它基于像素绘制,无需维护庞大的DOM树。实测数据显示,10万数据点下Canvas渲染时间(约900ms)远优于SVG(约1800ms)。

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

作者 LiuMingXin
2026年3月12日 11:05

image.png

引言

你有没有想过这样一种场景,你所有的开发电脑全部都是内网的完全的物理隔离,而且这台电脑上没有安装前端开的的环境,但是你的项目又是不同的技术栈,比如说vue2、vue3、react等,同时你使用包管理器可能有npm、yarn、pnpm、bun等,而且你还需要在内网环境中开发桌面端类似于electron、tauri等,同时你的构建还需要二进制的依赖等,并且你还使用vue3开发了一些业务组件库之类的,解决这类问题,那这种你可能又两种选择:

  • 方案一:全部改成npm,把node_modules跟着项目走;
  • 方案二:使用verdaccio搭建完整的私服npm源,支持不同的技术栈和包管理器

那就引发出来一类场景:我们怎么在一台纯物理隔离的纯净电脑上搭建出支持不同技术栈的前端私有npm源!

下面我们将开启verdaccio使用,希望你读完这篇文章之后能对verdaccio有一定的了解和认识。

准备

由于我们是全新的环境,以windows环境为例,我们首选需要安装的是node, 但是我们的项目又涉及不同的vue版本等,所有的开发环境需要多个node版本,鉴于遮掩这样的情况我们需要node版本的管理工具,常见的用nvmfnmvolta等,这里以使用nvm为例进行安装(可以自己自行选择),下面以nvm进行操作。

换句话说下面的操作可以让我们在一个纯净的内网机器下把前端开发前置环境搭建起来!

NVM 安装部署

下面示例进行windows和linux的nvm的安装。

Windows环境安装

下载nvm-windows

在github上搜索nvm-windows, 进入项目,点击右侧的Releases的,下载 nvm-setup.exenvm-noinstall.zip

Github地址:github.com/coreybutler…

下载node js

访问 nodejs.org/dist/ 下载需要的版本

Windows版本列表:

https://nodejs.org/dist/v12.22.12/node-v12.22.12-win-x64.zip
https://nodejs.org/dist/v14.21.3/node-v14.21.3-win-x64.zip
https://nodejs.org/dist/v16.20.2/node-v16.20.2-win-x64.zip
https://nodejs.org/dist/v18.19.0/node-v18.19.0-win-x64.zip
https://nodejs.org/dist/v20.11.0/node-v20.11.0-win-x64.zip
安装nvm-windows
  • 点击 nvm-setup.exe 进行安装
  • 解压 nvm-noinstall.zip 到指定目录

安装目录以:C:\nvm为例, 后续操作都是再这个目录下进行,如需更换做相应的替换即可

安装时记住两个路径:

  • nvm安装路径:C:\nvm
  • node js符号链接路径:C:\Program Files\nodejs
手动添加node js版本

将下载的 node js 手动压缩包解压到 nvm 安装目录下:

操作步骤:

# 1. 解压 node-v12.22.12-win-x64.zip
# 2. 将解压后的文件夹重命名为 v12.22.12
# 3. 将 v12.22.12 文件夹移动到 C:\nvm\ 目录下
# 4. 重复以上步骤处理其他版本
# 目录结构应该是这样
C:\nvm\
  ├─ v12.22.12\
  │   ├─ node.exe
  │   ├─ npm
  │   └─ ...
  ├─ v14.21.3\
  ├─ v16.20.2\
  ├─ v18.19.0\
  └─ v20.11.0\
修改settings.txt

编辑 C:\nvm\settings.txt,进行下面的修改,修改对应的rootpath:

root: C:\nvm
path: C:\Program Files\nodejs
arch: 64
proxy: none
配置环境变量

确保以下环境变量已设置:

NVM_HOME = C:\nvm
NVM_SYMLINK = C:\Program Files\nodejs

Path 中添加:
%NVM_HOME%
%NVM_SYMLINK%

Linux环境安装

下载nvm

在github上搜索nvm, 进入项目,点击右侧的Releases的,下载对应版本的,解压即可。

Github地址: github.com/nvm-sh/nvm

下载node js

访问 nodejs.org/dist/ 下载需要的版本

Linux版本列表:

https://nodejs.org/dist/v12.22.12/node-v12.22.12-linux-x64.tar.gz
https://nodejs.org/dist/v14.21.3/node-v14.21.3-linux-x64.tar.gz

...

下载linux上需要的node安装包时需要注意架构版本arm和x64是有区别的,留意一下即可

安装nvm

步骤:

# 以某个版本为例
wget https://github.com/nvm-sh/nvm/archive/refs/tags/v0.39.0.tar.gz
tar -xzf v0.39.0.tar.gz
mv nvm-0.39.0 .nvm
配置环境变量

编辑 ~/.bashrc~/.zshrc

export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"

生效配置:

source ~/.bashrc  # 或 source ~/.zshrc
手动添加node js版本
# 创建版本目录
mkdir -p ~/.nvm/versions/node

# 解压并移动 Node.js
tar -xzf node-v12.22.12-linux-x64.tar.gz
mv node-v12.22.12-linux-x64 ~/.nvm/versions/node/v12.22.12

# 重复以上步骤处理其他版本

目录结构:

~/.nvm/versions/node/
  ├─ v12.22.12/
  ├─ v14.21.3/
  ├─ v16.20.2/
  ├─ v18.19.0/
  └─ v20.11.0/

NVM验证和使用

验证安装

# 重新加载环境
source ~/.bashrc

# 验证NVM
nvm --version

# 查看已安装的Node.js版本
nvm list

# 使用特定版本
nvm use 18.17.1

# 验证Node.js和npm
node --version
npm --version

# 测试npm基本功能
npm config list

使用操作

# 切换Node.js版本
nvm use 16.20.2    # 切换到16版本
nvm use 18.17.1    # 切换到18版本
nvm use 20.5.1     # 切换到20版本

# 设置默认版本
nvm alias default 18.17.1

# 查看当前使用的版本
nvm current

# 查看所有可用版本
nvm list

# 在特定版本下运行命令
nvm exec 16.20.2 node --version

# 显示可用命令
nvm --help

# 卸载指定版本(删除文件夹)
nvm uninstall 12.22.12

因为我们时离线安装,所有nvm install version其实不可用的,上述步骤就是手动的实现这个命令。

NVM常见问题

Linux找不到nvm命令

# 手动加载NVM
source ~/.nvm/nvm.sh

# 或者重新加载bashrc
source ~/.bashrc

切换版本后 node 命令无效

# Windows: 重新打开命令行窗口
# Linux/Mac: 检查 PATH 环境变量
echo $PATH

# 手动设置PATH
export PATH="$NVM_DIR/versions/node/v18.17.1/bin:$PATH"

npm 全局包丢失

这个是个比较常见的问题:

  • 每个node版本有独立的全局包目录
  • 切换版本后需要重新安装全局包

node版本无法切换

# 检查版本目录是否正确
ls -la ~/.nvm/versions/node/

# 手动设置PATH
export PATH="$NVM_DIR/versions/node/v18.17.1/bin:$PATH"

权限问题

# 修复NVM目录权限
chmod -R 755 ~/.nvm
chmod +x ~/.nvm/nvm.sh

NVM卸载

windows卸载

把安装目录和环境变量删除即可

linux卸载

如果需要完全卸载:

# 删除NVM目录
rm -rf ~/.nvm

# 从.bashrc中移除NVM配置
sed -i '/# NVM配置/,/# 加载nvm bash补全/d' ~/.bashrc

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

上面的操作我们相当于再内网机器上安装了node和node的版本管理器,这是第一步也是前置准备工作,下面将进入到verdaccio搭建的正式环节。

Verdaccio 安装部署

首先想一个问题,我们的verdaccio是通过npm全局安装的,verdaccio是发布到npm js的,但是我的内网环境是访问不了npm js的?我需要怎么做呢?

下面以windows系统为例,详细说明我们要做的事情:

  • 第一步:可以上网的机器安装verdaccio
  • 第二部:利用verdaccio缓存需要的依赖,包括项目依赖和全局依赖(pnpm等)
  • 第三步:把verdaccio本身以及缓存的依赖全部平移到内部机器
  • 第四步:根据需求动态的更新verdaccio的storage中的依赖和config.yaml
  • 第五步:把更新的storage依赖和config.yaml传输到内部机器下做合并

重复上述步骤便能实现大多数场景的依赖私有化部署(但是某些特殊情况下可能不适用,后续单独说明)。

Windows环境安装部署

Verdaccio安装

我们上面已经安装了node,那我们直接使用npm再上网机上面直接全局安装

# 如果担心有问题,可以使用管理员权限安装
npm install -g verdaccio

# 其他包管理器的安装方式
yarn global add verdaccio
pnpm install -g verdaccio

等待安装完成即可。

安装完成之后执行:

verdaccio --version  # 验证是否安装成功

之后进行启动,启动之后的启动信息要注意查看,这是有用的:

C:\Users\LMX>verdaccio
info --- config file  - C:\Users\LMX\AppData\Roaming\verdaccio\config.yaml
info --- plugin @verdaccio/local-storage successfully loaded (storage)
info --- using htpasswd file: C:\Users\LMX\AppData\Roaming\verdaccio\htpasswd
info --- plugin verdaccio-htpasswd successfully loaded (authentication)
info --- plugin verdaccio-audit successfully loaded (middleware)
info --- plugin @verdaccio/ui-theme successfully loaded (theme)
warn --- http address - http://localhost:4873/ - verdaccio/6.2.1

如果不侧重私有包的管理的话,我们要关注两个地方:

  • C:\Users\LMX\AppData\Roaming\verdaccio\ (配置文件和storage所在路径)
  • http://localhost:4873/ (可视化地址,我们后续会让所有的依赖再这个地址简单显示)
注册安装源

verdaccio安装完成之后,后续一个比较重要的动作就是注册各个包管理器的registry,以便verdaccio可以正常的缓存依赖。

为所有的项目设置全局的注册中心:

npm set config registry http://localhost:4873 # 注册地址就是上面的verdaccio的访问地址

yarn config set registry http://localhost:4873

也可以为某个依赖单独注册:

npm install lodash --registry http://localhost:4873

其他常用的命令:

# 终端窗口打开项目的根目录
npm set registry http://localhost:4873/ --location project

# 单次依赖安装指定注册中心,以lodash为例
npm install lodash --registry http://localhost:4873

上面的命令其实就相当于再你的项目.npmrc下增加了注册中心, 你也可以直接修改.npmrc

registry=http://localhost:4873/

但是现在好多vue3的项目都是monorepo多包管理的,那我们肯定离不开pnpm,其实这里有扩展出来一点知识点,

纯内网机制下的pnpm全局安装,这是其实用两种实现思路。

  • 思路一: 借助verdaccio缓存pnpm依赖
  • 思路二: 借助npm的pack命令打包pnpm

思路二后续章节展开说明,先按照思路一进行实践。

按照上述步骤启动verdaccio,并指定pnpm的注册源:

verdaccio
npm  install  pnpm  --registry http://localhost:4873

这时verdaccio的窗口会显示pnpm的被缓存安装的信息:

C:\Users\LMX>verdaccio
warn --- This is a deprecated method, please use runServer instead
info --- config file  - C:\Users\LMX\AppData\Roaming\verdaccio\config.yaml
info --- plugin @verdaccio/local-storage successfully loaded (storage)
info --- using htpasswd file: C:\Users\LMX\AppData\Roaming\verdaccio\htpasswd
info --- plugin verdaccio-htpasswd successfully loaded (authentication)
info --- plugin verdaccio-audit successfully loaded (middleware)
info --- plugin @verdaccio/ui-theme successfully loaded (theme)
warn --- http address - http://localhost:4873/ - verdaccio/6.2.1
info <-- 127.0.0.1 requested 'GET /pnpm'
http <-- 200, user: null(127.0.0.1), req: 'GET /pnpm', bytes: 0/0
info --- making request: 'GET https://registry.npmjs.org/pnpm'
http --- 200, req: 'GET https://registry.npmjs.org/pnpm' (streaming)
http --- 200, req: 'GET https://registry.npmjs.org/pnpm', bytes: 0/5275788
http <-- 200, user: null(127.0.0.1), req: 'GET /pnpm', bytes: 0/779825
info <-- 127.0.0.1 requested 'POST /-/npm/v1/security/advisories/bulk'
http <-- 200, user: null(127.0.0.1), req: 'POST /-/npm/v1/security/advisories/bulk', bytes: 40/0
info <-- 127.0.0.1 requested 'GET /pnpm/-/pnpm-10.32.1.tgz'
http <-- 200, user: null(127.0.0.1), req: 'GET /pnpm/-/pnpm-10.32.1.tgz', bytes: 0/0
info --- making request: 'GET https://registry.npmjs.org/pnpm/-/pnpm-10.32.1.tgz'
http <-- 200, user: null(127.0.0.1), req: 'POST /-/npm/v1/security/advisories/bulk', bytes: 40/2
http <-- 200, user: null(127.0.0.1), req: 'GET /pnpm/-/pnpm-10.32.1.tgz', bytes: 0/4524746

之后去verdaccio的storage目录查看是否被正常的缓存了。

正常pnpm文件夹下有两个文件:

package.json
pnpm-10.32.1.tgz

正常的话,tgz的包是有大小的,当是ok的时候要注意了,当然也不可能只有pnpm这个一个依赖,它是依赖树,这里只是举例说明。

注意点:

我们使用verdaccio做依赖缓存时可能会遇到,依赖没有缓存到的情况,确实存在这种情况,一般的处理思路如下:

  • 清空缓存再次进行安装
  • 重启verdaccio再次进行安装
  • 条件允许的话删除整个verdaccio缓存再次安装

相关命令如下:

npm  cache clean --force
pnpm  store  prune
配置文件

上面配置完注册源了,我们需要来了解一下俩个文件config.yaml.verdaccio-db.json, 这两个文件对我们后续操作有帮助,需要了解它。

config.yaml

这个文件是和storage再同级目录的,文件的配置内容如下,我们需要了解其中的一些配置项:

# 缓存依赖的存储位置
storage: ./storage

# 监听所有网卡(让其他机器能访问)
listen: 0.0.0.0:4873

web:
  title: Verdaccio
auth:
  htpasswd:
    file: ./htpasswd

# 内网机器的一定要关闭uplinks
uplinks:
  npmjs:
    url: https://registry.npmjs.org/
    cache: true

packages:
  "@*/*":
    access: $all
    publish: $authenticated
    unpublish: $authenticated
    proxy: npmjs
  "**":
    access: $all
    publish: $authenticated
    unpublish: $authenticated
    proxy: npmjs #  内网机绝对不能有这行
server:
  keepAliveTimeout: 60
middlewares:
  audit:
    enabled: true
log:
  type: stdout
  format: pretty
  level: http
i18n:
  web: en-US

cache属性官方的表述: 设置cache为false可以帮助节省你的硬盘空间。 这将避免存储tarballs,但是它将保留元数据在文件夹里。

其实官方的解释有点晦涩,其实可以这样理解:

  • cache: true(默认值); 从上游拉取的tarball会缓存到storage目录下,下次请求直接从本地返回
  • cache: false; tarball不会写入磁盘,但是包的元数据(package.json、版本列表等)依然会被缓存,每次请求tarball都会请求上游。

在解释一下tarball是啥? 答:是依赖包的tgz文件。

这个配置设置不当也是导致缓存不能进行的一个重要原因。

更近一步说明:

⚠️ 关键点:内网机:物理隔离环境下,一定要去掉 proxy: npmjs,否则每次装包都会卡在尝试连接外网。

⚠️ 关键点:外网机:可以上网的机器,不要去掉proxy但是cache不设置或者设置为true

.verdaccio-db.json

我们怎么理解.verdaccio-db.json

官方给的说法是:

微型数据库用于存储用户发布的私有包。 该数据库基于JSON文件,其中包含已发布的私有包列表以及用于令牌签名的秘密令牌。 首次启动应用程序时会自动创建。

文件的内容如下:

{ "list": [], "secret": "KzZPQifqvCrV7HBMyVPb1C9+FdWteKqe" }

我这里放出了secret(令牌密钥), 正常来说这个是不能暴漏的,只做演示用。

启动verdaccio,访问: http://localhost:4873, 我们会发现什么也没有,那是因为我们的list: []是空的。

只用我们发部私服npm包时(发布私服包verdaccio才主动写入数据库文件),list才会更新。

但是我们想要verdaccio承担私用npm的方式,所有我们想要我们已经缓存的包放追加到list中,因此我们需要编写一个js文件sync-verdaccio-db.js来更新它:

主要实现的思路是访问:http://localhost:4873/-/all,获取所有的依赖数据。

const fs = require("fs");
const path = require("path");
const http = require("http");

const VERDACCIO_URL = "http://localhost:4873";
// 根据实际路径调整
const DB_PATH = path.join(__dirname, "storage", ".verdaccio-db.json");

/**
 * 获取所有包名(从/-/all)
 * @returns
 */
function fetchPackages() {
  return new Promise((resolve, reject) => {
    http
      .get(`${VERDACCIO_URL}/-/all`, (res) => {
        let data = "";
        res.on("data", (chunk) => (data += chunk));
        res.on("end", () => {
          try {
            const json = JSON.parse(data);
            resolve(Object.keys(json)); // 返回包名数组
          } catch (e) {
            reject(e);
          }
        });
      })
      .on("error", reject);
  });
}

/**
 * 更新数据库文件
 * @param {*} packages
 */
function updateDb(packages) {
  let db;
  try {
    db = JSON.parse(fs.readFileSync(DB_PATH, "utf8"));
  } catch (e) {
    // 文件不存在或损坏,创建一个新的
    db = {
      list: [],
      secret: require("crypto").randomBytes(16).toString("hex"),
    };
  }
  // 去重并排序(可选)
  db.list = [...new Set(packages)].sort();
  fs.writeFileSync(DB_PATH, JSON.stringify(db, null, 2));
  console.log(
    `[${new Date().toISOString()}] Database updated with ${db.list.length} packages.`,
  );
}

/**
 * 主函数
 */
async function main() {
  try {
    const packages = await fetchPackages();
    updateDb(packages);
  } catch (err) {
    console.error("Sync failed:", err.message);
  }
}

main();

注意: 我们上面的做法只是想让安装了哪些依赖都显示出来,依赖是有依赖树的,这个默认都理解,不做过多解释!

先启动verdaccio, 再storage的同级目录使用node执行sync-verdaccio-db.js文件即可,如下:

node  .sync-verdaccio-db.js

⚠️ 完成之后重启verdaccio,再浏览器中打开http://localhost:4873 , 可以查看缓存的依赖了。

创建用户

verdaccio的用户注册和登录之类的也加单说明一下,可以后续会用的上,但不是当前文章的侧重点。

使用npm 8或更低版本时, adduser或login都可以同时创建用户并登录。

npm adduser --registry http://localhost:4873

在 npm@9 之后的版本,这两个命令分开工作:

npm login --registry http://localhost:4873
npm adduser --registry http://localhost:4873

默认情况下,两个命令都依赖于web登录,添加 --auth-type=legacy 可以使用之前的登录方式。

内网部署

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

其实也有两种思路:

  • 思路一: 使用npm的pack打包verdaccio, 之后再内网机器使用npm全局安装
  • 思路二: 把上网机器上的安装的verdaccio直接复制到内网机(个人推荐最可靠方式)

思路一感觉上规范,但是可能遇到意想不到的问题,思路二在windows环境成功率更高,先介绍思路二,在说明思路一。

上面已经全局安装了,我们需要查看全局安装路径

npm  config  get  prefix

输出结果如下:

C:\Users\LMX>npm  config  get  prefix

C:\nvm4w\nodejs  # 这个路径是你自己的,每个人的安装路径都不一样

进入到安装目录复制相关文件到内网机器下npm的相同目录

需要复制的文件包括(verdaccio、verdaccio.cmd、verdaccio.ps1和node_modules/verdaccio文件夹):

image-20251126155020273.png

image-20251126155420136.png

最后内网机器验证verdaccio是否成功

verdaccio --version

verdaccio安装成功之后,启动verdaccio即可,在将上述保存的storage和config.yaml复制到内网机器下verdaccio缓存目录

思路一的方式虽然有点野路子,但是是在windows下尝试成功概率比较高的方式。

稍微解释一下为啥思路一的简单粗暴:

我们安装依赖的时候不是单个的依赖,却是复杂的依赖树,我们直接拷贝node_modules/verdaccio 相当于跳过了复杂依赖树的查找和安装过程,这种操作相当于是一个环境依赖平移的过程。

下面在来说明一下看似正规的思路一:

思路一要想成功需要很重要的一点,不光要npm pack verdaccio,而且要npm pack每一个verdaccio需要的子依赖,极其繁琐,容易出错。

那我们是否可以直接打包node_modules/verdaccio,类似于方案二的思想呢?那其实有回到思路二,这里先不做过多的说明, 其实本质上就是思路二脚本化和标准化的一个过程。

但是我们可以通过思路一和思路二想到一个更优的实践方式:

用 verdaccio 来引导 verdaccio

先用思路二在内网机上部署完verdaccio,在外网启动verdaccio,让它缓存verdaccio自身及其所有依赖,然后把存储数据一起复制到内网机verdaccio的缓存下。 其他内网机直接通过已安装的verdaccio的内网机安装verdaccio即可。

上面所有的安装都是以windows进行举例说明的,那当我们的开发环境是linux的时候,我们需要怎么做呢?

通常来说前端大部分的开发环境是都是windows的,所有windows部分才是我们最关心的!

正好借此说明一下不通过verdaccio缓存pnpm, 通过打包的方式平移pnpm

Linux环境安装部署

linux环境简短说明,大家了解即可,不是本篇文章的侧重点。

Verdaccio安装

# 安装 Verdaccio 和 pnpm
npm install -g verdaccio pnpm

# 验证安装
verdaccio --version
pnpm --version

确认安装结构

PREFIX=$(npm config get prefix)

# 查看可执行文件类型(确认是否为软链接)
ls -la $PREFIX/bin/ | grep -E "verdaccio|pnpm"
# 示例输出:
# lrwxrwxrwx ... verdaccio -> ../lib/node_modules/verdaccio/bin/verdaccio
# lrwxrwxrwx ... pnpm -> ../lib/node_modules/pnpm/bin/pnpm.cjs

# 查看模块目录
ls $PREFIX/lib/node_modules/ | grep -E "verdaccio|pnpm"

启动verdaccio并下载项目依赖

# 后台启动 Verdaccio
verdaccio &

# 配置 pnpm 使用 Verdaccio
pnpm config set registry http://localhost:4873/

# 安装所有项目依赖(会自动缓存到 Verdaccio)
cd /path/to/project1
pnpm install

cd /path/to/project2
pnpm install

# 重复所有项目...

# 完成后停止 Verdaccio
pkill -f verdaccio

打包所有文件

PREFIX=$(npm config get prefix)
mkdir -p ~/verdaccio-offline-package
cd ~/verdaccio-offline-package

# 打包完整模块目录(含所有子依赖,这是关键)
tar -czf verdaccio-full.tar.gz -C $PREFIX/lib/node_modules verdaccio
tar -czf pnpm-full.tar.gz -C $PREFIX/lib/node_modules pnpm

# 记录软链接目标路径(供内网还原使用)
VERDACCIO_LINK=$(readlink $PREFIX/bin/verdaccio)
PNPM_LINK=$(readlink $PREFIX/bin/pnpm)
PNPX_LINK=$(readlink $PREFIX/bin/pnpx 2>/dev/null || echo "")

cat > link-targets.txt << EOF
VERDACCIO_LINK=$VERDACCIO_LINK
PNPM_LINK=$PNPM_LINK
PNPX_LINK=$PNPX_LINK
EOF

echo "软链接信息已记录:"
cat link-targets.txt

# 打包 verdaccio 配置和依赖缓存
tar -czf verdaccio-data.tar.gz -C ~ .config/verdaccio

# 查看打包结果
ls -lh
# 应该看到:
# verdaccio-full.tar.gz    # Verdaccio 完整模块
# pnpm-full.tar.gz         # pnpm 完整模块
# link-targets.txt         # 软链接记录
# verdaccio-data.tar.gz    # 配置和依赖缓存

du -sh .

内网解压部署

cd /tmp
tar -xzf verdaccio-offline-linux.tar.gz
cd verdaccio-offline-package

# 查看文件
ls -lh

配置npm用户全局安装目录

mkdir -p ~/.npm-global
npm config set prefix ~/.npm-global

echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc

# 确认配置
npm config get prefix

还原模块文件

cd /tmp/verdaccio-offline-package
PREFIX=$(npm config get prefix)

# 确保目标目录存在
mkdir -p $PREFIX/lib/node_modules
mkdir -p $PREFIX/bin

# 还原完整模块(含所有子依赖)
tar -xzf verdaccio-full.tar.gz -C $PREFIX/lib/node_modules/
tar -xzf pnpm-full.tar.gz -C $PREFIX/lib/node_modules/

# 验证模块已还原
ls $PREFIX/lib/node_modules/ | grep -E "verdaccio|pnpm"

还原软链接

PREFIX=$(npm config get prefix)

# 读取外网记录的软链接目标
source /tmp/verdaccio-offline-package/link-targets.txt

# 建立软链接
ln -sf $PREFIX/lib/node_modules/$VERDACCIO_LINK $PREFIX/bin/verdaccio
ln -sf $PREFIX/lib/node_modules/$PNPM_LINK $PREFIX/bin/pnpm

# pnpx 可选
if [ -n "$PNPX_LINK" ]; then
    ln -sf $PREFIX/lib/node_modules/$PNPX_LINK $PREFIX/bin/pnpx
fi

# 验证软链接
ls -la $PREFIX/bin/ | grep -E "verdaccio|pnpm"

还原Verdaccio数据

tar -xzf /tmp/verdaccio-offline-package/verdaccio-data.tar.gz -C ~

# 验证
ls -la ~/.config/verdaccio/
ls ~/.config/verdaccio/storage/

设置systemd服务(可选)

PREFIX=$(npm config get prefix)

sudo tee /etc/systemd/system/verdaccio.service > /dev/null << EOF
[Unit]
Description=Verdaccio Private NPM Registry
After=network.target

[Service]
Type=simple
User=$USER
WorkingDirectory=$HOME
Environment=PATH=$PREFIX/bin:$HOME/nodejs/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=$PREFIX/bin/verdaccio
Restart=on-failure
RestartSec=10
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl start verdaccio
sudo systemctl enable verdaccio

# 查看状态
sudo systemctl status verdaccio

# 查看日志
sudo journalctl -u verdaccio -f

缺失内容

上述我们只提到了最常见的场景,但是还有几类场景是不能忽视的,由于篇幅原因,这里先列出来,不做展开了,后续在补充。

  • electron/tauri等桌面端构建(涉及native binary 依赖的处理)
  • 二进制依赖, 如何处理 node-gyp、prebuild等
  • 私有业务组件库发布流程为涉及说明

扩展和思考

其实理论上有更加成熟的方案,Nexus Repository Manager或者淘宝 cnpm方案,但是cnpmcore对纯物理隔离部署有一定的要求,上述方案也是仅针对前端开发者去部署的,有更好的方案可以一起讨论。

文章同步地址:www.liumingxin.site/blog/detail…

基于 wujie.js 进行微前端融合

2026年3月12日 10:49

项目背景

目前项目中需要将A ,B 两个项目进行融合,A ,B 项目有各自的用户token与自己的交互方式,技术栈一致。之前是用跳链的方式,新开一个页面进行操作,给用户的感官是两个项目较为割裂,目前期望让两个项目看起来像一个整体,减少割裂感。

解决方案

  1. iframe内嵌 + 菜单融合
  2. 微前端融合

iframe内嵌 + 菜单融合

iframe内嵌有两种方式:

第一种是将整个B 项目作为一个页面融入A 项目,这样做的好处是切换页面不会重复请求,session,local等存储便于管理,不会出现样式覆盖。缺点是依旧有一些割裂感,且B 项目是整体作为一个页面,感官上很奇怪。

第二种是将B 项目的多个页面融入A 项目的菜单,整体感官上会是一个整体,但是切换页面的时候容易造成重复请求,白屏等情况,用户体验较差

微前端融合——qiankun

qiankun 是一个由阿里巴巴(蚂蚁金服)团队开源的微前端解决方案。它基于Single-SPA进行二次开发和增强,旨在帮助开发者将单体前端应用改造为由多个独立“微应用”聚合而成的架构。‌‌

如果你是使用umi框架进行开发,我建议使用qiankun,因为umi 提供开箱即用的微前端支持,通过简单的配置即可实现微前端融合

qiankun 使用 Proxy沙箱拦截全局变量,通过重写CSS选择器,添加前缀实现样式隔离,依赖 import-html-entry解析HTML。通过 props 传递和 globalState 进行全局状态管理。基于数据流传递内容

微前端融合——wujie

Wujie 是由‌京东物流技术团队自主研发的微前端框架,用于解决大型前端应用在模块解耦、独立部署、技术栈兼容等方面的挑战。

如果你是使用 vue 进行页面开发,同时期望用尽可能少的改动实现简单的微前端融合,我推荐使用 wujie。

无界使用iframe 作为JS运行沙箱,通过 WebComponent 将渲染内容映射到主应用上,既享受了iframe对JS的绝对隔离,又避免了iframe的UI通信和样式局限性。CSS通过样式作用域和 Shadow-DOM机制,隔离性优于qiankun的Proxy模式。原生支持子应用保活,预加载等功能,开发改动量小。

Wujie 接入

详细代码见:wujie/examples at master · Tencent/wujie · GitHub

主应用改造

main.js 改造

// main.js
import WujieVue from "wujie-vue2";
const isProduction = process.env.NODE_ENV === "production";
const { setupApp, preloadApp, bus } = WujieVue;

bus.$on("click", (msg) => window.alert(msg));

// 在 xxx-sub 路由下子应用将激活路由同步给主应用,主应用跳转对应路由高亮菜单栏
bus.$on("sub-route-change", (name, path) => {
  const mainName = `${name}-sub`;
  const mainPath = `/${name}-sub${path}`;
  const currentName = router.currentRoute.name;
  const currentPath = router.currentRoute.path;
  if (mainName === currentName && mainPath !== currentPath) {
    router.push({ path: mainPath });
  }
});

const degrade = window.localStorage.getItem("degrade") === "true" || !window.Proxy || !window.CustomElementRegistry;
const props = {
  jump: (name) => {
    router.push({ name });
  },
};
/**
 * 大部分业务无需设置 attrs
 * 此处修正 iframe 的 src,是防止github pages csp报错
 * 因为默认是只有 host+port,没有携带路径
 */
const attrs = isProduction ? { src: hostMap("//localhost:8000/") } : {};
/**
 * 配置应用,主要是设置默认配置
 * preloadApp、startApp的配置会基于这个配置做覆盖
 */
setupApp({
  name: "react16",
  url: hostMap("//localhost:7600/"),
  attrs,
  exec: true,
  props,
  fetch: credentialsFetch,
  plugins,
  prefix: { "prefix-dialog": "/dialog", "prefix-location": "/location" },
  degrade,
  ...lifecycles,
});

子应用渲染组件实现

//Vue2Sub
<template>
  <!--单例模式,name相同则复用一个无界实例,改变url则子应用重新渲染实例到对应路由 -->
  <WujieVue width="100%" height="100%" name="vue2" :url="vue2Url"></WujieVue>
</template>

<script>
import hostMap from "../hostMap";

export default {
  computed: {
    vue2Url() {
      return hostMap("//localhost:7200/") + `#/${this.$route.params.path}`;
    },
  },
  methods: {
    jump(name) {
      this.$router.push({ name });
    },
  },
};
</script>

<style lang="scss" scoped></style>

编写路由!!!

//router.js
{
    path: "/vue2-sub/:path",
    name: "vue2-sub",
    component: Vue2Sub,
},

总结

wujie 的优势很明显,代码改动量小,无需配置子应用前缀,适合快速接入开发的项目,数据传递主要依赖于事件系统,例如我本来想传递一个全局用户信息给子应用,结果发现数据变化时,不会触发任何内容,并且子应用的 props 也没有更新,所以我自己基于事件传递实现了一套自动化获取token然后登陆的逻辑,实现相关功能。此外wujie的文档较为简单,有些功能函数的具体作用也不是很清晰,只能根据自己去猜(也可能是笔者能力较弱,叠个甲,哈哈哈)

深入解析 Vue 包:`vue` 究竟导出了什么?

2026年3月12日 10:48

本文系统梳理 Vue 3 各子包(vue@vue/reactivity@vue/runtime-core@vue/runtime-dom@vue/compiler-core@vue/compiler-dom@vue/shared)的完整导出结构,重点覆盖官方文档未详细说明、日常开发中被忽视但架构价值极高的 API。


一、Vue 包体系全景:Monorepo 架构

很多开发者误认为 vue 是一个完整的实现包,实则它只是一个 Facade(门面模块)

vue(聚合导出层)
│
├── @vue/runtime-dom        ← 浏览器平台渲染层
│    └── @vue/runtime-core  ← 平台无关运行时
│         └── @vue/reactivity ← 纯响应式系统
│
├── @vue/compiler-dom       ← 浏览器模板编译器
│    └── @vue/compiler-core ← 平台无关编译器
│
└── @vue/shared             ← 内部共享工具函数

每个子包都可以独立安装使用,这正是 Vue 3 架构解耦的核心体现。


二、@vue/reactivity:独立响应式引擎

这是 Vue 3 最具突破性的设计——响应式系统与框架完全解耦,可以在任何 JS 环境中独立使用。

npm install @vue/reactivity

2.1 核心响应式原语

ref / shallowRef / customRef

import { ref, shallowRef, customRef } from '@vue/reactivity'

// 标准 ref:深度响应
const state = ref({ a: { b: 1 } })

// shallowRef:只有 .value 本身是响应式,内部属性不追踪
const shallow = shallowRef({ count: 0 })
shallow.value.count++ // 不触发更新
shallow.value = { count: 1 } // 触发更新

// customRef:完全自定义追踪时机(防抖、节流场景极为有用)
function useDebouncedRef(value, delay = 200) {
  let timer
  return customRef((track, trigger) => ({
    get() {
      track() // 手动声明依赖追踪
      return value
    },
    set(newValue) {
      clearTimeout(timer)
      timer = setTimeout(() => {
        value = newValue
        trigger() // 手动触发更新
      }, delay)
    }
  }))
}

customRef 是实现防抖输入、异步数据源等场景的底层利器,但鲜少被人使用。


reactive / shallowReactive / readonly / shallowReadonly

import { reactive, shallowReactive, readonly, shallowReadonly } from '@vue/reactivity'

// shallowReactive:只追踪顶层属性,不深度转换
const state = shallowReactive({
  user: { name: 'Tom' } // user.name 的变化不会触发更新
})

// readonly:深度只读代理,任何写入都会在 dev 环境报警告
const config = readonly({ api: 'https://example.com' })

// shallowReadonly:只有顶层只读,嵌套对象仍可修改
const mixed = shallowReadonly({ nested: { value: 1 } })
mixed.nested.value = 2 // 允许(不报警告)
mixed.nested = {} // 阻止(报警告)

2.2 副作用系统底层 API(极少被直接使用)

effect / ReactiveEffect

effect 是 Vue 响应式系统最底层的副作用原语computedwatch 都是基于它构建的。

import { effect, reactive } from '@vue/reactivity'

const state = reactive({ count: 0 })

// effect 会立即执行,并自动追踪其中访问的响应式数据
const runner = effect(() => {
  console.log('count is:', state.count)
})

state.count++ // 自动重新执行 effect

// 手动停止追踪
runner.effect.stop()

effectScope / getCurrentScope / onScopeDispose

这组 API 是 Vue 3.2 引入的副作用作用域管理机制,专为编写可复用组合函数设计,但日常业务代码中极少被直接使用。

import { effectScope, getCurrentScope, onScopeDispose } from '@vue/reactivity'

// 创建一个作用域,批量管理所有 effect/watch/computed
const scope = effectScope()

scope.run(() => {
  const doubled = computed(() => count.value * 2)
  watchEffect(() => console.log(doubled.value))
})

// 一次性停止作用域内所有副作用——无需逐一手动 stop()
scope.stop()

架构价值:在大型组合函数库(如 VueUse)中,effectScope 是正确管理副作用生命周期的标准方式,避免内存泄漏。

// 在自定义组合函数中使用
function useFeature() {
  const scope = effectScope()
  
  scope.run(() => {
    // 所有 effect 在这里注册
    onScopeDispose(() => {
      // scope 被 stop 时的清理逻辑
      scope.stop()
    })
  })
  
  return scope
}

pauseTracking / resumeTracking / resetTracking

这组 API 允许手动控制依赖追踪的暂停与恢复,是构建高级响应式工具的基础。

import { pauseTracking, resetTracking, effect } from '@vue/reactivity'

effect(() => {
  state.a // 正常追踪
  
  pauseTracking()
  state.b // 访问但不追踪,state.b 变化不会触发 re-run
  resetTracking()
  
  state.c // 恢复正常追踪
})

实际应用场景:在实现 readonly 包装、不希望某些读操作建立依赖时使用。


trackOpBit / triggerRef

import { ref, triggerRef } from '@vue/reactivity'

// triggerRef:强制触发 shallowRef 的更新(直接修改内部结构后手动通知)
const state = shallowRef({ list: [1, 2, 3] })
state.value.list.push(4) // 直接改内部,不触发更新
triggerRef(state)         // 手动强制触发

2.3 工具函数

toRaw / markRaw

import { reactive, toRaw, markRaw } from '@vue/reactivity'

const state = reactive({ data: {} })

// toRaw:获取响应式对象的原始对象(绕过 Proxy),用于性能敏感操作
const raw = toRaw(state) // 直接操作,不触发任何追踪

// markRaw:标记对象永不被转为响应式(第三方库实例、大型数据集等)
const chart = markRaw(new ECharts())
const state2 = reactive({ chart }) // chart 不会被代理

架构意义:在将第三方库对象(如 Canvas 实例、WebSocket 对象)放入响应式状态时,必须用 markRaw 避免性能问题。


proxyRefs

import { proxyRefs, ref } from '@vue/reactivity'

// proxyRefs:自动解包对象中的所有 ref(这正是 setup() 返回值的内部处理机制)
const state = { count: ref(0), name: ref('Tom') }
const proxied = proxyRefs(state)

proxied.count // 直接访问,无需 .value(内部自动解包)

这是 Vue 模板自动解包 ref 的底层实现机制。


isRef / isReactive / isReadonly / isProxy / isShallow

import { ref, reactive, readonly, isRef, isReactive, isReadonly, isProxy, isShallow, shallowRef } from '@vue/reactivity'

isRef(ref(0))           // true
isReactive(reactive({})) // true
isReadonly(readonly({})) // true
isProxy(reactive({}))    // true(reactive 和 readonly 都是 Proxy)
isShallow(shallowRef(0)) // true(Vue 3.2+ 新增)

unref

import { unref, ref } from '@vue/reactivity'

// unref:安全地获取 ref 的值,非 ref 原样返回
function useValue(val) {
  return unref(val) // 不需要判断 isRef
}

useValue(ref(42)) // 42
useValue(42)      // 42

这是编写接受 MaybeRef<T> 参数的组合函数的标准做法。


toRef / toRefs / toValue(Vue 3.3+)

import { reactive, toRef, toRefs, toValue } from '@vue/reactivity'

const state = reactive({ x: 1, y: 2 })

// toRef:从 reactive 对象中创建单个属性的 ref(保持响应式连接)
const xRef = toRef(state, 'x')

// toRefs:将整个 reactive 对象解构为 ref 集合(解构时保持响应性的核心工具)
const { x, y } = toRefs(state)

// toValue(3.3+):同 unref,但还能接受 getter 函数
const val = toValue(() => state.x + 1) // 执行 getter 并返回结果

2.4 计算属性底层

computed 的完整签名

import { computed } from '@vue/reactivity'

// 可写计算属性(文档有提及但很少被用到)
const count = ref(1)
const plusOne = computed({
  get: () => count.value + 1,
  set: (val) => { count.value = val - 1 }
})

plusOne.value = 10
console.log(count.value) // 9

三、@vue/runtime-core:平台无关运行时

这是 Vue 组件模型的核心,包含大量未被官方文档重点介绍的底层 API

3.1 组件内部访问

getCurrentInstance

import { getCurrentInstance } from '@vue/runtime-core'

// 只能在 setup() 或生命周期钩子中调用
const instance = getCurrentInstance()

// 实例上有大量内部属性(仅用于框架级开发,生产代码慎用)
instance.uid          // 组件唯一 ID
instance.type         // 组件定义对象
instance.parent       // 父组件实例
instance.root         // 根组件实例
instance.appContext   // 应用上下文(含全局注册的组件、指令、插件数据)
instance.subTree      // 当前 vnode 子树
instance.isMounted    // 是否已挂载
instance.isUnmounted  // 是否已卸载

场景:编写需要感知组件树的底层库(如 Pinia 内部通过它访问 appContext)。


useAttrs / useSlots

import { useAttrs, useSlots } from '@vue/runtime-core'

// 在 setup 中访问透传的 attrs 和 slots(等效于选项式 API 的 $attrs/$slots)
const attrs = useAttrs()  // 包含 class, style, 事件监听器等非 prop 属性
const slots = useSlots()  // 包含所有插槽的渲染函数

useCssModule(与 <style module> 配合)

import { useCssModule } from '@vue/runtime-core'

// 访问 CSS Modules 的类名映射(通常与 SFC <style module> 配合)
const css = useCssModule()
// css.myClass → 'myClass_hash123_1'(编译后的哈希类名)

3.2 依赖注入高级用法

inject 的完整签名与 InjectionKey

import { provide, inject, InjectionKey } from '@vue/runtime-core'

// 使用 Symbol 作为类型安全的注入键(TypeScript 推荐做法)
const UserKey: InjectionKey<{ name: string }> = Symbol('user')

// 父组件
provide(UserKey, { name: 'Tom' })

// 子组件 - 有完整类型推断
const user = inject(UserKey)            // User | undefined
const user2 = inject(UserKey, { name: 'Guest' }) // 带默认值,类型为 User
const user3 = inject(UserKey, () => ({ name: 'Guest' }), true) // 工厂函数(第三个参数 true 表示是工厂)

3.3 VNode 操作 API

h 渲染函数的完整能力

import { h, resolveComponent, resolveDirective, withDirectives } from '@vue/runtime-core'

export default {
  render() {
    // 渲染组件
    const MyComp = resolveComponent('MyComp') // 按名称解析已注册的全局组件
    
    // 渲染带自定义指令的 vnode
    const dir = resolveDirective('my-directive')
    return withDirectives(
      h('div', 'hello'),
      [[dir, value, argument, modifiers]]
    )
  }
}

cloneVNode

import { h, cloneVNode } from '@vue/runtime-core'

// 克隆 VNode 并可覆盖部分 props(用于高阶组件 HOC 模式)
const original = h('div', { class: 'foo' }, 'hello')
const cloned = cloneVNode(original, { class: 'bar', style: 'color: red' })
// 等效于 h('div', { class: ['foo', 'bar'], style: 'color: red' }, 'hello')

mergeProps

import { mergeProps } from '@vue/runtime-core'

// 智能合并多个 props 对象(class/style 合并,事件监听器链式调用)
const merged = mergeProps(
  { class: 'a', onClick: handler1 },
  { class: 'b', onClick: handler2 }
)
// { class: ['a', 'b'], onClick: [handler1, handler2] }

这是实现透传属性合并、封装 UI 组件库的核心工具。


createVNode / openBlock / createBlock / createElementVNode

这些是编译器输出的运行时助手函数,通常不应在业务代码中直接使用,但理解它们有助于分析编译产物性能。

// 模板编译后生成的代码示意:
import { createElementVNode, openBlock, createElementBlock } from '@vue/runtime-core'

function render() {
  return (openBlock(), createElementBlock("div", null, [
    createElementVNode("span", null, "hello")
  ]))
}

openBlock + createBlock 组合是 Vue 3 **Fragment 优化与靶向更新(patch flag)**的基础。


createTextVNode / createCommentVNode / createStaticVNode

import { createTextVNode, createCommentVNode, createStaticVNode } from '@vue/runtime-core'

createTextVNode('hello')         // 创建文本节点
createCommentVNode('debug info') // 创建注释节点(v-if 的占位符就是它)

// createStaticVNode:将静态 HTML 字符串作为 vnode(SSR hydration 场景)
createStaticVNode('<div>static content</div>', 1)

3.4 异步组件

defineAsyncComponent 完整配置

import { defineAsyncComponent } from '@vue/runtime-core'

const AsyncComp = defineAsyncComponent({
  loader: () => import('./HeavyComponent.vue'),
  
  loadingComponent: LoadingSpinner,  // 加载中显示的组件
  delay: 200,                        // 显示 loading 前的延迟(避免闪烁)
  
  errorComponent: ErrorDisplay,      // 加载失败显示的组件
  timeout: 3000,                     // 超时时间(ms),超时视为失败
  
  // 高级:自定义加载函数(控制重试逻辑)
  onError(error, retry, fail, attempts) {
    if (attempts <= 3) retry() // 最多重试 3 次
    else fail()
  }
})

3.5 内置组件的底层实现

Suspense 深度用法

import { Suspense, defineAsyncComponent } from 'vue'
<Suspense>
  <template #default>
    <AsyncComponent />
  </template>
  <template #fallback>
    <LoadingSpinner />
  </template>
</Suspense>

Suspense 还支持与 async setup() 配合:

export default {
  async setup() {
    // setup 可以是异步的,父级 Suspense 会等待它 resolve
    const data = await fetch('/api/data').then(r => r.json())
    return { data }
  }
}

3.6 调度器 API

nextTick 与内部调度机制

import { nextTick } from '@vue/runtime-core'

// nextTick 返回 Promise,可在 DOM 更新后执行操作
await nextTick()

// 也接受回调(兼容旧写法)
nextTick(() => {
  // DOM 已更新
})

queuePostFlushCb(内部 API,谨慎使用)

import { queuePostFlushCb } from '@vue/runtime-core'

// 在当前刷新周期结束后异步执行回调(比 nextTick 更底层)
queuePostFlushCb(() => {
  // 所有 DOM 更新和 watchPostEffect 之后执行
})

3.7 生命周期扩展钩子

除了常见的八个生命周期,还有以下较少使用的:

import {
  onRenderTracked,     // 每次响应式依赖被追踪时触发(仅 dev)
  onRenderTriggered,   // 每次重渲染被触发时携带具体原因(仅 dev)
  onActivated,         // KeepAlive 激活时
  onDeactivated,       // KeepAlive 停用时
  onServerPrefetch,    // SSR:组件 prefetch 数据钩子
  onErrorCaptured,     // 捕获子孙组件的错误
} from '@vue/runtime-core'

// onRenderTracked / onRenderTriggered 是性能调试利器
onRenderTriggered((event) => {
  console.log('触发重渲染的原因:', {
    effect: event.effect,
    target: event.target,   // 被修改的对象
    type: event.type,       // 'set' / 'add' / 'delete'
    key: event.key,         // 被修改的属性名
    newValue: event.newValue,
    oldValue: event.oldValue,
  })
})

onRenderTrackedonRenderTriggered 是排查不必要重渲染的终极工具,但极少被开发者使用。


3.8 defineComponent 的类型推断能力

import { defineComponent, PropType } from '@vue/runtime-core'

// defineComponent 的核心价值是 TypeScript 类型推断,而非运行时行为
const MyComp = defineComponent({
  props: {
    user: {
      type: Object as PropType<{ name: string; age: number }>,
      required: true
    },
    // 函数类型 prop
    onClick: Function as PropType<(id: number) => void>
  },
  emits: {
    // 带类型验证的 emits 定义
    change: (value: string) => typeof value === 'string',
    submit: null  // 无验证
  },
  setup(props, { emit, expose, attrs, slots }) {
    // props 此处有完整类型推断
    expose({ publicMethod() {} }) // 限制对外暴露的 API
  }
})

3.9 渲染器相关(高级)

createRenderer / createHydrationRenderer

这是 Vue 跨平台渲染的核心 API,允许创建自定义渲染器。

import { createRenderer } from '@vue/runtime-core'

// 创建一个 Canvas 渲染器(伪代码示意)
const { render, createApp } = createRenderer({
  createElement(type) { /* 创建 Canvas 元素 */ },
  patchProp(el, key, prevVal, nextVal) { /* 更新属性 */ },
  insert(el, parent, anchor) { /* 插入节点 */ },
  remove(el) { /* 删除节点 */ },
  createText(text) { /* 创建文本节点 */ },
  setText(node, text) { /* 更新文本 */ },
  // ...其他 DOM 操作
})

这是 Pixi.js、Three.js、Native 渲染器等 Vue 跨平台项目的基础 API。


四、@vue/runtime-dom:浏览器平台层

runtime-core 之上,runtime-dom 提供了浏览器专属能力。

4.1 内置指令实现

这些通常由编译器自动生成,但在 Render Function 场景下需要手动导入:

import {
  vModelText,      // <input v-model>
  vModelCheckbox,  // <input type="checkbox" v-model>
  vModelRadio,     // <input type="radio" v-model>
  vModelSelect,    // <select v-model>
  vModelDynamic,   // 动态类型的 v-model(根据 input type 自动选择实现)
  vShow,           // v-show
  vOn,             // v-on 事件监听
  vBind,           // v-bind
  withDirectives,  // 应用自定义指令
} from '@vue/runtime-dom'

// 在 render function 中手动使用 v-model
import { h, withDirectives, vModelText } from 'vue'

export default {
  setup() {
    const text = ref('')
    return () => withDirectives(
      h('input', { 'onUpdate:modelValue': val => (text.value = val) }),
      [[vModelText, text.value]]
    )
  }
}

4.2 CSS 过渡钩子(内部实现)

import { Transition, TransitionGroup } from '@vue/runtime-dom'

// TransitionGroup 配置详解(文档常被忽视的属性)
h(TransitionGroup, {
  name: 'list',
  tag: 'ul',           // 渲染的包装元素(默认 span)
  moveClass: 'move',   // FLIP 动画时的 CSS 类名
  appear: true,        // 初次渲染也触发过渡
  css: false,          // 禁用 CSS 过渡,纯 JS 控制
  onBeforeEnter(el) {},
  onEnter(el, done) { /* 必须调用 done() */ },
  onLeave(el, done) { /* 必须调用 done() */ },
})

4.3 SSR 相关

import { createSSRApp } from '@vue/runtime-dom'

// SSR hydration:将服务端渲染的 HTML 与 Vue 状态绑定
const app = createSSRApp(App)
app.mount('#app') // 自动检测已有 HTML,进行 hydration 而非全量渲染

五、@vue/compiler-core:编译器核心

这是 Vue 模板编译管道的核心,纯函数式架构,完全平台无关

5.1 完整编译管道

模板字符串
    ↓
parse()          → AST(抽象语法树)
    ↓
transform()      → 转换 AST(应用各种 transform 插件)
    ↓
generate()       → 渲染函数代码字符串
import { parse, transform, generate, baseParse } from '@vue/compiler-core'

// 解析模板为 AST
const ast = parse('<div>{{ msg }}</div>')

// 对 AST 进行转换(插件化)
transform(ast, {
  nodeTransforms: [
    // 自定义 transform 插件
    (node, context) => {
      if (node.type === 1 /* ELEMENT */ && node.tag === 'div') {
        // 可以修改 AST,添加、删除、替换节点
      }
    }
  ]
})

// 生成代码
const { code } = generate(ast)
console.log(code) // 输出渲染函数字符串

5.2 AST 节点类型(NodeTypes 枚举)

import { NodeTypes } from '@vue/compiler-core'

// 完整节点类型列表(调试 AST 时极为有用)
NodeTypes.ROOT           // 0 - 根节点
NodeTypes.ELEMENT        // 1 - 元素节点
NodeTypes.TEXT           // 2 - 文本节点
NodeTypes.COMMENT        // 3 - 注释节点
NodeTypes.SIMPLE_EXPRESSION // 4 - 简单表达式(如 msg、count + 1)
NodeTypes.INTERPOLATION  // 5 - 插值({{ }})
NodeTypes.ATTRIBUTE      // 6 - 普通属性
NodeTypes.DIRECTIVE      // 7 - 指令(v-if、v-for 等)
NodeTypes.COMPOUND_EXPRESSION // 8 - 复合表达式
NodeTypes.IF             // 9 - v-if 结构
NodeTypes.IF_BRANCH      // 10 - v-if 分支
NodeTypes.FOR            // 11 - v-for 结构
NodeTypes.TEXT_CALL      // 12
NodeTypes.VNODE_CALL     // 13 - createVNode 调用
NodeTypes.JS_CALL_EXPRESSION     // 14
NodeTypes.JS_OBJECT_EXPRESSION   // 15
NodeTypes.JS_PROPERTY            // 16
NodeTypes.JS_ARRAY_EXPRESSION    // 17
NodeTypes.JS_FUNCTION_EXPRESSION // 18
NodeTypes.JS_CONDITIONAL_EXPRESSION // 19
NodeTypes.JS_CACHE_EXPRESSION    // 20 - 带缓存的表达式(v-once / 静态提升)

5.3 编译优化标记(PatchFlags

这是 Vue 3 性能优化的核心机制,编译器通过这些标记让运行时的 diff 算法跳过静态内容:

import { PatchFlags } from '@vue/compiler-core'

PatchFlags.TEXT          // 1  - 动态文本内容
PatchFlags.CLASS         // 2  - 动态 class
PatchFlags.STYLE         // 4  - 动态 style
PatchFlags.PROPS         // 8  - 动态 props(非 class/style)
PatchFlags.FULL_PROPS    // 16 - 有动态键名的 props(如 v-bind="obj")
PatchFlags.HYDRATE_EVENTS // 32 - 含事件监听的节点(SSR hydration 用)
PatchFlags.STABLE_FRAGMENT // 64 - 子节点顺序稳定的 Fragment
PatchFlags.KEYED_FRAGMENT  // 128 - 带 key 的 Fragment
PatchFlags.UNKEYED_FRAGMENT // 256 - 无 key 的 Fragment(v-for 无 key 时)
PatchFlags.NEED_PATCH    // 512 - 需要 patch 但不在上述分类中
PatchFlags.DYNAMIC_SLOTS // 1024 - 动态插槽
PatchFlags.DEV_ROOT_FRAGMENT // 2048 - dev 模式根节点 Fragment
PatchFlags.HOISTED       // -1  - 静态提升节点,永不更新
PatchFlags.BAIL          // -2  - 退出优化,进行完整 diff

理解 PatchFlags 是分析 Vue 模板编译产物、进行极致性能优化的必备知识。


5.4 ShapeFlags(组件形态标记)

import { ShapeFlags } from '@vue/shared' // 实际在 shared 包中

ShapeFlags.ELEMENT                    // 1   - 普通 DOM 元素
ShapeFlags.FUNCTIONAL_COMPONENT       // 2   - 函数式组件
ShapeFlags.STATEFUL_COMPONENT         // 4   - 有状态组件
ShapeFlags.TEXT_CHILDREN              // 8   - children 是文本
ShapeFlags.ARRAY_CHILDREN             // 16  - children 是数组
ShapeFlags.SLOTS_CHILDREN             // 32  - children 是插槽对象
ShapeFlags.TELEPORT                   // 64  - Teleport 组件
ShapeFlags.SUSPENSE                   // 128 - Suspense 组件
ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE // 256
ShapeFlags.COMPONENT_KEPT_ALIVE       // 512
ShapeFlags.COMPONENT                  // 6   - FUNCTIONAL | STATEFUL 的组合

5.5 自定义编译器插件

import { parse, transform, generate, createTransformContext } from '@vue/compiler-core'

// 实现一个自定义 transform:自动给所有 div 添加 data-testid
const myTransform = (node, context) => {
  if (node.type === 1 && node.tag === 'div') {
    node.props.push({
      type: 6, // NodeTypes.ATTRIBUTE
      name: 'data-testid',
      value: { type: 2, content: 'auto-id' }
    })
  }
}

const ast = parse('<div><span>hello</span></div>')
transform(ast, { nodeTransforms: [myTransform] })
const { code } = generate(ast, { mode: 'module' })

六、@vue/compiler-dom:浏览器编译器

compiler-core 基础上扩展了浏览器专属的处理逻辑。

6.1 @vue/compiler-dom vs @vue/compiler-core

能力 compiler-core compiler-dom
模板解析 ✅(扩展 HTML 实体)
v-if / v-for
v-model 基础实现 ✅(区分 input/select/checkbox)
v-on 基础实现 ✅(支持 .stop .prevent 等修饰符)
静态提升
内联事件缓存
SSR 优化

6.2 compile 函数

import { compile } from '@vue/compiler-dom'

// 直接将模板字符串编译为渲染函数代码
const { code } = compile('<div>{{ msg }}</div>', {
  mode: 'module',           // 'module' | 'function'
  prefixIdentifiers: true,  // 是否给变量名加前缀
  hoistStatic: true,        // 静态节点提升
  cacheHandlers: true,      // 内联事件处理器缓存
  ssr: false,               // 是否生成 SSR 渲染代码
  ssrCssVars: '',           // SSR CSS 变量
  isNativeTag: (tag) => true, // 判断是否是原生 HTML 标签
  isCustomElement: (tag) => tag.includes('-'), // 自定义元素判断
  
  // 编译时优化选项
  scopeId: 'data-v-xxxxxx', // Scoped CSS 的 scope ID(SFC 场景)
})

console.log(code)
// import { toDisplayString as _toDisplayString, ... } from "vue"
// export function render(_ctx, _cache) {
//   return (_openBlock(), _createElementBlock("div", null, _toDisplayString(_ctx.msg), ...))
// }

七、@vue/shared:内部工具函数

这个包虽然标注为"内部使用",但其中有不少工具函数在框架级开发中很有价值。

import {
  EMPTY_OBJ,       // Object.freeze({}) — 空对象常量
  EMPTY_ARR,       // Object.freeze([]) — 空数组常量
  NOOP,            // () => {} — 空操作函数
  NO,              // () => false
  
  isArray,         // Array.isArray
  isMap,           // 判断是否为 Map
  isSet,           // 判断是否为 Set
  isDate,          // 判断是否为 Date
  isRegExp,        // 判断是否为 RegExp
  isFunction,      // typeof val === 'function'
  isString,        // typeof val === 'string'
  isSymbol,        // typeof val === 'symbol'
  isObject,        // val !== null && typeof val === 'object'
  isPromise,       // isObject(val) && isFunction(val.then)
  isPlainObject,   // Object.prototype.toString.call(val) === '[object Object]'
  
  extend,          // Object.assign
  hasOwn,          // Object.prototype.hasOwnProperty.call
  camelize,        // kebab-case → camelCase('on-click''onClick')
  capitalize,      // 首字母大写
  hyphenate,       // camelCase → kebab-case(与 camelize 互逆)
  toHandlerKey,    // 'click''onClick'
  
  looseEqual,      // 宽松相等比较(数组、对象深比较,用于 v-model 多选场景)
  looseIndexOf,    // 使用 looseEqual 的 indexOf
  
  invokeArrayFns,  // 批量调用函数数组(生命周期钩子的内部调用方式)
  def,             // Object.defineProperty 封装
  toRawType,       // Object.prototype.toString 取类型名('Map', 'Set', 'Array' 等)
  makeMap,         // 从字符串创建成员检测函数(HTML 标签白名单等场景)
} from '@vue/shared'

// makeMap 示例:
const isHTMLTag = makeMap('div,span,p,a,img,ul,li,...')
isHTMLTag('div')  // true
isHTMLTag('my-comp') // false

八、完整包导出结构总览

Vue 3 包生态
│
├── @vue/reactivity(独立响应式)
│   ├── 核心:ref, reactive, computed, watch
│   ├── 浅层:shallowRef, shallowReactive, shallowReadonly
│   ├── 副作用:effect, effectScope, getCurrentScope, onScopeDispose
│   ├── 追踪控制:pauseTracking, resumeTracking, resetTracking
│   ├── 工具:toRaw, markRaw, proxyRefs, triggerRef, customRef
│   ├── 类型判断:isRef, isReactive, isReadonly, isProxy, isShallow
│   └── 转换:toRef, toRefs, toValue, unref
│
├── @vue/runtime-core(核心运行时)
│   ├── 应用:createApp(通过 runtime-dom 暴露)
│   ├── 组件:defineComponent, defineAsyncComponent
│   ├── VNode:h, createVNode, cloneVNode, mergeProps, createTextVNode
│   ├── 编译助手:openBlock, createBlock, createElementBlock(内部)
│   ├── 实例:getCurrentInstance, useAttrs, useSlots, useCssModule
│   ├── 注入:provide, inject, InjectionKey
│   ├── 生命周期:onMounted...onUnmounted, onRenderTracked, onRenderTriggered
│   ├── 调度:nextTick, queuePostFlushCb
│   ├── 渲染器:createRenderer, createHydrationRenderer
│   └── 内置组件:Suspense, KeepAlive, Teleport(定义在此)
│
├── @vue/runtime-dom(浏览器运行时)
│   ├── 应用:createApp, createSSRApp, render, hydrate
│   ├── 指令实现:vModelText, vModelCheckbox, vShow 等
│   ├── 过渡:Transition, TransitionGroup
│   └── 重新导出:全部 runtime-core 导出
│
├── @vue/compiler-core(编译器核心)
│   ├── 编译管道:parse, transform, generate
│   ├── 枚举:NodeTypes, PatchFlags, ElementTypes
│   ├── 工具:createTransformContext, traverseNode
│   └── 助手:createSimpleExpression, createCallExpression 等
│
├── @vue/compiler-dom(浏览器编译器)
│   ├── 顶层:compile(模板字符串 → 渲染函数代码)
│   └── 重新导出:全部 compiler-core 导出
│
└── @vue/shared(内部工具)
    ├── 类型判断:isArray, isFunction, isObject, isPromise...
    ├── 字符串:camelize, hyphenate, capitalize, toHandlerKey
    ├── 标志:ShapeFlags, PatchFlags(部分)
    └── 工具:makeMap, extend, hasOwn, looseEqual, invokeArrayFns

九、架构层次与使用建议

适用场景 是否可独立使用
@vue/reactivity 非 UI 的响应式状态管理 ✅ 完全独立
@vue/runtime-core 自定义渲染器 ✅ 需搭配自定义渲染器
@vue/runtime-dom 浏览器 Vue 应用 ✅ 等价于 vue(无编译器)
@vue/compiler-core 构建工具插件、代码转换 ✅ 独立使用
@vue/compiler-dom 运行时模板编译(少用) ✅ 独立使用
@vue/shared 框架级工具函数 ⚠️ 内部包,API 随版本变化
vue 标准应用开发 ✅ 聚合所有能力

十、总结

Vue 3 的包体系围绕关注点分离构建:

  • 响应式渲染 完全解耦,@vue/reactivity 可独立运行
  • 平台无关运行时浏览器平台实现 分离,createRenderer 支持跨平台
  • 编译器 同样分层,compiler-core 可扩展用于 Vite 插件、Babel 转换等
  • PatchFlagseffectScope 等低层 API 是框架性能优化的真正秘密

理解这套架构,不仅能写出更高质量的组合函数和 UI 组件库,也能在需要时深入框架层进行扩展——这是从"会用 Vue"到"精通 Vue 架构"的关键跨越。

低代码工具很多,为什么 RollCode 更像一套「页面生产平台」

2026年3月12日 10:28

过去几年,低代码工具几乎成了企业数字化里的“标配”。从表单搭建到活动页面,从运营后台到数据看板,各类拖拽工具层出不穷。但很多前端开发者用过几次之后都会产生一种微妙的感觉:这些工具很适合“搭页面”,却很难真正进入团队的工程体系。

原因其实很简单。大多数低代码工具只解决了一件事——让页面更快被拖出来。而真正的业务场景里,一个页面的生命周期远不止“拖拽完成”这么简单。

页面需要:和代码仓库共存、能复用模板、持开发者自定义逻辑、可以静态发布、可以被运营同学快速修改。

当这些能力被拆开在不同工具里时,团队的效率并不会真正提升。这也是我最近重新看了一遍 RollCode 官网之后的一个直观感受:

它想做的事情,已经不只是低代码。 它更像是一套完整的 页面生产平台(Page Production Platform)【传送门】


一、传统低代码工具的问题在哪里?

很多低代码产品的定位,其实非常清晰:

让不会写代码的人,也能快速搭出页面。

这个目标没有问题,但在真实团队协作中会遇到一个非常典型的断层。

通常的流程会是这样:

  1. 运营同学在低代码平台拖拽页面
  2. 页面上线
  3. 业务复杂度增加
  4. 前端开发重新写一套页面

于是就形成了一个循环: “低代码做原型 → 开发重写正式版本” 这种模式的效率其实并不高。因为低代码平台做出来的页面,往往存在几个工程问题:

  • 代码结构不可控
  • 自定义能力有限
  • 组件体系不统一
  • 很难接入现有前端工程

所以很多前端团队对低代码的态度一直很微妙:能用,但很难真正进入工程体系。


二、RollCode 的思路:把“搭建”和“开发”放进同一套系统

当你仔细看 RollCode 的能力结构时,会发现一个很明显的设计思路:

它并没有把“拖拽”和“代码”做成两个世界。

而是尝试把它们融合到同一个生产流程里。

从架构角度看,大致可以理解为下面这层结构。

在这套结构里,页面并不是一个“编辑器里的成品”。它更像是一份 可持续迭代的页面配置。这带来一个很重要的变化:

页面既可以拖拽搭建,也可以被开发者扩展。这种结构对于前端团队来说就非常关键了。


三、它和传统低代码最大的差别:工程能力

如果用一个比较直观的方式理解,可以看下面这个能力对比。

暂时无法在飞书文档外展示此内容

从这个角度看,RollCode 的定位其实更接近:Page Builder + Frontend Framework 的结合体。 它解决的并不是“如何拖拽页面”。而是:如何把页面生产流程工程化。


四、从“页面搭建”升级为“页面生产链路”

如果站在团队效率的角度看,一个营销页面从需求到上线,大致会经历这些环节:

  1. 需求设计
  2. 页面搭建
  3. 开发扩展
  4. 发布上线
  5. 模板复用

很多公司会用 3~4个工具来完成这件事。

而 RollCode 的思路是把这些能力放进同一个平台。

这样带来的直接变化是:页面从一次性产物变成可复用资产。

例如:

  • 活动页模板
  • 落地页模板
  • 产品介绍页模板

这些都可以沉淀在系统里。当业务需要新页面时,只需要在模板上做轻量修改。页面生产效率会明显提升。


五、开发者为什么会喜欢这种结构

对于开发者来说,一个平台好不好用,其实只看两件事:

1、有没有工程能力 2、有没有扩展能力

RollCode 在这两个点上的设计,其实比较接近开发者的习惯。

第一点是 组件体系。组件并不是编辑器里的黑盒,而是可以被扩展和复用的能力模块。

第二点是 代码融合能力。很多低代码平台只允许写少量脚本。

而在 RollCode 的设计里:页面既可以通过可视化搭建,也可以通过代码扩展。

这样一来,团队协作就会变得非常顺滑。运营可以快速搭建页面结构。开发者可以补充复杂逻辑。

两者并不会互相冲突。


结尾

如果说传统低代码工具解决的是 “不会写代码的人如何做页面” 。那么 RollCode 更像是在解决另一个问题:

如何让页面搭建、开发、复用和发布成为同一条生产链路。 当这条链路被打通之后,页面就不再是一次性的交付物。

它会逐渐变成团队可复用的资产。这也是为什么在看完 RollCode 的设计之后,我更愿意把它理解为:

一套面向团队协作的页面生产平台。

如果你也在做营销落地页、活动页面或者企业官网系统,这种“可视化 + 工程能力”的组合,其实值得认真研究一下。

以上就是本次分享。我是安东尼(TUARAN),持续关注大模型应用、AI工程化与自动化系统。欢迎一起交流 OpenClaw、Agent、数字员工 等实践,也欢迎共创  《前端周刊》  、加入 博主联盟加我或进群,一起做点有意思的 AI 项目。

Flutter StatefulWidget让界面动起来(六)

作者 HelloReader
2026年3月12日 10:22

前言

在上一篇文章中,我们给 Birdle 游戏添加了输入框和提交按钮,玩家已经可以输入猜测的单词了。但有一个问题——输入完按回车后,棋盘上什么都没变。单词虽然被提交了(在控制台能看到打印),但界面纹丝不动。

这是因为到目前为止,我们所有的组件都是 StatelessWidget(无状态组件)。它们一旦创建就"定型了",不会自动更新。

今天这篇文章基于官方教程的「Stateful Widgets」章节,我们将学习 Flutter 中最关键的概念之一——StatefulWidget(有状态组件)和 setState。学完之后,Birdle 游戏就真正能玩起来了!


一、为什么需要 StatefulWidget?

1.1 回顾 StatelessWidget 的局限

StatelessWidget 就像一张打印好的照片——内容在创建时就确定了,之后不会再变。这对于显示固定内容(比如标题、图标)来说足够了。

但游戏棋盘不是固定的。每当玩家提交一个猜测,棋盘就需要更新:显示猜测的字母,并用绿色、黄色、灰色标记对错。这种需要在运行过程中改变外观或数据的场景,就需要用到 StatefulWidget

1.2 StatefulWidget 的工作原理

StatefulWidget 由两个类组成:

  • Widget 类本身:和 StatelessWidget 一样,它是不可变的(immutable)。你可以理解为"外壳"。
  • State 类:一个长期存在的伴侣对象,保存着可变的数据。当数据改变时,它能触发界面重新构建。

打个比方:StatefulWidget 就像一块白板。白板本身(Widget)不会变,但白板上写的内容(State)可以随时擦掉重写。每次内容变了,Flutter 就会重新"拍一张照"(调用 build 方法),把最新的内容展示在屏幕上。

1.3 基本结构预览

// StatefulWidget 的标准写法由两个类组成:

// 第一个类:Widget 本身(不可变的"外壳")
class ExampleWidget extends StatefulWidget {
  ExampleWidget({super.key});

  // createState() 方法创建并返回对应的 State 对象
  @override
  State<ExampleWidget> createState() => _ExampleWidgetState();
}

// 第二个类:State 对象(保存可变数据 + build 方法)
// 命名惯例:_WidgetName + State,前面加下划线表示私有
class _ExampleWidgetState extends State<ExampleWidget> {
  // 可变数据放在这里
  // ...

  // build 方法从 Widget 类移到了 State 类中
  @override
  Widget build(BuildContext context) {
    return Container();
  }
}

二、将 GamePage 转换为 StatefulWidget

2.1 为什么是 GamePage?

回想一下我们的组件结构:GamePage 持有 Game 对象,而 Game 对象保存着所有猜测记录。每次玩家提交猜测,Game 的数据就会改变,棋盘就需要重新绘制。所以 GamePage 是需要"变成有状态"的那个组件。

TileGuessInput 不需要改——它们只是接收数据并展示,本身不管理任何会变化的数据。

2.2 转换步骤

转换过程分为三步。VS Code 的 Flutter 插件提供了"快速辅助"功能,可以一键完成转换(光标放在类名上,按 Ctrl + .,选择"Convert to StatefulWidget")。但为了理解原理,我们手动来做一次。

第一步:把 GamePage 的父类从 StatelessWidget 改为 StatefulWidget,并添加 createState 方法。

第二步:创建 _GamePageState 类,继承 State<GamePage>

第三步:把 build 方法和所有可变属性从 GamePage 移到 _GamePageState 中。

转换后的代码:

// ===== 转换前(StatelessWidget)=====
// class GamePage extends StatelessWidget {
//   GamePage({super.key});
//   final Game _game = Game();
//   @override
//   Widget build(BuildContext context) { ... }
// }

// ===== 转换后(StatefulWidget)=====

// 第一个类:GamePage 本身变成了一个轻量的"外壳"
// 只负责创建 State 对象,不再包含 build 方法
class GamePage extends StatefulWidget {
  GamePage({super.key});

  // createState() 创建并返回 _GamePageState 实例
  // Flutter 内部会调用这个方法来获取 State 对象
  @override
  State<GamePage> createState() => _GamePageState();
}

// 第二个类:_GamePageState 保存可变数据和 build 方法
// 下划线 _ 开头表示这个类是私有的,只在当前文件中可见
class _GamePageState extends State<GamePage> {
  // _game 被移到了 State 类中
  // 因为它保存着可变的游戏数据(猜测记录等)
  final Game _game = Game();

  // build 方法也移到了 State 类中
  // 每次调用 setState 后,Flutter 会重新调用这个方法
  @override
  Widget build(BuildContext context) {
    // ... 界面代码(下一节补充完整)
  }
}

三、用 setState 触发界面更新

3.1 setState 是什么?

setState 是 State 类中最重要的方法。它的作用是:

  1. 执行数据修改(在传入的函数中修改状态数据)
  2. 通知 Flutter:"我的数据变了,请重新调用 build 方法,重绘界面"

如果你修改了数据但没有调用 setState,数据确实会变,但 Flutter 不知道需要重绘,用户在屏幕上看不到任何变化。

3.2 在 GamePage 中使用 setState

现在把 onSubmitGuess 回调中的 print 替换为真正的游戏逻辑:

GuessInput(
  onSubmitGuess: (String guess) {
    // setState 告诉 Flutter:"我要修改数据了,改完请重绘界面"
    setState(() {
      // 在 setState 内部修改游戏状态
      // _game.guess() 会:
      // 1. 把玩家猜的单词与目标单词逐字母比较
      // 2. 给每个字母标记 hit/partial/miss
      // 3. 把结果保存到 _game.guesses 列表中
      _game.guess(guess);
    });
    // setState 执行完毕后,Flutter 会自动重新调用 build 方法
    // build 方法中的 for 循环会遍历更新后的 _game.guesses
    // 棋盘上就会显示出玩家的猜测和颜色标记
  },
),

3.3 数据流动的完整过程

让我们梳理一下从用户输入到界面更新的完整流程:

用户输入 "abbey" → 按回车
        ↓
GuessInput._onSubmit() 被调用
        ↓
onSubmitGuess("abbey") 回调触发
        ↓
setState(() { _game.guess("abbey"); })
  ├── _game.guess("abbey") → 数据更新
  │     ├── 字母 a: hit(位置和字母都对)→ 绿色
  │     ├── 字母 b: partial(字母对,位置不对)→ 黄色
  │     ├── 字母 b: miss(多余的 b)→ 灰色
  │     ├── 字母 e: miss → 灰色
  │     └── 字母 y: miss → 灰色
  └── Flutter 收到通知 → 重新调用 build()build() 重新执行,遍历更新后的 _game.guesses
        ↓
棋盘第一行的 Tile 更新:显示字母和颜色

四、StatelessWidget vs StatefulWidget 对比

特性 StatelessWidget StatefulWidget
数据是否可变 不可变,创建后固定 State 对象中的数据可变
是否能自动更新 UI 不能 调用 setState 后自动重绘
类的数量 1 个类 2 个类(Widget + State)
build 方法位置 在 Widget 类中 在 State 类中
适用场景 静态展示(标题、图标、固定布局) 需要响应交互或数据变化的界面
生命周期 短暂,随时可能被重建 State 对象长期存在
本系列中的例子 Tile、GuessInput、MainApp GamePage(本篇转换)

简单的判断规则:如果一个组件的内容在创建后永远不需要变化,用 StatelessWidget;如果它的内容可能在运行过程中改变,用 StatefulWidget。


五、常见误区

5.1 不要忘记调用 setState

// ❌ 错误:修改了数据,但没有调用 setState
// 数据确实变了,但 Flutter 不知道,界面不会更新
onSubmitGuess: (String guess) {
  _game.guess(guess);  // 数据变了,但界面没动
},

// ✅ 正确:用 setState 包裹数据修改
// Flutter 会在 setState 执行完后重新调用 build
onSubmitGuess: (String guess) {
  setState(() {
    _game.guess(guess);  // 数据变了,界面也跟着更新
  });
},

5.2 不要把所有组件都变成 StatefulWidget

只有真正需要管理可变数据的组件才需要是 StatefulWidget。在我们的应用中,只有 GamePage 需要转换,因为它持有会变化的 Game 对象。Tile 和 GuessInput 保持 StatelessWidget 就好——它们只是接收数据并展示。

5.3 VS Code 快速转换技巧

不需要手动做上面的转换工作!VS Code 提供了快捷操作:

1. 把光标放在 StatelessWidget 类名上
2. 按 Ctrl + .(或 Cmd + . on Mac)
3. 选择 "Convert to StatefulWidget"
4. VS Code 自动完成所有修改

六、本节知识点小结

StatefulWidget: 当组件的外观或数据需要在运行过程中改变时使用。由两个类组成——Widget 类(不可变外壳)和 State 类(保存可变数据 + build 方法)。

State 类: StatefulWidget 的伴侣对象,长期存在。通过 createState() 方法创建。可变数据和 build 方法都放在这里。

setState: State 类中最重要的方法。在回调中修改数据时必须用 setState 包裹,它会通知 Flutter 重新调用 build 方法来更新界面。忘记调用 setState 是最常见的新手错误。

转换方法: 将 StatelessWidget 转换为 StatefulWidget 需要三步——改父类、创建 State 类、移动 build 方法和可变属性。VS Code 可以一键完成。


七、下一步学习

Birdle 游戏现在已经可以完整运行了!玩家输入单词、提交猜测、棋盘实时更新颜色。下一课我们将学习隐式动画(Implicit Animations),给方块的颜色变化添加平滑的过渡效果,让游戏体验更加丝滑。

我们下篇文章见!

参考资料:Flutter 官方教程 - Stateful Widgets

vue2升级vue3:图片点击预览出现样式错乱

作者 lemon_yyds
2026年3月12日 10:14

表格内的图片点击预览出现样式错乱

image.png

表格里的图片预览后,预览层只显示在表格区域里,没有覆盖整个页面,而是被页面容器“裁剪”了,看起来像是只在表格区域中间显示一块

这在 Element Plus 的 el-image 预览里是一个比较常见的问题,原因通常是:

1️⃣ 父容器有 transform / overflow 导致层级被限制

很多后台布局(如 el-table 外层、dialogdrawerlayout)会有:

overflow: hidden;
transform: translate3d(...);

这样会导致 fixed / teleport 的层被限制在父容器内

所以预览层就不会覆盖整个页面,而是被裁剪。

2️⃣ preview-teleported 没有开启(Element Plus 关键属性)

Element Plus 中,图片预览需要开启 teleport 到 body

你现在的代码:

<el-image
  class="table-image-preview"
  style="width: 90px; height: 50px"
  :src="scope.row.appendixFiles[0].fullUrlShow"
  :preview-src-list="scope.row.imgList"
  :append-to-body="true"
  fit="cover"
>

append-to-body 其实 不是 el-image 的属性

正确写法应该是:

<el-image
  class="table-image-preview"
  style="width: 90px; height: 50px"
  :src="scope.row.appendixFiles[0].fullUrlShow"
  :preview-src-list="scope.row.imgList"
  preview-teleported
  fit="cover"
/>

✅ 改成这样

<el-image
  class="table-image-preview"
  style="width: 90px; height: 50px"
  :src="scope.row.appendixFiles[0].fullUrlShow"
  :preview-src-list="scope.row.imgList"
  :preview-teleported="true"
  fit="cover"
/>

3️⃣ 如果还不行,再检查这个 CSS

某些布局会有:

.el-table__body-wrapper {
  overflow: hidden;
}

可以测试:

.el-table {
  overflow: visible;
}

不过 一般 preview-teleported 就能解决


📌 最推荐写法(完整)

<el-table-column label="图片" align="center" min-width="90px">
  <template #default="scope">
    <div v-if="scope.row.appendixFiles?.length">
      <el-image
        style="width:90px;height:50px"
        :src="scope.row.appendixFiles[0].fullUrlShow"
        :preview-src-list="scope.row.imgList"
        :preview-teleported="true"
        fit="cover"
      />
    </div>
  </template>
</el-table-column>

💡 额外建议(企业项目常用)

如果你的 imgList 其实只有一张,可以这样写更安全:

:preview-src-list="[scope.row.appendixFiles[0].fullUrlShow]"

避免 imgList undefined。

又遇到生产与开发环境结果不一致问题。。。

作者 真夜
2026年3月12日 10:11

问题展现

功能背景

一个坐席详情页面功能,用到, 聊天记录接口与sse连接实时推送聊天记录,该逻辑为

async useMessageList() {
      if (!this.agentNo) {
        return;
      }

      this.loading = true;
      try {

        const res = await getMessageList({
          agentNo: this.agentNo,
          pageNum: this.pageNum,
          // seachCallId: this.seachCallId,
          pageSize: this.pageSize,
        });

        ...



        this.initSse();
        
      } catch (error) {
        console.error('获取消息列表失败:', error);
        // this.$message.error('获取消息列表失败');
      } finally {
        this.loading = false;
      }
    },
    
     initSse() {
      if (this.wsConnected) {
        return;
      }
      const uuid = crypto.randomUUID();
      this.uuid = uuid;
      const agentNo = `${this.agentNo}:${uuid}`
      
      const wsUrl = 'xxx'
      this.$ws.connect(wsUrl);


    },

问题出在crypto

开发环境上

image.png

生产环境上

image.png

可见该crypto缺少了部分方法。

crypto详解

在mdn文档中crypto是浏览器原生的加密api,版本要求是

image.png

同时也存在一个限制,那就是非localhso与https无法使用randomUUID函数,既这也是生产与开发不一致的罪魁祸首。

image.png

uniapp实现小程序地图导航

效果图:

84eb69d7182579a8aa74a5b5dbd1ec43.jpg
const target = computed(() => ({
lat: data.value?.latitude || 22.525294,
lng: data.value?.longitude || 113.94319,
destination: data.value.address || ''
}));
// 点击按钮触发:先授权定位,再唤起地图
const handleOpenLocation = () => {
wx.getSetting({
success(res) {
const locationAuth = res.authSetting['scope.userLocation']
if (locationAuth === undefined) {
wx.authorize({
scope: 'scope.userLocation',
success() {
openLocationFn();
},
fail() {
console.log('授权失败:', err);
wx.showToast({
title: '拒绝授权后无法使用导航',
icon: 'none'
});
}
});
} else if (locationAuth === false) {
wx.showModal({
title: '需要位置权限',
content: '你已拒绝位置授权,请手动开启:点击右上角「···」→「设置」→「位置信息」→「允许」',
confirmText: '知道了'
});
} else {
openLocationFn();
}
},
fail() {
wx.showToast({
title: '获取权限设置失败',
icon: 'none'
});
}
});
};

// 封装:调用wx.openLocation
const openLocationFn = () => {
wx.openLocation({
latitude: parseFloat(target.value.lat),
longitude: parseFloat(target.value.lng),
name: target.value.destination,
scale: 18,
success() {
console.log('唤起微信地图成功,用户可选择Apple/腾讯/高德导航');
},
fail(err) {
wx.showToast({
title: `唤起失败:${err.errMsg}`,
icon: 'none'
});
}
});
};

vue 2 升级vue3 : element ui 校验红色高亮失去效果

作者 lemon_yyds
2026年3月12日 10:09

element ui 校验红色高亮失去效果

先看代码


// vue2 

.xxxstylename ::v-deep .el-input-number.is-error .el-input__inner {

border-color: #f56c6c;}
// vue3

.xxxstylename :deep(.el-input-number.is-error .el-input__wrapper) {

box-shadow: 0 0 0 1px #f56c6c inset;

}

第一行 ::v-deep 是 Vue 2 中的写法,而第二行 :deep() 是 Vue 3 中推荐的写法

核心对比:

特性 ::v-deep (第一行) :deep() (第二行)
语法形式 伪元素选择器 (Pseudo-element) 伪类函数 (Pseudo-class function)
主要适用 Vue 2 (配合 SCSS/Less 等预处理器) Vue 3 (官方推荐)
兼容性 Vue 3 中仍可用,但已不推荐 Vue 2 中不可用
代码风格 写法相对自由 语法更严谨,将穿透的选择器包裹在函数内

🔍 核心原因分析

Element UI (Vue 2) 的结构逻辑

在 Vue 2 的 Element UI 中,.el-input__inner 就是那个真正的 <input> 标签。

  • DOM 结构.el-input > .el-input__inner (<input>)
  • 样式逻辑:边框 (border) 是直接画在 .el-input__inner 这个 input 元素上的。
  • 你的代码.el-input__inner { border-color: #f56c6c; } —— 有效,因为直接修改了 input 的边框属性。

Element Plus (Vue 3) 的结构逻辑

在 Vue 3 的 Element Plus 中,引入了一个新的概念 .el-input__wrapper 作为视觉容器,而 .el-input__inner 变成了一个透明的“内容层”。

  • DOM 结构.el-input > .el-input__wrapper > .el-input__inner (<input>)

  • 样式逻辑

    • .el-input__inner:默认是透明的(无背景、无边框)。它的作用仅仅是承载文字内容。
    • .el-input__wrapper:负责所有的视觉样式。边框是通过给这个容器添加 box-shadow: inset (内阴影) 来模拟的。
  • 你的代码

    • .el-input__inner { border-color: ... } —— 无效。因为这个 input 元素本身已经没有边框了,它是透明的,你给它加边框在视觉上根本看不到。
    • .el-input__wrapper { box-shadow: ... } —— 有效。因为这才是真正绘制边框的地方。

这并非是 Vue 2 和 Vue 3 的区别,而是 Element UI (Vue 2)  和 Element Plus (Vue 3)  这两个组件库在底层 DOM 结构上的重大变化导致的。

简单来说:Element Plus 废弃了 .el-input__inner 直接控制边框的方式,改用 .el-input__wrapper 阴影来绘制边框。

📌 总结

你遇到的现象完全正常,这是因为 Element Plus 改变了实现方式。

  • Vue 2 (Element UI) :边框属于  .el-input__inner (直接改 border)。
  • Vue 3 (Element Plus) :边框属于  .el-input__wrapper (通过改 box-shadow 模拟)。

所以,在 Vue 3 项目中,如果你想修改输入框的边框颜色(例如错误状态),必须针对 .el-input__wrapper 的 box-shadow 属性进行操作,直接修改 .el-input__inner 的 border 是不起作用的。

前端微前端架构实战指南:构建可扩展的大型应用架构

作者 bluceli
2026年3月12日 10:05

随着前端应用的规模不断扩大,单体应用逐渐暴露出维护困难、部署复杂、团队协作效率低下等问题。微前端架构作为一种新兴的解决方案,正在成为大型前端应用的首选架构模式。本文将深入探讨微前端架构的核心概念、实现方案以及最佳实践。

什么是微前端

微前端是一种将前端应用分解为多个小型、独立的前端应用的架构模式。每个微应用可以独立开发、测试和部署,最终组合成一个完整的前端应用。这种架构模式借鉴了微服务的思想,将后端的微服务理念延伸到了前端领域。

微前端的核心优势包括:

  • 独立部署:每个微应用可以独立部署,不影响其他应用
  • 技术栈无关:不同微应用可以使用不同的技术栈
  • 团队自治:不同团队可以独立开发和维护各自的微应用
  • 增量升级:可以逐步升级技术栈,无需一次性重构整个应用

主流微前端方案对比

1. qiankun

qiankun是阿里开源的基于single-spa的微前端框架,提供了更完善的API和开箱即用的功能。

import { registerMicroApps, start } from 'qiankun';

// 注册微应用
registerMicroApps([
  {
    name: 'reactApp',
    entry: '//localhost:7100',
    container: '#subapp-viewport',
    activeRule: '/react',
  },
  {
    name: 'vueApp',
    entry: '//localhost:7101',
    container: '#subapp-viewport',
    activeRule: '/vue',
  },
]);

// 启动qiankun
start();

优点

  • HTML entry接入方式,使用简单
  • 样式隔离机制完善
  • JS沙箱隔离
  • 资源预加载

缺点

  • 对子应用有侵入性
  • 需要子应用支持导出生命周期函数

2. single-spa

single-spa是微前端领域的鼻祖,提供了基础的微前端能力。

import { registerApplication, start } from 'single-spa';

registerApplication({
  name: 'reactApp',
  app: () => System.import('reactApp'),
  activeWhen: '/react',
  customProps: {},
});

start();

优点

  • 轻量级,核心功能完善
  • 社区活跃,生态丰富
  • 灵活性高

缺点

  • 需要手动处理样式隔离
  • 配置相对复杂

3. Module Federation

Webpack 5推出的Module Federation是另一种微前端实现方案。

// webpack.config.js
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'app1',
      remotes: {
        app2: 'app2@http://localhost:3002/remoteEntry.js',
      },
      shared: ['react', 'react-dom'],
    }),
  ],
};

优点

  • 运行时动态加载模块
  • 共享依赖,减少重复加载
  • 支持双向依赖

缺点

  • 依赖Webpack 5
  • 配置相对复杂

实战案例:构建微前端应用

主应用搭建

// main-app/src/index.js
import { registerMicroApps, start, initGlobalState } from 'qiankun';
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

// 渲染主应用
ReactDOM.render(<App />, document.getElementById('root'));

// 初始化全局状态
const { onGlobalStateChange, setGlobalState } = initGlobalState({
  user: { name: 'guest' },
  token: '',
});

// 注册微应用
registerMicroApps([
  {
    name: 'sub-react',
    entry: '//localhost:3001',
    container: '#subapp-container',
    activeRule: '/sub-react',
    props: {
      onGlobalStateChange,
      setGlobalState,
    },
  },
  {
    name: 'sub-vue',
    entry: '//localhost:3002',
    container: '#subapp-container',
    activeRule: '/sub-vue',
    props: {
      onGlobalStateChange,
      setGlobalState,
    },
  },
]);

// 启动微前端
start({
  sandbox: {
    strictStyleIsolation: true,
    experimentalStyleIsolation: true,
  },
});

子应用改造(React)

// sub-react/src/public-path.js
if (window.__POWERED_BY_QIANKUN__) {
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

// sub-react/src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import './public-path';

let root = null;

function render(props) {
  const { container } = props;
  root = ReactDOM.createRoot(
    container ? container.querySelector('#root') : document.querySelector('#root')
  );
  root.render(<App />);
}

// 独立运行时
if (!window.__POWERED_BY_QIANKUN__) {
  render({});
}

// 导出生命周期
export async function bootstrap() {
  console.log('React app bootstraped');
}

export async function mount(props) {
  console.log('React app mount', props);
  render(props);
}

export async function unmount(props) {
  console.log('React app unmount', props);
  root?.unmount();
}

子应用改造(Vue)

// sub-vue/src/main.js
import Vue from 'vue';
import App from './App.vue';
import router from './router';
import store from './store';

let instance = null;

function render(props = {}) {
  const { container } = props;
  instance = new Vue({
    router,
    store,
    render: (h) => h(App),
  }).$mount(container ? container.querySelector('#app') : '#app');
}

// 独立运行
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

// 导出生命周期
export async function bootstrap() {
  console.log('Vue app bootstraped');
}

export async function mount(props) {
  console.log('Vue app mount', props);
  render(props);
}

export async function unmount(props) {
  console.log('Vue app unmount', props);
  instance.$destroy();
  instance.$el.innerHTML = '';
  instance = null;
}

最佳实践

1. 样式隔离

使用CSS Modules或CSS-in-JS来避免样式冲突:

/* 使用CSS Modules */
.container {
  padding: 20px;
}

.title {
  font-size: 18px;
}
// 使用CSS-in-JS
import styled from 'styled-components';

const Container = styled.div`
  padding: 20px;
`;

const Title = styled.h1`
  font-size: 18px;
`;

2. 状态管理

使用全局状态管理实现微应用间通信:

// 主应用
const { onGlobalStateChange, setGlobalState } = initGlobalState({
  user: { name: 'admin' },
});

// 子应用
export async function mount(props) {
  props.onGlobalStateChange((state, prev) => {
    console.log('State changed:', state, prev);
  });
  
  // 更新全局状态
  props.setGlobalState({
    user: { name: 'updated' }
  });
}

3. 性能优化

  • 预加载:在用户可能访问前预加载微应用
  • 懒加载:按需加载微应用资源
  • 缓存策略:合理利用浏览器缓存
// 预加载配置
start({
  prefetch: 'all', // 或 'app'、'content'
});

4. 错误处理

完善的错误处理机制:

start({
  beforeLoad: [
    app => {
      console.log('Before load:', app.name);
    }
  ],
  beforeMount: [
    app => {
      console.log('Before mount:', app.name);
    }
  ],
  afterMount: [
    app => {
      console.log('After mount:', app.name);
    }
  ],
  afterUnmount: [
    app => {
      console.log('After unmount:', app.name);
    }
  ],
});

常见问题与解决方案

1. 路由冲突

使用baseURL或路由前缀避免冲突:

// 主应用路由
const mainRouter = [
  {
    path: '/',
    component: MainLayout,
  }
];

// 子应用路由
const subRouter = [
  {
    path: '/sub-react',
    component: SubLayout,
  }
];

2. 依赖共享

通过webpack externals或Module Federation共享依赖:

// webpack.config.js
module.exports = {
  externals: {
    react: 'React',
    'react-dom': 'ReactDOM',
  },
};

3. 开发环境调试

配置本地开发环境:

// 开发环境配置
const isDev = process.env.NODE_ENV === 'development';

registerMicroApps([
  {
    name: 'sub-app',
    entry: isDev ? '//localhost:3001' : '//production.com/sub-app',
    container: '#container',
    activeRule: '/sub-app',
  },
]);

总结

微前端架构为大型前端应用提供了灵活的解决方案,但同时也带来了复杂度的提升。在选择微前端方案时,需要根据项目规模、团队结构、技术栈等因素综合考虑。

关键要点:

  • 选择合适的微前端框架
  • 做好样式隔离和状态管理
  • 优化性能和用户体验
  • 建立完善的错误处理机制
  • 保持良好的开发体验

微前端不是银弹,但在合适的场景下,它能够显著提升开发效率和应用的可维护性。随着技术的不断发展,微前端架构也将继续演进,为前端开发带来更多可能性。

终于不用看到CSDN该死的弹窗限制了

2026年3月12日 10:05

想必大家在网上搜索解决问题解决方案时都会有这样的经历,明明找到了想要的解决问题的代码,想要一键复制到项目代码中,但有些网页限制了你的复制行为,可能让你登录账号、关注博主,更有甚者直接收费,其中比如CSDN,所以秉着互联网的开源精神我写个脚本来解决这问题,但由于个游览器的差异本文只针对我经常使用的Chrome游览器进行讲解。

开发脚本

根据对CSDN网页结构的了解我们主要针对包裹代码的<pre> 和 <code> 标签,还有网页对用户复制操作的监听的处理。

// ==UserScript==
// @name         终极复制解锁(捕获阶段拦截 + 全面清理)
// @namespace    http://tampermonkey.net/
// @version      1.6
// @description  彻底解除复制限制,包括 pre > code 无法复制的问题
// @author       You
// @match        *://*/*
// @grant        none
// @run-at       document-start
// ==/UserScript==

(function() {
    'use strict';

    // 需要拦截的事件类型(这些事件常被用于限制复制)
    const BLOCKED_EVENTS = [
        'copy', 'cut', 'selectstart', 'contextmenu', 'selectionchange', 'mousedown', 'mouseup'
    ];
    console.log(`脚本启动`);
    // ---------- 1. 捕获阶段拦截事件传播,但不阻止默认行为 ----------
    function stopPropagationOnly(e) {
        e.stopPropagation(); // 阻止事件传播到页面注册的监听器
        // 不调用 preventDefault,保证浏览器的默认复制行为能执行
    }

    BLOCKED_EVENTS.forEach(eventType => {
        document.addEventListener(eventType, stopPropagationOnly, true); // 捕获阶段
        window.addEventListener(eventType, stopPropagationOnly, true);
    });

    // ---------- 2. 拦截后续 addEventListener 调用 ----------
    const originalAddEventListener = EventTarget.prototype.addEventListener;
    EventTarget.prototype.addEventListener = function(type, listener, options) {
        if (BLOCKED_EVENTS.includes(type)) {
            console.log(`[解锁脚本] 已阻止添加事件: ${type}`);
            return; // 忽略添加
        }
        return originalAddEventListener.call(this, type, listener, options);
    };

    // ---------- 3. 清除已有事件属性和 CSS 限制 ----------
    function removeRestrictions(root) {
        if (!root) return;
        // 兼容 iframe 传入的 window
        if (root.document) root = root.document;

        // 清理 document 对象
        if (root.nodeType === Node.DOCUMENT_NODE) {
            root.oncopy = null;
            root.oncut = null;
            root.onselectstart = null;
            root.oncontextmenu = null;
            root.onselectionchange = null;

            // 添加强制选择样式
            const style = root.createElement('style');
            style.innerHTML = `
                * {
                    user-select: text !important;
                    -webkit-user-select: text !important;
                    -moz-user-select: text !important;
                    -ms-user-select: text !important;
                }
            `;
            root.head.appendChild(style);
        } else {
            // 普通元素
            root.oncopy = null;
            root.oncut = null;
            root.onselectstart = null;
            root.oncontextmenu = null;
            root.onselectionchange = null;
        }

        // 处理 Shadow DOM
        if (root.shadowRoot) {
            removeRestrictions(root.shadowRoot);
        }

        // 遍历所有后代元素
        if (root.querySelectorAll) {
            root.querySelectorAll('*').forEach(el => {
                el.oncopy = null;
                el.oncut = null;
                el.onselectstart = null;
                el.oncontextmenu = null;
                el.onselectionchange = null;
                if (el.shadowRoot) {
                    removeRestrictions(el.shadowRoot);
                }
            });
        }
    }

    // ---------- 4. 专门处理 pre 和 code 标签:克隆替换以移除所有事件监听器 ----------
    function unlockElements(root) {
        const selectors = ['pre', 'code'];
        selectors.forEach(selector => {
            const elements = root.querySelectorAll(`${selector}:not([data-unlocked])`);
            elements.forEach(el => {
                try {
                    const clone = el.cloneNode(true);
                    clone.setAttribute('data-unlocked', 'true');
                    el.parentNode.replaceChild(clone, el);
                    console.log(`[解锁脚本] 已替换 ${selector} 标签,移除了所有事件监听器`);
                } catch (e) {
                    console.warn(`[解锁脚本] 替换 ${selector} 失败`, e);
                }
            });
        });
    }

    // 初始执行
    removeRestrictions(document);
    unlockElements(document);

    // ---------- 5. 观察动态添加的内容 ----------
    const observer = new MutationObserver(mutations => {
        mutations.forEach(mutation => {
            mutation.addedNodes.forEach(node => {
                if (node.nodeType === Node.ELEMENT_NODE) {
                    removeRestrictions(node);
                    unlockElements(node);
                }
            });
        });
    });
    if (document.body) {
        observer.observe(document.body, { childList: true, subtree: true });
    }

    // ---------- 6. 处理 iframe ----------
    function unlockIframes(root) {
        root.querySelectorAll('iframe').forEach(iframe => {
            try {
                const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
                if (iframeDoc) {
                    removeRestrictions(iframeDoc);
                    unlockElements(iframeDoc);
                    // 为 iframe 建立独立的观察者
                    const iframeObserver = new MutationObserver(() => {
                        removeRestrictions(iframeDoc);
                        unlockElements(iframeDoc);
                    });
                    if (iframeDoc.body) {
                        iframeObserver.observe(iframeDoc.body, { childList: true, subtree: true });
                    }
                }
            } catch(e) {
                // 跨域 iframe 忽略
            }
        });
    }

    // 定期扫描 iframe 和重复清理(防止页面动态重置)
    setInterval(() => {
        unlockIframes(document);
        removeRestrictions(document);
        unlockElements(document);
    }, 2000);
})();

运行脚本

脚本开发完了就到了实战的时候了,要在游览器中运行写好的脚本文件,以下提供您两种方式,以下方法方案都需要用户打开Chrome的开发者模式方能使用。

打开Chrome的开发者模式 打开Chrome的开发者模式 打开Chrome的开发者模式 !!!重要的事情说三遍

工具运行

使用Chrome扩展工具 在Chrome扩展应用商城下载Tampermonkey,操作Tampermonkey新建脚本,将之前写的脚本代码放入新建的脚本文件中,保存完毕后使用以下方法在网页中运行

方法一:通过扩展管理页面开启 "允许用户脚本"

这是 Chrome 138+ 版本的标准操作方式

  1. 进入管理页:在 Chrome 右上角点击拼图图标,找到 Tampermonkey,点击右侧的 三个点,选择  "管理扩展程序"
  2. 查找开关:在 Tampermonkey 的详情页面里,向下滑动,找到  "允许用户脚本"  的开关。
  3. 开启:将其打开,然后完全重启一次 Chrome 浏览器 

方法二:在控制台强制执行(如果方法一无效)

如果在详情页找不到上述开关,或者开启后依然无效,可以尝试用命令强制激活 

  1. 确保开发者模式已开:在地址栏输入 chrome://extensions/ 并回车,确认右上角的  "开发者模式"  处于开启状态 

  2. 粘贴命令:按 F12 打开开发者工具,切换到 Console(控制台)  标签页,将以下代码完整粘贴进去并回车:

    javascript

    chrome.developerPrivate.updateExtensionConfiguration({
        extensionId: "dhdgffkkebhmkfjojejmpbldmpobfkfo",
        userScriptsAccess: true
    });
    
  3. 重启:执行完毕后,完全关闭 Chrome 并重新打开,再刷新网页试试 

直接运行

使用 "代码片段" 保存终极脚本(一劳永逸)

如果你经常需要面对复杂的复制限制(比如你之前提到的 <pre> 标签问题),每次在控制台输入一大段代码会很麻烦。Chrome 的  "代码片段(Snippets)"  功能可以完美解决这个问题,相当于一个内置的、轻量级的脚本管理器。

  1. 打开Snippets面板

    • 打开开发者工具(F12)。
    • 切换到 Sources(源代码)  标签页。
  2. 创建并保存脚本

    • 点击  "New snippet"(新建代码片段)
    • 将我之前为你提供的那个增强版终极脚本(包含了捕获阶段拦截、克隆替换 <pre><code> 标签、处理 Shadow DOM 等功能的完整代码)粘贴到右侧的编辑器中。
    • 按 Ctrl+S 保存,你可以给它起个名字,比如 终极复制解锁
  3. 运行脚本

    • 以后在任何遇到复制限制的网页,只需打开开发者工具 -> Sources -> Snippets,找到你保存的脚本,右键点击并选择  "Run"(运行) ,或者点击右下角的运行按钮(一个类似播放键的图标)即可。

这样,你就拥有了一个不需要 Tampermonkey 也能随时调用的 "终极解锁工具"。

前端大文件上传的另一种提速思路

作者 baozj
2026年3月12日 10:03

最近在重构项目里的大文件上传模块,本想着按常规方案实现:File API 切片、计算 Hash、封装一个带并发限制(通常习惯性设为 6)的请求池,最后调个 Merge 接口收尾。

这套方案可以说是前端圈处理大文件的标配了。但看着 Network 面板里稳步推进的进度条,我突然意识到一个经常被忽略的细节:平时我们习惯性设置的“6 个并发请求”,其实是 HTTP/1.1 时代的经验产物。

在 HTTP/1.1 中,由于协议本身的限制,一个 TCP 连接在同一时刻只能处理一个请求。为了避免某个慢请求把后面的请求全堵死(队头阻塞),浏览器不得不采取一种妥协的策略:针对同一个域名,建立多个独立的 TCP 连接(大部分浏览器限制为 6 个)。
这就像是超市里开了 6 个结账通道,虽然不多,但在物理上是实打实并行的。

根据现代浏览器的机制,对于同一个域名,HTTP/1.1 确实会建立最多 6 个左右的 TCP 连接来实现物理层面的并行。但如今我们的生产环境几乎都已经全面拥抱了 HTTP/2。

根据 RFC 7540 规范,HTTP/2 的核心特性之一就是单连接多路复用。这就意味着,浏览器面对同一个域名,通常只会建立 1 个 TCP 连接

这就引发了我的一个思考:

虽然 HTTP/2 的多路复用通过二进制分帧解决了 HTTP/1.1 必须等待上一个请求响应才能发下一个的痛点,省去了大量的排队等待时间,但只要这些分片同属一个域名,底层就依然只有一条 TCP 连接。在物理传输层面上,这些并发切片的数据依然是串行发送的。

那么,如果我们能像 HTTP/1.1 时代那样,打破单条 TCP 连接的束缚,逼着浏览器多开几条物理 TCP 通道,是不是就能在 HTTP/2 的基础上实现真正的物理并行,从而进一步提升上传速度?

既然浏览器是按“域名”来复用 TCP 连接的,顺着这个思路,我周末写了个 Demo,尝试用多域名分片来验证这个猜想,还真拿到了一组直观的对比数据。

搭建多域名对照组实验

为了验证多 TCP 通道是否能带来上传速度的提升,我们需要在本地搭一个对照组。本地跑这个实验只有一个前置难点:HTTP/2 的开启条件。

目前所有主流浏览器(Chrome, Firefox, Safari)在实现 HTTP/2 时,都强制要求基于 TLS(HTTPS),通过 ALPN (Application-Layer Protocol Negotiation) 扩展来协商协议。因此,本地跑 HTTP/2 必须配置 SSL 证书。

1. 配置多域名与证书

首先修改一下系统的 /etc/hosts 文件,映射几个指向本地的别名域名:

127.0.0.1 u1.local.com
127.0.0.1 u2.local.com
127.0.0.1 u6.local.com

image.png 然后,使用 mkcert 在本地给这几个域名一键签发受信任的 SSL 证书,留给后端服务使用。

2. 极简的 Node.js 后端

后端不需要复杂的业务逻辑,起个 Express 服务,挂载刚刚签发的证书,写个接口专门接收切片即可。这里唯一要注意的是 cors 跨域配置,因为前端接下来会跨好几个子域名来发请求。

const https = require('https');
const express = require('express');
const cors = require('cors');

const app = express();

// 允许携带 credentials 以及来自不同子域名的跨域请求
app.use(cors({ 
    origin: (origin, callback) => callback(null, true), 
    credentials: true 
}));

app.post('/upload', (req, res) => {
    // 省略切片落盘逻辑...
});

// 使用 mkcert 生成的证书启动 HTTPS 服务
https.createServer(sslOptions, app).listen(443);

3. 前端的动态网关调度

平时我们写上传,目标 URL 通常写死为一个。现在我们需要维护一个“域名池”。在遍历分片数组时,通过简单的取模算法,把不同的分片请求均匀地分配给这些不同的子域名。

// 我们预设的上传域名池
const SHARDING_DOMAINS =[
  'https://u1.local.com',
  'https://u2.local.com',
  'https://u6.local.com'
];

async function uploadChunks(chunks) {
  const uploadTasks = chunks.map((chunk, index) => {
    // 轮询分配域名
    const targetDomain = SHARDING_DOMAINS[index % SHARDING_DOMAINS.length];
    const url = `${targetDomain}/upload`;
    
    const formData = new FormData();
    formData.append('chunk', chunk.file);
    
    return fetch(url, { method: 'POST', body: formData });
  });

  // 使用并发控制函数(这里省略 p-limit 的实现),最大并发保持 6
  await asyncPool(6, uploadTasks); 
}

直观的数据对比

实验准备就绪。我用一个约 1.5G 的测试文件,在同样的本地网络环境下,分别跑了“单域名常规上传”和“多域名分片上传”。

直接看 Chrome Network 面板的截图对比:

image.png

这张图里验证了几个关键的细节:

  1. 左侧(策略 A:单域名):
    耗时 3.58s,吞吐量大概在 420.35 MB/s。
    注意看下方请求列表中 u1.local.com 对应的 连接 ID (Connection ID) ,所有的分片请求,其 ID 完全一致(均为 2081087)。这在工程实际上证明了,哪怕你发起了并发请求,HTTP/2 依然尽职尽责地把它们全塞进了这一条 TCP 隧道里串行发送。

image.png 2. 右侧(策略 B:域名分片):
耗时缩短到了 2.44s,吞吐量达到了 616.82 MB/s,速度提升了将近 46%
再看底下的请求列表,发往 u1.local.com、u2.local.com 和 u6.local.com 的请求,分别拿到了 三个独立的 TCP 连接 ID(2081087、2082684、2081775)。

image.png

事实证明,通过我们在前端引入多域名策略,成功越过了浏览器针对 HTTP/2 的单连接复用机制,在物理层面上拓宽了上传的整体带宽,实现了真正的物理并行传输。

以上实验的完整前后端代码已经提交到了 GitHub,代码比较精简,主要为了提供一个验证思路。欢迎大家在本地跑跑看,或者交流不同的见解。

🔗 前端 Demo 源码: large-file-upload-demo-frontend
🔗 后端 Demo 源码: large-file-upload-demo-backend

小程序提现功能升级改造

2026年3月12日 10:01

一、问题发现

1.1 遇到的问题

在测试小程序提现功能时,调用微信支付商家转账接口时遇到权限错误:

错误信息

{
  "code": "NO_AUTH",
  "message": "当前商户号没有相关权限,暂不支持使用"
}

1.2 问题原因

微信支付在 2025年1月15日 对商家转账功能进行了重大升级:

2025年1月15号之后申请的权限都是新的商家转账产品(对应转账接口 /v3/fund-app/mch-transfer/transfer-bills),需要用户确认才能收款。2025年1月15号之前已有权限的商户仍可使用旧的商家转账到零钱产品权限(对应转账接口 /v3/transfer/batches)。

1.3 新旧接口对比

对比维度 旧版接口 新版接口
用户体验 后台直接打款,用户无感知 必须用户手动确认收款
接口路径 /v3/transfer/batches /v3/fund-app/mch-transfer/transfer-bills
核心流程 商户发起 → 微信直接打款 商户发起 → 用户确认 → 微信打款
超时机制 无确认环节 24小时未确认自动关单退款

二、流程改造方案

2.1 整体流程对比

旧版流程

用户申请提现 → 后台审核通过 → 直接调用微信支付接口 → 打款成功

新版流程

用户申请提现 → 后台审核通过 → 调用微信支付接口获取packageInfo 
→ 用户在小程序中点击"确认收款" → 调起微信支付确认页 
→ 用户确认 → 打款成功

2.2 流程时序图

sequenceDiagram
    participant 用户
    participant 小程序
    participant 后端服务
    participant 微信支付

    用户->>小程序: 1. 申请提现
    小程序->>后端服务: 2. 提交提现申请
    后端服务->>后端服务: 3. 创建订单(待审核)
    
    Note over 后端服务: 管理员审核
    
    后端服务->>微信支付: 4. 调用转账接口
    微信支付-->>后端服务: 5. 返回 package_info
    后端服务->>后端服务: 6. 更新订单状态(待确认收款)
    
    用户->>小程序: 7. 查看提现记录
    小程序->>小程序: 显示"确认收款"按钮
    用户->>小程序: 8. 点击"确认收款"
    小程序->>后端服务: 9. 获取 packageInfo
    后端服务-->>小程序: 返回 packageInfo
    小程序->>微信支付: 10. 调起 wx.requestMerchantTransfer
    微信支付->>用户: 11. 显示确认收款页
    用户->>微信支付: 12. 确认收款
    微信支付->>后端服务: 13. 回调通知打款结果
    后端服务->>后端服务: 14. 更新订单状态(打款成功)

2.3 订单状态流转

待审核(0) → 待确认收款(1) → 打款成功(2)
    ↓              ↓
审核驳回(3)    打款失败(4)

状态说明

  • 0 - 待审核:用户提交申请,等待管理员审核
  • 1 - 待确认收款:审核通过,等待用户确认收款
  • 2 - 打款成功:用户确认收款,打款成功
  • 3 - 审核驳回:管理员审核拒绝
  • 4 - 打款失败:打款失败(余额不足、用户信息错误等)

三、小程序端实现

3.1 提现记录页面改造

核心改动:新增"确认收款"按钮和确认收款逻辑

3.1.1 显示确认收款按钮

当订单状态为 1(待确认收款)时,显示"确认收款"按钮:

<template>
  <div v-if="item.status === 1" class="withdrawal-item__actions">
    <button
      class="withdrawal-item__btn"
      :disabled="isConfirming"
      @click="handleConfirmTransfer(item)"
    >
      {{ isConfirming ? '处理中...' : '确认收款' }}
    </button>
  </div>
</template>

3.1.2 实现确认收款逻辑

const handleConfirmTransfer = async (item) => {
  // 1. 版本检测
  if (!wx.canIUse('requestMerchantTransfer')) {
    wx.showModal({
      content: '你的微信版本过低,请更新至8.0.30及以上版本后重试',
      showCancel: false
    });
    return;
  }

  try {
    isConfirming.value = true;
    wx.showLoading({ title: '处理中...', mask: true });

    // 2. 调用后端接口,获取 packageInfo
    const result = await apiConfirmWithdrawalTransfer(item.withdrawalOrderNo);

    wx.hideLoading();

    // 3. 调用微信支付接口
    wx.requestMerchantTransfer({
      mchId: result.mchId,
      appId: result.appId,
      package: result.packageInfo,
      success: (res) => {
        showToast('收款确认成功,请稍候查看到账情况');
        setTimeout(() => refreshList(), 2000);
      },
      fail: (err) => {
        if (err.errMsg?.includes('cancel')) {
          showToast('已取消收款');
        } else {
          showToast('收款失败:' + err.errMsg);
        }
      },
      complete: () => {
        isConfirming.value = false;
      }
    });
  } catch (error) {
    wx.hideLoading();
    isConfirming.value = false;
    showToast(error.message || '确认打款失败,请稍后重试');
  }
};

3.1.3 状态文本映射

const getStatusText = (status: number) => {
  const statusMap = {
    0: '待审核',
    1: '待确认收款',  // 新增状态
    2: '打款成功',
    3: '审核驳回',
    4: '打款失败'
  };
  return statusMap[status] || '未知状态';
};

3.2 API 接口封装

新增确认打款接口:

/**
 * 确认提现打款(获取 packageInfo)
 */
export const apiConfirmWithdrawalTransfer = (withdrawalOrderNo: string) => {
  return request('/commission/confirmWithdrawalTransfer', {
    type: 'post',
    data: { withdrawalOrderNo }
  }) as Promise<{
    packageInfo: string;
    appId: string;
    mchId: string;
  }>;
};

四、关键技术点

4.1 微信支付新版接口

4.1.1 发起转账接口

接口路径POST /v3/fund-app/mch-transfer/transfer-bills

关键参数

{
  "appid": "wx1234567890abcdef",
  "out_bill_no": "商户单号",
  "transfer_amount": 10000,  // 单位:分
  "transfer_scene": "佣金发放",
  "openid": "用户openid",
  "user_name": "加密后的用户姓名"
}

响应示例

{
  "out_bill_no": "商户单号",
  "transfer_bill_no": "微信转账单号",
  "state": "WAIT_USER_CONFIRM",
  "package_info": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
}

关键注意事项

  • transfer_amount 单位是分,需要将元转换为分
  • user_name 需要使用微信支付公钥加密
  • 0.3元以下不支持传入 user_name 校验
  • 返回的 package_info 需要保存,供小程序调起确认页使用

4.1.2 小程序调起确认收款

APIwx.requestMerchantTransfer

参数说明

wx.requestMerchantTransfer({
  mchId: '商户号',
  appId: '小程序appid',
  package: '从后端获取的 packageInfo',
  success: (res) => {
    // 用户确认成功
  },
  fail: (err) => {
    // 用户取消或失败
  }
});

版本要求

  • 微信版本 >= 8.0.30
  • 基础库版本 >= 3.4.5

兼容性检测

if (!wx.canIUse('requestMerchantTransfer')) {
  wx.showModal({
    content: '你的微信版本过低,请更新至8.0.30及以上版本后重试',
    showCancel: false
  });
  return;
}

4.2 超时处理

用户需在 24小时内 确认收款,超时后系统会自动关单并将资金退回商户运营账户。

建议处理方式

  1. 设置定时任务,检查超过24小时未确认的订单
  2. 调用微信支付查询接口确认订单状态
  3. 如果订单已关闭,更新订单状态为"打款失败"
  4. 解冻用户的提现金额

4.3 回调处理

微信支付会通过回调通知转账结果。

回调内容示例

{
  "out_bill_no": "商户单号",
  "transfer_bill_no": "微信转账单号",
  "state": "SUCCESS",
  "fail_reason": "",
  "transfer_success_time": "2026-03-12T15:30:00+08:00"
}

处理逻辑

  1. 验证签名
  2. 解密回调数据
  3. 根据商户单号查询订单
  4. 更新订单状态为"打款成功"
  5. 更新用户账户余额
  6. 返回成功响应

注意事项

  • 必须同时接入查询接口兜底,以防收不到回调通知
  • 回调接口需要做幂等性处理,避免重复处理

五、常见问题

5.1 权限类错误

问题:调用旧接口报错 NO_AUTH

原因:商户号是2025年1月15日之后开通的,只能使用新版接口

解决方案:改用新版接口 /v3/fund-app/mch-transfer/transfer-bills

5.2 参数类错误

问题Openid格式错误或者不属于商家公众账号

原因:openid 与 appid 不匹配

解决方案

  1. 确保使用该 appid 下获取的 openid
  2. 检查 appid 是否已绑定商户号

5.3 前端调起错误

问题package_info信息有误

原因:package 参数与后端返回的不一致

解决方案

  1. 检查参数是否完整、正确
  2. 确保没有对 packageInfo 进行额外处理(如 trim、encode 等)

问题system:access_denied

原因:在非小程序环境调起(如 webview)

解决方案:只能在小程序页面内直接调用,不支持 webview

5.4 用户体验问题

问题:用户不知道需要确认收款

解决方案

  1. 在提现记录页面显著位置显示"待确认收款"状态
  2. 提供明确的"确认收款"按钮
  3. 可以考虑推送模板消息提醒用户确认

问题:用户24小时未确认导致提现失败

解决方案

  1. 在提现申请时明确告知用户需要在24小时内确认
  2. 提供重新申请提现的入口
  3. 审核通过后立即引导用户确认收款

六、相关文档

拍照识题 OCR

作者 前端付豪
2026年3月12日 09:58

本节添加这些内容

  • 手动输入题目解析

  • 上传题目图片

  • OCR 识别文字

  • 自动调用 AI 解析

  • 自动写入历史记录 / 错题本

后端先装依赖

backend 目录执行:

pip install pillow paddleocr

后端新增文件

新增 app/ocr_service.py

from paddleocr import PaddleOCR
from PIL import Image

ocr = PaddleOCR(use_angle_cls=True, lang="ch")


def extract_text_from_image(image_path: str) -> str:
    result = ocr.ocr(image_path, cls=True)

    lines = []
    for block in result:
        if not block:
            continue
        for item in block:
            if len(item) < 2:
                continue
            text = item[1][0].strip()
            if text:
                lines.append(text)

    return "\n".join(lines)

后端修改 app/schemas.py

只新增下面这个响应结构

class OCRSolveResponse(SolveQuestionResponse):
    question: str
    ocr_text: str

后端修改 app/main.py

1)先补充 import

在顶部 import 区域,新增这几行:

import os
import tempfile
from fastapi import File, UploadFile
from app.ocr_service import extract_text_from_image
from app.schemas import OCRSolveResponse

2)新增图片解析接口

把这个接口加到 main.py 里:

@app.post("/api/solve-image", response_model=OCRSolveResponse)
async def solve_image(file: UploadFile = File(...), db: Session = Depends(get_db)):
    temp_path = None
    try:
        suffix = os.path.splitext(file.filename or "")[-1] or ".png"
        with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
            content = await file.read()
            temp_file.write(content)
            temp_path = temp_file.name

        ocr_text = extract_text_from_image(temp_path).strip()
        if not ocr_text:
            raise HTTPException(status_code=400, detail="OCR 未识别到题目内容")

        result = solve_math_question(ocr_text)

        row = QuestionHistory(
            question=ocr_text,
            answer=result["answer"],
            steps=json.dumps(result["steps"], ensure_ascii=False),
            knowledge_points=json.dumps(result["knowledge_points"], ensure_ascii=False),
            similar_question=result["similar_question"],
            is_wrong=False,
        )
        db.add(row)
        db.commit()
        db.refresh(row)

        return OCRSolveResponse(
            id=row.id,
            question=ocr_text,
            ocr_text=ocr_text,
            answer=row.answer,
            steps=json.loads(row.steps),
            knowledge_points=json.loads(row.knowledge_points),
            similar_question=row.similar_question,
            is_wrong=row.is_wrong,
        )
    except HTTPException:
        db.rollback()
        raise
    except Exception as e:
        db.rollback()
        raise HTTPException(status_code=500, detail=str(e))
    finally:
        if temp_path and os.path.exists(temp_path):
            os.remove(temp_path)

前端修改 src/api/math.ts

1)新增 OCR 返回类型

export interface OCRSolveResponse extends HistoryItem {
  question: string
  ocr_text: string
}

2)新增图片解析接口方法

export function solveMathImage(file: File) {
  const formData = new FormData()
  formData.append('file', file)
  return request.post<OCRSolveResponse>('/api/solve-image', formData, {
    headers: {
      'Content-Type': 'multipart/form-data',
    },
  })
}

前端修改 src/App.vue

1)修改 import

import {
  solveMathQuestion,
  solveMathImage,
  getHistoryList,
  getWrongQuestionList,
  markWrongQuestion,
  type SolveResponse,
  type HistoryItem,
} from './api/math'

2)新增上传状态

script setup 里,新增:

const imageLoading = ref(false)

3)新增图片上传解析方法

script setup 里,新增:

const handleImageChange = async (event: Event) => {
  const target = event.target as HTMLInputElement
  const file = target.files?.[0]
  if (!file) return

  imageLoading.value = true
  try {
    const { data } = await solveMathImage(file)
    result.value = {
      ...data,
      question: data.question,
    }
    activeTab.value = 'solve'
    await loadHistory()
    await loadWrongList()
  } catch (error: any) {
    console.error('图片解析失败:', error)
    alert(error?.response?.data?.detail || '图片解析失败,请检查后端日志')
  } finally {
    imageLoading.value = false
    target.value = ''
  }
}

4)修改“题目解析”区域模板

找到 activeTab === 'solve' 这一段,在 textarea 上方插入下面这块:

<div class="upload-area">
  <label class="upload-btn">
    {{ imageLoading ? '识别中...' : '上传题目图片' }}
    <input
      type="file"
      accept="image/*"
      class="file-input"
      :disabled="imageLoading"
      @change="handleImageChange"
    />
  </label>
</div>

5)补充样式

style scoped 里新增:

.upload-area {
  margin-bottom: 16px;
}

.upload-btn {
  display: inline-flex;
  align-items: center;
  padding: 10px 16px;
  background: #2080f0;
  color: #fff;
  border-radius: 8px;
  cursor: pointer;
}

.file-input {
  display: none;
}

重启

1)重启后端

uvicorn app.main:app --reload --port 8000

2)重启前端

npm run dev

预期结果

  • 上传一张题目图片
  • OCR 提取题目
  • 自动解析
  • 页面展示答案 / 步骤 / 知识点 / 相似题
  • 历史记录新增一条

错误处理

import paddle ModuleNotFoundError: No module named 'paddle'

解决

pip install paddlepaddle==2.6.2

uvicorn app.main:app --reload --port 8000

报错

AttributeError: 'paddle.base.libpaddle.AnalysisConfig' object has no attribute 'set_optimization_level'. Did you mean: 'tensorrt_optimization_level'?

PaddleOCR 和 PaddlePaddle 版本不兼容 导致的。

pip uninstall paddleocr paddlepaddle -y

pip install paddlepaddle==2.6.2
pip install paddleocr==2.7.3

报错

25, in <module> import cv2 ImportError: numpy.core.multiarray failed to import

解决

pip uninstall -y numpy opencv-python opencv-contrib-python opencv-python-headless

pip install numpy==1.26.4
pip install opencv-python-headless==4.10.0.84

效果

上传这个图片

test.png

image.png

image.png

非常不错!!!

全局防抖方案的设计思路与实现:原型劫持的完整方案(零侵入)

作者 poo
2026年3月12日 09:57

有个历史遗留的老项目规范不够好,事件触发按钮较多且未作局部loading和防抖处理,当前任务是想给全局按钮自动添加防抖,用以优化用户体验和服务负载。

几种方案

  • 组件维度进行防抖设计,给当前交互对应的事件包裹防抖
  • 业务或者整个子应用可以进行自定义指令实现
  • 给当前使用的UI组件,二次封装使其拥有防抖功能
  • 对于我们这个,考虑全局劫持方案最优
    • ✅ 零侵入性:不需要修改任何现有代码
    • ✅ 全局覆盖:自动应用于所有按钮,包括动态生成的
    • ✅ 维护性好:集中管理,一处修改全局生效
    • ✅ 框架无关:纯JavaScript实现,不依赖特定框架

一、EventTarget 是一个 JavaScript 接口,表示可以接收事件的对象。

EventTarget 接口定义了三个主要的方法:

  • addEventListener():用于绑定事件监听器。
  • removeEventListener():用于移除已绑定的事件监听器。
  • dispatchEvent():用于分发事件,即触发事件。

image.png

二、识别目前元素,我们这个项目仅限制button类型

  • 验证当前是否为有效DOM元素element instanceof HTMLElement
  • 识别几种常用的按钮
    function isButton(element) {
        return element instanceof HTMLElement && (
            element.tagName === 'BUTTON'
            || (element.tagName === 'INPUT' && ['button', 'submit'].includes(element.type))
            || element.getAttribute('role') === 'button'
        );
    }

三、防抖

    function debounce(func, wait) {
        let timeout;
        return function (...args) {
            clearTimeout(timeout);
            timeout = setTimeout(() => func.apply(this, args), wait);
        };
    }
四、核心劫持逻辑
  EventTarget.prototype.addEventListener = function (type, listener, options) {
    if (type === 'click' && isButton(this)) {
      // 避免重复创建防抖函数
      if (debounceMap.has(this)) {
        const buttonMap = debounceMap.get(this);
        if (buttonMap.has(listener)) {
          const existingDebounced = buttonMap.get(listener);
          return originalAddEventListener.call(this, type, existingDebounced, options);
        }
      }

      const debouncedListener = debounce(listener, delay);

      if (!debounceMap.has(this)) {
        debounceMap.set(this, new Map());
      }
      debounceMap.get(this).set(listener, debouncedListener);
      // 注册防抖后的监听器
      return originalAddEventListener.call(this, type, debouncedListener, options);
    }
    //非目标事件保持原样
    return originalAddEventListener.call(this, type, listener, options);
  };

五、完整示例及使用

  • 在项目入口文件main.js中进行使用即可
  • import { debounceClick } from './debounce-click.js';
// 保存原始方法的全局变量
let originalAddEventListener;
let originalRemoveEventListener;

export function debounceClick(delay = 300) {
  if (window._eventTargetHijackEnabled) {
    console.warn('重复初始化');
    return;
  }

  // 备份原始方法
  if (!window._originalAddEventListener) {
    window._originalAddEventListener = EventTarget.prototype.addEventListener;
    window._originalRemoveEventListener = EventTarget.prototype.removeEventListener;
  }

  originalAddEventListener = window._originalAddEventListener;
  originalRemoveEventListener = window._originalRemoveEventListener;

  const debounceMap = new WeakMap();

  function isButton(element) {
    return (
      element instanceof HTMLElement &&
      (element.tagName === 'BUTTON' ||
        (element.tagName === 'INPUT' && ['button', 'submit'].includes(element.type)) ||
        element.getAttribute('role') === 'button')
    );
  }

  // 防抖函数
  function debounce(func, wait) {
    let timeout;
    return function (...args) {
      clearTimeout(timeout);
      timeout = setTimeout(() => func.apply(this, args), wait);
    };
  }

  // 劫持addEventListener
  EventTarget.prototype.addEventListener = function (type, listener, options) {
    if (type === 'click' && isButton(this)) {
      // 避免重复创建防抖函数
      if (debounceMap.has(this)) {
        const buttonMap = debounceMap.get(this);
        if (buttonMap.has(listener)) {
          const existingDebounced = buttonMap.get(listener);
          return originalAddEventListener.call(this, type, existingDebounced, options);
        }
      }

      const debouncedListener = debounce(listener, delay);

      if (!debounceMap.has(this)) {
        debounceMap.set(this, new Map());
      }
      debounceMap.get(this).set(listener, debouncedListener);

      return originalAddEventListener.call(this, type, debouncedListener, options);
    }
    return originalAddEventListener.call(this, type, listener, options);
  };

  // 劫持removeEventListener,不然会导致无法移除劫持处理后的事件监听器
  EventTarget.prototype.removeEventListener = function (type, listener, options) {
    if (type === 'click' && isButton(this) && debounceMap.has(this)) {
      const buttonMap = debounceMap.get(this);

      if (buttonMap.has(listener)) {
        const debouncedListener = buttonMap.get(listener);
        buttonMap.delete(listener);

        if (buttonMap.size === 0) {
          debounceMap.delete(this);
        }

        return originalRemoveEventListener.call(this, type, debouncedListener, options);
      }
    }
    return originalRemoveEventListener.call(this, type, listener, options);
  };

  window._eventTargetHijackEnabled = true;
  console.log(`劫持防抖启用(delay: ${delay}ms)`);
}

// 回滚原始
export function disableEventTargetHijack() {
  if (!window._eventTargetHijackEnabled) {
    console.warn('EventTarget劫持未启用,无需禁用');
    return;
  }

  // 恢复原始方法
  if (window._originalAddEventListener) {
    EventTarget.prototype.addEventListener = window._originalAddEventListener;
  }
  if (window._originalRemoveEventListener) {
    EventTarget.prototype.removeEventListener = window._originalRemoveEventListener;
  }

  // 清理所有无用的全局变量
  delete window._originalAddEventListener;
  delete window._originalRemoveEventListener;
  delete window._eventTargetHijackEnabled;

  console.warn('EventTarget劫持禁用完成');
}

我把 Vue Router 搬到了 React —— 从 API 到文件路由、转场动画,一个都不少

作者 汤姆Tom
2026年3月12日 09:51

如果你同时写 Vue 和 React,一定懂那种感觉:切回 React 项目,想用 useRoute() 拿参数,却发现根本没有这个 hook。


起因

我平时 Vue 和 React 都写。Vue Router 的体验一直让我很满意——useRouteuseRouter、导航守卫、嵌套路由、文件路由……每一块都设计得恰到好处。

切回 React 项目,用 React Router 时总觉得哪里别扭:

  • useParamsuseSearchParams 是两个 hook,而不是一个统一的 route 对象
  • 没有全局导航守卫,鉴权逻辑得自己包一层
  • 文件路由要靠框架(Next.js / Remix),单独用 Vite 就得手写
  • 路由切换动画没有官方方案

于是我决定自己搓一个:把 Vue Router 的 API 完整搬到 React,同时加上文件系统路由和转场动画。

这就是 @tangmu1121/rvue-router


它长什么样

先看三步起步:

npm install @tangmu1121/rvue-router

第一步:创建路由

// src/router/index.ts
import { createRouter, createWebHistory } from '@tangmu1121/rvue-router'
import { lazy } from 'react'

export const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/',      redirect: '/home' },
    { path: '/home',  name: 'home',  component: lazy(() => import('@/pages/Home')) },
    { path: '/about', name: 'about', component: lazy(() => import('@/pages/About')) },
    { path: '/users/:id', name: 'user-detail', component: lazy(() => import('@/pages/User')) },
    { path: '*', component: lazy(() => import('@/pages/NotFound')) },
  ],
})

// 全局鉴权守卫
router.beforeEach((to, from, next) => {
  if (to.meta.requiresAuth && !localStorage.getItem('token')) {
    next('/login')
  } else {
    next()
  }
})

第二步:注入 Provider

// src/main.tsx
const { RouterProvider } = router

createRoot(document.getElementById('root')!).render(
  <RouterProvider>
    <App />
  </RouterProvider>
)

第三步:渲染出口

// src/App.tsx
import { RouterView, RouterLink } from '@tangmu1121/rvue-router'

export default function App() {
  return (
    <div>
      <nav>
        <RouterLink to="/home" activeClass="active">首页</RouterLink>
        <RouterLink to="/about" activeClass="active">关于</RouterLink>
      </nav>
      <RouterView transition="fade" />  {/* 带淡入淡出动画 */}
    </div>
  )
}

就这些。如果你写过 Vue Router,基本不用看文档就能上手。


核心功能速览

1. useRoute —— 一个 hook 拿到所有路由信息

function UserDetail() {
  const route = useRoute()

  // 动态参数
  const { id } = route.params

  // 查询参数
  const page = route.query.page

  // 路由元信息
  const title = route.meta.title

  // 完整路径、matched 链……都在这里
}

对比 React Router:useParams() + useSearchParams() + 自己实现 meta。

2. 导航守卫 —— 完整的四阶段执行链

每次路由切换,守卫按以下顺序执行:

组件 useBeforeRouteLeave → 全局 beforeEach → 组件 useBeforeRouteUpdate → 路由级 beforeEnter

在组件里用 hook 直接注册:

function EditForm() {
  const [isDirty, setIsDirty] = useState(false)

  // 离开前确认
  useBeforeRouteLeave((to, from, next) => {
    if (isDirty && !confirm('有未保存的更改,确认离开?')) {
      next(false)  // 阻止跳转
    } else {
      next()
    }
  })

  // 路由参数变化时重新加载(/users/1 → /users/2,组件复用)
  useBeforeRouteUpdate((to, from, next) => {
    fetchUserData(to.params.id)
    next()
  })
}

3. RouterLink —— 智能激活状态

// 前缀匹配时加 active 类,精确匹配时加 active-exact 类
<RouterLink to="/home" activeClass="active" exactActiveClass="active-exact">
  首页
</RouterLink>

// 精确匹配时自动添加 aria-current="page",满足无障碍标准
// Ctrl/Meta/Shift 点击时走浏览器默认行为(新标签页打开)
// disabled 状态渲染为 <a> 但阻止跳转
<RouterLink to="/admin" disabled>管理员</RouterLink>

重头戏一:文件系统路由

这是我最花时间的部分。只需要一个 Vite 插件,创建文件就等于注册路由

// vite.config.ts
import { fileRouter } from '@tangmu1121/rvue-router/vite'

export default defineConfig({
  plugins: [react(), fileRouter({ dir: 'src/pages' })],
})
// src/router/index.ts
import routes from 'virtual:rvue-routes'  // 自动生成!
import { createRouter, createWebHistory } from '@tangmu1121/rvue-router'

export const router = createRouter({ history: createWebHistory(), routes })

文件命名约定

src/pages/
  index.tsx           →  /           name: 'index'
  about.tsx           →  /about      name: 'about'
  users/
    index.tsx         →  /users      name: 'users'
    [id].tsx          →  /users/:id  name: 'users-id'
    [id]/
      posts.tsx       →  /users/:id/posts  name: 'users-id-posts'
  [...404].tsx        →  *           name: '404'

加上 _layout.tsx 就能做嵌套路由:

src/pages/
  _layout.tsx         ← 根布局
  index.tsx
  users/
    _layout.tsx       ← /users 布局
    index.tsx
    [id].tsx

生成结果:

[{
  path: '/',
  component: lazy(() => import('./pages/_layout.tsx')),
  children: [
    { path: '', name: 'index', component: lazy(() => import('./pages/index.tsx')) },
    {
      path: 'users',
      name: 'users',
      component: lazy(() => import('./pages/users/_layout.tsx')),
      children: [
        { path: '', name: 'users', component: lazy(...) },
        { path: ':id', name: 'users-id', component: lazy(...) },
      ],
    },
  ],
}]

HMR 支持: 新增/删除文件自动触发路由更新,开发体验丝滑。

同级路由配置文件(*.route.ts

想给某个页面加 meta 或路由守卫,但不想污染组件文件?创建一个同名的 .route.ts

// src/pages/dashboard.route.ts
import { defineRouteConfig } from '@tangmu1121/rvue-router'

export default defineRouteConfig({
  name: 'dashboard',          // 覆盖自动生成的 name
  meta: {
    requiresAuth: true,
    title: '控制台',
    roles: ['admin'],
  },
  beforeEnter: (to, from, next) => {
    if (!hasPermission(to.meta.roles)) next('/403')
    else next()
  },
})

插件会自动将这个文件的导出 spread 到路由对象上。页面逻辑和路由配置完全分离,整洁。


重头戏二:路由转场动画

这块我参照 Vue 的 <Transition> 设计,做到了零额外依赖。

// 一行开启动画
<RouterView transition="fade" />
/* 在全局 CSS 里定义类 */
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }

动画模式是 out-in:旧组件先完成离开动画,新组件再进入,不会出现两个组件叠加的问题。

完整的六个生命周期类:

时机 添加 移除
离开开始 name-leave-fromname-leave-active
下一帧 name-leave-to name-leave-from
离开结束 name-leave-activename-leave-to
进入开始 name-enter-fromname-enter-active
下一帧 name-enter-to name-enter-from
进入结束 name-enter-activename-enter-to

几个常用的动画效果

/* 水平滑动 */
.slide-enter-active, .slide-leave-active { transition: all 0.35s ease; }
.slide-enter-from { opacity: 0; transform: translateX(30px); }
.slide-leave-to { opacity: 0; transform: translateX(-30px); }

/* 缩放 */
.zoom-enter-active, .zoom-leave-active { transition: all 0.25s ease; }
.zoom-enter-from, .zoom-leave-to { opacity: 0; transform: scale(0.95); }

用 Tailwind?也支持

<RouterView
  transition={{
    enterFromClass:   'opacity-0 translate-x-4',
    enterActiveClass: 'transition-all duration-300 ease-out',
    enterToClass:     'opacity-100 translate-x-0',
    leaveFromClass:   'opacity-100 translate-x-0',
    leaveActiveClass: 'transition-all duration-200 ease-in',
    leaveToClass:     'opacity-0 -translate-x-4',
  }}
/>

不同路由用不同动画

function App() {
  const route = useRoute()
  return <RouterView transition={route.meta.transition ?? 'fade'} />
}

// 路由配置
{ path: '/home',      meta: { transition: 'fade'  } },
{ path: '/dashboard', meta: { transition: 'slide' } },
{ path: '/settings',  meta: { transition: 'zoom'  } },

其他细节

动态路由

// 登录后按权限动态添加路由
router.addRoute({ path: '/admin', component: AdminPage })
router.addRoute({ path: 'logs', component: Logs }, 'admin') // 添加到 admin 子路由

// 退出时清理
router.removeRoute('admin')

// 检查是否存在
router.hasRoute('admin')

useIsNavigating —— 全局加载指示器

function GlobalProgressBar() {
  const isNavigating = useIsNavigating()
  return isNavigating ? <ProgressBar /> : null
}

router.isReady() —— 等待初始导航

// SSR 或需要在路由就绪后再执行某些逻辑
await router.isReady()

三种历史模式

createWebHistory()    // /path        需要服务器配置
createHashHistory()   // /#/path      无需服务器配置
createMemoryHistory() // 内存         SSR / 测试

技术实现简记

几个有意思的实现细节:

响应式路由:基于 useSyncExternalStore,保证所有订阅者在路由变化时同步更新,不会出现撕裂(tearing)。

转场动画时序:用双帧 requestAnimationFramenextFrame)确保浏览器在类名变化之间完成一次 paint,这样 CSS transition 才能正确触发。自动从 getComputedStyle 读取 transition-duration + transition-delay 计算最大时长,不需要手动指定。

文件路由路径匹配:路由按静态 > 动态 > 通配符排序,避免 :idabout 拦截掉。无 _layout 的子目录路由会"提升"到父层并拼接路径前缀,保持扁平结构。

守卫取消函数beforeEachafterEachonError 均返回取消函数,便于动态注册/注销,不会内存泄漏。


与 React Router 的对比

功能 rvue-router React Router
统一路由对象 useRoute() useParams() + useSearchParams()
全局导航守卫 router.beforeEach 需自己实现
组件级守卫 useBeforeRouteLeave 无原生支持
文件系统路由 内置 Vite 插件 需要框架(Remix/Next.js)
转场动画 内置,零依赖 需要 Framer Motion 等
动态路由 addRoute / removeRoute 有,但 API 不同
路由元信息 meta 字段 无原生支持
TypeScript 完整类型 完整类型

安装

npm install @tangmu1121/rvue-router
# or
pnpm add @tangmu1121/rvue-router
# or
yarn add @tangmu1121/rvue-router

npm 地址:www.npmjs.com/package/@ta…


最后

这个库目前已发布 v0.3.1,核心功能都已稳定:

  • ✅ Vue Router 风格的完整 API
  • ✅ 文件系统路由 + 自动路由名称 + .route.ts 配置文件
  • ✅ 路由转场动画(支持 Tailwind / CSS Modules)
  • ✅ 完整 TypeScript 类型
  • ✅ 零运行时依赖(只有 React 作为 peer dep)

如果你也是个 Vue 转 React(或者两个都写)的开发者,欢迎试试。有问题或建议欢迎提 issue。

给 PR 接一个 LLM 自动 Review:GitHub Actions 落地踩坑全记录

2026年3月12日 08:42

给 PR 接一个 LLM 自动 Review:GitHub Actions 落地踩坑全记录

你提了个 PR。

团队仨 reviewer,一个开会,一个休假,一个已读不回。干等两天,review 回来了——"这个变量名改一下"。

逻辑漏洞?没人看。SQL 注入?想多了。

不是 reviewer 水平不行,是逐行扫 diff 这事本身就反人类。人脑带宽就那么大。

那让大模型干?

要解决的问题

把 LLM 塞进 CI 跑 Code Review,想法挺美。坑也挺多:

  • PR diff 几千行,token 塞不下
  • 模型幻觉一顿误报,reviewer 被"狼来了"搞麻了
  • 怎么只审增量,不是整个仓库
  • 安全检测和重构建议的 prompt 能不能共用一套

这不是调个 API 的事。是信息压缩 + prompt 工程 + CI 编排一套组合拳。

架构长这样

PR 创建/更新
    ↓
GitHub Actions 触发
    ↓
┌─────────────────────────┐
│  1. 拉取增量 diff        │
│  2. 按文件分片           │
│  3. 构造 prompt          │
│  4. 并发调 LLM           │
│  5. 聚合 + 去重 + 过滤   │
│  6. 写回 PR Review 评论  │
└─────────────────────────┘

六步,每一步都有坑。

拿增量 Diff

GitHub API 能直接拿 PR 的变更文件列表。别用 REST API 的 diff 接口——返回纯文本 unified diff,解析起来一言难尽。

// 拿 PR 变更文件列表
const { data: files } = await octokit.pulls.listFiles({
  owner: 'your-org',
  repo: 'your-repo',
  pull_number: prNumber,
  per_page: 100, // 超过 100 个文件要分页
})

// file 对象结构:
// {
//   filename: 'src/auth/login.ts',
//   status: 'modified',        // added | modified | removed | renamed
//   patch: '@@ -10,6 +10,8 @@\n ...',  // unified diff 片段
//   additions: 12,
//   deletions: 3,
// }

// 只管有实际代码变更的文件,lock 文件和构建产物跳过
const reviewable = files.filter(f =>
  f.status !== 'removed' &&
  !f.filename.match(/\.(lock|min\.js|map|snap)$/) &&
  !f.filename.startsWith('dist/')
)

patch 字段就是增量 diff。不用 clone 仓库,不用跑 git diff

不过 patch 有大小限制。超大文件 GitHub 会截断,得 fallback 到 git diff

分片——Token 是硬约束

一个 PR 改了 30 个文件,全拼一起扔给模型?

爆了。

就算没爆,上下文太长模型也会走神——long context 中间段落注意力衰减,"lost in the middle",这个问题挺多论文聊过。

interface DiffChunk {
  filename: string
  patch: string
  language: string
  tokenEstimate: number
}

function splitIntoChunks(files: DiffChunk[], maxTokens = 3000): DiffChunk[][] {
  const chunks: DiffChunk[][] = []
  let current: DiffChunk[] = []
  let currentTokens = 0

  for (const file of files) {
    // 单文件就超限 → 独占一个 chunk
    if (file.tokenEstimate > maxTokens) {
      chunks.push([file])
      continue
    }

    if (currentTokens + file.tokenEstimate > maxTokens) {
      chunks.push(current)  // 满了,切一刀
      current = [file]
      currentTokens = file.tokenEstimate
    } else {
      current.push(file)
      currentTokens += file.tokenEstimate
    }
  }

  if (current.length) chunks.push(current)
  return chunks
}

3000 token 一个 chunk。留 1000 给 system prompt,留 1000 给输出,加起来在 Claude/GPT-4 甜区里。不是拍脑袋定的——调过几轮才稳定在这个数。

太大的文件?按函数级别再切。用 AST?太重。正则按 function/class 关键字切就够了,Code Review 不需要编译级精度。

Prompt 工程——整条链路最值得花时间的地方

垃圾 prompt 进去,垃圾 review 出来。

别写"请帮我 review 这段代码"。太泛。模型会吐一堆"建议添加注释""变量命名可以更清晰"——正确的废话。

const SYSTEM_PROMPT = `你是一个资深代码审查员。只关注以下三类问题:

1. **Bug 风险**:空指针、竞态、边界溢出、类型不安全
2. **安全漏洞**:注入(SQL/XSS/命令)、敏感信息泄露、权限校验缺失
3. **可维护性硬伤**:重复代码超过 10 行、圈复杂度过高、接口设计不合理

不要提出以下建议(这些是 linter 的活):
- 命名风格
- 缺少注释
- import 顺序
- 格式问题

输出格式:
\`\`\`json
[{
  "file": "文件路径",
  "line": 行号,
  "severity": "error" | "warning" | "info",
  "category": "bug" | "security" | "maintainability",
  "message": "一句话说明问题",
  "suggestion": "修复代码(可选)"
}]
\`\`\`

如果没有发现问题,返回空数组 []。
不要编造问题。宁可漏报,不要误报。`

三个设计决策:

一,明确告诉模型"别管什么"。 比告诉它"要管什么"管用。不写这条,一半输出都是 lint 噪音。

二,强制 JSON。 下游要解析、要写 GitHub 评论、要按行定位。自由文本没法自动化。

三,"宁可漏报,不要误报"。 这句是整个 prompt 里最值钱的。大模型天然倾向于多说,你不压它,每个 any 类型都给你标 error。reviewer 三天就关了这 bot。

并发调用 + 去重

片分好了,prompt 也有了,开始调 API。

async function reviewChunks(chunks: DiffChunk[][]): Promise<ReviewComment[]> {
  // 并发但限流,别把 rate limit 打爆
  const limiter = new Bottleneck({ maxConcurrent: 3, minTime: 500 })

  const results = await Promise.all(
    chunks.map(chunk =>
      limiter.schedule(() => callLLM(chunk))
    )
  )

  return dedup(results.flat())
}

function dedup(comments: ReviewComment[]): ReviewComment[] {
  const seen = new Set<string>()
  return comments.filter(c => {
    const key = `${c.file}:${c.line}:${c.category}`
    if (seen.has(key)) return false
    seen.add(key)
    return true
  })
}

3 个并发,500ms 间隔。这个数是踩了几次 Claude API rate limit 之后调出来的。

去重拿 file + line + category 当 key,同行同类问题只留一条。

写回 PR

GitHub PR Review API 有两种评论模式:单条(createReviewComment)和整体(createReview)。

用整体。一次提交不会疯狂刷通知。

async function postReview(prNumber: number, comments: ReviewComment[]) {
  if (comments.length === 0) {
    await octokit.pulls.createReview({
      owner, repo, pull_number: prNumber,
      event: 'APPROVE',
      body: '🤖 LLM Review: 未发现明显问题。',
    })
    return
  }

  await octokit.pulls.createReview({
    owner, repo, pull_number: prNumber,
    event: 'COMMENT',  // 别用 REQUEST_CHANGES
    body: `🤖 发现 ${comments.length} 个潜在问题`,
    comments: comments.map(c => ({
      path: c.file,
      line: c.line,
      body: formatComment(c),
    })),
  })
}

COMMENT 不用 REQUEST_CHANGES

这点很关键。LLM 判断不是 100% 准,REQUEST_CHANGES 会卡 merge 流程。bot 的定位是辅助,不是守门人。搞反了团队会恨死你。

Actions Workflow

name: LLM Code Review
on:
  pull_request:
    types: [opened, synchronize]  # 新 PR 和新 push 都触发

jobs:
  review:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
      contents: read

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # 完整历史,不然 diff 算不了

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - run: npm ci

      - name: Run LLM Review
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        run: node dist/review.js ${{ github.event.pull_request.number }}

fetch-depth: 0 容易漏。不写就是 shallow clone,diff 跑不了。

安全审计单独跑一轮

通用 review 和安全检测别混。

const SECURITY_PROMPT = `你是安全审计专家。只检查以下问题:

1. SQL 注入:字符串拼接 SQL?用了参数化查询没?
2. XSS:用户输入直接插 DOM?转义了没?
3. 命令注入:exec/spawn 拼了用户输入?
4. 敏感信息泄露:hardcoded secret/密码/API key?
5. 路径遍历:文件操作校验路径了没?
6. SSRF:用户可控的 URL 请求?

只报 80% 以上把握的问题。
JSON 格式输出,category 固定 "security"。`

为什么分开?

安全检测需要不一样的"人格"。通用 review 要克制,安全审计要敏感,两种倾向塞一个 prompt 会互相打架。另外安全问题 severity 要统一拉高,后面过滤逻辑也不同。

几个绕不开的设计取舍

Fine-tune?不搞。

Fine-tune 要大量标注好的 Code Review 数据。哪来?开源项目的 review 评论质量参差不齐,跟你的业务风格也对不上。Prompt engineering 加通用大模型,性价比高得多。模型升级你直接受益,fine-tune 了旧模型,新版出来得重新训。

AST 做精确分片?不搞。

AST 依赖语言。仓库里 TypeScript、Python、Go、YAML 都有——每种配一个 parser?维护成本扛不住。文本级按 token 分片粗糙是粗糙,但够用。

成本算一下。

中等 PR,改 15 个文件,diff 约 500 行。5 个 chunk,每个约 3000 token 输入 + 500 输出。再加一轮安全审计。拿 Claude Sonnet 算:

通用 review:5 × (3000 × $0.003 + 500 × $0.015) = $0.082
安全审计:  5 × (3000 × $0.003 + 500 × $0.015) = $0.082
总计:约 $0.16 / PR

一天 20 个 PR,一个月 $96。

PR 经常几千行的话……要么拆 PR,要么认命花钱。

误报怎么压

三层:

  1. Prompt 层——"宁漏勿错"写死在指令里
  2. 置信度层——模型输出 confidence 字段,低于 0.7 直接丢
  3. 规则层——已知误报 pattern 加白名单,比如 test 文件里的 eval

调好之后误报率能压到 15% 以下。不完美,但比裸奔强。

做成可插拔的

别把逻辑全糊在一个脚本里。

// 审查管道,每一步可替换
interface ReviewPlugin {
  name: string
  prompt: string
  filter?: (files: DiffChunk[]) => DiffChunk[]
  postProcess?: (comments: ReviewComment[]) => ReviewComment[]
}

const plugins: ReviewPlugin[] = [
  {
    name: 'general',
    prompt: GENERAL_PROMPT,
  },
  {
    name: 'security',
    prompt: SECURITY_PROMPT,
    filter: files => files.filter(f => !f.filename.includes('__test__')),
  },
  {
    name: 'sql-review',
    prompt: SQL_PROMPT,
    filter: files => files.filter(f => f.language === 'sql' || f.patch.includes('query')),
  },
]

async function runPipeline(files: DiffChunk[]): Promise<ReviewComment[]> {
  const allComments = await Promise.all(
    plugins.map(async plugin => {
      const target = plugin.filter ? plugin.filter(files) : files
      if (target.length === 0) return []
      const chunks = splitIntoChunks(target)
      const raw = await reviewChunks(chunks, plugin.prompt)
      return plugin.postProcess ? plugin.postProcess(raw) : raw
    })
  )
  return dedup(allComments.flat())
}

要加个"国际化检测"?写个 plugin 就行,不碰主流程。中间件式的管道架构,各管各的。

踩坑

行号对不上。

diff 里的行号是 hunk 偏移量,不是文件绝对行号。得自己算映射。

// @@ -10,6 +10,8 @@ 意思:
// 旧文件第 10 行起 6 行 → 新文件第 10 行起 8 行
// 评论要用新文件行号(+侧)
function parsePatchLineMap(patch: string): Map<number, number> {
  const map = new Map<number, number>()
  let newLine = 0

  for (const line of patch.split('\n')) {
    const hunkMatch = line.match(/^@@ .+\+(\d+)/)
    if (hunkMatch) {
      newLine = parseInt(hunkMatch[1])
      continue
    }
    if (line.startsWith('+') || line.startsWith(' ')) {
      map.set(newLine, newLine)
      newLine++
    }
    // '-' 开头是删除行,不占新文件行号
  }
  return map
}

GitHub API 的 line 只接受 diff 里存在的行。 模型报了个不在 diff 范围的行号?422。写回之前得校验。

模型偶尔不返回 JSON。 prompt 写得再严格,总有 1% 概率它给你一段纯文本。加 try-catch,解析失败跳过这个 chunk,别让整条 pipeline 挂了。

不适合的场景

代码不能外发的。 金融、医疗那种合规要求,不能把代码丢第三方 API,得自建模型。成本翻 10 倍起。

超大 monorepo。 一个 PR 200 个文件 5000 行 diff,分片数爆炸,调用费飙升,评论多到没人看。这种先解决"PR 太大"的问题。

团队不买账。 reviewer 对 bot 每条建议都反复验证,那它不是省时间,是加活。信任得慢慢攒——先跑一个月 COMMENT 模式让大家看看准确率,再考虑要不要接进 CI 必过检查。

反馈闭环

跑起来不算完,得知道它干得咋样。

// 每条评论带 👍/👎 reaction
// 定期统计
async function collectFeedback(prNumber: number) {
  const reviews = await octokit.pulls.listReviews({ owner, repo, pull_number: prNumber })
  const botReview = reviews.data.find(r => r.user?.login === 'github-actions[bot]')
  if (!botReview) return

  const comments = await octokit.pulls.listReviewComments({ owner, repo, pull_number: prNumber })
  const botComments = comments.data.filter(c => c.pull_request_review_id === botReview.id)

  for (const comment of botComments) {
    const reactions = await octokit.reactions.listForPullRequestReviewComment({
      owner, repo, comment_id: comment.id,
    })
    const thumbsUp = reactions.data.filter(r => r.content === '+1').length
    const thumbsDown = reactions.data.filter(r => r.content === '-1').length

    await db.insert({ commentId: comment.id, thumbsUp, thumbsDown, category: '...' })
  }
}

thumbs down 多的 category,说明那类 prompt 得调了。

跑几个月回头看数据会发现:安全类检测准确率最高,pattern 明确嘛;重构建议争议最大,"好代码"这事本来就主观。准确率低的 plugin 降 severity 或者直接关掉。这套东西最后值不值钱,不取决于模型多强,取决于你愿不愿意持续看数据、调 prompt、做迭代。

React Native 远程多语言动态更新方案详解

2026年3月12日 08:31

React Native 远程多语言动态更新方案详解

在移动端应用开发中,多语言(Internationalization, i18n)是一个基础需求。然而,传统的“将翻译文件打包在 App 中”的方案存在一个痛点:文案修改需要发版

为了解决这个问题,我们设计了一套 “本地兜底 + 本地缓存 + 远程同步” 的混合多语言方案,实现了文案热更新,同时保证了离线可用性即时性

核心架构:三级加载策略

我们的多语言系统遵循“三级流水线”加载机制,优先级从低到高,确保用户在任何网络环境下都能看到正确的文案。

Level 1: 本地兜底 (Local Files)

  • 时机:App 启动瞬间。
  • 机制i18next 初始化时加载打包在 JS Bundle 中的 JSON 文件(如 src/i18n/locales/zh-Hans/common.json)。
  • 作用绝对安全网。即使 App 刚安装且无网络,也能显示基础文案,避免出现 Key 值乱码(如 login.submit)。

Level 2: 本地缓存 (MMKV Cache)

  • 时机:App 组件挂载时。
  • 机制:通过 MMKV 高性能键值存储,读取上一次从服务器下载的最新翻译包。
  • 作用离线体验。用户打开 App 立即可见最新文案,无需等待网络请求,体验流畅。

Level 3: 远程同步 (Remote Sync)

  • 时机:用户登录后 / 网络就绪时。
  • 机制
    1. 请求后端接口获取最新版本号。
    2. 对比本地缓存版本。
    3. 如有差异,下载最新 JSON 并合并到内存中。
    4. 持久化到 MMKV,供下次启动使用。
  • 作用热更新。运营后台修改文案,用户端静默生效。

关键代码实现

1. 启动时的双重保障 (RemoteI18nContext.js)

我们在 Context 初始化时,会强制加载一次缓存,确保在等待网络请求期间,界面已经显示的是“上次最新”的文案。

// src/context/RemoteI18nContext.js

useEffect(() => {
  // 1. 无论是否登录,首先加载本地缓存,保证离线可用性
  loadFromCache();
}, []);

useEffect(() => {
  // 2. 仅当登录后,才触发远程同步
  if (isSignedIn) {
    syncTranslations();
  }
}, [isSignedIn]);

2. 踩坑与修复:版本控制与增量更新

在实际生产环境中,我们遇到了两个棘手的问题,并进行了针对性修复。

问题一:版本回滚无效

现象:后端因为误操作发布了新版本,想回滚到旧版本(版本号变小),但 App 拒绝更新。 原因:原本的逻辑是 if (remoteVersion > localVersion),导致版本号变小被忽略。 修复:改为只要版本号不一致就更新,支持回滚。

// Before
if (Number(newVersionStr) > Number(currentVersion)) { ... }

// After
if (String(newVersionStr) !== String(currentVersion)) { 
  // 允许版本回滚,只要 hash/version 变了就同步
  fetchAndApply(...); 
}
问题二:增量更新导致字段丢失

现象:后端为了节省流量,只返回了修改过的字段(增量包)。App 接收后直接覆盖缓存,导致未修改的旧字段被丢弃,界面出现缺词。 修复:在写入缓存前,先执行深度合并 (Deep Merge)

// 1. 将新数据合并到 i18n 内存实例中(i18next 会处理深度合并)
i18n.addResourceBundle(cultureName, targetNs, finalData, true, true);

// 2. 关键点:从内存中读出【合并后】的完整数据
const mergedData = i18n.getResourceBundle(cultureName, targetNs) || finalData;

// 3. 将完整数据写入 MMKV 缓存
i18nStorage.set(key, JSON.stringify({
  value: mergedData, // 此时是全量数据
  version: version
}));

总结

这套方案完美平衡了动态性稳定性

  • 无需发版:文案错误随时修,运营活动随时上。
  • 无惧断网:MMKV 缓存保证离线也能用。
  • 无惧增量:合并逻辑确保字段不丢失。

通过这种“本地兜底 + 远程优先”的策略,我们构建了一个健壮的移动端多语言系统。

❌
❌