[译] 设计大型 JavaScript 应用程序

1,891 阅读26分钟

这是我在 JavaScript 澳大利亚开发者大会(JSConf AU)上演讲内容的文字编辑记录。在 YouTube 上观看整个演讲视频

幻灯片文本:你好,我曾经构建过非常大型的 JavaScript 应用。

你好,我曾经构建过非常大型的 JavaScript 应用。我不再那么做了,所以我认为现在是个好时机来回顾并分享我学到的东西。昨天我在会议聚会上喝啤酒时,有人问我:“嘿,马尔特,究竟是什么赋予了你权利和权威,来讲这个话题?”我想这个问题的答案实际上就是这个演讲的主题,尽管我通常觉得谈论自己有点奇怪。大概是因为,我在谷歌构建了这样一个 JavaScript 框架。它被 Google 照片,Google 协作平台,Google+,Google 云端硬盘,Google Play,搜索引擎,所有这些网站使用。其中一些项目非常大,你可能已经使用了其中的一些。

幻灯片文本:我认为 React 很好。

这个 Javascript 框架不是开源的。它不是开源的原因是它与 React 同时出现,我想“世界是否真的需要另一个 JS 框架来做选择?”。谷歌已经拥有了一些 JS 框架,Angular 和 Polymer,并且我觉得再有一个会让人们感到困惑,所以我只是认为我们应该把它留给我们自己。但除了不是开源的,我认为还是有很多东西可以从中学习,值得分享我们一路上学到的东西。

一张人山人海的图片.

所以,我们来谈谈非常大型的应用,以及他们之间的共同点。当然可能会有很多开发者参与其中。可能有几十人甚至更多,他们都有自己的情感和人际问题,你必须要考虑到这一点。

一张非常古老建筑的图片.

即使你的规模不大,也许你已经在这个领域工作了一段时间,也许你甚至不是第一个维护它的人,你可能不了解项目的所有结构或者内容,可能有些东西是你不太明白的,你的团队中可能还有其他人不了解应用程序的所有信息。这些都是我们在构建非常大型的应用程序时,必须考虑的事情。

推特: 一个没有初级工程师的高级工程师团队是一个工程师团队。

我想在这里做的另一件事是以我们的职业生涯说明下背景。我想我们很多人会认为自己是高级工程师。或者是还差一点点,但我们想成为一个高级工程师。我认为高级的意思是我几乎可以解决其他人可能抛出的任何问题。我熟悉我的工具,我熟悉我的领域。而这项工作的另一个重要部分是我让初级工程师最终成为高级工程师。

幻灯片文本:初级 -> 高级 -> ?

但是会发生什么呢?在某种程度上,我们可能会怀疑“下一步可能是什么?”。当我们达到这个高级阶段时,我们接下来要做什么?对于我们中的一些人来说,答案可能是做管理,但我认为这不应该成为每个人的答案,因为不是每个人都应该成为管理者,对吗?我们中有些人是非常优秀的工程师,为什么我们不应该在我们的余生中也这样做?

幻灯片文本:“我知道我会如何解决问题”

我想提出一种方法来升级到高级水平。我把自己当作高级工程师的方式是,我会说:“我知道如何解决这个问题”,并且因为我知道如何解决这个问题,所以我也可以教别人去解决它。

幻灯片文本:“我知道别人怎么解决这个问题”

我的理论是,下一个层次是我可以对自己说:“我知道别人会如何解决这个问题”。

幻灯片文本:“我可以预测 API 选择和抽象如何影响其他人解决问题的方式。”

让我们更具体一点。你说了这样一句话:“我可以预见我做出 API 选择时,或者我往项目中引入抽象时,它们如何影响其他人解决问题。”我认为这是一个强大的概念,可以让我思考我所做的选择对应用程序的影响。

幻灯片文本:同理心的应用。

我会称之为同理心的应用。你在和其他软件工程师一起思考,你在思考你所做的事情以及你给他们的 API 是怎么样的,以及它们如何影响其他工程师编写软件。

幻灯片文本:对简易模式感同身受。

幸运的是,这是对简易模式感同身受。同理心通常很难,而且这仍然非常困难。但至少与你有同感的人,他们也是软件工程师。尽管他们可能与你截然不同,但他们至少同你一样也在开发软件。当你获得更多的经验时,你可以很擅长的运用这种类型的同理心。

幻灯片文本:编程模型。

考虑到这些话题,我想谈谈一个非常重要的术语,那就是编程模型,这个词我会用很多次。它代表“给定一套 API,库,框架或工具,人们如何在这种背景下编写软件”。我演讲的真正内容是关于 API 等细微变化对编程模型的影响。

幻灯片文本:影响编程模型的示例:React,Preact,Redux,Date picker,npm。

我想举几个影响编程模型的例子:假设你有一个 Angular 项目,并且你说:“我将把它移植到 React 中”,这显然会改变人们编写软件的方式,对吧?但是接下来你想:“啊哈,60 KB 就为了使用一点虚拟 DOM 操作,让我们切换到 Preact”,这是一个 与 React API 兼容的库,即使你做出了这个选择,它也不会改变人们编写软件的方式。或许随着项目的进展,你会觉得“单单 React 自有的状态管理还不够,应用会变得很复杂,我应该有一些东西来管理应用状态,我会引入 Redux”,这将改变人们编写软件的方式。然后又来了个新需求“我们需要一个日期选择器”,你到 npm 上进行搜索,有 500 个结果,你选了一个日期组件。你挑选哪一个真的很重要吗?它绝对不会改变你编写软件的方式。但是,npm 以及它的庞大生态集合,绝对会改变你编写软件的方式。当然,这些只是可能影响人们如何编写软件的几个例子。

幻灯片文本:代码分割.

现在我想谈谈所有大型 JavaScript 应用在将它们交付给用户时的一个共同点:它们最终变得非常大,以至于你不希望一开始就把整个应用一次性传输给用户。为此,我们引入了这种称为代码分割的技术。代码分割意味着你为应用程序定义了一组打包。所以,你会说“有些用户只使用我的应用程序的这一部分,有些用户使用另一部分”,因此,当用户实际使用应用程序时,只有使用到的部分才被下载执行。这是我们所有人都可以做到的。像许多事情一样,它是由闭包编译器实现的 —— 至少在 JavaScript 世界中。但我认为使用 webpack 进行代码分割是最流行的方式。如果你使用的是 RollupJS,这是超棒的,他们最近也增加了对代码分割的支持。代码分割绝对是你们应该做的事情,但是当你将它引入到应用程序中时有一些事情需要考虑,因为它确实对编程模型有影响。

幻灯片文本:同步 -> 异步。

你有过去是同步现在成为异步的东西。你的应用程序在没有代码分割时,简单美好。整个项目只有一件大事。它启动,然后它很稳定,你了解它的前世今生,你不必等待资源加载。有了代码分割后,有时候你可能会说“哦,我需要那个打包文件”,所以你现在需要利用网络来获取所需的文件,这也使得你必须考虑网络可能出现异常情况,所以应用程序也变得更加复杂。

幻灯片文本:人性化。

此外,我们需要有人介入,因为代码拆分需要你定义如何打包,需要你考虑何时加载它们,所以,那些在你们团队的工程师们现在必须决定哪些文件打包到一起,什么时候加载那些打包文件。每次有人介入时,都会明显影响编程模型,因为他们必须考虑这些问题。

幻灯片文本:基于路由的代码分割。

有一种非常成熟的方法可以解决这个问题,它可以将我们从进行代码分割的混乱中解脱出来,它被称作基于路由的代码分割。如果你还没有使用代码分割,那它可能是你初次进行代码分割的方式。路由将应用程序以 URL 粒度进行分割。例如,你的产品页面可能在 /product/ 上,并且你的分类页面可能在其他地方。你只需将每个路由用的文件打包到一起,然后你的应用程序将根据路由自动进行代码分割。无论何时用户访问路由,路由都会加载相关的打包文件,有了路由之后,你可以忘记代码分割的存在。再从编程模型上来看,这几乎与将所有东西都打包到一起一样。这是一种非常好的代码分割方法,绝对是个好的开始。

但是这个演讲的主题是设计非常大型的 JavaScript 应用程序,并且这类应用程序很快会变得巨大无比,路由本身也会随之变大,以至于基于路由的代码分割不再适用。实际上我有一个关于这类应用程序的好例子。

“public speaking 101”的谷歌搜索查询截图。

我正在弄清楚如何成为这场演讲的公众演讲者,并且我得到了一个很好的蓝色链接列表。你完全可以设想这个页面非常适合将所有文件打包到一个路由里。

“weath”的谷歌搜索查询截图。

但后来我对天气感到疑惑,因为加州有一个严峻的冬天,突然间有了这个完全不同的模块。所以,这个看似简单的路由比我们想象的更为复杂。

“20 usd to aud”的谷歌搜索查询截图。

后来我被邀请参加这次会议,我查看了 1 美元是多少澳元,那时出现了这个复杂的货币转换器。很显然,这些专用模块大约有 1000 多个,将它们放在同一个打包文件中是不可行的,因为打包文件的大小会有几兆字节,用户将会真的变得不高兴。

幻灯片文本:组件级别的懒加载?

所以,我们不能只使用基于路由的代码分割,我们必须想出一个不同的方式来做代码分割。基于路由的代码拆分很不错,因为你将应用程序进行了最粗略级别的拆分,而当应用程序进一步增长时,它能起到的作用就微乎其微了。因为我喜欢直截了当,那么做超级细粒度而不是超级粗粒度拆分怎么样。让我们想象如果我们网站的每一个组件都懒加载,会发生什么。当你只考虑带宽时,从效率的角度来看,这似乎非常好。从延迟等其他观点来看,这可能是非常糟糕的,但它肯定是值得考虑。

幻灯片文本:React 组件同他们的子组件是静态依赖关系。

但让我们想象一下,例如,你的应用程序使用 React。并且在 React 中,组件们同他们的子组件是静态依赖关系。这意味着如果你懒加载你的子组件,就会改变你的编程模型,并且事情会变得不那么美好,这让你只好叫停这种策略。

ES6 导入示例。

假设你有一个货币转换器组件,你想把它放在你的搜索页面上,你可以导入它,是这样的吧?这是在 ES6 模块中使用的普通方式。

Loadable 组件示例。

但是如果你想延迟加载它,你会得到这样的代码,你把它包装在 Loadable 组件中,你还使用一种懒加载 ES6 模块的新方式动态导入。当然有成千上万种方法可以做到这一点,我不是 React 专家,但所有这些方式都会改变你编写应用程序的方式。

幻灯片文本:静态 -> 动态。

事情不再那么美好了 —— 一些静态的东西现在变成了动态的,这是编程模型改变的另一个警示。

幻灯片文本:谁来决定何时对什么东西进行懒加载?

你不得已突然想知道:“谁来决定何时对什么东西进行懒加载”,因为这会影响到应用程序的等待时间。

幻灯片文本:静态还是动态?

人类再次出现,他们必须思考“有静态导入,有动态导入,什么时候该用哪一个?”。弄错就非常糟糕了,当一个静态导入的文件突然变成动态导入的时候,可能会把某些东西错误的打包进文件。随着时间的推移,同时你又有很多工程师在这个项目上开发,恐怕就会出错。

幻灯片文本:分割逻辑和渲染。

接下来我会分享 Google 如何做到保证良好编程模型的前提下,又有不错的性能的。我们通过渲染逻辑和应用逻辑来分割组件,比如当你按下货币转换器上的按钮时发生的情况。

幻灯片文本:仅在渲染时加载是唯一的加载逻辑。

所以,现在我们有两件独立的事情,并且我们只在渲染时才加载组件的应用程序逻辑。事实证明,这是一个非常简单的模型,因为你可以简单地在服务端渲染页面,然后由实际呈现的内容,触发下载关联的应用程序打包文件。因为加载是通过渲染自动触发的,这使得人得以脱离系统。

幻灯片文本:搜索结果页面上的货币转换器。

这个模型看起来不错,但它确实有一些折中。如果你知道通常服务端渲染在 React 或 Vue.js 等框架中如何工作,这个过程被称为 hydration。hydration 是这样的,你服务端渲染的一些东西,然后在客户端再次渲染它,这意味着你必须加载代码来渲染一些已经在页面上的东西,这在加载代码和执行代码方面都是巨大的浪费。这么做既浪费带宽,又浪费 CPU —— 但它确实很好,因为你在客户端忽略了服务端渲染的东西。我们在 Google 使用的方法不是那样的。所以,如果你设计这个非常大型的应用程序,你就会想:我是采用那种更复杂的超快速方法,还是采用效率较低的 hydration 方式,但这样能有个良好的编程模型?你将不得不做出这个决定。

幻灯片文本:2017 新年快乐。

我的下一个话题是我最喜欢的计算机科学问题 —— 它不是命名问题,尽管我很可能给它起了个糟糕的名字。这是“2017 年假期特别问题”。过去有人写过一些代码,现在不再需要它们了,但它仍然在你的代码库中?...这种情况时常发生,我认为 CSS 的问题尤为突出。你有一个大型 CSS 文件。里面有很多样式选择器。谁真的知道哪些样式选择器是否仍然对应着你应用中的内容?所以,你最终只能把那些代码留在那里。我认为 CSS 社区处于变革的最前沿,因为他们意识到这个问题,并且他们创建了诸如 CSS-in-JS 之类的解决方案。因为你的组件可以放到一个单独的文件里,2017 年假期特别问题组件,你可以说“它不再是 2017 问题”,你可以删除整个组件,并且所有相关文件一并消失。这使得删除代码非常容易。我认为这是一个非常好的想法,它不仅仅适用于 CSS。

幻灯片文本:不惜一切代价避免中央配置。

我想举几个例子,说明为什么你想不惜一切代价避免在你的应用程序中采用中央配置,因为中央配置(比如大型 CSS 文件)使得代码难以删除。

幻灯片文本:routes.js。

我之前在你的应用程序中谈论过路由。许多应用程序都会有一个类似“routes.js”的文件,其中包含所有路由信息,这些路由将自己映射到某个根组件。这是一个中央配置的例子,你不会希望在大型应用程序中这么做。因为有了这种中央配置,工程师会说:“我还需要那个根组件吗?我需要更新其他文件,那是其他团队负责的文件。我不确定是否被允许修改它。也许我该明天再做“。之后,这些文件只会越来越大。

幻灯片文本:webpack.config.js。

这种反模式的另一个例子是 webpack.config.js 文件,在这里你可以假设你通过它构建了整个应用程序。刚开始可能没什么问题,但随着时间的推移,这份配置不再适用,你需要知道其他团队在应用程序中做了什么,这样才能对配置文件做出兼容性的调整。再一次,我们需要一个模式来展现如何分散我们构建过程的配置。

幻灯片文本:package.json。

这有一个很好的例子:npm 使用的 package.json。每个软件包都会说“我有这些依赖关系,这就是你如何运行我,如何构建我的方式”。显然,对于所有的 npm,都不能有一个巨大的配置文件。这对于成千上万的文件来说不起作用。这肯定会让你在 git 操作中遇到很多合并冲突。当然,npm 非常大,但我认为我们的许多应用程序已经变得足够大,让我们不得不担心同样的问题,并且必须采用相同的模式。我没有所有的解决方案,但我认为 CSS-in-JS 的想法将会涉及我们应用程序的其他方面。

幻灯片文本:依赖关系树。

更抽象地说,我会描述这个想法,即我们负责如何抽象地设计我们的应用程序,如何组织它,作为承担塑造我们的应用程序的依赖树的责任。当我说“依赖”时,我的意思是非常抽象的。它可能是模块依赖关系,可能是数据依赖关系,服务依赖关系,还有很多不同的类型。

幻灯片文本:由路由和 3 个根组件构成的依赖关系树示例。

显然,我们都有超复杂的应用程序,但我会用一个非常简单的例子。它只有 4 个组成部分。它有一个路由,知道如何从应用程序的一个路由到下一个路由,它有几个根组件:A、B 和 C。

幻灯片文本:中心导入问题。

正如我之前提到的那样,这具有中心导入问题。

幻灯片文本:由路由和3个根组件构成的依赖关系树示例。路由导入根组件。

因为路由现在必须导入所有的根组件,如果你想删除其中的一个,你不得不进入路由文件,删除引用,删除路由,并最终你有了 2017 假期特别问题。

幻灯片文本:导入 -> 增强。

我们在谷歌已经为此提出了一个解决方案,我想向你们介绍一下,我想我们从来没有谈过这件事。我们提出了一个新概念。它被称为增强。这是你用来代替导入的东西。

幻灯片文本:导入 -> 增强。

实际上,这与导入是相反的。这是一个逆向依赖。如果你增强一个模块,你会让这个模块对你有依赖性。

幻灯片文本:由路由和3个根组件构成的依赖关系树示例。根组件增强了路由。

看看依赖关系图,它发生了什么,仍然是相同的组件,但箭头指向相反的方向。因此,不是路由导入根组件,根组件宣布自己增强了路由的功能。这意味着我可以通过删除文件来删除根组件。因为它不再增强路由,所以这是删除组件的唯一操作。

幻灯片文本:谁来决定何时使用增强?

这真的很棒,如果它不是再次涉及人性化。他们现在必须考虑“我是该导入它,还是使用增强?我在哪种情况下使用哪一种方式?”。

图片:危险。危险化学品。

这是这个问题的特别糟糕的情况,因为增强模块的能力,能够使系统中的所有其他东西都依赖于你是非常强大的,如果出错的话,就会非常危险。很容易想象这可能会导致非常糟糕的情况。所以,在谷歌我们认为这是一个好主意,但我们也认为它是非法的,没有人可以使用它 —— 有一个例外:生成的代码。它实际上非常适合于生成的代码,它解决了生成代码的一些固有问题。有了生成的代码,你有时必须导入你甚至看不到的文件,必须猜测他们的名字。但是,如果生成的文件恰好不可见,并增强了它所需的任何内容,那么你就没有这些问题。你根本不需要知道这些文件。他们只是神奇地增强了中央注册表。

幻灯片文本:单文件组件指向其增强路由的组件。

我们来看一个具体的例子。我们这里有个单文件组件。我们在其上运行代码生成器,并从中提取这个小的路由定义文件。那个路由文件只是说“嘿,路由,我在这里,请导入我”。显然,你可以将这种模式用于各种其他事情。也许你正在使用 GraphQL,你的路由应该知道你的数据依赖关系,那么你可以使用相同的模式。

幻灯片文本:基本打包文件。

不幸的是,这不仅仅是我们所需要知道的。第二个我最喜欢的计算机科学问题,我称之为“基础垃圾打包文件”。在应用程序的打包逻辑中的基本打包文件总是会被加载,而与用户与应用程序的交互方式无关。所以,这一点尤其重要,因为如果它很大,那么所有进一步深入的东西都会很大。如果它很小,那么依赖文件也有可能变小。一个小故事:在某个时候,我加入了 Google Plus JavaScript 基础架构团队,并且我发现他们的基础打包文件包含 800 KB 的 JavaScript。所以,我对你的警告是:如果你想比 Google Plus 更成功,就不要让你的 JS 基础打包文件超过 800 KB,但不幸的是你的文件体积很难维持在理想状态。

幻灯片文本:指向 3 个不同依赖关系的基础打包文件。

这有一个例子。你的基础打包文件需要依赖于路由,因为当你从 A 到 B 时,你需要知道 B 的路由,所以它总是在周围。但是你真正不想要的是将任何形式的 UI 代码打包进基础打包文件,是因为取决于用户如何进入你的应用程序,可能会有不同的用户界面。所以,例如日期选择器绝对不应该放在你的基础打包文件中,结账流程也不应该。但我们如何防止这种情况?不幸的是导入非常脆弱。你可能在无意中导入那个很酷的工具包,因为它有一个函数来生成随机数。现在有人说“我需要一种自动驾驶汽车的实用工具”,并且突然将自动驾驶汽车的机器学习算法导入到你的基础打包文件中。类似这样的事情很容易发生,因为导入是传递性的,所以问题往往会随着时间的推移而累积起来。

幻灯片文本:禁止依赖测试。

我们找到的解决方案是禁止依赖测试。禁止依赖测试是一种断言,例如你的基础打包文件不依赖于任何 UI。

幻灯片文本:断言基本打包文件不依赖于 React.Component。

我们来看一个具体的例子。在 React 中,每个组件都需要继承自 React.Component。因此,如果你的目标是基本打包文件中没有 UI,只需添加一个测试来确定 React.Component 不是你基本打包文件的传递依赖。

禁止的依赖关系被删除。

再看一下前面的例子,当有人想添加日期选择器时,只会出现测试失败。而这些测试失败通常很容易就能很好地解决,因为通常这个人并不是真的想要添加依赖关系 —— 它只是通过一些传递路径进入。比较这一点,当这种依赖关系已经存在了 2 年,因为你没有测试。在这些情况下,通常很难通过重构代码来摆脱依赖关系。

幻灯片文本:最自然的路径。

理想情况下,你会发现最自然的路径。

幻灯片文本:最直接的方式必须是正确的。

你想要达到这样一个状态,无论你的团队中的工程师做什么,最直接的方式也是正确的方式 —— 这样他们就不会离开这条道路,所以他们自然而然地做了正确的事情。

幻灯片文本:否则添加一个确保正确的测试。

这可能不总是可行的。在那种情况下,只需添加一个测试。但这不是很多人认为有权做的事情。但是,为确保你的基础架构保持不变,请为你的测试程序添加测试的授权。测试不仅仅是为了测试你的数学函数是否正确。它们也用于基础架构和应用程序的主要设计特性。

幻灯片文本:避免在应用领域之外进行人为判断。

尽可能避免在应用领域之外进行人为判断。在开发应用程序时,我们必须了解业务,但是并非团队中的每位工程师都能理解代码拆分的原理。而且他们不需要那样做。在不是每个人都能理解它们的时候,试着将这些东西以一种友好的方式引入到你的应用程序中,并保持其复杂性。

幻灯片文本:可以轻松删除代码。

真的,让删除代码简单点。我的演讲题为“构建非常大型的 JavaScript 应用程序”。我可以给出的最佳建议:不要让你的应用程序变得非常大。最好的办法是在还来得及的时候开始删除东西。

幻灯片文本:没有抽象比错误的抽象更好。

我想再谈一点,那就是人们有时会说,没有抽象比错误的抽象要好。这实际上意味着错误的抽象代价非常高,所以要小心。我认为这有时会被误解。这并不意味着你不应该有抽象。这只是意味着你必须非常小心。

我们必须善于找到正确的抽象

幻灯片文本:同理心和经验 -> 正确的抽象。

正如我在演讲开始时所说的:实现目标的方式是使用同理心,并与团队中的工程师一起思考他们将如何使用你的 API​​ 以及他们将如何使用抽象。你如何随着时间的推移充实这种同理心会成为经验。综上所述,同理心和经验使你能够为你的应用程序选择正确的抽象


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏