技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTJS

20 道 JavaScript 原理题:从手写实现到边界条件

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/04-20道JS原理题助你面试一臂之力!
本文目录22 个章节
  1. 1. 实现一个 call
  2. 2. 实现一个 apply
  3. 3. 实现一个 bind
  4. 4. instanceof 的原理
  5. 5. Object.create 的基本原理
  6. 6. new 的本质
  7. 7. 实现一个教学版 Promise
  8. 8. 实现浅拷贝
  9. 9. 实现深拷贝
  10. 10. 使用 setTimeout 模拟间隔任务
  11. 11. 实现原型继承
  12. 12. 实现 Event Bus
  13. 13. 实现一个简单的双向数据绑定
  14. 14. 实现一个简单路由
  15. 15. 实现图片懒加载
  16. 16. 设置 rem
  17. 17. 手写 AJAX
  18. 18. 实现拖拽
  19. 19. 实现节流
  20. 20. 实现防抖
  21. 总结
  22. 参考资料

20 道 JavaScript 原理题:从手写实现到边界条件

Category(分类): JavaScript Status: 已更新

本文保留原文的 20 个练习方向,但把原示例中被压缩在一行的代码重新排版,并修正了 call 重复执行、apply 使用 evalnew 忽略构造函数返回值、JSON.parse 使用 evalinnerHTML 注入风险和懒加载计算不准确等问题。

这些实现用于理解机制,不是为了覆盖 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

applycall 的区别是参数以一个类数组对象传入。它不要求参数一定是数组,也不需要 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 还会处理函数的 lengthnamenew.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) 大体会:

  1. 创建一个新对象;
  2. 把新对象的原型连接到 Constructor.prototype
  3. 以新对象为 this 调用构造函数;
  4. 如果构造函数返回对象或函数,则使用该返回值,否则返回新对象。
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))
  }
}

面试中应说明还缺少 allraceallSettledany、完整的 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()

setTimeoutsetInterval 都是“最早排队时间”,不保证精确间隔;页面后台、主线程长任务和浏览器节流都会影响执行。不要使用已废弃的 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 注入。下面用 textContentinput 事件实现教学示例:

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 需要处理 pushStatereplaceStatepopstate、服务端回退到入口文件以及 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/heightaspect-ratio,避免加载后布局跳动;使用 alt 描述内容,不要把所有图片都懒加载到影响 LCP。

16. 设置 rem

原文按屏幕宽度除以 75 设置 rem,这是特定设计稿的约定,不是通用标准。现代 CSS 更推荐使用 clamp()vw、容器查询和响应式布局:

:root {
  font-size: 100%;
}

.page-title {
  font-size: clamp(1.5rem, 4vw, 3rem);
}

如果项目确实采用 rem 设计稿,可以使用 resizeResizeObserver,并设置上限,避免超宽屏文字过大:

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 查询参数应使用 URLURLSearchParams 编码,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,并处理取消、生命周期和可访问性。

参考资料

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS