图片来源: https://zhuanlan.zhihu.com/p/...
本文做者:史志鹏
LOOK 直播运营后台工程是一个迭代了 2+ 年,累计超过 10+ 位开发者参与业务开发,页面数量多达 250+ 的“巨石应用”。代码量的庞大,带来了构建、部署的低效,此外该工程依赖内部的一套 Regularjs 技术栈也已经完成了历史使命,相应的 UI 组件库、工程脚手架也被推荐中止使用,走向了少维护或者不维护的阶段。所以, LOOK 直播运营后台基于 React 新建工程、作工程拆分被提上了工做日程。一句话描述目标就是:新的页面将在基于 React 的新工程开发, React 工程能够独立部署,而 LOOK 直播运营后台对外输出的访问地址指望维持不变。
本文基于 LOOK 直播运营后台的微前端落地实践总结而成。主要介绍在既有“巨石应用”、 Regularjs 和 React 技术栈共存的场景下,使用微前端框架 qiankun ,实现CMS应用的微前端落地历程。
关于 qiankun 的介绍,请移步至官方查阅,本文不会侧重于介绍有关微前端的概念。javascript
https://example.com/liveadmin
,访问以下图所示。https://example.com/lookadmin
,访问以下图所示:咱们但愿使用微前端的方式,集成这两个应用的全部菜单,让用户无感知这个变化,依旧按照原有的访问方式 https://example.com/liveadmin
,能够访问到 liveadmin 和 increase 工程的全部页面。
针对这样一个目标,咱们须要解决如下两个核心问题:html
对于第 2 个问题,相信对 qiankun 了解的同窗能够和咱们同样达成共识,至于第 1 个问题,咱们在实践的过程当中,经过内部的一些方案获得解决。下文在实现的过程会加以描述。这里咱们先给出整个项目落地的效果图:
能够看到, increase 新工程的一级菜单被追加到了 liveadmin 工程的一级菜单后面,原始地址能够访问到两个工程的全部的菜单。前端
说到 CMS,还须要说一下权限管理系统的实现,下文简称 PMS。html5
入口文件执行如下请求权限和菜单数据、渲染菜单的功能。java
// 使用 Redux Store 处理数据 const store = createAppStore(); // 检查登陆状态 store.dispatch(checkLogin()); // 监听异步登陆状态数据 const unlistener = store.subscribe(() => { unlistener(); const { auth: { account: { login, name: userName } } } = store.getState(); if (login) { // 若是已登陆,根据当前用户信息请求当前用户的权限和菜单数据 store.dispatch(getAllMenusAndPrivileges({ userName })); subScribeMenusAndPrivileges(); } else { injectView(); // 未登陆则渲染登陆页面 } }); // 监听异步权限和菜单数据 const subScribeMenusAndPrivileges = () => { const unlistener = store.subscribe(() => { unlistener(); const { auth: { privileges, menus, allMenus, account } } = store.getState(); store.dispatch(setMenus(menus)); // 设置主应用的菜单,据此渲染主应用 lookcms 的菜单 injectView(); // 挂载登陆态的视图 // 启动qiankun,并将菜单、权限、用户信息等传递,用于后续传递给子应用,拦截子应用的请求 startQiankun(allMenus, privileges, account, store); }); }; // 根据登陆状态渲染页面 const injectView = () => { const { auth: { account: { login } } } = store.getState(); if (login) { new App().$inject('#j-main'); } else { new Auth().$inject('#j-main'); window.history.pushState({}, '', `${$config.rootPath}/auth?redirect=${window.location.pathname}`); } };
定义好子应用,按照 qiankun 官方的文档,肯定 name、entry、container 和 activeRule 字段,其中 entry 配置注意区分环境,并接收上一步的 menus, privileges等数据,基本代码以下:node
// 定义子应用集合 const subApps = [{ // liveadmin 旧工程 name: 'music-live-admin', // 取子应用的 package.json 的 name 字段 entrys: { // entry 区分环境 dev: '//localhost:3001', // liveadmin这里定义 rootPath为 liveadminlegacy,便于将原有的 liveadmin 释放给主应用使用,以达到使用原始访问地址访问页面的目的。 test: `//${window.location.host}/liveadminlegacy/`, online: `//${window.location.host}/liveadminlegacy/`, }, pmsAppCode: 'live_legacy_backend', // 权限处理相关 pmsCodePrefix: 'module_livelegacyadmin', // 权限处理相关 defaultMenus: ['welcome', 'activity'] }, { // increase 新工程 name: 'music-live-admin-react', entrys: { dev: '//localhost:4444', test: `//${window.location.host}/lookadmin/`, online: `//${window.location.host}/lookadmin/`, }, pmsAppCode: 'look_backend', pmsCodePrefix: 'module_lookadmin', defaultMenus: [] }]; // 注册子应用 registerMicroApps(subApps.map(app => ({ name: app.name, entry: app.entrys[$config.env], // 子应用的访问入口 container: '#j-subapp', // 子应用在主应用的挂载点 activeRule: ({ pathname }) => { // 定义加载当前子应用的路由匹配策略,此处是根据 pathname 和当前子应用的菜单 key 比较来作的判断 const curAppMenus = allMenus.find(m => m.appCode === app.pmsAppCode).subMenus.map(({ name }) => name); const isInCurApp = !!app.defaultMenus.concat(curAppMenus).find(headKey => pathname.indexOf(`${$config.rootPath}/${headKey}`) > -1); return isInCurApp; }, // 传递给子应用的数据:菜单、权限、帐户,可使得子应用再也不请求相关数据,固然子应用须要作好判断 props: { menus: allMenus.find(m => m.appCode === app.pmsAppCode).subMenus, privileges, account } }))); // ... start({ prefetch: false });
咱们基于已有的 menus 菜单数据,使用内部的 UI 组件完成了菜单的渲染,对每个菜单绑定了点击事件,点击后经过 pushState 的方式,变动窗口的路径。好比点击 a-b 菜单,对应的路由即是 http://example.com/liveadmin/a/b
,qiankun 会响应路由的变化,根据定义的 activeRule 匹配到对应的的子应用,接着子应用接管路由,加载子应用对应的页面资源。详细的实现过程能够参考 qiankun 源码,基本的思想是清洗子应用入口返回的 html 中的 <script>
标签 ,fetch 模块的 Javascript 资源,而后经过 eval 执行对应的 Javascript。react
if (window.__POWERED_BY_QIANKUN__) { // 注入 Webpack publicPath, 使得主应用正确加载子应用的资源 __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__; } if (!window.__POWERED_BY_QIANKUN__) { // 独立访问启动逻辑 bootstrapApp({}); } export const bootstrap = async () => { // 启动前钩子 await Promise.resolve(1); }; export const mount = async (props) => { // 集成访问启动逻辑,接手主应用传递的数据 bootstrapApp(props); }; export const unmount = async (props) => { // 卸载子应用的钩子 props.container.querySelector('#j-look').remove(); };
output: { path: DIST_PATH, publicPath: ROOTPATH, filename: '[name].js', chunkFilename: '[name].js', library: `${packageName}-[name]`, libraryTarget: 'umd', // 指定打包的 Javascript UMD 格式 jsonpFunction: `webpackJsonp_${packageName}`, },
const App = Regular.extend({ template: window.__POWERED_BY_QIANKUN__ ? ` <div class="g-wrapper" r-view></div> ` : ` <div class="g-bd"> <div class="g-hd mui-row"> <AppHead menus={headMenus} moreMenus={moreMenus} selected={selectedHeadMenuKey} open={showSideMenu} on-select={actions.selectHeadMenu($event)} on-toggle={actions.toggleSideMenu()} on-logout={actions.logoutAuth}></AppHead> </div> <div class="g-main mui-row"> <div class="g-sd mui-col-4" r-hide={!showSideMenu}> <AppSide menus={sideMenus} selected={selectedSideMenuKey} show={showSideMenu} on-select={actions.selectSideMenu($event)}></AppSide> </div> <div class="g-cnt" r-class={cntClass}> <div class="g-wrapper" r-view></div> </div> </div> </div> `, name: 'App', // ... })
if (props.container) { // 集成访问时,直接设置权限和菜单 store.dispatch(setMenus(props.menus)) store.dispatch({ type: 'GET_PRIVILEGES_SUCCESS', payload: { privileges: props.privileges, menus: props.menus } }); } else { // 独立访问时,请求用户权限,菜单直接读取本地的配置 MixInMenus(props.container); store.dispatch(getPrivileges({ userName: name })); } if (props.container) { // 集成访问时,设置用户登陆帐户 store.dispatch({ type: 'LOGIN_STATUS_SUCCESS', payload: { user: props.account, loginType: 'OPENID' } }); } else { // 独立访问时,请求和设置用户登陆信息 store.dispatch(loginStatus()); }
由于集成访问时要统一 rootPath 为 liveadmin,因此集成访问时注册的路由要修改为主应用的 rootPath 以及新的挂载点。webpack
const start = (container) => { router.start({ root: config.base, html5: true, view: container ? container.querySelector('#j-look') : Regular.dom.find('#j-look') }); };
同 liveadmin 子应用作的事相似。git
if (window.__POWERED_BY_QIANKUN__) { __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__; } const CONTAINER = document.getElementById('container'); if (!window.__POWERED_BY_QIANKUN__) { const history = createBrowserHistory({ basename: Config.base }); ReactDOM.render( <Provider store={store()}> <Symbol /> <Router path="/" history={history}> {routeChildren()} </Router> </Provider>, CONTAINER ); } export const bootstrap = async () => { await Promise.resolve(1); }; export const mount = async (props) => { const history = createBrowserHistory({ basename: Config.qiankun.base }); ReactDOM.render( <Provider store={store()}> <Symbol /> <Router path='/' history={history}> {routeChildren(props)} </Router> </Provider>, props.container.querySelector('#container') || CONTAINER ); }; export const unmount = async (props) => { ReactDOM.unmountComponentAtNode(props.container.querySelector('#container') || CONTAINER); };
output: { path: DIST_PATH, publicPath: ROOTPATH, filename: '[name].js', chunkFilename: '[name].js', library: `${packageName}-[name]`, libraryTarget: 'umd', jsonpFunction: `webpackJsonp_${packageName}`, },
if (window.__POWERED_BY_QIANKUN__) { // eslint-disable-line return ( <BaseLayout location={location} history={history} pms={pms}> <Fragment> { curMenuItem && curMenuItem.block ? blockPage : children } </Fragment> </BaseLayout> ); }
useEffect(() => { if (login.status === 1) { history.push(redirectUrl); } else if (pms.account) { // 集成访问,直接设置数据 dispatch('Login/success', pms.account); dispatch('Login/setPrivileges', pms.privileges); } else { // 独立访问,请求数据 loginAction.getLoginStatus().subscribe({ next: () => { history.push(redirectUrl); }, error: (res) => { if (res.code === 301) { history.push('/login', { redirectUrl, host }); } } }); } });
export const mount = async (props) => { const history = createBrowserHistory({ basename: Config.qiankun.base }); ReactDOM.render( <Provider store={store()}> <Symbol /> <Router path='/' history={history}> {routeChildren(props)} </Router> </Provider>, props.container.querySelector('#container') || CONTAINER ); };
要尽可能维持原有的权限管理方式(权限管理人员经过前端应用后门推送页面权限码到 PMS,而后到 PMS 进行页面权限分配),则微前端场景下,权限集成须要作的事情能够描述为:github
自此,咱们已经完成了基于 qiankun LOOK 直播运营后台的微前端的实现,主要是新建了主工程,划分了主应用的职责,同时修改了子工程,使得子应用能够被集成到主应用被访问,也能够保持原有独立访问功能。总体的流程,能够用下图描述:
qiankun 官方并无推荐具体的依赖共享解决方案,咱们对此也进行了一些探索,结论能够总结为:对于 Regularjs,React 等 Javascript 公共库的依赖的能够经过 Webpack 的 externals 和 qiankun 加载子应用生命周期函数以及 import-html-entry 插件来解决,而对于组件等须要代码共享的场景,则可使用 Webapck 5 的 module federation plugin 来解决。具体方案以下:
3.1. 咱们整理出的公共依赖分为两类
3.1.1. 一类是基础库,好比 Regularjs,Regular-state,MUI,React,React Router 等指望在整个访问周期中不要重复加载的资源。
3.1.2. 另外一类是公共组件,好比 React 组件须要在各子应用之间互相共享,不须要进行工程间的代码拷贝。
3.2. 对于以上两类依赖,咱们作了一些本地的实践,由于尚未迫切的业务需求以及 Webpack 5 暂为发布稳定版(截至本文发布时,Webpack 5 已经发布了 release 版本,后续看具体的业务需求是否上线此部分 feature ),所以尚未在生产环境验证,但在这里能够分享下处理方式和结果。
3.2.1. 对于第一类公共依赖,咱们实现共享的指望的是:在集成访问时,主应用能够动态加载子应用强依赖的库,子应用自身再也不加载,独立访问时,子应用自己又能够自主加载自身须要的依赖。这里就要处理好两个问题:a. 主应用怎么搜集和动态加载子应用的依赖 b. 子应用怎么作到集成和独立访问时对资源加载的不一样表现。
3.2.1.1. 第一个问题,咱们须要维护一个公共依赖的定义,即在主应用中定义每一个子应用所依赖的公共资源,在 qiankun 的全局微应用生命周期钩子 beforeLoad 中经过插入 <script>
标签的方式,加载当前子应用所需的 Javascript 资源,参考代码以下。
// 定义子应用的公共依赖 const dependencies = { live_backend: ['regular', 'restate'], look_backend: ['react', 'react-dom'] }; // 返回依赖名称 const getDependencies = appName => dependencies[appName]; // 构建script标签 const loadScript = (url) => { const script = document.createElement('script'); script.type = 'text/javascript'; script.src = url; script.setAttribute('ignore', 'true'); // 避免重复加载 script.onerror = () => { Message.error(`加载失败${url},请刷新重试`); }; document.head.appendChild(script); }; // 加载某个子应用前加载当前子应用的所需资源 beforeLoad: [ (app) => { console.log('[LifeCycle] before load %c%s', 'color: green;', app.name); getDependencies(app.name).forEach((dependency) => { loadScript(`${window.location.origin}/${$config.rootPath}${dependency}.js`); }); } ],
这里还要注意经过 Webpack 来生产好相应的依赖资源,咱们使用的是 copy-webpack-plugin 插件将 node_modules 下的 release 资源转换成包成能够经过独立 URL 访问的资源。
// 开发 plugins: [ new webpack.DefinePlugin({ 'process.env': { NODE_ENV: JSON.stringify('development') } }), new webpack.NoEmitOnErrorsPlugin(), new CopyWebpackPlugin({ patterns: [ { from: path.join(__dirname, '../node_modules/regularjs/dist/regular.js'), to: '../s/regular.js' }, { from: path.join(__dirname, '../node_modules/regular-state/restate.pack.js'), to: '../s/restate.js' }, { from: path.join(__dirname, '../node_modules/react/umd/react.development.js'), to: '../s/react.js' }, { from: path.join(__dirname, '../node_modules/react-dom/umd/react-dom.development.js'), to: '../s/react-dom.js' } ] }) ], // 生产 new CopyWebpackPlugin({ patterns: [ { from: path.join(__dirname, '../node_modules/regularjs/dist/regular.min.js'), to: '../s/regular.js' }, { from: path.join(__dirname, '../node_modules/regular-state/restate.pack.js'), to: '../s/restate.js' }, { from: path.join(__dirname, '../node_modules/react/umd/react.production.js'), to: '../s/react.js' }, { from: path.join(__dirname, '../node_modules/react-dom/umd/react-dom.production.js'), to: '../s/react-dom.js' } ] })
3.2.1.2. 关于子应用集成和独立访问时,对公共依赖的二次加载问题,咱们采用的方法是,首先子应用将主应用已经定义的公共依赖经过 copy-webpack-plugin 和 html-webpack-externals-plugin 这两个插件使用 external 的方式独立出来,不打包到 Webpack bundle 中,同时经过插件的配置,给 <script>
标签加上 ignore 属性,那么在 qiankun 加载这个子应用时使用,qiankun 依赖的 import-html-entry 插件分析到 <script>
标签时,会忽略加载有 ignore 属性的 <script>
标签,而独立访问时子应用自己能够正常加载这个 Javascript 资源。
plugins: [ new CopyWebpackPlugin({ patterns: [ { from: path.join(__dirname, '../node_modules/regularjs/dist/regular.js'), to: '../s/regular.js' }, { from: path.join(__dirname, '../node_modules/regular-state/restate.pack.js'), to: '../s/restate.js' }, ] }), new HtmlWebpackExternalsPlugin({ externals: [{ module: 'remoteEntry', entry: 'http://localhost:3000/remoteEntry.js' }, { module: 'regularjs', entry: { path: 'http://localhost:3001/regular.js', attributes: { ignore: 'true' } }, global: 'Regular' }, { module: 'regular-state', entry: { path: 'http://localhost:3001/restate.js', attributes: { ignore: 'true' } }, global: 'restate' }], }) ],
3.2.2. 针对第二类共享代码的场景,咱们调研了 Webpack 5 的 module federation plugin, 经过应用之间引用对方导入导出的 Webpack 编译公共资源信息,来异步加载公共代码,从而实现代码共享。
3.2.2.1. 首先,咱们实践所定义的场景是:lookcms 主应用同时提供基于 Regularjs 的 RButton 组件和基于 React 的 TButton 组件分别共享给 liveadmin 子应用和 increase 子应用。
3.2.2.2. 对于 lookcms 主应用,咱们定义 Webpack5 module federation plugin 以下:
plugins: [ // new BundleAnalyzerPlugin(), new ModuleFederationPlugin({ name: 'lookcms', library: { type: 'var', name: 'lookcms' }, filename: 'remoteEntry.js', exposes: { TButton: path.join(__dirname, '../client/exports/rgbtn.js'), RButton: path.join(__dirname, '../client/exports/rcbtn.js'), }, shared: ['react', 'regularjs'] }), ],
定义的共享代码组件以下图所示:
3.2.2.3. 对于 liveadmin 子应用,咱们定义 Webpack5 module federation plugin 以下:
plugins: [ new BundleAnalyzerPlugin(), new ModuleFederationPlugin({ name: 'liveadmin_remote', library: { type: 'var', name: 'liveadmin_remote' }, remotes: { lookcms: 'lookcms', }, shared: ['regularjs'] }), ],
使用方式上,子应用首先要在 html 中插入源为 http://localhost:3000/remoteEntry.js
的主应用共享资源的入口,能够经过 html-webpack-externals-plugin 插入,见上文子应用的公共依赖 external 处理。
对于外部共享资源的加载,子应用都是经过 Webpack 的 import 方法异步加载而来,而后插入到虚拟 DOM 中,咱们指望参考 Webapck 给出的 React 方案作 Regularjs 的实现,很遗憾的是 Regularjs 并无相应的基础功能帮咱们实现 Lazy 和 Suspense。
经过一番调研,咱们选择基于 Regularjs 提供的 r-component API 来条件渲染异步加载的组件。
基本的思想是定义一个 Regularjs 组件,这个 Regularjs 组件在初始化阶段从 props 中获取要加载的异步组件 name ,在构建阶段经过 Webpack import 方法加载 lookcms 共享的组件 name,并按照 props 中定义的 name 添加到 RSuspense 组件中,同时修改 RSuspense 组件 r-component 的展现逻辑,展现 name 绑定的组件。
因为 Regularjs 的语法书写受限,咱们不便将上述 RSuspense 组件逻辑抽象出来,所以采用了 Babel 转换的方式,经过开发人员定义一个组件的加载模式语句,使用 Babel AST 转换为 RSuspense 组件。最后在 Regularjs 的模版中使用这个 RSuspense
组件便可。
// 支持定义一个 fallback const Loading = Regular.extend({ template: '<div>Loading...{content}</div>', name: 'Loading' }); // 写成一个 lazy 加载的模式语句 const TButton = Regular.lazy(() => import('lookcms/TButton'), Loading); // 模版中使用 Babel AST 转换好的 RSuspense 组件 `<RSuspense origin='lookcms/TButton' fallback='Loading' />`
经过 Babel AST 作的语法转换以下图所示:
实际运行效果以下图所示:
3.2.2.4. 对于 increase 子应用,咱们定义 Webpack 5 module federation plugin 以下:
plugins: [ new ModuleFederationPlugin({ name: 'lookadmin_remote', library: { type: 'var', name: 'lookadmin_remote' }, remotes: { lookcms: 'lookcms', }, shared: ['react'] }), ],
使用方式上,参考 Webpack 5 的官方文档便可,代码以下:
const RemoteButton = React.lazy(() => import('lookcms/RButton')); const Home = () => ( <div className="m-home"> 欢迎 <React.Suspense fallback="Loading Button"> <RemoteButton /> </React.Suspense> </div> );
实际运行效果以下图所示:
LOOK 直播运营后台基于实际的业务场景,使用 qiankun 进行了微前端方式的工程拆分,目前在生产环境平稳运行了近 4 个月,在实践的过程当中,确实在需求确立和接入 qiankun 的实现以及部署应用几个阶段碰到了一些难点,好比开始的需求确立,咱们对要实现的主菜单功能有过斟酌,在接入 qiankun 的过程当中常常碰到报错,在部署的过程当中也遇到内部部署系统的抉择和阻碍,好在同事们给力,项目能顺利的上线和运行。
本文发布自 网易云音乐大前端团队,文章未经受权禁止任何形式的转载。咱们常年招收前端、iOS、Android,若是你准备换工做,又刚好喜欢云音乐,那就加入咱们 grp.music-fe(at)corp.netease.com!