编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

    • 基础入门

    • 综合案例

      • README
      • 待办清单事件驱动
      • 异步订阅流式解析
        • 目录快速导航
        • 01.项目最终效果展示
        • 02.项目结构与启动
        • 03.http.js · fetch 三件套:超时 / 重试 / 取消
          • 3.1 fetch 为什
          • 3.2 第一步:写最简版
          • 3.3 第二步:加上指数
        • 04.errors.js · 自定义错误体系
          • 4.1 为什么不直接用
          • 4.2 写 errors
          • 4.3 把 http.j
        • 05.parser.js · 三种 Feed 格式解析
          • 5.1 为什么 Feed
          • 5.2 写 parser
          • 5.3 故意造 bug
        • 06.pipeline.js · 限流并发流水线
          • 6.1 为什么不能 `P
          • 6.2 第一步:手写 p
          • 6.3 第二步:写 pi
          • 6.4 第三步:跑通
          • 6.5 故意造 bug
        • 07.cache.js · 离线缓存与熔断器
          • 7.1 写 cache.
          • 7.2 写一个最简熔断器
          • 7.3 把 cache
        • 08.pager.js · 异步生成器实现无限滚动
          • 8.1 为什么用生成器
          • 8.2 写 pager.
          • 8.3 接到 view
          • 8.4 main.js
        • 09.streaming.js · 流式 reader 处理大文件
          • 9.1 为什么需要流
          • 9.2 写 stream
          • 9.3 接入 pipel
        • 10.项目总结分析
          • 10.1 整体目录结构(最
          • 10.2 异步演进三代对照
          • 10.3 核心原理一句话总结
        • 11.项目技术思考
          • 11.1 Promise.
          • 11.2 为什么不用 Rx
          • 11.3 为什么生成器在
        • 12.卷一章节反向索引
        • 13.衔接与延伸
          • 13.1 与下一案例 js
          • 13.2 三个延伸挑战
      • 视频播放器自实现
      • 图表看板全栈开发
    • 专栏博客

  • CodeX
  • JavaScript入门
  • 综合案例
杨充
2026-06-12
目录

异步订阅流式解析

# 案例 02 · jsfeed · 异步 RSS 阅读器(⭐⭐⭐⭐)

阅读前置条件:已学完卷一第 02、04、07、09、10、12、15 章;已完成案例 01 待办清单事件驱动。

案例基调:JS 区别于 C++/Go 的灵魂之一是 异步与事件循环。本案例不追求"功能多",而是把 Promise → async/await → 生成器 → 流式 reader 这条主线死磕透,让你写完之后再看任何 Node/前端框架的异步源码都能秒读懂。


# 目录快速导航

点击以下条目即可跳转。

  • 01. 项目最终效果展示
  • 02. 项目结构与启动
  • 03. http.js · fetch 三件套:超时 / 重试 / 取消
    • 3.1 fetch 为什
    • 3.2 第一步:写最简版
    • 3.3 第二步:加上指数
  • 04. errors.js · 自定义错误体系
    • 4.1 为什么不直接用
    • 4.2 写 errors
    • 4.3 把 http.j
  • 05. parser.js · 三种 Feed 格式解析
    • 5.1 为什么 Feed
    • 5.2 写 parser
    • 5.3 故意造 bug
  • 06. pipeline.js · 限流并发流水线
    • 6.1 为什么不能 `P
    • 6.2 第一步:手写 p
    • 6.3 第二步:写 pi
    • 6.4 第三步:跑通
    • 6.5 故意造 bug
  • 07. cache.js · 离线缓存与熔断器
    • 7.1 写 cache.
    • 7.2 写一个最简熔断器
    • 7.3 把 cache
  • 08. pager.js · 异步生成器实现无限滚动
    • 8.1 为什么用生成器
    • 8.2 写 pager.
    • 8.3 接到 view
    • 8.4 main.js
  • 09. streaming.js · 流式 reader 处理大文件
    • 9.1 为什么需要流
    • 9.2 写 stream
    • 9.3 接入 pipel
  • 10. 项目总结分析
    • 10.1 整体目录结构(最
    • 10.2 异步演进三代对照
    • 10.3 核心原理一句话总结
  • 11. 项目技术思考
    • 11.1 Promise.
    • 11.2 为什么不用 Rx
    • 11.3 为什么生成器在
  • 12. 卷一章节反向索引
  • 13. 衔接与延伸
    • 13.1 与下一案例 js
    • 13.2 三个延伸挑战

# 01.项目最终效果展示

┌────────────────────────────────────────────────────────────┐
│ ┃ jsfeed —— 我的 RSS 阅读器                  [+ 添加订阅]  │
├──────────┬─────────────────────────────────────────────────┤
│ 订阅源   │ ◉ 阮一峰的网络日志                              │
│ ─────    │   2026-06-10  科技爱好者周刊(第 305 期)       │
│ 阮一峰 ●│   2026-06-03  科技爱好者周刊(第 304 期)       │
│ 廖雪峰   │ ◉ 廖雪峰的官方网站                              │
│ MIT...   │   2026-06-08  Python 教程更新                   │
│ [刷新]   │ ─── 滚动到底部自动加载下一页 ───                │
└──────────┴─────────────────────────────────────────────────┘
状态栏: 拉取 5/5 源 · 耗时 1.2s · 失败 0 · 命中缓存 2
1
2
3
4
5
6
7
8
9
10
11

它能做什么:

  • 同时拉取 N 个 RSS/Atom/JSON Feed 订阅源,并发但不打死浏览器(限流 5)
  • 任何源失败不影响其他源(Promise.allSettled 容错)
  • 任何源超时 5s 自动取消(AbortController)
  • 失败自动指数退避重试 3 次,连续失败开熔断 30s
  • 大文件流式解析(10MB feed 不阻塞主线程)
  • 离线时使用上次缓存(localStorage 降级)
  • 滚动到底部自动加载下一页(async function* 生成器)

为什么不是 todo 第二季:todo 教你 DOM/事件;jsfeed 教你 异步/网络/迭代器。这两块合起来才覆盖前端 80% 的真实痛点。


# 02.项目结构与启动

jsfeed/
├── index.html              # 25 行,挂 #app 容器
├── style.css               # 100 行,左右两栏 flex 布局
└── src/
    ├── main.js             # 装配中心
    ├── feeds.js            # 订阅源 CRUD(复用 jstodo 的 store 模式)
    ├── http.js             # fetch 封装:超时 + 重试 + 取消
    ├── parser.js           # RSS/Atom/JSON 三种格式解析
    ├── pipeline.js         # 并发拉取流水线(pLimit + Promise.allSettled)
    ├── pager.js            # async function* 分页生成器
    ├── cache.js            # localStorage 离线缓存
    ├── errors.js           # 自定义 Error 体系
    └── view.js             # DOM 渲染(沿用 jstodo 委托模式)
1
2
3
4
5
6
7
8
9
10
11
12
13
┌─ 🎯 阶段 ① 目标 ────────────────────────────────────┐
│ 完成什么:跑出"hello jsfeed"——验证 ESM 能加载        │
│ 不做什么:不接任何网络(避免被 CORS 卡住乱怀疑环境)  │
│ 验收标准:Console 输出 [main] booted; <h1> 渲染出来   │
│ 预计耗时:10 分钟                                      │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6

📁 index.html(25 行):

<!doctype html>
<html lang="zh">
<head>
  <meta charset="UTF-8">
  <title>jsfeed</title>
  <link rel="stylesheet" href="./style.css">
</head>
<body>
  <h1>jsfeed</h1>
  <main id="app"></main>
  <script type="module" src="./src/main.js"></script>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13

📁 src/main.js:

console.log('[main] booted');
document.querySelector('#app').textContent = 'hello jsfeed';
1
2

🧪 启动:在项目根目录起静态服务器(不要 file:// 直接打开 —— 后面 fetch 会被同源策略拒绝):

# 任选一种
npx serve .                   # Node 用户
python3 -m http.server 8000   # Python 用户
1
2
3

打开 http://localhost:8000 → Console 见 [main] booted,页面显示 hello jsfeed → ✅。

┌─ 📌 阶段 ① 小结 ──────────────────────────────────┐
│ ✅ 启动方式:本地静态服务器(不能 file://)          │
│ ✅ 入口:&lt;script type="module">                     │
│ 💡 为什么要本地服务器:fetch 跨协议(file→http)会被拒绝│
└──────────────────────────────────────────────────┘
1
2
3
4
5

# 03.http.js · fetch 三件套:超时 / 重试 / 取消

┌─ 🎯 阶段 ② 目标 ────────────────────────────────────┐
│ 完成什么:封装 fetch,自带超时 5s、3 次指数退避重试    │
│ 不做什么:不做拦截器(不是真业务,没必要)             │
│ 验收标准:在 Console 输入 await http('https://httpbin.org/delay/3') │
│           → 5s 超时被 catch,并看到 3 次重试日志       │
│ 预计耗时:60 分钟                                      │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7

# 3.1 fetch 为什

❓ fetch(url) 不能直接用吗?

不能,fetch 有三个臭名昭著的坑:

  1. HTTP 4xx/5xx 不会 reject——只有网络断了才 reject。所以 try/catch 包不到 404 错误
  2. 没有内置超时——服务器一直不响应,fetch 会永远 pending
  3. 无法取消——除非配合 AbortController

❓ 重试为什么要"指数退避"而不是固定间隔?

服务器挂了的时候,全网客户端同时疯狂重试 → 服务器刚启动又被打死 → 这叫 雪崩(thundering herd)。指数退避(1s / 2s / 4s / 8s...)让重试时刻自然分散,再加 jitter(随机扰动)防止"整齐撞门"。

❓ AbortController 怎么取消已经在路上的请求?

fetch 接收 signal 参数,当 controller.abort() 调用时,浏览器会立刻关闭底层 TCP 连接,pending 的 Promise 以 AbortError reject。这是浏览器原生的能力。

# 3.2 第一步:写最简版

📁 src/http.js:

/**
 * 带超时的 fetch
 * @param {string} url
 * @param {{ timeout?: number, signal?: AbortSignal }} opts
 */
export async function httpOnce(url, { timeout = 5000, signal } = {}) {
  const ctrl = new AbortController();
  // ⭐ 把外部 signal 与内部 ctrl 串联——任意一方 abort 都能取消
  if (signal) signal.addEventListener('abort', () => ctrl.abort(signal.reason));
  const timer = setTimeout(() => ctrl.abort(new Error('timeout')), timeout);

  try {
    const resp = await fetch(url, { signal: ctrl.signal });
    // ⭐ fetch 不会因 4xx/5xx reject,必须自己判断
    if (!resp.ok) throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
    return resp;
  } finally {
    clearTimeout(timer);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

🧪 立刻测试:在 Console 跑:

import('./src/http.js').then(async ({ httpOnce }) => {
  // httpbin 延迟 3s 响应——5s 超时不触发
  const r = await httpOnce('https://httpbin.org/delay/3');
  console.log('OK', r.status);
});
1
2
3
4
5

✅ 看到 OK 200。改成 delay/10 → 看到 5s 后报 Error: timeout → 超时机制生效。

# 3.3 第二步:加上指数

继续追加到 http.js:

/**
 * 带超时 + 指数退避重试的 fetch
 */
export async function http(url, opts = {}) {
  const { retries = 3, baseDelay = 500 } = opts;
  let lastErr;

  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      return await httpOnce(url, opts);
    } catch (err) {
      lastErr = err;

      // ⭐ 主动取消(用户行为)不应该重试
      if (err.name === 'AbortError' && opts.signal?.aborted) throw err;

      // ⭐ 4xx 客户端错误一般不该重试(参数错了,重试也是错)
      if (/^HTTP 4\d\d/.test(err.message)) throw err;

      if (attempt === retries) break;

      // ⭐⭐⭐ 指数退避 + jitter
      const delay = baseDelay * 2 ** attempt + Math.random() * 200;
      console.warn(`[http] 第 ${attempt + 1} 次失败: ${err.message},${delay.toFixed(0)}ms 后重试`);
      await sleep(delay);
    }
  }
  throw lastErr;
}

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

📚 关键设计:

  • 不是所有错误都该重试:4xx 是请求本身错(你重试 100 次也是 404);超时 / 5xx / 网络断了才重试
  • 2 ** attempt = 1× / 2× / 4× / 8×——经典指数退避
  • + Math.random() * 200 = jitter——消除"整齐撞门"

🧪 再测:在 Console 跑一个肯定失败的 URL:

import('./src/http.js').then(async ({ http }) => {
  try { await http('https://invalid.invalid/x', { retries: 2 }); }
  catch (e) { console.log('最终失败:', e.message); }
});
1
2
3
4

应该看到:

[http] 第 1 次失败: ..., 542ms 后重试
[http] 第 2 次失败: ..., 1187ms 后重试
最终失败: ...
1
2
3

3 次尝试,间隔越来越长 → ✅ 指数退避生效。

┌─ 📌 阶段 ② 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的:                                       │
│   • fetch 不会因 4xx reject——必须手动判 resp.ok       │
│   • AbortController + setTimeout 实现"超时即取消"       │
│   • 4xx 不重试 / 5xx &amp; timeout 才重试的策略             │
│   • 指数退避 + jitter 防雪崩                            │
│ ⏸ 还没碰的(下阶段才会做):                            │
│   • 并发限流(一次发 100 个 fetch 怎么办)               │
│   • 流式 reader(10MB 大文件怎么办)                    │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10

# 04.errors.js · 自定义错误体系

┌─ 🎯 阶段 ③ 目标 ────────────────────────────────────┐
│ 完成什么:定义 NetworkError / ParseError / TimeoutError │
│ 不做什么:不集成 Sentry(卷一第 9 章学过原理就够了)   │
│ 验收标准:catch (err) 能用 instanceof 区分错误来源     │
│ 预计耗时:30 分钟                                      │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6

# 4.1 为什么不直接用

❓ throw new Error('网络错了') 不就够了吗?

不够。上层 catch 时只能用字符串匹配判断错误类型——脆弱、易错、不可维护。

catch (e) {
  if (e.message.includes('网络')) ...   // ❌ 改一下中文就废了
}
1
2
3

✅ 正确做法:自定义 Error 子类,上层用 instanceof 判断。

❓ 多个源同时失败怎么聚合?

ES2021 引入了 AggregateError——专门用来包多个错误。Promise.any() 全部失败时抛的就是它。

❓ extends Error 在老浏览器有坑吗?

ES6 class extends Error 在 Babel 转译下会丢 instanceof 判断(因为 Babel 模拟 class 的方式有缺陷)。纯原生现代浏览器没问题——本案例基线是 Chrome ≥ 110,不必担心。

# 4.2 写 errors

📁 src/errors.js:

/** 自定义 Error 基类——统一打印格式 */
class FeedError extends Error {
  constructor(message, opts = {}) {
    super(message, opts);          // ⭐ ES2022:opts.cause 链式错误
    this.name = this.constructor.name;
    // ⭐ 让 stack trace 干净(V8 专有,其它引擎降级也无碍)
    if (Error.captureStackTrace) Error.captureStackTrace(this, this.constructor);
  }
}

export class NetworkError extends FeedError {
  constructor(url, cause) {
    super(`网络请求失败: ${url}`, { cause });
    this.url = url;
  }
}

export class TimeoutError extends FeedError {
  constructor(url, timeout) {
    super(`请求超时 ${timeout}ms: ${url}`);
    this.url = url;
    this.timeout = timeout;
  }
}

export class ParseError extends FeedError {
  constructor(url, cause) {
    super(`解析失败: ${url}`, { cause });
    this.url = url;
  }
}

/** 熔断错误:不可重试 */
export class CircuitBreakError extends FeedError {
  constructor(url) { super(`熔断器打开: ${url}`); this.url = url; }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

📚 cause 链式错误(ES2022 新特性):

try { JSON.parse(text); }
catch (raw) {
  throw new ParseError(url, raw);   // ⭐ raw 作为 cause 保留下来
}

// 上层 catch
catch (err) {
  console.error(err);            // 打印 ParseError
  console.error(err.cause);      // 打印原始 SyntaxError
}
1
2
3
4
5
6
7
8
9
10

这样既给用户抽象的业务错误,又给开发者完整的根因链——Java/Python 都要写一堆代码才能做到,JS 一个 cause 字段搞定。

# 4.3 把 http.j

📁 src/http.js(修改):

import { NetworkError, TimeoutError } from './errors.js';

export async function httpOnce(url, { timeout = 5000, signal } = {}) {
  const ctrl = new AbortController();
  if (signal) signal.addEventListener('abort', () => ctrl.abort(signal.reason));
  let timedOut = false;
  const timer = setTimeout(() => { timedOut = true; ctrl.abort(); }, timeout);

  try {
    const resp = await fetch(url, { signal: ctrl.signal });
    if (!resp.ok) throw new NetworkError(url, new Error(`HTTP ${resp.status}`));
    return resp;
  } catch (err) {
    if (timedOut) throw new TimeoutError(url, timeout);
    if (err instanceof FeedError) throw err;     // 已经是自定义错误,直接抛
    throw new NetworkError(url, err);
  } finally {
    clearTimeout(timer);
  }
}
// 别忘了 import FeedError
import { FeedError } from './errors.js';
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

⚠️ 小心循环依赖:http.js 引 errors.js,但 errors.js 不引 http.js——单向依赖,安全。

🧪 测试:

import('./src/http.js').then(async ({ http }) => {
  try { await http('https://httpbin.org/delay/10', { timeout: 1000, retries: 0 }); }
  catch (e) {
    console.log(e.constructor.name);   // TimeoutError
    console.log(e instanceof Error);   // true(向下兼容)
  }
});
1
2
3
4
5
6
7

✅ instanceof 精准识别 = 阶段 ③ 完成。

┌─ 📌 阶段 ③ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的:                                       │
│   • class extends Error 的正确姿势(name + capture)    │
│   • ES2022 cause 链式错误(保留根因)                   │
│   • instanceof 替代字符串匹配判断错误类型               │
│ 💡 设计哲学:                                           │
│   "错误也是对象——给它一个 class,就给它了所有 OOP 能力"│
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8

# 05.parser.js · 三种 Feed 格式解析

┌─ 🎯 阶段 ④ 目标 ────────────────────────────────────┐
│ 完成什么:把 RSS/Atom/JSON Feed 三种 XML/JSON 解析为统一对象│
│ 不做什么:不做完整规范覆盖(只取 title/link/pubDate/desc 4 字段)│
│ 验收标准:在 Console 跑 await parse(text) 返回 [{...}, ...] │
│ 预计耗时:60 分钟                                      │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6

# 5.1 为什么 Feed

❓ 历史包袱:1999 年 RSS 0.9 → 2002 年 RSS 2.0 → 2005 年 Atom 1.0 → 2017 年 JSON Feed。前两者是 XML,后者是 JSON。

❓ 怎么识别格式? 答:看 content-type 头 + 取 body 前 100 字符判断。

if (text.trimStart().startsWith('<?xml') || text.includes('<rss')) format = 'rss';
else if (text.includes('<feed')) format = 'atom';
else if (text.trimStart().startsWith('{')) format = 'json';
1
2
3

❓ DOMParser 解析 XML 出错怎么办? 答:它不抛异常——把错误塞到返回的文档里 <parsererror> 节点。必须手动检查。

# 5.2 写 parser

📁 src/parser.js:

import { ParseError } from './errors.js';

/** 入口:自动识别格式 */
export async function parse(url, text) {
  const head = text.trimStart().slice(0, 200);
  let module;
  // ⭐ 动态 import:只加载需要的解析器(卷一第 10 章模块拆分)
  if (head.includes('<?xml') || head.includes('<rss')) {
    module = await import('./parsers/rss.js');
  } else if (head.includes('<feed')) {
    module = await import('./parsers/atom.js');
  } else if (head.startsWith('{')) {
    module = await import('./parsers/json.js');
  } else {
    throw new ParseError(url, new Error('unknown feed format'));
  }
  try {
    return module.parse(text);
  } catch (raw) {
    throw new ParseError(url, raw);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

📁 src/parsers/rss.js:

export function parse(text) {
  const doc = new DOMParser().parseFromString(text, 'text/xml');
  // ⭐⭐⭐ DOMParser 不会抛——错了把信息塞进文档
  const errNode = doc.querySelector('parsererror');
  if (errNode) throw new Error(errNode.textContent.slice(0, 200));

  const channelTitle = doc.querySelector('channel > title')?.textContent ?? '';
  const items = [...doc.querySelectorAll('item')].map((node) => ({
    title:   node.querySelector('title')?.textContent?.trim() ?? '',
    link:    node.querySelector('link')?.textContent?.trim() ?? '',
    pubDate: node.querySelector('pubDate')?.textContent?.trim() ?? '',
    desc:    node.querySelector('description')?.textContent?.trim() ?? '',
  }));
  return { title: channelTitle, items };
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

📁 src/parsers/atom.js:

export function parse(text) {
  const doc = new DOMParser().parseFromString(text, 'text/xml');
  if (doc.querySelector('parsererror')) throw new Error('atom parse error');

  const feedTitle = doc.querySelector('feed > title')?.textContent ?? '';
  const items = [...doc.querySelectorAll('entry')].map((node) => ({
    title:   node.querySelector('title')?.textContent?.trim() ?? '',
    link:    node.querySelector('link')?.getAttribute('href') ?? '',  // ⭐ atom 的 link 是属性
    pubDate: node.querySelector('updated, published')?.textContent ?? '',
    desc:    node.querySelector('summary, content')?.textContent?.trim() ?? '',
  }));
  return { title: feedTitle, items };
}
1
2
3
4
5
6
7
8
9
10
11
12
13

📁 src/parsers/json.js:

export function parse(text) {
  const data = JSON.parse(text);
  return {
    title: data.title ?? '',
    items: (data.items ?? []).map((it) => ({
      title:   it.title ?? '',
      link:    it.url ?? it.external_url ?? '',
      pubDate: it.date_published ?? '',
      desc:    it.summary ?? it.content_text ?? '',
    })),
  };
}
1
2
3
4
5
6
7
8
9
10
11
12

🧪 测试一段假 RSS:

const fakeRss = `<?xml version="1.0"?><rss><channel>
  <title>测试源</title>
  <item><title>第一条</title><link>https://x.com/1</link>
    <pubDate>2026-06-10</pubDate><description>正文</description></item>
</channel></rss>`;

import('./src/parser.js').then(async ({ parse }) => {
  console.log(await parse('test', fakeRss));
  // { title: '测试源', items: [{ title: '第一条', link: 'https://x.com/1', ... }] }
});
1
2
3
4
5
6
7
8
9
10

✅ 输出统一格式 = 解析器完成。

# 5.3 故意造 bug

🧨 故意挑战:试试这段:

const evilXml = `<?xml version="1.0"?><rss><channel>
  <title>邪恶源</title>
  <item><title><![CDATA[<img src=x onerror=alert(1)>]]></title>...
</channel></rss>`;
1
2
3
4

DOMParser 解析出来的 title 字段值会是 <img src=x onerror=alert(1)>——这不是 HTML,是纯文本字符串,它本身不会执行。

⚠️ 真正的 XSS 风险在你的 view.js:如果你写了 el.innerHTML = item.title,那就炸了;卷一第 14 章反复强调用 textContent 就能从根上杜绝。

我们的 view 接下来会严格用 textContent 渲染所有 feed 字段——CDATA 里再脏的东西也只是字符串。

┌─ 📌 阶段 ④ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的:                                       │
│   • DOMParser 解析 XML(注意 parsererror 节点)         │
│   • 动态 import 按需加载解析插件(树摇友好)            │
│   • RSS / Atom / JSON Feed 字段映射差异                 │
│   • XSS 防御:textContent 是底层防线                    │
│ 💡 本阶段最大领悟:                                      │
│   "异构数据源 → 统一中间表示" 是任何聚合系统的标准套路   │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9

# 06.pipeline.js · 限流并发流水线

┌─ 🎯 阶段 ⑤ 目标 ────────────────────────────────────┐
│ 完成什么:N 个订阅源并发拉,但同时在飞 ≤ 5 个;任意源失败不影响其他 │
│ 不做什么:不写真实订阅源(用 mock URL,避免 CORS 干扰主线)│
│ 验收标准:投 20 个 url → 任何时刻 in-flight ≤ 5;返回 20 个结果(成功/失败混合)│
│ 预计耗时:90 分钟(本案例最难的一节,慢慢来)           │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6

# 6.1 为什么不能 `P

❓ 直接 Promise.all(urls.map(http)) 不行吗?

20 个还行,200 个就会:

  • 浏览器同域并发上限大约 6—Chrome 会自动排队(你看不见,但浏览器在串行)
  • 跨域则没有上限—一次发 200 个 fetch,电脑都会卡
  • 任意一个失败,Promise.all 全部 reject——所有成功的源都白拉了

❓ Promise.allSettled 解决了第二个,但第一个怎么办?

需要手写信号量(pLimit)——同时在飞最多 N 个,第 N+1 个排队等。

❓ pLimit 不引 npm 包行吗?

行,核心算法只有 30 行——这正是本节的高光。

# 6.2 第一步:手写 p

📁 src/pLimit.js(独立文件,用完丢卷四毕业设计还能复用):

/**
 * 信号量限流器
 * @param {number} concurrency 同时在飞的最大任务数
 * @returns {(fn: () => Promise<T>) => Promise<T>}
 */
export function pLimit(concurrency) {
  let inFlight = 0;
  const queue = [];

  // ⭐ 取下一个排队任务执行
  const next = () => {
    if (inFlight >= concurrency || queue.length === 0) return;
    inFlight++;
    const { fn, resolve, reject } = queue.shift();
    Promise.resolve()
      .then(fn)              // 调用业务函数(也可能同步抛)
      .then(resolve, reject)
      .finally(() => {
        inFlight--;
        next();              // ⭐ 一个任务完成,立刻调度下一个
      });
  };

  return (fn) =>
    new Promise((resolve, reject) => {
      queue.push({ fn, resolve, reject });
      next();
    });
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

📚 代码细读(每一行都很关键):

行为 为什么
Promise.resolve().then(fn) 把"调 fn"挪到微任务里——保证 fn 同步抛错也能被 catch
next() 在 finally 里 不论成功失败,槽位必须释放——否则前几个失败就会永远卡死
queue.shift() FIFO 保证排队顺序——先进的先发
闭包 inFlight 不需要 class,闭包就够了(卷一第 4.7 节)

🧪 30 秒小测:

import('./src/pLimit.js').then(async ({ pLimit }) => {
  const limit = pLimit(2);
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  const tasks = [1, 2, 3, 4, 5].map((i) =>
    limit(async () => {
      console.log(`task ${i} start`);
      await sleep(500);
      console.log(`task ${i} done`);
      return i;
    })
  );
  console.log(await Promise.all(tasks));
});
1
2
3
4
5
6
7
8
9
10
11
12
13

预期输出:

task 1 start
task 2 start              ← 1、2 同时开始(concurrency=2)
task 1 done
task 3 start              ← 1 完成,3 进来
task 2 done
task 4 start
task 3 done
task 5 start
task 4 done
task 5 done
[ 1, 2, 3, 4, 5 ]
1
2
3
4
5
6
7
8
9
10
11

✅ 任何时刻在飞 ≤ 2 = pLimit 写对了。

# 6.3 第二步:写 pi

📁 src/pipeline.js:

import { http } from './http.js';
import { parse } from './parser.js';
import { pLimit } from './pLimit.js';

/**
 * 并发拉取多个订阅源
 * @param {string[]} urls
 * @param {{ concurrency?: number, signal?: AbortSignal }} opts
 * @returns {Promise<Array<{ url, ok, data?, error? }>>}
 */
export async function fetchFeeds(urls, { concurrency = 5, signal } = {}) {
  const limit = pLimit(concurrency);

  // ⭐ Promise.allSettled:任意失败不会中断整个流水线
  const results = await Promise.all(
    urls.map((url) =>
      limit(async () => {
        try {
          const resp = await http(url, { signal });
          const text = await resp.text();
          const data = await parse(url, text);
          return { url, ok: true, data };
        } catch (error) {
          return { url, ok: false, error };       // ⭐ 把错误转成数据返回
        }
      })
    )
  );
  return results;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

📚 设计技巧 · "把错误转成数据":

limit 内部已经是 Promise.all,如果让某个任务 reject,整个 Promise.all 就 reject 了。所以永远在 limit 内部 catch 掉,然后把 { ok: false, error } 当作正常结果返回——这样上层用统一的方式处理成功失败。

💡 这其实就是 Promise.allSettled 的内部实现思想;这里没用它而是用 Promise.all + 手动包装,是为了把"错误对象"转成"业务对象",保留 url 上下文——allSettled 返回的 {status, reason} 没有 url 字段。

# 6.4 第三步:跑通

📁 src/main.js(升级):

import { fetchFeeds } from './pipeline.js';

// ⭐ 用 codetabs 代理一下,避开 CORS(教学用,生产请走自己的代理)
const PROXY = (u) => `https://api.codetabs.com/v1/proxy?quest=${encodeURIComponent(u)}`;

const SOURCES = [
  'https://www.ruanyifeng.com/blog/atom.xml',
  'https://feeds.feedburner.com/ruanyifeng',
  'https://invalid.invalid/x',                      // 故意写一个失败的
];

(async function () {
  console.time('fetch');
  const results = await fetchFeeds(SOURCES.map(PROXY), { concurrency: 3 });
  console.timeEnd('fetch');

  for (const r of results) {
    if (r.ok) {
      console.log('✅', r.data.title, '共', r.data.items.length, '条');
    } else {
      console.warn('❌', r.url, r.error.constructor.name, r.error.message);
    }
  }
})();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

🧪 预期 Console:

[http] 第 1 次失败: 网络请求失败: ..., 612ms 后重试      ← 失败源在重试
[http] 第 2 次失败: ..., 1234ms 后重试
fetch: 5.34s
✅ 阮一峰的网络日志 共 30 条
✅ ... 共 N 条
❌ https://invalid.invalid/x NetworkError 网络请求失败: ...
1
2
3
4
5
6

✅ 部分成功 + 部分失败 + 总耗时 ≈ 最慢源的耗时(不是 N 个串行求和)= 限流并发完成。

# 6.5 故意造 bug

🧨 回退测试:把 pipeline.js 里的 try/catch 临时去掉:

// ❌ 错误版
return limit(async () => {
  const resp = await http(url, { signal });   // 抛出去了
  ...
});
1
2
3
4
5

刷新 → 你会看到一条未处理的 Promise 拒绝红字,然后所有结果都不返回——一坏全坏。

✅ 加回 try/catch → 一切正常。这就是为什么"错误转数据"是关键。

┌─ 📌 阶段 ⑤ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的:                                       │
│   • pLimit 信号量手写(30 行核心逻辑)                   │
│   • Promise.all + 内部 catch = 容错并发                 │
│   • 错误转数据 → 上层统一处理成功失败                   │
│   • Promise.resolve().then(fn) 守护同步异常             │
│ ⏸ 还没碰的(下阶段才会做):                            │
│   • 流式 reader(10MB feed 怎么不卡)                   │
│   • 生成器分页(无限滚动)                              │
│ 💡 本阶段最大领悟:                                      │
│   "异步本质是调度——pLimit 就是你写的迷你调度器"        │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12

# 07.cache.js · 离线缓存与熔断器

┌─ 🎯 阶段 ⑥ 目标 ────────────────────────────────────┐
│ 完成什么:① 拉取成功后写 localStorage;② 失败时降级到上次缓存│
│            ③ 一个源连续失败 3 次 → 熔断 30s 不再发请求    │
│ 不做什么:不接 IndexedDB(数据量小,localStorage 够)    │
│ 验收标准:断网刷新页面 → 仍能看到上次成功的内容          │
│ 预计耗时:45 分钟                                      │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7

# 7.1 写 cache.

📁 src/cache.js(沿用 jstodo 的"任何 IO 都 try/catch"哲学):

const KEY = 'jsfeed:cache:v1';

/**
 * 整体形状:{ [url]: { ts, data } }
 */
export function readAll() {
  try {
    return JSON.parse(localStorage.getItem(KEY) || '{}');
  } catch {
    return {};
  }
}

export function writeOne(url, data) {
  try {
    const all = readAll();
    all[url] = { ts: Date.now(), data };
    localStorage.setItem(KEY, JSON.stringify(all));
  } catch (err) {
    console.warn('[cache] write fail:', err.message);
  }
}

export function readOne(url, maxAge = Infinity) {
  const all = readAll();
  const entry = all[url];
  if (!entry) return null;
  if (Date.now() - entry.ts > maxAge) return null;
  return entry.data;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

# 7.2 写一个最简熔断器

📁 src/breaker.js:

import { CircuitBreakError } from './errors.js';

const failCount = new Map();     // url → 连续失败次数
const openUntil = new Map();     // url → 熔断恢复时间

const THRESHOLD = 3;             // 连续 3 次失败开熔断
const COOLDOWN  = 30_000;        // 熔断 30s

/** 在发请求前调用——返回 true 表示熔断中,应跳过 */
export function isOpen(url) {
  const until = openUntil.get(url);
  if (!until) return false;
  if (Date.now() >= until) {
    openUntil.delete(url);       // 时间到了,半开放尝试
    return false;
  }
  return true;
}

export function reportSuccess(url) {
  failCount.delete(url);
  openUntil.delete(url);
}

export function reportFailure(url) {
  const n = (failCount.get(url) || 0) + 1;
  failCount.set(url, n);
  if (n >= THRESHOLD) {
    openUntil.set(url, Date.now() + COOLDOWN);
    console.warn(`[breaker] ${url} 熔断 ${COOLDOWN / 1000}s`);
  }
}

export { CircuitBreakError };
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

📚 熔断三态:

  • closed(默认):正常发
  • open(连失 3 次):直接拒绝 30s
  • half-open(30s 后):放一个尝试——成功则恢复 closed,失败则重开

本案例为简洁只做了 closed 和 open,half-open 是隐式的(30s 后下次请求自然就是尝试)。Netflix Hystrix 的核心模型也就这三态。

# 7.3 把 cache

📁 src/pipeline.js(升级):

import { http } from './http.js';
import { parse } from './parser.js';
import { pLimit } from './pLimit.js';
import { readOne, writeOne } from './cache.js';
import { isOpen, reportSuccess, reportFailure, CircuitBreakError } from './breaker.js';

export async function fetchFeeds(urls, { concurrency = 5, signal } = {}) {
  const limit = pLimit(concurrency);

  return Promise.all(
    urls.map((url) =>
      limit(async () => {
        // ① 熔断中?直接降级缓存
        if (isOpen(url)) {
          const cached = readOne(url);
          return cached
            ? { url, ok: true, data: cached, fromCache: true }
            : { url, ok: false, error: new CircuitBreakError(url) };
        }
        try {
          const resp = await http(url, { signal });
          const text = await resp.text();
          const data = await parse(url, text);
          // ② 成功:写缓存 + 复位熔断
          writeOne(url, data);
          reportSuccess(url);
          return { url, ok: true, data };
        } catch (error) {
          reportFailure(url);
          // ③ 失败:尝试降级缓存
          const cached = readOne(url);
          if (cached) return { url, ok: true, data: cached, fromCache: true, staleError: error };
          return { url, ok: false, error };
        }
      })
    )
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

🧪 三步验证:

  1. 第一次刷新 → 全部正常拉到 → Console 看不到 fromCache
  2. 关闭 Wi-Fi → 刷新 → 应该看到 fromCache: true,仍然有数据展示
  3. 故意把某个源改成 https://invalid.invalid/x → 刷新 3 次 → 看到 [breaker] ... 熔断 30s → 第 4 次刷新该源不再走网络

✅ 三步全过 = 阶段 ⑥ 完成。

┌─ 📌 阶段 ⑥ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的:                                       │
│   • localStorage 离线降级模式                           │
│   • 熔断器三态(closed / open / half-open)             │
│   • Map 做"按 key 计数"的运行时状态容器                 │
│   • 网络失败 ≠ 用户白屏——三层降级链                    │
│ 💡 本阶段最大领悟:                                      │
│   "可用性高于一切——错误兜底比新功能重要 10 倍"          │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9

# 08.pager.js · 异步生成器实现无限滚动

┌─ 🎯 阶段 ⑦ 目标 ────────────────────────────────────┐
│ 完成什么:用 async function* 实现"按 20 条/页 拉取"     │
│ 不做什么:不做真正的 HTTP 分页(feed 协议没分页 API)   │
│           我们模拟分页:把已合并的全部 items 切片下发    │
│ 验收标准:for await (const page of pager()) 每次拿 20 条│
│ 预计耗时:45 分钟(异步生成器是卷一最难的一节)        │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7

# 8.1 为什么用生成器

❓ 直接 slice(start, end) 不行吗?

行,但调用方要管理 start/end,状态外泄。生成器把"我下一页是哪段"封装成内部状态——调用方只 next() 就行。

❓ 为什么是 async function* 而不是普通 function*?

如果分页逻辑里有 await(比如 IO 等待、延迟),就必须是 async function*。这是 ES2018 才标准化的产物——JS 异步演进的终点。

❓ for await...of 怎么知道生成器结束了?

生成器函数 return 或不再 yield 时,迭代器返回 { done: true }——for await...of 自动跳出。

# 8.2 写 pager.

📁 src/pager.js:

/**
 * 异步生成器:把 items 数组按 pageSize 分页惰性产出
 * @param {Array<Item>} items
 * @param {number} pageSize
 */
export async function* paginate(items, pageSize = 20) {
  let cursor = 0;
  while (cursor < items.length) {
    // ⭐ 模拟"网络延迟"——真实场景这里可能是 await fetch(nextPage)
    await new Promise((r) => setTimeout(r, 200));
    const page = items.slice(cursor, cursor + pageSize);
    yield page;
    cursor += pageSize;
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

🧪 快速测:

import('./src/pager.js').then(async ({ paginate }) => {
  const fake = Array.from({ length: 53 }, (_, i) => ({ title: `item ${i}` }));
  for await (const page of paginate(fake, 20)) {
    console.log('收到一页', page.length, '条');
  }
});
1
2
3
4
5
6

预期:

(200ms 后)收到一页 20 条
(再 200ms)收到一页 20 条
(再 200ms)收到一页 13 条
1
2
3

✅ 惰性产出 + 异步等待 = 异步生成器跑通。

# 8.3 接到 view

📁 src/view.js(先把基础结构搭起来):

import { paginate } from './pager.js';

const app = document.querySelector('#app');
const list = document.createElement('ul');
list.id = 'feed-list';
app.appendChild(list);

const sentinel = document.createElement('div');
sentinel.id = 'sentinel';
sentinel.textContent = '正在加载下一页…';
app.appendChild(sentinel);

let pager = null;          // 当前生成器
let loading = false;

export async function setItems(items) {
  list.innerHTML = '';
  pager = paginate(items, 20);
  await loadNext();
}

async function loadNext() {
  if (!pager || loading) return;
  loading = true;
  const { value: page, done } = await pager.next();
  loading = false;
  if (done) {
    sentinel.textContent = '— 没有更多了 —';
    observer.disconnect();
    return;
  }
  const frag = document.createDocumentFragment();
  for (const item of page) {
    const li = document.createElement('li');
    const t = document.createElement('a');
    t.href = item.link;
    t.target = '_blank';
    t.rel = 'noopener noreferrer';   // ⭐ 防 reverse-tabnabbing 攻击
    t.textContent = item.title;       // ⭐⭐⭐ textContent 而非 innerHTML——抗 XSS
    li.appendChild(t);
    const meta = document.createElement('small');
    meta.textContent = ' ' + item.pubDate;
    li.appendChild(meta);
    frag.appendChild(li);
  }
  list.appendChild(frag);
}

// ⭐⭐ 用 IntersectionObserver 监测哨兵元素是否进入视口
const observer = new IntersectionObserver((entries) => {
  for (const e of entries) {
    if (e.isIntersecting) loadNext();
  }
}, { rootMargin: '200px' });    // 离视口 200px 时就开始加载(提前预取)
observer.observe(sentinel);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55

📚 关键技巧:

  • IntersectionObserver——比"监听 scroll 事件 + 算坐标"高 100 倍效率(浏览器原生计算)
  • rootMargin: '200px'——提前 200px 触发,给加载留缓冲
  • rel="noopener noreferrer"——防被打开的页面通过 window.opener 反控当前页(reverse-tabnabbing 攻击)

# 8.4 main.js

📁 src/main.js(最终版):

import { fetchFeeds } from './pipeline.js';
import { setItems } from './view.js';

const PROXY = (u) => `https://api.codetabs.com/v1/proxy?quest=${encodeURIComponent(u)}`;
const SOURCES = [
  'https://www.ruanyifeng.com/blog/atom.xml',
  // 自行添加你喜欢的 feed
].map(PROXY);

(async function () {
  const results = await fetchFeeds(SOURCES, { concurrency: 3 });

  // ⭐ 把所有源的 items 拍平 + 按时间倒排
  const merged = results
    .filter((r) => r.ok)
    .flatMap((r) => r.data.items)
    .sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate));

  console.log(`合并 ${merged.length} 条`);
  await setItems(merged);
})();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

🧪 跑起来:刷新 → 应该看到先出 20 条 → 滚到底部 → 自动加载下 20 条 → 继续 → 直到"没有更多了"。

┌─ 📌 阶段 ⑦ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的:                                       │
│   • async function* 异步生成器(卷一第 12 章实战落地)  │
│   • for await...of 消费异步迭代器                       │
│   • IntersectionObserver 替代 scroll 监听               │
│   • rel="noopener noreferrer" 安全细节                  │
│ 💡 本阶段最大领悟:                                      │
│   "生成器把'状态'锁进函数内部——调用方只问 next()"       │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9

# 09.streaming.js · 流式 reader 处理大文件

┌─ 🎯 阶段 ⑧ 目标 ────────────────────────────────────┐
│ 完成什么:response.body.getReader() 边下载边处理       │
│ 不做什么:不做完整的 SAX-style XML 流式解析(学院派)   │
│           我们示范"按 chunk 计算下载进度"+"够阈值就解析"│
│ 验收标准:拉取大 feed 时能在 UI 实时显示"已下载 XX KB"  │
│ 预计耗时:60 分钟                                      │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7

# 9.1 为什么需要流

❓ await response.text() 不就好了吗?

如果 feed 有 10MB,text() 会一直等到全下完才返回——这期间用户看不到任何进度,体验极差。流式 reader 让你边下边处理。

❓ 流式真能解析 XML 吗?

完整的"边下边解析"需要 SAX-style 解析器(如 sax-js)——超出本案例范围。本案例做降级:流式只是为了实时进度反馈,等下载完整后再走 DOMParser 一次性解析。

❓ 流式 reader 的 API 怎么用?

response.body 是一个 ReadableStream(卷一第 15.6 节有提)。.getReader() 拿到 reader → 反复 await reader.read() → 每次拿到 { value: Uint8Array, done }。

# 9.2 写 stream

📁 src/streaming.js:

/**
 * 流式下载——边下边汇报进度
 * @param {Response} resp
 * @param {(loaded: number, total: number) => void} onProgress
 * @returns {Promise<string>} 完整文本
 */
export async function streamText(resp, onProgress) {
  const total = Number(resp.headers.get('content-length')) || 0;
  const reader = resp.body.getReader();
  const decoder = new TextDecoder('utf-8');     // ⭐ 字节流 → utf-8 字符串
  const chunks = [];
  let loaded = 0;

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    chunks.push(value);
    loaded += value.byteLength;
    onProgress?.(loaded, total);
  }

  // ⭐ 把所有 Uint8Array 拼起来再统一 decode(避免跨 chunk 截断 UTF-8 多字节字符)
  const merged = new Uint8Array(loaded);
  let offset = 0;
  for (const c of chunks) { merged.set(c, offset); offset += c.byteLength; }
  return decoder.decode(merged);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

📚 关键陷阱 · UTF-8 跨 chunk 截断:

UTF-8 中 "中" 字占 3 字节。如果一个 chunk 在 "中" 的第 2 字节切断,逐 chunk decode 会得到乱码。

✅ 正确做法(本案例采用):先把所有 chunk 拼成完整 Uint8Array,再一次性 decode。

✅ 进阶做法(卷三再讲):用 new TextDecoder('utf-8', { stream: true }) 的流模式——它内部维护"未完成字符"缓冲。

# 9.3 接入 pipel

📁 src/pipeline.js(仅修改 try 块):

import { streamText } from './streaming.js';

// ...
try {
  const resp = await http(url, { signal });
  const text = await streamText(resp, (loaded, total) => {
    // ⭐ 这里就能实时上报,view.js 可以做个进度条
    if (total > 0) console.log(`[${url}] ${(loaded / total * 100).toFixed(0)}%`);
  });
  const data = await parse(url, text);
  // ...
}
1
2
3
4
5
6
7
8
9
10
11
12

🧪 测试:拉一个稍大的源 → Console 看到 0% → 25% → 60% → 100% 渐进上报 → ✅。

💡 注意:很多 RSS 服务器不返回 content-length(动态生成)。这种情况下 total = 0,只能显示"已下载 N KB"而不是百分比。本案例的代码已用 if (total > 0) 防御。

┌─ 📌 阶段 ⑧ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的:                                       │
│   • response.body.getReader() 流式 API                  │
│   • TextDecoder 字节流 → 字符串                         │
│   • UTF-8 跨 chunk 截断陷阱 + 解决方案                  │
│   • content-length 不可靠的应对                         │
│ 💡 本阶段最大领悟:                                      │
│   "Promise 是粒度=整体的异步;Stream 是粒度=分块的异步" │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9

# 10.项目总结分析

# 10.1 整体目录结构(最

jsfeed/
├── index.html              # 25 行
├── style.css               # 100 行
└── src/
    ├── main.js             # 60 行:装配中心
    ├── http.js             # 90 行:fetch + 超时 + 重试
    ├── pLimit.js           # 30 行:信号量限流
    ├── pipeline.js         # 80 行:限流并发流水线
    ├── parser.js           # 30 行:格式分发
    ├── parsers/
    │   ├── rss.js          # 25 行
    │   ├── atom.js         # 25 行
    │   └── json.js         # 20 行
    ├── pager.js            # 15 行:异步生成器
    ├── streaming.js        # 35 行:流式 reader
    ├── cache.js            # 40 行:localStorage 降级
    ├── breaker.js          # 50 行:熔断器
    ├── errors.js           # 50 行:自定义 Error 体系
    └── view.js             # 80 行:DOM 渲染 + 无限滚动
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

约 730 行 JS + 125 行 HTML/CSS = 850 行业务代码——比 jstodo 多 50 行就实现了 RSS 阅读、并发限流、熔断、缓存、流式、无限滚动。密度比 todo 高 5 倍。

# 10.2 异步演进三代对照

写完案例后回头看异步的三代演进,会有真切感受:

// 第一代:回调地狱(2010 之前)
fetch(urlA, (errA, dataA) => {
  if (errA) return cb(errA);
  fetch(urlB, (errB, dataB) => {
    if (errB) return cb(errB);
    fetch(urlC, ...);    // 越缩越深
  });
});

// 第二代:Promise(2015)
fetch(urlA)
  .then((dataA) => fetch(urlB))
  .then((dataB) => fetch(urlC))
  .catch(handleErr);     // ⭐ 错误统一捕

// 第三代:async/await(2017)
try {
  const dataA = await fetch(urlA);
  const dataB = await fetch(urlB);
  const dataC = await fetch(urlC);
} catch (err) { handleErr(err); }    // ⭐ 看起来像同步代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

✅ 本案例代码 100% 第三代风格——每一个 await 都是教学锚点。

# 10.3 核心原理一句话总结

模块 一句话
http fetch 原生不防 4xx / 不超时 / 不取消,必须自己包装
pLimit 信号量 = 计数器 + 队列 + 槽位释放回调,30 行就够
pipeline 错误转数据,永远不让 Promise.all 因单个失败炸掉
cache+breaker 三层降级:网络成功 → 网络失败用缓存 → 熔断 30s
pager async function* + for await 把分页状态封装进闭包
streaming reader.read 反复读取 chunk,注意 UTF-8 跨 chunk 截断

# 11.项目技术思考

# 11.1 Promise.

API 一个失败 全部成功 第一个 reject 行为 典型场景
all 整体 reject 整体 resolve(数组) 立即 reject 必须全成功的事务
allSettled 不影响 整体 resolve(每项 status) 不影响 部分容错的批量任务(本案例)
any 不影响 第一个 resolve 即返回 全部失败抛 AggregateError 多副本竞速(CDN 选最快)
race 立即 reject 第一个 settle 即返回 立即 reject 超时控制(一边业务一边定时器)

本案例选 Promise.all + 内部 catch 而不是 allSettled,是为了自定义返回结构(保留 url + 区分 fromCache)。

# 11.2 为什么不用 Rx

RxJS 强大但学习成本极高。99% 的前端业务用 async/await + 生成器就够了。Observable 的核心优势是"流的组合"——本案例的 pipeline 已经达到中等复杂度,但仍能用原生 Promise 清晰表达。学完本案例你能秒读 RxJS 源码。

# 11.3 为什么生成器在

原因 解释
async/await 太好用 大部分异步用 await 一行解决,用不到生成器
调试体验差 DevTools 在 yield 处的断点不如普通 await 直观
TypeScript 类型推导复杂 AsyncGenerator<T, R, N> 三个泛型参数

但生成器不可替代的场景:

  • 流式分页(本案例)
  • 状态机(如 redux-saga)
  • 协程(co.js / koa 中间件)

掌握它就掌握了前端中"等价于 Go channel 的能力"。


# 12.卷一章节反向索引

案例段落 对应卷一章节 知识点
§03 fetch 封装 第 15.2 fetch / 第 07.6 Promise fetch 三大坑
§03 AbortController 第 15.4 取消 / 第 08.6 EventTarget abort 信号链
§03 指数退避 第 07.5.1 setTimeout sleep 实现
§04 自定义 Error 第 09.3 Error 继承 / 第 09.4 cause name + capture + cause
§05 DOMParser 第 14.5 解析 / 第 14.3 安全 parsererror 节点
§05 动态 import 第 10.4 ESM 动态导入 按需加载插件
§06 pLimit 第 04.7 闭包 / 第 07.6 Promise 信号量手写
§06 Promise.all 第 07.6.5 静态方法 all vs allSettled 抉择
§07 localStorage 第 02.5 标准库 / 第 09.2 try/catch IO 异常兜底
§07 熔断 Map 第 02.5 Map 运行时状态容器
§08 async function* 第 12.4 异步生成器 for await...of
§08 IntersectionObserver 第 14.6 观察者 替代 scroll 监听
§09 streaming reader 第 15.6 ReadableStream TextDecoder + UTF-8 陷阱

# 13.衔接与延伸

# 13.1 与下一案例 js

维度 本案例 jsfeed 下一案例 jsplayer
数据形态 文本(XML/JSON) 二进制(视频片段)
流式 reader.read 拼字符串 MSE.appendBuffer 喂解码器
取消 单次请求取消 切换码率时链式取消旧请求
错误 网络错误自定义 媒体错误 MediaError 码映射
重点 异步流水线 Web 平台宿主 API

本案例的 http.js / pLimit / AbortController 在下一案例会直接复用——拉视频分片的 fetch 队列就用同一套代码。

# 13.2 三个延伸挑战

⭐ 挑战 A · 基础:把 http.js 改成可插拔拦截器

仿 axios,让用户 http.use((ctx, next) => {...}) 注册请求/响应拦截器。 关键:洋葱模型——每个拦截器是 (ctx, next) => Promise<void>,按注册顺序压栈。

⭐⭐ 挑战 B · 进阶:把限流改成令牌桶

当前 pLimit 是并发数限制(同时在飞≤N)。改成令牌桶——每秒最多 N 次请求(QPS 限制)。 提示:维护 tokens,每 1000/N ms 加 1 个,请求前消费 1 个,没令牌就排队等。

⭐⭐⭐ 挑战 C · 现代化:把 cache 换成 IndexedDB

localStorage 同步且有 5MB 限制。改成 IndexedDB(异步、容量 GB 级)。 提示:把 cache.js 的同步签名改成 Promise<...>,上层 await 即可,pipeline 几乎不用改——这就是接口稳定的好处。


🎉 恭喜!你已经完成了 JS 综合案例的第二关。

此时你应该有一个真正能用的 jsfeed 阅读器——并发拉 N 个源、自动重试、熔断、降级缓存、流式下载、无限滚动。这份代码的"异步密度"已经超过 90% 的前端项目。

➡ 下一章:案例 03 · 视频播放器自实现

上次更新: 2026/06/16, 14:18:46
待办清单事件驱动
视频播放器自实现

← 待办清单事件驱动 视频播放器自实现→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式