一、为什么有这一篇阅读源码的文章?
想要提升自己的编码水平,阅读优秀的开源项目是一个重要的手段。掘金网站上面有很多解析各种框架源码的文章,还有更多教你如何组织好代码的文章。
比如说,红皮书、绿皮书、黄皮书告诉了我们JS是什么?而《重构》、《代码整洁之道》告诉了我们如何组织好JS。
唯独阅读源码的文章却很少。这就让我在一开始阅读源码的时候无所适从。打开github,搜索vue3的源码,看到如下的界面:
和我们平时做的业务项目结构基本不对应。不知道从哪里看起,应该看什么?想来是一些技巧和方法是我没有掌握的。
所以我开始认为,阅读源码也是一种技能。
这是一项重要技能,决定你是否从初中级程序员步向高级程序员,甚至可能是最重要的。
二、为什么需要阅读源码?
- 扩充知识面,提升编程能力
- 面试会考
- 源码不是教程,它背后没有习题,你无法保证你看完之后就真的会了。
- 所以最直接的就是面向面试学习,了解 Vue 与 Node.js 的面试考点,针对面经中提到的问题去看源码。也是为了更好的树立一个目标去阅读源码,可以直观的验证自己所学的是否到位。这样做除了可以带来更直观的反馈之外,也能够更好的应对面试。
- 提升设计思维和架构能力
- “前端娱乐圈”这句戏言不是一句戏言。
- 框架是一直在变,一直都在更新的,然而JavaScript基础是永恒不变的,无论是学习Vue源码还是Nodejs再或者是学习React ,都是提升自己使用JS这门语言组织能力。这个组织能力就是设计思维和架构能力
- 提升代码审美能力
- 要写一手优雅的代码,首先分清楚什么是好的代码,什么坏的代码。而这个审美能力,除了需要大量的实践,还需要阅读很多优秀的代码才能够提高。
三、我是如何去阅读源码的?
3.1 一定的基础知识
-
看Vue3之前,如果你对于
proxy
和reflect
,你是看不懂它响应式是如何实现的 -
在看Npm源码之前,如果不会
commonJs
模块化,fs
、process
这些基本API,你从一行代码就会满头雾水 -
想要学习
diff
算法,不管是Vue还是react的,如果没有一定的数据结构和算法的基础,更是寸步难行。 -
所以在自己的基础还不够之前,就直接去阅读这些框架源码,收益比太低,还不如多切几张图,而且看不明白,耽误时间之外,还会让自己变得焦虑。
3.2 正确的心态
- 要有信心: 这些优秀的开源框架本身还是使用JS或者TS写的,我们没有工作都在使用的东西,他们或许写得很精妙,封装得很高级。既然是开源的,就是要有多人协作。所以,当我们将它们拆成一下块一小块,总是会转化成为我们能够理解的东西。看不懂,也只是因为我们对于框架的整体结构的不了解。
- 敢于质疑: 既然是人写的,就可能会出错,所以我们要敢于质疑
- 拉长反馈周期: 在阅读这些框架源码的时候,不能着急,贡献者编程能力在那摆着呢,更不用说都是一个团队经过长时间的维护。所以,不要想着一上来就能够看懂别人写的是什么,这样子也太看不起人家了。我们阅读源码的过程要降低预期,拉长反馈的周期。
四、阅读源码的流程
4.1 全盘了解
- 这个框架是为了解决什么问题诞生,或者说产生的背景是什么?
- 有什么功能
- 有什么API
4.2 列出疑问清单
- 和我们当年 在学习语文课的时候一样,语文老师都要求我们在预习中带着疑问
4.3 理清项目结构
- 找到入口文件
- 通过项目根目录的文档,或者百度谷歌都可以
4.4 阅读的策略
-
为什么:
- 很多框架的源码行数非常的多,宛如枝繁叶茂的树,我们要做的就是从根部开始往上爬,可是时间又是有限的,所以 有一个好的阅读策略是很有必要的。
-
把项目跑起来:
- 毕竟不是真正在阅读文章,我们是应用学科,是一定要上手边调试边阅读才能够搞清楚的,所以我们的标题更加贴切的叫法应该叫做“如何调试源码”
-
聚集问题:
- 我们一般来说是不需要参与开发的,所以不要求一次性就把源码如何实现都弄明白。这就是需要第二点的原因,带着我们的疑问去看代码,在理清主干流程之后,再来针对性的弄明白细节是如何实现的。
- 这就是要求我们确定阅读路径 , 比如在我们熟悉的Vue的源码当中,有一个目录是解析模板的主逻辑,将虚拟DOM转化生成语法树AST,最后生成" Render Function" , 这个目录关键的流程是在 "Render Function" ,至于如何解析模板,如何生成语法树,可以先放一放,这一块阅读起来还是很有难度的,陷进去的话,很容易卡死出不来,进而导致阅读源码的正反馈少,距离放弃阅读也就不远了。
- 记得阅读英语文章的时候,老师说过,不要看到不认识的单词就慌,我们可以继续往下读,通过上下文,自然能够猜测到那个单词是什么意思。
- 有人问,如果这一串上下文你大部分单词都不认识的话,怎么结合上下文。这个时候那肯定是要先去背背单词吧。
- 所以说,在阅读源码之前是要有一定的基础的。
-
不要对自己太狠了:
- 和上一点相似,不要对自己太狠了,非要一下子弄懂源码每一个细节。
- 别人都更新迭代多少代了,网上才有一种阅读策略是从他们的第一个版本开始看起,但是这种策略实在是太费时间了。当然每一个细节都搞懂的话当然是最好的,转眼就会忘记了,收益比很低。毕竟自己写的代码,两个月后再来看,你自己都搞不明白是啥意思。
4.5 其他关注点
- 争取成为这个项目的" contributors"
- 有一种说法是,当你看不懂某个模块是做什么的时候,可以通过测试用例来看,它直白的入参出参就能够很直白的表述这个模块的作用。
- 个人玩不了这种策略,前端的测试用例实在是太乱太杂了,还涉及到了界面的渲染的测试,看得时候效率比直接看源代码更低。
五、辅助手段
5.1 直接github上面进行调试
- 将
.com
改为.dev
, 比如github.com/npm/cli ⇒ github.dev/npm/cli - 点击
.
和第一点同样的效果 - 如果不想调试,可以将
github
⇒github1s
, 进入编辑器只读模式- 登录自己的 VSCode 账号,这样它就会自动把我们本地 VSCode 的配置、主题、快捷键、插件等统统同步过来. 还会保存我们最后一次操作的快照.
- 我本身是一个idea党派,但是也因为这个方便的功能,配置上了VsCode
5.2 插件
Code translate
哪里不会点哪里,在线翻译英语单词Bookmarks
书签,可以给当前行做一个标记,方便下一次直接进来
5.3 画图工具
- Excalidraw | Hand-drawn look & feel • Collaborative • Secure 手绘风格的画图工具,操作简单,风格富有灵性,个人最喜欢的画图工具.
5.4 笔记软件
-
框架源码的量是很多的,不是一天两天就能够看完的. 所以为了每次阅读代码之前,能够快速回顾,进入状态.我们需要把重点的记录道笔记当中. 画图工具中画的流程图也是同样的道理.
-
而如果是想要使用笔记来记录的话,我这里推荐
logseq
这款免费的大纲笔记软件, 操作简单,可以放入代码块,通过插件还能够一键生成思维导图. -
如果条件允许的,我也很推荐我正在使用的
remNote
推荐的这两款都是大纲类型笔记, 大纲类型笔记作为自己的笔记的时候会很舒服.但是要迁移的时候就很麻烦. 比如说迁移到绝金上面发布,格式基本不可用.
六、npm的源码试试水
6.1 背景
解决的问题:
- npm的出现弥补了node没有包管理工具的缺陷,早就是node内置工具了
- 运行的环境:
- NODE. 相比浏览器,多处来下载、删除、读写本地文件的功能
6.2 有什么功能?
npm init
npm install
npm run
6.3 其他的API
6.4 疑问清单
npm run
如何跑的- 为什么它安装的依赖版本和我们
package.jons
不一样 - 为什么执行了npm install 但是安装的包版本和我package.json不一样呢?
- npm install安装到一半卡住?
npm
是如何对第三方包进行安装的 ? 又是如何对这些包的依赖关系进行处理的?npm install <foo>
的运行机制- ...
6.5 找到入口文件
- 操作环境: Mac
- Window的用户可以研究一下
wsl2
,就现阶段的wsl2
而言,你的下一台电脑何必是Mac.
- Window的用户可以研究一下
- 终端: Warp
- 操作步骤:
which npm
: 查找软连接路径ll <path>
: 查看软链接实际执行运行源码- 此时我们键入
node /opt/homebrew/lib/node_modules/npm/bin/npm-cli.js
就等于直接在command中输入npm
. 这就是我们要找的入口文件. - 当然找到了入口文件,我们可以直接在github上面到npm/cli仓库进行dev. 或者直接把源码拷贝到本地
- 此时我们键入
6.6 配置断点
配置launch.json
json
复制代码
{ "version": "0.2.0", "configurations": [ { "type": "node", "request": "launch", "name": "npm run dev", "skipFiles": [ "/**" ], "program": "${workspaceFolder}/index.js", "args": [ "run", "dev", ], "stopOnEntry": true }, { "type": "node", "request": "launch", "name": "npm install lodash", "skipFiles": [ "/**" ], "program": "${workspaceFolder}/index.js", "args": [ "i", "lodash", "--no-package-lock" ], "stopOnEntry": true }, ] }
6.7 进入主干流程源码
6.71 cli.js
- 进入" cli.js" 文件,可以看到分为两部分,一部分是在验证版本信息,以及监听异常. 另外一部分就是该文件的主函数来.
js
复制代码
// 1. 校验版本信息, 监听异常 const createEnginesValidation = () => { ... } // ... // ... let cmd; // 2. 初始化npm信息 await npm.load(); cmd = npm.argv.shift(); // !executive 执行 , 核心程序 await npm.exec(cmd, npm.argv); return exitHandler();
-
直接从" exec" 的命名就可以知道,当我拿到command输入的命令和参数之后,初始化npm的数据之后, 就调用" npm.exec" 来执行对应的逻辑.
-
接下来可以进行查看" npm.exec" 中的逻辑
6.72 npm.js
阅读指导:
-
当我们进入到这个文件的时候,就会发现里面有很多很多的文件.这个时候我们一定要谨记之前说的阅读策略.
- 确定我们的阅读路径: 我们这次的目标是为了搞懂npm运行的主流程是什么
- 不需要搞懂每一个细节的实现是什么, 每一个参数什么.
-
基于阅读策略指导,我们不需要把这个文件从头看到尾. 跟着上一个文件" npm.exec" 走到这个文件暴露出去的" exec" 函数.
exec:void
:
js
复制代码
// 1. 拿到对应分支程序返回的实例 const command = await this.cmd(cmd); const timeEnd = this.time(`command:${cmd}`); // 用于超时判断 execPromise = command.exec(args); // 返回的执行实例 return execPromise.finally(timeEnd); // 返回执行实例的promise
- 排除掉" timeEnd" ,这个函数做的事情,就是拿到" this.cmd" 返回的什么东西,然后return 它的promise出去.
- 此时我们的目标就到了" this.cmd" 上.
cmd: void
:
js
复制代码
await this.load(); const command = this.deref(cmd); // 对当前命名进行校验 const Impl = require(`./commands/${command}.js`); const impl = new Impl(this); return impl;
- "this.load()" 一时间看不懂不要紧,直接跳过去. 直接去看第二个用的函数" this.deref()" , 它就很简单,是对于传入的" cmd" 进行校验.我们通过打断点可以知道, "command" 是我们配置运行时候的参数,比如install、run、init....
- 所以我们是拿到目录" ./commands/xxx.js" 中返回的类, 然后将当前环境的" this" 传入中,最后直接返回对应文件的实例.
6.73 总结一下
上面就是NPM源代码的主流程
-
文件跨越: "cli.js" ⇒ "npm.js"
-
函数调用栈: "npm.load()" ⇒ "cmd()" ⇒ "exec()" ⇒ "npm.exec()"
-
想不如写,写不如画
6.73 小技巧提示
- 声明部分不用看
- 借助单步调试
- if直接“删除”,不用看
- if/else 二选一 需要看
6.8 npm install
运行机制
6.81 前置操作
分析来主流程, 我们就可以直接指定到" commands" 目录下面找到对应的" install.js"
即便是没有都在使用install的命令,但是不见得能够了解它的所有功能, 所以建议先阅读官方文档来了解一下 npm-install | npm Docs
6.82 断点配置
javascript
复制代码
{ "version": "0.2.0", "configurations": [ { "type": "node", "runtimeExecutable": "/usr/local/bin/node", "request": "launch", "name": "Launch Program", "skipFiles": [ "<node_internals>/**" ], "program": "index.js", "args": ["i", "lodash", "--no-package-lock"] } ] }
6.83 执行过程
exec()
=>
把 if
都删除掉之后就不剩下什么了
javascript
复制代码
const install = async args => { const opts = { ...this.npm.flatOptions, auditLevel: null, path: where, add: args, workspaces: this.workspaceNames, }; const arb = new Arborist(opts); await arb.reify(opts); }
- 其中
args
打印一下就可以知道,这个是我们要安装的所有包名的列表, 这里就是['lodash']
- 其他参数很容易可以通过命名来知道它大概的意思.
where
当前运行install命名的目录this.workspaceNames
工作空间的相关信息this.npm.flatOptions
一个对象,放了一些初始化的配置
- 所以我们主要研究对象就变成了
Arborist
实例.
很有意思的一个命名,就很贴切.我们在把文件从远程下载到本地之一,还需要处理如何构建 node_modules
这个依赖文件树
Arborist
=>
- 路径:
'/nodel_module/arborist/index.js'
- 主代码:
js
复制代码
const mixins = [ require('../tracker.js'), // 追踪? 或许是日志 require('./pruner.js'), // 修建?优化 require('./deduper.js'), // 去重 require('./audit.js'), // 审核?校验? require('./build-ideal-tree.js'), // 构建文件目录树 require('./load-workspaces.js'), // 加载工作区 require('./load-actual.js'), // 加载真实节点树 require('./load-virtual.js'), // 加载虚拟节点树 require('./rebuild.js'), // 重构 require('./reify.js'), // 整理包 ] const Base = mixins.reduce((a, b) => b(a), require('events')) class Arborist extends Base { // ... }
-
不用挨个点进去就知道各个模块大概是什么作用的. 注释已经标出.
-
可以大胆猜测一下它的执行顺序:
- "reify" ⇒ "loadVirual" ⇒ "loadActual" ⇒ "loadWorkspaces" ⇒ "builddealTree" ⇒ "audit" ⇒ "deduper" ⇒ "pruner" ⇒ "rebuild"
-
通过断点调试, 观察调用栈可知, "reify" 这是我们想象的执行入口.当然直接看官方文档,上面啥都有.
reify()
=>
- 路径:
'/nodel_module/arborist/reify.js'
- 主代码:
javascript
复制代码
async reify (options = {}) { // ... await this[_validatePath]() await this[_loadTrees](options) await this[_diffTrees]() await this[_reifyPackages]() await this[_saveIdealTree](options) await this[_copyIdealToActual]() // ... }
- 代码的执行顺序很明了, 函数名也很直白的表达出了它要做的事情. 剩下的就是我们一个一个点下去看每一个环境是怎么执行.
- 校验路径:
this[_validatePath]()
- [validatePath] ⇒ mkdirp ⇒ mkdirpNative ⇒ findNode
- 校验当前路径是否有效 ⇒ 判断当前目录路径是否已经创建, 如果没有就创建
- 加载依赖树:
this[_lodaTrees]()
- 简介:
- 这部分就会涉及到我们平时经常遇到的问题, 它是如何根据
package.json
来生成依赖关系的, 如果多个包都有一样的依赖如何出来, 如果这些依赖版本不一样,但是包名一样又是如何处理的? 是整个依赖整理的核心
- 这部分就会涉及到我们平时经常遇到的问题, 它是如何根据
- 核心代码:
javascript
复制代码
if (!this[_global]) { return Promise.all([ this.loadActual(actualOpt), this.buildIdealTree(bitOpt), ]).then(() => process.emit('timeEnd', 'reify:loadTrees')) } return this.buildIdealTree(bitOpt) .then(() => this.loadActual(actualOpt)) .then(() => process.emit('timeEnd', 'reify:loadTrees'))
- 解析:
- 就是一个if else, 这里使用了卫语句 . 不是安装到全局的话,就同时运行
this.loadActual
和this.buildIdealTree
这两个方法. 而非全局的话,就有执行的顺序. - 我们这次的目的是为了搞懂install的运行机制, 所以碰到这种分岔路,我们只要选择其一就可以了.这就是之前为什么说if可以不用看, if/else 需要看的
- 就是一个if else, 这里使用了卫语句 . 不是安装到全局的话,就同时运行
七、总结
- 我们在阅读源码中做一个一切准备都只是尽量的拉平你和项目作者之间的信息差.
- NPM的源码内容很多,没有办法通过一篇文件就解析完全, 这里只是作为一个引子. 希望大家都能够早日进入源码的源码当中.