Bắt đầu AngularJS không khó. Docs tốt nhất, tutorial đơn giản.
Nhưng phức tạp khi kết hợp công nghệ.
Nếu dùng CoffeeScript thay vì JavaScript thuần, có preprocessing concern và syntax difference. Vấn đề nhỏ riêng lẻ, nhưng nếu thêm Ruby on Rails, Jasmine, Karma? Thử thách hơn.
Đây chính stack tutorial này. Không phải vì thích tự làm khổ, mà vì đây là setup thực tế bạn sẽ gặp.
Tutorial giả định bạn quen Rails, chưa cần biết AngularJS.
Tạo Rails app cơ bản
Vì nhiều layer công nghệ, xây app đơn giản gần như không làm gì. Setup CRUD cho restaurant — thực tế chỉ CR. UD để bài tập. ;-)
App tên Restauranteur.
Dùng PostgreSQL và RSpec, nhưng DBMS và server-side test framework không quan trọng. Dùng gì tuỳ bạn.
Setup ban đầu
Tạo project:
$ rails new restauranteur --database=postgresql --skip-test-unit
Nếu dùng Pow, add project vào Pow:
$ ln -s /Users/jasonswett/projects/restauranteur ~/.pow/restauranteur
Tạo user PostgreSQL:
$ createuser -P -s -e restauranteur
Thêm RSpec vào Gemfile:
# Gemfile
gem "rspec-rails", "~> 2.14.0"
Cài RSpec:
$ bundle install
$ rails g rspec:install
Tạo database:
$ rake db:create
Tạo model Restaurant
Project và DB ready, tạo resource đầu. Restaurant resource chỉ có 1 attribute: name, string.
$ rails generate scaffold restaurant name:string
Làm unique restaurant name cho OCD:
# db/migrate/[timestamp]_create_restaurants.rb
class CreateRestaurants < ActiveRecord::Migration
def change
create_table :restaurants do |t|
t.string :name
t.timestamps
end
# Thêm dòng sau
add_index :restaurants, :name, unique: true
end
end
Chạy migration:
$ rake db:migrate
Thêm spec verify không tạo restaurant invalid. Unique failure cho raw error.
require 'spec_helper'
describe Restaurant do
before do
@restaurant = Restaurant.new(name: "Momofuku")
end
subject { @restaurant }
it { should respond_to(:name) }
it { should be_valid }
describe "when name is not present" do
before { @restaurant.name = " " }
it { should_not be_valid }
end
describe "when name is already taken" do
before do
restaurant_with_same_name = @restaurant.dup
restaurant_with_same_name.name = @restaurant.name.upcase
restaurant_with_same_name.save
end
it { should_not be_valid }
end
end
Thêm validator làm spec pass:
class Restaurant < ActiveRecord::Base
validates :name, presence: true, uniqueness: { case_sensitive: false }
end
OK, tiến tiếp.
Kéo AngularJS vào
Thay vì dump tất cả, trước demo version "Hello, world" đơn giản nhất AngularJS-Rails app, rồi build restaurant CRUD lên đó.
Không lý do "Hello, world" page phải gắn với Rails resource cụ thể. Tạo `StaticPagesController` serve AngularJS home page.
Tạo controller
$ rails generate controller static_pages index
Root route hiện tại chỉ "Welcome to Rails". Set thành action `index` của `StaticPagesController` mới:
# config/routes.rb
Restauranteur::Application.routes.draw do
# Thêm dòng sau
root 'static_pages#index'
end
Tải Angular
- Để test chạy sau, cần file `angular-mocks.js`. Không thấy nhắc trong Angular docs, nhưng bắt buộc.
- AngularJS tutorial liệt kê latest bleeding-edge version, nhưng nhớ tôi gặp compatibility issue giữa `angular.js` và `angular-mocks.js` latest. Version 1.1.5 làm việc cùng nhau, dù không phải stable mới nhất, dùng version này. Tương lai compatibility sẽ cải thiện.
Tải `angular.js` và `angular-mocks.js` từ code.angularjs.org, move vào `app/assets/javascripts`.
$ wget https://code.angularjs.org/1.1.5/angular.js \
https://code.angularjs.org/1.1.5/angular-mocks.js
$ mv angular* app/assets/javascripts
Thêm vào asset pipeline
Báo app require AngularJS file, load trước file phụ thuộc. (Có thể dùng RequireJS manage dependency, đó là làm cho production, nhưng tutorial này giữ stack mỏng nhất.)
Lưu ý: Angular và Turbolinks conflict, disable ở đây.
// app/assets/javascripts/application.js
//= require jquery
//= require jquery_ujs
// Thêm 2 dòng sau
//= require angular
//= require main
//= require_tree .
Setup layout
Thêm `ng-app` và `ng-view`, báo có Angular app trong page. Cũng xóa nhắc Turbolinks.
<%= yield %>
Tạo Angular controller
Tạo directory cho controller. Đặt tên tuỳ ý.
$ mkdir -p app/assets/javascripts/angular/controllers
Tạo file controller. Gọi "home controller", convention Angular append filename với `Ctrl`. File: `app/assets/javascripts/angular/controllers/HomeCtrl.js.coffee`:
# app/assets/javascripts/angular/controllers/HomeCtrl.js.coffee
@restauranteur.controller 'HomeCtrl', ['$scope', ($scope) ->
# Body controller rỗng
]
Thêm Angular route
Thêm routing directive làm `HomeCtrl` thành "default page". Định nghĩa routing trong `app/assets/javascripts/main.js.coffee` (filename không quan trọng).
# app/assets/javascripts/main.js.coffee
@restauranteur = angular.module('restauranteur', [])
@restauranteur.config(['$routeProvider', ($routeProvider) ->
$routeProvider.
otherwise({
templateUrl: '../templates/home.html',
controller: 'HomeCtrl'
})
])
Thêm Angular template
Cần nơi chứa Angular template. Chọn `public/templates`.
mkdir public/templates
Tạo file `public/templates/home.html` với nội dung tùy ý, xem được trên browser.
This is the home page.
Truy cập `https://restauranteur.dev/` (hoặc `https://localhost:3000/` nếu không dùng Pow) thấy nội dung `home.html`.
Ví dụ data binding
Thú vị nhưng chưa ấn tượng. Gửi dữ liệu thật. Edit `app/assets/angular/controllers/HomeCtrl.js.coffee`:
# app/assets/javascripts/angular/controllers/HomeCtrl.js.coffee
@restauranteur.controller 'HomeCtrl', ['$scope', ($scope) ->
$scope.foo = 'bar'
]
Tương tự `@foo = "bar"` trong Rails controller. Plug `foo` vào template bằng double-brace:
Value of "foo": {{foo}}
Làm thực tế lần này
Đã build hello world app. CRUD full blown không khó hơn nhiều.
Seed database
Làm việc restaurant CRUD meaningful hơn nếu DB có sẵn restaurant. Seed file:
# db/seeds.rb
Restaurant.create([
{ name: "The French Laundry" },
{ name: "Chez Panisse" },
{ name: "Bouchon" },
{ name: "Noma" },
{ name: "Taco Bell" },
])
rake db:seed
Tạo restaurant index page
Tạo template folder cho restaurants:
mkdir public/templates/restaurants
Template đầu tiên — index page:
[index](/#) * {{ restaurant.name }}
Giải thích ngay. Tạo controller:
# app/assets/javascripts/angular/controllers/RestaurantIndexCtrl.js.coffee
@restauranteur.controller 'RestaurantIndexCtrl', ['$scope', '$location', '$http', ($scope, $location, $http) ->
$scope.restaurants = []
$http.get('./restaurants.json').success((data) ->
$scope.restaurants = data
)
]
Adjust routing config:
# app/assets/javascripts/main.js.coffee
@restauranteur = angular.module('restauranteur', [])
@restauranteur.config(['$routeProvider', ($routeProvider) ->
$routeProvider.
when('/restaurants', {
templateUrl: '../templates/restaurants/index.html',
controller: 'RestaurantIndexCtrl'
}).
otherwise({
templateUrl: '../templates/home.html',
controller: 'HomeCtrl'
})
])
Truy cập `/#/restaurants` thấy list restaurant. Thêm test trước khi tiến.
Thêm test đầu tiên
Thêm JS test folder:
mkdir spec/javascripts
Viết test:
# spec/javascripts/controllers_spec.js.coffee
describe "Restauranteur controllers", ->
beforeEach module("restauranteur")
describe "RestaurantIndexCtrl", ->
it "should set restaurants to an empty array", inject(($controller) ->
scope = {}
ctrl = $controller("RestaurantIndexCtrl", $scope: scope )
expect(scope.restaurants.length).toBe 0
)
Thêm config:
// spec/javascripts/restauranteur.conf.js
module.exports = function(config) {
config.set({
basePath: '../..',
frameworks: ['jasmine'],
autoWatch: true,
preprocessors: {
'**/*.coffee': 'coffee'
},
files: [
'app/assets/javascripts/angular.js',
'app/assets/javascripts/angular-mocks.js',
'app/assets/javascripts/main.js.coffee',
'app/assets/javascripts/angular/controllers/RestaurantIndexCtrl.js.coffee',
'app/assets/javascripts/angular/*',
'spec/javascripts/*_spec.js.coffee'
]
});
};
Cài Karma và start server:
sudo npm install -g karma
sudo npm install -g karma-ng-scenario
karma start spec/javascripts/restauranteur.conf.js
Truy cập `https://localhost:9876/`, test chạy và pass. Muốn thấy fail, đổi `expect(scope.restaurants.length).toBe 0` thành `expect(scope.restaurants.length).toBe 1` chạy lại.
Ý nghĩa test này obviously questionable, nhưng mục đích: cứu bạn khỏi việc tìm cách đưa Angular code vào test harness. Có thứ không obvious như CoffeeScript preprocessor và `angular-mocks.js` inclusion tốn vài giờ đau đầu.
Xây dựng thêm restaurants page
Adjust tạm restaurant index template:
* {{restaurant.name}} ({{restaurant.id}})
Ghé lại `/#/restaurants`, nhận thấy restaurant không có ID. Tại sao blank?
Generate scaffold Rails 4 cho `.jbuilder` files:
$ ls -1 app/views/restaurants/*.jbuilder
app/views/restaurants/index.json.jbuilder
app/views/restaurants/show.json.jbuilder
Mở `app/views/restaurants/index.json.jbuilder`:
# app/views/restaurants/index.json.jbuilder
json.array!(@restaurants) do |restaurant|
json.extract! restaurant, :name
json.url restaurant_url(restaurant, format: :json)
end
Chỉ include `:name`, không `:id`. Thêm nó:
# app/views/restaurants/index.json.jbuilder
json.array!(@restaurants) do |restaurant|
json.extract! restaurant, :id, :name
json.url restaurant_url(restaurant, format: :json)
end
Save và refresh `/#/restaurants`, ID xuất hiện.
Đổi template về bản gốc:
[index](/#) * {{ restaurant.name }}
Nhận ra trỏ đến `viewRestaurant()` nhưng chưa define. Làm ngay:
# app/assets/javascripts/angular/controllers/RestaurantIndexCtrl.js.coffee
@restauranteur.controller 'RestaurantIndexCtrl', ['$scope', '$location', '$http', ($scope, $location, $http) ->
$scope.restaurants = []
$http.get('./restaurants.json').success((data) ->
$scope.restaurants = data
)
# Thêm dòng sau
$scope.viewRestaurant = (id) ->
$location.url "/restaurants/#{id}"
]
Convention Rails: `resource_name/:id` map đến "show" page. Làm y như vậy. Tạo show template, route, controller.
# {{restaurant.name}}
# app/assets/javascripts/main.js.coffee
@restauranteur = angular.module('restauranteur', [])
@restauranteur.config(['$routeProvider', ($routeProvider) ->
$routeProvider.
when('/restaurants', {
templateUrl: '../templates/restaurants/index.html',
controller: 'RestaurantIndexCtrl'
}).
when('/restaurants/:id', {
templateUrl: '../templates/restaurants/show.html',
controller: 'RestaurantShowCtrl'
}).
otherwise({
templateUrl: '../templates/home.html',
controller: 'HomeCtrl'
})
])
# app/assets/javascripts/angular/controllers/RestaurantShowCtrl.js.coffee
@restauranteur.controller 'RestaurantShowCtrl', ['$scope', '$http', '$routeParams', ($scope, $http, $routeParams) ->
$http.get("./restaurants/#{$routeParams.id}.json").success((data) ->
$scope.restaurant = data
)
]
Refresh `/#/restaurants`, click restaurant, đến show page. Yay!
Hết phần này
Kết quả chưa đặc biệt ấn tượng, nhưng hy vọng tiết kiệm thời gian plug AngularJS vào Rails 4. Tiếp theo có thể xem `ngResource`, giúp CRUD DRY hơn.
Muốn học thêm?
Xem bài hay của Adam Anderson, series Bootstrapping an AngularJS app in Rails 4.0 giúp tôi bắt đầu AngularJS + Rails. Có thể làm tutorial của ông ấy, nhưng tutorial này khác ở chỗ tôi _thực sự_喂 bạn mọi chi tiết, minimize cơ hội bạn bị mắc kẹt.