vite-@vitejs/plugin-legacy源码阅读
vitelegacypolyfill
@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配置为解析前后(config
、configResolved
)进行了处理:
- 在
config
钩子中对build
配置进行设值cssTarget
、target
- 并对是否会覆盖原有构建目标进行判断
- 使用
define
定义环境变量import.meta.env.LEGACY=__VITE_IS_LEGACY__
并返回 - 最终在
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
钩子中进行处理:
- 如果不是
legacy
类型的bundle则走modern
polyfill构建处理 - 将在
renderChunk
中处理后的polyfill集合信息单独构建polyfillChunk
并追加到原有的bundle信息中,在bundle.write时真正输出
polyfillsPlugin
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";`); } } };}
buildPolyfillChunk
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;}
legacyGenerateBundlePlugin
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
此步骤插件仅在构建时对相同configResolved
、renderChunk
、transformIndexHtml
和generateBundle
钩子做后置处理:
ts
const legacyPostPlugin = { name: "vite:legacy-post-process", enforce: "post", apply: "build", configResolved, renderChunk, transformIndexHtml, generateBundle}
- 在
configResolved
钩子中对产出的chunk
作legacy
命名化处理(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 || {}]; }}
- 在
renderChunk
钩子中对每个system格式的chunk通过babel transform进行ast分析后,将根据targets生成的polyfill记录在legacyPolyfills
中,以供generateBundle
时生成
ts
// raw为system格式处理后的代码字符串// chunk为当前renderChunk信息// opts为处理后的rollupOptionsasync 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;}
- 在
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 };}
- 在
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来选择使用哪套代码。