Performance: Split large dependencies into their own individual chunks (take 2) (#26594)

This commit is contained in:
Tom Ross 2021-10-29 09:48:10 +01:00 committed by GitHub
parent 04d5cbccc8
commit bd4b3c02e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 120 additions and 41 deletions

View File

@ -0,0 +1,42 @@
const config = {
files: [
/**
* Our main entry JavaScript bundles, contains core logic that is loaded on every page.
*/
{
path: '../../ui/assets/scripts/*.bundle.js.br',
maxSize: '500kb',
compression: 'none',
},
/**
* Our generated application chunks. Matches the deterministic id generated by Webpack.
*
* Note: The vast majority of our chunks are under 200kb, this threshold is bloated as we treat the Monaco editor as a normal chunk.
* We should consider not doing this, as it is much larger than other chunks and we would likely benefit from caching this differently.
* Issue to improve this: https://github.com/sourcegraph/sourcegraph/issues/26573
*/
{
path: '../../ui/assets/scripts/[0-9]*.chunk.js.br',
maxSize: '500kb',
compression: 'none',
},
/**
* Our generated worker files.
*/
{
path: '../../ui/assets/*.worker.js.br',
maxSize: '250kb',
compression: 'none',
},
/**
* Our main entry CSS bundle, contains core styles that are loaded on every page.
*/
{
path: '../../ui/assets/styles/app.*.css.br',
maxSize: '50kb',
compression: 'none',
},
],
}
module.exports = config

View File

@ -4,22 +4,17 @@ import path from 'path'
import * as esbuild from 'esbuild'
import { STATIC_ASSETS_PATH } from '../utils'
import { WebpackManifest } from '../webpack/get-html-webpack-plugins'
export const assetPathPrefix = '/.assets'
interface Manifest {
'app.js': string
'app.css': string
isModule: boolean
}
export const getManifest = (): Manifest => ({
'app.js': path.join(assetPathPrefix, 'scripts/app.js'),
'app.css': path.join(assetPathPrefix, 'scripts/app.css'),
export const getManifest = (): WebpackManifest => ({
appBundle: path.join(assetPathPrefix, 'scripts/app.js'),
cssBundle: path.join(assetPathPrefix, 'scripts/app.css'),
isModule: true,
})
const writeManifest = async (manifest: Manifest): Promise<void> => {
const writeManifest = async (manifest: WebpackManifest): Promise<void> => {
const manifestPath = path.join(STATIC_ASSETS_PATH, 'webpack.manifest.json')
await fs.promises.writeFile(manifestPath, JSON.stringify(manifest, null, 2))
}

View File

@ -136,10 +136,7 @@ async function startEsbuildDevelopmentServer({
csrfTokenCookieMiddleware,
}: DevelopmentServerInit): Promise<void> {
const manifest = getManifest()
const htmlPage = getHTMLPage({
head: `<link rel="stylesheet" href="${manifest['app.css']}">`,
bodyEnd: `<script src="${manifest['app.js']}" type="module"></script>`,
})
const htmlPage = getHTMLPage(manifest)
await esbuildDevelopmentServer({ host: '0.0.0.0', port: SOURCEGRAPH_HTTPS_PORT }, app => {
app.use(csrfTokenCookieMiddleware)

View File

@ -8,16 +8,33 @@ import { createJsContext, environmentConfig, STATIC_ASSETS_PATH } from '../utils
const { SOURCEGRAPH_HTTPS_PORT, NODE_ENV } = environmentConfig
interface HTMLPageData {
head: string
bodyEnd: string
export interface WebpackManifest {
/** Main app entry JS bundle */
appBundle: string
/** Main app entry CSS bundle, only used in production mode */
cssBundle?: string
/** Runtime bundle, only used in development mode */
runtimeBundle?: string
/** React entry bundle, only used in production mode */
reactBundle?: string
/** If script files should be treated as JS modules. Required for esbuild bundle. */
isModule?: boolean
}
/**
* Returns an HTML page similar to `cmd/frontend/internal/app/ui/app.html` but when running
* without the `frontend` service.
*
* Note: This page should be kept as close as possible to `app.html` to avoid any inconsistencies
* between our development server and the actual production server.
*/
export const getHTMLPage = ({ head, bodyEnd }: HTMLPageData): string => `
export const getHTMLPage = ({
appBundle,
cssBundle,
runtimeBundle,
reactBundle,
isModule,
}: WebpackManifest): string => `
<!DOCTYPE html>
<html lang="en">
<head>
@ -26,7 +43,7 @@ export const getHTMLPage = ({ head, bodyEnd }: HTMLPageData): string => `
<meta name="viewport" content="width=device-width, viewport-fit=cover" />
<meta name="referrer" content="origin-when-cross-origin"/>
<meta name="color-scheme" content="light dark"/>
${head}
${cssBundle ? `<link rel="stylesheet" href="${cssBundle}">` : ''}
</head>
<body>
<div id="root"></div>
@ -39,19 +56,40 @@ export const getHTMLPage = ({ head, bodyEnd }: HTMLPageData): string => `
createJsContext({ sourcegraphBaseUrl: `http://localhost:${SOURCEGRAPH_HTTPS_PORT}` })
)}
</script>
${bodyEnd}
${runtimeBundle ? `<script src="${runtimeBundle}"></script>` : ''}
${reactBundle ? `<script src="${reactBundle}" ${isModule ? 'type="module"' : ''}></script>` : ''}
<script src="${appBundle}" ${isModule ? 'type="module"' : ''}></script>
</body>
</html>
`
/**
* Search a list of file strings for a specific file.
* Only uses the file prefix to allow matching against content-hashed filenames.
*/
const getBundleFromPath = (files: string[], filePrefix: string): string | undefined =>
files.find(file => file.startsWith(`/.assets/${filePrefix}`))
export const getHTMLWebpackPlugins = (): WebpackPluginInstance[] => {
const htmlWebpackPlugin = new HtmlWebpackPlugin({
// `TemplateParameter` can be mutated. We need to tell TS that we didn't touch it.
templateContent: (({ htmlWebpackPlugin }: TemplateParameter): string =>
getHTMLPage({
head: htmlWebpackPlugin.tags.headTags.filter(tag => tag.tagName !== 'script').toString(),
bodyEnd: htmlWebpackPlugin.tags.headTags.filter(tag => tag.tagName === 'script').toString(),
})) as Options['templateContent'],
templateContent: (({ htmlWebpackPlugin }: TemplateParameter): string => {
const { files } = htmlWebpackPlugin
const appBundle = getBundleFromPath(files.js, 'scripts/app')
if (!appBundle) {
throw new Error('Could not find any entry bundle')
}
return getHTMLPage({
appBundle,
cssBundle: getBundleFromPath(files.css, 'styles/app'),
runtimeBundle: getBundleFromPath(files.js, 'scripts/runtime'),
reactBundle: getBundleFromPath(files.js, 'scripts/react'),
})
}) as Options['templateContent'],
filename: path.resolve(STATIC_ASSETS_PATH, 'index.html'),
alwaysWriteToDisk: true,
inject: false,

View File

@ -30,17 +30,10 @@
"eslint": "eslint --cache '**/*.[tj]s?(x)'",
"stylelint": "stylelint 'src/**/*.scss' --quiet",
"browserslist": "browserslist",
"analyze-bundle": "NODE_ENV=production ENTERPRISE=1 WEBPACK_ANALYZER=1 yarn build",
"bundlesize": "bundlesize"
"analyze-bundle": "WEBPACK_USE_NAMED_CHUNKS=true NODE_ENV=production ENTERPRISE=1 WEBPACK_ANALYZER=1 yarn build",
"bundlesize": "bundlesize --config=./bundlesize.config.js"
},
"jest": {
"testURL": "http://localhost:3080"
},
"bundlesize": [
{
"path": "../../ui/assets/scripts/*.js",
"maxSize": "600kb",
"compression": "brotli"
}
]
}
}

View File

@ -108,6 +108,15 @@ const config = {
}),
new CssMinimizerWebpackPlugin(),
],
splitChunks: {
cacheGroups: {
react: {
test: /[/\\]node_modules[/\\](react|react-dom)[/\\]/,
name: 'react',
chunks: 'all',
},
},
},
...(isDevelopment && {
// Running multiple entries on a single page that do not share a runtime chunk from the same compilation is not supported.
// https://github.com/webpack/webpack-dev-server/issues/2792#issuecomment-808328432
@ -125,10 +134,11 @@ const config = {
output: {
path: path.join(rootPath, 'ui', 'assets'),
// Do not [hash] for development -- see https://github.com/webpack/webpack-dev-server/issues/377#issuecomment-241258405
// Note: [name] will vary depending on the Webpack chunk. If specified, it will use a provided chunk name, otherwise it will fallback to a deterministic id.
filename:
mode === 'production' && !useNamedChunks ? 'scripts/[name].[contenthash].bundle.js' : 'scripts/[name].bundle.js',
chunkFilename:
mode === 'production' && !useNamedChunks ? 'scripts/[id]-[contenthash].chunk.js' : 'scripts/[id].chunk.js',
mode === 'production' && !useNamedChunks ? 'scripts/[name]-[contenthash].chunk.js' : 'scripts/[name].chunk.js',
publicPath: '/.assets/',
globalObject: 'self',
pathinfo: false,

View File

@ -64,6 +64,7 @@
You need to enable JavaScript to run this app.
</noscript>
{{if .Manifest.AppJSRuntimeBundlePath}}<script src="{{.Manifest.AppJSRuntimeBundlePath}}"></script>{{end}}
<script src="{{.Manifest.ReactJSBundlePath}}" {{if .Manifest.IsModule}}type="module"{{end}}></script>
<script src="{{.Manifest.AppJSBundlePath}}" {{if .Manifest.IsModule}}type="module"{{end}}></script>
{{.Injected.BodyBottom}}
</body>

View File

@ -226,7 +226,7 @@
"babel-plugin-lodash": "^3.3.4",
"babel-preset-react-app": "^10.0.0",
"browserslist": "^4.17.4",
"bundlesize2": "^0.0.30",
"bundlesize2": "^0.0.31",
"chai": "^4.2.0",
"chai-as-promised": "^7.1.1",
"chalk": "^4.1.0",

View File

@ -1,6 +1,9 @@
package assets
type WebpackManifest struct {
// ReactJSBundlePath contains the file name of the ReactJS
// dependency bundle, that is required by our main app bundle.
ReactJSBundlePath string `json:"react.js"`
// AppJSBundlePath contains the file name of the main
// Webpack bundle that serves as the entrypoint
// for the webapp code.

View File

@ -7946,10 +7946,10 @@ buffer@^5.1.0, buffer@^5.2.1, buffer@^5.5.0, buffer@^5.7.0:
base64-js "^1.3.1"
ieee754 "^1.1.13"
bundlesize2@^0.0.30:
version "0.0.30"
resolved "https://registry.npmjs.org/bundlesize2/-/bundlesize2-0.0.30.tgz#a90de469eaaca9840bd20562a011f258f5c97cd9"
integrity sha512-dVNC4zGdaRMMf+KK4Xf+wsPIuuDYrgDtZaX5gb1jX2vRCSOgrh2ka0Y3BGmcwMC0vViBg38yoap9AUXLBItZFA==
bundlesize2@^0.0.31:
version "0.0.31"
resolved "https://registry.npmjs.org/bundlesize2/-/bundlesize2-0.0.31.tgz#cef6830dbbe5360e27fce07593ec0f4e3e5355d9"
integrity sha512-MdzJW/u+n+0jH0Uz78g8WENHAW7QNUdLD/c8aLuPB/aCIwt52zMJ4fc2fBU2y1K2iMwE/9+JoR8ojsAF0r0Xjw==
dependencies:
bytes "^3.1.0"
chalk "^4.0.0"