做 howtolivebetter 这个站点,需求一步步从“静态页面”变成“要有动态功能”,最后我选了云开发的 Node.js 环境来跑后端逻辑。可真上手之后才发现,光会写前端代码根本不够——HTTPS 协议、Promise 异步、Axios 请求库,这三样全得从头补。这篇文章就把我这段补课经历完整记录下来,从 Ubuntu 上装 Node.js 20 开始,一直讲到云函数调通第三方接口。
事情的起因很简单。我原本只想要一个生活指南类的静态站点,页面扔 GitHub Pages 上,免费托管、不用搭理服务器,安安稳稳挺好。可做着做着需求就变了:首页想加“每日一句”,还想做留言、访问统计,这些没后端根本玩不转。为了不为一两句话就买台服务器,我算了一笔账——个人项目的流量不大,云函数按调用次数计费,每个月几块钱甚至免费额度就够,于是果断把后端放到了云开发上。
真正动手写云函数的时候,我发现最大的障碍不是不会写 JavaScript,而是长期在浏览器里写前端代码,很多底层细节被框架惯坏了。HTTPS 开头的接口平时有现成封装帮我处理,Promise 在页面上随便用用也不深究,Axios 更是拿来即走。可到了 Node.js 云函数这边,协议细节、异步机制、请求库选型全都要自己拿主意。这篇文章适合和我一样“半路出家”做云开发的人,我把踩过的坑、补课的思路、最终跑通方案都写出来,你按这个顺序走,能少绕很多弯。
1. 云开发里为什么偏偏是 Node.js
1.1 云开发解决的到底是什么问题
云开发可以理解成“后端能力按菜单点单”。你不需要自己买服务器、装环境、配 nginx、写运维脚本,平台上直接给你几样东西:云函数用来跑一段 Node.js 代码,按调用次数计费;云数据库是文档型的,前端可以直连;云存储用来传文件;还有静态托管。我用的微信云开发,腾讯云 CloudBase、uniCloud 这些底层其实是一家人,API 风格高度相似,共同底座就是 Node.js 运行时。
为什么偏偏是 Node.js?因为云开发的目标用户绝大多数是前端开发者。JavaScript 是少有的前后端通吃的语言:前端写页面用 JS,云函数里还是 JS,心智负担最低。另一个原因是 Node.js 生态足够大,任何需求几乎都能找到现成的 npm 包,不会出现“这个功能没人写过”的尴尬。
1.2 Node.js 环境带来的双重身份
在云开发上,你写的云函数本质上就是一个运行在 Node.js 里的模块。它和你在本地写的 Node 脚本没有任何区别,只是由平台负责调度、弹性和日志收集。举个具体的例子:你本地写一个node index.js,里面用console.log打印、用setTimeout延时、用process.env读环境变量,这些东西在云函数里全部可用。这意味着两件事——本地能跑的 Node 代码几乎都能直接丢进云函数;同时,Node 生态里的所有坑,你在云函数里一个都躲不掉。
1.3 HTTPS、Promise、Axios 是怎么被逼出来的
先说 HTTPS。云函数部署后,平台会给你的 HTTP 触发器分配一个 HTTPS 域名,用户访问入口是 https 开头。同时云函数里要访问的第三方接口,几乎也都是 https。你要是连 TLS 握手、证书、端口这些基本概念都没有,遇到“请求被拒绝”“证书无效”时会一头雾水。
再说 Promise。云函数的导出函数是exports.main = async (event, context) => {}这种形态,async 函数本质就是 Promise 的语法糖。平台拿到你返回的 Promise,会等它 resolve 之后再把结果返回给调用方。如果你代码里到处是回调嵌套,或者有某个 Promise 忘了 catch,就会看到各种“uncaught (in promise)”的报错刷屏。
最后是 Axios。Node 原生有 http/https 模块,但用起来太底层了,JSON 解析、超时控制、错误信息都得自己搞。Axios 就是把细节封装好的请求库,也是当前云函数里最常用的 HTTP 客户端。这三样东西不是零散的技术点,而是串成一条链:云函数用 Axios 发 HTTPS 请求,用 Promise 管理异步流程,最后把结果返回给前端。
2. 环境准备:Ubuntu 上装 Node.js 20+ 的三条路
2.1 LTS 和 Current,先别选错
我在 Ubuntu 上折腾 Node 的时候,第一个困惑是官网写着两个版本:LTS 和 Current。LTS 是 Long Term Support,维护周期长,社区和云厂商都会优先跟进,安全性修复持续好几年;Current 是最新功能版,能尝鲜新特性,但更新频率高,大版本之间可能有破坏性变更。
做云开发这种要长期跑的环境,无脑选 LTS。现在 Node 20 是 LTS 里的绝对主力,Node 22 也已经转正,云开发控制台创建新函数时,运行时通常支持到 20+。本文统一以 Node 20 为例,你换成 22 也不会有什么差别,关键是把本地版本和云端运行时对齐,避免本地跑得好好的、上云就报语法错误。
2.2 三种安装方式实测对比
我实际试过三条路,各有适用场景,先说结论再解释。
第一种是 apt + NodeSource 源。Ubuntu 自带的 apt 源里那个 nodejs 包版本太老,直接apt install nodejs装出来可能是 12 甚至更老,完全没法用。正确做法是先加官方推荐的 NodeSource 源:
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - sudo apt-get install -y nodejs这条路适合一次性把环境装好、不想引入额外工具的服务器,装完就是系统级 Node,升级靠apt upgrade就行。
第二种是 nvm,全称 Node Version Manager。它可以在同一台机器上装多个 Node 版本、随时切换,特别适合云函数开发——因为你需要验证代码在不同 Node 版本下的行为。安装方式:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash # 重新打开终端或 source ~/.bashrc nvm install 20 nvm alias default 20我最终推荐这一条。本地开发时切换版本的成本几乎为零,哪天云厂商升级运行时,你一条nvm install 22就能在本地复现问题。
第三种是官方二进制包。去 nodejs.org 下载 linux-x64 的 .tar.xz,解压后自己建软链接。适合没有 root 权限、或者对包管理器有洁癖的场景,但升级要手动下载覆盖,稍麻烦。
| 安装方式 | 上手难度 | 版本切换 | 适用场景 |
|---|---|---|---|
| apt + NodeSource | 中等 | 不支持 | 服务器一次性装好 |
| nvm | 简单 | 方便 | 本地开发、多版本验证 |
| 官方二进制包 | 较麻烦 | 手动 | 无 root 权限、定制安装 |
2.3 装完验证三件套
装完别急着写代码,先跑三个命令:node -v、npm -v、which node。第一个确认版本号是 20.x,第二个确认 npm 跟着装好了,第三个最关键——确认你调用的 node 确实是刚装的那个路径。这个最容易翻车:如果你之前用 apt 装过旧版,which node出来的可能是/usr/bin/node,而 nvm 装在~/.nvm/versions/node/下,两个共存时 PATH 顺序决定你实际用的是哪个。我一度咋验都是 18,折腾半天发现是 PATH 没刷新。
另外建议顺手配一下 npm 镜像源:
npm config set registry https://registry.npmmirror.com这不是什么花活,只是把 npm 官方源换成国内镜像,装包速度快好几个数量级,云函数里装依赖时尤其明显。
3. HTTPS:从网址前缀到证书信任链
3.1 每天看的 https:// 到底在干什么
我认真补 HTTPS 是在排查一个“请求卡死”问题的时候。其实拆开看就三件事:加密、身份验证、完整性。HTTP 是明文,你在网上传的账号密码可以被中间设备直接读走;HTTPS 就是在 HTTP 外面套了一层 TLS 加密,相当于把信装进信封再寄出去。信封保证路上没人能拆开偷看,封口蜡印保证没人能中途换掉信纸。
它俩的默认端口也不一样,HTTP 是 80,HTTPS 是 443,你平时写https://xxx.com,浏览器会自动去连 443 端口。TLS 建立连接的过程叫握手,简化成四步:客户端先打招呼说“我要建立安全连接”,服务器把证书甩过来证明身份,双方交换密钥参数,最后在各自本地生成一把只有他俩知道的会话密钥,之后的通信全部用这把钥匙加密。握手的速度直接影响首屏加载,这也是为什么 HTTPS 站点要做 TLS 1.3 配置和会话复用。
还有一个概念必须理解:证书。服务器那把证书不是自己印的,由 CA(证书颁发机构)签发,浏览器内置了主流 CA 的信任列表。如果证书签发的域名对不上、证书过期、或者签名链断了一截,浏览器直接给你拦下来,这就是“不安全”警告的来源。为什么自签名证书在本地测试能用、放到公网就被拦?因为公网浏览器不认你这个“民间 CA”,没有谁能证明你手里的公钥真的是你的。
3.2 云函数为什么能自动变成 HTTPS
用云开发有个福利:平台帮你把证书这件事全做了。创建一个 HTTP 触发器,平台分配一个默认域名,自动挂证书、自动续期,你完全不用碰 openssl 和证书文件。这一点比自建服务器省心太多——我身边有朋友自己买服务器配 Let's Encrypt 续期脚本,每三个月折腾一次,还会遇到各种兼容性问题。
但要注意,云函数之间相互调用,或者云函数里去 fetch 第三方接口,依然是你的 Node.js 进程在发起 HTTPS 请求。这时候平台的“自动证书”帮不了你,证书验证发生在 Node 的 TLS 层,由代码运行的机器来做判断。理解这个边界很重要:HTTPS 入口是平台管的,但 HTTPS 出口是你自己代码管的。
3.3 在 Node.js 里调用外部 HTTPS 接口
Node 原生有 https 模块,https.get(url, callback)能发请求,但用起来特别原始:返回的是数据流,你得自己监听data和end事件,JSON 要自己 parse,错误要自己归类。Axios 内部会根据协议自动选择 http 或 https 模块,把手握、收流、解析都封装好,但底层仍然是 Node 的 TLS 实现,证书验证规则还是 Node 的那套。
实际开发中常见两个坑。第一,有些内网或测试环境用自签名证书,Node 默认严格校验,直接报self-signed certificate错误。有一个干净的解决办法:用环境变量NODE_EXTRA_CA_CERTS指向你的自签 CA 文件,只影响当前进程,比修改全局行为稳妥。第二,代理环境下经常报getaddrinfo ENOTFOUND,这不是证书问题而是 DNS 解析失败,先查网络配置再怀疑代码。如果你平时做接口测试,JMeter 录制 HTTPS 脚本的时候会要求先把 JMeter 的根证书导入信任库,原理和上面说的 CA 信任链完全是一回事——它让 JMeter 变成中间代理,而测试环境选择信任它。
3.4 证书相关的坑与排查顺序
我把常见的 HTTPS 报错整理成一个自查顺序:先看域名是否拼错、端口是否正确;再看系统时间对不对——证书有效期校验对时间极敏感,Ubuntu 时钟跳了会导致所有证书看起来“已过期”;然后看证书链是否完整、根证书是否缺失;最后才轮到代码里的验证参数。顺序对了,排查快很多。我在云函数里遇到过最诡异的一次是“上午好好的下午全挂”,最后发现是服务器的 UTC 时间和真实时间差了八个小时,证书恰好在这八小时窗口内过期。这种坑,查代码永远查不出来。
4. Promise:把异步逻辑捋顺
4.1 回调地狱是怎么来的
Node.js 是单线程事件循环模型,文件读取、网络请求这类 IO 操作都是异步的,不会阻塞后续代码。这个设计在 IO 密集型的后端场景里简直是神器,但早期的写法是回调:第一个异步操作完成后再执行第二个操作,第二个完成后再执行第三个,层层嵌套下去就变成了“箭头金字塔”。缩进越来越深,逻辑越来越乱,错误处理更是散落得到处都是,读代码的人需要来回翻上下文才知道整个流程在干嘛。这就是大名鼎鼎的回调地狱。
4.2 Promise 的三态与链式调用
Promise 本质是一个状态机,任何时刻处于三种状态之一:pending(进行中)、fulfilled(成功)、rejected(失败)。状态只能从 pending 变到 fulfilled 或 rejected,一旦确定就不可逆。你用new Promise包住一个异步操作,成功时调 resolve,失败时调 reject,然后用.then接成功结果、.catch接失败原因、.finally做收尾。
Promise 最大的价值是链式调用:.then里可以 return 一个新的 Promise,下一段.then会等它完成后再执行,异步流程变成一条清晰的链,而不是无限嵌套。我常用一个小类比:Promise 像餐厅排队叫号,你拿个号坐下来等,轮到你就处理,没轮到你也不影响别人。如果多个互不依赖的异步操作要并行做,用Promise.all([p1, p2, p3]),它等全部成功后返回数组结果,但有个大坑——只要其中一个 reject,整个 all 立刻失败,完全不管其他几个已经成功。适合“全部成功才算成功”的场景。想要各自独立的结果,用Promise.allSettled,它永远等所有操作结束,返回每个操作各自的状态和值。云函数里要聚合多个接口数据时,我基本都用 allSettled,一个接口挂了不至于拖垮整个函数。
4.3 async/await 是语法糖,不是替代品
ES2017 引入的 async/await 让 Promise 代码读起来像同步代码:
async function getDailyQuote() { const res = await axios.get('https://api.example.com/daily'); return res.data; }注意两个细节:第一,async 函数无论返回什么,最终返回值一定包裹在一个 Promise 里;第二,await只能在 async 函数内部使用,在普通函数里写 await 直接报语法错误。云函数的exports.main = async (event, context) => {}本身就是 async,所以函数体内可以放心用 await。
我的建议是:能写 async/await 就写 async/await,可读性比裸的.then().catch()链强太多。但你要记住,它内部实现仍然是 Promise,所以“某个 Promise 没人处理”的问题,await 也救不了。如果你在 async 函数里直接 await 一个会 reject 的调用,外层没有 try/catch 兜底,错误照样会变成 unhandledRejection。
4.4 那个让人崩溃的 Uncaught (in promise) error
这个报错是我写云函数这几个月里见到频率最高的。它的出现条件只有一个:某个 Promise reject 了,但没有对应的 catch 处理。云函数里最常见的翻车姿势是“fire and forget”——一段触发后就不管的异步操作里抛了错,外层既没有 await 也没有 catch,Node 把这个错误标记为 unhandledRejection,控制台就打出Uncaught (in promise) Error: ...。
解决方案分三层。第一层也是最根本的:代码里保证每个 Promise 都有归宿,用 await 配合 try/catch。第二层:不需要等待结果的异步操作,单独补一个.catch(() => {})兜底,至少让错误有地方去。第三层:给进程加process.on('unhandledRejection', ...)做最后一道监控——但云函数里要谨慎,因为 Node 遇到未处理的拒绝会默认直接崩溃当前进程,平台检测到后会自动重启,反而把问题掩盖掉。正确做法是监控到错误后记录日志、保留堆栈,然后正常结束函数。遇到这个报错先别慌,看栈顶,它一定会告诉你到底是哪个函数 reject 了,顺着栈去补 try/catch 就行。
5. Axios:Node.js 请求库的正确打开方式
5.1 为什么不用 Node 原生 http 模块
直接对比代码就明白了。原生的写法长这样:
const http = require('http'); http.get('http://api.example.com/data', (res) => { res.setEncoding('utf8'); let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { console.log(JSON.parse(data)); }); });要监听 data 事件、end 事件,要手动拼接字符串,要自己 JSON.parse,还得自己做异常处理。换成 Axios,一行搞定:
const axios = require('axios'); const res = await axios.get('https://api.example.com/data'); console.log(res.data);Axios 自动按响应头把返回体解析成文本、JSON 或者二进制,错误对象里 status、headers、data 一应俱全,还能配置超时、取消请求。云函数这种场景下,你省下的每一行样板代码,都是减少一次出错机会。
5.2 基本用法与自定义 Headers
云函数里调外部接口,最常见的需求是带 token 的鉴权请求。Axios 支持 GET/POST/PUT/DELETE,第二个参数可以传配置对象:
const axios = require('axios'); // GET 请求,带自定义请求头 const res = await axios.get('https://api.example.com/quotes/daily', { headers: { 'Authorization': 'Bearer ' + token, 'X-Requested-With': 'XMLHttpRequest' }, timeout: 5000 // 单位毫秒 }); // POST 提交 JSON const res2 = await axios.post('https://api.example.com/log', { event: 'view', page: 'home' }, { headers: { 'Content-Type': 'application/json' } });自定义 Headers 有两个容易被忽略的坑。一是大小写:Axios 内部会把 headers 键名做归一化处理,但某些对请求头敏感的后端服务,自定义头最好统一用小写连字符风格,比如x-request-id,减少意外。二是Content-Type:Axios 对普通对象做 POST 时,默认自动设成application/json,这没问题;但如果你传的是纯字符串,它不会帮你转换类型,后端解析不了的时候优先检查这个。另外补充一点,浏览器环境下自定义请求头会触发 CORS 预检请求,也就是先发一个 OPTIONS 请求试探,这也是我把请求收口到云函数的原因之一——Node 环境没有 CORS 概念,省掉一堆跨域问题。
5.3 拦截器、超时和重试
拦截器是 Axios 里被我用得最狠的功能。全局统一给每个请求加鉴权头、打印请求日志、统一处理 401,全都在拦截器里做,不用每个请求重复写:
// 请求拦截器:统一加来源标记 axios.interceptors.request.use((config) => { config.headers['X-Source'] = 'cloud-function'; return config; }); // 响应拦截器:统一处理超时和错误 axios.interceptors.response.use( (res) => res, (err) => { if (err.code === 'ECONNABORTED') { console.error('请求超时:', err.config.url); } return Promise.reject(err); } );超时这件事必须单独说:Axios 的timeout默认是 0,也就是永不超时。云函数平台本身有执行超时限制,常见的有 5 秒和 60 秒两档可选。如果你的外部请求没设 timeout,接口一直挂在那里不返回,云函数就会被平台强行掐断,日志里只有一句“Function timed out”,特别难排查。我的做法是:所有外部请求 timeout 设在 3000 到 5000 毫秒之间,宁可失败重试,也不要默默耗尽函数配额。重试可以直接用 axios-retry 包,也可以自己写个简单循环:判断网络错误或 5xx,最多重试两次,每次退避 200 毫秒。云函数场景里,一次请求的重试次数不宜超过三次,因为平台超时是硬上限。
5.4 云函数里的实际配置习惯
云端运行时我已经不推荐node-fetch或request了。前者 API 和浏览器 fetch 略有差异,流处理比较繁琐;后者官方已停止维护,带着历史遗留问题。Axios 加一层自己封装的公共请求方法,足以覆盖云函数里 99% 的场景。另外要注意依赖体积:云函数包体积直接影响上传速度和冷启动时间。Axios 本身算轻量,但要警惕为了一个小功能把整个 SDK 或一堆大型工具库塞进去,尤其是带 node_modules 目录上传时,看仔细了。我通常只装 wx-server-sdk 和 axios,再有事就往这两个上面扩展,保持函数包清爽。
6. 完整案例:一个云函数调第三方 HTTPS 接口
6.1 需求与数据流
回到我的实际项目。howtolivebetter 的首页有一块“每日一句”,内容需要从第三方开放接口拉取。最初的方案是前端直接在浏览器里 fetch,结果遇到两个问题:第三方接口的 appKey 不能暴露在 HTML 里,否则等于把密钥公开挂在页面上;有些接口的响应结构不稳定,前端要写一堆兜底解析逻辑。于是改成:前端调云函数,云函数用 Axios 请求第三方 HTTPS 接口,拿到数据后先写一份到云数据库做当天缓存,再把简化后的结果返回前端。数据流是:前端页面 → 云函数 → 第三方接口 → 云函数处理 → 云数据库缓存 → 前端渲染。
6.2 云函数完整代码
这里给一个精简但完整的示例,用的是微信云开发的云函数形态:
const cloud = require('wx-server-sdk'); const axios = require('axios'); cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }); const db = cloud.database(); exports.main = async (event, context) => { // 1. 先查缓存,当天有数据就直接返回 const today = new Date().toISOString().slice(0, 10); try { const cached = await db.collection('quotes') .where({ date: today }) .get(); if (cached.data.length > 0) { return { code: 0, data: cached.data[0].content, source: 'cache' }; } } catch (e) { console.error('读取缓存失败', e); } // 2. 请求第三方接口 try { const res = await axios.get('https://api.example.com/v1/daily', { headers: { 'Authorization': 'AppCode ' + process.env.QUOTE_API_KEY }, timeout: 5000 }); const quote = res.data.data.quote; // 3. 写缓存,避免同一日期重复请求 await db.collection('quotes').add({ data: { date: today, content: quote, createdAt: Date.now() } }); return { code: 0, data: quote, source: 'api' }; } catch (err) { console.error('第三方接口调用失败', err.message); // 降级方案:第三方挂了也有内容可展示 return { code: 1, data: '今天也要好好生活。', source: 'fallback' }; } };这个代码里几个设计选择值得说。密钥从process.env.QUOTE_API_KEY读取而不是写死在代码里,云开发控制台配好环境变量后,部署后还能在控制台随时修改而不用重新上传代码。缓存策略是:先查当天记录,有就直接返回,没有才调外部接口,同一日期只有第一次调用会走外部请求,后续全部命中数据库,既快又省配额。降级方案保证外部接口挂了页面也不至于空白——对个人项目来说,可用性比数据的实时性重要得多。
6.3 部署、调试和日志
云开发控制台上传云函数其实就三步:右键目录上传、选择云端安装依赖、配好触发器和环境变量。第一次跑通之后,重点看两个东西:调用日志里有没有执行成功记录,以及返回的 JSON 结构和前端约定是否一致。调试阶段我喜欢在函数里加几个阶段性的 console.log,比如“开始请求外部接口”“外部接口返回 status=200”,这些日志在控制台按调用链串起来,一眼就能定位卡在哪一步。平台的本地调试工具支持模拟触发,但外部 HTTPS 请求依然走你本机的网络,和云端唯一的差异是环境变量和网络出口。所以本地没问题不代表云端没问题,出了怪事先对比这两项,别急着质疑代码。
7. 常见问题速查表与排查思路
我把实际开发中高频遇到的问题整理成一张速查表,你可以直接照着排查:
| 症状 | 可能原因 | 处理办法 |
|---|---|---|
| Function timed out | 函数执行超限 | 检查 await 是否阻塞,外部请求设 timeout |
| Uncaught (in promise) Error | Promise 没有 catch | 所有异步操作补 try/catch 或兜底 catch |
| self-signed certificate | 自签名证书不被认可 | NODE_EXTRA_CA_CERTS 指向 CA 文件 |
| getaddrinfo ENOTFOUND | DNS 解析失败 | 检查域名、网络出口、平台白名单 |
| 接口返回 401 | 鉴权信息缺失 | 检查 Authorization、环境变量是否配置 |
| JSON 解析报错 | 响应根本不是 JSON | 先看 res.headers['content-type'] |
| 本地正常、云端失败 | 环境变量或网络差异 | 对比云上和本机的 env 与出网策略 |
再补充三条从实际项目里磨出来的经验。
第一,调试网络请求时,把错误对象完整打出来,不要只打err.message。Axios 的报错对象里带着err.config(当时的请求配置)、err.response(响应状态和数据)、err.code(比如 ECONNABORTED 表示超时)——这些信息量比一句话多得多,是定位问题的第一手资料。
第二,代码里 IO 操作多的时候,先画一条数据流再动手写。从入口到出口经过哪些异步步骤、哪些可以并行、哪些必须串行,理清楚再落代码。我踩过最贵的坑是自以为是地并行发请求,结果触发了第三方接口的限频,后来改成串行加简单重试才消停。
第三,把云函数当成整个项目的“稳定出口”。前端能不发外部请求就尽量不发,所有跨域、密钥、格式转换的活全部收口到云函数,前端代码只等一个固定的 JSON 结构。这个思路让我的前端代码简化了一大截,也让云函数的价值真正体现出来——它不只是一个“跑代码的小盒子”,而是你所有后端逻辑的统一入口。
补完这三块知识之后,我最大的变化不是代码写得更顺了,而是对“请求从浏览器到云函数再到第三方接口”这条链路有了整体认知。以后再遇到任何网络错误,我大概知道它发生在哪一层:是 DNS 还是证书、超时还是权限、Promise 还是 JSON 解析。对个人开发者来说,这个认知比会写几个 API 值钱得多。最后分享一个建议:如果你也在做多端项目,把云函数封装成统一的数据网关,网页、小程序、脚本都只和你自己的函数打交道,密钥不外露,格式全统一,你会感谢这个决定的。