React基础与原理实现

一、前言

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></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></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节点,如下:
React基础与原理实现

五、实现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事件,此时启动项目执行,可以看到页面上已经能看到渲染后的结果了,并且点击文字,可以看到事件处理函数执行了。
React基础与原理实现

六、实现组件功能

此时已经完成了基本的声明式渲染功能了,但是目前只能渲染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基础与原理实现

②支持类组件
在定义类组件的时候,是通过继承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挂在组件实例上

}

类组件渲染结果:
React基础与原理实现

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

以上是 React基础与原理实现 的全部内容, 来源链接: utcz.com/a/67365.html

回到顶部