Web页面调用本地Windows客户端:URL协议与回环桥接实践
简介:这份压缩包围绕 Web 页面调用 Windows 本地应用程序这一典型混合应用场景,面向需要实现网页与桌面端交互的前端、全栈及 Windows 开发学习者,帮助理解浏览器环境与本地程序之间的通信边界与可行方案。内容涉及 ActiveX、自定义 URL 协议、Web API 桌面桥接等思路,并配有可运行的小型示例,便于对照实验。包内共 4 个文件,以 exe 可执行程序、html 网页、reg 注册表脚本和 txt 说明文档为主,压缩包约 189KB,体积轻便,适合快速解压查看与本地验证。已有 454 人学习,说明该主题在混合应用开发中具有持续需求。读者可通过示例观察网页如何触发本地程序、如何完成协议注册与基础配置,并借助说明文档梳理调用流程和排错要点,为后续在 Electron、WebView2 或自定义协议方案中选型提供直观参考。
1. 一个「点按钮拉起客户端」的需求,能拆出多少东西
网页上放一个按钮,点下去要拉起用户电脑上已经装好的 Windows 客户端,还得把页面上选中的单据号一起带过去,客户端处理完再把结果回写到页面。这类 web 调用本地应用的需求在内部系统里出现频率极高,但浏览器沙箱天然不允许页面随便起进程,所以真正能走通的路径就那么几条。
这个包给的是两条主线的完整示例:一条用注册表注册自定义 URL 协议把 exe 唤起,一条在 127.0.0.1 上起一个回环桥接服务做双向通信,附带前端调用封装和几个常见失败场景的处理。适合手上正卡在「点了没反应」这一步的前端,也适合要给桌面客户端做 web 入口的 Windows 端开发。
2. 路线选型:URL 协议、本地桥接、原生通道到底怎么挑
动手之前先想清楚一件事:你需要的是单向通知,还是双向问答。这个判断直接决定了后面代码量差一个数量级。单向的意思是页面告诉客户端「打开这篇单据」,说完就不管了;双向的意思是页面还要拿到客户端返回的结果,比如渲染耗时、导出文件路径、校验失败原因。前者用自定义协议就够,后者基本只能上本地桥接服务。
除了这两条,还有浏览器扩展的原生消息通道和把网页塞进桌面壳两条路。原生消息通道要求用户装扩展、注册 host 清单,受众太窄;桌面壳适合前端完全由自己控制、能把 web 打包成客户端的场景。只能留在浏览器里、用户环境不可控的情况下,前两条才是主战场。
2.1 自定义 URL Scheme:注册表里那几条键值到底在说什么
自定义协议的本质是往注册表写一个映射,告诉 Windows「看到localbridge://开头的地址,就去启动这个 exe,并把完整地址当参数传给它」。浏览器侧只要执行一句location.href = 'localbridge://open?docId=A1024',剩下的事系统会接管。
写的位置有讲究。HKEY_CLASSES_ROOT看着最直观,但它是个合并视图,写入需要管理员权限,卸载时也容易残留。更稳的是写在HKEY_CURRENT_USER\Software\Classes下,当前用户级别生效,普通权限就能改,卸载干净不留尾巴。这个包里的注册脚本走的就是 HKCU 这条路。
协议名别用太通用的词。曾经有人图省事注册成app://,结果和用户机器上另一个软件撞了,谁后装谁覆盖,调起来玄学得很。协议名带上自己的产品前缀,冲突概率会低很多。
注意:注册表里的
URL Protocol键必须存在,值可以是空字符串,但不能没有。缺了它,浏览器压根不会把这个 scheme 当成可唤起的外部协议,点了就是静默失败,连确认框都不弹。
2.2 本地桥接服务:为什么生产项目最后大多选它
桥接的思路很朴素:客户端随系统启动时,在127.0.0.1的某个端口上挂一个 HTTP 或 WebSocket 服务,页面直接fetch过去。因为是回环地址,不经过外网,也不受跨域策略的普通限制影响——只要服务端乖乖回 CORS 头。
它比协议唤起强在三点。第一,有返回值,页面能拿到结构化 JSON,不用靠临时文件或再开一个页面回传。第二,参数长度不受 URL 长度限制,传个几千字的 JSON 都没问题。第三,连接是持久的,WebSocket 模式下客户端可以主动推消息给页面,比如「导出进度 60%」。
代价是三个必须处理的问题:端口占用、跨站调用鉴权、新版本浏览器的私有网络访问限制。端口占用好理解,固定端口被别的程序占了服务就起不来,得准备候选端口列表。跨站调用鉴权是最容易被忽略的——你起了一个监听 127.0.0.1 的服务,意味着用户浏览器里打开的任何网页理论上都能请求它,如果不校验 Origin、不发令牌,一个恶意页面就能让你的客户端执行任意动作。私有网络访问限制是较新版本 Chromium 系浏览器加的门槛,从公网页面请求回环地址会先发一个预检,服务端不回Access-Control-Allow-Private-Network: true就直接被拦。
2.3 两条路的对比与组合姿势
| 维度 | 自定义 URL 协议 | 本地回环桥接 |
|---|---|---|
| 通信方向 | 单向,页面到客户端 | 双向,可请求可推送 |
| 返回值 | 不支持 | 支持 JSON |
| 参数长度 | 受 URL 长度限制,实际几百字符内稳妥 | 基本无限制 |
| 部署成本 | 写注册表,一个 .reg 文件 | 常驻进程 + 端口 + 鉴权 |
| 权限要求 | HKCU 下无需管理员 | 无需管理员 |
| 用户感知 | 每次弹「是否打开」确认框 | 首次配对后可静默 |
| 安全风险 | 协议被其他程序抢注 | 任意网页可访问,需鉴权 |
| 适合场景 | 唤起、跳转、打开文件 | 取数据、长任务、状态同步 |
实际项目里我一般两个都留:页面加载时先探活桥接服务,探到就走 fetch,探不到就降级用协议唤起客户端,唤起后客户端自己起服务,再刷新页面重试。用户第一次用会看到一次确认框,之后就走静默通道了。这个降级链路是这类需求最省心的组合方式。
3. 工程结构:从点击一个链接到 exe 真正跑起来
拿到包先别急着双击,把目录过一遍,知道哪块归谁管,改起来才不会牵一发动全身。这类项目通常按职责切成宿主程序、示例前端、协议注册脚本、构建脚本四块,边界清楚。
3.1 目录分层与各目录职责
web-local-bridge/ ├─ host/ # 本地宿主程序,负责承载桥接服务和业务动作 │ ├─ LocalBridge.sln │ ├─ Program.cs # 入口:区分协议唤起和托盘常驻两种启动方式 │ ├─ BridgeServer.cs # 回环 HTTP 服务,路由与鉴权 │ ├─ ProtocolRegistrar.cs # 注册/卸载自定义协议的代码实现 │ ├─ ActionRouter.cs # 动作白名单与分发 │ └─ app.manifest # 声明权限级别,避免被虚拟化重定向 ├─ web/ # 示例前端,可替换成你自己的页面 │ ├─ index.html │ ├─ call-local.js # 前端调用封装,探活 + 降级 + 超时 │ └─ pair.html # 首次配对页,拿令牌用 ├─ protocol/ │ └─ register-protocol.reg # 注册表脚本,双击导入即可 ├─ tools/ │ └─ build.ps1 # 打包脚本,输出 exe 和 .reg └─ README.mdhost下面Program.cs的分支逻辑是整个项目的枢纽。带参数启动说明是被浏览器协议唤起的,走一次性处理然后退出;不带参数说明是用户双击或者开机自启,走托盘常驻 + 起服务。这两个模式必须分清楚,混在一起会出现「唤起一次多一个托盘图标」的经典翻车。
web/pair.html这个文件容易被当成示例页忽略,其实它是鉴权链路的一环。桥接服务第一次起来时,随机生成一个令牌,通过打开配对页把令牌交给前端,用户点一下「允许」之后令牌存进localStorage,后续请求都带上它。没有这一步,服务端就只能靠 Origin 单点防御,安全性差一截。
3.2 一次完整调用的时序,不用画图也能讲清
用户点击按钮,前端先做一次探活请求,超时设短一点,500 毫秒,直接打/ping。这一步的目的不是拿数据,是判断客户端有没有在跑。
探活成功,前端把动作名、参数、请求 ID 打包 POST 到/invoke,带上令牌头。服务端校验 Origin 白名单,校验令牌,查动作白名单,命中后执行,把结果连同请求 ID 一起返回。请求 ID 的作用是把并发请求的响应配对回去,别小看这个字段,同时点两个单据的时候没有它就是一团乱麻。
探活失败,前端降级,把动作和参数拼成localbridge://open?action=xxx&docId=xxx挂到location.href上。宿主程序被系统唤起,解析命令行里的完整 URL,拆出参数,执行动作。这时候页面已经跳走或者停在原地了,结果没法直接回传,宿主处理完可以往本地写一个结果文件,再打开一个页面把它读出来,或者干脆只做「唤起后用户自己看客户端」这种不要求回传的交互。
宿主处理完可选地再拉起桥接服务,这样用户下一次点击就能走静默通道。这条「唤起后自愈」的路径在生产里非常实用,能避免用户每次都要看确认框。
3.3 把最小可运行版本跑起来
前置条件很简单:装了 .NET 运行时或者直接用包里的 exe,浏览器随便一个现代的就行。步骤按顺序来,别跳。
# 1. 解压后先确认宿主程序能被正常执行,不带参数会走托盘模式 ./host/LocalBridge.exe --version # 2. 导入注册表脚本,注意路径里的 exe 位置要和实际解压位置一致 reg import protocol/register-protocol.reg # 3. 验证协议是否注册成功,能看到 shell\open\command 就算对了 reg query "HKCU\Software\Classes\localbridge\shell\open\command" # 4. 起一个静态文件服务托管示例前端,避免 file:// 协议下的各种限制 cd web && python -m http.server 5173 # 5. 浏览器访问 http://localhost:5173,点页面上的测试按钮第 2 步是新手最容易出问题的地方。.reg文件里写的是绝对路径,你换个目录解压,注册表指向的就是一个不存在的 exe,浏览器点击后系统找不到程序,表现得跟没注册一样。改路径有两种办法:一是用文本编辑器打开.reg手动替换,二是直接调宿主程序自带的注册接口,让它用Assembly.GetExecutingAssembly().Location拿到自己的真实路径写进去,后者更靠谱,也是我在正式项目里一律采用的做法。
第 4 步用file://也能打开页面,但localStorage在部分浏览器下对本地文件的行为不一致,配对令牌可能存不住,调试时会误以为是鉴权代码写错了。老老实实起个本地静态服务,省掉这一整类问题。
4. 核心模块拆解:三段代码撑起整个调用链
这个项目的代码量不大,真正需要吃透的只有三块:注册表怎么写、桥接服务怎么防、前端怎么调。下面逐段过。
4.1 协议注册与卸载:一行写错就是静默失败
Windows Registry Editor Version 5.00 ; 写在 HKCU 下,普通用户权限即可生效,卸载时删掉对应分支就干净了 [HKEY_CURRENT_USER\Software\Classes\localbridge] @="URL:LocalBridge Protocol" "URL Protocol"="" ; 图标可以省略,但加上之后浏览器确认框里会显示程序图标,用户更敢点 [HKEY_CURRENT_USER\Software\Classes\localbridge\DefaultIcon] @="\"C:\\Tools\\LocalBridge\\LocalBridge.exe\",0" [HKEY_CURRENT_USER\Software\Classes\localbridge\shell] [HKEY_CURRENT_USER\Software\Classes\localbridge\shell\open] ; %1 会被系统替换成浏览器传过来的完整 URL,引号必须包住,否则路径带空格就截断 [HKEY_CURRENT_USER\Software\Classes\localbridge\shell\open\command] @="\"C:\\Tools\\LocalBridge\\LocalBridge.exe\" \"%1\""这段脚本的关键就三个点。URL Protocol是空值但必须存在,它标记这个分支是个协议处理器而不是普通文件关联。shell\open\command的默认值里,exe 路径和%1都要用转义的引号包起来,因为 Windows 的路径里出现空格是常态,不包引号的话命令行会在第一个空格处被切断,宿主收到的参数只剩半截。反斜杠在.reg文件里要写成双反斜杠,这是文件格式要求,不是笔误。
注册之后建议立刻验证一次,reg query看输出,或者直接在浏览器地址栏敲localbridge://open?docId=1回车,能弹出确认框就说明注册成功。地址栏敲不行但页面里点可以,那问题多半出在页面的 URL 拼接上,不是注册表。
卸载对应的是删分支:reg delete "HKCU\Software\Classes\localbridge" /f。打包安装程序时记得把这条写进卸载流程,不然用户卸了软件协议还留着,下次点链接会去唤起一个不存在的 exe。
4.2 桥接服务:端口、令牌、Origin 三件套一个都不能少
// bridge-server.js —— 回环桥接服务的最小实现,监听地址写死 127.0.0.1 const http = require('http'); const crypto = require('crypto'); const { execFile } = require('child_process'); const PORT = 18730; // 每次启动重新生成,避免长期固定的令牌被页面缓存后一直有效 const TOKEN = crypto.randomBytes(16).toString('hex'); // 只放行你自己的站点,别图省事写 '*' const ALLOWED_ORIGINS = new Set(['http://localhost:5173', 'https://your-web.example']); const server = http.createServer((req, res) => { const origin = req.headers.origin || ''; // 1. Origin 白名单,挡住用户浏览器里其他标签页的恶意请求 if (!ALLOWED_ORIGINS.has(origin)) { res.writeHead(403); return res.end(JSON.stringify({ ok: false, msg: 'origin not allowed' })); } res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Access-Control-Allow-Headers', 'content-type,x-bridge-token'); res.setHeader('Access-Control-Allow-Private-Network', 'true'); // 2. 预检请求要回 204,并且把头带全,缺一个浏览器就直接判定失败 if (req.method === 'OPTIONS') { res.writeHead(204); return res.end(); } // 3. 令牌校验,挡住猜端口的扫描脚本 if (req.headers['x-bridge-token'] !== TOKEN) { res.writeHead(401); return res.end(JSON.stringify({ ok: false, msg: 'bad token' })); } if (req.url === '/ping') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ ok: true, pid: process.pid })); } // 4. 只允许白名单动作,绝不把 action 直接拼进命令行 if (req.url === '/invoke' && req.method === 'POST') { let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { const { action, params } = JSON.parse(body || '{}'); if (!['render', 'export'].includes(action)) { res.writeHead(400); return res.end(JSON.stringify({ ok: false, msg: 'unknown action' })); } // execFile 传的是参数数组,不走 shell,天然规避命令注入 execFile('C:\\Tools\\LocalBridge\\worker.exe', [action, String(params.docId || '')], { timeout: 10000 }, (err, stdout) => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ ok: !err, data: stdout, error: err ? err.message : null })); }); }); return; } res.writeHead(404); res.end(); }); // 监听 127.0.0.1 而不是 0.0.0.0,否则局域网内其他机器也能打进来 server.listen(PORT, '127.0.0.1', () => { console.log(`bridge listening on 127.0.0.1:${PORT}`); });参数逐个说明。PORT固定是 18730,选它是因为在常见软件占用的端口区间之外,冲突概率低,但正式项目还是要在启动时做一次占用检测,被占就顺延到候选列表里的下一个,并把实际端口通过配对页告诉前端。TOKEN用crypto.randomBytes生成而不是写死,横竖只多一行代码,能挡掉一类「令牌泄露后长期有效」的风险。ALLOWED_ORIGINS千万别用通配符,一旦写成*,上面那套鉴权等于作废。listen的第二个参数写'127.0.0.1',如果不写,Node 默认监听所有网卡,同一个办公网里的其他人就能直接访问你机器上的这个服务。
执行动作那段用execFile而不是exec,因为前者不经过 shell,参数以数组形式传递,params.docId里就算塞了&&或者分号也只是一段普通字符串。用exec拼接字符串的写法,就是给命令注入留门。
4.3 前端封装:探活、超时、降级三件事做在一个函数里
// call-local.js —— 页面侧统一调用入口,外部只需要关心动作名和参数 const BRIDGE = 'http://127.0.0.1:18730'; const PROTOCOL = 'localbridge'; function getToken() { // 令牌由配对流程写入,没有就说明用户没走过首次授权 return localStorage.getItem('bridge_token') || ''; } async function pingBridge(timeout = 500) { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), timeout); try { const resp = await fetch(`${BRIDGE}/ping`, { signal: ctrl.signal }); return resp.ok; } catch (e) { return false; // 连接被拒、超时,都按「客户端没起来」处理 } finally { clearTimeout(timer); } } async function invoke(action, params, timeout = 3000) { // 第一段:探活,成功才走桥接,省掉一次注定失败的请求日志 if (await pingBridge()) { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), timeout); try { const resp = await fetch(`${BRIDGE}/invoke`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-bridge-token': getToken() }, body: JSON.stringify({ action, params }), signal: ctrl.signal, }); if (resp.status === 401) throw new Error('token expired'); return await resp.json(); } catch (e) { console.warn('bridge failed, fallback to protocol', e.message); } finally { clearTimeout(timer); } } // 第二段:降级到自定义协议,注意参数必须 encodeURIComponent const qs = new URLSearchParams({ action, ...params }).toString(); window.location.href = `${PROTOCOL}://open?${qs}`; return { ok: true, mode: 'protocol' }; }pingBridge的超时给到 500 毫秒是有意为之。客户端没启动时,对回环地址的连接会被系统立刻拒绝,基本是毫秒级返回,但某些环境下会挂住,给个短超时能防止用户点击后页面卡住不动。这个数值再小就不稳了,机器负载高的时候本地服务响应也可能慢一点。
invoke的返回值做了区分,走桥接时返回服务端真实结果,走协议时返回{ mode: 'protocol' },调用方可以根据mode决定要不要提示用户「已为你打开客户端,请在那里完成操作」。这种显式标记比让调用方自己猜要靠谱。
URLSearchParams那行是防中文和特殊字符的。单据号里带&或者中文的情况太常见了,不编码的话参数会在&处被腰斩,宿主收到的是一半的 ID,然后报一个「单据不存在」,排查半天才发现是编码问题。这也是血泪经验里最常见的一条。
5. 避坑与排查:本地调用最常见的五个翻车点
5.1 协议唤起类问题
现象:点了按钮完全没反应,浏览器也不弹确认框。原因通常是注册表分支写在了HKEY_LOCAL_MACHINE下但当前进程没有管理员权限,写入被系统静默重定向到了别的地方;或者shell\open\command的默认值里 exe 路径变了。解决方式是先reg query确认分支存在且路径正确,再在浏览器地址栏直接敲一次协议地址,能弹框就说明注册没问题,问题在页面拼接上。
现象:弹了确认框,点了「打开」之后什么也没发生。原因是shell\open\command里的路径带空格却没加引号,命令行被截断,系统尝试启动一个不存在的程序。解决是把 exe 路径和%1都用转义引号包住,改完重新reg import一次。改完记得重启浏览器,有些浏览器会缓存协议处理器的路径。
现象:用户机器上装了同类软件,协议被抢注,唤起的是别人的程序。原因是协议名起得太通用。解决是换一个带产品前缀的协议名,注册前先reg query看有没有同名分支,有就换一个。
5.2 桥接服务类问题
现象:fetch报 CORS 错误,或者控制台提示私有网络访问被阻止。原因有两个层面。浅层是服务端没回Access-Control-Allow-Origin,或者回的值和页面 Origin 不一致;深层是较新版本浏览器对公网页面访问回环地址有额外限制,预检请求里带了Access-Control-Request-Private-Network: true,服务端没回对应的允许头就被拒。解决是在 OPTIONS 分支里把Access-Control-Allow-Private-Network: true补上,并且确认Access-Control-Allow-Headers里包含了自定义的令牌头。
现象:服务明明起了,页面请求一直 pending 然后超时。原因可能是端口被别的程序占了,你的服务根本没监听成功,日志里打了一行错误就退出了;也可能是服务监听在0.0.0.0但被防火墙拦了回环以外的流量——这种情况反而说明监听配置有问题。解决是先netstat -ano | findstr 18730确认端口占用者是谁,再检查服务启动日志。端口冲突要在代码里做自动顺延,别指望用户自己去杀进程。
现象:任意网页都能调你的本地服务,被安全扫描标了高危。原因是 Origin 没校验或者写成了通配符,令牌也没做。解决是收窄 Origin 白名单、加令牌、把动作做成明确的白名单而不是把action直接透传给命令行。
5.3 参数与权限类问题
现象:传过去的单据号少了半截,或者中文变成乱码。原因是参数没编码就拼进 URL,遇到&、#、空格就断。解决是统一用URLSearchParams或者encodeURIComponent,宿主侧用标准 URL 解析器拆参数,别自己split('&')后再split('=')。
现象:宿主程序读不到自己目录下的配置文件。原因是程序清单里没声明权限级别,系统开了文件虚拟化,把写入重定向到了用户的虚拟存储目录。解决是在app.manifest里显式声明requestedExecutionLevel level="asInvoker",走标准用户权限,读写都在程序自己的目录内完成。
现象:开机自启之后第一次点击按钮总是失败,第二次就好了。原因是自启时服务还没起完,页面就发了请求。解决是宿主启动时先完成服务监听再返回,前端探活加重试,第一次失败后隔 300 毫秒再试一次,两次都失败才降级到协议。
6. 二次开发:把 Demo 改成能长期维护的调用框架
把这个包当起点用,最先要改的不是代码,是消息契约。示例里的请求是{action, params},简单够用,但一旦动作多起来(十几种导出、打印、预览格式),没有统一的响应结构会让前端代码迅速失控。
我一般会固定成四个字段:id做请求追踪,action做路由,params装业务参数,timeout由调用方指定而不是服务端写死。响应侧固定{id, ok, data, code, message},code用枚举值而不是给人看的文案,前端根据code决定提示什么,文案改动不用动服务端。这套结构看着啰嗦,但它让「客户端版本和页面版本不同步」这个必然发生的问题变得可处理——老版本客户端收到不认识的action时回一个明确的UNSUPPORTED_ACTION,页面提示用户升级客户端,而不是转圈到超时。
路由那段建议做成注册表形式,而不是一串if-else:
// ActionRouter.js —— 动作注册表,新增动作只加一行,不用改分发逻辑 const handlers = new Map(); function register(action, handler, { minVersion = '1.0.0' } = {}) { // 记录最低客户端版本,老客户端遇到不支持的动作会明确拒绝而不是报错崩溃 handlers.set(action, { handler, minVersion }); } register('render', async (params) => { // 具体业务逻辑,返回任意可序列化对象 return { file: `C:\\Temp\\${params.docId}.pdf` }; }, { minVersion: '1.1.0' }); async function dispatch(action, params) { const entry = handlers.get(action); if (!entry) return { code: 'UNSUPPORTED_ACTION', ok: false }; try { return { code: 'OK', ok: true, data: await entry.handler(params) }; } catch (e) { return { code: 'HANDLER_ERROR', ok: false, message: e.message }; } }register的第三个参数是我加的私货,用来标记这个动作从哪个客户端版本开始支持。配合握手时上报的版本号,页面可以在调用前就判断「这个用户的客户端太老」,直接给升级提示,而不是发起一次注定失败的请求。这个字段在实际运维里省掉的沟通成本,比它本身那几行代码值钱得多。
验证改造是否到位,有几个固定动作。用curl绕过浏览器直接打服务,带一个不在白名单里的 Origin,必须返回 403;带正确 Origin 但不带令牌,必须返回 401;动作名传一个不存在的值,必须返回UNSUPPORTED_ACTION而不是 500。这三个用例过了,说明鉴权和路由的边界是清楚的。前端侧再补两个:客户端没启动时点击按钮,必须在一秒内给出降级反馈而不是一直转圈;连续快速点五次,五次请求的id各不相同且能正确配对返回。
有一个改造点容易被忽略:把端口从常量改成「优先 18730,占用则顺延到 18731 到 18740」,并且把实际端口写到配对页的 URL 参数里。前端拿到之后存起来,下次直接按这个端口探活,探不到再从 18730 开始扫一遍。少了这段,用户装了两个同源系统就必然有一个用不了。
从那以后我每次接这类需求,都会先把「客户端没启动」「客户端版本太老」「用户拒绝了授权」这三种状态在页面上各准备一句人话文案,再动第一行代码。因为线上真正的故障报告几乎全落在这三种情况里,而它们跟代码写得好不好关系不大,只跟有没有提前想过有关。希望帮到你。
本文还有配套的精品资源,点击获取