上篇讲了 .mpx 结构、条件编译和跨端样式。这篇继续聊实际开发里绕不开的几块:页面怎么滚、组件怎么传数据、节点怎么量、状态怎么管、编译报错了怎么查。

一、scroll-view:列表页的核心

上篇说过,输出 RN 时页面默认不滚动。所以凡是「一屏放不下、要上下滑」的页面,基本都要用 scroll-view

1.1 最简用法

<template>
  <scroll-view scroll-y class="scroll-wrap">
    <view wx:for="{{ list }}" wx:key="id" class="row">
      {{ item.name }}
    </view>
  </scroll-view>
</template>

<style>
.scroll-wrap {
  height: 100vh;  /* 必须给 scroll-view 明确高度 */
}
</style>

scroll-view 一定要设高度。不设的话,小程序里可能「碰巧能滚」,RN 里经常直接滚不动。高度可以用 100vhcalc(100vh - 88rpx)(减掉导航栏),或者父级 Flex 布局里 flex: 1

1.2 下拉刷新

小程序页面级有 onPullDownRefresh,RN 端页面级不触发,刷新逻辑要绑在 scroll-view 上:

<scroll-view
  scroll-y
  class="scroll-wrap"
  refresher-enabled="{{ true }}"
  refresher-triggered="{{ refreshing }}"
  bindrefresherrefresh="onRefresh"
>
  <!-- 列表内容 -->
</scroll-view>
createPage({
  data: {
    refreshing: false,
    list: [],
  },
  methods: {
    async onRefresh() {
      this.refreshing = true
      await this.loadData()
      this.refreshing = false
    },
    async loadData() {
      // 请求数据...
    },
  },
})

几个注意点:

  • refresher-triggered 要手动置回 false,不然刷新动画可能一直转
  • 输出 RN 时,确认项目文档里 refresher-* 相关属性在 RN 端的支持情况,不支持的话要用条件编译走别的方案
  • 页面 json 里如果开了 enablePullDownRefresh,那是页面级下拉,和 scroll-view 级是两套机制,别混着用

1.3 上拉加载更多

小程序页面级有 onReachBottom,RN 同样不触发。要在 scroll-view 上监听滚动到底:

<scroll-view
  scroll-y
  class="scroll-wrap"
  bindscrolltolower="onLoadMore"
  lower-threshold="{{ 100 }}"
>
  <!-- 列表 -->
  <view wx:if="{{ loading }}" class="loading-tip">加载中...</view>
  <view wx:if="{{ noMore }}" class="loading-tip">没有更多了</view>
</scroll-view>
methods: {
  onLoadMore() {
    if (this.loading || this.noMore) return
    this.page++
    this.loadData()
  },
}

lower-threshold 是距底部多少 px 时触发,默认 50,列表项比较高时可以适当加大,避免用户还没滑到底就频繁触发。

1.4 横向滚动

<scroll-view scroll-x class="scroll-x-wrap">
  <view class="scroll-x-inner">
    <view wx:for="{{ tabs }}" wx:key="id" class="tab-item">
      {{ item.label }}
    </view>
  </view>
</scroll-view>

横向滚动时,内部容器要设 white-space: nowrap 或者子项 display: inline-flex,不然子元素会换行,滚不起来。

1.5 长列表性能

列表几百上千条时,几个实用建议:

  • wx:key 用稳定唯一值(id),别用 index
  • 分页加载,别一次渲染全部数据
  • 图片列表加 lazy-load(<image lazy-load />),RN 端看文档是否支持
  • 能拆成多个 scroll-view 区块的别全塞一个里

二、组件通信:props、事件、插槽、wx:model

Mpx 组件通信和小程序原生 + Vue 的组合差不多,实际项目里四种方式都会用到。

2.1 props:父传子

选项式:

createComponent({
  properties: {
    title: String,
    count: {
      type: Number,
      value: 0,
    },
    disabled: {
      type: Boolean,
      value: false,
    },
  },
})

组合式 <script setup>

const props = defineProps({
  title: { type: String, value: '' },
  count: { type: Number, value: 0 },
})

父组件传值:

<my-card title="{{ pageTitle }}" count="{{ 3 }}" />

props 是单向的,子组件不要直接改 props。要改的话,在子组件里 emit 事件让父组件改,或者本地 copy 一份。

2.2 事件:子传父

子组件触发事件,父组件监听:

<!-- 子组件 my-button.mpx -->
<view class="btn" bindtap="handleTap">点击</view>
// 子组件
methods: {
  handleTap() {
    this.triggerEvent('click', { id: 1 })
  },
}

// 或者 script setup
const ctx = useContext()
const handleTap = () => {
  ctx.triggerEvent('click', { id: 1 })
}
<!-- 父组件 -->
<my-button bind:click="onBtnClick" />
// 父组件
methods: {
  onBtnClick(e) {
    console.log(e.detail)  // { id: 1 }
  },
}

事件名可以用 bind:clickbindclick,团队统一一种就行。传的数据在 e.detail 里。

2.3 插槽 slot

默认插槽:

<!-- 子组件 card.mpx -->
<view class="card">
  <view class="card-header">{{ title }}</view>
  <view class="card-body">
    <slot></slot>
  </view>
</view>
<!-- 父组件 -->
<my-card title="标题">
  <view>这里是插入的内容</view>
</my-card>

具名插槽:

<!-- 子组件 -->
<view class="layout">
  <view class="left"><slot name="left"></slot></view>
  <view class="center"><slot name="center"></slot></view>
  <view class="right"><slot name="right"></slot></view>
</view>
<!-- 父组件 -->
<my-layout>
  <view slot="left">左侧</view>
  <view slot="center">中间</view>
  <view slot="right">右侧</view>
</my-layout>

插槽适合「容器组件」:外层管布局和样式,内容由父组件决定。弹层框架、卡片框架、页面骨架这类场景很常见。

2.4 wx:model 双向绑定

简单表单控件:

<input wx:model="{{ keyword }}" />
<view>当前输入:{{ keyword }}</view>

自定义组件双向绑定,需要指定 prop 名和 event 名:

<!-- 父组件 -->
<my-input wx:model="{{ formData }}" wx:model-prop="value" wx:model-event="change" />
// 子组件 my-input.mpx
createComponent({
  properties: {
    value: Object,
  },
  methods: {
    onInput(e) {
      this.triggerEvent('change', { ...this.value, name: e.detail.value })
    },
  },
})

wx:model 本质是语法糖:父组件传 prop + 监听 event,编译器帮你展开。复杂表单(多个字段)可以整个对象绑一个 wx:model,子组件改完整个对象 emit 回去。

2.5 通信方式怎么选

场景 推荐方式
父传数据给子 props
子通知父(点击、提交、关闭) triggerEvent
父定制子组件内部结构 slot
表单输入、开关状态 wx:model
跨多层组件、全局状态 store(下一节)

三、wx:ref 与节点查询

有时候需要拿 DOM 尺寸、滚动位置,或者调用子组件的方法。Mpx 提供两套 API。

3.1 wx:ref 拿组件实例

模板里标记 ref:

<my-input wx:ref="inputRef" />
<view wx:ref="boxRef" class="target-box"></view>

脚本里访问:

// 选项式
createPage({
  onReady() {
    const inputIns = this.$refs.inputRef
    // 调子组件暴露的方法
    inputIns.focus()
  },
})

// 组合式 setup
createPage({
  setup(props, context) {
    onMounted(() => {
      const inputIns = context.refs.inputRef
      inputIns.focus()
    })
  },
})

子组件要用 defineExposemethods 暴露方法,父组件才能调到:

// 子组件 script setup
const focus = () => { /* ... */ }
defineExpose({ focus })

onReady / onMounted 之后 ref 才可靠,之前访问可能是 undefined。

3.2 createSelectorQuery 量节点

拿节点位置、尺寸:

<view class="target-box" id="target"></view>
methods: {
  measureBox() {
    this.createSelectorQuery()
      .select('.target-box')
      .boundingClientRect((rect) => {
        console.log(rect.width, rect.height, rect.top)
      })
      .exec()
  },
}

常用 API:

方法 作用
.select('.class') 选第一个匹配节点
.selectAll('.class') 选所有匹配节点
.select('#id') 按 id 选
.boundingClientRect(callback) 位置和尺寸
.scrollOffset(callback) 滚动偏移
.exec() 执行查询,必须调用

输出 RN 时的限制:

  • 选择器只支持 #id.class 单选择器,不支持复合选择器
  • 模板节点需要加空 wx:ref 帮助编译器建立映射(部分项目规范要求)
<view class="target-box" wx:ref="boxRef"></view>

改类名的时候,模板、样式、query 里的选择器字符串要一起改,漏一处就是「量出来全是 null」。

3.3 createIntersectionObserver 监听可见性

元素进入/离开视口时触发,做懒加载、曝光埋点时会用到:

onReady() {
  this.observer = this.createIntersectionObserver()
  this.observer
    .relativeToViewport({ bottom: 0 })
    .observe('.list-item', (res) => {
      if (res.intersectionRatio > 0) {
        console.log('元素可见了')
      }
    })
},
onUnload() {
  this.observer && this.observer.disconnect()
}

页面销毁时记得 disconnect(),不然可能内存泄漏。

四、状态管理:store 怎么用

页面间、组件间传 props 传 event,层级深了就很痛苦。全局或模块级状态用 store。

Mpx 支持两种风格:

4.1 Vuex 风格(@mpxjs/store)

老项目常见,和 Vuex 用法几乎一样:

// store/index.js
import { createStore } from '@mpxjs/core'

export default createStore({
  state: {
    userInfo: null,
    theme: 'light',
  },
  getters: {
    isLogin: (state) => !!state.userInfo,
  },
  mutations: {
    setUserInfo(state, info) {
      state.userInfo = info
    },
    setTheme(state, theme) {
      state.theme = theme
    },
  },
  actions: {
    async fetchUserInfo({ commit }) {
      const res = await mpx.xfetch.fetch({ url: '/api/user' })
      commit('setUserInfo', res.data)
    },
  },
})

页面里用:

import { createStore } from '@mpxjs/core'
import store from '../store'

createPage({
  store,
  computed: {
    ...createStore.mapState(['userInfo', 'theme']),
    ...createStore.mapGetters(['isLogin']),
  },
  methods: {
    ...createStore.mapMutations(['setTheme']),
    ...createStore.mapActions(['fetchUserInfo']),
  },
})

4.2 Pinia 风格(@mpxjs/pinia)

新项目更推荐,组合式 API 配合更自然:

// store/user.js
import { defineStore } from '@mpxjs/pinia'

export const useUserStore = defineStore('user', {
  state: () => ({
    userInfo: null,
  }),
  getters: {
    isLogin: (state) => !!state.userInfo,
  },
  actions: {
    async fetchUserInfo() {
      const res = await mpx.xfetch.fetch({ url: '/api/user' })
      this.userInfo = res.data
    },
  },
})
// 页面 setup 里
import { useUserStore } from '../store/user'

createPage({
  setup() {
    const userStore = useUserStore()

    onShow(() => {
      if (!userStore.isLogin) {
        userStore.fetchUserInfo()
      }
    })

    return {
      userInfo: computed(() => userStore.userInfo),
    }
  },
})

4.3 什么时候用 store

适合放 store 的:

  • 登录用户信息
  • 全局主题、语言
  • 多个页面共享的列表缓存
  • 跨组件层级的临时状态(比如全局 loading)

不适合放 store 的:

  • 只在单个组件内用的 UI 状态(展开/折叠)
  • 父子一层就能传完的 props
  • 可以靠 url 参数带过去的数据

store 不是万能胶,什么都塞进去后期很难维护。

4.4 模块化

项目大了以后 store 要拆模块:

// store/modules/cart.js
export default {
  namespaced: true,
  state: { items: [] },
  mutations: { /* ... */ },
}

// store/index.js
import cart from './modules/cart'
import user from './modules/user'

export default createStore({
  modules: { cart, user },
})

访问:createStore.mapState('cart', ['items']) 或 Pinia 的多个 defineStore

五、网络请求:mpx-fetch 实际用法

跨端项目里网络请求走 @mpxjs/fetch,不要各端各写一套。

5.1 接入

// app.mpx 或入口文件
import mpx from '@mpxjs/core'
import mpxFetch from '@mpxjs/fetch'

mpx.use(mpxFetch)

5.2 发请求

// 全局
mpx.xfetch.fetch({
  url: 'https://api.example.com/list',
  method: 'GET',
  params: { page: 1, size: 20 },
}).then((res) => {
  console.log(res.data)
})

// 页面/组件内
this.$xfetch.fetch({ url: '/api/detail', params: { id: 1 } })

POST 请求:

mpx.xfetch.fetch({
  url: '/api/submit',
  method: 'POST',
  data: { name: 'test', value: 123 },
})

5.3 拦截器

统一加 token、统一处理错误:

mpx.xfetch.interceptors.request.use((config) => {
  config.header = {
    ...config.header,
    Authorization: `Bearer ${getToken()}`,
  }
  return config
})

mpx.xfetch.interceptors.response.use(
  (res) => {
    if (res.data.code !== 0) {
      mpx.showToast({ title: res.data.message || '请求失败' })
      return Promise.reject(res)
    }
    return res
  },
  (err) => {
    mpx.showToast({ title: '网络异常' })
    return Promise.reject(err)
  },
)

拦截器写在入口文件里,全局生效。注意 request 拦截器必须 return config,否则请求发不出去。

5.4 取消请求

页面销毁时取消还在进行的请求,避免回调里改已卸载页面的 data:

import { CancelToken } from '@mpxjs/fetch'

const source = CancelToken.source()

mpx.xfetch.fetch({
  url: '/api/list',
  cancelToken: source.token,
})

// 页面 onUnload
source.cancel('page unloaded')

六、i18n 国际化

多语言项目会用到 Mpx 的 i18n 能力。几个容易踩的点:

import mpx from '@mpxjs/core'

// 组合式
const { t } = mpx.i18n

// 模板里直接用
// {{ t('namespace.key') }}

组合式 API 里解构出来的翻译函数,return 给模板时必须保持原名 ttctetm,不能 rename。 这是 Mpx 编译器的限制,改了名模板里翻译函数就不生效,而且编译不一定报错。

locale 文件按命名空间组织,key 用点分隔。新增文案时记得各语言文件同步加,别只改中文。

七、编译报错:怎么查、怎么分

做跨端开发,编译报错是日常。按类型分,排查会快很多。

7.1 模板相关

报错特征unexpected token、标签未闭合、属性不认识

常见原因

  • 模板里调了方法:{{ formatDate(time) }} → 改 computed
  • class 属性里拼 Mustache → 改 wx:class
  • 用了 RN 不支持的组件属性 → 查文档或加 @ios|android 条件编译
  • 标签没闭合、嵌套层级错了

排查:先定位报错的 .mpx 文件和行号,对照模板语法逐行看。

7.2 样式相关

报错特征selector not supported、PostCSS 报错、空选择器

常见原因

  • 复合选择器 .a .b → 改单类名
  • Stylus/Less 嵌套展开后是复合选择器 → 手动铺平
  • 条件编译留下空选择器 .foo { } → 整条规则包进 @mpx-if
  • 用了 RN 不支持的属性(floatgrid 等)

排查:把报错的选择器在样式文件里搜出来,对照 RN 样式支持列表改。

7.3 脚本相关

报错特征xxx is not defined、生命周期报错、API 不存在

常见原因

  • 用了 wx.xxx 而不是 mpx.xxx
  • 用了 RN 不支持的生命周期(onShareTimeline 等)
  • setup 里忘了 return 模板要用的变量
  • import 路径错了或循环依赖

排查:看报错栈最上面几帧,定位到具体 .mpx.ts 文件。

7.4 JSON / 配置相关

报错特征:组件未注册、usingComponents 路径错误

常见原因

  • 用了组件但 json 里没注册
  • 组件路径写错(相对路径从当前 .mpx 文件算)
  • 分包组件没配 componentPlaceholder

排查:看 json 区块的 usingComponents,路径能不能对应到实际文件。

7.5 RN 特有问题

报错特征:React Native 红屏、mpxTagName 相关、原生模块找不到

常见原因

  • mpxTagName@ios|android 指向的 RN 组件没注册或没 import
  • .ios.mpx 里 import 了 RN 库但构建配置没配
  • RN 专有属性拼写错误

排查:RN 红屏看 Call Stack,找到是哪个 .mpx 编译产物出的问题,回溯源文件。

7.6 通用排查流程

我自己遇到问题时的顺序:

  1. 读报错信息:文件路径 + 行号 + 错误类型,这三样够定位 80% 的问题
  2. 只改最近动过的文件:git diff 看一眼,八成是刚改的那几行
  3. 缩小范围:注释掉可疑代码块,看编译能不能过,二分法定位
  4. 查文档:Mpx 官方文档有各平台能力对照表,先查再改
  5. 清缓存重编:有时候缓存导致旧产物干扰,rm -rf dist node_modules/.cache 再编
  6. 分平台编:小程序能过、RN 不过,说明是跨端差异,上条件编译

别一上来就大范围改,越改越乱。

八、几个进阶技巧

8.1 mixins

多个页面有相同的初始化逻辑(埋点、登录检查、语言加载),可以抽 mixin:

// mixins/page-init.js
export default {
  onShow() {
    // 公共 onShow 逻辑
  },
}

// 页面
createPage({
  mixins: [pageInitMixin],
})

mixin 里的生命周期会和页面自己的合并执行。注意 mixin 太多时,逻辑来源不清晰,新人不好追。

8.2 动态组件

Tab 切换、状态机切换不同 UI 块:

<component is="{{ currentView }}"></component>
data: {
  currentView: 'view-a',  // 对应 usingComponents 里的 key
}
{
  "usingComponents": {
    "view-a": "./components/view-a",
    "view-b": "./components/view-b"
  }
}

is 的值必须是 json 里注册过的组件名字符串。

8.3 分包异步化

大项目按功能拆分包,首屏只加载主包:

{
  "subPackages": [
    {
      "root": "package-a",
      "pages": ["pages/list/index"]
    }
  ]
}

分包里的组件如果被主包引用,需要配 componentPlaceholder 做加载占位(上篇提过)。

预下载分包可以配 preloadRule,用户进某个页面时提前下载关联分包,减少跳转等待。

8.4 TypeScript

Mpx 支持 <script lang="ts"><script setup lang="ts">。props 可以这样写:

const props = defineProps<{
  title: string
  count?: number
}>()

类型定义文件 .d.ts 放项目里,接口响应、store state 都可以有类型提示。跨端项目里 TS 主要帮助少写低级错误,编译阶段就能拦一批。

九、踩坑备忘

  1. scroll-view 没高度 → 滚不动
  2. 下拉刷新动画不停 → refresher-triggered 没置 false
  3. 上拉加载重复触发 → 没做 loading / noMore 防抖
  4. 子组件方法调不到 → 检查 defineExpose 和 ref 时机(onReady 之后)
  5. query 量出来 null → 类名改了没同步、RN 用了复合选择器
  6. store 改了 UI 不更新 → mutation 里直接改 state,别替换整个 store
  7. i18n 模板不生效 → 翻译函数 rename 了,必须叫 t
  8. 拦截器忘了 return config → 请求发不出去
  9. 页面销毁没 cancel 请求 → 控制台 warning 或数据错乱
  10. 编译报错先 git diff → 别盲目大改
Logo

一站式 AI 云服务平台

更多推荐