手写 React 系列第一篇之【初始渲染】

1,432 阅读8分钟

毕业生求职中,有坑联系~

【github】 【个人简历】

最近感悟

刚毕业不久,转行前端,自学半年多,之后去找工作,发现工作机会真的很难找,心里焦急万分。这时候前辈鼓励我,稳定心态,你所需要做的就是投资自己,当工作机会真正来临的时候,能够做到一把抓住就可以了。突然觉得这句话很有道理,我想只要每天都有进步,哪怕一时找不到工作,我也不吃亏,顶多不能尽早赚钱。。

找准方向

我决定找一个比较流行的框架进行深度学习,在 Vue 和 React 之间,我选择了 React,主要是因为对 React 框架比较熟,而且大厂对 React 的使用度比较高。

明确目标

这个系列的目标有三个:

  • 首先在学习过程中,能够加深自己对于 React 的理解,做到手写一个简单的 React 框架。
  • 其次总结 React 框架中的一些优秀的算法思想。
  • 最后希望这些内容能够帮助到他人。

正文开始

本文以 15.X 版本的 React 框架进行学习,在实现 类 React 框架之前,我们先看看需要有什么前置知识?

  • 熟练的原生 JS 技能。
  • JSX 语法。
  • Webpack 构建能力。

JSX 和虚拟 DOM

我们声明如下一个组件实例。

var component = <div>测试中</div>;

webpack 打包的时候会自动将上面的写法转换成 React.createElement 的方式,最终返回虚拟DOM 对象,如下所示。

var virtualDOM = {
    props:{
        children:['children'],
    },
    type:'div'

    
}

实际上 React 的虚拟 DOM 对象还有一些其他属性,比如 key,ref,这里为了简单起见,本节我们只讲解初始渲染过程,所以我们只关注和渲染相关的 propstype 两个关键属性,到后面讲解 diff 的时候,再去关注他们。

JSX 转化成虚拟 DOM

上面的转换需要借助 JSX 语法解析器的能力,解析器能够将 JSX 元素编译为一个虚拟 DOM 对象,即 virtualDOM。

借助虚拟 DOM,我们可以不需要真正操作一个 DOM 对象,而是将实际的 DOM 对象映射给一个 JS 对象中,通过对比虚拟 DOM 来决定是否要操作真实 DOM。

JSX 首先在编译器由 webpack 结合 babel 将 JSX 元素编译为如下格式的代码,以上面代码为例。

var component = <div>测试中</div>

这段代码在编译期间转化为如下代码:

var component = React.createElement("div", null, "\u6D4B\u8BD5\u4E2D");

当有两个子元素的时候,

var component = <div>测试中<button>知道了</button></div>

编译为

var component = React.createElement("div", null, "\u6D4B\u8BD5\u4E2D", React.createElement("button", null, "\u77E5\u9053\u4E86"));

那么,当webpack将 JSX 语法编译为 React.createElement 方法调用之后,如何再通过createElement 方法返回虚拟 DOM 对象呢?

这就要涉及到 createElement 的函数实现了,接下来我们实现它。

首先,该方法接收一系列参数。

createElement(type, props, children1, children2, ...);
  • type 代表虚拟 DOM 的节点类型。
  • props 代表虚拟 DOM 的属性。
  • children1,children2,... 代表子节点。

我们的 createElement 方法需要将这些参数组装成一个 虚拟DOM 对象输出出去,实现比较简单,如下:

function createElement(type, props, ...children){
    props = {...props};
    if(children.length > 0){
        props.children = children;
    }
    
    return {
        type,
        props
    }
    
}

render 实现

那么,有了虚拟 DOM 了,下一步要做的事就是将虚拟 DOM 转化为真实 DOM ,并且将真实 DOM 插入到页面中。

我们来看一段经典的 React 代码。

import React from 'react';
import ReactDOM from 'react-dom';

var app = <div>首次渲染</div>;

ReactDOM.render(app, document.querySelector('#root'));

我们看下 render 方法如何实现。

元素、组件、组件实例的关系

在继续往下讲解之前,我们先了解一下 React 中的概念。

  • 元素 元素是指通过 createElement 方法创建出来的 JS 对象,即虚拟 DOM,它会对应 html 文档里的一个真实 DOM 片段。

  • 组件类

    • 组件类是一个构造函数或者一个类,它能够生成组件实例,组件实例通过一定的方法生成元素,具体的方法后面我们也会讲解。
    • 组件的另一个好处是方便复用和扩展。
  • 组件实例。 我们通常说组件的时候,其实有时候是指组件类,有时候是指组件实例,很容易引起歧义。接下来的篇幅,我们会分清楚这两个概念。

渲染方法的实现

  • 最简单的虚拟 DOM

看下面最简单的元素:

var component = 1;
var component1 = '2';
var component2 = true;
var component3 = null;
var component4 = undefined;

我们的 render 方法需要能够处理它们,处理策略如下:

  • 针对 null、undefined、布尔类型的值,返回一个空字符串的 text 节点。
  • 针对字符串,返回该字符串对应的 text 节点。
  • 针对数字,返回该数字对应的 text 节点。

针对以上这三种情况,render 的实现如下:

function render(vdom, container){
    let dom = renderElement(vdom);
    container.appendChild(dom);
}

function renderElement(vdom){
    if(typeof vdom === 'boolean' ||  vdom === null || vdom === undefined){
        return document.createTextNode('');
    }
    if(typeof vdom === 'number'){
        return document.createTextNode(String(vdom));
    }
    if(typeof vdom === 'string'){
        return document.createTextNode(vdom);
    }
}
  • 稍微简单一些的虚拟 DOM

假设这样一个元素:

var component = <div>测试中</div>;

对应虚拟 DOM 也就是

{
    props: {
        children: ["测试中"]
    },
    type: "div"
}

那我们的render方法需要要怎么改进,才可以对其进行渲染呢?

很明显我们可以根据 type 区分,对 type 为 string 类型的虚拟 DOM 单独处理,下面看下具体实现。

//改造 renderElement 方法。
function renderElement(vdom){
    //基础数据类型处理
    //此处略。
    if(typeof vdom.type === 'string'){
        return renderNativeDom(vdom);
    }
}
// 将类型为原生节点的虚拟 DOM 转化为真实 DOM。
function renderNativeDom(vdom){
    let dom = document.createElement(vdom.type);
    const { props: { children }} = vdom;
    for(var i = 0; i < children.length; i++){
        // 渲染子节点
        let childNode = renderElement(children[i]);
        dom.appendChild(childNode);
    }
    return dom;
}
  • 函数组件

再来看比较常用的函数组件。

function A(props){
    return <div>测试中</div>
}

上面这种方式实际上定义了一个函数组件类,注意是组件类,不是组件实例。

那么我们用的时候,就要通过这样的方式来实例化组件了。

var component = <A />;

我们看下,该如何改造 renderElement 方法。

按照惯例,我们先看下函数组件实例对应的虚拟 DOM 形式。

{
    props: {},
    type: ƒ B()
}

可见,type 是一个函数,这个函数就是组件类的构造函数。所以我们可以依然可以根据 type进行区分。

  • 策略如下:

    • 针对 type 为 function 的虚拟 DOM,通过执行该 function 来拿到待渲染的虚拟 DOM。

    • 之后的策略就很简单了,递归执行 renderElement 方法就行了。

我们看下实现

//改造 renderElement
function renderElement(vdom){
    //函数组件实例的判断
    if(typeof vdom.type === 'function'){
        return return renderFunctionComponent(vdom);
    }
}
// 增加对函数组件实例的渲染。
function renderFunctionComponent(vdom){
    let inst = vdom.type(vdom.props);
    return renderElement(inst);
}
  • 类组件

最后,我们看下类组件,看一个最常用的组件类的定义。

class Header extends React.Component{
    render(){
        return <div>类组件实例</div>
    }
}

构造一个类组件实例:

var component = <Header />;
console.log(component);

看下这种类型实例对应的虚拟 DOM 是什么样子的。

{
    props: {}
    type: ƒ Header()
}

可见,类组件实例对应的虚拟 DOM 的 type 也是 函数,所以,我们无法单纯根据虚拟 DOM 的type 来区分函数组件和类组件的实例了,那怎么区分这两种实例呢?

还记得类组件定义的时候,要继承的 Component 抽象类吗?我们可以根据这个特点进行区分,只需要判断当前组件实例是否是 Component 的实例即可。

看下代码实现。


// 增加组件基类 Component
class Component(){
    constructor(props){
        this.state = {};
        this.props = props || {};
    }
    setState(nextState){
        
    }
}

//改造 renderElement 方法,识别类组件和函数组件。
function renderElement(vdom){
    if(typeof vdom.type === 'function'){
        //区分类组件还是函数组件。
        if(vdom.type.prototype instanceof React.Component){
            //类组件
            return renderClassComponent(vdom);
        } else {
            //函数组件
            return renderFunctionComponent(vdom);
        }
    }
}

function renderClassComponent(vdom){
    let inst = new vdom.type(vdom.props);
    let vd = inst.render();
    return renderElement(vd);
}

总结

以上就是我们 React 的第一个环节,初始渲染,代码整理如下:

  • React.js
function createElement(type, props, ...children){
    props = {...props};
    if(children.length > 0){
        props.children = children;
    }
    
    return {
        type,
        props
    }
}

class Component(){
    constructor(props){
        this.state = {};
        this.props = props || {};
    }
    setState(nextState){
        
    }
}

export default {
    createElement,
    Component
}
  • React-Dom.js

function render(vdom, container){
    let dom = renderElement(vdom);
    container.appendChild(dom);
}

function renderElement(vdom){
    if(typeof vdom === 'boolean' ||  vdom === null || vdom === undefined){
        return document.createTextNode('');
    }
    if(typeof vdom === 'number'){
        return document.createTextNode(String(vdom));
    }
    if(typeof vdom === 'string'){
        return document.createTextNode(vdom);
    } 
    if(typeof vdom.type === 'function'){
        //区分类组件还是函数组件。
        if(vdom.type.prototype instanceof React.Component){
            //类组件
            return renderClassComponent(vdom);
        } else {
            //函数组件
            return renderFunctionComponent(vdom);
        }
    }
}


function renderClassComponent(vdom){
    let inst = new vdom.type(vdom.props);
    let vd = inst.render();
    return renderElement(vd);
}


function renderFunctionComponent(vdom){
    let inst = vdom.type(vdom.props);
    return renderElement(inst);
}

function renderNativeDom(vdom){
    let dom = document.createElement(vdom.type);
    const { props: { children }} = vdom;
    for(var i = 0; i < children.length; i++){
        // 渲染子节点
        let childNode = renderElement(children[i]);
        dom.appendChild(childNode);
    }
    return dom;
}

结语

本节我们只实现了 React 的初始渲染,大家会发现我们并没有针对属性做处理,也并没有针对 state 变化引发界面渲染的逻辑。

没关系,我们一步步来,下一节我们讲解属性、state,以及组件生命周期的处理。

再见~