普通视图

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

dify案例分享-用 Dify 一键生成教学动画 HTML!AI 助力,3 分钟搞定专业级课件

作者 wwwzhouhui
2025年10月27日 21:11

1.前言

在教育信息化的今天,如何用更生动、更直观的方式讲解抽象的知识点,一直是教育工作者面临的挑战。传统的教学课件制作往往需要教师花费大量时间学习复杂的动画制作软件,而制作出的效果又往往难以达到理想水平。

AI辅助生成精美动画HTML技术的出现,彻底改变了这一局面。它是一种结合了人工智能大语言模型和现代Web技术的创新解决方案,能够根据用户输入的教学主题,自动生成视觉精美、内容准确、交互流畅的HTML5动画页面。这些动画页面不仅可以在浏览器中直接播放,还能导出为MP4视频或GIF格式,便于在各种教学场景中使用。

img

对教育教学的深远影响

img

img

最近使用dify做了一个AI教学动画HTML网页,效果如下:

img

gif动画

bubble_sort_animation

工作流如下:

img

那么这样的工作流是如何制作的呢?下面小编带大家手把手制作这个工作流

2.工作流制作

这个工作流主要有哪些组成部分构成的呢?我们通过上面的截图就可以看出它主要有开始节点、Agent、LLM大语言模型、代码执行、直接回复组成。

img

开始

这个开始节点我们这里就没有设置用户定义的提示词,就用sys.query的提示词。配置内容如下:

img

LLM大语言模型

这个LLM大语言模型这块作用是将前面的Agent联网搜到的金句使用大语言模型生成精美的HTML 页面。

模型这里我们使用google gemini2.5pro模型。当然国内以下主要几个模型也都是可以的,比如Qwen/Qwen3-Coder-480B-A35B-Instruct、deepseek-ai/DeepSeek-V3.2-Exp 、glm4.6等

img

系统提示词

# Role: 精美动态动画生成专家

## Profile
- author: 周辉
- version: 2.0
- language: 中文
- description: 专注于生成符合2K分辨率标准的、视觉精美的、自动播放的教育动画HTML页面,确保所有元素正确布局且无视觉缺陷

## Skills
1. 精通HTML5、CSS3、JavaScript和SVG技术栈
2. 擅长响应式布局和固定分辨率容器设计
3. 熟练掌握动画时间轴编排和视觉叙事
4. 精通浅色配色方案和现代UI设计美学
5. 能够实现双语字幕和旁白式文字解说系统

## Background
用户需要生成一个完整的单文件HTML动画,用于知识点讲解。该动画必须在固定的2K分辨率容器(1280px × 720px)中完美呈现,避免任何布局错误、元素穿模或字幕遮挡问题。

## Goals
1. 生成视觉精美、设计感强的动态动画页面
2. 确保所有元素在1280px × 720px容器内正确定位
3. 实现清晰的开场、讲解过程和收尾结构
4. 提供双语字幕和旁白式解说
5. 在动画结束时插入完结标记供录制判断

## Constraints
1. 分辨率约束:所有内容必须在固定的1280px宽 × 720px高的容器内呈现
2. 视觉完整性:禁止出现元素穿模、字幕遮挡、图形位置错误
3. 技术栈:仅使用HTML + CSS + JS + SVG,不依赖外部库,资源尽量内嵌
4. 自动播放:页面加载后立即开始播放,无交互按钮
5. 单文件输出:所有资源内嵌在一个HTML文件中
6. 完结标记:动画结束时必须执行指定的JavaScript完结逻辑

## OutputFormat
请严格输出以下结构的完整HTML文档,并使用代码块包裹(```html 开头,``` 结尾):
```html
<!DOCTYPE html>
<html lang=\"zh-CN\">
<head>
  <meta charset=\"UTF-8\"> 
  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">
  <title>{{{{主题标题}}}}</title>
  <style>
    /* 确保容器固定为2K分辨率 */
    :root {{
      --bg: #f6f7fb;
      --panel: #ffffff;
      --text: #223;
      --accent: #4a90e2;
      --sub: #7b8ba8;
    }}
    html, body {{ height: 100%; }}
    body {{
      margin: 0; padding: 0; display: flex; justify-content: center; align-items: center;
      min-height: 100vh; background: var(--bg); overflow: hidden; color: var(--text);
      font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', Roboto, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
    }}
    #animation-container {{
      position: relative; width: 1280px; height: 720px; background: var(--panel); overflow: hidden;
      box-shadow: 0 0 50px rgba(0,0,0,0.08); border-radius: 20px;
    }}
    /* 建议的字幕区域(底部居中,150-200px 高) */
    .subtitles {{ position: absolute; left: 0; right: 0; bottom: 40px; height: 180px; display: flex; align-items: center; justify-content: center; pointer-events: none; }}
    .subtitles .line {{
      background: rgba(255,255,255,0.85); color: #111; border-radius: 12px; padding: 18px 24px; font-size: 40px; line-height: 1.3; max-width: 80%; text-align: center; box-shadow: 0 8px 24px rgba(0,0,0,.08);
    }}
    /* 其他样式... */
  </style>
  </head>
  <body>
    <div id=\"animation-container\">
      <!-- 在此放置SVG/图形/讲解元素,确保关键视觉位于中心区域的60-70%,保留20-30px安全边距 -->
      <div class=\"subtitles\"><div id=\"sub-cn\" class=\"line\"></div></div>
      <div class=\"subtitles\" style=\"bottom: 240px;\"><div id=\"sub-en\" class=\"line\"></div></div>
    </div>
    <script>
      // 动画逻辑示例:请在此实现开场(5-10s)  讲解(30-60s)  收尾(5-10s) 的时间轴
      // 并确保元素动画流畅、无穿模,与字幕同步。

      function setSubtitles(cn, en) {{
        const cnEl = document.getElementById('sub-cn');
        const enEl = document.getElementById('sub-en');
        if (cnEl) cnEl.textContent = cn || '';
        if (enEl) enEl.textContent = en || '';
      }}

      // 动画结束时的完结标记(必须包含)
      function markAnimationFinished() {{
        try {{
          window.playFinished = true;
          window.dispatchEvent(new Event('recording:finished'));
          var flag = document.createElement('div');
          flag.id = 'finished-flag';
          flag.style.display = 'none';
          document.body.appendChild(flag);
        }} catch (e) {{ /* no-op */ }}
      }}

      // 请在最后一个动画结束后调用 markAnimationFinished();
      // markAnimationFinished();
    </script>
  </body>
</html>
```

## Workflows
1. 接收主题:获取用户指定的知识点主题(本次主题:{{#sys.query#}})。
2. 结构规划:设计开场(5-10秒)→ 核心讲解(30-60秒)→ 收尾(5-10秒)的时间轴。
3. 视觉设计:选择和谐浅色配色,精准布局到 1280×720 容器,字幕区域底部居中。
4. 动画编排:用CSS动画/JS控制时间轴,保证流畅与无穿模,字幕与视觉同步。
5. 完结逻辑:在最后一个动画完成后必须调用 markAnimationFinished()。
6. 质量检查:元素不越界、字幕不遮挡关键视觉、配色和谐易读。
7. 输出交付:仅输出完整单文件HTML,并用 ```html 代码块包裹。

## Suggestions
1. 使用CSS Grid或Flexbox精确控制1280×720容器内的布局。
2. 字幕字号建议32-48px,确保2K分辨率下清晰可读。
3. 关键视觉元素应占据容器中心区域的60-70%。
4. 使用CSS变量统一管理配色方案。
5. 动画总时长建议40-90秒。
6. 关键内容保持20-30px安全边距,防止溢出。

## Output Rule
- 仅输出完整、可直接保存为 .html 的单文件源码。
- 必须使用 ```html 代码块包裹;不得输出说明文字或多余内容。

用户提示词

请根据用户输入的信息{{#1750772708073.text#}}生成HTML代码

img

代码处理

客户端代码,这里我们使用我自己搭建的服务端代码来实现html页面的生成的。 有的小伙伴可能会问了。怎么不用腾讯的EdgeOne Pages 实现静态HTML 部署呢? 这里我们使用大模型部署MCP 主要是慢。这里为了节约时间就用代码直接生成处理了。这个处理代码生成大概在1秒就可以完成,如果用大模型至少要10秒以上时间。

输入参数 json_html 输入值 上个LLM大模型输出

apikey 和apiurl 是我们定义的远程调用服务端代码的apikey 和apiurl

我们在环境变量里面设置

img

apiurl:http://14.103.204.132:8080/generate-html/

apikey:sk-zhouhuixxx

这个有小伙伴问过 这个apikey从哪来的,这个是我们服务端代码自己定义的。

关于服务端代码部署发布和使用可以看我之前的文章dify案例分享-探秘:AI 怎样颠覆财报分析,打造酷炫 HTML 可视化

重点看代码处理生成html调用 这部分

img

服务端代码可以从我开源项目中github.com/wwwzhouhui/… 获取

客户端代码如下

import json
import re
import time
import requests

def main(json_html: str, apikey: str,apiurl: str) -> dict:
    try:
        # 去除输入字符串中的 ```html 和 ``` 标记
        match = re.search(r'```html\s*([\s\S]*?)```', json_html, re.DOTALL)
        
        if match:
            # group(1) 获取第一个捕获组的内容,即纯HTML代码
            # .strip() 去除可能存在的前后空白
            html_content = match.group(1).strip()
        else:
            # 如果在输入中找不到HTML代码块,则返回错误
            raise ValueError("未能在输入中找到 ```html ... ``` 代码块。")
        
        # 生成时间戳,确保文件名唯一
        timestamp = int(time.time())
        filename = f"makehtml_{timestamp}.html"
        
        # API端点(假设本地运行)
        url = f"{apiurl}"
        
        # 请求数据
        payload = {
            "html_content": html_content,
            "filename": filename  # 使用传入的文件名
        }
        
        # 设置请求头(包含认证token)
        headers = {
            "Authorization": f"Bearer {apikey}",  # 替换为实际的认证token
            "Content-Type": "application/json"
        }
        
        try:
            # 发送POST请求
            response = requests.post(url, json=payload, headers=headers)
            
            # 检查响应状态
            if response.status_code == 200:
                result = response.json()
                html_url = result.get("html_url", "")
                generated_filename = result.get("filename", "")
                
                # 返回结果
                return {
                    "html_url": html_url,
                    "filename": generated_filename,
                    "markdown_result":  f"[点击查看]({html_url})"
                }
            else:
                raise Exception(f"HTTP Error: {response.status_code}, Message: {response.text}")
        
        except requests.exceptions.RequestException as e:
            raise Exception(f"Request failed: {str(e)}")
    
    except Exception as e:
        return {
            "error": f"Error: {str(e)}"
        }

输入变量 我这里设置三个html_url、filename、markdown_result 返回的变量类型是string

img

img

直接回复

这个直接回复我们这里输出2个值,一个是代码执行返回的URL ,一个是URL markdown地址

img

以上我们就完成了工作流的制作。

3.验证及测试

我们打开工作流预览按钮。聊天窗口中输入如下提示词

冒泡排序

img

工作流执行完成后我们看到生成的HTML 页面链接

我们点击查看,现在链接。

img

保存本地打开。

img

大家也可以打开我这个链接地址下载本地直观的感受。

dify-1258720957.cos.ap-nanjing.myqcloud.com/makehtml_17…

有的小伙伴就说这个只是一个冒泡排序算法,其他的内容支持不支持呢?这个当然是可以的。

我这里有个开源项目github.com/wwwzhouhui/…

img

你可以基于各种数学、物理、化学、生物、计算机原理等生成各种科普类视频,该项目涵盖了小学、初中、高中、大学各种原理理解。

上面的开源项目除了生成html页面外还能基于html生成视频方便教学使用。感兴趣的小伙伴可以关注我这个开源项目。

dify体验地址difyhs.duckcloud.fun/chat/SPtLwd…

dify备用地址(http://14.103.204.132/chat/SPtLwdDGZkVSjrln)

4.总结

今天主要带大家了解并实现了基于 Dify 工作流构建 AI 辅助教学动画 HTML 页面生成工具的完整流程,该流程以 Google Gemini 2.5 Pro 等大语言模型为核心,结合 Dify 工作流的可视化编排优势与自定义服务端接口的支持,形成了一套从教学主题输入到精美动画 HTML 页面生成的完整解决方案,能够快速产出符合 2K 分辨率标准、具备双语字幕和自动播放功能的教育动画内容。

感兴趣的小伙伴可以通过文中提供的 Dify 工作流体验地址直接试用,也可以参考工作流配置进行自定义扩展开发,基于开源项目进一步实现视频生成等进阶功能。今天的分享就到这里结束了,我们下一篇文章见。

如何处理管理系统中(Vue PC + uni-app 移动端):业务逻辑复用基本方案

2025年10月27日 17:07

本文主要从以下几个方面入手:

  1. API请求、

  2. 状态管理、

  3. 工具函数

  4. 路由

  5. 包管理工具

一、整体设计:分层解耦,复用优先

核心思路是  “业务逻辑抽离为公共层,两端仅保留平台特有逻辑” ,整体架构分为 5 层,从下到上分别为:

├─ 1. 基础工具层(utils):纯函数工具,两端完全复用
├─ 2. API 通信层(api):统一请求逻辑,适配两端差异
├─ 3. 状态管理层(store):核心业务状态,两端共享
├─ 4. 业务逻辑层(services):封装业务方法,两端复用
└─ 5. 视图层(components/views):平台特有组件/页面,差异化实现

核心原则

  1. 公共逻辑 “上提” :API、状态、工具函数等无平台依赖的逻辑,抽离到独立包 / 目录,避免两端重复编写;
  2. 平台差异 “下沉” :UI 组件、路由、原生能力(如扫码、推送)等平台特有逻辑,在两端单独实现,通过 “适配层” 对接公共逻辑;
  3. 依赖统一管理:统一包管理工具(如 npm),公共依赖(如 axios、lodash)在两端共享版本,避免兼容性问题。

二、具体技术方案:分层实现复用

1. 基础工具层(utils):100% 复用,纯函数设计

核心目标

提供无副作用的纯函数工具,覆盖格式化、验证、计算等通用能力,两端完全复用。

实现方案

  • 目录结构:在项目根目录创建 packages/utils(或独立 npm 包),按功能分类:

    utils/
    ├─ format/:时间格式化(dateFormat)、金额格式化(moneyFormat)
    ├─ validate/:手机号验证(isPhone)、邮箱验证(isEmail)、表单规则(formRules)
    ├─ compute/:分页计算(calcPageInfo)、权限判断(hasPermission)
    └─ common/:深拷贝(deepClone)、防抖节流(debounce/throttle)
    
  • 代码示例(时间格式化工具):

    // packages/utils/format/dateFormat.js
    export const dateFormat = (date, format = 'YYYY-MM-DD HH:mm:ss') => {
      // 纯函数实现,无平台依赖
      const dt = new Date(date);
      const options = {
        'YYYY': dt.getFullYear(),
        'MM': String(dt.getMonth() + 1).padStart(2, '0'),
        // ... 其他格式化逻辑
      };
      return Object.entries(options).reduce((res, [key, val]) => res.replace(key, val), format);
    };
    
  • 复用方式:两端通过 import { dateFormat } from '@/utils/format/dateFormat' 直接引入,无需修改。

2. API 通信层(api):统一请求逻辑,适配两端差异

核心目标

封装 API 请求逻辑(请求拦截、响应拦截、错误处理),统一接口调用方式,仅适配两端的请求库差异(Vue 用 axios,uni-app 用 uni.request)。

实现方案

  • 分层设计:分为 “基础请求适配层” 和 “业务接口层”,前者处理平台差异,后者纯业务逻辑复用:

    api/
    ├─ request/:基础请求适配(区分 Vue/uni-app)
    │  ├─ index.js:入口文件(根据环境导出对应请求实例)
    │  ├─ webRequest.js:Vue 端(基于 axios)
    │  └─ uniRequest.js:uni-app 端(基于 uni.request)
    └─ modules/:业务接口(两端完全复用)
       ├─ user.js:用户相关(登录、权限)
       ├─ order.js:订单相关(列表、详情)
       └─ goods.js:商品相关(新增、编辑)
    
  • 关键实现:请求适配层(处理平台差异):

    // api/request/webRequest.js(Vue 端)
    import axios from 'axios';
    const service = axios.create({
      baseURL: import.meta.env.VITE_API_BASE_URL, // PC 端环境变量
      timeout: 10000
    });
    // 请求拦截:添加 token
    service.interceptors.request.use(config => {
      config.headers.token = localStorage.getItem('token');
      return config;
    });
    // 响应拦截:统一错误处理
    service.interceptors.response.use(
      res => res.data,
      err => { /* 统一错误提示(如 Element Plus Message) */ }
    );
    export default service;
    
    // api/request/uniRequest.js(uni-app 端)
    export default function uniRequest(config) {
      return new Promise((resolve, reject) => {
        uni.request({
          url: config.baseURL || import.meta.env.VITE_API_BASE_URL, // 移动端环境变量
          method: config.method || 'GET',
          data: config.data,
          header: { token: uni.getStorageSync('token') }, // uni-app 存储 API
          success: res => resolve(res.data),
          fail: err => { /* 统一错误提示(如 uni.showToast) */ }
        });
      });
    }
    
    // api/request/index.js(入口:自动适配环境)
    let request;
    if (process.env.VUE_APP_PLATFORM === 'web') {
      request = import('./webRequest').then(m => m.default);
    } else {
      request = import('./uniRequest').then(m => m.default);
    }
    export default request;
    
  • 业务接口层(纯逻辑复用,无平台依赖):

    // api/modules/user.js(两端完全复用)
    import request from '../request';
    
    // 登录接口
    export const login = (params) => request({
      url: '/api/user/login',
      method: 'POST',
      data: params
    });
    
    // 获取用户权限列表
    export const getUserPermissions = () => request({
      url: '/api/user/permissions',
      method: 'GET'
    });
    

3. 状态管理层(store):核心业务状态共享

核心目标

用 Vuex/Pinia 管理全局状态(如用户信息、权限、全局配置),两端共享状态定义和 mutations/actions,仅适配平台特有存储(如 token 存储)。

实现方案

  • 技术选型:优先用 Pinia(Vue 3 推荐,支持 TypeScript,更轻量,如果项目的搭建时不要求ts,推荐vuex),两端共用 Pinia 实例。

  • 目录结构

    store/
    ├─ index.js:Pinia 实例创建(适配两端存储)
    ├─ modules/
    │  ├─ userStore.js:用户状态(登录、权限)
    │  ├─ appStore.js:应用配置(主题、语言)
    │  └─ orderStore.js:订单状态(待办数量、筛选条件)
    
  • 关键实现:适配两端存储

    // store/index.js(Pinia 实例创建)
    import { createPinia } from 'pinia';
    import { createPersistedState } from 'pinia-plugin-persistedstate'; // 持久化插件
    
    const pinia = createPinia();
    
    // 适配两端持久化存储:Vue 用 localStorage,uni-app 用 uniStorage
    const storage = process.env.VUE_APP_PLATFORM === 'web' 
      ? window.localStorage 
      : {
          getItem: uni.getStorageSync,
          setItem: uni.setStorageSync,
          removeItem: uni.removeStorageSync
        };
    
    // 安装持久化插件,指定存储方式
    pinia.use(createPersistedState({
      storage: {
        getItem: (key) => storage.getItem(key),
        setItem: (key, value) => storage.setItem(key, value),
        removeItem: (key) => storage.removeItem(key)
      }
    }));
    
    export default pinia;
    
  • 业务状态示例(用户状态,两端复用):

    // store/modules/userStore.js
    import { defineStore } from 'pinia';
    import { login, getUserPermissions } from '@/api/modules/user';
    import { hasPermission } from '@/utils/compute/permission';
    
    export const useUserStore = defineStore('user', {
      state: () => ({
        token: '',
        info: {}, // 用户信息
        permissions: [] // 权限列表
      }),
      actions: {
        // 登录:业务逻辑完全复用
        async loginAction(params) {
          const res = await login(params);
          this.token = res.token;
          await this.getPermissionsAction(); // 登录后获取权限
        },
        // 获取权限:业务逻辑完全复用
        async getPermissionsAction() {
          const res = await getUserPermissions();
          this.permissions = res.list;
        },
        // 权限判断:复用工具函数
        hasPerm(perm) {
          return hasPermission(this.permissions, perm);
        }
      },
      persist: true // 持久化状态(适配两端存储)
    });
    

4. 业务逻辑层(services):封装复杂业务流程

核心目标

将跨组件的复杂业务流程(如表单提交、数据导出、批量操作)抽离为 service 方法,两端复用业务逻辑,仅调用平台特有 UI 交互(如弹窗、加载提示)。

实现方案

  • 目录结构:按业务模块分类,每个 service 方法接收 “平台适配回调” 处理 UI 差异:

    plaintext

    services/
    ├─ userService.js:用户相关(密码重置、信息修改)
    ├─ orderService.js:订单相关(批量审核、导出订单)
    └─ formService.js:表单相关(复杂表单提交、数据校验)
    
  • 代码示例(订单批量审核,适配两端 UI):

    // services/orderService.js
    import { useOrderStore } from '@/store/modules/orderStore';
    import { batchAuditOrder } from '@/api/modules/order';
    
    /**
     * 批量审核订单
     * @param {Array} orderIds - 订单ID列表
     * @param {Function} loadingCallback - 平台加载提示(如 Vue 的 ElLoading、uni 的 showLoading)
     * @param {Function} successCallback - 平台成功提示(如 ElMessage、uni.showToast)
     */
    export const batchAuditOrderService = async (orderIds, loadingCallback, successCallback) => {
      const orderStore = useOrderStore();
      // 1. 调用平台加载提示(通过回调适配)
      const closeLoading = loadingCallback();
      
      try {
        // 2. 业务逻辑:调用 API + 更新状态(两端复用)
        await batchAuditOrder({ ids: orderIds });
        await orderStore.getOrderListAction(); // 重新获取订单列表
        // 3. 调用平台成功提示(通过回调适配)
        successCallback('审核成功');
      } catch (err) {
        throw err; // 抛错让调用方处理(如统一错误提示)
      } finally {
        closeLoading(); // 关闭加载提示
      }
    };
    
  • 两端调用示例

    // Vue PC 端调用
    import { batchAuditOrderService } from '@/services/orderService';
    import { ElLoading, ElMessage } from 'element-plus';
    
    const handleBatchAudit = async () => {
      await batchAuditOrderService(
        selectedIds,
        () => ElLoading.service({ text: '审核中...' }), // PC 加载提示
        (msg) => ElMessage.success(msg) // PC 成功提示
      );
    };
    
    // uni-app 移动端调用
    import { batchAuditOrderService } from '@/services/orderService';
    
    const handleBatchAudit = async () => {
      await batchAuditOrderService(
        selectedIds,
        () => { // 移动端加载提示
          uni.showLoading({ title: '审核中...' });
          return () => uni.hideLoading(); // 返回关闭方法
        },
        (msg) => uni.showToast({ title: msg, icon: 'success' }) // 移动端成功提示
      );
    };
    

5. 视图层:差异化实现,复用公共组件

核心目标

UI 组件和页面因平台交互差异(PC 用鼠标 / 键盘,移动端用触摸)需单独实现,但可抽离 “无交互纯展示组件”(如数据卡片、空状态)两端复用。

实现方案

  • 公共 UI 组件:在 components/common 目录创建纯展示组件(无平台依赖),如:

    components/
    ├─ common/:两端复用组件
    │  ├─ EmptyState.vue:空状态(无数据提示)
    │  ├─ DataCard.vue:数据卡片(展示统计数据)
    │  └─ TablePagination.vue:分页控件(适配两端表格)
    ├─ web/:Vue PC 特有组件(如 ElTable 封装)
    └─ uni/:uni-app 特有组件(如 uni-table 封装)
    
  • 页面差异化:两端页面目录分开,但复用公共业务逻辑:

    // Vue PC 端页面
    views/web/order/OrderList.vue:用 Element Plus 组件,调用 orderService
    // uni-app 移动端页面
    pages/uni/order/OrderList.vue:用 uni-app 组件,调用 same orderService
    

三、工程化保障:统一规范,降低维护成本

1. 环境配置统一

  • 用 .env 文件统一管理环境变量(API 地址、环境标识),两端共享变量名:

    // Vue PC 端 .env.development
    VITE_API_BASE_URL = 'https://pc-api.xxx.com'
    VITE_APP_PLATFORM = 'web'
    
    // uni-app 端 .env.development
    VITE_API_BASE_URL = 'https://mobile-api.xxx.com'
    VITE_APP_PLATFORM = 'uni'
    

2. 代码规范统一

  • 用 ESLint + Prettier 统一代码风格,两端共用配置文件(.eslintrc.js.prettierrc);
  • 用 Husky + lint-staged 做提交校验,避免不规范代码提交。

3. 构建部署统一

  • Vue PC 端:用 Vite 构建,部署到 Web 服务器(如 Nginx);
  • uni-app 端:用 HBuilderX 或 CLI 构建,发布为微信小程序 / APP;
  • 公共层(utils/api/store)可打包为 npm 私有包,两端通过 npm 安装,避免代码复制。

腾讯地图 SDK 接入到 uniapp 的多端解决方案

作者 liusheng
2025年10月27日 17:03

qqmap-uniapp:腾讯地图SDK的现代化解决方案

基于腾讯官方微信小程序JavaScript SDK v1.2,专为现代化开发环境打造的ES模块版本

背景:为什么需要这个包?

在开发基于 Vue3 + Vite 的 uni-app 项目时,笔者遇到了两个关键问题:

问题1:模块格式不兼容

腾讯官方提供的 qqmap-wx-jssdk.min.js 是一个 CommonJS 格式的文件,而现代构建工具如 Vite、Webpack 5+ 默认使用 ES Module(ESM) 格式。

// ❌ 官方SDK使用CommonJS格式
var QQMapWX = require('qqmap-wx-jssdk.min.js');

// ✅ Vue3 + Vite需要ESM格式
import QQMapWX from 'qqmap-uniapp';

问题表现:

  • Vite 无法正确解析 module.exports,导致导入失败
  • 编译错误:require is not defined or ReferenceError: Can't find variable: require __ERROR

问题2:平台限制过于严格

官方SDK只支持微信小程序环境,使用了 wx.request 进行网络请求,这限制了其在其他平台的使用:

// 官方SDK中只使用wx.request
wx.request({
  url: 'https://apis.map.qq.com/ws/...',
  success: function(res) { /* ... */ }
});

在 uni-app 跨平台开发中,我们需要使用 uni.request 来实现一套代码多端运行。

问题表现:

  • 无法在 H5、APP、支付宝小程序等平台使用
  • 被迫使用条件编译,增加了维护成本

qqmap-uniapp 的解决方案

核心特性

  1. ESM 支持

    • 完整的 ES Module 格式,支持 import/export 语法
    • 与 Vite、Webpack 5+ 等现代构建工具完美兼容
    • 支持 Tree-shaking,减少打包体积
  2. 跨平台兼容

    • 使用 uni.request 替代 wx.request
    • 支持 uni-app 的所有平台:H5、小程序、APP
    • 保持与官方 API 完全一致的接口
  3. API 完全兼容

    • 基于腾讯官方 v1.2 版本
    • 8 个核心方法全部保留:
      • search() - 地点搜索
      • getSuggestion() - 关键词输入提示
      • reverseGeocoder() - 逆地址解析
      • geocoder() - 地址解析
      • direction() - 路线规划
      • getCityList() - 获取城市列表
      • getDistrictByCityId() - 获取城市区县
      • calculateDistance() - 距离计算

使用示例

保持与腾讯官方 API 示例完全一致

// 示例和参数与原版完全相同:
const qqmapsdk = new QQMapWX({
  key: 'YOUR_KEY'
});

// 使用方法完全一致
qqmapsdk.search({
  keyword: '酒店',
  location: '39.908823,116.397470',
  success: function(res) {
    console.log(res);
  }
});

在 uni-app 项目中使用

// 1. 安装
npm install qqmap-uniapp

// 2. 引入
import QQMapWX from 'qqmap-uniapp';

// 3. 初始化
const qqmapsdk = new QQMapWX({
  key: '您的开发者密钥'
});

// 4. 使用API
onMounted(() => {
  // 地点搜索
  qqmapsdk.search({
    keyword: '酒店',
    location: '39.908823,116.397470',
    success: (res) => {
      console.log('搜索结果:', res);
    },
    fail: (err) => {
      console.error('搜索失败:', err);
    }
  });
});

在 Vue3 + Vite 项目中使用

查看更多实际用法示例,欢迎访问案例仓库:demo 代码 & 预览(GitHub)

路线规划示例转存失败,建议直接上传图片文件
路线规划示例(qqmap-direction.vue)
地点搜索示例转存失败,建议直接上传图片文件
地点搜索示例(qqmap-search.vue)

对比:官方 SDK vs qqmap-uniapp

特性 官方SDK qqmap-uniapp
模块格式 CommonJS ES Module
导入方式 require() import
构建工具支持 需要额外配置 开箱即用
平台支持 仅微信小程序 uni-app 全平台
网络请求 wx.request uni.request
Tree-shaking
Vite 兼容
体积优化 - 支持按需引入
现代 JS 语法 旧语法 现代语法

如果您正在使用 Vue3 + Vite 或 uni-app 开发项目,需要集成腾讯地图功能,欢迎尝试 qqmap-uniapp

相关链接

浏览器连接 新北洋BTP-P33/P32蓝牙打印机,打印 二维码

作者 Zsnoin能
2025年10月27日 16:55

浏览器连接 新北洋BTP-P33/P32蓝牙打印机,打印 二维码

  1. 连接全程用的就是浏览器原生 Web Bluetooth API,没有任何第三方库,这个api存在一定的兼容问题,谨慎使用。
  2. 转码库 gbk.js ,一个小而快的GBK库,支持浏览器,有中文的话,需要转码,BTP-P33/P32这个型号不支持UTF-8,不转换成 GBK 格式,中文打印出来就是乱码。

结合ai和网上扒的部分代码实现的,懒得介绍各个模块,交给ai分析,完整代码移步至最后。

1、蓝牙连接相关

1.1 蓝牙连接(打印机连接)

  1. 函数入口

    • 命名:getBluetooth,异步函数,负责完成一次完整的「选设备 → 连 GATT → 缓存服务」流程。
  2. 连接锁

    • 先调用 closeBluetooth() 把上一次可能未断开的设备强制释放。
    • isConnecting.value 做布尔锁,防止用户多次点击造成并发请求。
  3. 浏览器兼容性检查

    • 判断全局是否存在 navigator.bluetooth,没有就弹错误通知并直接返回。
  4. 第一次设备选择(带过滤器)

    • 通过 requestDevice 只列出名字或前缀符合的打印机:
      – 精确名:BTP-P32BTP-P33
      – 前缀:BTPPrinter
    • 指定 optionalServices 为后续打印指令所需 UUID 数组。
    • acceptAllDevices: false 强制走过滤器,减少用户误选。
  5. 提前注册断开监听

    • 给选中的 device 绑定 gattserverdisconnected 事件,一旦硬件断电或超出范围可立即触发 onDisconnected 回调,方便 UI 状态同步。
  6. 成功反馈

    • 把设备对象写入 ConnectedBluetooth.value,供后续打印逻辑使用。
    • 弹通知告诉用户「设备已选择」并显示设备名。
  7. 预拉服务/特征值

    • 调用 discoverServicesAndCharacteristics(device) 一次性拿到所有服务和特征并缓存,减少真正打印时的往返延迟。
  8. 第一次异常处理(firstError

    • NotFoundError:用户没看到任何匹配设备 → 弹 confirm 询问「是否放宽条件显示所有蓝牙设备」。
      – 若用户点「确定」则第二次调用 requestDevice,这次 acceptAllDevices: true,过滤器失效。
      – 若二次选择仍失败,弹「未找到任何蓝牙设备」。
    • NotAllowedError:用户拒绝权限 → 提示「请允许浏览器访问蓝牙」。
    • 其他错误 → 统一弹「连接失败」并输出具体 message
  9. 连接锁释放

    • 无论成功还是任何分支的异常,都在 finally 里把 isConnecting.value 重置为 false,保证下次可重新点击。
  10. 整体特点

    • 采用「先精确后宽泛」两步走策略,兼顾易用性与兼容性。
    • 所有耗时/异步操作都放在 try 内,用户侧只有「选择弹窗」会阻塞,其余异常均友好提示。
    • 通过「断开监听 + 预缓存服务」让后续打印阶段只需纯粹写特征值,缩短真正出纸时间。
    // 蓝牙连接逻辑(增加连接状态锁定)
    const getBluetooth = async () => {
        // 先断开已有连接
        await closeBluetooth();
        if (isConnecting.value) return;
        isConnecting.value = true;

        let device;
        try {
            // @ts-ignore
            if (!navigator.bluetooth) {
                notification.error({
                    message: '浏览器不支持',
                    description: '请使用Chrome、Edge等支持Web Bluetooth API的浏览器'
                });
                return;
            }

            // 优先过滤打印机设备
            //@ts-ignore
            device = await navigator.bluetooth.requestDevice({
                filters: [
                    { name: 'BTP-P32' },
                    { name: 'BTP-P33' },
                    { namePrefix: 'BTP' },
                    { namePrefix: 'Printer' } // 通用打印机名称前缀
                ],
                optionalServices: possibleServices,
                acceptAllDevices: false
            });

            // 监听设备断开事件
            device.addEventListener('gattserverdisconnected', onDisconnected);

            notification.success({
                message: '设备已选择',
                description: `名称:${device.name || '未知设备'}`
            });
            ConnectedBluetooth.value = device;

            // 提前获取服务和特征值,减少打印时的耗时
            await discoverServicesAndCharacteristics(device);
        } catch (firstError: any) {
            if (firstError.name === 'NotFoundError') {
                const userConfirm = confirm(
                    '未找到指定打印机,是否显示所有蓝牙设备?\n' +
                    '提示:请确保打印机已开启并处于可配对状态'
                );
                if (userConfirm) {
                    try {
                        // @ts-ignore
                        device = await navigator.bluetooth.requestDevice({
                            acceptAllDevices: true,
                            optionalServices: possibleServices
                        });
                        device.addEventListener('gattserverdisconnected', onDisconnected);
                        ConnectedBluetooth.value = device;
                        await discoverServicesAndCharacteristics(device);
                        notification.success({
                            message: '设备已选择',
                            description: `名称:${device.name || '未知设备'}`
                        });
                    } catch (e) {
                        notification.error({ message: '选择设备失败', description: '未找到任何蓝牙设备' });
                    }
                }
            } else if (firstError.name === 'NotAllowedError') {
                notification.error({
                    message: '权限被拒绝',
                    description: '请允许浏览器访问蓝牙设备'
                });
            } else {
                notification.error({
                    message: '连接失败',
                    description: firstError.message || '未知错误'
                });
            }
        } finally {
            isConnecting.value = false;
        }
    }
    
    
        // 断开连接处理
    const onDisconnected = (event: any) => {
        const device = event.target;
        notification.warning({
            message: '设备已断开',
            description: `${device.name || '蓝牙设备'}连接已丢失`
        });
        ConnectedBluetooth.value = null;
        currentService.value = null;
        currentCharacteristic.value = null;
    };
    
        // 断开连接逻辑
    const closeBluetooth = async () => {
        try {
            if (ConnectedBluetooth.value) {
                if (ConnectedBluetooth.value.gatt.connected) {
                    await ConnectedBluetooth.value.gatt.disconnect();
                }
                notification.success({
                    message: '断开成功',
                    description: `${ConnectedBluetooth.value?.name || '设备'}已断开`
                });
            }
            ConnectedBluetooth.value = null;
            currentService.value = null;
            currentCharacteristic.value = null;
        } catch (error) {
            notification.error({
                message: '断开失败',
                description: '无法断开蓝牙连接'
            });
        }
    }

    
       // 发现设备支持的服务和特征值(动态探测)
    const discoverServicesAndCharacteristics = async (device: any) => {
        try {
            const server = await device.gatt.connect();
            const services = await server.getPrimaryServices();

            // 遍历所有服务,寻找支持的特征值
            for (const service of services) {

                const characteristics = await service.getCharacteristics();
                for (const characteristic of characteristics) {
                    // 检查特征值是否可写入
                    const properties = characteristic.properties;
                    if (properties.write || properties.writeWithoutResponse) {
                        currentService.value = service;
                        currentCharacteristic.value = characteristic;
                        notification.success({
                            message: `服务:${characteristic.uuid}`,
                            description: `特征值:${characteristic.uuid}`,
                        })
                        return true; // 找到可用的特征值后退出
                    }
                }
            }

            // 如果没有找到预设的特征值,提示用户
            notification.warning({
                message: '未找到打印特征值',
                description: '设备可能不支持打印功能'
            });
            return false;
        } catch (error) {
            console.error('发现服务失败:', error);
            return false;
        }
    };

    // 连接并获取打印特征值(增加重试机制)
    const connectAndPrint = async (retries = 2) => {
        if (!ConnectedBluetooth.value) {
            notification.error({ message: '未连接蓝牙设备' });
            return null;
        }

        try {
            // 检查当前连接状态
            if (!ConnectedBluetooth.value.gatt.connected) {
                await ConnectedBluetooth.value.gatt.connect();
                // 重新发现服务
                await discoverServicesAndCharacteristics(ConnectedBluetooth.value);
            }

            if (currentCharacteristic.value) {
                return currentCharacteristic.value;
            }

            // 如果之前没有发现特征值,再次尝试
            const success = await discoverServicesAndCharacteristics(ConnectedBluetooth.value);
            return success ? currentCharacteristic.value : null;
        } catch (error) {
            console.error('连接打印服务失败:', error);
            if (retries > 0) {
                console.log(`重试连接(剩余${retries}次)`);
                return connectAndPrint(retries - 1); // 重试机制
            }
            notification.error({ message: '连接失败', description: '无法连接到打印服务' });
            return null;
        }
    };


1.2 断开逻辑(关闭打印机连接)

  1. 函数入口

    • 命名:closeBluetooth,异步函数,专门负责“干净”地释放当前已连接的蓝牙设备及其缓存对象。
  2. 空值保护

    • 先判断 ConnectedBluetooth.value 是否存在;若无,直接跳过所有断开步骤,避免空对象报错。
  3. GATT 连接状态二次确认

    • 再判断 ConnectedBluetooth.value.gatt.connected 是否为 true
      – 仅当仍处于连接状态才调用 .disconnect(),防止对“已断设备”重复操作。
    • 使用 await 等待 disconnect 完成,确保底层链路真正释放。
  4. 成功反馈

    • 一旦 disconnect 成功(或设备本来就未连接),立即弹通知告诉用户「××设备已断开」。
  5. 清空全局缓存

    • 把三个核心响应式变量全部置空:
      ConnectedBluetooth.value = null(设备对象)
      currentService.value = null(上次缓存的服务)
      currentCharacteristic.value = null(上次缓存的特征值)
    • 保证下次连接时不会误用旧引用。
  6. 异常兜底

    • 整个流程包在 try…catch 内:
      – 若 disconnect() 抛出任何异常,立即弹错误通知「无法断开蓝牙连接」,避免 UI 卡死。
    • 即使出现异常,也会执行到 finally 隐式逻辑(此处代码无显式 finally,但变量已提前置空),确保状态一致。
  7. 整体特点

    • 双重判断(存在性 + 连接态)避免冗余调用。
    • 无论成功或失败,用户都能得到明确提示。
    • 缓存清零后,后续 getBluetooth() 可安全重新连接新设备。

代码如 1.1 中断开逻辑。

2、打印相关

打印需要处理以下几个问题:

  1. 打印机不支持utf-8,需要进行转码处理。
  2. 使用的打印语言是 ESC/POS ,不会问题不大,可以问ai,慢慢尝试即可。
  3. ESC/POS 如何打印二维码(条形码未尝试)。
  4. 受浏览器限制,单次传输文件非常小,需要分包传输打印。

下面把“打印链路”上的 4 个核心函数按 “输入-处理-输出” 逐条拆开,让你一眼看懂它们各自在 “拼指令 → 拆包 → 写特征值 → 批量调度” 中的角色与边界。


1. breakLine

作用:按 GBK 字节长度 硬截断长字符串,保证每行绝对不超过打印机纸宽。
输入

  • str:任意中文/英文/符号混合字符串
  • maxByte:默认 36 字节(18 个汉字,36 个 ASCII)

处理

  • 逐字符 iconvLite.encode(ch, 'gbk') 计算真实字节数(1 或 2)
  • 累加字节,超上限就切一行,继续累加
  • 最后一行不足上限也单独 push

输出

  • string[]:每行都保证 byte ≤ maxByte,数组空时返回 [''],避免后续逻辑空指针

2. buildOneLabel(it: printData)

作用:把 一条业务数据 变成 一张完整标签的 ESC/POS 字节流(二维码+文字+对齐+走纸+切刀)。
输入it 里含物料名称、料号、规格、工单号、数量、人员、二维码内容、打印份数等字段

关键步骤

  1. 二维码指令

    • gbk(it.qrcode) 算出实际字节长度 qrLen
    • 按 ESC/POS 手册拼 4 段命令
      • 存储二维码数据 → 选择模型 → 设置模块大小 → 打印
    • 最终得到 qrCmd: Uint8Array
  2. 文字区

    • 物料名称可能超长 → 用 breakLine(...,36) 切成 1~2 行
    • 固定字段:料号、规格、工单号、总量、排程数、人员
    • 每行末尾手动加 \n 或双 \n 增大行间距
  3. 二维码下方居中文字

    • 再次 gbk(it.qrcode+'\n') 供人眼核对
  4. 拼总指令

    • 0x1b 0x40 初始化打印机
    • 0x1b 0x74 0x01 指定 GBK 内码表
    • 文字 → 居中 → 二维码 → 加粗 → 下方文字 → 走纸 8 行 → 切纸
    • 全部展开成 单条 Uint8Array,后续直接丢给蓝牙特征值

输出Uint8Array——一张标签的“原子”打印数据包


3. writeLarge(char, data: Uint8Array)

作用:把 “任意长度” 的 ESC/POS 指令安全地拆包写进蓝牙特征值,避免 MTU 溢出。
输入

  • char:Web Bluetooth 特征值对象
  • data:单张标签完整字节流(通常 400~800 字节)

处理

  • 244 字节 静态切片(兼容常见 247 MTU,留 3 byte 协议头)
  • 优先使用 writeValueWithoutResponse(速度快,无回包)
    • 若特征值不支持,则退回到 writeValue(有回包,稍慢)
  • 每写完一块 sleep 20 ms ——给打印机缓存/蓝牙控制器喘息,防止丢包

输出:无返回值;全部块写完后函数退出
异常:若特征值两种写模式都不支持,直接抛 Error 强制中断上层循环


4. print(list: printData[])

作用批量调度器——把 N 条业务数据 × 每条 printNum 份,顺序送进打印机,并实时反馈进度/异常。
输入list 数组,每个元素含业务字段 + printNum(打印份数)

执行流程

  1. 设备检查

    • 当前无连接 → 自动调用 getBluetooth() 让用户选设备
    • 拿到特征值 char(内部 connectAndPrint() 负责发现服务/特征并返回)
  2. 生成总队列

    • 双重循环:list.forEach × for(printNum)
    • 每份调用 buildOneLabel(it) 得到 Uint8Array,压入 queue 数组
    • 结果:queue.length = Σ(printNum),顺序即最终出纸顺序
  3. 顺序写打印机

    • 遍历 queue,下标从 0 开始
    • 每写一张:
      • await writeLarge(char, queue[idx])
      • UI 弹 notification.info 显示进度 “第 idx+1 / 总张数”
      • 机械延迟 400 ms(等走纸、切刀完成,再发下一张)
  4. 异常策略

    • 任意一张失败(蓝牙断开、写特征值抛错)→ 立即弹错误通知并 return终止整个批次
    • 用户需检查纸张/电量后手动重打
  5. 完成提示

    • 全部 queue 写完统一弹 notification.success:“批量打印完成”

一张图总结链路

业务数据 printData
     ↓  buildOneLabel
单张 ESC/POS 字节流
     ↓  writeLarge(244 字节切片)
蓝牙特征值
     ↓  print 调度器循环
纸张 / 二维码 / 文字 / 切刀

以上 4 个函数分工明确、耦合度低:

  • breakLine 只关心“截断”
  • buildOneLabel 只关心“拼指令”
  • writeLarge 只关心“拆包写特征值”
  • print 只关心“队列+进度+异常”

后续想换打印机、改纸宽、改二维码大小,只需在对应函数内部调整即可,不会牵一发动全身。

export type printData = {
    mitemName: string, // 物料名称
    mitemCode: string, // 物料编码
    spec: string, // 规格
    mo: string, // 工单号
    num: number, // 总量
    scheduleNum: number,// 排程数量
    user: string, // 打印人
    qrcode: string, // 二维码
    printNum: number, // 打印次数
}

    /** 按字节长度截断(GBK 一个汉字 2 字节) */
    function breakLine(str: string, maxByte: number = 36): string[] {
        const lines: string[] = [];
        let buf = '';
        let byte = 0;
        for (const ch of str) {
            const sz = iconvLite.encode(ch, 'gbk').length; // 1 或 2
            if (byte + sz > maxByte) {                 // 超了先存
                lines.push(buf);
                buf = ch;
                byte = sz;
            } else {
                buf += ch;
                byte += sz;
            }
        }
        if (buf) lines.push(buf);
        return lines.length ? lines : [''];
    }

    // 新增:单张指令生成器(根据业务字段拼 ESC/POS)
    const buildOneLabel = (it: printData) => {
        const gbk = (str: string) => {
            // iconv-lite 直接返回 Uint8Array,无需额外处理
            return iconvLite.encode(str, 'gbk');
        };


        /* ---------- 二维码 ---------- */
        const qrBytes = gbk(it.qrcode);
        const qrLen = qrBytes.length;
        const qrCmd = new Uint8Array([
            0x1d, 0x28, 0x6b, qrLen + 3, 0x00, 0x31, 0x50, 0x30, ...qrBytes,
            0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x45, 0x30,
            0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x43, 0x08,
            0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x51, 0x30
        ]);

        const nameLines = breakLine(it.mitemName, 36); // 36 字节 ≈ 18 汉字
        /* ---------- 文字(每行末尾加2个换行符加大间距) ---------- */
        let text = '';
        if (nameLines.length > 1) {
            text = [
                ` \n`,
                ` 物料名称:${nameLines[0]}`,
                ` ${nameLines[1]}`,
                ` 料号:${it.mitemCode}\n`,
                ` 规格:${it.spec}\n`,
                ` 工单号:${it.mo}\n`,
                ` 工单总量:${it.num}\n`,
                ` 排程数量:${it.scheduleNum}\n`,
                ` 人员:${it.user}\n`,
                ` \n`,
                ` \n`,
            ].join('\n');
        } else {
            text = [
                ` \n`,
                ` 物料名称:${it.mitemName}\n`,
                ` 料号:${it.mitemCode}\n`,
                ` 规格:${it.spec}\n`,
                ` 工单号:${it.mo}\n`,
                ` 工单总量:${it.num}\n`,
                ` 排程数量:${it.scheduleNum}\n`,
                ` 人员:${it.user}\n`,
                ` \n`,
                ` \n`,
            ].join('\n');
        }


        /* ---------- 二维码下方文字(居中显示) ---------- */
        const qrContentText = gbk(`${it.qrcode}\n`);

        return new Uint8Array([
            ...[0x1b, 0x40],               // 初始化
            ...[0x1b, 0x74, 0x01],         // 选择GBK编码
            ...gbk(text),                  // 打印文字(已加大行间距)
            ...[0x1b, 0x61, 0x01],         // 文字居中对齐
            ...qrCmd,                      // 打印二维码
            ...gbk('\n'),                  // 二维码与文字之间加一个换行
            ...[0x1b, 0x45, 0x01],         // 加粗
            ...qrContentText,              // 打印二维码内容文字
            ...[0x1b, 0x64, 0x08],        // 走纸8行(比原来多2行,适配新增文字)
            ...[0x1b, 0x69]                // 切纸(无刀可删)
        ]);
    };

    /* ---------- 大数据分包 ---------- */
    const writeLarge = async (char: any, data: Uint8Array) => {
        const chunk = 244;
        // 检查特征值是否支持writeWithoutResponse
        if (char.properties.writeWithoutResponse) {
            for (let i = 0; i < data.length; i += chunk) {
                await char.writeValueWithoutResponse(data.slice(i, i + chunk));
                await new Promise(r => setTimeout(r, 20)); // 蓝牙喘息
            }
        } else if (char.properties.write) {
            for (let i = 0; i < data.length; i += chunk) {
                await char.writeValue(data.slice(i, i + chunk));
                await new Promise(r => setTimeout(r, 20)); // 蓝牙喘息
            }
        } else {
            throw new Error('特征值不支持写入操作');
        }

    };

    /* ---------- 批量打印(新 print) ---------- */
    const print = async (list: printData[]) => {
        if (!ConnectedBluetooth.value) return getBluetooth();
        const char = await connectAndPrint();
        if (!char) return;

        // 1. 生成总队列:每条数据重复 printNum 次
        const queue: Uint8Array[] = [];
        list.forEach(it => {
            for (let i = 0; i < it.printNum; i++) queue.push(buildOneLabel(it));
        });

        // 2. 顺序打印
        for (let idx = 0; idx < queue.length; idx++) {
            try {
                await writeLarge(char, queue[idx]);
                notification.info({
                    message: `进度 ${idx + 1}/${queue.length}`,
                    description: `正在打印:${list.find(it => it.printNum > 0)?.mitemCode}`
                });
                await new Promise(r => setTimeout(r, 400)); // 等机械完成
            } catch (e) {
                notification.error({
                    message: `打印中断`,
                    description: `第 ${idx + 1} 张失败:${e}`
                });
                return; // 立即停止
            }
        }
        notification.success({ message: '批量打印完成' });
    };

踩坑:

  1. 根据打印机支持什么格式的数据,一定要转码。
  2. 写入的时候需要检测支持什么方法,不然就可能出现和我一样的问题,上周还能正常打印,这周就打印不了,ai没发现文图,自己查半天才发现是写入方法不支持了,这太离谱,为啥突然不支持了也不知道原因。
  3. 分包啥的ai就能完成,问题不大。

检测支持的方法:

    /* ---------- 大数据分包 ---------- */
    const writeLarge = async (char: any, data: Uint8Array) => {
        const chunk = 244;
        // 检查特征值是否支持writeWithoutResponse
        if (char.properties.writeWithoutResponse) {
            for (let i = 0; i < data.length; i += chunk) {
                await char.writeValueWithoutResponse(data.slice(i, i + chunk));
                await new Promise(r => setTimeout(r, 20)); // 蓝牙喘息
            }
        } else if (char.properties.write) {
            for (let i = 0; i < data.length; i += chunk) {
                await char.writeValue(data.slice(i, i + chunk));
                await new Promise(r => setTimeout(r, 20)); // 蓝牙喘息
            }
        } else {
            throw new Error('特征值不支持写入操作');
        }
    };

3、打印效果

IMG_20251027_164238.jpg

原数据没问题的,图中马赛克为敏感数据手动打码。

4、完整代码

  1. 完整代码,导出为单列,共用一个蓝牙连接服务,你在这里连接成功了,在别的地方只要没有断开连接,直接传入数据打印即可,无需重复连接。
  2. 导出 print, getBluetooth, ConnectedBluetooth ,打印函数,蓝牙连接,蓝牙信息。打印函数内部已经自动做判断了,直接打印也行,会自动判断是否需要连接,在外部做更精细的判断也行,根据业务需要调整。
  3. 无UI界面,直接调用即可。
  4. 这是我遇到过比较复杂的一个需求了,大家看情况调整吧,对你的打印机不一定适用。

完整代码如下:


import { notification } from "ant-design-vue";
import { ref } from "vue";
import iconvLite from 'gbk.js'; // 引入编码库
export type printData = {
    mitemName: string, // 物料名称
    mitemCode: string, // 物料编码
    spec: string, // 规格
    mo: string, // 工单号
    num: number, // 总量
    scheduleNum: number,// 排程数量
    user: string, // 打印人
    qrcode: string, // 二维码
    printNum: number, // 打印次数
}

const BluetoothModule = () => {

    // 蓝牙服务和特征值UUID配置(优先尝试常见打印服务)
    const possibleServices = [
        '00001101-0000-1000-8000-00805f9b34fb', // SPP服务(最常用)
        '0000ffe0-0000-1000-8000-00805f9b34fb',
        '49535343-fe7d-4ae5-8fa9-9fafd205e455',
        '6e400001-b5a3-f393-e0a9-e50e24dcca9e',
        '49535343-1e4d-4bd9-ba61-23c647249616'
    ];

    const possibleCharacteristics = [
        '0000ffe1-0000-1000-8000-00805f9b34fb',
        '0000ff01-0000-1000-8000-00805f9b34fb',
        '49535343-8841-43f4-a8d4-ecbe34729bb3',
        '6e400002-b5a3-f393-e0a9-e50e24dcca9e',
        '0000ffe1-0000-1000-8000-00805f9b34fb'
    ];

    const ConnectedBluetooth: any = ref(null)
    const currentService: any = ref(null)
    const currentCharacteristic: any = ref(null)
    const isConnecting: any = ref(false)


    // 蓝牙连接逻辑(增加连接状态锁定)
    const getBluetooth = async () => {
        // 先断开已有连接
        await closeBluetooth();
        if (isConnecting.value) return;
        isConnecting.value = true;

        let device;
        try {
            // @ts-ignore
            if (!navigator.bluetooth) {
                notification.error({
                    message: '浏览器不支持',
                    description: '请使用Chrome、Edge等支持Web Bluetooth API的浏览器'
                });
                return;
            }

            // 优先过滤打印机设备
            //@ts-ignore
            device = await navigator.bluetooth.requestDevice({
                filters: [
                    { name: 'BTP-P32' },
                    { name: 'BTP-P33' },
                    { namePrefix: 'BTP' },
                    { namePrefix: 'Printer' } // 通用打印机名称前缀
                ],
                optionalServices: possibleServices,
                acceptAllDevices: false
            });

            // 监听设备断开事件
            device.addEventListener('gattserverdisconnected', onDisconnected);

            notification.success({
                message: '设备已选择',
                description: `名称:${device.name || '未知设备'}`
            });
            ConnectedBluetooth.value = device;

            // 提前获取服务和特征值,减少打印时的耗时
            await discoverServicesAndCharacteristics(device);
        } catch (firstError: any) {
            if (firstError.name === 'NotFoundError') {
                const userConfirm = confirm(
                    '未找到指定打印机,是否显示所有蓝牙设备?\n' +
                    '提示:请确保打印机已开启并处于可配对状态'
                );
                if (userConfirm) {
                    try {
                        // @ts-ignore
                        device = await navigator.bluetooth.requestDevice({
                            acceptAllDevices: true,
                            optionalServices: possibleServices
                        });
                        device.addEventListener('gattserverdisconnected', onDisconnected);
                        ConnectedBluetooth.value = device;
                        await discoverServicesAndCharacteristics(device);
                        notification.success({
                            message: '设备已选择',
                            description: `名称:${device.name || '未知设备'}`
                        });
                    } catch (e) {
                        notification.error({ message: '选择设备失败', description: '未找到任何蓝牙设备' });
                    }
                }
            } else if (firstError.name === 'NotAllowedError') {
                notification.error({
                    message: '权限被拒绝',
                    description: '请允许浏览器访问蓝牙设备'
                });
            } else {
                notification.error({
                    message: '连接失败',
                    description: firstError.message || '未知错误'
                });
            }
        } finally {
            isConnecting.value = false;
        }
    }

    // 断开连接处理
    const onDisconnected = (event: any) => {
        const device = event.target;
        notification.warning({
            message: '设备已断开',
            description: `${device.name || '蓝牙设备'}连接已丢失`
        });
        ConnectedBluetooth.value = null;
        currentService.value = null;
        currentCharacteristic.value = null;
    };

    // 断开连接逻辑
    const closeBluetooth = async () => {
        try {
            if (ConnectedBluetooth.value) {
                if (ConnectedBluetooth.value.gatt.connected) {
                    await ConnectedBluetooth.value.gatt.disconnect();
                }
                notification.success({
                    message: '断开成功',
                    description: `${ConnectedBluetooth.value?.name || '设备'}已断开`
                });
            }
            ConnectedBluetooth.value = null;
            currentService.value = null;
            currentCharacteristic.value = null;
        } catch (error) {
            notification.error({
                message: '断开失败',
                description: '无法断开蓝牙连接'
            });
        }
    }

    // 发现设备支持的服务和特征值(动态探测)
    const discoverServicesAndCharacteristics = async (device: any) => {
        try {
            const server = await device.gatt.connect();
            const services = await server.getPrimaryServices();

            // 遍历所有服务,寻找支持的特征值
            for (const service of services) {

                const characteristics = await service.getCharacteristics();
                for (const characteristic of characteristics) {
                    // 检查特征值是否可写入
                    const properties = characteristic.properties;
                    if (properties.write || properties.writeWithoutResponse) {
                        currentService.value = service;
                        currentCharacteristic.value = characteristic;
                        notification.success({
                            message: `服务:${characteristic.uuid}`,
                            description: `特征值:${characteristic.uuid}`,
                        })
                        return true; // 找到可用的特征值后退出
                    }
                }
            }

            // 如果没有找到预设的特征值,提示用户
            notification.warning({
                message: '未找到打印特征值',
                description: '设备可能不支持打印功能'
            });
            return false;
        } catch (error) {
            console.error('发现服务失败:', error);
            return false;
        }
    };

    // 连接并获取打印特征值(增加重试机制)
    const connectAndPrint = async (retries = 2) => {
        if (!ConnectedBluetooth.value) {
            notification.error({ message: '未连接蓝牙设备' });
            return null;
        }

        try {
            // 检查当前连接状态
            if (!ConnectedBluetooth.value.gatt.connected) {
                await ConnectedBluetooth.value.gatt.connect();
                // 重新发现服务
                await discoverServicesAndCharacteristics(ConnectedBluetooth.value);
            }

            if (currentCharacteristic.value) {
                return currentCharacteristic.value;
            }

            // 如果之前没有发现特征值,再次尝试
            const success = await discoverServicesAndCharacteristics(ConnectedBluetooth.value);
            return success ? currentCharacteristic.value : null;
        } catch (error) {
            console.error('连接打印服务失败:', error);
            if (retries > 0) {
                console.log(`重试连接(剩余${retries}次)`);
                return connectAndPrint(retries - 1); // 重试机制
            }
            notification.error({ message: '连接失败', description: '无法连接到打印服务' });
            return null;
        }
    };

    /** 按字节长度截断(GBK 一个汉字 2 字节) */
    function breakLine(str: string, maxByte: number = 36): string[] {
        const lines: string[] = [];
        let buf = '';
        let byte = 0;
        for (const ch of str) {
            const sz = iconvLite.encode(ch, 'gbk').length; // 1 或 2
            if (byte + sz > maxByte) {                 // 超了先存
                lines.push(buf);
                buf = ch;
                byte = sz;
            } else {
                buf += ch;
                byte += sz;
            }
        }
        if (buf) lines.push(buf);
        return lines.length ? lines : [''];
    }

    // 新增:单张指令生成器(根据业务字段拼 ESC/POS)
    const buildOneLabel = (it: printData) => {
        const gbk = (str: string) => {
            // iconv-lite 直接返回 Uint8Array,无需额外处理
            return iconvLite.encode(str, 'gbk');
        };


        /* ---------- 二维码 ---------- */
        const qrBytes = gbk(it.qrcode);
        const qrLen = qrBytes.length;
        const qrCmd = new Uint8Array([
            0x1d, 0x28, 0x6b, qrLen + 3, 0x00, 0x31, 0x50, 0x30, ...qrBytes,
            0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x45, 0x30,
            0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x43, 0x08,
            0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x51, 0x30
        ]);

        const nameLines = breakLine(it.mitemName, 36); // 36 字节 ≈ 18 汉字
        /* ---------- 文字(每行末尾加2个换行符加大间距) ---------- */
        let text = '';
        if (nameLines.length > 1) {
            text = [
                ` \n`,
                ` 物料名称:${nameLines[0]}`,
                ` ${nameLines[1]}`,
                ` 料号:${it.mitemCode}\n`,
                ` 规格:${it.spec}\n`,
                ` 工单号:${it.mo}\n`,
                ` 工单总量:${it.num}\n`,
                ` 排程数量:${it.scheduleNum}\n`,
                ` 人员:${it.user}\n`,
                ` \n`,
                ` \n`,
            ].join('\n');
        } else {
            text = [
                ` \n`,
                ` 物料名称:${it.mitemName}\n`,
                ` 料号:${it.mitemCode}\n`,
                ` 规格:${it.spec}\n`,
                ` 工单号:${it.mo}\n`,
                ` 工单总量:${it.num}\n`,
                ` 排程数量:${it.scheduleNum}\n`,
                ` 人员:${it.user}\n`,
                ` \n`,
                ` \n`,
            ].join('\n');
        }


        /* ---------- 二维码下方文字(居中显示) ---------- */
        const qrContentText = gbk(`${it.qrcode}\n`);

        return new Uint8Array([
            ...[0x1b, 0x40],               // 初始化
            ...[0x1b, 0x74, 0x01],         // 选择GBK编码
            ...gbk(text),                  // 打印文字(已加大行间距)
            ...[0x1b, 0x61, 0x01],         // 文字居中对齐
            ...qrCmd,                      // 打印二维码
            ...gbk('\n'),                  // 二维码与文字之间加一个换行
            ...[0x1b, 0x45, 0x01],         // 加粗
            ...qrContentText,              // 打印二维码内容文字
            ...[0x1b, 0x64, 0x08],        // 走纸8行(比原来多2行,适配新增文字)
            ...[0x1b, 0x69]                // 切纸(无刀可删)
        ]);
    };

    /* ---------- 大数据分包 ---------- */
    const writeLarge = async (char: any, data: Uint8Array) => {
        const chunk = 244;
        // 检查特征值是否支持writeWithoutResponse
        if (char.properties.writeWithoutResponse) {
            for (let i = 0; i < data.length; i += chunk) {
                await char.writeValueWithoutResponse(data.slice(i, i + chunk));
                await new Promise(r => setTimeout(r, 20)); // 蓝牙喘息
            }
        } else if (char.properties.write) {
            for (let i = 0; i < data.length; i += chunk) {
                await char.writeValue(data.slice(i, i + chunk));
                await new Promise(r => setTimeout(r, 20)); // 蓝牙喘息
            }
        } else {
            throw new Error('特征值不支持写入操作');
        }
    };

    /* ---------- 批量打印(新 print) ---------- */
    const print = async (list: printData[]) => {
        if (!ConnectedBluetooth.value) return getBluetooth();
        const char = await connectAndPrint();
        if (!char) return;

        // 1. 生成总队列:每条数据重复 printNum 次
        const queue: Uint8Array[] = [];
        list.forEach(it => {
            for (let i = 0; i < it.printNum; i++) queue.push(buildOneLabel(it));
        });

        // 2. 顺序打印
        for (let idx = 0; idx < queue.length; idx++) {
            try {
                await writeLarge(char, queue[idx]);
                notification.info({
                    message: `进度 ${idx + 1}/${queue.length}`,
                    description: `正在打印:${list.find(it => it.printNum > 0)?.mitemCode}`
                });
                await new Promise(r => setTimeout(r, 400)); // 等机械完成
            } catch (e) {
                notification.error({
                    message: `打印中断`,
                    description: `第 ${idx + 1} 张失败:${e}`
                });
                return; // 立即停止
            }
        }
        notification.success({ message: '批量打印完成' });
    };

    return {
        print,
        getBluetooth,
        ConnectedBluetooth,
    }
}

// utils/Bluetooth.ts 末尾
const bluetoothInstance = BluetoothModule()
export default () => bluetoothInstance   // 永远返回同一个

2025前端AI开发实战范式:RAG+私有库落地指南

作者 trsoliu
2025年10月27日 16:52

2025前端AI开发实战范式:RAG+私有库落地指南

前言

随着人工智能技术的快速发展,前端开发正在经历一场革命性的变革。传统的开发模式已经无法满足现代应用对智能化、个性化的需求。本文将深入探讨最新的前端AI编程范式,以RAG(检索增强生成)结合私有组件库为核心,为开发者提供一套完整的实战落地指南。

一、前端AI开发新范式概述

1.1 传统开发模式的局限性

传统的前端开发依赖于静态代码和预定义的交互逻辑,面临以下挑战:

  • 用户体验缺乏个性化
  • 无法实时适应用户需求变化
  • 开发效率提升遇到瓶颈
  • 组件复用性和智能化程度不高

1.2 AI驱动的新开发范式

新的开发范式具备以下特点:

  • 智能化组件生成:基于用户需求自动生成UI组件
  • 上下文感知:理解应用场景和用户意图
  • 动态适应:实时调整界面和交互逻辑
  • 知识驱动:利用私有知识库提升开发效率

二、RAG技术在前端开发中的应用

2.1 RAG架构核心概念

RAG(Retrieval-Augmented Generation)通过结合检索和生成两个步骤,显著提升了AI系统的准确性和实用性:

// RAG架构示例
class FrontendRAGSystem {
  constructor(vectorStore, llmModel) {
    this.vectorStore = vectorStore;
    this.llmModel = llmModel;
  }
  
  async generateComponent(userQuery) {
    // 1. 检索相关组件和文档
    const relevantDocs = await this.vectorStore.search(userQuery);
    
    // 2. 构建增强提示
    const enhancedPrompt = this.buildPrompt(userQuery, relevantDocs);
    
    // 3. 生成组件代码
    const componentCode = await this.llmModel.generate(enhancedPrompt);
    
    return componentCode;
  }
}

2.2 前端RAG实现核心技术

向量化存储

  • 组件代码向量化
  • 设计规范向量化
  • 用户需求向量化

语义检索

  • 基于相似度的组件匹配
  • 多维度检索策略
  • 实时索引更新

智能生成

  • 上下文感知的代码生成
  • 样式和逻辑的协同生成
  • 响应式设计自动适配

三、私有组件库构建策略

3.1 组件库架构设计

// 私有组件库结构
interface ComponentLibrary {
  metadata: ComponentMetadata;
  components: Record<string, ComponentDefinition>;
  themes: ThemeConfiguration[];
  utilities: UtilityFunctions;
}

interface ComponentDefinition {
  name: string;
  props: PropDefinition[];
  variants: VariantDefinition[];
  usageExamples: UsageExample[];
  semanticTags: string[];
}

3.2 智能组件分类与标记

语义标记系统

  • 功能标记(表单、导航、展示等)
  • 样式标记(现代、简约、商务等)
  • 场景标记(移动端、桌面端、响应式等)

智能分类算法

  • 基于使用频率的动态分类
  • 用户行为驱动的组件推荐
  • A/B测试驱动的组件优化

3.3 组件版本管理与进化

// 组件进化策略
class ComponentEvolution {
  async evolveComponent(componentId, usageData, feedbackData) {
    const insights = this.analyzeUsagePatterns(usageData);
    const improvements = await this.generateImprovements(
      componentId, 
      insights, 
      feedbackData
    );
    
    return this.applyEvolution(componentId, improvements);
  }
}

四、实战落地案例

4.1 电商平台智能组件生成

场景描述:为电商平台快速生成商品展示组件

实现步骤

  1. 需求分析:解析"商品卡片"需求
  2. 检索匹配:从私有库检索相关组件
  3. 智能组合:基于RAG生成定制化组件
  4. 样式适配:自动适应品牌设计规范
// 生成的智能组件示例
const ProductCard = ({ product, theme, layout }) => {
  const { ai_generated_styles } = useAIStyles({
    context: 'ecommerce',
    brand: theme.brand,
    layout: layout
  });
  
  return (
    <Card className={ai_generated_styles.container}>
      <ImageOptimizer 
        src={product.image} 
        alt={product.name}
        loading="smart"
      />
      <ContentSection>
        <Title variant={ai_generated_styles.title}>
          {product.name}
        </Title>
        <Price 
          value={product.price} 
          format={ai_generated_styles.priceFormat}
        />
        <ActionButton 
          variant={ai_generated_styles.cta}
          onClick={() => addToCart(product)}
        >
          加入购物车
        </ActionButton>
      </ContentSection>
    </Card>
  );
};

4.2 企业内部管理系统快速搭建

技术栈

  • RAG引擎:基于Langchain + 私有向量库
  • 组件库:基于React + TypeScript
  • 智能生成:GPT-4 + 定制化提示工程

核心优势

  • 开发效率提升300%
  • 代码复用率达到85%
  • UI一致性得到显著改善
  • 维护成本降低50%

五、性能优化与最佳实践

5.1 RAG系统性能优化

缓存策略

  • 向量检索结果缓存
  • 生成内容智能缓存
  • 分层缓存架构

检索优化

  • 索引分片策略
  • 异步检索管道
  • 结果相关性排序算法

5.2 组件库维护最佳实践

持续集成/持续部署

  • 自动化测试覆盖
  • 组件变更影响分析
  • 渐进式发布策略

文档与示例管理

  • 自动化文档生成
  • 交互式示例平台
  • 使用统计与反馈收集

六、未来发展趋势

6.1 技术发展方向

多模态AI集成

  • 图像识别驱动的UI生成
  • 语音交互的组件控制
  • 手势识别的界面适配

边缘计算优化

  • 本地AI推理能力
  • 离线组件生成
  • 隐私保护增强

6.2 生态系统建设

开源社区贡献

  • 通用RAG框架开源
  • 最佳实践案例分享
  • 跨平台组件标准制定

产业协作

  • 设计工具深度集成
  • IDE插件生态建设
  • 云服务平台支持

总结

前端AI开发范式的变革正在加速到来。RAG+私有组件库的结合为开发者提供了强大的工具集,不仅能够显著提升开发效率,还能够创造更智能、更个性化的用户体验。

关键成功要素包括:

  1. 技术架构的合理设计:确保系统的可扩展性和维护性
  2. 数据质量的持续优化:高质量的训练数据是AI效果的基础
  3. 团队能力的全面提升:技术栈的升级需要团队技能的同步发展
  4. 用户反馈的快速迭代:持续的用户反馈是系统优化的重要驱动力

随着技术的不断成熟,我们有理由相信,AI驱动的前端开发将成为行业标准,为开发者和用户都带来更大的价值。


作者简介:资深前端架构师,专注于AI与前端技术融合创新,在RAG技术应用和智能化开发工具构建方面有丰富的实践经验。

相关资源

Syncovery Premium(文件同步软件)

作者 非凡ghost
2025年10月27日 16:49

Syncovery 是一款功能强大且用户友好的文件同步和备份工具,它提供了丰富的文件管理和同步功能,帮助用户高效地管理和保护重要数据。Syncovery 特别适合需要频繁备份和同步文件的个人用户、企业和 IT 专业人士。

软件功能
  1. 文件同步:
    双向同步:支持双向同步功能,用户可以在两个文件夹之间保持文件的一致性。
    单向同步:支持单向同步功能,用户可以将一个文件夹的内容复制到另一个文件夹。
    增量同步:支持增量同步功能,只同步发生变化的文件,提高同步效率。
    定时同步:支持定时同步功能,用户可以设置定时任务,自动进行文件同步。
  2. 文件备份:
    完全备份:支持完全备份功能,用户可以将整个文件夹或驱动器的内容备份到目标位置。
    增量备份:支持增量备份功能,只备份发生变化的文件,节省存储空间。
    差异备份:支持差异备份功能,备份自上次完全备份以来发生变化的文件。
    版本控制:支持版本控制功能,用户可以保留多个备份版本,方便恢复旧版本的文件。
  3. 文件传输:
    本地传输:支持本地文件和文件夹的快速传输。
    网络传输:支持通过 FTP、SFTP、WebDAV 等协议进行网络文件传输。
    云存储:支持多种云存储服务,如 Dropbox、Google Drive、OneDrive 等,用户可以将文件备份到云端。
  4. 文件管理:
    文件过滤:支持文件过滤功能,用户可以设置过滤规则,排除不需要同步或备份的文件。
    文件删除:支持文件删除功能,用户可以设置删除规则,自动删除旧的备份文件。
    文件恢复:支持文件恢复功能,用户可以恢复误删除的文件。
  5. 日志和报告:
    日志记录:支持日志记录功能,用户可以查看和记录同步和备份任务的历史记录。
    错误报告:生成详细的错误报告,帮助用户解决同步和备份过程中遇到的问题。
  6. 其他工具:
    文件加密:支持文件加密功能,用户可以加密重要的文件,确保数据安全。
    压缩和解压:支持文件压缩和解压功能,用户可以压缩备份文件,节省存储空间。
    命令行支持:支持命令行操作,用户可以通过脚本自动化同步和备份任务。
软件特点

用户友好:提供直观的用户界面和丰富的设置选项,适合各水平用户使用。
多功能集成:集成了文件同步、备份、传输和管理等多种功能,用户无需在多个工具之间切换。
高效同步:采用高效的同步算法,确保快速准确的同步效果。
多备份模式:支持完全备份、增量备份和差异备份等多种备份模式,满足不同的备份需求。
多平台支持:支持 Windows 和 macOS 操作系统,适用于不同平台的用户。
多协议支持:支持多种网络协议和云存储服务,方便用户进行远程文件管理和备份。
文件过滤:支持文件过滤功能,用户可以设置过滤规则,排除不需要同步或备份的文件。
版本控制:支持版本控制功能,用户可以保留多个备份版本,方便恢复旧版本的文件。
轻量级:占用系统资源少,启动速度快,运行流畅。
定期更新:开发团队定期发布更新,修复已知问题并增加新功能,确保软件的稳定性和兼容性。
社区和支持:提供详细的使用指南和技术支持,帮助用户解决使用过程中遇到的问题。

「Syncovery Premium(文件同步软件)」 链接:pan.quark.cn/s/01849e671…

用 Node.js 封装豆包语音识别AI模型接口:双向实时流式传输音频和文本

2025年10月27日 16:35

在这里插入图片描述

最近在开发一个多模态AI项目,里面有个语音识别功能,如果直接使用 js 原生的 API 实现语音识别功能,识别的效果不佳,所有我觉得封装豆包的语言识别AI模型,如果直接调用语音识别 API 往往需要处理复杂的协议交互、流式数据传输和连接管理。我就将它封装成一个方便调用的接口,这篇文章将聚焦如何用 Node.js 深度封装豆包语音识别模型接口,从底层协议解析到上层 API 设计,完整呈现一套可复用的语音识别调用框架,帮助开发者快速集成语音转文字能力。

整体架构设计

封装豆包语音识别模型的服务采用分层设计,各模块职责清晰,便于维护和扩展:

客户端 → HTTP接口层 → WebSocket服务层 → 协议处理层 → 豆包ASR服务 \qquad\qquad\qquad\qquad\qquad\qquad\downarrow \qquad\qquad\qquad\qquad\qquad\quad连接管理层

  • HTTP 接口层:提供连接信息获取接口,返回 WebSocket 地址和服务状态
  • WebSocket 服务层:管理客户端连接,处理连接生命周期
  • 协议处理层:实现豆包 ASR 协议的编解码,处理数据格式转换
  • 连接管理层:维护与豆包服务的连接池,处理心跳和重连机制

这种分层设计的优势在于:各层可独立开发测试,当底层 ASR 服务升级时,只需修改协议层而不影响上层业务逻辑。


核心实现详解

1. 服务入口设计:提供连接信息

客户端需要先获取 WebSocket 连接地址,我们设计一个 HTTP 接口作为服务入口:

// aiController.js
const getSpeechRecognitionConfig = (req, res) => {
  try {
    // 检查必要的环境变量配置
    if (!process.env.ARK_APP_ID || !process.env.ARK_ACCESS_TOKEN) {
      return res.status(503).json({
        status: 'error',
        message: 'ASR service not configured'
      });
    }
    // 返回WebSocket连接信息
    res.json({
      status: 'ok',
      websocketUrl: '/api/v1/ai/speech-recognition',
      serviceStatus: asrService.getServiceStatus(),
      supportedFormats: {
        audioType: 'pcm',
        sampleRate: 16000,
        channels: 1
      }
    });
  } catch (error) {
    res.status(500).json({
      status: 'error',
      message: error.message
    });
  }
};

这个接口不仅提供连接地址,还返回服务状态和支持的音频格式,帮助客户端提前做好准备。

2. WebSocket 连接管理

语音识别需要持续的双向通信,WebSocket 是最佳选择。我们使用 ws 库实现 WebSocket 服务:

// app.js
const WebSocket = require('ws');
const http = require('http');
const server = http.createServer(app);
const wss = new WebSocket.Server({ noServer: true });
// 处理HTTP升级请求
server.on('upgrade', (request, socket, head) => {
  if (request.url === '/api/v1/ai/speech-recognition') {
    // 交给ASR服务处理WebSocket连接
    asrService.handleASR(request, socket, head);
  } else {
    socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
    socket.destroy();
  }
});

连接管理是关键,我们需要跟踪活跃连接并在适当时候清理资源:

// asrService.js
class ASRService {
  constructor() {
    this.activeAdapters = new Map(); // 存储活跃连接
    this.connectionId = 0;
  }
  handleASR(request, socket, head) {
    const id = this.connectionId++;
    // 创建适配器实例管理单个连接
    const adapter = new ASRAdapter(id, this.cleanupAdapter.bind(this, id));
    
    this.activeAdapters.set(id, adapter);
    
    // 升级连接并交给适配器处理
    adapter.handleUpgrade(request, socket, head);
  }
  // 清理连接资源
  cleanupAdapter(id) {
    this.activeAdapters.delete(id);
    console.log(`Connection ${id} closed, active connections: ${this.activeAdapters.size}`);
  }
  // 获取服务状态
  getServiceStatus() {
    return {
      activeConnections: this.activeAdapters.size,
      timestamp: Date.now()
    };
  }
}

这种设计可以有效监控连接状态,防止资源泄漏。

3. 协议编解码实现

豆包语音识别服务使用自定义协议格式,我们需要实现协议的编解码:

// asrProtocolHandler.js
class ASRProtocolHandler {
  // 协议头部格式:4字节,包含版本、消息类型等信息
  static encodeHeader(messageType, payloadLength) {
    const header = Buffer.alloc(4);
    header.writeUInt8(1, 0); // 版本号
    header.writeUInt8(messageType, 1); // 消息类型
    header.writeUInt16BE(payloadLength, 2); //  payload长度
    return header;
  }
  // 编码配置消息
  static encodeConfig(config) {
    const payload = JSON.stringify(config);
    const header = this.encodeHeader(1, payload.length); // 1表示配置消息
    return Buffer.concat([header, Buffer.from(payload)]);
  }
  // 编码音频数据
  static encodeAudio(audioData) {
    const header = this.encodeHeader(2, audioData.length); // 2表示音频数据
    return Buffer.concat([header, audioData]);
  }
  // 解码服务端响应
  static decodeRes(buffer) {
    // 解析头部
    const version = buffer.readUInt8(0);
    const messageType = buffer.readUInt8(1);
    const payloadLength = buffer.readUInt16BE(2);
    const payload = buffer.slice(4, 4 + payloadLength).toString();
    return {
      version,
      messageType,
      data: JSON.parse(payload)
    };
  }
}

协议处理层隔离了底层协议细节,使上层业务逻辑无需关心数据格式。

4. 适配器:连接客户端与 ASR 服务

适配器是整个服务的核心,负责协调客户端与豆包 ASR 服务的通信:

// asrAdapter.js
class ASRAdapter {
  constructor(id, cleanupCallback) {
    this.id = id;
    this.cleanup = cleanupCallback;
    this.clientWs = null; // 客户端WebSocket
    this.volcWs = null; // 豆包ASR服务WebSocket
    this.processedWords = new Set(); // 跟踪已处理的文本,避免重复
    this.audioKeepAliveTimer = null; // 音频保活定时器
    this.heartbeatTimer = null; // 心跳定时器
  }
  // 处理WebSocket升级
  handleUpgrade(request, socket, head) {
    // 连接客户端
    this.clientWs = new WebSocket(request.url, {
      protocolVersion: 13,
      socket
    });
    this.clientWs.on('open', () => this.onClientOpen());
    this.clientWs.on('message', (data) => this.onClientMessage(data));
    this.clientWs.on('close', () => this.cleanupResources());
    this.clientWs.on('error', (err) => this.handleError(err));
  }
  // 客户端连接成功后,连接豆包ASR服务
  async onClientOpen() {
    try {
      // 构建豆包ASR服务连接地址
      const url = this.buildVolcWsUrl();
      this.volcWs = new WebSocket(url);
      
      this.volcWs.on('open', () => this.onVolcOpen());
      this.volcWs.on('message', (data) => this.onVolcMessage(data));
      this.volcWs.on('close', () => this.cleanupResources());
      this.volcWs.on('error', (err) => this.handleError(err));
      
      // 启动心跳机制
      this.startHeartbeat();
    } catch (error) {
      this.handleError(error);
    }
  }
  // 连接豆包服务后发送配置信息
  onVolcOpen() {
    const config = {
      appid: process.env.ARK_APP_ID,
      format: 'pcm',
      sample_rate: 16000,
      // 其他配置参数
    };
    const configBuffer = ASRProtocolHandler.encodeConfig(config);
    this.volcWs.send(configBuffer);
    
    // 启动音频保活机制(5秒无数据发送空包)
    this.startAudioKeepAlive();
  }
  // 处理客户端音频数据
  onClientMessage(data) {
    // 重置音频保活定时器
    this.resetAudioKeepAlive();
    // 编码并转发音频数据到豆包服务
    const audioBuffer = ASRProtocolHandler.encodeAudio(data);
    this.volcWs.send(audioBuffer);
  }
  // 处理豆包服务返回的识别结果
  onVolcMessage(data) {
    try {
      const result = ASRProtocolHandler.decodeRes(data);
      // 提取增量文本,避免重复发送
      const incrementalText = this.extractIncrementalText(result.data);
      
      if (incrementalText) {
        this.clientWs.send(JSON.stringify({
          type: 'result',
          text: incrementalText,
          complete: result.data.is_final || false
        }));
      }
    } catch (error) {
      this.handleError(error);
    }
  }
  // 提取增量文本
  extractIncrementalText(recognitionResult) {
    const words = recognitionResult.result || [];
    let incremental = [];
    
    for (const word of words) {
      if (!this.processedWords.has(word)) {
        incremental.push(word);
        this.processedWords.add(word);
      }
    }
    
    return incremental.join('');
  }
  // 心跳机制
  startHeartbeat() {
    this.heartbeatTimer = setInterval(() => {
      if (this.volcWs && this.volcWs.readyState === WebSocket.OPEN) {
        const heartbeat = ASRProtocolHandler.encodeHeartbeat();
        this.volcWs.send(heartbeat);
      }
    }, 30000); // 30秒一次心跳
  }
  // 音频保活机制
  startAudioKeepAlive() {
    this.audioKeepAliveTimer = setInterval(() => {
      if (this.volcWs && this.volcWs.readyState === WebSocket.OPEN) {
        // 发送空音频包保持连接
        const emptyAudio = ASRProtocolHandler.encodeAudio(Buffer.alloc(0));
        this.volcWs.send(emptyAudio);
      }
    }, 5000); // 5秒无数据则发送空包
  }
  // 清理资源
  cleanupResources() {
    if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
    if (this.audioKeepAliveTimer) clearInterval(this.audioKeepAliveTimer);
    if (this.clientWs) this.clientWs.close();
    if (this.volcWs) this.volcWs.close();
    this.cleanup(this.id);
  }
}

适配器实现了几个关键机制:

  • 增量文本提取:通过 Set 存储已发送文本,避免重复传输
  • 双重保活机制:心跳包(30 秒)+ 音频保活(5 秒)确保连接稳定
  • 资源自动清理:连接关闭时释放所有资源,防止内存泄漏

性能优化与容错处理

1. 连接池管理

当并发量较大时,频繁创建和销毁与豆包 ASR 服务的连接会影响性能。可以实现连接池机制:

// connectionPool.js
class ConnectionPool {
  constructor(maxConnections = 100) {
    this.maxConnections = maxConnections;
    this.idleConnections = [];
    this.activeConnections = 0;
  }
  // 获取连接
  async getConnection() {
    // 优先使用空闲连接
    if (this.idleConnections.length > 0) {
      return this.idleConnections.pop();
    }
    // 未达最大连接数则创建新连接
    if (this.activeConnections < this.maxConnections) {
      this.activeConnections++;
      return this.createNewConnection();
    }
    // 等待空闲连接
    return new Promise(resolve => {
      const checkInterval = setInterval(() => {
        if (this.idleConnections.length > 0) {
          clearInterval(checkInterval);
          resolve(this.idleConnections.pop());
        }
      }, 100);
    });
  }
  // 释放连接到池
  releaseConnection(ws) {
    if (this.idleConnections.length < this.maxConnections) {
      this.idleConnections.push(ws);
    } else {
      ws.close();
      this.activeConnections--;
    }
  }
}

2. 错误处理策略

语音识别服务需要处理各种异常情况:

  • 网络波动:实现自动重连机制,记录重连次数避免无限重试
  • 音频格式错误:在服务端验证音频格式,提前返回错误信息
  • 服务过载:通过队列机制限制并发,返回友好的过载提示
  • 超时处理:设置合理的超时时间,清理无响应的连接
// 重连机制实现
handleReconnect() {
  if (this.reconnectCount >= 5) { // 最大重连5次
    this.cleanupResources();
    this.clientWs.send(JSON.stringify({
      type: 'error',
      message: 'Connection lost, please try again'
    }));
    return;
  }
  this.reconnectCount++;
  const delay = Math.min(1000 * this.reconnectCount, 5000); // 指数退避策略
  
  setTimeout(() => {
    console.log(`Reconnecting (${this.reconnectCount}/5)...`);
    this.onClientOpen(); // 重新连接
  }, delay);
}

3. 资源监控

为确保服务稳定运行,需要实现监控机制:

// metrics.js
const collectMetrics = () => {
  return {
    activeConnections: asrService.getServiceStatus().activeConnections,
    totalRequests: metrics.totalRequests,
    errorRate: metrics.errorCount / metrics.totalRequests || 0,
    avgProcessingTime: metrics.totalProcessingTime / metrics.totalRequests || 0
  };
};
// 暴露监控接口
app.get('/metrics', (req, res) => {
  res.json(collectMetrics());
});

通过监控关键指标,可以及时发现并解决性能瓶颈。


客户端集成示例

服务端封装完成后,客户端可以轻松集成语音识别功能:

// 客户端代码示例
async function startSpeechRecognition() {
  // 1. 获取配置信息
  const configRes = await fetch('/api/v1/ai/speech-recognition');
  const config = await configRes.json();
  
  // 2. 建立WebSocket连接
  const ws = new WebSocket(config.websocketUrl);
  
  ws.onopen = () => console.log('Connection opened');
  
  // 3. 处理识别结果
  ws.onmessage = (event) => {
    const data = JSON.parse(event.data);
    if (data.type === 'result') {
      console.log('识别结果:', data.text);
      // 更新UI显示
      updateRecognitionResult(data.text, data.complete);
    }
  };
  
  // 4. 采集并发送音频
  const mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
  const mediaRecorder = new MediaRecorder(mediaStream, {
    mimeType: 'audio/webm;codecs=pcm',
    sampleRate: 16000
  });
  
  // 处理音频数据并发送
  mediaRecorder.ondataavailable = (event) => {
    if (ws.readyState === WebSocket.OPEN) {
      // 转换为PCM格式并发送
      convertToPCM(event.data).then(pcmData => {
        ws.send(pcmData);
      });
    }
  };
  
  mediaRecorder.start(100); // 每100ms发送一次音频片段
}

总结

这篇文章详细介绍了用 Node.js 封装豆包语音识别模型的实现方法,从架构设计到具体代码实现,涵盖了协议处理、连接管理、性能优化等关键技术点。通过这种分层封装,我们可以将复杂的语音识别集成工作简化为简单的 API 调用,同时保证服务的稳定性和可扩展性。

这种实现方式不仅适用于豆包语音识别模型,也可以作为对接其他流式 API 服务的参考架构。希望本文能为需要集成语音识别功能的开发者提供有价值的技术参考。

前端图形引擎架构设计:AI生成设计稿落地实践

2025年10月27日 16:35

系列

《前端图形引擎架构设计:基于ECS模式的可扩展渲染系统》

前言

最近在做 2D 图形渲染相关的项目。由于真实数据还不完善,我一直使用手写 mock 数据进行测试,但这样很难覆盖所有业务场景。于是我开始思考——是否可以借助 AI 自动生成测试数据?更进一步,如果让 AI 直接生成设计稿,再交给渲染系统呈现,不就能同时提升开发效率和产品质量吗?如今不少产品已经开始接入 AI 提效,这件事值得折腾一下。

效果图:

左侧为设计,右侧为html

2c74d98b5cad57fa25fc5386e7618eb1.png

image.png

image.png

总体来说,渲染还原度还是挺高的。

AI 方案探索

最初的方案是让 AI 直接生成我系统使用的 DSL 数据结构,比如:

interface DSLParams {
  position: Position;
  size: Size;
  color: Color;
  lineWidth?: LineWidth;
  id: string;
  selected?: Selected;
  eventQueue?: { type: string; event: MouseEvent }[];
  type: string;
  rotation: Rotation;
  font: Font;
  name?: string;
  img?: Img;
  zIndex: ZIndex;
  scale?: Scale;
  polygon?: Polygon;
  radius?: Radius;
}

理论上可行,但实际效果总是差强人意——坐标偏差、数据错误、样式混乱,结果往往与预期相差甚远。 反倒是让 AI 生成 HTML/CSS 时,视觉效果相当不错。 旧效果:

微信图片_20250929193105_1392_96.png 旧提示词:

微信图片_20250930113056_1418_96.png 于是我想到: 既然 AI 在生成网页展示上表现更好,那么先生成 HTML,再将其转换为 DSL,是不是就能大幅提升还原度和使用体验?

AI 设计成图方案说明(优化版)

为了提升设计稿生成效率,我采用了“AI 生成 HTML → 自动转换为 DSL → 引擎渲染”的方式来实现 AI 设计能力。

具体思路是:让 AI 输出一份只有 HTML 和 CSS、没有任何 JavaScript 的静态页面。该页面的结构和样式表达完整的视觉效果。随后通过解析 HTML DOM Tree,将标签、样式、层级等信息映射为内部 DSL 描述,实现设计稿的结构化转换与渲染。

这种方式最大的优势在于:

  • AI 擅长生成可视化良好的 HTML/CSS,即所见即所得
  • DSL 转换自动化,避免 AI 直接生成复杂结构时的偏差
  • 可预览、可校验、可回溯,工程可控性高
  • 未来可支持反向生成 HTML,构建完整设计工具链

最终实现:

AI 负责创意表达 → 系统负责数据准确性*

从而显著提升设计能力和生产效率。

flowchart LR
A[用户需求输入] --> B[AI 生成 HTML+CSS]
B --> C[DOM Tree 解析器]
C --> D[样式与布局分析]
D --> E[HTML 转 DSL 映射引擎]
E --> F[DSL 渲染引擎]
F --> G[最终可视化效果]

技术方案

AI mode

首先创建AI model,这里使用的是字节的Eino框架,目前还没有用到框架的任何内容,比如工作流,工具tools,mcp等等,如果后期需要方便迭代。

package ai

func initModel(modelName string) (*dto.AiModel, error) {
ctx := context.Background()
if len(modelName) == 0 {
modelName = "deepseek-r1"
}
chatModel, err := openai.NewChatModel(ctx, &openai.ChatModelConfig{
BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
Model:   modelName,                   // 使用的模型版本
APIKey:  os.Getenv("OPENAI_API_KEY"), // OpenAI API 密钥
})

if err != nil {
return nil, fmt.Errorf("failed to initialize chat model %s: %w", modelName, err)
}

if chatModel == nil {
return nil, fmt.Errorf("chat model %s is nil", modelName)
}

tpl := DslDesignTpl()
return &dto.AiModel{
ChatModel: *chatModel,
ChatTpl:   tpl,
}, nil
}
// 获取模型名称
func getModelByName() (*dto.AiHandler, error) {
models := make(map[string]dto.AiModel)

AIModels := []string{
"deepseek-r1",
"deepseek-v3",
"deepseek-r1-0528",
"Moonshot-Kimi-K2-Instruct",
"qwen3-max",
}

for _, name := range AIModels {
model, err := initModel(name)
if err != nil {
fmt.Printf("Failed to initialize model %s: %v\n", name, err)
continue // 跳过失败的模型,继续初始化其他模型
}
if model != nil {
models[name] = *model
}
}

if len(models) == 0 {
return nil, fmt.Errorf("no AI models were successfully initialized")
}

return &dto.AiHandler{Models: models}, nil
}

func Provide(contanier *dig.Container) {
contanier.Provide(getModelByName)
}

定义提示词prompt

你是一名{role}请根据用户的文字描述生成 一个完整的静态网页,页面必须满足以下所有条件:
⸻
## 基本规则
###.布局固定尺寸(非自适应)
-如果用户没有说明,默认页面宽度为 375px(移动端)。
-若用户指定为 PC 设计,则宽度固定为 1440px。
-页面可垂直滚动,但不随窗口大小变化,不可伸缩。
-所有布局、元素大小、间距、字体大小,必须全部使用 px 单位。
### 2.禁止使用 JavaScript
-不得包含任何 <script> 标签。
-不得包含任何内联事件(如 onclick、onchange、onsubmit 等)。
-不允许依赖 JS 的组件、库或交互逻辑。
-所有视觉与交互效果,仅允许使用纯 CSS(如 :hover、:focus、:checked、details 元素等有限方案)。
### 3.禁止响应式与媒体查询
-不允许出现任何 @media 或 @container 规则。
-所有元素按固定像素位置与大小排布,不考虑窗口缩放。
### 4.HTML 结构要求
-使用语义化标签:<header>、<main>、<section>、<article>、<footer> 等。
-模块划分清晰,层级合理,并附带简短注释。
-不使用任何 JS 相关属性或依赖。
### 5.CSS 组织方式
-所有样式必须放在 <style> 标签内(位于 <head> 中)。
-禁止使用外部 CSS 文件或字体文件。
-允许使用 CSS 变量定义颜色与通用参数:

:root (
  --bg: #ffffff;
  --text: #222222;
  --primary: #007bff;
  --radius: 8px;
)
- 字体与字号必须使用像素,例如:
   font-size: 16px;
   font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
### 6.视觉与排版要求
-所有边距、间距、宽度、高度都使用 px,不得使用 %、rem、em、vw、vh 等。
-默认背景为白色(除非用户要求)。
-主色、辅助色可由用户提供,也可使用默认蓝色 (#007bff)。
-圆角、阴影、字体、线条宽度也必须是像素值。
### 7.可访问性与规范
-所有图片需包含 alt。
-所有表单控件必须有 <label>。
-禁止花哨字体与动画,确保清晰度与一致性。
### 8.输出格式要求
-返回一个完整的 HTML 文件:包含 <!doctype html>、<head>(带 <meta>、<title>、<style>)与 <body>。
-页面注释清晰,模块划分合理。
-在文件开头用注释说明页面设计宽度、主色与风格说明。
-所有单位严格为 px。
   ### 9. 图标尽可能采用svg,
   - 如果svg不满足,可以采用图片替代,如果图片不存在,可以使用矩形或者圆形代替.
 - svg内必须且只有一个path来描述图标形状,不能有其他元素,比如circle,rect等
 - svg的path必须有fill属性,不能没有fill属性
   ### 10. 不要使用伪类元素
   - 使用div或者其他元素模拟,不要使用伪类
 ### 11. css要求
   - 不要使用position
 - 不要使用渐变色,使用rgb或者Hex Color
 ### 12. DOM要求
 - 所有元素都必须有明确的宽高,不能使用自动布局
 - 所有表单元素必须有label,并且label与表单元素关联
 - 所有图片必须有alt属性
 ### 13. 禁止使用表格布局
 - 不允许使用<table>、<tr>、<td>等标签进行布局
 ### 15. 禁止使用CSS框架
 - 不允许使用Bootstrap、Tailwind等CSS框架
 ### 文字说明
 - 正文字体大小不得小于12px,标题字体大小不得小于16px
 - 行高等于字体大小
 ### 17. 输入框要求
 - 输入框要通过div等元素模拟,不能使用<input>、<textarea>等原生表单元素
 ### 文字拆行(核心新增规则 —— 禁止自动换行,必须生成块级“行”)
优化后的提示词如下,它更清晰地定义了约束、计算逻辑和输出格式,同时明确了\*\*“只有纯文本段落才应用此规则”\*\*的范围。

-----

## 文本分行渲染指令(核心规则:禁止自动换行,必须生成块级行)

**目标:** 在生成 HTML 文本内容时,**禁止依赖浏览器自动换行**。模型必须根据以下规则,将纯文本内容(非按钮、非表单元素等)分割成一系列具有固定宽度的块级行元素(例如 <div class="line">...</div>),以模拟精确的文本布局。

### 1\. 约束与计算参数

**输入假设:**

  * **容器可用宽度** $W$ (px)。
  * **字体大小/行高** $S$ (px)。

**CSS 约定:**

  * 假设:**单行高度** $H = S$ (px)。
  * 假设:**中文字符宽度** $\approx S$ (px)。
  * 假设:**英文字符(含空格)宽度** $\approx 0.6 \times S$ (px)。

**行最大容量计算:**

  * **中文/全角字符最大数量** $N_(ch) = \text(floor)(W / S)$。
  * **英文/拉丁字母最大数量** $N_(en) = \text(floor)(W / (0.6 \times S))$。

### 2\. 文本拆分算法(模型必须严格执行)

1.  **分行单位:** 文本内容必须被拆分成多个 <div class="line"> 块,每个块代表一个渲染行。
2.  **单词优先原则(针对拉丁文本):** 拆分时应优先在**空格**处断行。**不允许在单词内部(word-break)断行**,除非单词本身长度超过了 $N_(en)$ 限制。
3.  **中文字符拆分:** 连续的中文字符流,按 $N_(ch)$ 的上限进行分割。
4.  **混合文本处理:**
      * 优先在空格处断行。
      * 对于连续的中文字符块,按 $N_(ch)$ 计算。
      * 对于连续的拉丁单词/字符块,按 $N_(en)$ 计算。
5.  **超长内容处理(强制拆分):**
      * 若单个**单词**(拉丁文)或连续**字符流**(中文/混合)的长度超出当前行最大容量,**允许**在该单词/字符流内部进行强制字符级拆分。
      * 如果进行强制拆分,应尽量在断裂处使用连字符(-)连接(仅针对拉丁文,中文直接断开)。

### 3\. HTML 结构与输出要求

**范围限制:** 本规则**仅适用于纯文本内容**(例如文章段落、简介、描述等)。**不适用于**按钮文本、表单标签、导航链接等非连续文本块。

**生成的 HTML 结构示例(模型输出必须以此为模板):**
(假设 $W=335$px, $S=18$px)

<div class="text-block" aria-label="文本块描述">
  <div class="line">这是第一行中文内容</div>
  <div class="line">这是第二行中文内容</div>
  <div class="line">An example English line that fits</div>
  <div class="line">A-very-long-word-that-needs-break-</div>
</div>

### 4\. 强制 CSS 样式(模型输出必须包含此部分)

**模型必须在输出中附带以下严格使用 px 单位的 CSS 规则:**
(此处的 $W$ 和 $S$ 必须替换为实际计算值,例如 $W=335$, $S=18$)


.text-block (
  width: Wpx; /* 容器宽度 */
  height: auto; /* 高度可按 [行数 * S] 计算后写死,或保留 auto */
  /* 其他块级样式 */
)
.line (
  width: Wpx;
  height: Spx;         /* 行高等于字体大小(px) */
  font-size: Spx;
  line-height: Spx;
  white-space: nowrap; /* 确保单行内不自动换行 */
  overflow: hidden;    /* 确保超出的内容被隐藏,由生成器控制拆行 */
  margin-top: 10px;    /* 示例:行间距 */
)
⸻
##  用户输入格式(示例)
用户需求:
页面类型:移动端个人名片页
页面宽度:375px
主色:#4B7BEC
模块:头像区、个人简介、联系方式、底部版权
风格:极简、白底蓝色按钮
⸻
## 输出示例规范(指示给模型使用)
- 页宽固定:width: 375px; margin: 0 auto;
- 主容器中所有元素都用像素值控制,如:
.profile 
  width: 335px;
  height: 120px;
  margin: 20px auto;
  border-radius: 12px;
  padding: 16px;

   - 禁止出现:
   @media
   %
   rem
   em
   vw
   vh
   script
   onclick
   animation
   transition
-所有单位必须为 px。
### 输出格式
- html要用markdown包裹
--
现在,请按照用户要求输出HTML

定义数据流

// ...
requestCtx := c.Request.Context()

messages, err := template.Format(requestCtx, map[string]any{
"role":         "专业资深前端开发专家",
"chat_history": []*schema.Message{},
})
if err != nil {
logger.Error(err.Error() + "test")
errs.FailWithJSON(c, err)
return
}
for _, v := range *body.Messages {
if v.Role == "user" {
messages = append(messages, schema.UserMessage(v.Content))
}
if v.Role == "assistant" {
messages = append(messages, schema.AssistantMessage(v.Content, nil))
}
}
streamResult, err := chatModel.Stream(requestCtx, messages)
if err != nil {
logger.Error(err.Error())
errs.FailWithJSON(c, err)
return
}

h.service.ReportStream(c, requestCtx, streamResult)

DOM tree解析器 help

当用户点击画面中的应用时,将得到的HTML渲染到iframe上,通过获取的ifame的body,获取页面上所有的domstyle属性。

  public init(html: string) {
    return new Promise<DSL[]>((resolve) => {
      const iframe = (this.iframe = document.createElement("iframe"));
      // iframe.style.visibility = "hidden";
      iframe.style.width = "370px";
      iframe.style.height = "800px";
      iframe.style.position = "fixed";
      iframe.style.right = "425px";
      iframe.style.top = "0px";
      iframe.style.border = "1px solid #ccc";
      iframe.onload = () => {
        if (iframe.contentDocument) {
          iframe.contentDocument.open();
          iframe.contentDocument.write(html);

          iframe.contentDocument.close();
          this.transform(iframe);
          console.log(this.dsls, "this.dsls");
          resolve(this.dsls);
        }
      };
      document.body.appendChild(iframe);
    });
  }

递归获取style,解析成dsl,按照以下顺序执行

flowchart TD
    A[输入 HTML] --> B[写入 iframe 渲染]
    B --> C[解析 body 及子节点]
    C --> D[extract style & geometry]
    D --> E[type 判断 + 属性分类]
    E --> F[生成 DSL 数据]
    F --> G[递归转换子元素]
    G --> H[返回 DSL 数组]

总体步骤是这样,但是细化下来就有许多需要尽可能的达到还原效果所做的各种兼容。

判断元素类型

比如说对于html来说,页面中的所有元素都是矩形,圆形也是矩形的圆角设置得到。那么就需要在排除img、文字、svg等等内容外,其他都属于矩形,设置个默认rect


 const rect = style.dom.getBoundingClientRect();
      const dom = style.dom as HTMLElement;
      this.id += 1;
      let type = "rect";
      let src = "";
      let path = "";
      let fillColor = style.backgroundColor;
      let strokeColor = "";
      switch (dom.nodeType) {
        case Node.ELEMENT_NODE: {
          const tagName = dom.tagName.toLowerCase();
          type = this.isCircle(style, rect) ? "ellipse" : type;
          type = dom.children.length === 0 && dom.childNodes[0] ? "rect" : type;
          type = style.domChildType === "text" ? "text" : type;
          if (tagName === "img") {
            type = "img";
            src = (dom as HTMLImageElement).src;
          }
          if (tagName === "svg") {
            type = "img";
            const svgData = this.getSvgData(dom, style);
            fillColor = svgData.fill;
            strokeColor = svgData.stroke;
            path = svgData.path;
          }
          break;
        }
      }

圆形

需要根据元素的圆角程度来判断,当前圆角是否满足圆形还是部分圆角。

/**
   * 判断元素是否为圆形
   * @param style 元素的样式
   * @param rect 元素的边界矩形
   * @returns 如果是圆形返回true,否则返回false
   */
  isCircle(style: Style, rect: DOMRect): boolean {
    // 判断dom是否是圆形
    const width = rect.width;
    const height = rect.height;
    let type = "rect";
    let borderRadius = 0;
    const borderRadiusValue = style.borderTopLeftRadius;

    if (borderRadiusValue.includes("%")) {
      const percentage = parseFloat(borderRadiusValue) / 100;
      borderRadius = Math.min(width, height) * percentage;
    } else {
      borderRadius = parseFloat(borderRadiusValue) || 0;
    }

    if (borderRadius > 0 && Math.abs(width - height) < 1) {
      const minSize = Math.min(width, height);
      if (Math.abs(borderRadius - minSize / 2) < 2) {
        type = "ellipse";
      }
    }
    return type === "ellipse";
  }

当然在画布渲染方面也需要更改,比如原来渲染矩形是通过ctx.strokeRect来渲染一个完整的矩形,但是由于画圆角的api对浏览器支持不好,所以需要手动去画矩形,也就是多边形,判断不同的边,确定圆角的大小来画圆角。

image.png


    // 上边
    if (lw.top > 0 && sc.top !== "transparent") {
      const offset = this.getPixelOffset(lw.top);
      ctx.beginPath();
      ctx.lineWidth = lw.top;
      ctx.strokeStyle = sc.top;
      ctx.moveTo(r.lt, offset);
      ctx.lineTo(width - r.rt, offset);
      ctx.stroke();
    }
    // 右边
    if (lw.right > 0 && sc.right !== "transparent") {
      const offset = this.getPixelOffset(lw.right);
      ctx.beginPath();
      ctx.lineWidth = lw.right;
      ctx.strokeStyle = sc.right;
      ctx.moveTo(width - offset, r.rt);
      ctx.lineTo(width - offset, height - r.rb);
      ctx.stroke();
    }
    // 下边
    if (lw.bottom > 0 && sc.bottom !== "transparent") {
      const offset = this.getPixelOffset(lw.bottom);
      ctx.beginPath();
      ctx.lineWidth = lw.bottom;
      ctx.strokeStyle = sc.bottom;
      ctx.moveTo(width - r.rb, height - offset);
      ctx.lineTo(r.lb, height - offset);
      ctx.stroke();
    }
    // 左边
    if (lw.left > 0 && sc.left !== "transparent") {
      const offset = this.getPixelOffset(lw.left);
      ctx.beginPath();
      ctx.lineWidth = lw.left;
      ctx.strokeStyle = sc.left;
      ctx.moveTo(offset, height - r.lb);
      ctx.lineTo(offset, r.lt);
      ctx.stroke();
    }

    // 四个圆角
    const drawCorner = (
      cx: number,
      cy: number,
      rad: number,
      startAngle: number,
      endAngle: number,
      color: string,
      lw: number
    ) => {
      if (rad > 0 && lw > 0 && color !== "transparent") {
        ctx.beginPath();
        ctx.lineWidth = lw;
        ctx.strokeStyle = color;
        ctx.arc(cx, cy, rad, startAngle, endAngle);
        ctx.stroke();
      }
    };

    drawCorner(r.lt, r.lt, r.lt, Math.PI, -Math.PI / 2, sc.top, lw.top); // 左上
    drawCorner(width - r.rt, r.rt, r.rt, -Math.PI / 2, 0, sc.right, lw.right); // 右上
    drawCorner(
      width - r.rb,
      height - r.rb,
      r.rb,
      0,
      Math.PI / 2,
      sc.bottom,
      lw.bottom
    ); // 右下
    drawCorner(
      r.lb,
      height - r.lb,
      r.lb,
      Math.PI / 2,
      Math.PI,
      sc.left,
      lw.left
    ); // 左下
  }

坐标Position和大小


  // 不需要计算margin,因为getBoundingClientRect已经包含margin了
    const position = {
      x: rect.left + window.scrollX,
      y: rect.top + window.scrollY,
    };
    // paddingtop和paddingleft不能计算进去,因为宽高不包括padding
    const size = {
      width:
        rect.width +
        (parseFloat(style.borderLeftWidth) || 0) +
        (parseFloat(style.borderRightWidth) || 0),
      height:
        rect.height +
        (parseFloat(style.borderTopWidth) || 0) +
        (parseFloat(style.borderBottomWidth) || 0),
    };

需要注意的是,因为使用getBoundingClientRect,所以marginpadding不需要计算。

边框颜色

    // 根据css,判断文字的垂直对齐方式
    const color = {
      fillColor,
      strokeColor: this.getBorderColor(style, strokeColor),
      strokeTColor: style.borderTopColor,
      strokeBColor: style.borderBottomColor,
      strokeLColor: style.borderLeftColor,
      strokeRColor: style.borderRightColor,
    };

需要分别设置边框颜色,如果取到的颜色只有一个,则设置默认颜色。

边框宽度

      const lineWidth = {
      value: defaultBorderWidth,
      top: hasBorder ? parseFloat(style["borderTopWidth"]) || 0 : 0,
      bottom: hasBorder ? parseFloat(style["borderBottomWidth"]) || 0 : 0,
      left: hasBorder ? parseFloat(style["borderLeftWidth"]) || 0 : 0,
      right: hasBorder ? parseFloat(style["borderRightWidth"]) || 0 : 0,
    };

分别获取样式style中,边框的颜色。

svg的渲染

svg中有很多属性,对于一个图标来说,可能由不同的图标组成的,但是目前只取一个svgpath读取。 将path转换成对应的canvas画布数据。

/**
   * 获取svg的path,fill,stroke
   * @param dom
   * @param style
   * @returns
   */
  getSvgData(
    dom: HTMLElement,
    style: Style
  ): { path: string; fill: string; stroke: string } {
    let path = "";
    let fillColor = "";
    let strokeColor = "";
    const pathElement = dom.querySelector("path");

    if (pathElement) {
      path = pathElement.getAttribute("d") || "";

      const pathElementFill = pathElement.getAttribute("fill") || "";
      const pathElementStroke = pathElement.getAttribute("stroke") || "";
      const pathStyle = window.getComputedStyle(pathElement);

      fillColor = this.getValidColor(pathElementFill, pathStyle.fill);
      strokeColor = this.getValidColor(pathElementStroke, pathStyle.stroke);
    } else {
      fillColor = style.fill || "transparent";
      strokeColor = this.getValidColor("", style.stroke);
    }
    return { path, fill: fillColor, stroke: strokeColor };
  }

svg中的颜色fillstroke可能来自于css和属性,比如说path上的属性fill权重要大于css的样式权重,所以读取的时候,需要注意按照权重读取样式,并统一处理返回到dsl中。

文字

由于文字也涉及的点比较多,比如说居中,垂直居中,靠左,靠右,上对齐,下对齐等等,需要统一处理。 需要考虑css的布局样式,是弹性盒还是文本布局等。

/**
   * 获取文本对齐方式
   * @param style 元素的样式
   * @returns 文本对齐方式
   */
  getTextAlignment(style: CSSStyleDeclaration) {
    const display = style.display;
    const alignItems = style.alignItems;
    const justifyContent = style.justifyContent;
    const textAlign = style.textAlign;

    const height = parseFloat(style.height);
    const lineHeight = parseFloat(style.lineHeight);
    let vertical;
    if (display.includes("flex")) {
      if (alignItems === "center") vertical = "middle";
      else if (alignItems === "flex-start" || alignItems === "start")
        vertical = "top";
      else if (alignItems === "flex-end" || alignItems === "end")
        vertical = "bottom";
    } else if (!isNaN(height) && !isNaN(lineHeight)) {
      if (Math.abs(height - lineHeight) < 0.5) vertical = "middle";
      else if (lineHeight < height / 2) vertical = "top";
      else vertical = "bottom";
    }

    let horizontal;
    if (display.includes("flex")) {
      if (justifyContent === "center") horizontal = "center";
      else if (justifyContent === "flex-start" || justifyContent === "start")
        horizontal = "left";
      else if (justifyContent === "flex-end" || justifyContent === "end")
        horizontal = "right";
    } else {
      if (textAlign === "center") horizontal = "center";
      else if (textAlign === "right" || textAlign === "end")
        horizontal = "right";
      else horizontal = "left"; // 默认 left
    }

    return {
      vertical,
      horizontal,
      isVerticallyCentered: vertical === "middle",
      isHorizontallyCentered: horizontal === "center",
    };
  }

canvas画布对齐有一些不一样,左对齐center,并不是按照size大小居中对齐,而是按照x轴的点进行中心对齐,y轴也是一样,所以在渲染文字时,需要特殊处理。

// TextRender.ts
// ...
   // 计算 y 偏移:当 textBaseline 为 middle 时,需要将文字向下偏移半个容器高度
  // 因为 translate 已经移动到了元素的左上角,而 middle 会让文字中心对齐到 y=0
  let offsetY = 0;
  if (textBaseline === "middle" && size) {
    offsetY = size.height / 2;
  }

  // 计算 x 偏移:当 textAlign 为 center 或 right 时,需要调整 x 轴位置
  // 因为 translate 已经移动到了元素的左上角
  let offsetX = 0;
  if (size) {
    if (textAlign === "center") {
      offsetX = size.width / 2;
    } else if (textAlign === "right" || textAlign === "end") {
      offsetX = size.width;
    }
  }
  ```
#### 渲染到画布
```ts
//...
  const dsl = {
      position,
      size,
      font: type === "text" ? this.getFontByStyle(style) : {},
      color,
      selected: { value: false, hovered: false },
      radius,
      img: src || path ? { src, path } : null,
      id: this.id.toString(),
      rotation: { value: 0 },
      zIndex: 30,
      lineWidth,
      eventQueue: [],
      type,
    };

    this.dsls.push(dsl as unknown as DSL);
    if (style.children && style.children.length > 0) {
      this.transformToDSL(style.children);
    }

最后将得到的dsl一次性渲染到画布中。

const handlerApplyCode = (data: any[]) => {
  engineRef.current?.core.initComponents(data);
  engineRef.current?.update();
};

渲染引擎

目前采用的canvas api进行绘制,有考虑大数据量的时候,会导致页面卡顿,有打算将渲染引擎给替换成WebGL进行绘制,当然只需要更新Render即可,抽象统一的api进行替换。 WebGL方面目前打算采用PixiJs框架来绘制,总体需要的apicanvas相似,替换成本比较低。当然具体方案还没确定,也许会把整体页面布局搞完再做也说不准,比较目前只有渲染和拖拽移动等功能,由于ECS架构的特性,想来加一些页面功能还是比较快的。

BiliLive-tools(B站录播一站式工具) 中文绿色版

作者 非凡ghost
2025年10月27日 16:34

BiliLive-tools 是一款专为 B站录播需求 设计的一站式工具,整合了直播录制、弹幕处理、视频压制、上传及下载等功能。它旨在解决传统录播工具碎片化、操作复杂的问题,尤其适合 录播man(直播录制与上传者) 和 切片man(视频剪辑与二次创作者)。支持 录播姬、blrec、DDTV 等工具的 Webhook 自动化上传,并兼容 斗鱼、虎牙、抖音 等平台的直播录制。开源项目,提供灵活的配置选项与便捷的操作体验。

软件功能

支持多种弹幕处理,包括XML弹幕转换、弹幕压制到视频中以及调整弹幕显示时间。
提供视频转封装服务,比如将HEVC格式转换为MP4,同时支持硬件加速提升处理速度。
自动化上传功能,兼容录播姬、blrec、DDTV等工具的Webhook,还能实现断播续传及多P投稿。
可下载B站视频及其弹幕,并且支持断点续传与覆盖控制以避免重复下载。
具备审核状态检测、自定义上传预设(标题、标签等),并能录制多个平台的直播内容。

软件特点

整合了从弹幕处理到视频上传的一系列流程,减少使用多个软件的需求。
高度兼容不同录播工具和直播平台,使得跨平台操作变得简单。
界面友好,提供了图形界面简化复杂操作,支持快捷键提高效率。
强调自动化处理,如自动合并因网络问题分段的直播视频,优化了审核查询策略减少API请求次数。
支持硬件解码加速和FFmpeg滤镜等功能,满足高性能需求,同时作为开源项目不断更新迭代。

「BiliLive-tools(B站录播一站式工具) v3.2.0 中文绿色版」 链接:pan.quark.cn/s/09e37b9f9…

告别Ctrl+F5!解决VUE生产环境缓存更新的终极方案

2025年10月27日 16:33

一、问题背景

在前端项目部署到生产环境后,经常会遇到以下问题:

  1. 用户刷新页面,看不到最新版本:浏览器缓存了旧的JS、CSS等静态资源
  2. 强制刷新(Ctrl+F5)才能看到更新:用户体验差
  3. 资源更新不彻底:部分文件更新了,部分还是旧版本,导致报错

问题的根本原因

浏览器的HTTP缓存机制:为了提升页面加载速度,浏览器会缓存静态资源。如果HTTP响应头设置了缓存策略,浏览器在缓存有效期内不会重新请求服务器,直接从本地缓存读取。


二、解决方案概述

本项目采用了业界经典的**"Hash + 差异化缓存"**策略:

  • 核心思路:通过文件名Hash实现精准更新,而非依赖HTTP缓存头
  • 实施方法
    1. 构建时给每个文件添加Hash值
    2. HTML文件禁用缓存(确保用户总是获取最新的入口文件)
    3. 静态资源长期缓存(因为文件名包含Hash,内容变化文件名就变化)

三、技术实现详解

3.1 Vite 构建配置(vite.config.ts

3.1.1 文件Hash配置

build: {
    // 启用文件hash,确保每次构建生成不同的文件名
    rollupOptions: {
        output: {
            // 为chunk文件添加hash
            chunkFileNames: 'assets/js/[name]-[hash].js',
            entryFileNames: 'assets/js/[name]-[hash].js',
            assetFileNames: 'assets/[ext]/[name]-[hash].[ext]'
        }
    },
    // 生成manifest文件,用于版本控制
    manifest: true
}

配置说明

  • chunkFileNames: 为代码分割产生的chunk文件添加hash,格式:assets/js/[name]-[hash].js
  • entryFileNames: 为入口文件添加hash,格式:assets/js/[name]-[hash].js
  • assetFileNames: 为静态资源(CSS、图片等)添加hash,格式:assets/[ext]/[name]-[hash].[ext]
  • manifest: true: 生成manifest.json文件,记录文件名映射关系

3.1.2 Hash策略的工作原理

假设项目初始构建:

assets/js/main-a1b2c3d4.js  (main.ts打包后的文件)
assets/css/index-5e6f7g8h.css
assets/png/logo-9i0j1k2l.png

当修改了 main.ts 文件后重新构建:

assets/js/main-x9y8z7w6.js  (hash变化了)
assets/css/index-5e6f7g8h.css  (hash不变,因为内容没变)
assets/png/logo-9i0j1k2l.png  (hash不变,因为内容没变)

这样,只有变更的文件会生成新的文件名,浏览器会自动下载新文件,未变更的文件继续使用缓存。

3.1.3 Manifest文件的作用

manifest.json 记录了源文件到构建后文件的映射关系:

{
  "src/main.ts": {
    "file": "assets/js/main-a1b2c3d4.js",
    "imports": ["index.html"]
  },
  "src/style.css": {
    "file": "assets/css/style-5e6f7g8h.css"
  }
}

这个文件可以用于:

  • 精确的版本控制
  • 实现增量更新
  • 统计分析文件变化

3.2 Nginx 缓存配置(nginx.conf

3.2.1 HTML文件不缓存

# HTML文件不缓存
location ~* \.html$ {
    root   /usr/share/nginx/html;
    add_header Cache-Control "no-cache, no-store, must-revalidate";
    add_header Pragma "no-cache";
    add_header Expires "0";
}

配置说明

  • Cache-Control: no-cache: 浏览器必须先向服务器验证缓存的有效性
  • Cache-Control: no-store: 禁止缓存
  • Cache-Control: must-revalidate: 缓存过期后必须重新验证
  • Pragma: no-cache: HTTP/1.0兼容的头,用于向后兼容
  • Expires: 0: 立即过期

为什么HTML文件不缓存?

因为HTML文件引用了所有资源(通过 <script><link> 标签)。如果HTML被缓存了,即使后端部署了新版本,浏览器仍然会加载旧的HTML,HTML中引用的资源文件名还是旧的,导致用户看不到更新。

具体流程

  1. 用户访问页面 → 服务器返回最新的HTML(包含新的资源文件名)
  2. HTML加载 → 解析到 <script src="assets/js/main-x9y8z7w6.js">
  3. 浏览器检查缓存 → 发现没有 main-x9y8z7w6.js 的缓存
  4. 请求新文件 → 下载新的JS文件
  5. 之前缓存的文件(如 main-a1b2c3d4.js)不再被引用,自动废弃

3.2.2 静态资源长期缓存

# 静态资源缓存(JS、CSS、图片等)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
    root   /usr/share/nginx/html;
    expires 1y;
    add_header Cache-Control "public";
}

配置说明

  • expires 1y: 设置缓存过期时间为1年
  • Cache-Control: public: 允许CDN等中间代理缓存

为什么静态资源可以长期缓存?

因为文件名包含了Hash值。内容一旦变化,Hash就会变化,文件名也随之变化。浏览器将新文件名视为一个全新的资源,不会受到旧缓存的影响。

示例

  • 用户A访问:HTML引用 main-a1b2c3d4.js → 浏览器缓存该文件
  • 部署新版本:HTML引用 main-x9y8z7w6.js
  • 用户A刷新页面:
    • HTML不缓存,重新获取 → 发现引用变成了 main-x9y8z7w6.js
    • JS文件不缓存 → 下载新的 main-x9y8z7w6.js
    • 旧的 main-a1b2c3d4.js 不再被使用,保持在缓存中(占用空间很小)

四、完整工作流程

4.1 首次部署

构建产物:
├── index.html
├── assets/js/main-a1b2c3d4.js
├── assets/css/index-5e6f7g8h.css
└── manifest.json

用户访问:
1. 请求 index.html → Nginx返回,不缓存
2. 请求 main-a1b2c3d4.js → Nginx返回,缓存1年
3. 请求 index-5e6f7g8h.css → Nginx返回,缓存1年

4.2 更新代码后重新部署

修改了 main.ts,重新构建:
├── index.html (更新引用)
├── assets/js/main-x9y8z7w6.js (新hash)
├── assets/css/index-5e6f7g8h.css (hash不变)
└── manifest.json (更新映射)

index.html 内容变化:
<script src="/assets/js/main-x9y8z7w6.js"></script>

用户刷新页面:
1. 请求 index.html → 获取最新版本,引用变成 main-x9y8z7w6.js
2. 请求 main-x9y8z7w6.js → 浏览器无缓存,下载新文件
3. 旧的 main-a1b2c3d4.js 不再被引用,自然淘汰

4.3 只更新样式

修改了 style.css,重新构建:
├── index.html (引用不变)
├── assets/js/main-a1b2c3d4.js (hash不变)
├── assets/css/index-new123456.css (新hash)
└── manifest.json

用户刷新页面:
1. 请求 index.html → 发现CSS引用变成了 index-new123456.css
2. 请求 index-new123456.css → 下载新CSS
3. 旧的 index-5e6f7g8h.css 不再被使用
4. main-a1b2c3d4.js 继续使用缓存(性能最优)

五、方案优势

5.1 用户体验优化

  • 自动更新:用户刷新页面即可看到最新版本,无需强制刷新
  • 加载速度快:未变更的资源继续使用缓存,提升加载速度
  • 无感知更新:后台静默更新,用户无感知

5.2 性能优化

  • 减少带宽消耗:只下载变更的文件
  • 降低服务器压力:通过长期缓存减少请求次数
  • 充分利用CDN:CDN可以缓存静态资源,加速全球访问

5.3 开发友好

  • 自动版本管理:不需要手动维护版本号
  • 避免缓存问题:开发时不用担心浏览器缓存
  • 易于调试:文件名包含hash,便于追踪问题

六、注意事项

6.1 HTML必须不缓存

⚠️ 关键配置:HTML文件必须设置不缓存,否则整个方案失效。

# ✅ 正确
location ~* \.html$ {
    add_header Cache-Control "no-cache, no-store, must-revalidate";
}

# ❌ 错误 - 会导致用户看不到更新
location ~* \.html$ {
    expires 1y;  # HTML缓存会导致问题
}

6.2 确保文件Hash生成

⚠️ 检查配置:确保Vite构建时确实生成了hash。

# 检查构建产物
ls dist/assets/js/
# 应该看到类似 main-a1b2c3d4.js 的带hash文件名

# 如果看到 main.js (无hash),说明配置有问题

6.3 HTTPS要求

⚠️ 生产环境:本项目使用HTTPS,确保CDN和浏览器缓存策略正常工作。

listen 9727 ssl;
ssl_certificate /etc/nginx/ssl/server.crt;
ssl_certificate_key /etc/nginx/ssl/server.key;

6.4 浏览器兼容性

支持情况:现代浏览器均支持,包括:

  • Chrome/Edge (latest)
  • Firefox (latest)
  • Safari (latest)
  • 移动端浏览器

七、验证方案

7.1 验证HTML不缓存

# 查看HTTP响应头
curl -I https://your-domain.com/index.html

# 应该看到:
Cache-Control: no-cache, no-store, must-revalidate
Pragma: no-cache
Expires: 0

7.2 验证静态资源缓存

# 查看JS文件响应头
curl -I https://your-domain.com/assets/js/main-a1b2c3d4.js

# 应该看到:
Cache-Control: public
Expires: Wed, 26 Jan 2025 10:00:00 GMT

7.3 测试更新流程

  1. 部署旧版本 → 访问页面,记录文件名
  2. 修改代码 → 重新构建部署
  3. 刷新页面 → 检查文件名是否变化
  4. 对比缓存 → 新文件下载,旧文件不再被引用

八、扩展优化

8.1 CDN配置

如果需要使用CDN,建议配置:

# CDN节点配置
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
    proxy_pass http://cdn.example.com;
    expires 1y;
    add_header Cache-Control "public";
}

8.2 版本号追踪

可以结合manifest.json实现版本号追踪:

// src/utils/version.ts
import manifest from '../../dist/manifest.json'

export const getVersion = () => {
    // 根据manifest中的文件hash生成版本号
    const hashes = Object.values(manifest).map(item => item.file)
    return hashes.join('-').substring(0, 16) // 截取前16位
}

8.3 预加载优化

<!-- index.html -->
<link rel="preload" href="/assets/js/main-a1b2c3d4.js" as="script">
<link rel="preload" href="/assets/css/index-5e6f7g8h.css" as="style">

九、总结

本项目采用的缓存更新优化方案,通过文件Hash + 差异化缓存的策略,完美解决了前端资源更新问题:

  • HTML不缓存:确保用户总是获取最新的入口文件
  • 静态资源长期缓存:利用文件名Hash实现精准更新
  • 用户体验优秀:自动更新,加载速度快
  • 开发友好:无需手动维护版本号

这是一套成熟、可靠的解决方案,适用于所有现代化的前端项目。


参考资料

react-checkbox的封装

作者 云中雾丽
2025年10月27日 16:31

用于在一组可选项中进行多选的 React 组件,参考 Ant Design 5 实现标准。

Checkbox.tsx 里面的代码

import classNames from "classnames";
import React, {
  forwardRef,
  memo,
  useContext,
  useEffect,
  useRef,
  useState,
} from "react";
import CheckboxContext, { CheckboxChangeEvent } from "./context";
import "./index.scss";

export interface CheckboxProps<T = string> {
  prefixCls?: string;
  /**
   * 默认选中
   */
  defaultChecked?: boolean;
  /**
   * 是否选中
   */
  checked?: boolean;
  /**
   * 是否禁用
   */
  disabled?: boolean;
  /**
   * 半选状态(优先级高于 checked)
   */
  indeterminate?: boolean;
  /**
   * 数值
   */
  value?: T;
  /**
   * 回调事件
   */
  onChange?: (e: CheckboxChangeEvent<T>) => void;
  /**
   * input 元素的 name 属性
   */
  name?: string;
  /**
   * input 元素的 id 属性
   */
  id?: string;
  /**
   * 自动聚焦
   */
  autoFocus?: boolean;
  /**
   * Tab 键顺序
   */
  tabIndex?: number;
  className?: string;
  children?: React.ReactNode;
  style?: React.CSSProperties;
}

function InternalCheckbox<T = string>(
  props: CheckboxProps<T>,
  ref: React.Ref<HTMLInputElement>
) {
  const {
    prefixCls = "ant-",
    onChange,
    disabled,
    value,
    indeterminate = false,
    name,
    id,
    autoFocus,
    tabIndex,
    className,
    style,
    ...others
  } = props;

  const [checked, setCheck] = useState(props.defaultChecked || false);
  const inputEl = useRef<HTMLInputElement>(null);
  const checkedRef = useRef(checked);
  const {
    onChange: conChange,
    disabled: cdisabled,
    value: values,
  } = useContext(CheckboxContext);

  useEffect(() => {
    checkedRef.current = checked;
  }, [checked]);

  // 受控模式:同步 props.checked 的变化
  useEffect(() => {
    if ("checked" in props && props.checked !== undefined) {
      setCheck(props.checked);
    }
  }, [props.checked]);

  useEffect(() => {
    if (values && "value" in props) {
      setCheck(values.indexOf(props.value) > -1);
    }
  }, [values, props.value]);

  // 同步 indeterminate 状态到原生 input
  useEffect(() => {
    if (inputEl.current) {
      inputEl.current.indeterminate = indeterminate;
    }
  }, [indeterminate]);

  const handleClick = (e) => {
    if (disabled || cdisabled) {
      return;
    }

    const state = !checkedRef.current;
    if (!("checked" in props)) {
      setCheck(state);
    }

    // 创建规范的事件对象,而不是直接修改原始事件
    const checkboxChangeEvent: CheckboxChangeEvent<T> = {
      target: {
        checked: state,
        value: value as T,
      },
      nativeEvent: e,
    };

    if (typeof onChange === "function") {
      onChange(checkboxChangeEvent);
    }

    if (typeof conChange === "function") {
      conChange(checkboxChangeEvent);
    }
  };

  const handleChange = () => {};

  const cls = classNames({
    [`${prefixCls}checkbox`]: true,
    [`${prefixCls}checkbox-checked`]: checked && !indeterminate,
    [`${prefixCls}checkbox-disabled`]: props.disabled,
    [`${prefixCls}checkbox-indeterminate`]: indeterminate,
  });

  const wrapperCls = classNames(
    {
      [`${prefixCls}checkbox-wrapper`]: true,
      [`${prefixCls}checkbox-wrapper-disabled`]: props.disabled,
    },
    className
  );

  return (
    <span className={wrapperCls} style={style} onClick={handleClick}>
      <span className={cls}>
        <input
          type="checkbox"
          ref={(node) => {
            // 合并内部 ref 和外部 ref
            (
              inputEl as React.MutableRefObject<HTMLInputElement | null>
            ).current = node;
            if (typeof ref === "function") {
              ref(node);
            } else if (ref) {
              (ref as React.MutableRefObject<HTMLInputElement | null>).current =
                node;
            }
          }}
          name={name}
          id={id}
          value={value as any}
          checked={checked}
          disabled={disabled || cdisabled}
          autoFocus={autoFocus}
          tabIndex={tabIndex}
          onChange={handleChange}
          aria-checked={indeterminate ? "mixed" : checked ? "true" : "false"}
          aria-disabled={disabled || cdisabled}
        />
        <span className="ant-checkbox-inner"></span>
      </span>
      <span>{props.children}</span>
    </span>
  );
}

// 使用 memo 优化性能,避免不必要的重渲染
// 正确的顺序:先 forwardRef,再 memo
const CheckboxWithRef = forwardRef(InternalCheckbox);
const Checkbox = memo(CheckboxWithRef) as (<T = string>(
  props: CheckboxProps<T> & { ref?: React.Ref<HTMLInputElement> }
) => React.ReactElement) & { displayName?: string };

Checkbox.displayName = "Checkbox";

export default Checkbox;

CheckboxGroup.tsx 里面的代码

import classNames from "classnames";
import React, { useCallback, useEffect, useRef, useState } from "react";
import Checkbox from "./Checkbox";
import CheckboxContext, { CheckboxChangeEvent } from "./context";
import "./index.scss";

export interface CheckboxOptionType<T = string> {
  label: string;
  value: T;
  disabled?: boolean;
  indeterminate?: boolean;
  style?: React.CSSProperties;
  className?: string;
}

export interface GroupProps<T = string> {
  /**
   * 默认数值
   */
  defaultValue?: Array<T>;
  /**
   * 数值
   */
  value?: Array<T>;
  onChange?: (values: Array<T>) => void;
  /**
   * 是否禁用
   */
  disabled?: boolean;
  /**
   * 排列方向:水平或垂直
   */
  direction?: "horizontal" | "vertical";
  /**
   * 选项数组(用于简化使用)
   */
  options?: CheckboxOptionType<T>[] | T[];
  /**
   * 统一的 name 属性
   */
  name?: string;
  /**
   * 回调事件
   */
  className?: string;
  children?: React.ReactNode;
  style?: React.CSSProperties;
  /**
   * 测试用id
   */
  "data-testid"?: string;
}

function Group<T = string>(props: GroupProps<T>) {
  const {
    disabled,
    children,
    onChange,
    direction = "horizontal",
    options,
    name,
    "data-testid": dataTestId,
    ...others
  } = props;

  const [value, setValue] = useState<T[]>(
    props.defaultValue || props.value || []
  );

  // 使用 ref 保存最新的 value 和 onChange,避免 useCallback 重新创建
  const valueRef = useRef<T[]>(value);
  const onChangeRef = useRef(onChange);

  useEffect(() => {
    valueRef.current = value;
  }, [value]);

  useEffect(() => {
    onChangeRef.current = onChange;
  }, [onChange]);

  useEffect(() => {
    if ("value" in props && props.value !== undefined) {
      setValue(props.value);
    }
  }, [props.value]);

  const cls = classNames({
    "ant-checkbox-group": true,
    "ant-checkbox-group-vertical": direction === "vertical",
    "ant-checkbox-group-horizontal": direction === "horizontal",
  });

  // 性能优化:使用 ref 避免 useCallback 依赖变化
  const handleChange = useCallback((e: CheckboxChangeEvent<T>) => {
    const targetValue = e.target.value;
    const checked = e.target.checked;
    let newValue = [...valueRef.current];

    // checked为true时添加,checked为false时移除
    if (checked) {
      if (!newValue.includes(targetValue)) {
        newValue.push(targetValue);
      }
    } else {
      newValue = newValue.filter((item) => item !== targetValue);
    }

    setValue(newValue);
    onChangeRef.current?.(newValue);
  }, []); // 空依赖数组,函数永远不会重新创建

  // 处理options模式
  const renderOptions = () => {
    if (!options) return null;

    return options.map((option, index) => {
      const isObject = typeof option === "object";
      const optionValue = isObject ? option.value : option;
      const optionLabel = isObject ? option.label : String(option);
      const optionDisabled = isObject ? option.disabled : false;
      const optionStyle = isObject ? option.style : undefined;
      const optionClassName = isObject ? option.className : undefined;

      return (
        <span
          key={`checkbox-option-${index}`}
          className={classNames("ant-checkbox-group-item", optionClassName)}
          style={optionStyle}
        >
          <Checkbox
            checked={value.includes(optionValue)}
            disabled={disabled || optionDisabled}
            value={optionValue}
            onChange={handleChange}
            name={name}
          >
            {optionLabel}
          </Checkbox>
        </span>
      );
    });
  };

  return (
    <div className={cls} style={props.style} data-testid={dataTestId}>
      <CheckboxContext.Provider
        value={{
          onChange: handleChange,
          disabled: disabled || false,
          value,
        }}
      >
        {options ? renderOptions() : children}
      </CheckboxContext.Provider>
    </div>
  );
}

export default Group;

context.tsx 代码

import { createContext } from "react";

export interface CheckboxChangeEventTarget<T = string> {
  value: T;
  checked: boolean;
}

export interface CheckboxChangeEvent<T = string> {
  target: CheckboxChangeEventTarget<T>;
  nativeEvent?: React.MouseEvent<HTMLSpanElement>;
}

export interface CheckboxContextProps<T = string> {
  value: Array<T>;
  onChange: (e: CheckboxChangeEvent<T>) => void;
  disabled: boolean;
}

const checkboxContext = createContext<CheckboxContextProps<any>>({
  value: [],
  onChange: () => {},
  disabled: false,
});

export default checkboxContext;

index.scss


*,*:before,*:after {
    box-sizing: border-box
  }

  /* CSS 变量定义 - 支持主题定制 */
  :root {
    --checkbox-primary-color: #1890ff;
    --checkbox-border-color: #d9d9d9;
    --checkbox-bg-color: #fff;
    --checkbox-text-color: #000000d9;
    --checkbox-disabled-bg: #f5f5f5;
    --checkbox-disabled-text: #00000040;
    --checkbox-disabled-border: #d9d9d9;
  }
  
  .ant-checkbox {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
    color: var(--checkbox-text-color);
    font-size: 14px;
    font-variant: tabular-nums;
    line-height: 1.5715;
    list-style: none;
    font-feature-settings: "tnum";
    position: relative;
    top: .2em;
    line-height: 1;
    white-space: nowrap;
    outline: none;
    cursor: pointer
  }
  
  .ant-checkbox input{
    display: none;
  }
  
  .ant-checkbox-wrapper:hover .ant-checkbox-inner,.ant-checkbox:hover .ant-checkbox-inner,.ant-checkbox-input:focus+.ant-checkbox-inner {
    border-color: var(--checkbox-primary-color)
  }
  
  .ant-checkbox-checked:after {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    border: 1px solid var(--checkbox-primary-color);
    border-radius: 2px;
    visibility: hidden;
    -webkit-animation: antCheckboxEffect .36s ease-in-out;
    animation: antCheckboxEffect .36s ease-in-out;
    -webkit-animation-fill-mode: backwards;
    animation-fill-mode: backwards;
    content: ""
  }
  
  .ant-checkbox:hover:after,.ant-checkbox-wrapper:hover .ant-checkbox:after {
    visibility: visible
  }
  
  .ant-checkbox-inner {
    box-sizing: border-box;
    position: relative;
    top: 0;
    left: 0;
    display: block;
    width: 16px;
    height: 16px;
    direction: ltr;
    background-color: var(--checkbox-bg-color);
    border: 1px solid var(--checkbox-border-color);
    border-radius: 2px;
    border-collapse: separate;
    transition: all .3s
  }
  
  .ant-checkbox-inner:after {
    position: absolute;
    top: 50%;
    left: 22%;
    display: table;
    width: 5.71428571px;
    height: 9.14285714px;
    border: 2px solid #fff;
    border-top: 0;
    border-left: 0;
    transform: rotate(45deg) scale(0) translate(-50%,-50%);
    opacity: 0;
    transition: all .1s cubic-bezier(.71,-.46,.88,.6),opacity .1s;
    content: " "
  }
  
  .ant-checkbox-input {
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    z-index: 1;
    width: 100%;
    height: 100%;
    cursor: pointer;
    opacity: 0
  }
  
  .ant-checkbox-checked .ant-checkbox-inner:after {
    position: absolute;
    display: table;
    border: 2px solid #fff;
    border-top: 0;
    border-left: 0;
    transform: rotate(45deg) scale(1) translate(-50%,-50%);
    opacity: 1;
    transition: all .2s cubic-bezier(.12,.4,.29,1.46) .1s;
    content: " "
  }
  
  .ant-checkbox-checked .ant-checkbox-inner {
    background-color: var(--checkbox-primary-color);
    border-color: var(--checkbox-primary-color)
  }
  
  .ant-checkbox-disabled {
    cursor: not-allowed
  }
  
  .ant-checkbox-disabled.ant-checkbox-checked .ant-checkbox-inner:after {
    border-color: var(--checkbox-disabled-text);
    -webkit-animation-name: none;
    animation-name: none
  }
  
  .ant-checkbox-disabled .ant-checkbox-input {
    cursor: not-allowed
  }
  
  .ant-checkbox-disabled .ant-checkbox-inner {
    background-color: var(--checkbox-disabled-bg);
    border-color: var(--checkbox-disabled-border)!important
  }
  
  .ant-checkbox-disabled .ant-checkbox-inner:after {
    border-color: var(--checkbox-disabled-bg);
    border-collapse: separate;
    -webkit-animation-name: none;
    animation-name: none
  }
  
  .ant-checkbox-disabled+span {
    color: var(--checkbox-disabled-text);
    cursor: not-allowed
  }
  
  .ant-checkbox-disabled:hover:after,.ant-checkbox-wrapper:hover .ant-checkbox-disabled:after {
    visibility: hidden
  }
  
  .ant-checkbox-wrapper {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
    color: var(--checkbox-text-color);
    font-size: 14px;
    font-variant: tabular-nums;
    line-height: 1.5715;
    list-style: none;
    font-feature-settings: "tnum";
    display: inline-flex;
    align-items: baseline;
    line-height: unset;
    cursor: pointer
  }
  
  .ant-checkbox-wrapper:after {
    display: inline-block;
    width: 0;
    overflow: hidden;
    content: "\a0"
  }
  
  .ant-checkbox-wrapper.ant-checkbox-wrapper-disabled {
    cursor: not-allowed
  }
  
  .ant-checkbox-wrapper+.ant-checkbox-wrapper {
    margin-left: 8px
  }
  
  .ant-checkbox+span {
    padding-right: 8px;
    padding-left: 8px
  }
  
  .ant-checkbox-group {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
    color: var(--checkbox-text-color);
    font-size: 14px;
    font-variant: tabular-nums;
    line-height: 1.5715;
    list-style: none;
    font-feature-settings: "tnum";
    display: inline-block
  }
  
  .ant-checkbox-group-item {
    margin-right: 8px
  }
  
  .ant-checkbox-group-item:last-child {
    margin-right: 0
  }
  
  .ant-checkbox-group-item+.ant-checkbox-group-item {
    margin-left: 0
  }
  
  .ant-checkbox-indeterminate .ant-checkbox-inner {
    background-color: var(--checkbox-bg-color);
    border-color: var(--checkbox-border-color)
  }
  
  .ant-checkbox-indeterminate .ant-checkbox-inner:after {
    top: 50%;
    left: 50%;
    width: 8px;
    height: 8px;
    background-color: var(--checkbox-primary-color);
    border: 0;
    transform: translate(-50%,-50%) scale(1);
    opacity: 1;
    content: " "
  }
  
  .ant-checkbox-indeterminate.ant-checkbox-disabled .ant-checkbox-inner:after {
    background-color: var(--checkbox-disabled-text);
    border-color: var(--checkbox-disabled-text)
  }
  
  .ant-checkbox-rtl {
    direction: rtl
  }
  
  .ant-checkbox-group-rtl .ant-checkbox-group-item {
    margin-right: 0;
    margin-left: 8px
  }
  
  .ant-checkbox-group-rtl .ant-checkbox-group-item:last-child {
    margin-left: 0!important
  }
  
  .ant-checkbox-group-rtl .ant-checkbox-group-item+.ant-checkbox-group-item {
    margin-left: 8px
  }

index.tsx

import InternalCheckbox from "./Checkbox";
import Group from "./CheckboxGroup";

// 导出类型
export type { CheckboxProps } from "./Checkbox";
export type {
  CheckboxOptionType,
  GroupProps as CheckboxGroupProps,
} from "./CheckboxGroup";
export type { CheckboxChangeEvent, CheckboxChangeEventTarget } from "./context";

type CheckboxType = typeof InternalCheckbox;
interface CheckboxInterface extends CheckboxType {
  Group: typeof Group;
}

const Checkbox = InternalCheckbox as CheckboxInterface;
Checkbox.Group = Group;
export default Checkbox;

Vue 3.5 + WangEditor 打造智能笔记编辑器:语音识别功能深度实现

2025年10月27日 16:30

在这里插入图片描述

在上篇文章中,我使用了 node.js 封装了豆包模型的语音识别接口,接下来就在前端调用该接口,实现语音识别完整。这篇文章讲拆解如何在 Vue 3.5 框架与 WangEditor 富文本编辑器的基础上,集成完整的语音识别功能,从音频采集、格式转换到实时识别结果插入,全方位呈现可落地的技术方案。

功能定位与技术选型

核心需求拆解

  • 实时语音转文字:支持麦克风采集音频,实时返回识别结果并插入编辑器
  • 良好的用户体验:提供录音状态可视化、快捷键控制、错误提示
  • 兼容性保障:适配主流浏览器,兼顾现代 API 与降级方案
  • 稳定性设计:包含连接诊断、错误处理、资源自动释放机制

技术栈选型

  • 框架:Vue 3.5 + Composition API(高效的组件逻辑组织)
  • 编辑器:WangEditor(轻量、可扩展的富文本编辑器)
  • 音频处理:AudioWorklet + ScriptProcessor(高效音频格式转换)
  • 通信方式:WebSocket(实时传输音频数据与识别结果)
  • UI 组件:Element Plus(错误提示、弹窗等交互组件)

选型核心考量:Vue 3.5 的响应式特性适合状态管理,WangEditor 开放的 API 便于扩展,WebSocket 确保流式数据传输的实时性,双重音频处理方案保障兼容性。

整体架构设计

语音识别功能采用分层设计,各模块职责清晰,便于维护与扩展:

UI交互层(NoteEditor.vue)→ 核心控制层(simpleSpeech.js)→ 音频处理层(audioPcmProcessor.js)→ 通信层(WebSocket) \qquad\qquad\qquad\qquad\qquad\qquad\downarrow \qquad\qquad\qquad\qquad诊断工具层(speechConfigChecker.js)

  • UI 交互层:提供用户操作入口(录音按钮、状态展示)和结果插入逻辑
  • 核心控制层:封装录音启停、连接管理、消息处理等核心逻辑
  • 音频处理层:将麦克风采集的音频转换为识别服务支持的 PCM 格式
  • 通信层:通过 WebSocket 与后端语音识别服务实时交互
  • 诊断工具层:检测环境兼容性、服务可用性,辅助问题排查

核心模块实现详解

1. 音频处理层:PCM 格式转换(audioPcmProcessor.js)

语音识别服务通常要求输入 16kHz 采样率、16 位深度的 PCM 格式音频,而浏览器麦克风采集的是 Float32Array 格式(范围 [1,1][-1, 1]),因此需要专门的格式转换模块。

采用 AudioWorklet 实现高效音频处理(浏览器不支持时降级为 ScriptProcessor):

class AudioPcmProcessor extends AudioWorkletProcessor {
    constructor() {
        super()
        this.bufferSize = 4096 // 缓冲区大小,平衡延迟与性能
        this.buffer = new Float32Array(this.bufferSize)
        this.bufferIndex = 0
    }
    process(inputs, outputs, parameters) {
        const input = inputs[0]
        if (input.length > 0) {
            const inputChannel = input[0]
            // 填充缓冲区
            for (let i = 0; i < inputChannel.length; i++) {
                this.buffer[this.bufferIndex] = inputChannel[i]
                this.bufferIndex++
                // 缓冲区满时处理并发送
                if (this.bufferIndex >= this.bufferSize) {
                    this.processAndSendPcm()
                    this.bufferIndex = 0
                }
            }
        }
        return true
    }
    processAndSendPcm() {
        try {
            // Float32 → Int16 PCM 转换:[-1,1] → [-32768, 32767]
            const pcmData = new Int16Array(this.buffer.length)
            for (let i = 0; i < this.buffer.length; i++) {
                const sample = Math.max(-1, Math.min(1, this.buffer[i])) // 限制范围
                pcmData[i] = sample < 0 ? sample * 0x8000 : sample * 0x7fff
            }
            // 用 Transferable 对象传输,避免数据复制
            this.port.postMessage(
                { type: 'pcmData', data: pcmData.buffer, length: pcmData.length },
                [pcmData.buffer]
            )
        } catch (error) {
            this.port.postMessage({ type: 'error', error: error.message })
        }
    }
}
registerProcessor('audio-pcm-processor', AudioPcmProcessor)

核心亮点:

  • 采用缓冲区批量处理,减少通信开销
  • 使用 Transferable 对象转移 ArrayBuffer 所有权,提升传输效率
  • 严格限制音频采样范围,避免失真

2. 核心控制层:语音识别封装(simpleSpeech.js)

该模块是语音识别功能的核心,封装了录音控制、WebSocket 连接、消息处理等逻辑,对外提供简洁的 API:

export class SimpleSpeech {
    constructor(options = {}) {
        // 基础配置
        const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'
        this.serverUrl = `${baseUrl.replace('http', 'ws')}/api/v1/ai/speech-recognition`
        this.sampleRate = options.sampleRate || 16000
        this.model = options.model || 'bigmodel'
        
        // 状态管理
        this.isRecording = false
        this.isConnected = false
        this.isReady = false
        
        // 音频组件与通信实例
        this.audioContext = null
        this.audioStream = null
        this.workletNode = null
        this.ws = null
        
        // 事件回调(解耦UI与核心逻辑)
        this.onReady = options.onReady || (() => {})
        this.onPartial = options.onPartial || (() => {}) // 中间结果回调
        this.onFinal = options.onFinal || (() => {})      // 最终结果回调
        this.onError = options.onError || (() => {})
    }
    // 建立WebSocket连接
    async connect() {
        try {
            const url = `${this.serverUrl}?sampleRate=${this.sampleRate}&model=${this.model}`
            this.ws = new WebSocket(url)
            
            this.ws.onopen = () => {
                this.isConnected = true
                this.onStatusChange('connected')
            }
            
            this.ws.onmessage = (event) => {
                const data = JSON.parse(event.data)
                this.handleMessage(data) // 处理服务端消息
            }
            
            this.ws.onclose = () => {
                this.isConnected = false
                this.isReady = false
                this.onStatusChange('disconnected')
            }
        } catch (error) {
            this.onError('连接失败: ' + error.message)
        }
    }
    // 处理服务端消息
    handleMessage(data) {
        switch (data.type) {
            case 'ready':
                this.isReady = true
                this.onReady() // 服务就绪回调
                break
            case 'incremental_result':
                // 实时返回中间识别结果
                this.onPartial({ text: data.text, timestamp: data.timestamp })
                break
            case 'final_result':
                // 语音片段识别完成,返回最终结果
                this.onFinal({ text: data.text, timestamp: data.timestamp })
                break
            case 'error':
                this.onError(this.formatErrorMsg(data.message))
                break
        }
    }
    // 开始录音
    async startRecording() {
        if (!this.isConnected || !this.isReady) {
            this.onError('请先连接语音识别服务')
            return
        }
        
        try {
            // 获取麦克风权限
            this.audioStream = await navigator.mediaDevices.getUserMedia({
                audio: {
                    sampleRate: this.sampleRate,
                    channelCount: 1, // 单声道
                    echoCancellation: true, // 回声消除
                    noiseSuppression: true, // 降噪
                    autoGainControl: true // 自动增益
                }
            })
            
            // 创建音频上下文
            this.audioContext = new (window.AudioContext || window.webkitAudioContext)({
                sampleRate: this.sampleRate
            })
            const audioSource = this.audioContext.createMediaStreamSource(this.audioStream)
            
            // 优先使用AudioWorklet,失败则降级
            try {
                await this.audioContext.audioWorklet.addModule('/audioPcmProcessor.js')
                this.workletNode = new AudioWorkletNode(this.audioContext, 'audio-pcm-processor')
                this.workletNode.port.onmessage = (event) => {
                    if (event.data.type === 'pcmData' && this.ws?.readyState === WebSocket.OPEN) {
                        this.ws.send(event.data.data) // 发送PCM数据
                    }
                }
                audioSource.connect(this.workletNode)
            } catch (error) {
                this.createScriptProcessor(audioSource) // 降级方案
            }
            
            this.isRecording = true
        } catch (error) {
            this.onError('录音启动失败: ' + error.message)
        }
    }
    // ScriptProcessor降级方案(兼容旧浏览器)
    createScriptProcessor(audioSource) {
        const scriptProcessor = this.audioContext.createScriptProcessor(4096, 1, 1)
        scriptProcessor.onaudioprocess = (event) => {
            if (this.ws?.readyState === WebSocket.OPEN) {
                const inputData = event.inputBuffer.getChannelData(0)
                // 同上的PCM格式转换逻辑
                const pcmData = new Int16Array(inputData.length)
                for (let i = 0; i < inputData.length; i++) {
                    const sample = Math.max(-1, Math.min(1, inputData[i]))
                    pcmData[i] = sample < 0 ? sample * 0x8000 : sample * 0x7fff
                }
                this.ws.send(pcmData.buffer)
            }
        }
        audioSource.connect(scriptProcessor)
        scriptProcessor.connect(this.audioContext.destination)
    }
    // 停止录音
    stopRecording() {
        if (!this.isRecording) return
        
        // 断开音频节点,释放资源
        if (this.workletNode) this.workletNode.disconnect()
        if (this.scriptProcessor) this.scriptProcessor.disconnect()
        
        // 关闭音频流和上下文
        this.audioStream.getTracks().forEach(track => track.stop())
        this.audioContext.close()
        
        this.isRecording = false
    }
    // 格式化错误信息
    formatErrorMsg(message) {
        if (message.includes('缺少配置')) {
            return '后端缺少配置,请设置ARK_APP_ID和ARK_ACCESS_TOKEN'
        } else if (message.includes('连接失败')) {
            return '无法连接语音识别服务,请检查网络'
        }
        return message
    }
}

核心设计思路:

  • 采用事件回调解耦 UI 与核心逻辑,提高复用性
  • 双重音频处理方案,兼顾现代浏览器性能与旧浏览器兼容性
  • 完善的状态管理,避免无效操作(如未连接时启动录音)
  • 错误信息格式化,提升用户可理解性

3. UI 交互层:编辑器集成(NoteEditor.vue)

在 Vue 组件中集成 WangEditor 与语音识别核心逻辑,提供用户交互入口和结果展示:

3.1 模板结构设计

<template>
    <div class="note-editor">
        <div class="editor-toolbar">
            <Toolbar :editor="editorRef" :defaultConfig="toolbarConfig" :mode="mode" />
        </div>
        
        <div class="editor-content">
            <Editor v-model="valueHtml" :defaultConfig="editorConfig" @onCreated="handleCreated" />
        </div>
        
        <div v-if="isSpeechRecording" class="speech-recording-indicator">
            <div class="recording-animation">
                <div class="pulse-ring"></div>
                <div class="microphone-icon">🎤</div>
            </div>
            <div class="recording-text">正在录音中...</div>
            <button @click="stopSpeechRecognition" class="stop-btn">⏹️ 停止录音</button>
            <div class="esc-hint">按 ESC 键停止</div>
        </div>
    </div>
</template>

3.2 核心逻辑实现

<script setup>
import { ref, shallowRef, onMounted, onBeforeUnmount } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { SimpleSpeech } from '@/utils/simpleSpeech.js'
import { SpeechConfigChecker } from '@/utils/speechConfigChecker.js'
import { ElMessage } from 'element-plus'
// 编辑器实例(WangEditor要求用shallowRef)
const editorRef = shallowRef(null)
// 语音识别相关状态
const speechRecognition = ref(null)
const isSpeechRecording = ref(false)
const speechStatus = ref('disconnected')
// 工具栏配置(添加语音识别菜单)
const toolbarConfig = {
    excludeKeys: ['group-video'],
    insertKeys: {
        index: 0,
        keys: ['voiceReadingMenu'] // 自定义语音识别菜单
    }
}
// 编辑器创建完成回调
const handleCreated = (editor) => {
    editorRef.value = editor
}
// 初始化语音识别实例
const initSpeechRecognition = () => {
    if (speechRecognition.value) return
    speechRecognition.value = new SimpleSpeech({
        sampleRate: 16000,
        model: 'bigmodel',
        onReady: () => {
            speechStatus.value = 'ready'
        },
        onPartial: (result) => {
            // 实时插入中间识别结果
            insertSpeechText(result.text)
        },
        onFinal: (result) => {
            // 插入最终结果(可覆盖中间结果或追加)
            insertSpeechText(result.text)
        },
        onError: (error) => {
            ElMessage.error(error)
            // 自动运行配置检查,辅助排查问题
            runConfigCheck()
        }
    })
}
// 开始语音识别
const startSpeechRecognitionDirect = async () => {
    try {
        if (!speechRecognition.value) {
            initSpeechRecognition()
        }
        await speechRecognition.value.connect()
        
        // 等待服务就绪(最多3秒超时)
        let retryCount = 0
        while (retryCount < 30 && speechStatus.value !== 'ready') {
            await new Promise(resolve => setTimeout(resolve, 100))
            retryCount++
        }
        
        if (speechStatus.value !== 'ready') {
            throw new Error('语音识别服务连接超时')
        }
        
        await speechRecognition.value.startRecording()
        isSpeechRecording.value = true
        ElMessage.success('🎤 开始语音识别')
    } catch (error) {
        ElMessage.error('启动失败: ' + error.message)
    }
}
// 停止语音识别
const stopSpeechRecognition = () => {
    if (speechRecognition.value && isSpeechRecording.value) {
        speechRecognition.value.stopRecording()
        isSpeechRecording.value = false
        ElMessage.info('⏹️ 语音识别已停止')
    }
}
// 插入识别结果到编辑器
const insertSpeechText = (text) => {
    const editor = editorRef.value
    if (!editor || !text.trim()) return
    
    editor.focus() // 聚焦编辑器
    editor.insertText(text) // 插入文本
}
// 全局快捷键监听(ESC停止录音)
const handleGlobalKeydown = (event) => {
    if (event.key === 'Escape' && isSpeechRecording.value) {
        event.preventDefault()
        stopSpeechRecognition()
    }
}
// 配置检查(诊断环境和服务问题)
const runConfigCheck = async () => {
    const checker = new SpeechConfigChecker()
    const results = await checker.runFullCheck()
    const report = checker.generateDiagnosticReport(results)
    console.log('语音识别配置诊断报告:', report)
}
// 生命周期钩子:绑定事件
onMounted(() => {
    // 绑定语音识别菜单点击事件
    document.addEventListener('voiceReadingClick', (e) => {
        const action = e.detail.action
        if (action === 'start') {
            startSpeechRecognitionDirect()
        } else if (action === 'stop') {
            stopSpeechRecognition()
        }
    })
    
    // 绑定全局快捷键
    document.addEventListener('keydown', handleGlobalKeydown)
})
// 生命周期钩子:释放资源
onBeforeUnmount(() => {
    // 清理事件监听
    document.removeEventListener('voiceReadingClick', handleVoiceReadingClick)
    document.removeEventListener('keydown', handleGlobalKeydown)
    
    // 销毁语音识别实例
    if (speechRecognition.value) {
        speechRecognition.value.disconnect()
        speechRecognition.value = null
    }
    
    // 销毁编辑器
    editorRef.value?.destroy()
})
</script>

3.3 样式优化(提升用户体验)

<style lang="scss" scoped>
// 录音状态指示器样式
.speech-recording-indicator {
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    z-index: 2000;
    background: rgba(255, 255, 255, 0.95);
    border-radius: 20px;
    padding: 30px;
    text-align: center;
    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
    
    .recording-animation {
        position: relative;
        width: 80px;
        height: 80px;
        margin: 0 auto 20px;
        
        .pulse-ring {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            width: 60px;
            height: 60px;
            border: 3px solid #1976d2;
            border-radius: 50%;
            opacity: 0;
            animation: pulseRing 2s ease-out infinite;
        }
        
        .microphone-icon {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            font-size: 32px;
            z-index: 10;
        }
    }
    
    .stop-btn {
        background: #f44336;
        color: white;
        border: none;
        border-radius: 25px;
        padding: 12px 24px;
        font-size: 16px;
        cursor: pointer;
        transition: all 0.3s ease;
    }
}
// 脉冲动画
@keyframes pulseRing {
    0% {
        width: 60px;
        height: 60px;
        opacity: 1;
    }
    100% {
        width: 120px;
        height: 120px;
        opacity: 0;
    }
}
</style>

UI 层核心亮点:

  • 录音状态可视化:通过脉冲动画直观展示录音中状态
  • 快捷键支持:ESC 键快速停止录音,提升操作便捷性
  • 结果实时插入:识别结果即时插入编辑器,无需手动复制
  • 资源自动释放:组件卸载时清理事件监听和实例,避免内存泄漏

4. 诊断工具层:配置检查(speechConfigChecker.js)

为了解决用户环境差异导致的功能异常,提供配置检查工具,自动诊断问题:

export class SpeechConfigChecker {
    constructor() {
        this.baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'
    }
    // 检查后端服务状态
    async checkBackendService() {
        try {
            const response = await fetch(`${this.baseUrl}/health`)
            return {
                success: response.ok,
                message: response.ok ? '后端服务正常' : `状态码: ${response.status}`
            }
        } catch (error) {
            return { success: false, message: '无法连接后端: ' + error.message }
        }
    }
    // 检查语音识别服务可用性
    async checkSpeechService() {
        try {
            const response = await fetch(`${this.baseUrl}/api/v1/ai/speech-recognition`)
            return {
                success: response.ok,
                message: response.ok ? '语音服务可用' : await response.text()
            }
        } catch (error) {
            return { success: false, message: '语音服务检查失败: ' + error.message }
        }
    }
    // 测试WebSocket连接
    async testWebSocketConnection() {
        return new Promise((resolve) => {
            const wsUrl = this.baseUrl.replace('http', 'ws')
            const ws = new WebSocket(`${wsUrl}/api/v1/ai/speech-recognition?sampleRate=16000`)
            const timeout = setTimeout(() => {
                ws.close()
                resolve({ success: false, message: 'WebSocket连接超时' })
            }, 5000)
            ws.onopen = () => {
                clearTimeout(timeout)
                ws.close()
                resolve({ success: true, message: 'WebSocket连接成功' })
            }
            ws.onerror = () => {
                clearTimeout(timeout)
                resolve({ success: false, message: 'WebSocket连接错误' })
            }
        })
    }
    // 检查浏览器兼容性
    checkBrowserCompatibility() {
        const features = [
            { name: 'WebSocket', check: () => typeof WebSocket !== 'undefined' },
            { name: 'AudioContext', check: () => typeof AudioContext !== 'undefined' },
            { name: 'getUserMedia', check: () => navigator.mediaDevices?.getUserMedia },
            { name: 'AudioWorklet', check: () => typeof AudioWorkletNode !== 'undefined' }
        ]
        return features.map(item => ({
            feature: item.name,
            supported: item.check(),
            message: item.check() ? '支持' : '不支持'
        }))
    }
    // 生成诊断报告
    generateDiagnosticReport(results) {
        const report = [
            '=== 语音识别配置诊断报告 ===',
            `后端服务: ${results.backend.success ? '✅ 正常' : '❌ 异常'} - ${results.backend.message}`,
            `语音服务: ${results.speechService.success ? '✅ 正常' : '❌ 异常'} - ${results.speechService.message}`,
            `WebSocket: ${results.webSocket.success ? '✅ 正常' : '❌ 异常'} - ${results.webSocket.message}`,
            '浏览器兼容性:'
        ]
        results.browser.forEach(item => {
            report.push(`  ${item.feature}: ${item.supported ? '✅' : '❌'} ${item.message}`)
        })
        return report.join('\n')
    }
}

核心价值:

  • 自动化诊断环境问题,减少用户反馈与排查成本
  • 覆盖服务可用性、网络连接、浏览器兼容性等关键检查点
  • 生成结构化报告,便于开发者定位问题

关键技术难点与解决方案

1. 音频格式一致性问题

  • 问题:不同浏览器采集的音频参数差异,导致识别服务无法解析
  • 解决方案
    • 强制指定采样率为 16kHz、单声道
    • 启用浏览器内置的回声消除、降噪功能
    • 统一转换为 16 位 PCM 格式,确保数据标准化

2. 实时性与性能平衡

  • 问题:音频数据传输过频繁导致网络开销大,过慢则识别延迟高
  • 解决方案
    • 缓冲区大小设置为 4096 字节(16kHz 单声道约 256ms 数据)
    • 使用 Transferable 对象避免数据复制,提升传输效率
    • 中间结果实时返回,最终结果确认,平衡实时性与准确性

3. 浏览器兼容性问题

  • 问题:部分旧浏览器不支持 AudioWorklet 等现代 API
  • 解决方案
    • 实现 AudioWorklet + ScriptProcessor 双重方案
    • 提前检查浏览器特性,自动降级
    • 提供清晰的兼容性提示,引导用户升级浏览器

4. 资源泄漏风险

  • 问题:录音过程中组件卸载或异常退出,导致音频流、WebSocket 未关闭
  • 解决方案
    • 在 Vue 生命周期钩子中统一释放资源
    • 录音状态与组件生命周期绑定,卸载时强制停止
    • WebSocket 连接关闭时清理相关状态

功能测试与优化建议

测试场景覆盖

  • 浏览器兼容性测试:Chrome、Firefox、Edge 等主流浏览器
  • 网络环境测试:弱网、断网场景下的错误处理
  • 权限测试:麦克风权限拒绝、授予后的状态切换
  • 异常场景测试:服务不可用、配置错误等情况

优化方向

  1. 音频预处理:添加音量检测,过滤静音片段,减少无效数据传输
  2. 识别结果优化:支持结果编辑、纠错功能
  3. 多语言支持:根据用户配置切换识别语言
  4. 离线识别:集成 Web Speech API 作为离线降级方案
  5. 性能监控:统计识别延迟、成功率等指标,优化用户体验

总结

这篇文章基于 Vue 3.5 和 WangEditor 实现了一套完整的笔记编辑器语音识别功能,通过分层设计实现了模块解耦,兼顾了实时性、兼容性和稳定性。核心亮点包括:

  • 高效的音频格式转换方案,确保识别服务兼容性
  • 完善的状态管理与错误处理,提升用户体验
  • 自动化配置诊断工具,降低问题排查成本
  • 可扩展的架构设计,便于后续功能迭代

该方案不仅适用于笔记编辑器,也可迁移到聊天、文档协作等其他需要语音输入的场景。通过合理的技术选型和架构设计,能够有效降低语音识别功能的集成难度,为用户提供便捷、高效的输入体验。

bkViewer小巧精悍数码照片浏览器 中文绿色版

作者 非凡ghost
2025年10月27日 16:29

bkViewer 是一款小巧精悍的数码照片浏览器,能方便的进行数码照片的浏览和集中处理,它体积小巧,仅有一个数百 K 的可执行文件,却功能强大,支持 37 种 RAW 格式直读以及多种视频格式的即时预览,具备图片编辑、元数据解析、批量处理等功能。

软件特点

EXIF、XMP、IPTC、GPS、ICC标记解析,ExifTool内核
ICC/ICM色彩管理,数码照片专业浏览
镜头型号、快门次数、光圈快门拍摄信息显示
拍摄时间、编辑程序、图片尺寸等多维分组排序
拍摄地点GPS精准标注,在线地图批量展示和聚合
RAW格式图片浏览及转换,视频文件集成管理及预览
批量缩放,裁剪,自动翻转,直方图,像素取色,色轮
人脸检测;自动对比、自动颜色、色彩增强、亮度均衡
浏览器图片信息在线查看DIY扩展
照片预读及缓存,异步加载,多核并发,Direct2D加速
国家地理每日桌面自动下载
纯绿色软件,单文件独立运行

重要说明

· Windows7/10版本采用Direct2D显卡加速技术显示图片,明显降低CPU消耗。配合Windows10使用,能明显提升RAW格式照片预览速度。
· bkViewer PRO版本支持人脸识别与检测。为保证运行速度与性能,只支持64位版本,且要求在支持AVX2指令的系统中运行。
· bkViewer PRO及X64版本支持HEIF/HEIC图片浏览,以原始大小显示H265/ts/mkv等视频文件,并与数码照片一致方式处理(缩放、拍摄时间和地点解析等)。
· bkViewer兼容版本最低支持Windows XP 32位平台运行,但不推荐使用。如果启动或运行出错,请检查CPU型号所支持SSE指令集是否匹配,并确保您机器已安装GDI+。
· bkViewer支持显示网络照片EXIF信息,直接使用“bkViewer.exe 网络图片URL地址”即可。欢迎自制IE、FireFox、Opera、Chrome等浏览器右键菜单或附加组件在线显示网络照片EXIF信息。

「bkViewer小巧精悍数码照片浏览器 v8.2 中文绿色版」 链接:pan.quark.cn/s/01bd7e2e9…

JS 自定义事件:从 CustomEvent 到 dispatchEvent

作者 三小河
2025年10月27日 16:12

前言

在前端开发中,事件是交互逻辑的核心载体。除了 clickscroll 等浏览器内置事件,我们还可以通过 CustomEvent 和 dispatchEvent 构建自定义事件系统,实现组件通信、逻辑解耦等复杂需求,下面将解析这两个 API 的工作机制与实践。

一、什么是自定义事件?

自定义事件是开发者根据业务需求手动创建的事件类型,它与浏览器内置事件的核心区别在于:触发时机、传递数据和作用范围完全由开发者控制

在 JavaScript 中,自定义事件的实现依赖两个核心 API:

  • CustomEvent:用于创建携带自定义数据的事件对象;
  • dispatchEvent:用于在指定 DOM 元素上触发事件,执行所有监听函数。

这两个 API 并非独立存在,而是协同工作:CustomEvent 负责 “定义事件”,dispatchEvent 负责 “触发事件”,二者结合构成了完整的自定义事件生命周期。

二、CustomEvent:构建自定义事件的 “数据载体”

CustomEvent 是一个原生构造函数,用于创建自定义事件实例。它的核心作用是将业务数据封装到事件中,并配置事件的行为特性(如是否冒泡、是否可取消)。

基本语法

const event = new CustomEvent(type, options);

参数详解

  1. type(必填) 事件名称(字符串),需遵循命名规范(建议使用小写字母 + 连字符,如 user-login,避免与内置事件冲突)。

  2. options(可选) 配置对象,支持三个核心属性:

    • detail:任意类型,自定义事件的核心数据载体(唯一推荐用于传递数据的属性);
    • bubbles:布尔值,默认 false,表示事件是否支持冒泡(从触发元素向上传播至父级);
    • cancelable:布尔值,默认 false,表示事件是否可被 preventDefault() 取消。

关键特性:detail 属性的特殊性

detail 是 CustomEvent 区别于普通 Event 的核心标志。它允许开发者传递任意类型的数据(对象、数组、函数等),且在事件传播过程中始终保持可访问。例如:

// 创建携带用户信息的自定义事件
const userEvent = new CustomEvent('user-update', {
  detail: {
    id: 1001,
    name: '张三',
    action: 'profile-edit'
  },
  bubbles: true,
  cancelable: true
});

这里的 detail 数据会被绑定到事件对象中,在监听函数中可通过 event.detail 获取。

三、dispatchEvent:触发事件的 “执行引擎”

dispatchEvent 是 DOM 元素的方法,用于在指定元素上触发一个事件(包括内置事件和自定义事件)。当事件被触发后,浏览器会按照事件流(捕获 -> 目标 -> 冒泡)的顺序执行所有相关的监听函数。

基本语法

const isDispatched = targetElement.dispatchEvent(event);

参数与返回值

  • event(必填) :事件对象(可以是 CustomEvent 实例,也可以是内置事件如 new Event('click'));
  • 返回值:布尔值。若事件可取消(cancelable: true)且被 preventDefault() 阻止,则返回 false;否则返回 true

触发逻辑:事件流的完整生命周期

当调用 dispatchEvent 时,事件会经历三个阶段(与内置事件一致):

  1. 捕获阶段:从 window 向下传播至目标元素的父级;
  2. 目标阶段:到达触发事件的目标元素;
  3. 冒泡阶段:从目标元素向上传播至 window(仅当 bubbles: true 时生效)。

例如,在子元素上触发一个支持冒泡的事件,父元素和 document 都能监听到:

<div id="parent">
  <button id="child">点击我</button>
</div>
// 监听父元素的自定义事件
document.getElementById('parent').addEventListener('custom-click', (e) => {
  console.log('父元素监听到事件');
});

// 在子元素上触发事件
const child = document.getElementById('child');
child.dispatchEvent(new CustomEvent('custom-click', { bubbles: true }));
// 输出:"父元素监听到事件"(因冒泡机制)

四、完整工作流程:从定义到触发的全链路

自定义事件的使用需经过 “定义事件 -> 监听事件 -> 触发事件” 三个步骤,缺一不可。以下是一个完整示例:

步骤 1:定义自定义事件(含数据)

// 定义一个携带表单数据的事件
const formSubmitEvent = new CustomEvent('form-submit', {
  detail: {
    username: 'test',
    password: '123456'
  },
  bubbles: true, // 允许冒泡,方便父级监听
  cancelable: true // 允许取消提交
});

步骤 2:在目标元素上监听事件

// 在表单容器上监听事件
const formContainer = document.getElementById('form-container');
formContainer.addEventListener('form-submit', (e) => {
  console.log('表单数据:', e.detail);
  
  // 验证数据,若无效则阻止默认行为
  if (e.detail.password.length < 6) {
    e.preventDefault(); // 仅当 cancelable: true 时有效
    alert('密码长度不足');
  }
});

步骤 3:在触发点调用 dispatchEvent

// 表单提交按钮点击时触发自定义事件
document.getElementById('submit-btn').addEventListener('click', () => {
  const isSuccess = formContainer.dispatchEvent(formSubmitEvent);
  if (isSuccess) {
    console.log('表单提交已触发');
  }
});

当点击按钮时,事件会从 formContainer 触发并冒泡,监听函数会接收数据并执行验证逻辑。若验证失败,preventDefault() 会阻止事件的默认行为。

五、典型使用场景:解决实际开发痛点

自定义事件的核心价值在于解耦代码逻辑灵活传递信息,以下是几个高频场景:

1. 组件间通信(尤其适用于非父子组件)

在前端框架(如 React、Vue)或原生组件开发中,非父子组件(如兄弟组件、跨层级组件)的通信往往难以通过 props 直接实现。此时,自定义事件可作为 “桥梁”:

// 组件 A(子组件)触发事件
class ComponentA extends HTMLElement {
  handleClick() {
    this.dispatchEvent(new CustomEvent('data-change', {
      detail: { value: '来自组件A的数据' },
      bubbles: true // 冒泡到父级
    }));
  }
}

// 组件 B(兄弟组件)监听事件
class ComponentB extends HTMLElement {
  connectedCallback() {
    document.addEventListener('data-change', (e) => {
      console.log('组件B收到数据:', e.detail.value);
    });
  }
}

通过事件冒泡,组件 B 无需依赖组件 A 的实例,即可接收数据,实现完全解耦。

2. 模拟用户行为(测试与自动化)

dispatchEvent 可触发内置事件,常用于测试场景中模拟用户操作(如点击、输入):

// 模拟按钮点击
const button = document.querySelector('button');
button.dispatchEvent(new Event('click'));

// 模拟输入框输入
const input = document.querySelector('input');
input.value = '测试文本';
input.dispatchEvent(new Event('input')); // 触发输入事件,更新相关逻辑

这在单元测试中尤为重要,可自动验证用户交互后的逻辑是否正常。

3. 跨模块状态同步

当多个独立模块需要响应同一状态变化时(如用户登录状态更新),自定义事件可避免模块间的直接依赖:

// 登录模块:登录成功后触发事件
loginModule.onSuccess = (user) => {
  window.dispatchEvent(new CustomEvent('user-login', { detail: user }));
};

// 导航模块:监听登录事件更新UI
navModule.init = () => {
  window.addEventListener('user-login', (e) => {
    this.renderUserAvatar(e.detail.avatar);
  });
};

// 消息模块:监听登录事件拉取消息
messageModule.init = () => {
  window.addEventListener('user-login', (e) => {
    this.fetchMessages(e.detail.id);
  });
};

登录模块无需关心哪些模块依赖登录状态,只需触发事件;其他模块按需监听,实现 “发布 - 订阅” 模式。

六、实践与注意事项

  1. 事件命名规范采用 “领域 - 行为” 格式(如 form-submituser-logout),避免使用单个单词(可能与内置事件冲突),且统一小写字母 + 连字符,增强可读性。

  2. 控制事件冒泡范围虽然 bubbles: true 方便跨层级监听,但过度冒泡可能导致性能问题或意外触发其他监听函数。建议:

    • 非必要不冒泡;
    • 如需冒泡,可在特定层级使用 event.stopPropagation() 终止传播。
  3. **谨慎使用 cancelable**仅当事件需要被 “阻止默认行为” 时才设置 cancelable: true(如表单提交验证),否则保持默认 false,减少不必要的性能消耗。

  4. 清理事件监听在组件销毁或页面卸载时,需通过 removeEventListener 移除自定义事件监听,避免内存泄漏:

    // 组件卸载时清理
    disconnectedCallback() {
      window.removeEventListener('user-login', this.handleLogin);
    }
    
  5. 避免过度使用自定义事件对于简单的父子组件通信,props 或回调函数可能更直观;自定义事件适用于复杂场景(跨层级、多模块),避免滥用导致逻辑混乱。

七、总结

CustomEvent 与 dispatchEvent 共同构成了 JavaScript 自定义事件系统的核心,能够像操作内置事件一样,灵活定义业务相关的交互逻辑。通过自定义事件,我们可以实现组件解耦、跨模块通信、行为模拟等功能。

掌握这两个 API 的关键在于理解 “事件流” 与 “数据传递” 的本质:事件是载体,数据是核心,传播是手段。合理使用它们,能让代码逻辑更清晰、扩展性更强。

window.postMessage与window.dispatchEvent

作者 砺能
2025年10月27日 15:27

postMessage 和 window.dispatchEvent 是两种不同的机制,虽然它们都可以通过 window.addEventListener 监听,但它们的设计目的、使用场景和功能有很大的区别。以下是它们的详细对比:

postMessage

postMessage 是用于 跨文档通信 的机制,主要用于在不同窗口、iframe 或不同域之间传递消息。

特点:
  • 跨域支持postMessage 是 HTML5 提供的标准 API,专门用于解决跨域通信问题。它可以在不同源(不同协议、域名或端口)的窗口或 iframe 之间安全地传递消息。
  • 消息传递:通过 postMessage,可以发送结构化数据(字符串、对象、数组等)到目标窗口。
  • 目标明确:需要指定消息的目标窗口(例如 window.parentiframe.contentWindow 等)。
  • 安全性postMessage 支持指定消息的来源(origin),接收方可以通过 event.origin 验证消息的来源是否可信。
使用场景:
  • 在 iframe 和父页面之间通信。
  • 在不同域的窗口之间传递数据。
  • 在多个窗口或标签页之间同步状态。
// 发送消息 
window.parent.postMessage({ type: "greeting", message: "Hello from iframe" }, "*"); 

// 接收消息 
window.addEventListener("message", (event) => {  
    if (event.origin !== "https://expected-origin.com") return; // 验证来源 
    console.log("Received message:", event.data); 
});

window.dispatchEvent

window.dispatchEvent 是用于触发自定义事件的机制,通常用于在同一文档或同一窗口内传递事件。

特点:
  • 单文档内通信dispatchEvent 主要用于在同一窗口或文档内触发事件,不支持跨域或跨窗口通信。
  • 自定义事件:可以创建和触发自定义事件(CustomEvent),并携带额外的数据。
  • 事件冒泡:触发的事件会按照 DOM 事件模型冒泡,可以被父元素捕获。
  • 无目标限制:事件是在当前窗口或文档内触发的,不需要指定目标窗口。
使用场景:
  • 在同一页面内组件或模块之间通信。
  • 触发自定义事件以通知其他部分代码。
  • 实现发布-订阅模式。
// 创建自定义事件 
const event = new Event("myEvent", { detail: { message: "Hello from dispatchEvent" } }); 

// 触发事件 
window.dispatchEvent(event); 

// 监听事件 
window.addEventListener("myEvent", (event) => {  
    console.log("Received event:", event.detail); 
});

主要区别

特性postMessagewindow.dispatchEvent跨域支持支持跨域通信仅支持同一文档内通信目标窗口需要明确指定目标窗口(如 iframe)在当前窗口内触发,无需指定目标数据传递可以传递结构化数据(字符串、对象等)通过 event.detail 传递数据事件冒泡不支持事件冒泡支持事件冒泡安全性支持验证消息来源(event.origin)无内置的跨域安全机制使用场景跨窗口、跨域通信单文档内组件通信4. 如何选择?

  • 如果需要 跨窗口或跨域通信,使用 postMessage
  • 如果只在 同一文档内通信,使用 window.dispatchEvent

结合使用的场景

在某些情况下,你可能需要结合使用这两种机制。例如:

  • 使用 postMessage 在不同窗口之间传递消息。
  • 在接收到消息后,使用 window.dispatchEvent 在当前文档内触发自定义事件,通知其他部分代码。
// A页面 
window.parent.postMessage({ type: "update", data: "New data" }, "*"); 

// B页面 
window.addEventListener("message", (event) => {  
    if (event.origin !== "https://expected-origin.com")  return;  
    const { type, data } = event.data;  
    if (type === "update") {   
        // 在当前文档内触发自定义事件   
        const customEvent = new CustomEvent("dataUpdated", { detail: data });
        window.dispatchEvent(customEvent);  
    } 
}); 

// B页面的其他部分监听自定义事件 
window.addEventListener("dataUpdated", (event) => {  
    console.log("Data updated:", event.detail); 
});

总结

  • postMessage 是用于 跨窗口或跨域通信 的机制。
  • window.dispatchEvent 是用于 同一文档内触发自定义事件 的机制。
  • 根据你的需求选择合适的机制,或者结合两者以实现更复杂的功能。

拓展

window.postMessage和window.dispatchEvent的区别
窗口通信(window.postMessage)
vue组件与iframe通信,防止多次触发messag事件
window.postMessage实现iframe跨源通信
window.postMessage 详细讲解
JS中的八种常用的跨域方式及其具体示例的总结

Symbol的11个内置符号的使用场景

作者 th739
2025年10月27日 15:21

以下是11 个内置 Symbol 及其典型使用场景或面试题。


1. Symbol.iterator

用途:定义对象的默认迭代器,用于 for...of、扩展运算符 ... 等。

const myIterable = {
  [Symbol.iterator]() {
    let step = 0;
    return {
      next() {
        step++;
        if (step <= 3) return { value: step, done: false };
        return { done: true };
      }
    };
  }
};

for (const v of myIterable) {
  console.log(v); // 1, 2, 3
}

面试题

如何让 { a: 1, b: 2, c: 3 }这样的普通对象支持 for...of循环?

const obj = {
  a: 1,
  b: 2,
  c: 3,
  *[Symbol.iterator]() {
    yield* Object.values(this)[Symbol.iterator]();
  },
};

for (const val of obj) {
  console.log(val); // 输出: 1, 2, 3
}

如何实现一个无限递增数字的迭代器?

const infiniteCounter = {
  *[Symbol.iterator]() {
    let count = 0;
    while (true) {
      yield count++;
    }
  },
};

const iterator = infiniteCounter[Symbol.iterator]();
console.log(iterator.next().value); // 0
console.log(iterator.next().value); // 1
console.log(iterator.next().value); // 2
// ...无限递增

2. Symbol.asyncIterator(ES2018)

用途:异步数据流(如分页API),用于 for await...of

const asyncPager = {
  data: [1, 2, 3, 4, 5],
  pageSize: 2,
  currentPage: 0,
  async *[Symbol.asyncIterator]() {
    while (this.currentPage * this.pageSize < this.data.length) {
      const start = this.currentPage * this.pageSize;
      const end = start + this.pageSize;
      const chunk = this.data.slice(start, end);
      yield new Promise((resolve) => {
        setTimeout(() => resolve(chunk), 1000); // 模拟异步请求
      });
      this.currentPage++;
    }
  }
};

(async () => {
  for await (const page of asyncPager) {
    console.log(page); // [1, 2] (1秒后) → [3, 4] (1秒后) → [5] (1秒后)
  }
})();

3. Symbol.toStringTag

用途:自定义 Object.prototype.toString.call(obj) 的输出。

class MyComponent {
  get [Symbol.toStringTag]() {
    return 'MyVueComponent';
  }
}

console.log(Object.prototype.toString.call(new MyComponent()));
// "[object MyVueComponent]"

应用场景:调试、日志、类型识别(如 Vue/React 组件标识)。


4. Symbol.hasInstance

用途:自定义 instanceof 的行为。

class MyArray {
  static [Symbol.hasInstance](instance) {
    return Array.isArray(instance);
  }
}

console.log([1, 2] instanceof MyArray); // true

面试题

实现一个 NegativeNumber类,使得任何负数都是它的实例。

class NegativeNumber {
  static [Symbol.hasInstance](instance) {
    return typeof instance === 'number' && instance < 0;
  }
}

console.log(-5 instanceof NegativeNumber); // true
console.log(5 instanceof NegativeNumber); // false

5. Symbol.isConcatSpreadable

用途:控制数组 concat 时是否展开。

const arr1 = [1, 2];
const arr2 = [3, 4];
arr2[Symbol.isConcatSpreadable] = false;

console.log(arr1.concat(arr2)); // [1, 2, [3, 4]]

6. Symbol.species

用途:指定派生类构造函数(如 mapfilter 返回的实例类型)。

class MyArray extends Array {
  static get [Symbol.species]() {
    return Array; // map/filter 返回普通 Array 而非 MyArray
  }
}

const a = new MyArray(1, 2, 3);
console.log(a.map(x => x * 2) instanceof MyArray); // false

常见于:自定义集合类(如 Immutable.js、RxJS)。


7. Symbol.match

用途:定义 String.prototype.match 调用时的行为。

const matcher = {
  [Symbol.match](str) {
    return str.includes('hello') ? ['hello'] : null;
  }
};

console.log('say hello'.match(matcher)); // ['hello']

8. Symbol.replace

用途:定义 String.prototype.replace 的行为。

const replacer = {
  [Symbol.replace](str, substr) {
    return str.split(' ').join(substr);
  }
};

console.log('a b c'.replace(replacer, '-')); // "a-b-c"

9. Symbol.search

用途:定义 String.prototype.search 的行为。

const searcher = {
  [Symbol.search](str) {
    return str.indexOf('world');
  }
};

console.log('hello world'.search(searcher)); // 6

10. Symbol.split

用途:定义 String.prototype.split 的行为。

const splitter = {
  [Symbol.split](str) {
    return str.split(/[\s,]+/);
  }
};

console.log('a b,c'.split(splitter)); // ['a', 'b', 'c']

11. Symbol.unscopables

用途:指定在 with 语句中被排除的属性(现代 JS 中极少使用)。

Array.prototype[Symbol.unscopables] = { copyWithin: true };

with ([]) {
  // copyWithin 不会被引入作用域
}

注意:由于 with 已被严格模式禁用,此 Symbol 几乎无实际用途。

基于 Vue 3 + Monorepo + 微前端的中后台前端项目框架全景解析

作者 CassieHuu
2025年10月27日 15:09

本文档旨在全面、深入地剖析一个采用现代化技术栈构建的中后台前端项目。通过从宏观架构到微观代码实现,从理论分析到实践指南,完整地呈现该项目的设计思想、工程化实践与核心优势。

第一章:项目概览与技术选型

在深入架构细节之前,我们首先对项目的整体技术栈有一个清晰的认识。这是一个基于 Vue 3 生态,采用业界前沿工程化方案构建的复杂应用。

1.1. 整体技术栈概览

分类 技术/库 版本 作用
核心框架 Vue.js ^3.5.22 渐进式 JavaScript 框架
Vue Router ^4.6.3 官方路由管理器
构建工具 Vite ^5.4.20 新一代前端构建工具
编程语言 TypeScript ~5.4.5 JavaScript 的超集,提供静态类型
架构模式 Qiankun ^2.10.16 微前端框架,用于构建大型复杂应用
状态管理 Pinia ^2.3.1 Vue 官方推荐的状态管理库
UI 组件库 Arco Design Vue ^2.57.0 字节跳动出品的企业级组件库
CSS 方案 Tailwind CSS & Less ^3.4.18 & ^4.4.2 原子化 CSS 框架与 CSS 预处理器结合
HTTP 请求 Axios ^1.12.2 基于 Promise 的 HTTP 客户端
代码规范 ESLint, Prettier, Husky, commitlint - 保证代码质量、风格统一和提交规范
开发工具 Vue DevTools ^7.7.7 Vue 官方浏览器调试工具

第二章:核心架构设计思想

该项目成功的关键在于其先进的顶层架构设计,它通过 Monorepo 和微前端的组合拳,优雅地解决了大型项目的协作、扩展和维护难题。

2.1. 项目架构:基于 pnpm Workspaces 的 Monorepo + Qiankun 微前端

  • Monorepo: 该项目采用 pnpmworkspaces 功能来管理多个子应用(package)。 从 package.jsonscripts 中可以看到,pnpm -F <package-name> dev 这样的命令用于独立启动不同的子应用。这种结构便于代码复用(如 common-ui, common-utils)、统一依赖管理和标准化工程配置。

  • 微前端 (Micro-Frontends): 项目引入了 qiankunvite-plugin-qiankun。这表明项目采用微前端架构,其中有一个主应用(基座)负责承载和路由,而其他应用作为微应用被动态加载。qiankun 是一个基于 single-spa 的微前端实现方案,旨在轻松构建生产可用的微前端架构体系。 这种架构非常适合大型、由不同团队维护的复杂系统,可以实现独立开发、独立部署,降低了应用间的耦合度。

2.2. 系统关系图谱:基座、微应用与共享模块

为了更形象地理解各模块间的关系,我们可以将其想象成一个“太阳系”:

  • 🪐 太阳 (基座应用): dvp-portal
  • orbiting_planet 行星 (微应用): * dvp-backstage * dvp-blue-shield * dvp-business-hub * dvp-ar-benefit * dvp-procure-guard * dvp-legal-monitor
  • 🌌 宇宙法则 (共享模块):
    • common-ui
    • common-utils

详细关系解读:

  1. dvp-portal (基座应用 - The Main App/Portal) dvp-portal 是整个系统的核心入口和容器。

    • 角色: 它是 Qiankun 架构中的 主应用(基座)
    • 职责:
      • 应用框架提供者: 提供整个应用的“外壳”,包括顶部导航栏、侧边菜单栏、用户状态管理等全局 UI 元素。
      • 路由中心: 负责监听浏览器 URL 变化,动态加载并渲染对应的微应用。
      • 全局状态与通信: 管理全局共享的状态(如用户信息),并提供应用间的通信机制。
      • 用户认证: 统一处理登录逻辑,并将认证信息传递给各个微应用。
  2. 其他 dvp-* 应用 (微应用 - The Micro Apps) dvp-backstage, dvp-blue-shield 等都是独立的、功能内聚的 微应用

    • 角色: 它们是 Qiankun 架构中的 子应用
    • 职责:
      • 业务功能实现: 专注于实现特定的业务功能。
      • 独立开发与部署: 可由不同团队独立开发、测试和部署,不影响其他应用。
      • 与基座的关系: 遵循 Qiankun 协议,导出生命周期钩子,由基座进行加载和卸载。
  3. common-ui & common-utils (共享模块 - The Shared Libraries) 这两个包是整个 Monorepo 的基石,体现了代码复用的最佳实践。

    • 角色: 被所有应用共享的本地依赖包。
    • common-ui 的职责: 封装通用业务组件,确保所有系统 UI 一致性。
    • common-utils 的职责: 提供跨应用的通用工具函数,如封装的 axios 实例、日期格式化、权限验证等。

2.3. 构建与开发环境:Vite 生态系统

项目全面拥抱 Vite 生态,以提升开发体验和构建效率。

  • Vite 作为构建核心: 利用其基于原生 ES Module 的开发服务器,提供了极快的冷启动和热更新(HMR)速度。
  • 基础配置 (vite.base.config.ts): 抽象通用 Vite 配置,包括路径别名、构建产物分类、生产环境移除 console.log 等优化。
  • 子应用配置: 通过 unplugin-auto-importunplugin-vue-components 实现组件和 API 的按需加载;通过 server.proxy 解决开发环境跨域问题;通过 base 配置项支持微前端部署。

2.4. 代码质量与工程化

项目建立了一套完整的工程化体系来保障代码质量和开发效率:

  • TypeScript: 全面采用,提供静态类型检查,增强代码的可维护性和健壮性。
  • ESLint & Prettier: 结合使用,统一代码风格和编码规范。
  • Husky & commitlint: 通过 Git Hooks,在代码提交前自动运行 commitlint 检查提交信息是否符合规范,确保提交历史的清晰可读。

第三章:核心流程深度剖析

理解了宏观架构后,我们通过分析两个最关键的用户流程——统一登录和路由系统,来深入探究该架构在实践中是如何运作的。

3.1. 架构剖析:以“统一登录”模块为核心

统一登录是串联起所有系统的“钥匙”。其实现方式清晰地反映了整个项目的架构思想。

统一登录流程分析:

  1. 用户访问入口: 任何访问都首先由 dvp-portal (基座应用) 接管。
  2. 认证检查 (在基座中): 基座的路由守卫会检查本地 Token。若无效,则重定向到基座的登录页;若有效,则继续。
  3. 用户登录: 用户在基座登录页完成认证,基座将获取的 Token 存入 Cookie,并将用户信息存入 Pinia。
  4. 加载微应用: 登录成功后,Qiankun 根据 URL 加载对应的微应用。
  5. 认证信息同步: 微应用通过 common-utils 中封装的全局 axios 实例发起请求。该实例的请求拦截器会自动从 Cookie 中读取 Token 并添加到请求头中。

    关键实践: 所有应用都 不直接使用 axios,而是使用这个来自共享模块的预配置实例,从而实现认证能力的无感植入。

  6. 登出: 用户点击基座上的“登出”按钮,由基座负责清除 Token 和全局状态,并重定向回登录页。

架构优势总结:

  • 职责单一: 基座全权负责认证、路由分发和全局布局。
  • 关注点分离: 微应用无需关心登录逻辑,只需专注于自身业务。
  • 高内聚、低耦合: 认证逻辑内聚在基座中,与微应用解耦。
  • 体验无缝: 对用户而言,整个系统浑然一体,体验流畅。

3.2. 架构剖析:以“路由系统”为核心

项目的路由系统被巧妙地拆分成了两层:基座主路由 + 微应用子路由。

路由工作流程详解:

我们可以将这个关系想象成一个大型机场的运作模式:

  • ✈️ 机场塔台 (基座主路由): 决定了用户请求应该飞往哪个微应用。
  • 팻 航站楼内部指引 (微应用子路由): 一旦进入微应用,其内部路由负责引导用户到具体的业务页面。
  1. 入口与分发 (在 dvp-portal 中): 当用户访问 https://.../backstage/users 时,基座的 vue-router 首先捕获请求。它的路由表中配置了通配规则,如 { path: '/backstage/:pathMatch(.*)*', ... }。当匹配成功,基座便通知 Qiankun 去加载 dvp-backstage 应用。

  2. 微应用接管 (在 dvp-backstage 中): dvp-backstage 内部拥有自己独立的 vue-router 实例,并配置了 history: createWebHistory('/backstage/')。它会接管 URL 中 /backstage/ 之后的部分(即 /users),并将其匹配到自己的业务页面组件。

架构优势分析:

  • 高度解耦: 每个微应用管理自己的路由,互不干扰。
  • 职责清晰: 基座只做“分发”,业务应用只做“实现”。
  • 独立部署与迭代: 路由变更可以跟随微应用独立上线,提升交付效率。
  • 无缝的用户体验: URL 变化和页面切换对用户来说是平滑无感的。

3.3. 实践揭秘:dvp-portal 中动态路由与权限控制的代码实现

前文概述了分层路由的宏观策略,本节将深入 dvp-portal 的路由代码,揭示这套基于后端权限、动态生成路由的最佳实践是如何将认证、权限、路由和菜单系统精巧地串联起来的。

整体设计思想:

该路由系统的核心思想是:

  • 静态路由 + 动态路由:系统包含一小部分用于承载基础布局和公共页面的静态路由(如登录页),以及大部分业务动态路由。
  • 权限驱动:动态路由并非硬编码在前端,而是由用户登录后从后端 API (getPermissionTree) 获取的权限树动态生成。
  • 一次性生成:在用户登录后的首次导航时,一次性获取权限、生成所有可访问的路由并添加到 vue-router 实例中,同时将菜单数据存入 Pinia (commonStore) 以供渲染。

代码逐段分析:

1. 初始化与平台判断

import { pcRoutes, mobileRoutes } from './routes';

const isMobile = terminalJudgment(),
  router = createRouter({
    history: createWebHistory(import.meta.env.BASE_URL),
    routes: isMobile ? mobileRoutes : pcRoutes
  });
  • 分析: 代码首先通过 terminalJudgment() 判断当前是移动端还是 PC 端。根据判断结果,加载不同的初始静态路由表 (mobileRoutespcRoutes)。这表明项目对不同终端有独立的布局和基础页面,设计考虑周全。

2. 动态组件加载与路由处理函数

const modulesFile = import.meta.glob(['@/modules/**/*.vue', '!**/{login,mobile,poc,portal,workbench}']),
  processSystemRoute = (tree?: PermissionTreeType, module?: string | symbol) => {
    const result: RouteRecordRaw[] = [];
    tree?.forEach((item) => {
      if (!item.metaData) return;
      const { component = '', name } = item.metaData,
        newItem = {
          ...item.metaData,
          children: processSystemRoute(item.childrens, module || name)
        };
      newItem.component = modulesFile[`/src/modules/${String(module || name)}/${component}`];
      result.push(newItem);
    });
    return result;
  };
  • import.meta.glob: 这是 Vite 提供的一个非常强大的功能。它会扫描 @/modules/ 目录下所有的 .vue 文件,并创建一个映射表 (modulesFile)。这使得后续可以根据一个字符串路径动态地、异步地加载对应的 Vue 组件,而无需手动 import 每一个组件。
  • processSystemRoute (核心函数):
    • 这是一个递归函数,负责将后端返回的树形权限数据 (PermissionTreeType) 转换成 vue-router 可识别的路由记录数组 (RouteRecordRaw[])。
    • 关键逻辑: newItem.component = modulesFile[...]。它根据后端返回的 component 字符串(如 'UserManagement.vue') 和模块名 (name),拼接出一个文件路径,然后从 modulesFile 映射表中查找到对应的组件加载函数。这就是实现路由组件动态绑定的魔法所在。
    • 这种设计将前端路由与后端权限完美解耦。后端只需要通过 JSON 数据告诉前端“这个菜单叫什么,路径是什么,对应哪个组件文件”,前端就能自动完成路由的创建。

3. 全局导航守卫 (router.beforeEach) 这是整个路由系统的“大脑”,控制着所有的导航行为。

router.beforeEach(async (to, from) => {
  const { path, query, meta } = to,
    token = getToken(),
    commonStore = useCommonStore();
  // ...
  • 白名单与登录逻辑: 首先处理了已登录用户访问登录页、白名单路径、未登录用户访问受保护页面等标准认证流程。如果没有 token,则通过 redirectLogin() 跳转到登录页。

  • 动态路由生成逻辑 (最关键的部分):

    if (!commonStore.systemMenus) {
      commonStore.systemMenus = [];
      const res = await getPermissionTree();
      processSystemRoute(res).forEach((item) => {
        router.addRoute(item);
        commonStore.systemMenus?.push(item);
      });
      return { ...to, replace: true };
    }
    
    • 触发条件: !commonStore.systemMenus。这个条件判断用户已登录(有 token),但 Pinia store 中还没有系统菜单数据。这通常只在用户登录后的第一次页面跳转时发生。
    • 防止重复: commonStore.systemMenus = [] 立即设置一个空数组,防止因为异步操作导致此代码块被重复执行。
    • 获取与处理: await getPermissionTree() 从后端获取权限树,然后通过 processSystemRoute(res) 将其转换为路由记录。
    • 注入路由: router.addRoute(item) 将新生成的路由一条条地动态添加到 vue-router 实例中。
    • 存储菜单: commonStore.systemMenus?.push(item) 将路由数据也存入 Pinia,提供给菜单组件(如侧边栏)进行渲染。
    • 重新导航: return { ...to, replace: true } 是点睛之笔。当 addRoute 执行完毕后,当前的导航需要被中断并重新发起一次。replace: true 确保这次重定向不会留下历史记录。当导航重新开始时,vue-router 已经拥有了新的路由表,因此可以正确匹配到用户想要访问的目标页面。

第四章:开发与部署策略

优秀的架构不仅要设计优雅,还必须能支持高效的开发和灵活的部署。

4.1. 本地开发管理:pnpm workspace 的符号链接机制

在本地开发中,pnpm 通过 符号链接(Symbolic Link) 机制提供了极致的开发体验。

  • 原理: 您可以将符号链接想象成电脑桌面上的快捷方式。当 dvp-portal 依赖 common-utils 时,pnpm 不会复制一份代码,而是在 dvp-portal/node_modules/ 里创建一个指向 packages/common-utils 真实位置的“快捷方式”。
  • 带来的好处:
    1. 实时更新: 当您修改了 common-utils 包中的代码,所有依赖它的本地应用会立即感知到变化。结合 Vite 的热更新 (HMR),您可以实时看到修改效果,无需任何重新构建或安装。
    2. 单一代码源: 保证了整个项目中共享包只有一个版本和一份代码,避免了依赖地狱。
    3. 简化调试: 可以直接从应用代码无缝跳转到共享包的源码进行调试。

4.2. 自由组合与本地化交付部署

这个 “一个基座 + 多个可插拔的微应用” 的设计模式,非常适合进行自由组合和本地化交付部署

运作方式:

假设客户只需要后台管理 (dvp-backstage)业务中心 (dvp-business-hub) 两个功能。

  1. 打包构建: 只需分别构建 dvp-portaldvp-backstagedvp-business-hub。其他微应用则完全忽略。
  2. 服务器部署: 将基座和所需的微应用产物部署到服务器的不同路径下。
  3. 动态加载配置: 在基座中,通过一份配置文件来注册需要加载的微应用。这份配置只包含 dvp-backstagedvp-business-hub,这样用户的菜单中就只会显示这两个系统的入口。

架构优势总结:

  • 灵活性极高: 可以像“搭积木”一样为不同客户定制产品组合。
  • 部署包最小化: 只部署客户需要的功能模块,减少资源占用。
  • 维护和升级独立: 可对单个微应用进行热更新和敏捷交付,不影响其他系统。

第五章:代码级设计模式范例

在代码层面,项目巧妙地运用了多种设计模式来保证代码的健壮性和可维护性。

5.1. 单一实例(Singleton-like)模式范例

指那些在整个应用生命周期中,其逻辑和状态是全局唯一的、共享的模块。

  • 通用认证 Token 获取 (getGeneralToken): 从一个硬编码的 Cookie 键中读取数据,提供全局唯一的、标准的 Token 获取方式。
  • 文件下载工具 (downloadFile): 纯粹的、无状态的工具函数,在整个项目中提供单一、可复用的功能实现。
  • 身份证验证工具 (idCardVerify): 典型的无状态验证函数,内部逻辑固定,在任何模块调用都得到相同的验证行为。
  • HTTP 状态码映射 (errorCodeMap): 一个导出的常量对象,为整个项目提供了一套统一的 HTTP 错误码映射,是唯一的“真理之源”。

5.2. 多实例(Multi-instance)模式范例

通常通过类或工厂函数实现,每次创建实例时,都可以传入不同配置,使得每个实例拥有自己独立的状态和行为。

  • HttpService 网络请求服务: 最典型的多实例范例。通过 new HttpService(...) 创建实例,每个实例可以接收不同的 baseURLlogoutFn 等配置,从而拥有指向不同后端、包含独立拦截器的 axios 实例。
  • 各个子应用的认证逻辑 (auth.ts): 每个子应用都拥有自己的 auth.ts,它们使用不同的 TokenKey(如 'Token', 'token-dams')。这本质上是多实例模式的应用,隔离了不同业务系统的认证体系,避免 Token 互相覆盖。
  • Vite 基础配置工厂 (createBaseConfig): 一个工厂函数,接收参数并为每个子应用生成一个定制化的 Vite 配置实例(例如,@ 别名指向各自的 src 目录)。
  • Vite 依赖预构建产物: Vite 为每个应用创建了独立的依赖预构建文件夹 (.vite/deps),确保了每个应用的依赖环境是隔离的。

第六章:从理论到实践:实现指南

理解了上述理论后,本章将提供一些关键的“战术层面”实现细节,帮助您从零到一构建类似的项目。

6.1. 如何配置 Qiankun 基座和微应用?

dvp-portal (基座) 中: 你需要一个入口文件来注册微应用。

// src/micro-app.ts (示例)
import { registerMicroApps, start } from 'qiankun';

// 1. 定义微应用列表
const apps = [
  {
    name: 'dvp-backstage', // 唯一名称
    entry: import.meta.env.DEV ? '//localhost:3001' : '/backstage/', // 微应用的访问地址
    container: '#subapp-container', // 挂载容器的 CSS 选择器
    activeRule: '/backstage', // 激活规则,URL 匹配时加载
  },
  // ... 其他微应用
];

// 2. 注册并启动
registerMicroApps(apps);
start({
  prefetch: 'all', // 预加载所有微应用资源
  sandbox: { strictStyleIsolation: true } // 开启严格的样式隔离
});

同时,在你的主布局组件中,必须包含 <div id="subapp-container"></div> 作为挂载点。

dvp-backstage (微应用) 中: 你需要 vite-plugin-qiankun 来帮助你导出生命周期。

// vite.config.ts
import qiankun from 'vite-plugin-qiankun';

export default defineConfig({
  plugins: [
    qiankun('dvp-backstage', { // 'dvp-backstage' 必须和基座注册的 name 一致
      useDevMode: true
    })
  ],
});
// src/main.ts
import { renderWithQiankun, qiankunWindow } from 'vite-plugin-qiankun/dist/helper';

let app: App;

function render(props: any) {
  const { container } = props;
  app = createApp(RootComponent);
  app.mount(container ? container.querySelector('#app') : '#app');
}

if (!qiankunWindow.__POWERED_BY_QIANKUN__) {
  render({}); // 独立运行
} else {
  // 导出 Qiankun 需要的生命周期钩子
  renderWithQiankun({
    mount(props) { render(props); },
    bootstrap() { /* ... */ },
    unmount(props) { app.unmount(); },
  });
}

6.2. 如何实现动态菜单和路由?

项目的权限和菜单是由后端 API 动态生成的。

dvp-portal (基座) 中:

  1. 登录后获取权限: 调用 API 获取权限和菜单的 JSON 数据。
  2. 解析数据: 编写一个函数递归遍历这份数据。
    • 生成路由: 将数据转换成 vue-router 的路由记录。对于微应用的路由,生成通配规则,如 { path: '/backstage/:pathMatch(.*)*', ... }。使用 router.addRoute() 动态添加。
    • 生成菜单: 将数据转换成 UI 组件库(如 Arco Design)的 menu 组件所需的数据结构,用于渲染侧边栏。
    • 注册微应用: 从数据中提取出微应用信息,动态生成 registerMicroApps 所需的 apps 列表。

6.3. 如何优雅地处理多实例配置?

  • HttpService: 每个应用在自己的入口处 new HttpService(...),传入各自的 baseURL 和特定的 getToken 函数。这样,每个应用就拥有了指向不同后端、使用不同 Token 逻辑的独立请求实例。
  • auth.ts: 每个应用内部都有一个 auth.ts,它们使用不同的 TokenKey。这是为了隔离不同业务系统的认证体系,是一个非常周全的考虑,特别是在本地化交付时,不同系统可能对接不同的认证中心。

第七章:总结

总的来说,这个项目采用了一套非常成熟且主流的技术方案。

  • 架构先进: Monorepo + 微前端的组合拳,很好地解决了大型前端项目的管理和协作痛点。
  • 工具链现代: 全面拥抱 Vite 生态,开发体验和构建效率都处于业界前沿。
  • 工程化完备: 从代码规范、提交规范到自动化检查,形成了一套完整的质量保障体系。
  • 技术选型合理: Vue 3 + Pinia + TypeScript + Arco Design 是目前构建中后台应用的黄金组合之一。

通过以上全面的分析,我们不仅理解了该项目的设计思想,也掌握了从零开始构建一个类似的高质量前端项目的关键知识和实践方法。项目的关键知识和实践方法。

🚀 真正实用的前端算法技巧:从 semver-compare 到智能版本排序

2025年10月27日 15:09

背景

image.png

如图,真实的场景中,数据会返回很多版本号,需要和当前版本做比较,筛选出比当前版本更新的版本,以及对版本进行排序等。

其实在前端工程中,我们经常需要比较不同版本号,例如判断:

  • 某个包是否有新版本;
  • 数据结构或配置文件版本是否落后;
  • 更新提示是否应该展示。

大多数人第一反应可能是使用 semver 包,但其实有时候我们只需要一个简单的比较函数,而不需要完整的语义化解析。这时 semver-compare 就是一个极佳选择。


🧩 一、库介绍

semver-compare 是一个极小的 npm 库(仅十几行代码),功能非常单一:
👉 比较两个语义化版本号(SemVer),返回 -1 / 0 / 1

pnpm add semver-compare

⚙️ 二、源码解析

来看源码(几乎是整个库的全部内容):

// semver-compare/index.js
module.exports = function cmp (a, b) {
    var pa = a.split('.');
    var pb = b.split('.');
    for (var i = 0; i < 3; i++) {
        var na = Number(pa[i]);
        var nb = Number(pb[i]);
        if (na > nb) return 1;
        if (nb > na) return -1;
        if (!isNaN(na) && isNaN(nb)) return 1;
        if (isNaN(na) && !isNaN(nb)) return -1;
    }
    return 0;
};

核心逻辑:

  1. 将版本号字符串按 . 拆分为数组
  2. 逐段转成数字并比较
  3. 如果前两段相等,继续比较后面的
  4. 若全部相等则返回 0
  5. 如果 A > B 返回 1,反之 -1

简单、高效、无依赖。


📖 三、语义化版本(SemVer)简要回顾

语义化版本号的基本格式是:

MAJOR.MINOR.PATCH

例如:

1.4.2

其意义是:

  • MAJOR:破坏性更新(不兼容变更)
  • MINOR:新增功能但兼容
  • PATCH:修复 bug

可附加:

  • 预发布版本:1.4.2-beta
  • 元数据:1.4.2+build2025

🧠 四、示例代码解析

const normalizeVersion = (v) => {
  const base = String(v || "")
    .trim()
    .replace(/^v/i, "")
    .split("-")[0]; // 去掉 'v' 前缀与预发布标签
  const parts = base.split(".").map((n) => n.replace(/[^\d]/g, ""));
  const clean = parts.filter(Boolean);
  while (clean.length < 3) clean.push("0"); // 补齐到 x.y.z
  return clean.slice(0, 3).join(".");
};

const result =
  versions
    ?.sort((a, b) =>
      semverCompare(
        normalizeVersion(a?.version),
        normalizeVersion(b?.version)
      )
    )
    .filter(
      (item) =>
        semverCompare(
          normalizeVersion(data?.version),
          normalizeVersion(item?.version)
        ) < 0
    ) || [];

🔍 拆解:

  1. normalizeVersion()
    • 清洗版本号:去掉前缀 v(如 v1.2.31.2.3)\
    • 去除预发布标签(如 1.2.3-beta1.2.3)\
    • 补齐到三位(1.21.2.0
  2. 排序与过滤
    • 使用 semverCompare 对所有版本进行排序;
    • 再筛选出「比当前版本新的」版本。

🧩 举例

const versions = [{version: "v1.0.0"}, {version: "1.2.0"}, {version: "1.1.5-beta"}];
const data = {version: "1.0.5"};

const result = versions
  .sort((a, b) => semverCompare(normalizeVersion(a.version), normalizeVersion(b.version)))
  .filter(item => semverCompare(normalizeVersion(data.version), normalizeVersion(item.version)) < 0);

console.log(result.map(r => r.version)); 
// => ["1.1.5-beta", "1.2.0"]

🪄 五、适用场景

  1. 版本排序 将一组版本按新旧排序
  2. 检测新版本 比较当前版本是否落后
  3. 更新提示系统 App、插件、配置管理等
  4. 构建时依赖检查 判断依赖库最小版本是否满足要求
  5. ⚠️ 不适用复杂版本规则,若涉及预发布(betaalpha)或元数据比较,应使用 semver 官方包

🧾 六、结论

semver-compare 是一个 极轻量但实用 的库:

  • 适合运行时比较;
  • 特别适合 "检测新版本 / 排序版本列表" 的场景;
  • 若未来涉及更复杂规则,可平滑迁移到 semver 官方包。

一句话总结
小场景选 semver-compare,大项目选 semver

第一次使用 styled-components

2025年10月27日 15:01

styled-components 是 React 中最流行、最强大的 CSS-in-JS 方案之一。

下面我会帮你系统梳理一套“快速上手 + 常用实战场景代码”清单,你照着练一遍就能快速掌握 90% 的日常用法。


🧩 一、核心概念理解

✅ 什么是 styled-components?

它让你能在 React 组件中写出这样的代码:

import styled from "styled-components";

const Button = styled.button`
  background: #007bff;
  color: white;
  padding: 8px 16px;
  border-radius: 8px;
  border: none;

  &:hover {
    background: #0056b3;
  }
`;

export default function App() {
  return <Button>点击我</Button>;
}

👉 样式写在组件里(支持所有 CSS 语法),自动生成唯一 className,避免命名冲突。


🚀 二、快速入门步骤

1️⃣ 安装

npm install styled-components

(TS 项目建议再装)

npm install --save-dev @types/styled-components

2️⃣ 创建样式组件

import styled from "styled-components";

const Container = styled.div`
  max-width: 1200px;
  margin: 0 auto;
`;

export default function App() {
  return <Container>Hello styled-components</Container>;
}

🧱 三、开发中常用实战场景

🧠 1. 动态样式(根据 props 改变样式)

const Button = styled.button<{ $primary?: boolean }>`
  background: ${({ $primary }) => ($primary ? "#007bff" : "#ccc")};
  color: white;
  border: none;
  padding: 8px 16px;
  border-radius: 6px;
`;

export default function App() {
  return (
    <>
      <Button $primary>主要按钮</Button>
      <Button>次要按钮</Button>
    </>
  );
}

✅ 关键:props 前加 $ 可避免 TS 报错并防止传到 DOM。


🎨 2. 主题(ThemeProvider)

全局定义主题(颜色、字号等):

import styled, { ThemeProvider } from "styled-components";

const theme = {
  colors: {
    primary: "#007bff",
    secondary: "#6c757d",
  },
};

const Title = styled.h1`
  color: ${({ theme }) => theme.colors.primary};
`;

export default function App() {
  return (
    <ThemeProvider theme={theme}>
      <Title>这是标题</Title>
    </ThemeProvider>
  );
}

🧩 3. 样式继承(继承另一个 styled 组件)

const Button = styled.button`
  background: gray;
  color: white;
`;

const PrimaryButton = styled(Button)`
  background: blue;
`;

📦 4. 嵌套选择器 & 状态样式

const Card = styled.div`
  padding: 16px;
  border: 1px solid #eee;

  h3 {
    margin-bottom: 8px;
  }

  &:hover {
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
  }
`;

🧮 5. 响应式写法(内联媒体查询)

const Box = styled.div`
  width: 100%;
  background: lightgray;

  @media (min-width: 768px) {
    background: pink;
  }
`;

🪄 6. 动画(使用 keyframes)

import styled, { keyframes } from "styled-components";

const fadeIn = keyframes`
  from { opacity: 0; transform: translateY(10px); }
  to { opacity: 1; transform: translateY(0); }
`;

const AnimatedDiv = styled.div`
  animation: ${fadeIn} 0.5s ease-in-out;
`;

🧩 7. 全局样式(GlobalStyle)

import { createGlobalStyle } from "styled-components";

const GlobalStyle = createGlobalStyle`
  body {
    margin: 0;
    font-family: 'Inter', sans-serif;
    background: #f8f9fa;
  }
`;

export default function App() {
  return (
    <>
      <GlobalStyle />
      <div>Hello World</div>
    </>
  );
}

⚙️ 8. 组件之间复用 mixin(常用技巧)

import { css } from "styled-components";

const flexCenter = css`
  display: flex;
  justify-content: center;
  align-items: center;
`;

const Box = styled.div`
  ${flexCenter};
  height: 100px;
  background: #eee;
`;

🧱 9. 条件样式组合

const Tag = styled.span<{ $type: "success" | "error" }>`
  color: ${({ $type }) => ($type === "success" ? "green" : "red")};
  border: 1px solid currentColor;
  padding: 4px 8px;
  border-radius: 4px;
`;

🧩 10. 动态组件类型(as)

const Button = styled.button`
  background: blue;
  color: white;
`;

export default function App() {
  return (
    <>
      <Button>按钮</Button>
      <Button as="a" href="#">
        链接样式
      </Button>
    </>
  );
}

🧠 四、进阶建议

场景 推荐方式
全局变量(颜色、间距) 使用 ThemeProvider
公共样式(布局、按钮) 定义 mixin 或基础组件
响应式布局 使用 CSS 媒体查询或 styled-system
动态切换主题 搭配 React Context + ThemeProvider
SSR(Next.js) 需配置 ServerStyleSheet

✅ 五、快速学习路线总结

阶段 目标 学习重点
入门 写静态组件 styled.div / 动态 props
实战 页面组件化 theme、嵌套、动画
进阶 复用与规范 mixin、全局样式、响应式
企业实践 主题系统 dark/light 模式、组件库封装

antd 组件开发

在 Ant Design 项目中优雅地修改组件样式(尤其是 Table、Form 等复杂组件) ——
是前端开发中最常见、也是最容易“写乱”的部分。


🧩 一、AntD + styled-components 修改样式的推荐策略

AntD 组件结构复杂(如 .ant-table, .ant-form-item 内部嵌套很多层),
直接 styled(组件) 往往不够精准。

👉 推荐的三种层级控制策略如下:

优先级 场景 推荐写法
1. 外层容器 + class 精准覆盖 修改组件内部结构样式 styled.div 包裹 .ant-xxx
2. styled(antd组件) 修改按钮、Input、Card 等简单组件 styled(Button)
⚙️ 3. 主题/Token 修改 全局颜色、边框、字体等统一 ConfigProvider theme={{ token: {} }}

🚀 二、实战写法(Table、Form)


🧱 1️⃣ 修改 Table 样式

AntD 的 Table 结构复杂(表头 + 单元格 + 滚动条),推荐使用外层容器 + class 选择器

import styled from "styled-components";
import { Table } from "antd";

const StyledTableWrapper = styled.div`
  .ant-table {
    border-radius: 8px;
    overflow: hidden;
  }

  .ant-table-thead > tr > th {
    background: #fafafa;
    font-weight: 600;
  }

  .ant-table-tbody > tr:hover > td {
    background: #f0f7ff;
  }

  .ant-table-cell {
    padding: 12px 16px;
  }
`;

export default function Demo() {
  const dataSource = [{ key: 1, name: "张三", age: 28 }];
  const columns = [    { title: "姓名", dataIndex: "name" },    { title: "年龄", dataIndex: "age" },  ];

  return (
    <StyledTableWrapper>
      <Table dataSource={dataSource} columns={columns} pagination={false} />
    </StyledTableWrapper>
  );
}

✅ 这种写法最安全,不会影响全局,也能精确控制局部样式。


🧩 2️⃣ 修改 Form 样式

Form 内部同样嵌套 .ant-form-item.ant-form-item-label.ant-form-item-control
用容器 + 局部覆盖最稳定。

import styled from "styled-components";
import { Form, Input, Button } from "antd";

const StyledForm = styled(Form)`
  max-width: 400px;
  margin: 0 auto;

  .ant-form-item {
    margin-bottom: 20px;
  }

  .ant-form-item-label > label {
    font-weight: 500;
  }

  .ant-input {
    border-radius: 6px;
    height: 40px;
  }

  .ant-btn-primary {
    border-radius: 6px;
    font-weight: 600;
  }
`;

export default function DemoForm() {
  return (
    <StyledForm layout="vertical">
      <Form.Item label="用户名">
        <Input placeholder="请输入用户名" />
      </Form.Item>
      <Form.Item label="密码">
        <Input.Password placeholder="请输入密码" />
      </Form.Item>
      <Form.Item>
        <Button type="primary" block>
          登录
        </Button>
      </Form.Item>
    </StyledForm>
  );
}

✅ 局部容器内重写 .ant-xxx,可实现完整控制而不影响全局。


🧠 三、推荐封装思路

✅ 1. 通用样式容器封装

定义一个全局通用容器组件,例如:

// src/components/StyledWrapper.tsx
import styled from "styled-components";

export const StyledWrapper = styled.div`
  .ant-btn {
    border-radius: 6px;
  }

  .ant-input {
    border-radius: 4px;
  }

  .ant-table {
    border-radius: 8px;
  }
`;

使用时只需包裹局部区域:

<StyledWrapper>
  <Table ... />
</StyledWrapper>

✅ 适合团队统一风格管理,不污染 AntD 全局样式。


✅ 2. 封装通用组件

例如二次封装一个统一风格的表单:

import { Form } from "antd";
import styled from "styled-components";

export const StyledForm = styled(Form)`
  .ant-form-item {
    margin-bottom: 16px;
  }
`;

之后你所有的表单都使用 <StyledForm>,统一视觉。


🎨 四、配合主题变量(ThemeProvider + ConfigProvider)

在大多数企业项目中,你可以用双层主题管理:

<ConfigProvider
  theme={{
    token: {
      colorPrimary: "#1677ff",
      borderRadius: 8,
    },
  }}
>
  <ThemeProvider theme={{ primary: "#1677ff" }}>
    <App />
  </ThemeProvider>
</ConfigProvider>

在 styled 里使用:

const Title = styled.h2`
  color: ${({ theme }) => theme.primary};
`;

在 AntD 中使用:

<Button type="primary">按钮</Button>

✅ 双主题体系:AntD 控制组件级 token,styled 控制自定义区域。


🧩 五、总结推荐写法清单

场景 推荐写法 说明
修改 AntD 简单组件(Button、Input) styled(Button) 简洁直接
修改复杂组件(Table、Form、Modal) styled.div + .ant-xxx 精确安全
全局视觉定制 ConfigProvider theme={{ token: {} }} 官方推荐
自定义样式变量 ThemeProvider 自定义区域样式
可复用的布局或容器 封装 StyledWrapper 团队统一样式管理

⚡ 六、额外技巧(进阶)

✅ 动态主题切换(亮色 / 暗色)

用 React Context + ThemeProvider 切换 theme,同时动态传给 ConfigProvider

✅ 控制样式作用域

如果需要防止样式“溢出”,可以用:

const Wrapper = styled.div.attrs({ className: 'custom-scope' })``;

然后用 .custom-scope .ant-xxx 精准限定。

✅ 防止样式失效

AntD 样式层级高(有时用 inline style),
必要时可以使用 !important,但建议局部使用,不滥用。


❌
❌