资讯详情 轻量级前端部署脚手架deploy-cli:多环境一键发布与回滚实战
📅 2026/10/17 18:00:45
前端部署这事单独拎出来说多少人觉得也就是 build 完传个文件的事。可真到了一线你会发现麻烦远不止“传文件”这三个字测试环境的接口地址要切预发环境和线上环境目录不能串新同事第一次部署常常不知道该往哪传发布到一半如果发现构建产物不对还得回滚。这两年我们团队为了省事把部署脚本从一段 shell 干成了一个小工具最后整理成了命令行脚手架 deploy-cli核心能力就三块支持测试、预发、线上等多环境一键部署环境配置可以按项目按人扩展部署流程可以加构建前检查、构建后验证、上传后通知之类的自定义步骤。这篇文章我从自己实践的角度把 deploy-cli 是怎么设计、怎么写配置、踩过哪些坑一次说清楚。适合手里有一堆前端项目、又不想被重型 CI/CD 流程绑死的朋友参考。1. 为什么需要轻量级部署脚手架1.1 前端部署的常见痛点先说一个最典型的场景周五下午产品说“测试环境要提测帮我发一版”。你打开终端跑到项目目录先确认要发哪个分支然后手动改一串环境变量再执行npm run build打包完成后打开传输工具找到测试服务器某个目录小心翼翼地把 dist 上传覆盖。覆盖完心里发慌赶紧到测试地址点开几个页面确认正常后才算舒了口气。这套流程我做了三年几乎每次都会在某个环节犹豫一下这次到底用的哪个接口地址上次上传的是哪台机器手动部署最大的问题不是慢而是“不固定”。不同项目的构建命令不一样不同环境的服务器地址不一样不同同事的部署习惯也不一样。有人用传输工具有人用命令行 scp有人直接把整个项目传到服务器再在服务器上构建方式各不相同。结果是部署过程成了一个“只有少数老人会”的黑盒操作新人来了不敢碰老人忙起来也容易手滑。另一个隐患是“环境错位”。前端项目里最经典的线上事故就是线上页面请求了测试环境的接口或者测试环境误用了线上的存储配置。这种问题往往不是代码逻辑错误而是部署时环境变量没切干净。等到页面实际跑起来才发现用户数据已经写错地方了。应对这种事靠人肉盯着 env 字符串根本不现实必须把“环境”这个概念从代码里抽出来变成部署工具层面的一个参数。1.2 为什么不用重型 CI/CD有人可能会说现在 CI/CD 工具这么成熟直接上 Jenkins、GitLab CI 甚至云效不就行了确实团队规模大、发布频率固定、流程审批严格的时候一套完整的流水线非常必要。但很多前端团队并没有走到那一步尤其像我所在的这种小团队项目多、规模小、服务器分散在不同账号下有些项目甚至只是内部系统一周发不了一次。为了偶尔手动发一次版本硬搭一套流水线把构建、归档、审批、发布全串起来维护成本比部署本身还高。我们需要的其实就是一个“轻量的替代品”本地执行构建产物打到目录然后自动传到指定服务器中间可以插入一点自定义逻辑。不需要常驻服务不需要额外的后台页面不强制要求所有人都装同一个客户端。终端里一行命令能解决的事情就不要搞成一个大系统。deploy-cli 就是按这个思路做的。它只依赖 Node 运行时和几个常用包不启动任何服务不占用端口安装快卸载也干净。它不替你做构建决策只负责把“构建”“上传”“发布”这三步串成一个稳定的流水线并且让每一步都有清晰的日志和错误提示。轻量不代表功能弱而是把复杂逻辑收敛在本地让使用者只面对一条命令。1.3 设计目标一条命令部署、环境可配置、流程可扩展设计之前我先列了三个目标后面所有功能都是围绕它们展开。第一个目标叫“一条命令部署”。开发者不需要知道测试服务器 IP、不需要记住目标目录只要执行deploy-cli --envtest工具会自己读取项目配置完成构建、上传、发布。对新人来说这句话本身就是操作文档。第二个目标叫“环境可配置”。环境列表必须是可增可减的。今天只有测试和线上明天可能要加一个预发配置里增加一段就行不需要改工具代码。环境还可以继承公共配置避免重复写一堆相同的参数。第三个目标叫“流程可扩展”。部署不是一个孤立的动作它天然需要前后挂一堆操作构建前要检查分支构建后要验证产物大小上传后要通知群里的人。这些操作各不相同且随时变化不能写死在工具里。所以 deploy-cli 预留了钩子机制让每个项目能按需定义自己的流程段落。这三个目标定下来之后后面写代码的目标就清晰了核心稳定边界开放。2. 核心功能拆解与模块设计2.1 命令设计与整体流程deploy-cli 对外暴露的命令非常少常用就三个deploy-cli deploy --envtest、deploy-cli list、deploy-cli rollback --envtest --version202405241530。少并不是偷懒而是让使用者的心智负担降到最低。先看list。执行之后它会把当前项目已经配置好的所有环境列出来包括环境名、目标服务器别名、目标目录、环境变量标签方便确认自己要发的环境到底在哪里。这个命令主要给新人或者很久没部署的人用看一眼就不会发错地方。再看deploy。实际执行时工具会按以下顺序走读取项目根目录的deploy.config.js把当前环境对应配置合并出来执行配置里的hooks.beforeBuild比如检查当前分支、确认工作区干净调用项目的构建命令比如npm run build构建期间把环境变量注入进去校验构建产物目录存在且产物文件数量大于 0执行hooks.beforeUpload比如生成一个带时间戳的版本目录名把产物上传到目标服务器的版本目录在服务器上执行“切换当前目录”的操作把软链指到刚上传的版本执行hooks.afterUpload比如清理旧版本、发通知、做健康检查。整个过程日志分阶段输出哪个阶段出错会直接中断并提示不会出现“看似传完实际没生效”的尴尬。2.2 配置文件结构一个文件搞定多环境配置文件直接关系到好不好用所以我想了很久。最终定下来的结构是每个项目根目录放一个deploy.config.js整个文件就是一个普通对象按环境名分块。这样做有几个好处一是 Node 环境直接 require 就能拿到对象不用额外解析二是可以在文件里写注释方便说明加密方式、路径含义三是配置本身支持 JS 逻辑比如对用户名做拼接、读取本机用户名等。最简单的多环境配置长这样module.exports { base: { // 所有环境都通用的配置 builder: npm, buildCommand: build, distDir: dist, }, test: { // 测试环境会合并 base 的配置 server: 192.168.1.10, targetDir: /data/www/test-demo, env: { API_BASE_URL: https://test-api.example.com, }, }, prod: { server: your-prod-server-alias, targetDir: /data/www/example, env: { API_BASE_URL: https://api.example.com, }, }, };这里base不是真实环境它只是配置合并的根。工具运行时会先读取base再把具体环境名对应的配置覆盖上去。这样公共的构建参数只写一遍环境私有的差异写在各自块里。后面要加一个预发环境就再增加一个pre块几十行代码就能完成完全不需要动构建逻辑和上传逻辑。在环境变量处理上deploy-cli 会把env字段里的内容原样透传给构建进程。具体实现是在构建命令执行前把base和当前环境的env合并后写成一个临时.env.local文件构建结束后再删掉。因为主流的前端构建工具都支持.env系列文件这个方案兼容性最好。2.3 多环境配置的继承与扩展配置光能写出来还不够还得考虑“扩展性”。这里有一个很实际的问题不同团队成员的本机用户名不一样服务器连接方式也不一样总不能所有人的配置都写成同一个用户名。deploy-cli 的配置里允许写动态值比如const os require(os); module.exports { base: { ssh: { username: os.userInfo().username, privateKey: ${os.homedir()}/.ssh/id_rsa, }, }, prod: { host: your-server-alias, }, };这样每个开发者第一次运行的时候工具会自动用当前系统的用户名和默认密钥去连接大部分人不需要改配置就能直接用。如果某个人有特殊密钥也可以在本地建一个deploy.config.local.js工具启动时会检测这个文件如果存在就把它当最高优先级配置合并进去。这个文件默认被 gitignore不会污染公共配置。更复杂一点的场景是“环境模板”。比如有三个环境都指向同一套服务器只是目标目录不同可以先定义一个template再用extends: template继承module.exports { template: { host: common-server, distDir: dist, env: { NODE_ENV: production }, }, test: { extends: template, targetDir: /data/www/test }, pre: { extends: template, targetDir: /data/www/pre }, prod: { extends: template, targetDir: /data/www/prod }, };这种写法在项目多、环境多的时候特别好用。改一次模板所有环境都同步更新不会出现某个环境忘了改的问题。2.4 部署流程中的钩子机制我在文章开头说过deploy-cli 的很大一部分价值来自流程可扩展。这个能力是通过 hooks 实现的。hooks 可以在配置文件的某个环境里单独写也可以在 base 里写每个阶段支持一个函数或一组函数。实际用过几次之后发现最常用的钩子就这几个module.exports { base: { hooks: { beforeBuild: async (ctx) { // 检查是否在主分支避免把开发分支发到线上 // ctx.env 是当前环境名 }, afterBuild: async (ctx) { // 检查产物文件大小是否异常 // ctx.distDir 是构建产物路径 }, beforeUpload: async (ctx) { // 生成版本号、备份当前目录 }, afterUpload: async (ctx) { // 通知群、触发健康检查 }, }, }, };这个机制的实现思路不复杂工具把每个阶段视为一个“可插入点”执行到对应节点时如果发现配置里有函数就await掉。函数会收到一个上下文对象里面包含当前环境名、解析后的完整配置、产物目录、服务器信息等方便做更多判断。为什么要把钩子设计成函数而不是直接在工具里内置一堆开关因为前置检查和后置检查永远因项目而异。有的项目要求提交前必须跑 lint有的项目发布后必须清 CDN 缓存有的项目需要打完包后把 sourcemap 单独归档。这些逻辑本质上不属于“部署”而是“项目管理”所以放在钩子里由项目自己定义最灵活。工具本身只保证流程完整具体行为交给使用者编排。3. 从零落地安装、配置与一次完整部署3.1 安装方式与初始化项目deploy-cli 支持两种使用方式。一种是全局安装命令行适合经常要在多个项目间部署的人npm install -g deploy-cli另一种是作为项目本地开发依赖安装这样项目里可以通过npx deploy-cli或者在 package.json 的 scripts 里引用方便团队统一版本npm install --save-dev deploy-cli安装完成后进入项目根目录执行deploy-cli init工具会自动做几件事在当前目录生成一个最简deploy.config.js示例检查 package.json 里有没有build脚本如果没有会提示在当前目录创建一个.deploy/目录用来存放上传时的临时文件和日志。初始化完成后打开配置文件把服务器地址和目标目录改成实际值基本就能用了。对于新项目我建议第一步不要急着写线上环境的配置。先在模板里只写 test 环境手动跑一次deploy-cli deploy --envtest确认整个流程通了再复制配置改成 prod。这样可以把“配置错误”和“工具不熟”两个变量分开排错上线前心里有底。3.2 多环境配置的实际写法这里给出一份我实际用过的“标准配置模板”加了注释方便对照理解// deploy.config.js const os require(os); module.exports { base: { // 构建相关 builder: npm, // 包管理器 buildCommand: build, // package.json 里的 script 名 distDir: dist, // 构建产物目录默认 dist // 服务器连接相关统一走 ssh key 登录避免明文密码 ssh: { username: os.userInfo().username, privateKey: ${os.homedir()}/.ssh/id_rsa, }, // 发布策略默认保留最近 5 个版本 keepReleases: 5, hooks: { beforeBuild: async (ctx) { const branch require(child_process) .execSync(git rev-parse --abbrev-ref HEAD) .toString() .trim(); if (ctx.env prod branch ! main) { throw new Error(线上部署只允许 main 分支); } }, }, }, test: { host: test-server-alias, targetDir: /data/www/demo-test, env: { API_BASE_URL: https://api.test.xxx.com, }, }, pre: { host: test-server-alias, targetDir: /data/www/demo-pre, env: { API_BASE_URL: https://api.pre.xxx.com, }, }, prod: { host: prod-server-1, targetDir: /data/www/demo, env: { API_BASE_URL: https://api.xxx.com, }, }, };这份配置里host的值既可以是 IP也可以是~/.ssh/config里配置的主机别名。我比较推荐用别名好处是服务器换 IP 的时候不用改前端项目配置只需要改开发者本机的 ssh config。对同一台服务器上的多个前端项目还可以加一层rootDir概念把 targetDir 统一放到/data/www/demo/releases/版本号下通过软链切换回滚也会更方便。3.3 构建、上传、发布的完整流程配置写好后部署一次的实际体验大致是这样cd your-project deploy-cli deploy --envtest工具输出日志大概长这样[deploy] env: test [deploy] target: test-server-alias:/data/www/demo-test [deploy] hook: beforeBuild start [deploy] hook: beforeBuild done [deploy] building with command: npm run build [deploy] build success, dist size: 1.2MB [deploy] hook: afterBuild start [deploy] creating release: 20240524153000 [deploy] uploading files... 42 files [deploy] switching symlink to 20240524153000 [deploy] cleaning old releases, keep 5 [deploy] deploy done.在服务器目录里的真实布局如下/data/www/demo-test/ ├── current - releases/20240524153000 └── releases/ ├── 20240523100000/ ├── 20240523153000/ └── 20240524153000/这里的关键设计是“版本目录 软链切换”。每次上传都不直接覆盖current而是先传到带时间戳的releases/日期目录上传成功后再把current软链指向新版本。这样带来的好处非常明显上传过程中发生意外中断current还是指向旧版本线上不受影响回滚只是把软链指回上一个版本目录秒级完成旧版本目录可以保留几天方便排查问题对比差异。在这一步上我强烈建议不要用“直接上传覆盖网站根目录”这种原始方式。看起来简单但一旦传错文件或传了一半断网线上页面就是不可用状态连恢复都得先找到旧包。版本目录的方式虽然多占一点磁盘但在稳定性上的收益是值得的。3.4 环境变量注入与构建细节前端的另一个坑是构建时环境变量到底怎么进去。现在主流框架都支持import.meta.env或process.env读取环境变量但前提是你把这个变量写进了.env文件。deploy-cli 的做法是根据当前环境解析出的env字段在执行构建命令前生成一个临时文件比如测试环境生成.env.localAPI_BASE_URLhttps://api.test.xxx.com构建工具会自动加载这个文件前端代码里import.meta.env.API_BASE_URL就能读到。构建完成后工具可以把临时文件删掉避免把 .env 文件误提交到 Git。我特别要提醒一点不要把敏感信息写进构建产物能读取到的环境变量里因为前端代码最终会下发到浏览器用户可以通过开发者工具直接看到打包结果。像服务器密码、云厂商密钥这类东西永远不应该出现在前端环境变量里。deploy-cli 的配置里虽然有env字段但我建议只放接口地址、站点标识这类非敏感信息。真正的部署密钥放在本机 ssh key 里走连接层保护。3.5 回滚操作一条命令恢复上一版回滚功能是我自己最看重的功能因为哪怕流程再稳也难免会遇到线上紧急回归。deploy-cli 提供基于版本目录的回滚命令deploy-cli rollback --envprod不传--version时默认回滚到上一个版本也就是列出当前版本目录的上一项。如果明确知道要回滚到哪一次发布可以加参数deploy-cli rollback --envprod --version20240523153000工具会先检查这个版本目录是否存在存在则直接把current软链切到该版本。整个过程不重新构建、不重新上传快的时候一两秒就完成。这里顺带说一个经验发布前最好看一眼当前时间对应的版本号万一事后要回滚你不需要去翻日志时间戳本身就是线索。4. 常见问题与排查技巧实录4.1 服务器连不上从目录权限到 ssh key 的一串坑有一次同事反映执行deploy-cli deploy --envprod一直提示权限拒绝。我看日志发现连接阶段就失败了排查下来是他本机的 ssh key 不在白名单里。deploy-cli 默认读取~/.ssh/id_rsa但他是新换的机器还没给服务器加上公钥。这种情况不算工具问题但我随后在配置里加了一个更明确的报错提示连接失败时直接打印“请检查用户目录下 id_rsa 是否存在以及服务器 authorized_keys 里是否包含对应公钥”避免同事再去翻代码。另一个常见情况是目标目录没有写权限。上传文件用的是 sftp 或 scp如果目标目录的属主不对上传会直接失败。尤其常见的是targetDir存在但它的上层目录权限是www:www而登录用户是个人账号这时候需要把上传账号加进www组或者把目标目录的属主改成部署账号。这类问题最好在“首次配置”时一次性解决而不是等发布时才暴露。4.2 构建产物异常如何提前发现而不是上线后补救“构建成功但线上白屏”是我们遇到过最恼火的问题。后来我总结下来这类问题大多可以通过 afterBuild 钩子提前拦截。我在这个钩子里会检查几项内容产物目录是否为空有没有实际上生成了 index.htmlCSS、JS 文件大小是否异常比如小于 1KB 的文件要警惕是不是打包出错了是否出现了 sourcemap 文件如果生产环境不打算保留需要提前处理。这样在构建完成后、上传之前就把问题拦下来比传到服务器上再发现快得多。有一次某项目构建出来的 HTML 文件只有几百字节而且里头全是错误提示信息就是因为代码里某个全局常量写错了导致构建进程直接退出。有了 afterBuild 检查这种问题在上传前一秒就能被发现不会让一个错包流到测试环境。4.3 上传成功但页面刷新还是旧版本CDN 缓存问题这是部署界的老大难。现象是服务器上的文件已经更新了但浏览器打开还是旧页面。原因基本都出在两层一个是浏览器本地缓存另一个是 CDN 节点缓存。deploy-cli 本身解决不了 CDN 配置但它可以在 afterUpload 钩子里触发刷新逻辑。我们团队的通用做法是静态资源文件名都带 hash比如app.8f3d2a.js这类文件更新后 URL 会变浏览器自然加载新文件入口 HTML 文件设置no-cache这样每次访问都会去服务端校验。对于一些框架的默认输出没有 hash 的情况则需要在 afterUpload 里调用 CDN 平台的 API 刷新目录缓存。deploy-cli 只负责执行钩子里写的逻辑具体清哪些路径由项目自己定义互不干扰。如果确实遇到某个文件迟迟不更新最快的验证方式是直接用 curl 带时间戳参数访问 CDN 地址比如curl https://your-cdn.example.com/app.8f3d2a.js?v20240524这个命令能判断到底是源站没有更新还是 CDN 节点挡了一层。4.4 环境配置混乱分支与环境不匹配很多线上事故其实发生在开发阶段开发人员在自己的分支上联调却用 prod 环境配置构建了一版然后误发布到测试服务器。deploy-cli 的 beforeBuild 钩子里有一套推荐配置判断当前 git 分支与环境名是否匹配。比如prod环境只允许main或master分支test环境只允许dev、feat/*等非主分支。加上这个检查之后分支推错了会直接报错中断。还有更隐蔽的情况测试环境配置里漏了某个环境变量导致页面请求了线上接口结果开发过程中把线上数据搞坏了。这个问题的根因是“环境隔离不够彻底”。后来我们的做法是在同一份测试配置里显式列出所有参与部署的env项部署完成后在 afterUpload 钩子里检查线上页面加载的接口域名必须与配置里的env.API_BASE_URL一致不一致就发警报。这种“部署后自检”不是 deploy-cli 自动内置的但通过钩子做起来非常简单。4.5 失败重试与上传中断的处理上传过程中的网络抖动很常见。早期版本一旦某个文件传失败整个流程就断了开发者只能重新再来一遍。后来我在工具里加了一个重试机制单个文件传输失败时自动重试三次重试仍失败才会中断整体流程。同时在中断模式下已经上传到 releases 目录的文件不会影响current指向所以线上服务始终是正常的。这里还有一个小技巧每次上传前工具会写一个.upload.json的临时状态文件里面记录当前正在上传的文件清单。如果中途手动终止了命令下次运行 deploy 时它会提示“检测到上次未完成的部署任务”可以选择清理残留文件或者重新上传。这个设计避免了很多“传了一半目录很乱”的现场。4.6 常见问题速查表问题现象可能原因排查思路连接服务器失败ssh key 无效或服务器白名单未配置检查~/.ssh/id_rsa、authorized_keys上传目录权限不足目标目录属主与当前用户不一致修改目录属主或加入对应用户组构建成功但页面白屏publicPath 配置错误、产物路径不对检查静态资源请求路径、CDN base 配置部署完页面还是旧样式浏览器/CDN 缓存验证文件更新时间、清理缓存或刷新 CDNprod 环境混入测试接口地址环境变量配置被覆盖检查env字段、分支与环境映射回滚无效current 软链没切换检查 releases 目录是否存在对应版本上传中断后文件残留手动了 CtrlC 或网络断连使用清理命令或重新部署这张表是团队里实际遇到最高频的几类问题配置阶段先看一眼能少踩一半的坑。5. 部署脚手架的扩展方向与总结体会5.1 支持多项目共享配置当团队里的项目多到一定程度每个项目复制一份deploy.config.js虽然没问题但维护起来还是有点烦。deploy-cli 的配置解析支持读取远端配置包比如从某个私有 npm 包或 Git 仓库拉一份基础配置再和本项目的配置合并。这样不同项目的公共部分比如登录服务器用的 SSH key 路径、保留版本数、公共钩子逻辑都能沉淀在一个共享包里。我实际用下来两个月之后项目里的 deploy.config.js 缩到了二十行左右剩下的都是环境差异项。新项目接入时只要安装共享配置包再填几个环境名和目录路径就能立刻用起来。这个过程很接近“脚手架”这个词的本意了。5.2 与通知系统、监控系统打通部署动作天然适合接通知。我们团队把 afterUpload 钩子接到内部群机器人上每次部署完成会在群里收到一条消息内容包括部署人、环境名、版本号、产物大小、回滚命令。这个信息对团队协作非常有用其他同事不用问“谁刚动过线上”翻群记录就能看到。更进一步可以在 afterUpload 钩子里写一个健康检查逻辑主动访问一下线上地址确认 200 状态码检查页面内容里包含特定标记。健康检查失败就自动执行一次 rollback 并通知。自动化回滚听起来有点激进但实际用下来确实挽回了几次因为构建产物异常导致的事故。5.3 我个人的一些体会写 deploy-cli 这大半年的过程中我最深的感受是“部署工具”最大的意义不是节省那几分钟的上传时间而是把部署从“个人经验”变成“团队共识”。过去新同事部署要问东问西现在只需要记住deploy-cli deploy --envtest这一条命令。所有环境的差异都被配置收敛所有流程的边界都变得清晰不会出现某个人用自己私有的方式发布的情况。另外一点很实在的经验是这类工具不要一上来就做太重。先满足自己团队最简单的“构建上传发布”场景跑顺之后再通过钩子逐步加功能。等到团队离不开它了你自然知道下一步要支持什么。如果你也正在被手动多环境部署折腾与其去搭一套完整流水线不如先做一个能跑通的轻量脚手架它会成为你手里最顺手的部署工具。最后再分享一个小习惯配置文件写好之后把deploy-cli list的执行结果截个图放在项目 README 里。新人一进项目看到环境列表就明白所有目标环境长什么样连问都不用问。就是这么点小细节能给团队省下不少沟通成本。