Skip to content

Latest commit

 

History

History
310 lines (253 loc) · 11.6 KB

微前端基础知识.md

File metadata and controls

310 lines (253 loc) · 11.6 KB

微前端

time: 2021.6.9
author: heyunjiang

背景

目前项目采用的是众多模块共享一个前端项目,存在如下问题

  1. 全量更新:前端项目体积大,每次更新都要全部打包,现在需要独立部署、增量升级
  2. 后端微服务:各子模块对应不同后端,在接口规范实现上差异较大,前端无法做到统一管理,比如错误、登录鉴权、重定向等
  3. 代码需要解耦:老项目模块存在一定技术债务,对于新的模块没有必要遵循旧的模式

1 什么是微前端?

简单一句话:庞大应用拆分,由多个小应用组成

特点

  1. 各应用独立部署
  2. 应用之间代码解耦,各团队自己开发管理
  3. 对应后端各个微服务
  4. 增量升级

2 需要解决一些问题

  1. js 沙箱:解决各子应用互相影响的问题,包括全局变量、事件
  2. css 样式隔离
  3. 公共依赖加载
  4. 子应用如何加载,入口如何定义
  5. 父子应用如何通讯,比如状态共享
  6. 路由相应:对于全局变量隔离了,那 location 变化和 history 变化如何被子应用知晓
  7. 其他:子应用嵌套、子应用并行、子应用预加载
  8. 权限管理
  9. 缓存实现:子应用是否每次都需要远程加载,是否实现浏览器缓存

3 实现方案

  1. 后端模板集成: 传统实现方案,通过 url 跳转到各微服务提供的前端页面,缺点是整体刷新
  2. 包集成: 每个微前端服务,作为一个 npm 包,每次更新发布时,需要把所有微前端服务一起打包发布了
  3. iframe: 能实现子应用完全隔离,但是不方便实现应用上下文共享,比如公共 js 资源、浏览器 url 变化、前进后退、全局状态共享等,还有登录状态无法共享、占用内存过多、全局 dialog 展示等问题
  4. ESM: es module 加载,但是无法实现子应用隔离
  5. web components: 使用 shadow dom 实现隔离,兼容性不太好
  6. qiankun: 较成熟微前端框架,实现了样式隔离、js 全局变量和事件隔离
  7. EMP: YY业务中台出的方案,结合了众多微前端的优点实现,待体验

3.1 frame

iframe 优点

  1. 完全隔离
  2. HMR 方便
  3. 技术栈随便跨

iframe 缺点

  1. 页面刷新,路由丢失问题
  2. 浏览器前进后退问题
  3. 内部弹窗问题。是否可以使用 teleport 绑定到 pareantFrame.body 上面?
  4. 登录同步,cookie 透传?
  5. 跨域时cookie不会带上,会在同域或 host 主域相同时带上

3.2 umd, esm script 模式加载

加载对应 js 文件,暴露对应组件或实例变量

优点

  1. 技术栈随便跨
  2. 路由同步
  3. 登录同步

待解决技术点

  1. HMR:是否可以直接加载 dev 模式的临时构建资源?可以,通过 w2 拦截请求并转发配置,iframe 方式 dev,url 配置为同域不同 path 即可;prod 则直接渲染到 shadow dom 里面
  2. 按虚加载:esm? umd 多模块能自加载吗?每个模块搞小一点
  3. 样式隔离:shadow dom
  4. js 沙箱:模块都是二方接入,非三方接入,可以直接通过规范限制

4 qiankun

微前端解决方案之一,内部集成 single-spa

主应用:registerMicroApps、registerApplication、loadApp、start,根据 url 动态匹配子应用 子应用:提供 name、entry(uri 地址) 等

问题

  1. 对子应用 window 对象的代理,子应用代码执行原理,为什么直接修改 window = {} 都不成功?

关键实现原理 1 - loadApp

import { importEntry } from 'import-html-entry';

export async function loadApp<T extends object>(
  app: LoadableApp<T>,
  configuration: FrameworkConfiguration = {},
  lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObject> {
  // entry 表示子应用的 uri
  const { entry, name: appName, render: legacyRender, container } = app;
  const { singular = true, jsSandbox = true, cssIsolation = false, ...importEntryOpts } = configuration;

  // 拿到入口 html 、可执行的脚本、静态资源连接
  const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);

  // 等待其他子应用 unmounting
  if (await validateSingularMode(singular, app)) {
    await (prevAppUnmountedDeferred && prevAppUnmountedDeferred.promise);
  }

  const appInstanceId = `${appName}_${
    appInstanceCounts.hasOwnProperty(appName) ? (appInstanceCounts[appName] ?? 0) + 1 : 0
  }`;

  const appContent = getDefaultTplWrapper(appInstanceId)(template);
  let element: HTMLElement | null = createElement(appContent, cssIsolation);

  const render = getRender(appContent, container, legacyRender);

  // 第一次加载设置应用可见区域 dom 结构
  // 确保每次应用加载前容器 dom 结构已经设置完毕
  render({ element, loading: true });

  // 沙箱配置
  const containerGetter = getAppWrapperGetter(appInstanceId, !!legacyRender, cssIsolation, () => element);

  let global: Window = window;
  let mountSandbox = () => Promise.resolve();
  let unmountSandbox = () => Promise.resolve();
  if (jsSandbox) {
    const sandbox = genSandbox(appName, containerGetter, Boolean(singular));
    // 用沙箱的代理对象作为接下来使用的全局对象
    global = sandbox.sandbox;
    mountSandbox = sandbox.mount;
    unmountSandbox = sandbox.unmount;
  }

  const { beforeUnmount = [], afterUnmount = [], afterMount = [], beforeMount = [], beforeLoad = [] } = mergeWith(
    {},
    getAddOns(global, assetPublicPath),
    lifeCycles,
    (v1, v2) => concat(v1 ?? [], v2 ?? []),
  );

  // 执行 beforeLoad 钩子
  await execHooksChain(toArray(beforeLoad), app);

  // 解析脚本,并缓存其返回的 promise 结果
  if (!appExportPromiseCaches[appName]) {
    appExportPromiseCaches[appName] = execScripts(global, !singular);
  }

  // 读取子应用配置的 bootstrap、mount、unmount 约束
  const scriptExports: any = await appExportPromiseCaches[appName];
  let bootstrap;
  let mount: any;
  let unmount: any;

  if (validateExportLifecycle(scriptExports)) {
    // eslint-disable-next-line prefer-destructuring
    bootstrap = scriptExports.bootstrap;
    // eslint-disable-next-line prefer-destructuring
    mount = scriptExports.mount;
    // eslint-disable-next-line prefer-destructuring
    unmount = scriptExports.unmount;
  } else {
    if (process.env.NODE_ENV === 'development') {
      console.warn(
        `[qiankun] lifecycle not found from ${appName} entry exports, fallback to get from window['${appName}']`,
      );
    }

    // fallback to global variable who named with ${appName} while module exports not found
    const globalVariableExports = (global as any)[appName];

    if (validateExportLifecycle(globalVariableExports)) {
      // eslint-disable-next-line prefer-destructuring
      bootstrap = globalVariableExports.bootstrap;
      // eslint-disable-next-line prefer-destructuring
      mount = globalVariableExports.mount;
      // eslint-disable-next-line prefer-destructuring
      unmount = globalVariableExports.unmount;
    } else {
      delete appExportPromiseCaches[appName];
      throw new Error(`[qiankun] You need to export lifecycle functions in ${appName} entry`);
    }
  }

  return {
    name: appInstanceId,
    bootstrap: [bootstrap],
    mount: [
      // 1 等待其他子应用 unmounting
      async () => {
        if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {
          return prevAppUnmountedDeferred.promise;
        }

        return undefined;
      },
      // 2 第二次渲染。添加 mount hook, 确保每次应用加载前容器 dom 结构已经设置完毕
      async () => {
        // element would be destroyed after unmounted, we need to recreate it if it not exist
        element = element || createElement(appContent, cssIsolation);
        render({ element, loading: true });
      },
      // 3 执行 beforeMount 钩子
      async () => execHooksChain(toArray(beforeMount), app),
      // 4 沙箱 mount
      mountSandbox,
      // 5 应用 mount
      async props => mount({ ...props, container: containerGetter() }),
      // 6 应用 mount 完成后结束 loading
      async () => render({ element, loading: false }),
      // 7 执行 afterMount 钩子
      async () => execHooksChain(toArray(afterMount), app),
      // initialize the unmount defer after app mounted and resolve the defer after it unmounted
      async () => {
        if (await validateSingularMode(singular, app)) {
          prevAppUnmountedDeferred = new Deferred<void>();
        }
      },
    ],
    unmount: [
      async () => execHooksChain(toArray(beforeUnmount), app),
      async props => unmount({ ...props, container: containerGetter() }),
      unmountSandbox,
      async () => execHooksChain(toArray(afterUnmount), app),
      async () => {
        render({ element: null, loading: false });
        // for gc
        element = null;
      },
      async () => {
        if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {
          prevAppUnmountedDeferred.resolve();
        }
      },
    ],
  };
}

关键实现原理 2 - mount

这里是子应用内部提供的渲染方法,可以自行控制渲染到什么地方

/**
 * 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
 */
export async function mount(props) {
  ReactDOM.render(<App />, document.getElementById('react15Root'));
}

关键实现原理 3 - importEntry

const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);

是如何通过入口解析文档的呢?返回的又是什么数据格式?脚本获取是在 importEntry 还是 execScripts 获取?
不同于 url 刷新跳转,这里目的是实现无缝刷新

export function importEntry(entry, opts = {}) {
	if (typeof entry === 'string') {
		return importHTML(entry, { fetch, getPublicPath, getTemplate });
	}
}
export default function importHTML(url, opts = {}) {
	let fetch = defaultFetch; // 也就是 window.fetch
	let getPublicPath = defaultGetPublicPath;
	let getTemplate = defaultGetTemplate;

	return embedHTMLCache[url] || (embedHTMLCache[url] = fetch(url)
		.then(response => response.text())
		.then(html => {

      const assetPublicPath = getPublicPath(url);
      // processTpl 采用一系列正则匹配,查找 template, style, script
			const { template, scripts, entry, styles } = processTpl(getTemplate(html), assetPublicPath);

			return getEmbedHTML(template, styles, { fetch }).then(embedHTML => ({
				template: embedHTML,
				assetPublicPath,
				getExternalScripts: () => getExternalScripts(scripts, fetch),
				getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),
				execScripts: proxy => {
					if (!scripts.length) {
						return Promise.resolve();
					}
					return execScripts(entry, scripts, proxy, { fetch });
				},
			}));
		}));
};

乾坤特点

  1. 子应用3次 mount:加载时mount,用于设置 loading 效果及设置 dom 结构;渲染前 mount, 用于设置 loading 效果及设置 dom 结构;正式 mount,渲染子应用
  2. 样式隔离:css 前缀、shadow dom
  3. js 沙箱:proxy 实现

5 module federation

子应用提供者:暴露出对应的模块,统一打包进指定 filename 文件,通过 exposes 读取对应组件,还可以通过 shared 共享公共模块
父应用获取:通过

参考文献

  1. qiankun 中文版 umi 内部使用微前端服务解决方案
  2. 微前端入门
  3. 帮你对比多种微前端方案
  4. EMP