Vue Playground 演练场源码解读(四)- 终篇

Vue Playground 演练场源码解读(四)- 终篇

在上篇中咱们注重讲了代码编辑器的相关应用:

  1. codemirror 代码编辑器的基础用法,以及Vue Playground源码中具体实践
  2. monaco-editor 代码编辑器的基础用法,以及 Vue Playground源码中的具体实践。

咱们接着对Vue Playground源码进行拆分学习。

在《Vue Playground 演练场源码解读(二)》中咱们了解到了?raw以及importmap的应用。本篇中也会有相关应用实践在这里就不在多说用法以及作用了

iframe 的交互应用

同源交互方法:

  • 协议、域名和端口号相同 :例如,主页面和 iframe 页面的 URL 都是 https://example.com
  • 可以直接访问 DOM:可以使用 iframe.contentWindow iframe.contentDocument 来访问 iframe 内部的内容,反之亦然。

主页面访问 iframe 内容

js 复制代码
const iframe = document.getElementById('myIframe');
const iframeWindow = iframe.contentWindow; // 获取 iframe 的窗口对象
const iframeDocument = iframe.contentDocument || iframeWindow.document; // 获取 iframe 的文档对象

// 操作 iframe 内部的 DOM
iframeDocument.getElementById('someElement').innerText = 'Hello from main page!';

iframe 访问主页面内容

js 复制代码
// 在 iframe 页面中
const parentWindow = window.parent; // 获取主页面的窗口对象
const parentDocument = parentWindow.document; // 获取主页面的文档对象

// 操作主页面的 DOM
parentDocument.getElementById('someElement').innerText = 'Hello from iframe!';

特点:
安全风险 :由于可以直接访问 DOM,如果 iframe 内容来自不可信的第三方,可能会导致安全问题,如 XSS 攻击。
适用场景 :主要用于内部系统或完全可控的页面之间的交互。

不同源交互方法(Vue Playground选择用的就是这种):

如果主页面和 iframe 页面不属于同源(即协议、域名或端口号不同),则无法直接访问彼此的 DOM,需要使用 postMessage API 来实现安全的跨域通信。

主页面向 iframe 发送消息

js 复制代码
const iframe = document.getElementById('myIframe');
const message = { type: 'fromMainPage', content: 'Hello from the main page!' };
const targetOrigin = 'https://iframe-domain.com'; // 目标页面的源
iframe.contentWindow.postMessage(message, targetOrigin);

iframe 向主页面发送消息

js 复制代码
const message = { type: 'fromIframe', content: 'Hello from the iframe!' };
const targetOrigin = 'https://example.com'; // 主页面的源
window.parent.postMessage(message, targetOrigin);

监听消息

js 复制代码
// 在主页面或 iframe 页面中
window.addEventListener('message', function(event) {
  // 验证消息来源
  if (event.origin !== 'https://expected-origin.com') {
    return; // 忽略非预期来源的消息
  }
  console.log('Received message:', event.data);
}, false);

特点:
安全性 :postMessage 提供了更安全的通信方式,但需要严格验证 event.origin,以防止恶意消息注入。
性能:消息传递的性能通常较好,但需要合理控制消息的大小和频率。

iframe 的属性了解

srcdoc属性

可能对于iframe使用不是那么熟悉的同学,不太了解这个srcdoc属性(也包括我)。在Vue laygroundiframe应用中就使用了该属性。

iframesrcdoc 属性是一个非常有用的特性,它允许你在 iframe 中直接嵌入HTML内容,而无需通过外部文件的URL来加载内容。这种方式非常适合在页面中快速嵌入简单的HTML片段,同时避免了跨域问题和额外的HTTP请求。应用如下:

html 复制代码
<iframe srcdoc="<h1>Hello, World!</h1><p>This is an inline iframe.</p>"></iframe>

在这个例子中,iframe 会直接渲染 <h1>Hello, World!</h1><p>This is an inline iframe.</p>,而不需要从外部URL加载内容。

srcdoc 也可以进行同源交互

sandbox属性

<iframe>sandbox 属性用于限制嵌入内容的行为,以增强安全性。通过指定不同的关键字,可以解除特定的限制。以下是在Vue Playground中的一些应用:

  • allow-forms: 允许 iframe 内部的表单提交。如果没有使用该关键字,表单将无法校验输入内容、发送数据到 Web 服务器或关闭对话框

  • allow-modals: 允许 iframe 内部通过 Window.alert()、Window.confirm()、Window.print() 和 Window.prompt() 打开模态窗口。此外,它还允许页面接收 BeforeUnloadEvent 事件

  • allow-pointer-lock: 允许 iframe 内部使用指针锁定 API,用于需要鼠标指针锁定的场景,如游戏。

  • allow-popups: 允许 iframe 内部打开新窗口或标签页(例如通过 Window.open() 或 target="_blank")。如果没有使用该关键字,这些操作将静默失败。

  • allow-same-origin: 如果没有使用该关键字,iframe 中的文档将被视为来自一个特殊的源,始终使同源策略失败。这可以防止嵌入内容访问父页面的数据存储或执行某些 JavaScript API 操作

  • allow-scripts: 允许 iframe 内部运行脚本(但不能创建弹窗)。如果没有使用该关键字,脚本执行将被禁止

  • allow-top-navigation-by-user-activation: 允许 iframe 内部的内容导航到顶级浏览上下文(例如改变主页面的 URL),但这种导航只能由用户手势(如点击)启动

大家当作了解吧,这些都是源码中应用到的,博主在这里简单说明下具体作用

Vue Playground预览功能的实现

前面说了那么多就是利用iframesrcdoc属性实现的,这里主要说一下

先看下srcdoc.html,重点关注下我红框圈住的内容

之后看下这段代码:

js 复制代码
// ?raw 获取到html文件内容
import srcdoc from './srcdoc.html?raw'

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(' '),
  )

  const importMap = store.value.getImportMap() // 获取到importMap值(vue相关版本)
  const sandboxSrc = srcdoc
    .replace(
      /<html>/,
      `<html class="${previewTheme.value ? theme.value : ''}">`, // 这里是为了适配主题
    )
    .replace(/<!--IMPORT_MAP-->/, JSON.stringify(importMap)) // 替换 importMap
    .replace(
      /<!-- PREVIEW-OPTIONS-HEAD-HTML -->/, // 这里在 Vue Playground中没有应用,应该是暴漏的替换入口(可以内置一些 <style>、<script>等等)
      previewOptions.value?.headHTML || '',
    )
    .replace(
      /<!--PREVIEW-OPTIONS-PLACEHOLDER-HTML-->/,// 这里在 Vue Playground中没有应用,应该是暴漏的替换入口(可以内置一些iframe的body DOM)
      previewOptions.value?.placeholderHTML || '', 
    )
  sandbox.srcdoc = sandboxSrc
  containerRef.value?.appendChild(sandbox) // 把iframe 添加到dom中

上面就是创建iframe的相关代码。其实可以看到srcdoc.html东西不是很多,那么怎么做到渲染代码编辑器 的代码呢?请接着往下看!

再看下srcdoc.html这段代码

js 复制代码
 async function handle_message(ev) {
          let { action, cmd_id } = ev.data
          const send_message = (payload) =>
            parent.postMessage({ ...payload }, ev.origin)
          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 })

          if (action === 'eval') {
            try {
              if (scriptEls.length) {
                scriptEls.forEach((el) => {
                  document.head.removeChild(el)
                })
                scriptEls.length = 0
              }

              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')
                // send ok in the module script to ensure sequential evaluation
                // of multiple proxy.eval() calls
                const done = new Promise((resolve) => {
                  window.__next__ = resolve
                })
                scriptEl.innerHTML = script + `\nwindow.__next__()`
                document.head.appendChild(scriptEl)
                scriptEl.onerror = (err) => send_error(err.message, err.stack)
                scriptEls.push(scriptEl)
                await done
              }
              send_ok()
            } catch (e) {
              send_error(e.message, e.stack)
            }
          }

          if (action === 'catch_clicks') {
          }
        }

        window.addEventListener('message', handle_message, false)

if (action === 'eval')这块代码内容主要就是根据传输进来的数据创建script标签,并渲染对应的js。比如:

js 复制代码
 const scriptEl = document.createElement('script')
scriptEl.setAttribute('type', 'module')
scriptEl.innerHTML = `
const h1 = document.createElement('h1')
h1.innerText = '三原'
document.body.appendChild(h1)
`
document.head.appendChild(scriptEl)

这一段代码就是创建了一个<script>标签,内容呢就是创建一个<h1>三原</h1>插入到了body。

区别于Vue Playground就是代码段是通过编译器 vue/compiler-sfc 以及 babel编译后的代码。

if (action === 'catch_clicks')中主要是处理a标签的点击事件的拦截处理。默认没有target属性的统一使用window.open(el.href, '_blank')做跳转

srcdoc.html中其他的javascript逻辑都是做代理的如下几段代码块:

js 复制代码
// 对 console 上的方法做代理 向上交互
;['clear', 'log', 'info', 'dir', 'warn', 'error', 'table'].forEach(
    (level) => { 
        // ...省略部分代码
        parent.postMessage({ action: 'console', level, duplicate: true },'*',)
})

也是对console上的方法代理的

js 复制代码
;[
          { method: 'group', action: 'console_group' },
          { method: 'groupEnd', action: 'console_group_end' },
          { method: 'groupCollapsed', action: 'console_group_collapsed' },
        ].forEach((group_action) => {
          const original = console[group_action.method]
          console[group_action.method] = (label) => {
            parent.postMessage({ action: group_action.action, label }, '*')

            original(label)
          }
        })

读到这里应该能了解整个从Vue PlaygroundVue DevTools的渲染逻辑了。

PreviewProxy.ts 是如何做代理类的

ts 复制代码
let uid = 1

export class PreviewProxy {
  iframe: HTMLIFrameElement
  handlers: Record<string, Function>
  pending_cmds: Map<
    number,
    { resolve: (value: unknown) => void; reject: (reason?: any) => void }
  >
  handle_event: (e: any) => void

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

  destroy() {
    window.removeEventListener('message', this.handle_event)
  }

  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 }, '*')
    })
  }

  handle_command_message(cmd_data: any) {
    let action = cmd_data.action
    let id = cmd_data.cmd_id
    let handler = this.pending_cmds.get(id)

    if (handler) {
      this.pending_cmds.delete(id)
      if (action === 'cmd_error') {
        let { message, stack } = cmd_data
        let e = new Error(message)
        e.stack = stack
        handler.reject(e)
      }

      if (action === 'cmd_ok') {
        handler.resolve(cmd_data.args)
      }
    } else if (action !== 'cmd_error' && action !== 'cmd_ok') {
      console.error('command not found', id, cmd_data, [
        ...this.pending_cmds.keys(),
      ])
    }
  }

  handle_repl_message(event: any) {
    if (event.source !== this.iframe.contentWindow) return

    const { action, args } = event.data

    switch (action) {
      case 'cmd_error':
      case 'cmd_ok':
        return this.handle_command_message(event.data)
      case 'fetch_progress':
        return this.handlers.on_fetch_progress(args.remaining)
      case 'error':
        return this.handlers.on_error(event.data)
      case 'unhandledrejection':
        return this.handlers.on_unhandled_rejection(event.data)
      case 'console':
        return this.handlers.on_console(event.data)
      case 'console_group':
        return this.handlers.on_console_group(event.data)
      case 'console_group_collapsed':
        return this.handlers.on_console_group_collapsed(event.data)
      case 'console_group_end':
        return this.handlers.on_console_group_end(event.data)
    }
  }

  eval(script: string | string[]) {
    return this.iframe_command('eval', { script })
  }

  handle_links() {
    return this.iframe_command('catch_clicks', {})
  }
}

eval()、handle_links() 方法调用 iframe_command 是发送消息给iframe,然后iframe接受处理创建script标签和处理a标签的点击事件。

handle_repl_message() 方法是接受iframe向上交互来的数据(多是一些log日志)也有创建script完成失败的(cmd_error、cmd_ok)。

这么看PreviewProxy的作用就一清二楚了,就是做代理的。如果后面咱们有需要iframe交互的可以借鉴这个类的实现

关于编译

其实就差moduleCompiler.ts文件没有展开分析了,它里面出来是用vue/compiler-sfc来编译咱们编辑器中写的SFC源代码的。编译这块还挺复杂牵扯到AST生成,以及使用babel咱们就不在这系列中讲了。

其实在iframe中已经引入vueCDN了,如果在编辑器中限制只让编写js或者html文件,并且不模块化,那么就不需要编译了直接丢给iframe源代码即可。如下

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vue.js Button Example with Import Maps</title>
  <!-- 使用 Import Maps 引入 Vue.js -->
  <script type="importmap">
    {
      "imports": {
        "vue": "https://unpkg.com/vue@3.2.31/dist/vue.esm-browser.js"
      }
    }
  </script>
</head>
<body>
  <div id="app">
    <h1>{{ message }}</h1>
    <button @click="updateMessage">点击我</button>
  </div>

  <script type="module">
    import Vue from 'vue';

    new Vue({
      el: '#app',
      data: {
        message: '欢迎来到 Vue.js 示例!'
      },
      methods: {
        updateMessage() {
          this.message = '按钮被点击了!';
        }
      }
    });
  </script>
</body>
</html>

当然为了让用户体验更好,Vue Playground还是做了SFC并且编译的逻辑。

总结

Vue Playground源码系列咱们共分为四篇,分别从:

Vue Playground 演练场源码解读(一)

  1. 动态获取版本,并且简单了解了下jsdelivr
  2. 动态设置高度
  3. ?raw 是做什么的,以及jszip配合file-saver的下载项目.

Vue Playground 演练场源码解读(二)

  1. importmap 的相关用法。
  2. 简单介绍了hashhistory模式的区别。
  3. 介绍了playground分享功能利用hash模式的实现。
  4. 使用hash模式下利用fflate对数据进行压缩、解压。
  5. 介绍了核心源码useVueImportMap 和 useStore的实现逻辑

Vue Playground 演练场源码解读(三)

  1. codemirror 代码编辑器的基础用法,以及Vue Playground源码中具体实践
  2. monaco-editor 代码编辑器的基础用法,以及 Vue Playground源码中的具体实践。

《Vue Playground 演练场源码解读(四)- 终篇》

  1. iframe 的交互应用
  2. iframesrcdocsandbox相关应用实践
  3. Vue Playground预览功能的实现
  4. 拆分 PreviewProxy.ts 是如何做代理类的

咱们从源码中学到了这么多东西,以及演武场的实现思路,还学到了许多好用的插件。大家好,我是三原,欢迎大家点赞收藏

相关推荐
RaidenLiu10 分钟前
告别陷阱:精通Flutter Signals的生命周期、高级API与调试之道
前端·flutter·前端框架
非凡ghost10 分钟前
HWiNFO(专业系统信息检测工具)
前端·javascript·后端
非凡ghost12 分钟前
FireAlpaca(免费数字绘图软件)
前端·javascript·后端
非凡ghost18 分钟前
Sucrose Wallpaper Engine(动态壁纸管理工具)
前端·javascript·后端
拉不动的猪20 分钟前
为什么不建议项目里用延时器作为规定时间内的业务操作
前端·javascript·vue.js
该用户已不存在27 分钟前
Gemini CLI 扩展,把Nano Banana 搬到终端
前端·后端·ai编程
地方地方29 分钟前
前端踩坑记:解决图片与 Div 换行间隙的隐藏元凶
前端·javascript
jason_yang33 分钟前
vue3+element-plus按需自动导入-正确姿势
vue.js·vite·element
小猫由里香34 分钟前
小程序打开文件(文件流、地址链接)封装
前端
Tzarevich37 分钟前
使用n8n工作流自动化生成每日科技新闻速览:告别信息过载,拥抱智能阅读
前端