1. 先把账算清楚:IIS 跑 Vue 到底在跑什么
上周帮一个朋友把他那套 Vue3 + Vite 的后台管理端从 Linux 迁到一台 Windows Server 2019 上,他原话是"我在 Windows 下用 IIS 部署 Vue 项目,搜了一堆教程全是互相抄的,照着做要么白屏要么刷新 404"。这其实是很多人第一次在 Windows 上折腾 IIS 部署 Vue 时的共同体验——不是教程错,是教程普遍跳过了"为什么"这一步,只告诉你往 web.config 里粘一段规则就完事,出了问题完全不知道怎么查。
先把最核心的认知立住:Vue 打完包之后就是一堆静态文件,HTML、CSS、JS、图片、字体,仅此而已。它不像 Java 或 .NET 后端那样需要常驻进程、需要运行时、需要端口监听。IIS 在这里扮演的角色,本质上和 Nginx、Apache 是同一类东西——一个能对外提供静态文件、并且能按规则改写 URL 的 Web 服务器。理解这一点,后面所有配置就都有了解释:你不需要为 Vue 装任何运行时,不需要配任何特殊模块,你要做的只有两件半事情——把文件放对位置、让服务器知道怎么找文件、让 URL 能正确回退。
1.1 什么情况下用 IIS 部署前端是合理的
不是所有场景都值得上 IIS。如果你手上是一台干净的新机器,只是要跑一个纯前端项目,我更建议直接用 Nginx 或者干脆用 Node 起个静态服务,配置量小一个数量级。但现实里经常遇到这几种情况,IIS 就成了唯一或最省事的选择:
- 公司已有 Windows Server,运维团队只维护 IIS,加一台 Linux 机器的流程要审批两周;
- 后端是 ASP.NET Core / .NET 那套,已经跑在 IIS 上,前端想跟它在同一个站点下用同一个域名和端口,避免跨域和证书重复配置;
- 内网环境,服务器上已经装了 IIS 用来跑其他内部系统,想复用现有的 80/443 绑定和证书;
- 需要 Windows 身份验证(域账号免登录),这个能力是 IIS 开箱即用的,Nginx 做起来要绕一大圈。
提示:如果你的项目后端是 ASP.NET Core 且必须在 IIS 上跑,记得装对应版本的 Hosting Bundle,并且把承载前端的那个应用程序池的 .NET CLR 版本设为"无托管代码"。纯静态前端站点完全不需要 .NET 运行时,别被"iis 里没有 .NET 8"这类报错带着去乱装东西。
1.2 路由模式决定了你后面要不要写 URL 规则
这是整篇文章里最重要的一条分水岭。打开你的router/index.js,看一眼createRouter的配置:
// 模式一:hash 模式(URL 里带 #) const router = createRouter({ history: createWebHashHistory(), routes }) // 模式二:history 模式(URL 干净,但需要服务端配合) const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes })hash 模式下,浏览器访问的是http://host/#/user/list,#后面的内容浏览器自己处理,根本不会发给服务器。所以 IIS 这一侧你什么都不用配,文件扔进去就能跑。缺点也明显:URL 不好看,分享出去的链接带个井号,某些统计工具和 SEO 场景会受影响。
history 模式下,URL 是http://host/user/list。用户在首页点导航没问题,因为那是前端路由在拦截;但一旦用户在/user/list这个地址上按了 F5,浏览器就会真的向服务器请求/user/list这个路径。IIS 在物理目录下找不到叫user的文件夹,直接给你一个 404。这就是"点进去正常、一刷新就 404"的根本原因。
解决办法就是让它找不到文件时把index.html吐回去,剩下的路由判断交给 Vue Router。这个"回退"动作,在 IIS 上靠URL Rewrite模块完成。所以记住这条判断链:hash 模式 → 只放文件;history 模式 → 文件 + 一条回退规则。后面第三章会给出完整写法。
1.3 资源路径前缀:白屏问题的八成来自这里
另一个必须在打包前想清楚的问题:你的站点是部署在根路径还是子路径。
- 根路径:
http://host/,访问首页就是http://host/; - 子路径:
http://host/admin/,作为一个子应用挂在已有站点下面。
Vue 打包出来的index.html里,引用资源的路径是打包时写死的。如果你按根路径打包(base: '/'),却把文件放到了子目录/admin下,那么index.html会去请求/assets/index-abc123.js,而这个文件实际在/admin/assets/index-abc123.js。结果是:页面能打开、标题正常、但一片空白,F12 里一堆 404。这就是最常见的白屏。
解决方式是在打包配置里把 base 设对。第三章会给出 Vite 和 vue-cli 两种写法。这里先建立一个直觉:子路径部署时,base 的结尾必须有斜杠,而且要和 IIS 里的目录名严格一致(包括大小写)。我见过太多次因为 IIS 里建的是Admin、base 写的是/admin/而导致的诡异问题——Windows 文件系统本身不区分大小写,但浏览器拼接 URL 和某些前端库的内部匹配是区分的。
2. 打包这一步:Vite 与 vue-cli 的配置差异与取舍
很多教程直接从"IIS 里新建网站"开始讲,把打包当成一句话带过,结果读者卡在上传什么、传到哪儿、为什么路径不对。我把打包这一段单拎出来讲透,因为部署期的绝大多数问题,都是打包期埋下的。
2.1 base 与 publicPath:一个参数决定整条路径链
Vite 项目在vite.config.js里配:
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import path from 'node:path' export default defineConfig({ // 根路径部署写 '/',子路径部署写 '/admin/' base: '/admin/', plugins: [vue()], resolve: { alias: { '@': path.resolve(__dirname, 'src') } }, build: { outDir: 'dist', assetsDir: 'assets', sourcemap: false, // 生产环境务必关掉 chunkSizeWarningLimit: 1500, rollupOptions: { output: { // 把大依赖拆开,减少单文件体积 manualChunks: { vue: ['vue', 'vue-router', 'pinia'], ui: ['element-plus'] } } } } })vue-cli(webpack)项目在vue.config.js里配:
const { defineConfig } = require('@vue/cli-service') module.exports = defineConfig({ // 注意这里叫 publicPath,5.0 之前叫 baseUrl publicPath: process.env.NODE_ENV === 'production' ? '/admin/' : '/', outputDir: 'dist', assetsDir: 'static', productionSourceMap: false, devServer: { proxy: { '/api': { target: 'http://127.0.0.1:8080', changeOrigin: true } } } })注意:
publicPath用来解决的是"静态资源从哪儿加载",它管不了"前端路由的跳转"。history 模式下如果你部署在子路径,还要给路由指定 base,也就是上面代码里的createWebHistory('/admin/'),或者用import.meta.env.BASE_URL让它自动跟随。这两处不一致,就会出现"首页正常、点导航跳到了根路径"的现象。
2.2 sourcemap 与拆包:不只是体积问题
sourcemap: false这个开关,新手容易忽略。它不是纯粹为了省几 MB 空间——sourcemap 文件会把你的源码结构、注释甚至部分业务逻辑暴露在公网可访问的.map文件里。内网环境风险低一些,但公网站点关掉它应该是默认动作。如果确实需要线上排查,也建议把 map 文件单独放、只对特定 IP 开放。
拆包(manualChunks)对 IIS 部署有额外的意义:IIS 静态压缩默认对超过一定大小的文件不做压缩,或者压缩开销很大。把 2MB 的vendor.js拆成几个 300~500KB 的块,压缩效果和首屏加载体验都会明显好一些。我实测过一个后台项目,拆包后 gzip 前的总体积没变,但 gzip 后的传输量少了将近 20%,因为小文件里有更多重复字符串模式能被字典命中。
2.3 打包产物长什么样,你该上传哪些东西
执行npm run build之后,dist目录内容大致是这样:
| 文件/目录 | 说明 | 是否上传 |
|---|---|---|
index.html | 入口页,引用所有哈希化资源 | 必须 |
assets/ | 打包后的 JS/CSS/字体/图片 | 必须 |
favicon.ico | 站点图标,通常放public/下 | 必须 |
robots.txt | 若public/下存在 | 按需 |
*.map | sourcemap | 不上传 |
只上传dist里的内容,不要把dist这个文件夹本身再套一层。这是另一个高频翻车点:物理路径配的是D:\sites\admin,结果文件实际在D:\sites\admin\dist\index.html,IIS 找不到默认文档,直接 403.14。我一般的做法是本地打完包,把dist里的文件压缩后拷到服务器上一个临时目录,解压后核对一眼index.html是否在物理路径的第一层,然后再覆盖正式目录。
2.4 打包前值得顺手检查的几项
在打包命令敲下去之前,我会习惯性过一遍这几项,能省掉后面不少来回:
package.json里的scripts.build是否带了--mode production,确保走的是生产环境变量文件.env.production;- 生产环境的 API 地址是否已经指向真实域名或相对路径
/api(相对路径更适合 IIS 反向代理或同站点后端); - 路由 base 与
base/publicPath是否一致; - 是否有硬编码在代码里的
localhost:8080; - 是否残留了
console.log大对象的调试代码(大数据量打印会拖慢生产环境)。
3. IIS 侧配置:从建站到 URL Rewrite 的完整落地
前面都是准备工作,这一章开始动服务器。我按实际操作的顺序写,每一步都会说明为什么这么做,而不是只给点击路径。
3.1 站点、应用程序与应用程序池的关系
打开 IIS 管理器,左侧树形结构里"Sites"下面默认会有一个Default Web Site。我通常不直接在它上面部署,而是新建一个站点或应用程序,理由有两个:一是默认站点绑定着 80 端口,容易和已有系统冲突;二是独立站点/应用程序有独立的应用池,重启一个不影响另一个。
这里有个容易被忽略的决策:子路径部署时,用"添加应用程序"还是"添加虚拟目录"。
- 虚拟目录:共享父站点的应用程序池和大部分配置,它更像是父站点下的一个文件夹映射;
- 应用程序:有自己独立的应用程序池、独立的配置边界(可以有自己的 web.config 而不继承父站点的某些设置)。
对于一个 SPA 前端,我强烈建议用应用程序。原因很实在:SPA 需要自己的 URL Rewrite 回退规则,而这条规则如果写在虚拟目录里,会继承父站点的规则链,可能出现父站点规则先命中、导致回退失效的情况;另外应用池独立之后,将来要重启它也不会影响同服务器上的其他站点。
关键配置对照表:
| 配置项 | 建议值 | 说明 |
|---|---|---|
| 应用程序池 .NET CLR 版本 | 无托管代码 | 纯静态前端不需要 .NET 运行时 |
| 托管管道模式 | 集成 | 配合 URL Rewrite 使用更自然 |
| 物理路径 | D:\sites\admin(示例) | 里面直接是index.html和assets |
| 默认文档 | index.html | 必须加,否则访问目录得到 403.14 |
| 绑定 | 端口 80/443 + 主机名 | 443 需绑定证书 |
| 预加载已启用 | True(可选) | 减少首次访问冷启动 |
提示:默认文档里如果
index.html排在Default.htm、iisstart.htm之后,而目录里恰好有同名文件,IIS 会优先返回那个。稳妥做法是把index.html移到列表最上面,或者干脆删掉用不上的默认项。
3.2 URL Rewrite 模块与 web.config 的完整写法
URL Rewrite 不是 IIS 自带模块,需要单独安装URL Rewrite Module 2.1(微软官方提供)。判断装没装的方法很简单:IIS 管理器里选中站点,中间功能区如果没有"URL 重写"图标,就是没装。没有这个模块的情况下,你粘一个含<rewrite>节点的 web.config 进站点根目录,整个站点会直接返回 HTTP 500.19,错误代码 0x8007000d,页面完全打不开。这个现象的诡异之处在于:你明明只是加了个配置文件,结果原本能访问的静态页也挂了,很多人会误以为是权限问题。
模块装好之后,在站点根目录放一个web.config:
<?xml version="1.0" encoding="UTF-8"?> <configuration> <system.webServer> <!-- 1. 前端路由回退:找不到文件的请求统统交给 index.html --> <rewrite> <rules> <rule name="Vue History Fallback" stopProcessing="true"> <match url=".*" /> <conditions logicalGrouping="MatchAll"> <add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" /> <add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" /> <!-- 排除接口路径,避免把 API 请求也回退成页面 --> <add input="{REQUEST_URI}" pattern="^/api/" negate="true" /> </conditions> <action type="Rewrite" url="/admin/index.html" /> </rule> </rules> </rewrite> <!-- 2. 补一些旧系统缺失的 MIME 映射 --> <staticContent> <remove fileExtension=".woff2" /> <mimeMap fileExtension=".woff2" mimeType="font/woff2" /> <remove fileExtension=".json" /> <mimeMap fileExtension=".json" mimeType="application/json" /> </staticContent> <!-- 3. 一些基础安全响应头 --> <httpProtocol> <customHeaders> <add name="X-Content-Type-Options" value="nosniff" /> <remove name="X-Powered-By" /> </customHeaders> </httpProtocol> </system.webServer> </configuration>几个要点值得展开说。stopProcessing="true"表示这条规则命中后就不再往下匹配后续规则,避免和其他规则打架。negate="true"是"取反",意思是"当请求的路径不是真实存在的文件、也不是真实存在的目录时"才回退——真实存在的assets/index-abc.js会正常返回,不会被回退干扰。action里的 URL 要写相对于站点根的路径,如果你用的是独立站点部署到根路径,就写/index.html;如果挂在应用/admin下,写/admin/index.html,或者用相对写法index.html也可以,但绝对路径更不容易出错。
3.3 应用程序池权限与那类 0x80005000 报错
站点建好、文件放好、规则配好,访问还是 500 或 403,大概率是权限。IIS 的应用程序池默认以IIS AppPool\<池名>这个虚拟账号身份读取文件。如果你把文件放在D:\这种非系统盘,或者手动改过目录 ACL,很容易出现"应用池标识没有读取权限"。
标准的解决方式有两种。一种是图形界面:右键站点 → 管理网站 → 高级设置 → 记下应用程序池名,然后在文件资源管理器里给物理目录添加IIS AppPool\你的池名的读取和执行权限。另一种我更常用——命令行,批量且可脚本化:
icacls "D:\sites\admin" /grant "IIS AppPool\AdminPool:(OI)(CI)(RX)" /T /C(OI)(CI)表示对象继承和容器继承,RX是读取和执行,/T递归子目录,/C是遇到错误继续。这条命令在批量部署多套环境时特别省事。
至于"应用程序池权限设置失败,请手动为其设置 LocalSystem 权限,未知错误 0x80005000"这类报错,我在几台服务器上都遇到过,根因基本集中在三种情况:
- 物理路径根本不存在或不完整,IIS 想给一个不存在的目录设 ACL,直接失败;
- 服务器在域环境里,IIS 试图解析账号的 SID 时和域控通信异常,报出来的就是 0x80005000;
- IIS 管理器不是以管理员权限运行,改 ACL 的动作被系统拒绝,但错误信息被包装成了权限设置失败。
我的处理顺序是:先在命令行里确认路径存在(dir D:\sites\admin),再用上面那条icacls手动授权,最后重启应用池。不要照搬错误提示去给目录加 LocalSystem 权限——那等于把整个目录敞开给系统最高权限,Windows 上的提权风险会明显上升,而且它根本不是正确的修法。
3.4 压缩与缓存:让传输量真正降下来
IIS 的静态压缩默认是关闭的,需要先在"服务器角色 → Web 服务器 → 性能"里勾选"静态内容压缩",装完之后在站点或服务器级别的"压缩"功能里启用。启用后,IIS 会对text/html、text/css、application/javascript等 MIME 类型做 gzip。
这里有个特别容易踩的坑:Vite 打包可以生成.br(Brotli)和.gz预压缩文件,但 IIS 原生不认这些预压缩产物,它只会自己实时压缩。所以你带着一堆.js.br上传上去,IIS 依然会传原始.js,白白占了磁盘。想要 Brotli,要么给 IIS 装第三方压缩模块,要么干脆放弃预压缩,交给 IIS 的 gzip 处理——对绝大多数后台系统来说,gzip 已经完全够用。
另一个坑是静态压缩的 MIME 白名单。IIS 的applicationHost.config里维护着staticCompression的 MIME 列表,如果某个类型不在里面,就不会被压缩。旧的 IIS 版本里.js的映射有时是text/javascript有时是application/javascript,两边对不上就不压缩。排查方法很直接:F12 看响应头有没有Content-Encoding: gzip。
4. 上线后高频故障的排查链路
这一章我按"现象 → 排查动作 → 根因"的方式来写,你可以直接照着走一遍。这也是我觉得最有价值的部分,因为这些现象在教程里很少被系统整理,但实际部署时几乎必然会撞上至少一个。
4.1 页面标题正常但一片空白:路径前缀错位
这是最经典的。判断方法:按 F12 打开 Network 面板,刷新页面,看index.html本身是不是 200。如果它是 200,但下面一堆 JS/CSS 全是 404,那基本可以确定是资源路径错了。
这时看 404 的请求 URL。如果请求的是/assets/xxx.js,但你实际部署在/admin/assets/xxx.js,说明打包时base/publicPath没设成/admin/。反过来,请求的是/admin/assets/xxx.js但你的文件在/assets/,说明 base 设多了。改配置、重新打包、重新上传即可。
还有一种更隐蔽的情况:index.html是 200,资源也全是 200,但页面还是白的,控制台报Uncaught SyntaxError: Unexpected token '<'。这通常意味着某个 JS 请求实际返回的是 HTML 内容——也就是你的回退规则把 JS 请求也回退了。回头检查matchType="IsFile"那个条件有没有写对,或者物理路径下这个文件是否真的存在。
4.2 首页正常、刷新就 404:history 模式缺少回退
前面说过原理了,这里补充排查动作。先确认路由模式:createWebHashHistory还是createWebHistory。hash 模式出现这个问题,说明你在某处错误地改成了 history;history 模式出现这个问题,就去检查站点根目录有没有web.config、URL Rewrite 模块装没装、规则里的url路径对不对。
有一个容易忽略的变体:根路径访问正常,带参数或带多级路径就 404。比如/user/detail/123能打开,/user/detail打不开。这种情况下多半是规则里加了精确匹配或者正则限制,把部分路径排除在外了。用<match url=".*" />全匹配、只靠条件过滤,是最省心的写法。
4.3 接口报 404/405:前后端边界没划清
前端页面跑起来了,登录接口报 404,这种问题的排查思路和纯静态问题完全不同,得分清是"请求根本没到后端"还是"到了后端被拒"。
先在 F12 里看请求的实际 URL 和状态码。如果是 404 且响应体是一段 HTML,说明请求被回退规则吞掉、返回了index.html。解决办法是在回退规则里显式排除接口前缀,也就是前面web.config里那条pattern="^/api/" negate="true"。这里的/api/必须和你前端里 axios 的baseURL完全一致。
如果是 405,通常是方法不被允许。IIS 的 WebDAV 模块有时候会拦截PUT、DELETE、PROPFIND这类方法,返回 405。处理方式是在web.config里把 WebDAV 模块移除:
<system.webServer> <modules> <remove name="WebDAVModule" /> </modules> <handlers> <remove name="WebDAV" /> </handlers> </system.webServer>如果是跨域报错,那说明前后端不在同一个源上。最稳的方案是把后端也挂到同一个 IIS 站点下(比如/api走反向代理),这样根本没有跨域问题。次选是在后端或者 IIS 层加 CORS 响应头,但要注意Access-Control-Allow-Origin和Allow-Credentials不能同时用通配符。
4.4 400/500 类报错:从 IIS 日志反推
有些问题前端看不出来,得回到服务器上看日志。IIS 日志默认在%SystemDrive%\inetpub\logs\LogFiles\W3SVC<站点ID>下,按天切分。日志里能直接看到请求的 URL、状态码、子状态码和耗时。
几个常见状态码的含义:
| 状态码 | 子状态码 | 常见含义 |
|---|---|---|
| 403 | 14 | 目录列表被拒,通常是没配默认文档 |
| 404 | 3 | MIME 类型未映射,比如.woff2缺失 |
| 404 | 4 | 处理程序未映射,通常出现在动态请求上 |
| 500 | 19 | web.config 配置错误,常见是模块未安装 |
| 500 | 30 | URL Rewrite 规则内部错误 |
| 502 | 3 | 反向代理时后端不可达 |
我一般会先用appcmd确认站点状态和配置有没有语法问题:
%windir%\system32\inetsrv\appcmd list site %windir%\system32\inetsrv\appcmd list apppool改动多了之后,配置容易越改越乱,这时候上线前做一次配置备份能救命:
%windir%\system32\inetsrv\appcmd list site /config /xml > d:\backup\sites-20240101.xml %windir%\system32\inetsrv\appcmd list apppool /config /xml > d:\backup\apppools-20240101.xml出问题时对照备份文件比对比盲改快得多,尤其是接手别人维护的服务器时。
5. 缓存策略与上线流程:让部署这件事可重复
文件放上去能跑,只是第一次成功。真正让部署这件事变得轻松的,是可重复、可回滚、不会因为缓存导致用户看到旧页面。
5.1 index.html 绝不能被强缓存
Vue 打包的 JS/CSS 文件名带内容哈希,文件内容一变文件名就变,所以它们可以设很长的缓存时间——一年都没问题,这是行业惯例。但index.html是个例外:它引用的哈希文件名每次发版都会变,如果它被用户浏览器缓存住了,用户就会拿着旧 HTML 去请求已经删掉的旧 JS,结果就是白屏。
IIS 的clientCache是针对静态内容的统一设置,如果粗暴地设成一年,.html也会被一起缓存。所以需要给index.html单独指定策略:
<location path="index.html"> <system.webServer> <httpProtocol> <customHeaders> <add name="Cache-Control" value="no-cache, no-store, must-revalidate" /> <add name="Pragma" value="no-cache" /> </customHeaders> </httpProtocol> </system.webServer> </location>要对assets目录设长缓存,则在web.config根级别加:
<staticContent> <clientCache cacheControlMode="UseMaxAge" cacheControlMaxAge="365.00:00:00" /> </staticContent>注意:
<location>节点如果在站点 web.config 里报"此配置节不能在此路径中使用",说明该节被锁定了,需要在服务器级别用appcmd unlock config解锁,或者把 index.html 换一种方式处理。改之前先备份配置,别在没备份的情况下动 applicationHost.config。
5.2 到底该让 IIS 做反向代理,还是交给别的组件
不少项目是前端挂在 IIS、后端跑在另一台机器上。这时候有两种做法:让 IIS 用 URL Rewrite 的Rewrite动作把/api转发到后端(需要装 Application Request Routing 模块),或者让前端直接请求后端的完整域名。
我的经验是:如果后端就在同一台机器上、同一个内网里,用 IIS 反向代理最省事,因为前端代码里写相对路径/api就行,开发、测试、生产三套环境不用改代码,也不用处理跨域和证书问题。如果后端在别的网络区域或者性能要求高,那还是让专业的网关组件去做,IIS 在这个位置更多是过渡方案。
用 ARR 做反向代理时,记得开启代理功能(在 ARR 的 Server Proxy Settings 里勾选 Enable proxy),否则规则写了也不生效——这个开关藏在服务器级别而不是站点级别,很多人找不到。
5.3 一份可以照着走的部署自检清单
最后给一份我常用的检查清单,发版前照着过一遍,能挡掉九成以上的问题:
- 打包配置里的
base/publicPath与部署路径一致; - 路由 base 与打包 base 一致;
dist里的内容直接位于物理路径第一层,没有多套一层目录;- 默认文档里有
index.html,且排在第一; - URL Rewrite 模块已安装,
web.config无语法错误; - 回退规则排除了
/api前缀; - 应用程序池标识对物理目录有读取和执行权限;
index.html已设置不缓存,assets已设置长缓存;- 静态压缩已启用,响应头里能看到
Content-Encoding; - 关闭 sourcemap,删除调试代码和测试环境地址;
- 部署前已用
appcmd导出配置备份; - 手动在浏览器里对 3~5 个深层路由做一次刷新测试。
5.4 子路径部署时容易忽略的两个细节
子路径部署除了 base,还有两个地方会出问题,我踩过不止一次。
一是Cookie 的 path。如果登录态是存在 Cookie 里的,而 Cookie 的 path 设成了/,那它会在整个域名下可见,正常;但如果后端设置时带了具体路径,或者你在子路径下发现登录态丢失,就得去检查这条 Cookie 的 path 是不是覆盖了/admin。
二是在CSS 里通过url()引用的背景图或字体。这类资源在构建时会被 Vite 处理成带 base 的绝对路径,一般不会有问题;但如果代码里写了url('/images/bg.png')这种以/开头的硬编码路径,构建工具不会帮你加 base,浏览器会去根路径找,自然 404。这种问题在本地开发时因为根路径恰好是对的,完全看不出来,一上生产就暴露。排查方式很简单:全局搜一遍url('/和src="/,改成相对路径或者用import引入。
我自己从 Nginx 转到 IIS 部署前端的过程中,最深的体会是:Nginx 那套try_files $uri $uri/ /index.html一行搞定的事情,在 IIS 上被拆成了"装模块 + 写 XML + 处理权限 + 处理配置锁定"四步,学习曲线确实更陡。但反过来,IIS 的图形化界面在排查权限和绑定问题时也更直观,日志和配置导出机制做得相当规范。如果你后续还要在这台机器上部署其他前端项目,建议把调好的web.config做成模板存下来,改一下回退路径就能复用,第二次部署的时间能压缩到十分钟以内。