file

這篇我想寫出來十之八九會被同行看笑話、覺得這根本是個大外行亂玩出來的;不過這真的是事實沒錯,因為我本來對於 Vue CLI 就沒啥使用經驗,而跟 .NET Core 整合又真的是頭一遭,所以獻醜一下也沒關係,而且也非常歡迎各路在下方留言指教更好的作法。

之前我曾經在一些文章中說過,如果是我自己開發的專案,例如 BPS 等等,我是不會使用 Vue CLI 或 webpack 來進行建置的,因為我自己在開發的時候我會使用 vue-property-decorator、但我卻不希望把它的程式碼打包到我的建置輸出之中(太多餘了),所以之前我是自己寫了一個套件來把 vue-property-decorator 的語法重新轉譯成普通的 Vue.js 語法並且進行建置。

不過這樣的開發方式自己用還過得去,如果是在工作上要用,跟同事以及日後接手的工程師之間要配合就比較麻煩了。而最近正好工作上有一個專案經過評估之後決定採用 Vue.js、而非我最近比較常玩的 Blazor 1,所以我就來試了一下 Vue 跟 .NET Core 之間的整合。

而既然動機上是要追求跟別的工程師之間的配合方便,那在專案架構以及建置流程上是越沿著既有的方式做越好,所以我第一件事就是先去找看看有沒有範本可以用。關鍵字一打下去馬上就跳出來這個 VueJS with Asp.Net Core 3.1 Web API Template,名字聽起來就像是完全符合我們想要的站台架構,所以我馬上就安裝了這個範本,並且新增了一個專案。

可是馬上令人挫折的是,建出來的專案立刻按執行根本跑不了。呃……正常來說這種從範本建立起來的專案,不是應該建置了馬上就要可以跑才對嗎?

我研究了一下之後,才發現這個範本漏了幾個關鍵,底下來分享一下我的結論。

建置流程設置

首先這個範本在建立完專案之後,是不會順便幫你執行 npm install 的,你必須自己到專案底下的 clientapp 資料夾(即裡頭的 Vue 子專案)裡面去執行這道指令才行。除此之外,你也必須自己安裝全域的 Vue CLI(指令為 npm install -g @vue/cli)才能夠執行裡面預先寫好的腳本。

再來是建置的部份;其實平常在本地進行測試的時候,我們是不用執行 Vue 子專案的建置動作的,因為這個範本有預先寫好一個特別的啟動流程在它的 Startup.cs 裡面:

app.UseSpa(spa => {
    if (env.IsDevelopment())
        spa.Options.SourcePath = "ClientApp";
    else
        spa.Options.SourcePath = "dist";

    if (env.IsDevelopment()) {
        spa.UseVueCli(npmScript: "serve");
    }
});

其中 UseVueCli 這個的意思是說,它會去呼叫 Vue CLI 來開一個伺服器、直接用子專案裡頭的 .vue 檔案來執行站台,所以根本不用編譯那些 .vue 檔案也能跑;更好的是,如果有修改那些檔案,還會自動立刻刷新頁面,這對測試來說相當方便。在 Vue CLI 開了伺服器之後,這個 .NET Core 伺服器本身就只是一個指向它的代理(除非呼叫了某個 API 的路由)。

所以,其實建置的動作只有真的要發佈站台的時候需要做,而在偵錯執行時省去建置步驟、可以讓啟動速度加快很多。建置的指令本身是 npm run build(至於怎樣讓 VS 自動執行這個等一下再一起講),這個腳本執行下去之後,根據預設,會把結果建置在 clientapp\dist 目錄裡面,但是這邊又很好笑了:這個目錄根據範本的設置,並不會跟著一起發佈出去(呃……範本的作者你是在哈囉?)。

這個部份我真的花了非常多時間研究,最後終於弄出了一大串咒語來完美實現幾件事:1. 只有在按發佈的時候才執行 Vue CLI 的建置,偵錯執行的時候不會;2. 建置的結果中任何檔案增減都會立即反應到當前的發佈之中;3. 只要改專案的 .csproj 一個地方就好了,不用去改每一個發佈組態的 .pubxml 檔案(除了勾選「刪除現有檔案」之外)。而這串咒語就是(加入到 .csproj 的最上層 <Project> 標籤之中):

<Target Name="PrePublish"
        BeforeTargets="UpdateAspNetToFrameworkReference"
        Condition=" '$(DeployOnBuild)' == 'true' " >
    <Exec Command="npm run build" WorkingDirectory="clientapp" />
    <ItemGroup>
        <Content Include="clientapp\dist\**\*">
            <CopyToPublishDirectory>Always</CopyToPublishDirectory>
        </Content>
    </ItemGroup>
</Target>

另外,如同前述,發佈選項裡面也要記得勾選「刪除現有檔案」的選項,才能正確地反應 dist 裡面的檔案變更。

設置發佈後執行模式

然後這個範本的坑還沒完呢。這樣發佈出去(例如佈到 IIS 上)之後,又會再次發現站台根本跑不了,直接跳 500 錯誤出來。開啟偵錯 log 的話,會發現錯誤訊息是說「找不到 clientapp 裡面的入口頁面」。怪了,明明我們的入口頁面是在 clientapp\dist 裡面,它怎麼往上找了一層?而且前面我們不是有寫什麼 spa.Options.SourcePath = "dist" 這一行嗎?

起先我有點懷疑是不是這邊的路徑寫錯了,我於是就把它改成 "clientapp\dist" 看看,但是結果還是一樣沒用。我實驗了老半天才發現這一行好像根本就沒有差別,真正有影響的是寫在 ConfigureServices() 方法裡面的這一行:

configuration.RootPath = "ClientApp";

如果我把這一行改成 "clientapp\dist",那麼發佈出去之後就確實在 IIS 上是會跑的,可是這樣改的話又變成在 Visual Studio 裡面偵錯執行不能跑了。所以我必須類似地去判斷 env.IsDevelopment() 與否來決定要用哪一個當作 RootPath;問題是,env 這個參數只會傳入 Configure() 方法之中,沒有傳到 ConfigureServices() 中,怎麼辦?

其實也不難解決:

// 在外面宣告一個這樣的變數來儲存就好了
private IWebHostEnvironment CurrentEnvironment { get; set; }

public void ConfigureServices(IServiceCollection services) {
    services.AddControllers();
    services.AddSpaStaticFiles(configuration => {
        // 切換 RootPath
        if(CurrentEnvironment.IsDevelopment()) {
            configuration.RootPath = "ClientApp";
        } else {
            configuration.RootPath = "ClientApp/dist";
        }
    });
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env) {
    // 在這邊把 env 儲存起來;這邊會比上面的方法更早執行
    CurrentEnvironment = env;

    // 底下其它的基本上不用改
    ...
}

如此一來終於無論是偵錯執行還是發佈後的執行都沒問題了。

其它注意事項

這個範本提供的 Vue 子專案也是非常之簡陋,連個標配的 Vue Router 都沒有幫我們設置上去。幸好,這部份只要安裝一下並且在裡面的 main.js 設定一下就好了。這邊可以放心地啟用 Vue Router 的 history 模式,因為我們在 Startup.cs 裡面的設定會自動地把所有不是 API 的路由全部導向 Vue 的起始頁面,而 Vue Router 會接手剩下的工作。只要頁面裡面的資源都是以絕對路徑的方式寫成(或者有加上 <base> 元件),資源的路徑就不會出問題了。

不過,如果還要在進一步啟用更多功能,例如 TypeScript 支援、Vuex、PWA 等等,那與其一個一個自己設定還不如乾脆重新用 Vue CLI 開一個新的子專案。要做到這一點,只要手動清空 clientapp 目錄,然後到上層目錄中執行 vue create clientapp,根據自己的需求選取要的項目,就可以重新建立想要的子專案了,且新開出來的專案也完全跟前面設置的 .NET Core 可以正常串接。不過視選取的項目而定,在 .csproj 裡面還要再多一些設定:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    ...

    <!-- 有使用 TypeScript 的話就要設置這一行把 VS 自帶的功能關掉,才不會衝突 -->
    <TypeScriptCompileBlocked>true</TypeScriptCompileBlocked>

    <!-- 這一行建議都加入,尤其若選用 PWA 則一定要加,不然會出錯 -->
    <EnableDefaultContentItems>false</EnableDefaultContentItems>
  </PropertyGroup>

  ...
</Project>

再補充幾個我在偵錯執行當中碰到過的坑以及解法:

  1. 跑出「Request headers must contain only ASCII characters.」的錯誤
    開瀏覽器偵錯工具看一下是不是 localhost(無論哪個 port)有存了一個非 ASCII 字元的 cookie,有的話就會導致這個錯誤,刪除掉即可。
  2. 跑出「目標電腦拒絕連線」的錯誤
    原因不明,但重新執行 Visual Studio 的「開始偵錯」應該就可以解決問題。

  1. 最主要的考量在於我們需要這個專案可以完全離線使用,而 Blazor server 別說離線了、它是個連斷線都不行的框架,至於 Blazor WebAssembly 則又太重,所以基本上也不會考慮。 


分享此頁至:
最後修改日期: 2021/07/02

留言

撰寫回覆或留言

發佈留言必須填寫的電子郵件地址不會公開。