普通视图

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

PDF和Word文件转换为Markdown的技术实现

作者 Aitter
2025年10月21日 00:03

PDF和Word文件转换为Markdown的技术实现

PDF转Markdown技术实现

技术方案

使用Kimi AI API进行PDF内容提取和格式转换,采用三步流程:

  1. 文件上传:将PDF文件上传到Kimi服务器
  2. 内容提取:通过Kimi API提取PDF文件的文本内容
  3. 格式转换:使用Kimi AI的聊天完成API将提取的内容转换为Markdown格式

技术特点

  • 依赖外部AI服务:需要配置Kimi API密钥
  • 智能内容理解:利用AI理解文档结构和内容
  • 格式保留:能够保留表格结构、标题层级和重要格式
  • 自动清理:转换完成后自动删除上传的临时文件

代码实现要点

  • 使用fetch与Kimi API交互
  • 通过FormData处理文件上传
  • 使用系统提示词指导AI进行格式转换
  • 错误处理和资源清理

Word转Markdown技术实现

技术方案

采用混合转换方案,结合两个专业库的优势:

  1. Word到HTML:使用mammoth库将Word文档转换为HTML
  2. HTML到Markdown:使用node-html-markdown库将HTML转换为Markdown

技术特点

  • 本地处理:不依赖外部API,完全在本地处理
  • 专业库支持:使用专门针对Word文档解析的库
  • 格式保留:能够较好地保留文档格式和结构
  • 高效转换:两步转换流程,处理速度较快

代码实现要点

  • 使用mammoth.convertToHtml()将Word转换为HTML
  • 使用NodeHtmlMarkdown().translate()将HTML转换为Markdown
  • 简单的错误处理和响应返回

两种方案的对比

特性 PDF转Markdown Word转Markdown
技术依赖 外部AI服务(Kimi API) 本地库(mammoth + node-html-markdown)
处理方式 AI理解内容后转换 两步格式转换
配置需求 需要API密钥 无额外配置
处理速度 较慢(网络请求+AI处理) 较快(本地处理)
成本 可能产生API调用费用 免费
格式保留能力 强(AI理解能力强) 中等(依赖库的转换能力)
可靠性 依赖外部服务稳定性 依赖本地库的稳定性

总结

PDF转Markdown采用了基于AI的方案,利用Kimi AI的强大内容理解能力,能够更好地处理复杂的PDF文档结构和格式,但需要依赖外部API并可能产生费用。

Word转Markdown采用了本地处理的混合方案,结合了mammoth对Word文档的良好解析能力和node-html-markdown的HTML到Markdown转换能力,处理速度快且无额外成本,但在处理复杂格式时可能不如AI方案。

面试问题—上家公司的离职原因

作者 mapbar_front
2025年10月20日 23:37

面试结尾HR必问的问题,就是上一家公司的离职原因,作为多年的资深架构师,我做过多次终面,听到过千奇百怪的答案,有的真诚,有的官方,有的遮遮掩掩,有的情绪愤怒,这个问题是有正确答案的,今天就来和你分享一下。

1、真实的离职原因

其实离职无非就是两类原因,一类主动,一类被动。

主动,要么钱少,要么心累,但大多数情况都是钱少心又累。

被动,要么被行情拖累,要么末位淘汰,要么违纪被发现,这个问题只要不回答的稀碎,都不会影响你被录用。

2、避开下面两个错误答案

2.1、 破口大骂前公司前领导

有可能真的是你的前领导,做人做事很差劲,但是真实的面试中,不要这么说。

一般而言,面试官,会无形中把自己代入到你的领导的角色中,如果你现在这么骂别人,那面试官会觉得,后面你会不会骂他。(这是人性使然)

一家公司,只要是真正做事的公司,做到一定规模,对leader的要求还是比较高的,出现那种非常差劲的领导的可能性,比较低,在你批评你前领导的时候,很有可能别人会更相信是你自己的问题。

如果真的遇到那种特别垃圾的领导,那你就陈述事实即可,不要做过多的评价。倡导一些公平、正义、积极向上的价值观即可。

2.2、和上家公司闹翻,包装的离职原因被拆穿

尽量,在任何时候,都不要和上家的公司闹翻,当你从一家公司离职的时候,上家公司对你唯一的把柄就是,它具备一定的评价你的权力。

如果,真的是公司有巨大的问题,比如那种不给发工资,裁员不给赔偿的这种,那就直接走流程,该仲裁仲裁,该咋办就咋办。维护我们正当利益,也是我们的权力。如果这家公司在这种情况下,给你使绊子,你其实只需要提供完整的证据链,证明这家公司不行即可。(这种情况下,大家其实都是理解的)

面试官为什么会关心离职原因这个问题,因为如果你是违纪或者末尾淘汰,他们担心把你招进来,再出现类似的风险。钱少心累,这些都是正常理由,面试官天天面试,他们都能理解。

3、描述离职原因的场景

如果你觉得钱少,你就说多久没涨薪,你也要养家糊口,工作是为了更好的生活。

如果你觉得心累,你就说前公司管理比较混乱,发挥不出来个人价值,自己做的更多都是无用功。

如果钱少心又累,还是说钱少吧,这个理由更好一点。

如果从差的公司往好的公司跳,也可以顺便夸一夸新公司,就说想到更好的平台发展,想获得更大的个人提升。

如果是部门裁测,或者公司大规模裁员,直说就行,这也不是你的问题,就像最近某大厂30%的裁员,在圈子里一般都藏不住。

如果是末位淘汰或者违纪,只要不和前公司闹翻,不用提,就说钱少或者心累。

如果你和前公司闹翻了,这就有点麻烦,大厂全员背调,你只能直说,把自己放在受害者的身份包装出来,中小公司的话,普通员工背调的可能性不大,不用提了,就说钱少或者心累。

如果真的离职原因实在说不出口,那还是去中小公司。

昨天 — 2025年10月20日掘金 前端

用 Python 揭秘 IP 地址背后的地理位置和信息

作者 烛阴
2025年10月20日 22:13

准备工作:安装必备工具

首先,请确保你的Python环境中安装了requests库。

pip install requests

第一步:查询自己的公网 IP 信息

import requests
import json

# 向ipinfo.io发送请求,不带任何IP地址,它会默认查询你自己的IP
url = "https://ipinfo.io/json"

try:
    response = requests.get(url)
    response.raise_for_status() # 如果请求失败 (如状态码 4xx, 5xx), 会抛出异常

    # 将返回的JSON格式数据解析为Python字典
    data = response.json()

    print("--- 你的IP信息详情 ---")
    # 为了美观,使用json.dumps进行格式化输出
    print(json.dumps(data, indent=4, ensure_ascii=False))

except requests.exceptions.RequestException as e:
    print(f"请求失败: {e}")

运行后,你将看到类似这样的输出(信息会根据你的实际情况而变):

{
    "ip": "xxx.xxx.xxx.xxx",
    "hostname": "some.host.name",
    "city": "xx",
    "region": "xx",
    "country": "CN",
    "loc": "39.9042,116.4074",
    "org": "xx",
    "postal": "100000",
    "timezone": "Asia/Shanghai",
    "readme": "https://ipinfo.io/missingauth"
}

第二步:查询任意指定的 IP 地址

我们可以查询任何一个我们想查的公网IP,比如谷歌的公共DNS服务器 8.8.8.8

import requests
import json

# 定义要查询的IP地址
target_ip = "8.8.8.8"

# 构造请求URL,将IP地址拼接到URL中
url = f"https://ipinfo.io/{target_ip}/json"

try:
    response = requests.get(url)
    response.raise_for_status()

    data = response.json()

    print(f"--- IP: {target_ip} 的信息详情 ---")
    print(json.dumps(data, indent=4, ensure_ascii=False))

except requests.exceptions.RequestException as e:
    print(f"请求失败: {e}")

输出将会是:

{
    "ip": "8.8.8.8",
    "hostname": "dns.google",
    "city": "Mountain View",
    "region": "California",
    "country": "US",
    "loc": "37.4056,-122.0775",
    "org": "AS15169 Google LLC",
    "postal": "94043",
    "timezone": "America/Los_Angeles",
    "readme": "https://ipinfo.io/missingauth",
    "anycast": true
}

第三步:自由封装成自己需要的内容显示库

示例

import requests

def get_ip_info(ip_address: str) -> dict | None:
    """
    查询指定IP地址的详细信息。
    
    :param ip_address: 要查询的IP地址字符串。
    :return: 包含IP信息的字典,如果查询失败则返回None。
    """
    url = f"https://ipinfo.io/{ip_address}/json"
    try:
        response = requests.get(url, timeout=5) # 增加超时设置
        response.raise_for_status()
        return response.json()
    except requests.exceptions.RequestException as e:
        print(f"查询IP {ip_address} 时出错: {e}")
        return None

# --- 使用我们封装好的函数 ---
if __name__ == "__main__":
    ip_list = ["8.8.8.8", "1.1.1.1", "114.114.114.114"]
    
    for ip in ip_list:
        info = get_ip_info(ip)
        if info:
            country = info.get('country', 'N/A')
            city = info.get('city', 'N/A')
            org = info.get('org', 'N/A')
            print(f"IP: {ip:<15} | Location: {country}, {city} | Organization: {org}")

结语

点个赞,关注我获取更多实用 Python 技术干货!如果觉得有用,记得收藏本文!

尤雨溪官宣:"新玩具" 比 Prettier 快 45 倍!

2025年10月20日 22:00

“格式化太慢?Lint 卡到爆?不,我们全都要。” —— 尤雨溪

一条推文,前端圈炸锅

10 月 19 日下午,Vue.js 作者尤雨溪Twitter 甩出一张截图:

转存失败,建议直接上传图片文件

  • oxfmtBiome2–3 倍
  • Prettier45 倍
  • Biome 更贴合 Prettier 规范
  • 已跑通 ViteVueAntDesignSupabase亿级代码库
  • “即将可用,直接平替 Prettier!”

配图最后一句话,才是核弹:

“还有一些情况我们故意与 Prettier 不同。”

ESLint 也一起被“斩”?

等等,ESLint 呢?

转存失败,建议直接上传图片文件

别急,Oxlint 早已埋伏多时:

工具 职责 替代目标 性能倍数
oxfmt 代码格式化 Prettier 45×
oxlint 代码检查 ESLint 50–100×

Oxlint 规则已覆盖 200+ 条 ESLint 核心与社区推荐规则,并支持 --fix 自动修复。

ViteRollupVue-core 的 CI 里,ESLint 全程没启动,Oxlint 0 秒跑完。

为什么要“弃用”?

  1. 速度 = 生产力
    本地 save 秒级反馈,CI 从 3 min → 5 s,老板都忍不住点赞。

  2. 兼容 ≈ 零迁移
    oxfmtPrettier 快照测试 99.7% 一致
    oxlint 提供 eslint-compat 插件,老配置直接导入。

  3. 可扩展 > 老生态
    基于 RustWASM 插件,冷启动 <10 ms,不再被 Node 单线程卡死。

  4. 改进,而不是迁就
    对于带注释的 TS 联合类型,Prettier 会“对不齐”,oxfmt 主动修正
    尤雨溪直言:“不是固执,是 Prettier 真的错了。

迁移指南

1. 一键卸载老伙伴

npm  uninstall prettier eslint eslint-config-xxx eslint-plugin-xxx

2. 安装 OXC 全家桶

npm  i  -D  oxc-cli
# 或
pnpm  add  -D  oxc-cli

3. 配置 oxc.config.js

export default {
  formatter: { enable: true, semi: false, tabWidth: 2 },
  linter: {
    enable: true,
    rules: { import: { noCycle: 'error' } }
  }
}

4. 脚本替换

{
  "scripts": {
    "fmt": "oxc fmt",
    "lint": "oxc lint",
    "lint:fix": "oxc lint --fix"
  }
}

5. GitHub Actions 示例

- name: Lint & Format
  run: |
    npx oxc fmt --check
    npx oxc lint

写在最后

十年前,GruntGulpWebpack
五年前,RollupVite
今天,ESLint + Prettier → OXC

尤雨溪又一次把“不可能”写成 PR 标题:
“feat: retire eslint & prettier”

准备好迎接 0 秒 lint、0 秒 format 的新时代了吗?

OXC 官网/仓库https://github.com/oxc-project/oxc

用 Vue3 + Canvas 做了个超实用的水印工具,同事都在抢着用

作者 刘大华
2025年10月20日 19:07

大家好,我是大华!

在公众号运营和内容创作中,图片版权保护是一个非常重要的问题。今天我分享一个基于Vue3图片水印添加的源码和实现步骤,即使你是前端新手也能轻松掌握!

一、项目效果预览

我们最终将实现一个这样的工具:

  • 支持上传本地图片
  • 两种水印模式:单水印(9个位置可选)和平铺水印
  • 完全自定义水印样式:文本、字体、大小、颜色、透明度、旋转角度
  • 实时预览效果
  • 一键下载带水印的图片

效果图 在这里插入图片描述在这里插入图片描述

完整源码在文末可直接复制。

二、环境准备

这个项目只需要一个HTML文件,不需要复杂的开发环境:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vue3 图片水印添加器</title>
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
</head>
<body>
  <!-- 我们的代码将写在这里 -->
</body>
</html>

三、项目结构设计

让我们先规划一下页面的整体结构:

容器 (Container)
├── 头部 (Header)
├── 主要内容区域 (App Content)
    ├── 控制面板 (Controls)
    │   ├── 图片上传区域
    │   ├── 水印模式选择
    │   ├── 水印设置
    │   ├── 位置控制(单水印模式)
    │   ├── 平铺设置(平铺模式)
    │   └── 操作按钮
    └── 预览区域 (Preview)
        ├── 图片预览容器
        └── 预览信息

四、核心代码详解

1. Vue3应用初始化

const { createApp, ref, watch, onMounted } = Vue;

createApp({
  setup() {
    // 这里写我们的逻辑代码
  }
}).mount('#app');

简单解释:

  • createApp:创建Vue应用
  • ref:用于创建响应式数据
  • watch:监听数据变化
  • onMounted:页面加载完成后执行

2. 数据定义

const imageSrc = ref('');  // 存储上传的图片
const watermarkCanvas = ref(null);  // 画布元素引用

// 水印配置数据
const watermark = ref({
  mode: 'single',        // 模式:single(单水印) 或 tile(平铺)
  text: '示例水印',       // 水印文字
  fontFamily: 'Microsoft YaHei',  // 字体
  fontSize: 24,          // 字体大小
  color: '#ffffff',      // 颜色
  opacity: 0.7,          // 透明度
  rotation: -15,         // 旋转角度
  position: 'bottom-right',  // 位置(单水印模式)
  tileSpacingX: 100,     // 水平间距(平铺模式)
  tileSpacingY: 100      // 垂直间距(平铺模式)
});

响应式数据说明: 当这些数据发生变化时,页面会自动更新,这就是Vue3的响应式特性。

3. 图片上传功能

// 触发文件选择
const triggerFileInput = () => {
  document.getElementById('fileInput').click();
};

// 处理图片上传
const handleImageUpload = (event) => {
  const file = event.target.files[0];
  if (file) {
    const reader = new FileReader();
    reader.onload = (e) => {
      imageSrc.value = e.target.result;  // 将图片转为base64格式
    };
    reader.readAsDataURL(file);
  }
};

FileReader小知识: FileReader是浏览器提供的API,可以读取文件内容。readAsDataURL方法将文件读取为Data URL格式,可以直接用在img标签的src属性中。

4. 水印绘制核心逻辑

这是整个项目最核心的部分,我们分步骤来看:

准备工作

const drawWatermark = () => {
  if (!imageSrc.value || !watermarkCanvas.value) return;
  
  const canvas = watermarkCanvas.value;
  const ctx = canvas.getContext('2d');
  const img = new Image();
  
  img.onload = () => {
    // 设置canvas尺寸与图片一致
    canvas.width = img.width;
    canvas.height = img.height;
    
    // 清除之前的绘制内容
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    
    // 设置水印样式
    ctx.font = `${watermark.value.fontSize}px ${watermark.value.fontFamily}`;
    ctx.fillStyle = watermark.value.color;
    ctx.globalAlpha = watermark.value.opacity;
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    
    // 根据模式绘制水印
    if (watermark.value.mode === 'single') {
      drawSingleWatermark(ctx, canvas);
    } else {
      drawTiledWatermark(ctx, canvas);
    }
  };
  
  img.src = imageSrc.value;
};

单水印模式绘制

const drawSingleWatermark = (ctx, canvas) => {
  let x, y;
  const padding = 20;  // 距离边缘的间距
  
  // 根据选择的位置计算坐标
  switch(watermark.value.position) {
    case 'top-left':
      x = padding;
      y = padding;
      ctx.textAlign = 'left';
      ctx.textBaseline = 'top';
      break;
    case 'top-center':
      x = canvas.width / 2;
      y = padding;
      ctx.textBaseline = 'top';
      break;
    // ... 其他7个位置的类似计算
  }
  
  // 应用旋转效果
  ctx.save();                    // 保存当前画布状态
  ctx.translate(x, y);           // 移动坐标原点到水印位置
  ctx.rotate(watermark.value.rotation * Math.PI / 180);  // 旋转
  ctx.fillText(watermark.value.text, 0, 0);  // 绘制文字
  ctx.restore();                 // 恢复画布状态
};

Canvas绘图要点:

  • save()restore():保存和恢复画布状态,避免旋转影响后续绘制
  • translate():移动坐标原点
  • rotate():旋转画布,参数是弧度(角度×π/180)

平铺水印模式绘制

const drawTiledWatermark = (ctx, canvas) => {
  // 计算文字占据的宽度和高度
  const textWidth = ctx.measureText(watermark.value.text).width;
  const textHeight = watermark.value.fontSize;
  
  // 计算每个水印单元的大小(文字+间距)
  const unitWidth = textWidth + watermark.value.tileSpacingX;
  const unitHeight = textHeight + watermark.value.tileSpacingY;
  
  // 计算需要绘制多少行和列(+1确保覆盖整个图片)
  const cols = Math.ceil(canvas.width / unitWidth) + 1;
  const rows = Math.ceil(canvas.height / unitHeight) + 1;
  
  // 循环绘制每个水印
  for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
      const x = j * unitWidth;
      const y = i * unitHeight;
      
      // 交错排列,让平铺效果更自然
      const offsetX = (i % 2) * (unitWidth / 2);
      
      ctx.save();
      ctx.translate(x + offsetX, y);
      ctx.rotate(watermark.value.rotation * Math.PI / 180);
      ctx.fillText(watermark.value.text, 0, 0);
      ctx.restore();
    }
  }
};

5. 图片下载功能

const downloadImage = () => {
  if (!imageSrc.value) return;
  
  // 创建临时canvas
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  const img = new Image();
  
  img.onload = () => {
    // 设置canvas尺寸
    canvas.width = img.width;
    canvas.height = img.height;
    
    // 先绘制原始图片
    ctx.drawImage(img, 0, 0);
    
    // 再绘制水印(逻辑与预览时相同)
    // ... 水印绘制代码
    
    // 创建下载链接
    const link = document.createElement('a');
    link.download = 'watermarked-image.png';
    link.href = canvas.toDataURL('image/png');
    link.click();  // 触发下载
  };
  
  img.src = imageSrc.value;
};

下载原理:

  1. 创建隐藏的canvas
  2. 先画原始图片,再画水印
  3. 将canvas转为Data URL
  4. 创建a标签触发下载

6. 响应式更新

// 监听水印设置变化,实时更新预览
watch(watermark, () => {
  drawWatermark();
}, { deep: true });

// 监听图片变化
watch(imageSrc, () => {
  if (imageSrc.value) {
    setTimeout(drawWatermark, 100);
  }
});

watch函数说明:

  • 第一个参数:要监听的数据
  • 第二个参数:数据变化时执行的回调函数
  • deep: true:深度监听,对象内部属性变化也会触发

五、界面设计与CSS技巧

1. 整体布局

使用Flexbox实现响应式布局:

.app-content {
  display: flex;
  flex-wrap: wrap;
  gap: 20px;  /* 元素间距 */
}

.controls {
  flex: 1;
  min-width: 320px;  /* 最小宽度,确保在小屏幕上也能正常显示 */
}

.preview {
  flex: 2;
  min-width: 500px;
}

2. 网格布局实现9宫格位置选择

.position-controls {
  display: grid;
  grid-template-columns: repeat(3, 1fr);  /* 3列等宽 */
  gap: 5px;  /* 格子间距 */
}

3. 视觉反馈

使用CSS过渡效果增强用户体验:

button {
  transition: all 0.3s;  /* 所有属性变化都有0.3秒过渡 */
}

button:hover {
  transform: translateY(-2px);  /* 悬停时轻微上移 */
  box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);  /* 添加阴影 */
}

六、使用指南

1. 基本使用步骤

  1. 上传图片:点击"选择图片"按钮选择本地图片
  2. 选择模式:根据需求选择"单水印"或"平铺水印"
  3. 设置水印
    • 单水印模式:调整位置、文字、样式
    • 平铺模式:调整间距、文字、样式
  4. 实时预览:右侧查看效果
  5. 下载图片:满意后点击下载

2. 水印设置建议

  • 文字颜色:建议使用白色或浅灰色,通过透明度调节效果
  • 字体大小:根据图片尺寸调整,一般20-40px比较合适
  • 旋转角度:-15°到-45°的斜向水印更难被去除
  • 透明度:0.5-0.8之间既能看清又不会太突兀

七、技术亮点总结

  1. 纯前端实现:所有操作在浏览器完成,图片不上传服务器
  2. 实时预览:所有修改立即显示效果
  3. Canvas绘图:使用HTML5 Canvas实现精准的水印绘制
  4. Vue3响应式:数据驱动视图,代码简洁易维护

八、扩展思路

这个基础版本还可以继续增强:

  1. 图片水印:支持上传Logo图片作为水印
  2. 批量处理:一次为多张图片添加水印
  3. 模板保存:保存常用的水印设置
  4. 更多样式:文字阴影、描边等特效
  5. 压缩优化:下载时自动压缩图片大小

完整代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vue3 图片水印添加器</title>
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
      font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    }
    
    body {
      background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
      min-height: 100vh;
      padding: 20px;
      display: flex;
      justify-content: center;
      align-items: center;
    }
    
    .container {
      max-width: 1400px;
      width: 100%;
      background: white;
      border-radius: 15px;
      box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
      overflow: hidden;
    }
    
    header {
      background: linear-gradient(90deg, #4b6cb7 0%, #182848 100%);
      color: white;
      padding: 20px;
      text-align: center;
    }
    
    h1 {
      font-size: 28px;
      margin-bottom: 10px;
    }
    
    .subtitle {
      font-size: 16px;
      opacity: 0.8;
    }
    
    .app-content {
      display: flex;
      flex-wrap: wrap;
      padding: 20px;
      gap: 20px;
    }
    
    .controls {
      flex: 1;
      min-width: 320px;
      padding: 20px;
      background: #f8f9fa;
      border-radius: 10px;
    }
    
    .preview {
      flex: 2;
      min-width: 500px;
      display: flex;
      flex-direction: column;
      align-items: center;
      gap: 20px;
    }
    
    .control-group {
      margin-bottom: 20px;
    }
    
    h2 {
      font-size: 18px;
      margin-bottom: 15px;
      color: #2c3e50;
      border-bottom: 1px solid #eaeaea;
      padding-bottom: 8px;
    }
    
    label {
      display: block;
      margin-bottom: 8px;
      font-weight: 500;
      color: #34495e;
    }
    
    input, select {
      width: 100%;
      padding: 10px;
      border: 1px solid #ddd;
      border-radius: 5px;
      font-size: 14px;
    }
    
    input[type="color"] {
      height: 40px;
      padding: 3px;
    }
    
    input[type="range"] {
      padding: 0;
    }
    
    .range-value {
      display: inline-block;
      width: 40px;
      text-align: center;
      margin-left: 10px;
    }
    
    .button-group {
      display: flex;
      gap: 10px;
      margin-top: 20px;
    }
    
    button {
      flex: 1;
      padding: 12px;
      border: none;
      border-radius: 5px;
      font-weight: 600;
      cursor: pointer;
      transition: all 0.3s;
    }
    
    .upload-btn {
      background: #3498db;
      color: white;
    }
    
    .download-btn {
      background: #2ecc71;
      color: white;
    }
    
    .reset-btn {
      background: #e74c3c;
      color: white;
    }
    
    button:hover {
      transform: translateY(-2px);
      box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
    }
    
    button:disabled {
      background: #bdc3c7;
      cursor: not-allowed;
      transform: none;
      box-shadow: none;
    }
    
    .preview-container {
      width: 100%;
      max-width: 700px;
      border: 1px solid #eaeaea;
      border-radius: 10px;
      overflow: hidden;
      position: relative;
      background: #f8f9fa;
      min-height: 400px;
      display: flex;
      justify-content: center;
      align-items: center;
    }
    
    #previewImage {
      max-width: 100%;
      max-height: 500px;
      display: block;
    }
    
    .watermark-canvas {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      pointer-events: none;
    }
    
    .upload-placeholder {
      text-align: center;
      color: #7f8c8d;
      padding: 40px;
    }
    
    .upload-placeholder i {
      font-size: 48px;
      margin-bottom: 15px;
      display: block;
      color: #bdc3c7;
    }
    
    .position-controls {
      display: grid;
      grid-template-columns: repeat(3, 1fr);
      gap: 5px;
      margin-top: 10px;
    }
    
    .position-btn {
      padding: 10px;
      background: #ecf0f1;
      border: 1px solid #bdc3c7;
      border-radius: 5px;
      text-align: center;
      cursor: pointer;
      transition: all 0.2s;
    }
    
    .position-btn.active {
      background: #3498db;
      color: white;
      border-color: #2980b9;
    }
    
    .position-btn:hover {
      background: #d5dbdb;
    }
    
    .position-btn.active:hover {
      background: #2980b9;
    }
    
    .mode-controls {
      display: flex;
      gap: 10px;
      margin-top: 10px;
    }
    
    .mode-btn {
      flex: 1;
      padding: 10px;
      background: #ecf0f1;
      border: 1px solid #bdc3c7;
      border-radius: 5px;
      text-align: center;
      cursor: pointer;
      transition: all 0.2s;
    }
    
    .mode-btn.active {
      background: #9b59b6;
      color: white;
      border-color: #8e44ad;
    }
    
    .mode-btn:hover {
      background: #d5dbdb;
    }
    
    .mode-btn.active:hover {
      background: #8e44ad;
    }
    
    .tile-controls {
      margin-top: 15px;
      padding: 15px;
      background: #e8f4fc;
      border-radius: 8px;
      border-left: 4px solid #3498db;
    }
    
    @media (max-width: 768px) {
      .app-content {
        flex-direction: column;
      }
      
      .controls, .preview {
        min-width: 100%;
      }
    }
  </style>
</head>
<body>
  <div id="app">
    <div class="container">
      <header>
        <h1>Vue3 图片水印添加器</h1>
        <p class="subtitle">上传图片,添加自定义水印,支持单水印和平铺模式</p>
      </header>
      
      <div class="app-content">
        <div class="controls">
          <div class="control-group">
            <h2>图片上传</h2>
            <input type="file" id="fileInput" @change="handleImageUpload" accept="image/*" style="display: none;">
            <button class="upload-btn" @click="triggerFileInput">选择图片</button>
          </div>
          
          <div class="control-group">
            <h2>水印模式</h2>
            <div class="mode-controls">
              <div class="mode-btn" 
                   :class="{active: watermark.mode === 'single'}" 
                   @click="watermark.mode = 'single'">单水印</div>
              <div class="mode-btn" 
                   :class="{active: watermark.mode === 'tile'}" 
                   @click="watermark.mode = 'tile'">平铺水印</div>
            </div>
          </div>
          
          <div class="control-group">
            <h2>水印设置</h2>
            <label for="watermarkText">水印文本</label>
            <input type="text" id="watermarkText" v-model="watermark.text" placeholder="输入水印文本">
            
            <label for="fontFamily">字体</label>
            <select id="fontFamily" v-model="watermark.fontFamily">
              <option value="Arial">Arial</option>
              <option value="Verdana">Verdana</option>
              <option value="Helvetica">Helvetica</option>
              <option value="Tahoma">Tahoma</option>
              <option value="Times New Roman">Times New Roman</option>
              <option value="Courier New">Courier New</option>
              <option value="Georgia">Georgia</option>
              <option value="Microsoft YaHei">微软雅黑</option>
              <option value="SimHei">黑体</option>
              <option value="SimSun">宋体</option>
            </select>
            
            <label for="fontSize">字体大小: <span class="range-value">{{ watermark.fontSize }}px</span></label>
            <input type="range" id="fontSize" v-model.number="watermark.fontSize" min="10" max="60" step="1">
            
            <label for="color">颜色</label>
            <input type="color" id="color" v-model="watermark.color">
            
            <label for="opacity">透明度: <span class="range-value">{{ watermark.opacity }}</span></label>
            <input type="range" id="opacity" v-model.number="watermark.opacity" min="0.1" max="1" step="0.1">
            
            <label for="rotation">旋转角度: <span class="range-value">{{ watermark.rotation }}°</span></label>
            <input type="range" id="rotation" v-model.number="watermark.rotation" min="0" max="360" step="1">
          </div>
          
          <div class="control-group" v-if="watermark.mode === 'single'">
            <h2>水印位置</h2>
            <div class="position-controls">
              <div class="position-btn" 
                   :class="{active: watermark.position === 'top-left'}" 
                   @click="watermark.position = 'top-left'">左上</div>
              <div class="position-btn" 
                   :class="{active: watermark.position === 'top-center'}" 
                   @click="watermark.position = 'top-center'">中上</div>
              <div class="position-btn" 
                   :class="{active: watermark.position === 'top-right'}" 
                   @click="watermark.position = 'top-right'">右上</div>
              <div class="position-btn" 
                   :class="{active: watermark.position === 'center-left'}" 
                   @click="watermark.position = 'center-left'">左中</div>
              <div class="position-btn" 
                   :class="{active: watermark.position === 'center'}" 
                   @click="watermark.position = 'center'">中心</div>
              <div class="position-btn" 
                   :class="{active: watermark.position === 'center-right'}" 
                   @click="watermark.position = 'center-right'">右中</div>
              <div class="position-btn" 
                   :class="{active: watermark.position === 'bottom-left'}" 
                   @click="watermark.position = 'bottom-left'">左下</div>
              <div class="position-btn" 
                   :class="{active: watermark.position === 'bottom-center'}" 
                   @click="watermark.position = 'bottom-center'">中下</div>
              <div class="position-btn" 
                   :class="{active: watermark.position === 'bottom-right'}" 
                   @click="watermark.position = 'bottom-right'">右下</div>
            </div>
          </div>
          
          <div class="control-group" v-if="watermark.mode === 'tile'">
            <h2>平铺设置</h2>
            <div class="tile-controls">
              <label for="tileSpacingX">水平间距: <span class="range-value">{{ watermark.tileSpacingX }}px</span></label>
              <input type="range" id="tileSpacingX" v-model.number="watermark.tileSpacingX" min="20" max="200" step="5">
              
              <label for="tileSpacingY">垂直间距: <span class="range-value">{{ watermark.tileSpacingY }}px</span></label>
              <input type="range" id="tileSpacingY" v-model.number="watermark.tileSpacingY" min="20" max="200" step="5">
            </div>
          </div>
          
          <div class="button-group">
            <button class="download-btn" @click="downloadImage" :disabled="!imageSrc">下载图片</button>
            <button class="reset-btn" @click="resetWatermark">重置设置</button>
          </div>
        </div>
        
        <div class="preview">
          <h2>预览效果</h2>
          <div class="preview-container">
            <div v-if="!imageSrc" class="upload-placeholder">
              <i>📷</i>
              <p>请上传图片以添加水印</p>
            </div>
            <img v-else :src="imageSrc" id="previewImage" alt="预览图片">
            <canvas class="watermark-canvas" ref="watermarkCanvas"></canvas>
          </div>
          <div v-if="imageSrc" class="preview-info">
            <p>当前模式: <strong>{{ watermark.mode === 'single' ? '单水印' : '平铺水印' }}</strong></p>
            <p v-if="watermark.mode === 'single'">位置: <strong>{{ getPositionText(watermark.position) }}</strong></p>
            <p v-else>平铺间距: <strong>{{ watermark.tileSpacingX }}px × {{ watermark.tileSpacingY }}px</strong></p>
          </div>
        </div>
      </div>
    </div>
  </div>

  <script>
    const { createApp, ref, watch, onMounted } = Vue;
    
    createApp({
      setup() {
        const imageSrc = ref('');
        const watermarkCanvas = ref(null);
        
        const watermark = ref({
          mode: 'single', // 'single' 或 'tile'
          text: '示例水印',
          fontFamily: 'Microsoft YaHei',
          fontSize: 24,
          color: '#ffffff',
          opacity: 0.7,
          rotation: -15,
          position: 'bottom-right',
          tileSpacingX: 100,
          tileSpacingY: 100
        });
        
        const getPositionText = (position) => {
          const positionMap = {
            'top-left': '左上',
            'top-center': '中上',
            'top-right': '右上',
            'center-left': '左中',
            'center': '中心',
            'center-right': '右中',
            'bottom-left': '左下',
            'bottom-center': '中下',
            'bottom-right': '右下'
          };
          return positionMap[position] || position;
        };
        
        const triggerFileInput = () => {
          document.getElementById('fileInput').click();
        };
        
        const handleImageUpload = (event) => {
          const file = event.target.files[0];
          if (file) {
            const reader = new FileReader();
            reader.onload = (e) => {
              imageSrc.value = e.target.result;
            };
            reader.readAsDataURL(file);
          }
        };
        
        const drawWatermark = () => {
          if (!imageSrc.value || !watermarkCanvas.value) return;
          
          const canvas = watermarkCanvas.value;
          const ctx = canvas.getContext('2d');
          const img = new Image();
          
          img.onload = () => {
            // 设置canvas尺寸与图片一致
            canvas.width = img.width;
            canvas.height = img.height;
            
            // 清除画布
            ctx.clearRect(0, 0, canvas.width, canvas.height);
            
            // 设置水印样式
            ctx.font = `${watermark.value.fontSize}px ${watermark.value.fontFamily}`;
            ctx.fillStyle = watermark.value.color;
            ctx.globalAlpha = watermark.value.opacity;
            ctx.textAlign = 'center';
            ctx.textBaseline = 'middle';
            
            if (watermark.value.mode === 'single') {
              // 单水印模式
              let x, y;
              const padding = 20;
              
              switch(watermark.value.position) {
                case 'top-left':
                  x = padding;
                  y = padding;
                  ctx.textAlign = 'left';
                  ctx.textBaseline = 'top';
                  break;
                case 'top-center':
                  x = canvas.width / 2;
                  y = padding;
                  ctx.textBaseline = 'top';
                  break;
                case 'top-right':
                  x = canvas.width - padding;
                  y = padding;
                  ctx.textAlign = 'right';
                  ctx.textBaseline = 'top';
                  break;
                case 'center-left':
                  x = padding;
                  y = canvas.height / 2;
                  ctx.textAlign = 'left';
                  break;
                case 'center':
                  x = canvas.width / 2;
                  y = canvas.height / 2;
                  break;
                case 'center-right':
                  x = canvas.width - padding;
                  y = canvas.height / 2;
                  ctx.textAlign = 'right';
                  break;
                case 'bottom-left':
                  x = padding;
                  y = canvas.height - padding;
                  ctx.textAlign = 'left';
                  ctx.textBaseline = 'bottom';
                  break;
                case 'bottom-center':
                  x = canvas.width / 2;
                  y = canvas.height - padding;
                  ctx.textBaseline = 'bottom';
                  break;
                case 'bottom-right':
                  x = canvas.width - padding;
                  y = canvas.height - padding;
                  ctx.textAlign = 'right';
                  ctx.textBaseline = 'bottom';
                  break;
              }
              
              // 应用旋转
              ctx.save();
              ctx.translate(x, y);
              ctx.rotate(watermark.value.rotation * Math.PI / 180);
              ctx.fillText(watermark.value.text, 0, 0);
              ctx.restore();
            } else {
              // 平铺水印模式
              const textWidth = ctx.measureText(watermark.value.text).width;
              const textHeight = watermark.value.fontSize;
              
              // 计算每个水印单元的大小(包括间距)
              const unitWidth = textWidth + watermark.value.tileSpacingX;
              const unitHeight = textHeight + watermark.value.tileSpacingY;
              
              // 计算需要绘制的水印数量
              const cols = Math.ceil(canvas.width / unitWidth) + 1;
              const rows = Math.ceil(canvas.height / unitHeight) + 1;
              
              // 平铺绘制水印
              for (let i = 0; i < rows; i++) {
                for (let j = 0; j < cols; j++) {
                  const x = j * unitWidth;
                  const y = i * unitHeight;
                  
                  // 交错排列,使平铺更自然
                  const offsetX = (i % 2) * (unitWidth / 2);
                  
                  ctx.save();
                  ctx.translate(x + offsetX, y);
                  ctx.rotate(watermark.value.rotation * Math.PI / 180);
                  ctx.fillText(watermark.value.text, 0, 0);
                  ctx.restore();
                }
              }
            }
          };
          
          img.src = imageSrc.value;
        };
        
        const downloadImage = () => {
          if (!imageSrc.value) return;
          
          const canvas = document.createElement('canvas');
          const ctx = canvas.getContext('2d');
          const img = new Image();
          
          img.onload = () => {
            canvas.width = img.width;
            canvas.height = img.height;
            
            // 绘制原始图片
            ctx.drawImage(img, 0, 0);
            
            // 设置水印样式
            ctx.font = `${watermark.value.fontSize}px ${watermark.value.fontFamily}`;
            ctx.fillStyle = watermark.value.color;
            ctx.globalAlpha = watermark.value.opacity;
            ctx.textAlign = 'center';
            ctx.textBaseline = 'middle';
            
            if (watermark.value.mode === 'single') {
              // 单水印模式
              let x, y;
              const padding = 20;
              
              switch(watermark.value.position) {
                case 'top-left':
                  x = padding;
                  y = padding;
                  ctx.textAlign = 'left';
                  ctx.textBaseline = 'top';
                  break;
                case 'top-center':
                  x = canvas.width / 2;
                  y = padding;
                  ctx.textBaseline = 'top';
                  break;
                case 'top-right':
                  x = canvas.width - padding;
                  y = padding;
                  ctx.textAlign = 'right';
                  ctx.textBaseline = 'top';
                  break;
                case 'center-left':
                  x = padding;
                  y = canvas.height / 2;
                  ctx.textAlign = 'left';
                  break;
                case 'center':
                  x = canvas.width / 2;
                  y = canvas.height / 2;
                  break;
                case 'center-right':
                  x = canvas.width - padding;
                  y = canvas.height / 2;
                  ctx.textAlign = 'right';
                  break;
                case 'bottom-left':
                  x = padding;
                  y = canvas.height - padding;
                  ctx.textAlign = 'left';
                  ctx.textBaseline = 'bottom';
                  break;
                case 'bottom-center':
                  x = canvas.width / 2;
                  y = canvas.height - padding;
                  ctx.textBaseline = 'bottom';
                  break;
                case 'bottom-right':
                  x = canvas.width - padding;
                  y = canvas.height - padding;
                  ctx.textAlign = 'right';
                  ctx.textBaseline = 'bottom';
                  break;
              }
              
              // 应用旋转
              ctx.save();
              ctx.translate(x, y);
              ctx.rotate(watermark.value.rotation * Math.PI / 180);
              ctx.fillText(watermark.value.text, 0, 0);
              ctx.restore();
            } else {
              // 平铺水印模式
              const textWidth = ctx.measureText(watermark.value.text).width;
              const textHeight = watermark.value.fontSize;
              
              // 计算每个水印单元的大小(包括间距)
              const unitWidth = textWidth + watermark.value.tileSpacingX;
              const unitHeight = textHeight + watermark.value.tileSpacingY;
              
              // 计算需要绘制的水印数量
              const cols = Math.ceil(canvas.width / unitWidth) + 1;
              const rows = Math.ceil(canvas.height / unitHeight) + 1;
              
              // 平铺绘制水印
              for (let i = 0; i < rows; i++) {
                for (let j = 0; j < cols; j++) {
                  const x = j * unitWidth;
                  const y = i * unitHeight;
                  
                  // 交错排列,使平铺更自然
                  const offsetX = (i % 2) * (unitWidth / 2);
                  
                  ctx.save();
                  ctx.translate(x + offsetX, y);
                  ctx.rotate(watermark.value.rotation * Math.PI / 180);
                  ctx.fillText(watermark.value.text, 0, 0);
                  ctx.restore();
                }
              }
            }
            
            // 创建下载链接
            const link = document.createElement('a');
            link.download = 'watermarked-image.png';
            link.href = canvas.toDataURL('image/png');
            link.click();
          };
          
          img.src = imageSrc.value;
        };
        
        const resetWatermark = () => {
          watermark.value = {
            mode: 'single',
            text: '示例水印',
            fontFamily: 'Microsoft YaHei',
            fontSize: 24,
            color: '#ffffff',
            opacity: 0.7,
            rotation: -15,
            position: 'bottom-right',
            tileSpacingX: 100,
            tileSpacingY: 100
          };
        };
        
        // 监听水印设置变化,实时更新预览
        watch(watermark, () => {
          drawWatermark();
        }, { deep: true });
        
        // 监听图片变化,更新水印
        watch(imageSrc, () => {
          if (imageSrc.value) {
            // 等待DOM更新后绘制水印
            setTimeout(drawWatermark, 100);
          }
        });
        
        onMounted(() => {
          // 初始化水印
          drawWatermark();
        });
        
        return {
          imageSrc,
          watermarkCanvas,
          watermark,
          getPositionText,
          triggerFileInput,
          handleImageUpload,
          downloadImage,
          resetWatermark
        };
      }
    }).mount('#app');
  </script>
</body>
</html>

完整代码可以直接复制到HTML文件中运行,无需任何额外配置。


希望这篇教程能帮助你理解Vue3和Canvas的配合使用,并为你的图片版权保护提供有力工具。

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

📌往期精彩

《SpringBoot 中的 7 种耗时统计方式,你用过几种?》

《千万级大表如何新增字段?别再直接 ALTER 了》

《Vue3 如何优雅地实现一个全局的 loading 组件》

《我用 Vue3 + Canvas 做了个超实用的水印工具,同事都在抢着用》

前端:从0-1实现一个脚手架

作者 Bacon
2025年10月20日 18:25

从0-1实现一个前端脚手架

一,为什么需要脚手架?

脚手架本质就是一个工具,作用是能够让使用者专注于写代码,它可以让我们只用一个命令就生成一个已经配置好的项目,而不用我们再花时间去配置和安装相关依赖,可以在很大程度上提升我们的开发效率。比如我们常用的create-vuecreate-react-app就是脚手架,很多大厂也都有自己的脚手架。

二,一个脚手架应该具备哪些功能?

我们以vue官方的脚手架create-vue为例来分析下一个脚手架应该具备哪些功能?

  1. 运行命令创建项目

    npm create vue@latest
    
  2. 用户根据自己需要选择一些配置项 在这里插入图片描述

  3. 根据选择的配置项会生成一个模版项目

在这里插入图片描述

通过分析create-vue,我们可以知道,一个脚手架如果想要创建一个项目,最少要有以下两点功能:

  1. 可以通过命令行和用户交互
  2. 根据交互的结果去生成对应的模版项目

三,脚手架实现

初始化项目:

  1. 执行如下初始化命令
mkdir diy-cli   //创建一个文件夹
cd    diy-cli   //进入这个文件夹
npm   init -y   //npm初始化

2. 在根目录下创建bin/index.js文件作为入口文件,并添加如下代码

#!/usr/bin/env node
console.log('diy-cli')
  1. 在package.json中添加bin字段
"bin": {
  "diy-cli": "/bin/index.js"
}
  1. 在根目录下执行npm link将项目链接到本地环境,就可以实现diy-cli命令全局调用

  2. 运行diy-cli并查看控制台输出

相关依赖

实现一个脚手架,通常会用到以下依赖包

  • commander:命令行处理工具
  • chalk:命令行输出美化工具
  • inquirer:命令行交互工具
  • ora:终端loading美化工具
  • git-clone:下·载项目模版工具
  • figlet:终端生成艺术字
  • fs-extra:用来操作本地目录
talk is cheap, show me the code
#!/usr/bin/env node
const inquirer = require('inquirer');
const { program } = require('commander');
const figlet = require('figlet');
const fs = require('fs-extra');
const path = require('path');
const chalk = require('chalk');
const gitClone = require('git-clone');
const ora = require('ora');

const projectList = {
  'vue': 'git@github.com:diy-cli/vue-template.git',
  'react': 'git@github.com:diy-cli/react-template.git',
  'react&ts': 'git@github.com:diy-cli/react-template-ts.git',
  'vue&ts': 'git@github.com:diy-cli/vue-template-ts.git',
}

// 修改帮助信息的首行展示
program.usage('<command> [options]')
// 版本号
program.version(`v${require('../package.json').version}`)
// 艺术字展示
program.on('--help', function () {
  console.log(
    figlet.textSync('kfc vme50', {
      font: 'Ghost',
      horizontalLayout: 'default',
      verticalLayout: 'default',
      width: 100,
      whitespaceBreak: true
    })
  )
});

// 创建项目的命令
program
  .command('create <app-name>')
  .description('创建新项目')
  .option('-f, --force', '如果创建的目录存在则强制删除')
  .action(async function (name, option) {
    const cwd = process.cwd();
    const targetPath = path.join(cwd, name);
    // 如果文件夹存在
    if (fs.existsSync(targetPath)) {
      if (option.force) {
        fs.remove(targetPath)
      } else {
        const res = await inquirer.prompt([
          {
            name: 'action',
            type: 'list',
            message: '是否覆盖已有文件夹?',
            choices: [
              {
                name: 'YES',
                value: true
              },
              {
                name: 'NO',
                value: false
              }
            ]
          }
        ])
        if (!res.action) {
          return;
        } else {
          fs.remove(targetPath)
          console.log(chalk.red('已删除之前的文件夹'))
        }
      }
    }

    const res = await inquirer.prompt([
      {
        name: 'type',
        type: 'list',
        message: '请选择使用的框架',
        choices: [
          {
            name: 'Vue',
            value: 'vue'
          },
          {
            name: 'React',
            value: 'react'
          }
        ]
      },
      {
        name: 'ts',
        type: 'list',
        message: '是否使用ts项目',
        choices: [
          {
            name: 'YES',
            value: true
          },
          {
            name: 'NO',
            value: false
          }
        ]
      }
    ])
    const rep = res.type + (res.ts ? '&ts' : '');
    // 拉取项目模板
    const spinner = ora('download project template ing...').start();
    gitClone(
      projectList[rep],
      targetPath,
      {
        checkout: 'main'
      },
      (err) => {
        if (!err) {
          fs.remove(path.resolve(targetPath, '.git'));
          spinner.succeed('finished!');
          console.log('now run:')
          console.log(chalk.green(`\n  cd ${name}`))
          console.log(chalk.green('  npm install'))
          console.log(chalk.green(`  npm run ${res.type === 'react' ? 'start' : 'dev'}\n`))
        } else {
          spinner.fail(chalk.red('Failed to load the project template. Please obtain it again!'));
        }
      }
    )
  })

program.parse(process.argv)

四,发布

  1. 注册npm账号
  2. 在本地登录并发布
# 登录刚注册的账号
npm login

Username: 用户名
Password: 密码
Email: 注册邮箱
Enter one-time password: 一次性密码  邮箱会收到邮件

# 在我们脚手架的根目录下执行发布命令
➜ npm publish

注意:

  1. 登录和发包前一定要先查看npm的源,需要修改为https://registry.npmjs.org/
  2. 在发布时包名不能重复,所以先在线上搜索下看看有没有存在的包,如果出现403错误可能是包名和线上的包重复了,修改package.json中的name即可

前端项目部署实战 nginx+docker持续集成

作者 Bacon
2025年10月20日 18:23

一,创建后端

  1. 建立后端文件夹 vue-back 并初始化
 npm init -y  
  1. 创建server.js
let http = require("http")
const users = [
    { id: 1, name: 'bian' },
    { id: 2, name: '张三' },
    { id: 3, name: '李四' },
]
let server = http.createServer(function(req, res) {
    console.log("req", req.url)
     // 为了防止中文乱码问题,需要设置响应头,
    res.setHeader('Content-Type', 'text/html; charset=utf-8')
    //解决跨域
    res.setHeader('Access-Control-Allow-Origin', '*')

    if (req.url === '/api/users') {
        res.end(JSON.stringify(users))
    } else {
        res.end("not found")
    }
})

server.listen(3001, () => {
    console.log("3001端口服务已启动", "http://localhost:3001")
})
  1. 修改packges.json中的启动项
{
    "name": "vue-back",
    "version": "1.0.0",
    "description": "",
    "main": "index.js",
    "scripts": {
        "start": "node ./server.js"
    },
    "keywords": [],
    "author": "",
    "license": "ISC"
}
  1. 创建.gitignore git忽略文件.
lib
node_modules
.idea
package-lock.json
Dockerfile
.dockerignore

二,创建前端.

// 初始化一个vue项目
vue create vue-front
//安装axios请求接口用
npm install axios 

三,创建webhook

  1. 先创建文件夹vue-webhook,进入文件夹内初始化 npm init -y

  2. 安装nodemailer npm i nodemailer -S

四,服务器

  1. 去阿里云选个ecs服务器
  2. 购买后ssh 连接ssh 用户名@IP地址
  3. 连接后开始在服务器中安装环境. npm update //升级命令
  4. 创建文件夹projects并安装git
  • mkdir /usr/projects
  • yum install git -y
  1. 生成git公钥 ssh-keygen -t rsa -b 4096 -C 'xxxxxxxx@qq.com' //邮箱地址

  2. 查看公钥 cat /root/.ssh/id_rsa.pub

  3. 进入到/usr/projects文件夹拉取前后端代码

  • git clone git@github.com:github-bian/vue-front.git
  • git clone git@github.com:github-bian/vue-back.git
  1. 服务器上安装nvm wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.34.0/install.sh | bash

  2. 执行shell脚本 . /root/.bashrc

  3. 安装最新稳定版本的 npm和node nvm install stable

  4. 安装nrm (切换npm的源) npm install nrm -g

五,安装Docker

  1. 安装yum工具包 yum install -y yum-utils device-mapper-persistent-data lvm2

  2. 添加阿里的安装源 yum-config-manager \ --add-repo \ http://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo

  3. 安装docker yum install -y docker-ce docker-ce-cli containerd.io

  4. 安装docker镜像 切换阿里云镜像 阿里云加速 mkdir -p /etc/docker tee /etc/docker/daemon.json <<-'EOF' { "registry-mirrors": ["https://fwvjnv59.mirror.aliyuncs.com"] } EOF

  5. 重载所修改的配置 sudo systemctl daemon-reload sudo systemctl restart docker

六,希望服务长久运行

pm2 服务管家 npm install pm2 -g

  1. 去vue-webhook改变启动方式

"scripts": { "start": "pm2 start ./webhook.js --watch --name webhook --watch", "stop": "pm2 stop webhook" },

  1. pm2操作命令
  • 停止 pm2 stop
  • 查看日志pm2 logs

七,git短命令

  1. 服务器配置: vi ~/gitconfig
[alias]
 a=add -A
 c=commit -m'msg'
 p=push origin master
 pu=pulll origin master

八,vue-back下创建Dockerfile

FROM node
LABEL name='vue-back'   
LABEL version='1.0'     
COPY . /app
WORKDIR /app 
RUN npm install
EXPOSE 3001
CMD npm start
  1. 创建dockerignore 忽略文件
.gitignore
node_modules 
Dockerfile
.dockerignore

九,vue-webpack下创建vue-back.sh 脚本文件

#!/bin/bash
WORK_PATH='/usr/projects/vue-back'
cd $WORK_PATH
echo '先清除老代码'
git reset --hard origin main
git clean -f
echo '拉取代码'
git pull origin main
echo '开始构建'
docker build -t vue-back:1.0 .
echo '停止并删除旧容器'
docker stop vue-back-container 
docker rm vue-back-container 
echo ' 启动新容器'
docker container run -p 3001:3001 --name vue-back-container -d vue-back:1.0
  1. vue-webpack文件内容
let http = require("http")
let crypto = require('crypto')
let SECRET = '123456'

function sign(body) {
   return `sha1=` + crypto.createHmac('sha1', SECRET).update(body).digest('hex');
}

let server = http.createServer(function(req, res) {
   console.log("触发webhook", req.method)
   if (req.method == 'POST' && req.url === '/webhook') {
       let buffers = [];
       req.on('data', function(buffer) {
           buffers.push(buffer)
       })

       req.on('end', function(buffer) {
           let body = Buffer.concat(buffers);
           let event = req.headers['x-gitHub-event'];
           let signature = req.headers['x-hub-signature'];
           if (signature !== sign(body)) {
               return res.end("not found")
           }

       })
       res.setHeader('Content-Type', 'application/json')
       res.end(JSON.stringify({ ok: true }))
   } else {
       res.end("not found")
   }
})

server.listen(4000, () => {
   console.log("webhook服务已经在4000端口服务已启动")
})

十,vue-webpack下创建vue-front.sh 脚本文件

#!/bin/bash
WORK_PATH='/usr/projects/vue-front'
cd $WORK_PATH
echo '先清除老代码'
git reset --hard origin main
git clean -f
echo '拉取新代码'
git pull origin main
echo '开始编译'
npm run build
echo '开始构建'
docker build -t vue-front:1.0 .
echo '停止并删除旧容器'
docker stop vue-front-container 
docker rm vue-front-container 
echo ' 启动新容器'
docker container run -p 80:80 --name vue-front-container -d vue-front:1.0

十一,vue-front下创建vue-front.conf 。 nginx自定义配置文件

  1. 先在服务器上安装nginx yum install nginx -y

  2. 创建 vue-front.conf 文件

  listen:80;
  server_name 106.14.181.6;
  location / {
    root /usr/share/nginx/html;
    index index.html index.htm;
    try_files $uri $uri/ /index.html;
  }
  location /api{
    proxy_pass http://106.14.181.6:3001;
  }
}
  1. 查看docker 的包/ 删除包 docker container ps //查看docker容器下所有的包 docker container rm bc9a21e87416 2d1ce2995142 -f //删除docker下指定ID的包 bc9a21e87416包ID

十二,webhook项目下创建sendMail.js

安装nodemailer插件 npm install nodemailer -g

smtp授权码:txakqvjzeiqqcbea

const nodemailer = require('nodemailer');
let transporter = nodemailer.createTransport({
  service: 'xxx2@qq.com', // 发给QQ邮箱
  port: 465, // 发邮箱的端口号
  secureConnection: true, // 使用SSL加密传输
  auth: { // 权限认证
    user: 'QQ邮箱',
    pass: '这是邮箱的授权码在邮箱后台生成的'
  }
})

let mailOptions = {
  from: '来自xxx1@qq.com', // 发邮件的账号
  to: 'xxx2@qq.com', // 收邮件的账号
  subject: 'hello', // 标题
  html: '<html><h1>world</h1></html>' // 邮寄的内容
}

transporter.sendMail(mailOptions, (err, info) => {
  if (!err) {
    console.log('邮件已经发生完成')
  }
})

一文带你彻底掌握Function Call 的使用(nodejs版)

2025年10月20日 18:15

前言

本文使用的模型是阿里云百炼的模型 直通阿里云百炼Function Calling

要求: 需要的为nodejs 做服务器 、会基础的sse(不会也没事,会cv就完了)

记得自己配一下apiKey~

简介

AI 根据任务需求 主动触发对外部工具/服务/代码逻辑的调用

Function Call 的核心逻辑不是AI自己执行函数,而是:

  • 判断是否需要调用:AI 先分析用户需求 —— 如果靠自身知识库能回答(比如 “地球半径多少”),就不调用;如果需要外部数据 / 操作(比如 “实时股票价格”),就决定调用。
  • 正确构造函数参数: AI 需要按照外部函数的格式要求,自动填充参数(比如调用天气API的时候,正确传入“城市”,“日期”,避免参数缺失)
  • 处理返回结果:外部函数返回数据后,AI需要理解结果并转化为用户能懂的自然语言(而不是直接丢出API原始响应)

image.png

预备函数

// 工具执行映射
export const toolExecutors = {
  get_current_weather: getCurrentWeather,
  // 未来可以添加更多工具
  // get_stock_price: getStockPrice,
  // search_web: searchWeb,
};
// 执行工具函数
export const executeTool = (functionName, args) => {
  const executor = toolExecutors[functionName];
  if (!executor) {
    throw new Error(`未找到工具: ${functionName}`);
  }
  return executor(args);
};
router.post("/stream", async (req, res) => {
  console.log(req.body, "req.body");

  try {
    const {
      message,
      model = "qwen-plus",
      sessionId = null,
      systemPrompt = null,
    } = req.body;

    if (!message) {
      return res.status(400).json({ error: "message 参数不能为空" });
    }

    // 设置 SSE 头
    res.setHeader("Content-Type", "text/event-stream");
    res.setHeader("Cache-Control", "no-cache");
    res.setHeader("Connection", "keep-alive");
    res.flushHeaders();

    await streamChatWithTools(message, model, {
      onToken: (delta) => {
        console.log(delta, "token delta");
        //给前端逐字发送消息
        res.write(
          `data: ${JSON.stringify({ type: "token", content: delta })}\n\n`
        );
      },
      onToolCall: (name, args, result) => {
        console.log({ name, args, result }, "tool delta");

        res.write(
          `data: ${JSON.stringify({ type: "tool", name, args, result })}\n\n`
        );
      },
      onDone: ({ sessionId: sid, stats }) => {
        console.log("onDone");
        res.write(
          `data: ${JSON.stringify({ type: "done", sessionId: sid, stats })}\n\n`
        );
        res.end();
      },
      onError: (error) => {
        res.write(
          `data: ${JSON.stringify({
            type: "error",
            message: error.message,
          })}\n\n`
        );
        res.end();
      },
      sessionId,
      systemPrompt,
    });
  } catch (error) {
    console.error("❌ 工具流式聊天错误:", error);
    if (!res.headersSent) {
      res.status(500).json({
        success: false,
        error: error.message || "服务器内部错误",
      });
    }
  }
});

初步使用function call(非流式)

定义工具

export const weatherTool = {
  type: "function",
  function: {
    name: "get_current_weather",
    description: "当你想查询指定城市的天气时非常有用。",
    parameters: {
      type: "object",
      properties: {
        location: {
          type: "string",
          description: "城市或县区,比如北京市、杭州市、余杭区等。",
        },
      },
      required: ["location"],
    },
  },
};

注意:使用的时候是一个tools数组 export const tools = [weatherTool];

创建聊天函数

export const chatWithTools = async (userMessage, model = "qwen-plus") => {
  //1.-----------------------------发起请求--------------------------------

  const messages = [
    {
      role: "user",
      content: userMessage,
    },
  ];
  //模型返回
  let response = await openai.chat.completions.create({
    model,
    messages,
    tools,//工具数组
  });
  let assistantMessage = response.choices[0].message;
  //2.-------------------------判断是否需要工具----------------------------
  //
  // 确保 AI 回复不是null
  //// 兜底:如果 content 是 null,置为空字符串
  if (!assistantMessage.content) {
    assistantMessage.content = "";
  }
  // 把模型的回复加入对话历史
  messages.push(assistantMessage);
  // 判断:模型有没有要求调用工具?
  if (!assistantMessage.tool_calls) {
    console.log(`无需调用天气查询工具,直接回复:${assistantMessage.content}`);
    return {
      needToolCall: false,
      finalResponse: assistantMessage.content,
      messages,
    };
  }
  
  //3 --------------------执行工具-------------------------------------
  // 工具调用循环
  const toolCallLogs = []; // 记录所有工具日志
  while (assistantMessage.tool_calls) {
    // 取第一个工具  因为现在只能调用一个 不支持多个
    const toolCall = assistantMessage.tool_calls[0];
    const toolCallId = toolCall.id; // "call_abc123"
    const funcName = toolCall.function.name;  // "get_current_weather"
    const funcArgs = JSON.parse(toolCall.function.arguments); // { location: "北京" }
    console.log(`🔧 正在调用工具 [${funcName}],参数:`, funcArgs);
    // 执行工具函数(下方给)  返回:"北京今天是晴天,温度25°C。"
    const toolResult = await executeTool(funcName, funcArgs);
   // 记录日志
    toolCallLogs.push({
      tool: funcName,
      args: funcArgs,
      result: toolResult,
    });

   // 构造工具返回消息(符合 OpenAI 规范)
    const toolMessage = {
      role: "tool",
      tool_call_id: toolCallId, // 必须对应上面的 id
      content: toolResult,   // 工具执行结果
    };
    console.log(`工具返回:${toolMessage.content}`);
    // 把工具结果加入对话历史
    messages.push(toolMessage);
    
    
    
    
// 4. ----------------------模型总结成自然语言-----------------------------
    response = await openai.chat.completions.create({
      model,
      messages,
      tools,
    });
    assistantMessage = response.choices[0].message;
     // 兜底
    if (!assistantMessage.content) {
      assistantMessage.content = "";
    }
    
   // 把模型的总结加入对话历史
    messages.push(assistantMessage);
  }
  console.log(`助手最终回复:${assistantMessage.content}`);
  return {
    needToolCall: true,
    finalResponse: assistantMessage.content,
    toolCallLogs,
    messages,
  };
};

代码逻辑(注释有讲解,数字对应每个步骤)

1. 发起请求

创建用户信息,发起请求

模型返回 数据格式如下:

{
  choices: [{
    message: {
      role: "assistant",
      content: null,  // 没有文本内容
      tool_calls: [    // 决定调用工具
        {
          id: "call_abc123",
          type: "function",
          function: {
            name: "get_current_weather",
            arguments: '{"location":"北京"}'  // 完整的 JSON 字符串
          }
        }
      ]
    }
  }]
}

2.是否需要工具

执行后的message状态

image.png

3. 执行工具

message状态

image.png

4.模型总结

执行后的模型返回的repsonse结果

image.png

此时message的最终形态

image.png

代码解惑

  • 为什么要确保content不是null

因为在“function call” 的场景下 模型返回的assisant 通常只有tool_calls(或function_call),规范允许省略文本内容,此时message.content 可能是null/undefined,

如果后续: 把这条消息直接messages.push(assistantOutput)再继续对话或在日志/前端 里做字符串,渲染文都可能因为content 为空而报错 或显示异常。

  • tool_call_id 的作用

模型返回的每个工具调用都有唯一 ID,工具结果必须带上对应的 ID,模型才能匹配"这是哪个工具的返回值"。

流程

image.png

接口请求

router.post("/", async (req, res) => {
  try {
    const { message, model = "qwen-plus" } = req.body;

    if (!message) {
      return res.status(400).json({
        success: false,
        error: "message 参数不能为空",
      });
    }

    console.log(`🤖 收到工具调用请求: ${message}`);

    const result = await chatWithTools(message, model);

    console.log(`✅ 工具调用完成,最终回复: ${result.finalResponse}`);

    res.json({
      success: true,
      response: result.finalResponse,
      needToolCall: result.needToolCall,
      toolCallLogs: result.toolCallLogs || [],
      model,
    });
  } catch (error) {
    console.error("❌ 工具调用错误:", error);
    res.status(500).json({
      success: false,
      error: error.message || "服务器内部错误",
    });
  }
});

流式调用(单个工具)

注意: 工具函数名称仅在第一个流式返回的对象(delta) 中出现 核心设置:stream:true

函数

export const streamChatWithTools = async (
  userMessage,
  model = "qwen-plus",
  { onToken, onToolCall, onDone, onError } = {}
) => {
 // 1. ----------------------发起请求---------------------------
  const messages = [{ role: "user", content: userMessage }];
  console.log("streamChatWithTools");

  try {
    while (true) {
      const stream = await openai.chat.completions.create({
        model,
        messages,
        tools,
        stream: true,
      });

      // 单个工具调用
      let toolCall = null;  // 存储工具调用信息
      let contentBuffer = "";  //累积文本内容
      let finishReason = null; // 记录结束原因
      
      
  //2.-------------逐块处理流式数据(目的就是拼接成完整信息)-------------------

      for await (const chunk of stream) {
        const choice = chunk.choices?.[0];
        if (!choice) continue;

        // 文本增量
        const delta = choice.delta?.content || "";
        console.log(choice.delta, "choice.delta");

        if (delta) {
          contentBuffer += delta;
          onToken?.(delta);
        }

        // 工具调用增量(单个工具)
        const deltaToolCalls = choice.delta?.tool_calls || [];
        for (const tc of deltaToolCalls) {
          // 🔴 第一次出现时初始化
          if (!toolCall) {
            toolCall = {
              id: tc.id || "",
              type: "function",
              function: { name: "", arguments: "" },
            };
          }

          // 🔴 累积拼接
          if (tc.id) toolCall.id = tc.id;
          if (tc.function?.name) toolCall.function.name += tc.function.name;
          if (tc.function?.arguments) toolCall.function.arguments += tc.function.arguments;
        }

        // 结束原因
        if (choice.finish_reason) {
          finishReason = choice.finish_reason;
        }
      }

      //3.--------------- 构造标准assistant 消息 ---------------------
      const assistantMessage = {
        role: "assistant",
        content: contentBuffer || null,
      };
      if (toolCall && toolCall.id) {
        assistantMessage.tool_calls = [toolCall]; // 🔴 单个工具也要用数组
      }
      messages.push(assistantMessage);

      // 没有工具调用,流程结束
      if (!toolCall || !toolCall.id) {
        break;
      }

      // 执行单个工具
      const funcName = toolCall.function.name;
      const funcArgs = (() => {
        try {
          return JSON.parse(toolCall.function.arguments || "{}");
        } catch {
          return {};
        }
      })();

      const result = await executeTool(funcName, funcArgs);
      const resultStr = typeof result === "string" ? result : JSON.stringify(result);

      onToolCall?.(funcName, funcArgs, resultStr);

      // 🔴 添加工具消息
      messages.push({
        role: "tool",
        tool_call_id: toolCall.id,
        content: resultStr,
      });

      // 如果模型明确结束,不再继续
      if (finishReason && finishReason !== "tool_calls") {
        break;
      }
    }

    onDone?.();
  } catch (error) {
    onError?.(error);
  }
};

代码逻辑

建议结合完整代码看 截个图放旁边最佳

处理流式数据

单工具调用时,无论返回了多少个chunk -->index 的值始终为0

chunk1(告诉你函数名,没返回任何文本信息 delta为空):

{
  choices: [{
    delta: {
      role: "assistant",
      tool_calls: [{
        index: 0,
        id: "call_abc123",
        type: "function",
        function: { name: "get_current_weather", arguments: "" }
      }]
    },
    finish_reason: null
  }]
}

这个chunk可以知道deltaToolCalls

然后因为一开始toolCall是个null 所以要进行初始化 然后拼接

chunk2:

{
  choices: [{
    delta: {
      tool_calls: [{
        index: 0,
        function: { arguments: "{\"location\":" }
      }]
    }
  }]
}

chunk3:

{
  choices: [{
    delta: {
      tool_calls: [{
        index: 0,
        function: { arguments: "\"北京\"}" }
      }]
    },
    finish_reason: "tool_calls"
  }]
}

拼接后 :

toolCall = {
  id: "call_abc123",
  type: "function",
  function: {
    name: "get_current_weather",
    arguments: "{\"location\":\"北京\"}"  // 完整的 JSON 字符串
  }
}

3.构造标准assistant信息

!!! toolCall 是有固定格式的

image.png

此时的message :

messages = [
  { role: "user", content: "北京天气怎么样?" },
  { 
    role: "assistant",
    content: null,
    tool_calls: [{
      id: "call_abc123",
      type: "function",
      function: {
        name: "get_current_weather",
        arguments: "{\"location\":\"北京\"}"
      }
    }]
  }
]

将工具信息添加到messages

此时的messages:

 [
      { role: "user", content: "北京天气怎么样?" },
      { 
        role: "assistant",
        content: null,
        tool_calls: [{ id: "call_abc123", ... }]
      },
      {
        role: "tool",
        tool_call_id: "call_abc123",
        content: "{\"temperature\":15,\"weather\":\"晴天\"}"
      }
]

进入二次循环

再次循环调用AI 但这次messages包含了工具结果 AI看到工具结果后开始生成自然语言服务

这次delta有数据了 就开始调用onToken每次触发 前端可以逐字显示 最后的contentBuffer就是一个完整句子:"北京今天天气晴朗,温度15度。"




最终的messages:

messages = [
  { role: "user", content: "北京天气怎么样?" },
  { role: "assistant", content: null, tool_calls: [...] },
  { role: "tool", tool_call_id: "call_abc123", content: "{...}" },
  { role: "assistant", content: "北京今天天气晴朗,温度15度。" }
]

循环结束 触发onDone 对话结束

流式调用(并行工具)

核心参数 parallel_tool_calls:true

并行工具实际上是为了支持询问多次

以北京上海天气为例

单个工具只会接收北京的天气 因为目前只能执行天气函数 location 是北京 并行工具就可以调用多次

函数

/**
 * 流式工具调用(支持多工具并行)
 * @param {string} userMessage - 用户消息
 * @param {string} model - 模型名称
 * @param {function} onToken - 文本增量回调 (delta: string) => void
 * @param {function} onToolCall - 工具调用回调 (toolName, args, result) => void
 * @param {function} onDone - 完成回调 () => void
 * @param {function} onError - 错误回调 (error) => void
 */
export const streamChatWithTools = async (
  userMessage,
  model = "qwen-plus",
  { onToken, onToolCall, onDone, onError } = {}
) => {
  const messages = [{ role: "user", content: userMessage }];
  console.log("streamChatWithTools");

  try {
    // 可能发生多轮:模型产生 tool_calls -> 执行工具 -> 继续流式总结
    while (true) {
      const stream = await openai.chat.completions.create({
        model,
        messages,
        tools,
        tool_choice: "auto",
        parallel_tool_calls: true, // 支持并行工具调用
        stream: true,
      });

      // 聚合流式增量:按 index 累积 tool_calls
      const toolCallsMap = new Map(); // 用来拼接工具调用的增量 index -> { id, type, function: { name, arguments } }
      let contentBuffer = ""; //用来拼接文本的增量
      let finishReason = null; // 用来记录结束原因

      for await (const chunk of stream) {
        // 工具函数名称:仅在第一个流式返回的对象(delta)中出现。
        const choice = chunk.choices?.[0];
        if (!choice) continue;
        // 文本增量
        const delta = choice.delta?.content || "";
        console.log(choice.delta, "choice.delta");

        if (delta) {
          contentBuffer += delta;
          onToken?.(delta);
        }

        // 工具调用增量(OpenAI 风格:按 index 累积)
        const deltaToolCalls = choice.delta?.tool_calls || [];
        for (const tc of deltaToolCalls) {
          const idx = tc.index ?? 0;

          //   先创建后赋值
            // 如果是这个工具的第一次出现,初始化空对象
          if (!toolCallsMap.has(idx)) {
            toolCallsMap.set(idx, {
              id: tc.id || "",
              type: "function",
              function: { name: "", arguments: "" },
            });
          }
           // 获取当前工具的累积对象
          const current = toolCallsMap.get(idx);
             // 拼接 ID(通常只在第一个 chunk 有)
          if (tc.id) current.id = tc.id;
           // 拼接函数名(可能分多次推送:"get" + "_current" + "_weather")
          if (tc.function?.name) current.function.name += tc.function.name;
             // 拼接参数(JSON 字符串分多次推送:'{"loc' + 'ation":"北京"}')
          if (tc.function?.arguments)
            current.function.arguments += tc.function.arguments;
        }

        // 结束原因
        if (choice.finish_reason) {
          finishReason = choice.finish_reason;
        }
      }

      // 本轮流式结束,若有内容则加入消息
      const assistantMessage = {
        role: "assistant",
        content: contentBuffer || null,
      };
      const toolCalls = Array.from(toolCallsMap.values()).filter((tc) => tc.id);
      if (toolCalls.length > 0) {
        assistantMessage.tool_calls = toolCalls;
      }
      messages.push(assistantMessage);

      // 没有工具调用,流程结束
      if (toolCalls.length === 0) {
        break;
      }

      // 并行执行所有工具
      const toolMessages = await Promise.all(
        toolCalls.map(async (tc) => {
          const funcName = tc.function.name;
          const funcArgs = (() => {
            try {
              return JSON.parse(tc.function.arguments || "{}");
            } catch {
              return {};
            }
          })();

          const result = await executeTool(funcName, funcArgs);
          const resultStr =
            typeof result === "string" ? result : JSON.stringify(result);

          // 通知前端工具调用结果
          onToolCall?.(funcName, funcArgs, resultStr);

          return {
            role: "tool",
            tool_call_id: tc.id,
            content: resultStr,
          };
        })
      );

      // 批量加入工具消息
      messages.push(...toolMessages);

      // 如果模型明确 finish_reason !== 'tool_calls',不再继续
      if (finishReason && finishReason !== "tool_calls") {
        break;
      }
    }

    onDone?.();
  } catch (error) {
    onError?.(error);
  }
};

这个我就不多说了 中间部分的实际上核心还是进行拼接 用map 数组都可以

然后使用Promise.all并行调用 拿到所有结果再给AI处理

官方的单(多)个工具通用的方法:

const toolCalls = {};
for await (const responseChunk of stream) {
  const deltaToolCalls = responseChunk.choices[0]?.delta?.tool_calls;
  if (deltaToolCalls) {
    for (const toolCallChunk of deltaToolCalls) {
      const index = toolCallChunk.index;
      if (!toolCalls[index]) {
        toolCalls[index] = { ...toolCallChunk };
        if (!toolCalls[index].function) {
            toolCalls[index].function = { name: '', arguments: '' };
        }
      } 
      else if (toolCallChunk.function?.arguments) {
        toolCalls[index].function.arguments += toolCallChunk.function.arguments;
      }
    }
  }
}

前端规范工具之husky与lint-staged

作者 东东233
2025年10月20日 18:13

团队日常开发中为了统一编码规范,我们经常会用到eslint工具,统一配置校验规则后执行eslint就可以对代码就行检查,但是这样会有代码一些问题:

  • 每次检查都对全量代码进行检查,浪费时间
  • 每次需要手动执行命令、依赖于开发人员的主观能动性,可能大多数时候会跳过检查直接提交

husky和lint-staged介绍的两个工具就是解决这两个问题的

husky

Git 本身是可以配置很多hook的、可以打开项目下的 .git/hooks 看到很多文件,比如:

  • pre-commit:在提交前运行,用于检查代码格式、运行测试等。
  • prepare-commit-msg:在生成默认提交信息后、编辑器启动前运行,可用于动态修改提交信息。
  • commit-msg:在提交信息编辑完成后运行,用于验证提交信息格式。
  • post-commit:提交完成后运行,常用于通知或记录提交信息。
  • pre-push:在推送代码前运行,可用于验证推送内容。 所有hook参考官网但是这些文件保存在本地,更改之后只有当前电脑生效、无法同步到同事之间,这个时候husky就登场了

介绍husky

husky 参考官网介绍 Husky 增强了你的提交和更多功能 🐶 woof!在提交或推送时自动检查你的提交消息、代码并运行测试。而且它可以植入代码中,使你配置的hook保存,更多功能可以参考这里

引入husky

首先需要安装husky,用npm、yarn、pnpm都可以、这里使用yarn

yarn add husky -D

然后初始化

npx husky init

运行完之后我们可以看到项目中多了个.husky文件夹,文件夹内部有一个pre-commit和 _

  • _ 下面是husky运行所需脚本,该文件不会提交到git仓库,每次运行husky命令自动生成
  • pre-commit文件即我们要执行的hook

我们将pre-commit文件原内容删除改为

npm run lint

并在package.json中配置lint命令

"lint": "eslint",

这时候我们更改一个文件,然后commit、可以看到eslint 命令正常执行了,只有eslint不报错才能正常提交

2f6eeb13cd5cf7af4ca33e37dc73410b.png

lint-staged

前面我们已经配置好了在commit时候自动执行eslint检查代码,解决了手动执行eslint的问题,lint-staged就是解决另一个每次提交全量校验的问题

介绍lint-staged

lint-staged 只针对暂存的 git 文件运行格式化器和代码检查器等任务,即只对你git add后的文件运行命令,更多内容

引入lint-staged

一样的我们需要安装

yarn add lint-staged -D

然后我们在package.json 中增加

"lint-staged": {
  "*.{js,jsx,ts,tsx,vue,md,html,css,json}": [
    "eslint --fix"
  ]
}

这时候我们只要执行npx lint-staged就可以看到eslint朝着我们期望的方式执行了,不用npx执行的话我们也可以把这个命令放到package.json中

"lint-staged": "lint-staged"

然后我们只需把pre-commit文件中的内容换成

npm run lint-staged

这样我们每次提交有js、css等文件时候就会对该文件执行eslint检查了,最后我们需要将.husky中的pre-commit文件上传

团队共享

做到这一步如果换台电脑clone仓库安装后提交会发现其并没有生效,因为我们只提交了pre-commit,.husky/_下面的文件并没有提交、这个文件会在每次运行husky命令的时候生成,我们只需要将他加入npm scripts命令中即可

  "scripts": {
    ...
    "prepare": "husky"
  },

这样每次我们在安装完之后就会自动生成husky/_下的内容

结合prettier使用

prettier和eslint 在我们项目中一个用于风格检查、一个用于错误检查,eslint错误检查这部分的错误往往需要手工更改、但是prettier风格检查这部分往往可以自动调整,比如间距、空格这类的 我们可以在eslint前运行prettier --write将代码格式化之后再交给eslint就可以做到

"lint-staged": {
  "*.{js,jsx,ts,tsx,vue,md,html,css,json}": [
    "prettier --write",
    "eslint --fix"
  ]
}

最后

本文介绍了将通过husky和lint-staged的集成、使得我们自动化代码检查,如果开发中确定是在不要lint的部分我们可以再commit命令后加-n参数跳过检查

手写事件总线、事件总线可能带来的内存泄露问题

作者 jump680
2025年10月20日 18:06

事件总线

class EventBus {
  constructor() {
    // 使用一个 Map 来存储事件,键是事件名,值是回调函数数组
    // 使用 Map 比普通对象更健壮,因为键可以是任何类型
    this.events = new Map();
  }

  /**
   * 订阅事件
   * @param {string} eventName 事件名称
   * @param {function} callback 回调函数
   */
  on(eventName, callback) {
    if (!this.events.has(eventName)) {
      this.events.set(eventName, []);
    }
    this.events.get(eventName).push(callback);
  }

  /**
   * 发布事件
   * @param {string} eventName 事件名称
   * @param  {...any} args 传递给回调的参数
   */
  emit(eventName, ...args) {
    if (!this.events.has(eventName)) {
      // 如果没有订阅者,可以选择静默失败或打印警告
      // console.warn(`No listeners for event: ${eventName}`);
      return;
    }
    
    // 创建一个回调数组的副本,防止在回调中修改原始数组导致问题
    const callbacks = [...this.events.get(eventName)];
    callbacks.forEach(callback => {
      try {
        callback(...args);
      } catch (e) {
        console.error(`Error in event handler for ${eventName}:`, e);
      }
    });
  }

  /**
   * 取消订阅事件
   * @param {string} eventName 事件名称
   * @param {function} callback 要移除的回调函数
   */
  off(eventName, callback) {
    if (!this.events.has(eventName)) {
      return;
    }

    const callbacks = this.events.get(eventName);
    const updatedCallbacks = callbacks.filter(cb => cb !== callback);
    
    // 如果移除后还有回调,则更新数组;否则直接删除该事件
    if (updatedCallbacks.length > 0) {
      this.events.set(eventName, updatedCallbacks);
    } else {
      this.events.delete(eventName);
    }
  }

  /**
   * 订阅一个只执行一次的事件
   * @param {string} eventName 事件名称
   * @param {function} callback 回调函数
   */
  once(eventName, callback) {
    const wrapper = (...args) => {
      // 执行原始回调
      callback(...args);
      // 执行后立即移除自身
      this.off(eventName, wrapper);
    };
    // 订阅这个包装后的函数
    this.on(eventName, wrapper);
  }
}

事件总线带来的问题

  1. 内存泄漏:在 Vue、React 等框架中,如果一个组件订阅了事件总线,但在组件销毁时没有通过 off 方法取消订阅,那么这个组件的实例和它所引用的回调函数将无法被垃圾回收,从而导致内存泄漏。务必在组件的销毁生命周期钩子(如 Vue 的 beforeUnmount 或 React 的 useEffect 的清理函数)中调用 off

事件总线可能带来内存泄露详细分析

2. 事件总线场景下的引用链分析

现在,我们把这个原理应用到“组件订阅事件总线”的场景中。

参与者:

  • 事件总线 (bus) :通常是一个全局单例。这意味着它在应用的整个生命周期中都存在,是 GC 的一个“根”对象,或者很容易从根访问到。它永远不会被回收
  • 组件实例 (MyComponent) :由框架(Vue/React)创建的对象,包含了它的 state, props, methods 等。当组件被卸载时,框架会移除对它的引用,我们期望它能被 GC 回收。
  • 回调函数 (handler) :通常是组件的一个方法或一个在组件内部定义的函数。

当组件订阅事件时,发生了什么?

codeJavaScript

// 在 MyComponent 内部
mounted() { // 或者 useEffect
  this.handler = (data) => {
    // 使用 this 来访问组件的数据
    this.someData = data; 
    this.someMethod();
  };
  bus.on('some-event', this.handler);
}

这里建立了一个关键的、双向关联的引用链:

  1. 事件总线 -> 回调函数
    bus.on('some-event', this.handler) 这行代码执行后,全局的 bus 对象的内部 events 映射中,'some-event' 这个键对应的值(一个数组)现在包含了对 this.handler 这个函数的引用。

    • 链条:window -> bus -> bus.events['some-event'][] -> handler
  2. 回调函数 -> 组件实例
    这个回调函数 handler 是在 MyComponent 的上下文中定义的(无论是箭头函数还是普通函数 function() {}.bind(this))。为了能在执行时访问 this.someData 和 this.someMethod(),它通过一个叫做**闭包(Closure)**的机制,隐式地持有了对 MyComponent 实例(this)的引用。

    • 链条:handler -> this (即 MyComponent 实例)

完整的引用链就形成了:

window (根) → bus (全局单例) → events 数组 → handler (回调函数) → MyComponent (组件实例)

3. 组件卸载与内存泄漏的发生

现在,用户导航到其他页面,MyComponent 需要被销毁/卸载。

  1. 框架的操作:Vue 或 React 会执行卸载流程,将组件从 DOM 中移除,并断开框架自身对该组件实例的引用。

  2. GC 的检查:垃圾回收器开始工作。它从 window 等根开始扫描。

  3. 发现问题:GC 沿着上面的引用链进行遍历:

    • window 是根,可达。
    • bus 是全局的,可达。
    • bus.events 数组是 bus 的一部分,可达。
    • handler 函数被 bus.events 数组引用,因此它也是可达的
    • MyComponent 实例被 handler 函数(通过闭包)引用,因此它也是可达的!

结论:  尽管框架已经“抛弃”了 MyComponent 实例,但由于全局的事件总线 bus 还牢牢地抓着指向它的回调函数,而这个回调函数又抓着组件实例本身,导致 GC 认为这个组件实例仍然是“有用的”,不能回收。

这就是内存泄漏。  组件实例及其内部所有的数据(state, props, methods)都将永久地保留在内存中,即使它在界面上早已消失。如果用户反复进入和离开这个组件所在的页面,内存中就会堆积大量死掉的组件实例,最终可能导致应用性能下降甚至崩溃。

4. 解决方案:在销毁时 off

解决方案就是在组件销毁前,手动断开这个引用链

codeJavaScript

// 在 MyComponent 内部
beforeUnmount() { // 或者 useEffect 的清理函数
  // 使用当初注册时完全相同的函数引用来取消订阅
  bus.off('some-event', this.handler);
}

当 bus.off() 执行时:

  1. 事件总线 bus 会在其内部的 events 数组中找到并移除对 handler 函数的引用。
  2. 引用链 bus -> handler 被切断

现在,handler 函数不再被全局的 bus 引用了。如果没有其他地方引用它,handler 自身就变得不可达了。

于是,MyComponent 实例也不再通过 handler 被间接引用。一旦框架也释放了对它的引用,MyComponent 实例就变得完全不可达。

在下一次 GC 运行时,它会发现 MyComponent 实例和它的 handler 函数都是“孤立”的,于是就会安全地将它们占用的内存回收。问题解决!

安全漏洞修复,组合图副值轴支持同环比设置,DataEase开源BI工具v2.10.14 LTS版本发布

2025年10月20日 18:01

2025年10月17日,人人可用的开源BI工具DataEase正式发布v2.10.14 LTS版本。

这一版本修复了四个安全漏洞,并且新增了诸多功能,具体包括:图表方面,组合图副值轴支持同环比设置,指标卡支持跳转设置,支持携带查询组件的值;查询组件方面,筛选时间时支持通过控件的形式选择日期,文本下拉组件在选项值设置为多选时,也可以设置首选默认值,时间范围在年月格式下增加“年初至本月”的范围设置项,查询条件“选项值数量”的默认设置增加了提示信息;仪表板方面,添加了组件内边距模式选择功能;数据集方面,筛选时间时,支持通过控件的形式选择日期;应用方面,应用导入的时候可以选择已有的数据集创建仪表板。

X-Pack增强包的功能变动包括:新增支持SAML2认证。

安全漏洞修复

■ fix(漏洞):修复H2 RCE漏洞(CVE-2025-62420);

■ fix(漏洞):修复JDBC绕过漏洞(CVE-2025-62419);

■ fix(漏洞):修复XSS漏洞(CVE-2025-62421);

■ fix(漏洞):修复数据集存在的SQL注入漏洞(CVE-2025-62422);

感谢腾讯悟空代码安全团队、社区用户zoiltin、wude1988、httpwwwcom发现并向DataEase开源社区反馈上述漏洞。

新增功能

■ feature(图表):组合图副值轴支持同环比设置;

■ feature(图表):指标卡支持跳转设置,支持携带查询组件的值;

■ feature(查询组件):筛选时间时支持通过控件的形式选择日期;

■ feature(查询组件):文本下拉组件在选项值设置为多选时,也可以设置首选默认值(#17088);

■ feature(查询组件):时间范围在年月格式下增加“年初至本月”的范围设置项(#17101);

■ feature(查询组件):查询条件“选项值数量”的默认设置增加了提示信息(#17091);

■ feature(仪表板):添加组件内边距模式选择功能;

■ feature(数据集):筛选时间时,支持通过控件的形式选择日期;

■ feature(应用):应用导入的时候可以选择已有的数据集创建仪表板;

■ feature(X-Pack):新增支持SAML2认证。

功能优化

■ refactor(图表):汇总表支持指标联动;

■ refactor(查询组件):查询组件去除聚合字段;

■ refactor(数据大屏):修改大屏最小尺寸(#17097);

■ refactor(数据大屏):优化大屏联动图标的显示方式;

■ refactor(仪表板、数据大屏):设置空值不参与跳转;

■ refactor(仪表板、数据大屏):优化静态图片上传功能,防止伪装文件上传至服务器;

■ refactor(数据源):添加数据源校验失败日志;

■ refactor(数据源):调整API数据源的查询超时时间;

■ refactor(定时报告):优化定时报告中查询组件的默认值设置;

■ refactor(X-Pack):扩大OIDC中的Scope字段长度;

■ refactor:优化前端缓存清除机制。

Bug修复

■ fix(图表):去除地图地名映射中名称为空的选项;

■ fix(图表):修复明细表在隐藏某一列后,导出数据出现错乱的问题(#16785);

■ fix(查询组件):修复时间范围的动态时间自定义设置中,会出现默认值超出日期筛选范围的问题;

■ fix(查询组件):修复移动端查询组件异常的问题(#17074);

■ fix(仪表板):修复开启首选项后禁用默认值,但规则中依然显示默认值的问题;

■ fix(仪表板、数据大屏):修复设置多组级联,搭配外部参数使用传入多参数,只有一组参数生效的问题(#17035);

■ fix(仪表板、数据大屏):修复跳转关联多个字段的情况下,部分字段存在空值时跳转失败的问题;

■ fix(仪表板、数据大屏):修复部分联动报错的问题;

■ fix(同步管理):优化任务的启动/停止逻辑;

■ fix(数据集):修复无法在导出条件中删除条件的问题;

■ fix(数据源):修复API数据源提取字段时,字段名称显示不完整的问题;

■ fix(数据源):修复对接SQLBot列权限全字段禁用无效的问题;

■ fix(数据源):修复H2数据源报错的问题;

■ fix(数据源):修复数据源国际化中出现的问题;

■ fix(X-Pack):修复“权限配置”-“按用户配置资源”页面,在数据量大的场景下页面卡死的问题;

■ fix:修复安装时磁盘大小包含小数导致安装脚本报错的问题。

在 Vue 3.5 中优雅地集成 wangEditor,并定制“AI 工具”下拉菜单(总结/润色/翻译)

2025年10月20日 17:42

在这里插入图片描述

在内容创作场景中,富文本编辑器与 AI 能力的结合高效且轻量化的组合。这篇文章以 wangEditorVue 3 中的集成为基础,分享一套低耦合、高可扩展性的方案,核心目标是:如何优雅地新增一个包含“AI 总结/润色/翻译”功能的自定义下拉菜单。

本文实现思路清晰,不仅关注代码的实现,更侧重于架构的解耦与设计模式的应用。

一、架构设计:解耦与单向数据流

高质量的代码意味着清晰的边界。为了避免将业务逻辑直接“污染”到 UI 组件(即 wangEditor 菜单)中,我们采用了经典的 事件总线(Event Bus) 模式进行分层解耦:

  1. 视图层 (NoteEditor.vue):负责编辑器的实例化、生命周期管理以及对最终 AI 服务的调用。
  2. 工具层 (definedMenu.js):仅负责定义 UI 菜单的外观和点击事件的派发。
  3. 核心交互:菜单被点击后,只派发一个统一的 askAiClick 事件,不关心具体业务逻辑。视图层监听该事件,随后执行业务调用(如 AI 服务)。

💡 幂等性注册的必要性: 在 Vue 3 或 Vite 等现代构建工具的 HMR (热模块替换) 环境下,模块代码可能会被多次执行。为防止自定义菜单被重复注册导致异常,我们引入了全局标记,确保注册过程只发生一次。

二、视图层 (NoteEditor.vue):集成与事件响应

NoteEditor.vue 是编辑器的主战场。这里的关键在于将自定义菜单键 myselectAiBar 优雅地插入到工具栏配置中,并管理好 AI 工具的生命周期。

1. 注入自定义菜单键与初始化 AI 工具

在编辑器初始化阶段,我们需要通过 insertKeys 将我们的自定义菜单键插入到工具栏的指定位置(这里是第一个,index: 0)。同时,在 handleCreated 钩子中,我们将编辑器实例传递给 aiToolManager 进行初始化。

// 工具栏配置:插入 AI 工具菜单
const toolbarConfig = {
    excludeKeys: ['group-video'], // 排除默认菜单
    insertKeys: {
        index: 0, // 插入到最前面
        keys: ['myselectAiBar'] // 自定义菜单键
    }
}

// 编辑器创建完成
const handleCreated = (editor) => {
    editorRef.value = editor // 记录实例
    // 初始化 AI 工具管理器(重要)
    aiToolManager.init(editor)
}

2. 监听事件与“即时反馈”占位

这是实现用户体验的关键一步。当用户点击 AI 菜单时,到 AI 服务返回结果之间存在延迟。为了提供“即时反馈”,我们先插入一个**“处理中…”的占位文本**,让用户感知到操作已生效。

// 监听 AI 菜单点击事件,并插入占位提示
const handleAskAiClick = (e) => {
    const detail = e?.detail || {}
    const action = detail.value || '' // summary | polish | translate
    const editor = editorRef.value
    if (!editor) return

    const actionLabelMap = { summary: '总结', polish: '润色', translate: '翻译' }
    const label = actionLabelMap[action] || '处理'
    
    // 立即插入反馈文本
    editor.insertText(`【AI${label}处理中…】`)
    
    // 💡 随后的 AI 服务调用逻辑将在下方“最小落点”部分详细说明
}

onMounted(() => {
    // 全局监听事件
    document.addEventListener('askAiClick', handleAskAiClick)
})

3. 生命周期清理:避免内存泄漏

在组件卸载时,必须及时清理编辑器实例、移除事件监听器以及销毁 AIToolManager,这是保证应用健壮性的基本要求。

// 组件销毁前
onBeforeUnmount(() => {
    // 移除全局事件监听
    document.removeEventListener('askAiClick', handleAskAiClick)
    
    const editor = editorRef.value
    if (editor && !editor.destroyed) {
        editor.destroy()
    }

    // 销毁 AI 工具管理器
    aiToolManager.destroy()
})

三、工具层 (definedMenu.js):定义与事件派发

definedMenu.js 的职责是纯粹的:定义 UI 菜单结构,并在点击时触发一个不带业务逻辑的事件。

1. 构造自定义菜单类 MyselectAiBar

我们继承 wangEditor 的菜单规范,实现 getPanelContentElem 方法来构建自定义下拉面板的 DOM 结构。

class MyselectAiBar {
    constructor() {
        this.title = 'AI 工具'
        this.tag = 'button'
        this.showDropPanel = true // 关键:显示下拉面板
    }
    // ... 其他 required 方法(略)
    
    getPanelContentElem() {
        const ul = document.createElement('ul')
        ul.className = 'w-e-panel-my-list'
        const items = [
            { label: 'AI 总结', value: 'summary' },
            { label: 'AI 润色', value: 'polish' },
            { label: 'AI 翻译', value: 'translate' },
        ]
        
        // 构建 UI 列表并绑定事件(下一段重点说明)
        // ...
        return ul
    }
}

2. 事件派发:解耦的核心

在列表项的点击事件中,我们不调用任何业务 API,仅仅是构造一个携带 value(即具体操作类型)的 CustomEvent,并派发到全局 document 上。

// 在 getPanelContentElem 内部
items.forEach((item) => {
    const li = document.createElement('li')
    li.textContent = item.label
    
    li.addEventListener('click', () => {
        // 核心:仅派发事件,不执行业务逻辑
        const event = new CustomEvent('askAiClick', {
            detail: {
                value: item.value, // summary | polish | translate
                type: 'toolbar',
            },
        })
        document.dispatchEvent(event)
    })
    ul.appendChild(li)
})

3. 幂等性注册与管理器封装

为了应对 HMR 和提供更友好的 API,我们将注册过程封装在 registerMenusOnce 函数中,并通过 AIToolManager 类来集中管理注册和编辑器实例的引用。

// 注册模块(只执行一次)
function registerMenusOnce() {
    // 利用全局标记避免 Vite/Webpack HMR 导致的重复注册
    if (globalThis.__aiMenusRegistered) return
    const module = {
        menus: [/* myselectAiConf */],
    }
    Boot.registerModule(module)
    globalThis.__aiMenusRegistered = true
}

export class AIToolManager {
    init(editor) {
        registerMenusOnce() // 确保注册
        this.editor = editor
    }
    // ... 
}

四、UI 细节优化:样式与可发现性

为了提升用户体验,我们对 AI 菜单按钮和下拉面板进行了克制且高效的样式定制。目标是:高亮、可发现、不突兀。

  • 使用 background-color: #FFE0B2(柔和的橙色系)对 AI 按钮进行高亮,以降低用户的发现成本。
  • 通过 :deep 选择器为下拉面板添加圆角、阴影和 Hover 效果,使其看起来更现代、更有质感。
/* 高亮 AI 工具按钮 */
:deep(button[data-menu-key="myselectAiBar"]) {
    background-color: #FFE0B2 !important; /* 柔和背景 */
    /* ... 更多样式,如圆角、边框、字体等 */
}

/* 下拉面板美化 */
:deep(.w-e-panel),
:deep(.w-e-drop-panel) {
    border-radius: 8px !important;
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08) !important; /* 现代阴影 */
    /* ... */
}

五、最小落点:对接真实 AI 服务(可选扩展)

至此,我们的前端架构已完全解耦。最后一步是实现 AI 服务的真实调用。这发生在 NoteEditor.vue 内部,在接收到 askAiClick 事件后:

  1. 获取文本:优先获取用户选中的文本,如果未选中,则获取全文。
  2. 调用服务:根据 action 调用后端 AI 服务。
  3. 结果回写:将 AI 返回的结果插入到编辑器中(并替换掉之前插入的“处理中”占位文本)。
// 增强后的 handleAskAiClick 内部逻辑(示意)
const editor = editorRef.value
const text = editor?.getSelectionText?.() || editor?.getText() || '' // 获取选区或全文
const action = detail.value 
const label = { summary: '总结', polish: '润色', translate: '翻译' }[action] || '处理'

// 1. 插入占位提示(已完成)
// editor.insertText(`【AI${label}处理中…】`) 

// 2. 调用你的 AI 服务 (推荐封装到独立的 service/api 层)
const data = await aiService.process({ action, text }) 

// 3. 插入结果 (可以先删除占位符,再插入结果)
editor.insertText(`\n【AI${label}完成】\n${data}\n`)

多模态Ai项目全流程开发中,从需求分析,到Ui设计,前后端开发,部署上线,感兴趣打开链接(带项目功能演示)多模态AI项目开发中...

总结: 通过事件驱动和清晰的分层,我们不仅成功地在 Vue 3 集成了 wangEditor 并定制了 AI 功能菜单,更重要的是,我们构建了一个可维护、易扩展的架构。definedMenu.js 专注于 UI,NoteEditor.vue 专注于事件响应与业务调度——这正是现代前端工程实践所推崇的解耦之道。

🍀继分页器组件后,封装了个抽屉组件

作者 H_四叶草
2025年10月20日 17:18

Drawer 组件

效果图

动画.gif

使用

<template>
 <Button @click="openDrawer1">从右边显示抽屉</Button>
 <Button @click="openDrawer2">从左边显示抽屉</Button>
 <Button @click="openDrawer3">从上边显示抽屉</Button>
 <Button @click="openDrawer4">从下边显示抽屉</Button>

 <Drawer v-model="showDrawer1" direction="right" title="抽屉组件"></Drawer>
 <Drawer v-model="showDrawer2" direction="left" title="抽屉组件"></Drawer>
 <Drawer v-model="showDrawer3" direction="top" title="抽屉组件"></Drawer>
 <Drawer v-model="showDrawer4" direction="bottom" title="抽屉组件"></Drawer>
</template>
<script lang="ts" setup>
const showDrawer1 = ref(false);
const showDrawer2 = ref(false);
const showDrawer3 = ref(false);
const showDrawer4 = ref(false);
const openDrawer1 = () => {
  showDrawer1.value = true
}
const openDrawer2 = () => {
  showDrawer2.value = true
}
const openDrawer3 = () => {
  showDrawer3.value = true
}
const openDrawer4 = () => {
  showDrawer4.value = true
}
</script>

一、组件目录结构

|-Drawer
   |--Drawer.vue(组件)
   |--style.css(样式)
   |--types.ts(类型定义)
   |--index.ts(入口文件)

二、Drawer.vue

<template>
  <Overlay :modelValue="modelValue" :modalClass="modalClass">
    <Transition :name="transitionName">
      <div
        ref="drawerRef"
        class="vh-drawer"
        :class="[`vh-drawer--${direction}`, customClass]"
        :style="drawerStyle"
        v-if="modelValue"
      >
        <div class="vh-drawer__header" v-if="title || $slots.header">
          <slot name="header">{{ title }}</slot>
          <Icon class="vh-drawer__close" icon="xmark" v-if="showClose" @click.stop="handleClose" />
        </div>
        <div class="vh-drawer__body">
          <slot />
        </div>
        <div class="vh-drawer__footer" v-if="$slots.footer">
          <slot name="footer" />
        </div>
      </div>
    </Transition>
  </Overlay>
</template>

<script setup lang="ts">
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
import Icon from '../Icon/Icon.vue'
import Overlay from '../Overlay/Overlay.vue'
import type { DrawerProps, DrawerEmits } from './types'
import { isFunction } from 'lodash-es'

defineOptions({ name: 'vhDrawer' })

const props = withDefaults(defineProps<DrawerProps>(), {
  direction: 'right',
  size: '30%',
  title: '',
  showClose: true,
  closeOnClickModal: true,
  closeOnPressEscape: true,
  beforeClose: undefined,
  customClass: '',
  modal: true,
  modalClass: '',
  openDelay: 0,
  closeDelay: 0
})

const emits = defineEmits<DrawerEmits>()

const drawerRef = ref<HTMLElement>()
const isVisible = ref(false)

// 计算抽屉样式
const drawerStyle = computed(() => {
  const style: Record<string, string> = {}
  const isHorizontal = ['left', 'right'].includes(props.direction)

  if (isHorizontal) {
    style.width = typeof props.size === 'number' ? `${props.size}px` : props.size
  } else {
    style.height = typeof props.size === 'number' ? `${props.size}px` : props.size
  }

  return style
})

// 根据方向计算过渡动画名称
const transitionName = computed(() => {
  return `vh-drawer-${props.direction}`
})

// 关闭抽屉
const handleClose = () => {
  if (isFunction(props.beforeClose)) {
    props.beforeClose(() => {
      emits('update:modelValue', false)
    })
  } else {
    emits('update:modelValue', false)
  }
}

// 处理点击外部关闭
const handleWrapperClick = (e: MouseEvent) => {
  if (e.target === e.currentTarget && props.closeOnClickModal) {
    handleClose()
  }
}

// 处理ESC键关闭
const handleKeydown = (e: KeyboardEvent) => {
  if (e.key === 'Escape' && props.closeOnPressEscape) {
    handleClose()
  }
}

// 过渡动画钩子
const handleBeforeEnter = () => {
  isVisible.value = true
  emits('open')
}

const handleAfterEnter = () => {
  emits('opened')
}

const handleBeforeLeave = () => {
  emits('close')
}

const handleAfterLeave = () => {
  isVisible.value = false
  emits('closed')
}

// 监听显示状态
watch(
  () => props.modelValue,
  (newVal) => {
    if (newVal) {
      nextTick(() => {
        document.addEventListener('keydown', handleKeydown)
      })
    } else {
      document.removeEventListener('keydown', handleKeydown)
    }
  }
)

// 组件卸载时清理事件监听
onBeforeUnmount(() => {
  document.removeEventListener('keydown', handleKeydown)
})
</script>

types.ts

import type { PropType } from 'vue'

export type PaginationLayout = string

export interface PaginationProps {
  // 当前页码
  pageNum?: number
  // 每页显示条数
  pageSize?: number
  // 总条数
  total: number
  // 每页显示条数选择器的选项
  pageSizes?: number[]export interface DrawerProps {
    modelValue: boolean;
    direction?: 'left' | 'right' | 'top' | 'bottom';
    size?: number | string;
    title?: string;
    showClose?: boolean;
    closeOnClickModal?: boolean;
    closeOnPressEscape?: boolean;
    beforeClose?: (done: () => void) => void;
    customClass?: string;
    modal?: boolean;
    modalClass?: string;
    openDelay?: number;
    closeDelay?: number;
}

export interface DrawerEmits {
  (e: 'update:modelValue', value: boolean): void;
  (e: 'open'): void;
  (e: 'opened'): void;
  (e: 'close'): void;
  (e: 'closed'): void;
}
  // 布局配置 (total, sizes, prev, pager, next, jumper)
  layout?: PaginationLayout
  // 背景是否显示
  background?: boolean
  // 是否禁用
  disabled?: boolean
  // 是否显示小型分页
  small?: boolean
  // 页码按钮的数量,当总页数超过该值时会折叠
  pagerCount?: number
  // 上一页按钮的文本
  prevText?: string
  // 下一页按钮的文本
  nextText?: string
  // 省略时显示的内容
  ellipsisText?: string
  // 自定义跳转内容
  jumper?: boolean
  // 自定义大小选择器的内容
  sizeSelector?: boolean
  // 自定义总条数显示的内容
  totalSelector?: boolean
}

// 事件类型定义
export interface PaginationEmits {
  (e: 'update:pageNum', value: number): void
  (e: 'update:pageSize', value: number): void
  (e: 'size-change', size: number): void
  (e: 'current-change', current: number): void
  (e: 'prev-click', current: number): void
  (e: 'next-click', current: number): void
}

index.ts

import type { App } from 'vue'
import Drawer from './Drawer.vue'

Drawer.install = (app: App) => {
  app.component(Drawer.name, Drawer)
}

export default Drawer

export * from './types'

style.css

.vh-drawer__wrapper {
    position: fixed;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    z-index: 2000;
    overflow: hidden;
  }
  
  .vh-drawer {
    position: fixed;
    background-color: var(--vh-bg-color);
    transition: all var(--vh-transition-duration) ease;
    box-shadow: 0 0 30px rgba(0, 0, 0, 0.1);
    display: flex;
    flex-direction: column;
    z-index: 2001;
  
    &--left {
      top: 0;
      left: 0;
      bottom: 0;
      border-right: var(--vh-border);
    }
  
    &--right {
      top: 0;
      right: 0;
      bottom: 0;
      border-left: var(--vh-border);
    }
  
    &--top {
      top: 0;
      left: 0;
      right: 0;
      border-bottom: var(--vh-border);
    }
  
    &--bottom {
      bottom: 0;
      left: 0;
      right: 0;
      border-top: var(--vh-border);
    }
  }
  
  .vh-drawer__header {
    padding: 20px 24px;
    border-bottom: var(--vh-border);
    display: flex;
    align-items: center;
    justify-content: space-between;
    font-size: var(--vh-font-size-large);
    font-weight: var(--vh-font-weight-primary);
    color: var(--vh-text-color-primary);
  }
  
  .vh-drawer__close {
    color: var(--vh-text-color-regular);
    font-size: var(--vh-font-size-large);
    cursor: pointer;
    transition: color var(--vh-transition-duration);
  
    &:hover {
      color: var(--vh-text-color-primary);
    }
  }
  
  .vh-drawer__body {
    flex: 1;
    padding: 24px;
    overflow-y: auto;
  }
  
  .vh-drawer__footer {
    padding: 16px 24px;
    border-top: var(--vh-border);
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: 12px;
  }
  
  .vh-drawer-right-enter-from {
    transform: translateX(100%);
  }
  
  .vh-drawer-right-leave-to {
    transform: translateX(100%);
  }
  
  /* 左侧抽屉动画 */
  .vh-drawer-left-enter-from {
    transform: translateX(-100%);
  }
  
  .vh-drawer-left-leave-to {
    transform: translateX(-100%);
  }
  
  /* 顶部抽屉动画 */
  .vh-drawer-top-enter-from {
    transform: translateY(-100%);
  }
  
  .vh-drawer-top-leave-to {
    transform: translateY(-100%);
  }
  
  /* 底部抽屉动画 */
  .vh-drawer-bottom-enter-from {
    transform: translateY(100%);
  }
  
  .vh-drawer-bottom-leave-to {
    transform: translateY(100%);
  }

其他文章

# 🍀上班摸鱼,手搓了个分页器组件

# 我很好奇客户会用得懂这个组件吗

#【🍀新鲜出炉 】十个 “如何”从零搭建 Nuxt3 项目

# 图解封装多种数据结构(栈、队列、优先级队列、链表、双向链表、二叉树)

# 面试官提问:为什么表单提交不会出现跨域

# 【前端】整理了一些网络相关面试题(🍀拿走不谢🍀)

# 学完 Pinia 真香,不想用 vuex 了

# 非标题党:前端项目编程规范化配置(大厂规范)

图文引用打包时的常见情景解析

2025年10月20日 17:14

前端开发时最常见的图文打包是我们必然要遇到的,图片文字的引用无非就是相对路径的引用和绝对路径的引用。当然还有一些存储在服务上的url地址引用。这里我们只分析本地一些引用。

相对路径的引用

相对路径不包含协议(如http://)或网站根目录符号(/),完全依赖当前 HTML/CSS/JS 文件的所在位置来寻找目标资源。

  • 核心特点:路径是 “相对” 的,当前文件移动后,路径可能失效
  • 常见使用场景:引用当前项目内部、与当前文件有固定位置关系的资源,如同一文件夹下的图片、样式表。

前端开发中,../../a.png 这种相对路径引入图片在手机上可能失效,而 require 方式能正常显示,核心原因在于构建工具对路径的处理逻辑不同,尤其在打包后的资源路径解析、环境适配(如移动端)上存在差异。

一、两种引入方式的本质区别

  1. 相对路径直接引入(../../a.png

    • 属于 “静态路径”,直接写在代码(如 img srcbackground-image)中,构建工具(Webpack/Vite 等)会按 “源码路径” 解析,但打包后可能因资源路径映射错误导致引用失效。
    • 例如:开发时源码中图片路径是 ../../a.png,但打包后图片可能被输出到 dist/assets 目录,而代码中仍保留原始相对路径,导致移动端(或生产环境)找不到资源
  2. require('../../a.png')(Webpack 等工具支持)

    • 属于 “动态引入”,通过构建工具的模块系统解析路径,会自动处理图片资源:

      • 打包时将图片复制到输出目录(如 dist/assets),并生成正确的哈希文件名(如 a.hash.png)。
      • 返回处理后的绝对路径(或相对于输出目录的路径),确保代码中引用的路径与实际打包后的资源位置一致。

二、常见生产环境上相对路径失效的常见原因

  1. 路径解析错误(打包后路径不匹配)

    • 开发环境(本地)中,../../a.png 能正确找到源码中的图片,但打包后项目目录结构改变(如 index.html 可能在 dist 根目录,而图片在 dist/assets),原始相对路径在新结构中指向错误位置。
    • 移动端浏览器对路径的容错性可能更低,轻微的路径错误就会导致图片加载失败(PC 端可能因缓存或浏览器兼容机制偶然能显示)。
  2. 资源未被打包(静态路径未被识别)

    • 若图片路径写在非模块化的代码中(如直接在 HTML 或 CSS 里用相对路径),部分构建工具可能未将其识别为 “需要打包的资源”,导致图片未被复制到 dist 目录,手机访问时自然找不到文件。
    • require 方式会明确告诉构建工具 “这是一个需要处理的资源”,确保图片被正确打包。
  3. 大小写或路径符号问题

    • 手机端(尤其是 iOS)的文件系统对路径大小写敏感(如 A.png 和 a.png 视为不同文件),而开发时的系统(如 Windows)可能不敏感,导致本地正常但手机失效。
    • 相对路径中的 ../ 层级计算错误(如多写或少写一层),在复杂目录结构中更易出现,require 会通过模块解析规则自动修正层级。

三、解决方案:统一用构建工具支持的引入方式

  1. 在 JS/JSX 中用 require 或 import

    // Webpack 环境(require)
    const imgSrc = require('../../a.png'); 
    // Vite 环境(ES 模块 import,更推荐)
    import imgSrc from '../../a.png';
    
    // 在组件中使用
    <img src={imgSrc} alt="示例" />
    
  2. 在 CSS/SCSS 中用 url() + 相对路径(确保构建工具处理)

    css

    /* 确保构建工具(如 css-loader)会解析该路径并打包图片 */
    .bg {
      background-image: url('../../a.png');
    }
    
  3. 在 HTML 中避免直接写相对路径,改用动态注入若需在 HTML 中引入,可通过 JS 将 require 后的路径注入:

    <!-- index.html -->
    <img id="myImg" alt="示例" />
    
    <script>
      // 动态设置 src(确保构建时处理路径)
      document.getElementById('myImg').src = require('../../a.png');
    </script>
    

四、总结

  • 相对路径直接引入依赖 “源码目录结构”,打包后路径易失效;require/import 依赖构建工具的资源解析能力,自动适配打包后的目录,兼容性更优(尤其移动端)。
  • 开发时务必让所有资源通过构建工具处理(而非直接写静态路径),避免因环境差异(开发 / 生产、PC / 手机)导致资源加载失败。

为什么有些图片在使用相对路径后(未使用require或者import)依然能够正确显示呢?

有些相对路径能正常打开,核心原因是这些场景下的相对路径恰好匹配了手机端实际访问的资源位置,本质是路径映射未被破坏,而非相对路径本身 “适配” 手机。具体可分为以下 3 种情况:

一、生产环境打包后路径仍 “对齐”

如果项目打包时,图片资源的最终输出路径与代码中写的相对路径能对应上,手机访问时自然能加载。

  • 典型场景:未使用复杂构建配置(如未将图片输出到单独 assets 目录),打包后目录结构与源码一致。例如:源码中 src/pages/detail/index.js 用 ../../a.png 引用 src/a.png,打包后若 dist 目录完全复刻 src 结构(dist/pages/detail/index.js 对应 dist/a.png),那么手机访问 dist 下的页面时,../../a.png 仍能准确定位到 dist/a.png,不会失效。
  • 关键前提:打包工具未修改图片的相对位置(如未做资源合并、重命名或目录迁移),路径 “原样保留”。

二、生产上访问的是 “未打包的静态资源”

如果生产上不是访问打包后的生产环境,而是直接访问静态文件(如通过手机浏览器打开本地 HTML 文件、或访问未打包的静态服务器),此时相对路径的解析逻辑与 PC 一致 —— 只要文件实际位置和路径层级匹配,就能加载。

  • 例子 1:把 index.html 和 a.png 放在手机本地同一文件夹,index.html 中用 ./a.png 引用,手机打开该 HTML 时,路径直接指向同目录文件,自然能显示。
  • 例子 2:通过手机访问一个 “仅托管静态文件的服务器”(如把源码直接上传到服务器,未经过 Webpack/Vite 打包),此时 ../../a.png 对应的是服务器上的真实文件路径,只要路径没错,手机就能加载。

三、构建工具隐性处理了相对路径(生产无感知)

有些时候你写的是相对路径,但实际被构建工具自动转为了 “手机可识别的路径”,你误以为是 “相对路径直接生效”:

  • 场景 1:CSS 中的相对路径被 css-loader 处理。比如在 CSS 中写 background: url('../../a.png'),Webpack/Vite 会自动解析这个路径,打包时把图片复制到 dist/assets 目录,并将 CSS 中的路径替换为 ../assets/a.hash.png(具体路径根据配置而定)。手机访问时,实际加载的是处理后的路径,而非你写的原始相对路径 —— 只是你没感知到工具的处理过程,以为是原始相对路径生效。
  • 场景 2:框架模板中的相对路径被编译器转换。比如 Vue 单文件组件(SFC)的模板里写 <img src="../../a.png">,Vue 编译器会自动把这个相对路径转为 require('../../a.png')(Webpack 环境)或 ES 模块导入(Vite 环境),最终打包后生成正确路径。手机能加载,本质是工具帮你 “偷偷” 处理了路径,而非相对路径本身直接适配手机。

生产上能加载某些相对路径,核心不是 “页面支持这种写法”,而是:

  1. 要么打包后路径仍对齐,相对路径能找到资源;
  2. 要么访问的是未打包的静态文件,路径直接对应真实位置;
  3. 要么构建工具隐性处理了路径,只是你没察觉。

而那些失效的相对路径,本质是打包后路径被破坏(如图片被移到 assets 目录,但代码中还是原始 ../../a.png),或手机访问的环境(如生产服务器)路径映射变了 —— 这和 “手机” 无关,换成 PC 访问同样会失效,只是你可能没在 PC 上测试这种场景。

在生产上确保图片正确显示,核心是让图片路径在任何环境下都能被正确解析除了前文提到的 require/import 和相对路径优化,还可以通过绝对路径的方式进行解决

使用绝对路径(推荐生产环境)

以项目部署的根目录为基准,用绝对路径引用图片,避免相对路径的层级计算误差。

  • 适用场景:项目有明确的部署根目录(如 https://example.com/app/),或使用 public 目录(Vite/Webpack 中 public 文件夹下的资源会被原样复制到打包根目录)。

  • 示例

    1. 把图片放在 public/images/a.png(Vite/Webpack 项目),打包后会位于 dist/images/a.png

    2. 在代码中用绝对路径引用:

      <img src="/images/a.png" alt="示例"> <!-- / 代表部署根目录 -->
      

      css

      .bg { background-image: url('/images/a.png'); }
      
  • 优势:路径不依赖当前文件位置,无论页面在哪个目录层级,都能正确指向图片。

二、通过 CDN 托管图片(适合线上环境,不推荐,这样无疑是加大了服务器的开销)

将图片上传到 CDN(如阿里云 OSS、七牛云等),用 CDN 提供的绝对 URL 引用,彻底避开本地路径问题。

  • 示例

    <img src="https://cdn.example.com/images/a.png" alt="示例">
    
  • 优势

    1. 路径完全独立于项目结构,手机 / PC 访问均无差异;
    2. CDN 加速图片加载,优化移动端体验。

网上还有一种方案是通过publicPath 配置统一路径前缀,这种方式如果是项目要求,本身项目就是那样配置的,那就去用,不是的话建议大家还是用绝对路径,主打一个简单省事。

当使用public下的绝对路径时,检测也是很方便,在打包后只要看dist目录下看是否有创建的文件夹就行啦,这样基本就能确保上线不会出现什么问题。

@vue/reactivity

2025年10月20日 17:12

前言

vue 的响应式是 数据更新视图,也就是当数据变更时,vue 会自动帮我们更新页面,我们无需手动操作 dom

这么理解当然是对的,但是探究 vue 的响应式之根本就不单单是 数据更新视图,而应该是 数据更新函数

数据更新视图,无非就是 ref/reactive 后的数据变更,然后触发了 render 函数的重新执行,最后才是我们看到的 视图 更新

因此数据更新视图,这个函数仅仅是 render,我们不妨跳出 render,来到函数这一层,这样也方便我们实现

我们继续来看响应式,如何理解数据和函数之间的关联

我们看 vue 的响应式就是 数据变更了,用过这些数据的函数能够一起执行

因此这个关联体现在

  1. 函数要能够监听数据的 读取 以及 修改
  2. 以及该数据对应了哪些函数

弄清了这两个要点,我们就有实现的方向了

实现拦截

第一点我们可以给一个最小实现 demo,也就是实现监听对象

实现监听对象我们有两个方法,一个是 es5 的 Object.defineProperty,另一个是 es6 的 Proxy ,我们不妨回顾下二者的区别

在用法上,Object.defineProperty 需要拿到对象以及对应的 key,对 key 添加 get,set 属性,比如下面

1.png

a 属性在 definedProperty 后得到了 get,set 属性,这样 a 属性就获得了一个监听

而 proxy 则是对整个对象添加 handlers,这个 handlers 里面总共有 13 种拦截属性,不仅仅是 get,set

2.jpg

单单从这里就可以看出 Object.defineProperty 的局限性在于必须清楚对象的属性名,动态新增/删除无法感知,而 Proxy 则是通过代理整个对象,对所有操作进行拦截,不仅仅是get,set,Object.defineProperty 要想代理对象所有属性还得遍历挨个实现,proxy 则是天然支持,无需遍历

要说 Object.defineProperty 相较于 Proxy 的优点,也就只有 兼容可谈,不过目前基本上浏览器都兼容 proxy,可能只有 IE 不行

接下来就用 proxy 来实现一个demo,我们尽量参考 vue/reactivity 的模块来写

首先,我们期望有个 reactive 能够把数据变成响应式数据,也就是可以进行监听

import { reactive } from './reactive.js'

const obj = {
    a: 1,
    b: 2,
}

const state = reactive(obj)

function fn () {
  state.a;
}
fn()

这里 fn 执行,会读取到 state.a,那么应该会触发 get

因此在 reactive 里面实现一个 proxy 即可,proxy 的第二个参数是 handlers,考虑到延展,我们用单独一个文件 baseHandlers.js 里面存放 目前 有的 get,set

import { baseHandlers } from './baseHandlers.js'

export function reactive (target) {
    return new Proxy(target, baseHandlers)
}

baseHandlers.js

function get (target, key, value) {
    console.log('get', target, key);
    
    if (target[key]) {
        return target[key]
    } 
}

function set (target, key, value) {
    console.log('set', target, key, value);
    
    if (target[key] === value) return 
    target[key] = value
}

export const baseHandlers = {
    get,
    set, 
}

我们把 fn 改成修改值 state.a = 2,再来看

不出意外就会报错了

3.jpg

这是因为 proxy 的 set 操作返回一个 布尔,我们当然可以在结尾新增一个 return true,用 try catch 兜住错误。但是 es6 新增了一个 Reflect 对象,身上的 set 属性天然支持返回 true,因此我们 get,set 通通换成 Reflect 来做

那个 log 后续的逻辑其实就是依赖收集和派发更新,我们分别用函数 tracktrigger 代替

export function track (target, key) {
    console.log('依赖收集', target, key);
}

export function trigger (target, key) {
    console.log('依赖触发', target, key);
}

然后我们的 handlers 就用 Reflect 去 set 和 get

function get (target, key) {
    track(target, key)
    return Reflect.get(target, key)
}

function set (target, key, value) {    
    trigger(target, key)
    return Reflect.set(target, key, value)
}

至此,一个简单的响应式读取值和修改值的监听就实现了

后续的实现就是第二点,如何让一个数据能够收集到对应的使用过这些数据的函数

但是目前实现第二点还有点距离需要爬,我们先看下有些特殊情况,可能读写不会被监听上

in 的读取 --- has

我们来看一个 in 操作符

import { reactive } from './reactive.js'

const obj = {
    a: 1,
    b: 2,
}

const state = reactive(obj)

function fn () {
  if ('a' in state) {
    console.log('aaaa');
  }
}
fn()

'a' in state 这里会读取 a ,按道理应该可以触发 track 依赖收集,但是并没有

而 in 这个操作符其实本质上调用的是 hasProperty,proxy 里面拦截 hasProperty 的操作属性为 has

因此我们需要往 baseHandlers 中加一个 has

function has (target, key, value) {
    track(target, key)
    return Reflect.has(target, key, value)
}

这样一来就能拦截 'a' in state 了

for in 的读取 -- ownKeys

我们再来看个 for in 的例子

const obj = {
    a: 1,
    b: 2,
}

const state = reactive(obj)

function fn () {
  for (const key in state) {
  } 
}
fn()

这里按道理预期也是 会触发 track ,因为我把这个响应式数据的每个 key 都读取了一遍

这个 for in 其实是需要对象有 iterate 属性,而这个刚好对应着 ReflectownKeys 属性,不过这个迭代是不需要 key 的,因此这里的 key 就不用传给 track 了

function ownKeys (target) {
    track(target)
    return Reflect.ownKeys(target)
}

ownKeys 和 属性无关,因此没有 key value

操作类型与拦截类型

其实到这里你肯定会发现规律,那就是可能还会有很多操作类型,不仅仅是 get,set,has,ownKeys,还有 add,delete 等,考虑得越全面这些东西就越多,而这些属性其实刚好可以分为 操作类型 和 拦截类型,

后续在 track / trigger 中,可以根据这些类型去优化,比如 set 能够影响到的 只有 get,而 add 能够影响的就多了,有 get,has,ownKeys

我们先给出一个类型文件,里面存放 trackOpTypes 以及 triggerOpTypes

export const trackOpTypes = {
  GET: "get",
  HAS: "has",
  ITERATE: "iterate",
};

export const triggerOpTypes = {
  SET: "set",
  ADD: "add",
  DELETE: "delete",
};

刚刚聊到的 for in,其实对应的 track 类型为 iterate,后续还能发现有新的类型就再补全

receiver

我们再来看一个🌰

import { reactive } from './reactive.js'

const obj = {
    a: 1,
    b: 2,
    get c () {
      return this.a + this.b
    }
}

const state = reactive(obj)

function fn () {
  state.c;
}
fn()

当我们访问 c 属性时,c 会返回 a 和 b,按道理会触发 三次 get,对应 a,b,c

但是这里实际上只有 c

我们可以在 c 里面添加一个 log ,看看 this 是啥,结果你会发现就是 obj,这当然符合预期,但是要想 a 和 b 也能被 get 到,是不是得希望 this 是 Proxy 后的 obj

实际上 state.c 就是 get 里面的 target[key],而 [[get]] 其实调用的是 [key, receiver],默认语法上,get 第二个参数就是 this 指向的对象,这里对应的就是 obj,我们无法更改

但是这个却可以通过 Reflect 解决,Reflect 支持更改 this,我们可以在 get 中多加一个 入参 receiver,也就是 Reflect.get(target, key, receiver)

function get (target, key, receiver) {
    track(target, key)
    return Reflect.get(target, key, receiver)
}

深度监听

我们再来看一个🌰

import { reactive } from './reactive.js'

const obj = {
    a: 1,
    b: 2,
    c: {
      d: 3
    }
}

const state = reactive(obj)

function fn () {
  state.c.d;
}
fn()

这里读取 state.c.d ,但道理 get 应该监听到 c 和 d,但是实际上只有 c,其实也符合预期,因为 state.c 返回的 对象是 { d } ,而非 Proxy 后的对象,因此在 get 中我们不用着急返回 Reflect.get,我们可以先判断其返回值是否为 对象,是就用 reactive 再包裹一层

function get (target, key, receiver) {
    track(target, key)
    const res = Reflect.get(target, key, receiver)
    if (isObject(res)) {
        return reactive(res)
    } else {
        return res
    }
}

add & delete

add 以及 delete 都是派发更新,我们现在来补充下这两个函数

先看 add,看这个🌰

import { reactive } from './reactive.js'

const obj = {
    a: 1,
    b: 2,
    c: {
      d: 3
    }
}

const state = reactive(obj)

function fn () {
  state.e = 4
}
fn()

我们希望 trigger 的时候能看到 add 信息

add 其实就是 set,当 key 存在就是 set,不存在就是 add

因此add我们可以在 set 里面补充,另外,我们可以把 track 和 trigger 的 log 添加 type 信息

export function track (target, key, type) {
    console.log('依赖收集', target, key, type);
    
}

export function trigger (target, key, type) {
    console.log('依赖触发', target, key, type);
}

我们在 set 中区分出 add

function set (target, key, value, receiver) {    
    const type = target.hasOwnProperty(key) ? TriggerOpTypes.SET : TriggerOpTypes.ADD
    trigger(target, key, type)
    return Reflect.set(target, key, value, receiver)
}

其实 set 还有处可以优化,当 set 原来的值就不用 trigger 了

也就是补充 if (target[key] === value) return true

再来看 delete,我们希望下面这个例子能看到 delete 信息

const obj = {
    a: 1,
    b: 2,
    c: {
      d: 3
    }
}

const state = reactive(obj)

function fn () {
  delete state.e
}
fn()

那就得新增一个 deletePropertyhandlers

function deleteProperty (target, key) {
    if (!target.hasOwnProperty(key)) return true 
    trigger(target, key, TriggerOpTypes.DELETE)
    return Reflect.deleteProperty(target, key)
}

前面判断是因为若本身就没有这个 key,那就不用 trigger 了

数组的拦截

includes 为例

你或多或少听说过 vue 有重写数组的方法,这就是因为数组的有些修改监听不上,下面就一一来举例说明

首先看 includes 对象时的情况

import { reactive } from './reactive.js'

const obj = {}

const arr = [1, obj, 2]

const state = reactive(arr)

function fn () {
  let index = state.includes(obj)
  console.log(index)
}
fn()

state 是 Proxy(arr),它调用 includes 就会访问 includes 属性,我们可以看看会输出什么

依赖收集 (3) [1, {…}, 2] includes get
依赖收集 (3) [1, {…}, 2] length get
依赖收集 (3) [1, {…}, 2] 0 get
依赖收集 (3) [1, {…}, 2] 1 get
依赖收集 (3) [1, {…}, 2] 2 get
false

includes 内部实现必然会访问到 includes ,访问 length 其实也好理解,要我们自己手写一个 includes,不就是一个 for(let i = 0; i < arr.length; i++),然后找不到就返回 false 或者说 -1,这里很奇怪,includes 遍历了所有项都找不到 obj,最终返回了 false

我们若 includes(1) 那便输出如下

依赖收集 (3) [1, {…}, 2] includes get
依赖收集 (3) [1, {…}, 2] length get
依赖收集 (3) [1, {…}, 2] 0 get
true

这是符合预期的,为啥 obj 就找不到

其实细想下也好理解,因为state 调用 includes 时,state 可是 Proxy,之前我们已经做了深度监听,也就是说 state 里面的 obj 会被再次 reactive 一次,proxy 里面找 原生的 obj,这个 obj 并非 proxy,因此可以理解为 proxy !== raw 导致的 false

但是我们肯定不希望把 深度监听去掉,那就灵活点,当 includes 找不到时,我们再次处理下,能否让原生的 arr 去调用 includes

在 get 中我们有三个入参,分别为 原生的 target,key,以及 proxy 后的 receiver

若 key 为 includes 那就特殊处理,我们把处理过的 includes 给到 proxy

在 includes 内部,找不到时我们给一个唯一属性,让 proxy 访问这个唯一属性时,再次触发 get,此时原生 arr,也就是 target 返回出去,这样就实现了 数组特定方法调用时,避免了深度监听

具体实现如下,indexOflastIndexOf 同理

const arrayInstrumentations = {}

const RAW = Symbol('raw');

['includes', 'indexOf', 'lastIndexOf'].forEach(method => {
    const originMethod = Array.prototype[method]
    arrayInstrumentations[method] = function (...args) {
        let res = originMethod.apply(this, args)
        if (res === false || res === -1) {
            res = originMethod.apply(this[RAW], args)
        }
        return res
    }
})

function get (target, key, receiver) {
    if (key === RAW) return target
    track(target, key, TrackOpTypes.GET)
    const res = Reflect.get(target, key, receiver)
    if (Array.isArray(target) && arrayInstrumentations.hasOwnProperty(key)) {
      return arrayInstrumentations[key].bind(receiver)
    }
    return isObject(res) ? reactive(res) : res;
}

length

我们再来看个栗子

import { reactive } from './reactive.js'

const obj = {}

const arr = [1, obj, 2]

const state = reactive(arr)

function fn () {
  state[4] = 2
}
fn()

这里通过下标的形式新增了一个 item,那必然会 tirgger 一次 add

但是这样数组的 length 也变了,按道理 length 也要一次 set ,但是实际上目前并没有 length 的 trigger

其实 length 的变化就相当于用 Object.defineProperty() ,这种变化是 trigger 不到的

既然如此,我们就在这种情况下手动 trigger 下 length

这种情况就是数组 add,判断两下就好了,具体实现直接看下面代码

function set (target, key, value, receiver) {    
    const type = target.hasOwnProperty(key) ? TriggerOpTypes.SET : TriggerOpTypes.ADD
    const oldValue = target[key]
    const oldLen = Array.isArray(target) ? target.length : null
    const res = Reflect.set(target, key, value, receiver)
    const newLen = Array.isArray(target) ? target.length : null
    if (!Object.is(oldValue, value) || type === TriggerOpTypes.ADD) {
        trigger(target, key, type)
        if (Array.isArray(target) && oldLen !== newLen && key !== 'length') {
            trigger(target, 'length', TriggerOpTypes.SET)
        }
    }

    return res
}

通过下标新增 item,length 的写会被监听不上,若通过 length 删减 arr,item 的删除会被监听上吗,实际上并不会

import { reactive } from './reactive.js'

const obj = {}

const arr = [1, obj, 2]

const state = reactive(arr)

function fn () {
  state.length = 2
}
fn()

目前这个情况只能 trigger 到 length

既然失真了,我们同样手动处理,刚才的实现我们是 key !== length

现在则是 key === length ,那么就是从 oldLen 到 newLen 的区别去 trigger 下标就行,并且类型给一个 delete

function set (target, key, value, receiver) {    
    const type = target.hasOwnProperty(key) ? TriggerOpTypes.SET : TriggerOpTypes.ADD
    const oldValue = target[key]
    const oldLen = Array.isArray(target) ? target.length : null
    const res = Reflect.set(target, key, value, receiver)
    const newLen = Array.isArray(target) ? target.length : null
    if (!Object.is(oldValue, value) || type === TriggerOpTypes.ADD) {
        trigger(target, key, type)
        if (Array.isArray(target) && oldLen !== newLen) {
            if (key !== 'length') {
                trigger(target, 'length', TriggerOpTypes.SET)
            } else {
                for (let i = newLen; i < oldLen; i++) {
                    trigger(target, i, TriggerOpTypes.DELETE)
                }
            }
        }
    }

    return res
}

push

我们再来看一个 push 案例

import { reactive } from './reactive.js'

const obj = {}

const arr = [1, obj, 2]

const state = reactive(arr)

function fn () {
  state.push(3)
}
fn()

这里的 log 如下

依赖收集 (3) [1, {…}, 2] push get
依赖收集 (3) [1, {…}, 2] length get
依赖触发 (4) [1, {…}, 2, 3] 3 add
依赖触发 (4) [1, {…}, 2, 3] length set

看着并没有问题,最后的 length set 也是对的,但是当我们多次重复调用 fn 时,你就会觉得 length 每次 push 一次 length 都会被 track 依赖收集一次显得重复,因此我们需要避免重复触发 length 相关依赖

那就对 push 这类 方法做点手脚,当 push 时,内部就会有 length 的 key,此时暂停 track,等调用完再恢复 track

["push", "pop", "shift", "unshift", "splice"].forEach((method) => {
  arrayInstrumentations[method] = function (...args) {
    pauseTrack();
    const res = Array.prototype[method].apply(this, args);
    resumeTrack();
    return res;
  };
});

pauseTrackresumeTrack 也很容易实现

let shouldTrack = true;

export function track (target, key, type) {
    if (!shouldTrack) return
    console.log('依赖收集', target, key, type);
    
}

export function trigger (target, key, type) {
    console.log('依赖触发', target, key, type);
}

export function pauseTrack () {
    shouldTrack = false
}

export function resumeTrack () {
    shouldTrack = true
}

track 和 trigger

现在开始实现 track 和 trigger

目前的实现仅仅是拿到 target, key, type 信息,但是具体的功能并没有实现

比如 track 依赖收集,他需要收集对应的函数,trigger 则是把这些收集到的函数重新触发执行

另外我们又要继续探讨一个问题,是否所有用到的响应式数据的函数都要收集进来呢

render1() {
    render2() {
        .....
    }
}

比如上面这个情景,父组件里面包含一个子组件,这在实际 vue 项目中是个非常常见的情景,render2 里面的响应式数据若发生变更,render2 组件就会重新渲染,但是 render1 不会,若 render2 里面的响应式数据被 render1 用到了,数据变了 render1 就会重新渲染

要想实现到这个精度,那肯定不是一股脑把所有用到响应式的数据全部收集起来,既然是人为控制,那么我们可以给一个 effect 函数,你若希望 fn 能够被收集进来,那么将 fn 传入进去即可,state(fn)

const obj = {
  a: 1
}

const state = reactive(obj)

function fn1 () {
  function fn2 () {
    state.a;
  }
  fn2()
}

effect(fn1)

比如这里,希望 fn1 被 track 拿到,但是 fn1 是函数 effect 的入参,那就在 effect.js 文件中把 fn 保存到全局中去让 track 拿到

let shouldTrack = true;
let activeEffect = undefined;

export function effect (fn) {
    activeEffect = fn
    fn()
    activeEffect = null
}

export function track (target, key, type) {
    if (!shouldTrack || !activeEffect) return
    console.log('依赖收集', target, key, type, activeEffect);
    
}

收集依赖时其实需要函数运行才能确定,而非编译阶段,举一个直观的栗子

const obj = {
  a: 2
}

const state = reactive(obj)

function fn1 () {
  if (state.a === 1) {
    state.b
  } else {
    state.c
  }
}

effect(fn1)

fn1 执行过程中才能确定到底是依赖 a,b 还是 a,c

而运行时就肯定会产生嵌套的情况,嵌套就会每层作用域有对应的 activeEffect,因此我们收集函数不应该仅仅只是收集 fn,而是把 effect 里面的内容都收集进来

export function effect (fn) {
    const effectFn = () => {
        try {
            activeEffect = fn
            return fn()
        } finally {
            activeEffect = null
        }
    }
    effectFn()
}

这里用 try catch 处理是因为用户写的函数可能会有报错的情况

现在开始用 map 去把一个 target 的对应的函数 去串起来

一个 target 里面会有多个 key,一个 key 又可以有多个 操作类型,操作类型后才对应着 effectFn

除了 effectFn 可以用 set 去个重,其余都可以用 map 表示,另外这里有个 iterate 的操作类型是没有 key 的,因此我们需要手动给 iterate 加一个 key

const targetMap = new WeakMap()
const ITERATE_KEY = Symbol('iterate')

export function track (target, key, type) {
    if (!shouldTrack || !activeEffect) return
    
    let propMap = targetMap.get(target)
    if (!propMap) {
        propMap = new Map()
        targetMap.set(target, propMap)
    }
    if (key === TrackOpTypes.ITERATE) {
        key = ITERATE_KEY;
    }

    let typeMap = propMap.get(key)
    if (!typeMap) {
        typeMap = new Map()
        propMap.set(key, typeMap)
    }

    let depSet = typeMap.get(type)
    if (!depSet) {
        depSet = new Set()
        typeMap.set(type, depSet)
    }

    if (!depSet.has(activeEffect)) {
        depSet.add(activeEffect)
        activeEffect.deps.push(depSet)
    }

    console.log('targetMap', targetMap);
}

track 基本上把 effectFns 收集到 targetMap 中了,现在 trigger 的目的则是用 target,key,type 去寻找对应的 effectFns,然后触发他们的执行

寻找 effectFns 我们单独给一个函数 getEffectFns 来做这件事,其实寻找对应的 effectFns 主要是需要注意 操作类型 之间的对应关系,比如 trigger 时,我们用了set 类型,但是 map 里面的只有 get ,因此我们需要处理每个 TriggerOpType 对应的所有 TrackOpType,然后挨个遍历,有就输出 effectFn 即可

我们先把 操作类型 之间的对应关系 map 给补上

const triggerTypeMaps = {
    [TriggerOpTypes.SET]: [TrackOpTypes.GET],
    [TriggerOpTypes.ADD]: [TrackOpTypes.ITERATE, TrackOpTypes.GET, TrackOpTypes.HAS],
    [TriggerOpTypes.DELETE]: [TrackOpTypes.ITERATE, TrackOpTypes.GET, TrackOpTypes.HAS],
}

然后就是 trigger

export function trigger (target, key, type) {
    const effects = getEffectFns(target, key, type)
    effects.forEach(effectFn => effectFn())
}

现在实现 getEffectFns

function getEffectFns (target, key, type) {
    const propMap = targetMap.get(target)
    if (!propMap) return []
    const keys = [key]
    if (type === TriggerOpTypes.ADD || type === TriggerOpTypes.DELETE) {
        keys.push(ITERATE_KEY)
    }

    const effects = new Set()
    for (const key of keys) {
        const typeMap = propMap.get(key)
        if (!typeMap) continue

        const types = triggerTypeMaps[type]
        for (const type of types) {
            const depSet = typeMap.get(type)
            if (!depSet) continue
            depSet.forEach(effectFn => effects.add(effectFn))
        }
    }
    return [...effects]
}

我们再来看一个栗子

const obj = { a: 1, b: 2, c: 3 };

const state = reactive(obj);

function fn1() {
  console.log("fn1 执行");
  if (state.a === 1) {
    state.b;
  } else {
    state.c;
  }
}
effect(fn1);

state.a = 2;
state.b = 3;

这里会触发三次执行,看似没毛病,实际上只应该触发两次,因为 state.a 被赋值为 2 时,函数的依赖项就和 state.b 无关了

因此我们要实现的就是当 effectFn 执行时重新把 effectFn 里面的依赖项给清空便是

export function effect (fn) {
    const effectFn = () => {
        try {
            activeEffect = effectFn
            cleanup(effectFn)
            return fn()
        } finally {
            activeEffect = null
        }
    }
    effectFn.deps = []
    effectFn()
}

function cleanup (effectFn) {
    const { deps } = effectFn
    if (!deps.length) return  
    deps.forEach(dep => dep.delete(effectFn))
    effectFn.deps.length = 0
}

当然,这需要我们在 track 中给 activeEffect 加一个 deps 属性,把 depSet 挂上去

我们再来看一个例子

const obj = { a: 1, b: 2, c: 3 };

const state = reactive(obj);

function fn1() {
  console.log("fn1 执行");
  effect(() => {
    console.log("fn1 inner");
    state.a;
  });
  state.b;
}

effect(fn1);

state.b = 3;

这里按道理会执行两次 fn1 和 fn1 inner,但是实际上只执行了一次

这是因为在执行 inner 时,activeEffect 已经被置为 null 了,而此时 state.b 还没来得及收集,因此 b 的修改没被监听上

之前的 activeEffect 就不能直接 置为 null,这由于调用栈的关系我们只需要取当前栈的栈顶

let effectStack = []

export function effect (fn) {
    const effectFn = () => {
        try {
            activeEffect = effectFn
            effectStack.push(effectFn)
            cleanup(effectFn)
            return fn()
        } finally {
            effectStack.pop()
            activeEffect = effectStack[effectStack.length - 1]
        }
    }
    effectFn.deps = []
    effectFn()
}

我们再来看个 bug

let state = reactive({
  a: 1,
  b: 2,
  c: 3,
});

function fn1() {
  console.log("fn1 执行");
  state.a++;
}
effect(fn1);

目前这么运行会出现爆栈的问题,这是因为 state.a++ 会先 get 后 set,get 时记录了当前 fn,set 时重新执行 fn,重新执行时之前的逻辑是会重新收集依赖,这就导致了无限递归

解决方案那就在 trigger 时,判断函数是不是当前的 activeEffect,若是则不执行 effectFn

export function trigger (target, key, type) {
    const effects = getEffectFns(target, key, type)
    for (const effectFn of effects) {
        if (effectFn === activeEffect) {
            continue
        }
        effectFn()
    }
}

包括我们可以实现一个 lazy 的 effect 函数

let state = reactive({
  a: 1,
  b: 2,
  c: 3,
});

function fn1() {
  console.log("fn1 执行");
  state.a++;
}
let fn = effect(fn1, {lazy: true});
fn()

lazy 时,就返回函数,而不是执行,非 lazy 就是正常执行

export function effect (fn, options = {}) {
    const { lazy } = options 
    const effectFn = () => {
        try {
            activeEffect = effectFn
            effectStack.push(effectFn)
            cleanup(effectFn)
            return fn()
        } finally {
            effectStack.pop()
            activeEffect = effectStack[effectStack.length - 1]
        }
    }
    effectFn.deps = []
    if (!lazy) {
        effectFn()
    }
    return effectFn
}

包括多次更改,渲染一次的调度器

调度器需要存到 effectFn.options 上,给到 trigger 去执行

export function effect (fn, options = {}) {
    const { lazy } = options 
    const effectFn = () => {
        try {
            activeEffect = effectFn
            effectStack.push(effectFn)
            cleanup(effectFn)
            return fn()
        } finally {
            effectStack.pop()
            activeEffect = effectStack[effectStack.length - 1]
        }
    }
    effectFn.deps = []
    effectFn.options = options
    if (!lazy) {
        effectFn()
    }
    return effectFn
}

export function trigger (target, key, type) {
    const effects = getEffectFns(target, key, type)
    for (const effectFn of effects) {
        if (effectFn === activeEffect) {
            continue
        }
        if (effectFn.options.scheduler) {
            effectFn.options.scheduler(effectFn)
        } else {
            effectFn()
        }
    }
}

实现 ref

现在实现 ref,有了 track ,trigger,基本上 ref 可以很轻松实现

ref 其实就是利用 对象 get set 属性,get value 就是依赖收集,调用 track,返回 value,set value 就是派发更新,调用 trigger

import { track, trigger } from './effect.js'
import { TrackOpTypes, TriggerOpTypes } from './constants.js'
import { reactive } from './reactive.js'
import { isObject } from './utils.js'

export function ref (value) {
    return {
        get value () {
            track(this, TrackOpTypes.GET, 'value')
            return value
        },
        set value (v) {
            if (v !== value) {
                value = v
                trigger(this, 'value', TriggerOpTypes.SET)
            }
        }
    }
}

当然 ref 也要支持 对象,对象就需要调用 reactive 了,另外还需要 reactive 支持 value

实现 computed

computed 其实就是一个仅访问的 effect 函数,只不过里面支持两种写法,一个是写的 getter,setter,一个就是直接写的函数,里面返回的响应式数据

既然支持两种入参,我们可以在 computed 里面先把参数归一化,统一弄成 getter,setter 的形式,但是一般来说我们用函数的形式,函数就是 getter,setter 默认给一个初始值不作用就行

import { effect } from './effect.js'
import { track, trigger } from './effect.js'
import { TrackOpTypes, TriggerOpTypes } from './constants.js'

function normalizeOptions (getterOptions) {
    let getter, setter
    if (typeof getterOptions === 'function') {
        getter = getterOptions
        setter = () => {
            console.warn('Write operation failed: computed value is readonly')
        }
    } else {
        getter = getterOptions.get
        setter = getterOptions.set
    }
    return { getter, setter }
}

export function computed (getterOptions) {
    const { getter, setter } = normalizeOptions(getterOptions)
    const effectFn = effect(getter, {
        lazy: true,
        scheduler () {
            dirty = true
            trigger(obj, 'value', TriggerOpTypes.SET)
        }
    })
    let value;
    let dirty = true;
    const obj = {
        get value () {
            track(obj, 'value', TrackOpTypes.GET)
            if (dirty) {
                value = effectFn()
                dirty = false
            }
            return value
        }, 
        set value (v) {
            setter(v)
        }
    }
    return obj
}

实现 watchEffect

watchEffect 会自动追踪所有响应式依赖,依赖项发生变化会自动执行传入的 fn

watchEffect 肯定是 lazy false,因为函数在组件加载时就会开始执行一次

export function watchEffect (fn) {
    const effectFn = effect(fn, {
        lazy: false,
        scheduler (effectFn) {
            effectFn()
        }
    })

    return () => {
        effectFn.deps.forEach(dep => dep.delete(effectFn))
        effectFn.deps.length = 0
    }
}

实现 watch

watch 会接受三个参数,分别为 source,callback,options,source 就是响应式数据,callback 其实就是 getter 函数 ,options 支持 deep,lazy 等,其实 reactive 默认就是 deep

watch 的 deep 指的是是否要把对象的所有内部属性纳入观察依赖中,默认 false

实现 deep 则是通过 traverse 去深度遍历 get 所有属性

export function watch (source, callback, options = {}) {
    const { immediate = false, deep = false } = options

    let getter
    let oldValue

    if (typeof source === 'function') {
        getter = deep ? () => traverse(source()) : source
    } else if (isObject(source)) {
        getter = () => traverse(source)
    } else {
        console.warn('watch source must be a function or an object')
        return () => {}
    }
    
    const effectFn = effect(getter, {
        lazy: true,
        scheduler (effectFn) {
            const newValue = effectFn()
            callback(newValue, oldValue)
            oldValue = newValue
        }
    })
    
    if (immediate) {
        const newValue = effectFn()
        callback(newValue, undefined)
        oldValue = newValue
    } else {
        oldValue = effectFn()
    }
    
    return () => {
        effectFn.deps.forEach(dep => dep.delete(effectFn))
        effectFn.deps.length = 0
    }
}

function traverse (value, seen = new Set()) {
    if (!isObject(value) || seen.has(value)) {
        return value
    }
    
    seen.add(value)
    
    for (const key in value) {
        traverse(value[key], seen)
    }
    
    return value
}

最后

v3 响应式就是通过 proxy 代理实现的,proxy 天生支持多种 handlers ,我们要做的无非就是在这个基础上进行封装,而数组对于我们的系统来说,很多方法内部会读取 length 或逐个索引可能不符合我们的预期,这才需要进行稍微修改,优先在 proxy 身上找,找不到就再在 this[RAW] 原始数组上找

而 track 依赖收集就是帮我们把数据和对应的 key 之间所有的关系给 weakmap 起来,target -> Map(key) -> Map(type) -> Set(effectFn), 这样我们 trigger 派发更新时就可以依靠这个联系挨个触发。给一个 effect 函数其目的也主要是要收集到函数,给函数打上对应的 deps 标记

至此,@vue/reactivity 基本上实现了个大概。

前端性能优化之:图片缩放 🚀

作者 namehu
2025年10月20日 17:05

哈喽,大家好。今天,我们来聊一个非常接地气但又极其重要的话题——图片缩放

你可能会说:“切,图片缩放?img 标签上加个 widthheight 不就行了?” 🤔

朋友,如果事情这么简单,那我就没必要写这篇文章了。在如今这个移动互联网时代,流量、加载速度、用户体验,每一个环节都至关重要。一张未经处理的高清大图,可能会让你的网页在移动端“瞬间劝退”用户。

所以,今天我要带大家深入探讨一下,如何利用云存储服务,优雅且高效地实现图片缩放,全面提升你的应用性能!

1. 前言:为什么我们需要图片缩放?

1.1 背景:移动端的“流量刺客”

想象一个场景:你的产品运营上传了一张 50MB 的高清 Banner 图,分辨率高达 4000x3000。在 PC 端,这可能没什么问题。但在移动端,一个用户的屏幕宽度可能只有 375px,你却让他加载了一张宽度是你 10 倍还多的图片。

这会带来什么问题?

  • 加载缓慢 ⏳:用户在 4G/5G 环境下,需要消耗更多的时间和流量来下载这张大图,白屏时间大大增加。
  • 体验糟糕 😫:图片加载出来之前,页面布局可能会因为图片尺寸未知而发生跳动(CLS),非常影响观感。
  • 成本增加 💰:对于按流量计费的云服务来说,加载不必要的图片数据意味着真金白银的浪费。
  • 内存崩溃 🤯:在微信小程序等有严格内存限制的环境中,加载过大的图片会迅速消耗宝贵的内存资源,极易导致小程序闪退或崩溃。

因此,按需加载合适尺寸的图片,是移动端开发中必须考虑的优化项。

1.2 目标:只为用户提供“刚刚好”的图片

我们这篇文章的目标很明确:解决上述痛点。我们要实现一个方案,能够根据前端页面所需的尺寸,动态地从云存储请求一张“刚刚好”大小的图片。

这样做的好处是显而易见的:

  • 提升加载速度:图片体积大大减小,加载速度飞快。
  • 优化用户体验:图片秒开,布局稳定。
  • 节省带宽成本:只传输必要的数据,为公司省钱。

2. 主体:云存储缩放的黑魔法

2.1 原理揭秘

很多新手可能会以为图片缩放是前端或者后端服务的工作。其实,现在主流的云存储服务(如阿里云 OSS、腾讯云 COS、华为云 OBS)都内置了强大的数据处理功能,图片处理只是其中之一。

其实现方式非常简单:通过在图片的 URL 链接后拼接特定的参数,来告诉云存储服务你想要对这张图片做什么样的处理。

云服务在接收到这个请求后,会实时地对原图进行处理(例如缩放、裁剪、加水印等),然后将处理后的结果返回给用户,整个过程无需我们自己部署任何图片处理服务。

下面我画了一个简单的流程图来说明这个过程:

graph TD
    A["用户设备 (Client)"] -- "请求一张 300x200 的图片" --> B("前端代码");
    B -- "组装URL: https://.../image.jpg?param=w_300,h_200" --> C("云存储服务 (OSS/COS/OBS)");
    C -- "找到原图 image.jpg" --> D{"图片处理模块"};
    D -- "根据参数实时缩放/裁剪" --> E["生成 300x200 的新图片"];
    E -- "返回处理后的图片" --> A;

2.1.1 图片缩放的技术原理简介

你可能会好奇,云服务背后是怎么把一张大图变小的呢?这其实涉及到图像处理中的插值算法。简单来说,就是当图片的像素点数量发生变化时,如何计算新图中每个像素点的颜色。

常见的算法有这么几种:

  • 最近邻插值 (Nearest Neighbor) :最简单粗暴的一种。新图像中的像素点,直接照搬原图中离它最近的那个像素点的颜色。优点是速度飞快,缺点是缩放后的图片容易出现锯齿,质量较差。
  • 双线性插值 (Bilinear Interpolation) :它会考察原图中目标位置周围的 4 个像素点,并根据它们与目标位置的距离,加权计算出新像素点的颜色。效果比最近邻平滑得多,是速度和质量的一个很好的平衡点。
  • 双三次插值 (Bicubic Interpolation) :更高级的玩法。它会考察周围 16 个像素点,使用更复杂的计算公式来生成新像素。缩放后的图片更清晰,细节保留得更好,但计算量也更大。

大部分云厂商的图片处理服务,默认会采用效果较好的双线性或双三次插值,以确保输出的图片质量。

2.2 代码实现

理论说完了,我们来看代码

我司针对 阿里云 / 腾讯云 / 华为云 等都做了包封装。其中包含了 多端(PC/Taro/Uni/React Native)等图片上传和缩放的逻辑封装
下面是关于图片缩放部分的实现。仅作演示讲解,并不代表最佳实践和通解。如果你有更好的实现方案。欢迎讨论

核心逻辑:trunc 函数

在所有实现中,工具函数:

image.png

// 几倍图
const multiple = 2

/**
 * 计算整数
 */
function trunc(nu: number) {
  return Math.trunc(nu) * multiple
}

这个函数的作用是取一个整数,并乘以 multiple(这里是 2)。这是为了适配高分屏(Retina 屏幕) 。在这些设备上,一个 CSS 像素点背后对应着多个物理像素点(DPR > 1)。为了让图片在这些屏幕上看起来更清晰,我们通常会请求 2 倍或 3 倍大小的图片。

阿里云 OSS 实现 (index.ts)

阿里云 OSS 的图片处理参数以 ?x-oss-process=image 开头。

function getResizeUrl({ width, height }: ISize = {}) {
  // 参数以 x-oss-process=image/resize 开头
  // 如果同时有宽高,则使用 m_fill 模式(固定宽高,自动裁剪)
  let url = `?x-oss-process=image/resize${width && height ? ',m_fill' : ''}`
  if (width) {
    // w_宽
    url += `,w_${trunc(width)}`
  }
  if (height) {
    // h_高
    url += `,h_${trunc(height)}`
  }
  return url
}

export function assembleResizeUrl(url?: string, size?: ISize) {
  return url ? url + getResizeUrl(size) : ''
}

腾讯云 COS 实现 (tecent-yun.ts)

腾讯云的参数风格则完全不同,它使用 ?imageView2 作为入口。

export function assembleResizeUrl(url?: string, size?: ISize) {
  return url
    ? `${url}?imageView2/2/w/${trunc(size?.width) || ''}${
        size?.height ? `/h/${trunc(size?.height)}` : ''
      }/gravity/center`
    : ''
}

华为云 OBS 实现 (huaweiyun.ts)

华为云的参数又是一种风格,以 ?x-image-process=image 开头,和阿里云非常相似。

function getResizeUrl({ width, height }: ISize = {}) {
  if (!width && !height) {
    return ''
  }
  let url = '?x-image-process=image/resize'

  // m_fixed 模式等同于阿里云的 m_fill
  if (width && height) {
    url += ',m_fixed'
  }

  if (width) {
    url += `,w_${trunc(width)}`
  }

  if (height) {
    url += `,h_${trunc(height)}`
  }

  return url
}

2.3 各家云厂商实现方式对比

云厂商 主要参数指令 缩放模式示例 (固定宽高) 优点 注意事项
阿里云 OSS x-oss-process m_fill 功能强大,文档详细,生态完善 参数拼接较为复杂,逗号分隔容易出错
腾讯云 COS imageView2 imageView2/2 URL 路径形式,语义化更强,更直观 必须开通数据万象服务,开通后存储桶将自动绑定数据万象
华为云 OBS x-image-process m_fixed 与阿里云参数类似,迁移成本较低 视频处理等功能可能需要额外开启 MPC 服务

2.4 云服务 vs 开源自建方案

除了使用各大云厂商提供的服务,我们还有另一种选择:自建图片处理服务。社区也有一些非常优秀的开源项目。

  • Thumbor: 一个非常老牌和强大的图片处理服务,用 Python 编写。它的功能极其丰富,甚至包括基于人脸识别的智能裁剪。但功能强大的背后是部署和维护相对复杂。并且如果你有视频截帧的需求,就用它!
  • Imgproxy: 一个用 Go 编写的后起之秀。它以速度、安全、简单为核心设计理念。虽然功能不及 Thumbor 那么花哨,但它的性能极高,资源消耗低,并且非常容易通过 Docker 进行部署,是目前非常流行的选择。虽然很好用 但是很多高级功能比如视频截帧并不开源。

怎么选?

  • 初创团队/中小型项目:直接用云厂商的服务。开箱即用,按量付费,省心省力。
  • 大型项目/有特殊需求:当图片处理请求量巨大,或者有深度定制化的需求时,可以考虑自建方案。虽然有运维成本,但长期来看可能会更节省费用,并且拥有更高的自主性。

3. 总结

好了,关于云存储图片缩放的话题,今天就聊到这里。我们来回顾一下核心要点:

  1. 为什么要做? 为了提升移动端加载速度、优化用户体验、节省带宽成本,以及防止在小程序等环境中内存崩溃。
  2. 原理是什么? 利用云存储服务或自建服务,通过 URL 参数对图片进行实时处理,其背后是各种图像插值算法在起作用。
  3. 如何实现? 可以选择云厂商的服务(方便快捷),也可以选择 ThumborImgproxy 等开源方案进行自建(灵活可控)。

这个技术虽然简单,但效果立竿见影,是前端性能优化中的“必杀技”。希望这篇文章能帮助你更好地理解和应用它。

如果你觉得这篇文章对你有帮助,别忘了点赞、评论、分享三连哦!👍❤️

下次见!👋

【编程】是什么编程思想,让老板对小伙怒飙英文?Are you OK?

2025年10月20日 16:55

一个段子引发的思考

先讲一个段子:

说某公司的老板招了一个软件研发经理小李,希望小李能帮企业完成一款重要软件的开发。

入职第一天,小李就给老板发邮件:"你好,老板,你的商业模式有问题,屌丝们没啥钱,赚他们的钱死路一条,咱走高端路线吧,卖给土豪,嘎嘎赚钱。"

老板皱了皱眉,没回复他。

第二天,小李又给他发邮件:“隔壁项目组的项目一坨狗屎,肯定做不成的,老板你多留意一下!”

老板面有愠色。

第三天,小李啥也没发,但是人力跑来汇报,小李私下抱怨他接手的项目风险巨大,但从未向上反馈过。

老板坐不住了,给小李发了封邮件:

“Are you OK?”

段子结束。

请问,小李去的是哪家公司?哈哈,开个玩笑

请问,小李犯了哪些错误?你理解了小李在职场上犯得错误,那么你也就差不多理解了一种极为重要的软件编程思想。

"控制向下,信号向上"

1. 什么是 "控制向下,信号向上"

在前端编程界,有个非常常见的概念叫“单向数据流”

在游戏编程领域,也有个概念词叫 "控制向下,信号向上"

在企业软件的架构层面,下一层的组件通常作为上一层组件的“能力提供者”被调用。

抛开名词差异,他们的本质其实都是类似,内核殊途同归,人们从思想上有意识地把代码块划分成了两个大类:

  • 被调用者
  • 调用者

当然,任何组件都可以同时作为被调用者,和调用者

认识这种编程思想,理解它,可能对你的帮助不仅仅是在编程领域,对于职场的运转规则或者也能带来不一样的理解和体验。

让我们思考一个典型的编程场景:计数器

20251020-01.png

好的,现在你看到了一个经常作为入门demo 被拿来讨论的场景,让我们脱离具体的编程语言和技术框架,把它当成一种通用场景来进行抽象,我们大抵可以把它按"调用者"和"被调用者"拆成如下:

20251020-02.png

在上图中,我们不仅完成了一次组件设计上的分层,而且设计了最简单也最符合"信号向上,控制向下"的典型的架构。

"按钮"和"数字显示组件"作为层级更低的组件,在该架构中只有两个职责:

  • 按钮负责将自己被点击这个信号反馈给上层
  • 数字显示组件负责显示上层命令自己显示的数字

而"计数器"作为层级更高层级的组件,它需要完成的事项责更多:

  • 记录当前数字
  • 监听按钮的点击信号
  • 在监听后计算,获得计算后的数字
  • 控制数字显示组件的显示数字

这样,一个典型的"控制向下,信号向上"架构,就完成了。

同时,计数器也可以作为"被调用者",当它被调用时,它可以提供如下能力:

  • 向上发送信号:我的数字变化了
  • 被上方控制:重置计数

通过这样一层层搭建,你可以完成绝大多数符合业务的客户端页面。(浏览器当然也算客户端)

那么,这样做的好处是什么呢?

2. 组件的核心能力是被复用

为什么要对组件分层,为什么要把代码块抽象为组件或者工具方法?

答案只有一个:复用

软件工程的不可能三角大家都是知道的:

20251020-03.png

  • 效率
  • 质量
  • 成本

这三者在有限资源的开发过程中是不可能同时满足的,组件化可以在长期来看一定是对"快"和”好“ 这两方面带来了显著收益的。

  • 快速的组件能力复用提升效率
  • 充分的单元测试提升质量

达成以上效果的核心,就是要保证组件能尽可能被更多的开发者、团队、项目复用,正是组件这种天然的需要被复用的特性,使得它一定需要满足以下特性:

  • 它的能力是面向“未知者”的。

一个“按钮组件”永远无法轻易预知未来自己会被用在怎样的场景下,它可能被用在双十一的购物车旁,可能被用在相亲网站的一见钟情场景,甚至可能被用来发动一场战争。

因此,错误的做法有一千万种:

尝试在组件内提前面向所有它可能面对的全部一千万种场景,包括控制调用它的那个组件+1计数,控制点击它的那个人爱上另一个人,控制一枚核弹完成装填等等...

而正确的做法只有一种:

无论调用者是谁,只要他监听了,便像他发送信号:我被点击了。

20251020-05.png

如图,以上两种设计模式,很显然“信号向上”是显著优于“控制向上”的。

这也正是为什么大家总是在强调“组件的单一原则性”,不是组件不能做得更多,而是在面向复用这个核心内容的场景下:

少,就是多。

同时:

组件只有通知调用者的义务,却没有控制调用者的权利。

3. 组件的边界

组件到底应不应该和同层的其他组件通信,这是个设计问题。

还是刚才那个“计数器”的例子,让我们尝试一下“同层组件互相感知”的设计:

20251020-06.png

看起来也没什么问题是吧?

但是如果这时候设计变了,甲方提出了更符合实际的要求,点一下按钮要增加两个计数,那你将无法在“调用者层”完成业务更新,而需要去重新修改组件内部的业务逻辑。

而且,更致命的是“耦合”。

耦合是一个源自物理学的术语,核心含义是两个组件之间产生了非常紧密的互相依赖,单单任何一方都将无法正常运作。

在当前这个例子里:

  • 如果没有计数器:按钮将毫无作用,因为它只向数字显示组件输出信号。
  • 如果没有按钮:数字显示组件也毫无作用,因为它只接收按钮组件的信息进行更新。

很显然,组件在这种耦合中失去了自己在更广阔场景复用的核心能力。

用人话说就是:废了。

4. 调用者的向下控制

上面讲了“被调用者”这种身份呢的组件应该如何设计,那我们再来看一看身为“调用者”有哪些不同?

首先,调用者非常明确自己拥有哪些组件,它们分别具备什么样的能力。

以“计数器”为例,它在设计和实现时就能明确,它拥有:

  • 按钮组件
  • 数字显示组件

它的调用者,则只能通过调用组件提供的能力,来完成自己需要的功能。

因此,“调用者” 向下面对的是一个个具体的组件,它只需要确认两点:

  • 用户进行点击时,有组件把信号告诉我。
  • 我命令显示组件显示数字时,它能正常执行。

是不是很像职场上的“控制者”?

他们需要获得更多更为准确的信息,需要你及时反馈风险和进度,但不惜要你命令他去做一件具体的事情。

具体的事情谁来做的?当然,可能是他自己来做,更多的情况是他负责向下控制,交给更加专业的人来做。

而标准组件如果满足不了要求了,他们可以随时更换一个具备相同能力的组件,顶替到相应的岗位,整个机器依然能够正常运转。

VitePress 文档站点:打造专业级组件文档(含交互式示例)

2025年10月20日 16:54

本文是《从零到一:构建现代化企业级 Monorepo 项目实战》系列的第六篇。这篇文章将教你如何搭建一个媲美官方文档的专业文档站点。

🎯 本文目标

  • VitePress 在 Monorepo 中的集成
  • 组件示例自动导入和渲染
  • 自定义主题和样式
  • 文档站点性能优化
  • 部署配置

📖 为什么选择 VitePress?

文档工具对比

工具 性能 Vue 支持 Markdown 扩展 学习成本 推荐指数
VuePress ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐
Docusaurus ⭐⭐⭐ ❌ React ⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐
Docsify ⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐
VitePress ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐

VitePress 优势:

  • ⚡ 基于 Vite,开发和构建速度极快
  • 🎨 Vue 组件可以直接在 Markdown 中使用
  • 📝 Markdown 扩展能力强大
  • 🎯 专为 Vue 生态设计

🏗️ 项目结构设计

docs/
├── .vitepress/
│   ├── config.ts              # 配置文件
│   ├── theme/
│   │   ├── index.ts           # 主题入口
│   │   ├── components/
│   │   │   └── DemoBlock.vue  # 示例容器组件
│   │   └── custom.css         # 自定义样式
│   └── plugins/
│       └── demo-container.ts  # 自定义插件
├── components/
│   ├── button/
│   │   ├── index.md           # Button 文档
│   │   └── basic.vue          # 基础示例
│   └── index.md
├── guide/
│   ├── index.md
│   └── getting-started.md
├── index.md                   # 首页
└── package.json

🚀 第一步:安装和配置

1.1 安装 VitePress

cd docs
pnpm init
pnpm add -D vitepress vue

1.2 基础配置

// docs/.vitepress/config.ts
import { defineConfig } from 'vitepress'
import { resolve } from 'path'

export default defineConfig({
  title: 'GDU Common',
  description: 'GDU 前端通用组件库和工具集',
  lang: 'zh-CN',

  // 主题配置
  themeConfig: {
    logo: '/logo.svg',

    nav: [
      { text: '指南', link: '/guide/' },
      { text: '组件', link: '/components/' },
      { text: '工具', link: '/utils/' },
    ],

    sidebar: {
      '/guide/': [
        {
          text: '开始',
          items: [
            { text: '介绍', link: '/guide/' },
            { text: '快速开始', link: '/guide/getting-started' },
          ],
        },
      ],
      '/components/': [
        {
          text: '组件',
          items: [{ text: 'Button 按钮', link: '/components/button' }],
        },
      ],
    },

    socialLinks: [{ icon: 'github', link: 'https://github.com/your-org/gdu-common' }],

    search: {
      provider: 'local', // 本地搜索
    },
  },

  // Vite 配置
  vite: {
    resolve: {
      alias: {
        '@gdu-common/ui': resolve(__dirname, '../../packages/ui/src'),
        '@gdu-common/utils': resolve(__dirname, '../../packages/utils/src'),
        '@gdu-common/shared': resolve(__dirname, '../../packages/shared/src'),
      },
    },
  },
})

1.3 添加脚本

// docs/package.json
{
  "scripts": {
    "dev": "vitepress dev",
    "build": "vitepress build",
    "preview": "vitepress preview"
  }
}

🎨 第二步:自定义主题

2.1 创建自定义样式

/* docs/.vitepress/theme/custom.css */

/* 首页渐变背景 */
:root {
  --vp-home-hero-name-color: transparent;
  --vp-home-hero-name-background: linear-gradient(120deg, #bd34fe 30%, #41d1ff);
}

/* 自定义品牌色 */
:root {
  --vp-c-brand-1: #42b983;
  --vp-c-brand-2: #35a069;
  --vp-c-brand-3: #299e5c;
}

/* 代码块样式优化 */
.vp-code-group {
  margin: 16px 0;
}

/* 表格样式 */
.vp-doc table {
  display: block;
  overflow-x: auto;
}

2.2 注册全局组件

// docs/.vitepress/theme/index.ts
import DefaultTheme from 'vitepress/theme'
import './custom.css'

export default {
  extends: DefaultTheme,
  enhanceApp({ app }) {
    // 自动注册所有示例组件
    const examples = import.meta.glob('../../components/**/*.vue', {
      eager: true,
    })

    for (const path in examples) {
      const component = examples[path].default
      const name = path.match(/\/([^/]+)\.vue$/)?.[1]
      if (name) {
        app.component(name, component)
      }
    }
  },
}

🎭 第三步:交互式组件示例

3.1 创建 Demo 容器组件

<!-- docs/.vitepress/theme/components/DemoBlock.vue -->
<template>
  <div class="demo-block">
    <div class="demo-preview">
      <slot name="demo" />
    </div>

    <div class="demo-actions">
      <button @click="toggleCode" class="toggle-code-btn">
        {{ showCode ? '隐藏代码' : '查看代码' }}
      </button>
    </div>

    <div v-show="showCode" class="demo-code">
      <slot name="code" />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const showCode = ref(false)
const toggleCode = () => {
  showCode.value = !showCode.value
}
</script>

<style scoped>
.demo-block {
  border: 1px solid var(--vp-c-divider);
  border-radius: 8px;
  margin: 16px 0;
}

.demo-preview {
  padding: 24px;
  background: var(--vp-c-bg-soft);
}

.demo-actions {
  padding: 12px 16px;
  border-top: 1px solid var(--vp-c-divider);
  display: flex;
  justify-content: flex-end;
}

.toggle-code-btn {
  padding: 4px 12px;
  font-size: 14px;
  border: 1px solid var(--vp-c-brand-1);
  color: var(--vp-c-brand-1);
  background: transparent;
  border-radius: 4px;
  cursor: pointer;
  transition: all 0.3s;
}

.toggle-code-btn:hover {
  background: var(--vp-c-brand-1);
  color: white;
}

.demo-code {
  border-top: 1px solid var(--vp-c-divider);
}
</style>

3.2 Markdown 自定义容器插件

// docs/.vitepress/plugins/demo-container.ts
import MarkdownIt from 'markdown-it'
import container from 'markdown-it-container'
import { readFileSync } from 'fs'
import { resolve } from 'path'

export default (md: MarkdownIt) => {
  md.use(container, 'demo', {
    validate(params: string) {
      return params.trim().match(/^demo\s+(.*)$/)
    },

    render(tokens: any[], idx: number) {
      const token = tokens[idx]
      const info = token.info.trim().match(/^demo\s+(.*)$/)

      if (tokens[idx].nesting === 1) {
        // 开始标签
        const demoPath = info?.[1] || ''
        const filePath = resolve(__dirname, '../../components', `${demoPath}.vue`)
        const source = readFileSync(filePath, 'utf-8')

        return `
          <DemoBlock>
            <template #demo>
              <${demoPath.split('/').pop()} />
            </template>
            <template #code>
              
\`\`\`vue
${source}
\`\`\`

            </template>
          </DemoBlock>
          <div>
        `
      } else {
        // 结束标签
        return '</div>\n'
      }
    },
  })
}

3.3 在 config.ts 中注册插件

import demoContainer from './plugins/demo-container'

export default defineConfig({
  markdown: {
    config: md => {
      md.use(demoContainer)
    },
  },
})

📝 第四步:编写组件文档

4.1 创建示例组件

<!-- docs/components/button/basic.vue -->
<template>
  <div class="demo">
    <Button>默认按钮</Button>
    <Button type="primary">主要按钮</Button>
    <Button type="success">成功按钮</Button>
  </div>
</template>

<script setup lang="ts">
import { Button } from '@gdu-common/ui'
</script>

<style scoped>
.demo {
  display: flex;
  gap: 12px;
}
</style>

4.2 编写文档

<!-- docs/components/button.md -->

# Button 按钮

常用的操作按钮。

## 基础用法

最简单的按钮用法。

:::demo button/basic
:::

## API

### Props

| 属性     | 说明     | 类型                                  | 默认值      |
| -------- | -------- | ------------------------------------- | ----------- |
| type     | 按钮类型 | `'default' \| 'primary' \| 'success'` | `'default'` |
| size     | 按钮尺寸 | `'small' \| 'medium' \| 'large'`      | `'medium'`  |
| loading  | 加载状态 | `boolean`                             | `false`     |
| disabled | 禁用状态 | `boolean`                             | `false`     |

### Events

| 事件名 | 说明           | 回调参数                      |
| ------ | -------------- | ----------------------------- |
| click  | 点击按钮时触发 | `(event: MouseEvent) => void` |

### Slots

| 插槽名  | 说明       |
| ------- | ---------- |
| default | 按钮内容   |
| icon    | 自定义图标 |

效果:

  • 📖 文档和代码在一起,易于维护
  • 🎨 实时渲染的组件示例
  • 💻 可以查看源代码
  • 📱 响应式设计

🎨 第五步:首页定制

5.1 英雄区域

## <!-- docs/index.md -->

layout: home

hero:
name: GDU Common
text: 企业级前端通用组件库
tagline: 基于 Vue 3 + TypeScript + Vite
image:
src: /logo.svg
alt: GDU Common
actions: - theme: brand
text: 快速开始
link: /guide/getting-started - theme: alt
text: 查看组件
link: /components/ - theme: alt  
 text: GitHub
link: https://github.com/your-org/gdu-common

---

5.2 特性展示

features:

- icon: 🎨
  title: Vue 3 组件库
  details: 基于 Vue 3 Composition API 开发,提供丰富且高质量的 UI 组件
  link: /components/
  linkText: 查看组件
- icon: ⚡
  title: Vite & Turborepo
  details: 使用 Vite 极速构建,Turborepo 智能缓存,构建速度提升 19 倍
- icon: 🔧
  title: TypeScript 优先
  details: 完整的类型定义和智能提示,提供一流的开发体验
- icon: 📦
  title: Monorepo 架构
  details: 使用 pnpm workspace + Turborepo 管理,支持多包开发和发布

5.3 自定义样式

<!-- docs/index.md -->
<style>
:root {
  --vp-home-hero-name-color: transparent;
  --vp-home-hero-name-background: linear-gradient(-45deg, #bd34fe 30%, #41d1ff);

  --vp-home-hero-image-background-image: linear-gradient(-45deg, #bd34fe50 50%, #47caff50);
  --vp-home-hero-image-filter: blur(44px);
}

.VPFeature {
  transition: all 0.3s;
}

.VPFeature:hover {
  transform: translateY(-4px);
  box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
}
</style>

📱 第六步:高级功能

6.1 组件 Playground

<!-- docs/.vitepress/theme/components/Playground.vue -->
<template>
  <div class="playground">
    <div class="playground-preview">
      <component :is="currentComponent" v-bind="props" />
    </div>

    <div class="playground-controls">
      <h4>属性配置</h4>
      <div v-for="(value, key) in props" :key="key" class="control-item">
        <label>{{ key }}</label>
        <input v-model="props[key]" />
      </div>
    </div>

    <div class="playground-code">
      <pre><code>{{ generatedCode }}</code></pre>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, reactive } from 'vue'
import { Button } from '@gdu-common/ui'

const props = reactive({
  type: 'primary',
  size: 'medium',
  loading: false,
})

const currentComponent = Button

const generatedCode = computed(() => {
  const propsStr = Object.entries(props)
    .map(([key, val]) => `${key}="${val}"`)
    .join(' ')
  return `<Button ${propsStr}>点击我</Button>`
})
</script>

6.2 API 表格自动生成

// docs/.vitepress/plugins/api-table.ts
import { readFileSync } from 'fs'
import { parse } from 'vue/compiler-sfc'

export function extractProps(filePath: string) {
  const source = readFileSync(filePath, 'utf-8')
  const { descriptor } = parse(source)

  // 解析 <script setup> 中的 defineProps
  const scriptContent = descriptor.script?.content || ''
  const propsMatch = scriptContent.match(/defineProps<(.+)>/)

  if (propsMatch) {
    // 提取 Props 类型定义
    return parsePropsType(propsMatch[1])
  }

  return []
}

6.3 暗黑模式支持

// config.ts
export default defineConfig({
  appearance: true, // 启用暗黑模式切换

  themeConfig: {
    // 暗黑模式下的logo
    logo: {
      light: '/logo-light.svg',
      dark: '/logo-dark.svg',
    },
  },
})
/* 暗黑模式样式 */
.dark .demo-block {
  border-color: var(--vp-c-divider);
  background: var(--vp-c-bg-soft);
}

🔧 第七步:Markdown 扩展

7.1 自定义容器

::: tip 提示
这是一个提示信息
:::

::: warning 警告
这是一个警告信息
:::

::: danger 危险
这是一个危险警告
:::

::: details 点击查看详情
这是详细内容
:::

7.2 代码组

::: code-group

\`\`\`bash [pnpm]
pnpm add @gdu-common/ui
\`\`\`

\`\`\`bash [npm]
npm install @gdu-common/ui
\`\`\`

\`\`\`bash [yarn]
yarn add @gdu-common/ui
\`\`\`

:::

7.3 代码高亮行

// 高亮特定行
\`\`\`typescript {2,4-6}
function hello() {
  const name = 'world'  // [!code highlight]

  console.log('line 4')  // [!code highlight]
  console.log('line 5')  // [!code highlight]
  console.log('line 6')  // [!code highlight]
}
\`\`\`

// 标记添加/删除
\`\`\`typescript
function hello() {
  const name = 'world'   // [!code --]
  const name = 'Vue 3'   // [!code ++]
}
\`\`\`

📊 第八步:SEO 优化

8.1 Meta 标签配置

export default defineConfig({
  head: [
    // SEO
    ['meta', { name: 'keywords', content: 'Vue3, 组件库, TypeScript, Monorepo' }],
    ['meta', { name: 'author', content: 'GDU Team' }],

    // Open Graph
    ['meta', { property: 'og:type', content: 'website' }],
    ['meta', { property: 'og:title', content: 'GDU Common' }],
    ['meta', { property: 'og:description', content: '企业级前端组件库' }],
    ['meta', { property: 'og:image', content: '/og-image.png' }],

    // Favicon
    ['link', { rel: 'icon', href: '/favicon.ico' }],
    ['link', { rel: 'apple-touch-icon', href: '/apple-touch-icon.png' }],
  ],

  // 最后更新时间
  lastUpdated: true,

  // 干净的 URL
  cleanUrls: true,
})

8.2 站点地图生成

// docs/.vitepress/config.ts
export default defineConfig({
  sitemap: {
    hostname: 'https://gdu-common.example.com',
  },
})

🚀 第九步:性能优化

9.1 构建优化

export default defineConfig({
  vite: {
    build: {
      // 代码压缩
      minify: 'terser',

      // 分包策略
      rollupOptions: {
        output: {
          manualChunks: {
            'vue-vendor': ['vue'],
            'vitepress-vendor': ['vitepress'],
          },
        },
      },

      // Chunk 大小警告
      chunkSizeWarningLimit: 1000,
    },
  },
})

9.2 图片优化

# 使用 webp 格式
docs/public/
├── logo.svg
├── hero.webp          # 首页图片
└── og-image.webp      # 社交分享图片

9.3 代码分割

// 异步加载组件
export default {
  async enhanceApp({ app }) {
    if (!import.meta.env.SSR) {
      const HeavyComponent = await import('./components/HeavyComponent.vue')
      app.component('HeavyComponent', HeavyComponent.default)
    }
  },
}

📦 第十步:部署配置

10.1 静态站点部署

export default defineConfig({
  base: '/', // 根目录部署
  // 或
  base: '/docs/', // 子目录部署

  outDir: '.vitepress/dist',
  cacheDir: '.vitepress/cache',
})

10.2 Nginx 配置

server {
    listen 80;
    server_name docs.gdu-common.com;
    root /var/www/docs/.vitepress/dist;
    index index.html;

    # SPA 路由支持
    location / {
        try_files $uri $uri/ /index.html;
    }

    # 静态资源缓存
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

10.3 Docker 部署

# docs/Dockerfile
FROM node:20-alpine

WORKDIR /app

# 复制文件
COPY package.json pnpm-lock.yaml ./
COPY docs ./docs
COPY packages ./packages

# 安装依赖
RUN npm install -g pnpm
RUN pnpm install --frozen-lockfile

# 构建文档
RUN pnpm --filter @gdu-common/docs build

# 使用 nginx 服务
FROM nginx:alpine
COPY --from=0 /app/docs/.vitepress/dist /usr/share/nginx/html
COPY docs/nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80

💡 最佳实践

1. 文档组织结构

docs/
├── guide/                 # 指南(给新手)
│   ├── index.md          # 介绍
│   ├── getting-started.md # 快速开始
│   └── installation.md   # 安装
├── components/            # 组件文档(给使用者)
│   ├── index.md
│   └── button.md
├── utils/                 # 工具文档
│   ├── index.md
│   └── common.md
└── advanced/              # 高级用法(给进阶用户)
    ├── customization.md
    └── theme.md

2. 示例代码规范

<!-- ✅ 好的示例 -->
<template>
  <!-- 清晰的HTML结构 -->
  <div class="demo">
    <Button @click="handleClick"> 点击次数:{{ count }} </Button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { Button } from '@gdu-common/ui'

// 简洁的逻辑
const count = ref(0)
const handleClick = () => {
  count.value++
}
</script>

<style scoped>
/* 必要的样式 */
.demo {
  padding: 20px;
}
</style>

3. 文档写作规范

# ✅ 清晰的标题层级

# 组件名

## 基础用法

### 示例标题

#### 细节说明

# ✅ 代码示例在前,文字说明在后

:::demo button/basic
:::

按钮组件支持多种类型...

# ✅ 完整的 API 文档

## Props

## Events

## Slots

## Methods

📈 效果展示

构建性能

# 文档构建速度
pnpm --filter @gdu-common/docs build

vitepress v1.6.4
✓ building client + server bundles...
✓ rendering pages...

build complete in 4.3s

访问性能

指标 数值 说明
FCP 0.8s 首次内容绘制
LCP 1.2s 最大内容绘制
TTI 1.5s 可交互时间
总分 95/100 Lighthouse 性能分数

用户体验

  • ✅ 本地搜索(无需服务器)
  • ✅ 暗黑模式切换
  • ✅ 响应式设计
  • ✅ 代码一键复制
  • ✅ 交互式示例

🤔 常见问题

Q1: 如何在文档中引用组件?

// 方式1:配置 alias
vite: {
  resolve: {
    alias: {
      '@gdu-common/ui': resolve(__dirname, '../../packages/ui/src')
    }
  }
}

// 方式2:直接导入
import { Button } from '@gdu-common/ui'

Q2: 如何实现组件的在线编辑?

// 集成 @vue/repl
import { Repl } from '@vue/repl'

// 提供在线编辑环境
<Repl :store="store" />

Q3: 如何自动生成 API 文档?

# 使用 vue-docgen-api
pnpm add -D vue-docgen-api

# 解析组件生成 JSON
# 渲染成 Markdown 表格

Q4: 文档部署后 404 怎么办?

// 配置正确的 base
export default defineConfig({
  base: '/', // 根目录
  // 或
  base: '/docs/', // 子目录

  cleanUrls: true, // 干净的 URL
})

🎁 完整示例

目录结构

docs/
├── .vitepress/
│   ├── config.ts
│   ├── theme/
│   │   ├── index.ts
│   │   ├── components/
│   │   │   ├── DemoBlock.vue
│   │   │   └── Playground.vue
│   │   └── custom.css
│   └── plugins/
│       └── demo-container.ts
├── components/
│   ├── button/
│   │   ├── index.md
│   │   ├── basic.vue
│   │   ├── types.vue
│   │   └── loading.vue
│   └── index.md
├── guide/
│   ├── index.md
│   └── getting-started.md
├── public/
│   ├── logo.svg
│   └── favicon.ico
├── index.md
└── package.json

🎉 总结

VitePress 文档站点的核心价值:

技术实现

  • ✅ VitePress 配置和自定义主题
  • ✅ 交互式组件示例
  • ✅ 自动代码提取和高亮
  • ✅ SEO 和性能优化

实际效果

  • 📖 专业级文档体验
  • ⚡ 4.3s 构建,1.2s 加载
  • 🎨 美观的 UI 设计
  • 📱 完美的响应式

关键配置

pnpm doc:dev     # 开发
pnpm doc:build   # 构建
pnpm doc:preview # 预览

下一篇文章,我将分享 CI/CD 自动化流程,包括:

  • GitLab CI 配置
  • 自动化测试
  • 自动化发布
  • Docker 镜像构建

🔗 系列文章


文档站点搭建完成!觉得有帮助的话点个赞支持一下! 👍

你的文档站点用的什么工具?有什么优化技巧?评论区交流! 💬

版本管理实战:Changeset 工作流完全指南(含中英文对照)

2025年10月20日 16:52

本文是《从零到一:构建现代化企业级 Monorepo 项目实战》系列的第五篇。前面我们搭建了项目和代码质量工具,这篇文章将讲解如何优雅地管理多包版本和发布流程。

🎯 本文目标

读完这篇文章,你将学会:

  • Changeset 的工作原理和优势
  • 完整的版本发布流程
  • 语义化版本控制实践
  • CHANGELOG 自动生成技巧
  • Changeset 中英文交互对照

📖 Monorepo 版本管理的挑战

传统方式的问题

# 场景:需要同时发布 3 个相关的包

# 😫 方式1:手动修改版本号
vim packages/shared/package.json    # 1.0.0 → 1.0.1
vim packages/utils/package.json     # 1.0.0 → 1.0.1
vim packages/ui/package.json        # 1.0.0 → 1.0.1

# 😫 方式2:使用 lerna
lerna version patch
# 所有包都升级,即使有些包没有改动

# 😫 方式3:使用 standard-version
pnpm exec standard-version
# 只能基于 commit 判断,不够灵活

理想的版本管理

# ✅ 期望的效果:
1. 灵活选择要更新的包
2. 自动处理依赖关系
3. 生成清晰的 CHANGELOG
4. 一键发布所有变更

🚀 Changeset 工作流

核心概念

Changeset = 变更集合

一个 Changeset 包含:
1. 哪些包需要更新
2. 每个包的版本类型(major/minor/patch)
3. 变更描述(会出现在 CHANGELOG 中)

工作流程图

graph LR
    A[开发代码] --> B[添加 Changeset]
    B --> C[提交代码]
    C --> D[准备发布]
    D --> E[执行 version]
    E --> F[更新版本号]
    F --> G[生成 CHANGELOG]
    G --> H[构建项目]
    H --> I[发布到 NPM]
    I --> J[推送到远程]

📦 安装和配置

1. 安装 Changeset

pnpm add -Dw @changesets/cli
pnpm changeset init

2. 配置文件

// .changeset/config.json
{
  "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
  "changelog": "@changesets/cli/changelog",
  "commit": false,
  "access": "public",
  "baseBranch": "master",
  "updateInternalDependencies": "patch",
  "ignore": ["@gdu-common/docs", "@gdu-common/build-config"]
}

配置说明:

字段 说明 推荐值
changelog CHANGELOG 生成器 @changesets/cli/changelog
commit 是否自动提交 false(手动控制更好)
access NPM 发布权限 publicrestricted
baseBranch 主分支名称 mastermain
ignore 不需要发布的包 文档、构建配置等

3. 添加脚本

// package.json
{
  "scripts": {
    "changeset:add": "pnpm changeset add",
    "changeset:version": "pnpm changeset version",
    "changeset:publish": "pnpm build && pnpm changeset publish",
    "changeset:status": "pnpm changeset status",
    "uv": "pnpm changeset && pnpm changeset version"
  }
}

🎬 完整发布流程实战

场景:修复了一个 Bug

# 1. 修改代码
vim packages/ui/src/components/Button/button.vue

# 2. 添加 Changeset
pnpm changeset add

Changeset 交互流程(中英文对照)

步骤1:选择包

🦋 Which packages would you like to include?
   选择要更新的包

( ) @gdu-common/shared
( ) @gdu-common/utils
(●) @gdu-common/ui          # 空格选中
( ) @gdu-common/controls-sdk

→ 操作:空格键选择,回车确认

步骤2:选择 Major(主版本)

🦋 Which packages should have a major bump?
   选择需要主版本更新的包(不兼容的 API 变更)

( ) all packages
( ) @gdu-common/ui@1.0.0

→ 操作:如果不是破坏性变更,直接回车跳过

步骤3:选择 Minor(次版本)

🦋 Which packages should have a minor bump?
   选择需要次版本更新的包(新功能)

( ) all packages
( ) @gdu-common/ui@1.0.0

→ 操作:如果不是新功能,直接回车跳过

步骤4:Patch(补丁版本)

🦋 The following packages will be patch bumped:
   以下包将进行补丁更新(Bug 修复)

@gdu-common/ui@1.0.0

→ 说明:未选 major/minor 的包会自动归到 patch

步骤5:输入变更描述

🦋 Please enter a summary for this change
   请输入变更说明(会出现在 CHANGELOG 中)

Summary » 修复 Button 组件点击事件bug

→ 操作:输入中文描述,回车确认

步骤6:确认

🦋 === Summary of changesets ===
🦋 patch:  @gdu-common/ui

🦋 Is this your desired changeset? (Y/n)
   这是你期望的变更吗?

→ 操作:输入 Y 或直接回车

生成的 Changeset 文件

## <!-- .changeset/funny-elephants-dance.md -->

## "@gdu-common/ui": patch

修复 Button 组件点击事件bug

提交 Changeset

git add .changeset/funny-elephants-dance.md
git commit -m "docs: 添加变更记录"
git push

🔄 更新版本号

执行 version 命令

pnpm changeset version

自动执行的操作:

🦋 Running version command...

# 1. 读取所有 changeset 文件
📝 Found 1 changeset:
   - funny-elephants-dance.md

# 2. 更新 package.json
📦 @gdu-common/ui: 1.0.0 → 1.0.1

# 3. 更新依赖此包的其他包
📦 @gdu-common/docs (dev): @gdu-common/ui@1.0.0 → 1.0.1

# 4. 生成 CHANGELOG
📝 Generating CHANGELOG.md for @gdu-common/ui

# 5. 删除已处理的 changeset 文件
🗑️ Removing .changeset/funny-elephants-dance.md

✅ Version bump complete!

生成的 CHANGELOG

<!-- packages/ui/CHANGELOG.md -->

# @gdu-common/ui

## 1.0.1

### Patch Changes

- 修复 Button 组件点击事件bug

提交版本更新

git add .
git commit -m "chore: 更新版本到 1.0.1"
git push

📦 发布到 NPM

构建并发布

# 1. 构建所有包
pnpm build

# 2. 发布到 NPM
pnpm changeset publish

自动执行:

🦋 Publishing packages to npm...

📦 @gdu-common/ui@1.0.1
✅ Published successfully!

🏷️ Creating git tag...
✅ Tag v1.0.1 created

🚀 Pushing tag to remote...
✅ Tag pushed

🎉 All packages published!

推送代码

git push --follow-tags

🎯 版本类型详解

Major - 主版本(破坏性变更)

示例:修改组件 API

// ❌ 旧版本 1.0.0
<Button type="primary">点击</Button>

// ✅ 新版本 2.0.0
<Button variant="primary">点击</Button>  // type 改成 variant

// 版本:1.0.0 → 2.0.0

Minor - 次版本(新功能)

示例:添加新属性

// 旧版本 1.0.0
<Button>点击</Button>

// 新版本 1.1.0
<Button loading>点击</Button>  // ✨ 新增 loading 属性

// 版本:1.0.0 → 1.1.0
// 向后兼容,不影响旧代码

Patch - 补丁版本(Bug 修复)

示例:修复 Bug

// 旧版本 1.0.0 - 有 Bug
const handleClick = () => {
  emit('click') // 😱 忘记传递 event
}

// 新版本 1.0.1 - 修复
const handleClick = event => {
  emit('click', event) // ✅ 修复
}

// 版本:1.0.0 → 1.0.1

💡 高级用法

1. 预发布版本(Alpha/Beta)

# 进入预发布模式
pnpm changeset pre enter alpha

# 添加变更
pnpm changeset add

# 更新版本
pnpm changeset version
# 生成:1.0.0 → 1.0.1-alpha.0

# 继续添加变更
pnpm changeset add
pnpm changeset version
# 生成:1.0.1-alpha.0 → 1.0.1-alpha.1

# 退出预发布模式
pnpm changeset pre exit

# 最终发布
pnpm changeset version
# 生成:1.0.1-alpha.1 → 1.0.1

2. 批量更新多个包

pnpm changeset add

# 选择多个包
🦋 Which packages would you like to include?
(●) @gdu-common/shared    # 空格选中
(●) @gdu-common/utils     # 空格选中
(●) @gdu-common/ui        # 空格选中

3. 依赖自动更新

# ui 依赖 utils
{
  "dependencies": {
    "@gdu-common/utils": "workspace:^"
  }
}

# 当 utils 更新到 1.1.0 时
# ui 的 CHANGELOG 会自动记录:

## 1.0.5

### Patch Changes

- Updated dependencies
  - @gdu-common/utils@1.1.0

4. 查看即将发布的内容

# 查看状态
pnpm changeset status

# 输出
🦋 Changeset status

This branch has:
- 2 changesets that will bump packages

Packages to be bumped:
@gdu-common/ui: patch
@gdu-common/utils: minor

# 预览版本更新(不实际执行)
pnpm changeset version --dry-run

🔧 实际项目配置

package.json 配置

{
  "name": "@gdu-common/ui",
  "version": "1.2.3",
  "publishConfig": {
    "registry": "http://jfrog.gdu-tech.com/artifactory/api/npm/gdu-npm-front/",
    "access": "public"
  },
  "repository": {
    "type": "git",
    "url": "https://gitlab.gdu-tech.com/front-group/template/gdu-common.git",
    "directory": "packages/ui"
  }
}

发布脚本优化

// 根 package.json
{
  "scripts": {
    "uv": "pnpm changeset && pnpm changeset version",
    "publish:all": "pnpm build && pnpm changeset publish",
    "push": "git push --follow-tags"
  }
}

简化流程:

# 一键添加变更并更新版本
pnpm uv

# 一键构建并发布
pnpm publish:all

# 推送
pnpm push

📝 CHANGELOG 管理

自动生成的 CHANGELOG

# @gdu-common/ui

## 1.2.0

### Minor Changes

- 添加 Button 组件的 loading 状态
- 支持自定义图标

### Patch Changes

- 修复 Input 组件的清除按钮样式问题
- Updated dependencies
  - @gdu-common/utils@1.1.0

## 1.1.0

### Minor Changes

- 新增 Input 组件
- 新增 Modal 组件

### Patch Changes

- 修复 Button 组件在 Safari 下的样式问题

CHANGELOG 最佳实践

# ✅ 好的变更描述

- 修复 Button 组件在暗色模式下的对比度问题
- 添加 Input 组件的键盘导航支持
- 优化 Modal 组件的动画性能

# ❌ 不好的变更描述

- 修复 bug
- 更新组件
- 改进代码

🎯 实战演练:完整发布流程

场景:开发了一个新功能

# ===== 第1步:开发完成 =====
# 添加了 Button 组件的 loading 状态

# ===== 第2步:添加 Changeset =====
pnpm changeset add

🦋 Which packages would you like to include?
(●) @gdu-common/ui

🦋 Which packages should have a minor bump?
(●) @gdu-common/ui@1.1.0  # 新功能选 minor

🦋 Summary » 添加 Button 组件的 loading 状态

🦋 Is this your desired changeset? (Y/n) » Y

✅ Changeset added!

# ===== 第3步:提交 Changeset =====
git add .changeset/
git commit -m "docs: 添加变更记录"
git push

# ===== 第4步:准备发布 =====
# 所有功能开发完成,准备发布

# ===== 第5步:更新版本号 =====
pnpm changeset version

🦋 All files have been updated and committed

# 查看变更
git diff HEAD~1

# packages/ui/package.json
- "version": "1.1.0"
+ "version": "1.2.0"

# packages/ui/CHANGELOG.md 新增内容
+ ## 1.2.0
+ ### Minor Changes
+ - 添加 Button 组件的 loading 状态

# ===== 第6步:提交版本更新 =====
git add .
git commit -m "chore: 发布 v1.2.0"
git push

# ===== 第7步:构建 =====
pnpm build

Tasks:    4 successful, 4 total
Cached:   3 cached, 4 total
  Time:   2.1s

# ===== 第8步:发布到 NPM =====
pnpm changeset publish

🦋 Publishing packages to npm...
📦 @gdu-common/ui@1.2.0
✅ Published

# ===== 第9步:推送标签 =====
pnpm push

✅ 发布完成!

🎨 高级技巧

1. 一次更新多个包

# 场景:utils 添加了新函数,ui 使用了这个函数

pnpm changeset add

🦋 Which packages would you like to include?
(●) @gdu-common/utils     # utils 新增功能
(●) @gdu-common/ui        # ui 使用新功能

🦋 Which packages should have a minor bump?
(●) @gdu-common/utils@1.0.0  # utils minor

🦋 Which packages should have a patch bump?
(●) @gdu-common/ui@1.1.0     # ui patch(只是使用,不是新功能)

🦋 Summary »
工具库新增 formatCurrency 函数
UI 组件使用新的货币格式化工具

2. 依赖自动升级

// .changeset/config.json
{
  "updateInternalDependencies": "patch"
}

效果:

# utils 升级到 1.1.0
# ui 依赖 utils,自动升级一个 patch 版本

@gdu-common/utils: 1.0.0 → 1.1.0 (minor)
@gdu-common/ui: 1.0.0 → 1.0.1 (patch, 因为依赖更新)

3. 快捷命令

{
  "scripts": {
    "uv": "pnpm changeset && pnpm changeset version"
  }
}
# 一个命令完成添加和更新
pnpm uv

# 交互完成后,自动执行 version
# 减少操作步骤!

📊 版本管理最佳实践

1. 语义化版本控制

严格遵循 SemVer:

版本格式:主版本.次版本.补丁版本

1.2.3
│ │ │
│ │ └─ Patch:Bug 修复,向后兼容
│ └─── Minor:新功能,向后兼容
└───── Major:破坏性变更,不向后兼容

判断标准:

变更类型 版本类型 示例
修改 API 接口 Major 删除/重命名参数
添加新组件 Minor 新增 Button 组件
修复样式bug Patch 修复按钮颜色
优化性能 Patch 优化渲染性能
添加新属性 Minor Button 新增 loading
修改内部实现 Patch 重构内部逻辑

2. 变更描述规范

✅ 好的描述:

- 添加 Button 组件的 loading 状态支持
- 修复 Input 组件在 IE11 下的兼容性问题
- 优化 Modal 组件的打开动画性能,减少 30% 的渲染时间

❌ 不好的描述:

- 更新
- 修复bug
- 改进

3. 发布前检查

# 创建 pre-publish 脚本
{
  "scripts": {
    "prepublishOnly": "pnpm lint:all && pnpm build"
  }
}

# 发布前自动执行检查和构建

🤖 自动化发布

CI/CD 集成

# .gitlab-ci.yml
version-check:
  stage: check
  script:
    - |
      if [ -d ".changeset" ] && [ "$(ls -A .changeset/*.md 2>/dev/null)" ]; then
        echo "✅ Found changeset files"
      else
        echo "⚠️ No changeset files found"
        echo "💡 Run: pnpm changeset add"
        exit 1
      fi

publish:
  stage: deploy
  only:
    - master
  script:
    - pnpm install
    - pnpm build
    - pnpm changeset publish

📈 实际效果

版本发布效率对比

方式 手动操作 Changeset 提升
修改版本号 5分钟 30秒 10x
生成 CHANGELOG 15分钟 自动
发布到 NPM 10分钟 2分钟 5x
总耗时 30分钟 3分钟 10x

版本管理准确性

# 使用 Changeset 前
- 忘记更新版本号:20% 的发布
- 版本号错误:10% 的发布
- CHANGELOG 缺失:50% 的发布

# 使用 Changeset 后
- 自动更新,100% 准确 ✅
- 自动生成 CHANGELOG ✅
- 依赖关系自动处理 ✅

🤔 常见问题

Q1: 多人协作时如何处理冲突?

场景: 两个人同时添加了 changeset

# 开发者 A
.changeset/cool-cats-sing.md
  → 更新 ui 组件

# 开发者 B
.changeset/brave-dogs-jump.md
  → 更新 utils 工具

# 合并后
pnpm changeset version
# ✅ 自动合并两个变更,生成统一的版本

Q2: 如何回滚发布?

# 1. 删除 NPM 上的版本(24小时内)
npm unpublish @gdu-common/ui@1.2.0

# 2. 删除 Git tag
git tag -d v1.2.0
git push origin :refs/tags/v1.2.0

# 3. 回滚代码
git reset --hard HEAD~1

Q3: 如何发布特定的包?

# 只发布 ui 包
pnpm changeset publish --filter @gdu-common/ui

# 或者在添加 changeset 时只选择 ui

Q4: Changeset 文件可以手动编辑吗?

## <!-- .changeset/cool-update.md -->

"@gdu-common/ui": minor
"@gdu-common/utils": patch

---

添加新功能并修复相关bug

详细说明:

- UI 组件新增 loading 状态(minor)
- 工具函数修复边界情况(patch)

可以! 手动编辑可以更精确地控制版本和描述。

🎁 快速上手清单

初始化

# 1. 安装
pnpm add -Dw @changesets/cli

# 2. 初始化
pnpm changeset init

# 3. 配置 .changeset/config.json
# 4. 添加脚本到 package.json

日常使用

# 开发功能 → 添加变更
pnpm changeset add

# 准备发布 → 更新版本
pnpm changeset version

# 发布 → 构建并发布
pnpm build && pnpm changeset publish

# 推送
git push --follow-tags

检查清单

  • .changeset/config.json 配置正确
  • baseBranch 设置为你的主分支
  • ignore 包含不需要发布的包
  • 各包的 publishConfig 配置正确
  • Git 远程仓库配置正确

🎉 总结

Changeset 提供了:

核心价值

  1. 灵活的版本控制 - 精确控制每个包的版本
  2. 自动的依赖更新 - 依赖包自动同步版本
  3. 清晰的 CHANGELOG - 自动生成,格式统一
  4. 简单的工作流 - 3个命令完成发布

实际收益

  • 📈 版本管理效率提升 10 倍
  • 🎯 版本准确性 100%
  • 📝 CHANGELOG 覆盖率 100%
  • ⏱️ 发布时间从 30分钟 → 3分钟

关键命令

pnpm changeset add      # 添加变更
pnpm changeset version  # 更新版本
pnpm changeset publish  # 发布
pnpm changeset status   # 查看状态

在下一篇文章中,我将分享 VitePress 文档站点的搭建和优化,包括:

  • 自定义主题配置
  • 组件示例自动导入
  • 交互式文档
  • SEO 和性能优化

🔗 系列文章


Changeset 真的很好用!如果你也在用或者准备用,点个赞让我知道! 👍

你的版本管理用的什么方案?有什么坑要分享?评论区见! 💬

❌
❌