使用 Taro 开发微信小程序的实践 + 踩坑合集

19,400 阅读10分钟

我和这篇文章

我是一名前端爱好者,现在是大三学生。大二开始接触小程序开发,目前自己唯一还在弄的项目是校内面向学生的一款课程评测小程序 uCourse。

使用过微信小程序原生语言开发过小程序,也用过一系列后来紧随其上的第三方框架,如 WePY (1.x), mpvue (1.x),以及 Taro (0.x 至今)。

出现这么多第三方框架的原因,在我看来,微信小程序原生语言的不合理性和缺陷「功不可没」,同时后期涌现的一批「xx 小程序」加强了多端编译需求的重要性。而我有幸体验到了这一批框架从早期到现在的发展过程,也有了一些自己的对比感受,最终选择了 Taro。

这篇文章我想简单总结一下我在使用 Taro 开发小程序时的一些经验和踩坑,以及我在与微信小程序纠缠时的各方面心路历程……(主要是微信小程序,我还暂时没有开发多端。)

另外除了 Taro 本身,也会带一些小程序的开发实践。总之乱七八糟说啦,希望能对各位有所帮助。

原生小程序开发 vs 第三方框架

原生小程序开发的痛点

原生小程序开发有哪些痛点?Taro 这篇 《为什么我们要用 Taro 来写小程序 - Taro 诞生记》 里,点明得相当到位。

总结来说:

  • 单页面的文件结构繁琐(四个之多)
  • 语法像 React、像 Vue、又像风(四不像)
  • 组建/方法命名规范不统一(中划线分割/单词连写/驼峰写法 混杂)
  • 不完整的前端开发体验(webpack、ES 6+ 语法、CSS 预处理器 等的缺失)

当然,微信小程序官方团队也在不断完善和发展。不过迭代速度、以及开发者社区内反馈问题的积极性实在让人不敢恭维……

第三方框架一览

目前(2019 年 3 月)——

  • WePY (类 Vue.js)
  • mpvue (Vue.js + H5/百度/字节/支付宝)
  • Min (类 Vue.js)
  • Taro (React.js + H5/百度/字节/支付宝/RN)
  • nanachi(React.js + H5/百度/字节/支付宝)
  • uni-app (Vue.js + H5/百度/字节/支付宝/Native)

至于哪一种更好,由于各个框架都在迭代,我现在无法很全面地比较各个框架。建议各位开发者可以看看社区活跃、再看对多端编译的需求、再看技术栈符不符合团队技能。

去年七八月份的时候,后两款框架还没推出,我对比了前四款:由于 WePY 的坑太多,Min 社区并不活跃,mpvue 开源后几乎再无动静,我个人又比较喜欢 React,Taro 的社区活跃度和版本迭代速度可喜,所以毫无意外选择了 Taro。但现在前两款框架又启动了 2.x 的开发计划,并且又有另两款框架冒出……所以各位可以再折腾对比下看看。_(:з」∠)_

Taro 起步

所以闲话少叙,我们说回 Taro……

Taro 的开发体验可以说和 React 别无二致。如果你有过 React 的开发经验,可以毫无困难地顺滑上手;如果没有,直接看 Taro 的 官方文档 来入门也是没有问题的。

从安装到建立一个新项目使用——

$ npm install -g @tarojs/cli
$ taro init myApp
$ cd myApp
$ npm install
# 开发
$ npm run dev:weapp
# 编译
$ npm run build:weapp

这里的开发和编译指令中的 weapp 换成 h5 / swan / alipay / tt / rn 后,即可在对应的其他端编译运行。多端的代码逻辑可以不同,详情请看 跨平台开发

项目结构

官方有一篇基于 Redux 的项目最佳实践文章:《Taro深度开发实践》

官方推荐的项目结构——

├── config                 配置目录
|   ├── dev.js             开发时配置
|   ├── index.js           默认配置
|   └── prod.js            打包时配置
├── src                    源码目录
|   ├── components         公共组件目录
|   ├── pages              页面文件目录
|   |   ├── index          index 页面目录
|   |   |   ├── banner     页面 index 私有组件
|   |   |   ├── index.js   index 页面逻辑
|   |   |   └── index.css  index 页面样式
|   ├── utils              公共方法库
|   ├── app.css            项目总通用样式
|   └── app.js             项目入口文件
└── package.json

我在项目中并没有用到 Redux / MobX,项目「发展壮大」后的结构也比较简单明了——

├── dist                                编译结果目录
├── config                              配置目录
|   ├── dev.js                          开发时配置
|   ├── index.js                        默认配置
|   └── prod.js                         打包时配置
├── src                                 源码目录
|   ├── assets                          公共资源目录(内含图标等资源)
|   ├── components                      公共组件目录
|   |   └── Btn                         公共组件 Btn 目录
|   |       ├── Btn.js                  公共组件 Btn 逻辑
|   |       └── Btn.scss                公共组件 Btn 样式
|   ├── pages                           页面文件目录
|   |   └── index                       index 页面目录
|   |       ├── components              index 页面的独有组件目录
|   |       |   └── Banner              index 页面的 Banner 组件目录
|   |       |       ├── Banner.js       index 页面的 Banner 组件逻辑
|   |       |       └── Banner.scss     index 页面的 Banner 组件样式
|   |       ├── index.js                index 页面逻辑
|   |       └── index.scss              index 页面样式
|   ├── subpackages                     分包目录(项目过大时建议分包)
|   |   └── profile                     一个叫 profile 的分包目录
|   |       └── pages                   该分包的页面文件目录
|   |           └── index               该分包的页面 index 目录(其下结构与主包的页面文件一致)
|   ├── utils                           项目辅助类工具目录
|   |   └── api.js                      比如辅助类 api 等
|   ├── app.css                         项目总通用样式
|   └── app.js                          项目入口文件
└── package.json

什……这也叫「简单明了」吗? (゚д゚≡゚д゚)

这是我个人比较喜欢的组织方式。我的项目已经不算小了,总计近 30 个页面,使用上面这种方式维护,确实感觉还挺省心舒服的。当然你也可以按照你的喜好组织文件~

编译配置文件

编译配置存放于项目根目录下 config 目录中,包含三个文件

  • index.js 是通用配置
  • dev.js 是项目预览时的配置
  • prod.js 是项目打包时的配置

下面说一些使用案例和某些坑的解决方案——

路径别名

在项目中不断 import 相对路径的后果就是,不能方便直观地明白目录结构;如果要迁移改动一个目录,IDE 又没有很准确的重构功能的话,需要手动更改每一个 import 的路径,非常难受。

所以我们想把:

import A from '../../componnets/A'

变成

import A from '@/componnets/A'

这种引用。

方式如下:

/* config/index.js */
const path = require('path')
alias: {
  '@/components': path.resolve(__dirname, '..', 'src/components'),
  '@/utils': path.resolve(__dirname, '..', 'src/utils'),
},

详见:nervjs.github.io/taro/docs/c…

在代码中判断环境

/* config/dev.js */
env: {
  NODE_ENV: '"development"', // JSON.stringify('development')
},
/* config/prod.js */
env: {
  NODE_ENV: '"production"', // JSON.stringify('development')
},

代码中可以通过 process.env.NODE_ENV === 'development' 来判断环境。

区分开发和线上环境的 API

/* config/dev.js */
defineConstants: {
  BASE_URL: '"https://dev.com/api"',
},
/* config/prod.js */
defineConstants: {
  BASE_URL: '"https://prod.com/api"',
},

如此一来,可以直接在代码中引用 BASE_URL 来基于环境获取不同的 API Gateway。

解决打包后样式丢失等问题

如果你在开发中遇到了开发环境时样式没有问题,但是编译打包后出现部分样式丢失,可能是因为 csso 的 restructure 特性。可以在 plugins.csso 中将其关闭:

/* config/prod.js */
plugins: {
  csso: {
    enable: true,
    config: {
      restructure: false,
    },
  },
},

解决编译压缩过的 js 文件出错的问题

如果你遇到了编译时,压缩过的 js 文件过编译器报错,可以将其排除编译:

/* config/index.js */
weapp: {
  compile: {
    exclude: [
      'src/utils/qqmap-wx-jssdk.js',
      'src/components/third-party/wemark/remarkable.js',
    ],
  },
},

解决编译后资源文件找不到的问题

如果你遇到了编译后,资源文件(如图片)没有被编译到 dist 目录中导致找不到,可以令其直接被复制过来:

/* config/index.js */
copy: {
  patterns: [
    {
      from: 'src/assets/',
      to: 'dist/assets/',
    },
  ],
},

使用微信小程序原生第三方组件和插件

官方文档:nervjs.github.io/taro/docs/m…

需要注意的是,如果这么做了,项目就不能再多端编译了。

使用方式看官方文档描述即可,十分简单。但我实际在使用的过程中,还是踩了坑的。比如我尝试集成 wemark 来做 markdown 的渲染。发现了两个问题:

  1. Taro 会漏编译仅在 wxss 中引用的 wxss 文件。解决方式是需要 copy 配置把所有文件在编译时全部拷贝过去。
  2. 在编译压缩过的 js 文件时,会再次经过一次编译导致出错,且无视 copy 配置。解决方式是需要 exclude 配置把压缩的 js 文件排除。(如上面提到的那样。)

所以以 wemark 为例,项目集成原生组件,需要另加配置:

/* config/index.js */
copy: {
  patterns: [
    {
      from: 'src/components/wemark',
      to: 'dist/components/wemark',
    },
  ],
},
weapp: {
  compile: {
    exclude: [
      'src/components/wemark/remarkable.js',
    ],
  },
},

然后可以引用了——

import Taro, { Component } from '@tarojs/taro'
import { View } from '@tarojs/components'

export default class Comp extends Component {
  config = {
    usingComponents: {
      wemark: '../components/wemark/wemark'
    }
  }

  state = {
    md: '# heading'
  }

  render() {
    return (
      <View>
        <wemark md={this.state.md} link highlight type="wemark" />
      </View>
    )
  }
}

简而言之,如果你在集成原生组件中遇到了类似问题,可以试试直接 copy 整个组件目录,并且排除掉某些 js 文件防止过编译。

使用图标字体组件

我们希望在项目中拥有自己的图标字体组件,使用方法如下:

<UIcon icon="home" />

为什么是 UIcon,而不是 Icon?因为命名不能与官方自带的组件 Icon 冲突呀……(|||゚д゚) 你也可以管他叫 OhMyIcon 之类的。

这里先说实践,再说说坑……

实践就是,如果大家没有专业的设计师或者公司内部的图标库的话,可以使用 Iconfont 的图表库,优点是图标多而优、CDN 开箱即用。你可以新建一个项目,选择适合你项目的图标后,直接获取到 @font-face 的引用代码:

Unicode 和 Font class 的引用效果几乎一样,后者的优势是 class name 的语义化,而由于我们需要再进行一层包装,将 class name 变得可定制,所以推荐大家选择 Unicode。

而 Symbol 的优势就在于支持多色图标,那为什么不用它呢……踩坑啦踩坑啦,微信小程序不兼容 svg 图标 QwQ。(在官方社区搜了很多帖子,官方只会说「好的,我们看看」、「请贴一下代码片段」然后就没动静了……类似的情况还有很多,提了几年的 bug,始终不修,留着当传家宝……(/‵Д′)/~ ╧╧ )

那么我们在组件的样式文件里加上上面这段 @font-face 的代码后,再写类似下面的这段:

/* UIcon.scss */
.u-icon {
  display: inline-block;
  &:before {
    font-family: 'iconfont' !important;
    font-style: normal;
    font-weight: normal;
    speak: none;
    display: inline-block;
    text-decoration: inherit;
    width: 1em;
    text-align: center;
  }
}

然后针对每个图标,给出它的 unicode 定义:

/* UIcon.scss */
.u-icon-add::before {
  content: '\e6e0';
}
.u-icon-addition::before {
  content: '\e6e1';
}
/* ... */

UIcon.js 如此包装:

/* UIcon.js */
import Taro, { Component } from '@tarojs/taro'
import { View } from '@tarojs/components'
import './UIcon.scss'

export default class UIcon extends Component {
  static externalClasses = ['uclass']

  static defaultProps = {
    icon: '',
  }

  render() {
    return <View className={`uclass u-icon u-icon-${this.props.icon}`} />
  }
}

这里注意到我加了一个 externalClasses 的配置,以及一个 uclassclassName。原因是我想在组件外部定义内部的样式,如此定义后,可以这么调用:

<UIcon icon="home" uclass="external-class" />

详情可以看文档 组件的外部样式和全局样式。(这篇文档就是我当时踩完这个坑帮忙给补上的 QwQ……)

包装一个汇报 formId 的组件

如果你有主动发送小程序模板消息卡片的需求,可能需要这样的组件。

小程序目前的策略是你只能在用户触发一个 button 点击事件后,汇报给你一个一次性的 7 天过期 formId,你用它来发送一次模板消息。所以涌现一批机智的小程序开发者,把 button 包裹在整个页面上,用户每一次点击都汇报一个 formId,存起来之后,七天之内反正不愁啦,慢慢发。而官方貌似也一直睁一只眼闭一只眼…… (●` 艸 ´)

Taro 中实现这样的包裹器也很简单:

/* FormIdReporter.js */
import Taro, { Component } from '@tarojs/taro'
import { Button, Form } from '@tarojs/components'
import './FormIdReporter.scss'

export default class FormIdReporter extends Component {
  handleSubmit = e => {
    console.log(e.detail.formId) // 这里处理 formId
  }

  render() {
    return (
      <Form reportSubmit onSubmit={this.handleSubmit}>
        <Button plain formType="submit" hoverClass="none">
          {this.props.children}
        </Button>
      </Form>
    )
  }
}

在调用时,把整个页面包裹上即可:

<FormIdReporter>
  {/* 一些其他组件 */}
</FormIdReporter>

需要注意的是,这里的 Button 需要使用下面的样式代码清除掉默认样式,达到「隐藏」的效果:

/* FormIdReporter.scss */
button {
  width: 100%;
  border-radius: 0;
  padding: 0;
  margin: 0;
  &:after {
    border: 0;
  }
}

利用 Decorator 实现快速分享/登录验证

由于这部分内容也是我从他处学到的,并且有既成的教程,我就不再添油加醋啦。参考:


下面说的这些,更多的是关于小程序自身的一些实践案例了。当然,也是以 Taro 为背景的。

i18n 国际化

由于项目需要实现小程序文本国际化,我找了很多案例,最终参考了这个比较简洁的方案的思路:weapp-i18n。已经运用到两个项目中了。在 Taro 中,可以包装成下面这个类:

/* utils/i18n.js */
export default class T {
  constructor(locales, locale) {
    this.locales = locales
    if (locale) {
      this.locale = locale
    }
  }

  setLocale(code) {
    this.locale = code
  }

  _(line) {
    const { locales, locale } = this
    if (locale && locales[locale] && locales[locale][line]) {
      line = locales[locale][line]
    }

    return line
  }
}

新建一个 locales.js,写上你的本地化语言,key 名要和微信系统语言的叫法一致:

/* utils/locales.js */
locales.zh_CN = {
  Discover: '发现',
  Schools: '学校',
  Me: '我',
  'Courses of My Faculty': '我的院系课程',
  'Popular Evaluations Monthly': '本月热门评测',
  'Popular Evaluations': '热门评测',
  'Recent Evaluations': '最新评测',
  'Top Courses': '高分课程',
  /* ... */
}
locales.zh_TW = {
  ...locales.zh_CN,
  Discover: '發現',
  Schools: '學校',
  Me: '我',
  'Courses of My Faculty': '我的院系課程',
  'Popular Evaluations Monthly': '本月熱門評測',
  'Popular Evaluations': '熱門評測',
  'Recent Evaluations': '最新評測',
  'Top Courses': '高分課程',
  /* ... */
}

使用方式是在 App.js 中先初始化:

/* App.js */
componentWillMount() {
  this.initLocale()
}

initLocale = () => {
  let locale = Taro.getStorageSync('locale')
  if (!locale) { // 初始化语言
    const systemInfo = await Taro.getSystemInfo()
    locale = systemInfo.language // 默认使用系统语言
    Taro.setStorage({ key: 'locale', data: locale })
  }
  Taro.T = new T(locales, locale) // 初始化本地化工具实例并注入 Taro.T
  // 手动更改 TabBar 语言(目前只能这么做)
  Taro.setTabBarItem({
    index: 0,
    text: Taro.T._('Discover'),
  })
  Taro.setTabBarItem({
    index: 1,
    text: Taro.T._('Me'),
  })
}

组件中使用:

<Button>{Taro.T._('Hello')}</Button>

如果小程序提供了更改语言的功能,用户更改后,储存配置,然后直接 Taro.reLaunch 到首页,并且依次如上所述更改 TabBar 的语言即可。

确实挫了一点,不过在我看来,已经是在小程序里实现国际化的最方便可行的办法啦……(*´ω`)人(´ω`*)

包装 API 方法

虽然 Taro 提供了 Taro.request 这个方法,但我还是选择了 Fly.js 这个库,包装了一个自己的 request 方法:

/* utils/request.js */
import Taro from '@tarojs/taro'
import Fly from 'flyio/dist/npm/wx'
import config from '../config'
import helper from './helper'

const request = new Fly()
request.config.baseURL = BASE_URL
const newRquest = new Fly() // 这是用来 lock 时用的,详见后面

// 请求拦截器,我在这里的使用场景是:除了某些路由外,如果没有权限的用户「越界」了,就报错给予提示
request.interceptors.request.use(async conf => {
  const { url, method } = conf
  const allowedPostUrls = [
    '/login',
    '/users',
    '/email',
  ]
  const isExcept = allowedPostUrls.some(v => url.includes(v))
  if (method !== 'GET' && !isExcept) {
    try {
      await helper.checkPermission() // 一个用来检测用户权限的方法
    } catch (e) {
      throw e
    }
  }

  return conf
})

// 响应拦截器,我在这里的使用场景是:如果用户的 session 过期了,就锁定请求队列,完成重新登录,然后继续请求队列
request.interceptors.response.use(
  response => response,
  async err => {
    try {
      if (err.status === 0) { // 网络问题
        throw new Error(Taro.T._('Server not responding'))
      }

      const { status } = err.response
      if (status === 401 || status === 403) { // 这两个状态码表示用户没有权限,需要重新登录领 session
        request.lock() // 锁定请求队列,避免重复请求
        const { errMsg, code } = await Taro.login() // 重新登录
        if (code) {
          const res = await newRquest.post('/login', { code }) // 使用新实例完成登录
          const { data } = res.data
          const { sessionId, userInfo } = data
          Taro.setStorageSync('sessionId', sessionId) // 储存新 session
          if (userInfo) {
            Taro.setStorageSync('userInfo', userInfo) // 更新用户信息
          }
          request.unlock() // 解锁请求队列
          err.request.headers['Session-Id'] = sessionId // 在请求头加上新 session
          return await request.request(err.request) // 重新完成请求
        } else {
          request.unlock()
          throw new Error(Taro.T._('Unable to get login status'), errMsg)
          return err
        }
      }
    } catch (e) {
      Taro.showToast({ title: e.message, icon: 'none' })
      throw e
    }
  },
)

export default request

你可以在这个的基础上,再包装一层 api.js 的 SDK。用起来很舒服~σ ゚∀ ゚) ゚∀゚)σ

使用第三方统计

第三方统计我目前用过两个,阿拉丁TalkingData。二者进行对比后,发现大同小异,阿拉丁社区更活跃一些,而 TalkingData 提供了数据获取的 API。但使用中发现,TalkingData 并不能很好地兼容 Taro,在我反馈后,得到的回复是由于小程序第三方开发框架太多,所以没有支持的计划 (´c_`);阿拉丁虽然之前也有这样的问题,但在几个月前的版本中已经修复,而且提供了集成的参考文档。

所以如果有这方面需求的话,可以考虑看看阿拉丁~

全局样式的统一配置文件

最后,说一个统一全局样式的实践吧。很简单,比如创建一个 _scheme.scss 的文件:

/* utils/_scheme.js */
$f1: 80px; // 阿拉伯数字信息,如:金额、时间等
$f2: 40px; // 页面大标题,如:结果、控状态等信息单一页面
$f3: 36px; // 大按钮字体
$f4: 34px; // 首要层级信息,基准的,可以是连续的,如:列表标题、消息气泡
$f5: 28px; // 次要描述信息,服务于首要信息并与之关联,如:列表摘要
$f6: 26px; // 辅助信息,需弱化的内容,如:链接、小按钮
$f7: 22px; // 说明文本,如:版权信息等不需要用户关注的信息
$f8: 18px; // 十分小

$color-primary: #ff9800; // 品牌颜色
$color-secondary: lighten($color-primary, 10%);
$color-tertiary: lighten($color-primary, 20%);

$color-line: #ececec; // 分割线颜色
$color-bg: #ebebeb; // 背景色

$color-text-primary: #000000; // 主内容
$color-text-long: #353535; // 大段说明的主要内容
$color-text-secondary: #888888; // 次要内容
$color-text-placeholder: #b2b2b2; // 缺省值

$color-link-normal: #576b96; // 链接用色
$color-link-press: lighten($color-link-normal, 10%);
$color-link-disable: lighten($color-link-normal, 20%);

$color-complete-normal: $color-primary; // 完成用色
$color-complete-press: lighten($color-complete-normal, 10%);
$color-complete-disable: lighten($color-complete-normal, 20%);

$color-success-normal: #09bb07; // 成功用色
$color-success-press: lighten($color-success-normal, 10%);
$color-success-disable: lighten($color-success-normal, 20%);

$color-error-normal: #e64340; // 出错用色
$color-error-press: lighten($color-error-normal, 10%);
$color-error-disable: lighten($color-error-normal, 20%);

之后在样式文件中引用这个配置文件,使用相应变量,而不使用绝对的数值即可。

对了,Taro 中的 px 其实指的就是 rpx;如果你想要真实的 px,可以大写 PX


以上就是这些,没能在开发的同时做笔记导致可能也遗漏了不少点,争取以后补上吧。写这么多一方面是总结,一方面也是分享。谢谢各位看到这里。如果有任何地方说的不对,欢迎指正教导。我要学习的还有很多!

使用 Taro 开发小程序,总之还是蛮爽的啦,大家快来用(挥手~)

٩(´ ω` )۶