基础教程

安装好 Taro CLI 之后可以通过 taro init 命令创建一个全新的项目,你可以根据你的项目需求填写各个选项,一个最小版本的 Taro 项目会包括以下文件:

  1. ├── babel.config.js # Babel 配置
  2. ├── .eslintrc.js # ESLint 配置
  3. ├── config # 编译配置目录
  4. ├── dev.js # 开发模式配置
  5. ├── index.js # 默认配置
  6. └── prod.js # 生产模式配置
  7. ├── package.json # Node.js manifest
  8. ├── dist # 打包目录
  9. ├── project.config.json # 小程序项目配置
  10. ├── src # 源码目录
  11. ├── app.config.js # 全局配置
  12. ├── app.css # 全局 CSS
  13. ├── app.js # 入口组件
  14. ├── index.html # H5 入口 HTML
  15. └── pages # 页面组件
  16. └── index
  17. ├── index.config.js # 页面配置
  18. ├── index.css # 页面 CSS
  19. └── index.jsx # 页面组件,如果是 Vue 项目,此文件为 index.vue

我们以后将会讲解每一个文件的作用,但现在,我们先把注意力聚焦在 src 文件夹,也就是源码目录:

入口组件

每一个 Taro 项目都有一个入口组件和一个入口配置,我们可以在入口组件中设置全局状态/全局生命周期,一个最小化的入口组件会是这样:

import Tabs from ‘@theme/Tabs’; import TabItem from ‘@theme/TabItem’;

  1. import React, { Component } from 'react'
  2. import './app.css'
  3. class App extends Component {
  4. render () {
  5. // this.props.children 是将要会渲染的页面
  6. return this.props.children
  7. }
  8. }
  9. // 每一个入口组件都必须导出一个 React 组件
  10. export default App
  1. import Vue from 'vue'
  2. import './app.css'
  3. const App = new Vue({
  4. render(h) {
  5. // this.$slots.default 是将要会渲染的页面
  6. return h('block', this.$slots.default)
  7. }
  8. })
  9. export default App

每一个入口组件(例如 app.js)总是伴随一个全局配置文件(例如 app.config.js),我们可以在全局配置文件中设置页面组件的路径、全局窗口、路由等信息,一个最简单的全局配置如下:

  1. export default {
  2. pages: [
  3. 'pages/index/index'
  4. ]
  5. }
  1. export default {
  2. pages: [
  3. 'pages/index/index'
  4. ]
  5. }

你可能会注意到,不管是 还是 ,两者的全局配置是一样的。这是在配置文件中,Taro 并不关心框架的区别,Taro CLI 会直接在编译时在 Node.js 环境直接执行全局配置的代码,并把 export default 导出的对象序列化为一个 JSON 文件。接下来我们要讲到 页面配置 也是同样的执行逻辑。

因此,我们必须保证配置文件是在 Node.js 环境中是可以执行的,不能使用一些在 H5 环境或小程序环境才能运行的包或者代码,否则编译将会失败。

info 了解更多 Taro 的入口组件和全局配置规范是基于微信小程序而制定的,并对全平台进行统一。 你可以通过访问 React 入口组件Vue 入口组件,以及 全局配置 了解入口组件和全局配置的详情。 >

页面组件

页面组件是每一项路由将会渲染的页面,Taro 的页面默认放在 src/pages 中,每一个 Taro 项目至少有一个页面组件。在我们生成的项目中有一个页面组件:src/pages/index/index,细心的朋友可以发现,这个路径恰巧对应的就是我们全局配置pages 字段当中的值。一个简单的页面组件如下:

  1. import { View } from '@tarojs/components'
  2. class Index extends Component {
  3. state = {
  4. msg: 'Hello World!'
  5. }
  6. onReady () {
  7. console.log('onReady')
  8. }
  9. render () {
  10. return <View>{ this.state.msg }</View>
  11. }
  12. }
  13. export default Index
  1. <template>
  2. <view>
  3. {{ msg }}
  4. </view>
  5. </template>
  6. <script>
  7. export default {
  8. data() {
  9. return {
  10. msg: 'Hello World!'
  11. };
  12. },
  13. onReady () {
  14. console.log('onReady')
  15. }
  16. };
  17. </script>

这不正是我们熟悉的 和 组件吗!但还是有两点细微的差别:

  1. onReady 生命周期函数。这是来源于微信小程序规范的生命周期,表示组件首次渲染完毕,准备好与视图交互。Taro 在运行时将大部分小程序规范页面生命周期注入到了页面组件中,同时 React 或 Vue 自带的生命周期也是完全可以正常使用的。
  2. View 组件。这是来源于 @tarojs/components 的跨平台组件。相对于我们熟悉的 divspan 元素而言,在 Taro 中我们要全部使用这样的跨平台组件进行开发。

和入口组件一样,每一个页面组件(例如 index.vue)也会有一个页面配置(例如 index.config.js),我们可以在页面配置文件中设置页面的导航栏、背景颜色等参数,一个最简单的页面配置如下:

  1. export default {
  2. navigationBarTitleText: '首页'
  3. }

info 了解更多 Taro 的页面钩子函数和页面配置规范是基于微信小程序而制定的,并对全平台进行统一。 你可以通过访问 React 页面组件Vue 页面组件 了解全部页面钩子函数和页面配置规范。 >

自定义组件

如果你看到这里,那不得不恭喜你,你已经理解了 Taro 中最复杂的概念:入口组件和页面组件,并了解了它们是如何(通过配置文件)交互的。接下来的内容,如果你已经熟悉了 或 以及 Web 开发的话,那就太简单了:

我们先把首页写好,首页的逻辑很简单:把论坛最新的帖子展示出来。

  1. import Taro from '@tarojs/taro'
  2. import React from 'react'
  3. import { View } from '@tarojs/components'
  4. import { ThreadList } from '../../components/thread_list'
  5. import api from '../../utils/api'
  6. import './index.css'
  7. class Index extends React.Component {
  8. config = {
  9. navigationBarTitleText: '首页'
  10. }
  11. state = {
  12. loading: true,
  13. threads: []
  14. }
  15. async componentDidMount () {
  16. try {
  17. const res = await Taro.request({
  18. url: api.getLatestTopic()
  19. })
  20. this.setState({
  21. threads: res.data,
  22. loading: false
  23. })
  24. } catch (error) {
  25. Taro.showToast({
  26. title: '载入远程数据错误'
  27. })
  28. }
  29. }
  30. render () {
  31. const { loading, threads } = this.state
  32. return (
  33. <View className='index'>
  34. <ThreadList
  35. threads={threads}
  36. loading={loading}
  37. />
  38. </View>
  39. )
  40. }
  41. }
  42. export default Index
  1. <template>
  2. <view class='index'>
  3. <thread-list
  4. :threads="threads"
  5. :loading="loading"
  6. />
  7. </view>
  8. </template>
  9. <script>
  10. import Vue from 'vue'
  11. import Taro from '@tarojs/taro'
  12. import api from '../../utils/api'
  13. import ThreadList from '../../components/thread_list.vue'
  14. export default {
  15. components: {
  16. 'thread-list': ThreadList
  17. },
  18. data () {
  19. return {
  20. loading: true,
  21. threads: []
  22. }
  23. },
  24. async created() {
  25. try {
  26. const res = await Taro.request({
  27. url: api.getLatestTopic()
  28. })
  29. this.loading = false
  30. this.threads = res.data
  31. } catch (error) {
  32. Taro.showToast({
  33. title: '载入远程数据错误'
  34. })
  35. }
  36. }
  37. }
  38. </script>

info 了解更多 可能你会注意到在一个 Taro 应用中发送请求是 Taro.request() 完成的。 和页面配置、全局配置一样,Taro 的 API 规范也是基于微信小程序而制定的,并对全平台进行统一。 你可以通过在 API 文档 找到所有 API。 >

在我们的首页组件里,还引用了一个 ThreadList 组件,我们现在来实现它:

  1. import React from 'react'
  2. import { View, Text } from '@tarojs/components'
  3. import { Thread } from './thread'
  4. import { Loading } from './loading'
  5. import './thread.css'
  6. class ThreadList extends React.Component {
  7. static defaultProps = {
  8. threads: [],
  9. loading: true
  10. }
  11. render () {
  12. const { loading, threads } = this.props
  13. if (loading) {
  14. return <Loading />
  15. }
  16. const element = threads.map((thread, index) => {
  17. return (
  18. <Thread
  19. key={thread.id}
  20. node={thread.node}
  21. title={thread.title}
  22. last_modified={thread.last_modified}
  23. replies={thread.replies}
  24. tid={thread.id}
  25. member={thread.member}
  26. />
  27. )
  28. })
  29. return (
  30. <View className='thread-list'>
  31. {element}
  32. </View>
  33. )
  34. }
  35. }
  36. export { ThreadList }
  1. import Taro, { eventCenter } from '@tarojs/taro'
  2. import React from 'react'
  3. import { View, Text, Navigator, Image } from '@tarojs/components'
  4. import api from '../utils/api'
  5. import { timeagoInst, Thread_DETAIL_NAVIGATE } from '../utils'
  6. class Thread extends React.Component {
  7. handleNavigate = () => {
  8. const { tid, not_navi } = this.props
  9. if (not_navi) {
  10. return
  11. }
  12. eventCenter.trigger(Thread_DETAIL_NAVIGATE, this.props)
  13. // 跳转到帖子详情
  14. Taro.navigateTo({
  15. url: '/pages/thread_detail/thread_detail'
  16. })
  17. }
  18. render () {
  19. const { title, member, last_modified, replies, node, not_navi } = this.props
  20. const time = timeagoInst.format(last_modified * 1000, 'zh')
  21. const usernameCls = `author ${not_navi ? 'bold' : ''}`
  22. return (
  23. <View className='thread' onClick={this.handleNavigate}>
  24. <View className='info'>
  25. <View>
  26. <Image src={member.avatar_large} className='avatar' />
  27. </View>
  28. <View className='middle'>
  29. <View className={usernameCls}>
  30. {member.username}
  31. </View>
  32. <View className='replies'>
  33. <Text className='mr10'>
  34. {time}
  35. </Text>
  36. <Text>
  37. 评论 {replies}
  38. </Text>
  39. </View>
  40. </View>
  41. <View className='node'>
  42. <Text className='tag'>
  43. {node.title}
  44. </Text>
  45. </View>
  46. </View>
  47. <Text className='title'>
  48. {title}
  49. </Text>
  50. </View>
  51. )
  52. }
  53. }
  54. export { Thread }
  1. <template>
  2. <view className='thread-list'>
  3. <loading v-if="loading" />
  4. <thread
  5. v-else
  6. v-for="t in threads"
  7. :key="t.id"
  8. :node="t.node"
  9. :title="t.title"
  10. :last_modified="t.last_modified"
  11. :replies="t.replies"
  12. :tid="t.id"
  13. :member="t.member"
  14. />
  15. </view>
  16. </template>
  17. <script >
  18. import Vue from 'vue'
  19. import Loading from './loading.vue'
  20. import Thread from './thread.vue'
  21. export default {
  22. components: {
  23. 'loading': Loading,
  24. 'thread': Thread
  25. },
  26. props: {
  27. threads: {
  28. type: Array,
  29. default: []
  30. },
  31. loading: {
  32. type: Boolean,
  33. default: true
  34. }
  35. }
  36. }
  37. </script>
  1. <template>
  2. <view class='thread' @tap="handleNavigate">
  3. <view class='info'>
  4. <view>
  5. <image :src="member.avatar_large | url" class='avatar' />
  6. </view>
  7. <view class='middle'>
  8. <view :class="usernameCls">
  9. {{member.username}}
  10. </view>
  11. <view class='replies'>
  12. <text class='mr10'>{{time}}</text>
  13. <text>评论 {{replies}}</text>
  14. </view>
  15. </view>
  16. <view class='node'>
  17. <text class='tag'>{{node.title}}</Text>
  18. </view>
  19. </view>
  20. <text class='title'>{{title}}</text>
  21. </view>
  22. </template>
  23. <script>
  24. import Vue from 'vue'
  25. import { eventCenter } from '@tarojs/taro'
  26. import Taro from '@tarojs/taro'
  27. import { timeagoInst, Thread_DETAIL_NAVIGATE } from '../utils'
  28. import './thread.css'
  29. export default {
  30. props: ['title', 'member', 'last_modified', 'replies', 'node', 'not_navi', 'tid'],
  31. computed: {
  32. time () {
  33. return timeagoInst.format(this.last_modified * 1000, 'zh')
  34. },
  35. usernameCls () {
  36. return `author ${this.not_navi ? 'bold' : ''}`
  37. }
  38. },
  39. filters: {
  40. url (val) {
  41. return 'https:' + val
  42. }
  43. },
  44. methods: {
  45. handleNavigate () {
  46. const { tid, not_navi } = this.$props
  47. if (not_navi) {
  48. return
  49. }
  50. eventCenter.trigger(Thread_DETAIL_NAVIGATE, this.$props)
  51. // 跳转到帖子详情
  52. Taro.navigateTo({
  53. url: '/pages/thread_detail/thread_detail'
  54. })
  55. }
  56. }
  57. }
  58. </script>

这里可以发现我们把论坛帖子渲染逻辑拆成了两个组件,并放在 src/components 文件中,因为这些组件是会在其它页面中多次用到。 拆分组件的力度是完全由开发者决定的,Taro 并没有规定组件一定要放在 components 文件夹,也没有规定页面一定要放在 pages 文件夹。

另外一个值得注意的点是:我们并没有使用 div/span 这样的 HTML 组件,而是使用了 View/Text 这样的跨平台组件。

info 了解更多 Taro 文档的跨平台组件库 包含了所有组件参数和用法。但目前组件库文档中的参数和组件名都是针对 React 的(除了 React 的点击事件是 onClick 之外)。 对于 Vue 而言,组件名和组件参数都采用短横线风格(kebab-case)的命名方式,例如:<picker-view indicator-class="myclass" /> >

路由与 Tabbar

src/components/thread 组件中,我们通过

  1. Taro.navigateTo({ url: '/pages/thread_detail/thread_detail' })

跳转到帖子详情,但这个页面仍未实现,现在我们去入口文件配置一个新的页面:

  1. export default {
  2. pages: [
  3. 'pages/index/index',
  4. 'pages/thread_detail/thread_detail'
  5. ]
  6. }

然后在路径 src/pages/thread_detail/thread_detail 实现帖子详情页面,路由就可以跳转,我们整个流程就跑起来了:

  1. import Taro from '@tarojs/taro'
  2. import React from 'react'
  3. import { View, RichText, Image } from '@tarojs/components'
  4. import { Thread } from '../../components/thread'
  5. import { Loading } from '../../components/loading'
  6. import api from '../../utils/api'
  7. import { timeagoInst, GlobalState } from '../../utils'
  8. import './index.css'
  9. function prettyHTML (str) {
  10. const lines = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6']
  11. lines.forEach(line => {
  12. const regex = new RegExp(`<${line}`, 'gi')
  13. str = str.replace(regex, `<${line} class="line"`)
  14. })
  15. return str.replace(/<img/gi, '<img class="img"')
  16. }
  17. class ThreadDetail extends React.Component {
  18. state = {
  19. loading: true,
  20. replies: [],
  21. content: '',
  22. thread: {}
  23. } as IState
  24. config = {
  25. navigationBarTitleText: '话题'
  26. }
  27. componentWillMount () {
  28. this.setState({
  29. thread: GlobalState.thread
  30. })
  31. }
  32. async componentDidMount () {
  33. try {
  34. const id = GlobalState.thread.tid
  35. const [{ data }, { data: [ { content_rendered } ] } ] = await Promise.all([
  36. Taro.request({
  37. url: api.getReplies({
  38. 'topic_id': id
  39. })
  40. }),
  41. Taro.request({
  42. url: api.getTopics({
  43. id
  44. })
  45. })
  46. ])
  47. this.setState({
  48. loading: false,
  49. replies: data,
  50. content: prettyHTML(content_rendered)
  51. })
  52. } catch (error) {
  53. Taro.showToast({
  54. title: '载入远程数据错误'
  55. })
  56. }
  57. }
  58. render () {
  59. const { loading, replies, thread, content } = this.state
  60. const replieEl = replies.map((reply, index) => {
  61. const time = timeagoInst.format(reply.last_modified * 1000, 'zh')
  62. return (
  63. <View className='reply' key={reply.id}>
  64. <Image src={reply.member.avatar_large} className='avatar' />
  65. <View className='main'>
  66. <View className='author'>
  67. {reply.member.username}
  68. </View>
  69. <View className='time'>
  70. {time}
  71. </View>
  72. <RichText nodes={reply.content} className='content' />
  73. <View className='floor'>
  74. {index + 1}
  75. </View>
  76. </View>
  77. </View>
  78. )
  79. })
  80. const contentEl = loading
  81. ? <Loading />
  82. : (
  83. <View>
  84. <View className='main-content'>
  85. <RichText nodes={content} />
  86. </View>
  87. <View className='replies'>
  88. {replieEl}
  89. </View>
  90. </View>
  91. )
  92. return (
  93. <View className='detail'>
  94. <Thread
  95. node={thread.node}
  96. title={thread.title}
  97. last_modified={thread.last_modified}
  98. replies={thread.replies}
  99. tid={thread.id}
  100. member={thread.member}
  101. not_navi={true}
  102. />
  103. {contentEl}
  104. </View>
  105. )
  106. }
  107. }
  108. export default ThreadDetail
  1. <template>
  2. <view class='detail'>
  3. <thread
  4. :node="topic.node"
  5. :title="topic.title"
  6. :last_modified="topic.last_modified"
  7. :replies="topic.replies"
  8. :tid="topic.id"
  9. :member="topic.member"
  10. :not_navi="true"
  11. />
  12. <loading v-if="loading" />
  13. <view v-else>
  14. <view class='main-content'>
  15. <rich-text :nodes="content | html" />
  16. </view>
  17. <view class='replies'>
  18. <view v-for="(reply, index) in replies" class='reply' :key="reply.id">
  19. <image :src='reply.member.avatar_large' class='avatar' />
  20. <view class='main'>
  21. <view class='author'>
  22. {{reply.member.username}}
  23. </view>
  24. <view class='time'>
  25. {{reply.last_modified | time}}
  26. </view>
  27. <rich-text :nodes="reply.content_rendered | html" class='content' />
  28. <view class='floor'>
  29. {{index + 1}} 楼
  30. </view>
  31. </view>
  32. </view>
  33. </view>
  34. </view>
  35. </view>
  36. </template>
  37. <script>
  38. import Vue from 'vue'
  39. import Taro from '@tarojs/taro'
  40. import api from '../../utils/api'
  41. import { timeagoInst, GlobalState, IThreadProps, prettyHTML } from '../../utils'
  42. import Thread from '../../components/thread.vue'
  43. import Loading from '../../components/loading.vue'
  44. import './index.css'
  45. export default {
  46. components: {
  47. 'loading': Loading,
  48. 'thread': Thread
  49. },
  50. data () {
  51. return {
  52. topic: GlobalState.thread,
  53. loading: true,
  54. replies: [],
  55. content: ''
  56. }
  57. },
  58. async created () {
  59. try {
  60. const id = GlobalState.thread.tid
  61. const [{ data }, { data: [ { content_rendered } ] } ] = await Promise.all([
  62. Taro.request({
  63. url: api.getReplies({
  64. 'topic_id': id
  65. })
  66. }),
  67. Taro.request({
  68. url: api.getTopics({
  69. id
  70. })
  71. })
  72. ])
  73. this.loading = false
  74. this.replies = data
  75. this.content = content_rendered
  76. } catch (error) {
  77. Taro.showToast({
  78. title: '载入远程数据错误'
  79. })
  80. }
  81. },
  82. filters: {
  83. time (val) {
  84. return timeagoInst.format(val * 1000)
  85. },
  86. html (val) {
  87. return prettyHTML(val)
  88. }
  89. }
  90. }
  91. </script>

到目前为止,我们已经实现了这个应用的所有逻辑,除去「节点列表」页面(在进阶指南我们会讨论这个页面组件)之外,剩下的页面都可以通过我们已经讲解过的组件或页面快速抽象完成。按照我们的计划,这个应用会有五个页面,分别是:

  1. 首页,展示最新帖子(已完成)
  2. 节点列表
  3. 热门帖子(可通过组件复用)
  4. 节点帖子 (可通过组件复用)
  5. 帖子详情 (已完成)

其中前三个页面我们可以把它们规划在 tabBar 里,tabBar 是 Taro 内置的导航栏,可以在 app.config.js 配置,配置完成之后处于的 tabBar 位置的页面会显示一个导航栏。最终我们的 app.config.js 会是这样:

  1. export default {
  2. pages: [
  3. 'pages/index/index',
  4. 'pages/nodes/nodes',
  5. 'pages/hot/hot',
  6. 'pages/node_detail/node_detail',
  7. 'pages/thread_detail/thread_detail'
  8. ],
  9. tabBar: {
  10. list: [{
  11. 'iconPath': 'resource/latest.png',
  12. 'selectedIconPath': 'resource/lastest_on.png',
  13. pagePath: 'pages/index/index',
  14. text: '最新'
  15. }, {
  16. 'iconPath': 'resource/hotest.png',
  17. 'selectedIconPath': 'resource/hotest_on.png',
  18. pagePath: 'pages/hot/hot',
  19. text: '热门'
  20. }, {
  21. 'iconPath': 'resource/node.png',
  22. 'selectedIconPath': 'resource/node_on.png',
  23. pagePath: 'pages/nodes/nodes',
  24. text: '节点'
  25. }],
  26. 'color': '#000',
  27. 'selectedColor': '#56abe4',
  28. 'backgroundColor': '#fff',
  29. 'borderStyle': 'white'
  30. },
  31. window: {
  32. backgroundTextStyle: 'light',
  33. navigationBarBackgroundColor: '#fff',
  34. navigationBarTitleText: 'V2EX',
  35. navigationBarTextStyle: 'black'
  36. }
  37. }