Быстро создавайте онлайн-инструмент предварительного просмотра для редактирования кода

внешний интерфейс
Быстро создавайте онлайн-инструмент предварительного просмотра для редактирования кода

Введение

Привет всем, я неизвестный фронтенд, который простаивает и хочет повторить создание колес.Сегодня я познакомлю вас с реализацией онлайн-инструмента для редактирования и предварительного просмотра кода.В настоящее время такие инструменты широко используются и обычно можно найти на различных веб-сайтах с документами. и сценарии совместного использования кода, существует множество связанных инструментов, таких какcodepen,jsrun,codesandbox,jsbin,plnkr,jsfiddleИ т. д., эти инструменты можно условно разделить на две категории: в одну можно свободно добавлять несколько файлов, что больше похоже на редактор, который мы обычно используем, а в другую можно редактировать только отдельно.html,js,css, вторая категория более распространена, дляdemoНа самом деле для сцены этого достаточно, конечно, это только видимость, а лежащие в основе методы реализации еще могут иметь свои достоинства.

Эта статья в основном знакомит с одним из методов реализации второй категории, он вообще не зависит от бэкенда, вся логика завершается во фронтенде, достаточно прост в реализации.vue3Его можно разрабатывать всей семьей, а также можно использовать другие фреймворки.

ps На основе этой статьи автор разработал полноценный онлайн-инструмент с облачным хранилищем, адрес:lxqnsys.com/code-run/,Добро пожаловать.

структура страницы

image-20210427170009062.png

Я поднял типичную относительно приятную конструкцию, и макет по умолчанию разделен на четыре части сверху вниз, панели инструментов, редакторов, предварительного просмотра и консоли, редактор разделен на три части, а именно, а именно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)
                }
            }
        }
    }
}

Эффект следующий:

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

Чтобы обеспечить свободное переключение нескольких раскладок, необходимо инкапсулировать вышеуказанную логику в два компонента, один компонент-контейнер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, потому что он мощный, простой в использовании, поддерживает подсветку синтаксиса, поддерживает несколько языков и тем и т. д., но для более удобной поддержки синтаксических подсказок в этой статье выбран вариант Microsoftmonaco-editor, функция иVSCodeтакой же мощный,VSCodeМне не нужно больше говорить о том, насколько он силен, недостаток в том, что в целом он сложнее, объем кода велик, а встроенных тем меньше.

monaco-editorПоддержка различных методов загрузки,esmНеобходимо использовать способ загрузки модуля.webpack,ноviteБазовый инструмент упаковки используетRollup, поэтому в этой статье используется прямое введениеjsПуть.

Загрузите сжатый пакет на официальном сайте и извлеките его в проект.publicпапку, а затем обратитесь кПримерпуть вindex.htmlДобавьте в файл:

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

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

monaco-editorВстроенных языков 10, выбираем китайский, а остальные не нужные можно удалить напрямую:

image-20210430163748892.png

Далее создайте редактор:

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

Это так просто, можно использовать редактор с подсветкой, синтаксическими подсказками и подсказками об ошибках Эффект следующий:

image-20210430154406199.png

Несколько других часто используемыхapiследующим образом:

// 设置文档内容
editor.setValue(props.content)
// 监听编辑事件
editor.onDidChangeModelContent((e) => {
    console.log(editor.getValue())// 获取文档内容
})
// 监听失焦事件
editor.onDidBlurEditorText((e) => {
    console.log(editor.getValue())
})

предварительный просмотр

С помощью кода вы можете отобразить страницу для предварительного просмотра. Для предварительного просмотра, очевидно, используйте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
}

Эффект следующий:

image-20210507141946844.png

предотвращатьjsОшибка возникает при запуске кода и блокировке страницы от рендеринга, ставимjsиспользование кодаtry catchЗаверните:

let body = `
    ${editData.value.code.html.content}
    <script>
        try {
          ${editData.value.code.javascript.content}
        } catch (err) {
          console.error('js代码运行出错')
          console.error(err)
        }
    <\/script>
  `

приставка

минималистский способ

Сначала представьте очень простой способ, используя вызываемыйeruda, который используется для облегчения отладки на телефоне, иvConsoleТочно так же мы напрямую встраиваем его вiframeможет поддерживать функцию консоли, встраиватьiframeМы должны поместить файлы вpublicВ папке:

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

Эффект следующий:

image-20210507154345054.png

Недостатком этого подхода является то, что он может быть встроен только вiframeВ , консоль и страница не могут быть разделены, так что каждый раз, когда код перезапускается, консоль также будет перезапускаться, и предыдущие журналы не могут быть сохранены.Конечно, стиль не так просто контролировать.

реализовать это самостоятельно

Если вы решите реализовать это самостоятельно, то эта часть будет самой сложной в этом проекте.Если вы реализуете это самостоятельно, вы, как правило, реализуете только одинconsoleфункции, другие, такие какhtmlНе делается структура, запрашиваются ресурсы и т. д. Ведь реализовать долго и трудозатратно, да и пользы мало.

consoleКак правило, для вывода должны поддерживаться два вида информации, один из которыхconsoleИнформация печатается объектом, второе это различные сообщения об ошибках, смотрите первоеconsoleИнформация.

информация о консоли

Идея проста, вiframeперехватыватьconsoleВсе методы объекта, используемые при вызове методаpostMessageЧтобы передать информацию на родительскую страницу, консоль родительской страницы распечатывает соответствующую информацию.

// /public/console/index.js

// 重写的console对象的构造函数,直接修改console对象的方法进行拦截的方式是不行的,有兴趣可以自行尝试
function ProxyConsole() {};
// 拦截console的所有方法
[
    'debug',
    'clear',
    'error',
    'info',
    'log',
    'warn',
    'dir',
    'props',
    'group',
    'groupEnd',
    'dirxml',
    'table',
    'trace',
    'assert',
    'count',
    'markTimeline',
    'profile',
    'profileEnd',
    'time',
    'timeEnd',
    'timeStamp',
    'groupCollapsed'
].forEach((method) => {
    let originMethod = console[method]
    // 设置原型方法
    ProxyConsole.prototype[method] = function (...args) {
        // 发送信息给父窗口
        window.parent.postMessage({
            type: 'console',
            method,
            data: args
        })
        // 调用原始方法
        originMethod.apply(ProxyConsole, args)
    }
})
// 覆盖原console对象
window.console = new ProxyConsole()

Вставьте этот файл также вiframeвнутри:

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

монитор родительской страницыmessageСобытия могут быть:

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

Если следующее:

image-20210507165953197.png

Информация может отображаться, когда мониторинг получает информацию.Давайте рассмотрим это шаг за шагом:

первыйconsoleМетоды могут одновременно получать несколько параметров, печатать несколько данных и отображать их в одной строке одновременно.

1. Основные типы данных

Пока основные типы данных преобразуются в строки и отображаются, это не что иное, как использование цветов для их различения:

// /public/console/index.js

// ...

window.parent.postMessage({
    type: 'console',
    method,
    data: args.map((item) => {// 对每个要打印的数据进行处理
        return handleData(item)
    })
})

// ...

// 处理数据
const handleData = (content) => {
    let contentType = type(content)
    switch (contentType) {
        case 'boolean': // 布尔值
            content = content ? 'true' : 'false'
            break;
        case 'null': // null
            content = 'null'
            break;
        case 'undefined': // undefined
            content = 'undefined'
            break;
        case 'symbol': // Symbol,Symbol不能直接通过postMessage进行传递,会报错,需要转成字符串
            content = content.toString()
            break;
        default:
            break;
    }
    return {
        contentType,
        content,
    }
}
// 日志列表
const logList = ref([])

// 监听iframe信息
window.addEventListener('message', ({ data = {} }) => {
  if (data.type === 'console') 
    logList.value.push({
      type: data.method,// console的方法名
      data: data.data// 要显示的信息,一个数组,可能同时打印多条信息
    })
  }
})
<div class="logBox">
    <div class="logRow" v-for="(log, index) in logList" :key="index">
        <template v-for="(logItem, itemIndex) in log.data" :key="itemIndex">
            <!-- 基本数据类型 -->
            <div class="logItem message" :class="[logItem.contentType]" v-html="logItem.content"></div>
        </template>
    </div>
</div>

image-20210508091625420.png

2. Функция

функция просто вызовtoStringМетод можно преобразовать в строку:

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

3.json данные

jsonДанные должны быть отформатированы для отображения, то есть с выделением, отступом и поддержкой расширения и сжатия.

Реализация тоже очень простая, подсветку можно сделать черезcssКонтроль имени класса, можно использовать отступ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>

Наконец, вы можете написать стили для разных имен классов, и эффект будет следующим:

image-20210508195753623.png

Событие щелчка кнопок расширения и сокращения привязывается к внешнему элементу посредством делегирования события:

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

При нажатии кнопки расширения и сжатия расширение или сжатие определяется в соответствии с текущим состоянием расширения.wrapВысота элемента, элемент с многоточием вставляется одновременно при сжатии, чтобы указать, что здесь происходит сжатие.В то же время, поскольку кнопка использует абсолютное позиционирование и отделена от обычного потока документа, она также необходимо вручную управлять ее отображением и скрытием.Необходимо уметь различать Какими кнопками можно оперировать в этот раз, иначе нижний уровень может находиться в сжатом состоянии, а верхний уровень снова отображает кнопку:

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

// json数据展开收缩
let expandIndex = 0
const jsonClick = (e) => {
  // 点击是展开收缩按钮
  if (e.target && e.target.classList.contains('expandBtn')) {
    let target = e.target
    let parent = target.parentNode
    // id,每个展开收缩按钮唯一的标志
    let index = target.getAttribute('data-index')
    if (index === null) {
      index = expandIndex++
      target.setAttribute('data-index', index)
    }
    // 获取当前状态,0表示收缩、1表示展开
    let status = target.getAttribute('expand-status') || '1'
    // 在子节点里找到wrap元素
    let wrapEl = getChildByClassName(parent, 'wrap')
    // 找到下层所有的按钮节点
    let btnEls = wrapEl.querySelectorAll('.expandBtn')
    // 收缩状态 -> 展开状态
    if (status === '0') {
      // 设置状态为展开
      target.setAttribute('expand-status', '1')
      // 展开
      wrapEl.style.height = 'auto'
      // 按钮箭头旋转
      target.classList.remove('shrink')
      // 移除省略号元素
      let ellipsisEl = getChildByClassName(parent, 'ellipsis')
      parent.removeChild(ellipsisEl)
      // 显示下级展开收缩按钮
      for (let i = 0; i < btnEls.length; i++) {
        let _index = btnEls[i].getAttribute('data-for-index')
        // 只有被当前按钮收缩的按钮才显示
        if (_index === index) {
          btnEls[i].removeAttribute('data-for-index')
          btnEls[i].style.display = 'inline-block'
        }
      }
    } else if (status === '1') {
      // 展开状态 -> 收缩状态
      target.setAttribute('expand-status', '0')
      wrapEl.style.height = 0
      target.classList.add('shrink')
      let ellipsisEl = document.createElement('div')
      ellipsisEl.textContent = '...'
      ellipsisEl.className = 'ellipsis'
      parent.insertBefore(ellipsisEl, wrapEl)
      for (let i = 0; i < btnEls.length; i++) {
        let _index = btnEls[i].getAttribute('data-for-index')
        // 只隐藏当前可以被隐藏的按钮
        if (_index === null) {
          btnEls[i].setAttribute('data-for-index', index)
          btnEls[i].style.display = 'none'
        }
      }
    }
  }
}

Эффект следующий:

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

4. Другие методы консольного объекта

consoleНекоторые методы объектов имеют определенную логику, напримерconsole.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
    }
}

Давайте посмотрим на обработку плейсхолдеров.Плейсхолдеры описываются следующим образом:

image-20210512135732215.png

Можно определить, является ли первый параметр строкой и содержит ли он заполнитель, если он включен, то определить, какой это заполнитель, а затем вынести параметры в соответствующей позиции для форматирования, а параметры, которые не используются нельзя отбрасывать. , еще нужно показать:

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) {}
}

Эффект следующий:

image-20210512140705004.png

сообщение об ошибке

Сообщение об ошибке было рассмотрено выше, мы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Может выполняться динамическиjscode и возвращает значение последнего выражения,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)

Эффект следующий:

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

Поддержка препроцессора

Помимо основного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Проблема с ярлыками:

image-20210514140452547.png

Предварительно установите некоторые общие шаблоны, такие какvue3,reactи т. д., чтобы быстро приступить к работе и не заморачиваться с написанием базовой структуры:

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

Есть ли более быстрый способ

Если вы это увидите, то обязательно скажете, что это быстрый способ сборки, есть ли более быстрый способ, конечно же, напрямую клонировать репозиторий этого проекта илиcodepan, вы можете использовать его после изменения ~

конец

В этой статье рассказывается, как создать инструмент для онлайн-редактирования и предварительного просмотра кода с нуля.В грубой реализации всегда есть недостатки, пожалуйста, укажите.

репозиторий проектаcode-run,Добро пожаловатьstar.