Building a Minimal Vite Dev Server from Scratch: A Step‑by‑Step Guide
This article walks through the source‑code analysis of Vite, explains why Vite rewrites import paths and creates a .vite folder, and provides a complete, minimal implementation of a Vite‑like development server using esbuild, connect middleware, and Vue SFC compilation.
Introduction
The goal is to implement a ultra‑light version of Vite that can start a dev server, pre‑bundle dependencies, and transform
.jsand
.vuefiles so they run in the browser. The final simple Vite can be downloaded from the GitHub repository linked in the article.
Problems to Solve
When running a Vite project, developers notice three main phenomena:
The import statement
import { createApp } from 'vue'is rewritten to
import { createApp } from '/node_modules/.vite/vue.js'.
A
.vitefolder appears under
node_modules.
.vue files are served as JavaScript after transformation.
These issues are addressed by analyzing Vite’s source code and reproducing the essential logic without hot‑module replacement.
Preparation
Clone the Vite repository and create a symlink:
<code>git clone [email protected]:vitejs/vite.git
cd vite && yarn
cd packages/vite && yarn build && yarn link
yarn dev</code>Link the local Vite package to the demo project:
<code>cd vite-demo
yarn link vite</code>Source Code Analysis
Server Creation
<code>// src/node/cli.ts
cli.command('[root]')
.alias('serve')
.action(async () => {
const { createServer } = await import('./server')
const server = await createServer({ /* ... */ })
await server.listen()
})</code>The CLI loads
createServerfrom
src/node/server/index.ts, which builds a Connect middleware stack and starts an HTTP server.
Dependency Pre‑bundling
<code>// src/node/optimizer/index.ts
if (config.cacheDir) {
server._isRunningOptimizer = true
try {
server._optimizeDepsMetadata = await optimizeDeps(config)
} finally {
server._isRunningOptimizer = false
}
server._registerMissingImport = createMissingImporterRegisterFn(server)
}</code>The
optimizeDepsfunction uses
esbuildto scan imports, bundle them, and write the results to
node_modules/.vite. This explains the extra
.vitefolder.
Import Analysis
<code>// src/node/optimizer/scan.ts
import { Loader, Plugin, build, transform } from 'esbuild'
export async function scanImports() {
const entry = await globEntries('**/*.html', config)
const plugin = esbuildScanPlugin()
await build({
write: false,
entryPoints: [entry],
bundle: true,
format: 'esm',
plugins: [plugin]
})
}</code>The built‑in
vite:dep-scanplugin parses HTML, extracts
importstatements, and records dependencies such as
vue.
Transform Middleware
<code>// src/node/server/middlewares/transform.ts
if (isJSRequest(url)) {
const result = await transformRequest(url)
return send(req, res, result.code, type, result.etag,
isDep ? 'max-age=31536000,immutable' : 'no-cache',
result.map)
}</code>The middleware calls
transformRequest, which reads the file, runs all Vite plugins (including
import‑analysis), and returns transformed code.
Import‑analysis Plugin
<code>// src/node/plugins/importAnalysis.ts
export function importAnalysisPlugin() {
return {
name: 'vite:import-analysis',
async transform(source, importer, ssr) {
const specifiers = parseImports(source)
for (const { n, s, e } of specifiers) {
const resolved = await this.resolve(n)
const replacePath = resolved.id
source = source.slice(0, s) + replacePath + source.slice(e)
}
return { code: source }
}
}
}</code>This plugin uses
es‑module‑lexerto locate import specifiers, resolves them to the pre‑bundled files, and rewrites the import paths.
Implementation
Server Skeleton
<code>const http = require('http')
const connect = require('connect')
const middlewares = connect()
async function createServer() {
await optimizeDeps()
http.createServer(middlewares).listen(3000, () => {
console.log('simple-vite-dev-server start at localhost:3000!')
})
}
middlewares.use(indexHtmlMiddleware)
middlewares.use(transformMiddleware)
createServer()</code>Dependency Pre‑bundling Function
<code>const fs = require('fs')
const path = require('path')
const esbuild = require('esbuild')
const cacheDir = path.join(__dirname, '../node_modules/.vite')
async function optimizeDeps() {
if (fs.existsSync(cacheDir)) return false
fs.mkdirSync(cacheDir, { recursive: true })
const deps = Object.keys(require('../package.json').dependencies)
const result = await esbuild.build({
entryPoints: deps,
bundle: true,
format: 'esm',
logLevel: 'error',
splitting: true,
sourcemap: true,
outdir: cacheDir,
treeShaking: 'ignore-annotations',
metafile: true,
define: { 'process.env.NODE_ENV': '"development"' }
})
const outputs = Object.keys(result.metafile.outputs)
const data = {}
deps.forEach(dep => {
data[dep] = '/' + outputs.find(o => o.endsWith(`${dep}.js`))
})
const dataPath = path.join(cacheDir, '_metadata.json')
fs.writeFileSync(dataPath, JSON.stringify(data, null, 2))
}
</code>HTML Middleware
<code>const indexHtmlMiddleware = (req, res, next) => {
if (req.url === '/') {
const htmlPath = path.join(__dirname, '../index.html')
const html = fs.readFileSync(htmlPath, 'utf-8')
res.setHeader('Content-Type', 'text/html')
res.statusCode = 200
return res.end(html)
}
next()
}
</code>Transform Middleware (JS)
<code>const transformMiddleware = async (req, res, next) => {
if (req.url.endsWith('.js') || req.url.endsWith('.map')) {
const jsPath = path.join(__dirname, '../', req.url)
const code = fs.readFileSync(jsPath, 'utf-8')
res.setHeader('Content-Type', 'application/javascript')
res.statusCode = 200
const transformed = req.url.endsWith('.map') ? code : await importAnalysis(code)
return res.end(transformed)
}
next()
}
</code>Import Analysis Helper
<code>const { init, parse } = require('es-module-lexer')
const MagicString = require('magic-string')
const cacheDir = path.join(__dirname, '../node_modules/.vite')
async function importAnalysis(code) {
await init
const [imports] = parse(code)
if (!imports || !imports.length) return code
const meta = require(path.join(cacheDir, '_metadata.json'))
let magic = new MagicString(code)
imports.forEach(({ n, s, e }) => {
const replace = meta[n] || n
magic = magic.overwrite(s, e, replace)
})
return magic.toString()
}
</code>Transform Middleware (Vue SFC)
<code>const compileSFC = require('@vue/compiler-sfc')
const compileDom = require('@vue/compiler-dom')
const transformMiddleware = async (req, res, next) => {
if (req.url.includes('.vue')) {
const vuePath = path.join(__dirname, '../', req.url.split('?')[0])
const content = fs.readFileSync(vuePath, 'utf-8')
const { descriptor } = compileSFC.parse(content)
const script = descriptor.script.content.replace('export default ', 'const __script = ')
const tpl = compileDom.compile(descriptor.template.content, { mode: 'module' }).code
const tplReplaced = tpl.replace('export function render(_ctx, _cache)', '__script.render=(_ctx,_cache)=>')
const finalCode = `
${await importAnalysis(script)}
${tplReplaced}
export default __script;
`
res.setHeader('Content-Type', 'application/javascript')
res.statusCode = 200
return res.end(await importAnalysis(finalCode))
}
next()
}
</code>Conclusion
The minimal Vite implementation demonstrates two core ideas: using
esbuildfor fast dependency pre‑bundling to convert CommonJS/AMD modules into browser‑compatible ES modules, and a
transformMiddlewarethat rewrites import paths and compiles Vue single‑file components into executable JavaScript.
WeDoctor Frontend Technology
Official WeDoctor Group frontend public account, sharing original tech articles, events, job postings, and occasional daily updates from our tech team.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.