一、前言
React是用於構建使用者介面的 JavaScript
庫。其有著許多優秀的特性,使其受到大眾的歡迎。
① 宣告式渲染:
所謂宣告式,就是關注結果,而不是關注過程。比如我們常用的html標記語言就是一種宣告式的,我們只需要在.html檔案上,寫上宣告式的標記如<h1>這是一個標題</h1>
,瀏覽器就能自動幫我們渲染出一個標題元素。同樣react中也支援jsx的語法,可以在js中直接寫html,由於其對DOM操作進行了封裝,react會自動幫我們渲染出對應的結果。
② 元件化:
元件是react的核心,一個完整的react應用是由若干個元件搭建起來的,每個元件有自己的資料和方法,元件具體如何劃分,需要根據不同的專案來確定,而元件的特徵是可複用,可維護性高。
③ 單向資料流:
子元件對於父元件傳遞過來的資料是只讀的。子元件不可直接修改父元件中的資料,只能通過呼叫父元件傳遞過來的方法,來間接修改父元件的資料,形成了單向清晰的資料流。防止了當一個父元件的變數被傳遞到多個子元件中時,一旦該變數被修改,所有傳遞到子元件的變數都會被修改的問題,這樣出現bug除錯會比較困難,因為不清楚到底是哪個子元件改的,把對父元件的bug除錯控制在父元件之中。
之後的內容,我們將一步步瞭解React相關知識,並且簡單實現一個react。
二、jsx
剛接觸react的時候,首先要了解的就是jsx語法,jsx其實是一種語法糖,是js的一種擴充套件語法,它可以讓你在js中直接書寫html程式碼片段,並且react推薦我們使用jsx來描述我們的介面,例如下面一段程式碼:
// 直接在js中,將一段html程式碼賦值給js中的一個變數
const element = <h1>Hello, react!</h1\>;
在普通js中,執行這樣一段程式碼,會提示Uncaught SyntaxError: Unexpected token '<'
,也就是不符合js的語法規則。那麼為什麼react能夠支援這樣的語法呢?
因為react程式碼在打包編譯的過程中,會通過babel進行轉化,會對jsx中的html片段進行解析,解析出來標籤名、屬性集、子元素,並且作為引數傳遞到React提供的createElement方法中執行。如上面程式碼的轉換結果為:
// babel編譯轉換結果
const element = React.createElement("h1", null, "Hello, react!");
可以看到,babel轉換的時候,識別到這是一個h1標籤,並且標籤上沒有任何屬性,所以屬性集為null,其有一個子元素,純文字"Hello, react!",所以經過babel的這麼一個騷操作,React就可以支援jsx語法了。因為這個轉換過程是由babel完成的,所以我們也可以通過安裝babel的jsx轉換包,從而讓我們自己的專案程式碼也可以支援jsx語法。
三、讓我們的專案支援jsx語法
因為我們要實現一個簡單的react,由於我們使用react程式設計的時候是可以使用jsx語法的,所以我們首先要讓我們的專案支援jsx語法。
① 新建一個名為my-react的專案
在專案根目錄下新建一個src目錄,裡面存放一個index.js作為專案的入口檔案,以及一個public目錄,裡面存放一個index.html檔案,作為單頁面專案的入口html頁面,如:
cd /path/to/my-react // 進入到專案根目錄下
npm init --yes // 自動生成專案的package.json檔案
// project_root/src/index.js 內容
const element = <h1>hello my-react</h1>;
// project_root/public/index.html 內容
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>my-react</title>
</head>
<body>
<div id="root"></div>
<script src="../src/index.js"></script>
</body>
</html>
② 安裝 parcel-bundler
模組
parcel-bundler是一個打包工具,它速度非常快,並且可以零配置,相對webpack而言,不需要進行復雜的配置即可實現web應用的打包,並且可以以任何型別的檔案作為打包入口,同時自動啟動內建的web伺服器方便除錯。
// 安裝parcel-bundler
npm install parcel-bundler --save-dev
// 修改package.json,執行parcel-bundler命令並傳遞入口檔案路徑作為引數
{
"scripts": {
"start": "parcel -p 8080 ./public/index.html"
}
}
// 啟動專案
npm run start
parcel啟動的時候會在8080埠上啟動Web伺服器,並且以public目錄下的index.html檔案作為入口檔案進行打包,因為index.html檔案中有一行<script src="../src/index.js"></script>
,所以index.html依賴src目錄下的index.js,所以又會編譯src目錄下的index.js並打包。
此時執行npm run start
會報錯,因為此時還不支援jsx語法的編譯。
③ 安裝@babel/plugin-transform-react-jsx
模組
安裝好@babel/plugin-transform-react-jsx
模組後,還需要新建一個.babelrc檔案,配置如下:
// .babelrc
{
"plugins": [
["@babel/plugin-transform-react-jsx", {
"pragma": "React.createElement" // default pragma is React.createElement
}]
]
}
其作用就是,遇到jsx語法的時候,將解析後的結果傳遞給React.createElement()方法,預設是React.createElement,可以自定義。此時編譯就可以通過了,可以檢視編譯後的結果,如下:
var element = React.createElement("h1", null, "hello my-react");
四、實現React.createElement
此時專案雖然能編譯jsx了,但是執行的時候會報錯,因為還沒有引入React以及其createElement()方法,React的createElement()方法作用就是建立虛擬DOM,虛擬DOM其實就是一個普通的JavaScript物件,裡面包含了tag、attrs、children等屬性。
① 在src目錄下新建一個react目錄
在react目錄下新建一個index.js作為模組的預設匯出,裡面主要就是createElement方法的實現,babel解析jsx後,如果有多個子節點,那麼所有的子節點都會以引數的形式傳入createElement函式中,所以createElement的第三個引數可以用es6剩餘引數語法,以一個陣列的方式來接收所有的子節點,如:
// src/react/index.js
// 作用就是接收babel解析jsx後的結果作為引數,建立並返回虛擬DOM節點物件
function createElement(tag, attrs, ...children) {
attrs = attrs || {}; // 如果元素的屬性為null,即元素上沒有任何屬性,則設定為一個{}空的物件
const key = attrs.key || null; // 如果元素上有key,則去除key,如果沒有則設定為null
if (key) {
delete attrs.key; // 如果傳了key,則將key屬性從attrs屬性物件中移除
}
return { // 建立一個普通JavaScript物件,並將各屬性新增上去,作為虛擬DOM進行返回
tag,
key,
attrs,
children
}
}
export default {
createElement // 將createElement函式作為react的方法匯出
}
至此,react上已經新增了createElement函式了,然後在src/index.js中引入react模組即可。
// src/index.js
import React from "./react"; // 引入react模組
const element = <h1>hello my-react</h1>;
console.log(element);
引入react後,由於React上有了createElement方法,所以可以正常執行,並且拿到返回的虛擬DOM節點,如下:
五、實現ReactDOM.render
此時,我們已經能夠拿到對應的虛擬DOM節點了,由於虛擬DOM只是一個普通的JavaScript物件,不是真正的DOM,所以需要對虛擬DOM進行render,建立對應的真實DOM並新增到頁面中,才能在頁面中看到,react中專門提供了一個ReactDOM模組用於處理DOM相關的操作。
① 在src目錄下新建一個react-dom目錄
在react-dom目錄下新建一個index.js作為模組的預設匯出,並在其中建立一個render方法並對外暴露,render函式需要接收一個虛擬DOM節點和一個掛載點,即將虛擬DOM渲染成了真實DOM後,需要將其掛載到哪裡,這個掛載點就是一個容器,即應用的根節點。
// src/react-dom/index.js
// 負責將虛擬DOM渲染到容器之下
function render(vnode, container) {
if (container) {
container.appendChild(_render(vnode)); // 虛擬DOM渲染成真實DOM後將其加入到容器之下
}
}
// 負責將虛擬DOM轉換為真實DOM
function _render(vnode) {
}
export default {
render
}
render函式主要就是將傳入的虛擬DOM渲染成真實的DOM之後,再將其加入到容器內。這點和Vue是不同的,Vue是將根元件渲染成真實DOM後,再替換掉容器節點。
接下來就是實現_render()函式,主要就是對傳入的虛擬DOM型別進行判斷並進行相應的處理,建立出對應的DOM節點。
function _render(vnode) {
if (typeof vnode === "undefined" || vnode === null || typeof vnode === "boolean") {
vnode = ""; // 如果傳入的虛擬DOM是undefined、null、true、false,則直接轉換為空字串
}
if (typeof vnode === "number") {
vnode = String(vnode); // 如果傳入的虛擬DOM是數字,那麼將其轉換為字串形式
}
if (typeof vnode === "string") { // 如果傳入的虛擬DOM是字串,則直接建立一個文字節點即可
return document.createTextNode(vnode);
}
const {tag, attrs, children} = vnode;
const dom = document.createElement(tag);
if (attrs) {
Object.keys(attrs).forEach((key) => { // 遍歷屬性
const value = attrs[key];
setAttribute(dom, key, value); // 設定屬性
});
}
if (children) {
children.forEach((child) => {
render(child, dom); // 遞迴渲染子節點
});
}
return dom;
}
接下來實現setAttribute()方法,主要就是給DOM元素設定屬性、樣式、事件等。
function setAttribute(dom, key, value) {
if (key === "className") {
key = "class";
}
if (/on\w+/.test(key)) {
key = key.toLowerCase();
dom[key] = value || "";
} else if(key === "style") {
if (!value || typeof value === "string") {
dom.style.cssText = value || "";
} else if (value && typeof value === "object") {
for (let key in value) {
if (typeof value[key] === "number") {
dom.style[key] = value[key] + "px";
} else {
dom.style[key] = value[key];
}
}
}
} else {
if (key in dom) { // 如果是dom的原生屬性,直接賦值
dom[key] = value || "";
}
if (value) {
dom.setAttribute(key, value);
} else {
dom.removeAttribute(key);
}
}
}
測試是否可以渲染一段JSX。
// src/index.js
import React from "./react"; // 引入react模組
import ReactDOM from "./react-dom"; // 引入react-dom模組
function doClick() {
console.log("doClick method run.");
}
const element = <h1 onClick={doClick}>hello my-react</h1>;
console.log(element);
ReactDOM.render(element, document.getElementById("root"));
這裡繫結了一個onClick事件,此時啟動專案執行,可以看到頁面上已經能看到渲染後的結果了,並且點選文字,可以看到事件處理函式執行了。
六、實現元件功能
此時已經完成了基本的宣告式渲染功能了,但是目前只能渲染html中存在的標籤元素,而我們的react是支援自定義元件的,可以讓其渲染出我們自定義的標籤元素。react中的元件支援函式元件和類元件,函式元件的效能比類元件的效能要高,因為類元件使用的時候要例項化,而函式元件直接執行函式取返回結果即可。為了提高效能,儘量使用函式元件。但是函式元件沒有this、沒有生命週期、沒有自己的state狀態。
① 首先實現函式元件功能
函式元件相對較簡單,我們先看一下怎麼使用函式元件,就是直接定義一個函式,然後其返回一段jsx,然後將函式名作為自定義元件名,像html標籤元素一樣使用即可,如:
// src/index.js
import React from "./react"; // 引入react模組
import ReactDOM from "./react-dom"; // 引入react-dom模組
function App(props) {
return <h1>hello my-{props.name}-function</h1>
}
console.log(<App name="react"/>);
ReactDOM.render(<App name="react"/>, document.getElementById("root"));
<App name="react"/>
經過babel轉換之後,tag就變成了App函式,所以我們不能直接通過document.createElement("App")去建立App元素了,我們需要執行App()函式拿到其返回值<h1>hello my-{props.name}</h1>
,而這個返回值是一段jsx,所以會被babel轉換為一個虛擬DOM節點物件,我們只需要執行該函式就能拿到該函式元件對應的虛擬DOM節點了,然後將函式元件對應的虛擬DOM轉換為真實DOM並加入到其父節點之下即可,如:
// 修改src/react-dom/index.js
function _render(vnode) {
......
if (typeof tag === "function") { // 如果是函式元件
const vnode = tag(attrs); // 執行函式並拿到對應的虛擬DOM節點
return _render(vnode); // 將虛擬DOM節點渲染為真實的DOM節點,並加入到其父節點下
}
......
}
函式元件渲染如下:
②支援類元件
在定義類元件的時候,是通過繼承React.Component類的,所以我們需要建立一個元件基類即Component,在src/react目錄下新建一個component.js檔案,如下:
// src/react/component.js
class Component {
constructor(props = {}) {
this.props = props; // 儲存props屬性集
this.state = {}; // 儲存狀態資料
}
}
export default Component;
我們在看一下類元件的使用方式,如下:
// src/index.js
class App extends React.Component {
constructor(props) {
super(props);
this.state = {
count: 0
}
}
render() {
return <h1>hell my-{this.props.name}-class-state-{this.state.count}</h1>
}
}
console.log(<App name="react"/>);
ReactDOM.render(<App name="react"/>, document.getElementById("root"));
<App name="react"/>
元件經過babel轉換後,tag變成了一個class函式,如果class類函式的原型上有render()方法,那麼就是一個類元件,我們可以通過類元件的類名建立出對應的類元件物件,然後呼叫其render()函式拿到對應的虛擬DOM節點即可。
// 修改src/react-dom/index.js
function _render(vnode) {
......
if (tag.prototype && tag.prototype.render) { // 如果是類元件
const comp = new tag(attrs); // 通過類建立出對應的元件例項物件
setComponentProps(comp, attrs); // 設定元件例項的屬性
return comp.base; // 返回類元件渲染後掛在元件例項上的真實DOM
} else if (typeof tag === "function") {
const vnode = tag(attrs); // 執行函式並拿到對應的虛擬DOM節點
return _render(vnode); // 將虛擬DOM節點渲染為真實的DOM節點,並加入到其父節點下
}
......
}
實現setComponentProps(),主要就是設定元件的屬性並開始啟動元件的渲染。
// 給元件設定屬性,並開始渲染元件
function setComponentProps(comp, attrs) {
comp.props = attrs;
renderComponent(comp); // 啟動元件的渲染
}
實現renderComponent(),主要就是執行元件例項的render()方法拿到對應的虛擬DOM,然後將虛擬DOM渲染為真實DOM並掛在元件例項上,如:
// 渲染元件,根據元件的虛擬DOM渲染成真實DOM
export function renderComponent(comp) {
const vnode = comp.render(); // 執行元件的render()函式拿到對應的虛擬DOM
const base = _render(vnode); // 將元件對應的虛擬DOM渲染成真實DOM
comp.base = base; // 將元件對應的真實DOM掛在元件例項上
}
類元件渲染結果:
七、讓類元件支援setState
react中setState是Component中的一個方法,用於修改元件的狀態資料的。當元件中呼叫setState函式的時候,元件的狀態資料被更新,同時會觸發元件的重新渲染,所以需要修改Component.js並在其中新增一個setState函式。如:
// src/react/component.js
import {renderComponent} from "../react-dom/index";
class Component {
constructor(props = {}) {
this._container = null; // 儲存元件所在容器
}
setState(stateChange) {
Object.assign(this.state, stateChange); // 更新狀態資料
renderComponent(this); // 重新渲染元件
}
}
此時元件呼叫setState之後會改變元件的狀態,然後呼叫renderComponent()方法進行元件的重新渲染,但是此時元件並沒有重新渲染,因為目前renderComponent()方法只是負責執行元件例項的render()方法拿到對應的虛擬DOM然後將其渲染為真實DOM,此時只是建立出了真實DOM並沒有掛載到DOM樹中,所以我們需要判斷當前元件是否已經渲染過,如果是渲染過了,那麼我們可以通過之前渲染的真實DOM找到其父節點,然後用最新的DOM替換掉之前舊的DOM即可。
// 修改renderComponent
export function renderComponent(comp) {
const vnode = comp.render(); // 執行元件的render()函式拿到對應的虛擬DOM
const base = _render(vnode); // 將元件對應的虛擬DOM渲染成真實DOM
if (comp.base) { // base存在表示已經渲染過了
// 找到上一次渲染結果的父節點,並用最新渲染的DOM替換掉之前舊的真實DOM
comp.base.parentNode.replaceChild(base, comp.base);
}
comp.base = base; // 將元件對應的真實DOM掛在元件例項上
}
測試類元件渲染:
// src/index.js上測試
class App extends React.Component {
constructor(props) {
super(props);
this.state = {
count: 0
}
}
doClick() {
this.setState({
count: 1
});
}
render() {
return <h1 onClick={this.doClick.bind(this)}>hell my-{this.props.name}-class-state-{this.state.count}</h1>
}
}
console.log(<App name="react"/>);
ReactDOM.render(<App name="react"/>, document.getElementById("root"));
八、支援生命週期
在啟動渲染前主要有componentWillMount和componentWillReceiveProps兩個生命週期,如果啟動渲染前,元件還沒有建立出來,那麼就會執行componentWillMount,如果元件已經建立,那麼就會執行componentWillReceiveProps。
function setComponentProps(comp, attrs) {
if (!comp.base) { // 如果啟動渲染前,元件沒有對應的真實DOM,則表示首次渲染,執行componentWillMount
if (comp.componentWillMount) {
comp.componentWillMount();
}
} else if(comp.componentWillReceiveProps) { // 如果啟動渲染前,元件有對應的真實DOM,則表示非首次渲染,則執行componentWillReceiveProps
comp.componentWillReceiveProps();
}
comp.props = attrs;
renderComponent(comp); // 啟動元件的渲染
}
啟動渲染之後主要有componentDidMount、componentWillUpdate、componentDidUpdate三個生命週期。啟動渲染之後,如果元件還沒有建立出來,那麼執行componentDidMount,如果元件已經建立,那麼執行componentWillUpdate、componentDidUpdate。
export function renderComponent(comp) {
const vnode = comp.render(); // 執行元件的render()函式拿到對應的虛擬DOM
if (comp.base && comp.componentWillUpdate) { // 如果元件已經渲染過,則執行componentWillUpdate
comp.componentWillUpdate();
}
const base = _render(vnode); // 將元件對應的虛擬DOM渲染成真實DOM
if (comp.base) { // base存在表示已經渲染過了
// 找到上一次渲染結果的父節點,並用最新渲染的DOM替換掉之前舊的真實DOM
comp.base.parentNode.replaceChild(base, comp.base);
if (comp.componentDidUpdate) { // 將新的DOM替換舊的DOM後,如果元件存在真實DOM則執行componentDidUpdate
comp.componentDidUpdate();
}
} else { // 如果元件還沒有渲染過,則執行componentDidMount
if (comp.componentDidMount) {
comp.componentDidMount();
}
}
comp.base = base; // 將元件對應的真實DOM掛在元件例項上
}
九、優化setState
此時如果我們在元件渲染完成後執行如下程式碼,我們可以發現,會執行10次setState操作,同時元件也會被連續更新10次,這樣非常損耗效能,其實沒有必要更新10次,我們只需要更新10次狀態,然後用最後的狀態更新一次元件即可。我們可以在執行setState的時候不立即更新元件,而是將狀態和元件進行快取起來,等所有狀態都更新完畢之後再一次性更新元件。
for (let i = 0; i < 10; i++) {
this.setState({ // 這裡只是一個入棧的操作,元件的狀態還沒有發生變化
num: this.state.num + 1
});
console.log(this.state.num); // 元件狀態還沒有變化,所以仍然為0
}
在react模組下新建一個set_state_queue.js,其對外暴露一個enqueueSetState()函式,負責狀態和元件的入棧,如果當前狀態棧為空,則開啟一個微任務,等元件狀態和元件都入棧完畢之後再開啟一次性更新操作。
// react/set_state_queue.js
import {renderComponent} from "../react-dom/index";
let stateQueue = []; // 狀態棧
let renderQueue = []; // 元件棧
function defer(fn) {
return Promise.resolve().then(fn)
}
export function enqueueSetState(stateChange, component) {
if (stateQueue.length === 0) { // 如果狀態棧為空,則開啟一個微任務,等狀態入棧完畢之後再開啟元件的一次性更新
defer(flush);
}
stateQueue.push({ // 將狀態資料入棧
stateChange,
component
});
const hasComponent = renderQueue.some((item) => { // 判斷元件棧中是否已經入棧過該元件
return item === component;
});
if (!hasComponent) { // 如果該元件沒有入棧過,則入元件棧
renderQueue.push(component);
}
}
實現flush,主要就是先遍歷狀態棧,在真實的React中,stateChange可以是物件,也可以是函式,是函式的時候會傳入上一次的狀態和元件的props,然後返回一個新的狀態,再與元件的狀態進行合併。由於stateChange為物件的時候,拿不到之前的狀態,所以不管合併多少次都相當於只合並了一次,stateChange為函式的時候,可以拿到之前的狀態,所以合併多次,最終狀態也會變化多次。接著遍歷元件棧,重新渲染該元件一次即可。
// react/set_state_queue.js
function flush() {
stateQueue.forEach((item) => {
const {stateChange, component} = item;
if (!component.prevState) { // 初始化prevState
component.prevState = component.state;
}
// 合併狀態,每次遍歷都會更新元件的state
if (typeof stateChange === "function") {
Object.assign(component.state, stateChange(component.prevState, component.props));
} else { // stateChange為物件的時候,因為呼叫setState的時候,元件狀態還沒有變化,所以每次遍歷stateChange都是一樣的,此時不管執行多少次,相當於執行了一次
Object.assign(component.state, stateChange);
}
// 將最新的狀態儲存為prevState,以便在stateChange為函式的時候能夠拿到最新的狀態
component.prevState = component.state;
});
stateQueue = []; // 清空狀態棧
// 遍歷元件棧
renderQueue.forEach((component) => {
renderComponent(component);
});
renderQueue = []; // 清空元件棧
}
十、總結
至此,已經基本實現react的基本功能,包括宣告式渲染、元件支援、setSate、生命週期。其過程為,首先通過babel將jsx語法進行編譯轉換,babel會將jsx語法解析為三部分,標籤名、屬性集、子節點,然後用React.createElement()函式進行包裹,react實現createElement函式,用於建立虛擬DOM節點,然後呼叫render()函式對虛擬DOM節點進行分析,並建立對應的真實DOM,然後掛載到頁面中。然後提供自定義元件的支援,自定義元件,無非就是將jsx定義到了函式和類中,如果是函式,那麼就直接執行就可返回對應的jsx,也即拿到了對應的虛擬DOM,如果是類,那麼就建立元件類例項,然後呼叫其render()函式,那麼也可以拿到對應的jsx,也即拿到了對應的虛擬DOM,然後掛載到頁面中。類元件中新增setSate函式,用於更新元件例項上的資料,然後setState函式會觸發元件的重新渲染,從而更新渲染出帶最新資料的元件。