React是前端最受欢迎的框架之一,解读其源码的文章非常多,但是我想从另一个角度去解读React:从零开始实现一个React,从API层面实现React的大部分功能,在这个过程中去探索为什么有虚拟DOM、diff、为什么setState这样设计等问题。
提起React,总是免不了和Vue做一番对比
Vue的API设计非常简洁,但是其实现方式却让人感觉是“魔法”,开发者虽然能马上上手,但其原理却很难说清楚。
相比之下React的设计哲学非常简单,虽然有很多需要自己处理的细节问题,但它没有引入任何新的概念,相对更加的干净和简单。
JSX和虚拟DOM
准备工作
为了集中精力编写逻辑,在代码打包工具上选择了火热的零配置打包工具parcel
全局安装parcel
新建项目react_simple,新建index.html和index.js,将index.js引入到index.html中
执行
项目依赖配置
安装parcel-bundler
配置package.json
安装babel
插件,将jsx
的语法转化成js
对象(这个js
对象即所谓的虚拟DOM
)
多说一下,我们都知道vue和react都有所谓的虚拟DOM,那么为什么要有虚拟DOM呢?虚拟DOM到底提升性能是因为什么呢?
大家可以参考一下这篇文章,主要是底层浏览器渲染DOM的一下事,把这些原理给面试官讲清楚了。基本上就没问题了。
回到正题,我们开始操作
同时.babelrc
配置如下
先看一个react的最简单,也是最熟悉的例子
因此,我们得出结论:JSX 语法糖经过 Babel 编译后转换成一种对象,该对象即所谓的虚拟 DOM
,使用虚拟 DOM 能让页面进行更为高效的渲染。
函数构造
index.js
打印结果如下:
createElement
方法返回的对象记录了这个DOM节点所有的信息,换句话说,通过它我们可以生成真正的DOM,这个记录信息的对象的我们称之为虚拟DOM
接下来,我们一一来实现想要的功能
ReactDOM.render
接下来是ReactDOM.render方法,
经过转换
所以render
的第一个参数实际上接受的是createElement返回的对象,也就是虚拟DOM .而第二个参数则是挂载的目标DOM
总而言之,render方法的作用就是将虚拟DOM渲染成真实的DOM,下面是它的实现:
设置属性需要考虑一些特殊情况(className,onClick,其它属性等),我们单独将其封装一个方法setAttribute
注意:多次地调用render函数时,不会清除原来的内容 先清除一下挂载目标DOM的内容
修改代码如下:
测试1
测试2
问题
在定义React组件或者书写React相关代码,不管代码中有没有用到React这个对象,我们都必须将其import进来,这是为什么?
其实上面就是答案了!!!!
组件和生命周期
在react中,组件有两种:函数组件和类组件,函数组件可以看作是类定义的一种简单形式
上面咱们说道:React.createElement
的实现
这种实现不得不说我们只是来渲染原生DOM元素,而对于组件来说,createElement
得到的参数略有不同:如果JSX片段中的每个元素是组件,那么createElement
的第一个参数tag将会是一个方法,而不是字符串
例如在处理<Home name='react' />
时,createElement
方法的第一个参数tag
,实际上就是我们定义的Home
方法:
我们不需要对createElement做修改,只需要知道如果渲染的是组件,tag的值将是一个函数
组件基类React.Component
通过类的方式定义组件,我们需要继承React.Component
Component
React.Component包含了一些预先定义好的变量和方法啊,我们一一实现它:
react文件夹下,新建component.js,定义一个Component
类:
组件内部的state
和渲染结果无关,当state
改变时通常会触发渲染,为了让React知道我们改变了state,我们只能通过setState方法来修改数据,我们可以通过Object.assign
来做一个简单的实现,在每次更新state
后,我们需要调用renderComponent
方法来重新渲染组件,renderComponent
方法的实现
render方法
实现的render方法只支持渲染原生的DOM元素,不支持渲染组件,我们需要修改ReactDOM.render
方法,让它支持渲染组件
思路:如果是函数组件或者类组件,则vnode.tag的类型为函数
在render方法中添加代码如下:
组件渲染
createComponent
方法用来创建组件实例,并且我想将函数定义组件扩展成为类定义的组件,为了后面方便处理
setComponentPorps
方法用来更新props
生命周期方法实现
在setComponentProps
方法中可以实现componentWillMount
和componentWillReceiveProps
两个生命周期方法
基本的组件渲染和声明周期方法已经实现
测试1:
效果:
组件挂载输出一次,后面每次更新会输出update
React的核心功能基本已经实现,但是我们目前的做法是每次更新都重新渲染整个组件甚至整个应用,这样的做法在页面复杂时会暴露出性能的问题,为了减少DOM操作,React又使用diff算法对DOM进行操作
diff算法
diff算法?what?什么玩意
如何减少DOM更新:我们需要找出渲染前后真正变化的部分,只更新这一部分.而对比变化,找出需要更新部分的算法称之为diff算法
对比策略
在前面我们实现了render
方法,它能讲虚拟DOM转换成真正的DOM
但是我们需要改进它,不要让它傻乎乎地重新渲染整个DOM数,而是找出真正变化的部分进行替换
这部分很多类似React框架实现方式都不太一样,有的框架会选择保存上次渲染的虚拟DOM,然后对比虚拟DOM前后的变化,得到一系列更新的数据,然后再将这些更新应用到真正的DOM上。
我们会选择直接对比虚拟DOM和真实DOM,这样就不需要额外保存上一次渲染的虚拟DOM,并且能够一边对比一边更新,这也是我们选择的方式。
不管是DOM还是虚拟DOM,它们的结构都是一棵树,完全对比两棵树变化的算法时间复杂度是O(n^3),但是考虑到我们很少会跨层级移动DOM,所以我们只需要对比同一层级的变化。
总而言之,我们的diff算法有两个原则
- 对比当前真实的DOM和虚拟DOM,在对比过程中直接更新真实DOM
- 只对比同一层级的变化
起步
先修改render函数,将_render方法渲染的方式改为我们即将写的diff算法方式
/react-dom/index.js
由上面的diff()
可以看出,传入了 真实DOM对象,虚拟DOM对象,根元素
实现
实现一个diff算法,它的作用是对比真实的DOM和虚拟DOM,最后返回更新后的DOM
/react-dom/diff.js
接下来实现这个方法
在这之前先来回忆一下我们虚拟DOM的结构:
虚拟DOM的结构可以分为三种,分别表示文本,原生DOM节点以及组件
对比文本节点
首先考虑最简单的文本节点,如果当前的DOM就是文本节点,则直接更新内容,否则就新建一个文本节点,并移除原来的DOM
文本节点十分简单,它没有属性,也没有子元素
对比组件
之前也说过,react组件分为函数组件和类组件,我们定制一个方法diffComponent
对比非文本DOM节点
如果vnode表示的是一个非文本DOM节点,分两种情况分析:
情况一: 如果真实DOM不存在,表示此节点是新增的
情况二:如果真实DOM存在,需要对比属性
和对比子节点
对比属性
找出来节点的属性以及事件监听的变化 单独起一个diffAttributes
方法
对比子节点
节点对比完成之后,接下来对比它的子节点
这个时候会有一个问题,前面我们实现的不同的diff算法,都是明确知道哪一个是真实DOM和虚拟DOM对比,但是子节点childrens是一个数组,他们可能改变顺序,或者数量有所变化,我们很难确定是和虚拟DOM对比的是哪一个?
对比子节点的方法有点复杂,在这里理解一下原理
最后再修改renderComponent
方法的两个地方
看一下有无diff算法之后的网页效果:
diff算法网页效果
无diff算法网页效果
我们实现了diff算法,通过它做到了每次只更新需要更新的部分,极大地减少了DOM操作。React实现远比这个要复杂,特别是在React 16之后还引入了Fiber架构,但是主要的思想是一致的。
实现diff算法可以说性能有了很大的提升,但是在别的地方仍然后很多改进的空间:每次调用setState后会立即调用renderComponent重新渲染组件,但现实情况是,我们可能会在极短的时间内多次调用setState。 假设我们在上文的Counter组件中写出了这种代码
那以目前的实现,每次点击都会渲染100次组件,对性能肯定有很大的影响。
下节课改进setState方法
异步的setState
上节,虽然我们实现了diff算法,性能有非常大的改进.同时我们也指出了问题:按照目前的实现,每次调用setState都会触发更新,如果组件内执行这样一段代码:
那么执行这段代码会导致这个组件被重新渲染100次,这对性能是一个非常大的负担
真正的React是怎么做的
效果显示: 你会发现..... What?为什么输出了100个0,我不是想让它每次循环+1么?而且你还发现页面还渲染了1
这说明每次循环中,拿到的state仍然是更新之前的
看到这种效果,不要惊讶,这是React的优化手段,但是显然它会导致一些不符合直觉的问题,所以针对这种情况,React给出了一种解决方法:setState接收的参数还可以是一个函数,在这个函数中可以获取先前的状态,并通过这个函数的返回值得到下一个状态
修改组件:
通过以上的演示,我们要做两个事情
- 异步更新state,将短时间内的多个setState合并成一个
- 为了解决异步更新导致的问题,增加另一种形式的setSatet:接受一个函数作为参数,在函数中可以得到前一个状态并返回下一个状态
合并setState
这种实现,每次调用setState都会更新state并马上渲染一次
setState队列
为了合并setState,我们需要一个队列来保存每次setState的数据,然后在一段时间后,清空这个队列来渲染组件
然后修改组件的setState方法,不再直接更新state和渲染组件,而是添加进队列中
现在队列是有了,怎么清空队列并渲染组件呢?
清空队列
定义一个flush方法,它的作用就是清空队列
这只是实现state的更新,接下来渲染组件,渲染组件不能遍历时进行,因为同一个组件可能会多次添加到队列中,我们需要另一个队列来保存所有组件,不同在于,这个队列内不会有重复的组件
我们在enqueueSetState
方法中,可以做这件事
在flush
方法中,我们还需要遍历renderQueue,来渲染每一个组件
以上操作,添加状态队列和组件队列,以及更新状态和更新组件的任务都已完成,接下来还剩下最重要的一件事情:什么时候执行flush方法
我们需要合并一段时间内所有的setState,也就是在一段时间后才执行flush方法来清空队列,关键是这一段时间
怎么决定
答案毫无疑问就是利用js的事件队列机制
你可以打开浏览器的调试工具运行一下,它们打印的结果是:
我们可以利用事件队列,让flush的所有同步任务后执行
这样在一次“事件循环“中,最多只会执行一次flush了,在这个“事件循环”中,所有的setState都会被合并,并只渲染一次组件。
我们又实现了一个很重要的优化:合并短时间内的多次setState,异步更新state。 到这里我们已经实现了React的大部分核心功能和优化手段了。
好了,读到这里,基本的React内部的原理可以跟面试官聊起来了,其实React源码内部还做了很多的事情,大家可以自行去阅读相关的源码。
觉得不做的倔友。留下你的小心心和赞 谢谢~~~~~
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!