http请求的简单流程可归纳为:
- 创建
request请求。 - 请求发出后,通过回调获取
response响应。 - 读取
response响应流内容。
环境搭建
请根据上一篇《前端进阶 - node基本操作 - Koa快速搭建本地服务》文中代码,启动本地HTTP服务。
快速上手
创建文件request.js:
// 创建请求
const req = require('http').request('http://127.0.0.1:3001/hello', {
method: 'GET',
}, res => {
// 获取响应状态码和响应头
const { statusCode, headers } = res
// 创建一个收集内容的缓存池
const chunks = []
// 当有数据返回时,将内容放入缓存
res.on('data', chunk => {
chunks.push(chunk)
})
// 当响应结束时,汇总请求数据。
res.on('end', () => {
// 拼接所有缓存
const buff = Buffer.concat(chunks)
// 缓存转码
const data = buff.toString('utf-8')
// 输出最终内容
console.log({ statusCode, headers, data })
})
})
// 发起请求
req.end()
日志打印:
{
statusCode: 200,
headers: {
'content-type': 'text/plain; charset=utf-8',
'content-length': '6',
date: 'Mon, 28 Jun 2021 09:24:22 GMT',
connection: 'close'
},
data: '/hello'
}
流程拆解
基于上面的代码,我们将其做一下封装,模拟wx.request方法的输入输出格式,实现一版node版本的request方法。封装过程中,有下列问题需要处理:
适配http连接类型
对url参数的自适应,我们先只处理 http https 两种协议。
const http = require('http')
const https = require('https')
/**
* 判断使用 http 或者 https 连接
* @param {string} url
* @returns
*/
const getHttp = (url) => {
return /^https:/.test(url) ? http : https
}
data参数
上面代码中,url的queryParams部分是写在url中的。按照wx.request的标准,此部分应支持写入data参数中,使用时应是类似下面的代码:
request({
method: 'GET',
url: 'https://www.baidu.com/s',
data: { wd: 'nodejs' }
})
// GET https://www.baidu.com/s?wd=nodejs
我们将这个过程拆分为两部分:
1. data参数解析
const obj2url = (data, enc) =>
data
? typeof data === 'string'
? enc ? encodeURIComponent(data) : data
: Object.keys(data).map(k => `${k}=${encodeURIComponent(data[k])}`).join('&')
: ''
2. data参数与url合并
const combinData = (url, data, enc) => {
const query = obj2url(data, enc)
if(query) {
const flag = url.indexOf('?') > -1 ? '&' : '?'
return `${url}${flag}${query}`
}
return url
}
自定义配置header
比如要配置请求的cookie:
request({
method: 'GET',
url: 'https://www.baidu.com/s',
data: { wd: 'nodejs' },
header: { Cookie: 'a=1;b=2' }
})
实现setHeaders方法
const setHeaders = (req, header) => {
// 写入自定义header
if(header) {
Object.keys(header).forEach(key => {
req.setHeader(key.toUpperCase(), header[key])
})
}
// 高优先级header 覆盖
req.setHeader('USER-AGENT', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.106 Safari/537.36')
}
这里需要注意的问题是:
- 强制写入
USER-AGENT,目的是模拟浏览器访问,防止中标人机检测。 - 对
header的key全部转大写。理论上,header的key不区分大小写,但不同的库实现情况不同,确实遇到过发送了大小写不同的两个header的情况。
封装 request
上述问题一一解决后,我们来进行封装:
整合代码
const http = require('http')
const https = require('https')
/**
* 判断使用 http 或者 https 连接
* @param {string} url
* @returns
*/
const getHttp = (url) => {
return /^https:/.test(url) ? https : http
}
const obj2url = (data) =>
data
? Object.keys(data).map(k => `${k}=${encodeURIComponent(data[k])}`).join('&')
: ''
const combinData = (url, data) => {
const query = obj2url(data)
if(query) {
const flag = url.indexOf('?') > -1 ? '&' : '?'
return `${url}${flag}${query}`
}
return url
}
const setHeaders = (req, header) => {
// 写入自定义header
if(header) {
Object.keys(header).forEach(key => {
req.setHeader(key.toUpperCase(), header[key])
})
}
// 高优先级header 覆盖
req.setHeader('USER-AGENT', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.106 Safari/537.36')
}
const request = (options) => {
const { method = 'GET', url, data, header, success, fail, complete } = options
const combinUrl = combinData(url, data)
const H = getHttp(combinUrl)
// 创建请求
const req = H.request(combinUrl, {
method
}, res => {
// 获取响应状态码和响应头
const { statusCode, headers } = res
// 创建一个收集内容的缓存池
const chunks = []
// 当有数据返回时,将内容放入缓存
res.on('data', chunk => {
chunks.push(chunk)
})
// 当响应结束时,汇总请求数据。
res.on('end', () => {
// 拼接所有缓存
const buff = Buffer.concat(chunks)
// 缓存转码
const data = buff.toString('utf-8')
// 输出最终内容
const result = { statusCode, headers, data, error: null, message: 'request:ok' }
typeof success === 'function' && success(result)
typeof complete === 'function' && complete(result)
})
res.on('error', (err) => {
const error = { error: err, message: 'response:fail' }
typeof fail === 'function' && fail(error)
typeof complete === 'function' && complete(error)
})
})
setHeaders(req, header)
req.on('error', (err) => {
const error = { error: err, message: 'request:fail' }
typeof fail === 'function' && fail(error)
typeof complete === 'function' && complete(error)
})
// 发起请求
req.end()
}
module.exports = request
request({
method: 'GET',
url: 'http://127.0.0.1:3001/',
data: 'a=1',
success(res) {
console.log(res)
},
fail(err) {
console.log(2, err)
}
})
输出
{
statusCode: 200,
headers: {
'content-type': 'text/plain; charset=utf-8',
'content-length': '5',
date: 'Mon, 28 Jun 2021 09:33:42 GMT',
connection: 'close'
},
data: '/?a=1',
error: null,
message: 'request:ok'
}
符合预期。
处理post请求
使用POST,与GET本质差异在于:POST的数据会写入请求的body中,也就是说,是带输入发起请求。
我们创建一个postData的二进制变量:
const postData = Buffer.from(M === 'POST' ? obj2url(data) : '', 'utf8')
然后在发送请求时,附加该数据:
// 发起请求
req.end(postData)
验证一下:
request({
method: 'POST',
url: 'http://127.0.0.1:3001/',
data: { a: 1 },
success(res) {
console.log(res)
},
fail(err) {
console.log(2, err)
}
})
输出
% node request.js
{
statusCode: 200,
headers: {
'content-type': 'text/plain; charset=utf-8',
'content-length': '3',
date: 'Mon, 28 Jun 2021 09:36:50 GMT',
connection: 'close'
},
data: 'a=1',
error: null,
message: 'request:ok'
}
返回的data是按照formData格式存贮,符合预期。
完整代码
request.js
const http = require('http')
const https = require('https')
/**
* 判断使用 http 或者 https 连接
* @param {string} url
* @returns
*/
const getHttp = (url) => {
return /^https:/.test(url) ? https : http
}
/**
* 对象转URL的key:value参数模式
* @param {*} data
* @param {*} enc
* @returns
*/
const obj2url = (data, enc) =>
data
? typeof data === 'string'
? enc ? encodeURIComponent(data) : data
: Object.keys(data).map(k => `${k}=${encodeURIComponent(data[k])}`).join('&')
: ''
/**
* 合并现有url和params
* @param {*} url
* @param {*} data
* @param {*} enc
* @returns
*/
const combinData = (url, data, enc) => {
const query = obj2url(data, enc)
if(query) {
const flag = url.indexOf('?') > -1 ? '&' : '?'
return `${url}${flag}${query}`
}
return url
}
/**
* request请求头配置
* @param {*} req
* @param {*} header
*/
const setHeaders = (req, header) => {
// 写入自定义header
if(header) {
Object.keys(header).forEach(key => {
req.setHeader(key.toUpperCase(), header[key])
})
}
// 高优先级header 覆盖
req.setHeader('USER-AGENT', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.106 Safari/537.36')
}
const request = (options) => {
const { method = 'GET', url, data, header, success, fail, complete } = options
const M = ((method || 'GET') + '').toUpperCase()
const combinUrl = M === 'POST' ? url : combinData(url, data, M !== 'POST')
const postData = Buffer.from(M === 'POST' ? obj2url(data) : '', 'utf8')
const H = getHttp(combinUrl)
// 创建请求
const req = H.request(combinUrl, {
method
}, res => {
// 获取响应状态码和响应头
const { statusCode, headers } = res
// 创建一个收集内容的缓存池
const chunks = []
// 当有数据返回时,将内容放入缓存
res.on('data', chunk => {
chunks.push(chunk)
})
// 当响应结束时,汇总请求数据。
res.on('end', () => {
// 拼接所有缓存
const buff = Buffer.concat(chunks)
// 缓存转码
const data = decodeURIComponent(buff.toString('utf-8'))
// 输出最终内容
const result = { statusCode, headers, data, error: null, message: 'request:ok' }
typeof success === 'function' && success(result)
typeof complete === 'function' && complete(result)
})
res.on('error', (err) => {
const error = { error: err, message: 'response:fail' }
typeof fail === 'function' && fail(error)
typeof complete === 'function' && complete(error)
})
})
setHeaders(req, header)
req.on('error', (err) => {
const error = { error: err, message: 'request:fail' }
typeof fail === 'function' && fail(error)
typeof complete === 'function' && complete(error)
})
// 发起请求
req.end(postData)
}
module.exports = request
单元测试
request.test.js
const request = require('./request')
describe('Request get', () => {
it(`get /123`, done => {
request({
url: `http://127.0.0.1:3001/123`,
success({ data }) {
expect(data).toEqual('/123')
done()
}
})
})
it(`get /中文`, done => {
request({
url: `http://127.0.0.1:3001/中文`,
success({ data }) {
expect(data).toEqual('/中文')
done()
}
})
})
it(`get /a=1`, done => {
request({
url: `http://127.0.0.1:3001/?a=1`,
success({ data }) {
expect(data).toEqual('/?a=1')
done()
}
})
})
it(`get /?a=中文`, done => {
request({
url: `http://127.0.0.1:3001/?a=中文`,
success({ data }) {
expect(data).toEqual('/?a=中文')
done()
}
})
})
})
describe('Request post', () => {
it(`post 123`, done => {
request({
method: 'post',
url: `http://127.0.0.1:3001`,
data: '123',
success({ data }) {
expect(data).toEqual('123')
done()
}
})
})
it(`post 中文`, done => {
request({
method: 'post',
url: `http://127.0.0.1:3001`,
data: '中文',
success({ data }) {
expect(data).toEqual('中文')
done()
}
})
})
it(`post { a: 1 }`, done => {
request({
method: 'post',
url: `http://127.0.0.1:3001`,
data: { a: 1 },
success({ data }) {
expect(data).toEqual('a=1')
done()
}
})
})
it(`post '{ a: 1 }'`, done => {
request({
method: 'post',
url: `http://127.0.0.1:3001`,
data: '{ a: 1 }',
success({ data }) {
expect(data).toEqual('{ a: 1 }')
done()
}
})
})
it(`post { a: "中文" }`, done => {
request({
method: 'post',
url: `http://127.0.0.1:3001`,
data: { a: "中文" },
success({ data }) {
expect(data).toEqual(`a=中文`)
done()
}
})
})
it(`post '{ a: "中文" }'`, done => {
request({
method: 'post',
url: `http://127.0.0.1:3001`,
data: '{ a: "中文" }',
success({ data }) {
expect(data).toEqual('{ a: "中文" }')
done()
}
})
})
})
测试
包含上一篇的koa.test.js
% npm test -- --verbose
> testnpm@1.0.0 test
> jest "--verbose"
PASS ./request.test.js
Request get
✓ get /123 (16 ms)
✓ get /中文 (2 ms)
✓ get /a=1 (1 ms)
✓ get /?a=中文 (2 ms)
Request post
✓ post 123 (1 ms)
✓ post 中文 (1 ms)
✓ post { a: 1 } (2 ms)
✓ post '{ a: 1 }' (1 ms)
✓ post { a: "中文" } (2 ms)
✓ post '{ a: "中文" }' (1 ms)
PASS ./koa.test.js
Koa service get
✓ get /123 (11 ms)
✓ get /?123 (2 ms)
✓ get /中文 (2 ms)
✓ get /?中文 (1 ms)
Koa service post
✓ post '中文' (1 ms)
✓ post 'AbC' (2 ms)
Test Suites: 2 passed, 2 total
Tests: 16 passed, 16 total
Snapshots: 0 total
Time: 0.462 s, estimated 1 s
Ran all test suites.
测试通过。以上。