Taro 小程序开发大型实战(一):熟悉的 React,熟悉的 Hooks



  • 正当移动互联网进入白热化阶段时,以微信小程序为代表的一类“轻应用”异军突起。它们无需下载,使用方便,“用完即走”,同时功能也较为完备,一经推出即得到了各大平台和及用户的热烈追捧。但是问题也随之而来——开发者们要同时维护 Web 端、移动端、微信小程序、支付宝小程序等等多套用户界面,其维护成本可以想象。作为一个优秀的多端统一开发解决方案,Taro 的出现则改变了这一情况。正值 Taro 2.x 进入 beta 阶段,让我们沏上一杯茶,开始我们的 Taro 多端小程序开发之旅吧。

    起步

    对于国内 React 开发者来说,Taro 的出现无疑是福音——它能够让我们用熟悉的 React 代码去搭建各类小程序,并且一份代码可以编译成多个平台的应用(目前包括微信小程序、支付宝小程序、React Native、H5 等等)。随着 Taro 的不断进化,它对 React 代码的支持程度越来越好,所支持的目标平台也越来越多,学习的价值自然不必多言。正值 Taro 进入 2.0.0 版本的 beta 阶段,我们在这一篇教程将手把手带你实现一个能够部署到多端的小程序,让你感受 Taro 的强大与魅力!

    在这一系列教程中,我们将构建一个多端小程序应用——奥特曼俱乐部(Ultraman Club,简称 UltraClub),一个支持多端登录(微信和支付宝)的类似贴吧的小程序。我们还提供了项目仓库的 GitHub 地址项目目前还在开发阶段,您可以跳转到任意一次 commit 查看当前步骤的所有代码哦。

    我们将构建什么?

    在完成这篇教程后,项目的 GIF 动图展示如下:

    具体有三个页面:

    1. 主页:展示了所有帖子,以及添加新帖子的按钮。
    2. 帖子详情:展示单个帖子的全部内容。
    3. 个人主页:展示用户的个人信息。

    前提条件

    在阅读这篇教程之前,我们希望你已经具备以下知识:

    • 了解 HTML、CSS、JavaScript 的基础知识,如果了解 Sass 就更好了
    • 了解 React 框架的基础知识,可以参考这篇教程进行学习;如果接触过 React Native 以及 Hooks 则更好了
    • 了解并已经安装好 Node 与 npm,可以参考这篇教程进行学习

    除此之外,你还需要下载并安装微信开发者工具,这里是下载地址

    用 Taro 脚手架初始化项目

    首先安装 Taro CLI:

    npm install -g @tarojs/cli
    

    然后创建我们的项目:

    taro init ultra-club
    

    之后会出现一系列选项,按照下图所示进行选择即可(CSS 预处理器选择 Sass,模板选择“默认模板”,老司机可自行选择使用 TS):

    提示

    本项目使用 Sass 主要是为了兼容 taro-ui 的样式,并没有使用到 Sass 的高级特性,如果你不熟悉的话也不用担心哦,就当成是常规的 CSS 代码。

    进入到我们的项目目录 ultra-club 之后,可以看到项目模板包括以下文件:

    .
    ├── config                    # 项目配置
    │   ├── dev.js                # 开发环境配置文件
    │   ├── index.js              # 主配置文件
    │   └── prod.js               # 生产环境配置文件
    ├── package.json
    ├── project.config.json       # 微信小程序项目配置
    └── src                       # 项目源码目录
        ├── app.scss              # 根组件样式
        ├── app.jsx               # 根组件 app
        ├── index.html            # 等待被嵌入代码的 HTML 文档
        └── pages                 # 页面目录
            └── index             # index 页面模块
                ├── index.scss    # index 页面样式
                └── index.jsx     # index 页面组件
    

    我们主要看一下两个代码文件:src/app.jsx 以及 src/pages/index/index.jsx

    初探脚手架代码

    src/app.jsx 定义了项目的根组件 App,它的代码如下:

    import Taro, { Component } from '@tarojs/taro'
    import Index from './pages/index'
    
    import './app.scss'
    
    // 如果需要在 h5 环境中开启 React Devtools
    // 取消以下注释:
    // if (process.env.NODE_ENV !== 'production' && process.env.TARO_ENV === 'h5')  {
    //   require('nerv-devtools')
    // }
    
    class App extends Component {
      config = {
        pages: ['pages/index/index'],
        window: {
          backgroundTextStyle: 'light',
          navigationBarBackgroundColor: '#fff',
          navigationBarTitleText: 'WeChat',
          navigationBarTextStyle: 'black',
        },
      }
    
      // 在 App 类中的 render() 函数没有实际作用
      // 请勿修改此函数
      render() {
        return <Index />
      }
    }
    
    Taro.render(<App />, document.getElementById('app'))
    

    如果你熟悉 React 的话,那么上面这段代码一定不难理解,只不过是把相应的地方(导包、渲染)从之前的 React 以及 ReactDOM 改成 Taro

    注意

    可以看到这个组件还多了一个 config 属性,这个属性是小程序应用专属的。其中要重点关注的是 pages 数组,列出了所有的页面模块,例如这里的 pages/index/index 就对应 src/pages/index/index.jsx。后面在实现路由时还会用到 pages 属性。

    我们再看看 src/pages/index/index.jsx。按照最佳实践,Taro 项目中一般把页面组件放到 src/pages 目录中,src/pages/index 就是 index 页面组件模块,其中 index.jsx 的代码如下:

    import Taro, { Component } from '@tarojs/taro'
    import { View, Text } from '@tarojs/components'
    import './index.scss'
    
    export default class Index extends Component {
      config = {
        navigationBarTitleText: '首页',
      }
    
      render() {
        return (
          <View className="index">
            <Text>Hello world!</Text>
          </View>
        )
      }
    }
    

    依旧是熟悉的 React 组件风格,只不过与普通的 React 相比,在 render 函数中我们用的不再是 divp 标签,而是 Taro 为我们准备好的 ViewText 组件。为什么 Taro 要自己搞一套组件库呢?因为 Taro 的目标是星辰大海……sorry,是能够编译到各个平台。只有通过制订 Taro 自己的组件库,才能在各个平台的原生组件库上盖了一层抽象层,进而实现跨平台的目标

    提示

    如果你有过 React Native 的开发经验,那么一定对 Taro 组件库不陌生。

    运行小程序

    Taro 提供的模板代码直接可以运行。打开终端,运行以下命令:

    npm run dev:weapp
    

    会出现以下提示信息:

    当看到“监听文件修改中...”的提示后,我们就可以打开微信开发者工具,用微信扫码登录后界面如下:

    点击那个硕大的➕号,开始导入我们刚才创建的 ultra-club 项目:

    如上图所示,首先切换到”导入项目“一栏,然后点击”目录“输入栏右侧的按钮选择刚才创建的 ultra-club 文件夹,最后点击右下角的”导入“按钮即可。

    导入成功后,微信开发者工具的界面如下图所示:

    在模拟器页面中,看到了我们 index 页面渲染的 Hello world;编辑器能够查看所有代码,不过通常我们用自己习惯的代码编辑器来开发(VSCode 真香!);调试器则是类似 Chrome 的开发者工具。

    一切就绪,让我们开始动工吧!

    提示

    从这一步开始,我们的主要开发目标将是微信小程序,但是不要担心,我们会在文章的最后演示怎么编译到其他平台哦。

    React 代码,熟悉的味道

    从这一步开始,我们就来实现”奥特曼俱乐部“小程序。按照 React 中”万物皆组件“的思想,我们抽象出两个组件:

    • PostCard:用于展示一篇帖子,包括标题 title 和内容 content
    • PostForm:用于发布新帖子的表单

    实现 PostCard 组件

    首先创建 src/components 目录,我们的通用组件都会放在这里面。然后创建 src/components/PostCard 组件目录,在其中分别创建 index.jsxindex.scssindex.jsx 代码如下:

    import Taro from '@tarojs/taro'
    import { View } from '@tarojs/components'
    
    import './index.scss'
    
    export default function PostCard(props) {
      return (
        <View className="postcard">
          <View className="post-title">{props.title}</View>
          <View className="post-content">{props.content}</View>
        </View>
      )
    }
    

    正如之前所说,PostCard 组件包含两个 props:标题 title 和内容 content

    PostCard 组件的样式 index.scss 代码如下:

    .postcard {
      margin: 30px;
      padding: 20px;
      border: 1px solid #ddd;
    }
    
    .post-title {
      font-weight: bolder;
      margin-bottom: 10px;
    }
    
    .post-content {
      font-size: medium;
      color: #666;
    }
    

    实现 PostForm 组件

    接着我们实现用于创建新帖子的 PostForm 组件。在 src/components 中创建 PostForm 目录,并在其中添加 index.jsxindex.scss 文件。index.jsx 代码如下:

    import Taro from '@tarojs/taro'
    import { View, Form, Input, Textarea, Button } from '@tarojs/components'
    
    import './index.scss'
    
    export default function PostForm(props) {
      return (
        <View className="post-form">
          <View>添加新的帖子</View>
          <Form onSubmit={props.handleSubmit}>
            <View>
              <View className="form-hint">标题</View>
              <Input
                className="input-title"
                type="text"
                placeholder="点击输入标题"
                value={props.formTitle}
                onInput={props.handleTitleInput}
              />
              <View className="form-hint">正文</View>
              <Textarea
                placeholder="点击输入正文"
                className="input-content"
                value={props.formContent}
                onInput={props.handleContentInput}
              />
              <Button className="form-button" formType="submit" type="primary">
                提交
              </Button>
            </View>
          </Form>
        </View>
      )
    }
    

    PostForm 组件一共定义了五个 props,分别如下:

    • formTitle:当前编辑中帖子的标题
    • formContent:当前编辑中帖子的内容
    • handleSubmit:处理提交表单的回调函数
    • handleTitleInput:处理标题接收到用户输入时的回调函数
    • handleContentInput:处理内容接收到用户输入时的回调函数

    提示

    如果你不熟悉 React,可能会对上面编写表单的方式有点困惑。实际上,React 推荐用”受控组件“的方式编写表单,可参考这篇文档

    PostForm 的样式文件 index.scss 的代码如下:

    .post-form {
      border: 1px solid #ddd;
      margin: 30px;
      padding: 30px;
    }
    
    .input-title {
      border: 1px solid #eee;
      padding: 10px;
      font-size: medium;
    }
    
    .input-content {
      border: 1px solid #eee;
      padding: 10px;
      height: 200px;
      font-size: medium;
    }
    
    .form-hint {
      font-size: small;
      color: gray;
      margin-top: 20px;
      margin-bottom: 10px;
    }
    
    .form-button {
      margin-top: 40px;
    }
    

    为了方便在页面组件中使用 PostCardPostForm 组件,我们把 src/components 变成一个模块。具体地,创建 src/components/index.jsx,代码如下:

    import PostCard from './PostCard'
    import PostForm from './PostForm'
    
    export { PostCard, PostForm }
    

    在 index 页面中接入 PostCard 和 PostForm

    最后在 src/pages/index/index.jsx 文件中加入之前写好的 PostCard 和 PostForm 组件,代码如下:

    import Taro, { Component } from '@tarojs/taro'
    import { View } from '@tarojs/components'
    import { PostCard, PostForm } from '../../components'
    import './index.scss'
    
    export default class Index extends Component {
      state = {
        posts: [
          {
            title: '泰罗奥特曼',
            content: '泰罗是奥特之父和奥特之母唯一的亲生儿子。',
          },
        ],
        formTitle: '',
        formContent: '',
      }
    
      config = {
        navigationBarTitleText: '首页',
      }
    
      handleSubmit(e) {
        e.preventDefault()
    
        const { formTitle: title, formContent: content } = this.state
        const newPosts = this.state.posts.concat({ title, content })
    
        this.setState({
          posts: newPosts,
          formTitle: '',
          formContent: '',
        })
      }
    
      handleTitleInput(e) {
        this.setState({
          formTitle: e.target.value,
        })
      }
    
      handleContentInput(e) {
        this.setState({
          formContent: e.target.value,
        })
      }
    
      render() {
        return (
          <View className="index">
            {this.state.posts.map((post, index) => (
              <PostCard key={index} title={post.title} content={post.content} />
            ))}
            <PostForm
              formTitle={this.state.formTitle}
              formContent={this.state.formContent}
              handleSubmit={e => this.handleSubmit(e)}
              handleTitleInput={e => this.handleTitleInput(e)}
              handleContentInput={e => this.handleContentInput(e)}
            />
          </View>
        )
      }
    

    可以看到,除了接入之前定义的两个组件外,我们还加入了一些状态:

    • posts:当前所有的帖子,每个帖子是一个包含 titlecontent 的对象
    • formTitle:当前正在编辑的帖子的标题
    • formContent:当前正在编辑的帖子的内容

    以及定义了 PostForm 组件中所需要的三个回调函数。

    查看效果

    如果之前的开发服务器还打开着,那么微信开发者工具应该就能直接看到效果了(如果刚才关了,可以运行 npm run dev:weapp 再次打开哦):

    注意

    有时候 Taro 可能会出现样式加载失败的问题。如果你遇到了,可以关闭开发服务器,重新运行 npm run dev:weapp

    Hooks 轻装上阵

    自从 React 团队在 2018 年的 React Conf 引入了 Hooks 之后,前端圈无疑是经历了一场地震。仅仅只需几个 API,就轻松地用纯函数的方式搞定了组件的状态管理和数据流,这是何等的神仙操作?

    幸运的是,Taro 团队也在 v1.3.0 版本中添加了对 Hooks 的支持。因此,我们也将在本项目中用 Hooks 解决状态管理和数据流的问题。

    Hooks 之 useState 快速复习

    本文在这里简单地过一遍 useState Hook,如果你已经很熟悉了,请直接移步下面的动手环节。

    比如我们之前有这么一个类组件 ClickMe,它会抱怨你点了它多少次:

    class ClickMe extends Component {
      state = { count: 0 }
    
      render() {
        return (
          <div>
            <button onClick={() => this.setState({ count: this.state.count + 1 })}>
              你点了我 {this.state.count} 次!
            </button>
          </div>
        )
      }
    }
    

    用 Hooks 改写之后,就变成了一个函数式组件:

    // 记得导入 useState 函数
    import Taro, { useState } from '@tarojs/taro'
    
    function ClickMe() {
      const [count, setCount] = useState(0)
    
      return (
        <div>
          <button onClick={() => setCount(count + 1)}>你点了我 {count} 次!</button>
        </div>
      )
    }
    

    可以看到,useState 函数返回了两个值:

    • 状态(也就是上面的 count):可以在渲染时直接使用
    • 修改状态的函数(也就是上面的 setCount):用于在处理相应事件时,通过传入新的状态来更新状态

    还注意到 useState 接受一个参数,即状态的初始值。这里我们取了一个 Number 类型,事实上还可以是字符串、数组、对象等等。

    动手环节

    到了动手环节,我们用 useState 来重构我们的 index 页面。具体地,我们将整个 Index 组件转换成函数式组件,然后之前的三个状态都用 useState 来创建,代码如下:

    import Taro, { useState } from '@tarojs/taro'
    import { View } from '@tarojs/components'
    import { PostCard, PostForm } from '../../components'
    import './index.scss'
    
    export default function Index() {
      const [posts, setPosts] = useState([
        {
          title: '泰罗奥特曼',
          content: '泰罗是奥特之父和奥特之母唯一的亲生儿子。',
        },
      ])
      const [formTitle, setFormTitle] = useState('')
      const [formContent, setFormContent] = useState('')
    
      function handleSubmit(e) {
        e.preventDefault()
    
        const newPosts = posts.concat({ title: formTitle, content: formContent })
        setPosts(newPosts)
        setFormTitle('')
        setFormContent('')
      }
    
      return (
        <View className="index">
          {posts.map((post, index) => (
            <PostCard key={index} title={post.title} content={post.content} />
          ))}
          <PostForm
            formTitle={formTitle}
            formContent={formContent}
            handleSubmit={e => handleSubmit(e)}
            handleTitleInput={e => setFormTitle(e.target.value)}
            handleContentInput={e => setFormContent(e.target.value)}
          />
        </View>
      )
    }
    
    Index.config = {
      navigationBarTitleText: '首页',
    }
    

    注意

    由于我们把 Index 从类组件改造成了函数组件,所以挂载 config 要在 Index 组件定义之后直接挂载在 Index 上面。

    你尽可以打开模拟器试一下重构之后效果,看看功能是否与上一步完全一致哦!在接下来的第二篇中,我们将进一步实现多页面跳转,并用 Taro UI 组件库升级我们的界面。

    下一篇:《Taro 小程序开发大型实战(二):多页面跳转和 Taro UI 组件库》


登录后回复