企业级微前端实战

450 阅读6分钟

什么是微前端

微前端有很多种说法,但是其中最核心的一点就是解耦,项目层面的解耦

当下前端领域,单页面应用(SPA)是非常流行的项目形态之一,而随着时间的推移以及应用功能的丰富,这破玩意会越来越复杂,比如王潇跟我说咱这个cms就有的地方不敢改,改了不知道哪就坏了,牵一发动全身。 又或者比如说这个同排期下cms同时开发两个需求,那无论是开发还是测试都会很麻烦。

微前端的意义就是将这些庞大应用进行拆分,并随之解耦,每个部分可以单独进行维护部署,提升效率。

微前端方案

大概是以下几种

方案描述优点缺点
Nginx路由转发通过Nginx配置反向代理来实现不同路径映射到不同应用,例如www.abc.com/app1对应app1,www.abc.com/app2对应app2,这种方案本身并不属于前端层面的改造,更多的是运维的配置。简单,快速,易配置在切换应用时会触发浏览器刷新,影响体验
iframe嵌套父应用单独是一个页面,每个子应用嵌套一个iframe,父子通信可采用postMessage或者contentWindow方式实现简单,子应用之间自带沙箱,天然隔离,互不影响iframe的样式显示、兼容性等都具有局限性
Web Components每个子应用需要采用纯Web Components技术编写组件,是一套全新的开发模式每个子应用拥有独立的script和css,也可单独部署对于历史系统改造成本高,子应用通信较为复杂易踩坑
组合式应用路由分发每个子应用独立构建和部署,运行时由父应用来进行路由管理,应用加载,启动,卸载,以及通信机制纯前端改造,体验良好,可无感知切换,子应用相互隔离需要设计和开发,由于父子应用处于同一页面运行,需要解决子应用的样式冲突,变量对象污染,通信机制等技术点

技术方案层面没有高低只有优劣

最后我们决定使用这个组合式应用路由分发,我习惯简称为基座式微前端,原因大概就是比较平衡,体验好,而且比较可控,解决方案也多

基座式微前端

基座式微前端,即有一个主应用统一做登录和权限全局状态layout公共功能,然后下面的分业务的系统独立开发独立部署。但是整体对于我们的用户来说是无感知的。是在项目层面做了解耦

最后我们选择qiankunnginx配合的基座式微前端解决方案,原因如下

  • 简单,由于主应用微应用都能做到技术栈无关,qiankun 对于用户而言只是一个类似 jQuery 的库,你需要调用几个 qiankunAPI 即可完成应用的微前端改造。同时由于 qiankunHTML entry 及沙箱的设计,使得微应用的接入像使用 iframe 一样简单。

  • 解耦/技术栈无关,微前端的核心目标是将巨石应用拆解成若干可以自治的松耦合微应用,而 qiankun 的诸多设计均是秉持这一原则,如 HTML entry沙箱应用间通信等。这样才能确保微应用真正具备 独立开发独立运行 的能力。

实战

首先我们改造主项目,由于主项目的路由是完全由后端返回,所以这里使用动态加载子项目的方法。

首先改造主项目的路由为history

// src/router/index.js
const createRouter = () => new Router({
    mode: 'history',
    routes: baseRouters
});

然后在主项目后端配置的路由文件index.vue中加载子项目

// 主项目的加载子项目的路由页面
import {loadMicroApp} from 'qiankun';

export default {
    name: 'crm',
    mounted() {
        loadMicroApp(
            {
                name: 'crm',
                // 本地子项目地址,生产环境会换为可被ng识别的路由
                entry: '//localhost:7799/',
                activeRule: '/crm',
                // 子应用挂载的div
                container: '#subapp-viewport',
                props: {
                    routerBase: '/crm'
                }
            }
        );
    }
};

然后在子项目中配置加入主项目后的路由

// main.ts
function render(props = {}) {
    const {container} = props;
    router = createRouter({
        history: createWebHistory(window.__POWERED_BY_QIANKUN__ ? '/micro' : '/'),
        routes
    });

    instance = createApp(App);
    instance.use(router);
    instance.use(store);
    instance.use(ElementPlus);

    instance.mount(container ? container.querySelector('#micro-app') : '#micro-app');
}

加入生命周期函数

// main.ts
export async function mount(props) {
    storeTest(props);
    render(props);
    instance.config.globalProperties.$onGlobalStateChange
    = props.onGlobalStateChange;
    instance.config.globalProperties.$setGlobalState = props.setGlobalState;
}

export async function unmount() {
    instance.unmount();
    instance._container.innerHTML = '';
    instance = null;
    router = null;
}

这个时候我们配置好主应用的路由子应用的路由后,启动两个项目其实已经可以看到链接成功了

但是这里面有个问题,就是接口请求的时候后端会做接口校验,这里大部分是使用cookie来做的,如果是cookie的话,不需要做多余的事情,因为cookie是绑定域名的

但是我们的项目是将token信息包含在请求header里,存储的位置是vuexlocalstorage,所以这里直接用将父应用的vuex同步到子应用

function render(props = {}) {
    const {container, store} = props;
    router = createRouter({
        history: createWebHistory(window.__POWERED_BY_QIANKUN__ ? '/micro' : '/'),
        routes
    });
    microStore.getters = store.getters;
    instance = createApp(App);
    instance.use(router);
    instance.use(microStore);
    instance.use(ElementPlus);
    instance.mount(container ? container.querySelector('#micro-app') : '#micro-app');
}

并在axios拦截器中加入到http请求中

// http request 拦截器
service.interceptors.request.use(
    config => {
        const token = store.getters['user/token'];
        const user = store.getters['user/userInfo'];
        config.data = JSON.stringify(config.data);
        config.headers = {
            'Content-Type': 'application/json',
            'x-token': token,
            'x-user-id': user?.ID,
            'withCredentials': true
        };
        return config;
    },
    error => {
        message({
            showClose: true,
            message: error,
            type: 'error'
        });
        return Promise.reject(error);
    }
);

这个时候可以考虑一个特殊情况,就是在页面的浏览中token失效了,并没有触发主项目的重新登录,这个时候子项目报错,这里可以在子项目中统一配置重新登录页面,这里就不粘代码了。

这里就是我们的本地环境的闭环已经完成了,现在我们想一下上到生产环境的局势

首先是当我们进入到相应的路由中,父应用开始加载子应用,而父应用并不知道去哪里找子应用,也就是说配置qiankun用的子应用的entry是什么,如果是已经在线上跑的子应用,那么就应该是子应用的域名,但是我们的子应用只作为父应用的模块存在,所以我决定将子应用部署在父应用同样的机器里,并用nginx直接将路由link到子应用打包出来的index.html

这里我们的entry生产环境和开发环境就不一样了,所以要做下配置

loadMicroApp(
    {
        name: 'micro',
        entry: process.env.NODE_ENV === 'development' ? '//localhost:7799/' : '/micro',
        activeRule: '/micro',
        // 子应用挂载的div
        container: '#subapp-viewport',
        props: {
            routerBase: '/micro',
            store
        }
    }
);

此刻我们看到在生产环境中,entry变成了/qiankun/micro即主项目本机的/qiankun/micro路由需要请求到子项目,所以我们需要做两步

  1. 将子项目部署到主项目同样的机器
npm install --registry=http://npm.baijia.com
rm -rf ./dist
yarn build --test 
scp -r ./dist/* worker@xx.xx.xx.xx:/apps/website/micro/
  1. 主项目的nginx路由改造,hash模式改为history
location / {
   alias /apps/website/main/;
   index    index.html index.htm;
   try_files $uri $uri/ /index.html;
}
  1. 配置机器nginx/micro路由配置到子项目的目录
location /micro {
   alias /apps/website/micro;
   index    index.html index.htm;
   try_files $uri $uri/ /index.html;
}

这里有一个细节就是vue-routerhash模式改为history,在项目中会有一个问题即查找不到runtime.js,解决方案是修改 preload 的配置 忽略为 runtime 添加 preload

config.when(process.env.NODE_ENV !== 'development', config => {
    config
        .plugin('preload')
        .tap(options => {
            options[0].fileBlacklist = options[0].fileBlacklist || [];
            options[0].fileBlacklist.push(/runtime\..*\.js$/);
            return options;
        })
        .end();
}

然后我们重启nginx就可以发现我们的部署成功了!

最后一步呢,是做一下原来系统到新系统的重定向

location  /before {
     rewrite ^ https://test.now.com/ permanent;
}

接下来就是业务的开发了