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

显示模式

登录
ARCHIVE DOCUMENTJS

原生 JS 灵魂之问(下),冲刺进阶最后一公里

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/86-原生JS灵魂之问(下), 冲刺🚀进阶最后一公里
本文目录19 个章节
  1. 第24篇:JavaScript 内存机制之问——数据是如何存储的?
  2. 第25篇:V8 引擎如何进行垃圾回收?
  3. 第26篇:描述一下 V8 执行一段 JS 代码的过程?
  4. 第28篇:如何理解 Event Loop——宏任务和微任务
  5. 第29篇:如何理解 Event Loop——浏览器篇
  6. 第30篇:如何理解 Event Loop——Node.js 篇
  7. 第31篇:Node.js 中的异步、非阻塞 I/O 是如何实现的?
  8. 第32篇:JS 异步编程有哪些方案?为什么会出现这些方案?
  9. 第33篇:简单实现 Node 中回调函数的机制
  10. 第34篇:Promise 凭借什么消灭了回调地狱?
  11. 第35篇:为什么 Promise 要引入 microtask?
  12. 第36篇:Promise 如何实现链式调用?
  13. 第37篇:实现 Promise 的 resolve、reject 和 finally
  14. 第38篇:实现 Promise 的 all 和 race
  15. 第39篇:谈谈生成器以及协程
  16. 第40篇:如何让 Generator 的异步代码按顺序执行完毕?
  17. 第41篇:解释一下 async/await 的运行机制
  18. 第42篇:forEach 中用 await 会产生什么问题?
  19. 经验分享

原生 JS 灵魂之问(下),冲刺进阶最后一公里

Category(分类): JavaScript Status: 已整理

本文保留原文从 JavaScript 内存、V8、事件循环、Node.js I/O、Promise、Generator 到 async/await 的系统路线。原文中的很多代码来自旧文章抓取,存在函数名称粘连、数组构造器名称错误、缺少空格、错误变量名和过时 V8 内部结论;下面保留学习目标并重写关键示例。

第24篇:JavaScript 内存机制之问——数据是如何存储的?

网上常说“基本数据类型在栈中,引用数据类型在堆中”。这有助于初学者建立直觉,但不是 ECMAScript 规定的内存布局。JavaScript 规范只规定值、引用、执行上下文和可达性;具体引擎可以根据优化策略把数据放在寄存器、栈、堆或其他内部区域。

更可靠的表述是:

  • 原始值包括 booleannullundefinednumberstringsymbolbigint
  • 对象值包括普通对象、数组、函数、Map、Set、Promise 和宿主对象。
  • 变量赋值和参数传递都是按值传递;对象的“值”是一个指向对象的引用值。
  • 闭包可以让外层环境中的变量在外层函数返回后继续可达,垃圾回收器会根据可达性决定是否回收,而不是简单根据“栈/堆”分类决定。
const object = { value: 1 }
const other = object

other.value = 2
console.log(object.value) // 2

赋值 other = object 复制的是引用值,不是对象本身。要得到新对象,必须明确使用浅拷贝、深拷贝或结构化克隆。

function createCounter() {
  let count = 0
  return () => ++count
}

const next = createCounter()
console.log(next()) // 1
console.log(next()) // 2

createCounter 返回后,count 仍被返回的函数引用,因此不能被当成不可达变量立即回收。闭包也可能因为事件监听器、定时器、缓存或全局引用长期存活,形成实际的内存保留。

执行上下文与内存示意图

原文把系统栈指针和 JavaScript 执行上下文直接等同,是实现层面的教学模型。调用栈可以帮助我们理解函数嵌套:

function f(value) {
  return value + 1
}

function run(value) {
  return f(value)
}

console.log(run(1)) // 2

执行 run 时会暂时保存它的调用状态,f 返回后再恢复 run;但引擎内部的栈帧布局不属于 JavaScript 代码可观察的标准保证。

第25篇:V8 引擎如何进行垃圾回收?

JavaScript 由运行时自动管理内存。V8 使用分代、增量、并发和并行等技术降低垃圾回收对主线程的影响,但垃圾回收仍可能带来暂停和 CPU/内存开销。

V8 内存限制

旧文章常写 64 位 V8 约 1.4GB、32 位约 0.7GB。这些数字只适合特定年代和配置,现代 V8/Node.js 的堆上限会受指针压缩、版本、平台、进程内存和启动参数影响,不能作为所有环境的固定值。

Node.js 可以通过启动参数调整老生代上限:

node --max-old-space-size=2048 app.js

当前 Node.js 更常用 --max-semi-space-size 调整新生代半空间;参数含义和可用选项以目标 Node 版本文档为准:

node --max-semi-space-size=32 app.js

调大堆上限不是解决内存泄漏的替代方案。若对象仍然可达,垃圾回收器不会把它当垃圾回收;应先用 heap snapshot、Allocation instrumentation 和引用链定位真正的保留者。

新生代与老生代

V8 使用分代假设:许多临时对象很快死亡,少数长期存活对象会晋升到老生代。新生代通常采用复制/疏散思路,老生代采用标记、清扫、压缩以及增量/并发辅助。

V8 堆空间历史示意图

早期教材常把新生代画成 From/To 两个半空间:

新生代 From/To 半空间示意图

在一次 scavenger 回收中,仍可达对象会被复制或疏散到另一空间,随后交换角色。这样可以同时回收死对象并减少碎片,但需要预留空间,也不适用于大型老生代对象。当前 V8 的具体实现还会使用页、对象移动、记忆集和写屏障,不要把简单的二分图当作全部实现。

老生代、标记与压缩

垃圾回收器会从根集合出发遍历可达对象,例如活动执行上下文、全局对象、原生句柄和闭包引用。不可达对象才具备被回收的资格。常见流程可以概括为:

  1. 标记可达对象。
  2. 清扫不可达对象。
  3. 必要时移动存活对象,压缩碎片。
  4. 更新被移动对象的引用。

堆内存碎片历史示意图

标记压缩后的空间示意图

“所有垃圾回收都会暂停 JavaScript”“增量标记把阻塞减少到固定的六分之一”都过于绝对。V8 会尽量把标记、清扫和压缩拆分为增量、并行或并发工作,但某些阶段仍可能需要主线程短暂停顿,实际时间取决于对象规模、写屏障、设备和版本。

如何排查内存问题

const retained = []

function onMessage(message) {
  retained.push(message)
}

// 如果 retained 无限增长,数据就会持续可达,GC 无法回收它们

常见泄漏来源包括:

  • 全局数组、缓存没有上限。
  • 事件监听器或 Observer 没有解除。
  • 定时器闭包长期引用大型对象。
  • DOM 节点已经从文档移除,但仍被 JavaScript 集合引用。
  • Promise、任务队列或第三方库保留了不再需要的回调。

第26篇:描述一下 V8 执行一段 JS 代码的过程?

“JavaScript 是解释型语言”或“JavaScript 是编译型语言”都过于简单。现代 V8 会根据代码类型和运行状态,经历解析、字节码解释执行、分层编译和去优化等过程。实现细节会随版本变化。

1. 解析生成 AST

源代码首先经过词法分析和语法分析,生成抽象语法树(AST):

const name = 'sanyuan'
console.log(name)

AST 是解析器和后续编译流程使用的结构化表示。Babel 也会解析 AST,但 Babel 是源码转换工具,不等于 V8 的运行时编译器。

词法分析 Token 历史示意图

AST 历史示意图

2. 生成字节码

V8 的 Ignition 会生成并解释执行字节码。字节码比针对特定 CPU 的机器码更紧凑,适合快速启动;它不是可以直接交给 CPU 执行的机器指令。

AST 到字节码历史示意图

字节码与机器码体积历史对比

3. 分层编译与优化

V8 会根据运行反馈对热点代码进行优化。现代 V8 可能涉及 Sparkplug、Maglev、TurboFan 等不同层级;优化代码依赖类型反馈,如果假设失效,就可能去优化回到较通用的执行路径。

function add(left, right) {
  return left + right
}

for (let i = 0; i < 100_000; i++) {
  add(i, 1)
}

这不表示“运行越久一定越快”:类型变化、隐藏类变化、频繁去优化、内存压力和函数本身的复杂度都会影响结果。性能判断应使用目标版本的基准测试和 profiler。

第28篇:如何理解 Event Loop——宏任务和微任务

“宏任务/微任务”是社区常用名称。浏览器规范通常使用 task/microtask;Node.js 还有 next tick queue 和 libuv 阶段。

浏览器的基本模型

console.log('script start')

setTimeout(() => {
  console.log('timer')
}, 0)

Promise.resolve().then(() => {
  console.log('promise')
})

console.log('script end')

常见浏览器输出:

script start
script end
promise
timer

整段脚本是一个 task。Promise reaction 在脚本 task 结束后的 microtask checkpoint 中执行;计时器回调是后续 task。微任务会清空到稳定,但渲染是否在两个 task 之间发生由用户代理决定。

为什么需要 microtask?

如果所有异步回调都排到很长的 task 队列末尾,已经完成的 Promise 可能要等待许多不相关任务。microtask 能让 Promise reaction 在当前 task 结束后尽快运行,同时不把回调同步插入当前调用栈。

Promise.resolve().then(() => console.log('microtask'))
console.log('sync')

输出:

sync
microtask

垃圾回收不是 JavaScript microtask;它是运行时内部工作,不应列入 Promise 微任务清单。Object.observe 也已废弃,不能作为现代 API 推荐。

第29篇:如何理解 Event Loop——浏览器篇

console.log('start')

setTimeout(() => {
  console.log('timeout')
}, 0)

Promise.resolve().then(() => {
  console.log('resolve')
})

console.log('end')

常见输出为:

start
end
resolve
timeout

可以按以下步骤分析:

  1. 整段脚本作为当前 task 执行,同步代码先输出 startend
  2. setTimeout 注册后续 timer task。
  3. Promise.then 注册 microtask。
  4. 当前 task 结束,清空 microtask,输出 resolve
  5. 后续 task 执行计时器,输出 timeout

再看一个嵌套例子:

Promise.resolve().then(() => {
  console.log('Promise1')
  setTimeout(() => {
    console.log('setTimeout2')
  }, 0)
})

setTimeout(() => {
  console.log('setTimeout1')
  Promise.resolve().then(() => {
    console.log('Promise2')
  })
}, 0)

console.log('start')

常见输出:

start
Promise1
setTimeout1
Promise2
setTimeout2

不要把“执行完微任务后一定立刻绘制”写成规范保证;浏览器可能跳过、合并或延后更新渲染。requestAnimationFrame 属于渲染更新流程,而不是普通宏任务。

第30篇:如何理解 Event Loop——Node.js 篇

Node.js 和浏览器的事件循环有明显差异。Node 使用 libuv,常见阶段包括 timers、pending callbacks、poll、check 和 close callbacks;具体阶段顺序及计时器时机还与 Node/libuv 版本相关。

Node.js 事件循环历史流程图

1. process.nextTick、Promise 和阶段

console.log('global')

process.nextTick(() => console.log('nextTick'))
Promise.resolve().then(() => console.log('promise'))
setImmediate(() => console.log('immediate'))

常见输出是:

global
nextTick
promise
immediate

process.nextTick 不属于 libuv 的普通阶段,它使用 Node 特有的 next tick queue,通常会在当前操作后优先处理。递归 nextTick 会饿死 I/O 和其他 microtask:

function starve() {
  process.nextTick(starve)
}

// 不要执行:会阻塞事件循环
// starve()

2. setTimeoutsetImmediate

在 Node 主模块顶层,下面的输出不能写死:

setTimeout(() => console.log('timeout'), 0)
setImmediate(() => console.log('immediate'))

在 I/O 回调中,通常更容易观察到 setImmediate 先执行:

const fs = require('node:fs')

fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0)
  setImmediate(() => console.log('immediate'))
})

旧文章按 Node 10 或更早版本总结的“某一轮一定先 timers 后 check”不能直接套到所有新版本。Node 20 使用的新 libuv 计时器阶段安排也影响了部分边界顺序。

3. Node 与浏览器的主要区别

  • 浏览器以 HTML task source、microtask checkpoint 和渲染机会描述宿主调度。
  • Node 以 libuv 阶段、next tick queue、Promise microtask 和 I/O 描述运行时调度。
  • 浏览器没有 process.nextTick 和标准 setImmediate
  • Node 没有浏览器页面的 DOM 渲染阶段。

第31篇:Node.js 中的异步、非阻塞 I/O 是如何实现的?

“阻塞/非阻塞”主要是操作系统 I/O 语义;“异步 API”是应用收到完成通知的组织方式。Node.js 的 JavaScript 回调通常在事件循环线程执行,但底层工作可能由操作系统异步机制、内核事件通知或 libuv 线程池完成。

浏览器和 Node.js 都不应简化为“有一个隐藏线程帮 JavaScript 做完所有异步任务”。不同 I/O 类型和平台的实现不同:文件系统操作常可能使用线程池,网络 I/O 常结合系统事件通知,DNS、加密和压缩也可能使用线程池。

const fs = require('node:fs')

fs.readFile('./test.txt', 'utf8', (error, data) => {
  if (error) {
    console.error(error)
    return
  }
  console.log(data)
})

console.log('readFile 已发起')

Node.js I/O 观察者历史示意图

可以把过程概括成:Node API 创建请求 → libuv/操作系统或线程池执行 → 完成事件进入可处理队列 → 回调在事件循环适当阶段执行。node_file.cc、IOCP、GetQueuedCompletionStatus 等是某些平台/版本的实现细节,不应当作为所有系统的统一流程。

第32篇:JS 异步编程有哪些方案?为什么会出现这些方案?

异步代码的组织方式随着语言发展逐步演进。

回调函数

fs.readFile('1.json', (error, data) => {
  if (error) return handleError(error)
  fs.readFile('2.json', (error, nextData) => {
    if (error) return handleError(error)
    console.log(data, nextData)
  })
})

回调本身并没有错,Node 风格回调可以表示错误优先协议、事件通知和流式处理;嵌套过深、错误处理分散和取消困难时,维护成本会上升。

Promise

readFilePromise('1.json')
  .then((data) => readFilePromise('2.json'))
  .then((data) => readFilePromise('3.json'))
  .catch(handleError)

Promise 提供统一的成功/失败状态和链式组合。它不会自动让任务并行;链式 then 中返回下一个 Promise 通常表示串行依赖。

Generator + co

Generator 可以暂停并恢复,co 等库曾经把 Promise/Thunk 递归驱动起来:

function* readFiles() {
  const first = yield readFilePromise('1.json')
  const second = yield readFilePromise('2.json')
  return [first, second]
}

现代项目通常直接使用 async/await,但 Generator 仍用于迭代器、协作式控制流和部分库的实现。

async/await

async function readFiles() {
  const first = await readFilePromise('1.json')
  const second = await readFilePromise('2.json')
  return [first, second]
}

async 函数总是返回 Promise;await 会等待一个值被 Promise 解析,并把当前 async 函数的后续部分安排到后续 Promise reaction 中。它让代码看起来像同步流程,但不阻塞 JavaScript 线程。

第33篇:简单实现 Node 中回调函数的机制

Node 的 EventEmitter 是事件发布-订阅的一种实现。下面给出一个简化版本,重点展示监听器列表、一次性监听和删除:

class SimpleEventEmitter {
  #events = new Map()

  on(type, listener) {
    if (typeof listener !== 'function') {
      throw new TypeError('listener must be a function')
    }
    const listeners = this.#events.get(type) ?? []
    listeners.push(listener)
    this.#events.set(type, listeners)
    return this
  }

  addListener(type, listener) {
    return this.on(type, listener)
  }

  once(type, listener) {
    const wrapper = (...args) => {
      this.off(type, wrapper)
      listener.apply(this, args)
    }
    return this.on(type, wrapper)
  }

  off(type, listener) {
    const listeners = this.#events.get(type)
    if (!listeners) return this

    const next = listeners.filter((item) => item !== listener)
    if (next.length === 0) this.#events.delete(type)
    else this.#events.set(type, next)
    return this
  }

  removeListener(type, listener) {
    return this.off(type, listener)
  }

  removeAllListeners(type) {
    if (type === undefined) this.#events.clear()
    else this.#events.delete(type)
    return this
  }

  emit(type, ...args) {
    const listeners = this.#events.get(type)
    if (!listeners) return false

    for (const listener of [...listeners]) {
      listener.apply(this, args)
    }
    return true
  }
}

const emitter = new SimpleEventEmitter()
emitter.on('type', (value) => console.log(value))
emitter.once('type', () => console.log('once'))
emitter.emit('type', 1)
emitter.emit('type', 2)

这仍不是 Node EventEmitter 的完整实现。真实模块还处理 error 事件、监听器顺序、最大监听器警告、wrapper 引用和异常传播等细节。

第34篇:Promise 凭借什么消灭了回调地狱?

Promise 常被概括为三种能力:

  • 回调延迟绑定:任务创建和成功/失败处理可以分离。
  • 返回值穿透then 返回新 Promise,回调返回值会决定下一个 Promise。
  • 错误冒泡:未处理的 rejection 可以沿链传递到后续 catch
readFilePromise('1.json')
  .then((data) => readFilePromise('2.json'))
  .then((data) => readFilePromise('3.json'))
  .catch((error) => {
    console.error(error)
  })

Promise 并没有消除所有异步复杂度:并行、取消、超时、重试和资源释放仍需要业务代码设计。

第35篇:为什么 Promise 要引入 microtask?

Promise executor 在创建时同步执行,但 reaction 必须异步调用,即使 Promise 已经完成:

const promise = Promise.resolve(1)

promise.then((value) => console.log(value))
console.log('after')

输出:

after
1

这样可以避免 handler 同步插入当前调用栈,也比把每个 reaction 都排成普通 task 更快。ECMAScript 将 Promise reaction 作为 job 交给宿主,浏览器和 Node 再把它接入各自的 microtask 处理机制。

第36篇:Promise 如何实现链式调用?

Promise 是一个不可逆的状态机:pending 只能转为 fulfilledrejected,第一次有效的 settle 会锁定状态。

const PENDING = 'pending'
const FULFILLED = 'fulfilled'
const REJECTED = 'rejected'

class TeachingPromise {
  constructor(executor) {
    if (typeof executor !== 'function') {
      throw new TypeError('executor must be a function')
    }

    this.state = PENDING
    this.value = undefined
    this.reason = undefined
    this.handlers = []

    const resolve = (value) => this.#settle(FULFILLED, value)
    const reject = (reason) => this.#settle(REJECTED, reason)

    try {
      executor(resolve, reject)
    } catch (error) {
      reject(error)
    }
  }

  #settle(state, value) {
    if (this.state !== PENDING) return
    this.state = state
    if (state === FULFILLED) this.value = value
    else this.reason = value

    queueMicrotask(() => {
      const handlers = this.handlers.splice(0)
      for (const handler of handlers) this.#run(handler)
    })
  }

  #run(handler) {
    const callback = this.state === FULFILLED
      ? handler.onFulfilled
      : handler.onRejected
    const value = this.state === FULFILLED ? this.value : this.reason

    if (typeof callback !== 'function') {
      const pass = this.state === FULFILLED ? handler.resolve : handler.reject
      pass(value)
      return
    }

    try {
      handler.resolve(callback(value))
    } catch (error) {
      handler.reject(error)
    }
  }

  then(onFulfilled, onRejected) {
    return new TeachingPromise((resolve, reject) => {
      const handler = { onFulfilled, onRejected, resolve, reject }
      if (this.state === PENDING) this.handlers.push(handler)
      else queueMicrotask(() => this.#run(handler))
    })
  }

  catch(onRejected) {
    return this.then(undefined, onRejected)
  }
}

上面只展示状态机、回调数组和异步 handler 的骨架,仍未完整实现 Promises/A+ 的 thenable resolution procedure。完整的教学实现请参考第 74 篇;生产代码使用原生 Promise

thenable 解析

then 回调返回 Promise 或 thenable 时,后续 Promise 必须跟随它的最终状态;返回普通值则 fulfilled,回调抛错则 rejected。解析过程要处理自解析、重复调用和 getter 抛错:

function resolvePromiseLike(nextPromise, value, resolve, reject) {
  if (value === nextPromise) {
    reject(new TypeError('A promise cannot resolve to itself'))
    return
  }

  if (value !== null && (typeof value === 'object' || typeof value === 'function')) {
    let called = false
    try {
      const then = value.then
      if (typeof then === 'function') {
        then.call(
          value,
          (nextValue) => {
            if (called) return
            called = true
            resolvePromiseLike(nextPromise, nextValue, resolve, reject)
          },
          (reason) => {
            if (called) return
            called = true
            reject(reason)
          },
        )
        return
      }
    } catch (error) {
      if (!called) reject(error)
      return
    }
  }

  resolve(value)
}

这是核心思想的简化片段,不要直接覆盖原生 Promise 的方法。

第37篇:实现 Promise 的 resolverejectfinally

Promise.resolve 会返回同构造器的 Promise;传入 thenable 时会吸收其状态;普通值会得到 fulfilled Promise:

const thenable = {
  then(resolve) {
    resolve('value')
  },
}

Promise.resolve(thenable).then(console.log) // value
Promise.resolve(1).then(console.log) // 1
Promise.reject(new Error('failed')).catch(console.error)

不要只用 value instanceof Promise 判断 thenable;跨 realm、子类和第三方 thenable 都需要按 then 属性解析。原生静态方法还会考虑 this 构造器。

finally 不接收成功值或 rejection reason,回调完成后透传原结果;如果 finally 回调抛错或返回拒绝 Promise,则会覆盖原结果:

Promise.resolve('value')
  .finally(() => console.log('cleanup'))
  .then(console.log) // value

Promise.reject(new Error('original'))
  .finally(() => {
    throw new Error('cleanup failed')
  })
  .catch((error) => console.log(error.message)) // cleanup failed

教学版可以这样表达其主要逻辑:

function finallyLike(promise, callback) {
  return promise.then(
    (value) => Promise.resolve(callback()).then(() => value),
    (reason) => Promise.resolve(callback()).then(() => {
      throw reason
    }),
  )
}

原文实现遗漏了 return,会导致链断裂;上面的形式修正了这一点。

第38篇:实现 Promise 的 allrace

Promise.all 接受可迭代对象,按输入顺序返回结果;任一输入拒绝时整体拒绝;空迭代器会异步 fulfilled 为 []Promise.race 返回第一个 settled 的结果,空迭代器则保持 pending。

Promise.all([
  Promise.resolve(1),
  2,
  Promise.resolve(3),
]).then(console.log) // [1, 2, 3]

Promise.race([
  new Promise((resolve) => setTimeout(() => resolve('slow'), 20)),
  Promise.resolve('fast'),
]).then(console.log) // fast

教学版的关键逻辑如下:

function allLike(iterable) {
  return new Promise((resolve, reject) => {
    const values = Array.from(iterable)
    const result = []
    let remaining = values.length

    if (remaining === 0) {
      resolve([])
      return
    }

    values.forEach((value, index) => {
      Promise.resolve(value).then(
        (item) => {
          result[index] = item
          remaining--
          if (remaining === 0) resolve(result)
        },
        reject,
      )
    })
  })
}

function raceLike(iterable) {
  return new Promise((resolve, reject) => {
    for (const value of iterable) {
      Promise.resolve(value).then(resolve, reject)
    }
  })
}

原文的 race 使用了不存在的 promise[i] 变量;这里改为遍历值。完整原生语义还包括构造器 species、迭代器异常和 thenable 处理。

Promise 状态转换历史示意图

第39篇:谈谈生成器以及协程

Generator 函数调用时不会立即执行函数体,而是返回一个实现 Iterator/Generator 协议的对象;每次调用 next 才继续运行到下一个 yieldreturn

function* gen() {
  console.log('enter')
  const a = yield 1
  const b = yield a + 1
  return b + 1
}

const generator = gen()
console.log(typeof generator) // object
console.log(generator.next()) // { value: 1, done: false }
console.log(generator.next(10)) // { value: 11, done: false }
console.log(generator.next(20)) // { value: 21, done: true }

传给 next(value) 的值会成为上一个 yield 表达式的结果。第一次 next 传入的值不会成为函数开始处某个 yield 的结果。

yield*

function* numbers() {
  yield 2
  yield 3
}

function* allNumbers() {
  yield 1
  yield* numbers()
  yield 4
}

console.log([...allNumbers()]) // [1, 2, 3, 4]

生成器与协程

协程是由程序主动暂停和恢复的执行单元;在同一个线程上不会真正并行执行多个协程。JavaScript Generator 提供了可观察的暂停/恢复机制,但不能把它直接等同为引擎内部所有协程实现,也不能说它自动创建线程。

function* child() {
  console.log('child')
  yield 'pause'
  return 100
}

function* parent() {
  console.log('parent')
  const result = yield* child()
  console.log('result', result)
}

const iterator = parent()
console.log(iterator.next()) // 输出 parent、child,并暂停在 child 的 yield
console.log(iterator.next()) // 输出 result 100,并完成

原文中的 yield B() 如果 B 是普通函数,只会先同步调用 B 得到结果,并不会自动切换到另一个 Generator;要委托另一个生成器,应使用 yield* 或显式驱动器。

第40篇:如何让 Generator 的异步代码按顺序执行完毕?

thunk 函数

原文把“传入若干参数生成定制函数”的工厂函数称作 thunk。严格地说,经典 thunk 通常是把带参数计算延迟为一个无参数函数;下面的 isType 更接近函数工厂/偏应用:

function isType(type) {
  return (value) => Object.prototype.toString.call(value) === `[object ${type}]`
}

const isString = isType('String')
console.log(isString('123')) // true

thunk 驱动 Generator

const readFileThunk = (filename) => (callback) => {
  require('node:fs').readFile(filename, callback)
}

function* readFiles() {
  const first = yield readFileThunk('001.txt')
  const second = yield readFileThunk('002.txt')
  return [first, second]
}

function runThunk(generator) {
  const iterator = generator()

  function step(error, value) {
    let result
    try {
      result = error ? iterator.throw(error) : iterator.next(value)
    } catch (caught) {
      console.error(caught)
      return
    }

    if (result.done) return
    result.value(step)
  }

  step()
}

// runThunk(readFiles)

真实文件读取回调是 (error, data),驱动器必须把错误传给 iterator.throw,不能忽略错误参数。

Promise 驱动 Generator

function runPromise(generator) {
  const iterator = generator()

  function step(method, value) {
    let result
    try {
      result = iterator[method](value)
    } catch (error) {
      return Promise.reject(error)
    }

    if (result.done) return Promise.resolve(result.value)
    return Promise.resolve(result.value).then(
      (nextValue) => step('next', nextValue),
      (error) => step('throw', error),
    )
  }

  return step('next')
}

co 等库曾经把这类驱动逻辑封装起来。现代代码多数直接使用 async/await,但这个例子仍能帮助理解 async 函数的控制流。

第41篇:解释一下 async/await 的运行机制

async 函数总是返回 Promise:

async function value() {
  return 100
}

console.log(value() instanceof Promise) // true
value().then(console.log) // 100

await 会先把操作数按 Promise 解析,然后暂停当前 async 函数的后续执行;后续部分通过 Promise reaction 在微任务中继续。它不是把整个 JavaScript 线程阻塞住,也不是脚本可以直接操作的“父协程/子协程”切换 API。

async function test() {
  console.log(100)
  const value = await 200
  console.log(value)
  console.log(200)
}

console.log(0)
test()
console.log(300)

输出为:

0
100
300
200
200

可以用下面的教学近似理解 await 200

Promise.resolve(200).then((value) => {
  // async 函数中 await 后面的代码在这里继续
  console.log(value)
})

但这不是规范要求引擎真的把 async 函数转换成这一段源码;真实语义还涉及 Promise resolution、异常、returnthrow

第42篇:forEach 中用 await 会产生什么问题?

function handle(value) {
  return new Promise((resolve) => {
    setTimeout(() => resolve(value), 100 * value)
  })
}

async function wrong() {
  const array = [4, 2, 1]
  array.forEach(async (item) => {
    const result = await handle(item)
    console.log(result)
  })
  console.log('结束')
}

wrong()

forEach 不等待回调返回的 Promise,因此通常先输出 结束,随后按任务完成速度输出 124。它既不能提供串行等待,也不会把回调 Promise 汇总给调用者。

串行执行:for...of

async function serial() {
  for (const item of [4, 2, 1]) {
    console.log(await handle(item))
  }
  console.log('结束')
}

输出顺序是 421结束,总耗时约为各任务耗时之和。

并行执行:Promise.all

async function parallel() {
  const values = await Promise.all([4, 2, 1].map(handle))
  console.log(values) // [4, 2, 1],结果按输入顺序排列
}

任务完成顺序可能不同,但 Promise.all 的结果顺序与输入顺序一致;任一任务拒绝时整体拒绝。需要收集所有成功/失败结果时使用 Promise.allSettled

迭代器与 Generator

数组是可迭代对象,可以手动读取迭代器:

const iterator = [4, 2, 1][Symbol.iterator]()
console.log(iterator.next()) // { value: 4, done: false }
console.log(iterator.next()) // { value: 2, done: false }
console.log(iterator.next()) // { value: 1, done: false }
console.log(iterator.next()) // { value: undefined, done: true }

for...of 会按顺序读取迭代器,await 只会暂停当前 async 函数,因此它能表达串行流程。原文手写迭代器时的变量名称拼写错误,已修正。

Generator 本身也是可迭代对象:

function* fibonacci() {
  let previous = 0
  let current = 1

  while (true) {
    yield current
    ;[previous, current] = [current, previous + current]
  }
}

for (const value of fibonacci()) {
  if (value > 50) break
  console.log(value)
}

经验分享

原文最后关于“建立完整知识体系、持续学习、不要只追逐技术名词”的经验仍然有参考价值。今天的知识体系还应包括规范阅读、运行时差异、测试、性能观测和安全边界:

  • 面试题的输出必须注明环境,必要时实际运行。
  • 手写内置方法时要区分教学模型和完整规范。
  • 浏览器、Node.js、Worker 和不同版本 V8 的行为不能混用。
  • 复杂结论优先查看 ECMAScript、WHATWG、MDN、Node.js 和 V8 官方资料。
  • 性能结论需要基准测试,不应从一张旧截图推广到所有设备。

系列文章目录历史截图

本文保留原系列的学习价值,同时删除了无法运行或会误导读者的抓取代码。生产代码应优先使用标准 API,并根据目标环境测试。

配图说明:图示按原文顺序从 article.rivers.pub 的历史文章图片地址下载到本地 images/86-image-*,图片并非 ECMAScript、WHATWG、Node.js 或 V8 官方图,公开发布前仍需核实原始授权。

参考资料:

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

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

按分类浏览

10 COLLECTIONS