页 面 正 在 赶 来 的 路 上。

从零开始写一个React.js(一)——实现jsx和组件渲染

javascriptReact.js

 唐益达

date:  2019-01-02 14:30


view 150 comment 0

从零开始写一个React.js(一)——实现jsx和组件渲染


写在前面

用了一段时间的React,总想吃透它,吃透它最好的办法就是变成它,所以想基本实现一个简易的React.js,以此来深刻理解React机制。篇幅较长,今天只先介绍第一步:如何渲染。

准备工作

工欲善其事必先利其器,找到一个好的打包工具也是很重要的。恰好最近看到很火的parcel应用程序打包器(有2W8的star吓到我了),其特点是简易,方便,快速。这不就是我要的特性吗?webpack太重不适合现在小demo,gulp,grunt又老且没有这个来得方便,rollup适合插件发布的情景。

所以思前想后,决定就用parcel,实现基本的小应用,用最快最便捷的方式诠释从零开始的React.js。

那么,拿好武器,接下来就是开始磨刀霍霍向猪羊吧。

安装环境

我们先创建一个如下结构的文件夹:

|-- .babelrc
|-- node_modules
|-- src
|-- index.html
|-- index.js
|-- package.json

先安装parcelnpm install -g parcel,安装完成后,可以通过parcel index.html运行程序,默认端口号是1234,

index.html是入口的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>react-simple-demo</title>
</head>
<body>
  <div id="root"></div>
  <script src="./index.js"></script>
</body>
</html>

只要有一个<div id="root"></div>作为顶层的div就OK,随后引入一个入口文件index.js

JSX渲染

我们都知道react有特定的标签语法:一种 JavaScript 的语法扩展。

那么首先我们如果从根本上实现jsx,从文件系统底层解析新的语法,这个难度就大了,而且工作量很大。所以还好我们有babel,这个应该大家都耳熟能详了,我们可以用babel来解析jsx

当然需要里面一个插件:@babel/plugin-transform-react-jsx。他可以把类似于下面的jsx代码:

const profile = <div>
  <img src="avatar.png" className="profile" />
  <h3>{[user.firstName, user.lastName].join(' ')}</h3>
</div>;

转义成:

var profile = React.createElement("div", null,
  React.createElement("img", { src: "avatar.png", className: "profile" }),
  React.createElement("h3", null, [user.firstName, user.lastName].join(" "))
);

可以看到输出是js语法了,从这里可以看到,jsx实质是React.createElement方法的实现,接下去只要重点实现createElement方法就可以了。

接下来通过npm安装:

npm install --save-dev @babel/plugin-transform-react-jsx
npm i --save-dev @babel/preset-env

然后根据babel的格式编辑你根目录下的.babelrc

{
    "presets": ["@babel/preset-env"],
    "plugins": [
      ["@babel/plugin-transform-react-jsx", {
        "pragma": "React.createElement"
      }]
    ]
  }

这样你就可以肆无忌惮地用ES6、7,同时解析JSX了。

确定src结构

接下来确定src的初步结构:

|-- src
	|-- React.js // React方法的集合
	|-- ReactDOM.js // ReactDOM方法的集合
|-- react-dom
	|-- element.js
	|-- render.js

首先在index.js中写入我们准备实现的代码:

const demo =
<div>
  <h1 className="h1_class">Hello World</h1>
</div>

ReactDOM.render(
  demo,
  document.getElementById('root')
);

createElement方法

从上面可以看到,demo属性是一段jsx,而下面的ReactDOM.render方法则是所有渲染的入口,所以我们现在重点就是实现这个render方法即可。

上述demo字段转义过来是:

const demo = React.createElement("div", null, 
	React.createElement('h1', { className: 'h1_class' }, 'Hello World'))

总入口React.js

方法createElement只是返回一个对象( 标签名,属性值,子节点):

const createElement = function (tagName, attr, ...children) {
    return {
        tagName,
        attr,
        children
    }
}

如果节点是undefined或者是null则过滤掉:

children = children.filter(item => (item != undefined && item != null))

综上返回就是这个

const createElement = function (tagName, attr, ...children) {
    children = children.filter(item => (item != undefined && item != null))
    return {
        tagName,
        attr,
        children
    }
}

const React = {
    createElement
}

export {
    React
}

可以看到实现了createElement方法,接下去看一下代码可以看到渲染的方法是ReactDOM.render()render方法传入的参数是刚刚createElement返回的虚拟节点对象。

虚拟节点对象的本质是下列这样的对象结构:

接下来如何循环递归根据虚拟节点对象获取真实dom是我们的重点。

编译节点实例

首先我们先编译我们原先的虚拟节点至节点实例,在不包括function组件类型的节点下,渲染的函数递归如下:

  1. 如果节点是文本,说明节点到头了,直接赋值你的vnode
if (typeof vnode === 'string') {
    inst = vnode;
}
  1. 如果节点的tagName是文本,说明是一个节点,需要创建一个新节点实例:
if (typeof vnode.tagName === 'string') {
    const elementDom = {
      _currentInstance: null, // 如果不是组件,都是空
      nextLevelNode: [], // 如果不是组件,先把下一层级的数组置空
      tagName: vnode.tagName,
      attrs: vnode.attr,
      _isComponent: false // 是否组件
    }
    Object.assign(inst, {} ,elementDom)
}
  1. 最后我们判断是否含有children字段,有的话继续循环编译:
if (childrenNode && childrenNode.length > 0) {
    childrenNode.forEach((item, index) => {
        const result = getInstanceFromVirtualNode(item);
        inst.nextLevelNode[index] = result; // 这里下一层级对应赋值
    });
}

综上整个获取编译后的代码函数如下:

function getInstanceFromVirtualNode (vnode, inst = {}) {
  let childrenNode = vnode.children;

  if (typeof vnode === 'string') {
    inst = vnode;
  }

  if (typeof vnode.tagName === 'string') {
    const elementDom = {
      _currentInstance: null, // 如果不是组件,都是空
      nextLevelNode: [], // 如果不是组件,先把下一层级的数组置空
      tagName: vnode.tagName,
      attrs: vnode.attr,
      _isComponent: false // 是否组件
    }
    Object.assign(inst, {} ,elementDom)
  }

  if (childrenNode && childrenNode.length > 0) {
    childrenNode.forEach((item, index) => {
      const result = getInstanceFromVirtualNode(item);
      inst.nextLevelNode[index] = result;
    });
  }

  return inst
}

返回的整个instNode

const instNode = getInstanceFromVirtualNode(vnode); // vnode为createElement方法返回的对象

instNode结构如下:

你看到这肯定疑惑,我们为什么要多此一举再编译一次,多麻烦,是傻了吗?

原因慢慢听我道来:

整个节点树不仅仅是节点对象,还可能存在函数节点,什么是函数节点呢?实质就是React中的组件。那么函数节点就需要编译(执行)过。

同时我们也需要中间态的实例,作为后续的更新来对比使用,如果不创建一个过渡中间态,后续的操作可能会很麻烦,所以我们为了后面的扩展,多一个编译的过程。

挂载实例

先判断节点的类型?如果是纯文本的话就创建文本#text节点,如果是其他类型,就创建新节点:

const element = typeof instance == 'string'
    ? document.createTextNode(instance)
    : document.createElement(instance.tagName);

同时遍历实例的attrs,把所有节点的属性值遍历出来,同时赋值给节点,因为React的节点class类名名称和CSS的不太一样,所以这里做个判断。

const setElementAttr = function (element, attrName, value) {
    // 解析className
    attrName = attrName === 'className' ?  'class' : attrName;

    element.setAttribute(attrName, value);
}

if (instance.attrs) {
     // 在这里遍历节点
    Object.keys(instance.attrs).forEach(item => {
        setElementAttr(element, item, instance.attrs[item])
    })
}

判断如果还有下一层级的节点,则进行递归:

// 还有下一层级
if (instance.nextLevelNode && instance.nextLevelNode.length > 0) {
    instance.nextLevelNode.forEach(item => _MounteInstance(item, element))
}

整个函数如下:

function _MounteInstance (instance, dom) {

    // 如果dom未定义,默认值为vnode的标签名,这里用作根节点的赋值
    dom = dom ||  document.createElement(instance.tagName);

    // 判断虚拟节点的类型,如果是#text类型创建text节点,如果是其他类型创建tag.name的节点
    const element = typeof instance == 'string'
    ? document.createTextNode(instance)
    : document.createElement(instance.tagName);

    if (instance.attrs) {
        Object.keys(instance.attrs).forEach(item => {
            setElementAttr(element, item, instance.attrs[item])
        })
    }

    // 如果节点还有子节点,进行递归
    if (instance.nextLevelNode && instance.nextLevelNode.length > 0) {
        instance.nextLevelNode.forEach(item => _MounteInstance(item, element))
    }

    // 返回此段节点的dom
    return dom.appendChild(element);
}

至此,返回的dom是节点实例渲染出来的dom值:

// 获取渲染的根节点真实dom
const mountedNode = _MounteInstance(instNode); 

上面提到的createElement方法第一个参数是虚拟节点,那么第二个参数就是根节点真实DOM。

我们把上述的mountedNode通过根节点appendChild方法挂载上去:

rootElement.innerHTML = null; // 先把根节点置空
rootElement.appendChild(mountedNode);

挂载结果如下:

至此,我们基本完成了JSX的渲染。

事件处理与style渲染

我们都知道React有一系列处理事件的方式,如Onclick,Onscroll直接定义事件,我们在这也需要设置。

我们可以把目光聚焦于setElementAttr这个方法,传入的参数分别是(element)真实节点dom,(attrName):属性的key值,(value)属性的value值。

const setElementAttr = function (element, attrName, value) {
    // 解析className
    attrName = attrName === 'className' ?  'class' : attrName;

    element.setAttribute(attrName, value);
}

首先先确定三种情况:1. on事件的情况 2. React自定义style对象的方式 3. 一般情况:

// 这里判断属性名是否是on开头,是的话就是事件
if ( /on\w+/.test( attrName ) ) {
    // 如果是相关事件,则加入监听
} else if (attrName === 'style') {
    // 如果是style对象,则解析成dom的style
} else {
	// 普通属性,直接赋值
}

事件

先获取事件名,随后添加监听,记得转换成小写,否则BOM识别不了

const eventName = attrName.replace(/on/, '').toLowerCase(); 
element.addEventListener(eventName, value)

style

如果是字符串模式,直接复制,如果是object对象模式,遍历对象。

这里重点就是记得把驼峰式的style写法,转换成实际CSS实现的写法。

if (typeof value === 'string') {
    element.style.cssText = value || '';
} else if (typeof value === 'object' ) {
    Object.keys(value).forEach(_name => {
        const styleName = _name.replace(/([A-Z])/g,"-$1").toLowerCase();
        element.style[styleName] = value[_name]
    })
}

其他情况

value ? element.setAttribute(attrName, value) : element.removeAttribute(attrName)

综上我们修改一下setElementAttr方法的实现代码:

const setElementAttr = function (element, attrName, value) {
    // 解析className
    attrName = attrName === 'className' ?  'class' : attrName;

    // 如果是相关事件,则加入监听
    if ( /on\w+/.test( attrName ) ) {
      const eventName = attrName.replace(/on/, '').toLowerCase();
      element.addEventListener(eventName, value)
      // 如果是style对象,则解析成dom的style
    } else if (attrName === 'style') {
      if (typeof value === 'string') {
        element.style.cssText = value || '';
      } else if (typeof value === 'object' ) {
        Object.keys(value).forEach(_name => {
          const styleName = _name.replace(/([A-Z])/g,"-$1").toLowerCase();
          element.style[styleName] = value[_name]
        })
      }
      // 普通属性,直接赋值
    } else {
      value ? element.setAttribute(attrName, value) : element.removeAttribute(attrName)
    }
}

至此,我们第一章节的介绍就结束了,实现了简单的jsx渲染和事件、style处理。

实际例子

既然学会了怎么写渲染ReactJSX和事件监听、style解析,那么肯定也想实践一下吧?

我们模仿React官方文档的例子,稍加改变一下,就可以实现啦,下面是例子的代码,大家有空可以试验一下!

import { React, ReactDOM } from './src/React'

let count = 0;

function clickEvent (e) {
  clearIntervalTest();
  alert(`Hello World ${e.type} && interval already stop`);
}

let style = {
  color: '#ffffff',
  backgroundColor: '#900b09',
  paddingLeft: '10px'
}

function setIntervalTest () {
  const currentStyle = count % 2 === 0 ? style : {
    ...style,
    color: '#ffd700',
    backgroundColor: '#6495ed'
  }
  let demo =
  <div>
    <h1 style={currentStyle} onClick={clickEvent} id="message">Hello World</h1>
    <h2>Time: {new Date().toLocaleTimeString()}.</h2>
  </div>
  ReactDOM.render(
    demo,
    document.getElementById('root')
  );
  count++;
}

setIntervalTest();

const interval =  setInterval(setIntervalTest, 1000);

function clearIntervalTest () {
  clearInterval(interval);
  count = 0;
}

写在最后

这一章节本来想把组件加进去,后面发现有点复杂,还是循序渐进比较好。

一口吃不成胖子,我自己也研究了有一个星期了,如果一下子全部放出来,不仅让读者囫囵吞枣般接受,而且自己也无法得到很好的总结。

下一章我们将继续介绍:从零开始写一个React.js(二)—— 组件和生命周期





评论列表 (共0条评论)
  • T.T尴尬,似乎没人评论......
  • {{comment.nickname}}  博主{{commentList.length-$index}}楼  {{comment.date}}
    引用 {{comment.responseName}}发表时间:{{comment.responseTime}}
    {{comment.response}}
精选文章