一次Vite define的深入研究

677

前言

Vite,作为一个现代前端开发环境,它的出现极大地提升了前端开发的效率和体验。Vite引领了前端构建工具的新趋势,不仅使得HMR(热模块替换)变得更加迅速,而且在构建速度、ES Modules支持等方面也表现出色。而在Vite中,有一个配置选项 define,我们将在这篇文章中深入探讨。

Vite和Rollup

值得注意的是,Vite在内部使用了Rollup作为其打包器。因此,Vite的define配置和Rollup的replace插件有类似的作用:它们都可以在源码中替换特定的字符串。然而,Vite对Rollup进行了优化,使得在开发模式下,Vite不需要使用Rollup,而在构建时才会使用Rollup进行打包。

因此,Vite的define在开发模式和生产模式下的行为是不同的。

Vite中的 define

在Vite中,define 选项主要用于在开发期间全局替换和内联环境变量或者其它变量。在上述代码示例中,我们将process.env定义为一个空对象,意味着在源码中的任何process.env引用都会被替换为一个空对象。

例子分析

先看一个Vite配置的例子:

我们测试将环境变量对象process.env写入Vite .

src/index入口文件

假设我有个入口文件,src/index.ts,代码如下,为了方便测试 process.env 功能,我只保留了关键代码

process.env.NODE_ENV !== "production" && console.log("test1");
process.env.NODE_ENV === "production" && console.log("test2");

例子1:纯Vite配置模式


import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  define: {
    'process.env': {}
  }
})

网上有一篇教程,高赞的解决方法是:

我在Vite打包后的代码,关键节点加了两个log。作用:打印代码被替换前是什么样,代码被替换后是什么样。(代码路径,node_modules/vite/dist/node/chunks/dep-934dbc7c.js)

dev模式

pnpm run dev

不走这段代码,无log,符合预期。

build模式

pnpm run build

代码转换前Vite会进行变量替换(define就是干这个事的),最终 process.env.NODE_ENV被转换成了"production",符合预期。

Define

这里稍微补充下define的逻辑,

define替换分为两步,

  • replace,替换 "process.env" 为 {},你可以认为这阶段是纯文本处理。

  • transform,将ES6代码替换为 目标代码,通常是ES5,这里面会涉及语法解析,词法解析,就转换成AST的流程。

例子2.1:'process.env': {}

我们现在改下配置,将打包模式改成 umd格式。

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  define: {
    'process.env': {}
  },
  build: {
    lib: {
      formats: ['umd'],
      name: 'test',
      entry: {
        // 设置入口文件
        entry: './src/index',
      },
    },
  }
})
pnpm run build

控制台报错,被replace后的代码 {}.NODE_ENV !== "production"报错,错误原因是Rollup把{}当成了块状作用域处理。

说明在lib模式下,process.env 的处理 Vite 并没有处理好。anywhere,只是一个兼容问题,我们继续往下看。

例子2.2:'process.env': ({})

修改配置为

'process.env': `({})`

build成功了,替换成({}) ,Rollup能当成JS的空对象解析,编译通过。

例子2.3:改成if语句

当我们修改用户源代码,Vite配置还是'process.env': {}

// process.env.NODE_ENV !== "production" && console.log("test1");
// process.env.NODE_ENV === "production" && console.log("test2");

if (process.env.NODE_ENV !== "production") {
  console.log("test1");
} else {
  console.log("test2");
}

打包结果,正常。在 if ()里的{}能被正确解析成对象。

浏览器解析

上面都是Rollup AST解析的处理,下面我们看看浏览器里的处理。

1、{}.a

2、({}).a

3、if ({}.a)

也是符合预期,Rollup块状作用域的解析规则和浏览器一致。

源码浅析

Vite版本 4.3.5,代码路径:/vite/packages/vite/src/node/plugins/define.ts

export function definePlugin(config: ResolvedConfig): Plugin {
  const isBuild = config.command === 'build'
  const isBuildLib = isBuild && config.build.lib

  // ignore replace process.env in lib build
  const processEnv: Record<string, string> = {}
  const processNodeEnv: Record<string, string> = {}
  if (!isBuildLib) {
    const nodeEnv = process.env.NODE_ENV || config.mode
    Object.assign(processEnv, {
      'process.env.': `({}).`,
      'global.process.env.': `({}).`,
      'globalThis.process.env.': `({}).`,
    })
    Object.assign(processNodeEnv, {
      'process.env.NODE_ENV': JSON.stringify(nodeEnv),
      'global.process.env.NODE_ENV': JSON.stringify(nodeEnv),
      'globalThis.process.env.NODE_ENV': JSON.stringify(nodeEnv),
      __vite_process_env_NODE_ENV: JSON.stringify(nodeEnv),
    })
  }

  // ... 省略

  function generatePattern(
    ssr: boolean,
  ): [Record<string, string | undefined>, RegExp | null] {
    const replaceProcessEnv = !ssr || config.ssr?.target === 'webworker'

    const replacements: Record<string, string> = {
      ...(replaceProcessEnv ? processNodeEnv : {}),
      ...getImportMetaKeys(ssr),
      ...userDefine,
      ...getImportMetaFallbackKeys(ssr),
      ...(replaceProcessEnv ? processEnv : {}),
    }

    if (isBuild && !replaceProcessEnv) {
      replacements['__vite_process_env_NODE_ENV'] = 'process.env.NODE_ENV'
    }

    const replacementsKeys = Object.keys(replacements)
    const pattern = replacementsKeys.length
      ? new RegExp(
          // Mustn't be preceded by a char that can be part of an identifier
          // or a '.' that isn't part of a spread operator
          '(?<![\p{L}\p{N}_$]|(?<!\.\.)\.)(' +
            replacementsKeys.map(escapeRegex).join('|') +
            // Mustn't be followed by a char that can be part of an identifier
            // or an assignment (but allow equality operators)
            ')(?:(?<=\.)|(?![\p{L}\p{N}_$]|\s*?=[^=]))',
          'gu',
        )
      : null

    return [replacements, pattern]
  }

  const defaultPattern = generatePattern(false)
  const ssrPattern = generatePattern(true)

  return {
    name: 'vite:define',

    transform(code, id, options) {
      const ssr = options?.ssr === true
      if (!ssr && !isBuild) {
        // for dev we inject actual global defines in the vite client to
        // avoid the transform cost.
        return
      }

      // ... 省略

      const s = new MagicString(code)
      let hasReplaced = false
      let match: RegExpExecArray | null

      while ((match = pattern.exec(code))) {
        hasReplaced = true
        const start = match.index
        const end = start + match[0].length
        const replacement = '' + replacements[match[1]]
        s.update(start, end, replacement)
      }

      if (!hasReplaced) {
        return null
      }

      return transformStableResult(s, id, config)
    },
  }
}

我们看上面源代码,发现 Vite在 开发模式dev 和 生产模式build,有不同的分支逻辑处理。

  • dev模式,vite在客户端注入实际的全局定义
  • build模式,走Vite的replace,和rollup的transform

看注释就知道:// 对于开发模式,我们在vite客户端注入实际的全局定义,以避免转换成本。

export function definePlugin(config: ResolvedConfig): Plugin {
   return {
       name: 'vite:define',
          transform(code, id, options) {
             if (!ssr && !isBuild) {
                // for dev we inject actual global defines in the vite client to
                // avoid the transform cost.
                return
             }
          }
       }
   }
}

如果是 lib 模式,则不会走这段兜底,代码会报错,其他模式没问题。

if (!isBuildLib) {
    const nodeEnv = process.env.NODE_ENV || config.mode
    Object.assign(processEnv, {
      'process.env.': `({}).`,
      'global.process.env.': `({}).`,
      'globalThis.process.env.': `({}).`,
    })
    Object.assign(processNodeEnv, {
      'process.env.NODE_ENV': JSON.stringify(nodeEnv),
      'global.process.env.NODE_ENV': JSON.stringify(nodeEnv),
      'globalThis.process.env.NODE_ENV': JSON.stringify(nodeEnv),
      __vite_process_env_NODE_ENV: JSON.stringify(nodeEnv),
    })
  }

代码替换的核心逻辑就是这段恶心的正则,看不懂。

const pattern = replacementsKeys.length
      ? new RegExp(
          // Mustn't be preceded by a char that can be part of an identifier
          // or a '.' that isn't part of a spread operator
          '(?<![\p{L}\p{N}_$]|(?<!\.\.)\.)(' +
            replacementsKeys.map(escapeRegex).join('|') +
            // Mustn't be followed by a char that can be part of an identifier
            // or an assignment (but allow equality operators)
            ')(?:(?<=\.)|(?![\p{L}\p{N}_$]|\s*?=[^=]))',
          'gu',
        )
      : null

    return [replacements, pattern]

结论

虽然这个Feature很容易解决,但其中涉及到内容还挺有意思,包括,构建替换原理,块状作用域与对象解析规则,浏览器和构建工具解析规范等等。最终我推荐用官方推荐的方式,就不用有那么多问题,官方推荐用静态变量替换。

总的来说,Vite的define选项提供了一种灵活的方式,可以在源码中全局替换特定的字符串。虽然其在开发模式和生产模式下的行为有所不同,但其核心目的是一样的:提供一种机制,使得我们可以在构建时替换源代码。