
Привет, меня зовут Илья, я лидер сообщества фронтенд-разработки в Райффайзенбанке. Среди моих задач — проведение собеседований, при этом хочется, чтобы этот процесс был эффективным, продуктивным и полезным. Для проведения таких собеседований нам нужно выбрать классный инструмент для совместного написания кода. Изучив рынок инструментов, мы пришли к выводу, что из-за безопасности и функциональности проще написать свое решение.
В этой статье я расскажу, как создать главный элемент платформы совместного написания и проверки кода — онлайн-песочницу. Она будет поддерживать один из самых популярных фреймворков и несколько библиотек для него, что достаточно для проверки необходимых навыков и умений.
Введение
Эта небольшая статья включает в себя разбор таких инструментов:
-
Monaco editor
-
Esbuild + WebAssembly
Писать мы будем на React + TS.
Что должна делать наша онлайн-песочница:
-
Работать автономно без участия сервера
-
Разделяться на блок для написания кода и на плейграунд
-
Подсвечивать синтаксис
-
Подсказывать типы для react, react-dom, react-router-dom styled-components
-
Иметь виртуальную адресную строку в части плейграунда
-
Уметь собираться из TS в JS и проигрываться в плейграунде
Полный код примера можно посмотреть на Github и в демонстрации.
Monaco editor
Monaco editor — редактор кода с подсветкой синтаксиса, и большой, но не очень удобной, документацией. Monaco создан Microsoft — он является урезанной версией редактора, встраиваемого в VS Code.
Отличительная черта Monaco в том, что он запускает TypeScript-анализатор вместе с собой и обеспечивает не только подсветку синтаксиса, но и работающий из коробки IntelliSense с возможностью передать ему дополнительные файлы типов (*.d.ts).
Сначала определим глобальную конфигурацию редактора:
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({ noSemanticValidation: true, noSyntaxValidation: false }); monaco.languages.typescript.typescriptDefaults.setCompilerOptions({ jsx: monaco.languages.typescript.JsxEmit.React, allowNonTsExtensions: true, moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs, module: monaco.languages.typescript.ModuleKind.CommonJS, noEmit: true, esModuleInterop: true, });
По сути — это обычная конфигурация из tsconfig.json. При этом поля typeRoots или outDir, связанные с конфигурацией файловой системы, никакого эффекта не окажут, так как файловой системы в браузере нет. Это упущение можно исправить, и кратко я расскажу об этом в конце.
Напишем загрузчик дополнительных типов для подсветки синтаксиса:
loaStaticdDTS = async (libName: string) => { const response = await fetch(`/${libName}.d.ts`) const dts = await response.text(); monaco.editor.createModel(dts, 'typescript', monaco.Uri.parse(`file:///node_modules/@types/${libName}/index.d.ts`)); monaco.languages.typescript.typescriptDefaults.addExtraLib(dts, `file:///node_modules/@types/${libName}/index.d.ts`); }
На самом деле можно использовать только 5-ю строку кода, если мы просто хотим, чтобы заработали подсветка синтаксиса и импорт. Однако мы хотим также просматривать содержимое файла d.ts, поэтому надо добавить и 4-ю строчку. В качестве пути до файла нам нужно передать `file:///node_modules/@types/${libName}/index.d.ts` — это позволит запустить короткий импорт по названию библиотеки. Без этого движок редактора безуспешно попытается найти библиотеку по стандартному пути.

Ключ typeRoots в такой конфигурации эффекта не окажет — для этого необходимо наличие файловой системы, а из браузерной версии редактора такой функционал убран.
Добавим модель с содержимым нашего будущего редактора:
monaco.editor.createModel(`console.log('Hello world')`, 'typescript', monaco.Uri.parse(`file:///index.tsx`));
Логика такая же, как и при добавлении библиотеки. Мы можем добавить любое количество файлов — главное правильно указывать их относительные пути, тогда подсветка синтаксиса и импорты заработают автоматически. В нашем случае мы добавим только один файл — с типом языка typescript.
Изменить тип языка можно так:
monaco.editor.setModelLanguage(model, 'typescript');
В этом коде model — значение, возвращаемое методом createModel. По сути, модель схожа с открытой вкладкой в редакторе VS Code.
Если все пути прописаны правильно, мы получим:

Последним шагом создадим экземпляр редактора:
const editor = monaco.editor.create(ref, { theme: "vs-dark", automaticLayout: true, model: this.model });
В коде:
-
ref — ссылка на DOM-узел, полученный, например, через useRef
-
theme — цветовая схема
-
automaticLayout — автоматически подстраивает размеры редактора при изменении размеров родителя
-
model — созданная ранее модель, которая будет отображаться
Еще мы можем включать или отключать отображение миникарты, поддержку мультикурсорности и многие другие параметры редактора.
Читать и передавать значение сборщику мы сможем так:
editor.onDidChangeModelContent(() => ESService.build(editor.getModel().getValue()));
Esbuild
Esbuild позиционируется как один из быстрых сборщиков. Нас больше всего интересует, что он умеет работать в WebAssembly — это значит, что мы сможем запустить его прямо в браузере. Кроме того, у Esbuild есть хорошая документация по плагинам, которые нам потребуется написать, и минималистичный конфиг.
Для работы с этим сборщиком нам потребуется пакет esbuild-wasm и, опционально, esbuild, если мы хотим помочь себе при разработке подсветкой типов.
Для начала инициализируем сборщик:
esbuild.initialize({ wasmURL: 'esbuild.wasm' }).then(() => { ESService.build = async (text: string) => { const data = await esbuild.build(esBuildConfig(text)); data.outputFiles?.forEach((file: any) => { const _file = new File([file.text], `index.js`, { type: 'text/javascript' }); const url = URL.createObjectURL(_file); localStorage.setItem('script', url); }); } });
После инициализации нам будут доступны два метода build и transform.
-
outputFiles — отображает готовый бандл. Мы сформируем из него url и передадим в localeStorage.
-
build — асинхронный метод, в качестве параметра принимает объект с конфигурацией.
export const esBuildConfig = (text: string): BuildOptions => ({ entryPoints: ['<stdin>'], bundle: true, loader: { '.tsx': 'tsx' }, external: ['react', 'react-dom', 'react-router-dom', 'styled-components'], plugins: [pluginEntry(this, text), pluginGlobalExternal()], write: false });
Конфигурация — стандартная для esbuild. Указываем точку входа, используемые лоадеры, внешние зависимости и плагины. Особое внимание заслуживает ключ write — задав ему значение false, мы предотвратим запись файла в несуществующую файловую систему и поймаем его в data.outputFiles. Также стоит обратить внимание на entryPoints — тут мы указываем, откуда брать исходные данные — это важно для плагина. Обычно тут указывают путь к файлу, но у нас нет файловой системы, поэтому будем выкручиваться.
Опишем плагины
Очень подробно о плагинах к Esbuild написано здесь.
Если кратко — плагин содержит имя (name) и набор методов onResolve и onLoad, которые определяют, как и что интерпретировать в момент составления бандла.
pluginEntry
const namespace = 'virtual'; export const pluginEntry = (context: any, text: string): Plugin => { return { name: 'virtual-entry', setup(build) { build.onResolve({ filter: /^<stdin>$/ }, () => { return { path: 'index.tsx', namespace: namespace, pluginData: { importer: '', }, }; }); build.onLoad({ filter: /.*/, namespace: namespace }, async (args) => { return { contents: text, pluginData: { importer: 'index.tsx', }, loader: 'tsx', }; }); }, }; };
Этим плагином мы указываем, что в случае импорта строки <stdin> (ранее указана в конфиге), мы помечаем ее тегом virtual, На него нацелен следующий onLoad — он, видя этот тег, сопоставляет ему текст из model-редактора и использует лоадер для tsx.
pluginGlobalExternal
export const pluginGlobalExternal = (): Plugin => { return { name: 'plugin-modules', setup(build) { build.onResolve({ filter: /^([^\.\/]).*/ }, (args) => { const external = build.initialOptions.external?.includes(args.path); if (external) { return { path: args.path, namespace: `node_modules:external`, pluginData: { ...args.pluginData, package: args.path, }, }; } }); build.onLoad({ filter: /.*/, namespace: `node_modules:external` }, async (args) => { const content = `module.exports = window['${args.path}'];`; return { contents: content, pluginData: { importer: args.path, }, loader: 'js', }; }); }, }; };
Плагин реагирует на любые строки import … from …, помечая их как внешние зависимости для соответствующего обработчика onLoad. Он, в свою очередь, вместо импорта вставляет строку вида module.exports = window[‘${args.path}’].
Поле pluginData в нашем коде перетекает по цепочке от одного плагина к другому. Кроме того, важно правильно прописывать поля importer и path, особенно в случае, если вы включите флаг sourcemap (в нашем случае его значение должно быть inline).
// node_modules:external:react var require_react = __commonJS({ "node_modules:external:react"(exports, module) { module.exports = window["react"]; } }); // node_modules:external:react-dom var require_react_dom = __commonJS({ "node_modules:external:react-dom"(exports, module) { module.exports = window["react-dom"]; } }); // node_modules:external:styled-components var require_styled_components = __commonJS({ "node_modules:external:styled-components"(exports, module) { module.exports = window["styled-components"]; } }); // node_modules:external:react-router-dom var require_react_router_dom = __commonJS({ "node_modules:external:react-router-dom"(exports, module) { module.exports = window["react-router-dom"]; } });
Playground
В части плейграунда у нас будет только один html-файл:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Playground</title> <script crossorigin src="https://unpkg.com/react@17/umd/react.development.js"></script> <script crossorigin src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script> <script crossorigin src="//unpkg.com/react-is/umd/react-is.production.min.js"></script> <script crossorigin src="//unpkg.com/styled-components/dist/styled-components.min.js"></script> <script crossorigin src="https://cdnjs.cloudflare.com/ajax/libs/react-router-dom/5.2.0/react-router-dom.min.js"></script> </head> <body> <div id="app"></div> </body> <script> window['react'] = window.React; window['react-dom'] = window.ReactDOM; window['react-router-dom'] = window.ReactRouterDOM; window['styled-components'] = window.styled; const listener = (e) => { if (e.key === 'script') { fetch(e.newValue) .then(response => response.text()) .then(eval) .catch(console.log); } } listener({key: 'script', newValue: localStorage.getItem('script')}); const updateNavigationText = () => window.top.postMessage(JSON.stringify(location)); updateNavigationText(); const pushState = history.pushState; history.pushState = (...args) => { pushState.apply(history, args); updateNavigationText(); }; window.addEventListener('storage', listener); window.addEventListener('popstate', updateNavigationText); </script> </html>
Именно он и будет определять библиотеки, которые бандл ищет в объекте window, а также следит через событие storage за обновлением бандла.
-
popstate ловит изменение хеша
-
переопределение history.pushState — ловит изменения url без хеша
В результате мы получим такой вид:

Заключение
В этом примере мы рассмотрели простейший способ создания песочницы. В дальнейшем его можно расширить — например, добавив виртуальную файловую систему с использованием memfs (как, например, здесь), а также поддержку других сред, используя плейграунд на blazor или pyodide.
Также можно расширить его применение, добавив совместный режим (monaco-collab-ext), но тут нам уже понадобится сервер. Такое решение мы использовали для нашей новой платформы собеседований.
Бонус
Дополнительно, как и обещал, расскажу про файловую систему. Использовать будем memfs (сокращение от memory file system). Эта библиотека — полная копия библиотеки fs из стандартного пакета для node.js.
Первым делом модифицируем файл webpack.config.js, добавив в resolve следующий блок:
fallback: { fs: require.resolve("memfs"), stream: require.resolve("stream-browserify"), buffer: require.resolve("buffer"), path: require.resolve("path-browserify"), assert: require.resolve("assert-browserify"), process: false, }
Это необходимо сделать, чтобы перенаправить системные вызовы к библиотекам-заместителям. Также нам необходимо создать файл со следующим содержанием:
var process = module.exports = {}; var cachedSetTimeout; var cachedClearTimeout; function defaultSetTimeout() { throw new Error('setTimeout has not been defined'); } function defaultClearTimeout() { throw new Error('clearTimeout has not been defined'); } (function () { try { if (typeof setTimeout === 'function') { cachedSetTimeout = setTimeout; } else { cachedSetTimeout = defaultSetTimeout; } } catch (e) { cachedSetTimeout = defaultSetTimeout; } try { if (typeof clearTimeout === 'function') { cachedClearTimeout = clearTimeout; } else { cachedClearTimeout = defaultClearTimeout; } } catch (e) { cachedClearTimeout = defaultClearTimeout; } }()) function runTimeout(fun) { if (cachedSetTimeout === setTimeout) { return setTimeout(fun, 0); } if ((cachedSetTimeout === defaultSetTimeout || !cachedSetTimeout) && setTimeout) { cachedSetTimeout = setTimeout; return setTimeout(fun, 0); } try { return cachedSetTimeout(fun, 0); } catch (e) { try { return cachedSetTimeout.call(null, fun, 0); } catch (e) { return cachedSetTimeout.call(this, fun, 0); } } } function runClearTimeout(marker) { if (cachedClearTimeout === clearTimeout) { return clearTimeout(marker); } if ((cachedClearTimeout === defaultClearTimeout || !cachedClearTimeout) && clearTimeout) { cachedClearTimeout = clearTimeout; return clearTimeout(marker); } try { return cachedClearTimeout(marker); } catch (e) { try { return cachedClearTimeout.call(null, marker); } catch (e) { return cachedClearTimeout.call(this, marker); } } } var queue = []; var draining = false; var currentQueue; var queueIndex = -1; function cleanUpNextTick() { if (!draining || !currentQueue) { return; } draining = false; if (currentQueue.length) { queue = currentQueue.concat(queue); } else { queueIndex = -1; } if (queue.length) { drainQueue(); } } function drainQueue() { if (draining) { return; } var timeout = runTimeout(cleanUpNextTick); draining = true; var len = queue.length; while (len) { currentQueue = queue; queue = []; while (++queueIndex < len) { if (currentQueue) { currentQueue[queueIndex].run(); } } queueIndex = -1; len = queue.length; } currentQueue = null; draining = false; runClearTimeout(timeout); } process.nextTick = function (fun) { var args = new Array(arguments.length - 1); if (arguments.length > 1) { for (var i = 1; i < arguments.length; i++) { args[i - 1] = arguments[i]; } } queue.push(new Item(fun, args)); if (queue.length === 1 && !draining) { runTimeout(drainQueue); } }; function Item(fun, array) { this.fun = fun; this.array = array; } Item.prototype.run = function () { this.fun.apply(null, this.array); }; process.title = 'browser'; process.browser = true; process.env = {}; process.argv = []; process.version = ''; process.versions = {}; function noop() { } process.on = noop; process.addListener = noop; process.once = noop; process.off = noop; process.removeListener = noop; process.removeAllListeners = noop; process.emit = noop; process.prependListener = noop; process.prependOnceListener = noop; process.listeners = function (name) { return [] } process.binding = function (name) { throw new Error('process.binding is not supported'); }; process.cwd = function () { return '/' }; process.chdir = function (dir) { throw new Error('process.chdir is not supported'); }; process.umask = function () { return 0; }; window.process = process; window.global = window;
Такой код позволит нам уводить системные вызовы в правильные заглушки, так как многих элементов окружения node.js в браузере нет.
Инициализируем образ виртуального диска:
import { Volume, IFs } from 'memfs'; this.vol = Volume.fromJSON({});
И напишем memfs-плагин для esbuild:
const getLibMainFile = (libName: string) => { const filePath = `${store.project.name}/node_modules/${libName}`; const isJS = FS.existsSync(`${filePath}.js`); const main = !isJS && (JSON.parse(FS.readFileSync(`${filePath}/package.json`, 'utf-8')).main ?? 'index.js'); return isJS ? `${filePath}.js` : path.resolve(filePath, main); } const resolve = ({ id, importer }: { id: string; importer: string; }) => { let resolvedPath = id; if (importer && id.startsWith('.')) { resolvedPath = path.resolve(path.dirname(importer), id); } for (const x of ['.ts', '.js', '.tsx', '.jsx']) { const realPath = resolvedPath + '/index' + x; if (fs.existsSync(realPath)) { return realPath; } } for (const x of ['', '.ts', '.js', '.css', '.tsx', '.jsx']) { const realPath = resolvedPath + x; if (fs.existsSync(realPath)) { return realPath; } } throw new Error(`${resolvedPath} not exists`); } export const pluginMemfs = (context: any): Plugin => { return { name: 'memfs-plugin', setup(build) { build.onResolve({ filter: /^\.{1,2}\/.*/, namespace: namespace }, (args) => { return { path: args.path, pluginData: args.pluginData, namespace: namespace, }; }); build.onLoad({ filter: /.*/, namespace: namespace }, async (args) => { let realPath = args.path; const resolvePath = resolve({ id: args.path, importer: args.pluginData.importer }); if (!resolvePath) { throw new Error('not found'); } realPath = resolvePath; const content = (await FS.readFileAsync(realPath)).toString(); return { contents: content, pluginData: { importer: realPath, }, loader: path.extname(realPath).slice(1) as 'js', }; }); }, }; }
Метод onResolve из плагина для загрузки node_modules:
build.onResolve({ filter: /^([^\.\/]).*/ }, (args) => { return { path: getLibMainFile(args.path), namespace: `node_modules`, pluginData: { ...args.pluginData, package: args.path, }, }; });
Обратите внимание на методы: getLibMainFile и resolve. Несмотря на то, что мы добавили файловую систему, esbuild не знает об этом и не может самостоятельно применить механизм определения пути. Этими методами мы разбираем разные случаи импортов, например:
-
import … from ‘libname’ — ищем package.json и поле main
-
import … from ‘libname/folder’ — ищем папку в библиотеке, а в ней package.json или index.js
-
import … from ‘libname/file’ — ищем файл в библиотеке
-
Разбираем разные случаи импортов из папок, так как пользователь может как указывать, так и не указывать расширение файла в импорте, и иметь или не иметь файлы index.ts или index.js.
На этом все. Ниже добавлю ссылки на все представленные в статье инструменты.
Ссылки
ссылка на оригинал статьи https://habr.com/ru/articles/568102/
Добавить комментарий