1. 进入封装阶段,先把“封装”拆成三件事
V1 项目进入“封装”节点那天,我原本以为活儿不大:软件打几个包,硬件导几份光绘,再写一份总结文档就收工。真正开工后才发现,团队里每个人嘴里的“封装”根本不是同一个词——软件在说函数、接口、模块的收口,硬件在说原理图符号、PCB焊盘、封装库,而产线那边说的又是外壳、打样和装配。为了不让“封装”二字把大家带沟里,我先把这次 V1 涉及的封装对象拆了一遍,然后再分头推进。
这次 V1 项目是个软硬一体的设备端产品。硬件主控用了 Cortex-M 级别的 MCU,外围搭配 EMMC 存储、Type-C 16Pin 接口、电源用的 PWR2.5 座子,板上器件从 0603、0805 的阻容到 TSSOP-10、SOP-20W 这类小封装 IC 都有。软件侧则是“管理后台 + H5 端 + 设备端”三件套,管理后台要对接大模型做 AI 交互,H5 端要兼容两个正式域名。这里面的“封装”任务可细分成三类:
- 代码封装:请求库的二次封装,接口的收口,AI 流式输出的模块封装,以及生成器/迭代器这类处理数据结构的封装。
- 硬件封装库:建原理图符号、PCB焊盘、芯片封装,整理 AD/Allegro 的封装库,并处理跨软件转换。
- 系统/协议层面的封装:把设备协议、分发方式、系统镜像做成统一可复用的“外壳”。
这样一分,事情就很清楚了。网上被频繁搜索的“封装”关键词也基本都是这三块:要么是“axios 二次封装”“uniapp 封装 H5”“SSE 流式输出配合 abort”这类软件话题,要么是“0603 封装尺寸”“AD 封装库”“Allegro 封装制作流程”这类硬件话题。很多朋友其实是被其中的某一块卡住了,但“封装”这个总关键词把他们聚集到了同一屏搜索结果里。这篇就以这次 V1 为主轴,把三类封装分别讲透,结尾再给一份可以直接拿去用的封装复盘清单。
2. 软件侧:从请求封装到迭代器封装的落地细节
2.1 axios 二次封装:拦截器比想象中重要
先说后台管理端。前端技术栈是 Vue3 + Vite,网络层我选了 axios,但没有直接在最外层写一堆业务逻辑,而是先做了一层“中间件”式的封装。直接给出骨架:
import axios from 'axios' import store from '@/store' import router from '@/router' const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 15000, }) service.interceptors.request.use( (config) => { const token = store.getters.token if (token) { config.headers['Authorization'] = `Bearer ${token}` } return config }, (error) => Promise.reject(error) ) service.interceptors.response.use( (response) => { const res = response.data if (res.code !== 0) { if (res.code === 401) { store.dispatch('logout') router.push('/login') } return Promise.reject(new Error(res.message || '请求失败')) } return res.data }, (error) => { return Promise.reject(error) } ) export default service这套封装的核心不是“少写几行请求代码”,而是把鉴权、错误码、超时处理统一收口。很多朋友第一次写 axios 封装时只顾着把 baseURL 和 token 放进去,却忽略了响应的“拆包”逻辑。实际用起来才发现,接口返回的数据结构如果不在这里统一拆掉,业务组件里就会到处出现res.data.data.data,而且一旦后端改了返回结构,你能体会到拆地雷式改文件的痛苦。所以我在 interceptor 里直接约定:后端统一返回{ code, message, data },code 非 0 视为异常,401 直接踢回登录页。这样业务层拿到的永远是最干净的 data。
提示:这里的 code 约定是 0,很多后端习惯用 200,不管哪种,前后端在接口文档里必须先统一,否则封装层再怎么写都是白搭。
2.2 小程序与 uniapp 场景下的请求封装差异
除了后台管理端,这次 V1 还有一个 uniapp 编写的 H5 端。如果你真的用过 uniapp,会发现它默认没有 axios,而是自带的 uni.request。直接把 axios 那套搬过来是不行的,因为 uni.request 的回调风格、拦截器实现都不太一样。做 uniapp 的请求封装,我习惯包一层 Promise:
const request = (options = {}) => { return new Promise((resolve, reject) => { uni.request({ url: options.url, method: options.method || 'GET', data: options.data || {}, header: { 'Content-Type': 'application/json', ...(options.header || {}), }, success: (res) => { if (res.statusCode >= 200 && res.statusCode < 300) { const body = res.data if (body.code === 0) { resolve(body.data) } else { uni.showToast({ title: body.message || '请求失败', icon: 'none' }) reject(new Error(body.message)) } } else { reject(new Error(`HTTP ${res.statusCode}`)) } }, fail: (err) => reject(err), }) }) } export default request注意这里把鉴权 token 的注入放在 header 里,没额外写拦截器。原因是 uniapp 的拦截器在不同平台(H5、小程序、App)上的表现不完全一致,早期版本还有不少兼容问题。为了让 V1 能按时交付,我宁愿用最朴素的 Promise 包裹,也不引入额外一层不确定性。这里也给新手一个建议:封装不是越复杂越好,复杂封装带来的抽象成本和你自己的调试成本要成正比。
2.3 生成器与迭代器封装函数:处理树形数据的利器
代码封装里另一个容易被忽略的是数据结构处理。这次 V1 的权限菜单是树形结构,后端返回的是一份扁平列表,需要前端自己加工成嵌套树。如果每次都在业务组件里递归,不仅代码冗余,而且很容易因为深拷贝不及时把原数组改了。我封装了一个用生成器实现的树遍历函数:
function* walkTree(nodes, childrenKey = 'children') { for (const node of nodes) { yield node if (Array.isArray(node[childrenKey]) && node[childrenKey].length) { yield* walkTree(node[childrenKey], childrenKey) } } } // 使用示例:扁平列表转树,保留原数据不变 const buildTree = (flatList, pidKey = 'pid', idKey = 'id') => { const map = new Map() const roots = [] flatList.forEach((item) => map.set(item[idKey], { ...item, children: [] })) flatList.forEach((item) => { const node = map.get(item[idKey]) if (item[pidKey] && map.has(item[pidKey])) { map.get(item[pidKey]).children.push(node) } else { roots.push(node) } }) return roots }这段代码看着简单,它其实已经把手头两个问题各自封装好了:扁平数据转树、按生成器协议遍历树。生成器的好处是惰性执行——你只在需要遍历的时候一点一点取,而不是一次性把整棵树的递归结果全塞进内存。配合迭代器协议,后续要对菜单做权限过滤、节点搜索、展开状态还原,都直接调用同一个函数,业务代码会干净很多。
封装继承多态这些老生常谈,放到真实项目里其实就是“把变化隔离出去、把公共逻辑收敛回来”。你不用背一堆名词,动手写一两次自然就理解了。
3. AI 交互逻辑封装:SSE 流式输出与 abort 的配合实战
3.1 为什么 AI 对话不能用普通 fetch 一把梭
V1 项目的管理后台需要接入大模型做智能助手,核心体验是“回答要实时渲染”。一开始团队有人提议直接用普通 fetch 请求,把完整的 JSON 拿回来再一次渲染,结果用户反馈很直接:“点完发送按钮,两三秒屏幕没动静,以为卡死了。” 这其实是所有流式交互的通病:人在等待超过 1 秒时就会开始焦虑。
要解决实时渲染,最主流的方案是 SSE(Server-Sent Events)。大模型的接口基本都支持流式返回 token,服务端会把内容一段一段推下来。这里的“封装”不是说调一个流式接口那么简单,而是要把连接建立、数据解析、异常退出、手动停止都包成一个可复用的模块。如果每个页面都自己写 fetch + ReadableStream,代码会以肉眼可见的速度腐烂。
3.2 封装一个带 abort 控制的 SSE 模块
我最终封装了一个SSEChannel类,核心代码如下:
class SSEChannel { private controller: AbortController | null = null private reader: ReadableStreamDefaultReader<Uint8Array> | null = null private buffer = '' private onMessage: (text: string) => void private onDone: () => void private onError: (err: unknown) => void constructor( private url: string, handlers: { onMessage: (text: string) => void onDone?: () => void onError?: (err: unknown) => void } ) { this.onMessage = handlers.onMessage this.onDone = handlers.onDone || (() => {}) this.onError = handlers.onError || (() => {}) } async start() { this.controller = new AbortController() try { const resp = await fetch(this.url, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream', }, body: JSON.stringify({ /* 你的请求参数 */ }), signal: this.controller.signal, }) if (!resp.ok || !resp.body) { throw new Error('SSE 连接建立失败') } this.reader = resp.body.getReader() const decoder = new TextDecoder('utf-8') while (true) { const { value, done } = await this.reader.read() if (done) break this.buffer += decoder.decode(value, { stream: true }) const lines = this.buffer.split('\n') this.buffer = lines.pop() || '' for (const line of lines) { if (!line.startsWith('data:')) continue const data = line.slice(5).trim() if (data === '[DONE]') { this.onDone() return } try { const json = JSON.parse(data) const content = json.choices?.[0]?.delta?.content || '' if (content) this.onMessage(content) } catch { // 忽略无法解析的中间帧 } } } } catch (err) { if ((err as Error).name === 'AbortError') { // 主动停止,不视为异常 return } this.onError(err) } } abort() { this.controller?.abort() this.reader?.cancel().catch(() => {}) } }这里有几个关键点很多人会忽略。第一,为什么用 fetch 而不用 EventSource?因为 EventSource 不支持自定义请求头,也不支持 POST 带 body,而现在多数大模型接口都是 POST。第二,为什么要在 while 循环里手动split('\n')?因为 SSE 是按事件流一帧一帧推下来的,底层 ReadableStream 每次返回的 chunk 长度不固定,可能在任意字符处断掉。如果不做缓冲区处理,经常会出现半截 JSON 解析失败。第三,abort 的作用是人工停止生成,用户点击“停止生成”按钮时调用this.controller.abort(),底层连接立即断开,前端 UI 马上停住。
3.3 配合 abort 时踩到的几个坑
封装模块已经写出来了,真正跑起来才发现坑都在细节里。
- 缓冲字符问题。上面代码里的
this.buffer必须保留不完整的尾行,否则会出现每帧都解析失败的情况。我第一次写的时候忘了做 buffer,结果屏幕上只出字,但 console 里全是 parse error,查了半天才发现是流分片导致的。 - abort 和 onError 的竞态。调用
abort()后浏览器会抛一个AbortError,如果 catch 里不加判断,会把主动停止也算成异常,弹一个“连接中断”的错误提示。后面我改成判断err.name === 'AbortError',主动停止就静默处理。 - 大模型输出的 markdown 渲染。流式输出拿到的是 token 片段,不能每收到一段就整段重新渲染,否则用户滚动时会看到光标跳动;我最终采用了“增量追加到缓冲区 + requestAnimationFrame 节流渲染”的方案,一帧内只渲染一次,体验明显顺滑。
- 后端 SSE 与 nginx 的缓冲。服务端跑在 nginx 后面,nginx 默认会缓冲响应,导致你在前端等半天收不到第一个 token。需要把
X-Accel-Buffering设为 no,并在 nginx 中关闭相关缓冲,这些要在部署文档里写清楚,否则接手运维的人会很痛苦。
这节内容放在整个 V1 的角度看,就是把 AI 交互逻辑封装成了一个黑盒:业务组件只需要new SSEChannel(url, handlers)然后start(),所有协议细节都藏在模块内部。后续如果要加重新生成、历史记录、流式输出开关,改动范围都被锁在这一个文件里,这就是封装带来的直接收益。
4. 硬件侧的大头:PCB 封装库整理与转换的真实经历
4.1 跟着一堆封装尺寸和型号做斗争
硬件这块,V1 项目电路板上的器件不算多,但器件类型很杂:电源部分的 PWR2.5 座子、卧贴 4.5×4.5mm 轻触开关、Type-C 16Pin 连接器、EMMC 存储芯片、STM32H7 系列主控,还有一批 0603/0805 的阻容、TSSOP-10 的器件、SOP-20W 的驱动芯片。说到建封装库,第一个要面对的就是“尺寸地狱”。
以最常见的 0603 和 0805 为例:
- 0603 封装:外形 1.6mm × 0.8mm,焊盘建议宽度 0.8mm,焊盘间距约 0.8mm(以厂商手册为准)。
- 0805 封装:外形 2.0mm × 1.25mm,焊盘尺寸相应放大到 1.0mm 左右。
这些数字如果只凭记忆乱填,生产时很容易和钢网、回流焊工艺打架。正确做法是每个封装都去查原厂数据手册的 recommended land pattern,而不是抄别的板子上的现成封装。
还有一些容易让人迷惑的型号。比如“2.5×3mm 是什么封装型号”,这个问题单独看没法回答,因为外形尺寸并不能唯一确定封装型号,可能是 SOT 系列、晶振封装、或者某些特殊二极管封装,必须结合引脚数量和功能手册才能判断。我的习惯是:凡是遇到叫不出名字的封装,先把手册里的三视图截下来,标上外形、间距、焊盘尺寸,放进一个“待确认封装”文件夹,等确认后再转移到正式库。如果后续业务做到 SiP、3D 堆叠这类东西,还需要专门补半导体先进封装技术的知识,那和普通 PCB 封装完全是两套体系。
4.2 AD/Allegro 封装制作与转换流程
V1 项目原先在 Altium Designer(AD)里画原理图,后来部分信号完整性仿真要迁到 Cadence Allegro 环境,封装库必须跟着转。这一步是最容易翻车的。AD 的封装文件用的是.PcbLib,Allegro 用的是.dra/.psm,两者本质上是两种完全不同的数据模型。我试过直接导入,结果焊盘形状、丝印层、参考点全都错位,最后老老实实按标准流程重新做:
- 在 Allegro 中设置好用户环境变量:
padpath、psmpath,指向自定义封装库目录。 - 用 Pad Designer 制作焊盘,先做 flash symbol(热焊盘)和 regular pad,再设置钻孔尺寸、通孔属性。
- 用 Package Symbol 编辑器创建封装,放置焊盘、添加装配层位号、丝印外框、约束区域,并设置 refdes 和 value 的字体大小。
- 检查 Pin Number 顺序。这一步非常关键,AD23 里如果焊盘顺序需要重新按顺序编号,我一般先按原理图 symbol 的管脚顺序列出映射表,再在封装编辑器里逐个检查,避免用“自动编号”产生隐蔽错位。
封装转换同样要考虑 AD 导入 PADS、AD 转 Allegro 这类场景。常规做法是用 ASCII 格式作为中间桥梁,但无论哪条路径,转完后必须做一个“3D 模型比对”:让封装工程师把两种软件里的封装都导出成 STEP 模型,放进同一坐标系叠一下,看丝印、焊盘、实体高度是否一致。V1 项目里就是因为漏了这一步,一个连接器的封装从 AD 转 Allegro 后焊盘中心偏移了 0.2mm,打样回来才发现,硬生生浪费了一版板子。
4.3 常用封装尺寸参数表与识别引脚的技巧
整理封装文档时,我会把常用的封装参数做成表格,方便团队直接对照。
| 封装名称 | 常见外形/间距 | 备注 |
|---|---|---|
| 0603 | 1.6mm × 0.8mm | 焊盘宽度约 0.8mm |
| 0805 | 2.0mm × 1.25mm | 功率稍大的阻容 |
| TSSOP-10 | 引线间距 0.5mm | 本体宽约 3.0mm |
| SOP-20W | 引线间距 1.27mm | 宽体封装 |
| EMMC BGA-153 | 球距约 0.5mm | 需要看芯片规格书球排列 |
| Type-C 16Pin | 引脚间距 0.5mm | 注意电源/信号引脚定义 |
| PWR2.5 | 适配 2.5mm 电源座 | 孔径按端子规格确认 |
“封装怎么识别引脚”也是新手常问的问题。我总结的经验是四看:一看位号旁的丝印圆点或斜角(Pin 1 标记);二看芯片手册的 TOP VIEW 图;三看底视图和顶视图是否镜像(BGA 往往容易搞反脚位);四看原理图 symbol 和 PCB 封装的 Pin Number 映射。一句话,所有引脚识别最后都落到“数据手册”四个字上。
还有一个特别容易踩的坑:只看外形相似就套用封装。比如 DB9 和 DB15 接口,远看都是 D-sub 形状,但如果直接套用封装,轻则插不进去,重则烧板子。DB9 和 DB15 的引脚数量、引脚间距、外壳尺寸都不一样,不能混用。连接器类封装我强烈建议去封装库网站下载原厂推荐封装,或者按官方结构图重建,不要凭着“目测差不多”来做。如果是 XCZU19EG-2FFVC1760 这类大型 BGA FPGA,更是建议直接找官网封装文件再校验,手工慢慢画很容易出错。
5. 协议与系统级封装:DL645 电表接入和多域名 H5 分发
5.1 像封装接口一样封装设备协议
这次 V1 项目里还涉及一个工业场景:现场要采集 DL645-2007 电能表的数据,上层平台要拿到统一的电表读数。如果直接在业务系统里写一套 DL645 的报文解析代码,那么以后换一块支持其他规约的电表,代码又要重写。我的做法是把它当作一次“协议封装”来处理:维持上层接口不变,底层适配不同的设备规约。
具体到技术栈,我用了 Kepware 作为中间连接件。Kepware 是工业协议网关软件,可以把各种各样的设备协议(Modbus、DL645、OPC UA 等)统一变成上层可访问的数据项。这里要做的封装分两层:
- 驱动层:在 Kepware 里建一个 Channel,配置以太网连接,填写电表的 IP 地址和端口。
- 数据映射层:把 DL645-2007 的数据标识(如电能量、瞬时电压、电流)映射成统一的变量名,比如
CurrentEnergy、VoltageA、CurrentA。
这样一来,业务系统只需要订阅 Kepware 暴露的标准接口,完全不用关心底层是 DL645 还是 Modbus。演示效果就是:一套软件,今天接 DL645 电表,明天接 Modbus 电表,只要改 Kepware 内部的驱动配置,业务代码一行不改。这个过程本质上是“把设备的方言封装成普通话”,和代码里的接口封装思路完全一致。
实际踩坑是地址格式。DL645 的报文里数据地址是四个字节十六进制,很多驱动文档里写地址时要倒序或者按规约的偏移量换算,第一次配的时候怎么都读不到数据,最后是抓包看规约帧,才确认地址映射需要从数据标识低字节开始填。强烈建议在联调前先准备一个 DL645 模拟器,把报文打印出来和实际设备比对,能省大量现场排查时间。
5.2 uniapp 封装 H5 如何指向两个域名
H5 端这次要支持两个正式域名:一个面向内部环境,一个面向公网客户。直接在某一个页面里写死 API 地址肯定不行,正确做法是把域名配置提升到“构建环境”层面。
我在 uniapp 项目根目录放了.env.development、.env.production两组变量,里面定义VITE_API_BASE_URL和VITE_WEB_BASE_URL。打包时按目标环境分别构建:
# 构建 OA 环境包 VITE_API_BASE_URL=https://oa-api.example.com VITE_BUILD_TARGET=oa npm run build:h5 # 构建公网环境包 VITE_API_BASE_URL=https://public-api.example.com VITE_BUILD_TARGET=public npm run build:h5打包脚本里用 cross-env 设置环境变量,代码里统一通过import.meta.env.VITE_API_BASE_URL读取。这样 H5 项目虽然是同一套代码,但生成的两个静态包指向不同 API 域名,两个域名互不干扰。如果将来要上微信公众号或者 App 内嵌,只要在 H5 封装分发平台上传对应构建包就行,代码不用再动。
这里还有一个容易被忽略的体验问题:H5 封装分发平台的更新机制。如果 H5 包被分发到 App 的 WebView 里,最好的更新方式是让 App 每次冷启动时请求一次最新版本号,与本地缓存的包版本做对比。这个“版本检查 + 拉新包”的逻辑,最好也封装成一个独立模块,因为一旦要做灰度发布或者限流,你只需要改这一个模块。
5.3 系统封装的一个类比
如果项目涉及 Windows 设备交付,还会碰到 sysprep 这类系统封装工具。sysprep 的本质是把一台电脑上的驱动、用户配置、软件授权信息“抽象掉”,生成一个干净且可复制的镜像,然后分发给同型号的几十台设备。其实这和代码封装是同一个道理:把个性剥离、把共性固化。我每次跟团队讲“为什么要封装”时,都会搬这个类比出来,大家一下子就懂了。
6. V1 复盘:封装规范化清单与踩坑记录
6.1 封装前先回答五个问题
V1 忙完之后,我把封装相关的工作复盘了一遍,最大的收获是在大规模封装之前,应该先让团队回答五个问题:
- 封装的边界在哪里?这个模块/库应该对外暴露什么,隐藏什么?
- 命名规范是否已经定义?AD/Allegro 库、接口函数、环境变量,都必须有统一前缀或命名法则。
- 版本怎么管理?封装库和代码库一样需要 git 管理,封装库的变更记录也要可以 diff 回溯。
- 有没有测试用例?软件封装要有单元测试,硬件封装要有 3D 模型比对和试产验证。
- 谁负责维护?没有 owner 的封装库,三个月后就会变成一群谁都不敢动的陈旧代码。
其中命名规范和 git 版本管理最容易被省掉。V1 里我坚持把所有 PCB 封装库放进 git 仓库,配合 diff 工具比对封装库的版本差异,每次改动都能看到是哪个焊盘、哪层丝印发生了变化。软件侧也一样,vue 项目中封装函数的 git 版本差异比对,可以快速定位“以前能用现在不能用”是哪个 commit 引起的。很多项目到后期混乱,就是因为没有版本管理这个“后悔药”。
提示:硬件封装库最好把“封装名 + 创建日期 + 作者”写进自定义属性里,导出 BOM 和坐标文件时这些信息会跟着带出来,排查问题时非常有用。
6.2 一份可以直接抄走的检查清单
最后分享一份我在 V1 项目里整理出来的检查清单,可以当作团队评审的模板:
| 检查项 | 说明 | 状态 |
|---|---|---|
| 数据库/接口字段命名 | 统一 camelCase 或 snake_case | [ ] |
| 请求封装含鉴权与错误码 | 401 处理、HTTP 错误统一拦截 | [ ] |
| AI 流式输出支持 abort | 停止生成按钮和断线重连 | [ ] |
| PCB 封装焊盘编号顺序 | Pin1 与丝印一致,BGA 注意方向 | [ ] |
| 封装库 3D 模型比对 | AD/Allegro/PADS 互转后确认尺寸 | [ ] |
| 连接器封装选用官方推荐 | 不凭目测套用相近封装 | [ ] |
| 协议接入的可配置化 | 切换设备规约不改业务代码 | [ ] |
| H5 多域名构建配置 | 环境变量进入构建产物 | [ ] |
| 封装对象纳入 git 管理 | 可 diff、可回溯、有 owner | [ ] |
坦白讲,V1 项目并不是每个封装都做得完美。数据库那层的字段命名,因为一开始没定死,后期花了不少时间重构;AI 流式模块的 abort 竞态,也差点上线前没测出来。但正是这些问题让我认识到:封装不是某个时间节点的临时动作,而是贯穿整个开发周期的设计意识。每次动手封装前多想一步“这东西将来会怎样被复用”,养成了习惯,V1 的教训就会变成 V2 的本能。
以上是这次 V1 项目封装细节里能完整公开的部分。个人实际体会是,最具价值的不是封装本身,而是封装迫使你把边界理清楚——软件模块的边界、硬件焊盘的边界、协议兼容的边界。边界清楚了,项目后续的扩展和交接都会轻松很多。