乐趣区

关于前端:Vue3官方出的Playground你都用了吗没有没关系直接原理讲给你听

相比 Vue2Vue3 的官网文档中新增了一个在线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')

首先取出存储在 urlhash中的文件数据,而后创立了一个 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/[email protected]${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 内置的 btoaatob办法不反对 Unicode 字符串,而咱们的代码内容显然不可能只应用 ASCII256个字符,那么间接应用 btoa 编码就会报错:

详情:https://base64.guru/developers/javascript/examples/unicode-strings。

看完了压缩办法再来看一下对应的解压办法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 单文件,那么 scripttemplate会编译成 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>

那么咱们看一下 ReplStoreinitImportMap办法都做了 什么:

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 保留到 storeoptions属性上,后续编译文件时会应用,当然默认啥也没传,一个空对象而已,而后执行了 storeinit办法,这个办法就会开启文件编译。

文件编译

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
    }
    // ...
}

shouldTransformReftransformRef 两个办法是 @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/[email protected]${version}/dist/compiler-sfc.esm-browser.js`
        const runtimeUrl = `https://unpkg.com/@vue/[email protected]${version}/dist/runtime-dom.esm-browser.js`
        this.pendingCompiler = import(/* @vite-ignore */ compilerUrl)
        this.compiler = await this.pendingCompiler
        // ...
    }
}

默认应用以后仓库的 compiler-sfc,然而能够通过调用store.setVueVersion 办法来设置指定版本的 vuecompiler

假如咱们的 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办法会将其解析成如下后果:

其实就是解析出了其中的 scripttemplatestyle 三个局部的内容。

持续回到 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)。

如果应用了 <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 单文件的 scripttemplate局部就曾经编译完了,接下来会解决 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 块,这个办法会帮咱们解决 scopedmodule 以及 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"> 标签插入到页面,所以须要把 importexport转换成其余模式。

创立 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,而后把promiseresolvereject 通过 id 保存起来,并且这个 id 会发送给 iframe,当iframe 工作执行完了会向父窗口回复信息,并且会发回这个 id,那么父窗口就能够通过这个id 取出 resovereject依据函数依据工作执行的胜利与否决定调用哪个。

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 代码,而后将模块列表发送给 iframeiframe 会动态创建 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__ 实例,阐明是更新状况,那么先卸载之前的组件,而后在页面中创立一个 idappdiv 元素用于挂载 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 标签的插件,不得不说,还是很奇妙的。

总结

本文从源码角度来看了一下 @vue/repl 组件的实现,其实疏忽了挺多内容,比方 ssr 相干的、应用 html 作为入口文件、信息输入等,有趣味的能够自行浏览源码。

因为该组件不反对运行 Vue2,所以我的一个共事fork 批改创立了一个 Vue2 的版本,有需要的能够关注一下 vue2-repl。

最初也举荐一下我的开源我的项目,也是一个在线 Playground,也反对Vue2Vue3单文件,不过更通用一些,然而不反对创立多个文件,有趣味的能够关注一下 code-run。

退出移动版