この記事の内容はいささか、古くなってしまっているようです。投稿からしばらくして、AngularもCommonJS対応を強化して、以前よりはrequire
との相性が改善しています。ただ、筆者自身はもうAngularから離れてしまったのでキャッチアップしきれていません。
うっかりこの記事にたどり着いてしまった方は、@armorik83さんによる詳説「AngularJSモダンプラクティス」にあるCommonJS + Browserifyスタイルを好む方はの項を参照することをお勧めします。
AngularJSとBrowserify、この二人の関係、かなり微妙です。もうどうしてくれようかってくらい。例えるなら宗教の違う恋人のようなものです(適当)。CommonJSに慣れた人には、AngularのDIが正直お邪魔。かといって、Angularの根幹に関わる部分なので使わないわけにもいかないし、と距離感に悩む二人です。もちろん、ReactやAmpersand.jsと幸せになる手もありますが、それはそれ。どんな折り合いのつけ方があるのか、別のパートナーを探すべきなのか、少し考えてみたいと思います。
時代背景的な
AngularJSの初版は案外古くて2009年に遡ります。Backbone.jsが2010年、Ember.jsが2011年という順番です。なので、当初は確かにモジュールローダを独自実装する必要が強くありました。ただ、2015年の今、AMDかCommonJSにしておいてくれれば...!と思わずにはいられません。実際、将来のAngularの2.0ではDIコンテナが分離され、他のフレームワークとも統合しやすいものになると見られています。(2.0でのweb componentsの扱いも気になりますね)
年 | 関係するできごと | 備考 |
---|---|---|
2009 | AngularJS初版 | Googleが支援 |
2010 | Knockout.js, Backbone.js初版 | |
2011 | Ember.js初版 | SproutCore 2 から |
2013 | React初版 | Facebookが支援 |
2014 | HTML5 正式勧告 | |
2015 | ES6 勧告予定 | |
???? | Web Components |
モジュールローダの宗派
Node自体や最近のNode製ツールを触っていると、CommonJSがデフォルトのような気がしてきますが、クライアントサイドへの浸透は案外進んでいません。Browserifyがあらためて普及し始めて、クライアントサイドへの道が開けましたが、それまではほぼAMD一択の状況だったので、まあしょうがないかなと。2014年は、webpackやduoなど、Browserify以外でもCommonJS対応するものが話題になりました。
- AMD: RequireJS, webpack, Dojo, ...
- CommonJS: Node.js, Browserify, webpack, duo, ...
- UMD: AMDとCommonJSの両方に対応する書き方
- ECMAScript 6: 次期バージョンの JavaScript (2015年6月勧告予定)
- 独自系: AngularJS ※Angular用語的には「依存性の注入(DI)」
種類 | 呼び出し | 定義 |
---|---|---|
AMD | requirejs.config({ backbone: 'path/to/file' }) |
define(['backbone'], function (Backbone) {/* */}) |
CommonJS | var Backbone = require('backbone') |
module.export = function() {/* */} |
ES6 (参考) | import { hello } from 'somemodule' |
export function hello {/* */} |
「AngularのDIはrequire
である」
Angularの初学者を悩ませるものの一つが「DI = Dependency Injection」です。
- 「他言語でいうところのDIとなんか違うような...?」
- 「やっていることは、モジュール連携の作法に見える」
- 「独特でよくわからん」
いろんな声が聞こえてきそうです。でも「DI」という言葉にとらわれ過ぎると、よくわからなくなります。むしろ、CommonJS慣れしているなら、単に「require
の代わりになる何か」と思った方がわかりやすいはず。
Angularとモジュールローダ
さて、そろそろ具体論に。HTMLにscript
タグが氾濫することと、グローバル汚染だけは避けたいのが人情です。TodoMVCのHTMLでこれ(↓)なので、実際の運用は推して知るべし。
<script src="bower_components/todomvc-common/base.js"></script>
<script src="bower_components/angular/angular.js"></script>
<script src="bower_components/angular-route/angular-route.js"></script>
<script src="js/app.js"></script>
<script src="js/controllers/todoCtrl.js"></script>
<script src="js/services/todoStorage.js"></script>
<script src="js/directives/todoFocus.js"></script>
<script src="js/directives/todoEscape.js"></script>
これ(↑)をこんな風(↓)にスッキリしたい...!
<script src="app.js"></script>
それでは、いくつかのパターンを考えていきましょう。
Angular + gulp-concat
トップバッターは、しょっぱな浮気してgulp-concat
です。
シンプルかつ頑張りすぎてなくてバランスのとれた好人物もとい解決策です。でも、なんか納得がいかない... ><
一応説明を加えると、コントローラとサービスをそれぞれファイルを分けて書き、単純にファイルを結合しています。下記は簡略化した例です。(以下、ややこしくなるので、angularはすでにグローバルに定義済みとして話を進めます)
# ./app.coffee
app = angular.module 'app', []
残念ながら、app
はグローバルです。続いて、コントローラと
# ./controller/todoCtrl.coffee
app.controller 'todoCtrl', ($scope, todoStorage) ->
$scope.todos = todoStorage.get()
# 処理が続く
サービスです。
# src/service/todoStorage.coffee
app.factory 'todoStorage', ->
STORAGE_ID = 'todos-angularjs-perf'
ret =
get: -> JSON.parse localStorage.getItem STORAGE_ID
put: (todos) -> localStorage.setItem STORAGE_ID, JSON.stringify todos
結合(concat)するためのgulpfileです。ソースマップもつけた方が良いですね。
# gulpfile.coffee
gulp = require 'gulp'
coffee = require 'gulp-coffee'
concat = require 'gulp-concat'
uglify = require 'gulp-uglify'
annotate = require 'gulp-ng-annotate'
gulp.task 'angular', ->
gulp.src [
'src/app.coffee'
'src/*/*.coffee'
]
.pipe coffee bare: true # CoffeeScriptのコンパイル
.pipe concat 'app.js' # 結合
.pipe annotate() # アノテーションをつけて`uglify`に備える
.pipe uglify() # ミニファイ
.pipe gulp.dest 'dist/'
Angular + Browserify (弱)
次。本命らしきBrowserifyに弱気に近づきます。
ほとんど、結合しているのと変わりませんが、require
を使ってapp.coffee
から他のファイルを読み込むようにしています。なお、AngularJSは非CommonJSなため、angular = require 'angular'
とすることは難しいです。
# src/app.coffee
angular.module 'app', []
require './controller/todoCtrl'
require './service/todoStorage'
先ほどと違うのは、読み込む先のファイルからapp
オブジェクトを直接参照できない点。angular
はグローバル変数なのでmodule
関数経由で参照を取得します。ちなみに、module
関数は引数2つでモジュール生成、引数1つでモジュール取得になります。
# src/controller/todoCtrl.coffee
app = angular.module 'app'
app.controller 'todoCtrl', ($scope, todoStorage) ->
$scope.todos = todoStorage.get()
# 処理が続く
これだけ見ると、不要な複雑さを持ち込んでいるようにも見えますが、require
で必要なnpm/bowerモジュールを読み込めるのは魅力です。あと、app.factory
の外側に変数を書いてもグローバルを汚染しないのは気楽ですね。
# src/service/todoStorage.coffee
app = angular.module 'app'
STORAGE_ID = 'todos-angularjs-perf'
app.factory 'todoStorage', ->
get: -> JSON.parse localStorage.getItem STORAGE_ID
put: (todos) -> localStorage.setItem STORAGE_ID, JSON.stringify todos
Browserify用のgulpfileはこんな感じに。ここではBrowserifyのTransformを使いましたが、前の例と同じにgulp-ng-annotate
でも構いません。ちなみに、bower経由のモジュールを使う場合はdebowerifyをTransformに加える必要があります。
gulp = require 'gulp'
browserify = require 'browserify'
coffeeify = require 'coffeeify'
annotatify = require 'browserify-ngannotate'
source = require 'vinyl-source-stream'
streamify = require 'gulp-streamify'
uglify = require 'gulp-uglify'
gulp.task 'angular', ->
browserify
entries: ['src/app.coffee']
extensions: ['.coffee']
.transform coffeeify # CoffeeScriptのコンパイル
.transform annotatify # アノテーションをつけて`uglify`に備える
.bundle()
.pipe source 'app.js'
.pipe streamify uglify() # ミニファイ
.pipe gulp.dest 'dist/'
Angular + Browserify (中)
さらに、もう少し歩み寄ってみます。要は、CommonJSっぽい運用にします。
# src/app.coffee
angular
.module 'app', []
.controller 'todoCtrl', require './controller/todoCtrl'
.factory 'todoStorage', require './service/todoStorage'
(弱)の例ではmodule.export
をあえて使っていませんでした。
# src/controller/todoCtrl.coffee
module.exports = [
'$scope', 'todoStorage'
($scope, todoStorage) ->
$scope.todos = todoStorage.get()
# 処理が続く
]
Angularのモジュールとしての体裁に揃えるため、関数でラップして返しているのもポイントです。Angularのマジックで$scope
とtodoStorage
が使えるのが気になりますが、そこはぐっとこらえます。
# src/service/todoStorage.coffee
STORAGE_ID = 'todos-angularjs-perf'
module.exports = ->
get: -> JSON.parse localStorage.getItem STORAGE_ID
put: (todos) -> localStorage.setItem STORAGE_ID, JSON.stringify todos
gulpfileは先ほどとあまり変わりません。ただ、上述のように (修正: 配列で渡せば可能でした).controller
や.factory
関数などからrequire
する形だと、自動アノテーションができません。そのため、圧縮率で不利ですがmangleをしない設定にしています。
gulp = require 'gulp'
browserify = require 'browserify'
coffeeify = require 'coffeeify'
source = require 'vinyl-source-stream'
streamify = require 'gulp-streamify'
uglify = require 'gulp-uglify'
gulp.task 'angular', ->
browserify
entries: ['src/app.coffee']
extensions: ['.coffee']
.transform coffeeify # CoffeeScriptのコンパイル
.bundle()
.pipe source 'app.js'
.pipe streamify uglify mangle: false # ミニファイ (mangleなし)
.pipe gulp.dest 'dist/'
Angular + Browserify (強)
ふたりも慣れてきたので、本音をぶつけ合ってみます。
先の例では、Angularのサービスを一旦Angularのモジュールとして読み込んでから使っていましたが、なんだか不毛です。HTMLテンプレート内から呼ぶ可能性があるのはディレクティブとフィルターが主なので、サービスに関してはばっさりAngularから切り離します。(ただし、コアのサービスやプラグインとして提供されているものは、Angularの文法に従うほかないので、ダブルスタンダード感は否めません)
# src/app.coffee
angular
.module 'app', []
.controller 'todoCtrl', require './controller/todoCtrl'
ふう...、やっと普通のJavaScriptっぽくなってきました。
# src/controller/todoCtrl.coffee
todoStorage = require '../service/todoStorage'
module.exports = [
'$scope'
($scope) ->
$scope.todos = todoStorage.get()
# 処理が続く
]
Angularの作法に従う必要はないので、この例ではオブジェクトを返しています。(つまり、関数のラッピングを外しました)
# src/service/todoStorage.coffee
STORAGE_ID = 'todos-angularjs-perf'
module.exports =
get: -> JSON.parse localStorage.getItem STORAGE_ID
put: (todos) -> localStorage.setItem STORAGE_ID, JSON.stringify todos
※gulpfileは(中)の例と同じなので省略。
Angular + RequireJS
別キャラ登場ですが、すみません、力尽きました。angularAMDがわかりやすいので、説明を譲ります。
まとめ
ここまで、5つのパターンを見ました。
- Angular + gulp-concat
- Angular + Browserify (弱)
- Angular + Browserify (中)
- Angular + Browserify (強)
- Angular + RequireJS
個人的には、どれもしっくりきていませんが、(強)でやるか、Browserifyを諦めてgulp-concatのみで行くかどちらかという気がしています。
実は、Angularのソースから個別にrequire
するという荒技の「Angular + Browserify (烈)」というレシピもあるはずなんですが、茨の道すぎて尻込みしてした結果が、この記事になります。あしからず。