微前端项目开发总结

262 阅读4分钟

基座应用加载所有子应用

项目中有个不太规范的写法:通常在Switch里面,只有Route组件,但这里他们添加了Layout组件,这个Layout组件如何加载?

根据路由分析加载路径:

登录 -> login -> success 跳转至/

-> 匹配到<Route exact path="/" render={() => } />

-> 跳到/rdb,rdb不匹配一级路由(和登录平级)中的任何一条规则,所以加载Layout组件,Layout组件主要包括选择项目和租户的树状下拉,快捷跳转,登出,个人中心

在没有错误或鉴权不通过的情况下,由于在跳转到每个子应用时都不会匹配这一层级的任何路由,所以,都会走Layout组件的逻辑

因此把Layout组件挂载完毕作为注册所有子应用的时机

app.tsx

import registerApps from '../config/registerApps';

// ...

export default function App() {
	return (
    <IntlProvider
      locale={_.get(localeMap[language], 'intl', 'zh')}
      messages={intlMessages}
    >
      <ConfigProvider locale={_.get(localeMap[language], 'antd', antdZhCN)}>
        <InjectIntlContext>
          <Provider value={language}>
            <BrowserRouter>
              <Switch>
                <Route path="/login" component={Login} />
                <Route path="/auth-callback" component={CallBack} />
                <Route path="/change-password" component={ChangePassword} />
                <Route path="/register" component={Register} />
                <Route path="/403" component={Page403} />
                <Route path="/404" component={Page404} />
                <Route exact path="/task-output/:taskId/:outputType" component={TaskOutput} />
                <Route exact path="/task-output/:taskId/:host/:outputType" component={TaskHostOutput} />
                <Route
                  exact
                  path="/big-screen/:id"
                  render={(props: any) => <BigScreen {...props} mode="full-screen" />}
                />
                <Route
                  exact
                  path="/big-screen/modify/:id"
                  render={(props: any) => <BigScreen {...props} mode="editable" />}
                />
                <Route exact path="/" render={() => <Redirect to="/rdb" />} />
                <Layout
                  language={language}
                  onLanguageChange={setlanguage}
                  tenantProjectVisible={tenantProjectVisible}
                  belongProjects={belongProjects}
                  selectedTenantProject={selectedTenantProject}
                  setSelectedTenantProject={setSelectedTenantProjectFunc}
                  onMount={async () => {
                    const projsData = await request(`${api.tree}/projs`);
                    noProjCheck(projsData);
                    setBelongProjects(projsData);

                    // 监听子系统修改租户和项目
                    window.addEventListener('message', (event) => {
                      const { data } = event;
                      if (_.isPlainObject(data) && data.type === 'tenantProjectUpdate') {
                        const tenantProjectByProject = getTenantProjectByProjectId(projsData, data.value);
                        setSelectedTenantProjectFunc(tenantProjectByProject);
                      }
                    }, false);

                    // 设置租户和项目默认值
                    if (
                      (!defaultTenant && !defaultProject)
                      || !_.find(projsData, { id: _.get(defaultProject, 'id') })
                    ) {
                      const defaultTenantProject = getDefaultTenantProject(projsData);
                      setSelectedTenantProjectFunc(defaultTenantProject);
                    }

                    // 注册子系统
                    registerApps({}, () => {
                      request(api.permissionPoint).then((res) => {
                        const permissionPoint: any = {};
                        _.forEach(res, (_val, key) => {
                          permissionPoint[key] = true;
                        });
                        // 等待 1s 后再发送消息通知
                        setTimeout(() => {
                          window.postMessage({
                            type: 'permissionPoint',
                            value: permissionPoint,
                          }, window.location.origin);
                        }, 1000);
                      });
                      window.postMessage({
                        type: 'tenantProject',
                        value: {
                          tenant: _.attempt(JSON.parse.bind(null, localStorage.getItem('icee-global-tenant') as string)),
                          project: _.attempt(JSON.parse.bind(null, localStorage.getItem('icee-global-project') as string)),
                        },
                      }, window.location.origin);
                    });
                    distributeResourcePermissionPoint()
                  }}
                >
                  <div id="ecmc-layout-container" />
                </Layout>
                <Route render={() => <Redirect to="/404" />} />
              </Switch>
            </BrowserRouter>
          </Provider>
        </InjectIntlContext>
      </ConfigProvider>
    </IntlProvider>
  );
}

registerApps

import * as singleSpa from 'single-spa';

const customProps = {
  env: {
    NODE_ENV: process.env.NODE_ENV,
  },
};

function fetchManifest(url, publicPath) {
  return fetch(url).then((res) => {
    return res.text();
  }).then((data) => {
    console.log('fetchManifest', data);
    if (data) {
      const manifest = data.match(/<meta name="manifest" content="([\w|\d|-]+.json)">/);
      let result = '';
      if (publicPath && manifest) {
        result = `${publicPath}${manifest[1]}`;
      }
      return result;
    }
  });
}

function prefix(location, ident, matchPath) {
  if (matchPath && Object.prototype.toString.call(matchPath) === '[object Function]') {
    return matchPath(location);
  }
  if (location.href === `${location.origin}/${ident}`) {
    return true;
  }
  return location.href.indexOf(`${location.origin}/${ident}/`) !== -1;
}

function getStylesheetLink(ident) {
  return document.getElementById(`${ident}-stylesheet`);
}

function createStylesheetLink(ident, path) {
  const headEle = document.getElementsByTagName('head')[0];
  const linkEle = document.createElement('link');
  linkEle.id = `${ident}-stylesheet`;
  linkEle.rel = 'stylesheet';
  // linkEle.href = systemConf[process.env.NODE_ENV].css;
  linkEle.href = path;
  headEle.appendChild(linkEle);
}

function removeStylesheetLink(ident) {
  const linkEle = getStylesheetLink(ident);
  if (linkEle) linkEle.remove();
}

async function getPathBySuffix(systemConf, jsonData, suffix) {
  let targetPath = '';
  _.forEach(Object.values(jsonData.assetsByChunkName), (assetsArr) => {
    if(typeof assetsArr === 'string') {
      targetPath = assetsArr
    }
    if(Array.isArray(assetsArr)) {
      targetPath = assetsArr.find((assetStr) => {
        return assetStr.indexOf(systemConf.ident) === 0 && _.endsWith(assetStr, suffix);
      });
      if (targetPath) {
        return false;
      }
    }
  });
  console.log('targetPath', targetPath);
  if (process.env.NODE_ENV === 'development') {
    return `${systemConf[process.env.NODE_ENV].publicPath}${targetPath}`;
  }
  return `${systemConf[process.env.NODE_ENV].publicPath}${targetPath}`;
}

export default function registerApps(props = {}, mountCbk) {
  fetch('/static/systemsConfig.json').then((res) => {
    return res.json();
  }).then((res) => {
    res.forEach(async (systemsConfItem) => {
      const { ident, matchPath } = systemsConfItem;
      const sysUrl = systemsConfItem[process.env.NODE_ENV].index;
      console.log('ident, matchPath', ident, matchPath);
      singleSpa.registerApplication(ident, async () => {
        let manifestUrl = sysUrl;
        // html 作为入口文件
        if (/.+html$/.test(sysUrl)) {
          manifestUrl = await fetchManifest(sysUrl, systemsConfItem[process.env.NODE_ENV].publicPath);
        }
        console.log('manifestUrl', manifestUrl);
        const lifecyclesFile = await fetch(manifestUrl).then((res) => res.json());
        let lifecycles = {};
        if (lifecyclesFile) {
          const jsPath = await getPathBySuffix(systemsConfItem, lifecyclesFile, '.js');
          console.log('jsPath', jsPath);
          lifecycles = await System.import(jsPath);
        } else {
          lifecycles = lifecyclesFile;
        }
        const { mount, unmount } = lifecycles;
        mount.unshift(async () => {
          if (lifecyclesFile) {
            const cssPath = await getPathBySuffix(systemsConfItem, lifecyclesFile, '.css');
            console.log('cssPath', cssPath);
            createStylesheetLink(ident, cssPath);
          }
          return Promise.resolve();
        });

        if (mountCbk) {
          mount.unshift(async () => {
            mountCbk();
            return Promise.resolve();
          });
        }
        unmount.unshift(() => {
          removeStylesheetLink(ident);
          return Promise.resolve();
        });
        return lifecycles;
      }, location => prefix(location, ident, matchPath), {
        ...customProps,
        ...props,
      });
    });
    
    singleSpa.start();
  });
}

systemsConfig.json

[{
	"ident": "rdb",
	"development": {
		"publicPath": "http://localhost:8001/rdb/",
		"index": "http://localhost:8001/rdb/manifest.json"
	},
	"production": {
		"publicPath": "/rdb/",
		"index": "/rdb/manifest.json"
	}
}, {
	"ident": "ams",
	"development": {
		"publicPath": "http://localhost:8002/ams/",
		"index": "http://localhost:8002/ams/manifest.json"
	},
	"production": {
		"publicPath": "/ams/",
		"index": "/ams/manifest.json"
	}
}, {

开发环境和生产环境的差异

1、开发模式下,无论是否以微前端方式启动,都需要提前鉴权(devServer中before的配置),所以开发模式下我们直接就会进入项目的主页,而不需要登录

生产环境中,是经历单点登录 ->

用户身份信息存入rdb ->

Response中在客户端set-cookie ->

进入各个子系统后各个子系统发请求时,客户端会携带cookie ->

请求先到达各应用下的rbac-proxy这一层(前端nginx配置的对应的地址)->

rbac-proxy会携带request中的cookie信息去rdb鉴权 ->

鉴权成功后再将请求转发至对应的业务方服务

2、开发模式下,以微前端方式启动,node服务器需要支持跨域:"Access-Control-Allow-Origin": "*",

生产环境中,不存在服务之间互相访问,而只是访问不同的静态文件,所以没有这个问题

子应用的注册

每个子应用都有两种启动方式:微前端方式启动、独立启动

  • 微前端方式启动:fetk run devServer,项目入口是 ****./src/index.tsx

react项目:

const reactLifecycles = singleSpaReact({
  React,
  ReactDOM,
  rootComponent: (_props: any) => {
    auth.checkAuthenticate()
    return <App />;
  },
  domElementGetter,
});

export const bootstrap = [
  reactLifecycles.bootstrap,
];

export const mount = [
  reactLifecycles.mount,
];

export const unmount = [
  reactLifecycles.unmount,
];

vue项目(dstor):

// singleSpaVue包装一个vue微前端服务对象
const vueLifecycles = singleSpaVue({
  Vue,
  appOptions: vueOptions,
  domElementGetter
})

// console.log('vueLifecycles', vueLifecycles)

// 导出生命周期对象
export const bootstrap = [vueLifecycles.bootstrap] // 启动时
export const mount = [vueLifecycles.mount] // 挂载时
export const unmount = [vueLifecycles.unmount] // 卸载时
  • 独立启动
ReactDOM.render(
  <App />,
  document.getElementById('react-content'),
);

dstor只能通过和基座一起启动来进行开发

由于微前端要求所有js都打包到同一文件中,所以通常都会在webpack中添加如下插件:

new StatsPlugin(manifestName, {
  chunkModules: false,
  source: true,
  chunks: false,
  modules: false,
  assets: true,
  children: false,
  exclude: [/node_modules/]
})

权限点

权限点是用来描述页面上按钮、菜单等显示与否的数据结构,用户登录以后系统会默认初始化一个tenant和一个project放到全局(从localStorage里取)

tenant和project可以理解为部门、团队,或者不同层级的组织机构,个人认为tenant和project应该是包含关系

权限点和tenant、project有关,所以切换tenant和project的时候,会获取权限点并通过postMessage派发给各个子应用

layout-web/app.tsx:

  useEffect(() => {
    window.postMessage({
      type: 'tenantProject',
      value: selectedTenantProject,
    }, window.location.origin);
    distributeResourcePermissionPoint()
  }, [selectedTenantProject]);

派发权限点时,可能会存在子应用接收不到的情况,所以开了若干定时器,派发了好几次

各个子应用中,通过监听message事件来接收

const distributeResourcePermissionPoint = () => {
    if(_.get(selectedTenantProject,"project.id") && window.location.pathname!=='/auth-callback' && window.location.pathname!=='/change-password' && window.location.pathname!=='/register'){
      request(api.resourcepermissionPoint+`/${_.get(selectedTenantProject,"project.id")}`).then(res => {
        setTimeout(() => {
          window.postMessage({
            type: 'resourcepermissionPoint',
            value: res,
          }, window.location.origin);
        }, 1000);
        setTimeout(() => {
          window.postMessage({
            type: 'resourcepermissionPoint',
            value: res,
          }, window.location.origin);
        }, 2000);
        setTimeout(() => {
          window.postMessage({
            type: 'resourcepermissionPoint',
            value: res,
          }, window.location.origin);
        }, 4000);
      })
    }
  }

所以有些子应用中,干脆自己在切换tenant和project的时候发请求获取了权限点,而没有依赖基座的派发

部署时的各项准备工作

Linux(centos)下安装node

cd/usr/local/src
wget https://nodejs.org/dist/v8.9.0/node-v8.9.0-linux-x64.tar.xz
## 解压
tar xf node-v8.9.0-linux-x64.tar.xz
cd/usr/local
## 重命名
mv src/node-v8.9.0-linux-x64 node
 
## 将node添加至path
vi~/.bashrc
export NODE_HOME=/usr/local/node
export PATH=$NODE_HOME/bin:$PATH
source ~/.bashrc
 
## 安装成功
node -v  // 安装成功会显示Node版本

Linux(centos)下安装nginx

sudo yum -y install nginx

Linux(centos)下添加yum源

cd /etc/yum.repos.d/

Linux(centos)下nginx的升级

www.prado.lt/5-minute-up…

本地传代码到Linux服务器或Linux服务器传文件到本地:rsync scp nc python

  • rsync:

rsync -avz --delete /Users/didi/state-grid-web/mongdb-web/pub/* root@10.96.98.84:/root/ecmc8026/pub/

  • scp:

scp -r -p7755 /Users/didi/Downloads/pub-external admin@172.17.238.5:/root/ecmcMicro

  • nc的使用:

步骤1,先在B机器上启动一个接收文件的监听,格式如下

意思是把在10086端口接收到的数据都写到file文件里(这里文件名随意取)

nc -l port >file

栗子:nc -l 10086 >zabbix.rpm

步骤2,在A机器上往B机器的10086端口发送数据,把下面rpm包发送过去

nc 192.168.0.2 10086 < zabbix.rpm

B机器接收完毕,它会自动退出监听,文件大小和A机器一样,md5值也一样

  • python:

python -m SimpleHTTPServer 9000