如何阅读源码 —— 以 Vetur 为例

1,349 阅读30分钟

本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金。

全文近万字。。。来都来了,点个赞再走吧

我很早就意识到,能熟练、高效阅读开源前端框架源码是成为一个高级前端工程师必须具备的基本技能之一,所以在我职业生涯的最早期,就已经开始做了很多次相关的尝试,但结果通常都以失败告终,原因五花八门:

  • 缺乏必要的背景知识,犹如阅读天书
  • 不理解项目架构、设计理念,始终不得要领
  • 目标不够聚焦,阅读过程容易复杂化
  • 容易陷入细节,在不重要的问题上纠结半天
  • 容易追着分支流程跑,分散注意力
  • 没有及时记录笔记和总结,没有把知识碾碎、重组、内化成自己的东西
  • 没有处理过特别复杂问题的经历,潜在的不自信心理
  • 个人毅力、韧性不足,或者目标感不够强烈,遇到困难容易放弃
  • 等等

这个列表还可以继续往下拉很长很长,总之既有我自己主观认知上的限制又有切切实实的客观原因。后来因为工作的契机硬着头皮看完 Vue 和 mxGraph 的源码,发现事情并没有自己想象中那么困难,后来前前后后陆续看了很多框架源码,包括 Webpack、Webpack-sources、Vite、Eslint、Babel、Vue-cli、Vuex、Uniapp、Lodash、Vetur、Echarts、Emmet 等等,愚钝如我也慢慢摸索出了一些普适的方式方法,进而斗胆撰下这篇文章,不敢说授人以渔,但至少也该抛砖引玉吧。

所以这是一篇为哪些有意,或准备,或已经在阅读前端框架源码的同学而写的文章,我会在这里抛出一些经过我个人多次实践总结出来的阅读技巧和原则,并结合 Vetur 源码,具体地讲解我在阅读源码的各个阶段所思所想,希望能给读者带来一些启发。

弄清楚目标

在介绍具体的技巧之前,有必要先跟读者探讨一下阅读源码的动机,想清楚到底需不需要通过这种方式提升自身技能,虽然学习优秀框架源码确实有非常多不言自明的好处,但每个人的经验、所处的语境、诉求、思维习惯不同,实际学习效果在不同个体或个体的不同时期必然存在极大的差异,这里面最大的变量一是经验,二是目标,经验因人而异,且很难在短时间内补齐,没有太多讨论空间;倒是目标方面值得盘道盘道。

第一层,先弄清楚为啥要阅读源码?可能的原因有很多,例如:

  • 为了增进对框架的认知深度,提升个人能力
  • 为了应对面试
  • 为了解决当下某个棘手的 bug 或性能问题
  • 基于某些原因,需要对框架做二次改造
  • 反正闲着,也不知道该学点啥,试试呗。。。
  • 好奇

这里面有一些很抽象,例如最后一个“好奇”;有一些很具体,例如“为了做二次改造”;还有一些在具体与抽象之间。按照 SMART 原则的说法,越具体、可衡量的目标越容易达成,如果读者的目标还处在比较模棱两可,不够具体详细的阶段,那执行过程大概率会翻车,毕竟这是一件特别消耗精力与耐性的活儿。

对于这种情况,我的建议是不妨往更细节的层次再想一想,例如对于最后一点“好奇”,可以想想具体有哪些特性让你特别神奇,值得花时间精力去细致地探索,放在 Vetur 语境下可以是“我想了解 Vetur 的 template 错误提示与 eslint 如何结合在一起,实现模板层面的错误提示功能”,这就很具体很容易衡量了。

第二层,读者如果已经有了明确、具体、可衡量的目标,不妨在开始之前先自问几个问题:

  • 当下确实需要以阅读源码的方式增进自己对框架的认知深度吗?有没有一些更轻量级,迭代速度更快的学习方式?
  • 你所选定的框架,其复杂度、技术难度是否与你当下的能力匹配?最好的状态是你自认为踮踮脚就能够到,过高,不具有可行性;过低,ROI 不值当。

如果经过这番推敲之后,必要性、可行性、相关性都与个人目标契合,那就没啥可犹豫的。

第三层,需要辩证地去看待所谓“目标” —— 不是把整个项目完整读完读通才叫成功,如果能从一些语句、片段、局部模块中习得新的设计思维、工具方法,甚至仅仅是命名规范都可以算作个人的一点进步,积少成多远比拔苗助长靠谱的多。所以一开始没必要把目标定的太高,能刚刚好满足自身需求是最好的,过程中如果发现问题域的复杂度在不断膨胀变大,持续投入很多时间却始终没有明显成效的话,那建议果断放弃或者请求外援,重新评估目标与可行性之后再做决定。

总之,这是一个预期管理的问题,我们可以多参考 SMART 原则,多从具体、可衡量、可行性、相关性几个维度思考,不断推敲是否需要做这件事;如何拆解目标,用目标反推计划,不断推进个人成功。

阅读技巧

了解背景知识

知识是形成理解的必要条件,展开学习任何一个开源项目之前都有必要花点时间调研项目相关的基础知识,渐进构建起一套属于你自己的知识体系,这包括:

  • 优质参考资料 —— 收集一波质量较高的学习资料,收集过程可以同步通读一遍
  • 框架是如何运行的 —— 也就是所谓的入口
  • IO —— 框架如何与外部交互?它通常接受什么形态的运行参数?输出什么形式的结果?
  • 生态 —— 优秀的框架背后通常都带有一套成熟的生态系统,例如 Vue,框架衍生品如何补齐框架本身的功能缺失?它们以何种方式,以什么样的 IO 与主框架交互?遵循怎么样的写法规则?
  • 如何断点调试 —— 这几乎是最有效的分析方法,断点调试能够帮助你细致地了解每一行代码的作用。

注意,这里的目标是迅速构建起关于这个开源项目的抽象 —— 甚至不太准确的知识框架,有意思地避免陷入无尽的细节中,就像在阅读一篇文章的时候,可以先看看目录结构粗略地了解文章的信息框架,了解文章大概内容。

例如,我刚开始学习 Vetur 的时候只知道这是一个 VS Code 插件,但完全不了解插件怎么写、怎么运行、怎么实现语言特性,所以我做的第一件事情是仔仔细细阅读 VS Code 的官方文档(所幸文档非常齐全,不像某著名打包工具),学习关于插件开发的基本知识,包括:

进一步总结关于 VS Code 语言插件的要素:

  • 怎么写插件:通过 package.json 文件的 contributesmain 等属性,声明插件的功能与入口
  • 怎么运行:开发阶段使用 F5 启动调试
  • 怎么编写语言特性:使用 词法高亮、Language API、Language Server Protocol 三类技术实现

VS Code 领域的知识量还是很庞大的,学习背景知识并梳理成这种高度结构化、高度抽象的脑图能够给你一个更高层、全面的视角,理想状态下,后续实际分析源码的时候这些骨架脉络能够让你非常本能地映射到某一个切面的知识点,事半功倍。

六步循环分析

接下来,我会介绍一套我常用的分析流程:

整体分为六个步骤:

  • 理解项目结构
  • 寻找合适的切入点
  • 就着切入点查阅文章资料
  • 就着切入点分析代码流程
  • 局部深入研究
  • 及时总结
  • 之后,再继续设定切入点,重复执行上述流程直到透彻地理解了问题

这是一套在 总-分-总 视角之间反复横跳最终构建出完整视角的方法论,重点就在于告诉读者在什么阶段应该关注什么,忽略什么,输入什么,输出什么,我个人就是按照这个方法慢慢摸索出包括 Webpack、Babel、Vue、Vetur、mxGraph 在内的各种开源框架的实现原理。

理解项目结构

刚开始阅读源码的时候,相信大多数人都会很懵逼,无从下手,这是因为读者对项目缺乏一个必要的框架性认知,不了解程序的入口在哪里、关键组件有哪些、各个文件夹有什么作用等,遇到问题无法迅速推测实现路径。

所以,阅读源码的第一个步骤,应该是先花点时间粗浅地分析、理解项目的组织结构。所幸一个值得深入阅读学习的开源项目,通常都会有较强的整体性与一致性,我们只需要梳理出三条线索:

  • 分析项目入口
  • 分析项目依赖了哪些基础工具,包括编译工具,如 webpack、Typescript、babel;基础库,如 lodash、tapable、snabbdom。
  • 将项目中重要文件夹、文件逐一列举出来,理解它们如何按照依赖关系组成一个整体的架构。

放在 vetur 语境下,我们在上面“了解背景知识”一节已经了解到 VS Code 插件需要在 package.json 文件通过 contributes 等属性声明插件的配置信息,所以这几个问题都能在 package.json 文件找到答案。

入口分析

首先,需要识别出 Vetur 应用的入口,这一步的作用是帮助我们理解 Vetur 是如何向 VS Code 贡献新特性的。分析 vetur 的 package.json 发现有三种直接指向到文件的配置项:

  • contributes.languages 指定语言配置文件
  • contributes.grammars 指定语法配置文件
  • "main": "./dist/vueMain.js" 指定插件执行入口

三个入口分别实现三种不同的语言特性功能,略显复杂,这里有必要分别展开了解一下。

探索 contributes.languages 配置

逐个讲解,contributes.languages 配置信息指向到 ./languages/***-language-configuration.json 文件,如:

{
    // ...
    "contributes": {
        "languages": [
            {
                "id": "vue",
                "configuration": "./languages/vue-language-configuration.json"
            },
            {
                "id": "vue-html",
                "configuration": "./languages/vue-html-language-configuration.json"
            }
            // ...
        ]
    }
    // ...
}

这里回过头翻一下 VS Code 对 [contributes.languages](https://code.visualstudio.com/api/references/contribution-points#contributes.languages) 的解释(感谢资源丰富的 VS Code 社区):

Contribute definition of a language. This will introduce a new language or enrich the knowledge VS Code has about a language.

大意是说 contributes.languages 配置项的作用主要是增进 VS Code 对具体语言的理解,至于怎么增强呢?继续打开配置项中的 ./languages/vue-language-configuration.json 文件:

{
    "comments": {
        // symbol used for single line comment. Remove this entry if your language does not support line comments
        "lineComment": "//",
        // symbols used for start and end a block comment. Remove this entry if your language does not support block comments
        "blockComment": [
            "/*",
            "*/"
        ]
    },
    // ...
}

文件中定义了行内 comment、块级 comment、括号、折叠等语言规则的配置,规则都很简单直白,篇幅关系这里不展开。

回顾一下探索步骤:

  • 翻阅参考资料,理解 contributes.languages 配置的作用
  • 打开对应入口文件,猜测各个配置项的作用
  • 继续翻阅参考资料,或者修改配置,验证猜想
探索 contributes.grammars 配置

contributes.grammars 项包含诸多指向到 ./syntaxes/vue-xxx.json 的配置信息,形如:

{
    "contributes": {
      "grammars": [
        {
          "language": "vue",
          "scopeName": "source.vue",
          "path": "./syntaxes/vue-generated.json",
          "embeddedLanguages": {
            "text.html.basic": "html",
            // ...
          }
        },
        {
          "language": "vue-postcss",
          "scopeName": "source.css.postcss",
          "path": "./syntaxes/vue-postcss.json"
        }
        // ...
      ]
    }
  }
  

同样的,我们先查一下官网对 [contributes.grammars](https://code.visualstudio.com/api/references/contribution-points#contributes.grammars) 配置项的解释:

Contribute a TextMate grammar to a language. You must provide the language this grammar applies to, the TextMate scopeName for the grammar and the file path.

这段描述略微复杂,大意是开发者可以通过 grammars 属性提供关于语言的 TextMate 形式的语法描述,grammars 配置项包含三个属性:

  • language:语言的名称
  • scopeName:语言的分类,与 TextMate scopeName 同义,可用于嵌套语法定义
  • path:语言的词法规则文件

这里面 path 属性指向一个内容更复杂的配置文件 ./syntaxes/vue-xxx.json,我们可以接着打开其中任意一个文件,关键内容结构如下:

{
    "name": "Vue HTML",
    "scopeName": "text.html.vue-html",
    "fileTypes": [],
    "uuid": "ca2e4260-5d62-45bf-8cf1-d8b5cc19c8f8",
    "patterns": [
        // ...
        {
            "name": "meta.tag.any.html",
            "begin": "(<)([A-Z][a-zA-Z0-9:-]*)(?=[^>]*></\\2>)",
            "beginCaptures": {
                "1": {
                    "name": "punctuation.definition.tag.begin.html"
                },
                "2": {
                    "name": "support.class.component.html"
                }
            }
        }
    ],
    "repository": {
        // ...
    }
}

按照 Syntax Highlight Guide(zjsms.com/e7E5Jdq/\) 一节的说法这里面最重要的是 patterns 属性,而 patterns 属性最关键的功能就是以正则语句表达语言的词法分析规则,并分配词法对应的 name 命名,详细的配置规则还可以继续参考 TextMate 官网,这里大致理解作用即可,先不展开深究。

探索 main 配置

接着往下看,第三个值得关注的是 main 属性,在 vetur 中对应的值为:

"main": "./dist/vueMain.js"

VS Code 官网main 属性的解释非常精简:The entry point to your extension,也就是插件的入口,通常需要指向到可执行的 JS 文件,插件启动时 VS Code 会执行这个入口文件导出的 activate 方法,内容框架大致为:

import vscode from 'vscode';

export async function activate(context: vscode.ExtensionContext) {
    // ... 启动逻辑
}

在 Vetur 中,activate 函数定义在 client/vueMain.ts 文件,分析源码可知该函数主要完成如下事项:

  • 调用 registerXXXCommands 方法注册一系列命令
  • 调用 initializeLanguageClient 方法初始化 LSP Client 对象

这两个操作具体的作用,我们先按下不表,后面再展开。

小结

对入口的分析就到这里了,我们先总结、记录下关键信息:

  • Vetur 本质上是一个 VS Code 插件,所有配置 —— 包括入口都记录在 package.json 文件中
  • Vetur 包含三种启动入口:
    • contributes.languages: 定义一些简单的语言基本配置,包括怎么折叠,怎么注释
    • contributes.grammars:定义了一套基于 TextMate 引擎的词法规则,用于实现代码高亮
    • main:定义了插件的启动入口,入口中注册了一系列命令,同时创建了基于 LSP 协议的 Language Client 对象,而 LSP 协议用于实现如代码补全、错误诊断、跳转定义等高级特性

到这里,虽然我们还是不了解 Vetur 的实现细节,但是对 Vetur 的背景知识与项目结构应该已经有了一个比较基础的认知,已经能大致识别哪些功能由哪些模块实现。

OK,这里先保持好这个模模糊糊的认知就行了,不要花太多时间。

基础依赖分析

接下来,需要梳理一下 Vetur 的基础依赖,这一步的作用是帮助我们理解 Vetur 可能用到哪些基础技术,比如用到哪些工程化工具、怎么编译、怎么检查代码等。

Vetur 的 package.json 文件主要包含三类信息:

  • VS Code 插件配置信息,大体上在上一节都有描述,这里不展开
  • 工程化命令,核心有:
    • watch:对应命令为 rollup -c rollup.config.js -w ,由此可以推断 Vetur 基于 Rollup 实现构建
    • compile:功能与 watch 相似
    • lint:对应命令为 tslint -c tslint.json **.ts ,由此可以推断 Vetur 基于 tslint 实现代码检查
  • 项目的 devDependencies 依赖,主要包含 typescript、tslint、rollup、vscode-languageclient、husky、mocha、vscode-test、prettier

那么,从这些信息我们基本可以推断出如下信息:

  • Vetur 使用 Rollup + typescript 等工具执行构建工作,按常理执行 yarn watch 命令应该就能启动一个持续的构建工作进程
  • Vetur 使用 tslint 实现代码检查,配合 huscky + prettier 完成格式化工作
  • Vetur 使用 mocha + vscode-test 实现自动化测试

文件结构

接着,还需要稍微展开看看 Vetur 的文件结构,这一步能够一定程度上帮助我们理解 Vetur 的代码架构及要素,推测各种特性是在什么位置实现的。Vetur 的文件结构大致上如下:

vetur
├─ .vscode
│  ├─ ...
├─ build
│  ├─ ...
├─ client
│  ├─ client.ts
│  ├─ commands
│  │  ├─ ...
│  ├─ grammar.ts
│  ├─ ...
├─ languages
│  ├─ vue-html-language-configuration.json
│  ├─ ...
├─ scripts
│  ├─ build_grammar.ts
│  └─ tsconfig.json
├─ server
│  ├─ .gitignore
│  ├─ .mocharc.yml
│  ├─ .npmrc
│  ├─ bin
│  │  └─ vls
│  ├─ package.json
│  ├─ rollup.config.js
│  ├─ src
│  │  ├─ ...
├─ syntaxes
│  ├─ markdown-vue.json
│  ├─ pug
│  │  ├─ ...
│  ├─ ...
│  └─ vue.yaml
├─ test
│  ├─ ...
├─ vti
│  ├─ README.md
│  ├─ bin
│  │  └─ vti
│  ├─ package.json
│  ├─ rollup.config.js
│  ├─ src
│  │  ├─ ...
│  ├─ tsconfig.json
│  └─ yarn.lock
├─ tsconfig.options.json
├─ package.json
├─ ...
└─ yarn.lock

其中,比较关键的有:

  • client:VS Code 插件的入口代码,package.json 文件中 main 字段会指向这个目录的产物
  • server:LSP 架构中的 Server 端,上述 client 会通过 LSP 协议与这个 server 目录通信
  • syntaxes: Vetur 的词法规则文件夹,内部包含许多 JSON 格式,符合 TextMate 规则的词法声明
  • languages:Vetur 提供的语言配置信息,规则比较简单,了解作用即可,不必深入
  • vti:按 vti/bin/vti 文件可以推断,这里是 Vetur 的命令行工具,不在主流程内可以先忽略
  • docs:按内容可以推断这是 Vetur 的介绍文档,此处可忽略
  • build:构建命令,package.json 文件的 script 命令有一些会指向这个目录,可以忽略
  • 一系列基础配置文件,包括 tsconfig.jsonpackage.json 等,可先忽略

我们还可以继续往下探索各个子目录的内容,但是注意浅尝辄止即可,后面随着源码阅读的深入,读者对各个目录的理解应该会不断迭代增长,现在没必要花太多时间。

小结

回顾一下,我们首先学习了一些背景知识,之后花了一些时间分析项目的入口、基础依赖、文件结构,到这里我们基本上可以推断出:

  • Vetur 是一个语言插件,所以必然是使用 词法高亮、Language API、Language Server Protocol 三类技术实现核心逻辑的,而 package.json 文件中的 contributes 配置项的内容也恰好验证了这一点
  • 词法高亮 相关的代码集中在 syntaxes 文件夹
  • Language Server Protocol 相关的代码集中在 clientserver 文件夹
  • 可以用 yarn watch 命令持续构建,配合 F5 快捷键启动调试

这些信息是后续分析源码的必要条件,而这个过程跟学习一门新语言很类似,读者可以回想一下最开始学习 JavaScript 的时候,有经验的学习者不会一上来马上深入诸如原型、变量提升、事件循环等语言细节,而是先以更高层、更抽象的视角学习 JavaScript 语言的基本骨架,包括函数、循环语句、分支判断语句、对象等,从而构建起一个抽象的结构化认知,后续再慢慢填充细节,有点自顶向下的味道。

设定切入点

在对项目背景与结构有基本了解之后,我们可以正式开始分析源码了。首先,读者要找到一个匹配自身状态和需求的切入点,本质上就是将大目标拆解成一系列小目标,将大问题拆解成一系列更具体的小问题,然后带着具体问题更聚焦地去看代码。

所谓切入点可以直接对标到框架的具体功能,或者某些底层机制的实现上,以 Vetur 为例,它实现了诸多辅助开发 Vue SFC 组件的特性,包括代码补全、错误诊断、代码高亮、跳转到定义、hover 提示等等,这里面任意一个展开来都有大量可以挖掘的空间,如果从一开始就漫无目的瞎逛乱看那铁定是看不出个所以然的,鉴于我的目标就是想通过 Vetur 学习 VS Code 插件的开发套路,所以选择了一个看起来比较简单的特性:代码补全 作为第一个切入点,后续的学习经历证明这是一个非常合适的点,不复杂但是已经能帮我窥见 Vetur 的核心工作机制,以此类推后面分析其它高级特性如代码高亮、代码补全等,基本上就是很轻车熟路的状态了。

如果你有一些更明确的目的,比如解决某个具体的 bug,那你应该会更容易 get 到当下最需要做的事情;如果始终抓不到要点,那么建议先回到前面“了解背景知识”或“理解项目结构”的步骤,继续探索一些上下文信息,再试试问自己:我接下来到底应该先了解哪些具体功能的实现逻辑?

记住,这并不是一锤子买卖,如果你在后续的分析过程中发现这个切入点变得越来越复杂,超出最开始的预期,不要有心理负担,这再正常不过了,而且反而侧面表现出你对问题域有越来越少的理解了,可以回过头来重新调整目标,找一个更小的切入点。

善用搜索引擎

定下切入点后,首先要做的不是打开代码咔咔就干,而应该首先试试在社区搜索相关的资料,毕竟自媒体时代了,很多开源框架的知识已经被无数人吃透、捏碎、重组成各种维度的文章,顺着这些文章的思路去理解源码会比完全靠自己摸索效率高很多。

列举几种我常用的搜索渠道:

  • 谷歌 and 百度一类的搜索引擎,体感上谷歌的搜索质量会好很多,不过有一定的英语门槛
  • 开源项目的官网、社区、wiki、github 等官方渠道,通常都会有比较不错的资料
  • Segmentfault、知乎、掘金、公众号等垂直社区
  • 国外的 Medium/StackOverflow 社区,质量极高,很多大佬在上面活跃

假如搜了一通找不到答案,可以试试不同的关键词组合,我经常用的关键词有:

  • Xxx 源码解析
  • Xxx 原理
  • 如何实现 xxx

假如还是找不到,还可以试试换一个意思接近的关键词,绕点弯路。总之就是想尽办法找到有用的,适合当下问题的信息,帮助读者更快更平滑地深入研究源码,这一步对新手尤为重要。

顺便推销一下我的公众号:【Tecvan】,长期聚焦各类前端框架源码的研读解析,欢迎订阅。

分析关键流程

前面说了一大通,到这里终于要开始正儿八经地深入研究代码了。

其实源码分析的过程特别像侦探电影,最开始你需要面对一堆凌乱,看起来相关又不太相关的线索,侦探需要从这千头万绪中找出唯一事实答案,这个过程通常有两种行之有效的做法,一是自顶向下,从时间线、流程的角度出发归纳出大致的事件框架,之后再深入研究细节,由抽象到具体;一是自底向上,找到疑点再往上逐层推敲,梳理出事件的全貌,由具体到抽象。

两种方式各有适用场景,如果出于学习目的,就是想了解某些功能特性的实现原理的话,就应该自定向下,从应用入口出发逐步向下跟踪梳理出执行流程,理解大框架之后深挖具体细节;而如果是追查单一 bug 的时候就应该找到出问题的地方,自底向上追溯出全貌再加以更改。

我个人会更倾向于自顶向下的方式,例如我在学习 Vetur 的时候,首先是选定了 代码补全 这一类功能作为切入点,之后从 server/main.ts 开始一路沿着主流程向下逐级探索,最终到达实际执行代码补全的位置,虽然实际学习过程没有现在说的这么顺利,但最终还是慢慢推导出了这样一个流程图:

这个流程图非常重要,它基本上让我理透 Vetur 的两个重要阶段:

  • 启动阶段,vls 类型会初始化化 projectService 对象,之后再监听各类 LSP 事件
  • 执行阶段,LSP 事件触发时,vls 会将事件直接委托给 projectService 对象处理,而 projectService 会做两件事情:
    • 针对 SFC 文件做 region 切割,解析出 templatescriptstyle 等区块
    • 针对不同区块,调用 modes/xxx 对象的 doComplete 函数处理

基于这个流程图,逻辑上可以推断出:所有 LSP 请求最终都会按照代码的类型流转到相应的 **modes** 文件夹上,例如:

  • 对于 template 的格式化请求,最终会流转到 modes/template/index.ts 文件的 format 函数做处理
  • 对于 style 的格式化请求,则流转到 modes/style/index.ts 文件的 format 函数
  • 同理可以推导出包括代码补全、hover 提示、跳转到定义、错误诊断等等高级特性上

这个发现让整个 Vetur 的代码架构变得很扁平,后续研究具体特性的时候可以跳过前面这一对 LSP 请求的处理、分割步骤,直接找到对应的 modes/xxx/index.ts 代码。

那么,如何分析代码的执行流程呢?我个人总结的方法有两个:静态猜想 + 动态验证,放在下一节细讲。

局部深入

经历前面一系列步骤,储备了足够的背景知识与框架认知后,我们可以开始逐行分析源码,了解每一行、每一个变量、每一个函数、每一个模块的具体作用与实现了。接下来我会介绍两种行之有效的方法论:

  • 静态猜想:“读”源码,从面上理解代码逻辑并作出猜想
  • 动态验证:“运行”源码,借用 debug 工具逐行跟踪代码执行过程,必要时可以改动原有代码,验证猜想

这两种方法并不泾渭分明,通常是一边看代码,一边做推测,有疑点马上运行起来验证猜想是否正确,灵活配合使用效果更佳。

静态分析 —— 做猜想

所谓的静态分析,说白了就是逐行逐句分析代码,研究每一个变量、每一个过程的作用,是一个特别吃基础知识和信息检索能力的苦力活。虽然每个框架的实现细节不一样,但还是有一些普适的技巧可以讨论一下:

  • 函数层面,关注输入输出及副作用:
    • 函数接受什么结构的参数,这些参数经过函数内部的每一条语句之后会发生什么变化,或者如何影响语句的执行
    • 函数执行完毕之后,会返回什么结构的结果,这些结果下一步会被谁消费,影响谁的执行逻辑
    • 特别的,有不少库的函数实现有明显的“副作用”,不是那么“纯”,包括 Webpack、Vetur、Eslint 等 —— 这会急剧提升理解成本,所以阅读的时候多留个心眼
  • 分支语句中,优先关注主流程,分支流程很容易增加心智负担,到后面就不认得谁是谁了
  • 对于循环语句,通常可以关注循环之前的状态与之后的状态,通过这些变化推断循环的作用
  • 对于变量与子函数,根据命名推断作用,通常不必过度细究
  • 跳过参数校验、错误处理等分支逻辑,抓主流程!抓重点!
  • 谨记你要研究的切入点,遇到特别复杂的子模块,先大致理解功能,点到为止,记下这个硬骨头回头再作为一个新的切入点继续研究
  • 学点常用的设计模式,工厂、装饰器、代理等等,这些模式的使用率非常高

结合这些技巧,在分析过程中读者应该还是会遇到很多推测和问题:这个函数是干什么的;这个语句太复杂了,看不懂;这个循环太多 side effect 了,捋不出重点。有问题是好事,证明你开始能看出端倪了,这个时候就需要将框架运行起来,并且逐步、动态地观察代码的流转,验证你的猜想或者问题。

动态分析 —— 验证猜想

经历前面静态阅读代码后,相信读者已经有一些对代码逻辑的基本推断与问题,接下来就需要运行框架,在任何有疑问的地方添加断点,观察执行栈、参数变化、环境变化、逻辑分支语句,确定输入参数是如何确定的,输出结果又会被谁消费。

不过,要修改、运行起一个开源框架可能并不容易,通常需要关注三个点:

  • 如果框架已经接入了一些工程化工具,需要弄清楚如何将源码编译为运行产物,例如 Vetur 项目接入了 tsc + rollup,对应的命令为 yarn watch/compile
  • 如何启动调试模式,例如 Vetur 场景下需要借用 VS Code 的 .vscode/launch.json 配置文件 + F5 命令启动调试;而对于前端框架如 Vue、React,通常打开浏览器的 DevTool 面板即可
  • 如何插入调试语句,前端或 Node 场景下通常添加 debugger; 语句即可

如果一段代码你运行不起来,那么你大概率是无法掌握它的,所以我才会在前面“了解背景知识”一节特意强调需要了解项目的入口、启动、调试方法,而一旦掌握了调试技能,那这份代码在你面前就相当于脱掉了所有外装,可以逐行、逐句观察代码逻辑的动态流转。

及时总结

好记性永远不如烂笔头,前面花了这么多时间不断探索、验证,如果没有及时做笔记,那这大概率会是一场无用功;而如果没有及时做总结,那大概率无法将信息内化为你脑子里面的知识!

当然了,也没必要把这事看得太难,总结的目的是对已经掌握的零碎信息做一次抽象,撇除部分细节,进而整合、梳理成体系化的知识,重点在于内化成自己的知识而不是具体形式形态!所以不要完美注意,不要纠结把语句写的更漂亮,把图画的更好看,在确保基本逻辑通顺的前提下怎么快怎么来。

我个人的习惯是针对一个主题会单开一个自己可见的飞书文档,阅读过程不断记笔记 —— 都是一些没有任何修饰的大白话,一旦自我感觉到达一个节点就马上做一次总结,将代码流程归类为 N 个步骤 —— 尽量控制在 10 个以内,每个步骤写清楚逻辑和输出,条件允许的话还会同步画一些粗糙的流程图、时序图、状态机等。最后,攒到一定量级后我还会更进一步地,输出对外的文章或 PPT,作为对自己学习成果的验收。

下一个切入点

人类所知的物质,或人类创造的产品中,没有一样是绝对简单的,优秀的开源项目通常是非常复杂的组合体,如果仅仅停留在“怎样实现某个具体功能”,那远还没有掌握精髓,你还需要继续探索,分析它“怎样实现、组合多个单体功能”——但不是各自为政,互不相干的“多个”,而是互相融合成有机整体的“多个”。

所以在理解某个切面后,我们可以继续沿用上面的分析步骤,循环敲定下一个切入点,可以是你之前遗留下来的复杂问题,或者某个新的功能特性,沿着这些碎片顺藤摸瓜,逐步梳理成一套比较体系化的认知。

放在 Vetur 语境下,当时经过第一遍分析之后我基本上就掌握了 Vetur 的架构、核心流程及 代码补全 的实现细节,了解到“所有 LSP 请求最终都会按照代码的类型流转到相应的 **modes** 文件夹上”这一基本规则。接下来我想继续挖掘其它特性的实现原理,包括错误诊断、跳转定义、智能提示等,于是重新设定切入点,重新跑一边搜索、流程分析、局部深入,循环往复并最终总结出一系列知识点,汇总组织成在线分享:《如何开发一款 VS Code 语言插件 —— 以 Vetur 为例》,人生第一次直播。

最佳实践

再聊聊我个人比较认可的最佳实践吧:

  • 设定好具体、可衡量的目标,不要为了学习而学习,如果有切实的强诉求,那就别由于彷徨,马上去做
  • 磨刀不误砍柴工,不要上来就对着源码疯狂输出,一定要花点时间站在高层视角去看框架的背景和生态
  • 抓大放小,忽略哪些还不熟悉的概念、语句、工具、分支逻辑,你要认识到复杂事物的学习模型往往螺旋上升,逐步深入的,不可能过一遍就能掌握所有细节和精髓,如果一开始就过度关注细节,通常会让整个学习周期拉到无限长。要弄清楚啥时候,什么情况下应该忽略细节,什么时候应该抓住不放 —— 这与你的目标和切入点有很大的关系
  • 随时笔记:一旦有任何新发现、新问题,做好笔记,记录下来,这些都会成为继续探索的重要线索
  • 随时总结:
    • 笔记记录当下的、零碎的发现,总结则将这些线索串联形成知识点。
    • 总结过程你会发现更多认知漏洞,提出更多问题,可以反过来继续挖掘
    • 好记性不如烂笔头,探索的结果落到纸面上才会真正成为你自己的东西,极端一点看,没有形成输出的学习过程往往会随着时间的流逝,变成徒劳

学习毕竟很个人的事情,上面提到的这些方法、技巧、原则对我个人特别受用但实际效果必然还是因人而异的,如果读者执行过程中感觉特别膈应特别难受,那完全可以适当微调,慢慢找出更适合自己的方式方法。