準備工作
需要用到的模板檔案的倉庫地址
1. JSX
先看看jsx語法,做了什麼事情 babel.js
可以看到,這些jsx語法,經過babel轉譯後,最終呼叫了React.createElement
,其需要三個引數type, props, children
。其返回值就是virtual DOM物件。也就是說,我們可以使用babel
將我們的jsx
程式碼,轉換成虛擬DOM, 但是我們需要實現一個自己的createElement
方法
2. 專案配置
檢視倉庫地址,可以直接獲取到模板檔案。這裡主要介紹一下我們的.babelrc
中如何配置,幫助我們解析jsx程式碼,並自動的呼叫我們自己寫的createElement
方法
可以看看babel官網 是如何配置react
的。presets
中配置@babel/prset-react
,我們將使用他來轉換我們程式碼中的jsx程式碼。想想上面的程式碼,我們寫的函式式元件,或者jsx
程式碼,都被轉換成了React.createElement
程式碼,所以我們藉助babel
就可以實現我們自定義的createElement
功能
{
"presets": [
"@babel/preset-env",
[
"@babel/preset-react",
{
"pragma": "MyReact.createElement" //預設的pragma就是 React.createElement,也就是說,我們要實現一個我們的MyReact.createElement, 那麼就需要在這裡寫成MyReact.createElement (only in classic runtime)
}
]
]
}
3. Virtual DOM
3.1 什麼是Virtual DOM
使用javascript物件來描述真實的dom物件,其結構一般就是這樣
vdom = {
type: '',
props: {} // 屬性物件
children: [] // 子元素,子元件
}
3.2 建立Virtual DOM
3.2.1 實現一個createElement
方法
在我們的模板檔案中, 已經使用webpack
配置好了程式碼的入口檔案,安裝好依賴,然後將專案執行起來,這時候瀏覽器啥都沒有發生。解析jsx
的工作也都有babel
幫我們完成了
如果你出現了這種情況,那麼請自行更改webpack
中devserver的埠號
這是我們的專案目錄結構:
同時,也可以看看我們專案的目錄結構, 這裡我已經新增了一個createElement.js
的檔案,我們將在這個檔案中,實現將jsx程式碼,轉換為virtual DOM
物件。
上面我們提到過,React.createElement
會接收三個引數type, props, children
,然後會自動的將jsx
程式碼轉換成下面這個型別,因此我們需要做的就是提供這麼一個方法,接收這三個引數,然後在將其組裝成我們想要的物件。
vdom = {
type: '',
props: {} // 屬性物件
children: [] // 子元素,子元件
}
首先在MyReact資料夾下建立
createDOMElement.js
,他的結構我們上面提到過,接收三個引數,並且返回一個vdom的物件export default function createElement(type, props, ...children) { return { type, props, children } }
建立好了createElement方法,那麼我們需要往外暴露,因此在
MyReact/index.js
中,我們將其暴露出來// MyReact/index.js import createElement from './createElement' export default { createElement, }
然後我們在入口檔案, 引入我們的
MyReact
,同時寫一段jsx
的程式碼,看看能不能符合我們的預期// index.js import MyReact from "./MyReact" // 按照react的使用方法,這裡我們先引入我們自定義的MyReact 此處的MyReact會將jsx語法 通過呼叫MyReact.createElement(),然後返回我們所需要的VDOM // 這個是在.babelrc配置的 const virtualDOM = ( <div className="container"> <h1>你好 Tiny React</h1> <h2 data-test="test">(我是文字)</h2> <div> 巢狀1 <div>巢狀 1.1</div> </div> <h3>(觀察: 這個將會被改變)</h3> {2 == 1 && <div>如果2和1相等渲染當前內容</div>} {2 == 2 && <div>2</div>} <span>這是一段內容</span> <button onClick={() => alert("你好")}>點選我</button> <h3>這個將會被刪除</h3> 2, 3 <input type="text" value="13" /> </div> ) console.log(virtualDOM)
看看列印結果,是不是我們的預期
bingo,確實是我們想要的。這裡大家可以看到
- children中,有些節點是一個
boolean
,還有就是我們可能節點就是個null, 不需要轉換 - children中,有些節點直接就是文字,需要轉換文文字節點
props中,需要可以訪問children節點
上述兩個特殊情況都沒有被正確的轉換成vDOM,因此我們接下來需要做的就是,對children節點,在進行一次createElement
的操作。3.2.2 改進
createElement
方法上面我們說到,我們需要遞迴的呼叫createElement方法去生成vDOM。根據上面三個問題,我們可以作如下改進
export default function createElement(type, props, ...children) { // 1.迴圈children物件進行物件轉換,如果是物件,那麼在呼叫一次createElement方法,將其轉換為虛擬dom,否則直接返回,因為他就是普通節點. const childrenElements = [].concat(...children).reduce((result, child) => { // 2.節點中還有表示式, 不能渲染 null節點 boolean型別的節點, 因此我們這裡用的reduce,而不是用map if (child !== true && child !== false && child !== null) { // child已經是物件了,那麼直接放進result中 if (child instanceof Object) { result.push(child) } else { // 如果他是文字節點,則直接轉換為為本節點 result.push(createElement("text", { textContent: child })) } } return result }, []) // 3. props 可以訪問children節點, return { type, props: Object.assign({ children: childrenElements }, props), // hebing Props children: childrenElements } }
現在再看看我們的輸出, 可以看到,之前我們children中有false的,以及純文字的節點,都被正確的處理了,到這裡,我們的createElement
就結束了
3.3 實現render方法
3.3.1 render方法
首先在MyReact
資料夾下建立render.js
。
在render中,我們還要一個diff
方法,diff
演算法就是保證檢視只更新變動的部分,需要將新舊dom進行對比(vDOM, oldDOM),然後更新更改部分的dom(container)。我們先寫一個diff
方法,實際的演算法,我們留在後面來補充。
// MyReact/render.js
import diff from "./diff"
export default function render(vDOM, container, oldDOM) {
// diff演算法
diff(vDOM, container, oldDOM)
}
然後,我們在MyReact/index.js
將render方法進行匯出。
import createElement from './createElement'
import render from './render'
export default {
createElement,
render
}
3.3.2 diff方法
剛剛的分析,我們可以知曉,這個diff
演算法是需要三個引數的,newDom, container, oldDom
, 在這裡,我們需要做的是就是對比新舊dom,這裡,我們需要一個方法,用來建立元素,於是我們現在又需要一個 mountElement
方法,於是建立檔案mountElement.js
,用於建立元素。
// MyReact/diff.js
import mountElement from "./mountElement"
export default function diff(vDOM, container, oldDOM) {
// 判斷oldDOM是否存在
if (!oldDOM) {
// 建立元素
mountElement(vDOM, container)
}
}
3.3.3 mountElement方法
我們的元素,需要區分原生dom元素還是元件。元件分為class元件,以及函式元件。在這我們先把原生dom進行渲染。
mountElement
- mountNativeElement
mountComponentElement
- class元件
- 函式元件
// MyReact/mountElement.js
export default function mountElement(vDOM, container) {
// 此處需要區分原生dom元素還是元件,如何區分? 這個邏輯我們後面再補充
mountNativeElement(vDOM, container)
}
3.3.4 mountNativeElement方法
在這個方法中,我們需要將virtual DOM轉成真正的DOM節點,在這裡,我們藉助一個方法,來建立真實DOM元素,然後再將其append到容器中。
// MyReact/mountNativeElement.js
import createDOMElement from "./createDOMElement"
/**
* 渲染vdom到指定節點
* @param {*} vDOM
* @param {*} container
*/
export default function mountNativeElement(vDOM, container) {
let newElement= createDOMElement(vDOM)
container.appendChild(newElement)
}
下面我們來實現這個createDOMElement
方法,因為後續我們也會用到,所以把它作為一個公共的函式,方便其他地方使用。
這個方法,我們需要做下面的幾件事情。
- 將傳進來的
vDOM
建立成html
元素 - 建立html元素 又分為兩種情況, 純文字節點,還是元素節點
遞迴建立子節點的html元素
// MyReact/createDOMElement.js import mountElement from "./mountElement" /** * 建立虛擬dom * @param {*} vDOM * @returns */ export default function createDOMElement(vDOM) { let newElement = null // 1. 渲染文字節點, 根據我們之前處理的,純文字節點,通過text去標記, 值就是props中的textContent if (vDOM.type === 'text') { newElement = document.createTextNode(vDOM.props.textContent) } else { // 2.渲染元素節點 newElement = document.createElement(vDOM.type) // type 就是html元素型別 div input p這些標籤等等 // 注意,這裡我們只渲染了節點,並沒有將props的屬性,放在html標籤上,這個我們後面在進行 } // 以上步驟僅僅只是建立了根節點,還需要遞迴建立子節點 vDOM.children.forEach(child => { // 將其放置在父節點上, 由於不確定當前子節點是元件還是普通vDOM,因此我們再次呼叫mountElement方法,當前的節點容器,就是newElement mountElement(child, newElement) }) return newElement }
程式碼已經就緒,我們去瀏覽器看看有沒有什麼變化, 這個時候,你的瀏覽器應該長這樣了。然後我們再來分析下,我們還缺什麼?
3.3.5 更新節點屬性的方法(updateNodeElement)
我們現在已經實現了將jsx
的程式碼,渲染到了頁面上。但是現在看看我們的虛擬DOM的結構。跟我們的預期還缺少了下面的東西
className
沒有被渲染為 classdata-test
type
value
等這些原生屬性沒有被新增到對應的標籤上button
的響應事件
接下來,我們就去實現這個updateNodeElement
方法。
還是先建立MyReact/updateNodeElement.js
這個檔案。思考一個問題,我們什麼時候呼叫這個方法來更新node的屬性呢?
在上面 3.3.4中,我們在進行更新節點的步驟,因此更新node節點的屬性,也需要在那裡進行
然後可以肯定的是,我們需要兩個引數,一個是容器container
,一個是我們的虛擬DOM
,這樣才能確定一個完整的element
.
接下來的工作就是要把props屬性,依次的賦值給html。回憶一下,如何設定html的屬性?我們使用 element.setAttribute('prop', value)
來實現.
明確瞭如何更新html上面的屬性,接下來來分析下,我們要處理哪些屬性,和事件
首先我們需要遍歷當前vDOM的props屬性,根據鍵值
確定使用何種設定屬性的方式
- 事件繫結:我們繫結事件都是以on開頭,類似這樣 onClick;
value
checked
這樣的屬性值,就不能使用setAttribute
方法了,想想我們使用原生dom的時候,對於輸入框這樣的value值,我們是直接用的input.value
設定輸入框的值;children
屬性,這是我們之前手動新增的節點屬性,因此,我們要把children
給他剔除;className
屬性,這個需要我們將其改為class
,剩下的屬性,就可以直接使用鍵來設定了
程式碼實現如下:// MyReact/updateNodeElement.js export default function updateNodeElement (newElement, vDOM) { const { props } = vDOM // 遍歷vdom上的key,獲取每個prop的值, Object.keys(props).forEach(key => { const currentProp = props[key] // 如果是以on開頭的,那麼就認為是事件屬性,因此我們需要給他註冊一個事件 onClick -> click if (key.startsWith('on')) { // 由於事件都是駝峰命名的,因此,我們需要將其轉換為小寫,然後取最後事件名稱 const eventName = key.toLowerCase().slice(2) // 為當前元素新增事件處理函式 newElement.addEventListener(eventName, currentProp) } else if (key === 'value' || key === 'checked') { // input 中的屬性值 newElement[key] = currentProp } else if (key !== 'children') { // 拋開children屬性, 因為這個是他的子節點, 這裡需要區分className和其他屬性 newElement.setAttribute(key === 'className' ? 'class' : key, currentProp) } }) }
接下來,我們找到
createDOMElement.js
檔案,我們需要在渲染元素節點後,更新他的屬性值// MyReact/createDOMElement.js import mountElement from "./mountElement" /** * 建立虛擬dom * @param {*} vDOM * @returns */ export default function createDOMElement(vDOM) { let newElement = null // 1. 渲染文字節點, 根據我們之前處理的,純文字節點,通過text去標記, 值就是props中的textContent if (vDOM.type === 'text') { newElement = document.createTextNode(vDOM.props.textContent) } else { // 2.渲染元素節點 newElement = document.createElement(vDOM.type) // type 就是html元素型別 div input p這些標籤等等 // 更新dom元素的屬性,事件等等 updateNodeElement(newElement, vDOM) } // 以上步驟僅僅只是建立了根節點,還需要遞迴建立子節點 vDOM.children.forEach(child => { // 將其放置在父節點上, 由於不確定當前子節點是元件還是普通vDOM,因此我們再次呼叫mountElement方法,當前的節點容器,就是newElement mountElement(child, newElement) }) return newElement }
至此,我們已經完成了屬性設定,現在回到瀏覽器看看,我們的結果,
class
屬性被正確的載入了元素上面,其他屬性,以及事件響應,也都做好了。
階段一完成!
階段一小結
jsx
語法在babel
加持下,可以轉換成vDOM
,我們使用babel-preset-react
,並配置.babelrc
,可以讓我們實現自定義的createElement
方法。然後將jsx
轉換成虛擬DOM
.- 我們通過
createElement
方法生成的虛擬DOM物件,通過diff
演算法(本文未實現),然後進行dom
的更新 - 虛擬dom物件需要我們對所有的節點進行真實dom的轉換。
- 建立節點我們需要使用
element.createElement(type)
建立文字節點element.createElement(type)
, 設定屬性,我們需要用到element.setAttribute(key, value)
這個方法。 - 節點更新需要區分html節點,元件節點。元件節點又需要區分 class元件以及函式元件
未完待續
可以看到,我們僅僅實現了jsx
程式碼能夠被正確的渲染到頁面中,我們還有很多工作未做,比如下面的這些,後續的程式碼更新都放在這裡了。原始碼
- 元件渲染函式元件、類元件
- 元件渲染中 props的處理
- dom元素更新時的vDOM對比,刪除節點
- setState方法
- 實現ref屬性獲取dom物件陣列
- key屬性的節點標記與對比
2021-12-31更新
4.元件渲染
上面我們說到,我們只進行了html節點的渲染,接下來我們要做的就是渲染元件。react
中,元件分為兩種,一種是函式元件,一種是類元件。那麼我們如何來進行區分呢?
想想我們在使用的時候是怎麼使用的
// 函式元件
function Hello = function() {
return <div>Hello </div>
}
// 類元件
class World extends React.Component {
constructor(props) {
super(props)
}
render(){
return <div>Hello react</div>
}
}
看到上面的方法,我們考慮函式元件與類元件的區別。函式元件實際上就是個函式,那麼我們在生成virtual DOM的時候,那他的type應該就是function
, class元件是一個類,當然他的型別也會是函式,於是我們就可以知道,class元件可以通過prototype
找到render方法,而函式元件則不存在這個方法,因此,我們需要一個方法來判斷當前元件是函式元件還是類元件。
建立MyReact/isFunction.js
以及MyReact/isFunctionComponet.js
。
// isFunction.js
export default function isFunction(vDOM) {
return typeof vDOM?.type === 'function'
}
// isFunctionComponet.js
export default function isFunctionComponent(vDOM) {
const type = vDOM.type
return type && isFunction(vDOM) && !(type.prototype && type.prototype.render)
}
我們現在需要做的就是在mountElement.js
中進行元件的區分,此時我們需要新建一個方法,叫做mountComponentElement
,在這裡面去區分函式元件以及類元件
// mountElement.js
import mountNativeElement from "./mountNativeElement"
import mountComponentElement from "./mountComponentElement"
import isFunction from "./isFunction"
/**
* 掛載元素
* @param {*} vDOM
* @param {*} container
*/
export default function mountElement(vDOM, container) {
// 此處需要區分原生dom元素還是元件,如何區分?
if (isFunction(vDOM)) {
mountComponentElement(vDOM, container)
} else {
mountNativeElement(vDOM, container)
}
}
4.1 函式元件的渲染
上面分析到我們需要建立一個mountComponentElement
方法,用於渲染元件元素. 我們該如何去渲染這個函式式元件呢?我們先看看呼叫render方法後,傳入的元件vDOM是個什麼情況
我們在入口index.js中寫上這麼一段測試程式碼,然後在下面看看輸出
function Hello() {
return <div>Hello 123!</div>
}
function Heart() {
return (<div>
♥
<Hello />
</div>)
}
MyReact.render(<Heart />, root)
控制檯可以看到,現在傳入的函式元件,他的type就是一個function
, 就是我們寫的那個Heart
,那既然是函式,我們就可以直接執行,然後就能獲取到他返回的virtual DOM了。
這裡,我們需要引入之前建立的isFunctionComponet
函式,用於區分函式元件還是類元件.然後我們在分析下,如果這個傳入的元件,他還嵌入的其他的元件呢?因此,我們在呼叫了這個函式元件的方法後,還需要去判斷,此時生成的元件是什麼型別的html
functional
class
.因此,在mountComponentElement
我們這個方法中,需要遞迴的去呼叫這個方法,同時我們還要呼叫渲染html元件的方法。我們看來如何實現
import isFunction from "./isFunction"; // 判斷是否為function
import isFunctionComponent from "./isFunctionComponent"; // 區分函式元件 類元件
import mountNativeElement from "./mountNativeElement"; // mount Native元素
//
export default function mountComponentElement (vDOM, container) {
console.log(vDOM, 'vDOM')
let nextElementVDOM = null // 申明一個變數用於儲存即將生成的vDOM
// 區分class元件, 以及函式元件,如果vDOM中 有render方法,那麼就是class元件,否則就是函式元件
if (isFunctionComponent(vDOM)) {
// 這裡生成的可能是一個包含元件的函式式元件
nextElementVDOM = buildFunctionComponent(vDOM) // 借用一個方法,去生成函式元件的vDOM
} else {
// TODO 生成類元件vDOM
}
// 如果這個建立的elemnt還是個函式,那麼就繼續處理
if (isFunction(nextElementVDOM)) {
mountComponentElement(nextElementVDOM, container)
} else {
// 如果不是函式,那麼就是普通的Dom元素了,直接進行渲染
mountNativeElement(nextElementVDOM, container)
}
}
/**
* build函式式元件,函式式元件直接執行,執行過後就是生成的vdom
* @param {*} vDOM
* @returns
*/
function buildFunctionComponent(vDOM) {
return vDOM.type() // 上面的分析可知,這個vDOM的type就是個函式 執行過後,返回return中的內容
}
寫到這裡,我們的頁面上應該已經出現結果了
4.2 類元件的渲染
接下來 我們來實現類元件的渲染,同樣我們也需要藉助一個方法去實現這個功能.但是在這之前,我們還需要一個關鍵的東西。那就是Component
。因為我們使用class元件的時候,都需要 class Com extends React.Component
,因此我們還需要實現這個Component
類, 然後我們的類元件才能去繼承這個類實現其他方法
新建MyReact/Component.js
,現在這裡我們什麼都不做,只是將這個類暴露出去
// Component.js
export default class Component {
}
在index.js中,將這個Component類匯出
// MyReact/index.js
import createElement from './createElement'
import render from './render'
import Component from './Component'
export default {
createElement,
render,
Component
}
更新下我們的測試程式碼,index.js
檔案中我們來使用Component
宣告一個類元件
// index.js
class MyClass extends MyReact.Component {
constructor(props) {
super(props)
}
render() {
return <div>我是類元件-----</div>
}
}
MyReact.render(<MyClass />, root)
然後在4.1的基礎上,增加buildClassComponent方法。按照我們之前做函式元件的分析,類元件生成vDOM需要執行一次例項化,然後手動的去呼叫render放發
import isFunction from "./isFunction"; // 判斷是否為function
import isFunctionComponent from "./isFunctionComponent"; // 區分函式元件 類元件
import mountNativeElement from "./mountNativeElement"; // mount Native元素
//
export default function mountComponentElement (vDOM, container) {
console.log(vDOM, 'vDOM')
let nextElementVDOM = null // 申明一個變數用於儲存即將生成的vDOM
// 區分class元件, 以及函式元件,如果vDOM中 有render方法,那麼就是class元件,否則就是函式元件
if (isFunctionComponent(vDOM)) {
// 這裡生成的可能是一個包含元件的函式式元件
nextElementVDOM = buildFunctionComponent(vDOM) // 借用一個方法,去生成函式元件的vDOM
} else {
// 類元件的渲染
nextElementVDOM = buildClassComponent(vDOM)
}
// 如果這個建立的elemnt還是個函式,那麼就繼續處理
if (isFunction(nextElementVDOM)) {
mountComponentElement(nextElementVDOM, container)
} else {
// 如果不是函式,那麼就是普通的Dom元素了,直接進行渲染
mountNativeElement(nextElementVDOM, container)
}
}
/**
* build函式式元件,函式式元件直接執行,執行過後就是生成的vdom
* @param {*} vDOM
* @returns
*/
function buildFunctionComponent(vDOM) {
return vDOM.type() // 上面的分析可知,這個vDOM的type就是個函式 執行過後,返回return中的內容
}
// 新增,build類元件方法
/**
* build類元件, 需要對其進行例項化,然後手動的呼叫render方法
* @param {*} vDOM
* @returns
*/
function buildClassComponent(vDOM) {
const component = new vDOM.type()
return component.render()
}
到這裡,我們的類元件渲染方法已經結束了,可以到瀏覽器中看看執行結果
4.3 元件的props渲染
在4.1 4.2
中,我們完成了元件的渲染,但是沒有把props放入進去。我們回憶一下,props在react中是如何進行傳遞的.
// 類元件
class MyComponent extends React.Component {
constructor(props) {
super(props)
}
render() {
return <div>
<p>{this.props.age}</p>
<p>{this.props.name}</p>
<p>{this.props.gender}</p>
</div>
}
}
// 函式元件
function Heart(props) {
return (<div>
♥
{props.title}
<Hello />
</div>)
}
- 類元件
通過繼承自父類的props,然後在子類中使用,因此我們需要在Component
這個類中,實現props的儲存。如何去做呢,很簡單,那就是將子類傳遞過來的props,在建構函式中儲存起來,這樣子類便可以訪問到父類的props了。於是我們加入下面的程式碼到Component
類中。
接下來的問題是,我們如何將props屬性,傳遞到類元件的建構函式中呢?也很簡單,那就是在我們buildClassComponent
這個方法中,例項化了類元件,因此我們只需要在這裡將props屬性在類元件被例項化的時候傳遞過去就可以了
// Component.js
export default class Component {
constructor(props) {
this.props = props
}
}
// mountComponentElement.js
function buildClassComponent(vDOM) {
const component = new vDOM.type(vDOM.props) // 將props作為例項化是的引數傳遞過去
return component.render()
}
函式元件
函式元件相對更簡單,只需要在呼叫render方法的時候,將props傳遞過去就可以了,因為他就是個普通函式。// mountComponentElement.js function buildFunctionComponent(vDOM) { return vDOM.type(vDOM.props) // 上面的分析可知,這個vDOM的type就是個函式 執行過後,返回return中的內容, 我們把props傳遞過去,在元件內部就可以獲取到props的值了。 }
至此,函式元件的渲染就已經完成了。
接下來要做的就是:
- dom元素更新時的vDOM對比,刪除節點
- setState方法
- 實現ref屬性獲取dom物件陣列
- key屬性的節點標記與對比