异步订阅流式解析
# 案例 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 三件套:超时 / 重试 / 取消
- 04. errors.js · 自定义错误体系
- 05. parser.js · 三种 Feed 格式解析
- 06. pipeline.js · 限流并发流水线
- 07. cache.js · 离线缓存与熔断器
- 08. pager.js · 异步生成器实现无限滚动
- 09. streaming.js · 流式 reader 处理大文件
- 10. 项目总结分析
- 11. 项目技术思考
- 12. 卷一章节反向索引
- 13. 衔接与延伸
# 01.项目最终效果展示
┌────────────────────────────────────────────────────────────┐
│ ┃ jsfeed —— 我的 RSS 阅读器 [+ 添加订阅] │
├──────────┬─────────────────────────────────────────────────┤
│ 订阅源 │ ◉ 阮一峰的网络日志 │
│ ───── │ 2026-06-10 科技爱好者周刊(第 305 期) │
│ 阮一峰 ●│ 2026-06-03 科技爱好者周刊(第 304 期) │
│ 廖雪峰 │ ◉ 廖雪峰的官方网站 │
│ MIT... │ 2026-06-08 Python 教程更新 │
│ [刷新] │ ─── 滚动到底部自动加载下一页 ─── │
└──────────┴─────────────────────────────────────────────────┘
状态栏: 拉取 5/5 源 · 耗时 1.2s · 失败 0 · 命中缓存 2
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 委托模式)
2
3
4
5
6
7
8
9
10
11
12
13
┌─ 🎯 阶段 ① 目标 ────────────────────────────────────┐
│ 完成什么:跑出"hello jsfeed"——验证 ESM 能加载 │
│ 不做什么:不接任何网络(避免被 CORS 卡住乱怀疑环境) │
│ 验收标准:Console 输出 [main] booted; <h1> 渲染出来 │
│ 预计耗时:10 分钟 │
└──────────────────────────────────────────────────┘
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>
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';
2
🧪 启动:在项目根目录起静态服务器(不要 file:// 直接打开 —— 后面 fetch 会被同源策略拒绝):
# 任选一种
npx serve . # Node 用户
python3 -m http.server 8000 # Python 用户
2
3
打开 http://localhost:8000 → Console 见 [main] booted,页面显示 hello jsfeed → ✅。
┌─ 📌 阶段 ① 小结 ──────────────────────────────────┐
│ ✅ 启动方式:本地静态服务器(不能 file://) │
│ ✅ 入口:<script type="module"> │
│ 💡 为什么要本地服务器:fetch 跨协议(file→http)会被拒绝│
└──────────────────────────────────────────────────┘
2
3
4
5
# 03.http.js · fetch 三件套:超时 / 重试 / 取消
┌─ 🎯 阶段 ② 目标 ────────────────────────────────────┐
│ 完成什么:封装 fetch,自带超时 5s、3 次指数退避重试 │
│ 不做什么:不做拦截器(不是真业务,没必要) │
│ 验收标准:在 Console 输入 await http('https://httpbin.org/delay/3') │
│ → 5s 超时被 catch,并看到 3 次重试日志 │
│ 预计耗时:60 分钟 │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
# 3.1 fetch 为什
❓ fetch(url) 不能直接用吗?
不能,fetch 有三个臭名昭著的坑:
- HTTP 4xx/5xx 不会 reject——只有网络断了才 reject。所以
try/catch包不到 404 错误 - 没有内置超时——服务器一直不响应,fetch 会永远 pending
- 无法取消——除非配合
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);
}
}
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);
});
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));
}
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); }
});
2
3
4
应该看到:
[http] 第 1 次失败: ..., 542ms 后重试
[http] 第 2 次失败: ..., 1187ms 后重试
最终失败: ...
2
3
3 次尝试,间隔越来越长 → ✅ 指数退避生效。
┌─ 📌 阶段 ② 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的: │
│ • fetch 不会因 4xx reject——必须手动判 resp.ok │
│ • AbortController + setTimeout 实现"超时即取消" │
│ • 4xx 不重试 / 5xx & timeout 才重试的策略 │
│ • 指数退避 + jitter 防雪崩 │
│ ⏸ 还没碰的(下阶段才会做): │
│ • 并发限流(一次发 100 个 fetch 怎么办) │
│ • 流式 reader(10MB 大文件怎么办) │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
# 04.errors.js · 自定义错误体系
┌─ 🎯 阶段 ③ 目标 ────────────────────────────────────┐
│ 完成什么:定义 NetworkError / ParseError / TimeoutError │
│ 不做什么:不集成 Sentry(卷一第 9 章学过原理就够了) │
│ 验收标准:catch (err) 能用 instanceof 区分错误来源 │
│ 预计耗时:30 分钟 │
└──────────────────────────────────────────────────┘
2
3
4
5
6
# 4.1 为什么不直接用
❓ throw new Error('网络错了') 不就够了吗?
不够。上层 catch 时只能用字符串匹配判断错误类型——脆弱、易错、不可维护。
catch (e) {
if (e.message.includes('网络')) ... // ❌ 改一下中文就废了
}
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; }
}
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
}
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';
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(向下兼容)
}
});
2
3
4
5
6
7
✅ instanceof 精准识别 = 阶段 ③ 完成。
┌─ 📌 阶段 ③ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的: │
│ • class extends Error 的正确姿势(name + capture) │
│ • ES2022 cause 链式错误(保留根因) │
│ • instanceof 替代字符串匹配判断错误类型 │
│ 💡 设计哲学: │
│ "错误也是对象——给它一个 class,就给它了所有 OOP 能力"│
└──────────────────────────────────────────────────┘
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 分钟 │
└──────────────────────────────────────────────────┘
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';
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);
}
}
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 };
}
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 };
}
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 ?? '',
})),
};
}
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', ... }] }
});
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>`;
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 是底层防线 │
│ 💡 本阶段最大领悟: │
│ "异构数据源 → 统一中间表示" 是任何聚合系统的标准套路 │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
# 06.pipeline.js · 限流并发流水线
┌─ 🎯 阶段 ⑤ 目标 ────────────────────────────────────┐
│ 完成什么:N 个订阅源并发拉,但同时在飞 ≤ 5 个;任意源失败不影响其他 │
│ 不做什么:不写真实订阅源(用 mock URL,避免 CORS 干扰主线)│
│ 验收标准:投 20 个 url → 任何时刻 in-flight ≤ 5;返回 20 个结果(成功/失败混合)│
│ 预计耗时:90 分钟(本案例最难的一节,慢慢来) │
└──────────────────────────────────────────────────┘
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();
});
}
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));
});
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 ]
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;
}
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);
}
}
})();
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 网络请求失败: ...
2
3
4
5
6
✅ 部分成功 + 部分失败 + 总耗时 ≈ 最慢源的耗时(不是 N 个串行求和)= 限流并发完成。
# 6.5 故意造 bug
🧨 回退测试:把 pipeline.js 里的 try/catch 临时去掉:
// ❌ 错误版
return limit(async () => {
const resp = await http(url, { signal }); // 抛出去了
...
});
2
3
4
5
刷新 → 你会看到一条未处理的 Promise 拒绝红字,然后所有结果都不返回——一坏全坏。
✅ 加回 try/catch → 一切正常。这就是为什么"错误转数据"是关键。
┌─ 📌 阶段 ⑤ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的: │
│ • pLimit 信号量手写(30 行核心逻辑) │
│ • Promise.all + 内部 catch = 容错并发 │
│ • 错误转数据 → 上层统一处理成功失败 │
│ • Promise.resolve().then(fn) 守护同步异常 │
│ ⏸ 还没碰的(下阶段才会做): │
│ • 流式 reader(10MB feed 怎么不卡) │
│ • 生成器分页(无限滚动) │
│ 💡 本阶段最大领悟: │
│ "异步本质是调度——pLimit 就是你写的迷你调度器" │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
# 07.cache.js · 离线缓存与熔断器
┌─ 🎯 阶段 ⑥ 目标 ────────────────────────────────────┐
│ 完成什么:① 拉取成功后写 localStorage;② 失败时降级到上次缓存│
│ ③ 一个源连续失败 3 次 → 熔断 30s 不再发请求 │
│ 不做什么:不接 IndexedDB(数据量小,localStorage 够) │
│ 验收标准:断网刷新页面 → 仍能看到上次成功的内容 │
│ 预计耗时:45 分钟 │
└──────────────────────────────────────────────────┘
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;
}
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 };
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 };
}
})
)
);
}
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
🧪 三步验证:
- 第一次刷新 → 全部正常拉到 → Console 看不到
fromCache - 关闭 Wi-Fi → 刷新 → 应该看到
fromCache: true,仍然有数据展示 - 故意把某个源改成
https://invalid.invalid/x→ 刷新 3 次 → 看到[breaker] ... 熔断 30s→ 第 4 次刷新该源不再走网络
✅ 三步全过 = 阶段 ⑥ 完成。
┌─ 📌 阶段 ⑥ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的: │
│ • localStorage 离线降级模式 │
│ • 熔断器三态(closed / open / half-open) │
│ • Map 做"按 key 计数"的运行时状态容器 │
│ • 网络失败 ≠ 用户白屏——三层降级链 │
│ 💡 本阶段最大领悟: │
│ "可用性高于一切——错误兜底比新功能重要 10 倍" │
└──────────────────────────────────────────────────┘
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 分钟(异步生成器是卷一最难的一节) │
└──────────────────────────────────────────────────┘
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;
}
}
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, '条');
}
});
2
3
4
5
6
预期:
(200ms 后)收到一页 20 条
(再 200ms)收到一页 20 条
(再 200ms)收到一页 13 条
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);
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);
})();
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()" │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
# 09.streaming.js · 流式 reader 处理大文件
┌─ 🎯 阶段 ⑧ 目标 ────────────────────────────────────┐
│ 完成什么:response.body.getReader() 边下载边处理 │
│ 不做什么:不做完整的 SAX-style XML 流式解析(学院派) │
│ 我们示范"按 chunk 计算下载进度"+"够阈值就解析"│
│ 验收标准:拉取大 feed 时能在 UI 实时显示"已下载 XX KB" │
│ 预计耗时:60 分钟 │
└──────────────────────────────────────────────────┘
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);
}
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);
// ...
}
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 是粒度=分块的异步" │
└──────────────────────────────────────────────────┘
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 渲染 + 无限滚动
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); } // ⭐ 看起来像同步代码
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 · 视频播放器自实现