搭建体系的模块依赖关系与 import maps

作者:步天
来源: Alibaba F2E公众号

搭建体系的模块依赖关系与 import maps

我是淘系前端搭建服务团队的步天,目前负责建设天马搭建服务和斑马搭建平台,既提供通用的搭建服务,也提供直接可用的搭建产品。在开始之前,先介绍一些下面会提到的名词,有助于更好的理解:

  1. 搭建:可以给到非技术同学,通过拖拽、配置的方式,产出页面来运作自己的业务
  2. 模块:搭建操作的最小单位
  3. 天马:淘系搭建服务团队建设的,面向阿里内部多个 BU 提供服务的搭建中台,可以协助业务快速构建一个业务搭建系统

我们在去年4月分享过《淘宝前端在搭建服务上的探索》,介绍很多淘系搭建在过去发生的变化,以及为什么会有天马这样的搭建服务。

今年的4月分享了《淘系前端搭建服务在2020年有哪些变化》,介绍了在搭建体系比较完整的情况下,我们在 2020 年又做了哪些事情来提升用户的体验。

在阅读本文之前,推荐先阅读以上两篇文章,对搭建体系有一些更多的了解。本篇文章会和前面两篇文章不同,会专注于讨论搭建体系下,模块依赖关系的处理以及未来如何标准化的思考上。

老生常谈的 Web 资源引用问题

虽然现代的浏览器性能已经基本不输原生客户端,但真正跑在浏览器上的页面却依然有比较多的体验问题,其中加载性能长时间处于最大问题之列。其中很大一部分原因是渲染页面时依赖的文件都需要远程下载,这个也是 Web 区分于 Native 最大的差异。

为了提升加载性能,从社区规范上,经历了几个阶段:

  1. 从纯 script 标签组织加载顺序到开始使用 CDN combo 功能。
  2. 从没有模块规范到出现了 CommonJS 模块规范,可以同步加载模块。
  3. 由于浏览器远程下载文件的特性,出现了 AMD 模块规范,可以声明异步的依赖关系。
  4. 异步加载性能依然受限于浏览器并发请求数、调试困难等问题,开始出现了打包成单文件的方案(Dojo、Webpack)
  5. 随着 Web 复杂度的上升,以及 ES Module 标准、import maps 规范出现,开始出现 bundless 的方案,解决开发时构建效率以及面向标准运行的问题。

而天马 seed 模块规范源自 KISSY,本身沿用了 AMD 的思路,最后采用了 CMD 的规范(因为产物生成逻辑更简单),在搭建的动态化背景下,保留了模块化开发和运行的方式。天马模块和市面上的大部分搭建、低代码体系不一样的地方:

  1. 模块化开发,统一并隐藏了基础的构建逻辑,公共依赖自动去重,开发者基本上不需要关注构建过程。
  2. 页面的发布没有构建过程,用户访问时渲染,支持大批量页面同时修改。
  3. 页面之间发布独立,模块版本更新可以细粒度到单个页面生效。

但这套规范运行5年下来,也遇到了比较多的问题,我们在接下来与 import maps 规范的对比中,来讲讲天马模块规范和社区标准衍进的差异。

seed 规范与 import maps

seed 是天马模块中描述依赖关系的文件,类似 Webpack Dependency、SystemJS depcache,在天马体系里,这个文件会给到渲染引擎以及浏览器端直接执行。seed 这个词来源于 YUI 体系,seed 规范本身沿用了很多 KISSY seed 的描述方式。我们内部自研了 feloader 加载器(这部分也继承了 KISSY loader 的实现)来支持 seed 格式的解析和模块的加载与执行。

引入和使用

seed

  1. 引入 feloader 加载器,会在全局环境下注册 require 和 define 方法。
  2. 配置依赖关系。
  3. 加载 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

  1. 声明 importmap 配置
  2. 用浏览器提供的 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 会更严格:

  1. importmap 文件需要用 script + type="importmap" 的方式加载,虽然是 json 文件,但是浏览器会自动解析,所以 importmap 文件需要在调用 import 的脚本之前加载,并且加载过程是阻塞性的。
  2. 一个页面只能加载一次 importmap,多 importmap 还在讨论中。在开始加载脚本之后,增加新的 importmap 文件会直接报错,并且无法生效。
  3. 只能用 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 的方式,包括合并多个页面等等偏模板的方式,让模块搭建的页面接近源码开发的页面。但有一些问题始终很难解决:

  1. 如果每次发布都需要打包,生效速度和页面数量上存在比较大的问题。
  2. 如果合并多个页面作为一个源码模板,那么首屏资源无法精准计算,影响用户体验。

绕了一圈之后,发现 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》
上一篇:网页中多个图标在一张图片上,使用css将各图标显示


下一篇:简单跳板机的搭建笔记