如何把Angular1.x项目迁移到es6?

In parahome on 2017-11-08 by para

前言

或许有着这些那些的掣肘,但改变还是必须的,否则我们无法知道团队到底需要什么样的框架。

大概今年九月中旬的时候,我们团队开始决心对系统框架基于Angular1.x进行迁移,一方面,前端针对es6的改造已是势在必行的事,项目迁移到React也早已被提上日程,但是直接从Angular1.x过渡到React显得不太实际,毕竟React的体系不是人人都熟悉,重构项目的同时需求也跟着同时开发,es6的语法也需要慢慢过渡着来。幸运的是,我对es6的体系比较熟悉,第一个开始试点的项目也从程程白条开始进行。在此之前,春哥已经将基础框架和组件库更新到es6版本(基础开发库请参考venus-cli),我所做的主要是业务代码方面的迁移.这中间包含了我自己对设计理念的一些实现,这里尝试做一些阐述,请有针对性的吸收。

在项目重构前我们需要考虑清楚,这次重构我们需要将框架和业务分离到哪种层次,讨论的结果是, 1. 变动越小越好,旧项目最好能够不修改并且能保证运行,保证向下兼容 2. 接口和业务逻辑的变动应当慎之又慎, 3. es6的语法糖有选择性的引入,不考虑es7的decorator等模式。

决定这些因素后我们需要考虑以下几个问题: 1. 徐飞kuitos在各自的文中都不约而同得将消除Angualr显式的$scope作为第一要务,但这种决定是否符合我们团队的选择,有待商榷。 2. es6的class目前来看比较符合我们变动不大的需求,因为给controller,service,directive和factory的外层简单包裹一层class的语法糖,不仅使定义的过程更加自然,而且符合我们按需引入的需求。 3. 是否使用controllerAs的语法糖,上面提到的两位大大也无一例外的提到了controllerAs+class的改造方式,实现出来的模块加组件的方式清晰又明确,而且保证了viewModel(视图模型的)的纯洁性(虽然页面上我写起来也感觉再多写一个前置变量有些繁琐),但是最终还是没有采用,原因我接下来会说。 4. 解决繁琐的Angular的依赖注入问题。Angular之前的依赖注入写法真的很容易让人崩溃,完全没有组件化引入的形式不说,格式还特别严格,稍有错误就会给你报未知错误,而且错误也很难定位,示例如下,这种挂香肠的代码也是写够了😫。。。

app.controller('Order.indexCtrl', [
  '$timeout',
  '$scope',
  '$q',
  '$state',
  'RepayOrderService',
  'hybridBridge',
  'signals',
  'envConfig',
  function (
    $timeout,
    $scope,
    $q,
    $state,
    RepayOrderService,
    hybrid,
    signals,
    envConfig
  ) {

  }])

模块机制的改造

之前我们并没有使用Angular的模块机制,而是使用了一套叫做enyo.depends的东西,现在既然es6提供了cmd模块加载机制,那么这套老旧的东西就可以扔掉了。但是,Angular1.x还是套了一层套子在我们身上,他的意思是,你的模块想在我的框架中运行,就必须按照我的意图去定义module,即angular.module。 举例说明: 现在我们全局定义一个app,所有的angular服务都定义在app上,定义了服务名,并注入了一些服务。

// MainCtrl 基础组件库
import MainCtrl from 'core';
// sentry 是第三方线上错误日志收集框架
import sentry from 'sentry';
window.app = angular.module('angular_jfwebapp', ['venus', 'sentry'])
  .controller('mainCtrl', MainCtrl)
  .run(() => sentry.init());

现在这个全局的app就能被我在任意模块或者组件中引用和注册服务了。 这样的话,旧项目的controller甚至不用修改就能直接使用,因为它也是注册在app上的服务,只用稍加修改模块的引用机制即可。

改造的选择

之前提到过,关于controller的改造我们有两种可行的方案,一种是只将controller套上class的语法糖,所有的依赖注入机制还是走$scope的那一套,即原来的$scope变成this.$scope,还有一种就是Angular1.2版本后所提倡的controllerAs的写法,这种写法提供了一套将controller中class中的变量隐射到$scope上的语法糖,意思是在class中定义在this上的变量可以被映射到页面上,而不再需要$scope这个中间件,代码很清爽也很好看: 比如:

controller/index.js

class IndexCtrl {
  static $inject = ['$scope'];
  constructor($scope) {
    this.$scope = $scope;
    this.init();
  }
  init() {
    this.name = 'angular&es6';
  }
  getName(){
    console.log('app's name is ' + this.name);
  }
}

app.controller('IndexCtrl', IndexCtrl);

index.html

<div class="page" ng-controller="indexCtrl as app">
  <div ng-bind="app.name"></div>
  <button ng-click="app.getName()">get app name</button>
</div>

看起来很美好是不是?但是,现实往往是残酷的…

首先,改动会很大,基本上所有的controller, service, directive, factory都需要改造,这肯定是不可避免的,但这对于我这种爱折腾的肯定不成问题。接下来的问题就是不得不面对的了。 1. 我们目前的框架脱离不了$scope,大量和业务挂钩的方法都挂载在scope上,比如hybrid, eventTrack,和最重要的组件都挂载在scope上,目前框架内所有的dialog都是基于ngDialog开发,如果脱离了ngDialog,我们没有精力去重新造一个弹窗的轮子来。但是,这个问题可以克服,解决办法就是我们在依赖中将$scope注入,并绑定为this的属性,虽然写法上成了难看的this.$scope.ngDialog,但是毕竟能用了不是。 2. 关于指令link中属性计算的问题 directive: 指令的使用通常会出现依赖属性计算或者监听的场景,

  $scope.$watch('running',function(newValue){
    if(newValue){
      $scope.state = false;
    }
  });

改造成es6形式如下:

  this.$scope.$watch('running',(newValue)=>{
    if(newValue){
      this.state = false;
    }
  })

这种的虽然可以用了,但是还是存在一种基于accessor的写法:

  class Driective {
    get running(){
      this.state = this.running ? true : false;
    }
  }

这样当running发生变化时,state的值也会发生相应的改变。但是徐飞指出了一种不知名的原因导致绑定失效,导致不得不使用$watch的情况,kuitos解决的办法是采用angualr.component中新的语法,生命周期中的$onChanges去监听属性值得变化,但是这又会导致带来副作用,$onChanges回调有个限制是,它的变量检测是引用检查而不是值检查,也就是说,监听引用类型的变量根本不起作用,目前能够解决这种问题的方法可能就是需要引入不可变变量的概念,即immutablejs。事情不但没有解决,反而引来了更多的问题,何况,我们还没有引入component的计划。

  1. $on, $watch, $emit带来的问题 之前在ng中模块或者组件之间的通信直接依赖$watch,$on和$broadcast,现在没有了这些,我们还怎么做通信?从React和vue走过来的我们明白,不能过度依赖跨组件间的通信机制,通常,非必要的场景,我们可以使用inline-component解决这个问题。 const user ={ template: '<button type="button" ng-click="$ctrl.change('kuitos')">click me</button>', controller: class { click(userName) { this.onClick({userName}); } }, bindings: { onClick: '&' } } app.commponent('user', user); Usage: <user on-click="logUserName(username)"></user>

如果我们需要在不存在父子关系的节点上做通信时,我们可以使用自定义的事件中介者来完成,如kiutos大大提供的eventBus,我们内部使用的是自定义的signal广播机制。

以上种种原因都可以克服,但是我们意识到,所有这一切的改动都是牵一发而动全身的,想要优雅的抽出$scope,基本是只留一个月的重构时间的我们无法完成的。 春哥也适时的打击了我一句,他的mergePropsToScope(后面会提到)可以有目的性的将需要的方法输出到view上,而controllerAs将class绑定到this上的变量,view层都可以访问到,这本身就不合理,我也是哑口无言。 这也导致了我们此次的重构全面放弃了controllerAs和component。

我们做了哪些改变

首先我要介绍的是一个项目中所有controller都会继承的类BaseCtrl。

import merge from 'utils';
export default class BaseCtrl {
  merge = merge;
  static $inject = ['$scope'];
  constructor(...injectArgs) {
    const injects = this.constructor.$inject;
    injectArgs.forEach((inject, index) => {
      this[injects[index]] = inject;
    });
  }

  mergePropsToScope = (...args) => {
    args.forEach((prop) => {
      const propVal = this[prop];
      if (isUndefined(propVal)) {
        console.warn(`this.mergePropsToScope() prop['${prop}'] is undefined!`);;
      }
      this.merge(true, this.$scope, {
        [`${prop}`]: propVal
      });
    });
  }
}

使用方法如下: Usage

import BaseCtrl from 'core';
import { arrayUnion } from 'utils';

class IndexCtrl extends BaseCtrl {
  static $inject = arrayUnion(BaseCtrl.$inject ,['$scope', '$stateParams', 'indexService']);
  constructor(...props){
    super(...props);
    this.mergePropsToScope('navClick');
    this.$scope.pageInfo = 'xxx';
  }
}

这个方法主要是为了解决几个比较关键的问题: 1. 模块内使用Angular内置服务。 2. 存在继承类的依赖注入问题 3. 将内置服务注册到this上。

上文提到过,我们不准备使用es7的decorator模式,那么,kiutos团队提供的这种 @Inject('$scope')模式我们没有采用,这种比较新颖也比较好用,推荐一下。 我们使用的办法是子类merge父类的$inject数组,这样避免了一个问题:**子类继承父类时,不需要将父类的依赖(子类不需要的)再写一遍。 针对第三个问题,我们写了一个mergePropsToScope,顾名思义,就是将props merge到ng的scope上,这样我们的contrller中可以随意使用this,只要最终调用这个方法,就可以将this上的值同步到view上,算是ng scope兼容es6的一种hack。这里有两点需要注意下: 1. 属性不需要使用该方法merge,只需要赋值到this.$scope上即可。 2. 所有在初始化时不能merge到scope中的方法需要自己再手动merge,

  const promiseMethod = (data = {}) =>{
    const deferred = this.$q.defer();
    deferred.resove(data);
    return deferred.promise;
  }
  promiseMethod(data).then((data) => {
    this.$scope.syncPageinfoModule = () => {
      tis.$scope.pageinfo = 'xxx';
    }
    // 注意这里需要同步syncPageinfoModule到scope上
    this.mergePropsToScope('syncPageinfoModule');
  })

这意味着,如果存在异步操作中定义了多个绑定到scope上的方法,需要手动将它们一个个绑定到scope上,所幸我们这种场景不是很多,否则也会是个大麻烦。

其次,所有的业务contrller继承自这个BaseCtrl,这个BaseCtrl做了什么工作呢?其实它不仅帮我们简化了$inject的方式,还帮我们merge了所有的注入依赖到this上,省却了我们在constructor中写繁琐的this.xxx = xxx的工作。

directive的改造

这个算是改造比较彻底的地方,我们的组件大多依赖directive写成(没有使用angular component方案),而一个directive可能包含link,compile等成员函数,可配置的tepl和controller,这里面我们主要思考了如下几个问题: 1. 如何改造成class模式的代码 2. 成员函数如何处理 3. $scope如何尽可能消除

angular1.x在最新版本已经尽可能的弱化了component和directive的区别。

// DatePickerCtrl.js
export default class DatePickerCtrl {

  $onInit() {
      this.date = `${this.year}-${this.month}`;
  }
  getMonth() {
      ...
  }
  getYear() {
      ...
  }
}

在模块的入口文件index.js

// index.js
import template from './date-picker.tpl.html';
import controller from './DatePickerCtrl';

const ddo = {
  restrict: 'E',
  template,
  controller,
  controllerAs: '$ctrl',
  bindToContrller: {
      year: '=',
      month: '='
  }
};

export default angular.module('components.datePicker', [])
  .directive('datePicker', ddo)
  .name;

在这里,我们将组件的生命周期钩子的概念引入进来,借以摆脱link,complie中dom的操作,原因在于一个数据驱动的组件体系下,我们应该尽量减少DOM操作,因此理想状态下,组件是不需要link或compile方法的,而且controller在语义上更贴合mvvm架构。.

Provider、Service、Factory、Constant、Value

Angualr1.x中,这几种服务大概可以定义成下面三种类型, 1. 工具类/工具方法 2. 一些应用级别的常量和存储单元 3. 依赖内置服务的一系列服务组件

首先,provider跟service、factory的区别在于,启动ng时可配置内置服务,脱离这些服务使用ES6 Module的方式,本身没有什么区别。

provider.js

let apiPrefix = '';

function setPrefix(prefix) {
    apiPrefix = prefix;
}
function genResource(url) {
    return resource(apiPrefix + url);
}
export {
  setPrefix,
  genResource
}

usage

import {setPrefix} from './provider.js';
setPrefix('rt_');

factory和service,其实这两者可以替换,service传入的是构造函数,通过new创建出实例,而factory传入的是工厂函数,通过对这个工厂函数的调用而创建实例。

service.js

class ServiceA{}

app.service('serviceA', ServiceA);

factory.js

class FactoryA{
  
}

app.factory('factoryA', ()=> new FactoryA());

注意这边factory传入的是factoryA的实例,service则不同,它直接将构造函数传入,ng判断到它是构造函数的模式,会将实例返回回来。这和controller的定义方式相同。

Constant、Value这两个其实就是专门为应用级别的常量和存储单元而设计的,撇开ng,就和我们平时定义变量没有区别。 constant.js

export const VERSION = '1.0.1'; 

总结

这次项目重构下来,我最大的感受就是重构的阻碍主要还是来自于对原先项目的依赖程度,原先项目虽然不可以说严谨,但起码可以说稳定,因此我们在原来的基础上尽量对其改造,期望它脱离angualr浓重的框架思维,但就目前而言,无意是失败的。当然我们的目的也不是脱离angular,旧的项目依然会依靠在angualr上进行开发,这次改造主要是让我们意识到ES6的便利性, 使得我们可以地组织下层的业务代码。而如果需要在组件和模块上脱离angular的思想,这就需要我们使用其他框架去改造我们的项目,比方说react,这正是我们下一阶段的重构目标。

 
comments powered by Disqus