ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vue3+TypeScript实现纯前端进制转换器:算法与组件设计

Vue3+TypeScript实现纯前端进制转换器:算法与组件设计 前两周整理内部工具库临时需要一个不依赖在线接口、纯前端就能跑的进制转换器。本来想从网上随便找个现成的拖进来结果发现要么UI样式和外框格格不入要么只支持十进制输入、拿到二进制和十六进制还是得先手算一轮。正好那阵子在把项目从 Vue2 往 Vue3 迁“用 Composition API 重写一个小工具顺手验证新语法”的念头一冒出来就有了这篇文章里这套方案。完整项目里包含输入校验、小数转换、批量行录入和剪贴板复制代码都贴在下面想抄作业的直接抄。我做这个转换器时定的目标是打开页面就能用支持 2/8/10/16 进制互转输入带小数、负数都得能算遇到非法字符要当场提示而不是算出来一个 NaN 再让用户干瞪眼。往深了说这也是在给团队沉淀一个可复用组件后续做调试工具、协议解析面板都能直接引。所以这篇会从设计分层讲到具体实现算法原理和踩坑记录都会交代清楚。1. 项目定位与设计思路1.1 为什么需要一个纯前端的进制转换器先说需求来源。做网络调试、嵌入式设备配置、串口通信的时候经常会拿到十六进制报文比如设备返回0x1A 2F要转成十进制查看温度值反过来写寄存器参数又得把十进制数拼成十六进制协议帧。这种转换频率高、数值还经常嵌套在长文本里靠手机计算器或者在线网页一次一次复制粘贴效率低是小事粘错一位才麻烦。纯前端实现的核心价值在于离线可用、响应快、不用把数据送到第三方服务。尤其调试现场可能在内网环境在线转换工具根本打不开。用 Vue3 这样的框架来做还有个额外好处组件能顺手嵌进现有后台变成“调试工具箱”里的一个 Tab而不是孤零零一个网页。还有一种更常见的场景是给小学/初中生做编程启蒙讲解二进制和十六进制时直接在网页上演示“十进制 255 为什么等于二进制 1111 1111”。此时交互要实时、步骤要能看清纯展示无关紧要的案例反而更好。1.2 技术选型为何锁 Vue3 TypeScript Vite选 Vue3 不单是因为新而是 Composition API 在“一个页面里聚合多个转换逻辑”时确实清爽。四个进制面板如果还用 Options API 写每个面板的data、watch、methods都要各来一遍不少代码是复制粘贴的。Composition API 允许我把转换逻辑抽成一个 hook面板组件里只关心展示和事件逻辑层和视图层彻底分离。TypeScript 解决的是“进制枚举值”满天飞的问题。base到底是number还是string转换函数该接收什么类型没有类型约束时最容易出错。我定义了一个Radix联合类型2 | 8 | 10 | 16不在这四个值里的进制根本无法通过编译这比运行时做一堆if判断可靠得多。Vite 选得没有悬念启动快到什么程度不用多吹。我初始化项目的时候直接把vue-ts模板拉下来零配置就能同时跑 TS 类型检查和 HMR 热更新。有一点要注意如果项目最终要嵌入后台系统Vite 的server.proxy需要配好否则 dev 环境下请求后台接口会出现跨域问题。1.3 交互模型从“多输入框”到“源值 源进制”我最开始画原型时犯过一个错误画了四个输入框二进制一个、八进制一个、十进制一个、十六进制一个用户改哪个其他三个跟着变。听起来很符合直觉真用起来就发现问题了用户想输入的“源数值”到底该写在哪个框里如果手头拿到的数据是二进制他会去二进制框里输入拿到十六进制又去十六进制框输入。结果就是“源值”永远没有固定位置四个框都得做输入处理校验逻辑要复制四份。后来改成“源值 源进制”单一模型页面顶部一个输入框旁边一个下拉框选定当前输入的进制下面一排展示其他三种进制的换算结果。用户拿到什么进制就往里填什么再告诉转换器这是什么进制剩下的全是计算。这个交互模型的要点是“源值”状态永远只有一份。顶层组件只维护sourceText和sourceBase至于输入的是二进制还是十六进制组件不关心转换函数能根据sourceBase正确解析就行。四个按钮复制结果互不干扰因为每个目标进制的结果都是独立 computed 算出来的。2. 工程初始化与环境配置2.1 一条命令搭建 Vue3 TS 开发环境项目初始化用 Vite 官方脚手架注意 Node 版本要 18 以上否则 create-vite 会因版本校验失败。我执行的是npm create vitelatest radix-converter -- --template vue-ts cd radix-converter npm install npm run dev装完之后我会立刻装两个依赖一个是sass一个是vueuse/core。前者是为了在组件里直接写langscss否则 cli 模板里的样式只能写纯 CSS嵌套写法、混合宏全部不能用后者用来简化复制、按键监听等常用逻辑比自己手动封装navigator.clipboard干净很多。npm i -D sass npm i vueuse/core关于sass多说一句现在新版本用的是sass包而不是已经废弃的node-sass装完不需要额外配置Vite 会自动识别.scss文件。如果你在旧项目里碰到Cannot find module node-sass不用去补装 node-sass直接把代码里的.scss后缀保留装好sass就能编译。2.2 目录结构与工具函数层划分我对组件目录划分很看重因为转换器将来大概率不止一个页面用。结构我用了非常常规的分层src/ components/ RadixConverter.vue ConversionRow.vue hooks/ useRadixConverter.ts utils/ radix.ts validators.ts views/ HomeView.vue styles/ variables.scssutils/radix.ts只放纯函数比如decimalToBase、baseToDecimal、formatRadixValue不引入任何 Vue 相关 API。这样将来做单元测试时直接import函数就能跑不需要挂载组件。hooks/useRadixConverter.ts再把这些纯函数包装成响应式逻辑返回ref、computed等。路径别名建议直接在vite.config.ts里配好import { defineConfig } from vite import vue from vitejs/plugin-vue import { fileURLToPath, URL } from node:url export default defineConfig({ plugins: [vue()], resolve: { alias: { : fileURLToPath(new URL(./src, import.meta.url)) } } })配了别名后import { decimalToBase } from /utils/radix不管组件在哪层目录都不会写出“../../utils/radix”这种又臭又长的路径。如果在 TypeScript 里编辑器报警告Cannot find module /utils/radix大概率是tsconfig.json里没加paths映射两个配置要同时维护。2.3 SCSS 全局样式与主题变量样式方面我没有上重型 UI 框架这个工具本来就是内嵌到后台的组件自带 UI 框架反而难统一主题。用 SCSS 变量定义基础色板将来覆盖后台主题只需改这几个变量。styles/variables.scss我比较常放的几组$color-primary: #2f6fed; $color-danger: #e5484d; $color-text: #252433; $color-border: #d8d7de; $color-bg: #f6f6f8; $radius-sm: 6px; $radius-md: 10px; $radius-lg: 16px;注意全局变量文件不要直接在每个组件里import效率低还可能造成重复引入。我在vite.config.ts里用css.preprocessorOptions.additionalData全局注入了一段export default defineConfig({ css: { preprocessorOptions: { scss: { additionalData: use /styles/variables.scss as *; } } } })这样所有.scss文件里可以直接使用$color-primary不用人人写 import。用use而不是import是 SCSS 新规范要求的写法import在新版本里已经开始逐步废弃。3. 进制转换核心算法与边界处理3.1 十进制转任意进制取余法与小数部分的连乘法核心逻辑我用 TypeScript 实现先看整数部分。十进制整数转其他进制最经典的是“除基取余法”不断除以目标进制把每次的余数逆序排列。// src/utils/radix.ts export type Radix 2 | 8 | 10 | 16 const FRACTION_PRECISION 16 export function decimalToBase(value: number, base: Radix): string { if (!Number.isFinite(value)) return const sign value 0 ? - : const absValue Math.abs(value) const integerPart Math.floor(absValue) let fractionPart absValue - integerPart let integerStr integerPart.toString(base) if (fractionPart 0) { let fractionStr let guard 0 while (fractionPart 0 guard FRACTION_PRECISION) { fractionPart * base const digit Math.floor(fractionPart) fractionStr digit.toString(base) fractionPart - digit guard } integerStr . fractionStr } return sign integerStr }integerPart.toString(base)是 JavaScript 的原生转换注意两点第一它自动处理十六进制时的a-f默认返回小写字母第二它只对整数有效所以我特意把小数部分拆出来单独算。小数部分采用“乘基取整法”每次乘以目标进制取整数位作为下一位数字余下的小数继续乘。限制循环次数是必须的因为二进制小数很容易出现无限循环比如 0.1 转二进制是 0.0001100110011...没有这个护栏浏览器会直接卡死到假死。Math.abs和Math.floor是配套使用的。负数如果直接value.toString(2)得到的是-101开头的字符串但包含符号数学上没问题。我用显式sign分开处理好处是后续如果想扩展成补码显示不用再解析符号。3.2 任意进制转十进制按位累加的写法反向转换我一开始直接用parseInt结果被一个真实案例坑了后面再细讲。正确的做法是逐位解析、按位累加export function baseToDecimal(input: string, base: Radix): number { const trimmed input.trim() if (trimmed ) return Number.NaN const negative trimmed.startsWith(-) const unsigned trimmed.replace(/^[-]/, ) const [integerPart , fractionPart ] unsigned.split(.) let result 0 for (const char of integerPart) { const digit parseInt(char, base) if (Number.isNaN(digit) || digit base) return Number.NaN result result * base digit } if (fractionPart) { let scale 1 for (const char of fractionPart) { const digit parseInt(char, base) if (Number.isNaN(digit) || digit base) return Number.NaN scale / base result digit * scale } } return negative ? -result : result }解析的核心公式是result result * base digit。举个例子二进制字符串1011解析过程最开始 0遇到 1 变成 1再遇到 01*202再遇到 12*215最后遇到 15*2111。这个过程其实就是十进制展开式的迭代版本。小数部分我引入了一个scale变量每往下一位就多除以一次base。比如十六进制0.A第一位digit10scale先变成1/16累加结果是10/160.625到第二位0.8时digit8scale变成1/256再加8/2560.03125最终得到0.65625。逻辑清晰还能顺便校验每一位是否合法。3.3 非法输入、负数和精度边界处理进制转换最容易出错的地方不是转换本身而是“这个字符串到底算不算合法输入”。比如十六进制字符串GGparseInt(GG, 16)返回NaN能发现但字符串0x1A呢它带0x前缀parseInt(0x1A, 16)在大多数浏览器里能正确返回 26可我的源值输入框如果允许0x前缀结果就是“既能解析又可能解析错”因为parseInt(0x1A, 2)会解析出 0 而不是报错用户以为输错了还要自查半天。所以我的baseToDecimal干脆不认识0x、0b这些前缀每一位都严格按进制字符校验超出digit base直接返回NaN。这一来输入0x1A时 x 会被判定非法提示用户去掉前缀。习惯写十六进制的人可能会不习惯但只要校验提示文字说清楚“不需要 0x 前缀”一次就学会了。负数允许输入我通过前面的符号处理自动支持。二进制里的“负数用补码表示”这种进阶需求在这版里还没做因为普通调试场景下十进制源值转二进制用户要的是“负号加原码”这种方式补码主要用于位运算后续可以再扩展。精度方面有两处边界。第一处是十进制大整数超过Number.MAX_SAFE_INTEGER9007199254740991时toString(2)还能返回结果但最后几位已经是脏数据了。这种情况我的表格里会加一行提示“数值过大结果可能不精确”。第二处是小数转换截断我只保留 16 位小数末尾可能会带多余的 0展示时可以再做一次尾零清理的格式化。export function trimTrailingZeros(value: string): string { if (!value.includes(.)) return value return value.replace(/\.?0$/, ) }4. 组件实现与交互细节4.1 转换面板组件input select 结果网格整个组件的核心是“一个入口四个出口”。我先定义一个带v-model风格的子组件RadixConverter.vue这样既能直接放在 HomeView 里独立使用也能嵌入到其他表单中做受控输入。script setup langts import { computed, ref } from vue import { baseToDecimal, decimalToBase, trimTrailingZeros, type Radix } from /utils/radix const props withDefaults(defineProps{ modelValue?: string base?: Radix disabled?: boolean placeholder?: string }(), { modelValue: , base: 10, disabled: false, placeholder: 请输入数值 }) const emit defineEmits{ (e: update:modelValue, value: string): void (e: update:base, base: Radix): void (e: change, payload: { value: string; base: Radix; decimal: number }): void }() const sourceText computed({ get: () props.modelValue, set: (value: string) emit(update:modelValue, value) }) const sourceBase computed({ get: () props.base, set: (value: Radix) emit(update:base, value) }) const decimalValue computed(() { const text sourceText.value.trim() if (text ) return Number.NaN return baseToDecimal(text, sourceBase.value) }) const isValid computed(() Number.isFinite(decimalValue.value)) const resultRows computed(() { const bases: Radix[] [2, 8, 10, 16] return bases .filter(base base ! sourceBase.value) .map(base ({ base, label: ${base} 进制, value: isValid.value ? trimTrailingZeros(decimalToBase(decimalValue.value, base)) : 非法输入 })) }) /script模板部分用了一个结果网格每种进制单独一行template div classradix-converter div classconverter-input input v-modelsourceText :disableddisabled :placeholderplaceholder classsource-input / select v-modelsourceBase :disableddisabled classbase-select option :value22进制/option option :value88进制/option option :value1010进制/option option :value1616进制/option /select /div div classresult-grid div v-forrow in resultRows :keyrow.base classresult-row span classresult-label{{ row.label }}/span code classresult-value{{ row.value }}/code /div /div /div /template这里有个细节resultRows过滤掉了源进制本身。比如源值是十进制那么结果列表里只展示二、八、十六进制不再重复展示一个“十进制等于它自己”的无意义行。用户在源进制下拉框里始终看得到当前输入是几进制信息并没有丢失。当输入非法时decimalValue是NaN结果区所有行统一显示“非法输入”。这比每个结果行单独判断一遍要简单也比直接显示NaN更友好。4.2 批量转换动态增删行的实现单行转换器能满足大多数场景但面对“一帧协议里有 20 个字段要转进制”的批量需求就得支持多行动态编辑。我写了一个ConversionRow.vue配合父组件用一个reactive数组维护所有行。import { reactive } from vue interface ConversionRecord { id: number value: string base: Radix } let seed 1 const records reactiveConversionRecord[]([]) function addRecord() { records.push({ id: seed, value: , base: 10 }) } function removeRecord(id: number) { const index records.findIndex(record record.id id) if (index 0) { records.splice(index, 1) } }模板里用v-for渲染每行组件内部维护自己的文本框和下拉框。注意这里有个 Vue3 响应式的小坑reactive数组里直接push一个整对象对象是可以被响应的但如果你要“赋值替换整个数组”应该用splice或重新赋值records.length 0再 push直接records newArray会丢失响应式代理。我在 4.3 节里遇到了历史记录清空的场景清空用的是records.splice(0, records.length)。每行删除按钮要确认吗我没有加确认弹窗因为批量录入场景用户删错行的频率不高而且重新输入也不复杂。倒是“添加一行”按钮我固定放在表格左上角随加随用效率优先。4.3 历史记录与复制功能增强转换器用顺手以后历史记录是很有价值的功能。我把每次合法转换的操作都存进一个refRadixRecord[]数组数据结构是interface RadixRecord { id: number sourceText: string sourceBase: Radix results: Recordstring, string createdAt: number }展示历史记录我推荐用表格组件因为列多、行数还可能很多。我项目里用的vxe-table主要看中它的虚拟滚动和列宽控制。一个关键配置是scroll-y开启后大数据量下 UI 不卡。它的列配置和 Element Plus 的el-table差不多如果你的后台本来就有表格库直接用现成的就行没必要为了一个小工具再引一个库。复制功能我用vueuse/core的useClipboard钩子import { useClipboard } from vueuse/core const { copy, copied } useClipboard({ timeout: 1500 })模板里直接clickcopy(row.value)。但要注意navigator.clipboard在http非安全上下文里不可用局域网 IP 访问 dev server 时尤其容易踩中。所以我在第 5 节里专门讲了兼容方案。5. 常见问题与优化经验5.1 三个让我印象深刻的坑第一个坑就是parseInt的“宽容性”。parseInt(08, 8)的结果是 0因为8不是合法的八进制数字解析器在碰到非法字符后直接返回已解析的0。同样的parseInt(19, 10)返回 1parseInt(1F, 2)返回 1都是不报错但结果是错的。这对表单来说是灾难用户以为输了个二进制数换算出来的结果却差之千里。我因此放弃了parseInt作为唯一的校验手段改用逐位累加并显式判定digit base。第二个坑是小数浮点误差。0.1 0.2不等于0.3这谁都知道但转换器里我一开始用\(整数部分).\(小数部分)拼接字符串再Number(string)转回数字时String(0.1 0.2)会输出0.30000000000000004展示在结果区极其尴尬。最后我在trimTrailingZeros之前先做了一次parseFloat(decimal.toPrecision(15))保证展示不乱。第三个坑和表单校验有关。我在另一个模块里用过 Element Plus 的form.validateon-success回调发现回调偶尔不触发排查到最后的原因是校验只在“全部字段校验通过”时触发on-success任何一列校验失败时它都静默跳过。转换器场景里如果嵌在复杂表单中别依赖on-success做联动逻辑建议监听校验失败的对象或者使用validateField。这个小知识点比较偏但真能省半天排查时间。5.2 复制到剪贴板的兼容处理复制功能最简单的写法navigator.clipboard.writeText(value)但这个 API 只在 HTTPS 或 localhost 环境可用。内网调试时浏览器地址栏是http://192.168.1.10:5173/直接调用会报NotAllowedError。兼容方案是检测navigator.clipboard是否存在不存在则退回document.execCommand(copy)老路子async function fallbackCopy(value: string): Promiseboolean { try { if (navigator.clipboard window.isSecureContext) { await navigator.clipboard.writeText(value) return true } } catch (e) { // 继续走 fallback } const textarea document.createElement(textarea) textarea.value value textarea.style.position fixed textarea.style.opacity 0 document.body.appendChild(textarea) textarea.select() const success document.execCommand(copy) document.body.removeChild(textarea) return success }useClipboard内部其实也有类似的兼容逻辑但如果团队项目里没有引入vueuse/core把上面这段函数存在 utils 里也一样用。抄的时候注意textarea要插入到 DOM 再 select否则在部分浏览器里会复制失败。5.3 计算性能与代码组织的小建议转换器计算量本身很小哪怕一次性渲染 200 行也跑不满 CPU但代码组织上还是有几个提升点。第一纯函数放utils不要直接写在组件里。这样好处是 Jest/Vitest 可以直接测试进制转换函数不用挂载组件。我写过这样的测试用例import { describe, expect, it } from vitest import { baseToDecimal, decimalToBase } from /utils/radix describe(decimalToBase, () { it(should convert 255 to ff in hex, () { expect(decimalToBase(255, 16)).toBe(ff) }) })第二计算属性尽量粒度细一点。我在 4.1 里把decimalValue和resultRows拆成两个 computed而不是一个 computed 里塞完整对象。好处是源值不变时decimalValue不会重新计算依赖它的其他逻辑也不会被连带触发。第三如果界面里还整合了其他工具比如把历史记录放到弹窗组件里展示弹窗组件建议用defineAsyncComponent动态加载避免首屏加载太多代码。Vite 下这样写import { defineAsyncComponent } from vue const HistoryDialog defineAsyncComponent(() import(/components/HistoryDialog.vue))这个小改造能让首屏 bundle 体积小一截尤其后台系统里组件很多时收益更明显。我自己在实际使用中还有一个体验上的小习惯转换面板输入框默认聚焦按回车直接跳到下一个目标进制的复制按钮。这种细节虽然跟“技术实现”关系不大但作为一个日常工具来说操作便利性往往比代码优雅度更能决定它会不会被团队真正用起来。整套方案做完我最大的体会是越小的工具越要在边界条件上下功夫字符串解析、非法输入、浮点精度这些地方多花的时间会在真正使用时成倍赚回来。
返回列表