
Привет, меня зовут Илья, я лидер сообщества фронтенд-разработки в Райффайзенбанке. Среди моих задач — проведение собеседований, при этом хочется, чтобы этот процесс был эффективным, продуктивным и полезным. Для проведения таких собеседований нам нужно выбрать классный инструмент для совместного написания кода. Изучив рынок инструментов, мы пришли к выводу, что из-за безопасности и функциональности проще написать свое решение.
В этой статье я расскажу, как создать главный элемент платформы совместного написания и проверки кода — онлайн-песочницу. Она будет поддерживать один из самых популярных фреймворков и несколько библиотек для него, что достаточно для проверки необходимых навыков и умений.
Введение
Эта небольшая статья включает в себя разбор таких инструментов:
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.
На этом все. Ниже добавлю ссылки на все представленные в статье инструменты.
Ссылки