この記事は Symfony Advent Calendar 2016 2日目の記事です。 弊社の過去のブログを実践した方法をチュートリアル的に紹介します。
Symfony2でTypeScriptなAngularJSを使う方法
http://tech.quartetcom.co.jp/2016/07/08/typed-angularjs-inside-symfony/
2016/12/08 追記
ブログのタイトルがSymfony2向けとなっていますが、上記ブログの内容を元にSymfony3で実践した内容を紹介しています。
仕組みと主な環境
TypeScriptで書いたソースコードをwebpackでJavaScriptにトランスパイルし、Symfonyのassetとしてtwigに読み込ませます。
補足
サンプルとして紹介するコードでAssetic Filterを通してJavaScriptを読み込んでいます。 今回のコードでは特にasseticを通す必要はありませんが、弊社プロジェクトでこの記事の内容を実践するにあたりasseticを利用したため、参考までに記述させていただきます。
- Mac OSX 10.11
- Symfony 3.1
- node 6.5
- webpack 1.13
- AngularJS 1.5
- TypeScript 2.0
- Karma 1.3
1) Symfony3のプロジェクトを作る
プロジェクト作成
新規のプロジェクトを作成します。 Symfonyコマンドのインストールは公式ドキュメント Installing & Setting up the Symfony Framework を参考にしてください。
$ symfony new project
$ cd project
assetic-bundleを有効にする
$ php composer.phar require symfony/assetic-bundle
<?php
// project/app/AppKernel.php
public function registerBundles()
{
$bundles = [
+ new Symfony\Bundle\AsseticBundle\AsseticBundle(),
ブラウザで見てみる
ビルトインサーバーを起動してページを表示してみましょう。
$ bin/console server:start localhost:8000
ブラウザで http://localhost:8000 を開くとページが表示されます。
2) TypeScriptをトランスパイルする環境を作る
package.jsonの作成
$ mkdir -p [path/to/project]/src/AppBundle/Resources/npm
$ cd [path/to/project]/src/AppBundle/Resources/npm
$ npm init
トランスパイルに必要なパッケージのインストール
$ npm install --save \
awesome-typescript-loader \
source-map-loader \
typescript \
webpack \
webpack-merge
webpackの設定ファイル
以下のファイルを作成します。
// project/src/AppBundle/Resources/npm/webpack.conf.js
switch (process.env.NODE_ENV) {
case 'prod':
case 'production':
module.exports = require('./webpack/prod.conf.js');
break;
case 'test':
module.exports = require('./webpack/test.conf.js');
break;
default:
module.exports = require('./webpack/dev.conf.js');
}
// project/src/AppBundle/Resources/npm/webpack/conf.js
const webpack = require('webpack');
const path = require('path');
const ForkCheckerPlugin = require('awesome-typescript-loader').ForkCheckerPlugin;
module.exports = {
entry: {
'app': ['index']
},
resolve: {
extensions: ['', '.ts', '.js'],
root: path.resolve(__dirname, '../src'),
modulesDirectories: ['node_modules']
},
module: {
loaders: [
{ test: /\.ts$/, loader: 'awesome-typescript-loader' }
]
},
preLoaders: [
{ test: /\.js$/, loader: 'source-map-loader' }
],
plugins: [
new ForkCheckerPlugin(),
new webpack.optimize.OccurenceOrderPlugin(true)
]
};
// project/src/AppBundle/Resources/npm/webpack/dev.conf.js
const webpackMerge = require('webpack-merge');
const path = require('path');
const config = require('./conf.js');
module.exports = webpackMerge(config, {
debug: true,
devtool: 'cheap-module-eval-source-map',
output: {
path: path.resolve(__dirname, '../../public/assets'),
filename: '[name].js',
sourceMapFilename: '[file].map',
chunkFilename: '[id].chunk.js'
}
});
// project/src/AppBundle/Resources/npm/webpack/prod.conf.js
const webpack = require('webpack');
const webpackMerge = require('webpack-merge');
const path = require('path');
const config = require('./conf.js');
module.exports = webpackMerge.smart(config, {
debug: false,
devtool: 'source-map',
output: {
path: path.resolve(__dirname, '../../public/assets'),
filename: '[name].js',
sourceMapFilename: '[file].map',
chunkFilename: '[id].chunk.js'
},
plugins: [
new webpack.optimize.DedupePlugin(),
new webpack.optimize.UglifyJsPlugin({
beautify: false,
mangle: { screw_ie8 : true },
compress: { screw_ie8: true },
comments: false
})
]
});
// project/src/AppBundle/Resources/npm/webpack/test.conf.js
const webpackMerge = require('webpack-merge');
const path = require('path');
const config = require('./conf.js');
module.exports = webpackMerge(config, {
devtool: 'inline-source-map',
output: {
path: path.resolve(__dirname, '../../public/assets'),
filename: '[name].js',
sourceMapFilename: '[file].map',
chunkFilename: '[id].chunk.js'
}
});
tsconfig.jsonの作成
このファイルはTypeScriptのトランスパイルの設定として使用されます。 各オプションの詳細は TypeScript tsconfig.json を参照してください。
// project/src/AppBundle/Resources/npm/tsconfig.json
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"sourceMap": true,
"noEmitHelpers": true,
"typeRoots": [
"node_modules/@types"
]
},
"exclude": [
"node_modules"
],
"filesGlob": [
"./src/index.d.ts",
"./src/**/*.ts"
],
"awesomeTypescriptLoaderOptions": {
"resolveGlobs": true,
"forkChecker": true
},
"compileOnSave": false,
"buildOnSave": false,
"atom": { "rewriteTsconfig": false }
}
npm-scriptsの登録
コマンドから webpack --[env]
のように呼び出すと、環境に応じた設定 webpack/[env].conf.js
を読み込んでwebpackが実行されます。これをnpm-scriptsとして登録しておきます。
// project/src/AppBundle/Resources/npm/package.json
"scripts": {
+ "build": "webpack --config webpack.conf.js --progress --profile --colors --display-error-details --display-cached",
+ "build:prod": "npm run build --production"
},
サンプルのソースコード
どのように動作するのかブラウザで確認するためサンプルを書いてみましょう。
// project/src/AppBundle/Resources/npm/src/index.ts
namespace foo {
export function greet(name:string):void {
console.log(`hello ${name}`);
}
}
foo.greet('test');
ビルドしてみる
$ cd [path/to/project]/src/AppBundle/Resources/npm
$ npm run build
ビルドが終了すると src/AppBundle/Resources/public/assets が作成されるはずです。
assets
フォルダにはTypeScriptからJavaScriptにトランスパイルされた app.js
が保存されています。
トランスパイルされたソースを表示するページ
[BundleName]/Resources/public
はSymfonyのAssetとして利用されます。コントローラにアクションを追加し、トランスパイルされたソースをtwigから参照してみます。
<?php
// src/AppBundle/Controller/DefaultController.php
/**
* @Route("/demo", name="demo")
* @return \Symfony\Component\HttpFoundation\Response
*/
+ public function demoAction()
+ {
+ return $this->render('default/demo.html.twig');
+ }
{# app/Resources/views/default/demo.html.twig #}
{% extends 'base.html.twig' %}
{% block body %}
<div>トランスパイルされたソースを表示するページ</div>
{% endblock %}
{% block javascripts %}
{% javascripts 'bundles/app/assets/*.js' %}
<script src="{{ asset_url }}"></script>
{% endjavascripts %}
{% endblock %}
$ bin/console asset:install --symlink
$ bin/console assetic:dump
ブラウザで見てみる
ブラウザで http://localhost:8000/demo を開くと結果が確認できます。
3) angularJSを動かす
angularJSのパッケージを追加
$ cd [path/to/project]/src/AppBundle/Resources/npm
$ npm install --save angular angular-provide angular-sanitize angular-ui-router
angularJSの型をインストール
TypeScriptからJavaScriptのパッケージを利用する場合、パッケージ内のオブジェクトや関数などの型情報を参照する必要があります。
TypeScript2から @types
を使ってnpmからインストールできるようになったため、これを使って型情報をインストールします。
$ cd [path/to/project]/src/AppBundle/Resources/npm
$ npm install --save-dev \
@types/angular \
@types/angular-ui-router \
@types/es6-shim \
@types/jquery
angularJSのアプリケーションを定義
angularJSのアプリケーションを書いてみましょう。
以下は angular-ui-router で /home
/items
というルーティングを定義し、それぞれのページを行き来するだけの簡単なサンプルアプリケーションです。
{# app/Resources/views/default/demo.html.twig #}
{% extends 'base.html.twig' %}
{% block body %}
- <div>トランスパイルされたソースを表示するページ</div>
+ <div ng-app="app" ng-strict-di>
+ <ui-view></ui-view>
+ </div>
{% endblock %}
// project/src/AppBundle/Resources/npm/src/index.ts
- namespace foo {
- export function greet(name:string):void {
- console.log(`hello ${name}`);
- }
- }
-
- foo.greet('test');
+ export * from './module';
// project/src/AppBundle/Resources/npm/src/module.ts
import * as angular from 'angular';
import angularUiRouter from 'angular-ui-router';
import { CONFIG_PROVIDERS } from './config';
import provide from 'angular-provide';
export default provide(angular.module('app', [
angularUiRouter
]),
...CONFIG_PROVIDERS
);
// project/src/AppBundle/Resources/npm/src/config/index.ts
import provide from 'angular-provide';
import { configUrlRouter } from './router';
import { configState } from './state';
export const CONFIG_PROVIDERS = [
provide.config(configUrlRouter),
provide.config(configState)
];
// project/src/AppBundle/Resources/npm/src/config/router.ts
import { IUrlRouterProvider } from 'angular-ui-router';
configUrlRouter.$inject = ['$urlRouterProvider'];
export function configUrlRouter ($urlRouterProvider: IUrlRouterProvider) {
$urlRouterProvider.otherwise('/home');
}
// project/src/AppBundle/Resources/npm/src/config/state.ts
import { IStateProvider } from 'angular-ui-router';
configState.$inject = ['$stateProvider'];
export function configState($stateProvider: IStateProvider) {
$stateProvider
.state('home', {
url: '/home',
template: `
<h2>home</h2>
<div><a href ui-sref="items({id:1})">items1</a></div>
<div><a href ui-sref="items({id:2})">items2</a></div>
`
})
.state('items', {
url: '/items/{id:int}',
template: `
<h2>items {{$ctrl.id}}</h2>
<div><a href ui-sref="home">back to home</a></div>
`,
resolve: {
id: ['$stateParams', $stateParams => $stateParams.id]
},
controller: ['id', function (id) {
this.id = id;
}],
controllerAs: '$ctrl'
})
;
}
ブラウザで見てみる
$ cd [path/to/project]/src/AppBundle/Resources/npm
$ npm run build
ブラウザをリロードすると結果が確認できます。
4) gitで除外するファイル・ディレクトリ
ソースコードをgitで管理する場合は以下のファイルおよびディレクトリを .gitignore
に追加してください。これらのファイルはnpm-scriptsにより再作成されます。
- [path/to/project]/src/AppBundle/Resources/npm/node_modules
- [path/to/project]/src/AppBundle/Resources/public/assets
5) ファイルの監視をするnpm-scripts
ソースコードを変更する度にビルドするのは面倒なのでnpm-scriptsとして登録しておきます。
// project/src/AppBundle/Resources/npm/package.json
"scripts": {
+ "watch": "npm run build -- --watch"
$ cd [path/to/project]/src/AppBundle/Resources/npm
$ npm run watch
先に書いたようにビルド結果はSymfonyのAssetとして利用されるため、こちらも監視しておくと便利です。
$ bin/console assetic:watch
6) ルートディレクトリからの実行
さきほどから以下のディレクトリ移動が何度も出てくるのにお気づきでしょうか。
$ cd [path/to/project]/src/AppBundle/Resources/npm
フロントエンドのソースコードをバンドルごとにnpmで管理する利点として
- バンドルごとにビルドやインストールが行える
- インストールが高速
などが挙げられますが、インストールの度にディレクトリ移動するのが面倒です。 冒頭の Symfony2でTypeScriptなAngularJSを使う方法 でも紹介していますが、Symfonyプロジェクトのルートディレクトリからnpmコマンドを実行するパッケージを改めて紹介します。
hshn/npm-bundle
https://packagist.org/packages/hshn/npm-bundle
hshn/npm-bundleインストール
$ php composer.phar require hshn/npm-bundle
<?php
// project/app/AppKernel.php
public function registerBundles()
{
$bundles = [
+ new \Hshn\NpmBundle\HshnNpmBundle(),
// app/config/config.yml
+ hshn_npm:
+ bin: "%kernel.root_dir%/../node_modules/.bin/npm"
+ bundles:
+ AppBundle: ~
プロジェクト直下のnode_modulesにnpmをインストール
パッケージをインストールするとSymfonyのコンソールで hshn:npm
コマンドが使えるようになります。
このコマンドはプロジェクト直下の node_modules/npm
を使用して各バンドルのnpmコマンドを実行するため、npmをインストールしておきます。
$ cd [path/to/project]
$ npm init
$ npm install --save npm
実際にコマンドを使ってみましょう。
# 全バンドルのnpmパッケージをインストール
$ bin/console hshn:npm:instal
# 全バンドルのビルド
$ bin/console hshn:npm:run build
build
のようにプロジェクト全体で一括処理したいnpm-scriptsを定義しておくと便利ですね。
7) テスト
最後にユニットテストを実行してみましょう。
テストするためのコンポーネント
テストする対象として簡単なangularJSのコンポーネントを作成します。
<link-label no="1"></link-label>
とすると items/1
のリンクを生成します。
// src/AppBundle/Resources/npm/src/components/link-label.ts
import IComponentOptions = angular.IComponentOptions;
import IComponentController = angular.IComponentController;
class LinkLabelComponent implements IComponentController {}
export let LinkLabelComponentOptions: IComponentOptions = {
bindings: {
no: '<'
},
controller: LinkLabelComponent,
template: `
<div>
<a href ui-sref="items({id: $ctrl.no})">item {{$ctrl.no}}</a>
</div>
`
};
// src/AppBundle/Resources/npm/src/components/index.ts
import provide from 'angular-provide';
import { LinkLabelComponentOptions } from './link-label';
export const COMPONENT_PROVIDERS = [
provide.component('linkLabel', LinkLabelComponentOptions)
];
// src/AppBundle/Resources/npm/src/module.ts
+ import { COMPONENT_PROVIDERS } from './components';
export default provide(angular.module('app', [
angularUiRouter
]),
...CONFIG_PROVIDERS,
+ ...COMPONENT_PROVIDERS
);
追加したコンポーネントをビューで使用する
// src/AppBundle/Resources/npm/src/config/state.ts
export function configState($stateProvider: IStateProvider) {
$stateProvider
.state('home', {
url: '/home',
template: `
<h2>home</h2>
- <div><a href ui-sref="items({id:1})">items1</a></div>
- <div><a href ui-sref="items({id:2})">items2</a></div>
+ <link-label no="1"></link-label>
+ <link-label no="2"></link-label>
`
})
テスト用のパッケージをインストール
$ npm install --save-dev \
karma karma-jasmine \
karma-mocha-reporter \
karma-phantomjs-launcher \
karma-sourcemap-loader \
karma-webpack \
jasmine-core \
phantomjs-prebuilt \
angular-mocks \
@types/angular-mocks \
@types/jasmine
karmaの設定を追加
karmaの preprocessors
を使ってwebpackでビルドしたファイルをテストするように設定します。
// src/AppBundle/Resources/npm/karma.conf.js
module.exports = function(config) {
var testWebpackConfig = require('./webpack/test.conf.js');
config.set({
basePath: './src',
frameworks: ['jasmine'],
exclude: [ ],
files: [ { pattern: './specs.js', watched: false } ],
preprocessors: { './specs.js': ['webpack', 'sourcemap'] },
webpack: testWebpackConfig,
webpackServer: { noInfo: true },
reporters: [ 'mocha' ],
mochaReporter: { output: 'minimal' },
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: false,
browsers: ['PhantomJS'],
singleRun: true
});
};
// src/AppBundle/Resources/npm/src/specs.js
Error.stackTraceLimit = Infinity;
var testContext = require.context('./', true, /\.spec\.ts$/);
function requireAll(requireContext) {
return requireContext.keys().map(requireContext);
}
var modules = requireAll(testContext);
テスト用のnpm-scripts
// src/AppBundle/Resources/npm/package.json
"scripts": {
+ "test": "karma start",
+ "watch:test": "npm run test -- --auto-watch --no-single-run"
コンポーネントのテスト
テスト自体はJavaScriptで書く方法と同じですが、型指定が増えた分コード量が多く見えます。
// src/AppBundle/Resources/npm/src/test.ts
import module from './module';
export let testModule = module;
// src/AppBundle/Resources/npm/src/components/link-labels.spec.ts
import { testModule } from '../test';
import * as angular from 'angular';
import 'angular-mocks/ngMock';
import ICompileService = angular.ICompileService;
import IRootScopeService = angular.IRootScopeService;
import IAugmentedJQuery = angular.IAugmentedJQuery;
import IScope = angular.IScope;
describe('component', () => {
let $element: IAugmentedJQuery;
let $scope: IScope;
beforeEach(() => {
angular.mock.module(testModule.name);
angular.mock.inject(($compile: ICompileService, $rootScope: IRootScopeService) => {
$element = $compile('<link-label no="100"></link-label>')($scope = $rootScope.$new());
$scope.$digest();
});
});
it('link-label', () => {
expect($element.find('a').length).toBe(1);
let $anchor = $element.find('a');
expect($anchor.text()).toBe('item 100');
expect($anchor.attr('href')).toContain('/items/100');
});
});
テストを実行
せっかくなのでルートディレクトリから実行してみましょう。
$ bin/console hshn:npm:run test
[AppBundle]
> demo@1.0.0 test [path/to/project]/src/AppBundle/Resources/npm
> karma start[AppBundle] SUMMARY:
[AppBundle] ✔ 1 test completed
おわりに
TypeScriptを導入すると「console.log()しないと何が入っていたオブジェクトなのか思いだせない」状態から抜け出せます。また次の世代のECMAScriptの構文を積極的に取り入れている言語でもあるので、先行して勉強できるメリットもあります。Symfonyと組み合わせて積極的に使っていきたいですね。
今回のソースコードはgithubで公開しています。参考としてお使いください。 https://github.com/qcmatsuoka/typescript-symfony3