Skip to content

Configuration Analysis of Low version Browser Compatibility with Vite

Gookyn edited this page Oct 31, 2022 · 2 revisions

Difference between Modern Browsers and Legacy Browsers

Modern Browsers vs Legacy Browsers differentiate the baseline:

Whether to support:

Vite

https://vitejs.dev/

Default supported browser versions

  • Chrome >= 87
  • Firefox >= 78
  • Safari >= 13
  • Edge >= 88

https://vitejs.dev/guide/#browser-support

Compatibility-related configuration options and source code analysis

The following analysis is based on vite v2.9.4

esbuild

vite uses esbuild to transform JS, TS, CSS and other syntaxes, which can be passed to esbuild by configuring this option, works in both development and production environments.

The relevant source code is as follows:

// packages/vite/src/node/plugins.ts

export async function resolvePlugins(
  config: ResolvedConfig,
  prePlugins: Plugin[],
  normalPlugins: Plugin[],
  postPlugins: Plugin[]
): Promise<Plugin[]> {
 ...
  return [
    ...
    config.esbuild !== false ? esbuildPlugin(config.esbuild) : null,
    ...
  ].filter(Boolean) as Plugin[]
}
// packages/vite/src/node/esbuild.ts

import { transform } from 'esbuild'
...
export async function transformWithEsbuild(
  code: string,
  filename: string,
  options?: TransformOptions,
  inMap?: object
): Promise<ESBuildTransformResult> {
  ...
  // transform by esbuild's transform
  const result = await transform(code, resolvedOptions)
  ...
}
...

For example: The ?? operator only supports Chrome >= 85 and above, so you need to configure this option to transform it into a conditional expression equivalent to a lower version, which is used in a lower version of Chrome. The configuration example is as follows:

// vite.config.js

export default defineConfig({
  esbuild: {
    target: 'chrome70',
  }
})

build.target

Set the browser target for the final build, the compilation process will be performed by esbuild.

Note: Due to the limitation of esbuild, the minimum version is supported to es2015

The relevant source code is as follows:

// packages/vite/src/node/build.ts

...
// default value of build.target
if (resolved.target === 'modules') {
  resolved.target = [
    'es2019',
    'edge88',
    'firefox78',
    'chrome87',
    'safari13.1'
  ]
} else if (resolved.target === 'esnext' && resolved.minify === 'terser') {
  resolved.target = 'es2019'
}
...
// packages/vite/src/node/build.ts

...
export function resolveBuildPlugins(config: ResolvedConfig): {
  pre: Plugin[]
  post: Plugin[]
} {
  const options = config.build

  return {
    // user plugin with enforce: 'pre'
    pre: [
      ...
    ],
    // user plugin with enforce: 'post'
    post: [
      // follow here: the default configuration (config.esbuild !== false), will transform the code through esbuild
      ...(config.esbuild !== false ? [buildEsbuildPlugin(config)] : []),
      ...
    ]
  }
}
...
// packages/vite/src/node/plugins/esbuild.ts

import { transform } from 'esbuild'

export const buildEsbuildPlugin = (config: ResolvedConfig): Plugin => {
  return {
    name: 'vite:esbuild-transpile',
    ...
    async renderChunk(code, chunk, opts) {
      if (opts.__vite_skip_esbuild__) {
        return null
      }

      // transform target
      const target = config.build.target
      const minify =
        config.build.minify === 'esbuild' &&
        !(config.build.lib && opts.format === 'es')

      if ((!target || target === 'esnext') && !minify) {
        return null
      }

      // transform code by esbuild
      const res = await transformWithEsbuild(code, chunk.fileName, {
        ...config.esbuild, // incoming esbuild configuration
        target: target || undefined, // in the production environment, if config.build.target is configured, it will override config.esbuild.target
        ...(minify
          ? {
              minify,
              treeShaking: true,
              format: rollupToEsbuildFormatMap[opts.format]
            }
          : undefined)
      })
      return res
    }
  }
}

export async function transformWithEsbuild(
  code: string,
  filename: string,
  options?: TransformOptions,
  inMap?: object
): Promise<ESBuildTransformResult> {
  ...

  try {
  	// esbuild transform
    const result = await transform(code, resolvedOptions)
    ...
  } catch (e: any) {
  	...
  }
}

build.cssTarget

Set a different browser target for CSS compression, in the cssPostPlugin will use esbuild to compress the CSS code.

Should only be used when targeting non-mainstream browsers, such as: webview in Android WeChat, supports most modern JavaScript features, but does not support the #RGBA hex color symbol in CSS. So set build.cssTarget to chrome61 to prevent vite from converting rgba() colors to #RGBA hex notation.

The relevant source code is as follows:

// packages/vite/src/node/build.ts

...
// default will be the same as build.target
if (!resolved.cssTarget) {
  resolved.cssTarget = resolved.target
}
...
// packages/vite/src/node/plugins/css.ts

import { transform, formatMessages } from 'esbuild'

export function cssPostPlugin(config: ResolvedConfig): Plugin {
  return {
    name: 'vite:css-post',
  	...
    async transform(css, id, options) {
      ...
      // defaults to 'esbuild', compressed by esbuild
      if (minify && config.build.minify) {
        css = await minifyCSS(css, config)
      }
      ...
		},
    ...
  }
}

async function minifyCSS(css: string, config: ResolvedConfig) {
  // esbuild transform
  const { code, warnings } = await transform(css, {
    loader: 'css',
    minify: true,
    target: config.build.cssTarget || undefined
  })
  return code
}

@vitejs/plugin-legacy

@vitejs/plugin-legacy: compatible plugins for low-version browsers in production environment.

Note: Does not work on development environments!

The main function

  • Depending on the target browser version, use babel to transform the new syntax to generate the corresponding transformed chunk for each chunk in the final package
  • Generate a polyfill chunk
    • If the transform target includes legacy browsers, a js file with the name polyfill-legacy will be generated by default, containing:
      • SystemJS runtime
      • Any necessary polyfills determined by specified browser targets and actual usage in the bundle
    • If the transform target includes a modern browser, a js file with the name polyfill-modern is generated by default, containing:
      • The target browser and the actual usage polyfill in the bundle
  • If the transform target includes legacy browsers
    • Generate files with legacy names for each js file
    • Inject <script nomodule> tags into generated HTML to load polyfills and legacy bundle in legacy browsers
  • Inject the import.meta.env.LEGACY env variable, which will only be true in the legacy production build, and false in all other cases.

Some configuration options and source code analysis

Note: The following analysis is based on vite v2.9.4, @vitejs/plugin-legacy v1.8.1, and the logic of the new version will be changed! ! !

There are two main logics:

  • legacy browser related
  • modern browser related

targets

The target browser version range, passed to @babel/preset-env.

This option value is also compatible with Browserslist, the default value of defaults is the value recommended by Browserslist.

The relevant source code is as follows:

// packages/plugin-legacy/index.js

let babel
const loadBabel = () => babel || (babel = require('@babel/standalone'))

function viteLegacyPlugin(options = {}) {
  // default target browser range
	const targets = options.targets || 'defaults'
  ...
  const legacyPostPlugin = {
		name: 'vite:legacy-post-process',
    enforce: 'post',
    apply: 'build',
    ...
    renderChunk(raw, chunk, opts) {
    	// babel transform
      const { code, map } = loadBabel().transform(raw, {
        ...
        presets: [
          ...
          // @babel/preset-env
          [
            'env',
            {
              targets, // pass the target browser to @babel/preset-env
              modules: false,
              bugfixes: true,
              loose: false,
              useBuiltIns: needPolyfills ? 'usage' : false,
              corejs: needPolyfills
                ? {
                    version: require('core-js/package.json').version,
                    proposals: false
                  }
                : undefined,
              shippedProposals: true,
              ignoreBrowserslistConfig: options.ignoreBrowserslistConfig
            }
          ]
        ]
      })
      return { code, map }
    }
  }
}

renderLegacyChunks

Whether to generate legacy browser chunks

  • Default: true
  • Don't generate when set to false, i.e. only compatible with modern browsers

modernPolyfills (only works on modern browser configurations)

Generate a separate polyfill chunk for modern browsers

  • Default: false
  • When set to true, the polyfills will be automatically detected based on the target browser version range
  • When set to a string array, it means to explicitly control which polyfills to include, in this case it will no longer be automatically detected

The relevant source code is as follows:

// packages/plugin-legacy/index.js

function viteLegacyPlugin(options = {}) {
  ...
  // whether to generate legacy browser chunks
  const genLegacy = options.renderLegacyChunks !== false

  // modern browser's polyfills
  const modernPolyfills = new Set()

  // if an array of modernPolyfills is passed in, format it and concatenate the full path
  if (Array.isArray(options.modernPolyfills)) {
    options.modernPolyfills.forEach((i) => {
      modernPolyfills.add(
        i.includes('/') ? `core-js/${i}` : `core-js/modules/${i}.js`
      )
    })
  }
  ...
  const legacyPostPlugin = {
    name: 'vite:legacy-post-process',
    enforce: 'post',
    apply: 'build',
  	...
    renderChunk(raw, chunk, opts) {
      // logic of modern browser
      if (!isLegacyChunk(chunk, opts)) {
     		// if options.modernPolyfills is true, auto detect polyfills
        if (
          options.modernPolyfills &&
          !Array.isArray(options.modernPolyfills)
        ) {
          // detects needed polyfills, appends to the modernPolyfills collection
          detectPolyfills(raw, { esmodules: true }, modernPolyfills)
        }
      }

      // when legacy browser compatibility is not required, subsequent logic is no longer executed
      if (!genLegacy) {
        return
      }
      ...
      // the logic after that is mainly to pass the babel transform syntax and generate chunks corresponding to legacy browsers
    },
    ...
  }

  // plugins that generate polyfill chunks
  const legacyGenerateBundlePlugin = {
    name: 'vite:legacy-generate-polyfill-chunk',
    apply: 'build',

    async generateBundle(opts, bundle) {
    	// logic of modern browser
      if (!isLegacyBundle(bundle, opts)) {
        // if there are no polyfills to build, just return
        if (!modernPolyfills.size) {
          return
        }

        // build polyfills for modern browsers as polyfill chunks
        await buildPolyfillChunk(
          'polyfills-modern',
          modernPolyfills, // pass in polyfills required by modern browsers
          bundle,
          facadeToModernPolyfillMap,
          config.build,
          options.externalSystemJS
        )
        return
      }

      // when legacy browser compatibility is not required, subsequent logic is no longer executed
      if (!genLegacy) {
        return
      }

      // the logic after that is to generate polyfill chunks for legacy browsers
      ...
    }
  }
}

polyfills (only works on legacy browser configurations)

Add polyfills for legacy browsers

  • Default: true, a chunk of polyfills will be generated based on the target browser range and actual usage in the final bundle
  • Set to a list of strings to explicitly control which polyfills to include, this will no longer be auto-detected

The relevant source code is as follows:

// packages/plugin-legacy/index.js

let babel
const loadBabel = () => babel || (babel = require('@babel/standalone'))

function viteLegacyPlugin(options = {}) {
  ...
  const DEFAULT_LEGACY_POLYFILL = [
    'core-js/modules/es.promise',
    'core-js/modules/es.array.iterator'
  ]

  // polyfills required by legacy browsers
  const legacyPolyfills = new Set(DEFAULT_LEGACY_POLYFILL)

  // if an array of polyfills is passed in, format it and add it to the polyfills collection required by legacy browsers
	if (Array.isArray(options.polyfills)) {
    options.polyfills.forEach((i) => {
      if (i.startsWith(`regenerator`)) {
        legacyPolyfills.add(`regenerator-runtime/runtime.js`)
      } else {
        legacyPolyfills.add(
          i.includes('/') ? `core-js/${i}` : `core-js/modules/${i}.js`
        )
      }
    })
  }
  ...
  const legacyPostPlugin = {
		name: 'vite:legacy-post-process',
    enforce: 'post',
    apply: 'build',
    ...
    renderChunk(raw, chunk, opts) {
      ...
      // the above is the logic of modern browsers
      ...
    	// if options.polyfills is set, and the value is not an array, it is assumed that polyfills need to be auto-detected
    	// default true
      const needPolyfills = options.polyfills !== false && !Array.isArray(options.polyfills)
      ...
      // babel transform
      const { code, map } = loadBabel().transform(raw, {
        ...
        presets: [
          ...
          // @babel/preset-env
          [
            'env',
            {
              targets,
              modules: false,
              bugfixes: true,
              loose: false,
              /**
                * The essential:
                * useBuiltIns
                * 	- 'usage':@babel/preset-env will load the actual polyfills needed
                * 	- false:don't load polyfills
                *
                * https://babeljs.io/docs/en/babel-preset-env#usebuiltins
                */
              useBuiltIns: needPolyfills ? 'usage' : false,
              // version of corejs(https://babeljs.io/docs/en/babel-preset-env#corejs)
              corejs: needPolyfills
                ? {
                    version: require('core-js/package.json').version,
                    proposals: false
                  }
                : undefined,
              shippedProposals: true,
              ignoreBrowserslistConfig: options.ignoreBrowserslistConfig
            }
          ]
        ]
      })

      return { code, map }
    }
  }

  // plugins that generate polyfill chunks
  const legacyGenerateBundlePlugin = {
    name: 'vite:legacy-generate-polyfill-chunk',
    apply: 'build',

    async generateBundle(opts, bundle) {
    	...
      // the above is the logic of modern browsers
      ...

      // generate legacy browser polyfill chunk
      if (legacyPolyfills.size || genDynamicFallback) {
        ...
        // generate polyfill chunk
        await buildPolyfillChunk(
          'polyfills-legacy',
          legacyPolyfills, // pass in the polyfills required by legacy browsers
          bundle,
          facadeToLegacyPolyfillMap,
          config.build,
          options.externalSystemJS
        )
      }
    }
  }
}

additionalLegacyPolyfills (only works on legacy browser configurations)

Add custom polyfills additionally to legacy browser polyfills.

The relevant source code is as follows:

// packages/plugin-legacy/index.js

function viteLegacyPlugin(options = {}) {
  ...
  const DEFAULT_LEGACY_POLYFILL = [
    'core-js/modules/es.promise',
    'core-js/modules/es.array.iterator'
  ]
  // polyfills required by legacy browsers
  const legacyPolyfills = new Set(DEFAULT_LEGACY_POLYFILL)
  ...
  // add custom polyfills additionally to polyfills
  if (Array.isArray(options.additionalLegacyPolyfills)) {
    options.additionalLegacyPolyfills.forEach((i) => {
      legacyPolyfills.add(i)
    })
  }
  ...
}

Several main methods

  1. Determine whether it is a legacy browser chunk
function isLegacyChunk(chunk, options) {
  // mainly based on the format value and whether the filename includes -legacy
  return options.format === 'system' && chunk.fileName.includes('-legacy')
}
  1. Determine whether it is a legacy browser bundle
function isLegacyBundle(bundle, options) {
  // mainly based on the format value and whether the filename of the entry block includes -legacy
  if (options.format === 'system') {
    const entryChunk = Object.values(bundle).find(
      (output) => output.type === 'chunk' && output.isEntry
    )

    return !!entryChunk && entryChunk.fileName.includes('-legacy')
  }

  return false
}
  1. Detect required polyfills
function detectPolyfills(code, targets, list) {
  // generate ast corresponding to polyfill by babel
  const { ast } = loadBabel().transform(code, {
    ast: true,
    babelrc: false,
    configFile: false,
    presets: [
      [
        'env',
        {
          targets, // target browser range
          modules: false,
          useBuiltIns: 'usage', // load the polyfills that actually need to be used
          corejs: { version: 3, proposals: false }, // version of corejs
          shippedProposals: true,
          ignoreBrowserslistConfig: true
        }
      ]
    ]
  })

  // parse ast
  for (const node of ast.program.body) {
    if (node.type === 'ImportDeclaration') {
      const source = node.source.value
      if (
        source.startsWith('core-js/') ||
        source.startsWith('regenerator-runtime/')
      ) {
        // append to the incoming collection
        list.add(source)
      }
    }
  }
}
  1. Build the polyfill chunk
async function buildPolyfillChunk(
  name,
  imports,
  bundle,
  facadeToChunkMap,
  buildOptions,
  externalSystemJS
) {
  ...
  // vite build
  const res = await build({
    ...
    // load polyfills
    plugins: [polyfillsPlugin(imports, externalSystemJS)],
    build: {
      write: false,
      target: false,
      minify,
      assetsDir,
      rollupOptions: {
        input: {
          [name]: polyfillId
        },
        output: {
          format: name.includes('legacy') ? 'iife' : 'es',
          manualChunks: undefined
        }
      }
    }
  })

  // get the built polyfill chunk
  const _polyfillChunk = Array.isArray(res) ? res[0] : res
  const polyfillChunk = _polyfillChunk.output[0]
  ...
  // add the built polyfill chunk to the bundle
  bundle[polyfillChunk.name] = polyfillChunk
}

const polyfillId = '\0vite/legacy-polyfills'

function polyfillsPlugin(imports, externalSystemJS) {
  return {
    name: 'vite:legacy-polyfills',
    ...
    load(id) {
      if (id === polyfillId) {
        return (
          [...imports].map((i) => `import "${i}";`).join('') +
          (externalSystemJS ? '' : `import "systemjs/dist/s.min.js";`)
        )
      }
    }
  }
}