1. 前言
大家好,我是若川。最近組織了原始碼共讀活動,感興趣的可以加我微信 ruochuan12 參與,已進行兩個多月,大家一起交流學習,共同進步。
想學原始碼,極力推薦之前我寫的《學習原始碼整體架構系列》 包含jQuery
、underscore
、lodash
、vuex
、sentry
、axios
、redux
、koa
、vue-devtools
、vuex4
、koa-compose
、vue-next-release
、vue-this
、create-vue
等十餘篇原始碼文章。
最近組織了原始碼共讀活動,大家一起學習原始碼。於是各種搜尋值得我們學習,且程式碼行數不多的原始碼。
在 vuejs組織 下,找到了尤雨溪幾年前寫的“玩具 vite”
vue-dev-server,發現100來行程式碼,很值得學習。於是有了這篇文章。
閱讀本文,你將學到:
1. 學會 vite 簡單原理
2. 學會使用 VSCode 除錯原始碼
3. 學會如何編譯 Vue 單檔案元件
4. 學會如何使用 recast 生成 ast 轉換檔案
5. 如何載入包檔案
6. 等等
2. vue-dev-server 它的原理是什麼
vue-dev-server#how-it-worksREADME
文件上有四句英文介紹。
發現谷歌翻譯的還比較準確,我就原封不動的搬運過來。
- 瀏覽器請求匯入作為原生 ES 模組匯入 - 沒有捆綁。
- 伺服器攔截對 *.vue 檔案的請求,即時編譯它們,然後將它們作為 JavaScript 發回。
- 對於提供在瀏覽器中工作的 ES 模組構建的庫,只需直接從 CDN 匯入它們。
- 匯入到 .js 檔案中的 npm 包(僅包名稱)會即時重寫以指向本地安裝的檔案。 目前,僅支援 vue 作為特例。 其他包可能需要進行轉換才能作為本地瀏覽器目標 ES 模組公開。
也可以看看vitejs 文件,瞭解下原理,文件中圖畫得非常好。
看完本文後,我相信你會有一個比較深刻的理解。
3. 準備工作
3.1 克隆專案
本文倉庫 vue-dev-server-analysis,求個star^_^
# 推薦克隆我的倉庫
git clone https://github.com/lxchuan12/vue-dev-server-analysis.git
cd vue-dev-server-analysis/vue-dev-server
# npm i -g yarn
# 安裝依賴
yarn
# 或者克隆官方倉庫
git clone https://github.com/vuejs/vue-dev-server.git
cd vue-dev-server
# npm i -g yarn
# 安裝依賴
yarn
一般來說,我們看原始碼先從package.json
檔案開始:
// vue-dev-server/package.json
{
"name": "@vue/dev-server",
"version": "0.1.1",
"description": "Instant dev server for Vue single file components",
"main": "middleware.js",
// 指定可執行的命令
"bin": {
"vue-dev-server": "./bin/vue-dev-server.js"
},
"scripts": {
// 先跳轉到 test 資料夾,再用 Node 執行 vue-dev-server 檔案
"test": "cd test && node ../bin/vue-dev-server.js"
}
}
根據 scripts
test
命令。我們來看 test
資料夾。
3.2 test 資料夾
vue-dev-server/test
資料夾下有三個檔案,程式碼不長。
- index.html
- main.js
- text.vue
如圖下圖所示。
接著我們找到 vue-dev-server/bin/vue-dev-server.js
檔案,程式碼也不長。
3.3 vue-dev-server.js
// vue-dev-server/bin/vue-dev-server.js
#!/usr/bin/env node
const express = require('express')
const { vueMiddleware } = require('../middleware')
const app = express()
const root = process.cwd();
app.use(vueMiddleware())
app.use(express.static(root))
app.listen(3000, () => {
console.log('server running at http://localhost:3000')
})
原來就是express
啟動了埠3000
的服務。重點在 vueMiddleware
中介軟體。接著我們來除錯這個中介軟體。
鑑於估計很多小夥伴沒有用過VSCode
除錯,這裡詳細敘述下如何除錯原始碼。學會除錯原始碼後,原始碼並沒有想象中的那麼難。
3.4 用 VSCode 除錯專案
vue-dev-server/bin/vue-dev-server.js
檔案中這行 app.use(vueMiddleware())
打上斷點。
找到 vue-dev-server/package.json
的 scripts
,把滑鼠移動到 test
命令上,會出現執行指令碼
和除錯指令碼
命令。如下圖所示,選擇除錯指令碼。
點選進入函式(F11)
按鈕可以進入 vueMiddleware
函式。如果發現斷點走到不是本專案的檔案中,不想看,看不懂的情況,可以退出或者重新來過。可以用瀏覽器無痕(隱私)模式(快捷鍵Ctrl + Shift + N
,防止外掛干擾)開啟 http://localhost:3000
,可以繼續除錯 vueMiddleware
函式返回的函式。
如果你的VSCode
不是中文(不習慣英文),可以安裝簡體中文外掛。
如果VSCode
沒有這個除錯功能。建議更新到最新版的VSCode
(目前最新版本v1.61.2
)。
接著我們來跟著除錯學習 vueMiddleware
原始碼。可以先看主線,在你覺得重要的地方繼續斷點除錯。
4. vueMiddleware 原始碼
4.1 有無 vueMiddleware 中介軟體對比
不在除錯情況狀態下,我們可以在 vue-dev-server/bin/vue-dev-server.js
檔案中註釋 app.use(vueMiddleware())
,執行 npm run test
開啟 http://localhost:3000
。
再啟用中介軟體後,如下圖。
看圖我們大概知道了有哪些區別。
4.2 vueMiddleware 中介軟體概覽
我們可以找到vue-dev-server/middleware.js
,檢視這個中介軟體函式的概覽。
// vue-dev-server/middleware.js
const vueMiddleware = (options = defaultOptions) => {
// 省略
return async (req, res, next) => {
// 省略
// 對 .vue 結尾的檔案進行處理
if (req.path.endsWith('.vue')) {
// 對 .js 結尾的檔案進行處理
} else if (req.path.endsWith('.js')) {
// 對 /__modules/ 開頭的檔案進行處理
} else if (req.path.startsWith('/__modules/')) {
} else {
next()
}
}
}
exports.vueMiddleware = vueMiddleware
vueMiddleware
最終返回一個函式。這個函式裡主要做了四件事:
- 對
.vue
結尾的檔案進行處理 - 對
.js
結尾的檔案進行處理 - 對
/__modules/
開頭的檔案進行處理 - 如果不是以上三種情況,執行
next
方法,把控制權交給下一個中介軟體
接著我們來看下具體是怎麼處理的。
我們也可以斷點這些重要的地方來檢視實現。比如:
4.3 對 .vue 結尾的檔案進行處理
if (req.path.endsWith('.vue')) {
const key = parseUrl(req).pathname
let out = await tryCache(key)
if (!out) {
// Bundle Single-File Component
const result = await bundleSFC(req)
out = result
cacheData(key, out, result.updateTime)
}
send(res, out.code, 'application/javascript')
}
4.3.1 bundleSFC 編譯單檔案元件
這個函式,根據 @vue/component-compiler 轉換單檔案元件,最終返回瀏覽器能夠識別的檔案。
const vueCompiler = require('@vue/component-compiler')
async function bundleSFC (req) {
const { filepath, source, updateTime } = await readSource(req)
const descriptorResult = compiler.compileToDescriptor(filepath, source)
const assembledResult = vueCompiler.assemble(compiler, filepath, {
...descriptorResult,
script: injectSourceMapToScript(descriptorResult.script),
styles: injectSourceMapsToStyles(descriptorResult.styles)
})
return { ...assembledResult, updateTime }
}
接著我們來看 readSource
函式實現。
4.3.2 readSource 讀取檔案資源
這個函式主要作用:根據請求獲取檔案資源。返回檔案路徑 filepath
、資源 source
、和更新時間 updateTime
。
const path = require('path')
const fs = require('fs')
const readFile = require('util').promisify(fs.readFile)
const stat = require('util').promisify(fs.stat)
const parseUrl = require('parseurl')
const root = process.cwd()
async function readSource(req) {
const { pathname } = parseUrl(req)
const filepath = path.resolve(root, pathname.replace(/^\//, ''))
return {
filepath,
source: await readFile(filepath, 'utf-8'),
updateTime: (await stat(filepath)).mtime.getTime()
}
}
exports.readSource = readSource
接著我們來看對 .js 檔案的處理
4.4 對 .js 結尾的檔案進行處理
if (req.path.endsWith('.js')) {
const key = parseUrl(req).pathname
let out = await tryCache(key)
if (!out) {
// transform import statements
// 轉換 import 語句
// import Vue from 'vue'
// => import Vue from "/__modules/vue"
const result = await readSource(req)
out = transformModuleImports(result.source)
cacheData(key, out, result.updateTime)
}
send(res, out, 'application/javascript')
}
針對 vue-dev-server/test/main.js
轉換
import Vue from 'vue'
import App from './test.vue'
new Vue({
render: h => h(App)
}).$mount('#app')
// 公眾號:若川視野
// 加微信 ruochuan12
// 參加原始碼共讀,一起學習原始碼
import Vue from "/__modules/vue"
import App from './test.vue'
new Vue({
render: h => h(App)
}).$mount('#app')
// 公眾號:若川視野
// 加微信 ruochuan12
// 參加原始碼共讀,一起學習原始碼
4.4.1 transformModuleImports 轉換 import 引入
const recast = require('recast')
const isPkg = require('validate-npm-package-name')
function transformModuleImports(code) {
const ast = recast.parse(code)
recast.types.visit(ast, {
visitImportDeclaration(path) {
const source = path.node.source.value
if (!/^\.\/?/.test(source) && isPkg(source)) {
path.node.source = recast.types.builders.literal(`/__modules/${source}`)
}
this.traverse(path)
}
})
return recast.print(ast).code
}
exports.transformModuleImports = transformModuleImports
也就是針對 npm
包轉換。 這裡就是 "/__modules/vue"
import Vue from 'vue' => import Vue from "/__modules/vue"
4.5 對 /__modules/ 開頭的檔案進行處理
import Vue from "/__modules/vue"
這段程式碼最終返回的是讀取路徑 vue-dev-server/node_modules/vue/dist/vue.esm.browser.js
下的檔案。
if (req.path.startsWith('/__modules/')) {
//
const key = parseUrl(req).pathname
const pkg = req.path.replace(/^\/__modules\//, '')
let out = await tryCache(key, false) // Do not outdate modules
if (!out) {
out = (await loadPkg(pkg)).toString()
cacheData(key, out, false) // Do not outdate modules
}
send(res, out, 'application/javascript')
}
4.5.1 loadPkg 載入包(這裡只支援Vue檔案)
目前只支援 Vue
檔案,也就是讀取路徑 vue-dev-server/node_modules/vue/dist/vue.esm.browser.js
下的檔案返回。
// vue-dev-server/loadPkg.js
const fs = require('fs')
const path = require('path')
const readFile = require('util').promisify(fs.readFile)
async function loadPkg(pkg) {
if (pkg === 'vue') {
// 路徑
// vue-dev-server/node_modules/vue/dist
const dir = path.dirname(require.resolve('vue'))
const filepath = path.join(dir, 'vue.esm.browser.js')
return readFile(filepath)
}
else {
// TODO
// check if the package has a browser es module that can be used
// otherwise bundle it with rollup on the fly?
throw new Error('npm imports support are not ready yet.')
}
}
exports.loadPkg = loadPkg
至此,我們就基本分析完畢了主檔案和一些引入的檔案。對主流程有個瞭解。
5. 總結
最後我們來看上文中有無 vueMiddleware 中介軟體的兩張圖總結一下:
啟用中介軟體後,如下圖。
瀏覽器支援原生 type=module
模組請求載入。vue-dev-server
對其攔截處理,返回瀏覽器支援內容,因為無需打包構建,所以速度很快。
<script type="module">
import './main.js'
</script>
5.1 import Vue from 'vue' 轉換
// vue-dev-server/test/main.js
import Vue from 'vue'
import App from './test.vue'
new Vue({
render: h => h(App)
}).$mount('#app')
main.js 中的 import 語句
import Vue from 'vue'
通過 recast 生成 ast 轉換成 import Vue from "/__modules/vue"
而最終返回給瀏覽器的是 vue-dev-server/node_modules/vue/dist/vue.esm.browser.js
5.2 import App from './test.vue' 轉換
main.js
中的引入 .vue
的檔案,import App from './test.vue'
則用 @vue/component-compiler 轉換成瀏覽器支援的檔案。
5.3 後續還能做什麼?
鑑於文章篇幅有限,快取 tryCache
部分目前沒有分析。簡單說就是使用了 node-lru-cache 最近最少使用
來做快取的(這個演算法常考)。後續應該會分析這個倉庫的原始碼,歡迎持續關注我@若川。
非常建議讀者朋友按照文中方法使用VSCode
除錯 vue-dev-server
原始碼。原始碼中還有很多細節文中由於篇幅有限,未全面展開講述。
值得一提的是這個倉庫的 master
分支,是尤雨溪兩年前寫的,相對本文會比較複雜,有餘力的讀者可以學習。
也可以直接去看 vite
原始碼。
看完本文,也許你就能發現其實前端能做的事情越來越多,不由感慨:前端水深不可測,唯有持續學習。
最後歡迎加我微信 ruochuan12 交流,參與 原始碼共讀 活動,大家一起學習原始碼,共同進步。