圖片來源: 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!