关于微前端的那些事

137 阅读9分钟

每日一诗

丑奴儿·书博山道中壁
作者:[辛弃疾]

少年不识愁滋味,爱上层楼。爱上层楼,为赋新词强说愁。
而今识尽愁滋味,欲说还休。欲说还休,却道天凉好个秋。

微前端是什么?

微前端是什么?解答这个问题之前就必须了解一下微服务。
微服务 一个服务随着迭代越来越大,而且维护人员也越来越大,协同和功能回归需要花费的成本也越来越高。
为了解决这个问题,开发者就想到能不能把这个巨石服务拆成一个一个子服务,独立开发、独立测试、独立部署,降低协同和回归的成本,而且每个服务也可以专注于负责的业务,再也不怕改a业务的代码,一步小心改到了b业务的代码。而且升级的时间也变少了,错误定位范围也缩小了。

image.png

那么把微服务的概念用到前端就变成了微前端

image.png

微前端每个子应用独立开发、独立测试、独立部署。

微前端解决的问题

  • 独立开发
    每个子应用独立开发,降低了协同成本,再也不用改a模块的组件还要看是否其他模块是否用到了这个组件。

  • 独立构建部署
    构建
    一个巨石应用,构建的时候花费的构建时间是非常长了,之前作者负责的项目构建时间就长达15分钟,十分不敏捷,就修个fix都得等很长时间。
    微前端化后只要打包对应的子应用就好了,时间缩短明显。

    部署 没有拆之前,虽然优化git flow,但是时常上线还得看看前面的人上线了吗,以防把别人没有验证完的代码带到线上。团队成员少的时候还不严重。团队成员多的时候简直是噩梦。
    有时候其他需求验证时间长,你就得一直等。微前端化后虽然也有这个问题,但是把团队成员划分成了模块成员,相对来说就没有那么严重了。

  • 独立测试
    巨石应用的时候,改动一个小块,为了确保无问题都要全部回归(线上问题无小事,都是大事情,搞不好就得复盘)。
    微前端化后就只用回归改模块,再也不用担心影响其他模块了。当然谨慎点还得冒泡其他模块。

  • 增量迁移 我们通常模块迁移,从a平台迁移到b平台一般是把代码迁移过去,改造成符合b规范的代码。

    代码、目录规范
    不同的项目,规范可能不一致,特别是老项目,如果不改造的话就会有可能把b项目的规范给搞乱,而且开发体验也很差。(同一规范的重要性)

    技术栈 现在团队内项目的技术栈是统一的,但是还有一些老项目的技术栈是不一致的,那么迁移的话就需要改造代码,例如react项目的状态管理工具不一致,还可以改造一下,如果一个使用react、一个使用vue,那么迁移相当于重写。

    成本 代码迁移,代码量少还好说,如果代码量一大,上述几点就增加了很高的复杂度,而且老项目一般迭代都是非常久了,历史包袱非常大。

    如果微前端化后就完全可把迁移模块改造成子应用,这样迁移的时候就不必担心上述问题。

  • 无技术栈限制

    微前端后,每个子应用可以有自己的构建工具、自己的技术栈、自己的规范。当然能统一还是统一。

微前端的拆分方式

  • iframe
    通过iframe去嵌套其他页面
    优点 成本低,侵入性低
    缺点 体验差,主子应用通信十方麻烦,而且iframe还有很多不确定的点。

  • nginx
    优点 通过nginx重定向,改造成本低,只需要维护一个nginx配置就好了
    缺点 体验差,主子应用通信十方麻烦。

  • 服务端匹配
    优点 成本高,依赖于后端,但是有时候出问题很难定位,作者之前端内h5,分页打包服务端渲染模板的时候,就遇到过服务端渲染了两个模板的内容。导致定位成本高。 缺点 体验差,主子应用通信十方麻烦。

  • js import
    例如system.js,可以再浏览器环境下去导入对应的js bundle
    优点 成本高,子应用需要改造,但微前端框架乾坤、garfish都封装好了,如果用这些框架那么成本就非常低了
    缺点 体验好,主子应用通信简单。

微前端的历史

  • 早期 使用system.js、import-html-entry、singleSpa,支持路由劫持、子应用加载,不支持样式隔离和js隔离

  • 后来 乾坤对上面的操作进行封装并且支持样式隔离、js隔离,改造成本低

  • 现在 最近研究了一下garfish,实践了一下发现很好用,改造成本低,侵入低,上手简单,但是文档没有乾坤那么好。

微前端的原理

这里主要围绕乾坤来进行讲解,原理都是相似的,万变不离其宗。\

  • 路由劫持
  • 加载子应用
  • 调用子应用生命周期进行渲染

乾坤的简单应用

这里主要讲述两步重要改造:

  • 注册路由
registerMicroApps([
  {
    name: "a", // app name registered
    entry: "http://localhost:3000",
    container: "#container",
    activeRule: "/media/a",
  },
  {
    name: "b",
    entry: "http://localhost:8082",
    container: "#container",
    activeRule: "/media/b",
  },
]);

start();
  • 子应用改造 主要是提供对应的生命周期,以及把包打成umd格式
//main.js
import { createApp } from "vue";
import App from "./App.vue";

let instance = null;
function render(props = {}) {
  const { container } = props;

  instance = createApp(App);
  instance.mount(container ? container.querySelector("#app") : "#app");
}

// 独立运行时
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

export async function bootstrap() {
  console.log("[vue] vue app bootstraped");
}
export async function mount(props) {
  console.log("[vue] props from main framework", props);
  render(props);
}
export async function unmount() {
  instance.$destroy();
  instance.$el.innerHTML = "";
  instance = null;
}

// vue.config.js
const { defineConfig } = require("@vue/cli-service");
const { name } = require("./package");

module.exports = defineConfig({
  transpileDependencies: true,
  devServer: {
    headers: {
      "Access-Control-Allow-Origin": "*", // 允许跨域
    },
  },
  configureWebpack: {
    output: {
      library: `${name}-[name]`,
      libraryTarget: "umd", // 把微应用打包成 umd 库格式
      chunkLoadingGlobal: `webpackJsonp_${name}`,
    },
  },
});

接下来我们实现一下其原理,实践具体操作可以看一下官网文档。 qiankun

基座路由劫持

qiankun是通过劫持url来查找应用,从而来加载子应用,如果看过vueRouter或者react-router-dom的同学很容易理解。 分为两种模式:

  • hash hash的改变可以监听window的onhashchange事件

image.png

  • history
    history模式则比较麻烦一点,需要监听浏览器的前进后退,以及history的操作
    1 forward、go、back、浏览器前进后退操作触发后会触发onpopstate事件(注意这个事件是被动事件)
    2 pushState和replaceState则需要进行重写,在原生方法外边包一层

实现代码

let apps = null;

// 查询对应path的App
const findApp = (path) => {
  const appOfPath = apps.find((item) => {
    const { activeRule } = item;
    return Object.is(path, activeRule);
  });

  return appOfPath;
};

// 处理history路由
const handlePathChange = () => {
  const path = window.location.pathname;

  const app = findApp(path);

  console.log(app);
};

// 处理hash路由
const handleHashChange = () => {
  const hash = window.location.hash;

  const app = findApp(hash);

  console.log(app);
};

// 处理onpopstate
const handlePopState = () => {
  handlePathChange();
};

// 路由监听
const processRouterEvent = () => {
  const historyPush = window.history.pushState;
  const historyReplace = window.history.replaceState;

  window.history.pushState = (...reset) => {
    handlePathChange();
    historyPush.call(window.history, ...reset); //注意要绑定函数调用的this执行 不然会报错非法调用
  };

  window.history.replaceState = (...reset) => {
    handlePathChange();
    historyReplace(window.history, ...reset); //注意要绑定函数调用的this执行 不然会报错非法调用
  };

  window.addEventListener("hashchange", handleHashChange, false);

  window.addEventListener("popstate", handlePopState, false);
};

export const start = () => {
  // 首次渲染拿path匹配子应用
  handlePathChange();

  processRouterEvent();
};

export const registerMicroApps = (router) => {
  apps = router; // 存储子路由表
};

加载子应用

当我们匹配到子应用后就应该去加载子应用,有两种加载:

  • js入口
    通过script标签可以去加载js文件

  • html 入口 html入口主要通过fetch和eval去加载。值得注意的是,渲染的时候我们会通过innerHTML渲染到浏览器上, 但是innerHTML会忽略script标签的加载,主要是因为安全原因,所以我们需要去拿到全部script标签,然后逐一去加载

实现代码

// 追加script
const loadScript = (path) => {
  const script = document.createElement("script");
  script.src = path;
  script.defer = true;

  document.head.appendChild(script);
};

//html入口 获取资源
const fetchResource = (path) => {
  return fetch(path).then((res) => {
    return res.text();
  });
};

//html入口加载子应用
const loadHtml = async (app) => {
  let htmlPath = app.entry;

  if (htmlPath.endsWith("/")) {
    htmlPath += "index.html";
  } else {
    htmlPath += "/index.html";
  }

  const htmlTemplate = await fetchResource(htmlPath);

  renderHtml(htmlTemplate, app);
};

// 渲染子应用
const renderHtml = (html, app) => {
  const entry = app.entry;

  const div = document.createElement("div");
  div.innerHTML = html;

  const scripts = div.querySelectorAll("script");
  const scriptArr = Array.from(scripts);

  for (let script of scriptArr) {
    const src = script.src;

    const origin = window.location.origin; // 注意资源路径问题
    const appSrc = src.replace(origin, entry);
    fetchResource(appSrc).then((text) => {
      eval(text);
    });
  }

  const container = app.container;
  const containerDom = document.querySelector(container);
  containerDom.appendChild(div);
};

// 加载子应用
const loadApp = (app) => {
  const entry = app.entry;
  if (entry.endsWith(".js")) {
    loadScript(entry);
  } else {
    loadHtml(app);
  }
};

注意此时我们还没有传递container到子应用,所以子应用渲染的时候还是按照默认容器

子应用微前端生命周期原理

生命周期我们这里大概实现一下bootstrap、mount、unmount,这里你可能会疑惑我们怎么拿到子应用导出的生命周期函数?我们按之前提到到的子应用会打包成umd包,那么我们看一下umd包的内容

image.png

umd包会判断环境,然后选择对应的注入方式 因此我们可以手动构造一个commonJS,这里不用window,主要时解决应用重名问题

    // 获取js字符串
  const sources = await Promise.all(scriptArr);
  
  // 手动构造commonJS
  const module = { exports: {} };
  const exports = module.exports;

  sources.forEach((text) => {
    eval(text);
  });

  const container = app.container;
  const containerDom = document.querySelector(container);
  containerDom.appendChild(div);

  return module;

然后把生命周期赋值给匹配到的app路由对象,并调用


  // 渲染
  if (entry.endsWith(".js")) {
    // module = await loadScript(entry);
  } else {
    module = await loadHtml(app);
  }

  const exports = module.exports;

  app.bootstrap = exports.bootstrap;
  app.mount = exports.mount;
  app.unmount = exports.unmount;

  const container = document.querySelector(app.container);

  app.bootstrap();
  app.mount({ container });
  preApp = app;
  
 //卸载
 const handlePathChange = () => {
  const path = window.location.pathname;

  const app = findApp(path);
  preApp?.unmount();
  loadApp(app);
};

注意还得需要设置__POWERED_BY_QIANKUN__变量,用来给子应用区分是否是微前端环境。当然还有关于静态资源路径问题,可以设置一个运行时的publicPath

if (window.__POWERED_BY_QIANKUN__) {
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

沙箱隔离

这里就不再具体阐述了

样式隔离

  • css module

  • 属性选择器

  • 动态加载和卸载css

  • shadow dom

js隔离

  • 快照沙箱

  • proxy沙箱
    单例沙箱 多例沙箱

应用通信问题

这里主要是在mount的时候传入props,这里有一个问题就是如果是想做到数据响应式的话,需要react和reactDom实例是同一个,通过externals就可以,内部机制还要再看一下源码,下次写写总结,之前也有人把应用通信再封装一层,这样虽然灵活性下降了,但是方便定位问题。

总结

写在夜晚,很多东西越写越多,可以拆成几块去总结了,写的比较粗糙,写的比较急,有些小点没有写下来了,有空再进行填补。一篇小小的总结文章。