小程序为什么“快”:双线程架构
第一次接触小程序的人常常会问:同样是 HTML、CSS、JavaScript 的写法,为什么小程序的页面切换明显比网页更顺滑?答案不在写法,而在运行模型。小程序的宿主是微信客户端,它把页面渲染和业务逻辑拆到了两个彼此隔离的线程里:渲染层运行在 WebView 中,只负责把数据画成界面;逻辑层运行在独立的 JavaScript 引擎里(iOS 上是 JavaScriptCore,Android 上是 V8 系引擎),负责数据处理、网络请求和事件响应。两个线程不共享内存,所有数据交换都要经过微信客户端原生层转发。
小程序没有 DOM,也没有 window 和 document。你写的不是“网页”,而是一份描述界面的数据结构加一份处理事件的函数表,剩下的交给微信去渲染。
这套模型带来两个直接后果。第一是安全:逻辑层拿不到渲染层的 DOM,第三方脚本很难注入页面。第二是性能上限:既然跨线程通信有成本,那么“一次传多少数据”就成了性能的关键变量。理解了这一点,后面关于 setData 的所有建议都会变得顺理成章。
工程结构:四件套与 app.json
新建一个小程序项目,微信开发者工具会给出固定结构的骨架。根目录下 app.js、app.json、app.wxss 是全局文件,pages 目录里每个文件夹代表一个页面,页面又由四个同名文件组成。
project/
├─ app.js // 全局逻辑:App() 生命周期、全局数据
├─ app.json // 全局配置:页面路径、窗口样式、tabBar
├─ app.wxss // 全局样式,页面样式默认叠加在其上
├─ project.config.json // 开发者工具项目配置(不参与线上运行)
└─ pages/
├─ index/
│ ├─ index.wxml // 结构
│ ├─ index.wxss // 样式
│ ├─ index.js // 逻辑
│ └─ index.json // 页面配置(可选,但建议保留)
└─ logs/
app.json 是最先要读懂的配置文件:页面必须在这里注册,否则工具根本不会编译它。下面是一份最小可运行的配置。
{
"pages": ["pages/index/index", "pages/logs/logs"],
"window": {
"navigationBarTitleText": "吉极云码示例",
"navigationBarBackgroundColor": "#1E5FA8",
"navigationBarTextStyle": "white",
"backgroundTextStyle": "light"
},
"style": "v2",
"sitemapLocation": "sitemap.json"
}
pages 数组的第一项就是启动页。window 决定全局导航栏样式,而单个页面可以在自己的 json 里覆盖它——页面配置优先级高于全局配置,这也是“同一个导航栏在不同页面呈现不同标题”的实现方式。
视图层:WXML 数据绑定与 WXSS 自适应
WXML 是模板语法,不是 HTML。它提供数据绑定、列表渲染、条件渲染和模板引用四类能力,写法上以双大括号为入口。
<!-- pages/index/index.wxml -->
<view class="wrap">
<text class="title">{{title}}</text>
<view class="list">
<view class="row" wx:for="{{logs}}" wx:key="id"
bindtap="onTapRow" data-id="{{item.id}}">
<text>{{index + 1}}. {{item.name}}</text>
</view>
</view>
<button type="primary" bindtap="onAdd">添加一条</button>
</view>
/* pages/index/index.wxss */
.wrap { padding: 32rpx; }
.title { font-size: 40rpx; font-weight: 600; color: #1E5FA8; }
.row { padding: 24rpx; margin-bottom: 16rpx;
background: #F4F8FF; border-radius: 16rpx; }
wx:for 负责列表渲染,wx:key 一定要写,它让框架在数据变化时复用节点而不是整段重建;bindtap 绑定事件,data-* 用来把参数随事件带给逻辑层。样式里的 rpx 是小程序的自适应单位:屏幕宽度固定为 750rpx,所以 750rpx 的盒子在任何机型上都是满宽,而 40rpx 的字号在 375px 宽的手机上恰好等于 20px。
逻辑层:Page 生命周期与 setData
页面的逻辑写在 Page() 里,注册时传入一个对象,框架会在不同时机调用其中的生命周期函数。掌握下面这四个时机,绝大多数“数据什么时候取、界面什么时候更新”的问题就有答案了。
// pages/index/index.js
Page({
data: { title: "吉极云码", logs: [] },
onLoad(options) {
// 只触发一次:拿路由参数、发首次请求
this.fetchLogs();
},
onShow() { /* 每次显示都触发,包括从其他页面返回 */ },
onReady() { /* 首次渲染完成,此时可以获取节点信息 */ },
onUnload() { /* 页面销毁:清理定时器与监听 */ },
fetchLogs() {
wx.request({
url: "https://example.com/api/logs",
success: (res) => this.setData({ logs: res.data.list }),
fail: () => wx.showToast({ title: "加载失败", icon: "none" })
});
},
onAdd() {
const logs = this.data.logs.concat([{ id: Date.now(), name: "新增记录" }]);
this.setData({ logs });
},
onTapRow(e) { console.log("点击了第", e.currentTarget.dataset.id, "行"); }
});
- 改数据只能走 setData直接 this.data.logs.push(...) 不会触发渲染,逻辑层与渲染层就此不同步。
- 只传变化的部分用 this.setData({ "logs[0].name": "新名字" }) 这种路径写法,比整份数组重传的通信量小得多。
- 不要频繁调用setData 是跨线程通信,把多次修改合并成一次;高频场景(拖动、滚动)优先用 CSS 或 wxs 响应事件。
还有一个容易被忽略的细节:setData 的第二个参数是回调,界面渲染完成后才会执行。需要“先渲染、后测量”的逻辑必须放在这个回调里,否则量到的是旧布局。
自定义组件:把复用结构抽出来
当同一段结构在多个页面重复出现时,就应该把它抽成自定义组件。组件的写法和页面很像,只是把 Page() 换成 Component(),并通过 properties 声明对外暴露的数据。
// components/price-tag/index.js
Component({
properties: {
price: { type: Number, value: 0 },
unit: { type: String, value: "元" }
},
data: { display: "" },
observers: {
"price, unit"(p, u) {
this.setData({ display: (p / 100).toFixed(2) + u });
}
},
methods: {
onTap() { this.triggerEvent("buy", { price: this.data.price }); }
},
lifetimes: {
attached() { /* 进入节点树 */ },
detached() { /* 被移除,清理副作用 */ }
}
});
要用组件,得先在页面或全局的 json 里登记路径:在 index.json 中写 "usingComponents": { "price-tag": "/components/price-tag/index" },之后在 WXML 里就能像内置组件一样使用 <price-tag price="{{1999}}" unit="元" bind:buy="onBuy"></price-tag>。事件通过 triggerEvent 对外抛出,父级用 bind:事件名 接收,这和原生组件的 bindtap 是同一套机制。
常用内置组件与布局要点
内置组件不必背全,先把下面这一组用熟,日常界面基本都能拼出来。
| 组件 | 用途 | 实践要点 |
|---|---|---|
| view / text | 容器与文本 | 只有 text 能被长按选中,纯展示文字优先用 text |
| scroll-view | 局部滚动 | 必须给定高度或受 flex 约束,否则滚不起来 |
| image | 图片 | 默认高度 240px,务必显式设置宽高或 mode |
| button | 按钮 | type="primary" 走品牌色,open-type 可唤起分享、授权 |
| form / input | 表单 | 表单值通过 bindsubmit 的 detail.value 一次性收集 |
布局上仍然推荐 flex:小程序支持绝大部分 flex 属性,配合 justify-content 与 align-items 就能完成九成排版。真正需要警惕的是 fixed 定位与滚动容器混用,以及给 scroll-view 的子元素设置浮动——这些差异在真机上暴露得比模拟器里严重得多。
从工具到真机:上线前的检查清单
开发完成后别急着点上传。下面这份清单能挡掉大部分“本地正常、真机异常”的问题。
- 域名与证书:wx.request、wx.uploadFile 等接口调用的域名必须在小程序后台配置为合法域名,且必须是 HTTPS。开发阶段可以用开发者工具的“不校验合法域名”开关绕过,上线后没有这个开关。
- 包体积:主包有明确的大小上限,超了就要拆分包。把二级页面、帮助中心这类不常访问的内容放进 subpackages,能显著改善首次启动速度。
- 权限与隐私:涉及用户信息、位置、相册的接口,必须先在后台声明用途并通过隐私协议校验,否则接口直接失败。
- 体验评分:开发者工具的体验评分面板会指出图片未压缩、接口失败率偏高、setData 数据量过大等具体问题,比凭感觉优化靠谱得多。
// app.json 中拆分包的基本写法
{
"pages": ["pages/index/index"],
"subpackages": [
{ "root": "packageA", "pages": ["detail/detail", "help/help"] }
],
"preloadRule": {
"pages/index/index": { "network": "all", "packages": ["packageA"] }
}
}
preloadRule 让主包页面在进入时顺带预下载分包,用户点进二级页时内容已经在本地。这是一种“用流量换体验”的取舍,是否开启取决于目标用户的网络环境。