从vite看打包工具内卷史

·  阅读 1674
从vite看打包工具内卷史

从什么时候开始,我们的打包工具也开始内卷了
从gulp到webpack到rollup esbulid 到vite 眼花缭乱

gulp

emmm,让我想想,我第一次用打包工具是为什么

那是15年长沙的一个夏天 6365f1ae-56d2-45e8-97b8-a83f4b0e7573.png
测试同事反馈我移测的页面,很多手机访问直接报错
我发现是浏览器兼容性问题,让我用旧代码重写是不可能重写的
于是我找到了gulp-babel,转换成es5之后解决了问题

好吧,我承认我当初只是为了解决问题而使用打包工具的
不过我慢慢发现只是用来解决兼容性问题太浪费了
于是我又加上了gulp-uglify,gulp-minify-css.....

gulp提供了一套简便的文件流处理方案,
配合上社区丰富的gulp插件,能实现非常多的前端文件转换处理
虽说要自己从入口开始写gulp代码,但在前端项目不复杂的情况下非常适用

遇到的问题

可自从 react、vue 等前端工程化项目出现之后
gulp 也渐渐难以应付如此复杂的打包工程了
为什么呢,因为 gulp 难以处理项目中各个资源之间的依赖
在gulp的工作流中,各个页面,资源都被认为是独立的

当然你也可以自己编写代码处理……不过复杂度太高了

webpack

万物皆模块 image.png

于是乎,随着前端模块化的兴起,模块化打包工具也应运而生
webpack就是一个模块打包器,它会把一切资源都当做模块
从entry开始构建,根据webpack配置的Loader将对应类型的文件递归编译
在其中各个生命周期还会根据配置的plugin进行处理

之后通过acorn生成AST,递归AST之后
就能构建一个moudules数组
每个module会记录对应的依赖

有了这些之后,我们就能生成chunk输出文件打包完毕。

然后我们启用devserver,开始本地开发调试
由于webpack自身运行时也打包进了项目,
并且重写了 require、import ,
每个模块都能从入口开始在浏览器内顺利的运行了。

遇到的问题

可以看到,在webpack里,是先需要编译完所有代码之后
才能开始启动服务
因为项目内的所有模块,引用,依赖都是需要webpack接管的

当然,我们可以用externals,webpack dll等进行部分优化 但是也只能解决部分问题,编译不可避免

但是目前在开发过程中,随着项目迭代越大
我们发现打包工具启动和更新越来越慢了

Unbundled

随着浏览器的更新开始支持原生ESModule,
我们再想,有没有这样一种可能,不用编译打包我们的项目
直接就能在浏览器中运行我们的前端工程化项目呢

可行,却又不是完全可行
(treesharking、chunk 分割、懒加载等等..显然原生目前无法直接支持)

但至少我们能在开发环境做一些尝试
所以一系列的Unbundled工具诞生了,vite就是其中一个

vite

vite主要做了两件事情来解决我们开发过程中项目启动与更新慢的问题

vite主要侧重点都在开发环境,也是区分于其他打包工具最大的方面

  1. unbundled,就是最重要的,不先编译构建整个项目才能启动,而是按需构建,只有访问到的时候才转换
  2. 以原生 ESM 方式提供源码,让现代浏览器承载部分打包与模块加载工作

这两张图可以看到他们的区别:

image.png

image.png

那么是不是我们只要启动一个本地服务,然后再HTTP Request 阶段进行按需构建就行了呢?

还不行,首先我们npm引入的包,都是基于CommonJS规范的,需要处理
其次,类似JSX,.vue 文件也需要对应的处理器才行

我们来看看vite是如何具体实现的

使用esbuild预构建依赖

上面说到vite不会先编译构建整个项目,为什么还有一个预构建呢?
看看官方的解释

严格来说,如果你所有的依赖都是esmoudle的代码,那这一步确实可以省略
但目前我们的包都是CommonJS的,所以需要转换为ESM
不过vite使用esbuild进行预构建,比以 JavaScript 编写的打包器预构建依赖快 10-100 倍。

还有一点,vite并不会一开始就把全部依赖都预构建,如果遇到一个新的依赖关系导入,
而这个依赖关系还没有在缓存中,Vite 将重新运行依赖构建进程并重新加载页面。

性能: Vite 将有许多内部模块的 ESM 依赖关系转换为单个模块,以提高后续页面加载性能。

例如,lodash-es 有超过 600 个内置模块!当我们执行 import { debounce } from 'lodash-es' 时,浏览器同时发出 600 多个 HTTP 请求!尽管服务器在处理这些请求时没有问题,但大量的请求会在浏览器端造成网络拥塞,导致页面的加载速度相当慢。

通过预构建 lodash-es 成为一个模块,我们就只需要一个 HTTP 请求了!

我们起一个demo来看看,执行vite命令后:

image.png

node_modules/.vite 目录很快就生成好了预构建的文件

image.png

并且vite会缓存预构建的文件,下次再冷启动时直接使用缓存

image.png

缓存

它根据几个源来决定是否需要重新运行预构建步骤:
package.json 中的 dependencies 列表
包管理器的 lockfile,例如 package-lock.json, yarn.lock,或者 pnpm-lock.yaml
可能在 vite.config.js 相关字段中配置的
只有在上述其中一项发生更改时,才过需要重新运行预构建。

浏览器缓存

解析后的依赖请求会以 HTTP 头 max-age=31536000,immutable 强缓存,以提高在开发时的页面重载性能。一旦被缓存,这些请求将永远不会再到达开发服务器。如果安装了不同的版本(这反映在包管理器的 lockfile 中),则附加的版本 query 会自动使它们失效。

源码分析

好了,预构建完成了,那么接下来就到了按需构建环节
我们根据 vite 入口去看看它的实现细节

可以看到,vite通过connect启动了一个Http服务

本文的vite版本为:2.5.1,vite更新很频繁,之前的版本是用Koa作为http server 2.5.1版本的源码比较好阅读

export async function resolveHttpServer(
  { proxy }: ServerOptions,
  app: Connect.Server,
  httpsOptions?: HttpsServerOptions
): Promise<HttpServer> {
  // app :传入的 connect对象
  if (!httpsOptions) {
    return require('http').createServer(app)
  }
    
  // 配置代理
  if (proxy) {
    // #484 fallback to http1 when proxy is needed.
    return require('https').createServer(httpsOptions, app)
  } else {
    return require('http2').createSecureServer(
      {
        ...httpsOptions,
        allowHTTP1: true
      },
      app
    )
  }
}

复制代码

加载了很多中间件,其中我们需要关注的是 transformMiddleware

// connect
const middlewares = connect() as Connect.Server
....
// 处理一些非原始路径
middlewares.use(baseMiddleware(server))
// 使用sirv处理静态文件
middlewares.use(servePublicMiddleware(config.publicDir))
middlewares.use(serveStaticMiddleware(root, config))
middlewares.use(serveRawFsMiddleware(server))

// vite的主要转换逻辑处理
middlewares.use(transformMiddleware(server))

if (!middlewareMode && httpServer) {
let isOptimized = false
// overwrite listen to run optimizer before server start
const listen = httpServer.listen.bind(httpServer)
httpServer.listen = (async (port: number, ...args: any[]) => {
  if (!isOptimized) {
    try {
      await container.buildStart({})
      await runOptimize()
      isOptimized = true
    } catch (e) {
      httpServer.emit('error', e)
      return
    }
  }
  return listen(port, ...args)
}) as any
} else {
await container.buildStart({})
await runOptimize()
}

....

复制代码

transformMiddleware

export function transformMiddleware(
  server: ViteDevServer
): Connect.NextHandleFunction {
 try {
        ...
        // 转换请求的资源
        const result = await transformRequest(url, server, {
          html: req.headers.accept?.includes('text/html')
        })
        if (result) {
          const type = isDirectCSSRequest(url) ? 'css' : 'js'
          const isDep =
            DEP_VERSION_RE.test(url) ||
            (cacheDirPrefix && url.startsWith(cacheDirPrefix))
          return send(
            req,
            res,
            result.code,
            type,
            result.etag,
            // allow browser to cache npm deps!
            isDep ? 'max-age=31536000,immutable' : 'no-cache',
            result.map
          )
        }
    } catch (e) {
      return next(e)
 }
 
 ...

export async function transformRequest(
  url: string,
  server: ViteDevServer,
  options: TransformOptions = {}
): Promise<TransformResult | null> {

  ...
   // transform
  const transformStart = isDebug ? Date.now() : 0
  // 注意看这里
  // 最终执行的是  pluginContainer.transform  
  // 而pluginContainer就是我们vite.config.js 里的 plugins 配置
  // 就vue 项目而言,它就是 @vitejs/plugin-vue
  // 当然vite内还预制了很多内部的插件,是无需我们配置的
  const transformResult = await pluginContainer.transform(code, id, map, ssr)
  ...

}
复制代码

可以发现,就.vue文件而言,最终执行转换的是 @vitejs/plugin-vue
我们再去看看它的transform方法

...

// vue 解析器
// src/compiler.ts
var compiler;
try {
  compiler = require("vue/compiler-sfc");
} catch (e) {
  try {
    compiler = require("@vue/compiler-sfc");
  } catch (e2) {
    throw new Error(`@vitejs/plugin-vue requires vue (>=3.2.13) or @vue/compiler-sfc to be present in the dependency tree.`);
  }
}

...

transform(code, id, opt) {
      const ssr = isSSR(opt);
      const { filename, query } = parseVueRequest(id);
      if (query.raw) {
        return;
      }
      // 如果不是vue文件,就跳出,交给下个中间件
      if (!filter(filename) && !query.vue) {
        if (!query.vue && refTransformFilter(filename)) {
          if (!canUseRefTransform) {
            this.warn("refTransform requires @vue/compiler-sfc@^3.2.5.");
          } else if (compiler.shouldTransformRef(code)) {
            return compiler.transformRef(code, {
              filename,
              sourceMap: true
            });
          }
        }
        return;
      }
      if (!query.vue) {
      // 转换主体script
        return transformMain(code, filename, options, this, ssr, customElementFilter(filename));
      } else {
        const descriptor = getDescriptor(filename, options);
        if (query.type === "template") {
          // 转换template
          return transformTemplateAsModule(code, descriptor, options, this, ssr);
        } else if (query.type === "style") {
          // 转换style
          return transformStyle(code, descriptor, Number(query.index), options, this);
        }
      }
    }
复制代码

可以看到,这里和webpack的vue-loader类似
可以分别转换出script template style 最终输出给浏览器

这里我们梳理一下:

  • 首先通过transformIndexHtml转换html文件,处理所有的script标签,然后加入vite的client端
<script type="module" src="[/@vite/client](http://localhost:3000/@vite/client)"></script>
复制代码
  • 通过内置的 importAnalysisPlugin 插件,转换所有 js 代码内的 import 路径
  • 接着 .vue 的请求由 @vitejs/plugin-vue 转换输出对应的代码
  • 其余静态文件由 StaticMiddleware 接管

于是在启动服务之后,我们访问index.html开始
支持原生ESM的浏览器就开始接管了部分打包,加载工作
所以我们才能在开发阶段拥有如此流畅的体验

生产打包

好了,愉快的开发已经做完了,我们需要打包投产了
Vite开发环境的这种模式很显然不能直接用于生产

  • 未打包的ESM模块未经过任何处理,文件很大
  • 会产生多次http请求网络往返
  • 最重要的,没有进行进行 tree-shaking、懒加载和 chunk 分割

所以,vite在生产环境必须用另一套模式进行打包

我们来run build 跑一下

image.png

可以看到,打包后和 webpack 打包的结果类似
其实vite的打包用的是  Rollup

默认情况下,Vite 的目标浏览器是指能够 支持原生 ESM script 标签 和 支持原生 ESM 动态导入 的
Vite 使用这个 browserslist 作为查询标准:

defaults and supports es6-module and supports es6-module-dynamic-import, not opera > 0, not samsung > 0, not and_qq > 0
复制代码

由于vite在开发环境和生产是采用的两套不同的构建模式
要确保开发服务器和生产环境构建之间的最优输出和行为一致并不容易。
所以 Vite 附带了一套 构建优化 的 构建命令,开箱即用。
vite配置在项目vite.config.js内,默认它是尽可能的简便
由于build采用rollup打包,如果有特殊需求,可以使用rollup配置

不过要记住的是,vite做的默认构建优化

  • CSS 代码分割 Vite 会自动地将一个异步 chunk 模块中使用到的 CSS 代码抽取出来并为其生成一个单独的文件。这个 CSS 文件将在该异步 chunk 加载完成时自动通过一个 标签载入,该异步 chunk 会保证只在 CSS 加载完毕后再执行,避免发生 FOUC 。

  • 预加载指令生成 Vite 会为入口 chunk 和它们在打包出的 HTML 中的直接引入自动生成 指令。

  • 异步 Chunk 加载优化 Vite  将使用一个预加载步骤自动重写代码,来分割动态导入调用。会跟踪所有的直接导入,无论导入的深度如何,都能够完全消除不必要的往返。


结语

总的来说,vite是一款在当前环境下优秀的开发构建、打包工具

从底层逻辑上就比目前流行的打包工具性能更加优异,既可以做到开箱即用
也能深入细节配置优化,尤其是在开发环境带来的流畅体验

不过还是将来期待有浏览器完全支持前端工程化项目的一天,那就可以和打包说拜拜了

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改