当前位置 博文首页 > 前端开发博客:厉害了,手把手教你搭建一个代码在线编辑预览工具

    前端开发博客:厉害了,手把手教你搭建一个代码在线编辑预览工具

    作者:[db:作者] 时间:2021-06-15 09:21

    点击下方“前端开发博客”,选择“设为星标”

    回复“2”加入前端群

    简介

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

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

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

    页面结构

    image-20210427170009062.png

    我挑了一个比较典型也比较好看的结构来仿照,默认布局上下分成四部分,工具栏、编辑器、预览区域及控制台,编辑器又分为三部分,分别是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)
    ????????????????}
    ????????????}
    ????????}
    ????}
    }
    复制代码
    

    实现效果如下:

    2021-04-29-19-15-42.gif

    为了能提供多种布局的随意切换,我们有必要把上述逻辑封装一下,封装成两个组件,一个容器组件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种语言,我们选择中文的,其他不用的可以直接删掉:

    image-20210430163748892.png

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

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

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

    image-20210430154406199.png

    其他几个常用的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
    }
    复制代码
    

    效果如下:

    image-20210507141946844.png

    为了防止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
    }
    复制代码
    

    效果如下:

    image-20210507154345054.png

    这种方式的缺点是只能嵌入到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)
    })
    复制代码
    

    如果如下:

    image-20210507165953197.png

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

    首先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>
    复制代码
    
    image-20210508091625420.png

    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>
    复制代码
    

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

    image-20210508195753623.png

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

    <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?=?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'
    ????????}
    ??????}
    ????}
    ??}
    }
    复制代码
    

    效果如下:

    2021-05-08-20-00-57.gif

    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方法来对特定的方法进行参数处理,比如