Skip to content
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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/config/shared-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,13 @@ Enabling this setting causes vite to determine file identity by the original fil

A nonce value placeholder that will be used when generating script / style tags. Setting this value will also generate a meta tag with nonce value.

## html.chunkImportMap

- **Type:** `boolean`
- **Default:** `false`

Whether to inject importmap for generated chunks. This importmap is used to optimize caching efficiency.

## css.modules

- **Type:**
Expand Down
4 changes: 4 additions & 0 deletions docs/guide/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,10 @@ By default, during build, Vite inlines small assets as data URIs. Allowing `data
Do not allow `data:` for [`script-src`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src). It will allow injection of arbitrary scripts.
:::

## Chunk importmap

Creating an importmap for chunks helps prevent the cascading cache invalidation issue. This importmap features a list of stable file id linked to filenames with content-based hashes. When one chunk references another, it utilizes the file id instead of the filename hashed by content. 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.

## 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.
Expand Down
85 changes: 84 additions & 1 deletion packages/plugin-legacy/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
import type {
NormalizedOutputOptions,
OutputBundle,
OutputChunk,
OutputOptions,
PreRenderedChunk,
RenderedChunk,
Expand Down Expand Up @@ -122,6 +123,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: Map<string, string> = new Map()

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 = createHash('sha256').update(text).digest('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']
Expand Down Expand Up @@ -153,6 +199,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()
Expand Down Expand Up @@ -450,6 +497,16 @@ function viteLegacyPlugin(options: Options = {}): Plugin[] {
}
}

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
}
Expand Down Expand Up @@ -505,13 +562,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
}
Expand Down Expand Up @@ -634,6 +704,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,
Expand Down
36 changes: 36 additions & 0 deletions packages/vite/src/node/__tests__/build.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,42 @@ describe('build', () => {
assertOutputHashContentChange(result[0], result[1])
})

test('file hash should not change when changes for dynamic entries while chunk map option enabled', async () => {
const buildProject = async (text: string) => {
return (await build({
root: resolve(__dirname, 'packages/build-project'),
logLevel: 'silent',
build: {
chunkImportMap: true,
write: false,
},
plugins: [
{
name: 'test',
resolveId(id) {
if (id === 'entry.js' || id === 'subentry.js') {
return '\0' + id
}
},
load(id) {
if (id === '\0entry.js') {
return `window.addEventListener('click', () => { import('subentry.js') });`
}
if (id === '\0subentry.js') {
return `console.log(${text})`
}
},
},
],
})) as RollupOutput
}

const result = await Promise.all([buildProject('foo'), buildProject('bar')])

expect(result[0].output[0].fileName).toBe(result[1].output[0].fileName)
expect(result[0].output[1].fileName).not.toBe(result[1].output[1].fileName)
})

test('file hash should change when pure css chunk changes', async () => {
const buildProject = async (cssColor: string) => {
return (await build({
Expand Down
11 changes: 11 additions & 0 deletions packages/vite/src/node/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
import { manifestPlugin } from './plugins/manifest'
import type { Logger } from './logger'
import { dataURIPlugin } from './plugins/dataUri'
import { chunkImportMapPlugin } from './plugins/chunkImportMap'
import { buildImportAnalysisPlugin } from './plugins/importAnalysisBuild'
import { ssrManifestPlugin } from './ssr/ssrManifestPlugin'
import { loadFallbackPlugin } from './plugins/loadFallback'
Expand Down Expand Up @@ -247,6 +248,12 @@ export interface BuildOptions {
* @default null
*/
watch?: WatcherOptions | null
/**
* Whether to inject importmap for generated chunks.
* This importmap is used to optimize caching efficiency.
* @default false
*/
chunkImportMap?: boolean
}

export interface LibraryOptions {
Expand Down Expand Up @@ -353,6 +360,7 @@ export function resolveBuildOptions(
reportCompressedSize: true,
chunkSizeWarningLimit: 500,
watch: null,
chunkImportMap: false,
}

const userBuildOptions = raw
Expand Down Expand Up @@ -443,6 +451,9 @@ export async function resolveBuildPlugins(config: ResolvedConfig): Promise<{
Boolean,
) as Plugin[]),
...(config.isWorker ? [webWorkerPostPlugin()] : []),
...(!config.isWorker && options.chunkImportMap
? [chunkImportMapPlugin()]
: []),
],
post: [
buildImportAnalysisPlugin(config),
Expand Down
117 changes: 117 additions & 0 deletions packages/vite/src/node/plugins/chunkImportMap.ts
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,
]
}),
)
}

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 codeProcessed = augmentFacadeModuleIdHash(code)
return {
code: codeProcessed,
map: new MagicString(codeProcessed).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)

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
}