-
-
Notifications
You must be signed in to change notification settings - Fork 6.4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: chunk importmap #16552
base: main
Are you sure you want to change the base?
feat: chunk importmap #16552
Changes from all commits
9bed85b
a0e94cc
bb63e50
eaca13c
5f898b3
553e503
47ce953
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||
---|---|---|---|---|---|---|---|---|---|---|
|
@@ -715,7 +715,7 @@ Do not allow `data:` for [`script-src`](https://developer.mozilla.org/en-US/docs | |||||||||
|
||||||||||
## Build Optimizations | ||||||||||
|
||||||||||
> Features listed below are automatically applied as part of the build process and there is no need for explicit configuration unless you want to disable them. | ||||||||||
> Features listed below (except for the chunk importmap feature) are automatically applied as part of the build process and there is no need for explicit configuration unless you want to disable them. | ||||||||||
|
||||||||||
### CSS Code Splitting | ||||||||||
|
||||||||||
|
@@ -749,3 +749,7 @@ Entry ---> (A + C) | |||||||||
``` | ||||||||||
|
||||||||||
It is possible for `C` to have further imports, which will result in even more roundtrips in the un-optimized scenario. Vite's optimization will trace all the direct imports to completely eliminate the roundtrips regardless of import depth. | ||||||||||
|
||||||||||
### Chunk importmap | ||||||||||
|
||||||||||
Creating an import map for chunks helps prevent the issue of cascading cache invalidation. This import map features a list of stable file IDs linked to filenames with content-based hashes. When one chunk references another, it utilizes the file ID instead of the content-hashed filename. As a result, only the updated chunk needs cache invalidation in the browser, leaving intermediary chunks unchanged. This strategy enhances the cache hit rate following deployments. | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -136,6 +136,51 @@ const _require = createRequire(import.meta.url) | |
const nonLeadingHashInFileNameRE = /[^/]+\[hash(?::\d+)?\]/ | ||
const prefixedHashInFileNameRE = /\W?\[hash(?::\d+)?\]/ | ||
|
||
const hashPlaceholderLeft = '!~{' | ||
const hashPlaceholderRight = '}~' | ||
const hashPlaceholderOverhead = | ||
hashPlaceholderLeft.length + hashPlaceholderRight.length | ||
const maxHashSize = 22 | ||
// from https://github.com/rollup/rollup/blob/91352494fc722bcd5e8e922cd1497b34aec57a67/src/utils/hashPlaceholders.ts#L41-L46 | ||
const hashPlaceholderRE = new RegExp( | ||
// eslint-disable-next-line regexp/strict, regexp/prefer-w | ||
`${hashPlaceholderLeft}[0-9a-zA-Z_$]{1,${ | ||
maxHashSize - hashPlaceholderOverhead | ||
}}${hashPlaceholderRight}`, | ||
'g', | ||
) | ||
|
||
const hashPlaceholderToFacadeModuleIdHashMap = new Map<string, string>() | ||
|
||
function augmentFacadeModuleIdHash(name: string): string { | ||
return name.replace( | ||
hashPlaceholderRE, | ||
(match) => hashPlaceholderToFacadeModuleIdHashMap.get(match) ?? match, | ||
) | ||
} | ||
|
||
function createChunkImportMap( | ||
bundle: OutputBundle, | ||
base: string, | ||
): Record<string, string> { | ||
return Object.fromEntries( | ||
Object.values(bundle) | ||
.filter((chunk): chunk is OutputChunk => chunk.type === 'chunk') | ||
.map((output) => { | ||
return [ | ||
base + augmentFacadeModuleIdHash(output.preliminaryFileName), | ||
base + output.fileName, | ||
] | ||
}), | ||
) | ||
} | ||
|
||
export function getHash(text: Buffer | string, length = 8): string { | ||
const h = hash('sha256', text, 'hex').substring(0, length) | ||
if (length <= 64) return h | ||
return h.padEnd(length, '_') | ||
} | ||
|
||
function viteLegacyPlugin(options: Options = {}): Plugin[] { | ||
let config: ResolvedConfig | ||
let targets: Options['targets'] | ||
|
@@ -167,6 +212,7 @@ function viteLegacyPlugin(options: Options = {}): Plugin[] { | |
const isDebug = | ||
debugFlags.includes('vite:*') || debugFlags.includes('vite:legacy') | ||
|
||
const chunkImportMap = new Map() | ||
const facadeToLegacyChunkMap = new Map() | ||
const facadeToLegacyPolyfillMap = new Map() | ||
const facadeToModernPolyfillMap = new Map() | ||
|
@@ -519,6 +565,16 @@ function viteLegacyPlugin(options: Options = {}): Plugin[] { | |
} | ||
} | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I tried simply adding an if statement at the end of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The current That said, I noticed that the script that has the |
||
if (config.build.chunkImportMap) { | ||
const hashPlaceholder = chunk.fileName.match(hashPlaceholderRE)?.[0] | ||
if (hashPlaceholder) { | ||
hashPlaceholderToFacadeModuleIdHashMap.set( | ||
hashPlaceholder, | ||
getHash(chunk.facadeModuleId ?? chunk.fileName), | ||
) | ||
} | ||
} | ||
|
||
if (!genLegacy) { | ||
return null | ||
} | ||
|
@@ -574,13 +630,26 @@ function viteLegacyPlugin(options: Options = {}): Plugin[] { | |
return null | ||
}, | ||
|
||
transformIndexHtml(html, { chunk }) { | ||
transformIndexHtml(html, { chunk, filename, bundle }) { | ||
if (config.build.ssr) return | ||
if (!chunk) return | ||
if (chunk.fileName.includes('-legacy')) { | ||
// The legacy bundle is built first, and its index.html isn't actually emitted if | ||
// modern bundle will be generated. Here we simply record its corresponding legacy chunk. | ||
facadeToLegacyChunkMap.set(chunk.facadeModuleId, chunk.fileName) | ||
|
||
if (config.build.chunkImportMap) { | ||
const relativeUrlPath = path.posix.relative( | ||
config.root, | ||
normalizePath(filename), | ||
) | ||
const assetsBase = getBaseInHTML(relativeUrlPath, config) | ||
chunkImportMap.set( | ||
chunk.facadeModuleId, | ||
createChunkImportMap(bundle!, assetsBase), | ||
) | ||
} | ||
|
||
if (genModern) { | ||
return | ||
} | ||
|
@@ -703,6 +772,19 @@ function viteLegacyPlugin(options: Options = {}): Plugin[] { | |
}) | ||
} | ||
|
||
if (config.build.chunkImportMap) { | ||
const imports = chunkImportMap.get(chunk.facadeModuleId) | ||
|
||
if (imports) { | ||
tags.push({ | ||
tag: 'script', | ||
attrs: { type: 'systemjs-importmap' }, | ||
children: JSON.stringify({ imports }), | ||
injectTo: 'head-prepend', | ||
}) | ||
} | ||
} | ||
|
||
return { | ||
html, | ||
tags, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,117 @@ | ||
import path from 'node:path' | ||
import type { OutputBundle, OutputChunk } from 'rollup' | ||
import MagicString from 'magic-string' | ||
import { getHash, normalizePath } from '../utils' | ||
import type { Plugin } from '../plugin' | ||
import type { ResolvedConfig } from '../config' | ||
import type { IndexHtmlTransformHook } from './html' | ||
|
||
const hashPlaceholderLeft = '!~{' | ||
const hashPlaceholderRight = '}~' | ||
const hashPlaceholderOverhead = | ||
hashPlaceholderLeft.length + hashPlaceholderRight.length | ||
export const maxHashSize = 22 | ||
// from https://github.com/rollup/rollup/blob/91352494fc722bcd5e8e922cd1497b34aec57a67/src/utils/hashPlaceholders.ts#L41-L46 | ||
const hashPlaceholderRE = new RegExp( | ||
// eslint-disable-next-line regexp/strict, regexp/prefer-w | ||
`${hashPlaceholderLeft}[0-9a-zA-Z_$]{1,${ | ||
maxHashSize - hashPlaceholderOverhead | ||
}}${hashPlaceholderRight}`, | ||
'g', | ||
) | ||
|
||
const hashPlaceholderToFacadeModuleIdHashMap = new Map() | ||
|
||
function augmentFacadeModuleIdHash(name: string): string { | ||
return name.replace( | ||
hashPlaceholderRE, | ||
(match) => hashPlaceholderToFacadeModuleIdHashMap.get(match) ?? match, | ||
) | ||
} | ||
|
||
export function createChunkImportMap( | ||
bundle: OutputBundle, | ||
base: string = '', | ||
): Record<string, string> { | ||
return Object.fromEntries( | ||
Object.values(bundle) | ||
.filter((chunk): chunk is OutputChunk => chunk.type === 'chunk') | ||
.map((output) => { | ||
return [ | ||
base + augmentFacadeModuleIdHash(output.preliminaryFileName), | ||
base + output.fileName, | ||
] | ||
Comment on lines
+40
to
+43
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Note to reviewers: the mapping by the browsers is operated after both the module specifier and the key of import maps are resolved to absolute URLs. |
||
}), | ||
) | ||
} | ||
|
||
export function chunkImportMapPlugin(): Plugin { | ||
return { | ||
name: 'vite:chunk-importmap', | ||
|
||
// If the hash part is simply removed, there is a risk of key collisions within the importmap. | ||
// For example, both `foo/index-[hash].js` and `index-[hash].js` would become `assets/index-.js`. | ||
// Therefore, a hash is generated from the facadeModuleId to avoid this issue. | ||
renderChunk(code, _chunk, _options, meta) { | ||
Object.values(meta.chunks).forEach((chunk) => { | ||
const hashPlaceholder = chunk.fileName.match(hashPlaceholderRE)?.[0] | ||
if (!hashPlaceholder) return | ||
if (hashPlaceholderToFacadeModuleIdHashMap.get(hashPlaceholder)) return | ||
|
||
hashPlaceholderToFacadeModuleIdHashMap.set( | ||
hashPlaceholder, | ||
getHash(chunk.facadeModuleId ?? chunk.fileName), | ||
) | ||
}) | ||
|
||
const s = new MagicString(code) | ||
// note that MagicString::replace is used instead of String::replace | ||
s.replace( | ||
hashPlaceholderRE, | ||
(match) => hashPlaceholderToFacadeModuleIdHashMap.get(match) ?? match, | ||
) | ||
return { code: s.toString(), map: s.generateMap({ hires: 'boundary' }) } | ||
}, | ||
} | ||
} | ||
|
||
export function postChunkImportMapHook( | ||
config: ResolvedConfig, | ||
): IndexHtmlTransformHook { | ||
return (html, ctx) => { | ||
if (!config.build.chunkImportMap) return | ||
|
||
const { filename, bundle } = ctx | ||
|
||
const relativeUrlPath = path.posix.relative( | ||
config.root, | ||
normalizePath(filename), | ||
) | ||
const assetsBase = getBaseInHTML(relativeUrlPath, config) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess this does not take There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I haven’t come up with a good way to use it together with the renderBuiltUrl runtime yet. I’ll need some time to investigate and think it over. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess there aren't any. |
||
|
||
return { | ||
html, | ||
tags: [ | ||
{ | ||
tag: 'script', | ||
attrs: { type: 'importmap' }, | ||
children: JSON.stringify({ | ||
imports: createChunkImportMap(bundle!, assetsBase), | ||
}), | ||
injectTo: 'head-prepend', | ||
}, | ||
], | ||
} | ||
} | ||
} | ||
|
||
function getBaseInHTML(urlRelativePath: string, config: ResolvedConfig) { | ||
// Prefer explicit URL if defined for linking to assets and public files from HTML, | ||
// even when base relative is specified | ||
return config.base === './' || config.base === '' | ||
? path.posix.join( | ||
path.posix.relative(urlRelativePath, '').slice(0, -2), | ||
'./', | ||
) | ||
: config.base | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
#16552 (comment)