前言

好久都没写博客了,这两天略微空闲些计划找个技术专题来写一些东西。本想写写最近非常火的 Turbopack,但考虑到没有深化的研究其运转原理,假如要写可能就要是流于表面讲讲用法,感觉价值也不是很高,故此仍是选了之前预研过的在线编辑器。

现在社区中一些开源的框架在布置文档时为方便开发者快速了解运用,都会完结一套自己的在线编辑器。如

  • omi-Playground
  • typescript-Play
  • vue-Playground
  • -Code

第一次体会这种功能时挺别致,浏览器端居然能够直接运转 typescript、less、scss 等代码,还能够有自己的独立的运转时环境,最重要的是沙箱环境中能够有自己的依靠项(沙箱中履行代码时能够主动加载对应的依靠,如加载 vue、react 这种运转时依靠)。

后续也连续调研了此类东西的一些架构,根据这些理论架构花费了几天的时刻简略完结了一套 WebComponent 技术栈的在线编辑器,感兴趣的能够 在线体会, 浏览器代开奥 。

已然专题已定,那说干就干,先做一个根本的架构规划。

架构规划

已然要做在线的编译器,那首先得支撑编辑代码,其次得有一个能独立运转的沙箱环境,最后便是需求具有代码的编译能力(浏览器不支撑直接履行 typescript、less 此类的代码)。

怎么花一天时刻打造一款前端在线代码编辑器——完结代码预览

根据设想做了一个简略的架构,架构根据浏览器以及 WebWorker 环境,Compiler 是中心纽带担任三方通讯,有了根本的架构规划,后续开端针对每个模块进行技术选型以及开发。

模块规划

编辑器

Web 编辑器是前端领域中算是比较深化的一个领域了,常见的 Md 编辑器、富文本编辑器等,从能力层来说,任何具有输入能力的控件都能承担架构中 Editor 的人物,但考虑到用户体会,如代码智能提示、代码格局美化、主题色等,故此仍是选一款老练的编辑器。

现在社区中也有许多优异代码编辑器,比如,

  • codemirror
  • monaco-editor
  • vue-codemirror

codemirror是一块比较老牌的插件,功能非常丰富,但工程化集成略微困难些,因而计划中未采用该插件,故此此处不再赘述演示,感兴趣的能够移步去官网瞅瞅。

此计划中选用大大名顶顶的 monaco-editor 的编辑器,monaco-editor 是一个浏览器端的代码编辑器库,一起它也是 VS Code 所运用的编辑器。monaco-editor 能够看作是一个编辑器控件,只供给了基础的编辑器与言语相关的接口,能够被用于任何根据 Web 技术构建的项目中,而 VS Code 包括了文件管理、版别控制、插件等功能,是一款桌面软件。monaco-editor 的 GitHub 仓库中不包括任何实践功能代码,因为其源代码与 VS Code 在同一个仓库,只是在版别发布时会构建出独立的编辑器代码。现在社区中关于集成 monaco-editor 的计划比较多,此处大致做一个计划对比。

monaco-editor-webpack-plugin

插件安装

monaco-editor-webpack-plugin 是一个根据 webpack 的集成计划,周下载量大致 204k 左右,此处拷贝了下官网的集成代码。

const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin');
const path = require('path');
module.exports = {
	entry: './index.js',
	output: {
		path: path.resolve(__dirname, 'dist'),
		filename: 'app.js'
	},
	module: {
		rules: [
			{
				test: /.css$/,
				use: ['style-loader', 'css-loader']
			},
			{
				test: /.ttf$/,
				use: ['file-loader']
			}
		]
	},
	plugins: [new MonacoWebpackPlugin()]
};

实例化

import * as monaco from 'monaco-editor';
// or import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
// if shipping only a subset of the features & languages is desired
monaco.editor.create(document.getElementById('container'), {
	value: 'console.log("Hello, world")',
	language: 'javascript'
});

@monaco-editor/loader

monaco-editor-webpack-plugin 虽好,但约束了东西链只能 webpack 运用,经过开源社区了解到 @monaco-editor/loader ,周下载量大致 224k 左右, 用官网的描述便是

The utility to easy setup monaco-editor into your browser。Configure and download monaco sources via its loader script, without needing to use webpack’s (or any other module bundler’s) configuration files

实例化

import loader from '@monaco-editor/loader';
loader.init().then(monaco => {
  monaco.editor.create(document.querySelector("#dom"), {
    value: '// some comment',
    language: 'javascript',
  });
});

@monaco-editor/react

@monaco-editor/loader 计划很优异,但货比三家仍是调研了别的一个计划,@monaco-editor/react ,周下载量大致 219k 左右,是一款根据 react 的组件。

Monaco Editor for React use the monaco-editor in any React application without needing to use webpack (or rollup/parcel/etc) configuration files / plugins

实例化

import React from "react";
import ReactDOM from "react-dom";
import Editor from "@monaco-editor/react";
function App() {
  return (
   <Editor
     height="90vh"
     defaultLanguage="javascript"
     defaultValue="// some comment"
   />
  );
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

Editor 的完结最后运用了 monaco-editor + @monaco-editor/loader 的计划,封装了一个根据 WebComponent 的插件:wu-code-monaco-editor 。

沙箱环境

因编辑器输入代码的不可信任,所以需求一个沙箱环境来履行代码, 避免程序访问/影响主页面。

在计算机安全中,沙箱(Sandbox)是一种用于阻隔正在运转程序的安全机制,一般用于履行未经测验或不受信任的程序或代码,它会为待履行的程序创立一个独立的履行环境,内部程序的履行不会影响到外部程序的运转。

该计划中选用了最传统的 Iframe 计划,毕竟它的兼容性最好,功能最完善(沙箱做的最完全,js 作用域、css 阻隔等),但此处仍是列举了几个社区中其他的沙箱计划。

Proxy Sandbox

能够经过署理 Proxy 完结目标的绑架, 经过 window 目标的修正进行记载,在卸载时删除这些记载,在运用再次激活时康复这些记载,来到达模拟沙箱环境的意图。此处贴了一份社区中的完结代码,能够略作研习。

// 修正window特点的公共办法
const updateHostProp = (prop: any, value, isDel?) => {
    if (value === undefined || isDel) {
        delete window[prop];
    } else {
        window[prop] = value;
    }
};
class ProxySandbox {
    private currentUpdatedPropsValueMap = new Map()
    private modifiedPropsMap = new Map()
    private addedPropsMap = new Map()
    public name: string = "";
    public proxy: any;
    /**
     * 激活沙箱
     */
    public active() {
        // 根据记载还原沙箱
        this.currentUpdatedPropsValueMap.forEach((v, p) => updateHostProp(p, v));
    }
    /**
     * 封闭沙箱
     */
    public inactive() {
        // 1 将沙箱期间修正的特点还原为原先的特点
        this.modifiedPropsMap.forEach((v, p) => updateHostProp(p, v));
        // 2 将沙箱期间新增的全局变量消除
        this.addedPropsMap.forEach((_, p) => updateHostProp(p, undefined, true));
    }
    constructor(name) {
        this.name = name;
        this.proxy = null;
        // 寄存新增的全局变量
        this.addedPropsMap  = new Map();
        // 寄存沙箱期间更新的全局变量
        this.modifiedPropsMap = new Map();
        // 存在新增和修正的全局变量,在沙箱激活的时分运用
        this.currentUpdatedPropsValueMap = new Map();
        const { addedPropsMap, currentUpdatedPropsValueMap, modifiedPropsMap } = this;
        const fakeWindow = Object.create(null);
        const proxy = new Proxy(fakeWindow, {
            set(target, prop, value) {
                if (!window.hasOwnProperty(prop)) {
                    // 假如window上没有的特点,记载到新增特点里
                    addedPropsMap.set(prop, value);
                } else if (!modifiedPropsMap.has(prop)) {
                    // 假如当时window目标有该特点,且未更新过,则记载该特点在window上的初始值
                    const originalValue = window[prop];
                    modifiedPropsMap.set(prop, originalValue);
                }
                // 记载修正特点以及修正后的值
                currentUpdatedPropsValueMap.set(prop, value);
                // 设置值到全局window上
                updateHostProp(prop, value);
                return true;
            },
            get(target, prop) {
                return window[prop];
            },
        });
        this.proxy = proxy;
    }
}
const newSandBox: ProxySandbox = new ProxySandbox('署理沙箱');
const proxyWindow = newSandBox.proxy;
proxyWindow.a = '1';
console.log('敞开沙箱:', proxyWindow.a, window.a);
newSandBox.inactive(); //失活沙箱
console.log('失活沙箱:', proxyWindow.a, window.a);
newSandBox.active(); //失活沙箱
console.log('从头激活沙箱:', proxyWindow.a, window.a);

以上代码完结了基础版的沙箱, 经过 active 办法开端沙箱署理,社区中的 qiankunu 等此类的微前端架构中根本都采用了此类的规划。

Diff Sandbox

除 Proxy 方式外,我们能够经过 diff 的方式创立沙箱,一般作为 Proxy Sandbox 的降级计划,在运用运转的时分保存一个快照 window 目标,将当时 window 目标的全部特点都复制到快照目标上,子运用卸载的时分将 window 目标修正做个 diff,将不同的特点用个 modifyMap 保存起来,再次挂载的时分再加上这些修正的特点。

class DiffSandbox {
    public name: any;
    public modifyMap: {};
    private windowSnapshot: {};
    constructor(name) {
        this.name = name;
        this.modifyMap = {}; // 寄存修正的特点
        this.windowSnapshot = {};
    }
    public active() {
        // 缓存active状态的沙箱
        this.windowSnapshot = {};
        for (const item in window) {
            this.windowSnapshot[item] = window[item];
        }
        Object.keys(this.modifyMap).forEach(p => {
            window[p] = this.modifyMap[p];
        });
    }
    public inactive() {
        for (const item in window) {
            if (this.windowSnapshot[item] !== window[item]) {
                // 记载变更
                this.modifyMap[item] = window[item];
                // 还原window
                window[item] = this.windowSnapshot[item];
            }
        }
    }
}
const diffSandbox = new DiffSandbox('diff沙箱');
diffSandbox.active();  // 激活沙箱
window.a = '1';
console.log('敞开沙箱:', window.a);
diffSandbox.inactive(); //失活沙箱
console.log('失活沙箱:', window.a);
diffSandbox.active();   // 从头激活
console.log('再次激活', window.a);

iframe

iframe 计划是该规划中的沙箱计划,此处细细道说。

怎么花一天时刻打造一款前端在线代码编辑器——完结代码预览

宿主环境中经过实例化 new ProxySandbox() 操作来创立加载 Iframe, Iframe 加载完毕后会监听来自宿主的消息,比如履行代码、加载依靠。内部也能够经过 postMessage 向宿主环境发送消息,此逻辑参考了 @vue/repl

let uid = 1;
export class ProxySandbox {
    iframe: HTMLIFrameElement
    handlers: Record<string, Function>
    pending_cmds: Map<
        number,
        { resolve: (value: unknown) => void; reject: (reason?: any) => void }
        >
    handle_event: (e: any) => void
    constructor(iframe: HTMLIFrameElement, handlers: Record<string, Function>) {
        this.iframe = iframe;
        this.handlers = handlers;
        this.pending_cmds = new Map();
        this.handle_event = (e) => this.handle_repl_message(e);
        window.addEventListener('message', this.handle_event, false);
    }
    destroy() {
        window.removeEventListener('message', this.handle_event);
    }
    iframe_command(action: string, args: any) {
        return new Promise((resolve, reject) => {
            const cmd_id = uid++;
            this.pending_cmds.set(cmd_id, { resolve, reject });
            this.iframe.contentWindow!.postMessage({ action, cmd_id, args }, '*');
        });
    }
    handle_command_message(cmd_data: any) {
        const action = cmd_data.action;
        const id = cmd_data.cmd_id;
        const handler = this.pending_cmds.get(id);
        if (handler) {
            this.pending_cmds.delete(id);
            if (action === 'cmd_error') {
                const { message, stack } = cmd_data;
                const e = new Error(message);
                e.stack = stack;
                handler.reject(e);
            }
            if (action === 'cmd_ok') {
                handler.resolve(cmd_data.args);
            }
        } else if (action !== 'cmd_error' && action !== 'cmd_ok') {
            console.error('command not found', id, cmd_data, [
                ...this.pending_cmds.keys()
            ]);
        }
    }
    handle_repl_message(event: any) {
        if (event.source !== this.iframe.contentWindow) return;
        const { action, args } = event.data;
        this.handlers.on_default_event(event);
        switch (action) {
            case 'cmd_error':
            case 'cmd_ok':
                return this.handle_command_message(event.data);
            case 'fetch_progress':
                return this.handlers.on_fetch_progress(args.remaining);
            case 'error':
                return this.handlers.on_error(event.data);
            case 'unhandledrejection':
                return this.handlers.on_unhandled_rejection(event.data);
            case 'console':
                return this.handlers.on_console(event.data);
            case 'console_group':
                return this.handlers.on_console_group(event.data);
            case 'console_group_collapsed':
                return this.handlers.on_console_group_collapsed(event.data);
            case 'console_group_end':
                return this.handlers.on_console_group_end(event.data);
        }
    }
    eval(script: string | string[]) {
        return this.iframe_command('eval', { script });
    }
    handle_links() {
        return this.iframe_command('catch_clicks', {});
    }
    load_depend(options: Record<any, any>) {
        return this.iframe_command('load_dependencies', options);
    }
}

在线编译

Editor 和 Sandbox 计划既定,最后便是代码的编译问题,此计划中仅触及 TypeScript 的编译。

monaco-editor 供给了 Worker 编译代码的能力,运用起来也是非常方便,读取到编辑器中输入的代码后直接输入到 Worker 中,等候编译完结再调用上章中沙箱供给的 eval 的接口送入沙箱中即可。


export const compileTS = async (uri: InstanceType<typeof monaco.Uri>) => {
    // const tsWorker = await monaco.languages.typescript.getTypeScriptWorker();
    const monaco = window.monaco;
    // 读取编译子线程
    const tsWorker = await monaco.languages.typescript.getTypeScriptWorker();
    const client = await tsWorker(uri);
    const result = await client.getEmitOutput(uri.toString());
    const files = result.outputFiles[0];
    return files.text;
};
export class WuCodePlayground extends WuComponent {
    /// .....code
    constructor() {
        super();
    }
    /**
     * 中心逻辑, 读取输入的代码,履行 compileTS 编译
     */
    public async runCode() {
        const editor = this.editorContainer.editor;
        const tsJs: string = await compileTS(editor.getModel("typescript").uri);
        this.previewContainer.runCode('ts', tsJs);
    }
    /// .....code
}

至于其他比如 less, scss 等的编译问题社区中也有老练的计划:

  • less
  • sass

考虑

时刻太晚了写不动了,此计划在实时过程中有许多的细节问题后续抽暇在记载吧,如沙箱中经过怎么经过 import-maps 加载运转时依靠、沙箱与宿主间通讯怎么保证安稳、以及 WebComponent 不能重复界说等问题。

  • 感兴趣的能够移步到这儿参阅源码
  • 组件

参考资料

  • @vue/repl
  • monaco-editor
  • writing-a-javascript-framework-sandboxed-code-evaluation
  • create-a-custom-web-editor-using-typescript-react-antlr-and-monaco-editor
  • To create a lightweight WebIDE, reading this article is enough
  • import-maps