記:實現一個mini-react

路飛的笑發表於2021-12-29

準備工作

需要用到的模板檔案的倉庫地址

1. JSX

先看看jsx語法,做了什麼事情 babel.js

image.png

可以看到,這些jsx語法,經過babel轉譯後,最終呼叫了React.createElement,其需要三個引數type, props, children。其返回值就是virtual DOM物件。也就是說,我們可以使用babel將我們的jsx程式碼,轉換成虛擬DOM, 但是我們需要實現一個自己的createElement方法

2. 專案配置

檢視倉庫地址,可以直接獲取到模板檔案。這裡主要介紹一下我們的.babelrc中如何配置,幫助我們解析jsx程式碼,並自動的呼叫我們自己寫的createElement方法
可以看看babel官網 是如何配置react的。
image.png
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的埠號
image.png

這是我們的專案目錄結構:

image.png
同時,也可以看看我們專案的目錄結構, 這裡我已經新增了一個createElement.js的檔案,我們將在這個檔案中,實現將jsx程式碼,轉換為virtual DOM物件。

上面我們提到過,React.createElement會接收三個引數type, props, children,然後會自動的將jsx程式碼轉換成下面這個型別,因此我們需要做的就是提供這麼一個方法,接收這三個引數,然後在將其組裝成我們想要的物件。

vdom = {
    type: '',
    props: {} // 屬性物件
    children: [] // 子元素,子元件
}
  1. 首先在MyReact資料夾下建立createDOMElement.js,他的結構我們上面提到過,接收三個引數,並且返回一個vdom的物件

    export default function createElement(type, props, ...children) {
      return {
     type,
     props,
     children
      }
    }
  2. 建立好了createElement方法,那麼我們需要往外暴露,因此在MyReact/index.js中,我們將其暴露出來

    // MyReact/index.js
    import createElement from './createElement'
    export default {
      createElement,
    }
  3. 然後我們在入口檔案, 引入我們的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)

    看看列印結果,是不是我們的預期
    image.png
    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就結束了
image.png

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方法,因為後續我們也會用到,所以把它作為一個公共的函式,方便其他地方使用。
這個方法,我們需要做下面的幾件事情。

  1. 將傳進來的vDOM建立成html元素
  2. 建立html元素 又分為兩種情況, 純文字節點,還是元素節點
  3. 遞迴建立子節點的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
    }
    

    程式碼已經就緒,我們去瀏覽器看看有沒有什麼變化, 這個時候,你的瀏覽器應該長這樣了。然後我們再來分析下,我們還缺什麼?
    image.png

3.3.5 更新節點屬性的方法(updateNodeElement)

我們現在已經實現了將jsx的程式碼,渲染到了頁面上。但是現在看看我們的虛擬DOM的結構。跟我們的預期還缺少了下面的東西

  1. className沒有被渲染為 class
  2. data-test type value等這些原生屬性沒有被新增到對應的標籤上
  3. button的響應事件

接下來,我們就去實現這個updateNodeElement方法。
還是先建立MyReact/updateNodeElement.js這個檔案。思考一個問題,我們什麼時候呼叫這個方法來更新node的屬性呢?
在上面 3.3.4中,我們在進行更新節點的步驟,因此更新node節點的屬性,也需要在那裡進行
然後可以肯定的是,我們需要兩個引數,一個是容器container,一個是我們的虛擬DOM,這樣才能確定一個完整的element.
接下來的工作就是要把props屬性,依次的賦值給html。回憶一下,如何設定html的屬性?我們使用 element.setAttribute('prop', value)來實現.
明確瞭如何更新html上面的屬性,接下來來分析下,我們要處理哪些屬性,和事件
首先我們需要遍歷當前vDOM的props屬性,根據鍵值確定使用何種設定屬性的方式

  1. 事件繫結:我們繫結事件都是以on開頭,類似這樣 onClick;
  2. value checked這樣的屬性值,就不能使用setAttribute方法了,想想我們使用原生dom的時候,對於輸入框這樣的value值,我們是直接用的input.value設定輸入框的值;
  3. children屬性,這是我們之前手動新增的節點屬性,因此,我們要把children給他剔除;
  4. 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屬性被正確的載入了元素上面,其他屬性,以及事件響應,也都做好了。
    image.png
    image.png

階段一完成!

階段一小結

  1. jsx語法在babel加持下,可以轉換成vDOM,我們使用 babel-preset-react,並配置.babelrc,可以讓我們實現自定義的createElement方法。然後將jsx轉換成虛擬DOM.
  2. 我們通過createElement方法生成的虛擬DOM物件,通過diff演算法(本文未實現),然後進行dom的更新
  3. 虛擬dom物件需要我們對所有的節點進行真實dom的轉換。
  4. 建立節點我們需要使用 element.createElement(type)建立文字節點element.createElement(type), 設定屬性,我們需要用到 element.setAttribute(key, value)這個方法。
  5. 節點更新需要區分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>
    &hearts;
    <Hello />
  </div>)
}
MyReact.render(<Heart />, root)

image.png
控制檯可以看到,現在傳入的函式元件,他的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中的內容
}

寫到這裡,我們的頁面上應該已經出現結果了
image.png

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()
}

到這裡,我們的類元件渲染方法已經結束了,可以到瀏覽器中看看執行結果
ef3c6c38702cc6aadea894b44666ed92.png

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>
    &hearts;
    {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()
}

image.png

  • 函式元件
    函式元件相對更簡單,只需要在呼叫render方法的時候,將props傳遞過去就可以了,因為他就是個普通函式。

    
    // mountComponentElement.js
    function buildFunctionComponent(vDOM) {
      return vDOM.type(vDOM.props) // 上面的分析可知,這個vDOM的type就是個函式 執行過後,返回return中的內容, 我們把props傳遞過去,在元件內部就可以獲取到props的值了。
    }

    image.png

至此,函式元件的渲染就已經完成了。


接下來要做的就是:

  • dom元素更新時的vDOM對比,刪除節點
  • setState方法
  • 實現ref屬性獲取dom物件陣列
  • key屬性的節點標記與對比

相關文章