为什么需要手写 radash?
不是为了造轮子。
而是为了理解抽象、掌握组合、掌控类型。
工具库的本质:编程原语的封装
我们每天都在使用工具函数:map、filter、debounce、omit。它们看起来简单,甚至“理所当然”。但正是这些看似平凡的函数,构成了现代 JavaScript/TypeScript 开发的底层基石。
它们不是“便利函数”,而是编程原语(Programming Primitives)——就像加减乘除之于数学,变量与函数之于编程。
当你调用 array.map(fn),你不是在“遍历数组”,而是在声明一种转换关系:将一个集合通过纯函数映射为另一个集合。这种思维,是函数式编程的核心。
而 radash 这类工具库,正是对这些原语的系统性封装。它不提供业务逻辑,而是提供构建业务逻辑的基本构件。
为什么要自己实现一遍?
因为使用和理解之间,隔着一条深谷。
你可以用 lodash.debounce 轻松实现搜索框防抖,但你是否清楚:
- 为什么
leading: true和trailing: false在某些交互中会导致意料之外的行为? - 如果底层库的
debounce实现有内存泄漏,你如何定位? - 当你在 TypeScript 中调用
debounce(fn, 300),返回函数的参数类型是如何保持一致的?
这些问题的答案,不在文档里,而在实现中。
手写 radash 的意义,不在于写出一个比现有库更快或更全的版本,而在于:
- 理解抽象:看懂每一个函数背后的设计权衡。
- 掌握组合:学会如何将小函数组合成复杂逻辑。
- 掌控类型:让 TypeScript 不再是“类型擦除”,而是“类型驱动”。
理解抽象:从 debounce 看设计决策
以 debounce 为例。
它的基本语义是:“在最后一次调用后延迟执行”。实现看似简单:
const debounce = (fn, delay) => {
let timer = null
return (...args) => {
clearTimeout(timer)
timer = setTimeout(() => fn(...args), delay)
}
}但这只是最简版本。真实场景中,你会遇到:
- 是否在第一次调用时立即执行?(
leading) - 是否在最后一次调用后执行?(
trailing) leading和trailing能否同时为true?如果能,行为如何?
Lodash 支持四种组合:{ leading: false, trailing: true }(默认)、{ true, false }、{ true, true }、{ false, false }。
但为什么在某些 UI 场景中,leading 和 trailing 同时为 true 会导致函数被执行两次?
因为 leading 是“首次调用立即执行”,而 trailing 是“最后一次调用后延迟执行”。如果用户快速连续触发,leading 会在第一次触发时执行,而 trailing 会在最后一次触发后的延迟结束时再执行一次。
这在按钮防重复点击中是灾难性的——用户点一次,操作执行两次。
所以,“能否共存”不是技术问题,而是语义冲突。手写实现的过程,就是直面这种冲突的过程。
掌握组合:函数即数据,流水线即程序
函数式编程的核心思想之一是:函数是一等公民。
这意味着函数可以被传递、组合、抽象。
pipe 和 compose 就是这种思想的极致体现:
const result = pipe(
add(1),
multiply(2),
subtract(3)
)(5) // 等价于 subtract(3)(multiply(2)(add(1)(5)))这不仅仅是语法糖。它代表了一种声明式编程范式:你不再关心“如何做”,而是描述“做什么”。
当你手写 pipe,你会思考:
- 如何让类型系统正确推导链式调用的输入输出?
- 如何处理异步函数的组合?
- 如何在中间函数出错时优雅地失败?
这些问题迫使你深入高阶函数、泛型推导和错误处理机制。
而一旦掌握,你的代码将从“命令式面条”转变为“声明式流水线”。
掌控类型:TypeScript 不是装饰品
TypeScript 的真正威力,在于类型即程序。
考虑 omit 函数:
const omit = <T, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> => {
const result = {} as T
for (const key in obj) {
if (!keys.includes(key)) {
result[key] = obj[key]
}
}
return result
}这个函数的返回类型是 Omit<T, K>,这意味着如果你有一个对象:
const user = { id: 1, name: 'Alice', password: '123' }
const safeUser = omit(user, ['password'])
// safeUser 的类型自动变为 { id: number, name: string }IDE 能立即识别 safeUser.password 是不存在的字段。
这种类型安全不是靠文档或约定,而是由编译器保证。
当你手写这些函数时,你必须思考:
- 如何设计泛型参数?
- 如何利用条件类型处理默认值?
- 如何让路径字符串
a.b.c被类型系统解析为嵌套属性?
你不再只是“写代码”,而是在设计类型系统。
工程化视角:Tree-shaking 与生产就绪
现代前端工程要求“按需加载”。你不想因为用了一个 debounce,就把整个工具库打包进去。
这就是 ESM + sideEffects: false 的意义。
手写 radash 时,你必须从第一天就考虑模块化结构:
src/
function/
debounce.ts
throttle.ts
object/
omit.ts
pick.ts
deepGet.ts
async/
retry.ts
sleep.ts
index.ts每个函数独立导出,支持:
import { debounce } from '@my/radash/function'配合 Rollup 或 Vite,实现真正的 Tree-shaking——未引用的函数不会进入最终包。
此外,你还需要考虑:
- 单元测试:确保
debounce的leading行为符合预期。 - 类型测试:验证
omit的返回类型是否正确排除了键。 - 性能基准:对比原生
setTimeout实现与优化版本的开销。
结语:手写,是为了超越
手写 radash 不是复古,而是回归本质。
在这个充斥着“开箱即用”工具的时代,我们容易忘记:每一个 npm install 背后,都是一系列设计决策的结晶。
当你亲手实现 debounce,你不再把它当作黑盒。
当你为 retry 添加指数退避和随机抖动,你理解了分布式系统中的容错哲学。
当你让 deepGet 支持类型路径推导,你掌握了 TypeScript 的高级类型技巧。
最终,你写的不是一个工具库。
你构建的是一套思维模型:如何抽象、如何组合、如何用类型驱动开发。
这才是“手写 radash”的真正意义。