初探Vite2

2,055 阅读4分钟

Vite

Vite (法语意为 "快速的",发音 /vit/) 是一种新型前端构建工具,能够显著提升前端开发体验。

特点

  • 快速的冷启动(将应用中的模块区分为 依赖源码 两类,改进了开发服务器启动时间。源码模块的请求会根据 304 Not Modified 进行协商缓存,而依赖模块请求则会通过 Cache-Control: max-age=31536000,immutable 进行强缓存)
  • 即时的模块热更新 HMR 是在原生 ESM 上执行的。当编辑一个文件时,Vite 只需要精确地使已编辑的模块与其最近的 HMR 边界之间的链失效(大多数时候只需要模块本身),使 HMR 更新始终快速,无论应用的大小。
  • 真正的按需加载 Vite 只需要在浏览器请求源码时进行转换并按需提供源码。根据情景动态导入的代码,即只在当前屏幕上实际使用时才会被处理。

基于打包器的开发服务器

基于 ESM 的开发服务器

主要组成部分

  • 一个开发服务器:基于 原生 ES 模块 提供了 丰富的内建功能
    • npm依赖解析和预构建。原生 ES 引入不支持裸模块导入,Vite 将在服务的所有源文件中检测此类裸模块导入,并通过esbuild预构建它们以提升页面重载速度,重写导入为合法的 URL,例如 /node_modules/.vite/my-dep.js?v=f3sf2ebd 以便浏览器能够正确导入它们。
    • 依赖是强缓存的。
    • 支持模块热重载
    • 支持typescript
    • 为 Vue 提供第一优先级支持:Vue 3 单文件组件支持:@vitejs/plugin-vue、Vue 3 JSX 支持:@vitejs/plugin-vue-jsx、Vue 2 支持:underfin/vite-plugin-vue2
    • 支持JSX
    • css支持@import内联和重命名、接受postcss.config.js配置、支持CSS Modules、支持css预处理器.scss .sass .less .styl .stylus
    • 导入一个静态资源会返回解析后的 URL
    • json文件支持具名导入
    • 支持Glob导入
    • 支持Web Assembly
    • web worker 脚本可以直接通过添加一个 ?worker 查询参数来导入。默认导出将是一个自定义的 worker 构造器。worker 脚本也可以使用 import 语句来替代 importScripts()(目前只在 Chrome 中适用,而在生产版本中,它已经被编译掉了)
  • 一套构建指令:使用 Rollup 打包代码,并且是预配置的,可以输出用于生产环境的优化过的静态资源。
    • 对动态导入的Polyfill Vite 自动会生成一个轻量级的 对动态导入的 polyfill
    • css代码分割 Vite 会自动地将一个异步 chunk 模块中使用到的 CSS 代码抽取出来并为其生成一个单独的文件。这个 CSS 文件将在该异步 chunk 加载完成时自动通过一个 <link> 标签载入
    • 预加载指令生成 Vite 会为入口 chunk 和它们在打包出的 HTML 中的直接引入自动生成 <link rel="modulepreload"> 指令。
    • 异步加载chunk优化 优化将跟踪所有的直接导入,无论导入深度如何,都完全消除不必要的往返。

兼容性

项目搭建

#npm
npm init @vitejs/app

#yarn
yarn create @vitejs/app
# npm 6.x
npm init @vitejs/app [project-name] --template vue

# npm 7+, 需要额外的双横线:
npm init @vitejs/app [project-name] -- --template vue

# yarn
yarn create @vitejs/app [project-name] --template vue

支持的模板预设包括:

  • vanilla
  • vue
  • vue-ts
  • react
  • react-ts
  • preact
  • preact-ts
  • lit-element
  • lit-element-ts
  • svelte
  • svelte-ts

index.html

在开发期间 Vite 是一个服务器,而 index.html 是该 Vite 项目的入口文件

Vite 将 index.html 视为源码和模块图的一部分。Vite 解析 <script type="module" src="..."> ,这个标签指向你的 JavaScript 源码。甚至内联引入 JavaScript 的 <script type="module" src="..."> 和引用 CSS 的 <link href> 也能利用 Vite 特有的功能被解析。另外,index.html 中的 URL 将被自动转换,因此不再需要 %PUBLIC_URL% 占位符了。

启动命令

{
  "scripts": {
    "dev": "vite", // 启动开发服务器
    "build": "vite build", // 为生产环境构建产物
    "serve": "vite preview" // 本地预览生产构建产物
  }
}

常用配置

默认配置文件vite.config.js,可以显示的通过--config指定配置文件

vite --config my-config.js

Vite 也直接支持 TS 配置文件。你可以在 vite.config.ts 中使用 defineConfig 帮手函数。

如果配置文件需要基于(servebuild)命令或者不同的 模式 来决定选项,则可以选择导出这样一个函数:

export default ({ command, mode }) => {
  if (command === 'serve') {
    return {
      // serve 独有配置
    }
  } else {
    return {
      // build 独有配置
    }
  }
}

如果配置需要调用一个异步函数,也可以转而导出一个异步函数:

export default async ({ command, mode }) => {
  const data = await asyncFunction()
  return {
    // 构建模式所需的特有配置
  }
}

共享配置

配置项类型默认值说明
basestring'/'开发或生产环境服务的公共基础路径
plugins(PluginOption|PluginOption[])[]
publicDirstring'public'作为静态资源服务的文件夹。这个目录中的文件会在开发中被服务于 /,在构建模式时,会被拷贝到 outDir 的根目录,并没有转换,永远只是复制到这里。
cacheDirstring'node_modules/.vite'存储预打包的依赖项或 vite 生成的某些缓存文件
resolve.alias
resolve.extensionsstring[]['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json'] 建议忽略自定义导入类型的扩展名(例如:.vue),因为它会干扰 IDE 和类型支持。
json.namedExportsbooleantrue是否支持从 .json 文件中进行按名导入。
json.stringifybooleanfalse若设置为 true,导入的 JSON 会被转换为 export default JSON.parse("...") 会比转译成对象字面量性能更好,尤其是当 JSON 文件较大的时候。开启此项,则会禁用按名导入。

Server Options

配置项类型说明
server.hoststring指定服务器主机名
server.portnumber指定服务器端口。注意:如果端口已经被使用,Vite 会自动尝试下一个可用的端口,所以这可能不是服务器最终监听的实际端口。
server.strictPortboolean设为 true 时若端口已被占用则会直接退出,而不是尝试下一个可用端口。
server.httpsboolean|https.ServerOptions启用 TLS + HTTP/2
server.openboolean|string在服务器启动时自动在浏览器中打开应用程序。当此值为字符串时,会被用作 URL 的路径名。
server.proxyRecord<string, string|ProxyOptions>
server.corsboolean|CorsOptions为开发服务器配置 CORS。默认启用并允许任何源,传递一个 选项对象 来调整行为或设为 false 表示禁用。
server.forceboolean设置为 true 强制使依赖预构建。

Build Options

配置项类型默认值说明
build.targetstring’modules‘设置最终构建的浏览器兼容目标。默认值是一个 Vite 特有的值,'modules',这是指 支持原生 ES 模块的浏览器
build.polyfillDynamicImportbooleantrue决定是否自动注入 对动态导入的 polyfill。该 polyfill 将被自动注入进每个 index.html 入口的代理模块中。
build.outDirstringdist指定输出路径(相对于 项目根目录).
build.assetsDirstringassets指定生成静态资源的存放路径(相对于 build.outDir)。
build.assetsInlineLimitnumber4096(4kb)小于此阈值的导入或引用资源将内联为 base64 编码,以避免额外的 http 请求。设置为 0 可以完全禁用此项。
build.cssCodeSplitbooleantrue启用/禁用 CSS 代码拆分。当启用时,在异步 chunk 中导入的 CSS 将内联到异步 chunk 本身,并在块加载时插入。如果禁用,整个项目中的所有 CSS 将被提取到一个 CSS 文件中。
build.sourcemapboolean|'inline'false构建后是否生成 source map 文件。
build.rollupOptionsRolluoOptions自定义底层的 Rollup 打包配置。这与从 Rollup 配置文件导出的选项相同,并将与 Vite 的内部 Rollup 选项合并。
build.minifyboolean|'terser'|'esbuild''terser'设置为 false 可以禁用最小化混淆,或是用来指定使用哪种混淆器。Terser 相对较慢,但大多数情况下构建后的文件体积更小。ESbuild 最小化混淆更快但构建后的文件相对更大。
build.emptyoutDirbooleanoutDirroot 目录下,则为 true默认情况下,若 outDirroot 目录下,则 Vite 会在构建时清空该目录。若 outDir 在根目录之外则会抛出一个警告避免意外删除掉重要的文件。
build.chunkSizeWarningLimitnumber500chunk 大小警告的限制(以 kbs 为单位)。

依赖优化选项

配置项类型说明
optimizeDeps.entriesstring|string[]默认情况下,Vite 会抓取你的 index.html 来检测需要预构建的依赖项。如果指定了 build.rollupOptions.input,Vite 将转而去抓取这些入口点。如果这两者都不适合你的需要,则可以使用此选项指定自定义条目 - 该值需要遵循 fast-glob 模式 ,或者是相对于 vite 项目根的模式数组。这将覆盖掉默认条目推断。
optimizeDeps.excludestring[]在预构建中强制排除的依赖项。
optimizeDeps.includestring[]默认情况下,不在 node_modules 中的,链接的包不会被预构建。使用此选项可强制预构建链接的包。

SSR选项

配置项类型说明
ssr.externalstring[]列出的是要为 SSR 强制外部化的依赖。
ssr.noExternalstring[]列出的是防止被 SSR 外部化依赖项。

环境变量

Vite 在一个特殊的 import.meta.env 对象上暴露环境变量。

在生产环境中,这些环境变量会在构建时被静态替换,因此请在引用它们时使用完全静态的字符串。动态的 key 将无法生效。例如,动态 key 取值 import.meta.env[key] 是无效的。

.env文件

Vite 使用 dotenv 在项目根目录下从以下文件加载额外的环境变量

.env                # 所有情况下都会加载
.env.local          # 所有情况下都会加载,但会被 git 忽略
.env.[mode]         # 只在指定模式下加载
.env.[mode].local   # 只在指定模式下加载,但会被 git 忽略

加载的环境变量也会通过 import.meta.env 暴露给客户端源码。为了防止意外地将一些环境变量泄漏到客户端,只有以 VITE_ 为前缀的变量才会暴露给经过 vite 处理的代码。

Vite插件API

默认情况下插件在部署(serve)和构建(build)模式中都会调用。如果插件只需要在服务或构建期间有条件地应用,请使用 apply 属性指明它们仅在 'build''serve' 模式时调用:

// vite.config.js
import typescript2 from 'rollup-plugin-typescript2'

export default {
  plugins: [
    {
      ...typescript2(),
      apply: 'build'
    }
  ]
}

通用钩子

  • 在服务器启动时被调用 options、buildStart
  • 在每个传入模块请求时被调用 resolveId load transform
  • 服务器关闭时被调用 buildEnd closeBundle

Vite特有钩子

开发时,vite创建一个插件容器按照顺序调用的各个钩子

  • config:修改vite配置
  • configResolved:vite配置确认
  • configureServer:用于配置dev server
  • transformIndexHtml:用于转换宿主页
  • resolveId:创建自定义确认函数,常用语定位第三方依赖
  • load:创建自定义加载函数,可用于返回自定义的内容
  • transform:可用于转换已加载的模块内容
  • handleHotUpdate:自定义HMR更新时调用

原理浅析

开启一个服务器,对不同请求进行响应,重写裸模块路径。

const Koa = require('koa')
const fs = require('fs')
const path = require('path')
const compilerSfc = require('@vue/compiler-sfc')
const compilerDom = require('@vue/compiler-dom')

const app = new Koa()

function rewriteImport (content) {
    return content.replace(/from ['"]([^'"]+)['"]/g, function ($0, $1) {
        if ($1.startsWith('./') || $1.startsWith('../') || $1.startsWith('/')) {
            return $0
        }

        return ` from '/@modules/${$1}'`
    })
}

app.use(async ctx => {
    const url = ctx.request.url
    const query = ctx.request.query

    if (url === '/') {
        // 处理html文件
        ctx.type = 'text/html'
        ctx.body = fs.readFileSync('./index.html', 'utf-8')
    } else if (url.endsWith('.js')) {
        // 处理js文件
        const filePath = path.join(__dirname, url)
        ctx.type = 'application/javascript'
        ctx.body = rewriteImport(fs.readFileSync(filePath, 'utf-8'))
    } else if (url.startsWith('/@modules')) {
        // 处理依赖
        const moduleName = url.replace('/@modules/', '')
        const prefix = path.join(__dirname, './node_modules', moduleName)
        const module = require(path.join(prefix, '/package.json')).module
        const modulePath = path.join(prefix, module)

        const file = fs.readFileSync(modulePath, 'utf-8')
        ctx.type = 'application/javascript'
        ctx.body = rewriteImport(file)

    } else if (url.indexOf('.vue') > -1) {
        // 处理sfc文件
        const filePath = path.join(__dirname, url.split('?')[0])
        const res = compilerSfc.parse(fs.readFileSync(filePath, 'utf-8'))
        console.log(res.descriptor, 'descriptor')
        if (!query.type) {
            const scriptContent = res.descriptor.script.content
            const script = scriptContent.replace('export default ', 'const __script = ')
            // 返回App.vue解析结果
            ctx.type = 'application/javascript'
            ctx.body = `
                ${rewriteImport(script)}
                import { render as __render } from '${url}?type=template'
                __script.render = __render
                export default __script
            `
        }else if (query.type === 'template') {
            // 模板内容
            const template = res.descriptor.template.content
            // 编译为render
            const render = compilerDom.compile(template, { mode: 'module' }).code
            ctx.type = 'application/javascript'
            ctx.body = rewriteImport(render)
          }

    }
})

app.listen(3001, () => {
    console.log('simple-vite start at port 3001')
})

plugin-vue插件部分源码

function vuePlugin(rawOptions = {}) {
  let options = __assign(__assign({
    isProduction: process.env.NODE_ENV === "production"
  }, rawOptions), {
    root: process.cwd()
  });
  const filter = createFilter(rawOptions.include || /\.vue$/, rawOptions.exclude);
  return {
    name: "vite:vue",
    handleHotUpdate(ctx) {
      if (!filter(ctx.file)) {
        return;
      }
      return handleHotUpdate(ctx);
    },
    config(config) {
      return {
        define: __assign({
          __VUE_OPTIONS_API__: true,
          __VUE_PROD_DEVTOOLS__: false
        }, config.define),
        ssr: {
          external: ["vue", "@vue/server-renderer"]
        }
      };
    },
    configResolved(config) {
      options = __assign(__assign({}, options), {
        root: config.root,
        isProduction: config.isProduction
      });
    },
    configureServer(server) {
      options.devServer = server;
    },
    async resolveId(id, importer) {
      if (parseVueRequest(id).query.vue) {
        return id;
      }
    },
    load(id, ssr = !!options.ssr) {
      const {filename, query} = parseVueRequest(id);
      if (query.vue) {
        if (query.src) {
          return import_fs.default.readFileSync(filename, "utf-8");
        }
        const descriptor = getDescriptor(filename);
        let block;
        if (query.type === "script") {
          block = getResolvedScript(descriptor, ssr);
        } else if (query.type === "template") {
          block = descriptor.template;
        } else if (query.type === "style") {
          block = descriptor.styles[query.index];
        } else if (query.index != null) {
          block = descriptor.customBlocks[query.index];
        }
        if (block) {
          return {
            code: block.content,
            map: block.map
          };
        }
      }
    },
    transform(code, id, ssr = !!options.ssr) {
      const {filename, query} = parseVueRequest(id);
      if (!query.vue && !filter(filename) || query.raw) {
        return;
      }
      if (!query.vue) {
        return transformMain(code, filename, options, this, ssr);
      } else {
        const descriptor = getDescriptor(filename);
        if (query.type === "template") {
          return transformTemplateAsModule(code, descriptor, options, this, ssr);
        } else if (query.type === "style") {
          return transformStyle(code, descriptor, Number(query.index), options, this);
        }
      }
    }
  };
}

SSR

SSR 支持还处于试验阶段,你可能会遇到 bug 和不受支持的用例。请考虑你可能承担的风险。

源码结构:

一个典型的 SSR 应用应该有如下的源文件结构:

- index.html
- src/
  - main.js          # 导出环境无关的(通用的)应用代码
  - entry-client.js  # 将应用挂载到一个 DOM 元素上
  - entry-server.js  # 使用某框架的 SSR API 渲染该应用

`

index.html 将需要引用 entry-client.js 并包含一个占位标记供给服务端渲染时注入:

<div id="app"><!--ssr-outlet--></div>
<script type="module" src="/src/entry-client.js"></script>
const fs = require('fs')
const path = require('path')
const express = require('express')
const { createServer: createViteServer } = require('vite')

async function createServer() {
  const app = express()

  // 以中间件模式创建 vite 应用,这将禁用 Vite 自身的 HTML 服务逻辑
  // 并让上级服务器接管控制
  const vite = await createViteServer({
    server: { middlewareMode: true }
  })
  // 使用 vite 的 Connect 实例作为中间件
  app.use(vite.middlewares)

  app.use('*', async (req, res) => {
    const url = req.originalUrl

    try {
    // 1. 读取 index.html
        let template = fs.readFileSync(
        path.resolve(__dirname, 'index.html'),
        'utf-8'
        )

        // 2. 应用 vite HTML 转换。这将会注入 vite HMR 客户端,and
        //    同时也会从 Vite 插件应用 HTML 转换。
        //    例如:@vitejs/plugin-react-refresh 中的 global preambles
        template = await vite.transformIndexHtml(url, template)

        // 3. 加载服务器入口。vite.ssrLoadModule 将自动转换
        //    你的 ESM 源码将在 Node.js 也可用了!无需打包
        //    并提供类似 HMR 的根据情况随时失效。
        const { render } = await vite.ssrLoadModule('/src/entry-server.js')

        // 4. 渲染应用的 HTML。这假设 entry-server.js 导出的 `render`
        //    函数调用了相应 framework 的 SSR API。
        //    例如 ReactDOMServer.renderToString()
        const appHtml = await render(url)

        // 5. 注入应用渲染的 HTML 到模板中。
        const html = template.replace(`<!--ssr-outlet-->`, appHtml)

        // 6. 将渲染完成的 HTML 返回
        res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
    } catch (e) {
        // 如果捕获到了一个错误,让 vite 来修复该堆栈,这样它就可以映射回
        // 你的实际源码中。
        vite.ssrFixStacktrace(e)
        console.error(e)
        res.status(500).end(e.message)
    }
  })

  app.listen(3000)
}

createServer()

生产构建:

{
  "scripts": {
    "dev": "node server",
    "build:client": "vite build --outDir dist/client",
    "build:server": "vite build --outDir dist/server --ssr src/entry-server.js "
  }
}

注意使用 --ssr 标志表明这将会是一个 SSR 构建。它也应该能指明 SSR 入口。