原生小程序工程化指北:从混乱到规范的进化之路

TL;DR

本文是一份关于原生小程序项目模板搭建的实践指南,旨在帮助开发者构建一套高效、可维护、工程化程度高的小程序基础模板。内容覆盖依赖管理、状态管理、样式处理、环境配置、分包策略等多个方面。

核心能力概览

  • 依赖管理更顺畅 :用npm管理三方库,更好地拥抱开源社区。
  • 路径引用更清爽 :通过alias设置别名,告别层层 ../../
  • 状态更新更灵活 :引入MobX做响应式状态管理,告别频繁手动 setData
  • 数据变化一目了然 :支持使用computedwatch实现视图自动联动。
  • 样式更易维护 :无需依赖第三方插件,内置less预处理器,支持变量和函数,统一样式规范。
  • 组件逻辑复用更高效 :通过behaviors封装页面和组件的通用逻辑。
  • 分包策略更合理:支持路径不变的页面拆包,更合理的分包架构,解决主包超体积问题。
  • 配置切换更方便:构建时自动切换开发、测试、生产环境,省心不易错。
  • 常量使用更清晰:统一管理状态值,提升代码可读性与维护性。
  • 写组件更顺手 :支持 Vue Options API 风格写法,逻辑更集中,无缝使用mixins, computed, watch, created, mountedVue Options API,维护成本更低。

如需查看完整示例代码,可访问github.com/sparkle027/...

前言

在实际开发原生小程序的过程中,我们常常会遇到一些共性问题:代码结构混乱、组件复用度不高、状态管理不够灵活,甚至在环境切换、分包管理等方面也存在不少痛点。随着项目规模的扩大,这些问题会逐渐积累,影响开发效率和团队协作。

npm支持

需要注意的是,安装时需要使用npm install,而不是cnpm installcnpm安装会导致大部分第三方包无法被正确地构建而导致依赖安装异常和失败。这里列举安装npm包vant-weapp dayjs

配置cnpm镜像:

bash 复制代码
npm config set registry https://registry.npmmirror.com

创建package.json文件

bash 复制代码
npm init

安装依赖

bash 复制代码
npm i @vant/weapp dayjs

构建npm

Alias配置

使用 resolveAlias 配置项用来自定义模块路径的映射规则。

需要注意的是:

  1. resolveAlias的配置项对路由路径JSON**配置**文件是不生效。
  2. 小程序不会解析模块时并不会自动寻找index文件,需要手动补全文件路径。如:import { store } from '@/store/index'
json 复制代码
{
  "resolveAlias": {
    "@/*": "/*"
  }
}

响应式状态管理 MobX

在原生小程序开发中,虽然官方提供了 setData 进行状态管理,但它并不具备 Vue 2 那种 响应式、自动派发更新 的体验。因此,我们在模板库中引入了小程序官方的 mobx-miniprogram-bindings 进行响应式状态管理,使得状态的管理方式更接近 Vue 2 的 data 和 computed。

示例代码github.com/sparkle027/...

引入npm库

每次引入新的npm库都需要重新构建一遍npm。

bash 复制代码
npm install --save mobx-miniprogram mobx-miniprogram-bindings

创建store目录

结构示例:

bash 复制代码
/behaviors
└── compIndex.js    # 组件behavior
└── pageIndex.js    # 页面behavior

/components
└── button          # 按钮组件
  ├── index.js
  ├── index.json
  ├── index.wxml
  └── index.wxss

/pages
└── index           # 首页页面
  ├── index.js
  ├── index.json
  ├── index.wxml
  └── index.wxss
└── index-behavior  # behavior示例页面
  ├── index.js
  ├── index.json
  ├── index.wxml
  └── index.wxss
└── store-updated   # store-updated示例页面
  ├── index.js
  ├── index.json
  ├── index.wxml
  └── index.wxss

/store
├── index.js        # 状态管理入口文件
└── modules         # 存放具体的状态管理模块
  ├── common.js     # 通用状态管理模块
  └── user.js       # 用户相关状态管理模块
javascript 复制代码
import { makeAutoObservable } from 'mobx-miniprogram'

class Store {
  userInfo = {
    nickname: '白飞飞',
  }
  compBehaviors = 'compIndexBehavior'
  pageBehaviors = 'pageIndexBehavior'

  constructor() {
    makeAutoObservable(this)
  }
  
  get userType() {
    const { userInfo } = this

    return userInfo.nickname + '-1024'
  }

  setUserInfo(data) {
    this.userInfo = data
  }
}

export default new Store()
javascript 复制代码
import { makeAutoObservable } from 'mobx-miniprogram'

import user from './modules/user'

class Store {
  userStore

  constructor() {
    makeAutoObservable(this)
    this.userStore = user
  }
}

export const store = new Store()

在页面中引入

目前,在 Page 构造器内使用时,需要使用手工绑定的方式,createStoreBindings绑定的数据在data中:

做法:使用 createStoreBindings 创建绑定,它会返回一个包含清理函数的对象用于取消绑定。

注意:在页面 onUnload (自定义组件 detached )时一定要调用清理函数,否则将导致内存泄漏!

javascript 复制代码
import { createStoreBindings } from 'mobx-miniprogram-bindings'
import { store } from '@/store/index'

Page({
  onLoad() {
    this.storeBindings = createStoreBindings(this, {
      fields: {
        userInfo: () => store.userStore.userInfo,
        userType: () => store.userStore.userType,
      },
    })

    //// 如果有多个store
    //this.storeBindings2 = createStoreBindings(this, {
    //  fields: {
    //    xxx: () => store2.xxxStore.xxx,
    //  },
    //})
  },
  onUnload() {
    this.storeBindings.destroyStoreBindings()

    //// 如果有多个store
    //this.storeBindings2.destroyStoreBindings()
  },

  // 更改状态
  handleTap() {
    store.userStore.setUserInfo({
      nickname: '白飞飞 Page',
    })
  },
})
html 复制代码
<view>
  <text>
    userInfo.nickname: <text>{{ userInfo.nickname }}</text>
  </text>
</view>

<view>
  <text>
    userType: <text>{{ userType }}</text>
  </text>
</view>

<button bind:tap="handleTap">更改状态</button>

效果图:

在组件中引入

在 Component 构造器中使用时,可以使用behavior 绑定/手工绑定的方式,这里使用behavior的方式进行绑定:

做法:使用 storeBindingsBehavior 这个 behavior 和 storeBindings 定义段。

javascript 复制代码
import { storeBindingsBehavior } from 'mobx-miniprogram-bindings'

import { store } from '@/store/index'

Component({
  behaviors: [storeBindingsBehavior],
  properties: {},
  data: {},
  storeBindings: {
    fields: {
      userInfo: () => store.userStore.userInfo,
      userType: () => store.userStore.userType,
    },
  },
  methods: {
    handleTap() {
      store.userStore.setUserInfo({
        nickname: '白飞飞 Comp',
      })
    },
  },
})
html 复制代码
<view>
  <text>
    userInfo.nickname: <text>{{ userInfo.nickname }}</text>
  </text>
</view>

<view>
  <text>
    userType: <text>{{ userType }}</text>
  </text>
</view>

<button bind:tap="handleTap">component 更改状态</button>
json 复制代码
{
  "navigationBarTitleText": "pages/index",
  "usingComponents": {
    "mp-button": "/components/button/index"
  }
}
html 复制代码
<view>
  <text>
    userInfo.nickname: <text>{{ userInfo.nickname }}</text>
  </text>
</view>

<view>
  <text>
    userType: <text>{{ userType }}</text>
  </text>
</view>

<button bind:tap="handleTap">更改状态</button>

<view>
  <mp-button />
</view>

效果图:

在Page Behaviors中引入

写法和在页面中引入 是一致的,区别在于Page的生命周期需要写在Behavior的methods中。

:::warning 需要注意的是,Page的生命周期函数会覆盖Behavior methods中的Page生命周期函数(方法),即同名方法遵循以下规则:

:::

javascript 复制代码
import { createStoreBindings } from 'mobx-miniprogram-bindings'
import { store } from '@/store/index'

module.exports = Behavior({
  behaviors: [],
  properties: {},
  data: {},

  methods: {
    onLoad() {
      this.storeBindings = createStoreBindings(this, {
        fields: {
          userInfo: () => store.userStore.userInfo,
          userType: () => store.userStore.userType,
        },
      })
    },
    onUnload() {
      this.storeBindings.destroyStoreBindings()
    },
  },
})
javascript 复制代码
import behavior from '@/behaviors/pageIndex'
import { store } from '@/store/index'

Page({
  behaviors: [behavior],
  data: {},
  //// 会覆盖 behavior的 onLoad,导致无法绑定 store
  //onLoad() {
  //  console.log('[page onLoad]')
  //},
  handleTap() {
    store.userStore.setUserInfo({
      nickname: '白飞飞 Page',
    })
  },
})
javascript 复制代码
<view>
  <text>
    userInfo.nickname: <text>{{ userInfo.nickname }}</text>
  </text>
</view>

<view>
  <text>
    userType: <text>{{ userType }}</text>
  </text>
</view>

<button bind:tap="handleTap">更改状态</button>

效果图:

在Component Behaviors中引入

在Component Behaviors中必须使用手工绑定的方式。

:::warning 与Page Behaviors不同的是,组件的生命周期不会被相互覆盖

:::

javascript 复制代码
import { createStoreBindings } from 'mobx-miniprogram-bindings'
import { store } from '@/store/index'

module.exports = Behavior({
  lifetimes: {
    attached() {
      this.storeBindings = createStoreBindings(this, {
        fields: {
          userInfo: () => store.userStore.userInfo,
          userType: () => store.userStore.userType,
        },
      })
    },
    detached() {
      this.storeBindings.destroyStoreBindings()
    },
  },
})
javascript 复制代码
import compIndexBehavior from '@/behaviors/compIndex'
import { store } from '@/store/index'

Component({
  behaviors: [compIndexBehavior],
  lifetimes: {
    attached() {
      console.log('[log] - comp attached')
    },
  },
  methods: {
    handleTap() {
      store.userStore.setUserInfo({
        nickname: '白飞飞 Comp',
      })
    },
  },
})
html 复制代码
<view>
  <text>
    userInfo.nickname: <text>{{ userInfo.nickname }}</text>
  </text>
</view>

<view>
  <text>
    userType: <text>{{ userType }}</text>
  </text>
</view>

<button bind:tap="handleTap">component 更改状态</button>
json 复制代码
{
  "navigationBarTitleText": "pages/index-behavior",
  "usingComponents": {
    "mp-behavior-button": "/components/button-behavior/index"
  }
}
javascript 复制代码
<view>
  <text>
    userInfo.nickname: <text>{{ userInfo.nickname }}</text>
  </text>
</view>

<view>
  <text>
    userType: <text>{{ userType }}</text>
  </text>
</view>

<button bind:tap="handleTap">更改状态</button>

<view>
  <mp-behavior-button />
</view>

效果图:

延迟更新与立刻更新

为了提升性能,在 store 中的字段被更新后,并不会立刻同步更新到 this.data 上,而是等到下个 wx.nextTick 调用时才更新。(这样可以显著减少 setData 的调用次数。)

如果需要立刻更新,可以调用:

  • this.updateStoreBindings() (在 behavior 绑定 中)
  • this.storeBindings.updateStoreBindings() (在 手工绑定 中)

延迟/强制更新示例:

javascript 复制代码
import { createStoreBindings } from 'mobx-miniprogram-bindings'
import { store } from '@/store/index'

Page({
  onLoad() {
    this.storeBindings = createStoreBindings(this, {
      fields: {
        userInfo: () => store.userStore.userInfo,
        userType: () => store.userStore.userType,
      },
    })
  },
  onUnload() {
    this.storeBindings.destroyStoreBindings()
  },

  // 延迟更新
  handleTap() {
    console.log('[log] - 延迟更新')
    console.log('[log] - userInfo', this.data.userInfo)

    store.userStore.setUserInfo({
      nickname: '白飞飞 Page',
    })
    console.log('[log] - userInfo updated', this.data.userInfo)

    wx.nextTick(() => {
      console.log('[log] - userInfo updated nextTick', this.data.userInfo)
    })
  },

  // 强制更新
  handleTapUpdate() {
    console.log('[log] - 强制更新')
    console.log('[log] - userInfo', this.data.userInfo)

    store.userStore.setUserInfo({
      nickname: '白飞飞 Page',
    })
    this.storeBindings.updateStoreBindings()

    console.log('[log] - userInfo updated', this.data.userInfo)

    wx.nextTick(() => {
      console.log('[log] - userInfo updated nextTick', this.data.userInfo)
    })
  },
})
html 复制代码
<view>
  <text>
    userInfo.nickname: <text>{{ userInfo.nickname }}</text>
  </text>
</view>

<view>
  <text>
    userType: <text>{{ userType }}</text>
  </text>
</view>

<button bind:tap="handleTap">延迟更改状态</button>
<button bind:tap="handleTapUpdate">强制更改状态</button>

延迟更新:

强制更新:

部分更新

mobx-miniprogram-bindings是惰性依赖收集,只在数据被访问时建立追踪关系,通过精确的依赖追踪,避免不必要的页面更新。它不会追踪userInfo.nickname = xxx的变化。因此,如果只是更新对象中的一部分(子字段),是不会引发视图的更新的。建议的解决方案是:每次赋值都给一个新的引用对象。

需要注意的是,计算属性get绑定的值是会引发视图的更新

javascript 复制代码
import { makeAutoObservable } from 'mobx-miniprogram'

class Store {
  userInfo = {
    nickname: '白飞飞',
  }

  constructor() {
    makeAutoObservable(this)
  }

  get userType() {
    const { userInfo } = this

    return userInfo.nickname + '-1024'
  }

  setUserInfo(data) {
    this.userInfo = data
  }

  setNickname(nickname) {
    this.userInfo.nickname = nickname
  }

  setNewFields(data) {
    this.userInfo = data
  }
}

export default new Store()
javascript 复制代码
import { createStoreBindings } from 'mobx-miniprogram-bindings'
import { store } from '@/store/index'

Page({
  onLoad() {
    this.storeBindings = createStoreBindings(this, {
      fields: {
        userInfo: () => store.userStore.userInfo,
        userType: () => store.userStore.userType,
      },
    })
  },
  onUnload() {
    this.storeBindings.destroyStoreBindings()
  },

  // 仅更新对象中的某一个子字段
  handleTapUpdate() {
    console.log('[log] - 仅更新对象中的某一个子字段')
    console.log('[log] - userInfo', this.data.userInfo)

    store.userStore.setNickname('新名称')
    this.storeBindings.updateStoreBindings()

    console.log('[log] - userInfo updated', this.data.userInfo)
    console.log('[log] - userType updated', this.data.userType)

    wx.nextTick(() => {
      console.log('[log] - userInfo updated nextTick', this.data.userInfo)
    })
  },
  // 添加新的属性
  handleNewFields() {
    store.userStore.setNewFields({
      ...store.userStore.userInfo,
      desc: '个人描述',
    })
  },
})
html 复制代码
<view>
  <text>
    userInfo.nickname: <text>{{ userInfo.nickname }}</text>
  </text>
</view>

<view>
  <text>
    userType: <text>{{ userType }}</text>
  </text>
</view>

<view>
  <text>
    userInfo.desc: <text>{{ userInfo.desc }}</text>
  </text>
</view>

<button bind:tap="handleTapUpdate">仅更新对象中的某一个子字段</button>
<button bind:tap="handleNewFields">添加新的属性</button>

示例图:

实现原理

mobx-miniprogram-bindings 实际是基于MobX 的一个专门构建的兼容微信小程序的版本。响应式原理实际上就是MobX库的实现原理。

mobx-miniprogram-bindings 响应式更新机制的核心组成部分

  1. 响应式数据劫持
    • 优先使用 Proxy 进行深度代理,实现对数据的全方位监听。
    • 对于不支持 Proxy 的环境,自动降级到 Object.defineProperty 实现。
    • 通过递归代理的方式,确保嵌套对象的每个层级都能被正确追踪。
  2. 依赖追踪系统
    • 在组件中使用 store 的数据时,MobX 会自动记录组件依赖的数据。
    • 建立数据使用清单,清晰追踪每个组件依赖了哪些数据。
    • 当数据变化时,能够准确识别需要更新的组件。
  3. 更新触发机制
    • 修改 store 中的数据时,MobX 立即知道哪些组件需要更新。
    • 采用批量更新和异步调度机制,等到下个 wx.nextTick 调用时,通过小程序的 setData 方法将新数据同步到页面上。减少 setData 调用次数,优化性能。
  4. 性能优化策略
    • 实现惰性依赖收集,仅追踪真正用到的数据,避免资源浪费。
    • 采用批量更新和异步调度机制,提升渲染性能。

Computed - Watch

小程序官方的小程序自定义组件 computed / watch 扩展库 miniprogram-computed,写法与Vue2基本无异。

示例代码github.com/sparkle027/...

引入npm库

每次引入新的npm库都需要重新构建一遍npm。

bash 复制代码
npm install --save miniprogram-computed

computed 基本用法

需要关注的是:

  1. computed 函数中不能访问 this ,只有 data 对象可供访问。
  2. computed 中的函数的返回值会被设置到 this.data 对象中。
  3. 如果需要访问this,可以使用watch替换。
javascript 复制代码
const computedBehavior = require('miniprogram-computed').behavior

Page({
  behaviors: [computedBehavior],

  data: {
    applePrice: 5, // 苹果单价
    appleCount: 10, // 苹果数量
    discount: 0.8, // 折扣率
  },

  computed: {
    // 计算总价
    totalPrice(data) {
      return (data.applePrice * data.appleCount).toFixed(2)
    },
    // 计算折扣后的价格
    discountedPrice(data) {
      return (data.applePrice * data.appleCount * data.discount).toFixed(2)
    },
    // 计算节省的金额
    savedMoney(data) {
      return (data.applePrice * data.appleCount * (1 - data.discount)).toFixed(2)
    }
  },

  onLoad() {},

  // 添加苹果数量
  addApple() {
    this.setData({
      appleCount: this.data.appleCount + 1
    })
  },

  // 减少苹果数量
  reduceApple() {
    if (this.data.appleCount > 0) {
      this.setData({
        appleCount: this.data.appleCount - 1
      })
    }
  },

  // 增加折扣率
  addDiscount() {
    if (this.data.discount < 1) {
      this.setData({
        discount: Number((this.data.discount + 0.1).toFixed(1))
      })
    }
  },

  // 减少折扣率
  reduceDiscount() {
    if (this.data.discount > 0.1) {
      this.setData({
        discount: Number((this.data.discount - 0.1).toFixed(1))
      })
    }
  }
})
html 复制代码
<view>
  <view class="my-5">苹果单价:¥{{ applePrice }} </view>
  <view class="flex items-center gap-5 my-5">
    <text>苹果数量:{{ appleCount }}</text>
    <view class="flex items-center gap-5">
      <button bindtap="reduceApple">-</button>
      <button bindtap="addApple">+</button>
    </view>
  </view>
  <view class="flex items-center gap-5 my-5">
    <text>折扣率:{{ discount * 100 }}%</text>
    <view class="flex items-center gap-5">
      <button bindtap="reduceDiscount">-</button>
      <button bindtap="addDiscount">+</button>
    </view>
  </view>

  <view class="my-5">(computed)总价:¥{{ totalPrice }}</view>
  <view class="my-5">(computed)折扣后价格:¥{{ discountedPrice }}</view>
  <view class="my-5">(computed)节省金额:¥{{ savedMoney }} </view>
</view>
css 复制代码
.flex {
  display: flex;
}

.items-center {
  align-items: center;
}

.gap-5 {
  gap: 20rpx;
}

.my-5 {
  margin: 20rpx 0;
}

button {
  margin: 0 !important;
  width: 120rpx !important;
  height: 60rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  line-height: 1;
}

效果图:

watch 基本用法

watchobservers 的用法类似,但二者存在关键区别:

  • observers 无论字段值是否实际发生变化,都会触发;
  • watch 只有在字段值真正变化时才会触发,并且触发时会携带一个参数,表示当前字段的新值(不包含旧值)。
javascript 复制代码
const computedBehavior = require('miniprogram-computed').behavior

Page({
  behaviors: [computedBehavior],

  data: {
    applePrice: 5, // 苹果单价
    appleCount: 10, // 苹果数量
    discount: 0.8, // 折扣率
  },

  computed: {
    // 计算总价
    totalPrice(data) {
      return (data.applePrice * data.appleCount).toFixed(2)
    },
    // 计算折扣后的价格
    discountedPrice(data) {
      return (data.applePrice * data.appleCount * data.discount).toFixed(2)
    },
    // 计算节省的金额
    savedMoney(data) {
      return (data.applePrice * data.appleCount * (1 - data.discount)).toFixed(
        2
      )
    },
  },

  watch: {
    appleCount(newVal) {
      console.log('[log] - appleCount', newVal)
    },
    totalPrice(newVal) {
      console.log('[log] - totalPrice', newVal)
    },
    discountedPrice(newVal) {
      console.log('[log] - discountedPrice', newVal)
    },
    'discount, savedMoney'(discount, savedMoney) {
      console.log('[log] - [discount, savedMoney]', [discount, savedMoney])
    },
  },

  onLoad() {},

  // 添加苹果数量
  addApple() {
    this.setData({
      appleCount: this.data.appleCount + 1,
    })
  },

  // 减少苹果数量
  reduceApple() {
    if (this.data.appleCount > 0) {
      this.setData({
        appleCount: this.data.appleCount - 1,
      })
    }
  },

  // 增加折扣率
  addDiscount() {
    if (this.data.discount < 1) {
      this.setData({
        discount: Number((this.data.discount + 0.1).toFixed(1)),
      })
    }
  },

  // 减少折扣率
  reduceDiscount() {
    if (this.data.discount > 0.1) {
      this.setData({
        discount: Number((this.data.discount - 0.1).toFixed(1)),
      })
    }
  },
})

效果图:

与 mobx-miniprogram-bindings 一起使用

mobx-miniprogram-bindings一起使用时,在behaviors列表中computedBehavior必须在后面:

javascript 复制代码
import { storeBindingsBehavior } from 'mobx-miniprogram-bindings'
const computedBehavior = require('miniprogram-computed').behavior

Component({
  behaviors: [storeBindingsBehavior, computedBehavior],
  /* ... */
})

预处理器 Less

小程序代码包要求代码文件为 wxml / wxss / js / json / wxs。

如果我们希望使用 TypeScript 或 less 去开发小程序,就需要将 ts 文件或 less 文件编译成对应的 js 文件 或 wxss 文件,这个编译过程以前是需要开发者在工具外自行配置。

早期在小程序中使用Less时,通常依赖 VS Code 插件 Easy Less 来自动生成对应的 wxss 文件。虽然配置简单,但这种方式强绑定编辑器,开发者之间还需频繁同步配置文件,既繁琐又低效。

从开发者工具版本 1.05.2109101 起,小程序官方已支持扩展编译功能。现在只需进行简单配置,即可原生支持 Less 编译,开发体验大幅提升。

示例代码github.com/sparkle027/...

开启扩展编译

/project.config.json文件中,修改setting下的useCompilerPlugins字段为["less"],即可开启工具内置的less编译插件。 目前支持三个编译插件:typescript、less、sass

需要注意的是:开启了less编译后,当同级文件夹中存在同名的.less文件和.wxss文件时,样式以.less文件为准生成(没有.less文件时依旧以.wxss文件为准)。

json 复制代码
{
  /* ... */
  "setting": {
    /* ... */
    "useCompilerPlugins": [
      "less"
    ]
  },
}

全局变量

从开发者工具 1.06.2403132 以上开始,支持 less 直接引用app.less中声明的变量和方法,编译器会默认为所有的非 app.less 文件增加引用

less 复制代码
@import (optional, reference) '/app.less';

使用示例

less 复制代码
@primary-color: #66ccff;

.text-ellipsis(@line: 1) {
  overflow: hidden;
  text-overflow: ellipsis;
  display: -webkit-box;
  -webkit-line-clamp: @line;
  -webkit-box-orient: vertical;
}

page {
  padding: 24rpx;
  box-sizing: border-box;

  button {
    width: auto !important;
    max-width: auto !important;
  }
}
html 复制代码
<view class="container">
  <text>pages/less/index.wxml</text>

  <view class="desc">
    Lorem ipsum dolor sit amet consectetur adipisicing elit. Omnis fugit rerum,
    quas quaerat est ratione aliquid ducimus qui illo in corrupti perferendis
    sunt, corporis magnam. Necessitatibus at error distinctio accusantium?
  </view>
</view>
less 复制代码
.container {
  text {
    color: @primary-color;
  }
  .desc {
    width: 600rpx;
    border: 1rpx solid #000;
    .text-ellipsis(2);
  }
}

示例图:

Behaviors (Mixins)

behaviors 是用于组件间代码共享的特性,类似于一些编程语言中的 "mixins" 或 "traits"。

每个 behavior 可以包含一组属性、数据、生命周期函数和方法。组件引用它时,它的属性、数据和方法会被合并到组件中,生命周期函数也会在对应时机被调用。每个组件可以引用多个 behaviorbehavior 也可以引用其它 behavior

使用方式和规则直接参考小程序文档-自定义组件 / behaviors

Page生命周期声明方式

文档中没提到的是Page的生命周期是如何声明的。在社区找到了Page生命周期的声明方式,因为生命周期实际也是一个可执行的函数,区别在于Page的生命周期需要写在Behavior的methods中。

需要注意的是,Page的生命周期函数会覆盖Behavior methods中的Page生命周期函数(方法)。

使用示例

在组件中使用

注册一个behavior my-behavior.js

javascript 复制代码
module.exports = Behavior({
  behaviors: [],
  properties: {
    myBehaviorProperty: {
      type: String
    }
  },
  data: {
    myBehaviorData: {}
  },
  attached {},
  methods: {
    myBehaviorMethod() {}
  }
})

引入behavior my-behavior.js

javascript 复制代码
// my-behavior.js
import myBehavior from 'my-behavior'

Component({
  behaviors: [myBehavior],
  properties: {
    myProperty: {
      type: String,
    },
  },
  data: {
    myData: 'my-data',
  },
  created() {},
  attached() {},
  ready() {},
  methods: {
    myMethod() {},
  }
})

在页面中使用

注册一个behavior my-behavior.js

javascript 复制代码
module.exports = Behavior({
  behaviors: [],
  properties: {
    myBehaviorProperty: {
      type: String
    }
  },
  data: {
    myBehaviorData: {}
  },

  methods: {
    onLoad() {},
    onUnload() {},
    
    myBehaviorMethod() {}
  },
})

引入behavior my-behavior.js

javascript 复制代码
// my-behavior.js
import myBehavior from 'my-behavior'

Page({
  behaviors: [behavior],
  properties: {
    myProperty: {
      type: String,
    },
  },
  data: {
    myData: 'my-data',
  },
  //// 会覆盖 behavior的 onLoad
  //onLoad() {},
  
  myMethod() {},
})

常量管理(辅助工具)

在开发中,"魔法数字"(Magic Numbers)是指代码中直接使用的具体数字或字符串,这些值没有明确的意义,容易导致代码难以理解和维护。使用常量可以解决这个问题:

  • 提高可读性:通过使用具有描述性的常量名称,代码变得更容易理解。
  • 便于维护:当某个值需要修改时,只需更新常量定义,而不必搜索整个代码库。
  • 减少错误:避免因为误用或错写数字或字符串而引入的潜在错误。.

示例代码: github.com/sparkle027/...

应用场景

举个常见的例子:

使用魔法数字的代码:

javascript 复制代码
if (task.status === 1) {
  // 任务[未开始]时逻辑
}

if (task.status === 3) {
  // 任务[完成]时逻辑
}

if (task.status === 5) {
  // 任务[取消]时逻辑
}

使用常量的代码

javascript 复制代码
const {
  TASK_STATUS: { NOT_STARTED, COMPLETED, CANCELLED },
} = constants

if (task.status === NOT_STARTED) {
  // 任务[未开始]时逻辑
}

if (task.status === COMPLETED) {
  // 任务[完成]时逻辑
}

if (task.status === CANCELLED) {
  // 任务[取消]时逻辑
}

通过使用常量,3 被替换为 COMPLETED,语义清晰,明确地表达了代码的意图,代码变得更容易理解。

创建constants目录

javascript 复制代码
/constants
├── index.js              # 常量管理入口文件
├── constant-helper.js		# 管理和处理常量的辅助工具
└── modules               # 存放具体的常量管理模块
  ├── task.js             # 任务相关常量管理模块

/pages
└── constants             # 常量示例页面
  ├── index.js
  ├── index.json
  ├── index.wxml
  └── index.wxss

实现原理 & 特性

constant-helper.js用于初始化和管理常量对象,提供统一的常量处理接口,具有以下特性:

  • 结构化常量:支持以数组形式定义的常量,第一个元素为值,第二个元素为名称。
  • 实用方法:自动为每个常量集添加方法,用于获取键、值和名称。

实现原理:

javascript 复制代码
/**
 * 常量辅助工具
 * 用于初始化和管理常量对象,提供统一的常量处理接口
 * @param {Object} constants - 包含多个常量对象的对象
 * @example
 * const constants = {
 *   STATUS: {
 *     PENDING: [0, '待处理'],
 *     PROCESSING: [1, '处理中'],
 *     COMPLETED: [2, '已完成']
 *   }
 * };
 * initConstant(constants);
 */
export function initConstant(constants) {
  // 遍历所有常量对象
  for (const key of Object.keys(constants)) {
    const item = constants[key]
    const keys = Object.keys(item)
    const nameMap = {}
    
    // 处理数组格式的常量 [值, 名称]
    for (const cst of keys) {
      const value = item[cst]
      if (Array.isArray(value)) {
        item[cst] = value[0]
        nameMap[item[cst]] = value[1]
      }
    }
    item.typeMap = nameMap

    /**
     * 将常量转换为列表格式,用于生成下拉列表等UI组件的数据源
     * @returns {Array} 返回格式为 [{ text: 显示名称, value: 常量值 }] 的数组
     */
    item.toList = function () {
      return Object.entries(this)
        .filter(
          ([key]) =>
            !['typeMap', 'toList', 'keys', 'values', 'names', 'name'].includes(
              key
            )
        )
        .map(([_, value]) => ({
          text: this.name(value),
          value,
        }))
    }

    /**
     * 获取所有常量键名
     * @returns {Array} 返回所有常量键名的数组,过滤掉内部属性和方法
     */
    item.keys = function () {
      return Object.keys(this).filter(
        (it) => it !== 'typeMap' && typeof this[it] !== 'function'
      )
    }

    /**
     * 获取所有常量值
     * @returns {Array} 返回所有常量值的数组
     */
    item.values = function () {
      return this.keys().map((it) => this[it])
    }

    /**
     * 获取所有常量的显示名称
     * @returns {Array} 返回所有常量显示名称的数组
     */
    item.names = function () {
      if (typeof this.name === 'function') {
        let result = []
        for (const v of this.values()) {
          result.push(this.name(v))
        }
        return result
      }
      return Object.keys(this.typeMap).map((it) => this.typeMap[it])
    }

    /**
     * 根据常量值获取对应的显示名称
     * 如果未自定义name方法,则使用默认的typeMap映射
     * @param {*} v - 常量值
     * @returns {string} 返回对应的显示名称
     */
    if (!item.name) {
      item.name = function (v) {
        return this.typeMap[v]
      }
    }
  }
}

使用示例

假设 /constants/modules/task.js 中定义了如下常量:

javascript 复制代码
export const TASK_STATUS = {
  NOT_STARTED: [1, '未开始'],
  IN_PROGRESS: [2, '进行中'],
  COMPLETED: [3, '已完成'],
  PAUSED: [4, '已暂停'],
  CANCELLED: [5, '已取消'],
  OVERDUE: [6, '已逾期'],
}

注册入口:

javascript 复制代码
import { TASK_STATUS } from './modules/task'
import { initConstant } from './constant-helper'

const constants = {
  TASK_STATUS,
}

initConstant(constants)

export const Constants = constants
export default constants

代码示例:

javascript 复制代码
import { Constants } from '@/constants/index'

const { TASK_STATUS } = Constants

console.log('[log] NOT_STARTED', TASK_STATUS['NOT_STARTED'])
// 输出: [log] NOT_STARTED 1
console.log('[log] IN_PROGRESS', TASK_STATUS['IN_PROGRESS'] === 2)
// 输出: true

// 示例1:获取任务状态列表(用于下拉选择)
const taskStatusList = TASK_STATUS.toList()
// 输出: [
//   { text: '未开始', value: 1 },
//   { text: '进行中', value: 2 },
//   { text: '已完成', value: 3 },
//   { text: '已暂停', value: 4 },
//   { text: '已取消', value: 5 },
//   { text: '已逾期', value: 6 }
// ]

// 示例2:获取所有状态值
const statusValues = TASK_STATUS.values()
// 输出: [1, 2, 3, 4, 5, 6]

// 示例3:获取所有状态名称
const statusNames = TASK_STATUS.names()
// 输出: ['未开始', '进行中', '已完成', '已暂停', '已取消', '已逾期']

// 示例4:根据值获取显示名称
const statusName = TASK_STATUS.name(1)
// 输出: '未开始'

// 示例5:自定义name方法
TASK_STATUS.name = function(v) {
  if (v === 1) return '尚未开始';
  if (v === 2) return '正在处理';
  if (v === 3) return '处理完成';
  if (v === 4) return '暂停处理';
  if (v === 5) return '已取消';
  if (v === 6) return '已超时';
  return '未知状态';
}

// 示例6:使用自定义name方法后的效果
const customStatusName = TASK_STATUS.name(1)
// 输出: '尚未开始'

// 示例7:获取所有键名
const statusKeys = TASK_STATUS.keys()
// 输出: ['NOT_STARTED', 'IN_PROGRESS', 'COMPLETED', 'PAUSED', 'CANCELLED', 'OVERDUE']

Page({
  data: {
    taskTypeMap: TASK_STATUS.typeMap,
    taskStatusList,
    statusValues,
    statusNames,
    statusName,
    customStatusName,
    statusKeys,
    currentStatus: 1
  },

  // 切换状态
  changeStatus(e) {
    const status = e.detail.value
    this.setData({
      currentStatus: taskStatusList[status].value
    })
  }
})
html 复制代码
<view class="container">
  <view class="section">
    <text class="title">当前状态值:{{ currentStatus }}</text>
    <text class="title">当前状态名称:{{ taskTypeMap[currentStatus] }}</text>
  </view>

  <!-- 示例1:下拉选择器 -->
  <view class="section">
    <text class="title">1. 下拉选择器示例</text>
    <picker
      bindchange="changeStatus"
      range="{{ taskStatusList }}"
      range-key="text"
    >
      <view class="picker"> 当前状态:{{ taskTypeMap[currentStatus] }} </view>
    </picker>
  </view>

  <!-- 示例2:显示所有状态值 -->
  <view class="section">
    <text class="title">2. 所有状态值</text>
    <view class="list">
      <text wx:for="{{ statusValues }}" wx:key="*this">{{ item }}</text>
    </view>
  </view>

  <!-- 示例3:显示所有状态名称 -->
  <view class="section">
    <text class="title">3. 所有状态名称</text>
    <view class="list">
      <text wx:for="{{ statusNames }}" wx:key="*this">{{ item }}</text>
    </view>
  </view>

  <!-- 示例4:根据值获取名称 -->
  <view class="section">
    <text class="title">4. 根据值获取名称</text>
    <view>
      <text>状态值 1 对应的名称:</text>
      <text class="highlight">{{ statusName }}</text>
    </view>
  </view>

  <!-- 示例5:自定义名称后的效果 -->
  <view class="section">
    <text class="title">5. 自定义名称后的效果</text>
    <view>
      <text>状态值 1 对应的自定义名称:</text>
      <text class="highlight">{{ customStatusName }}</text>
    </view>
  </view>

  <!-- 示例6:显示所有键名 -->
  <view class="section">
    <text class="title">6. 所有键名</text>
    <view class="list">
      <text wx:for="{{ statusKeys }}" wx:key="*this">{{ item }}</text>
    </view>
  </view>
</view>
less 复制代码
.container {
  padding: 20rpx;
}

.section {
  margin-bottom: 30rpx;
  padding: 20rpx;
  background: #f8f8f8;
  border-radius: 10rpx;
  .title {
    display: block;
    font-size: 32rpx;
    font-weight: bold;
    margin-bottom: 20rpx;
    color: #333;
    & + .title {
      margin-bottom: 0;
    }
  }

  .highlight {
    color: red;
  }

  .picker {
    padding: 20rpx;
    background: #fff;
    border-radius: 8rpx;
  }

  .list {
    display: flex;
    flex-wrap: wrap;
    gap: 20rpx;
    text {
      padding: 10rpx 20rpx;
      background: #fff;
      border-radius: 6rpx;
      font-size: 28rpx;
    }
  }
}

示例图:

分包

微信小程序的分包功能是为了减小主包体积,通过按需加载子包提升启动速度和加载效率。目前小程序分包大小有以下限制:

  • 整个小程序所有分包大小不超过 30M(服务商代开发的小程序不超过 20M)
  • 单个分包/主包大小不能超过 2M

随着小程序业务的不断扩展,页面数量和资源体积迅速增长,主包体积接近 2M 已成为常见问题。为了满足微信平台的包体限制要求(主包/子包 ≤ 2M,总体积 ≤ 30M),同时优化启动速度和加载性能,推荐采用"分层拆包的策略进行小程序分包架构设计。

示例代码: github.com/sparkle027/...

以下是常用的分包策略,适用于大多数业务场景:

推荐架构

为了方便分包的统一管理,推荐在项目中单独创建一个用于分包的目录(如 /packages),将所有子包页面集中管理。每个子包使用一个独立的目录,并保持其内部结构与/pages目录一致,便于维护和迁移。

这种方式不仅有利于代码组织清晰,更适合团队协作和模块化管理, 也方便后期结合CI/CD做全项目构建或动态发布 。

npm分包

每个分包可以拥有自己的package.json,在构建时,它们各自的依赖不会被打包进主包,而是分别打进对应的分包产物中,从而进一步减小主包体积,实现依赖的模块化管理。

主包旧页面分包(路径不变)

在维护老项目时,经常会遇到主包体积超过 2M,导致小程序无法上传的问题。常见原因之一是某些推广页面资源较大,占用了过多空间。这个时候,就需要通过分包的方式,把这些页面从主包中剥离出来,减轻主包负担。

不过,对于已经投放使用的推广页面来说,路径通常是不能修改的。如果路径发生变化,用户通过旧链接访问就会出现白屏,影响使用体验。

为了解决这个问题,我们可以用一个相对简单的处理方式:

假设页面原本在主包路径是pages/old-page/index,那么在分包配置中,将该子包的root设置为 pages/old-page,分包页面路径保持为 index。这样页面路径不变,仍然是pages/old-page/index,但它实际上已经被划分到了子包中,不再占用主包体积

这种方式既满足了页面路径不变的要求,又成功实现了页面资源的模块化拆分,是处理历史页面分包的一个常用技巧。

json 复制代码
{
  "pages": [],
  "subpackages": [
    {
      "root": "pages/old-page",
      "pages": ["index"]
    }
  ],
}

环境变量配置

在小程序开发中,我们通常会接触多个运行环境,比如开发环境、测试环境和生产环境。不同环境对应不同的配置,例如 API 地址、调试开关、页面跳转链接等。如果将这些配置硬编码在代码中,每次切换环境就需要手动修改,既繁琐又容易出错,严重时还可能导致上线后访问了错误的接口。

示例代码: github.com/sparkle027/...

这时,引入环境变量可以很好地解决问题:

  1. 统一管理多环境配置:通过环境变量,可以将每个环境的配置集中管理(API地址、CDN地址、WebView地址等),代码中只需引用,不用修改。
  2. 自动切换,避免人为出错:搭配构建工具或配置脚本,可以在构建时根据不同环境自动替换变量,确保部署稳定、安全。
  3. 提高代码可读性和维护性:配置与业务逻辑解耦,让项目结构更清晰,团队协作更顺畅。

由于原生小程序不支持类似.env文件的机制,我们可以手动构建一套简单自动化的环境变量管理方案,通过node脚本在构建时写入环境配置。整个流程如下:

  1. 创建环境配置文件 :为开发和生产环境分别编写配置(如:development.jsproduction.js)。
  2. 编写设置环境的脚本 :通过 cross-env 设置 NODE_ENV,并将其写入env.js
  3. 根据环境动态加载配置 :在 index.js 中读取 env.js 的值,加载对应的环境配置。
  4. 在业务代码中统一引用配置 :项目中只需 import config from '@/config/index',无需关心当前是哪个环境。
  5. 配置构建命令 :使用 npm run devnpm run prod 来自动设置环境变量并在终端提示当前是在哪个环境变量。

结构示例:

plain 复制代码
/config
├── index.js           # 统一导出配置入口
└── env                # 首页页面
  ├── development.js   # 开发环境配置
  ├── env.js           # 环境变量读取文件
  ├── index.js         # 环境变量配置导出逻辑
  └── production.js    # 生成环境配置

安装依赖

shell 复制代码
npm i -D chalk cross-env

创建脚本

javascript 复制代码
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import chalk from 'chalk'

const __dirname = path.dirname(fileURLToPath(import.meta.url))
const envFile = path.join(__dirname, '../config/env/env.js')

const nodeEnv = process.env.NODE_ENV || 'development'
const content = `export const NODE_ENV = '${nodeEnv}'`

fs.writeFileSync(envFile, content, 'utf8')

// 根据环境选择颜色
const envColor = nodeEnv === 'production' ? chalk.red : chalk.green
const message = `🌟 环境已设置为: ${envColor(nodeEnv)}`
const padding = 6
const borderLength = message.length + padding * 2
const borderLine = '═'.repeat(borderLength)
const emptyLine = ' '.repeat(borderLength)

console.log(envColor.bold(`╔${borderLine}╗`))
console.log(envColor.bold(`║${emptyLine}║`))
console.log(
  envColor.bold(`║${' '.repeat(padding)}${message}${' '.repeat(padding + 4)}║`)
)
console.log(envColor.bold(`║${emptyLine}║`))
console.log(envColor.bold(`╚${borderLine}╝`))

process.exit(0)

配置环境变量读取逻辑

javascript 复制代码
import { NODE_ENV } from './env.js'

let config = {}

if (NODE_ENV === 'development') {
  config = require('./development').default
} else if (NODE_ENV === 'production') {
  config = require('./production').default
}

export default {
  ...config,
}

示例配置文件

开发环境示例文件:

javascript 复制代码
export default {
  mode: 'development',
  baseUrl: 'https://xxx.api-test.com',
  storagePrefix: 'https://xxx.cdn-test.com',
}

生产环境示例文件:

javascript 复制代码
export default {
  mode: 'production',
  baseUrl: 'https://xxx.api.com',
  storagePrefix: 'https://xxx.cdn.com',
}

统一导出配置

/config/index统一导出配置。

javascript 复制代码
import env from './env/index'

export default {
  ...env,
}

配置构建命令

json 复制代码
{
  "name": "mp-template",
  "version": "1.0.0",
  "description": "",
  "type": "module",
  "main": "app.js",
  "scripts": {
    "dev": "cross-env NODE_ENV=development node scripts/setEnv.js",
    "prod": "cross-env NODE_ENV=production node scripts/setEnv.js"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@vant/weapp": "^1.11.7",
    "dayjs": "^1.11.13",
    "miniprogram-computed": "^7.0.0",
    "mobx-miniprogram": "^6.12.3",
    "mobx-miniprogram-bindings": "^5.0.0"
  },
  "devDependencies": {
    "chalk": "^5.4.1",
    "cross-env": "^7.0.3"
  }
}

使用示例

设置为开发环境

bash 复制代码
npm run dev

设置为生产环境

支持 Vue Options API 风格写法

在小程序开发中,生命周期命名和配置方式与主流前端框架存在明显差异,既增加了维护成本,也不利于多项目的逻辑代码统一。

为此,我们封装了一套更贴近 **Vue Options API **的组件写法,在不改变底层运行机制的前提下,对常用字段进行了适配与映射。通过这套封装,你可以使用熟悉的配置项(如 datapropsmethods 和生命周期钩子)来组织组件逻辑。

代码的实现参考了 Vant Weapp 的思想,通过高阶函数的封装,实现在小程序中使用 Vue 风格的组件开发模式。

示例代码: github.com/sparkle027/...

Component 实现原理

通过mapKeys方法,将Vue风格的配置项(如data、props、methods、created等)映射为小程序组件所需的标准字段(如properties、behaviors、attached等)。

Vue 风格字段 小程序字段
props properties
mixins behaviors
created attached
mounted ready
destroyed detached
javascript 复制代码
import { basic } from '@/mixins/basic.js'
const computedBehavior = require('miniprogram-computed').behavior

function mapKeys(source, target, map) {
  Object.keys(map).forEach((key) => {
    if (source[key]) {
      target[map[key]] = source[key]
    }
  })
}

function VueComponent(vantOptions) {
  const options = {}
  const mappedKeys = {
    props: 'properties',
    mixins: 'behaviors',
    beforeCreate: 'created',
    created: 'attached',
    mounted: 'ready',
    destroyed: 'detached',
    classes: 'externalClasses',
  }

  mapKeys(vantOptions, options, mappedKeys)

  // 将未映射的字段直接放入options中
  Object.keys(vantOptions).forEach((key) => {
    if (!mappedKeys[key] && !options[key]) {
      options[key] = vantOptions[key]
    }
  })

  // add default externalClasses
  options.externalClasses = options.externalClasses || []
  options.externalClasses.push('custom-class')

  // add default behaviors
  options.behaviors = vantOptions.behaviors || []
  options.behaviors.push(basic)
  options.behaviors.push(computedBehavior)

  // map field to form-field behavior
  if (vantOptions.field) {
    options.behaviors.push('wx://form-field')
  }
  // add default options
  options.options = {
    multipleSlots: true,
    addGlobalClass: true,
  }

  Component(options)
}

export { VueComponent }

为了统一语法风格,我们将小程序的事件触发封装为this.$emit,与 Vue Options API 保持一致,并通过内置的 basic.js 提供 set、setView 等常用方法。另集成miniprogram-computed,支持computedwatch,增强组件响应能力。

javascript 复制代码
export const basic = Behavior({
  methods: {
    $emit(name, detail, options) {
      this.triggerEvent(name, detail, options)
    },
    set(data) {
      this.setData(data)
      return new Promise((resolve) => wx.nextTick(resolve))
    },
    // high performance setData
    setView(data, callback) {
      const target = {}
      let hasChange = false
      Object.keys(data).forEach((key) => {
        if (data[key] !== this.data[key]) {
          target[key] = data[key]
          hasChange = true
        }
      })
      if (hasChange) {
        return this.setData(target, callback)
      }
      return callback && callback()
    },
  },
})

Component 使用示例

子组件/components/vue-component

javascript 复制代码
import { VueComponent } from '@/common/component.js'

VueComponent({
  classes: ['inner-class'],
  props: {
    initialCount: {
      type: Number,
      default: 0,
    },
  },
  data: {
    count: 0,
    name: 'VueComponent示例',
  },
  computed: {
    doubleCount(data) {
      return data.count * 2
    },
    formattedName(data) {
      return `欢迎使用${data.name}`
    },
  },
  watch: {
    count(newVal) {
      console.log(`[log - watch] 计数${newVal}`)
    },
  },

  created() {
    console.log('[log - created]组件创建完成')
    this.setData({
      count: this.data.initialCount,
    })
  },
  mounted() {
    console.log('[log - mounted] 组件挂载完成')
  },

  methods: {
    increment() {
      this.setData({
        count: this.data.count + 1,
      })
    },
    decrement() {
      this.setData({
        count: this.data.count - 1,
      })
    },
    showInfo() {
      this.$emit('showInfo', { count: this.data.count })
    },
  },
})
javascript 复制代码
<view class="vue-component">
  <view class="header">
    <text class="title">{{ formattedName }}</text>
  </view>
  <view class="inner-class">外部样式类:custom-class</view>

  <view class="counter">
    <text class="count">当前计数: {{ count }}</text>
    <text class="double-count">两倍计数: {{ doubleCount }}</text>
  </view>

  <view class="buttons">
    <button class="btn" bindtap="decrement">减少</button>
    <button class="btn" bindtap="increment">增加</button>
    <button class="btn" bindtap="showInfo">显示信息</button>
  </view>
</view>

子组件页面/pages/vue-options-api-like

javascript 复制代码
Page({
  showInfo(e) {
    console.log('[log - showInfo] 组件信息', e.detail)
  },
})
html 复制代码
<vue-component
  initial-count="5"
  inner-class="inner-class"
  bind:showInfo="showInfo"
/>
less 复制代码
.inner-class {
  background-color: red;
}

Page 实现原理

通过mapKeys方法,将Vue风格的配置项(如data、created、mounted等)映射为小程序组件所需的标准字段(如behaviors、onLoad、onReady等)。Page中没有methods这个对象字面量,需要手动创建methods并要处理一下this指向问题。

需要注意的是:methods中定义的方法拥有更高的优先级。

Vue 风格字段 小程序字段
mixins behaviors
created onLoad
mounted onReady
destroyed onUnload
javascript 复制代码
import { basic } from '@/mixins/basic.js'
const computedBehavior = require('miniprogram-computed').behavior

function mapKeys(source, target, map) {
  Object.keys(map).forEach((key) => {
    if (source[key]) {
      target[map[key]] = source[key]
    }
  })
}

function VuePage(vantOptions) {
  const options = {}
  const mappedKeys = {
    mixins: 'behaviors',
    created: 'onLoad',
    mounted: 'onReady',
    destroyed: 'onUnload',
  }

  mapKeys(vantOptions, options, mappedKeys)

  // 将未映射的字段直接放入options中
  Object.keys(vantOptions).forEach((key) => {
    if (!mappedKeys[key] && !options[key]) {
      options[key] = vantOptions[key]
    }
  })

  // 处理methods中的方法
  if (vantOptions.methods) {
    Object.keys(vantOptions.methods).forEach((methodName) => {
      options[methodName] = function (...args) {
        return vantOptions.methods[methodName].call(this, ...args)
      }
    })
  }

  // add default behaviors
  options.behaviors = vantOptions.behaviors || []
  options.behaviors.push(basic)
  options.behaviors.push(computedBehavior)

  Page(options)
}

export { VuePage }

Page 使用示例

页面/pages/vue-options-api-like

javascript 复制代码
import { VuePage } from '@/common/page.js'

VuePage({
  data: {
    text: 'Hello Page.',
  },
  created() {
    console.log('[log - page - created] 页面创建完成')
  },
  onShow() {
    console.log('[log - page - onShow] 页面显示')
  },
  onHide() {
    console.log('[log - page - onHide] 页面隐藏')
  },
  mounted() {
    console.log('[log - page - mounted] 页面渲染完成')
  },
  destroyed() {
    console.log('[log - page - destroyed] 页面销毁完成')
  },
  showInfo(e) {
    console.log('[log - page - showInfo] 组件信息', e.detail)
  },
  methods: {
    async log() {
      console.log('[log - page - methods - log] ', this.data.text)
      await this.set({ text: 'Updated Text' })
      console.log('[log - page - methods - log Updated] ', this.data.text)
    },
  },
})
html 复制代码
<vue-component
  initial-count="5"
  inner-class="inner-class"
  bind:showInfo="showInfo"
/>

<view bind:tap="log">{{ text }}</view>

自动化安装/构建npm依赖

由于小程序项目可能按功能模块划分多个子包,且每个子包下可能独立存在自己的 npm 依赖,如果每次都手动进入每个目录执行安装/构建命令,不仅效率低,也容易出错。为此,我们编写了以下脚本,辅助一键完成依赖管理和构建流程。

前置工作

安装脚本需要的依赖包

bash 复制代码
npm i -D miniprogram-ci ora

定义脚本命令,在 package.json中定义三个脚本命令:

markdown 复制代码
- `teardown`: 递归卸载项目依赖和小程序构建文件
- `setup`: 递归安装项目npm和构建小程序npm文件
- `ci-pack-npm`: 递归构建小程序npm
json 复制代码
{
  "name": "mp-template",
  "version": "1.0.0",
  "description": "",
  "type": "module",
  "main": "app.js",
  "scripts": {
    "dev": "cross-env NODE_ENV=development node scripts/setEnv.js",
    "prod": "cross-env NODE_ENV=production node scripts/setEnv.js",
    "teardown": "node scripts/teardown.js",
    "setup": "node scripts/setup.js",
    "ci-pack-npm": "node scripts/ci-pack-npm.js"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@vant/weapp": "^1.11.7",
    "dayjs": "^1.11.13",
    "miniprogram-computed": "^7.0.0",
    "mobx-miniprogram": "^6.12.3",
    "mobx-miniprogram-bindings": "^5.0.0"
  },
  "devDependencies": {
    "chalk": "^5.4.1",
    "cross-env": "^7.0.3",
    "miniprogram-ci": "^2.0.10",
    "ora": "^8.2.0"
  }
}

递归安装npm依赖脚本

自动遍历整个项目中的子目录,查找存在package.json的目录,并在其中执行npm install,确保每个模块的依赖都被正确安装。

javascript 复制代码
import fs from 'fs'
import path from 'path'
import { execSync } from 'child_process'

function findPackageJsonDirs(rootDir) {
  const packageJsonDirs = []

  function traverse(dir) {
    const files = fs.readdirSync(dir)

    if (files.includes('package.json')) {
      packageJsonDirs.push(dir)
    }

    files.forEach((file) => {
      const fullPath = path.join(dir, file)
      if (
        fs.lstatSync(fullPath).isDirectory() &&
        !file.startsWith('.') &&
        file !== 'node_modules'
      ) {
        traverse(fullPath)
      }
    })
  }

  traverse(rootDir)
  return packageJsonDirs
}

function installDependencies(dir) {
  try {
    console.log(`\n正在安装依赖: ${dir}`)
    execSync('npm install', {
      cwd: dir,
      stdio: 'inherit',
    })
    console.log(`✅ 依赖安装完成: ${dir}`)
  } catch (error) {
    console.error(`❌ 安装失败: ${dir}`)
    console.error(error.message)
    process.exit(1)
  }
}

async function main() {
  const rootDir = process.cwd()
  const packageJsonDirs = findPackageJsonDirs(rootDir)

  console.log(`找到 ${packageJsonDirs.length} 个需要安装依赖的目录`)

  for (const dir of packageJsonDirs) {
    installDependencies(dir)
  }
}

main().catch((error) => {
  console.error('执行过程中发生错误:', error)
  process.exit(1)
})

执行安装脚本

bash 复制代码
npm run setup

递归执行构建小程序npm脚本

构建小程序需要用到 miniprogram-ci库 ,同时需要在小程序管理后台 -> 管理 -> 开发管理 -> 开发设置 -> 小程序代码上传中生成密钥。

javascript 复制代码
import path from 'path'
import { fileURLToPath } from 'url'
import ora from 'ora'

const __dirname = path.dirname(fileURLToPath(import.meta.url))

const mainSpinner = ora('开始执行构建脚本...').start()

const { default: ci } = await import('miniprogram-ci')
const project = new ci.Project({
  appid: 'example appid',
  type: 'miniProgram',
  projectPath: path.resolve(__dirname, '../'),
  privateKeyPath: path.resolve(__dirname, '../private.example.key'),
  ignores: ['node_modules/**/*'],
})

mainSpinner.succeed('项目配置加载完成')

const buildSpinner = ora('开始构建 npm 包...').start()

console.log('项目配置:', {
  appid: project.appid,
  type: project.type,
  projectPath: project.projectPath,
})

console.log('开始构建 npm 包...')

ci.packNpm(project, {
  ignores: [],
  reporter: console.log,
})
  .then((warning) => {
    if (warning && warning.length > 0) {
      buildSpinner.warn('构建完成,存在警告')
      console.warn('构建警告:', warning)
    } else {
      buildSpinner.succeed('npm 包构建完成')
    }
    process.exit(0)
  })
  .catch((error) => {
    buildSpinner.fail('构建失败')
    console.error('错误详情:', error)
    process.exit(1)
  })

执行安装脚本

bash 复制代码
npm run setup

一键安装/构建npm依赖脚本

合并安装和构建逻辑,执行一次命令即可完成所有子包的 npm 安装与构建流程。我们更改一下/scripts/setup.js:

javascript 复制代码
import fs from 'fs'
import path from 'path'
import { execSync } from 'child_process'

function findPackageJsonDirs(rootDir) {
  const packageJsonDirs = []

  function traverse(dir) {
    const files = fs.readdirSync(dir)

    if (files.includes('package.json')) {
      packageJsonDirs.push(dir)
    }

    files.forEach((file) => {
      const fullPath = path.join(dir, file)
      if (
        fs.lstatSync(fullPath).isDirectory() &&
        !file.startsWith('.') &&
        file !== 'node_modules'
      ) {
        traverse(fullPath)
      }
    })
  }

  traverse(rootDir)
  return packageJsonDirs
}

function installDependencies(dir) {
  try {
    console.log(`\n正在安装依赖: ${dir}`)
    execSync('npm install', {
      cwd: dir,
      stdio: 'inherit',
    })
    console.log(`✅ 依赖安装完成: ${dir}`)
  } catch (error) {
    console.error(`❌ 安装失败: ${dir}`)
    console.error(error.message)
    process.exit(1)
  }
}

async function runCiPackNpm() {
  try {
    console.log('\n开始构建 npm 包...')
    execSync('node --no-warnings scripts/ci-pack-npm.js', {
      stdio: 'inherit',
    })
  } catch (error) {
    console.error(error.message)
    process.exit(1)
  }
}

async function main() {
  const rootDir = process.cwd()
  const packageJsonDirs = findPackageJsonDirs(rootDir)

  console.log(`找到 ${packageJsonDirs.length} 个需要安装依赖的目录`)

  for (const dir of packageJsonDirs) {
    installDependencies(dir)
  }

  await runCiPackNpm()
}

main().catch((error) => {
  console.error('执行过程中发生错误:', error)
  process.exit(1)
})

执行安装脚本

bash 复制代码
npm run setup

递归删除npm/构建npm依赖脚本

遍历所有存在 node_modules 或构建产物的目录,统一执行删除操作,用于重装依赖或清理构建缓存。

javascript 复制代码
import fs from 'fs'
import path from 'path'
import ora from 'ora'

function deleteDirectory(dir) {
  if (fs.existsSync(dir)) {
    const spinner = ora(`正在删除目录: ${dir}`).start()
    fs.readdirSync(dir).forEach((file) => {
      const curPath = path.join(dir, file)
      if (fs.lstatSync(curPath).isDirectory()) {
        deleteDirectory(curPath)
      } else {
        fs.unlinkSync(curPath)
      }
    })
    fs.rmdirSync(dir)
    spinner.succeed(`已删除目录: ${dir}`)
  }
}

function findAndDeleteDirectories(rootDir, targetDirs) {
  const files = fs.readdirSync(rootDir)

  files.forEach((file) => {
    const fullPath = path.join(rootDir, file)
    const stat = fs.lstatSync(fullPath)

    if (stat.isDirectory()) {
      if (targetDirs.includes(file)) {
        deleteDirectory(fullPath)
      } else {
        findAndDeleteDirectories(fullPath, targetDirs)
      }
    }
  })
}

const mainSpinner = ora('开始清理项目...').start()
const targetDirs = ['node_modules', 'miniprogram_npm']
findAndDeleteDirectories(process.cwd(), targetDirs)
mainSpinner.succeed('项目清理完成')

执行安装脚本

bash 复制代码
npm run teardown

总结回顾

本文围绕"打造高效小程序基础模板"这一主题,从项目初始化到核心能力建设,逐步拆解了多个关键实践点,包括:

  • 使用 npm 管理依赖,提升模块化开发能力;
  • 配置路径别名,优化引用路径,提升代码可读性;
  • 引入 MobX 实现响应式状态管理;
  • 支持 computed/watch,让数据驱动视图更加清晰;
  • 启用 less 预处理器,规范样式体系;
  • 通过 behaviors 实现逻辑复用,提高组件灵活性;
  • 设计合理的分包架构,解决主包体积限制;
  • 搭建可自动切换的多环境配置系统;
  • 封装 Vue Options API 风格的组件写法,降低学习和维护成本。

构建一套高质量的模板,不仅能显著提升开发效率,也能为团队协作打下良好基础。希望这份文档能作为你构建或优化小程序项目的参考,助力你写出更稳定、可维护、易扩展的代码。

如需查看完整示例代码,可访问github.com/sparkle027/...

相关推荐
kidding72312 分钟前
微信小程序怎么分包步骤(包括怎么主包跳转到分包)
前端·微信小程序·前端开发·分包·wx.navigateto·subpackages
微学AI27 分钟前
详细介绍:MCP(大模型上下文协议)的架构与组件,以及MCP的开发实践
前端·人工智能·深度学习·架构·llm·mcp
liangshanbo12151 小时前
CSS 包含块
前端·css
Mitchell_C1 小时前
语义化 HTML (Semantic HTML)
前端·html
倒霉男孩1 小时前
CSS文本属性
前端·css
晚风3081 小时前
前端
前端
JiangJiang1 小时前
🚀 Vue 人如何玩转 React 自定义 Hook?从 Mixins 到 Hook 的华丽转身
前端·react.js·面试
鱼樱前端1 小时前
让人头痛的原型和原型链知识
前端·javascript
用户19727304821961 小时前
传说中的开发增效神器-Trae,让我在开发智能旅拍小程序节省40%时间
前端
lianghj1 小时前
前端高手必备:深度解析高频场景解决方案与性能优化实战
前端·javascript·面试