hujiulong / blog

:open_book:

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

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

hujiulong opened this issue · comments

前言

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(二):组件和生命周期

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

梳理的挺好的

赞,期待二

赞 学长牛逼~

@dabaoabc 下周更新~

@Sunshine168 哈哈,是麦子吗?

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

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

期待继续跟新。。。

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

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

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

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

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

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

有个小问题, 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);
}

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

@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

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

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

@hujiulong 受教了,多谢指点。

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

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

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

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

commented

Niubility

很棒!

removeAttribute那里只传name就行了吧

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

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

timline 20180420152957

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

+ 增加一行
- 删除一行

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

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

请问为啥我按你写的 用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也配置了
求解惑

@BiggerHacker
你是写在HTML里了吧?

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

@duhongjun 我记得parcel有sourcemap

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

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中这般写会报错),所以这里应该处理下,要么直接抛出错误,要么就得提示不能使用驼峰的形式写。

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

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

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

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

最近在学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>,但是测试了没有效果。

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

@hujiulong

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

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

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

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

@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 );
  }
}

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

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

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

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

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

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

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

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

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

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

感谢您的回答

commented

明白了,受教了,谢谢

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

commented

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

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

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

👍 博主写的太赞了!

这里有个问题问一下

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

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

commented

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

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

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

讲的很清楚,谢谢UP主。

很有趣

真的很强!

文章写的真不错🍬

后面会出hooks嘛

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

@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 );
   }

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

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

没看明白这里是咋解析的

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类型的判断逻辑。

commented

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 方式。

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) }
才对吧

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