1. 程式人生 > >升級 AngularJS 1.5:新特性對比與最佳實踐(angular.component(),transclusion)

升級 AngularJS 1.5:新特性對比與最佳實踐(angular.component(),transclusion)

圖謀不軌:幫助( 勾引 )開發者過渡到 Angular 2.0

私以為,本次更新最重要的兩個部分就是 angular.component() 方法和支援了 Multi-slot 的 transclusion:

  • 元件與 angular.component() 方法:

首先最大的一個變化就是引入了元件的概念,並新增了 angular.component() 方法。經過這麼長時間的摸爬滾打,Angular 社群乃至整個前端領域也慢慢地總結出了符合實際專案開發的最佳實踐,其中最大的一個共識就是元件化,在 Angular 1.x 中我們就已經可以通過元素指令的方式定義可複用的元件,而其實 Components 就是一種特別的元素指令,通過自定義的 HTML 元素將之啟用。

本次更新的 .component() 方法其實就是一種更加方便定義元素指令的方式,並自帶預設配置使之符合最佳實踐。而元件開發的方式也就使應用更加符合 Angular 2.0的架構風格,所以說,Angular 1.5 就是為了方便開發者能夠更加順利地過渡到 Angular 2.0。為了 Angular 2.0 的未來 Google 也是迫不得已啊,要知道很多前端開發者在經歷過 Angular 1.x 的「折磨」過後都轉投了其他框架的懷抱。可以說前有 React.js 攜著元件化,虛擬 DOM ,單向資料流等利器,給前端 UI 構建掀起了一波聲勢浩大的函式式新潮流;後有 Vue.js 等更輕便的 MVVM 框架窮追猛趕,據說用過 Vue 的開發者都一致叫好。

  • 終於支援了 Multi-slot transclusion :

在之前,使用 Directive 定義元件的時候老是感覺有點兒憋屈,我們的元件只有一個 transclusion 也就是說只能在一個地方被填充,這就完全限制乃至喪失了元件的模板自定義功能。在構建複雜元件元素的時候,也就需要更多類似於 ng-if 和 ng-switch等方式輔助,從而導致元件模板的樣板檔案越來越多。而 Multi-slot transclusion 則可以把自定義的主動權交出去,我並不需要知道這個地方會變成什麼樣,我只需要告訴你這個地方能夠被填充就好了。當然,新版本也支援了預設的 transclusion 內容,這也就讓配置元件模板的成本進一步降低了。

舉個栗子: angular.component()

需要說明的就是,沒有什麼事是 Component 能做而 Directive 不能夠做的,下面就來看個對比的例子:

<my-component first-name="'Alan'" last-name="'Rickman'"></my-component>

myModule.component('myComponent', {
  template: '<h1>Hello </h1>',
  bindings: { 
    firstName: '<', 
    lastName: '<' 
  },
  controller: function() {
    this.getFullName = function() {
      return this.firstName + ' ' + this.lastName;
    };
  }
});


myModule.directive('myComponent', function() {
    return {
        restrict: 'E',
        template: '<h1>Hello </h1>',
        scope: {},
        bindToController: { 
            firstName: '<', 
            lastName: '<' 
        },
        controller: function() {
            this.getFullName = function() {
                return this.firstName + ' ' + this.lastName;
            };
        },
        controllerAs: 'ctrl'
    }
)};

通過 .component() 方法定義的元件預設就是一個元素元件,並且擁有自己的獨立 Scope。與此同時使用 bindings 替代了 bindToController ,並且可以通過 ‘<’ 符號單向繫結一個變數,即只有父 Scope 的變化會影響子 Scope 的值,這樣也就避免了一些 Scope 黑魔法所造成的誤傷。還有比較重要的就是,元件擁有預設為空的 Controller 方法,並且不需要 controllerAs 語法就可以在模板中直接使用預設的 $ctrl 別名。更多詳細對比請看 官方文件 。

新的 $onInit() 生命週期

新加入 AngularJS 豪華午餐的 $onInit() 方法,其實就 相當於 React 元件的 componentDidMount() 方法,在元件內 Controller 初始化的時候統一載入資料。其實這種方式在之前的版本當中,已經被約定俗成作為一種最佳實踐了,可以參考 johnpapa/angular-styleguide 中所提到的 activate() 方法。只不過 AngularJS 1.5 進一步提供了官方的支援, $onInit() 方法會在元件及其所有 binding 初始化之後被 compiler 呼叫,從而我們就有了一個清晰的地方統一存放資料初始化的邏輯。

controller: function ($location, githubService) {
  'ngInject';

  var vm = this;
  
  vm.$onInit = function () {
    githubService.getConfig().then(res =>
      vm.config = res.data
    );
    githubService.getIndex().then(res =>
      vm.posts = res.data.paginator
    );
  };
}

當然,這也更加便於使用者向 Angular 2.0 遷移,如果你對 Angular 2.0 的 生命週期有所瞭解的話,這裡的 $onInit() 其實就等同於 ngOnInit 函式。

ControllerAs 語法是什麼鬼?

AngularJS 在早些版本引入了 controllerAs 語法,相當於給 ViewModel 定義了一個名稱空間,從而避免了不同層級 Scope 關係的混淆不清。並且, controllerAs 語法也更加從語法層面上體現了 Controller 初始化 ViewModel 資料的單一職責,若把as 看做面向物件程式設計當中的 new ,其實就相當於將 Controller 這個 Function() 進行例項化,從而我們就擁有了 ViewModel 這麼一個可以在模板當中直接使用的物件。而其實現原理,則是直接把這個物件再次掛在當前 Controller 所對應的 $scope 之上,可以試著在 link 方法裡邊兒判斷一下 $ctrl === $scope.vm ,其結果為 true 。

<div ng-controller="MainCtrl as main">
    <my-component 
        first-name="'Alan'" 
        last-name="'Rickman'" 
        name="main.name">
    </my-component>
</div>

myModule.controller('MainCtrl', function () {
  this.name = 'JimmyLv';
});

但與此同時,在指令使用 controllerAs 語法也會遇到問題:

myModule.directive('myComponent', {
  restrict: 'E',
  template: '<h1>Hello  to </h1>',
  scope: {
    firstName: '@', 
    lastName: '<',
    name: '='
  },
  controller: function($scope) {
    this.getFullName = function() {
      return this.firstName + ' ' + this.lastName;
    };
      this.name = 'Pascal';
      $scope.$watch('name', function (newValue) {
        this.name = newValue;
      }.bind(this));
  },
  controllerAs: 'ctrl'
});

當某一個變數需要雙向繫結的時候,我們不得已重新使用只有掛載在 $scope 底下的$watch() 方法來動態監測這個值在指令當中是否發生了改變,好不容易消失的 $scope 又出來丟人現眼了。也就是因為這個原因,Angular 又加入新的 bindToController語法,從字面上就很容易看懂它的意思,即將這個變數直接繫結到指令自帶的 Controller,從而也就不用 $watch() 方法了,至此,徹底擺脫了 $scope。

再論 Scope 黑魔法

在 AngularJS 當中,Scope 可以說是最難理解也是最強大的一部分,在沒有理解其背後原理的時候感覺處處都是坑,理解過後又覺得 Angular 恰恰因此而獲得了 JavaScript 原型繼承的強大力量。首先,子 Scope 總是會自動繼承自父 Scope,一切的源頭就是所謂的 $rootScope ,試著把它在 console 裡面打印出來,可以看到其 $id 為 1,之後的所有子 Scope $id 依次增加。

回到正題,我們來看看指令當中的 Scope,對於指令來說會有三種情況:

  • scope: false —— 定義指令時 .directive() 的預設設定,此時不會建立子 Scope,將會直接共享父 Scope,所以說沒有理解的時候就可能出現莫名其妙改變了上一層 Scope 當中某個值的情況。
  • scope: { ... } —— 建立新的「獨立」 Scope,但是不會繼承父 Scope,當需要傳入某些值的時候,只需要在花括號當中寫入該變數名稱以及繫結方式即可,1.5 新版本增加了單向繫結,所以共有四種方式:’@’、’<’、’=’、’&’,而這在 .component() 建立元件的時候是預設的,Scope 永遠都是隔離的,通過繫結變數和方法的方式定義元件的輸入輸出,這一點之後會提到。
  • scope: true —— 建立新的「獨立」 Scope 並且繼承自父 Scope,所以說能夠在 Directive 當中訪問到所有父 Scope 當中的值。其原理需要大家去理解一下 JavaScript 的原型鏈,就能夠明白 Angular 是如何一層層往上查詢並獲得該變數的值了。

(理想的)元件樹

理想情況下,整個 Web 應用就是一顆元件樹,並且每個元件都有著非常的輸入輸出,資料流從根部擴散至每個部分,很少會出現雙向繫結的情況。通過這種方式,就可以很容易預測資料的改變會如何影響到 UI 元件的狀態改變。

  • 元件只能控制自身的輸入輸出:元件絕不應該修改不屬於自身 Scope 的任何資料和 DOM。通常來說,Angular 通過 Scope 繼承的方式提供了隨時隨處可修改資料的能力。但其實,當修改資料職責不清晰的時候就會導致問題,這也就是為什麼元件指令要預設使用獨立 Scope,從而避免了跨 Scope 操作的可能,

  • 元件應該擁有清晰的公共API - Inputs 和 Outputs:隔離 Scope 的方式也難以避免 Angular 的雙向繫結,如果你通過 bindings: {item: '='} 這種方式將一個物件傳入元件,你依然可以改變父元件當中的屬性。所以說,元件應該只能修改屬於它自己的資料,這樣的話就很容易控制什麼時候要進行修改,以及為什麼要修改。所以說純粹的元件 Inputs 應該只使用 ‘<’ 和 ‘@’ 單向資料繫結,而 Outputs 應當通過 ‘&’ 進行函式方法的繫結,作為元件內事件的 callbacks。

比如說一個刪除操作,元件不再直接操作輸入的資料,而是去呼叫正確的 Outputs 事件來改變資料,這就意味著元件不會刪除資料本身,而是通過事件的形式將其返回到擁有該資料的元件當中。

<button ng-click="$ctrl.onDelete({hero: $ctrl.hero})">Delete</button>

通過這種方式,父元件就可以根據這個事件再來決定最終的操作,比如說徹底刪除該項資料,或者只是更新自己的屬性而已。

ctrl.deleteHero(hero) {
  $http.delete(...).then(function() {
    var idx = ctrl.list.indexOf(hero);
    if (idx >= 0) {
      ctrl.list.splice(idx, 1);
    }
  });
}

ngRoute 路由的 $resolve

我們都知道,Controller 應該保持初始化 ViewModel 的單一職責,不應該把資料獲取的邏輯放入 Controller,所以說在進入 Controller 之前資料就應該已經準備好了。最通常的辦法就是將資料在 Route 的時候就 Resolve 出來,而 ngRoute 也提供了非常方便的方式來獲取 $resolve 當中的資料並將其傳入元件。

var myMod = angular.module('myMod', ['ngRoute']);
myMod.component('home', {
  template: '<h1>Home</h1><p>Hello,  !</p>',
  bindings: {
    user: '<'
  }
});
myMod.config(function($routeProvider) {
  $routeProvider.when('/', {
    template: '<home user="$resolve.user"></home>',
    resolve: {
      user: function($http) { return $http.get('...'); }
    }
  });
});

多個 slot 的 Transclusion

通過 transclude: {...} 的方式就可以直接定義支援多個 slot 的 transclusion,而以往我們只能設定 transclude: true 而且只能定義一個 transclusion。

myMod
.controller('ExampleController', ['$scope', function($scope) {
    $scope.title = 'Lorem Ipsum';
    $scope.link = 'https://google.com';
    $scope.text = 'Neque porro quisquam est qui dolorem ipsum quia dolor...';
  }]
)
.directive('pane', function(){
    return {
      restrict: 'E',
      transclude: {
        'title': '?paneTitle',
        'body': 'paneBody',
        'footer': '?paneFooter'
      },
      template: '<div ng-transclude="title">Fallback Title</div>' + 
                '<p ng-transclude="body"></p>' + 
                '<div ng-transclude="footer">Fallback Footer</div>'
    };
});

<div ng-controller="ExampleController as ctrl">
    <input ng-model="ctrl.title">
    <textarea ng-model="ctrl.text"></textarea>
    <pane>
        <pane-title>
            <a ng-href=""></a>
        </pane-title>
        <pane-body></pane-body>
    </pane>
</div>

首先可以看到 ng-transclude="footer" 這一部分,我們可以通過 'footer': '?paneFooter' 當中的問號形式表明這個 transclusion 其實是可選的,在元件模板當中已經有了預設值。而其他兩部分,通過自定義 HTML 的方式可以任意得傳入我們想要填充的模板,這帶來的一個明顯好處就是,不需要再把一些不必要的值都統統傳進去了,比如說這裡的``,我們在上一層進行計算過後就直接替換了 ng-transclusion 的位置。

而我們再來看看不使用 Multi-slot transclusion 定義 Component 的方式,不但需要傳入所有的值,而且更重要的是喪失了定義 HTML 模板的機會,而這種能力在定義不同形式的複雜元件時是非常重要的。

myMod.component('pane', function(){
    return {
      template: '<div></div>' + 
                '<p></p>' + 
                '<div></div>',
      bindings: {
        'title': '<paneTitle',
        'body': '<paneBody',
        'footer': '<?paneFooter'
      }
    };
})

<div ng-controller="ExampleController as ctrl">
  <input ng-model="ctrl.title">
  <textarea ng-model="ctrl.text"></textarea>
  <pane pane-title="ctrl.title"
        pane-body="ctrl.text">
  </pane>
</div>