作者:步天
来源: Alibaba F2E公众号
我是淘系前端搭建服务团队的步天,目前负责建设天马搭建服务和斑马搭建平台,既提供通用的搭建服务,也提供直接可用的搭建产品。在开始之前,先介绍一些下面会提到的名词,有助于更好的理解:
- 搭建:可以给到非技术同学,通过拖拽、配置的方式,产出页面来运作自己的业务
- 模块:搭建操作的最小单位
- 天马:淘系搭建服务团队建设的,面向阿里内部多个 BU 提供服务的搭建中台,可以协助业务快速构建一个业务搭建系统
我们在去年4月分享过《淘宝前端在搭建服务上的探索》,介绍很多淘系搭建在过去发生的变化,以及为什么会有天马这样的搭建服务。
今年的4月分享了《淘系前端搭建服务在2020年有哪些变化》,介绍了在搭建体系比较完整的情况下,我们在 2020 年又做了哪些事情来提升用户的体验。
在阅读本文之前,推荐先阅读以上两篇文章,对搭建体系有一些更多的了解。本篇文章会和前面两篇文章不同,会专注于讨论搭建体系下,模块依赖关系的处理以及未来如何标准化的思考上。
老生常谈的 Web 资源引用问题
虽然现代的浏览器性能已经基本不输原生客户端,但真正跑在浏览器上的页面却依然有比较多的体验问题,其中加载性能长时间处于最大问题之列。其中很大一部分原因是渲染页面时依赖的文件都需要远程下载,这个也是 Web 区分于 Native 最大的差异。
为了提升加载性能,从社区规范上,经历了几个阶段:
- 从纯 script 标签组织加载顺序到开始使用 CDN combo 功能。
- 从没有模块规范到出现了 CommonJS 模块规范,可以同步加载模块。
- 由于浏览器远程下载文件的特性,出现了 AMD 模块规范,可以声明异步的依赖关系。
- 异步加载性能依然受限于浏览器并发请求数、调试困难等问题,开始出现了打包成单文件的方案(Dojo、Webpack)
- 随着 Web 复杂度的上升,以及 ES Module 标准、import maps 规范出现,开始出现 bundless 的方案,解决开发时构建效率以及面向标准运行的问题。
而天马 seed 模块规范源自 KISSY,本身沿用了 AMD 的思路,最后采用了 CMD 的规范(因为产物生成逻辑更简单),在搭建的动态化背景下,保留了模块化开发和运行的方式。天马模块和市面上的大部分搭建、低代码体系不一样的地方:
- 模块化开发,统一并隐藏了基础的构建逻辑,公共依赖自动去重,开发者基本上不需要关注构建过程。
- 页面的发布没有构建过程,用户访问时渲染,支持大批量页面同时修改。
- 页面之间发布独立,模块版本更新可以细粒度到单个页面生效。
但这套规范运行5年下来,也遇到了比较多的问题,我们在接下来与 import maps 规范的对比中,来讲讲天马模块规范和社区标准衍进的差异。
seed 规范与 import maps
seed 是天马模块中描述依赖关系的文件,类似 Webpack Dependency、SystemJS depcache,在天马体系里,这个文件会给到渲染引擎以及浏览器端直接执行。seed 这个词来源于 YUI 体系,seed 规范本身沿用了很多 KISSY seed 的描述方式。我们内部自研了 feloader 加载器(这部分也继承了 KISSY loader 的实现)来支持 seed 格式的解析和模块的加载与执行。
引入和使用
seed
- 引入 feloader 加载器,会在全局环境下注册 require 和 define 方法。
- 配置依赖关系。
- 加载 seed 里配置过的模块。
<script src="feloader.js"><script/>
<script>
require.config({
"packages": {
"example-1": {
"path": "/example-1/"
}
}
});
require(['example-1/index'], function(Example) {
// do something.
});
</script>
import maps
- 声明 importmap 配置
- 用浏览器提供的 import 方法加载配置过的模块
注意:下面只是一个范例,展示了 import maps 的使用方式。目前规范下,因为暂无合适的配置合并策略,所以一个页面只能设置一次 importmap。
<script type="importmap">
{
"imports": { ... },
"scopes": { ... }
}
</script>
<script type="importmap" src="import-map.importmap"></script>
<script>
const im = document.createElement('script');
im.type = 'importmap';
im.textContent = JSON.stringify({
imports: {
'my-library': Math.random() > 0.5 ? '/my-awesome-library.mjs' : '/my-rad-library.mjs';
}
});
document.currentScript.after(im);
</script>
<script type="module">
import 'my-library'; // will fetch the randomly-chosen URL
</script>
方案对比
设置方式
首先,seed 是一个相对灵活的设置方式,在脚本执行的任何阶段都可以动态修改模块的配置,模块在加载完成后,依然可以重置模块状态,并重新加载。
而 import maps 会更严格:
- importmap 文件需要用 script + type="importmap" 的方式加载,虽然是 json 文件,但是浏览器会自动解析,所以 importmap 文件需要在调用 import 的脚本之前加载,并且加载过程是阻塞性的。
- 一个页面只能加载一次 importmap,多 importmap 还在讨论中。在开始加载脚本之后,增加新的 importmap 文件会直接报错,并且无法生效。
- 只能用 type="importmap" 来加载 importmap 文件,MIME 类型需要是 application/importmap+json(需要支持 CSP,普通 JSON 文件不需要)。
在这些限制下,我们就无法和 Node.js 环境一样,每个外部库都可以自己管理自己的依赖。而是需要从完整页面的视角,统一管理页面上用到的所有依赖,外部库应该只负责 import 就可以了。也就是如果页面上加载了一个远程的 CDN 外部库,开发者还需要关注这个外部库依赖了哪些其他外部库,并在 importmap 文件里声明清楚。这部分对于开发者实操来说很难,毕竟大部分开发者还是喜欢拿来即用,并不关注外部库做了什么。
理论上,无论用 seed 还是 import maps,开发者都应当知道页面有哪些外部依赖,并合理升级。npm 模式提供了一个较为宽松的版本化依赖方式,但在 Web 上并不一定适用。其中最大的差异是,Node.js 应用是在打包部署的时候,就把版本确定下来了,在下一次重新部署前,依赖版本是不会变化的,而 Web 页面是在用户侧,每次访问的时候重新组织,语义化版本带到 Web 页面渲染的时候并不合适,带来了太多不确定性,所以个人觉得 Airpack 是很好的方案,但用起来可能比 npm 会有更多的问题。大部分同学还是习惯了,拿来即用
目前标准推荐用行内的方式加载 importmap 文件,性能最佳,如果用外链的方式,由于页面上的脚本都需要等待 import maps 配置完成才能执行,那么只能用 HTTP/2 Push 或者 Web Bundle 来节省下载耗时。其实从 Web 标准上,css 样式表也是推荐用行内的方式加载的,虽然 css 文件不阻塞资源加载,但依然会阻塞元素的展示,毕竟浏览器总不能先展示没有样式的 HTML 元素,然后等 css 文件加载完了再把样式重新渲染上去。
支持 Package
为了缩减依赖配置的体积,减少重复的路径声明,seed 格式是由 packages 和 modules 组成的。packages 用于声明模块的目录,modules 用于声明模块间的依赖关系。同时 packages 上可以配置更多的信息,比如 CDN combine 的时候,是否需要独立的 script 标签,这样可以控制 script src 地址的组合,实现跨页面的脚本缓存共享。
{
"modules": {
"example-1/index": {
"requires": [
"example-1/utils"
]
}
},
"packages": {
"example-1": {
"path": "/example-1/"
}
}
}
import maps 规范也有 packages 的概念,用 “/” 结尾与模块进行区分。配置 packages 的方式可以支持子文件方式的 import。
<script type="importmap">
{
"imports": {
"moment": "/node_modules/moment/src/moment.js",
"moment/": "/node_modules/moment/src/",
"lodash": "/node_modules/lodash-es/lodash.js",
"lodash/": "/node_modules/lodash-es/"
}
}
</script>
<script type="module">
import moment from "moment";
import _ from "lodash";
import localeData from "moment/locale/zh-cn.js";
import fp from "lodash/fp.js";
</script>
作用域和大版本不兼容设置
由于前面 import maps 要求页面统一管理依赖,但是实际情况下,同一个外部库在页面上只有一个版本还是比较理想的情况。那么 import maps 是支持了通过设置作用域的方式,让不同文件可以加载的外部库的不同版本。
{
"imports": {
"querystringify": "/node_modules/querystringify/index.js"
},
"scopes": {
"/node_modules/socksjs-client/": {
"querystringify": "/node_modules/socksjs-client/querystringify/index.js"
}
}
}
seed 规范下目前还没有支持作用域,一方面是同个外部库不同版本加载两遍带来的体验问题,另一方面也是对开发者做好依赖管理的要求。目前对于 npm 上允许大版本(x位)不兼容的情况,我们的解决方案是把大版本号放到模块名上,实际使用的时候就是两个模块了。比作用域的方式更加简单,不过依赖工程链路来做模块名的转换,带来了一定的复杂度。
{
"packages": {
"example-1@1": {
"version": "1.0.1",
"path": "/example-1/1.0.1/"
},
"example-1@2": {
"version": "2.0.2",
"path": "/example-1/2.0.2/"
}
}
}
依赖模块的下载
seed 机制下,最重要的其实是依赖包的加载能力。一个模块可以声明依赖,类似 npm 的 dependencies,然后在加载模块的同时,会并发下载所有的依赖,包括依赖的依赖,确保模块可以正常执行。
比如在下面的例子里,加载 index 模块的同时会加载和执行 utils 和 tools,最后执行 index。
{
"modules": {
"example-1/index": {
"requires": [
"example-1/utils",
"example-1/tools"
]
}
}
}
seed 机制支持了 CDN combo,多个依赖的下载合并到一个 script 请求后,大部分情况下速度会比多个并发更快一些。具体还是要看场景,毕竟不做 combo 的话,跨页面之间可以自然共享脚本缓存。
import maps 目前还没有支持类似的依赖下载方式,这样就意味着,存在串行下载依赖的情况,目前 Webpack Module Federation 也有类似的问题,不过至少不是一个一个串行加载的。
SystemJS 内部有增加类似 requires 的实现,目前还在讨论阶段,用法如下:
<script type="systemjs-importmap">
{
"imports": {
"dep": "/path/to/dep.js"
},
"depcache": {
"/path/to/dep.js": ["./dep2.js"],
"/path/to/dep2.js": ["./dep3.js"],
"/path/to/dep3.js": ["./dep4.js"],
"/path/to/dep4.js": ["./dep5.js"]
}
}
</script>
<script>
setTimeout(() => {
System.import('dep');
}, 10000);
</script>
因为提前声明了 depcache (requires),就可以提前并行加载所有依赖的文件,省去了串行等待环节,只要加载器支持就好了。
关于 seed next
回想前面几年,我们尝试了很多方案想把 seed 模块化的体系改造到更贴近 Bundle 的方式,包括合并多个页面等等偏模板的方式,让模块搭建的页面接近源码开发的页面。但有一些问题始终很难解决:
- 如果每次发布都需要打包,生效速度和页面数量上存在比较大的问题。
- 如果合并多个页面作为一个源码模板,那么首屏资源无法精准计算,影响用户体验。
绕了一圈之后,发现 import maps 还是带来了不一样的思路,模块化的方式还是可以长期在 Web 下推进下去的。
面向未来的话,一方面需要考虑将 CMD 规范升级到 ES Module 上,需要考虑好降级方案和 CommonJS 依赖的处理。另一方面更重要的是需要推进一套更加规范的依赖加载方式,这个加载方式需要结合 CDN combo,异步并发下载依赖,客户端 native cache,PWA,Server Push,甚至浏览器实验功能预测加载 JavaScript 文件等等,针对的不同的场景,给到不同的加载方案。渐进增强,平稳退化应该是前端的强项。目前这部分还是会继续在内部的 feloader 上拓展,未来会考虑和 SystemJS 有更多的结合。
资料参考
- 《github/import-maps》
- 《W3C import maps 草案》
- 《SystemJS 支持的 import-maps》
- 《import maps需要支持 CSP》
- 《import maps 支持 depcache 提案》
- 《KISSY config》
- 《YUI Loader》