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관련 프로젝트를 기반으로 한 온라인 프로젝트 에 적합합니다 demo. 현재 많은 Vue3구성 요소 라이브러리가 사용됩니다. 창고 주소: @vue/repl .

@vue/repl의 데이터 저장, 다중 파일 생성 지원 등 사람(나)을 빛나게 하는 기능도 있고 , 전처리 언어 사용 url만 지원하고 지원하지 않는 등의 제약도 있지만 , . _Vue3CSSts

다음으로 처음부터 구현 원리를 탐색하도록 안내하겠습니다.설명해야 할 것은 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사항 이 실시간으로 반영됩니다 . 실시간 저장소, 마지막으로 구성 요소를 렌더링 하고 전달합니다 .hashReplStorestorestorestoreurlReplstore

먼저 클래스를 살펴보겠습니다 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기본 제공 sum btoa메서드가 문자열을 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)));
}

파일 클래스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 되며 스타일이 추출되어 에 저장됩니다.jscompiled.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.템플릿 부분은 말할 것도 없습니다.주로 왼쪽과 오른쪽의 두 부분으로 나뉩니다.왼쪽의 편집기가 사용되고 codemirror오른쪽의 iframe미리보기가 사용됩니다. script주요 부분 살펴보기 :

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

핵심은 이 두줄인데 component 사용시 넘겨주는 속성 sfcOptions으로 저장 되어 추후에 파일 컴파일시 사용하게 됩니다 . 을 실행하면 파일이 열립니다.storeoptionsstoreinit

파일 편집

class ReplStore {
    
    
  init() {
    
    
    watchEffect(() => compileFile(this, this.state.activeFile))
    for (const file in this.state.files) {
    
    
      if (file !== defaultMainFile) {
    
    
        compileFile(this, this.state.files[file])
      }
    }
  } 
}

현재 편집 중인 파일을 컴파일합니다. 기본값은 이며 App.vue현재 편집 중인 파일이 변경되면 컴파일을 다시 트리거합니다. 또한 초기에 여러 파일이 있는 경우 다른 파일도 컴파일을 위해 트래버스됩니다.

컴파일 하는 방법 compileFile이 비교적 길기 때문에 천천히 살펴보도록 하겠습니다.

CSS 파일 컴파일

export async function compileFile(
store: Store,
 {
    
     filename, code, compiled }: File
) {
    
    
    // 文件内容为空则返回
    if (!code.trim()) {
    
    
        store.state.errors = []
        return
    }
    // css文件不用编译,直接把文件内容存储到compiled.css属性
    if (filename.endsWith('.css')) {
    
    
        compiled.css = code
        store.state.errors = []
        return
    }
    // ...
}

@vue/repl현재 전처리 언어의 사용이 지원되지 않기 css때문에 스타일은 css파일을 만드는 것만 가능하며 당연히 css컴파일이 필요하지 않으며 컴파일 결과 개체에 직접 저장할 수 있습니다.

js 및 ts 파일 컴파일

계속하다:

export async function compileFile(){
    
    
    // ...
    if (filename.endsWith('.js') || filename.endsWith('.ts')) {
    
    
        if (shouldTransformRef(code)) {
    
    
            code = transformRef(code, {
    
     filename }).code
        }
        if (filename.endsWith('.ts')) {
    
    
            code = await transformTS(code)
        }
        compiled.js = code
        store.state.errors = []
        return
    }
    // ...
}

shouldTransformRef그리고 transformRef두 가지 방법은 @vue/reactivity-transform 패키지에 있는 방법들입니다.그들은 무엇을 위해 사용됩니까?사실, Vue3실험적인 제안이 있습니다.우리 모두는 그것을 사용하여 ref원래 값의 반응형 데이터를 생성할 수 있다는 것을 알고 있습니다. 그러나 액세스 시간이 경과해야 하므로 .value이 제안은 이것을 제거하는 것입니다 .value. 방법은 사용하는 것이 아니라 ref사용하는 것 입니다 $ref. 예를 들면 다음과 같습니다.

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

또한 다음과 같은 ref몇 가지 다른 기능이 지원됩니다 api.

따라서 shouldTransformRef이 실험적 구문이 사용되는지 여부를 확인하는 방법을 사용하고 transformRef이를 일반 구문으로 변환하는 방법을 사용합니다.

파일 인 경우 다음 방법을 ts사용하여 transformTS컴파일됩니다 .

import {
    
     transform } from 'sucrase'

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

sucrase를 사용 하여 ts구문을 변환 합니다 . 컴파일러가 초고속 이기 때문에 걱정이 덜 된다면 결과 브라우저 호환성을 사용할 수 있습니다 .tstsbabelsucrase

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메서드는 이를 다음 결과로 구문 분석합니다.

사실 , , 세 부분의 내용을 분석하는 scripttemplate입니다 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]
  }
}

이 함수는 주로 세 가지 일을 하는데, 하나씩 살펴보겠습니다.

1. 스크립트 컴파일

compileScript메서드 컴파일을 호출합니다 . 이 메서드는 구문, 변수 주입 및 기타 기능을 script처리합니다 . 변수 주입은 레이블 에서 바인딩된 구성 요소 데이터의 사용 을 나타냅니다 . [CSS의 v-bind]( 단일 파일 구성 요소 CSS 함수| Vue.js) 에 대해 자세히 알아보세요. ).<script setup>csscssstylev-binddata

구문을 사용 <script setup>하고 inlineTemplate옵션을 전달하면 true부품이 template렌더링 함수로 컴파일되고 setup동시에 함수에 인라인됩니다. 그렇지 않으면 template별도로 컴파일해야 합니다.

id매개변수는 블록에서 사용하거나 구문을 사용할 scoped id로 사용되며 이를 사용하여 고유한 클래스 이름, 스타일 이름을 생성 해야 합니다 .stylescopedv-bindidclass

컴파일 결과는 다음과 같습니다.

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내보내기로 정의된 변수를 사용합니다.

컴파일 템플릿

앞에서도 여러 번 언급했듯이 <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컴파일 블록 메서드를 사용하면 이 메서드가 , 및 구문을 style처리하는 데 도움이 됩니다 .scopedmodulev-bind

이 시점에서 파일 컴파일 부분이 소개됩니다. 요약하자면 다음과 같습니다.

  • 스타일 파일은 기본적으로만 사용할 수 있으므로 css컴파일할 필요가 없습니다.
  • js파일은 원래 컴파일할 필요는 없으나 실험적인 $ref구문을 사용할 수 있으므로 판단 및 처리가 필요합니다.사용할 경우 ts컴파일해야 합니다.
  • vue@vue/compiler-sfc단일 파일이 컴파일 되고 일부는 구문, 변수 주입 및 기타 기능을 script처리합니다 .이를 사용하면 컴파일도됩니다 .최종 결과는 실제로 구성 요소 개체입니다. 함께 컴파일 하든 별도로 컴파일하든 상관없이 렌더링 함수로 컴파일되어 컴포넌트 객체에 마운트, 부분 컴파일 후 바로 저장 가능setupcsstststemplatescriptstyle

시사

파일이 컴파일된 후 직접 미리 볼 수 있습니까? 안타깝게도 이전 파일은 일반 모듈을 얻기 위해 컴파일되기 때문에 , ESM가져오기 및 내보내기를 import 통해 예를 들어 파일을 만든 다음exportApp.vueComp.vueApp.vue

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

./Comp.vue언뜻 보기에는 문제가 없어 보이지만 문제는 서버에 파일이 없다는 것입니다. , 그리고 우리의 시뮬레이션에 의해 생성된 파일은 결국 태그를 통해 페이지에 하나씩 삽입 되므로 다른 형식으로 변환 <script type="module">해야 합니다 .importexport

아이프레임 만들기

미리보기 섹션은 먼저 다음을 생성합니다 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설정합니다 . 자세한 내용은 arrt-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메시지를 보내고 하나 promiseid반환 있습니다 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특성 추가 문으로 변환합니다. 즉, 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사용 만든 다음 메서드 컴파일에 의해 반환된 모듈 배열을 추가하여 이러한 구성 요소의 전역 변수가 실행 중일 때 정의되고 구성 요소가 스타일을 추가 모든 구성 요소가 실행 중이 면 페이지에 스타일을 추가하십시오.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
            }
        }
        // ...
    }
}

모듈을 하나씩 순서대로 추가하기 위해 하나를 생성 promise하고 resove메서드를 전역 속성에 할당한 __next__다음 각 모듈의 끝에서 호출 코드를 연결하여 태그 script가 삽입하면 태그의 코드가 메서드를 실행하고 window.__next__현재 메서드를 종료 하고 플러그인의 promise다음 탭으로 들어갑니다 . 여전히 매우 영리합니다.script

요약하다

이 글은 컴포넌트의 구현을 소스코드의 관점에서 살펴본다 실제로 관련 내용, 엔트리 파일로 사용 , 정보 출력 등 @vue/repl많은 내용이 무시된다 관심 있는 분들은 소스코드를 읽어보면 된다 당신 자신.ssrhtml

이 구성 요소는 running 을 지원하지 않기 때문에 Vue2동료 중 한 명이 fork수정하여 Vue2새 버전을 만들었습니다.필요한 경우 vue2-repl 에주의하십시오 .

마지막으로 제 오픈소스 프로젝트도 추천하는데 역시 온라인이고 단일 파일을 Playground지원 하지만 좀 더 일반적이지만 다중 파일 생성을 지원하지 않습니다 .Vue2Vue3

Supongo que te gusta

Origin blog.csdn.net/sinat_33488770/article/details/127523876
Recomendado
Clasificación