<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          快速搭建一個(gè)代碼在線編輯預(yù)覽工具

          共 67205字,需瀏覽 135分鐘

           ·

          2021-07-16 00:23

          點(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ú)編輯htmljscss,第二類比較常見(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)

          image-20210427170009062.png

          我挑了一個(gè)比較典型也比較好看的結(jié)構(gòu)來(lái)仿照,默認(rèn)布局上下分成四部分,工具欄、編輯器、預(yù)覽區(qū)域及控制臺(tái),編輯器又分為三部分,分別是HTMLCSSJavaScript,其實(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)效果如下:

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

          為了能提供多種布局的隨意切換,我們有必要把上述邏輯封裝一下,封裝成兩個(gè)組件,一個(gè)容器組件Drag.vue,一個(gè)容器的子組件DragItem.vueDragItem通過(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ǔ)言,我們選擇中文的,其他不用的可以直接刪掉:

          image-20210430163748892.png

          接下來(lái)創(chuàng)建編輯器就可以了:

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

          就這么簡(jiǎn)單,一個(gè)帶高亮、語(yǔ)法提示、錯(cuò)誤提示的編輯器就可以使用了,效果如下:

          image-20210430154406199.png

          其他幾個(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ù)覽,顯然是使用iframeiframe除了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)的思路就很清晰了,把htmlcssjs代碼組裝起來(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ù)制代碼

          效果如下:

          image-20210507141946844.png

          為了防止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ù)制代碼

          效果如下:

          image-20210507154345054.png

          這種方式的缺點(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ù)制代碼

          如果如下:

          image-20210507165953197.png

          監(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ù)制代碼
          image-20210508091625420.png

          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)換行可以使用divspan來(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, falsetrue, [])
                      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ě)一下樣式即可,效果如下:

          image-20210508195753623.png

          展開(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ù)制代碼

          效果如下:

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

          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ù)制代碼

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

          image-20210512135732215.png

          可以判斷第一個(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(02)
                                  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ù)制代碼

          效果如下:

          image-20210512140705004.png

          報(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ù)制代碼

          效果如下:

          2021-05-12-18-31-12.gif

          支持預(yù)處理器

          除了基本的htmljscss,作為一個(gè)強(qiáng)大的工具,我們有必要支持一下常用的預(yù)處理器,比如htmlpugjsTypeScriptcssless等,實(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: {
                  enabledfalse// 關(guān)閉小地圖
              },
              wordWrap'on'// 代碼超出換行
              theme'vs-dark'// 主題
              fontSize18,
              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 = {
              htmltrue,
              javascripttrue,
              csstrue,
              lessfalse,
              scssfalse,
              sassfalse,
              stylusfalse,
              postcssfalse,
              pugfalse,
              babelfalse,
              typescriptfalse
          }

          // 某個(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, {
                      returnPromisetrue
                  }).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)的功能,比如添加額外的cssjs資源,免去手寫(xiě)linkscript標(biāo)簽的麻煩:

          image-20210514140452547.png

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

          2021-05-14-14-37-28.gif

          有沒(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ù)「面試」即可。


          瀏覽 51
          點(diǎn)贊
          評(píng)論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報(bào)
          評(píng)論
          圖片
          表情
          推薦
          點(diǎn)贊
          評(píng)論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報(bào)
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  亚洲无码一卡一卡 | 青娱乐极品视频盛宴 | 翔田千里系列无码流出 | 影音先锋在线播放99av | 亚洲欧美精品AAAAAA片 |