Skip to content

Commit

Permalink
feat: chunk importmap
Browse files Browse the repository at this point in the history
  • Loading branch information
bhbs committed Apr 29, 2024
1 parent b7ddfae commit 1a0eaf5
Show file tree
Hide file tree
Showing 23 changed files with 485 additions and 21 deletions.
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
}

0 comments on commit 1a0eaf5

Please sign in to comment.