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(html): Inline entry chunk into html when possible #4555

Merged
merged 3 commits into from
Sep 13, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions packages/playground/html/__tests__/html.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,37 @@ describe('nested w/ query', () => {

testPage(true)
})

if (isBuild) {
describe('inline entry', () => {
const _countTags = (selector) => page.$$eval(selector, (t) => t.length)
const countScriptTags = _countTags.bind(this, 'script[type=module]')
const countPreloadTags = _countTags.bind(this, 'link[rel=modulepreload]')

test('is inlined', async () => {
await page.goto(viteTestUrl + '/inline/shared-1.html?v=1')
expect(await countScriptTags()).toBeGreaterThan(1)
expect(await countPreloadTags()).toBe(0)
})

test('is not inlined', async () => {
await page.goto(viteTestUrl + '/inline/unique.html?v=1')
expect(await countScriptTags()).toBe(1)
expect(await countPreloadTags()).toBeGreaterThan(0)
})

test('execution order when inlined', async () => {
await page.goto(viteTestUrl + '/inline/shared-2.html?v=1')
expect((await page.textContent('#output')).trim()).toBe(
'dep1 common dep2 dep3 shared'
)
})

test('execution order when not inlined', async () => {
await page.goto(viteTestUrl + '/inline/unique.html?v=1')
expect((await page.textContent('#output')).trim()).toBe(
'dep1 common dep2 unique'
)
})
})
}
8 changes: 8 additions & 0 deletions packages/playground/html/inline/common.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import './dep1'
import './dep2'

export function log(name) {
document.getElementById('output').innerHTML += name + ' '
}

log('common')
3 changes: 3 additions & 0 deletions packages/playground/html/inline/dep1.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { log } from './common'

log('dep1')
3 changes: 3 additions & 0 deletions packages/playground/html/inline/dep2.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { log } from './common'

log('dep2')
4 changes: 4 additions & 0 deletions packages/playground/html/inline/dep3.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import './dep2'
import { log } from './common'

log('dep3')
16 changes: 16 additions & 0 deletions packages/playground/html/inline/module-graph.dot
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
digraph Module {
common -> { dep1, dep2 } [style=dashed,color=grey]
dep1 -> common
dep2 -> common
dep3 -> { dep2, common }

subgraph shared {
shared [style=filled]
shared -> { dep3, common }
}

subgraph unique {
unique [style=filled]
unique -> { common, dep2 }
}
}
2 changes: 2 additions & 0 deletions packages/playground/html/inline/shared-1.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<pre id="output"></pre>
<script type="module" src="./shared.js"></script>
2 changes: 2 additions & 0 deletions packages/playground/html/inline/shared-2.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<pre id="output"></pre>
<script type="module" src="./shared.js"></script>
4 changes: 4 additions & 0 deletions packages/playground/html/inline/shared.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import './dep3'
import { log } from './common'

log('shared')
2 changes: 2 additions & 0 deletions packages/playground/html/inline/unique.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<pre id="output"></pre>
<script type="module" src="./unique.js"></script>
4 changes: 4 additions & 0 deletions packages/playground/html/inline/unique.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { log } from './common'
import './dep2'

log('unique')
5 changes: 4 additions & 1 deletion packages/playground/html/vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ module.exports = {
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
nested: resolve(__dirname, 'nested/index.html')
nested: resolve(__dirname, 'nested/index.html'),
inline1: resolve(__dirname, 'inline/shared-1.html'),
inline2: resolve(__dirname, 'inline/shared-2.html'),
inline3: resolve(__dirname, 'inline/unique.html'),
}
}
},
Expand Down
82 changes: 55 additions & 27 deletions packages/vite/src/node/plugins/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,30 +273,43 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin {
}
},

async generateBundle(_, bundle) {
async generateBundle(options, bundle) {
const analyzedChunk: Map<OutputChunk, number> = new Map()
const getPreloadLinksForChunk = (
const getImportedChunks = (
chunk: OutputChunk,
seen: Set<string> = new Set()
): HtmlTagDescriptor[] => {
const tags: HtmlTagDescriptor[] = []
): OutputChunk[] => {
const chunks: OutputChunk[] = []
chunk.imports.forEach((file) => {
const importee = bundle[file]
if (importee?.type === 'chunk' && !seen.has(file)) {
seen.add(file)
tags.push({
tag: 'link',
attrs: {
rel: 'modulepreload',
href: toPublicPath(file, config)
}
})
tags.push(...getPreloadLinksForChunk(importee, seen))

// post-order traversal
chunks.push(...getImportedChunks(importee, seen))
chunks.push(importee)
}
})
return tags
return chunks
}

const toScriptTag = (chunk: OutputChunk): HtmlTagDescriptor => ({
tag: 'script',
attrs: {
type: 'module',
crossorigin: true,
src: toPublicPath(chunk.fileName, config)
}
})

const toPreloadTag = (chunk: OutputChunk): HtmlTagDescriptor => ({
tag: 'link',
attrs: {
rel: 'modulepreload',
href: toPublicPath(chunk.fileName, config)
}
})

const getCssTagsForChunk = (
chunk: OutputChunk,
seen: Set<string> = new Set()
Expand Down Expand Up @@ -343,23 +356,25 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin {
chunk.isEntry &&
chunk.facadeModuleId === id
) as OutputChunk | undefined
let canInlineEntry = false

// inject chunk asset links
if (chunk) {
const assetTags = [
// js entry chunk for this page
{
tag: 'script',
attrs: {
type: 'module',
crossorigin: true,
src: toPublicPath(chunk.fileName, config)
}
},
// preload for imports
...getPreloadLinksForChunk(chunk),
...getCssTagsForChunk(chunk)
]
// an entry chunk can be inlined if
// - it's an ES module (e.g. not generated by the legacy plugin)
// - it contains no meaningful code other than import statments
if (options.format === 'es' && isEntirelyImport(chunk.code)) {
canInlineEntry = true
}

// when not inlined, inject <script> for entry and modulepreload its dependencies
// when inlined, discard entry chunk and inject <script> for everything in post-order
const imports = getImportedChunks(chunk)
const assetTags = canInlineEntry
? imports.map(toScriptTag)
: [toScriptTag(chunk), ...imports.map(toPreloadTag)]

assetTags.push(...getCssTagsForChunk(chunk))

result = injectToHead(result, assetTags)
}
Expand Down Expand Up @@ -390,6 +405,11 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin {
chunk
})

if (chunk && canInlineEntry) {
// all imports from entry have been inlined to html, prevent rollup from outputting it
delete bundle[chunk.fileName]
}

this.emitFile({
type: 'asset',
fileName: shortEmitName,
Expand Down Expand Up @@ -523,6 +543,14 @@ export async function applyHtmlTransforms(
return html
}

const importRE = /\bimport\s*("[^"]*[^\\]"|'[^']*[^\\]');*/g
const commentRE = /\/\*[\s\S]*?\*\/|\/\/.*$/gm
function isEntirelyImport(code: string) {
// only consider "side-effect" imports, which match <script type=module> semantics exactly
// the regexes will remove too little in some exotic cases, but false-negatives are alright
return !code.replace(importRE, '').replace(commentRE, '').trim().length
andylizi marked this conversation as resolved.
Show resolved Hide resolved
}

function toPublicPath(filename: string, config: ResolvedConfig) {
return isExternalUrl(filename) ? filename : config.base + filename
}
Expand Down