1. 为什么我们需要手写这些工具函数?
作为一名前端开发者,我经常在项目中遇到需要控制函数执行频率、处理对象拷贝等场景。虽然lodash等库提供了现成的解决方案,但理解这些工具函数的实现原理对于提升我们的JS基本功至关重要。今天,我将分享6个高频工具函数的手写实现,包括防抖、节流、深拷贝等,每个函数都会附带核心原理讲解和完整的测试用例。
在实际面试中,这些工具函数的手写实现也是高频考点。通过自己动手实现,我们不仅能更好地理解其工作原理,还能根据项目需求进行定制化改造。下面我们就从最常用的防抖函数开始。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 防抖(debounce)函数实现与原理
2.1 防抖的核心概念
防抖函数的本质是:在事件被触发n秒后再执行回调,如果在这n秒内又被触发,则重新计时。这种技术特别适合处理高频触发的事件,比如:
- 窗口大小调整(resize)
- 输入框实时搜索(input)
- 滚动加载(scroll)
想象一下电梯关门的场景:当有人进入电梯时,电梯不会立即关门,而是等待一段时间(比如10秒),如果在这10秒内又有人进入,计时器会重置,直到10秒内没有人进入才会真正关门。
2.2 手写防抖函数实现
javascript复制function debounce(fn, delay, immediate = false) {
let timer = null
return function(...args) {
const context = this
// 如果存在定时器,清除它
if (timer) clearTimeout(timer)
// 立即执行模式
if (immediate) {
// 如果定时器不存在,说明是第一次触发,立即执行
const callNow = !timer
timer = setTimeout(() => {
timer = null
}, delay)
if (callNow) fn.apply(context, args)
}
// 非立即执行模式
else {
timer = setTimeout(() => {
fn.apply(context, args)
}, delay)
}
}
}
2.3 防抖函数测试用例
javascript复制// 测试用例
const mockFn = jest.fn()
const debouncedFn = debounce(mockFn, 1000)
// 快速连续调用
debouncedFn()
debouncedFn()
debouncedFn()
// 等待1.1秒后断言
setTimeout(() => {
expect(mockFn).toHaveBeenCalledTimes(1)
}, 1100)
2.4 防抖函数的注意事项
- this指向问题:在返回的函数中使用
apply确保原函数的this指向正确 - 参数传递:使用剩余参数
...args确保所有参数都能正确传递 - 立即执行选项:有些场景需要第一次触发立即执行,后续触发才防抖
- 清除定时器:每次触发时都要清除之前的定时器,避免多次执行
在实际项目中,我遇到过因为忘记清除定时器导致的内存泄漏问题。特别是在SPA应用中,组件卸载时应该清除所有定时器,可以在返回的函数上添加一个cancel方法:
javascript复制function debounce(fn, delay) {
let timer = null
const debounced = function(...args) {
// ...原有逻辑
}
debounced.cancel = function() {
clearTimeout(timer)
timer = null
}
return debounced
}
3. 节流(throttle)函数实现与原理
3.1 节流与防抖的区别
节流和防抖经常被混淆,但它们有本质区别:
- 防抖:在事件停止触发后一段时间执行一次
- 节流:在一定时间间隔内只执行一次,稀释执行频率
用生活场景比喻:
- 防抖:电梯关门(最后一次触发后执行)
- 节流:地铁发车(固定时间间隔执行)
3.2 手写节流函数实现
节流有两种主流实现方式:时间戳版和定时器版。我们先看时间戳版:
javascript复制function throttle(fn, delay) {
let lastTime = 0
return function(...args) {
const now = Date.now()
if (now - lastTime >= delay) {
fn.apply(this, args)
lastTime = now
}
}
}
定时器版实现:
javascript复制function throttle(fn, delay) {
let timer = null
return function(...args) {
const context = this
if (!timer) {
timer = setTimeout(() => {
fn.apply(context, args)
timer = null
}, delay)
}
}
}
结合版(首尾都执行):
javascript复制function throttle(fn, delay) {
let timer = null
let lastTime = 0
return function(...args) {
const context = this
const now = Date.now()
const remaining = delay - (now - lastTime)
if (remaining <= 0) {
if (timer) {
clearTimeout(timer)
timer = null
}
fn.apply(context, args)
lastTime = now
} else if (!timer) {
timer = setTimeout(() => {
fn.apply(context, args)
lastTime = Date.now()
timer = null
}, remaining)
}
}
}
3.3 节流函数测试用例
javascript复制// 测试用例
const mockFn = jest.fn()
const throttledFn = throttle(mockFn, 1000)
// 快速连续调用
throttledFn() // 第一次立即执行
throttledFn() // 被节流
throttledFn() // 被节流
// 等待1.1秒后
setTimeout(() => {
throttledFn() // 可以再次执行
expect(mockFn).toHaveBeenCalledTimes(2)
}, 1100)
3.4 节流函数的应用场景
- 滚动加载更多:滚动时检查是否到达底部
- 按钮防重复点击:防止用户快速重复提交
- 鼠标移动事件:跟踪鼠标位置但不需要太高频率
- 动画渲染:控制动画帧率
在实际项目中,我发现结合版节流函数用户体验最好,因为它确保了第一次立即执行和最后一次也会执行。特别是在无限滚动加载的场景中,这种实现方式能确保用户快速滚动到底部时也能触发加载。
4. 深拷贝(deepClone)实现与陷阱
4.1 浅拷贝与深拷贝的区别
在JS中,对象和数组是引用类型,直接赋值只是复制了引用(浅拷贝)。深拷贝则是创建一个全新的对象,完全复制所有属性和嵌套属性。
javascript复制const obj = { a: 1, b: { c: 2 } }
const shallowCopy = { ...obj } // 浅拷贝
const deepCopy = deepClone(obj) // 深拷贝
obj.b.c = 3
shallowCopy.b.c // 3 - 受影响
deepCopy.b.c // 2 - 不受影响
4.2 基础深拷贝实现
最简单的深拷贝方式是使用JSON方法:
javascript复制function deepClone(obj) {
return JSON.parse(JSON.stringify(obj))
}
但这种方案有局限性:
- 不能处理函数、undefined、Symbol等
- 不能处理循环引用
- 会丢失对象的constructor
4.3 完整版深拷贝实现
javascript复制function deepClone(obj, hash = new WeakMap()) {
// 处理基本类型和null/undefined
if (obj === null || typeof obj !== 'object') {
return obj
}
// 处理日期对象
if (obj instanceof Date) {
return new Date(obj)
}
// 处理正则对象
if (obj instanceof RegExp) {
return new RegExp(obj)
}
// 检查循环引用
if (hash.has(obj)) {
return hash.get(obj)
}
// 创建新对象/数组
const cloneObj = new obj.constructor()
hash.set(obj, cloneObj)
// 递归拷贝所有属性
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
cloneObj[key] = deepClone(obj[key], hash)
}
}
return cloneObj
}
4.4 深拷贝测试用例
javascript复制// 测试用例
const original = {
a: 1,
b: { c: 2 },
d: new Date(),
e: /regex/,
f: function() { return 'test' },
g: [1, 2, { h: 3 }]
}
// 制造循环引用
original.circular = original
const cloned = deepClone(original)
// 修改原始对象
original.b.c = 3
original.g[2].h = 4
// 断言克隆对象不受影响
expect(cloned.b.c).toBe(2)
expect(cloned.g[2].h).toBe(3)
expect(cloned.f()).toBe('test')
expect(cloned.circular === cloned).toBe(true)
4.5 深拷贝的注意事项
- 循环引用处理:使用WeakMap记录已拷贝对象
- 特殊对象处理:Date、RegExp等需要特殊处理
- 原型链维护:保持拷贝对象的constructor正确
- 性能考虑:深拷贝大对象可能很耗性能
在实际项目中,我曾经因为忘记处理循环引用导致栈溢出。后来发现WeakMap是解决这个问题的完美方案,因为它不影响垃圾回收,且可以用对象作为键。
对于特别大的对象,可以考虑使用结构化克隆算法(通过MessageChannel)或者第三方库如lodash的_.cloneDeep,它们通常有更好的性能优化。
5. 其他高频工具函数实现
5.1 函数柯里化(currying)
柯里化是把接受多个参数的函数变换成接受单一参数的函数,并返回接受余下参数的新函数的技术。
javascript复制function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
return fn.apply(this, args)
} else {
return function(...args2) {
return curried.apply(this, args.concat(args2))
}
}
}
}
// 使用示例
function sum(a, b, c) {
return a + b + c
}
const curriedSum = curry(sum)
console.log(curriedSum(1)(2)(3)) // 6
5.2 函数组合(compose)
函数组合是将多个函数组合成一个新函数的技术,数据从右向左流动。
javascript复制function compose(...fns) {
return function(x) {
return fns.reduceRight((v, f) => f(v), x)
}
}
// 使用示例
const add5 = x => x + 5
const multiply2 = x => x * 2
const addThenMultiply = compose(multiply2, add5)
console.log(addThenMultiply(3)) // (3 + 5) * 2 = 16
5.3 惰性函数(lazy function)
惰性函数是指在第一次调用时改变自身实现,后续调用直接使用新实现的函数。
javascript复制function lazyQuerySelector(selector) {
let cached = null
return function() {
if (!cached) {
cached = document.querySelector(selector)
}
return cached
}
}
// 使用示例
const getHeader = lazyQuerySelector('header')
console.log(getHeader()) // 第一次查询DOM
console.log(getHeader()) // 直接返回缓存结果
6. 测试工具函数的最佳实践
6.1 为什么需要测试工具函数
- 确保可靠性:工具函数通常被多处使用,必须保证其正确性
- 边界条件验证:测试各种边界条件和异常情况
- 重构安全保障:有测试覆盖后,重构时更有信心
- 文档作用:测试用例本身就是函数使用方式的示例
6.2 测试工具函数的策略
- 基础功能测试:验证函数的基本功能
- 边界条件测试:测试极端值和边界情况
- 性能测试:特别对于节流/防抖函数,需要测试执行频率
- 内存泄漏测试:确保没有未清除的定时器或引用
6.3 使用Jest编写测试用例示例
javascript复制describe('debounce', () => {
jest.useFakeTimers()
it('should execute only once when called rapidly', () => {
const mockFn = jest.fn()
const debouncedFn = debounce(mockFn, 1000)
debouncedFn()
debouncedFn()
debouncedFn()
jest.advanceTimersByTime(1000)
expect(mockFn).toHaveBeenCalledTimes(1)
})
it('should execute immediately when immediate is true', () => {
const mockFn = jest.fn()
const debouncedFn = debounce(mockFn, 1000, true)
debouncedFn()
expect(mockFn).toHaveBeenCalledTimes(1)
debouncedFn()
jest.advanceTimersByTime(1000)
expect(mockFn).toHaveBeenCalledTimes(1)
})
})
describe('deepClone', () => {
it('should clone object deeply', () => {
const obj = { a: 1, b: { c: 2 } }
const cloned = deepClone(obj)
obj.b.c = 3
expect(cloned.b.c).toBe(2)
})
it('should handle circular references', () => {
const obj = { a: 1 }
obj.self = obj
const cloned = deepClone(obj)
expect(cloned.self).toBe(cloned)
})
})
6.4 测试覆盖率的重要性
在实际项目中,我建议对这些工具函数达到100%的测试覆盖率。因为它们是基础构建块,任何bug都可能影响整个应用。使用Jest的覆盖率报告可以确保所有边界条件都被覆盖:
bash复制jest --coverage
7. 工具函数的性能优化
7.1 防抖和节流的性能考量
- 定时器管理:确保及时清除无用的定时器
- 内存使用:避免闭包导致的内存泄漏
- 执行频率:根据实际场景调整delay时间
7.2 深拷贝的性能优化
- 避免过度拷贝:对于不需要深拷贝的属性,可以使用浅拷贝
- 使用WeakMap:比Map更适合处理循环引用,不影响垃圾回收
- 特殊类型处理:对Buffer、ArrayBuffer等特殊类型提供专门处理
7.3 柯里化的性能优化
- 参数缓存:避免每次调用都重新处理参数
- 提前返回:当参数足够时立即执行原函数
- 避免过深嵌套:控制柯里化层级,防止调用栈过深
8. 在实际项目中的应用经验
8.1 表单输入的防抖应用
在搜索框实时搜索的场景中,使用防抖可以显著减少不必要的请求:
javascript复制const searchInput = document.getElementById('search')
const fetchResults = debounce(async (query) => {
const response = await fetch(`/api/search?q=${query}`)
// 处理结果
}, 500)
searchInput.addEventListener('input', (e) => {
fetchResults(e.target.value)
})
8.2 无限滚动的节流应用
实现无限滚动加载时,节流可以优化滚动事件处理:
javascript复制window.addEventListener('scroll', throttle(() => {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 500) {
loadMoreItems()
}
}, 200))
8.3 状态管理的深拷贝应用
在Redux等状态管理中,reducer必须返回新状态而非修改原状态:
javascript复制function reducer(state = initialState, action) {
switch (action.type) {
case 'UPDATE_SETTINGS':
return {
...deepClone(state),
settings: action.payload
}
default:
return state
}
}
8.4 遇到的坑与解决方案
- 防抖函数的this丢失:确保使用apply保持正确的this指向
- 节流函数的最后一次执行:结合时间戳和定时器确保最后一次触发也会执行
- 深拷贝的性能问题:对于大对象,考虑使用不可变数据结构或专业库
- 内存泄漏:组件卸载时取消所有定时器和事件监听
9. 如何扩展这些工具函数
9.1 支持更多配置选项
例如为防抖函数添加:
- maxWait:最大等待时间
- leading/trailing:控制首尾执行
9.2 组合使用工具函数
将多个工具函数组合使用可以解决更复杂的问题:
javascript复制// 防抖+节流组合
const debounceThrottle = (fn, delay) => {
const debounced = debounce(fn, delay)
const throttled = throttle(fn, delay)
return function(...args) {
debounced(...args)
throttled(...args)
}
}
9.3 支持TypeScript类型
为这些工具函数添加TypeScript类型定义,提升开发体验:
typescript复制function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number,
immediate?: boolean
): (...args: Parameters<T>) => void
9.4 发布为npm包
将这些通用工具函数发布为npm包,方便团队共享:
- 初始化npm项目
- 添加单元测试
- 配置TypeScript编译
- 编写文档
- 发布到npm registry
10. 总结与个人心得
通过手写这些工具函数,我深刻理解了它们背后的原理和实现细节。在实际项目中,我有几点心得体会:
- 不要过度优化:简单的JSON深拷贝能满足大部分场景,不必一开始就用复杂实现
- 理解比记忆重要:理解防抖和节流的区别比记住实现代码更重要
- 测试驱动开发:先写测试用例再实现函数,能确保代码质量
- 文档很关键:为每个工具函数添加清晰的注释和使用示例
这些工具函数虽然看起来简单,但要做到生产环境可用,需要考虑很多边界条件和性能问题。建议读者也动手实现一遍,遇到问题时参考成熟库的源码,这是提升JS基本功的有效方法。
