20 道 JavaScript 原理题:从手写实现到边界条件
Category(分类): JavaScript Status: 已更新
本文保留原文的 20 个练习方向,但把原示例中被压缩在一行的代码重新排版,并修正了 call 重复执行、apply 使用 eval、new 忽略构造函数返回值、JSON.parse 使用 eval、innerHTML 注入风险和懒加载计算不准确等问题。
这些实现用于理解机制,不是为了覆盖 ECMAScript 全部内部抽象操作。真正的生产代码应优先使用标准 API。
1. 实现一个 call
call 立即调用函数,并传入一个 this 值和一组参数。下面是教学版,使用临时 Symbol 避免覆盖目标对象已有属性:
Function.prototype.myCall = function (context, ...args) {
if (typeof this !== 'function') {
throw new TypeError('myCall 只能调用在函数上')
}
const receiver = context == null ? globalThis : Object(context)
const key = Symbol('temporary-call')
Object.defineProperty(receiver, key, {
value: this,
configurable: true
})
try {
return receiver[key](...args)
} finally {
delete receiver[key]
}
}
function greet(prefix, name) {
return `${prefix}, ${this.title} ${name}`
}
greet.myCall({ title: 'Engineer' }, 'Hello', 'Ada')
边界:原生 call 对严格模式函数使用原始 this 值,而临时属性法需要把原始值装箱,因此这是近似实现。globalThis 也只是非严格函数中 null/undefined 的常见默认场景,不能替代规范中完整的 this 绑定算法。
2. 实现一个 apply
apply 与 call 的区别是参数以一个类数组对象传入。它不要求参数一定是数组,也不需要 eval:
Function.prototype.myApply = function (context, argsList) {
if (typeof this !== 'function') {
throw new TypeError('myApply 只能调用在函数上')
}
const args = argsList == null ? [] : Array.from(argsList)
return this.myCall(context, ...args)
}
标准 API:
Reflect.apply(Math.max, null, [1, 5, 3]) // 5
3. 实现一个 bind
bind 返回一个新函数,预先绑定 this 和部分参数;绑定后的函数仍然可以被 new 调用,此时构造调用提供的新实例应覆盖原来的 this,但预置参数仍然保留。
Function.prototype.myBind = function (context, ...boundArgs) {
if (typeof this !== 'function') {
throw new TypeError('myBind 只能调用在函数上')
}
const target = this
function bound(...callArgs) {
const args = [...boundArgs, ...callArgs]
if (new.target) {
return Reflect.construct(target, args, new.target)
}
return target.apply(context, args)
}
if (target.prototype) {
bound.prototype = Object.create(target.prototype, {
constructor: {
value: bound,
configurable: true,
writable: true
}
})
}
return bound
}
完整原生 bind 还会处理函数的 length、name、new.target 传递和更多内部不变量。学习实现时应重点理解“预置参数”和“构造调用忽略绑定 this”。
4. instanceof 的原理
普通类或构造函数的 instanceof 会沿左值的原型链查找右值的 prototype。右侧还可以通过 Symbol.hasInstance 自定义行为,因此下面是常见原理的简化实现:
function myInstanceOf(value, Constructor) {
if (typeof Constructor !== 'function') {
throw new TypeError('右侧必须是可调用对象')
}
if (value == null) return false
const type = typeof value
if (type !== 'object' && type !== 'function') return false
const targetPrototype = Constructor.prototype
if (
targetPrototype === null ||
(typeof targetPrototype !== 'object' && typeof targetPrototype !== 'function')
) {
throw new TypeError('prototype 必须是对象')
}
let current = Object.getPrototypeOf(value)
while (current !== null) {
if (current === targetPrototype) return true
current = Object.getPrototypeOf(current)
}
return false
}
instanceof 检查的是原型链关系,不是对象是否“长得一样”。跨 realm 的数组可能不适合用 value instanceof Array 判断,跨窗口场景更推荐 Array.isArray(value)。
5. Object.create 的基本原理
Object.create(proto, descriptors) 创建一个对象并把指定对象设为其原型,还可以定义属性描述符:
function myCreate(proto, descriptors) {
if (
proto !== null &&
typeof proto !== 'object' &&
typeof proto !== 'function'
) {
throw new TypeError('原型必须是对象、函数或 null')
}
const object = {}
Object.setPrototypeOf(object, proto)
if (descriptors !== undefined) {
Object.defineProperties(object, descriptors)
}
return object
}
const base = { greet() { return 'hello' } }
const object = myCreate(base)
这个教学实现与原生 Object.create() 还有属性描述符、null 原型和原型构造器细节差异。生产代码直接使用标准 API。
6. new 的本质
调用 new Constructor(...args) 大体会:
- 创建一个新对象;
- 把新对象的原型连接到
Constructor.prototype; - 以新对象为
this调用构造函数; - 如果构造函数返回对象或函数,则使用该返回值,否则返回新对象。
function myNew(Constructor, ...args) {
if (typeof Constructor !== 'function') {
throw new TypeError('构造器必须是函数')
}
if (
/^class\s/.test(Function.prototype.toString.call(Constructor)) ||
Constructor.prototype === undefined
) {
// class 和无 prototype 的可构造函数(例如 bound function)交给规范级构造调用处理;
// 箭头函数会在这里按原生行为抛出 TypeError。
return Reflect.construct(Constructor, args)
}
const prototype = Constructor.prototype
const instance = Object.create(
prototype !== null &&
(typeof prototype === 'object' || typeof prototype === 'function')
? prototype
: Object.prototype
)
const result = Reflect.apply(Constructor, instance, args)
return result !== null && (
typeof result === 'object' || typeof result === 'function'
)
? result
: instance
}
function Person(name, age) {
this.name = name
this.age = age
}
const person = myNew(Person, 'Ada', 18)
原文实现没有处理构造函数显式返回对象的情况,也把 __proto__ 赋值当作推荐写法;这里使用 Object.create() 和 Reflect.apply() 更清晰。
7. 实现一个教学版 Promise
Promise/A+ 规范实现涉及 thenable 展开、状态不可逆、回调异步化、链式返回和异常传播。下面保留最小可读版本,明确它不是完整的生产级 polyfill:
class TinyPromise {
constructor(executor) {
if (typeof executor !== 'function') {
throw new TypeError('executor 必须是函数')
}
this.state = 'pending'
this.value = undefined
this.handlers = []
let locked = false
const fulfill = value => this.settle('fulfilled', value)
const rejectInternal = reason => this.settle('rejected', reason)
const reject = reason => {
if (locked) return
locked = true
rejectInternal(reason)
}
const resolve = value => {
if (locked) return
locked = true
if (value === this) {
rejectInternal(new TypeError('Promise 不能解析为自身'))
return
}
if (value && (typeof value === 'object' || typeof value === 'function')) {
let then
try {
then = value.then
} catch (error) {
rejectInternal(error)
return
}
if (typeof then === 'function') {
let called = false
try {
then.call(
value,
nextValue => {
if (called) return
called = true
locked = false
resolve(nextValue)
},
reason => {
if (called) return
called = true
rejectInternal(reason)
}
)
} catch (error) {
if (!called) rejectInternal(error)
}
return
}
}
fulfill(value)
}
try {
executor(resolve, reject)
} catch (error) {
reject(error)
}
}
settle(state, value) {
if (this.state !== 'pending') return
this.state = state
this.value = value
this.flush()
}
flush() {
if (this.state === 'pending') return
queueMicrotask(() => {
const handlers = this.handlers.splice(0)
for (const handler of handlers) {
const callback = this.state === 'fulfilled'
? handler.onFulfilled
: handler.onRejected
if (typeof callback !== 'function') {
if (this.state === 'fulfilled') handler.resolve(this.value)
else handler.reject(this.value)
continue
}
try {
handler.resolve(callback(this.value))
} catch (error) {
handler.reject(error)
}
}
})
}
then(onFulfilled, onRejected) {
return new TinyPromise((resolve, reject) => {
this.handlers.push({ onFulfilled, onRejected, resolve, reject })
this.flush()
})
}
catch(onRejected) {
return this.then(undefined, onRejected)
}
finally(onFinally) {
const callback = typeof onFinally === 'function' ? onFinally : () => undefined
return this.then(
value => TinyPromise.resolve(callback()).then(() => value),
reason => TinyPromise.resolve(callback()).then(() => {
throw reason
})
)
}
static resolve(value) {
return value instanceof TinyPromise
? value
: new TinyPromise(resolve => resolve(value))
}
}
面试中应说明还缺少 all、race、allSettled、any、完整的 thenable 边界测试;Promise 本身没有取消协议,请求取消通常由 AbortController 等外部机制提供。实际项目应直接使用原生 Promise。
8. 实现浅拷贝
对象展开和 Object.assign() 都是浅拷贝,只复制可枚举自有属性:
const source = {
name: 'Ada',
profile: { city: 'London' }
}
const copy1 = { ...source }
const copy2 = Object.assign({}, source)
copy1.profile.city = 'Paris'
console.log(source.profile.city) // Paris,共享嵌套对象
数组可以使用 slice()、展开或 Array.from()。浅拷贝不会复制原型、非枚举属性和嵌套引用。
9. 实现深拷贝
优先使用标准 structuredClone():
const source = {
date: new Date(),
values: new Set([1, 2]),
nested: { ok: true }
}
source.self = source
const copy = structuredClone(source)
console.log(copy !== source, copy.self === copy)
structuredClone() 能处理循环引用、Date、RegExp、Map、Set、ArrayBuffer 等一部分类型,但不能克隆函数、DOM 节点和所有宿主对象,还可能转移而不是复制可转移对象。
JSON.parse(JSON.stringify(value)) 只适用于非常受限的 JSON 数据:会丢失 undefined、函数、Symbol、BigInt、循环引用、特殊对象和部分数值语义。自定义递归实现还必须处理原型、属性描述符、Map、Set、TypedArray、循环引用和访问器。
10. 使用 setTimeout 模拟间隔任务
递归定时器可以避免回调执行时间过长时多个 setInterval 回调堆积,并且方便取消:
function createInterval(task, delay) {
let stopped = false
let timer
const tick = () => {
if (stopped) return
const start = performance.now()
task()
const elapsed = performance.now() - start
timer = setTimeout(tick, Math.max(0, delay - elapsed))
}
timer = setTimeout(tick, delay)
return () => {
stopped = true
clearTimeout(timer)
}
}
const stop = createInterval(() => {
console.log('执行一次')
}, 1000)
// stop()
setTimeout 和 setInterval 都是“最早排队时间”,不保证精确间隔;页面后台、主线程长任务和浏览器节流都会影响执行。不要使用已废弃的 arguments.callee。
11. 实现原型继承
现代代码优先使用 class extends:
class Parent {
constructor(name) {
this.name = name
}
say() {
return `父类:${this.name}`
}
}
class Child extends Parent {
constructor(name, school) {
super(name)
this.school = school
}
say() {
return `${super.say()},学校:${this.school}`
}
}
ES5 风格的核心是调用父构造函数获得实例属性,再使用 Object.create() 连接原型,并恢复 constructor:
function Parent(name) {
this.name = name
}
Parent.prototype.say = function () {
return this.name
}
function Child(name, school) {
Parent.call(this, name)
this.school = school
}
Child.prototype = Object.create(Parent.prototype, {
constructor: {
value: Child,
configurable: true,
writable: true
}
})
12. 实现 Event Bus
事件总线本质上是发布/订阅机制。必须支持多个监听器、取消监听、一次性监听和没有监听器时不报错:
class EventBus {
#events = new Map()
on(type, listener) {
const listeners = this.#events.get(type) ?? new Set()
listeners.add(listener)
this.#events.set(type, listeners)
return () => this.off(type, listener)
}
once(type, listener) {
const off = this.on(type, (...args) => {
off()
listener(...args)
})
return off
}
off(type, listener) {
const listeners = this.#events.get(type)
if (!listeners) return false
const removed = listeners.delete(listener)
if (listeners.size === 0) this.#events.delete(type)
return removed
}
emit(type, ...args) {
const listeners = this.#events.get(type)
if (!listeners) return false
for (const listener of [...listeners]) {
listener(...args)
}
return true
}
}
跨组件通信还要考虑组件销毁时自动 off。如果事件需要跨标签页、跨进程或跨设备,应该使用 BroadcastChannel、Service Worker、WebSocket 等真正的通信 API,而不是把一个 EventBus 放在页面内存中。
13. 实现一个简单的双向数据绑定
原文只在 setter 中更新 DOM,没有 getter 返回值,并且使用 innerHTML 可能造成 HTML 注入。下面用 textContent 和 input 事件实现教学示例:
const input = document.querySelector('#input')
const output = document.querySelector('#output')
const state = {}
Object.defineProperty(state, 'text', {
configurable: true,
enumerable: true,
get() {
return input.value
},
set(value) {
const text = String(value)
input.value = text
output.textContent = text
}
})
input.addEventListener('input', event => {
state.text = event.target.value
})
现代响应式框架通常使用 Proxy、依赖追踪和批量更新。原生实现需要进一步处理初始同步、多个观察者、数组、异步刷新和销毁清理。
14. 实现一个简单路由
Hash 路由仍可用于静态站点,但现代应用也常使用 History API 或框架路由:
class HashRouter {
#routes = new Map()
constructor() {
this.render = this.render.bind(this)
window.addEventListener('hashchange', this.render)
window.addEventListener('DOMContentLoaded', this.render)
if (document.readyState !== 'loading') this.render()
}
on(path, handler) {
this.#routes.set(path, handler)
return this
}
render() {
const path = window.location.hash.slice(1) || '/'
const handler = this.#routes.get(path)
handler?.()
}
destroy() {
window.removeEventListener('hashchange', this.render)
window.removeEventListener('DOMContentLoaded', this.render)
}
}
History API 需要处理 pushState、replaceState、popstate、服务端回退到入口文件以及 URL 编码,实际项目优先使用经过验证的路由库或框架方案。
15. 实现图片懒加载
优先使用 HTML 原生能力:
<img src="placeholder.webp" data-src="photo.webp" loading="lazy" alt="示例图片">
需要自定义占位图时使用 IntersectionObserver:
const images = document.querySelectorAll('img[data-src]')
const loadImage = image => {
const source = image.dataset.src
if (!source) return
image.src = source
image.removeAttribute('data-src')
}
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver((entries, instance) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue
loadImage(entry.target)
instance.unobserve(entry.target)
}
}, { rootMargin: '200px 0px' })
images.forEach(image => observer.observe(image))
} else {
images.forEach(loadImage)
}
图片应设置稳定的 width/height 或 aspect-ratio,避免加载后布局跳动;使用 alt 描述内容,不要把所有图片都懒加载到影响 LCP。
16. 设置 rem
原文按屏幕宽度除以 75 设置 rem,这是特定设计稿的约定,不是通用标准。现代 CSS 更推荐使用 clamp()、vw、容器查询和响应式布局:
:root {
font-size: 100%;
}
.page-title {
font-size: clamp(1.5rem, 4vw, 3rem);
}
如果项目确实采用 rem 设计稿,可以使用 resize 或 ResizeObserver,并设置上限,避免超宽屏文字过大:
function setRem() {
const width = document.documentElement.clientWidth
const rootSize = Math.min(width / 75, 24)
document.documentElement.style.fontSize = `${rootSize}px`
}
setRem()
window.addEventListener('resize', setRem, { passive: true })
17. 手写 AJAX
现代代码优先使用 Fetch:
async function requestJson(url, options) {
const response = await fetch(url, options)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return response.json()
}
保留 XHR 的学习示例时,至少要使用正确的 readyState,处理网络错误、超时和状态码:
function requestText(url, { method = 'GET', body, timeout = 0 } = {}) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open(method, url, true)
xhr.timeout = timeout
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.responseText)
} else {
reject(new Error(`HTTP ${xhr.status}`))
}
})
xhr.addEventListener('error', () => reject(new TypeError('网络错误')))
xhr.addEventListener('timeout', () => reject(new Error('请求超时')))
xhr.send(body ?? null)
})
}
GET 查询参数应使用 URL 和 URLSearchParams 编码,POST 的 Content-Type 要与 body 格式匹配,不能把任意对象直接拼成查询字符串。
18. 实现拖拽
现代拖拽应优先使用 Pointer Events,并通过 setPointerCapture() 保证指针移出元素后仍能收到移动事件:
const drag = document.querySelector('#drag')
let startX = 0
let startY = 0
function move(event) {
const deltaX = event.clientX - startX
const deltaY = event.clientY - startY
drag.style.transform = `translate(${deltaX}px, ${deltaY}px)`
}
drag.addEventListener('pointerdown', event => {
startX = event.clientX
startY = event.clientY
drag.setPointerCapture(event.pointerId)
drag.addEventListener('pointermove', move)
})
drag.addEventListener('pointerup', event => {
drag.releasePointerCapture(event.pointerId)
drag.removeEventListener('pointermove', move)
})
还要处理 pointercancel、键盘可操作性、边界限制、滚动容器和 touch-action。如果只是移动元素,transform 可能减少布局影响,但仍应以性能工具验证。
19. 实现节流
节流是在一段时间内限制执行次数。下面的版本支持 leading 和 trailing 语义,并提供取消方法:
function throttle(fn, wait, { leading = true, trailing = true } = {}) {
let timer = null
let lastArgs
let lastThis
let lastCallTime = 0
const invoke = () => {
lastCallTime = Date.now()
const args = lastArgs
const context = lastThis
lastArgs = lastThis = undefined
fn.apply(context, args)
}
const throttled = function (...args) {
const now = Date.now()
if (!lastCallTime && !leading) lastCallTime = now
const remaining = wait - (now - lastCallTime)
lastArgs = args
lastThis = this
if (remaining <= 0 || remaining > wait) {
if (timer) clearTimeout(timer)
timer = null
invoke()
} else if (!timer && trailing) {
timer = setTimeout(() => {
timer = null
if (lastArgs) invoke()
}, remaining)
}
}
throttled.cancel = () => {
clearTimeout(timer)
timer = null
lastArgs = lastThis = undefined
lastCallTime = 0
}
return throttled
}
滚动和指针场景还可以使用 requestAnimationFrame 合并每帧更新;节流不是越短越好,要结合交互目的和 INP 验证。
20. 实现防抖
防抖会在连续触发停止一段时间后执行,适合搜索输入、窗口变化后的重新计算和延迟校验:
function debounce(fn, wait, { leading = false, trailing = true } = {}) {
let timer = null
let lastArgs
let lastThis
let result
const invoke = () => {
const args = lastArgs
const context = lastThis
lastArgs = lastThis = undefined
result = fn.apply(context, args)
return result
}
function debounced(...args) {
const callNow = leading && !timer
lastArgs = args
lastThis = this
clearTimeout(timer)
timer = setTimeout(() => {
timer = null
if (trailing && lastArgs) invoke()
else lastArgs = lastThis = undefined
}, wait)
if (callNow) invoke()
return result
}
debounced.cancel = () => {
clearTimeout(timer)
timer = null
lastArgs = lastThis = undefined
}
return debounced
}
防抖和节流都不是请求取消机制。如果输入变化时要取消旧请求,应把 AbortController 和防抖结合起来;如果要限制事件处理的视觉更新,可以结合 requestAnimationFrame。
总结
- 手写实现首先要说明是教学版还是兼容完整规范;
call/apply/bind要考虑 null、原始值、构造调用和异常清理;new必须处理构造函数显式返回对象;Promise的难点不只是三个状态,还包括 thenable、异步回调、链式返回和错误传播;- 深拷贝优先使用
structuredClone,不要把 JSON 技巧当作通用实现; - DOM 文本默认使用
textContent,避免把用户输入直接写入innerHTML; - 懒加载、拖拽、AJAX、节流和防抖应优先使用现代 Web API,并处理取消、生命周期和可访问性。