Mpx 跨端开发实录(下):滚动、组件通信、节点查询与编译自救
mpx怎么调试呢
上篇讲了 .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 里经常直接滚不动。高度可以用 100vh、calc(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:click 或 bindclick,团队统一一种就行。传的数据在 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()
})
},
})
子组件要用 defineExpose 或 methods 暴露方法,父组件才能调到:
// 子组件 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 给模板时必须保持原名 t、tc、te、tm,不能 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 不支持的属性(
float、grid等)
排查:把报错的选择器在样式文件里搜出来,对照 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 通用排查流程
我自己遇到问题时的顺序:
- 读报错信息:文件路径 + 行号 + 错误类型,这三样够定位 80% 的问题
- 只改最近动过的文件:git diff 看一眼,八成是刚改的那几行
- 缩小范围:注释掉可疑代码块,看编译能不能过,二分法定位
- 查文档:Mpx 官方文档有各平台能力对照表,先查再改
- 清缓存重编:有时候缓存导致旧产物干扰,
rm -rf dist node_modules/.cache再编 - 分平台编:小程序能过、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 主要帮助少写低级错误,编译阶段就能拦一批。
九、踩坑备忘
- scroll-view 没高度 → 滚不动
- 下拉刷新动画不停 →
refresher-triggered没置 false - 上拉加载重复触发 → 没做 loading / noMore 防抖
- 子组件方法调不到 → 检查
defineExpose和 ref 时机(onReady 之后) - query 量出来 null → 类名改了没同步、RN 用了复合选择器
- store 改了 UI 不更新 → mutation 里直接改 state,别替换整个 store
- i18n 模板不生效 → 翻译函数 rename 了,必须叫
t - 拦截器忘了 return config → 请求发不出去
- 页面销毁没 cancel 请求 → 控制台 warning 或数据错乱
- 编译报错先 git diff → 别盲目大改
更多推荐




所有评论(0)