相比
Vue2
,
Vue3
的官方文檔中新增了一個線上
Playground
:
![](https://img.laitimes.com/img/_0nNw4CM6IyYiwiM6ICdiwiIwJWZ3xCdh1mcvZ2LcRDOxEzX3xCZlhXam9VbsUmepNXZy9CXldWYtlWPzNXZj9mcw1ycz9WL4xSPrdEZwZ1Rh5WNXp1bwNjW1ZUba9VZwlHdsAjMfd3bkFGazxCMx8VesATMfhHLlN3XnxCMz8FdsYkRGZkRG9lcvx2bjxSa2EWNhJTW1AlUxEFeVRUUfRHelRHL0EzXlpXazxyayFWbyVGdhd3LcV2Zh1Wa9M3clN2byBXLzN3btg3PwJWZ35iM5QDOzQWN5czYjdTOzI2NzYzX4IDM0YTMxIzLcFTMyIDMy8CXn9Gbi9CXzV2Zh1WavwVbvNmLvR3YxUjLyM3Lc9CX6MHc0RHaiojIsJye.webp)
打開是這樣的:
相當于讓你可以線上編寫和運作
Vue
單檔案元件,當然這個東西也是開源的,并且釋出為了一個
npm
包,本身是作為一個
Vue
元件,是以可以輕松在你的
Vue
項目中使用:
<script setup>
import { Repl } from '@vue/repl'
import '@vue/repl/style.css'
</script>
<template>
<Repl />
</template>
用于
demo
編寫和分享還是很不錯的,尤其适合作為基于
Vue
相關項目的線上
demo
,目前很多
Vue3
的元件庫都用了,倉庫位址:@vue/repl。
@vue/repl
有一些讓人(我)眼前一亮的特性,比如資料存儲在
url
中,支援建立多個檔案,當然也存在一些限制,比如隻支援
Vue3
,不支援使用
CSS
預處理語言,不過支援使用
ts
。
接下來會帶領各位從頭探索一下它的實作原理,需要說明的是我們會選擇性的忽略一些東西,比如
ssr
相關的,有需要了解這方面的可以自行閱讀源碼。
首先下載下傳該項目,然後找到測試頁面的入口檔案:
// test/main.ts
const App = {
setup() {
// 建立資料存儲的store
const store = new ReplStore({
serializedState: location.hash.slice(1)
})
// 資料存儲
watchEffect(() => history.replaceState({}, '', store.serialize()))
// 渲染Playground元件
return () =>
h(Repl, {
store,
sfcOptions: {
script: {}
}
})
}
}
createApp(App).mount('#app')
首先取出存儲在
url
的
hash
中的檔案資料,然後建立了一個
ReplStore
類的執行個體
store
,所有的檔案資料都會儲存在這個全局的
store
裡,接下來監聽
store
的檔案資料變化,變化了會實時反映在
url
中,即進行實時存儲,最後渲染元件
Repl
并傳入
store
。
先來看看
ReplStore
類。
資料存儲
// 預設的入口檔案名稱
const defaultMainFile = 'App.vue'
// 預設檔案的内容
const welcomeCode = `
<script setup>
import { ref } from 'vue'
const msg = ref('Hello World!')
</script>
<template>
<h1>{{ msg }}</h1>
<input v-model="msg">
</template>
`.trim()
// 資料存儲類
class ReplStore {
constructor({
serializedState = '',
defaultVueRuntimeURL = `https://unpkg.com/@vue/runtime-dom@${version}/dist/runtime-dom.esm-browser.js`,
}) {
let files: StoreState['files'] = {}
// 有存儲的資料
if (serializedState) {
// 解碼儲存的資料
const saved = JSON.parse(atou(serializedState))
for (const filename in saved) {
// 周遊檔案資料,建立檔案執行個體儲存到files對象上
files[filename] = new File(filename, saved[filename])
}
} else {
// 沒有存儲的資料
files = {
// 建立一個預設的檔案
[defaultMainFile]: new File(defaultMainFile, welcomeCode)
}
}
// Vue庫的cdn位址,注意是運作時版本,即不包含編譯模闆的代碼,也就是模闆必須先被編譯成渲染函數才行
this.defaultVueRuntimeURL = defaultVueRuntimeURL
// 預設的入口檔案為App.vue
let mainFile = defaultMainFile
if (!files[mainFile]) {
// 自定義了入口檔案
mainFile = Object.keys(files)[0]
}
// 核心資料
this.state = reactive({
mainFile,// 入口檔案名稱
files,// 所有檔案
activeFile: files[mainFile],// 目前正在編輯的檔案
errors: [],// 錯誤資訊
vueRuntimeURL: this.defaultVueRuntimeURL,// Vue庫的cdn位址
})
// 初始化import-map
this.initImportMap()
}
}
主要是使用
reactive
建立了一個響應式對象來作為核心的存儲對象,存儲的資料包括入口檔案名稱
mainFile
,一般作為根元件,所有的檔案資料
files
,以及目前我們正在編輯的檔案對象
activeFile
。
資料是如何存儲在url中的
可以看到上面對
hash
中取出的資料
serializedState
調用了
atou
方法,用于解碼資料,還有一個與之相對的
utoa
,用于編碼資料。
大家或多或少應該都聽過
url
有最大長度的限制,是以按照我們一般的想法,資料肯定不會選擇存儲到
url
上,但是
hash
部分的應該不受影響,并且
hash
資料也不會發送到服務端。
即便如此,
@vue/repl
在存儲前還是先做了壓縮的處理,畢竟
url
很多情況下是用來分享的,太長總歸不太友善。
首先來看一下最開始提到的
store.serialize()
方法,用來序列化檔案資料存儲到
url
上:
class ReplStore {
// 序列化檔案資料
serialize() {
return '#' + utoa(JSON.stringify(this.getFiles()))
}
// 擷取檔案資料
getFiles() {
const exported: Record<string, string> = {}
for (const filename in this.state.files) {
exported[filename] = this.state.files[filename].code
}
return exported
}
}
調用
getFiles
取出檔案名和檔案内容,然後轉成字元串後調用
utoa
方法:
import { zlibSync, strToU8, strFromU8 } from 'fflate'
export function utoa(data: string): string {
// 将字元串轉成Uint8Array
const buffer = strToU8(data)
// 以最大的壓縮級别進行壓縮,傳回的zipped也是一個Uint8Array
const zipped = zlibSync(buffer, { level: 9 })
// 将Uint8Array重新轉換成二進制字元串
const binary = strFromU8(zipped, true)
// 将二進制字元串編碼為Base64編碼字元串
return btoa(binary)
}
壓縮使用了fflate,号稱是目前最快、最小、最通用的純
JavaScript
壓縮和解壓庫。
可以看到其中
strFromU8
方法第二個參數傳了
true
,代表轉換成二進制字元串,這是必要的,因為
js
内置的
btoa
和
atob
方法不支援
Unicode
字元串,而我們的代碼内容顯然不可能隻使用
ASCII
的
256
個字元,那麼直接使用
btoa
編碼就會報錯:
詳情:base64.guru/developers/…。
看完了壓縮方法再來看一下對應的解壓方法
atou
:
import { unzlibSync, strToU8, strFromU8 } from 'fflate'
export function atou(base64: string): string {
// 将base64轉成二進制字元串
const binary = atob(base64)
// 檢查是否是zlib壓縮的資料,zlib header (x78), level 9 (xDA)
if (binary.startsWith('\x78\xDA')) {
// 将字元串轉成Uint8Array
const buffer = strToU8(binary, true)
// 解壓縮
const unzipped = unzlibSync(buffer)
// 将Uint8Array重新轉換成字元串
return strFromU8(unzipped)
}
// 相容沒有使用壓縮的資料
return decodeURIComponent(escape(binary))
}
和
utoa
稍微有點不一樣,最後一行還相容了沒有使用
fflate
壓縮的情況,因為
@vue/repl
畢竟是個元件,使用者初始傳入的資料可能沒有使用
fflate
壓縮,而是使用下面這種方式轉
base64
的:
function utoa(data) {
return btoa(unescape(encodeURIComponent(data)));
}
檔案類File
儲存到
files
對象上的檔案不是純文字内容,而是通過
File
類建立的檔案執行個體:
// 檔案類
export class File {
filename: string// 檔案名
code: string// 檔案内容
compiled = {// 該檔案編譯後的内容
js: '',
css: ''
}
constructor(filename: string, code = '', hidden = false) {
this.filename = filename
this.code = code
}
}
這個類很簡單,除了儲存檔案名和檔案内容外,主要是存儲檔案被編譯後的内容,如果是
js
檔案,編譯後的内容儲存在
compiled.js
上,
css
顯然就是儲存在
compiled.css
上,如果是
vue
單檔案,那麼
script
和
template
會編譯成
js
儲存到
compiled.js
上,樣式則會提取到
compiled.css
上儲存。
這個編譯邏輯我們後面會詳細介紹。
使用import-map
在浏覽器上直接使用
ESM
文法是不支援裸導入的,也就是下面這樣不行:
import moment from "moment";
導入來源需要是一個合法的
url
,那麼就出現了import-map這個提案,當然目前相容性還不太好import-maps,不過可以
polyfill
:
這樣我們就可以通過下面這種方式來使用裸導入了:
<script type="importmap">
{
"imports": {
"moment": "/node_modules/moment/src/moment.js",
}
}
</script>
<script type="importmap">
import moment from "moment";
</script>
那麼我們看一下
ReplStore
的
initImportMap
方法都做了 什麼:
private initImportMap() {
const map = this.state.files['import-map.json']
if (!map) {
// 如果還不存在import-map.json檔案,就建立一個,裡面主要是Vue庫的map
this.state.files['import-map.json'] = new File(
'import-map.json',
JSON.stringify(
{
imports: {
vue: this.defaultVueRuntimeURL
}
},
null,
2
)
)
} else {
try {
const json = JSON.parse(map.code)
// 如果vue不存在,那麼添加一個
if (!json.imports.vue) {
json.imports.vue = this.defaultVueRuntimeURL
map.code = JSON.stringify(json, null, 2)
}
} catch (e) {}
}
}
其實就是建立了一個
import-map.json
檔案用來儲存
import-map
的内容。
接下來就進入到我們的主角
Repl.vue
元件了,模闆部分其實沒啥好說的,主要分為左右兩部分,左側編輯器使用的是
codemirror
,右側預覽使用的是
iframe
,主要看一下
script
部分:
// ...
props.store.options = props.sfcOptions
props.store.init()
// ...
核心就是這兩行,将使用元件時傳入的
sfcOptions
儲存到
store
的
options
屬性上,後續編譯檔案時會使用,當然預設啥也沒傳,一個空對象而已,然後執行了
store
的
init
方法,這個方法就會開啟檔案編譯。
檔案編譯
class ReplStore {
init() {
watchEffect(() => compileFile(this, this.state.activeFile))
for (const file in this.state.files) {
if (file !== defaultMainFile) {
compileFile(this, this.state.files[file])
}
}
}
}
編譯目前正在編輯的檔案,預設為
App.vue
,并且當目前正在編輯的檔案發生變化之後會重新觸發編譯。另外如果初始存在多個檔案,也會周遊其他的檔案進行編譯。
執行編譯的
compileFile
方法比較長,我們慢慢來看。
編譯css檔案
export async function compileFile(
store: Store,
{ filename, code, compiled }: File
) {
// 檔案内容為空則傳回
if (!code.trim()) {
store.state.errors = []
return
}
// css檔案不用編譯,直接把檔案内容存儲到compiled.css屬性
if (filename.endsWith('.css')) {
compiled.css = code
store.state.errors = []
return
}
// ...
}
@vue/repl
目前不支援使用
css
預處理語言,是以樣式的話隻能建立
css
檔案,很明顯
css
不需要編譯,直接儲存到編譯結果對象上即可。
編譯js、ts檔案
繼續:
export async function compileFile(){
// ...
if (filename.endsWith('.js') || filename.endsWith('.ts')) {
if (shouldTransformRef(code)) {
code = transformRef(code, { filename }).code
}
if (filename.endsWith('.ts')) {
code = await transformTS(code)
}
compiled.js = code
store.state.errors = []
return
}
// ...
}
shouldTransformRef
和
transformRef
兩個方法是@vue/reactivity-transform包中的方法,用來幹啥的呢,其實
Vue3
中有個實驗性質的提案,我們都知道可以使用
ref
來建立一個原始值的響應性資料,但是通路的時候需要通過
.value
才行,那麼這個提案就是去掉這個
.value
,方式是不使用
ref
,而是使用
$ref
,比如:
// $ref都不用導出,直接使用即可
let count = $ref(0)
console.log(count)
除了
ref
,還支援其他幾個
api
:
是以
shouldTransformRef
方法就是用來檢查是否使用了這個實驗性質的文法,
transformRef
方法就是用來将其轉換成普通文法:
如果是
ts
檔案則會使用
transformTS
方法進行編譯:
import { transform } from 'sucrase'
async function transformTS(src: string) {
return transform(src, {
transforms: ['typescript']
}).code
}
使用sucrase轉換
ts
文法(說句題外話,我喜歡看源碼的一個原因之一就是總能從源碼中發現一些有用的庫或者工具),通常我們轉換
ts
要麼使用官方的
ts
工具,要麼使用
babel
,但是如果對編譯結果的浏覽器相容性不太關心的話可以使用
sucrase
,因為它超級快:
編譯Vue單檔案
繼續回到
compileFile
方法:
import hashId from 'hash-sum'
export async function compileFile(){
// ...
// 如果不是vue檔案,那麼就到此為止,其他檔案不支援
if (!filename.endsWith('.vue')) {
store.state.errors = []
return
}
// 檔案名不能重複,是以可以通過hash生成一個唯一的id,後面編譯的時候會用到
const id = hashId(filename)
// 解析vue單檔案
const { errors, descriptor } = store.compiler.parse(code, {
filename,
sourceMap: true
})
// 如果解析出錯,儲存錯誤資訊然後傳回
if (errors.length) {
store.state.errors = errors
return
}
// 接下來進行了兩個判斷,不影響主流程,代碼就不貼了
// 判斷template和style是否使用了其他語言,是的話抛出錯誤并傳回
// 判斷script是否使用了ts外的其他語言,是的話抛出錯誤并傳回
// ...
}
編譯
vue
單檔案的包是@vue/compiler-sfc,從
3.2.13
版本起這個包會内置在
vue
包中,安裝
vue
就可以直接使用這個包,這個包會随着
vue
的更新而更新,是以
@vue/repl
并沒有寫死,而是可以手動配置:
import * as defaultCompiler from 'vue/compiler-sfc'
export class ReplStore implements Store {
compiler = defaultCompiler
vueVersion?: string
async setVueVersion(version: string) {
this.vueVersion = version
const compilerUrl = `https://unpkg.com/@vue/compiler-sfc@${version}/dist/compiler-sfc.esm-browser.js`
const runtimeUrl = `https://unpkg.com/@vue/runtime-dom@${version}/dist/runtime-dom.esm-browser.js`
this.pendingCompiler = import(/* @vite-ignore */ compilerUrl)
this.compiler = await this.pendingCompiler
// ...
}
}
預設使用目前倉庫的
compiler-sfc
,但是可以通過調用
store.setVueVersion
方法來設定指定版本的
vue
和
compiler
。
假設我們的
App.vue
的内容如下:
<script setup>
import { ref } from 'vue'
const msg = ref('Hello World!')
</script>
<template>
<h1>{{ msg }}</h1>
<input v-model="msg">
</template>
<style>
h1 {
color: red;
}
</style>
compiler.parse
方法會将其解析成如下結果:
其實就是解析出了其中的
script
、
template
、
style
三個部分的内容。
繼續回到
compileFile
方法:
export async function compileFile(){
// ...
// 是否有style塊使用了scoped作用域
const hasScoped = descriptor.styles.some((s) => s.scoped)
// 儲存編譯結果
let clientCode = ''
const appendSharedCode = (code: string) => {
clientCode += code
}
// ...
}
clientCode
用來儲存最終的編譯結果。
編譯script
繼續回到
compileFile
方法:
export async function compileFile(){
// ...
const clientScriptResult = await doCompileScript(
store,
descriptor,
id,
isTS
)
// ...
}
調用
doCompileScript
方法編譯
script
部分,其實
template
部分也會被一同編譯進去,除非你沒有使用
<script setup>
文法或者手動配置了不要這麼做:
h(Repl, {
sfcOptions: {
script: {
inlineTemplate: false
}
}
})
我們先忽略這種情況,看一下
doCompileScript
方法的實作:
export const COMP_IDENTIFIER = `__sfc__`
async function doCompileScript(
store: Store,
descriptor: SFCDescriptor,
id: string,
isTS: boolean
): Promise<[string, BindingMetadata | undefined] | undefined> {
if (descriptor.script || descriptor.scriptSetup) {
try {
const expressionPlugins: CompilerOptions['expressionPlugins'] = isTS
? ['typescript']
: undefined
// 1.編譯script
const compiledScript = store.compiler.compileScript(descriptor, {
inlineTemplate: true,// 是否編譯模闆并直接在setup()裡面内聯生成的渲染函數
...store.options?.script,
id,// 用于樣式的作用域
templateOptions: {// 編譯模闆的選項
...store.options?.template,
compilerOptions: {
...store.options?.template?.compilerOptions,
expressionPlugins// 這個選項并沒有在最新的@vue/compiler-sfc包的源碼中看到,可能廢棄了
}
}
})
let code = ''
// 2.轉換預設導出
code +=
`\n` +
store.compiler.rewriteDefault(
compiledScript.content,
COMP_IDENTIFIER,
expressionPlugins
)
// 3.編譯ts
if ((descriptor.script || descriptor.scriptSetup)!.lang === 'ts') {
code = await transformTS(code)
}
return [code, compiledScript.bindings]
} catch (e: any) {
store.state.errors = [e.stack.split('\n').slice(0, 12).join('\n')]
return
}
} else {
return [`\nconst ${COMP_IDENTIFIER} = {}`, undefined]
}
}
這個函數主要做了三件事,我們一一來看。
1.編譯script
調用
compileScript
方法編譯
script
,這個方法會處理
<script setup>
文法、
css
變量注入等特性,
css
變量注入指的是在
style
标簽中使用
v-bind
綁定元件的
data
資料這種情況,詳細了解[CSS 中的 v-bind](單檔案元件 CSS 功能 | Vue.js)。
如果使用了
<script setup>
文法,且
inlineTemplate
選項傳了
true
,那麼會同時将
template
部分編譯成渲染函數并内聯到
setup
函數裡面,否則
template
需要另外編譯。
id
參數用于作為
scoped id
,當
style
塊中使用了
scoped
,或者使用了
v-bind
文法,都需要使用這個
id
來建立唯一的
class
類名、樣式名。
編譯結果如下:
可以看到模闆部分被編譯成了渲染函數并内聯到了元件的
setup
函數内,并且使用
export default
預設導出元件。
2.轉換預設導出
這一步會把前面得到的預設導出語句轉換成變量定義的形式,使用的是
rewriteDefault
方法,這個方法接收三個參數:要轉換的内容、變量名稱、插件數組,這個插件數組是傳給
babel
使用的,是以如果使用了
ts
,那麼會傳入
['typescript']
。
轉換結果如下:
轉成變量有什麼好處呢,其實這樣就可以友善的在對象上添加其他屬性了,如果是
export default {}
的形式,如果想在這個對象上擴充一些屬性要怎麼做呢?正則比對?轉成
ast
樹?好像可以,但不是很友善,因為都得操作源内容,但是轉成變量就很簡單了,隻要知道定義的變量名稱,就可以直接拼接如下代碼:
__sfc__.xxx = xxx
不需要知道源内容是什麼,想添加什麼屬性就直接添加。
3.編譯ts
最後一步會判斷是否使用了
ts
,是的話就使用前面提到過的
transformTS
方法進行編譯。
編譯完
script
回到
compileFile
方法:
export async function compileFile(){
// ...
// 如果script編譯沒有結果則傳回
if (!clientScriptResult) {
return
}
// 拼接script編譯結果
const [clientScript, bindings] = clientScriptResult
clientCode += clientScript
// 給__sfc__元件對象添加了一個__scopeId屬性
if (hasScoped) {
appendSharedCode(
`\n${COMP_IDENTIFIER}.__scopeId = ${JSON.stringify(`data-v-${id}`)}`
)
}
if (clientCode) {
appendSharedCode(
`\n${COMP_IDENTIFIER}.__file = ${JSON.stringify(filename)}` +// 給__sfc__元件對象添加了一個__file屬性
`\nexport default ${COMP_IDENTIFIER}`// 導出__sfc__元件對象
)
compiled.js = clientCode.trimStart()// 将script和template的編譯結果儲存起來
}
// ...
}
将
export default
轉換成變量定義的好處來了,添加新屬性很友善。
最後使用
export default
導出定義的變量即可。
編譯template
前面已經提到過幾次,如果使用了
<script setup>
文法且
inlineTemplate
選項沒有設為
false
,那麼無需自己手動編譯
template
,如果要自己編譯也很簡單,調用一下
compiler.compileTemplate
方法編譯即可,實際上前面隻是
compiler.compileScript
方法内部幫我們調了這個方法而已,這個方法會将
template
編譯成渲染函數,我們把這個渲染函數字元串也拼接到
clientCode
上,并且在元件選項對象,也就是前面一步編譯
script
得到的
__sfc__
對象上添加一個
render
屬性,值就是這個渲染函數:
let code =
`\n${templateResult.code.replace(
/\nexport (function|const) render/,
`$1 render`
)}` + `\n${COMP_IDENTIFIER}.render = render
編譯style
繼續回到
compileFile
方法,到這裡
vue
單檔案的
script
和
template
部分就已經編譯完了,接下來會處理
style
部分:
export async function compileFile(){
// ...
let css = ''
// 周遊style塊
for (const style of descriptor.styles) {
// 不支援使用CSS Modules
if (style.module) {
store.state.errors = [
`<style module> is not supported in the playground.`
]
return
}
// 編譯樣式
const styleResult = await store.compiler.compileStyleAsync({
...store.options?.style,
source: style.content,
filename,
id,
scoped: style.scoped,
modules: !!style.module
})
css += styleResult.code + '\n'
}
if (css) {
// 儲存編譯結果
compiled.css = css.trim()
} else {
compiled.css = '/* No <style> tags present */'
}
}
很簡單,使用
compileStyleAsync
方法編譯
style
塊,這個方法會幫我們處理
scoped
、
module
以及
v-bind
文法。
到這裡,檔案編譯部分就介紹完了,總結一下:
- 樣式檔案因為隻能使用原生
,是以不需要編譯css
-
檔案原本也不需要編譯,但是有可能使用了實驗性質的js
文法,是以需要進行一下判斷并處理,如果使用了$ref
那麼需要進行編譯ts
-
單檔案會使用vue
編譯,@vue/compiler-sfc
部分會處理script
文法、setup
變量注入等特性,如果使用了css
也會編譯ts
,最後的結果其實就是元件對象,ts
部分無論是和template
一起編譯還是單獨編譯,最後都會編譯成渲染函數挂載到元件對象上,script
部分編譯後直接儲存起來即可style
預覽
檔案都編譯完成了接下來是不是就可以直接預覽了呢?很遺憾,并不能,為什麼呢,因為前面檔案編譯完後得到的是普通的
ESM
子產品,也就是通過
import
和
export
來導入和導出,比如除了
App.vue
外,我們還建立了一個
Comp.vue
檔案,然後在
App.vue
中引入
// App.vue
import Comp from './Comp.vue'
乍一看好像沒啥問題,但問題是伺服器上并沒有
./Comp.vue
檔案,這個檔案隻是我們在前端模拟的,那麼如果直接讓浏覽器發出這個子產品請求肯定是失敗的,并且我們模拟建立的這些檔案最終都會通過一個個
<script type="module">
标簽插入到頁面,是以需要把
import
和
export
轉換成其他形式。
建立iframe
預覽部分會先建立一個
iframe
:
onMounted(createSandbox)
let sandbox: HTMLIFrameElement
function createSandbox() {
// ...
sandbox = document.createElement('iframe')
sandbox.setAttribute(
'sandbox',
[
'allow-forms',
'allow-modals',
'allow-pointer-lock',
'allow-popups',
'allow-same-origin',
'allow-scripts',
'allow-top-navigation-by-user-activation'
].join(' ')
)
// ...
}
建立一個
iframe
元素,并且設定了
sandbox
屬性,這個屬性可以控制
iframe
架構中的頁面的一些行為是否被允許,詳情arrt-sand-box。
import srcdoc from './srcdoc.html?raw'
function createSandbox() {
// ...
// 檢查importMap是否合法
const importMap = store.getImportMap()
if (!importMap.imports) {
importMap.imports = {}
}
if (!importMap.imports.vue) {
importMap.imports.vue = store.state.vueRuntimeURL
}
// 向架構頁面内容中注入import-map
const sandboxSrc = srcdoc.replace(
/<!--IMPORT_MAP-->/,
JSON.stringify(importMap)
)
// 将頁面HTML内容注入架構
sandbox.srcdoc = sandboxSrc
// 添加架構到頁面
container.value.appendChild(sandbox)
// ...
}
srcdoc.html
就是用于預覽的頁面,會先注入
import-map
的内容,然後通過建立的
iframe
渲染該頁面。
let proxy: PreviewProxy
function createSandbox() {
// ...
proxy = new PreviewProxy(sandbox, {
on_error:() => {}
// ...
})
sandbox.addEventListener('load', () => {
stopUpdateWatcher = watchEffect(updatePreview)
})
}
接下來建立了一個
PreviewProxy
類的執行個體,最後在
iframe
加載完成時注冊一個副作用函數
updatePreview
,這個方法内會處理檔案并進行預覽操作。
和iframe通信
PreviewProxy
類主要是用來和
iframe
通信的:
export class PreviewProxy {
constructor(iframe: HTMLIFrameElement, handlers: Record<string, Function>) {
this.iframe = iframe
this.handlers = handlers
this.pending_cmds = new Map()
this.handle_event = (e) => this.handle_repl_message(e)
window.addEventListener('message', this.handle_event, false)
}
}
message
事件可以監聽來自
iframe
的資訊,向
iframe
發送資訊是通過
postMessage
方法:
export class PreviewProxy {
iframe_command(action: string, args: any) {
return new Promise((resolve, reject) => {
const cmd_id = uid++
this.pending_cmds.set(cmd_id, { resolve, reject })
this.iframe.contentWindow!.postMessage({ action, cmd_id, args }, '*')
})
}
}
通過這個方法可以向
iframe
發送消息,傳回一個
promise
,發消息前會生成一個唯一的
id
,然後把
promise
的
resolve
和
reject
通過
id
儲存起來,并且這個
id
會發送給
iframe
,當
iframe
任務執行完了會向父視窗回複資訊,并且會發回這個
id
,那麼父視窗就可以通過這個
id
取出
resove
和
reject
根據函數根據任務執行的成功與否決定調用哪個。
iframe
向父級發送資訊的方法:
// srcdoc.html
window.addEventListener('message', handle_message, false);
async function handle_message(ev) {
// 取出任務名稱和id
let { action, cmd_id } = ev.data;
// 向父級發送消息
const send_message = (payload) => parent.postMessage( { ...payload }, ev.origin);
// 回複父級,會帶回id
const send_reply = (payload) => send_message({ ...payload, cmd_id });
// 成功的回複
const send_ok = () => send_reply({ action: 'cmd_ok' });
// 失敗的回複
const send_error = (message, stack) => send_reply({ action: 'cmd_error', message, stack });
// 根據actiion判斷執行什麼任務
// ...
}
編譯子產品進行預覽
接下來看一下
updatePreview
方法,這個方法内會再一次編譯檔案,得到子產品清單,其實就是
js
代碼,然後将子產品清單發送給
iframe
,
iframe
會動态建立
script
标簽插入這些子產品代碼,達到更新
iframe
頁面進行預覽的效果。
async function updatePreview() {
// ...
try {
// 編譯檔案生成子產品代碼
const modules = compileModulesForPreview(store)
// 待插入到iframe頁面中的代碼
const codeToEval = [
`window.__modules__ = {}\nwindow.__css__ = ''\n` +
`if (window.__app__) window.__app__.unmount()\n` +
`document.body.innerHTML = '<div id="app"></div>'`,
...modules,
`document.getElementById('__sfc-styles').innerHTML = window.__css__`
]
// 如果入口檔案時Vue檔案,那麼添加挂載它的代碼!
if (mainFile.endsWith('.vue')) {
codeToEval.push(
`import { createApp } as _createApp } from "vue"
const _mount = () => {
const AppComponent = __modules__["${mainFile}"].default
AppComponent.name = 'Repl'
const app = window.__app__ = _createApp(AppComponent)
app.config.unwrapInjectedRef = true
app.config.errorHandler = e => console.error(e)
app.mount('#app')
}
_mount()
)`
}
// 給iframe頁面發送消息,插入這些子產品代碼
await proxy.eval(codeToEval)
} catch (e: any) {
// ...
}
}
codeToEval
數組揭示了預覽的原理,
codeToEval
數組的内容最後是會發送到
iframe
頁面中,然後動态建立
script
标簽插入到頁面進行運作的。
首先我們再添加一個檔案
Comp.vue
:
<script setup>
import { ref } from 'vue'
const msg = ref('我是子元件')
</script>
<template>
<h1>{{ msg }}</h1>
</template>
然後在
App.vue
元件中引入:
<script setup>
import { ref } from 'vue'
import Comp from './Comp.vue'// ++
const msg = ref('Hello World!')
</script>
<template>
<h1>{{ msg }}</h1>
<input v-model="msg">
<Comp></Comp>// ++
</template>
<style>
h1 {
color: red;
}
</style>
此時經過上一節【檔案編譯】處理後,
Comp.vue
的編譯結果如下所示:
App.vue
的編譯結果如下所示:
compileModulesForPreview
會再一次編譯各個檔案,主要是做以下幾件事情:
1.将子產品的導出語句
export
轉換成屬性添加語句,也就是把子產品添加到
window.__modules__
對象上:
const __sfc__ = {
__name: 'Comp',
// ...
}
export default __sfc__
轉換成:
const __module__ = __modules__["Comp.vue"] = { [Symbol.toStringTag]: "Module" }
__module__.default = __sfc__
2.将
import
了相對路徑的子產品
./
的語句轉成指派的語句,這樣可以從
__modules__
對象上擷取到指定子產品:
import Comp from './Comp.vue'
轉換成:
const __import_1__ = __modules__["Comp.vue"]
3.最後再轉換一下導入的元件使用到的地方:
_createVNode(Comp)
轉換成:
_createVNode(__import_1__.default)
4.如果該元件存在樣式,那麼追加到
window.__css__
字元串上:
if (file.compiled.css) {
js += `\nwindow.__css__ += ${JSON.stringify(file.compiled.css)}`
}
此時再來看
codeToEval
數組的内容就很清晰了,首先建立一個全局對象
window.__modules__
、一個全局字元串
window.__css__
,如果之前已經存在
__app__
執行個體,說明是更新情況,那麼先解除安裝之前的元件,然後在頁面中建立一個
id
為
app
的
div
元素用于挂載
Vue
元件,接下來添加
compileModulesForPreview
方法編譯傳回的子產品數組,這樣這些元件運作時全局變量都已定義好了,元件有可能會往
window.__css__
上添加樣式,是以當所有元件運作完後再将
window.__css__
樣式添加到頁面。
最後,如果入口檔案是
Vue
元件,那麼會再添加一段
Vue
的執行個體化和挂載代碼。
compileModulesForPreview
方法比較長,做的事情大緻就是從入口檔案開始,按前面的4點轉換檔案,然後遞歸所有依賴的元件也進行轉換,具體的轉換方式是使用
babel
将子產品轉換成
AST
樹,然後使用magic-string修改源代碼,這種代碼對于會的人來說很簡單,對于沒有接觸過
AST
樹操作的人來說就很難看懂,是以具體代碼就不貼了,有興趣檢視具體實作的可以點選moduleCompiler.ts。
codeToEval
數組内容準備好了,就可以給預覽的
iframe
發送消息了:
await proxy.eval(codeToEval)
iframe
接收到消息後會先删除之前添加的
script
标簽,然後建立新标簽:
// scrdoc.html
async function handle_message(ev) {
let { action, cmd_id } = ev.data;
// ...
if (action === 'eval') {
try {
// 移除之前建立的标簽
if (scriptEls.length) {
scriptEls.forEach(el => {
document.head.removeChild(el)
})
scriptEls.length = 0
}
// 周遊建立script标簽
let { script: scripts } = ev.data.args
if (typeof scripts === 'string') scripts = [scripts]
for (const script of scripts) {
const scriptEl = document.createElement('script')
scriptEl.setAttribute('type', 'module')
const done = new Promise((resolve) => {
window.__next__ = resolve
})
scriptEl.innerHTML = script + `\nwindow.__next__()`
document.head.appendChild(scriptEl)
scriptEls.push(scriptEl)
await done
}
}
// ...
}
}
為了讓子產品按順序挨個添加,會建立一個
promise
,并且把
resove
方法指派到一個全局的屬性
__next__
上,然後再在每個子產品最後拼接上調用的代碼,這樣當插入一個
script
标簽時,該标簽的代碼運作完畢會執行
window.__next__
方法,那麼就會結束目前的
promise
,進入下一個
script
标簽的插件,不得不說,還是很巧妙的。