「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!」
Vite 是由 Vue 作者尤雨溪开发的 Web 开发工具,尤雨溪在微博上推广时对 Vite 做了简短介绍:
Vite,一个基于浏览器原生 ES imports 的开发服务器。利用浏览器去解析 imports,在服务器端按需编译返回,完全跳过了打包这个概念,服务器随起随用。同时不仅有 Vue 文件支持,还搞定了热更新,而且热更新的速度不会随着模块增多而变慢。针对生产环境则可以把同一份代码用 Rollup 打包。虽然现在还比较粗糙,但这个方向我觉得是有潜力的,做得好可以彻底解决改一行代码等半天热更新的问题。
我们可以从这段话中提取一些关键信息
- Vite 基于 ESM,因此实现了快速启动和即时模块热更新能力;
- Vite 在服务端实现了按需编译。
所以可以直白一些来讲:Vite 在开发环境下并没有打包和构建过程。
开发者在代码中写到的 ESM 导入语法会直接发送给服务器,而服务器也直接将 ESM 模块内容运行处理后,下发给浏览器。接着,现代浏览器通过解析 script module,对每一个 import 到的模块进行 HTTP 请求,服务器继续对这些 HTTP 请求进行处理并响应。
Vite 实现原理解读
环境搭建
Vite 思想比较容易理解,实现起来也并不复杂。接下来,我们来对 Vite 源码进行分析
首先,我们打造一个学习环境,创建一个基于 Vite 的应用,并启动:
$ yarn global add vite
$ npm init vite-app vite-app
$ cd vite-app
$ yarn
$ yarn dev
会得到如下图所示的目录结构:
启动项目:
$ yarn dev
其中浏览器请求:**http://localhost:3000/**,得到的内容即是我们应用项目中的 index.html 内容。
入口源码
cli
.command('[root]') // default command
.alias('serve')
.option('--host [host]', `[string] specify hostname`)
.option('--port <port>', `[number] specify port`)
.option('--https', `[boolean] use TLS + HTTP/2`)
.option('--open [path]', `[boolean | string] open browser on startup`)
.option('--cors', `[boolean] enable CORS`)
.option('--strictPort', `[boolean] exit if specified port is already in use`)
.option('-m, --mode <mode>', `[string] set env mode`)
.option(
'--force',
`[boolean] force the optimizer to ignore the cache and re-bundle`
)
.action(async (root: string, options: ServerOptions & GlobalCLIOptions) => {
// output structure is preserved even after bundling so require()
// is ok here
const { createServer } = await import('./server')
try {
const server = await createServer({
root,
base: options.base,
mode: options.mode,
configFile: options.config,
logLevel: options.logLevel,
clearScreen: options.clearScreen,
server: cleanOptions(options) as ServerOptions
})
await server.listen()
} catch (e) {
createLogger(options.logLevel).error(
chalk.red(`error when starting dev server:\n${e.stack}`)
)
process.exit(1)
}
})
通过 createServer 来启动一个 http 服务,来实现对浏览器请求的响应。
const { createServer } = await import('./server')
createServer方法的实现,代码如下
export async function createServer(inlineConfig) {
// 配置文件处理
const config = await resolveConfig(inlineConfig, 'serve', 'development')
const root = config.root
const serverConfig = config.server
const httpsOptions = await resolveHttpsConfig(config)
let { middlewareMode } = serverConfig
// 以中间件模式创建 vite 服务器,不使用 vite 创建的服务器
if (middlewareMode === true) {
middlewareMode = 'ssr'
}
const middlewares = connect()
// 创建一个 http 实例,注意,这里如果 middlewareMode = 'ssr' 则使用中间件来创建服务器
const httpServer = middlewareMode
? null
: await resolveHttpServer(serverConfig, middlewares, httpsOptions)
// HMR 连接
const ws = createWebSocketServer(httpServer, config, httpsOptions)
const { ignored = [], ...watchOptions } = serverConfig.watch || {}
// 文件监听
const watcher = chokidar.watch(path.resolve(root), {
ignored: ['**/node_modules/**', '**/.git/**', ...ignored],
ignoreInitial: true,
ignorePermissionErrors: true,
disableGlobbing: true,
...watchOptions
})
const plugins = config.plugins
const container = await createPluginContainer(config, watcher)
const moduleGraph = new ModuleGraph(container)
const closeHttpServer = createServerCloseFn(httpServer)
// eslint-disable-next-line prefer-const
let exitProcess
const server = {
config: config,
middlewares,
get app() {
config.logger.warn(
`ViteDevServer.app is deprecated. Use ViteDevServer.middlewares instead.`
)
return middlewares
},
httpServer,
watcher,
pluginContainer: container,
ws,
moduleGraph,
transformWithEsbuild,
transformRequest(url, options) {
return transformRequest(url, server, options)
},
transformIndexHtml: null,
ssrLoadModule(url) {
if (!server._ssrExternals) {
server._ssrExternals = resolveSSRExternal(
config,
server._optimizeDepsMetadata
? Object.keys(server._optimizeDepsMetadata.optimized)
: []
)
}
return ssrLoadModule(url, server)
},
ssrFixStacktrace(e) {
if (e.stack) {
e.stack = ssrRewriteStacktrace(e.stack, moduleGraph)
}
},
listen(port, isRestart) {
return startServer(server, port, isRestart)
},
async close() {
process.off('SIGTERM', exitProcess)
if (!middlewareMode && process.env.CI !== 'true') {
process.stdin.off('end', exitProcess)
}
await Promise.all([
watcher.close(),
ws.close(),
container.close(),
closeHttpServer()
])
},
_optimizeDepsMetadata: null,
_ssrExternals: null,
_globImporters: {},
_isRunningOptimizer: false,
_registerMissingImport: null,
_pendingReload: null
}
server.transformIndexHtml = createDevHtmlTransformFn(server)
exitProcess = async () => {
try {
await server.close()
} finally {
process.exit(0)
}
}
// 如果收到终止信号句柄,停止服务
process.once('SIGTERM', exitProcess)
if (!middlewareMode && process.env.CI !== 'true') {
process.stdin.on('end', exitProcess)
}
watcher.on('change', async (file) => {
file = normalizePath(file)
// invalidate module graph cache on file change
moduleGraph.onFileChange(file)
if (serverConfig.hmr !== false) {
try {
await handleHMRUpdate(file, server)
} catch (err) {
ws.send({
type: 'error',
err: prepareError(err)
})
}
}
})
watcher.on('add', (file) => {
handleFileAddUnlink(normalizePath(file), server)
})
watcher.on('unlink', (file) => {
handleFileAddUnlink(normalizePath(file), server, true)
})
// 插件处理
// apply server configuration hooks from plugins
const postHooks = []
for (const plugin of plugins) {
if (plugin.configureServer) {
postHooks.push(await plugin.configureServer(server))
}
}
// 下面是一些中间件的处理
// Internal middlewares ------------------------------------------------------
// request timer
// 请求时间调试
if (process.env.DEBUG) {
middlewares.use(timeMiddleware(root))
}
// cors (enabled by default)
const { cors } = serverConfig
if (cors !== false) {
middlewares.use(corsMiddleware(typeof cors === 'boolean' ? {} : cors))
}
// proxy
const { proxy } = serverConfig
if (proxy) {
middlewares.use(proxyMiddleware(httpServer, config))
}
// base
if (config.base !== '/') {
middlewares.use(baseMiddleware(server))
}
// open in editor support
middlewares.use('/__open-in-editor', launchEditorMiddleware())
// hmr reconnect ping
// Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...`
middlewares.use('/__vite_ping', function viteHMRPingMiddleware(_, res) {
res.end('pong')
})
//decode request url
middlewares.use(decodeURIMiddleware())
// serve static files under /public
// this applies before the transform middleware so that these files are served
// as-is without transforms.
if (config.publicDir) {
middlewares.use(servePublicMiddleware(config.publicDir))
}
// main transform middleware
middlewares.use(transformMiddleware(server))
// serve static files
middlewares.use(serveRawFsMiddleware(server))
middlewares.use(serveStaticMiddleware(root, config))
// spa fallback
if (!middlewareMode || middlewareMode === 'html') {
middlewares.use(
history({
logger: createDebugger('vite:spa-fallback'),
// support /dir/ without explicit index.html
rewrites: [
{
from: /\/$/,
to({ parsedUrl }) {
const rewritten = parsedUrl.pathname + 'index.html'
if (fs.existsSync(path.join(root, rewritten))) {
return rewritten
} else {
return `/index.html`
}
}
}
]
})
)
}
// run post config hooks
// This is applied before the html middleware so that user middleware can
// serve custom content instead of index.html.
postHooks.forEach((fn) => fn && fn())
if (!middlewareMode || middlewareMode === 'html') {
// transform index.html
middlewares.use(indexHtmlMiddleware(server))
// handle 404s
// Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...`
middlewares.use(function vite404Middleware(_, res) {
res.statusCode = 404
res.end()
})
}
// error handler
middlewares.use(errorMiddleware(server, !!middlewareMode))
const runOptimize = async () => {
if (config.cacheDir) {
server._isRunningOptimizer = true
try {
server._optimizeDepsMetadata = await optimizeDeps(config)
} finally {
server._isRunningOptimizer = false
}
server._registerMissingImport = createMissingImporterRegisterFn(server)
}
}
if (!middlewareMode && httpServer) {
// overwrite listen to run optimizer before server start
const listen = httpServer.listen.bind(httpServer)
httpServer.listen = (async (port, ...args) => {
try {
await container.buildStart({})
await runOptimize()
} catch (e) {
httpServer.emit('error', e)
return
}
return listen(port, ...args)
})
httpServer.once('listening', () => {
// update actual port since this may be different from initial value
serverConfig.port = (httpServer.address()).port
})
} else {
await container.buildStart({})
await runOptimize()
}
return server
}
代码很长,简单来说做了这几件事:
- 创建一个服务器,用于作为一个静态服务器,响应应用的请求
- 创建一个 webSocket,提供 HMR
- 使用 chokidar 启用文件监听,并对文件修改进行处理
- 插件处理
- 监听句柄,如遇到停止信号则停止服务
启动服务器的作用
浏览器在访问在访问了 http://localhost:3000/ 后,得到了下面的内容:
<body>
<di v id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
依据 ESM 规范在浏览器 script 标签中的实现,对于 <script type="module" src="./bar.js"></script> 内容:当出现 script 标签 type 属性为 module 时,浏览器会发出 HTTP 请求模块相应内容。经过 Vite Server 处理。
我们可以看到,经过 Vite Server 处理 http://localhost:3000/src/main.js 请求后,最终返回了上面图片的内容。不过这个内容和我们项目中的
./src/main.js 有差别
源代码是这样
import { createApp } from 'vue'
import App from './App.vue'
import './index.css'
createApp(App).mount('#app')
经过 Vite 后变成这样了
import { createApp } from '/@modules/vue.js'
import App from '/src/App.vue'
import '/src/index.css?import'
createApp(App).mount('#app')
这里我们拆成两部分来看。
其中 import { createApp } from 'vue' 改为 import { createApp } from '/@modules/vue.js',原因很明显:import 对应的路径只支持 "/""./"或者 "../" 开头的内容,直接使用模块名 import,会立即报错。
所以在 Vite Server 处理请求时,通过 resolve 这个插件来给 import from 'A' 的 A 添加 /@module/ 前缀为 from '/@modules/A',源码部分对应。
整个过程和调用链路较长,我对 Vite 处理 import 方法做一个简单总结:
-
在 createServer 里获取请求 path 对应的 body 内容;
-
通过 es-module-lexer 解析资源 AST,并拿到 import 的内容;
-
如果判断 import 的资源是绝对路径,即可认为该资源为 npm 模块,并返回处理后的资源路径。比如上述代码中,
vue → /@modules/vue。
对于形如:import App from './App.vue'和 import './index.css' 的处理,与上述情况类似:
-
在 createSercer 里获取请求 path 对应的 body 内容;
-
通过 es-module-lexer 解析资源 AST,并拿到 import 的内容;
-
如果判断 import 的资源是相对路径,即可认为该资源为项目应用中资源,并返回处理后的资源路径。比如上述代码中,
./App.vue → /src/App.vue。
接下来浏览器根据 main.js 的内容,分别请求:
/@modules/vue.js
/src/App.vue
/src/index.css?import
对于 /@module/ 类请求较为容易,我们只需要完成下面三步:
-
在 createServer 中间件里获取请求 path 对应的 body 内容;
-
判断路径是否以 /@module/ 开头,如果是,取出包名(这里为 vue.js);
-
去 node_modules 文件中找到对应的 npm 库,并返回内容。
上述步骤在 Vite 中使用 resolve 中间件实现。
接着,就是对 /src/App.vue 类请求进行处理,这就涉及 Vite 服务器的编译能力了。
我们先看结果,对比项目中的 App.vue,浏览器请求得到的结果显然出现了大变样:
实际上,App.vue 这样的单文件组件对应 script、style 和 template,在经过 Vite Server 处理时,服务端对 script、style 和 template 三部分分别处理,对应中间件为 @vitejs/plugin-vue。这个插件的实现很简单,即对 .vue 文件请求进行处理,通过 parseSFC 方法解析单文件组件,并通过 compileSFCMain 方法将单文件组件拆分为形如上图内容,对应中间件关键内容可在源码 vuePlugin 中找到。源码中,涉及 parseSFC 具体所做的事情,是调用 @vue/compiler-sfc 进行单文件组件解析。精简为我自己的逻辑,帮助你理解:
总的来说,每一个 .vue 单文件组件都被拆分成多个请求。比如对应上面场景,浏览器接收到 App.vue 对应的实际内容后,发出 HelloWorld.vue 以及 App.vue?type=template 的请求(通过 type 这个 query 来表示是 template 还是 style)。createServer 进行分别处理并返回,这些请求依然分别被上面提到的 @vitejs/plugin-vue 插件处理:对于 template 的请求,服务使用 @vue/compiler-dom 进行编译 template 并返回内容。
对于上面提到的 http://localhost:3000/src/index.css?import 请求稍微特殊,在 css 插件中, 通过 cssPostPlugin 对象的 transform 来实现解析:
transform(css, id, ssr) {
if (!cssLangRE.test(id) || commonjsProxyRE.test(id)) {
return
}
const modules = cssModulesCache.get(config)!.get(id)
const modulesCode =
modules && dataToEsm(modules, { namedExports: true, preferConst: true })
if (config.command === 'serve') {
if (isDirectCSSRequest(id)) {
return css
} else {
// server only
if (ssr) {
return modulesCode || `export default ${JSON.stringify(css)}`
}
return [
`import { updateStyle, removeStyle } from ${JSON.stringify(
path.posix.join(config.base, CLIENT_PUBLIC_PATH)
)}`,
`const id = ${JSON.stringify(id)}`,
`const css = ${JSON.stringify(css)}`,
`updateStyle(id, css)`,
// css modules exports change on edit so it can't self accept
`${modulesCode || `import.meta.hot.accept()\nexport default css`}`,
`import.meta.hot.prune(() => removeStyle(id))`
].join('\n')
}
}
// build CSS handling ----------------------------------------------------
// record css
styles.set(id, css)
return {
code: modulesCode || `export default ${JSON.stringify(css)}`,
map: { mappings: '' },
// avoid the css module from being tree-shaken so that we can retrieve
// it in renderChunk()
moduleSideEffects: 'no-treeshake'
}
},
调用 cssPostPlugin 中的 transform 方法:
该方法会在浏览器中执行 updateStyle 方法,就像是 http://localhost:3000/src/index.css?import 的源码如下:
import { createHotContext as __vite__createHotContext } from "/@vite/client";import.meta.hot = __vite__createHotContext("/src/components/HelloWorld.vue?vue&type=style&index=0&scoped=true&lang.css");import { updateStyle, removeStyle } from "/@vite/client"
const id = "/Users/study/vite-app/src/components/HelloWorld.vue?vue&type=style&index=0&scoped=true&lang.css"
const css = "\nh1[data-v-469af010] {\n font-size:18px;\n}\n"
updateStyle(id, css)
import.meta.hot.accept()
export default css
import.meta.hot.prune(() => removeStyle(id))
最终完成在浏览器中插入样式。
至此,我们解析并列举了较多源码内容。以上内容需要跟着思路,一步步梳理,也强烈建议你打开 Vite 源码自己动手剖析。如果看到这里你仍然也有些“云里雾里”,不要心急,结合我下面这个图示,再次进行阅读,相信会更有收获。
和 webpack 对比
webpack bundleless 的思路
Vite bundleless 的思路:
总结
-
Vite 利用浏览器原生支持 ESM 这一特性,省略了对模块的打包,也就不需要生成 bundle,因此初次启动更快,HMR 特性友好。
-
Vite 开发模式下,通过启动 Node 服务器,在服务端完成模块的改写(比如单文件的解析编译等)和请求处理,实现真正的按需编译。
-
Vite Server 所有逻辑基本都依赖中间件实现。这些中间件,拦截请求之后,完成了如下内容:
-
处理 ESM 语法,比如将业务代码中的 import 第三方依赖路径转为浏览器可识别的依赖路径;
-
对 .ts、.vue 等文件进行即时编译;
-
对 Sass/Less 的需要预编译的模块进行编译;
-
和浏览器端建立 socket 连接,实现 HMR。
-