yliu

时来天地皆同力,运去英雄不自由

优雅传递 postMessage


在最近开发的过程中遇到了一个问题,在集成 Vue SFC Playground 的时候同时也使用了 monaco-editor,而 Vue SFC Playground 使用的默认编辑器就是 monaco-editor-core

image

这就导致了一个问题,存在两个编辑器,但是它们都定义了全局变量 self.MonacoEnvironment 。导致虽然功能还能用,但是高亮以及智能联想全部没了。

点击展开具体 `MonacoEnvironment` 代码
// self.MonacoEnvironment通常情况下是这样的
import * as monaco from "monaco-editor";
import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker";
import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker";
import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker";
import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker";

// @ts-ignore
self.MonacoEnvironment = {
  getWorker(_: unknown, label: string) {
    if (label === "json") {
      return new jsonWorker();
    }
    if (label === "css" || label === "scss" || label === "less") {
      return new cssWorker();
    }
    if (label === "html" || label === "handlebars" || label === "razor") {
      return new htmlWorker();
    }
    if (label === "typescript" || label === "javascript") {
      return new tsWorker();
    }
    return new editorWorker();
  },
};

monaco.languages.typescript.typescriptDefaults.setEagerModelSync(true);

Vue SFC Playground 的定义是这样的

(self as any).MonacoEnvironment = {
  async getWorker(_: any, label: string) {
    if (label === "vue") {
      const worker = new vueWorker();
      const init = new Promise<void>((resolve) => {
        worker.addEventListener("message", (data) => {
          if (data.data === "inited") {
            resolve();
          }
        });
        worker.postMessage({
          event: "init",
          tsVersion: store.typescriptVersion,
          tsLocale: store.locale,
        } satisfies WorkerMessage);
      });
      await init;
      return worker;
    }
    return new editorWorker();
  },
};

还有没有办法愉快玩耍了,痛苦。 a2790e6d31980a2207a54d9fffeb5489

解决方案

于是就在脑海中风暴,有没有解决方案呢?

  1. 直接把 monaco-editod 干掉,但是这样在编写在线代码的时候,同一路由的其他 monaco-editor 就无法使用了。
  2. 使用 iframe 来对 Vue SFC Playground 进行隔离,缺点就是工作量比较大。

最终还是决定使用方案 2,虽然会增加很多工作量,但是从长久来看是更有收益的。

iframe 实现思路

通过 vue-route 注册一个特定的路由,它的作用就是嵌套 iframe 元素,通过这个路由来渲染 Vue SFC Playground 项目。

之后所有跟编辑器相关的值和状态全部保留在 iframe 内,但是如果需要使用则通过 postMessage 来进行传递。 image-2

具体来说,iframe 内部的变量全部都是都是自身独享的,如果其他模块需要调用,例如给 Vue SFC Playground 插入一个新文件,更改 importsMap 等操作,需要通过主页面的 postMessage 来更新 iframe。取值也是发送消息之后,从主页面接收 message 消息完成取值。

等等,你不会以为我这篇文章只为说这个吧。

1baacc180ee270013391d16077abff8e

回到开发体验的角度来说这个事情,我要做的事情很简单,就是用 iframe 来隔离应用,防止出现全局变量冲突导致的一系列问题。

但是就诞生了怎么跟 iframe 交互的这个事情,正常的流程肯定走 message + postMessage 这套,但是这样就会有两套接收、发送。而且消息的取值也会收到限制。

简单来说就是 postMessage 会使用结构化算法,其实也就是深拷贝原生实现的,但是它不是全能的,有一些限制。 image-3

这就导致我们通过 postMessage 传递消息其实是收到很多限制的,这个方案先放到一边。

方案一

从开发角度来说,更合理的是我使用响应式的对象,传递给子 iframe 这个对象,它修改,我的主页面也触发 watch 等操作。 这样就是无缝感知的了,下面是一个伪代码。

const store = ref({
  // ...
});

iframe

<script setup>
  const store = parent["xxxx"];
  // 后续一系列使用
  store.value.xx.xx == xxx;
</script>

愿景很好,但是并不支持,我还特意去 vue issues 提了一个问题 watch Unable to Track Changes on window Object in Parent When Accessed from an iframe

image-4

没法了,这个方案被噶了。

方案二

除此之外呢,还有什么方案呢?

分析一下 iframe 挂载过程,主页面先加载 => 然后触发子组件 => 子组件加载 iframe。

这个版本方案则是通过响应式来完成主窗口和子 iframe 的通讯。

具体来说就是在主窗口,取自身的 window 对象,而子 iframe 通过 parent[xxx] 来更新主窗口的值。

每一个 parent[xxx] 值变化的时候通知主窗口来进行值的修改,而主窗口的值更改通知 iframe 进行值的更新。

下面是一个伪代码的实现

  • iframe
<script setup>
  const mountValue = computed(() => {
    return {
      // ...
    };
  });

  watch(
    () => mountValue.value,
    (values) => {
      parent["11111"] = values;
    },
    { deep: true, immediate: true }
  );

  window.addEventListener("message", (e) => {
    // 接收到消息,更新mountValue
    const { key } = e.data;
    mountValue.value[key] = parent["11111"][key];
  });
</script>
  • 主窗口
const [, { store }] = useSimulate<globalMountingAll>({
  iframeId: id,
});
// 操作
store.value = {};

这个就是一个简单版本的实现,不过有两个点感觉可以详细说一下。

  1. 我们都知道解构一个对象,会触发 proxyget 方法,不过 get 方法如果第一次运行的时候因为子 iframe 没有值,所以会返回 undefined,但是但是,如果后续有值了,在触发更新就不是一个响应式对象了,因为你解构返回的值就不是一个对象。 所以这块一定要用 ref 来包裹返回,也就是说,初次的时候正常会 undefined,但是后续的值会在 iframe 加载之后更新正确。

  2. 对于如果原来的值就是 ref,如果继续用 ref 包裹起来会出现问题,或者是一个 computed 的时候会出现 store.value.value 才能访问到,所以先需要调用一次 unref,来消除 ref

上面就是方案二的实现细节了,虽然从代码角度来说工作量没有减少,但是一次编写之后后续使用不会再出现手动管理 messagepostMessage 的事情发生。

这或许也是一种权衡吧。

具体实现代码

下面是 ts 版本的一个代码实现

useSimulate

import {
  isRef,
  MaybeRef,
  onUnmounted,
  Ref,
  ref,
  toValue,
  unref,
  watch,
  WatchStopHandle,
} from "vue";
import {
  COMMUNICATION_TYPE,
  useCommunicate,
  Props as UseCommunicateProps,
} from "../useCommunicate";
import _ from "lodash-es";
import { useLoadingStatus } from "../useLoadingStatus";
import { useEventListener } from "@vueuse/core";
import type { globalMountingAll } from "../../components/iframeRepl/component.vue";

type Props = UseCommunicateProps;

export type Unref<T> = T extends MaybeRef<infer U> ? U : T;

export type Referencing<V> = Ref<Unref<V> | undefined>;

export type ToPromise<T> = {
  [K in keyof T]: Referencing<T[K]>;
};

/*
 * 对参数T必须进行约束,不然会出现值不存在的情况
 */
export const useSimulate = <T extends globalMountingAll>(props: Props) => {
  const { postMessage } = useCommunicate(props);
  const { iframeId } = props;
  const { initLoading: loading } = useLoadingStatus();

  const dependencyItems = new Map<string | symbol, Ref<any>>();

  const unwatchAll = new Set<WatchStopHandle>();

  const additionalParameters = new Proxy({} as ToPromise<T>, {
    get(_t, p) {
      /*
       * 收集依赖
       */
      const prop = p as keyof T;
      const value = _.get(window[toValue(iframeId) as any], prop);
      const v = ref(_.clone(unref(value))) as Ref<
        Unref<T[typeof prop]> | undefined
      >;
      /*
       * 如果v发生了变更,直接通知原属性进行更新值
       */
      const unwatch = watch(
        () => v.value,
        (v) => {
          // @ts-ignore
          const value = _.get(window[toValue(iframeId)], p);
          if (!isRef(value)) {
            return;
          }
          value.value = v;
          // @ts-ignore
          _.set(window[toValue(iframeId)], p, value);
          postMessage({
            key: p,
            //   // value: v,
          });
        },
        { deep: true }
      );
      unwatchAll.add(unwatch);
      dependencyItems.set(p, v);
      return v;
    },
    set() {
      return true;
    },
  });

  // 表示已经变更了,重新赋值
  const setChange = () => {
    for (const [key, refItem] of dependencyItems) {
      // 更新 Ref 的值,而不是替换 Ref 对象
      refItem.value = unref(_.get(window[toValue(iframeId) as any], key));
    }
  };

  useLoadingStatus(setChange);

  /*
   * 订阅变更
   */
  useEventListener(window, "message", (e) => {
    if (e.data?.type !== COMMUNICATION_TYPE || e.data?.data !== "change") {
      return;
    }

    setChange();
  });

  onUnmounted(() => {
    dependencyItems.clear();
    unwatchAll.forEach((unwatch) => unwatch());
    unwatchAll.clear();
  });

  return [loading, additionalParameters] as const;
};

useLoadingStatus 的作用就是在 iframe 加载完成的时候通知。

useCommunicate 则是进行 postMessage 发送的封装,不过在这里不重要你可以自己实现。

iframe 实现

const globalMounting = computed(() => {
  return {
    store,
  };
});

export type globalMountingAll = Unref<typeof globalMounting>;

watch(
  () => globalMounting.value,
  (values) => {
    if (!id) {
      return;
    }
    // @ts-ignore
    parent[id] = values;
    parent.postMessage({
      type: COMMUNICATION_TYPE,
      data: "change",
    });
  },
  { immediate: true }
);

useEventListener(window, "message", (e) => {
  const key = e.data?.data?.key;

  if (e.data?.type !== COMMUNICATION_TYPE || !key) {
    return;
  }

  // @ts-ignore
  globalMounting.value[key] = parent[id]?.[key];
});

Unref 是我封装的一个 Type 方法,你可以理解成去掉.value 这块可以自行实现。

使用

const [, { store }] = useSimulate<globalMountingAll>({
  iframeId: id,
});

最后

虽然很想把所有的想法都说出来,不过肯定会有一些疏忽的地方,另外文章如果有错别字以及其他错误也欢迎指出。