← 代码课堂

第 18 课:纯函数与脱离平台测试

难度:★★☆(需要会一点 JS)
教材:wechat-map-tools/(微信小程序地图工具,47 项单测)
预计时间:讲解 60 分钟 + 练习 50 分钟
学完你能:把业务逻辑写成"纯函数",并用最轻的方式验证它——不用开模拟器、不用真机


课前须知:平台代码最难测

微信小程序的问题:页面逻辑跑在微信环境里,wx.getLocation、wx.setStorage 这些 API
在 Node 里根本没有。如果逻辑和平台 API 混在一起,测试就只能靠"拿真机点一遍"。

这个项目的做法:把能算法化的逻辑全部抽进 utils/,一行 wx 都不碰。
于是 node tests/test-core.js 就能跑完 47 项测试——秒级、免环境、可重复。

这就是"纯函数"的威力:输入相同 → 输出相同 → 没有副作用 → 随便测、随便搬。


第一步:跑测试

cd wechat-map-tools
node tests/test-core.js

你会看到按模块分组的 ok 列表,最后打印通过率(47/47)。
不需要装任何依赖、不需要微信开发者工具。


第二步:模块地图

miniprogram/utils/
├── coord.js   103 行  坐标系转换(纯函数)
├── geo.js      97 行  距离/面积/格式化(纯函数)
├── parse.js   379 行  文本/GeoJSON/GPX/KML 解析(纯函数)
└── store.js   127 行  normalize/merge 是纯函数;只有 load/save 碰 wx API

tests/test-core.js  211 行  自研极简测试框架 + 47 项断言
pages/              页面层(index/import)——渲染和 wx 调用在这里

分界线非常清楚:utils/ 是"算东西的",pages/ 是"显示和调平台 API 的"。
复习第 06、07 课:这就是逻辑与界面分离在小程序里的样子。


第三步:逐块精讲

块 1:坐标系三兄弟(coord.js 第 1-16 行)

/**
 * WGS-84:GPS / GPX 原始数据
 * GCJ-02:国内地图(微信 map 组件、腾讯、高德)用的"火星坐标"
 * BD-09 :百度在 GCJ-02 之上又偏移了一次
 */
const A = 6378245.0;                  // 克拉索夫斯基椭球长半轴
const EE = 0.00669342162296594323;    // 偏心率平方

function outOfChina(lat, lng) {
  return lng < 72.004 || lng > 137.8347 || lat < 0.8293 || lat > 55.8271;
}

背景知识(这也是个不错的谈资):中国大陆的地图出于安全考虑,
把真实 GPS 坐标经过一次非线性偏移后才显示,这就是"火星坐标" GCJ-02。
所以你把 GPS 轨迹直接画到微信地图上,会发现整体偏了几百米。

三个系统的用途:

坐标系谁用
WGS-84GPS 设备、GPX 文件(真实坐标)
GCJ-02微信/腾讯/高德地图显示
BD-09百度地图

outOfChina 是快速判断:境外不做偏移(边界是粗略的,够用即可)。

块 2:正向转换 + 反向"不动点迭代"(coord.js 第 34-65 行)

正向(WGS → GCJ)是公式直接算(第 35-46 行)。
难点在反向(GCJ → WGS):偏移公式是单向的,没有闭式反解。
作者的解法很聪明:

/**
 * GCJ-02 -> WGS-84:反解没有闭式解,用不动点迭代。
 * 偏移本身是米级小量且连续,迭代 3~5 次就收敛到厘米内。
 */
function gcj02ToWgs84(lng, lat) {
  if (outOfChina(lat, lng)) return { longitude: lng, latitude: lat };
  let wLng = lng, wLat = lat;
  for (let i = 0; i < 8; i++) {
    const g = wgs84ToGcj02(wLng, wLat);   // 把当前的"猜测值"正向转一遍
    const dLng = g.longitude - lng;       // 看差了多少
    const dLat = g.latitude - lat;
    wLng -= dLng;                         // 往反方向修正
    wLat -= dLat;
    if (Math.abs(dLng) < 1e-10 && Math.abs(dLat) < 1e-10) break;   // 收敛就停
  }
  return { longitude: wLng, latitude: wLat };
}

不动点迭代(fixed-point iteration)的思想:
"我不知道答案,但我能验证一个猜测准不准。那就先猜,再用误差修正,反复逼近。"
因为偏移量很小且连续,几次就收敛。测试里直接验证了这点(test-core.js 第 50-51 行):

const back = coord.gcj02ToWgs84(bjGcj.longitude, bjGcj.latitude);
ok('GCJ -> WGS 往返误差 < 1 厘米', geo.haversine(BJ, back) < 0.01, ...);

"往返测试"(round-trip test)是转换函数最有力的验证方式:
A→B→A 应该回到原点。

最后是统一入口(第 81-93 行),让调用方不用记六个函数名:

function toGcj02(lng, lat, from) {       // 任意源 → GCJ(显示用)
  if (from === 'gcj02') return { longitude: lng, latitude: lat };
  if (from === 'bd09') return bd09ToGcj02(lng, lat);
  return wgs84ToGcj02(lng, lat);
}
function fromGcj02(lng, lat, to) { ... } // GCJ → 目标(导出用)

块 3:球面几何 —— 距离与面积(geo.js)

距离用 Haversine 公式(第 9-17 行):

const EARTH_R = 6371008.8;   // 地球平均半径(米)
const D2R = Math.PI / 180;

function haversine(a, b) {
  const lat1 = a.latitude * D2R;
  const lat2 = b.latitude * D2R;
  const dLat = lat2 - lat1;
  const dLng = (b.longitude - a.longitude) * D2R;
  const s = Math.sin(dLat/2)*Math.sin(dLat/2) +
            Math.cos(lat1)*Math.cos(lat2)*Math.sin(dLng/2)*Math.sin(dLng/2);
  return 2 * EARTH_R * Math.asin(Math.min(1, Math.sqrt(s)));
}

面积用等距圆柱投影 + 鞋带公式(第 26-45 行):

/**
 * 多边形面积(平方米)
 * 做法:以首点纬度做等距圆柱投影,再用平面鞋带公式。
 * 对于公里级的地块,误差远小于 GPS 本身噪声,够用。
 */
function polygonArea(points) {
  if (!points || points.length < 3) return 0;
  const lat0 = points[0].latitude * D2R;
  let sum = 0;
  for (let i = 0; i < points.length; i++) {
    const p1 = points[i];
    const p2 = points[(i + 1) % points.length];    // 最后一个点连回第一个
    const x1 = p1.longitude * D2R * Math.cos(lat0);  // 经度按纬度压缩
    const y1 = p1.latitude * D2R;
    ...
    sum += x1 * y2 - x2 * y1;
  }
  return Math.abs(sum / 2) * EARTH_R * EARTH_R;
}

关键细节:经度方向要乘 cos(纬度)——因为越靠近两极,同样经度差代表的距离越短。
(把球面近似成"以首点纬度展开的平面",是这个精度下的合理妥协,注释也坦诚写了。)

boundsOf / centerOf(第 48-67 行)算外接矩形和中心,
供地图"自适应缩放"用——先抽稀、再自适应是地图应用的常规操作。

格式化函数(第 69-85 行)也值得注意:< 1000 米 显示米、否则显示公里,
面积自动切换到公顷/平方公里——面向人的输出要考虑可读性。

块 4:正则解析四种格式(parse.js)

小程序里没有 DOMParser,所以 XML 类格式(GPX/KML)用正则解析。
核心是两个小工具(第 26-33 行):eachTag 同时支持
<tag>...</tag> 和自闭合 <tag/>,decodeXml 处理实体转义。

坐标文本解析(第 80-119 行)的容错很有意思:

detectFormat(第 59-66 行)按内容特征判断格式,
最后统一入口 parseAny(第 357 行起)——和 coord.js 的统一入口同一种设计习惯。

测试提醒:test-core.js 对每种格式都有用例(GeoJSON 6 项、GPX 7 项、KML 3 项…),
而且验证的是"解析结果的具体数值",不是"不报错就行"。

块 5:存储层怎么做到"可测"(store.js)

核心取舍:本地存储统一用 GCJ-02(地图底座),导入时转一次、导出转回 WGS-84。
好处是避免"每次显示都转一次"的累积误差;代价是存进去的就不是真实坐标了——
README 里把取舍写清楚了。

工程上最关键的一点:

// normalize / merge 是纯函数(可以测)
// 只有 load / save / append / clearAll 才碰 wx.getStorageSync 等 API

所以测试能覆盖 normalize(把旧/脏数据补成标准结构)和 merge(合并、去重、限量),
而不用担心微信 API。还有个细节:isNum 专门处理 isFinite(null) === true 的坑。

这就是"脱离平台测试"的完整套路:
把纯逻辑和平台 API 分开 → 纯逻辑写测试 → 平台 API 只在真机验一次。

块 6:自研极简测试框架(test-core.js 第 15-36 行)

let pass = 0, fail = 0;
const failures = [];

function ok(name, cond, extra) {
  if (cond) { pass++; console.log('  ok   ' + name); }
  else { fail++; failures.push(name + (extra ? ' -> ' + extra : '')); console.log('  FAIL ' + name + ...); }
}
function near(name, actual, expect, tol) {
  ok(name, Math.abs(actual - expect) <= tol, 'actual=' + actual + ' expect=' + expect + ' ±' + tol);
}
function group(title) { console.log('\n' + title); }

30 行就有了一个能用的测试框架:断言 + 分组 + 计数 + 失败收集,
最后 process.exit(fail ? 1 : 0)(让 CI/脚本能感知失败)。
不引入 Jest/Mocha,是因为这个小项目不值得为测试工具装几十 MB 依赖
(和第 02 课"零依赖"、第 17 课"零依赖 Markdown"是同一种取舍)。

块 7:用"已知答案"验收

看测试用例(第 41-54 行)——它们不是随便编的数字:

const BJ = { longitude: 116.397428, latitude: 39.90923 }; // 天安门附近,WGS-84
const bjGcj = coord.wgs84ToGcj02(BJ.longitude, BJ.latitude);
near('天安门 GCJ 经度', bjGcj.longitude, 116.4036716, 1e-6);
near('国内偏移量在 500~600 米', geo.haversine(BJ, bjGcj), 555, 60);

const tokyo = { longitude: 139.6917, latitude: 35.6895 };
ok('境外点不做偏移', ...);

还有"京沪约 1068 公里""1km 见方 ≈ 1km²"等领域已知答案。
用外部事实做断言,比"我自己跑一遍得到的数字"可信得多——
这是测试设计的一个重要原则。


第四步:对照开源,别人怎么组织"可测试的逻辑"

纯函数的好处(为什么不只在"能测")

好处说明
可测试不需环境、不需 mock、快
可复用换语言/换平台直接搬(这四个文件几乎能原样用在网页/H5)
可推理输入输出确定,读代码就能想明白
可缓存相同输入可缓存结果

对照各平台的"逻辑分离"

平台分离方式
微信小程序(本项目)utils/ 纯函数 + pages/ 页面
Python 桌面(第 07 课)core/ + gui/,core 不 import PyQt
React(第 11 课)自定义 Hook + 组件
后端(第 06 课)service / repository 分层

"把能算的和能显示的分开"是跨平台通用能力。

对照测试策略


动手练习(做完才算过关)

练习 1(热身):故意改坏测试
把 test-core.js 里"天安门 GCJ 经度"的期望值改错一位小数,跑测试看失败输出,
再改回来。观察 near 打印的 actual / expect / ±tol 有多清楚。

练习 2(必做):加一条往返测试
给悉尼({ longitude: 151.2093, latitude: -33.8688 })加一条
"境外不偏移"的断言(提示:模仿东京那三行)。

练习 3(必做):BD-09 往返测试
coord.js 有 gcj02ToBd09 和 bd09ToGcj02。写一条测试验证
"GCJ → BD → GCJ 往返误差 < 1e-6 度",用 near() 断言。

练习 4(必做):解析器容错实验
构造一段坐标文本,混入:中文逗号、# 注释、一行乱码。
把 parseCoordText 的输出和 errors 打印出来,确认"非法行不影响合法行解析"。

练习 5(思考题):
为什么 polygonArea 用"首点纬度"做投影,而不是每个点各用各的纬度?

看答案

鞋带公式要求多边形在同一个平面上;公里级尺度下这个近似够不够?

练习 6(选做,跨语言):
把这个项目里你最喜欢的一个纯函数(比如 haversine 或 gcj02ToWgs84)
用 Python 重写一份,并配上 pytest 测试(对照你学过的第 12 课)。
体会:算法是跨语言的,工程习惯也是。


本课小结

术语表

术语人话解释
纯函数无副作用、同输入同输出的函数
副作用修改外部状态(文件、网络、全局变量)
WGS-84 / GCJ-02 / BD-09真实坐标 / 火星坐标 / 百度坐标
不动点迭代用"猜测→修正"反复逼近方程解
往返测试A→B→A 应回到原点
Haversine用球面三角算两点距离的公式
鞋带公式用顶点坐标算多边形面积的公式
等距圆柱投影把球面按某纬度近似摊平成平面
测试夹具 / 断言测试环境 / "应该是什么"的判断
单元 / 集成 / 端到端测单个函数 / 测配合 / 测完整链路

下节预告

第 19 课:零依赖构建与交付验收。
教材是 portfolio-site(作品集静态站)+ course-publisher(课程发布器):
纯 Node 写构建器(含原子发布、失败不替换旧版)、
用 node:test 写"契约测试"、用 headless Edge + CDP 做真浏览器验收,
还要看那个很酷的汉字笔顺开场动画是怎么用贝塞尔曲线和动画描边实现的。