likes
comments
collection
share

关于下一代前端代码构建技术的设想

作者站长头像
站长
· 阅读数 30

简单阐述下我对前端代码构建技术代际关系的理解,以及每个阶段遇到的问题。

最初的前端代码是没有构建这个概念的,早期的 JavaScript 代码通常在编写完成后就直接通过 script 标签加载到浏览里就可以直接运行,随着技术进步,seajs requirejs 等技术在纯浏览器环境下引入了 JavaScript 代码的模块化方案,为了匹配这些方案于是有了最基础的构建技术 ”文件合并“

文件合并

文件合并就如字面理解的,将多个 JavaScript 文件进行拼接,同时配合 requirejs 等模块化加载器来隔离代码的运行上下文。利用 gulp 和 grant 在构建流程中可以加入图片处理,样式处理,压缩/混淆,二次编译(babel)等技术,最终代码具备模块化和现代化的特征,并且这一套技术方案至今还在相当多的项目中运行。

基于文件合并的构建技术实现简单,但随着文件数量的集聚增长和对加载性能的要求,这种技术已经无法满足大型前端应用对性能的诉求,其中最主要的是文件合并和模块化加载两者之间没有任何联系,对于代码量庞大的 spa 应用来说我们耳熟能详的优化技术都无法实现,包括 TreeShaking, chuank 代码切割,公共依赖提取等等,于是 webpack 将模块加载和文件处理有机结合,将前端代码的构建技术推进到下一阶段,也是目前主流的代码构建技术 ”基于文件依赖的构建“

基于文件依赖的构建

和文件合并不同的是,基于文件依赖的分析,将代码构建的分析粒度从文件级细化到了模块级,通过对主流模块化语法的支持,webpack 可以兼容大多数不同模块语法的 JavaScript 代码,随着 ES6 模块语法的普及,rollup 提出了 TreeShaking,通过语法识别来对未使用的代码进行剪切,从而进一步缩小构建包的大小,webpack 在 TreeShaking 之外有自己的一套代码构建优化方案,webpack 将文件抽象成 chunk,通过优化算法对 chunk 进行合并拆分,从而实现对构建结果的体积控制,在此基础上 webpack 提出了 mf,允许开发者将一个工程拆分独立构建,在运行时再整合构建结果,这一定程度上缓解了 webpack 单体工程构建性能的问题。

同时 vite 的出现也将浏览器原生模块加载推进到了实用阶段,不过目前考虑浏览器的兼容性,生产还是要进行构建的。但也大大提升了开发时的构建速度,本质上来说和 mf 类似都是将构建技术的环境进行转移和分化,从而降低单体构建性能差的问题。

不过这一阶段的技术的发展也引申出一个问题,如果文件的数量继续增长?是否有一天浏览器也无法满足对构建性能的支撑呢?又或者我们需要将构建性能拆分运行时和开发时,尽可能榨干浏览器的原生性能,但当浏览器无法满足的时候又必须在开发时对构建结果进行优化,从而使构建结果不至于超过浏览器的加载极限。关于这一点 V8 的开发团队在 BLOG 中也提到过,浏览器原生的加载性能是有瓶颈的,开发者应该了解这一点,不能无限制的寄望于将性能问题转移到浏览器上。

需求是无限的,人机交互技术的发展可能在下一个时代将前端应用的代码量提升到一个惊人的级别,例如基于 AR 眼镜的多屏应用就超出了单一的屏幕的限制,需要同时呈现更多的交互界面,响应更复杂的交互逻辑,这无疑对于现代的前端代码构建技术形成了潜在的挑战。因此我相信新的需求必然会催生出新的代码构建技术,至少从目前主流的构建技术来看,新一代的构建技术应该将依赖分析控制在有限级,而不是完全基于依赖分析来构建,让我大胆的起个名字 ”基于服务的构建“

基于服务的构建

微服务是软件工程历史上的伟大发明,在这之前 webService 已经存在,软件工程师们从服务的角度讲代码抽象出来进行打包部署,通过通信协议让服务之间代码能够互相调用,就像他们原本就构建在一起一样。

前端是软降工程的一部分,因此我猜测下一代的构建技术会和通信协议结合起来,推动前端代码服务化,这种通信协议并不是基于网络的,考虑到 JavaScript 代码最终都在一个线程里执行,我设想这种通信协议是基于模块的,也就是让模块和模块之间能够实现基于协议的调用,而非基于依赖的调用,这两者有几点非常大的区别。

基于依赖的调用,模块和模块必须通过构建来产生关联,对于浏览器来说,加载 JavaScript 模块本身就有两层含义,加载代码本身,通过分析代码,加载依赖模块,直到依赖加载完成,代码才可以执行,我举个极端点的例子

import hello from 'hello'
import hello1 from 'hello1'
import hello2 from 'hello2'
import hello3 from 'hello3'
import hello4 from 'hello4'
import hello5 from 'hello5'
import hello6 from 'hello6'
import hello7 from 'hello7'

hello()
hello1()
hello2()
hello3()
hello4()
hello5()
hello6()
hello7()

要开始执行 hello 必须将 hello... 所有模块加载完毕才能执行,如果里面还有其他依赖,则这个过程会继续,当然如果你想尽快执行 hello 可以将 hello 转变成一个异步模块

import('hello').then(module=>{
    module.default()
})

但这会带来一个问题,谁来决定哪个模块应该异步执行?谁又来维护这些异步模块呢?但是基于服务就不会有这困惑,因为服务从来都是异步的,如果将 hello 定义为服务。那么调用 hello 服务这件事就和模块加载毫无关系了。

mpc('hello').hello()

我对这行代码的预期是,首先我不将 hello 视为模块,hello 只是服务的一个地址,通过 mpc 快模块调用方法,我和 hello 服务建立联系,从而调用服务内模块上的 hello 方法。这一过程的背后不需要我对代码如何加载运行有任何认知。这是服务化和异步模块之间一个很重要的区别。异步模块不是服务,是手动控制模块加载时机来限制模块的运行,提前或者延后。而服务并不关心模块何时加载何时运行,对于调用者而言我仅需要服务在我理解的时机运行即可,如果 hello 方法是 async 函数,那么就可以同步调用

await mpc('hello').hello()

服务化隐藏了模块加载这种显性特征,但并不意味着代码加载的过程消失了,因此基于服务的构建技术依然要解决模块的加载问题,但显然如果要保证服务调用的语法和语义,传统的 import() 显然是行不通的,因此基于依赖的模块加载在服务端点上不行,那么需要另一种加载方式,我们假设服务内部的代码依然是基于依赖构建,而服务和服务之间的代码可以通过生产消费队列来实现。

const helloProxy = mpc('hello') // 构架一个 hello 服务代理对象
helloProxy.hello() // 通过代理对象向服务方法队列推入一条消息,hello 服务的 hello 方法被调用了

我们假设 hello.js 存放在某个 cdn 上,在上述代码执行前,浏览器就已经将 hello.js 加载下来。

那么当 hello.js 加载完毕,它会查看当前的服务方法队列中是否有调用的消息,如果有,则取出并执行,

和异步模块不同,服务的代码是可以提前加载的,而不是等到 import() 执行的时候才加载,对于超大型应用来说,可以通过 http2 并行加载大量的服务代码,从而实现异步服务的同步调用。同时将代码加载收敛到一个统一的框架中进行,而不是通过不可控的 import(),人为手动的维护代码的加载时机

综上所述,下一代前端代码的构建技术,我设想是一种基于服务的代码构建技术,允许将任意大小的模块进行服务化封装,从而打破单体应用内的依赖关系,并且支持服务化代码的并行加载,同时对于调用者而言是无感的