安裝好 Taro CLI 之后可以通過(guò) ?taro init
? 命令創(chuàng)建一個(gè)全新的項(xiàng)目,你可以根據(jù)你的項(xiàng)目需求填寫(xiě)各個(gè)選項(xiàng),一個(gè)最小版本的 Taro 項(xiàng)目會(huì)包括以下文件:
├── babel.config.js # Babel 配置
├── .eslintrc.js # ESLint 配置
├── config # 編譯配置目錄
│ ├── dev.js # 開(kāi)發(fā)模式配置
│ ├── index.js # 默認(rèn)配置
│ └── prod.js # 生產(chǎn)模式配置
├── package.json # Node.js manifest
├── dist # 打包目錄
├── project.config.json # 小程序項(xiàng)目配置
├── src # 源碼目錄
│ ├── app.config.js # 全局配置
│ ├── app.css # 全局 CSS
│ ├── app.js # 入口組件
│ ├── index.html # H5 入口 HTML
│ └── pages # 頁(yè)面組件
│ └── index
│ ├── index.config.js # 頁(yè)面配置
│ ├── index.css # 頁(yè)面 CSS
│ └── index.jsx # 頁(yè)面組件,如果是 Vue 項(xiàng)目,此文件為 index.vue
我們以后將會(huì)講解每一個(gè)文件的作用,但現(xiàn)在,我們先把注意力聚焦在 ?src
? 文件夾,也就是源碼目錄:
每一個(gè) Taro 項(xiàng)目都有一個(gè)入口組件和一個(gè)入口配置,我們可以在入口組件中設(shè)置全局狀態(tài)/全局生命周期,一個(gè)最小化的入口組件會(huì)是這樣:
import React, { Component } from 'react'
import './app.css'
class App extends Component {
render () {
// this.props.children 是將要會(huì)渲染的頁(yè)面
return this.props.children
}
}
// 每一個(gè)入口組件都必須導(dǎo)出一個(gè) React 組件
export default App
import Vue from 'vue'
import './app.css'
const App = new Vue({
render(h) {
// this.$slots.default 是將要會(huì)渲染的頁(yè)面
return h('block', this.$slots.default)
}
})
export default App
每一個(gè)入口組件(例如 ?app.js
?)總是伴隨一個(gè)全局配置文件(例如 ?app.config.js
?),我們可以在全局配置文件中設(shè)置頁(yè)面組件的路徑、全局窗口、路由等信息,一個(gè)最簡(jiǎn)單的全局配置如下:
export default {
pages: [
'pages/index/index'
]
}
export default {
pages: [
'pages/index/index'
]
}
你可能會(huì)注意到,不管是 還是 ,兩者的全局配置是一樣的。這是在配置文件中,Taro 并不關(guān)心框架的區(qū)別,Taro CLI 會(huì)直接在編譯時(shí)在 Node.js 環(huán)境直接執(zhí)行全局配置的代碼,并把 ?export default
? 導(dǎo)出的對(duì)象序列化為一個(gè) JSON 文件。接下來(lái)我們要講到 頁(yè)面配置 也是同樣的執(zhí)行邏輯。
因此,我們必須保證配置文件是在 Node.js 環(huán)境中是可以執(zhí)行的,不能使用一些在 H5 環(huán)境或小程序環(huán)境才能運(yùn)行的包或者代碼,否則編譯將會(huì)失敗。
了解更多 Taro 的入口組件和全局配置規(guī)范是基于微信小程序而制定的,并對(duì)全平臺(tái)進(jìn)行統(tǒng)一。 你可以通過(guò)訪問(wèn)? React 入口組件? 和? Vue 入口組件?,以及?全局配置?了解入口組件和全局配置的詳情。
頁(yè)面組件是每一項(xiàng)路由將會(huì)渲染的頁(yè)面,Taro 的頁(yè)面默認(rèn)放在 ?src/pages
? 中,每一個(gè) Taro 項(xiàng)目至少有一個(gè)頁(yè)面組件。在我們生成的項(xiàng)目中有一個(gè)頁(yè)面組件:?src/pages/index/index
?,細(xì)心的朋友可以發(fā)現(xiàn),這個(gè)路徑恰巧對(duì)應(yīng)的就是我們 全局配置 的 ?pages
? 字段當(dāng)中的值。一個(gè)簡(jiǎn)單的頁(yè)面組件如下:
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
? 組件嗎!但還是有兩點(diǎn)細(xì)微的差別:
onReady
? 生命周期函數(shù)。這是來(lái)源于微信小程序規(guī)范的生命周期,表示組件首次渲染完畢,準(zhǔn)備好與視圖交互。Taro 在運(yùn)行時(shí)將大部分小程序規(guī)范頁(yè)面生命周期注入到了頁(yè)面組件中,同時(shí) React 或 Vue 自帶的生命周期也是完全可以正常使用的。View
? 組件。這是來(lái)源于 ?@tarojs/components
? 的跨平臺(tái)組件。相對(duì)于我們熟悉的 ?div
?、?span
? 元素而言,在 Taro 中我們要全部使用這樣的跨平臺(tái)組件進(jìn)行開(kāi)發(fā)。和入口組件一樣,每一個(gè)頁(yè)面組件(例如 ?index.vue
?)也會(huì)有一個(gè)頁(yè)面配置(例如 ?index.config.js
?),我們可以在頁(yè)面配置文件中設(shè)置頁(yè)面的導(dǎo)航欄、背景顏色等參數(shù),一個(gè)最簡(jiǎn)單的頁(yè)面配置如下:
export default {
navigationBarTitleText: '首頁(yè)'
}
了解更多 Taro 的頁(yè)面鉤子函數(shù)和頁(yè)面配置規(guī)范是基于微信小程序而制定的,并對(duì)全平臺(tái)進(jìn)行統(tǒng)一。 你可以通過(guò)訪問(wèn) ?React 入口組件? 和? Vue 入口組件?,了解全部頁(yè)面鉤子函數(shù)和頁(yè)面配置規(guī)范。
如果你看到這里,那不得不恭喜你,你已經(jīng)理解了 Taro 中最復(fù)雜的概念:入口組件和頁(yè)面組件,并了解了它們是如何(通過(guò)配置文件)交互的。接下來(lái)的內(nèi)容,如果你已經(jīng)熟悉了 或 以及 Web 開(kāi)發(fā)的話,那就太簡(jiǎn)單了:
我們先把首頁(yè)寫(xiě)好,首頁(yè)的邏輯很簡(jiǎn)單:把論壇最新的帖子展示出來(lái)。
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: '首頁(yè)'
}
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: '載入遠(yuǎn)程數(shù)據(jù)錯(cuò)誤'
})
}
}
render () {
const { loading, threads } = this.state
return (
<View className='index'>
<ThreadList
threads={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 = false
this.threads = res.data
} catch (error) {
Taro.showToast({
title: '載入遠(yuǎn)程數(shù)據(jù)錯(cuò)誤'
})
}
}
}
</script>
了解更多 可能你會(huì)注意到在一個(gè) Taro 應(yīng)用中發(fā)送請(qǐng)求是 ?Taro.request()
? 完成的。 和頁(yè)面配置、全局配置一樣,Taro 的 API 規(guī)范也是基于微信小程序而制定的,并對(duì)全平臺(tái)進(jìn)行統(tǒng)一。 你可以通過(guò)在 ?API 文檔? 找到所有 API。
在我們的首頁(yè)組件里,還引用了一個(gè) ?ThreadList
? 組件,我們現(xiàn)在來(lái)實(shí)現(xiàn)它:
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.props
if (loading) {
return <Loading />
}
const element = threads.map((thread, index) => {
return (
<Thread
key={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.props
if (not_navi) {
return
}
eventCenter.trigger(Thread_DETAIL_NAVIGATE, this.props)
// 跳轉(zhuǎn)到帖子詳情
Taro.navigateTo({
url: '/pages/thread_detail/thread_detail'
})
}
render () {
const { title, member, last_modified, replies, node, not_navi } = this.props
const 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>
評(píng)論 {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" />
<thread
v-else
v-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>評(píng)論 {{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.$props
if (not_navi) {
return
}
eventCenter.trigger(Thread_DETAIL_NAVIGATE, this.$props)
// 跳轉(zhuǎn)到帖子詳情
Taro.navigateTo({
url: '/pages/thread_detail/thread_detail'
})
}
}
}
</script>
這里可以發(fā)現(xiàn)我們把論壇帖子渲染邏輯拆成了兩個(gè)組件,并放在 ?src/components
? 文件中,因?yàn)檫@些組件是會(huì)在其它頁(yè)面中多次用到。 拆分組件的力度是完全由開(kāi)發(fā)者決定的,Taro 并沒(méi)有規(guī)定組件一定要放在 ?components
? 文件夾,也沒(méi)有規(guī)定頁(yè)面一定要放在 ?pages
? 文件夾。
另外一個(gè)值得注意的點(diǎn)是:我們并沒(méi)有使用 ?div
?/?span
? 這樣的 HTML 組件,而是使用了 ?View
?/?Text
? 這樣的跨平臺(tái)組件。
了解更多 Taro 文檔的?跨平臺(tái)組件庫(kù)? 包含了所有組件參數(shù)和用法。但目前組件庫(kù)文檔中的參數(shù)和組件名都是針對(duì) React 的(除了 React 的點(diǎn)擊事件是 ?onClick
? 之外)。 對(duì)于 Vue 而言,組件名和組件參數(shù)都采用短橫線風(fēng)格(kebab-case)的命名方式,例如:<picker-view indicator-class="myclass" />
在 ?src/components/thread
? 組件中,我們通過(guò)
Taro.navigateTo({ url: '/pages/thread_detail/thread_detail' })
跳轉(zhuǎn)到帖子詳情,但這個(gè)頁(yè)面仍未實(shí)現(xiàn),現(xiàn)在我們?nèi)ト肟谖募渲靡粋€(gè)新的頁(yè)面:
export default {
pages: [
'pages/index/index',
'pages/thread_detail/thread_detail'
]
}
然后在路徑 ?src/pages/thread_detail/thread_detail
? 實(shí)現(xiàn)帖子詳情頁(yè)面,路由就可以跳轉(zhuǎn),我們整個(gè)流程就跑起來(lái)了:
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 IState
config = {
navigationBarTitleText: '話題'
}
componentWillMount () {
this.setState({
thread: GlobalState.thread
})
}
async componentDidMount () {
try {
const id = GlobalState.thread.tid
const [{ 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: '載入遠(yuǎn)程數(shù)據(jù)錯(cuò)誤'
})
}
}
render () {
const { loading, replies, thread, content } = this.state
const 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'>
<Thread
node={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.tid
const [{ data }, { data: [ { content_rendered } ] } ] = await Promise.all([
Taro.request({
url: api.getReplies({
'topic_id': id
})
}),
Taro.request({
url: api.getTopics({
id
})
})
])
this.loading = false
this.replies = data
this.content = content_rendered
} catch (error) {
Taro.showToast({
title: '載入遠(yuǎn)程數(shù)據(jù)錯(cuò)誤'
})
}
},
filters: {
time (val) {
return timeagoInst.format(val * 1000)
},
html (val) {
return prettyHTML(val)
}
}
}
</script>
到目前為止,我們已經(jīng)實(shí)現(xiàn)了這個(gè)應(yīng)用的所有邏輯,除去「節(jié)點(diǎn)列表」頁(yè)面(在進(jìn)階指南我們會(huì)討論這個(gè)頁(yè)面組件)之外,剩下的頁(yè)面都可以通過(guò)我們已經(jīng)講解過(guò)的組件或頁(yè)面快速抽象完成。按照我們的計(jì)劃,這個(gè)應(yīng)用會(huì)有五個(gè)頁(yè)面,分別是:
其中前三個(gè)頁(yè)面我們可以把它們規(guī)劃在 ?tabBar
? 里,?tabBar
? 是 ?Taro
? 內(nèi)置的導(dǎo)航欄,可以在 ?app.config.js
? 配置,配置完成之后處于的 ?tabBar
? 位置的頁(yè)面會(huì)顯示一個(gè)導(dǎo)航欄。最終我們的 ?app.config.js
? 會(huì)是這樣:
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: '熱門(mén)'
}, {
'iconPath': 'resource/node.png',
'selectedIconPath': 'resource/node_on.png',
pagePath: 'pages/nodes/nodes',
text: '節(jié)點(diǎn)'
}],
'color': '#000',
'selectedColor': '#56abe4',
'backgroundColor': '#fff',
'borderStyle': 'white'
},
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#fff',
navigationBarTitleText: 'V2EX',
navigationBarTextStyle: 'black'
}
}
更多建議: