# LgReact **Repository Path**: Dominguito/lg-react ## Basic Information - **Project Name**: LgReact - **Description**: 簡單手寫一個React - **Primary Language**: JavaScript - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2021-03-04 - **Last Updated**: 2021-03-08 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 手寫 React 通過实現一個簡單版的React,以了解React的運行原理。相關代碼放置在gitee: [點我看代碼](https://gitee.com/Dominguito/lg-react) ## 環境搭建 創建分支 `init`,記錄下環境搭建。 首先要搭建環境。先看一下`package.json`,以知道項目要依賴什么: ```javascript "devDependencies": { "@babel/core": "^7.11.4", "@babel/preset-env": "^7.11.0", "@babel/preset-react": "^7.10.4", "babel-loader": "^8.1.0", "clean-webpack-plugin": "^3.0.0", "html-webpack-plugin": "^4.3.0", "webpack": "^4.44.1", "webpack-cli": "^3.3.12", "webpack-dev-server": "^3.11.0" } ``` 我們需要babel來解析React的jsx,上述的babel依賴都是日常React常使用的,就不多說了。還要有webpack來幫我們打包實現的React,創建 `webpack.config.js`: ```javascript const path = require("path") const HtmlWebpackPlugin = require("html-webpack-plugin") const { CleanWebpackPlugin } = require("clean-webpack-plugin") module.exports = { entry: "./src/index.js", output: { path: path.resolve("dist"), filename: "bundle.js" }, devtool: "inline-source-map", module: { rules: [ { test: /\.js$/, exclude: /node_modules/, use: "babel-loader" } ] }, plugins: [ // 在构建之前将dist文件夹清理掉 new CleanWebpackPlugin({ cleanOnceBeforeBuildPatterns: ["./dist"] }), // 指定HTML模板, 插件会将构建好的js文件自动插入到HTML文件中 new HtmlWebpackPlugin({ template: "./src/index.html" }) ], devServer: { // 指定开发环境应用运行的根据目录 contentBase: "./dist", // 指定控制台输出的信息 stats: "errors-only", // 不启动压缩 compress: false, host: "localhost", port: 5000 } } ``` 入口文件是 `./src/index.js`,頁面模板是`./src/index.html`,所以要創建相應的入口文件和頁面。 環境基本搭建完成了。 創建一個分支叫 **init**,用來記錄基本開發環境 ## 創建和掛載虛擬DOM 創建分支 `render`,記錄实現渲染的過程 首先要了解虛擬DOM,可以看 [你不知道的Virtual DOM](https://segmentfault.com/a/1190000016129036),這里主要關注實現React。 React借助babel把jsx變成Virtual Dom: ```javascript

Hello React

React is great

{ type: "div", props: { className: "container" }, children: [ { type: "h3", props: null, children: [ { type: "text", props: { textContent: "Hello React" } } ] }, { type: "p", props: null, children: [ { type: "text", props: { textContent: "React is great" } } ] } ] } ``` 由於babel會把jsx轉成 `React.createElement`方法,里面轉入Virtual Dom內容: ```javascript

Hello

React.createElement("h1", null, "Hello"); ``` 而我們做的是TinyReact,所以在項目根目錄要建立 `.babelrc`,在 `.babelrc`做點調整: ```javascript { "presets": [ "@babel/preset-env", [ "@babel/preset-react", { "pragma": "TinyReact.createElement" } ] ] } ``` 現在可以在 `src`里建一個 `TinyReact`文件夾,里面新建 `index.js`: ```javascript function createElement (type, props, ...children) { return { type, // tag props, children } } export default { createElement } ``` `createElement` 方法返回一個虛擬Dom對象。現在在`src`的`index.js`做測試: ```javascript import TinyReact from './TinyReact' const virtualDOM = (

你好 Tiny React

(编码必杀技)

嵌套1
嵌套 1.1

(观察: 这个将会被改变)

{2 == 1 &&
如果2和1相等渲染当前内容
} {2 == 2 &&
2
} 这是一段内容

这个将会被删除

2, 3
) console.log(virtualDOM) ``` 在命令行中輸入 `npm start`,在瀏覧器的控制台可以看到: ```javascript {type: "div", props: {…}, children: Array(11)} children: (11) [{…}, {…}, {…}, {…}, false, {…}, {…}, {…}, {…}, "2, 3", {…}] props: {className: "container"} type: "div" __proto__: Object ``` 但現在有些問題。首先Virtual Dom的文本不是對象包裝起來,還有它把表達式判斷的值也保留下來,其實是不需要的,因此要修改一下 `createElement`: ```javascript function createElement (type, props, ...children) { const childElement = [].concat(...children).reduce((result,child) => { if (child !== null || child !== true || child !== false) { if (child instanceof Object) { result.push(child) } else { result.push(createElement ('text', {textContent: child})) } } return result }, []) return { type, // tag props: Object.assign({children: childElement}, props), children: childElement } } ``` 如果是 `null`, `true`和`false`就不要,如果是文本,就再調用`createElement`封裝,用一個數組保存起來。由於props也有children屬性,所以要增加。 VirtualDOM完成後,要考慮渲染問題。因此在`src/TinyReact`創建 `render`: ```javascript import diff from './diff' // container是要在頁面中掛載的根節點 export default function render (virtualDOM, container , oldDOM) { diff(virtualDOM, container, oldDOM) // 如果有舊節點,則對比新舊節點 } ``` 由於之後可能有舊節點,要對比後才能重新渲染,所以要有`diff`方法,也是在`src/TinyReact`創建: ```javascript export default function diff(virtualDOM, container, oldDOM) { if (!oldDOM) { mountElement(virtualDOM, container) } } ``` 如果沒有舊節點,直接渲染。看下 `mountElement`: ```javascript import mountNativeElement from './mountNativeElement' export default function mountElement (virtualDOM, container) { mountNativeElement(virtualDOM, container) } ``` 現在只處理函數型組件 ```javascript import mountElement from "./mountElement" export default function mountNativeElement (virtualDOM, container) { let newElement = null if (virtualDOM.type === 'text') { // 如果是文本節點 newElement = document.createTextNode(virtualDOM.props.textContent) } else { // 普通節點 newElement = document.createElement(virtualDOM.type) } virtualDOM.children.forEach(child => { // 遞歸處理子節點,掛載到父節點的DOM上 mountElement(child, newElement) }) // 把創建的DOM節點增加到容器上 container.appendChild(newElement) } ``` VirtualDOM變成DOM大概完成,因為之後其他地方也要創建DOM,所以把代碼封裝一下: ```javascript import createDOMElement from './createDOMElement' export default function mountNativeElement (virtualDOM, container) { let newElement = createDOMElement(virtualDOM) container.appendChild(newElement) } ``` ```javascript import mountElement from './mountElement' export default function createDOMElement (virtualDOM) { let newElement = null if (virtualDOM.type === 'text') { newElement = document.createTextNode(virtualDOM.props.textContent) } else { newElement = document.createElement(virtualDOM.type) } virtualDOM.children.forEach(child => { mountElement(child, newElement) }) return newElement } ``` 接下來要為DOM增加屬性。 ```javascript import mountElement from './mountElement' import updateNodeElement from './updateNodeElement' export default function createDOMElement (virtualDOM) { let newElement = null if (virtualDOM.type === 'text') { newElement = document.createTextNode(virtualDOM.props.textContent) } else { newElement = document.createElement(virtualDOM.type) updateNodeElement(newElement, virtualDOM) } virtualDOM.children.forEach(child => { mountElement(child, newElement) }) return newElement } ``` 增加了`updateNodeElement(newElement, virtualDOM)`的方法。 屬性存放在props上。因為不同屬性的處理方法不同,所以要分別處理: ```javascript export default function updateNodeElement (newElement, virtualDOM) { const newProps = virtualDOM.props Object.keys(newProps).forEach(propName => { const newPropsValue = newProps[propName] if (propName.slice(0, 2) === "on") { // 增加事件 const eventName = propName.toLowerCase().slice(2) newElement.addEventListener(eventName, newPropsValue) } else if (propName === "value" || propName === "checked") { // value和checked不允許用setAttribute newElement[propName] = newPropsValue } else { if (propName === "className") { // 轉為class newElement.setAttribute("class", newPropsValue) } else { newElement.setAttribute(propName, newPropsValue) } } }) } ``` 屬性部分完成後,還要考慮渲染組件。組件的特點是 `type`是`function`,所以創建`isFunction`判斷是否為函數: ```javascript export default function isFunction (virtualDOM) { return virtualDOM && virtualDOM.type === "function" } ``` 修改下`mountElement`: ```javascript import mountNativeElement from './mountNativeElement' import isFunction from './isFunction' export default function mountElement (virtualDOM, container) { if (isFunction(virtualDOM)) { // type是函數則渲染組件 mountComponent(virtualDOM, container) } mountNativeElement(virtualDOM, container) } ``` `mountComponent`首先要判斷是函數還是類組件,因此要有`isFunctionComponent`方法。類組件的一大特點是原型方法上有render方法: ```javascript import { prototype } from 'html-webpack-plugin' import isFunction from './isFunction' export default function isFunctionComponent (virtualDOM) { const type = virtualDOM.type return type && isFunction(virtualDOM) && !(type.prototype && type.prototype.render) } ``` 如果是函數型組件,我們要先調用屬性type的函數,取出virtualDOM後進行渲染,但要注意可能返回也是一個組件,所以要先判斷是否為組件,是的話,遞歸調用`mountComponent`: ```javascript import isFunction from './isFunction' import isFunctionComponent from './isFunctionComponent' import mountNativeElement from './mountNativeElement' export default function mountComponent(virtualDOM, container) { let nextVirtualDOM = null if (isFunctionComponent(virtualDOM)) { nextVirtualDOM = buildFunctionComponent(virtualDOM) } if (isFunction(nextVirtualDOM)) { mountComponent (virtualDOM, container) } else { mountNativeElement(nextVirtualDOM, container) } } function buildFunctionComponent (virtualDOM) { return virtualDOM.type(virtualDOM.props || {}) } ``` 因為有可能組件傳入props,所以要傳入`virtualDOM.props`,如果沒有則傳空對象。 還有類組件要處理。類組件主要是要調用它的`render`方法來取出virtualDOM: ```javascript import isFunction from './isFunction' import isFunctionComponent from './isFunctionComponent' import mountNativeElement from './mountNativeElement' export default function mountComponent(virtualDOM, container) { let nextVirtualDOM = null if (isFunctionComponent(virtualDOM)) { nextVirtualDOM = buildFunctionComponent(virtualDOM) } else { // 類組件渲染 nextVirtualDOM = buildClassComponent(virtualDOM) } if (isFunction(nextVirtualDOM)) { mountComponent (virtualDOM, container) } else { mountNativeElement(nextVirtualDOM, container) } } ... function buildClassComponent(virtualDOM) { const type = new virtualDOM.type(virtualDOM.props) const nextVirtualDOM = type.render() return nextVirtualDOM } ``` 因為平時類組件是繼承`React.Component`,`props`是傳入父類的,所以我們也要創建`Component`: ```javascript export default class Component { constructor (props) { this.props = props } } ``` 渲染部分基本完成了。 在`src/index.js`中測試下: ```javascript class CDemo extends TinyReact.Component { render() { return
Class Component
} } function Demo () { return
Demo
} function Heart () { return (
Heart
) } TinyReact.render (, root) ``` 內容可以渲染出來,也就沒有問題了。 ## 更新 創建`update`分支,記錄實現差異對比的過程。 因為要更新節點,所以要存下之前舊的VirtualDOM做對比。在`createDOMElement`中增加: ```javascript newElement._virtualDOM = virtualDOM ``` 存下舊節點。之後回到`render`,傳參部分作點修改: ```javascript export default function render (virtualDOM, container, oldDOM = container.firstChild) ``` 因為舊節點的第一個子節點一定是元素`div`包含的節點,所以取第一個子節點就可以了。現在去真正實現`diff`: ```javascript import mountElement from './mountElement' export default function diff(virtualDOM, container, oldDOM) { const oldVirtualDOM = oldDOM && oldDOM._virtualDOM if (!oldDOM) { mountElement(virtualDOM, container) } else if (oldVirtualDOM && oldVirtualDOM.type === virtualDOM.type) { if (virtualDOM.type === "text") { // 更新文本節點 updateTextNode(virtualDOM, oldVirtualDOM, oldDOM) } else { // 更新元素節點 } } virtualDOM.children.forEach((child, i) => { // 遞歸把子節點也更新 diff(child, oldDOM, oldDOM.childNodes[i]) }) } ``` 先處理同類型,內容不同的情況。如果是文本節點,就直接更新文本,然後替代之前的VirtualDOM: ```javascript export default function updateTextNode (virtualDOM, oldVirtualDOM, oldDOM) { if (virtualDOM.props.textContent !== oldVirtualDOM.props.textContent) { oldDOM.textContent = virtualDOM.props.textContent oldDOM._virtualDOM = virtualDOM } } ``` 對比完成後,還要遞歸對比子節點,子節點的`container`是父節點,所以是`oldDOM`,它的舊節點則用索引從父節點取出。 完後文本節點後,要考慮一般節點,我們可以調用`updateNodeElement`來處理: ```javascript if (!oldDOM) { mountElement(virtualDOM, container) } else if (oldVirtualDOM && oldVirtualDOM.type === virtualDOM.type) { if (virtualDOM.type === "text") { // 更新文本節點 updateTextNode(virtualDOM, oldVirtualDOM, oldDOM) } else { // 更新元素節點 updateNodeElement(oldDOM, virtualDOM, oldVirtualDOM) } } ``` 把`updateNodeElement`補完,讓它進行新舊VirtualDOM對比: ```javascript export default function updateNodeElement (newElement, virtualDOM, oldVirtualDOM = {}) { const oldProps = oldVirtualDOM.props || {} const newProps = virtualDOM.props || {} Object.keys(newProps).forEach(propName => { const newPropsValue = newProps[propName] || {} const oldPropsValue = oldProps[propName] || {} if (newPropsValue !== oldPropsValue) { if (propName.slice(0, 2) === "on") { // 增加事件 const eventName = propName.toLowerCase().slice(2) newElement.addEventListener(eventName, newPropsValue) if (oldPropsValue) { // 如果有舊事件存在,要刪除它 newElement.removeEventListener(eventName, oldPropsValue) } } else if (propName === "value" || propName === "checked") { // value和checked不允許用setAttribute newElement[propName] = newPropsValue } else { if (propName === "className") { // 轉為class newElement.setAttribute("class", newPropsValue) } else { newElement.setAttribute(propName, newPropsValue) } } } }) // 刪除新節點沒有的屬性 Object.keys(oldProps).forEach(propName => { const newPropsValue = newProps[propName] const oldPropsValue = oldProps[propName] // 屬性被刪除了 if (!newPropsValue) { if (propName.slice(0,2) === 'on') { const eventName = propName.toLowerCase().slice(2) newElement.removeEventListener(eventName, oldPropsValue) } else if (propName !== "children") { newElement.removeAttribute(propName) } } }) } ``` `oldVirtualDOM = {}`是要防止沒有傳入`oldVirtualDOM`,如果新舊節點的`props`有不相等的,就直接替換,但要考慮移除舊的事件,最後要刪除新節點沒有的屬性。 如果新舊節點是類型不同而且不是組件的情況,回到`diff`增加代碼: ```javascript else if (oldVirtualDOM && oldVirtualDOM.type !== virtualDOM.type && typeof virtualDOM.type !== "function") { // 新舊節點類型不同而且不是組件的情況 const newElement = createDOMElement(virtualDOM) // 直接取代 oldDOM.parentNode.replaceChild(newElement, oldDOM) } ``` 删除节点发生在节点更新以后并且发生在同一个父节点下的所有子节点身上。 在节点更新完成以后,如果旧节点对象的数量多于新 VirtualDOM 节点的数量,就说明有节点需要被删除。 ```javascript // 获取就节点的数量 let oldChildNodes = oldDOM.childNodes // 如果旧节点的数量多于要渲染的新节点的长度 if (oldChildNodes.length > virtualDOM.children.length) { for ( let i = oldChildNodes.length - 1; i > virtualDOM.children.length - 1; i-- ) { unmountNode(oldChildNodes[i]) } } ``` 現在只是簡單遍歴刪除,之後要進行優化,換成用索引來刪除。