# JS 核心六大主题:闭包内存模型、原型链查找、事件流机制与 Promise / Event Loop 实现视角复盘

摘要:JavaScript 的灵活性容易让人迷失在 API 里。本文不聊框架用法,只谈运行时基石。从引擎与规范的实现视角,拆解闭包的内存模型、原型链查找、DOM 事件流,以及异步从回调到 Promise / Event Loop 的演进。这是一份面试备忘,也是前端工程师的底层修炼提纲。

说明:文中未给出实现的函数(如 checkStockcalculateShippingdeductPointssubmitOrdergetProductInfohandleErrorreportAPMapiFn)均为示意依赖,需自行提供。代码示例面向现代浏览器环境。

引言:从 API 调用者到机制掌控者

框架迭代很快,但 Vue / React 仍建立在同一套语言与运行时机制上。建议掌握的六大主题是:

  1. 闭包(作用域与堆上 Context)
  2. 原型链(属性查找与继承)
  3. 事件流(捕获 / 目标 / 冒泡与委托)
  4. 异步演进(回调 → Promise → async/await;「回调地狱」是痛点,不是与前五并列的语言基石)
  5. Promise(状态机与链式规范化)
  6. Event Loop(任务、微任务与渲染协同)

掌握它们,读框架源码和面试答题都会更稳。


一、闭包:堆上 Context 与状态持久化

1. 逃逸变量与 Context

闭包不是魔法,而是「内层函数仍引用外层词法环境」的结果。

更贴近 V8 的说法是:函数执行时有调用栈帧,多数局部量可留在栈上。但若内层函数引用了外层变量(变量逃逸),这些变量会分配到堆上的 Context,由闭包持有引用。外层函数返回、栈帧弹出后,只要闭包还在,对应 Context 就不会被 GC 回收------这就是「记住变量」的原因。

注:旧规范里的 Activation Object / Variable Object 是抽象机概念,不宜与 V8 的 Context 混成「先建 Activation Object 再提升到 Context」。也勿把这里的「分配到堆」说成变量提升(hoisting)。Context 的懒分配、压缩等属于引擎优化细节,会随版本变化。

2. 业务实战:搜索请求竞态(Race Condition)

高频输入场景里,后发请求可能先返回,页面会闪回旧数据。用闭包维护单调递增的 requestId,只采纳「最新一次」的结果,是常见做法。

这里提供两个版本的思路。注意:第一个示例仅解决竞态丢弃,并非完整防抖;工业级方案应合并触发(清定时器)并配合 AbortController 取消过期请求。

基础版:竞态丢弃

javascript 复制代码
function createSearchFetcher(apiFn) {
  let requestId = 0; // 闭包:最新请求序号
  return async function (keyword) {
    const currentRequestId = ++requestId;
    // 可选:简单延迟;这不等于防抖(每次调用仍会请求)
    await new Promise((r) => setTimeout(r, 300));
    
    const result = await apiFn(keyword);
    
    // 若期间又有新输入,丢弃本次结果
    // 注意:此处用 null 表示过期;若业务结果也可能是 null,调用方无法区分
    if (currentRequestId !== requestId) {
      console.log('丢弃过期响应:', keyword);
      return null;
    }
    return result;
  };
}

工业版:防抖 + Abort + 序号双保险

更接近工业实践的组合:防抖合并触发 + Abort 取消 in-flight + 序号立刻作废。

关键约定:

  • 被挤掉 / 已过期 / 已 abort 的调用,统一结算为 { cancelled: true, reason },不用裸 null,以免与业务空结果混淆。
  • apiFn 必须把 signal 传给底层请求(如 fetch(url, { signal }))。
  • 硬前提:若 apiFn 既忽略 signal、又永不 settle(挂死),旧 Promise 会一直 pending;须保证可取消,或再包超时(如 Promise.race)。
  • 关键细节:每次新调用一进来就先 requestId += 1(立刻作废 in-flight),再 abort。不要等到下次定时器触发才递增,否则在「新输入 → 下次 setTimeout 回调」之间存在竞态窗口。这也是为什么你会看到 requestId 有时跳两格------入口加一次,定时器里再加一次,这是刻意的双保险。
ini 复制代码
function isAbortLikeError(err) {
  return (
    err?.name === 'AbortError' ||
    err?.name === 'CanceledError' ||
    err?.code === 'ERR_CANCELED'
  );
}

function createDebouncedSearch(apiFn, wait = 300) {
  let timer = null;
  let controller = null;
  let requestId = 0;
  let pending = null; // { resolve, reject }

  return function (keyword) {
    clearTimeout(timer);
    
    // 结算仍在防抖等待中的上一次 Promise
    if (pending) {
      pending.resolve({ cancelled: true, reason: 'superseded' });
      pending = null;
    }
    
    // ① 先递增:立刻作废已在飞行中的请求
    // ② 再 abort:尽量让底层尽快结束
    requestId += 1;
    controller?.abort();

    return new Promise((resolve, reject) => {
      pending = { resolve, reject };
      
      timer = setTimeout(async () => {
        const settle = pending;
        pending = null;
        
        // 为「这一次实际发出的请求」分配 id
        const currentId = ++requestId;
        const ac = new AbortController();
        controller = ac; 
        
        try {
          const value = await apiFn(keyword, { signal: ac.signal });
          
          if (currentId !== requestId) {
            settle?.resolve({ cancelled: true, reason: 'stale' });
            return;
          }
          
          settle?.resolve({ cancelled: false, value });
        } catch (err) {
          // 优先认本次 signal;再兼容各库的取消错误名
          if (ac.signal.aborted || isAbortLikeError(err)) {
            settle?.resolve({ cancelled: true, reason: 'aborted' });
            return;
          }
          settle?.reject(err);
        }
      }, wait);
    });
  };
}

// 调用方示意
// const outcome = await debouncedSearch('鞋');
// if (outcome.cancelled) return;
// render(outcome.value);

3. 面试高频:循环闭包陷阱

Q:为什么 var 在循环里配合异步会打出同一个值?给出两种修复。

javascript 复制代码
// ❌ var 只有函数作用域,五个回调共享同一个 i
for (var i = 0; i < 5; i++) {
  setTimeout(() => console.log(i), 0); // 五个 5
}

// ✅ 方案 1:let 块级作用域(每次迭代新绑定)
for (let i = 0; i < 5; i++) {
  setTimeout(() => console.log(i), 0); // 0 1 2 3 4
}

// ✅ 方案 2:IIFE 捕获当时的值
for (var i = 0; i < 5; i++) {
  (function (j) {
    setTimeout(() => console.log(j), 0);
  })(i);
}

二、原型链:查找算法与共享行为

1. \[Prototype] 与 class

对象通过内部槽 [[Prototype]] 链接到另一个对象。__proto__ 只是访问该槽的遗留访问器;工程上优先用 Object.getPrototypeOf / Object.create

  • 属性查找:在自身找不到 → 沿 [[Prototype]] 向上,直到 null
  • Class 本质:ES6 class 大体是原型继承的语法糖(方法挂在 prototype 上,实例通过 [[Prototype]] 链接)。但并非毫无差异:例如 class 构造器不可省略 new 调用(其[[Call]]直接抛TypeError)、方法默认不可枚举、super 有特殊绑定语义等。

2. 业务实战:组件实例工厂(共享原型)

低代码 / 动态组件场景里,用共享原型挂公共方法,实例只存差异数据,可降低内存。

ini 复制代码
const BaseComponentProto = {
  render() {
    console.log(`Rendering ${this.type}`);
  },
  destroy() {
    console.log('Cleaning up resources');
  },
};

function ComponentFactory(config) {
  const instance = Object.create(BaseComponentProto);
  instance.type = config.type;
  instance.props = config.props;
  return instance;
}

const btn = ComponentFactory({ type: 'Button', props: {} });
btn.render(); // 方法来自共享原型

3. 面试高频:手写 instanceof

需对齐常见边界:null / undefined / 原始值左侧为 false;右侧须为带 prototype 的对象。

教学说明:下列实现覆盖 OrdinaryHasInstance 路径,未实现 Symbol.hasInstance。若右侧自定义了 @@hasInstance,原生 instanceof 会走该方法,本函数不会。

javascript 复制代码
function myInstanceof(left, right) {
  // 与原生 Ordinary 路径一致:原始值、null、undefined 都为 false
  if (left === null || (typeof left !== 'object' && typeof left !== 'function')) {
    return false;
  }
  if (typeof right !== 'function' && (typeof right !== 'object' || right === null)) {
    throw new TypeError('Right-hand side of instanceof is not an object');
  }
  // OrdinaryHasInstance Step 1:非 callable 直接返回 false
  if (typeof right !== 'function') return false;

  const prototype = right.prototype;
  if (typeof prototype !== 'object' || prototype === null) {
    throw new TypeError('Function has non-object prototype');
  }

  let proto = Object.getPrototypeOf(left);
  while (proto) {
    if (proto === prototype) return true;
    proto = Object.getPrototypeOf(proto);
  }
  return false;
}

三、事件流:DOM 交互与框架委托

1. 规范视角:三阶段与 React 委托

现代浏览器遵循:捕获 → 目标 → 冒泡。

React 为减少监听器数量,把多数事件委托在根容器上。更准确的说法是:

  • React 17+ 委托在应用 root(不再是 document)。
  • 原生事件沿 DOM 传播到 root 的监听时,React 再按组件树派发合成事件。
  • 不宜简单说成「一定在整个原生冒泡完全结束之后」。
  • 注意 16 与 17+ 挂载点差异;少数事件(如部分 scroll、媒体相关事件)不完全走同一套委托。

2. 业务实战:Dashboard 全局快捷键

可用捕获阶段做全局拦截(如阻止浏览器默认保存),具体业务仍可在目标 / 冒泡阶段处理。跨平台需同时考虑 Windows 的 Ctrl 与 macOS 的 ⌘(metaKey)。key 在按住 Shift 时可能是 'S',应做大小写不敏感比较。

javascript 复制代码
window.addEventListener(
  'keydown',
  (e) => {
    if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
      e.preventDefault();
      console.log('触发全局保存逻辑');
    }
  },
  true // 捕获阶段
);

3. 面试高频:事件委托

万级列表不要给每个子节点绑点击;在父节点委托,并用 closest 处理点击落在子元素内部的情况。e.target 可能是文本节点,需先落到 Element。

注意:假定页面上已存在 id="list" 的节点;若 getElementById 得到 null,直接 addEventListener 会抛错。Element.closest 不会穿过 Shadow DOM 边界。若列表在 Web Component 的 shadow 树内,需改用 event.composedPath() 等方案,下文示例仅覆盖普通 DOM。

javascript 复制代码
const list = document.getElementById('list');
if (!list) {
  throw new Error('Expected #list to exist');
}

list.addEventListener('click', function (e) {
  const raw = e.target;
  const el =
    raw instanceof Element
      ? raw
      : raw instanceof Node
      ? raw.parentElement
      : null;
      
  const li = el?.closest('li');
  if (!li || !this.contains(li)) return;
  
  console.log('点击了列表项:', li.textContent);
});

四、异步演进:从回调嵌套到线性控制流

1. 问题本质:控制反转与信任危机

回调地狱的本质是 控制权交给第三方(Inversion of Control):调用次数、时机、错误通道都可能失控。深层嵌套还让错误处理分散、难读。

2. 业务实战:电商下单四阶段

阶段一:回调嵌套(难维护)

javascript 复制代码
checkStock(productId, (stock) => {
  calculateShipping(stock, (shipping) => {
    deductPoints(shipping, (points) => {
      submitOrder(points, (orderId) => {
        console.log('订单提交成功:', orderId);
      }, handleError);
    }, handleError);
  }, handleError);
}, handleError);

阶段二:Promise 链(扁平化 + 统一 catch)

scss 复制代码
checkStock(productId)
  .then((stock) => calculateShipping(stock))
  .then((shipping) => deductPoints(shipping))
  .then((points) => submitOrder(points))
  .then((orderId) => console.log('订单提交成功:', orderId))
  .catch(handleError);

阶段三:async/await(同步风格)

语义上可类比「Generator + Promise 自动执行器」;历史上也确有 co 等库走这条路。现代引擎多为独立实现,未必再 desugar 成真正的 Generator。

javascript 复制代码
async function processOrder(productId) {
  try {
    const stock = await checkStock(productId);
    const shipping = await calculateShipping(stock);
    const points = await deductPoints(shipping);
    const orderId = await submitOrder(points);
    console.log('订单提交成功:', orderId);
  } catch (err) {
    handleError(err);
  }
}

阶段四:无依赖步骤并行

javascript 复制代码
async function processOrderOptimized(productId) {
  const [stock, productInfo] = await Promise.all([
    checkStock(productId),
    getProductInfo(productId),
  ]);
  const shipping = await calculateShipping(stock, productInfo.weight);
  // ...
}

3. 面试高频:简易 co(Generator 自动执行)

async/await 普及前,常用 Generator + 执行器。下面是教学用实现:返回 Promise,正确续传 throw,并在 Generator return 值为 thenable 时拆一层(更接近 async 函数的 return 语义)。

注:此实现未覆盖 yield* 委托情况。

scss 复制代码
function co(generatorFn) {
  const gen = generatorFn();
  return new Promise((resolve, reject) => {
    function onFulfilled(value) {
      let step;
      try {
        step = gen.next(value);
      } catch (e) {
        return reject(e);
      }
      next(step);
    }
    function onRejected(err) {
      let step;
      try {
        step = gen.throw(err);
      } catch (e) {
        return reject(e);
      }
      next(step);
    }
    function next({ done, value }) {
      if (done) {
        // 与 async 的 return 类似:若最终值是 thenable,拆一层再结束
        return Promise.resolve(value).then(resolve, reject);
      }
      Promise.resolve(value).then(onFulfilled, onRejected);
    }
    onFulfilled(undefined);
  });
}

// 使用示例
co(function* () {
  const stock = yield checkStock(101);
  const shipping = yield calculateShipping(stock);
  console.log(shipping);
});

五、Promise:状态机与异步规范化

1. 规范核心(Promise/A+)

  • 三态:pendingfulfilled / rejected,不可逆、不可再变。
  • 链式:then 总是返回新 Promise,支撑链式调用。
  • 微任务:then 回调以微任务调度(不是 setTimeout 宏任务)。
  • 完整实现:还需 Resolution Procedure(解析 thenable)、值穿透、错误冒泡等。

2. 业务实战:并发请求与 APM

Promise.allSettled 不会因单个失败而 reject,适合「都要等完再汇总」。失败信息在每项的 status 里,不要指望外层 catch 接到业务失败。

另:fetch 在收到 HTTP 4xx / 5xx 时仍然 fulfill(status 为 'fulfilled'),只有网络故障、CORS 阻断、AbortSignal 触发等才会 reject。业务成败需再看 response.ok / status

javascript 复制代码
async function fetchWithMonitoring(urls) {
  const start = performance.now();
  const results = await Promise.allSettled(urls.map((url) => fetch(url)));
  
  try {
    reportAPM({ duration: performance.now() - start }); // 示意
  } catch (err) {
    console.error('监控上报失败', err);
  }
  
  // 如需区分「网络失败」与「HTTP 错误」:
  // results.forEach((r) => {
  //   if (r.status === 'fulfilled' && !r.value.ok) { /* HTTP 错误 */ }
  //   if (r.status === 'rejected') { /* 网络等失败 */ }
  // });
  
  return results;
  // [{ status: 'fulfilled', value: Response }, { status: 'rejected', reason }, ...]
}

3. 面试高频:教学版 Promise(非完整 A+)

下面演示状态机、回调队列与链式返回。刻意用 queueMicrotask

明确边界:构造器 resolve(x) 不拆 thenable;then 回调返回值不走 Promise Resolution Procedure(即回调若返回Promise/thenable,不会被自动展开------链式传递的是Promise对象本身而非其值);无 catch / finally 等。因此只适合讲解状态机与微任务调度,不能当生产实现。

ini 复制代码
class MyPromise {
  constructor(executor) {
    this.status = 'pending';
    this.value = undefined;
    this.reason = undefined;
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];

    const resolve = (value) => {
      if (this.status !== 'pending') return;
      this.status = 'fulfilled';
      this.value = value;
      this.onFulfilledCallbacks.forEach((fn) => fn());
    };

    const reject = (reason) => {
      if (this.status !== 'pending') return;
      this.status = 'rejected';
      this.reason = reason;
      this.onRejectedCallbacks.forEach((fn) => fn());
    };

    try {
      executor(resolve, reject);
    } catch (e) {
      reject(e);
    }
  }

  then(onFulfilled, onRejected) {
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : (v) => v;
    onRejected =
      typeof onRejected === 'function'
        ? onRejected
        : (e) => {
            throw e;
          };

    return new MyPromise((resolve, reject) => {
      const run = (cb, arg) => {
        queueMicrotask(() => {
          try {
            const x = cb(arg);
            resolve(x);
          } catch (e) {
            reject(e);
          }
        });
      };

      if (this.status === 'fulfilled') {
        run(onFulfilled, this.value);
      } else if (this.status === 'rejected') {
        run(onRejected, this.reason);
      } else {
        this.onFulfilledCallbacks.push(() => run(onFulfilled, this.value));
        this.onRejectedCallbacks.push(() => run(onRejected, this.reason));
      }
    });
  }
}

六、Event Loop:调度与 UI 协同

1. 更准确的模型(浏览器主线程 / HTML 事件环简化版)

下文指浏览器主线程。规范用语是 task(任务)与 microtask(微任务);中文面试常说的「宏任务」是社区俗称,约等于 task。Node.js 的阶段模型(含 process.nextTick 等)与此不同,勿直接套用。

注:本模型指经典 <script> 执行流程,不含 type="module" / defer 等加载细节。

浏览器主线程可简化为:

  1. 从任务队列取一个 task 执行(每个经典 <script>块的执行对应一个独立task)。
  2. 执行完毕后清空微任务队列(微任务中再排队的微任务也会在本轮清空)。
  3. 浏览器可能渲染(按帧预算,不是每轮必渲染;渲染前后规范里还有 microtask checkpoint,此处从略)。
  4. 再取下一个 task(setTimeout、部分 I/O / UI 任务等)。
类型 常见示例 相对时机
微任务 Promise.thenqueueMicrotaskMutationObserverawait 之后的续跑 当前 task 结束后、下一 task 前清空
任务(俗称宏任务) setTimeoutsetInterval、部分 I/O / UI 任务 进入后续 task

勿写成固定流水线「同步 → 微任务 → 必渲染 → 宏任务」。同步代码属于当前 task 的一部分;渲染是可选步骤。以上是便于面试与工程沟通的简化模型,不是规范全文。

2. 业务实战:大数据分片渲染(Time Slicing)

一次插入十万 DOM 会长时间占住主线程。用任务切片可让出主线程(注意 setTimeout(0) 仍有最小延迟钳制)。若目标是「跟帧绘制」,可再结合 requestAnimationFrame / scheduler.postTask

ini 复制代码
function renderLargeList(data, container) {
  let index = 0;
  const chunkSize = 20;

  function processChunk() {
    const fragment = document.createDocumentFragment();
    for (let i = 0; i < chunkSize && index < data.length; i++) {
      const div = document.createElement('div');
      div.textContent = data[index++];
      fragment.appendChild(div);
    }
    container.appendChild(fragment);

    if (index < data.length) {
      setTimeout(processChunk, 0); // 让出主线程;跟帧可用 rAF
    }
  }
  processChunk();
}

3. 面试高频:输出顺序

javascript 复制代码
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 输出: 1 → 4 → 3 → 2

原因:

  • 14 在当前 task(script)同步执行;
  • 3 是微任务,在该 task 结束后、下一 task 前执行;
  • 2 是 timer 任务,进入后续轮次。

结语

闭包与 Context、原型查找、事件流、Promise 状态机、Event Loop 调度,构成前端可迁移的护城河。读框架时先映射到这些机制,比死记 API 更抗过时。

互动:你在项目里遇过最棘手的闭包或异步竞态是什么?欢迎分享踩坑与解法。

相关推荐
小智社群7 小时前
VR漫游视频完整版
前端·javascript·音视频
小程故事多_808 小时前
拆解SOP+ReAct对话Agent,告别硬编码,解锁智能对话的柔性迭代能力
前端·人工智能·react.js·前端框架
lzjava20248 小时前
LangChain Prompt提示词工程
前端·langchain·prompt
nicole bai8 小时前
vue 自定义模块内容
前端·javascript·vue.js
清水白石0088 小时前
Python 类定义阶段自动注册子类:从 `__init_subclass__` 到插件系统实战
linux·前端·python
西西学代码8 小时前
Flutter---StreamBuilder案例
javascript·windows·flutter
用户69371750013849 小时前
Kimi K3 综合能力处于全球第一梯队
android·前端·后端
anno9 小时前
Agent 新手 Skill 优先级指南:从第一套必装工作流,到专业能力补全
前端·后端
Liora_Yvonne9 小时前
前端请求层到底怎么封?为什么全局 axios 单例越用越难维护
前端
anno9 小时前
别再把文档“一刀切”:用 LangChain.js 做好 RAG 的第一公里
前端·后端