前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >快速搭建一个代码在线编辑预览工具

快速搭建一个代码在线编辑预览工具

作者头像
街角小林
发布于 2022-06-15 00:21:03
发布于 2022-06-15 00:21:03
4.2K00
代码可运行
举报
运行总次数:0
代码可运行

简介

大家好,我是一个闲着没事热衷于重复造轮子的不知名前端,今天给大家带来的是一个代码在线编辑预览工具的实现介绍,目前这类工具使用很广泛,常见于各种文档网站及代码分享场景,相关工具也比较多,如codepenjsruncodesandboxjsbinplnkrjsfiddle等,这些工具大体分两类,一类可以自由添加多个文件,比较像我们平常使用的编辑器,另一类固定只能单独编辑htmljscss,第二类比较常见,对于demo场景来说其实已经够用,当然,说的只是表象,底层实现方式可能还是各有千秋的。

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

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

页面结构

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

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

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

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

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

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

核心代码如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
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绑定的鼠标事件,然后只要根据所需的结构进行组合即可,下面的结构就是上述默认的布局:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
<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文件里添加:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
<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种语言,我们选择中文的,其他不用的可以直接删掉:

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

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
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如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// 设置文档内容
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方法就行了:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
iframeRef.value.contentWindow.document.write(htmlStr)

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

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
<iframe class="iframe" :srcdoc="srcdoc"></iframe>
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
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包裹起来:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
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文件夹下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
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来向父页面传递信息,父页面的控制台打印出对应的信息即可。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// /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里:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
const run = () => {
  let head = `
    <title>预览<\/title>
    <style type="text/css">
        ${editData.value.code.css.content}
    <\/style>
    <script src="/console/index.js"><\/script>
  `
  // ...
}

父页面监听message事件即可:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
window.addEventListener('message', (e) => {
  console.log(e)
})

如果如下:

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

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

1.基本数据类型

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

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// /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,
    }
}
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// 日志列表
const logList = ref([])

// 监听iframe信息
window.addEventListener('message', ({ data = {} }) => {
  if (data.type === 'console') 
    logList.value.push({
      type: data.method,// console的方法名
      data: data.data// 要显示的信息,一个数组,可能同时打印多条信息
    })
  }
})
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
<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方法转成字符串即可:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
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来和属性及冒号显示在同一行,此外,也要考虑到循环引用的情况。

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

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
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数据的支持:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
<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>

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

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

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
<div
     class="logItem json"
     v-if="['object', 'array'].includes(logItem.contentType)"
     v-html="logItem.content"
     @click="jsonClick"
     >
</div>

点击展开收缩按钮的时候根据当前的展开状态来决定是展开还是收缩,展开和收缩操作的是wrap元素的高度,收缩时同时插入一个省略号的元素来表示此处存在收缩,同时因为按钮使用绝对定位,脱离了正常文档流,所以也需要手动控制它的显示与隐藏,需要注意的是要能区分哪些按钮是本次可以操作的,否则可能下级是收缩状态,但是上层又把该按钮显示出来了:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// 在子元素里找到有指定类名的第一个元素
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 = 0
const 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拦截的逻辑:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
 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方法:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
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
    }
}

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

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

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
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异常,我们也需要加上对应的监听及显示。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// /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打印一下,这样所有的输出都能显示到控制台。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
<textarea v-model="jsInput" @keydown.enter="implementJs"></textarea>
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
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 = ''
    }
}
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// /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来设置文档内容,这样后期无法再动态修改语言,我们修改为切换文档模型的方式:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// 创建编辑器
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

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// 记录加载状态
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 方法:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
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方法:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
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) => {
      // ...
    })
}

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

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// 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的。

其他功能

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

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

有没有更快的方法

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

结尾

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

项目仓库code-run,欢迎star

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2022-06-15,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
暂无评论
推荐阅读
编辑精选文章
换一批
快速搭建一个代码在线编辑预览工具(实战)
大家好,我是一个闲着没事热衷于重复造轮子的不知名前端,今天给大家带来的是一个代码在线编辑预览工具的实现介绍,目前这类工具使用很广泛,常见于各种文档网站及代码分享场景,相关工具也比较多,如codepen、jsrun、codesandbox、jsbin、plnkr、jsfiddle等,这些工具大体分两类,一类可以自由添加多个文件,比较像我们平常使用的编辑器,另一类固定只能单独编辑html、js、css,第二类比较常见,对于demo场景来说其实已经够用,当然,说的只是表象,底层实现方式可能还是各有千秋的。
coder_koala
2021/07/08
4.5K0
快速搭建一个代码在线编辑预览工具(实战)
从零开始实现一个简单的低代码编辑器
低代码编辑器作为一种能够极大地提升开发效率的PaaS软件,近些年来一直收到各大公司以及各路投资方的追捧。而对于我们前端开发者来说,编辑器也是为数不多的拥有较深前端技术深度的开发场景。
Tecvan
2022/09/02
1.7K0
从零开始实现一个简单的低代码编辑器
站在巨人的肩膀上--用VUE3试试搞个在线IDE吧!
然,生那受苦的命,闲不住啊,领下军令状,重构单位单位的组件库使用的在线代码编辑IDE
用户7413032
2022/08/30
1.6K0
React Playground 实现原理揭秘
在 textarea 输入内容,设置默认值 defaultValue,用 useRef 获取它的 value。
神说要有光zxg
2024/04/30
3080
React Playground 实现原理揭秘
Vue实现在线文档预览
本次实现的文档预览的类型有:docx, xlsx, pptx, pdf,以及纯文本、代码文件和各种图片、视频格式的在线预览
不愿意做鱼的小鲸鱼
2023/07/09
4.3K0
Vue实现在线文档预览
Vue 基于vue-codemirror实现的代码编辑器
element-ui 2.13.1 (使用到element-ui message组件,提示错误消息,如果不想安装该组件,替换编辑器中的this.$message所在行函数代码即可)
授客
2020/07/15
11.1K1
Vue 基于vue-codemirror实现的代码编辑器
如何写一个代码编辑器
学无止境,我们现在的技术都是基于前一代人之上做出来的,要想成为一个高级/资深的前端,重点不是你能创造多少东西,而是你知道多少东西。有没有解决问题的能力。遇到问题能不能找到最优解,为公司减少成本的同时提升效率。系统性的解决问题,提高代码的维护性、稳定性、可扩展行等等。所以现代社会是一个认知的社会,只有不断的突破自己的认知,才能够成为更优秀的人。
用户4131414
2020/03/19
1.8K0
基于 vite2 + Vue3 写一个在线帮助文档工具
VuePress 是“静态网站生成器”,需要我们自行编写文档,然后交给VuePress变成网站,VuePress 并没有提供编写环境,我知道有很多编写 Markdown 的方式,但是我还是喜欢编写、浏览合为“一体”的方式。
用户1174620
2022/05/09
1.4K0
一起来做一个json格式化工具吧
说到json格式化你肯定很熟悉,毕竟压缩后的json数据基本不可读,为了方便查看,我们可以在编辑器中可以通过插件一键格式化,也可以通过一些在线工具来美化,当然,有时在开发中也会遇到json格式化的需求,有很多开源库或组件能我们解决这个问题,不过并不妨碍我们自己实现一个。
街角小林
2023/07/09
4570
一起来做一个json格式化工具吧
如何手动解析vue单文件并预览?
笔者之前的文章里介绍过一个代码在线编辑预览工具的实现,实现了css、html、js的编辑,但是对于demo场景来说,vue单文件也是一个比较好的代码组织方式,至少笔者就经常在写vue项目的同时顺便写写各种demo,但是分享不太方便,因为单文件不能直接运行看效果,基于此,笔者决定在之前的基础上再增加一个vue单文件的编辑及预览功能。
街角小林
2022/03/21
1.4K0
如何手动解析vue单文件并预览?
monaco-editor做自己的代码测试工具
本文说的是如何通过monaco-editor实现一个类似于codepen一样的在线代码测试工具。
牛老师讲GIS
2020/03/12
2.1K0
实现一个 Code Pen:(三)10 行代码实现代码格式化
在上文中,我们使用 monaco-editor 结合 Next.js,打造了编辑器的功能,在本文中,我们将继续优化 monaco-editor, 使它拥有代码格式化的功能。
狂奔滴小马
2022/05/13
1.7K0
实现一个 Code Pen:(三)10 行代码实现代码格式化
Vue-html5-editor 编辑器的使用及一些问题解决
近期由于需要对公司运营系统进行优化和升级,而原有后台系统所使用的vue-quill-editor编辑器对粘贴进来的内容的行内样式全部进行了过滤,虽然这样可以防止XSS攻击,但是却完全无法满足业务需要,为此对编辑器进行了更换,采用Vue-html5-editor 这个编辑器。
越陌度阡
2022/05/06
1.6K0
Vue-html5-editor 编辑器的使用及一些问题解决
手摸手打造类码上掘金在线IDE(二)——编辑器篇
在上一篇的文章中,我们详细介绍了 在线IDE的优劣势, 市面上的在线IDE种类,IDE的大致的实现方式,以及简单的实现原理
用户7413032
2022/11/11
2.9K0
手摸手打造类码上掘金在线IDE(二)——编辑器篇
实现一个 Code Pen:(四)浏览器编译代码
前面的文章中,我们配置好了编辑器,实现了 css、html、js 的编辑,现在我们需要做代码实时运行的功能了,并且可以直接写 less、scss、可以写 JavaScript、typescript、react。这个就涉及到了浏览器编译代码的逻辑,前期我们编译语言少一点、先把整体流程跑通,后面可以对语言和功能再慢慢丰富,这也是做项目的主要思路。
狂奔滴小马
2022/05/13
1K0
实现一个 Code Pen:(四)浏览器编译代码
javaweb项目集成editor.md编辑器—markdown编辑器
最近写一个博客系统,需要集成编辑器,所以我就打算集成一个markdown编辑器用来编辑文章。在网上搜,好像editor.md这个国产开源的markdown编辑器比较好。不过想吐槽是网上的教程真的。。。哈哈哈,开始我们的教程: 1.首先在editor.md官网(http://pandao.github.io/editor.md/)下载这一个编辑器。 这是下载好后的文件目录
全栈程序员站长
2022/09/30
1.7K0
javaweb项目集成editor.md编辑器—markdown编辑器
基于slate构建文档编辑器
slate.js是一个完全可定制的框架,用于构建富文本编辑器,在这里我们使用slate.js构建专注于文档编辑的富文本编辑器。
WindRunnerMax
2022/06/27
1.1K0
如何评价Dooring低代码/零代码搭建平台?
大家好, 我是徐小夕, 之前一直在分享可视化低代码的一些实践, 围绕 H5-Dooring 零代码搭建平台也输出了很多技术文章, 最近2.7.0 版本也顺利迭代完成, 这里详细分享一下 H5-Dooring 无代码搭建平台技术方案.
徐小夕
2022/09/27
1.2K0
如何评价Dooring低代码/零代码搭建平台?
我熬夜开发了一款简约实用、支持多平台的Markdown在线编辑器(开源)
之前,一直想开发一款属于自己的Markdown编辑器,主要是自己平常写文章可以更加灵活操作,另外扩宽自己的视野也是非常不错的选择啊!所以在周末就决定玩耍一番。首先我调研了很多线上热门的md编辑器,都很优秀。不为超过他们,主要自己用着舒服点。这篇文章主要是记录下我是如何从0到1是完成一款还算拿得出手的Markdown编辑器。
Vam的金豆之路
2021/12/01
9790
我熬夜开发了一款简约实用、支持多平台的Markdown在线编辑器(开源)
vconsole助力实现在线代码编辑调试
前面有文章monaco-editor做自己的代码测试工具 ,本文书接前文,在代码中加入vconsole工具,可以进行代码调试、查看网络、查看元素等。
牛老师讲GIS
2024/05/24
2180
vconsole助力实现在线代码编辑调试
推荐阅读
相关推荐
快速搭建一个代码在线编辑预览工具(实战)
更多 >
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档