快速搭建一個(gè)代碼在線編輯預(yù)覽工具
點(diǎn)擊上方 前端Q,關(guān)注公眾號(hào)
回復(fù)加群,加入前端Q技術(shù)交流群
簡(jiǎn)介
大家好,我是一個(gè)閑著沒(méi)事熱衷于重復(fù)造輪子的不知名前端,今天給大家?guī)?lái)的是一個(gè)代碼在線編輯預(yù)覽工具的實(shí)現(xiàn)介紹,目前這類工具使用很廣泛,常見(jiàn)于各種文檔網(wǎng)站及代碼分享場(chǎng)景,相關(guān)工具也比較多,如codepen、jsrun、codesandbox、jsbin、plnkr、jsfiddle等,這些工具大體分兩類,一類可以自由添加多個(gè)文件,比較像我們平常使用的編輯器,另一類固定只能單獨(dú)編輯html、js、css,第二類比較常見(jiàn),對(duì)于demo場(chǎng)景來(lái)說(shuō)其實(shí)已經(jīng)夠用,當(dāng)然,說(shuō)的只是表象,底層實(shí)現(xiàn)方式可能還是各有千秋的。
本文主要介紹的是第二類其中的一種實(shí)現(xiàn)方式,完全不依賴于后端,所有邏輯都在前端完成,實(shí)現(xiàn)起來(lái)相當(dāng)簡(jiǎn)單,使用的是vue3全家桶來(lái)開(kāi)發(fā),使用其他框架也完全可以。
ps.在本文基礎(chǔ)上筆者開(kāi)發(fā)了一個(gè)完整的線上工具,帶云端保存,地址:lxqnsys.com/code-run/,歡迎使用。
頁(yè)面結(jié)構(gòu)

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

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

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

其他幾個(gè)常用的api如下:
// 設(shè)置文檔內(nèi)容
editor.setValue(props.content)
// 監(jiān)聽(tīng)編輯事件
editor.onDidChangeModelContent((e) => {
console.log(editor.getValue())// 獲取文檔內(nèi)容
})
// 監(jiān)聽(tīng)失焦事件
editor.onDidBlurEditorText((e) => {
console.log(editor.getValue())
})
復(fù)制代碼
預(yù)覽
代碼有了,接下來(lái)就可以渲染頁(yè)面進(jìn)行預(yù)覽了,對(duì)于預(yù)覽,顯然是使用iframe,iframe除了src屬性外,HTML5還新增了一個(gè)屬性srcdoc,用來(lái)渲染一段HTML代碼到iframe里,這個(gè)屬性IE目前不支持,不過(guò)vue3都要不支持IE了,咱也不管了,如果硬要支持也簡(jiǎn)單,使用write方法就行了:
iframeRef.value.contentWindow.document.write(htmlStr)
復(fù)制代碼
接下來(lái)的思路就很清晰了,把html、css和js代碼組裝起來(lái)扔給srcdoc不就完了嗎:
<iframe class="iframe" :srcdoc="srcdoc"></iframe>
復(fù)制代碼
const assembleHtml = (head, body) => {
return `<!DOCTYPE html>
<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
}
復(fù)制代碼
效果如下:

為了防止js代碼運(yùn)行出現(xiàn)錯(cuò)誤阻塞頁(yè)面渲染,我們把js代碼使用try catch包裹起來(lái):
let body = `
${editData.value.code.html.content}
<script>
try {
${editData.value.code.javascript.content}
} catch (err) {
console.error('js代碼運(yùn)行出錯(cuò)')
console.error(err)
}
<\/script>
`
復(fù)制代碼
控制臺(tái)
極簡(jiǎn)方式
先介紹一種非常簡(jiǎn)單的方式,使用一個(gè)叫eruda的庫(kù),這個(gè)庫(kù)是用來(lái)方便在手機(jī)上進(jìn)行調(diào)試的,和vConsole類似,我們直接把它嵌到iframe里就可以支持控制臺(tái)的功能了,要嵌入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
}
復(fù)制代碼
效果如下:

這種方式的缺點(diǎn)是只能嵌入到iframe里,不能把控制臺(tái)和頁(yè)面分開(kāi),導(dǎo)致每次代碼重新運(yùn)行,控制臺(tái)也會(huì)重新運(yùn)行,無(wú)法保留之前的日志,當(dāng)然,樣式也不方便控制。
自己實(shí)現(xiàn)
如果選擇自己實(shí)現(xiàn)的話,那么這部分會(huì)是本項(xiàng)目里最復(fù)雜的,自己實(shí)現(xiàn)的話一般只實(shí)現(xiàn)一個(gè)console的功能,其他的比如html結(jié)構(gòu)、請(qǐng)求資源之類的就不做了,畢竟實(shí)現(xiàn)起來(lái)費(fèi)時(shí)費(fèi)力,用處也不是很大。
console大體上要支持輸出兩種信息,一是console對(duì)象打印出來(lái)的信息,二是各種報(bào)錯(cuò)信息,先看console信息。
console信息
思路很簡(jiǎn)單,在iframe里攔截console對(duì)象的所有方法,當(dāng)某個(gè)方法被調(diào)用時(shí)使用postMessage來(lái)向父頁(yè)面?zhèn)鬟f信息,父頁(yè)面的控制臺(tái)打印出對(duì)應(yīng)的信息即可。
// /public/console/index.js
// 重寫(xiě)的console對(duì)象的構(gòu)造函數(shù),直接修改console對(duì)象的方法進(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對(duì)象
window.console = new ProxyConsole()
復(fù)制代碼
把這個(gè)文件也嵌入到iframe里:
const run = () => {
let head = `
<title>預(yù)覽<\/title>
<style type="text/css">
${editData.value.code.css.content}
<\/style>
<script src="/console/index.js"><\/script>
`
// ...
}
復(fù)制代碼
父頁(yè)面監(jiān)聽(tīng)message事件即可:
window.addEventListener('message', (e) => {
console.log(e)
})
復(fù)制代碼
如果如下:

監(jiān)聽(tīng)獲取到了信息就可以顯示出來(lái),我們一步步來(lái)看:
首先console的方法都可以同時(shí)接收多個(gè)參數(shù),打印多個(gè)數(shù)據(jù),同時(shí)打印的在同一行進(jìn)行顯示。
1.基本數(shù)據(jù)類型
基本數(shù)據(jù)類型只要都轉(zhuǎn)成字符串顯示出來(lái)就可以了,無(wú)非是使用顏色區(qū)分一下:
// /public/console/index.js
// ...
window.parent.postMessage({
type: 'console',
method,
data: args.map((item) => {// 對(duì)每個(gè)要打印的數(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': // null
content = 'null'
break;
case 'undefined': // undefined
content = 'undefined'
break;
case 'symbol': // Symbol,Symbol不能直接通過(guò)postMessage進(jìn)行傳遞,會(huì)報(bào)錯(cuò),需要轉(zhuǎn)成字符串
content = content.toString()
break;
default:
break;
}
return {
contentType,
content,
}
}
復(fù)制代碼
// 日志列表
const logList = ref([])
// 監(jiān)聽(tīng)iframe信息
window.addEventListener('message', ({ data = {} }) => {
if (data.type === 'console')
logList.value.push({
type: data.method,// console的方法名
data: data.data// 要顯示的信息,一個(gè)數(shù)組,可能同時(shí)打印多條信息
})
}
})
復(fù)制代碼
<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>
復(fù)制代碼

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

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

4.console對(duì)象的其他方法
console對(duì)象有些方法是有特定邏輯的,比如console.assert(expression, message),只有當(dāng)express表達(dá)式為false時(shí)才會(huì)打印message,又比如console的一些方法支持占位符等,這些都得進(jìn)行相應(yīng)的支持,先修改一下console攔截的邏輯:
ProxyConsole.prototype[method] = function (...args) {
// 發(fā)送信息給父窗口
// 針對(duì)特定方法進(jìn)行參數(shù)預(yù)處理
let res = handleArgs(method, args)
// 沒(méi)有輸出時(shí)就不發(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)
}
復(fù)制代碼
增加了handleArgs方法來(lái)對(duì)特定的方法進(jìn)行參數(shù)處理,比如assert方法:
const handleArgs = (method, contents) => {
switch (method) {
// 只有當(dāng)?shù)谝粋€(gè)參數(shù)為false,才會(huì)輸出第二個(gè)參數(shù),否則不會(huì)有任何結(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
}
}
復(fù)制代碼
再看一下占位符的處理,占位符描述如下:

可以判斷第一個(gè)參數(shù)是否是字符串,以及是否包含占位符,如果包含了,那么就判斷是什么占位符,然后取出后面對(duì)應(yīng)位置的參數(shù)進(jìn)行格式化,沒(méi)有用到的參數(shù)也不能丟棄,仍然需要顯示:
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) {
// 后續(xù)參數(shù)
let sliceArgs = contents.slice(1)
let strList = []
// 遍歷匹配到的結(jié)果
match.forEach((item, index) => {
let placeholder = item.slice(0, 2)
let arg = sliceArgs[index]
// 對(duì)應(yīng)位置沒(méi)有數(shù)據(jù),那么就原樣輸出占位符
if (arg === undefined) {
strList.push(item)
return
}
let newStr = ''
switch (placeholder) {
// 字符串,此處為簡(jiǎn)單處理,實(shí)際和chrome控制臺(tái)的輸出有差異
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;
// 浮點(diǎn)數(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) {}
}
復(fù)制代碼
效果如下:

報(bào)錯(cuò)信息
報(bào)錯(cuò)信息上文已經(jīng)涉及到了,我們對(duì)js代碼使用try catch進(jìn)行了包裹,并使用console.error進(jìn)行錯(cuò)誤輸出,但是還有些錯(cuò)誤可能是try catch監(jiān)聽(tīng)不到的,比如定時(shí)器代碼執(zhí)行出錯(cuò),或者是沒(méi)有被顯式捕獲的Promise異常,我們也需要加上對(duì)應(yīng)的監(jiān)聽(tīng)及顯示。
// /public/console/index.js
// 錯(cuò)誤監(jiān)聽(tīng)
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)]
})
})
// ...
復(fù)制代碼
執(zhí)行輸入的js
console的最后一個(gè)功能是可以輸入js代碼然后動(dòng)態(tài)執(zhí)行,這個(gè)可以使用eval方法,eval能動(dòng)態(tài)執(zhí)行js代碼并返回最后一個(gè)表達(dá)式的值,eval會(huì)帶來(lái)一些安全風(fēng)險(xiǎn),但是筆者沒(méi)有找到更好的替代方案,知道的朋友請(qǐng)?jiān)谙路搅粞砸黄鹛接懓伞?/p>
動(dòng)態(tài)執(zhí)行的代碼里的輸出以及最后表達(dá)式的值我們也要顯示到控制臺(tái)里,為了不在上層攔截console,我們把動(dòng)態(tài)執(zhí)行代碼的功能交給預(yù)覽的iframe,執(zhí)行完后再把最后的表達(dá)式的值使用console打印一下,這樣所有的輸出都能顯示到控制臺(tái)。
<textarea v-model="jsInput" @keydown.enter="implementJs"></textarea>
復(fù)制代碼
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 = ''
}
}
復(fù)制代碼
// /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í)行出錯(cuò)')
console.error(error)
}
}
}
window.addEventListener('message', onMessage)
復(fù)制代碼
效果如下:

支持預(yù)處理器
除了基本的html、js和css,作為一個(gè)強(qiáng)大的工具,我們有必要支持一下常用的預(yù)處理器,比如html的pug,js的TypeScript及css的less等,實(shí)現(xiàn)思路相當(dāng)簡(jiǎn)單,加載對(duì)應(yīng)預(yù)處理器的轉(zhuǎn)換器,然后轉(zhuǎn)換一下即可。
動(dòng)態(tài)切換編輯器語(yǔ)言
Monaco Editor想要?jiǎng)討B(tài)修改語(yǔ)言的話我們需要換一種方式來(lái)設(shè)置文檔,上文我們是創(chuàng)建編輯器的同時(shí)直接把語(yǔ)言通過(guò)language選項(xiàng)傳遞進(jìn)去的,然后使用setValue來(lái)設(shè)置文檔內(nèi)容,這樣后期無(wú)法再動(dòng)態(tài)修改語(yǔ)言,我們修改為切換文檔模型的方式:
// 創(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)建一個(gè)新的文檔模型
let newModel = monaco.editor.createModel(code, language)
// 設(shè)置成新的
editor.setModel(newModel)
// 銷毀舊的模型
if (oldModel) {
oldModel.dispose()
}
}
復(fù)制代碼
加載轉(zhuǎn)換器
轉(zhuǎn)換器的文件我們都放在/public/parses/文件夾下,然后進(jìn)行動(dòng)態(tài)加載,即選擇了某個(gè)預(yù)處理器后再去加載對(duì)應(yīng)的轉(zhuǎn)換器資源,這樣可以節(jié)省不必要的請(qǐng)求。
異步加載js我們使用loadjs這個(gè)小巧的庫(kù),新增一個(gè)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
}
// 某個(gè)轉(zhuǎn)換器需要加載多個(gè)文件
const resources = {
postcss: ['postcss-cssnext', 'postcss']
}
// 異步加載轉(zhuǎn)換器的js資源
export const load = (preprocessorList) => {
// 過(guò)濾出沒(méi)有加載過(guò)的資源
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)
})
})
}
復(fù)制代碼
然后修改一下上文預(yù)覽部分的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])
// ...
}
復(fù)制代碼
轉(zhuǎn)換
所有代碼都使用轉(zhuǎn)換器轉(zhuǎn)換一下,因?yàn)橛械霓D(zhuǎn)換器是同步方式的,有的是異步方式的,所以我們統(tǒng)一使用異步來(lái)處理,修改一下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) => {
// ...
})
}
復(fù)制代碼
接下來(lái)就是最后的轉(zhuǎn)換操作,下面只展示部分代碼,完整代碼有興趣的可查看源碼:
// transform.js
const html = (preprocessor, code) => {
return new Promise((resolve, reject) => {
switch (preprocessor) {
case 'html':
// html的話原封不動(dòng)的返回
resolve(code)
break;
case 'pug':
// 調(diào)用pug的api來(lái)進(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來(lái)編譯,你可以根據(jù)需要設(shè)置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;
}
})
}
復(fù)制代碼
可以看到很簡(jiǎn)單,就是調(diào)一下相關(guān)轉(zhuǎn)換器的api來(lái)轉(zhuǎn)換一下,不過(guò)想要找到這些轉(zhuǎn)換器的瀏覽器使用版本和api可太難了,筆者基本都沒(méi)找到,所以這里的大部分代碼都是參考codepan的。
其他功能
另外還有一些實(shí)現(xiàn)起來(lái)簡(jiǎn)單,但是能很大提升用戶體驗(yàn)的功能,比如添加額外的css或js資源,免去手寫(xiě)link或script標(biāo)簽的麻煩:

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

有沒(méi)有更快的方法
如果你看到這里,你一定會(huì)說(shuō)這是哪門(mén)子快速搭建,那有沒(méi)有更快的方法呢,當(dāng)然有了,就是直接克隆本項(xiàng)目的倉(cāng)庫(kù)或者codepan,改改就可以使用啦~
結(jié)尾
本文從零開(kāi)始介紹了如何搭建一個(gè)代碼在線編輯預(yù)覽的工具,粗糙實(shí)現(xiàn)總有不足之處,歡迎指出。
項(xiàng)目倉(cāng)庫(kù)code-run,歡迎star。
關(guān)于本文
作者:街角小林
https://juejin.cn/post/6965467528600485919
內(nèi)推社群
我組建了一個(gè)氛圍特別好的騰訊內(nèi)推社群,如果你對(duì)加入騰訊感興趣的話(后續(xù)有計(jì)劃也可以),我們可以一起進(jìn)行面試相關(guān)的答疑、聊聊面試的故事、并且在你準(zhǔn)備好的時(shí)候隨時(shí)幫你內(nèi)推。下方加 winty 好友回復(fù)「面試」即可。
