ASP.NET Core + Vue.js (TypeScript)で開発する時の覚書

# ASP.NET Core + Vue開発時まとめ

## 概要

きっとみんなこのスタックで開発したいはずなのに、
個別の情報は散在していてまとまった情報がないのでまとめてみた
(AngularとReactはVSやCore SDKのテンプレートがあるのにVueはなぜかない...)

### できるようになること

- C#(Server side) + TypeScript + Vue(Client)での開発
- Hot Module Reloadingによるデバッグ時でのフロントエンドコードの修正・リアルタイム反映
- リリース・デプロイ時のWebpackの設定

### この記事で触れないもの

- Vue CLI : 結局Webpackを触らないとやってられないし、サーバはASP.NET Coreを使えばいいからあまりメリットがない
- 認証周り : ASP.NET Coreサーバ単体で運用するんだったらASP.NET Core Identity, バックエンドを分けてCORS設定してJWTをVuexに入れるとか考えられるがここでは割愛。

## 環境

### Windows

- Visual Studio
- .Net Core SDK
- Node開発モジュール

### Mac, Linux, WSL

- .Net Core SDK
- Node.js
- Visual Studio Code
  - Vetur
  - TS Lint
  - C# Extension

これらがインストール済みであることが前提。
Node, Net Core SDK、VS Codeなどは出来れば最新のものが望ましい。

## 準備

VSはASP.NET Coreのテンプレから構築。GUIでどれを選んでもいい。
CLIベースのツールで以降は説明。

### 新規のprojectを作成し、パッケージを追加する

```sh
dotnet new webapp --name QiitaArticle
cd QiitaArticle
dotnet add package Microsoft.AspNetCore.SpaServices
dotnet add package Microsoft.Typescript.MSBuild
dotnet add package Microsoft.VisualStudio.Web.BrowserLink
dotnet add package Microsoft.VisualStudio.Web.CodeGeneration.Design
dotnet tool install --global dotnet-aspnet-codegenerator
```

### Nodeの設定とモジュールのインストール

```sh
npm init # デフォルトの設定でOK
npm i vue axios
npm i -D webpack webpack-cli webpack-hot-middleware clean-webpack-plugin aspnet-webpack webpack-dev-middleware
npm i -D typescript ts-loader css-loader vue-loader vue-template-compiler webpack-merge terser-webpack-plugin
npm i -D vue-class-component vue-property-decorator vuex vue-router
```

### 各種設定ファイルを配置する

```sh
mkdir Frontend
```

ここに以下を配置していく

```tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "experimentalDecorators": true,
    "sourceMap": true,
    "moduleResolution": "node",
    "lib": ["dom", "es5"]
  },
  "exclude": [
    "node_modules"
  ]
}
```

互換性はES5とした。APIの通信でPromiseをメソッドチェーンよりもC#書きにとって馴染み深い async / await記法を使いたいのでlibに追加する。
Vueのコンポーネントの各要素の属性を指定するデコレータは現時点ではExperimentalなのでその旨明記。 

```vue-shims.d.ts
declare module '*.vue' {
    import Vue from 'vue';
    export default Vue;
}
```

VueファイルをTSとして読み込むために必要

### Webpackの設定

開発と本番で設定ファイルを分ける。共通部分は `common` にいれる。
HTML Loaderなどは特に不要。

```webpack.common.js
const path = require('path');
const VueLoaderPlugin = require('vue-loader/lib/plugin');

module.exports = {
    entry: { main: './Frontend/index.ts' },
    output: {
        path: path.resolve(__dirname, 'wwwroot'),
        filename: 'js/[name].js',
        publicPath: '/'
    },
    resolve: {
        extensions: ['.ts', '.js', '.vue', '.json'],
        alias: {
            'vue$': 'vue/dist/vue.esm.js'
        }
    },
    module: {
        rules: [
            {
                test: /\.vue$/,
                loader: 'vue-loader',
                options: {}
            },
            {
                test: /\.tsx?$/,
                loader: 'ts-loader',
                exclude: /node_modules/,
                options: {
                    appendTsSuffixTo: [/\.vue$/]
                }
            }
        ]
    },
    plugins: [
        new VueLoaderPlugin(),
    ]
};
```

Devではソースマップを有効にする。

```webpack.dev.js
const merge = require('webpack-merge');
const common = require('./webpack.common.js');

module.exports = merge(common, {
    mode: 'development',
    devtool: 'inline-source-map',
    devServer: {
        contentBase: './wwwroot',
    }
});
```

Prodではminifyする。

```webpack.prod.js
const merge = require('webpack-merge');
const common = require('./webpack.common.js');
const TerserPlugin = require('terser-webpack-plugin');

module.exports = merge(common, {
    mode: 'production',
    optimization: {
        minimizer: [
            new TerserPlugin({
                terserOptions: { ecma: 5, compress: true,
                    output: { comments: false, beautify: false }
                }
            })
        ]
    }
});
```

### package.jsonを編集して、Webpackのコマンドを呼び分ける

```diff:package.json
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
+   "build": "webpack --config webpack.dev.js"
+   "release": "webpack --config webpack.prod.js"

  },
```

### 本番デプロイ用にcsprojを編集すうる

```diff:QiitaArticle.csproj
    <PackageReference Include="Microsoft.VisualStudio.Web.BrowserLink" Version="2.2.0" />
  </ItemGroup>


+ <Target Name="RunWebpack" AfterTargets="ComputeFilesToPublish">
+   <Exec Command="npm i" />
+   <Exec Command="npm run release" />
+   <ItemGroup>
+     <DistFiles Include="wwwroot\**" />
+     <ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
+       <RelativePath>%(DistFiles.Identity)</RelativePath>
+       <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
+     </ResolvedFileToPublish>
+   </ItemGroup>
+ </Target>

</Project>
```


### ASP.NET CoreでHMRを使えるようにする

```Diff:Sartup.cs
using Microsoft.Extensions.DependencyInjection;
+ using Microsoft.AspNetCore.SpaServices.Webpack;

//(中略)
        
        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
+               app.UseBrowserLink();
+               app.UseWebpackDevMiddleware(new WebpackDevMiddlewareOptions
+               {
+                   HotModuleReplacement = true,
+                   ConfigFile = @"./webpack.dev.js"
+               });
            }
            else
            {
                app.UseExceptionHandler("/Error");
                app.UseHsts();
            }

            app.UseHttpsRedirection();
            app.UseStaticFiles();
            app.UseCookiePolicy();

-           app.UseMvc();
+           app.UseMvc(routes =>
+           {
+               routes.MapRoute(
+                   name: "default",
+                   template: "{controller=Home}/{action=Index}/{id?}");
+               routes.MapSpaFallbackRoute(name: "spa-fallback", new { controller = "Home", action = "Index" });
+           });        
        }
```

Fall back routeはVue RouterでClient Side Renderingした際に必ず必要なので入れる。

### VueがHello worldできることを確認する

```sh
Frontend
├───Components
│   └───HelloWorld.vue
├───index.ts
├───tsconfig.json
└───vue-shims.d.ts
```

```index.ts
import Vue from 'vue'
import HelloComponent from './Components/HelloWorld.vue'

let v : Vue = new Vue({
    el: '#app',
    template: `
    <div>
        Name: <input v-model="name" type="text">
        <hello-component :name="name" :initialEnthusiasm="5" />
    </div>
    `,
    data: { name: 'World' },
    components: { HelloComponent }
});
```

```Components/HelloWorld.vue
<template>
    <div>
        <div class="greeting"> Hello {{ name }} {{ exclamationMarks }}</div>
        <button @click="decrement"> -</button>
        <button @click="increment"> +</button>
     </div>
</template>

<script lang = "ts">
import { Vue, Component, Prop } from 'vue-property-decorator';

@Component
export default class HelloDecorator extends Vue {
    @Prop() name!: string;
    @Prop() initialEnthusiasm!: number;

    enthusiasm = this.initialEnthusiasm;

    increment() {
        this.enthusiasm++;
    }
    decrement() {
        if (this.enthusiasm > 1) {
            this.enthusiasm--;
        }
    }

    get exclamationMarks(): string {
        return Array(this.enthusiasm + 1).join('!');
    }
}
</script>
```

```diff:Pages/Shared/_Layout.cshtml
    </environment>
    <script src="~/js/site.js" asp-append-version="true"></script>
+   <script src="~/js/main.js" asp-append-version="true"></script>

    @RenderSection("Scripts", required: false)
</body>
```

```diff:Pages/Index.cshtml
    <p>Learn about <a href="https://docs.microsoft.com/aspnet/core">building Web apps with ASP.NET Core</a>.</p>
+   <div id="app"></div>
+   <div id="api"></div>
</div>
```

```sh
dotnet run
```

でエラーが出ないことを確認。またDeveloper ToolのConsole上に `[HMR]` と表示されていれば、
フロントエンドのコードをデバッグ実行中に編集しても変更が反映される。


### APIを追加して、コンポーネントから呼び出して表示させる

```sh
dotnet aspnet-codegenerator controller -name SampleController --relativeFolderPath Controllers --useDefaultLayout --referenceScriptLibraries
```

```Controllers/SampleCOntroller.cs
using Microsoft.AspNetCore.Mvc;

namespace QiitaArticle.Controllers
{
    [Route("api/sample")]
    [ApiController]
    public class SampleController : Controller
    {
        [HttpGet]
        public IActionResult Index()
        {
            return Json(new string[] {"Vue.js", "With", "ASP.NET Core", "Rocks!"});
        }
    }
}
```

```Frontend/Components/CallingApi.vue
<template>
    <div>
        <ul v-for="c in content" :key="c">
            <li>{{c}}</li>
        </ul>
     </div>
</template>

<script lang = "ts">
import { Vue, Component, Prop } from 'vue-property-decorator';
import axios from 'axios';

@Component
export default class CallingApi extends Vue {
    content : string[] = [];
    async mounted() : Promise<void> {
        this.content =  (await axios.get('api/sample')).data;
    }
}
</script>
```

```diff:index.ts
import Vue from 'vue'
import HelloComponent from './Components/HelloWorld.vue'
+ import CallingApi from './Components/CallingApi.vue'

new Vue({
    el: '#app',
    template: `
    <div>
        Name: <input v-model="name" type="text">
        <hello-component :name="name" :initialEnthusiasm="5" />
    </div>
    `,
    data: { name: 'World' },
    components: { HelloComponent }
});
+ new CallingApi({}).$mount('#api');
```

これでControllerから取り出した内容が表示されるはず

## 本番デプロイ

環境変数を変えてProductionモードで見てみる。

```sh
dotnet publish -c Release
export ASPNETCORE_ENVIRONMENT=Production
cd ./bin/Release/aspnetcoreapp2.0/publish
dotnet QiitaArticle.dll
```

これで起動した後、F12を押してConsoleを見てみると、minifyされたJSが読み込まれていて、 `HMR` が消えている。
実際はこれをサーバに置けばOK.


## 参考

- TypeScript Vue Starter([Github](https://github.com/microsoft/TypeScript-Vue-Starter))
- [Webpack Production](https://webpack.js.org/guides/production/)
- ASP.NET Core + Node ([Microsoft Docs](https://docs.microsoft.com/en-us/aspnet/core/client-side/spa-services?view=aspnetcore-2.2))
- [TypeScriptでVue.jsを書く](https://mae.chab.in/archives/60167)