聊聊 ESM、Bundle 、Bundleless 、Vite 、Snowpack

前言

一切要都要从打包构建提及。html

当下咱们不少项目都是基于 webpack 构建的, 主要用于:前端

  • 本地开发
  • 打包上线

首先,webpack 是一个伟大的工具。vue

通过不断的完善,webpack 以及周边的各类轮子已经能很好的知足咱们的平常开发需求。node

咱们都知道,webpack 具有将各种资源打包整合在一块儿,造成 bundle 的能力。 react

但是,当资源愈来愈多时,打包的时间也将愈来愈长。webpack

一个中大型的项目, 启动构建的时间能达到数分钟之久。git

拿个人项目为例, 初次构建大概须要三分钟, 并且这个时间会随着系统的迭代愈来愈长。 github

相信很多同窗也都遇到过相似的问题。 打包时间过久,这是一个让人很难受的事情。web

那有没有什么办法来解决呢?算法

固然是有的。

这就是今天的主角 ESM, 以及以它为基础的各种构建工具, 好比:

  1. Snowpack
  2. Vite
  3. Parcel

等等。

今天,咱们就这个话题展开讨论, 但愿能给你们一些其发和帮助。

正文

什么是 ESM

ESM 是理论基础, 咱们都须要了解。

「 ESM 」 全称 ECMAScript modules,基本主流的浏览器版本都以已经支持。

image.png

ESM 是如何工做的

image.png

当使用ESM 模式时, 浏览器会构建一个依赖关系图。不一样依赖项之间的链接来自你使用的导入语句。

经过这些导入语句, 浏览器 或 Node 就能肯定加载代码的方式。

经过指定一个入口文件,而后从这个文件开始,经过其中的import语句,查找其余代码。

image.png

经过指定的文件路径, 浏览器就找到了目标代码文件。 可是浏览器并不能直接使用这些文件,它须要解析全部这些文件,以将它们转换为称为模块记录的数据结构。

image.png

而后,须要将 模块记录 转换为 模块实例

image.png

模块实例, 其实是 「 代码 」(指令列表)与「 状态」(全部变量的值)的组合。

对于整个系统而言, 咱们须要的是每一个模块的模块实例。

模块加载的过程将从入口文件变为具备完整的模块实例图。

对于ES模块,这分为 三个步骤

  1. 构造—查找,下载全部文件并将其解析为模块记录。
  2. 实例化—查找内存中的框以放置全部导出的值(但还没有用值填充它们)。而后使导出和导入都指向内存中的那些框,这称为连接。
  3. 运行—运行代码以将变量的实际值填充到框中。

image.png

在构建阶段时, 发生三件事情:

  1. 找出从何处下载包含模块的文件
  2. 提取文件(经过从URL下载文件或从文件系统加载文件)
  3. 将文件解析为模块记录
1. 查找

首先,须要找到入口点文件。

在HTML中,能够经过脚本标记告诉加载程序在哪里找到它。

image.png

可是,如何找到下一组模块, 也就是 main.js 直接依赖的模块呢?

这就是导入语句的来源。

导入语句的一部分称为模块说明符, 它告诉加载程序能够在哪里找到每一个下一个模块。

image.png

在解析文件以前,咱们不知道模块须要获取哪些依赖项,而且在提取文件以前,也没法解析文件。

这意味着咱们必须逐层遍历树,解析一个文件,而后找出其依赖项,而后查找并加载这些依赖项。

image.png

若是主线程要等待这些文件中的每一个文件下载,则许多其余任务将堆积在其队列中。

那是由于当浏览器中工做时,下载部分会花费很长时间。

image.png

这样阻塞主线程会使使用模块的应用程序使用起来太慢。

这是ES模块规范将算法分为多个阶段的缘由之一。

将构造分为本身的阶段,使浏览器能够在开始实例化的同步工做以前获取文件并创建对模块图的理解。

这种方法(算法分为多个阶段)是 ESMCommonJS模块 之间的主要区别之一。

CommonJS能够作不一样的事情,由于从文件系统加载文件比经过Internet下载花费的时间少得多。

这意味着Node能够在加载文件时阻止主线程。

而且因为文件已经加载,所以仅实例化和求值(在CommonJS中不是单独的阶段)是有意义的。

这也意味着在返回模块实例以前,须要遍历整棵树,加载,实例化和评估任何依赖项。

该图显示了一个Node模块评估一个require语句,而后Node将同步加载和评估该模块及其任何依赖项

在具备CommonJS模块的Node中,能够在模块说明符中使用变量。

require在寻找下一个模块以前,正在执行该模块中的全部代码。这意味着当进行模块解析时,变量将具备一个值。

可是,使用ES模块时,须要在进行任何评估以前预先创建整个模块图。

这意味着不能在模块说明符中包含变量,由于这些变量尚未值。

使用变量的require语句很好。 使用变量的导入语句不是。

可是,有时将变量用于模块路径确实颇有用。

例如,你可能要根据代码在作什么,或者在不一样环境中运行来记载不一样的模块。

为了使ES模块成为可能,有一个建议叫作动态导入。有了它,您可使用相似的导入语句:

import(`${path}/foo.js`)

这种工做方式是将使用加载的任何文件import()做为单独图的入口点进行处理。

动态导入的模块将启动一个新图,该图将被单独处理。

两个模块图之间具备依赖性,并用动态导入语句标记

可是要注意一件事–这两个图中的任何模块都将共享一个模块实例。

这是由于加载程序会缓存模块实例。对于特定全局范围内的每一个模块,将只有一个模块实例。

这意味着发动机的工做量更少。

例如,这意味着即便多个模块依赖该模块文件,它也只会被提取一次。(这是缓存模块的一个缘由。咱们将在评估部分中看到另外一个缘由。)

加载程序使用称为模块映射的内容来管理此缓存。每一个全局变量在单独的模块图中跟踪其模块。

当加载程序获取一个URL时,它将把该URL放入模块映射中,并记下它当前正在获取文件。而后它将发出请求并继续以开始获取下一个文件。

加载程序图填充在“模块映射表”中,主模块的URL在左侧,而“获取”一词在右侧

若是另外一个模块依赖于同一文件会怎样?加载程序将在模块映射中查找每一个URL。若是在其中看到fetching,它将继续前进到下一个URL。

可是模块图不只跟踪正在获取的文件。模块映射还充当模块的缓存,以下所示。

2. 解析

如今咱们已经获取了该文件,咱们须要将其解析为模块记录。这有助于浏览器了解模块的不一样部分。

该图显示了被解析成模块记录的main.js文件

建立模块记录后,它将被放置在模块图中。这意味着不管什么时候今后处请求,加载程序均可以将其从该映射中拉出。

模块映射图中的“获取”占位符被模块记录填充

解析中有一个细节看似微不足道,但实际上有很大的含义。

解析全部模块,就像它们"use strict"位于顶部同样。还存在其余细微差别。

例如,关键字await是在模块的顶级代码保留,的值this就是undefined

这种不一样的解析方式称为“解析目标”。若是解析相同的文件但使用不一样的目标,那么最终将获得不一样的结果。
所以,须要在开始解析以前就知道要解析的文件类型是不是模块。

在浏览器中,这很是简单。只需放入type="module"的script标签。
这告诉浏览器应将此文件解析为模块。而且因为只能导入模块,所以浏览器知道任何导入也是模块。

加载程序肯定main.js是一个模块,由于script标签上的type属性代表是这样,而counter.js必须是一个模块,由于它已导入

可是在Node中,您不使用HTML标记,所以没法选择使用type属性。社区尝试解决此问题的一种方法是使用 .mjs扩展。使用该扩展名告诉Node,“此文件是一个模块”。您会看到人们在谈论这是解析目标的信号。目前讨论仍在进行中,所以尚不清楚Node社区最终决定使用什么信号。

不管哪一种方式,加载程序都将肯定是否将文件解析为模块。若是它是一个模块而且有导入,则它将从新开始该过程,直到提取并解析了全部文件。

咱们完成了!在加载过程结束时,您已经从只有入口点文件变为拥有大量模块记录。

建设阶段的结果,左侧为JS文件,右侧为3个已解析的模块记录

下一步是实例化此模块并将全部实例连接在一块儿。

3. 实例化

就像我以前提到的,实例将代码与状态结合在一块儿。

该状态存在于内存中,所以实例化步骤就是将全部事物链接到内存。

首先,JS引擎建立一个模块环境记录。这将管理模块记录的变量。而后,它将在内存中找到全部导出的框。模块环境记录将跟踪与每一个导出关联的内存中的哪一个框。

内存中的这些框尚没法获取其值。只有在评估以后,它们的实际值才会被填写。该规则有一个警告:在此阶段中初始化全部导出的函数声明。这使评估工做变得更加容易。

为了实例化模块图,引擎将进行深度优先的后顺序遍历。这意味着它将降低到图表的底部-底部的不依赖其余任何内容的依赖项-并设置其导出。

中间的一列空内存。 计数和显示模块的模块环境记录已链接到内存中的框。

引擎完成了模块下面全部出口的接线-模块所依赖的全部出口。而后,它返回一个级别,以链接来自该模块的导入。

请注意,导出和导入均指向内存中的同一位置。首先链接出口,能够确保全部进口均可以链接到匹配的出口。

与上图相同,但具备main.js的模块环境记录,如今其导入连接到其余两个模块的导出。

这不一样于CommonJS模块。在CommonJS中,整个导出对象在导出时被复制。这意味着导出的任何值(例如数字)都是副本。

这意味着,若是导出模块之后更改了该值,则导入模块将看不到该更改。

中间的内存中,有一个导出的通用JS模块指向一个内存位置,而后将值复制到另外一个内存位置,而导入的JS模块则指向新位置

相反,ES模块使用称为实时绑定的东西。两个模块都指向内存中的相同位置。这意味着,当导出模块更改值时,该更改将显示在导入模块中。

导出值的模块能够随时更改这些值,可是导入模块不能更改其导入的值。话虽如此,若是模块导入了一个对象,则它能够更改该对象上的属性值。

导出模块更改内存中的值。 导入模块也尝试但失败。

之因此拥有这样的实时绑定,是由于您能够在不运行任何代码的状况下链接全部模块。当您具备循环依赖性时,这将有助于评估,以下所述。

所以,在此步骤结束时,咱们已链接了全部实例以及导出/导入变量的存储位置。

如今咱们能够开始评估代码,并用它们的值填充这些内存位置。

4. 执行

最后一步是将这些框填充到内存中。JS引擎经过执行顶级代码(函数外部的代码)来实现此目的。

除了仅在内存中填充这些框外,评估代码还可能触发反作用。例如,模块可能会调用服务器。

模块将在功能以外进行编码,标记为顶级代码

因为存在潜在的反作用,您只须要评估模块一次。与实例化中发生的连接能够彻底相同的结果执行屡次相反,评估能够根据您执行多少次而得出不一样的结果。

这是拥有模块映射的缘由之一。模块映射经过规范的URL缓存模块,所以每一个模块只有一个模块记录。这样能够确保每一个模块仅执行一次。与实例化同样,这是深度优先的后遍历。

那咱们以前谈到的那些周期呢?

在循环依赖关系中,您最终在图中有一个循环。一般,这是一个漫长的循环。可是为了解释这个问题,我将使用一个简短的循环的人为例子。

左侧为4个模块循环的复杂模块图。 右侧有一个简单的2个模块循环。

让咱们看一下如何将其与CommonJS模块一块儿使用。首先,主模块将执行直到require语句。而后它将去加载计数器模块。

一个commonJS模块,其变量是在require语句以后从main.js导出到counter.js的,具体取决于该导入

而后,计数器模块将尝试message从导出对象进行访问。可是因为还没有在主模块中对此进行评估,所以它将返回undefined。JS引擎将在内存中为局部变量分配空间,并将其值设置为undefined。

中间的内存,main.js和内存之间没有链接,可是从counter.js到未定义的内存位置的导入连接

评估一直持续到计数器模块顶级代码的末尾。咱们想看看咱们是否最终将得到正确的消息值(在评估main.js以后),所以咱们设置了超时时间。而后评估在上恢复main.js

counter.js将控制权返回给main.js,从而完成评估

消息变量将被初始化并添加到内存中。可是因为二者之间没有链接,所以在所需模块中它将保持未定义状态。

main.js获取到内存的导出链接并填写正确的值,可是counter.js仍指向其中未定义的其余内存位置

若是使用实时绑定处理导出,则计数器模块最终将看到正确的值。到超时运行时,main.js的评估将完成并填写值。

支持这些循环是ES模块设计背后的重要理由。正是这种设计使它们成为可能。


(以上是关于 ESM 的理论介绍, 原文连接在文末)。

Bundle & Bundleless

谈及 Bundleless 的优点,首先是启动快

由于不须要过多的打包,只须要处理修改后的单个文件,因此响应速度是 O(1) 级别,刷新便可即时生效,速度很快。

image.png

因此, 在开发模式下,相比于Bundle,Bundleless 有着巨大的优点。

基于 Webpack 的 bundle 开发模式

image.png
上面的图具体的模块加载机制能够简化为下图:
image.png
在项目启动和有文件变化时从新进行打包,这使得项目的启动和二次构建都须要作较多的事情,相应的耗时也会增加。

基于 ESModule 的 Bundleless 模式

image.png
从上图能够看到,已经再也不有一个构建好的 bundle、chunk 之类的文件,而是直接加载本地对应的文件。
image.png
从上图能够看到,在 Bundleless 的机制下,项目的启动只须要启动一个服务器承接浏览器的请求便可,同时在文件变动时,也只须要额外处理变动的文件便可,其余文件可直接在缓存中读取。

对比总结

image.png

Bundleless 模式能够充分利用浏览器自主加载的特性,跳过打包的过程,使得咱们能在项目启动时获取到极快的启动速度,在本地更新时只须要从新编译单个文件。

实现一个乞丐版 Vite

Vite 也是基于 ESM 的, 文件处理速度 O(1)级别, 很是快。

做为探索, 我就简单实现了一个乞丐版Vite:

GitHub 地址: Vite-mini

image.png

简要分析一下。

<body>
  <div id="app"></div>
  <script type="module" src="/src/main.js"></script>
</body>

html 文件中直接使用了浏览器原生的 ESM(type="module") 能力。

全部的 js 文件通过 vite 处理后,其 import 的模块路径都会被修改,在前面加上 /@modules/。当浏览器请求 import 模块的时候,vite 会在 node_modules 中找到对应的文件进行返回。

image.png

其中最关键的步骤就是模块的记载和解析, 这里我简单用koa简单实现了一下, 总体结构:

const fs = require('fs');
const path = require('path');
const Koa = require('koa');
const compilerSfc = require('@vue/compiler-sfc');
const compileDom = require('@vue/compiler-dom');
const app = new Koa();

// 处理引入路径
function rewriteImport(content) {
  // ...
}

// 处理文件类型等, 好比支持ts, less 等相似webpack的loader的功能
app.use(async (ctx) => {
  // ...
}

app.listen(3001, () => {
  console.log('3001');
});

咱们先看路径相关的处理:

function rewriteImport(content) {
    return content.replace(/from ['"]([^'"]+)['"]/g, function (s0, s1) {
        // import a from './c.js' 这种格式的不须要改写
        // 只改写须要去node_module找的
        if (s1[0] !== '.' && s1[0] !== '/') {
          return `from '/@modules/${s1}'`;
        }
        return s0;
    });
}

处理文件内容: 源码地址

image.png

后续的都是相似的:

image.png

这个代码只是解释实现原理, 不一样的文件类型处理逻辑其实能够抽离出去, 以中间件的形式去处理。

代码实现的比较简单, 就不额解释了。

Snowpack

image.png

使用 Snowpack 作了个demo, 支持打包, 输出 bundle。

github: Snowpack-react-demo

image.png

可以清晰的看到, 控制台产生了大量的文件请求(也叫瀑布网络请求),

不过由于都是加载的本地文件, 因此速度很快。

配合HMR, 实现编辑完成马上生效, 几乎不用等待:

image.png

可是若是是在生产中,这些请求对于生产中的页面加载时间而言, 就不太好了。

尤为是HTTP1.1,浏览器都会有并行下载的上限,大部分是5个左右,因此若是你有60个依赖性要下载,就须要等好长一点。

虽说HTTP2多少能够改善这问题,但如果东西太多,依然没办法。

关于这个项目的打包, 直接执行build:

image.png

打包完成后的文件目录,和传统的 webpack 基本一致:

image.png

在 build 目录下启动一个静态文件服务:

image.png

build 模式下,仍是借助了 webpack 的打包能力:

image.png

作了资源合并:

image.png

就这点而言, 我认为将来一段时间内, 生产环境仍是不可避免的要走bundle模式。

bundleless 模式在实际开发中的一些问题

开门见山吧, 开发体验不是很友好,几点比较突出的问题:

  • 部分模块没有提供 ESModule 的包。(这一点尤其致命)
  • 生态不够健全,工具链不够完善;

固然还有其余方方面面的问题, 就不一一列举。

我简单改造了一个页面, 就遇到不少奇奇怪怪的问题, 开发起来十分难受, 尽管代码的修改能马上生效。

结论

bundleless 能在开发模式下带了很大的便利, 但目前来讲, 还有一段路要走。

就目前而言, 若是要用的话,可能仍是 bundleless(dev) + bundle(production) 的组合。

至于将来能不能全面铺开 bundleless,我认为仍是有可能的, 交给时间吧。

结尾

本文主要介绍了esm 的原理, 以及介绍了以此为基础的Vite, Snowpack 等工具, 提供了两个可运行的 demo:

并探索了bundleless在生产中的可行性。

Bundleless 本质上是将原先 Webpack 中模块依赖解析的工做交给浏览器去执行,使得在开发过程当中代码的转换变少,极大地提高了开发过程当中的构建速度,同时也能够更好地利用浏览器的相关开发工具。

很是感谢 ESModule、Vite、Snowpack 等标准和工具的出现,为前端开发提效。

才疏学浅, 文中如有错误,还能各位大佬指正, 谢谢。

参考资料

  1. https://hacks.mozilla.org/201...
  2. https://developer.aliyun.com/...
相关文章
相关标签/搜索