Taro 基础教程
安装好 Taro CLI 之后可以通过 taro init 命令创建一个全新的项目,你可以根据你的项目需求填写各个选项,一个最小版本的 Taro 项目会包括以下文件:
├── babel.config.js # Babel 配置├── .eslintrc.js # ESLint 配置├── config # 编译配置目录│ ├── dev.js # 开发模式配置│ ├── index.js # 默认配置│ └── prod.js # 生产模式配置├── package.json # Node.js manifest├── dist # 打包目录├── project.config.json # 小程序项目配置├── src # 源码目录│ ├── app.config.js # 全局配置│ ├── app.css # 全局 CSS│ ├── app.js # 入口组件│ ├── index.html # H5 入口 HTML│ └── pages # 页面组件│ └── index│ ├── index.config.js # 页面配置│ ├── index.css # 页面 CSS│ └── index.jsx # 页面组件,如果是 Vue 项目,此文件为 index.vue
我们以后将会讲解每一个文件的作用,但现在,我们先把注意力聚焦在 src 文件夹,也就是源码目录:
入口组件
每一个 Taro 项目都有一个入口组件和一个入口配置,我们可以在入口组件中设置全局状态/全局生命周期,一个最小化的入口组件会是这样:
import React, { Component } from 'react'import './app.css'class App extends Component {render () {// this.props.children 是将要会渲染的页面return this.props.children}}// 每一个入口组件都必须导出一个 React 组件export default App
import Vue from 'vue'import './app.css'const App = new Vue({render(h) {// this.$slots.default 是将要会渲染的页面return h('block', this.$slots.default)}})export default App
每一个入口组件(例如 app.js)总是伴随一个全局配置文件(例如 app.config.js),我们可以在全局配置文件中设置页面组件的路径、全局窗口、路由等信息,一个最简单的全局配置如下:
export default {pages: ['pages/index/index']}
export default {pages: ['pages/index/index']}
你可能会注意到,不管是 还是 ,两者的全局配置是一样的。这是在配置文件中,Taro 并不关心框架的区别,Taro CLI 会直接在编译时在 Node.js 环境直接执行全局配置的代码,并把 export default 导出的对象序列化为一个 JSON 文件。接下来我们要讲到 页面配置 也是同样的执行逻辑。
因此,我们必须保证配置文件是在 Node.js 环境中是可以执行的,不能使用一些在 H5 环境或小程序环境才能运行的包或者代码,否则编译将会失败。
了解更多 Taro 的入口组件和全局配置规范是基于微信小程序而制定的,并对全平台进行统一。 你可以通过访问 React 入口组件 和 Vue 入口组件,以及全局配置了解入口组件和全局配置的详情。
页面组件
页面组件是每一项路由将会渲染的页面,Taro 的页面默认放在 src/pages 中,每一个 Taro 项目至少有一个页面组件。在我们生成的项目中有一个页面组件:src/pages/index/index,细心的朋友可以发现,这个路径恰巧对应的就是我们 全局配置 的 pages 字段当中的值。一个简单的页面组件如下:
import { View } from '@tarojs/components'class Index extends Component {state = {msg: 'Hello World!'}onReady () {console.log('onReady')}render () {return <View>{ this.state.msg }</View>}}export default Index
<template><view>{{ msg }}</view></template><script>export default {data() {return {msg: 'Hello World!'};},onReady () {console.log('onReady')}};</script>
这不正是我们熟悉的 onReady 和 View 组件吗!但还是有两点细微的差别:
-
onReady 生命周期函数。这是来源于微信小程序规范的生命周期,表示组件首次渲染完毕,准备好与视图交互。Taro 在运行时将大部分小程序规范页面生命周期注入到了页面组件中,同时 React 或 Vue 自带的生命周期也是完全可以正常使用的。 -
View 组件。这是来源于 @tarojs/components 的跨平台组件。相对于我们熟悉的 div、span 元素而言,在 Taro 中我们要全部使用这样的跨平台组件进行开发。
和入口组件一样,每一个页面组件(例如 index.vue)也会有一个页面配置(例如 index.config.js),我们可以在页面配置文件中设置页面的导航栏、背景颜色等参数,一个最简单的页面配置如下:
export default {navigationBarTitleText: '首页'}
了解更多 Taro 的页面钩子函数和页面配置规范是基于微信小程序而制定的,并对全平台进行统一。 你可以通过访问 React 入口组件 和 Vue 入口组件,了解全部页面钩子函数和页面配置规范。
自定义组件
如果你看到这里,那不得不恭喜你,你已经理解了 Taro 中最复杂的概念:入口组件和页面组件,并了解了它们是如何(通过配置文件)交互的。接下来的内容,如果你已经熟悉了 或 以及 Web 开发的话,那就太简单了:
我们先把首页写好,首页的逻辑很简单:把论坛最新的帖子展示出来。
import Taro from '@tarojs/taro'import React from 'react'import { View } from '@tarojs/components'import { ThreadList } from '../../components/thread_list'import api from '../../utils/api'import './index.css'class Index extends React.Component {config = {navigationBarTitleText: '首页'}state = {loading: true,threads: []}async componentDidMount () {try {const res = await Taro.request({url: api.getLatestTopic()})this.setState({threads: res.data,loading: false})} catch (error) {Taro.showToast({title: '载入远程数据错误'})}}render () {const { loading, threads } = this.statereturn (<View className='index'><ThreadListthreads={threads}loading={loading}/></View>)}}export default Index
<template><view class='index'><thread-list:threads="threads":loading="loading"/></view></template><script>import Vue from 'vue'import Taro from '@tarojs/taro'import api from '../../utils/api'import ThreadList from '../../components/thread_list.vue'export default {components: {'thread-list': ThreadList},data () {return {loading: true,threads: []}},async created() {try {const res = await Taro.request({url: api.getLatestTopic()})this.loading = falsethis.threads = res.data} catch (error) {Taro.showToast({title: '载入远程数据错误'})}}}</script>
了解更多 可能你会注意到在一个 Taro 应用中发送请求是 Taro.request() 完成的。 和页面配置、全局配置一样,Taro 的 API 规范也是基于微信小程序而制定的,并对全平台进行统一。 你可以通过在 API 文档 找到所有 API。 在我们的首页组件里,还引用了一个 ThreadList 组件,我们现在来实现它:
import React from 'react'import { View, Text } from '@tarojs/components'import { Thread } from './thread'import { Loading } from './loading'import './thread.css'class ThreadList extends React.Component {static defaultProps = {threads: [],loading: true}render () {const { loading, threads } = this.propsif (loading) {return <Loading />}const element = threads.map((thread, index) => {return (<Threadkey={thread.id}node={thread.node}title={thread.title}last_modified={thread.last_modified}replies={thread.replies}tid={thread.id}member={thread.member}/>)})return (<View className='thread-list'>{element}</View>)}}export { ThreadList }
import Taro, { eventCenter } from '@tarojs/taro'import React from 'react'import { View, Text, Navigator, Image } from '@tarojs/components'import api from '../utils/api'import { timeagoInst, Thread_DETAIL_NAVIGATE } from '../utils'class Thread extends React.Component {handleNavigate = () => {const { tid, not_navi } = this.propsif (not_navi) {return}eventCenter.trigger(Thread_DETAIL_NAVIGATE, this.props)// 跳转到帖子详情Taro.navigateTo({url: '/pages/thread_detail/thread_detail'})}render () {const { title, member, last_modified, replies, node, not_navi } = this.propsconst time = timeagoInst.format(last_modified * 1000, 'zh')const usernameCls = `author ${not_navi ? 'bold' : ''}`return (<View className='thread' onClick={this.handleNavigate}><View className='info'><View><Image src={member.avatar_large} className='avatar' /></View><View className='middle'><View className={usernameCls}>{member.username}</View><View className='replies'><Text className='mr10'>{time}</Text><Text>评论 {replies}</Text></View></View><View className='node'><Text className='tag'>{node.title}</Text></View></View><Text className='title'>{title}</Text></View>)}}export { Thread }
<template><view className='thread-list'><loading v-if="loading" /><threadv-elsev-for="t in threads":key="t.id":node="t.node":title="t.title":last_modified="t.last_modified":replies="t.replies":tid="t.id":member="t.member"/></view></template><script >import Vue from 'vue'import Loading from './loading.vue'import Thread from './thread.vue'export default {components: {'loading': Loading,'thread': Thread},props: {threads: {type: Array,default: []},loading: {type: Boolean,default: true}}}</script>
<template><view class='thread' @tap="handleNavigate"><view class='info'><view><image :src="member.avatar_large | url" class='avatar' /></view><view class='middle'><view :class="usernameCls">{{member.username}}</view><view class='replies'><text class='mr10'>{{time}}</text><text>评论 {{replies}}</text></view></view><view class='node'><text class='tag'>{{node.title}}</Text></view></view><text class='title'>{{title}}</text></view></template><script>import Vue from 'vue'import { eventCenter } from '@tarojs/taro'import Taro from '@tarojs/taro'import { timeagoInst, Thread_DETAIL_NAVIGATE } from '../utils'import './thread.css'export default {props: ['title', 'member', 'last_modified', 'replies', 'node', 'not_navi', 'tid'],computed: {time () {return timeagoInst.format(this.last_modified * 1000, 'zh')},usernameCls () {return `author ${this.not_navi ? 'bold' : ''}`}},filters: {url (val) {return 'https:' + val}},methods: {handleNavigate () {const { tid, not_navi } = this.$propsif (not_navi) {return}eventCenter.trigger(Thread_DETAIL_NAVIGATE, this.$props)// 跳转到帖子详情Taro.navigateTo({url: '/pages/thread_detail/thread_detail'})}}}</script>
这里可以发现我们把论坛帖子渲染逻辑拆成了两个组件,并放在 src/components 文件中,因为这些组件是会在其它页面中多次用到。 拆分组件的力度是完全由开发者决定的,Taro 并没有规定组件一定要放在 components 文件夹,也没有规定页面一定要放在 pages 文件夹。
另外一个值得注意的点是:我们并没有使用 div/span 这样的 HTML 组件,而是使用了 View/Text 这样的跨平台组件。
了解更多 Taro 文档的跨平台组件库 包含了所有组件参数和用法。但目前组件库文档中的参数和组件名都是针对 React 的(除了 React 的点击事件是 onClick 之外)。 对于 Vue 而言,组件名和组件参数都采用短横线风格(kebab-case)的命名方式,例如:<picker-view indicator-class="myclass" />
路由与 Tabbar
在 src/components/thread 组件中,我们通过
Taro.navigateTo({ url: '/pages/thread_detail/thread_detail' })
跳转到帖子详情,但这个页面仍未实现,现在我们去入口文件配置一个新的页面:
export default {pages: ['pages/index/index','pages/thread_detail/thread_detail']}
然后在路径 src/pages/thread_detail/thread_detail 实现帖子详情页面,路由就可以跳转,我们整个流程就跑起来了:
import Taro from '@tarojs/taro'import React from 'react'import { View, RichText, Image } from '@tarojs/components'import { Thread } from '../../components/thread'import { Loading } from '../../components/loading'import api from '../../utils/api'import { timeagoInst, GlobalState } from '../../utils'import './index.css'function prettyHTML (str) {const lines = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6']lines.forEach(line => {const regex = new RegExp(`<${line}`, 'gi')str = str.replace(regex, `<${line} class="line"`)})return str.replace(/<img/gi, '<img class="img"')}class ThreadDetail extends React.Component {state = {loading: true,replies: [],content: '',thread: {}} as IStateconfig = {navigationBarTitleText: '话题'}componentWillMount () {this.setState({thread: GlobalState.thread})}async componentDidMount () {try {const id = GlobalState.thread.tidconst [{ data }, { data: [ { content_rendered } ] } ] = await Promise.all([Taro.request({url: api.getReplies({'topic_id': id})}),Taro.request({url: api.getTopics({id})})])this.setState({loading: false,replies: data,content: prettyHTML(content_rendered)})} catch (error) {Taro.showToast({title: '载入远程数据错误'})}}render () {const { loading, replies, thread, content } = this.stateconst replieEl = replies.map((reply, index) => {const time = timeagoInst.format(reply.last_modified * 1000, 'zh')return (<View className='reply' key={reply.id}><Image src={reply.member.avatar_large} className='avatar' /><View className='main'><View className='author'>{reply.member.username}</View><View className='time'>{time}</View><RichText nodes={reply.content} className='content' /><View className='floor'>{index + 1} 楼</View></View></View>)})const contentEl = loading? <Loading />: (<View><View className='main-content'><RichText nodes={content} /></View><View className='replies'>{replieEl}</View></View>)return (<View className='detail'><Threadnode={thread.node}title={thread.title}last_modified={thread.last_modified}replies={thread.replies}tid={thread.id}member={thread.member}not_navi={true}/>{contentEl}</View>)}}export default ThreadDetail
<template><view class='detail'><thread:node="topic.node":title="topic.title":last_modified="topic.last_modified":replies="topic.replies":tid="topic.id":member="topic.member":not_navi="true"/><loading v-if="loading" /><view v-else><view class='main-content'><rich-text :nodes="content | html" /></view><view class='replies'><view v-for="(reply, index) in replies" class='reply' :key="reply.id"><image :src='reply.member.avatar_large' class='avatar' /><view class='main'><view class='author'>{{reply.member.username}}</view><view class='time'>{{reply.last_modified | time}}</view><rich-text :nodes="reply.content_rendered | html" class='content' /><view class='floor'>{{index + 1}} 楼</view></view></view></view></view></view></template><script>import Vue from 'vue'import Taro from '@tarojs/taro'import api from '../../utils/api'import { timeagoInst, GlobalState, IThreadProps, prettyHTML } from '../../utils'import Thread from '../../components/thread.vue'import Loading from '../../components/loading.vue'import './index.css'export default {components: {'loading': Loading,'thread': Thread},data () {return {topic: GlobalState.thread,loading: true,replies: [],content: ''}},async created () {try {const id = GlobalState.thread.tidconst [{ data }, { data: [ { content_rendered } ] } ] = await Promise.all([Taro.request({url: api.getReplies({'topic_id': id})}),Taro.request({url: api.getTopics({id})})])this.loading = falsethis.replies = datathis.content = content_rendered} catch (error) {Taro.showToast({title: '载入远程数据错误'})}},filters: {time (val) {return timeagoInst.format(val * 1000)},html (val) {return prettyHTML(val)}}}</script>
到目前为止,我们已经实现了这个应用的所有逻辑,除去「节点列表」页面(在进阶指南我们会讨论这个页面组件)之外,剩下的页面都可以通过我们已经讲解过的组件或页面快速抽象完成。按照我们的计划,这个应用会有五个页面,分别是:
- 首页,展示最新帖子(已完成)
- 节点列表
- 热门帖子(可通过组件复用)
- 节点帖子 (可通过组件复用)
- 帖子详情 (已完成)
其中前三个页面我们可以把它们规划在 tabBar 里,tabBar 是 Taro 内置的导航栏,可以在 app.config.js 配置,配置完成之后处于的 tabBar 位置的页面会显示一个导航栏。最终我们的 app.config.js 会是这样:
export default {pages: ['pages/index/index','pages/nodes/nodes','pages/hot/hot','pages/node_detail/node_detail','pages/thread_detail/thread_detail'],tabBar: {list: [{'iconPath': 'resource/latest.png','selectedIconPath': 'resource/lastest_on.png',pagePath: 'pages/index/index',text: '最新'}, {'iconPath': 'resource/hotest.png','selectedIconPath': 'resource/hotest_on.png',pagePath: 'pages/hot/hot',text: '热门'}, {'iconPath': 'resource/node.png','selectedIconPath': 'resource/node_on.png',pagePath: 'pages/nodes/nodes',text: '节点'}],'color': '#000','selectedColor': '#56abe4','backgroundColor': '#fff','borderStyle': 'white'},window: {backgroundTextStyle: 'light',navigationBarBackgroundColor: '#fff',navigationBarTitleText: 'V2EX',navigationBarTextStyle: 'black'}}