@vue/repl中实现了什么?

1,031 阅读4分钟

@vue/repl 是Vue playground的实现的源代码仓库。本文探究下该仓库下到底实现了什么样的功能以及使用到的一些技术和技巧。

页面布局 基础架构

Repl 组件使用了 SplitPane组件 同时分为左右两个编辑面板和输出面板 使用动态slot插入面板组件和编辑组件

Repl.vue

  <div class="vue-repl">
    <SplitPane :layout="layout">
      <template #[editorSlotName]>
        <EditorContainer :editor-component="editor" />
      </template>
      <template #[outputSlotName]>
        <Output
          ref="output"
          :editor-component="editor"
          :show-compile-output="props.showCompileOutput"
          :ssr="!!props.ssr"
        />
      </template>
    </SplitPane>
  </div>

SplitPane组件

  <div
    ref="container"
    class="split-pane"
    :class="{
      dragging: state.dragging,
      'show-output': store.showOutput,
      vertical: isVertical,
    }"
    @mousemove="dragMove"
    @mouseup="dragEnd"
    @mouseleave="dragEnd"
  >
  // 左边区域
    <div
      class="left"
      :style="{ [isVertical ? 'height' : 'width']: boundSplit + '%' }"
    >
      <slot name="left" />
      // 中间拖拽bar
      <div class="dragger" @mousedown.prevent="dragStart" />
    </div>
    // 右边区域
    <div
      class="right"
      :style="{ [isVertical ? 'height' : 'width']: 100 - boundSplit + '%' }"
    >
    // 右边区域显示宽高view bar
      <div v-show="state.dragging" class="view-size">
        {{ `${state.viewWidth}px x ${state.viewHeight}px` }}
      </div>
      <slot name="right" />
    </div>
    // 切换输出
    <button class="toggler" @click="store.showOutput = !store.showOutput">
      {{
        store.showOutput
          ? splitPaneOptions?.codeTogglerText || '< Code'
          : splitPaneOptions?.outputTogglerText || 'Output >'
      }}
    </button>
  </div>

上述是SplitPane组件的基本构成和功能

编辑器组件 EditorContainer.vue 主要展现文件和文件代码 vue代码等 用于实时操作编辑组件代码,通过外部编辑器实现。一般是 Monaco 编辑器组件实现。

  <div class="editor-container">
    <props.editorComponent
      :value="store.activeFile.code"
      :filename="store.activeFile.filename"
      @change="onChange"
    />
    <Message v-show="showMessage" :err="store.errors[0]" />

    <div class="editor-floating">
      <ToggleButton
        v-if="editorOptions?.showErrorText !== false"
        v-model="showMessage"
        :text="editorOptions?.showErrorText || 'Show Error'"
      />
      <ToggleButton
        v-if="editorOptions?.autoSaveText !== false"
        v-model="autoSave"
        :text="editorOptions?.autoSaveText || 'Auto Save'"
      />
    </div>
  </div>

Repl 里的store

要实现Playground的功能 Repl库里实现了一个用于管理和维护不同文件内容和文件名称的store 来实现不同tsx jsx 等文件的编译。json文件的解析。importMap 内容的解析和插入。 同时包括tsconfig文件的初始化和加载等逻辑。

当前所有关联的组件内容和相关联的文件都组织在File 对象里面。

store.ts 管理组织File对象
export function useStore(
  {
    files = ref(Object.create(null)), // files对象
    ...
    }
transform.ts 编译不同类型的文件

compileFile 函数 编译文件类型 /repl/src/transform.ts

image.png

css 文件的处理 源代码不做转换

  if (filename.endsWith('.css')) {
    compiled.css = code
    return []
  }
tsx jsx 等文件的编译
if (REGEX_JS.test(filename)) {
    const isJSX = testJsx(filename)
    if (testTs(filename)) {
      code = transformTS(code, isJSX)
    }
    if (isJSX) {
      code = await import('./jsx').then(({ transformJSX }) =>
        transformJSX(code),
      )
    }
    compiled.js = compiled.ssr = code
    return []
  }

transformTS 功能 编译ts 用到 sucrase 这个库相对于babel 和ts 原生的编译能力更快。

import { type Transform, transform } from 'sucrase' 
function transformTS(src: string, isJSX?: boolean) {
  return transform(src, {
    transforms: ['typescript', ...(isJSX ? (['jsx'] as Transform[]) : [])],
    jsxRuntime: 'preserve',
  }).code
}

transformJsx jsx文件使用babel来进行编译。

import { transform } from '@babel/standalone'
import jsx from '@vue/babel-plugin-jsx'

export function transformJSX(src: string) {
  return transform(src, {
    plugins: [jsx],
  }).code!
}

json文件的解析 拼接导出

  if (filename.endsWith('.json')) {
    let parsed
    try {
      parsed = JSON.parse(code)
    } catch (err: any) {
      console.error(`Error parsing ${filename}`, err.message)
      return [err.message]
    }
    compiled.js = compiled.ssr = `export default ${JSON.stringify(parsed)}`
    return []
  }

vue文件的编译 利用到 vue/compiler-sfc单文件编译库里面的编译能力。

  const { errors, descriptor } = store.compiler.parse(code, {
    filename,
    sourceMap: true,
    templateParseOptions: store.sfcOptions?.template?.compilerOptions,
  })
  if (errors.length) {
    return errors
  }
  ....
  ....

通过上述的编译操作 将vue文件编译成 script style template 三个模块 并分别调用对应的方法处理script template style 模块的代码。

  const hasScoped = descriptor.styles.some((s) => s.scoped)
  let clientCode = '' // csr 渲染编译之后的代码
  let ssrCode = '' // ssr 渲染编译之后的代码

  const appendSharedCode = (code: string) => {
    clientCode += code
    ssrCode += code
  }

处理没有加 setup部分的script 代码块

  let clientScript: string
  let bindings: BindingMetadata | undefined
  try {
    ;[clientScript, bindings] = await doCompileScript(
      store,
      descriptor,
      id,
      false,
      isTS,
      isJSX,
    )
  } catch (e: any) {
    return [e.stack.split('\n').slice(0, 12).join('\n')]
  }

处理加了setup 的代码块

  if (descriptor.scriptSetup || descriptor.cssVars.length > 0) {
    try {
      const ssrScriptResult = await doCompileScript(
        store,
        descriptor,
        id,
        true,
        isTS,
        isJSX,
      )
      ssrCode += ssrScriptResult[0]
    } catch (e) {
      ssrCode = `/* SSR compile error: ${e} */`
    }
  } else {
    // the script result will be identical.
    ssrCode += clientScript
  }

template 模板编译的部分

同时编译处理 csr 模板 和ssr 模板

image.png

针对jsx的处理

  if (isJSX) {
    const { transformJSX } = await import('./jsx')
    clientCode &&= transformJSX(clientCode)
    ssrCode &&= transformJSX(ssrCode)
  }

styles 模块处理的部分

image.png 最后的代码拼接

  if (clientCode || ssrCode) {
    const ceStyles = isCE
      ? `\n${COMP_IDENTIFIER}.styles = ${JSON.stringify(styles)}`
      : ''
    appendSharedCode(
      `\n${COMP_IDENTIFIER}.__file = ${JSON.stringify(filename)}` +
        ceStyles +
        `\nexport default ${COMP_IDENTIFIER}`,
    )
    compiled.js = clientCode.trimStart()
    compiled.ssr = ssrCode.trimStart()
  }

具体模块的编译能力需要深入 @vue/compiler-sfc 这个包。

Output.vue 输出组件

// 这里定义了右侧顶部栏的模块 tabs模块
  <div class="tab-buttons">
    <button
      v-for="m of modes"
      :key="m"
      :class="{ active: mode === m }"
      @click="mode = m"
    >
      <span>{{ m }}</span>
    </button>
  </div>

  <div class="output-container">
    // 这里是预览组件 展示代码更改之后的实时预览效果
    <Preview ref="preview" :show="mode === 'preview'" :ssr="ssr" />
    // 这里是编译之后的js css ssr 模块  使用Monaco 编辑器做展示效果
    <props.editorComponent
      v-if="mode !== 'preview'"
      readonly
      // 当前文件类型 js css ssr
      :filename="store.activeFile.filename"
       // 内容从当前活动文件的 activeFile compiled 对象中取值。
      :value="store.activeFile.compiled[mode]"
      :mode="mode"
    />
  </div>

Preview.vue 预览组件

  <div
    v-show="show"
    ref="container"
    class="iframe-container"
    :class="{ [theme]: previewTheme }"
  />
  <Message :err="(previewOptions?.showRuntimeError ?? true) && runtimeError" />
  <Message
    v-if="!runtimeError && (previewOptions?.showRuntimeWarning ?? true)"
    :warn="runtimeWarning"
  />

很明显 预览组件实现了一个iframe 的沙箱机制,将实时渲染之后的结果挂载在 div container DOM节点之上。

iframe 沙箱机制 创建一个iframe节点 并将渲染内容挂载在iframe节点之上

image.png

引入preview html 文件 srcdoc.html import srcdoc from './srcdoc.html?raw'

<!doctype html>
<html>
  <head>
    <style>
      html.dark {
        color-scheme: dark;
      }
      body {
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
          Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
      }
    </style>
    <!-- PREVIEW-OPTIONS-HEAD-HTML -->
    ........
    //省略部分代码
    ........
    <!-- ES Module Shims: Import maps polyfill for modules browsers without import maps support (all except Chrome 89+) -->
    <script
      async
      src="https://cdn.jsdelivr.net/npm/es-module-shims@1.5.18/dist/es-module-shims.wasm.js"
    ></script>
    <script type="importmap">
      <!--IMPORT_MAP-->
    </script><!--  -->
  </head>
  <body>
    <!--PREVIEW-OPTIONS-PLACEHOLDER-HTML-->
  </body>
</html>

处理importMap 和head Html占位符 placeholder html 占位符等内容

image.png

  const importMap = store.value.getImportMap()
  const sandboxSrc = srcdoc
    .replace(
      /<html>/,
      `<html class="${previewTheme.value ? theme.value : ''}">`,
    )
    .replace(/<!--IMPORT_MAP-->/, JSON.stringify(importMap))
    .replace(
      /<!-- PREVIEW-OPTIONS-HEAD-HTML -->/,
      previewOptions.value?.headHTML || '',
    )
    .replace(
      /<!--PREVIEW-OPTIONS-PLACEHOLDER-HTML-->/,
      previewOptions.value?.placeholderHTML || '',
    )
  sandbox.srcdoc = sandboxSrc
  containerRef.value?.appendChild(sandbox)

PreviewProxy 沙箱机制 待下文阐述~