简介

大家好,今天我跟大家分享的是一个代码在线编辑预览工具的实现教程,手把手教你完成这样一个项目。

目前这类工具使用很广泛,常见于各种文档网站及代码分享场景,相关工具也比较多,如codepen、jsrun、codesandbox、jsbin、plnkr、jsfiddle等,这些工具大体分两类,一类可以自由添加多个文件,比较像我们平常使用的编辑器;另一类固定只能单独编辑html、js、css。

第二类比较常见,对于demo场景来说其实已经够用,当然,说的只是表象,底层实现方式可能还是各有千秋的。

本文主要介绍的是第二类其中的一种实现方式,完全不依赖于后端,所有逻辑都在前端完成,实现起来相当简单,使用的是vue3全家桶来开发,使用其他框架也完全可以。

PS:在本文基础上笔者开发了一个完整的线上工具,带云端保存,地址:http://lxqnsys.com/code-run/,欢迎使用。

页面结构

我挑了一个比较典型也比较好看的结构来仿照,默认布局上下分成四部分,工具栏、编辑器、预览区域及控制台,编辑器又分为三部分,分别是HTML、CSS、JavaScript,其实就是三个编辑器,用来编辑代码。

各部分都可以拖动进行调节大小,比如按住js编辑器左边的灰色竖条向右拖动,那么js编辑器的宽度会减少,同时css编辑器的宽度会增加,如果向左拖动,那么css编辑器宽度会减少,js编辑器的宽度会增加,当css编辑器宽度已经不能再减少的时候css编辑器也会同时向左移,然后减少html的宽度。

在实现上,水平调节宽度和垂直调节高度原理是一样的,以调节宽度为例,三个编辑器的宽度使用一个数组来维护,用百分比来表示,那么初始就是100/3%,然后每个编辑器都有一个拖动条,位于内部的左侧,那么当按住拖动某个拖动条拖动时的逻辑如下:

1.把本次拖动瞬间的偏移量由像素转换为百分比;

2.如果是向左拖动的话,检测本次拖动编辑器的左侧是否存在还有空间可以压缩的编辑器,没有的话代表不能进行拖动;如果有的话,那么拖动时增加本次拖动编辑器的宽度,同时减少找到的第一个有空间的编辑器的宽度,直到无法再继续拖动;

3.如果是向右拖动的话,检测本次拖动编辑器及其右侧是否存在还有空间可以压缩的编辑器,没有的话也代表不能再拖动,如果有的话,找到第一个并减少该编辑器的宽度,同时增加本次拖动编辑器左侧第一个编辑器的宽度;

核心代码如下:

const onDrag = (index, e) => {    let client = this._dir === 'v' ? e.clientY : e.clientX    // 本次移动的距离    let dx = client - this._last    // 换算成百分比    let rx = (dx / this._containerSize) * 100    // 更新上一次的鼠标位置    this._last = client    if (dx < 0) {        // 向左/上拖动        if (!this.isCanDrag('leftUp', index)) {            return        }        // 拖动中的编辑器增加宽度        if (this._dragItemList.value[index][this._prop] - rx < this.getMaxSize(index)) {            this._dragItemList.value[index][this._prop] -= rx        } else {            this._dragItemList.value[index][this._prop] = this.getMaxSize(index)        }        // 找到左边第一个还有空间的编辑器索引        let narrowItemIndex = this.getFirstNarrowItemIndex('leftUp', index)        let _minSize = this.getMinSize(narrowItemIndex)        // 左边的编辑器要同比减少宽度        if (narrowItemIndex >= 0) {            // 加上本次偏移还大于最小宽度            if (this._dragItemList.value[narrowItemIndex][this._prop] + rx > _minSize) {                this._dragItemList.value[narrowItemIndex][this._prop] += rx            } else {                // 否则固定为最小宽度                this._dragItemList.value[narrowItemIndex][this._prop] = _minSize            }        }    } else if (dx > 0) {        // 向右/下拖动        if (!this.isCanDrag('rightDown', index)) {            return        }        // 找到拖动中的编辑器及其右边的编辑器中的第一个还有空间的编辑器索引        let narrowItemIndex = this.getFirstNarrowItemIndex('rightDown', index)        let _minSize = this.getMinSize(narrowItemIndex)        if (narrowItemIndex <= this._dragItemList.value.length - 1) {            let ax = 0            // 减去本次偏移还大于最小宽度            if (this._dragItemList.value[narrowItemIndex][this._prop] - rx > _minSize) {                ax = rx            } else {                // 否则本次能移动的距离为到达最小宽度的距离                ax = this._dragItemList.value[narrowItemIndex][this._prop] - _minSize            }            // 更新拖动中的编辑器的宽度            this._dragItemList.value[narrowItemIndex][this._prop] -= ax            // 左边第一个编辑器要同比增加宽度            if (index > 0) {                if (this._dragItemList.value[index - 1][this._prop] + ax < this.getMaxSize(index - 1)) {                    this._dragItemList.value[index - 1][this._prop] += ax                } else {                    this._dragItemList.value[index - 1][this._prop] = this.getMaxSize(index - 1)                }            }        }    }}

实现效果如下:

正在上传…重新上传取消正在上传…重新上传取消

为了能提供多种布局的随意切换,我们有必要把上述逻辑封装一下,封装成两个组件,一个容器组件Drag.vue,一个容器的子组件DragItem.vue,DragItem通过slot来显示其他内容,DragItem主要提供拖动条及绑定相关的鼠标事件,Drag组件里包含了上述提到的核心逻辑,维护对应的尺寸数组,提供相关处理方法给DragItem绑定的鼠标事件,然后只要根据所需的结构进行组合即可,下面的结构就是上述默认的布局:

<Drag :number="3" dir="v" :config="[{ min: 0 }, null, { min: 48 }]">    <DragItem :index="0" :disabled="true" :showTouchBar="false">        <Editor></Editor>    </DragItem>    <DragItem :index="1" :disabled="false" title="预览">        <Preview></Preview>    </DragItem>    <DragItem :index="2" :disabled="false" title="控制台">        <Console></Console>    </DragItem></Drag>

这部分代码较多,有兴趣的可以查看源码。

编辑器

目前涉及到代码编辑的场景基本使用的都是codemirror,因为它功能强大,使用简单,支持语法高亮、支持多种语言和主题等。

但是为了能更方便的支持语法提示,本文选择的是微软的monaco-editor,功能和VSCode一样强大,VSCode有多强就不用我多说了,缺点是整体比较复杂,代码量大,内置主题较少。

monaco-editor支持多种加载方式,esm模块加载的方式需要使用webpack,但是vite底层打包工具用的是Rollup,所以本文使用直接引入js的方式。

在官网上下载压缩包后解压到项目的public文件夹下,然后参考示例的方式在index.html文件里添加:

<link rel="stylesheet" data-name="vs/editor/editor.main" href="/monaco-editor/min/vs/editor/editor.main.css" />
<script>    var require = {        paths: {            vs: '/monaco-editor/min/vs'        },        'vs/nls': {            availableLanguages: {                '*': 'zh-cn'// 使用中文语言,默认为英文            }        }    };</script><script src="/monaco-editor/min/vs/loader.js"></script><script src="/monaco-editor/min/vs/editor/editor.main.js"></script>

monaco-editor内置了10种语言,我们选择中文的,其他不用的可以直接删掉:

接下来创建编辑器就可以了:

const editor = monaco.editor.create(    editorEl.value,// dom容器    {        value: props.content,// 要显示的代码        language: props.language,// 代码语言,css、javascript等        minimap: {            enabled: false,// 关闭小地图        },        wordWrap: 'on', // 代码超出换行        theme: 'vs-dark'// 主题    })

就这么简单,一个带高亮、语法提示、错误提示的编辑器就可以使用了,效果如下:

其他几个常用的api如下:

// 设置文档内容editor.setValue(props.content)// 监听编辑事件editor.onDidChangeModelContent((e) => {    console.log(editor.getValue())// 获取文档内容})// 监听失焦事件editor.onDidBlurEditorText((e) => {    console.log(editor.getValue())})

预览

代码有了,接下来就可以渲染页面进行预览了,对于预览,显然是使用iframe,iframe除了src属性外,HTML5还新增了一个属性srcdoc,用来渲染一段HTML代码到iframe里,这个属性IE目前不支持,不过vue3都要不支持IE了,咱也不管了,如果硬要支持也简单,使用write方法就行了:

iframeRef.value.contentWindow.document.write(htmlStr)

接下来的思路就很清晰了,把html、css和js代码组装起来扔给srcdoc不就完了吗:

<iframe class="iframe" :srcdoc="srcdoc"></iframe>
const assembleHtml = (head, body) => {    return `<!DOCTYPE html>        <html>        <head>            <meta charset="UTF-8" />            ${head}        </head>        <body>            ${body}        </body>        </html>`}
const run = () => {  let head = `    <title>预览<\/title>    <style type="text/css">        ${editData.value.code.css.content}    <\/style>  `  let body = `    ${editData.value.code.html.content}    <script>        ${editData.value.code.javascript.content}    <\/script>  `  let str = assembleHtml(head, body)  srcdoc.value = str}

效果如下:

为了防止js代码运行出现错误阻塞页面渲染,我们把js代码使用try catch包裹起来:

let body = `    ${editData.value.code.html.content}    <script>        try {          ${editData.value.code.javascript.content}        } catch (err) {          console.error('js代码运行出错')          console.error(err)        }    <\/script>  `

控制台

极简方式

先介绍一种非常简单的方式,使用一个叫eruda的库,这个库是用来方便在手机上进行调试的,和vConsole类似,我们直接把它嵌到iframe里就可以支持控制台的功能了,要嵌入iframe里的文件我们都要放到public文件夹下:

const run = () => {  let head = `    <title>预览<\/title>    <style type="text/css">        ${editData.value.code.css.content}    <\/style>  `  let body = `    ${editData.value.code.html.content}    <script src="/eruda/eruda.js"><\/script>    <script>        eruda.init();        ${editData.value.code.javascript.content}    <\/script>  `  let str = assembleHtml(head, body)  srcdoc.value = str}

效果如下:

这种方式的缺点是只能嵌入到iframe里,不能把控制台和页面分开,导致每次代码重新运行,控制台也会重新运行,无法保留之前的日志,当然,样式也不方便控制。

自己实现

如果选择自己实现的话,那么这部分会是本项目里最复杂的,自己实现的话一般只实现一个console的功能,其他的比如html结构、请求资源之类的就不做了,毕竟实现起来费时费力,用处也不是很大。

console大体上要支持输出两种信息,一是console对象打印出来的信息,二是各种报错信息,先看console信息。

console信息

思路很简单,在iframe里拦截console对象的所有方法,当某个方法被调用时使用postMessage来向父页面传递信息,父页面的控制台打印出对应的信息即可。

// /public/console/index.js
// 重写的console对象的构造函数,直接修改console对象的方法进行拦截的方式是不行的,有兴趣可以自行尝试function ProxyConsole() {};// 拦截console的所有方法[    'debug',    'clear',    'error',    'info',    'log',    'warn',    'dir',    'props',    'group',    'groupEnd',    'dirxml',    'table',    'trace',    'assert',    'count',    'markTimeline',    'profile',    'profileEnd',    'time',    'timeEnd',    'timeStamp',    'groupCollapsed'].forEach((method) => {    let originMethod = console[method]    // 设置原型方法    ProxyConsole.prototype[method] = function (...args) {        // 发送信息给父窗口        window.parent.postMessage({            type: 'console',            method,            data: args        })        // 调用原始方法        originMethod.apply(ProxyConsole, args)    }})// 覆盖原console对象window.console = new ProxyConsole()

把这个文件也嵌入到iframe里:

const run = () => {  let head = `    <title>预览<\/title>    <style type="text/css">        ${editData.value.code.css.content}    <\/style>    <script src="/console/index.js"><\/script>  `  // ...}

父页面监听message事件即可:

window.addEventListener('message', (e) => {  console.log(e)})

如果如下:

监听获取到了信息就可以显示出来,我们一步步来看:

首先console的方法都可以同时接收多个参数,打印多个数据,同时打印的在同一行进行显示。

1.基本数据类型

基本数据类型只要都转成字符串显示出来就可以了,无非是使用颜色区分一下:

// /public/console/index.js
// ...
window.parent.postMessage({    type: 'console',    method,    data: args.map((item) => {// 对每个要打印的数据进行处理        return handleData(item)    })})
// ...
// 处理数据const handleData = (content) => {    let contentType = type(content)    switch (contentType) {        case 'boolean': // 布尔值            content = content ? 'true' : 'false'            break;        case 'null': // null            content = 'null'            break;        case 'undefined': // undefined            content = 'undefined'            break;        case 'symbol': // Symbol,Symbol不能直接通过postMessage进行传递,会报错,需要转成字符串            content = content.toString()            break;        default:            break;    }    return {        contentType,        content,    }}
// 日志列表const logList = ref([])
// 监听iframe信息window.addEventListener('message', ({ data = {} }) => {  if (data.type === 'console')     logList.value.push({      type: data.method,// console的方法名      data: data.data// 要显示的信息,一个数组,可能同时打印多条信息    })  }})
<div class="logBox">    <div class="logRow" v-for="(log, index) in logList" :key="index">        <template v-for="(logItem, itemIndex) in log.data" :key="itemIndex">            <!-- 基本数据类型 -->            <div class="logItem message" :class="[logItem.contentType]" v-html="logItem.content"></div>        </template>    </div></div>

2.函数

函数只要调用toString方法转成字符串即可:

const handleData = (content) => {        let contentType = type(content)        switch (contentType) {            // ...            case 'function':                content = content.toString()                break;            default:                break;        }    }

3.json数据

json数据需要格式化后进行显示,也就是带高亮、带缩进,以及支持展开收缩。

实现也很简单,高亮可以通过css类名控制,缩进换行可以使用div和span来包裹,具体实现就是像深拷贝一样深度优先遍历json树,对象或数组的话就使用一个div来整体包裹,这样可以很方便的实现整体缩进。

具体到对象或数组的某项时也使用div来实现换行,需要注意的是如果是作为对象的某个属性的值的话,需要使用span来和属性及冒号显示在同一行,此外,也要考虑到循环引用的情况。

展开收缩时针对非空的对象和数组,所以可以在遍历下级属性之前添加一个按钮元素,按钮相对于最外层元素使用绝对定位。

const handleData = (content) => {    let contentType = type(content)    switch (contentType) {            // ...        case 'array': // 数组        case 'object': // 对象            content = stringify(content, false, true, [])            break;        default:            break;    }}
// 序列化json数据变成html字符串/*     data:数据    hasKey:是否是作为一个key的属性值    isLast:是否在所在对象或数组中的最后一项    visited:已经遍历过的对象/数组,用来检测循环引用*/const stringify = (data, hasKey, isLast, visited) => {    let contentType = type(data)    let str = ''    let len = 0    let lastComma = isLast ? '' : ',' // 当数组或对象在最后一项时,不需要显示逗号    switch (contentType) {        case 'object': // 对象            // 检测到循环引用就直接终止遍历            if (visited.includes(data)) {                str += `<span class="string">检测到循环引用</span>`            } else {                visited.push(data)                let keys = Object.keys(data)                len = keys.length                // 空对象                if (len <= 0) {                    // 如果该对象是作为某个属性的值的话,那么左括号要和key显示在同一行                    str += hasKey ? `<span class="bracket">{ }${lastComma}</span>` : `<div class="bracket">{ }${lastComma}</div>`                } else { // 非空对象                    // expandBtn是展开和收缩按钮                    str += `<span class="el-icon-arrow-right expandBtn"></span>`                    str += hasKey ? `<span class="bracket">{</span>` : '<div class="bracket">{</div>'                    // 这个wrap的div用来实现展开和收缩功能                    str += '<div class="wrap">'                    // 遍历对象的所有属性                    keys.forEach((key, index) => {                        // 是否是数组或对象                        let childIsJson = ['object', 'array'].includes(type(data[key]))                        // 最后一项不显示逗号                        str += `                            <div class="objectItem">                                <span class="key">\"${key}\"</span>                                <span class="colon">:</span>                                ${stringify(data[key], true, index >= len - 1, visited)}${index < len - 1 && !childIsJson ? ',' : ''}                            </div>`                    })                    str += '</div>'                    str += `<div class="bracket">}${lastComma}</div>`                }            }            break;        case 'array': // 数组            if (visited.includes(data)) {                str += `<span class="string">检测到循环引用</span>`            } else {                visited.push(data)                len = data.length                // 空数组                if (len <= 0) {                    // 如果该数组是作为某个属性的值的话,那么左括号要和key显示在同一行                    str += hasKey ? `<span class="bracket">[ ]${lastComma}</span>` : `<div class="bracket">[ ]${lastComma}</div>`                } else { // 非空数组                    str += `<span class="el-icon-arrow-right expandBtn"></span>`                    str += hasKey ? `<span class="bracket">[</span>` : '<div class="bracket">[</div>'                    str += '<div class="wrap">'                    data.forEach((item, index) => {                        // 最后一项不显示逗号                        str += `                            <div class="arrayItem">                              ${stringify(item, true, index >= len - 1, visited)}${index < len - 1 ? ',' : ''}                            </div>`                    })                    str += '</div>'                    str += `<div class="bracket">]${lastComma}</div>`                }            }            break;        default: // 其他类型            let res = handleData(data)            let quotationMarks = res.contentType === 'string' ? '\"' : '' // 字符串添加双引号            str += `<span class="${res.contentType}">${quotationMarks}${res.content}${quotationMarks}</span>`            break;    }    return str}

模板部分也增加一下对json数据的支持:

<template v-for="(logItem, itemIndex) in log.data" :key="itemIndex">    <!-- json对象 -->    <div         class="logItem json"         v-if="['object', 'array'].includes(logItem.contentType)"         v-html="logItem.content"         ></div>    <!-- 字符串、数字 --></template>

最后对不同的类名写一下样式即可,效果如下:

展开收缩按钮的点击事件我们使用事件代理的方式绑定到外层元素上:

<div     class="logItem json"     v-if="['object', 'array'].includes(logItem.contentType)"     v-html="logItem.content"     @click="jsonClick"     ></div>

点击展开收缩按钮的时候根据当前的展开状态来决定是展开还是收缩,展开和收缩操作的是wrap元素的高度,收缩时同时插入一个省略号的元素来表示此处存在收缩,同时因为按钮使用绝对定位,脱离了正常文档流。

所以也需要手动控制它的显示与隐藏,需要注意的是要能区分哪些按钮是本次可以操作的,否则可能下级是收缩状态,但是上层又把该按钮显示出来了:

// 在子元素里找到有指定类名的第一个元素const getChildByClassName = (el, className) => {  let children = el.children  for (let i = 0; i < children.length; i++) {    if (children[i].classList.contains(className)) {      return children[i]    }  }  return null}
// json数据展开收缩let expandIndex = 0const jsonClick = (e) => {  // 点击是展开收缩按钮  if (e.target && e.target.classList.contains('expandBtn')) {    let target = e.target    let parent = target.parentNode    // id,每个展开收缩按钮唯一的标志    let index = target.getAttribute('data-index')    if (index === null) {      index = expandIndex++      target.setAttribute('data-index', index)    }    // 获取当前状态,0表示收缩、1表示展开    let status = target.getAttribute('expand-status') || '1'    // 在子节点里找到wrap元素    let wrapEl = getChildByClassName(parent, 'wrap')    // 找到下层所有的按钮节点    let btnEls = wrapEl.querySelectorAll('.expandBtn')    // 收缩状态 -> 展开状态    if (status === '0') {      // 设置状态为展开      target.setAttribute('expand-status', '1')      // 展开      wrapEl.style.height = 'auto'      // 按钮箭头旋转      target.classList.remove('shrink')      // 移除省略号元素      let ellipsisEl = getChildByClassName(parent, 'ellipsis')      parent.removeChild(ellipsisEl)      // 显示下级展开收缩按钮      for (let i = 0; i < btnEls.length; i++) {        let _index = btnEls[i].getAttribute('data-for-index')        // 只有被当前按钮收缩的按钮才显示        if (_index === index) {          btnEls[i].removeAttribute('data-for-index')          btnEls[i].style.display = 'inline-block'        }      }    } else if (status === '1') {      // 展开状态 -> 收缩状态      target.setAttribute('expand-status', '0')      wrapEl.style.height = 0      target.classList.add('shrink')      let ellipsisEl = document.createElement('div')      ellipsisEl.textContent = '...'      ellipsisEl.className = 'ellipsis'      parent.insertBefore(ellipsisEl, wrapEl)      for (let i = 0; i < btnEls.length; i++) {        let _index = btnEls[i].getAttribute('data-for-index')        // 只隐藏当前可以被隐藏的按钮        if (_index === null) {          btnEls[i].setAttribute('data-for-index', index)          btnEls[i].style.display = 'none'        }      }    }  }}

效果如下:

4.console对象的其他方法

console对象有些方法是有特定逻辑的,比如console.assert(expression, message),只有当express表达式为false时才会打印message,又比如console的一些方法支持占位符等,这些都得进行相应的支持,先修改一下console拦截的逻辑:

ProxyConsole.prototype[method] = function (...args) {     // 发送信息给父窗口     // 针对特定方法进行参数预处理     let res = handleArgs(method, args)     // 没有输出时就不发送信息     if (res.args) {         window.parent.postMessage({             type: 'console',             method: res.method,             data: res.args.map((item) => {                 return handleData(item)             })         })     }     // 调用原始方法     originMethod.apply(ProxyConsole, args) }

增加了handleArgs方法来对特定的方法进行参数处理,比如assert方法:

const handleArgs = (method, contents) => {    switch (method) {        // 只有当第一个参数为false,才会输出第二个参数,否则不会有任何结果        case 'assert':            if (contents[0]) {                contents = null            } else {                method = 'error'                contents = ['Assertion failed: ' + (contents[1] || 'console.assert')]            }            break;        default:            break;    }    return {        method,        args: contents    }}

再看一下占位符的处理,占位符描述如下:

可以判断第一个参数是否是字符串,以及是否包含占位符,如果包含了,那么就判断是什么占位符,然后取出后面对应位置的参数进行格式化,没有用到的参数也不能丢弃,仍然需要显示:

const handleArgs = (method, contents) => {        // 处理占位符        if (contents.length > 0) {            if (type(contents[0]) === 'string') {                // 只处理%s、%d、%i、%f、%c                let match = contents[0].match(/(%[sdifc])([^%]*)/gm) // "%d年%d月%d日" -> ["%d年", "%d月", "%d日"]                if (match) {                    // 后续参数                    let sliceArgs = contents.slice(1)                    let strList = []                    // 遍历匹配到的结果                    match.forEach((item, index) => {                        let placeholder = item.slice(0, 2)                        let arg = sliceArgs[index]                        // 对应位置没有数据,那么就原样输出占位符                        if (arg === undefined) {                            strList.push(item)                            return                        }                        let newStr = ''                        switch (placeholder) {                            // 字符串,此处为简单处理,实际和chrome控制台的输出有差异                            case '%s':                                newStr = String(arg) + item.slice(2)                                break;                                // 整数                            case '%d':                            case '%i':                                newStr = (type(arg) === 'number' ? parseInt(arg) : 'NaN') + item.slice(2)                                break;                                // 浮点数                            case '%f':                                newStr = (type(arg) === 'number' ? arg : 'NaN') + item.slice(2)                                break;                                // 样式                            case '%c':                                newStr = `<span style="${arg}">${item.slice(2)}</span>`                                break;                            default:                                break;                        }                        strList.push(newStr)                    })                    contents = strList                    // 超出占位数量的剩余参数也不能丢弃,需要展示                    if (sliceArgs.length > match.length) {                        contents = contents.concat(sliceArgs.slice(match.length))                       }                }            }        }        // 处理方法 ...        switch (method) {}}

效果如下:

报错信息

报错信息上文已经涉及到了,我们对js代码使用try catch进行了包裹,并使用console.error进行错误输出。

但是还有些错误可能是try catch监听不到的,比如定时器代码执行出错,或者是没有被显式捕获的Promise异常,我们也需要加上对应的监听及显示。

// /public/console/index.js
// 错误监听window.onerror = function (message, source, lineno, colno, error) {    window.parent.postMessage({        type: 'console',        method: 'string',        data: [message, source, lineno, colno, error].map((item) => {            return handleData(item)        })    })}window.addEventListener('unhandledrejection', err => {    window.parent.postMessage({        type: 'console',        method: 'string',        data: [handleData(err.reason.stack)]    })})
// ...

执行输入的js

console的最后一个功能是可以输入js代码然后动态执行,这个可以使用eval方法,eval能动态执行js代码并返回最后一个表达式的值,eval会带来一些安全风险,但是笔者没有找到更好的替代方案,知道的朋友请在下方留言一起探讨吧。

动态执行的代码里的输出以及最后表达式的值我们也要显示到控制台里,为了不在上层拦截console,我们把动态执行代码的功能交给预览的iframe,执行完后再把最后的表达式的值使用console打印一下,这样所有的输出都能显示到控制台。

<textarea v-model="jsInput" @keydown.enter="implementJs"></textarea>
const jsInput = ref('')const implementJs = (e) => {    // shift+enter为换行,不需要执行    if (e.shiftKey) {        return    }    e.preventDefault()    let code = jsInput.value.trim()    if (code) {        // 给iframe发送信息        iframeRef.value.contentWindow.postMessage({            type: 'command',            data: code        })        jsInput.value = ''    }}
// /public/console/index.js
// 接收代码执行的事件const onMessage = ({ data = {} }) => {    if (data.type === 'command') {        try {            // 打印一下要执行的代码             console.log(data.data)            // 使用eval执行代码            console.log(eval(data.data))        } catch (error) {            console.error('js执行出错')            console.error(error)        }    }}window.addEventListener('message', onMessage)

效果如下:

支持预处理器

除了基本的html、js和css,作为一个强大的工具,我们有必要支持一下常用的预处理器,比如html的pug,js的TypeScript及css的less等,实现思路相当简单,加载对应预处理器的转换器,然后转换一下即可。

动态切换编辑器语言

Monaco Editor想要动态修改语言的话我们需要换一种方式来设置文档,上文我们是创建编辑器的同时直接把语言通过language选项传递进去的,然后使用setValue来设置文档内容,这样后期无法再动态修改语言,我们修改为切换文档模型的方式:

// 创建编辑器editor = monaco.editor.create(editorEl.value, {    minimap: {        enabled: false, // 关闭小地图    },    wordWrap: 'on', // 代码超出换行    theme: 'vs-dark', // 主题    fontSize: 18,    fontFamily: 'MonoLisa, monospace',})// 更新编辑器文档模型 const updateDoc = (code, language) => {  if (!editor) {    return  }  // 获取当前的文档模型  let oldModel = editor.getModel()  // 创建一个新的文档模型  let newModel = monaco.editor.createModel(code, language)  // 设置成新的  editor.setModel(newModel)  // 销毁旧的模型  if (oldModel) {    oldModel.dispose()  }}

加载转换器

转换器的文件我们都放在/public/parses/文件夹下,然后进行动态加载,即选择了某个预处理器后再去加载对应的转换器资源,这样可以节省不必要的请求。

异步加载js我们使用loadjs这个小巧的库,新增一个load.js:

// 记录加载状态const preprocessorLoaded = {    html: true,    javascript: true,    css: true,    less: false,    scss: false,    sass: false,    stylus: false,    postcss: false,    pug: false,    babel: false,    typescript: false}
// 某个转换器需要加载多个文件const resources = {    postcss: ['postcss-cssnext', 'postcss']}
// 异步加载转换器的js资源export const load = (preprocessorList) => {    // 过滤出没有加载过的资源    let notLoaded = preprocessorList.filter((item) => {        return !preprocessorLoaded[item]    })    if (notLoaded.length <= 0) {        return    }    return new Promise((resolve, reject) => {        // 生成加载资源的路径        let jsList = []        notLoaded.forEach((item) => {            let _resources = (resources[item] || [item]).map((r) => {                return `/parses/${r}.js`            })            jsList.push(..._resources)        })        loadjs(jsList, {            returnPromise: true        }).then(() => {            notLoaded.forEach((item) => {                preprocessorLoaded[item] = true            })            resolve()        }).catch((err) => {            reject(err)        })    })}

然后修改一下上文预览部分的run方法:

const run = async () => {  let h = editData.value.code.HTML.language  let j = editData.value.code.JS.language  let c = editData.value.code.CSS.language  await load([h, j, c])  // ...}

转换

所有代码都使用转换器转换一下,因为有的转换器是同步方式的,有的是异步方式的,所以我们统一使用异步来处理,修改一下run方法:

const run = async () => {  // ...  await load([h, j, c])  let htmlTransform = transform.html(h, editData.value.code.HTML.content)  let jsTransform = transform.js(j, editData.value.code.JS.content)  let cssTransform = transform.css(c, editData.value.code.CSS.content)  Promise.all([htmlTransform, jsTransform, cssTransform])    .then(([htmlStr, jsStr, cssStr]) => {      // ...    })    .catch((error) => {      // ...    })}

接下来就是最后的转换操作,下面只展示部分代码,完整代码有兴趣的可查看源码:

// transform.js
const html = (preprocessor, code) => {    return new Promise((resolve, reject) => {        switch (preprocessor) {            case 'html':                // html的话原封不动的返回                resolve(code)                break;            case 'pug':                // 调用pug的api来进行转换                resolve(window.pug.render(code))            default:                resolve('')                break;        }    })}
const js = (preprocessor, code) => {    return new Promise((resolve, reject) => {        let _code = ''        switch (preprocessor) {            case 'javascript':                resolve(code)                break;            case 'babel':                // 调用babel的api来编译,你可以根据需要设置presets                _code = window.Babel.transform(code, {                    presets: [                        'es2015',                        'es2016',                        'es2017',                        'react'                    ]                }).code                resolve(_code)            default:                resolve('')                break;        }    })}
const css = (preprocessor, code) => {    return new Promise((resolve, reject) => {        switch (preprocessor) {            case 'css':                resolve(code)                break;            case 'less':                window.less.render(code)                    .then(                        (output) => {                            resolve(output.css)                        },                        (error) => {                            reject(error)                      }                  );                break;            default:                resolve('')                break;        }    })}

可以看到很简单,就是调一下相关转换器的api来转换一下,不过想要找到这些转换器的浏览器使用版本和api可太难了,笔者基本都没找到,所以这里的大部分代码都是参考codepan的。

其他功能

另外还有一些实现起来简单,但是能很大提升用户体验的功能,比如添加额外的css或js资源,免去手写link或script标签的麻烦:

预设一些常用模板,比如vue3、react等,方便快速开始,免去写基本结构的麻烦:

有没有更快的方法

如果你看到这里,你一定会说这是哪门子快速搭建,那有没有更快的方法呢,当然有了,就是直接克隆本项目的仓库或者codepan,改改就可以使用啦~

结尾

本文从零开始介绍了如何搭建一个代码在线编辑预览的工具,粗糙实现总有不足之处,欢迎指出。

手把手教你快速搭建一个代码在线编辑预览工具相关推荐

  1. 厉害了,手把手教你搭建一个代码在线编辑预览工具

    点击下方"前端开发博客",选择"设为星标" 回复"2"加入前端群 简介 大家好,我是一个闲着没事热衷于重复造轮子的不知名前端,今天给大家带来 ...

  2. 搭建一个代码在线编辑预览工具

    本文主要介绍的是第二类其中的一种实现方式,完全不依赖于后端,所有逻辑都在前端完成,实现起来相当简单,使用的是vue3全家桶来开发,使用其他框架也完全可以. ps.在本文基础上笔者开发了一个完整的线上工 ...

  3. vue实现浏览器代码在线编辑预览

    效果图如下: 右边可输入代码, 左边可时时查看效果 实现如下 参照以下 CodeSandbox 演示 使用 codeopen.vue 组件: <!-- slug是codeopen上对应的url ...

  4. 手把手教你如何搭建一个自己的安卓快速开发框架之带你做自己的APP(二)

    ####点击查看上一篇文章:手把手教你如何搭建一个自己的安卓快速开发框架之BaseActivity(一) 继上一篇我实现了基本的BaseActivity,包含 ToolBar 透明状态栏 生命周期监控 ...

  5. 手把手教你快速搭建私服环境

    手把手教你快速搭建私服环境,简单实用,一看就懂 1.准备工作:先下载Nxus Nexus 是 Maven 仓库管理器, 通过 nexus 可以搭建 maven 仓库,同时 nexus 还提供强大的仓库 ...

  6. 手把手教你快速搭建 EOS 主网见证人节点(BP)

    EOS主网启动至今已经超过一个月,然而截至当前,注册成为EOS Block Producer(大家习惯称为见证人)的账号仅有393个,活跃的EOS BP节点更是仅有376个,远远不如EOS主网上线前我 ...

  7. 手把手教你快速构建一个企业自有“微信”

    超链接实验室,是融云策划推出的 IT 系列直播课,携手行业专家,一起聊聊 IT 国产化.协同办公通信.通信中台.企业数字化的那些事儿.关注[融云 RongCloud],了解协同办公平台更多干货. 后疫 ...

  8. 手把手教你快速搭建个人博客 Hexo + Github

    平时学习查找资料发现了很多个人博客,搭建的很不错,一直想抽空自己也动手实践一下,正好趁着新型冠状肺炎这段宅在家的空,赶紧搭建一下自己的个人博客 先来预览一下博主的个人博客:Fly's Blog 动手能 ...

  9. 手把手教你快速创建一个超高性价比弹性云服务器

    近些年来,我们能明显的感受到物价.尤其是房价上涨的迹象.本来就不容易鼓起来的钱袋子,在高昂的房价压力下显得更加瘪了.但你听过网络的"房屋空间"吗?即云服务器,近些年却在不断降低成本 ...

最新文章

  1. R语言构建xgboost模型:指定特征交互方式、单调性约束的特征、获取模型中的最终特征交互形式(interaction and monotonicity constraints)
  2. Python中多层List展平为一层
  3. PowerDesigner导出
  4. 一年的第几周怎么算_部编版一年级下册第7课《怎么都快乐》图文讲解+知识点梳理...
  5. Redis list(列表)
  6. Java堆空间,本机堆和内存问题
  7. java语法糖效率高吗_打包 Java将持续向“高糖”方向发展,你真的了解Java语法糖吗? _好机友...
  8. 素材诊断分析助手_资深优化师告诉你广告投放素材都在哪找?(国内篇)
  9. MCCSframework 教程(四)表单
  10. 【通信仿真】基于matlab STAP全自由度空时自适应处理【含Matlab源码 1956期】
  11. 优化理论10----约束优化的罚函数法、外点法(Penalty method)、内点法(**Barrier Methods**)、混合惩罚函数法
  12. idea中MySQL数据库分页
  13. 斐讯k2怎么设置虚拟服务器,设置斐讯K2路由器上网连接教程 | 192路由网
  14. fulisha-English
  15. 机器学习——《西瓜书》
  16. word 文档如何加密
  17. 基于JAVA计算机类专业考研交流学习平台计算机毕业设计源码+数据库+lw文档+系统+部署
  18. 解决you-get下载速度慢 B站 bilibili
  19. 微信小程序--计算器demo实现
  20. seek()函数与tell()函数

热门文章

  1. 数据库系统概论(第五版)重点总结,期末考试也可以用
  2. IOS Constraints自动布局适应不同尺寸
  3. 【蓝牙CC2541】调试蓝牙收发功能
  4. 一款超方便超强大的16进制编辑器软件-HxD
  5. js将图片或者文件转成base64格式的两种方法
  6. Android模拟7段LED数码管文字显示,光标定位
  7. inet_pton和inet_ntop函数的使用
  8. 关于软件测试的MySQL基础
  9. 陶哲轩实分析:数学家对于R和Q集可数性的探究
  10. JavaScript专精系列(6)——FileReader 文件读取