От автора: недавно я провел некоторое время, разрабатывая собственный визуальный редактор JavaScript под названием Demoit. Что-то вроде CodeSandbox, JSBin или Codepen. Я уже писал о том, почему я это сделал, но теперь решил поделиться некоторыми деталями реализации. Все происходит во время работы в браузере, поэтому это довольно интересный проект.
Цель
Интерактивная JavaScript-площадка — это место, где мы можем писать код JavaScript и видеть его результат. Это означает изменения в дереве DOM или логов в консоли. Чтобы реализовать это, я создал довольно стандартный интерфейс.
У нас есть две панели с левой стороны — одна для созданной разметки и одна для логов консоли. С правой стороны находится редактор. Каждый раз, когда мы вносим изменения в код и сохраняем его, мы хотим видеть обновление левых панелей. Инструмент должен также поддерживать несколько файлов, поэтому у нас есть панель навигации с вкладками для каждого файла.
Редактор
Нет, я не реализовал редактор самостоятельно. Это куча работы. Я использовал CodeMirror. Это довольно приличный онлайн-редактор. Интеграция довольно проста. На самом деле код, который я написал для этой части, составляет всего 25 строк.
// editor.js export const createEditor = function (settings, value, onSave, onChange) { const container = el('.js-code-editor'); const editor = CodeMirror(container, { value: value || '', mode: 'jsx', tabSize: 2, lineNumbers: false, autofocus: true, foldGutter: false, gutters: [], styleSelectedText: true, ...settings.editor }); const save = () => onSave(editor.getValue()); const change = () => onChange(editor.getValue()); editor.on('change', change); editor.setOption("extraKeys", { 'Ctrl-S': save, 'Cmd-S': save }); CodeMirror.normalizeKeyMap(); container.addEventListener('click', () => editor.focus()); editor.focus(); return editor; };
Конструктор CodeMirror принимает HTML-элемент и набор параметров. Остальное — это просто прослушивание событий, два набора горячих клавиш и фокусировка редактора.
В самых первых версиях я добавил много логики. Как, например, транспиляция или чтение начального значения из локального хранилища, но позже решил убрать это. Теперь это функция, которая создает редактор и отправляет все, что мы печатаем.
Трансляция кода
Думаю, вы согласитесь со мной, если я скажу, что большинство скриптов JavaScript, которые мы сегодня пишем, требует транспиляции. Я решил использовать Babel. Не потому, что это самый популярный транспилер, а потому, что он предлагает автономную обработку на стороне клиента. Это означает, что мы можем импортировать babel.js на страницу, и будем иметь возможность перекодировать код на лету. Например:
// transpile.js const babelOptions = { presets: [ "react", ["es2015", { "modules": false }]] } export default function preprocess(str) { const { code } = Babel.transform(str, babelOptions); return code; }
Используя этот код, мы можем получить JavaScript из редактора и перевести его в действительный синтаксис ES5, который отлично работает в браузере. Это все хорошо, но то, что мы имеем до сих пор, это просто строка. Нам нужно каким-то образом преобразовать эту строку в рабочий код.
Использование JavaScript для запуска JavaScript, сгенерированного JavaScript
Существует конструктор Function, который принимает код в формате строки. Это не очень популярно, потому что мы почти никогда не используем его. Если мы хотим запустить функцию, мы просто вызываем ее. Тем не менее, это действительно полезно, если мы генерируем код во время выполнения. Вот простой пример:
const func = new Function('var a = 42; console.log(a);'); func(); // logs out 42
Это то, что я использовал для обработки входной строки. Код отправляется конструктору Function и затем выполняется:
// execute.js import transpile from './transpile'; export default function executeCode(code) { try { (new Function(transpile(code)))(); } catch (error) { console.error(error); } }
Блок try-catch здесь необходим, потому что мы хотим, чтобы приложение работало, даже если есть ошибка. И неплохо бы получать некоторые ошибки, потому что это инструмент, который мы используем для тестовых вещей. Обратите внимание: вышеприведенный скрипт обнаруживает ошибки синтаксиса, а также ошибки во время выполнения.
Обработка операторов импорта
В какой-то момент я добавил возможность редактировать нескольких файлов, и я понял, что Demoit может работать как настоящий редактор кода. Иногда это означает, что вы экспортируете логику в файл и импортируете ее в другом файле. Однако для поддержки такого поведения нам приходится иметь дело операторами import и export. Это (как и многое другое) не является частью Babel. Существует плагин , который понимает этот синтаксис и транспилирует его в старые добрые require и exports — он называется transform-es2015-modules-common. Вот пример:
import test from 'test'; export default function blah() {}
переводится в:
Object.defineProperty(exports, "__esModule", { value: true }); exports.default = blah; var _test = require('test'); var _test2 = _interopRequireDefault(_test); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function blah() {}
Не супер результат, но, по крайней мере, код преобразуется правильно, без ошибок. Плагин помогает получить корректный код в виде строки, но не связан с его выполнением. Полученный код не работал, потому что не имел определений ни require, ни exports.
Давайте вернемся к нашей функции executeCode и посмотрим, что мы должны изменить, чтобы сделать возможным импорт / экспорт. Хорошей новостью является то, что все происходит в браузере, поэтому у нас действительно есть код всех файлов в редакторе. Мы знаем их содержимое заранее. Мы также контролируем код, который выполняется, потому что, как мы сказали, это просто строка. Из-за этого мы можем динамически добавлять все, что захотим. Включая другие функции или переменные.
Давайте немного изменим сигнатуру executeCode. Вместо кода в виде строки мы примем индекс текущего редактируемого файла и массив всех доступных файлов:
export default function executeCode(index, files) { // magic goes here }
Предположим, что у нас есть файлы редактора в следующем формате:
const files = [ { filename: "A.js", content: "import B from 'B.js';\nconsole.log(B);" }, { filename: "B.js", content: "const answer = 42;\nexport default answer;" } ]
Если все в порядке, и мы запускаем A.js, мы должны увидеть в консоли 42. Теперь давайте начнем создание новой строки, которая будет отправлена конструктору Function:
const transpiledFiles = files.map(({ filename, content }) => ` { filename: "${ filename }", func: function (require, exports) { ${ transpile(content) } }, exports: {} } `);
transpiledFiles — это новый массив, содержащий строки. Эти строки являются фактически объектными литералами, которые будут использоваться позже. Мы переносим код в закрытие, поэтому избегаем конфликтов с другими файлами, и мы также определяем, откуда require и куда exports. У нас также есть пустой объект, который будет хранить все, что экспортирует файл. В случае B.js, это число 42.
Следующие действия заключаются в том, чтобы реализовать эту функцию require и выполнить код файла согласно правильному index(помните, что мы передаем индекс текущего редактируемого файла):
const code = ` const modules = [${ transpiledFiles.join(',') }]; const require = function(file) { const module = modules.find(({ filename }) => filename === file); if (!module) { throw new Error('Demoit can not find "' + file + '" file.'); } module.func(require, module.exports); return module.exports; }; modules[${ index }].func(require, modules[${ index }].exports); `;
Массив modules представляет собой нечто вроде пакета , содержащего все из нашего кода. Функция require в основном просматривает этот пакет, чтобы найти файл, который нам нужен, и запускает его закрытие. Обратите внимание, как мы передаем одни и те же функцию require и объект module.exports. Этот же объект возвращается в конце.
Последний фрагмент выполняет закрытие текущего файла. Сгенерированный код для приведенного выше примера выглядит следующим образом:
const modules = [ { filename: "A.js", func: function (require, exports) { 'use strict'; var _B = require('B.js'); var _B2 = _interopRequireDefault(_B); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } console.log(_B2.default); }, exports: {} }, { filename: "B.js", func: function (require, exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); var answer = 42; exports.default = answer; }, exports: {} } ]; const require = function(file) { const module = modules.find(({ filename }) => filename === file); if (!module) { throw new Error('Demoit can not find "' + file + '" file.'); } module.func(require, module.exports); return module.exports; }; modules[0].func(require, modules[0].exports);
Этот код отправляется конструктору Function. Это близко к тому, как работают современные упаковщики. Они обычно оборачивают модули в закрытие и имеют аналогичный код для разрешения импорта.
Создание в результате разметки
Здесь не выполняется много работы. Мне просто нужно было предоставить элемент DOM и сообщить разработчику об этом. В случае Demoit я разместил div с классом «output». Посмотрите на следующий снимок экрана. Он иллюстрирует, как мы можем настроить таргетинг на верхнюю левую панель:
Код, который поступает из CodeMirror, выполняется в контексте той же страницы, на которой выполняется приложение. Таким образом, код имеет доступ к тому же дереву DOM.
Однако была одна проблема, которую мне пришлось решить. Речь шла об очистке div перед запуском кода еще раз. Это было необходимо, потому что могут существовать некоторые элементы из предыдущего запуска. Простой element.innerHTML = » не работал должным образом с React, поэтому я остановился на следующем:
async function teardown() { const output = el('.output'); if (typeof ReactDOM !== 'undefined') { ReactDOM.unmountComponentAtNode(output); } output.innerHTML = ''; }
Если код использует пакет ReactDOM, мы делаем предположение, что приложение React отображается в div и размонтируем его. Если мы этого не сделаем, то получим ошибку во время выполнения, потому что мы сбросили элементы DOM, которые использует React. unmountComponentAtNode довольно стабилен и его не волнует, есть ли React в переданном элементе или нет. Он просто выполняет свою работу, если может.
Перехват логов
Во время кодирования мы очень часто используем console.log. Мне нужно было перехватить эти вызовы и отобразить в нижней левой панели. Я выбрал немного ламерское решение — переписать методы консоли:
const add = something => { // ... add a new element to the panel } const originalError = console.error; const originalLog = console.log; const originalWarning = console.warn; const originalInfo = console.info; const originalClear = console.clear; console.error = function (error) { add(error.toString() + error.stack); originalError.apply(console, arguments); }; console.log = function (...args) { args.forEach(add); originalLog.apply(console, args); }; console.warn = function (...args) { args.forEach(add); originalWarning.apply(console, args); }; console.info = function (...args) { args.forEach(add); originalInfo.apply(console, args); }; console.clear = function (...args) { element.innerHTML = ''; originalClear.apply(console, args); };
Заметьте, что я сохранил обычное поведение, поэтому не нарушил нормальную работу объекта console. Я также переписал .error, .warn, .infoи .clear, чтобы обеспечить лучший опыт для разработчиков. Если все указано в панели, разработчику не нужно использовать инструменты разработчика браузера.
Заключение
Существует также некоторый код для связки, некоторый код для разделения экрана, некоторый код, который касается навигации и локального хранилища. Фрагменты, приведенные выше, были самыми интересными и хитрыми, и, вероятно, теми, на которые вы должны обратить внимание. Если вы хотите увидеть полный исходный код интерактивной площадки, перейдите на страницу github.com/krasimir/demoit. Вы можете попробовать демо-версию здесь.
Автор: Krasimir
Источник: http://krasimirtsonev.com/
Редакция: Команда webformyself.