内容简介:摘要:教你撸一个React。现在(2018年)首先我们需要弄明白几个容易混淆的概念,最开始学习
摘要:教你撸一个React。
- 原文: 200行代码实现简版react
- 作者: iamswf
Fundebug经授权转载,版权归原作者所有。
现在(2018年) react
在前端开发领域已经越来越了,我自己也经常在项目中使用 react
,但是却总是好奇 react
的底层实现原理,多次尝试阅读 react
源代码都无法读下去,确实太难了。前不久在网上看到几篇介绍如何自己动手实现 react
的文章,这里基于这些资料,并加入一些自己的想法,从0开始仅用 200
行代码实现一个 简版react
,相信看完后大家都会对 react
的内部实现原理有更多了解。但是在动手之前我们需要先掌握几个 react
相关的重要概念,比如 组件(类)
与 组件实例
的区别、 diff
算法以及 生命周期
等,下面依次介绍下,熟悉完这些概念我们再动手实现。
1. 基本概念:Component(组件)、instance(组件实例)、 element、jsx、dom
首先我们需要弄明白几个容易混淆的概念,最开始学习 react
的时候我也有些疑惑他们之间有什么不同,前几天跟一个新同学讨论一个问题,发现他竟然也分不清 组件
和 组件实例
,因此很有必要弄明白这几个概念的区别于联系,本篇后面我们实现这个 简版react
也是基于这些概念。
Component(组件)
Component
就是我们经常实现的组件,可以是 类组件
( class component
)或者 函数式组件
( functional component
),而 类组件
又可以分为普通类组件( React.Component
)以及纯类组件( React.PureComponent
),总之这两类都属于 类组件
,只不过 PureComponent
基于 shouldComponentUpdate
做了一些优化,这里不展开说。 函数式组件
则用来简化一些简单组件的实现,用起来就是写一个函数,入参是组件属性 props
,出参与 类组件
的 render
方法返回值一样,是 react element
(注意这里已经出现了接下来要介绍的 element
哦)。 下面我们分别按三种方式实现下 Welcome
组件:
// Component class Welcome extends React.Component { render() { return <h1>Hello, {this.props.name}</h1>; } } // PureComponent class Welcome extends React.PureComponent { render() { return <h1>Hello, {this.props.name}</h1>; } } // functional component function Welcome(props) { return <h1>Hello, {props.name}</h1>; }
instance(组件实例)
熟悉 面向对象编程
的人肯定知道 类
和 实例
的关系,这里也是一样的, 组件实例
其实就是一个 组件类
实例化的结果,概念虽然简单,但是在 react
这里却容易弄不明白,为什么这么说呢?因为大家在 react
的使用过程中并不会自己去实例化一个 组件实例
,这个过程其实是 react
内部帮我们完成的,因此我们真正接触 组件实例
的机会并不多。我们更多接触到的是下面要介绍的 element
,因为我们通常写的 jsx
其实就是 element
的一种表示方式而已(后面详细介绍)。虽然 组件实例
用的不多,但是偶尔也会用到,其实就是 ref
。 ref
可以指向一个 dom节点
或者一个 类组件(class component)
的实例,但是不能用于 函数式组件
,因为 函数式组件
不能 实例化
。这里简单介绍下 ref
,我们只需要知道 ref
可以指向一个 组件实例
即可,更加详细的介绍大家可以看 react
官方文档 Refs and the DOM 。
element
前面已经提到了 element
,即 类组件
的 render
方法以及 函数式组件
的返回值均为 element
。那么这里的 element
到底是什么呢?其实很简单,就是一个纯对象( plain object
),而且这个纯对象包含两个属性: type:(string|ReactClass)
和 props:Object
,注意 element
并不是 组件实例
,而是一个纯对象。 虽然element不是组件实例,但是又跟组件实例有关系,element是对组件实例或者dom节点的描述 。如果 type
是 string
类型,则表示 dom节点
,如果 type
是 function
或者 class
类型,则表示 组件实例
。比如下面两个 element
分别描述了一个 dom节点
和一个 组件实例
:
// 描述dom节点 { type: 'button', props: { className: 'button button-blue', children: { type: 'b', props: { children: 'OK!' } } } } 复制代码 function Button(props){ // ... } // 描述组件实例 { type: Button, props: { color: 'blue', children: 'OK!' } }
jsx
只要弄明白了 element
,那么 jsx
就不难理解了, jsx
只是换了一种写法,方便我们来创建 element
而已,想想如果没有 jsx
那么我们开发效率肯定会大幅降低,而且代码肯定非常不利于维护。比如我们看下面这个 jsx
的例子:
const foo = <div id="foo">Hello!</div>;
其实说白了就是定义了一个dom节点 div
,并且该节点的属性集合是 {id: 'foo'}
, children
是 Hello!
,就这点信息量而已,因此完全跟下面这种纯对象的表示是等价的:
{ type: 'div', props: { id: 'foo', children: 'Hello!' } }
那么 React
是如何将 jsx
语法转换为纯对象的呢?其实就是利用 Babel
编译生成的,我们只要在使用 jsx
的代码里加上个 编译指示(pragma)
即可,可以参考这里 Babel如何编译jsx 。比如我们将 编译指示
设置为指向 createElement
函数: /** @jsx createElement */
,那么前面那段 jsx
代码就会编译为:
var foo = createElement('div', {id:"foo"}, 'Hello!');
可以看出, jsx
的编译过程其实就是从 <
、 >
这种 标签式
写法到 函数调用式
写法的一种转化而已。有了这个前提,我们只需要简单实现下 createElement
函数不就可以构造出 element
了嘛,我们后面自己实现 简版react
也会用到这个函数:
function createElement(type, props, ...children) { props = Object.assign({}, props); props.children = [].concat(...children) .filter(child => child != null && child !== false) .map(child => child instanceof Object ? child : createTextElement(child)); return {type, props}; }
dom
dom我们这里也简单介绍下,作为一个前端研发人员,想必大家对这个概念应该再熟悉不过了。我们可以这样创建一个dom节点 div
:
const divDomNode = window.document.createElement('div');
其实所有dom节点都是 HTMLElement类
的实例,我们可以验证下:
window.document.createElement('div') instanceof window.HTMLElement; // 输出 true
关于 HTMLElement
API可以参考这里: HTMLElement介绍 。因此, dom
节点是 HTMLElement类
的实例;同样的,在 react
里面, 组件实例
是 组件类
的实例,而 element
又是对 组件实例
和 dom
节点的描述,现在这些概念之间的关系大家应该都清楚了吧。介绍完了这几个基本概念,我们画个图来描述下这几个概念之间的关系:
2. 虚拟dom与diff算法
相信使用过 react
的同学都多少了解过这两个概念: 虚拟dom
以及 diff算法
。这里的 虚拟dom
其实就是前面介绍的 element
,为什么说是 虚拟
dom呢,前面咱们已经介绍过了, element
只是 dom
节点或者 组件实例
的一种纯对象描述而已,并不是真正的 dom
节点,因此是 虚拟
dom。 react
给我们提供了 声明式
的组件写法,当组件的 props
或者 state
变化时组件自动更新。整个页面其实可以对应到一棵 dom
节点树,每次组件 props
或者 state
变更首先会反映到 虚拟dom
树,然后最终反应到页面 dom
节点树的渲染。
那么 虚拟dom
跟 diff算法
又有什么关系呢?之所以有 diff
算法其实是为了提升 渲染
效率,试想下,如果每次组件的 state
或者 props
变化后都把所有相关 dom
节点删掉再重新创建,那效率肯定非常低,所以在 react
内部存在两棵 虚拟dom
树,分别表示 现状
以及 下一个状态
, setState
调用后就会触发 diff
算法的执行,而好的 diff
算法肯定是尽可能复用已有的 dom
节点,避免重新创建的开销。我用下图来表示 虚拟dom
和 diff算法
的关系:
react组件最初渲染到页面后先生成第1帧虚拟dom,这时current指针指向该第一帧。setState调用后会生成第2帧虚拟dom,这时next指针指向第二帧,接下来diff算法通过比较第2帧和第1帧的异同来将更新应用到真正的dom树以完成页面更新。
这里再次强调一下setState后具体怎么生成虚拟dom,因为这点很重要,而且容易忽略。前面刚刚已经介绍过什么是虚拟dom了,就是element树而已。那element树是怎么来的呢?其实就是render方法返回的嘛,下面的流程图再加深下印象:
其实react官方对diff算法有另外一个称呼,大家肯定会在react相关资料中看到,叫Reconciliation,我个人认为这个词有点晦涩难懂,不过后来又重新翻看了下词典,发现跟diff算法一个意思:
可以看到reconcile有消除分歧、核对的意思,在react语境下就是对比虚拟dom异同的意思,其实就是说的diff算法。这里强调下,我们后面实现部实现reconcile函数,就是实现diff算法。
3. 生命周期与diff算法
生命周期
与 diff算法
又有什么关系呢?这里我们以 componentDidMount
、 componentWillUnmount
、 ComponentWillUpdate
以及 componentDidUpdate
为例说明下二者的关系。我们知道, setState
调用后会接着调用 render
生成新的 虚拟dom
树,而这个 虚拟dom
树与上一帧可能会产生如下区别:
- 新增了某个组件;
- 删除了某个组件;
- 更新了某个组件的部分属性。
因此,我们在实现 diff算法
的过程会在相应的时间节点调用这些 生命周期
函数。
这里需要重点说明下前面提到的 第1帧
,我们知道每个 react
应用的入口都是:
ReactDOM.render( <h1>Hello, world!</h1>, document.getElementById('root') );
ReactDom.render
也会生成一棵 虚拟dom
树,但是这棵 虚拟dom
树是开天辟地生成的 第一帧
,没有前一帧用来做diff,因此这棵 虚拟dom
树对应的所有组件都只会调用 挂载期
的生命周期函数,比如 componentDidMount
、 componentWillUnmount
。
4. 实现
掌握了前面介绍的这些概念,实现一个 简版react
也就不难了。这里需要说明下,本节实现部分是基于这篇博客的实现 Didact: a DIY guide to build your own React 。 现在首先看一下我们要实现哪些API,我们最终会以如下方式使用:
// 声明编译指示 /** @jsx DiyReact.createElement */ // 导入我们下面要实现的API const DiyReact = importFromBelow(); // 业务代码 const randomLikes = () => Math.ceil(Math.random() * 100); const stories = [ {name: "React", url: "https://reactjs.org/", likes: randomLikes()}, {name: "Node", url: "https://nodejs.org/en/", likes: randomLikes()}, {name: "Webpack", url: "https://webpack.js.org/", likes: randomLikes()} ]; const ItemRender = props => { const {name, url} = props; return ( <a href={url}>{name}</a> ); }; class App extends DiyReact.Component { render() { return ( <div> <h1>DiyReact Stories</h1> <ul> {this.props.stories.map(story => { return <Story name={story.name} url={story.url} />; })} </ul> </div> ); } componentWillMount() { console.log('execute componentWillMount'); } componentDidMount() { console.log('execute componentDidMount'); } componentWillUnmount() { console.log('execute componentWillUnmount'); } } class Story extends DiyReact.Component { constructor(props) { super(props); this.state = {likes: Math.ceil(Math.random() * 100)}; } like() { this.setState({ likes: this.state.likes + 1 }); } render() { const {name, url} = this.props; const {likes} = this.state; const likesElement = <span />; return ( <li> <button onClick={e => this.like()}>{likes}<b>❤️</b></button> <ItemRender {...itemRenderProps} /> </li> ); } // shouldcomponentUpdate() { // return true; // } componentWillUpdate() { console.log('execute componentWillUpdate'); } componentDidUpdate() { console.log('execute componentDidUpdate'); } } // 将组件渲染到根dom节点 DiyReact.render(<App stories={stories} />, document.getElementById("root"));
我们在这段业务代码里面使用了 render
、 createElement
以及 Component
三个API,因此后面的任务就是实现这三个API并包装到一个函数 importFromBelow
内即可。
4.1 实现createElement
createElement
函数的功能跟 jsx
是紧密相关的,前面介绍 jsx
的部分已经介绍过了,其实就是把类似 html
的标签式写法转化为纯对象 element
,具体实现如下:
function createElement(type, props, ...children) { props = Object.assign({}, props); props.children = [].concat(...children) .filter(child => child != null && child !== false) .map(child => child instanceof Object ? child : createTextElement(child)); return {type, props}; }
4.2 实现render
注意这个 render
相当于 ReactDOM.render
,不是 组件
的 render
方法, 组件
的 render
方法在后面 Component
实现部分。
// rootInstance用来缓存一帧虚拟dom let rootInstance = null; function render(element, parentDom) { // prevInstance指向前一帧 const prevInstance = rootInstance; // element参数指向新生成的虚拟dom树 const nextInstance = reconcile(parentDom, prevInstance, element); // 调用完reconcile算法(即diff算法)后将rooInstance指向最新一帧 rootInstance = nextInstance; }
render
函数实现很简单,只是进行了两帧 虚拟dom
的对比(reconcile),然后将 rootInstance
指向新的 虚拟dom
。细心点会发现,新的 虚拟dom
为 element
,即最开始介绍的 element
,而 reconcile
后的 虚拟dom
是 instance
,不过这个 instance
并不是 组件实例
,这点看后面 instantiate
的实现。总之 render
方法其实就是调用了 reconcile
方法进行了两帧 虚拟dom
的对比而已。
4.3 实现instantiate
那么前面的 instance
到底跟 element
有什么不同呢?其实 instance
指示简单的是把 element
重新包了一层,并把对应的 dom
也给包了进来,这也不难理解,毕竟我们调用 reconcile
进行 diff
比较的时候需要把跟新应用到真实的 dom
上,因此需要跟 dom
关联起来,下面实现的 instantiate
函数就干这个事的。注意由于 element
包括 dom
类型和 Component
类型(由 type
字段判断,不明白的话可以回过头看一下第一节的 element
相关介绍),因此需要分情况处理:
dom
类型的 element.type
为 string
类型,对应的 instance
结构为 {element, dom, childInstances}
。
Component
类型的 element.type
为 ReactClass
类型,对应的 instance
结构为 {dom, element, childInstance, publicInstance}
,注意这里的 publicInstance
就是前面介绍的 组件实例
。
function instantiate(element) { const {type, props = {}} = element; const isDomElement = typeof type === 'string'; if (isDomElement) { // 创建dom const isTextElement = type === TEXT_ELEMENT; const dom = isTextElement ? document.createTextNode('') : document.createElement(type); // 设置dom的事件、数据属性 updateDomProperties(dom, [], element.props); const children = props.children || []; const childInstances = children.map(instantiate); const childDoms = childInstances.map(childInstance => childInstance.dom); childDoms.forEach(childDom => dom.appendChild(childDom)); const instance = {element, dom, childInstances}; return instance; } else { const instance = {}; const publicInstance = createPublicInstance(element, instance); const childElement = publicInstance.render(); const childInstance = instantiate(childElement); Object.assign(instance, {dom: childInstance.dom, element, childInstance, publicInstance}); return instance; } }
需要注意,由于 dom节点
和 组件实例
都可能有孩子节点,因此 instantiate
函数中有递归实例化的逻辑。
4.4 区分类组件与函数式组件
前面我们提到过,组件包括 类组件
( class component
)与 函数式组件
( functional component
)。我在平时的业务中经常用到这两类组件,如果一个组件仅用来渲染,我一般会使用 函数式组件
,毕竟代码逻辑简单清晰易懂。那么 React
内部是如何区分出来这两种组件的呢?这个问题说简单也简单,说复杂也复杂。为什么这么说呢,是因为 React
内部实现方式确实比较简单,但是这种简单的实现方式却是经过各种考量后确定下来的实现方式。蛋总( Dan
)有一篇文章详细分析了下 React
内部如何区分二者,强烈推荐大家阅读,这里我直接拿过来用,文章链接见这里 How Does React Tell a Class from a Function? 。其实很简答,我们实现 类组件
肯定需要继承自类 React.Component
,因此首先给 React.Component
打个标记,然后在实例化组件时判断 element.type
的原型链上是否有该标记即可。
// 打标记 Component.prototype.isReactComponent = {}; // 区分组件类型 const type = element.type; const isDomElement = typeof type === 'string'; const isClassElement = !!(type.prototype && type.prototype.isReactComponent);
这里我们升级下前面的实例化函数 instantiate
以区分出 函数式组件
与 类组件
:
function instantiate(element) { const {type, props = {}} = element; const isDomElement = typeof type === 'string'; const isClassElement = !!(type.prototype && type.prototype.isReactComponent); if (isDomElement) { // 创建dom const isTextElement = type === TEXT_ELEMENT; const dom = isTextElement ? document.createTextNode('') : document.createElement(type); // 设置dom的事件、数据属性 updateDomProperties(dom, [], element.props); const children = props.children || []; const childInstances = children.map(instantiate); const childDoms = childInstances.map(childInstance => childInstance.dom); childDoms.forEach(childDom => dom.appendChild(childDom)); const instance = {element, dom, childInstances}; return instance; } else if (isClassElement) { const instance = {}; const publicInstance = createPublicInstance(element, instance); const childElement = publicInstance.render(); const childInstance = instantiate(childElement); Object.assign(instance, {dom: childInstance.dom, element, childInstance, publicInstance}); return instance; } else { const childElement = type(element.props); const childInstance = instantiate(childElement); const instance = { dom: childInstance.dom, element, childInstance }; return instance; } }
可以看到,如果是 函数式组件
,我们没有实例化该组件,而是直接调用了该函数获取 虚拟dom
。
4.5 实现reconcile(diff算法)
重点来了, reconcile
是 react
的核心,显然如何将新设置的 state
快速的渲染出来非常重要,因此 react
会尽量复用已有节点,而不是每次都动态创建所有相关节点。但是 react
强大的地方还不仅限于此, react16
将 reconcile
算法由之前的 stack
架构升级成了 fiber
架构,更近一步做的性能优化。 fiber
相关的内容下一节再介绍,这里为了简单易懂,仍然使用类似 stack
架构的算法来实现,对于 fiber
现在只需要知道其 调度
原理即可,当然后面有时间可以再实现一版基于 fiber
架构的。
首先看一下整个 reconcile
算法的处理流程:
可以看到,我们会根据不同的情况做不同的处理:
- 如果是新增
instance
,那么需要实例化一个instance
并且appendChild
; - 如果是不是新增
instance
,而是删除instance
,那么需要removeChild
; - 如果既不是新增也不是删除
instance
,那么需要看instance
的type
是否变化,如果有变化,那节点就无法复用了,也需要实例化instance
,然后replaceChild
; - 如果
type
没变化就可以复用已有节点了,这种情况下要判断是原生dom
节点还是我们自定义实现的react
节点,两种情况下处理方式不同。
大流程了解后,我们只需要在对的时间点执行 生命周期
函数即可,下面看具体实现:
function reconcile(parentDom, instance, element) { if (instance === null) { const newInstance = instantiate(element); // componentWillMount newInstance.publicInstance && newInstance.publicInstance.componentWillMount && newInstance.publicInstance.componentWillMount(); parentDom.appendChild(newInstance.dom); // componentDidMount newInstance.publicInstance && newInstance.publicInstance.componentDidMount && newInstance.publicInstance.componentDidMount(); return newInstance; } else if (element === null) { // componentWillUnmount instance.publicInstance && instance.publicInstance.componentWillUnmount && instance.publicInstance.componentWillUnmount(); parentDom.removeChild(instance.dom); return null; } else if (instance.element.type !== element.type) { const newInstance = instantiate(element); // componentDidMount newInstance.publicInstance && newInstance.publicInstance.componentDidMount && newInstance.publicInstance.componentDidMount(); parentDom.replaceChild(newInstance.dom, instance.dom); return newInstance; } else if (typeof element.type === 'string') { updateDomProperties(instance.dom, instance.element.props, element.props); instance.childInstances = reconcileChildren(instance, element); instance.element = element; return instance; } else { if (instance.publicInstance && instance.publicInstance.shouldcomponentUpdate) { if (!instance.publicInstance.shouldcomponentUpdate()) { return; } } // componentWillUpdate instance.publicInstance && instance.publicInstance.componentWillUpdate && instance.publicInstance.componentWillUpdate(); instance.publicInstance.props = element.props; const newChildElement = instance.publicInstance.render(); const oldChildInstance = instance.childInstance; const newChildInstance = reconcile(parentDom, oldChildInstance, newChildElement); // componentDidUpdate instance.publicInstance && instance.publicInstance.componentDidUpdate && instance.publicInstance.componentDidUpdate(); instance.dom = newChildInstance.dom; instance.childInstance = newChildInstance; instance.element = element; return instance; } } function reconcileChildren(instance, element) { const {dom, childInstances} = instance; const newChildElements = element.props.children || []; const count = Math.max(childInstances.length, newChildElements.length); const newChildInstances = []; for (let i = 0; i < count; i++) { newChildInstances[i] = reconcile(dom, childInstances[i], newChildElements[i]); } return newChildInstances.filter(instance => instance !== null); }
看完 reconcile
算法后肯定有人会好奇,为什么这种算法叫做 stack
算法,这里简单解释一下。从前面的实现可以看到,每次组件的 state
更新都会触发 reconcile
的执行,而 reconcile
的执行也是一个递归过程,而且一开始直到递归执行完所有节点才停止,因此称为 stack
算法。由于是个递归过程,因此该 diff
算法一旦开始就必须执行完,因此可能会阻塞线程,又由于js是单线程的,因此这时就可能会影响用户的输入或者ui的渲染帧频,降低用户体验。不过 react16
中升级为了 fiber
架构,这一问题得到了解决。
4.6. 整体代码
把前面实现的所有这些代码组合起来就是完整的 简版react
,不到 200
行代码,so easy~!完整代码见 DiyReact 。
5. fiber架构
react16
升级了 reconcile
算法架构,从 stack
升级为 fiber
架构,前面我们已经提到过 stack
架构的缺点,那就是使用递归实现,一旦开始就无法暂停,只能一口气执行完毕,由于js是单线程的,这就有可能阻塞用户输入或者ui渲染,会降低用户体验。
而 fiber
架构则不一样。底层是基于 requestIdleCallback
来调度 diff
算法的执行,关于 requestIdleCallback
的介绍可以参考我之前写的一篇关于js 事件循环
的文章 javascript事件循环(浏览器端、node端) 。 requestIdlecallback
的特点顾名思义就是利用空闲时间来完成任务。注意这里的 空闲时间
就是相对于那些优先级更高的任务(比如用户输入、ui渲染)来说的。
这里再简单介绍一下 fiber
这个名称的由来,因为我一开始就很好奇为什么叫做 fiber
。 fiber
其实是 纤程
的意思,并不是一个新词汇,大家可以看维基百科的解释 Fiber (computer science) 。其实就是想表达一种 更加精细粒度的调度
的意思,因为基于这种算法 react
可以随时暂停 diff
算法的执行,而后有空闲时间了接着执行,这是一种更加 精细
的调度算法,因此称为 fiber
架构。本篇对 fiber
就先简单介绍这些,后面有时间再单独总结一篇。
6. 参考资料
- React Components, Elements, and Instances
- Refs and the DOM
- Didact: a DIY guide to build your own React
- How Does React Tell a Class from a Function?
- Lin Clark - A Cartoon Intro to Fiber - React Conf 2017
- Let’s fall in love with React Fiber
关于Fundebug
Fundebug专注于JavaScript、微信小程序、微信小游戏、支付宝小程序、React Native、Node.js和 Java 实时BUG监控。 自从2016年双十一正式上线,Fundebug累计处理了9亿+错误事件,得到了Google、360、金山软件、百姓网等众多知名用户的认可。欢迎免费试用!
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:- php实现mysql连接池效果实现代码
- 移动端下拉刷新头实现原理及代码实现
- 教你用一行Python代码实现并行任务(附代码)
- 50 行代码教 AI 实现动作平衡 | 附完整代码
- 排序算法代码实现-Java
- 幻术,一行代码实现镂空效果
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
Android软件安全与逆向分析
丰生强 / 人民邮电出版社 / 2013-2 / 69.00元
本书由浅入深、循序渐进地讲解了Android 系统的软件安全、逆向分析与加密解密技术。包括Android软件逆向分析和系统安全方面的必备知识及概念、如何静态分析Android 软件、如何动态调试Android 软件、Android 软件的破解与反破解技术的探讨,以及对典型Android 病毒的全面剖析。 本书适合所有Android 应用开发者、Android 系统开发工程师、Android ......一起来看看 《Android软件安全与逆向分析》 这本书的介绍吧!