微信小程序原生开发工程化解决方案

4,623 阅读12分钟

前言

经过一番调研(调研文章在这里:小程序框架调研),最终决定还是用原生开发小程序。但原生开发工程化能力比较差,直接使用官方模板写写demo还可以,要进行持续迭代的项目开发还需要进行武装一下,typescript、国际化、scss预处理、环境变量设置等我们全都要,经过一番折腾,一个加强版的原生微信小程序模板诞生了,代码可以到 github 中查看(期待你的Star⭐️ )。如果想直接使用该小程序模板,可以使用 useful-cli 命令创建。

使用 npx useful-cli create [app-name] 命令可以创建一个小程序项目

项目搭建过程中使用了Gulp构建工具,一些 glup 细节文中没有去细究,所以没有接触过 glup 的小伙伴最好还是先去了解一下再回来看。如果已经熟练使用gulp,那么大致看向 项目中的gulpfile 也就知道项目做了哪些配置了。

小程序原生模板搭建

小程序能力

最终的小程序原生开发模板,集成了以下功能:

  • 支持typescript并且可以通过alias来import一个文件
  • 支持国际化
  • 支持scss 预编译
  • 支持设置环境变量
  • 支持热更新(执行 yarn dev 在文件被修改时会自动编译)
  • 使用 Promise 封装了 wx.request,并创建了一个 httpManager 单例
  • 使用 eslint + prettier + husky 规范代码格式
  • 引入weUI库
  • 基于globalData的状态管理方案

以上能力都是在官方模板基础上一步步实践得来的,主要目的是能让正在进行小程序原生开发的你提供一点点参考,当然,如果发现有更好的解决方案可以在下方留言讨论。下面来详细讲一讲每一项能力的引入过程。

创建项目

  • 申请一个小程序账号
  • 使用官方提供的 微信开发者工具 可以快速的创建一个小程序项目

image.png

项目语言选择Typescript,创建完项目会发现官方小程序模板目录结构很简单,一个小程序页面由四个文件组成,分别是:

  1. .json 后缀的 JSON 配置文件
  2. .wxml 后缀的 WXML 模板文件
  3. .wxss 后缀的 WXSS 样式文件
  4. .js 后缀的 JS 脚本逻辑文件(如果是ts模板,该js文件则由ts文件编译后得到)

image.png

配置Typescript

创建完项目可以发现所谓的支持Typescript,就是官方提供了小程序的接口文件typings,并且每个page下多一个ts文件,然后通过tsc命令将ts文件编译成js文件。Em...这也太粗糙了吧。但问题不大,只要有typings文件,剩下的我们都可以自己改造。

  1. 修改 ts 输出路径为 dist

    ts 文件和 js 文件放在一起会显得文件结构十分乱,所以我们把 tsconfig 中的 outDir 路径改成 dist,这样编译完的 js 文件就被放到了 dist 目录下了

  2. 使用Glup构建工具把 json、wxml、wxss 也拷贝到 dist 目录下

    在配置 国际化 和 scss预编译 的时候这几个文件都会被移到 dist,具体逻辑可以去看最终的模板代码,这里只是讲一下最开始去支持 typescript 的实现步骤

  3. project.config.jsonminiprogramRoot设置为dist,微信开发者工具就可以正确的找到 dist 去加载他需要的文件了

  4. 配置 alias

    由于使用 typescript 编译后无法正常解析 alias,所以引用 ttypescript + typescript-transform-paths 来解决这个问题,如引用 utils 中的方法可直接 import { ... } from '@utils/util'或 import { ... } from 'utils/util',配置如下

    a. 安装 ttypescript + typescript-transform-paths 依赖

    // 安装依赖
    yarn add -D ttypescript
    yarn add -D typescript-transform-paths
    

    b. 配置 tsconfig

    // tsconfig
    {
    ...
        "baseUrl": "miniprogram" /* Base directory to resolve non-absolute module names. */,
        "paths": {
          "@pages/*": ["page/*"],
          "@utils/*": ["utils/*"],
          "@npm/*": ["miniprogram_npm/*"]
          ...
        } /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */,
        "plugins": [{ "transform": "typescript-transform-paths" }],
    ...
    }
    

    c. 使用 ttsc 进行编译

    // package.json
    ...
       "scripts" {
           "tsc": "ttsc",
           ...
        }
    ...
    

    d. 使用 alias

    // 引用utils中的方法
    import { ... } from '@utils/util'
    // 或者
    import { ... } from 'utils/util'
    
    // 引用npm模块
    import { ... } from '@npm/***'
    // 或者
    import { ... } from 'miniprogram_npm/***'
    

Tip:小程序仅支持运行 JS 文件,因此所有的 TS 文件都默认不会被打包上传

配置国际化

国际化配置官方有参考方案

WXML 中的用法

  1. 在 WXML 文件对应的 JavaScript 文件里引入国际化运行时。

注意:这里建议 Page 以及 Component 都采用 Component 构造器进行定义,这样可以使用 I18n 这个 Behavior。如果需要在 Page 构造上使用 I18n 则需要引入 I18nPage 代替 Page 构造器。

<!-- pages/index/index.ts -->
import { I18nPage } from '@miniprogram-i18n/core'
I18nPage({
  ...
})

或者

import { I18n } from '@miniprogram-i18n/core'
Component({
  behaviors: [I18n]
})
  1. 在 WXML 中使用 t 函数(或其他你指定的函数名)来获取文本。
<!-- pages/index/index.wxml -->
<view>{{ t('helloWorld') }}</view>

JavaScript 中的用法

miniprogram/utils/i18n 中封装了 message,可以直接使用

import { message, toggleLanguage } from '@utils/i18n'
console.log(message('helloWorld'))
toggleLanguage() // 切换语言
console.log(message('helloWorld'))

Note

  • 使用@miniprogram-i18n/core 前需要构建 npm
  • 使用 gulp 解析i18n中的json文件page中的wxml文件后输出到 dist

配置scss

scss预编译配置十分简单,使用gulp-sass就可以了,唯一需要注意的是@import的使用,如果使用gulp-sass解析@import,那么sass就会把import也打包到当前文件中,如果import的文件很大,超过单包代码限制2M就会有问题,比较经典的做法在编译前可以先把@import语句注释掉,编译完成后再将@import打开(wxss是支持样式导入的,可以识别@import)。

但是注释掉@import会导致无法在sass文件中去@import一个变量申明(如$black)或@mixin文件,因为wxss不支持这种写法。考虑到小程序的样式文件单包应该不会超过2M,所以这里没有采用注释掉@import的方法,而是直接使用gulp-sass去处理@import。

具体实现步骤看下方的gulp 配置(整体思路参考这篇文章)。配置完支持以下几个功能:

  • 支持.scss样式文件
  • 支持@import和配置alias(看下方的styleAliases配置)
  • 支持将px转为rpx
// gulpfile.js
const scss = require('gulp-sass'); // scss编译插件
const postcss = require('gulp-postcss'); // 强大的css处理插件
const pxtorpx = require('postcss-px2rpx'); // px转为rpx
const styleAliases = require('gulp-style-aliases'); // scss设置alias
const rename = require('gulp-rename'); // 更改文件名
const replace = require('gulp-replace'); // 替换内容
const changed = require('gulp-changed'); // 检测改动
const autoprefixer = require('autoprefixer'); //  自动添加前缀

/**
 * 编译sass文件为wxss文件
 * refrence: https://juejin.cn/post/6844903778496282632
 */
function compileStyle() {
    /**
    * 步骤如下:
    * 指定文件处理目录
    * gulp-replace通过正则匹配@import语句将其注释
    * 启用gulp-sass编译scss文件,
    * 通过postcss对低版本ios和安卓进行兼容样式处理
    * gulp-rename更改文件后缀为.wxss
    * gulp-replace通过正则匹配@import语句打开注释
    * 最后输入到dist目录
    */
    return src(['miniprogram/**/*.scss'])
      .pipe(replace(/\@(import\s[^@;]*)+(;import|\bimport|;|\b)?/g, ($1) => {
        // 与小程序自带的import不同,sass会把@improt的内容打包到当前文件,所以打出来的包会大一点
        // 而小程序限制单包大小不能超过2M,如果由于@import导致包过大,需要注释掉@import(使用自带的import),等sass编译完后在重新打开
        // 考虑到小程序的样式文件单包应该不会超过2M,而且注释掉@import会导致sass中的变量申明、@mixin功能就不能用了,这里还是选择使用sass的@import功能
        return $1 // `\/*T${$1}T*\/`
      }))
      .pipe(styleAliases({
        "@miniprogram": 'miniprogram',
        "@page": 'miniprogram/page',
        "@styles": 'miniprogram/styles'
      }))
      .pipe(scss())
      .pipe(postcss([autoprefixer(['iOS >= 8', 'Android >= 4.1']), pxtorpx()]))
      .pipe(
        rename(function(path) {
          path.extname = '.wxss'
      }))
      .pipe(changed('dist'))
      .pipe(replace(/.scss/g, '.wxss'))
      .pipe(replace(/\/\*T(@import\s[^@;]*;)?(T\*\/)/g, '$1'))
      .pipe(dest('dist'))
  }

设置环境变量

  1. miniprogram/config中定义不同环境的 ts 文件

  2. 使用cross-env注入环境变量

// package.json
{
		...
    "scripts": {
        ...
        "dev": "cross-env NODE_ENV=development gulp watch",
        "build:test": "cross-env NODE_ENV=test gulp",
        "build:prod": "cross-env NODE_ENV=production gulp",
        ...
    },
		...
}
  1. gulpfile 中获取环境变量process.env.NODE_ENV

  2. 根据process.env.NODE_ENV生成 env.ts(从 miniprogram/config 中拷贝)

  3. 使用如下:

import ENV from 'env';
console.log('当前环境变量', ENV)

支持热更新

开发过程中(使用yarn dev)页面会在代码发生改变时重新加载。

实现思路:由于我们已经将project.config.jsonminiprogramRoot设置为dist了,只要dist中的文件发生改变,微信开发者工具就会重建加载代码。所以我们要做的是使用gulp watch功能去监听miniprogram中的文件是否发生改变,只要发生了改变就去更新dist,达到热更新的效果。具体代码实现可以看模板中的gulpfile配置

yarn dev // 执行yarn dev命令即可开启glup watch功能

封装wx.request

使用 Promise 封装了 wx.request,并创建了一个 httpManager 单例,具体代码看模板中的httpManager

使用 eslint + prettier + husky 规范代码格式

模板中集成了eslint、prettire和husky,配合.vscode中的配置可以很好的规范项目代码格式,实现过程部分参考了这篇文章

引入weUI库

选型

微信本身就提供了丰富的组件,当然还可以使用一些第三方UI库

  • WeUI (官方样式库)
    • 微信官方团队设计的小程序UI库
    • 可以使用useExtendedLib引入,不占用小程序包体积
  • iView Weapp
  • vantUI
  • Omim / Omiu - 腾讯跨框架omi提供的UI组件

weUI支持使用useExtendedLib引入不占用小程序包体积,无脑选就对了

配置

  1. 在 app.json 中配置 useExtendedLib
// app.json
{
  ...
  "useExtendedLib": {
    "weui": true
  }
  ...
}
  1. 在页面对应的 json 文件的 usingComponents 配置字段添加要使用的组件
// ***.json
{
  "usingComponents": {
    "mp-dialog": "weui-miniprogram/dialog/dialog"
  }
}
  1. 在对应页面的 wxml 中直接使用该组件
<mp-dialog title="test" show="{{true}}" bindbuttontap="tapDialogButton" buttons="{{[{text: '取消'}, {text: '确认'}]}}">
    <view>test content</view>
</mp-dialog>

基于globalData的状态管理方案

状态管理方案选型

为什么需要状态管理?

  1. 当一个组件需要多次派发事件时。例如购物车数量加减。
  2. 跨组件共享数据、跨页面共享数据。例如订单状态更新。
  3. 需要持久化的数据。例如登录后用户的信息。
  4. 当您需要开发中大型应用,适合复杂的多模块多页面的数据交互,考虑如何更好地在组件外部管理状态时。

微信小程序官方没有提供状态管理方案,这也是小程序原生开发的一大痛点。

  • 自定义globalData:相当于设置了一个全局变量和更新它的function,比较简单,但业务量起来以后不好管理。
  • Behavior管理全局状态:使用小程序自定义组件中的behaviors 重构组件能力,没有细看,感觉有点麻烦。
  • westore:腾讯出品的微信小程序解决方案。
  • omix:同样腾讯出品的腾讯原生小程序框架,westore 的进化版,支持typescript(omix-ts),但ts支持度不好,项目拉下来ts报了很多错 - -
  • 使用redux:redux作为一个已经被熟知且广泛应用到react项目中的状态管理方案,应付复杂的业务需求没有什么大问题,对前端开发也十分友好。网上现成的方案有:
    • minapp-redux / mp-redux: 直接复制minapp-redux / mp-redux 中的src/index到项目中即可使用redux的connect、connectComponent、use方法,但是不支持ts,需要自己去定义这几个方法的interface。
    • wxa/redux : 微众银行出品,开源了一个小程序开发框架wxa,文档写的不错,但是要用wxa的整套解决方案。

Tip: 原生框架在setdata上没有单独优化,还需要手动写优化代码来控制setdata,使用状态管理库(如 minapp-redux、omix等)会在调用setdata先进行diff计算,提高性能。

本来一开始是打算使用 redux 来进行状态管理的,但引入后发现在小程序中使用 redux 对 ts 的支持不大好,要用的话需要自己写 interface 否则将失去类型检查,而且小程序的持久化和共享的数据不会很多,最终还是决定使用 globalData 开发

GlobalData配置

  1. 使用getApp() 或者 在App()函数内使用this 获取到小程序全局唯一的 App 实例。

注意:

  • 不要在定义于 App() 内的函数中,或调用 App()函数 前调用 getApp() ,使用 this 就可以拿到 app 实例。
  • 通过 getApp() 获取实例之后,不要使用该实例私自调用生命周期函数。
  1. 直接获取 App 实例的 globalData 属性就可以设置全局属性了, 可以在 App()函数 中初始化 globalData
// app.ts
App<IMiniAppOption>({
    globalData: {
      test: '123'
    },
    onLaunch() {
      this.globalData.test = '456'
    }
})

// pages/**/**.ts
const app = getApp()
Page({
    data: {
    },
    onLoad() {
	app.globalData.test = '789'
    },
})

为了方便管理整个工程的全局属性,我们定义了一个单例 - StoreManager, 约定所有的 globalData 操作都通过 StoreManager 来执行。

// storeManager.ts
import { IMiniApp } from 'app'

export interface IGlobalData {
    test: string
}

export function initGlobalData() {
    return {
        test: '',
    }
}

export default class StoreManager {
    // StoreManager instance
    private static _storeManager: StoreManager

    private _app: IMiniApp

    constructor(app?: IMiniApp) {
        this._app = app || getApp()
        if (!this._app) {
            throw 'StoreManager Error: 获取app实例失败'
        }
    }

    public static getInstance(app?: IMiniApp): StoreManager {
        if (!this._storeManager) {
            this._storeManager = new StoreManager(app)
        }

        return this._storeManager
    }

    public setTest(test) {
        this._app.globalData.test = test
    }

    public getLogs() {
        return this._app.globalData.test
    }
}

// app.ts
import StoreManager, { IGlobalData, initGlobalData } from 'store/storeManager'
interface IMiniAppOption {
    globalData: IGlobalData
    userInfoReadyCallback?: WechatMiniprogram.GetUserInfoSuccessCallback
}
export type IMiniApp = WechatMiniprogram.App.Instance<IMiniAppOption>

App<IMiniAppOption>({
    globalData: initGlobalData(),
    onLaunch() {
        StoreManager.getInstance(this).initLogs()
    }
})

小程序模板使用笔记

路由配置

在app.json文件中的pages字段可以直接配置项目的路由,使用 wx.navigateTo可以进行路由跳转,跳转 url 可以是相对路径,也可以是绝对路径("/pages//")

wx.navigateTo({
    url: '../logs/logs',
})
或者
wx.navigateTo({
    url: '/pages/logs/logs',
})

引入第三方库

引入第三方依赖有两种方式:

  1. 直接将依赖输出文件拷贝出来

  2. 使用 npm 构建工具

直接拷贝依赖的输出文件

以 ali-oss 文件为例,可以将github.com/ali-sdk/ali… git 仓库中的 dist/aliyun-oss-sdk.min.js 拷贝到项目 lib 文件夹下

构建NPM

小程序支持使用npm,但是需要使用微信开发者工具的构建npm功能

当我们需要引入npm包时,需要执行以下操作

  1. 在miniprogram目录下添加对应的依赖
cd miniprogram
yarn add ***
  1. 添加完依赖后小程序miniprogram中会有node_modules文件夹,通过微信开发者工具把node_modules构建成miniprogram_npm

微信开发者工具菜单 -> 工具 -> 构建npm

  1. 构建完后miniprogram_npm目录下就生成了相应的npm包,可在项目中直接引用
import { ... } from 'miniprogram_npm/***'

小程序支持使用 npm,但是需要使用微信开发者工具的构建npm功能

当我们需要引入 npm 包时,需要执行以下操作

  1. 修改 project.config.json

    a. 配置 project.config.jsonsetting.packNpmManually 为 true,开启自定义配置 node_modules 和 miniprogram_npm 路径。

    b. 配置 project.config.jsonsetting.packNpmRelationList 项,指定 packageJsonPath 和 miniprogramNpmDistDir 的路径。

"packNpmManually": true,
"packNpmRelationList": [{
  "packageJsonPath": "miniprogram/package.json",
  "miniprogramNpmDistDir": "miniprogram"
}],
  1. 在 miniprogram 目录下添加对应的依赖
cd miniprogram
yarn add ***
  1. 添加完依赖后小程序 miniprogram 中会有 node_modules 文件夹,通过微信开发者工具把node_modules构建成miniprogram_npm。微信开发者工具菜单 -> 工具 -> 构建 npm

  2. 构建完后miniprogram_npm目录下就生成了相应的 npm 包,可在项目中直接引用

import { ... } from '@npm/***'
// 或者
import { ... } from 'miniprogram_npm/***'

小程序分包大小有限制

  • 使用分包加载时,整个小程序所有分包大小不超过 20M
  • 单个分包/主包大小不能超过 2M

所以为了避免小程序体积过大,建议图片都放到 cdn,一些第三方依赖能后端处理就后端处理(如 ali-oss 获取签名 url 就可以让后端提供 api,这样小程序就不需要引入 ali-oss 依赖了)。

微信支付

微信小程序官方提供了对应的支付方案

开发调试

可以直接在官方提供的 微信开发者工具 中进行开发、预览、调试。

参考