React16源码解读:开篇带你搞懂几个面试考点

引言

现在,主流的前端框架React,Vue和Angular在前端领域已成三足鼎立之势,基于前端技术栈的发展示状,大大小小的公司或多或少也会使用其中某一项或者多项技术栈,那么掌握并熟练使用其中至少一种也成为了前端人员必不可少的技能饭碗。固然,框架的部分实现细节也常成为面试中的考察要点,所以,一方面为了应付面试官的连番追问,另外一方面为了提高本身的技能水平,仍是有必要对框架的底层实现原理有必定的涉猎。javascript

固然对于主攻哪门技术栈没有严格的要求,挑选你本身喜欢的就好,在面试中面试官通常会先问你最熟悉的是哪门技术栈,对于你不熟悉的领域,面试官可能也不会作太多的追问。笔者在项目中一直是使用的Vue框架,其上手门槛低,也提供了比较全面和友好的官方文档可供参考。可是可能因人而异,感受本身仍是比较喜欢React,也说不出什么好坏,多是本身最先接触的前端框架吧,不过很遗憾,在以前的工做中一直派不上用场,但即使如此,也阻挡不了本身对底层原理的好奇心。因此最近也是开始研究React的源码,并对源码的解读过程作一下记录,方便加深记忆。若是你的技术栈恰好是React,而且也对源码感兴趣,那么咱们能够一块儿互相探讨技术难点,让整个阅读源码的过程变得更加容易和有趣。源码中若是有理解错误的地方,还但愿可以指出。html

一、准备阶段

在facebook的github上,目前React的最新版本为v16.12.0,咱们知道在React的v16版本以后引入了新的Fiber架构,这种架构使得任务拥有了暂停恢复机制,将一个大的更新任务拆分为一个一个执行单元,充分利用浏览器在每一帧的空闲时间执行任务,无空闲时间则延迟执行,从而避免了任务的长时间运行致使阻塞主线程同步任务的执行。为了了解这种Fiber架构,这里选择了一个比较适中的v16.10.2的版本,没有选择最新的版本是由于在最新版本中移除了一些旧的兼容处理方案,虽然说这些方案只是为了兼容,可是其思想仍是比较先进的,值得咱们推敲学习,因此先将其保留下来,这里选择v16.10.2版本的另一个缘由是React在v16.10.0的版本中涉及到两个比较重要的优化点:前端


在上图中指出,在任务调度(Scheduler)阶段有两个性能的优化点,解释以下:java

  • 将任务队列的内部数据结构转换成最小二叉堆的形式以提高队列的性能(在最小堆中咱们可以以最快的速度找到最小的那个值,由于那个值必定在堆的顶部,有效减小整个数据结构的查找时间)。
  • 使用周期更短的postMessage循环的方式而不是使用requestAnimationFrame这种与帧边界对齐的方式(这种优化方案指得是在将任务进行延迟后恢复执行的阶段,先后两种方案都是宏任务,可是宏任务也有顺序之分,postMessage的优先级比requestAnimationFrame高,这也就意味着延迟任务可以更快速地恢复并执行)。

固然如今不太理解的话不要紧,后续会有单独的文章来介绍任务调度这一块内容,遇到上述两个优化点的时候会进行详细说明,在开始阅读源码以前,咱们可使用create-react-app来快速搭建一个React项目,后续的示例代码能够在此项目上进行编写:react

// 项目搭建完成后React默认为最新版v16.12.0
create-react-app react-learning

// 为了保证版本一致,手动将其修改成v16.10.2
npm install --save react@16.10.2 react-dom@16.10.2

// 运行项目
npm start

执行以上步骤后,不出意外的话,浏览器中会正常显示出项目的默认界面。得益于在Reactv16.8版本以后推出的React Hooks功能,让咱们在原来的无状态函数组件中也能进行状态管理,以及使用相应的生命周期钩子,甚至在新版的create-react-app脚手架中,根组件App已经由原来的类组件的写法升级为了推荐的函数定义组件的方式,可是原来的类组件的写法并无被废弃掉,事实上咱们项目中仍是会大量充斥着类组件的写法,所以为了了解这种类组件的实现原理,咱们暂且将App根组件的函数定义的写法回退到类组件的形式,并对其内容进行简单修改:git

// src -> App.js
import React, {Component} from 'react';

function List({data}) {
    return (
        <ul className="data-list">
            {
                data.map(item => {
                    return <li className="data-item" key={item}>{item}</li>
                })
            }
        </ul>
    );
}

export default class App extends Component {

    constructor(props) {
        super(props);
        this.state = {
            data: [1, 2, 3]
        };
    }

    render() {
        return (
            <div className="container">
                <h1 className="title">React learning</h1>
                <List data={this.state.data} />
            </div>
        );
    }
}

通过以上简单修改后,而后咱们经过调用github

// src -> index.js
ReactDOM.render(<App />, document.getElementById('root'));

来将组件挂载到DOM容器中,最终获得App组件的DOM结构以下所示:面试

<div class="container">
    <h1 class="title">React learning</h1>
    <ul class="data-list">
        <li class="data-item">1</li>
        <li class="data-item">2</li>
        <li class="data-item">3</li>
    </ul>
</div>

所以咱们分析React源码的入口也将会是从ReactDOM.render方法开始一步一步分析组件渲染的整个流程,可是在此以前,咱们有必要先了解几个重要的前置知识点,这几个知识点将会更好地帮助咱们理解源码的函数调用栈中的参数意义和其余的一些细节。npm

二、前置知识

首先咱们须要明确的是,在上述示例中,App组件的render方法返回的是一段HTML结构,在普通的函数中这种写法是不支持的,因此咱们通常须要相应的插件来在背后支撑,在React中为了支持这种jsx语法提供了一个Babel预置工具包@babel/preset-react,其中这个preset又包含了两个比较核心的插件:设计模式

  • @babel/plugin-syntax-jsx:这个插件的做用就是为了让Babel编译器可以正确解析出jsx语法。
  • @babel/plugin-transform-react-jsx:在解析完jsx语法后,由于其本质上是一段HTML结构,所以为了让JS引擎可以正确识别,咱们就须要经过该插件将jsx语法编译转换为另一种形式。在默认状况下,会使用React.createElement来进行转换,固然咱们也能够在.babelrc文件中来进行手动设置。
// .babelrc
{
    "plugins": [
        ["@babel/plugin-transform-react-jsx", {
            "pragma": "Preact.h", // default pragma is React.createElement
            "pragmaFrag": "Preact.Fragment", // default is React.Fragment
            "throwIfNamespace": false // defaults to true
        }]
    ]
}

这里为了方便起见,咱们能够直接使用Babel官方实验室来查看转换后的结果,对应上述示例,转换后的结果以下所示:

// 转换前
render() {
    return (
        <div className="container">
            <h1 className="title">React learning</h1>
            <List data={this.state.data} />
        </div>
    );
}

// 转换后
render() {
    return React.createElement("div", {
        className: "content"
    }, 
    React.createElement("header", null, "React learning"), 
    React.createElement(List, { data: this.state.data }));
}

能够看到jsx语法最终被转换成由React.createElement方法组成的嵌套调用链,可能你以前已经了解过这个API,或者接触过一些伪代码实现,这里咱们就基于源码,深刻源码内部来看看其背后为咱们作了哪些事情。

2.1 createElement & ReactElement

为了保证源码的一致性,也建议你将React版本和笔者保持一致,采用v16.10.2版本,能够经过facebook的github官方渠道进行获取,下载下来以后咱们经过以下路径来打开咱们须要查看的文件:

// react-16.10.2 -> packages -> react -> src -> React.js

React.js文件中,咱们直接跳转到第63行,能够看到React变量做为一个对象字面量,包含了不少咱们所熟知的方法,包括在v16.8版本以后推出的React Hooks方法:

const React = {
  Children: {
    map,
    forEach,
    count,
    toArray,
    only,
  },

  createRef,
  Component,
  PureComponent,

  createContext,
  forwardRef,
  lazy,
  memo,

  // 一些有用的React Hooks方法
  useCallback,
  useContext,
  useEffect,
  useImperativeHandle,
  useDebugValue,
  useLayoutEffect,
  useMemo,
  useReducer,
  useRef,
  useState,

  Fragment: REACT_FRAGMENT_TYPE,
  Profiler: REACT_PROFILER_TYPE,
  StrictMode: REACT_STRICT_MODE_TYPE,
  Suspense: REACT_SUSPENSE_TYPE,
  unstable_SuspenseList: REACT_SUSPENSE_LIST_TYPE,

  // 重点先关注这里,生产模式下使用后者
  createElement: __DEV__ ? createElementWithValidation : createElement,
  cloneElement: __DEV__ ? cloneElementWithValidation : cloneElement,
  createFactory: __DEV__ ? createFactoryWithValidation : createFactory,
  isValidElement: isValidElement,

  version: ReactVersion,

  unstable_withSuspenseConfig: withSuspenseConfig,

  __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: ReactSharedInternals,

这里咱们暂且先关注createElement方法,在生产模式下它来自于与React.js同级别的ReactElement.js文件,咱们打开该文件,并直接跳转到第312行,能够看到createElement方法的函数定义(去除了一些__DEV__环境才会执行的代码):

/**
 * 该方法接收包括但不限于三个参数,与上述示例中的jsx语法通过转换以后的实参进行对应
 * @param type 表示当前节点的类型,能够是原生的DOM标签字符串,也能够是函数定义组件或者其它类型
 * @param config 表示当前节点的属性配置信息
 * @param children 表示当前节点的子节点,能够不传,也能够传入原始的字符串文本,甚至能够传入多个子节点
 * @returns 返回的是一个ReactElement对象
 */
export function createElement(type, config, children) {
  let propName;

  // Reserved names are extracted
  // 用于存放config中的属性,可是过滤了一些内部受保护的属性名
  const props = {};

  // 将config中的key和ref属性使用变量进行单独保存
  let key = null;
  let ref = null;
  let self = null;
  let source = null;

  // config为null表示节点没有设置任何相关属性
  if (config != null) {

    // 有效性判断,判断 config.ref !== undefined
    if (hasValidRef(config)) {
      ref = config.ref;
    }

    // 有效性判断,判断 config.key !== undefined
    if (hasValidKey(config)) {
      key = '' + config.key;
    }

    self = config.__self === undefined ? null : config.__self;
    source = config.__source === undefined ? null : config.__source;

    // Remaining properties are added to a new props object
    // 用于将config中的全部属性在过滤掉内部受保护的属性名后,将剩余的属性所有拷贝到props对象中存储
    // const RESERVED_PROPS = {
    //   key: true,
    //   ref: true,
    //   __self: true,
    //   __source: true,
    // };
    for (propName in config) {
      if (
        hasOwnProperty.call(config, propName) &&
        !RESERVED_PROPS.hasOwnProperty(propName)
      ) {
        props[propName] = config[propName];
      }
    }
  }

  // Children can be more than one argument, and those are transferred onto
  // the newly allocated props object.
  // 因为子节点的数量不限,所以从第三个参数开始,判断剩余参数的长度
  // 具备多个子节点则props.children属性存储为一个数组
  const childrenLength = arguments.length - 2;
  if (childrenLength === 1) {
    
    // 单节点的状况下props.children属性直接存储对应的节点
    props.children = children;
  } else if (childrenLength > 1) {
    
    // 多节点的状况下则根据子节点数量建立一个数组
    const childArray = Array(childrenLength);
    for (let i = 0; i < childrenLength; i++) {
      childArray[i] = arguments[i + 2];
    }
    props.children = childArray;
  }

  // Resolve default props
  // 此处用于解析静态属性defaultProps
  // 针对于类组件或函数定义组件的状况,能够单独设置静态属性defaultProps
  // 若是有设置defaultProps,则遍历每一个属性并将其赋值到props对象中(前提是该属性在props对象中对应的值为undefined)
  if (type && type.defaultProps) {
    const defaultProps = type.defaultProps;
    for (propName in defaultProps) {
      if (props[propName] === undefined) {
        props[propName] = defaultProps[propName];
      }
    }
  }
  
  // 最终返回一个ReactElement对象
  return ReactElement(
    type,
    key,
    ref,
    self,
    source,
    ReactCurrentOwner.current,
    props,
  );
}

通过上述分析咱们能够得出,在类组件的render方法中最终返回的是由多个ReactElement对象组成的多层嵌套结构,全部的子节点信息均存放在父节点的props.children属性中。咱们将源码定位到ReactElement.js的第111行,能够看到ReactElement函数的完整实现:

/**
 * 为一个工厂函数,每次执行都会建立并返回一个ReactElement对象
 * @param type 表示节点所对应的类型,与React.createElement方法的第一个参数保持一致
 * @param key 表示节点所对应的惟一标识,通常在列表渲染中咱们须要为每一个节点设置key属性
 * @param ref 表示对节点的引用,能够经过React.createRef()或者useRef()来建立引用
 * @param self 该属性只有在开发环境才存在
 * @param source 该属性只有在开发环境才存在
 * @param owner 一个内部属性,指向ReactCurrentOwner.current,表示一个Fiber节点
 * @param props 表示该节点的属性信息,在React.createElement中经过config,children参数和defaultProps静态属性获得
 * @returns 返回一个ReactElement对象
 */
const ReactElement = function(type, key, ref, self, source, owner, props) {
  const element = {
    // This tag allows us to uniquely identify this as a React Element
    // 这里仅仅加了一个$$typeof属性,用于标识这是一个React Element
    $$typeof: REACT_ELEMENT_TYPE,

    // Built-in properties that belong on the element
    type: type,
    key: key,
    ref: ref,
    props: props,

    // Record the component responsible for creating this element.
    _owner: owner,
  };
  
  ...
  
  return element;
};

一个ReactElement对象的结构相对而言仍是比较简单,主要是增长了一个$$typeof属性用于标识该对象是一个React Element类型。REACT_ELEMENT_TYPE在支持Symbol类型的环境中为symbol类型,不然为number类型的数值。与REACT_ELEMENT_TYPE对应的还有不少其余的类型,均存放在shared/ReactSymbols目录中,这里咱们能够暂且只关心这一种,后面遇到其余类型再来细看。

2.2 Component & PureComponent

了解完ReactElement对象的结构以后,咱们再回到以前的示例,经过继承React.Component咱们将App组件修改成了一个类组件,咱们不妨先来研究下React.Component的底层实现。React.Component的源码存放在packages/react/src/ReactBaseClasses.js文件中,咱们将源码定位到第21行,能够看到Component构造函数的完整实现:

/**
 * 构造函数,用于建立一个类组件的实例
 * @param props 表示所拥有的属性信息
 * @param context 表示所处的上下文信息
 * @param updater 表示一个updater对象,这个对象很是重要,用于处理后续的更新调度任务
 */
function Component(props, context, updater) {
  this.props = props;
  this.context = context;
  // If a component has string refs, we will assign a different object later.
  // 该属性用于存储类组件实例的引用信息
  // 在React中咱们能够有多种方式来建立引用
  // 经过字符串的方式,如:<input type="text" ref="inputRef" />
  // 经过回调函数的方式,如:<input type="text" ref={(input) => this.inputRef = input;} />
  // 经过React.createRef()的方式,如:this.inputRef = React.createRef(null); <input type="text" ref={this.inputRef} />
  // 经过useRef()的方式,如:this.inputRef = useRef(null); <input type="text" ref={this.inputRef} />
  this.refs = emptyObject;
  // We initialize the default updater but the real one gets injected by the
  // renderer.
  // 当state发生变化的时候,须要updater对象去处理后续的更新调度任务
  // 这部分涉及到任务调度的内容,在后续分析到任务调度阶段的时候再来细看
  this.updater = updater || ReactNoopUpdateQueue;
}

// 在原型上新增了一个isReactComponent属性用于标识该实例是一个类组件的实例
// 这个地方曾经有面试官考过,问如何区分函数定义组件和类组件
// 函数定义组件是没有这个属性的,因此能够经过判断原型上是否拥有这个属性来进行区分
Component.prototype.isReactComponent = {};

/**
 * 用于更新状态
 * @param partialState 表示下次须要更新的状态
 * @param callback 在组件更新以后须要执行的回调
 */
Component.prototype.setState = function(partialState, callback) {
  ...
  this.updater.enqueueSetState(this, partialState, callback, 'setState');
};

/**
 * 用于强制从新渲染
 * @param callback 在组件从新渲染以后须要执行的回调
 */
Component.prototype.forceUpdate = function(callback) {
  this.updater.enqueueForceUpdate(this, callback, 'forceUpdate');
};

上述内容中涉及到任务调度的会在后续讲解到调度阶段的时候再来细讲,如今咱们知道能够经过原型上的isReactComponent属性来区分函数定义组件和类组件。事实上,在源码中就是经过这个属性来区分Class ComponentFunction Component的,能够找到如下方法:

// 返回true则表示类组件,不然表示函数定义组件
function shouldConstruct(Component) {
  return !!(Component.prototype && Component.prototype.isReactComponent);
}

Component构造函数对应的,还有一个PureComponent构造函数,这个咱们应该仍是比较熟悉的,经过浅比较判断组件先后传递的属性是否发生修改来决定是否须要从新渲染组件,在必定程度上避免组件重渲染致使的性能问题。一样的,在ReactBaseClasses.js文件中,咱们来看看PureComponent的底层实现:

// 经过借用构造函数,实现典型的寄生组合式继承,避免原型污染
function ComponentDummy() {}
ComponentDummy.prototype = Component.prototype;

function PureComponent(props, context, updater) {
  this.props = props;
  this.context = context;
  // If a component has string refs, we will assign a different object later.
  this.refs = emptyObject;
  this.updater = updater || ReactNoopUpdateQueue;
}

// 将PureComponent的原型指向借用构造函数的实例
const pureComponentPrototype = (PureComponent.prototype = new ComponentDummy());

// 从新设置构造函数的指向
pureComponentPrototype.constructor = PureComponent;

// Avoid an extra prototype jump for these methods.
// 将Component.prototype和PureComponent.prototype进行合并,减小原型链查找所浪费的时间(原型链越长所耗费的时间越久)
Object.assign(pureComponentPrototype, Component.prototype);

// 这里是与Component的区别之处,PureComponent的原型上拥有一个isPureReactComponent属性
pureComponentPrototype.isPureReactComponent = true;

经过以上分析,咱们就能够初步得出ComponentPureComponent之间的差别,能够经过判断原型上是否拥有isPureReactComponent属性来进行区分,固然更细粒度的区分,还须要在阅读后续的源码内容以后才能见分晓。

三、面试考点

看完以上内容,按道理来讲如下几个可能的面试考点应该就不成问题了,或者说至少也不会遇到一个字也回答不了的尴尬局面,试试看吧:

  • 在React中为什么可以支持jsx语法
  • 类组件的render方法执行后最终返回的结果是什么
  • 手写代码实现一个createElement方法
  • 如何判断一个对象是否是React Element
  • 如何区分类组件和函数定义组件
  • ComponentPureComponent之间的关系
  • 如何区分ComponentPureComponent

四、总结

本文做为React16源码解读的开篇,先讲解了几个比较基础的前置知识点,这些知识点有助于咱们在后续分析组件的任务调度和渲染过程时可以更好地去理解源码。阅读源码的过程是痛苦的,一个缘由是源码量巨大,文件依赖关系复杂容易让人产生恐惧退缩心理,另外一个是阅读源码是个漫长的过程,期间可能会占用你学习其余新技术的时间,让你没法彻底静下心来。可是其实咱们要明白的是,学习源码不仅是为了应付面试,源码中其实有不少咱们能够借鉴的设计模式或者使用技巧,若是咱们能够学习并应用到咱们正在作的项目中,也不失为一件有意义的事情。后续文章就从ReactDOM.render方法开始,一步一步分析组件渲染的整个流程,咱们也不须要去搞懂每一行代码,毕竟每一个人的思路不太同样,可是关键步骤咱们仍是须要去多花时间理解的。

五、交流

若是你以为这篇文章的内容对你有帮助,可否帮个忙关注一下笔者的公众号[前端之境],每周都会努力原创一些前端技术干货,关注公众号后能够邀你加入前端技术交流群,咱们能够一块儿互相交流,共同进步。

文章已同步更新至Github博客,若觉文章尚可,欢迎前往star!

你的一个点赞,值得让我付出更多的努力!

逆境中成长,只有不断地学习,才能成为更好的本身,与君共勉!

相关文章
相关标签/搜索