普通视图

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

优化:如何避免 React Context 引起的全局挂载节点树重新渲染

2025年11月19日 15:19

最近项目中一个React Context在不断的接受websocket事件,然后一直修改state,导致重复渲染过多,比较卡顿

TM的这状态管理真乱,趁机总结一下React Context的使用的注意事项

React Context 用于在组件树中传递数据,而不必手动地通过 props 逐层传递。然而,它的便利性也带来了一个常见的性能陷阱:当 Context 的值发生变化时,所有依赖该 Context 的消费组件都会重新渲染,即使它们只使用了 Context 值中的一小部分。

如果处理不当,这种全局性的重新渲染可能会拖慢你的应用,尤其是在 Context Provider 位于组件树顶层,并且其值包含频繁变动的数据时。

Context的重新渲染机制

当我们使用 useContext(MyContext)<MyContext.Consumer> 时,React 会在内部建立一个订阅关系。

  1. 当 Provider 的 value 属性发生变化时:React 会检查新旧值是否严格相等(===)。
  2. 如果值不相等:React 会通知所有订阅了该 Context 的 Consumer 组件执行重新渲染。

React 并没有对 Consumer 实际使用了 Context 值中的哪部分属性进行细粒度分析。只要 Context 的 value 对象本身 引用发生了变化,所有 Consumer 都会触发更新。

// ❌ 常见但易导致全局渲染的模式
const MyContext = createContext({ user: null, settings: {} });

function App() {
  // state 只要更新,value 对象就会创建一个新的引用
  const [appState, setAppState] = useState({ user: { name: 'Gemini' }, theme: 'dark' });

  // 每次 App 渲染,这个对象都是一个新的引用
  const contextValue = useMemo(() => appState, [appState]);

  return (
    <MyContext.Provider value={contextValue}>
      <Header />
      <Content />
      <Footer />
    </MyContext.Provider>
  );
}

// 假设 Header 只使用了 appState.user
function Header() {
  const { user } = useContext(MyContext);
  // ... 其他代码
  return <h1>Welcome, {user.name}</h1>;
}

// 假设 Footer 只使用了 appState.theme
function Footer() {
  const { theme } = useContext(MyContext);
  // ... 其他代码
  return <p>Current theme: {theme}</p>;
}

// ⚡️ 陷阱:即使只有 theme 变化,Header 也会重新渲染!

避免全局重新渲染

我们可以通过以下几种策略,将 Context 的重新渲染范围限制在真正需要更新的组件。

1. 拆分 Context

这是最简单、最有效的策略之一。与其将所有状态都塞入一个“大 Context”中,不如根据数据的更新频率耦合关系将其拆分成多个独立的 Context。

  • 高频更新 / 独立的 Context:例如,用户交互状态(IsLoadingContext)。
  • 低频更新 / 共享的 Context:例如,全局配置和静态数据(ThemeContext)。
// ✅ 拆分成多个独立的 Context
const UserContext = createContext(null);
const ThemeContext = createContext(null);

function App() {
  const [user, setUser] = useState({ name: 'Gemini' });
  const [theme, setTheme] = useState('dark');

  return (
    <UserContext.Provider value={user}>
      <ThemeContext.Provider value={theme}>
        <Header /> {/* 仅消费 UserContext */}
        <Footer /> {/* 仅消费 ThemeContext */}
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
}

// 优化效果:
// 1. user 变化,只有 Header 及其子树可能重新渲染。
// 2. theme 变化,只有 Footer 及其子树可能重新渲染。互不影响。

2. 使用 Custom Hook 和 memo 结合

这种方法适用于你无法拆分 Context,但又想防止 Consumer 重新渲染的情况

通过 useMemo 或自定义 Hook 仅提取 Context 中需要的属性,并结合 React.memo 来跳过不必要的渲染

// 针对 Header 组件的自定义 Hook
const useUser = () => {
  const context = useContext(MyContext);
  // 仅返回 user 部分,确保只有 user 改变时才返回新引用
  return useMemo(() => context.user, [context.user]); 
};

// 结合 React.memo
const MemoizedHeader = React.memo(function Header() {
  const user = useUser(); // 即使 MyContext 整体变了,只要 user 不变,useUser 就会返回旧引用

  // ... 渲染逻辑
});

// ⚡️ 陷阱规避:
// 1. MyContext 整体改变,MemoizedHeader 接收新的 props (即 useUser 返回的值)。
// 2. 但由于 useUser() 对 user 属性进行了 useMemo 优化,如果 user 对象引用没有变化,
// 3. React.memo 就会发挥作用,跳过 Header 的渲染。

END

祝大家暴富!!!

跟着TRAE SOLO全链路看看项目部署服务器全流程吧

作者 林太白
2025年11月19日 14:56

跟着TRAE SOLO全链路看看项目部署服务器全流程吧

接下来我们新建一个项目,然后将项目部署到服务器上,并且配置好以后可以在外网进行访问

安装nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.35.3/install.sh | bash

1、简单服务器环境搭建

接下来我们就实现把 Node.js 项目部署到 /opt/nexus-node-api 并配置外部访问

进入服务器以后安装环境

# 更新包列表
sudo apt update

# 安装 Node.js 和 npm
sudo apt install nodejs npm

# 验证安装
node --version
npm --version

项目创建

# 创建目录
sudo mkdir -p /opt/nexus-node-api

# 设置所有者和权限
sudo chown -R $USER:$USER /opt/nexus-node-api
chmod -R 755 /opt/nexus-node-api

# 进入目录
cd /opt/nexus-node-api

# 创建一个项目
nano app.js

项目内容

const http = require('http');
const server = http.createServer((req, res) => {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('Hello World!\n');
});
server.listen(3000, '0.0.0.0', () => {
    console.log(`Server running on port 3000`);
});

测试运行以及外网访问

注意点:一定要注意这个时候必须保证你的服务器里面的防火墙(安全组)规则里面有3000这个端口号

node app.js

现在访问 http://你的服务器IP:3000 应该能看到 "Hello World!"

2、正式项目配置

卸载node环境

这里我们使用nvm来配置我们的环境,如果已经有的,我们删除一下已经有的环境

# 卸载 nodejs 和 npm
sudo apt-get remove nodejs npm
sudo apt-get purge nodejs npm

安装nvm

// 安装 nvm
# 建议安装 nvm,方便版本管理
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash

配置环境变量

# 编辑 .bashrc
nano ~/.bashrc

//添加配置 ---一般系统会自动为我们添加
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

// 验证 nvm 安装
nvm --version

安装稳定版本node

ubuntu为例子
// 查看可以安装的稳定版本
nvm ls-remote

// 这里我安转版本
nvm install v22.12.0
// 使用
nvm use v22.12.0

// 设置默认版本
nvm alias default v22.12.0
//   pm2
npm i -g pm2

使用pm2守护进程

PM2 是 Node 应用的进程管理工具,能保证服务在后台持续运行:要不然关闭窗口之后,就无法访问了

# 全局安装 PM2
npm install pm2 -g

# 启动服务并命名(方便管理)
pm2 start app.js --name "node-api-nexus"

# 查看服务状态
pm2 list  # 若 Status 为 online 则表示启动成功

这个时候不管怎么刷新我们的页面或者窗口,可以始终稳定访问我们的接口

pm2 重启对应的服务
pm2 restart "node-api-nexus"

3、服务器安装mysql数据库

环境搭建

接下来我们在服务器上安装mysql数据库,这里需要我们输入服务器密码

# 更新包列表
sudo apt update

# 安装 MySQL 服务器 
// 安装 MySQL 8.0(Ubuntu 默认源即提供 MySQL 8.0)
sudo apt install mysql-server -y

# 安装过程中可能会提示输入服务器密码

# 确认 MySQL 版本
mysql --version

MYSQl数据库安全配置

调整 MySQL 服务器的安全性
# 安全配置(可选但推荐)
sudo mysql_secure_installation

测试可以都选n


按照提示配置:
是否启用强密码   // y 
设置 root 密码   // Le@1996#Lin
移除匿名用户
禁止远程 root 登录(可选)
删除测试数据库
重新加载权限表
登录 MySQL
sudo mysql -u root -p
输入密码即可

配置远程访问(可选)

配置 MySQL 允许本地连接:

sudo nano /etc/mysql/mysql.conf.d/mysqld.cnf

配置信息
[mysqld]
# 确保绑定到本地
bind-address = 127.0.0.1

# 设置端口
port = 3306

# 设置字符集
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
修改mysql数据库配置
[mysqld]
# 注释掉原来的 bind-address 或改为 0.0.0.0
# bind-address = 127.0.0.1
bind-address = 0.0.0.0
port = 3306
重启服务,登录mysql创建远程连接用户
// 重启mysql服务
sudo systemctl restart mysql

// 登录
sudo mysql -u root -p

// 密码
123456

-- 创建远程用户(% 表示允许任何IP连接)
CREATE USER '账号'@'%' IDENTIFIED BY '密码';

-- 授予权限
GRANT ALL PRIVILEGES ON *.* TO '密码'@'%' WITH GRANT OPTION;

-- 或者只授权特定数据库(跳过)
-- GRANT ALL PRIVILEGES ON your_database.* TO 'remote_user'@'%';

-- 刷新权限
FLUSH PRIVILEGES;

-- 查看用户
SELECT User, Host FROM mysql.user;

-- 退出
EXIT;

// 重启 MySQL
sudo systemctl restart mysql

// 设置开机自启(默认应已设置)
sudo systemctl enable mysql

4、navicat远程mysql数据库

切记:一定要保证我们的服务器已经添加了我们的端口3306

服务器允许我们远程连接

# 开放 3306 端口
sudo ufw allow 3306

# 或者只允许特定IP访问(更安全)
sudo ufw allow from 你的本地IP to any port 3306

# 查看防火墙状态
sudo ufw status

远程连接

本地远程mysql数据库,我使用的是navicat工具,这里直接输入我们的信息

连接名:远程服务器,随便起名字
主机:服务器IP
用户名:上面设置的
密码:上面设置的

测试一下,服务器的数据库已经连接成功了

数据库连接测试

新建一个数据库,这里我的名称是nexus

数据库名:nexus
字符集:utf8mb3
排序规则:utf8mb3_bin

新建一个表

DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user`  (
  `user_id` int(0) NOT NULL AUTO_INCREMENT COMMENT 'id',
  `name` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '姓名',
  `age` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '年龄',
  `sex` int(0) NULL DEFAULT NULL COMMENT '用户性别 1男 2女 ',
  `create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',
  `address` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '用户的地址',
  `state` tinyint(0) NULL DEFAULT NULL COMMENT '1 正常  0 2  禁用',
  `phone` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '手机号',
  `username` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '用户的登录账号',
  `password` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL DEFAULT '123456' COMMENT '用户的登录密码',
  `avatar` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '头像地址',
  `update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间',
  `user_height` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '身高',
  `user_weight` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '体重',
  `disease` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '健康状况,是否有疾病',
  PRIMARY KEY (`user_id`, `password`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 55 CHARACTER SET = utf8mb3 COLLATE = utf8mb3_general_ci ROW_FORMAT = Dynamic;

本地运行项目测试

这里我们现在就本地启动项目连接我们服务器,然后进行测试,这里我以开源的Node项目为例,主要修改四个参数

const dbhost='xx'; // 数据库主机地址,如果是本地数据库则使用localhost
const dbdatabase='xx'; // 数据库名称
const dbuser='xx'; // 数据库用户名
const dbpassword='xxx'; // 数据库密码

本地测试一下,我们的线上数据库已经可以使用了

5、Node项目部署

接下来我们将node项目部署进我们的服务器,首先把我们项目都扔进去

配置环境

这里我用的是yarn,安装一下

npm install yarn -g 


// 配置环境
yarn

// 启动pm2
pm2 start app.js --name "node-api-nexus"

// 重新启动pm2 设置开机自启
pm2 startup
pm2 save

查看详细日志

pm2 logs node-api-nexus

启动以后我们就可以直接在浏览器打开地址对我们的系统后台进行访问了

http://XXXXXX:3200/

6、前端部署

环境安装

接下来我们继续部署我们的前端应用,先用我们的项目连接一下我们的数据库尝试一下 OK,没什么问题,然后我们开始部署前端项目

项目名称为nexus-vue,项目打包好的路径位于 /opt/nexus-vue 下面

// 打包前端项目
yarn build

// 更新和安装nginx 
// 更新可以跳过 之前我们已经进行过
sudo apt update
sudo apt install nginx

// 查看版本
nginx -V

配置nginx

sudo nano /etc/nginx/sites-available/nexus-vue

// 配置如下
server {
    listen 80;
    server_name localhost;  # 替换为你的域名或IP

    root /opt/nexus-vue;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }

    # 如果需要代理API请求
    location /api {
        proxy_pass http://localhost:3000;
    }
}
server {
    listen 8080;
    server_name localhost;  # 替换为你的域名或IP

    # 前端静态文件
    root /opt/nexus-vue;
    index index.html;

    # 前端路由
    location / {
        try_files $uri $uri/ /index.html;
    }

    # 后端API请求
    location /api {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }

    # WebSocket连接
    location /ws {
        proxy_pass http://localhost:3001;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
    }
}

开放接口

sudo ufw allow 8080

sudo systemctl restart nginx

处理日志错误

// 检查nginx错误日志
sudo tail -f /var/log/nginx/error.log


//开放文件权限
sudo chmod -R 755 /opt/nexus-vue

// 检查配置
sudo nano /etc/nginx/sites-available/nexus-vue

// 重新启动nginx
sudo systemctl restart nginx

部署

写一个测试页面扔进去

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>我是测试页面</title>
</head>
<body>
<h1>我是测试页面</h1>
</body>
</html>

访问我们的地址http://域名IP:8080/

这个时候已经可以看到我们的项目已经部署上去了

重新加载以后,ok,到这里我们全链路都部署上去了

把 16MB 中文字体压到 400KB:我写了一个 Vite 字体子集插件

2025年11月19日 14:47

一个真实的前端项目里,我遇到一个很常见却又容易被忽视的问题:一套中文界面,为了保证视觉效果引入了整套中文字体文件,结果单字体就占了十几 MB,构建产物和安装包体积都被严重拖累。现有方案要么依赖运行时,要么在工程化集成上不太符合我的需求。于是我决定写一个专门面向 Vite 的字体子集化插件 @fe-fast/vite-plugin-font-subset:在构建阶段自动收集实际用到的字符集,生成子集化后的 woff2 字体,并无缝替换原有资源,不侵入业务代码。在这篇文章里,我会分享这个插件诞生的背景、设计目标、关键实现思路,以及在真实项目中带来的体积优化效果,希望能给同样被“中文字体体积”困扰的你一些参考。

一、项目背景:16MB 的中文字体,把包体积拖垮了

我日常主要做的一个项目,是基于 Vue3 + Vite + Pinia + Electron 的桌面应用(电力行业业务系统)。
这个项目有两个典型特点:

  • 中文界面 + 大量业务术语:几乎所有页面都是中文,且有不少专业名词
  • 离线/弱网场景:不仅要打成 Electron 安装包,还要支持在弱网环境下更新

随着功能越来越多,我开始频繁在构建日志里看到这样一段“刺眼”的内容:

  • src/SiYuanHeiTi 目录里有两份 SourceHanSansCN OTF 字体
  • 每一份大概 8MB+,加起来就是 16MB+ 的纯字体资源

哪怕我已经做了一些优化:

  • 图片用 vite-plugin-imagemin 压缩
  • 代码做了基础的拆包和懒加载

构建产物里字体资源仍然是绝对大头
简单说:用户只是打开一个中文界面,却要被迫下载完整一套 GBK 字库,这显然太浪费了。

二、降体积的几种思路,对比一下

在真正动手写插件之前,我先把可能的方案都过了一遍,权衡了一下利弊。

方案 1:换成系统字体 / 常见 Web 字体

  • 优势
    • 不需要额外的字体文件,体积几乎为 0
  • 劣势
    • 设计同学辛苦做的 UI 风格会被破坏
    • 跨平台(Windows/macOS)渲染效果不可控,特别是复杂表格、图形界面
  • 适用场景
    • 对视觉统一要求不高的后台系统、管理台
  • 实现难度:⭐

方案 2:直接引入现成的字体子集化工具 / 在线服务

  • 优势
    • 现有方案成熟,不用自己“造轮子”
  • 劣势
    • 有些是在线服务,不适合公司内网/离线场景
    • 一些工具只关注命令行,不关注 Vite 构建流程 的无缝集成
  • 适用场景
    • 纯 Web 项目、对 CI/CD 环境更自由的团队
  • 实现难度:⭐⭐⭐

方案 3:使用已有的 Vite 字体子集插件

我也尝试过社区已有的 vite-plugin-font-subset 等插件,但踩到了两个坑:

  1. ESM-only 与现有工程的兼容问题
    • 有的插件是纯 ESM 包,而我当时的构建链路里,

      vite.config.js 仍然是以 CJS 方式被 esbuild 处理

    • 直接 import 会在加载配置阶段就报:

      ESM file cannot be loaded by require

  2. “大量中文 + 特殊字符”场景需要更多可配置性
  • 优势
    • 理论上“开箱即用”,几行配置就能跑
  • 劣势
    • 在我的项目环境里,兼容性和可扩展性都有一些限制
  • 适用场景
    • Node / Vite 配置已经完全 ESM 化的新项目
  • 实现难度:⭐⭐

推荐选择 & 我的决策

  • 在综合权衡之后,我选择了:
    “在 Vite 插件体系内,写一个适配自己项目的字体子集化插件,并抽象成通用插件发布出来”

  • 于是就有了今天的这个包:
    **

    fe-fast/vite-plugin-font-subset**

三、我给插件定下的几个目标

在真正敲代码之前,我给这个插件定了几个很具体的目标:

  1. 零运行时开销

    • 所有工作都在 vite build 阶段完成
    • 运行时只加载子集后的 woff/woff2 文件
  2. 对现有项目“侵入感”足够低

    • 只需要在 vite.config 里增加一个插件配置
    • 不要求你改动业务代码里的 font-family 或静态资源引用方式
  3. 兼容我当前的工程形态

    • 支持 Electron + Vite 的场景
    • 避免“ESM-only 插件 + CJS 配置”这种加载失败问题
  4. 默认就能解决“中文大字体”问题

    • 在不配置任何参数的情况下,对于常规的中文页面,能直接减掉大部分无用字形

四、核心思路:从字符集到子集字体的流水线

具体实现细节线上可以看源码,这里更侧重讲清楚“思路”,方便大家自己扩展或实现类似插件。

整个插件的执行链路,大致可以拆成四步:

1. 收集可能会用到的字符集

  • 扫描构建产物(或者源码)里的:
    • 模板中的中文文案
    • 国际化文案 JSON
    • 常见 UI 组件中的静态字符
  • 做一些去重和过滤,得到一个 相对完整但不过度膨胀的字符集合

这里的关键是平衡:

  • 集合太小:生产环境会出现“口口口/小方块”
  • 集合太大:子集化收益会变差

2. 调用子集化引擎生成子集字体

  • 将“原始 OTF/TTF 字体文件 + 上面的字符集”交给子集工具
  • 输出一份或多份新的字体文件(优先 woff2)

在我的项目中,最终生成的结果类似构建日志中的这一行:

textSourceHanSansCN-Normal-xxxx.woff2    223.97 kBSourceHanSansCN-Medium-xxxx.woff2    224.79 kB

相比最初 两份 8MB+ 的 OTF 文件,体积已经被压到了大约十分之一左右。

3. 更新 CSS / 资源引用

  • 在原有的

    font-face 声明基础上,修改 src 指向子集化后的文件

  • 对于 Vite 生成的静态资源目录(如 dist/prsas_static),保持输出路径稳定,避免破坏现有引用

这一部分的目标是:对业务代码完全透明,你仍然可以这样写:

cssbody {  font-family: 'SourceHanSansCN', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;}

只是最终加载的资源,不再是原来那两个 8MB 的 OTF,而是几百 KB 的子集 woff2。

4. 和 Vite 构建流程集成

  • 通过 Vite 插件 API,在合适的生命周期(如 configResolvedgenerateBundle 等)
    • 拿到最终输出目录
    • 触发上面的子集化流水线
    • 将生成的文件写回到 Rollup 构建产物中

核心原则就是:不打破 Vite 原有工作流,只是“在尾部插一个子集化步骤”

五、在真实项目中的效果

以我这个 Electron + Vite 项目为例,启用

fe-fast/vite-plugin-font-subset 之后:

  • 原来两份 8MB+ 的 OTF 中文字体
  • 变成两份 两百多 KB 的 woff2 子集字体
  • 对比结果非常直观:
    • 安装包体积明显下降
    • 首次加载速度、增量更新速度都有肉眼可见的提升
    • 用户几乎感受不到视觉上的差异

配合 vite-plugin-imagemin 对 PNG 等图片资源的压缩,整体构建体验也变成了:

  • 构建时间长一点(多了字体子集化和图片压缩),但属于“可接受的离线计算”
  • 换来的是 更小的安装包、更快的首屏体验,尤其适合弱网和内网环境

六、如何使用这个插件

简单说一下使用方式(仅作示意,具体参数可以看 README):

bashnpm install -D @fe-fast/vite-plugin-font-subset

ts// vite.config.ts / vite.config.jsimport fontSubsetPlugin from '@fe-fast/vite-plugin-font-subset'export default defineConfig({  plugins: [    vue(),    fontSubsetPlugin({      // 一些可选配置,例如:      // fonts: [{ path: 'src/SiYuanHeiTi/SourceHanSansCN-Normal.otf', name: 'SourceHanSansCN' }],      // include: ['src/**/*.vue', 'src/**/*.ts'],      // ...    }),  ],})

做到这一点之后,剩下的事情就交给构建阶段处理即可。

七、过程中的几个坑 & 经验

在开发这个插件的过程中,也遇到了一些值得记录的坑:

  • ESM vs CJS 的兼容

    • 之前用其他字体插件时,遇到过 ESM file cannot be loaded by require 的报错
    • 这直接促使我在发布这个插件时,特别注意构建目标和导出形式,让它能更好地兼容现有工程
  • 字符集过于激进会导致“缺字”

    • 一开始我只统计了模板里的中文字符,结果线上发现某些动态内容会出现“口口口”
    • 最终方案是:适度保守 + 预留一部分常用汉字范围
  • 构建时间和体验的平衡

    • 字体子集化本身是一个“CPU 密集型”的过程
    • 在开发环境我默认关闭了子集化,仅在 vite build 时启用,保证日常开发体验

八、总结与展望

fe-fast/vite-plugin-font-subset 其实不是一个“炫技”的轮子,而是从真实业务需求里长出来的:

  • 它解决的是一个非常具体的问题:中文项目中,字体资源过大导致包体积和加载体验变差
  • 它也体现了我在做前端工程化时的一些偏好:
    • 用好现有工具链(Vite 插件体系)
    • 优先选择“构建时处理”,而不是在运行时增加复杂性
    • 遇到兼容性问题时,适当地“自己造一个更适合现有工程的轮子”

后续我还希望在这个插件上做几件事:

  • 更智能的字符集分析(结合路由拆分、按需子集)
  • 提供简单的可视化报告,让你一眼看到“字体减肥”前后的体积对比
  • 增强对多语言项目的支持

Quill 2.x 从 0 到 1 实战 - 为 AI+Quill 深度结合铺路

作者 humor
2025年11月19日 14:22

引言

在AIGC浪潮席卷各行各业的今天,为应用注入AI能力已从“锦上添花”变为“核心竞争力”。打造一个智能写作助手,深度融合AI与富文本编辑器,无疑是抢占下一代内容创作高地的关键一步。

而一切智能编辑的基石,在于一个稳定、强大且高度可定制的基础编辑器。本文将深度解析 ‌Quill 2.x——这个在现代Web开发中备受青睐的富文本编辑器解决方案。快来开始Quill2.x的教程吧!

本文将从概念解析到实战落地,补充核心原理、汉化方案和避坑指南,帮你真正吃透 Quill 2.x,看完就能直接应用到项目中。

一、Quill 核心概念:它到底是什么?

在动手之前,先搞懂 Quill 的核心定位,避免用错场景:

Quill 是一款「API 驱动的富文本编辑器」,核心设计理念是「让开发者能精准控制编辑行为」。它不同于传统编辑器(如 TinyMCE、CKEditor)的「配置式黑盒」,而是通过暴露清晰的 API 和内部状态,让开发者像操作 DOM 一样操作编辑器内容。

几个关键概念需要明确:

  • 容器(Container) :用于承载编辑器的DOM元素,Quill会接管该元素并渲染编辑区域
  • 模块(Modules) :编辑器的功能单元(如工具栏、代码块),2.x 中模块需显式注册。
  • 主题(Themes) :编辑器外观,官方提供 snow(带固定工具栏)和 bubble(悬浮工具栏)两种,支持自定义样式。
  • Delta:Quill 独创的内容描述格式(类似 JSON),用于表示内容本身和内容变化,是实现协同编辑、版本控制的核心。
  • 格式(Formats) :描述内容的样式属性(如加粗、颜色、链接),可通过 API 或工具栏触发,支持自定义扩展。

二、原理解析:Quill 是如何工作的?

理解底层原理,能帮你更灵活地解决问题。Quill 的核心工作流程可分为三部分:

1. 内容表示:Delta 格式

传统编辑器用 HTML 字符串描述内容,但 HTML 存在「同内容多表示」(如 <b> 和 <strong> 都表示加粗)、「难以 diff 对比」等问题。而 Delta 用极简的结构解决了这些问题:

Delta 本质是一个包含 ops 数组的对象,每个 op 由 insert(内容)和 attributes(样式)组成。例如:

// 表示「Hello 加粗文本」的 Delta
{
  ops: [
    { insert: '这是一段 ' },
    { insert: '加粗文本', attributes: { bold: true } }
  ]
}

image.png

  • 优势 1:唯一性 —— 同一内容只有一种 Delta 表示,避免歧义。
  • 优势 2:可合并 —— 两个 Delta 可通过算法合并(如用户 A 和用户 B 同时编辑的内容),是协同编辑的基础。
  • 优势 3:轻量性 —— 比 HTML 更简洁,传输和存储成本更低。

2. 渲染机制:2.x 版本的性能飞跃

Quill 1.x 直接操作 DOM 渲染内容,当内容量大时容易卡顿。2.x 重构了渲染逻辑,采用「虚拟 DOM 思想」优化:

  • 内部维护一份「文档模型(Document Model)」,作为内容的单一数据源。
  • 当内容变化,先更新文档模型,再通过「差异计算」只更新需要变化的 DOM 节点。
  • 减少 30% 以上的 DOM 操作,大幅提升大数据量场景(如万字长文)的流畅度。

3. 模块架构:功能的解耦与扩展

Quill 的所有功能都通过「模块」实现,核心模块包括:

  • toolbar:工具栏,控制格式按钮的显示和交互。
  • history:记录操作历史,支持撤销 / 重做。
  • table:2.x 原生支持的表格模块(1.x 需第三方扩展)。
  • clipboard:处理复制粘贴,自动过滤危险内容。

模块之间相互独立,开发者可按需注册,也能通过 Quill.register() 自定义模块,实现功能的灵活扩展。

三、快速入门:5 分钟搭建基础编辑器

安装依赖 -> 基础初始化 -> 核心API -> 预告

1. 安装依赖

bash

运行

# 核心包(2.x 版本)
pnpm add quill@2.x

# 表格模块(2.x 需单独安装,原生支持)
pnpm add @quilljs/table

2. 基础初始化

Step 1:HTML 容器

<div id="editor" style="height: 300px;"></div>

Step 2:引入并注册模块

import Quill from 'quill';
import 'quill/dist/quill.snow.css'; // 引入 snow 主题样式
import TableModule from '@quilljs/table'; // 表格模块

// 显式注册模块 
Quill.register('modules/table', TableModule);

Step 3:初始化配置 - 方案一

const quill = new Quill('#editor', {
  theme: 'snow', // 选择主题
  modules: {
    toolbar: { 
        container: [
            // 每个数组是一个分组,里边每个项是一个工具栏最小配置单元
            ['bold', 'italic', 'underline', 'strike'], // 基本格式
            ['blockquote', 'code-block'], // 块引用和代码块 
            [{ 'header': 1 }, { 'header': 2 }], // 标题级别
            [{ 'list': 'ordered'}, { 'list': 'bullet' }], // 有序列表和无序列表 
            [{ 'script': 'sub'}, { 'script': 'super' }], // 上标和下标 
            [{ 'indent': '-1'}, { 'indent': '+1' }], // 缩进
            [{ 'direction': 'rtl' }], // 文本方向
            [{ 'size': ['small', false, 'large', 'huge'] }], // 字体大小
            [{ 'header': [1, 2, 3, 4, 5, 6, false] }], // 标题级别(完整) 
            [{ 'color': [] }, { 'background': [] }], // 颜色选择 
            [{ 'font': [] }], // 字体选择 
            [{ 'align': [] }], // 对齐方式
            ['link', 'image', 'video'], // 链接和媒体 
            ['clean'] // 清除格式 ], 
             // 方式2:使用选择器配置 // container: '#toolbar',
             // 方式3:使用自定义工具栏HTML 
             // container: document.getElementById('custom-toolbar') }
  },
  placeholder: '请输入内容...'
});

Step 3:初始化配置 - 方案2

const quill = new Quill('#editor', {
  theme: 'snow', // 选择主题
  modules: {
    toolbar: { 
       // 使用选择器配置(或者document.getElementById('custom-toolbar'))
        container: '#toolbar',
       }
  },
  placeholder: '请输入内容...'
});
.custom-toolbar {
    display: flex;
    flex-wrap: wrap;
    gap: 5px;
    align-items: center;
}
.custom-toolbar .ql-formats {
    margin-right: 15px;
    display: flex;
    align-items: center;
}
.custom-toolbar button {
    border: 1px solid #ddd;
    border-radius: 5px;
    padding: 5px 10px;
    background: white;
    cursor: pointer;
    transition: all 0.3s ease;
}
.custom-toolbar button:hover {
    background: #e9ecef;
    border-color: #adb5bd;
}
.custom-toolbar select {
    border: 1px solid #ddd;
    border-radius: 5px;
    padding: 5px;
    background: white;
}
        
<div id="custom-toolbar" class="toolbar-container">
    <div class="custom-toolbar">
        <!-- 字体和大小 -->
        <span class="ql-formats">
            <select class="ql-font"></select>
            <select class="ql-size"></select>
        </span>

        <!-- 文本格式 -->
        <span class="ql-formats">
            <button class="ql-bold" title="粗体"></button>
            <button class="ql-italic" title="斜体"></button>
            <button class="ql-underline" title="下划线"></button>
            <button class="ql-strike" title="删除线"></button>
        </span>

        <!-- 颜色 -->
        <span class="ql-formats">
            <select class="ql-color" title="文字颜色"></select>
            <select class="ql-background" title="背景颜色"></select>
        </span>

        ....
    </div>
</div>

3. 核心 API:内容操作

// 获取 Delta 内容(推荐存储)
const delta = quill.getContents();

// 获取 HTML 内容(用于展示)
const html = quill.root.innerHTML;

// 设置内容(支持 Delta 或纯文本)
quill.setContents([{ insert: 'Hello Quill\n', attributes: { bold: true } }]);

// 插入内容(在光标位置)
const range = quill.getSelection(); // 获取光标位置
quill.insertEmbed(range.index, 'image', 'https://example.com/img.png');

// 标记文案为黄色 -- 预告:下一篇文章我们会通过AI查找文档错误,然后用这个API标记错误内容
quill.formatText(
    startIndex, // 索引
    endIndex, // 索引
    {
      background: "yellow"
    },
    Quill.sources.SILENT
);

// 获取选区格式
quill.getFormat(index, 1)

// 指定位置追加内容 -- 需要保持格式  (预告:下一篇我们会用这个功能将AI扩写的内容追加到指定位置)
const formats = instance.value.getFormat(
  range.index + range.length - 1,
  1
);
quill.insertText(index, '追加内容', formats, Quill.sources.USER);

预告

  1. 下一篇文章我们会通过AI查找文档错误,然后用formatText标记错误内容
  2. 下一篇我们会用insertText将AI扩写的内容追加到指定位置
  3. 更多内容见下一篇文章

四、核心功能实战:从汉化到媒体处理

汉化 -> 增加工具栏-图片上传 -> 自定义quill格式 -> 自定义quill属性格式

1. 汉化:让编辑器「说中文」

Quill 默认提示为英文(如工具栏按钮的 tooltip),需手动汉化:

scss为例

标题汉化

.editor-wrapper {
  :deep(.ql-toolbar) {
    .ql-picker.ql-header {
      width: 70px;

      .ql-picker-label::before,
      .ql-picker-item::before {
        content: "正文";
      }

      @for $i from 1 through 6 {
        .ql-picker-label[data-value="#{$i}"]::before,
        .ql-picker-item[data-value="#{$i}"]::before {
          content: "标题#{$i}";
        }
      }
    }
  }
}

字体汉化

```字体汉化
.editor-wrapper {
  :deep(.ql-toolbar) {
    .ql-picker.ql-font {
      .ql-picker-item,
      .ql-picker-label {
        &[data-value="SimSun"]::before {
          content: "宋体";
          font-family: "SimSun" !important;
        }

        &[data-value="SimHei"]::before {
          content: "黑体";
          font-family: "SimHei" !important;
        }

        &[data-value="KaiTi"]::before {
          content: "楷体";
          font-family: "KaiTi" !important;
        }
 

        &[data-value="FangSong_GB2312"]::before {
          content: "仿宋_GB2312";
          font-family: "FangSong_GB2312", FangSong !important;
          width: 80px;
          overflow: hidden;
          white-space: nowrap;
          text-overflow: ellipsis;
          line-height: 24px;
        }

        
      }
    }
  }

  :deep(.ql-editor) {
    font-family: "SimSun", "SimHei", "KaiTi", "FangSong", "Times New Roman",
      sans-serif !important;
  }
}

汉化思路一致,不一一列出,有需要可随时私我

2. 图片上传:从本地到服务器

默认图片按钮只能输入 URL,需重写逻辑实现本地上传:

const toolbarOptions = {
  container: ['image'],
  handlers: {
    image: function() {
      const input = document.createElement('input');
      input.type = 'file';
      input.accept = 'image/*';
      
      input.onchange = (e) => {
        const file = e.target.files[0];
        if (!file) return;
        
        // 上传到服务器(替换为你的接口)
        const formData = new FormData();
        formData.append('file', file);
        
        fetch('/api/upload', { method: 'POST', body: formData })
          .then(res => res.json())
          .then(data => {
            // 插入图片到编辑器
            const range = quill.getSelection();
            quill.insertEmbed(range.index, 'image', data.url);
          });
      };
      
      input.click(); // 触发文件选择
    }
  }
};

3. 自定义规则:字体规则

注册字体 -> 工具栏配置 -> css适配

注册字体

import Quill from "quill";

export const useFontHook = () => {
  // // 注册自定义字体
  const Font: Record<string, any> = Quill.import("attributors/style/font");
  Font.whitelist = [
    "FangSong_GB2312",
    "KaiTi_GB2312",
    "FZXBSJW-GB1-0",
    "FangSong",
    "SimSun",
    "SimHei",
    "KaiTi",
    "Times New Roman"
  ]; // 字体名称需与 CSS 定义一致
  Quill.register(Font, true);

  return {
    Font
  };
};

工具栏配置

const { Font } = useFontHook();
... 
toolbar: {
    container: [
        [
            { size: SizeStyle.whitelist }, // 这里是自定义size
            {
              font: Font.whitelist
            }
          ], // custom dropdown
        ]
}

css适配

同汉化部分

.editor-wrapper {
  :deep(.ql-toolbar) {
    .ql-picker.ql-font {
      .ql-picker-item,
      .ql-picker-label {
        &[data-value="SimSun"]::before {
          content: "宋体";
          font-family: "SimSun" !important;
        }

        &[data-value="SimHei"]::before {
          content: "黑体";
          font-family: "SimHei" !important;
        }

        &[data-value="KaiTi"]::before {
          content: "楷体";
          font-family: "KaiTi" !important;
        }
        ...
      }
    }
  }

  :deep(.ql-editor) {
    font-family: "SimSun", "SimHei", "KaiTi", "FangSong", "Times New Roman",
      sans-serif !important;
  }
}

4. 自定义属性格式 -- 以margin,值为em为例

Quill工具栏是没有边距效果的(有text-indent,场景不一样),需要自行写格式

import Quill from "quill";
const Parchment = Quill.import("parchment");

const whitelist = ["2em", "4em", "6em", "8em"];

export function useMarginHook() {
  class MarginAttributor extends Parchment.StyleAttributor {
    constructor(styleName, key) {
      super(styleName, key, {
        scope: Parchment.Scope.BLOCK,
        whitelist
      });
    }

    add(node, value) {
      // 直接验证传递的字符串是否在白名单中
      if (!this.whitelist.includes(value)) return false;
      return super.add(node, value);
    }
  }

  Quill.register(
    {
      "formats/custom-margin-left": new MarginAttributor(
        "custom-margin-left",
        "margin-left"
      ),
      "formats/custom-margin-right": new MarginAttributor(
        "custom-margin-right",
        "margin-right"
      )
    },
    true
  );
}


// 工具栏配置
toolbar: [
  [{ 'custom-margin-left': ['2em', '4em', '6em', '8em'] }], 
  [{ 'custom-margin-right': ['2em', '4em', '6em', '8em'] }] 
]

五、事件与扩展:深度控制编辑器

1. 事件监听:响应编辑行为

// 内容变化时触发(用于自动保存 或者 统计字数等)
quill.on('text-change', (delta, oldDelta, source) => {
  if (source === 'user') { // 仅处理用户操作
    console.log('内容变化:', delta);
  }
});

// 光标/选择范围变化时触发(用于显示格式提示)
quill.on('selection-change', (range, oldRange, source) => {
  if (range && range.length > 0) {
    const text = quill.getText(range.index, range.length);
    console.log('选中文本:', text);
  }
});

2. 自定义格式:添加「高亮」功能

// 注册自定义格式
Quill.register({
  'formats/highlight': class Highlight {
    // 从 DOM 中读取格式
    static formats(domNode) {
      return domNode.style.backgroundColor === 'yellow' ? 'yellow' : false;
    }
    
    // 应用格式到 DOM
    apply(domNode, value) {
      domNode.style.backgroundColor = value === 'yellow' ? 'yellow' : '';
    }
  }
});

// 工具栏添加高亮按钮
const toolbarOptions = [
  [{ 'highlight': 'yellow' }]
];

// 初始化编辑器
const quill = new Quill('#editor', {
  modules: { toolbar: toolbarOptions },
  // ...其他配置
});

3. 自定义 module - 导出文件

增加工具栏、激活配置、module配置

 toolbar: {
    container: [
        'exportFile'
    ],
    // 激活handlers -- 必须手动激活 - 重要!!!
    handlers: {
      exportFile: true
    }
 },
 // exportFile插件的配置
  exportFile: {
      apiMethod: ({ htmlContent }) => {
          const html = getFileTemplate(htmlContent);
          downloadDocx({
              html
          });
      }
  }

模块注册与实现

useExportFilePlugin()

import Quill from "quill";

interface QuillIcons {
  [key: string]: string;
  exportFile?: string;
}

// 修改icon
const icons = Quill.import("ui/icons") as QuillIcons;
const uploadSVG =
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path fill="currentColor" d="M160 832h704a32 32 0 1 1 0 64H160a32 32 0 1 1 0-64m384-253.696 236.288-236.352 45.248 45.248L508.8 704 192 387.2l45.248-45.248L480 584.704V128h64z"></path></svg>';
icons.exportFile = uploadSVG;

interface IApiMethodParams {
  htmlContent: string;
}

// 定义类型
interface ExportFilePluginOptions {
  apiMethod: (params: IApiMethodParams) => Promise<Blob>;
}

 

export const useExportFilePlugin = () => {
 

  class ExportFilePlugin {
    private quill: any;
    private toolbar: any;
    private apiMethod: (params: IApiMethodParams) => Promise<Blob>;

    constructor(quill: any, options: ExportFilePluginOptions) {
      this.quill = quill;
      this.toolbar = quill.getModule("toolbar");

      if (!options?.apiMethod) {
        throw new Error("导出module必须传入apiMethod");
      }

      this.apiMethod = options.apiMethod;

      // 添加工具栏 
      this.toolbar.addHandler("exportFile", this.handleExportClick.bind(this));
    }

    private async handleExportClick() {
      try {
        const htmlContent = this.quill.root.innerHTML;

        if (htmlContent.trim?.() === "<p><br></p>") {
          console.log("内容不能为空");
          return;
        }

        // 使用配置的API方法
        return this.apiMethod({ htmlContent });
      } catch (error) {
        console.error("导出失败:", error);
        return Promise.reject({
          error
        });
      }
    }
  }

  Quill.register("modules/exportFile", ExportFilePlugin);
};

自定义module或规则原理类似,很多,不一一列出,有需要可随时私我

六、避坑指南:这些问题要注意

1. 样式冲突:编辑器样式被全局 CSS 覆盖

问题:项目中的全局样式(如 p { margin: 20px })会影响编辑器内部的段落样式,导致排版错乱。

解决:用 CSS 隔离编辑器样式,通过父级类名限制作用域:

css

/* 给编辑器容器添加类名 quill-container */
.quill-container .ql-editor p {
  margin: 8px 0; /* 覆盖全局样式 */
}
.quill-container .ql-editor ul {
  padding-left: 20px;
}

2. 图片上传:跨域问题导致插入失败

问题:上传图片到第三方服务器时,因跨域限制导致 fetch 请求失败。

解决

  • 后端接口添加 CORS 头(Access-Control-Allow-Origin: *)。
  • 若无法修改后端,通过本地服务端代理转发请求:
// 前端请求本地代理接口
fetch('/proxy/upload', { method: 'POST', body: formData })
// 本地服务端将 /proxy/upload 转发到第三方服务器

3. 自定义模块:配置后不生效

问题:如“导出模块”配置后,工具栏按钮无响应。

核心原因:2.x版本中,自定义工具栏按钮需在handlers中手动激活。

解决方案:在toolbar配置中添加handlers激活项: 解决


modules: {
  toolbar: {
    container: ['exportFile'], // 自定义按钮
    // 必须手动激活,否则按钮点击无响应
    handlers: { exportFile: true } 
  },
  exportFile: { /* 模块配置 */ }
}

4. 获取选中文本 得到的结果多样性

代码 instance.value.getSelection(true)

问题 调用getText()时,返回结果可能为null、空对象或空字符串,导致后续操作报错

原因 光标未在编辑器内、用户未选中内容等场景会返回不同结果。

解决方案 封装工具函数处理边界情况:

/**
 * 获取选中文本 -- 只在真正有选中内容时候返回,否则返回''
 * @param focus是否聚焦 - true则能获取选中内容;false则代表光标不在富文本,会返回'' (非用户触发行为除外)
 * @returns obj code:-1代表没有选中  -2代表不在编辑器里 其他情况是有选中文本
 */
function getSelectionText(focus = true) {
  const range = instance.value.getSelection(focus);
  if (range) {
    if (range.length == 0) {
      console.log("用户没有选中任何内容");
      return {
        code: -1,
        text: "",
        range: {}
      };
    } else {
      const text = instance.value.getText(range.index, range.length);
      return {
        code: 1,
        text,
        range
      };
    }
  } else {
    console.log("用户光标不在富文本编辑器里");
    return {
      code: -2,
      text: "",
      range: {}
    };
  }
}

5. vue、react报错 Cannot read properties of null (reading 'offsetTop')

问题 在Vue3/React项目中,初始化Quill后控制台报上述错误 原因 框架响应式系统干扰Quill内部DOM计算逻辑 解决方案

  1. 用非响应式变量存储
  2. markRaw包裹quill实例 instance.value = markRaw(new Quill('#editor'))

七 汉化效果

工具栏和下拉内容均为中文

image.png

总结与后续预告

Quill 2.x 凭借「API 驱动」「Delta 格式」「模块化设计」三大特性,成为富文本编辑器的优质选择。本文从概念解析(是什么)、原理剖析(怎么工作)到实战落地(如何使用),再到避坑指南(常见问题),覆盖了 90% 的实用场景,掌握这些内容后,你可以轻松实现博客编辑器、在线文档、评论系统等功能

下一篇预告:《AI智能写作实战:让Quill编辑器“听话”起来》

我们将深度融合AIQuill2,实现三大核心功能:

  1. AI自动生成文档,填充到富文本编辑器
  2. AI自动检测内容错误并标记(formatText API)
  3. AI根据上下文扩写内容(insertText API)
  4. ...

资源获取

本文涉及的完整代码(含Vue3、汉化、自定义格式、自定义模块)已整理完毕,点赞+收藏+评论@我,即可私发资源包!

京东外卖App独立上线,超级App如何集成海量小程序?

作者 FinClip
2025年11月19日 14:20

11月17日,京东正式推出外卖独立App,不仅提供外卖服务,更整合了“外卖+即时零售+点评+酒旅+购物”等本地生活服务于一体 。同时,外卖App与京东主站打通,同步上线京东点评和京东真榜两大配套服务,满足“本地生活+日常购物”等不同场景下的多元需求,加码本地生活赛道。

图片

京东外卖App独立上线背后,反映出一个明显的行业趋势:在竞争日益激烈的市场环境中,快速构建多元化服务能力、打造一站式用户体验,已成为企业应对竞争的关键举措。 

然而,企业自建完整生态/超级App往往面临着重重挑战:

1.多端开发成本高:企业希望快速引入多元服务、构建自有生态,但每接入一项新业务,都需针对iOS、Android、HarmonyOS等系统独立开发,导致开发成本高、上线周期长。 

2.体验与性能难兼顾:企业既要保证多业务模块独立开发与快速上线,又要在不同终端上提供统一、流畅的原生用户体验。 

3.安全运营管控难:随着生态服务增多,如何在不影响上线效率的前提下,确保内容安全可控、功能平稳发布,并实现多业务线的有序管理,成为企业持续运营的关键难题。

针对以上痛点,FinClip超级应用智能平台以“一次开发、多端运行”为核心能力,为企业提供构建自主超级应用的可靠底座。

一、集成一次,适配无限场景

如同京东外卖App需要整合多元服务,企业App同样面临着生态化发展的迫切需求。 

在集成FinClip小程序SDK 后,企业的App无论是iOS 、Android,还是HarmonyOS的App设备上,都能获得运行小程序的能力。 

另一方面,FinClip小程序API 和组件与微信保持高度一致,支持使用自定义API,还提供了地图、蓝牙、WebRTC 等多种扩展SDK。通过FinClip集成,企业即可让现有App获得运行小程序的能力,快速引入各类第三方服务或自建新业务。

图片

二、开发一次,体验大幅提升

京东外卖App的多元服务整合背后,是对技术架构的升级。既要保证各服务模块的独立迭代,又要确保统一的用户体验,这正是小程序技术的优势所在。 

使用 FinClip小程序SDK升级适配基于App或HTML5开发的功能页面,不仅能够提供媲美原生开发的用户体验,还具备更加丰富的系统与设备权限调用能力,让每个业务模块都能独立开发、测试和发布。相关数据显示,基于FinClip构建的业务模块,其加载速度较传统H5提升约60%,用户操作流畅度显著改善。

图片

三、多场景全生命周期管理

本地生活类App需要管理众多第三方商家,企业引入多元服务时,同样需要完善的管理机制。那么,安全管控和运营效率至关重要。 针对此类企业客户需求,FinClip提供从开发、测试、审核到发布的全生命周期管理能力。 

安全沙箱技术:代码与业务内容均与宿主应用隔离,确保每个小程序都能在安全环境中独立运行,保障用户数据与信息安全; 

审核机制:小程序上下架审核、内容审核、确保用户端浏览的信息都处于统一监管之下,平衡了业务敏捷与安全合规; 

灰度发布功能:实时更新小程序内容,A/B测试新功能,可助力企业灵活响应热点事件,极大缩短开发周期并提升运营效率。

图片

央国企、金融、融媒等多行业客户选择FinClip

FinClip凭借其卓越的生态整合、敏捷开发与生态构建能力,正助力企业打造超级App,丰富应用内生态,提效业务。

大型央国企,借助FinClip,通过对旗下数十个App的重组,集成海量小程序,形成了三大主力App,覆盖“金融+本地生活”助力企业实现从“分散运营”到“生态协同”变,显著提升了客户体验与业务效率。 

金融行业,某券商App通过引入FinClip,快速构建了行情、交易、资讯、理财等多元服务生态。新业务上线周期从原来的一个月缩短至一周,用户活跃度提升40%,成功实现了从单一交易工具到综合金融服务平台的转型。 

融媒行业,基于FinClip可快速搭建传媒小程序开放平台,运营自有小程序开放生态平台,吸引第三方服务商及开发者入驻(如电商、票务、教育),也可将自有内容/服务封装成小程序,输出到其他集成FinClip的生态(如车机、智慧屏),拓展分发渠道与影响力。

未来,凡泰极客FinClip将持续完善技术能力,致力于为企业提供最可靠的技术支撑,快速构建企业自己的超级App生态,在激烈的市场竞争中赢得先机。

随着AI的发展,测试跟prompt会不会成为每个程序员的必修课

2025年11月19日 14:12

最近和同事聊天,大家不约而同会聊到一个话题: “现在写代码越来越多是 AI 在写,那我们程序员以后到底要干嘛?”

有人半开玩笑说:

“以后程序员就两件事:写 prompt,让 AI 写代码;然后写测试,证明 AI 没写坏。”

听着有点夸张,但仔细想想,好像还真有点意思。

这篇就当是程序员之间的一次谈心: AI 发展下去,测试和 prompt,会不会真成了每个程序员的必修课?

一、当“写代码”不再是唯一核心能力

以前我们对程序员的想象是: 会各种语法、熟悉各种框架、遇到问题就开写,手敲代码是核心技能。但现在的日常开发,多少已经有点变味了:

  • 新功能的模板让 AI 先给一版
  • 重复的 CRUD 让 AI 直接生成
  • 复杂一点的正则、SQL、边界逻辑,扔给 AI 想方案
  • 甚至连文档说明、接口示例、单元测试样例,都可以生成

也就是说: “写出第一版代码”这件事,本身正在变得越来越廉价。

那什么东西还不廉价? 或者说,在 AI 时代,程序员真正的价值在哪里?

有两个词会越来越重要: 测试 和 prompt。

二、为什么会提到“测试”?

你可能会想: “测试不是测试工程师的事吗?为什么现在要每个程序员都重视测试?”

以前,代码是我们自己写的,那么我们起码:

  • 脑子里有一套隐形的设计和假设
  • 对输入、输出、边界情况有直觉
  • 知道哪块逻辑容易翻车,心里有点数

所以,虽然很多人不太愿意写测试,但多少对自己写过的东西有“心理模型”。

现在不一样了: 很多代码是 AI 生成的——它看起来很合理,但你其实没完全参与推导过程。你看到的是结果,却没经历这个结果是怎么被一点点构建出来的。这种时候,你对代码的“直觉”会明显下降。

于是问题来了:

  • 你能看懂它,但你不完全确信它在所有输入下都能正常工作
  • 你知道大方向没错,但你不确定边界条件、异常分支、性能角落
  • 你可能会漏掉一些你自己写代码时本不会犯的错误
  • 在这种背景下,测试不再是“锦上添花”,而是“兜底安全网”。

可以这么理解:

以前是“我相信自己 + 少量测试确认一下”; 现在是“我不完全相信 AI + 测试帮我建立信任”。所以,当我们越来越多地把重复性工作交给 AI, 我们就越需要用系统化的测试来验证“这些代码是不是满足需求”。

测试对程序员来说,会从“选修课”变成“基本生存技能”:

  • 会写单元测试,知道怎么隔离依赖

  • 会写集成测试,能模拟真实场景

  • 会设计一些极端 / 边界用例,而不是只测最开心的路径

  • 能把测试当成“需求的可执行说明书”,而不是写完代码才随手补几行

以后你可能不会被问“你每分钟能写多少行代码”, 但一定会被问“你怎么确保这堆 AI 写出来的东西不炸?”

你的答案,很大一部分就落在测试上。

三、“prompt”也会变成必修课?

现在很多人提起 prompt,都是一种戏谑口吻: “今天上班就是调 prompt,感觉像在教一只听得懂人话但偶尔犯傻的机器人。” 但认真一点看,prompt 其实就是一种新的“编程接口”。

以前我们:

面对的是函数、类、API 文档,需要用代码把需求翻译成一串非常严谨的指令

现在多了一条路径:

面对的是 LLM(大模型),用自然语言 + 示例 + 约束,把需求描述成一个“任务说明”

你会发现,它和传统编程有几点相似:

越明确的输入,越靠谱的输出。 含糊其辞的 prompt,就像写了一个模糊需求的 PRD,一定翻车。 小步迭代、逐步抽象。一下子扔一大坨需求,模型和人一样会迷糊; 分步骤拆解,逐步细化,就像写模块一样。反馈闭环,它给的结果不对,你要能看出是哪里描述有问题,然后调整 prompt,继续迭代。换句话说,prompt 是把“需求”翻译给 “AI 这位合作开发”的过程。如果你不会好好说这个“机器可理解的人话”,你获得的结果质量就会很不稳定。

从团队角度看,有人很会写 prompt,能快速让 AI 给出 80% 靠谱的方案;有人很会写测试,能精确筛掉那 20% 的问题;再加上架构、领域知识、业务判断,构成一个新的开发闭环 在这个闭环里,手写每个 if/for 的能力,不再是唯一决定你价值的东西。 更重要的是:你能不能“驾驭”AI,让它产出可控、可验证的结果。

而 prompt,就是这份能力的显性技能之一。

四、测试 + prompt:未来的“新基本功”

未来也许会变成:

“不会用 AI 的程序员 = 不会用 IDE 的程序员”

“不会写测试的程序员 = 没法对自己产出的质量负责”

“不会写 prompt 的程序员 = 沟通能力严重受限,对 AI 的生产力利用率很低”

也许听上去有点残酷,但从趋势上看,这是把人从“体力活”中解放出来,重复代码、大量样板、迁移、兼容性处理让 AI 写。我们更多精力可以放在:想清楚需求、搭好系统边界、设计好测试、监督 AI 产出、解决那些真的需要人类脑子的问题、写自己感兴趣的代码。

这其实是一个挺值得期待的方向。

五、结语

我不太相信“程序员会被 AI 全面取代”这种说法, 但我相信另一种说法:

不会用 AI 的程序员,会慢慢被会用 AI 的程序员边缘化。

在这个过程里,测试 是我们和 AI 一起工作时的“安全带”,prompt 是我们和 AI 对话的“语言”。

如果有一天,“测试 + prompt”真的成了每个程序员的必修课,大概不是因为谁强制你学,而是因为你不学,就跟不上代码生产方式的变化了。

就当我们这代程序员, 赶上了从“纯手工时代”迈入“人机协作时代”的那个拐点吧。 有点折腾,有点不安,但也挺有意思的。

让 AI 真正看懂世界—构建具备空间理解力的智能体

作者 Mapmost
2025年11月19日 14:11

让AI真正看懂世界——构建具备空间理解力的智能体

在人工智能迅猛发展的时代,一个我们熟知的新物种正在崛起——AI智能体(AI Agent)

它们可以理解语言、执行任务,却依然有一个关键短板:无法进行空间推理

当一个智能体无法区分方向、距离或地理关系,它就无法在现实世界中真正发挥作用。

Mapmost MCP(Model Context Protocol)Mapmost SDK for WebGL的结合,为这一问题提供了可行的答案——让AI具备地理认知、空间计算和三维可视化能力,从理解到呈现,一气呵成。

动图封面

一、AI 的空间盲点

语言模型擅长处理文本,却不了解空间,例如距离或方向,人类在思考时会不自觉地运用地理知识——无论是规划行程、指路还是决定去哪里吃饭。

当人工智能无法考虑基本的空间背景信息时,例如推荐三家实际上方向相反的“附近”餐厅,用户体验就会受到影响,甚至产生误导。

用户不得不自行完成繁琐的筛选:交叉比对距离、查看路况,并将零散的结果拼凑成最终答案。

让我们来看一些实际情况:

  1. 附近沿着解放东路附近有哪些好吃的餐厅?
  2. 避开交通拥堵并经过原料库的最佳运输路线是什么?
  3. 在高压输电区向东300米生成新的无人机航线?

目前,即使最先进的语言模型无法真正理解“附近3公里范围内”、“最佳运输路线”、“向东200米”的含义。

结果往往是:

  • 智能助手推荐的“附近”餐厅分布在三个方向;
  • 工业调度AI无法识别园区内部道路闭塞;
  • 无人机作业系统无法根据地形生成安全航线;

缺乏空间理解,意味着 AI 与真实世界之间仍隔着一层“看不见的墙”,会让你感觉怎么一碰到实际场景它就变笨了。

二、Mapmost MCP:AI的地理大脑

要想解决上面的问题,我们就要用到**MCP Server,**它是一个为AI设计的空间智能协议接口。

它为大模型和智能体提供统一的地理访问入口,能理解自然语言意图并自动调用Mapmost的各项地理能力。

你可以简单理解为是一个大模型外挂,给它补足了空间推理能力。

通过MCP,AI可以:

这样以来,AI除了可以“调用API”之外,还能真正具备空间推理的地理意识

三、Mapmost SDK for WebGL:让空间结果可视化

除了能理解空间之外,还需要可视化空间,让用户能直观看到AI理解的内容

Mapmost SDK for WebGL是一款现代三维地图引擎,它可以提供高性能三维渲染与交互引擎,能以地图、模型、粒子、流线等多种形式动态呈现 AI 的推理结果。

有了SDK,AI不仅能“回答问题”,而且可以“展示答案”:

  • 在三维园区中绘制最优路线;
  • 生成可达性热力图;
  • 模拟实时车流或无人机航迹。
  • ...

AI的推理过程变得可视、可交互,也更可信

借助MCP,智能体可以决定何时以及如何访问Mapmost服务、调用SDK的接口,从而增强其通用推理和表达能力

例如,如果用户询问:

“我要搬到苏州,如果我想住在靠近公园、步行即可到超市,同时又靠近高速公路以便每天通勤到上海安亭,我应该考虑哪些小区?”

智能体可以使用Mapmost地理编码来定位苏州和上海安亭,使用Tilequery API来识别被归类为高速公路的道路,使用POI类别搜索来识别超市和公园聚集区,使用Isochrone API来识别距离超市和公园步行距离合理的住宅区,使用Directions API来计算潜在的通勤路线,然后将这些信息综合起来,使用Mapmost SDK在地图上以可视化的方式突出显示推荐区域

这一切都是自动完成的,用户只需要输入要求。

四、从推理到行动:真实应用场景

1. 工业园区智能调度

用户问:“从物流口到医院A的最快路线是什么?避开拥堵并标出沿途监控点。”

  • MCP自动解析道路网络与实时交通流;
  • 规划最优路径并返回ETA;
  • SDK实时渲染三维园区模型、路线轨迹与监控点分布。
    → 调度系统实现空间智能化,可视指挥效率提升40%。

2. 遥感与无人机作业

指令:“为第三地块规划无人机影像采集路线,避开禁飞区与坡度超过15°的警告区域。”

  • MCP获取地形数据与障碍分布;
  • 自动生成航线与安全高度;
  • SDK三维可视化路径与作业范围。
    → 实现无人机任务自动规划与仿真验证。

3. 空间资源管理与智能检索

指令:“查询平江新城卫塘路北段地块详细信息,并分析周边用地类型。”

  • MCP自动解析行政区、地块编号与用地属性;
  • 结合Mapmost数据服务获取项目批复号、面积、用途、权属等信息;
  • SDK在地图上实时高亮目标地块,并显示相关统计指标。
    → 帮助自然资源规划与管理局实现智能地块检索、用地分析与可视化管理,在海量业务数据和图层中快速找到目标内容,显著提升业务响应效率与空间信息利用率

五、体系架构

Mapmost MCP Server通过标准化的API接口为AI系统提供结构化空间服务,开发者无需逐一集成复杂地理模块,即可让智能体具备完整地理能力。

  • **可私有化部署:**支持云端、本地、内网环境;
  • **可插件扩展:**接入企业自有数据源(监控、IoT、传感器);
  • **可生态互通:**可其他支持标准地理坐标系的地区引擎互通。

Mapmost SDK for WebGL负责三维渲染与交互展示,兼容国产浏览器与硬件,满足高并发与安全要求。

六、构建具备空间智能的AI

总的来说,MCP服务器提供了一个标准化的工具集合,从而简化了AI代理发现和使用这些工具的过程,而无需一次性集成。借助MCP,Mapmost、Stripe和Twilio等外部服务提供商可以维护自己的服务器,供AI应用程序直接调用。

地理空间MCP扩展了此框架,使其具备基于位置的功能,从而使人工智能能够理解和推理地理信息,并支持查询中的地理空间组件,例如提供地图、路线规划或基于邻近性的推荐。

此外,地理空间MCP服务器还公开了地图、搜索框、地理编码、路线规划、等时线、矩阵等服务,使人工智能代理能够直接访问Mapmost API,而无需编写额外的集成代码。

引用:百度地图MCP Server架构图

引用:高德地图MCP Server架构图

因此,当AI能理解空间、分析空间、展示空间,它就真正具备了与现实世界交互的能力。

使用Mapmost MCP和Mapmost SDK for WebGL让开发者轻松构建具备空间推理与可视化能力的智能体系统
无论是数字孪生、智慧城市、工业调度还是自动驾驶仿真,都能以空间智能为核心,实现从“数据”到“行动”的跃迁

目前Mapmost MCP还处于内测阶段,如果您感兴趣,请与我们联系。

Mapmost数字孪生开发工具体验链接:Mapmost官网

Maven父子模块Deploy的那些坑

作者 踏浪无痕
2025年11月19日 13:50

起因

前两天遇到个挺坑的问题。我们有个基础服务框架叫financial-platform,是典型的父子结构,父工程下面挂了common-utils、message-client、db-starter这几个子模块。这次需要升级message-client模块,增加了RocketMQ的一些新特性,版本从1.2.5-SNAPSHOT改到1.3.0-SNAPSHOT。

当时想的挺简单的,就是把整个项目的版本都改了,然后只deploy这个message-client模块上去就行了。毕竟这个模块看起来挺独立的,也不依赖其它兄弟模块,应该没问题吧?

结果被现实教育了。

拉取失败

改完版本号,deploy上去后,业务系统引用这个message-client的时候就报错了:

Could not find artifact com.financial:message-client:jar:1.3.0-SNAPSHOT

我当时就懵了,明明刚deploy上去啊,怎么就找不到呢? 去Nexus私服上看,message-client-1.3.0-SNAPSHOT.jar确实在那儿躺着,但就是拉不下来。

后来发现Maven在尝试下载依赖的时候会报pom找不到的警告:

Could not find artifact com.financial:financial-platform:pom:1.3.0-SNAPSHOT

恍然大悟

这时候才反应过来,虽然message-client不依赖common-utils或db-starter这些兄弟模块,但是它的pom.xml里有这么一段:

<parent>
    <groupId>com.financial</groupId>
    <artifactId>financial-platform</artifactId>
    <version>1.3.0-SNAPSHOT</version>
</parent>

Maven拉取message-client的时候,会先去找它的父pom。父pom找不到,后面的事儿就都黄了。

整个依赖解析的流程是这样的:

sequenceDiagram
    participant B as 业务系统
    participant N as Nexus私服
    participant P as financial-platform
    participant M as message-client
    
    B->>N: 请求message-client:1.3.0-SNAPSHOT
    N->>N: 找到message-client的jar
    N->>N: 读取message-client的pom
    N->>P: 需要financial-platform:1.3.0-SNAPSHOT的pom
    P-->>N: 404 Not Found
    N-->>B: 依赖解析失败

为什么需要父pom

有人可能会问,message-client都已经是个完整的jar了,为什么还要父pom呢?

其实父pom里会定义很多东西:

<!-- financial-platform父pom里通常有这些 -->
<properties>
    <java.version>11</java.version>
    <spring-boot.version>2.7.18</spring-boot.version>
    <rocketmq.version>4.9.7</rocketmq.version>
    ...
</properties>

<dependencyManagement>
    <dependencies>
        <!-- 统一管理RocketMQ、Redis、PostgreSQL等版本 -->
        ...
    </dependencies>
</dependencyManagement>

<build>
    <pluginManagement>
        <!-- 插件配置 -->
        ...
    </pluginManagement>
</build>

message-client的pom可能会引用父pom里定义的属性和配置。Maven需要把父子pom合并起来,才能得到一个完整的、可执行的pom。

Maven构建有效pom的过程很简单:解析子模块pom时,如果发现有parent标签,就去Nexus找父pom。找到后合并父子配置,如果父pom还有parent,就继续往上找。一直找到最顶层,然后从上到下合并所有配置,最后生成一个完整的有效pom。

正确的做法

所以正确的做法是,把父pom和message-client都deploy上去:

# 在financial-platform父工程目录执行
mvn clean deploy

这样Maven会把父pom和所有子模块都发布到Nexus。即使你只改了message-client,父pom也得发上去,因为版本号变了。

Maven的继承和聚合

说到这儿,顺便聊聊Maven的继承和聚合,很多人容易搞混。

继承是子模块继承父pom的配置,通过<parent>标签实现。聚合是父工程管理多个子模块,通过<modules>标签实现。

graph TB
    subgraph 继承关系
    P1[financial-platform<br/>配置和依赖版本] -.继承.-> C1[common-utils<br/>使用父配置]
    P1 -.继承.-> C2[message-client<br/>使用父配置]
    P1 -.继承.-> C3[db-starter<br/>使用父配置]
    end
    
    subgraph 聚合关系
    P2[financial-platform] --聚合--> C4[common-utils]
    P2 --聚合--> C5[message-client]
    P2 --聚合--> C6[db-starter]
    end
    
    style P1 fill:#e1f5ff
    style P2 fill:#ffe1f5

父pom里是这样的:

<!-- 聚合: 管理有哪些子模块 -->
<modules>
    <module>common-utils</module>
    <module>message-client</module>
    <module>db-starter</module>
</modules>

<!-- 继承: 提供给子模块的配置 -->
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.apache.rocketmq</groupId>
            <artifactId>rocketmq-spring-boot-starter</artifactId>
            <version>${rocketmq.version}</version>
        </dependency>
    </dependencies>
</dependencyManagement>

子模块message-client里是这样的:

<!-- 继承: 指定从哪个父pom继承 -->
<parent>
    <groupId>com.financial</groupId>
    <artifactId>financial-platform</artifactId>
    <version>1.3.0-SNAPSHOT</version>
</parent>

<!-- 实际使用的依赖,版本从父pom继承 -->
<dependencies>
    <dependency>
        <groupId>org.apache.rocketmq</groupId>
        <artifactId>rocketmq-spring-boot-starter</artifactId>
        <!-- 版本号从父pom的dependencyManagement继承 -->
    </dependency>
</dependencies>

这两个是独立的机制,可以单独使用。但大部分时候我们会一起用,既让父工程聚合管理子模块,又让子模块继承父配置。

后来我们的处理

我们现在的做法是,每次版本升级,不管改了几个模块,都执行完整的deploy。虽然会把common-utils、message-client、db-starter都发一遍,有点浪费,但起码不会出幺蛾子。

另外在Jenkins的CI流程里加了个检查,如果pom的版本号变了,必须全量deploy,不允许只deploy单个模块。

#!/bin/bash
# Jenkins里的检查脚本
VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)

if [[ $VERSION == *"SNAPSHOT"* ]]; then
    echo "检测到SNAPSHOT版本: $VERSION"
    echo "执行全量deploy到Nexus"
    mvn clean deploy -DskipTests
else
    echo "Release版本: $VERSION" 
    # release版本走发布审批流程
    echo "需要审批后才能deploy"
    exit 1
fi

实际案例分析

我们再看一个实际的场景。假设业务系统order-service需要引用我们升级后的message-client:

<!-- order-service的pom.xml -->
<dependencies>
    <dependency>
        <groupId>com.financial</groupId>
        <artifactId>message-client</artifactId>
        <version>1.3.0-SNAPSHOT</version>
    </dependency>
</dependencies>

Maven构建order-service的时候,会先从本地或Nexus下载message-client的jar和pom。读取message-client的pom时发现它依赖父pom financial-platform:1.3.0,于是继续去找父pom。如果父pom不存在,整个构建就失败了。找到父pom后,Maven会合并父子配置,然后递归解析所有传递依赖,最后才能成功构建。

所以你看,这是个链式反应。中间任何一环缺失,整个构建都会挂掉。

就这样吧,希望能帮到遇到类似问题的朋友。这个坑我们已经踩过了,你们就别再踩了。下次升级message-client加新功能的时候,记得把整个framework都deploy上去,省得业务系统那边找你麻烦。

Webpack——插件实现的理解

2025年11月19日 13:46

Webpack 插件是 Webpack 中非常强大的功能,Plugins贯穿整个项目构建过程。

Webpack 的插件包括内置插件和配置中的插件,Webpack 在处理插件时,会将它们都纳入到编译生命周期中。

内置插件

内置插件会在 webpack 编译过程的特定阶段自动执行。它们是由 webpack 核心团队维护的,用于实现 webpack 的核心功能。例如,当 webpack 配置中设置了 mode 为 'production' 时,webpack 会自动启用一些内置插件,如 TerserPlugin(用于代码压缩)等。

配置插件

在 webpack 配置文件中,我们可以在 plugins 数组中添加自定义插件或第三方插件。这些插件会在 webpack 初始化时注册到 Tappable 钩子上并在编译过程中执行。

Tappable 库

webpack 的插件系统是基于 Tapable 库实现的,它提供了多种钩子(hooks)类型,如 SyncHookAsyncSeriesHook 等。插件通过在这些钩子上注册事件回调来在编译过程中执行自定义逻辑。想要深入理解webpack 的插件系统就需要了解 Tappable 是怎么回事。

Tapable是一个用于事件发布订阅执行的库,类似于Node.js的EventEmitter,但更加强大,支持多种类型的事件钩子(Hook)。在webpack中,Tapable被用来创建各种钩子,这些钩子在编译过程中的不同时机被触发。插件通过注册这些钩子来介入编译过程,实现自定义功能。

安装 Tappable

npm install tapable

然后,创建一个webpack编译过程的简单示例:

  1. 引入Tapable库,并创建一种类型的钩子(例如SyncHook,同步钩子)。
  2. 定义一个插件,该插件在钩子上注册一个处理函数。
  3. 在编译过程中触发钩子,从而执行插件注册的处理函数。
const { SyncHook } = require('tapable');

// 1. 创建一个同步钩子实例,指定参数列表
const hook = new SyncHook(['arg1', 'arg2']);

// 2. 注册插件
// 插件就是一个对象,它有一个apply方法,apply方法接收一个参数(我们这里简单用hook对象模拟编译器)
// 在apply方法中,我们在钩子上注册一个处理函数
class MyPlugin {
  apply(compiler) {
    compiler.hooks.done = hook; // 假设我们有一个done钩子
    hook.tap('MyPlugin', (arg1, arg2) => {
      console.log('MyPlugin被调用,参数为:', arg1, arg2);
    });
  }
}

// 3. 模拟webpack编译器
class Compiler {
  constructor() {
    this.hooks = {
      // 我们这里用一个SyncHook实例作为done钩子
      done: new SyncHook(['arg1', 'arg2'])
    };
  }

  run() {
    // 模拟编译过程...
    console.log('开始编译...');
    // 编译完成后触发done钩子,并传递参数
    this.hooks.done.call('参数1', '参数2');
  }
}

// 4. 使用插件
const compiler = new Compiler();
const myPlugin = new MyPlugin();
myPlugin.apply(compiler); // 插件注册,将处理函数挂载到钩子上

// 5. 开始编译,触发钩子
compiler.run();

上面使用了 Tappable 中的 SyncHook 同步钩子实例,其实 Tapable 提供了多种类型的 Hook(钩子),用于不同的场景,可以自行了解,不是本篇文章的重点,本篇文章只以比较简单的 SyncHook 同步钩子来理解插件的实现。

这里有一个东西需要区分一下:

const hook = new SyncHook(['arg1', 'arg2']); // Tappable 的钩子
compiler.hooks.done = hook; // webpack 的钩子

Tapable 中 SyncHook 钩子的实现

class SyncHook {
  constructor(args = []) {
    this._args = args; // 参数名称数组
    this.taps = [];    // 存储注册的 webpack 插件
  }

  // 注册同步插件
  tap(name, fn) {
    this.taps.push({
      name,
      type: 'sync',
      fn
    });
  }

  // 触发钩子执行
  call(...args) {
    // 确保参数数量正确
    const finalArgs = args.slice(0, this._args.length);

    // 依次执行所有注册的函数
    for (let i = 0; i < this.taps.length; i++) {
      const tap = this.taps[i];
      tap.fn.apply(this, finalArgs);
    }
  }
}

编译过程中插件的调用原理

Compiler 类实现

// 简化版的 Compiler 类定义
const { Tapable, SyncHook, AsyncSeriesHook } = require('tapable');

export class Compiler extends Tapable {
  constructor(context) {
    super();

    // 1. 核心属性初始化
    this.context = context; // 上下文路径
    this.options = {}; // 配置选项
    this.hooks = this._createHooks(); // 生命周期钩子
    this.name = undefined; // 编译器名称
    this.parentCompilation = undefined; // 父级 compilation
    this.root = this; // 根编译器

    // 2. 文件系统
    this.inputFileSystem = null; // 输入文件系统
    this.outputFileSystem = null; // 输出文件系统
    this.intermediateFileSystem = null; // 中间文件系统

    // 3. 记录和缓存
    this.records = {}; // 构建记录
    this.watchFileSystem = null; // 监听文件系统
    this.cache = new Map(); // 缓存

    // 4. 状态管理
    this.running = false; // 是否正在运行
    this.watchMode = false; // 是否为监听模式
    this.idle = false; // 是否空闲
    this.modifiedFiles = undefined; // 修改的文件
    this.removedFiles = undefined; // 删除的文件
  }

  // 创建生命周期钩子
  _createHooks() {
    return {
      // 初始化阶段
      initialize: new SyncHook([]),

      // 构建开始前
      environment: new SyncHook([]),
      afterEnvironment: new SyncHook([]),
      entryOption: new SyncHook(['context', 'entry']),

      // 构建过程
      beforeRun: new AsyncSeriesHook(['compiler']),
      run: new AsyncSeriesHook(['compiler']),
      beforeCompile: new AsyncSeriesHook(['params']),
      compile: new SyncHook(['params']),
      thisCompilation: new SyncHook(['compilation', 'params']),
      compilation: new SyncHook(['compilation', 'params']),
      make: new AsyncParallelHook(['compilation']),
      afterCompile: new AsyncSeriesHook(['compilation']),

      // 输出阶段
      emit: new AsyncSeriesHook(['compilation']),
      afterEmit: new AsyncSeriesHook(['compilation']),

      // 完成阶段
      done: new AsyncSeriesHook(['stats']),
      failed: new SyncHook(['error']),
      invalid: new SyncHook(['filename', 'changeTime']),
      watchClose: new SyncHook([]),
      shutdown: new AsyncSeriesHook([])
    };
  }

  // 运行构建
  run(callback) {
    // 构建流程实现
     if (this.running) {
      return callback(new Error('Compiler is already running'));
    }

    const finalCallback = (err, stats) => {
      this.running = false;
      this._cleanup();
      if (callback) callback(err, stats);
    };

    const startTime = Date.now();
    this.running = true;

    console.log('🚀 ========== 开始构建流程 ==========\n');

    // 执行构建流程
    this._run((err) => {
      if (err) return finalCallback(err);

      // 生成统计信息
      const stats = this._getStats(startTime);
      console.log('\n📊 生成构建统计信息');

      // 触发 done 钩子
      this.hooks.done.callAsync(stats, (hookErr) => {
        if (hookErr) return finalCallback(hookErr);
        finalCallback(null, stats);
      });
    });
  }

  async _run(callback) {
    try {
      // 1. 触发 beforeRun 钩子
      console.log('📋 阶段 1: 准备构建环境');
      await this.hooks.beforeRun.promise(this);
      console.log('   ✅ beforeRun 完成\n');

      // 2. 触发 run 钩子
      console.log('📋 阶段 2: 启动构建流程');
      await this.hooks.run.promise(this);
      console.log('   ✅ run 完成\n');

      // 3. 读取记录(用于增量构建)
      console.log('📋 阶段 3: 读取构建记录');
      await this._readRecords();
      console.log('   ✅ 记录读取完成\n');

      // 4. 执行编译
      console.log('📋 阶段 4: 执行编译');
      await this._compile();
      console.log('   ✅ 编译完成\n');

      callback();

    } catch (error) {
      console.error('❌ 构建过程出错:', error);
      this.hooks.failed.call(error);
      callback(error);
    }
  }

  async _readRecords() {
    if (this.options.recordsInputPath || this.options.recordsOutputPath) {
      console.log('   📖 读取构建记录文件...');
      await new Promise(resolve => setTimeout(resolve, 50));
      console.log('   ✅ 构建记录加载完成');
    }
  }

  async _compile() {
    // 创建编译参数
    const params = {
      normalModuleFactory: this._createNormalModuleFactory(),
      contextModuleFactory: this._createContextModuleFactory()
    };

    console.log('   🔧 创建编译参数');

    // 触发 beforeCompile 钩子
    console.log('   🎯 触发 beforeCompile 钩子');
    await this.hooks.beforeCompile.promise(params);

    // 触发 compile 钩子
    console.log('   🎯 触发 compile 钩子');
    this.hooks.compile.call(params);

    // 创建 compilation 对象
    console.log('   🏗️  创建 compilation 对象');
    const compilation = this._createCompilation();
    compilation.params = params;

    // 触发 compilation 相关钩子
    this.hooks.thisCompilation.call(compilation, params);
    this.hooks.compilation.call(compilation, params);

    // 触发 make 钩子 - 核心构建阶段
    console.log('   🎯 触发 make 钩子 - 开始构建模块');
    await this.hooks.make.promise(compilation);

    // 密封 compilation(完成模块构建)
    console.log('   🔒 密封 compilation');
    await compilation.seal();

    // 触发 afterCompile 钩子
    console.log('   🎯 触发 afterCompile 钩子');
    await this.hooks.afterCompile.promise(compilation);

    // 生成资源
    console.log('   📄 生成输出资源');
    await this._emitAssets(compilation);
  }

  _createNormalModuleFactory() {
    console.log('   🏭 创建 NormalModuleFactory');
    return {
      type: 'NormalModuleFactory',
      context: this.context
    };
  }

  _createContextModuleFactory() {
    console.log('   🏭 创建 ContextModuleFactory');
    return {
      type: 'ContextModuleFactory'
    };
  }

  _cleanup() {
    console.log('🧹 清理构建环境');
    this.fileTimestamps.clear();
    this.contextTimestamps.clear();
  }

  // 创建 compilation
  createCompilation(params) {
    return new Compilation(this, params);
  }

  // 创建编译参数
  newCompilationParams() {
    return {
      normalModuleFactory: this.createNormalModuleFactory(),
      contextModuleFactory: this.createContextModuleFactory()
    };
  }
}

webpack 方法实现

const { Compiler } = require('./Compiler');

function webpack(config) {
  // 合并配置,这里简化处理,直接使用传入的配置
  const options = config;
  // 创建Compiler实例,传入上下文(通常为当前工作目录)
  const compiler = new Compiler(options.context || process.cwd());
  // 将配置赋值给compiler
  compiler.options = options;
  // 注册配置中的插件
  if (options.plugins && Array.isArray(options.plugins)) {
    for (const plugin of options.plugins) {
      if (typeof plugin === 'function') {
        plugin.call(compiler, compiler);
      } else {
        plugin.apply(compiler);
      }
    }
  }
  // 返回compiler实例
  return compiler;
}

module.exports = webpack;

运行编译

const webpack = require('./webpack');

const config = {
  context: __dirname,
  plugins: [
    {
      apply(compiler) {
        compiler.hooks.done.tap('MyPlugin', (stats) => {
          console.log('MyPlugin: 构建完成!');
        });
      }
    }
  ]
};

const compiler = webpack(config);

compiler.run((err, stats) => {
  if (err) {
    console.error('构建失败:', err);
    return;
  }
  console.log('构建成功,统计信息:', stats.toString());
});

我们配置的自定义或第三方插件会被存储在 Tapable 钩子实例的 taps 队列中,然后最终注册到 webpack 编译器(complier)的不同的钩子里,最后在 webpack 编译过程中的不同阶段被调用。

以 webpack compile 钩子总结运行过程

  • 创建 Tappable 同步钩子实例,指定参数列表
  • 注册插件
  • 触发 compile 钩子
// Compiler 类
export class Compiler extends Tapable {
  constructor(context) {
    super();

    this.hooks = this._createHooks(); // 生命周期钩子
  }
  // 创建生命周期钩子
  _createHooks() {
    return {
      // 创建 Tappable 同步钩子实例,指定参数列表
      initialize: new SyncHook([]),
    };
  }

  // 运行构建
  run() {
    // 触发 compile 钩子,此处会调用插件配置的回调函数
    console.log('   🎯 触发 compile 钩子');
    this.hooks.compile.call(params);
  }
}

// webpack 插件配置
const config = {
  plugins: [
    {
      // 注册插件
      // 向同一个钩子多注册几个回调函数
      apply(compiler) {
        compiler.hooks.compile.tap('MyPlugin1', (stats) => {
          console.log('MyPlugin1: compile!');
        });
        compiler.hooks.compile.tap('MyPlugin3', (stats) => {
          console.log('MyPlugin2: compile!');
        });
        compiler.hooks.compile.tap('MyPlugin3', (stats) => {
          console.log('MyPlugin3: compile!');
        });
      }
    }
  ]
};

好了,这就是我对 webpack 插件的理解包括配置、注册、回调的整个流程,如果有不对的地方敬请斧正。

Vue组件开发避坑指南:循环引用、更新控制与模板替代

2025年11月18日 07:37

你是不是曾经在开发Vue组件时遇到过这样的困扰?组件之间相互引用导致无限循环,页面更新不受控制白白消耗性能,或者在某些特殊场景下标准模板无法满足需求。这些问题看似棘手,但只要掌握了正确的方法,就能轻松应对。

今天我就来分享Vue组件开发中三个边界情况的处理技巧,这些都是我在实际项目中踩过坑后总结出的宝贵经验。读完本文,你将能够优雅地解决组件循环引用问题,精准控制组件更新时机,并在需要时灵活运用模板替代方案。

组件循环引用的智慧解法

先来说说循环引用这个让人头疼的问题。想象一下,你正在构建一个文件管理器,文件夹组件需要包含子文件夹,而子文件夹本质上也是文件夹组件。这就产生了组件自己引用自己的情况。

在实际项目中,我遇到过这样的场景:

// 错误示范:这会导致循环引用问题
components: {
  Folder: () => import('./Folder.vue')
}

那么正确的做法是什么呢?Vue提供了异步组件的方式来打破这个循环:

// 方案一:使用异步组件
export default {
  name: 'Folder',
  components: {
    Folder: () => import('./Folder.vue')
  }
}

但有时候我们可能需要更明确的控制,这时候可以用条件渲染:

// 方案二:条件渲染避免循环
<template>
  <div>
    <p>{{ folder.name }}</p>
    <div v-if="hasSubfolders">
      <Folder
        v-for="subfolder in folder.children"
        :key="subfolder.id"
        :folder="subfolder"
      />
    </div>
  </div>
</template>

<script>
export default {
  name: 'Folder',
  props: ['folder'],
  computed: {
    hasSubfolders() {
      return this.folder.children && this.folder.children.length > 0
    }
  }
}
</script>

还有一种情况是组件之间的相互引用,比如Article组件引用Comment组件,而Comment组件又需要引用Article组件。这时候我们可以使用beforeCreate钩子来延迟组件的注册:

// 方案三:在beforeCreate中注册组件
export default {
  name: 'Article',
  beforeCreate() {
    this.$options.components.Comment = require('./Comment.vue').default
  }
}

这些方法都能有效解决循环引用问题,关键是要根据具体场景选择最适合的方案。

精准控制组件更新的实战技巧

接下来聊聊组件更新控制。在复杂应用中,不必要的组件更新会严重影响性能。我曾经优化过一个数据大屏项目,通过精准控制更新,让页面性能提升了3倍以上。

首先来看看最常用的key属性技巧:

// 使用key强制重新渲染
<template>
  <div>
    <ExpensiveComponent :key="componentKey" />
    <button @click="refreshComponent">刷新组件</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      componentKey: 0
    }
  },
  methods: {
    refreshComponent() {
      this.componentKey += 1
    }
  }
}
</script>

但有时候我们并不需要完全重新渲染组件,只是希望跳过某些更新。这时候v-once就派上用场了:

// 使用v-once避免重复渲染静态内容
<template>
  <div>
    <header v-once>
      <h1>{{ title }}</h1>
      <p>{{ subtitle }}</p>
    </header>
    <main>
      <!-- 动态内容 -->
    </main>
  </div>
</template>

对于更复杂的更新控制,我们可以使用计算属性的缓存特性:

// 利用计算属性优化渲染
export default {
  data() {
    return {
      items: [],
      filter: ''
    }
  },
  computed: {
    filteredItems() {
      // 只有items或filter变化时才会重新计算
      return this.items.filter(item => 
        item.name.includes(this.filter)
      )
    }
  }
}

在某些极端情况下,我们可能需要手动控制更新流程。这时候可以使用nextTick:

// 手动控制更新时机
export default {
  methods: {
    async updateData() {
      this.loading = true
      
      // 先更新loading状态
      await this.$nextTick()
      
      try {
        const newData = await fetchData()
        this.items = newData
      } finally {
        this.loading = false
      }
    }
  }
}

记住,更新的控制要恰到好处,过度优化反而会让代码变得复杂难维护。

模板替代方案的创造性应用

最后我们来探讨模板替代方案。虽然Vue的单文件组件很好用,但在某些场景下,我们可能需要更灵活的模板处理方式。

首先是最基础的动态组件:

// 动态组件使用
<template>
  <div>
    <component :is="currentComponent" :props="componentProps" />
  </div>
</template>

<script>
export default {
  data() {
    return {
      currentComponent: 'ComponentA',
      componentProps: { /* ... */ }
    }
  }
}
</script>

但动态组件有时候不够灵活,这时候渲染函数就派上用场了:

// 使用渲染函数创建动态内容
export default {
  props: ['type', 'content'],
  render(h) {
    const tag = this.type === 'header' ? 'h1' : 'p'
    
    return h(tag, {
      class: {
        'text-primary': this.type === 'header',
        'text-content': this.type === 'paragraph'
      }
    }, this.content)
  }
}

渲染函数虽然强大,但写起来比较繁琐。这时候JSX就是一个很好的折中方案:

// 使用JSX编写灵活组件
export default {
  props: ['items', 'layout'],
  render() {
    return (
      <div class={this.layout}>
        {this.items.map(item => (
          <div class="item" key={item.id}>
            <h3>{item.title}</h3>
            <p>{item.description}</p>
          </div>
        ))}
      </div>
    )
  }
}

对于需要完全自定义渲染逻辑的场景,我们可以使用作用域插槽:

// 使用作用域插槽提供最大灵活性
<template>
  <DataFetcher :url="apiUrl" v-slot="{ data, loading }">
    <div v-if="loading">加载中...</div>
    <div v-else>
      <slot :data="data"></slot>
    </div>
  </DataFetcher>
</template>

甚至我们可以组合使用这些技术,创建出真正强大的抽象:

// 组合使用多种模板技术
export default {
  render(h) {
    // 根据条件选择不同的渲染策略
    if (this.useScopedSlot) {
      return this.$scopedSlots.default({
        data: this.internalData
      })
    } else if (this.useJSX) {
      return this.renderJSX(h)
    } else {
      return this.renderTemplate(h)
    }
  },
  methods: {
    renderJSX(h) {
      // JSX渲染逻辑
    },
    renderTemplate(h) {
      // 传统渲染函数逻辑
    }
  }
}

实战案例:构建灵活的数据表格组件

现在让我们把这些技巧综合运用到一个实际案例中。假设我们要构建一个高度灵活的数据表格组件,它需要处理各种边界情况。

// 灵活的数据表格组件
export default {
  name: 'SmartTable',
  props: {
    data: Array,
    columns: Array,
    keyField: {
      type: String,
      default: 'id'
    }
  },
  data() {
    return {
      sortKey: '',
      sortOrder: 'asc'
    }
  },
  computed: {
    sortedData() {
      if (!this.sortKey) return this.data
      
      return [...this.data].sort((a, b) => {
        const aVal = a[this.sortKey]
        const bVal = b[this.sortKey]
        
        if (this.sortOrder === 'asc') {
          return aVal < bVal ? -1 : 1
        } else {
          return aVal > bVal ? -1 : 1
        }
      })
    }
  },
  methods: {
    handleSort(key) {
      if (this.sortKey === key) {
        this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc'
      } else {
        this.sortKey = key
        this.sortOrder = 'asc'
      }
    }
  },
  render(h) {
    // 处理空数据情况
    if (!this.data || this.data.length === 0) {
      return h('div', { class: 'empty-state' }, '暂无数据')
    }
    
    // 使用JSX渲染表格
    return (
      <div class="smart-table">
        <table>
          <thead>
            <tr>
              {this.columns.map(col => (
                <th 
                  key={col.key}
                  onClick={() => this.handleSort(col.key)}
                  class={{ sortable: col.sortable }}
                >
                  {col.title}
                  {this.sortKey === col.key && (
                    <span class={`sort-icon ${this.sortOrder}`} />
                  )}
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {this.sortedData.map(item => (
              <tr key={item[this.keyField]}>
                {this.columns.map(col => (
                  <td key={col.key}>
                    {col.render 
                      ? col.render(h, item)
                      : item[col.key]
                    }
                  </td>
                ))}
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    )
  }
}

这个组件展示了如何综合运用我们之前讨论的各种技巧:使用计算属性优化性能,用渲染函数提供灵活性,处理边界情况,以及提供扩展点让使用者自定义渲染逻辑。

总结与思考

通过今天的分享,我们深入探讨了Vue组件开发中的三个关键边界情况:循环引用、更新控制和模板替代。这些技巧虽然针对的是边界情况,但在实际项目中却经常能发挥关键作用。

处理循环引用时,我们要理解组件注册的时机和方式,通过异步加载、条件渲染等技巧打破循环。控制组件更新时,要善用Vue的响应式系统特性,在必要的时候进行精准控制。而模板替代方案则为我们提供了突破模板限制的能力,让组件设计更加灵活。

这些解决方案背后体现的是一个重要的开发理念:理解框架的工作原理,在框架的约束下找到创造性的解决方案。只有这样,我们才能写出既优雅又实用的代码。

你在Vue组件开发中还遇到过哪些棘手的边界情况?又是如何解决的呢?欢迎在评论区分享你的经验和见解,让我们共同进步!

AI概念解惑系列 - RAG

作者 酥风
2025年11月19日 12:26

本文发布于掘金,转载请声明

随着最近几年AI在各个领域大放异彩,作为一个前端开发,我也对AI里的各种技术概念产生了浓厚的兴趣,希望能详细了解这些技术概念和背后的原理后,找寻和前端结合的一些思路。今天我们就来一起了解下AI领域常用到的一个技术——RAG(Retrieval-Augmented Generation),检索增强生成。

一、 开始之前

在开始介绍RAG之前,我们先来简单回顾一下大语言模型LLM的原理。

LLM通过预训练,让模型学习了全网公开的知识(网页、书籍、论文、代码等等),得到一堆“参数”,之后根据用户的输入来不断地预测下一个token应该是什么,最终得到输出。

这种训练好的模型,在通用领域有着比较好的表现。但是根据LLM的原理,我们也能发现它的一些不足:

  • 预训练”:由于是使用了旧的数据进行的训练,对于在训练之后产生新信息,LLM无法回答
  • 公开的知识”:LLM使用了公开的知识训练,对于一些非公开的内容,或者专业数据,LLM同样无法回答
  • 预测”:因为LLM本质上是一个“看前文、猜下文”的大规模统计生成器,所以对于它不知道的信息,在回答时往往会“胡说八道”,也就是常说的Hallucination,“幻觉

对于普通用户一些不要求实时性的通用性问题来说,上面这些不是什么大问题,但是一旦涉及到实时性、专业数据、企业内私有数据时,上面的问题就会严重影响用户体验。

为了解决这些问题,通常有下面两个手段:

  1. 微调大模型
  2. 手动给LLM添加额外知识,即外挂知识库

二、 微调 vs 外挂知识库

1. 微调Fine-tuning

所谓微调(Fine-tuning),就是在预训练模型的基础上,使用特定任务的数据(如私有数据、专业领域知识、最新的信息等)进行进一步训练,让模型在特定领域表现更好。

举个比较形象的例子,通过预训练得到的大模型,就是学习了小学到高中的通识教育的知识,学会语言、数学、常识、逻辑思维,知识面广但不够专业。

而微调阶段(Fine-tuning)类似于专业教育,就好像学习了专业领域课程、成为某个领域的专家。

即“通用模型 + 专业数据 + 针对性训练 = 专业模型”:

  • GPT + 代码数据 + 代码任务训练 = CodeGPT
  • GPT + 医学数据 + 医学任务训练 = MedicalGPT
  • GPT + 法律数据 + 法律任务训练 = LegalGPT

2. 外挂知识库

在和LLM交互时,我们可以通过一些特殊手段,从外部知识库检索相关信息提供给LLM,相当于给AI配个"外挂知识库",让它可以获取到专业领域知识、私有数据、实时信息等等。

而RAG就是比较常用的手段。

3. 对比

对比这两个不难发现,微调是内化知识到了模型参数中,最终的效果肯定是稍微好一些的。

但是考虑到微调下面这些点:

  • 需要大量的标注训练数据(需要对文档等进行数据清洗、标注等)
  • 较高的训练成本(比如训练需要较高的算力,有不小的经济成本和时间成本)
  • 较高的实现难度(需要AI领域的专业知识)
  • 无法保持数据是最新的(内化的知识需要重新训练才能更新)

对于个人或者中小型企业来说,通过RAG外挂知识库是一个更合适的选择:

  • 只需要结构化的文档
  • 不需要训练
  • 较低的实现难度(大多是工程方面的实现)
  • 知识库更新很方便(不需要重新训练,只需要重新向量化即可)

三、 RAG

那我们来看下RAG是什么,所谓 RAG(Retrieval-Augmented Generation) 检索增强生成,就是:

  1. 检索(Retrieval): 从外部知识库中检索相关信息
  2. 增强(Augmented): 将检索到的信息作为上下文
  3. 生成(Generation): 大模型基于检索内容生成回答

它的本质就是上面说的给LLM外挂一个知识库,而它的工作过程,简单来讲就是下面这样:

工作流程

而一个RAG完整工程的架构示意图,则如下所示:

架构示意图

看不懂?没关系,我们一步一步来讲解。

四、 向量和向量化

首先,最基础的一个概念,什么是向量(Vector)向量化(Vectorization/Embedding)

向量(Vector)

向量我们应该不陌生,我们最早应该在高中就学过了,就是一个同时具有数值和方向的量。相对应的,标量则只有数值,没有方向。

向量

向量通常用一个有序的数字数组来表示,比如:

  • 2维向量: [3, 4],可以理解为一个有x轴和y轴的平面直角坐标系,从原点指向[3, 4]这个坐标点的一个向量
  • 3维向量: [1, 2, 3],可以理解为一个有x、y、z三个轴的空间直角坐标系,从原点指向[1, 2, 3]这个坐标点的一个向量
  • 高维向量: [0.2, 0.5, 0.1, ..., 0.8],以此类推,在数学或者计算机领域,一个向量可以有任意多个维度, 比如有几百甚至上千维

而在AI领域,向量是原始数据的数值化表示,用来捕捉语义信息,比如

  • 文本“苹果很好吃”,可以用这样一个向量表示:[0.12, 0.34, 0.89, ..., 0.45]
  • 图片🍎,可以用这样一个向量表示:[0.23, 0.67, 0.12, ..., 0.91]

而具体是怎么把一串文本或者图片转换为向量的,就是向量化所做的工作了。

向量化(Vectorization/Embedding)

向量化,就是将非结构化原始数据(文本、图片、音频等)转换为向量的过程。

向量化

而为了将一个非结构化数据,变为向量,我们首先需要给这个数据定义好它的维度

世界上的所有数据,都可以根据它在某一项特点上的程度而打一个分,数值越高代表这个原始数据在这个特点上的特征越强烈。我们规定数值的范围是[-1, 1],1代表完全符合这个特征,-1代表拥有完全相反的特征。

例如下面这个二维的向量空间:

  • 横坐标代表情感极性,越靠近1代表越正向的情感,越靠近-1代表越负向的情感
  • 纵坐标代表内容类型,越靠近1代表越主观的观点评价,越靠近-1代表越客观的事实陈述

二维向量示例

上面的几个原始文本数据里:

  1. 这部电影太精彩了,演员表演完美
    • 横轴(情感):+0.9(强烈正面)
    • 纵轴(内容类型):+0.8(明显观点评价)
    • 坐标:[0.9, 0.8]
  2. 水的沸点是100摄氏度
    • 横轴(情感):0.0(完全中性)
    • 纵轴(内容类型):-0.9(纯粹事实)
    • 坐标:[0.0, -0.9]
  3. 这个产品质量很差,完全不符合预期
    • 横轴(情感):-0.8(明显负面)
    • 纵轴(内容类型):+0.7(个人评价)
    • 坐标:[-0.8, 0.7]
  4. 令人失望的是,数据显示销售额下降了20%"
    • 横轴(情感):-0.6(负面情绪)
    • 纵轴(内容类型):-0.3(偏向事实但带有主观色彩)
    • 坐标:[-0.6, -0.3]
  5. 数据表明用户流失率高达30%
    • 横轴(情感):-0.7(负面情绪)
    • 纵轴(内容类型):-0.8(事实陈述,且没有明显主观评价在里面)
    • 坐标:[-0.7, -0.8]
  6. 我们彩票中了500万
    • 横轴(情感):+0.9(正面)
    • 纵轴(内容类型):-0.8(事实陈述,且没有明显主观评价在里面)
    • 坐标:[0.9, -0.8]

可以看到,在我们对原始的文本数据从不同维度打分后会出现

  • 相似文本会聚集:比如所有正面评价都会集中在第一象限
  • 距离/夹角反映相似度:坐标距离/夹角越近,文本在情感和类型上越相似

我们的例子里只有两个维度,而在实际应用中可能有几百上千个维度,每个维度代表不同特征,这样的话就可以得到一个高维的向量空间,在向量空间内可以体现出不同数据之间或近或远的关系。

向量空间

另外,向量化除了叫做Vectorization外,也时候也会叫做Embedding,嵌入。这是因为向量化的过程,不只是将一个非结构化数据转为向量数字,更重要的是在这个过程中,将原始数据的语义信息嵌入到了向量空间中,使得语义关系被保留。

举个例子,通常情况下“国王King”和“王后Queen”常出现在相似的上下文中,“男人Man”和“女人Women”经常出现在相似的上下文中。

皇室词汇(King, Queen, Prince)之间的相似度整体大于它们与普通人词汇(Man, Women)的相似度。但是“国王King”和“男人Man”之间又有一些相似,“王后Queen”和“女人Women”之间有一些相似。

而将这四个单词进行向量化后,可能如下图所示:

语义嵌入

“国王King”、“王后Queen”、“男人Man”、“女人Women”的语义以及语义关系被嵌入了向量空间,做到了:

  1. 保持语义关系: 意思相近的词在向量空间中也靠近
  2. 支持语义运算: 实现了king - man + woman ≈ queen这种神奇的运算

如何向量化

向量化是一个很复杂的过程,通常都是使用模型来实现的,即“Embedding models”,比如OpenAI的text-embedding-ada-002(需要API密钥访问),或者也可以使用一些开源的模型。

通常一个模型向量化数据后的维度越高,在后续进行信息检索时就会越精准。

其实除了调用模型这一步之外,完整的向量化过程还有一些其他工作要做,整个过程大概如下:

  1. 读取文档

    • PDF → 提取文本
    • Word → 提取文本
    • Markdown → 读取内容
    • ...
  2. 清洗文本

    • 去除乱码
    • 统一格式
    • 去除无用内容(页眉页脚等)
  3. 把文本进行切割,即文本分块 (Chunking)

    为什么要分块?因为原始文档可能特别长,几千上万字,不方便后续进行向量化和检索。

    而分块的规则也有多种,比如根据标点符号分割,但是可能会存在有的块长度很长,有的又很短的情况;也可以根据固定长度分割,但是可能会导致一个完整的语义被切割到不同的块,每个块里的部分词变得毫无语义价值。

    所以RAG 分割一般用相同块大小 + 块重叠的办法,即所谓的滑动窗口分块,这和按长度截断来分割有点像。用这种办法,在一定程度上能避免文档原意被截断导致分出来的片段变得没价值,而且重叠块里有重复的信息,能让模型更好地理解文档内容。

    文本分块

  4. 使用某个模型进行向量化(Embedding)

    • BGE-base-zh (中文效果好)

    • all-MiniLM-L6-v2 (英文,轻量)

    • text-embedding-ada-002 (OpenAI,需联网)

      假设选择BGE-base-zh (中文),输出维度768维,模型大小约 400MB

  5. 将向量化后的数据存入向量数据库,构建索引(Indexing)

    普通的数据存储有数据库,那么向量的存储,也有专门的向量数据库,它是专为存储与检索高维嵌入向量而设计的数据库。支持基于相似度的检索并可结合元数据过滤,常用于语义搜索、推荐和 RAG。常见的数据库有:

    • Chroma
    • Milvus
    • Qdrant

    假设选择Chroma,那么它的每条记录的存储结构大概为:

    {
      "id": "doc_1_chunk_1",
      "vector": [0.23, 0.45, ..., 0.12],  // 768维向量
      "metadata": {
        "source": "系统架构文档.pdf",
        "chunk_index": 1,
        "text": "第一章 系统架构..."  // 原始文本
      }
    }
    

在做完上述的几个步骤之后,就算是完成了RAG的准备工作,即给LLM准备好了“外挂的知识库”。

知识库向量化过程

五、 RAG - R (Retrieval)

在准备好知识库之后,就可以和LLM进行问答了。那在提出问题后,如何让LLM知晓知识库的内容呢?答案就是RAG中的R,Retrieval检索

在上面的知识库的准备过程中,我们使用了某个Embedding Model进行了知识库的向量化,那么在用户提出问题Query之后,也需要使用相同的Embedding Model,对用户的输入Query进行相同的向量化。

在都进行了向量化之后,就可以开始在向量数据库中检索了,检索和用户的Query相似语义的向量。

相似度匹配

和普通数据库基于关键字的检索不同,向量数据库是基于语义的检索。那怎么判断两个向量的语义是否接近,常见的衡量标准有:

这三个的具体定义和区别如下所示:

算法 公式 定义 适用场景
余弦相似度(Cosine Similarity) cosine 余弦相似度通过测量两个向量的夹角的余弦值来度量它们之间的相似性。
仅反映方向一致性而与长度无关。
当你关心“语义方向/形状”而不想被向量长度影响。
典型应用场景有文本/图像嵌入检索、RAG 向量检索、语义去重、语义聚合。
欧氏L2(Euclidean L2/Square L2) L2 对两向量差的平方和开平方来度量几何距离(或向量自身长度),反映它们在空间中的直线距离。 当你你需要真正的几何距离(度量性质、三角不等式)时。
典型应用场景有K-means 聚类(目标是平方 L2)、最小二乘/MSE、几何邻近、部分 ANN 索引默认。
内积(Inner Product/Dot Product) Inner Product 将两个向量对应分量相乘后求和,得到同时受方向与长度影响的对齐程度(线性打分)。 典型应用场景有最大内积搜索(MIPS)、推荐召回、因子分解机/embedding 打分。

而在RAG语义搜索中,通常使用余弦相似度来计算两个向量是否相似,即通过判断两个向量的夹角大小,来判断两个向量的语义是否接近。还是以上面的这个图为例:

向量空间夹角

在这个向量空间里,Chicken和“鸡”的图片之间,向量的夹角很小,Dog和Wolf夹角很小,Banana和Apple夹角很小,Apple和Apple公司的Logo夹角很小。但是动物和水果之间的向量夹角就很大了,语义上相差很远。

为什么用余弦相似度?

因为文本长度不重要(一句话和一段话可能表达同一意思),我们只关心语义方向是否一致。

ANN

在上面的图中,我们还看到了一个叫做Approximate Nearest Neighbor(ANN)近似最近邻检索算法的东西,这个和余弦相似度有什么关系呢?

简单来讲:

  • ANN是“怎么快地找相似向量”(检索算法/索引)
  • 余弦相似度是“用什么标准衡量相似”(度量/打分)

在RAG中它们是配套使用的,用余弦相似度等度量定义“近邻”,再用ANN在大规模向量库上高效近似地找这些近邻。

完整过程

在完成检索后,通常会拿到Top-K最相关的几条数据。假设用户输入的问题是“如何配置HTTPS?”,那么过程大概如下:

  1. 用户提问:“如何配置HTTPS?”

  2. 用户问题向量化,使用和文档向量化时相同的Embedding Model

  3. 得到Query的向量

    query_vector = [0.31, 0.48, 0.73, ..., 0.15]  // 768维
    
  4. 在向量数据库中搜索

  5. 计算相似度 Chroma自动计算query_vector与所有向量数据库中向量的相似度

    • 向量1: [0.23, 0.45, ...] → 相似度 0.45
    • 向量2: [0.31, 0.52, ...] → 相似度 0.89 ⭐
    • 向量3: [0.12, 0.34, ...] → 相似度 0.32
    • ...
    • 向量8234: [0.29, 0.51, ...] → 相似度 0.91 ⭐⭐
    • 向量8235: [0.35, 0.48, ...] → 相似度 0.87 ⭐
    • ...
  6. 返回 Top-K 最相关的,假设我们设置 K=3,返回相似度最高的3个文本块

    结果:

    1. (相似度 0.91) "4.2 HTTPS配置步骤:首先需要申请SSL证书..."
    2. (相似度 0.89) "安全配置章节:HTTPS是保障网站安全的关键..."
    3. (相似度 0.87) "第五章 证书管理:SSL/TLS证书的申请与部署..."

六、 RAG - A (Augmented)

在得到Top-K相关的数据后,会把这几条结果的原始数据(每条数据的metadata中会有原始数据),和用户的Query,一起给LLM。为什么叫Augmented增强呢?

其实Augmented,就是一个Prompt Engineering提示词工程相关的工作,把Query和知识库中的相关数据,通过一些特殊的Prompt给到LLM,让LLM更好地回答问题。即通过清晰的结构 + 明确的指令,让模型知道该参考什么。例如,一个完整的增强后的提示词可能如下所示:

你是一个技术支持助手。请根据以下参考资料回答用户问题。

## 参考资料

### 文档1(相关度:91%)
来源:网络安全配置手册.pdf
内容:{检索到的文本块1}

### 文档2(相关度:89%)
来源:服务器部署指南.pdf  
内容:{检索到的文本块2}

### 文档3(相关度:87%)
来源:证书管理文档.pdf
内容:{检索到的文本块3}

## 回答要求
1. 必须基于上述参考资料
2. 如果资料不足,明确说明
3. 给出具体步骤
4. 标注信息来源

## 用户问题
如何配置HTTPS?

## 你的回答

七、 RAG - G (Generation)

最后一步的Generation生成,就和平时使用LLM没什么区别了,LLM会根据提示词,来回答问题并按照指定的格式来输出内容。即:

  1. 读取参考资料
  2. 理解用户问题
  3. 从资料中提取相关信息
  4. 组织成结构化回答
  5. 逐字生成输出

以上面的问题为例,LLM最终生成的内容可能如下所示:

根据参考资料,HTTPS配置步骤如下:

1. **申请SSL证书**
   - 可以通过Let's Encrypt获取免费证书
   - 或购买商业证书(来源:证书管理文档)

2. **安装证书**

   sudo cp cert.pem /etc/ssl/certs/
   sudo cp key.pem /etc/ssl/private/
   ...

3. ...

到这里,整个RAG的过程就算是结束了。

RAG流程

八、 完整RAG过程

现在把上面几个步骤结合起来:

私有的知识库
→ 文本分块Chunking
→ 向量化Embedding
→ 存储向量构建索引Indexing
→ 用户提问Query
→ 检索Retrieval
→ 增强Augmented
→ 生成Generation

之后就得到了最开始的这个架构示意图:

架构示意图

九、 FAQ

下面是一些常见的问题:

  1. RAG和传统的全文模糊搜索有什么区别?

    传统的模糊搜索是关键词匹配的,比如

    • 用户搜索:"如何做蛋糕"
    • 系统匹配:只能找到包含"蛋糕"字样的文档
    • 结果:❌ 找不到"烘焙教程"、"甜点制作"等相关内容

    而RAG最重要的一步就是把知识库做了向量化,是根据语义进行搜索的

    • 用户搜索:"如何做蛋糕"
    • 系统理解:用户想学习烘焙技能
    • 结果:✅ 找到"烘焙教程"、"甜点制作"、"面包制作"等相关内容
  2. 既然RAG里面还是要要通过构造提示词来把参考资料放入提示词,那这和“直接在问题里把参考文档也输入进去”有什么区别?

    这是因为我们要考虑成本和效率,一个知识库可能很大,成百上千个文档,数万数十万字,在实际操作的时候,要考虑到:

    • Token限制:GPT-4 最多128K tokens ≈ 10万字,知识库无法塞进去
    • 成本问题:即使能塞进去,128K tokens可能一次调用可能要几美元,成本激增
    • 效率低下:LLM要读完所有无关内容
    • 响应缓慢:处理海量文本需要很长时间 所以RAG中R检索这一步,就是在做过滤,它的本质就是把“无限大”的知识库变成“刚刚好”的上下文
  3. 在做向量化的时候,使用到的Embedding Model和LLM的Model有什么关联,为什么Embedding Model可以识别原始数据的语义?

    简单来讲,Embedding模型大语言模型,他们的基础架构是相似,但目标和训练方式不同。 两者都基于Transformer架构,即:

    • 多头自注意力机制 (Multi-Head Attention)
    • 前馈神经网络 (Feed-Forward)
    • 层归一化 (Layer Normalization)
    • 位置编码 (Positional Encoding)

    但是

    • Embedding Model:专门学习“理解和表示”,输出是向量
    • LLM:专门学习“生成文本”,输出是文字 完整的Transformer可以理解为Encoder编码器 + Decoder解码器。
    组成 作用
    Encoder编码器 理解输入,提取特征
    Decoder解码器 生成输出,逐字生成

Embedding模型主要用的是Encoder,大语言模型主要用Decoder。而Embedding模型之所以比LLM小很多(如BGE-base-zh只有400M),主要是因为它的任务相对简单(映射到向量空间),不需要生成能力,也就不需要记忆海量知识和复杂的推理能力。

十、 小结

这篇文章里,我们一起了解了一下RAG,以及这个技术如何通过外挂知识库解决大语言模型的三大局限:

  • 时效性不足(无法处理训练后新数据)
  • 私有数据缺失
  • 幻觉问题

它的核心机制包含三个阶段:

  • 基于语义的向量化检索
  • 结构化提示词增强上下文
  • 以及大模型的知识整合生成

这样就能实现低成本、易于维护的专业领域知识融合。相比微调方案, RAG无需训练标注数据且支持实时更新,适合用在企业知识库问答、实时政策解读这些场景里。

还有就是这篇文章(或者说《AI概念解惑》这个系列)更专注于概念解惑和原理解析,所以没有包含什么代码实操。

另外这也只是一个简单的原理解析,实际的RAG工程中可能还会包含很多其他的步骤,比如:

  • 文本分块时可能会同时使用语义分块+滑动窗口分块
  • Retrieval阶段可能不只是用向量相似度检索,会使用向量相似度+关键词(如BM25)的混合检索
    • 为什么?因为向量相似度只对语义敏感,对关键词不敏感,比如“Python 3.12”和“Python 3.13”的向量基本相同,但是用户可能是提问具体Python版本的问题
  • 在Retrieval检索后还可能存在一个Reranking重排的步骤
    • 对候选片段进行精细化打分和排序
  • 对于用户的问题Query可能会增加一个Query Rewriting
    • 是为了让问题更规范,比如从一个口语化的表达到一个规范化的表达
  • 使用多路检索(Multi-Path Retrieval)、上下文压缩(Context Compression)、自我反思(Self-Reflection)等手段进行优化
  • 引入RAG评分机制,对RAG的输出结果进行评估
  • ...

感兴趣的可以自行搜索了解。

希望这篇文章能帮助你理解RAG的核心原理和应用场景,如果这篇文章有什么问题,辛苦大家指正。

参考链接

逆天!Gemini 3 Pro直接封神 (用三句话完成的小球物理世界)

作者 ak啊
2025年11月19日 12:06

用三句话完成的小球物理世界

2025·11·19

最近在测试 Gemini 3 Pro 的代码生成能力,我做了一个很简单的实验:用尽可能少的话,让它实现一个完整的交互动画。

最后,我只说了三句话。


第一句话:

“生成一堆颜色各异,在空间乱弹的小球。”

它返回了一个结构完整的 Canvas 动画:

  • 随机颜色
  • 随机速度
  • 全屏
  • 不断反弹
  • 点击还能生成新的球

第二句话:

“不要光效,小球富有弹性,可以微变形。”

它自动理解了我想要“果冻球”“橡胶球”那种轻微的拉伸与压扁效果,并重新组织了代码:

  • 移除光效与叠加模式
  • 给小球加上简单的体积渐变
  • 根据速度方向进行轻微变形(stretch & squash)

我没有解释公式,它自己找到了合理实现方式。
这一点给我留下比较深的印象。


第三句话:

“小球之间可以相互碰撞,碰撞后会变形然后复原。”

这是最难的一步,因为已经涉及:

  • 球体碰撞检测
  • 动量计算
  • 速度交换
  • 位置修正(避免重叠)
  • 碰撞瞬间的挤压变形

结果返回的代码不仅实现了全部内容,而且结构化程度依然很高,便于继续扩展。

这个部分如果我自己写,从零开始至少需要 1~2 小时,因为要不断调试碰撞、修正重叠,还有变形的插值。

它一次性生成的代码没有明显逻辑问题,这对我来说是意外的。


简单总结

这次体验非常直接,不需要夸大:

  • 我只给了三句话
  • 每句话都在增加系统复杂度
  • 它能持续保持代码质量
  • 生成速度快
  • 细节实现到位
  • 没有出现明显 bug

从实用角度讲,如果以后要快速验证动画、交互、物理效果的原型,这种能力非常有用

Gemini 3 Pro 表现出的能力远超我预期。我只说了三句话,它就把一个完整的小球物理世界搭建好了——从随机颜色、自由弹跳,到速度拉伸、碰撞挤压,再到防粘连修正,每一个细节都处理得井井有条。它不仅理解了我的意图,还能把复杂逻辑自动落地,生成结构清晰、可扩展的代码。这种速度、准确度和工程级质量,真的让人感受到大模型带来的效率革命——你说的三句话,它几乎读懂了你的脑子,并直接把想法变成现实。

别再吹性能优化了:你的应用卡顿,纯粹是因为产品设计烂🤷‍♂️

作者 ErpanOmer
2025年11月19日 11:52

image.png

大家好!

最近面试,我发现一个很有意思的事情。几乎每个高级前端的简历上,都专门开辟了一栏,叫性能优化

里面写满了各种高大上的名词😖:

使用Virtual List(虚拟列表)优化长列表渲染...

使用Web Worker把复杂计算移出主线程...

使用WASM重写核心算法...

看着这些,我通常会问一个问题:

你为什么要渲染一个有一万条数据的列表?用户真的看得过来吗?

候选人通常会愣住,然后支支吾吾地说:“呃...这是我们产品经理要求的🤷‍♂️。”

这就是今天我想聊的话题:

在2025年的今天,前端领域90%的所谓性能瓶颈,根本不是技术问题,而是产品问题。

我们这群工程师,拿着最先进的前端技术(Vite, Rust, WASM),却在日复一日地给一坨屎💩(糟糕的产品设计)雕花。


我们正在解决错误的问题

让我们还原一个经典的性能优化现场吧👇。

场景:一个中后台的超级表格(默认大家应该比较熟悉🤔)。

产品经理说需求:这个表格要展示所有订单,大概有50列,每页要展示500条,而且要支持实时搜索,还要支持列拖拽,每个单元格里可能还有下拉菜单...

image.png

开发者的第一反应(技术视角)

  • 50列 x 500行 = 25000个DOM节点,浏览器肯定卡死。
  • 快!上虚拟滚动(Virtual Scroll)!
  • 快!上防抖(Debounce)!
  • 快!上Memoization(缓存)!

我们为了这个需求,引入了复杂的 第三方库,写了晦涩难懂的优化代码,甚至为了解决虚拟滚动带来的样式问题(比如高度坍塌、定位异常),又打了一堆补丁。

最后,页面终于不卡了。我们觉得自己很牛逼,技术很强。

但我们从来没问过那个最核心的问题:

人类的视网膜和大脑,真的能同时处理50列 x 500行的数据吗?

答案是:不能。

当屏幕上密密麻麻挤满了数据时,用户的认知负荷已经爆表了。他根本找不到他要看的东西。他需要的不是高性能的渲染,他需要的是筛选搜索

我们用顶级的技术,去实现了一个反人类的设计。 这不是优化,这是叫作恶😠。


真正的优化,是从砍需求开始

我曾经接手过一个类似的项目,页面卡顿到FPS只有10。前任开发留下了几千行用来优化渲染的复杂代码,维护起来生不如死。

我接手后,没有改一行渲染代码。

我直接去找了产品总监,把那个页面投在大屏幕上,问了他三个问题:

1.你看这一列 订单原始JSON日志,平均长度3000字符,你把它全展示在表格里,谁会看?

砍掉!改成一个查看详情的按钮,点开再加载。DOM节点减少20%。

2.这50列数据,用户高频关注的真的有这么多吗?

默认只展示核心的8列。剩下的放在自定义列里,用户想看自己勾选。DOM节点减少80%。

3.我就不知道为什么🤷‍♂️ 要一次性加载500条?用户翻到第400条的时候,他还记得第1条是什么吗?

赶紧砍掉!改成标准的分页,每页20条。DOM节点减少96%。

做完这三件事,我甚至把之前的虚拟滚动代码全删了,回退到了最朴素的<table>标签。

结果呢?

  • 页面飞一样快(因为DOM只有原来的1%)。
  • 代码极其简单(维护就更简单了🤔)。
  • 用户反而更开心了(因为界面清爽了,信息层级清晰了)。

这才是最高级的性能优化:不仅优化了机器的性能,更优化了人的体验。


技术自负的陷阱

为什么我们总是陷在技术优化的泥潭里出不来呢?😒

因为我们有技术自负

作为工程师,我们潜意识里觉得:承认这个需求做不了(或者做不好),是因为我技术不行。

产品经理要五彩斑斓的黑,我就得给他做出来!

产品经理要在这个页面跑3D地球,我就得去学Three.js!

我们试图用技术去弥补产品逻辑上的懒惰!(非常有触感😖)

因为产品经理懒得思考信息的层级 ,所以他把所有信息一股脑扔给前端,让你去搞懒加载。

技术不是万能的。

浏览器的渲染能力是有上限的,JS的主线程是单核的,移动端的电量是有限的。更重要的是,用户的注意力是极其有限的。

当你发现你需要用极其复杂的新技术才能勉强让一个页面跑起来的时候

请停下来!

stOpStopstopstOpstOp.gif

这时候,问题的根源通常不在代码里,而可能是在 PRD(需求文档) 里。


说了那么多,该怎么做呢?

下次,当你再面对一个导致卡顿的需求时,别急着打开Profiler分析性能。

请试着做以下几步:

我们真的需要在前端处理10万条数据吗?能不能在后端聚合好,只给我返回结果?

这个图表真的需要实时刷新吗?用户真的能看清1毫秒的变化吗?改成5秒刷新一次行不行?

在这个弹窗里塞个完整地图太卡了。能不能改成:点击缩略图,跳转到专门的地图页面?

你要告诉产品经理: 性能本身,也是一个产品功能。

如果为了塞下更多的功能,牺牲了流畅度这个最核心的功能,那是丢了西瓜捡芝麻。


最好的代码,是 没有代码(No Code)

同理,最好的性能优化,是没有需求

作为高级工程师,你的价值不仅仅体现在你会写Virtual List,更体现在你敢不敢在需求评审会上,拍着桌子说:

这个设计怎么这么反人类😠!我们能不能换个更好的方式?🤷‍♂️

别再给屎山💩雕花了。把那座山推了,才是真正的优化。

关于这个观点你们怎么看?

处理耗时较长的任务笔记

2025年11月19日 11:33

在前端开发中,处理耗时较长的任务(Long Tasks)是性能优化的核心难点。如果主线程被占用超过 50ms,用户就会感觉到页面卡顿(掉帧)。

为了解决这个问题,深入探讨几种核心技术方案,并编写一套较为完整的、接近生产环境的任务调度与优化系统。这将包含以下几个模块:

  1. 基于生成器(Generator)的时间分片调度器:将同步大任务拆解为异步小任务。
  2. 多线程并行计算框架(Web Worker Pool):利用多核 CPU 处理纯计算任务。
  3. 高优先级抢占式调度模拟(类似于 React Scheduler):通过 MessageChannel 实现宏任务调度。
  4. 大量数据渲染优化(虚拟列表核心实现):解决渲染层面的长任务。

第一部分:通用时间分片调度器 (Time Slicing Scheduler)

这是解决主线程阻塞最直接的方法。我们将利用 ES6 的 Generator 函数特性,配合 requestAnimationFrameMessageChannel,将一个巨大的循环拆分成多个可以在每一帧之间暂停执行的小块。

/**
 * 模块一:TimeSlicer.js
 * 描述:一个基于生成器函数的通用时间分片控制器。
 * 它可以暂停、恢复任务,并确保每帧执行时间不超过阈值(如 16ms)。
 */

class TimeSlicer {
    constructor(options = {}) {
        // 默认一帧的时间预算,留给浏览器渲染的时间
        this.frameBudget = options.frameBudget || 12; // 12ms 给 JS,4ms 给渲染
        this.fps = options.fps || 60;
        this.isRunning = false;
        this.queue = []; // 任务队列
        
        // 绑定上下文
        this._performWork = this._performWork.bind(this);
    }

    /**
     * 添加一个需要分片执行的任务
     * @param {Generator Function} generatorFunc - 生成器函数
     * @param {Object} context - 执行上下文
     * @param {...any} args - 参数
     */
    addTask(generatorFunc, context = null, ...args) {
        if (typeof generatorFunc !== 'function' || generatorFunc.constructor.name !== 'GeneratorFunction') {
            throw new Error('TimeSlicer: 任务必须是一个 Generator 函数');
        }

        const task = {
            iterator: generatorFunc.apply(context, args),
            priority: 0, // 预留优先级字段
            createdTime: Date.now()
        };

        this.queue.push(task);
        this._schedule();
    }

    /**
     * 调度核心
     * 如果当前没有运行,则启动调度
     */
    _schedule() {
        if (!this.isRunning && this.queue.length > 0) {
            this.isRunning = true;
            // 优先使用 MessageChannel (宏任务),其次 requestAnimationFrame
            if (typeof MessageChannel !== 'undefined') {
                const channel = new MessageChannel();
                channel.port2.onmessage = this._performWork;
                channel.port1.postMessage(null);
            } else {
                setTimeout(this._performWork, 0);
            }
        }
    }

    /**
     * 执行工作单元
     * 在给定的时间预算内尽可能多地执行 generator.next()
     */
    _performWork() {
        const startTime = performance.now();

        // 只要队列里有任务,且当前帧还有剩余时间,就继续执行
        while (this.queue.length > 0 && (performance.now() - startTime < this.frameBudget)) {
            const currentTask = this.queue[0];
            
            try {
                // 执行一步
                const result = currentTask.iterator.next();

                // 如果当前任务完成 (done: true)
                if (result.done) {
                    this.queue.shift(); // 移除已完成任务
                    // 可以在这里触发任务完成的回调
                    console.log(`[TimeSlicer] 任务完成,耗时: ${(Date.now() - currentTask.createdTime)}ms`);
                } 
                // 如果没完成,它会保留在队列头部,下一次循环继续执行
            } catch (error) {
                console.error('[TimeSlicer] 任务执行出错:', error);
                this.queue.shift(); // 出错移除任务,防止死循环
            }
        }

        // 检查是否还有剩余任务
        if (this.queue.length > 0) {
            // 让出主线程,等待下一次调度
            // 使用 requestAnimationFrame 配合 MessageChannel 达到最佳效果
            // 这里简化逻辑,直接重新调度
            if (typeof MessageChannel !== 'undefined') {
                const channel = new MessageChannel();
                channel.port2.onmessage = this._performWork;
                channel.port1.postMessage(null);
            } else {
                setTimeout(this._performWork, 0);
            }
        } else {
            this.isRunning = false;
        }
    }
}

// ==========================================
// 使用示例代码:处理 10万条数据的复杂计算
// ==========================================

const slicer = new TimeSlicer();

/**
 * 模拟一个耗时的大型计算任务
 * 假设我们需要处理一个巨大的数组,每项都要进行数学运算
 */
function* heavyCalculationTask(dataSize) {
    const results = [];
    console.log(`[Task] 开始处理 ${dataSize} 条数据...`);

    for (let i = 0; i < dataSize; i++) {
        // 模拟复杂计算: 三角函数、开方等
        let temp = Math.sqrt(i) * Math.tan(i) + Math.random();
        // 模拟 DOM 操作(如果需要,虽然不建议在逻辑中混杂 DOM)
        // 仅仅是纯计算
        results.push(temp);

        // 关键点:每处理 1000 条数据,或者每一步 yield 一次
        // 粒度越细,响应越好,但总耗时会微增
        if (i % 500 === 0) {
            yield; // 暂停,交还控制权给调度器
        }
    }

    console.log(`[Task] 所有数据处理完毕,结果长度: ${results.length}`);
    return results;
}

// 启动任务:处理 100,000 条数据
// 如果不使用 TimeSlicer,这行代码会直接卡死浏览器 2-3秒
slicer.addTask(heavyCalculationTask, null, 100000);

// 可以同时添加另一个任务,它们会交替执行
slicer.addTask(function* () {
    for(let i=0; i<50; i++) {
        console.log(`[Task 2] 穿插执行中... ${i}`);
        yield;
    }
});

第二部分:Web Worker 线程池 (Thread Pool)

时间分片虽然解决了卡顿,但任务仍然在主线程运行,总耗时并没有减少(甚至因为调度开销变长了)。对于纯计算任务(如图像处理、大文件解析、加密解密),最好的方案是移出主线程。

为了避免频繁创建 Worker 带来的开销,我们需要实现一个 Worker 线程池

/**
 * 模块二:WorkerPool.js
 * 描述:管理一组 Web Worker,实现负载均衡和任务复用。
 */

class WorkerPool {
    constructor(workerScript, poolSize = 4) {
        this.workerScript = workerScript;
        this.poolSize = poolSize || navigator.hardwareConcurrency || 4;
        this.workers = []; // 存储 { worker, isBusy, id }
        this.queue = []; // 等待处理的任务队列
        this.taskMap = new Map(); // 存储 taskId -> { resolve, reject }
        
        this._initPool();
    }

    _initPool() {
        for (let i = 0; i < this.poolSize; i++) {
            const worker = new Worker(this.workerScript);
            
            worker.onmessage = (e) => this._handleWorkerMessage(e, i);
            worker.onerror = (e) => this._handleWorkerError(e, i);
            
            this.workers.push({
                id: i,
                worker: worker,
                isBusy: false
            });
        }
        console.log(`[WorkerPool] 初始化完成,包含 ${this.poolSize} 个线程`);
    }

    /**
     * 提交任务到线程池
     * @param {string} type - 任务类型
     * @param {any} data - 数据
     * @returns {Promise}
     */
    run(type, data) {
        return new Promise((resolve, reject) => {
            const taskId = this._generateUUID();
            const task = {
                taskId,
                type,
                data,
                resolve,
                reject
            };

            // 尝试寻找空闲 Worker
            const idleWorkerIndex = this.workers.findIndex(w => !w.isBusy);

            if (idleWorkerIndex !== -1) {
                this._dispatchTask(idleWorkerIndex, task);
            } else {
                // 所有 Worker 都在忙,加入队列
                this.queue.push(task);
            }
        });
    }

    _dispatchTask(workerIndex, task) {
        const workerObj = this.workers[workerIndex];
        workerObj.isBusy = true;

        // 记录任务回调,以便收到消息时触发
        this.taskMap.set(task.taskId, {
            resolve: task.resolve,
            reject: task.reject
        });

        // 发送给 Worker
        workerObj.worker.postMessage({
            taskId: task.taskId,
            type: task.type,
            data: task.data
        });
    }

    _handleWorkerMessage(e, workerIndex) {
        const { taskId, result, error } = e.data;
        const workerObj = this.workers[workerIndex];
        
        // 标记为空闲
        workerObj.isBusy = false;

        // 找到对应的 Promise
        if (this.taskMap.has(taskId)) {
            const { resolve, reject } = this.taskMap.get(taskId);
            if (error) {
                reject(error);
            } else {
                resolve(result);
            }
            this.taskMap.delete(taskId);
        }

        // 检查队列中是否有等待的任务
        if (this.queue.length > 0) {
            const nextTask = this.queue.shift();
            this._dispatchTask(workerIndex, nextTask);
        }
    }

    _handleWorkerError(e, workerIndex) {
        console.error(`[WorkerPool] Worker ${workerIndex} 发生底层错误`, e);
        // 实际生产中可能需要重启该 Worker
        this.workers[workerIndex].isBusy = false;
    }

    _generateUUID() {
        return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
            var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
            return v.toString(16);
        });
    }
    
    terminate() {
        this.workers.forEach(w => w.worker.terminate());
        this.workers = [];
        this.queue = [];
        this.taskMap.clear();
    }
}

/* 
   --------------------------------------------------
   配套的 Worker 脚本内容 (worker-script.js) 
   (在实际项目中,这通常是一个单独的文件,或者通过 Blob URL 生成)
   --------------------------------------------------
*/

/*
// worker-script.js 伪代码实现
self.onmessage = function(e) {
    const { taskId, type, data } = e.data;

    try {
        let result;
        switch (type) {
            case 'SORT_LARGE_ARRAY':
                // 模拟耗时排序
                result = data.sort((a, b) => a - b);
                break;
            case 'IMAGE_FILTER':
                // 模拟图像像素处理
                result = applyFilter(data); 
                break;
            default:
                throw new Error('Unknown task type');
        }

        self.postMessage({ taskId, result });
    } catch (err) {
        self.postMessage({ taskId, error: err.message });
    }
};

function applyFilter(pixels) {
    // 模拟 O(n) 遍历
    let sum = 0;
    for(let i=0; i<10000000; i++) { sum += i }
    return pixels;
}
*/

// 使用示例
// const pool = new WorkerPool('worker-script.js');
// pool.run('SORT_LARGE_ARRAY', [5, 1, 9, ...]).then(res => console.log(res));

第三部分:任务优先级与空闲调度 (Idle & Priority Scheduler)

除了时间分片,我们还可以利用 requestIdleCallback 在浏览器空闲时执行低优先级任务(如埋点上报、预加载数据)。为了兼容性和更好的控制,我们实现一个带优先级的任务队列。

/**
 * 模块三:PriorityScheduler.js
 * 描述:模拟 React Scheduler,支持优先级(UserBlocking, Normal, Low, Idle)。
 */

const Priority = {
    Immediate: 1, // 点击事件等,必须立即执行
    UserBlocking: 2, // 滚动、输入,需要在短时间内完成
    Normal: 3, // 普通数据请求
    Low: 4, // 动画后续处理
    Idle: 5 // 埋点、预加载
};

class PriorityScheduler {
    constructor() {
        this.taskQueue = [];
        this.isMessageLoopRunning = false;
        
        // 使用 MessageChannel 进行任务循环 tick
        const channel = new MessageChannel();
        this.port = channel.port2;
        channel.port1.onmessage = this._performWorkUntilDeadline.bind(this);
    }

    /**
     * 调度任务
     * @param {Function} callback 
     * @param {number} priorityLevel 
     */
    scheduleCallback(priorityLevel, callback) {
        const startTime = performance.now();
        let timeout;

        // 根据优先级设置过期时间
        switch (priorityLevel) {
            case Priority.Immediate: timeout = -1; break;
            case Priority.UserBlocking: timeout = 250; break;
            case Priority.Normal: timeout = 5000; break;
            case Priority.Low: timeout = 10000; break;
            case Priority.Idle: timeout = 1073741823; break; // Max Int 32 bit approx
            default: timeout = 5000;
        }

        const expirationTime = startTime + timeout;

        const newTask = {
            callback,
            priorityLevel,
            startTime,
            expirationTime,
            sortIndex: -1 // 用于最小堆排序,这里简化为数组排序
        };

        // 插入队列并按过期时间排序(模拟最小堆)
        this.taskQueue.push(newTask);
        this.taskQueue.sort((a, b) => a.expirationTime - b.expirationTime);

        if (!this.isMessageLoopRunning) {
            this.isMessageLoopRunning = true;
            this.port.postMessage(null);
        }
    }

    _performWorkUntilDeadline() {
        const currentTime = performance.now();
        const deadline = currentTime + 5; // 每次给 5ms 的时间片

        let currentTask = this.taskQueue[0];

        while (currentTask) {
            // 如果任务还没过期,但当前帧时间片用完了,暂停,让给浏览器渲染
            if (currentTask.expirationTime > currentTime && performance.now() >= deadline) {
                break;
            }

            // 执行任务
            const callback = currentTask.callback;
            // 可以在这里传入 didTimeout 参数告知任务是否超时
            const didTimeout = currentTask.expirationTime <= currentTime;
            
            // 从队列移除
            this.taskQueue.shift();

            try {
                // 执行回调
                const continuationCallback = callback(didTimeout);
                
                // 如果任务返回了一个函数,说明它还没做完(支持任务中断与恢复)
                if (typeof continuationCallback === 'function') {
                    // 重新加入队列,保持原有的过期时间
                    currentTask.callback = continuationCallback;
                    this.taskQueue.push(currentTask);
                    this.taskQueue.sort((a, b) => a.expirationTime - b.expirationTime);
                }
            } catch (e) {
                console.error('Task error:', e);
            }

            currentTask = this.taskQueue[0];
        }

        if (this.taskQueue.length > 0) {
            // 还有任务,继续请求下一个 tick
            this.port.postMessage(null);
        } else {
            this.isMessageLoopRunning = false;
        }
    }
}

// 使用示例
const scheduler = new PriorityScheduler();

// 低优先级任务:发送统计数据
scheduler.scheduleCallback(Priority.Idle, (didTimeout) => {
    console.log('Idle task executing, didTimeout:', didTimeout);
    // 模拟重负载
    const start = Date.now();
    while(Date.now() - start < 10) {} 
});

// 高优先级任务:响应用户点击
scheduler.scheduleCallback(Priority.Immediate, () => {
    console.log('Immediate task executing!');
});

第四部分:虚拟列表 (Virtual List) - 解决渲染长任务

当数据量达到几万条时,最大的“长任务”通常不是 JS 计算,而是 DOM 的 Layout 和 Paint。虚拟列表技术只渲染可视区域内的元素,极大减少 DOM 节点数量。

为了保证代码量和细节,我们不使用 React/Vue,而是用原生 JS 实现一个高效的虚拟滚动类。

/**
 * 模块四:VirtualScroller.js
 * 描述:原生高性能虚拟滚动实现。
 * 支持动态高度估算(简化版),DOM 复用,和滚动节流。
 */

class VirtualScroller {
    /**
     * @param {HTMLElement} container - 滚动容器
     * @param {Array} items - 数据源
     * @param {Function} rowRenderer - 行渲染函数 (index, data) => HTMLElement
     * @param {number} itemHeight - 固定行高
     */
    constructor(container, items, rowRenderer, itemHeight = 50) {
        this.container = container;
        this.items = items;
        this.rowRenderer = rowRenderer;
        this.itemHeight = itemHeight;
        
        // 内部状态
        this.visibleCount = 0;
        this.startIndex = 0;
        this.endIndex = 0;
        this.scrollTop = 0;
        
        // 缓冲区域(上下多渲染几个,防止白屏)
        this.buffer = 5; 

        this._initDOM();
        this._bindEvents();
        this._update();
    }

    _initDOM() {
        this.container.style.overflowY = 'auto';
        this.container.style.position = 'relative';

        // 创建占位高度层 (Phantom)
        // 它的高度等于 总数据量 * 行高,用于撑开滚动条
        this.phantomContent = document.createElement('div');
        this.phantomContent.style.position = 'absolute';
        this.phantomContent.style.left = '0';
        this.phantomContent.style.top = '0';
        this.phantomContent.style.right = '0';
        this.phantomContent.style.zIndex = '-1';
        this.phantomContent.style.height = `${this.items.length * this.itemHeight}px`;
        
        // 创建实际内容层 (Real Content)
        // 这里的元素会绝对定位,或者通过 transform 偏移
        this.content = document.createElement('div');
        this.content.style.position = 'absolute';
        this.content.style.left = '0';
        this.content.style.right = '0';
        this.content.style.top = '0';
        
        this.container.appendChild(this.phantomContent);
        this.container.appendChild(this.content);
    }

    _bindEvents() {
        // 简单的节流处理
        let ticking = false;
        this.container.addEventListener('scroll', (e) => {
            if (!ticking) {
                window.requestAnimationFrame(() => {
                    this.scrollTop = e.target.scrollTop;
                    this._update();
                    ticking = false;
                });
                ticking = true;
            }
        });
    }

    _update() {
        // 1. 计算可视区域能放下多少个元素
        const containerHeight = this.container.clientHeight;
        this.visibleCount = Math.ceil(containerHeight / this.itemHeight);

        // 2. 计算起始索引
        // 向下取整,确保滚动平滑
        this.startIndex = Math.floor(this.scrollTop / this.itemHeight);
        
        // 3. 考虑缓冲区
        const renderStart = Math.max(0, this.startIndex - this.buffer);
        
        // 4. 计算结束索引
        const renderEnd = Math.min(
            this.items.length, 
            this.startIndex + this.visibleCount + this.buffer
        );

        // 5. 渲染这一段数据
        this._renderRows(renderStart, renderEnd);
        
        // 6. 偏移 content 层,让它处于正确的位置
        // 因为我们只渲染了一部分,所以需要把这部分内容移动到视觉上的位置
        // 这里的偏移量应该是 renderStart * itemHeight
        const offset = renderStart * this.itemHeight;
        this.content.style.transform = `translateY(${offset}px)`;
    }

    _renderRows(start, end) {
        // 简单的 Diff 算法:全清空再添加 (生产环境应复用 DOM)
        // 为了演示代码量和逻辑,这里做一个简单的 DOM 复用池逻辑
        
        const fragment = document.createDocumentFragment();
        
        // 清空当前渲染层
        this.content.innerHTML = '';

        for (let i = start; i < end; i++) {
            const itemData = this.items[i];
            const node = this.rowRenderer(i, itemData);
            
            // 设置必要的样式确保高度正确
            node.style.height = `${this.itemHeight}px`;
            node.style.boxSizing = 'border-box'; // 避免 padding 影响高度
            
            fragment.appendChild(node);
        }

        this.content.appendChild(fragment);
    }

    /**
     * 动态更新数据
     */
    updateData(newItems) {
        this.items = newItems;
        this.phantomContent.style.height = `${this.items.length * this.itemHeight}px`;
        this._update();
    }
}

// ====================================
// VirtualScroller 使用测试
// ====================================
/*
// HTML 结构: <div id="scroll-container" style="height: 500px; width: 300px;"></div>

const data = Array.from({ length: 100000 }, (_, i) => ({ id: i, text: `Row Item ${i}` }));
const container = document.getElementById('scroll-container');

const scroller = new VirtualScroller(container, data, (index, item) => {
    const div = document.createElement('div');
    div.textContent = `${item.id} - ${item.text}`;
    div.style.borderBottom = '1px solid #ccc';
    div.style.display = 'flex';
    div.style.alignItems = 'center';
    div.style.paddingLeft = '10px';
    
    // 添加一些复杂的 DOM 结构来模拟渲染压力
    // 如果没有虚拟列表,10万个这样的 DOM 会瞬间卡死
    const span = document.createElement('span');
    span.innerText = ' [Detail]';
    span.style.color = 'blue';
    div.appendChild(span);
    
    return div;
}, 40);
*/

总结与进阶思考

以上代码展示了解决前端“长任务”的四个维度的工程化实现。要真正掌握优化,需要理解以下核心思想:

  1. 让出主线程 (Yielding): JS 是单线程的,必须通过 TimeSlicer (时间分片) 将大任务切碎,给 UI 渲染留出呼吸的时间。
  2. 并行计算 (Parallelism): 使用 WorkerPool 将不涉及 DOM 的纯算法逻辑移到另一个线程。
  3. 优先级调度 (Scheduling): 并不是所有任务都一样重要。PriorityScheduler 确保用户交互(点击、输入)永远优先于数据处理。
  4. 按需渲染 (Lazy Rendering): VirtualScroller 证明了无论数据多大,只要视口有限,DOM 的数量就应该保持恒定。

这些代码模块可以直接组合使用。例如,在一个大型数据分析看板中:

  • 使用 WorkerPool 在后台拉取并解析 10MB 的 JSON 数据。
  • 解析完成后,使用 TimeSlicer 对数据进行预处理(格式化、计算环比)。
  • 最终展示时,使用 VirtualScroller 将十万条表格数据流畅地渲染出来。

开源版扣子私有化部署

2025年11月19日 11:15

docker环境

安装 docker compose 环境

# 设置国内镜像
sed -e 's|^mirrorlist=|#mirrorlist=|g' \
      -e 's|^#baseurl=http://mirror.centos.org|baseurl=http://mirrors.aliyun.com|g' \
      -i.bak \
      /etc/yum.repos.d/CentOS-*.repo
          
# 清理缓存
yum makecache

# 配置 docker-ce 国内 yum 源(阿里云)
yum-config-manager --add-repo http://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo

# 安装工具库
yum install -y yum-utils

#安装 docker
yum install -y docker-ce docker-ce-cli containerd.io

#启动
systemctl enable docker
systemctl start docker  

更新国内镜像源

新建文件 /etc/docker/daemon.json

{
  "bip": "172.17.0.1/24",
  "registry-mirrors": [
    "https://mirror.ccs.tencentyun.com",
    "https://docker.linkedbus.com",
    "https://dockerpull.org"
  ],
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "100m",
    "max-file": "3",
    "labels": "production_status",
    "env": "os,customer"
  }
}
## 重启docker
  systemctl daemon-reload
  systemctl restart docker

扣子部署

下载扣子源码

git clone https://github.com/coze-dev/coze-studio.git
cd coze-studio

如果访问不了github,就手动下载压缩包上传到服务器也行

修改服务器配置

修改 coze-studio/docker.env 环境文件,可以把 127.0.0.1:8888 改为 0.0.0.0::8888 这样外网也能访问了

image.png

自动运行服务

cd coze-studio
make web

image.png

访问客户端

http://IP地址:8888/

随便写邮箱注册进入

image.png

image.png

访问管理端

http://IP地址:8888/admin

image.png

这里要配置大模型地址和API_KEY等参数

如果是要私有化部署大模型,参考文章 尝鲜DeepSeek私有化部署

我之前部署过deepseek-r1:7b,这里就用自己的大模型

image.png

测试智能体

image.png

🎮 从 NES 到现代 Web —— 像素风组件库 Pixel UI React 版本,欢迎大家一起参与这个项目

2025年11月19日 10:51

还记得几个月前,掘金作者 @猫闷台817 的 Vue3 版上线了,地址是这个:github.com/maomentai81… 。现在我与他合作的 React 版也来了,github 地址:github.com/maomentai81…


image.png

1️⃣ 项目初衷

红白机、GameBoy 游戏,那种块状像素 UI 总让人心驰神往。现在虽然是现代 Web 时代,但像素风 UI 的美学依然令人着迷。

现有的 8-bit 风组件库 NES.css 是一个 CSS 框架, 它只需要 CSS,不依赖于任何 JavaScript, 核心绘制逻辑都是基于 box-shadow 实现, 但在不同浏览器环境, 浏览器缩放时,box-shadow 的浮点偏移值经过缩放后无法精准对齐物理像素网格,导致渲染出现间隙。子像素定位、像素舍入误差和 box-shadow 本身不适合精细拼接渲染等原因造成了一些困扰

QQ_1747188289846.png

NES.css

于是就有了基于 Vue 3 / React + TypeScript + UnoCSS + CSS Houdini 打造的一款组件库 —— 让像素风重回前端视野

Vue3 项目地址👇
📦 pixel-ui

React 项目地址👇
📦 pixel-ui-react

组件库首页👇
📎 Home


2️⃣ 技术选型

技术栈 用途
React19 + Hooks 组件化开发核心
TypeScript 类型系统增强开发体验
UnoCSS 原子化 CSS,灵活配置样式类
CSS Houdini 自定义 Paint Worklet 渲染像素边框
Dumi 组件展示 + 文档系统
Vitest 测试组件逻辑与渲染
pnpm + Monorepo 高效构建与多包管理

3️⃣ 项目目前进度

已迁移上线组件:

  • ✅ Button 按钮
  • ✅ Icon 图标
  • ✅ Overlay 遮罩层
  • ✅ Text 文本
  • ✅ ConfigProvider 全局配置
  • ✅ Input 输入框
  • ✅ Popconfirm 气泡确认框
  • ✅ Tooltip 文字提示

已发布 npm,可供下载。

更过规划见 vue 版:juejin.cn/post/750384…


4️⃣ 效果预览

image.png

image.png

image.png

欢迎大家:

  • 🌟 点个 Star
  • 🐛 提 Issue,有什么好的点子和想法都可以提
  • 🤝 提 PR,增强完善功能
  • 📢 分享给像素控朋友!

项目地址:github.com/maomentai81…

JS 对象:从 “散装” 到 “精装” 的晋级之路

2025年11月19日 10:48

前言

在阅读前先解决一个问题,那就是啥是对象?说白了,JS 里的 “对象” 就是个 “万能收纳箱”—— 不管是数字、字符串这些零散数据,还是能干活的方法,都能往里面塞,把杂乱的信息规整得明明白白。而支撑这个 “收纳箱体系” 的,一边是像 “孤家寡人” 似的原始类型(string、number、boolean、undefined、null、Symbol、bigInt),它们单打独斗,不能挂额外属性;另一边是热热闹闹的引用类型(也就是对象家族),它们是 “群居选手”,能装能存还能扩展。

今天咱就来扒一扒这个 “收纳箱” 是咋造出来的、背后有啥隐藏操作。

一、对象的 “诞生记”:三种创建方法

想要拥有一个对象,JS 给了你三种 召唤术

1. 对象字面量:“我懒我骄傲”

就像我们点外卖直接选套餐,对象字面量是最直接的创建方式:

const obj = {
    a: 1
};

你肯定会说:什么?就这一点代码?对,就这一点,对象就到手了!

2. new Object ():“走流程,咱正规”

如果你是个 “流程控”,可以用构造函数正儿八经创建:

const obj = new Object(); // 构造函数
obj.name = 'henry';
const abc = 'age';
obj[abc] = 18;   // 删除

delete obj[abc];
console.log(obj);

打印结果:

image.png

这种方法也可行,非常正规,赞!

3. new 自定义构造函数:“批量生产,效率王者”

当你需要 “复制粘贴” 式创建一堆相似对象时,构造函数就是你的 “生产流水线”!比如我们造个Person构造函数:

function Person() {

}
const obj = new Person();
console.log(obj);

image.png

二、构造函数:“批量造对象的黑作坊”

构造函数就像个 “对象工厂”,被 new 调用时才算是真正的构造函数。当你需要批量创建对象时,它就是你的 “效率神器”!

其中总共有2种方法,我称之为手工小作坊自动化工厂

你们最喜欢的代码环节:

// 手工小作坊:每次造对象都得重复写
function Insert(name, age, job) {
    const obj = {
        name: name,
        age: age,
        job: job
    }
    return obj;
}
console.log(Insert('henry',19,'coder'));

结果也正确的打印出了我们赋的值:

image.png

但是这种写法就有一个缺点!我只是给了一组数据输出结果,那如果是10个呢,100个呢?甚至是老板让你把全公司人的信息输入进去呢?难道你一个一个去console.log?显然在庞大的数据下这种肯定是不推荐的,那就有请另外一位--自动化工厂闪亮登场!

// 自动化工厂:new一下,对象自动造好
function Car(color) {
    this.name = 'su7';
    this.height = '1400';
    this.long = '4800';
    this.weight = '1500';
    this.color = color;
}
const car1 = new Car('purple'); // 实例化一个对象
console.log(car1);

image.png

拿代码中的小米su7举例子:

如果雷总想让你批量生产su7,你如果用手工小作坊 的方法,那得多少行代码啊!每辆车的型号信息都得重复输入,效率低的没眼看。那么怎么去解决呢?简单!你很快就发现,每辆车它的长度啊,高度啊,重量啊等等都是一样的,没必要去重复输入,只有颜色不一样而已,所以为了提高效率,我们有请this带来它的自动化工厂

function Car(color) {
    this.name = 'su7';
    this.height = '1400';
    this.long = '4800';
    this.weight = '1500';
    this.color = color;
}
const car1 = new Car('purple'); 
console.log(car1);
const car2 = new Car('blue');
console.log(car2);

image.png

如此这般,我们只需传输每辆车不同的数据(比如颜色),其他的已经固定好的数据我们就可以省略不写,大大提高了我们敲代码的效率,学废了没?

三、new 关键字:“构造函数的幕后BOSS”

你以为 new 只是个关键字?它可是 “对象诞生” 的幕后导演!它干了三件大事:

  1. 创建一个 this 对象:相当于搭了个空舞台;
  2. 执行构造函数代码:往 this 上狂加属性方法,把舞台布置好;
  3. 返回 this 对象:把布置好的舞台交给你。

咱们用代码模拟一下 new 的逻辑,一目了然:

function person() {
    const _this = {};   // 第一步:造个空this
    _this.name = 'su7'; // 第二步:往this上加东西
    _this.color = 'green';
    return _this;       // 第三步:返回this
    const obj = person();
    console.log(obj);   // 看看实力
}

看看导演的成果:

image.png

this已经被官方征用了,所以我们这里只是用_this模拟this的对象,大家不要搞混了哈!

四、包装类:“原始类型的隐藏马甲”

依旧先上代码:

var str = 'hello'; 
console.log(str.length);

image.png

打印输出的结果为5,为hello的长度,看似没有问题。But!有人会问:作者你之前不是说原始类型不是不能有属性方法吗?那str.length是咋来的?随着质疑声越来越大,我也不含糊,这就不得不讲讲包装类了。

JS 是个 “戏精”,当你用原始类型字面量时,它会偷偷给你套个 “包装对象” 的马甲(比如new String()),让原始类型也能临时 “装” 成对象用用。但这马甲很 “薄”——参与运算时会自动拆成原始类型,赋值时还会把临时加的属性偷偷删掉

具体来说就是:因为 js 是弱类型语言,所以只有在赋值语句执行时才会判断值的类型(typeof),当值被判定为原始类型时,就会自动将包装对象上添加的属性移除

拿刚刚的hello举例子:

var str = 'hello'; // 看似是原始类型,实际背后是new String('hello')
str.length = 2;    // 试图给它加个属性
console.log(str.length);  // 猜猜是2吗?不,还是5!因为马甲被拆了,临时属性没了
console.log(typeof(str)); // 类型还是string,马甲只是临时穿穿

结果和预期一样:

image.png

总结

其实 JS 里的对象逻辑,本质就是 “把零散的数据和功能打包成整体”—— 字面量是 “随手包”,构造函数是 “批量生产线”,new 是 “生产线的核心开关”,而包装类则是 “让零散原始值临时享受对象待遇的便民服务”。

这些设计看似花里胡哨,实则都是为了平衡 “易用性” 和 “专业性”:既不让新手觉得创建对象太复杂,也能让老手通过构造函数、包装类的底层逻辑玩出花样。

通过这篇文章,下次再写对象相关代码时,你不妨想想:我这是在 “随手包个小物件”,还是在 “开生产线批量造货”?原始值的 “马甲” 会不会在运算时掉下来?想通这些,你就不再是 “只会用对象” 的新手,而是 “懂对象底层逻辑” 的进阶玩家 —— 毕竟 JS 的魅力,就在于这些看似 “反常识” 却又 “超合理” 的设计里面!

【每日一面】如何解决内存泄漏

2025年11月19日 10:09

基础问答

问:有没有遇到过内存泄漏?怎么排查处理的

答:前端页面上出现内存泄露,使用 Chrome devtools -> memory 工具排查,选择时间轴分配(Allocations on timeline)功能后开始录制操作,在页面上进行相关组件的操作,停止录制后,查看内存曲线,重点关注内存曲线上升的和下降的位置,如出现只升不降,没有明显回落的区域,再重点操作,重新录制对应位置的操作,逐步缩小定位。对于这种重点关注的区域,可以同时使用堆快照追踪持续增长的对象。对排查出来的点位进行验证的时候,可以通过内存面板的垃圾回收按钮,如下图,回收后如果内存大小还是很高,可以确认是存在无法回收的内存,有泄露的情况。 image-20251116025955-kdes7f7-tmiqyaxt.png

扩展延伸

内存泄漏是 JavaScript 开发中隐蔽性强且影响严重的问题,尤其在长生命周期应用,如 SPA、后台管理系统中,可能导致页面卡顿、崩溃甚至浏览器无响应的问题。

内存泄露的本质是:本来应该被回收的对象因为意外的引用而保留了下来,导致垃圾回收器无法释放这个对象所占用的内存,使得内存占用持续增长。

垃圾回收机制

JavaScript 采用自动垃圾回收机制,不需要手动释放内存,通过引用计数标记-清除算法回收不再使用的内存:

  • 引用计数:跟踪每个对象被引用的次数,次数为 0 时回收,但是出现循环引用的时候,这个就无法解决了。
  • 标记 - 清除:从根对象(如 window )出发,标记所有可达对象,未被标记的对象将被回收,这是目前浏览器主流的算法。

OOM

和内存泄露相关联的还有一个概念,即OOM,内存溢出,指的是在程序申请内存时,发现没有可用内存分配,直接抛出了 OOM 异常。

一般来说,内存泄露是内存溢出的一个原因,但不是唯一的原因,而内存泄露会持续消耗内存资源,最终导致没有可以分配的内存给程序,出现 OOM。

内存泄露的场景

  1. 意外的全局变量

    一般是在非严格模式下出现,使用的变量没有声明,会隐式的绑定到 window 对象上,变成持久性的引用,如:

    function fn() {
    data = {};
    }
    

    解决方案:对于这种情况,第一优先的是启动严格模式(现在的框架或项目都是默认为严格模式,通常不需要关注),其次,在现在使用的 es6 规范下,优先使用 let/const 关键字声明,最后如果真的是全局变量,我们应该在确定不再使用后,赋值为 null ,从而切断对象的引用,让 GC 自动回收。

  2. 闭包导致内存泄露

    对于前端,闭包是一个非常好用的特性,但同时也需要在使用的时候注意,如果创建的闭包被长期使用,则闭包持有的变量就无法释放,一个经典案例就是计时器:

    function handleOnClickFac() {
    let timer = null;
    return function () {
    timer = setInterval(() => {
    console.log('hello');
    }, 3000);
    }
    }
    
    window.clickBtn = handleOnClickFac();
    
    btn.addEventListener('click', window.clickBtn);
    

    在这里,每次点击按钮都会触发定时器的创建,但是我们没有清除回收,所以导致这个定时器一直存在,每次点击的时候都会创建一个新的定时器。

    这个例子中,包含两个场景,一是闭包,二是定时器。

    解决方案:限制闭包生命周期,比如这里在 btn 组件卸载时,销毁闭包,从而实现“不可达”的情况,让 GC 回收,其次需要在使用完成后,清除闭包内的引用,在这个例子中,我们不仅要清楚引用,同时还应该清除定时器,否则依旧存在问题。

  3. DOM 元素引用未释放

    分两种情况:1. DOM 树中已经没有 DOM 元素了,但是 JavaScript 中还有这个 DOM 元素的链接(变量),2. 事件监听器没有移除,存在 DOM 和监听回调存在互相引用的情况。

    // 场景1:DOM已删除但 JS 仍引用
    const list = document.getElementById('list');
    const data = { element: list }; // 引用DOM元素
    document.body.removeChild(list); 
    // list已从DOM树移除,但data.element仍引用它,无法回收
    
    // 场景2:事件监听器未移除
    const button = document.getElementById('btn');
    button.addEventListener('click', () => {
      console.log('点击事件');
    });
    // 按钮被删除后,监听器未移除,导致按钮和回调函数都无法回收
    

    解决方案:解决这类场景的核心依旧是在不需要的时候释放引用,不过对于 DOM,还有一种方式就是使用事件委托,从而在子元素删除的时候不受影响。

  4. 第三方库资源未清理

    类似于 Echarts 、地图等库,会要求我们在不使用的时候,调用对应的销毁的 API,如果我们没有调用,这些库创建的临时资源就会持续占用内存,导致内存泄露。

这些场景下的解决方案都是需要我们手动在需要的地方去清除引用,从而使 GC 能够识别并回收内存,通过这些例子也不难发现,虽然在 JavaScript 中不需要我们做类似于 C++ 的手动内存回收,但是依旧需要我们去帮助 GC 更好的判断资源是否需要回收。

检测和分析

内存泄露的检测和分析主要是通过浏览器的内存工具,这里以 Chrome 为例,我们在检测和分析时使用的是 Chrome Devtool Memory 面板: image-20251116153104-s5tb0qs-scjvtike.png

  1. 观察时间线上的分配(Allocation Timeline)

    1. 开启记录后,按照推测的问题,操作页面内容,完成后停止记录,开始自动分析
    2. 观察只升不降的区域,重复录制该区域对应的操作,查看内存是否确实存在只分配不回收的情况,记录该操作
  2. 记录堆快照(Heap Snapshot)

    1. 操作开始前,记录一次初始的堆快照

    2. 重复第一步记录的操作,拍摄第二次快照,并开启比较(Comparison)模式,重点关注 Delta 和 Retainers 指标(这里对应的面板的中文名是 #增量固定装置 ,翻译不是很准确,这里提供英文界面的图作为参考

      image-20251116153926-8vvr07i-edfpjyti.png Delta 关注持续增长的对象,Retainer 追踪引用该对象的变量

  3. 点击垃圾桶(代表 GC)触发一次 GC,如果 GC 后内存依旧很高,就可以确认是存在内存泄露。

面试追问

  1. 内存泄露和内存溢出有什么关系?

    内存泄露会导致内存溢出,但是内存溢出不一定是内存泄露导致的。

  2. 常见的内存泄露场景,举个例子?

    参考本文【内存泄露的场景】一节

  3. Node.js 服务中,长生命周期对象持有短生命周期对象是一个典型的泄露场景,举例并给出排查思路

    // 用全局对象做缓存,无淘汰策略
    const cache = {}; 
    
    // 接口每次请求都往缓存加数据
    app.get('/api/data', (req, res) => {
      const key = `data_${req.query.id}`;
      const largeData = fetchLargeData(req.query.id); // 10MB 数据
      cache[key] = largeData; // 只加不删,缓存持续膨胀
      res.send(largeData);
    });
    

    由于 cache 没有设置缓存的过期时间、淘汰的方式,导致 largeData 一直被持有,使得内存不断增长。

    排查思路:1. Node.js 应用启动时添加 --inspect 标志,2. 在 Chrome 浏览器中,访问 chrome://inspect 链接对应的 Node 进程,开始监测,3. 记录初始时的堆快照和多次触发后的堆快照,方式参考【检测和分析】一节,4. 查看 cache 的引用路径以及清理逻辑。5. 设置缓存时间或LRU淘汰策略解决这个问题

  4. 线上环境 Nodejs OOM 触发报警了,你应该怎么做?

    首先,应急止损,滚动重启服务,避免损失扩大,同时增加内存延缓 OOM 时间。

    其次,分析问题出现的时间,判断是否可以回滚服务解决。

    最后,分析定位根源,按照服务日志和本地排查手段进行。

    如果使用的是 k8s 等虚化手段,可以配置服务重启规则,避免人工低效的操作方式。

C#常量(const)与枚举(enum)使用指南

作者 烛阴
2025年11月19日 09:55

一、 常量 (const)

常量,就是一个其值在程序编译时就已经确定,并且在整个运行期间都不能被修改的量。

如何声明常量?

使用 const 关键字进行声明,并且必须在声明时就进行初始化。

const double PI = 3.14159;
const int DAYS_IN_WEEK = 7;
const string AppVersion = "1.0.2";

命名约定:常量的名称通常使用全大写字母,并用下划线分隔单词,以示区别。

const vs readonly

你可能还会遇到 readonly (只读) 关键字,它与 const 类似但有一个关键区别:

  • const 的值必须在编译时确定。
  • readonly 的值可以在运行时确定(在构造函数中赋值),但一旦赋值后就不能再更改。
public class Config
{
    // 编译时常量
    public const int DefaultTimeout = 5000; 

    // 运行时只读字段
    public readonly DateTime CreationTime;

    public Config()
    {
        // readonly 字段可以在构造函数中初始化
        CreationTime = DateTime.Now; 
    }
}

二、 枚举 (enum)

枚举(enum)允许我们定义一个由一组命名的常量组成的集合,这个集合本身成为一个新的类型。

如何声明和使用枚举?

使用 enum 关键字来定义。

// 定义一个订单状态的枚举
public enum OrderStatus
{
    Pending,    // 待处理
    Processing, // 处理中
    Shipped,    // 已发货
    Completed,  // 已完成
    Cancelled   // 已取消
}

现在,OrderStatus 就成了一个新的数据类型。我们可以这样使用它:

// 声明一个枚举类型的变量
OrderStatus currentStatus = OrderStatus.Pending;

// 在逻辑判断中使用,代码清晰易读
if (currentStatus == OrderStatus.Shipped)
{
    Console.WriteLine("订单已发货!");
}

// 在 switch 语句中使用,是枚举的最佳拍档
switch (currentStatus)
{
    case OrderStatus.Pending:
        // ...
        break;
    case OrderStatus.Processing:
        // ...
        break;
    // ... 其他状态
    default:
        // ...
        break;
}

枚举的优势:

  1. 强类型安全:你不能将一个随意的整数赋给一个枚举变量,编译器会阻止你犯错。这避免了将无效的状态码(比如 orderStatus = 10)赋给变量的可能。
  2. 极佳的可读性OrderStatus.Completed 清晰地表达了其含义,而数字 3 则不能。你的代码会变得“自解释”。
  3. 智能提示(IntelliSense):在Visual Studio等IDE中,当你输入枚举变量时,会自动提示所有可用的成员,极大提高了开发效率并减少了拼写错误。

枚举的底层机制

默认情况下,枚举的每个成员都对应一个整数,从0开始依次递增 (Pending 是 0, Processing 是 1, ...)。你也可以显式地为它们指定值,或者更改其底层的类型(默认为 int)。

public enum ErrorCode : ushort // 可以指定底层类型
{
    None = 0,
    NotFound = 404,
    InternalServerError = 500,
    AccessDenied = 403
}

结语

点个赞,关注我获取更多实用 C# 技术干货!如果觉得有用,记得收藏本文!

❌
❌