Build Your Own React:第一次渲染

Build Your Own React:第一次渲染

背景

近几年 React 彻底改变了前端的开发方式,在 Web Component 未能成为现实之前首先确立了前端组件化的现实标准,我们在使用 React 的同时也可以不再关心一些细节问题。但是在 React 提供便利的同时,我们也应该明白它到底为我们做了什么,为什么这么做。

一些文章通过讲述 React 的使用或者最佳范式试图让你理解 React 干了什么,也有一些文章是直接分析 React 的源码。而只谈使用过于空泛,读源码的工作量又太大,容易陷入无边的细节。本文则试图填补这中间的空白,我们会实现一个简化版的 React -- React-tiny,覆盖 React 的一些核心功能。从而让我们身临其境地理解 React 到底为我们解决了什么问题,以及为什么要这么解决。

由于 React 本身过于复杂,笔者本身也不能非常全面的了解其具体的实现和表现,所以 React-tiny 并不会拘泥于 React 的真正实现方式,我会按照自己的理解尽可能贴近 React 的行为,如果有不一致的地方还望指正。

原型

为了快速地验证我们的思路,也为了鼓励读者自己动手尝试,我们直接在 html 文件里通过 babel-standalone 直接写 ES6 和 JSX。

我们计划覆盖 React 最核心的一小块功能,为了避免手工验证带来的重复工作和不可靠性,我们将使用 jasmine 来编写自动化测试案例,它能运行在浏览器中进行自动化测试,并且直接在 HTML 上提供测试的结果。


目前这篇文章的代码见:CodeFalling/react-tiny

为什么需要单元测试

由于一些操作是每次测试时都需要的,例如创建一个容器并且渲染到页面上,所以我们把他放在公共的函数中做。有些同学对于前端测试可能比较陌生,认为前端没必要写测试或者只有非常严肃的项目才写测试。其实在这个例子中整体的需求非常稳定,写单元测试除了是为了保证质量外,也是极大程度上避免了重复劳动。针对于 React-tiny 的测试就是 为了替代我们的手工验证,例如说点击一个按钮然后查看其标题是否变化。

公用的函数,用于创建一个容器,并且给他加上一个 append 方法,可以直接 append 到测试容器中,并返回其第一个子节点(这往往是测试的对象)。

function createContainer(name) {
const container = document.createElement('div');
    container.setAttribute('id', 'test-container-' + name);
    container.append = function () {
        con.appendChild(container);
return container.childNodes[0];
    };
return container;
}

下面我们就来写第一个测试案例

describe('First-time render', () => {
    it('string should be rendered into span', function () {
const con = createContainer('render-string');
        render('test', con);
const target = con.append();

        expect(target.nodeName).toEqual('SPAN');
        expect(target.innerText).toEqual('test');
    });

// 更多测试
}

上面这个测试就是针对 render 函数,直接 render 一个字符串,应该在 DOM 上反应为一个 span 元素包住的字符串。如果没有单元测试,我们就需要每次都去肉眼验证是否为 span,内容是否为期望的字符串。而单元测试不但自动完成了这个过程,而且在我们后面的改动中仍然会保留前面的测试案例,以防止我们的改动导致之前完好的功能损坏。

JSX

React 最让人眼前一亮的莫过于 JSX 了,在 Babel 等转译工具的帮助下,JSX 能够让 JavaScript 和 HTML 模板很自然的混合在一起。而事实上对于 JSX 的解析和转译等完全是由 Babel 完成的,React 本身并不需要提供什么特别的支持。

例如

<div>
    <span>Test</span> 
    Hey {name}
</div>

这样的代码经过 Babel 的转译后,得到的是

React.createElement("div",null,
    React.createElement("span",null,"Test"),
    "Hey ",
    name);

"https://zhuanlan.zhihu.com/p/28257907/15011579204982.html">ps: 可以通过 Babel REPL Online 很方便的看到 JSX 翻译的结果,这对于我们的开发有帮助

而 React 只需提供 React.createElement 这个函数即可,所以我们要做的就是在完成 React-tiny 的 createElement 函数后,令 React.createElement 等于它。

第一次渲染

文字节点

了解了 JSX 是如何被构造出来的,我们就再来研究它们是怎么被渲染的页面上的,先从最简单的文字开始,如上面的第一个测试案例写的,render 一个字符串,应该渲染一个包裹着字符串的 span 元素到页面上。

const DATA_ATTR_REACT_ID = 'data-reactid';
let currentRootId = 0;

class VElement {
    constructor(type, props) {
this.type = type;
this.props = props;
    }
}
function instantiateInternalComponent(vElem) {
if (_.isString(vElem) || _.isNumber(vElem)) {
return new TextInternalComponent(vElem);
    }
}

class InternalComponent {
    constructor(vElem) {
this._vElem = vElem;
    }
}

class TextInternalComponent extends InternalComponent {
    mount(rootID) {
this._rootID = rootID;
const result = document.createElement('span');
        result.innerText = this._vElem;
        result.setAttribute(DATA_ATTR_REACT_ID, rootID);
this._dom = result;
return result;
    }
}

function h(type, attrs, ...children) {
return new VElement(type, {
        ...attrs,
        children,
    });
}

function render(vElem, container) {
    container.appendChild(instantiateInternalComponent(vElem).mount(currentRootId++));
}

React = {
    createElement: h,
    render,
}

如果只是为了渲染一个文字节点其实不用这么复杂,我们之所以将其分成多个数据结构,是为了后面能够更加自然的实现其他类型尤其是用户定义的 Component。


大致的流程是 babel 把 JSX 转译成含有 React.createElement 的 js 代码后,createElement 返回一个 VElement 对象,以一个树状的数据结构保存着元素的父子关系,props 等(其实在这里 VElement 就是 VirtualDOM Element 的缩写)。而后 render 函数在拿到 VElement 后先实例化一个内部的 InternalComponent,然后调用其 mount 函数得到 DOM 元素并且渲染到页面上。

之所以还需要一个内部的 InternalComponent 是因为事实上 React 组件是有生命周期的,我们需要使用 InternalComponent 来维护整个组件树真正的状态。当前只有 TextInternalComponent 表示文字组件,后面在如何更新的部分会了解到这个数据结构存在的意义。

另外在这里我们的 mount 函数中使用了 document.createElement 来创建元素,事实上在 React v15 以前的版本或者服务器端渲染中采用的是拼接字符串的方法。除此之外,在 React v15 后 React 不再在渲染文字结点的时候套一层 span,也不再在客户端渲染中在页面上渲染 react-id,本文出于方便仍然予以保留,欢迎大家针对这点进行改进。

列表

React 支持渲染

{list.map(item => <li>{item.name}</li>)}

这样的 JSX,其实它返回的就是一个数组,我们特意把列表放在前面来讲,因为 DOMInternalComponent 中需要一些逻辑来保证列表的正确渲染。

class ListInternalComponent extends ParentInternalComponent {
    mount(rootID) {
this._rootID = rootID;
return this._mountChildren(rootID);
    }
}

列表元素 mount 的结果是一个 "[object HTMLElement],[object HTMLElement]" 这样的数组,所以在 DOM 元素渲染时把它拍平。

DOM 节点

function transPropName(propName) {
const table = {
        className: 'class'
    };
if (table[propName]) return table[propName];
return propName.split('')
        .map(c => /A-Z/.test(c) ? `-${c.toLowerCase()}` : c)
        .join('');
}
class ParentInternalComponent extends InternalComponent {
    _mountChildren(rootID) {
const children = _.isArray(this._vElem) ?
this._vElem : _.get(this._vElem.props, 'children');
// mount children
const result = [];
        _.forEach(children, (child, index) => {
            result.push(instantiateInternalComponent(child).mount(`${rootID}.${index}`));
        });
return result;
    }
}

class DOMInternalComponent extends ParentInternalComponent {
    mount(rootID) {
const { type, props } = this._vElem;

const result = document.createElement(type);
        result.setAttribute(DATA_ATTR_REACT_ID, rootID);

        _.forEach(props, (value, key) => {
if (key !== 'children') {
const keyResult = transPropName(key);
                result.setAttribute(keyResult, value);
            }
        });
const children = this._mountChildren(rootID);

// 打平 children 是为了能够渲染列表
        _.forEach(_.flatten(children), child => {
            result.appendChild(child);
        });

this._dom = result;
return result;
    }
}

DOM 元素的渲染只需要递归渲染子元素即可,同时把 props 渲染到 DOM 上,transPropName 将 JSX 中的 props 和 DOM 的 attributes 做一个映射。

_.flatten 可以很方便地把列表返回的 DOM 数组打平然后直接 append 到元素上。

自定义 Component

组件化是 React 的核心,用户通过

class Custom React extends React.Component{
    render() {
return <div>Test</div>
    }
}

可以书写自己的组件,这里的 Component 其实并非 InternalComponent,只是提供给用户一个好看的接口。

class Component {
// _internalComponent: InternalComponent
    constructor(props) {
this.props = props;
    }
}

class CompositeInternalComponent extends InternalComponent {
    mount(rootID) {
this._rootID = rootID;
const componentClass = this._vElem.type;
const props = this._vElem.props;
const componentIns = new componentClass(props);
        componentIns.componentWillMount && ins.componentWillMount();

// 获取 render 结果
let renderedVElem = componentIns.render();

const renderedInternalComponent = instantiateInternalComponent(renderedVElem);
const result = renderedInternalComponent.mount(rootID);

        componentIns.componentDidMount && componentIns.componentDidMount();
        componentIns._internalComponent = this;
this._renderedInternalComponent = renderedInternalComponent;
this._componentInstance = componentIns;

this._dom = result;
return result;
    }
}

JSX 中的 <CustomComponent/> 其实并非实例化的 CustomComponent,而是其构建函数,所以它实际上是 ComponentClass,会在 CompositeInternalComponent 中将它实例化,再调用 ComponentClass.render() 得到应该渲染的 VElement,而后根据 VElement 实例化渲染结果对应的 InternalComponent 并且 mount。

同时 CompositeInternalComponent 和 render 出来的 VElement、针对 VElement 实例化的 InternalComponent 和最后得到的 DOM 元素相互之间有关联。

这里多个数据结非常容易混淆,用户自己定义的 Component,这个自定义 Component 对应的 InternalComponent,其 render 函数返回的 VirtualDOM Element,以及根据这个 VirtualDOM Element 构造来的 InternalComponent 是几个不同的概念。



Pure Functional Component

React 还支持纯函数组件,即

const PureFunctionalComponent = ({name}) => {
return <div>{name}</div>;
}

这样的组件同样是组件,由于纯函数组件实际上是没有生命周期和 state 的,它实际上完全等价于自己 render 的结果。所以只需在渲染 CompositeInternalComponent 时根据 render 返回的结果是否是纯函数组件,如果是再取其 render 结果即可。

let renderedVElem = componentIns.render();

while(isElementOfPureFunctionalComponent(renderedVElem)) {
   renderedVElem = instanceInternalComponent(renderedVElem).mount(rootID);
}

判断其是否为 PureFunctionalComponent 也非常简单,只要它是函数,而且不是从 React.Component 继承来的,我们就可以认为是 PureFunctionalComponent。

判断继承关系我们可以参照 babel 对 ES6 类继承的实现:

function _inherits(subClass, superClass) {
if (typeof superClass !== "function" && superClass !== null) {
throw new TypeError("Super expression must either be null or a function, not " + typeof superClass);
    }
    subClass.prototype = Object.create(superClass && superClass.prototype, {
        constructor: {
            value: subClass,
            enumerable: false,
            writable: true,
            configurable: true
        }
    });
if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass;
}

可以看到 subClass 的 prototype 指向 superClass 的 prototype,而 superClass 的原型(__proto__)指向 superClass。所以当我们要判断一个对象是否由继承自 superClass 的类实例化就非常简单:

function isInheritFrom(childObject, parentClass) {
return childObject.prototype instanceof parentClass;
}

事件机制

目前为止我们只是把 JSX 渲染到了页面上,但是原来在 DOM 中的事件绑定例如 onClick并不会生效,所以接下来要实现事件机制。

为了统一管理,保持事件冒泡的一致性,React 采用在顶层做事件代理的方式。React 为了消除不同浏览器之间的差异,还实现了一层合成事件,在这里我们不做实现。直接用比较取巧的方式监听所有的事件。

const allEvent = _(window).keys()
    .filter(item => /^on/.test(item))
    .map(item => item.replace(/^on/, ''))
    .value()

_.forEach(allEvent, event => {
    document.addEventListener(event, e => {
const target = e.target;
const targetID = target.getAttribute && target.getAttribute(DATA_ATTR_REACT_ID);

// TODO: 根据拿到的 react-id 触发事件
    });
});

通过监听 window 上所有的事件,我们可以拿到触发事件的 target 所带的 react-id。然后触发相应的事件,而注册事件只需用一个对象保存事件,对象和相应的回调事件即可。

比较有意思的是冒泡的实现,因为我们是在顶层的事件代理中直接监听了所有的事件,事件的响应也是由我们调用相应的 callback,所以要重新实现事件的捕获和冒泡,而 react-id 在此处同样起到了非常大的作用。

当我们点击一个组件时,假设它的 react-id 是 2.2.4,事件代理在拿到事件后,只需要按照下面的顺序触发即可:

eventCaptureHandler['2']['click']()
eventCaptureHandler['2.2']['click']()
eventCaptureHandler['2.2.4']['click']()
eventBubbleHandler['2.2.4']['click']()
eventBubbleHandler['2.2']['click']()
eventBubbleHandler['2']['click']()

同时需要在前面渲染 DOMInternalComponent 时判断 props 的名字是否是一个事件,如果是则注册到事件代理中。

针对事件的测试案例也可以体现自动化测试的优势,我们不需要每次都去点击按钮查看时间是否生效。

it('Event should be bind', () => {
const con = createContainer('render-bind-event');
const funcs = {
        handleClick: () => { }
    };
// 监听 funcs.handleClick 函数
    spyOn(funcs, 'handleClick');
    render(<input value="TEST BUTTON" type="button" onClick={funcs.handleClick} />, con);
    const target = con.append();
    target.click();
    // funcs.handleClick 应该被调用
    expect(funcs.handleClick).toHaveBeenCalled();
});

后续

由于涉及到的内容过多,这篇文章暂时介绍到第一次渲染结束,后续的文章会逐步介绍 VirtualDOM,setState 等。

拓展阅读

编辑于 2017-10-17