手摸手打造类码上掘金在线IDE(四)——双向通信

2022-11-22 18:30:33 浏览数 (1)

前言

写字楼里写字间,写字间里程序员;

程序人员写程序,挣点小钱过大年

一首定场诗送给大家

上回书说道,一个在线IDE所必备的条件之一——沙箱环境,我们讲了现在市面上主流的沙箱环境的原理

讲了现在主流IDE 都在使用iframe 作为解决方案,因为他天然的隔离,能使得大家省心不少。

但是众所周知,iframe不是银弹有得必有失,所谓鱼与熊掌不可兼得

iframe 也有着两个难题

1、通信

2、跨域

在实现的过程中,需要花费很多力气绕很多弯路来达到目的。

有人问了,那为啥有缺点,这么多在线IDE 去争先恐后的用它呢?

那是因为他们都相信中国的一句老话,两权相害取其轻

比起,天然的沙箱能力,多写点代码绕点路又算得了什么呢?

接下来,我们就开始多写点代码,是多绕点弯路,来解决这两个问题

通信

说起iframe 的通信,相信对这个标签稍微有点了解的jym都对他深恶痛绝

因为,由于iframe 本身的限制,导致想要打通宿主环境,和沙箱环境,只能通过window.postMessage 来传递。

既不能自己定义格式,也不能设置全局状态,还得各个环境都允许通信监听通信

当然,最后一个我也能理解,己所不欲勿施于人对吧,

可我自己同时操作,宿主,和沙箱啊,为啥不能给我们这些人来个特权

额,有点跑题了,接下来,我们先来温习一下,这个恶心postMessage

postMessage

postMessage 通俗的讲,是可以安全的实现沙箱和宿主的安全通信,他的使用方式需要两步

1、宿主使用postMessage发送数据 ,沙箱使用message 事件接收数据,代码如下:

代码语言:javascript复制
  // 1、父页面向子页面发送消息
   let data = { type: 'data', data: 'aaaaaa' };
  iframe.contentWindow.postMessage(data, '*');
    // 3、接收消息方法
   window.addEventListener('message', function (e) { })

大家发现其实的使用方式也不是那么恶心,甚至还有点简单。

其实这么理解,你就错了,因为我们现在看到的只是单向通信,而我们要做的确实双向通信,也就是宿主,需要给沙箱发消息,沙箱也需要给宿主发消息。

有人就好奇了,为啥要这样呢?

其实原因很简单,因为你要拿到沙箱的工作状态,以便,能及时的传递数据啊

举个例子,沙箱必然有初始化吧, 那么你他必须给你初始化完成的信号,你才能传入数据来让他执行编译

所以,接下来,我们就需要设计一个双向通信的设计

设计双向通信

我们在设计双向通信之前我们先需要有一个iframe,所以他必须有沙箱外部创建,然后在传给沙箱,这样才能将沙箱内的代码和沙箱外的代码玩去隔离

说干就干,我们开始

代码语言:javascript复制
export class sandbox {
    // 默认挂载节点
    selector: string | undefined;
    element: Element;
    iframe: HTMLIFrameElement;
    constructor(selector: string | HTMLIFrameElement,) {
        //初始化容器
        // 兼容处理
        if (typeof selector === "string") {
            this.selector = selector;
            const element = document.querySelector(selector);
            this.element = element;
            this.iframe = document.createElement("iframe") as HTMLIFrameElement;
        } else {
            this.element = selector;
            this.iframe = selector;
        }
        // 设置宽高、边框等参数
        this.iframe.style.border = "0";
        this.iframe.style.width = "100%";
        this.iframe.style.height = "100%";
        this.iframe.style.overflow = "hidden";
        // 执行挂载创建容器
        this.element.parentNode.replaceChild(this.iframe, this.element);
   
        if (!this.iframe.getAttribute("sandbox")) {
            // 添加必备属性来抹除所有限制条件
            this.iframe.setAttribute(
                "sandbox",
                "allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
            );
            // 添加id方便辨识
            this.iframe.setAttribute(
                "id",
                "sandbox-preview"
            );

        }
    }
}

如上代码所示,我们有了一个创建沙箱的构造函,执行构造函数之后,我们就相当于创建了一个沙箱环境的iframe

但是此时此刻,我们只是一个空的iframe的创建,我们怎么跟沙箱联动呢?

别着急听我一个个解析!

我们知道一个iframe的想执行属于自己的js有两种途径

iframe执行方式

我们知道在iframe中常用的执行方式有三种

  • 1、srcdoc执行方式
  • 2、src 的执行方式
  • 3、contentWindow 塞入html方式

srcdoc执行方式

srcdoc,简单的讲,就是利用 srcdoc属性,嵌入html 文本代码

代码如下:

代码语言:javascript复制
const iframe = document.createElement("iframe");
iframe.srcdoc = `<!DOCTYPE html><p>Hello World!</p>`;
document.body.appendChild(iframe);

src执行方式

src执行方式这是最多见的,大家应该都能理解,就是个链接,我们直接引入之后,自动渲染

代码如下:

代码语言:javascript复制
 <iframe id="inlineFrameExample" title="Inline Frame Example" width="1000" height="1000" src="https://juejin.cn/">
    </iframe>

contentWindow 塞入html方式

这种方式就比较有意思了他得有一个前提就是在不跨域的情况下才能操作

代码如下:

代码语言:javascript复制
  <iframe id="foo" width="1000" height="1000"></iframe>
    <script>
        var iframe = document.getElementById('foo'),
            iframedoc = iframe.contentDocument || iframe.contentWindow.document;
        iframedoc.body.innerHTML = `<div>Hello world</div>`;
    </script>

当然在跨域的情况下,他是修改不成功的!

代码语言:javascript复制
 <iframe id="foo" width="1000" height="1000" src="https://juejin.cn/"></iframe>
    <script>

        setTimeout(() => {
            var iframe = document.getElementById('foo'),
                iframedoc = iframe.contentDocument || iframe.contentWindow.document;
            console.log(iframedoc.body);
            iframedoc.body.innerHTML = `<div>Hello world</div>`;
        }, 5000)

    </script>

如上图所示,如果你要改的话他就会报错

方案选择

基于以上三种方案,啊不,其实是两种方案,第三种方案,你发现压根都不能用,因为他直接塞入html,相当于你整个的渲染编译还是在当前的宿主环境,只是呈现在iframe中进行!最多也就是有个css 样式隔离,显然不可用。

那么可供商榷的就是两种了

此时,这两种方案的选择,就看你的需求场景了,如果你需要将编译bundler抽离为一个单独的项目,那么src方案当然就是一个非常好的选择!

因为你项目可以单独上线,单独部署!

而如果你想要和编辑器放在一起,那当然需要docsrc方案!并且我们可以将代码做的不是那么解耦!让别人难以维护,

如此一来,你就可以不可代替,你的饭碗岂不是能万古长存

那对于我来说,当然是第一种啊,我可是对于代码质量有着严格的要求!

既然选择了最难的路,那我们含着泪也得干完啊!

我们知道 src 引入方式他首先有个跨域限制,于是,跨域限制就会导致我们只能通过postMessage 来通信

我们上回书说道,如果通过 postMessage来通信!

他要具备几个步骤

  • 1、外界初始化Iframe,并传入沙箱内部
  • 2、内部初始化完成需要通知外界
  • 3、外界收到通知,需要通知沙箱启动编译
  • 4、编译完成启动启动渲染,挂载
  • 5、内容变化需要通知沙箱启动再次编译

接下来我们就需要一个个解析

设计流程

在前面的解释中我们已经完成了第一步,外接初始化iframe,我们也提到过,我们在宿主和沙箱的链接选中src 的方案。

既然是这种方案,那么他一定是个请求,请求他就会有初始化时间,因为他是个异步的,所以我们需要内部初始化完成,才能执行编译渲染等能力。

这样一来,我们就需要沙箱通知宿主,初始化完成

内部初始化完成需要通知外界

内部初始化完成需要通知外界的设计其实很简单,就是外接也监听一个postMessage 即可,代码书写也很简单,我们只需要在当前sandbox方法上拓展即可

代码如下:

代码语言:javascript复制
export class sandbox {
    // 默认挂载节点
    selector: string | undefined;
    element: Element;
    iframe: HTMLIFrameElement;
    listener = [];
    // 生成id防止多个实例混乱,传入沙箱中也需要保存
    channelId: number = Math.floor(Math.random() * 1000000);
    // 监听message 事件
    iframeProtocol() {
        //兼容判断
        if (typeof window !== "undefined") {
            window.addEventListener("message", this.eventListener);
        }
    }
    // 如果监听到message事件,就执行回调函数
    //这个回调函数可以是执行编译,也可以是执行其他操作
    eventListener() {
        this.listener.forEach((listener) => {
            listener()
        })
    }
}

那么相应的,在宿主中有了监听,那么我们在沙箱中就得有发送

代码如下:

代码语言:javascript复制
export class sandboxInstance {
    // 整体的bundler 实例
    private bundler;
    // 父组件生成的id 用于区分不同的实例
    private parentId: number | null = null;
    constructor() {
        // 初始化完成之后只需要向父组件发送初始化完成的消息
        this.sendMessage('init');
    }
    _postMessage(message: any) {
        // 向父组件通知消息

        window.parent.postMessage(message, '*');
    }
    // 向父组件发送事件
    sendMessage(type: string, data: Record<string, any> = {}) {
        // 向父组件发送消息,并且根据tpye来区分不同的消息
        this._postMessage({
            ...data,
            $id: this.parentId,
            type,
            codesandbox: true,
        });
    }
}

此时当外界接收到消息之后,就可以启动编译了,还记得我们之前的listener 的伏笔吗?

他就是我们启动编译的关键,由于在通常的代码设计中,我们为了代码结构的结构,通常我们就会使用这种设计模式,来解决问题,这也是常用的发布订阅模式

不熟悉的jym可能不太理解,我给大家简单的普及一下,所谓发布订阅模式的本质,说穿了,就是利用数组的存储能力在在需要的时候执行而已

他的流程图如下,只不过,为了更好的使用,或者说规范的使用,大佬们,给他做了很多的封装,比如加个什么订阅的方法啊加个什么发布的方法啊

但是,我想说,万变不离其宗,我们学习就是要抓关键!

那些厉害的大哥,现在在我看来,他们就是会抓关键,一个知识点,他们上来就知道什么重要,顺着这个重要的线索去走,很快就能找到答案!

这个在行当里,其实也叫做慧根!

有人问,本来很简单的东西,为啥非要搞得这么复杂,加这么多定义啊!

其实俺以为,这就是为了传播和使用

所谓为了传播,就是他为了形成文字,就必须加很多的概念和定语来表达清楚,这个东西是啥!

他不像我们面对面教学,我说不清楚,我可以比划,或者举例子来解决问题。

既然是形成文字,那他必须这样做,他没有办法做到在古代面对面教学这么智能!

当然如果能面对面教学,口传心授那当然是最好

我们古代讲究口传心授!口传是大德行,心授是大智慧

然而,现在社会做不到啊,所以就有了这么多复杂的定语概念!

使用这个就比较好理解了,就是为了方便我们简单的能用,封装了堆东西,

但是由于行业内卷,我们必须得知道原理!本来很简单就能用就行的东西,就变得复杂!

外界收到通知,需要通知沙箱启动编译

此时我们已经通知外界了,我们要做的就是利用之前的发布订阅模式将编译编译指令再发送到沙箱中去,在沙箱中启动编译即可

如此一来我们的双向通信就可谓说大功告成了!

接下来我们说干就干!

我们现在封装一个发布订阅的代码

代码语言:javascript复制
export class sandbox {
    // 默认挂载节点

    listeners = {};

    constructor(selector: string | HTMLIFrameElement,) {
        //订阅事件
        this.on('js1', () => {
            console.log('js1')
        });
        //发布事件
        this.emit('js1');
    }
    // 订阅事件
    on(event, fn) {
        let _this = this;
        // 如果对象中没有对应的 event 值,也就是说明没有订阅过,就给 event 创建个缓存列表
        // 如有对象中有相应的 event 值,把 fn 添加到对应 event 的缓存列表里
        (_this.listeners[event] || (_this.listeners[event] = [])).push(fn);
        return _this;
    }
    // 发布事件
    emit(event) {
        let _this = this;
        // 第一个参数是对应的 event 值,直接用数组的 shift 方法取出
        let fns = [..._this.listeners[event]];
        // 如果缓存列表里没有 fn 就返回 false
        if (!fns || fns.length === 0) {
            return false;
        }
        // 遍历 event 值对应的缓存列表,依次执行 fn
        fns.forEach(fn => {
            fn.apply(_this, arguments);
        });
        return _this;
    }
}

我们利用on 来订阅事件,利用emit来发送事件即可!

接下来的内容就很简单了!

我们在收到沙箱发送的指令之后,发送事件即可

代码如下:

代码语言:javascript复制
export class sandbox {
   iframeProtocol() {
        //兼容判断
        if (typeof window !== "undefined") {
            window.addEventListener("message", this.eventListener);
        }
        // 监听沙箱指令之后订阅事件
        this.on("init", () => {
            this.updatePreview();
        })
    }
}
  // 如果监听到message事件,就执行回调函数
    //这个回调函数可以是执行编译,也可以是执行其他操作
    eventListener() {
        this.emit('init')
    }
    
     // 封装传递指令的方法
    dispatch(message): void {
        if (!this.frameWindow) {
            return;
        }
        // 发送指令
        this.frameWindow.postMessage(
            {
                $id: this.channelId,
                codesandbox: true,
                ...message,
            },
            "*"
        );
    }
    updatePreview() {
        const code = `hello world`;
        // 发送指令
        this.dispatch({
            type: "update-preview",
            data: code
        });
    }

这一步完成之后,我们就算是完全完成了整个在线IDE的双向通信机制,完整代码如下:

代码语言:javascript复制
// 宿主端
export class sandbox {
    // 默认挂载节点
    selector: string | undefined;
    element: Element;
    iframe: HTMLIFrameElement;
    listeners = {};
    frameWindow: null | Window  // iframe的window对象
    channelId: number = Math.floor(Math.random() * 1000000);
    constructor(selector: string | HTMLIFrameElement,) {
        //初始化容器
        // 兼容处理
        if (typeof selector === "string") {
            this.selector = selector;
            const element = document.querySelector(selector);
            this.element = element;
            this.iframe = document.createElement("iframe") as HTMLIFrameElement;
        } else {
            this.element = selector;
            this.iframe = selector;
        }
        // 设置宽高、边框等参数
        this.iframe.style.border = "0";
        this.iframe.style.width = "100%";
        this.iframe.style.height = "100%";
        this.iframe.style.overflow = "hidden";
        // 执行挂载
        this.element.parentNode.replaceChild(this.iframe, this.element);
        //创建容器
        if (!this.iframe.getAttribute("sandbox")) {
            // 添加必备属性来抹除所有限制条件
            this.iframe.setAttribute(
                "sandbox",
                "allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
            );
            // 添加id方便辨识
            this.iframe.setAttribute(
                "id",
                "sandbox-preview"
            );

        }
        // 拿到iframe 的window对象实例
        this.frameWindow = this.iframe.contentWindow;

        this.iframeProtocol()
    }
    // 订阅事件
    on(event, fn) {
        let _this = this;
        // 如果对象中没有对应的 event 值,也就是说明没有订阅过,就给 event 创建个缓存列表
        // 如有对象中有相应的 event 值,把 fn 添加到对应 event 的缓存列表里
        (_this.listeners[event] || (_this.listeners[event] = [])).push(fn);
        return _this;
    }
    // 发布事件
    emit(event) {
        let _this = this;
        // 第一个参数是对应的 event 值,直接用数组的 shift 方法取出
        let fns = [..._this.listeners[event]];
        // 如果缓存列表里没有 fn 就返回 false
        if (!fns || fns.length === 0) {
            return false;
        }
        // 遍历 event 值对应的缓存列表,依次执行 fn
        fns.forEach(fn => {
            fn.apply(_this, arguments);
        });
        return _this;
    }

    // 监听message 事件
    iframeProtocol() {
        //兼容判断
        if (typeof window !== "undefined") {
            window.addEventListener("message", this.eventListener);
        }
        // 监听沙箱指令之后订阅事件
        this.on("init", () => {
            this.updatePreview();
        })
    }
    // 如果监听到message事件,就执行回调函数
    //这个回调函数可以是执行编译,也可以是执行其他操作
    eventListener() {
        this.emit('init')
    }
    // 封装传递指令的方法
    dispatch(message): void {
        if (!this.frameWindow) {
            return;
        }
        // 发送指令
        this.frameWindow.postMessage(
            {
                $id: this.channelId,
                codesandbox: true,
                ...message,
            },
            "*"
        );
    }
    updatePreview() {
        const code = `hello world`;
        // 发送指令
        this.dispatch({
            type: "update-preview",
            data: code
        });
    }
}
//沙箱环境
export class sandboxInstance {
    // 整体的bundler 实例
    private bundler;
    // 父组件生成的id 用于区分不同的实例
    private parentId: number | null = null;
    constructor() {
        this._messageListener()
        // 初始化完成之后只需要向父组件发送初始化完成的消息
        this.sendMessage('init');

    }
    // 监听编译消息
    _messageListener() {
        window.addEventListener('message', this.messageListener);
    }
    messageListener(event) {
        // 执行编译等逻辑
    }
    _postMessage(message: any) {
        // 向父组件通知消息

        window.parent.postMessage(message, '*');
    }
    // 向父组件发送事件
    sendMessage(type: string, data: Record<string, any> = {}) {
        // 向父组件发送消息,并且根据tpye来区分不同的消息
        this._postMessage({
            ...data,
            $id: this.parentId,
            type,
            codesandbox: true,
        });
    }
}

我这里只是一个指令类型的处理,其实在真正的通信中,还有很多类型,比如完成之后的通信,重新渲染的通信,等等

在这里我们暂且按下不表,因为后面还有个重头戏,编译,这一块是整个内容中最重要的部分,因为涉及babelvuesfc解析等内容,留给我的时间不多了...

最后

我们这一期讲了在线ide的双向通信,原理以及机制!但是在这个庞大的系统面前才算完成了第四步数!

后面还有5、6、7、8、9、10步.....

预知后事如何,还切听我下回分解!

0 人点赞