前言
本文篇幅有点长,希望看完后能给你带去一些收获;主要针对react组件化原理、与vue开发感知上的对比以及一些基础优化进行叙述。
React组件化基本原理
react是近来十分火热的一个前端框架,它把注意力放在UI层,将页面分成一些细块,这些块就是组件,组件之间的组合嵌套就形成最后的网页界面。和其他很多前端框架一样,它们主要解决了前端组件化的问题。下面通过一个例子逐步探寻react的组件化原理。
实例分析
假设我们需要实现一个收藏按钮的功能,你应该能很快的写出以下html结构:
<div id="starBtn">
<button>
<span class="star">☆</span>
</button>
</div>
这时候的按钮如上,它只是一个静态结构没有绑定任何点击事件。如果我们需要点击收藏,再次点击取消,那么也很容易写出下面的js代码:
const starBtn = document.getElementById('starBtn')
const starStyle = document.getElementsByClassName('star')[0]
let isLike = false
document.getElementById('starBtn').addEventListener('click', function() {
isLike = !isLike
if (isLike) {
starStyle.innerHTML = '★'
} else {
starStyle.innerHTML = '☆'
}
}, false)
这时我们基本实现了收藏按钮功能,可突然你的同事过来说想用你这个按钮,你得不得拷贝整段html和js给他,还得考虑各种命名冲突兼容性,可以说这个按钮毫无复用性可言。
结构复用
下面我们换一种方式来实现它,让它具备一定的复用性。
class StarBtn {
render() {
return `
<button>
<span class="star">☆</span>
</button>`
}
}
上面实现了一个收藏按钮类,用一个render函数返回了它的html结构。然后按照下面的用法,基本实现了结构的复用:
let starBtn = document.getElementById('starBtn')
let starbtn1 = new StarBtn()
starBtn.innerHTML = starbtn1.render()
但是有一个很明显的问题,按钮的结构都是通过innerHTML将一堆字符串塞进去的,这样只是一个死的按钮,不能绑定任何事件,下面我们进行优化。
简单的组件化
设想如果有一个这样的函数,它可以把一段html字符串输出成一个dom元素,再把这个元素通过dom操作插入页面里,我们就可以在这个dom元素上绑定事件来完成逻辑了:
function createDomFromString(str) {
let _div = document.createElement('div')
_div.innerHTML = str
return _div
}
通过这个函数,我们就可以修改上面的按钮父类代码:
class StarBtn {
constructor() {
this.state = {
isLike: false
}
}
changeState() {
const starStyle = document.getElementsByClassName('star')[0]
this.state.isLike = !this.state.isLike
starStyle.innerHTML = !this.state.isLike ? '☆' : '★'
}
render() {
this._el = createDomFromString(`
<button>
<span class="star">☆</span>
</button>`)
this._el.addEventListener('click', this.changeState.bind(this), false)
return this._el
}
}
如上修改我们在构造函数里添加了一个state属性用来保存每个实例的收藏状态。然后在render函数中利用createDomFromString来返回一个dom节点,并在节点上绑定了changeState点击事件处理函数来修改收藏状态。
减少DOM操作
上面的代码乍一看很好的实现了一个可复用的按钮,这是因为该按钮比较简单,只有一个isLike状态和一次修改文本的DOM操作,如果是一个有很多状态的组件,那么将会充满DOM操作。那么怎么才能从手动管理这些DOM操作中抽出来呢?我们可以使用一个中介函数来修改状态并统一重绘这个组件:
class StarBtn {
constructor() {
this.state = {
isLike: false
}
}
setState(state) {
this.state = state
this.render()
}
changeState() {
this.setState({
isLike: !this.state.isLike
})
}
render() {
this._el = createDomFromString(`
<button>
<span class="star">${this.state.isLike ? '★' : '☆'}</span>
</button>`)
this._el.addEventListener('click', this.changeState.bind(this), false)
return this._el
}
}
我们使用了一个setState函数来统一修改state状态,并在修改后调用render函数来重绘整个节点。也就是说你只要调用 setState,组件就会重新渲染,成功的去除了DOM操作。但其实这时候点击按钮并不会有任何反应,因为你只是重新构建了DOM但是并没有把新的DOM插入页面中。
替换新的DOM节点
这时候我们需要在组件外去监听state的变化,然后插入新DOM,删除老DOM来实现更新,我们需要添加一个onStateChange函数来做这些。
...
setState(state) {
const _oldEl = this._el
this.state = state
this.render()
if (this.onStateChange) this.onStateChange(_oldEl, this._el)
}
...
let starBtn = document.getElementById('starBtn')
let starbtn1 = new StarBtn()
starbtn1.onStateChange = (oldEl, newEl) => {
starBtn.insertBefore(newEl, oldEl) // 插入新的元素
starBtn.removeChild(oldEl) // 删除旧的元素
}
starBtn.appendChild(starbtn1.render())
给每个实例添加一个onStateChange函数来监听state的变化,然后渲染新的组件。还需要在setState函数里添加处理onStateChange函数的代码。但是你可能会发现一个问题,这样每次修改state都进行删除、插入DOM操作不会十分影响性能吗?这个问题在React中会用一个叫Virtual-DOM的策略规避掉,该策略具体内容不在本文所述范围内。
这个收藏按钮组件现在看起来还不错,可以自由地添加功能,简单的复用,也不需要DOM操作。但是如果这时想写一个别的组件,我们可以进一步抽离出一个父类以供所有组件使用。
抽象公共组件类
我们可以抽象出一个Component类作为公共组件类:
class Component {
setState(state) {
const _oldEl = this._el
this.state = state
this._renderDom()
if (this.onStateChange) this.onStateChange(_oldEl, this._el)
}
_renderDom() {
this._el = createDomFromString(this.render())
if (this.onClick) {
this._el.addEventListener('click', this.onClick.bind(this), false)
}
return this._el
}
}
这个公共类里包含了setState和renderDom两个方法,用来修改状态和输出组件DOM,绑定事件。但是外部还需要将DOM插入页面以及监听state改变替换DOM的函数,如下mount函数:
const mount = (component, wrap) => {
wrap.appendChild(component._renderDom())
component.onStateChange = (oldEl, newEl) => {
starBtn.insertBefore(newEl, oldEl) // 插入新的元素
starBtn.removeChild(oldEl) // 删除旧的元素
}
}
我们就可以这样改写刚才的收藏按钮组件的代码:
class StarBtn extends Component {
constructor() {
super()
this.state = {
isLike: false
}
}
onClick() {
this.setState({
isLike: !this.state.isLike
})
}
render() {
return `
<button>
<span class="star">${this.state.isLike ? '★' : '☆'}</span>
</button>`
}
}
let starBtn = document.getElementById('starBtn')
let starbtn1 = new StarBtn()
mount(starbtn1, starBtn)
该类继承了Component类,并且在render函数中返回html字符串,绑定onClick函数调用父类的setState来修改自己的状态。然后将实例和父节点传入mount函数完成state的监听和DOM更新。
至此我们基本从头到尾实现了一次组件化,实际上这个Component类和React源码中的Component类使用方式很类似。了解了这些就很容易明白React组件化的基本原理。
React组件开发经验
区分容器组件和可视化组件
Presentational称为可视化组件,也就是我们用来渲染到页面上可以被看见的组件,它只负责根据父组件传来的props渲染视图;
Container称为容器组件,它总是作为可视化组件的父级组件出现,通常作用是给可视化组件准备数据,充当支架。
//BAD
class UserList extends React.Component {
constructor() {
this.state = {
userArr: []
}
}
componentWillMount() {
//ajax获取用户列表数据
this.setState({
userArr: users
})
}
render() {
return (
<ul>
{
this.props.userArr.map((user) => {
<li key={user.id}>
<img src={user.pic} />
<a href={user.link}>user detail info</a>
</li>
})
}
</ul>
)
}
}
//NICE,使用纯组件
const User = (user) => { //这个User组件就是可视化组件,只负责渲染相关
<li>
<img src={user.pic} />
<a href={user.link}>user detail info</a>
</li>
}
class UserList extends React.Component { //UserList组件为容器组件,ajax请求数据
constructor() {
this.state = {
userArr: []
}
}
componentWillMount() {
//ajax获取用户列表数据
this.setState({
userArr: users
})
}
render() {
return (
<ul>
{
this.props.userArr.map((user) => {
return <User user={user} key={user.id}></User>
})
}
</ul>
)
}
}
使用setState需要注意的问题
setState()方法是react中用来修改状态的内置方法,而且是必须的方式。也就是说不能使用this.state来修改组件的状态。this.state是不可变的。文档中提到setState执行总是会触发一次重绘,所以使用过程中有一些需要注意的地方。
setState是异步的,没有任何同步性的保证
在开发过程中,我们可能会在 setState 之后立即去拿 this.state 中的某个属性,但是发现该值并没有修改过来。官方说法是,为了性能上的优化,采用了 Batch 思想,会收集“一波” state 的变化,统一进行处理。为了解决这个问题 setState 可以有以下两种写法:
//第一种
this.setState((prevState, props) => {
return {counter: prevState.counter + props.step};
});
//第二种
this.setState({
counter: newVal
}, () => {
//callback,会在state修改完成后执行
})
或者在生命周期 componentDidUpdate 函数中处理state修改后的逻辑。
setState会造成不必要的渲染
由于setState每执行一次都会出发一次重绘,但实际上很多渲染都是浪费的。我们可能只需要更新一个子组件,但却会导致很大面积的组件重新render。这个问题会在后面的性能优化中提及。
setState只管理渲染相关的数据
与渲染无关的数据,例如id号、临时变量等数据并不会影响ui渲染,可能只是用来做标记或与后端交互,那么这些值的修改不应该用setState去重绘。可以使用 localStorage 或者第三方状态管理库,如MobX。
绑定this的问题
this指向、执行上下文一直都是js中不可避免会遇到的问题。在react中,对this绑定的处理有很多种方案,总结大致有以下这些:
- 使用ES5 createClass 方法创建组件自动绑定this
- 在渲染时使用bind方法绑定
- 在渲染时使用箭头函数绑定
- 在 constructor 构造函数中绑定
- 使用 等号 加 箭头函数 绑定
React.createClass方法
在v13版本之前,react都是使用该方式创建组件,但之后的版本开始推荐使用ES6的class属性来创建,或者使用无状态组件的方式。文档中说明了使用React.createClass方法会自动完成对this的绑定,也就是this会自动指向当前react组件。不过框架和语言发展的大势所趋,这个方式会被放弃。
渲染时绑定
render() {
return (
<User onClick = {this.selectUser.bind(this)}></User>
)
}
这种方式很常规,并且能够很好的解决this绑定问题;但同时带来了另一个问题:每次重新渲染组件时都会创建一个新的函数。虽然听起来好像很严重,但实际上对性能的损耗并不明显。
渲染时使用箭头函数
受益于ES6的箭头函数,可以将this的作用域传入函数内部。所以可以这样去绑定this:
render() {
return (
<User onClick = {e => this.selectUser(e)}></User>
)
}
该方式和上一种本质是相同的,都会有潜在的性能问题。
在constructor中绑定
class User extends Component {
constructor(props) {
super(props);
this.selectUser = this.selectUser.bind(this);
}
...
}
因为构造函数只会在组件第一次挂载时执行,所以整个生命周期中只会执行一次。在构造函数中对方法进行this绑定,就不会像前面的方法那样重复创建新函数而造成性能问题。但是如果方法很多的时候,这个构造函数的可读性就很差了。
一种实验性特性的写法
如果你的代码配置了 babel 的 transform-class-properties 插件,那么可以使用下面的方法完美解决上面的所有问题。既不会造成性能问题,也不会导致代码冗长难于阅读。
//babel配置
{
"plugins": [
"transform-class-properties"
]
}
class User extends Component {
selectUser = (e) => {
...
}
}
设定 propTypes 和 defaultProps
这两个都是react组件的静态属性,所有组件都应该有这两个属性; propTypes 用来规定每个 props 的类型,而 defaultProps 则是给 props 填充默认值。在15.3版本之前,propTypes 在 React 对象中定义了,但之后的版本则需要使用第三方库 prop-types 来替代。配置了这两个属性后,如果传递的 props 类型错误会在浏览器报错,可以让使用组件的人清晰的看到问题。同时它们也起到了文档的作用。
class Dialog extends Component {
static propTypes = {
buttons: PropTypes.array,
show: PropTypes.bool,
title: PropTypes.string,
type: PropTypes.string,
}
static defaultProps = {
buttons: [],
show: false,
title: '',
type: '',
}
...
}
这是一个对话框组件,共设置了四个 props ,并规定了他的类型和默认值。
利用解构的便利
这一点其实就是ES6提供的便利,因为react中有一些组件可能会有很多的props。如果将每个属性都从props中解构出来,可以很好的提高代码的可阅读性。还是刚才那个对话框组件:
render() {
const {title, show, className, children, buttons, type, autoDectect, ...others} = this.props //解构props
const styleType = type ? type : 'ios'
const cls = classNames('tui-dialog', {
'tui-skin_android': styleType === 'android',
[className]: className
})
return (
<div style={{display: show ? 'block' : 'none'}}>
<Mask/>
<div className={cls} {...others}>
{ title ?
<div className="tui-dialog__hd">
<strong className="weui-dialog__title">{title}</strong>
</div> : false }
<div className="tui-dialog__bd">
{children}
</div>
<div className="tui-dialog__ft">
{this.renderButtons()}
</div>
</div>
</div>
)
}
JSX中的判断
在实际项目中,我们难免会需要在JSX中进行一些判断,来分别处理不同情况下的渲染。但这是一件不太容易的事情,因为JSX并不像js一样拥有判断语法。我们一般的做法是使用三目运算来处理。比如下面这样:
return (
{ title ?
<div className="tui-dialog__hd">
<strong className="weui-dialog__title">{title}</strong>
</div> : null
}
)
但是这并不是最好的写法,&&运算会比三目运算性能更好,如果可以最好写成这样:
return (
{ title &&
<div className="tui-dialog__hd">
<strong className="weui-dialog__title">{title}</strong>
</div>
}
)
React与Vue使用中的对比
组件写法
React
在react中,组件的写法是JSX + inline style,也就是说“all in js”,把结构和样式都写进js里;
class AlertBox extends React.Component {
render() {
var styleObj = {
color:"blue",
fontSize:40,
fontWeight:"normal"
}
return <h1 style={styleObj} className="alert-text">Hello {this.props.name} {this.props.title}</h1>;
}
}
Vue
而vue中,推荐的写法是利用webpack+vue-loader来处理.vue单文件组件,js、css、html在一个文件的不同区域。所以对于用模板构建的应用,vue是更合适的选择。
<template>
<div class="alert-box"></div>
</template>
<script>
export default {
name: 'alertbox',
data() {
return {}
},
methods: {
},
...
}
</script>
<style>
.alert-box {
width: 100px;
}
</style>
Virtual DOM处理
Vue
在vue中,它会分析跟踪每一个组件内部和相互之间的依赖关系,当实例的data值改变时,不需要重新渲染整个组件树。
React
在react中,每当应用的 state 被改变,该组件包括其后代组件都会重新渲染,所以react中会需要用到 shouldComponentUpdate 这个生命周期函数来进行控制是否重新渲染。
数据绑定
Vue
在vue中,实现了数据的双向绑定,即对于input这类表单元素可以使用v-model指令来将输入数据与data中的变量进行双向绑定。内部原理是监听表单元素的change事件,来通知model层进行更新。
React
在react中,数据只能单向的绑定,即从model到view;如果需要将表单元素的输入绑定到model上,需要手动实现:
handleChange(e) {
this.setState({inputValue: e.target.value})
}
render() {
return (
<input ref="input"
value={this.state.inputValue}
onChange="this.handleChange.bind(this)" />
)
}
修改状态
Vue
在vue中,组件的状态保存在组件实例的data属性中,当需要修改时,直接用 this.property = xxx 就可以完成,并且这个操作是立即生效的。
React
在React中,组件的状态保存在state当中,但是要修改state不能直接用this.state.property = xxx,而要使用内置的setState函数来修改。并且该方法不能保证修改的同步性。
监听数据变化
Vue
在Vue中,官方提供了一个 watch 方法来供我们去监听组件某个属性的变化:
watch: {
count (curVal, oldVal) {
this.step = curVal - oldVal
},
userArr(curVal, oldVal) {
//这里不会执行
},
userArr: {
handler(curVal, oldVal) {
//这里会执行
},
deep: true
}
}
初次之外,vue中还提供了 computed 计算属性来监听某个属性从而计算出另一个属性的值。
React
在React中则没有提供类似 watch 这样的api。我们需要使用react组件的某些生命周期函数来间接的做到这一点。
componentWillReceiveProps(object nextProps) {
//当props改变时,组件会执行获取新props的钩子函数,处理一些逻辑
}
componentWillUpdate(object nextProps, object nextState) {
//当state改变时,组件会重新渲染,更新前的钩子函数中,可以拿到新的state和props处理一些逻辑
}
React v16版本新特性
内核改变 react-fiber
react-fiber 是为了增强动画、布局、移动端手势领域的适用性,最重要的特性是对页面渲染的优化: 允许将渲染方面的工作拆分为多段进行。
react-fiber 可以为我们提供如下几个功能:
- 设置渲染任务的优先
- 采用新的Diff算法
- 采用虚拟栈设计允许当优先级更高的渲染任务和较低优先的任务之间来回切换
render函数可return数组,处理多纬渲染
如下有两个节点的渲染,由于JSX返回时必须嵌套在一个标签内,所以必须外层套一个div
render() {
return(
<div>
<div className="box1">
<p>box1</p>
</div>
<div className="box2">
<p>box2</p>
</div>
</div>
)
}
新版本可直接如下处理,以一个数组的形式渲染多个节点
render() {
return([
<div className="box1">
<p>box1</p>
</div>,
<div className="box2">
<p>box2</p>
</div>
])
}
异常降级处理
var MyGoodView = React.createClass({
render: function () {
return <p>Cool</p>;
}
});
var MyBadView = React.createClass({
render: function () {
throw new Error('crap');
}
});
try {
// 希望抛出错误
React.render(<MyBadView/>, document.body);
} catch (e) {
// 进行错误降级处理
React.render(<MyGoodView/>, document.body);
}
这段代码在之前是无法进入到catch内的,而v16版本允许对错误进行降级处理,从而来提高组件的可用性。
重写 SSR api
依赖 Map、Set数据类型
v16版本开始依赖 Map 和 Set 数据类型,所以需要注意在低版本浏览器中使用时需要添加polyfill垫片库;
v16开始有一个ReactElementValidator.js对元素工厂进行包装,主要对传递给组件的props进行验证,该文件中的 new Map([['children', true], ['key', true]]) 这句话,在ios8中会报错:TypeError: Map constructor does not accept arguments ,即Map函数不能接收参数。为了解决该问题需要将react及react-dom降级至v15.x.x。
React性能优化实例
在React中,组件的状态(state)或属性(props)改变都会导致整个组件的重新渲染,具体的更新流程如下图所示:
在首次渲染完成后,组件的属性/状态改变,会触发组件一系列的生命周期函数。从图中可以看出,SCU(shouldComponentUpdate)钩子十分关键,它决定了组件是否进行re-render去生成虚拟DOM。默认情况下,SCU都会返回true。看下面的例子:
如果我们有图中结构的一个组件树,其中绿色的组件是我们需要更新的组件,但是它依赖于从祖父节点传下来的props来更新。
那么我们期望的更新方式是如下绿色的三个节点。祖父节点的某个state改变,然后将它以props的方式传递到孙子节点去更新它。
但是事实情况并不如我们所想,而是整个组件树都会进行re-render,然后生成虚拟DOM,再对虚拟DOM进行diff操作,若改变则更新,否则不更新。这样就会造成很多冗余的render和diff操作,图中黄色节点。
这时候我们就需要利用SCU来规避这种情况,但是SCU需要慎重使用,方式不当可能会造成数据改变但UI不变的bug,也可能导致更严重的性能问题。
// 接收两个参数,分别为待更新的属性及状态值
shouldComponentUpdate(nextProps, nextState) {
// 如果当前的value值与待更新不相等,才执行更新
return this.props.value !== nextProps.value;
// return this.state.user !== nextState.user;
}
上面是常用的方式,通过判断新的某个状态/属性与之前的值是否相等,来决定是否重新渲染。同时,React v15.3开始提供了一个叫做 PureComponent 的组件来替代 Component 来封装 shouldComponentUpdate 以达到上述目的。
import React, { PureComponent } from 'react'
class Example extends PureComponent {
render() {
// ...
}
}
但是这样实际上有一个问题,它们进行的都是浅比较,也就是说如果对比的值是一个对象或者数组,那么它的引用会一直相等,从而造成误判。
解决这个问题有两种方式:
- 深比较 该方式十分损耗性能,试想一下如果每次修改 state 都要对该深层嵌套的对象进行一次彻底的遍历,可能还不如多 render 几次来得快;
- Immutable.js 用来替代浅拷贝和深拷贝的方式,用来创建不可变的数据。
// 原来的写法
let foo = {a: {b: 1}};
let bar = foo;
bar.a.b = 2;
console.log(foo.a.b); // 打印 2
console.log(foo === bar); // 打印 true
// 使用 immutable.js 后
import Immutable from 'immutable';
foo = Immutable.fromJS({a: {b: 1}});
bar = foo.setIn(['a', 'b'], 2); // 使用 setIn 赋值
console.log(foo.getIn(['a', 'b'])); // 使用 getIn 取值,打印 1
console.log(foo === bar); // 打印 false
下面是项目中遇到的一个实际情况:
this.state = {
startTime: new Date().getTime(),
chartDatas: []
}
shouldComponentUpdate(nextProps, nextState) {
return this.state.chartDatas.length !== nextState.chartDatas.length
}
render() {
return (
<Button
onClick={e => {
this.setState({
startTime: _today //今天开始的数据
})
}
}>
<Button
onClick={e => {
this.setState({
startTime: _yesterday //昨天开始的数据
})
}
}>
...
{
this.state.chartDatas.map((item, i) => {
return(
<div className="chartItem" key={i}>
<h1 className="chartTitle">{item.name}<span>(单位:{item.sum !== undefined ? '次)' : '毫秒)'}</span></h1>
<p className="chartOverall">
<span>最小值:<b>{item.sum !== undefined ? formatCurrency(item.min) : item.min.toFixed(2)}</b></span>
<span>最大值:<b>{item.sum !== undefined ? formatCurrency(item.max) : item.max.toFixed(2)}</b></span>
{
item.sum !== undefined ?
<span>总值:<b>{formatCurrency(item.sum)}</b></span> :
<span>平均值:<b>{item.average.toFixed(2)}</b></span>
}
</p>
<DetailCharts isReflow={this.state.isReflow} data={[{type: 'area', name: item.name, data: item.data}]}></DetailCharts>
</div>
)
})
}
)
}
在我没有加上 SCU 之前,我每点击一次切换时间的按钮,这时只是修改了 startTime 属性并没有改图表数据,但是依然会触发组件的重新渲染。所有图标重新画了一遍,性能差一点的手机上体验极差。通过 SCU 改善后,避免了绝大部分的不必要渲染。判断组件是否渲染的方法很简单,只要在render函数里加console.log()就可以看到渲染了多少次,从而监控到不必要的渲染。