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 .js and .vue files 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 .vite folder 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:
git clone [email protected]:vitejs/vite.git
cd vite && yarn
cd packages/vite && yarn build && yarn link
yarn devLink the local Vite package to the demo project:
cd vite-demo
yarn link viteSource Code Analysis
Server Creation
// src/node/cli.ts
cli.command('[root]')
.alias('serve')
.action(async () => {
const { createServer } = await import('./server')
const server = await createServer({ /* ... */ })
await server.listen()
})The CLI loads createServer from src/node/server/index.ts, which builds a Connect middleware stack and starts an HTTP server.
Dependency Pre‑bundling
// 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)
}The optimizeDeps function uses esbuild to scan imports, bundle them, and write the results to node_modules/.vite. This explains the extra .vite folder.
Import Analysis
// 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]
})
}The built‑in vite:dep-scan plugin parses HTML, extracts import statements, and records dependencies such as vue.
Transform Middleware
// 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)
}The middleware calls transformRequest, which reads the file, runs all Vite plugins (including import‑analysis), and returns transformed code.
Import‑analysis Plugin
// 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 }
}
}
}This plugin uses es‑module‑lexer to locate import specifiers, resolves them to the pre‑bundled files, and rewrites the import paths.
Implementation
Server Skeleton
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()Dependency Pre‑bundling Function
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))
}HTML Middleware
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()
}Transform Middleware (JS)
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()
}Import Analysis Helper
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()
}Transform Middleware (Vue SFC)
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()
}Conclusion
The minimal Vite implementation demonstrates two core ideas: using esbuild for fast dependency pre‑bundling to convert CommonJS/AMD modules into browser‑compatible ES modules, and a transformMiddleware that rewrites import paths and compiles Vue single‑file components into executable JavaScript.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
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.
