简介

大家好,我是一个闲着没事热衷于反复造轮子的不出名前端,明天给大家带来的是一个代码在线编辑预览工具的实现介绍,目前这类工具应用很宽泛,常见于各种文档网站及代码分享场景,相干工具也比拟多,如codepen、jsrun、codesandbox、jsbin、plnkr、jsfiddle等,这些工具大体分两类,一类能够自在增加多个文件,比拟像咱们平时应用的编辑器,另一类固定只能独自编辑htmljscss,第二类比拟常见,对于demo场景来说其实曾经够用,当然,说的只是表象,底层实现形式可能还是各有千秋的。

本文次要介绍的是第二类其中的一种实现形式,齐全不依赖于后端,所有逻辑都在前端实现,实现起来相当简略,应用的是vue3全家桶来开发,应用其余框架也齐全能够。

ps.在本文根底上笔者开发了一个残缺的线上工具,带云端保留,地址:http://lxqnsys.com/code-run/,欢送应用。

页面构造

我挑了一个比拟典型也比拟难看的构造来仿照,默认布局高低分成四局部,工具栏、编辑器、预览区域及控制台,编辑器又分为三局部,别离是HTMLCSSJavaScript,其实就是三个编辑器,用来编辑代码。

各局部都能够拖动进行调节大小,比方按住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.vueDragItem通过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())})

预览

代码有了,接下来就能够渲染页面进行预览了,对于预览,显然是应用iframeiframe除了src属性外,HTML5还新增了一个属性srcdoc,用来渲染一段HTML代码到iframe里,这个属性IE目前不反对,不过vue3都要不反对IE了,咱也不论了,如果硬要反对也简略,应用write办法就行了:

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

接下来的思路就很清晰了,把htmlcssjs代码组装起来扔给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类名管制,缩进换行能够应用divspan来包裹,具体实现就是像深拷贝一样深度优先遍历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)

成果如下:

反对预处理器

除了根本的htmljscss,作为一个弱小的工具,咱们有必要反对一下罕用的预处理器,比方htmlpugjsTypeScriptcssless等,实现思路相当简略,加载对应预处理器的转换器,而后转换一下即可。

动静切换编辑器语言

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.jsconst 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的。

其余性能

另外还有一些实现起来简略,然而能很大晋升用户体验的性能,比方增加额定的cssjs资源,免去手写linkscript标签的麻烦:

预设一些罕用模板,比方vue3react等,不便疾速开始,免去写根本构造的麻烦:

有没有更快的办法

如果你看到这里,你肯定会说这是哪门子疾速搭建,那有没有更快的办法呢,当然有了,就是间接克隆本我的项目的仓库或者codepan,改改就能够应用啦~

结尾

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

我的项目仓库code-run,欢送star