この記事は 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を利用したため、参考までに記述させていただきます。

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 を開くとページが表示されます。

welcome

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 を開くと結果が確認できます。

transpired

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

ブラウザをリロードすると結果が確認できます。

angular

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