Vite: 如何不使用 webpack 开发应用

3,117 阅读9分钟

前言

因为公司目前是React技术栈,所以很久没有关注Vue的动态,最近看了一下尤雨溪 - 聊聊 Vue.js 3.0 Beta 官方直播[1], 看到快结束的时候大佬推荐了一个他自己写的"小玩意": Vite

Vite: 法语: "快"的意思,它是一个http服务器,作用就是不需要使用webpack打包就可以开发应用,而且支持超级快速的热更新的功能(因为webpack的热更新需要重新打包)

突然就对这个非常感兴趣,而且这可能是未来的一种趋势,所以在项目不是太庞大的时候去了解一下它的内部原理就会更加简单一些。而且阅读他人优秀开源项目源码的过程可以学到很多平时工作学不到甚至看不到的东西

ps: 这篇文章只对如何不用webpack开发项目做尽可能详细的解析理解,至于热更新则在下一篇文章阐述,Vite版本为1.0.0-rc.4

创建项目

首先按照以下步骤做好项目的调试准备工作:

  1. 打开 Vite GitHub[2], 拷贝项目到本地。
  2. 根目录创建example文件夹,执行 yarn create vite-app <project-name> 创建一个基础的脚手架并安装依赖
  3. example/package.json中添加以下命令
{
  "scripts": {
    "dev": "node ../dist/node/cli.js",
    "dev:debug": "set DEBUG=* & node ../dist/node/cli.js" // 启动 debug
  }
}
  1. 根目录以及example分别执行 yarn dev, 在用浏览器打开http://localhost:3000就会看到以下页面

前置知识

Vite重度依赖module sciprt的特性,因此需要提前做下功课,参考:JavaScript modules 模块 - MDN。[3]

module sciprt允许在浏览器中直接运行原生支持模块

<script type="module">
    // index.js可以通过export导出模块,也可以在其中继续使用import加载其他依赖
    import App from './index.js'
</script>

当遇见import依赖时,会直接发起http请求对应的模块文件,但是并不支持如import { createApp } from 'vue' 这样的引用,具体怎么支持它下面会说

开始

启动服务器

打开项目就知道Vite是使用Koa创建http服务器, 从src/node/cli.js开始

const server = require('./server').createServer(options)

而转到 server/index.js 下,通过中间件的方式给它加入其它的处理, 这里展示跟本文相关的几个中间件

  const context = { app };

  const resolvedPlugins = [
    moduleRewritePlugin,  //解析js
    htmlRewritePlugin, //解析 html
    moduleResolvePlugin, //获取依赖加载内容
    vuePlugin, //解析vue文件
    cssPlugin, //解析css
    serveStaticPlugin //静态配置
  ];
  resolvedPlugins.forEach((m) => m && m(context))

静态配置

定位到server/serverPluginServeStatic.ts文件,为了默认解析index.html的以及其他静态文件的内容,添加了一些配置,比较简单,直接贴代码:

const send = require('koa-send')
...
app.use(require('koa-static')(root))
app.use(require('koa-static')(path.join(root, 'public')))
...
// root指向 example 目录
await send(ctx, `index.html`, { root })

重写引入库名称

上面说过module sciprt不支持import { createApp } from 'vue', 而对于这样npm库的应用,Vite内部做了特殊处理,首先打开我们启动的项目

原本的入口文件是这样的

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

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

而经过处理后会转换成:

可以看到 vue 转成了 /@modules/vue.js, 下面来说一下这一块经历的过程:

  1. 首先根据上面返回的 index.html 文件,转到server/serverPluginHtml.ts(只保留重要代码)
app.use(async (ctx, next) => {
  ...
  // 判断返回的时候是 .html
  if (ctx.response.is('html') && ctx.body) {
    const importer = ctx.path
    // 获取 html 内容
    const html = await readBody(ctx.body)
    // 是否有缓存
    if (rewriteHtmlPluginCache.has(html)) {
      ...
    } else {
      if (!html) return
      // 重点:重写 .html 文件内容
      ctx.body = await rewriteHtml(importer, html)
    }
    return
  }
})

const scriptRE = /(<script\b[^>]*>)([\s\S]*?)<\/script>/gm
async function rewriteHtml(importer: string, html: string) {
  html = html!.replace(scriptRE, (matched, openTag, script) => {
    // 如果 script 不是 src 引入的, 则直接执行 替换 js 引入的操作:rewriteImports
    if (script) {
      return `${openTag}${rewriteImports(
        root,
        script,
        importer,
        resolver
      )}</script>`
    }
  })
  return injectScriptToHtml(html, devInjectionCode)
}
  1. 根据上面的代码,如果script module是使用src引入,则转到 serverPluginModuleRewrite.ts, 这块很简单,也是直接调用rewriteImports方法进行重写
 app.use(async (ctx, next) => {
    ...
    const publicPath = ctx.path
    if (
      ctx.body &&
      ctx.response.is('js') && ...
    ) {
      ctx.body = rewriteImports(
        root,
        content!,
        importer,
        resolver,
        ctx.query.t
      )
    }
}

rewriteImports

这里是整个逻辑的重点,主要是重写 import引入,原理就是使用 es-module-lexer 解析 import, 再通过正则匹配符合条件的引入,之后进行重写

import MagicString from 'magic-string'
import { init as initLexer, parse as parseImports } from 'es-module-lexer'

export function rewriteImports(...) {
   // 解析 import
   imports = parseImports(source)[0]
   ...
   const s = new MagicString(source)
   for (let i = 0; i < imports.length; i++) {
      const { s: start, e: end, d: dynamicIndex } = imports[i]
      // id 即为引入的库名称,这里可以当做是 vue
      let id = source.substring(start, end)
      ...
      // 这里 resolveImport 的作用就是 添加 /@modules/
      const resolved = resolveImport(...)
  }
  // 重写 .js
  s.overwrite(
    start,
    end,
    hasLiteralDynamicId ? `'${resolved}'` : resolved
  )
}

resolveImport

这里关于 resolveImport 要说一下一个细节:在应用中即使不安装 vue 依赖也是可以执行的,因为Vite已经默认安装了vue,这里有个特殊的处理

const bareImportRE = /^[^\/\.]/
// 这里 id == vue
if (bareImportRE.test(id)) {
  id = `/@modules/${resolveBareModuleRequest(root, id, importer, resolver)}`
}

export function resolveBareModuleRequest(...): string {
  const optimized = resolveOptimizedModule(root, id)
  if (optimized) {
    return path.extname(id) === '.js' ? id : id + '.js'
  }
}
  • resolveOptimizedModule会查找package.json中是否安装依赖并缓存起来。optimized返回true
  • 如果未安装则在vitenode_modules中查找入口文件,并缓存 & 返回

解析代码

上文中修改了引入的 js 文件后会再次发起请求,这时候就转到serverPluginModuleResolve中间件下, 主要就是找到相关代码文件位置,读取并返回

const moduleRE = /^\/@modules\//
// 获取 vue 各个模块代码对应的文件路径
const vueResolved = resolveVue(root)
app.use(async (ctx, next) => {
  if (!moduleRE.test(ctx.path)) {
    return next()
  }
  // 去掉 /@modules
  const id = decodeURIComponent(ctx.path.replace(moduleRE, ''))
  // 读取文件并返回
  const serve = async (id: string, file: stringtypestring) => {
    ...
    await ctx.read(file)
    return next()
  }

  // isLocal 表示项目是否安装 vue
  if (!vueResolved.isLocal && id in vueResolved) {
    return serve(id, (vueResolved as any)[id], 'non-local vue')
  }
  ...
}

处理 Vue 文件

如何处理 .vue 文件也是这个项目的重点,这次转到serverPluginVue中间件下面,先看一下 .vue 处理后的样子

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

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

import "/src/App.vue?type=style&index=0"
import {render as __render} from "/src/App.vue?type=template"
__script.render = __render
__script.__hmrId = "/src/App.vue"
__script.__file = "XXX\\vite\\example\\src\\App.vue"
export default __script

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

主要是是根据 URL 的 query 参数来做不同的处理(简化分析如下):

const query = ctx.query
// 如果没有 query 的 type,比如直接请求的 /App.vue
if (!query.type) {
  // 这里首先对 script 做了处理,之后生成 template 和 stytle 链接
  const { code, map } = await compileSFCMain(
    descriptor,
    filePath,
    publicPath,
    root
  )
  ctx.body = code
  ctx.map = map
  return etagCacheCheck(ctx)
}

if (query.type === 'template') {
  // 生成 render function 并返回
  const { code, map } = compileSFCTemplate({ ... })
  ctx.body = code
  ctx.map = map
  return etagCacheCheck(ctx)
}

if (query.type === 'style') {
  // 生成 css 文件
  const result = await compileSFCStyle(...)
  ctx.type = 'js'
  ctx.body = codegenCss(`${id}-${index}`, result.code, result.modules)
  return etagCacheCheck(ctx)
}

小结

总体看下来主要的逻辑理解起来并不复杂,但可能有很多细节的操作需要仔细琢磨,主要就分为以下几步操作

  • script module 引入
  • imports 替换
  • 解析引入路径并再次请求 -> 返回

虽然Vite目前还不能大规模的生产环境推广,但现在一直在以惊人的速度迭代着,未来也很有可能是一种大趋势。不管怎么说,在项目还未过度复杂的情况下快速阅读代码并了解作者的设计思想和意图,对自己也是一个巨大的提升

参考资料

[1]

尤雨溪 - 聊聊 Vue.js 3.0 Beta 官方直播: https://m.bilibili.com/video/BV1Tg4y1z7FH

[2]

Vite GitHub: https://github.com/vitejs/vite

[3]

JavaScript modules 模块: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Modules