V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
iamjiulong
V2EX  ›  前端开发

从零开始实现 React(一): JSX 和虚拟 DOM

  •  
  •   iamjiulong · 2018-03-20 10:01:59 +08:00 · 1309 次点击
    这是一个创建于 2200 天前的主题,其中的信息可能已经有所发展或是发生改变。

    前言

    React 是前端最受欢迎的框架之一,解读其源码的文章非常多,但是我想从另一个角度去解读 React:从零开始实现一个 React,从 API 层面实现 React 的大部分功能,在这个过程中去探索为什么有虚拟 DOM、diff、为什么 setState 这样设计等问题。

    提起 React,总是免不了和 Vue 做一番对比

    Vue 的 API 设计非常简洁,但是其实现方式却让人感觉是“魔法”,开发者虽然能马上上手,但是为什么能实现功能却很难说清楚。

    相比之下 React 的设计哲学非常简单,虽然经常有需要自己处理各种细节问题,但是却让人感觉它非常“真实”,能清楚地感觉到自己仍然是在写 js。

    关于 jsx

    在开始之前,我们有必要搞清楚一些概念。

    我们来看一下这样一段代码:

    const title = <h1 className="title">Hello, world!</h1>;
    

    这段代码并不是合法的 js 代码,它是一种被称为 jsx 的语法扩展,通过它我们就可以很方便的在 js 代码中书写 html 片段。

    本质上,jsx 是语法糖,上面这段代码会被 babel 转换成如下代码

    const title = React.createElement(
        'h1',
        { className: 'title' },
        'Hello, world!'
    );
    

    你可以在 babel 官网提供的在线转译测试 jsx 转换后的代码,这里有一个稍微复杂一点的例子

    准备工作

    为了集中精力编写逻辑,在代码打包工具上选择了最近火热的零配置打包工具 parcel,需要先安装 parcel:

    npm install -g parcel-bundler
    

    接下来新建index.jsindex.html,在index.html中引入index.js

    当然,有一个更简单的方法,你可以直接下载这个仓库的代码:

    https://github.com/hujiulong/simple-react/tree/chapter-1

    注意一下 babel 的配置 .babelrc

    {
        "presets": ["env"],
        "plugins": [
            ["transform-react-jsx", {
                "pragma": "React.createElement"
            }]
        ]
    }
    

    这个transform-react-jsx就是将 jsx 转换成 js 的 babel 插件,它有一个pragma项,可以定义 jsx 转换方法的名称,你也可以将它改成h(这是很多类 React 框架使用的名称)或别的。

    准备工作完成后,我们可以用命令parcel index.html将它跑起来了,当然,现在它还什么都没有。

    React.createElement 和虚拟 DOM

    前文提到,jsx 片段会被转译成用React.createElement方法包裹的代码。所以第一步,我们来实现这个React.createElement方法

    从 jsx 转译结果来看,createElement 方法的参数是这样:

    createElement( tag, attrs, child1, child2, child3 );
    

    第一个参数是 DOM 节点的标签名,它的值可能是divh1span等等 第二个参数是一个对象,里面包含了所有的属性,可能包含了classNameid等等 从第三个参数开始,就是它的子节点

    我们对 createElement 的实现非常简单,只需要返回一个对象来保存它的信息就行了。

    function createElement( tag, attrs, ...children ) {
        return {
            tag,
            attrs,
            children
        }
    }
    

    函数的参数...children使用了 ES6 的rest 参数,它的作用是将后面 child1,child2 等参数合并成一个数组 children。

    现在我们来试试调用它

    // 将上文定义的 createElement 方法放到对象 React 中
    const React = {
        createElement
    }
    
    const element = (
        <div>
            hello<span>world!</span>
        </div>
    );
    console.log( element );
    

    打开调试工具,我们可以看到输出的对象和我们预想的一致

    1

    我们的 createElement 方法返回的对象记录了这个 DOM 节点所有的信息,换言之,通过它我们就可以生成真正的 DOM,这个记录信息的对象我们称之为虚拟 DOM

    ReactDOM.render

    接下来是 ReactDOM.render 方法,我们再来看这段代码

    ReactDOM.render(
        <h1>Hello, world!</h1>,
        document.getElementById('root')
    );
    

    经过转换,这段代码变成了这样

    ReactDOM.render(
        React.createElement( 'h1', null, 'Hello, world!' ),
        document.getElementById('root')
    );
    

    所以render的第一个参数实际上接受的是 createElement 返回的对象,也就是虚拟 DOM 而第二个参数则是挂载的目标 DOM

    总而言之,render 方法的作用就是将虚拟 DOM 渲染成真实的 DOM,下面是它的实现:

    function render( vnode, container ) {
        
        // 当 vnode 为字符串时,渲染结果是一段文本
        if ( typeof vnode === 'string' ) {
            const textNode = document.createTextNode( vnode );
            return container.appendChild( textNode );
        }
    
        const dom = document.createElement( vnode.tag );
    
        if ( vnode.attrs ) {
            Object.keys( vnode.attrs ).forEach( key => {
                if ( key === 'className' ) key = 'class';            // 当属性名为 className 时,改回 class
                dom.setAttribute( key, vnode.attrs[ key ] )
            } );
        }
    
        vnode.children.forEach( child => render( child, dom ) );    // 递归渲染子节点
    
        return container.appendChild( dom );    // 将渲染结果挂载到真正的 DOM 上
    }
    

    这里注意 React 为了避免类名class和 js 关键字class冲突,将类名改成了 className,在渲染成真实 DOM 时,需要将其改回。

    这里其实还有个小问题:当多次调用render函数时,不会清除原来的内容。所以我们将其附加到 ReactDOM 对象上时,先清除一下挂载目标 DOM 的内容:

    const ReactDOM = {
        render: ( vnode, container ) => {
            container.innerHTML = '';
            return render( vnode, container );
        }
    }
    

    渲染和更新

    到这里我们已经实现了 React 最为基础的功能,可以用它来做一些事了。

    我们先在 index.html 中添加一个根节点

    <div id="root"></div>
    

    我们先来试试官方文档中的Hello,World

    ReactDOM.render(
        <h1>Hello, world!</h1>,
        document.getElementById('root')
    );
    

    可以看到结果: 2

    试试渲染一段动态的代码,这个例子也来自官方文档

    function tick() {
        const element = (
            <div>
                <h1>Hello, world!</h1>
                <h2>It is {new Date().toLocaleTimeString()}.</h2>
            </div>
          );
        ReactDOM.render(
            element,
            document.getElementById( 'root' )
        );
    }
    
    setInterval( tick, 1000 );
    

    可以看到结果: 2

    后话

    这篇文章中,我们实现了 React 非常基础的功能,也了解了 jsx 和虚拟 DOM,下一篇文章我们将实现非常重要的组件功能。

    最后留下一个小问题 在定义 React 组件或者书写 React 相关代码,不管代码中有没有用到 React 这个对象,我们都必须将其 import 进来,这是为什么?

    例如:

    import React from 'react';    // 下面的代码没有用到 React 对象,为什么也要将其 import 进来
    import ReactDOM from 'react-dom';
    
    ReactDOM.render( <App />, document.getElementById( 'editor' ) );
    

    不知道答案的同学再仔细看看这篇文章哦

    从零开始实现 React 系列

    React 是前端最受欢迎的框架之一,解读其源码的文章非常多,但是我想从另一个角度去解读 React:从零开始实现一个 React,从 API 层面实现 React 的大部分功能,在这个过程中去探索为什么有虚拟 DOM、diff、为什么 setState 这样设计等问题。

    整个系列大概会有六篇左右,我每周会更新一到两篇,我会第一时间在 github 上更新,有问题需要探讨也请在 github 上回复我~

    博客地址: https://github.com/hujiulong/blog 关注点 star,订阅点 watch

    目前尚无回复
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   1013 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 32ms · UTC 19:40 · PVG 03:40 · LAX 12:40 · JFK 15:40
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.