zoukankan      html  css  js  c++  java
  • vue单页(spa)前端git工程拆分实践

    背景

    随着项目的成长,单页spa逐渐包含了许多业务线

    • 商城系统
    • 售后系统
    • 会员系统
    • ...

    当项目页面超过一定数量(150+)之后,会产生一系列的问题

    • 可扩展性
    项目编译的时间(启动server,修改代码)越来越长,而每次调试关注的可能只是其中1、2个页面
    • 需求冲突
    所有的需求都定位到当前git,需求过多导致测试环境经常排队

    基于以上问题有了对git进行拆分的技术需求。具体如下

    目标

    • 依然是spa
    由于改善的是开发环境,当然不希望拆分项目影响用户体验。如果完全将业务线拆分成2个独立页面,那么用户在业务线之间跳转时将不再流畅,因为所有框架以及静态资源都会在页面切换的时候重载。因此要求跳转业务线的时候依然停留在spa内部,不刷新页面,共用同一个页面入口;
    • 业务线页面不再重复加载资源
    因为大部分业务线需要用到的框架(vue, vuex...), 公共组件(dialogtoast)都已经在spa入口加载过了,不希望业务线重复加载这些资源。业务线项目中应该只包含自己独有的资源,并能使用公共资源;
    • 业务线之间资源完全共享
    业务线之间应该能用router互相跳转,能访问其他业务线包括全局的store

    需求如上,下面介绍的实现方式

    技术框架

    • vue: 2.4.2
    • vue-router: 2.7.0
    • vuex: 2.5.0
    • webpack: 4.7.0

    实现

    假设要从主项目拆分一个业务线 hello 出来

    • 主项目:包含系统核心页面 + 各种必须框架(vue, vuex...)
    • hello项目:包含hello自己内部的业务代码

    跳转hello页面流程

    1. 用户访问业务线页面 路由 #/hello/index
    2. 主项目router未匹配,走公共*处理;
    3. 公共router判定当前路由为业务线hello路由,请求hello的入口bundle js
    4. hello入口js执行过程中,将自身的router与store注册到主项目;
    5. 注册完毕,标记当前业务线hello为已注册;
    6. 之后路由调用next。会自动继续请求 #/hello/index对应的页面chunk(js,css)页面跳转成功;
    7. 此时hello已经与主项目完成融合,hello可以自由使用全部的store,使用router可以自由跳转任何页面。done

    需要的功能就是这些,下面分步骤看看具体实现

    请求业务线路由(步骤1)

    第一次请求#/hello/index时,此时router中所有路由无法匹配,会走公共*处理

    
    /** 主项目 **/
    const router = new VueRouter({
      routes: [
        ...
        // 不同路由默认跳转链接不同
        {
          path: '*',
          async beforeEnter(to, from, next) {
            // 业务线拦截
            let isService = await service.handle(to, from, next);
    
            // 非业务线页面,走默认处理
            if(!isService) {
              next('/error');
            }
    
          }
        }
      ]
    });
    

    业务线初始化(步骤2、步骤3)

    首先需要一个全局的业务线配置,存放各个业务线的入口js文件

    
    const config = {
        "hello": {
            "src": [
              "http://local.aaa.com:7000/dist/dev/js/hellobundle.js"
            ]
        },
        "其他业务线": {...}
    }
    

    此时需要利用业务线配置,判断当前路由是否属于业务线,是的话就请求业务线,不是返回false

    
    /** 主项目 **/
    // 业务线接入处理
    export const handle = async (to, from, next) => {
      let path = to.path || "";
      let paths = path.split('/');
      let serviceName = paths[1];
    
      let cfg = config[serviceName];
    
      // 非业务线路由
      if(!cfg) {
        return false;
      }
    
      // 该业务线已经加载
      if(cfg.loaded) {
        next();
        return true;
      }
    
      for(var i=0; i<cfg.src.length; i++) {
        await loadScript(cfg.src[i]);
      }
      cfg.loaded = true;
      next(to);  // 继续请求页面
      return true;
    }
    

    有几点需要注意

    • 一般业务线配置存放在后端,此处为了说明直接列出
    • 业务线只加载1次,loaded为判定条件。加载过的话直接进行next
    • 当第1次业务线加载成功,此时主项目已经包含了 #/hello/index 的路由,此时next可以正常跳转。原因见下一节

    hello的入口entry.js做的工作(步骤4)

    为了节省资源,hello业务线不再重复打包vuevuex等主项目已经加载的框架。

    那么为了hello能正常工作,需要主项目将以上框架传递给hello,方法为直接将相关变量挂在到window

    
    /** 主项目 **/
    import Vue from 'vue';
    import { default as globalRouter } from 'app/router.js'; 2个需要动态赋值
    import { default as globalStore } from 'app/vuex/index.js';
    import Vuex from 'vuex'
    
    // 挂载业务线数据
    function registerApp(appName, {
      store,
      router
    }) {
      if(router) {
        globalRouter.addRoutes(router);
      }
      if(store) {
        globalStore.registerModule(appName, Object.assign(store, {
          namespaced: true
        }));
      }
    }
    
    window.bapp = Object.assign(window.bapp || {}, {
      Vue,
      Vuex,
      router: globalRouter,
      store: globalStore,
      util: {
        registerApp
      }
    });
    

    注意registerApp这个方法,此方法为hello与主项目融合的挂载方法,由业务线调用。

    上一步已经正常运行了hello的entry.js,那我们看看hello在entry中干了什么:

    
    /** hello **/
    import App from 'app/pages/Hello.vue'; // 路由器根实例
    import {APP_NAME} from 'app/utils/global';
    import store from 'app/vuex/index';
    
    let router = [{
      path: `/${APP_NAME}`,
      name: 'hello',
      meta: {
        title: '页面测试',
        needLogin: true
      },
      component: App,
      children: [
        {
          path: 'index',
          name: 'hello-index',
          meta: {
            title: '商品列表'
          },
          component: resolve => require.ensure([], () => resolve(require('app/pages/goods/Goods.vue').default), 'hello-goods')
        },
        {
          path: 'newreq',
          name: 'hello-newreq',
          meta: {
            title: '新品页面'
          },
          component: resolve => require.ensure([], () => resolve(require('app/pages/newreq/List.vue').default), 'hello-newreq')
        },
      ]
    }]
    
    window.bapp && bapp.util.registerApp(APP_NAME, {router, store});
    

    注意几点

    • APP_NAME是业务线的唯一标识,也就是hello
    • 业务线有自己内部的routerstore
    • 业务线主动调用registerApp,将自己的router和store与主项目融合
    • store融合的时候需要添加namespace: true,因为此时整个hello业务线store成为了globalStore的一个module
    • addRoutesregisterModule是router与store的动态注册方法
    • 路由的name需要和主项目保持唯一

    业务线配置更新

    业务线配置需要在hello每次编译完成后更新,更新分为本地调试更新线上更新

    • 本地调试更新只需要更新一个本地配置文件service-line-config.json,然后在请求业务线config时由主项目读取该文件返回给js。
    • 线上更新更为简单,每次发布编译后,将当前入口js+md5的完整url更新到后端

    以上,看到使用webpack-plugin比较适合当前场景,实现如下

    
    class ServiceUpdatePlugin {
      constructor(options) {
        this.options = options;
        this.runCount = 0;
      }
    
      // 更新本地配置文件
      updateLocalConfig({srcs}) {
        ....
      }
    
      // 更新线上配置文件
      uploadOnlineConfig({files}) {
        ....
      }
    
      apply(compiler) {
        // 调试环境:编译完毕,修改本地文件
        if(process.env.NODE_ENV === 'dev') {
          // 本地调试没有md5值,不需要每次刷新
          compiler.hooks.done.tap('ServiceUpdatePlugin', (stats) => {
            if(this.runCount > 0) {
              return;
            }
            let assets = stats.compilation.assets;
            let publicPath = stats.compilation.options.output.publicPath;
            let js = Object.keys(assets).filter(item => {
              // 过滤入口文件
              return item.startsWith('js/');
            }).map(path => `${publicPath}${path}`);
    
            this.updateLocalConfig({srcs: js});
            this.runCount++;
          });
        }
        // 发布环境:上传完毕,请求后端修改
        else {
          compiler.hooks.uploaded.tap('ServiceUpdatePlugin', (upFiles) => {
            let entries = upFiles.filter(file => {
              return file &&
                file.endsWith('js') &&
                file.includes('js/');
            });
    
            this.uploadOnlineConfig({files: entries});
            return;
          })
    
        }
      }
    }
    
    

    注意,uploaded事件由我们项目组的静态资源上传plugin发出,会传递当前所有上传文件完整路径。需要等文件上传cdn完毕才可更新业务线

    之后在webpack中使用即可

    
    /** hello **/
    {
      ...
      plugins: [
        // 业务线js md5更新
        new McServiceUpdatePlugin({
          app_name,
          configFile: path.resolve(process.cwd(), '../mainProject/app/service-line-config.json')
        })
      ],
      ...
    }
    

    注意本地调试时业务线config是主项目才会用到的,因此直接更新主项目目录下的配置文件

    调试发布

    基于上面的plugin,有以下效果

    调试过程如下:
    1. 启动主项目server(端口7777);
    2. 启动hello业务线server(端口7000),此时启动成功会同时更新本地文件service-line-config.json;
    3. 访问hello页面,加载本地配置后,加载7000端口提供的静态资源(如http://local.aaa.com:7000/dist/dev/js/hellobundle.js)
    发布test过程如下:
    1. 执行 npm run test
    2. 执行过程中会上传文件并更新test环境业务线配置
    3. 此时访问test环境页面已经更新

    可以看到hello发布是比主项目更加轻量的,这是因为业务线只更新接口,但是主项目要发布还需要更新html的web服务

    小结

    至此已经完成了一开始的主体需求,访问业务线页面后,业务线页面会和主项目页面合并成为1个新的spa,spa内部store和router完全共享。

    可以看到主要利用了vue家族的动态注册方法。下面是一些过程中遇到的问题和解决思路

    遇到的问题与解决

    hello业务线的wepback打包

    • 业务线需要独立的打包命名空间
    • 为了能与主项目区分,会给hello业务线的bundle重命名,增加了业务线名称前缀
    • 入口文件越少越好,因此删除了一些打包配置

      • 删除了vendor: 主要第三方库由主项目加载
      • 删除了dll: dll资源由主项目加载
      • 删除了runtime(manifest)配置: 各业务线将各自处理依赖加载
    
    /** hello **/
    {
      ...
      entry: {
        [app_name + 'bundle']: path.resolve(SRC, `entry.js`)
      },
      output: {
        publicPath: `http://local.aaa.com:${PORT}${devDefine.publicPath}`,
        library: app_name // 业务线命名空间
      },
      ...
      optimization: {
        runtimeChunk: false, // 依赖处理与bundle合并
        splitChunks: {
          cacheGroups: false // 业务线不分包
        }
      },
      ...
    }
    

    注意library的设置隔离了各个业务线
    入口文件

    在这里插入图片描述

    依赖

    ![在这里插入图片描述](https://img-blog.csdnimg.cn/20181215110746504.png)

    在这里插入图片描述

    router拆分问题

    最开始使用/:name来做公共处理。

    但是发现router的优先级按照数组的插入顺序,那么后插入的hello路由优先级将一直低于/:name路由。

    之后使用*做公共处理,将一直处于兜底,问题解决。

    store拆分

    hello的store做为globalStore的一个module注册,需要标注 namespaced: true,否则拿不到数据;

    store使用基本和主项目一致:

    
    /** hello **/
    
    let { Vuex } = bapp;
    // 全局store获取
    let { mapState: gmapState, mapActions: gmapActions, createNamespacedHelpers } = Vuex;
    // 本业务线store获取
    const { mapState, mapActions } = createNamespacedHelpers(`${APP_NAME}/feedback`)
    
    export default {
      ...
      computed: {
        ...gmapState('userInfo', {
          userName: state => state.userName
        }),
        ...gmapState('hello/feedback', {
          helloName2: state => state.helloName
        }),
        ...mapState({
          helloName: state => state.helloName
        })
      },
    }
    

    接口拆分

    虽然前端工程拆分了,但是后端接口依然是走相同的域名,因此可以给hello暴露一个生成接口参数的公共方法,然后由hello自己组织。

    公共利用

    可以直接使用全局组件mixinsdirectives,可以直接使用font
    局部的相关内容需要拷贝到hello或者暴露给hello才可用。
    图片完全无法复用

    本地server工具

    主项目由于需要对request有比较精细的操作,因此是我们自己实现的express来本地调试。

    但是hello工程的唯一作用是提供本地当前的js与css,因此使用官方devServer就够了。


    以上

    原文地址:https://segmentfault.com/a/1190000017124192

  • 相关阅读:
    有用工具
    Questions
    Verizon Wireless 4G LTE DROID
    测试的几点心得
    推荐书
    工作总结
    电脑故障排查检修积累
    Windows 8 下如何安装Framework 3.5
    空间域名的选择项
    自动化research
  • 原文地址:https://www.cnblogs.com/datiangou/p/10122774.html
Copyright © 2011-2022 走看看