Have you used all the Playgrounds officially released by Vue3? It doesn't matter, let me tell you the principle

Compared to Vue2the Vue3official docs there is an addition online Playground:

Open like this:

It is equivalent to allowing you to write and run Vuesingle-file components online. Of course, this thing is also open source, and it is published as a npmpackage, which is itself a Vuecomponent, so it can be easily used in your Vueproject:

<script setup>
import {
      
       Repl } from '@vue/repl'
import '@vue/repl/style.css'
</script>

<template>
  <Repl />
</template>

demoIt is still very good for writing and sharing, especially suitable as Vuean online project based on related projects demo. At present, many Vue3component libraries are used. The warehouse address: @vue/repl .

@vue/replThere are some features that make people (me) shine, such as data storage in url, and support for creating multiple files. Of course, there are also some limitations, such as only supporting Vue3and not supporting the use of CSSpreprocessing languages, but they are supported ts.

Next, I will lead you to explore its implementation principle from the beginning. What needs to be explained is that we will selectively ignore some things, such as ssrrelated ones. If you need to understand this aspect, you can read the source code yourself.

First download the project, and then find the entry file of the test page:

// 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')

First take out the file data stored in url, hashand then create an ReplStoreinstance of a class store, all file data will be saved in this global store, and then the monitored storefile data changes, and the changes will be reflected in real-time url, that is, real-time storage , and finally render the component Repland pass in store.

Let's look at classes first ReplStore.

data storage

// 默认的入口文件名称
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()
    }
}

It is mainly used to reactivecreate a responsive object as the core storage object. The stored data includes the name of the entry file mainFile, generally as the root component, all file data files, and the file object we are currently editing activeFile.

how the data is stored in the url

It can be seen that the above method is called for hashthe data extracted from the middle , which is used to decode the data, and there is a corresponding one , which is used to encode the data.serializedStateatouutoa

Everyone should have heard more or less urlthat there is a maximum length limit, so according to our general thinking, the data will definitely not be stored on the server url, but hashsome of it should not be affected, and hashthe data will not be sent to the server.

Even so, @vue/replcompression is done before storage. After all, urlit is used for sharing in many cases, and it is not convenient if it is too long.

First, let's take a look at the method mentioned at the beginning store.serialize(), which is used to serialize file data and store it urlon:

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
    }

}

Call getFilesto take out the file name and file content, and then call utoathe method after converting it into a string:

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)
}

The compression uses fflate , which claims to be the fastest, smallest and most versatile pure JavaScriptcompression and decompression library.

It can be seen that the strFromU8second parameter of the method is passed true, which means that it is converted into a binary string. This is necessary because jsthe built-in sum btoamethod atobdoes not support Unicodestrings, and our code content obviously cannot use only ASCIIcharacters 256, so directly When using btoaencoding, an error will be reported:

Details: https://base64.guru/developers/javascript/examples/unicode-strings .

After reading the compression method, let's take a look at the corresponding decompression method 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))
}

utoaIt is a little different from the last line. The last line is also compatible with the fflatesituation where no compression is used, because @vue/replit is a component after all, and the data initially passed in by the user may not use fflatecompression, but use the following method to transfer base64:

function utoa(data) {
    
    
  return btoa(unescape(encodeURIComponent(data)));
}

File classFile

The file saved to filesthe object is not plain text content, but Filea file instance created by the class:

// 文件类
export class File {
    
    
  filename: string// 文件名
  code: string// 文件内容
  compiled = {
    
    // 该文件编译后的内容
    js: '',
    css: ''
  }

  constructor(filename: string, code = '', hidden = false) {
    
    
    this.filename = filename
    this.code = code
  }
}

This class is very simple. In addition to saving the file name and file content, it mainly stores the compiled content of the file. If it is a jsfile, the compiled content is saved on compiled.jsit, cssobviously it is saved on compiled.cssit. If it is vuea single file, then scriptit templatewill be compiled . jsSave it compiled.jsto , and the style will be extracted to compiled.csssave on .

We will introduce this compilation logic in detail later.

use import-map

Using syntax directly on the browser ESMdoes not support naked import, that is, the following does not work:

import moment from "moment";

The import source needs to be legal , so the import-map proposal urlappeared . Of course, the current compatibility is not very good import-maps , but it can :polyfill

In this way, we can use bare imports in the following way:

<script type="importmap">
{
      
      
  "imports": {
      
      
    "moment": "/node_modules/moment/src/moment.js",
  }
}
</script>

<script type="importmap">
import moment from "moment";
</script>

So let's take a look at what the ReplStoremethod initImportMapdoes:

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.jsonIn fact, a file is created to save import-mapthe content.

Next, we will enter our protagonist Repl.vuecomponent. There is nothing to say about the template part. It is mainly divided into two parts: left and right. The editor on the left is used codemirror, and the preview on the right is used iframe. Let’s take a look at scriptthe main part:

// ...
props.store.options = props.sfcOptions
props.store.init()
// ...

sfcOptionsThe core is these two lines, which will be saved to the attributes storepassed in when using the component options, and will be used when compiling the file later. Of course, nothing is passed by default, just an empty object, and then the method storeis executed init, which will open the file compile.

file compilation

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])
      }
    }
  } 
}

Compile the file currently being edited, the default is App.vue, and re-trigger compilation when the file currently being edited changes. In addition, if there are multiple files initially, other files will also be traversed for compilation.

The method of performing compilation compileFileis relatively long, let's take a look at it slowly.

Compile the css file

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/replAt present, the use of preprocessing language is not supported css, so the style can only create cssa file. Obviously, cssno compilation is required, and it can be directly saved to the compilation result object.

Compile js and ts files

continue:

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

shouldTransformRefAnd transformRefthe two methods are the methods in the @vue/reactivity-transform package. What are they used for? In fact, Vue3there is an experimental proposal. We all know that we can use it refto create a responsive data of the original value, but the access Time needs to be passed .value, so this proposal is to remove this .value, the way is not to use ref, but to use $ref, for example:

// $ref都不用导出,直接使用即可
let count = $ref(0)
console.log(count)

In addition ref, several others are supported api:

So shouldTransformRefthe method is used to check whether this experimental syntax is used, and transformRefthe method is used to convert it to normal syntax:

If it is a file, it will be compiled tsusing the method:transformTS

import {
    
     transform } from 'sucrase'

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

Use sucrase to convert tsthe syntax (as a digression, one of the reasons I like to look at the source code is that I can always find some useful libraries or tools from the source code), usually we convert tseither using official tstools or using babel, but if the compiler The resulting browser compatibility can be used if less concerned sucrase, because it's super fast:

Compile Vue single file

Continue back to compileFilethe method:

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外的其他语言,是的话抛出错误并返回
    // ...
}

The package for compiling vuea single file is @vue/compiler-sfc . From 3.2.13the version onwards, this package will be built into vuethe package vue. You can use this package directly after installation. This package will vuebe upgraded with the upgrade, so @vue/replit is not written to death, but Can be configured manually:

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

By default, the current warehouse is used compiler-sfc, but the sum store.setVueVersionof the specified version can be set by calling the method .vuecompiler

Suppose our App.vuecontent is as follows:

<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.parseThe method will parse it into the following result:

In fact, it is to analyze the contents of the script, template, and stylethree parts.

Continue back to compileFilethe method:

export async function compileFile(){
    
    
    // ...
    // 是否有style块使用了scoped作用域
    const hasScoped = descriptor.styles.some((s) => s.scoped)
	// 保存编译结果
    let clientCode = ''
    const appendSharedCode = (code: string) => {
    
    
        clientCode += code
    }
    // ...
}

clientCodeUsed to save the final compilation result.

compile script

Continue back to compileFilethe method:

export async function compileFile(){
    
    
    // ...
    const clientScriptResult = await doCompileScript(
        store,
        descriptor,
        id,
        isTS
    )
    // ...
}

Invoke doCompileScriptthe compiled part of the method script, in fact, templatethe part will also be compiled together, unless you do not use <script setup>syntax or manually configure it, do not do this:

h(Repl, {
    
    
    sfcOptions: {
    
    
        script: {
    
    
            inlineTemplate: false
        }
    }
})

Let's ignore this situation and look at doCompileScriptthe implementation of the method:

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]
  }
}

This function mainly does three things, let's look at them one by one.

1. Compile the script

Call compileScriptmethod compilation script, this method will handle <script setup>syntax, cssvariable injection and other features, cssvariable injection refers to the use of bound component data in stylethe label , learn more about [v-bind in CSS] ( single file component CSS function| Vue.js ).v-binddata

If the syntax is used <script setup>and inlineTemplatethe option is passed true, then the part will be templatecompiled into a rendering function and inlined into setupthe function at the same time, otherwise templateit needs to be compiled separately.

idParameters are used as scoped id, when styleused in the block scoped, or using v-bindsyntax, you need to use this idto create a unique classclass name, style name.

The compilation result is as follows:

It can be seen that the template part is compiled into a rendering function and inlined into setupthe function of the component, and export defaultthe default export component is used.

2. Convert the default export

This step will convert the default export statement obtained earlier into the form of variable definition, using a rewriteDefaultmethod, this method receives three parameters: the content to be converted, variable name, plug-in array, this plug-in array is passed to babeluse, so If used ts, it will be passed in ['typescript'].

The conversion result is as follows:

What are the benefits of turning it into a variable? In fact, it is convenient to add other attributes to the object. If it is in the export default {}form, what should I do if I want to extend some attributes on this object? regular match? Turn into asta tree? It seems to be possible, but it is not very convenient, because the source content has to be manipulated, but it is very simple to convert it into a variable. As long as you know the defined variable name, you can directly splice the following code:

__sfc__.xxx = xxx

You don't need to know what the source content is, just add any attributes you want to add directly.

3. Compile ts

The last step will determine whether it is used ts, and if so, use the method mentioned above transformTSto compile.

scriptReturn to the method after compiling 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的编译结果保存起来
  }
  // ...
}

Here export defaultcomes the benefit of converting to variable definitions, making it easy to add new attributes.

Finally, use export defaultthe variable defined by export.

compile template

As mentioned several times before, if <script setup>the syntax is used and inlineTemplatethe option is not set false, then there is no need to compile it manually template. If you want to compile it yourself, it is very simple, just call compiler.compileTemplatethe method to compile it. In fact, it is just compiler.compileScriptinside the method that helped us adjust this It's just a method. This method will templatecompile it into a rendering function. We also splice this rendering function string to it , and add a property to clientCodethe component option object, which is the object compiled in the previous step, scriptand the value is this rendering function:__sfc__render

let code =
    `\n${
      
      templateResult.code.replace(
      /\nexport (function|const) render/,
      `$1 render`
    )}` + `\n${
    
    COMP_IDENTIFIER}.render = render

compile style

Going back to the method, the sum of the single file has been compiled compileFilehere , and the next part will be processed:vuescripttemplatestyle

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 */'
  }
}

It's simple, use compileStyleAsyncthe method compile styleblock, this method will help us deal with scoped, moduleand v-bindsyntax.

At this point, the file compilation part is introduced. To summarize:

  • cssStyle files do not need to be compiled because they can only be used natively
  • jsThe file does not need to be compiled originally, but it may use experimental $refsyntax, so it needs to be judged and processed. If it is used, tsit needs to be compiled
  • vueSingle files will be @vue/compiler-sfccompiled, scriptand some will handle setupsyntax, cssvariable injection and other features. If they are used, they tswill also be compiled ts. The final result is actually a component object. templateWhether they are scriptcompiled together or compiled separately, they will be compiled into rendering functions and mounted to On the component object, styleit can be saved directly after partial compilation

preview

After the files are compiled, can they be previewed directly? Unfortunately, it can't, why, because the previous file is compiled to get a normal ESMmodule, that is, import and export through import and , for example , we also created a file, and then introduced inexportApp.vueComp.vueApp.vue

// App.vue
import Comp from './Comp.vue'

./Comp.vueAt first glance, there seems to be no problem, but the problem is that there is no file on the server. This file is only simulated by us on the front end, so if we directly let the browser issue this module request, it will definitely fail, and the files created by our simulation will eventually be It is inserted into the page through tags one by one <script type="module">, so it needs to convert importand exportinto other forms.

create iframe

The preview section will first create a 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(' ')
    )
    // ...
}

Create an iframeelement and set sandboxthe attribute, which can control iframewhether some behaviors of the page in the frame are allowed, details 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.htmlIt is the page used for preview, the content will be injected first , and then the page will be rendered import-mapby creating it.iframe

let proxy: PreviewProxy
function createSandbox() {
    
    
    // ...
    proxy = new PreviewProxy(sandbox, {
    
    
        on_error:() => {
    
    }
        // ...
    })
    sandbox.addEventListener('load', () => {
    
    
        stopUpdateWatcher = watchEffect(updatePreview)
    })
}

PreviewProxyNext, an instance of the class is created , and finally iframea side-effect function is registered when the loading is complete updatePreview. In this method, the file will be processed and the preview operation will be performed.

communicate with iframe

PreviewProxyThe class is mainly used to iframecommunicate with:

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)
  }
}

messageEvents can listen to iframeinformation from, and iframesend information to through postMessagemethods:

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 }, '*')
    })
  }
}

Through this method, you can iframesend a message to, and return one promise. Before sending the message, a unique one will be generated , idand then the sum promisewill be saved, and this will be sent to . When the task is completed, it will reply to the parent window and send back this , then the parent window can decide which one to call based on the success or failure of the task execution through this retrieval .resolverejectididiframeiframeididresovereject

iframeMethods for sending information to the parent:

// 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判断执行什么任务
  // ...
}

Compile the module for preview

Next, let's look at updatePreviewthe method. In this method, the file will be compiled again to get the module list, which is actually jsthe code, and then the module list will be sent to iframe, and the tags iframewill be dynamically created scriptand inserted into these module codes to achieve iframethe effect of updating the page for preview.

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

codeToEvalThe array reveals the principle of the preview. codeToEvalThe content of the array will be sent to iframethe page at last, and then dynamically create scripttags and insert them into the page for operation.

First we add another file Comp.vue:

<script setup>
import {
      
       ref } from 'vue'
const msg = ref('我是子组件')
</script>

<template>
  <h1>{
   
   { msg }}</h1>
</template>

Then App.vueimport in the component:

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

At this point, after processing in the previous section [File Compilation], Comp.vuethe compilation result is as follows:

App.vueThe compilation result is as follows:

compileModulesForPreviewEach file will be compiled again, mainly to do the following things:

1. Convert the export statement of the module exportinto an attribute addition statement, that is, add the module to window.__modules__the object:

const __sfc__ = {
    
    
  __name: 'Comp',
  // ...
}

export default __sfc__

converts to:

const __module__ = __modules__["Comp.vue"] = {
    
     [Symbol.toStringTag]: "Module" }

__module__.default = __sfc__

2. Convert the statement of the importmodule with the relative path into an assignment statement, so that the specified module can be obtained from the object:./__modules__

import Comp from './Comp.vue'

converts to:

const __import_1__ = __modules__["Comp.vue"]

3. Finally, convert the place where the imported component is used:

_createVNode(Comp)

converts to:

_createVNode(__import_1__.default)

4. If the component has a style, append it to window.__css__the string:

if (file.compiled.css) {
    
    
    js += `\nwindow.__css__ += ${
      
      JSON.stringify(file.compiled.css)}`
}

At this time codeToEval, it is very clear to look at the content of the array. First, create a global object window.__modules__and a global string . If an instance window.__css__already exists before __app__, it means that it is an update. Then uninstall the previous component first, and then create an element idfor It is used to mount components, and then add the module array returned by method compilation, so that the global variables of these components are defined when they are running, and the components may add styles to them, so after all components are running , add the styles to page.appdivVuecompileModulesForPreviewwindow.__css__window.__css__

Finally, if the entry file is a component, another section of instantiation and mounting code Vuewill be added .Vue

compileModulesForPreviewThe method is relatively long. What to do is roughly start from the entry file, convert the file according to the previous 4 points, and then recursively convert all dependent components. The specific conversion method is to convert the module into a tree, and then use magic-string babelto ASTmodify it . Source code, this kind of code is very simple for those who know it, but it is difficult for those who have not been in touch with ASTtree operations, so the specific code will not be posted. If you are interested in viewing the specific implementation, you can click moduleCompiler.ts .

codeToEvalWhen the array content is ready, you can iframesend a message to the preview:

await proxy.eval(codeToEval)

iframeAfter receiving the message, the previously added scriptlabel will be deleted first, and then a new label will be created:

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

In order to add modules one by one in order, one will be created promise, and resovethe method will be assigned to a global attribute __next__, and then the calling code will be spliced ​​at the end of each module, so that when a scripttag is inserted, the code of the tag will run Execute window.__next__the method, then it will end the current one promiseand enter the next scripttab of the plug-in, I have to say, it is still very clever.

Summarize

This article looks at @vue/replthe implementation of components from the perspective of source code. In fact, a lot of content is ignored, such as ssrrelated, used htmlas entry files, information output, etc. If you are interested, you can read the source code yourself.

Because this component does not support running Vue2, one of my colleagues forkmodified and created a Vue2new version. If you need it, you can pay attention to vue2-repl .

Finally, I also recommend my open source project, which is also online , and Playgroundsupports single files, but it is more general, but does not support the creation of multiple files. If you are interested, you can pay attention to code-run .Vue2Vue3

Guess you like

Origin blog.csdn.net/sinat_33488770/article/details/127523876