Как стать автором
Обновить
75.26
Райффайзен Банк
Развеиваем мифы об IT в банках

Как написать свою онлайн-песочницу с поддержкой React и популярных библиотек

Время на прочтение12 мин
Количество просмотров5.9K

Привет, меня зовут Илья, я лидер сообщества фронтенд-разработки в Райффайзенбанке. Среди моих задач — проведение собеседований, при этом хочется, чтобы этот процесс был эффективным, продуктивным и полезным. Для проведения таких собеседований нам нужно выбрать классный инструмент для совместного написания кода. Изучив рынок инструментов, мы пришли к выводу, что из-за безопасности и функциональности проще написать свое решение. 

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

Введение

Эта небольшая статья включает в себя разбор таких инструментов:

  • 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.

На этом все. Ниже добавлю ссылки на все представленные в статье инструменты. 

Ссылки

Теги:
Хабы:
Всего голосов 6: ↑6 и ↓0+6
Комментарии1

Публикации

Информация

Сайт
www.raiffeisen.ru
Дата регистрации
Дата основания
1996
Численность
5 001–10 000 человек
Местоположение
Россия