由于去年公司的小程序有跨端需求,于是陆陆续续做了部分基于Taro开发的小程序任务,后来觉得这段摸索的经历可以稍微整理成笔记,顺便分享给新人做参考。
笔记主要针对没有React/Taro经验的新人,如果内容有不准确的地方,还望指出,会及时更正。
环境搭建
这里直接丢官网文档。
由于公司小程序基于Taro2开发,这里只介绍Taro2相关功能。
前置知识
- 原生小程序开发经验;
- React基础;
- TypeScript基础(如果项目基于TS开发)。
前置知识是需要提前掌握的,否则可能会在开发中一脸懵逼,事倍功半。
Taro对比原生小程序
一些常见的使用场景
生命周期对比
原生小程序
Page({
data: {
text: "This is page data."
},
onLoad: function(options) {
// 页面创建时执行
},
onShow: function() {
// 页面出现在前台时执行
},
onReady: function() {
// 页面首次渲染完毕时执行
},
onHide: function() {
// 页面从前台变为后台时执行
},
onUnload: function() {
// 页面销毁时执行
},
onPullDownRefresh: function() {
// 触发下拉刷新时执行
},
onReachBottom: function() {
// 页面触底时执行
},
onShareAppMessage: function () {
// 页面被用户分享时执行
},
onPageScroll: function() {
// 页面滚动时执行
},
onResize: function() {
// 页面尺寸变化时执行
}
})
Taro class组件
export default class Example extends Component {
constructor(props) {
super(props);
this.state = {
text: "This is page data.",
};
}
// 对应onLoad方法,官方建议用constructor或componentDidMount(订阅、副作用时)替换,
// 该钩子在React中已废弃
componentWillMount() {}
// 对应onShow方法
componentDidShow() {}
// 对应onReady方法
componentDidMount() {}
// 对应onHide方法
componentDidHide() {}
// 对应onUnload方法
componentWillUnmount() {}
// 组件更新前执行(接收到新的props或state时),首次渲染不触发,
// 不能在这里setState,该钩子在React中已废弃
componentWillUpdate(prevProps, prevState) {}
// 接收到新的props时执行,推荐使用componentDidUpdate替换,该钩子在React中已废弃
componentWillReceiveProps(nextProps) {}
// 组件更新后执行
componentDidUpdate(prevProps, prevState) {
// 经常会在这里通过判断新旧数据的变化来做一些操作,比如
// if (prevProps.sessionId !== this.props.sessionId) {
// 已经登录了
// }
}
// 子组件是否需要重现渲染
shouldComponentUpdate(nextProps, nextState) {}
// onPullDownRefresh、onReachBottom、onShareAppMessage...与原生小程序一致
}
在React中已废弃的生命周期componentWillMount、componentWillUpdate、componentWillReceiveProps不建议使用。
Taro function组件
function Example() {
/**
* useEffect => React Hooks 提供的钩子
* 类似componentDidMount、componentDidUpdate、componentWillUnmount的组合
*/
// 默认使用(不传参),mounted和unmounted以及update时执行
useEffect(() => {
// 不能这里setState,否则会造成死循环
});
// 传[],mounted和unmounted时执行
useEffect(() => {
// ...
}, []);
// 传state,只有当该state发生变化时,才会执行
const [count, setCount] = useState(0);
useEffect(() => {
// 不能这里setState => count,否则会造成死循环
}, [count]);
// 返回函数时,unmounted阶段会自动执行,可用于销毁过时的对象
useEffect(() => {
const id = setInterval(() => {
setCount(count => count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
/**
* 其他 useDidShow、useDidHide、useReachBottom、useResize...
* 参考:https://taro-docs.jd.com/taro/docs/2.x/hooks
*/
return (<View></View>);
}
简单的示例
如果不熟悉React Hooks,建议从class组件开始学习、使用。
class组件
class组件的组成部分
- 定义defaultProps
- 定义state
- 组件生命周期
- render函数
父组件
import Taro, { Component } from "@tarojs/taro";
import { View } from "@tarojs/components";
// 公共组件
// import Empty from './empty';
// import Error from './error';
// 子组件
import Item from "./item";
export default class ExampleList extends Component {
// constructor...super为固定写法,可忽略
/*constructor(props) {
super(props);
}*/
// 定义state
state = {
list: [],
nothing: false,
error: false,
};
// ref方式访问组件(如果需要)
itemRefComp = Taro.createRef();
// 相当于原生小程序的page.json
config = {
navigationBarTitleText: "示例列表",
enablePullDownRefresh: true,
};
// 在生命周期中请求后台数据
componentDidMount() {
this.fetchData();
}
// 自定义方法
fetchData() {
const list = [
{
id: 1,
value: "测试数据",
},
{
id: 2,
value: "测试数据",
},
];
this.setState({
list,
});
}
// 自定义方法,通过props传递给子组件
handleJumpToDetail = () => {
// 通过ref方式调用子组件方法
this.itemRefComp.current.testRef();
console.log("detail");
};
// render函数,返回JSX
render() {
const { list, nothing, error } = this.state;
return (
<View className="container">
{!!list && !!list.length && (
<View className="list">
{list.map((item) => {
return (
<Item
ref={this.itemRefComp}
item={item}
key={item.id}
onItemClick={this.handleJumpToDetail}
/>
);
})}
</View>
)}
{/* {nothing && <Empty message='暂无数据' />}
{error && <Error />} */}
</View>
);
}
}
子组件
import Taro, { Component } from "@tarojs/taro";
import { Button } from "@tarojs/components";
export default class Item extends Component {
// 定义props
static defaultProps = {
item: {},
onItemClick: null,
};
// 自定义方法
handleClickItem = (item, e) => {
const { onItemClick } = this.props;
// 触发父组件通过props传递的方法
onItemClick && onItemClick(item);
};
// 自定义方法(父组件中可通过ref方式调用)
testRef() {
console.log("testRef click", this.props.item);
}
// render函数,返回JSX
render() {
const { item } = this.props;
return (
<Button className="item" onClick={this.handleClickItem.bind(this, item)}>
{item.value}
</Button>
);
}
}
需要注意setState并非像原生小程序setData一样是同步的,如果后面有取值操作,需在回调函数中或利用async/await获取,例如:
logList() {
const { list } = this.state;
console.log(list);
}
// 错误用法
fetchData() {
const list = [1, 2];
this.setState({
list,
});
// logList中无法正确获取list
this.logList();
}
// 正确用法一,回调函数
fetchData() {
const list = [1, 2];
this.setState(
{
list,
},
() => {
this.logList();
}
);
}
// 正确用法二,async/await
async fetchData() {
const list = [1, 2];
await this.setState({
list,
});
this.logList();
}
function组件
function组件组成部分
- 定义defaultProps
- 定义state
- useEffect(类似生命周期)
- 返回JSX
父组件
import { useState, useEffect, useRef } from "@tarojs/taro";
import { View } from "@tarojs/components";
// 公共组件
// import Empty from './empty';
// import Error from './error';
// 子组件
import Item from "./item";
export default function ExampleList(props) {
// 定义state
const [list, setList] = useState([]);
const [nothing, setNothing] = useState(false);
const [error, setError] = useState(false);
// ref方式访问组件(如果需要)
const itemRefComp = useRef();
// 在useEffect中请求后台数据
useEffect(() => {
fetchData();
}, []);
// 自定义方法
const fetchData = () => {
const list = [
{
id: 1,
value: "测试数据",
},
{
id: 2,
value: "测试数据",
},
];
setList(list);
};
// 自定义方法,通过props传递给子组件
const handleJumpToDetail = () => {
// 通过ref方式调用子组件方法
itemRefComp.current.testRef();
console.log("detail");
};
// 返回JSX
return (
<View className="container">
{!!list && !!list.length && (
<View className="list">
{list.map((item) => {
return (
<Item
childRef={itemRefComp}
key={item.id}
item={item}
onItemClick={handleJumpToDetail}
/>
);
})}
</View>
)}
{/* {nothing && <Empty message="暂无数据" />}
{error && <Error />} */}
</View>
);
}
// 相当于原生小程序的page.json
ExampleList.config = {
navigationBarTitleText: "示例列表",
enablePullDownRefresh: true,
};
子组件
import { useImperativeHandle } from "@tarojs/taro";
import { Button } from "@tarojs/components";
const Item = (props) => {
const { item, onItemClick, childRef } = props;
// 对外暴露ref方式调用的方法
useImperativeHandle(childRef, () => {
return {
testRef,
};
});
// 自定义方法
const handleClickItem = (item) => {
return () => {
// 触发父组件通过props传递的方法
onItemClick && onItemClick(item);
};
};
// 自定义方法(父组件中可通过ref方式调用)
function testRef() {
console.log("testRef click", item);
}
// 返回JSX
return (
<Button className="item" onClick={handleClickItem(item)}>
{item.value}
</Button>
);
};
// 定义defaultProps
Item.defaultProps = {
item: {},
onItemClick: null,
};
export default Item;
以上主要为了体现基本的用法,暂不会对各个细节做详细的介绍。
性能优化相关
为了方便调试,下面的部分示例代码基于React而非Taro,实际上它们的用法基本是一样的。
优化重新渲染问题
React不会像其他一些框架一样,在state变动时做一些依赖、是否相等的判断,以避免不必要的渲染。实际表现为每次setState后,无论JSX中是否引用了该state,甚至不管state的值是否改变,都会引起当前组件、子组件的重新渲染,这无疑是个不必要的开销。
虽然React通过virtual dom(H5端)做diff比较,能最小化改动,而非全部抛弃重新渲染,但减少render函数的执行、virtual dom生成和比较,仍然是有利于提升性能的,特别是在dom比较复杂、子组件较多的情况下。
一些基本的解决办法
- 将JSX中不依赖的、依赖但不会二次变动的数据移出state;
- 向子组件传递的函数不使用bind及不使用匿名函数包裹(会导致PureComponent优化失效)。
例如:
export default class Example extends Component {
// 定义state
state = {
list: [],
error: false,
nothing: false,
};
// render函数中不依赖,或者不会二次变动的数据
pageNo = 1;
hasMore = true;
noChange = 1;
// 使用普通函数时,render中需要用bind才能保障this正确,不推荐使用
// fun() {}
// 使用箭头函数时,render中不需要bind(this)
fun = () => {
console.log(this);
};
render() {
const { list, nothing, error } = this.state;
const { noChange } = this;
return (
<View className="container">
{list}
{nothing}
{error}
{noChange}
{/* 常见使用bind和匿名函数,会导致每次渲染都生成新的函数,触发子组件的重新渲染 */}
{/* <Child onFun={this.fun.bind(this)}></Child>*/}
{/* <Child onFun={() => this.fun()}></Child> */}
<Child onFun={this.fun}></Child>
</View>
);
}
}
再者使用shouldComponentUpdate、PureComponent、memo等官方API做一些新旧数据的判断,来决定是否重新渲染,以及用useMemo、useCallback来缓存函数。
shouldComponentUpdate
shouldComponentUpdate能让我们自主决定是否渲染子组件,但在数据量过大、涉及引用类型(对象、数组等)时,需要遍历、递归判断(深比较),可能会在性能上得不偿失。
shouldComponentUpdate(nextProps, nextState) {
// state数据不相同时才允许渲染
return nextState.someData !== this.state.someData;
}
PureComponent
PureComponent会自动帮我们将props和state做浅对比,来决定是否渲染视图,但当数据的内存指向没有发生变化时,会导致无法触发渲染,这时可以使用forceUpdate强制更新(不推荐),更推荐每次都返回一个新的对象。
import React, { PureComponent, Component } from "react";
class Child extends PureComponent {
updateChild() {
this.forceUpdate();
}
render() {
console.log("Child Component render");
const { name } = this.props.userInfo;
return (
<div>
这里是child子组件:
<p>{name}</p>
</div>
);
}
}
class Parent extends PureComponent {
state = {
userInfo: { name: "张三", age: 18 },
};
childRef = React.createRef();
changeName = () => {
const { userInfo } = this.state;
// 可以触发父子组件更新(对于引用类型数据,需要每次返回一个新的对象)
/*this.setState({
userInfo: {
...userInfo,
name: "李四",
},
});*/
// 数据内存地址没有变化,无法触发父子组件更新
userInfo.name = "李四";
this.setState({
userInfo,
});
// 强制子组件更新,不推荐使用
this.childRef.current.updateChild();
};
render() {
console.log("Parent Component render");
const { userInfo } = this.state;
return (
<div>
<p>{userInfo.name}</p>
<button onClick={this.changeName}>改变父组件state</button>
<br />
<Child ref={this.childRef} userInfo={userInfo}></Child>
</div>
);
}
}
export default Parent;
memo
memo为高阶组件,比较像是shouldComponentUpdate及PureComponent的结合体,是提供给function组件使用的。
memo默认会帮我们做props浅比较,如果props相等则不做更新,但缺陷跟PureComponent一样,在判断引用类型数据时可能不靠谱,这种情况除了每次都返回一个新的对象,还可以通过第二个参数实现自定义新旧数据判断。
import React, { useState, memo } from "react";
// 未优化的function,父组件的更新都会引起子组件的更新
const Child = (props = {}) => {
console.log(`--- re-render ---`);
return (
<div>
<p>number is : {props.number}</p>
</div>
);
};
// 使用memo优化的function,类似PureComponent,父组件的step及count改变不会引起该组件的更新
/*const ChildMemo = memo((props = {}) => {
console.log(`--- memo re-render ---`);
return (
<div>
<p>number is : {props.number}</p>
</div>
);
});*/
/**
* 可以传第二个参数,类似shouldComponentUpdate,不同的是返回false时,才会触发更新
*/
const isEqual = (prevProps, nextProps) => {
if (prevProps.number !== nextProps.number) {
return false;
}
return true;
};
const ChildMemo = memo((props = {}) => {
console.log(`--- memo re-render ---`);
return (
<div>
<p>number is : {props.number}</p>
</div>
);
}, isEqual);
export default (props = {}) => {
const [step, setStep] = useState(0);
const [count, setCount] = useState(0);
const [number, setNumber] = useState(0);
const handleSetStep = () => {
setStep(step + 1);
};
const handleSetCount = () => {
setCount(count + 1);
};
const handleCalNumber = () => {
setNumber(count + step);
};
return (
<div>
<button onClick={handleSetStep}>step is : {step} </button>
<button onClick={handleSetCount}>count is : {count} </button>
<button onClick={handleCalNumber}>number is : {number} </button>
<hr />
<Child number={number} /> <hr />
<ChildMemo number={number} />
</div>
);
};
useMemo
useMemo非常类似Vue的computed,具有缓存作用,并且根据第二个参数的传入,会有不同的执行结果,表现为:
- 不传参数,每次子组件更新都会执行(相当于没有优化);
- 传[],只会首次执行一次;
- 传[state/props],当依赖的参数改变时,才会重新执行。
由于function组件每次渲染都会重新创建,除了用useMemo实现computed,还可以用来缓存复杂的函数,以减少重新创建的消耗。
import React, { useState, useMemo } from "react";
export default (props = {}) => {
console.log("---function---render---");
const [step, setStep] = useState(5);
const [count, setCount] = useState(0);
// 类似vue的computed,具有缓存作用,只有当依赖(step)改变时才会重新执行
const { sum } = useMemo(() => {
console.log("---useMemo---render---");
let sum = 10;
sum += step;
return {
sum,
};
}, [step]);
// 当成缓存一个普通函数来使用
// const { sum } = useMemo(() => {
// console.log("---useMemo---render---");
// let sum = 0;
// // 假设是一个很复杂的计算过程
// for (let i = 0; i < 10000; i++) {
// sum += 5;
// }
// return {
// sum,
// };
// }, []);
const handleSetCount = () => {
setCount(count + 1);
};
const handleSetStep = () => {
setStep(step + 1);
};
return (
<div>
<button onClick={handleSetCount}>count is : {count} </button>
<p onClick={handleSetStep}>
step is: {step} sum is: {sum}
</p>
</div>
);
};
useCallback
回到上面的memo的介绍,使用memo可以实现props数据不改变就不触发子组件的重新渲染。
然而这仍然不是银弹,我们知道function组件每次渲染都会重新创建,当通过props向子组件传递函数时,因为函数是重新创建的,所以该函数的内存地址每次都会变更,于是memo的浅比较就会无法准确识别,也就达不到优化的效果。
useCallback就是为了解决这一问题而诞生的,它能帮我们缓存函数,不至于每次重新渲染都会重新创建,具体用法如下:
import React, { useState, memo, useCallback } from "react";
const ChildMemo = memo((props = {}) => {
console.log("--- memo re-render ---");
return (
<div>
<p>number is : {props.number}</p>
</div>
);
});
const ChildMemo2 = memo((props = {}) => {
console.log("--- memo re-render2 ---");
return (
<div>
<p>number is : {props.number}</p>
</div>
);
});
export default (props = {}) => {
const [step, setStep] = useState(0);
const [count, setCount] = useState(0);
const [number, setNumber] = useState(0);
const handleSetStep = () => {
setStep(step + 1);
};
const handleSetCount = () => {
setCount(count + 1);
};
const handleCalNumber = () => {
setNumber(count + step);
};
// 普通函数
const onClickA = () => {};
// useCallback
// 不传参,没有缓存效果
// const onClickB = useCallback(() => {});
// 传空数组,不会再次更新
const onClickB = useCallback(() => {}, []);
// 传[state/props],依赖改变时才会更新
// const onClickB = useCallback(() => {
// console.log(step);
// }, [step]);
return (
<div>
<button onClick={handleSetStep}>step is : {step} </button>
<button onClick={handleSetCount}>count is : {count} </button>
<button onClick={handleCalNumber}>number is : {number} </button>
<hr />
<ChildMemo number={number} onClick={onClickA} /> <hr />
<ChildMemo2 number={number} clickFun={onClickB} />
</div>
);
};
上面列举了一些优化性能的办法,还有更多没能一一写出来,末尾分享一句话:
“我们应该忽略很小的性能优化,可以说97%的情况下,过早的优化是万恶之源,而我们应该关心对性能影响最关键的那另外3%的代码。”——高德纳
从这里应该可以得出一些结论,例如:
- 我们更应该关注有性能瓶颈的地方;
- 如果没有良好的规则、提升性能的确信,过早的优化可能会造成代码过于复杂且难以维护。
常见问题
为什么有时候在function组件中拿到的state或props是旧的(capture value)?
观察下面的例子,当我们点击button之后,button里的文本会显示“count is: 5”,似乎没什么问题,但handleClick里面的log却告诉我们count是0,这是为什么呢?
import React, { useState } from "react";
export default (props = {}) => {
console.log('render')
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(5);
setTimeout(() => {
console.log(`count已经设置为5了,那么现在是=>${count}`);// 0
}, 3e3);
};
return <button onClick={handleClick}>count is: {count}</button>;
};
这个问题仍然归属于上面提到的function组件每次渲染都会重新创建,可以用下面这个例子做一个简单的对比。
const funComponent = (isAlert) => {
const { count } = state;
console.log(`render----count is ${count}`);
if (isAlert) {
setTimeout(() => {
console.log(`count is ${count}`);// 2 4
}, 3e3);
}
};
const state = new Proxy(
{
count: 0,
},
{
set(target, property, value) {
target[property] = value;
funComponent(!(value % 2));
return value;
},
}
);
state.count = 1;
state.count = 2;
state.count = 3;
state.count = 4;
state.count = 5;
由于function组件每次都会重新创建并执行,而state又是通过解构函数取值的,跟真正的state并没有引用关系。
click事件触发后,setTimeout处于上一次函数的运行环境中,而非当前的运行环境,也就无法从上一次的state中获取到最新值。
上面这个例子只需要把${count}
改为${state.count}
就可以了,因为是指向了真正的count,也就能保证获取到最新的值。
React中提供了类似的解决方法-useRef,用法如下:
import React, { useState, useRef } from "react";
export default (props = {}) => {
const [count, setCount] = useState(0);
const latestCount = useRef(count);
const handleClick = () => {
setCount(latestCount.current = 5);
setTimeout(() => {
console.log(`count已经设置为5了,那么现在是=>${latestCount.current}`); // 5
}, 3e3);
};
return <button onClick={handleClick}>count is: {count}</button>;
};
使用循环的index变量作为key是一种反优化?
运行Taro小程序时,可能会遇到如题这种提示,大部分的原因应该是我们想要“轻松省事”,用现成的index当key就完事了,小部分的原因是不知道用index当key的弊端。
那么再次提上案头,为什么使用循环的index变量作为key是一种反优化呢?
简单来说,当存在唯一key的情况下,对数组进行增删改等操作时,diff算法能正确地识别节点,找到正确的位置更新数组。而index作为key使用时,由于唯一标识key改变了,会引发节点批量重新渲染,甚至节点更新不正确。
如果想要了解更多,可去搜索Vue/React相关dom diff算法。
一般而言,后端会给我们提供数据的唯一ID,例如:
<ul>
{list.map((item) => {
return <li key={item.id}>{item.name}</li>;
})}
</ul>
如果没有,我们也可以按照一定的规则生成唯一key,或者使用uuid等一些插件生成。
虽然实际开发中,大部分的dom for循环仅为了数据展示,不需要绑定key。但尽可能地为for循环加上唯一的key属性,是代码严谨、消除隐藏bug以及避免编译器报错的良好习惯。