阅读视图

发现新文章,点击刷新页面。

smart-unit:一个优雅的 JavaScript 单位转换库,告别繁琐的依赖管理

痛点:现有方案的局限

在 JavaScript 项目中处理单位转换时,你是否遇到过这样的困扰?

方案一:专用库

  • bytes 只能处理文件大小
  • filesize 同样局限
  • 需要格式化时间、长度、货币?再装一个库

方案二:通用转换库

  • 每个转换都要手动定义
  • 代码臃肿,配置繁琐
// 老方式:繁琐且不灵活
const bytes = require('bytes')
const filesize = require('filesize')
// 时间、长度、货币还需要别的库...

如果只需要定义一次单位链,就能获得智能格式化和简洁的 API,会怎样?


解决方案:smart-unit npm version test license

smart-unit 是一个轻量级的 TypeScript 优先库,提供自动单位选择的单位转换功能。专为追求优雅而不牺牲功能的开发者设计。

npm install smart-unit

核心概念:简洁而强大

smart-unit 的精髓在于声明式单位链定义。只需定义一次单位和转换比例,剩下的交给库来处理。

文件大小格式化

import { SmartUnit } from 'smart-unit'

const fileSize = new SmartUnit(['B', 'KB', 'MB', 'GB', 'TB'], {
  baseDigit: 1024,
})

console.log(fileSize.format(1024))        // "1KB"
console.log(fileSize.format(1536))        // "1.5KB"
console.log(fileSize.format(1024 * 1024 * 100))  // "100MB"
console.log(fileSize.format(1024 * 1024 * 1024 * 5))  // "5GB"

注意 format(1536) 自动选择了 "1.5KB" 而不是 "1536B""0.0015MB"。库会智能选择最易读的单位。

长度单位(可变比例)

并非所有单位系统都使用一致的基数。公制长度单位的比例各不相同:

const length = new SmartUnit(['mm', 10, 'cm', 100, 'm', 1000, 'km'])

console.log(length.format(1500))      // "1.5m"
console.log(length.format(1500000))   // "1.5km"
console.log(length.format(25))        // "2.5cm"

通过指定单独的比例(101001000),可以准确建模任何单位层级。


双向转换:解析与格式化

smart-unit 不仅用于展示,还能将格式化字符串解析回基础值:

const time = new SmartUnit(['ms', 1000, 's', 60, 'm', 60, 'h'])

console.log(time.parse('90s'), 'ms')   // 90000 ms
console.log(time.parse('2.5h'), 'ms')  // 9000000 ms
console.log(time.parse('30m'), 'ms')   // 1800000 ms

这种双向能力使其非常适合配置文件、用户输入和数据序列化。


高精度模式:突破 JavaScript 极限

JavaScript 的 number 类型安全整数上限是 2^53 - 1(约 9 千万亿)。对于金融计算或科学应用,这是致命缺陷。

smart-unit 集成 decimal.js 实现任意精度运算:

const bigLength = new SmartUnit(['pm', 1000, 'nm', 1000, 'μm', 1000, 'mm', 1000, 'm'], {
  useDecimal: true,
})

console.log(bigLength.format('1000'))      // "1nm"
console.log(bigLength.format('1000000'))   // "1μm"

// BigInt 支持 - 超越 JS 安全整数限制
const bigNumber = 123456789012345678901234567890n
console.log('格式化结果:', bigLength.format(bigNumber))

金融计算

货币和金融数据经常超出安全整数限制,同时需要精确的十进制处理:

const currency = new SmartUnit(['', 'K', 'M', 'B', 'T'], {
  baseDigit: 1000,
  useDecimal: true,
  fractionDigits: 2,
})

console.log(currency.format('12345678901234567890'))  // "12345678.90T"

fractionDigits: 2 确保货币值保持一致的十进制位数。


对比优势

特性 bytes filesize smart-unit
文件大小
自定义单位
双向转换
高精度
BigInt 支持
TypeScript 部分 部分 ✅ 原生支持
包体积 ~1KB ~2KB ~2KB

smart-unit 用专用库的体积,提供通用库的灵活性。

测试覆盖

项目包含 66 条单元测试,覆盖各种边界情况:

  • BigInt 输入处理
  • Decimal.js 高精度计算
  • 边界值和异常处理
  • 多种单位链配置

确保在生产环境中的稳定性和可靠性。

image.png

实际应用场景

数据传输速率

const bitrate = new SmartUnit(['bps', 'Kbps', 'Mbps', 'Gbps'], {
  baseDigit: 1000,
  fractionDigits: 1,
})

bitrate.format(1500000)  // "1.5Mbps"

频率

const freq = new SmartUnit(['Hz', 'kHz', 'MHz', 'GHz'], {
  baseDigit: 1000,
  fractionDigits: 2,
})

freq.format(2400000000)  // "2.40GHz"

存储容量(自定义阈值)

const storage = new SmartUnit(['B', 'KB', 'MB', 'GB', 'TB'], {
  baseDigit: 1024,
  threshold: 0.9,  // 在下一单位的 90% 时切换
})

TypeScript 原生设计

smart-unit 使用 TypeScript 编写,提供完整的类型安全:

import { SmartUnit } from 'smart-unit'
import type { Decimal } from 'decimal.js'

// 普通模式 - 返回 number
const regular = new SmartUnit(['B', 'KB', 1024])
const num: number = regular.parse('1KB')

// 高精度模式 - 返回 Decimal
const precise = new SmartUnit(['B', 'KB', 1024], { useDecimal: true })
const dec: Decimal = precise.parse('1KB')

类型推断无缝工作,API 设计有意保持简洁,降低认知负担。


快速开始

npm install smart-unit
import { SmartUnit } from 'smart-unit'

// 定义一次,随处使用
const size = new SmartUnit(['B', 'KB', 'MB', 'GB'], { baseDigit: 1024 })

size.format(1024 * 1024 * 100)  // "100MB"
size.parse('2.5GB')             // 2684354560

在线体验

直接在浏览器中体验 smart-unit:

CodeSandbox 在线示例


总结

smart-unit 用优雅的方案解决了普遍存在的问题。无论是格式化文件上传、解析用户输入、处理金融数据,还是构建科学应用,它都在简洁性和功能性之间取得了完美平衡。

核心要点:

  • 用极简语法定义任意单位链
  • 自动选择最优单位
  • 双向转换(格式化和解析)
  • 高精度模式支持 BigInt
  • TypeScript 原生,包体积最小
  • 66 条单元测试全覆盖,稳定性有保障

在下一个项目中试试看,你的单位转换代码会感谢你的。


相关链接:

开发一个 TypeScript 语言服务插件:让 RTK Query 的"跳转到定义"更智能

前言

在使用 Redux Toolkit Query (RTK Query) 进行开发时,你是否遇到过这样的困扰:

当你想查看某个 API 端点的具体实现时,按下 F12(跳转到定义),IDE 却把你带到了类型定义文件,而不是真正的业务代码。你需要手动搜索endpoint名称,才能在 createApi 中找到对应的定义。

这是一个普遍存在的问题,因为 RTK Query 的 hook 名称(如 useGetUserQuery)是动态生成的,TypeScript 无法建立从 hook 调用到endpoint定义的静态映射关系。

今天,我将介绍如何开发一个 TypeScript Language Service Plugin,来解决这个问题,让开发者能够一键跳转到 RTK Query 的 endpoint 定义。


问题背景

RTK Query 的工作原理

RTK Query 通过 createApi 创建 API 切片:

export const userApi = createApi({
  reducerPath: 'userApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  endpoints: (builder) => ({
    getUser: builder.query<User, string>({
      query: (id) => `/users/${id}`,
    }),
    updateUser: builder.mutation<User, Partial<User>>({
      query: (body) => ({
        url: '/users',
        method: 'POST',
        body,
      }),
    }),
  }),
})

// 自动生成的 hooks
export const { useGetUserQuery, useUpdateUserMutation } = userApi

痛点分析

  1. Hook 名称是动态派生的getUseruseGetUserQuery
  2. TypeScript 只能看到类型:IDE 的"跳转到定义"只能指向类型体操生成的类型定义
  3. 开发体验断裂:开发者需要手动搜索endpoint名称,打断编码流

解决方案:TypeScript Language Service Plugin

什么是 Language Service Plugin?

TypeScript Language Service Plugin 是一种扩展机制,允许我们拦截和自定义 TypeScript 语言服务的各种操作,包括:

  • 跳转到定义 (Go to Definition)
  • 自动补全 (Auto Completion)
  • 悬停提示 (Hover Information)
  • 代码重构 (Code Refactoring)

核心思路

我们的插件需要完成以下工作:

  1. 识别 RTK Query Hook:通过命名规则识别 use{Endpoint}Queryuse{Endpoint}Mutation 等 hook
  2. 解析 AST:找到 hook 所属的 API 实例
  3. 定位 Endpoint:从 API 实例的 endpoints 属性中找到对应的端点定义
  4. 返回定义位置:将跳转目标指向 endpoint 定义处

实现详解

1. 项目结构

rtk-to-endpoints/
├── src/
│   ├── index.ts      # 插件入口
│   └── utils.ts      # 核心逻辑
├── package.json
└── tsconfig.json

2. 插件入口 (index.ts)

import tslib from "typescript/lib/tsserverlibrary";
import { getDefinitionAndBoundSpan } from "./utils.js";

function init(modules: { typescript: typeof tslib }) {
  const ts = modules.typescript;

  function create(info: tslib.server.PluginCreateInfo) {
    const logger = info.project.projectService.logger;
    
    log("✅ Plugin initialized");

    const proxy: tslib.LanguageService = Object.create(info.languageService);

    // 拦截"跳转到定义"请求
    proxy.getDefinitionAndBoundSpan = (
      fileName: string,
      position: number
    ): tslib.DefinitionInfoAndBoundSpan | undefined => {
      const program = info.languageService.getProgram();
      
      // 尝试我们的自定义跳转逻辑
      const definitionInfo = getDefinitionAndBoundSpan(
        fileName, position, ts, program
      );
      
      // 如果匹配到 RTK Query hook,返回自定义结果
      // 否则,回退到默认行为
      return definitionInfo || 
        info.languageService.getDefinitionAndBoundSpan(fileName, position);
    };

    return proxy;
  }

  return { create };
}

export = init;

3. 核心逻辑 (utils.ts)

3.1 识别 Hook 命名模式

RTK Query 生成的 hook 遵循固定的命名规则:

const HOOK_PREFIXES = ["useLazy", "use"] as const;
const HOOK_SUFFIXES = [
  "InfiniteQueryState",
  "InfiniteQuery", 
  "QueryState",
  "Mutation",
  "Query",
] as const;

// 从 hook 名中提取 endpoint 名
export function extractEndpointName(hookName: string) {
  for (const prefix of HOOK_PREFIXES) {
    if (hookName.startsWith(prefix)) {
      const rest = hookName.slice(prefix.length);
      for (const suffix of HOOK_SUFFIXES) {
        if (rest.endsWith(suffix)) {
          const endpointName = rest.slice(0, rest.length - suffix.length);
          if (endpointName) {
            // 首字母小写:GetUser → getUser
            return endpointName[0].toLowerCase() + endpointName.slice(1);
          }
        }
      }
    }
  }
}

3.2 AST 节点查找

使用二分查找在 AST 中快速定位光标所在的节点:

export function getIdentifierNodeAt(
  sourceFile: tslib.SourceFile,
  pos: number,
): tslib.Node | undefined {
  let current: tslib.Node = sourceFile;
  
  while (true) {
    const children = current.getChildren(sourceFile);
    let left = 0;
    let right = children.length - 1;
    let targetChild: tslib.Node | undefined;

    // 二分查找覆盖指定位置的子节点
    while (left <= right) {
      const mid = (left + right) >>> 1;
      const child = children[mid];
      if (pos < child.pos) {
        right = mid - 1;
      } else if (pos >= child.end) {
        left = mid + 1;
      } else {
        targetChild = child;
        break;
      }
    }

    if (!targetChild) break;
    current = targetChild;
  }
  
  return current;
}

3.3 查找 API 实例

支持两种常见的 API 使用模式:

export function findApi(node: tslib.Node, ts: typeof tslib) {
  const parent = node.parent;
  
  // 模式 1:解构赋值
  // const { useGetUsersQuery } = userApi
  if (ts.isBindingElement(parent)) {
    const expressionNode = parent.parent?.parent;
    if (!ts.isVariableDeclaration(expressionNode)) return;
    const apiNode = expressionNode.getChildAt(
      expressionNode.getChildCount() - 1
    );
    if (!apiNode || !ts.isIdentifier(apiNode)) return;
    return apiNode;
    
  // 模式 2:属性访问
  // userApi.useGetProductsQuery()
  } else if (parent && ts.isPropertyAccessExpression(parent)) {
    return parent.getChildAt(parent.getChildCount() - 3);
  }
}

3.4 定位 Endpoint 定义

利用 TypeScript 的类型检查器,从 API 实例的 endpoints 属性中找到目标端点:

export function findEndpoint(
  apiNode: tslib.Node, 
  endpointName: string, 
  checker: tslib.TypeChecker
) {
  // 获取 API 实例的类型
  const apiType = checker.getTypeAtLocation(apiNode);
  
  // 获取 endpoints 属性
  const endpointsSymbol = apiType.getProperty('endpoints');
  if (!endpointsSymbol) return;
  
  // 获取 endpoints 的类型
  const endpointsType = checker.getTypeOfSymbol(endpointsSymbol);
  
  // 查找具体的 endpoint
  const endpointsPropertySymbol = endpointsType.getProperty(endpointName);
  return endpointsPropertySymbol;
}

3.5 组装定义信息

export function getDefinitionAndBoundSpan(
  fileName: string, 
  position: number, 
  ts: typeof tslib, 
  program?: tslib.Program
) {
  const sf = program!.getSourceFile(fileName);
  const checker = program!.getTypeChecker();
  if (!sf || !program || !checker) return;

  // 1. 找到光标处的标识符节点
  const identNode = getIdentifierNodeAt(sf, position);
  if (!identNode || !ts.isIdentifier(identNode)) return;

  // 2. 提取 endpoint 名称
  const endpointName = extractEndpointName(identNode.getText());
  if (!endpointName) return;

  // 3. 找到 API 实例
  const apiNode = findApi(identNode, ts);
  if (!apiNode) return;

  // 4. 查找 endpoint 定义
  const endpointSymbol = findEndpoint(apiNode, endpointName, checker);
  if (!endpointSymbol?.declarations?.length) return;

  // 5. 组装定义信息
  const definitions = endpointSymbol.declarations.map((node): tslib.DefinitionInfo => {
    return {
      fileName: node.getSourceFile().fileName,
      kind: ts.ScriptElementKind.memberFunctionElement,
      name: endpointSymbol.getName(),
      containerKind: ts.ScriptElementKind.classElement,
      containerName: "endpoints",
      textSpan: {
        start: node.getStart(),
        length: node.getWidth(),
      },
    };
  });

  return {
    definitions,
    textSpan: {
      start: identNode.getStart(sf),
      length: identNode.getWidth(sf),
    },
  };
}

使用方式

1. 安装插件

npm install --save-dev rtk-to-endpoints

2. 配置 tsconfig.json

{
  "compilerOptions": {
    "plugins": [
      {
        "name": "rtk-to-endpoints"
      }
    ]
  }
}

3. 配置 VSCode

由于VSCode内置的TypeScript无法读取到项目下的npm包,需要在 VSCode 中设置使用工作区的TypeScript版本:

  1. Ctrl+Shift+P → 输入 "TypeScript: Select TypeScript Version"
  2. 选择 "Use Workspace Version"
  3. 重新加载窗口 (Developer: Reload Window)

效果演示

配置完成后,当你在任何 RTK Query hook 上使用"跳转到定义":

// 点击 useGetUserQuery,直接跳转到 getUser endpoint 定义
const { data } = userApi.useGetUserQuery(userId);

跳转前

  • 指向类型定义文件(无实际业务价值)

跳转后

  • 直接定位到 createApi 中的 getUser endpoint 定义

技术要点总结

1. TypeScript Language Service 架构

┌─────────────────────────────────────────┐
│           VSCode / IDE                  │
└─────────────┬───────────────────────────┘
              │ LSP 协议
┌─────────────▼───────────────────────────┐
│      TypeScript Language Server         │
└─────────────┬───────────────────────────┘
              │
┌─────────────▼───────────────────────────┐
│    TypeScript Language Service          │
│  ┌─────────────────────────────────┐    │
│  │  rtk-to-endpoints Plugin        │    │
│  │  (拦截 getDefinitionAndBoundSpan)│   │
│  └─────────────────────────────────┘    │
└─────────────────────────────────────────┘

2. 关键技术点

技术点 说明
AST 遍历 使用二分查找高效定位节点
类型检查器 利用 TypeChecker 解析类型信息
代理模式 包装原有 Language Service,保留默认行为
命名解析 通过字符串模式匹配识别 hook 类型

扩展思考

这个插件的实现思路可以扩展到其他类似的场景:

  1. Vue Composition API:从 useXxx 跳转到 composable 定义
  2. React Hooks:增强自定义 hook 的跳转体验

结语

TypeScript Language Service Plugin 是一个强大的工具,能够显著提升开发体验。通过理解 TypeScript 的编译器 API 和语言服务架构,我们可以针对特定的框架和库,打造更智能的 IDE 支持。

希望这篇文章能够帮助你理解 Language Service Plugin 的工作原理,并激发你为自己的项目开发类似的工具。


参考资源


如果这篇文章对你有帮助,欢迎点赞、收藏和分享!

有任何问题或建议,欢迎在评论区留言讨论。

❌