One of the first features vite mentions on its website is that it has a very fast local cold start. This is mainly due to the fact that it is pre-built when the local service is started. Out of curiosity, I took the time to understand the main implementation ideas of Vite in the pre-build part and shared them for your reference.

Why prebuild

This is simply to improve the cold start speed of the local development server. According to Vite, when starting a development server with a cold boot, the packager-based boot must first crawl and build your entire application before it can be served. As the size of the application increases, the packaging speed decreases significantly, and the local server starts up slowly.

To speed up the startup of the local development server,viteA pre-build mechanism was introduced. In terms of the selection of pre-build tools,viteChoose theesbuildesbuilduseGoWrite, than toJavaScriptWritten packers build 10-100 times faster, with pre-built, reused browsersesmIn this way, the service code is loaded on demand, and the dynamic real-time construction is carried out. Combined with the caching mechanism, the startup speed of the server is greatly improved.

Pre-built processes

1. Find dependencies

If you are starting a local service for the first time, vite will automatically grab the source code, find the dependencies that need to be pre-built, and eventually return a DEPS object like the following:

{
  vue: '/path/to/your/project/node_modules/vue/dist/vue.runtime.esm-bundler.js',
  'element-plus': '/path/to/your/project/node_modules/element-plus/es/index.mjs',
  'vue-router': '/path/to/your/project/node_modules/vue-router/dist/vue-router.esm-bundler.js'
}
Copy the code

This is done by calling the build API of esBuild, using index.html as entryPoints, and finding all modules from node_modules as well as those specified in the optimizedeps.include option in the configuration file.

/ /... Omit other code if (explicitEntryPatterns) {entries = await globEntries(explicitEntryPatterns, config) } else if (buildInput) { const resolvePath = (p: string) => path.resolve(config.root, p) if (typeof buildInput === 'string') { entries = [resolvePath(buildInput)] } else if (Array.isArray(buildInput)) { entries = buildInput.map(resolvePath) } else if (isObject(buildInput)) { entries = Object.values(buildInput).map(resolvePath) } else { throw new Error('invalid rollupOptions.input value.') } } else { // Entries = await globEntries('**/*.html', config)} //... Build. onResolve({// avoid matching Windows Volume filter: /^[\w@][^:]/}, async ({path: id, importer }) => { const resolved = await resolve(id, The importer) if (resolved) {/ / node_modules and if specified in the include module (resolved. Includes (' node_modules') | | the include? .includes(id)) { // dependency or forced included, Externalize and Stop if (isOptimizable(resolved)) { DepImports [ID] = resolved} return externalUnlessEntry({path: id }) } else if (isScannable(resolved)) { const namespace = htmlTypesRE.test(resolved) ? 'html' : undefined // linked package, keep crawling return { path: path.resolve(resolved), namespace } } else { return externalUnlessEntry({ path: id }) } } else { missing[id] = normalizePath(importer) } } )Copy the code

By default, esBuild supports js, TS, JSX, CSS, JSON, Base64, dataURL, binary, file (.png, etc.), but not HTML. How does Vite manage to use index.html as a packaging entry point? The reason is that Vite implements its own esbuild plugin, esbuildScanPlugin, to handle.vue and.html files. This is done by reading the content of the HTML and extracting the script from it into an ESM js module.

// For HTML files (.vue /.html /.svelte etc.), extract the script contents of the file. html types: extract script contents ----------------------------------- build.onResolve({ filter: htmlTypesRE }, async ({ path, importer }) => { const resolved = await resolve(path, importer) if (! resolved) return // It is possible for the scanner to scan html types in node_modules. // If we can optimize this html type, skip it so it's handled by the // bare import resolve, and recorded as optimization dep. if (resolved.includes('node_modules') && isOptimizable(resolved)) return return { path: resolved, namespace: 'HTML'}}) // With build.onResolve, for htMl-like files, extract the script from them, As a js module extract scripts inside html-like files and treat it as a JS module build.onload ({filter: htmlTypesRE, namespace: 'html' }, async ({ path }) => { let raw = fs.readFileSync(path, 'utf-8') // Avoid matching the content of the comment raw = raw.replace(commentRE, '<! ---->') const isHtml = path.endsWith('.html') const regex = isHtml ? ScriptModuleRE: scriptRE regex.lastIndex = 0 // The contents of js are processed into a virtual module. RegExpExecArray | null while ((match = regex.exec(raw))) { const [, openTag, content] = match const typeMatch = openTag.match(typeRE) const type = typeMatch && (typeMatch[1] || typeMatch[2] || typeMatch[3]) const langMatch = openTag.match(langRE) const lang = langMatch && (langMatch[1] || langMatch[2] || langMatch[3]) // skip type="application/ld+json" and other non-JS types if ( type && ! (type. Includes (' javascript ') | | the includes (' ecmascript ') | | type = = = 'module')) {continue} / / the default js file loader is js, For TS, TSX, JSX, let loader: Loader = 'js' if (lang === 'ts' || lang === 'tsx' || lang === 'jsx') { loader = lang } const srcMatch = Opentag.match (srcRE) // For <script SRC ='path/to/some.js'> Convert it to import 'path/to/some js' code if (srcMatch) {const SRC = srcMatch [1] | | srcMatch [2] | | srcMatch [3] js + = ` import ${JSON.stringify(src)}\n` } else if (content.trim()) { // The reason why virtual modules are needed: // 1. There can be module scripts (`<script context="module">` in Svelte and `<script>` in Vue) // or local scripts (`<script>` in Svelte and `<script setup>` in Vue) // 2. There can be multiple module scripts in html // We need to handle these separately in case variable names are reused between them // append imports in TS to prevent esbuild from removing them // since they may be used in the template const contents = content + (loader.startsWith('ts') ? ExtractImportPaths (content) : ") // Will be extracted from the script script, exists in xx. Vue? Script ={'xx.vue? id=1': 'js contents'} const key = `${path}? id=${scriptId++}` if (contents.includes('import.meta.glob')) { scripts[key] = { // transformGlob already transforms to js loader: 'js', contents: await transformGlob( contents, path, config.root, loader, resolve, config.logger ) } } else { scripts[key] = { loader, contents } } const virtualModulePath = JSON.stringify( virtualModulePrefix + key ) const contextMatch = openTag.match(contextRE) const context = contextMatch && (contextMatch[1] || contextMatch[2] || contextMatch[3]) // Especially for Svelte files, exports in <script context="module"> means module exports, // exports in <script> means component props. To avoid having two same export name from the // star exports, we need to ignore exports in <script> if (path.endsWith('.svelte') && context ! == 'module') { js += `import ${virtualModulePath}\n` } else { // e.g. export * from 'virtual-module:xx.vue? id=1' js += `export * from ${virtualModulePath}\n` } } } // This will trigger incorrectly if `export default` is contained // anywhere in a string. Svelte and Astro files can't have // `export default` as code so we know if it's encountered it's a // false positive (e.g. contained in a string) if (! path.endsWith('.vue') || ! js.includes('export default')) { js += '\nexport default {}' } return { loader: 'js', contents: js } } )Copy the code

As we saw above, module dependencies from node_modules need to be pre-built. For example import ElementPlus from ‘element-plus’. This is because the bare import is not supported in the browser environment. On the other hand, if you don’t build it, the browser is faced with hundreds or thousands of dependencies that rely on the native ESM loading mechanism, each of which generates an HTTP request for an import. Browsers are overwhelmed with requests. Therefore, it is necessary to package the raw module import objectively and process it into the relative path or path import mode supported in the browser environment. For example: import ElementPlus from ‘/path/to/.vite/element-plus/es/index.mjs’.

2. Build the found dependencies

In the previous step, you have a list of dependencies that need to be pre-built. Now you just need to package them as entryPoints for esbuilds.

Import {build} from 'esbuild' //... Const result = await build({absWorkingDir: process.cwd(), // flatIdDeps is the entryPoints obtained in the first step that need to be pre-built: Object.keys(flatIdDeps), bundle: true, format: 'esm', target: config.build.target || undefined, external: config.optimizeDeps? .exclude, logLevel: 'error', splitting: true, sourcemap: Outdir: processingCacheDir, ignoreAnnotations: true, // outdir specifies the package output directory, processingCacheDir. true, metafile: true, define, plugins: [ ...plugins, esbuildDepPlugin(flatIdDeps, flatIdToExports, config, ssr) ], ... EsbuildOptions}) // writes the _metadata file and replaces the cache file. Write metadata file, delete `deps` folder and rename the new `processing` folder to `deps` in sync commitProcessingDepsCacheSync()Copy the code

Instead of directly configuring esBuild outdir (the output directory for build artifacts) to a. Vite directory, Vite first stores build artifacts in a temporary directory. When the build is complete, remove the old.vite (if any). Then rename the temporary directory to.vite. The main reason for this is to avoid errors during program execution that make the cache unavailable.

function commitProcessingDepsCacheSync() { // Rewire the file paths from the temporal processing dir to the final deps cache dir const dataPath = path.join(processingCacheDir, '_metadata.json') writeFile(dataPath, stringifyOptimizedDepsMetadata(metadata)) // Processing is done, We can now replace the depsCacheDir with processingCacheDir If (fs.existssync (depsCacheDir)) {const rmSync = fs.rmsync?? fs.rmdirSync // TODO: Remove after support for Node 12 is dropped rmSync(depsCacheDir, { recursive: true }) } fs.renameSync(processingCacheDir, depsCacheDir) } }Copy the code

This is the main process of pre-build.

Caching and prebuild

The reason why Vite cold starts quickly is not only because esBuild itself is fast, but also because Vite does the necessary caching mechanism. Vite creates a _metadata.json file with the following structure:

{
  "hash": "22135fca",
  "browserHash": "632454bc",
  "optimized": {
    "vue": {
      "file": "/path/to/your/project/node_modules/.vite/vue.js",
      "src": "/path/to/your/project/node_modules/vue/dist/vue.runtime.esm-bundler.js",
      "needsInterop": false
    },
    "element-plus": {
      "file": "/path/to/your/project/node_modules/.vite/element-plus.js",
      "src": "/path/to/your/project/node_modules/element-plus/es/index.mjs",
      "needsInterop": false
    },
    "vue-router": {
      "file": "/path/to/your/project/node_modules/.vite/vue-router.js",
      "src": "/path/to/your/project/node_modules/vue-router/dist/vue-router.esm-bundler.js",
      "needsInterop": false
    }
  }
}
Copy the code

The hash is the main identifier of the cache and is determined by the Vite configuration file and project dependencies (the dependencies are obtained from package-lock.json, yarn.lock, and pnpm-lock.yaml). So if the user changes viet.config.js or a dependency changes (adding or deleting a dependency update causes the lock file to change), the hash will change and the cache will be invalidated. At this point, Vite needs to be pre-built again. Of course, if you manually delete the. Vite cache directory, it will be rebuilt.

// Hash const lockfileFormats = ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml'] function getDepHash(root: string, config: ResolvedConfig): string { let content = lookupFile(root, lockfileFormats) || '' // also take config into account // only a subset of config options that can affect dep optimization content += JSON.stringify( { mode: config.mode, root: config.root, define: config.define, resolve: config.resolve, buildTarget: config.build.target, assetsInclude: config.assetsInclude, plugins: config.plugins.map((p) => p.name), optimizeDeps: { include: config.optimizeDeps?.include, exclude: config.optimizeDeps?.exclude, esbuildOptions: { ... config.optimizeDeps? .esbuildOptions, plugins: config.optimizeDeps?.esbuildOptions?.plugins?.map( (p) => p.name ) } } }, (_, value) => { if (typeof value === 'function' || value instanceof RegExp) { return value.toString() } return value } ) return createHash('sha256').update(content).digest('hex').substring(0, 8) }Copy the code

If the current hash value is the same as the hash value in _metadata.json, the dependencies of the project have not changed.

// Calculate the current hash const mainHash = getDepHash(root, config) const metadata: DepOptimizationMetadata = {hash: mainHash, browserHash: mainHash, optimized: {}, discovered: {}, processing: processing.promise } let prevData: DepOptimizationMetadata | undefined try { const prevDataPath = path.join(depsCacheDir, '_metadata.json') prevData = parseOptimizedDepsMetadata( fs.readFileSync(prevDataPath, 'utf-8'), depsCacheDir, processing.promise ) } catch (e) { } // hash is consistent, No need to re-bundle // Compare cached hash with current hash if (prevData && prevdata.hash === metadata.hash) {log(' hash is consistent. Skipping. Use --force to override.') return { metadata: prevData, run: () => (processing.resolve(), processing.promise) } }Copy the code

conclusion

This is the main processing logic for vite prebuild, which boils down to finding dependencies that need to be prebuilt, building those dependencies as entryPoints, and updating the cache once they’re built. Vite checks whether the cache is valid during startup to improve the speed. If it is valid, the pre-build process can be skipped. The validity of the cache is determined by comparing the hash value in the cache with the current hash value. Since the hash generation algorithm is based on the Vite configuration file and project dependencies, changes in the configuration file and dependencies will cause the hash to change, leading to a re-build.

Pay attention to our