简介:这份 JavaScript 知识库面向希望系统梳理前端知识体系、补齐工程化与协作规范的开发者,也可供团队搭建内部知识库时参考。内容围绕 JavaScript 核心概念展开,并延伸到前端工程化、Vue 项目实践、CDN 与浏览器缓存、滚动行为、脚手架配置、Git 团队协作、NPM 与 VSCode 工具链、CSS BEM 规范及代码注释约定等主题,兼顾语言基础与项目落地场景。资源包共 26 个文件,以 19 个 Markdown 笔记为主体,辅以 3 个 Excel 表格、1 个 JavaScript 脚本及图片、Visio 图示等素材,压缩包约 342KB,体量轻便、结构清晰,便于按主题检索和持续补充。已有 133 人学习,适合需要建立知识索引、沉淀前端规范或准备团队分享的初中级开发者查阅。
1. 跑一遍比背十遍管用:这份 JavaScript 知识库到底该从哪一页翻起
面试被问到Promise和setTimeout谁先执行,答对了但心里没底;改别人代码时看到一串var加闭包,得在纸上画半天才敢动。这类问题的共同点是:背过,但没验证过。这份 JavaScript 知识库走的是另一条路:以语言机制为主线,每个主题拆成「一段讲结论的笔记 + 一个能独立跑的示例 + 一组把输出写死的断言」。它适合已经能写业务代码、但遇到闭包、原型链、this、事件循环还得靠回忆的人,也适合带新人时想找一份能当场演示的素材。翻它之前先接受一个前提:只读不跑,收益不到一半。
2. 拆开目录看分层:笔记、可执行示例、断言用例各管一摊
很多人拿到知识库的第一反应是从第一篇开始顺序读,读到第三篇就断了。原因不是内容难,而是这类资源的组织方式本身有层次:笔记负责给结论,示例负责给证据,断言负责给「下次改动不会错」的保险。顺序读会把三者混在一起,正确做法是先弄清楚每一层该在什么时候用。
2.1 三层结构分别对应什么动作
这类知识库的目录形状基本一致,命名可能不同,但职责划分大同小异:
| 目录类型 | 里面放什么 | 什么时候用 | 常见误用 |
|---|---|---|---|
| notes | 按主题分的 Markdown,一篇对应一个机制 | 跑完示例回来对结论 | 当成教程从头读到尾 |
| examples | 可独立运行的.js文件,一个文件一个知识点 | 想验证某个行为时直接跑 | 只改输出不改输入 |
| cases | 带断言的用例,跑完给出 pass/fail | 改完示例后回归 | 当成单元测试框架去学 |
三层的分工是:notes 写「结论是什么」,examples 写「为什么是这个结论」,cases 写「结论变了你怎么第一时间知道」。我一般先扫 notes 的标题,挑一个当前正好卡住的点,跳到对应 example 跑一遍,再看 case 里的断言对不对得上。整个过程十分钟内能闭环,比顺序读快得多。
需要提醒的是,notes 里的结论多半是压缩过的,比如「var是函数作用域,let是块级作用域」这一句,背后至少有变量提升、暂时性死区、循环中的每次迭代绑定三个考点。看笔记只能记住句子,跑示例才能看到行为。所以笔记的正确用法是「读完示例后回来对答案」,而不是「读完了再去跑」。
2.2 用 Node 把示例统一跑起来
示例文件通常是独立脚本,不需要打包工具,直接node跑就行。但一份知识库跨几十个文件,逐个敲命令太低效,常见做法是在根目录放一个package.json,把常用动作收进 scripts:
{ "name": "js-knowledge-base", "private": true, "type": "module", "scripts": { "ex:closure": "node examples/scope/closure.js", "ex:prototype": "node examples/object/prototype-chain.js", "ex:this": "node examples/function/this-binding.js", "ex:loop": "node examples/async/event-loop.js", "case:all": "node scripts/run-cases.js" } }"type": "module"这一行决定了.js文件按 ESM 解析,import能直接用;如果知识库里的示例写的是require,这一行要删掉或者改成"commonjs"。这是后面第 5 章要展开的第一个坑,先记住它和模块系统强绑定。private: true只是防止误发布,不影响本地运行。
跑单个主题:
# 先确认运行时版本,示例里用到的新语法对版本有要求 node -v # 跑一个具体示例,观察控制台输出的顺序 npm run ex:loopnpm run的价值不只是省字符,更重要的是它把「这个文件该用什么方式跑」写进了仓库里。半年后回来,你不需要回忆当时是node --experimental-xxx还是直接跑,package.json里写着的就是答案。
示例多了以后,逐个配 script 也累。我一般会补一个遍历脚本,把所有 examples 下的文件按目录顺序跑一遍,输出汇总:
// scripts/run-examples.js import { readdirSync, statSync } from 'node:fs'; import { join, extname } from 'node:path'; import { spawnSync } from 'node:child_process'; const ROOT = 'examples'; // 递归收集所有 .js 文件,跳过以 _ 开头的草稿文件 function walk(dir, acc = []) { for (const name of readdirSync(dir)) { const full = join(dir, name); if (statSync(full).isDirectory()) { walk(full, acc); } else if (extname(full) === '.js' && !name.startsWith('_')) { acc.push(full); } } return acc; } const files = walk(ROOT); let failed = 0; for (const file of files) { // stdio: 'pipe' 把子进程输出收回来,避免刷屏 const r = spawnSync(process.execPath, [file], { stdio: 'pipe', encoding: 'utf8' }); if (r.status !== 0) { failed += 1; console.log(`FAIL ${file}`); console.log(r.stderr.trim().split('\n').slice(0, 5).join('\n')); } else { console.log(`ok ${file}`); } } console.log(`\n共 ${files.length} 个示例,失败 ${failed} 个`); process.exit(failed ? 1 : 0);这段脚本的关键点有三个:walk用同步 API 递归,示例量级下没必要上异步;spawnSync用process.execPath而不是写死node,避免 PATH 里指向另一个运行时;stdio: 'pipe'把子进程输出收回来,只打印前五行错误,防止某个示例死循环刷屏。最后的process.exit用退出码表达结果,方便挂到 CI 或者 git hook 上。
参数上唯一需要按需调的是草稿过滤规则。我习惯把没验证完的示例命名为_draft-xxx.js,walk里跳过下划线开头,这样半成品不会污染批量运行的结果。
2.3 按主题索引挑路径
知识库的价值不在于「全」,而在于「你卡在哪就能跳到哪」。按使用频率排,我建议的翻阅顺序是这样:
| 卡点 | 先看哪一层 | 预期产出 |
|---|---|---|
| 变量在回调里值不对 | 作用域与闭包 | 能画出变量的持有关系 |
方法里的this是 undefined | 函数调用与绑定 | 能判断四类调用现场 |
| 继承来的属性判断错了 | 原型链与属性查找 | 分清自有属性和继承属性 |
| 输出顺序和预期不符 | 事件循环与微任务 | 能写出确定的执行序列 |
| 改了代码不知道有没有影响别处 | cases 目录 | 一条命令跑完回归 |
这张表的作用是减少「打开了但不知道看哪」的损耗。真正常用的其实只有前四行,第五行是进阶用法,放到最后一章讲。
3. 三类玄学示例的复现:闭包、原型链、this 绑定
这三块是 JavaScript 里最容易「看懂了但写不对」的部分,共同特点是:结论一句话能说完,反例能举出十种。知识库里给它们各配了独立示例,正确的用法不是读代码,而是先猜输出、再跑、最后对照,猜错的地方才是你真正的缺口。
3.1 闭包:用打印顺序把变量生命周期钉出来
先看一个最朴素的计数器:
// examples/scope/closure.js function makeCounter() { let count = 0; // 自由变量,被返回的函数持有 return { inc() { return ++count; }, read() { return count; }, }; } const c1 = makeCounter(); const c2 = makeCounter(); c1.inc(); c1.inc(); console.log('c1 =', c1.read()); // 2 console.log('c2 =', c2.read()); // 0,两次调用各自持有独立的 count跑之前先猜c2是多少。如果猜成 2,说明你把「闭包共享变量」和「每次调用创建新环境」混了。makeCounter()每执行一次就创建一份新的词法环境,count是这份环境里的绑定,c1和c2指向两份不同的环境,互不影响。这里没有玄学,只有一个事实:闭包捕获的是环境,不是值。
真正容易翻车的是循环里的闭包:
// 同一份示例里对比 var 和 let const fnsVar = []; for (var i = 0; i < 3; i++) { fnsVar.push(() => i); } const fnsLet = []; for (let j = 0; j < 3; j++) { fnsLet.push(() => j); } console.log(fnsVar.map(f => f())); // [3, 3, 3] console.log(fnsLet.map(f => f())); // [0, 1, 2]var声明的i在函数作用域里只有一份,三个箭头函数捕获的是同一个绑定,循环结束时它是 3。let在for循环里每次迭代都会创建新的绑定,并把上一轮的值复制进去,所以三个函数各持一份。这个差异不是「let 更先进」,而是规范里明确定义的每迭代环境(per-iteration environment)。跑一遍比记十遍管用。
参数上唯一能调的是循环边界和push的内容,建议至少改成 5 次再看一次,确认[3,3,3]不是巧合。
3.2 原型链:把查找路径一层层走一遍
原型相关的错误大多来自一个混淆:把「属性存在」当成「自有属性存在」。示例里通常会给一段手动搭链的代码:
// examples/object/prototype-chain.js const base = { kind: 'base' }; const mid = Object.create(base); mid.tag = 'mid'; const leaf = Object.create(mid); leaf.name = 'leaf'; // 沿原型链打印每一层的自有键,看清查找路径 function traceChain(obj) { const layers = []; let cur = obj; while (cur) { layers.push(Object.keys(cur)); cur = Object.getPrototypeOf(cur); } return layers; } console.log(traceChain(leaf)); // [ ['name'], ['tag'], ['kind'], [] ] 最后一层是 Object.prototypetraceChain用的是Object.keys,它只返回自有可枚举属性,正好对应「这一层自己有什么」。如果想看不可枚举的,换成Object.getOwnPropertyNames。循环终止条件是cur为null,也就是走到Object.prototype的上一层,所以数组最后会多一个空数组,这是正常现象,不是 bug。
判断属性归属时,示例里一般会并列三种写法:
console.log('tag' in leaf); // true,会查原型链 console.log(Object.hasOwn(leaf, 'tag')); // false,只看自有 console.log(Object.keys(leaf).includes('tag')); // false,只看自有可枚举in会沿链查找,Object.hasOwn只看自己,Object.keys还额外过滤掉不可枚举属性。三个结果不一样不是矛盾,是三个问题的三个答案。业务代码里判断「这个对象有没有配这个字段」,绝大多数场景想表达的是hasOwn,用in会把原型上的东西也算进去,这是很典型的一类线上问题。
3.3 this 绑定的四条规则与优先级
this的判定可以压缩成一句话:看调用点,不看定义点(箭头函数除外)。示例里通常会把四类调用现场摆在一起:
// examples/function/this-binding.js const obj = { name: 'obj', regular() { return this && this.name; }, arrow: () => (this && this.name), }; console.log(obj.regular()); // 'obj',方法调用 const fn = obj.regular; console.log(fn()); // undefined,默认绑定,严格模式下 this 为 undefined console.log(obj.regular.call({ name: 'x' })); // 'x',显式绑定 console.log(new obj.regular()); // undefined,new 绑定,this 是新对象 console.log(obj.arrow()); // undefined,箭头函数取外层 this判定顺序按优先级从高到低是:new绑定 > 显式call/apply/bind> 方法调用 > 默认绑定。箭头函数不参与这套规则,它没有自己的this,直接取定义时所在作用域的值,所以obj.arrow()里的this不是obj,而是模块顶层(ESM 下是undefined)。
实际排错时有一个很好用的习惯:把console.log(this)放在函数体第一行,同时打印调用栈的上一帧。如果this是undefined,看是严格模式下的默认绑定,还是箭头函数取到了外层;如果this是Window或者globalThis,基本可以确定这个函数是作为普通函数被调用的,而不是挂在对象上。
4. 异步执行顺序:把微任务、宏任务写成可回归的用例
事件循环的题之所以容易答错,是因为它涉及的规则不多,但组合起来的状态多:同步代码、微任务、宏任务、await之后的续体,四类东西的排队方式各不相同。知识库这块的处理方式是:先给一个最小可复现脚本,再给一个改写版本,最后用断言把顺序锁死。
4.1 最小事件循环复现脚本
// examples/async/event-loop.js console.log('1 script start'); setTimeout(() => console.log('2 timeout'), 0); Promise.resolve().then(() => console.log('3 promise then')); queueMicrotask(() => console.log('4 queueMicrotask')); console.log('5 script end');跑出来的顺序是1 5 3 4 2。拆开看:1和5是同步代码,直接执行;3和4进微任务队列,按入队顺序执行;2是定时器回调,属于宏任务,要等当前这轮同步代码和微任务队列都清空之后才执行。
这里有两个容易被忽略的细节。一是setTimeout(fn, 0)的延迟并不真的是 0,规范允许实现做时钟对齐,实际可能是 1ms 或者更多,但无论多少,它都排在微任务之后。二是queueMicrotask和Promise.then进的是同一个微任务队列,因此它们的相对顺序取决于谁先入队,而不是谁「更快」。
如果示例跑出来的顺序和预期不一样,先别怀疑代码,检查运行环境:不同运行时对宏任务和微任务的边界处理是一致的,但对定时器的最小延迟、nextTick这类扩展队列的处理不一样。第 5 章会把这条列进踩坑清单。
4.2 async/await 改写与等价性验证
await是微任务的语法糖,但它容易让人误以为「异步函数里的代码全在微任务里」。改写示例能把这个误解拆掉:
// 对比:async 函数体内的同步段在调用时就执行 async function task() { console.log('a1'); // 调用 task() 时立刻打印 await null; // 后续代码进微任务队列 console.log('a2'); await null; console.log('a3'); } console.log('b1'); task(); console.log('b2'); // 输出:b1 a1 b2 a2 a3关键结论是:async函数被调用时会同步执行到第一个await为止,await之后的代码才会被包成续体放进微任务队列。所以a1出现在b1和b2之间,而不是之后。很多人把async函数整体当成「异步执行」,在需要保证初始化顺序的代码里就会翻车。
await null和await Promise.resolve()在排队行为上基本等价,都只是让出一次。区别在于可读性和调试体验,我一般用await null做纯粹的「让出一次」,用它来验证顺序,因为它不引入额外的 Promise 状态。
4.3 把输出顺序变成断言
看完示例觉得自己懂了,一周后又会忘。真正能留下来的做法是把顺序写成断言:
// cases/event-loop.test.js import assert from 'node:assert/strict'; const order = []; const tick = (label) => order.push(label); // 用收集器替代 console.log,顺序变成可比较的数据 tick('sync-1'); setTimeout(() => { tick('timeout'); // 断言放在最后一个宏任务里,此时本轮所有微任务已清空 assert.deepEqual(order, ['sync-1', 'sync-2', 'promise', 'micro', 'timeout']); console.log('event-loop order passed'); }, 0); Promise.resolve().then(() => tick('promise')); queueMicrotask(() => tick('micro')); tick('sync-2');三个设计点值得说明。tick函数把输出从「打印」变成「入数组」,这样顺序才是可比较的数据结构,console.log的顺序没法拿来断言。断言放在setTimeout回调里,是因为此时同步代码和微任务队列都已经清空,order是完整的;放在同步段末尾会漏掉后面入队的项。用assert.deepEqual而不是比较字符串,报错时能直接看到差异位置。
node:assert/strict是常用选择,它默认走严格相等,不会出现==的隐式转换把0和'0'判等的情况。如果知识库里的 case 用的是别的断言库,逻辑一致,改一下引入即可。
5. 避坑与排查:五条血泪记录和一套定位顺序
知识库跑不通,八成不是内容错,而是环境和预期对不上。下面五条是我在不同机器上重复遇到过的,按「现象 → 原因 → 解决」记下来,遇到时直接对照。
5.1 环境与运行类踩坑
第一条:SyntaxError: Cannot use import statement outside a module。现象是双击跑某个示例,第一行import就报错。原因是.js文件默认按 CommonJS 解析,import是 ESM 语法。解决办法二选一:把文件后缀改成.mjs,或者在最近的package.json里加"type": "module"。选后者时要注意,整个目录下的.js都会变,如果同目录还有用require的老示例,会一起报错,这时应该分目录而不是全局改。
第二条:示例里的定时器顺序和笔记对不上。现象是笔记写1 5 3 4 2,你跑出来是1 5 3 2 4。原因通常是混入了运行时特有的队列,比如某些环境提供的额外微任务 API,或者示例本身在浏览器控制台里跑过一遍又被抄进了 Node 文件。解决办法是固定运行环境:在package.json里写明engines,跑之前在示例顶部打印process.version,把这个值记进笔记。跨环境对比是很多顺序题的根源。
第三条:改了示例,笔记没跟着改,两边打架。现象是你照着笔记的结论改代码,跑出来是另一个结果。原因是笔记和示例是两份独立维护的文本,改一份不会联动另一份。解决办法是定一个唯一事实来源:以可执行示例为准,笔记只保留结论和一句「见 examples/xxx.js」,不再抄代码。这条看起来是流程问题,实际上是这类知识库最容易腐化的地方。
5.2 语义理解类踩坑
第四条:以为把var换成let就万事大吉。现象是循环闭包的示例改完之后输出对了,但换到while循环里又错了。原因是let的每迭代绑定只对for这类有明确迭代语义的语句生效,while里在外面声明的变量仍然只有一份。解决办法是对照跑三个版本:var+for、let+for、let+while,把差异记录下来,而不是记住「用 let 就对」。
第五条:用in判断字段是否存在,结果命中了原型上的东西。现象是'toString' in {}返回true,让一个本该走默认分支的逻辑走了异常分支。原因是in会沿原型链查找。解决办法是明确你要问的是哪个问题:问「能不能调用」用in,问「自己有没有配」用Object.hasOwn,问「可枚举的自有键」用Object.keys。这三个函数的语义差异必须在笔记里写清楚,否则每次都要重新想一遍。
5.3 五分钟排查顺序
遇到跑不通,按固定顺序走,不要跳步:
node -v确认运行时版本,和package.json里的engines对一遍;- 把示例缩到最小,删掉所有
console.log只留一个,确认它在执行; - 在关键位置打印中间态,尤其是
this、Object.getPrototypeOf的返回值、order数组的当前长度; - 跑对应的 case,看断言报的差异在哪一项,差异项往往直接指向结论错在哪;
- 最后才回去看笔记,确认是笔记写错了还是你记错了。
这个顺序的核心是「先确认事实,再对照结论」。反过来做,很容易拿着一个错误的记忆去改一份正确的示例。
6. 进阶:把知识库改造成自己的题库和回归基线
读到这里,知识库的常规用法已经走完了。真正让它长期有价值的一步,是把它从「别人的资料」变成「你的基线」。做法不复杂,但需要一点耐心。
先给每个示例补一条断言,哪怕只有一行。比如闭包那个例子,跑完fnsLet.map(f => f())之后加一句assert.deepEqual(fnsLet.map(f => f()), [0, 1, 2])。这一步的意义不在于验证代码对不对,而在于把「你以为的输出」固化下来。半年后语言规范或者运行时行为有变化,断言会先炸,而不是等你上线才发现。
然后把验证顺序的那套收集器抽成一个公共小工具,所有涉及执行顺序的示例都复用它:
// scripts/order.js export function createOrder() { const list = []; return { tick: (label) => list.push(label), // 断言前先快照,避免后续入队影响比较 snapshot: () => [...list], }; }snapshot返回浅拷贝这一点很关键。如果直接返回list引用,后续再有tick调用,你手里的「期望值」会跟着变,断言就变成自己和自己比。这种错误我在早期版本里犯过一次,排查了半天才发现是比较对象被改了。
接着把case:all挂到提交前。不需要引入完整的测试框架,一个能返回退出码的脚本就够了,git的pre-commit钩子里跑一遍,失败就拦下来。知识库这种体量的项目,跑一遍通常在一两秒内,几乎无感。这里有个细节:批量跑的时候一定要按文件名排序,readdirSync在不同文件系统上的返回顺序不保证一致,顺序不稳会让失败日志难以对比。
最后一件事是给自己出题。挑一个示例,把console.log全删掉,只留输入,然后手写预期输出,再跑一遍对照。我一般每周做两三个,专挑自己没把握的:Proxy的拦截顺序、getter和Object.keys的关系、Promise.all里某个 reject 之后其余还跑不跑。这些点在笔记里往往只有一句结论,只有自己出题才会发现那一句根本不够用。
说个我自己的教训。早期我把这套东西当纯阅读材料,读了两个月,自以为很熟,结果在一个真实项目里写了个await放在forEach回调里,以为会串行等待,实际全部并发出去,接口被打了个对穿。从那以后我每次遇到执行顺序的问题,都强制先把示例改小、跑一遍、拿到确定的输出序列再动业务代码,绝不靠记忆下判断。希望帮到你。
本文还有配套的精品资源,点击获取