最近把海创软件组的这个综合项目复盘了一遍发现它正好串起了我平时工作里最常碰到的几条技术线Vue页面、mpvue小程序、Koa服务端以及一个用纯JS实现的图片点击标注坐标功能。项目本身不大但积累下来的问题和经验倒不少。这篇博客就把这四块内容的实现思路、核心代码和踩坑记录都理一遍给正在做类似前端实践的同学一个参考。先说整体背景。这个项目要同时支撑两个端一个是小程序端负责天气展示一个是常规的Vue页面端负责图片标注这类管理功能。两块业务都靠一个用Koa搭的前端服务来转发数据、处理跨域。听起来模块不少但其实每一块拆开看都不复杂放在一起才考验对技术栈的理解。下文按模块逐步拆解遇到我踩过的坑会直接标出来。1. 项目全貌四块功能是怎么串起来的1.1 这个项目到底在做什么你可以把整个项目理解成一套“数据采集与展示”的小系统。天气部分解决的是“当地天气数据从哪来、怎么在小程序和Web页面上展示”图片标注部分解决的是“用户在一张图片上点击某个位置系统要记录这个位置的坐标并且能事后回显这些标记”。图片标注听起来像是个小功能但实际上手后你会发现它牵涉到坐标换算、图片自适应、数据存储等一系列细节而且和天气功能共用同一套前端技术栈维护成本是叠加的。Koa服务则是所有数据的汇集中转站统一接收页面请求再去上游天气接口取数据返回给前端。1.2 mpvue、Vue、Koa的分工逻辑这个项目的分工其实非常清晰我用一句话概括Vue负责Web端交互mpvue负责小程序端展示Koa负责数据代理和接口统一。当时选择mpvue而不是原生小程序开发主要原因是我们团队里已经有比较熟练的Vue开发经验。mpvue允许直接用Vue语法写小程序页面模板、指令、组件化那套思维方式完全通用能省去重复学习原生小程序WXML、WXSS的时间。Koa则是因为它足够轻量中间件机制简单直观很适合做接口代理这种单薄的服务层。还有一点很关键用Koa在中间层转发天气接口能规避掉浏览器和小程序端的跨域问题。天气接口通常不开放跨域就算开放了也容易因为频繁被第三方校验拦住。自己搭一层服务相当于把数据请求集中管理前端只管调用相对路径。1.3 技术选型对比与思考过程技术模块可选方案最终选择选型理由Web页面Vue / ReactVue团队熟悉项目简单模板写法直白小程序端原生 / mpvue / uni-appmpvue复用Vue语法迁移和学习成本最低服务端Express / Koa / 云函数Koa代码简洁async/await友好不需要重型框架天气数据源高德 / 和风 / 聚合和风高德定位免费额度够用文档清晰请求链路成熟其实过程中也纠结过用uni-app一步到位做跨端但项目里已经有独立的Vue页面统一迁移到uni-app改动太大。mpvue在今天看来确实生态偏老旧迭代速度也不如以前但它的设计思路在当时非常吸引人尤其适合一个以Vue为主的团队快速出小程序版本。如果是新项目我会更倾向于uni-app或直接上原生但作为当时的技术现状mpvue是个务实选择。2. Vue获取当地天气从定位到展示的完整链路2.1 天气数据源对比与选择获取当地天气首先得解决“当地”这两个字也就是定位。定位方式决定你能拿到多精确的经纬度而经纬度又直接决定天气接口回传数据的准确性。我当时对比了几个常见天气数据源高德开放平台定位比较成熟提供IP定位和经纬度定位天气数据也有但天气信息偏向简单。和风天气天气数据维度丰富温度、湿度、风向、空气质量、生活建议都有免费版够用。聚合数据需要申请不同的接口流程偏重适合有密钥管理需求的商业项目。百度地图API定位能力强但天气接口集成相对繁琐。最终采用“高德定位 和风天气”的组合。核心原因是高德的定位接口对浏览器和小程序都比较友好和风的天气数据则足够详细可以在展示层做更多文章。需要注意的是和风天气有新老两个版本的接口认证方式老的用参数传递key新的用Header头传递Token。两者我都试过建议优先使用新版本因为老版本有个别地区查询时数据延迟偏高。如果是学习Demo哪个版本都能跑通关键是别把key硬编码在页面上不然一上线就会泄漏。2.2 定位逻辑与兜底方案浏览器定位在开发环境跑起来很容易但真机环境往往会因为权限设置或者网络环境导致定位失败。所以定位逻辑必须设计成“主链路 兜底链路”。主链路用浏览器Geolocation代码很简单function getPosition() { return new Promise((resolve, reject) { if (!navigator.geolocation) { reject(new Error(浏览器不支持定位)) return } navigator.geolocation.getCurrentPosition( (pos) resolve({ lat: pos.coords.latitude, lng: pos.coords.longitude }), (err) reject(err), { timeout: 5000, maximumAge: 60000 } ) }) }这里的timeout参数很关键默认情况下如果用户长时间不授权请求会一直挂着。加上5秒超时至少能给用户一个明确的失败反馈。如果主链路失败兜底方案有几种。一是请求高德的IP定位接口根据用户出口IP反查城市信息得到城市编码再传给天气接口。二是在小程序端使用wx.getLocation但如果用户拒绝授权同样需要走IP定位兜底。实际开发中我发现IP定位在城市级别的准确率没问题但精确到街道就差一些所以只拿它当兜底。2.3 请求封装与跨域代理定位拿到经纬度后接下来就要请求天气数据。这个过程需要注意两点一是前端不能直接请求第三方接口否则会遇到跨域问题二是请求逻辑最好封装成统一方法多个页面复用。我们在web端直接请求的是Koa服务端的相对路径例如/api/weather?latxxlngxx由Koa再去请求和风天气。这样处理后前端页面根本不用关心跨域问题。在Vue里封装请求我用的是axios示例代码如下import axios from axios async function fetchWeather(lat, lng) { const { data } await axios.get(/api/weather, { params: { lat, lng }, timeout: 10000 }) return data }小程序端则使用微信自带的wx.request但需要配置request合法域名。开发阶段可以勾选“不校验合法域名”上线前记得在后台配置域名白名单否则请求会被拦下来。这是一个非常典型的“开发环境一切正常一上线就失败”的坑。2.4 天气展示的体验细节天气数据拿到之后展示部分反而是最考验耐心的。手机屏幕就这么大信息层级怎么排是直接决定用户体感的因素。我在项目里把展示分成三个层级第一层当前温度和天气现象字体最大最醒目。第二层城市名和更新时间用于说明数据的归属和时效。第三层风向、湿度、空气质量和穿衣建议属于辅助信息用小字号收纳在下方卡片里。还有一个小细节天气图标。直接用第三方图标库的话要注意版权我当时用的是最简单的方式根据天气code映射到本地SVG。这样离线也能展示不依赖外部图片资源。另外天气数据最好做本地缓存。天气接口虽然免费版调用次数够用但毫无节制地刷新不仅浪费配额还会造成页面闪烁。我设置的是10分钟缓存缓存有效期内直接读取本地存储过期才重新请求。3. mpvue开发小程序的一点小总结3.1 mpvue生命周期映射关系mpvue最吸引人的一点是生命周期基本沿用了Vue的习惯但它毕竟是跑在小程序环境里的两者生命周期的对应关系一定要理清楚。简单来说Vue的mounted在小程序里的对应时机是页面初次渲染完成而onShow则对应页面每次出现在前台的事件。两者有交集但不等同。比如页面从后台切回前台mounted不会再触发但onShow一定会触发。所以获取定位、刷新天气这类“每次进入页面都要做”的逻辑应该放在onShow里而不是created或mounted。script export default { onShow() { this.refreshWeather() }, methods: { async refreshWeather() { const location await this.getLocation() const weather await this.fetchWeather(location.lat, location.lng) this.weather weather } } } /script3.2 页面开发里常见的坑mpvue开发里我被绊过的坑总结起来主要有三个。第一个是v-for循环中key的处理。mpvue对key的要求和小程序虚拟DOM的diff逻辑有关如果循环内部包含输入框之类的交互组件key不唯一会导致状态错乱。当时一个列表渲染天气数据时偶尔会出现勾选状态串到其他行的情况排查了很久才定位到是key的问题。解决方案很简单给每个循环项绑定唯一的id不要用index应付了事。第二个是自定义组件的样式隔离。小程序天然有样式隔离的属性mpvue虽然写着Vue语法但组件样式依然受限于小程序环境。如果发现某个组件的scoped样式不生效多半是因为编译后生成了类似_v-xxxx的选择器和原生小程序的class解析规则冲突。处理方法是尽量少用scoped样式用类名前缀替代。第三个是页面路由。在Vue里习惯了this.$router.pushmpvue里没有这个对象必须调用小程序原生的导航APIwx.navigateTo({ url: /pages/detail/main?city city })参数传递也只能通过URL字符串对象传参就得自己序列化或者借助全局存储。3.3 组件通信与全局状态mpvue把Vue的组件通信机制大部分都带过来了父子组件之间可以用props、自定义事件。但跨页面通信和Vue Router那套方式差别很大没有全局事件总线也没有官方推荐的Vuex变体常规做法是用小程序的globalData或者本地storage。我在项目里做了一个简单的全局状态对象放在独立模块里类似这样// store.js const store { city: , weather: null, listeners: [], set(key, value) { this[key] value this.listeners.forEach(fn fn(key, value)) }, watch(fn) { this.listeners.push(fn) } } export default store页面A设置store.city后页面B在onShow时主动读取或者通过watch回调更新视图。这种方式比到处用storage方便也比引入完整Vuex轻量得多。当然这只适用于小型项目全局状态一旦复杂还是得上正规的状态管理方案。3.4 分包与性能优化小程序对包体积有严格限制主包不能超过一定大小超出部分才走分包。mpvue编译产物里每个页面都会生成独立的js文件如果再引入一些体积较大的依赖很容易碰到超限问题。我的经验是首页和核心页面放主包其余低频页面拆到分包里。mpvue官方提供了配置分包的入口在app.json里添加subPackages字段。还需要注意静态图片资源。小程序里本地图片会直接计入包体如果图片多建议能放CDN就放CDN别让图片拖垮包体积。另外列表数据的渲染上尽量使用wx:if控制组件的挂载与卸载避免一次性渲染太多组件导致页面卡顿。天气页面数据量不大但图片标注列表可能涉及几十上百个圆点如果同时渲染也很吃力。后面会讲到圆点数量多时应该考虑用canvas绘制而非DOM节点。4. 前端基于Koa搭建服务器的关键细节4.1 服务端这一层的必要性可能有人会问项目本身不算大为什么还要单独搭一个Koa服务器直接在前端请求第三方天气接口不行吗直接请求的问题有两个。第一是跨域绝大多数天气接口不会为你的域名单独开放CORS。第二是密钥安全第三方接口的key如果放在前端代码里只要用户打开浏览器控制台就能看到等于把你的免费额度公之于众。密钥一旦泄漏轻则被刷爆配额重则被拉黑。Koa作为中间层把第三方请求的逻辑收敛到服务端前端只跟自己的服务通信安全性和代码整洁度都能兼顾。哪怕是个人学习项目我也建议养成这个习惯任何需要密钥的第三方接口一律走后端代理。4.2 Koa 路由 上游接口代理Koa搭建最小可用服务相当快核心代码就几行const Koa require(koa) const Router require(koa/router) const axios require(axios) require(dotenv).config() const app new Koa() const router new Router() router.get(/api/weather, async (ctx) { const { lat, lng } ctx.query if (!lat || !lng) { ctx.status 400 ctx.body { message: 缺少经纬度参数 } return } const response await axios.get(https://devapi.qweather.com/v7/weather/now, { params: { location: ${lng},${lat}, key: process.env.QWEATHER_KEY }, timeout: 5000 }) ctx.body response.data }) app.use(router.routes()) app.use(router.allowedMethods()) app.listen(3000, () { console.log(server running at http://localhost:3000) })这段代码的要点集中在参数校验和超时设置。参数校验是为了避免无效请求打到上游超时设置则是防止上游接口慢拖垮我们自己的服务。如果业务逻辑继续增长路由可以按模块拆分开。但就本项目而言一个路由文件完全够用不要过早抽象否则反而增加理解成本。4.3 缓存、限流与环境变量Koa服务作为中间层有一个天然优势可以在服务端做数据缓存避免每次请求都打到上游。天气数据本身是分钟级更新的所以一个简单的内存缓存足够const cache { data: null, timestamp: 0 } router.get(/api/weather, async (ctx) { const now Date.now() if (cache.data now - cache.timestamp 10 * 60 * 1000) { ctx.body cache.data return } // 请求上游并更新缓存 })这个缓存虽然简陋但胜在逻辑直观。如果需要更精细的按城市缓存可以把缓存key设为经纬度存成Map。环境变量管理方面我强烈建议使用dotenv。在项目根目录建一个.env文件写入QWEATHER_KEYxxx然后在代码里通过process.env.QWEATHER_KEY读取。注意把.env加进.gitignore密钥绝对不能提交到仓库。限流是个容易被忽略的点。没有限流的话一旦前端代码异常循环请求Koa服务就会不断去请求上游接口配额很快耗尽。简单做法是封装一个计数器对单IP做每分钟请求数限制超过限制直接返回429。4.4 部署和进程管理Koa服务部署其实没那么多花活。我的做法是在服务器上安装Node环境把代码拉下来安装依赖然后使用PM2管理进程。pm2 start app.js --name weather-server pm2 save如果服务器上有Nginx可以配置反向代理把某个路径转发到Koa的3000端口。这样前端请求走的还是80端口但实际由Koa服务处理。部署中有一个坑Koa服务要监听0.0.0.0而不是默认的localhost。如果只监听localhost外部请求就进不来。这不是Koa特有的问题但Node新手经常在这上面栽跟头。5. Vue中使用JS实现图片点击标注圆点并记录坐标5.1 功能拆解与交互设计图片标注功能是项目里最有交互感的部分。用户在一张图片上点击任意位置页面上就在对应位置出现一个圆点同时把这个点的坐标记录下来。这个功能常见于地图打点、图片审核、商品标注等场景。实现之前先想清楚交互设计。我定义的交互规则是点击图片任意位置添加一个新的圆点标注。每个圆点有唯一的序号。圆点颜色可以区分不同状态例如默认蓝色、选中红色。标注数据实时保存在本地下次刷新页面还能回显。这里最核心的技术点不是圆点的渲染而是“如何把鼠标点击的位置精准换算成图片原始坐标系里的坐标”。5.2 坐标换算的核心实现页面里图片可能被CSS缩放展示。比如一张原始尺寸是800x600的图片在屏幕上显示成400x300。用户在屏幕上的点击位置是(200, 100)对应到图片原始坐标就应该是(400, 200)。如果直接用屏幕坐标存储一旦图片显示尺寸变化标注位置就会错位。换算公式很简单scaleX img.naturalWidth / img.clientWidth scaleY img.naturalHeight / img.clientHeight originalX (event.clientX - rect.left) * scaleX originalY (event.clientY - rect.top) * scaleY完整实现如下template div classimage-annotator div classimg-wrapper clickhandleClick img refimg :srcimgSrc classtarget-img draggablefalse loadhandleImgLoad / span v-fordot in dots :keydot.id classdot-marker :style{ left: dot.displayX px, top: dot.displayY px } /span /div /div /template script import { nanoid } from nanoid export default { data() { return { imgSrc: /images/plan.png, dots: [], imgNaturalWidth: 0, imgNaturalHeight: 0 } }, methods: { handleImgLoad() { const img this.$refs.img this.imgNaturalWidth img.naturalWidth this.imgNaturalHeight img.naturalHeight this.loadDotsFromStorage() }, handleClick(event) { const img this.$refs.img const rect img.getBoundingClientRect() const scaleX this.imgNaturalWidth / rect.width const scaleY this.imgNaturalHeight / rect.height const originalX Math.round((event.clientX - rect.left) * scaleX) const originalY Math.round((event.clientY - rect.top) * scaleY) const displayX event.clientX - rect.left const displayY event.clientY - rect.top const dot { id: nanoid(6), x: originalX, y: originalY, displayX: Math.round(displayX), displayY: Math.round(displayY) } this.dots.push(dot) this.saveDotsToStorage() }, saveDotsToStorage() { localStorage.setItem(image-dots, JSON.stringify(this.dots)) }, loadDotsFromStorage() { const raw localStorage.getItem(image-dots) if (raw) { try { this.dots JSON.parse(raw) this.recalcDisplayPosition() } catch (e) { this.dots [] } } }, recalcDisplayPosition() { const img this.$refs.img const rect img.getBoundingClientRect() const scaleX rect.width / this.imgNaturalWidth const scaleY rect.height / this.imgNaturalHeight this.dots.forEach((dot) { dot.displayX Math.round(dot.x * scaleX) dot.displayY Math.round(dot.y * scaleY) }) } } } /script style scoped .img-wrapper { position: relative; display: inline-block; } .target-img { display: block; max-width: 100%; } .dot-marker { position: absolute; width: 16px; height: 16px; border-radius: 50%; background: rgba(66, 133, 244, 0.85); border: 2px solid #fff; transform: translate(-50%, -50%); box-sizing: border-box; cursor: pointer; } /style关键点在于同时保存原始坐标和显示坐标。原始坐标用于持久化存储和业务逻辑显示坐标用于当前屏幕上的渲染定位。窗口尺寸变化时用recalcDisplayPosition重新计算显示坐标就能保证标注点在缩放情况下不跑偏。5.3 坐标的记录、存储与回显坐标存储格式我用的是JSON数组。每条记录至少包含id、x、y三个字段。如果后续要给每个点加备注可以在对象上扩展label、remark字段数据格式本身就是开放的。浏览器端用localStorage存储是个快速方案缺点是缓存空间有限一般5MB左右存坐标数据完全够用。如果要做到多端同步建议把标注数据通过Koa服务传到后端数据库。前端只需要把POST /api/annotations和GET /api/annotations两个接口就能实现数据私有化保存。回显逻辑比添加逻辑多一个步骤需要在图片加载完成后执行坐标换算。因为图片还没加载完成时naturalWidth是0算出来的比例全是错的。这就是上面代码里在load事件里调用loadDotsFromStorage的原因。5.4 边界情况与兼容处理图片标注有几个边界情况特别容易出问题。第一个是圆点渲染数量爆炸。当标注点超过几百个时每个圆点都是一个DOM节点页面会明显卡顿。我的处理是设置一个阈值超过50个点后改用canvas绘制。canvas绘制圆点的逻辑不复杂但性能和DOM方案不可同日而语。第二个是点击图片边缘时圆点溢出。如果点击位置太靠近图片边缘圆点会有一半显示在图片外面。处理方案是在换算坐标前加一个边界检查把坐标限制在图片范围之内。第三个是图片object-fit: cover模式下的坐标错位。如果图片不是直接设置宽高而是用CSS控制缩放填充那getBoundingClientRect得到的尺寸和实际渲染的图片内容区域可能不一致需要根据object-fit的计算结果再做偏移修正。这是最容易忽略的一个坑建议项目里统一用宽度自适应模式展示原始图片尽量避免cover模式。6. 实操中遇到的典型问题与排查思路6.1 小程序请求天气接口失败的排查现象开发工具里请求天气接口偶尔能通真机预览时几乎必失败。排查过程先看了小程序控制台的报错显示“request:fail url not in domain list”。原因是小程序对request接口有域名白名单限制开发工具可以勾选“不校验合法域名”绕过但真机上必须配置合法域名。解决方案是在微信公众平台的后台把Koa服务对应的域名加到request合法域名列表里并确保该域名是HTTPS协议。经验教训小程序开发如果涉及自建服务最好在项目启动第一天就确认域名和HTTPS证书是否就绪不要拖到联调阶段才暴露问题。免费证书的申请和部署本身也需要一点时间。6.2 Koa代理接口异常时的处理现象Koa服务本地运行正常部署到服务器后接口返回502或504。排查过程先查Koa服务的日志发现请求确实到达了服务端但等待上游返回时超时。进一步检查发现服务器到天气接口的网络链路存在限制直接curl测试上游接口时延在2秒以上。最终在axios请求里把timeout从5秒放宽到10秒并增加了重试机制问题才缓解。经验教训任何第三方接口代理都应当设置合理的超时和重试策略同时给前端返回标准化错误信息类似{ code: 502, message: 上游服务超时 }。前端拿到错误后可以提示用户稍后重试而不是永久转圈。6.3 图片标注坐标偏移问题的排查现象图片加载后第一次点击圆点位置偏了缩放窗口后再点击圆点位置又偏了。排查过程第一次偏移是因为图片还没加载完成就绑定了点击事件此时naturalWidth是0比例系数算出为0坐标自然错误。处理方式是把事件绑定逻辑挪到load回调里同时在recalcDisplayPosition时判断图片是否加载完成。第二次偏移是因为窗口缩放后没有重新计算显示坐标在window.resize事件里加一个重新计算的方法即可。经验教训所有依赖图片原生尺寸的逻辑必须等load事件触发后再执行。不要在creator阶段就对图片尺寸做假设。6.4 mpvue列表渲染和样式上的坑现象列表页偶尔出现组件状态错乱样式在开发者工具正常但真机异常。排查过程列表状态错乱定位到v-for的key重复问题把key换成数据的唯一id后解决。真机样式异常则是因为mpvue的样式编译存在兼容性问题部分CSS属性在真机上表现不一致。凡是涉及单位转换、百分比宽度、flex布局的属性都要在真机上提前验证不能只看开发者工具效果。经验教训mpvue项目里尽量使用小程序原生支持的基础组件来拼页面不要过度依赖复杂的自定义组件。原生组件兼容性最好调试成本最低。6.5 环境切换与接口联调开发过程中存在多个环境本机Koa、服务器测试环境、线上环境。如果前端代码里把接口地址写死每次切换环境都要改代码重新编译非常折腾。我的做法是在前端代码里维护一个环境配置模块const ENV { development: { baseURL: http://localhost:3000 }, production: { baseURL: https://api.example.com } } const currentEnv process.env.NODE_ENV production ? production : development export default ENV[currentEnv]这样前端只管用baseURL拼路径打包时根据环境自动选择地址省去手动修改的步骤。写在最后的一些个人体会做完这个项目我最大的感受是所谓全栈不是所有技术都要精通而是能把几个技术栈打通知道请求怎么走、数据怎么流、坑大概在哪。这个项目里从Vue页面到mpvue小程序再到Koa服务端最后到第三方天气接口链路虽长但每一层的职责都很清晰。图片标注功能让我对坐标换算和浏览器事件有了更深的理解。很多看起来简单交互实际做起来都要处理一堆边界情况。建议在动手前先画清楚流程图和数据格式能省不少返工时间。最后再分享一个习惯项目中所有调通的接口、踩过的坑、解决过的问题我都随手记在项目文档里。这个项目结束一个多月后再来写总结很多细节还能回忆起来靠的全是当时的记录。如果你也在做类似的多端项目强烈建议养成这个习惯。