相比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
中,反对创立多个文件,当然也存在一些限度,比方只反对Vue3
,不反对应用CSS
预处理语言,不过反对应用ts
。
接下来会率领各位从头摸索一下它的实现原理,须要阐明的是咱们会选择性的疏忽一些货色,比方ssr
相干的,有须要理解这方面的能够自行浏览源码。
首先下载该我的项目,而后找到测试页面的入口文件:
// test/main.tsconst 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
的hash
中的文件数据,而后创立了一个ReplStore
类的实例store
,所有的文件数据都会保留在这个全局的store
里,接下来监听store
的文件数据变动,变动了会实时反映在url
中,即进行实时存储,最初渲染组件Repl
并传入store
。
先来看看ReplStore
类。
数据存储
// 默认的入口文件名称const defaultMainFile = 'App.vue'// 默认文件的内容const welcomeCode = `<script setup>import { ref } from 'vue'const msg = ref('Hello World!')</script><template> <h1>{{ msg }}</h1> <input v-model="msg"></template>`.trim()// 数据存储类class ReplStore { constructor({ serializedState = '', defaultVueRuntimeURL = `https://unpkg.com/@vue/[email protected]${version}/dist/runtime-dom.esm-browser.js`, }) { let files: StoreState['files'] = {} // 有存储的数据 if (serializedState) { // 解码保留的数据 const saved = JSON.parse(atou(serializedState)) for (const filename in saved) { // 遍历文件数据,创立文件实例保留到files对象上 files[filename] = new File(filename, saved[filename]) } } else { // 没有存储的数据 files = { // 创立一个默认的文件 [defaultMainFile]: new File(defaultMainFile, welcomeCode) } } // Vue库的cdn地址,留神是运行时版本,即不蕴含编译模板的代码,也就是模板必须先被编译成渲染函数才行 this.defaultVueRuntimeURL = defaultVueRuntimeURL // 默认的入口文件为App.vue let mainFile = defaultMainFile if (!files[mainFile]) { // 自定义了入口文件 mainFile = Object.keys(files)[0] } // 外围数据 this.state = reactive({ mainFile,// 入口文件名称 files,// 所有文件 activeFile: files[mainFile],// 以后正在编辑的文件 errors: [],// 错误信息 vueRuntimeURL: this.defaultVueRuntimeURL,// Vue库的cdn地址 }) // 初始化import-map this.initImportMap() }}
次要是应用reactive
创立了一个响应式对象来作为外围的存储对象,存储的数据包含入口文件名称mainFile
,个别作为根组件,所有的文件数据files
,以及以后咱们正在编辑的文件对象activeFile
。
数据是如何存储在url中的
能够看到上面对hash
中取出的数据serializedState
调用了atou
办法,用于解码数据,还有一个与之绝对的utoa
,用于编码数据。
大家或多或少应该都听过url
有最大长度的限度,所以依照咱们个别的想法,数据必定不会抉择存储到url
上,然而hash
局部的应该不受影响,并且hash
数据也不会发送到服务端。
即便如此,@vue/repl
在存储前还是先做了压缩的解决,毕竟url
很多状况下是用来分享的,太长总归不太不便。
首先来看一下最开始提到的store.serialize()
办法,用来序列化文件数据存储到url
上:
class ReplStore { // 序列化文件数据 serialize() { return '#' + utoa(JSON.stringify(this.getFiles())) } // 获取文件数据 getFiles() { const exported: Record<string, string> = {} for (const filename in this.state.files) { exported[filename] = this.state.files[filename].code } return exported }}
调用getFiles
取出文件名和文件内容,而后转成字符串后调用utoa
办法:
import { zlibSync, strToU8, strFromU8 } from 'fflate'export function utoa(data: string): string { // 将字符串转成Uint8Array const buffer = strToU8(data) // 以最大的压缩级别进行压缩,返回的zipped也是一个Uint8Array const zipped = zlibSync(buffer, { level: 9 }) // 将Uint8Array从新转换成二进制字符串 const binary = strFromU8(zipped, true) // 将二进制字符串编码为Base64编码字符串 return btoa(binary)}
压缩应用了fflate,号称是目前最快、最小、最通用的纯JavaScript
压缩和解压库。
能够看到其中strFromU8
办法第二个参数传了true
,代表转换成二进制字符串,这是必要的,因为js
内置的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
会编译成js
保留到compiled.js
上,款式则会提取到compiled.css
上保留。
这个编译逻辑咱们前面会具体介绍。
应用import-map
在浏览器上间接应用ESM
语法是不反对裸导入的,也就是上面这样不行:
import moment from "moment";
导入起源须要是一个非法的url
,那么就呈现了import-map这个提案,当然目前兼容性还不太好import-maps,不过能够polyfill
:
这样咱们就能够通过上面这种形式来应用裸导入了:
<script type="importmap">{ "imports": { "moment": "/node_modules/moment/src/moment.js", }}</script><script type="importmap">import moment from "moment";</script>
那么咱们看一下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.sfcOptionsprops.store.init()// ...
外围就是这两行,将应用组件时传入的sfcOptions
保留到store
的options
属性上,后续编译文件时会应用,当然默认啥也没传,一个空对象而已,而后执行了store
的init
办法,这个办法就会开启文件编译。
文件编译
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
语法(说句题外话,我喜爱看源码的一个起因之一就是总能从源码中发现一些有用的库或者工具),通常咱们转换ts
要么应用官网的ts
工具,要么应用babel
,然而如果对编译后果的浏览器兼容性不太关怀的话能够应用sucrase
,因为它超级快:
编译Vue单文件
持续回到compileFile
办法:
import hashId from 'hash-sum'export async function compileFile(){ // ... // 如果不是vue文件,那么就到此为止,其余文件不反对 if (!filename.endsWith('.vue')) { store.state.errors = [] return } // 文件名不能反复,所以能够通过hash生成一个惟一的id,前面编译的时候会用到 const id = hashId(filename) // 解析vue单文件 const { errors, descriptor } = store.compiler.parse(code, { filename, sourceMap: true }) // 如果解析出错,保留错误信息而后返回 if (errors.length) { store.state.errors = errors return } // 接下来进行了两个判断,不影响主流程,代码就不贴了 // 判断template和style是否应用了其余语言,是的话抛出谬误并返回 // 判断script是否应用了ts外的其余语言,是的话抛出谬误并返回 // ...}
编译vue
单文件的包是@vue/compiler-sfc,从3.2.13
版本起这个包会内置在vue
包中,装置vue
就能够间接应用这个包,这个包会随着vue
的降级而降级,所以@vue/repl
并没有写死,而是能够手动配置:
import * as defaultCompiler from 'vue/compiler-sfc'export class ReplStore implements Store { compiler = defaultCompiler vueVersion?: string async setVueVersion(version: string) { this.vueVersion = version const compilerUrl = `https://unpkg.com/@vue/[email protected]${version}/dist/compiler-sfc.esm-browser.js` const runtimeUrl = `https://unpkg.com/@vue/[email protected]${version}/dist/runtime-dom.esm-browser.js` this.pendingCompiler = import(/* @vite-ignore */ compilerUrl) this.compiler = await this.pendingCompiler // ... }}
默认应用以后仓库的compiler-sfc
,然而能够通过调用store.setVueVersion
办法来设置指定版本的vue
和compiler
。
假如咱们的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
、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
用来保留最终的编译后果。
编译script
持续回到compileFile
办法:
export async function compileFile(){ // ... const clientScriptResult = await doCompileScript( store, descriptor, id, isTS ) // ...}
调用doCompileScript
办法编译script
局部,其实template
局部也会被一起编译进去,除非你没有应用<script setup>
语法或者手动配置了不要这么做:
h(Repl, { sfcOptions: { script: { inlineTemplate: false } }})
咱们先疏忽这种状况,看一下doCompileScript
办法的实现:
export const COMP_IDENTIFIER = `__sfc__`async function doCompileScript( store: Store, descriptor: SFCDescriptor, id: string, isTS: boolean): Promise<[string, BindingMetadata | undefined] | undefined> { if (descriptor.script || descriptor.scriptSetup) { try { const expressionPlugins: CompilerOptions['expressionPlugins'] = isTS ? ['typescript'] : undefined // 1.编译script const compiledScript = store.compiler.compileScript(descriptor, { inlineTemplate: true,// 是否编译模板并间接在setup()外面内联生成的渲染函数 ...store.options?.script, id,// 用于款式的作用域 templateOptions: {// 编译模板的选项 ...store.options?.template, compilerOptions: { ...store.options?.template?.compilerOptions, expressionPlugins// 这个选项并没有在最新的@vue/compiler-sfc包的源码中看到,可能废除了 } } }) let code = '' // 2.转换默认导出 code += `\n` + store.compiler.rewriteDefault( compiledScript.content, COMP_IDENTIFIER, expressionPlugins ) // 3.编译ts if ((descriptor.script || descriptor.scriptSetup)!.lang === 'ts') { code = await transformTS(code) } return [code, compiledScript.bindings] } catch (e: any) { store.state.errors = [e.stack.split('\n').slice(0, 12).join('\n')] return } } else { return [`\nconst ${COMP_IDENTIFIER} = {}`, undefined] }}
这个函数次要做了三件事,咱们一一来看。
1.编译script
调用compileScript
办法编译script
,这个办法会解决<script setup>
语法、css
变量注入等个性,css
变量注入指的是在style
标签中应用v-bind
绑定组件的data
数据这种状况,具体理解CSS 中的 v-bind)。
如果应用了<script setup>
语法,且inlineTemplate
选项传了true
,那么会同时将template
局部编译成渲染函数并内联到setup
函数外面,否则template
须要另外编译。
id
参数用于作为scoped id
,当style
块中应用了scoped
,或者应用了v-bind
语法,都须要应用这个id
来创立惟一的class
类名、款式名。
编译后果如下:
能够看到模板局部被编译成了渲染函数并内联到了组件的setup
函数内,并且应用export default
默认导出组件。
2.转换默认导出
这一步会把后面失去的默认导出语句转换成变量定义的模式,应用的是rewriteDefault
办法,这个办法接管三个参数:要转换的内容、变量名称、插件数组,这个插件数组是传给babel
应用的:
所以如果应用了ts
,那么会传入['typescript']
。
转换后果如下:
转成变量有什么益处呢,其实这样就能够不便的在对象上增加其余属性了,如果是export default {}
的模式,如果想在这个对象上扩大一些属性要怎么做呢?正则匹配?转成ast
树?如同能够,但不是很不便,因为都得操作源内容,然而转成变量就很简略了,只有晓得定义的变量名称,就能够间接拼接如下代码:
__sfc__.xxx = xxx
不须要晓得源内容是什么,想增加什么属性就间接增加。
3.编译ts
最初一步会判断是否应用了ts
,是的话就应用后面提到过的transformTS
办法进行编译。
编译完script
回到compileFile
办法:
export async function compileFile(){ // ... // 如果script编译没有后果则返回 if (!clientScriptResult) { return } // 拼接script编译后果 const [clientScript, bindings] = clientScriptResult clientCode += clientScript // 给__sfc__组件对象增加了一个__scopeId属性 if (hasScoped) { appendSharedCode( `\n${COMP_IDENTIFIER}.__scopeId = ${JSON.stringify(`data-v-${id}`)}` ) } if (clientCode) { appendSharedCode( `\n${COMP_IDENTIFIER}.__file = ${JSON.stringify(filename)}` +// 给__sfc__组件对象增加了一个__file属性 `\nexport default ${COMP_IDENTIFIER}`// 导出__sfc__组件对象 ) compiled.js = clientCode.trimStart()// 将script和template的编译后果保存起来 } // ...}
将export default
转换成变量定义的益处来了,增加新属性很不便。
最初应用export default
导出定义的变量即可。
编译template
后面曾经提到过几次,如果应用了<script setup>
语法且inlineTemplate
选项没有设为false
,那么无需本人手动编译template
,如果要本人编译也很简略,调用一下compiler.compileTemplate
办法编译即可,实际上后面只是compiler.compileScript
办法外部帮咱们调了这个办法而已,这个办法会将template
编译成渲染函数,咱们把这个渲染函数字符串也拼接到clientCode
上,并且在组件选项对象,也就是后面一步编译script
失去的__sfc__
对象上增加一个render
属性,值就是这个渲染函数:
let code = `\n${templateResult.code.replace( /\nexport (function|const) render/, `$1 render` )}` + `\n${COMP_IDENTIFIER}.render = render
编译style
持续回到compileFile
办法,到这里vue
单文件的script
和template
局部就曾经编译完了,接下来会解决style
局部:
export async function compileFile(){ // ... let css = '' // 遍历style块 for (const style of descriptor.styles) { // 不反对应用CSS Modules if (style.module) { store.state.errors = [ `<style module> is not supported in the playground.` ] return } // 编译款式 const styleResult = await store.compiler.compileStyleAsync({ ...store.options?.style, source: style.content, filename, id, scoped: style.scoped, modules: !!style.module }) css += styleResult.code + '\n' } if (css) { // 保留编译后果 compiled.css = css.trim() } else { compiled.css = '/* No <style> tags present */' }}
很简略,应用compileStyleAsync
办法编译style
块,这个办法会帮咱们解决scoped
、module
以及v-bind
语法。
到这里,文件编译局部就介绍完了,总结一下:
- 款式文件因为只能应用原生
css
,所以不须要编译 js
文件本来也不须要编译,然而有可能应用了试验性质的$ref
语法,所以须要进行一下判断并解决,如果应用了ts
那么须要进行编译vue
单文件会应用@vue/compiler-sfc
编译,script
局部会解决setup
语法、css
变量注入等个性,如果应用了ts
也会编译ts
,最初的后果其实就是组件对象,template
局部无论是和script
一起编译还是独自编译,最初都会编译成渲染函数挂载到组件对象上,style
局部编译后间接保存起来即可
预览
文件都编译实现了接下来是不是就能够间接预览了呢?很遗憾,并不能,为什么呢,因为后面文件编译完后失去的是一般的ESM
模块,也就是通过import
和export
来导入和导出,比方除了App.vue
外,咱们还创立了一个Comp.vue
文件,而后在App.vue
中引入
// App.vueimport Comp from './Comp.vue'
乍一看如同没啥问题,但问题是服务器上并没有./Comp.vue
文件,这个文件只是咱们在前端模仿的,那么如果间接让浏览器收回这个模块申请必定是失败的,并且咱们模仿创立的这些文件最终都会通过一个个<script type="module">
标签插入到页面,所以须要把import
和export
转换成其余模式。
创立iframe
预览局部会先创立一个iframe
:
onMounted(createSandbox)let sandbox: HTMLIFrameElementfunction createSandbox() { // ... sandbox = document.createElement('iframe') sandbox.setAttribute( 'sandbox', [ 'allow-forms', 'allow-modals', 'allow-pointer-lock', 'allow-popups', 'allow-same-origin', 'allow-scripts', 'allow-top-navigation-by-user-activation' ].join(' ') ) // ...}
创立一个iframe
元素,并且设置了sandbox
属性,这个属性能够管制iframe
框架中的页面的一些行为是否被容许,详情arrt-sand-box。
import srcdoc from './srcdoc.html?raw'function createSandbox() { // ... // 查看importMap是否非法 const importMap = store.getImportMap() if (!importMap.imports) { importMap.imports = {} } if (!importMap.imports.vue) { importMap.imports.vue = store.state.vueRuntimeURL } // 向框架页面内容中注入import-map const sandboxSrc = srcdoc.replace( /<!--IMPORT_MAP-->/, JSON.stringify(importMap) ) // 将页面HTML内容注入框架 sandbox.srcdoc = sandboxSrc // 增加框架到页面 container.value.appendChild(sandbox) // ...}
srcdoc.html
就是用于预览的页面,会先注入import-map
的内容,而后通过创立的iframe
渲染该页面。
let proxy: PreviewProxyfunction 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
的resolve
和reject
通过id
保存起来,并且这个id
会发送给iframe
,当iframe
工作执行完了会向父窗口回复信息,并且会发回这个id
,那么父窗口就能够通过这个id
取出resove
和reject
依据函数依据工作执行的胜利与否决定调用哪个。
iframe
向父级发送信息的办法:
// srcdoc.htmlwindow.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
为app
的div
元素用于挂载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.htmlasync 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
,也反对Vue2
和Vue3
单文件,不过更通用一些,然而不反对创立多个文件,有趣味的能够关注一下code-run。