Taro 项目进阶与优化
状态管理
在我们实现帖子组件(src/components/thread)时,通过 Taro 内置的 eventCenter 发起了一个事件,把当前帖子的数据注入到一个全局的 GlobalState 中,然后在帖子详情页面再从 GlobalState 取出当前帖子的数据——这种简单的发布/订阅模式在处理简单逻辑时非常有效且清晰。
一旦我们的业务逻辑变得复杂,一个简单的发布订阅机制绑定到一个全局的 state 可能就会导致我们的数据流变得难以追踪。好在这个问题不管是在 React 还是 Vue 社区中都有很好的解决方案。我们会使用这两个社区最热门的状态管理工具:Redux 和 Vuex 来解决这个问题。
首先安装 redux 和 react-redux:
npm i redux react-redux
在入口文件使用 react-redux 的 Provider 注入 context 到我们的应用:
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 设置当前的帖子:
- 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 获取当前帖子的数据:
- 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:
npm i vuex
在入口文件中注入 Vuex 的 store:
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() 设置当前的帖子:
- eventCenter.trigger(Thread_DETAIL_NAVIGATE, this.props)+ this.$store.setThread(this.$props)
在帖子详情组件中通过 computed 获取当前帖子的数据:
{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 预处理器和后处理器,使用的方法也非常简单,只要在编译配置添加相关的插件即可:
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 Modules 和 CSS-in-JS。 原理上还支持更多 CSS 工具,我们将在下面的自定义编译继续讨论这个问题。
渲染 HTML
在帖子详情组件(ThreadDetail)中,我们使用了内置组件 RichText 来渲染 HTML,但这个组件的兼容性不好,无法在所有端都正常使用,某些特定的 HTML 元素也无法渲染。
幸运的是,Taro 内置了 HTML 渲染,使用方法也和 React/Vue 在 Web 开发中没什么区别:
- <RichText nodes={reply.content} className='content' />+ <View dangerouslySetInnerHTML={{ __html: reply.content }} className='content'></View>
- <rich-text :nodes="reply.content_rendered | html" class='content' />+ <view v-html="reply.content_rendered | html" class='content' />
info 了解更多 Taro 内置的 HTML 渲染功能不仅可以按 Web 开发的方式去使用,也支持自定义样式、自定义渲染、自定义事件这样的高级功能。 你可以访问 HTML 渲染文档 了解更多。
性能优化
虚拟列表
在帖子列表组件(ThreadList)中,我们直接渲染从远程得来的数据。这样做没有什么问题,但如果我们的数据非常庞大,或者列表渲染的 DOM 结构异常复杂,这就可能会产生性能问题。
为了解决这一问题,Taro 内置了虚拟列表(VirtualList)功能,比起全量渲染所有列表数据,我们只需要渲染当前可视区域(visable viewport)的视图:
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 (<Threadkey={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.propsif (loading) {return <Loading />}const element = (<VirtualListheight={800} /* 列表的高度 */width='100%' /* 列表的宽度 */itemData={threads} /* 渲染列表�的数据 */itemCount={threads.length} /* 渲染列表的长度 */itemSize={100} /* 列表单项的高度 */>{Row} /* 列表单项组件,这里只能传入一个组件 */</VirtualList>)return (<View className='thread-list'>{element}</View>)}}export { ThreadList }
// 在入口文件新增使用插件import VirtualList from `@tarojs/components/virtual-list`Vue.use(VirtualList)
<template><thread:key="data.id":node="data.node":title="data.title":last_modified="data.last_modified":replies="data.replies":tid="data.id":member="data.member"/></template><script>import Thread from './thread.vue'export default {components: {'thread': Thread},props: ['index', 'data', 'css']}</script>
<template><view className='thread-list'><loading v-if="loading" /><virtual-listv-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>
了解更多:在文档 虚拟列表 你可以找到虚拟列表的一些高级用法,例如:无限滚动、滚动偏移、滚动事件等。
预渲染
现在我们来实现最后一个页面:节点列表页面。这个页面本质说就是渲染一个存在本地的巨大列表:
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 (<NavigatorclassName='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
<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'><navigatorv-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 字符串,这样就获得了与原生小程序一致甚至更快的速度。
使用预渲染也非常简单,我们只要进行简单的配置即可:
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.jsmodule.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 中添加如下配置:
const config = {...mini: {webpackChain (chain, webpack) {chain.plugin('analyzer').use(require('webpack-bundle-analyzer').BundleAnalyzerPlugin, [])}}}
运行编译命令完成之后就可以看到各文件依赖关系及体积。
你可以访问 webpack-bundle-analyzer 文档了解详细的用法。
分包
在一些情况,我们希望我们的页面只有当用到时才按需进行加载。这种情况在 Taro 应用被称为分包,分包的使用也非常简单,只需要通过配置入口文件 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 来进行开发:
const config = {...mini: {webpackChain (chain, webpack) {chain.merge({module: {rule: {test: /\.coffee$/,use: [ 'coffee-loader' ]}}})}}}
同样,之前我们提到过的 CSS Modules 也可以通过 Webpack 的形式进行拓展支持。详情可以访问 webpack-chain 文档了解详细的用法。
使用插件化系统进行拓展
在 [CSS 工具](./guide#CSS 工具) 我们已经使用了名为 @tarojs/plugin-sass 的插件来实现对 Sass 的支持。比起使用 Webpack 拓展编译,Taro 的插件功能不用在每个端都对 Webpack 进行配置,只用使用插件即可。
除此之外,Taro 的插件化功能还可以拓展 Taro CLI 编译命令,拓展编译流程,拓展编译平台,你可以访问 插件功能文档 了解更多自定义配置的信息。
了解更多:除了以上两种方式外,Taro 还提供大量的编译相关选项,你可以访问编译配置详情文档了解更多。