阅读视图

发现新文章,点击刷新页面。

🚀 两年小程序开发,我把踩过的坑做成了开源 Skills

还记得第一次用 miniprogram-automator 写自动化测试时的绝望吗?

  • querySelector() 死活选不到自定义组件里的元素
  • waitFor() 不知道该等什么,测试时好时坏
  • wx.request Mock 不生效,每次测试都打真实接口
  • 截图对比总是因为时间戳、动画而误报

还有 CI 发布时的噩梦:

  • 上传到微信后台经常超时,CI 流水线红成一片
  • pnpm 项目的 npm 包打不进去,shamefully-hoist 是什么鬼?
  • GitHub Actions 里 secrets 配置错了,排查半天才发现是 IP 白名单问题

我决定不再让其他人重复踩这些坑。


📦 wechat-miniprogram-skills

这是我开源的微信小程序 AI 编程技能包,基于 Skills 规范,支持 40+ AI 编程工具(Claude Code、Cursor、Windsurf、Continue、Copilot 等)。

包含两个核心 Skill:

1️⃣ miniprogram-automation - 自动化测试不再是玄学

核心能力:

  • ✅ 生成可复用的 Node.js 自动化脚本(不强制 Jest)
  • ✅ 处理自定义组件边界问题(.shadow()>>> 选择器)
  • ✅ 智能 waitFor 策略(等待元素、网络、页面栈)
  • ✅ wx.request Mock 方案(mockWxMethod / restoreWxMethod)
  • ✅ 截图 + 回归验证(console / exception 监听)
  • ✅ 基于官方 miniprogram-demo 实际验证

常见触发词: "小程序自动化"、"automator"、"E2E 测试"、"选不到元素"、"mock wx.request"

实际案例:

// 自动生成的脚本会处理这些细节:
const input = await page.$('.custom-input >>> input')
await input.input('测试内容')
await page.waitFor(500) // 智能等待策略

2️⃣ miniprogram-ci - 让 CI/CD 稳如老狗

核心能力:

  • ✅ 生成 pack-npm / preview / upload 独立脚本
  • ✅ 内置超时重试机制(3次重试,每次等待 5 秒)
  • ✅ 完整 GitHub Actions 模板(npm / pnpm 双版本)
  • ✅ pnpm 兼容性配置(shamefully-hoist / public-hoist-pattern)
  • ✅ secrets 管理 + IP 白名单检查
  • ✅ 自动创建 GitHub Release

常见触发词: "上传小程序"、"CI 部署"、"miniprogram-ci"、"预览二维码"、"自动化发布"

实际案例:

# 自动生成的 GitHub Actions 会帮你:
- name: Upload to WeChat
  run: node scripts/upload.js
  env:
    PRIVATE_KEY: ${{ secrets.WX_PRIVATE_KEY }}

🎯 为什么要做成 Skills?

1. AI 编程时代,让 AI 直接生成正确的代码

  • 不用再翻文档、查 Stack Overflow
  • AI 知道如何处理自定义组件、如何配置 CI

2. 知识可复用、可迭代

  • 把经验固化成 Skill,团队共享
  • 遇到新坑就更新 Skill,让 AI 帮后来者避坑

3. 支持 40+ AI 工具

  • 不绑定特定 IDE 或 AI 助手
  • Claude、Cursor、Copilot 都能用

📥 如何使用?

# 安装整个仓库
npx skills add whinc/wechat-miniprogram-skills

# 只安装自动化测试 Skill
npx skills add whinc/wechat-miniprogram-skills --skill miniprogram-automation

# 只安装 CI 发布 Skill
npx skills add whinc/wechat-miniprogram-skills --skill miniprogram-ci

安装后,直接在 AI 编程工具中说:

  • "帮我生成小程序自动化测试脚本"
  • "配置小程序 CI 自动上传"

AI 会基于这些 Skills 生成经过实战验证的代码


🤝 欢迎贡献

如果你也在小程序开发中踩过坑、总结过经验,欢迎提交 PR 补充新的 Skill!

比如:

  • 小程序性能监控
  • 分包加载优化
  • 云开发自动化部署
  • ...

让我们一起让小程序开发不再痛苦 💪


🔗 链接

一文理清页面/组件通信与 Store 全局状态管理

【小程序实战】告别繁琐传递!一文理清页面/组件通信与 Store 全局状态管理

📢 前言: 大家好,今天集中攻克了微信小程序开发中的两座大山:页面与组件的通信 以及 全局状态管理(Store)

在刚接触小程序时,我们通常习惯把所有逻辑都写在 Page 里;但随着项目变大,组件化是必经之路。而组件一旦多起来,数据怎么互相传递就成了头疼的问题。今天这篇笔记,就来总结一下我的学习成果,并分享几个避坑经验,希望能帮到正在学习小程序的你!


一、 页面与组件的“窃窃私语”:基础通信方式

在引入复杂的 Store 之前,我们必须先掌握原生的页面与组件通信方式。核心可以总结为三招:

1. 父传子:properties (属性绑定)

这是最基础的单向数据流。页面(父)通过属性将数据传递给组件(子)。

页面(父)端:

<!-- index.wxml -->
<my-component my-name="{{userName}}"></my-component>

组件(子)端:

// components/my-component/my-component.js
Component({
  properties: {
    myName: {
      type: String,
      value: '默认名字' // 默认值
    }
  }
})

2. 子传父:triggerEvent (事件绑定)

当组件内部发生了点击或数据改变,需要通知页面时,就需要用到自定义事件。

组件(子)端触发:

// 当点击按钮时触发
handleTap() {
  this.triggerEvent('myevent', { age: 18 }) // 传递对象给父级
}

页面(父)端接收:

<!-- index.wxml 绑定事件 -->
<my-component bind:myevent="handleChildEvent"></my-component>
// index.js 处理事件
handleChildEvent(e) {
  console.log('收到子组件的数据:', e.detail.age); // 输出 18
}

3. 父控子:selectComponent (获取组件实例)

有时候页面需要直接调用子组件里的方法,这时候可以通过给组件加 idclass,直接获取实例。

// 父页面的 js 中
const child = this.selectComponent('#my-child-id');
child.someMethod(); // 直接调用子组件的方法
// ⚠️ 经验:虽然好用,但不建议滥用,容易造成父子组件强耦合。

二、 告别“回调地狱”,拥抱 Store 全局状态管理

❓ 为什么需要 Store?

当遇到跨页面通信,或者兄弟组件通信(比如 A 组件的数据,C 组件也要用)时,如果用原生方法,你需要:A组件 -> 传给父页面 -> 传给B组件 -> ...。这种**“属性层层透传”**简直是噩梦!

这时候,Store(全局状态管理) 就闪亮登场了!在原生小程序中,我们通常使用 mobx-miniprogrammobx-miniprogram-bindings

1. 定义 Store (数据仓库)

首先创建一个 store.js,用来存放全局共享的数据和修改数据的方法。

import { observable, action } from 'mobx-miniprogram';

export const store = observable({
  // 1. 数据字段 (State)
  numA: 1,
  numB: 2,

  // 2. 计算属性 (Getters)
  get sum() {
    return this.numA + this.numB;
  },

  // 3. 修改数据的方法 (Actions)
  updateNumA: action(function (step) {
    this.numA += step;
  })
});

2. 在 Page 中使用 Store

在页面中使用,需要用到 createStoreBindings

import { createStoreBindings } from 'mobx-miniprogram-bindings';
import { store } from '../../store/store';

Page({
  onLoad() {
    // 绑定 Store
    this.storeBindings = createStoreBindings(this, {
      store,
      fields: ['numA', 'numB', 'sum'], // 需要的数据
      actions: ['updateNumA'] // 需要的方法
    })
  },
  
  onUnload() {
    // ⚠️ 重点:页面卸载时一定要解绑,防止内存泄漏!
    this.storeBindings.destroyStoreBindings();
  },

  btnHandler() {
    this.updateNumA(1); // 直接调用 store 中的 action
  }
})

3. 在 Component 中使用 Store

在组件中使用更加优雅,官方提供了一个 behavior

import { storeBindingsBehavior } from 'mobx-miniprogram-bindings';
import { store } from '../../store/store';

Component({
  behaviors: [storeBindingsBehavior], // 引入 behavior
  storeBindings: {
    store,
    fields: {
      numA: () => store.numA, // 映射数据
      sum: 'sum'
    },
    actions: {
      updateNumA: 'updateNumA'
    }
  }
})

三、 💡 学习心得与“避坑”经验分享

经过今天的折腾,我对这两种方式有了更深的体会,总结了以下几条经验:

  1. 别把什么都塞进 Store 里! Store 确实“真香”,但千万别把什么数据都往里面丢。

    • 适合放 Store 的: 用户登录信息(Token、头像)、购物车数据、全局主题配置等(跨页面高度共享的数据)。
    • 适合放页面/组件内部(data)的: 表单的输入内容、弹窗的显示隐藏状态(isModalShow)、局部的 Loading 状态。保持局部状态的纯粹,代码才好维护。
  2. 时刻警惕内存泄漏 在 Page 中使用 createStoreBindings 时,必须、一定、千万要onUnload 生命周期里调用 destroyStoreBindings() 进行清理。如果你发现从小程序某个页面返回后,页面变卡或者数据出现诡异的重叠,大概率是忘记解绑了。

  3. 组件通信尽量保持“单向数据流” 即使有了 selectComponent,我们在开发组件时也应尽量遵循:父组件通过 properties 传值,子组件通过 triggerEvent 汇报。把子组件当成一个“黑盒”,这样写出来的组件复用性最高,不会因为换了个父页面就报错。


如果这篇文章对你有帮助,点个赞支持一下吧!你的鼓励是我持续分享的动力!


一文读懂:微信小程序云数据库直连原理与使用指南

前言

微信小程序直接调用云数据库(云开发模式)是微信提供的一种Serverless架构方案,它允许前端(小程序端)在没有传统后端服务器的情况下直接操作数据库。 以下是关于该机制的原理架构图流程图使用详解

一、 原理架构图

微信小程序云开发采用了 Serverless 架构。传统的开发模式需要“小程序前端 -> 后端服务器(API) -> 数据库”,而云开发模式则是“小程序前端 -> 云数据库(通过微信私有协议)”。

1. 架构示意

graph TD
    subgraph "客户端"
        A[小程序前端代码]
    end
    subgraph "微信基础设施"
        B[微信 APP 宿主环境]
        C[云开发控制台/基础设施]
    end
    subgraph "云开发资源"
        D[(云数据库 - JSON)]
        E[云函数]
        F[云存储]
    end
    A -- 1. 调用 wx.cloud/init --> B
    A -- 2. 调用 db.collection --> B
    B -- 3. 微信私有协议/鉴权 --> C
    C -- 4. 安全规则校验 --> D
    D -- 5. 返回JSON数据 --> C
    C -- 6. 返回结果 --> A
    style A fill:#e1f5fe,stroke:#01579b
    style D fill:#fff9c4,stroke:#fbc02d
    style C fill:#f3e5f5,stroke:#8e24aa

2. 核心组件解析

  • 小程序前端: 运行在小程序环境中的代码,通过微信提供的 SDK (wx.cloud) 发起请求。
  • 微信私有协议: 数据传输不经过公网 HTTP,而是通过微信客户端底层通道,速度更快,且自带微信登录态,无需手动管理 Token。
  • 安全规则: 这是“直接调用”的安全基石。数据库根据配置的 JSON 规则(如 auth.openid)判断当前用户是否有权读/写数据,替代了传统后端的权限校验逻辑。
  • 云数据库: 一个 MongoDB 文档型数据库,数据以 JSON 格式存储。

二、 调用流程图

当小程序端执行一条 db.collection('xxx').get() 时,底层发生了以下流程:

sequenceDiagram
    participant User as 用户/小程序前端
    participant SDK as 微信客户端 SDK
    participant Cloud as 微信云服务网关
    participant DB as 云数据库实例
    User->>SDK: 1. 调用 API (如 db.collection('user').get())
    Note right of User: 传入环境ID (env)
    
    SDK->>SDK: 2. 本地检查云环境初始化状态
    
    SDK->>Cloud: 3. 建立私有连接通道
    Note right of SDK: 自动携带 AppID, OpenID, UnionID
    
    Cloud->>Cloud: 4. 身份鉴权 (获取用户身份)
    
    Cloud->>DB: 5. 发送数据库请求指令
    
    Note over DB, Cloud: 6. 执行【安全规则】校验
    alt 权限校验失败
        DB-->>Cloud: 返回 Permission Denied
        Cloud-->>SDK: 返回错误信息
        SDK-->>User: Catch Error
    else 权限校验通过
        DB->>DB: 7. 执行查询/写入操作
        DB-->>Cloud: 8. 返回数据结果
        Cloud-->>SDK: 9. 封装返回数据
        SDK-->>User: 10. Promise Resolve (返回数据)
    end

关键点说明:

  1. 自动鉴权: 最大的特点是 “免登录”。SDK 会自动获取用户的 OpenID 并传给云端,开发者不需要写登录接口。
  2. 安全规则拦截: 如果在控制台配置了“仅创建者可写”,当用户 A 尝试修改用户 B 的数据时,Cloud 层会在第 6 步直接拦截,报错 database permission denied

三、 使用详解

要实现小程序直接调用云数据库,需遵循以下步骤。

1. 环境初始化

在调用任何云能力之前,必须先初始化。

// app.js
App({
  onLaunch: function () {
    if (!wx.cloud) {
      console.error('请使用 2.2.3 或以上的基础库以使用云能力');
    } else {
      wx.cloud.init({
        // env 参数说明:
        //   env: 'your-env-id' // 云开发环境ID,可在云开发控制台获取
        traceUser: true, // 自动上报用户信息
      });
    }
  }
});

2. 获取数据库引用

const db = wx.cloud.database();
// 指定特定环境(如果有多个环境)
// const db = wx.cloud.database({ env: 'your-env-id' });

3. CRUD 操作示例

(1) 增 - Insert

// 添加数据
db.collection('todos').add({
  data: {
    description: '学习云开发',
    due: new Date('2023-12-31'),
    tags: ['cloud', 'database'],
    location: new db.Geo.Point(113, 23), // 地理位置
    done: false
  }
})
.then(res => {
  console.log('添加成功,记录ID:', res._id);
})
.catch(err => {
  console.error('添加失败', err);
});

(2) 查 - Query

// 获取数据
db.collection('todos').where({
  _openid: 'xxx' // 此处通常不需要手动填,如果开启了安全规则,系统会自动校验
})
.get()
.then(res => {
  // res.data 是一个数组
  console.log('查询结果:', res.data);
});
// 获取单条记录
db.collection('todos').doc('record-id-here').get()
.then(res => {
  console.log(res.data);
});

(3) 改 - Update

注意:update 只能修改符合 where 条件或通过 doc 指定的记录。

db.collection('todos').doc('record-id-here').update({
  data: {
    done: true // 将 done 字段改为 true
  }
})
.then(res => {
  console.log('更新成功,影响行数:', res.stats.updated);
});

(4) 删 - Remove

注意:在小程序端直接调用 remove 删除多条记录通常受限制,建议一次删除一条或使用云函数批量删除。

db.collection('todos').doc('record-id-here').remove()
.then(res => {
  console.log('删除成功');
});

4. 权限管理(安全规则)- 核心中的核心

小程序直接调用数据库之所以安全,是因为数据库安全规则。 在微信开发者工具 -> 云开发控制台 -> 数据库 -> 选择集合 -> 权限设置,有以下常见模式:

  • 仅创建者可写,所有人可读:
    • 适合:文章、帖子、评论。
    • 原理:系统自动检查记录中的 _openid 是否与当前用户的 _openid 一致。
  • 仅创建者可读写:
    • 适合:个人隐私数据(如购物车、个人设置)。
  • 所有人可读,仅创建者可写:
    • 适合:字典数据、配置数据。
  • 自定义安全规则:
    • 使用 JSON 语法定义复杂的逻辑。例如:"read": true, "write": "auth.openid == doc._openid"

5. 数据类型支持

云数据库支持丰富的数据类型,不同于传统的 MySQL,它直接支持:

  • GeoJSON: 地理位置点 db.Geo.Point,支持地理位置查询(如查找附近的人)。
  • Date: 时间对象 new Date()
  • Null: 空值。
  • 嵌套对象: JSON 对象多层嵌套。

四、 总结

1.微信小程序直接调用云数据库 的核心优势在于:

  1. 开发效率高: 省去了搭建服务器、编写 API 接口、维护数据库连接池的工作。
  2. 安全性强: 通过微信底层鉴权和安全规则,实现了前端直接操作数据库且不泄露数据。
  3. 成本低: 按量付费,对于中小型应用极其友好。

2.适用场景:

  • 快速原型开发(MVP)。
  • 逻辑相对简单的 CRUD 应用(如备忘录、简单的商城、预约系统)。
  • 企业内部员工或者B端项目多为表单提交,列表详情展示类(用户量较小) 不适用场景:
  • 复杂的事务处理(如涉及多表关联、复杂的金钱流转逻辑)。
  • C端日活高应用,高并发、高吞吐量的写操作(小程序端有连接数和频率限制)。
  • 需要高度保密的计算逻辑(逻辑放在前端容易被反编译,此时应使用云函数)。

移动端调试工具VConsole初始化时的加载阻塞问题

问题复现(场景:小程序应用)

将VConsole下载到本地进行,在main.js中进行初始化,当上线后,页面首次加载会出现卡顿空白页,刷新之后显示正常


import Vue from 'vue'

import App from './App.vue'

import router from './router'

import ElementUI from 'element-ui'

import 'element-ui/lib/theme-chalk/index.css'

import VConsole from 'vconsole'

new VConsole();

new Vue({

render: h => h(App),

router,

}).$mount('#app')

阻塞原因

首次加载无缓存 + VConsole 初始化抢占主线程,阻塞了页面渲染;第二次刷新有缓存,VConsole 加载 / 初始化耗时骤降,不再阻塞

「第一次卡顿白屏」

当在 main.js 里直接 new VConsole() 时,会发生下面过程:

  • ✅ 资源无缓存:第一次进页面,浏览器要从网络下载 vconsole.js 源码(约 150KB),占网络带宽,拖慢页面 JS 加载;
  • ✅ 抢占主线程:VConsole 初始化时,会做「重写 console、插 DOM 面板、监听网络请求」等操作,这些操作和 Vue 初始化、页面 DOM 渲染抢同一根「主线程」,浏览器顾此失彼,页面来不及渲染就白屏;
  • ✅ 时序冲突:VConsole 初始化和 Vue 挂载($mount('#app'))同时执行,甚至更早,直接打乱页面渲染节奏,导致首屏出不来。

2.「第二次就正常」

第二次进页面时:

  • ✅ 资源有缓存:浏览器已经把 vconsole.js 存在本地,不用再从网络下载,加载耗时从几百毫秒降到几毫秒;
  • ✅ 初始化变快:VConsole 初始化的核心资源(如样式、面板模板)都在缓存里,主线程占用时间大幅减少,不会再阻塞 Vue 渲染和页面显示。

即使下载依赖到本地,只要在main.js立刻初始化同样会造成阻塞


加延迟让vue先完初始化,console再进行加载

加延迟解决卡顿

import { createApp } from 'vue'

import App from './App.vue'

// 1. 先挂载 Vue 应用

const app = createApp(App)

app.mount('#app')

// 2. 延迟初始化 VConsole(仅开发环境)

if (process.env.NODE_ENV === 'development') {

setTimeout(() => {

import('vconsole').then(({ default: VConsole }) => {

new VConsole()

})

}, 100)

}

虽然加延迟能解决卡顿,但是看不到初始化运行时加载的其他js文件

利用async和preload解决卡顿问题,但是会丢失少部分初始化最开始的加载日志文件

比如这样:、

<head>

<!-- VConsole异步加载 -->

<script src="https://cdn.bootcdn.net/ajax/libs/vConsole/3.15.0/vconsole.min.js" async></script>

<!-- 项目JS正常引入(也可加async) -->

<script src="./js/chunk-vendors.js"></script>

<script src="./js/app.js"></script>

<script>

// 等页面所有资源加载完,再初始化VConsole(避免卡顿)

window.addEventListener('load', () => {

if (window.VConsole) { // 检查VConsole是否加载完成

new window.VConsole({ disableLogScrolling: true });

console.log('VConsole初始化完成(async方案)');

// 此时执行你的日志打印逻辑

window.printNewJS();

}

});

// 你的printNewJS等逻辑保留

window.printedJS = new Set();

window.printNewJS = function() 

</script>

</head>

终极解决方案:先完成console的初始化再引入打包配的.js文件

<head>

<!-- 预加载VConsole,加速下载 -->

<link rel="preload" href="https://cdn.bootcdn.net/ajax/libs/vConsole/3.15.0/vconsole.min.js" as="script">

<script>

// 先加载并初始化VConsole

const scriptVConsole = document.createElement('script');

scriptVConsole.src = "https://cdn.bootcdn.net/ajax/libs/vConsole/3.15.0/vconsole.min.js";

scriptVConsole.onload = () => {

new VConsole();

console.log('VConsole初始化完成');

// VConsole就绪后,动态引入项目JS(保留日志)

loadProjectJS();

};
document.head.appendChild(scriptVConsole);

// 动态引入项目JS(原逻辑)

function loadProjectJS() {

const projectJS = ['./js/chunk-vendors.js', './js/app.js'];

projectJS.forEach(path => {

const script = document.createElement('script');

script.src = path;

document.body.appendChild(script);

});

}

</script>

</head>

但是这样有一个缺点:会有一个短暂的空白,所以可以给idnex.html中加入一个loading提示过渡,这样就可以查看项目运行过程中整个所有加载的文件运行状态结果

不写 Canvas 也能搞定!小程序图片导出的 WebView 通信方案

大家好,我是王嗨皮,一名主业前端,副业全栈的程序员,如果我的文章能让您有所收获,欢迎一键三连(评论,点赞,关注),感谢!

年前业务部门的同事提了一个需求,将公司PC端询价系统的报价单导出功能移植到到小程序上。

最初接到这个任务时,有点小崩溃,主要问题有两个:

  1. 小程序无法操作DOM元素,因此不能使用 html2Canvas 像PC端一样直接将DOM元素生成图片。
  2. 如果用Canvas自己画,只能手写大量代码,可读性差,拓展困难。

在对着uni-app文档思考了一段时间之后,决定尝试一下小程序与webview双向通信这个解决方案。

解决思路

其实思路也不复杂,就是利用 uni-app 的 <webview> 组件嵌入一个部署在服务器上的 H5 页面,借助小程序与 H5 之间的通信机制,将图片生成的工作转移到 H5 端完成,然后将生成的 base64 图片文件返回给小程序并保存到本地相册。

Screenshot 2026-03-03 at 09.31.35.png

完整方案代码

简单说一下这个方案的优势:

  1. 解决了小程序的DOM限制:H5的环境下可以操作DOM,使用 html2canvas 可以正常运行。
  2. 可读性/拓展性强:页面直接用传统的三件套(HTML/CSS/JS)构建,容易理解。同时针对不同业务板块可以拓展多个模板。
  3. 职责分离:小程序只负责传递数据,H5负责渲染页面和截图。

当然,在这个过程中我也需要和后端同事提前做好沟通,H5页面的数据是需要通过接口传参获取的。

小程序端代码:

<template>
  <view class="container">
    <web-view :src="url" @message="handleMessage"></web-view>
  </view>
</template>

<script>
  export default {
    data() {
      return {
        url: '' ,
        isShow: '',
        imgUrl: '',
        priceId: '',
        priceType: '',
        tokenId: '',
      }
    },

    onLoad(options) {
      this.priceId = options.feeId
      this.priceType = options.Type
      this.tokenId = uni.getStorageSync('loginInfo').F_WxToken

      //跳转的url并传递参数
      this.url = 'https://wxa.xxxx.com/index/index/lclindex?priceId=' + this.priceId + '&priceType=' + this.priceType + '&tokenId=' + this.tokenId
    },

    methods: {
      // 保存相册
      savePoster() {
        // 获取用户的当前设置
        uni.getSetting({
          success: (res) => {
            // 验证用户是否授权可以访问相册
            if (res.authSetting['scope.writePhotosAlbum']) {
              this.saveImageToMobilePhotos()
            } else {
              uni.authorize({
                scope: 'scope.writePhotosAlbum',
                success: () => {
                  this.saveImageToMobilePhotos()
                },
                fail: () => {
                  uni.showToast({
                    title: this.$t('pub.author'),
                    icon: 'none',
                    duration: 2000
                  })
                  setTimeout(() => {
                    uni.openSetting({
                      // 调起客户端小程序,让用户开启访问相册
                      success: (res2) => {
                        console.log(res2.authSetting)
                      }
                    })
                  }, 3000)
                }
              })
            }
          }
        })
      },

      // 接收webview传回参数
      handleMessage(e) {
        if(e.detail.data[0].url) {
          this.imgUrl = e.detail.data[0].url
          let base64 = this.imgUrl.replace(/^data:image\/\w+;base64,/, "")
          let filePath = wx.env.USER_DATA_PATH + '/worldjaguar_lclprice.jpg'

          uni.getFileSystemManager().writeFile({
            filePath: filePath,
            data: base64,
            encoding: 'base64',
            success: res => {
              uni.saveImageToPhotosAlbum({
                filePath: filePath,
                success: res2 => {
                  uni.hideLoading()
                  uni.showToast({
                    title: this.$t('pub.saveimageauthor'),
                    icon: "none",
                    duration: 3000
                  })
                },
                fail: err => {
                  uni.hideLoading()
                  console.log(err)
                }
              })
            },
            fail: err => {
              uni.hideLoading()
              console.log(err)
            }
          })
        }
      }
    }
  }
</script>

H5页面代码

在H5页面中兼容小程序并调用uni-app部分API需要分别引入 jweixin.jsuni.webview.js

与小程序端完成信息通信则使用 uni.postMessage

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta http-equiv="content-type" content="application/json; charset=UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=0, viewport-fit=cover">
    <title>生成报价单</title>
    <link rel="stylesheet" href="./css/common.css">
    <link rel="shortcut icon" type="image/x-icon" href="./favicon.ico" />
  </head>

  <body>
    <div id="app">
      <!-- 添加一层Loading页面遮罩 -->
      <div class="pub-mask" v-show="showMask">
        <div class="pub-mask-box">
          <img src="./images/loading.gif" alt="">
          <span>报价图片生成中...</span>
        </div>
      </div>
      <div class="savebox" id="savebox">
       ......布局代码
      </div>
      <!-- 点击保存图片触发postMessage -->
      <button type="button" @click="postMessage" id="postMessage" class="savebox-image">保存图片</button>
    </div>
    <script type="text/javascript" src="https://cdn.bootcdn.net/ajax/libs/vue/2.7.9/vue.min.js"></script>
    <script type="text/javascript" src="https://cdn.bootcdn.net/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
    <!-- 兼容小程序 -->
    <script type="text/javascript" src="https://res.wx.qq.com/open/js/jweixin-1.4.0.js"></script>
    <!-- 必须引入 -->
    <script type="text/javascript" src="https://js.cdn.aliyun.dcloud.net.cn/dev/uni-app/uni.webview.1.5.2.js"></script>
    <script type="text/javascript" src="https://cdn.bootcdn.net/ajax/libs/axios/1.5.0/axios.min.js"></script>
    <script type="text/javascript">
      var App = new Vue({
        el: '#app',
        data: {
          priceId: '',
          priceType: '',
          tokenId: '',
          dataInfo: {},
          userInfo: {},
          freightArr: [],
          polArr: [],
          podArr: [],
          showMask: true,
          dateNumber: ''
        },

        mounted() {
          console.log(document.title)
          this.$nextTick(() => {
            document.addEventListener('UniAppJSBridgeReady', function () {
              uni.getEnv(function (res) {
                console.log('当前环境:' + JSON.stringify(res));
              })
            })
          })
        },

        created() {
          this.priceId = this.getQuery('priceId')? this.getQuery('priceId') : ''
          this.priceType = this.getQuery('priceType')? this.getQuery('priceType') : ''
          this.tokenId = this.getQuery('tokenId')? this.getQuery('tokenId') : ''

          document.title = this.priceType == 1? '生成海出拼箱报价单' : '生成海进拼箱报价单'

          this.getSaveImageData()
        },

        methods: {
          // 接收uni-app小程序传递的参数
          getQuery(name) {
            let reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");
            let r = window.location.search.substr(1).match(reg);
            if (r != null) {
              // 对参数值进行解码
              return decodeURIComponent(r[2]);
            }
            return null;
          },

          postMessage() {
            html2canvas(document.querySelector('#savebox'), {
              allowTaint: true,
              scale: 2,
              dpi: 300,
              useCORS: true
            }).then(canvas => {
              let imgUrl = canvas.toDataURL('image/jpeg', 1.0)
              // 注意:base64大图可能导致通信超时,如果是海报或高清图片需求建议上传服务器后返回URL
              uni.postMessage({
                data: {
                  url: imgUrl
                }
              })
              uni.navigateBack({
                url: '/pages/saveimage/saveimage‘
              })
            })
          },

          getSaveImageData() {
            axios({
              method: 'post',
              url: 'https://wxa.worldjaguar.com/apis/Lclquote/getLclImgInfo',
              data: {
                QuoteId: 'xxxxxxxx',
                Type: 1,
                ApiType: 2,
                wxAuthorization: 'xxxxxxxx'
              }
            })
            .then(res => {
              if(res.data.code == 200) {
                this.dataInfo = res.data.data
                this.userInfo = res.data.data.Contact

                this.freightArr = res.data.data.freightSurcharge
                this.polArr = res.data.data.departurePortCharges
                this.podArr = res.data.data.destinationPorts

                this.createdPriceNumber()

                setTimeout(() => {
                  this.showMask = false
                }, 2600)
              }else {
                alert(res.data.info)
                this.showMask = false
              }
            })
          }
        }
      })
    </script>
  </body>
</html>

注意事项

<webview> 组件中的 @message 只会在组件销毁页面回退分享时进行触发,不会立刻收到消息,由于我的系统业务逻辑相对简单,所以采用了回退的方式。

比较推荐的做法是将生成的图片上传给服务器接口,然后将生成的URL传递给小程序,这样既能保证同步性,也能解决如果图片过大直接返回小程序造成卡顿的问题。

另外,由于每次生成图片都需要加载一个H5页面, 用户等待时会出现白屏或中间态等影响体验的问题,最好添加一个loading遮罩,优化用户体验。

最后,不要忘记在小程序后台将访问域名配置到白名单,域名确认为HTTPS,确保 <webview> H5页面URL可以正常访问。

QQ截图20231204161819.png

后续思考

这个案例本身并不复杂,但在实际落地过程中,我们仍然经历了近两个小时的讨论与权衡。从技术深度的角度来看,Canvas 方案无疑更能体现开发者的深层技术能力;但在真实的业务场景下,我们更倾向于选择一个可读性强、易于维护、迭代成本低的务实方案。

如果你有更好的方案或建议,欢迎分享交流,感谢🙏!!

重走 Vue 长征路 Weapp-vite:编译链路与 Wevu 运行时原理拆解

bg.jpg

重走 Vue 长征路 Weapp-vite:编译链路与 Wevu 运行时原理拆解

书接上篇

我当时在团队里做《Vue 编译本质论》分享,正好把一些判断过程也整理了下来:为什么这么做,没选什么,以及这些取舍在小程序里到底值不值。

如果你更关心怎么上手,先看发布文会更顺:Weapp-vite:原生模式之外,多一种 Vue SFC 选择

先把边界说清:Wevu 不是 Vue 3 的搬运工

Wevu 用起来确实很像 Vue 3,但骨子里不是一回事。

对比维度 Vue 3 Wevu
运行环境 Web 浏览器 微信小程序
响应式系统 Proxy + effect Proxy + effect(同源)
渲染目标 DOM 节点 小程序页面/组件实例
渲染方式 Virtual DOM Diff → DOM API Snapshot Diff → setData
数据模型 VNode 树 纯 JS 对象快照
更新机制 异步调度 + DOM 操作 异步调度 + setData
生命周期 onMounted/onUpdated 等 映射到小程序生命周期
事件系统 DOM 事件 小程序 bind/catch 事件
SFC 编译 @vitejs/plugin-vue Weapp-vite 内置

说白了就一件事:响应式 API 长得一样,但最后数据往哪送、怎么送,完全不同

API 为什么能"几乎同写法"

refcomputedwatch 这些在 wevu 里跟 Vue 3 写法一模一样,没必要再造一套 DSL 出来。

import { computed, ref, watch } from 'wevu'

const count = ref(0)
const doubled = computed(() => count.value * 2)

watch(count, (val) => {
  console.log('count changed:', val)
})

很多团队迁过来之后第一反应不是"又要学新东西",而是"这不就是我平时写的吗,换了个宿主而已"。

渲染链路才是真正不一样的地方

Vue 3 走的是这条路:

状态变化 -> effect 触发 -> 组件更新 -> VNode Diff -> DOM 操作

Wevu 走的是这条:

状态变化 -> effect 触发 -> 快照 Diff -> setData -> 小程序渲染

Wevu 干的事情说穿了就是把"算出哪些东西变了"这一步尽量提前做完,等到真正调 setData 的时候,payload 已经被压到最小了。这在小程序里特别关键——大家踩过坑的都知道,setData 传多了,页面就卡,尤其是列表页。

.vue 到四件套:编译阶段干了啥

一个 MyComponent.vue 最终会变成小程序四件套:

MyComponent.vue
  ├─> MyComponent.js
  ├─> MyComponent.wxml
  ├─> MyComponent.wxss
  └─> MyComponent.json

中间的流程大概是这样:先把 SFC 拆成四块——<script><template><style><json>,各自按小程序的规矩做转换,最后拼成产物。

其中 <json> 块用来声明页面或组件的配置(比如 usingComponentsnavigationBarTitleText 之类的),不过我更推荐用 definePageJson / defineComponentJson / defineAppJson 这几个编译宏来代替它——有类型提示,能跟 <script setup> 共享上下文,IDE 重构的时候也不容易漏改。<json> 块当兼容手段用没问题,但不太适合当主力。

.vue 文件
  ↓
vue/compiler-sfc 解析
  ↓
┌─────────┬──────────┬─────────┬────────┐
│ <script>│<template>│ <style> │ <json> │
└────┬────┴────┬─────┴────┬────┴───┬────┘
     │         │          │        │
     ↓         ↓          ↓        ↓
  处理宏    指令映射     样式转换  配置提取
     │         │          │        │
     └─────────┴──────────┴────────┘
               ↓
         生成 .js/.wxml/.wxss/.json

增量构建的时候只处理改过的文件,HMR 能跑得比较稳也是靠这个缓存策略撑着。

defineXxxJson 宏的用法

上面提到推荐用编译宏来代替 <json> 块,这里展开说一下。defineAppJsondefinePageJsondefineComponentJson 都是编译期宏,构建时提取合并到对应的 .json 文件里,运行时零开销。写起来大概是这样:

<script setup lang="ts">
  definePageJson({
    navigationBarTitleText: '首页',
    usingComponents: {},
  })
</script>

好处就是直接写在 <script setup> 里,有完整的类型推导,改字段名的时候 IDE 能帮你检查,不会出现"json 里改了但别的地方没跟上"的情况。

原生组件与插槽

.vue 里 import 原生组件之后,构建阶段会看模板里到底用没用到,用到了才往 usingComponents 里补。这样就不用手动维护那堆路径配置了,少写少错。

插槽也是类似的思路。你写的是 Vue 的 slot 语法,但输出的时候会按小程序的 slot 语义来生成。作用域插槽稍微复杂一点,背后走的是一套语义映射加代码生成,不是简单的字符串替换能搞定的。

Rolldown:收益主要体现在日常开发体感

v6 切到 Rolldown 不是为了赶时髦,就是想把开发时的等待再缩短一点。

日常能感受到的主要是三个地方:冷启动快了、改完代码后增量构建更灵敏、项目依赖多的时候不容易抽风。不是那种"跑分暴涨 300%"的故事,更像是每次都省个几百毫秒,积少成多,一天下来体感差挺多的。

为什么没走 createRenderer 这条路

@vue/runtime-corecreateRenderer 技术上能跑通,但拿来对小程序用,会发现抽象层对不上:它要求你提供一套完整的宿主节点操作接口,而小程序这边最核心的更新通道就是 setData(payload),两边的假设不太匹配。

Wevu 选了"编译到 WXML + 快照 diff + 最小 setData"这条路,优化点压在更贴近小程序实际约束的地方。不一定是最优雅的方案,但在真实业务里跑下来更稳当。

展开聊的话内容比较多,单独写了一篇:为什么没有使用 @vue/runtime-core 的 createRenderer 来实现

当前能力范围

日常开发用到的东西基本都覆盖了:v-if / v-for / v-model 这些核心指令,事件和属性绑定,SCSS/Less 和 CSS Modules,props/emits/slots/provide/inject,生命周期,常用的响应式 API,还有 TypeScript 类型推导和泛型组件。

如果你是从 Vue 3 过来的,写法上基本不用重新学,主要就是记住最后跑的不是浏览器而是小程序。

最后

感谢每一位提建议、报 bug、提 PR 的同学。


如果 Weapp-vite 帮到了你,欢迎给项目点个 Star

Happy Coding! 🚀

Weapp-vite:原生模式之外,多一种 Vue SFC 选择

bg.jpg

Weapp-vite:原生模式之外,多一种 Vue SFC 选择

大家好呀,我是你们的老朋友,开源爱好者 icebreaker!又到了新的一年了,祝大家财源滚滚,早日不用上班实现财务自由!

今天主要来分享一下我开源项目 Weapp-vite 的开发里程碑,核心就是来给大家秀一把。

前言

我还记得在过去 Weapp-vite@4.0 的发布文章里,写过这样的话:

Weapp-vite 不适用场景:需要使用 Vue/React 等前端框架的写法,来取代小程序原生写法。

但社区的声音让我重新想了想这个定位。说实话,原生小程序的语法写多了确实烦,尤其是你要是平时写 Vue 3 写习惯了,回头再 this.setData、手动绑事件、管生命周期,就会觉得特别笨重。Vue 的 SFC 设计确实好用,这个没什么好争的。

而且即使到了这个 AI 时代,小程序的验收工具也比较笨重,因为小程序缺少 playwright-cli, agent-browser, chrome-devtools-mcp 这类的验收工具, 还原度远远不及 Web。

另外还有一点就是当时我正好在团队里面做《Vue 编译本质论》的技术分享

所以我就在想能不能把 Weapp-vite 改造成一个既保留原生模式优势,又提供 Vue 开发体验的工具?

于是,Weapp-vite@6 来了——在原生模式之外,多一种 Vue 选择

背景故事:从零运行时到 Vue SFC 支持

最初的定位

Weapp-vite 一开始就是奔着零运行时去的——一个纯粹的原生小程序构建工具。你用原生写法写代码,它给你提供现代化的开发体验,打出来的包尽量小、跑起来尽量快。

这个定位确实满足了不少用户,特别是只做微信小程序、对性能有洁癖的那批人,还有用 Skyline 的。

但我后来一直在琢磨:能不能在不动原生模式的前提下,再给一个 Vue 的选项?

市面上的选择

让我们看看现有的方案吧:

Taro

跨端能力确实强,但运行时代码量不小。分包没规划好的话,主包很容易超。语法上虽然说支持 React/Vue,但写起来总有种"变种"的感觉,踩坑成本不低。

而且说实话 Taro 现在维护节奏慢了不少,issue 堆得挺多的。

我也曾经在 2 年前,在他们的公众号上,看到了招聘启事,于是投了简历,结果人家完全没有鸟我(笑~)。

uni-app

上手是挺快的,但 uni.xxx 那套 API 和专属 DSL 毕竟是另一套东西。uni-app x 搞的 uts,跟标准 Vue 生态和社区总感觉有点貌合神离。

我很喜欢 uni-app, 当时也很早就让我另外一个项目 weapp-tailwindcss 中兼容了 uni-app x,但是我不喜欢 HBuilderX

mpx

滴滴出品,基于 Vue 2.7 + webpack。我不喜欢,技术栈老了,响应式系统跟标准 Vue 也不完全一样。

我的 Weapp-vite 方案,你可以理解成 mpx 的下一代:Vue 3 风格 + Rolldown Vite,只做小程序,但跟原生 API 完全兼容

Weapp-vite 的思路

Weapp-vite@6 想做的事情很简单:同一个工具,两种模式

  • 原生模式:零运行时,包体积和性能都拉满,适合对这些有要求的项目
  • Vue 模式:完整的 Vue 3 写法,适合 Vue 技术栈的团队

两者可以在同一个项目里混着用。.vue 组件能引原生组件,原生组件也能引 .vue 组件,按页面按组件自己选就行。

运行时 Wevu 的诞生

转折点是 wevu 的出现——一个专门给小程序写的 Vue 运行时。

当时本来是叫 wevue 的,但是这个名字 npm 包已经被注册掉了,所以 trimEnd 了一个 e

wevu 保留了 Vue 3 那些核心 API——refcomputedwatchonMounted 之类的,但底层更新走的是小程序的 setData

更重要的是,Wevu 从一开始就是配合 Weapp-vite 的 SFC 编译来设计的,所以编译时能加的糖都尽量加上了,写起来会比较顺手。

编译时 + 运行时

wevu 运行时搞定之后,Vue SFC 编译支持就是顺水推舟的事了。

认识 Wevu:给小程序写的 Vue 3 风格运行时

Wevu 专门给小程序设计,核心思路就是:响应式那套跟 Vue 3 同源,渲染层按小程序的规矩来

它能干什么

  • refreactivecomputedwatchwatchEffect 这些响应式 API 都有,用法跟 Vue 3 一样
  • onMountedonUpdatedonUnmounted 等生命周期钩子,自动映射到小程序对应的生命周期
  • 快照 diff 优化,setData 只传变了的数据路径,不会整坨丢过去
  • 内置了 defineStore/storeToRefs,用法跟 Pinia 差不多
  • 跟 Weapp-vite 的 SFC 编译配合使用,响应式和生命周期都是打通的

Vue 3 和 Wevu 到底哪不一样

响应式 API 和写法基本一致,区别在渲染那层:Wevu 不操作 DOM,而是操作小程序实例,更新走的是"快照 diff + setData"。

为什么没用 createRenderer

@vue/runtime-corecreateRenderer 技术上能做,但拿来对小程序有个根本问题:它假设宿主能提供一套比较完整的节点操作接口,而小程序这边核心就一个 setData(payload),两边的抽象对不上。

Wevu 走的是"编译到 WXML + 快照 diff + 最小 setData",把优化做在更贴近小程序实际情况的地方。

Weapp-vite + Wevu 怎么配合

  • Weapp-vite 管编译:把 Vue SFC 拆开、转换、生成小程序四件套
  • Wevu 管运行时:提供响应式系统和生命周期

两个加一起,你得到的就是:

  1. Vue 3 的开发体验(SFC + Composition API)
  2. 接近小程序原生的运行性能

Vue SFC 支持是直接内置在 weapp-vite 里的,不是外挂插件。

一处编写,四处生成

你写一个 .vue 文件,Weapp-vite 编译完会变成小程序四件套:

MyComponent.vue
    ├─> MyComponent.js    // 脚本逻辑
    ├─> MyComponent.wxml  // 模板结构
    ├─> MyComponent.wxss  // 样式文件
    └─> MyComponent.json  // 组件配置

Vue 的 <script><template><style><json>(可被 defineXXXJson 宏指令取代) 会被拆开,各自转换成小程序能认的格式。整个过程就像是把 Vue 组件"翻译"成了小程序的方言。

Vue 语法怎么转的

这不是简单地把 Vue 代码塞进小程序,而是做了一层语法映射:

Vue 写法 转换为
v-if / v-else-if / v-else wx:if / wx:elif / wx:else
v-for="item in list" wx:for="{{list}}" + wx:key
@click / @tap bindtap / catchtap
:class / :style class="{{...}}" / style="{{...}}"
v-model 双向绑定的完整实现(input/checkbox/radio/textarea 等)
<script setup> 自动处理响应式和生命周期

你按 Vue 的方式写,Weapp-vite 按小程序的方式跑。

工具链友好:智能提示 + AI 协作

智能提示:直接复用 Vue 官方插件

VS Code 里装了 Vue 官方插件(Vue - Official / Volar)的话,Weapp-vite 的 .vue 文件直接就能用上模板智能提示和类型检查,不用再折腾一套新的编辑器插件。

  • v-for 场景下的 :key 等属性补全
  • :class / :style 等常用绑定提示
  • 组件属性与事件相关补全

ic.png

in.png

inc.png

AI 协作

如果你准备用 AI 来协作开发,我自己的顺序一直很固定:先把 skills 装好,再起 MCP,最后按需喂 llms 语料。

先装 skills:

npx skills add sonofmagic/skills

常用的几个:weapp-vite-best-practicesweapp-vite-vue-sfc-best-practiceswevu-best-practices

然后启动 MCP:

weapp-vite mcp

最后是 llms 语料入口:

  • 页面:/llms
  • 文件:/llms.txt/llms-full.txt/llms-index.json
  • 顺序:llms.txt -> llms-full.txt -> llms-index.json

几个常见用法

响应式状态 + 计算属性

<script setup lang="ts">
import { computed, ref } from 'wevu'

const count = ref(0)
const doubled = computed(() => count.value * 2)
</script>

<template>
  <view>
    <text>{{ count }} / {{ doubled }}</text>
    <button @tap="count++">+1</button>
  </view>
</template>

definePageJson 宏定义页面配置

<script setup lang="ts">
definePageJson({
  navigationBarTitleText: '首页',
  navigationBarTextStyle: 'white',
})
</script>

.vue 里直接用原生组件

<script setup lang="ts">
import NativeMeter from '../../native/native-meter/index'
</script>

<template>
  <NativeMeter label="构建链能力" :value="80" />
</template>

v-model 表单双向绑定

<script setup lang="ts">
import { ref } from 'wevu'

const message = ref('')
</script>

<template>
  <input v-model="message" placeholder="输入点什么..." />
  <text>{{ message }}</text>
</template>

更多像 slotsprops/emitsapp.vue 配置以及编译行为说明,已放到原理文档统一说明:Weapp-vite@6 原理拆解

适用场景

双模式并存才是 Weapp-vite 的杀手锏

Weapp-vite@6 最实用的一点就是"同仓双模式"。性能敏感的页面继续走原生,迭代快、业务重的页面丢到 Vue 模式里。迁移可以一个页面一个页面来,不用一口气重写整个项目。

什么时候用 Vue 模式:

  • 你平时写 Vue 3,想用同样的写法搞小程序
  • 团队本来就是 Vue 技术栈,想复用过来
  • 想要热重载、TypeScript 这些现代开发体验
  • 希望 Vue 代码后面还能往 Web 项目上搬

什么时候用原生模式:

  • 对性能有洁癖,一点运行时开销都不想要
  • 已经有一大堆原生代码,不想大动
  • 团队对小程序原生 API 很熟
  • 包体积卡得很死

什么时候该选别的框架?

  • Taro:如果你真的要同时出微信、支付宝、百度、字节好几个平台的小程序,甚至还要编 H5 和 RN,那 Taro 确实是绕不开的。不过说真的,大部分项目真需要跨这么多端吗?

  • uni-app:如果你想要一个开箱即用的全家桶,而且已经习惯了 DCloud 那套生态(HBuilderX、uniCloud 之类的),uni-app 挺合适。就是它的 DSL 跟标准 Vue 还是有些差异。

  • mpx:Vue 2.7 + webpack,技术栈偏老了。

快速体验

  1. 创建项目
pnpm create weapp-vite@latest
# 选择 Wevu 模板或者 Wevu + TDesign 模板
  1. 开发
pnpm dev
  1. 享受 Vue 带来的快乐
<script setup>
import { ref } from 'wevu'

const message = ref('Hello, weapp-vite@6!')
</script>

<template>
  <view>{{ message }}</view>
</template>

技术细节

原理和实现细节,如果大家有兴趣的话,我会另外写一篇专门的技术拆解文档。

后面打算做什么

接下来主要推两条线:支持更多小程序平台,以及支持 Web 目标。

Android / iOS 原生方向

现在原生 Android / iOS 这边,很多场景还是得靠微信开发者工具的多端框架来转。这块后面会继续投入,目标是把链路做得更稳、接入成本更低。

最后

Weapp-vite@6 这次就是想把选择权留给你:要性能就走原生,要开发体验就走 Vue 模式,混着来也行。背后靠的是 vue/compiler-sfc 的解析能力、wevu 的运行时设计,以及社区一路给的真实反馈。

感谢每一位提建议、报 bug、提 PR 的同学。


如果 Weapp-vite 帮到了你,欢迎给项目点个 Star

Happy Coding! 🚀

❌