一、前言

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-bundlernpm 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.jsimport 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转换为实在DOMfunction _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.jsimport 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.jsimport 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.jsfunction _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.jsclass Component {    constructor(props = {}) {        this.props = props; // 保留props属性集        this.state = {}; // 保留状态数据    }}export default Component;

咱们在看一下类组件的应用形式,如下:

// src/index.jsclass 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.jsfunction _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渲染成实在DOMexport 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.jsimport {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即可。

// 批改renderComponentexport 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.jsimport {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.jsfunction 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函数会触发组件的从新渲染,从而更新渲染出带最新数据的组件。