50 行代碼內實現一個 React.js
1 前言
本文會教你如何在 50 行代碼內,不依賴任何第三方的庫,用純 JavaScript 實現一個 React.js 。
本文的目的是:揭開對初學者看起來很很難理解的 React.js 的組件化形式的外衣,讓你有更多的精力和注意力去學習 React.js 精髓的地方。如果你剛開始學習 React.js 并且感覺很迷茫,那么看完這篇文章以后就能夠解除一些疑惑。
另外注意,本文所實現的代碼只用于說明教學展示,并不適用于生產環境。代碼托管這個 倉庫 。心急如焚的同學可以先去看代碼,但本文會從最基礎的內容開始解釋。
2 一切從點贊說起
接下來所有的代碼都會從一個基本的點贊功能開始演化,你會逐漸看到,文章代碼慢慢地越來越像 React.js 的組件代碼。而在這個過程里面,大家需要只需要跟著文章的思路,就可以在代碼的演化當中體會到組件化形式。
假設現在我們需要實現一個點贊、取消點贊的功能。
如果你對前端稍微有一點了解,你就順手拈來:
HTML:
<body>
<div class='wrapper'>
<button class='like-btn'>
<span class='like-text'>點贊</span>
<span>:+1:</span>
</button>
</div>
</body>
為了現實當中的實際情況,所以這里特意把這個 button 的 HTML 結構搞得稍微復雜一些。有了這個 HTML 結構,現在就給它加入一些 JavaScript 的行為:
JavaScript:
const button = document.querySelector('.like-btn')
const buttonText = button.querySelector('.like-text')
let isLiked = false
button.addEventListener('click', function () {
isLiked = !isLiked
if (isLiked) {
buttonText.innerHTML = '取消'
} else {
buttonText.innerHTML = '點贊'
}
}, false)
功能和實現都很簡單,按鈕已經可以提供點贊和取消點贊的功能。這時候你的同事跑過來了,說他很喜歡你的按鈕,他也想用你寫的這個點贊功能。你就會發現這種實現方式很致命:你的同事要把整個 button 和里面的結構復制過去,還有整段 JavaScript 代碼也要復制過去。這樣的實現方式沒有任何可復用性。
3 實現可復用性
所以現在我們來想辦法解決這個問題,讓這個點贊功能具有較好的可復用的效果,那么你的同事們就可以輕松自在地使用這個點贊功能。
3.1 結構復用
現在我們來重新編寫這個點贊功能。這次我們先寫一個類,這個類有 render 方法,這個方法里面直接返回一個表示 HTML 結構的字符串:
class LikeButton {
render () {
return `
<button class='like-btn'>
<span class='like-text'>贊</span>
<span>:+1:</span>
</button>
`
}
}
然后可以用這個類來構建不同的點贊功能的實例,然后把它們插到頁面中。
const wrapper = document.querySelector('.wrapper')
const likeButton1 = new LikeButton()
wrapper.innerHTML = likeButton1.render()
const likeButton2 = new LikeButton()
wrapper.innerHTML += likeButton2.render()
這里非常暴力地使用了 innerHTML ,把兩個按鈕粗魯地插入了 wrapper 當中。雖然你可能會對這種實現方式非常不滿意,但我們還是勉強了實現了結構的復用。我們后面再來優化它。
3.2 生成 DOM 元素并且添加事件
你一定會發現,現在的按鈕是死的,你點擊它它根本不會有什么反應。因為根本沒有往上面添加事件。但是問題來了,LikeButton 類里面是雖然說有一個 button,但是這玩意根本就是在字符串里面的。你怎么能往一個字符串里面添加事件呢?DOM 事件的 API 只有 DOM 結構才能用。
我們需要 DOM 結構,準確地來說:我們需要這個點贊功能的 HTML 字符串代表的 DOM 結構。假設我們現在有一個函數 createDOMFromString ,你往這個函數傳入 HTML 字符串,但是它會把相應的 DOM 元素返回給你。這個問題就可以額解決了。
// ::String => ::Document
const createDOMFromString = (domString) => {
// TODO
}
先不用管這個函數應該怎么實現,先知道它是干嘛的。拿來用就好,這時候用它來改寫一下 LikeButton 類:
class LikeButton {
render () {
this.el = createDOMFromString(`
<button class='like-btn'>
<span class='like-text'>點贊</span>
<span>:+1:</span>
</button>
`)
this.el.addEventListener('click', () => console.log('click'), false)
return this.el
}
}
現在 render() 返回的不是一個 html 字符串了,而是一個由這個 html 字符串所生成的 DOM。在返回 DOM 元素之前會先給這個 DOM 元素上添加事件再返回。
因為現在 render 返回的是 DOM 元素,所以不能用 innerHTML 暴力地插入 wrapper。而是要用 DOM API 插進去。
const wrapper = document.querySelector('.wrapper')
const likeButton1 = new LikeButton()
wrapper.appendChild(likeButton1.render())
const likeButton2 = new LikeButton()
wrapper.appendChild(likeButton2.render())
現在你點擊這兩個按鈕,每個按鈕都會在控制臺打印 click,說明事件綁定成功了。但是按鈕上的文本還是沒有發生改變,只要稍微改動一下 LikeButton 的代碼就可以完成完整的功能:
class LikeButton {
constructor () {
this.state = { isLiked: false }
}
changeLikeText () {
const likeText = this.el.querySelector('.like-text')
this.state.isLiked = !this.state.isLiked
if (this.state.isLiked) {
likeText.innerHTML = '取消'
} else {
likeText.innerHTML = '點贊'
}
}
render () {
this.el = createDOMFromString(`
<button class='like-btn'>
<span class='like-text'>點贊</span>
<span>:+1:</span>
</button>
`)
this.el.addEventListener('click', this.changeLikeText.bind(this), false)
return this.el
}
}
這里的代碼稍微長了一些,但是還是很好理解。只不過是在給 LikeButton 類添加了構造函數,這個構造函數會給每一個 LikeButton 的實例添加一個對象 state,state 里面保存了每個按鈕自己是否點贊的狀態。還改寫了原來的事件綁定函數:原來只打印 click,現在點擊的按鈕的時候會調用 changeLikeText 方法,這個方法會根據 this.state 的狀態改變點贊按鈕的文本。
如果你現在還能跟得上文章的思路,那么你留意下,現在的代碼已經和 React.js 的組件代碼有點類似了。但其實我們根本沒有講 React.js 的任何內容,我們一心一意只想怎么做好“組件化”。
現在這個組件的可復用性已經很不錯了,你的同事們只要實例化一下然后插入到 DOM 里面去就好了。
4 為什么不暴力一點?
仔細留意一下 changeLikeText 函數,這個函數包含了 DOM 操作,現在看起來比較簡單,那是因為現在只有 isLiked 一個狀態。但想一下,因為你的數據狀態改變了你就需要去更新頁面的內容,所以如果你的組件包含了很多狀態,那么你的組件基本全部都是 DOM 操作。一個組件包含很多狀態的情況非常常見,所以這里還有優化的空間:如何盡量減少這種手動 DOM 操作?
4.1 狀態改變 -> 構建新的 DOM 元素
這里要提出的一種解決方案:一旦狀態發生改變,就重新調用 render 方法,構建一個新的 DOM 元素。這樣做的好處是什么呢?好處就是你可以在 render 方法里面使用最新的 this.state 來構造不同 HTML 結構的字符串,并且通過這個字符串構造不同的 DOM 元素。頁面就更新了!聽起來有點繞,看看代碼怎么寫:
class LikeButton {
constructor () {
this.state = { isLiked: false }
}
setState (state) {
this.state = state
this.el = this.render()
}
changeLikeText () {
this.setState({
isLiked: !this.state.isLiked
})
}
render () {
this.el = createDOMFromString(`
<button class='like-btn'>
<span class='like-text'>${this.state.isLiked ? '取消' : '點贊'}</span>
<span>:+1:</span>
</button>
`)
this.el.addEventListener('click', this.changeLikeText.bind(this), false)
return this.el
}
}
其實只是改了幾個小地方:
- render 函數里面的 HTML 字符串會根據 this.state 不同而不同(這里是用了 ES6 的字符串特性,做這種事情很方便)。
- 新增一個 setState 函數,這個函數接受一個對象作為參數;它會設置實例的 state,然后重新調用一下 render 方法。
- 當用戶點擊按鈕的時候, changeLikeText 會構建新的 state 對象,這個新的 state ,傳入 setState 函數當中。
這樣的結果就是,用戶每次點擊,changeLikeText 都會調用改變組件狀態然后調用 setState;setState 會調用 render 方法重新構建新的 DOM 元素;render 方法會根據 state 的不同構建不同的 DOM 元素。
也就是說,你只要調用 setState,組件就會重新渲染。我們順利地消除了沒必要的 DOM 操作。
4.2 重新插入新的 DOM 元素
上面的改進不會有什么效果,因為你仔細看一下就會發現,其實重新渲染的 DOM 元素并沒有插入到頁面當中。所以這個組件之外,你需要知道這個組件發生了改變,并且把新的 DOM 元素更新到頁面當中。
重新修改一下 setState 方法:
...
setState (state) {
const oldEl = this.el
this.state = state
this.el = this.render()
if (this.onStateChange) this.onStateChange(oldEl, this.el)
}
...
使用這個組件的時候:
const likeButton = new LikeButton()
wrapper.appendChild(likeButton.render()) // 第一次插入 DOM 元素
component.onStateChange = (oldEl, newEl) => {
wrapper.insertBefore(newEl, oldEl) // 插入新的元素
wrapper.removeChild(oldEl) // 刪除舊的元素
}
這里每次 setState 都會調用 onStateChange 方法,而這個方法是實例化以后時候被設置的,所以你可以自定義 onStateChange 的行為。這里做的事是,每當 setState 的時候,就會把插入新的 DOM 元素,然后刪除舊的元素,頁面就更新了。這里已經做到了進一步的優化了:現在不需要再手動更新頁面了。
非一般的暴力。不過沒有關系,這種暴力行為可以被 Virtual-DOM 的 diff 策略規避掉,但這不是本文章所討論的范圍。
這個版本的點贊功能很不錯,我可以繼續往上面加功能,而且還不需要手動操作DOM。但是有一個不好的地方,如果我要重新另外做一個新組件,譬如說評論組件,那么里面的這些 setState 方法要重新寫一遍,其實這些東西都可以抽出來。
5 抽象出 Component 類
為了讓代碼更靈活,可以寫更多的組件,我把這種模式抽象出來,放到一個 Component 類當中:
class Component {
constructor (props = {}) {
this.props = props
}
setState (state) {
const oldEl = this.el
this.state = state
this.el = 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
}
}
還有一個額外的 mount 的方法,其實就是把組件的 DOM 元素插入頁面,并且在 setState 的時候更新頁面:
const mount = (wrapper, component) => {
wrapper.appendChild(component.renderDOM())
component.onStateChange = (oldEl, newEl) => {
wrapper.insertBefore(newEl, oldEl)
wrapper.removeChild(oldEl)
}
}
這樣的話我們重新寫點贊組件就會變成:
class LikeButton extends Component {
constructor (props) {
super(props)
this.state = { isLiked: false }
}
onClick () {
this.setState({
isLiked: !this.state.isLiked
})
}
render () {
return `
<button class='like-btn'>
<span class='like-text'>${this.props.word || ''} ${this.state.isLiked ? '取消' : '點贊'}</span>
<span>:+1:</span>
</button>
`
}
}
mount(wrapper, new LikeButton({ word: 'hello' }))
有沒有發現你寫的代碼已經和 React.js 的組件寫法很相似了?而且還是可以正常運作的代碼,而且我們從頭到尾都是用純的 JavaScript,沒有依賴任何第三方庫。(注意這里加入了上面沒有提到過點 props,可以給組件傳入配置屬性,跟 React.js 一樣)。
只要有了上面那個 Component 類和 mount 方法加起來不足40行代碼就可以做到組件化。如果我們需要寫另外一個組件,只需要像上面那樣,簡單地繼承一下 Component 類就好了:
class RedBlueButton extends Component {
constructor (props) {
super(props)
this.state = {
color: 'red'
}
}
onClick () {
this.setState({
color: 'blue'
})
}
render () {
return `
<div style='color: ${this.state.color};'>${this.state.color}</div>
`
}
}
簡單好用,完整的代碼可以在這里找到: React.js in 40
噢,忘了,還有一個神秘的 createDOMFromString,其實它更簡單:
const createDOMFromString = (domString) => {
const div = document.createElement('div')
div.innerHTML = domString
return div
}
6 總結
你到底能從文章中獲取到什么?
好吧,我承認我標題黨了,這個 40 行不到的代碼其實是一個殘廢而且智障版的 React.js,沒有 JSX ,沒有組件嵌套等等。它只是 React.js 組件化表現形式的一種實現而已。它根本沒有觸碰到 React.js 的精髓。
其實 React.js 最最精髓的地方可能就在于它的 Virtual DOM 算法,而它的 setState 、props 等等都只不過是一種形式,而很多初學者會被它這種形式作迷惑。本篇文章其實就是揭露了這種組件化形式的實現原理。如果你正在學習或者學習 React.js 過程很迷茫,那么看完這篇文章以后就能夠解除一些疑惑。
來自:https://zhuanlan.zhihu.com/p/25398176