Skip to content

滴滴 webapp 5.0 Vue 2.0 重构经验分享 #13

@ustbhuangyi

Description

@ustbhuangyi

项目背景

滴滴的 webapp 是运行在微信、支付宝、手 Q 以及其它第三方渠道的打车软件。借着产品层面的功能和视觉升级,我们用 Vue 2.0 对它进行了一次技术重构。

技术栈

MVVM框架: Vue 2.0
源码:es6
代码风格检查:eslint
构建工具:webpack
前端路由:vue-router
状态管理:vuex
服务端通讯:vue-resource

技术全景

技术全景图

几个问题

  1. 滴滴 webapp 是一个大的 SPA 应用么?
    滴滴 webapp 包含众多业务线,每个业务线都有独立的一套的发单流程逻辑。那么问题来了,这些业务逻辑都是在一个单页中完成的么?

  2. 如何实现组件化?
    滴滴 webapp 5.0 的设计思路就是组件化,设计提供了很多组件,每个页面都是由组件拼接而成。那么问题来了,如何区分基础组件和业务组件,并把基础组件抽象成一个公共组件库?

  3. 一个代码仓库多条业务线,如何很好的做到多人同时开发和持续集成?
    滴滴有多条业务线,每条业务线会有一位前端同学开发代码。那么问题来了,如何模块化的组织代码,如何尽可能的减少开发的冲突以及做好持续集成?

  4. 有部分业务线需要异步加载,这部分业务线如何开发?
    滴滴目前会把类专车业务线的代码放在一个仓库里,但是部分业务线,如顺风车的代码是不在这个仓库里的。那么问题来了,这部分代码如何开发,如何使用 Vue,Vuex,store,以及一些公用方法和组件?

  5. 异步加载的业务线组件,如何动态注册?
    我们需要异步加载业务线的 JS 代码,这些业务线实现的是一个 Vue component。那么问题来了,如何优雅地动态注册这些组件呢?

  6. 异步加载的业务线如何动态注册路由?
    我们在使用 Vue-router 初始化路由的时候,通常都会写一个路由映射表。那么问题来了,这些异步加载的业务线,如果也想使用路由,那么如何动态注册呢?

  7. 如何在测试环境下与后端接口交互?
    我们在开发阶段,通常都是在本地调试,本地起的服务域名通常是 localhost:端口号。那么问题来了,这样会和 ajax 请求产生跨域问题,而我们也不能要求服务端把所有 ajax 请求接口都开跨域,如何解决呢?

  8. 如何部署到线下测试环境?
    我们在本地开发完代码后,需要把代码提测。通常测试拿到代码后,需要部署和测试,那么问题来了,我们如何把本地代码部署到我们的开发机测试环境中呢?

解决方案

  1. 滴滴 webapp 是一个大的 SPA 应用么?

    滴滴 webapp 包含众多业务线,每个业务线都有独立的一套的发单 -> 接驾 -> 行程中 -> 订单完成的流程逻辑。试想一下,如果整体是一个 SPA 应用,最终打包的 JS 会变的很大,虽然可以通过 code spliting 技术异步加载,但也不可避免会增加代码量,而且非常不利于维护。

    因此,我们把发单和后续的业务逻辑拆开,拆成发单首页和后续流程页面,每个业务线都有自己独立的发单后的流程页面。这样滴滴的 webapp 相当于多个 SPA 应用构成,页面间跳转的数据传递通过 url 传参即可。

  2. 如何实现组件化?

    组件化现在几乎成为 webapp 开发的标准,滴滴从设计角度就已经是组件化的思路了。但是设计只会把页面拆成一个个组件,我们作为开发者,需要从这些众多组件中提取出哪些是基础组件,哪些是业务组件,哪些组件可被复用等等。

    基础组件主要指那些本身不包含任何业务逻辑、可以被轻松复用的组件,例如 picker、timepicker、toast、dialog、actionsheet 等等...我们基于 Vue 2.0 实现了一套移动端的基础组件库,打包了所有基础组件,并托管在 npm 私服上,使用非常方便。基础组件的通讯基本就是往组件传入 prop,并监听组件 $emit 的事件。

    业务组件主要指那些包含业务逻辑,包括一些与后端接口通讯的逻辑。业务组件会包含若干个基础组件,通常我们会把一些业务逻辑的数据通过 Vuex 管理起来,然后组件内部读取数据和提交对数据修改的动作。这里需要说明一点,当我们使用 Vuex 的时候,并不是所有和后端通讯的请求都需要提交一个 action,如果这个请求并不会修改我们 store 里的数据,可以在组件内部消化。举个实际的例子,我们在开发 suggest 组件的时候,每次输入字符检索相关的地址的时候,这个请求由组件内部发起,并且把请求的数据渲染到组件的列表即可,因为它并没有修改 store 里的数据。

    基础组件通常都是可复用的,部分业务组件同样可复用,它们的 UI 和业务逻辑相似。我们会把单个可复用的业务组件单独发布到 npm 私服上,需要使用的业务线依赖即可。注意,业务组件我们是不建议使用 Vuex,需要考虑到不同的使用方对 Vuex 内部变量的定义和使用是不相同的。

  3. 一个代码仓库多条业务线,如何很好的做到多人同时开发和持续集成?

    滴滴的 webapp 首页有多条业务线,每条业务线都有一个开发人员,为了保证尽量减少代码的冲突,我们按业务线对代码进行了模块划分。由于 Vuex 支持modules,我们很自然地按业务线拆分了 modules,每个 modules 维护自己的 getters、actions、mutaions 和 state,而一些公共数据如经纬度、上下车信息、用户登录状态等作为 root state,被所有业务线共享。同样,components 里也按业务线做了更细致的划分,每个业务线独立的业务组件放在各自的目录里,彼此之前不会有冲突。

    仅仅做到目录拆分还是不够的,我们还要考虑到持续集成,跟着产品的版本迭代节奏发布上线。那么每个版本的需求,每个业务线都会参与开发,我们用 gitlab 管理代码,如果每个开发同学都拉一个分支,那么会面临着分支太多,功能联调麻烦等问题。因此,我们约定了一套 git 的管理规范,每个大需求版本,我们会约定以 "dev +上线时间日期" 作为分支名创建开发分支,所有人在这个分支上开发,开发完成让 QA 测试该分支,上线前才会将分支合入主干发布。在两个版本发布期间如果有 bug fix,则约定以 "bugfix + 功能描述" 为分支名创建 bugfix 分支,修复完成后合入主干上线。每次上线前,我们都会运行脚本新增版本号,编译打包,保证前端资源的增量发布。

  4. 有部分业务线需要异步加载,这部分业务线如何开发?

    滴滴目前会把一些业务线的代码放在一个仓库里,但是部分业务线,如顺风车的代码是不在这个仓库里的。首页通过异步加载 JS 去加载这部分业务线的代码,这部分业务线很显然也是需要用 Vue 开发的,但是他们不可以再去单独引入 Vue.js。

    我们的解决方案是在 window 上注册一个 XXApp 对象,把 Vue、Vuex 以及一些公共组件和方法等挂载到这个对象上,那么这些异步加载的业务线就可以通过 window.XXApp 访问到了,代码如下:

    window.XXApp = {
        Vue, 
        Vuex,
        store, // 全局store
        saveCurrentBiz, // 公共方法
        Location // 公共组件
        // 其它一些公共方法和组件
    }
    

    业务线可以访问到这些对象后,接下来需要实现的就是一个 Vue component。

  5. 异步加载的业务线组件,如何动态注册?

    Vue.js 官网提供的异步组件的解决方案大多是基于 webpack 的 require.ensure 去异步加载组件的,但很显然这并不适用滴滴的业务场景,因为我们的代码并不在一个仓库下。我们需要一种类似 amd 的解决方案,这些异步业务线需要实现的是一个 Vue component,我们该如何优雅地动态注册这个 component 呢?

    我们知道 Vue 提供了动态注册组件的 api,通过 Vue.component('async-example',function(resolve){ //... }) 的方式在工厂函数里通过 resolve 方法动态注册组件。注意,这个工厂函数的执行时机是组件实际需要渲染时,那我们渲染这些异步组件的时机就是当我们切换顶部导航栏到该业务线的时候

    首先,每一条业务线对应着一个独立的组件,业务线有各自的 id,因此,我们先用一个对象去维护这样的映射关系,代码如下:

    const modules = {
        业务线id: Taxi, // 出租车
        // 其它同步业务线组件  
    }
    

    这个对象初始化的都是同步业务线组件,对于异步加载的业务线组件,我们需要动态注册。首先我们在全局的 config.js 里维护一个业务线的配置关系表,异步加载的业务线会多一个 src 属性,代码如下:

    bizConf: {
       异步业务线id: {
          name: 'alift', // 业务线名称
          src: xxx // 加载异步业务线的 js 地址
       },
       同步业务线 id: {
          name: 'taxi'
       }
       // 其它业务线配置
    

    接下来我们遍历这个对象,代码如下:

     // 获取 bizConf 对象
     const bizJSConf = config.get('bizConf') 
    
     for (let id in bizJSConf) {
        let conf = bizJSConf[id]
        if (conf.src) {
          modules[id] = conf.name
          Vue.component(conf.name, (resolve, reject) => {
            loadScript(conf.src).then(() => {
              resolve(modules[id])
            }).catch(() => {
              reject()
            })
          })
        }
      }
    

    可以看到,对于异步业务线,我们会把它的 name 添加到 modules 对象的映射关系中,并按这个 name 注册一个异步组件,注意,这个时候注册组件的工厂函数并不会执行。

    我们之前说到了渲染这些异步组件的时机就是当我们切换顶部导航栏到该业务线的时候,我们来看看切换顶部导航栏的时候执行了什么逻辑,关键代码如下:

     this.currentView = modules[productid]
    

    这个 currentView 我们是在 App.vue 的 data 里初始化的,映射到 template 的代码如下:

     <component :is="currentView"></component>
    

    没错,这里我们又用到一个 Vue 的高级用法,动态组件。我们的业务线组件对应的就是这个动态组件。官网文档介绍的动态组件是绑定到一个组件对象上的,这对于我们的同步组件,当然是没有问题的,modules 映射的就是一个组件对象;但是对于异步组件,我们映射的是组件的名称,它是一个字符串,当 currentView 指向这个字符串的时候,注册异步组件的工厂函数执行了,回顾之前的代码,这个时候它会去加载异步业务线的 js,加载完成的回调函数里,执行 resolve(modules[id])

    等等,看到这里,有人不禁会问,这里 modules[id] 是什么,还是异步组件的名称吗?当然不是了,这里的 modules[id] 对应的是异步业务线的组件对象。那么,它是怎么被赋值成组件对象的呢?我们来看代码:

    window.XXApp = {
        // ...
        // 一些公共方法和组件
        registerBiz(id, component) {
          modules[id] = component
        }
    }
    

    我们在 window.XXApp 下又添加了一个 registerBiz 的方法,当我们异步加载完业务线的 JS 后,异步业务线调用这个方法真正的把自己实现的 Vue component 注册到我们的 modules 里,所以我们 resolve 的就是这个组件对象,是不是很优雅?至此,我们完成了异步业务线组件的动态注册。

  6. 异步加载的业务线如何动态注册路由?
    再接着上述问题继续发散,我们在使用 Vue-router 初始化路由的时候,通常都会写一个路由映射表。对于同步业务线这些已知的组件,路由的映射是没有问题的,那么这些异步加载的业务线,如果它的某些子组件也想使用路由该怎么办?

    我们需要一套动态注册路由的方案,而官网文档提供的路由懒加载的方案并不能满足我们的需求,因此我们想到了另一种变通方案。我们在路由配置如下:

      {
        path: 'pathA' //这里的命名只是示意
        component: componentA
      },
      {
        path: 'pathB',
        component: componentB
      },
      //...
      {
        path: '/:name',  // 动态匹配
        component: Dynamic // 已知组件
      }
    

    可以看到,我们在定义了一系列常规的路由后,最后定义了一个动态匹配路由,也就是任意 name 的一级 path,只要没有命中之前的 path,都会映射到这个我们定义好的 Dynamic 组件上。我们来看看这个 Dynamic 组件的实现,先看一下模板:

    <template>
      <transition :name="transitionName">
        <component :is="currentRouter"></component>
      </transition>
    </template>
    

    本质上,Dynamic 组件还是利用了 Vue 的动态组件,通过修改 currentRouter 这个变量,可以改变当前渲染的组件。我们来看一下这个 currentRouter 修改的逻辑:

    created() {
      this.setCurrent()
    },
    methods: {
      setCurrent() {
        const name = this.$route.params.name
        const component = this.routes[name]
        if (component) {
          this.currentRouter = component
        }
      }
    }
    

    在组件创建的钩子函数里,我们会调用 this.setCurrent() ,该方法首先通过路由参数拿到 name,然后从 this.routes[name] 拿到对应的组件,并赋给 this.currentRouter 。那么 this.routes 变的尤为重要了。我们实际上是把 routes 存储到了 Vuex 的 store 里, 然后通过 Vuex 的 mapGetters 获取的:

     computed: {
      ...mapGetters([
        'routes'
      ])
    },
    

    既然通过 Vuex 的方法可以获取 this.routes ,我们一定会有写的逻辑,而这个存的逻辑实际上就是我们提供给这些异步业务线提供了一个 api 接口实现的:

    window.XXApp = {
        // ...
        // 一些公共方法和组件
    	registerRouter(name, component) {
          Vue.component(name, component)
          store.commit('ADD_ROUTES', {
            name,
            component
          })
        }
    }
    

    我们提供了 registerRouter 接口,参数就是路由的名称和对应的组件实例,我们首先通过 Vue.component 全局注册这个组件,然后通过 Vuex 提供的 commit 接口提交了一个 ADD_ROUTES 的 mutation,来看一下这个 mutation 的定义:

     [types.ADD_ROUTES](state, data) {
       state.routes = Object.assign({}, state.routes, {
         [data.name]: data.component
       })
     },
    

    至此,我们就完成了 routes 的存取逻辑,整个动态路由方案也就完成了, 异步业务线想使用动态路由,只需要调用我们提供的 registerRouter 接口,是不是很方便呢~

  7. 如何在测试环境下与后端接口交互?

    我们在开发阶段,通常都是在本地调试,本地起的服务域名通常是 localhost:端口号。这样会产生一些接口的跨域问题,除了常规的一些跨域方案,我们实际上可以借助 node.js 服务帮我们代理这些接口。

    我们借助 vue-cli 脚手架帮我们生成一些初始化代码。在 config/index.js 文件中,我们修改 dev 下 proxyTable 的配置,代码如下:

     proxyTable: {
      '/xxxservice': {
        target: 'http://xxx.com.cn', //你的目标域名
        changeOrigin: true
      },
      //...
    }
    

    实际上,它就是利用了 node.js 帮我们做了一层服务的转发,这样就可以解决开发阶段的跨域问题了。

  8. 如何部署到线下测试环境?

    我们在本地开发完代码后,需要把代码提测。通常测试拿到代码后,需要部署和测试,为此我们写了一个 deploy 的脚本。原理其实很简单,就是利用一个 scp2 的 node 包上传代码,它的执行时机是在 webpack 编译完成后,代码如下:

    var client = require('scp2')
    //...
    webpack(webpackConfig, function (err, stats) {
        // ...
    	client.scp('deploy/home.html', {
    	    host,
    	    username,
    	    password,
    	    path
    	  }, function (err) {
    	    if (err) {
    	      console.log(err)
    	    } else {
    	      console.log('Finished, the page url is xxx')
    	    }
    	  })
     })
    

总结

技术的重构总伴随着产品的升级,从这次大重构中,我们对 Vue 有了更深入的理解和掌握。对于它的周边插件如 Vuex 和 Vue-router,我们团队的小伙伴也有了较深入的研究,产出几篇文章也在这里和大家分享:
Vuex 2.0 源码分析
vue-router源码分析-整体流程
vue-router 源码分析-history

以上,欢迎拍砖~

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions