從零實現一個React&ReactDOM(附程式碼)
前言
作為一個react剛入門的小白,趁放假看了珠峰架構的從零實現react視訊,跟著敲了一下,為了加深記憶和理解,寫了這篇部落格。
文中程式碼來自於視訊,我做的就是根據自己的理解梳理了一遍整個邏輯,所以本文嚴格意義上不算一篇部落格,應該是算學習筆記。
如果說的有不正確的地方歡迎評論指出!
一個最簡單的實現
呼叫
// index.js
import React from './react';
React.render('hello world',document.getElementById('root'));
複製程式碼
實現
上面兩行程式碼的結果是在一個id是root的容器裡,塞了一個hello world字串,因此我們可以簡單的定義render方法
// react.js
function render(el,container) {
$(container).html(el); // jQuery API
}
export default React {
render,}
複製程式碼
函式元件和類元件
但是我們很快會發現,我們平時並不是這麼簡單的定義一個元件,常用的我們說react定義元件的方式有函式和類兩種。
但是這兩種方式的el要怎麼塞入container呢,我們可以先簡單的看看他們的呼叫和babel轉譯後的結果。
函式元件
function App() {
return (
<div style="color: red" onClick={function() {aler(1)}}>
Hello
<span>world</span>
</div>
)
}
React.render(App(),document.getElementById('root'));
複製程式碼
經過bebel轉譯後,發現他呼叫了React的ceateElement的方法,瞭解虛擬DOM的小夥伴可能對他可能不會陌生,不熟悉也沒事,我們往後看:
React.render(
React.createElement(
'div',{
style: "color: red",onClick: function () { alert(1); }
},'hello',React.createElement('span',{},'world')
),document.getElementById('root')
);
複製程式碼
類元件
class App extends React.Component {
render () {
return (
<div style="color: red" onClick={function() {aler(1)}}>
Hello
<span>world</span>
</div>
)
}
}
React.render(App,document.getElementById('root'));
複製程式碼
經過bebel轉譯後,我們發現他和函式元件非常類似,但是第一個引數從字串變成了一個App類(function):
React.render(
React.createElement(App),document.getElementById('root')
);
複製程式碼
React.createElement
我們發現,這兩種呼叫方式,經過轉移之後都呼叫了React.createElement這個方法。
我們可以看看他的簡單實現,第一個引數是Tag的元素型別,當我們用class宣告元件的時候,jsx語法就是<App></App>,相應的,type就是APP,此時的App是一種方法。
// 方便判斷物件是否是虛擬DOM,el instanceof DOM
class Element {
constructor(type,props) {
this.type = type;
this.props = props;
}
}
/**
* 返回虛擬DOM
* @param {string|function} type 節點型別
* @param {object} props 節點屬性
* @param {array} children 子節點
*/
export default function createElement(type,props = {},...children) {
props.children = children; // array
return new Element(type,props);
}
複製程式碼
React.createElement的返回是一個Element類,用類去構造他的好處是我們可以通過instanceof判斷物件是否是虛擬DOM。
虛擬DOM
虛擬DOM說白了就是用一個物件表示一個DOM節點。
例如: <div id="app">hello</div>我們可以這麼描述他,這是一個標籤型別為div,屬性是id,屬性值是app,具有一個string型別子節點的節點。
把高亮部分提取出來,我們可以用物件描述這個節點,這個物件就是虛擬DOM。
const el = {
type: 'div',props: {
id: 'app',
children: [
'hello'
]
},}
複製程式碼
使用虛擬DOM描述節點有什麼好處呢?
我們知道操作真實DOM節點的代價是非常高的,當資料頻繁發生更新的時候,直接操作真實DOM節點就會造成頻繁的重繪和迴流。
雖然一次重繪和迴流只耗時幾十ms,但是可能因為重繪,造成一個頻繁的閃爍,使用者體驗非常不好。
因此,我們可以操作虛擬DOM,然後將虛擬DOM一次性繪製到頁面上,當然,這個時候我們不需要將整個頁面重新渲染,React和Vue都有diff演算法,通過打補丁的方式更新改變的DOM節點,提升了更新的效率。
不過這個暫時不屬於本文範圍,有興趣的小夥伴可以自行了解一下,說這個的目的就是解釋一下為什麼我們不直接把真實DOM節點傳入render方法中,而是繞了一個彎傳入了虛擬DOM這樣一個js物件。
使用虛擬DOM的問題
第一個問題是,我們發現,React.createElement返回值是作為render方法的el入參傳入的。
function render(el,container) {
$(container).html(el); // jQuery API
}
複製程式碼
按照我們之前的簡單實現,el應該是一個HTML字串才能被渲染到容器中。
所以接下來我們要做的事情就是把虛擬DOM形式的物件,轉換為我們的HTML字串。
第二個問題是,既然使用了虛擬DOM,我們需要一個唯一標識去對應虛擬DOM和真實DOM節點。為了解決這個問題,我們可以給每個節點增加rootId,當然在新版中使用fiber代替,已經看不到它了。
對render函式的改造
現在我們發現,render方法的el引數有三種形式的入引數
- string型別或者number型別的簡單符號
- Element類的虛擬DOM
- type是string型別的函式式元件
- type是function型別的類元件
這時候我們可以簡單的構造一個工廠函式createReactUnit,根據不同的傳入型別,用相應的方法返回一個HTML字串。
createReactUnit這裡return的是一個類的例項,通過例項的getMarkUp方法去獲取HTML字串,其實是為了程式碼的可擴充套件性,如果元件還有其他的操作,就可以通過類的不同方法實現。
// unit.js
class Unit {
constructor(el) {
this.curremtEl = el;
}
}
class ReactTextUnit extends Unit {
// 每個不同的子類分別重寫getMarkUp方法,返回HTML字串
// rootId的作用在後面解釋,可以理解為區分節點設定的id值
getMarkUp(rootId) { ... }
}
class ReactNativeUnit extends Unit {
getMarkUp(rootId) { ... }
}
class ReactComponentUnit extends Unit {
getMarkUp(rootId) { ... }
}
// 對不同的el,根據我們剛剛三種形式入參的特徵進行判斷。
const creatReactUnit = function (el) {
// 如果是簡單的字串或者數字型別
if (typeof el === 'string' || typeof el === 'number') {
return new ReactTextUnit(el);
}
// 如果是函式式的元件
if (typeof el === 'object' && typeof el.type === 'string') {
return new ReactNativeUnit(el);
}
// 如果是class形式的
if (typeof el === 'object' && typeof el.type === 'function') {
return new ReactComponentUnit(el);
}
}
// react.js
function render(el,container) {
// 獲取相應的單元例項
let creatReactUnitInstance = creatReactUnit(el);
// 呼叫例項的getMarkUp方法,獲取相應的HTML字串
// 這裡有一個nextRootIndex引數,就是rootId
let mark = creatReactUnitInstance.getMarkUp(React.nextRootIndex);
// 將HTML字串塞入容器中進行渲染
$(container).html(mark);
}
export default const React = {
render,createElement,Component,nextRootIndex: 0,}
複製程式碼
編寫getMarkUp方法
現在,我們就可以重寫每個子類的getMarkUp方法,去生成HTML字串。
rootId
為了區分不同的節點,方便對虛擬DOM進行操作,我們給每個節點新增一個data-rootid屬性,形如:
<div data-rootid="0">
<div data-rootid="0.0">
<span data-rootid="0.0.0">hello</span>
<span data-rootid="0.0.1">world</span>
</div>
</div>
複製程式碼
我們可以通過[data-rootid="0.0.0"]方便的選擇到文字是hello的這個span。
String | number
其實string型別的字串可以直接塞入到容器中,但是為了區分不同的節點,我們還是要給他們外面套一層span,用rootId去標識他們。
class ReactTextUnit extends Unit {
getMarkUp(rootId) {
this._rootId = rootId;
return `<div data-rootid="${rootId}">
${this.curremtEl}
</div>`;
}
}
複製程式碼
函式式元件
這時候我們拿到的el是Element類,有type和prop兩個屬性,形如:
el = {
type: "div",props:{
style: "color: red",children: [
"app",{ props: {…},type: ƒ }
]
}
}
複製程式碼
要將這個結構轉化成HTML字串,很容易想到遞迴,遞迴的出口就是當children陣列的元素型別是基本型別。
例如<div>hello</div>可以拆解為一個elementNodediv和一個textNodehello,textNode沒有children,因此遞迴結束,此時creatReactUnit方法返回的就是ReactTextUnit類的例項,呼叫getMarkUp方法就能獲得相應的HTML字串。
class ` extends Unit {
getMarkUp(rootId) {
this._rootId = rootId;
let { type,props } = this.curremtEl;
let children;
// 起始標籤,屬性需要通過遍歷props向後新增
let startTag = `<${type} data-rootid="${rootId}"`
let endTag = `</${type}>`;
// 遍歷props,給Tag拼屬性 style="color: red"
for (let key in props) {
if (key === 'children') {
/**
* 處理是子節點的情況
* 遞迴遍歷children陣列,join方法將陣列轉為HTML字串
*/
children = props[key].map((el,index) => {
// 遞迴呼叫createReactUnit,將虛擬DOM轉化為HTML字串
let childInstance = creatReactUnit(el);
return childInstance.getMarkUp(`${rootId}.${index}`)
}).join('');
} else {
// 如果是節點的屬性,直接向startTag後面拼
startTag += `${key}="${props[key]}"`
}
}
// 返回html字串
return `${startTag}>${children}${endTag}`
}
}
複製程式碼
類宣告式元件
類宣告式元件el形式和函式式類似,但是節點型別是一個類,所以我們需要呼叫這個類的render方法,才能獲取類的結構(也就是let reactRendered = componentInstance.render();這一句呼叫)。
獲取到了結構之後,就和函式式元件一樣,遞迴呼叫creatReactUnit就可以了。
class ReactComponentUnit extends Unit {
getMarkUp(rootId) {
this._rootId = rootId;
let { type: Component,props } = this.curremtEl;
// 方便 this.props 呼叫
let componentInstance = new Component(props);
// 獲取元件render方法返回的結構,形如<App><span>123</span></App>
let reactRendered = componentInstance.render();
// 遞迴呼叫createReactUnit方法,渲染元件中的tag
let reactComponentUnitInstance = creatReactUnit(reactRendered);
return reactComponentUnitInstance.getMarkUp(rootId);
}
}
複製程式碼
實現事件的繫結
到這裡為止,我們已經可以簡單的把元件渲染到頁面上了,但是,當我們想給元件繫結一些事件的時候我們會發現,我們不能給HTML字串繫結事件。
這時候我們就可以想到事件委託機制,那麼怎麼知道事件要繫結到哪一個節點上呢,我們定義的rootId就派上了用場。
$(document).on('click','[data-rootid=0.0.0]',function() {alert(1)}) // 這裡用了jQuery的API,也可以用原生的
複製程式碼
這一部分判斷應該在ReactNativeUnit類中,只有這個類,才涉及props的拼接,所以我們新增一個判斷,如果匹配到了on開頭的屬性,作為事件繫結它。
完整程式碼如下:
class ReactNativeUnit extends Unit {
getMarkUp(rootId) {
this._rootId = rootId;
let { type,props } = this.curremtEl;
console.log(this.curremtEl)
let children;
let startTag = `<${type} data-rootid="${rootId}"`
let endTag = `</${type}>`;
for (let key in props) {
if (key === 'children') {
children = props[key].map((el,index) => {
let childInstance = creatReactUnit(el);
return childInstance.getMarkUp(`${rootId}.${index}`)
}).join('');
} else if (/on[a-z]/i.test(key)) {
/**
* 處理有事件的情況
* 給元素繫結事件
*/
let eventType = key.slice(2).toLocaleLowerCase(); // onClick -> click
// 通過事件委託繫結事件,引數分別是型別,子選擇器,要繫結的方法
$(document).on(eventType,`[data-rootid=${rootId}]`,props[key])
}
else {
startTag += `${key}="${props[key]}"`
}
}
return `${startTag}>${children}${endTag}`
}
}
複製程式碼
實現簡單的生命週期
react裡和元件掛載相關的兩個生命週期是componentWillMount和componentDidMount,當有元件巢狀的時候,他們的呼叫順序是。
- Parent will mount
- Children will mount
- Children did mount
- Parent did mount
怎麼樣才能使生命週期呈現這個順序呢,我們可以先看componentWillMount。
componentWillMount
這個順序很簡單,是按照遞迴的順序呼叫的,層級越外面,越先呼叫到。
和元件相關的遞迴呼叫,就只有ReactComponentUnit類的getMarkUp方法,因此只需要在這個方法里加一行componentWillMount方法的呼叫就可以了,完整程式碼如下。
class ReactComponentUnit extends Unit {
getMarkUp(rootId) {
this._rootId = rootId;
let { type: Component,props } = this.curremtEl;
let componentInstance = new Component(props);
// 呼叫生命週期鉤子函式
componentInstance.componentWillMount && componentInstance.componentWillMount();
let reactRendered = componentInstance.render();
let reactComponentUnitInstance = creatReactUnit(reactRendered);
return reactComponentUnitInstance.getMarkUp(rootId);
}
}
複製程式碼
componentDidMount
這個鉤子的呼叫順序和遞迴的解析順序是相反的,後掛載先執行,因此他應該在return方法之前呼叫。
其次,DidMount是在元件掛載成功後執行的鉤子,關於元件掛載,應該在React.render方法中執行,所以我們在render方法中先trigger一個‘mounted’事件,作為一個釋出者。
// react.js
function render(el,container) {
let creatReactUnitInstance = creatReactUnit(el);
let mark = creatReactUnitInstance.getMarkUp(React.nextRootIndex);
$(container).html(mark);
// 掛載元件完成的方法
$(document).trigger('mounted');
}
複製程式碼
通過$(document).on('mounted',()=> {})方法訂閱‘mounted’事件,執行生命週期鉤子,生命週期鉤子執行的順序就夠訂閱的順序。
因此,這句訂閱應該在return的前面進行。
完整程式碼如下:
class ReactComponentUnit extends Unit {
getMarkUp(rootId) {
this._rootId = rootId;
let { type: Component,props } = this.curremtEl;
let componentInstance = new Component(props);
componentInstance.componentWillMount && componentInstance.componentWillMount();
let reactRendered = componentInstance.render();
let reactComponentUnitInstance = creatReactUnit(reactRendered);
let markUp = reactComponentUnitInstance.getMarkUp(rootId);
// 子元件開始掛載,先於父元件訂閱這個方法
$(document).on('mounted',() => {
componentInstance.componentDidMount && componentInstance.componentDidMount();
})
return markUp;
}
}
複製程式碼
完整程式碼
看了這麼多,我們其實發現,整個邏輯的核心就是把jsx語法先轉成虛擬DOM,再遞迴遍歷虛擬DOM將它轉成HTML字串,按照這個思路就非常容易理解整個邏輯。
以下是本文提到的所有程式碼:
// react.js
import $ from 'jquery';
import creatReactUnit from './unit'
import createElement from './element'
import Component from './component'
let React = {
render,}
/**
* 將虛擬DOM渲染到頁面上
* @param {DOM} el 要渲染的元素,jsx語法
* @param {*} container 容器
*/
function render(el,container) {
// 通過標記獲取el中的元素
let creatReactUnitInstance = creatReactUnit(el);
let mark = creatReactUnitInstance.getMarkUp(React.nextRootIndex);
$(container).html(mark);
// 掛載元件完成的方法
$(document).trigger('mounted');
}
export default React;
複製程式碼
// unit.js
import $ from 'jquery';
// 父類,通過父類儲存引數
class Unit {
constructor(el) {
this.curremtEl = el;
}
}
class ReactTextUnit extends Unit {
getMarkUp(rootId) {
this._rootId = rootId;
return `<div data-rootid="${rootId}">
${this.curremtEl}
</div>`;
}
}
class ReactComponentUnit extends Unit {
getMarkUp(rootId) {
this._rootId = rootId;
let { type: Component,props } = this.curremtEl;
// 給方便this.props呼叫
let componentInstance = new Component(props);
componentInstance.componentWillMount && componentInstance.componentWillMount();
// 元件返回的
let reactRendered = componentInstance.render();
// 遞迴渲染元件中的tag,<App><span>123</span></App>
let reactComponentUnitInstance = creatReactUnit(reactRendered);
let markUp = reactComponentUnitInstance.getMarkUp(rootId);
// 子元件開始掛載,先於子元件訂閱這個方法
$(document).on('mounted',() => {
componentInstance.componentDidMount && componentInstance.componentDidMount();
})
return markUp;
}
}
class ReactNativeUnit extends Unit {
getMarkUp(rootId) {
this._rootId = rootId;
let { type,props } = this.curremtEl;
console.log(this.curremtEl)
let children;
let startTag = `<${type} data-rootid="${rootId}"`
let endTag = `</${type}>`;
// 給Tag拼屬性 style="color: red"
for (let key in props) {
if (key === 'children') {
/**
* 處理是子節點的情況
* 遞迴新增子節點,成為字串
*/
children = props[key].map((el,index) => {
let childInstance = creatReactUnit(el);
return childInstance.getMarkUp(`${rootId}.${index}`)
}).join('');
} else if (/on[a-z]/i.test(key)) {
/**
* 處理有事件的情況
* 給元素繫結事件
*/
let eventType = key.slice(2).toLocaleLowerCase();
// 元素,選擇器,方法
$(document).on(eventType,props[key])
}
else {
startTag += `${key}="${props[key]}"`
}
}
return `${startTag}>${children}${endTag}`
}
}
const creatReactUnit = function (el) {
if (typeof el === 'string' || typeof el === 'number') {
return new ReactTextUnit(el);
}
// 是否是 react 的 虛擬DOM
if (typeof el === 'object' && typeof el.type === 'string') {
return new ReactNativeUnit(el);
}
// 如果是class形式的
if (typeof el === 'object' && typeof el.type === 'function') {
return new ReactComponentUnit(el);
}
}
export default creatReactUnit;
複製程式碼
// element.js
class Element {
constructor(type,props);
}
複製程式碼
// component.js
export default class Component {
constructor(props) {
this.props = props;
}
/**
* 更新狀態
*/
setState() {
...
}
}
複製程式碼