NO.1css
vite 是什麼html
vite —— 一個由 vue 做者尤雨溪開發的 web 開發工具,它具備如下特色:前端
快速的冷啓動vue
即時的模塊熱更新node
真正的按需編譯react
從做者在微博上的發言:webpack
Vite,一個基於瀏覽器原生 ES imports 的開發服務器。利用瀏覽器去解析 imports,在服務器端按需編譯返回,徹底跳過了打包這個概念,服務器隨起隨用。同時不只有 Vue 文件支持,還搞定了熱更新,並且熱更新的速度不會隨着模塊增多而變慢。針對生產環境則能夠把同一份代碼用 rollup 打。雖然如今還比較粗糙,但這個方向我以爲是有潛力的,作得好能夠完全解決改一行代碼等半天熱更新的問題。web
中能夠看出 vite 主要特色是基於瀏覽器 native 的 ES module (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) 來開發,省略打包這個步驟,由於須要什麼資源直接在瀏覽器裏引入便可。面試
基於瀏覽器 ES module 來開發 web 應用也不是什麼新鮮事,snowpack 也基於此,不過目前此項目社區中並無流行起來,vite 的出現也許會讓這種開發方式再火一陣子。算法
有趣的是 vite 算是革了 webpack 的命了(生產環境用 rollup),因此 webpack 的開發者直接喊大哥了...
做者注:本文完成於 vite 早期時候,vite 部分功能和原理有更新。
NO.2
vite 的使用方式
同常見的開發工具同樣,vite 提供了用 npm 或者 yarn 一建生成項目結構的方式,使用 yarn 在終端執行:
$ yarn create vite-app <project-name>
$ cd <project-name>
$ yarn
$ yarn dev
便可初始化一個 vite 項目(默認應用模板爲 vue3.x),生成的項目結構十分簡潔:
|____node_modules
|____App.vue // 應用入口
|____index.html // 頁面入口
|____vite.config.js // 配置文件
|____package.json
執行 yarn dev
便可啓動應用 。
NO.3
vite 啓動鏈路
命令解析
這部分代碼在 src/node/cli.ts 裏,主要內容是藉助 minimist —— 一個輕量級的命令解析工具解析 npm scripts,解析的函數是 resolveOptions
,精簡後的代碼片斷以下。
function resolveOptions() {
// command 能夠是 dev/build/optimize
if (argv._[0]) {
argv.command = argv._[0];
}
return argv;
}
拿到 options 後,會根據 options.command
的值判斷是執行在開發環境須要的 runServe 命令或生產環境須要的 runBuild 命令。
if (!options.command || options.command === 'serve') {
runServe(options)
} else if (options.command === 'build') {
runBuild(options)
} else if (options.command === 'optimize') {
runOptimize(options)
}
在 runServe
方法中,執行 server 模塊的建立開發服務器方法,一樣在 runBuild
中執行 build 模塊的構建方法。
最新的版本中還增長了 optimize 命令的支持,關於 optimize 作了什麼,咱們下文再說。
server
這部分代碼在 src/node/server/index.ts 裏,主要暴露一個 createServer
方法。
vite 使用 koa 做 web server,使用 clmloader 建立了一個監聽文件改動的 watcher,同時實現了一個插件機制,將 koa-app 和 watcher 以及其餘必要工具組合成一個 context 對象注入到每一個 plugin 中。
context 組成以下:
plugin 依次從 context 裏獲取上面這些組成部分,有的 plugin 在 koa 實例添加了幾個 middleware,有的藉助 watcher 實現對文件的改動監聽,這種插件機制帶來的好處是整個應用結構清晰,同時每一個插件處理不一樣的事情,職責更分明,
plugin
上文咱們說到 plugin,那麼有哪些 plugin 呢?它們分別是:
用戶注入的 plugins —— 自定義 plugin
hmrPlugin —— 處理 hmr
htmlRewritePlugin —— 重寫 html 內的 script 內容
moduleRewritePlugin —— 重寫模塊中的 import 導入
moduleResolvePlugin ——獲取模塊內容
vuePlugin —— 處理 vue 單文件組件
esbuildPlugin —— 使用 esbuild 處理資源
assetPathPlugin —— 處理靜態資源
serveStaticPlugin —— 託管靜態資源
cssPlugin —— 處理 css/less/sass 等引用
...
咱們來看 plugin 的實現方式,開發一個用來攔截 json 文件 plugin 能夠這麼實現:
interface ServerPluginContext {
root: string
app: Koa
server: Server
watcher: HMRWatcher
resolver: InternalResolver
config: ServerConfig
}
type ServerPlugin = (ctx:ServerPluginContext)=> void;
const JsonInterceptPlugin:ServerPlugin = ({app})=>{
app.use(async (ctx, next) => {
await next()
if (ctx.path.endsWith('.json') && ctx.body) {
ctx.type = 'js'
ctx.body = `export default json`
}
})
}
vite 背後的原理都在 plugin 裏,這裏再也不一一解釋每一個 plugin 的做用,會放在下文背後的原理中一併討論。
build
這部分代碼在 node/build/index.ts 中,build 目錄的結構雖然與 server 類似,一樣導出一個 build 方法,一樣也有許多 plugin,不過這些 plugin 與 server 中的用途不同,由於 build 使用了 rollup ,因此這些 plugin 也是爲 rollup 打包的 plugin ,本文就再也不多提。
NO.4
vite 運行原理
ES module
要了解 vite 的運行原理,首先要知道什麼是 ES module,目前流覽器對其的支持以下:
主流的瀏覽器(IE11除外)均已經支持,其最大的特色是在瀏覽器端使用 export
import
的方式導入和導出模塊,在 script 標籤裏設置 type="module"
,而後使用模塊內容。
<script type="module">
import { bar } from './bar.js‘
</script>
當 html 裏嵌入上面的 script 標籤時候,瀏覽器會發起 http 請求,請求 htttp server 託管的 bar.js ,在 bar.js 裏,咱們用 named export 導出 bar
變量,在上面的 script 中能獲取到 bar 的定義。
// bar.js
export const bar = 'bar';
在 vite 中的做用
打開運行中的 vite 項目,訪問 view-source 能夠發現 html 裏有段這樣的代碼:
<script type="module">
import { createApp } from '/@modules/vue'
import App from '/App.vue'
createApp(App).mount('#app')
</script>
從這段代碼中,咱們能 get 到如下幾點信息:
從
http://localhost:3000/@modules/vue
中獲取createApp
這個方法從
http://localhost:3000/App.vue
中獲取應用入口使用
createApp
建立應用並掛載節點
createApp
是 vue3.X 的 api,只需知道這是建立了 vue 應用便可,vite 利用 ES module,把 「構建 vue 應用」 這個原本須要經過 webpack 打包後才能執行的代碼直接放在瀏覽器裏執行,這麼作是爲了:
去掉打包步驟
實現按需加載
去掉打包步驟
打包的概念是開發者利用打包工具將應用各個模塊集合在一塊兒造成 bundle,以必定規則讀取模塊的代碼——以便在不支持模塊化的瀏覽器裏使用。
爲了在瀏覽器里加載各模塊,打包工具會藉助膠水代碼用來組裝各模塊,好比 webpack 使用 map
存放模塊 id 和路徑,使用 __webpack_require__
方法獲取模塊導出。
vite 利用瀏覽器原生支持模塊化導入這一特性,省略了對模塊的組裝,也就不須要生成 bundle,因此打包這一步就能夠省略了。
實現按需打包
前面說到,webpack 之類的打包工具會將各模塊提早打包進 bundle 裏,但打包的過程是靜態的——無論某個模塊的代碼是否執行到,這個模塊都要打包到 bundle 裏,這樣的壞處就是隨着項目愈來愈大打包後的 bundle 也愈來愈大。
開發者爲了減小 bundle 大小,會使用動態引入 import()
的方式異步的加載模塊( 被引入模塊依然須要提早打包),又或者使用 tree shaking 等方式盡力的去掉未引用的模塊,然而這些方式都不如 vite 的優雅,vite 能夠只在須要某個模塊的時候動態(藉助 import()
)的引入它,而不須要提早打包,雖然只能用在開發環境,不過這就夠了。
vite 如何處理 ESM
既然 vite 使用 ESM 在瀏覽器裏使用模塊,那麼這一步到底是怎麼作的?
上文提到過,在瀏覽器裏使用 ES module 是使用 http 請求拿到模塊,因此 vite 必須提供一個 web server 去代理這些模塊,上文中提到的 koa
就是負責這個事情,vite 經過對請求路徑的劫持獲取資源的內容返回給瀏覽器,不過 vite 對於模塊導入作了特殊處理。
@modules 是什麼?
經過工程下的 index.html 和開發環境下的 html 源文件對比,發現 script 標籤裏的內容發生了改變,由
<script type="module">
import { createApp } from 'vue'
import App from '/App.vue'
createApp(App).mount('#app')
</script>
變成了
<script type="module">
import { createApp } from '/@modules/vue'
import App from '/App.vue'
createApp(App).mount('#app')
</script>
vite 對 都作了一層處理,其過程以下:
import
在 koa 中間件裏獲取請求 body
經過 es-module-lexer 解析資源 ast 拿到 import 的內容
判斷 import 的資源是不是絕對路徑,絕對視爲 npm 模塊
返回處理後的資源路徑:
"vue" => "/@modules/vue"
這部分代碼在 serverPluginModuleRewrite 這個 plugin 中,
爲何須要 @modules?
若是咱們在模塊裏寫下如下代碼的時候,瀏覽器中的 esm 是不可能獲取到導入的模塊內容的:
import vue from 'vue'
由於 vue
這個模塊安裝在 node_modules 裏,以往使用 webpack,webpack遇到上面的代碼,會幫咱們作如下幾件事:
獲取這段代碼的內容
解析成 AST
遍歷 AST 拿到
import
語句中的包的名稱使用 enhanced-resolve 拿到包的實際地址進行打包,
可是瀏覽器中 ESM 沒法直接訪問項目下的 node_modules,因此 vite 對全部 import 都作了處理,用帶有 @modules 的前綴重寫它們。
從另一個角度來看這是很是比較巧妙的作法,把文件路徑的 rewrite 都寫在同一個 plugin 裏,這樣後續若是加入更多邏輯,改動起來不會影響其餘 plugin,其餘 plugin 拿到資源路徑都是 @modules ,好比說後續可能加入 alias 的配置:就像 webpack alias 同樣:能夠將項目裏的本地文件配置成絕對路徑的引用。
怎麼返回模塊內容
在下一個 koa middleware 中,用正則匹配到路徑上帶有 @modules 的資源,再經過 require('xxx')
拿到 包的導出返回給瀏覽器。
以往使用 webpack 之類的打包工具,它們除了將模塊組裝到一塊兒造成 bundle,還可讓使用了不一樣模塊規範的包互相引用,好比:
ES module (esm) 導入 cjs
CommonJS (cjs) 導入 esm
dynamic import 導入 esm
dynamic import 導入 cjs
關於 es module 的坑能夠看這篇文章(https://zhuanlan.zhihu.com/p/40733281)。
起初在 vite 還只是爲 vue3.x 設計的時候,對 vue esm 包是通過特殊處理的,好比:須要 @vue/runtime-dom
這個包的內容,不能直接經過 require('
@vue/runtime-dom'
)獲得,而須要經過 require('@vue/runtime-dom/dist/runtime-dom.esm-bundler.js'
的方式,這樣可使得 vite 拿到符合 esm 模塊標準的 vue 包。
目前社區中大部分模塊都沒有設置默認導出 esm,而是導出了 cjs 的包,既然 vue3.0 須要額外處理才能拿到 esm 的包內容,那麼其餘平常使用的 npm 包是否是也一樣須要支持?答案是確定的,目前在 vite 項目裏直接使用 lodash 仍是會報錯的。
不過 vite 在最近的更新中,加入了 optimize
命令,這個命令專門爲解決模塊引用的坑而開發,例如咱們要在 vite 中使用 lodash,只須要在 vite.config.js (vite 配置文件)中,配置 optimizeDeps
對象,在 include
數組中添加 lodash。
// vite.config.js
module.exports = {
optimizeDeps: {
include: ["lodash"]
}
}
這樣 vite 在執行 runOptimize 的時候中會使用 roolup 對 lodash 包從新編譯,將編譯成符合 esm 模塊規範的新的包放入 node_modules 下的 .vite_opt_cache 中,而後配合 resolver 對 的導入進行處理:使用編譯後的包內容代替原來 lodash 的包的內容,這樣就解決了 vite 中不能使用 cjs 包的問題,這部分代碼在 depOptimizer.ts 裏。
lodash
不過這裏還有個問題,因爲在 depOptimizer.ts 中,vite 只會處理在項目下 package.json 裏的 dependencies
裏聲明好的包進行處理,因此沒法在項目裏使用
import pick from 'lodash/pick'
的方式單使用 pick 方法,而要使用
import lodash from 'lodash'
lodash.pick()
的方式,這可能在生產環境下使用某些包的時候對 bundle 的體積有影響。
返回模塊的內容的代碼在:serverPluginModuleResolve.ts 這個 plugin 中。
vite 如何編譯模塊
最初 vite 爲 vue3.x 開發,因此這裏的編譯指的是編譯 vue 單文件組件,其餘 es 模塊能夠直接導入內容。
SFC
vue 單文件組件(簡稱 SFC) 是 vue 的一個亮點,前端屆對 SFC 褒貶不一,我的看來,SFC 是利大於弊的,雖然 SFC 帶來了額外的開發工做量,好比爲了解析 template 要寫模板解析器,還要在 SFC 中解析出邏輯和樣式,在 vscode 裏要寫 vscode 插件,在 webpack 裏要寫 vue-loader,可是對於使用方來講能夠在一個文件裏能夠同時寫 template、js、style,省了各文件互相跳轉。
與 vue-loader 類似,vite 在解析 vue 文件的時候也要分別處理屢次,咱們打開瀏覽器的 network,能夠看到:
1 個請求的 query 中什麼都沒有,另 2 個請求分別經過在 query 裏指定了 type 爲 style 和 template。
先來看看如何將一個 SFC 變成多個請求,咱們從第一次請求開始分析,簡化後的代碼以下:
function vuePlugin({app}){
app.use(async (ctx, next) => {
if (!ctx.path.endsWith('.vue') && !ctx.vue) {
return next()
}
const query = ctx.query
// 獲取文件名稱
let filename = resolver.requestToFile(publicPath)
// 解析器解析 SFC
const descriptor = await parseSFC(root, filename, ctx.body)
if (!descriptor) {
ctx.status = 404
return
}
// 第一次請求 .vue
if (!query.type) {
if (descriptor.script && descriptor.script.src) {
filename = await resolveSrcImport(descriptor.script, ctx, resolver)
}
ctx.type = 'js'
// body 返回解析後的代碼
ctx.body = await compileSFCMain(descriptor, filename, publicPath)
}
// ...
}
在 compileSFCMain 中是一段長長的 generate 代碼:
function compileSFCMain(descriptor, filePath: string, publicPath: string) {
let code = ''
if (descriptor.script) {
let content = descriptor.script.content
code += content.replace(`export default`, 'const __script =')
} else {
code += `const __script = {}`
}
if (descriptor.styles) {
code += `\nimport { updateStyle } from "${hmrClientId}"\n`
descriptor.styles.forEach((s, i) => {
const styleRequest = publicPath + `?type=style&index=${i}`
code += `\nupdateStyle("${id}-${i}", ${JSON.stringify(styleRequest)})`
})
if (hasScoped) {
code += `\n__script.__scopeId = "data-v-${id}"`
}
}
if (descriptor.template) {
code += `\nimport { render as __render } from ${JSON.stringify(
publicPath + `?type=template`
)}`
code += `\n__script.render = __render`
}
code += `\n__script.__hmrId = ${JSON.stringify(publicPath)}`
code += `\n__script.__file = ${JSON.stringify(filePath)}`
code += `\nexport default __script`
return code
}
直接看 generate 後的代碼:
import { updateStyle } from "/vite/hmr"
updateStyle("c44b8200-0", "/App.vue?type=style&index=0")
__script.__scopeId = "data-v-c44b8200"
import { render as __render } from "/App.vue?type=template"
__script.render = __render
__script.__hmrId = "/App.vue"
__script.__file = "/Users/muou/work/playground/vite-app/App.vue"
export default __script
出現了 vite/hmr
的導入,vite/hmr
具體內容咱們下文再分析,從這段代碼中能夠看到,對於 style vite 使用 updateStyle
這個方法處理,updateStyle
內容很是簡單,這裏就不貼代碼了,就作了 1 件事:經過建立 style 元素,設置了它的 innerHtml 爲 css 內容。
這兩種方式都會使得瀏覽器發起 http 請求,這樣就能被 koa 中間件捕獲到了,因此就造成了上文咱們看到的:對一個 .vue 文件處理三次的情景。
這部分代碼在:serverPluginVue 這個 plugin 裏。
css
若是在 vite 項目裏引入一個 sass 文件會怎麼樣?
最初 vite 只是爲 vue 項目開發,因此並無對 css 預編譯的支持,不過隨着後續的幾回大更新,在 vite 項目裏使用 sass/less 等也能夠跟使用 webpack 的時候同樣優雅了,只須要安裝對應的 css 預處理器便可。
在 cssPlugin 中,經過正則:/(.+).(less|sass|scss|styl|stylus)$/
判斷路徑是否須要 css 預編譯,若是命中正則,就藉助 cssUtils 裏的方法藉助 postcss 對要導入的 css 文件編譯。
vite 熱更新的實現
上文中出現了 vite/hmr ,這就是 vite 處理熱更新的關鍵,在 serverPluginHmr plugin 中,對於 path
等於 vite/hmr
作了一次判斷:
app.use(async (ctx, next) => {
if (ctx.path === '/vite/hmr') {
ctx.type = 'js'
ctx.status = 200
ctx.body = hmrClient
}
}
hmrClient 是 vite 熱更新的客戶端代碼,須要在瀏覽器裏執行,這裏先來講說通用的熱更新實現,熱更新通常須要四個部分:
首先須要 web 框架支持模塊的 rerender/reload
經過 watcher 監聽文件改動
經過 server 端編譯資源,並推送新模塊內容給 client 。
client 收到新的模塊內容,執行 rerender/reload
vite 也不例外一樣有這四個部分,其中客戶端代碼在:client.ts 裏,服務端代碼在 serverPluginHmr 裏,對於 vue 組件的更新,經過 vue3.x 中的 HMRRuntime
處理的。
client 端
在 client 端, WebSocket
監聽了一些更新的類型,而後分別處理,它們是:
vue-reload —— vue 組件更新:經過 import 導入新的 vue 組件,而後執行
HMRRuntime.reload
vue-rerender —— vue template 更新:經過 import 導入新的 template ,而後執行
HMRRuntime.rerender
vue-style-update —— vue style 更新:直接插入新的 stylesheet
style-update —— css 更新:document 插入新的 stylesheet
style-remove —— css 移除:document 刪除 stylesheet
js-update —— js 更新:直接執行
full-reload —— 頁面 roload:使用
window.reload
刷新頁面
server 端
在 server 端,經過 watcher 監聽頁面改動,根據文件類型判斷是 js Reload 仍是 Vue Reload:
watcher.on('change', async (file) => {
const timestamp = Date.now()
if (file.endsWith('.vue')) {
handleVueReload(file, timestamp)
} else if (
file.endsWith('.module.css') ||
!(file.endsWith('.css') || cssTransforms.some((t) => t.test(file, {})))
) {
// everything except plain .css are considered HMR dependencies.
// plain css has its own HMR logic in ./serverPluginCss.ts.
handleJSReload(file, timestamp)
}
})
在 handleVueReload 方法裏,會使用解析器拿到當前文件的 template/script/style ,而且與緩存裏的上一次解析的結果進行比較,若是 template 發生改變就執行 vue-rerender,若是 style 發生改變就執行 vue-style-update,簡化後的邏輯以下:
async function handleVueReload(
file
timestamp,
content
) {
// 獲取緩存
const cacheEntry = vueCache.get(file)
// 解析 vue 文件
const descriptor = await parseSFC(root, file, content)
if (!descriptor) {
// read failed
return
}
// 拿到上一次解析結果
const prevDescriptor = cacheEntry && cacheEntry.descriptor
// 設置刷新變量
let needReload = false // script 改變標記
let needCssModuleReload = false // css 改變標記
let needRerender = false // template 改變標記
// 判斷 script 是否相同
if (!isEqual(descriptor.script, prevDescriptor.script)) {
needReload = true
}
// 判斷 template 是否相同
if (!isEqual(descriptor.template, prevDescriptor.template)) {
needRerender = true
}
// 經過 send 發送 socket
if (needRerender){
send({
type: 'vue-rerender',
path: publicPath,
timestamp
})
}
}
handleJSReload
方法則是根據文件路徑引用,判斷被哪一個 vue 組件所依賴,若是未找到 vue 組件依賴,則判斷頁面須要刷新,不然走組件更新邏輯,這裏就不貼代碼了。
總體代碼在 client.ts 和 serverPluginHmr.ts 裏。
NO.5
結語
本文分析了 vite 的啓動鏈路以及背後的部分原理,雖然在短期內 vite 不會替代 webpack,可是可以看到社區中多了一種方案仍是很興奮的,這也是我寫下這篇文章的緣由。
vite 更新的實在太快了,佩服尤大的勤奮和開源精神,短短一個月就加入了諸如 css 預編譯/react支持/通用 hmr 的支持,因爲篇幅有限本文再也不一一介紹這些新特性,這些新的特性等待讀者朋友們自行去探討了。
最後
本文分享自微信公衆號 - 前端瓶子君(pinzi_com)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。