前言
作为一个react刚入门的小白,趁放假看了珠峰架构的从零实现react视频,跟着敲了一下,为了加深记忆和理解,写了这篇博客。
文中代码来自于视频,我做的就是根据自己的理解梳理了一遍整个逻辑,所以本文严格意义上不算一篇博客,应该是算学习笔记。
如果说的有不正确的地方欢迎评论指出!
一个最简单的实现
调用
// index.js
import React from './react';
React.render('hello world', document.getElementById('root'));
实现
上面两行代码的结果是在一个id是root的容器里,塞了一个hello world字符串,因此我们可以简单的定义render方法
// react.js
function render(el, container) {
$(container).html(el); // jQuery API
}
export default React {
render,
}
函数组件和类组件
但是我们很快会发现,我们平时并不是这么简单的定义一个组件,常用的我们说react定义组件的方式有函数和类两种。
但是这两种方式的el要怎么塞入container呢,我们可以先简单的看看他们的调用和babel转译后的结果。
函数组件
function App() {
return (
<div style="color: red" onClick={function() {aler(1)}}>
Hello
<span>world</span>
</div>
)
}
React.render(App(), document.getElementById('root'));
经过bebel转译后,发现他调用了React的ceateElement的方法,了解虚拟DOM的小伙伴可能对他可能不会陌生,不熟悉也没事,我们往后看:
React.render(
React.createElement(
'div',
{
style: "color: red",
onClick: function () { alert(1); }
},
'hello',
React.createElement('span', {}, 'world')
),
document.getElementById('root')
);
类组件
class App extends React.Component {
render () {
return (
<div style="color: red" onClick={function() {aler(1)}}>
Hello
<span>world</span>
</div>
)
}
}
React.render(App, document.getElementById('root'));
经过bebel转译后,我们发现他和函数组件非常类似,但是第一个参数从字符串变成了一个App类(function):
React.render(
React.createElement(App),
document.getElementById('root')
);
React.createElement
我们发现,这两种调用方式,经过转移之后都调用了React.createElement这个方法。
我们可以看看他的简单实现,第一个参数是Tag的元素类型,当我们用class声明组件的时候,jsx语法就是<App></App>,相应的,type就是APP,此时的App是一种方法。
// 方便判断对象是否是虚拟DOM,el instanceof DOM
class Element {
constructor(type, props) {
this.type = type;
this.props = props;
}
}
/**
* 返回虚拟DOM
* @param {string|function} type 节点类型
* @param {object} props 节点属性
* @param {array} children 子节点
*/
export default function createElement(type, props = {}, ...children) {
props.children = children; // array
return new Element(type, props);
}
React.createElement的返回是一个Element类,用类去构造他的好处是我们可以通过instanceof判断对象是否是虚拟DOM。
虚拟DOM
虚拟DOM说白了就是用一个对象表示一个DOM节点。
例如: <div id="app">hello</div>我们可以这么描述他,这是一个标签类型为div,属性是id,属性值是app,具有一个string类型子节点的节点。
把高亮部分提取出来,我们可以用对象描述这个节点,这个对象就是虚拟DOM。
const el = {
type: 'div',
props: {
id: 'app',
children: [
'hello'
]
},
}
使用虚拟DOM描述节点有什么好处呢?
我们知道操作真实DOM节点的代价是非常高的,当数据频繁发生更新的时候,直接操作真实DOM节点就会造成频繁的重绘和回流。
虽然一次重绘和回流只耗时几十ms,但是可能因为重绘,造成一个频繁的闪烁,用户体验非常不好。
因此,我们可以操作虚拟DOM,然后将虚拟DOM一次性绘制到页面上,当然,这个时候我们不需要将整个页面重新渲染,React和Vue都有diff算法,通过打补丁的方式更新改变的DOM节点,提升了更新的效率。
不过这个暂时不属于本文范围,有兴趣的小伙伴可以自行了解一下,说这个的目的就是解释一下为什么我们不直接把真实DOM节点传入render方法中,而是绕了一个弯传入了虚拟DOM这样一个js对象。
使用虚拟DOM的问题
第一个问题是,我们发现,React.createElement返回值是作为render方法的el入参传入的。
function render(el, container) {
$(container).html(el); // jQuery API
}
按照我们之前的简单实现,el应该是一个HTML字符串才能被渲染到容器中。
所以接下来我们要做的事情就是把虚拟DOM形式的对象,转换为我们的HTML字符串。
第二个问题是,既然使用了虚拟DOM,我们需要一个唯一标识去对应虚拟DOM和真实DOM节点。为了解决这个问题,我们可以给每个节点增加rootId,当然在新版中使用fiber代替,已经看不到它了。
对render函数的改造
现在我们发现,render方法的el参数有三种形式的入参数
- string类型或者number类型的简单符号
- Element类的虚拟DOM
- type是string类型的函数式组件
- type是function类型的类组件
这时候我们可以简单的构造一个工厂函数createReactUnit,根据不同的传入类型,用相应的方法返回一个HTML字符串。
createReactUnit这里return的是一个类的实例,通过实例的getMarkUp方法去获取HTML字符串,其实是为了代码的可扩展性,如果组件还有其他的操作,就可以通过类的不同方法实现。
// unit.js
class Unit {
constructor(el) {
this.curremtEl = el;
}
}
class ReactTextUnit extends Unit {
// 每个不同的子类分别重写getMarkUp方法,返回HTML字符串
// rootId的作用在后面解释,可以理解为区分节点设置的id值
getMarkUp(rootId) { ... }
}
class ReactNativeUnit extends Unit {
getMarkUp(rootId) { ... }
}
class ReactComponentUnit extends Unit {
getMarkUp(rootId) { ... }
}
// 对不同的el,根据我们刚刚三种形式入参的特征进行判断。
const creatReactUnit = function (el) {
// 如果是简单的字符串或者数字类型
if (typeof el === 'string' || typeof el === 'number') {
return new ReactTextUnit(el);
}
// 如果是函数式的组件
if (typeof el === 'object' && typeof el.type === 'string') {
return new ReactNativeUnit(el);
}
// 如果是class形式的
if (typeof el === 'object' && typeof el.type === 'function') {
return new ReactComponentUnit(el);
}
}
// react.js
function render(el, container) {
// 获取相应的单元实例
let creatReactUnitInstance = creatReactUnit(el);
// 调用实例的getMarkUp方法,获取相应的HTML字符串
// 这里有一个nextRootIndex参数,就是rootId
let mark = creatReactUnitInstance.getMarkUp(React.nextRootIndex);
// 将HTML字符串塞入容器中进行渲染
$(container).html(mark);
}
export default const React = {
render,
createElement,
Component,
nextRootIndex: 0,
}
编写getMarkUp方法
现在,我们就可以重写每个子类的getMarkUp方法,去生成HTML字符串。
rootId
为了区分不同的节点,方便对虚拟DOM进行操作,我们给每个节点添加一个data-rootid属性,形如:
<div data-rootid="0">
<div data-rootid="0.0">
<span data-rootid="0.0.0">hello</span>
<span data-rootid="0.0.1">world</span>
</div>
</div>
我们可以通过[data-rootid="0.0.0"]方便的选择到文本是hello的这个span。
String | number
其实string类型的字符串可以直接塞入到容器中,但是为了区分不同的节点,我们还是要给他们外面套一层span,用rootId去标识他们。
class ReactTextUnit extends Unit {
getMarkUp(rootId) {
this._rootId = rootId;
return `<div data-rootid="${rootId}">
${this.curremtEl}
</div>`;
}
}
函数式组件
这时候我们拿到的el是Element类,有type和prop两个属性,形如:
el = {
type: "div",
props:{
style: "color: red",
children: [
"app",
{ props: {…}, type: ƒ }
]
}
}
要将这个结构转化成HTML字符串,很容易想到递归,递归的出口就是当children数组的元素类型是基本类型。
例如<div>hello</div>可以拆解为一个elementNodediv和一个textNodehello,textNode没有children,因此递归结束,此时creatReactUnit方法返回的就是ReactTextUnit类的实例,调用getMarkUp方法就能获得相应的HTML字符串。
class ` extends Unit {
getMarkUp(rootId) {
this._rootId = rootId;
let { type, props } = this.curremtEl;
let children;
// 起始标签,属性需要通过遍历props向后添加
let startTag = `<${type} data-rootid="${rootId}"`
let endTag = `</${type}>`;
// 遍历props,给Tag拼属性 style="color: red"
for (let key in props) {
if (key === 'children') {
/**
* 处理是子节点的情况
* 递归遍历children数组,join方法将数组转为HTML字符串
*/
children = props[key].map((el, index) => {
// 递归调用createReactUnit,将虚拟DOM转化为HTML字符串
let childInstance = creatReactUnit(el);
return childInstance.getMarkUp(`${rootId}.${index}`)
}).join('');
} else {
// 如果是节点的属性,直接向startTag后面拼
startTag += `${key}="${props[key]}"`
}
}
// 返回html字符串
return `${startTag}>${children}${endTag}`
}
}
类声明式组件
类声明式组件el形式和函数式类似,但是节点类型是一个类,所以我们需要调用这个类的render方法,才能获取类的结构(也就是let reactRendered = componentInstance.render();这一句调用)。
获取到了结构之后,就和函数式组件一样,递归调用creatReactUnit就可以了。
class ReactComponentUnit extends Unit {
getMarkUp(rootId) {
this._rootId = rootId;
let { type: Component, props } = this.curremtEl;
// 方便 this.props 调用
let componentInstance = new Component(props);
// 获取组件render方法返回的结构,形如<App><span>123</span></App>
let reactRendered = componentInstance.render();
// 递归调用createReactUnit方法,渲染组件中的tag
let reactComponentUnitInstance = creatReactUnit(reactRendered);
return reactComponentUnitInstance.getMarkUp(rootId);
}
}
实现事件的绑定
到这里为止,我们已经可以简单的把组件渲染到页面上了,但是,当我们想给组件绑定一些事件的时候我们会发现,我们不能给HTML字符串绑定事件。
这时候我们就可以想到事件委托机制,那么怎么知道事件要绑定到哪一个节点上呢,我们定义的rootId就派上了用场。
$(document).on('click', '[data-rootid=0.0.0]', function() {alert(1)}) // 这里用了jQuery的API,也可以用原生的
这一部分判断应该在ReactNativeUnit类中,只有这个类,才涉及props的拼接,所以我们添加一个判断,如果匹配到了on开头的属性,作为事件绑定它。
完整代码如下:
class ReactNativeUnit extends Unit {
getMarkUp(rootId) {
this._rootId = rootId;
let { type, props } = this.curremtEl;
console.log(this.curremtEl)
let children;
let startTag = `<${type} data-rootid="${rootId}"`
let endTag = `</${type}>`;
for (let key in props) {
if (key === 'children') {
children = props[key].map((el, index) => {
let childInstance = creatReactUnit(el);
return childInstance.getMarkUp(`${rootId}.${index}`)
}).join('');
} else if (/on[a-z]/i.test(key)) {
/**
* 处理有事件的情况
* 给元素绑定事件
*/
let eventType = key.slice(2).toLocaleLowerCase(); // onClick -> click
// 通过事件委托绑定事件,参数分别是类型,子选择器,要绑定的方法
$(document).on(eventType, `[data-rootid=${rootId}]`, props[key])
}
else {
startTag += `${key}="${props[key]}"`
}
}
return `${startTag}>${children}${endTag}`
}
}
实现简单的生命周期
react里和组件挂载相关的两个生命周期是componentWillMount和componentDidMount,当有组件嵌套的时候,他们的调用顺序是。
- Parent will mount
- Children will mount
- Children did mount
- Parent did mount
怎么样才能使生命周期呈现这个顺序呢,我们可以先看componentWillMount。
componentWillMount
这个顺序很简单,是按照递归的顺序调用的,层级越外面,越先调用到。
和组件相关的递归调用,就只有ReactComponentUnit类的getMarkUp方法,因此只需要在这个方法里加一行componentWillMount方法的调用就可以了,完整代码如下。
class ReactComponentUnit extends Unit {
getMarkUp(rootId) {
this._rootId = rootId;
let { type: Component, props } = this.curremtEl;
let componentInstance = new Component(props);
// 调用生命周期钩子函数
componentInstance.componentWillMount && componentInstance.componentWillMount();
let reactRendered = componentInstance.render();
let reactComponentUnitInstance = creatReactUnit(reactRendered);
return reactComponentUnitInstance.getMarkUp(rootId);
}
}
componentDidMount
这个钩子的调用顺序和递归的解析顺序是相反的,后挂载先执行,因此他应该在return方法之前调用。
其次,DidMount是在组件挂载成功后执行的钩子,关于组件挂载,应该在React.render方法中执行,所以我们在render方法中先trigger一个‘mounted’事件,作为一个发布者。
// react.js
function render(el, container) {
let creatReactUnitInstance = creatReactUnit(el);
let mark = creatReactUnitInstance.getMarkUp(React.nextRootIndex);
$(container).html(mark);
// 挂载组件完成的方法
$(document).trigger('mounted');
}
通过$(document).on('mounted',()=> {})方法订阅‘mounted’事件,执行生命周期钩子,生命周期钩子执行的顺序就够订阅的顺序。
因此,这句订阅应该在return的前面进行。
完整代码如下:
class ReactComponentUnit extends Unit {
getMarkUp(rootId) {
this._rootId = rootId;
let { type: Component, props } = this.curremtEl;
let componentInstance = new Component(props);
componentInstance.componentWillMount && componentInstance.componentWillMount();
let reactRendered = componentInstance.render();
let reactComponentUnitInstance = creatReactUnit(reactRendered);
let markUp = reactComponentUnitInstance.getMarkUp(rootId);
// 子组件开始挂载,先于父组件订阅这个方法
$(document).on('mounted', () => {
componentInstance.componentDidMount && componentInstance.componentDidMount();
})
return markUp;
}
}
完整代码
看了这么多,我们其实发现,整个逻辑的核心就是把jsx语法先转成虚拟DOM,再递归遍历虚拟DOM将它转成HTML字符串,按照这个思路就非常容易理解整个逻辑。
以下是本文提到的所有代码:
// react.js
import $ from 'jquery';
import creatReactUnit from './unit'
import createElement from './element'
import Component from './component'
let React = {
render,
createElement,
Component,
nextRootIndex: 0,
}
/**
* 将虚拟DOM渲染到页面上
* @param {DOM} el 要渲染的元素,jsx语法
* @param {*} container 容器
*/
function render(el, container) {
// 通过标记获取el中的元素
let creatReactUnitInstance = creatReactUnit(el);
let mark = creatReactUnitInstance.getMarkUp(React.nextRootIndex);
$(container).html(mark);
// 挂载组件完成的方法
$(document).trigger('mounted');
}
export default React;
// unit.js
import $ from 'jquery';
// 父类,通过父类保存参数
class Unit {
constructor(el) {
this.curremtEl = el;
}
}
class ReactTextUnit extends Unit {
getMarkUp(rootId) {
this._rootId = rootId;
return `<div data-rootid="${rootId}">
${this.curremtEl}
</div>`;
}
}
class ReactComponentUnit extends Unit {
getMarkUp(rootId) {
this._rootId = rootId;
let { type: Component, props } = this.curremtEl;
// 给方便this.props调用
let componentInstance = new Component(props);
componentInstance.componentWillMount && componentInstance.componentWillMount();
// 组件返回的
let reactRendered = componentInstance.render();
// 递归渲染组件中的tag,<App><span>123</span></App>
let reactComponentUnitInstance = creatReactUnit(reactRendered);
let markUp = reactComponentUnitInstance.getMarkUp(rootId);
// 子组件开始挂载,先于子组件订阅这个方法
$(document).on('mounted', () => {
componentInstance.componentDidMount && componentInstance.componentDidMount();
})
return markUp;
}
}
class ReactNativeUnit extends Unit {
getMarkUp(rootId) {
this._rootId = rootId;
let { type, props } = this.curremtEl;
console.log(this.curremtEl)
let children;
let startTag = `<${type} data-rootid="${rootId}"`
let endTag = `</${type}>`;
// 给Tag拼属性 style="color: red"
for (let key in props) {
if (key === 'children') {
/**
* 处理是子节点的情况
* 递归添加子节点,成为字符串
*/
children = props[key].map((el, index) => {
let childInstance = creatReactUnit(el);
return childInstance.getMarkUp(`${rootId}.${index}`)
}).join('');
} else if (/on[a-z]/i.test(key)) {
/**
* 处理有事件的情况
* 给元素绑定事件
*/
let eventType = key.slice(2).toLocaleLowerCase();
// 元素,选择器,方法
$(document).on(eventType, `[data-rootid=${rootId}]`, props[key])
}
else {
startTag += `${key}="${props[key]}"`
}
}
return `${startTag}>${children}${endTag}`
}
}
const creatReactUnit = function (el) {
if (typeof el === 'string' || typeof el === 'number') {
return new ReactTextUnit(el);
}
// 是否是 react 的 虚拟DOM
if (typeof el === 'object' && typeof el.type === 'string') {
return new ReactNativeUnit(el);
}
// 如果是class形式的
if (typeof el === 'object' && typeof el.type === 'function') {
return new ReactComponentUnit(el);
}
}
export default creatReactUnit;
// element.js
class Element {
constructor(type, props) {
this.type = type;
this.props = props;
}
}
/**
* 返回虚拟DOM
* @param {string|function} type 节点类型
* @param {object} props 节点属性
* @param {array} children 子节点
*/
export default function createElement(type, props = {}, ...children) {
props.children = children; // array
return new Element(type, props);
}
// component.js
export default class Component {
constructor(props) {
this.props = props;
}
/**
* 更新状态
*/
setState() {
...
}
}