首页
直播
壁纸
友链
搜索
1
微信小程序支付全链路实战:JSAPI 下单、调起支付、回调验签与退款
266 阅读
2
微信小程序云开发实战:云函数、云数据库与云存储的正确使用姿势
256 阅读
3
微信小程序自定义 tabBar 实战:custom-tab-bar 从适配到深色模式
255 阅读
4
微信小程序 Skyline 渲染引擎实战:worklet 动画从原理到落地
253 阅读
5
微信小程序分包进阶:独立分包、预下载与分包异步化实战
246 阅读
服务器运维
后端技术
前端技术
梯子
数据库
小程序
登录
搜索
标签搜索
fastadmin
Redis
微信小程序
前端开发
RabbitMQ
Go
服务器
codex
buildadmin
小程序
mysql
Nginx
Docker
Vue3
Node.js
MySQL优化
Linux
TypeScript
JWT
PHP
沿途的风景
累计撰写
74
篇文章
累计收到
0
条评论
首页
栏目
服务器运维
后端技术
前端技术
梯子
数据库
小程序
页面
直播
壁纸
友链
搜索到
74
篇与
» admin
的结果
2026-06-26
Vue 3 响应式原理剖析:从 Proxy 到副作用调度
Vue 3 用 Proxy 重写了响应式系统,性能与能力都大幅提升。本文从源码设计角度剖析依赖收集、触发更新、副作用调度和计算属性的实现原理,并手写一个迷你响应式系统。一、Vue 2 vs Vue 3 响应式对比特性Vue 2 (defineProperty)Vue 3 (Proxy)属性新增/删除需要 Vue.set/Vue.delete直接支持数组索引赋值不支持支持Map/Set不支持支持初始化递归遍历所有属性惰性代理,访问时才转换性能大对象初始化慢按需代理,更快// Vue 2 的局限 this.obj.newProp = 'value' // ❌ 不触发更新 this.arr[0] = newItem // ❌ 不触发更新 this.arr.length = 0 // ❌ 不触发更新 // Vue 3 全部支持 state.newProp = 'value' // ✅ state.arr[0] = newItem // ✅ state.arr.length = 0 // ✅ state.map.set('key', val) // ✅二、Proxy 基础const raw = { price: 100, quantity: 2 } const proxy = new Proxy(raw, { get(target, key, receiver) { console.log(`读取 ${key}`) track(target, key) // 依赖收集 return Reflect.get(target, key, receiver) }, set(target, key, value, receiver) { console.log(`设置 ${key} = ${value}`) const result = Reflect.set(target, key, value, receiver) trigger(target, key) // 触发更新 return result }, deleteProperty(target, key) { const result = Reflect.deleteProperty(target, key) trigger(target, key) return result }, has(target, key) { track(target, key) return Reflect.has(target, key) } }) proxy.price // 读取 price proxy.quantity = 3 // 设置 quantity = 3为什么用 Reflect? 保证 this 指向代理对象,让内嵌对象也能被正确追踪:const obj = reactive({ nested: { count: 0 } // nested 会被惰性转换为响应式 }) // obj.nested 访问时返回嵌套对象的代理 // 这是 Vue 3 实现深层响应式的关键三、手写迷你响应式系统1. 副作用函数与依赖收集let activeEffect = null const targetMap = new WeakMap() // 追踪依赖 function track(target, key) { if (!activeEffect) return // targetMap: target → key → Set<effect> let depsMap = targetMap.get(target) if (!depsMap) { depsMap = new Map() targetMap.set(target, depsMap) } let deps = depsMap.get(key) if (!deps) { deps = new Set() depsMap.set(key, deps) } deps.add(activeEffect) } // 触发更新 function trigger(target, key) { const depsMap = targetMap.get(target) if (!depsMap) return const deps = depsMap.get(key) if (!deps) return // 复制一份,避免无限循环 const effectsToRun = new Set(deps) effectsToRun.forEach(effect => { // 避免递归调用自身 if (effect !== activeEffect) { effect() } }) }2. effect 函数function effect(fn) { const effectFn = () => { // 清除旧依赖(解决分支切换问题) cleanup(effectFn) activeEffect = effectFn fn() activeEffect = null } effectFn.deps = [] effectFn() return effectFn } function cleanup(effectFn) { effectFn.deps.forEach(depSet => { depSet.delete(effectFn) }) effectFn.deps.length = 0 }3. reactive 实现const reactiveMap = new Map() function reactive(target) { // 同一对象只代理一次 if (reactiveMap.has(target)) { return reactiveMap.get(target) } const proxy = new Proxy(target, { get(target, key, receiver) { if (key === '__isReactive') return true track(target, key) const result = Reflect.get(target, key, receiver) // 惰性深层代理:嵌套对象访问时才转换 if (typeof result === 'object' && result !== null) { return reactive(result) } return result }, set(target, key, value, receiver) { const oldValue = target[key] const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD' const result = Reflect.set(target, key, value, receiver) // 值变化才触发 if (oldValue !== value || (typeof oldValue === 'NaN' && typeof value === 'NaN')) { trigger(target, key, type) } return result }, deleteProperty(target, key) { const hadKey = Object.prototype.hasOwnProperty.call(target, key) const result = Reflect.deleteProperty(target, key) if (hadKey && result) { trigger(target, key, 'DELETE') } return result } }) reactiveMap.set(target, proxy) return proxy }4. ref 实现function ref(value) { return { get value() { track(this, 'value') return value }, set value(newValue) { if (newValue !== value) { value = newValue trigger(this, 'value') } } } }5. computed 实现function computed(getter) { let value let dirty = true // 脏标记:是否需要重新计算 // 作为副作用运行,依赖变化时标记为脏 const effectFn = effect(() => { // 每次依赖变化重新执行,但不会立即触发渲染 if (dirty) { value = getter() } }) // 依赖变化时置脏 // (简化实现,真实实现用 scheduler) return { get value() { if (dirty) { value = getter() dirty = false } return value } } }更接近真实的 computed:function computed(getter) { let value let dirty = true const runner = effect(getter, { lazy: true, // scheduler:依赖变化时不立即执行,只标记脏 scheduler() { if (!dirty) { dirty = true // 通知依赖该 computed 的渲染副作用更新 triggerRefValue(this) } } }) return { get value() { if (dirty) { value = runner() dirty = false } // 让当前 activeEffect 追踪此 computed trackRefValue(this) return value } } }四、副作用调度与批更新Vue 3 的组件更新是异步批处理的:const queue = new Set() let isFlushing = false function queueJob(job) { queue.add(job) // Set 自动去重,同一组件多次修改只更新一次 if (!isFlushing) { isFlushing = true Promise.resolve().then(flushJobs) } } function flushJobs() { // 按组件 id 排序,父组件先更新 const jobs = [...queue].sort((a, b) => a.id - b.id) queue.clear() isFlushing = false jobs.forEach(job => job()) }这就是为什么修改多次数据只触发一次渲染:import { ref, nextTick } from 'vue' const count = ref(0) // 触发 3 次修改,但只渲染 1 次 count.value++ count.value++ count.value++ // DOM 更新是异步的 console.log(document.querySelector('#count').textContent) // 还是旧值 nextTick(() => { console.log(document.querySelector('#count').textContent) // "3" })五、Vue 3 编译优化静态提升(hoistStatic)// 模板中的静态节点会被提升到 render 函数外部 // 只创建一次,重复渲染时直接复用 // 编译前 // <div> // <span class="static">静态内容</span> // <span>{{ dynamic }}</span> // </div> // 编译后 const _hoisted_1 = createVNode("span", { class: "static" }, "静态内容") function render() { return createVNode("div", null, [ _hoisted_1, // 直接复用 createVNode("span", null, toDisplayString(dynamic)) ]) }Patch Flags(补丁标记)// 动态节点带上标记,diff 时只对比标记的部分 createVNode("span", null, text, 1 /* TEXT */) createVNode("span", { class: dynamicClass }, null, 2 /* CLASS */) createVNode("span", { id: dynamicId }, null, 4 /* PROPS */, ["id"]) // PatchFlag 枚举: // TEXT = 1 只有文本变化 // CLASS = 2 只有 class 变化 // STYLE = 4 只有 style 变化 // PROPS = 8 只有 props 变化 // FULL_PROPS = 16 props 有动态 key // NEED_HYDRATION = 32 // STABLE_FRAGMENT = 64 // KEYED_FRAGMENT = 128 带 key 的 fragment // ...Block Tree(块树)// 动态节点被收集到 block 中,diff 时跳过静态内容 function render() { return (openBlock(), createElementBlock("div", null, [ // 大量静态节点... createVNode("span", null, dynamic, 1 /* TEXT */) // 这个动态节点会被"block"追踪 // 更新时直接定位,不需要遍历整棵树 ])) }六、调试响应式onTrack 与 onTrigger// 仅开发模式可用 const state = reactive({ count: 0 }, { onTrack(event) { // 依赖被追踪时 console.log('track:', event.key, event.target) }, onTrigger(event) { // 依赖变化触发时 console.log('trigger:', event.key, event.type, event.newValue) } }) watch(source, callback, { onTrack(e) { debugger }, onTrigger(e) { debugger } })常见响应式陷阱// 1. 解构 reactive 丢失响应式 const state = reactive({ count: 0 }) let { count } = state // ❌ count 是普通值 const { count } = toRefs(state) // ✅ 保持响应式 // 2. ref 在模板中自动解包,在 JS 中需要 .value const count = ref(0) console.log(count) // Ref 对象 console.log(count.value) // 0 // 3. reactive 被整体替换 let state = reactive({ list: [] }) state = reactive({ list: [1] }) // ❌ 旧引用的依赖全部失效 // ✅ 用 ref 包裹,或只修改属性 // 4. markRaw 跳过转换 const state = reactive({ chart: markRaw(new ECharts()) // ✅ 第三方实例不代理 })七、小结Vue 3 响应式系统的核心是 Proxy 拦截 + WeakMap 存储依赖 + 副作用调度。理解 track/trigger 机制、惰性深层代理和异步批更新,不仅能帮你避开响应式陷阱,也是进阶 Vue 源码的第一步。手写迷你版响应式系统是最好的学习方式。本文由 inspirecl.asia 原创,转载请注明出处。
2026年06月26日
5 阅读
0 评论
0 点赞
2026-06-25
Vue 3 组合式 API 深度解析:Composition API 实战
组合式 API(Composition API)是 Vue 3 最重要的特性,解决了 Options API 在复杂组件中逻辑分散的问题。本文通过实战案例深入讲解 setup、响应式 API、生命周期钩子和自定义组合函数。一、为什么需要组合式 APIOptions API 的痛点// Options API:同一功能的代码被拆散到不同选项中 export default { data() { return { // 搜索功能的数据 searchQuery: '', searchResults: [], // 用户功能的数据 user: null, userPermissions: [], } }, computed: { // 搜索功能的计算属性 filteredResults() { /* ... */ }, // 用户功能的计算属性 isAdmin() { /* ... */ }, }, methods: { // 搜索功能的方法 handleSearch() { /* ... */ }, // 用户功能的方法 loadUser() { /* ... */ }, }, mounted() { // 搜索和用户的初始化逻辑混在一起 this.handleSearch() this.loadUser() } }组合式 API 的优势<script setup> // 搜索功能的所有代码聚合在一起 const { searchQuery, searchResults, handleSearch } = useSearch() // 用户功能的所有代码聚合在一起 const { user, isAdmin, loadUser } = useUser() </script>优势说明逻辑复用自定义组合函数替代 mixin,无命名冲突代码组织相关逻辑聚合,而非按选项类型分散类型推导更好的 TypeScript 支持代码压缩setup 语法糖减少样板代码生产性能编译优化(静态提升、patch flag)二、setup 与 script setup基本用法<script setup> import { ref, computed, onMounted } from 'vue' // 顶层变量自动暴露给模板 const count = ref(0) const message = 'Hello' const double = computed(() => count.value * 2) function increment() { count.value++ } onMounted(() => { console.log('组件已挂载') }) </script> <template> <button @click="increment">{{ count }} × 2 = {{ double }}</button> </template>defineProps 与 defineEmits<script setup> // 编译宏,无需导入 const props = defineProps({ title: String, items: { type: Array, default: () => [] }, disabled: Boolean }) // TypeScript 方式 // const props = defineProps<{ // title: string // items: Item[] // disabled?: boolean // }>() const emit = defineEmits(['update', 'delete']) // 带验证的 emit // const emit = defineEmits<{ // (e: 'update', value: number): void // (e: 'delete', id: string): void // }>() function handleUpdate() { emit('update', props.title) } </script>defineExpose 与 defineModel<script setup> import { ref } from 'vue' const count = ref(0) // 暴露给父组件的方法 defineExpose({ reset: () => { count.value = 0 }, getValue: () => count.value }) // Vue 3.4+ defineModel 双向绑定 const modelValue = defineModel({ default: '' }) const visible = defineModel('visible', { type: Boolean, default: false }) </script>三、响应式 APIref 与 reactiveimport { ref, reactive, shallowRef, shallowReactive } from 'vue' // ref — 适合基本类型和需要整体替换的对象 const count = ref(0) const user = ref({ name: '张三', age: 25 }) console.log(count.value) // 访问需 .value user.value.name = '李四' // 修改属性 user.value = { name: '王五' } // 整体替换 // reactive — 适合不替换整体的复合对象 const state = reactive({ items: [], loading: false, filters: { category: null, price: null } }) state.loading = true // 直接访问,无需 .value state.items.push({ id: 1 }) // ⚠️ reactive 的陷阱 let { items } = state // ❌ 解构丢失响应式 items = toRef(state, 'items') // ✅ 用 toRef 保持响应式ref vs reactive 选择原则场景推荐基本类型ref需要整体替换ref表单对象reactive组合函数返回值ref(保持统一)大型只读配置shallowRef / markRawreadonly 与 shallowRefimport { readonly, shallowRef, triggerRef, markRaw } from 'vue' // readonly — 防止修改(如传递给子组件的 props) const original = reactive({ count: 0 }) const copy = readonly(original) // copy.count++ // ⚠️ 警告:无法修改 // shallowRef — 只追踪 .value 本身,适合大列表 const list = shallowRef([]) // 替换整个数组触发更新 list.value = [...list.value, newItem] // 修改内部属性后手动触发 list.value.push(anotherItem) triggerRef(list) // markRaw — 排除响应式转换(如第三方类实例) const chart = markRaw(new ECharts())computed 与 watchimport { ref, computed, watch, watchEffect } from 'vue' const firstName = ref('三') const lastName = ref('张') // computed — 有缓存 const fullName = computed(() => `${lastName.value}${firstName.value}`) // 可写 computed const fullNameWritable = computed({ get: () => `${lastName.value}${firstName.value}`, set: (val) => { lastName.value = val[0] firstName.value = val.slice(1) } }) // watch — 明确监听源,可获取旧值 watch(fullName, (newVal, oldVal) => { console.log(`${oldVal} → ${newVal}`) }) // watch 对象属性 watch( () => state.filters, (newFilters) => { fetchList(newFilters) }, { deep: true, immediate: true } ) // watchEffect — 立即执行,自动追踪依赖 watchEffect(() => { console.log(`${firstName.value} ${lastName.value}`) }) // watchEffect 清理副作用 watchEffect((onCleanup) => { const timer = setTimeout(() => { console.log(firstName.value) }, 500) onCleanup(() => clearTimeout(timer)) // 依赖变化时清理 })toRefs 与 toValueimport { toRefs, toRef, toValue, reactive } from 'vue' const state = reactive({ x: 1, y: 2 }) // toRefs — 用于解构 reactive 对象时保持响应式 const { x, y } = toRefs(state) // toRef — 单个属性 const yRef = toRef(state, 'y') // toValue — 统一处理 ref / getter / 普通值(Vue 3.3+) function resolveValue(maybeRefOrGetter) { return toValue(maybeRefOrGetter) } resolveValue(refValue) // ref → .value resolveValue(() => 123) // getter → 调用结果 resolveValue(123) // 普通值 → 原样返回四、生命周期钩子<script setup> import { onBeforeMount, onMounted, onBeforeUpdate, onUpdated, onBeforeUnmount, onUnmounted, onErrorCaptured, onActivated, onDeactivated } from 'vue' onBeforeMount(() => console.log('挂载前')) onMounted(() => console.log('DOM 已就绪')) onBeforeUpdate(() => console.log('更新前')) onUpdated(() => console.log('更新后')) onBeforeUnmount(() => console.log('卸载前')) onUnmounted(() => console.log('已卸载')) // 捕获子组件错误 onErrorCaptured((err, instance, info) => { console.error('子组件错误:', err) return false // 阻止继续传播 }) // KeepAlive 相关 onActivated(() => console.log('组件激活')) onDeactivated(() => console.log('组件失活')) </script>五、自定义组合函数useFetch — 数据请求// composables/useFetch.js import { ref, shallowRef, triggerRef, toValue, watchEffect } from 'vue' export function useFetch(url) { const data = shallowRef(null) const error = ref(null) const loading = ref(false) async function doFetch() { data.value = null error.value = null loading.value = true try { const res = await fetch(toValue(url)) if (!res.ok) throw new Error(res.statusText) data.value = await res.json() } catch (e) { error.value = e } finally { loading.value = false } } // url 是响应式时自动重新请求 watchEffect(doFetch) return { data, error, loading, retry: doFetch } }<script setup> import { ref } from 'vue' import { useFetch } from '@/composables/useFetch' const userId = ref(1) const { data: user, error, loading } = useFetch( () => `/api/users/${userId.value}` // getter 形式 ) </script> <template> <div v-if="loading">加载中...</div> <div v-else-if="error">错误: {{ error.message }}</div> <div v-else-if="user">{{ user.name }}</div> </template>useLocalStorage — 本地存储// composables/useLocalStorage.js import { ref, watch } from 'vue' export function useLocalStorage(key, defaultValue) { const value = ref( JSON.parse(localStorage.getItem(key)) ?? defaultValue ) watch(value, (val) => { localStorage.setItem(key, JSON.stringify(val)) }, { deep: true }) function remove() { localStorage.removeItem(key) value.value = defaultValue } return { value, remove } }useMouse — 鼠标追踪// composables/useMouse.js import { ref, onMounted, onUnmounted } from 'vue' export function useMouse() { const x = ref(0) const y = ref(0) function update(e) { x.value = e.pageX y.value = e.pageY } onMounted(() => window.addEventListener('mousemove', update)) onUnmounted(() => window.removeEventListener('mousemove', update)) return { x, y } }useDebounceFn — 防抖// composables/useDebounceFn.js import { customRef } from 'vue' export function debouncedRef(value, delay = 200) { let timeout return customRef((track, trigger) => { return { get() { track() return value }, set(newValue) { clearTimeout(timeout) timeout = setTimeout(() => { value = newValue trigger() }, delay) } } }) }六、provide 与 inject<!-- 祖先组件 --> <script setup> import { provide, ref, readonly } from 'vue' const theme = ref('dark') const user = ref({ name: '张三' }) // 提供只读数据 provide('theme', readonly(theme)) provide('user', readonly(user)) // 提供修改方法 provide('updateTheme', (val) => { theme.value = val }) </script><!-- 后代组件(任意深度) --> <script setup> import { inject } from 'vue' const theme = inject('theme', 'light') // 带默认值 const user = inject('user') const updateTheme = inject('updateTheme') updateTheme('light') </script>七、小结组合式 API 是 Vue 3 的核心范式:ref/reactive 管理状态,computed/watch 处理派生与副作用,自定义组合函数实现逻辑复用。掌握这些 API 后,你会发现复杂组件的开发变得清晰而高效。本文由 inspirecl.asia 原创,转载请注明出处。
2026年06月25日
5 阅读
0 评论
0 点赞
2026-06-23
小程序自动化测试与 CI/CD 实战:miniprogram-simulate、automator 与流水线搭建
小程序自动化测试与 CI/CD 实战:miniprogram-simulate、automator 与流水线搭建"每次发版前手工点一遍所有页面"——小程序项目超过 20 个页面后,人工回归就是一场酷刑,而且一定漏测。这篇介绍微信官方的两大测试利器(miniprogram-simulate 组件测试、miniprogram-automator 端到端测试)和基于 miniprogram-ci 的自动化发布流水线,把发版从"手工活"变成"流水线作业"。一、测试金字塔:小程序版 / E2E 测试 \ automator(真机/工具自动化) / 业务流程回归 \ 核心链路:登录、下单、支付 /---------------\ / 页面测试 \ Page 级别,模拟交互 /-------------------\ / 组件测试 \ miniprogram-simulate(单组件渲染+断言) /-----------------------\ / 纯函数/工具类单元测试 \ Jest(无渲染依赖的逻辑) /---------------------------\比例原则:单元测试多而快,E2E 测试少而稳。全用 E2E 会又慢又脆,全用单元测试覆盖不了真实渲染。二、组件测试:miniprogram-simulate2.1 安装与配置npm install --save-dev miniprogram-simulate jest// package.json { "scripts": { "test": "jest" }, "jest": { "testEnvironment": "jsdom" } }2.2 测试一个组件假设有个 price-tag 组件,props 传分转换为元展示:// test/price-tag.test.js const simulate = require('miniprogram-simulate') const path = require('path') test('price-tag 渲染价格', () => { const id = simulate.load(path.join(__dirname, '../components/price-tag/index'), { // 处理 usingComponents 里的自定义组件引用 rootPath: path.join(__dirname, '../') }) const comp = simulate.render(id, { price: 9900, // 分 currency: '¥' }) const parent = document.createElement('parent-wrapper') comp.attach(parent) // 断言渲染结果 expect(comp.querySelector('.price').dom.innerHTML).toContain('99') expect(comp.querySelector('.symbol').dom.innerHTML).toBe('¥') // 修改数据触发更新 comp.setData({ price: 1050 }) expect(comp.dom.innerHTML).toContain('10.5') })2.3 测试组件事件test('点击触发 buy 事件', () => { const id = simulate.load('/components/goods-card/index') const comp = simulate.render(id, { goods: mockGoods }) // 监听组件自定义事件 let received = null comp.addEventListener('buy', (e) => { received = e.detail }) // 模拟点击 comp.querySelector('.buy-btn').dispatchEvent('tap') return simulate.sleep(10).then(() => { expect(received).toEqual({ goodsId: 'g1', count: 1 }) }) })适用边界:simulate 是在 jsdom 里模拟小程序运行时,wxs、部分原生组件(map/canvas/web-view)渲染不了。这类组件用 automator 的真机/工具链路测。三、端到端测试:miniprogram-automator3.1 启动与连接npm install --save-dev miniprogram-automator// test/e2e/login.e2e.js const automator = require('miniprogram-automator') describe('登录流程', () => { let miniProgram, page beforeAll(async () => { miniProgram = await automator.launch({ cliPath: 'D:/software/wechat-web-devtools/cli.bat', // 开发者工具 CLI 路径 projectPath: 'D:/work/my-miniprogram', // 项目路径 // 需要开发者工具开启「服务端口」(设置→安全) }) await miniProgram.reLaunch('/pages/login/login') page = await miniProgram.currentPage() }, 60000) afterAll(async () => { await miniProgram.close() }) })3.2 页面元素操作与断言test('微信授权登录成功后跳转首页', async () => { // 获取元素 const btn = await page.$('.login-btn') expect(await btn.text()).toBe('微信一键登录') // mock 授权弹窗(真机上无法自动点授权,mock wx API 是 E2E 的常规手段) await miniProgram.mockWxMethod('getSetting', { authSetting: { 'scope.userInfo': true } }) await miniProgram.mockWxMethod('login', { code: 'mock-code' }) // 点击 await btn.tap() // 等待页面跳转(轮询获取当前页面路径) await miniProgram.pageWaitFor('/pages/index/index') const newPage = await miniProgram.currentPage() expect(newPage.path).toBe('pages/index/index') // 断言页面数据 const data = await newPage.data() expect(data.userInfo.nickName).toBeTruthy() })3.3 mock 能力是 E2E 的灵魂真机自动化测不了微信授权弹窗、支付密码输入这类系统级交互,mockWxMethod 是官方给的口子:// mock 掉支付,专注测试支付成功后的业务流转 await miniProgram.mockWxMethod('requestPayment', { errMsg: 'requestPayment:ok' }) // mock 网络请求,构造稳定测试环境 await miniProgram.mockWxMethod('request', { data: { code: 0, data: mockOrder } }) // ...测试结束后恢复 await miniProgram.restoreWxMethod('request')策略:mock 边界 API(登录/支付/授权),不 mock 业务接口——业务接口走测试环境真实数据,E2E 才有回归价值。3.4 截图对比(可选)// 关键页面截图,接入像素对比工具(如 pixelmatch)做视觉回归 await page.screenshot({ path: `snapshots/${page.path.replace(/\//g, '_')}.png` })四、发布流水线:miniprogram-ci手动点"上传"发版的问题:谁传的、什么时候传的、传的什么代码全靠自觉。ci 工具让发版代码化。4.1 准备密钥小程序后台 → 开发管理 → 开发设置 → 小程序代码上传,生成上传密钥(IP 白名单建议配置)。npm install --save-dev miniprogram-ci4.2 上传脚本// scripts/upload.js const ci = require('miniprogram-ci') const path = require('path') async function upload({ version, desc }) { const project = new ci.Project({ appid: process.env.WX_APPID, type: 'miniProgram', projectPath: path.resolve(__dirname, '../dist'), privateKeyPath: path.resolve(__dirname, './private.key'), ignores: ['node_modules/**/*'] }) const result = await ci.upload({ project, version, // 版本号,如 1.4.2 desc, // 版本描述 setting: { es6: true, minify: true, // 压缩 autoPrefixWXSS: true }, // 机器人编号 1-30:不同流水线用不同机器人,后台显示区分来源 robot: process.env.CI_ROBOT || 1 }) console.log('上传成功', result) } upload({ version: process.env.VERSION, desc: process.env.DESC })4.3 预览二维码(测试分发)const qrcode = await ci.preview({ project, desc: '提测版本', qrcodeFormat: 'image', qrcodeOutputDest: './preview.jpg' }) // 把 preview.jpg 推到测试群,测试同学扫码即测4.4 完整流水线(GitHub Actions 示例)# .github/workflows/release.yml name: miniprogram-release on: push: tags: ['v*'] jobs: test-then-upload: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: '20' } - name: 安装依赖 run: npm ci - name: 单元测试 + 组件测试 run: npm test -- --coverage - name: 构建 run: npm run build - name: 上传体验版 env: PRIVATE_KEY: ${{ secrets.WX_PRIVATE_KEY }} VERSION: ${{ github.ref_name }} run: | echo "$PRIVATE_KEY" > scripts/private.key node scripts/upload.js # 私钥用 GitHub Secrets 存,永远不进仓库 - name: 通知测试群 run: curl -X POST ${{ secrets.WEBHOOK }} -d '{"msg":"体验版已更新: ${{ github.ref_name }}"}'打 tag 触发 → 跑测试 → 构建产物 → 上传体验版 → 群通知领测。全流程 5 分钟,人工介入为零,正式版发布只是最后一步"提交审核"由人在后台点一下确认。五、测试策略落地建议项目阶段投入策略0-5 个页面的新项目只测纯函数(Jest),先把工具函数测稳10+ 页面,开始多人协作引入组件测试,公共组件(请求弹层/卡片)全覆盖20+ 页面,有交易链路E2E 覆盖核心链路(登录/下单/支付),流水线强制卡点多端多项目沉淀测试脚手架,统一 mock 数据管理别追求覆盖率数字:90% 覆盖率但全是无断言的快照测试,不如 40% 覆盖率但每条都测在交易关键路径上。六、避坑清单坑现象解法automator 连不上工具launch 超时开发者工具开启服务端口,关多个实例E2E 时好时坏等待缺失用 pageWaitFor 轮询代替 sleep真机授权弹窗卡死自动化无法点击mockWxMethod 跳过系统弹窗simulate 渲染不出组件使用了 wxs/原生组件该组件改用 automator 测上传报 40125密钥或 IP 白名单问题检查 privateKeyPath 与后台 IP 白名单流水线密钥泄漏私钥提交到仓库用 CI 平台 Secrets,脚本里生成robot 冲突多流水线互相顶掉不同环境配不同 robot 编号写在最后小程序工程化的终局形态:git push 打 tag → 测试自动跑 → 体验版自动传 → 二维码自动进群 → 人只做两件事:看测试报告,点提交审核。前期搭流水线要花两三天,但从第一次"发版救火"开始,这些投入就开始指数级回本。测试代码不是负担,是团队交付速度的复利资产。
2026年06月23日
203 阅读
0 评论
0 点赞
2026-06-19
小程序埋点与性能监控体系:从 wx.getPerformance 到线上告警
小程序埋点与性能监控体系:从 wx.getPerformance 到线上告警"线上有点卡"——这种反馈没法排查:哪个页面、哪个环节、什么机型、什么网络?没有监控体系的小程序,性能问题全靠猜。这篇搭一套完整的可观测方案:性能指标采集、业务埋点规范、异常捕获、上报通道和告警闭环。一、监控什么:指标体系1.1 性能指标(技术侧)指标采集方式关注点启动耗时wx.getPerformance下载/注入/首屏渲染分段路由切换耗时手动打点onShow 到可交互setData 耗时wx.getPerformance + 手动单次通信量和频率请求耗时拦截器打点P95 接口慢查询白屏率/错误率异常捕获稳定性红线1.2 业务指标(产品侧)漏斗模型:曝光 → 点击 → 提交 → 支付成功。技术指标告诉你"哪里慢",业务指标告诉你"哪里漏"。二、性能指标采集2.1 wx.getPerformance// app.js onLaunch 里启动观察者 const perf = wx.getPerformance() const observer = perf.createObserver((entryList) => { entryList.getEntries().forEach(entry => { switch (entry.name) { case 'appLaunch': // 启动总耗时 report('perf_launch', { duration: entry.duration, path: entry.path, scene: entry.scene // 进入场景值,很关键 }) break case 'firstRender': // 首次渲染 report('perf_first_render', { duration: entry.duration }) break case 'evaluateScript': // 逻辑层注入 report('perf_evaluate', { duration: entry.duration }) break } }) }) observer.observe({ entryTypes: ['navigation', 'render', 'script'] })启动耗时的正确拆解:appLaunch = 代码包下载 + 代码注入 + 首次渲染 + 首屏接口。分别打点才能定位瓶颈:下载慢是包体积问题,注入慢是主包代码量问题,渲染慢是首屏 setData 数据量问题,接口慢是后端问题——处方不同,诊断必须分开。2.2 页面级打点// utils/perf-track.js function trackPageShow(page) { page._showAt = Date.now() } function trackPageReady(page) { const cost = Date.now() - page._showAt report(`perf_page_${page.route}`, { cost }) } // 劫持 Page 构造器统一埋点(一次接入全局生效) const originPage = Page Page = function (config) { const { onShow, onReady } = config config.onShow = function () { trackPageShow(this) onShow && onShow.call(this) } config.onReady = function () { trackPageReady(this) onReady && onReady.call(this) } originPage(config) }2.3 setData 监控高频 setData 是性能杀手,全局埋点监控:// 劫持 setData,记录每次调用数据量 function wrapSetData(page) { const origin = page.setData.bind(page) page.setData = function (data, callback) { const size = JSON.stringify(data).length if (size > 100 * 1024) { // 单次超 100KB 告警 report('perf_setdata_oversize', { page: page.route, size, keys: Object.keys(data).slice(0, 10) }) } const start = Date.now() origin(data, () => { callback && callback() report('perf_setdata', { page: page.route, size, cost: Date.now() - start }) }) } }三、业务埋点规范3.1 事件模型统一 schema,别每个页面自由发挥:// 埋点 = 谁 + 在哪 + 做了什么 + 结果如何 track('order_submit', { page: 'pages/order/confirm', // 页面路径 biz_params: { // 业务参数 goodsCount: 3, amount: 99.0, couponUsed: true }, result: 'success', // success / fail failReason: '' // 失败原因 })3.2 曝光埋点的坑列表项曝光不能用 onShow(整页级别太粗),用 IntersectionObserver:// 组件 attach 时注册观察器,进入视口才算曝光 Component({ lifetimes: { attached() { this._observer = this.createIntersectionObserver() this._observer.relativeToViewport({ bottom: 0 }).observe('.card', (res) => { if (res.intersectionRatio > 0.5 && !this._exposed) { this._exposed = true // 只曝光一次 track('goods_expose', { id: this.data.item.id }) this._observer.disconnect() // 观察完就断开,省性能 } }) }, detached() { this._observer && this._observer.disconnect() } } })3.3 漏斗分析把关键路径事件串起来,服务端按 session_id 聚合:session: abc123 10:00:01 home_banner_expose 10:00:05 home_banner_click 10:00:12 goods_detail_view 10:00:40 cart_add 10:01:15 order_submit (success) 10:01:30 pay_success转化率 = 后一环节人数 / 前一环节人数。掉率异常的环节,就是产品和技术共同盯的靶子。四、异常捕获4.1 JS 错误// app.js App({ onError(err) { report('error_js', { message: err.message, stack: err.stack.slice(0, 2000), // 截断,别把上报接口自己打爆 route: getCurrentPageRoute(), version: APP_VERSION // 版本号必须带,定位灰度问题 }) } })4.2 Promise 拒绝// onError 捕获不到未处理的 Promise rejection App({ onUnhandledRejection(res) { report('error_promise', { reason: String(res.reason).slice(0, 1000) }) } })4.3 请求失败请求封装层统一捕获(业务错误码 + 网络错误分类上报):function request(options) { return new Promise((resolve, reject) => { wx.request({ ...options, success: (res) => { if (res.statusCode >= 500) { report('error_http_5xx', { url: options.url, code: res.statusCode }) } resolve(res) }, fail: (err) => { report('error_network', { url: options.url, msg: err.errMsg }) reject(err) } }) }) }JSError 聚合:上报时对 stack 做 fingerprint(取函数名+文件名哈希),服务端按 fingerprint 聚合数量,否则同一个错误刷出几万条没法看。五、上报通道设计5.1 批量 + 采样// utils/report.js const queue = [] let timer = null function report(event, data) { // 采样:非错误类事件 30% 采样率足够 if (!event.startsWith('error') && Math.random() > 0.3) return queue.push({ event, data, ts: Date.now(), sessionId: getApp().globalData.sessionId, openid: getApp().globalData.openid || '', systemInfo: getApp().globalData.systemInfo, // 机型/系统/基础库,启动时采集一次缓存 version: APP_VERSION }) // 攒 10 条或 5 秒批量发 if (queue.length >= 10 || timer) return timer = setTimeout(flush, 5000) } function flush() { timer = null if (!queue.length) return const batch = queue.splice(0, 20) wx.request({ url: 'https://track.example.com/batch', method: 'POST', data: { events: batch }, fail: () => { // 失败回队,最多重试一次,别无限堆积 if (queue.length < 200) queue.unshift(...batch) } }) }设计要点:批量上报:逐条上报浪费请求数,也容易被限流错误类全量、行为类采样:监控成本和有效性平衡体积上限:队列封顶,极端情况下丢弃最旧数据,防止内存堆积onHide 时强制 flush:切后台立刻发送,别等定时器5.2 上报域名要求域名必须在微信后台配置 request 合法域名,且用独立子域名(track.xxx.com)——与业务接口隔离,统计流量波动不影响业务,业务接口故障也不影响监控数据回收。六、告警闭环采集只是开始,告警才产生价值:规则阈值动作JSError 突增5 分钟内同 fingerprint > 100立即电话/短信接口 5xx 率> 1% 持续 5 分钟群告警启动 P90> 5s 持续 30 分钟群告警支付成功率环比跌 10%立即电话告警分级是关键:全是紧急告警等于没有告警。资损类电话叫醒,体验类群消息,趋势类日报周报。七、避坑清单坑现象解法埋点代码入侵业务改个埋点改十处劫持 Page/请求层统一埋点上报打爆自己错误风暴连环上报fingerprint 聚合 + 队列上限没带版本号无法定位是哪个版本的问题全事件带 APP_VERSION曝光重复统计滚动来回算多次_exposed 标记一次性白名单域名忘了配上报静默失败上报 fail 打本地日志只看均值长尾用户被平均看分位数 P90/P95写在最后监控体系的建设顺序建议:先异常(保命)→ 再性能(体检)→ 最后业务(增长)。异常监控上线第一天就能发现存量 bug;性能基线建立后,每次发版有数据回归;业务漏斗则是产品迭代的弹药库。三步都不复杂,难的是坚持让"数据说话"成为团队习惯。
2026年06月19日
131 阅读
0 评论
0 点赞
2026-06-13
小程序接口安全攻防:签名、防重放、防刷与数据加密实战
小程序接口安全攻防:签名、防重放、防刷与数据加密实战小程序代码可以被反编译、请求可以被抓包、接口可以被脚本刷——这是做接口安全设计的前提假设。这篇按"攻击者视角"过一遍小程序接口的常见攻防手段,从请求签名到风控埋点,给出一套可落地的纵深防御方案。一、先明确攻击面拿到一个小程序包(unwxapkg 解包是公开技术),攻击者能做什么:读源码:所有前端逻辑可见,包括写死的密钥、接口地址、加密逻辑改请求:Charles/mitmproxy 抓包改包,绕过前端校验直接打接口重放请求:把合法请求原样重发(领券接口重发 100 次)脚本刷量:用抓到的协议写脚本,批量注册、批量抢券、爬数据核心结论:任何只放在前端的安全逻辑都等于明文。前端安全措施的定位是"提高破解成本",真正的防线必须在服务端。二、第一层:请求签名2.1 签名方案sign = HMAC-SHA256(secret, method + path + timestamp + nonce + body摘要)问题来了:secret 放哪?放前端代码里会被反编译提取。务实的做法是不追求绝对保密,而是动态化:// 登录后,服务端按设备指纹+时间窗口下发动态签名密钥 // 前端存内存(不落盘),每次请求计算签名 const crypto = require('./crypto') // 轻量 hmac 实现 function signRequest(method, path, body) { const timestamp = Date.now().toString() const nonce = randomStr(16) const message = [method, path, timestamp, nonce, hashBody(body)].join('\n') const sign = crypto.hmacSha256(getSessionSecret(), message) return { timestamp, nonce, sign } }2.2 服务端验签// Node.js 中间件 async function verifySign(req, res, next) { const { 'x-timestamp': ts, 'x-nonce': nonce, 'x-sign': sign } = req.headers // 1. 时间窗口校验(±5分钟),防长期重放 if (Math.abs(Date.now() - Number(ts)) > 5 * 60 * 1000) { return res.status(401).json({ code: 'SIGN_EXPIRED' }) } // 2. nonce 一次性校验:redis SETNX + TTL const isNew = await redis.set(`nonce:${nonce}`, 1, 'NX', 'EX', 300) if (!isNew) { return res.status(401).json({ code: 'REPLAY_ATTACK' }) } // 3. 按用户会话密钥重算签名比对 const expected = hmacSha256(req.sessionSecret, buildMessage(req)) if (expected !== sign) { return res.status(401).json({ code: 'SIGN_INVALID' }) } next() }密钥下发设计:签名密钥在登录时由服务端生成(绑定 openid + 设备 + 过期时间),存 Redis。密钥本身不下发到可持久化的存储,前端只存内存变量,冷启动重新走登录换取。这样即使被反编译拿到加密算法,拿不到实时密钥。三、第二层:防重放与防刷3.1 时间戳 + nonce 双保险只校验时间戳:5 分钟窗口内照样可以重放。只校验 nonce:nonce 表无限膨胀。两者结合:时间窗口内的 nonce 记 Redis(TTL = 窗口长度),窗口外的直接拒绝。3.2 幂等令牌(业务防重)关键操作(下单、领券、提现)加幂等令牌:进入下单页 → POST /idempotent-token → 服务端生成 token 存 Redis(5分钟有效,一次性) 提交订单 → 携带 token → 服务端 GETDEL 原子消费 → 消费成功:处理业务 → 消费失败(token 不存在/已用):拒绝,返回"请勿重复提交"const token = await redis.getdel(`idem:${clientToken}`) if (!token) return res.status(409).json({ code: 'DUPLICATE' }) // token 消费成功才继续业务比签名更硬:幂等令牌在业务层防重放,签名防不了"同一用户不同 nonce 重放同一业务"。3.3 频率限制多维度限流(Redis + 令牌桶/滑动窗口):async function rateLimit(req) { const rules = [ { key: `rl:ip:${req.ip}`, limit: 100, window: 60 }, // IP 维度 { key: `rl:uid:${req.openid}`, limit: 30, window: 60 }, // 用户维度 { key: `rl:uid-api:${req.openid}:${req.path}`, limit: 5, window: 60 } // 用户+接口 ] for (const r of rules) { const cnt = await redis.incr(r.key) if (cnt === 1) await redis.expire(r.key, r.window) if (cnt > r.limit) return { blocked: true, rule: r.key } } return { blocked: false } }梯度惩罚:超限不直接封禁(误伤共享 IP),而是返回 429 + 递增惩罚时长(30s → 5min → 1h),并在风控后台打标。3.4 行为验证高频价值接口(抢券、秒杀)前置行为验证:微信提供的 wx.checkSession 只是登录态检查,不是人机验证自建方案:滑块/点选验证码,配合行为埋点(操作时长、滑动轨迹的贝塞尔曲线特征、页面停留时间)综合评分简单有效的一招:接口必须携带页面埋点产生的行为链 ID,脚本直调接口没有行为链,直接拒绝四、第三层:数据加密传输HTTPS 之外再加一层应用层加密,主要防抓包工具明文查看(HTTPS 抓包在用户信任证书的前提下是明文的):// 前端:AES 加密业务数据 const key = getSessionKey() // 登录协商的会话密钥 const encrypted = aesEncrypt(JSON.stringify(data), key) wx.request({ url: api, data: { payload: encrypted }, // 密文外层再走签名 // ... }) // 后端:对称解密 const data = JSON.parse(aesDecrypt(req.body.payload, sessionKey))会话密钥协商:登录成功后服务端生成 32 字节随机密钥,用 openid 绑定存 Redis,返回给前端存内存。抓包者拿到的是密文+无法解密的密钥。加密的边界认知:这层防的是"偷看数据",防不了"拿会话重放"——重放要靠前面的签名+nonce+幂等令牌。四层各司其职。五、第四层:服务端业务兜底前面所有手段都可能被绕过(逆向能力强的大厂黑产团队有的是),服务端业务校验是最后防线:价格不信任前端:下单只传商品 ID 和数量,价格从数据库取——这条老规矩依然有人犯错库存原子扣减:UPDATE stock SET n = n - 1 WHERE id = ? AND n > 0,affected rows = 0 就售罄风控规则引擎:单用户单日领券上限、同设备指纹多账号识别、异常时段集中下单告警敏感操作二次核验:提现、改手机号等操作要求短信/生物识别二次验证六、纵深防御全景图┌─ L1 传输层:HTTPS + 证书锁定(防被动窃听) ├─ L2 签名层:动态密钥 + timestamp + nonce(防篡改、防重放) ├─ L3 业务层:幂等令牌 + 限流 + 行为验证(防刷量) ├─ L4 数据层:应用层 AES(防抓包明文) └─ L5 兜底层:服务端价格/库存/权限校验 + 风控(防一切绕过)攻击者要攻破你的系统,需要逐层突破;而每加一层,攻击成本都是指数级上升。绝大多数刷单攻击在 L2/L3 就被挡掉了,坚持打到 L5 的对手,靠风控人工处置。七、避坑清单坑现象解法secret 硬编码在前端反编译直接提取登录后动态下发,内存持有只校验时间戳不校验 nonce窗口期内重放成功双校验 + Redis SETNX前端传价格/库存改包 0.01 元下单服务端查库取价限流只按 IP公司/校园网集体误伤IP+用户+设备指纹多维加密了没验签抓包改密文照样打加密和签名是两件事,都要幂等令牌可复用并发重复提交GETDEL 原子消费429 直接封 IP黑产换 IP 池绕过梯度惩罚 + 设备指纹识别写在最后接口安全没有银弹,本质是一场成本博弈:你的防御成本要让攻击者的破解成本高于收益。普通业务做好"动态密钥签名 + 防重放 + 服务端兜底校验"三板斧,就能挡掉 99% 的脚本小子;剩余 1% 的职业黑产,交给风控规则和人工运营。别追求理论上的绝对安全,追求性价比足够高的纵深防御。
2026年06月13日
243 阅读
0 评论
0 点赞
1
...
5
6
7
...
15
0:00