你还记得自己第一次深入挖掘常用的库或框架的源代码时的情景吗?对我而言,那一刻是我三年前作为前端开发人员的第一份工作。

我们刚刚完成了用于创建在线课程的内部遗留框架的重写。在开始重写时,我们花时间研究了许多不同的解决方案,包括 Mithril、Inferno、Angular、React、Aurelia、Vue 和 Polymer。因为我是一个萌新(我刚从新闻转向网络开发),我记得每个框架的复杂性都让人感到害怕,而且不理解框架的工作方式。

当我开始更深入地研究我们选择的 Mithril 框架时,我的能力增长了。从那以后,我对 JavaScript 的了解以及一般的编程方式得到了很大的提高,我花了很多时间深入研究每天在工作种或在自己的项目中使用的库。在本文中,我将分享一些分析库或框架的方法。

Mithril 的超文本功能的源代码

通过 Mithril 的 hyperscript 功能介绍如何去阅读源代码。

阅读源代码的好处

阅读源代码的好处之一是可以使你学到更多的东西。当我第一次看到 Mithril 的代码库时,对虚拟 DOM 的含义只有一个模糊的概念。当我读完时,就知道了虚拟 DOM 是一种技术,它涉及创建描述用户界面的对象树应该是什么样的。然后使用 DOM API(例如 document.createElement)将该树转换为 DOM 元素。通过创建描述用户界面未来状态的新树,然后将其与旧树中的对象进行比较来执行更新。

之前我已经在各种文章和教程中读到过这些内容,虽然很有帮助,但是在程序的上下文中能够观察它对我来说是非常有启发性的。它还告诉我在比较不同的框架时要问哪些问题。例如我现在不是去查看 GitHub 上的 star 数量,而是会问“每个框架执行更新的方式如何影响性能和用户体验?”这样的问题。

另一个好处是增加你对良好应用架构的理解。虽然大多数开源项目通常与其存储库遵循相同的结构,但每个项目都包含差异。Mithril 的结构非常扁平,如果你熟悉它的 API,可以对文件夹中的代码进行有根据的猜测,比如renderrouterrequest 等。另一方面,React 的结构也反映了它的新架构。维护者将负责 UI 更新的模块(react-reconciler)与负责渲染 DOM 元素的模块(react-dom)分开。

这样做的好处之一是,开发人员现在可以通过 hook 到 react-reconciler 包来编写自己的自定义渲染器。我最近研究过的模块捆绑包 Parcel 也有像 React 这样的 packages 文件夹。密钥模块名为 parcel-bundler,它包含负责创建捆绑包、热启动模块服务器和命令行工具的代码。

解释 Object.prototype.toString 如何工作的 JavaScript 规范部分

不久之后,你正在阅读的源代码将引导你进入 JavaScript 规范。

另一个令我感到惊讶的好处是:你可以更轻松地阅读官方 JavaScript 规范,该规范定义了语言的工作方式。我第一次阅读规范的时候是在分析 throw Errorthrow new Error 之间的区别。之所以要分析这个,是因为我注意到 Mithril 在其 m 函数的实现中使用了 throw Error,我想知道这样是不是比 throw new Error 更好。从那以后,我也学会了逻辑运算符 &&|| 不一定返回布尔值,找到了控制 == 等式运算符如何强制赋值的规则Object.prototype.toString.call({})返回 '[object Object]'原因

阅读源代码的技巧

有很多方法可以处理源代码。我发现最简单的方法是,从你选择的库中挑选一种方法,并去记录调用它时会发生什么。不是去记录每一步,而是要尝试确定其整体流程和结构。

我最近用这种方法阅读了 ReactDOM.render 的代码 ,因此学到了很多关于 React Fibre 及其实现背后的一些原理。值得庆幸的是,由于 React 是一个流行的框架,我在同一个问题上看到过很多其他开发人员撰写的文章,这也加快了这个过程。

这深入探讨并向我介绍了co-operative schedulingwindow.requestIdleCallback 方法和真实的链表的示例(React 通过把更新放入队列来处理更新,这是优先更新的链表)。执行此类操作时,建议用库创建一个非常基本的程序。这可以使得调试时更容易,因为你不用去处理由其他库引起的栈跟踪信息。

如果没有对代码进行深入研究,我会正在处理的项目中打开 /node_modules 文件夹,或者转到 GitHub 存储库。当我遇到错误或有趣的功能时,通常会发生这种情况。在 GitHub 上阅读代码时,请确保你正在阅读最新版本。你可以通过单击用于更改分支的按钮,并选择 “tags” 来查看带有最新版本标记的代码。库和框架永远在持续更新,所以你不希望把精力花费在下一版本中可能会删除的内容。

还有另一种阅读源代码的方式,我喜欢称之为“粗略一瞥”,这种方法并不那么简单。在我刚刚开始阅读代码的时候安装了 *express.js*,我打开了它的 /node_modules 文件夹并浏览了它的依赖项。如果 README 没有给我一个满意的解释,我就会阅读源代码。这样做让我得到了一些有趣的发现:

  • Express 依赖两个模块,这两个模块都可以合并对象,但是合并方式的差异很大。 merge-descriptors 只添加在源对象上直接找到的属性,它还合并了不可枚举的属性,而 utils-merge 只迭代对象的可枚举属性以及在其原型链中找到的属性。 merge-descriptors 使用 Object.getOwnPropertyNames()Object.getOwnPropertyDescriptor(),而 utils-merge 使用了 for..in;
  • setprototypeof 模块提供了一种跨平台设置实例化对象原型的方式;
  • escape-html 是一个有 78 行代码的模块,用于转义字符串内容,因此可以用它在 HTML 内容中进行插值。

虽然阅读源代码的结果不太可能立即就能用得上,但是能够使你对自己使用的库或框架的依赖关系有一个大致的了解,这是非常有用的。

在调试前端代码时,浏览器的调试工具是你最好的朋友。除此之外,它们允许你随时暂停程序并检查其状态、跳过函数的执行、进入或退出程序。不过有时这不可能立即做到,因为代码有可能已经被压缩过。我倾向于取消它们的通知,并将未经压缩的代码复制到 /node_modules 目录中的相关文件里。

ReactDOM.render function 的源代码

像其他程序一样进行调试。形成一个假设,然后进行测试。

案例研究:Redux的 Connect 函数

React-Redux 是一个用于管理 React 应用状态的库。在处理诸如此类的库时,我首先会搜索已经编写过有关其实现的文章。在这个案例研究中,我遇到了这篇文章(https://blog.isquaredsoftware.com/2018/11/react-redux-history-implementation)。这是阅读源代码的另一个好处。研究阶段通常会引导你去阅读这样的信息性文章,通常这些文章只会改善你自己的思路和理解。

connect 是一个 React-Redux 函数,它将 React 组件连接到应用程序的 Redux 存储。怎么样?好吧,根据官方文档的说明,它执行以下操作:

“…返回一个新的连接组件类,它将会包装你传入的组件。”

看完之后,我会问下列问题:

  • 我知不知道函数接受输入的那些模式或概念,然后返回包含其他功能的相同输入?
  • 如果我知道此类模式,又将如何根据文档中给出的解释实现此模式?

通常,下一步是创建一个使用 connect 的非常基本的示例程序。但是在这种情况下,我选择使用我们在 Limejump 上构建的新 React 程序,因为我想在程序的上下文中理解 connect,最终再进入生产环境。

我关注的组件看起来像这样:

class MarketContainer extends Component {
 // code omitted for brevity
}

const mapDispatchToProps = dispatch => {
 return {
   updateSummary: (summary, start, today) => dispatch(updateSummary(summary, start, today))
 }
}

export default connect(null, mapDispatchToProps)(MarketContainer);

它是一个容器组件,包裹着四个较小的连接组件。你在导出 connect 方法的文件中遇到的第一件事就是这个评论:connect 是 connectAdvanced 的外观。这时我们就有了第一个学习的点:有机会观察外观设计模式。在文件的末尾,我们看到 connect 导出了一个名为 createConnect 的函数的调用。它的参数是一堆默认值,它们已被解构,如下所示:

export function createConnect({
 connectHOC = connectAdvanced,
 mapStateToPropsFactories = defaultMapStateToPropsFactories,
 mapDispatchToPropsFactories = defaultMapDispatchToPropsFactories,
 mergePropsFactories = defaultMergePropsFactories,
 selectorFactory = defaultSelectorFactory
} = {})

同样,我们遇到了另一个学习的点:导出调用函数解构默认函数参数。解构部分是一个学习的点,因为代码编写如下:

export function createConnect({
 connectHOC = connectAdvanced,
 mapStateToPropsFactories = defaultMapStateToPropsFactories,
 mapDispatchToPropsFactories = defaultMapDispatchToPropsFactories,
 mergePropsFactories = defaultMergePropsFactories,
 selectorFactory = defaultSelectorFactory
})

它会导致这个错误 Uncaught TypeError:无法解析 undefinednull 的属性 connectHOC。这是因为该函数没有默认参数可以依赖。

注意有关此内容的更多信息,请阅读 David Walsh 的文章(https://davidwalsh.name/destructuring-function-arguments)。根据你对语言的了解,一些学习的点可能看起来微不足道,所以最好专注于你以前从未见到过的或需要了解更多信息的内容。

createConnect 本身在其函数体中没有任何功能。它返回一个名为 connect 的函数,我在代码里使用的函数:

export default connect(null, mapDispatchToProps)(MarketContainer)

它需要四个参数,都是可选的,前三个参数根据参数是否存在及其值类型来定义它们的行为,这是通过 match 函数来实现的。现在因为提供给 match 的第二个参数是导入 connect 的三个函数之一,我必须决定应该遵循哪个线程。

在这里学习的重点是:如果这些参数是函数,用于将第一个参数包装为 connect代理函数isPlainObject 用于检查普通对象或 warning 模块,它揭示了如何将调试器设置为中断所有异常。在匹配函数之后,我们来到 connectHOC,这个函数接受我们的 React 组件并将它连接到 Redux。它是另一个函数调用,返回 wrapWithConnect,实际上它用来处理将组件连接到 store 的函数。

看一看 connectHOC 的实现,我可以理解为什么它需要 connect 来隐藏它的实现细节。它是 React-Redux 的核心,其中包含不需要通过 connect 公开的逻辑。我将结束这里的深度探讨,如果我继续的话,将是查阅我之前发现的参考资料的最佳时机,因为它包含了对代码库的非常详细的解释。

总结

刚开始阅读源代码时很困难,但与所有的事情一样,随着时间的推移会变得更容易。我们的目标不是理解一切,而是要获得不同的思路和新知识。关键是要对整个过程进行深思熟虑,并对所有事物充满好奇心。

例如,我发现 isPlainObject 函数很有趣,因为它用 if (typeof obj !== 'object' || obj === null) return false 来确保给定的参数是普通对象。当我第一次阅读它的代码实现时,想知道为什么它没有用Object.prototype.toString.call(opts)!=='[object Object]',这可以用更少的代码来区分对象和对象子类型,例如 Date 对象。但是阅读下一行就会发现,当开发人员在使用 connect 返回 Date 对象的极不可能的事件中,将由 Object.getPrototypeOf(obj)=== null 检查来进行处理。

isPlainObject 的另一个吸引人的地方是这段代码:

while (Object.getPrototypeOf(baseProto) !== null) {
    baseProto = Object.getPrototypeOf(baseProto)
}

谷歌搜索引导我找到这个 StackOverflow 帖子Redux issue,它们解释了该代码如何进行处理的案例,例如检查源自 iFrame 的对象。

有用的链接