浅谈前端 localhost 本地开发最佳实践

3,399 阅读9分钟

背景

所有前端教程或脚手架都可以做到「开箱即用」,npm i 之后运行 npm startnpm run serve 之类的命令,就可以在本地起一个 localhost:xxxx 的服务开始开发了,还带 HMR(热更新)的,非常的方便快捷。

但是,落实到实际业务中,往往没这么简单。比如本地开发需要调用真实的线上接口,此时往往会出现跨域问题;还有登录种 cookie,甚至 SSO 问题。笔者经常看到,为了解决本地开发问题,项目各种本地加代理,起服务,开 APP,绕来绕去的解决方案,笔者认为大可不必。

本文试着梳理一下各个场景下比较方便的本地开发实践,供大家参考。

原则

  1. 一切以线上环境最优为前提,不可以为了方便本地开发而做一些额外的工作。比如专门为 localhost 开启跨域访问等;
  2. 本地开发时尽量少依赖其他工具,比如浏览器插件、本地代理、抓包 APP 等;

前提

  1. 本文项目均默认以 webpack 为底层搭建,并默认读者熟知 NODE_ENVwebpack 常用配置;
  2. 工程中有多个 webpack 配置文件,传入不同 NODE_ENV 参数时读取不同配置,举例如下;
# 目录结构
├── config # 工程配置文件
│   ├── webpack.local.js
│   ├── webpack.dev.js
│   ├── webpack.prod.js
│   └── webpack.config.js # webpack-merge + NODE_ENV 判断 require 上面哪个 js
├── package.json
  1. NODE_ENV 变量要同步注入到浏览器当中,方便使用,代码如下:
plugins: [
  new webpack.DefinePlugin({
    "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV)
  })
]
  1. 假设现在有一个项目,分为生产(a.com)、测试(a-test.com)、开发(a-dev.com)三个环境,域名如括号中所示。且后端所有的接口都挂在同域的 /api 相对路径下。

正文

同域调用接口

假设我们本地开发 localhost:3000,需要调用开发环境的接口,即 a-dev.com/api/xxx。这个时候明显是跨域访问的,要如何解决这个问题呢?

我们知道,后端服务之间的调用是没有跨域概念的,所以解决思路自然是通过后端服务转发,也可称之为代理。如何实现这个代理呢?

Chrome 浏览器有个 Proxy SwitchyOmega 插件,功能很强大,可以解决这个问题,但是下载需要翻墙,所以还是不那么方便的。另外,通过 CharlesFiddler 等抓包工具也可以实现代理的功能,但是需要运行额外的 APP,还得有额外的学习成本,还是太麻烦。

那么最佳实践是什么呢?别忘了,我们本地开发的时候,已经有一个现成的 webpack devServer 服务在运行了,理论上它就能做代理的事情。实事也的确如此,devServer.proxy 就是做这个事的,我们来看下用法:

module.exports = {
  //...
  devServer: {
    // server: 'https', // webpack 5
    // https: true, // webpack 4
    proxy: {
      '/api': {
        target: 'http://a-dev.com',
        // secure: false,
        // pathRewrite: { '^/api': '' },
      },
    },
  },
};

如果做了上述配置,本地开发时所有访问 localhost:3000/api/* 的请求,都会被 devServer 「原封不动」(实际上还是动了一些的)地转发到 http://a-dev.com/api 上。

没错,就这么简单,不需要依赖任何其他软件或插件,就加这么几行代码就行。当然,如果你想调用生产或测试环境的接口,只要改一下 target 的值就可以了。

同域实现登录

上面的是最简单的情况,当开发的系统需要用户登录,也就是前后端需要传递 cookie 时情况就稍微复杂一些了,我们来分别看一下。

先是比较简单的场景,登录接口和服务都是同域的后端实现的,比如 /api/login。还是以开发环境举例,线上使用时,cookie 会被种到 a-dev.com 这个 domain 下,因为前后端是同域的,所以携带 cookie 不会有任何问题。但是如果换到本地开发,会有什么影响呢?

答案是不会有影响,仍然可以正常使用。不同的是本地开发时,cookie 会被种到 localhost domain 下。此时访问 localhost:3000/api,cookie 会被带到 devServer 服务,然后 devServer 经过处理后(比如修改 referrer 之类的属性)将 cookie 传到 a-dev.com/api,所以开发环境的后端还是可以正常消费 cookie 的,其返回也会被 devServer 经过适当处理后再返回给浏览器。

所以,对于同域实现登录的服务devServer.proxy 的方式仍然可以正常运行,不需要做任何修改

SSO 登录

说完了同域实现登录服务,我们再来看看更复杂的一个场景,SSO 实现登录服务。关于 SSO 的原理和细节,本文就不做展开了,这里推荐两篇文章:《机房夜话》,《从密码到token, 一个授权的故事》,写的生动形象,深入浅出,笔者认为非常经典,自己读过好几遍,常读常新。下面引用文章里的一张图来说明一下问题:

image.png

如上图所示的最后一步,SSO 服务会返回给浏览器一个真实的带 ticket 参数的 302 跳转命令。放到本文的例子就是 a-dev.com/landing?ticket=T123(通常这个地址会被网关特殊处理),注意,一旦浏览器跳转到了这个地址,就再也不能自动跳回 localhost:3000 了。就算用某些方式能跳转回来,但是 cookie 也只会被种到 a-dev.com 这个 domain 下,而不会种到 localhost 下。所以此种情况下,只使用 devServer.proxy无法正常开发的

上面这段内容对于不太理解 SSO 流程的同学可能有些难理解,不过没关系,只需要记住 SSO 登录的服务,无法单纯使用 devServer.proxy 解决就可以了。那如何解决呢?

推荐方案 - 手动种 cookie

从上文的论述过程中,我们可以发现,关键的问题就在于 cookie 没有成功种在 localhost 上。那么最简单粗暴的解决方式就是把 a-dev.com 下的 cookie 手动种到 localhost 上。听起来比较 low,但是的确有效。具体代码就不贴了,说一下解决思路和需要注意的点:

  1. 每个项目中要有一个 config-local.json 文件,里面存放 cookie 变量,值即为手动搬运过来的 cookie;
  2. 因为每个人的 cookie 不一样,该文件不应该入库,即应该被写入 .gitignore。可以写一个 config-local-demo.json 文件入库,方便本地开发时复制该文件,改名为 config-local.json 即可;
  3. 修改 devServer.proxy['/api'].headers.cookie 的值,即 config-local.json 中的 cookie 值; 上文说过将 NODE_ENV 变量注入到浏览器中,所以可以根据 NODE_ENV === 'local' 的条件,在 Ajax 请求的公共 headers,选择是否携带上 config-local.json 中的 cookie。

有的同学可能会问了,那每次 cookie 过期,我都要手动更新一次吗?没错,理论上是这样的。但是,通常 cookie 都有「自动续约」的机制。也就是说如果你每天都会开发这个项目,理论上你碰到 cookie 过期的几率也不是那么大,实际情况更可能是每周手动操作一次。

修改 hosts 不行吗

有的同学会问,那我手动修改下 hosts 文件,甚至用代理工具,把 a-dev.com 强行映射到 localhost:3000 是不是就可以了。很遗憾,所有 a-dev.com 的请求都被你这样「截胡」了,这意味着 a-dev.com/landing?ticket=T123 请求也无法正常发送到后端或网关的,前端是无法处理这种情况的。

前端 landing 页

还有一种场景,一些项目只有后端接口请求过网关,前端页面路由不过网关(所以没法自动完成 ticket 消费的闭环),而是直接定向到对象存储的 index.html 文件上。这种情况一般会做如下处理:

前端专门留出一个 /landing 的空白页面,来处理 ticket 的流转。即前端从 query 中获取到 ticket 后,向后端一个专门的接口(比如 /api/ticket,下同)发起请求,并携带 ticket。后端得到 ticket 后再向 SSO 发起校验 ticket 的流程,用这种方式完成闭环。此方案的特点如下:

  1. 本地开发要修改 hosts,将线上真实地址 a-dev.com 解析到 localhost,且本地开发时使用 a-dev.com
  2. SSO 服务种 ticket 的 redirect_uri 只能固定为 a-dev.com/landing
  3. 后端有专门的接口,比如 /api/ticket 来接收前端发送的 ticket,并去 SSO 完成验证 ticket 的程序闭环;
  4. SSO 不会因为 redirect_uri 与触发 ticket 验证的地址(/api/ticket)不同而验证失败
  5. 所有应用内页面跳转逻辑由前端完成,建议使用 location.replace 跳转。

此方案虽然不需要手动种 cookie 了,但是还是需要修改 hosts,好在修改频率也很低。

当然,即使是这种场景,你也可以选择用手动种 cookie 的方式来解决问题。

跨域调用接口

如果项目中真的存在一些必须跨域调用的接口,要怎么处理呢?情况稍微有些复杂,我们分几个维度来看:

  1. 被调用方是否允许 localhost 跨域访问?
    • 不允许:只能修改 hosts 了;
    • 允许:请前往第 2 问;
  2. 是否需要跨域携带 cookie?
    • 不需要:可以愉快的使用 localhost 开发了
    • 需要:只能修改 hosts 了;

可以看到,只有满足「被调用方允许 localhost 跨域访问」且「不需要跨域携带 cookie」的情况才能够愉快的使用 localhost 方式开发。看似条件比较苛刻,但是理论上允许跨域调用的服务,就应该具有这个特点。

总结

简单进行一下总结:

  1. 不需要登录或同域实现登录的,只需要用 devServer.proxy 就能实现本地开发;
  2. SSO 实现登录的,基本手动种 cookie 就能解决问题,但是会有一定的手动成本;
  3. 跨域调用接口的某些情况,可能需要修改 hosts 才能解决问题。

真实情况其实是很复杂的,很难穷举,本文提供的解决方案肯定不可能 100% 覆盖所有场景。但是按照本文的思路去分类思考,应该也足够应对自己的实际情况了。万变不离其宗,抓住问题的本质,自然能够游刃有余的应对看起来复杂的问题。

昔之善战者,先为不可胜,以待敌之可胜。不可胜在己,可胜在敌。——《孙子兵法·军形篇》