返回

Vue3官方出的Playground你都用了吗?没有没关系,直接原理讲给你听

发布时间:2023-07-24 18:06:43 404
# typescript# java# 数据# 信息# 工具

相比Vue2Vue3的官方文档中新增了一个在线Playground

打开是这样的:

相当于让你可以在线编写和运行Vue单文件组件,当然这个东西也是开源的,并且发布为了一个npm包,本身是作为一个Vue组件,所以可以轻松在你的Vue项目中使用:

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

<template>
  <Repl />
</template>

用于demo编写和分享还是很不错的,尤其适合作为基于Vue相关项目的在线demo,目前很多Vue3的组件库都用了,仓库地址:@vue/repl。

@vue/repl有一些让人(我)眼前一亮的特性,比如数据存储在url中,支持创建多个文件,当然也存在一些限制,比如只支持Vue3,不支持使用CSS预处理语言,不过支持使用ts

接下来会带领各位从头探索一下它的实现原理,需要说明的是我们会选择性的忽略一些东西,比如ssr相关的,有需要了解这方面的可以自行阅读源码。

首先下载该项目,然后找到测试页面的入口文件:

// test/main.ts
const App = {
  setup() {
    // 创建数据存储的store
    const store = new ReplStore({
      serializedState: location.hash.slice(1)
    })
	// 数据存储
    watchEffect(() => history.replaceState({}, '', store.serialize()))
	// 渲染Playground组件
    return () =>
      h(Repl, {
        store,
        sfcOptions: {
          script: {}
        }
      })
  }
}

createApp(App).mount('#app')

首先取出存储在urlhash中的文件数据,然后创建了一个ReplStore类的实例store,所有的文件数据都会保存在这个全局的store里,接下来监听store的文件数据变化,变化了会实时反映在url中,即进行实时存储,最后渲染组件Repl并传入store

先来看看ReplStore类。

数据存储

// 默认的入口文件名称
const defaultMainFile = 'App.vue'
// 默认文件的内容
const welcomeCode = `
<script setup>
import { ref } from 'vue'

const msg = ref('Hello World!')
</script>

<template>
  {{ msg }}
  <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内置的btoaatob方法不支持Unicode字符串,而我们的代码内容显然不可能只使用ASCII256个字符,那么直接使用btoa编码就会报错:

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

看完了压缩方法再来看一下对应的解压方法atou

import { unzlibSync, strToU8, strFromU8 } from 'fflate'

export function atou(base64: string): string {
    // 将base64转成二进制字符串
    const binary = atob(base64)
    // 检查是否是zlib压缩的数据,zlib header (x78), level 9 (xDA)
    if (binary.startsWith('\x78\xDA')) {
        // 将字符串转成Uint8Array
        const buffer = strToU8(binary, true)
        // 解压缩
        const unzipped = unzlibSync(buffer)
        // 将Uint8Array重新转换成字符串
        return strFromU8(unzipped)
    }
    // 兼容没有使用压缩的数据
    return decodeURIComponent(escape(binary))
}

utoa稍微有点不一样,最后一行还兼容了没有使用fflate压缩的情况,因为@vue/repl毕竟是个组件,用户初始传入的数据可能没有使用fflate压缩,而是使用下面这种方式转base64的:

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

文件类File

保存到files对象上的文件不是纯文本内容,而是通过File类创建的文件实例:

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

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

这个类很简单,除了保存文件名和文件内容外,主要是存储文件被编译后的内容,如果是js文件,编译后的内容保存在compiled.js上,css显然就是保存在compiled.css上,如果是vue单文件,那么scripttemplate会编译成js保存到compiled.js上,样式则会提取到compiled.css上保存。

这个编译逻辑我们后面会详细介绍。

使用import-map

在浏览器上直接使用ESM语法是不支持裸导入的,也就是下面这样不行:

import moment from "moment";

导入来源需要是一个合法的url,那么就出现了import-map这个提案,当然目前兼容性还不太好import-maps,不过可以polyfill:

这样我们就可以通过下面这种方式来使用裸导入了:




那么我们看一下ReplStoreinitImportMap方法都做了 什么:

private initImportMap() {
    const map = this.state.files['import-map.json']
    if (!map) {
        // 如果还不存在import-map.json文件,就创建一个,里面主要是Vue库的map
        this.state.files['import-map.json'] = new File(
            'import-map.json',
            JSON.stringify(
                {
                    imports: {
                        vue: this.defaultVueRuntimeURL
                    }
                },
                null,
                2
            )
        )
    } else {
        try {
            const json = JSON.parse(map.code)
            // 如果vue不存在,那么添加一个
            if (!json.imports.vue) {
                json.imports.vue = this.defaultVueRuntimeURL
                map.code = JSON.stringify(json, null, 2)
            }
        } catch (e) {}
    }
}

其实就是创建了一个import-map.json文件用来保存import-map的内容。

接下来就进入到我们的主角Repl.vue组件了,模板部分其实没啥好说的,主要分为左右两部分,左侧编辑器使用的是codemirror,右侧预览使用的是iframe,主要看一下script部分:

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

核心就是这两行,将使用组件时传入的sfcOptions保存到storeoptions属性上,后续编译文件时会使用,当然默认啥也没传,一个空对象而已,然后执行了storeinit方法,这个方法就会开启文件编译。

文件编译

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

编译当前正在编辑的文件,默认为App.vue,并且当当前正在编辑的文件发生变化之后会重新触发编译。另外如果初始存在多个文件,也会遍历其他的文件进行编译。

执行编译的compileFile方法比较长,我们慢慢来看。

编译css文件

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

@vue/repl目前不支持使用css预处理语言,所以样式的话只能创建css文件,很明显css不需要编译,直接保存到编译结果对象上即可。

编译js、ts文件

继续:

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

shouldTransformReftransformRef两个方法是@vue/reactivity-transform包中的方法,用来干啥的呢,其实Vue3中有个实验性质的提案,我们都知道可以使用ref来创建一个原始值的响应性数据,但是访问的时候需要通过.value才行,那么这个提案就是去掉这个.value,方式是不使用ref,而是使用$ref,比如:

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

除了ref,还支持其他几个api

所以shouldTransformRef方法就是用来检查是否使用了这个实验性质的语法,transformRef方法就是用来将其转换成普通语法:

如果是ts文件则会使用transformTS方法进行编译:

import { transform } from 'sucrase'

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

使用sucrase转换ts语法(说句题外话,我喜欢看源码的一个原因之一就是总能从源码中发现一些有用的库或者工具),通常我们转换ts要么使用官方的ts工具,要么使用babel,但是如果对编译结果的浏览器兼容性不太关心的话可以使用sucrase,因为它超级快:

编译Vue单文件

继续回到compileFile方法:

import hashId from 'hash-sum'

export async function compileFile(){
    // ...
    // 如果不是vue文件,那么就到此为止,其他文件不支持
    if (!filename.endsWith('.vue')) {
        store.state.errors = []
        return
    }
    // 文件名不能重复,所以可以通过hash生成一个唯一的id,后面编译的时候会用到
    const id = hashId(filename)
    // 解析vue单文件
    const { errors, descriptor } = store.compiler.parse(code, {
        filename,
        sourceMap: true
    })
    // 如果解析出错,保存错误信息然后返回
    if (errors.length) {
        store.state.errors = errors
        return
    }
    // 接下来进行了两个判断,不影响主流程,代码就不贴了
    // 判断template和style是否使用了其他语言,是的话抛出错误并返回
    // 判断script是否使用了ts外的其他语言,是的话抛出错误并返回
    // ...
}

编译vue单文件的包是@vue/compiler-sfc,从3.2.13版本起这个包会内置在vue包中,安装vue就可以直接使用这个包,这个包会随着vue的升级而升级,所以@vue/repl并没有写死,而是可以手动配置:

import * as defaultCompiler from 'vue/compiler-sfc'

export class ReplStore implements Store {
    compiler = defaultCompiler
      vueVersion?: string

    async setVueVersion(version: string) {
        this.vueVersion = version
        const compilerUrl = `https://unpkg.com/@vue/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的内容如下:




  {{ msg }}
  

compiler.parse方法会将其解析成如下结果:

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

继续回到compileFile方法:

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

clientCode用来保存最终的编译结果。

编译script

继续回到compileFile方法:

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

调用doCompileScript方法编译script部分,其实template部分也会被一同编译进去,除非你没有使用 {{ msg }}

然后在App.vue组件中引入:




  {{ msg }}
  
  // ++

此时经过上一节【文件编译】处理后,Comp.vue的编译结果如下所示:

App.vue的编译结果如下所示:

compileModulesForPreview会再一次编译各个文件,主要是做以下几件事情:

1.将模块的导出语句export转换成属性添加语句,也就是把模块添加到window.__modules__对象上:

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

export default __sfc__

转换成:

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

__module__.default = __sfc__

2.将import了相对路径的模块./的语句转成赋值的语句,这样可以从__modules__对象上获取到指定模块:

import Comp from './Comp.vue'

转换成:

const __import_1__ = __modules__["Comp.vue"]

3.最后再转换一下导入的组件使用到的地方:

_createVNode(Comp)

转换成:

_createVNode(__import_1__.default)

4.如果该组件存在样式,那么追加到window.__css__字符串上:

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

此时再来看codeToEval数组的内容就很清晰了,首先创建一个全局对象window.__modules__、一个全局字符串window.__css__,如果之前已经存在__app__实例,说明是更新情况,那么先卸载之前的组件,然后在页面中创建一个idappdiv元素用于挂载Vue组件,接下来添加compileModulesForPreview方法编译返回的模块数组,这样这些组件运行时全局变量都已定义好了,组件有可能会往window.__css__上添加样式,所以当所有组件运行完后再将window.__css__样式添加到页面。

最后,如果入口文件是Vue组件,那么会再添加一段Vue的实例化和挂载代码。

compileModulesForPreview方法比较长,做的事情大致就是从入口文件开始,按前面的4点转换文件,然后递归所有依赖的组件也进行转换,具体的转换方式是使用babel将模块转换成AST树,然后使用magic-string修改源代码,这种代码对于会的人来说很简单,对于没有接触过AST树操作的人来说就很难看懂,所以具体代码就不贴了,有兴趣查看具体实现的可以点击moduleCompiler.ts。

codeToEval数组内容准备好了,就可以给预览的iframe发送消息了:

await proxy.eval(codeToEval)

iframe接收到消息后会先删除之前添加的script标签,然后创建新标签:

// scrdoc.html
async function handle_message(ev) {
    let { action, cmd_id } = ev.data;
    // ...
    if (action === 'eval') {
        try {
            // 移除之前创建的标签
            if (scriptEls.length) {
                scriptEls.forEach(el => {
                    document.head.removeChild(el)
                })
                scriptEls.length = 0
            }
			// 遍历创建script标签
            let { script: scripts } = ev.data.args
            if (typeof scripts === 'string') scripts = [scripts]
            for (const script of scripts) {
                const scriptEl = document.createElement('script')
                scriptEl.setAttribute('type', 'module')
                const done = new Promise((resolve) => {
                    window.__next__ = resolve
                })
                scriptEl.innerHTML = script + `\nwindow.__next__()`
                document.head.appendChild(scriptEl)
                scriptEls.push(scriptEl)
                await done
            }
        }
        // ...
    }
}

为了让模块按顺序挨个添加,会创建一个promise,并且把resove方法赋值到一个全局的属性__next__上,然后再在每个模块最后拼接上调用的代码,这样当插入一个script标签时,该标签的代码运行完毕会执行window.__next__方法,那么就会结束当前的promise,进入下一个script标签的插件,不得不说,还是很巧妙的。

总结

本文从源码角度来看了一下@vue/repl组件的实现,其实忽略了挺多内容,比如ssr相关的、使用html作为入口文件、信息输出等,有兴趣的可以自行阅读源码。

因为该组件不支持运行Vue2,所以我的一个同事fork修改创建了一个Vue2的版本,有需求的可以关注一下vue2-repl。

最后也推荐一下我的开源项目,也是一个在线Playground,也支持Vue2Vue3单文件,不过更通用一些,但是不支持创建多个文件,有兴趣的可以关注一下code-run。

特别声明:以上内容(图片及文字)均为互联网收集或者用户上传发布,本站仅提供信息存储服务!如有侵权或有涉及法律问题请联系我们。
举报
评论区(0)
按点赞数排序
用户头像
精选文章
thumb 中国研究员首次曝光美国国安局顶级后门—“方程式组织”
thumb 俄乌线上战争,网络攻击弥漫着数字硝烟
thumb 从网络安全角度了解俄罗斯入侵乌克兰的相关事件时间线
下一篇
React如何更快的完成diff的比较 2023-07-24 15:04:54