小程序开发
What — 什么是小程序
小程序是一种无需下载安装即可使用的轻量级应用,运行在宿主 App(微信、支付宝、抖音等)提供的运行环境中,具有”用完即走”的特点。
小程序平台生态
| 平台 | 开发框架 | 渲染方式 | 特色 |
|---|---|---|---|
| 微信小程序 | WXML/WXSS/JS | WebView + WXCore | 生态最大,能力最全 |
| 支付宝小程序 | AXML/ACSS/JS | WebView | 金融场景强,芝麻信用 |
| 抖音小程序 | TTML/TTSS/JS | WebView + 自研渲染 | 内容分发,直播带货 |
| 百度小程序 | Swan/JS | WebView | 搜索分发,AI 能力 |
| 飞书小程序 | TTML/JS | WebView | 企业办公场景 |
| 快手小程序 | KSML/JS | WebView | 短视频电商 |
| 快应用 | CSS/JS | 原生渲染 | 手机厂商联盟,系统级入口 |
核心架构
┌─────────────────────────────────┐
│ 宿主 App │
│ ┌───────────┐ ┌───────────┐ │
│ │ 逻辑层 │ │ 渲染层 │ │
│ │ (JsCore) │ │ (WebView) │ │
│ │ │ │ │ │
│ │ AppService│ │ WXML/ │ │
│ │ 运行 JS │ │ WXSS │ │
│ └─────┬─────┘ └─────┬─────┘ │
│ │ 双线程通信 │ │
│ │ (JSBridge) │ │
│ └───────┬───────┘ │
│ ┌───┴───┐ │
│ │ 原生层 │ │
│ │ Native│ │
│ └───────┘ │
└─────────────────────────────────┘
双线程模型:
- 逻辑层(AppService):运行 JS 代码,处理业务逻辑,无法操作 DOM
- 渲染层(WebView):运行 WXML/WXSS,负责页面渲染
- 通信机制:通过 JSBridge 消息通信,逻辑层调用
setData触发渲染更新
小程序 vs H5 vs 原生 App
| 维度 | 小程序 | H5 | 原生 App |
|---|---|---|---|
| 安装 | 无需安装 | 无需安装 | 需下载安装 |
| 入口 | 宿主 App 内 | 浏览器 | 桌面图标 |
| 性能 | 接近原生 | 一般 | 最佳 |
| 能力 | 丰富原生 API | 受限浏览器 API | 全部系统能力 |
| 开发成本 | 中 | 低 | 高 |
| 审核 | 需平台审核 | 无 | 需应用商店审核 |
| 用户体验 | 流畅 | 一般 | 最佳 |
Why — 为什么选择小程序
适用场景
- 线下场景:扫码点餐、停车缴费、景区导览
- 轻量服务:快递查询、天气、计算器
- 电商导购:商品浏览、下单、拼团
- 内容消费:资讯阅读、短视频
- 工具型应用:投票、预约、报名
小程序优势
- 获客成本低:无需下载,扫码即用,分享即传播
- 开发成本低:一套代码 + Web 技术栈,比原生开发快 3-5 倍
- 平台流量:微信 12 亿+ 月活,支付宝 8 亿+ 月活
- 原生能力:支付、定位、摄像头、蓝牙、NFC 等系统级 API
- 离线能力:缓存机制,弱网环境可用
小程序劣势
- 平台绑定:受宿主平台规则限制
- 包体积限制:微信主包 2MB,总包 20MB
- 性能上限:双线程通信有延迟,大量数据渲染卡顿
- 审核周期:提交审核需要 1-7 天
- API 限制:部分 API 需要认证、权限申请
How — 怎么开发
1. 项目结构
├── app.js // 小程序入口(生命周期)
├── app.json // 全局配置(页面路由、窗口、tabBar)
├── app.wxss // 全局样式
├── project.config.json // 项目配置
├── sitemap.json // 站点地图(搜索配置)
├── pages/
│ ├── index/
│ │ ├── index.js // 页面逻辑
│ │ ├── index.json // 页面配置
│ │ ├── index.wxml // 页面模板
│ │ └── index.wxss // 页面样式
│ └── detail/
│ ├── detail.js
│ ├── detail.json
│ ├── detail.wxml
│ └── detail.wxss
├── components/ // 自定义组件
│ └── card/
│ ├── card.js
│ ├── card.json
│ ├── card.wxml
│ └── card.wxss
└── utils/ // 工具函数
└── util.js
2. 全局配置 app.json
{
"pages": [
"pages/index/index",
"pages/detail/detail"
],
"window": {
"navigationBarTitleText": "我的小程序",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"backgroundColor": "#f5f5f5",
"backgroundTextStyle": "dark"
},
"tabBar": {
"color": "#999",
"selectedColor": "#1890ff",
"list": [
{
"pagePath": "pages/index/index",
"text": "首页",
"iconPath": "assets/home.png",
"selectedIconPath": "assets/home-active.png"
},
{
"pagePath": "pages/mine/mine",
"text": "我的",
"iconPath": "assets/mine.png",
"selectedIconPath": "assets/mine-active.png"
}
]
},
"subpackages": [
{
"root": "packageA",
"pages": [
"pages/order/order",
"pages/pay/pay"
]
}
],
"lazyCodeLoading": "requiredComponents"
}
3. 生命周期
// App 生命周期
App({
onLaunch(options) {
// 小程序启动时触发(全局只触发一次)
console.log('启动参数', options)
// 获取场景值
console.log('场景值', options.scene)
},
onShow(options) {
// 小程序从后台进入前台
},
onHide() {
// 小程序从前台进入后台
},
onError(msg) {
// 小程序发生脚本错误
console.error(msg)
},
globalData: {
userInfo: null
}
})
// Page 生命周期
Page({
onLoad(options) {
// 页面加载时触发,获取路由参数
console.log('页面参数', options.id)
},
onShow() {
// 页面显示/切入前台
},
onReady() {
// 页面初次渲染完成
},
onHide() {
// 页面隐藏/切入后台
},
onUnload() {
// 页面卸载
},
onPullDownRefresh() {
// 下拉刷新
this.fetchData().then(() => {
wx.stopPullDownRefresh()
})
},
onReachBottom() {
// 上拉触底
this.loadMore()
},
onShareAppMessage() {
// 用户点击分享
return {
title: '分享标题',
path: '/pages/index/index?id=123',
imageUrl: '/assets/share.png'
}
},
onShareTimeline() {
// 分享到朋友圈
return {
title: '分享标题',
query: 'id=123'
}
}
})
4. 数据绑定与事件
<!-- WXML 数据绑定 -->
<view class="container">
<!-- 简单绑定 -->
<text>{{message}}</text>
<!-- 属性绑定 -->
<view id="item-{{id}}">属性绑定</view>
<!-- 条件渲染 -->
<view wx:if="{{type === 'A'}}">类型A</view>
<view wx:elif="{{type === 'B'}}">类型B</view>
<view wx:else>其他类型</view>
<!-- 列表渲染 -->
<view wx:for="{{items}}" wx:key="id">
<text>{{index}}: {{item.name}}</text>
</view>
<!-- 事件绑定 -->
<button bindtap="handleTap">点击</button>
<view catchtap="handleCatch">阻止冒泡</view>
<button bind:tap="handleTapWithData" data-id="{{item.id}}" data-name="{{item.name}}">
携带数据
</button>
<!-- 双向绑定 -->
<input model:value="{{value}}" />
<!-- 计算属性(WXS) -->
<wxs module="filters">
module.exports = {
formatPrice: function(price) {
return '¥' + price.toFixed(2)
}
}
</wxs>
<text>{{filters.formatPrice(price)}}</text>
</view>
// 页面逻辑
Page({
data: {
message: 'Hello',
items: [],
value: ''
},
handleTap() {
console.log('点击了按钮')
},
handleTapWithData(e) {
// 获取 data-* 传参
const { id, name } = e.currentTarget.dataset
console.log(id, name)
},
// setData 最佳实践
updateData() {
// ❌ 频繁 setData
// for (let i = 0; i < 100; i++) {
// this.setData({ count: i })
// }
// ✅ 合并 setData
this.setData({
count: 100,
name: 'test',
list: newList
})
// ✅ 只更新需要变化的数据(路径更新)
this.setData({
'list[0].name': '新名称',
'userInfo.age': 25
})
}
})
5. 自定义组件
// components/card/card.js
Component({
// 组件属性(对外接口)
properties: {
title: {
type: String,
value: '默认标题'
},
list: {
type: Array,
value: []
}
},
// 组件内部数据
data: {
expanded: false
},
// 数据监听器
observers: {
'list.**': function(list) {
this.setData({ count: list.length })
}
},
// 生命周期
lifetimes: {
attached() {
// 组件进入页面节点树
},
detached() {
// 组件离开页面节点树
}
},
pageLifetimes: {
show() {
// 所在页面显示
}
},
methods: {
handleToggle() {
this.setData({ expanded: !this.data.expanded })
// 触发事件(向父组件通信)
this.triggerEvent('toggle', { expanded: this.data.expanded })
}
}
})
<!-- components/card/card.wxml -->
<view class="card">
<view class="card-header" bindtap="handleToggle">
<text>{{title}}</text>
<text>{{expanded ? '收起' : '展开'}}</text>
</view>
<view class="card-body" wx:if="{{expanded}}">
<slot></slot>
</view>
</view>
// components/card/card.json
{
"component": true,
"usingComponents": {}
}
<!-- 使用组件 -->
<card title="我的卡片" bind:toggle="onCardToggle">
<text>插槽内容</text>
</card>
// 页面 json 中注册组件
{
"usingComponents": {
"card": "/components/card/card"
}
}
6. 网络请求封装
// utils/request.js
const BASE_URL = 'https://api.example.com'
const request = (options) => {
return new Promise((resolve, reject) => {
wx.request({
url: BASE_URL + options.url,
method: options.method || 'GET',
data: options.data || {},
header: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${wx.getStorageSync('token')}`,
...options.header
},
success(res) {
if (res.statusCode === 200) {
if (res.data.code === 0) {
resolve(res.data.data)
} else {
wx.showToast({ title: res.data.message, icon: 'none' })
reject(res.data)
}
} else if (res.statusCode === 401) {
// token 过期,跳转登录
wx.removeStorageSync('token')
wx.redirectTo({ url: '/pages/login/login' })
reject(res.data)
} else {
reject(res.data)
}
},
fail(err) {
wx.showToast({ title: '网络异常', icon: 'none' })
reject(err)
}
})
})
}
// 并发请求
const requestAll = (requests) => {
return Promise.all(requests.map(req => request(req)))
}
module.exports = { request, requestAll }
// 使用
const { request } = require('../../utils/request')
Page({
async onLoad() {
try {
const data = await request({
url: '/api/user/info',
method: 'GET'
})
this.setData({ userInfo: data })
} catch (err) {
console.error(err)
}
}
})
7. 分包加载
// app.json — 分包配置
{
"pages": [
"pages/index/index",
"pages/mine/mine"
],
"subpackages": [
{
"root": "packageA",
"name": "order",
"pages": [
"pages/order/order",
"pages/pay/pay"
],
"independent": false
},
{
"root": "packageB",
"name": "activity",
"pages": [
"pages/activity/detail"
]
}
],
"preloadRule": {
"pages/index/index": {
"network": "all",
"packages": ["order"]
}
}
}
// 跳转分包页面
wx.navigateTo({ url: '/packageA/pages/order/order?id=123' })
分包注意事项:
- 主包 2MB 限制,单个分包最大 2MB,总包 20MB
- 分包不能引用其他分包的资源,可以引用主包
tabBar页面必须在主包- 独立分包(
independent: true)可以不下载主包直接运行
8. 性能优化
// 1. 减少 setData 数据量
// ❌ 传输整个列表
this.setData({ list: newList })
// ✅ 只传输变化项
this.setData({ 'list[2].status': 'done' })
// 2. 合并 setData 调用
// ❌ 多次调用
this.setData({ a: 1 })
this.setData({ b: 2 })
// ✅ 一次调用
this.setData({ a: 1, b: 2 })
// 3. 虚拟列表(长列表优化)
// 使用 recycle-view 组件
// npm install miniprogram-recycle-view
// 4. 图片优化
// <image mode="aspectFill" lazy-load="{{true}}" />
// 5. 骨架屏
// page.json
{
"usingComponents": {
"skeleton": "/components/skeleton/skeleton"
}
}
<!-- 骨架屏 -->
<skeleton wx:if="{{loading}}" />
<view wx:else>
<!-- 真实内容 -->
</view>
// 6. 分页加载(上拉加载更多)
Page({
data: {
list: [],
page: 1,
hasMore: true,
loading: false
},
async loadList() {
if (this.data.loading || !this.data.hasMore) return
this.setData({ loading: true })
const data = await request({
url: '/api/list',
data: { page: this.data.page, size: 20 }
})
this.setData({
list: [...this.data.list, ...data.items],
page: this.data.page + 1,
hasMore: data.items.length === 20,
loading: false
})
},
onReachBottom() {
this.loadList()
}
})
9. 微信登录流程
用户 → 点击登录 → wx.login() → 获取 code
↓
发送 code 到后端
↓
后端调用微信 API
(code + appId + appSecret)
↓
获取 openid + session_key
↓
后端生成自定义 token 返回前端
↓
前端存储 token,后续请求携带
// 登录流程封装
async function login() {
// 1. 获取 code
const { code } = await wx.login()
// 2. 获取用户信息(需用户授权)
const { userInfo } = await wx.getUserProfile({
desc: '用于完善个人资料'
})
// 3. 发送到后端
const data = await request({
url: '/api/auth/wx-login',
method: 'POST',
data: { code, userInfo }
})
// 4. 存储 token
wx.setStorageSync('token', data.token)
return data
}
// 检查登录态
async function checkSession() {
try {
await wx.checkSession()
return true // session 未过期
} catch {
return false // session 已过期,需要重新登录
}
}
10. 支付流程
async function pay(orderId) {
// 1. 后端创建订单,获取支付参数
const payParams = await request({
url: '/api/pay/create',
method: 'POST',
data: { orderId }
})
// 2. 调起微信支付
return new Promise((resolve, reject) => {
wx.requestPayment({
timeStamp: payParams.timeStamp,
nonceStr: payParams.nonceStr,
package: payParams.package,
signType: 'RSA',
paySign: payParams.paySign,
success: resolve,
fail: reject
})
})
}
11. 多端框架对比
| 框架 | 语法 | 支持平台 | 特色 |
|---|---|---|---|
| 原生 | WXML/WXSS/JS | 仅微信 | 性能最优,能力最全 |
| uni-app | Vue | 微信/支付宝/抖音/H5/App | 生态丰富,跨端能力强 |
| Taro | React/Vue | 微信/支付宝/抖音/H5/RN | 京东出品,React 友好 |
| mpvue | Vue | 微信(已停止维护) | Vue 语法,不再更新 |
| kbone | Vue/React | 微信 + H5 | 同构方案,微信和 H5 共用代码 |
| Remax | React | 微信/支付宝 | 运行时方案,React 完整支持 |
12. 常用原生能力
// 扫码
wx.scanCode({
success(res) {
console.log(res.result) // 扫码结果
}
})
// 定位
wx.getLocation({
type: 'gcj02',
success(res) {
console.log(res.latitude, res.longitude)
}
})
// 选择图片
wx.chooseMedia({
count: 9,
mediaType: ['image'],
success(res) {
const tempFilePaths = res.tempFiles.map(f => f.tempFilePath)
}
})
// 获取手机号(需企业认证)
// <button open-type="getPhoneNumber" bindgetphonenumber="getPhoneNumber">
// 获取手机号
// </button>
// 分享
wx.shareAppMessage({
title: '分享标题',
path: '/pages/index/index'
})
// 订阅消息
wx.requestSubscribeMessage({
tmplIds: ['template_id_1', 'template_id_2'],
success(res) {
console.log(res)
}
})
// 小程序跳转
wx.navigateToMiniProgram({
appId: 'other_app_id',
path: 'pages/index/index?id=123'
})
常见问题与踩坑
| 问题 | 原因 | 解决方案 |
|---|---|---|
| setData 卡顿 | 传输数据量过大或调用过于频繁 | 只传变化数据,合并调用,使用路径更新 |
| 图片模糊 | 未使用 2x 图或 mode 不对 | 使用 CDN 参数适配 DPR,设置 mode="aspectFill" |
| 苹果底部安全区 | iPhone X 底部 Home 指示条遮挡 | env(safe-area-inset-bottom) 适配 |
| 分包预加载失败 | preloadRule 网络限制 | 设置 network: "all",或在 Wi-Fi 下测试 |
| 分享图片不显示 | imageUrl 使用网络图但未配置域名 | 后台配置 downloadFile 合法域名 |
| wx.getUserInfo 废弃 | 平台隐私规范调整 | 使用 wx.getUserProfile 或头像昵称填写能力 |
| 长列表卡顿 | 渲染节点过多 | 使用虚拟列表(recycle-view),按需渲染 |
| iOS 音频自动播放 | iOS 限制自动播放 | 必须用户交互触发 wx.createInnerAudioContext() |
最佳实践
- 分包策略:主包只放首页和 tabBar 页,其他页面分包,使用 preloadRule 预加载
- setData 优化:只传变化的数据,合并调用,避免频繁触发渲染
- 图片优化:CDN + WebP 格式 + 懒加载 + 适当压缩质量
- 请求优化:封装统一请求,token 自动刷新,请求重试,请求取消
- 缓存策略:合理使用
wx.setStorageSync缓存接口数据,减少重复请求 - 骨架屏:首屏使用骨架屏,提升用户感知速度
- 体验优化:使用
wx.nextTick延迟非关键操作,避免页面切换卡顿
面试题
1. 小程序的双线程模型是什么?为什么要这样设计?
答:小程序采用逻辑层(JsCore)和渲染层(WebView)双线程架构,通过 JSBridge 通信。逻辑层负责业务逻辑和数据处理,渲染层负责 WXML/WXSS 解析和页面渲染。这样设计的原因:(1) 安全管控——逻辑层无法直接操作 DOM,杜绝了开发者使用 DOM API 绕过平台审核的风险;(2) 稳定性——JS 执行阻塞不会导致页面渲染卡死,反之亦然;(3) 性能——渲染线程和逻辑线程可以并行执行;(4) 多页面管理——每个页面一个 WebView 实例,页面切换更流畅。代价是 setData 通信有开销,需要优化数据传输量。
2. setData 为什么会影响性能?如何优化?
答:setData 的性能问题源于:(1) 通信开销——数据从逻辑层通过 JSBridge 序列化传到渲染层,数据量越大耗时越长;(2) 重渲染——渲染层收到数据后重新执行 WXML 模板对比和节点更新;(3) 阻塞——JSBridge 通信是同步的,频繁调用会阻塞逻辑层。优化策略:(1) 只传变化数据——this.setData({ 'list[0].name': 'new' }) 而非整个 list;(2) 合并调用——多个状态更新合并为一次 setData;(3) 减少数据量——不需要渲染的数据不通过 setData 传递,使用 this._data 或实例变量存储;(4) 延迟非关键更新——wx.nextTick 延迟执行;(5) 长列表虚拟化——使用 recycle-view 只渲染可见区域。
3. 小程序的分包策略是什么?独立分包有什么区别?
答:分包将小程序拆分为一个主包和多个子包,主包包含首页和 tabBar 页面,子包按业务模块划分。加载时先下载主包,进入分包页面时再下载对应子包。好处:(1) 减少首次加载时间;(2) 突破 2MB 限制(总包可达 20MB);(3) 按需加载,节省流量。独立分包(independent: true)是特殊分包,可以不依赖主包独立运行——从独立分包页面进入小程序时,不需要下载主包。适用场景:分享页、广告落地页等需要快速打开的页面。限制:独立分包不能引用主包资源(图片、JS、组件),getApp() 获取不到主包 App 实例,需要使用 getApp({ allowDefault: true }) 处理。
4. 小程序登录流程是怎样的?session_key 有什么作用?
答:登录流程:(1) 前端调用 wx.login() 获取临时 code;(2) 将 code 发送到后端;(3) 后端用 code + appId + appSecret 调用微信服务器 jscode2session 接口;(4) 获取 openid(用户唯一标识)和 session_key(会话密钥);(5) 后端生成自定义 token 返回前端存储。session_key 的作用:(1) 解密用户敏感数据(手机号、地址等);(2) 作为用户会话的凭证。注意:session_key 会过期,需要通过 wx.checkSession() 检查有效性,过期后重新调用 wx.login()。重要安全规则:session_key 绝不能传到前端,只能保存在后端;code 只能使用一次。
5. WXS 是什么?为什么需要它?
答:WXS(WeiXin Script)是小程序的一套脚本语言,运行在渲染层(WebView)中,可以在 WXML 中直接使用。为什么需要:(1) 性能优化——普通 JS 运行在逻辑层,调用数据处理函数需要通过 JSBridge 通信(约 20-50ms 延迟),WXS 运行在渲染层可以直接操作数据,无通信开销;(2) 动画优化——<wxs> 处理的动画可以响应 touch 事件实现 60fps 的实时交互(如拖拽、滑动删除)。限制:WXS 语法是 JS 子集,不支持 ES6+,不能调用小程序 API,不能使用 DOM,只能做纯数据处理。典型场景:价格格式化、时间格式化、列表过滤、手势动画。
6. 小程序与 H5 的通信方式有哪些?
答:三种通信方式:(1) URL 参数——H5 页面通过 URL 传递参数给小程序 wx.miniProgram.navigateTo({ url: '/pages/web/index?data=xxx' });(2) postMessage——H5 调用 wx.miniProgram.postMessage({ data: { key: 'value' } }),小程序在 <web-view> 的 bindmessage 事件中接收(注意只在特定时机触发:后退、组件销毁、分享时);(3) 微信 JSSDK——H5 通过 wx.miniProgram 对象调用小程序 API,如 wx.miniProgram.navigateTo、wx.miniProgram.switchTab。反向通信(小程序 → H5):通过修改 web-view 的 src URL 参数,或注入 JS(evaluateJs 方法,基础库 2.22.1+)。
7. 如何实现小程序的埋点和数据统计?
答:(1) 全局拦截——在 App 的 onShow/onHide 中自动采集页面访问数据;封装 Page 构造器,自动注入 onLoad/onShow 等生命周期埋点;(2) 事件代理——重写 Component 构造器,在 methods 中代理事件方法,自动上报用户行为;(3) 请求拦截——封装 wx.request,自动上报接口性能(耗时、状态码、错误信息);(4) 性能采集——wx.getPerformance() 获取页面加载性能数据;(5) 自定义埋点——业务关键路径手动埋点(如按钮点击、表单提交)。常用方案:微信官方”小程序数据助手”、友盟、神策、自建埋点 SDK。注意埋点数据量控制,避免影响主业务性能。
8. 小程序的审核被拒常见原因有哪些?如何避免?
答:常见拒审原因及规避:(1) 虚拟支付——iOS 上不得包含非苹果渠道的虚拟商品支付(如会员、课程、道具),必须使用苹果内购或改为实物/线下服务;Android 不受限制;(2) 诱导分享——“分享后解锁""分享领红包”等强制分享行为被禁止,分享功能必须是用户自愿的;(3) 用户隐私——收集用户信息(位置、手机号、通讯录等)未说明用途或未经授权,必须在隐私协议中明确说明;(4) 内容审核——UGC 内容需要接入微信内容安全 API(msgSecCheck)进行文本和图片审核;(5) 类目不符——小程序实际功能与申请类目不匹配,选择正确类目;(6) 网页内嵌——<web-view> 内嵌的 H5 页面不能是纯展示型网站,必须有交互功能。建议提审前对照《微信小程序平台运营规范》自查。