youngwind/blog

preact 源码学习系列之一:JSX解析与DOM渲染

youngwind opened this issue · 18 comments

前言

一直以来我都想研究 React 的源码,但总是看不懂。即便是去翻看最早的源码,代码量也有1万多行,研究起来难度太高了,这个问题困扰了我很久。
直到前两天我跟同事讨论起这个问题,忽然发现一个可行的方法:React 的源码太难懂,可以看 preact 的源码啊!为什么呢?因为 React 代表的是一种**,能实现这种**的不只有 React。preact 便是一个 mini 版的 React,其代码量很少,目前也就 1000多行,但是已经实现了 React 的主要功能。
这的确是一个很不错的研究方法,值得推荐。那么,我们便按着这种思路来研究,此次参考的是 preact 2.0.1 版本

目标

React 类框架功能很多,应该从哪个角度入手的。
答案:从“解析 JSX,渲染 DOM 入手”
举个例子:

import {render} from 'preact';

render((
    <div id="foo">
        <span>Hello, world!</span>
        <button>按钮</button>
    </div>
), document.body);

问题是:给定一段 JSX 和一个挂载点,如何解析 JSX,生成真实 DOM 并挂载到页面中呢?

谁来解析 JSX

要想生成真实 DOM,必须有一个层级嵌套的对象,此对象表征了 DOM 的嵌套结构。我们只需要遍历此对象,便可拼接出 DOM。
问题是: 如何把这段 JSX 格式的字符串转化为对应嵌套结构的对象呢? 这本质上是 HTML 解析器所要完成的事情。然而,我无力实现一个 HTML 解析器,怎么办呢? → 答案是使用 babel
babel 作为一个代码转换工具,它不仅仅能将 ES6 转化成 ES5,也能够将 JSX 转换成某个函数嵌套调用的结构,而这个函数是可以自定义的,具体请参考 transform-react-jsx

请注意一下两点:

  1. JSX 并不是 React 专有的,它本质上是一种 DOM 表示结构,不过是因为 React 流行起来而被大家所熟知而已。
  2. 我使用的是 babel5,而非 babel6,所以在自定义 pragma 的格式方面与上述连接中有些不同。我的配置如下:
    {
        "jsxPragma": "h"
    }
    为什么要使用 babel5 而不是 babel6 呢?因为对于此部分的代码而言,babel6 转换之后的代码可读性太差了,且我参考的 preact 版本用的也是 babel5。

ok,经过 babel 转换之后,原有的 JSX 变成了下面这个样子。
jsx
从图中我们可以看到:经过 babel 转换之后,JSX 变成了 preact.h 的嵌套调用。
因此,问题就转化为:如何编写这样的一个 h 函数,使得上述嵌套调用最终返回一个层级嵌套的对象,此对象表征了 DOM 的结构。

h 函数的编写

h 是这样的一个函数,接收参数为:标签名、属性值和子元素,返回一个对象,该对象描述了一个 DOM 节点。

class VNode {
    constructor(nodeName, attributes, children) {
        this.nodeName = nodeName;
        this.attributes = attributes;
        this.children = children;
    }
}

function h(nodeName, attributes, ...args) {
    // 子元素的个数是不确定的
    let children = args.length ? [].concat(...args) : null;
    return new VNode(nodeName, attributes, children);
}

经过这样的 h 函数的嵌套调用,最终返回的结果如下:
vNode

DOM 渲染

有了上面的 vNode 结构,我们便能将之转换成真实的 DOM 元素。此处逻辑并不复杂,无非是递归的调用,代码如下:

function buildDOMByVNode(vNode) {
    if (typeof vNode === 'string') {
        return document.createTextNode(vNode);
    }

    let {nodeName, attributes: attrs, children} = vNode;
    if (typeof nodeName === 'string') {
        let node = document.createElement(nodeName);

        // 处理属性
        if (attrs) {
            for (let key in attrs) {
                if(!attrs.hasOwnProperty(key)) continue;
                setAttributes(node, key, attrs[key]);
            }
        }

        // 处理子元素
        if (children) {
            children.forEach(child => {
                // 递归
                let subNode = buildDOMByVNode(child);
                node.appendChild(subNode);
            });
        }
        return node;
    }
}
// 整个 render 的入口
function render(vNode, parent) {
    let builtDOM = buildDOMByVNode(vNode);
    parent.appendChild(builtDOM);
    return builtDOM;
}

最终实现效果如下图所示:
result

后话

本文实现的具体代码参考这里,这只是一个最基本的 demo,后续还有很多有待探索的功能,比如构造 Component 类,比如 DOM 的 diff 和 update 等等。

参考资料:WTF is JSX, By developit

--------------- EOF ----------------

React的代码结构看得我蛋疼。。。完全找不着北😂。
文件名和变量名也是又臭又长,和它的API一样。

@javoski 我也是,满眼都是什么 React、Component 这些单词,看得眼都花了。

deot commented

是不是应该把createDocumentFragment这个概念引进来

@deot 引入概念 createDocumentFragment 用来做什么呢?

deot commented

@youngwind 使用 DocumentFragment 处理节点,速度和性能远远优于直接操作 DOM,我在preact源码里看到,可以查看一下

imho,documentFragment的性能优是因为可以先在内存中多次创建和插入子元素再一次性放入DOM树中,不需要对DOM树进行多次修改而引起反复的reflow,本文的代码里思路也是一样的,用不用都一样。都是先把JSX解析为树结构的对象,再通过其生成相应的DOM结构再一次性插入到文档中。

deot commented

@shadeofgod 不是这样的,如果你一次性插入DOM,和传统操作效率又有什么区别,React还有DOM diff,一次性DOM是指从根节点开始生成虚拟DOM,前后对比,从根节点开始diff,有diff的地方再批量DOM处理;简单的说就是,虚拟DOM不是一次性插入的真实DOM里,React中有DOM diff,Vue用的是createDocumentFragment (好像是)!

@deot 没太明白你的意思。。。
如果你说的传统操作是指jQuery这种直接选择DOM然后进行操作效率是很低的,DOM对象慢,但是用js中的object来模拟就快多了。所谓Virtual DOM不就是一个树形结构的object么,DOM diff的计算也是直接在这个object的数据结构上进行,根本不牵涉到DOM,完了之后需要把差异应用到真实的DOM树上再通过比如createDocumentFragment或者createElement这种api,createDocumentFragment createElement本身性能上是没什么区别的,区别在于可以一次性插入多个平级的节点,比如页面已经有一个空的ul元素,要插入10个li,可以一次性完成(准确来说frament插入时只是插入的children,而另一个是整个全部插入了),如果用createElement那就只能创建一个li,再分10次appendChild插进去,效率就拙计了。

deot commented

@shadeofgod 好像又讨论了其他问题,我是指原生操作;
我可能会存在下面疑惑(可以帮我解答下😊):

  1. DOM属性值获取和操作DOM是否一样耗时我不是很清楚?
  2. 在简单的ui变化下,比如就只改变一个纯文本,和传统的效率并没有什么区别; 反而react要从根节点开始diff,反而耗时;
  3. 在复杂ui操作上,以传统节点操作上,假如开发者清楚知道要需要修改的哪部分的数据,删除,移动哪部分的节点,和react操作没啥区别,react要从根节点开始diff,反而耗时;

我的理解是:数据驱动,虚拟DOM更趋向的是提升了开发效率,减少开发者各种不必要的DOM操作达到性能提升

提升开发效率是因为框架帮我们完成了数据和视图之间的绑定,使得开发者只需要关注数据的变化,而数据到视图的映射利用了Virtual DOM这一思路来提升性能。
Virtual DOM只是一种利用数据结构的**,把复杂的,真实的DOM树转化为轻量的,速度更快的JS Object,通过优化的diff算法后再把变化应用到真实的DOM树上。所以为啥算法和数据结构是重要的计算机基础呢

deot的观点很好。shadeofgod姿态很高,其实没有听出关键点,在自说自话。前天我就用ng1比较vue,循环1000次的变化,ng1完爆vue,估计很多网上的观点都是有前提的。并不是说真的就能搬过来的。
主要是组件化的**。等浏览器支持的时候,就不用这么折中了

@william-xue 我有什么说错说漏的地方欢迎直接指出来,本来就是抱着学习交流的心态来的,单纯的文字很多时候都不能表达完整的感情色彩,技术上的事情我倾向于有问题放开了说清楚,没必要在别人的博文下说我姿态高把我批判一番。
不知道你所说的关键点是?

请问下,图里的(0,_preact.render)(....)这一串语句是什么意思,加个0是干什么的,完全看不懂,没见过这种语法

@EzioKissshot 这里的代码是 bable5 编译之后的,(0,_preact.h)()几乎等价于 _preact.h(),其区别仅仅在于 _preact.h 函数内 this 指向的不同。至于为什么 babel 要特意编译成这样,为什么(0,_preact.h)()_preact.h的 this 指向不同,我觉得太深奥了,就没有做进一步的研究。这里有些相关的讨论,你可以参考一下。

  1. https://stackoverflow.com/questions/6577812/calling-function-with-window-scope-explanation-0-function
  2. https://stackoverflow.com/questions/32275135/why-does-babel-rewrite-imported-function-call-to-0-fn

@youngwind 谢谢!

setAttributes(node, key, attrs[key]); 是不是引用了lodash之类的库?

@william-xue 不是,此处代码没贴完整,setAttributes 是我自己定义的函数,详情看这里:https://github.com/youngwind/fake-preact/blob/master/preact.js#L189-L202