vite-@vitejs/plugin-legacy源码阅读

/post/vite-legacy article cover image

@vitejs/plugin-legacy作为兼容不支持原生ESM的方案插件,其根据构建目标生成对应polyfill的插件设计思路很值得学习特此记录。

用法及参数说明

ts
// ...
export default {
  plugins: [
    legacy({
      // 构建目标,最终由@babel/preset-env处理
      // 自定义可使用browserslist数组形式: ['last 2 versions nad not dead', > 0.3%, 'Firefox ESR']
      targets: 'defaults',
      // 默认true,使用@babel/preset的useBuiltIns: 'usage'
      // 自定义可使用polyfill specifiers数组形式:['es.promise.finally', 'es/map', 'es/set', ...]
      // false时则生成任何polyfill
      polyfills: true,
      // 添加自定义导入至基于es语言功能生成的polyfill中,例如额外的DOM API polyfill
      additionalLegacyPolyfills: ['resize-observer-polyfill'],
      // 忽略@babel/preset-env的browserslist检测
      ignoreBrowserslistConfig: false,
      // 默认为false,为true时会会分开生成modern polyfill
      // 自定义可使用polyfill specifiers数组形式:['es.promise.finally', 'es/map', 'es/set', ...]
      modernPolyfills: false,
      // 默认为true,一般用在使用modernPolyfill为现代语法构建注入polyfill时设置为false
      renderLegacyChunks: true,
      // 默认为false,为true时systemjs/dist/s.min.js将不会包含在polyfills-legacy块中
      externalSystemJS: false
    })
  ]
}

vitelegacyplugin流程

从入口文件默认导出了由三个内部插件组成分别处理对应阶段分别config阶段的legacyConfigPlugin > generateBundle阶段的legacyGenerateBundlePlugin > 以及后置处理的legacyPostPlugin,以下会按照流程顺序依次解释:

ts
// ...
function viteLegacyPlugin(options: Options = {}): Plugin[] {
  // ...
  const legacyConfigPlugin: Plugin = {
    // ...
  }
  const legacyGenerateBundlePlugin: Plugin = {
    // ...
  }
  const legacyPostPlugin: Plugin = {
    // ...
  }
  return [legacyConfigPlugin, legacyGenerateBundlePlugin, legacyPostPlugin]
}
// ...
export default  viteLegacyPlugin

在vite-config.ts中调用实际形式如下:

ts
export default {
  plugins: [
    // legacy({ targets: 'last 2 versions' })
    {
      name: 'vite:legacy-config',
      config: () => {/**/},
      configResolved: () => {/**/}
    },
    {
      name: 'vite:legacy-generate-polyfill-chunk',
      apply: 'build',
      generateBundle: () => {/**/}
    },
    {
      name: 'vite:legacy-post-process',
      enforce: 'post',
      apply: 'build',
      configResolved: () => {/**/},
      renderChunk: () => {/**/}
    }
  ]
}

legacyconfigplugin

此步骤插件在vite配置为解析前后(configconfigResolved)进行了处理:

  1. config钩子中对build配置进行设值cssTargettarget
  2. 并对是否会覆盖原有构建目标进行判断
  3. 使用define定义环境变量import.meta.env.LEGACY=__VITE_IS_LEGACY__并返回
  4. 最终在configResolved中判断有无用户自定义的构建目标,有的话就提示将会被覆盖,应该在legacy插件中定义构建目标
ts
const legacyConfigPlugin = {
  name: "vite:legacy-config",
  config(config2, env) {
    if (env.command === "build") {
      if (!config2.build) {
        config2.build = {};
      }
      if (!config2.build.cssTarget) {
        config2.build.cssTarget = "chrome61";
      }
      if (genLegacy) {
        overriddenBuildTarget = config2.build.target !== void 0;
        config2.build.target = [
          "es2020",
          "edge79",
          "firefox67",
          "chrome64",
          "safari12"
        ];
      }
    }
    return {
      define: {
        "import.meta.env.LEGACY": env.command === "serve" || config2.build?.ssr ? false : legacyEnvVarMarker
      }
    };
  },
  configResolved(config2) {
    if (overriddenBuildTarget) {
      config2.logger.warn(
        picocolorsExports.yellow(
          `plugin-legacy overrode 'build.target'. You should pass 'targets' as an option to this plugin with the list of legacy browsers to support instead.`
        )
      );
    }
  }
}

legacygeneratebundleplug

此步骤插件在vite构建产物的generateBundle钩子中进行处理:

  1. 如果不是legacy类型的bundle则走modernpolyfill构建处理
  2. 将在renderChunk中处理后的polyfill集合信息单独构建polyfillChunk并追加到原有的bundle信息中,在bundle.write时真正输出
ts
function polyfillsPlugin(imports, excludeSystemJS) {
  return {
    name: "vite:legacy-polyfills",
    resolveId(id) {
      if (id === polyfillId) {
        return id;
      }
    },
    // polyfillsPlugin将legacyPolyfill在load钩子中遍历拼接import "systemjs/dist/s.min.js注入到入口文件中
    load(id) {
      if (id === polyfillId) {
        return [...imports].map((i) => `import ${JSON.stringify(i)};`).join("") + (excludeSystemJS ? "" : `import "systemjs/dist/s.min.js";`);
      }
    }
  };
}
ts
async function buildPolyfillChunk(mode, imports, bundle, facadeToChunkMap, buildOptions, format, rollupOutputOptions, excludeSystemJS) {
  let { minify, assetsDir } = buildOptions;
  minify = minify ? "terser" : false;
  const res = await vite.build({
    mode,
    // so that everything is resolved from here
    root: path.dirname(node_url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (document.currentScript && document.currentScript.src || new URL('index.cjs', document.baseURI).href)))),
    configFile: false,
    logLevel: "error",
    plugins: [polyfillsPlugin(imports, excludeSystemJS)],
    build: {
      write: false,
      minify,
      assetsDir,
      rollupOptions: {
        input: {
          polyfills: polyfillId
        },
        output: {
          format,
          entryFileNames: rollupOutputOptions.entryFileNames
        }
      }
    },
    // Don't run esbuild for transpilation or minification
    // because we don't want to transpile code.
    esbuild: false,
    optimizeDeps: {
      esbuildOptions: {
        // If a value above 'es5' is set, esbuild injects helper functions which uses es2015 features.
        // This limits the input code not to include es2015+ codes.
        // But core-js is the only dependency which includes commonjs code
        // and core-js doesn't include es2015+ codes.
        target: "es5"
      }
    }
  });
  const _polyfillChunk = Array.isArray(res) ? res[0] : res;
  if (!("output" in _polyfillChunk)) return;
  // polyfillChunk信息:以legacyPolyfills作为入口文件内容构建的产物
  const polyfillChunk = _polyfillChunk.output[0];
  for (const key in bundle) {
    const chunk = bundle[key];
    // 在整体bundle中查找chunk类型,并将以chunk的实际磁盘位置作为key,polyfillChunk的fileName作为value保存
    // 后后置transformIndexHtml钩子中会用到
    if (chunk.type === "chunk" && chunk.facadeModuleId) {
      facadeToChunkMap.set(chunk.facadeModuleId, polyfillChunk.fileName);
    }
  }
  // 将上面构建出的polyfillChunk追加到原有的bundle中,在后续的实际产出中将会打包出来
  bundle[polyfillChunk.fileName] = polyfillChunk;
}
ts
const legacyGenerateBundlePlugin = {
  name: "vite:legacy-generate-polyfill-chunk",
  apply: "build",
  async generateBundle(opts, bundle) {
    if (config.build.ssr) return;
    // isLegacyBundle会对format为system的chunk进行是否包含-legacy字段的判断
    // 如果是false则会进行以format为es的形式对chunk进行modern polyfill构建
    if (!isLegacyBundle(bundle, opts)) {
      if (!modernPolyfills.size) return;
      isDebug && console.log(
        `[@vitejs/plugin-legacy] modern polyfills:`,
        modernPolyfills
      );
      await buildPolyfillChunk(
        config.mode,
        modernPolyfills,
        bundle,
        facadeToModernPolyfillMap,
        config.build,
        "es",
        opts,
        true
      );
      return;
    }
    // 如果genLegacy为true则return,不将生成
    if (!genLegacy) return;
    // 此时经过renderChunk的babel处理后,legacyPolyfills中已经记录了需要的polyfill信息(ImportDeclaration、ExpressionStatement等)
    if (legacyPolyfills.size) {
      // 根据输入语法检测在legacyPolyfills中增加对应ImportDeclaration类型的polyfill模块名称
      await detectPolyfills(
        `Promise.resolve(); Promise.all();`,
        targets,
        legacyPolyfills
      );
      isDebug && console.log(
        `[@vitejs/plugin-legacy] legacy polyfills:`,
        legacyPolyfills
      );
      // 进行vite函数式构建
      await buildPolyfillChunk(
        config.mode,
        legacyPolyfills,
        bundle,
        facadeToLegacyPolyfillMap,
        // force using terser for legacy polyfill minification, since esbuild
        // isn't legacy-safe
        config.build,
        "iife",
        opts,
        options.externalSystemJS
      );
    }
  }
}

legacypostplugin

此步骤插件仅在构建时对相同configResolvedrenderChunktransformIndexHtmlgenerateBundle钩子做后置处理:

ts
const legacyPostPlugin = {
  name: "vite:legacy-post-process",
  enforce: "post",
  apply: "build",
  configResolved,
  renderChunk,
  transformIndexHtml,
  generateBundle
}
  1. configResolved钩子中对产出的chunklegacy命名化处理(chunkName-legacy-[hash].js)
ts
function configResolved(_config) {
  if (_config.build.lib) throw new Error("@vitejs/plugin-legacy does not support library mode.");
  config = _config; // 将修改后的config保存至顶层变量中
  if (!genLegacy || config.build.ssr) return;
  // 优先legacy-plugin传过来的targets,其次从项目根目录查找,否则使用默认预设目标
  targets = options.targets || browserslistLoadConfig({ path: config.root }) || "last 2 versions and not dead, > 0.3%, Firefox ESR";
  // chunk legacy命名化文件名称处理
  const getLegacyOutputFileName = (fileNames, defaultFileName = "[name]-legacy-[hash].js") => {
    // 没有文件名称时返回assets路径和默认文件名称的拼接路径
    // 本例中的情况是: createLegacyOutput => { ..., createLegacyOutput(entryFileNames) }
    // assets/[name]-legacy-[hash].js
    if (!fileNames) {
      return path.posix.join(config.build.assetsDir, defaultFileName);
    }
    // 有fileName时处理,会犯chunkFileNames方法
    // 本例中的情况是: createLegacyOutput => { ..., createLegacyOutput(chunkFileNames) }
    // 以供generateBundle时获取legacy命名化后的chunk信息
    // 对应的chunk名称将会变成:index-legacy-xxx.js
    return (chunkInfo) => {
      let fileName = typeof fileNames === "function" ? fileNames(chunkInfo) : fileNames;
      if (fileName.includes("[name]")) {
        fileName = fileName.replace("[name]", "[name]-legacy");
      } else {
        fileName = fileName.replace(/(.+)\.(.+)/, "$1-legacy.$2");
      }
      return fileName;
    };
  }
  // 创建legacy化rollupOptions.output配置
  // 在rollup的build阶段会根据这个配置进行transform和moduleParsed处理
  // 后续被renderChunk处理的代码都将是system格式的
  const createLegacyOutput = (options2 = {}) => {
    return {
      ...options2,
      format: "system",
      entryFileNames: getLegacyOutputFileName(options2.entryFileNames),
      chunkFileNames: getLegacyOutputFileName(options2.chunkFileNames)
    };
  };
  const { rollupOptions } = config.build;
  const { output } = rollupOptions;
  // 多出口配置判断
  if (Array.isArray(output)) {
    rollupOptions.output = [...output.map(createLegacyOutput), ...output];
  } else {
    // 本例中是单出口
    rollupOptions.output = [createLegacyOutput(output), output || {}];
  }
}
  1. renderChunk钩子中对每个system格式的chunk通过babel transform进行ast分析后,将根据targets生成的polyfill记录在legacyPolyfills中,以供generateBundle时生成
ts
// raw为system格式处理后的代码字符串
// chunk为当前renderChunk信息
// opts为处理后的rollupOptions
async function renderChunk(raw, chunk, opts) {
  if (config.build.ssr) return null;
  // 判断是否为*.+-legacy的chunk
  if (!isLegacyChunk(chunk, opts)) {
    // 如果modernPolyfill为true时就通过@babel/preset-env对代码进行polyfill的检测
    // 并将需要polyfill的模块名称添加到对应的modernPolyfills Map体中
    if (options.modernPolyfills && !Array.isArray(options.modernPolyfills)) {
      await detectPolyfills(raw, { esmodules: true }, modernPolyfills);
    }
    const ms = new MagicString(raw);
    if (genLegacy && chunk.isEntry) {
      ms.prepend(modernChunkLegacyGuard);
    }
    if (raw.includes(legacyEnvVarMarker)) {
      const re = new RegExp(legacyEnvVarMarker, "g");
      let match;
      while (match = re.exec(raw)) {
        ms.overwrite(
          match.index,
          match.index + legacyEnvVarMarker.length,
          `false`
        );
      }
    }
    if (config.build.sourcemap) {
      return {
        code: ms.toString(),
        map: ms.generateMap({ hires: true })
      };
    }
    return {
      code: ms.toString()
    };
  }
  if (!genLegacy) return null;
  // vite内部流程私有标识设置
  opts.__vite_skip_esbuild__ = true;
  opts.__vite_force_terser__ = true;
  opts.__vite_skip_asset_emit__ = true;
  // 是否需要polyfill用于createBabelPresetEnvOptions创建@babel/preset-env配置
  const needPolyfills = options.polyfills !== false && !Array.isArray(options.polyfills);
  // 是否需要sourcemap
  const sourceMaps = !!config.build.sourcemap;
  const babel2 = await loadBabel();
  const result = babel2.transform(raw, {
    babelrc: false,
    configFile: false,
    compact: !!config.build.minify,
    sourceMaps,
    inputSourceMap: void 0,
    // sourceMaps ? chunk.map : undefined, `.map` TODO: moved to OutputChunk?
    presets: [
      // forcing our plugin to run before preset-env by wrapping it in a
      // preset so we can catch the injected import statements...
      [
        () => ({
          // 自定义的babel transform plugin
          plugins: [
            // 后置插件: 将babel polyfill移除并添加到legacyPolyfills Map体中
            recordAndRemovePolyfillBabelPlugin(legacyPolyfills),
            // 访问器插件: 遍历替换节点
            replaceLegacyEnvBabelPlugin(),
            // 后置插件: iife包裹处理node.body
            wrapIIFEBabelPlugin()
          ]
        })
      ],
      [
        "@babel/preset-env",
        // 根据targets创建env对应配置项
        createBabelPresetEnvOptions(targets, {
          needPolyfills,
          ignoreBrowserslistConfig: options.ignoreBrowserslistConfig
        })
      ]
    ]
  });
  // 有编译结果则输出对应sourcemap
  if (result) return { code: result.code, map: result.map };
  return null;
}
  1. transformIndexHtml钩子中增加资源引用以及现代浏览器能力判断:
ts
function transformIndexHtml(html, { chunk }) {
  if (config.build.ssr) return;
  if (!chunk) return;
  // 记录legacy chunk
  if (chunk.fileName.includes("-legacy")) {
    facadeToLegacyChunkMap.set(chunk.facadeModuleId, chunk.fileName);
    return;
  }
  const tags = [];
  const htmlFilename = chunk.facadeModuleId?.replace(/\?.*$/, "");
  const modernPolyfillFilename = facadeToModernPolyfillMap.get(
    chunk.facadeModuleId
  );
  // 如果用户设置了 modern polyfill的话就加到body当中
  if (modernPolyfillFilename) {
    tags.push({
      tag: "script",
      attrs: {
        type: "module",
        crossorigin: true,
        src: toAssetPathFromHtml(
          modernPolyfillFilename,
          chunk.facadeModuleId,
          config
        )
      }
    });
  } else if (modernPolyfills.size) {
    throw new Error(
      `No corresponding modern polyfill chunk found for ${htmlFilename}`
    );
  }
  // 如果不生成legacy则交由其他钩子处理
  if (!genLegacy) {
    return { html, tags };
  }
  // safari兼容处理
  tags.push({
    tag: "script",
    attrs: { nomodule: true },
    children: safari10NoModuleFix,
    injectTo: "body"
  });
  const legacyPolyfillFilename = facadeToLegacyPolyfillMap.get(
    chunk.facadeModuleId
  );
  // legacy polyfill 注入
  if (legacyPolyfillFilename) {
    tags.push({
      tag: "script",
      attrs: {
        nomodule: true,
        crossorigin: true,
        id: legacyPolyfillId,
        src: toAssetPathFromHtml(
          legacyPolyfillFilename,
          chunk.facadeModuleId,
          config
        )
      },
      injectTo: "body"
    });
  } else if (legacyPolyfills.size) {
    throw new Error(
      `No corresponding legacy polyfill chunk found for ${htmlFilename}`
    );
  }
  const legacyEntryFilename = facadeToLegacyChunkMap.get(
    chunk.facadeModuleId
  );
  // legacy入口文件注入
  if (legacyEntryFilename) {
    tags.push({
      tag: "script",
      attrs: {
        nomodule: true,
        crossorigin: true,
        // we set the entry path on the element as an attribute so that the
        // script content will stay consistent - which allows using a constant
        // hash value for CSP.
        id: legacyEntryId,
        "data-src": toAssetPathFromHtml(
          legacyEntryFilename,
          chunk.facadeModuleId,
          config
        )
      },
      children: systemJSInlineCode,
      injectTo: "body"
    });
  } else {
    throw new Error(
      `No corresponding legacy entry chunk found for ${htmlFilename}`
    );
  }
  // 增加现代浏览器能力检测,如果支持则使用现代构建产物否则使用system格式产物
  if (genLegacy && legacyPolyfillFilename && legacyEntryFilename) {
    tags.push({
      tag: "script",
      attrs: { type: "module" },
      children: detectModernBrowserCode,
      injectTo: "head"
    });
    tags.push({
      tag: "script",
      attrs: { type: "module" },
      children: dynamicFallbackInlineCode,
      injectTo: "head"
    });
  }
  return {
    html,
    tags
  };
}
  1. generateBundle钩子中做了服务端渲染返回以及删除非.map文件的asset类型bundle
ts
function generateBundle(opts, bundle) {
  if (config.build.ssr) {
    return;
  }
  if (isLegacyBundle(bundle, opts)) {
    for (const name in bundle) {
      if (bundle[name].type === "asset" && !/.+\.map$/.test(name)) {
        delete bundle[name];
      }
    }
  }
}

至此整个legacy代码处理流程结束,也就是通过插件来讲转换后的代码通过babel再转换为指定浏览器目标的systemjs模块化形式.再通过入口文件运行时判断是否支持原生module来选择使用哪套代码。