小程序为什么“快”:双线程架构

第一次接触小程序的人常常会问:同样是 HTML、CSS、JavaScript 的写法,为什么小程序的页面切换明显比网页更顺滑?答案不在写法,而在运行模型。小程序的宿主是微信客户端,它把页面渲染和业务逻辑拆到了两个彼此隔离的线程里:渲染层运行在 WebView 中,只负责把数据画成界面;逻辑层运行在独立的 JavaScript 引擎里(iOS 上是 JavaScriptCore,Android 上是 V8 系引擎),负责数据处理、网络请求和事件响应。两个线程不共享内存,所有数据交换都要经过微信客户端原生层转发。

小程序没有 DOM,也没有 window 和 document。你写的不是“网页”,而是一份描述界面的数据结构加一份处理事件的函数表,剩下的交给微信去渲染。
微信小程序入门配图:手机聊天界面与九宫格组件图标
小程序的双线程架构:逻辑层负责数据与事件,视图层负责渲染,setData 是两层之间唯一的桥

这套模型带来两个直接后果。第一是安全:逻辑层拿不到渲染层的 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 的三条纪律
  • 改数据只能走 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 是同一套机制。