Skip to content
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 · 73 comments
Open

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

hujiulong opened this issue Mar 18, 2018 · 73 comments
Labels

Comments

@hujiulong
Copy link
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
Copy link

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

@xqk1
Copy link

xqk1 commented Mar 19, 2018

梳理的挺好的

@dabaoabc
Copy link

赞,期待二

@Sunshine168
Copy link

赞 学长牛逼~

@hujiulong
Copy link
Owner Author

@dabaoabc 下周更新~

@hujiulong
Copy link
Owner Author

@Sunshine168 哈哈,是麦子吗?

@Sunshine168
Copy link

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

@kaibeen
Copy link

kaibeen commented Mar 21, 2018

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

@shihangbo
Copy link

期待继续跟新。。。

@hujiulong
Copy link
Owner Author

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

@shihangbo
Copy link

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

@hujiulong
Copy link
Owner Author

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

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

@ws456999
Copy link

ws456999 commented Apr 4, 2018

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

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

@hujiulong hujiulong changed the title 从零开始实现React(一):JSX和虚拟DOM 从零开始实现一个React(一):JSX和虚拟DOM Apr 8, 2018
@hujiulong hujiulong added the React label Apr 8, 2018
@magic-akari
Copy link

有个小问题, 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
Copy link
Owner Author

@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
Copy link

magic-akari commented Apr 11, 2018

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

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

@hujiulong 受教了,多谢指点。

@BeliefRC
Copy link

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

@hujiulong
Copy link
Owner Author

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

@ivanberry
Copy link

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

@hujiulong
Copy link
Owner Author

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

@zhanyuzhang
Copy link

Niubility

@lduoduo
Copy link

lduoduo commented Apr 19, 2018

很棒!

@zhengdai
Copy link

removeAttribute那里只传name就行了吧

@hujiulong
Copy link
Owner Author

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

@cobish
Copy link

cobish commented Apr 20, 2018

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

timline 20180420152957

@hujiulong
Copy link
Owner Author

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

+ 增加一行
- 删除一行

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

@zhenghan2017
Copy link

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

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

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

@hujiulong
Copy link
Owner Author

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

@zhenghan2017
Copy link

那我们打印出来的element为什么像是调用了createElement后的效果,是因为我们配置的babel的关系吗?

@hujiulong
Copy link
Owner Author

hujiulong commented Dec 18, 2018

@zhenghan2017 JSX就是语法糖,就是用更方便的方法调用createElement

@zhenghan2017
Copy link

感谢您的回答

@xinre
Copy link

xinre commented Jan 6, 2019

明白了,受教了,谢谢

@jackYouth
Copy link

深入浅出,梳理的很不错哦

@zhouzhili
Copy link

render函数最后的return container.appendChild( dom )以及ReactDom.render最后的return不是必须的吧,或者这样return有什么好处呢?

@JxSx
Copy link

JxSx commented Mar 31, 2019

render函数最后的return container.appendChild( dom )以及ReactDom.render最后的return不是必须的吧,或者这样return有什么好处呢?

render方法被用来递归,需要返回子节点的内容呀

@caiyongmin
Copy link

👍 博主写的太赞了!

这里有个问题问一下

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

上面代码的意思是,每次渲染都把 container.innerHTML 值为空字符串,也就是每次都是全量重新渲染,这样是不是有性能问题?而且之前页面某个元素如果有焦点,也会发生失焦的情况

@zhouzhili
Copy link

render函数最后的return container.appendChild( dom )以及ReactDom.render最后的return不是必须的吧,或者这样return有什么好处呢?

render方法被用来递归,需要返回子节点的内容呀

render方法没有需要获取子节点的内容,return container.appendChild( dom ) 返回的子节点内容并没有使用啊

@zhuyongbo100
Copy link

讲的很清楚,谢谢UP主。

@Carrie999
Copy link

很有趣

@JinJieTan
Copy link

真的很强!

@jirengu
Copy link

jirengu commented Aug 9, 2019

文章写的真不错🍬

@zmj0920
Copy link

zmj0920 commented Aug 27, 2019

后面会出hooks嘛

@zmj0920
Copy link

zmj0920 commented Aug 28, 2019

推荐个 react16源码(Fiber架构) https://www.cnblogs.com/colorful-coco/p/9579402.html

@zkytech
Copy link

zkytech commented Oct 25, 2019

@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

为什么不直接全部用setAttribute来做?

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

@15764265847
Copy link

卧槽,你后面竟然用了个setinterval,差点看漏了,我说怎么会一直变化,并没有相关代码

@15764265847
Copy link

It is {new Date().toLocaleTimeString()}.

没看明白这里是咋解析的

@zkytech
Copy link

zkytech commented Oct 26, 2019

It is {new Date().toLocaleTimeString()}.

没看明白这里是咋解析的

抱歉,刚才没理解你的意思。

const element = ( <div> hello{new Date().toLocaleDateString()}</div> );

上面这段代码经过编译后得到的是

var element = _react.default.createElement("div", null, " hello", new Date().toLocaleDateString());

但如果改成{new Date()},文章中的代码就无法运行了,所以这里还需要修改对vnode类型的判断逻辑。

@0x-sen
Copy link

0x-sen commented Jun 1, 2020

@G-Grant
Copy link

G-Grant commented Jul 28, 2020

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

关于上述代码,我也查找了相关资料,也做了一些测试,发现去除 name in dom 这一判断,对代码逻辑没有特殊的影响。

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

我现在挺困惑,还请楼主可以解释下,为什么要加这个判断?我看了 react-dom 内部的处理,仅针对 checkedmultiplemutedselected这四个,会使用 property 方式。

@weijiababi
Copy link

weijiababi commented Sep 29, 2022

createElement那里的children处理不对吧,如果使用了{this.state.list.map(i => node)}这样的形式就渲染出问题了,应该改成
function createElement(tag, attrs, ...children) { attrs = attrs || {} children = children.flat(1) return new Element(tag, attrs, children, attrs.key) }
才对吧

@Junfeizhao
Copy link

jsx中引入React是为了调用React.createElement将jsx转换为虚拟dom

@leonyh7
Copy link

leonyh7 commented Jan 31, 2023 via email

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests