Xây dựng ứng dụng Angular cho Production
Chắc hẳn trong thời gian qua mọi người đã nghe và biết đến nhiều về công nghệ Progressive Web Applications (PWAs) giúp chúng ta xây dựng được navtive web apps nhờ một số công cụ như Service Workers, IndexDB, App Shell, ...Trình duyệt sẽ download tất cả static assets cần thiết cho ứng dụng của chúng ...
Chắc hẳn trong thời gian qua mọi người đã nghe và biết đến nhiều về công nghệ Progressive Web Applications (PWAs) giúp chúng ta xây dựng được navtive web apps nhờ một số công cụ như Service Workers, IndexDB, App Shell, ...Trình duyệt sẽ download tất cả static assets cần thiết cho ứng dụng của chúng ta, Service Worker sẽ giúp chúng ta cache chúng lại ở local. Do đó người dùng sẽ có thể cảm nhận việc load page lần đầu có vẻ bị chậm, nhưng những lần kế tiếp khi dùng app sẽ thấy: wow, toẹt vời.
Với mục đích nhằm giúp các developers có thể ứng dụng được công nghệ như PWA dễ dàng nhất, Angular team đã xây dựng lên Angular mobile toolkit. Tuy nhiên, vấn đề gặp phải về performance cho ứng dụng Angular lại chính là kích cỡ của chính nó. Ví dụ, một ví dụ "Hello World" app mà chưa tối ưu nó thì bundle với browserify là 1,4 MB! Nó sẽ vẫn không tiện lợi nếu giả sử người dùng download về thông qua mạng 3G.
Đó là nguyên nhân chính mà Angular bị chỉ trích. Qua một số điểm chính trong ng-conf, Brad Green (Angular team manager) đã đề cập tới việc họ sẽ giảm size của hello world app xuống còn < 50K!
Chúng ta sẽ cùng giải thích những bước cần làm để đạt được kết quả như trên: Sample Application Để có thể hiểu rõ hơn về việc chúng ta đã tối ưu ứng dụng như thế nào thì trước hết chúng ta cần 1 sample app để thực hiện.
Ứng dụng đơn giản của chúng ta gồm 3 files:
// app.component.ts import { Component } from '@angular/core'; @Component({ selector: 'my-app', template: 'Hello world!' }) export class AppComponent {}
// app.module.ts import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { AppComponent } from './app.component'; @NgModule({ imports: [BrowserModule], bootstrap: [AppComponent], declarations: [AppComponent], }) export class AppModule {}
và tiếp theo là:
// main.ts import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; import { AppModule } from './app.module'; platformBrowserDynamic().bootstrapModule(AppModule);
Ở app.component.ts chúng ta có 1 component với 1 template nó sẽ render dòng chữ "Hello world". main.ts dùng bootstrap cho ứng dụng của chúng ta thông qua method bootstrap export bởi package @angular/platform-browser-dynamic. File index.html của chúng ta sẽ như thế này:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title></title> </head> <body> <my-app></my-app> <script src="/node_modules/zone.js/dist/zone.js"></script> <script src="/node_modules/reflect-metadata/Reflect.js"></script> <script src="dist/bundle.js"></script> </body> </html>
Và đây là cấu trúc thư mục:
. ├── app │ ├── app.component.ts │ ├── app.module.ts │ └── main.ts ├── dist ├── index.html ├── package.json ├── tsconfig.json └── node_modules
Step 1 - Minification and Compression
Mọi người có thể lấy code mẫu ở trên repo angular2-simple-build và sau đó install package. Chúng ta cùng xem quá trình build qua file package.json:
"scripts": { "clean": "rm -rf dist", "serve": "http-server . -p 5556", "build": "npm run clean && tsc", "build_prod": "npm run build && browserify -s main dist/main.js > dist/bundle.js && npm run minify", "minify": "uglifyjs dist/bundle.js --screw-ie8 --compress --mangle --output dist/bundle.min.js" }
câu lệnh build trước tiên sẽ xóa bỏ những gì chúng ta đã bundle trước đó trong thư mục dist và sau đó sẽ compile lại thông qua TypeScript compiler. Nó sẽ sinh ra 3 files main.js, app.module.js và app.component.js trong thư mục dist.
Thông qua việc dùng SystemJS chúng ta đã chạy chúng trên trình duyệt, nhưng chúng ta muốn giảm bớt số HTTP requests tạo bởi trình duyệt trong quá trình load ứng dụng. Đó là những gì chúng ta làm với build_prod:
npm run build && browserify -s main dist/main.js > dist/bundle.js && npm run minify
Ở câu lệnh trên, đầu tiên chúng ta dùng TypeScript compiler. Sau khi compile xong, những gì chúng ta cần đó là tạo "standalone" bundle với file dist/main.js file, và nội dung được bundle sẽ nằm ở file bundle.js trong thư mục dist. Chúng ta có thể thử chạy app:
npm run build_prod npm run serve open http://localhost:5556
giờ chúng ta cùng xem dung lượng file bundle:
$ ls -lah bundle.js -rw-r--r-- 1 mgechev staff 1.4M Jun 26 12:01 bundle.js
Wow... như đã nói ở trên file có dung lượng như chúng ta đã nói ở trên. Tuy nhiên, bundle file chứa một tá những nội dung không cần thiết như:
- function hoặc biến không cần thiết
- chứa nhiều khoảng trắng
- comments
Để giảm dung lượng của file bundle, giờ chúng ta sẽ dùng câu lệnh minify:
uglifyjs dist/bundle.js --screw-ie8 --compress --mangle --output dist/bundle.min.js
Nó sẽ tối ưu bundle.js và sinh ra file dist/bundle.min.js và cùng kiểm tả size:
$ ls -lah bundle.min.js -rw-r--r-- 1 mgechev staff 524K Jun 26 12:01 bundle.min.js
Như vậy là chúng ta đã giảm dung lượng file xuống còn 524K chỉ bằng cách áp dụng minification Compression of the Application
Hầu hết các HTTP servers đều hỗ trợ việc nén nội dung với gzip. Những tài nguyên được yêu cầu được nén bởi web server và gửi thông qua mạng.Việc còn lại ở client là giải nén chúng.
Cùng xem dung lượng file sau khi nén:
$ gzip bundle.min.js $ ls -lah bundle.min.js.gz -rw-r--r-- 1 mgechev staff 128K Jun 26 12:01 bundle.min.js.gz
Chúng ta đã giảm được dung lượng file bundle xuống gần 75% chỉ bằng cách áp dụng việc nén nhưng chúng ta có thể làm được hơn thế.
Step 2 - Tree-shaking
Ở đây chúng ta sẽ dùng thuộc tính khá quan trọng của ES2015 modules đó là tree-shakable
Tree-shaking theo định nghĩa có thể hiểu là sẽ loại bỏ những exports không cần thiết (không dùng) của bundle
Bởi vì ES2015 modules là static, chúng ta có thể biết được những exports nào dùng và không dùng trong ứng dụng của chúng ta thông qua việc thực hiện static code analysis. Ngược lại, CommonJS modules không thường tree-shakable Ví dụ:
const readline = require('readline'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); rl.question('Algorithm you want to use for sorting the numbers? ', answer => { const sort = require(answer); sort([42, 1.618, 4]); });
Nó sẽ không thể đoán được giải thuật nào sẽ được chọn bởi người dùng để thực hiện static-code analysis.
Với ES2015 chúng ta có thể làm như sau:
import {Graphs} from './graphs'; import {Algorithms} from './algorithms'; const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); rl.question('Algorithm you want to use for sorting the numbers? ', answer => { const sort = Algorithms[answer]; sort([42, 1.618, 4]); });
Ở cách này, chúng ta có thể dùng tree-shaking và loại bỏ ./graphs modules từ file bundle vì chúng ta không dùng Graphs ở đâu cả. Ở phương diện khác, chúng ta cần import tất cả các thuật toán sắp xếp bởi vì chúng ta không hề biết được người dùng sẽ nhập vào thuật toán nào do vậy chúng ta không thể bỏ bất kì thuật toán nào từ Algorithms được.
Applying Tree-Shaking with Rollup Rollup là một module bundler nó tối ưu cho ES2015 modules. Nó là một công cụ mới và dễ extend mà cho phép chúng ta dùng tree-shaking thông qua ES2015 và CommonJS (trong hầu hết mọi trường hợp) qua việc dùng plugin.
Chúng ta sẽ dùng Rollup với ví dụ trên, và cố gắng để giảm dung lượng file bundle xuống nhỏ hơn!
Bây giờ chúng ta cùng xem lại chút thay đổi ở file pckage.json:
"scripts": { "test": "echo "Error: no test specified" && exit 1", "clean": "rm -rf dist", "clean": "rm -rf dist", "serve": "http-server . -p 5557", "build": "tsc -p tsconfig.json", "rollup": "rollup -f iife -c rollup.config.js -o dist/bundle.es2015.js", "es5": "tsc --target es5 --allowJs dist/bundle.es2015.js --out dist/bundle.js", "minify": "uglifyjs dist/bundle.js --screw-ie8 --compress --mangle --output dist/bundle.min.js", "build_prod": "npm run clean && npm run build && npm run rollup && npm run es5 && npm run minify" }
Chúng ta thêm một vài scipts mới đó là rollup, es5, build_prod.
rollup có nhiệm vụ là bundle ứng dụng của chúng ta và thực hiện tree-shaking.
TypeScript hỗ trợ ES2015 modules, nó có nghĩa là chúng ta có thể áp dụng tree-shaking trực tiếp vào ứng dụng của chúng ta khi chưa transpile. Thông qua TypeScript plugin for Rollup cho phép chúng ta thực hiện việc transpile như một phần việc khi bundle. Thật tốt biết mấy khi các dependencies của ứng dụng được chia bởi TypeScript. Tuy nhiên, Angular được chia và quản lý như là ES5 và ES2015 và RxJS cũng vậy (ở rxjs-es package).
Bởi vì chúng ta không thể áp dụng tree-shaking trực tiếp thông qua file TypeScript gốc trong ứng dụng của chúng ta, do đó điều đâu tiên chúng ta cần làm đó là chuyển nó về dạng ES2015, sau đó bundle chúng với rollup và chuyển nó về ES5.
Bởi vì sự thay đổi trong flow tiến trình build nên chúng ta có chút thay đổi khá cần thiết trong file tsconfig.json
{ "compilerOptions": { "target": "es2015", "module": "es2015", // ... }, "compileOnSave": false, "files": [ "app/main.ts" ] }
Target version của chúng ta là es2015 để chuyển từ TypeScript sang ES2015 với ES2015 modules.
Giờ chúng ta cùng nhìn lại script rollup:
rollup -f iife -c -o dist/bundle.es2015.js
Với script này chúng ta bundle modules như là IIFE (Immediately-Invoked Function Expression), sử dụng những config trong file rollup.config.js ở root project và sẽ bundle ra file bundle.es2015.js trong thư mục dist.
Giờ cùng xem qua file config cho rollup:
import nodeResolve from 'rollup-plugin-node-resolve'; class RollupNG2 { constructor(options){ this.options = options; } resolveId(id, from){ if (id.startsWith('rxjs/')){ return `${__dirname}/node_modules/rxjs-es/${id.replace('rxjs/', ')}.js`; } } } const rollupNG2 = (config) => new RollupNG2(config); export default { entry: 'dist/main.js', sourceMap: true, moduleName: 'main', plugins: [ rollupNG2(), nodeResolve({ jsnext: true, main: true }) ] };
Chúng ta export configuration object và khai báo nó trong entry , module (cần có nếu chúng ta dùng IIFE bundle), chúng ta cũng cần khai báo sourceMap và những plugín cần dùng. Chúng ta dùng nodeResolve plugin cho rollup để gợi ý cho bundler rằng chúng ta muốn dùng module resolution như nodejs; có nghĩa là bundler sẽ tìm import kiểu như @angular/core, nó sẽ vào node_modules/@angular/core và đọc file package.json. Nó sẽ tìm thuộc tính main:jsnext, bundler sẽ dùng file đó và set giá trị cho nó. Nếu như thuộc tính đó không được tìm thấy, bundler sẽ dùng file đc trỏ tới bởi main.
Vấn đề xảy ra đó là RxJS được mặc định điều phối bởi ES5. Để giải quyết vấn đề này, dùng cho việc bundle mà dùng RxJS operator chúng ta sẽ dùng package rxjs-es. Sau khi cài đặt các modules, chúng ta cần chắc chắn rằng module bundler sẽ dùng rxjs-es thay vì rxjs từ node_modules. Đó cũng chính là mục đích của plugin rollupNG2- để dịch mọi file rxjs/* và import sang rxjs-es/*
Giờ nếu chúng ta chạy:
npm run clean && npm run build && npm run rollup
Chúng ta sẽ có được file bundle.es2015.js. Giờ thì chuyển file bundle đó sang ES5 qua script:
tsc --target es5 --allowJs dist/bundle.es2015.js --out dist/bundle.js
Chúng ta vẫn dùng TypeScript compiler và xuất file ra dist/bundle.js Để có thể đạt đến việc tối ưu cuối chúng ta cần gọi npm run minify. Chạy thử để kiểm tra xem ứng dụng của chúng ta vẫn chạy ngon.
Giờ thì cùng nhau kiểm tra dung lượng file đầu tiên là ES5 bundle:
$ ls -lah bundle.js -rw-r--r-- 1 mgechev staff 1.5M Jun 26 13:00 bundle.js
Và sau đó là file đã đc minified:
$ ls -lah bundle.min.js -rw-r--r-- 1 mgechev staff 502K Jun 26 13:01 bundle.min.js
Từ 524K đã giảm xuống còn 502K, cũng không tệ lắm. Dường như có điều gì đó bất thường; file bundle với rollup thì lớn hơn file bundle với browserify, nhưng khi minified thì lại ngược lại. Điều đó chứng tỏ rằng chúng ta có thể loại bỏ dead-code tốt hơn với uglify. Sau khi gzip chúng ta được:
$ ls -lah bundle.min.js.gz -rw-r--r-- 1 mgechev staff 122K Jun 26 13:01 bundle.min.js.gz
Ồ, giảm hơn kìa nhưng chúng ta vẫn có thể làm tốt hơn
Using ngc
Tôi làm theo từng bước theo Angular compiler (ngc). Ý tưởng chính của ngc là tiến hành các templates của các components trong ứng dụng của chúng ta và tạo ra một VM friendly, tree-shakable code. Việc compile có thể xảy ra runtime hoặc buildtime, nhưng bởi vì runtime (Just-in-time) compilation của ứng dụng đã được load trên trình duyệt nên chúng ta không thể tận dụng được ưu điểm của tree-shaking. Do đó, chúng ta dùng ngc như 1 phần của tiến trình build, chúng ta có thể gọi đó là Ahead-of-Time compilation (or AoT).
Mặc dù ở trên chúng ta đã dùng tree-shaking nhưng tại sao chúng ta vẫn có thể làm cho nó trở nên tốt hơn ? Đơn giản vì HTML template rollup không thực sự chắc chắn rằng phần nào của Angular chúng ta có thể loại bỏ từ file bundle vì HTML là thứ mà rollup không thể phân tích được. Đó là lý do chúng ta có thể:
- compile ứng dụng của chúng ta (cả templates) sang TypeScript với ngc
- thực hiện tree-shaking với rollup (cách này sẽ giúp chúng ta nhận được dung lượng file nhỏ hơn ở trên)
- chuyển file bundle sang ES5
- minify chúng
- nén lại với gzip
Ok, bắt đầu thôi ! sample code của phần này mọi người có thể tham khảo ở đây
Nào cùng xem file package.json:
"scripts": { "clean": "rm -rf dist && rm -rf app/*.ngfactory.ts && cd compiled && find . ! -name 'main-prod.ts' -type f -exec rm -f {} + && cd ..", "serve": "http-server . -p 5557", "ngc": "ngc -p tsconfig.json && cp app/* compiled", "build": "tsc -p tsconfig-tsc.json", "rollup": "rollup -f iife -c rollup.config.js -o dist/bundle.es2015.js", "es5": "tsc --target es5 --allowJs dist/bundle.es2015.js --out dist/bundle.js", "minify": "uglifyjs dist/bundle.js --screw-ie8 --compress --mangle --output dist/bundle.min.js", "build_prod": "npm run clean && npm run ngc && npm run build && npm run rollup && npm run es5 && npm run minify" }
build_prod confirm thứ tự của những hành động đơn lẻ cần thực hiện. chúng ta cùng xem lại script clean, nó khá phức tạp so với trước đó:
rm -rf dist && rm -rf app/*.ngfactory.ts && cd compiled && find . ! -name 'main-prod.ts' -type f -exec rm -f {} + && cd ..
Đoạn script này dùng để xóa thư mục dist, mọi file mà có đuôi là .ngfatory.ts trong thư mục app và mọi thứ ngoại trừ main-ngc.ts từ thư mục compiled. ngc sinh ra file có đuôi là *.ngfactory.ts. Bởi vì nó xuất hiện trong tiến trình build nên chúng ta muốn xóa nó khỏi trước lần build kế tiếp. Nhưng tại sao lại xóa mọi file ngoại trừ file main-ngc.ts trong thư mục compiled ? cùng nhìn qua nội dung file:
import { enableProdMode } from '@angular/core'; import { platformBrowser } from '@angular/platform-browser'; import { AppModuleNgFactory } from './app.module.ngfactory'; enableProdMode(); platformBrowser().bootstrapModuleFactory(AppModuleNgFactory);
Bởi vì chúng ta dùng file compiled/main-ngc.ts như là file đầu vào. Cũng cần chú ý là chúng ta có 2 file tsconfig: 1 cho TypeScript compiler (tsc), file còn lại là cho ngc.
Ok, giờ chạy script npm run ngc. sau khi hoàn thành, chúng ta có cấu trúc thư mục như sau:
. ├── README.md ├── app │ ├── app.component.ngfactory.ts │ ├── app.component.ts │ ├── app.module.ngfactory.ts │ ├── app.module.ts │ └── main.ts ├── compiled │ ├── app.component.ngfactory.ts │ ├── app.component.ts │ ├── app.module.ngfactory.ts │ ├── app.module.ts │ ├── main-prod.ts │ └── main.ts ├── index.html ├── package.json ├── rollup.config.js ├── tsconfig-tsc.json └── tsconfig.json
Giờ thì chạy npm run es5 để chuyển nó qua ES2015. Ở đây chúng ta đã có es2015 version ở thư mục dist rồi. do vậy chỉ còn lại:
- tree-shaking
- chuyển từ es2015 sang es5
- minify
- gzip
những việc trên chúng ta đã khá quen rồi, do vậy hãy chạy từng lệnh tương ứng. Sau đó kiểm tra lại xem ứng dụng đã chạy ổn chưa
Cùng kiểm tra dung lượng file trước khi compile và tree-shaking:
$ ls -lah bundle.min.js -rw-r--r-- 1 mgechev staff 201K Jun 26 14:22 bundle.min.js
ứng dụng chúng ta đã giảm đc gần gấp đôi so với trước mà không dùng ngc! Nếu chúng ta nén nó lại thì sao:
$ ls -lah bundle.min.js.gz -rw-r--r-- 1 mgechev staff 49K Jun 26 14:22 bundle.min.js.gz
Yeah đó là kết quả chúng ta nói đến ở đầu bài viết: nhỏ hơn 50K. Ngoài ra chúng ta có thể giảm được dung lượng thêm chút nữa (khoảng 39K) nếu dùng Brotli nhưng cái này thì không được hỗ trợ rộng rãi.
Conclusion
Như chúng ta có thể thấy ở biểu đồ trên, qua việc áp dụng những cách tối ưu với production bundle chúng ta có thể giảm dung lượng của ứng dụng tới 36 lần.
Đó là nhờ vào một số kỹ thuật như:
- Tối ưu thông qua việc static-code analysis, mà cụ thể đó là tree-shaking
- minification (bao gồm cả mangling)
- Nén với gzip hoặc brotli
May thay là giờ chúng đã được áp dụng trong một số tool như angular-cli và angular-seed