MontoApp(一) - iframe 方案
本节开始微前端项目的介绍。
1. 背景知识
一个好的微前端架构,应该具备如下特点:
- SPA 体验:微前端可以使所有的应用保持原有的 SPA 体验,统一品牌认知;
- 技术栈无关:可以使用不同的技术栈(例如 React. Vue. Svelte )开发,支持独立部署;
- 性能优化: 在 MPA 和 iframe 中如果想要做性能优化,往往需要依赖浏览器和 HTTP 的能力,而在微前端中可以处理应用资源的去重. 应用自身的预加载. 预渲染和缓存处理,也可以对已加载的页面进行保活处理,增加了性能优化的手段;
- 解耦重构:部分低代码页面无法满足性能要求时,可以通过新的技术栈将页面进行重构,不影响其他低代码页面的运行,从而可以减少重构带来的影响面。
- 独立性:独立开发. 独立部署和独立运行
为什么使用微前端:微前端可以降低大型复杂应用的开发. 升级. 维护以及团队协作的成本。当然,解决历史遗留的难以开发. 升级和维护的大型应用,也是使用微前端的一个重要原因。
web 架构演进:
使用微前端:
其在企业中的业务场景:
然而微前端不是万能的,例如以下场景可能并不需要微前端:
- 应用的业务单一,不存在多个团队并行开发的情况,不需要兼容不同的技术栈;
- 应用的功能已经非常完善,不存在大量新需求开发的可能性;
- 项目组不想花费大量的时间在应用的改造上,以现有应用的稳定性为主;
- 应用进行微前端改造的成本还不如直接改造当前存量应用带来的收益大;
- 团队内开发人员不熟悉微前端,无法应对微前端架构的复杂性。
2. 浏览器隔离
浏览器是多进程架构,在渲染页面时,理论上不同的站点会启用不同的渲染进程(Renderer),他们之间是相互隔离的。Chrome 浏览器在进行沙箱设计时,会尽可能的复用现有操作系统的沙箱技术,例如以 Windows 操作系统的沙箱架构为例,所有的沙箱都会在进程粒度进行控制,所有的进程都通过 IPC 进行通信。
在 Chrome 浏览器中沙箱隔离以 Renderer 进程为单位,而在旧版的浏览器中会存在多个 Web 应用共享同一个 Renderer 进程的情况,此时浏览器会依靠同源策略来限制两个不同源的文档进行交互,帮助隔离恶意文档来减少安全风险。
但是老式浏览器未启用站点隔离,标签页应用和内部的 iframe 应用会处于同一个 Renderer 进程,Web 应用有可能发现安全漏洞并绕过同源策略的限制,访问同一个进程中的其他 Web 应用,因此可能产生如下安全风险:
- 获取跨站点 Web 应用的 Cookie 和 HTML 5 存储数据;
- 获取跨站点 Web 应用的 HTML. XML 和 JSON 数据;
- 获取浏览器保存的密码数据;
- 共享跨站点 Web 应用的授权权限,例如地理位置;
- 绕过 X-Frame-Options 加载 iframe 应用(例如百度的页面被 iframe 嵌套);
- 获取跨站点 Web 应用的 DOM 元素。
3. iframe 方案
在 Chrome 67 版本之后,为了防御多个跨站的 Web 应用处于同一个 Renderer 进程而可能产生的安全风险,浏览器会给来自不同站点的 Web 应用分配不同的 Renderer 进程。例如当前标签页应用中包含了多个不同站点的 iframe 应用,那么浏览器会为各自分配不同的 Renderer 进程,从而可以基于沙箱策略进行应用的进程隔离,确保攻击者难以绕过安全漏洞直接访问跨站 Web 应用。
主应用里定义:
<body>
<h1>main 应用</h1>
<button onclick="javascript:window.open('<%= iframeUrl %>')">在新的标签页打开 iframe 应用</button>
<br>
<!-- 同站应用:iframe.html -->
<iframe src="<%= iframeUrl %>"></iframe>
<!-- 跨站应用: -->
<iframe src="https://nextjs.org/"></iframe>
</body>
子应用里定义:
<body>
<h1>同站的 iframe 应用</h1>
<button onclick="javascript:alert('我是弹窗')">点我有弹窗</button>
</body>
然后在主服务里写入子应用的地址:
app.get("/", function (req, res) {
// 使用 ejs 模版引擎填充主应用 views/main.html 中的 iframeUrl 变量,并将其渲染到浏览器
res.render("main", {
// 填充 iframe 应用的地址,只有端口不同,iframe 应用和 main 应用跨域但是同站,可以使用 同站应用通过修改 document.domain 进行通信
iframeUrl: `http://${host}:${port.micro}`
});
});
子应用也使用单独的服务启动即可:
4. iframe 浏览上下文
判断一个页面是不是在 iframe 中被打开,可以通过如下代码:
// 通过访问 window.top 可以获取当前浏览上下文的顶级浏览上下文 window 对象,通过访问 window.parent 可以获取父浏览上下文的 window 对象。
if(window.top !== window) {}
如果主应用是在空白的标签页打开,那么主应用是一个顶级浏览上下文,顶级浏览器上下文既不是嵌套的浏览上下文,自身也没有父浏览上下文。
iframe 有很多优点:
- 天然的 js. css 隔离,省的写 sandbox 了
- 移植性和复用性好,技术栈独立,可以便捷地嵌在不同的主应用中
- 可以在运行时做到动态化修改
- 切换微应用不需要刷新主应用
- 天然的应用间资源懒加载,没有加载子应用,其资源也不会被加载。
然而 iframe 方案有哪些问题呢?
不足:
- 首次加载 iframe 应用时,会因为服务端请求而导致内容区带来短暂的白屏效果
- 主应用刷新时,iframe 无法保持 URL 状态(会重新加载 src 对应的初始 URL);
- 主应用和 iframe 处于不同的浏览上下文,无法使 iframe 中的模态框相对于主应用居中;
- 主应用和 iframe 微应用的数据状态同步问题:持久化数据和通信。
- 对于非后台管理系统而言,使用 iframe 还需要考虑 SEO. 移动端兼容性. 加载性能等问题。
- 主/子应用 cookie 共享问题(比如免登)
5. iframe 方案 cookie 共享
其他方案的 cookie 共享也类似
同域
app.get("/", function (req, res) {
// 设置主应用的 cookie 标识
res.cookie("cookie-test", "123");
// 使用 ejs 模版引擎填充主应用 public/main.html 中的 micro 变量,并将其渲染到浏览器
res.render("main", {
// micro 指向微应用的打开地址
micro: `http://${host}:${port.main}/micro`,
});
});
// 微应用和主应用同域,只是服务路由不同
app.get("/micro", function (req, res) {
// 设置 iframe 微应用的 cookie 标识,顺便观察能否覆盖主应用的 cookie 标识
// 同域时共享cookie,子应用会改动主应用的cookie
res.cookie("micro-app", "true").cookie("cookie-test", "456");
// 渲染 views/micro.html
res.render("micro");
});
结论:主子应用可以共享 Cookie 数据,但是存在同名属性值被覆盖的风险
同站不同域
主应用:
// 使用 iHosts 配置 example.com 指向本机的 ip 地址,模拟同站
// 主应用访问地址:http://example.com:4000
app.get("/", function (req, res) {
res.cookie("cookie-test", "456");
// 使子域的微应用可以共享 Cookie
// 在设置 Cookie 时,可以使用 Domain和 Path 来标记作用域
// 默认不指定的情况下,Domain 属性为当前应用的 host,由于 xiaodududududuo.example.com 和 example.com 不同
// 因此默认情况下两者不能共享 Cookie,可以通过设置 Domain 使其为子域设置 Cookie,例如 Domain=example.com,则 Cookie 也包含在子域 xiaodududududuo.example.com 中
res.cookie("main-cookie-test", "true", { domain: "example.com" }); // 核心代码
res.render("main", {
// 使用 iHosts 配置 xiaodududududuo.example.com 指向本机的 ip 地址
// 子应用访问地址: http://xiaodududududuo.example.com:3000
micro: `http://xiaodududududuo.example.com:${port.micro}`,
});
});
子应用:
app.get("/", function (req, res) {
// 设置 iframe 微应用的 cookie 标识,顺便观察能否覆盖主应用的 cookie 标识
res.cookie("micro-app", "true").cookie("cookie-test", "123");
res.render("micro");
});
结论:默认情况无法共享,但是通过设置 Domain 使得两个应用可以共享彼此的 Cookie。
跨站
代码就不用演示了,两个完全不同的地址作为主/子服务,直接说结论:子应用默认无法携带 Cookie(防止 CSRF 攻击),需要使用 HTTPS 协议并设置服务端 Cookie 的 SameSite 和 Secure 设置才行,并且子应用无法和主应用形成 Cookie 共享
本地可以使用 ngrok 模拟跨站
6. 剩余缺点
- 首次加载白屏效果(可以使用预加载. 预渲染等优化措施)
- iframe 无法保持 URL 状态(主子应用需要做 history 的代理,全局使用一个单例)
- 模态框相对于主应用居中问题(同上,主子应用需做 window 对象代理,弹窗相对于 body 置于顶层)
- 数据状态同步问题(同上,主子应用需做 window 对象代理,主子应用设置通信机制传递事件对象等)
- SEO. 移动端兼容性. 加载性能: ?