成为顶级工程师:JavaScript引领你走向十亿级应用的巅峰

发表时间: 2018-07-31 19:40

这是我在澳大利亚JSConf上的演讲稿,经过少许编辑。

我以前开发过超大规模的JavaScript应用。现在我不做了,所以我觉得应该回顾下我学到的东西。昨天我在宴会上喝啤酒时有人问我,“嗨Malte,你为什么要来讲这个话题?”

我觉得这个问题的回答也属于这次演讲的范围,虽然我觉得讨论自己的事情感觉有点奇怪。

言归正传,我在Google开发了这个JavaScript框架。Photos、Sites、Plus、Drive、Play和搜索引擎所有这些网站都用到了我的框架。有些网站的规模非常大,估计你也用过 。

这个JavaScript框架不是开源的。不开源的原因是,它跟React差不多是同时出来的,我当时有种“既生瑜何生亮”的感觉。

Google已经有好几个框架了,像Angular和Polymer,我觉得再有一个框架会让人难以选择,所以我觉得我们还是留给自己用就好了。

但除了没有开源之外,我感觉从这个框架中学到了很多东西,有必要在这里跟大家分享一下。

超大规模应用,以及这些应用的共同点

我们来讨论下超大规模应用,以及这些应用的共同点。当然,肯定有很多人一起开发。可能还有更多的共同点,很多是人的问题,他们有感情,有人际交流的问题,所以你必须得考虑到。

就算你的团队没那么大,也许你只是在其中工作了一段时间,甚至可能你并不是第一个负责维护的人,你可能也没有所有需要的信息。

也许有很多你并不理解的东西,也许团队里其他人也不理解应用的方方面面。那么,我们在构建超大规模应用时,必须要考虑下面的内容。

这里我想说的另一件事就是,从职业生涯的角度为这次演讲提供一些背景。我估计许多人都认为自己是高级工程师。或者我们虽然还不是,但希望有一天能成为高级工程师。

我认为,成为高级工程师意味着能够解决别人给我的几乎所有问题。我精通我用的工具,精通我的专业领域。而工作的另一个重要部分就是,我要让其他初级工程师最终成长为高级工程师。

但事实上,有时候我们会想“下一步该干什么了?”当我们到达高级阶段后,下一步又该怎么走?

一些人可能想进入管理层,但我不认为每个人都希望如此,因为肯定不可能所有人都是经理,对吧?一些人非常擅长工程,为什么不能一辈子干工程呢?

我想建议一条适合高级工程师的晋级之路。当我说我自己是高级工程师时,我会说“我知道我能解决这个问题”,而且因为我自己知道该怎么解决,我也能教别人该怎么解决。

我的理论是,下一级别应该是“我知道别人会怎么解决这个问题”。

我们来具体说一下。比如这句话:“我能预料到我做出的API选择,或者我引入到项目中的抽象,会如何影响到其他人解决问题的方法。”

我觉得这个概念十分有用,能够让我判断我的决定会怎样影响到应用程序。

我称这种为“共情的应用”。你要和其他工程师一起思考,并且要思考你的行为和你给他们的API会怎样影响他们写软件的方式。

共情的应用

幸运的是,这里说的共情是简单模式的。共情通常很难,这里的所说的共情也很难。但至少你需要共情的那些人也是软件工程师

尽管他们跟你会有很大区别,但至少在构建软件方面和你有很多共同点。在有经验之后,这种共情很容易做得很好。

仔细想一下这些话题,我只想谈一个真正重要的词,那就是编程模型——这个词我以后会经常提到。

它的意思是“给定一组API,或者一组库,或者框架,或者工具,人们会怎样用这些东西编写软件。”我的演讲的真正内容是,API等的变化会怎样影响编程模型。

我举几个影响编程模型的例子。假设你有个Angular项目,如果说要把这个项目移植到React上,这显然会影响到人们编写软件的方式,对吧?但如果说"60KB就能操作虚拟DOM,我们切换成Preact吧”,由于这个库是API兼容的,因此你做的这个决定不会影响到他人怎样编写软件。

也许之后你会说“这些都太复杂了,我们得有个工具来管理应用的工作。我们要引入Redux。”这就会影响到别人编写软件的方式。如果有个需求说“我们需要个日期选择控件”,结果在npm上搜了下发现了500个,于是你选了其中一个。

那么选哪个会有影响吗?肯定不会影响编写软件的方式。但是,手头有npm这个工具,那么琳琅满目的模块肯定会影响到你写软件的方式。当然,还有许多其他会影响或不会影响人们编写软件方式的例子。

“代码分割”的技术

现在谈一下所有大规模JavaScript应用在发布时的共同点之一,那就是由于项目非常庞大,我们不希望一次性发布所有部分。因此我们引入了一种叫做“代码分割”的技术。

代码分割的意思就是把应用做成多个包(bundle)。因此,如果一些用户只需要使用应用的这个部分,另一些用户只使用另一个部分,我们可以把应用分成几个包,这样用户只需要下载他实际会用到的那部分应用程序。

这个技术是我们都会做的。和许多其他东西一样,这种技术是由闭包编译器发明的——至少在JavaScript的世界中如此。不过我认为实现代码分割的最常见的办法就是使用webpack。

但如果你用的是RollupJS——这个库也很棒,他们最近也开始支持代码分割了。代码分割肯定是要做,但在引入代码分割时一定要谨慎,因为它会影响到编程模型。

有了代码分割,以前的同步的东西就变成了异步的。没有代码分割到时候应用很简单明了。程序加载之后就稳定了,无需再等待任何东西。

但有了代码分割,有时候就得因为“需要加载某个包”而访问网络,就得考虑这样做带来的影响,应用程序会变得更复杂。

此外,这里面也有人的参与,因为代码分割需要把包定义好,还需要你去思考什么时候该加载某个包,因此你的团队里的工程师必须要决定,哪些文件放进哪个包里,什么时候加载这个包。只要有人参与,就会影响到编程模型,因为他们得思考这些东西。

有个经过实践考验的方法能解决代码分割问题,这样人就不用考虑代码分割问题了。这种方法叫做“基于路由的代码分割”。如果你还没有使用代码分割,那你可以从这种方式入手。路由就是应用程序URL结构中的基础部分。

例如,产品页面位于/product/下,而分类页面位于别的地方。你可以把每个路由做成一个包,这样应用里的路由程序就能知道代码分割了。

当用户访问某个路由时,路由器就会加载相应的包,然后这个路径就不需要人去操心了。

现在的编程模型就跟刚开始只有一个大包的情况没什么太大区别了。这种方法很好,应该从这里开始入手。

但这次演讲的标题是“超大规模JavaScript应用”,应用的规模迅速扩大,导致每个路由一个包已经无法实现了,因为路由本身也变得非常大。关于什么是超大规模应用我有个很好的例子。

在这次演讲之前我搜索过怎样在公众场合讲话,于是我得到了这一堆蓝色的链接。可以很肯定,这个页面只需一个单一的路由包就可以处理。

但稍后我想知道天气,因为加州的冬天很冷。结果立刻就跳到了完全不同的模块。看起来这个简单的路由比我们想像的要复杂。

然后我收到会议邀请后,想查一下美元到澳元的汇率,结果显示了复杂的货币转换工具。

显然,这种特殊模块有上千个,显然不可能把所有模块都放到一个包里,否则这个包就会变成几个兆,用户下载起来会很困难。

因此,我们不能简单地根据路由进行分割,必须找其他的办法。基于路由的代码分割很容易,因为这是最粗糙的分割方式,更深入的部分可以忽略。

我喜欢简单的东西,那么如果在细粒度上进行代码分割会怎样呢?考虑下如果对每个组件都进行懒加载会这哪一个。

如果只从带宽效率的角度来看似乎很不错。但从其他角度考虑,比如延迟,这却是个很糟糕的想法,但这种想法是值得考虑的。

但想像一下,假设你的应用使用React,而React应用静态地依赖于子组件。也就是说,如果因为要懒加载子组件就打破这种依赖,那就改变了编程模型,以后的事情就没那么容易了。

如果你有个货币转换组件,希望放在搜索页面上,那肯定要import对吧?通常用ES6模块实现:

但如果想懒加载,代码就会变成这个样子,使用动态import懒加载ES6模块,并封装到一个可加载的组件中。

当然,实现方式数不胜数,而且我并不是React专家,但这种方式肯定会改变你编写应用的方式。

以后的事情就没那么简单了。静态的东西成了动态的,这是另一个编程模型改变的标志。

你会马上问:“由谁来决定什么时候懒加载哪个模块?”因为这个问题的答案会影响到应用程序的延迟。

于是又要涉及到人了。人需要思考“这儿有个静态import,还有个动态import,什么时候该用哪个呢?”搞错这个会很糟糕,因为本来是静态import的东西被动态import的话就会在包里加载不该加载的东西。这种事情,在项目持续很长时间,经历过很多工程师的手之后,就一定会出错。

现在我来说说Google怎么做到这一点,以及如何在保证良好性能的前提下实现优秀的编程模型。

我们的做法是,将组件按照渲染逻辑、应用逻辑分割。这里所说的逻辑就是按下货币转换工具上的按钮这种逻辑。

现在有两个分割好的东西,我们只加载之前渲染过的组件中的应用逻辑。这个模型非常简单,因为只需要做服务器端渲染,然后不管渲染的是什么,只需下载相关的应用包就可以了。这样就把人的因素排除在了系统之外,因为加载是通过渲染自动进行的。

“注水”

这个模型看起来似乎不错,但它需要付出些代价。如果你了解React或者Vue.js等框架中的服务器端渲染的典型做法的话,你就应该知道它们的做法叫做“注水”(hydration)。

注水的原理是,服务器先进行渲染,然后客户端再进行同样的渲染,也就是说前端需要加载代码以渲染那些已经存在于页面上的东西,因此无论加载这些代码还是运行它们都是显著的浪费。

它会浪费大量带宽和CPU,但它非常好用,因为你在客户端不用考虑服务器端已经渲染过什么了。我们在Google用的方法不一样。

因此,如果要设计一个超大规模的应用,你就得考虑:是要用超级快但超级复杂的方法,还是直接使用注水方式,虽然效率低一些但编程模型却很舒服的方法?你必须得作出决定。

下一个话题是我最喜欢的计算机科学问题之一——不是关于明明的,尽管我估计我起的名字很糟糕。

它的名字是“2017节日特别问题”。有没有人有过这种经历,以前写的代码,现在虽然不再使用了,但还留在代码库中?

大家都知道这个问题,而且最严重的就是CSS。一个超大的CSS,里面有各种选择器。谁知道哪个选择器还有用?所以干脆留在里面好了。

我觉得CSS社区正面临着革命,因为他们也意识到了这个问题,因此他们想出了像CSS-in-JS等解决方案。

用这种方案,一个组件只需要一个文件,比如
2017HolidaySpecialComponent组件,只要裹了2017年,就可以把整个组件都删掉,于是所有东西就都没了。这样删代码就变得特别容易了。这个点子很好,应该不仅仅是CSS使用这种方式。

如何避免集中化配置

我想举几个例子说明避免集中化配置这个思想,因为集中化配置会让删除代码变得极其困难,比如集中化CSS文件等。

(routes.js)

之前我说过应用程序里的路由。许多应用程序会有个名为routes.js的文件,里面包含了所有路由,路由将自己映射到某个根组件上。这就是集中化配置的例子,这种情况在大规模应用中应当尽力避免。

因为集中化配置会造成这种情况:有工程师会问“那个根组件还要不要?我需要更新那个文件,但它属于别的团队。不清楚我是否应该修改它。或者留着明天再说吧。”由于这样的原因,人们只敢向这个文件里添加内容了。

另一个例子就是webpack.config.js文件,通常这个文件被用作构建整个应用。短时间内可能没问题,但最终,由于这个文件必须了解所有其他团队在应用中的工作的细节,所以没办法伸缩。同样,我们需要一种模式,在构建过程中使用去中心化的配置。

另一个例子就是package.json,这是npm所用的文件。每个包都说“我有这些依赖,我的运行方式是这样,编译方式是这样”。但显然不可能存在一个巨大的配置文件适合所有的npm包。

它没办法处理几十万个文件。因此,在git中就会导致许多冲突。没错,这的确是因为npm太大了,但我认为我们的许多应用也会变得那么大规模,从而我们不得不考虑同样的问题,采用同样的模式去解决。

我并没有所有的解决方案,但我认为CSS-in-JS带来的思想也可以用在应用程序的其他方面。

用更抽象的形式来描述这种思想,就是我们要对应用程序的抽象设计思想和组织形式负责,即对应用程序的依赖树的形状负责。

这里所说的“依赖”是指抽象意义上的依赖,它可以是模块依赖,可以是数据依赖,服务依赖,有很多种。

显然,所有这些应用程序都超级复杂,但我这里举个非常简单的例子。它只有四个组件。

它包含一个路由器,路由器知道路由之间的转移。此外还有几个根组件A、B和C。

前面提过,这种模式有中心化import的问题。

在Google,我们想出了个办法来解决这个问题。我以前应该没说过这个方法,所以在这里介绍一下。我们发明了一个新概念,叫做“增强”(enhance)。我们用它来替换import。

实际上,它是import的另一面。它是反向依赖。enhance一个模块的意义就是让那个模块依赖你。

从依赖图中可以看出,组件还是那几个组件,但箭头的方向是反的。因此,我们没有让路由器导入根组件,而是让跟组件声明,自己会增强路由器。

这样,删除一个根组件只需要删除文件就可以了,因为这个根组件不再增强路由器,所以删除根组件需要的唯一操作就是删除文件。

这种方法很好,直到又遇到了人的问题。现在开发者得考虑“我是该用import,还是用enhance?什么情况下该用哪个?”

这正是这种方法的弊端。由enhance功能过于强大,它可以让系统中的所有模块都依赖于你,如果被错误使用,这是非常危险的。

不难想像,这会导致非常糟糕的结果。因此在Google,虽然我们确定这是个很好的想法,但最终我们认为它不应该被使用,任何人都不应该使用这种模式,除了一种情况之外:自动生成的代码。

实际上,这种模式非常适合自动生成的代码,它能解决一些生成代码的固有问题。生成代码的时候,有时你得导入一些看不到的文件,有时得猜测它们的名字。

但是,如果生成的文件只是在默默增强它们需要的组件的话,就没有这些问题了。你永远不必去理解那些文件,它们只是在增强着整个代码。

我们来看一个具体的例子。上面是个单一文件的组件。在该组件上运行代码生成器,然后从中提取出路由定义文件。

该文件说“嗨路由器,我在这儿,请import我”。显然,这种模式可以用在所有其他东西上。如果你在使用GraphQL,并且需要路由器知道数据依赖,那么就可以使用同样的模式。

不幸的是,我们需要知道的并不只这些。下面是我第二喜欢的计算机科学问题,我称它为“base包(bundle)的垃圾堆”。在应用程序的包的构成图中,base包是那个永远会被加载的包,不管用户需要使用的是应用程序的哪个部分。

因此,它极其重要,因为如果它过大的话,那么在它之下的一切东西都会过大。如果它比较小,那么至少依赖它的包还有机会小一些。

一个小笑话:我加入Google Plus JavaScript基础设施团队后,发现他们的base包有800KB的JavaScript。

所以我的忠告就是,如果你想比Google Plus更成功,不要让你的base包超过800KB的JS。很不幸,这种糟糕的状态很容易达到。

举个例子。base包需要依赖于路由,因为从A转移到B时,B的路由必须是已知的,所以base包里需要包含路由。但是,base包里不应该包含任何UI代码,因为用户从不同的途径打开应用可能会看到不同的UI。

因此,比如日期选择器绝对不应该在base包里,结账的工作流也不应该在base包里。但怎样防止这一点呢?很不幸,import十分脆弱。你导入了util包(package),因为它里面有个函数可以生成随机数。

后来有人说“我需要给自动驾驶汽车写个工具函数”,于是你的base包里就导入了为自动驾驶汽车准备的机器学习算法。这种事情很容易发生,因为import会传染,因此时间一长就很容易产生垃圾。

我们的解决方案叫做“禁止依赖测试”(forbidden dependency tests)。这种方法可以检查比如base包是否依赖任何UI。

来看个具体的例子。在React中每个组件都需要和React交互。因此,如果目标是base包不包含任何UI,那么只需要增加这样的断言:React.Component不是base包的依赖。

再看一下前面的例子。如果有人试图添加日期选择器,就会导致测试失败。而且这些测试失败通常很容易修复,因为大多数情况下那个人并不是有意添加那个依赖的,只是通过某种传染途径进去了而已。

相比之下,如果一个应用没有类似的测试,并且一个依赖进入了两年之久,那就很难通过重构来删除这个依赖了。

理想情况下,你会找到最自然的那条路径。

最理想的状态是,不管团队里的工程师做什么,最直接的路永远是正确的路,这样他们就不会走错路,从而自然而然地做正确的事情。

但这种状态并不一定可行。如果不可行,就写个测试。不过许多人并没有动力写测试。

但是,请务必给应用程序写测试,来保证基础设施不会被改变。测试不仅是要测试数学函数是否正确。测试也可以用于应用程序的基础设施和主要设计上。

在应用程序之外,尽量避免依赖于人的判断。编写应用程序时,我们要理解业务,但并不是公司里的每个工程师都能理解代码分割的原理。

而且他们也不需要知道。在导入这些东西时,要保证即使他们不理解也能正确使用。

真的,要让删除代码更容易。我的演讲叫做“创建超大规模JavaScript应用程序”。

我的最好的建议就是:不要让应用程序变得太大。做到这一点的最好方法就是及时删除东西。

我还想说一点,那就是一些人认为的没有抽象要比错误的抽象更好。这句话的真正含义是,错误抽象的代价非常高,因此一定要小心。

我觉得这句话有时候被误解了。它并不是说我们不应该要抽象。它只是说你要格外小心。

我们要善于找到正确的抽象。

正如我在演讲开头提到的:最好的办法就是通过共情,想像下团队里的工程师会怎样使用你的API,怎样使用你的抽象。有了经验,就能知道怎样使用这种共情。综合起来,共情和经验能让你给应用程序选择正确的抽象。

原文:https://medium.com/@
cramforce/designing-very-large-javascript-applications-6e013a3291a3

作者:Malte Ubl,AMP项目技术负责人,在Google负责JavaScript基础设施。

译者:弯月;责编:胡巍巍

“征稿啦”

CSDN 公众号秉持着「与千万技术人共成长」理念,不仅以「极客头条」、「畅言」栏目在第一时间以技术人的独特视角描述技术人关心的行业焦点事件,更有「技术头条」专栏,深度解读行业内的热门技术与场景应用,让所有的开发者紧跟技术潮流,保持警醒的技术嗅觉,对行业趋势、技术有更为全面的认知。

如果你有优质的文章,或是行业热点事件、技术趋势的真知灼见,或是深度的应用实践、场景方案等的新见解,欢迎联系 CSDN 投稿,联系方式:微信(guorui_1118,请备注投稿+姓名+公司职位),邮箱(guorui@csdn.net)。