普通视图

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

每日一题-网格图操作后的最大分数🔴

2026年4月29日 00:00

给你一个大小为 n x n 的二维矩阵 grid ,一开始所有格子都是白色的。一次操作中,你可以选择任意下标为 (i, j) 的格子,并将第 j 列中从最上面到第 i 行所有格子改成黑色。

如果格子 (i, j) 为白色,且左边或者右边的格子至少一个格子为黑色,那么我们将 grid[i][j] 加到最后网格图的总分中去。

请你返回执行任意次操作以后,最终网格图的 最大 总分数。

 

示例 1:

输入:grid = [[0,0,0,0,0],[0,0,3,0,0],[0,1,0,0,0],[5,0,0,3,0],[0,0,0,0,2]]

输出:11

解释:

第一次操作中,我们将第 1 列中,最上面的格子到第 3 行的格子染成黑色。第二次操作中,我们将第 4 列中,最上面的格子到最后一行的格子染成黑色。最后网格图总分为 grid[3][0] + grid[1][2] + grid[3][3] 等于 11 。

示例 2:

输入:grid = [[10,9,0,0,15],[7,1,0,8,0],[5,20,0,11,0],[0,0,0,1,2],[8,12,1,10,3]]

输出:94

解释:

我们对第 1 ,2 ,3 列分别从上往下染黑色到第 1 ,4, 0 行。最后网格图总分为 grid[0][0] + grid[1][0] + grid[2][1] + grid[4][1] + grid[1][3] + grid[2][3] + grid[3][3] + grid[4][3] + grid[0][4] 等于 94 。

 

提示:

  • 1 <= n == grid.length <= 100
  • n == grid[i].length
  • 0 <= grid[i][j] <= 109

前端性能优化实战指南

作者 Csvn
2026年4月28日 20:30

概述

性能优化是前端开发中至关重要的一环。优秀的性能不仅提升用户体验,还能提高转化率、降低跳出率,并改善 SEO 排名。本文将深入探讨前端性能优化的核心策略和实战技巧。

一、性能指标与测量

1.1 核心 Web 指标 (Core Web Vitals)

// 使用 Web Vitals 库测量核心指标
import { getCLS, getFID, getLCP } from 'web-vitals';

getCLS(console.log);   // 累积布局偏移
getFID(console.log);   // 首次输入延迟
getLCP(console.log);   // 最大内容绘制

关键指标说明:

  • LCP (Largest Contentful Paint) : 最大内容绘制,衡量加载性能

    • 优秀:≤ 2.5 秒
    • 需要改进:2.5-4.0 秒
    • 差:> 4.0 秒
  • FID (First Input Delay) : 首次输入延迟,衡量交互性

    • 优秀:≤ 100 毫秒
    • 需要改进:100-300 毫秒
    • 差:> 300 毫秒
  • CLS (Cumulative Layout Shift) : 累积布局偏移,衡量视觉稳定性

    • 优秀:≤ 0.1
    • 需要改进:0.1-0.25
    • 差:> 0.25

1.2 性能测量工具

# 使用 Lighthouse 进行性能审计
npx lighthouse https://example.com --view

# 使用 Chrome DevTools Performance 面板
# 使用 WebPageTest 进行多地点测试

# 使用 PageSpeed Insights
curl "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=https://example.com"

二、加载性能优化

2.1 资源压缩与优化

2.1.1 图片优化

// 使用 modern 图片格式
<img src="image.webp" alt="描述" 
     srcset="image-400w.webp 400w, image-800w.webp 800w"
     sizes="(max-width: 600px) 400px, 800px"
     loading="lazy">

// 使用 picture 元素提供多种格式
<picture>
  <source srcset="image.avif" type="image/avif">
  <source srcset="image.webp" type="image/webp">
  <img src="image.jpg" alt="描述" loading="lazy">
</picture>

图片优化策略:

  • 使用 WebP/AVIF 等现代格式
  • 实现响应式图片(srcset + sizes)
  • 懒加载非首屏图片
  • 使用 CDN 进行图片优化

2.1.2 代码压缩

// Vite 配置优化
export default defineConfig({
  build: {
    minify: 'terser', // 使用 terser 进行压缩
    terserOptions: {
      compress: {
        drop_console: true, // 生产环境移除 console
        drop_debugger: true,
      },
    },
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ['vue', 'vue-router', 'pinia'],
          utils: ['lodash-es', 'dayjs'],
        },
      },
    },
  },
});

2.2 资源预加载与预获取

<!-- 关键资源预加载 -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/js/hero.js" as="script">

<!-- 未来导航预获取 -->
<link rel="prefetch" href="/js/about.js">
<link rel="preconnect" href="https://api.example.com">

<!-- 智能预加载 -->
<script>
  // 检测用户意图,预加载可能访问的页面
  document.addEventListener('mouseover', (e) => {
    if (e.target.tagName === 'A') {
      const url = e.target.href;
      if (isSameOrigin(url)) {
        const link = document.createElement('link');
        link.rel = 'prefetch';
        link.href = url;
        document.head.appendChild(link);
      }
    }
  });
</script>

2.3 代码分割与懒加载

// 路由级代码分割
const routes = [
  {
    path: '/',
    component: () => import('@/views/Home.vue')
  },
  {
    path: '/about',
    component: () => import('@/views/About.vue')
  },
  {
    path: '/admin',
    component: () => import('@/views/Admin.vue'),
    meta: { requiresAuth: true }
  }
];

// 组件级懒加载
const HeavyChart = defineAsyncComponent({
  loader: () => import('@/components/HeavyChart.vue'),
  loadingComponent: LoadingSpinner,
  delay: 200,
  timeout: 3000
});

// 按需加载第三方库
const loadLodash = async () => {
  const _ = await import('lodash-es');
  return _.default;
};

三、运行时性能优化

3.1 渲染优化

3.1.1 虚拟列表

<!-- 实现虚拟列表处理大量数据 -->
<template>
  <div class="virtual-list" ref="listContainer">
    <div :style="{ height: totalHeight + 'px' }">
      <div 
        v-for="item in visibleItems" 
        :key="item.id"
        :style="{ 
          transform: `translateY(${item.offset}px)`,
          position: 'absolute',
          top: 0,
          left: 0,
          right: 0
        }"
      >
        <ItemComponent :item="item" />
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';

const props = defineProps({
  items: { type: Array, required: true },
  itemHeight: { type: Number, default: 50 }
});

const listContainer = ref(null);
const scrollTop = ref(0);

const visibleCount = 20;
const totalHeight = computed(() => props.items.length * props.itemHeight);

const visibleItems = computed(() => {
  const start = Math.floor(scrollTop.value / props.itemHeight);
  const end = Math.min(start + visibleCount, props.items.length);
  return props.items
    .slice(start, end)
    .map((item, index) => ({
      ...item,
      offset: (start + index) * props.itemHeight
    }));
});

const handleScroll = () => {
  scrollTop.value = listContainer.value.scrollTop;
};

onMounted(() => {
  listContainer.value.addEventListener('scroll', handleScroll);
});

onUnmounted(() => {
  listContainer.value.removeEventListener('scroll', handleScroll);
});
</script>

3.1.2 防抖与节流

// 防抖函数
function debounce(func, wait, immediate = false) {
  let timeout;
  return function(...args) {
    const later = () => {
      timeout = null;
      if (!immediate) func.apply(this, args);
    };
    const callNow = immediate && !timeout;
    clearTimeout(timeout);
    timeout = setTimeout(later, wait);
    if (callNow) func.apply(this, args);
  };
}

// 节流函数
function throttle(func, limit) {
  let inThrottle;
  return function(...args) {
    if (!inThrottle) {
      func.apply(this, args);
      inThrottle = true;
      setTimeout(() => inThrottle = false, limit);
    }
  };
}

// 使用示例
const handleScroll = throttle(() => {
  console.log('Scroll position:', window.scrollY);
}, 100);

const handleResize = debounce(() => {
  console.log('Window resized');
  updateLayout();
}, 300);

3.2 内存优化

// 避免内存泄漏
class DataFetcher {
  constructor() {
    this.abortController = new AbortController();
    this.cache = new Map();
  }

  async fetchData(url) {
    try {
      const response = await fetch(url, {
        signal: this.abortController.signal
      });
      const data = await response.json();
      this.cache.set(url, data);
      return data;
    } catch (error) {
      if (error.name === 'AbortError') {
        console.log('Request aborted');
      } else {
        throw error;
      }
    }
  }

  destroy() {
    this.abortController.abort();
    this.cache.clear();
  }
}

// 使用 WeakMap 避免内存泄漏
const componentData = new WeakMap();

function registerComponent(component, data) {
  componentData.set(component, data);
  // 当 component 被垃圾回收时,data 也会自动释放
}

3.3 Web Worker 优化

// 主线程
const worker = new Worker('./worker.js');

worker.postMessage({
  type: 'PROCESS_DATA',
  data: largeDataSet
});

worker.onmessage = (e) => {
  const result = e.data;
  updateUI(result);
};

// worker.js
self.onmessage = (e) => {
  const { type, data } = e.data;
  
  if (type === 'PROCESS_DATA') {
    const result = heavyComputation(data);
    self.postMessage(result);
  }
};

function heavyComputation(data) {
  // 繁重的计算逻辑
  return data.map(item => item * 2).filter(x => x > 10);
}

四、网络优化

4.1 HTTP/2与HTTP/3

# Nginx HTTP/2 配置
server {
    listen 443 ssl http2;
    server_name example.com;
    
    # HTTP/2 推送
    http2_push /js/app.js;
    http2_push /css/style.css;
    
    # 多路复用优化
    tcp_nodelay on;
    tcp_nopush on;
}

4.2 缓存策略

// Service Worker 缓存策略
const CACHE_NAME = 'v1';
const CACHE_STRATEGIES = {
  // 缓存优先
  static: ['/', '/index.html', '/css/*', '/js/*'],
  
  // 网络优先
  api: '/api/*',
  
  // 过期时间
  images: {
    pattern: '/images/*',
    maxAge: 7 * 24 * 60 * 60 // 7 天
  }
};

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  
  if (CACHE_STRATEGIES.static.some(pattern => url.pathname.includes(pattern))) {
    event.respondWith(cachedFirst(event.request));
  } else if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(event.request));
  } else {
    event.respondWith(staleWhileRevalidate(event.request));
  }
});

async function cachedFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached;
  
  const response = await fetch(request);
  if (response.ok) {
    const cache = await caches.open(CACHE_NAME);
    cache.put(request, response.clone());
  }
  return response;
}

4.3 请求优化

// 请求合并
class RequestBatcher {
  constructor(batchSize = 10, batchDelay = 100) {
    this.batchSize = batchSize;
    this.batchDelay = batchDelay;
    this.queue = [];
    this.timer = null;
  }

  add(request) {
    return new Promise((resolve, reject) => {
      this.queue.push({ request, resolve, reject });
      this.flush();
    });
  }

  flush() {
    if (this.timer) clearTimeout(this.timer);
    
    if (this.queue.length >= this.batchSize) {
      this.executeBatch();
    } else {
      this.timer = setTimeout(() => this.executeBatch(), this.batchDelay);
    }
  }

  async executeBatch() {
    if (this.queue.length === 0) return;
    
    const batch = [...this.queue];
    this.queue = [];
    
    try {
      const responses = await Promise.all(batch.map(item => item.request));
      batch.forEach((item, index) => item.resolve(responses[index]));
    } catch (error) {
      batch.forEach(item => item.reject(error));
    }
  }
}

// 使用示例
const batcher = new RequestBatcher();

// 批量请求
const results = await Promise.all([
  batcher.add(fetch('/api/user/1')),
  batcher.add(fetch('/api/user/2')),
  batcher.add(fetch('/api/user/3'))
]);

五、构建优化

5.1 依赖分析

# 分析打包体积
npx webpack-bundle-analyzer dist/stats.json

# 使用 source-map-explorer
npx source-map-explorer dist/js/*.js

# Vite 内置分析
vite build --analyze
// webpack 配置
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendors: {
          test: /[\/]node_modules[\/]/,
          name: 'vendors',
          chunks: 'all',
        },
        default: {
          minChunks: 2,
          priority: -10,
          reuseExistingChunk: true,
        },
      },
    },
  },
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: 'static',
      openAnalyzer: false,
    }),
  ],
};

5.2 Tree Shaking

// 确保使用 ES 模块语法
import { debounce, throttle } from 'lodash-es'; // ✅ 支持 tree shaking
// import _ from 'lodash'; // ❌ 会引入整个库

// 使用 sideEffects 配置
// package.json
{
  "sideEffects": [
    "*.css",
    "*.scss"
  ]
}

// 标记纯函数
/*#__PURE__*/
function pureFunction() {
  return 42;
}

六、监控与分析

6.1 性能监控

// 自定义性能监控
class PerformanceMonitor {
  constructor() {
    this.metrics = {};
    this.init();
  }

  init() {
    // 监听核心 Web 指标
    if ('PerformanceObserver' in window) {
      this.observeLCP();
      this.observeCLS();
      this.observeFID();
    }

    // 监听页面加载性能
    window.addEventListener('load', () => {
      this.recordLoadPerformance();
    });
  }

  observeLCP() {
    new PerformanceObserver((list) => {
      const entries = list.getEntries();
      const lastEntry = entries[entries.length - 1];
      this.recordMetric('LCP', lastEntry.startTime);
    }).observe({ type: 'largest-contentful-paint', buffered: true });
  }

  observeCLS() {
    let clsValue = 0;
    new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (!entry.hadRecentInput) {
          clsValue += entry.value;
        }
      }
      this.recordMetric('CLS', clsValue);
    }).observe({ type: 'layout-shift', buffered: true });
  }

  recordMetric(name, value) {
    this.metrics[name] = value;
    
    // 发送到分析服务
    this.sendToAnalytics(name, value);
  }

  recordLoadPerformance() {
    const timing = performance.timing;
    const loadTime = timing.loadEventEnd - timing.navigationStart;
    this.recordMetric('LoadTime', loadTime);
  }

  sendToAnalytics(name, value) {
    // 发送到监控服务
    navigator.sendBeacon('/api/performance', 
      JSON.stringify({ metric: name, value, timestamp: Date.now() })
    );
  }

  getReport() {
    return {
      ...this.metrics,
      timestamp: new Date().toISOString(),
      userAgent: navigator.userAgent,
      url: window.location.href
    };
  }
}

// 使用
const monitor = new PerformanceMonitor();

6.2 错误监控

// 全局错误处理
window.addEventListener('error', (event) => {
  reportError({
    type: 'javascript',
    message: event.message,
    filename: event.filename,
    lineno: event.lineno,
    colno: event.colno,
    error: event.error
  });
});

window.addEventListener('unhandledrejection', (event) => {
  reportError({
    type: 'promise',
    message: event.reason?.message || 'Unhandled promise rejection',
    error: event.reason
  });
});

function reportError(error) {
  // 发送到错误监控服务
  navigator.sendBeacon('/api/error', JSON.stringify({
    ...error,
    timestamp: Date.now(),
    url: window.location.href,
    userAgent: navigator.userAgent
  }));
  
  // 可选:记录到控制台
  console.error('Performance Error:', error);
}

七、实战案例

7.1 电商网站性能优化

优化前:

  • LCP: 4.2s
  • FID: 350ms
  • CLS: 0.35
  • 首屏加载时间:5.1s

优化措施:

  1. 图片优化(WebP + 懒加载)
  2. 代码分割(路由级 + 组件级)
  3. 预加载关键资源
  4. Service Worker 缓存
  5. HTTP/2 启用

优化后:

  • LCP: 1.8s ✅
  • FID: 85ms ✅
  • CLS: 0.08 ✅
  • 首屏加载时间:2.1s ✅

总结

前端性能优化是一个持续的过程,需要:

核心策略:

  1. 测量先行 - 使用工具了解当前性能状况
  2. 渐进优化 - 从影响最大的地方开始
  3. 持续监控 - 建立性能监控体系
  4. 团队协作 - 将性能纳入开发流程

关键要点:

  • 图片优化通常带来最大收益
  • 代码分割能显著改善首屏加载
  • 缓存策略对重复访问至关重要
  • 运行时优化提升用户体验
  • 监控确保优化效果持续

记住:性能优化不是一次性的任务,而是持续改进的过程。定期测量、分析、优化,确保你的应用始终保持最佳性能。

2026 年,AI 全栈时代到了,前端简历别再只写前端技术了 🫠🫠🫠

作者 Moment
2026年4月28日 20:29

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

最近我给一些前端方向的实习生做内推,看了不少简历。投递里常能看出一种预设,找实习只要把前端做熟,页面和接口能啃下来,似乎就踩对了主线。初筛读多了会有另一种感受,当业务和岗位已经大量贴上大模型、RAGAgent 时,纯前端技术栈写得再工整,也很难在一叠写法雷同的简历里单独把人托起来。

我想写的不是简历技巧,评审更常问的是,你有没有把智能接进一条可维护、可观测、也能和人协作的链路,还是只在项目名里多写几个关键词。

许多项目经历段落,技术名词很全,叙事却薄。写得多的几种模式是:

  • 周期很短,却同时堆上 RAG、流式输出、鉴权与复杂治理,读者很难估计真实投入与掌握深度
  • 段落像功能清单,缺少场景、难点、个人职责与可验证结果,性能数字也缺少口径与复现方式
  • 形态集中在教程型对话台或后台管理,技术栈高度同质,差异化不明显
  • Agent 被写成调模型、接工具,很少触及运行时、状态机、评测、观测与人在回路

还有一种写法,整段都在堆大家简历里常见的工程项,例如 JWT、双 tokenRBAC、请求拦截器、SSEWebSocket 流式、分片上传、虚拟列表。十条里七八条读起来像同一篇教程拆出来的,名词齐了,却看不出你解决的是哪一道别人没写清楚的难题。基本功当然要会,可若差异化只停在这一层,筛简历的人很难把你从同质化描述里拎出来。

简历上的项目经历一撞脸,筛简历的人就只好盯你有没有把系统做实。后面我按层写。

这两年简历里写 Agent 的人越来越多,细看实现却常常撞脸,核心路径几乎总是同一条。

接收用户输入,调用大模型,解析工具调用,执行工具,返回结果。

引用里那几步,无非是调模型、解析工具调用、执行、再把结果塞回模型。脚手架和跟练多了,半天跑通一个 Demo 很常见。评审里想听的,往往是上线以后要应付的那些事,超时、重试、成本、人要确认、出了事故怎么查和改,你有没有提前铺过路。

面试里真正该往下问的,早就不该是这种技能清单:

  • 你会不会调 LLM
  • 你会不会接 Tool
  • 你会不会用 LangChain

而是更底层的这个问题:

你有没有把 Agent 当成一个系统,而不是一个函数调用?

能讲清楚这一句,面试官才会继续往下问。落地时大家会盯这几件事有没有做实,有没有进真实产品而不是停在演示分支:

  • 有没有独立的 Agent Runtime
  • 有没有显式状态机驱动的 Agent Loop
  • 有没有把评测做成回归闸门
  • 有没有把观测、检测、红队、安全、成本和用户干预整成闭环
  • 能不能把这些能力真正接进产品,而不是停留在一段演示代码

缺了这些,名字再响亮,多半仍是一个带工具调用的聊天接口。本文不写怎么接 OpenAI、怎么声明 Tool,只写骨架。

从 Demo 到产品,Agent 系统到底还差哪几层骨架。

下面按层拆开说明。

为什么很多 Agent 项目能跑,但没有技术区分度

很多人会以为把大模型、多轮、ToolMemoryRAG 勾齐,项目就算做完了。那点东西多半只盖住 demo 里的顺利路径,一遇到真实流量,缺的是运行时、安全、观测和评测这一整圈骨架,而不是再多接一个模型。

它们看起来像 Agent,实际上更像一个带工具调用的聊天接口。

一放量,问题会先挤在几块地方,很少单靠改一段 Prompt 就能压住:

  • 同一类提问有时成有时败,工具忽对忽错
  • 用户只看到转圈,不知道卡在推理还是在等工具
  • 线上成功率掉了,分不清是模型、工具还是外部 API
  • Prompt 改完自测像变聪明,线上指标却掉头
  • 偏航多步也没有让人半途介入的口子
  • token 和钱烧在哪些步骤,心里没底

根本原因多半不在 Prompt 花不花或 Tool 多不多,而在下面几块是否长期空白:运行时、状态机、可观测、评测、风险、HITLStreaming、成本、异常和线上告警。填不全,项目就会一直像在交课堂作业。只会用 LangChain 也不等于会做 Agent,框架主要管编排,编排以外的那一圈才是评审想听你讲清楚的。

前端加 LangChain 开发者的真正优势

前端背景再叠上 LangChainLangGraph、工具与记忆,常被低估。和算法岗比的不是论文厚度,而是能不能把智能体做成别人能长期点着用的产品。

你更占便宜的地方,是把 Agent 做成用户看得见、停得下来、出了问题能对上账的系统。

模型只是其中一个节点。好不好用还要看,现在在干什么、为什么选这个工具、失败怎么补、用户能不能打断、高风险要不要确认、耗时和钱能不能对上账、改完 Prompt 有没有回归、输出能不能验、输入和工具参数有没有护栏、线上有没有告警。这些早就不是单次推理,而是一整条链路的工程活。状态、异步、中间态、确认、埋点和展示,本来就是前端日常,这块你会比只写脚本的人更顺手。

介绍自己时不必缩成会接某家 API 的前端,可以收成一句实在话。

我负责把 LLM 编排、工具、状态机、可观测、评测和交互收成一条,给人用的是产品不是脚本。

交付物也更该像任务控制台,把推理、调工具、等确认、报错恢复这些阶段摊开,而不是聊天框里一条接一条的气泡。

把 Agent 理解成运行中的系统而不是调用链

先把问题问清楚,这东西在产线里更像一次短请求,还是像一趟要跑很久的任务。

不要把 Agent 理解为一次请求的处理流程,而要把它理解为一个持续运行的任务系统。

普通接口大约四步,请求进来,处理完就结束。Agent 更像长跑任务,中间状态多。下图是一条典型阶段划分,提醒自己别再用短接口的思维去估长任务。

20260423093052

图的意思很直白,Agent 更接近任务引擎,而不是只吐一段字的接口。接下来这些问题躲不掉,状态放哪、刷新后怎么续、工具超时重试还是交给人、高风险要不要审批、token 快顶了还跑不跑、工具能不能并行、连走几步没进展算不算打转。这些都算系统设计,不是多写两行 Prompt 能糊过去的。

一个有区分度的 Agent 系统应有的分层

Agent 如果只停在调模型、调工具,听起来总像缺一块。动手前最好先想清楚,界面交互、流程编排、运行时控制、安全治理、可观测和评测各自归谁管,别全糊进一条链。

对外说法可以很简单,Agent 像一条流水线:

用户输入 → 模型推理 → 调用工具 → 返回结果

真要拆职责,可以收成六层:

  • 交互层:用户看得见、点得着的界面,负责步骤展示、审批、中断、重试和结果反馈
  • 编排层:用 LangChainLangGraph 等把 Prompt、模型、工具、记忆和状态流转组织成可维护的流程
  • 运行时 Harness:管理步数、超时、预算、快照、重试、取消和收尾,决定任务如何真正跑完或安全停下
  • 安全与检测层:在输入、工具执行前、输出和轨迹上做规则与模型检测,拦住不该发生的行为
  • 可观测层:用 Trace、Metrics、日志把每一步变成可查询、可对比、可回放的事实
  • 评测层:通过离线集、回归闸门和线上灰度,用数据判断一次改动到底有没有变好

编排层按意图出计划和工具调用,运行时层在预算、超时和状态约束下执行。执行中安全层可能拦截或要审批,可观测层记全程,评测层再拿这些记录去约束下一版 Prompt、节点、工具和发版节奏。

分层不是为了把图画复杂,而是别把运行时、安全、回放、评测和灰度都指望 LangChain 自动搞定。框架主要管编排,编排外那一圈才决定工程含量。

用户意图从交互层进编排层,编排层再把可执行步骤交给运行时。

image.png

运行时不只是把流程跑完,还要在执行过程中持续接入安全检测和可观测能力。

image.png

可观测记录下来的事实,会进入评测体系,再反向约束下一轮编排和发布策略。

image.png

每层用一句话带过,细节可以另写。

交互层负责摊开给人看、给人控。过程可见、风险写操作前要确认、能中断和改向,这些要和运行时对齐,别事后在文案里补两行提示。

编排层用 PromptToolMemory、图或链把节点串起来。LangChain 一类框架主要管这一层,观测、中断、预算、版本和 bad case 回流多半在编排之外,别把欠账算在框架头上。

运行时层用 Harness 钉死步数、单步和整体超时、token 和墙钟预算、取消和收尾。结束由状态和 Harness 判定,预算触顶该降级就降级,别指望模型自己说做完了。

安全与检测要盖住输入、工具执行前、输出和轨迹。模型吐出来的 tool call 只是草稿,执行前要走白名单、schema、权限和风险分级,高危路径该审批就审批。

可观测层靠 Trace、分层指标和结构化日志,把一次任务从猜变成查,后面才好做归因、回放和调参。

评测层用离线用例、回归闸门和线上灰度回答有没有变好。Prompt、模型、工具或状态机一动,就该自动对比基线,线上反馈要能回灌进用例集。

六层都沾到,才像能交给别人托管的产品,而不是只证明链路能跑通的 Demo

Agent Loop 应该是显式状态机而不是 while 循环

最朴素的 Agent Loop 是反复调模型、判断是否调工具、拿结果再回模型,直到产出答案。这个流程能跑通,但一进真实场景就容易失控,因为很多关键分支塞不进一个裸 while 循环。

真正容易栽跟头的几件事:

  • 工具失败后的重试与止损
  • 高风险动作前的人工确认
  • 预算触顶后的降级与收尾
  • 上下文过长时的压缩与续跑
  • 用户中断后的恢复与回放

Loop 收成显式状态机,让系统在 ReasoningToolSelectingExecutingAwaitingConfirmationRecoveringFinalizing 等状态之间按条件跳转,分支写在表里,比藏在 if 里好查也好测。

状态机写清楚以后,日常会顺很多。状态一眼能看见,分支不再散在 if 里,前后端对得上号,暂停、恢复、撤销、重试也好接。

把它和前面的 AgentHarness 组合后,职责会更清晰:

  • Harness 负责时间、步数、token、取消和强制收尾
  • 状态机负责业务语义、路径选择和异常分支

上线以后,Loop 往往还要挂审批、检测、回滚、埋点和评测,能挂在状态切换点上就别散在业务代码里。

收个尾,Agent Loop 不该只是会转的循环,最好收成一台可解释、可干预、可恢复、也能审计的状态机。

把人设计进系统而不是把人当兜底

很多团队把 HITL 理解成出错后的兜底,这会让人机协同长期停在救火阶段。设计阶段就把哪些动作自动放行、哪些必须确认、哪些默认拒绝写清楚,比上线后救火省事。

HITLHuman-in-the-loop,意思是把人放进关键决策回路。系统负责执行与提议,人负责在高风险节点确认、纠偏和兜底。

风险分级可以先从三档起步,阈值和白名单由业务与合规共同维护:

  • 低风险,默认自动执行,失败后可重试或降级,例如搜索文档、读取代码、查询只读数据、整理摘要
  • 中风险,可自动执行但要留痕,并保留撤销窗口,例如文案修改、批量替换、工作区文件编辑
  • 高风险,执行前必须阻断并等待确认,例如删除文件、外网请求、代码提交、数据库变更、发布和付费接口调用

差别通常不在有没有确认按钮,而在卡片里给不给够决策信息。动作是什么、为什么动、影响范围、能不能撤销、有没有备选,最好一眼能看完,别只剩一句是否继续。

审批如果只在前端拼文案,很快会和真实执行脱节。更省事的做法是把审批收成结构化数据,从后端下发,挂到同一条 trace 上,事件流里推 approval_required,回放、审计和告警都读同一份。

卡片上最好有:

  • 风险等级、审批时限、发起来源一眼可见
  • 受影响资源和变更范围可展开查看,必要时接 Diff
  • 可逆操作提供 Undo 入口和预计回滚成本
  • 支持改参数或切换替代动作后再执行,减少往返沟通
  • 全量记录审批人、审批理由、执行结果,满足审计留痕

审批、trace、状态机和观测如果能共用一套模型,人机协同就不只是打补丁。

Streaming 应该让 Agent 过程可见

流式输出如果只用来更快吐 token,对 Agent 任务帮助有限。用户更想知道现在卡在哪一步、工具在干什么、要不要自己点一下。

事件可以粗分三类,最好走同一条推送通道,省得前端接好几套协议:

  • token 层,持续输出自然语言内容
  • step 层,推送每一步的动作、工具状态和中间结论
  • progress 层,推送总进度、耗时和成本,减少等待焦虑

用一条联合类型把字段钉死,前后端少扯皮。下面是个示意,覆盖状态变化、工具起止、审批、进度和收尾,载体可以用 WebSocketEventSource

type AgentStreamEvent =
  | { type: "state_changed"; state: AgentState; at: number }
  | { type: "token"; text: string; stepId: string }
  | { type: "tool_started"; stepId: string; tool: string; args: unknown }
  | { type: "tool_finished"; stepId: string; ok: boolean; summary: string }
  | { type: "approval_required"; request: ApprovalRequest }
  | { type: "progress"; done: number; total: number; costUsd: number }
  | { type: "final"; answer: string; traceId: string }
  | { type: "error"; message: string; recoverable: boolean };

协议统一以后,时间线、步骤卡片、进度条和审批弹窗才好做,中间态不必全塞进气泡里。界面上比较值得先做的几件事:

  • 步骤折叠与展开,避免长任务刷满屏幕
  • Observation 面板分层展示工具入参、结果摘要、原始返回
  • 工具日志实时滚动,失败步骤高亮并给出重试入口
  • 全局状态浮层显示当前状态机节点与等待原因
  • StopRetryContinue、插话打断与后端取消契约对齐
  • 人工接管入口用于切换执行策略或直接改写下一步
  • 最终答案和中间证据联动,点击引用可回跳对应 step

同样是等三十秒,转圈和看着系统一步步推进,感受差很多。过程可见,用户能更早纠偏,也能少烧不少无效 token

离线评测资产、线上观测与可迁移遥测

Agent 要长期迭代,既要离线侧能证明有没有变好,也要线上侧能看见真实流量里发生了什么,还要让埋点与字段尽量不因换观测后端而推倒重来。这一节把三件事收进一条工程链条:先固定可迁移遥测语义,再让离线评测与回归产出可进闸门的证据,最后在线上仍用同一套字段读 trace、成本、实验与用户反馈。底座语义与线上观测必须同源,否则灰度里对不上离线报表。

image.png

语义底座先把典型 span 名、属性键和事件形状写进约定,常见列包括 trace_idspan_idmodeltoken 进出与 cost_usd 等,并对齐 GenAIOpenTelemetry 社区里已经有人在用的写法。这样换导出器或换观测后端时,主要改连接与映射,业务代码少动字段名。离线评测与回归靠版本化用例集、对结果与格式与合规的断言、与基线的对照统计、接入 CI 的闸门和可计量的回归耗时,把主观手感压成可复跑的 Eval 分数。线上可观测在同一套定义下读 trace 时间线、成本随时间和用量变化、AB 流量拆分、用户情绪与满意线索、以及告警与异常。工程上的收束是:语义先沉淀进离线证据,离线结论再拿去和线上 trace、金丝雀或灰度放量对齐,团队才不会各写各的报表。

落到工具时,离线侧靠版本化用例、对过程与结果的断言、基线对比和接入 CI 的闸门把手感变成证据。promptfooDeepEvalRagas 分别偏配置、断言、指标,关键是同一套用例能从开发跑到发布。线上噪声更大,盯住任务完成率、工具成功与超时、成本与风险侧信号即可。LangfuseLangSmithPhoenixHelicone 选型看能否把 trace、实验、分数和反馈收进同一面板。OpenTelemetryGenAI 语义适合当公共约定,先统一 LLMtoolagent 如何建 span,以及 token、延迟、错误码等字段,迁移成本主要在导出器。

前端加 LangChain 开发者可以重点讲的几点

前端把运行中的系统摊开给人看:状态、步骤、工具、风险、中断重试入口,以及 token 与成本摘要。trace 不应只躺在仓库里,而要变成时间线、Step 卡片、风险高亮和失败回放。模型差不多时,把过程讲清楚往往比再换一次模型更能换来信任和效率。

一个成熟 Agent 项目的技术区分度该怎么描述

重点不是接了哪个新模型,而是能否在真实业务里持续跑稳、可对比、可审计。下面是一段自述示例,可按实际情况改名词和程度。

我做的不是调模型、调工具的 Demo,而是面向真实用户的 Agent 运行系统。LLM 与编排负责生成与流转,Harness、状态机 Loop、风险控制、HITL、可观测和评测负责稳定与可治理。 工程上我会打通离线评测、线上观测和回归闸门,用统一遥测语义串起 trace、成本、质量与用户反馈,让每次迭代可对比、可回放、可审计。 我有前端背景,会把过程可视化、干预入口和体验指标当成主交付物,而不是只交最后一段文本。

总结

RAG 可以做,Agent 也可以做,它们都只是手段,不是终点。真正拉开差距的是你有没有把需求、执行、观测、评测和迭代接成闭环。下面四条自检,有一半答不上来,就值得对照正文里的分层补一补。

  • 执行与韧性:是否有独立运行时与预算约束,Loop 是否显式状态机,故障能否回放,成本与步数是否可解释。
  • 质量与证据:是否有维护中的评测集、CI 或合并前的回归闸门,红队用例是否像测试代码一样可复跑,而不是发版前凭手感点几下。
  • 安全与过程:输入、工具调用前、输出与轨迹四层里,哪些已经落地成策略与埋点,高风险路径是否默认进审批而不是靠运气不触发。
  • 观测与闭环:线上是否能同时看到 trace、成本、实验与用户反馈,离线分数与线上信号能否进同一套界面或同一套数据模型,而不是各团队各一份报表。

能跑通链路只是起点,能不能长期闭环才是标准。

你有没有把它做成一个能稳定运行、可观测、可评测、可干预、还能持续迭代的闭环系统。

走前端加 LangChain 这条线的人,手里正好捏着界面、状态和事件,把这些和模型、工具、观测、评测缝在一起,比单纯多接一个模型更难被模板替代,写进自我介绍里也更有话可说。

一次跨端 Loading 卡死复盘:把请求计数从 Axios 拦截器迁到 try/catch/finally

2026年4月28日 19:53

记录一次跨端项目里的典型问题:全局 Loading 偶发卡死。最终修复的关键点很简单——把“请求计数”的加减从 Axios 拦截器迁移到 request()try/catch/finally 内,由 finally 兜底保证对称收口。

1. 背景:我们在数什么,为什么会“卡死”

为了实现全局 Loading(顶层遮罩),我们维护一个全局计数器 requestCount

  • 请求开始requestCount += 1
  • 请求结束(成功/失败)requestCount -= 1
  • 展示规则
    • 0 → 1:打开 Loading
    • 1 → 0:关闭 Loading

这个机制的硬约束只有一句话:加减必须严格对称。一旦出现“+1 没有对应 -1”,requestCount 永远回不到 0,Loading 就会一直挂着(也就是“卡死”)。


2. 现象:App 端偶发“遮罩不消失”

同一套业务逻辑在 Web 侧长期正常,但在 App 端(跨端运行环境)偶发出现:

  • 接口已经失败(断网 / 弱网 / 超时等),业务提示也弹出了
  • 全局遮罩仍然存在,像是还有请求在进行
  • 往往需要返回重进、或等待后续请求“碰巧”把状态带回正常

这类问题的共同点是:通常出现在非理想网络与异常路径,并且复现不稳定。


3. 初版实现:把计数放在 Axios 拦截器里

最初我们把计数放在 Axios 拦截器里统一接管生命周期(典型写法如下):

axiosClient.interceptors.request.use((config) => {
  show(); // requestCount++
  return config;
});

axiosClient.interceptors.response.use(
  (res) => {
    hide(); // requestCount--
    return res;
  },
  (err) => {
    hide(); // requestCount--
    return Promise.reject(err);
  },
);

当时它看起来很合理:

  • 集中:所有请求自动纳管
  • 业务无感:业务侧无需写 finally
  • 统一:token/header/日志/计数都在一个地方处理

4. 排查:先确认根因是“计数漏减”

为了避免误判(UI/Redux/Modal 层级也可能导致“看起来像卡死”),排查顺序建议固定为:

  • 验证 UI 链路:手动切换全局 isLoading(true/false)看遮罩能否正常开关
  • 验证计数对称:在 show/hide 打日志,记录 requestCount 与请求标识(method、url、耗时、错误类型)

当确认出现 requestCount 长期不回 0,基本可以锁定:对称性在某条异常路径上被破坏了


5. 根因:拦截器不是可靠的“收尾点”,错误归一化会放大风险

随着封装演进,我们在请求层做了错误归一化(将 Axios 错误转换为更明确的业务异常),例如:

  • TimeoutError:超时(如 ECONNABORTED
  • ApiError:有响应、有状态码、有后端错误信息
  • NetworkError:无响应的断网/网络不可达等

这一步本身是对的,但它带来一个现实风险:

  • 计数在拦截器阶段加减,但 错误被封装层转换/重抛
  • 跨端环境下错误形态更多(弱网、取消、底层差异),拦截器链路不一定按你预期闭环
  • 结果可能出现:show() 发生了,但某条路径上的 hide() 没发生 → requestCount 回不到 0 → Loading 卡死

经验总结:

  • 拦截器更适合做“可重入的改写/注入”(token/header/日志)
  • “必须严格配对”的逻辑应放在调用边界收口(计数/锁/资源释放)

而最可靠的“调用边界收口”就是:try/finally


6. 修复:把计数从拦截器迁到 request() 的 try/catch/finally

迁移后的原则是:谁负责 +1,就必须在同一个调用边界里保证 -1

6.1 新方案的结构(核心是 finally)

async function request(config) {
  const usesLoading = !config.noLoading && config.headers?.['x-no-loading'] !== 'true';
  if (usesLoading) show(); // requestCount++

  try {
    const res = await axiosClient.request(config);
    return res.data;
  } catch (e) {
    // 在这里可以自由做错误归一化/重抛:ApiError / NetworkError / TimeoutError...
    throw normalizeError(e);
  } finally {
    // 关键:无论成功/失败/如何重抛,finally 都会执行
    if (usesLoading) hide(); // requestCount--
  }
}

6.2 为什么 finally 比拦截器更“稳”

finally 的价值在于:不依赖外部链路、也不依赖错误形态。无论 catch 里把错误转换成什么类型、抛出什么异常,finally 都会执行,从机制上消灭“漏减计数”的空间。


7. 迁移后顺手补齐的两个并发体验问题

只解决“对称性”还不够,真实体验里还会遇到两个常见问题,我们建议一起处理:

7.1 防闪烁:最短展示时间(MIN_VISIBLE_MS)

请求很快时,Loading 会“闪一下”,体验很差。做法是:

  • 记录 lastShownAt
  • 关闭时如果展示不足最短时间,就延迟关闭到满足最短展示

7.2 防竞态:延迟关闭要可取消 + 二次检查

延迟关闭通常用 setTimeout。并发场景下,如果不处理竞态,很容易出现:

  • 旧请求安排了延迟关闭
  • 新请求又开始了
  • 旧的关闭定时器触发,把新请求的 Loading 误关掉

工程化做法:

  • 新请求开始时取消旧的 hideTimer
  • 定时器触发时再检查一次 requestCount === 0 才真正关闭

8. 结果与结论(推荐写在文章结尾)

本次修复的关键不是“换了个写法”,而是把“必须严格配对”的逻辑从不确定的链路阶段(拦截器)迁移到确定性收口点(finally)。

最终结论:

  • 拦截器适合做改写/注入,不适合做严格配对的收尾
  • 计数、锁、资源释放这类逻辑,应在调用边界用 finally 收口
  • 跨端 + 错误归一化的组合,会放大所有“链路对称性假设”的风险

HTTP 请求的五种传参方式

作者 花满溪
2026年4月28日 18:25

在 Web 开发中,客户端向服务端传递数据有多种方式。本文介绍五种常见的传参方式,以及它们的适用场景。

为了让读者更好地理解每种传参方式,文中示例均使用 Express 搭建的本地服务器进行演示。你可以在本地启动服务后,通过浏览器或 Postman 实际发送请求,观察服务端的接收效果。

场景对比

传参方式 适用场景 数据类型 大小限制
路径参数 获取确定性资源,如用户信息、商品详情 简单字符串 无限制
查询参数 搜索、过滤、分页 短数据 受 URL 长度限制
x-www-form-urlencoded 传统表单提交 简单键值对 较小
application/json RESTful API 调用 复杂嵌套结构 较大
multipart/form-data 文件上传、混合数据提交 文件 + 表单

一、链接传参 - 路径参数

在 URL 路径中传递参数。例如:/users/{id}

// 示例:GET 请求
http://localhost:3000/users/123

服务端通过路径占位符获取参数 id=123


二、链接传参 - 查询参数

在 URL 查询字符串中传递参数。查询参数是问号 ? 后的键值对,多个参数用 & 连接。

// 示例:GET 请求
http://localhost:3000/search?keyword=node&page=2

服务端获取 keyword=nodepage=2


三、请求体传参 - application/x-www-form-urlencoded

POST 请求,请求头 Content-Type 设置为 application/x-www-form-urlencoded

HTML 表单使用 method="post" 时,默认使用这种编码方式。

<!DOCTYPE html>
<html>
<body>
    <form action="http://localhost:3000/register" method="post">
        <input type="text" name="username">
        <input type="password" name="password">
        <button>submit</button>
    </form>
</body>
</html>

提交后,浏览器将表单数据编码为 username=admin&password=123456,发送到后端。


四、请求体传参 - application/json

POST 请求,请求头 Content-Type 设置为 application/json

这种方式需要使用 JavaScript(如 fetch)发送请求。

<!DOCTYPE html>
<html>
<body>
    <script>
        fetch('http://localhost:3000/login', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify({
                username: "admin",
                password: "123456",
            })
        });
    </script>
</body>
</html>

请求体内容为 JSON 字符串:{"username":"admin","password":"123456"}


五、请求体传参 - multipart/form-data

POST 请求,请求头 Content-Type 设置为 multipart/form-data

这种方式通常用于文件上传,也可以同时提交普通表单字段。

<!DOCTYPE html>
<html>
<body>
    <form action="http://localhost:3000/upload" method="post" enctype="multipart/form-data">
        <input type="file" accept=".jpg" name="avatar">
        <input type="text" name="username">
        <input type="password" name="password">
        <button>submit</button>
    </form>
</body>
</html>

浏览器自动生成请求头:Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryO1HGmt23sYZeHuMf

boundary 后面的字符串是分隔符,用于区分不同的字段。

请求体格式示例:

------WebKitFormBoundaryO1HGmt23sYZeHuMf
Content-Disposition: form-data; name="avatar"; filename="avatar.jpg"
Content-Type: image/jpeg

[文件二进制数据]
------WebKitFormBoundaryO1HGmt23sYZeHuMf
Content-Disposition: form-data; name="username"

admin
------WebKitFormBoundaryO1HGmt23sYZeHuMf
Content-Disposition: form-data; name="password"

123456
------WebKitFormBoundaryO1HGmt23sYZeHuMf--

每个字段由 boundary 分隔,字段名和字段值之间有一个空行。


六、本地服务器

以下是一个 Express 服务器实现,集成了上述五种传参方式的处理逻辑。启动服务后,你可以配合前文的 HTML 页面或 Postman 进行测试。

环境准备

npm init -y
npm i express cors multer

服务端代码

const express = require('express');
const cors = require('cors');
const multer = require('multer');
const app = express();
const port = 3000;
app.use(cors());

// 1. 处理路由参数(URL 路径中的参数)
// 示例:GET /users/123
app.get('/users/:id', (req, res) => {
    const userId = req.params.id;
    res.json({
        message: `获取用户信息`,
        userId: userId
    });
});

// 2. 处理 URL 查询参数(Query String)
// 示例:GET /search?keyword=node&page=2
app.get('/search', (req, res) => {
    const { keyword, page } = req.query;
    res.json({
        message: '查询参数已接收',
        keyword: keyword,
        page: page || 1
    });
});

// 3. 处理表单提交的 URL 编码参数
app.use(express.urlencoded({ extended: true }));

// 示例:POST /register,表单数据
app.post('/register', (req, res) => {
    const { username, password } = req.body;
    res.json({
        username,
        password,
    });
});

// 4. 处理 POST 请求的 JSON 参数(请求体)
// 需要配置 JSON 解析中间件
app.use(express.json());

// 示例:POST /login,请求体 {"username": "admin", "password": "123345"}
app.post('/login', (req, res) => {
    const { username, password } = req.body;
    res.json({
        username,
        password,
    });
});

// 5.处理单文件上传
// 配置 multer(内存存储,文件以 Buffer 形式存在)
const upload = multer({ storage: multer.memoryStorage() });

app.post('/upload', upload.single('avatar'), (req, res) => {
    // req.file 包含上传的文件信息
    console.log('文件信息:', req.file);
    console.log('文本字段:', req.body);

    res.json({
        message: '文件上传成功',
        filename: req.file.originalname,
        size: req.file.size,
        mimetype: req.file.mimetype
    });
});

// 启动服务
app.listen(port, () => {
    console.log(`start:http://localhost:${port}`);
});

启动与测试

运行 node server.js 启动服务后,可以:

  • 访问前文的 HTML 页面,直接在浏览器中发送请求
  • 使用 Postman 或 curl 手动构造请求,观察响应结果
  • 查看终端日志,确认服务端是否正确接收参数

一个 3.5k Star Vue H5 项目的二次进化:我把它重构成了 Monorepo 工程体系

作者 方呵呵
2026年4月28日 18:18

前言

在实际的移动端开发中,我们经常面临这些痛点:

  • 多个 H5 项目,各自为战 — 每个项目独立一套配置,Lint、TypeScript、Vite 各玩各的
  • UI 框架选型难 — NutUI、Vant、Varlet 各有优势,不同业务场景想用不同 UI
  • Mock 接口重复写 — 每个项目写一套 Mock,费时费力
  • 新建项目成本高 — 每次复制粘贴、改配置、接路由,至少半天

Vue H5 Template 就是为解决这些问题而生的。它是一个基于 Turborepo + Vue 3 + TypeScript 的移动端 Monorepo 模板,提供三套 UI 框架版本,共享一切可以共享的东西。


技术栈一览

技术 版本 说明
Vue 3.5 Composition API + <script setup>
TypeScript 6.0 全量覆盖,严格模式
Vite 8.0 构建工具,共享配置
Turborepo 2.9 Monorepo 管理 + 缓存加速
pnpm 10.27 包管理器,workspace + catalog 协议
Pinia 3.0 状态管理 + 持久化 + AES 加密
Vue Router 5.0 路由管理 + NProgress 进度条
Vue I18n 11.3 国际化(简/繁/英/日/港)
Nitro 2.x Mock 后端服务
NutUI / Vant / Varlet 三套并存 三套移动端 UI 框架

项目结构

vue-h5-template/
├── apps/                     # 📱 应用目录
│   ├── h5-nutui/             # NutUI 版 H5(端口 5777)
│   ├── h5-vant/              # Vant 版 H5(端口 5778)
│   ├── h5-varlet/            # Varlet 版 H5(端口 5779)
│   └── backend-mock/         # Nitro Mock 后端(端口 5320)
├── packages/                 # 📦 共享包
│   ├── @core/                # 核心包(设计系统、composables、偏好设置)
│   ├── stores/               # Pinia 状态管理
│   ├── styles/               # 全局样式 + 各 UI 主题
│   ├── locales/              # 国际化语言包
│   └── utils/                # 工具函数(含 NProgress)
├── internal/                 # 🔧 内部配置
│   ├── vite-config/          # 共享 Vite 配置工厂
│   ├── tsconfig/             # 共享 TypeScript 配置
│   └── lint-configs/         # ESLint/Prettier/Stylelint/Commitlint
├── scripts/                  # 🛠️ CLI 工具
│   ├── vsh/                  # 项目 CLI(lint、创建应用等)
│   └── turbo-run/            # 交互式选择运行
└── docs/                     # 📖 多语言文档站(VitePress)

核心亮点

1. 三套 UI 框架,统一架构

项目同时支持 NutUI(京东风格)、Vant(有赞风格)、Varlet(Material Design)三套移动端 UI 框架:

apps/
├── h5-nutui/    → NutUI 4.3,适合电商类场景
├── h5-vant/     → Vant 4.9,适合通用 H5
└── h5-varlet/   → Varlet 3.12,适合 Material 风格

每个应用独立运行,共享路由结构、API 层、状态管理和国际化方案。切换 UI 框架只是换个 app 目录,核心逻辑一行不改。

三个 app 均按需自动导入,不需要手写 import:

// vite.config.ts 只需声明 UI 库类型
export default defineConfig(async () => ({
  application: { uiLibrary: "vant" }, // 'nut' | 'vant' | 'varlet'
  vite: {
    /* 自定义配置 */
  },
}));

剩下的 Resolver、auto-import、组件注册,全部由 @vh5/vite-config 自动处理。


2. 一键创建新应用

内置 create-app CLI,一行命令创建新 H5 应用:

pnpm create-app

交互式选择 UI 框架和应用名,自动生成:

  • 完整的项目结构(路由、视图、API、状态管理)
  • Vite 配置(含 Mock 代理和路径别名)
  • TypeScript 配置(继承共享 tsconfig)
  • 国际化支持(接入 @vh5/locales
  • 共享样式引入

不需要复制粘贴,不需要手改配置,60 秒启动新应用。


3. Turborepo 加持的构建缓存

# 交互式选择启动
pnpm dev

# 直接启动指定应用
pnpm dev:vant
pnpm dev:nutui
pnpm dev:varlet

Turborepo 会自动:

  • 缓存构建产物 — 代码没变就不重新构建,直接复用
  • 并行执行 — 共享包与应用同时编译
  • 依赖拓扑排序 — 自动处理包间依赖顺序,不用手动 --filter

增量构建从冷启动的 30s+ 降到缓存命中后的 2s 以内。


4. 内置 Nitro Mock 服务

不需要额外启动 Mock 服务,pnpm dev 时 Vite 自动拉起 Nitro Mock:

POST http://localhost:5320/api/auth/login       # 登录(返回 JWT)
POST http://localhost:5320/api/auth/logout      # 登出
GET  http://localhost:5320/api/user/info        # 用户信息
GET  http://localhost:5320/api/product/list     # 商品列表(支持分页)
GET  http://localhost:5320/api/product/detail   # 商品详情

支持 JWT 认证、请求拦截、分页查询,内置两个测试账号:

用户名 密码 角色
user 123456 普通用户
admin 123456 管理员

前端通过 Vite proxy 透明代理,开发体验和联调阶段完全一致,不改一行代码切到真实后端。


5. pnpm catalog 统一依赖版本

# pnpm-workspace.yaml
catalog:
  vue: ^3.5.32
  vite: ^8.0.8
  typescript: ^6.0.2
  vant: ^4.9.21
  # ...

所有 package.json 里只写 "vant": "catalog:",版本全部集中在 catalog 管理。三个 app 用的同一个版本,升级只改一处,彻底解决版本漂移问题。


6. 状态管理:Pinia + 持久化 + AES 加密

import { initStores } from "@vh5/stores";

// 初始化时传入命名空间,多应用同域部署不冲突
await initStores(app, { namespace: "h5-vant-1.0.0-prod" });
  • 开发环境:直接用 localStorage,方便 DevTools 调试
  • 生产环境SecureLS + AES 加密,敏感数据不裸奔
  • 命名空间隔离h5-vant-xxxh5-nutui-xxx 的 key 不会互相污染

7. 国际化:四语言 + 动态加载

内置简体中文、繁体中文(台湾/香港)、英文、日文五个语言:

packages/locales/src/langs/
├── zh-CN/    # 简体中文
├── zh-TW/    # 繁体中文(台湾)
├── en-US/    # 英文
└── ja-JP/    # 日文

语言包放在共享包里,各 app 通过 @vh5/locales 按需动态加载,不增加首屏包体积:

import { loadLocaleMessages } from "@vh5/locales";

// 切换语言时才加载对应语言包
await loadLocaleMessages("en-US");

8. 路由进度条 + 动态标题

三个 app 均集成了 NProgress 路由进度条和自动标题更新:

// router/guard.ts
import { startProgress, stopProgress } from "@vh5/utils";

router.beforeEach(() => startProgress());
router.afterEach(() => stopProgress());

动态标题在 bootstrap 里通过 VueUse 的 useTitle 绑定路由 meta,路由切换自动更新 document.title,无需每个页面单独处理。


工程化配置

统一 Lint 规则

所有 ESLint / Prettier / Stylelint / Commitlint / OXLint 配置在 internal/lint-configs/ 集中管理,各 app 只需一行继承:

# 检查 + 自动修复
pnpm lint

# 格式化
pnpm format

Git commit 使用 Conventional Commits 规范,Lefthook 自动执行 pre-commit 检查。

共享 Vite 配置

@vh5/vite-config 提供 defineConfig 工厂函数,内置以下插件:

插件 说明
@vitejs/plugin-vue Vue 3 SFC + defineModel
unplugin-auto-import API 自动导入(vue、pinia、vueuse)
unplugin-vue-components 组件按需自动注册
vite-plugin-vue-devtools Vue DevTools
postcss-px-to-viewport px → viewport 移动端适配
vite-plugin-html HTML 模板变量注入
vite-plugin-compression Gzip/Brotli 构建压缩
vite-plugin-pwa PWA 离线支持
nitro-mock 自动启动 Mock 服务
vite:license 构建产物注入版权信息

vsh CLI 工具集

pnpm create-app        # 创建新 H5 应用
vsh lint               # 代码检查
vsh check-circular     # 循环依赖检测
vsh check-dep          # 未使用依赖检查
vsh publint            # package.json 规范检查
vsh code-workspace     # 生成 VSCode workspace 文件

快速开始

# 克隆
git clone https://github.com/fonghehe/vue-h5-template.git
cd vue-h5-template

# 安装(需要 Node.js >= 20.12.0 + pnpm >= 10)
pnpm install

# 开发(交互式选择应用)
pnpm dev

# 或直接启动指定应用
pnpm dev:vant     # Vant 版,端口 5778
pnpm dev:nutui    # NutUI 版,端口 5777
pnpm dev:varlet   # Varlet 版,端口 5779

# 创建新应用
pnpm create-app

# 构建所有应用
pnpm build

适合哪些场景

  • ✅ 团队需要维护多个移动端 H5 项目
  • ✅ 想在不同 UI 框架之间灵活切换
  • ✅ 需要快速搭建标准化的新 H5 应用
  • ✅ 希望统一团队的工程化规范(Lint、TypeScript、构建)
  • ✅ 学习 Turborepo Monorepo 最佳实践

浏览器支持

支持现代浏览器和移动端浏览器,不支持 IE。

Edge Firefox Chrome Safari
≥ 80 ≥ 78 ≥ 80 ≥ 14

写在最后

这个模板目前在我自己的几个项目里实际使用,持续迭代中。

如果你觉得有帮助,欢迎 Star ⭐ 和 PR 🎉

有问题或建议欢迎提 Issue 讨论!

前端监控体系与实践:从错误上报到内存与 GC 观测

作者 我的刀盾
2026年4月28日 17:46

前端监控的目标,是把「用户侧真实体验」和「线上可观测性」连起来:出了问题能第一时间知道、能定位到版本与路径、能量化影响面,而不是依赖用户截图或口头描述。本文先梳理常见监控维度,再用一个基于 WeakRefFinalizationRegistryGC 监控工具 举例,说明如何把「疑似内存泄漏」从感觉变成可上报的信号。


1. 为什么要做前端监控

  • 错误与稳定性:未捕获异常、资源加载失败、接口 4xx/5xx、白屏,直接影响转化与留存。
  • 性能(RUM):首屏、可交互时间、长任务、INP 等,决定「卡不卡」的主观感受。
  • 业务与行为:关键漏斗、按钮曝光点击、实验分流,需要与技术指标同一条时间线对齐。
  • 安全与合规:CSP 违规、异常脚本注入等,有时也要在前端侧留痕。

没有监控时,团队往往在「复现难、归因慢、不知道影响多大」之间消耗;有监控后,可以把问题收敛到:哪次发布、哪条路由、哪类设备

1.1 典型场景:问题只在线上、本地怎么都复现不了

这类情况很常见,监控的意义就在于把「用户环境里的差异」变成可查的数据,而不是依赖你在本机再点一百遍。

虚构但贴近真实的一例:

某后台列表页带「侧滑详情抽屉」。客服反馈:安卓手机用一上午后,列表滚动越来越卡,偶发白屏;你用自己的电脑 Chrome 开着 DevTools 点了一下午,内存曲线平稳、Performance 里也没有明显长任务——本地就是复现不了。

为什么线上和本地会「长得不一样」:

维度 本地开发 线上用户侧
数据量 造数几条、几十条 真实账号上万条、分页反复加载
使用路径 点几条最短路径 反复打开/关闭抽屉、切 tab、退回列表
设备与内存 高配 PC、内存充裕 中低端机、系统杀进程压力大,GC 更频繁
网络与缓存 localhost / 企业内网 弱网、CDN 命中差异、接口偶发慢导致重试堆积
运行时长 每次刷新从零开始 单页长时间不刷新,泄漏是「攒出来」的

这时监控能帮你做什么(和本文 GC 示例如何接上):

  1. RUM / 自定义性能:按「路由 + 版本号」看 INP、长任务次数;若只有「列表+抽屉」这条路径在低端机上飙升,至少知道战场在哪
  2. 面包屑与会话:还原用户操作序列(进了多少次详情、是否总不关抽屉),本地往往不会按这种强度操作。
  3. 灰度打开 GCMonitor 一类工具(低采样、仅特定路由):对「抽屉根节点」在 onUnmounted 之后做延迟存活检查;若线上大量出现「某组件 id 已卸载仍长期存活」的上报,而本地没有——说明强引用链或全局缓存与用户数据规模、打开次数耦合,这就把「无法复现」收窄成可统计的线上特征,再回到代码里查事件监听、全局 Map、单例缓存是否按页面卸载清理。

结论:本地复现不了 ≠ 问题不存在;用线上监控把环境、路径、版本、设备拉齐,再配合有针对性的轻量探针(如 GC 观测),才能把「玄学问题」变成可修的工单。


2. 常见监控分层(你可以按优先级落地)

层级 典型内容 常见载体
采集 全局 error / unhandledrejection、路由变化、性能条目(PerformanceObserver) SDK 或自研脚本
传输 sendBeacon、批量队列、失败重试 网关 / 同域 API
存储与查询 日志索引、TraceId、用户/会话维度 ELK、ClickHouse、厂商后台
告警与工单 阈值、同比、影响用户数 PagerDuty、企业微信等

实践建议:先做「全局错误 + 基础 RUM(导航、LCP 等)」,再按业务补自定义事件;自定义越多,越要在 SDK 里做采样与体积控制,避免拖慢主线程。


3. 性能与内存:RUM 之外的「泄漏」怎么抓

性能监控里,内存问题相对难:堆快照适合线下深挖,线上则更适合:

  • Chrome Memory Pressure API(若可用)与 Performance.measureUserAgentSpecificMemory(需隔离上下文等,使用面有限);
  • 定期观察 JSHeapSizeLimit 相关指标(粗粒度);
  • 结合业务生命周期:路由离开、弹窗关闭后,相关 DOM/闭包是否仍被强引用。

下面示例走另一条路:用语言特性观察「对象是否已被 GC 回收」,适合在开发/灰度阶段对「组件卸载后是否仍被挂住」做自动化怀疑。


4. 示例:GCMonitor——用 WeakRef + FinalizationRegistry 观测回收

4.1 思路说明

  • WeakRef:对目标对象是弱引用,不会阻止 GC;deref() 在对象仍存活时返回引用,否则返回 undefined
  • FinalizationRegistry:当注册的对象被回收时,会异步调用你提供的回调(不要假设回调的精确时机,只把它当作「已回收」的信号之一)。

组合起来可以:

  1. monitor(obj, id):注册监控,记录开始时间;
  2. 回收发生时:在 registry 回调里打日志、算存活时长、可上报监控平台;
  3. 兜底:一段时间后 checkAlive,若 deref() 仍有值,说明对象仍被强引用链挂住,疑似泄漏(也可能是用户仍停留在该页、或正常仍需要该节点——所以要配合「组件已卸载」等业务语义)。

4.2 完整示例代码(utils/gcMonitor.js

// utils/gcMonitor.js
class GCMonitor {
  constructor() {
    this.refs = new Map() // id → { weakRef, timestamp }
    this.registry = new FinalizationRegistry((id) => {
      const info = this.refs.get(id)
      if (info) {
        const duration = Date.now() - info.timestamp
        console.log(`[GC] ✅ 组件 ${id} 已被回收,存活时长:${duration}ms`)
        this.refs.delete(id)
      }
    })
  }

  /**
   * 监控一个 DOM 节点(或任意对象)
   * @param {object} obj - 要监控的对象(通常是组件的根 DOM 元素)
   * @param {string} id - 唯一标识(推荐格式:组件名_路由_时间戳)
   */
  monitor(obj, id) {
    if (this.refs.has(id)) {
      console.warn(`[GCMonitor] 组件 ${id} 已存在监控,跳过`)
      return
    }

    const weakRef = new WeakRef(obj)
    this.refs.set(id, {
      weakRef,
      timestamp: Date.now(),
    })
    this.registry.register(obj, id)

    // 延迟 5 秒后主动检查是否还活着(兜底检测)
    setTimeout(() => this.checkAlive(id), 5000)
  }

  /**
   * 主动检查某个对象是否已被回收
   * @param {string} id
   * @returns {boolean} true=还活着,false=已回收
   */
  checkAlive(id) {
    const info = this.refs.get(id)
    if (!info) return false // 已被 FinalizationRegistry 清理

    const obj = info.weakRef.deref()
    if (obj) {
      console.error(
        `[GCMonitor] 🚨 疑似泄漏:组件 ${id}${
          Date.now() - info.timestamp
        }ms 后仍然存活!`
      )
      // 可上报到监控平台
      // window.__SENTRY__?.captureMessage(`内存泄漏疑似: ${id}`)
      return true
    } else {
      console.log(`[GCMonitor] 组件 ${id} 已被回收(主动检测到)`)
      this.refs.delete(id)
      return false
    }
  }

  /**
   * 记录组件销毁次数(用于统计泄漏率)
   * @param {string} id
   */
  recordDestroy(id) {
    console.log(`[组件销毁] ${id} 已从 DOM 树移除,等待 GC 验证`)
    // 可以扩展:将 id 存入一个 Set,后续对比 GC 回调数量
  }

  /**
   * 获取所有仍存活的监控对象 ID(调试用)
   */
  getAliveIds() {
    const alive = []
    for (const [id, info] of this.refs.entries()) {
      if (info.weakRef.deref()) {
        alive.push(id)
      }
    }
    return alive
  }
}

// 导出全局单例
export default new GCMonitor()

4.3 在组件里怎么用(示意)

要点:在「挂载完成、能拿到根 DOM」时 monitor;在「卸载钩子」里调用 recordDestroy(可选),并把 id 设计成可区分路由与实例。

import gcMonitor from '@/utils/gcMonitor'

const id = `UserCard_/users/${userId}_${Date.now()}`

onMounted(() => {
  const el = rootRef.value // 或 this.$el
  if (el) gcMonitor.monitor(el, id)
})

onUnmounted(() => {
  gcMonitor.recordDestroy(id)
})

4.4 使用时的注意点(避免误报)

  1. FinalizationRegistry 回调是异步且不确定时序的,不能与「同步卸载」画等号;checkAlive 的 5 秒只是示例,长生命周期页面要适当延长或多次采样。
  2. 若组件仍在当前路由或仍挂在树上deref() 一直非空是正常现象,不是泄漏。
  3. 生产环境建议:仅在灰度/调试开关打开时启用;上报时用采样率,避免刷屏。
  4. 兼容性:需较新的 JS 引擎;老旧 WebView 需自行降级或关闭该能力。

4.5 Vue2 示例:在路由切换中「批量」触发泄漏怀疑检查

很多泄漏不是单个组件的问题,而是「某个路由反复进出」才会逐渐堆积。一个很实用的做法是:

  • 组件卸载时登记 id(说明它“应该消失了”)
  • 路由切走后统一延迟检查:对离开的路由里登记过的全部 id 调用 checkAlive,一次切换跑一批,便于统计与上报

下面给出一个 Vue2 + Vue Router 的示例,核心是一个小插件 + 一个 mixin(或基类组件)。

4.5.1 路由批量调度器(utils/gcRouteBatch.js

// utils/gcRouteBatch.js
import gcMonitor from '@/utils/gcMonitor'

/**
 * 在路由切换时,对「离开的路由」里登记过的组件 id 批量触发 checkAlive。
 * - 只负责调度,不负责采集 DOM(DOM 由组件自己 monitor)
 * - 建议仅在灰度/调试开关下启用,并控制采样
 */
export function setupGCRouteBatch(router, options = {}) {
  const {
    enabled = true,
    delayMs = 8000, // 给 GC 留出时间窗口;可根据页面复杂度调大
    sampleRate = 0.1, // 线上建议采样
  } = options

  if (!enabled) return { trackDestroyed: () => {} }

  // routeKey → Set<id>
  const destroyedByRoute = new Map()

  const keyOf = (route) => {
    const name = route && route.name ? route.name : 'noname'
    const path = route && route.path ? route.path : ''
    const fullPath = route && route.fullPath ? route.fullPath : ''
    return `${name}|${path}|${fullPath}`
  }

  function shouldSample() {
    return Math.random() < sampleRate
  }

  function trackDestroyed(route, id) {
    const k = keyOf(route)
    let set = destroyedByRoute.get(k)
    if (!set) {
      set = new Set()
      destroyedByRoute.set(k, set)
    }
    set.add(id)
  }

  router.afterEach((to, from) => {
    if (!from) return
    if (!shouldSample()) return

    const fromKey = keyOf(from)
    const ids = destroyedByRoute.get(fromKey)
    if (!ids || ids.size === 0) return

    // 路由离开后,延迟批量检查:还活着 → 疑似泄漏
    setTimeout(() => {
      for (const id of ids) gcMonitor.checkAlive(id)
      destroyedByRoute.delete(fromKey)
    }, delayMs)
  })

  return { trackDestroyed }
}

4.5.2 组件侧统一接入(Vue2 mixin 示例)

组件侧做两件事:

  • mounted:拿到根 DOM 后 monitor(el, id)
  • beforeDestroyrecordDestroy(id) + 把 id 交给路由批量调度器(归到当前路由)
import gcMonitor from '@/utils/gcMonitor'

// mixins/gcTrackMixin.js
export function createGCTrackMixin(options = {}) {
  const { componentName, getRootEl, trackDestroyed } = options

  return {
    data() {
      const route = this.$route
      const name = componentName || this.$options.name || 'AnonymousComponent'
      const fullPath = route && route.fullPath ? route.fullPath : 'noroute'
      return {
        __gc_track_id__: `${name}_${fullPath}_${Date.now()}_${this._uid}`,
      }
    },
    mounted() {
      const el = getRootEl ? getRootEl.call(this) : this.$el
      if (el) gcMonitor.monitor(el, this.__gc_track_id__)
    },
    beforeDestroy() {
      gcMonitor.recordDestroy(this.__gc_track_id__)
      if (typeof trackDestroyed === 'function') {
        trackDestroyed(this.$route, this.__gc_track_id__)
      }
    },
  }
}

4.5.3 在应用入口启用(main.js

import Vue from 'vue'
import router from './router'
import { setupGCRouteBatch } from '@/utils/gcRouteBatch'

// 建议:仅在灰度/调试环境开启,或受开关控制
const { trackDestroyed } = setupGCRouteBatch(router, {
  enabled: true,
  delayMs: 8000,
  sampleRate: 0.1,
})

// 挂到全局,组件里可通过 this.$gcTrackDestroyed 调用
Vue.prototype.$gcTrackDestroyed = trackDestroyed

4.5.4 在组件中使用(示意)

方式 A:直接在组件里写(最直观)

import gcMonitor from '@/utils/gcMonitor'

export default {
  name: 'UserDrawer',
  mounted() {
    this.__gcId = `UserDrawer_${this.$route.fullPath}_${Date.now()}_${this._uid}`
    gcMonitor.monitor(this.$el, this.__gcId)
  },
  beforeDestroy() {
    gcMonitor.recordDestroy(this.__gcId)
    this.$gcTrackDestroyed && this.$gcTrackDestroyed(this.$route, this.__gcId)
  },
}

方式 B:用 mixin 复用(更适合大规模接入)

import { createGCTrackMixin } from '@/mixins/gcTrackMixin'

export default {
  name: 'UserDrawer',
  mixins: [
    createGCTrackMixin({
      componentName: 'UserDrawer',
      getRootEl() {
        return this.$el // 或者 return this.$refs.rootEl
      },
      trackDestroyed(route, id) {
        this.$gcTrackDestroyed && this.$gcTrackDestroyed(route, id)
      },
    }),
  ],
}

这种写法的好处是:你不需要在每个组件里手动 setTimeout(checkAlive)路由切走就是天然的批处理时机,也便于在监控平台按「from 路由」聚合统计疑似泄漏率。


5. 与「传统监控」如何配合

  • 错误监控(Sentry、自研等):堆栈 + Release + SourceMap,解决「哪行代码炸了」。
  • RUM:LCP、FID/INP、CLS、TTFB,解决「慢在哪里」。
  • 本文 GC 示例:偏向「卸载后的对象是否仍活着」,解决「是不是被挂住了」这一类内存侧怀疑

三者互补:错误告诉你异常路径,性能告诉你主线程与资源,GC 监控在合适场景下帮你缩小泄漏排查的搜索范围。


6. 小结

前端监控的本质是用统一管道把线上信号送回来:从全局错误与 RUM 打底,到业务自定义事件,再到像 GCMonitor 这样针对特定问题的轻量工具。尤其当问题呈现为仅线上、长路径、弱设备才暴露(见上文 1.1 节)时,没有监控几乎只能猜。WeakRefFinalizationRegistry 让我们能用较少侵入的方式观察回收行为;真正落地时,务必结合路由/挂载语义、采样与兼容性,把「疑似泄漏」变成可行动的工单,而不是控制台噪音。


参考与延伸阅读

别用Quill了,打造自己的Tiptap富文本编辑器

作者 卸任
2026年4月28日 17:32

前言

说起富文本编辑器,大多数都能想到Quill。但是他丑是真的丑,卡是真的卡。上次就想接入一个表格,不是报错就是样式太丑了。

后来我就找到了Tiptap,也有现成的npm包可以使用www.npmjs.com/package/rea… ,但是reactjs-tiptap-editor-playground.vercel.app/ 。在他的演示网址上的输入不知道为什么一卡一卡的。

最近小试了一下,打造自己的Tiptap富文本编辑器。

仓库地址:github.com/lzt-T/zt-re…

npm包地址:www.npmjs.com/package/zt-…

欢迎使用,bug随便提

正文

接下来看看有哪些东西

两种模式

传统的富文本形式

image.png

使用命令菜单

image.png

主题的切换和国际化

image.png

image.png

行内公式和块级公式

image.png

编辑弹窗

image.png

表格

image.png

编辑表格 image.png

附件上传

image.png

上传弹窗 image.png

图片上传

image.png

上传弹窗 image.png

代码块

image.png

结语

感兴趣可以安装一下试试

uni-app在微信小程序国际化分包方案:优雅解决主包体积超限问题

作者 3076
2026年4月28日 17:16

一、背景与痛点

在开发大型 UniApp 项目时,国际化语言包往往是体积大户。随着业务发展,支持的语言种类增多、文案内容丰富,主包体积会快速膨胀,甚至触发微信小程序 2MB 主包限制

核心问题

┌─────────────────────────────────────────────────────────────┐
│                    传统方案:主包包含所有语言                 │
├─────────────────────────────────────────────────────────────┤
│  主包 (2.1MB)                                              │
│  ├── zh-CN.js (500KB)                                     │
│  ├── en-US.js (500KB)                                     │
│  ├── ja-JP.js (500KB)                                     │
│  ├── ko-KR.js (500KB)                                     │
│  └── 业务代码 (100KB)                                      │
└─────────────────────────────────────────────────────────────┘
    ↓ 体积超限!微信审核不通过

二、解决方案:分包懒加载语言包

架构设计

┌─────────────────────────────────────────────────────────────┐
│                      优化后方案                              │
├─────────────────────────────────────────────────────────────┤
│  主包 (600KB)                                              │
│  ├── zh-CN.js (500KB)  ← 仅保留默认语言                     │
│  └── 业务代码 (100KB)                                       │
├─────────────────────────────────────────────────────────────┤
│  分包 pagesData/                                           │
│  ├── i18n/lang/                                           │
│  │   ├── zh-CN.js                                         │
│  │   └── en-US.js                                         │
│  └── appDetails/                                          │
│      └── appDetails.vue  ← 进入时自动加载分包语言包          │
└─────────────────────────────────────────────────────────────┘
    ↓ 主包体积大幅减少,通过审核

核心原理

  1. 主包仅包含默认语言:减少初始下载体积
  2. 分包语言包随页面懒加载:用户进入分包页面时才加载对应语言包
  3. 利用 mergeLocaleMessage 动态合并:Vue I18n 原生支持增量合并语言包

三、实现方案

3.1 主包入口:locale/index.js

import { createI18n } from "vue-i18n"
import zhCN from "./lang/zh-CN"
import enUS from "./lang/en-US"

// 创建基础 i18n 实例(仅包含核心语言)
const i18n = createI18n({
  locale: uni.getStorageSync("lang") || "zh-CN",
  fallbackLocale: "zh-CN",
  legacy: false,
  globalInjection: true,
  messages: {
    "zh-CN": zhCN,
    "en-US": enUS
  }
})

// 核心:动态合并分包语言包
const loadedModules = new Set()

export async function loadSubPackageI18n(pkgName) {
  if (!pkgName) return
  
  const locale = i18n.global.locale
  const lang = typeof locale === 'string' ? locale : locale.value
  const key = `${pkgName}-${lang}`

  // 防止重复加载
  if (loadedModules.has(key)) return

  try {
    // #ifdef H5 || MP-TOUTIAO || APP
    // 其他平台直接动态导入
    const messagesModule = await import(`../${pkgName}/i18n/lang/${lang}.js`)
    const messages = messagesModule.default || messagesModule
    i18n.global.mergeLocaleMessage(lang, messages)
    loadedModules.add(key)
    // #endif

    // #ifdef MP-WEIXIN
    // 微信小程序:主包无法加载分包,此处仅做标记,实际加载由分包完成
    // #endif
  } catch (err) {
    console.warn(`[i18n] 加载分包语言失败:${pkgName}/${lang}`, err)
  }
}

export default i18n

3.2 分包入口:pagesData/i18n/index.js

import { useI18n } from 'vue-i18n'
import zhCN from './lang/zh-CN.js'
import enUS from './lang/en-US.js'

/**
 * 分包内语言包初始化函数
 * 关键:在微信小程序环境下,必须在分包页面内部调用
 */
export function initPagesDataI18n() {
    const { locale, mergeLocaleMessage } = useI18n()
    const lang = typeof locale.value === 'string' ? locale.value : 'zh-CN'

    // 根据当前语言选择对应语言包
    const messages = lang === 'en-US' ? enUS : zhCN
    
    // 合并到全局 i18n 实例
    mergeLocaleMessage(lang, messages)
    console.log(`[i18n] pagesData 分包语言包加载成功`)
}

3.3 分包页面调用:pagesData/appDetails/appDetails.vue

<script setup>
// #ifdef MP-WEIXIN
// 微信小程序必须在分包页面内加载语言包
import { initPagesDataI18n } from "../i18n/index.js"
initPagesDataI18n()
// #endif

import { onLoad } from "@dcloudio/uni-app"

onLoad(() => {
  // 页面业务逻辑
})
</script>

四、关键技术点

4.1 微信小程序分包机制限制

限制类型 说明 解决方案
主包无法访问分包文件 import 分包文件会报 "找不到模块" 错误 在分包页面内自行加载
分包预加载 用户进入分包后才下载 利用 mergeLocaleMessage 动态合并
重复加载风险 多次进入同一分包页面 使用 Set 记录已加载模块

4.2 多端兼容策略

// #ifdef MP-WEIXIN
// 微信:在分包页面内调用 initPagesDataI18n()
// #endif

// #ifdef H5 || MP-TOUTIAO || APP
// 其他平台:主包动态 import 分包语言包
import(`../${pkgName}/i18n/lang/${lang}.js`)
// #endif

// #ifdef APP-HARMONY
// 鸿蒙:使用 import.meta.glob 静态收集
const locales = import.meta.glob('../pages*/i18n/lang/*.js', { eager: true })
// #endif

4.3 语言包结构设计

pagesData/i18n/lang/
├── zh-CN.js
└── en-US.js

语言包内容示例

// pagesData/i18n/lang/zh-CN.js
export default {
  appDetails: {
    title: "应用详情",
    size: "应用大小",
    version: "版本号",
    updateTime: "更新时间"
  }
}

五、使用流程

用户进入分包页面
        ↓
触发 onLoad 生命周期
        ↓
调用 initPagesDataI18n()
        ↓
获取当前语言 locale
        ↓
加载对应语言包 zh-CN.js / en-US.js
        ↓
mergeLocaleMessage 合并到全局
        ↓
页面使用 t("appDetails.title") 即可

六、效果对比

指标 优化前 优化后 提升
主包体积 2.1MB 600KB -71%
首屏加载时间 3.2s 1.8s -44%
审核通过率 失败 通过
分包按需加载

七、注意事项

  1. 语言包命名规范:保持与主包一致的语言标识(zh-CN、en-US)
  2. 避免重复键名:分包语言包建议使用独立命名空间(如 appDetails.*
  3. 错误处理:加载失败时应有降级方案(使用 fallbackLocale)
  4. 热更新兼容:分包更新后需清空 loadedModules 缓存

八、总结

通过将语言包按分包拆分,我们成功解决了微信小程序主包体积超限问题。核心思路是:

  1. 主包瘦身:仅保留必要的默认语言
  2. 分包自治:每个分包管理自己的语言包
  3. 懒加载策略:用户进入时才加载对应语言包
  4. 动态合并:利用 Vue I18n 的 mergeLocaleMessage 实现增量加载

wolfram详解山峦算法

2026年4月28日 17:04

代码链接:www.wolframcloud.com/obj/1051904…

知识点

  • noise 算法
  • noise 栅格
  • noise 栅格过度
  • 梯度

1-noise 绘制山峦的原理

noise 可以译作杂色,或者噪波,它可以理解为一种肌理,其表现形式有很多。

使用杂色可以绘制山峦、云海等。

noise 绘制山峦的原理如下:

1.杂色。

image-20260426114414985

2.栅格:降低采样频率,将杂色变成栅格。

image-20260426114511276

3.山峦:栅格平滑过度。

image-20260426170319591

4.山峦细节:对山峦进行多次变换叠加。

image-20260428110734479

利用这种算法,可以在shader 中渲染出云山云海的效果。

mount

2-随机数

杂色的实现原理就是随机数。

随机数的写法有很多。接下来,我会使用wolfram 语言演示其算法原理。

1.根据一维数据生成随机数的方法。

random[x_]:=Abs[FractionalPart[1000*Sin[x]]]
DiscretePlot[random[x],{x,0,10,0.1}]

效果如下:

image-20260423183548641

2.根据片元的二维位置生成随机数的方法。

vec2 = {_?NumericQ, _?NumericQ};
random[p:vec2]:=Module[{v},
v=FractionalPart[{p[[1]],p[[2]],p[[1]]}*0.1031];
v+=Dot[v,{v[[2]],v[[3]],v[[1]]}+33.33];
FractionalPart[(v[[1]]+v[[2]])*v[[3]]]
];
Plot3D[random[{x,y}],{x,0,10},{y,0,10},ColorFunction -> Function[{x, y, z}, GrayLevel[z]],Mesh->None]

效果如下:

image-20260426114414985

3-栅格

我们可以将点位取整,从而画出大块的杂色。

vec2 = {_?NumericQ, _?NumericQ};
random[p:vec2]:=Module[{v},
v=FractionalPart[{p[[1]],p[[2]],p[[1]]}*0.1031];
v+=Dot[v,{v[[2]],v[[3]],v[[1]]}+33.33];
FractionalPart[(v[[1]]+v[[2]])*v[[3]]]
];
noise[p:vec2]:=Module[{i,f,u,a,b,c,d,tx,ty},
i=Floor[p];
random[i]
]
plotSize=10;
Plot3D[noise[{x,y}],{x,0,plotSize},{y,0,plotSize},ColorFunction -> Function[{x, y, z}, GrayLevel[z]]]

效果如下:

image-20260426114511276

noise 栅格颜色的深浅代表了山的高度。但它现在是离散的,缺少过度。所以我们接下来要给栅格一个过度。

4-栅格过度

栅格过度的核心在于使用插值在相邻的栅格间做补间运算。

4-1-一行栅格的过度

一行没有过度的栅格高度图如下:

random[x_]:=Abs[FractionalPart[1000*Sin[x]]];
noise[x_]:=Module[{},
i=Floor[x];
a=random[i];
a
]
Plot[noise[x],{x,0,10}]

效果如下:

image-20260425002625971

我们可以以栅格位置的小数部分为插值,对相邻的两个栅格做补间。

random[x_]:=Abs[FractionalPart[1000*Sin[x]]];
mix[a_,b_,f_]:=Module[{},
(a+(b-a)*f)
];
noise[x_]:=Module[{},
i=Floor[x];
f=FractionalPart[x];
a=random[i];
b=random[i+1];
mix[a,b,f]
]
Plot[noise[x],{x,0,10}]

效果如下:

image-20260425003942467

mix[a_,b_,f_] 方法是基于f差值在a和b间做补间的方法。

在noise 方法中,a,b是相邻的2个栅格的值,f是栅格位置的小数部分,可以用作插值。

4-2-二维栅格的过度

过度算法

image-20241127151550726

已知:

  • 栅格尺寸为1

  • 栅格4个顶点:

    • 点P(px,py,a)
    • 点P右侧的点(px+1,py,b)
    • 点P上方的点(px,py+1,c)
    • 点P右上方的点(px+1,py+1,d)
  • 点F(px+fx,py+fy,e),px和py是整数,fx和fy是小数,e未知

求:e

思路:e 可以理解为b,c,d对a的加权

解:

b 对a 的影响力是:

(b-a)*fx*(1-fy)

(b-a)*fx 是b在x方向对a的影响,同时其影响力还会受到fy 的影响。

c 对a 的影响力是:

(c-a)*fy*(1-fx)

其原理与b同理。

d 对a 的影响力是:

(d-a)*fx*fy

最后把b,c,d 对a 的影响力合到a 上,就是着色点e 的颜色:

e=a+(b-a)*fx*(1-fy)+(c-a)*fy*(1-fx)+(d-a)*fx*fy

e 的颜色是对山的高度的可视化描述,方便大家理解。

算法可视化

vec2 = {_?NumericQ, _?NumericQ};
random[p:vec2]:=Module[
{
x=p[[1]],
y=p[[2]],
v
},
v=FractionalPart[{x,y,x}*0.1031];
v+=Dot[v,{v[[2]],v[[3]],v[[1]]}+33.33];
FractionalPart[(v[[1]]+v[[2]])*v[[3]]]
];
noise[p:vec2]:=Module[{i,f,u,a,b,c,d,tx,ty},
i=Floor[p];
f=FractionalPart[p];
a=random[i];
b=random[i+{1,0}];
c=random[i+{0,1}];
d=random[i+{1,1}];
tx=f[[1]];
ty=f[[2]];
a+(b-a)*tx*(1-ty)+(c-a)*ty*(1-tx)+(d-a)*tx*ty
]
plotSize=10;
Plot3D[noise[{x,y}],{x,0,plotSize},{y,0,plotSize},Mesh->None]

效果如下:

image-20260426170319591

5-山体圆滑

当前的山体看起来比较凌厉,我可以使其圆滑一些。

5-1-线性补间与曲线补间

我们之前使用的补间算法是线性补间。

noise[x_]:=Module[{},
FractionalPart[x]
]
Plot[noise[x],{x,0,3}]

image-20260425004053941

我们可以曲线补间。

noise[x_]:=Module[{},
tx=FractionalPart[x];
FractionalPart[3*tx^2-2*tx^3]
]
Plot[noise[x],{x,0,3}]

image-20260425004145361

5-2-曲线补间的应用

曲线补间可以圆滑折线图。

random[x_]:=Abs[FractionalPart[1000*Sin[x]]];
mix[a_,b_,f_]:=Module[{},
(a+(b-a)*f)
];
noise[x_]:=Module[{},
i=Floor[x];
f=FractionalPart[x];
u=3*f^2-2*f^3;
a=random[i];
b=random[i+1];
mix[a,b,u]
]
Plot[noise[x],{x,0,10}]

效果如下:

image-20260425005014832

曲线补间也可以三维山峦。

vec2 = {_?NumericQ, _?NumericQ};
random[p:vec2]:=Module[
{
x=p[[1]],
y=p[[2]],
v
},
v=FractionalPart[{x,y,x}*0.1031];
v+=Dot[v,{v[[2]],v[[3]],v[[1]]}+33.33];
FractionalPart[(v[[1]]+v[[2]])*v[[3]]]
];
noise[p:vec2]:=Module[{i,f,u,a,b,c,d,tx,ty},
i=Floor[p];
f=FractionalPart[p];
u=3*f^2-2*f^3;
a=random[i];
b=random[i+{1,0}];
c=random[i+{0,1}];
d=random[i+{1,1}];
tx=u[[1]];
ty=u[[2]];
a+(b-a)*tx*(1-ty)+(c-a)*ty*(1-tx)+(d-a)*tx*ty
]
plotSize=10;
Plot3D[noise[{x,y}],{x,0,plotSize},{y,0,plotSize},Mesh->None]

效果如下:

image-20260426170413707

我们可以让z轴和x,y 轴等比,使之更接近现实。

Plot3D[noise[{x,y}],{x,0,plotSize},{y,0,plotSize},PlotRange -> {0, plotSize},BoxRatios -> {1, 1, 1},Mesh -> None]

效果如下:

image-20260426170732918

当前的山体有些简约,我们可以增加更多的细节。

6-增加山体细节

我可以对山体进行多次变换叠加,使其具有更能多细节。

原理如下图所示:

山势叠加

第一张c 图是由下面的b 图和a 图叠加而成。

按照此原理,对三维山峦进行变换叠加。

vec2 = {_?NumericQ, _?NumericQ};
random[p:vec2]:=Module[
{
x=p[[1]],
y=p[[2]],
v
},
v=FractionalPart[{x,y,x}*0.1031];
v+=Dot[v,{v[[2]],v[[3]],v[[1]]}+33.33];
FractionalPart[(v[[1]]+v[[2]])*v[[3]]]
];
noise[p:vec2]:=Module[{i,f,u,a,b,c,d,tx,ty},
i=Floor[p];
f=FractionalPart[p];
u=3*f^2-2*f^3;
a=random[i];
b=random[i+{1,0}];
c=random[i+{0,1}];
d=random[i+{1,1}];
tx=u[[1]];
ty=u[[2]];
a+(b-a)*tx*(1-ty)+(c-a)*ty*(1-tx)+(d-a)*tx*ty
];
mountainTF=2*{{0.6,-0.8},{0.8,0.6}};
mountain[p:vec2]:=Module[{p2=p,a,b,n},
a=0;
b=1;
For[i = 0, i < 4, i++,
n=1.65*noise[p2*0.5];
a+=b*n/(1+i);
p2=mountainTF.p2;
b*=0.5;
];
a
];
plotSize=10;
Plot3D[mountain[{x,y}],{x,0,plotSize},{y,0,plotSize},PlotRange -> {0, plotSize},BoxRatios -> {1, 1, 1},PlotPoints -> 30,Mesh -> None]

效果如下:

image-20260426185404625

这样山体就有了细节。我们还可以根据山峰的形成规律,使其看起来更加接近现实。

7-山的规律

观察现实中的山峰,我们不难发现一个基本规律:山峰陡峭的地方会更加平滑,山峰平缓的地方会有更多的山岩。

山峰

按照此规律,我们在叠加山体的时候,根据采样点的梯度判断陡峭度,在山峰陡峭的地方,让叠加山体更能矮。

使用wolfram的Grad 方法求noise函数的梯度。

Grad[a+(b-a)*u[x]*(1-u[y])+(c-a)*u[y]*(1-u[x])+(d-a)*u[x]*u[y],{x,y}] //Simplify

输出:

{(-a+b+(a-b-c+d)u[y])u'[x],(-a+c+(a-b-c+d)u[x])u'[y]}
  • u是曲线函数.
u=3*f^2-2*f^3;
  • u'[x]和u'[y] 是u 的导数,即
D[3*f^2 - 2*f^3, f]
du=6f-6

noise函数的梯度可以简化一下:

({b-a,c-a}+a-b-c+d)*{ty,yx})*du

我们可以根据梯度的点积确定山峰的陡峭程度,从而让陡峭的地方山体更矮。

整体程序如下:

vec2 = {_?NumericQ, _?NumericQ};
random[p:vec2]:=Module[
{
x=p[[1]],
y=p[[2]],
v
},
v=FractionalPart[{x,y,x}*0.1031];
v+=Dot[v,{v[[2]],v[[3]],v[[1]]}+33.33];
FractionalPart[(v[[1]]+v[[2]])*v[[3]]]
];
noise[p:vec2]:=Module[{i,f,u,du,a,b,c,d,tx,ty,x,yz},
i=Floor[p];
f=FractionalPart[p];
u=3*f^2-2*f^3;
du=6*f-6f*f*f;
a=random[i];
b=random[i+{1,0}];
c=random[i+{0,1}];
d=random[i+{1,1}];
tx=u[[1]];
ty=u[[2]];
x=a+(b-a)*tx*(1-ty)+(c-a)*ty*(1-tx)+(d-a)*tx*ty;
yz=({b-a,c-a}+(a-b-c+d)*{ty,tx})*du;
{x,yz[[1]],yz[[2]]}
];
mountainTF=2*{{0.6,-0.8},{0.8,0.6}};
mountain[p:vec2]:=Module[{p2=p,a,b,d,n,nx,nyz},
a=0;
b=1;
d={0,0};
For[i = 0, i < 4, i++,
n=noise[p2*0.5];
nx=n[[1]];
nyz={n[[2]],n[[3]]};
a+=b*nx/(1+nyz.nyz);
d+=nyx;
p2=mountainTF.p2;
b*=0.5;
];
a
];
plotSize=10;
Plot3D[mountain[{x,y}],{x,0,plotSize},{y,0,plotSize},PlotRange -> {0, plotSize},BoxRatios -> {1, 1, 1},PlotPoints -> 30,Mesh -> None]

效果如下:

image-20260428110734479

总结

这一章我们说了wolfram 绘制山体的基本过程,其基本原理是noise 的变换叠加,并使用梯度判断山峰陡峭程度,让陡峭的山峦更加平滑,更加复合自然界中的山峦规律。

后面我会利用此原理,在shader中写一篇在绘制云山云海的文章。

参考链接:www.bilibili.com/video/BV18P…

React 图表库选型指南:Recharts、ECharts、Nivo、Lightweight Charts 深度对比

作者 GeraldChen
2026年4月28日 16:58

选图表库之前,先明确你最需要哪种图表——因为不同库的设计重心差异很大,没有一个"全能冠军"。

这篇文章针对三种最常用的图表类型:K 线图(金融场景核心)、柱状图(通用仪表盘标配)、Treemap(层级数据展示),逐一给出各主流库的真实能力评估,以及具体的选型建议。数据来自 2026 年 4 月的 npm/GitHub 公开数据。

市场格局:七个主流选项

定位 GitHub Stars 周下载量
Recharts React 声明式图表 27k 3.6M
Apache ECharts (echarts-for-react) 功能最全的配置式图表 64k 800k
Nivo 精致动画 + 无障碍 13.6k 450k
TradingView Lightweight Charts 专业金融/K线 15k 560k
Visx (Airbnb) D3 原语封装 20k 300k
Victory React Native 兼容(主要卖点) 11.2k 272k
Chart.js (react-chartjs-2) 通用 Canvas 图表 Chart.js 65k react-chartjs-2 ~2.5M / chart.js ~4.1M

Victory 的核心卖点是 React Native 兼容——同一套组件可以同时用于 Web 和 React Native App。纯 Web 场景下,Recharts 或 Nivo 通常是更好的选择;本文后续章节不展开 Victory,专注于 Web 场景更常用的库。

综合对比表

维度 Recharts ECharts Nivo Lightweight Charts Visx Chart.js
K 线图 变通实现 原生支持 不支持 专为此设计 需组合 需插件
柱状图 完整 最完整 完整 + Canvas 版 仅基础 完全自定义 完整
Treemap 基础支持 支持 + 层级钻取 三渲染模式 不支持 @visx/hierarchy 不支持
渲染方式 SVG Canvas / WebGL SVG + Canvas Canvas SVG + Canvas Canvas
大数据性能 1k+ 有感知卡顿 百万级数据点 Canvas 版较好 金融实时优化 取决于实现 百万级
Bundle(gzip) ~50 KB 按需 ~80-130 KB ~82 KB ~12 KB(gzip) ~30-50 KB ~66 KB
学习曲线 中高 中(金融专用) 高(需 D3 知识)
TypeScript v3 后良好 良好 良好 优秀(TS 编写) 优秀(TS 编写) 良好
SSR 支持 有限制 有限制 原生支持 不支持 需配置 不支持
维护状态 活跃 非常活跃 活跃 非常活跃 更新节奏慢 活跃

K 线图:哪个库真正好用?

TradingView Lightweight Charts — 唯一专业选择

如果 K 线图是项目的核心需求,Lightweight Charts 是唯一不需要妥协的选择。

它是专为金融时序数据设计的,K 线(Candlestick)、折线(Line)、面积(Area)、成交量柱(Histogram)都是第一公民。数据格式极简:

import { createChart } from "lightweight-charts";

const chart = createChart(document.getElementById("chart"), {
  width: 800,
  height: 400,
});

const candleSeries = chart.addCandlestickSeries({
  upColor: "#26a69a",
  downColor: "#ef5350",
  borderVisible: false,
  wickUpColor: "#26a69a",
  wickDownColor: "#ef5350",
});

candleSeries.setData([
  { time: "2024-01-01", open: 100, high: 110, low: 95, close: 105 },
  { time: "2024-01-02", open: 105, high: 115, low: 100, close: 98 },
  { time: "2024-01-03", open: 98, high: 108, low: 92, close: 103 },
]);

在 React 中使用(useRef + useEffect 模式):

import { useEffect, useRef } from "react";
import { createChart, IChartApi } from "lightweight-charts";

interface CandleData {
  time: string;
  open: number;
  high: number;
  low: number;
  close: number;
}

export function CandlestickChart({ data }: { data: CandleData[] }) {
  const containerRef = useRef<HTMLDivElement>(null);
  const chartRef = useRef<IChartApi | null>(null);

  useEffect(() => {
    if (!containerRef.current) return;

    const chart = createChart(containerRef.current, {
      layout: { background: { color: "#1a1a2e" }, textColor: "#d1d4dc" },
      grid: { vertLines: { color: "#2a2a3c" }, horzLines: { color: "#2a2a3c" } },
      width: containerRef.current.clientWidth,
      height: 400,
    });

    const series = chart.addCandlestickSeries();
    series.setData(data);
    chart.timeScale().fitContent();

    chartRef.current = chart;

    const handleResize = () => {
      if (containerRef.current) {
        chart.applyOptions({ width: containerRef.current.clientWidth });
      }
    };
    window.addEventListener("resize", handleResize);

    return () => {
      window.removeEventListener("resize", handleResize);
      chart.remove();
    };
  }, [data]);

  return <div ref={containerRef} />;
}

v5(2026 年 4 月最新)base bundle 35 KB(未压缩),gzip 后约 12 KB,是所有图表库里最小的。Canvas 渲染,实时 tick 级更新(每秒数百次数据刷新)依然流畅。

局限:只做金融图表。如果仪表盘还需要饼图、热力图、散点图,需要引入另一个库。

Apache ECharts — K 线 + 量价混合场景

量价图(K 线 + 柱状成交量)在金融产品里极常见,ECharts 的 candlestick series 和 bar series 可以直接叠加在同一个图表实例里:

import * as echarts from "echarts/core";
import { CandlestickChart, BarChart } from "echarts/charts";
import { GridComponent, TooltipComponent, DataZoomComponent } from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";

echarts.use([CandlestickChart, BarChart, GridComponent, TooltipComponent, DataZoomComponent, CanvasRenderer]);

const option = {
  xAxis: { data: dates },
  yAxis: [
    { scale: true },           // K 线坐标轴
    { scale: true, show: false }, // 成交量坐标轴(隐藏)
  ],
  series: [
    {
      type: "candlestick",
      data: klineData, // [open, close, low, high]
      yAxisIndex: 0,
    },
    {
      type: "bar",
      data: volumeData,
      yAxisIndex: 1,
      itemStyle: {
        color: (params: any) => params.data[1] >= params.data[0] ? "#26a69a" : "#ef5350",
      },
    },
  ],
  dataZoom: [{ type: "inside" }, { type: "slider" }],
};

内置的 dataZoom 组件(鼠标滚轮缩放、滑动条范围选择)在金融场景里非常实用,无需额外开发。

Recharts — K 线图的陷阱

Recharts 官方示例展示了用 <Bar> + <ErrorBar> 模拟 K 线图,但这只是近似实现:

// 这种实现的问题:
// 1. 阳线/阴线颜色需要用 Cell 逐个设置,数据量大时性能差
// 2. 影线(上下 wick)和实体颜色联动逻辑需要手写
// 3. 没有金融图表需要的十字光标、价格标签等组件
// 4. 代码量是 Lightweight Charts 的 3-5 倍

如果项目只是偶尔展示一个 K 线图,可以接受这种实现。但如果 K 线图是核心功能,维护成本会很高。


柱状图:Recharts 为什么是默认选择

Recharts — React 友好的首选

Recharts 是下载量最高的 React 专属图表库(3.6M 周下载),核心原因是它与 React 的组合方式高度一致:

import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, Cell, LabelList } from "recharts";

const data = [
  { month: "Jan", revenue: 4200, cost: 2400 },
  { month: "Feb", revenue: 3800, cost: 1900 },
  { month: "Mar", revenue: 5100, cost: 2800 },
  { month: "Apr", revenue: 4700, cost: 2200 },
];

// 堆叠柱状图
export function StackedBar() {
  return (
    <BarChart width={600} height={300} data={data}>
      <CartesianGrid strokeDasharray="3 3" />
      <XAxis dataKey="month" />
      <YAxis />
      <Tooltip />
      <Legend />
      <Bar dataKey="revenue" stackId="a" fill="#6366f1" />
      <Bar dataKey="cost" stackId="a" fill="#f43f5e" />
    </BarChart>
  );
}

// 单个柱子自定义颜色(Cell)
export function CustomColorBar() {
  const colors = ["#6366f1", "#8b5cf6", "#a78bfa", "#c4b5fd"];
  return (
    <BarChart width={600} height={300} data={data}>
      <Bar dataKey="revenue">
        {data.map((_, index) => (
          <Cell key={index} fill={colors[index % colors.length]} />
        ))}
        <LabelList dataKey="revenue" position="top" />
      </Bar>
    </BarChart>
  );
}

stackId 实现堆叠、Cell 自定义颜色、LabelList 显示数值标签,都是声明式写法,不需要记配置项路径。

限制:SVG 渲染,数据量超过 1000 个数据点时会有感知卡顿。柱状图 bar 数量很多(如时序数据按分钟统计 24 小时 = 1440 个点)时,应考虑切换到 ECharts 或 Chart.js。

Apache ECharts — 大数据 + 高级交互

数据量超 1 万,或者需要 brush 选择、动态排序等高级交互时,ECharts 是更合适的选择:

// 动态排序柱状图(ECharts 内置功能,Recharts 需要自实现)
const option = {
  xAxis: { max: "dataMax" },
  yAxis: { type: "category", data: categories, animationEasing: "linear" },
  series: [{
    type: "bar",
    data: values,
    realtimeSort: true,  // 实时排序动画
    label: { show: true, position: "right" },
  }],
  animationDuration: 0,
  animationDurationUpdate: 2000,
};

ECharts v6(2025 年 7 月发布)新增矩阵坐标系,进一步扩展了柱状图的展示能力。

Nivo — 视觉精致和无障碍

如果产品有无障碍要求(WCAG 2.1 AA),Nivo 是主流 React 图表库中开箱即用无障碍支持最完整的选择之一。动画效果基于 @react-spring,过渡效果最精致:

import { ResponsiveBar } from "@nivo/bar";

<ResponsiveBar
  data={data}
  keys={["revenue", "cost"]}
  indexBy="month"
  margin={{ top: 50, right: 130, bottom: 50, left: 60 }}
  padding={0.3}
  colors={{ scheme: "nivo" }}
  animate={true}          // 基于 @react-spring 的动画
  motionConfig="gentle"   // 动画缓动配置
  role="application"      // 无障碍 ARIA
  ariaLabel="Revenue and cost by month"
/>

数据量大时切换到 <BarCanvas>,用 Canvas 渲染替代 SVG,API 保持一致。


Treemap:Nivo 领先

Nivo @nivo/treemap — 三渲染模式

Nivo 的 Treemap 支持 SVG、Canvas、HTML 三种渲染方式,通过 nodeComponent 可以完全自定义每个节点:

import { ResponsiveTreeMap } from "@nivo/treemap";

const data = {
  name: "root",
  children: [
    {
      name: "Frontend",
      children: [
        { name: "React", size: 85000 },
        { name: "Vue", size: 42000 },
        { name: "Angular", size: 38000 },
      ],
    },
    {
      name: "Backend",
      children: [
        { name: "Node.js", size: 62000 },
        { name: "Python", size: 71000 },
      ],
    },
  ],
};

<ResponsiveTreeMap
  data={data}
  identity="name"
  value="size"
  valueFormat=".02s"
  margin={{ top: 10, right: 10, bottom: 10, left: 10 }}
  labelSkipSize={12}
  labelTextColor={{ from: "color", modifiers: [["darker", 1.2]] }}
  parentLabelPosition="left"
  parentLabelTextColor={{ from: "color", modifiers: [["darker", 2]] }}
  colors={{ scheme: "tableau10" }}
/>

如果需要完全自定义每个格子的渲染,Nivo 提供 nodeComponent prop,可以传入自定义 React 组件来控制节点的外观和交互——比如在格子里加 sparkline、progress bar 或任意 SVG 元素。

Apache ECharts — 层级钻取

如果 Treemap 需要支持点击父节点放大(drill-down)和面包屑导航,ECharts 内置了这个功能:

const option = {
  series: [{
    type: "treemap",
    data: hierarchicalData,
    leafDepth: 1,     // 默认显示到第几层
    drillDownIcon: "▶",
    breadcrumb: { show: true },  // 显示层级导航
    levels: [
      { itemStyle: { borderWidth: 3, gapWidth: 3 } },
      { itemStyle: { borderWidth: 2, gapWidth: 2 } },
      { colorSaturation: [0.35, 0.5] },
    ],
  }],
};

性能对比:何时 SVG 不够用?

渲染方式决定了性能上限:

渲染方式 适用数据量 特点
SVG < 1000 数据点 DOM 节点多,但可 CSS 控制、支持 SSR
Canvas 2D < 100 万数据点 性能强,无法 CSS 控制单个元素
WebGL > 100 万数据点 极限性能,ECharts GL 支持

Recharts 使用 SVG,数据点超过 1000 时页面渲染帧率明显下降。ECharts 默认用 Canvas,即使 10 万个数据点也能流畅渲染。

参考基准(MacBook Pro M3,Chrome 122,柱状图,默认配置):

数据量 Recharts (SVG) ECharts (Canvas) Chart.js (Canvas)
500 点 流畅 流畅 流畅
2000 点 轻微卡顿 流畅 流畅
10000 点 明显卡顿 流畅 流畅
100000 点 无法使用 流畅 基本流畅

以上为参考数据,实际表现与数据结构、动画配置、图表类型相关,建议在目标设备上自行基准测试。


Bundle Size 对比

按需引入是关键。ECharts 全量包 ~340 KB(gzip),按需只引入用到的组件可显著减少体积——以 BarChart + Tooltip + Grid + DataZoom 组合为例,约 80-130 KB(gzip,实际取决于组件组合,建议用 bundlejs.com 实测):

// 按需引入 ECharts(推荐写法)
import * as echarts from "echarts/core";
import { BarChart, CandlestickChart } from "echarts/charts";
import {
  TitleComponent,
  TooltipComponent,
  GridComponent,
  DataZoomComponent,
} from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";

echarts.use([
  BarChart,
  CandlestickChart,
  TitleComponent,
  TooltipComponent,
  GridComponent,
  DataZoomComponent,
  CanvasRenderer,
]);

Recharts v3 已设置 sideEffects: false 并提供 ES Module 输出,Vite/Webpack 5 下可 tree-shake,但其核心依赖(d3、victory-vendor 等)体积较大,实际收益有限,整包 gzip 仍约 50 KB。


选型决策树

需要 K 线图?
  ├── K 线是核心功能 → Lightweight Charts(专业、最小体积)
  └── K 线 + 其他图表混合 → Apache ECharts(一库全搞定)

不需要 K 线图:
  ├── 数据量 > 1 万 → Apache ECharts(Canvas 性能)
  ├── 需要 Treemap + 精致动画 → Nivo
  ├── 视觉高度定制(有 D3 经验)→ Visx
  └── 通用仪表盘,快速开发 → Recharts

场景推荐速查

场景 推荐 理由
纯金融 K 线图 Lightweight Charts 专业设计,gzip ~12 KB,Canvas 性能最优
K 线 + 量价柱状图混合 Apache ECharts 原生支持叠加,内置 dataZoom
通用业务仪表盘 Recharts 学习成本最低,社区最大
数据量 10k+ Apache ECharts Canvas/WebGL 渲染
Treemap + 层级钻取 Apache ECharts 内置 drill-down,面包屑导航
Treemap + 自定义节点 Nivo nodeComponent 完全自定义
无障碍(WCAG 2.1 AA) Nivo 开箱无障碍支持最完整的选择之一
极致定制化 Visx D3 原语,完全控制
快速原型 Chart.js 文档最多,社区问答最丰富

结论

没有一个图表库适合所有场景。实际项目中最常见的两种组合方案:

方案 A(金融/交易类产品):Lightweight Charts(K 线) + Recharts 或 ECharts(其他图表)

方案 B(通用 SaaS 仪表盘):Apache ECharts(主力,性能好功能全) 或 Recharts + Nivo(React 友好 + 精致视觉,适合数据量不大的场景)

如果只能选一个并且对性能有要求,Apache ECharts 是覆盖最广的单一选择:K 线、柱状图、Treemap 全部原生支持,Canvas 渲染不怕大数据,按需引入后体积可控。如果团队 React 经验丰富且数据量有限,Recharts 的开发体验更接近 React 惯例,上手成本最低。


原文链接chenguangliang.com/posts/blog1…

非常简单地学习一下slate.js的原理

作者 村上小树
2026年4月28日 16:50

前言

目前负责编辑器领域的工作,其中接触到slate.js这个库。在学习这个库的过程中顺带写了这篇文章总结了下自己的思路和心德

SlateNode与DOM

Vue、React框架中用VDOM(虚拟DOM)来描述DOM。而在Slate.js中,是用SlateNode来描述DOM。在slate.js中,有两种基本类型的SlateNode:

  1. Element:

    定位:容器节点,负责多类内容的结构布局。如段落、标题、列表、表格、块级引用

    ts类型:{type: string, children: SlateNode[], [key: string]: any]}

  2. Text:

    定位:最小文本格式节点,负责文本样式。如字体加粗、斜体、下划线、颜色、字号

    ts类型:{text: string, [key: string]: any]}

在没有用slate.js提供的renderElementrenderLeaf进行扩展时,Element会默认用{type: "paragraph", children: SlateNode[]}作为兜底格式。而Text会默认用{text: string}作为兜底格式。

例如下👇图中的两行字的富文本内容

两行内容.jpg

在没有扩展的情况下,对应的SlateNode结构如下:

[
  {
    type: 'paragraph',
    children: [
      { text: '123' },
    ],
  },
  {
    type: 'paragraph',
    children: [
      { text: '456' },
    ],
  },
]

而当我们使用renderElementrenderLeaf进行扩展时,我们可以自定义不同内容类型的ElementText对应的DOM。例如:

const App = () => {
  const [editor] = useState(() => withReact(createEditor()))

  // 通过renderElement来定义不同Element对应的HTML表现形式
  const renderElement = useCallback(props => {
    switch (props.element.type) {
      // 例如,我们新增了type为code的Element来表示代码块
      // ,而其对应的HTML表现形式为的pre>code
      case 'code':
        return <pre {...props.attributes}>
            <code>{props.children}</code>
        </pre>
      // 我们也可以更改type为paragraph的Element的HTML表现形式为p。否则默认为div
      default:
        return <p {...props.attributes}>{props.children}</p>
    }
  }, [])

  // 通过renderLeaf来定义不同Text对应的HTML表现形式
  const renderLeaf = useCallback(props => {
    // 例如,我们新增了bold属性来表示是否加粗,当Text的数据为{text: '123', bold: true}时,HTML表现效果等同于<span style={{fontWeight: 'bold'}}>123</span>
    return <span
      {...props.attributes}
      style={{ fontWeight: props.leaf.bold ? 'bold' : 'normal' }}
    >
      {props.children}
    </span>
  }, [])

  return (
    <Slate editor={editor} initialValue={initialValue}>
      <Editable
        // 新增了renderElement和renderLeaf属性
        renderElement={renderElement}
        renderLeaf={renderLeaf}
      />
    </Slate>
  )
}

那接下来的问题就是,当我们在slate.js中的编辑器里编辑内容时,是如何引起SlateNode的改变,进而引起HTML的改变呢?

从用户交互,到SlateNode的改变, 再到HTML的改变

编辑器的交互行为通常有:

  1. 键盘输入
  2. 剪贴/复制/粘贴行为
  3. 撤销/重做
  4. 操作外部按钮去插入Elemeng或更改字体样式

上述交互行为从触发到HTML的变化,都会依次经历以下三个阶段:

  1. Transforms
  2. Normalizing
  3. Rendering

其实,不同交互行为的触发机制和Transforms是不一样的,但NormalizingRendering是相同的。下面我们来说说这三个阶段。

Transforms

注意这里的Transforms是个复数词,即意味着这个阶段是由多个Tranform组合而成的。那么,什么是Transform

Transform是slate.js提供的用于底层原子状态变换的API。在每次用户交互时,slate.js都需要去变更一些变量例如Selection(光标)、SlateNode等等。而这些变量是通过Transform来变更的。

下面我们分场景来对Transforms阶段做的事情进行分析。

字体与目前光标所在文本的字体一致的输入场景

我们以一个字体与目前光标所在文本的字体一致的输入为例,如下👇动图所示:

字体一致输入.gif

在输入4后,触发的整个链路如下👇:

输入文字时经历的transforms.jpg

我们来分析一下Transforms阶段做的最主要的三个操作:

  1. Transforms.delete: 用于删除光标选中范围内的内容。在上图示例中,因为没有选中内容后进行输入,因此不需要删除
  2. Transforms.setSelection: 调整光标位置
  3. Transforms.transform(editor,{offset: 3, path: [0, 0], text: "4", type: "insert_text"}): 插入文字,其中offset表示插入的列坐标,path表示插入的行坐标,text表示插入的内容

值得注意的是,每一个Transform是生成一系列新的SlateNode然后对旧的SlateNode进行替换,而非在旧的SlateNode上直接修改。这也是slate.js自身强调的immutable特性。生成和替换SlateNode执行的代码简化后如下所示:

// 用于生成新的SlateNode的函数
const f = (node)=>{
    // 通过offset获取光标前和光标后的文字
    const before = node.text.slice(0, offset)
    const after = node.text.slice(offset)

    return {
        // 用...的好处是会继承目前光标在文字的字体格式,例如当光标在文字是粗体,那输出的文字也是粗体
        ...node,
        text: before + text + after,
    }
}

// 根据path找到对应的SlateNode
const node = Node.get(editor, path)
const slicedPath = path.slice()
let modifiedNode: Node = f(node)

// 根据path从下到上替换整个SlateNode树中的对应节点
while (slicedPath.length > 1) {
    const index = slicedPath.pop()!
    const ancestorNode = Node.get(editor, slicedPath) as Ancestor

    modifiedNode = {
        ...ancestorNode,
        children: [
            ...ancestorNode.children.slice(0, index), 
            modifiedNode, 
            ...ancestorNode.children.slice(index + 1)
        ]
    }
}

const index = slicedPath.pop()!
// editor.children是整个SlateNode树的根节点
editor.children = [
    ...editor.children.slice(0, index), 
    modifiedNode, 
    ...editor.children.slice(index + 1)
]

相当于把第一个type为"paragraph"的SlateNode给替换了,如下👇图所示,右侧新的SlateNode树中,绿色背景方块代表新生成的SlateNode,而蓝色方块代表复用的SlateNode。

输入文字时SlateNode树的变化.jpg

以上就是这个场景下Transforms阶段所做的事情了。我们下面再以一个粘贴的场景来分析。

粘贴场景

我们以往abef中间粘贴cd为例子,如下👇动图所示:

粘贴.gif

触发的整个链路如下👇:

粘贴文字时经历的transforms.jpg

我们来分析一下粘贴场景下Transforms阶段做的最主要操作:

  1. Transforms.delete: 跟上一个场景一样,用于删除光标选中范围内的内容

  2. Transforms.splitNodes: 调整光标位置,并把光标的文字所处的SlateNode,根据光标的位置分裂成两个,例如:

    // 旧的SlateNode
    [
        {
            type: "paragraph",
            children: [{text: "abef"}]
        },
    ]
    

    光标在abef之间,因此经过分裂会变成以下

    // 新的SlateNode
    [
        {
            type: "paragraph",
            children: [
                {text: "ab"},
                {text: "ef"}
            ]
        },
    ]
    
  3. Transforms.insertNodes: 往光标所在位置插入新SlateNode

    [
        {
            type: "paragraph",
            children: [
                {text: "ab"},
                {text: "cd"}, // 插入的新的SlateNode
                {text: "ef"}
            ]
        },
    ]
    
  4. Transforms.select: 更新光标位置

    Transforms阶段中,整个SlateNode树的变化用如下👇流程图所示:

    粘贴文字时SlateNode树的变化.jpg

    对于上述过程,有两个问题值得我们注意:

    1. 为什么需要分裂SlateNode,然后往中间插入一个新的SlateNode?而不是直接在旧的SlateNode上更改其text属性,如: {text: 'abef'}->{text: 'abcdef'}

      那是因为粘贴的元素多种多样。例如我要往abef中间粘贴的是粗体的cd或者是行内代码块cd(如下代码所示),此时用合并肯定是不行的。因此为了统一应对各种场景,对于粘贴行为都是以分裂SlateNode开始

      {
        type: "paragraph",
        children: [
          {text: 'ab'},
          {text: 'cd', bold: true}, // 插入粗体 or
          {text: 'cd', code: true}, // 插入行内代码块
          {text: 'ef'},
        ]
      }
      
    2. 如果插入的文本和光标所在的文本格式一致,那插入后就有三个Text,如下所示。而这三个Text其实是可以合并成一个的,如果不合并会导致SlateNode树体积增大吧?

      其实后续是会合并的,但不是在Transforms阶段,而是在Normalizing阶段。下面我们就来说一下Normalizing阶段主要的作用是什么

      {
        type: "paragraph",
        children: [
          {text: "ab"},
          {text: "cd"}, // 插入的新的SlateNode
          {text: "ef"}
        ]
      }
      

Normalizing

Normalizing主要对整个SlateNode树做两件事:

  1. 合并: 如果两个相邻的的Text的所有自定义属性完全一致,那这两个Text会被合并

    拿上面粘贴场景的经过Transforms阶段的SlateNode树进行分析,在经过Normalizing阶段后的SlateNode树如下👇所示:

    粘贴normalize合并.jpg

  2. 修复: 对不符合规范的SlateNode进行调整

    Normalizing中针对的规范有多个,想探索的话可看Built-in Constraints

    这里我主要分析一下比较重要的一个规范:

    Inline nodes cannot be the first or last child of a parent block, nor can it be next to another inline node in the children array(在children属性中,作为子元素的布局类型为InlineElement的两侧必须是Text)

    首先要这个规范提及到的“布局类型为InlineElement”作一下说明:

    Element分三种类型:

    1. Block(块级节点): 每一个Block都是占据一行的,就跟HTML中的div元素一样。所有的Element都默认是Block。通常开发者会把代码块、表格设置成Block
    2. Inline(行内节点): 一行里可以有多个Inline,就跟HTML中的span元素一样。开发者可以通过重写editor.isInline函数来定义哪些typeElement属于Inline。通常开发者会把行内代码设置成Inline
    3. Void(空节点): Void不是用布局的类型去理解它,而是用它的子节点是否可编辑去理解。通常开发者会把图片、提及块(即@xx的内容块)设置成Void。同样的,开发者可以通过重写editor.isVoid函数来定义哪些typeElement属于Inline

    而上述的规范,其实是为了保证Selection(选区)逻辑的稳定性。什么是Selection(选区)

    在slate.js中Selection代表你目前的光标所在位置。它的ts类型如下所示:

    interface Editor{
      // ...
      // selection属性代表当前选区,当前编辑器没有聚焦,则为null
      selection: Range | null;
      // ...
    }
    
    // 如果当前光标没有选中内容,anchor和focus一致
    // 如果选中了内容,anchor代表选中内容的前侧,focus代表选中内容的后侧
    interface Range {
      anchor: Point;
      focus: Point;
    }
    
    interface Point {
      path: Path;
      offset: number;
    }
    
    type Path = number[]
    

    我们拿下图👇为例来说明,此时光标在第二行文字的456之间:

    三行文字的光标位置.jpg

    我们用SlateNode树来展示上述案例则是:

    [
      {
          type: "paragraph",
          children: [
              {
                  text: "123"
              }
          ]
      },
      {
          type: "paragraph",
          children: [
              {
                  text: "456"
              }
          ]
      },
      {
          type: "paragraph",
          children: [
              {
                  text: "789"
              }
          ]
      }
    ]
    

    由于上图案例中没有选中内容,因此代表前后位置的属性anchorfocus是一致的。因此我们只需要研究如何用Point类型来代表光标位置即可:

    此时光标在第二个元素的children属性里的第一个元素上,所以用Point["path"]来表示就是[1,0]。且光标是在456之间,用Point["offset"]来表示就是1,即光标在第1个字符后面。因此用Point类型来代表光标位置的位置就是{path: [1,0], offset: 1}。因为目前并没有选中文字,因此用于表示前后选中范围的光标位置都一致,因此editor.selection,即当前选区为:

    {
      anchor: {path: [1,0], offset: 1},
      focus: {path: [1,0], offset: 1},
    }
    

    如果存在选中的内容如下👇:

    三行文字的选中的光标位置.jpg

    editor.selection为:

    {
      anchor: {path: [1,0], offset: 1},
      focus: {path: [1,0], offset: 2},
    }
    

    现在我们回来继续说一下这个规范,以下图👇的例子来说明。图中有一个按钮文字,这个按钮文字是一个Inline

    行内文字的光标位置.jpg

    对应这个例子的SlateNode树如下:

    [
        {
            type: "paragraph",
            children: [
                { "text": "" },
                {
                    type: "button",
                    children: [
                        {
                            "text": "editable button"
                        }
                    ]
                },
                { "text": ""}
            ]
        }
    ]
    

    当把光标放在这个typebuttonInline外部的右侧时(如下👇图所示),选区就会分别定位其右侧的Text,左侧也是同理

    行内文字右侧的光标位置.gif


值得一提的是Normalizing阶段中不会对SlateNode树中的每个Node进行检查。他只会对在交互过程中,被标记的Node进行检测(注意:新生成的SlateNode不一定会被标记)。slate.js内部会有一套巧妙的标记逻辑去过滤出有必要的Node去检查,从而减少Normalizing阶段的耗时。

Rendering

Rendering阶段后,就会触发整个编辑器渲染,编辑器会遍历SlateNode树上每个节点,然后依次调用slatenode与dom章节所提及的renderElementrenderLeaf去生成jsx节点,然后React内部生成成fiber树后再映射到HTMLDOM节点上。从而完成Rendering阶段

后记

本文到此就结束了,如果有问题随时在评论区留言哈

从零搭建 Amiko 受控金库|Solidity 链下签名链上执行实战

作者 木西
2026年4月28日 16:50

前言

本文基于OpenZeppelin V5 + Solidity 0.8.24原版开发调试,打造出签名式受控金库合约系统,实操性拉满、安全合规、适配各类Web3项目落地。

核心核心逻辑超级简单好懂:链下系统做决策签名,链上合约只做验证执行。不用把复杂逻辑写进合约、不高额耗Gas、不泄露私钥资产,完美适配机器人自动化、Web3游戏、DAO财务管理、DeFi量化交易等所有主流场景,新手也能直接部署即用。

一、四大核心落地使用场景

1、DeFi自动化量化交易&策略跟单

量化套利、多因子交易这类复杂策略,不适合写在智能合约里,Gas成本太高还容易触发风控。咱们直接把策略大脑放在链下服务器运行,量化算法触发买卖信号后,自动生成合规签名,金库合约收到有效签名,才会自动完成DEX资产划转交易。

核心安全亮点:所有资产全程锁在金库合约内,链下机器人只有签名权限,没有资产私钥控制权。就算服务器意外被攻击,黑客也只能执行预设合规策略,绝对没法盗走金库资产,安全性拉满。

2、Web3意图交易代付模式

用户不用自己操作复杂交易路径,只需要提交交易结果意愿就行。比如用户签署授权:自愿用1个ETH兑换足额USDT,专业链上求解器自动匹配最优交易路径,带上用户合规签名,调用金库合约完成交易即可。

实用核心:支持第三方中继器代为支付Gas费用,用户零Gas就能完成链上交易,体验拉满,也是当下Web3主流合规交易新模式。

3、Web3游戏&元宇宙经济系统

Web3游戏频繁链上交互,不仅影响玩家体验,还会产生大量Gas消耗。这套金库合约完美解决痛点:玩家通关、打怪完成游戏任务后,游戏后端服务器验证战绩合规后,自动生成专属掉落奖励签名。玩家凭有效签名,就能去合约一键领取代币、道具铸造或转账奖励。

核心作用:严格防作弊,只有官方授权后台认可的有效游戏战绩,合约才会发放对应奖励,杜绝恶意薅空投、刷道具行为。

4、DAO/团队企业级财务分权管理

初创Web3团队、DAO组织财务管理必备,完美实现财务审批和链上执行分离。财务负责人离线审核工资、转账账单后,生成批量合规签名;技术人员或自动化脚本,只负责把签名提交链上即可完成转账发放。

双重安全保障:签名设置有效期防止过期滥用,专属随机数避免重复转账发薪,搭配权限管控,只有指定财务负责人能签署有效指令,资金流转全程可追溯、合规可控。


场景对比总结表

场景名称 核心需求 authorizedSigner 身份
量化交易 极速响应、逻辑解耦 自动化量化脚本 (Bot)
资产领取 防作弊、低 Gas 交互 游戏后台/中心化服务器
代付方案 用户无 Gas 体验 中继器 (Relayer)
智能体金库 硬件隔离、自主权 TEE 隔离区中的私钥

二、智能合约

A. 权限代币合约:AmikoToken.sol

使用 OZ V5 的 AccessControl,明确定义了“铸币者”角色。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";

contract AmikoToken is ERC20, ERC20Permit, AccessControl {
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");

    constructor(address admin) 
        ERC20("Amiko Token", "AKT") 
        ERC20Permit("Amiko Token") 
    {
        // 初始角色分配
        _grantRole(DEFAULT_ADMIN_ROLE, admin);
        _grantRole(MINTER_ROLE, admin);
    }

    function mint(address to, uint256 amount) external {
        require(hasRole(MINTER_ROLE, msg.sender), "Caller is not a minter");
        _mint(to, amount);
    }
}

B. 签名验证金库:AmikoVault.sol

这是核心业务逻辑。金库本身不具备“意识”,它只在验证了来自特定地址(authorizedSigner)的有效 EIP-712 签名后才释放资产。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {SafeERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import {Nonces} from "@openzeppelin/contracts/utils/Nonces.sol";

contract AmikoVault is EIP712, Nonces {
    using SafeERC20 for IERC20;
    using ECDSA for bytes32;

    address public immutable authorizedSigner; // 授权的外部执行者地址

    bytes32 private constant _EXECUTE_TYPEHASH = 
        keccak256("ExecuteAction(address token,address to,uint256 amount,uint256 nonce,uint256 deadline)");

    event ActionExecuted(address indexed token, address indexed to, uint256 amount);

    constructor(address _signer) EIP712("AmikoVault", "1") {
        authorizedSigner = _signer;
    }

    /**
     * @notice 执行由授权签名者批准的转账
     */
    function executeAction(
        address token,
        address to,
        uint256 amount,
        uint256 deadline,
        bytes calldata signature
    ) external {
        require(block.timestamp <= deadline, "Amiko: Action expired");

        // 构建并校验 EIP-712 结构化数据
        bytes32 structHash = keccak256(
            abi.encode(_EXECUTE_TYPEHASH, token, to, amount, _useNonce(authorizedSigner), deadline)
        );
        bytes32 hash = _hashTypedDataV4(structHash);

        address signer = hash.recover(signature);
        require(signer == authorizedSigner, "Amiko: Invalid signature");

        // 执行资产划转
        IERC20(token).safeTransfer(to, amount);
        
        emit ActionExecuted(token, to, amount);
    }

    receive() external payable {}
}

三、测试脚本 (Amiko.test.ts)

该脚本利用 Viem 模拟了外部系统签署指令并提交给合约的全过程。

import assert from "node:assert/strict";
import { describe, it, beforeEach } from "node:test";
import { parseEther } from 'viem';
import { network } from "hardhat";

describe("Amiko Smart Contract Suite", async function () {
    let publicClient: any;
    let admin: any, signer: any, user: any;
    let token: any, vault: any;

    beforeEach(async function () {
        const { viem } = await network.connect();
        publicClient = await viem.getPublicClient();
        [admin, signer, user] = await viem.getWalletClients();

        // 部署代币
        token = await viem.deployContract("AmikoToken", [admin.account.address]);
        
        // 部署金库,指定授权签名地址
        vault = await viem.deployContract("AmikoVault", [signer.account.address]);

        // 给金库注资
        const hash = await admin.writeContract({
            address: token.address,
            abi: token.abi,
            functionName: "mint",
            args: [vault.address, parseEther("1000")],
        });
        await publicClient.waitForTransactionReceipt({ hash });
    });

    it("应该能够通过有效的外部签名执行转账", async function () {
        const amount = parseEther("50");
        const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600);
        const nonce = await publicClient.readContract({
            address: vault.address,
            abi: vault.abi,
            functionName: "nonces",
            args: [signer.account.address],
        });

        // 准备签名数据
        const domain = {
            name: 'AmikoVault',
            version: '1',
            chainId: await publicClient.getChainId(),
            verifyingContract: vault.address,
        };

        const types = {
            ExecuteAction: [
                { name: 'token', type: 'address' },
                { name: 'to', type: 'address' },
                { name: 'amount', type: 'uint256' },
                { name: 'nonce', type: 'uint256' },
                { name: 'deadline', type: 'uint256' },
            ],
        };

        const signature = await signer.signTypedData({
            domain,
            types,
            primaryType: 'ExecuteAction',
            message: { token: token.address, to: user.account.address, amount, nonce, deadline },
        });

        // 链上执行
        await admin.writeContract({
            address: vault.address,
            abi: vault.abi,
            functionName: "executeAction",
            args: [token.address, user.account.address, amount, deadline, signature],
        });

        const finalBalance = await publicClient.readContract({
            address: token.address,
            abi: token.abi,
            functionName: "balanceOf",
            args: [user.account.address],
        });

        assert.equal(finalBalance, amount);
    });
});

四、部署脚本

// scripts/deploy.js
import { network, artifacts } from "hardhat";
async function main() {
  // 连接网络
  const { viem } = await network.connect({ network: network.name });//指定网络进行链接
  
  // 获取客户端
  const [deployer,agent] = await viem.getWalletClients();
  const publicClient = await viem.getPublicClient();
 
  const deployerAddress = deployer.account.address;
   console.log("部署者的地址:", deployerAddress);
  // 加载合约
  const AmikoTokenArtifact = await artifacts.readArtifact("AmikoToken");

 
  // 部署
  const AmikoTokenHash = await deployer.deployContract({
    abi: AmikoTokenArtifact.abi,//获取abi
    bytecode: AmikoTokenArtifact.bytecode,//硬编码
    args: [deployerAddress],
  });
   const AmikoTokenReceipt = await publicClient.waitForTransactionReceipt({ hash: AmikoTokenHash });
   console.log("AmikoToken合约地址:", AmikoTokenReceipt.contractAddress);
  const AmikoVaultArtifact = await artifacts.readArtifact("AmikoVault");

 
  // 部署
  const AmikoVaultHash = await deployer.deployContract({
    abi: AmikoVaultArtifact.abi,//获取abi
    bytecode: AmikoVaultArtifact.bytecode,//硬编码
    args: [agent.account.address],
  });
   const AmikoVaultReceipt = await publicClient.waitForTransactionReceipt({ hash: AmikoVaultHash });
   console.log("AmikoVault合约地址:", AmikoVaultReceipt.contractAddress);
}

main().catch(console.error);

总结

1、安全核心:资产链上合约托管,签名链下生成,私钥和资产控制权分离,从根源规避盗币、黑客攻击风险;

2、适配性强:DeFi量化、Web3游戏、DAO财务、意图交易全场景通用,OpenZeppelin V5原版库开发,无安全漏洞、合规稳定;

3、实操性高:全套合约、测试脚本、部署脚本配齐,复制即可编译测试、一键上线部署,无需复杂二次开发。

node 包管理工具 : nvm vs fvm

作者 lemon_yyds
2026年4月28日 15:07

在 macOS 上管理 Node.js 版本,选择合适的工具至关重要。这不仅能让你在不同的项目中使用不同版本的 Node.js,还能避免权限问题,提升开发效率。

目前主流的工具有 nvmfnm 和 Volta,它们各有侧重,你可以根据自己的需求来选择。

🧰 主流 Node 版本管理工具对比

表格

工具 核心特点 适用场景
nvm 社区最流行,资源最丰富,命令直观。 需要广泛社区支持和丰富教程的开发者。
fnm 基于 Rust 开发,速度极快,支持目录切换时自动切换版本。 追求极致性能和自动化体验的开发者。
Volta 将 Node 和包管理器版本写入 package.json,实现项目级环境自动切换。 团队协作,希望统一项目环境,避免“在我机器上是好的”问题。

下面为你详细介绍这三种工具的安装和使用方法。


1. NVM (Node Version Manager)

NVM 是目前最流行、最成熟的版本管理工具,拥有庞大的用户基础和丰富的文档。

安装

在终端执行以下命令进行安装:

bash

编辑

1curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash

安装完成后,需要重启终端或执行 source ~/.zshrc 让配置生效。

常用命令

  • 安装指定版本nvm install 18.16.0
  • 切换版本nvm use 18.16.0
  • 查看已安装版本nvm list
  • 设置默认版本nvm alias default 18.16.0
  • 查看远程所有可用版本nvm list-remote

2. FNM (Fast Node Manager)

正如其名,fnm 的最大亮点就是。它由 Rust 编写,性能远超基于 Shell 脚本的 nvm。它还支持在进入项目目录时自动切换 Node 版本。

安装

bash

编辑

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

安装后,请根据终端提示配置环境变量。为了实现自动切换,需要在 ~/.zshrc 中添加 eval "$(fnm env --use-on-cd)"

常用命令

  • 安装指定版本fnm install v20.10.0
  • 切换版本fnm use v20.10.0
  • 查看已安装版本fnm list
  • 设置默认版本fnm default v20.10.0

3. Volta

Volta 的理念非常独特,它将 Node.js 和包管理器(如 pnpmyarn)的版本信息直接写入项目的 package.json 文件中。这意味着,任何安装了 Volta 的开发者,在进入项目目录时,都会自动切换到项目指定的环境,非常适合团队协作。

安装

bash

编辑

1curl https://get.volta.sh | bash

常用命令

  • 安装 Node.jsvolta install node@18

  • 为当前项目锁定版本:

    1. 进入你的项目目录。
    2. 执行 volta pin node@18
    3. 你会发现 package.json 中多了一个 volta 字段,里面记录了锁定的版本。

💡 国内加速小贴士

由于网络原因,直接安装 Node.js 版本可能会很慢或失败。你可以配置镜像源来加速下载。

  • 对于 NVM:

    bash

    编辑

    1export NVM_NODEJS_ORG_MIRROR=https://npmmirror.com/mirrors/node/
    

    建议将此行添加到 ~/.zshrc 文件中,使其永久生效。

  • 对于 FNM:

    bash

    编辑

    1export FNM_NODE_DIST_MIRROR=https://npmmirror.com/mirrors/node/
    

    同样,建议添加到 ~/.zshrc 文件中。

总结与建议

  • 追求稳定和丰富资源:选择 NVM
  • 追求极致速度和自动化:选择 FNM
  • 团队协作,统一环境:选择 Volta

重要提示:请避免同时使用多个版本管理工具,这可能会导致环境变量冲突,引发意想不到的问题。选择最适合你的一个即可。

uni-app 小程序主包瘦身指南 - 分包 node_modules

2026年4月27日 15:31

前言

大家好,这里是《前端毕业班》,前端开发者的自救互助小组。在 AI 与不确定性并存的时代,我们一起看清焦虑,聊技术、聊趋势,也聊前端还能走多远,走去哪。

郑重承诺以下内容不由 AI 生成

问题背景

5.04 版本之前的 uniapp 和 uniappx,小程序端不支持分包引用的 node_modules 依赖打包到分包中,这对于很多备受小程序主包体积超出困扰的开发者来说,显然不是一个好消息。为了解决这一问题,5.04 版本开始,hx项目或者 cli 项目支持分包引用的 node_modules 依赖打包到分包中。下面介绍下具体的操作步骤,附件是示例项目。

分包优化

首先,需要在 mainfest.json 指定小程序节点下添加如下配置,例如:

{
  "mp-weixin": {
    "optimization": {
       "subPackages": true
    }
   }
}

筛选分包用的依赖

这一步尤为重要,要先梳理出哪些依赖是分包用到的,哪些是主包用到的,以及你期望的主包分包产物引用关系。

我们举一个简单的例子,主包用到了 lodash-esaddsubtract 函数,分包 sub 用到了 lodash-esmultiply 函数,这种分包用到的内容主包没用,就可以考虑使用这种策略,把 分包 sub 用到的 lodash-esmultiply 函数打包到 分包 sub 下,我们来看下 5.04 版本之前的效果

首先是项目结构

image.png

打包的产物体积

image.png

可以看到,用到的 lodash-es 的三个函数都被打包到了主包的 vendor.js 文件中。下面我们看下 5.04 如何解决这种问题

首先进入到分包的根目录,创建一个 package.json 文件,这里写分包需要用到的依赖,然后安装依赖

image.png

然后重新打包即可。

可以看到 分包 sub 根目录下面多了 vendor.js 文件,里面就是 lodash-esmultiply 函数

image.png

image.png

测试项目

获取测试项目可以点击 ask.dcloud.net.cn/article/424…

注意事项

  • 该优化只对 vue3 项目生效
  • 支持 uniapp 和 uniappx 的小程序项目
  • 支持 hx 项目和 cli 项目,测试项目是 hx 项目,cli 项目同理
  • 仅支持 node_modules 中的 js 相关文件,不支持其他文件
  • 5.04 是指 hx 的版本号,uniapp 对应的依赖版本为 3.0.0-5000420260318001

写在最后

fetch和axios区别

2026年4月27日 13:18

一、它们是什么

Fetch Axios
类型 浏览器原生 API(Node 18+ 也内置) 第三方库
底层 基于 Promise,原生实现 浏览器基于 XMLHttpRequest,Node 端基于 http 模块
体积 0(原生) 约 15KB(gzip 后约 5KB)
安装 无需安装 npm i axios

二、核心差异速查

特性 Fetch Axios
HTTP 错误(4xx/5xx)是否进 catch ❌ 不进,需手动判断 res.ok ✅ 自动 reject
自动 JSON 解析 ❌ 需 res.json() ✅ 自动解析 response.data
请求/响应拦截器 ❌ 无(需自己封装) ✅ 内置
请求超时 ❌ 需配合 AbortController 手动实现 timeout 配置项
请求取消 AbortController AbortController / CancelToken(旧)
上传/下载进度 ⚠️ 仅支持下载(ReadableStream);上传需自己实现 onUploadProgress / onDownloadProgress
CSRF/XSRF 防护 ❌ 无 ✅ 内置 xsrfCookieName
默认携带 cookie ❌ 默认不带(需 credentials: 'include' ✅ 同源默认带,跨域需 withCredentials: true
自动转换请求体 ❌ 需手动 JSON.stringify ✅ 自动序列化
浏览器兼容 现代浏览器,IE 不支持 通过 XHR 兼容到 IE11
Node.js 支持 Node 18+ 内置 全版本支持

三、详细对比

1. 错误处理

Fetch:HTTP 错误状态不会让 Promise reject。

fetch('/api/user/999')
  .then(res => {
    // 即使是 404、500,这里依然会进入 then
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  })
  .catch(err => console.error(err));
// 只有网络断开、CORS 失败、请求被中止才会进 catch

Axios:HTTP 错误自动进 catch。

axios.get('/api/user/999')
  .then(res => console.log(res.data))
  .catch(err => {
    if (err.response) {
      // 服务器返回了非 2xx
      console.log(err.response.status, err.response.data);
    } else if (err.request) {
      // 请求发出但无响应
    } else {
      // 配置错误
    }
  });

2. 请求体与响应体

Fetch:手动序列化 + 手动解析。

fetch('/api/user', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Tom' }),
})
  .then(res => res.json())
  .then(data => console.log(data));

Axios:自动处理。

axios.post('/api/user', { name: 'Tom' })
  .then(res => console.log(res.data));
// Content-Type 自动设为 application/json,body 自动序列化,data 自动解析

3. 拦截器(Axios 杀手锏)

Axios:内置请求/响应拦截器,可统一加 token、统一处理错误。

// 请求拦截器:每次请求自动加 token
axios.interceptors.request.use(config => {
  config.headers.Authorization = `Bearer ${getToken()}`;
  return config;
});

// 响应拦截器:401 自动跳登录页
axios.interceptors.response.use(
  res => res.data,
  err => {
    if (err.response?.status === 401) location.href = '/login';
    return Promise.reject(err);
  }
);

Fetch:没有拦截器,要么自己封装一层,要么用 monkey-patch

const originalFetch = window.fetch;
window.fetch = async (url, options = {}) => {
  options.headers = { ...options.headers, Authorization: `Bearer ${getToken()}` };
  const res = await originalFetch(url, options);
  if (res.status === 401) location.href = '/login';
  return res;
};

4. 超时控制

Fetch:原生不支持,需 AbortController

const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);

fetch('/api/data', { signal: controller.signal })
  .finally(() => clearTimeout(timer));

Axios:一行配置。

axios.get('/api/data', { timeout: 5000 });

5. 请求取消

Fetch:AbortController

const ctrl = new AbortController();
fetch('/api/data', { signal: ctrl.signal });
ctrl.abort(); // 取消

Axios:新版本同样使用 AbortController(旧版本是 CancelToken,已废弃)。

const ctrl = new AbortController();
axios.get('/api/data', { signal: ctrl.signal });
ctrl.abort();

6. 上传进度

Fetch:原生不支持(只能监控下载,通过 ReadableStream)。

Axios:直接配置。

axios.post('/upload', formData, {
  onUploadProgress: (e) => {
    const percent = Math.round((e.loaded / e.total) * 100);
    console.log(`已上传 ${percent}%`);
  },
});

7. 并发请求

Fetch:用 Promise.all

const [user, posts] = await Promise.all([
  fetch('/api/user').then(r => r.json()),
  fetch('/api/posts').then(r => r.json()),
]);

Axios:同样支持 Promise.all,旧版本另有 axios.all / axios.spread

const [user, posts] = await Promise.all([
  axios.get('/api/user'),
  axios.get('/api/posts'),
]);

8. 跨域与 Cookie

Fetch:默认不发送 Cookie。

fetch('/api/data', { credentials: 'include' }); // 跨域携带
fetch('/api/data', { credentials: 'same-origin' }); // 同源携带(默认在某些场景)

Axios:默认同源带 Cookie,跨域需配置。

axios.get('/api/data', { withCredentials: true });

9. 创建实例

Fetch:没有实例概念,通常自己写一个工厂函数。

Axios:内置实例机制,方便配置不同 baseURL。

const api = axios.create({
  baseURL: 'https://api.example.com',
  timeout: 5000,
  headers: { 'X-Custom': 'foo' },
});

api.get('/users');

四、Fetch 简易封装(接近 Axios 体验)

async function request(url, options = {}) {
  const { timeout = 10000, baseURL = '', ...rest } = options;

  // 超时控制
  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(), timeout);

  // 自动 JSON
  if (rest.body && typeof rest.body === 'object') {
    rest.body = JSON.stringify(rest.body);
    rest.headers = { 'Content-Type': 'application/json', ...rest.headers };
  }

  try {
    const res = await fetch(baseURL + url, { ...rest, signal: ctrl.signal });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return await res.json();
  } finally {
    clearTimeout(timer);
  }
}

// 使用
request('/api/user', { method: 'POST', body: { name: 'Tom' } });

五、如何选择

场景 推荐
简单脚本、小项目、Demo Fetch(零依赖)
中后台、企业项目 Axios(拦截器、错误处理省心)
体积敏感(H5、小程序、SDK) Fetch + 简单封装
需要兼容 IE Axios
Node.js 服务端 Axios(生态成熟),或 Node 18+ 原生 fetch
React Server Components / Next.js Fetch(与缓存机制集成更好)

六、总结一句话

Fetch 是底层标准,Axios 是上层封装。 一个项目要不要 Axios,本质是问:"拦截器、自动 JSON、超时、错误统一处理 —— 这些功能你愿不愿意自己写?"

接入 MCP 之后,我如何让 Skill 稳定消费 Tool / Resource / Prompt

作者 倾颜
2026年4月27日 12:01

本文对应项目版本:v0.0.11

接入 MCP 之后,下一步是不是应该马上做 Agent?

这是我在 AI Mind 本版本里反复提醒自己的问题。

先简单介绍一下项目背景。AI Mind 是一个按版本持续演进的 AI Native Runtime Skeleton,不是一次性做完的 AI 产品。它从本地聊天闭环开始,逐步长出结构化流式协议、Tool Calling、Multi-Tool Runtime、Skill Runtime、MCP 接入,以及后续会继续推进的 Agent / 数据层能力。

到前一版本为止,项目里已经有了一条比较稳定的聊天主链:请求从 /api/chat 进入,经过 chat-service(聊天服务 facade,负责对外暴露稳定入口)和 runtime(聊天主链编排层,负责规划、工具执行和最终回答生成),再通过 @ai-mind/stream-core(流式协议、生命周期和 writer 内核)返回前端可消费的流式 chunk。

前面版本已经完成基础 MCP 接入,天气走 MCP Tool,文件读取升级成 MCP Resource。照这个节奏继续往下,很容易产生一个冲动:既然外部能力已经接进来了,那是不是该让模型自己决定下一步调用什么?

我最后把重心放在了更靠前的一层:先让能力面变得清楚、稳定、可消费。

本版本的主题不是 Agent,也不是 Remote MCP 教程,而是补了一层更基础的东西:Capability Surface(能力表面,用来描述能力、约束 Skill 承接范围,并把能力消费事实带进 Runtime)。

为了让第一次看到这个项目的读者也能顺着看下去,这里先把几个词放在当前项目语境里:MCP 负责把外部能力接进来,Tool 是可执行动作,Resource 是可读取上下文,Prompt 是可注入模型的任务消息,Skill 则负责承接某类稳定任务模式。

Capability Surface 可以先理解成一层“能力名片”:它先说清楚一个能力是谁、来自哪里、在本地还是远端、当前能不能用、哪个 Skill 可以承接。至于这个能力最终怎么执行,仍然交给 Tool / Resource / Prompt 各自的运行链路。

我更想复盘的是:在一个真实 AI Runtime 项目里,接入 MCP 之后、走向更完整的计划与执行之前,为什么需要先把能力表面收清楚。

它解决的问题是:当系统里同时存在 Tool、Resource、Prompt、本地 MCP、远端 MCP、Skill 路由和前端执行事实时,Skill 到底应该用什么方式稳定理解这些能力,并让 Runtime 安全地消费它们?

先看结论

MCP 接入解决的是“能力怎么接进来”,Capability Surface 解决的是“这些能力如何被描述、被 Skill 承接、被 Runtime 消费、被前端承接”。

本版本我先把 Tool / Resource / Prompt 收成统一的能力描述层,再让 reader-skill(文档读取、项目上下文和外部信息查询类 Skill)消费本版本固定的 local / remote MCP capability。

如果把这件事压成一条链路,大概是这样:

用户问题
  -> Skill(reader-skill)
    -> capabilitySelectors(能力选择范围)
      -> Tool / Resource / Prompt(三类能力)
        -> Runtime 消费
          -> 前端消息 part(执行事实卡片)

ai-1.gif


1. 为什么接入 MCP 后,我先补能力表面

接入 MCP 之后继续往上做调度,看起来很自然。

因为这时候系统已经有了外部能力来源:MCP server 可以暴露 Tool,可以暴露 Resource,也可以暴露 Prompt。再往上一层,好像就该让运行时自己规划:先读哪个资源,再拿哪个 Prompt,再调用哪个 Tool。

但在我这个阶段,这一步太早了。

问题不在于更高层调度不重要,而是它之前还有一个更基础的问题没有解决:

  • 系统里的能力是否能被统一描述?
  • Skill 是否知道自己可以承接哪些能力?
  • Prompt 是否已经被当成一等能力,而不是塞进工具链里的附属品?
  • 前端消息模型是否能承接能力执行事实,而不只是最终答案?
  • Runtime 是否真的消费了 capability metadata,而不是只把它当展示文案?

如果这些问题没有先收住,上层调度很容易变成一个过早的总入口:它看起来什么都能管,但底下的能力边界、错误语义、前端表达都还没清楚。

所以本版本我选择先补 Capability Surface。

这个名字听起来有点抽象,但在项目里它很具体:它是一层让 Skill 和 Runtime 能稳定理解能力对象的表面。它不替代 Tool、不替代 Resource、不替代 Prompt,也不替代 MCP 接入层。它只是先把“系统里有什么能力、来自哪里、在本地还是远端、属于哪类能力、当前是否可用”这些信息讲清楚。

对我来说,这比马上做更高层调度更值得先做。

因为后续计划与执行要做得稳,前提是能力面已经稳。


2. Capability Model 统一的是描述层,不是执行链

本版本最重要的一个判断是:Capability Model 只统一能力描述,不统一执行链。

这句话如果没有提前说清楚,很容易把事情做重。

Tool、Resource、Prompt 虽然都可以叫 capability,但它们的运行时语义完全不同:

  • Tool 通常是一次可执行动作,可能由模型 tool call 触发,也可能被 runtime 主动调用。
  • Resource 更像外部上下文读取,重点是把内容拿回来进入后续回答。
  • Prompt 是模板或消息注入,重点是生成一组可进入模型上下文的消息。

所以我没有把它们硬抽成一个统一的 executeCapability() 大协议。

统一的是这层描述对象:

export const capabilityTypes = ['prompt', 'resource', 'tool'] as const
export type CapabilityType = (typeof capabilityTypes)[number]

export const capabilityProviderKinds = ['internal', 'mcp'] as const
export type CapabilityProviderKind = (typeof capabilityProviderKinds)[number]

export const capabilityLocations = ['local', 'remote'] as const
export type CapabilityLocation = (typeof capabilityLocations)[number]

export interface CapabilityIdentity {
    name: string
    capabilityType: CapabilityType
    providerKind: CapabilityProviderKind
    location: CapabilityLocation
    serverId?: string
}

export interface CapabilityDefinition extends CapabilityIdentity {
    capabilityId: string
    title: string
    description: string
    availability: CapabilityAvailability
}

这段代码解决的是“能力身份如何被稳定描述”的问题。

这里有几个字段很关键:

  • capabilityType:能力是 toolresource 还是 prompt
  • providerKind:能力来自 internal 还是 mcp
  • location:能力在 local 还是 remote
  • serverId:如果来自 MCP,它属于哪个 server
  • availability:能力当前是否可用,不只是一句 true / false

capabilityId 也不是直接用 name,而是按 providerKind:location:capabilityType:serverId?:name 组合出来。这样做是为了避免重名。

一个本地 MCP server 里可以有 summary,一个远端 MCP server 里也可以有 summary。如果只靠 name,后面 Skill、Runtime、前端都会开始猜。capabilityId 把能力身份收成稳定规则,后面再扩 remote server 或 discovery,才不会一开始就欠债。

这一层的克制点也很重要:它只描述能力,不接管能力执行。

Tool / Resource / Prompt 仍然保持各自的执行语义。Capability Model 只是让它们能被同一套语言描述出来。


3. Skill Metadata 是 Skill 的表面,不是 Workflow

在之前的版本里,Skill 已经存在,但它更多像一组运行时规则:命中哪个 Skill、允许用哪些 Tool、拼什么 system prompt。

到了本版本,我想把 Skill 的“表面”讲清楚。

这里的表面不是 UI,而是 Skill 对外声明自己的方式:

  • 我是谁?
  • 我主要处理什么任务?
  • 我可以承接哪些能力来源?
  • 我允许消费哪些 capability?
  • 如果能力不可用,我怎么回退?

对应到类型上,就是 SkillDefinition(Skill 的统一定义对象,承载系统提示词、工具范围和 capability 选择范围)。这里最关键的新增字段是 capabilitySelectors,它用结构化条件描述 Skill 可承接的能力范围。

我刻意没有给 Skill 再包一层复杂的 metadata 对象,也没有把它扩成 workflow 定义。因为本版本的 Skill Metadata 只承担几件事:

  • 自描述
  • routing 辅助
  • 前端轻展示
  • capability 承接范围声明
  • fallback 策略声明

它不承担:

  • 多步 workflow
  • 通用 capability 调度
  • 通用 planner
  • 模型自主继续决策

reader-skill(阅读类 Skill,负责文件读取、文档总结、项目上下文和 MCP 文档能力)就是本版本最关键的例子:

export const readerSkillDefinition: SkillDefinition = {
    skillId: 'reader-skill',
    name: '阅读技能',
    allowedTools: ['city-weather', 'local-text-read'],
    sourceKinds: ['mcp'],
    capabilitySelectors: [
        { providerKind: 'mcp', location: 'local', capabilityType: 'tool', names: ['city-weather'] },
        { providerKind: 'mcp', location: 'local', capabilityType: 'resource', names: ['local-text-read'] },
        { providerKind: 'mcp', location: 'local', capabilityType: 'prompt', names: ['local-file-summary'] },
        { providerKind: 'mcp', location: 'remote', serverId: 'project-assistant-service', capabilityType: 'resource' },
        { providerKind: 'mcp', location: 'remote', serverId: 'project-assistant-service', capabilityType: 'prompt' },
        { providerKind: 'mcp', location: 'remote', serverId: 'project-assistant-service', capabilityType: 'tool' },
    ],
    fallbackPolicy: 'direct-answer',
}

这段配置的意义不在于“列了一堆能力”,而在于它把 Skill 的边界声明出来了。

reader-skill 可以承接本地 MCP Tool、Resource、Prompt,也可以承接来自 project-assistant-service(本版本新增的远端 MCP 服务)的三类 remote capability。但这不代表它拥有通用调度权。它只是声明:这些能力属于我的可承接范围。

真正是否执行,仍然由 Runtime 在具体上下文里决定。

这样 Skill 就没有偷偷长成上层调度器。


4. Prompt 为什么要成为一等 Capability

我在本版本很想强调 Prompt。

在很多 AI 应用里,Prompt 容易被当成“内部模板文件”,或者被塞进 Tool 调用前后的某段字符串里。短期看没问题,长期看会让能力面变得不完整。

如果 Tool 是“做一个动作”,Resource 是“取一段上下文”,那 Prompt 就应该是“生成一组可注入模型上下文的任务消息”。

它不是 Tool。

因为 Prompt 本身不执行外部动作,也不应该伪装成一次 tool call。

它也不是 Resource。

因为模板文件只是 Prompt 的存储介质,不等于 Prompt capability 本身。真正被消费的是“带参数注入后的 Prompt 消息”。

所以本版本补了两个 Prompt:

  • local-file-summary(本地文件总结 Prompt,通过 project-files-server 暴露)
  • tasklist-draft(远端 tasklist 草稿 Prompt,通过 project-assistant-service 暴露)

本地 Prompt 的运行时消费落在 prompt-context.ts(本地 Prompt 上下文注入模块,负责判断是否需要读取 prompt、注入参数并转换成模型消息):

export function resolvePromptContextInvocation(
    request: ChatRequest,
    executedToolResults: ExecutedToolResult[]
): PromptContextInvocation | null {
    const userGoal = getLastUserMessageText(request)

    if (!shouldUseLocalFileSummaryPrompt(userGoal)) {
        return null
    }

    const localTextReadResult = getLatestSuccessfulLocalTextReadResult(executedToolResults)

    if (!localTextReadResult) {
        return null
    }

    const filename = getLocalTextReadFilename(localTextReadResult.toolCall)

    if (!filename) {
        return null
    }

    return {
        promptName: LOCAL_FILE_SUMMARY_PROMPT_NAME,
        source: 'mcp',
        location: 'local',
        serverId: LOCAL_FILE_SUMMARY_SERVER_ID,
        input: formatPromptInvocationInput(filename, userGoal),
        execute: () => buildLocalSummaryPromptContextMessages(filename, userGoal),
    }
}

这段代码解决的是“Prompt 如何进入最终回答上下文”的问题。

它的执行链是:

  1. 用户先触发文件读取,例如读取 README.md
  2. Runtime 拿到最近一次成功的 local-text-read 结果
  3. 判断当前用户目标是否需要总结、摘要、提炼
  4. 获取 local-file-summary Prompt
  5. 注入 filename / content / userGoal
  6. 把 Prompt 消息转成模型上下文

这里我只把 Prompt 当成一类执行事实展示出来。前端需要稳定知道的是:

  • 哪个 Prompt 被使用了
  • 来自哪里
  • 属于 local 还是 remote
  • 注入了几条上下文消息
  • 是否失败

至于内部 Prompt 模板正文,则继续留在 Runtime 和模型上下文里,不作为前端展示重点。

ai-2.png


5. Remote MCP 只验证最小闭环,不做远程业务平台

本版本确实新增了一个 remote MCP server。

这里的 remote 很朴素:它不在 apps/webapp 进程内,而是一个独立服务,通过 Streamable HTTP 被 Webapp 消费。

但它的定位非常克制:只验证 remote capability 最小闭环。

新增服务是 apps/project-assistant-service(独立 NestJS 服务,当前只承载 remote MCP mock capability)。它通过官方 MCP SDK 暴露三类能力:

  • Resource:project://latest-context
  • Prompt:tasklist-draft
  • Tool:check_doc_consistency

服务侧注册能力的代码在 mcp-capability.service.ts(远端 MCP 能力注册服务,负责创建 MCP server 并注册 Resource / Prompt / Tool)。这里我只保留了“每类 capability 一个最小 mock”的形态,用来证明远端能力面可以成立。

我没有在这里接数据库,没有接第三方 API,也没有做远程文件系统。三个 capability 都是 mock 数据。这样做不是因为远端能力不重要,而是因为本版本要验证的是另一件事:

Webapp 作为 MCP 消费端,能不能通过 remote Streamable HTTP 稳定消费 Resource / Prompt / Tool 三类能力,并把执行事实并入当前聊天主链?

Webapp 侧的 server definition 也保持很明确:transport=streamable-httplocation=remoteserverId=project-assistant-service,并声明它同时具备 prompts / resources / tools 三类 capability。

这层配置解决的是“远端 MCP server 如何进入 Webapp 能力注册表”的问题。

这里我只做了 mock Bearer Token:

  • 无 token:unauthorized
  • 错 token:forbidden
  • 正确 token:正常连接

没有做用户态登录透传,也没有做 OAuth。

这也是本版本的边界:remote MCP 是为了验证 remote capability surface 和 runtime 消费链路,不是为了提前做一个生产级远程业务平台。


6. Capability Metadata 不能只用于展示,必须进入 Runtime

如果 capability 只停留在 catalog 和前端展示,本版本其实还不完整。

真正让我觉得本版本站住了的,是 Capability Metadata 进入了 Runtime 消费闭环

先用一个真实请求把链路放具体一点。

当我在前端输入:

帮我检查一下当前文档之间有没有明显不一致的地方

我希望它不是直接让模型凭空回答,而是先命中 reader-skill,再确认这个 Skill 是否声明过可承接 check_doc_consistency 这类 remote tool capability。确认通过后,Runtime 才去调用 project-assistant-service 暴露的 remote Tool,并把结果注入最终回答。

也就是说,reader-skill 已经声明的 capabilitySelectors,不能只是文档信息。Runtime 必须真正基于这层声明决定本轮能不能消费某个 capability。

对应模块是 capability-context.ts(最小 remote capability 消费层,只处理 reader-skill 下本版本固定远端能力,不做通用 planner)。

先看入口:

export function resolveCapabilityContextInvocations(request: ChatRequest, skillDefinition?: SkillDefinition): RemoteCapabilityInvocation[] {
    if (skillDefinition?.skillId !== 'reader-skill') {
        return []
    }

    const userGoal = getLastUserMessageText(request)
    const invocations: RemoteCapabilityInvocation[] = []
    const candidates: Array<[RemoteCapabilityName, CapabilityType, boolean]> = [
        [LATEST_CONTEXT_RESOURCE_NAME, 'resource', matchesAny(userGoal, PROJECT_CONTEXT_PATTERNS)],
        [TASKLIST_DRAFT_PROMPT_NAME, 'prompt', matchesAny(userGoal, TASKLIST_DRAFT_PATTERNS)],
        [DOC_CONSISTENCY_TOOL_NAME, 'tool', matchesAny(userGoal, DOC_CONSISTENCY_PATTERNS)],
    ]

    for (const [name, capabilityType, matched] of candidates) {
        const identity = createRemoteCapabilityIdentity(name, capabilityType)

        if (matched && isRemoteCapabilityAllowed(skillDefinition, identity)) {
            invocations.push(createRemoteCapabilityInvocation(name, capabilityType, userGoal))
        }
    }

    return invocations
}

这段代码解决的是“Skill metadata 如何进入 runtime 判断”的问题。

这里有两个约束很重要:

  1. 只有命中 reader-skill 才会进入这层 remote capability 消费。
  2. 即使用户输入命中了高置信规则,也必须通过 isRemoteCapabilityAllowed() 检查 capabilitySelectors

也就是说,Runtime 不会绕过 Skill 声明去随便调远端能力。

执行阶段也没有把三类 capability 强行揉成一种协议,而是保留各自语义:

function createRemoteCapabilityInvocation(
    name: RemoteCapabilityName,
    capabilityType: CapabilityType,
    userGoal: string
): RemoteCapabilityInvocation {
    const invocation: RemoteCapabilityInvocation = {
        capabilityType,
        execute: async options => {
            if (capabilityType === 'resource') {
                return executeRemoteResourceInvocation(invocation, options)
            }

            if (capabilityType === 'prompt') {
                return executeRemotePromptInvocation(invocation, options)
            }

            return executeRemoteToolInvocation(invocation, options)
        },
        input: capabilityType === 'resource' ? LATEST_CONTEXT_RESOURCE_URI : `goal=${userGoal}`,
        location: 'remote',
        name,
        serverId: PROJECT_ASSISTANT_SERVER_ID,
        source: 'mcp',
    }

    return invocation
}

这段代码体现了本版本的核心取舍:

  • invocation 形态是统一的
  • 但 Resource / Prompt / Tool 的执行语义不是统一的

Resource 会 readResource(),并把完整内容作为模型上下文。

Prompt 会 getPrompt(),并把返回 messages 转成模型上下文。

Tool 会 callTool(),并把执行结果作为最终回答依据。

如果某个 capability 失败,也不会直接打断整轮对话。Runtime 会写出统一错误 chunk,再注入一条“能力不可用”的上下文,让最终回答不要编造结果。

这个点很小,但非常关键。

因为这意味着 capability metadata 不再只是“给人看”的资料,而是真的进入了 Runtime 决策。


7. 前端为什么要承接执行事实

AI 应用的前端如果只承接最终答案,很多运行时事实会被藏起来。

这在普通聊天里问题不大,但一旦系统开始接 Tool、Resource、Prompt、MCP、Skill,前端就需要承接更多运行时事实。它不只是服务用户感知,也是在帮整个系统保持技术完整性:Runtime 写出了什么,前端就能稳定接住什么。

本版本扩展了流式协议:

export interface SkillSelectedChunk {
    type: 'skill-selected'
    skillId: string
    name: string
    description?: string
}

export interface PromptStartChunk {
    type: 'prompt-start'
    partId: string
    promptName: string
    source?: 'internal' | 'mcp'
    location?: 'local' | 'remote'
    serverId?: string
    input?: string
}

export interface PromptEndChunk {
    type: 'prompt-end'
    partId: string
    promptName: string
    source?: 'internal' | 'mcp'
    location?: 'local' | 'remote'
    serverId?: string
    status: 'completed' | 'failed'
    messageCount?: number
}

这段协议解决的是“前端消息模型如何稳定承接 Skill 命中和 Prompt 执行事实”的问题。

同时,原来的 Tool / Resource chunk 也补上了 source / location / serverId。这样前端就不只是知道“调用了一个工具”,还知道:

  • capability 类型是什么
  • 来源是 internal 还是 mcp
  • 位置是 local 还是 remote
  • 属于哪个 serverId
  • 当前状态是 called、completed 还是 failed

前端消费逻辑落在 use-chat-stream.ts(聊天流消费 hook,负责把 NDJSON chunk 合并成前端消息 part):

case 'skill-selected': {
    const messageId = activeStreamRef.current.messageId

    if (!messageId) {
        return
    }

    updateMessages(current => appendPart(current, messageId, createSkillPart(chunk.skillId, chunk.name, chunk.description)))
    return
}

case 'prompt-start': {
    const messageId = activeStreamRef.current.messageId

    if (!messageId) {
        return
    }

    updateMessages(current =>
        appendPart(
            current,
            messageId,
            createPromptPart(chunk.partId, chunk.promptName, 'called', chunk.source, chunk.location, chunk.serverId, chunk.input)
        )
    )
    return
}

case 'prompt-end': {
    const messageId = activeStreamRef.current.messageId

    if (!messageId) {
        return
    }

    updateMessages(current =>
        updatePromptPart(current, messageId, chunk.partId, part => ({
            ...part,
            promptName: chunk.promptName,
            source: chunk.source ?? part.source,
            location: chunk.location ?? part.location,
            serverId: chunk.serverId ?? part.serverId,
            status: chunk.status,
            messageCount: chunk.messageCount,
        }))
    )
    return
}

这段代码解决的是“流式协议如何落到前端消息结构”的问题。

最终前端不只是渲染答案,而是会把这些运行时事实沉淀成消息 part:

  • Skill 命中:阅读技能
  • Prompt 注入:tasklist-draft
  • Resource 读取:latest-context
  • Tool 执行:check_doc_consistency
  • 来源:MCP
  • 位置:remote
  • 服务:project-assistant-service

ai-3.png

我很喜欢这一步,因为它让 Runtime 不再像一个黑盒。

在调试、观察或复盘一轮回答时,我们能知道这轮回答依赖了哪个 Skill,消费了哪类 capability,来自本地还是远端。

这不是 UI 小修,而是协议层、运行时和产品表达一起往前走了一步。


8. 本版本刻意没做什么

这篇文章的主角是 Capability Surface,所以边界也要说清楚。

本版本刻意没有做这些事:

  • 不做 Agent
  • 不做 workflow
  • 不做多 remote server 编排
  • 不做模型自由规划任意 capability 调用
  • 不做复杂 OAuth 或账号体系
  • 不接数据库
  • 不接第三方 API
  • 不做 remote 文件系统
  • 不把 Tool / Resource / Prompt 强行抽成同一条执行链

这些不是“以后都不做”,而是本版本先不做。

因为当前更需要验证的是:

  • Capability 能不能先被统一描述?
  • Skill 能不能先声明自己可承接的能力范围?
  • Prompt 能不能成为 Tool / Resource 之外的一等 capability?
  • Remote MCP 能不能用一个 server 跑通最小闭环?
  • Metadata 能不能真正进入 Runtime,而不是停留在展示?
  • 前端消息模型能不能把执行事实承接下来?

这几个问题没有先收住,继续往更高层计划与执行走,复杂度会涨得很快。


9. 回到这次实践,我得到的几个判断

做完这一轮后,我对 AI Runtime 里的能力层有了一个更明确的判断:

做更完整的 Agent Runtime 之前,最好先有 Capability Surface。

更具体一点,我收获了 4 个判断。

第一,Capability Model 应该先是一层描述模型

它不需要一开始就包办执行链。先把 Tool / Resource / Prompt 的身份、来源、位置、可用性描述清楚,就已经很有价值。

第二,Skill Metadata 是 Skill 的表面,不是 planner

capabilitySelectors 用来表达 Skill 可承接什么能力,而不是让 Skill 变成 workflow 引擎。

第三,Prompt 应该是一等 capability

Prompt 不应该长期伪装成 Tool,也不应该只作为 Resource 背后的模板文件存在。它有自己的生命周期、参数注入方式和前端执行事实。

第四,Remote MCP 可以先做最小闭环

一个 remote server,三类 mock capability,一套 Streamable HTTP transport,一个 mock token,已经足够验证 MCP 接入层、Runtime、Skill、Protocol、Frontend 是否能跑通。

对我来说,本版本最有价值的地方,不是项目多了一个 project-assistant-service,也不是前端多了几张卡片。

真正的价值是:Capability 从“能被列出来”走到了“能被 Skill 声明、能被 Runtime 消费、能被前端消息模型承接”。

这才是从接入 MCP 继续往上层运行时走之前,我认为应该补上的一层。


10. 后续我会怎么继续往前推

短期内,我不会急着把这层扩成通用 Agent Runtime。

更合理的节奏是:

  • 先继续观察 Capability Model 是否足够承载更多本地 / 远端能力
  • 再考虑 remote MCP discovery 或 server 配置化
  • 再让更多 Skill 基于 capabilitySelectors 消费稳定能力
  • 最后再谈 Agent Runtime 如何基于这些能力做计划、执行和继续决策

换句话说,这条线更像从接入 MCP 走到 Capability Surface,再让 Skill Runtime 稳定消费更多 MCP 能力来源,最后再进入 Agent Runtime。

我现在更愿意先把中间这层做扎实。


项目地址

GitHub: github.com/HWYD/ai-min…

如果这篇文章刚好对正在做 AI Runtime、MCP 接入、Tool Calling 或 Skill 分层的同路人有一点参考价值,欢迎来仓库里看看。

如果大家也对这种按版本持续演进的 AI Runtime Skeleton 感兴趣,顺手点个 Star,也能让我知道这条路线确实对外部读者有帮助。后面我会继续沿着 Capability Surface、MCP 能力治理、Skill Runtime 和 Agent Runtime,把这套骨架一点点往前推。

钉钉小程序蓝牙打印探索与实践

2026年4月27日 11:19

作者:刘锦泉

一、前言

在实际物流配送业务中,电子签收在部分场景下仍无法完全替代纸质回单。配送员在门店现场需要打印配送单据,与收货方逐项核对并完成签字确认。纸质回单不仅是履约凭证,也是后续对账与责任追溯的重要依据。

但在真实配送环境中,作业条件通常较为复杂:

  • 无固定网络或 PC 设备
  • 网络环境不稳定,甚至可能离线
  • 作业地点分散且高度流动

在这种场景下,传统依赖固定设备与网络的打印方案难以落地。另一方面,配送业务的单据流转、任务执行与签收确认均在钉钉小程序中完成。

基于上述业务形态和环境约束,我们采用了如下方案:

钉钉小程序 + 便携式热敏打印机 + BLE

实现移动端直连打印能力,完成现场打印与签收闭环。

本文将围绕该方案,从工程实现角度拆解 BLE 打印链路,包含以下几个方面:

  • 为什么选择 BLE:对比经典蓝牙与 BLE 的差异,明确选型依据
  • BLE 通信模型:理解 GATT 如何抽象打印机能力
  • 连接建立与生命周期管理:从权限校验到连接释放的完整流程
  • ESC/POS 指令模型:构建打印机可识别的指令数据
  • 数据传输机制:解决分包、队列与发送可靠性问题
  • 图片打印:实现从彩色图像到黑白点阵的转换

二、为什么选择 BLE,而不是传统蓝牙?

在移动端驱动便携式热敏打印机的方案中,蓝牙是最常见的设备通信方式。然而,我们日常所说的“蓝牙”,并非单一技术标准,而是两个独立演进的无线通信分支

蓝牙技术分支:经典蓝牙 vs BLE

蓝牙技术联盟自 1999 年发布蓝牙 1.0 以来,技术规范经历了多次迭代。其中最关键的分水岭出现在 2010 年——蓝牙 4.0 规范的发布,首次将低功耗蓝牙(Bluetooth Low Energy,BLE)作为独立技术分支引入,与此前的经典蓝牙(BR/EDR)形成并行体系。

经典蓝牙(BR/EDR):

  • 诞生于蓝牙 1.0 ~ 3.0
  • 设计目标:替代短距离有线电缆,承载持续数据流
  • 典型应用:音频传输(A2DP)、免提通话(HFP)、文件传输
  • 特点:持续连接、高吞吐、功耗较高

低功耗蓝牙(BLE):

  • 随蓝牙 4.0 引入,并在 5.x 持续增强
  • 设计目标:以极低功耗支持间歇性、小数据量通信
  • 典型应用:传感器设备、IoT 终端、便携式打印机
  • 特点:短连接、小数据包、超低功耗

可以用一句话概括两者差异:

经典蓝牙是为“持续对话”设计的,而 BLE 是为“偶尔说一句”设计的。

从工程角度看:

  • 经典蓝牙解决的是“持续数据传输”问题
  • BLE 解决的是“高效指令交互”问题

虽然共享“蓝牙”之名,但两者在协议模型、连接机制与功耗设计上完全不同。接下来将从连接模型、系统支持与通信特性等维度展开对比。

连接模型差异

经典蓝牙(BR/EDR)通常基于SPP(Serial Port Profile ,串口仿真协议 向上层提供数据传输能力,本质上是建立一条持续的字节流通道,主要用于串口数据通信等场景。其特点是:

  • 面向流式数据传输
  • 提供连续字节流
  • 需要应用层自行实现分帧与协议切分

而 BLE 基于 GATT(Generic Attribute Profile,通用属性规范,将设备能力抽象为服务(Service)与特征值(Characteristic)的层级化数据结构,通信方式是围绕“特征值(Characteristic)”进行的读写与通知机制,更适合:

  • 小数据包
  • 指令型交互
  • 间歇性通信

在打印场景中,本质上传输的是 ESC/POS 指令流——一组结构清晰、长度有限的二进制命令序列,而不是持续大流量数据,因此:

GATT 模型天然更适合打印这种“指令驱动型通信”。

系统与小程序能力约束

在钉钉小程序运行环境中,蓝牙能力通过 JSAPI 封装,其底层依赖操作系统蓝牙协议栈。

经典蓝牙的困境:

  • iOS 对经典蓝牙串口协议(SPP/RFCOMM)实施严格的 MFi 认证管控,未经认证的设备无法被第三方 App 通过公开 API 访问。
  • Android 虽理论上支持经典蓝牙 SPP,但各厂商系统定制层碎片化严重,且 Android 12+ 对蓝牙权限的收紧进一步增加了连接的不确定性。
  • 在小程序体系中,蓝牙能力主要围绕 BLE(GATT)模型开放。经典蓝牙相关能力即便在部分平台存在,也缺乏统一标准与跨平台一致性,且在 iOS 上受限于 MFi 机制基本不可用。

因此,在工程实践中:

经典蓝牙难以作为稳定、可控的通信方案使用。

BLE 的确定性优势:

  • iOS 自 iOS 5 起通过 CoreBluetooth 框架完全开放 BLE 协议栈。
  • Android 自 4.3(API 18)起原生支持 BLE 中心模式。
  • 钉钉小程序提供完整的 BLE 链路 API。

换句话说:

BLE 是“系统优先支持”的标准能力,而经典蓝牙存在平台差异性风险。

连接稳定性与重连成本

在门店实际使用中,打印设备通常呈现“短连接、多设备、频繁切换”的特征。经典蓝牙的连接过程通常包括:

  • 设备发现(Inquiry / Page)
  • 配对(Pairing)
  • 绑定(Bonding)
  • 建链(SPP Channel Establish)

这一过程在实际设备上往往存在:

  • 首次连接耗时 2-5 秒,体验迟滞;
  • 配对状态强依赖系统蓝牙缓存,清除缓存后需重新配对;
  • 多设备切换时,原绑定关系可能干扰新连接;
  • 异常断连后,底层恢复机制不稳定,偶发需重启蓝牙服务。

而 BLE 的连接模型相对轻量:

  • 无强制配对流程(可采用 Just Works 模式,无弹窗交互)
  • 连接建立速度通常在 100-300 ms;
  • 断开后重连仅需重新发起 connect 请求,无历史绑定负担;
  • 天然适配“按单连接、用完即断”的业务模式。

因此:

BLE 更适合“按需连接、用完即断”的业务模式。

数据传输模型更适合打印协议

热敏打印本质是 ESC/POS 指令流输出,其数据特征为:

  • 数据量小(单次回单通常为 1-10 KB);
  • 结构固定(初始化命令 + 文本行 + 条码/二维码位图 + 走纸切纸命令);
  • 强时序要求(指令顺序不可乱,丢包将导致格式错乱或走纸异常)。

BLE 的写入方式(Write Characteristic / Write Without Response)配合 MTU 分包机制,可以很好支持:

  • 小包分片传输(单次写入数据受 ATT MTU 限制,默认约 20 字节,部分设备可协商至更大);
  • 应用层流控(根据 write 回调成功率控制发送节奏);
  • 避免阻塞 UI 线程(API 均为异步回调设计);
  • 支持 Notify 状态回传(实时感知打印机缺纸、过热等异常)。

相比之下:

BLE 的“受限分包模型”反而更适合 ESC/POS 这种指令流传输。

多维度对比总览

从工程落地角度来看,将经典蓝牙与 BLE 的核心差异归纳如下:

维度 经典蓝牙 BLE
通信模型 流式 特征值
协议 SPP GATT
连接 长连接 短连接
建连耗时 2~5s 100~300ms
功耗
iOS 支持 受限 完全开放
小程序支持 不统一 标准支持

小结

综合协议模型、系统支持以及小程序运行环境的约束可以看到:

经典蓝牙虽然在带宽与成熟度上具备优势,但在移动端尤其是 iOS 与小程序体系中,存在明显的可达性与一致性问题;而 BLE 在系统支持、连接模型与工程可控性上更符合当前场景。因此,在钉钉小程序驱动便携式打印机的场景下:

BLE 并不是“更优选择”,而是在现有平台约束下“可落地且稳定”的通信方案。

在明确选择 BLE 后,需要理解其核心通信机制——GATT。这是理解后续设备连接、服务发现与数据交互流程的认知基础。

三、BLE 通信模型

在 BLE 中,并不存在类似串口或 Socket 的持续数据通道。与经典蓝牙基于 SPP 提供“流式传输”不同,BLE 的通信建立在 GATT(Generic Attribute Profile)模型之上。

GATT 将设备能力抽象为一组层级化的数据结构,所有数据交互都围绕这些结构展开,而不是通过一条持续的数据流进行传输。

GATT 的基本结构

在 GATT 模型中,一个 BLE 设备可以抽象为为一棵层级结构:

设备(Device)
  └── 服务(Service)
        └── 特征值(Characteristic)
              └── 描述符(Descriptor)

在打印场景,BLE 打印机作为 GATT 服务端(Server),其内部属性表可简化为以下结构:

服务(Service)

服务是设备某项功能的逻辑集合,由一个或多个特征值组成。简单来说就是:

设备“对外声明的功能模块”——它能提供哪些能力

每个服务通过 UUID(通用唯一标识符) 唯一标识。UUID 可以分两类:

  • 16-bit 标准 UUID
  • 128-bit 自定义

16-bit 标准 UUID(SIG 定义)

格式:

0000xxxx-0000-1000-8000-00805f9b34fb

其中:

  • xxxx = 标准服务编号
  • 后缀 0000-1000-8000-00805f9b34fb = Bluetooth Base UUID

常见标准 Service(SIG 定义)

Service UUID 含义
Generic Access 0x1800 设备基础信息
Generic Attribute 0x1801 GATT 控制服务
Device Information 0x180A 设备信息
Battery Service 0x180F 电池信息
Heart Rate 0x180D 心率(穿戴设备)

特点:

  • 属于 BLE 官方标准
  • 这些服务本身不承载打印数据,但在工程中可用于读取设备信息、电量等辅助功能

128-bit 自定义 UUID

格式:

xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

例如:

  • 49535343-FE7D-4AE5-8FA9-9FAFD205E455
  • 0000FFF0-0000-1000-8000-00805F9B34FB

特点

  • 厂商自定义
  • 用于:
    • 打印机
    • 扫码枪
    • IoT 设备
  • ❗ 是否支持打印完全取决于厂商实现

示例

serviceId 示意图:

注意:ios 系统的serviceId 会进行简化,如下图:

特征值(Characteristic)

特征值是服务下的具体数据节点,是客户端真正进行读写操作的对象。可以把它理解为:

每个功能模块下的“具体操作入口”——能力如何被访问

一个特征值通常包含三个关键信息:

  • UUID:该特征值的唯一标识。
  • Properties(操作属性):定义支持的操作类型,常见值包括:
    • Read:可读
    • Write:可写(带响应)
    • Write Without Response:无响应写入
    • Notify:可通知
  • Value(值):实际存储的数据。

在打印场景中,最核心的特征值有两类:

  • 写入特征值:Property 包含 WriteWrite Without Response,用于向打印机发送 ESC/POS 指令流。
  • 通知特征值:Property 包含 Notify,用于打印机主动向客户端上报状态(缺纸、打印完成等)。

示例图如下:

描述符(Descriptor)与 CCCD

描述符是特征值的附加元数据,用于补充说明特征值的行为。其中最重要的是 CCCD(Client Characteristic Configuration Descriptor,客户端特征配置描述符)

CCCD 的作用,是让客户端配置某个特征值是否启用通知或指示:

  • 启用 Notify:通常写入 0x0001
  • 启用 Indicate:通常写入 0x0002

对于支持 Notify 的特征值,客户端必须向 CCCD 写入 0x0001 才能启用通知功能。此后当打印机状态发生变化时,才会主动向客户端推送数据。当你调用 dd.notifyBLECharacteristicValueChange 并设置 state: true 时,钉钉小程序底层就是在向该特征值的 CCCD 描述符写入 0x0001

Write 两种写入方式

向写入特征值发送数据时,BLE 提供两种写入模式:

  • Write Request(带响应写入):客户端每发送一包数据,打印机必须回复 Write Response 以确认接收成功。优点是可靠——应用层能明确感知每一包是否送达;缺点是吞吐量较低,每次写入都需等待对端确认。
  • Write Command / Write Without Response(无响应写入):客户端连续发送数据包,打印机不回复确认。这种方式显著提升了传输速率,但可靠性依赖于底层链路层的重传机制。

在打印场景中,由于单次回单数据量较小(1-10KB),且对实时性要求较高,Write Without Response 是常用选择

Notify 状态主动上报

Notify 是 BLE 设备主动向客户端推送数据的能力。在打印场景中,Notify 用于接收打印机的状态反馈,典型事件包括:

  • 打印完成
  • 缺纸报警
  • 机盖打开
  • 热敏头过热保护
  • 缓冲区状态(可接收新数据)

Notify 的本质是设备主动推送状态变化,而非客户端轮询。客户端订阅成功后,只要打印机状态发生变化,设备就可以主动推送数据给小程序。

小结

GATT 模型将 BLE 打印机的交互简化为两个核心操作:

  • 向“写入特征值”写入数据 → 发送 ESC/POS 打印指令
  • 订阅“通知特征值” → 接收打印机状态上报

有了这套模型,后续的 BLE 连接建立、服务发现、特征筛选、写入与订阅流程就会非常清晰。\ 在理解了 BLE 的 GATT 通信模型之后,接下来将进入实际工程实现层面,介绍在钉钉小程序中如何完成 BLE 打印设备的扫描、连接建立与断开管理流程。

四、钉钉小程序 BLE 打印设备连接与生命周期管理

在钉钉小程序中,通过 BLE 连接便携式打印机,本质上是对一系列异步系统能力的编排过程,而不仅仅是 API 的顺序调用。

在真实设备环境中,连接过程会受到权限、系统蓝牙状态、设备广播稳定性、信号强度等多种因素影响,因此整个流程需要以“状态机”的方式进行设计,而不是简单的线性调用。

权限校验与蓝牙可用性判断

在发起 BLE 操作之前,需要先完成两个层面的检查:

  • 权限是否具备(Permission Level)
  • 蓝牙是否可用(Adapter Level)

二者缺一不可,且需要分别校验。

权限校验

在钉钉小程序环境中,蓝牙能力依赖用户授权。可通过 dd.checkAuth 判断当前权限状态,并在未授权时引导用户开启权限。

/*
   * 检查授权
   */
checkAuthorization(authType: 'LBS' | 'BLUETOOTH'): Promise<boolean> {
  return new Promise((resolve) => {
    dd.checkAuth({
      authType,
      success: (res) => {
        const { granted } = res;
        if (!granted) {
          dd.showAuthGuide({
            authType,
          });
        }
        resolve(granted);
      },
      fail: () => {
        resolve(false);
      },
    });
  });
}
定位权限说明

在 Android 系统中,BLE 扫描能力与定位权限强绑定:

  • 未授权定位权限时,无法获取周边蓝牙设备
  • 即使蓝牙开启,扫描结果也可能为空

因此,在工程实践中,一般需要同时检查: ”蓝牙权限 + 定位权限“

async checkBluetoothAuthorization() {
  const bluetooth = await this.checkAuthorization('BLUETOOTH');

  const sys = dd.getSystemInfoSync();
  if (sys.platform === 'android') {
    const lbs = await this.checkAuthorization('LBS');
    return bluetooth && lbs;
  }

  return bluetooth;
}

蓝牙能力初始化

在调用任何 BLE 相关 API 之前,必须先初始化蓝牙模块。

const openBluetoothAdapter = () => {
  return new Promise((resolve, reject) => {
    dd.openBluetoothAdapter({
      success() {
        resolve();
      },
      fail(err) {
        if (err.errorCode === 10001) {
          dd.showToast({
            content: '系统蓝牙未开启',
            type: 'fail'
          });
        }
        reject(err);
      }
    });
  });
};

说明

  • dd.openBluetoothAdapter 是所有 BLE 能力的前置条件
  • 未初始化时调用其他 BLE API 会直接报错
  • 10001 表示系统蓝牙不可用或未开启

同时可以监听蓝牙状态变化:dd.onBluetoothAdapterStateChange 监听手机蓝牙状态的改变。

dd.onBluetoothAdapterStateChange((res) => {
  // available: 蓝牙模块是否可用(需支持 BLE 且蓝牙已开启)
  // discovering: 蓝牙模块是否处于搜索状态
  const { available, discovering } = res;
  if (!available) {
    // 蓝牙不可用,提示用户开启
  }
});

BLE 设备扫描

在完成蓝牙模块初始化后,即可开始扫描附近的 BLE 外围设备。

BLE 设备扫描本质上是一个基于广播包的实时发现机制,系统并不会返回“设备列表”,而是通过持续监听广播信号来逐步构建可见设备集合。

在钉钉小程序中,可以通过 dd.startBluetoothDevicesDiscovery 开始扫描:

dd.startBluetoothDevicesDiscovery({
  allowDuplicatesKey: false, // 是否允许重复上报同一设备
  interval: 0,               // 上报间隔,0 表示立即上报
  success() {
    // 超时自动停止
    timeoutId = setTimeout(() => {
      resolve();
      this.stopNearDeviceScan();
      clearTimeout(timeoutId);
    }, timeout);
  }
});

扫描过程中,通过 dd.onBluetoothDeviceFound 监听设备广播信息:

dd.onBluetoothDeviceFound((res) => {
  res.devices.forEach(device => {
    // 根据设备名称或制造商数据过滤打印机
    if (device.name && device.name.includes('Printer')) {
      console.log('发现打印机:', device.name, device.deviceId);
      // 保存设备信息,用于后续连接
      storeDeviceInfo(device);
    }
  });
});

在实际工程中,BLE 设备通常通过以下字段进行识别:

  • name
  • localName
  • 厂商自定义广播数据(manufacturerData)

建议在扫描阶段就完成设备过滤,而不是在连接阶段再做判断,以减少无效连接尝试。

扫描超时控制

BLE 扫描默认是一个持续过程,如果不主动停止,会一直占用系统资源,并可能影响后续连接操作。因此,在工程实践中,通常需要为扫描设置一个合理的超时时间。

常见做法是:在调用 startBluetoothDevicesDiscovery 后,通过定时器在指定时间后自动停止扫描。

function startScanWithTimeout(timeout = 5000) {
  dd.startBluetoothDevicesDiscovery({
    allowDuplicatesKey: false,
    interval: 0,
    success() {
      console.log('开始扫描蓝牙设备');

      // 超时自动停止扫描
      setTimeout(() => {
        dd.stopBluetoothDevicesDiscovery({
          success() {
            console.log('扫描超时,已停止');
          }
        });
      }, timeout);
    }
  });
}

扫描异常与设备发现不完整问题

在真实设备环境中(尤其是 iOS 与 Android),BLE 扫描可能出现以下异常行为:

  • 已扫描到的设备不会重复上报
  • 某些设备在重新扫描后无法再次被发现
  • 明确存在广播的设备,但扫描结果为空
  • 多次调用扫描 API 后仍无法恢复历史设备显示

该问题通常并非设备异常,而是由于系统层 BLE 扫描状态、缓存机制或扫描会话未完全释放导致。

✔ 解决方案:重置蓝牙适配器状态

在钉钉小程序中,可以通过重启蓝牙适配器来清理系统扫描上下文,从而恢复设备发现能力:

async resetBluetoothAdapter() {
  try {
    await dd.closeBluetoothAdapter();

    // 给予系统释放扫描上下文的时间(非常关键)
    await new Promise(resolve => setTimeout(resolve, 300));

    await dd.openBluetoothAdapter();

    console.log('蓝牙适配器已重置');
  } catch (err) {
    console.error('蓝牙适配器重置失败', err);
  }
}

由于 closeBluetoothAdapter → openBluetoothAdapter 会带来系统层状态重建开销,因此不建议频繁调用。

建立蓝牙连接

建立连接

在确定目标打印机后,使用 deviceId 建立连接。

dd.connectBLEDevice({
  deviceId,
  success() {
    console.log('设备连接成功');
    // 连接成功后立即停止扫描,避免干扰后续操作
    dd.stopBluetoothDevicesDiscovery();
    // 获取服务列表
    getDeviceServices(deviceId);
  },
  fail(err) {
    console.error('连接失败', err);
  }
});

说明

  • 若设备已连接,再次连接通常会直接返回成功
  • 建议连接成功后立即停止扫描,避免资源竞争
  • 若“可发现但无法连接”,优先检查是否仍在扫描状态

获取 Services

连接成功后,需要获取设备暴露的 Service 列表。

function getDeviceServices(deviceId) {
  dd.getBLEDeviceServices({
    deviceId,
    success(res) {
      console.log('Services列表:', res.services);
      // 通常打印服务使用自定义 UUID
      const printService = res.services.find(service => 
        service.uuid.includes('FF00') || service.isPrimary
                                            );
      if (printService) {
        getServiceCharacteristics(deviceId, printService.uuid);
      }
    },
    fail(err) {
      console.error('获取服务失败', err);
    }
  });
}

在实际设备中(尤其是打印机),往往会同时暴露多个 Service,其中只有极少数才是真正用于数据传输(打印)的通道。因此,需要建立一套合理的过滤与优先级策略,用于筛选候选 Service。

Service 的筛选可以遵循以下经验规则:

  1. 排除通用标准服务
    • 1800 / 1801 / 180A / 180F
    • 这些服务几乎不可能承载打印数据
  2. 优先选择 FFxx 类服务
    • FFF0 / FFE0 / FF00
    • 通常为串口透传服务(Serial over BLE)
    • 大多数打印机使用该通道接收 ESC/POS 指令
  3. 谨慎对待厂商自定义 UUID
    • 49535343-xxxx
    • 可能是蓝牙模块内部服务,而非打印通道
  4. 其余 Service 作为候选补充
    • 不能完全忽略,但优先级较低
private getServicePriority(uuid: string): number {
  const u = uuid.toLowerCase();

  // 提取短 UUID(如 0000fff0)
  const shortUUID = u.startsWith('0000') ? u.slice(4, 8) : '';

  // 1. 标准服务(最低优先级)
  const standardServices = ['1800', '1801', '180a', '180f'];
  if (standardServices.includes(shortUUID)) {
    return 100;
  }

  // 2. 打印透传服务(最高优先级)
  if (shortUUID.startsWith('ff')) {
    return 0;
  }

  // 3. 纯 128 位厂商 UUID(中优先级,需验证)
  if (!u.includes('0000-1000-8000-00805f9b34fb')) {
    return 50;
  }

  // 4. 其他标准扩展服务
  return 60;
}

在获取 Service 列表后,可结合排序使用:

services.sort((a, b) => 
  this.getServicePriority(a.uuid) - this.getServicePriority(b.uuid));

获取 Characteristics

确定目标 Service 后,需要进一步获取其下的 Characteristic。示例代码如下:

function getServiceCharacteristics(deviceId, serviceId) {
  dd.getBLEDeviceCharacteristics({
    deviceId,
    serviceId,
    success(res) {
      console.log('Characteristics列表:', res.characteristics);
      let writeCharId = null;
      let notifyCharId = null;
      res.characteristics.forEach(c => {
        // 注意:钉钉使用 characteristicId,不是 uuid
        if (c.properties.write || c.properties.writeWithoutResponse) {
          writeCharId = c.characteristicId;
        }
        if (c.properties.notify || c.properties.indicate) {
          notifyCharId = c.characteristicId;
        }
      });
      // 保存特征值 ID,启用 Notify
      enableNotify(deviceId, serviceId, notifyCharId);
    },
    fail(err) {
      console.error('获取特征值失败', err);
    }
  });
}

说明:

每个 Characteristic 都会声明自己的能力属性:

  • properties.write
  • properties.writeWithoutResponse
  • properties.notify
  • properties.read

这些属性,直接决定了是否能用于打印数据写入。打印通常至少需要两个特征:

  • 可写特征:用于写入打印数据,但需要特别注意 write ≠ 一定可用于打印

Characteristic 支持 write,仅代表“可以写入数据”,并不代表打印机会执行这些数据。

  • 通知特征(可选):用于接收打印状态回传,状态通知特征并非必需,但在批量或大数据打印时非常重要

启用特征值变化通知

对于支持 notifyindicate 的特征值,需调用 dd.notifyBLECharacteristicValueChange 启用通知功能。示例代码如下:

function enableNotify(deviceId, serviceId, characteristicId) {
  dd.notifyBLECharacteristicValueChange({
    deviceId,
    serviceId,
    characteristicId,
    state: true, // 启用 notify
    success() {
      console.log('Notify 已启用');
      // 监听特征值变化事件
      dd.onBLECharacteristicValueChange((res) => {
        const hexStr = res.value; // 钉钉返回 hex 字符串
        parsePrinterStatus(hexStr);
      });
      // 连接就绪,可以开始发送打印数据
      onPrinterReady();
    },
    fail(err) {
      console.error('启用 Notify 失败', err);
    }
  });
}

说明

  • 必须先启用 notify 才能监听到设备 characteristicValueChange 事件。
  • 设备的特征值必须支持 notifyindicate 才可以成功调用,具体参照 characteristic 的 properties 属性。
  • 订阅操作成功后,需要设备主动更新特征值的 value,才会触发 dd.onBLECharacteristicValueChange
  • 订阅方式效率比较高,推荐使用订阅代替 read 方式。
  • 注意调用顺序:最好在连接之后就调用 dd.notifyBLECharacteristicValueChange 方法。

蓝牙连接的断开与资源清理

BLE 连接具有天然不稳定性,因此必须设计完整的断开与恢复机制。

被动断开:监听与自动重连

蓝牙连接随时可能因为距离过远、设备断电、信号干扰等原因而意外断开。我们必须通过监听连接状态变化事件来应对这种情况。

// 监听连接状态变化
dd.onBLEConnectionStateChanged((res) => {
  if (!res.connected) {
    // 做相应的处理
  }
});

说明

  • 若对未连接的设备调用数据读写操作接口,会返回 10006 错误,此时应执行重连。
  • 避免重复监听:每次调用 on 方法监听事件之前,最好先调用 off 方法关闭之前的事件监听,防止多次注册导致事件被多次触发。

主动断开与清理:完整的退出机制

当用户主动关闭页面或完成打印后,我们需要手动断开连接并释放系统资源。一个标准的清理流程应该包含三步:

  1. 断开设备连接,
  2. 移除所有事件监听
  3. 关闭蓝牙适配器。
// 完整的资源清理方法,建议在页面 onUnload 或退出打印时调用
releaseBluetoothResources() {
  // 1. 停止搜索设备(如果还在搜索中)
  this.stopBluetoothDevicesDiscovery();

  // 2. 断开与蓝牙设备的连接
  if (this.isConnected) {
    dd.disconnectBLEDevice({
      deviceId: this.data.deviceId,
      success: () => {
        console.log('成功断开设备连接');
      },
      fail: (err) => {
        console.error('断开设备连接失败', err);
      }
    });
  }

  // 3. 移除所有蓝牙相关的事件监听,防止内存泄漏
  // 设备发现监听、连接状态监听、特征值变化监听、适配器状态监听
  this.removeAllListener();

  // 4. 最后,关闭蓝牙适配器,彻底释放系统资源
  dd.closeBluetoothAdapter({
    success: () => {
      console.log('蓝牙适配器已关闭,资源已释放');
      // 重置所有连接相关状态
      this.resetBleStatus();
    },
    fail: (err) => {
      console.error('关闭蓝牙适配器失败', err);
    }
  });
}

说明

  • 分步操作:虽然 dd.closeBluetoothAdapter 会断开所有连接并释放资源,但为了逻辑清晰和状态可控,建议还是显式地调用 dd.disconnectBLEDeviceoff 系列方法进行清理。
  • 调用时机:此方法建议在页面的 onUnload 生命周期中调用。因为 closeBluetoothAdapter 是异步操作,不建议将其与 openBluetoothAdapter 一起用作异常处理,效率低且易引发线程同步问题。
  • 页面卸载:点击小程序右上角关闭按钮时,小程序可能仅进入后台而非立即销毁,因此需要在 onHideonUnload 中主动调用清理逻辑,确保连接被及时断开。

工程化建议

蓝牙打印的交互链路很长,涉及权限、扫描、连接、状态管理、异常恢复等诸多环节。若将所有逻辑耦合在一起,代码会迅速膨胀且难以维护。建议将蓝牙能力拆分为两个独立实体:

BluetoothAdapter(能力适配层)

负责与钉钉蓝牙 API 的直接交互,向上屏蔽平台差异与底层细节。核心职责:

  • API 适配:封装 openBluetoothAdapterstartDiscovery 等基础调用,统一返回 Promise 接口
  • 权限校验:收敛蓝牙与定位权限的检查逻辑
  • 异常告警:统一捕获蓝牙错误码,映射为业务可理解的提示(如“系统蓝牙未开启”)
  • 埋点上报:记录扫描耗时、连接成功率、异常断开次数等关键指标

BluetoothConnection(连接实例层)

每一次打印任务对应一个连接实例,内置完整的状态机与连接属性。核心职责:

  • 连接属性:持有 deviceIdserviceIdwriteCharIdnotifyCharId 等关键标识
  • 状态机:管理 idle → scanning → connecting → ready → disconnecting 等状态流转,杜绝非法操作
  • 生命周期:统一处理连接建立、心跳维持、异常断连重试、资源释放
  • 事件管理:自动绑定与解绑 onBLEConnectionStateChanged 等事件监听,防止泄漏

小结

BLE 打印连接的本质并不是一次性调用成功,而是一个持续运行的状态机系统,其核心能力在于:

  • 连接状态管理
  • 异常恢复机制
  • 事件驱动模型

只有在稳定连接的基础上,才能保证打印数据的可靠传输与状态反馈。

在完成稳定的 BLE 连接建立之后,下一步需要解决的问题是:如何将业务数据转换为打印机可识别的二进制指令流,这将引出打印领域的核心协议模型——ESC/POS 指令体系

五、ESC/POS 指令模型

打印机本质上是一个顺序执行的硬件设备

  • 不解析 HTML
  • 不理解 JSON
  • 不具备页面布局能力

它唯一能够处理的,是一段按顺序输入的字节流(Byte Stream)

因此,要驱动打印机完成打印任务,必须将业务数据转换为其所支持的打印控制语言。在便携式热敏打印机领域,这一标准就是 ESC/POS

ESC/POS 概述

ESC/POS 是由 EPSON 定义的一套打印控制指令体系,现已成为热敏票据打印的事实标准。

其核心特征是:

  • 以控制字符开头:
    • ESC(0x1B)
    • GS(0x1D)
  • 后跟一个或多个参数字节
  • 构成一条完整指令

控制指令通常由以下几部分组成:

指令流结构

一条完整的 ESC/POS 指令流通常由以下部分组成:

  1. 初始化命令:复位打印机状态
  2. 格式控制命令:对齐、字体、加粗、行距等
  3. 内容数据:文本、条码、二维码、图片
  4. 结束控制命令:走纸、切纸

流式执行模型

打印机采用流式处理机制

边接收 → 边解析 → 边执行

不存在“完整接收后再统一执行”的过程。

因此:

👉 指令发送顺序必须严格等于打印顺序

常用 ESC/POS 指令

下面是配送回单场景中最常用的一组指令,以 JavaScript 对象形式组织便于后续封装使用。

const ESC_POS_COMMANDS = {
  // 初始化
  INIT: [0x1B, 0x40],

  // 换行
  LF: [0x0A],
  CR: [0x0D],
  CRLF: [0x0D, 0x0A],

  // 切纸(GS V m)
  CUT_FULL: [0x1D, 0x56, 0x41, 0x00],    // 全切(部分打印机支持)
  CUT_PARTIAL: [0x1D, 0x56, 0x42, 0x00], // 半切(留一个连接点)
  CUT: [0x1D, 0x56, 0x01],                // 标准切纸指令

  // 对齐(ESC a n)
  ALIGN_LEFT: [0x1B, 0x61, 0x00],
  ALIGN_CENTER: [0x1B, 0x61, 0x01],
  ALIGN_RIGHT: [0x1B, 0x61, 0x02],

  // 字体样式(ESC E n)
  BOLD_ON: [0x1B, 0x45, 0x01],
  BOLD_OFF: [0x1B, 0x45, 0x00],

  // 字体大小(GS ! n)
  FONT_NORMAL: [0x1D, 0x21, 0x00],
  FONT_DOUBLE_HEIGHT: [0x1D, 0x21, 0x01],
  FONT_DOUBLE_WIDTH: [0x1D, 0x21, 0x10],
  FONT_DOUBLE: [0x1D, 0x21, 0x11],

  // 行间距(ESC 3 n)
  LINE_SPACING_DEFAULT: [0x1B, 0x32],
  LINE_SPACING: [0x1B, 0x33], // 后跟一个字节表示间距
};

完整指令参考:更多指令请查阅打印机厂商提供的 ESC/POS 编程手册。 传送门

文本编码转换

为什么会出现乱码?

在小程序环境中,

  • JavaScript 字符串内部使用 UTF-16 编码
  • BLE 发送通常使用 UTF-8
  • 而大多数便携式热敏打印机只支持 GBKGB2312 这类中文字符集。

如果直接将 UTF-8 编码的中文发送给打印机,就会出现经典的“乱码”问题。

因此,必须在发送前完成编码转换:

UTF-16(JS) → GBK(打印机)

小程序环境下的转换方案

小程序不支持 Node.js 的 Buffer 或标准 Web API TextEncoder(其编码参数 encoding 在部分环境中无效)。工程上推荐使用纯 JavaScript 编码库,通过“查表法”实现编码转换:

  • iconv-lite:功能强大的纯 JavaScript 编码转换库,支持 GBK、GB2312、GB18030 等多种中文编码,体积适中。
  • GBK.js:专注于 GBK 编码的轻量库,如果只需支持 GBK,可进一步减小包体积。

以下以 iconv-lite 为例展示转换函数:

// 引入 iconv-lite(需通过 npm 安装后构建到小程序中)
import * as iconv from 'iconv-lite';


function textEncode(str) {
  // 将 UTF-16 字符串编码为 GBK 字节数组
  return iconv.encode(str, 'gbk');
}

打印任务的工程化封装

在实际项目中,如果直接拼接字节数组,会带来以下问题:

  • 可读性差
  • 维护成本高
  • 易出错

因此建议封装打印任务。

PrintJob封装示例

import iconv, { Iconv } from 'iconv-lite';

type Alignment = 'left' | 'center' | 'right';


export class ESCPOSGenerator {
  private commands: number[] = [];
  private currentEncoding: string;

  // 页面宽度(字符数)
  private pageWidth = 0;

  // 当前状态
  private currentState: TextOptions = {
    bold: false,
    align: 'left',
    lineSpacing: 64,
    size: 1,
  };
  
  private encoder: typeof Iconv;

  constructor(encoding = 'gb2312', pageWidth = 48) {
    this.currentEncoding = encoding;
    this.pageWidth = pageWidth;
    this.encoder = iconv;
  }

  /**
   * 初始化打印机
   */
  init(): this {
    this.pushCommand(ESC_POS_COMMANDS.INIT);
    return this;
  }

  /**
   * 添加文本
   */
  text(content: string, options: Partial<TextOptions> = {}): this {
    const nextState = { ...this.currentState, ...options };

    const prevState = { ...this.currentState };

    // 对齐每次都加
    this.align(nextState.align);

    // 👉 只发送“变化的指令”
    this.applyDiffStyle(nextState);

    // 添加文本内容
    const encoded = this.encoder.encode(content, this.currentEncoding);
    this.commands.push(...encoded);

    if (Object.keys(options).length > 0) {
      this.applyDiffStyle(prevState);
    }

    return this;
  }

  /*
   * 添加文本并换行
   */

  lineText(content: string, options: Partial<TextOptions> = {}): this {
    return this.text(content, options).newline();
  }

  /**
   * 换行
   */
  newline(lines = 1): this {
    for (let i = 0; i < lines; i++) {
      this.pushCommand(ESC_POS_COMMANDS.LF);
    }
    return this;
  }

  /**
   * 添加分隔线
   */
  separator(char = '-'): this {
    const repeatCount = char.length ? Math.floor(this.pageWidth / char.length) : 0;
    if (repeatCount <= 0) return this;

    const line = char.repeat(repeatCount);
    // 保存当前对齐方式
    const prevAlign = this.currentState.align;
    // 临时设置为居中
    this.align('center');
    this.text(line);
    this.newline();
    // 恢复原对齐方式
    if (prevAlign !== 'center') {
      this.align(prevAlign as Alignment);
    }
    return this;
  }

  /**
   * 切纸
   */
  cut(type: 'full' | 'partial' = 'full'): this {
    if (type === 'partial') {
      this.pushCommand(ESC_POS_COMMANDS.CUT_PARTIAL);
    } else {
      this.pushCommand(ESC_POS_COMMANDS.CUT_FULL);
    }
    return this;
  }

  /**
   * 构建最终字节流
   */
  build(): Uint8Array {
    return new Uint8Array(this.commands);
  }

  /**
   * 获取指令长度
   */
  getLength(): number {
    return this.commands.length;
  }

  /**
   * 清空指令
   */
  clear(): this {
    this.commands = [];
    return this;
  }

  /**
   * 推送指令到命令列表
   */
  private pushCommand(command: number[]): void {
    this.commands.push(...command);
  }

  /**
   * 设置对齐方式
   */
  private align(alignment: Alignment) {
    const alignmentMap = {
      left: ESC_POS_COMMANDS.ALIGN_LEFT,
      center: ESC_POS_COMMANDS.ALIGN_CENTER,
      right: ESC_POS_COMMANDS.ALIGN_RIGHT,
    };

    const alignCommand = alignmentMap[alignment];
    this.pushCommand(alignCommand);
  }

  /**
   * 设置粗体
   */
  private bold(enable = true) {
    if (enable) {
      this.pushCommand(ESC_POS_COMMANDS.BOLD_ON);
    } else {
      this.pushCommand(ESC_POS_COMMANDS.BOLD_OFF);
    }
  }

  /**
   * 设置字体大小
   */
  private size(font: number) {
    if (font < 1 || font > 8) {
      return;
    }

    const n = ((font - 1) << 4) | (font - 1);

    this.pushCommand([...ESC_POS_COMMANDS.FONT, n]);
  }

  /**
   * 设置行间距
   */
  private lineSpacing(spacing: number) {
    this.pushCommand([...ESC_POS_COMMANDS.LINE_SPACING, spacing]);
  }

  /**
   * 应用样式差异(用于 text 方法外部)
   */
  private applyDiffStyle(next: Required<TextOptions>) {
    // ✅ bold
    if (next.bold !== this.currentState.bold) {
      this.bold(next.bold);
      this.currentState.bold = next.bold;
    }

    // ✅ size
    if (next.size !== this.currentState.size) {
      this.size(next.size);
      this.currentState.size = next.size;
    }

    // ✅ line spacing
    if (next.lineSpacing !== this.currentState.lineSpacing) {
      this.lineSpacing(next.lineSpacing);
      this.currentState.lineSpacing = next.lineSpacing;
    }
  }

}

使用示例

// 创建配送回单打印任务
const printJob = new PrintJob();

const printData = printJob
  .init() // 初始化打印机
  .text('配送回单', { bold: true, size: 2, align: 'center' }) // 文本
  .newline(2) // 空两行
  .cut() // 切纸
  .build();  // 构建字节流

console.log(`打印数据大小: ${printData.length} 字节`);

小结

本章系统介绍了热敏打印的事实标准——ESC/POS 指令模型,包括:

  • 指令基础:ESC/POS 的组成结构与流式执行特性。
  • 常用命令速查:以 JavaScript 对象形式整理了初始化、换行、切纸、对齐、加粗等高频指令。
  • 文本编码转换:解释了小程序环境下中文乱码的根源,并给出了基于 iconv-lite 的 GBK 编码转换方案。
  • 工程化封装:提供了一个完整的 PrintJob 类实现,将复杂的指令拼接隐藏在语义化的链式 API 之后,同时输出钉钉小程序可直接使用的十六进制字符串。

指令构建完成之后,文本指令的发送链路已经打通,接下来让我们看看另一个常见但更复杂的场景:图片打印。

六、BLE 数据传输机制

在完成 BLE 连接建立以及 ESC/POS 指令构建之后,打印流程才真正进入核心阶段。

需要再次强调一个关键认知:

BLE 打印并不是一次“写入字符串”的操作,而是一套受协议严格约束的数据传输过程。

这一过程涉及分包、顺序控制、发送节奏以及可靠性保障等多个方面。

BLE 的分包通信模型

BLE 并非为大数据连续传输而设计,其底层采用的是基于 MTU(Maximum Transmission Unit)的分包通信模型

在协议栈中:

  • 应用层数据通过 ATT(Attribute Protocol)承载
  • ATT 层定义了单次传输的最大数据长度(MTU)

MTU 与有效载荷

根据蓝牙核心规范,ATT 协议的默认 MTU 为 23 字节。这 23 字节的构成如下:

组成部分 字节数 说明
Opcode 1 字节 操作码(如 Write Request = 0x12)
Attribute Handle 2 字节 特征值句柄
有效载荷 20 字节 应用层实际可用的数据

因此,应用层单次写入的实际可用数据量仅为 20 字节。这意味着,即使发送一条简单的“打印文本”指令,也可能被拆分为多个数据包。

跨平台 MTU 差异

MTU 并非固定不变,连接建立后双方可协商更大的 MTU 值。不同平台的 MTU 能力存在显著差异:

平台 MTU 协商能力 最大 MTU 说明
Android requestMtu(int mtu) 512 字节 Android 5.1+ 支持主动协商
iOS 系统自动协商 185 字节 无开放 API,由外设发起协商
钉钉小程序 不支持协商 23 字节(有效 20 字节) API 未提供 MTU 协商接口

钉钉小程序的关键约束

  • dd.writeBLECharacteristicValue API 要求单次写入数据“限制在 20 字节内”。
  • 钉钉小程序未提供 MTU 协商相关 API,开发者无法在应用层主动请求提升 MTU。
  • 这意味着钉钉小程序环境下的 BLE 通信,始终受限于默认的 20 字节单包上限

钉钉小程序的数据格式差异

钉钉小程序 BLE API 与微信小程序存在一个易被忽视的差异:

  • 微信小程序writeBLECharacteristicValuevalue 参数为 ArrayBuffer。
  • 钉钉小程序:要求传入 十六进制字符串(hexString)

因此,开发者需将 ESC/POS 二进制指令转换为 hex 格式后再调用 API。这一差异不影响传输能力,但需要在编码时注意格式转换。

分包策略:指令切片与顺序发送

受限于单包 20 字节的约束,完整 ESC/POS 指令流必须被切分为多个小包。分包的核心原则:

  1. 按顺序切片:将完整的 hex 字符串按 40 个字符(对应 20 字节)为一组进行切分。
  2. 保持顺序:分包必须严格按原始顺序发送,保证打印机接收的指令顺序正确。
  3. 最后一包处理:最后一包可能不足 20 字节,直接发送剩余部分。
// 将完整指令流切分为 20 字节的分包
function splitIntoPackets(hexString) {
  const packets = [];
  // 每 40 个 hex 字符 = 20 字节
  for (let i = 0; i < hexString.length; i += 40) {
    packets.push(hexString.slice(i, i + 40));
  }
  return packets;
}

发送节奏控制与队列管理

蓝牙缓冲区

打印机内部并不是“收到数据就立刻执行”,而是有一个临时存储区域:

蓝牙接收缓冲区(Bluetooth RX Buffer)

它的作用是:

  • 暂存 BLE 发送过来的数据
  • 再交给打印引擎逐条解析执行

可以理解为:

BLE 是“快递员”,缓冲区是“收件筐”,打印机是“处理工人”

为什么会发生溢出?

问题出在一个“速度不匹配”:

BLE 发送速度:

  • 可以连续快速 write
  • 无响应模式甚至几乎不等待

🐢 打印机处理速度

  • 需要解析 ESC/POS 指令
  • 热敏头逐行打印
  • 图片还要逐点绘制

💥** 结果就是:**

当你发送速度 > 打印机处理速度时:

📌 缓冲区被塞满 → 新数据进不来 → 旧数据被覆盖或丢弃

队列管理方案

Write Without Response 模式下,若连续写入速度过快,可能导致打印机蓝牙模块缓冲区溢出而丢包。因此必须控制发送节奏。

class BLEPacketQueue {
  constructor(deviceId, serviceId, characteristicId) {
    this.queue = [];
    this.isSending = false;
    this.deviceId = deviceId;
    this.serviceId = serviceId;
    this.characteristicId = characteristicId;
  }

  // 添加分包到队列
  addPackets(packets) {
    this.queue.push(...packets);
    if (!this.isSending) {
      this.sendNext();
    }
  }

  // 发送下一包
  sendNext() {
    if (this.queue.length === 0) {
      this.isSending = false;
      console.log('所有分包发送完成');
      return;
    }

    this.isSending = true;
    const packet = this.queue.shift();

    dd.writeBLECharacteristicValue({
      deviceId: this.deviceId,
      serviceId: this.serviceId,
      characteristicId: this.characteristicId,
      value: packet,
      success: () => {
        // 发送成功后延时 15-20ms,再发送下一包
        setTimeout(() => this.sendNext(), 20);
      },
      fail: (err) => {
        console.error('分包发送失败', err);
        // 可在此处实现重试逻辑
        this.queue.unshift(packet); // 放回队列头部
        setTimeout(() => this.sendNext(), 50);
      }
    });
  }
}

发送间隔的实践经验

  • 间隔过小(<10ms)可能导致打印机缓冲区溢出,表现为乱码或丢包。
  • 间隔过大则延长整体打印时间,影响配送员体验。
  • 15-20ms 是一个经过实践验证的平衡值

Write Without Response 的可靠性与流控权衡

打印场景通常选用 Write Without Response 以提升吞吐效率。但这一模式放弃了应用层的单包确认,可靠性依赖于底层链路层的重传机制。

在工程实践中,可通过以下策略平衡可靠性与效率:

  1. 发送间隔控制:给打印机蓝牙模块留出处理时间。
  2. Notify 状态监听:通过监听打印机的“缓冲区满/可接收”状态,实现应用层流控。
  3. 整单校验:打印完成后,通过 Notify 接收“打印完成”确认。若超时未收到,触发重打逻辑。

完整发送流程示例

async function printOrder(orderInfo) {
  // 1. 构建指令流
  const command = buildPrintCommand(orderInfo);

  // 2. 转 hex
  const hex = toHex(command);

  // 3. 分包
  const packets = splitIntoPackets(hex);

  // 4. 队列发送
  const queue = new BLEPacketQueue(deviceId, serviceId, writeCharId);
  queue.addPackets(packets);

  // 5. 等待完成通知
  waitForPrintComplete();
}

小结

本章从协议层到工程实现,系统说明了 BLE 打印的数据传输机制:

  • BLE 基于 MTU 的分包通信模型
  • 钉钉小程序 20 字节硬限制
  • hex 数据格式转换
  • 分包切片策略
  • 队列发送与节奏控制
  • 应用层可靠性与流控设计

通过这些机制,才能在 BLE 受限环境下,实现稳定的打印数据传输。

那么,如何在同样的 BLE 限制下,高效传输体积更大、数据更密集的图片内容?

七、BLE 图片打印

在大多数业务场景中,文本打印已经能够覆盖核心需求。但在实际落地过程中,很快会遇到一些无法回避的场景:

  • 回单需要打印签字图片
  • 单据需要展示公司 Logo
  • 业务要求打印盖章或二维码图片

相比文本,图片打印的复杂度会显著提升。

需要先建立一个关键认知:

打印机并不认识“图片”,它只认识“点”。

打印机如何理解图片

热敏打印机的本质,是一排密集排列的加热点阵列。以常见 58mm 打印机为例:

  • 打印宽度:通常为 384 点
  • 每一行:384 个独立加热点
  • 每个点状态:
    • 加热 → 黑点
    • 不加热 → 白点

👉 换句话说:

打印图片,本质是逐行描述:哪些点需要打印

黑白点阵模型

假设一行 8 像素宽的图像:

每个像素的状态可以抽象为可以抽象为:

1 1 0 0 1 1 0 0

其中:

  • 1 = 打印(加热)
  • 0 = 不打印

这组 0/1 数据,就是所谓的黑白点阵。

为什么 8 个像素 = 1 字节

  • 1字节 = 8 位二进制
  • 每一位对应一个像素
110011000xCC

这也是核心位运算的来源:

byte |= (0x80 >> bit);

含义:

  • 从高位开始写入
  • 每一位映射一个像素点

获取图片像素数据

在小程序中,图片通常来源于:

  • Canvas(签名)
  • 本地图片
  • 网络图片

统一方式是通过 Canvas 获取像素数据::

async function getImagePixelData(imagePath, targetWidth = 384) {
  return new Promise((resolve, reject) => {
    dd.getImageInfo({
      src: imagePath,
      success: (imgInfo) => {
        const scale = targetWidth / imgInfo.width;
        const targetHeight = Math.floor(imgInfo.height * scale);

        const ctx = dd.createCanvasContext('printCanvas');

        ctx.clearRect(0, 0, targetWidth, targetHeight);
        ctx.drawImage(imagePath, 0, 0, targetWidth, targetHeight);

        ctx.draw(false, () => {
          dd.canvasGetImageData({
            canvasId: 'printCanvas',
            x: 0,
            y: 0,
            width: targetWidth,
            height: targetHeight,
            success: (res) => {
              resolve({
                data: res.data,
                width: targetWidth,
                height: targetHeight
              });
            },
            fail: reject
          });
        });
      },
      fail: reject
    });
  });
}

此时得到的 data 是一个 RGBA 像素数组, 每个像素由 4 个字节组成(R、G、B、A),取值范围均为 0-255。

像素转换:彩色 → 黑白

现实中的图片是 RGB 彩色图,而打印机只能打印黑色。所以我们必须把:彩色像素 → 黑或白

这就需要两步:

  • 灰度化:把彩色图片变成亮度图。
  • 二值化: 把亮度图变成黑白图。

最终的黑白图本质上就是:

每个像素是否打印的布尔矩阵。

而这个布尔矩阵,就是点阵数据。

灰度化

灰度表示:

这个像素“亮”还是“暗”

一个灰度值通常在: 0 ~ 255,0 = 黑,255 = 白。

图像处理标准处理公式如下:

function rgbToGray(r, g, b) {
  // 使用加权平均法计算灰度值
  return Math.round(r * 0.299 + g * 0.587 + b * 0.114);
}

为什么是这个比例?因为:

  • 人眼对绿色最敏感
  • 对红色次之
  • 对蓝色最不敏感

所以不是简单平均 (r + g + b)/3,而是加权平均。

举个例子
// 红色 灰度化
(255, 0, 0) --> 255 * 0.29976

// 蓝色 灰度化
(0, 0, 255) --> 255 * 0.11429

所以蓝色会更“暗”。

二值化处理

灰度化之后,我们得到了一个亮度值:0 ~ 255,但打印机不能打印“灰色”。它只能:

  • 打印
  • 不打印

所以我们必须做一个判断:

if (gray < threshold) {
   // 打印
} else {
   // 不打印
}

这一步叫:

二值化(Binary Thresholding)

最终:灰度图 → 黑白图

常见算法对比

方法 特点 适用场景
固定阈值 简单快速 Logo / 二维码
OTSU 自动阈值 通用图片
Floyd-Steinberg 抖动优化 提升细节表现

转换为点阵

经过灰度 + 二值化后,每个像素变成:

1 = 打印
0 = 不打印

例如一行 8 像素:

灰度:  30  80  210 220  60  40  200  190
结果:   1   1    0   0   1   1    0    0

这就是:黑白点阵,然后:

  • 每 8 个像素
  • 压缩成 1 个字节
  • 按行发送给打印机

打印机就会:

  • 第1行按位加热
  • 第2行按位加热

于是图片就“被打印出来”了。

生成点阵数据
function convertImageToRaster(imageData, width, height) {
  const { data } = imageData;
  const bytesPerLine = Math.ceil(width / 8);
  const raster = new Uint8Array(bytesPerLine * height);

  const grayData = new Array(width * height);

  // 灰度化
  for (let i = 0, j = 0; i < data.length; i += 4, j++) {
    grayData[j] = rgbToGray(data[i], data[i + 1], data[i + 2]);
  }

  // 二值化(OTSU)
  const threshold = otsuThreshold(grayData);

  for (let y = 0; y < height; y++) {
    for (let x = 0; x < bytesPerLine; x++) {
      let byte = 0;

      for (let bit = 0; bit < 8; bit++) {
        const px = x * 8 + bit;

        if (px < width) {
          const idx = y * width + px;
          if (grayData[idx] < threshold) {
            byte |= (0x80 >> bit);
          }
        }
      }

      raster[y * bytesPerLine + x] = byte;
    }
  }

  return raster;
}

图片宽度对齐与补零处理

在生成点阵数据时,有一个非常重要的规则:

图片宽度必须按 8 像素对齐

原因

  • 1 字节 = 8 位
  • 每位对应一个像素

因此每一行必须是完整的字节数据。

不对齐的后果

如果宽度不是 8 的倍数:

  • 数据错位
  • 行解析错误
  • 图片打印异常(偏移、乱码)

处理方式

在每一行末尾补 0(白点)

例如:

原始:10 像素
补齐:16 像素(补 6 个 0)

代码中通过以下逻辑天然实现:

const bytesPerLine = Math.ceil(width / 8);

if (px < width) {
  // 原始像素
} else {
  // 自动补 0(白点)
}

ESC/POS 图片指令

最常用指令 GS v 0

function buildImageCommand(raster, width, height) {
  const bytesPerLine = Math.ceil(width / 8);

  const header = [
    0x1D, 0x76, 0x30, 0x00,
    bytesPerLine & 0xFF,
    (bytesPerLine >> 8) & 0xFF,
    height & 0xFF,
    (height >> 8) & 0xFF
  ];

  const result = new Uint8Array(header.length + raster.length);
  result.set(header);
  result.set(raster, header.length);

  return result;
}

本质仍然是:一段 ESC/POS 字节流

BLE 图片打印注意事项

在实际使用中,图片打印相比文本更容易出现失败或效果不佳,主要需要注意以下几点:

  • 控制图片尺寸
    • 图片宽度不要超过打印机最大宽度(58mm 机型通常为 384px)
    • 图片越大,数据量越大,传输时间越长,失败概率也越高
  • 简化图片内容
    • 尽量使用黑白图
    • 减少灰度和细节
    • 避免复杂图案或高精度图片
  • 控制发送节奏
    • 单包数据不超过 20 字节
    • 发送间隔建议 ≥ 30ms
    • 必须按顺序逐包发送,避免并发写入
  • 做好数据缓存
    • 对于固定图片(如 Logo、二维码),建议提前转换为点阵数据
    • 避免每次打印都重复处理图片
  • 合理评估使用场景
    • BLE 图片打印更适合:小尺寸图片、简单标识或二维码
    • 不适合:大图、长图、高精度图片、高频连续打印场景

八、总结

本文围绕钉钉小程序 + BLE + 便携打印机的方案,从技术选型、GATT 通信模型、连接生命周期管理,到 ESC/POS 指令构建、分包传输与图片打印,完整梳理了移动端蓝牙打印的工程链路与关键实践,为类似场景下的实现提供参考。

希望本文的实践经验,能对你在 BLE 蓝牙打印的探索之路上提供些许帮助。

做了 6 年前端,技术不差却拿不到 Offer?

作者 ErpanOmer
2026年4月27日 11:19

最近面了一个有着 6 年工作经验的前端候选人。

2026年4月27日 11_05_32.png

他的简历写得很漂亮:熟练掌握 React 19 核心特性,深入理解 Fiber 调度原理,精通 Webpack/Vite 工程化调优 。前半个小时的八股文环节,他答得滴水不漏。

但在最后的工程场景定级环节,我给他写了不通过❌。

因为我发现,他干了 6 年,脑子里装满了各种高大上的底层源码原理,但当面对真实的、极其恶心的业务泥潭时,他写出来的代码,和一个干了 3 年的初中级前端没有任何区别。

到底什么是工程能力?咱们不扯虚的沟通大局观,直接拿三道真实的高级面试场景题,看看 3 年经验和 6 年老兵在代码实现上的天壤之别👇。


面对 2000 行的屎山表单,你该怎么破局?

我问: 现有一个承载了上百个字段、几十种联动规则(选了A,B必填,C清空,D发请求)的巨型表单。前人写了 2000 行代码,每次改动都容易出线上 Bug,你接手后怎么重构?

那个 6 年经验的候选人是怎么答的? 他说:我会把大表单拆分成多个小组件,用 Context 往下传状态,然后用 useMemouseCallback 优化渲染性能。

这是极其典型的初中级思维。他满脑子想的只是组件拆分,但根本没解决业务逻辑混乱的问题。

解法呢?🤷‍♂️

引入规则引擎(Rule Engine)彻底解耦视图与逻辑。

真正的重构,是绝对不允许业务联动逻辑继续堆砌在视图层的。我会直接引入策略模式和发布订阅,手写一个轻量级的表单联动引擎:

// 联动规则引擎
class FormRuleEngine {
  constructor() {
    this.rules = new Map();
    this.formState = {};
  }

  // 注册联动规则
  registerRule(field, strategyFn) {
    this.rules.set(field, strategyFn);
  }

  // 统一的状态更新入口,内部触发联动链条
  updateField(field, value) {
    this.formState[field] = value;
    
    // 触发策略校验,不污染 UI 组件
    if (this.rules.has(field)) {
      const strategy = this.rules.get(field);
      strategy(value, this.formState, this.dispatchAction.bind(this));
    }
  }

  // 执行具体的联动动作(显隐、清空、拉取接口)
  dispatchAction(targetField, actionType, payload) {
    // 具体的派发逻辑...
  }
}

// 业务层面的配置化,极其清爽
const engine = new FormRuleEngine();
engine.registerRule('userType', (val, state, dispatch) => {
  if (val === 'VIP') {
    dispatch('discountCode', 'SHOW');
    dispatch('balance', 'FETCH_API');
  } else {
    dispatch('discountCode', 'HIDE');
  }
});

发现区别了吗?

初级前端在用几十个 useEffect 监听状态变化,互相缠绕,最后导致无限死循环渲染。 对于前端老兵,应该直接跳出 React/Vue 的框架束缚,用纯 JS 面向对象的思维,把联动逻辑做成了一套配置化、可独立进行单元测试的底层引擎。UI 只是这套引擎的渲染外壳而已。


你只会用 Promise.all 吗?

面试题:业务线有一个批量导出需求,需要前端向服务端并发发送 1000 个请求。由于浏览器对同一域名的连接数有限制,如果直接发会导致网络层阻塞甚至崩溃。你该怎么处理?

候选人听到这里,立刻自信作答:我会自己写一个分组逻辑,比如每次截取 10 个请求,用 Promise.all 跑完,再用 setTimeout 或者递归跑下一批 10 个。

代码写出来大概是这样的:

// 分批 Promise.all
for (let i = 0; i < urls.length; i += 10) {
  const batch = urls.slice(i, i + 10);
  await Promise.all(batch.map(url => fetch(url)));
}

这段代码能跑吗?

能跑。严苛的并发场景里,这是极其低效的。 为什么?因为 Promise.all 的机制是 木桶效应。如果这 10 个请求里,有 9 个只需 100ms 就能返回,而 1 个卡了 5 秒,那么整批任务都会被阻塞 5 秒,导致并发池的大量浪费。

解法:手写一个带有最大并发限制的 - 异步任务调度器。

一个干了 6 年的高级前端,必须具备底层任务调度的能力。绝不等待整批完成,而是只要有一个请求回来,立刻把下一个请求塞进并发池,把带宽压榨到极致:

// 企业级并发任务调度器
class ConcurrencyScheduler {
  constructor(maxConcurrent) {
    this.maxConcurrent = maxConcurrent; // 最大并发数
    this.runningCount = 0; // 当前运行的任务数
    this.queue =[]; // 等待队列
  }

  add(task) {
    return new Promise((resolve, reject) => {
      this.queue.push(() => task().then(resolve).catch(reject));
      this.runNext();
    });
  }

  runNext() {
    // 只有在没达到并发上限,且队列有任务时才执行
    if (this.runningCount < this.maxConcurrent && this.queue.length > 0) {
      const task = this.queue.shift();
      this.runningCount++;
      
      task().finally(() => {
        this.runningCount--;
        // 一个任务执行完,立即递归调度下一个任务!绝不干等!
        this.runNext(); 
      });
    }
  }
}

// 优雅的业务调用
const scheduler = new ConcurrencyScheduler(6); // 限制并发为 6
urls.forEach(url => {
  scheduler.add(() => fetch(url)).then(res => console.log('拉取完毕'));
});

当你能在白板上或编辑器里敲出这段代码时,面试官看你的眼神都会变。因为这段代码背后,体现的是你对事件循环机制的深度理解,以及对浏览器 IO 底层原理的精准拿捏🤔。


内存泄漏,你真的懂排查吗?

线上出现极其偶发的 OOM(内存溢出)导致页面崩溃,无法稳定复现,Lighthouse 和本地压测全都是正常的,你该怎么破局?

初级前端的回答往往是:我会排查一下是不是定时器没清理,或者事件监听没有 remove,然后用 Chrome 的 Memory 面板抓个快照看看。

这是背书😖。

真正在一线查过线上复杂 OOM 的人都知道,这种偶发的内存泄漏,用本地排查法根本抓不到。因为那是极端的业务边界触发的。

👉 建立全局的监控系统。

// 线上对象垃圾回收监听
const registry = new FinalizationRegistry((heldValue) => {
  // 当对象真正被 V8 垃圾回收时,这个回调才会触发
  console.log(`[GC 监控]: 大组件/大对象 ${heldValue} 已被成功回收释放`);
});

function mountHugeComponent(componentData) {
  const domNode = renderComponent(componentData);
  
  // 把可能泄漏的 DOM 节点注册到 V8 的清理注册表里
  registry.register(domNode, componentData.id);
  
  return domNode;
}

// 配合业务打点埋点
// 如果用户切换了 20 次路由,但我们日志里只有 5 次 GC 监控日志
// 就能在生产环境精准实锤:哪一类组件存在隐性引用没有被释放!

这种技术手段,能让你在毫无头绪的线上灵异事件中,用极其极客的思路。


跳出八股文👋

为什么很多人做了 6 年,技术很好,但大公司几乎不要?

因为你花了大量时间去背诵尤雨溪是怎么写 Vue 源码的,但从没思考过,如果把尤雨溪放到你那个烂摊子一样的业务项目里,他会写出什么样的重构代码?

把手弄脏,去解决最恶心的问题。不要把 6 年活成了 验重复用6次。当你能在烂泥潭里写出极具美感的工程方案时,大厂的 Offer,自然是你的囊中之物。

祝大家好运👏

好运速来.gif

❌
❌