Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

优雅传递 postMessage #116

Open
bosens-China opened this issue Sep 6, 2024 · 0 comments
Open

优雅传递 postMessage #116

bosens-China opened this issue Sep 6, 2024 · 0 comments
Labels
框架相关 目前Vue和React为主

Comments

@bosens-China
Copy link
Owner

在最近开发的过程中遇到了一个问题,在集成 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,
});

最后

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

@bosens-China bosens-China added the 框架相关 目前Vue和React为主 label Sep 6, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
框架相关 目前Vue和React为主
Projects
None yet
Development

No branches or pull requests

1 participant