Tối ưu lại application Angular trong quá trình phát triển

Đây là bài dịch, bài gốc mọi người có thể đọc ở đây: http://blog.mgechev.com/2017/01/17/angular-in-production/


Trong bài viết này, tôi sẽ điểm qua một lượt các case mà tôi đã từng gặp trong quá trình sử dụng Angular (2 trở lên) với môi trường production.

Hồi tháng Tư, chúng tôi - một team nhỏ thôi - bắt đầu với một dự án về học tập: một phiên bản nâng cấp từ Angular 1 cho sản phẩm mà chúng tôi đã phát triển từ trước đó 3 năm.

Sản phẩm này có định hướng nhắm tới trẻ em và phụ huynh. Mục đích của nó là thúc đẩy trẻ em trong việc học toán bằng cách đưa ra các mức phần thưởng. Chúng tôi đã có một phiên bản iOS với hàng trăm ngàn lượt user và giờ đây chúng tôi muốn cung cấp thêm một phiên bản nền web với trải nghiệm tương tự như thế. Từ ban đầu, chúng tôi chọn cách tiếp cận mobile-first, vì đối tượng chính là trẻ em chơi trên di động hay tablet.

Application bao gồm 2 module chính:

  • Luồng flow giới thiệu: bao gồm một vài màn hình, giới thiệu và giải thích ý nghĩa của app, đăng kí và đăng nhập ...
  • Luồng flow chính của app: bao gồm các chức năng chơi giải đố, chọn phần thưởng, phần dành cho phụ huynh ...

Vì một vài lý do về business, việc phát triển sản phẩm đã bị ngưng lại.

Lựa chọn công nghệ

Một khi cả team đã nắm được các mục tiêu chính cần làm, chúng tôi bắt đầu nghĩ tới việc lựa chọn công nghệ. Chúng tôi có tự đặt ra 1 vài tiêu chuẩn cho công nghệ được lựa chọn:

  • Phù hợp với các yêu cầu nghiệp vụ hiện tại.
  • Có thể sử dụng lại những kiến thức hay kinh nghiệm đã có.
  • Fun to work with 😃

Ở backend, chúng tôi không sử dụng Rails nữa. Đã có lúc chúng tôi cân nhắc lựa chọn GraphQL đóng vai trò làm phương thức giao tiếp giữa backend với frontend, nhưng cuối cùng lại quay trở lại với REST - một sự lựa chọn quen thuộc hơn. Nếu bây giờ được chọn lại lần nữa - tôi thực sự sẽ chọn GraphQL đấy !

Lựa chọn ngôn ngữ có static type

Một trong những vấn đề rắc rối nhất chúng tôi gặp phải với phiên bản đầu tiên đó là việc sử dụng ngôn ngữ kiểu dynamic type. Ngay cả sau khi application đã được deploy lên, các lỗi typo vẫn liên tục bị phát hiện khiến cho chúng tôi cảm thấy công nghệ này đầy rủi ro và rắc rối. Nó dẫn chúng tôi đến việc cân nhắc lựa chọn ngôn ngữ kiểu static type cho lần phân triển phiên bản tiếp theo.

Cuối cùng, các sự lựa chọn bị giảm xuống còn lại Flow và Typescript. Mặc dù Elm khá hot ở thời điểm đó, nhưng nó lại làm giới hạn khả năng mở rộng của team - thật sự là rất khó để tìm developer nào có kinh nghiệm với cái này đấy !

Framework ...

Tôi đã có những kỉ niệm đẹp với React trong một vài project trước đây, đi kèm với việc sử dụng flux và redux. Mặt khác, vào lúc đó tôi đang có một vài đóng góp cho chính dự án Angular, cũng như một vài project khác cũng liên quan tới Angular. Tôi - khi đó - đang làm việc với angular-seed , codelyzercũng đang viết một quyển sách về Angular. Team chúng tôi cũng có một vài thành viên đã có kinh nghiệm với Angular.

Lúc đó, việc lựa chọn Angular nghe có vẻ hay. Mặt khác, ở thời điểm đó, Angular vẫn còn đang trong giai đoạn beta và tôi biết chắc sẽ có nhiều thay đổi lớn đối với nó.

Tuy nhiên, cuối cùng, chúng tôi vẫn quyết định đánh cược vào Angular dựa trên những lý do sau:

  • Tôi đang là người đảm nhiệm duy trì một trong những starter Angular phổ biến nhất. Angular Seed đã ở trong giai đoạn hoàn thiện, nhờ vào nỗ lực của rất nhiều người. Do đó, thật tốt nếu như tôi có thể áp dụng nó trong dự án tiếp theo kia và qua đó tìm được những điểm có thể cải thiện thêm.
  • Thông qua kinh nghiệm đối với Angular 1, cơ chế dependency injection chính là thứ mà chúng tôi thích nhất ở Angular 1 và muốn được tiếp tục sử dụng nó.
  • Ngay cả trong giai đoạn beta này, Angular vẫn cung cấp tất cả những thứ chúng tôi cần - router, DI, change detection, view encapsulation ...
  • Đây là 1 framework còn mới mẻ, và do đó chắc sẽ có nhiều thứ để chúng tôi khám phá !

Ban đầu, chúng tôi không chắc lắm với việc kết hợp static type langulage Javascript; nhưng Angular đã lựa chọn sẵn và do đó chúng tôi đánh cược vào Typescript.

Nếu như không phải tại thời điểm đó, tôi đã khá quen thuộc với codebase của Angular rồi, rất có thể tôi đã muốn chuyển sang React. Trong quá trình xây dựng app của mình, chúng tôi đã gặp phải kha khá rắc rối đau đầu, thậm chí tôi đã phải fork lại và tự custom lại Angular router. Cuối cùng, đó cũng chỉ là những rủi ro mà ta phải nhận khi chấp nhận làm việc với một công nghệ vẫn chỉ đang trong giai đoạn beta. Cũng may, cho tới khi framework ra bản chính thức, không có thay đổi nào quá lớn mà chúng tôi phải giải quyết cả.

Kiến trúc

Flux và redux - cả hai hướng tiếp cận mà tôi đã sử dụng trong các dự án trước và tôi khá hài lòng về chúng. Cùng với đó, Angular kết hợp khá tốt với immutable data. Sau đó, chúng tôi lại tìm thấy @ngrx/store. Nó là một thư viện nhỏ cung cấp reactive state management, được xây dựng dựa trên ý tưởng của redux và integrate khá tốt với Angular.

Với @ngrx/store, ta có thể nghĩ về store của redux như một stream các immutable object. Mỗi lần ta emit một action - được xử lý bởi reducer - một state mới được emit qua stream - thứ có thể được filter, merge, map ... một cách dễ dàng. Cùng với đó, Angular cũng cung cấp một cách rất ngắn gọn để bind một observable , sử dụng async pipe:

@Component({
  selector: 'counter-cmp',
  template: '{{ counter | async }}'
})
class CounterComponent {
  counter = Observable.interval(1000);
}

Dựa theo một vài thứ nữa, chúng tôi đã đi tới được với "Kiến trúc xây dựng phần mềm dễ dàng mở rộng" mà trước đây tôi đã từng nhắc tới trong một vài hội nghị. Khi đó, chúng tôi đã có thể mở rộng hệ thống của mình lên với khoảng 40000 dòng code và không gặp phải bất cứ một vấn đề gì ví như conflig, mâu thuẫn giữa các dòng code của những người khác nhau, khả năng mở rộng hay việc chia nhỏ các vấn đề ...

Hạn chế duy nhất mà chúng tôi gặp phải đó là kiến trúc trên có một chút rườm rà (với state change cần phải định nghĩa một action, action creator và model method, thậm chí cả property cho store). Dù vậy, tôi không nghĩ rằng làm chi tiết tới mức độ đó là một điều xấu.

Runtime performance

Một vấn đề nghiêm trọng mà tôi đã gặp phải đó là trình trạng tràn bộ nhớ (memory leak). Observable rất tốt và nó hỗ trợ bạn viết code rất ngắn gọn, tuy nhiên ta có thể sẽ thường xuyên quên đi việc xóa chúng sau khi dùng xong. Sẽ mất nhiều thời gian cho đến khi bạn tự tập cho mình được thói quen luôn luôn nhớ xóa đi các subscription mà mình đã đăng kí.

Một trong những kinh nghiệm đau thương và ức chế nhất mà tôi đã gặp đó là phải đi tìm leaking global event handler. Dấu hiệu của lỗi này rất dễ gặp - chỉ một vài cái click chuột và app của bạn sẽ chậm đi trông thấy. Lỗi này sẽ bắt Angular phải chạy lại toàn bộ cơ chế nhận biết thay đổi (change-detection mechanism) thông qua toàn bộ component tree với mỗi một cái click chuột do cái callback mà bạn để sót kia. Tôi dính phải cái lỗi này nguyên nhân là do sự kiện addEventListener được custom lại trong zone.js - nó tự động kiểm tra dirty check trên toàn bộ component tree trong app (đó là do tôi đã không áp dụng bất cứ một điều chỉnh nào để tối ưu lại code của mình).

Dù rằng ban đầu nó khá là phiền phức, nhưng cuối cùng thì chúng tôi cũng đã có thể loại bỏ hết các rắc rối này. Trên hết, Angular cùng rxjs đã làm rất tốt việc clean bớt những subscription nào không dùng tới nữa. Nhưng nó vẫn đáng để nhắc lại một lần nữa - luôn luôn dọn đi những thứ mình đã tạo ra.

Tối ưu lại binding

Mặc dù cơ chế nhận biết thay đổi của Angular đã được tối ưu rất tốt, nhưng trong những trường hợp phức tạp bạn sẽ vẫn nhận ra độ trễ của ứng dụng. Lấy ví dụ với một component sau:

@Component({
  selector: 'fancy-button',
  template: '<button (click)="n++">Totally {{n}} clicks</button>'
})
class FancyButtonComponent {
  n = 0;
}

Giờ hãy tưởng tượng component trên được đặt ở một vị trí rất sâu trong cây component của app. Mỗi lần ta click một button, Angular sẽ phải thực hiện việc nhận biết sự thay đổi trên toàn bộ các component đã được render. Điều này là do framework không thể chắc chắn được là component này sẽ không thay đổi một object nào đó ở chỗ khác hay không.

Một lần nữa, cơ chế dùng để nhận biết thay đổi của Angular đã được tối ưu rất tốt - tuy nhiên - nếu như ta binding sử dụng các getter thực hiện nhiều phép tính toán, khi đó ta sẽ nhận thấy app chậm đi đáng kể.

Giờ, hãy coi với ví dụ sau - sử dụng input:

@Component({
  selector: 'name-input',
  template: '<input [(ngModel)]="name">'
})
class NameInputComponent {
  name: string;
}

[(ngModel)] là syntax cho phép thực hiện binding 2 chiều. Nó rất tiện để sử dụng, tuy nhiều, với mỗi một kí tự ta gõ sẽ dẫn tới hàng loạt kiểm tra change detection. Nếu ở một chỗ nào đó ta có:

<fib-cmp [n]="1e100"></fib-n>

.......................................

@Component({
  selector: 'fib-cmp',
  template: '{{ fibonacci(n) }}'
})
class FibonacciComponent {
  @Input() n: number;

  // Unoptimized Fibonacci implementation
  fibonacci(n: number) {
    if (n === 1 || n === 2) {
      return 1;
    }
    return this.fibonacci(n - 1) + this.fibonacci(n - 2);
  }
}

Bạn sẽ thấy app chạy cực kì lag. Tất nhiên, ở trên ta không tính số Fibonacci thứ n, tuy nhiên ta đã vẫn thực hiện một vài tính toán rất nặng.

Note lại một bài học ở đây - luôn chú ý tối ưu các binding.

Immutability có thể là lựa chọn tốt

Đôi khi binding sẽ rất nặng - ta phải chấp nhận nó và có lẽ sẽ không làm gì để thay đổi được. Ví dụ, đó là khi chúng tôi làm chức năng show quá trình học tập cho mỗi đứa trẻ, tính toán dựa trên một công thức cho trước. Ở một màn hình chúng tôi có trang cho phép edit profile của đứa trẻ và hàng loạt component khác. Với một một lần nhấn phím trong một input sử dụng [(ngModel)] , Angular sẽ tính toán thay đổi trên toàn bộ cây component, chỉ riêng việt typing cũng sẽ rất chậm:

Nghĩ lại, liệu ta có cần phải đi qua toàn bộ công việc tính toán performance của đứa trẻ không, ngay cả khi instance chưa có gì thay đổi. Với Angular, nó sẽ thực hiện tính toán vì nó không thể biết chắc object được reference tới có thay đổi hay chưa.

Rất may , ta có cái gọi là immutable record. Immutable record là một thứ kiểu như immutable maps, nhưng nó cũng chứa cả những lợi thế của việc sử dụng static type của Typescript. Mặc dùng khá là hay ho, nhưng việc sử dụng nó lại hơi lằng nhằng. Ví dụ, việc viết lại class Kid sẽ trông như thế này:

const kidRecord = Immutable.Record({
  id: null,
  name: null,
  gender: 0,
  grade: 0,
});

export interface IKid {
  id?: string;
  name?: string;
  gender?: number;
  grade?: number;
}

export class Kid extends kidRecord implements IKid {
  id: string;
  name: string;
  gender: number;
  grade: number;
  constructor(config: IKid) {
    super(config);
  }
}

Bằng cách này chúng ta đã định nghĩa:

  • Một immutable record với giá trị mặc định.
  • Một class extend lại immutable record, đồng thời khai báo các property của đối tượng thật (kid).
  • Một interface được dùng để truyền các object literal như một argument vào Kid constructor và để sử dụng được static type.

Cuối cùng, tôi thực hiện optimize với :

@Component({
  selector: 'kid-statistics',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `...`
})
class KidStatisticsComponent {
  @Input() kid: Kid;
  ...
}

Bằng cách này Angular sẽ thực hiện change detection trong nhánh component từ KidStatisticsComponent trở xuống chỉ khi nó nhận được một reference khác tới một trong những input của nó.

Bài học - hãy nhớ việc tối ưu lại chiến lượng kiểm soát thay đổi (change detection strategy).

Khi mà Immutability cũng không giúp gì được

Ngay cả với cách làm trên, thật không may, đôi khi Angular vẫn thực hiện các phép tính không cần thiết:

Trong hình trên, một đứa bé đang làm một bài test. Khi câu trả lời là đúng, ta sẽ tăng điểm tổng dựa trên kết quả từ câu trả lời trước. Component được dùng để render phần view tính điểm trông giống như sau:

@Component({
  selector: 'point-counter',
  template: '{{ boundPoints }}'
})
class PointCounterComponent implements OnChange {
  @Input() earnedPoints;
  ...

  ngOnChanges() {
    ...
    const increase = () => {
      if (currentPoints >= finalPoints) {
        this.boundPoints = finalPoints;
      } else {
        this.boundPoints += stepSize;
      }
      this.timeout = setTimeout(increase, 10);
    };
  }
}

Trong ảnh trên, cứ mỗi khi con số thay đổi là Angular sẽ phải thực hiện lại việc change detection.

Còn nhớ ở phần trên tôi có nhắc tới zone.js không ? Zone.js không chỉ chỉnh sửa lại addEventListener mà nó cũng làm ảnh hưởng tới tất cả các async API khác của browser, bao gồm cả setTimeout. Điều đó có nghĩa Angular sẽ thực hiện change detection mỗi 10 mili giây, do ta đã modify lại property boundPoints mà ta đã gắn trong template.

Angular cung cấp cho ta một giải pháp. Ta có thể inject NgZone và thực hiện cái animation tăng point này bên ngoài Angular - cách này sẽ không kích hoạt cơ chế change detection:

@Component({
  selector: 'point-counter',
  template: '<span #counter></span>'
})
class PointCounterComponent implements OnChange {
  ...
  constructor(private zone: NgZone) {}

  ngOnChanges() {
    ...
    this.zone.runOutsideAngular(() => {
      this.animatePoints();
    });
  }
}

Chỉ có một lưu ý nhỏ : trong trường hợp này ta phải tự tay thực hiện thay đổi trên DOM. Để có thể giữ component tách biệt khỏi DOM, trong trường hợp này ta nên thực hiện manipulate các element gián tiếp thông qua sử dụng Renderer từ thư viện @angular/core.

Vấn đề đã được giải quyết ! Chạy code bên ngoài Angular giúp ta trong trường hợp đặc biệt này, nhưng nên nhớ chỉ sử dụng runOutsideAngular khi thực sự cần thiết do trong trường hợp này ta phải tự xử lý việc binding data mà không nhờ vào cơ chế có sẵn của framework.

Network performance

Những vấn đề chúng tôi gặp phải với runtime performance trên kia, xét cho cùng cũng không phải vấn đề lớn lắm. Mặt khác, chúng tôi ghi nhận được đã có một số lượng người dùng đã không đủ kiên nhẫn để chờ cho app thậm chí load xong.

Ban đầu, quá trình build production của chúng tôi đó là rsync thư mục dist/prod sinh ra bởi Angular Seed tới server remote. Cách này đơn giản, nhưng không được tối ưu một chút nào cả ! nghix sử dụng gzip để đưa ra content đã được nén lại cho người dùng cuối, nhưng ngay cả bản nén này cũng nặng tới 800k.

Sau đó chúng tôi migrate lại app với Angular RC.5 để có thể dùng được tính năng mới - AoT compilation. Thứ này thậm chí còn làm tăng kích thức gói code lên hơn 1M !!! Việc tải về bundle này không phải là vấn đề duy nhất - trên các thiết bị low-end, giải nén gói này ra cũng lại làm mất thêm thời gian nữa.

Trên thực tế, dù ta có thể bỏ qua compiler, AoT lại là thứ có thể làm tăng kích thước gói bundle của ta. Cái mà AoT đã làm đó là gen những đoạn code cho template hay DI, do đó nếu ta có nhiều component, đoạn code Javascript được sinh ra có thể vượt quá kích thước của compiler.

Chúng tôi phải áp dụng một vài kĩ thuật sau:

  • Bundling
  • Minification.
  • Tree-shaking.

Và sau đó tôi đã có nhắc tới trong bài viết “Building an Angular 2 Application for Production”

Kết quả đạt được vẫn chưa làm thỏa mãn , do đó chúng tôi lại tiếp tục với một vài techniques khác:

Lazy-loading

Sau khi nâng lên phiên bản Angular mới nhất, chúng tôi đã thử áp dụng lazy-loading. Technique này đại khái như sau:

  • Chia nhỏ function của toàn bộ app ra:
    • 1 phần bundle chính chứa những logic, function nào được dùng xuyên xuốt toàn app.
    • Các bundle nhỏ chứa các feature con từ các phần tính năng mà có-thể-sẽ-được-dùng.

Dù ý tưởng trên là rõ ràng, nhưng việc thực hiện nó lại không đơn giản như chúng tôi đã nghĩ. Một trong những lý do chính mà tôi đã không tích hợp sẵn giải pháp lazy loading trong Angular Seed đó là nó rất khó để implement một giải pháp chung có thể chạy với mọi app. Hẹp hơn, chỉ trong phạm vi team chúng tôi, riêng chúng tôi đã có thể thống nhất chung là đưa ra 1 quy trình build dựa trên một vài ràng buộc cụ thể với những điều kiện được đưa ra và thống nhất chung.

Cuối cùng, sau một ngày vật lộn với hàng tá cây và các thuật toán đồ thị, chúng tôi cùng đi tới một giải pháp mà sẽ chia app ra làm 3 bundle con. Các bundle này sau này sẽ được load bằng SystemJS - là module loader mặc định của Angular.

Prefetching bundles

Một trong các bundle chúng tôi build chứa phần màn hình giới thiệu app (bao gồm cả luồng đăng kí). Ngoài ra là bundle chứa function chính của app và cuối cùng là core bundle chứa Angular và các function dùng chung giữa 2 cái trên.

Trong kịch bản prefetch, chúng tôi muốn download trước nhóm các bundle cụ thể được yêu cầu bởi trang mà người dùng sử dụng, sau đó sẽ tiến hành prefetch các bundle còn lại.

May mắn, Angular cung cấp sẵn giải pháp cho phép prefetch các module lazy-loaded. Tất cả việc chúng tôi cần làm là :

let routes: Routes = [...];

export const appRoutes = RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules });

Service worker

Service workder cung cấp API cho phép chúng tôi sử dụng các biện pháp kiểm soát network nâng cao trên toàn bộ các request đi từ browser ra network.

Team Angular đang duy trì project mobile toolkit giúp cho phép quản lý tự động các service worker và việc generate application shell. Nó cũng sử dụng một vài tính năng đặc thù của framework để cho phép quy trình tự động hóa hơn nữa.

Nhờ vào toolkit trên, chúng tôi có thể generate ra manifest file như là một phần của quá trình build - file này sau đó được toolkit đọc và dựa vào đó để config một cái service worker dùng để quản lý các static asset.

Một vài tính năng mà chúng tôi dùng bao gồm:

  • Cache lại các static asset trong cache của service worker.
  • Chỉ update các asset nào có thay đổi.

Mobile toolkit giúp chúng tôi cải thiện được kha khá hiệu năng của app trên những trình duyệt nào hỗ trợ các tiêu chuẩn hiện đại mới.

Ảnh trên cho thấy tree map của bundle core dùng để bootstrap cho app khi chưa được optimize . Sau khi đã optimize và nén lại, nó có kích thước chỉ còn khoảng 200K.

Dù vậy, nhìn vào tree map ta thấy bundle vẫn còn quá lớn và khi load nó trên mạng 2G, nó mất khoảng 20s để tải lên thiết bị người dùng. Điều này là vì để có thể render bất cứ một màn hình nào ta cũng cần có ít nhất 2 bundle:

  • Core bundle để bootstrap app.
  • Bundle chính hoặc bundle giới thiệu , được load sau core bundle - quyết định view nào được render.

Việc này yêu cầu ta phải load ít nhất 400K code Javascript (bundle core + bundle chính / bundle giới thiệu) trước khi có thể ẩn được loading screen.

Interactive App Shell

Trong trang giới thiệu, ta có 2 trang chứa nội dung tĩnh giải thích cho phụ huynh hiểu app này làm gì. Thật sự không cần thiết khi bắt người dùng phải chờ tới khi toàn bộ asset của app được tải về chỉ để phụ huynh có thể đọc được một vài dòng và quyết định liệu họ có nên tiếp tục sử dụng app hay không.

Việc chúng tôi đã quyết định làm - đó là tách phần template của component dùng để hiển thị các nội dung này và chuyển nó tới một app khác riêng biệt, hoàn toàn không liên quan tới Angular. Chúng tôi thiết kế lại template, nhúng vào một vài đoạn code javascript - thứ có thể hiểu được cơ bản về routing directive, được gọi tới trong một routerLink cụ thể. Bằng cách này chúng tôi đã có thể tái sử dụng template này trong cả interactive App Shell lẫn app Angular của mình.

Khái nhiệm "interactive App Shell" nghe có vẻ giống với một application shell, với một điểm khác biệt đó là nó là một application chỉ bao gồm các nội dung tĩnh. Tất cả code javascript, style, template cho trang này chỉ nặng 20K, từ đó giúp giảm thời gian load ban đầu cho người dùng mới xuống dưới 5 giây đối với mạng 2G.

Điều chúng tôi làm đó là ban đầu download style của app, đồi thời với cái interactive App Shell này. Ta render trước app này cho người dùng xem, còn ở bên dưới ta đã bắt đầu prefetch các bundle cần thiết khác (core bundle và bundle giới thiệu ...). Một khi các bundle đó đã sẵn sàng và người dùng quyết định đi tiếp, lúc này ta mới gọi tới app Angular. Trong trường hợp user quyết định tiến tới đăng kí trước khi bundle được tải xong, ta chỉ việc show ra loading icon và yêu cầu người dùng chờ một vài giây.

Trong trường hợp user đã log in và không vào trực tiếp trang chủ, gần như là các file script đã được tải và cache từ trước vì user đã sử dụng app trước đó. Cuối cùng thì thời gian tải trang đã được rút ngắn đi !

Một điều hay nữa của interactive App Shell là nó không thường xuyên thay đổi. Điều này cho phép ta có thể cache cái bundle chứa nó không chỉ trong cache của service worker mà thậm chí ngay trong cache của trình duyệt.

Ahead-of-Time compilation (AoT)

Khái niệm này trong Angular liên quan nhiều hơn tới việc cải thiện hiệu năng runtime. Dù vậy, tôi cũng sẽ giải thích nó ở đây, khi mà với với việc sử dụng 1 app tĩnh, ta có thể thấy rõ được sự cải thiện về hiệu năng mà ta có thể nhận được. Nếu bạn chưa biết gì về khái niệm AoT trong Angular, bạn có thể đọc ở đây.

Như tôi đã nói ở trên, khi Angular RC 5 được release, chúng tôi đã nâng cấp lên và áp dụng AoT compilation vào build process của mình. Lợi ích của AoT compiliation bao gồm:

  • Không còn cần Angular compiler trong production bundle nữa => giảm kích thước gói build ra 1 chút.
  • code được kiểm tra và loại bỏ bớt import thừa (tree-shakable) khi mà template được compile ra Javascript với static import.
  • Không bị vướng vừa runtime overhead như khi dùng JiT (Just-inTime) compilation.

2 điểm đầu tiên khá là dễ hiểu, nhưng còn điểm thứ 3 lại là điểm có tác động lớn nhất tới trải nghiệm người dùng.

Khi ta dùng cách tiếp cận interactive App Shell, ta chỉ bootstrap app Angular sau khi interactive App Shell đã được render. Điều này có nghĩa là ta thực hiện JiT compilation sau khi ta đã có gì đó trên màn hình rồi.

Các hình dưới đây demo cho app Angular đã được delay rồi. Hình đầu hiển thị app với JiT compilation, còn hình sau là khi ta compile app như là 1 phần của build process (vd AoT compilation ...):

Với trường hợp sử dụng Jit. Cho rằng user đã chờ 10 giây để download toàn bộ app của ta về. Ngay sau đó Angular sẽ phải thực hiện JiT compilation - có nghĩa rằng user sẽ không nhìn thấy gì trên màn hình. Khi mà code dùng cho template và DI đã được generate xong, user vẫn cần phải chờ cho code được parse xong và chỉ sau đó, user mới có thể nhìn thấy giao diện được render. Trên hết, trong một vài trường hợp (Chrome extension, strict CSP) JiT compilation không thể thực hiện được do eval không được permit.

Thực sự là ta nên sử dụng AoT compilation trong app của mình !

Cải tiến thêm nữa !

Một vài cải thiện về performance cho network chúng tôi đã áp dụng bao gồm:

offline store

Chúng tôi lưu toàn bộ state của app trong một immutable object lớn. Điều này giúp cho việc áp dụng các tinh chỉnh tối ưu thêm liên quan đến việc giao tiếp với API server. Khi mà việc thay đổi data model giữa các lần refresh lại app là không thường xuyên xảy ra, chúng tôi có thể serialize toàn bộ store của app và lưu nó vào localStorage hay indexDB.

Bằng cách này, khi user quay trở lại app trước một khoảng thời gian nhất định, ta chỉ cần deserialize state đã lưu và show ra cho người dùng. Điều này giúp giảm số lượng request lên server.

SVG sprites

Từ khi mà H2 hay server push còn chưa được hỗ trợ rộng rãi, chúng tôi đã sử dụng SVG sprited với mục đích giảm số lượng request gửi tới server. Code sẽ tự động generate ra một sprite trong quá trình build, sprite sẽ chứa một số lượng defs. Sau đó, bằng cách dùng <use xlink:href="..."></use>, ta đã trỏ tới image cụ thể trong app Angular của mình.

Vì cách tiếp cận này không hoạt động tốt với các phiên bản cũ của IE, chúng tôi fetch sprite sử dụng XHR và sau đó lồng từng defs biểu diễn cho SVG image trong component. Cách làm này tương tự với cách tiếp cận trong svg4everybody.

Kết luận

Cuối cùng, tôi đã có thể tự tin nói rằng chúng tôi khá hài lòng với quá trình sử dụng framework này. Việc kết hợp Angular và Typescript cho phép việc phát triển dễ dàng mở rộng được mà không cần động nhiều vào codebase.

Một trọng những tinh chỉnh có tác động lớn nhất mà chúng tôi áp dụng đó là thay đổi chiến lược change detection OnPush, thứ mà kết hợp rất tốt với thư viện immutable.js.

Về mặt network performance, vấn đề về kích thước gói bundle sinh ra là phức tạp nhất. Mặc dù lối tiếp cận sử dụng static app khá là loằng ngoằng, nó lại giúp chúng tôi có thể load app của mình dưới background.

Cuối cùng nhưng không kém phần quan trọng : áp dụng Ahead-of-Time compilation như là một phần của build process giúp ta cải thiện rút ngắn khoảng thời gian render đầu vào.

Sử dụng Web Worker cũng có thể là một giải pháp hứa hẹn. Không may, ở vào thời điểm bài viết này được viết, tính năng này vẫn chưa được sẵn sàng cho môi trường production. Trên hết, tôi hoàn toàn không khuyến khích việc sử dụng Web Worker trong phát triển khi mà ta có thể dễ dàng không kiểm soát được cách mà application sẽ chạy ra sao trên main UI thread.

Resources

Ahead-of-Time compilation in Angular Building an Angular Application for Production Scalable Single-Page Application Architecture Angular Seed An Overview of SVG Sprite Creation Techniques Angular Mobile Toolkit Progressive Web Apps Change Detection in Angular 2 ngrx immutable.js Angular 2 RC5 - NgModules, Lazy Loading and AoT compilation Implementing the Missing “resolve” Feature of the Angular 2 Router