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

от автора

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

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

Введение

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

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


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *