feat(asset): inline svg in dev if within limit (#18581)

This commit is contained in:
Bjorn Lu 2024-11-06 17:28:44 +08:00 committed by GitHub
parent 3ef0bf19a3
commit f08b1463db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 122 additions and 41 deletions

View File

@ -120,7 +120,10 @@ export interface BuildEnvironmentOptions {
assetsDir?: string
/**
* Static asset files smaller than this number (in bytes) will be inlined as
* base64 strings. Default limit is `4096` (4 KiB). Set to `0` to disable.
* base64 strings. If a callback is passed, a boolean can be returned to opt-in
* or opt-out of inlining. If nothing is returned the default logic applies.
*
* Default limit is `4096` (4 KiB). Set to `0` to disable.
* @default 4096
*/
assetsInlineLimit?:

View File

@ -38,6 +38,7 @@ const jsSourceMapRE = /\.[cm]?js\.map$/
const noInlineRE = /[?&]no-inline\b/
const inlineRE = /[?&]inline\b/
const svgExtRE = /\.svg(?:$|\?)/
const assetCache = new WeakMap<Environment, Map<string, string>>()
@ -180,12 +181,12 @@ export function assetPlugin(config: ResolvedConfig): Plugin {
let url = await fileToUrl(this, id)
// Inherit HMR timestamp if this asset was invalidated
const environment = this.environment
const mod =
environment.mode === 'dev' && environment.moduleGraph.getModuleById(id)
if (!url.startsWith('data:') && this.environment.mode === 'dev') {
const mod = this.environment.moduleGraph.getModuleById(id)
if (mod && mod.lastHMRTimestamp > 0) {
url = injectQuery(url, `t=${mod.lastHMRTimestamp}`)
}
}
return {
code: `export default ${JSON.stringify(encodeURIPath(url))}`,
@ -266,16 +267,27 @@ export async function fileToDevUrl(
skipBase = false,
): Promise<string> {
const config = environment.getTopLevelConfig()
const publicFile = checkPublicFile(id, config)
// If has inline query, unconditionally inline the asset
if (inlineRE.test(id)) {
const file = checkPublicFile(id, config) || cleanUrl(id)
const file = publicFile || cleanUrl(id)
const content = await fsp.readFile(file)
return assetToDataURL(environment, file, content)
}
// If is svg and it's inlined in build, also inline it in dev to match
// the behaviour in build due to quote handling differences.
if (svgExtRE.test(id)) {
const file = publicFile || cleanUrl(id)
const content = await fsp.readFile(file)
if (shouldInline(environment, file, id, content, undefined, undefined)) {
return assetToDataURL(environment, file, content)
}
}
let rtn: string
if (checkPublicFile(id, config)) {
if (publicFile) {
// in public dir during dev, keep the url as-is
rtn = id
} else if (id.startsWith(withTrailingSlash(config.root))) {
@ -369,7 +381,9 @@ async function fileToBuiltUrl(
const content = await fsp.readFile(file)
let url: string
if (shouldInline(pluginContext, file, id, content, forceInline)) {
if (
shouldInline(environment, file, id, content, pluginContext, forceInline)
) {
url = assetToDataURL(environment, file, content)
} else {
// emit as asset
@ -413,21 +427,28 @@ export async function urlToBuiltUrl(
)
}
const shouldInline = (
pluginContext: PluginContext,
function shouldInline(
environment: Environment,
file: string,
id: string,
content: Buffer,
/** Should be passed only in build */
buildPluginContext: PluginContext | undefined,
forceInline: boolean | undefined,
): boolean => {
const environment = pluginContext.environment
const { assetsInlineLimit } = environment.config.build
): boolean {
if (noInlineRE.test(id)) return false
if (inlineRE.test(id)) return true
// Do build only checks if passed the plugin context during build
if (buildPluginContext) {
if (environment.config.build.lib) return true
if (pluginContext.getModuleInfo(id)?.isEntry) return false
if (buildPluginContext.getModuleInfo(id)?.isEntry) return false
}
if (forceInline !== undefined) return forceInline
if (file.endsWith('.html')) return false
// Don't inline SVG with fragments, as they are meant to be reused
if (file.endsWith('.svg') && id.includes('#')) return false
let limit: number
const { assetsInlineLimit } = environment.config.build
if (typeof assetsInlineLimit === 'function') {
const userShouldInline = assetsInlineLimit(file, content)
if (userShouldInline != null) return userShouldInline
@ -435,9 +456,6 @@ const shouldInline = (
} else {
limit = Number(assetsInlineLimit)
}
if (file.endsWith('.html')) return false
// Don't inline SVG with fragments, as they are meant to be reused
if (file.endsWith('.svg') && id.includes('#')) return false
return content.length < limit && !isGitLfsPlaceholder(content)
}

View File

@ -282,15 +282,11 @@ describe('css url() references', () => {
})
test('url() with svg', async () => {
expect(await getBg('.css-url-svg')).toMatch(
isBuild ? /data:image\/svg\+xml,.+/ : '/foo/bar/nested/fragment-bg.svg',
)
expect(await getBg('.css-url-svg')).toMatch(/data:image\/svg\+xml,.+/)
})
test('image-set() with svg', async () => {
expect(await getBg('.css-image-set-svg')).toMatch(
isBuild ? /data:image\/svg\+xml,.+/ : '/foo/bar/nested/fragment-bg.svg',
)
expect(await getBg('.css-image-set-svg')).toMatch(/data:image\/svg\+xml,.+/)
})
})
@ -376,10 +372,8 @@ describe('svg fragments', () => {
test('from js import', async () => {
const img = await page.$('.svg-frag-import')
expect(await img.getAttribute('src')).toMatch(
isBuild
? // Assert trimmed (data URI starts with < and ends with >)
/^data:image\/svg\+xml,%3c.*%3e#icon-heart-view$/
: /svg#icon-heart-view$/,
// Assert trimmed (data URI starts with < and ends with >)
/^data:image\/svg\+xml,%3c.*%3e#icon-heart-view$/,
)
})

View File

@ -884,9 +884,24 @@ if (!isBuild) {
)
})
test('assets HMR', async () => {
test('not inlined assets HMR', async () => {
await setupModuleRunner('/hmr.ts')
const el = () => hmr('#logo-no-inline')
await untilConsoleLogAfter(
() =>
editFile('logo-no-inline.svg', (code) =>
code.replace('height="30px"', 'height="40px"'),
),
/Logo-no-inline updated/,
)
await vi.waitUntil(() => el().includes('logo-no-inline.svg?t='))
})
test('inlined assets HMR', async () => {
await setupModuleRunner('/hmr.ts')
const el = () => hmr('#logo')
const initialLogoUrl = el()
expect(initialLogoUrl).toMatch(/^data:image\/svg\+xml/)
await untilConsoleLogAfter(
() =>
editFile('logo.svg', (code) =>
@ -894,7 +909,10 @@ if (!isBuild) {
),
/Logo updated/,
)
await vi.waitUntil(() => el().includes('logo.svg?t='))
// Should be updated with new data url
const updatedLogoUrl = el()
expect(updatedLogoUrl).toMatch(/^data:image\/svg\+xml/)
expect(updatedLogoUrl).not.toEqual(initialLogoUrl)
})
} else {
test('this file only includes test for serve', () => {

View File

@ -8,6 +8,7 @@ import './intermediate-file-delete'
import './circular'
import './queries'
import logo from './logo.svg'
import logoNoInline from './logo-no-inline.svg'
import { msg as softInvalidationMsg } from './soft-invalidation'
export const foo = 1
@ -16,7 +17,8 @@ text('.dep', depFoo)
text('.nested', nestedFoo)
text('.virtual', virtual)
text('.soft-invalidation', softInvalidationMsg)
setLogo(logo)
setImgSrc('#logo', logo)
setImgSrc('#logo-no-inline', logoNoInline)
globalThis.__HMR__['virtual:increment'] = () => {
if (import.meta.hot) {
@ -41,10 +43,15 @@ if (import.meta.hot) {
}
import.meta.hot.accept('./logo.svg', (newUrl) => {
setLogo(newUrl.default)
setImgSrc('#logo', newUrl.default)
log('Logo updated', newUrl.default)
})
import.meta.hot.accept('./logo-no-inline.svg', (newUrl) => {
setImgSrc('#logo-no-inline', newUrl.default)
log('Logo-no-inline updated', newUrl.default)
})
import.meta.hot.accept('./hmrDep', ({ foo, nestedFoo }) => {
handleDep('single dep', foo, nestedFoo)
})
@ -98,8 +105,8 @@ function text(el, text) {
hmr(el, text)
}
function setLogo(src) {
hmr('#logo', src)
function setImgSrc(el, src) {
hmr(el, src)
}
function removeCb({ msg }) {

View File

@ -0,0 +1,3 @@
<svg viewBox="0 0 30 30" height="30px" width="30px" xmlns="http://www.w3.org/2000/svg">
<text x="1" y="20" fill="#646cff" font-size="16px">Vite</text>
</svg>

After

Width:  |  Height:  |  Size: 162 B

View File

@ -5,6 +5,13 @@ export default defineConfig({
experimental: {
hmrPartialAccept: true,
},
build: {
assetsInlineLimit(filePath) {
if (filePath.endsWith('logo-no-inline.svg')) {
return false
}
},
},
plugins: [
{
name: 'mock-custom',

View File

@ -998,7 +998,20 @@ if (!isBuild) {
)
})
test('assets HMR', async () => {
test('not inlined assets HMR', async () => {
await page.goto(viteTestUrl)
const el = await page.$('#logo-no-inline')
await untilBrowserLogAfter(
() =>
editFile('logo-no-inline.svg', (code) =>
code.replace('height="30px"', 'height="40px"'),
),
/Logo-no-inline updated/,
)
await untilUpdated(() => el.evaluate((it) => `${it.clientHeight}`), '40')
})
test('inlined assets HMR', async () => {
await page.goto(viteTestUrl)
const el = await page.$('#logo')
await untilBrowserLogAfter(

View File

@ -6,6 +6,7 @@ import './optional-chaining/parent'
import './intermediate-file-delete'
import './circular'
import logo from './logo.svg'
import logoNoInline from './logo-no-inline.svg'
import { msg as softInvalidationMsg } from './soft-invalidation'
export const foo = 1
@ -14,7 +15,8 @@ text('.dep', depFoo)
text('.nested', nestedFoo)
text('.virtual', virtual)
text('.soft-invalidation', softInvalidationMsg)
setLogo(logo)
setImgSrc('#logo', logo)
setImgSrc('#logo-no-inline', logoNoInline)
const btn = document.querySelector('.virtual-update') as HTMLButtonElement
btn.onclick = () => {
@ -40,10 +42,15 @@ if (import.meta.hot) {
}
import.meta.hot.accept('./logo.svg', (newUrl) => {
setLogo(newUrl.default)
setImgSrc('#logo', newUrl.default)
console.log('Logo updated', newUrl.default)
})
import.meta.hot.accept('./logo-no-inline.svg', (newUrl) => {
setImgSrc('#logo-no-inline', newUrl.default)
console.log('Logo-no-inline updated', newUrl.default)
})
import.meta.hot.accept('./hmrDep', ({ foo, nestedFoo }) => {
handleDep('single dep', foo, nestedFoo)
})
@ -131,8 +138,8 @@ function text(el, text) {
document.querySelector(el).textContent = text
}
function setLogo(src) {
;(document.querySelector('#logo') as HTMLImageElement).src = src
function setImgSrc(el, src) {
;(document.querySelector(el) as HTMLImageElement).src = src
}
function removeCb({ msg }) {

View File

@ -38,5 +38,6 @@
<div class="optional-chaining"></div>
<button class="intermediate-file-delete-increment">1</button>
<div class="intermediate-file-delete-display"></div>
<image id="logo"></image>
<img id="logo" />
<img id="logo-no-inline" />
<div class="circular"></div>

View File

@ -0,0 +1,3 @@
<svg viewBox="0 0 30 30" height="30px" width="30px" xmlns="http://www.w3.org/2000/svg">
<text x="1" y="20" fill="#646cff" font-size="16px">Vite</text>
</svg>

After

Width:  |  Height:  |  Size: 162 B

View File

@ -7,6 +7,13 @@ export default defineConfig({
experimental: {
hmrPartialAccept: true,
},
build: {
assetsInlineLimit(filePath) {
if (filePath.endsWith('logo-no-inline.svg')) {
return false
}
},
},
plugins: [
{
name: 'mock-custom',