New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
hujiulong opened this Issue Mar 18, 2018 · 49 comments

Comments

@hujiulong
Owner

hujiulong commented Mar 18, 2018

前言

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

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

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

相比之下React的设计哲学非常简单,虽然有很多需要自己处理的细节问题,但它没有引入任何新的概念,相对更加的干净和简单。

关于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 => {
            const value = vnode.attrs[ key ];
             setAttribute( dom, key, value );    // 设置属性
        } );
    }

    vnode.children.forEach( child => render( child, dom ) );    // 递归渲染子节点

    return container.appendChild( dom );    // 将渲染结果挂载到真正的DOM上
}

设置属性需要考虑一些特殊情况,我们单独将其拿出来作为一个方法setAttribute

function setAttribute( dom, name, value ) {
    // 如果属性名是className,则改回class
    if ( name === 'className' ) name = 'class';

    // 如果属性名是onXXX,则是一个事件监听方法
    if ( /on\w+/.test( name ) ) {
        name = name.toLowerCase();
        dom[ name ] = value || '';
    // 如果属性名是style,则更新style对象
    } else if ( name === 'style' ) {
        if ( !value || typeof value === 'string' ) {
            dom.style.cssText = value || '';
        } else if ( value && typeof value === 'object' ) {
            for ( let name in value ) {
                // 可以通过style={ width: 20 }这种形式来设置样式,可以省略掉单位px
                dom.style[ name ] = typeof value[ name ] === 'number' ? value[ name ] + 'px' : value[ name ];
            }
        }
    // 普通属性则直接更新属性
    } else {
        if ( name in dom ) {
            dom[ name ] = value || '';
        }
        if ( value ) {
            dom.setAttribute( name, value );
        } else {
            dom.removeAttribute( name );
        }
    }
}

这里其实还有个小问题:当多次调用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

下一篇文章

从零开始实现React(二):组件和生命周期

@supergaojian

This comment has been minimized.

supergaojian commented Mar 19, 2018

写的很简单,也很容易明白,点个赞

@xqk1

This comment has been minimized.

xqk1 commented Mar 19, 2018

梳理的挺好的

@dabaoabc

This comment has been minimized.

dabaoabc commented Mar 19, 2018

赞,期待二

@Sunshine168

This comment has been minimized.

Sunshine168 commented Mar 20, 2018

赞 学长牛逼~

@hujiulong

This comment has been minimized.

Owner

hujiulong commented Mar 20, 2018

@dabaoabc 下周更新~

@hujiulong

This comment has been minimized.

Owner

hujiulong commented Mar 20, 2018

@Sunshine168 哈哈,是麦子吗?

@Sunshine168

This comment has been minimized.

Sunshine168 commented Mar 20, 2018

@hujiulong 哈哈 是啊 学长还记得我 感人呐 持续关注跟着学习~

@tebin123

This comment has been minimized.

tebin123 commented Mar 21, 2018

搭楼问下,我在写utils的时候只想调用react-router-dom里的history.push,要怎么做

@shihangbo

This comment has been minimized.

shihangbo commented Mar 24, 2018

期待继续跟新。。。

@hujiulong

This comment has been minimized.

Owner

hujiulong commented Mar 26, 2018

系列第二篇更新了,同时这篇文章也修复了一点小问题,增加了事件处理。

@shihangbo

This comment has been minimized.

shihangbo commented Mar 28, 2018

请问,这里dom[ key.toLowerCase() ] = value;是通过什么机制绑定到真实dom上面的,方法体是注册到哪里的,因为我在this和window下都没有找到?

@hujiulong

This comment has been minimized.

Owner

hujiulong commented Mar 28, 2018

@shihangbo 给dom附加事件有两种方式,一个是通过addEventListener,另一个就是直接给dom添加onxxx属性

document.body.onclick = function() { console.log( 'click' ); };
@ws456999

This comment has been minimized.

ws456999 commented Apr 4, 2018

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

答案很明显啊,jsx转换成abstract dom tree的时候,需要 React.createElement

@hujiulong hujiulong changed the title from 从零开始实现React(一):JSX和虚拟DOM to 从零开始实现一个React(一):JSX和虚拟DOM Apr 8, 2018

@hujiulong hujiulong added the React label Apr 8, 2018

@magic-akari

This comment has been minimized.

magic-akari commented Apr 11, 2018

有个小问题, className 那里可能不需要特殊处理,因为

"className" in dom === true
"class" in dom === false

另外,后边普通属性更新那里逻辑可能要改成这样

if (name in dom) {
  dom[name] = value || "";
} else if (value) {
  dom.setAttribute(name, value);
} else {
  dom.removeAttribute(name, value);
}

我想法不太成熟,所以想向博主求证一下。

@hujiulong

This comment has been minimized.

Owner

hujiulong commented Apr 11, 2018

@hufan-akari 看得很仔细啊,这个地方确实有点问题,但是和你说的有点区别
className是有必要改回class的,大多数情况下 dom.className = valuedom.setAttribute( 'class', value )效果是一样的。
但是svg元素比较特殊,svg元素的className是一个SVGAnimatedString对象,也就是说给svg元素设置class时要用setAttribute

有问题的地方在于,我就算改成class了,也会执行dom[name] = value,所以这段代码应该这样改

-       if ( name in dom ) {
+       if ( name !== 'class' && name in dom ) {
            dom[ name ] = value || '';
        }

其实是一个小问题啦,这个实现我也其实也没太多考虑svg

@magic-akari

This comment has been minimized.

magic-akari commented Apr 11, 2018

要处理 SVG 的话,感觉就更麻烦了,印象中 SVG 的命名空间都不一样,要用 document.createElementNS 来创建。

很多细节,更适合单独拿出来写成一个函数吧。

@hujiulong 受教了,多谢指点。

@BeliefRC

This comment has been minimized.

BeliefRC commented Apr 12, 2018

setAttribute这个函数里node.style.cssText = value || ''; node应该改为dom,然后普通属性为什么需要name in dom 的判断呢 直接setAttributeremoveAttribute不就可以了吗 ,求指教

@hujiulong

This comment has been minimized.

Owner

hujiulong commented Apr 12, 2018

@BeliefRC node这里写错了,感谢指出。属性分为dom对象属性和标签属性,它们的区别可以参考一下jquery的prop()和attr()的区别

@ivanberry

This comment has been minimized.

ivanberry commented Apr 13, 2018

可能我理解上有问题,parcel在这里的作用是什么?babel编译?提供server? 因为parcel后观察不到输出的编译后的React Element,请博主指教

@hujiulong

This comment has been minimized.

Owner

hujiulong commented Apr 13, 2018

@ivanberry babel编译+打包+提供server。文章主要的内容是说原理,我不想花太多篇幅去介绍怎么用rollup或者webpack打包,所以就选择用parcel啦

@zhanyuzhang

This comment has been minimized.

zhanyuzhang commented Apr 19, 2018

Niubility

@lduoduo

This comment has been minimized.

lduoduo commented Apr 19, 2018

很棒!

@zhengdai

This comment has been minimized.

zhengdai commented Apr 20, 2018

removeAttribute那里只传name就行了吧

@hujiulong

This comment has been minimized.

Owner

hujiulong commented Apr 20, 2018

@zhengdai 是的,只传dom和name就行了,但是传一个undefined更能表达清楚意思,可读性强一点

@cobish

This comment has been minimized.

cobish commented Apr 20, 2018

文章很赞,话说,这个是怎么弄出来的?

timline 20180420152957

@hujiulong

This comment has been minimized.

Owner

hujiulong commented Apr 20, 2018

@cobish 正想说你不是打出来了吗,原来是张图片呀😄
markdown里代码块开头是```js,后面的js是语言,把js改成diff,然后在行前面写+或者-就有这种效果了

+ 增加一行
- 删除一行

但是diff本身不是语言,指定成diff就没有语法高亮了,这一点很不爽

@cobish

This comment has been minimized.

cobish commented Apr 20, 2018

@hujiulong 多谢分享~ 没有语法高亮确实很不爽

@BiggerHacker

This comment has been minimized.

BiggerHacker commented Apr 20, 2018

请问为啥我按你写的 用parcel index.html启动后报错

function createElement (tag, attrs, ...children) {
      return {
        tag,
        attrs,
        children
      }
    }
const React = {
      createElement
    }
    const element = (
      <div>
        hello<span>world!</span>
      </div>
    )
    console.log(element)

Uncaught SyntaxError: Unexpected token <

"devDependencies": {
    "babel-core": "^6.26.0",
    "babel-plugin-transform-react-jsx": "^6.24.1",
    "babel-preset-env": "^1.6.1"
  }

这边babel插件也下载了,.babelrc也配置了
求解惑

@duhongjun

This comment has been minimized.

duhongjun commented Apr 23, 2018

@BiggerHacker
你是写在HTML里了吧?

@duhongjun

This comment has been minimized.

duhongjun commented Apr 23, 2018

parcel确实是零配置... 但是我发现打断点的时候, 代码都转译过了... 看着略蛋疼

@hujiulong

This comment has been minimized.

Owner

hujiulong commented Apr 23, 2018

@duhongjun 我记得parcel有sourcemap

@duhongjun

This comment has been minimized.

duhongjun commented Apr 24, 2018

@hujiulong 看了下, 是有sourcemap.. 但是在代码debugger并不会跳到sourcemap里, 而是在编译后的文件里...🤣

@ivanberry

This comment has been minimized.

ivanberry commented May 8, 2018

setAttribute中style为字符串时,这里是否存在这样的问题:

ReactDOM.render(
    <h1 style='fontSize: 100px'>TTT</h1>,
    root
);

ReactDOM.render(
    <h1 style='font-size: 100px'>TTT</h1>,
    root
);

因为字符串直接是通过dom.style.cssText设置,理所当然没有fontSize,样式不起作用。

React中一般写成驼峰形式,而样式属性作为字符串出现时(React中这般写会报错),所以这里应该处理下,要么直接抛出错误,要么就得提示不能使用驼峰的形式写。

@hujiulong

This comment has been minimized.

Owner

hujiulong commented May 8, 2018

@ivanberry 感谢指出。这毕竟是对react的一个简单实现,很多边界情况没有考虑,为了让代码简洁,我就不增加这些检查了

@ivanberry

This comment has been minimized.

ivanberry commented May 8, 2018

@hujiulong 我也是在学习你的经历,以便自己有对React类库有更好的理解,并也将成文,可能会大量引用你的代码实现,看是否需要什么授权类的操作。再次感谢。

@hujiulong

This comment has been minimized.

Owner

hujiulong commented May 8, 2018

@ivanberry 引用的地方注明原作者并给出链接就行啦,我在README中增加了LICENSE

@webzhai

This comment has been minimized.

webzhai commented Jul 5, 2018

没有用到React对象,也要将其import进来是因为JSX的语法糖需要用到 react的React.createElement将其转换为虚拟dom吗?

@hujiulong

This comment has been minimized.

Owner

hujiulong commented Jul 5, 2018

@webzhai 没错

@dreamsline

This comment has been minimized.

dreamsline commented Aug 10, 2018

最近在学React,写的不错。可以帮忙解释以下

// 普通属性则直接更新属性
    } else {
        if ( name in dom ) {
            dom[ name ] = value || '';
        }
        if ( value ) {
            dom.setAttribute( name, value );
        } else {
            dom.removeAttribute( name, value );
        }
    }

这一段具体是干什么的吗?
我觉得是给标签直接设置属性例如:<h2 color="orange">It is {new Date().toLocaleTimeString()}</h2>,但是测试了没有效果。

@hujiulong

This comment has been minimized.

Owner

hujiulong commented Aug 10, 2018

@dreamsline <h2 style="color: orange">xxx</h2>

@ronffy

This comment has been minimized.

ronffy commented Oct 11, 2018

@hujiulong

给dom附加事件有两种方式,一个是通过addEventListener,另一个就是直接给dom添加onxxx属性

document.body.onclick = function() { console.log( 'click' ); };

这里面有个疑问,组件或DOM销毁时,没有看到有删除事件的处理呢。 这样会有内存泄漏的风险吧

@hujiulong

This comment has been minimized.

Owner

hujiulong commented Oct 11, 2018

@ronffy DOM被销毁掉时,注册在它上面的事件监听方法也会被回收,一般浏览器自己会做这个事,除了一些比较旧的浏览器,例如低版本IE

@ronffy

This comment has been minimized.

ronffy commented Oct 18, 2018

@hujiulong 请教下:

function setAttribute( dom, name, value ) {
  // ... 省略其他代码
    
  问题:下面的setAttribute 和 removeAttribute操作已经对dom属性更新了,请问这一步的目的是什么呢?
  if ( name in dom ) {
    dom[ name ] = value || '';
  }

  if ( value ) {
    dom.setAttribute( name, value );
  } else {

    指正:removeAttribute 方法不需要第二个参数
    dom.removeAttribute( name, value );
  }
}
@hujiulong

This comment has been minimized.

Owner

hujiulong commented Oct 18, 2018

@ronffy  上面我回复了同样的问题,removeAttribute这里确实写错了,感谢指出。

@ronffy

This comment has been minimized.

ronffy commented Oct 18, 2018

@hujiulong
明白了,之前没注意到 😁

@calpa

This comment has been minimized.

calpa commented Oct 30, 2018

很不错的教程,我把第一部分的代码做了成 CodePen 版本,开箱即学到了。
https://codepen.io/calpa/pen/WaBQMW?editors=1011

@JayWongwz

This comment has been minimized.

JayWongwz commented Dec 10, 2018

每次保存就多渲染个hello world呢?

@zhenghan2017

This comment has been minimized.

zhenghan2017 commented Dec 15, 2018

const element = ( <div> hello<span>world</span> </div> );

作为小白想问,这个()里写

是个什么意思,是调用什么方法吗?,为什么可以打印出react.createElement()执行后的效果,我自己主动调用createElement,控制台输出的也不一样,这里到底发生了什么?

@hujiulong

This comment has been minimized.

Owner

hujiulong commented Dec 15, 2018

@zhenghan2017 括号没什么意义,这里的括号可以省略

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment