关于react.js:React基础与原理实现

6次阅读

共计 13153 个字符,预计需要花费 33 分钟才能阅读完成。

一、前言

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 对象,外面蕴含了 tagattrschildren 等属性。
① 在 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"));

八、反对生命周期

在启动渲染前次要有 componentWillMountcomponentWillReceiveProps两个生命周期,如果启动渲染前,组件还没有创立进去,那么就会执行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); // 启动组件的渲染
}

启动渲染之后次要有 componentDidMountcomponentWillUpdatecomponentDidUpdate 三个生命周期。启动渲染之后,如果组件还没有创立进去,那么执行componentDidMount,如果组件曾经创立,那么执行componentWillUpdatecomponentDidUpdate

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 函数会触发组件的从新渲染,从而更新渲染出带最新数据的组件。

正文完
 0