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项目都有一个入口组件和一个入口配置,可以在入口组件中设置全局状态/全局生命周期,一个最小化的入口组件会是这样:

React版本:src/app.js

import React, { Component } from 'react'
import './app.css'

class App extends Component {
  render () {
    // this.props.children 是将要会渲染的页面
    return this.props.children
  }
}

// 每一个入口组件都必须导出一个 React 组件
export default App

Vue版本:src/app.js

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),我们可以在全局配置文件中设置页面组件的路径、全局窗口、路由等信息,一个最简单的全局配置如下:

React版本: src/app.config.js

export default {
  pages: [
    'pages/index/index'
  ]
}

Vue版本: src/app.config.js

export default {
  pages: [
    'pages/index/index'
  ]
}

注意到,不管是React还是Vue,两者的全局配置是一样的。这是在配置文件中,Taro并不关心框架的区别,Taro CLI会直接在编译时在Node.js环境直接执行全局配置的代码,并把export default导出的对象序列化为一个JSON文件。

页面组件

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

React版本:src/pages/index/index.jsx

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

Vue版本:src/pages/index/index.vue

<template>
  <view>
    {{ msg }}
  </view>
</template>

<script>

export default {
  data() {
    return {
      msg: 'Hello World!'
    };
  },
  onReady () {
    console.log('onReady')
  }
};
</script>

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

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

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

  • src/pages/index/index.config.js
export default {
  navigationBarTitleText: '首页'
}

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

自定义组件

已经理解了Taro中的入口组件和页面组件,并了解了它们是如何(通过配置文件)交互的。接下来的内容,实现如何自定义组件。

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

React版本:src/pages/index/index.jsx

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.state
    return (
      <View className='index'>
        <ThreadList
          threads={threads}
          loading={loading}
        />
      </View>
    )
  }
}

export default Index

Vue版本:src/pages/index/index.vue

<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: '载入远程数据错误'
      })
    }
  }
}
</script>

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

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

React版本:src/components/thread_list.jsx

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 }

React版本:src/components/thread.jsx

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)
    // 跳转到帖子详情
    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>
                评论 {replies}
              </Text>
            </View>
          </View>
          <View className='node'>
            <Text className='tag'>
              {node.title}
            </Text>
          </View>
        </View>
        <Text className='title'>
          {title}
        </Text>
      </View>
    )
  }
}

export { Thread }

Vue版本:src/components/thread_list.vue

<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>

Vue版本:src/components/thread.vue

<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.$props
      if (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' })

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

export default {
  pages: [
    'pages/index/index',
    'pages/thread_detail/thread_detail'
  ]
}

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

React版本:src/pages/thread_detail/thread_detail.jsx

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: '载入远程数据错误'
      })
    }
  }

  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

Vue版本:src/pages/thread_detail/thread_detail.vue

<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: '载入远程数据错误'
      })
    }
  },
  filters: {
    time (val) {
      return timeagoInst.format(val * 1000)
    },
    html (val) {
      return prettyHTML(val)
    }
  }
}
</script>

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

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

其中前三个页面我们可以把它们规划在tabBar里,tabBarTaro内置的导航栏,可以在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'
  }
}

项目进阶与优化

状态管理

在我们实现帖子组件(src/components/thread)时,通过Taro内置的eventCenter发起了一个事件,把当前帖子的数据注入到一个全局的GlobalState中,然后在帖子详情页面再从GlobalState取出当前帖子的数据——这种简单的发布/订阅模式在处理简单逻辑时非常有效且清晰。

一旦我们的业务逻辑变得复杂,一个简单的发布订阅机制绑定到一个全局的state可能就会导致我们的数据流变得难以追踪。好在这个问题不管是在React还是Vue社区中都有很好的解决方案。我们会使用这两个社区最热门的状态管理工具:ReduxVuex来解决这个问题。

Redux

首先安装reduxreact-redux

npm i redux react-redux

在入口文件使用react-reduxProvider注入context到我们的应用:

src/app.js

import React, { Component } from 'react'
import { Provider } from 'react-redux'
import { createStore, combineReducers } from 'redux';
import './app.css'

const reducers = combineReducers({
  thread: (state = {}, action) => {
    if (action.type === 'SET_CURRENT_THREAD') {
      return {
        ...state,
        ...action.thread
      }
    }
    return state
  }
})

const store = createStore(reducers)

class App extends Component {
  render () {
    // this.props.children 是将要会渲染的页面
    return (
      <Provider store={store}>
        {this.props.children}
      </Provider>
    )
  }
}

export default App

然后在帖子组件中我们就可以通过connect一个dispatch设置当前的帖子:

src/components/thread.jsx

- eventCenter.trigger(Thread_DETAIL_NAVIGATE, this.props)
+ this.props.setThread(this.props)
- export default Thread
+ const mapDispatchToProps = dispatch => {
+   return {
+     setThread: thread => dispatch({ type: 'SET_CURRENT_THREAD', thread })
+   }
+ }
+ export default connect(null, mapDispatchToProps)(Thread)

在帖子详情组件中通过connect一个mapStateToProps获取当前帖子的数据:

src/components/thread_detail.jsx

- const id = GlobalState.thread.tid
+ const id = this.props.thread.tid
- export default ThreadDetail
+ function mapStateToProps(state) {
+  return { thread: state.thread }
+ }
+ export default connect(mapStateToProps)(ThreadDetail)

此教程演示的是Redux极简用法,而非最佳实践。详情请访问 Redux 文档react-redux 文档

Vuex

首先安装vuex

npm i vuex

在入口文件中注入 Vuex 的store

src/app.js
import Vue from 'vue'
import './app.css'

const store = new Vuex.Store({
  state: {
    thread: {}
  },
  mutations: {
    setThread: (state, thread) => {
      state.thread = { ...thread }
    }
  }
})

const App = new Vue({
  store,
  render(h) {
    // this.$slots.default 是将要会渲染的页面
    return h('block', this.$slots.default)
  }
})

export default App

然后在帖子组件中我们就可以通过this.$store.setThread()设置当前的帖子:

src/components/thread.vue

- eventCenter.trigger(Thread_DETAIL_NAVIGATE, this.props)
+ this.$store.setThread(this.$props)

在帖子详情组件中通过 computed 获取当前帖子的数据:

src/components/thread_detail.vue

{
  data () {
    return {
-     topic: GlobalState.thread,
      loading: true,
      replies: [],
      content: ''
    }
  },
+ computed: {
+  topic() {
+    return this.$store.state.thread
+  }
+ }
}

此教程演示的是 Vuex 极简用法,而非最佳实践。详情请访问 Vuex 文档

其它状态管理工具

原理上来说,Taro可以支持任何兼容 React 或 Vue 的状态管理工具,使用这类工具通常都会要求在入口组件注入context,而在Taro中入口文件是不能渲染 UI 的,只要注意这点即可。

在 Vue 生态圈我们推荐使用Vuex。React 生态圈状态管理工具百花齐放,考虑到使用Taro的开发者很多应用会编译到小程序,我们推荐几个在性能或体积上有优势的状态管理工具:

  • mobx-react: 和 Vuex 一样响应式的状态管理工具
  • unstaged: 基于 React Hooks 的极简状态管理工具,压缩体积只有 200 字节
  • Recoil: Facebook 推出的基于 React Hooks 的状态管理工具

CSS 工具

Taro中,我们可以自由地使用 CSS 预处理器和后处理器,使用的方法也非常简单,只要在编译配置添加相关的插件即可:

config/index.js

const config = {
  projectName: 'v2ex',
  date: '2018-8-3',
  designWidth: 750,
  sourceRoot: 'src',
  outputRoot: 'dist',
  plugins: [
    '@tarojs/plugin-sass', // 使用 Sass
    // '@tarojs/plugin-less', // 使用 Less
    // '@tarojs/plugin-stylus', // 使用 Stylus
  ],
  defineConstants: {
  },
  mini: {

  },
  h5: {
    publicPath: '/',
    staticDirectory: 'static',
    module: {
      postcss: {
        autoprefixer: {
          enable: true
        }
      }
    }
  }
}

module.exports = function (merge) {
  if (process.env.NODE_ENV === 'development') {
    return merge({}, config, require('./dev'))
  }
  return merge({}, config, require('./prod'))
}

除了 CSS 预处理器之外,Taro 还支持 CSS ModulesCSS-in-JS。 原理上还支持更多 CSS 工具,我们将在自定义编译继续讨论这个问题。

渲染 HTML

在帖子详情组件(ThreadDetail)中,我们使用了内置组件RichText来渲染 HTML,但这个组件的兼容性不好,无法在所有端都正常使用,某些特定的 HTML 元素也无法渲染。

幸运的是,Taro 内置了 HTML 渲染,使用方法也和 React/Vue 在 Web 开发中没什么区别:

React版本:src/pages/thread_detail/thread_detail.jsx

- <RichText nodes={reply.content} className='content' />
+ <View dangerouslySetInnerHTML={{ __html: reply.content }} className='content'></View>

Vue版本:src/pages/thread_detail/thread_detail.vue

- <rich-text :nodes="reply.content_rendered | html" class='content' />
+ <view v-html="reply.content_rendered | html" class='content' />

Taro 内置的 HTML 渲染功能不仅可以按 Web 开发的方式去使用,也支持自定义样式、自定义渲染、自定义事件这样的高级功能。 可以访问 HTML 渲染文档 了解更多。

性能优化

虚拟列表

在帖子列表组件(ThreadList)中,我们直接渲染从远程得来的数据。这样做没有什么问题,但如果我们的数据非常庞大,或者列表渲染的 DOM 结构异常复杂,这就可能会产生性能问题。

为了解决这一问题,Taro内置了虚拟列表(VirtualList)功能,比起全量渲染所有列表数据,我们只需要渲染当前可视区域(visable viewport)的视图:

React版本:src/pages/thread_detail/thread_list.jsx

import React from 'react'
import { View, Text } from '@tarojs/components'
import { Thread } from './thread'
import { Loading } from './loading'
import VirtualList from `@tarojs/components/virtual-list`

import './thread.css'

const Row = React.memo(({ thread }) => {
  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}
    />
  )
})

class ThreadList extends React.Component {
  static defaultProps = {
    threads: [],
    loading: true
  }

  render () {
    const { loading, threads } = this.props

    if (loading) {
      return <Loading />
    }

    const element = (
      <VirtualList
        height={800} /* 列表的高度 */
        width='100%' /* 列表的宽度 */
        itemData={threads} /* 渲染列表的数据 */
        itemCount={threads.length} /*  渲染列表的长度 */
        itemSize={100} /* 列表单项的高度  */
      >
        {Row} /* 列表单项组件,这里只能传入一个组件 */
      </VirtualList>
    )

    return (
      <View className='thread-list'>
        {element}
      </View>
    )
  }
}

export { ThreadList }

Vue版本

  • app.js
// 在入口文件新增使用插件
import VirtualList from `@tarojs/components/virtual-list`
Vue.use(VirtualList)
  • src/components/row.vue
<template>
  <thread
    :key="thread.id"
    :node="thread.node"
    :title="thread.title"
    :last_modified="thread.last_modified"
    :replies="thread.replies"
    :tid="thread.id"
    :member="thread.member"
  />
</template>

<script>
import Thread from './thread.vue'
export default {
  components: {
    'thread': Thread
  },
  props: ['index', 'data', 'css'],
  computed:{
    thread(){
      return this.data[this.index]
    }
  }
}
</script>
  • src/components/thread_list.vue
<template>
  <view className='thread-list'>
    <loading v-if="loading" />
    <virtual-list
      v-else
      :height="500"
      :item-data="threads"
      :item-count="threads.length"
      :item-size="100"
      :item="Row"
      width="100%"
    />
  </view>
</template>

<script >
import Vue from 'vue'
import Loading from './loading.vue'
import Thread from './thread.vue'
import Row from './row.vue'
export default {
  components: {
    'loading': Loading,
    'thread': Thread
  },
  props: {
    threads: {
      type: Array,
      default: []
    },
    loading: {
      type: Boolean,
      default: true
    }
  }
}
</script>

在文档 虚拟列表 可以找到虚拟列表的一些高级用法,例如:无限滚动、滚动偏移、滚动事件等。

预渲染

现在我们来实现最后一个页面:节点列表页面。这个页面本质说就是渲染一个存在本地的巨大列表:

React版本:src/pages/nodes/nodes.jsx

import React from 'react'
import { View, Text, Navigator } from '@tarojs/components'
import allNodes from './all_node'
import api from '../../utils/api'

import './nodes.css'

function Nodes () {
  const element = allNodes.map(item => {
    return (
      <View key={item.title} className='container'>
        <View className='title'>
          <Text style='margin-left: 5px'>{item.title}</Text>
        </View>
        <View className='nodes'>
          {item.nodes.map(node => {
            return (
              <Navigator
                className='tag'
                url={`/pages/node_detail/node_detail${api.queryString(node)}`}
                key={node.full_name}
              >
                <Text>{node.full_name}</Text>
              </Navigator>
            )
          })}
        </View>
      </View>
    )
  })
  return <View className='node-container'>{element}</View>
}

export default Nodes

Vue版本:src/pages/nodes/nodes.vue

<template>
  <view class='node-container'>
    <view v-for="item in allNodes" :key="item.title" class='container'>
      <view class='title'>
        <text style='margin-left: 5px'>{{item.title}}</text>
      </view>
      <view class='nodes'>
        <navigator
          v-for="node in item.nodes"
          :key="node.full_name"
          class='tag'
          :url="node | url"
        >
          <text>{{node.full_name}}</text>
        </navigator>
      </view>
    </view>
  </view>
</template>

<script>
import Vue from 'vue'
import allNodes from './all_node'
import api from '../../utils/api'
import './nodes.css'

function getURL (node) {
  return `/pages/node_detail/node_detail${api.queryString(node)}`
}

export default {
  data () {
    return {
      allNodes
    }
  },
  filters: {
    url (node) {
      return getURL(node)
    }
  }
}
</script>

这个时候我们整个应用就完成了。但如果把这个应用放在真机小程序中,尤其是一些性能不高的真机中,切换到此页面的时间可能会比较长,会有一段白屏时间。

这是由于Taro的渲染机制导致的:在页面初始化时,原生小程序可以从本地直接取数据渲染,但Taro会把初始数据通过 React/Vue 渲染成一颗 DOM 树,然后将这颗 DOM 树序列化之后交给小程序渲染。也就是说,比起原生小程序Taro会在页面初始化时多一次调用setData函数的支出——而大部分小程序的性能问题是setData数据过大导致的。

为了解决这个问题,Taro引入了一种名为预渲染(Prerender)的技术,和服务端渲染一样,在 Taro CLI 直接将要渲染的页面转换为wxml字符串,这样就获得了与原生小程序一致甚至更快的速度。

使用预渲染也非常简单,我们只要进行简单的配置即可:

config/prod.js

const config = {
  ...
  mini: {
    prerender: {
      include: ['pages/nodes/nodes'], // `pages/nodes/nodes` 也会参与 prerender
    }
  }
};

// 我们这里在编译生产模式时才开启预渲染
// 如果需要开发时也开启,那就把配置放在 `config/index` 或 `config/dev`
module.exports = config

预渲染的配置支持条件渲染页面、条件渲染逻辑、自定义渲染函数等功能,详情可以访问 预渲染文档

打包体积

默认而言使用生产模式打包,Taro就会优化打包体积。但值得注意,Taro默认的打包配置是为了让多数项目和需求都可以运行,而不是针对任何项目的最优选择。因此可以在Taro配置的基础之上再针对自己的项目进行优化。

JavaScript

Taro应用中,所有 Java(Type)Script 都是通过babel.config.js配置的,具体来说是使用 babel-prest-taro 这个 Babel 插件编译的。

默认而言Taro会兼容所有 @babel/preset-env 支持的语法,并兼容到 iOS 9 和 Android 5,如果不需要那么高的兼容性,或者不需要某些 ES2015+ 语法支持,可以自行配置babel.config.js达到缩小打包体积效果。

例如我们可以把兼容性提升到 iOS 12:

babel.config.js

// babel.config.js
module.exports = {
  presets: [
    ['taro', {
      targets: {
        ios: '12'
      }
    }]
  ]
}

可以访问 Babel 文档 了解更多自定义配置的信息。

打包体积分析

Taro使用 Webpack 作为内部的打包系统,有时候当我们的业务代码使用了require语法或者import default语法,Webpack 并不能给我们提供tree-shaking的效果。在这样的情况下我们通过 webpack-bundle-analyzer 来分析我们依赖打包体积,这个插件会在浏览器打开一个可视化的图表页面告诉我们引用各个包的体积。

首先安装webpack-bundle-analyzer依赖:

npm install webpack-bundle-analyzer -D

然后在 mini.webpackChain 中添加如下配置:

config/index

const config = {
  ...
  mini: {
    webpackChain (chain, webpack) {
      chain.plugin('analyzer')
        .use(require('webpack-bundle-analyzer').BundleAnalyzerPlugin, [])
    }
  }
}

运行编译命令完成之后就可以看到各文件依赖关系及体积。

也可以访问 webpack-bundle-analyzer 文档了解详细的用法。

分包

在一些情况,我们希望我们的页面只有当用到时才按需进行加载。这种情况在Taro应用被称为分包,分包的使用也非常简单,只需要通过配置入口文件app.config.js即可。

假设我们需要把刚刚实现预渲染的所有节点页面进行分包:

src/app.config.js

export default {
  pages: [
    'pages/index/index',
    // 'pages/nodes/nodes', 把要分包的页面从 `pages` 字段中删除
    'pages/hot/hot',
    'pages/node_detail/node_detail',
    'pages/thread_detail/thread_detail'
  ],
  // 在 `subpackages` 字段添加分包
  "subpackages": [
    {
      "root": "pages",
      "pages": [
        "nodes/nodes"
      ]
    }
  ]
  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'
  }
}

自定义编译

在特定的情况下,Taro自带的编译系统没有办法满足我们的编译需求,这时Taro提供了两种拓展编译的方案:

使用 Webpack 进行拓展

打包体积分析中我们在mini.webpackChain添加了一个 Webpack 插件,达到了打包体积/依赖分析的效果。

事实上通过mini.webpackChain这个配置我们可以几乎使用任何 Webpack 生态的插件和 loader,例如我们想使用CoffeeScript来进行开发:

config/index

const config = {
  ...
  mini: {
    webpackChain (chain, webpack) {
      chain.merge({
          module: {
              rule: {
                  test: /\.coffee$/,
                  use: [ 'coffee-loader' ]
              }
          }
      })
    }
  }
}

同样,之前我们提到过的CSS Modules也可以通过 Webpack 的形式进行拓展支持。详情可以访问 webpack-chain 文档了解详细的用法。

使用插件化系统进行拓展

CSS 工具我们已经使用了名为@tarojs/plugin-sass的插件来实现对Sass的支持。比起使用 Webpack 拓展编译,Taro的插件功能不用在每个端都对 Webpack 进行配置,只要使用插件即可。

除此之外,Taro的插件化功能还可以拓展Taro CLI编译命令,拓展编译流程和编译平台,可以访问 插件功能文档 了解更多自定义配置的信息。

除了以上两种方式外,Taro 还提供大量的编译相关选项,可以访问 编译配置详情 文档了解更多。

多端开发

跨平台开发

在某些情况下,不同平台的表现或业务逻辑有质的不同。在这样的情况下我们是没有办法做到「一套代码走天下」的。

例如我们正在实现 V2EX 论坛应用,当前的 API 没有办法在浏览器中跨域调用,因此我们需要在 H5 端使用另一份 API。我们可以通过内置环境变量来解决:

- import api from '../../utils/api'
// 我们可以根据不同的平台,引入不同的 API
+ let api
+ if (process.env.TARO_ENV === 'weapp') {
+  api = require('../../utils/api-weapp')
+ } else if (process.env.TARO_ENV === 'h5') {
+  api = require('../../utils/api-h5')
+ }

Taro还提供了统一接口的多端文件,通过不同的命名方式寻找依赖,在这类情况下,我们可以保留:

import api from '../../utils/api'

语句原封不动,修改我们的文件结构,在文件名和后缀名之间加上平台的名字:

.
└── utils
    ├── api.h5.js
    ├── api.weapp.js
    └── index.js

除了「内置环境变量」和「统一接口的多端文件」之外,Taro还提供了别的跨平台开发解决方案,可以访问文档 跨平台开发 了解更多。

同步调试

默认情况下,Taro会把各端打包后的文件都放在dist目录。如果想要多端同步调试的话先编译后的文件就会被后编译好的文件覆盖。

但我们可以通过修改编译配置的outputRoot达到多端同步调试的目的:

config/index.js

const config = {
  outputRoot: `dist/${process.env.TARO_ENV}`
}

在这样的配置下,微信小程序编译后的目录就会是dist/weapp,H5 编译后目录就会是dist/h5

使用原生小程序组件

某些情况下我们需要复用小程序既有生态,而小程序的组件/库通常是针对特定小程序写的,并不能直接在Taro上使用,需要一些额外的操作。

例如我们的论坛应用,在帖子详情可能服务端返回的是 MarkDown 格式,我们就需要towxml来渲染的我们的帖子,参考:https://github.com/sbfkcel/towxml

首先我们需要在帖子详情页面的配置文件中引用towxml

export default {
  "usingComponents": {
    "towxml":"../../towxml/towxml"
  }
}

然后使用towxml组件,这里必须记住的是不管是 React 还是 Vue,原生小程序组件声明需要是小写的

React版本:src/pages/thread_detail/thread_detail.jsx

- <View dangerouslySetInnerHTML={{ __html: reply.content }} className='content'></View>
+ <towxml nodes="{{reply.content}}" />

Vue版本:src/pages/thread_detail/thread_detail.vue

- <view v-html="reply.content_rendered | html" class='content' />
+ <towxml :nodes="reply.content_rendered | html" />

最后按照 towxml 文档调用即可。

一旦使用了原生小程序组件,Taro应用就失去了跨端的能力。

Taro还支持使用小程序插件,详情可以访问文档 使用小程序原生第三方组件和插件

版权声明:
作者:Joe.Ye
链接:https://www.appblog.cn/index.php/2023/03/18/taro-beginner-practical-battle/
来源:APP全栈技术分享
文章版权归作者所有,未经允许请勿转载。

THE END
分享
二维码
打赏
海报
Taro入门实战
基础教程 安装好Taro CLI之后可以通过taro init命令创建一个全新的项目,一个最小版本的Taro项目会包括以下文件: ├── babel.config.js # Babel ……
<<上一篇
下一篇>>
文章目录
关闭
目 录