Razor类库是用Razor SDK设置的.NET类库,用于建立Razor文件(.razor, cshtml)和包含客户端网络类库(.js, .css, images等)。客户端网络类库通常需要使用npm和webpack等工具来构建或处理。但是,将客户端网络类库构建管道整合到Razor类库的构建过程中可能是一个挑战。它很容易出错,有时会导致脆弱的解决方案,比如依赖构建过程中的任务顺序,而这些任务在不同的版本中会发生变化。
在这篇文章中,我们将告诉你如何将npm和webpack整合到你的Razor类库的构建过程中。对于那些想要一个简单的解决方案的人来说,我们将看看新的实验性的Microsoft.AspNetCore.ClientAssets包,只要你安装了正确的工具,它就会自动为你处理这种整合。我们还将介绍如何使用MSBuild设置的所有细节,以满足那些希望能够控制和定制流程的人的需要。虽然这篇文章的重点是npm和webpack,但我们在这里提供的解决方案可以很容易地适应其他工具,如yarn、rollup等。
添加一个客户网络类库构建过程
作为构建过程的一部分,我们希望发生两件事,以整合对客户端网络类库产的处理。
- 恢复由其他软件包管理器(npm、yarn)管理的客户网络类库。
- 构建/生成客户端网络类库,并将结果作为静态网络类库纳入构建输出的一部分。
在我们的例子中,我们会把所有的客户端网络类库源放到项目根目录下的assets文件夹中。
对于构建过程的输出,我们有几个选择:
- 把输出放在dist子文件夹中。这在很多工具中都很常见,而且可以方便地瞥见输出。然而,如果你使用的是Git(或其他版本控制系统),你可能会被迫在*.gitignore*文件中添加一个条目,以避免意外地将输出结果添加到源码控制中,而且这也使得清理工作区更加困难。
- 把输出放在obj文件夹中。这更符合.NET中的构建工作方式,而且不需要任何额外的设置。我们将使用这种方法。
理想情况下,我们希望这些步骤是增量的,也就是说,它们只在任何输入发生变化时运行,而不是在所有东西都是最新的时候运行。例如,我们只希望在本地没有软件包或package.json文件被更新时,运行npm install 。
使用MicrosoftAspNetCore.ClientAssets包
所有这些构建逻辑都可以包含在一个NuGet包中,并在多个项目中重复使用。事实上,这就是我们在实验性的Microsoft.AspNetCore.ClientAssets包中所做的,它将自动添加对集成npm、webpack或其他工具到你的构建管道的支持。
要使用MicrosoftAspNetCore.ClientAssets包:
- 从你的项目中为Microsoft.AspNetCore.ClientAssets添加一个包引用。
- 添加一个带有package.json文件的assets文件夹。
- 在assets/package.json中指定你的依赖关系,并定义你想在调试和发布构建中运行的
build:Debug和build:Release脚本。
这就是了!你就可以恢复和构建你的客户网络类库了。你可以在这里找到一个样本,显示了使用这个包在Razor类库中构建TypeScript文件。
我们认为这个包可以满足大多数用户的需求,但请试一试,如果你在GitHub上发现任何问题,请告诉我们你的想法。如果由于某些原因,这对你使用的工具不起作用,你可以将MSBuild逻辑直接添加到你的项目中,并更新和调整它以适应你的需求。我们接下来会介绍这个。
使用MSBuild添加客户端网络类库构建过程
MicrosoftAspNetCore.ClientAssets包提供了自定义的MSBuild目标,以在你的项目中添加客户网络类库构建过程。在这一节中,我们将看看这些目标是如何工作的,这样你就可以定制它们了。
将npm install 与Razor类库结合起来
为了从npm恢复客户端网络类库,我们将创建一个任务来调用npm install 。MSBuild任务允许你指定Inputs 和Outputs ,它们用于通过检查一个输入是否比其中一个输出新,来确定目标是否需要运行。由于我们将所有的客户源放在assets文件夹中,我们可以使用assetspackage.json文件作为输入,以确定我们是否需要运行npm install 命令。当运行npm install ,会创建一个node_modules.package-lock.json文件,我们可以用它来做输出。如果package.json被改变,它将比node_modules.package-lock.json更新,这将在下次触发构建时触发新的安装。
完整的目标显示在下面:
<Target Name="NpmInstall" Inputs="assetspackage.json" Outputs="assetsnode_modules.package-lock.json">
<Message Importance="high" Text="Running npm install..." />
<Exec Command="npm install" WorkingDirectory="assets" />
</Target>
运行npm命令并收集输出为静态网络类库
接下来我们将创建一个目标,运行npm命令,并将输出结果收集为静态网络类库。在我们的例子中,我们将创建一个NpmRunBuild 目标,在每次构建时运行package.json中的build:$(Configuration) 脚本(其中$(Configuration) 是Release或Debug)。
NpmRunBuild 目标将做以下工作:
- 运行给定的npm命令。
- 收集该命令的输出。
- 将输出作为
Content,并将它们链接到wwwroot文件夹中的位置。 - 将生成的文件列表写入磁盘,作为增量的输出。
我们将首先定义NpmRunBuild 目标,使其在NpmInstall 之后、AssignTargetPaths 之前运行。该目标执行指定的npm命令,命令输出被指向*$(IntermediateOutputPath)\npm*,它通常指向obj\Debug|Release\net6.0:
<Target Name="NpmRunBuild" DependsOnTargets="NpmInstall" BeforeTargets="AssignTargetPaths">
<Exec Command="npm run build:$(Configuration) -- -o $(IntermediateOutputPath)npm" WorkingDirectory="assets" />
</Target>
接下来我们要把构建输出作为Content ,并把它们链接到wwwroot文件夹中。我们可以通过在调用Exec 任务后声明三个项目组来实现这一目标,具体如下:
<ItemGroup>
<_NpmOutput Include="$(IntermediateOutputPath)npm**" />
<Content Include="@(_NpmOutput)" Link="wwwroot%(_NpmOutput.RecursiveDir)%(_NpmOutput.FileName)%(_NpmOutput.Extension)" />
<FileWrites Include="@(_NpmOutput)" />
</ItemGroup>
_NpmOutput 项目组捕获了输出内容中的所有文件。然后我们将这些项目作为Content ,并将它们链接到wwwroot文件夹中。最后,我们将这些项目添加到FileWrites 的列表中,这样它们就可以被清理掉属性了。
最后一步是使这个目标只在来源改变时运行。要做到这一点,我们需要在目标上声明Inputs 和Outputs ,就像我们为NpmInstall 目标所做的那样。MSBuild将检查输出,并在两种情况下执行该目标:
- 输出不存在。
- 任何输出都比任何输入都要早。
鉴于我们不知道运行npm命令的输出会是什么,我们将生成一个包含输出文件列表的文件。输出文件列表将总是有一个众所周知的文件名*(npmbuild.complete.txt*),所以我们将能够使用它作为我们目标的输出。我们可以像这样产生输出文件列表:
<WriteLinesToFile File="$(IntermediateOutputPath)npmbuild.complete.txt" Lines="@(_NpmOutput)" />
因为我们要生成一个文件,所以我们也需要把它添加到FileWrites ,就像我们对其他输出所做的那样。
<FileWrites Include="$(IntermediateOutputPath)npmbuild.complete.txt" />
对于输入,我们可以定义一个项目组来捕获它们,如下所示:
<ItemGroup>
<NpmBuildInputs Include="assets**" Exclude="assetsnode_modules**" />
</ItemGroup>
有了所有这些,我们可以在我们的NpmRunBuild 目标上定义输入和输出,如下所示:
<ItemGroup>
<NpmBuildInputs Include="assets**" Exclude="assetsnode_modules**" />
</ItemGroup>
<Target Name="NpmRunBuild" DependsOnTargets="NpmInstall" BeforeTargets="AssignTargetPaths" Inputs="@(NpmBuildInputs)" Outputs="$(IntermediateOutputPath)npmbuild.complete.txt">
<Exec Command="$(NpmCommand) -- -o $(IntermediateOutputPath)npm" WorkingDirectory="assets" />
<ItemGroup>
<_NpmOutput Include="$(IntermediateOutputPath)npm**"></_NpmOutput>
<Content Include="@(_NpmOutput)" Link="wwwroot%(_NpmOutput.RecursiveDir)%(_NpmOutput.FileName)%(_NpmOutput.Extension)" />
<FileWrites Include="@(_NpmOutput)" />
<FileWrites Include="$(IntermediateOutputPath)npmbuild.complete.txt" />
</ItemGroup>
<WriteLinesToFile File="$(IntermediateOutputPath)npmbuild.complete.txt" Lines="@(_NpmOutput)" />
</Target>
这个目标只有在你改变源文件的时候才会运行,比如你的TypeScript文件或package.json。
在package.json中添加脚本
有了这些目标,构建管道将在必要时调用npm install 和npm run build:Debug 或npm run build:Release ,将输出文件夹作为-o 参数传递。我们需要在package.json中定义这些脚本来调用相应的工具。为了使用webpack,这些脚本看起来像这样:
"scripts": {
"build:Debug": "webpack --mode development",
"build:Release": "webpack --mode production"
}
从Exec 任务传递给npm run 的参数将透明地流入被调用的命令,并覆盖默认的输出路径。
重复使用MSBuild的代码
我们已经详细地看到了如何把所有的部分组合起来。然而,我们在运行什么命令、文件应该位于何处等方面都非常严格。接下来,我们将通过使用MSBuild来参数化这个过程来概括这个解决方案。方法是这样的:
<PropertyGroup>
<ClientAssetsDirectory Condition="'$(ClientAssetsDirectory)' == ''">assets</ClientAssetsDirectory>
<ClientAssetsRestoreInputs Condition="'$(ClientAssetsRestoreInputs)' == ''">$(ClientAssetsDirectory)package-lock.json;$(ClientAssetsDirectory)package.json<ClientAssetsRestoreInputs>
<ClientAssetsRestoreOutputs Condition="'$(ClientAssetsRestoreOutputs)' == ''">$(ClientAssetsDirectory)node_modules.package-lock.json<ClientAssetsRestoreOutputs>
<ClientAssetsRestoreCommand Condition="'$(ClientAssetsRestoreCommand)' == ''">npm install<ClientAssetsRestoreCommand>
<ClientAssetsBuildCommand Condition="'$(ClientAssetsBuildCommand)' == ''">npm run build:$(Configuration)<ClientAssetsBuildCommand>
<ClientAssetsBuildOutputParameter Condition="'$(ClientAssetsBuildOutputParameter)' == ''">-o<ClientAssetsBuildOutputParameter>
<ClientAssetsRestoreInputs>$(MSBuildProjectFile);$(ClientAssetsRestoreInputs)</ClientAssetsRestoreInputs>
</PropertyGroup>
<ItemGroup>
<ClientAssetsInputs Include="$(ClientAssetsDirectory)**" Exclude="$(DefaultItemExcludes)" />
</ItemGroup>
<Target Name="NpmInstall" Inputs="$(ClientAssetsRestoreInputs)" Outputs="$(ClientAssetsRestoreOutputs)">
<Message Importance="high" Text="Running $(ClientAssetsRestoreCommand)..." />
<Exec Command="$(ClientAssetsRestoreCommand)" WorkingDirectory="$(ClientAssetsDirectory)" />
</Target>
<Target Name="NpmRunBuild" DependsOnTargets="NpmInstall" BeforeTargets="AssignTargetPaths" Inputs="@(ClientAssetsInputs)" Outputs="$(IntermediateOutputPath)clientassetsbuild.complete.txt">
<Exec Command="$(ClientAssetsBuildCommand) -- $(ClientAssetsBuildOutputParameter) $(IntermediateOutputPath)clientassets" WorkingDirectory="$(ClientAssetsDirectory)" />
<ItemGroup>
<_ClientAssetsBuildOutput Include="$(IntermediateOutputPath)clientassets**"></_ClientAssetsBuildOutput>
<Content Include="@(_ClientAssetsBuildOutput)" Link="wwwroot%(_ClientAssetsBuildOutput.RecursiveDir)%(_ClientAssetsBuildOutput.FileName)%(_ClientAssetsBuildOutput.Extension)" />
<FileWrites Include="@(_ClientAssetsBuildOutput)" />
<FileWrites Include="$(IntermediateOutputPath)clientassetsbuild.complete.txt" />
</ItemGroup>
<WriteLinesToFile File="$(IntermediateOutputPath)clientassetsbuild.complete.txt" Lines="@(_ClientAssetsBuildOutput)" />
</Target>
有了上面的改动,我们可以通过改变项目文件中不同属性的值来调整构建过程。例如,我们可以指定我们的客户网络类库在js文件夹中,而不是Class Library,或者改变默认的排除列表:
<PropertyGroup>
<ClientAssetsDirectory>js</ClientAssetsDirectory>
<DefaultItemExcludes>$(ClientAssetsDirectory)dist**</DefaultItemExcludes>
</PropertyGroup>
总结
使用新的实验性的Microsoft.AspNetCore.ClientAssets包,在你的.NET项目中添加一个客户端网络类库构建过程现在变得很容易。或者你可以使用类似的MSBuild逻辑创建一个自定义的构建过程,以满足你的需求。我们希望你能尝试一下这个功能,并在GitHub上告诉我们你的想法。
编码愉快!