天天看點

Vue3官方出的Playground你都用了嗎?沒有沒關系,直接原理講給你聽

相比​

​Vue2​

​​,​

​Vue3​

​​的官方文檔中新增了一個線上​

​Playground​

​:

Vue3官方出的Playground你都用了嗎?沒有沒關系,直接原理講給你聽

打開是這樣的:

Vue3官方出的Playground你都用了嗎?沒有沒關系,直接原理講給你聽

相當于讓你可以線上編寫和運作​

​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​

​編碼就會報錯:

Vue3官方出的Playground你都用了嗎?沒有沒關系,直接原理講給你聽

詳情:​​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​

​:

Vue3官方出的Playground你都用了嗎?沒有沒關系,直接原理講給你聽

這樣我們就可以通過下面這種方式來使用裸導入了:

<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​

​:

Vue3官方出的Playground你都用了嗎?沒有沒關系,直接原理講給你聽

是以​

​shouldTransformRef​

​​方法就是用來檢查是否使用了這個實驗性質的文法,​

​transformRef​

​方法就是用來将其轉換成普通文法:

Vue3官方出的Playground你都用了嗎?沒有沒關系,直接原理講給你聽

如果是​

​ts​

​​檔案則會使用​

​transformTS​

​方法進行編譯:

import { transform } from 'sucrase'

async function transformTS(src: string) {
  return transform(src, {
    transforms: ['typescript']
  }).code
}      

使用​​sucrase​​轉換​

​ts​

​​文法(說句題外話,我喜歡看源碼的一個原因之一就是總能從源碼中發現一些有用的庫或者工具),通常我們轉換​

​ts​

​​要麼使用官方的​

​ts​

​​工具,要麼使用​

​babel​

​​,但是如果對編譯結果的浏覽器相容性不太關心的話可以使用​

​sucrase​

​,因為它超級快:

Vue3官方出的Playground你都用了嗎?沒有沒關系,直接原理講給你聽

編譯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​

​方法會将其解析成如下結果:

Vue3官方出的Playground你都用了嗎?沒有沒關系,直接原理講給你聽

其實就是解析出了其中的​

​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​

​類名、樣式名。

編譯結果如下:

Vue3官方出的Playground你都用了嗎?沒有沒關系,直接原理講給你聽

可以看到模闆部分被編譯成了渲染函數并内聯到了元件的​

​setup​

​​函數内,并且使用​

​export default​

​預設導出元件。

2.轉換預設導出

這一步會把前面得到的預設導出語句轉換成變量定義的形式,使用的是​

​rewriteDefault​

​​方法,這個方法接收三個參數:要轉換的内容、變量名稱、插件數組,這個插件數組是傳給​

​babel​

​​使用的,是以如果使用了​

​ts​

​​,那麼會傳入​

​['typescript']​

​。

轉換結果如下:

Vue3官方出的Playground你都用了嗎?沒有沒關系,直接原理講給你聽

轉成變量有什麼好處呢,其實這樣就可以友善的在對象上添加其他屬性了,如果是​

​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​

​的編譯結果如下所示:

Vue3官方出的Playground你都用了嗎?沒有沒關系,直接原理講給你聽

​App.vue​

​的編譯結果如下所示:

Vue3官方出的Playground你都用了嗎?沒有沒關系,直接原理講給你聽

​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​

​标簽的插件,不得不說,還是很巧妙的。

總結