前言
本文篇幅有點長,希望看完后能給你帶去一些收獲;主要針對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()就可以看到渲染了多少次,從而監控到不必要的渲染。