手把手教你快速搭建一個代碼在線編輯預(yù)覽工具

來源 | https://www.cnblogs.com/wanglinmantan/p/15091390.html
簡介
頁面結(jié)構(gòu)

我挑了一個比較典型也比較好看的結(jié)構(gòu)來仿照,默認(rèn)布局上下分成四部分,工具欄、編輯器、預(yù)覽區(qū)域及控制臺,編輯器又分為三部分,分別是HTML、CSS、JavaScript,其實就是三個編輯器,用來編輯代碼。
各部分都可以拖動進(jìn)行調(diào)節(jié)大小,比如按住js編輯器左邊的灰色豎條向右拖動,那么js編輯器的寬度會減少,同時css編輯器的寬度會增加,如果向左拖動,那么css編輯器寬度會減少,js編輯器的寬度會增加,當(dāng)css編輯器寬度已經(jīng)不能再減少的時候css編輯器也會同時向左移,然后減少html的寬度。
在實現(xiàn)上,水平調(diào)節(jié)寬度和垂直調(diào)節(jié)高度原理是一樣的,以調(diào)節(jié)寬度為例,三個編輯器的寬度使用一個數(shù)組來維護(hù),用百分比來表示,那么初始就是100/3%,然后每個編輯器都有一個拖動條,位于內(nèi)部的左側(cè),那么當(dāng)按住拖動某個拖動條拖動時的邏輯如下:
1.把本次拖動瞬間的偏移量由像素轉(zhuǎn)換為百分比;
2.如果是向左拖動的話,檢測本次拖動編輯器的左側(cè)是否存在還有空間可以壓縮的編輯器,沒有的話代表不能進(jìn)行拖動;如果有的話,那么拖動時增加本次拖動編輯器的寬度,同時減少找到的第一個有空間的編輯器的寬度,直到無法再繼續(xù)拖動;
3.如果是向右拖動的話,檢測本次拖動編輯器及其右側(cè)是否存在還有空間可以壓縮的編輯器,沒有的話也代表不能再拖動,如果有的話,找到第一個并減少該編輯器的寬度,同時增加本次拖動編輯器左側(cè)第一個編輯器的寬度;
核心代碼如下:
const onDrag = (index, e) => {let client = this._dir === 'v' ? e.clientY : e.clientX// 本次移動的距離let dx = client - this._last// 換算成百分比let rx = (dx / this._containerSize) * 100// 更新上一次的鼠標(biāo)位置this._last = clientif (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 {// 否則本次能移動的距離為到達(dá)最小寬度的距離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)}}}}}
實現(xiàn)效果如下:

為了能提供多種布局的隨意切換,我們有必要把上述邏輯封裝一下,封裝成兩個組件,一個容器組件Drag.vue,一個容器的子組件DragItem.vue,DragItem通過slot來顯示其他內(nèi)容,DragItem主要提供拖動條及綁定相關(guān)的鼠標(biāo)事件,Drag組件里包含了上述提到的核心邏輯,維護(hù)對應(yīng)的尺寸數(shù)組,提供相關(guān)處理方法給DragItem綁定的鼠標(biāo)事件,然后只要根據(jù)所需的結(jié)構(gòu)進(jìn)行組合即可,下面的結(jié)構(gòu)就是上述默認(rèn)的布局:
<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="預(yù)覽"><Preview></Preview></DragItem><DragItem :index="2" :disabled="false" title="控制臺"><Console></Console></DragItem></Drag>
這部分代碼較多,有興趣的可以查看源碼。
編輯器
目前涉及到代碼編輯的場景基本使用的都是codemirror,因為它功能強大,使用簡單,支持語法高亮、支持多種語言和主題等。
但是為了能更方便的支持語法提示,本文選擇的是微軟的monaco-editor,功能和VSCode一樣強大,VSCode有多強就不用我多說了,缺點是整體比較復(fù)雜,代碼量大,內(nèi)置主題較少。
monaco-editor支持多種加載方式,esm模塊加載的方式需要使用webpack,但是vite底層打包工具用的是Rollup,所以本文使用直接引入js的方式。
在官網(wǎng)上下載壓縮包后解壓到項目的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'// 使用中文語言,默認(rèn)為英文}}};</script><script src="/monaco-editor/min/vs/loader.js"></script><script src="/monaco-editor/min/vs/editor/editor.main.js"></script>
monaco-editor內(nèi)置了10種語言,我們選擇中文的,其他不用的可以直接刪掉:

接下來創(chuàng)建編輯器就可以了:
const editor = monaco.editor.create(editorEl.value,// dom容器{value: props.content,// 要顯示的代碼language: props.language,// 代碼語言,css、javascript等minimap: {enabled: false,// 關(guān)閉小地圖},wordWrap: 'on', // 代碼超出換行theme: 'vs-dark'// 主題})
就這么簡單,一個帶高亮、語法提示、錯誤提示的編輯器就可以使用了,效果如下:

其他幾個常用的api如下:
// 設(shè)置文檔內(nèi)容editor.setValue(props.content)// 監(jiān)聽編輯事件editor.onDidChangeModelContent((e) => {console.log(editor.getValue())// 獲取文檔內(nèi)容})// 監(jiān)聽失焦事件editor.onDidBlurEditorText((e) => {console.log(editor.getValue())})
預(yù)覽
代碼有了,接下來就可以渲染頁面進(jìn)行預(yù)覽了,對于預(yù)覽,顯然是使用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 `<html><head><meta charset="UTF-8" />${head}</head><body>${body}</body></html>`}const run = () => {let head = `<title>預(yù)覽<\/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代碼運行出現(xiàn)錯誤阻塞頁面渲染,我們把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的庫,這個庫是用來方便在手機上進(jìn)行調(diào)試的,和vConsole類似,我們直接把它嵌到iframe里就可以支持控制臺的功能了,要嵌入iframe里的文件我們都要放到public文件夾下:
const run = () => {let head = `<title>預(yù)覽<\/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里,不能把控制臺和頁面分開,導(dǎo)致每次代碼重新運行,控制臺也會重新運行,無法保留之前的日志,當(dāng)然,樣式也不方便控制。
自己實現(xiàn)
如果選擇自己實現(xiàn)的話,那么這部分會是本項目里最復(fù)雜的,自己實現(xiàn)的話一般只實現(xiàn)一個console的功能,其他的比如html結(jié)構(gòu)、請求資源之類的就不做了,畢竟實現(xiàn)起來費時費力,用處也不是很大。
console大體上要支持輸出兩種信息,一是console對象打印出來的信息,二是各種報錯信息,先看console信息。
console信息
思路很簡單,在iframe里攔截console對象的所有方法,當(dāng)某個方法被調(diào)用時使用postMessage來向父頁面?zhèn)鬟f信息,父頁面的控制臺打印出對應(yīng)的信息即可。
// /public/console/index.js// 重寫的console對象的構(gòu)造函數(shù),直接修改console對象的方法進(jìn)行攔截的方式是不行的,有興趣可以自行嘗試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]// 設(shè)置原型方法ProxyConsole.prototype[method] = function (...args) {// 發(fā)送信息給父窗口window.parent.postMessage({type: 'console',method,data: args})// 調(diào)用原始方法originMethod.apply(ProxyConsole, args)}})// 覆蓋原console對象window.console = new ProxyConsole()
把這個文件也嵌入到iframe里:
const run = () => {let head = `<title>預(yù)覽<\/title><style type="text/css">${editData.value.code.css.content}<\/style><script src="/console/index.js"><\/script>`// ...}
父頁面監(jiān)聽message事件即可:
window.addEventListener('message', (e) => {console.log(e)})
如果如下:

監(jiān)聽獲取到了信息就可以顯示出來,我們一步步來看:
首先console的方法都可以同時接收多個參數(shù),打印多個數(shù)據(jù),同時打印的在同一行進(jìn)行顯示。
1.基本數(shù)據(jù)類型
基本數(shù)據(jù)類型只要都轉(zhuǎn)成字符串顯示出來就可以了,無非是使用顏色區(qū)分一下:
// /public/console/index.js// ...window.parent.postMessage({type: 'console',method,data: args.map((item) => {// 對每個要打印的數(shù)據(jù)進(jìn)行處理return handleData(item)})})// ...// 處理數(shù)據(jù)const handleData = (content) => {let contentType = type(content)switch (contentType) {case 'boolean': // 布爾值content = content ? 'true' : 'false'break;case 'null': // nullcontent = 'null'break;case 'undefined': // undefinedcontent = 'undefined'break;case 'symbol': // Symbol,Symbol不能直接通過postMessage進(jìn)行傳遞,會報錯,需要轉(zhuǎn)成字符串content = content.toString()break;default:break;}return {contentType,content,}}
// 日志列表const logList = ref([])// 監(jiān)聽iframe信息window.addEventListener('message', ({ data = {} }) => {if (data.type === 'console')logList.value.push({type: data.method,// console的方法名data: data.data// 要顯示的信息,一個數(shù)組,可能同時打印多條信息})}})
<div class="logBox"><div class="logRow" v-for="(log, index) in logList" :key="index"><template v-for="(logItem, itemIndex) in log.data" :key="itemIndex"><!-- 基本數(shù)據(jù)類型 --><div class="logItem message" :class="[logItem.contentType]" v-html="logItem.content"></div></template></div></div>

2.函數(shù)
函數(shù)只要調(diào)用toString方法轉(zhuǎn)成字符串即可:
const handleData = (content) => {let contentType = type(content)switch (contentType) {// ...case 'function':content = content.toString()break;default:break;}}
3.json數(shù)據(jù)
json數(shù)據(jù)需要格式化后進(jìn)行顯示,也就是帶高亮、帶縮進(jìn),以及支持展開收縮。
實現(xiàn)也很簡單,高亮可以通過css類名控制,縮進(jìn)換行可以使用div和span來包裹,具體實現(xiàn)就是像深拷貝一樣深度優(yōu)先遍歷json樹,對象或數(shù)組的話就使用一個div來整體包裹,這樣可以很方便的實現(xiàn)整體縮進(jìn)。
具體到對象或數(shù)組的某項時也使用div來實現(xiàn)換行,需要注意的是如果是作為對象的某個屬性的值的話,需要使用span來和屬性及冒號顯示在同一行,此外,也要考慮到循環(huán)引用的情況。
展開收縮時針對非空的對象和數(shù)組,所以可以在遍歷下級屬性之前添加一個按鈕元素,按鈕相對于最外層元素使用絕對定位。
const handleData = (content) => {let contentType = type(content)switch (contentType) {// ...case 'array': // 數(shù)組case 'object': // 對象content = stringify(content, false, true, [])break;default:break;}}// 序列化json數(shù)據(jù)變成html字符串/*data:數(shù)據(jù)hasKey:是否是作為一個key的屬性值isLast:是否在所在對象或數(shù)組中的最后一項visited:已經(jīng)遍歷過的對象/數(shù)組,用來檢測循環(huán)引用*/const stringify = (data, hasKey, isLast, visited) => {let contentType = type(data)let str = ''let len = 0let lastComma = isLast ? '' : ',' // 當(dāng)數(shù)組或?qū)ο笤谧詈笠豁棔r,不需要顯示逗號switch (contentType) {case 'object': // 對象// 檢測到循環(huán)引用就直接終止遍歷if (visited.includes(data)) {str += `<span class="string">檢測到循環(huán)引用</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用來實現(xiàn)展開和收縮功能str += '<div class="wrap">'// 遍歷對象的所有屬性keys.forEach((key, index) => {// 是否是數(shù)組或?qū)ο?/span>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': // 數(shù)組if (visited.includes(data)) {str += `<span class="string">檢測到循環(huán)引用</span>`} else {visited.push(data)len = data.length// 空數(shù)組if (len <= 0) {// 如果該數(shù)組是作為某個屬性的值的話,那么左括號要和key顯示在同一行str += hasKey ? `<span class="bracket">[ ]${lastComma}</span>` : `<div class="bracket">[ ]${lastComma}</div>`} else { // 非空數(shù)組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數(shù)據(jù)的支持:
<template v-for="(logItem, itemIndex) in log.data" :key="itemIndex"><!-- json對象 --><divclass="logItem json"v-if="['object', 'array'].includes(logItem.contentType)"v-html="logItem.content"></div><!-- 字符串、數(shù)字 --></template>
最后對不同的類名寫一下樣式即可,效果如下:

展開收縮按鈕的點擊事件我們使用事件代理的方式綁定到外層元素上:
<divclass="logItem json"v-if="['object', 'array'].includes(logItem.contentType)"v-html="logItem.content"@click="jsonClick"></div>
點擊展開收縮按鈕的時候根據(jù)當(dāng)前的展開狀態(tài)來決定是展開還是收縮,展開和收縮操作的是wrap元素的高度,收縮時同時插入一個省略號的元素來表示此處存在收縮,同時因為按鈕使用絕對定位,脫離了正常文檔流。
所以也需要手動控制它的顯示與隱藏,需要注意的是要能區(qū)分哪些按鈕是本次可以操作的,否則可能下級是收縮狀態(tài),但是上層又把該按鈕顯示出來了:
// 在子元素里找到有指定類名的第一個元素const getChildByClassName = (el, className) => {let children = el.childrenfor (let i = 0; i < children.length; i++) {if (children[i].classList.contains(className)) {return children[i]}}return null}// json數(shù)據(jù)展開收縮let expandIndex = 0const jsonClick = (e) => {// 點擊是展開收縮按鈕if (e.target && e.target.classList.contains('expandBtn')) {let target = e.targetlet parent = target.parentNode// id,每個展開收縮按鈕唯一的標(biāo)志let index = target.getAttribute('data-index')if (index === null) {index = expandIndex++target.setAttribute('data-index', index)}// 獲取當(dāng)前狀態(tài),0表示收縮、1表示展開let status = target.getAttribute('expand-status') || '1'// 在子節(jié)點里找到wrap元素let wrapEl = getChildByClassName(parent, 'wrap')// 找到下層所有的按鈕節(jié)點let btnEls = wrapEl.querySelectorAll('.expandBtn')// 收縮狀態(tài) -> 展開狀態(tài)if (status === '0') {// 設(shè)置狀態(tài)為展開target.setAttribute('expand-status', '1')// 展開wrapEl.style.height = 'auto'// 按鈕箭頭旋轉(zhuǎn)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')// 只有被當(dāng)前按鈕收縮的按鈕才顯示if (_index === index) {btnEls[i].removeAttribute('data-for-index')btnEls[i].style.display = 'inline-block'}}} else if (status === '1') {// 展開狀態(tài) -> 收縮狀態(tài)target.setAttribute('expand-status', '0')wrapEl.style.height = 0target.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')// 只隱藏當(dāng)前可以被隱藏的按鈕if (_index === null) {btnEls[i].setAttribute('data-for-index', index)btnEls[i].style.display = 'none'}}}}}
效果如下:

4.console對象的其他方法
console對象有些方法是有特定邏輯的,比如console.assert(expression, message),只有當(dāng)express表達(dá)式為false時才會打印message,又比如console的一些方法支持占位符等,這些都得進(jìn)行相應(yīng)的支持,先修改一下console攔截的邏輯:
ProxyConsole.prototype[method] = function (...args) {// 發(fā)送信息給父窗口// 針對特定方法進(jìn)行參數(shù)預(yù)處理let res = handleArgs(method, args)// 沒有輸出時就不發(fā)送信息if (res.args) {window.parent.postMessage({type: 'console',method: res.method,data: res.args.map((item) => {return handleData(item)})})}// 調(diào)用原始方法originMethod.apply(ProxyConsole, args)}
增加了handleArgs方法來對特定的方法進(jìn)行參數(shù)處理,比如assert方法:
const handleArgs = (method, contents) => {switch (method) {// 只有當(dāng)?shù)谝粋€參數(shù)為false,才會輸出第二個參數(shù),否則不會有任何結(jié)果case 'assert':if (contents[0]) {contents = null} else {method = 'error'contents = ['Assertion failed: ' + (contents[1] || 'console.assert')]}break;default:break;}return {method,args: contents}}
再看一下占位符的處理,占位符描述如下:

可以判斷第一個參數(shù)是否是字符串,以及是否包含占位符,如果包含了,那么就判斷是什么占位符,然后取出后面對應(yīng)位置的參數(shù)進(jìn)行格式化,沒有用到的參數(shù)也不能丟棄,仍然需要顯示:
const handleArgs = (method, contents) => {// 處理占位符if (contents.length > 0) {if (type(contents[0]) === 'string') {// 只處理%s、%d、%i、%f、%clet match = contents[0].match(/(%[sdifc])([^%]*)/gm) // "%d年%d月%d日" -> ["%d年", "%d月", "%d日"]if (match) {// 后續(xù)參數(shù)let sliceArgs = contents.slice(1)let strList = []// 遍歷匹配到的結(jié)果match.forEach((item, index) => {let placeholder = item.slice(0, 2)let arg = sliceArgs[index]// 對應(yīng)位置沒有數(shù)據(jù),那么就原樣輸出占位符if (arg === undefined) {strList.push(item)return}let newStr = ''switch (placeholder) {// 字符串,此處為簡單處理,實際和chrome控制臺的輸出有差異case '%s':newStr = String(arg) + item.slice(2)break;// 整數(shù)case '%d':case '%i':newStr = (type(arg) === 'number' ? parseInt(arg) : 'NaN') + item.slice(2)break;// 浮點數(shù)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// 超出占位數(shù)量的剩余參數(shù)也不能丟棄,需要展示if (sliceArgs.length > match.length) {contents = contents.concat(sliceArgs.slice(match.length))}}}}// 處理方法 ...switch (method) {}}
效果如下:

報錯信息
報錯信息上文已經(jīng)涉及到了,我們對js代碼使用try catch進(jìn)行了包裹,并使用console.error進(jìn)行錯誤輸出。
但是還有些錯誤可能是try catch監(jiān)聽不到的,比如定時器代碼執(zhí)行出錯,或者是沒有被顯式捕獲的Promise異常,我們也需要加上對應(yīng)的監(jiān)聽及顯示。
// /public/console/index.js// 錯誤監(jiān)聽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)]})})// ...
執(zhí)行輸入的js
console的最后一個功能是可以輸入js代碼然后動態(tài)執(zhí)行,這個可以使用eval方法,eval能動態(tài)執(zhí)行js代碼并返回最后一個表達(dá)式的值,eval會帶來一些安全風(fēng)險,但是筆者沒有找到更好的替代方案,知道的朋友請在下方留言一起探討吧。
動態(tài)執(zhí)行的代碼里的輸出以及最后表達(dá)式的值我們也要顯示到控制臺里,為了不在上層攔截console,我們把動態(tài)執(zhí)行代碼的功能交給預(yù)覽的iframe,執(zhí)行完后再把最后的表達(dá)式的值使用console打印一下,這樣所有的輸出都能顯示到控制臺。
<textarea v-model="jsInput" @keydown.enter="implementJs"></textarea>const jsInput = ref('')const implementJs = (e) => {// shift+enter為換行,不需要執(zhí)行if (e.shiftKey) {return}e.preventDefault()let code = jsInput.value.trim()if (code) {// 給iframe發(fā)送信息iframeRef.value.contentWindow.postMessage({type: 'command',data: code})jsInput.value = ''}}
// /public/console/index.js// 接收代碼執(zhí)行的事件const onMessage = ({ data = {} }) => {if (data.type === 'command') {try {// 打印一下要執(zhí)行的代碼console.log(data.data)// 使用eval執(zhí)行代碼console.log(eval(data.data))} catch (error) {console.error('js執(zhí)行出錯')console.error(error)}}}window.addEventListener('message', onMessage)
效果如下:

支持預(yù)處理器
除了基本的html、js和css,作為一個強大的工具,我們有必要支持一下常用的預(yù)處理器,比如html的pug,js的TypeScript及css的less等,實現(xiàn)思路相當(dāng)簡單,加載對應(yīng)預(yù)處理器的轉(zhuǎn)換器,然后轉(zhuǎn)換一下即可。
動態(tài)切換編輯器語言
Monaco Editor想要動態(tài)修改語言的話我們需要換一種方式來設(shè)置文檔,上文我們是創(chuàng)建編輯器的同時直接把語言通過language選項傳遞進(jìn)去的,然后使用setValue來設(shè)置文檔內(nèi)容,這樣后期無法再動態(tài)修改語言,我們修改為切換文檔模型的方式:
// 創(chuàng)建編輯器editor = monaco.editor.create(editorEl.value, {minimap: {enabled: false, // 關(guān)閉小地圖},wordWrap: 'on', // 代碼超出換行theme: 'vs-dark', // 主題fontSize: 18,fontFamily: 'MonoLisa, monospace',})// 更新編輯器文檔模型const updateDoc = (code, language) => {if (!editor) {return}// 獲取當(dāng)前的文檔模型let oldModel = editor.getModel()// 創(chuàng)建一個新的文檔模型let newModel = monaco.editor.createModel(code, language)// 設(shè)置成新的editor.setModel(newModel)// 銷毀舊的模型if (oldModel) {oldModel.dispose()}}
加載轉(zhuǎn)換器
轉(zhuǎn)換器的文件我們都放在/public/parses/文件夾下,然后進(jìn)行動態(tài)加載,即選擇了某個預(yù)處理器后再去加載對應(yīng)的轉(zhuǎn)換器資源,這樣可以節(jié)省不必要的請求。
異步加載js我們使用loadjs這個小巧的庫,新增一個load.js:
// 記錄加載狀態(tài)const preprocessorLoaded = {html: true,javascript: true,css: true,less: false,scss: false,sass: false,stylus: false,postcss: false,pug: false,babel: false,typescript: false}// 某個轉(zhuǎn)換器需要加載多個文件const resources = {postcss: ['postcss-cssnext', 'postcss']}// 異步加載轉(zhuǎn)換器的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)})})}
然后修改一下上文預(yù)覽部分的run方法:
const run = async () => {let h = editData.value.code.HTML.languagelet j = editData.value.code.JS.languagelet c = editData.value.code.CSS.languageawait load([h, j, c])// ...}
轉(zhuǎn)換
所有代碼都使用轉(zhuǎn)換器轉(zhuǎn)換一下,因為有的轉(zhuǎn)換器是同步方式的,有的是異步方式的,所以我們統(tǒng)一使用異步來處理,修改一下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) => {// ...})}
接下來就是最后的轉(zhuǎn)換操作,下面只展示部分代碼,完整代碼有興趣的可查看源碼:
// transform.jsconst html = (preprocessor, code) => {return new Promise((resolve, reject) => {switch (preprocessor) {case 'html':// html的話原封不動的返回resolve(code)break;case 'pug':// 調(diào)用pug的api來進(jìn)行轉(zhuǎn)換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':// 調(diào)用babel的api來編譯,你可以根據(jù)需要設(shè)置presets_code = window.Babel.transform(code, {presets: ['es2015','es2016','es2017','react']}).coderesolve(_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;}})}
可以看到很簡單,就是調(diào)一下相關(guān)轉(zhuǎn)換器的api來轉(zhuǎn)換一下,不過想要找到這些轉(zhuǎn)換器的瀏覽器使用版本和api可太難了,筆者基本都沒找到,所以這里的大部分代碼都是參考codepan的。
其他功能
另外還有一些實現(xiàn)起來簡單,但是能很大提升用戶體驗的功能,比如添加額外的css或js資源,免去手寫link或script標(biāo)簽的麻煩:

預(yù)設(shè)一些常用模板,比如vue3、react等,方便快速開始,免去寫基本結(jié)構(gòu)的麻煩:

有沒有更快的方法
如果你看到這里,你一定會說這是哪門子快速搭建,那有沒有更快的方法呢,當(dāng)然有了,就是直接克隆本項目的倉庫或者codepan,改改就可以使用啦~
結(jié)尾
本文從零開始介紹了如何搭建一個代碼在線編輯預(yù)覽的工具,粗糙實現(xiàn)總有不足之處,歡迎指出。
感謝你的閱讀。
學(xué)習(xí)更多技能
請點擊下方公眾號
![]()

