有了 vite,还需要 webpack 么?

8,627 阅读7分钟

原创声明:本文首发于公众号:前端琐话(qianduansuohua),欢迎关注

前言

前两天尤大在 vue 3.0 beta 直播中提到了一个 vite 的工具,而且还发推表示再也回不去 webpack 了, 还引来了 webpack 核心开发人员肖恩的搞笑回复, 那就让我们一起来看一下 vite 到底有啥魔力?

什么是 Vite?

github:github.com/vitejs/vite

Vite 是一个由原生 ESM 驱动的 Web 开发构建工具。在开发环境下基于浏览器原生 ES imports 开发,在生产环境下基于 Rollup 打包。

它主要具有以下特点:

  • 快速的冷启动
  • 即时的模块热更新
  • 真正的按需编译

那废话少说,我们先直接来试用一下。

$ npm init vite-app <project-name>
$ cd <project-name>
$ npm install
$ npm run dev

我们来看下生成的代码, 因为 vite 尝试尽可能多地镜像 vue-cli 中的默认配置, 所以我们会发现看上去和 vue-cli 生成的代码没有太大区别。

├── index.html
├── package.json
├── public
│   └── favicon.ico
└── src
    ├── App.vue
    ├── assets
    │   └── logo.png
    ├── components
    │   └── HelloWorld.vue
    ├── index.css
    └── main.js

那我们看下入口 index.html 和 main.js

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <link rel="icon" href="/favicon.ico" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vite App</title>
</head>
<body>
  <div id="app"></div>
  <script type="module" src="/src/main.js"></script>
</body>
</html>

// main.js
// 只是引用的是最新的 vue3 语法,其余没有啥不同
import { createApp } from 'vue'
import App from './App.vue'
import './index.css'

createApp(App).mount('#app')

发现主要的不同在于多了这么个东西

<script type="module" src="/src/main.js"></script>

那下面我们就来看下这是个啥?

原理

ESM

script module 是 ES 模块在浏览器端的实现,目前主流的浏览器都已经支持

其最大的特点是在浏览器端使用 exportimport 的方式导入和导出模块,在 script 标签里设置 type="module"

<script type="module">
  import { createApp } from './main.js‘;
  createApp();
</script>

浏览器会识别添加 type="module"<script> 元素,浏览器会把这段内联 script 或者外链 script 认为是 ECMAScript 模块,浏览器将对其内部的 import 引用发起 http 请求获取模块内容。 在 main.js 里,我们用 named export 导出 createApp 函数,在上面的 script 中能获取到该函数

// main.js
export function createApp(){
    console.log('create app!');
};

其实到这里,我们基本可以理解 vite 宣称的几个特性了。

  • webpack 之类的打包工具为了在浏览器里加载各模块,会借助胶水代码用来组装各模块,比如 webpack 使用 map 存放模块 id 和路径,使用 webpack_require 方法获取模块导出,vite 利用浏览器原生支持模块化导入这一特性,省略了对模块的组装,也就不需要生成 bundle,所以 冷启动是非常快的
  • 打包工具会将各模块提前打包进 bundle 里,但打包的过程是静态的——不管某个模块的代码是否执行到,这个模块都要打包到 bundle 里,这样的坏处就是随着项目越来越大打包后的 bundle 也越来越大。而 ESM 天生就是按需加载的,只有 import 的时候才会去按需加载

看到这里是不是会好奇那 vite 到底做了什么,我们直接用浏览器的 ESM 不就好了,那我们就来试试。

Vite 运行

提供 web server

我们在刚才生成的代码库里,不通过 npm run dev 来启动项目,直接通过浏览器打开 index.html, 会看到下面一个报错

在浏览器里使用 ES module 是使用 http 请求拿到模块,所以 vite 的一个任务就是启动一个 web server 去代理这些模块,vite 里是借用了 koa 来启动了一个服务

export function createServer(config: ServerConfig): Server {
  // ...
  const app = new Koa<State, Context>()
  const server = resolveServer(config, app.callback())
  
  // ...
  const listen = server.listen.bind(server)
  server.listen = (async (...args: any[]) => {
    if (optimizeDeps.auto !== false) {
      await require('../optimizer').optimizeDeps(config)
    }
    return listen(...args)
  }) as any
  
  return server
}

模块解析

那我们就在本地起一个静态服务,再来打开一下 index.html 来看下

大概意思是说,找不到模块 vue,"/", "./", or "../"开头的 import 路径,才是合法的。

import vue from 'vue'

也就是说浏览器中的 ESM 是获取不到导入的模块内容的,平时我们写代码,如果不是引用相对路径的模块,而是引用 node_modules 的模块,都是直接 import xxx from 'xxx',由 Webpack 等工具来帮我们找这个模块的具体路径进行打包。但是浏览器不知道你项目里有 node_modules,它只能通过相对路径或者绝对路径去寻找模块。

那这就引出了 vite 的一个实现核心 - 拦截浏览器对模块的请求并返回处理后的结果

我们来看下 vite 是怎么处理的?

/@module/前缀

通过工程下的 main.js 和开发环境下的实际加载的 main.js 对比,发现 main.js 内容发生了改变,由

import { createApp } from 'vue'
import App from './App.vue'
import './index.css'

createApp(App).mount('#app')

变成了

import { createApp } from '/@modules/vue.js'
import App from '/src/App.vue'
import '/src/index.css?import'

createApp(App).mount('#app')

为了解决 import xxx from 'xxx' 报错的问题,vite 对这种资源路径做了一个统一的处理,加一个/@module/前缀。 我们在 src/node/server/serverPluginModuleRewrite.ts 源码这个 koa 中间件里可以看到 vite 对 import 都做了一层处理,其过程如下:

  • 在 koa 中间件里获取请求 body
  • 通过 es-module-lexer 解析资源 ast 拿到 import 的内容
  • 判断 import 的资源是否是绝对路径,绝对视为 npm 模块
  • 返回处理后的资源路径:"vue" => "/@modules/vue"

支持 /@module/

/src/node/server/serverPluginModuleResolve.ts 里可以看到大概的处理逻辑是

  • 在 koa 中间件里获取请求 body
  • 判断路径是否以 /@module/ 开头,如果是取出包名
  • 去node_module里找到这个库,基于 package.json 返回对应的内容

文件编译

上面我们提到的是对普通 js module 的处理,那对于其他文件,比如 vuecssts等是如何处理的呢?

我们以 vue 文件为例来看一下,在 webpack 里我们是使用的 vue-loader 对单文件组件进行编译,实际上 vite 同样的是拦截了对模块的请求并执行了一个实时编译。

通过工程下的 App.vue 和开发环境下的实际加载的 App.vue 对比,发现内容发生了改变

原本的 App.vue

<template>
  <img alt="Vue logo" src="./assets/logo.png" />
  <HelloWorld msg="Hello Vue 3.0 + Vite" />
</template>

<script>
import HelloWorld from './components/HelloWorld.vue';

export default {
  name: 'App',
  components: {
    HelloWorld,
  },
};
</script>
<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

变成了

import HelloWorld from '/src/components/HelloWorld.vue';

const __script = {
    name: 'App',
    components: {
        HelloWorld,
    },
};

import "/src/App.vue?type=style&index=0&t=1592811240845"
import {render as __render} from "/src/App.vue?type=template&t=1592811240845"
__script.render = __render
__script.__hmrId = "/src/App.vue"
__script.__file = "/Users/wang/qdcares/test/vite-demo/src/App.vue"
export default __script

这样就把原本一个 .vue 的文件拆成了三个请求(分别对应 script、style 和template) ,浏览器会先收到包含 script 逻辑的 App.vue 的响应,然后解析到 template 和 style 的路径后,会再次发起 HTTP 请求来请求对应的资源,此时 Vite 对其拦截并再次处理后返回相应的内容。

// App.vue?type=style
import { updateStyle } from "/vite/hmr"
const css = "\n#app {\n  font-family: Avenir, Helvetica, Arial, sans-serif;\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n  text-align: center;\n  color: #2c3e50;\n  margin-top: 60px;\n}\n"
updateStyle("7ac74a55-0", css)
export default css
// App.vue?type=template
import {createVNode as _createVNode, resolveComponent as _resolveComponent, Fragment as _Fragment, openBlock as _openBlock, createBlock as _createBlock} from "/@modules/vue.js"

const _hoisted_1 = /*#__PURE__*/
_createVNode("img", {
    alt: "Vue logo",
    src: "/src/assets/logo.png"
}, null, -1 /* HOISTED */
)

export function render(_ctx, _cache) {
    const _component_HelloWorld = _resolveComponent("HelloWorld")

    return (_openBlock(),
    _createBlock(_Fragment, null, [_hoisted_1, _createVNode(_component_HelloWorld, {
        msg: "Hello Vue 3.0 + Vite"
    })], 64 /* STABLE_FRAGMENT */
    ))
}

实际上在看到这个思路之后,对于其他的类型文件的处理几乎都是类似的逻辑,根据请求的不同文件类型,做出不同的编译处理。

实际上 vite 就是在按需加载的基础上通过拦截请求实现了实时按需编译

后语

到这里我们实际上就基本了解了 vite 的原理,虽然在目前的生态下,完全替代 webpack 还不可能,但毕竟是一种的新的解决方案的探索。 而实际上,除了 vite, 社区里类似的方案还有 snowpack, 有兴趣的可以去了解一下。