1

参考 Codepen,我做了一个基于 iframe 的代码预览系统

 3 years ago
source link: https://segmentfault.com/a/1190000040373631
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

参考 Codepen,我做了一个基于 iframe 的代码预览系统

一直觉得 Codepen 的在线代码预览系统很神奇,能够所见即所得地实时展示代码的运行效果,无论是代码演示,还是测试功能,都是非常方便快捷的存在。刚好最近手头有业务需要用到类似 Codepen 的能力,经过一番调研之后开发了一个具有基本的在线运行代码能力的 demo 出来。

在线体验地址:https://jrainlau.github.io/on...

由于业务只需要执行 JS 代码,因此 demo 也只具备 JS 代码的运行能力。

我们知道,浏览器是通过自带的引擎来处理 html,css 和 js 资源的,处理过程在页面载入的时候就已经开始。如果我们想要动态地运行这些资源,对于 html 和 css 我们可以用 DOM 操作的方式,对于 js 我们可以用 eval 或者 new Function()。但是这些操作都偏复杂且不安全(evalnew Function()很容易出事),那么有没有什么办法可以既优雅方便,又能安全地动态运行呢?我们看看大名鼎鼎的 Codepen 是怎么做的。

我在 Codepen 里面简单地写了一个按钮,绑定了样式和点击事件,可以看到白色区域已经展示出我们想要的结果。打开控制台自吸观察后可以发现,整个白色区域是一个 iframe,当中的 html 内容就是我们刚刚所编辑的代码。

不难想象,它的运行原理有点类似于 document.write,把内容直接写入到某一个 html 文件中,然后把它以 iframe 的方式内嵌到其他网页当中,实现代码预览的逻辑。那么使用 iframe 有什么好处呢?iframe 可以独立成为一个和宿主隔离的沙箱环境,在当中运行的代码在大部分情况下不会影响宿主,能有效地保证安全。配合 HTML5 新增的 sandbox 属性,可以给 iframe 定义更为精细的权限,比如是否允许它运行脚本,是否允许它弹窗等等。

二、实现方式

要实现类似 Codepen 的效果,最重要的一步就是如何把代码注入到 iframe 当中。由于我们需要使用到操控 iframe 的相关 API,浏览器出于安全的考虑,我们只能使用同域的 iframe 链接,否则将会报跨域的错误。

首先准备好一个 index.htmliframe.html,使用一个静态资源服务器把它们跑起来,假设均跑在 localhost:8080。然后我们在 index.html 里面插入一个 iframe,其链接就是 localhost:8080/iframe.html,代码如下:

<iframe src="localhost:8080/iframe.html"></iframe>

接下来我们可以使用 iframe.contentDocument 来获取 iframe 的内容,然后操作它:

<script>
  const ifrme = document.querySelector('iframe');
  const iframeDoc = iframe.contentDocument;
  iframeDoc.open(); // 需要先调用 `open()`,打开“写”的开关

  iframeDoc.write(`
  <body>
    <style>button { color: red }</style>
    <button>Click</button>
    <script>
      document.querySelector('button').addEventListener(() => {
        console.log('on-click!')
      })
    <\/script>
  </body>
  `);

  iframeDoc.close(); // 最后调用 `close()`,关闭“写”的开关
</script>

运行完毕后,我们可以在 localhost:8080/index.html 里面看到和前文 Codepen 所展示的一样的效果:

image

image

后续我们只需要找个输入框,把所写的代码保存成变量,然后调用 iframeDoc.write() 就可以动态地把代码写入到 iframe 并实时运行了。

三、控制台输出及安全

image

观察 Codepen 的页面,可以看到有一个 Console 的面板,它可以把 iframe 当中的 console 信息直接输出。这是怎么实现的呢?答案很简单,我们可以在 iframe 页面中劫持 console 等 API,在保留原有的控制台输出的功能的前提下,把相关的信息通过 postMessage 的方式把它们输出给父页面,父页面监听到 message 以后把信息整理后输出到页面上,实现 Console 面板。

iframe.html 中,我们在<body></body> 以外写入一段 js 代码(因为父页面调用 iframeDoc.write() 会覆盖 <body></body> 内的全部内容):

function rewriteConsole(type) {
  const origin = console[type];
  console[type] = (...args) => {
    window.parent.postMessage({ from: 'codeRunner', type, data: args }, '*');
    origin.apply(console, args);
  };
}

rewriteConsole('log');
rewriteConsole('info');
rewriteConsole('debug');
rewriteConsole('warn');
rewriteConsole('error');
rewriteConsole('table');

此外我们会给 iframe 设置 sandbox 属性来限制其部分权限,但是这里有一个套娃的隐患,就是如果在 iframe 里面执行 window.parent.document 相关 API 的话,可以让 iframe 去改写父页面的内容,甚至改写 sandbox 属性,这肯定是不安全的,因此我们需要在 iframe 中把这相关 API 给屏蔽掉:

Object.defineProperty(window, 'disableParent', {
  get() {
    throw new Error('无法调用 window.parent 属性!');
  },
  set() {},
});

在调用父页面的 iframeDoc.write(code) 之前,我们需要先把用户输入的自定义代码 code 进行一次 replace,把当中的所有 parent.document 改成 window.disableParent。当用户调用 parent.document 相关 API 时,实际在 iframe 运行的是 window.disableParent,届时将会直接报错无法调用 window.parent 属性!,有效避免了套娃的安全隐患。

四、使用 monaco-editor 实现编辑模块和 Console 面板模块

image

我所搭建的这个 online-code-runner 是基于 monaco-editor 来实现编辑模块和 Console 面板模块的,接下来会简单讲述它们分别都是怎么实现的。


对于编辑模块来说,就是一个简单的 monaco-editor,只需要简单地设置它的样式就可以了:

monaco.editor.create(document.querySelector('#editor'), {
  {
    language: 'javascript',
    tabSize: 2,
    autoIndent: true,
    theme: 'github',
    automaticLayout: true,
    wordWrap: 'wordWrapColumn',
    wordWrapColumn: 120,
    lineHeight: 28,
    fontSize: 16,
    minimap: {
      size: 'fill',
    },
  },
});

点击”执行代码“的按钮后,可以通过 editor.getValue() 把编辑模块中的内容读取出来,然后交给 iframe 去运行。


对于 Console 面板来说,它是另一个只读的 monaco-editor,主要有2个问题会有一点点费劲。其一是如何让新添加的内容挨个插入进去;其二是如何根据不同的 console 类型产生不用的背景色。

问题一的解法很简单,只需要定义一个字符串类型变量 infos,每当监听到来自 iframe 的 postMessage() 时,就往 infos 添加当中的信息,最后调用 editor.setValue() 即可。

问题二的解法,我们已经在 iframe 中劫持 console 的逻辑,在 postMessage 的时候同时告诉父页面 consle[type] 到底是 log 还是 warn 还是其他,因此父页面可以根据这里的 console[type] 来知道具体的类型。

接下来我们可以调用 editor.deltaDecorations 方法来设置某行某列的背景色:

const deltaDecorations = []

// 每当有新的 consle 消息推送过来时,都往 deltaDecorations 里插入一条信息,后面会用到
// 这里的 startLine 和 endLine 代表着这条新的消息的起始行号和结束行号,需要自行记录
// `${info.type}Decoration` 为不同 `console[type]` 的背景色对应的 className,对应着具体的 CSS
deltaDecorations.push({
  range: new monaco.Range(startLine, 1, endLine, 1),
  options: { isWholeLine: true, className: `${info.type}Decoration` },
});

然后我们可以定义不同 consle[type] 对应的背景色 CSS:

.warnDecoration {
  background: #ffd900;
  width: 100% !important;
}
.errorDecoration {
  background: #ff3300;
  width: 100% !important;
}

具体代码可以看这里:https://github.com/jrainlau/o...

本文通过分析 Codepen 的实现方式,使用 iframe 的方式配合 monaco-editor 自行开发了一套专用于执行 JavaScript 代码的在线代码预览系统。除了可以作为代码预览、展示的作用外,对于一些管理系统而言,往往需要人为编写一些后置脚本来处理系统中的数据,正好可以利用本文的方式去搭建一套代码预览系统,实时又安全地预览后置脚本,用处非常大。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK