zoukankan      html  css  js  c++  java
  • Node.js躬行记(9)——微前端实践

      后台管理系统使用的是umi框架,随着公司业务的发展,目前已经变成了一个巨石应用,越来越难维护,有必要对其进行拆分了。

      计划是从市面上挑选一个成熟的微前端框架,首先选择的是 icestark,虽然文档中有说明umi框架的改造,但版本得是 3 以上。

      而当前我们自己使用的版本是 1,差了整整两个版本。然后再去搜索,发现另一个微前端框架:qiankun,并且它有一个 umi插件

      但是又遇到了 umi版本的问题,好不容易找到一个咨询umi改造微前端的问题,版本也是2。

      本来是打算将老项目作为主应用,新项目作为微应用,但是现在因为版本的问题,难以实现。

      那么现在第一步是将umi升级,版本3的变化有点大,所以稳一点,先升级到版本 2。

    一、umi升级

      在官方文档中,有专门一页讲如何升级的,这个用户体验非常好。

      一个清单列的非常清楚,内容不多,让我信心大增。并且自己之前也曽依托 umi 2.0开源过一套系统

      所以在实际操作中,升级遇到的阻力没有我想象中的那么大,但期间还是遇到了些难缠的问题,诸如页面空白,文件不存在等。

      具体的改造其实就那么几步,升级和替换依赖库,更正路由配置,去除过时文件等。

      改造好后,自己粗略的刷刷页面,没啥问题,然后就开心地发布到预发环境。但是在生成source map文件时,报内存栈溢出。

      source map文件主要用于监控系统中的代码还原,在实际使用中用的比较少,那就先暂时关闭了。

      不过在生产发布的时候,又会报没有source map文件,因为生产有个将文件搬移到指定位置的脚本,得把这个脚本也关闭。

    二、Electron

      公司管理后台有一个监控直播的页面,之前因为种种原因将页面嵌在一个Electron中,而在那名老伙离职后,就处于无人维护的状态。

      近期对 umi 做了升级,在浏览器中可以正常访问,但是放到此软件中,就会变成空白,直觉告诉我JavaScript脚本报错了。

      公司相关的人员还比较依赖此软件,不能访问会很影响他们的日常工作,所以得花时间解决掉这个问题。

      在监控系统中选择runtime错误,可以看到下面的错误信息,但是这个错误还不够具体。

    {
      "type": "runtime",
      "lineno": 1,
      "colno": 2831631,
      "desc": "Uncaught TypeError: Cannot read property 'split' of undefined at https://xx.xx.me/umi.241e4aaf.js:1:2831631"
    }

      于是找到了一个名为Debugtron的工具,可以调试Electron中的页面(如下所示),在这个调试器中就可以看到具体的错误以及出错代码的位置。

      

      这段错误出现在一个加密算法库中,经过曲折的排查后,定位到业务逻辑中会引用crypto,一旦引用这个库就会报这个错误。

      一开始判断的错误是由于函数中传入的process是undefined或没有version字段,而global.process是有值的,从而进入到该条分支中调用split()。不过在配置webpack参数后,并没有解决该问题。

      然后想在本地的node_modules中找到那段代码,注释和打印变量,但总是无法生效。

      接着通过查看react的错误debug,找到了一段逻辑业务代码,将那个文件注释掉,居然能访问了。

      那是一段加密的逻辑,就用crypto-js替换crypto,但是两者的加密无法互通。

      休息了一会儿,和同事聊了下这个问题,他说服务端已经采用crypto-js封装了一段加密,给了我灵感,那就将原先的加密替换掉。

      全局搜索后发现只有一处引用了原先的加密逻辑,改造成本非常低,替换后,就能正常访问了。

    三、@umijs/plugin-qiankun

      在将 umi 升级到版本2.x后,就安装了 qiankun 插件,不过使用的版本是 1.7.0

      随后就是将原来的老项目配置为主应用,第一步是在 .umirc.js 中添加插件的声明。

    export default {
      plugins: [
        [
          '@umijs/plugin-qiankun',
          {
            master: {
              // 注册子应用信息
              apps: [
                {
                  name: 'app1', // 唯一 id
                  entry: '//localhost:8001', // html entry
                  base: '/app1',
                  mountElementId: 'root-slave',
                  // history: 'browser', 
                },
                // {
                //   name: 'app2', // 唯一 id
                //   entry: '//localhost:8002', // html entry
                //   base: '/app2',
                // },
              ],
              jsSandbox: true, // 是否启用 js 沙箱
            },
          }
        ]
      ],
    }

      第二步是在主应用中添加 id=root-slave 的元素,我将其加在 layout/index.js 文件中。

    <div id="root-slave"></div>

      若没指明子应用的挂载点,那么会报错:

    Application 'app1' died in status LOADING_SOURCE_CODE: Target container is not a DOM element

      第三步是修改 document.ejs 中的根节点ID,不再是 root,而是 root-master。

    <!-- <div id="root"></div> -->
    <div id="root-master"></div>

      第四步就是改造子应用,我直接下载了 umi 3.X 版本,在package.json 添加 name字段,值为app1。并在.umirc.ts中添加 qiankun 的配置。

    export default defineConfig({
      routes: [
        { path: '/', component: '@/pages/index' },
        { path: '/index', component: '@/pages/index' },
      ],
      qiankun: {
        slave: {},
      },
    });

      访问主应用地址:http://localhost:8000/app1,就能在页面中渲染出子应用,图中红色部分就是子应用界面。

      

      但是注意,该子应用的路径都是以 app1 开头,所以子应用中的路由如果是 /index的话,那么在主应用中的访问就是 http://localhost:8000/app1/index。

      当路由不匹配时,页面就会出现未渲染的空白。至此,初步实现了微前端。

    四、子应用

      子应用使用的是umi 3.X,该版本默认将Ant Design升级到了4.X版本,4.X在使用上做了些调整。

      在将通用模板组件和通用功能迁移到此框架中时,遇到了不少阻力。

    1)废弃Form.create()

      首先是对通用组件中的表单做修改,因为该版本的 Form 不再需要通过 Form.create() 创建上下文,因此 getFieldDecorator() 函数也不再需要,改成了直接写入 Form.Item。

    // antd v3
    const Demo = ({ form: { getFieldDecorator } }) => (
      <Form>
        <Form.Item>
          {getFieldDecorator('username', {
            rules: [{ required: true }],
          })(<Input />)}
        </Form.Item>
      </Form>
    );
    // antd v4
    const Demo = () => (
      <Form>
        <Form.Item name="username" rules={[{ required: true }]}>
          <Input />
        </Form.Item>
      </Form>
    );

    2)validateFields()返回值修改

      然后 validateFields() 也不再支持回调,而是会返回 Promise 对象,因而可以通过 async/await 或者 then/catch 来执行对应的错误处理。

    // antd v3
    validateFields((err, value) => {
      if (!err) {
        // Do something with value
      }
    });
    // antd v4
    validateFields().then(values => {
      // Do something with value
    });

      这两个是比较大的改造,基本都是围绕着它们做兼容。

    3)ICON按需引入

      之后是 ICON 图标引入的修正,新版本需要安装 @ant-design/icons,然后按需引入 ICON 组件。

    import { CheckCircleOutlined, PlusOutlined } from '@ant-design/icons';

      新版本的Ant Design组件的圆角采用的是 2px,而之前版本的圆角是 4px,所以新版本会看上去更加尖锐。

    4)DvaJS的TypeScript改造

      除了对组件的修改之外,还修改了@umijs/plugin-dva的相关文件,因为要符合 TypeScript 的语法,所以需要添加许多类型声明。

    import { Effect, ImmerReducer, Reducer, Subscription } from 'umi';
    export interface IndexModelState {
      name: string;
    }
    export interface IndexModelType {
      namespace: 'index';
      state: IndexModelState;
      effects: {
        query: Effect;
      };
      reducers: {
        save: Reducer<IndexModelState>;
        // 启用 immer 之后
        // save: ImmerReducer<IndexModelState>;
      };
      subscriptions: { setup: Subscription };
    }

    5)样式隔离

      在将子应用嵌入到主应用后,发现子应用的样式影响了主应用中的部分样式,所以需要将他们两者隔离样式。

      为子应用的ConfigProvider组件添加prefixCls属性,当然也可以在主应用中配置,但是主应用中涉及的页面众多,以防万一还是在子应用中配置比较保险。

    <ConfigProvider locale={zhCN} prefixCls="ant-slave">
        <App {...data} />
    </ConfigProvider>

      并且在.umirc.ts文件中修改Ant Design中的Less变量:@ant-prefix,若不与prefixCls对应,则整个页面将会没有样式。

    theme: {
      '@ant-prefix': 'ant-slave',
    },

    五、主应用发起两次请求

      在具体使用时又发现了一个严重的问题,那就是当切换菜单时,每个初始化的请求会发送两次(如下图所示),我这些请求都被封装在 history.listen的回调中。

      

      subscriptions: {
        setup({ dispatch, history }) {
          history.listen((location) => {
            if (location.pathname === '/developer/config') {
              dispatch({
                type: TEMPLATE_MODEL.QUERY,
                payload: {
                  url: api.toolConfigQuery
                }
              });
            }
          });
        },
      },

      在网上搜索,看到很多人也遇到了这个问题,究其原因是 popstate 事件触发了两次。缘由是因为:

      single-spa改写了window.history.pushState方法,在每次pushState的时候会主动dispatch一个popState事件,被history的checkDomListeners捕获,这样通过history.listen注册的监听函数都会被执行两次。

      既然知道了原因,那就可以对症下药了,网上的一个issue说可以用 urlRerouteOnly 参数来控制调用 history.pushState 和 history.replaceState 时是否触发一个 popstate 事件。

      说干就干,在.umirc.js中添加urlRerouteOnly参数,自动刷新页面后,什么也没有改变,依旧是两次,怎么配都不行。

        [
          '@umijs/plugin-qiankun',
          {
            master: {
              // 注册子应用信息
              apps: [
                {
                  name: 'app1', // 唯一 id
                  entry: '//localhost:8001', // html entry
                  base: '/app1',
                  mountElementId: 'root-slave',
                },
              ],
              jsSandbox: true, // 是否启用 js 沙箱
              urlRerouteOnly: true
            },
          }
        ]

      那就直接查看存在于 node_modules 的乾坤源码了(插件的源码没啥看头),使用的版本是 1.5.2,找到了调用single-spa的start()方法的函数,发现并没有传递我配置的参数。

    import { registerApplication, start as startSingleSpa } from 'single-spa';
    // opts参数包含urlRerouteOnly参数
    export function start(opts) {
      if (opts === void 0) {
        opts = {};
      }
      // 省略中间逻辑
      startSingleSpa();
      frameworkStartedDefer.resolve();
    }

      本来以为是因为 start() 函数中未传递urlRerouteOnly参数导致问题仍然存在,但是将参数手动的传入,问题并没有被排除。

      那么接下来就得查看 single-spa 的源码了,我发现与网上给出的源码不太一样,原来我当前使用的版本是4.4.4(如下所示),而网上解决方案给出的是5.9.4。

    window.history.pushState = function (state) {
      var result = originalPushState.apply(this, arguments);
      urlReroute(createPopStateEvent(state));
      return result;
    };

      在 5.9.3 中提供了 urlRerouteOnly 参数来控制是否触发 popstate 事件(如下所示),而旧版本是没有这个控制选项的。

    window.history.pushState = patchedUpdateState(
      window.history.pushState,
      "pushState"
    );
    function patchedUpdateState(updateState, methodName) {
      return function () {
        const urlBefore = window.location.href;
        const result = updateState.apply(this, arguments);
        const urlAfter = window.location.href;
    
        if (!urlRerouteOnly || urlBefore !== urlAfter) {
          if (isStarted()) {
            // fire an artificial popstate event once single-spa is started,
            // so that single-spa applications know about routing that
            // occurs in a different application
            window.dispatchEvent(
              createPopStateEvent(window.history.state, methodName)
            );
          } else {
            // do not fire an artificial popstate event before single-spa is started,
            // since no single-spa applications need to know about routing events
            // outside of their own router.
            reroute([]);
          }
        }
        return result;
      };
    }

      至此又回到了原点,网上还有另一种临时的解决方案,那就是改造 react-router-dom/BrowserRouter,在构造函数中覆盖 history.listen,并加个路径判断。

      若当前路径和上一个路径相同,那么就直接返回,停止后面的逻辑。不过个人感觉这样侵入性比较大,很有可能造成非常隐蔽的错误。

    export default class BrowserRouter extends React.Component<BrowserRouterProps> {
      private history: History
      constructor(props) {
        super(props)
        this.history = createHistory(this.props)
        const rawHistory = this.history.listen.bind(this.history)
        // 临时解决方案
        this.history.listen = (listener: LocationListener<LocationState>): UnregisterCallback => {
          let lastPathname = null
          function enhancerCb(...args) {
            const [location] = args
            if (location.pathname === lastPathname) return
            lastPathname = location.pathname
            // @ts-ignore
            return listener(...args)
          }
          return rawHistory(enhancerCb)
        }
      }
    }

      我在此思路上做了些修改,我封装了一个方法,首先想到的是判断当前路径和上一条路径的相等性,但遇到一个问题。

    export function listenHoc(history, callback) {
      let lastPath;
      history.listen((location) => {
        if (location.pathname === lastPath) return
        lastPath = location.pathname
        callback(location);
      });
    }

      当点击一个菜单项,能够阻止第二次的请求,但是我再点击相同的菜单项,就会不再请求了。因为此时lastPath和location.pathname是相等的。

      经过观察,发现location有个key属性,每次点击菜单就会重新生成key,可以用此属性来做二次请求的判断依旧。

    export function listenHoc(history, callback) {
      let lastKey;
      history.listen((location) => {
        if (location.key === lastKey) return
        lastKey = location.key
        callback(location);
      });
    }

      这么做的好处是灵活性高,并且都在我的控制之中,还不会影响其他第三方库。最大的坏处就是要修改许多Model文件,改变调用方式。

      在实际使用中马上暴露一个问题,那就是当直接在工具栏中输入地址回车后。

      location.key的值是undefined,而lastKey也是undefined,由此两者匹配就会相同,也就会终止接下去的逻辑。

      那么为了避免这种情况,需要添加一个条件限制。

    export function listenHoc(history, callback) {
      let lastKey;
      history.listen((location) => {
        if (location.key !== undefined && (location.key === lastKey)) return
        lastKey = location.key
        callback(location);
      });
    }

    六、部署

      在解决完这个问题后,就将子应用部署到服务器上,虽然在本地访问子应用,需要配置某个端口,但是在线上并不需要单独配置端口,默认80端口足矣。

      为了方便,连域名也没有单独申请,沿用主应用的域名,在Nginx上仅仅做了次转发,如下所示。这样当访问 xx.xx.com/app1/ 时,呈现的就是子应用的界面。

    xx.xx.com/app1/(.*) -> appts

      但是在访问时,发现该项目的脚本文件没有被正确读取,查看页面发现脚本读取的是根目录的umi.js,也就是主应用的脚本。

    <script src="/umi.js" entry=""></script>

      而我们需要的是子应用的脚本,因此,需要在 .umirc.ts 文件中手动配置静态资源路径。

    export default defineConfig({
      publicPath: '/app1/',         //静态资源路径
    });

      在主应用的.umirc.js配置文件中,需要有环境的判断,umi中的process.env.NODE_ENV是写死的,dev 时为 development,build 时为 production。

      在本地开发时,可以自定义端口,但是挂在服务器上就需要通过域名来访问了,此时的配置就会不同。

        [
          '@umijs/plugin-qiankun',
          {
            master: {
              apps: [
                {
                  name: 'app1',
                  entry: (process.env.NODE_ENV === 'development' ? '//localhost:8001' : '/app1/'),
                  base: '/app1',
                  mountElementId: 'root-slave',
                },
              ],
              jsSandbox: true,
            },
          }
        ]
      ]
  • 相关阅读:
    fzuoj Problem 2177 ytaaa
    zoj The 12th Zhejiang Provincial Collegiate Programming Contest Capture the Flag
    zoj The 12th Zhejiang Provincial Collegiate Programming Contest Team Formation
    zoj The 12th Zhejiang Provincial Collegiate Programming Contest Beauty of Array
    zoj The 12th Zhejiang Provincial Collegiate Programming Contest Lunch Time
    zoj The 12th Zhejiang Provincial Collegiate Programming Contest Convert QWERTY to Dvorak
    zoj The 12th Zhejiang Provincial Collegiate Programming Contest May Day Holiday
    zoj The 12th Zhejiang Provincial Collegiate Programming Contest Demacia of the Ancients
    zjuoj The 12th Zhejiang Provincial Collegiate Programming Contest Ace of Aces
    csuoj 1335: 高桥和低桥
  • 原文地址:https://www.cnblogs.com/strick/p/15104824.html
Copyright © 2011-2022 走看看