Vue3 によって公式にリリースされたすべてのプレイグラウンドを使用しましたか? 関係ありません、原則を教えましょう

Vue2公式ドキュメントと比較して、Vue3オンラインに追加がありますPlayground

次のように開きます。

これは、単一ファイルのコンポーネントをオンラインで記述して実行できるようにすることと同じですVue. もちろん、これもオープンソースであり、npmそれ自体がコンポーネントであるパッケージとして公開されているため、プロジェクトVueで簡単に使用できます:Vue

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

<template>
  <Repl />
</template>

特に、関連プロジェクトに基づくオンライン プロジェクトとして適しdemoています. 現在、多くのコンポーネント ライブラリが使用されています. ウェアハウス アドレス: @vue/repl .VuedemoVue3

@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/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メソッドの 2 番目のパラメーターが渡されていることがわかりますtrue, これは、バイナリ文字列に変換されることを意味します. これは、js組み込みの sumbtoaメソッドが文字列atobをサポートしていないため必要でありUnicode、コードの内容は明らかにASCII文字のみを使用できないためです256,エンコーディングを使用すると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)));
}

ファイルクラスファイル

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.jscsscompiled.cssvuescripttemplatejscompiled.jscompiled.css

このコンパイル ロジックについては、後で詳しく説明します。

インポートマップを使用

ブラウザーで構文を直接使用しても、ESMネイキッド インポートはサポートされません。つまり、次の操作は機能しません。

import moment from "moment";

インポート元が合法である必要があるため、 import-mapの提案urlが表示されました. もちろん、現在の互換性はあまり良くありません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. テンプレート部分については何も言うことはありません. 大きく分けて左右の2つに分かれています. 左側のエディタを使用し, 右側のプレビューを使用しcodemirrorますiframe.script主要部分を見てください:

// ...
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. もちろん、デフォルトでは何も渡されず、空のオブジェクトだけが渡されますstoreinitファイル compile を開きます。

ファイルのコンパイル

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そして、transformRef2 つのメソッドは@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(余談ですが、私がソース コードを見るのが好きな理由の 1 つは、ソース コードから便利なライブラリやツールをいつでも見つけることができるからです)。通常は、公式ツールまたは を使用しtsますtsbabel、コンパイラの場合、結果として得られるブラウザの互換性は、あまり気にしない場合に使用できます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に組み込まれます. このパッケージはインストール後すぐに使用できます. このパッケージはアップグレードに伴いアップグレードされます.死に至るまで書かれていませんが、手動で構成できます:vuevuevue@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、メソッドを呼び出すことで設定できます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メソッドはそれを次の結果に解析します。

script実際には、 、templateの 3 つの部分の内容を分析することです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最終的なコンパイル結果を保存するために使用されます。

コンパイルスクリプト

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

この関数は主に 3 つのことを行います。1 つずつ見ていきましょう。

1.スクリプトをコンパイルする

compileScriptメソッドのコンパイルを呼び出します。このメソッドは構文、変数インジェクションおよびその他の機能scriptを処理します。変数インジェクションとは、ラベル内のバインドされたコンポーネントデータの使用を指します。[CSS の v-bind] (単一ファイル コンポーネント CSS 関数| Vue.js)。<script setup>csscssstylev-binddata

構文が使用され<script setup>inlineTemplateオプションが渡された場合true、パーツはtemplateレンダリング関数にコンパイルされ、setup同時に関数にインライン化されます。それ以外の場合は、template個別にコンパイルする必要があります。

idパラメータは として使用されscoped idstyleブロックで使用される場合scoped、または構文を使用する場合は、これを使用して一意のクラス名、スタイル名を作成するv-bind必要がありますidclass

コンパイル結果は次のとおりです。

setupテンプレート部分がレンダリング関数にコンパイルされ、コンポーネントの関数にインライン化され、export defaultデフォルトのエクスポート コンポーネントが使用されていることがわかります。

2. デフォルトのエクスポートを変換する

この手順では、メソッドを使用して、以前に取得したデフォルトのエクスポート ステートメントを変数定義の形式に変換しますrewriteDefault。このメソッドは、変換するコンテンツ、変数名、プラグイン配列、このプラグイン配列を渡して使用しますbabel。ですのでts、使用すると渡されます['typescript']

変換結果は次のとおりです。

それを変数に変える利点は何ですか? 実際には、オブジェクトに他の属性を追加すると便利です. フォームの場合、export default {}このオブジェクトのいくつかの属性を拡張したい場合はどうすればよいですか? レギュラーマッチ?astになる?可能のようですが、ソースの内容を操作する必要があるため、あまり便利ではありませんが、変数に変換するのは非常に簡単です. 定義された変数名を知っている限り、次のように直接接続できます.コード:

__sfc__.xxx = xxx

ソース コンテンツが何であるかを知る必要はありません。追加したい属性を直接追加するだけです。

3.コンパイルする

最後のステップで、それが使用されているかどうかが判断されます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エクスポートで定義された変数を使用します。

テンプレートをコンパイルする

前に何度か述べたように、<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

コンパイルスタイル

メソッドに戻ると、単一ファイルの合計がcompileFileここにコンパイルされ、次の部分が処理されます。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 */'
  }
}

簡単です。compileStyleAsyncメソッド compileブロックを使用します。このメソッドは、構文を処理するstyleに役立ちますscopedmodulev-bind

この時点で、ファイルのコンパイル部分が導入されます。

  • スタイル ファイルはネイティブでしか使用できないため、cssコンパイルする必要はありません。
  • jsファイルは独自にコンパイルする必要はありませんが、実験的な$ref構文を使用している可能性があるため、判断して処理する必要がありますts
  • vue単一のファイルが@vue/compiler-sfcコンパイルされます,いくつかは構文,変数注入およびその他の機能をscript処理します. それらが使用される場合, それらもコンパイルされます. 最終結果は実際にはコンポーネントオブジェクトです.それらが一緒にコンパイルされても別々にコンパイルされても, それらはレンダリング関数にコンパイルされ、コンポーネント オブジェクトにマウントされ、部分的なコンパイル後に直接保存できますsetupcsstststemplatescriptstyle

プレビュー

ファイルをコンパイルした後、直接プレビューできますか? 残念ながら、前のファイルは通常のESMモジュールを取得するためにコンパイルされているため、たとえばimport とを介してexportインポートおよびエクスポートされApp.vueいるためComp.vue、それはできません。App.vue

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

./Comp.vue一見問題ないように見えますが、問題はサーバー上にファイルがないことです.このファイルは当方がフロントエンドでシミュレートしただけなので、ブラウザがこのモジュールリクエストを直接送信すると、間違いなく失敗します. 、そして私たちのシミュレーションによって作成されたファイルは最終的に タグを介して 1 つずつページに挿入さ、他の形式に変換する<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設定します。詳細はart-sand-boxです。sandboxiframe

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れ、に送信されます.タスクが完了すると親ウィンドウに応答してこれを送り返すと、親ウィンドウは、この取得によるタスク実行の成功または失敗に基づいて、どちらを呼び出すかを決定できます。resolverejectididiframeiframeididresovereject

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 ステートメントを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__, それは更新であることを意味します. 次に, まず以前のコンポーネントをアンインストールします.次に、It is used to idmount components の要素を作成し、メソッドのコンパイルによって返されたモジュール配列を追加して、これらのコンポーネントのグローバル変数が実行時に定義され、コンポーネントがそれらにスタイルを追加できるすべてのコンポーネントが実行されている場合、スタイルをページに追加します。appdivVuecompileModulesForPreviewwindow.__css__window.__css__

最後に、エントリ ファイルがコンポーネントの場合、インスタンス化とマウント コードの別のセクションVueが追加されます。Vue

compileModulesForPreview方法は比較的長い. 何をするかは, ざっとエントリファイルから始めて, 前の 4 つのポイントに従ってファイルを変換し, 次に依存するすべてのコンポーネントを再帰的に変換する. 具体的な変換方法は, モジュールをツリーに変換し, 次にbabelソースコード、この種のコードはAST知っている人には非常に簡単ですが、ツリー操作に触れことのない人には難しいので、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
            }
        }
        // ...
    }
}

モジュールを1つずつ順番に追加するために、1つ作成されpromiseresoveメソッドがグローバル属性に割り当てられ__next__、呼び出しコードが各モジュールの最後に接合されるため、タグscriptが挿入すると、タグのコードが実行されますwindow.__next__メソッドを実行すると、現在のメソッドが終了し、プラグインのpromise次のタブに入ります。それでも非常に巧妙です。script

要約する

この記事では、コンポーネントの実装をソース コードの観点から見ていきます. 実際には、関連するもの@vue/repl、エントリ ファイルとして使用されるもの、情報出力として使用されるものなど、多くのコンテンツが無視されています. 興味がある場合は、ソース コードを読むことができます.あなた自身。ssrhtml

このコンポーネントは実行をサポートしていないためVue2、同僚の 1 人がfork変更してVue2新しいバージョンを作成しました. 必要な場合は、vue2-replに注意してください.

最後に、私のオープン ソース プロジェクトもお勧めします。こちらもオンラインで、単一ファイルPlaygroundをサポートしていますVue2が、より一般的ですが、複数ファイルの作成はサポートしていません。興味がある場合は、 code-runVue3に注意してください

おすすめ

転載: blog.csdn.net/sinat_33488770/article/details/127523876