天天看點

手把手教你快速搭建一個代碼線上編輯預覽工具

手把手教你快速搭建一個代碼線上編輯預覽工具

簡介

大家好,今天我跟大家分享的是一個代碼線上編輯預覽工具的實作教程,手把手教你完成這樣一個項目。

目前這類工具使用很廣泛,常見于各種文檔網站及代碼分享場景,相關工具也比較多,如codepen、jsrun、codesandbox、jsbin、plnkr、jsfiddle等,這些工具大體分兩類,一類可以自由添加多個檔案,比較像我們平常使用的編輯器;另一類固定隻能單獨編輯html、js、css。

第二類比較常見,對于demo場景來說其實已經夠用,當然,說的隻是表象,底層實作方式可能還是各有千秋的。

本文主要介紹的是第二類其中的一種實作方式,完全不依賴于後端,所有邏輯都在前端完成,實作起來相當簡單,使用的是vue3全家桶來開發,使用其他架構也完全可以。

PS:在本文基礎上筆者開發了一個完整的線上工具,帶雲端儲存,位址:http://lxqnsys.com/code-run/,歡迎使用。

頁面結構

手把手教你快速搭建一個代碼線上編輯預覽工具

我挑了一個比較典型也比較好看的結構來仿照,預設布局上下分成四部分,工具欄、編輯器、預覽區域及控制台,編輯器又分為三部分,分别是HTML、CSS、JavaScript,其實就是三個編輯器,用來編輯代碼。

各部分都可以拖動進行調節大小,比如按住js編輯器左邊的灰色豎條向右拖動,那麼js編輯器的寬度會減少,同時css編輯器的寬度會增加,如果向左拖動,那麼css編輯器寬度會減少,js編輯器的寬度會增加,當css編輯器寬度已經不能再減少的時候css編輯器也會同時向左移,然後減少html的寬度。

在實作上,水準調節寬度和垂直調節高度原理是一樣的,以調節寬度為例,三個編輯器的寬度使用一個數組來維護,用百分比來表示,那麼初始就是100/3%,然後每個編輯器都有一個拖動條,位于内部的左側,那麼當按住拖動某個拖動條拖動時的邏輯如下:

1.把本次拖動瞬間的偏移量由像素轉換為百分比;

2.如果是向左拖動的話,檢測本次拖動編輯器的左側是否存在還有空間可以壓縮的編輯器,沒有的話代表不能進行拖動;如果有的話,那麼拖動時增加本次拖動編輯器的寬度,同時減少找到的第一個有空間的編輯器的寬度,直到無法再繼續拖動;

3.如果是向右拖動的話,檢測本次拖動編輯器及其右側是否存在還有空間可以壓縮的編輯器,沒有的話也代表不能再拖動,如果有的話,找到第一個并減少該編輯器的寬度,同時增加本次拖動編輯器左側第一個編輯器的寬度;

核心代碼如下:

const onDrag = (index, e) => {
    let client = this._dir === 'v' ? e.clientY : e.clientX
    // 本次移動的距離
    let dx = client - this._last
    // 換算成百分比
    let rx = (dx / this._containerSize) * 100
    // 更新上一次的滑鼠位置
    this._last = client
    if (dx < 0) {
        // 向左/上拖動
        if (!this.isCanDrag('leftUp', index)) {
            return
        }
        // 拖動中的編輯器增加寬度
        if (this._dragItemList.value[index][this._prop] - rx < this.getMaxSize(index)) {
            this._dragItemList.value[index][this._prop] -= rx
        } else {
            this._dragItemList.value[index][this._prop] = this.getMaxSize(index)
        }
        // 找到左邊第一個還有空間的編輯器索引
        let narrowItemIndex = this.getFirstNarrowItemIndex('leftUp', index)
        let _minSize = this.getMinSize(narrowItemIndex)
        // 左邊的編輯器要同比減少寬度
        if (narrowItemIndex >= 0) {
            // 加上本次偏移還大于最小寬度
            if (this._dragItemList.value[narrowItemIndex][this._prop] + rx > _minSize) {
                this._dragItemList.value[narrowItemIndex][this._prop] += rx
            } else {
                // 否則固定為最小寬度
                this._dragItemList.value[narrowItemIndex][this._prop] = _minSize
            }
        }
    } else if (dx > 0) {
        // 向右/下拖動
        if (!this.isCanDrag('rightDown', index)) {
            return
        }
        // 找到拖動中的編輯器及其右邊的編輯器中的第一個還有空間的編輯器索引
        let narrowItemIndex = this.getFirstNarrowItemIndex('rightDown', index)
        let _minSize = this.getMinSize(narrowItemIndex)
        if (narrowItemIndex <= this._dragItemList.value.length - 1) {
            let ax = 0
            // 減去本次偏移還大于最小寬度
            if (this._dragItemList.value[narrowItemIndex][this._prop] - rx > _minSize) {
                ax = rx
            } else {
                // 否則本次能移動的距離為到達最小寬度的距離
                ax = this._dragItemList.value[narrowItemIndex][this._prop] - _minSize
            }
            // 更新拖動中的編輯器的寬度
            this._dragItemList.value[narrowItemIndex][this._prop] -= ax
            // 左邊第一個編輯器要同比增加寬度
            if (index > 0) {
                if (this._dragItemList.value[index - 1][this._prop] + ax < this.getMaxSize(index - 1)) {
                    this._dragItemList.value[index - 1][this._prop] += ax
                } else {
                    this._dragItemList.value[index - 1][this._prop] = this.getMaxSize(index - 1)
                }
            }
        }
    }
}      

實作效果如下:

手把手教你快速搭建一個代碼線上編輯預覽工具

為了能提供多種布局的随意切換,我們有必要把上述邏輯封裝一下,封裝成兩個元件,一個容器元件Drag.vue,一個容器的子元件DragItem.vue,DragItem通過slot來顯示其他内容,DragItem主要提供拖動條及綁定相關的滑鼠事件,Drag元件裡包含了上述提到的核心邏輯,維護對應的尺寸數組,提供相關處理方法給DragItem綁定的滑鼠事件,然後隻要根據所需的結構進行組合即可,下面的結構就是上述預設的布局:

<Drag :number="3" dir="v" :config="[{ min: 0 }, null, { min: 48 }]">
    <DragItem :index="0" :disabled="true" :showTouchBar="false">
        <Editor></Editor>
    </DragItem>
    <DragItem :index="1" :disabled="false" title="預覽">
        <Preview></Preview>
    </DragItem>
    <DragItem :index="2" :disabled="false" title="控制台">
        <Console></Console>
    </DragItem>
</Drag>      

這部分代碼較多,有興趣的可以檢視源碼。

編輯器

目前涉及到代碼編輯的場景基本使用的都是codemirror,因為它功能強大,使用簡單,支援文法高亮、支援多種語言和主題等。

但是為了能更友善的支援文法提示,本文選擇的是微軟的monaco-editor,功能和VSCode一樣強大,VSCode有多強就不用我多說了,缺點是整體比較複雜,代碼量大,内置主題較少。

monaco-editor支援多種加載方式,esm子產品加載的方式需要使用webpack,但是vite底層打包工具用的是Rollup,是以本文使用直接引入js的方式。

在官網上下載下傳壓縮包後解壓到項目的public檔案夾下,然後參考示例的方式在index.html檔案裡添加:

<link rel="stylesheet" data-name="vs/editor/editor.main" href="/monaco-editor/min/vs/editor/editor.main.css" />


<script>
    var require = {
        paths: {
            vs: '/monaco-editor/min/vs'
        },
        'vs/nls': {
            availableLanguages: {
                '*': 'zh-cn'// 使用中文語言,預設為英文
            }
        }
    };
</script>
<script src="/monaco-editor/min/vs/loader.js"></script>
<script src="/monaco-editor/min/vs/editor/editor.main.js"></script>      

monaco-editor内置了10種語言,我們選擇中文的,其他不用的可以直接删掉:

手把手教你快速搭建一個代碼線上編輯預覽工具

接下來建立編輯器就可以了:

const editor = monaco.editor.create(
    editorEl.value,// dom容器
    {
        value: props.content,// 要顯示的代碼
        language: props.language,// 代碼語言,css、javascript等
        minimap: {
            enabled: false,// 關閉小地圖
        },
        wordWrap: 'on', // 代碼超出換行
        theme: 'vs-dark'// 主題
    }
)      

就這麼簡單,一個帶高亮、文法提示、錯誤提示的編輯器就可以使用了,效果如下:

手把手教你快速搭建一個代碼線上編輯預覽工具

其他幾個常用的api如下:

// 設定文檔内容
editor.setValue(props.content)
// 監聽編輯事件
editor.onDidChangeModelContent((e) => {
    console.log(editor.getValue())// 擷取文檔内容
})
// 監聽失焦事件
editor.onDidBlurEditorText((e) => {
    console.log(editor.getValue())
})      

預覽

代碼有了,接下來就可以渲染頁面進行預覽了,對于預覽,顯然是使用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 `<!DOCTYPE html>
        <html>
        <head>
            <meta charset="UTF-8" />
            ${head}
        </head>
        <body>
            ${body}
        </body>
        </html>`
}


const run = () => {
  let head = `
    <title>預覽<\/title>
    <style type="text/css">
        ${editData.value.code.css.content}
    <\/style>
  `
  let body = `
    ${editData.value.code.html.content}
    <script>
        ${editData.value.code.javascript.content}
    <\/script>
  `
  let str = assembleHtml(head, body)
  srcdoc.value = str
}      

效果如下:

手把手教你快速搭建一個代碼線上編輯預覽工具

為了防止js代碼運作出現錯誤阻塞頁面渲染,我們把js代碼使用try catch包裹起來:

let body = `
    ${editData.value.code.html.content}
    <script>
        try {
          ${editData.value.code.javascript.content}
        } catch (err) {
          console.error('js代碼運作出錯')
          console.error(err)
        }
    <\/script>
  `      

控制台

極簡方式

先介紹一種非常簡單的方式,使用一個叫eruda的庫,這個庫是用來友善在手機上進行調試的,和vConsole類似,我們直接把它嵌到iframe裡就可以支援控制台的功能了,要嵌入iframe裡的檔案我們都要放到public檔案夾下:

const run = () => {
  let head = `
    <title>預覽<\/title>
    <style type="text/css">
        ${editData.value.code.css.content}
    <\/style>
  `
  let body = `
    ${editData.value.code.html.content}
    <script src="/eruda/eruda.js"><\/script>
    <script>
        eruda.init();
        ${editData.value.code.javascript.content}
    <\/script>
  `
  let str = assembleHtml(head, body)
  srcdoc.value = str
}      

效果如下:

手把手教你快速搭建一個代碼線上編輯預覽工具

這種方式的缺點是隻能嵌入到iframe裡,不能把控制台和頁面分開,導緻每次代碼重新運作,控制台也會重新運作,無法保留之前的日志,當然,樣式也不友善控制。

自己實作

如果選擇自己實作的話,那麼這部分會是本項目裡最複雜的,自己實作的話一般隻實作一個console的功能,其他的比如html結構、請求資源之類的就不做了,畢竟實作起來費時費力,用處也不是很大。

console大體上要支援輸出兩種資訊,一是console對象列印出來的資訊,二是各種報錯資訊,先看console資訊。

console資訊

思路很簡單,在iframe裡攔截console對象的所有方法,當某個方法被調用時使用postMessage來向父頁面傳遞資訊,父頁面的控制台列印出對應的資訊即可。

// /public/console/index.js


// 重寫的console對象的構造函數,直接修改console對象的方法進行攔截的方式是不行的,有興趣可以自行嘗試
function ProxyConsole() {};
// 攔截console的所有方法
[
    'debug',
    'clear',
    'error',
    'info',
    'log',
    'warn',
    'dir',
    'props',
    'group',
    'groupEnd',
    'dirxml',
    'table',
    'trace',
    'assert',
    'count',
    'markTimeline',
    'profile',
    'profileEnd',
    'time',
    'timeEnd',
    'timeStamp',
    'groupCollapsed'
].forEach((method) => {
    let originMethod = console[method]
    // 設定原型方法
    ProxyConsole.prototype[method] = function (...args) {
        // 發送資訊給父視窗
        window.parent.postMessage({
            type: 'console',
            method,
            data: args
        })
        // 調用原始方法
        originMethod.apply(ProxyConsole, args)
    }
})
// 覆寫原console對象
window.console = new ProxyConsole()      

把這個檔案也嵌入到iframe裡:

const run = () => {
  let head = `
    <title>預覽<\/title>
    <style type="text/css">
        ${editData.value.code.css.content}
    <\/style>
    <script src="/console/index.js"><\/script>
  `
  // ...
}      

父頁面監聽message事件即可:

window.addEventListener('message', (e) => {
  console.log(e)
})      

如果如下:

手把手教你快速搭建一個代碼線上編輯預覽工具

監聽擷取到了資訊就可以顯示出來,我們一步步來看:

首先console的方法都可以同時接收多個參數,列印多個資料,同時列印的在同一行進行顯示。

1.基本資料類型

基本資料類型隻要都轉成字元串顯示出來就可以了,無非是使用顔色區分一下:

// /public/console/index.js


// ...


window.parent.postMessage({
    type: 'console',
    method,
    data: args.map((item) => {// 對每個要列印的資料進行處理
        return handleData(item)
    })
})


// ...


// 處理資料
const handleData = (content) => {
    let contentType = type(content)
    switch (contentType) {
        case 'boolean': // 布爾值
            content = content ? 'true' : 'false'
            break;
        case 'null': // null
            content = 'null'
            break;
        case 'undefined': // undefined
            content = 'undefined'
            break;
        case 'symbol': // Symbol,Symbol不能直接通過postMessage進行傳遞,會報錯,需要轉成字元串
            content = content.toString()
            break;
        default:
            break;
    }
    return {
        contentType,
        content,
    }
}      
// 日志清單
const logList = ref([])


// 監聽iframe資訊
window.addEventListener('message', ({ data = {} }) => {
  if (data.type === 'console') 
    logList.value.push({
      type: data.method,// console的方法名
      data: data.data// 要顯示的資訊,一個數組,可能同時列印多條資訊
    })
  }
})      
<div class="logBox">
    <div class="logRow" v-for="(log, index) in logList" :key="index">
        <template v-for="(logItem, itemIndex) in log.data" :key="itemIndex">
            <!-- 基本資料類型 -->
            <div class="logItem message" :class="[logItem.contentType]" v-html="logItem.content"></div>
        </template>
    </div>
</div>      
手把手教你快速搭建一個代碼線上編輯預覽工具

2.函數

函數隻要調用toString方法轉成字元串即可:

const handleData = (content) => {
        let contentType = type(content)
        switch (contentType) {
            // ...
            case 'function':
                content = content.toString()
                break;
            default:
                break;
        }
    }      

3.json資料

json資料需要格式化後進行顯示,也就是帶高亮、帶縮進,以及支援展開收縮。

實作也很簡單,高亮可以通過css類名控制,縮進換行可以使用div和span來包裹,具體實作就是像深拷貝一樣深度優先周遊json樹,對象或數組的話就使用一個div來整體包裹,這樣可以很友善的實作整體縮進。

具體到對象或數組的某項時也使用div來實作換行,需要注意的是如果是作為對象的某個屬性的值的話,需要使用span來和屬性及冒号顯示在同一行,此外,也要考慮到循環引用的情況。

展開收縮時針對非空的對象和數組,是以可以在周遊下級屬性之前添加一個按鈕元素,按鈕相對于最外層元素使用絕對定位。

const handleData = (content) => {
    let contentType = type(content)
    switch (contentType) {
            // ...
        case 'array': // 數組
        case 'object': // 對象
            content = stringify(content, false, true, [])
            break;
        default:
            break;
    }
}


// 序列化json資料變成html字元串
/* 
    data:資料
    hasKey:是否是作為一個key的屬性值
    isLast:是否在所在對象或數組中的最後一項
    visited:已經周遊過的對象/數組,用來檢測循環引用
*/
const stringify = (data, hasKey, isLast, visited) => {
    let contentType = type(data)
    let str = ''
    let len = 0
    let lastComma = isLast ? '' : ',' // 當數組或對象在最後一項時,不需要顯示逗号
    switch (contentType) {
        case 'object': // 對象
            // 檢測到循環引用就直接終止周遊
            if (visited.includes(data)) {
                str += `<span class="string">檢測到循環引用</span>`
            } else {
                visited.push(data)
                let keys = Object.keys(data)
                len = keys.length
                // 空對象
                if (len <= 0) {
                    // 如果該對象是作為某個屬性的值的話,那麼左括号要和key顯示在同一行
                    str += hasKey ? `<span class="bracket">{ }${lastComma}</span>` : `<div class="bracket">{ }${lastComma}</div>`
                } else { // 非空對象
                    // expandBtn是展開和收縮按鈕
                    str += `<span class="el-icon-arrow-right expandBtn"></span>`
                    str += hasKey ? `<span class="bracket">{</span>` : '<div class="bracket">{</div>'
                    // 這個wrap的div用來實作展開和收縮功能
                    str += '<div class="wrap">'
                    // 周遊對象的所有屬性
                    keys.forEach((key, index) => {
                        // 是否是數組或對象
                        let childIsJson = ['object', 'array'].includes(type(data[key]))
                        // 最後一項不顯示逗号
                        str += `
                            <div class="objectItem">
                                <span class="key">\"${key}\"</span>
                                <span class="colon">:</span>
                                ${stringify(data[key], true, index >= len - 1, visited)}${index < len - 1 && !childIsJson ? ',' : ''}
                            </div>`
                    })
                    str += '</div>'
                    str += `<div class="bracket">}${lastComma}</div>`
                }
            }
            break;
        case 'array': // 數組
            if (visited.includes(data)) {
                str += `<span class="string">檢測到循環引用</span>`
            } else {
                visited.push(data)
                len = data.length
                // 空數組
                if (len <= 0) {
                    // 如果該數組是作為某個屬性的值的話,那麼左括号要和key顯示在同一行
                    str += hasKey ? `<span class="bracket">[ ]${lastComma}</span>` : `<div class="bracket">[ ]${lastComma}</div>`
                } else { // 非空數組
                    str += `<span class="el-icon-arrow-right expandBtn"></span>`
                    str += hasKey ? `<span class="bracket">[</span>` : '<div class="bracket">[</div>'
                    str += '<div class="wrap">'
                    data.forEach((item, index) => {
                        // 最後一項不顯示逗号
                        str += `
                            <div class="arrayItem">
                              ${stringify(item, true, index >= len - 1, visited)}${index < len - 1 ? ',' : ''}
                            </div>`
                    })
                    str += '</div>'
                    str += `<div class="bracket">]${lastComma}</div>`
                }
            }
            break;
        default: // 其他類型
            let res = handleData(data)
            let quotationMarks = res.contentType === 'string' ? '\"' : '' // 字元串添加雙引号
            str += `<span class="${res.contentType}">${quotationMarks}${res.content}${quotationMarks}</span>`
            break;
    }
    return str
}      

模闆部分也增加一下對json資料的支援:

<template v-for="(logItem, itemIndex) in log.data" :key="itemIndex">
    <!-- json對象 -->
    <div
         class="logItem json"
         v-if="['object', 'array'].includes(logItem.contentType)"
         v-html="logItem.content"
         ></div>
    <!-- 字元串、數字 -->
</template>      

最後對不同的類名寫一下樣式即可,效果如下:

手把手教你快速搭建一個代碼線上編輯預覽工具

展開收縮按鈕的點選事件我們使用事件代理的方式綁定到外層元素上:

<div
     class="logItem json"
     v-if="['object', 'array'].includes(logItem.contentType)"
     v-html="logItem.content"
     @click="jsonClick"
     >
</div>      

點選展開收縮按鈕的時候根據目前的展開狀态來決定是展開還是收縮,展開和收縮操作的是wrap元素的高度,收縮時同時插入一個省略号的元素來表示此處存在收縮,同時因為按鈕使用絕對定位,脫離了正常文檔流。

是以也需要手動控制它的顯示與隐藏,需要注意的是要能區分哪些按鈕是本次可以操作的,否則可能下級是收縮狀态,但是上層又把該按鈕顯示出來了:

// 在子元素裡找到有指定類名的第一個元素
const getChildByClassName = (el, className) => {
  let children = el.children
  for (let i = 0; i < children.length; i++) {
    if (children[i].classList.contains(className)) {
      return children[i]
    }
  }
  return null
}


// json資料展開收縮
let expandIndex = 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攔截的邏輯:

ProxyConsole.prototype[method] = function (...args) {
     // 發送資訊給父視窗
     // 針對特定方法進行參數預處理
     let res = handleArgs(method, args)
     // 沒有輸出時就不發送資訊
     if (res.args) {
         window.parent.postMessage({
             type: 'console',
             method: res.method,
             data: res.args.map((item) => {
                 return handleData(item)
             })
         })
     }
     // 調用原始方法
     originMethod.apply(ProxyConsole, args)
 }      

增加了handleArgs方法來對特定的方法進行參數處理,比如assert方法:

const handleArgs = (method, contents) => {
    switch (method) {
        // 隻有當第一個參數為false,才會輸出第二個參數,否則不會有任何結果
        case 'assert':
            if (contents[0]) {
                contents = null
            } else {
                method = 'error'
                contents = ['Assertion failed: ' + (contents[1] || 'console.assert')]
            }
            break;
        default:
            break;
    }
    return {
        method,
        args: contents
    }
}      

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

手把手教你快速搭建一個代碼線上編輯預覽工具

可以判斷第一個參數是否是字元串,以及是否包含占位符,如果包含了,那麼就判斷是什麼占位符,然後取出後面對應位置的參數進行格式化,沒有用到的參數也不能丢棄,仍然需要顯示:

const handleArgs = (method, contents) => {
        // 處理占位符
        if (contents.length > 0) {
            if (type(contents[0]) === 'string') {
                // 隻處理%s、%d、%i、%f、%c
                let match = contents[0].match(/(%[sdifc])([^%]*)/gm) // "%d年%d月%d日" -> ["%d年", "%d月", "%d日"]
                if (match) {
                    // 後續參數
                    let sliceArgs = contents.slice(1)
                    let strList = []
                    // 周遊比對到的結果
                    match.forEach((item, index) => {
                        let placeholder = item.slice(0, 2)
                        let arg = sliceArgs[index]
                        // 對應位置沒有資料,那麼就原樣輸出占位符
                        if (arg === undefined) {
                            strList.push(item)
                            return
                        }
                        let newStr = ''
                        switch (placeholder) {
                            // 字元串,此處為簡單處理,實際和chrome控制台的輸出有差異
                            case '%s':
                                newStr = String(arg) + item.slice(2)
                                break;
                                // 整數
                            case '%d':
                            case '%i':
                                newStr = (type(arg) === 'number' ? parseInt(arg) : 'NaN') + item.slice(2)
                                break;
                                // 浮點數
                            case '%f':
                                newStr = (type(arg) === 'number' ? arg : 'NaN') + item.slice(2)
                                break;
                                // 樣式
                            case '%c':
                                newStr = `<span style="${arg}">${item.slice(2)}</span>`
                                break;
                            default:
                                break;
                        }
                        strList.push(newStr)
                    })
                    contents = strList
                    // 超出占位數量的剩餘參數也不能丢棄,需要展示
                    if (sliceArgs.length > match.length) {
                        contents = contents.concat(sliceArgs.slice(match.length))   
                    }
                }
            }
        }
        // 處理方法 ...
        switch (method) {}
}      

效果如下:

手把手教你快速搭建一個代碼線上編輯預覽工具

報錯資訊

報錯資訊上文已經涉及到了,我們對js代碼使用try catch進行了包裹,并使用console.error進行錯誤輸出。

但是還有些錯誤可能是try catch監聽不到的,比如定時器代碼執行出錯,或者是沒有被顯式捕獲的Promise異常,我們也需要加上對應的監聽及顯示。

// /public/console/index.js


// 錯誤監聽
window.onerror = function (message, source, lineno, colno, error) {
    window.parent.postMessage({
        type: 'console',
        method: 'string',
        data: [message, source, lineno, colno, error].map((item) => {
            return handleData(item)
        })
    })
}
window.addEventListener('unhandledrejection', err => {
    window.parent.postMessage({
        type: 'console',
        method: 'string',
        data: [handleData(err.reason.stack)]
    })
})


// ...      

執行輸入的js

console的最後一個功能是可以輸入js代碼然後動态執行,這個可以使用eval方法,eval能動态執行js代碼并傳回最後一個表達式的值,eval會帶來一些安全風險,但是筆者沒有找到更好的替代方案,知道的朋友請在下方留言一起探讨吧。

動态執行的代碼裡的輸出以及最後表達式的值我們也要顯示到控制台裡,為了不在上層攔截console,我們把動态執行代碼的功能交給預覽的iframe,執行完後再把最後的表達式的值使用console列印一下,這樣所有的輸出都能顯示到控制台。

<textarea v-model="jsInput" @keydown.enter="implementJs"></textarea>      
const jsInput = ref('')
const implementJs = (e) => {
    // shift+enter為換行,不需要執行
    if (e.shiftKey) {
        return
    }
    e.preventDefault()
    let code = jsInput.value.trim()
    if (code) {
        // 給iframe發送資訊
        iframeRef.value.contentWindow.postMessage({
            type: 'command',
            data: code
        })
        jsInput.value = ''
    }
}      
// /public/console/index.js


// 接收代碼執行的事件
const onMessage = ({ data = {} }) => {
    if (data.type === 'command') {
        try {
            // 列印一下要執行的代碼
             console.log(data.data)
            // 使用eval執行代碼
            console.log(eval(data.data))
        } catch (error) {
            console.error('js執行出錯')
            console.error(error)
        }
    }
}
window.addEventListener('message', onMessage)      

效果如下:

手把手教你快速搭建一個代碼線上編輯預覽工具

支援預處理器

除了基本的html、js和css,作為一個強大的工具,我們有必要支援一下常用的預處理器,比如html的pug,js的TypeScript及css的less等,實作思路相當簡單,加載對應預處理器的轉換器,然後轉換一下即可。

動态切換編輯器語言

Monaco Editor想要動态修改語言的話我們需要換一種方式來設定文檔,上文我們是建立編輯器的同時直接把語言通過language選項傳遞進去的,然後使用setValue來設定文檔内容,這樣後期無法再動态修改語言,我們修改為切換文檔模型的方式:

// 建立編輯器
editor = monaco.editor.create(editorEl.value, {
    minimap: {
        enabled: false, // 關閉小地圖
    },
    wordWrap: 'on', // 代碼超出換行
    theme: 'vs-dark', // 主題
    fontSize: 18,
    fontFamily: 'MonoLisa, monospace',
})
// 更新編輯器文檔模型 
const updateDoc = (code, language) => {
  if (!editor) {
    return
  }
  // 擷取目前的文檔模型
  let oldModel = editor.getModel()
  // 建立一個新的文檔模型
  let newModel = monaco.editor.createModel(code, language)
  // 設定成新的
  editor.setModel(newModel)
  // 銷毀舊的模型
  if (oldModel) {
    oldModel.dispose()
  }
}      

加載轉換器

轉換器的檔案我們都放在/public/parses/檔案夾下,然後進行動态加載,即選擇了某個預處理器後再去加載對應的轉換器資源,這樣可以節省不必要的請求。

異步加載js我們使用loadjs這個小巧的庫,新增一個load.js:

// 記錄加載狀态
const preprocessorLoaded = {
    html: true,
    javascript: true,
    css: true,
    less: false,
    scss: false,
    sass: false,
    stylus: false,
    postcss: false,
    pug: false,
    babel: false,
    typescript: false
}


// 某個轉換器需要加載多個檔案
const resources = {
    postcss: ['postcss-cssnext', 'postcss']
}


// 異步加載轉換器的js資源
export const load = (preprocessorList) => {
    // 過濾出沒有加載過的資源
    let notLoaded = preprocessorList.filter((item) => {
        return !preprocessorLoaded[item]
    })
    if (notLoaded.length <= 0) {
        return
    }
    return new Promise((resolve, reject) => {
        // 生成加載資源的路徑
        let jsList = []
        notLoaded.forEach((item) => {
            let _resources = (resources[item] || [item]).map((r) => {
                return `/parses/${r}.js`
            })
            jsList.push(..._resources)
        })
        loadjs(jsList, {
            returnPromise: true
        }).then(() => {
            notLoaded.forEach((item) => {
                preprocessorLoaded[item] = true
            })
            resolve()
        }).catch((err) => {
            reject(err)
        })
    })
}      

然後修改一下上文預覽部分的run方法:

const run = async () => {
  let h = editData.value.code.HTML.language
  let j = editData.value.code.JS.language
  let c = editData.value.code.CSS.language
  await load([h, j, c])
  // ...
}      

轉換

所有代碼都使用轉換器轉換一下,因為有的轉換器是同步方式的,有的是異步方式的,是以我們統一使用異步來處理,修改一下run方法:

const run = async () => {
  // ...
  await load([h, j, c])
  let htmlTransform = transform.html(h, editData.value.code.HTML.content)
  let jsTransform = transform.js(j, editData.value.code.JS.content)
  let cssTransform = transform.css(c, editData.value.code.CSS.content)
  Promise.all([htmlTransform, jsTransform, cssTransform])
    .then(([htmlStr, jsStr, cssStr]) => {
      // ...
    })
    .catch((error) => {
      // ...
    })
}      

接下來就是最後的轉換操作,下面隻展示部分代碼,完整代碼有興趣的可檢視源碼:

// transform.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的。

其他功能

另外還有一些實作起來簡單,但是能很大提升使用者體驗的功能,比如添加額外的css或js資源,免去手寫link或script标簽的麻煩:

手把手教你快速搭建一個代碼線上編輯預覽工具

預設一些常用模闆,比如vue3、react等,友善快速開始,免去寫基本結構的麻煩:

手把手教你快速搭建一個代碼線上編輯預覽工具

有沒有更快的方法

如果你看到這裡,你一定會說這是哪門子快速搭建,那有沒有更快的方法呢,當然有了,就是直接克隆本項目的倉庫或者codepan,改改就可以使用啦~

結尾

本文從零開始介紹了如何搭建一個代碼線上編輯預覽的工具,粗糙實作總有不足之處,歡迎指出。

感謝你的閱讀。

學習更多技能

請點選下方公衆号