初探Vite2

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="https://juejin.cn/post/..."> ,这个标签指向你的 JavaScript 源码。甚至内联引入 JavaScript 的 <script type="module" src="https://juejin.cn/post/..."> 和引用 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 {
    // 构建模式所需的特有配置
  }
}
复制代码

共享配置

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

Server Options

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

Build Options

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

依赖优化选项

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

SSR选项

配置项 类型 说明
ssr.external string[] 列出的是要为 SSR 强制外部化的依赖。
ssr.noExternal string[] 列出的是防止被 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 入口。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享