React 运行环境的隔离

React 运行环境的隔离

十月 11, 2020

在实现项目的时候遇到这么一种场景:用户可以在页面引入第三方的组件渲染页面。

示例

如图中的蓝框部分是用户引入的第三方组件,而下面的控制器是自己实现的组件。里面的列表项组件拥有固定的 props,需要实现正常的 Todo List 的效果。这里有两个最大的难题,一个是动态引入第三方组件库,这里暂且不提。另一个是将蓝框部分与应用的其余部分运行环境隔离。因为第三方组件的安全性是不能保证的,如果直接在应用环境中运行,会影响用户的安全。另一个原因是第三方组件样式的实现方式是不确定的,可能会和主应用的样式相互干扰产生问题。如果仅仅是样式问题,可以使用 Shadow DOM 解决,但是这里就需要使用 iframe 了。

function TodoList() {
  const [text, setText] = useState('');

  const handleSubmit = () => {
    // TODO
  };

  return (
    <div className="TodoList">
      <h1>
        Todo List <span>A simple React Todo List App</span>
      </h1>
      <iframe
        src="/iframe"
        sandbox="allow-forms allow-modals allow-popups allow-presentation allow-scripts"
      />
      <form className="NewTodoForm" onSubmit={handleSubmit}>
        <label htmlFor="task">New todo</label>
        <input
          value={text}
          onChange={e => setText(e.target.value)}
          id="task"
          type="text"
          name="task"
          placeholder="New Todo"
        />
        <button>Add Todo</button>
      </form>
    </div>
  );
}

这里一定要限制 iframe 的 sandbox 属性,尤其是不能有 allow-same-origin, allow-top-navigation 这样的属性,会隐藏严重的安全问题。然后再实现剩下的部分:

// iframe 部分
function IFrame() {
  const Component = useThirdPartyComponent();
  const props = useThirdPartyProps();

  return <Component {...props} />;
}

// 入口文件
function App() {
  return (
    <React.Suspense fallback={loading}>
      <Switch>
        <Route
          exact={true}
          path="/"
          component={React.lazy(() => import('./TodoList'))}
        />
        <Route
          exact={true}
          path="/iframe"
          component={React.lazy(() => import('./IFrame'))}
        />
      </Switch>
    </React.Suspense>
  );
}

这里使用 Suspense 简单地进行了代码的分割,另外使用 useThirdPartyComponent 代表抽象的获取第三方组件的过程,这里先不展开讨论它的细节。接下来需要实现的是 TodoListhandleSubmit 函数(添加一条 task)和 IFrameuseThirdPartyProps(获取 todolist 的属性)。

然后是经典的写 Redux 代码环节(好久没写过原生的 Redux 了,正经人是不会写的)。假设这是一个常规版的 todolist,那它的代码是很好写的(去网上抄一份就完了)。

// actions
const addTodo = text => ({
    type: 'ADD_TODO',
    id: Date.now(),
    text,
});

// reducer
const todosReducer = (state = [], action) => {
  switch (action.type) {
    case 'ADD_TODO':
      return [
        ...state,
        {
            id: action.id,
            text: action.text,
        },
      ];
    default:
      return state;
  };
};

然后组件里面的这两个函数就很好写了

function TodoList() {
  // ...
  const dispatch = useDispatch();

  const handleSubmit = () => {
    dispatch(addTodo(text));
    setText('');
  };
  // ...
}

function IFrame() {
  const Component = useThirdPartyComponent();
  const props = useSelector(state => state);

  return <Component tasks={props} />;
}

如果在 iframe 之间也可以像这样写代码就更好了,无需改动代码,写的时候不需要有其它的成本,而且还可以兼容更上层的 Redux 封装。经过一番搜索找到了这么一个库 redux-state-sync。它实现了 Redux 的一个 middleware,可以同步多个 Redux 之间的状态,于是果断 yarn add redux-state-sync。运行之后果不其然地失败了 ╮(╯▽╰)╭,不管怎么添加,里面的列表一点反应都没有。只能看看是怎么实现的了。

它的实现原理其实很简单,初始化时从其他实例获取 state 值,当有一个实例获取到 action 时将 action 转发给其它实例,由于 Redux 的性质,最终得到的 state 总是一致的。注意需要将 middleware 放在最后,防止使用 thunk 等其它 middleware 的时候转发异步的 action 导致问题。这个 middleware 使用 BroadcastChannel 发送消息,而 BroadcastChannel 只能支持发送消息到同源的页面,而我们的 iframe 关闭了 allow-same-origin,所以没有办法收到消息。

这样的话就只能用无敌的 window.postMessage 想想办法了。主页面和 iframe 直接使用 postMessage 发送消息是不需要同源的,可以使用 postMessage 模拟一个 BroadcastChannel。因为主页面和 iframe 不是完全对称的,一个主页面里可能有多个 iframe,iframe 之间也需要发送消息,为了简化代码的复杂度,统一使用主页面发送消息给自己和所有的 iframe 页面。需要实现 channel 的 postMessageonmessage。思路比较简单就直接贴出

太长了,点击展开实现代码
// 这里用到的事件类型有两种
// frame-broadcast-request 是原始事件,用于主页面的转发,只有主页面需要处理
// frame-broadcast 是转发生成的事件,收到事件的页面需要处理
class FrameBroadcastChannel {
  isTop: boolean;

  name: string;

  handlers: {
    [type: string]: MessageHandler[];
  } = {
    message: [],
    messageerror: [],
  };

  onMessage: null | MessageHandler = null;

  mainHandler: {
    [type: string]: MessageHandler;
  };

  constructor(name: string) {
    this.isTop = typeof window === 'undefined' || window === window.parent;
    this.name = name;
    this.onMessage = null;
    // 过滤出需要的事件,转到 handlers 里面的回调函数执行
    this.mainHandler = {
      message: e => {
        if (e.data.type !== 'frame-broadcast' || e.data.channel !== this.name) {
          return;
        }
        this.handlers.message.forEach(handler => handler(e.data.message));
      },
      messageerror: e => {
        if (e.data.type !== 'frame-broadcast' || e.data.channel !== this.name) {
          return;
        }
        this.handlers.messageerror.forEach(handler => handler(e.data.message));
      },
    };
    for (const type of ['message', 'messageerror'] as const) {
      window.addEventListener(type, this.mainHandler[type]);
    }

    // 如果是主页面就转发消息到所有 iframe 页面
    this.broadcastHandler = this.broadcastHandler.bind(this);
    if (this.isTop) {
      window.addEventListener('message', this.broadcastHandler);
    }
  }

  broadcastHandler(e: MessageEvent) {
    if (
      e.data.type !== 'frame-broadcast-request' ||
      e.data.channel !== this.name
    ) {
      return;
    }
    const data = {
      type: 'frame-broadcast',
      channel: this.name,
      message: e.data.message,
    };
    // 遍历发送事件,这里只考虑了两层,如果有需要可以递归
    window.postMessage(data, '*');
    for (const frame of Array.from(window.frames)) {
      frame.postMessage(data, '*');
    }
  }

  postMessage(message: any) {
    // 使用主页面发送事件
    // 这里只考虑了两层,如果有需要可以使用
    // let current = window;
    // while (current !== current.parent) {
    //   current = current.parent
    // }
    // 获取最顶层
    window.parent.postMessage(
      {
        type: 'frame-broadcast-request',
        channel: this.name,
        message,
      },
      '*',
    );
  }

  close() {
    this.handlers = {};
    this.onMessage = null;

    for (const type of ['message', 'messageerror'] as const) {
      window.removeEventListener(type, this.mainHandler[type]);
    }
    if (this.isTop) {
      window.removeEventListener('message', this.broadcastHandler);
    }
  }

  set onmessage(v: MessageHandler) {
    this.removeEventListener('message', v);
    if (typeof v === 'function') {
      this.addEventListener('message', v);
      this.onMessage = v;
    } else {
      this.onMessage = null;
    }
  }

  addEventListener(type: string, handler: MessageHandler) {
    this.handlers[type].push(handler);
  }

  removeEventListener(type: string, handler: MessageHandler) {
    this.handlers[type] = this.handlers[type].filter(x => x !== handler);
  }
}

最后使用 FrameBroadcastChannel 代替原本的 BroadcastChannel 就可以了。