react[学习笔记2]--JSX

973 阅读11分钟

【模块一】系统深入学习基础知识

JSX 代码是如何“摇身一变”成为 DOM 的?

时下虽然接入 JSX 语法的框架越来越多,但与之缘分最深的毫无疑问仍然是 React。2013 年,当 React 带着 JSX 横空出世时,社区曾对 JSX 有过不少的争议,但如今,越来越多的人面对 JSX 都要说上一句“真香”!那么JSX 代码究竟是如何‘摇身一变’成为 DOM 的?在这一章节中或许能够找到答案。

1、关于 JSX 的 3 个“大问题”

在日常的 React 开发工作中,我们已经习惯了使用JSX来描述 React 的组件内容。关于 JSX 语法本身,相信每位 React 开发者都不陌生。接下来就用一个简单的 React 组件,来迅速地唤醒自己脑海中与 JSX 相关的记忆。下面组件中的 render 方法返回值,就是用 JSX 代码来填充的:

import React from "react";
import ReactDOM from "react-dom";
class App extends React.Component {
  render() {
    return (
      <div className="App">
        <h1 className="title">I am the title</h1>
        <p className="content">I am the content</p>
      </div>
    );
  }
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

为了探寻 JSX 背后的故事。针对这“背后的故事”,我们先来探讨3 个最具代表性和区分度的问题:

1)、JSX 的本质是什么,它和 JS 之间到底是什么关系?

2)、为什么要用 JSX?不用会有什么后果?

3)、JSX 背后的功能模块是什么,这个功能模块都做了哪些事情?

对于以上三个问题,如果无法形成清晰且系统的思路,那么很可能是我们把 JSX 想得过于简单了。大多数人只是简单地把它理解为模板语法的一种,但事实上,JSX 作为 React 框架的一大特色,它与 React 本身的运作机制之间存在着千丝万缕的联系。而上述 3 个问题的答案,就恰恰隐藏在这层“联系”中,在很多面试场景下,候选人对这层“联系”吃得透不透,是评价其在 React 方面是否“资深”的一个重要依据。

2、三大问题的解答

1)、JSX 的本质:JavaScript 的语法扩展

JSX 到底是什么,我们先来看看 React 官网给出的一段定义:“JSX 是 JavaScript 的一种语法扩展,它和模板语言很接近,但是它充分具备 JavaScript 的能力。” 在上述定义中,“语法扩展”这一点在理解上几乎不会产生歧义,不过“它充分具备 JavaScript 的能力”这句,却总让人摸不着头脑,JSX 和 JS 怎么看也不像是一路人啊?这就引出了“JSX 语法是如何在 JavaScript 中生效的”这个问题。

(1)为解答JSX 语法是如何在 JavaScript 中生效的这一问题,我们先来认识 Babel。

Facebook 公司给 JSX 的定位是 JavaScript 的“扩展”,而非 JavaScript 的“某个版本”,这就直接决定了浏览器并不会像天然支持 JavaScript 一样地支持 JSX。那么,JSX 的语法是如何在 JavaScript 中生效的呢?React 官网其实早已给过我们线索——JSX 会被编译React.createElement(), React.createElement() 将返回一个叫作“React Element”的 JS 对象

这里提到,JSX 在被编译后,会变成一个针对 React.createElement 的调用,此时我们大可不必急于关注 React.createElement 这个 API 到底做了什么(下文会单独讲解)。我们先来说说这个“编译”是怎么回事:“编译”这个动作,是由 Babel来完成的。

(2)那么什么是 Babel 呢?

Babel 是一个工具链,主要用于将 ECMAScript 2015+ 版本的代码转换为向后兼容的 JavaScript 语法,以便能够运行在当前和旧版本的浏览器或其他环境中。 —— Babel 官网

比如说,ES2015+ 版本推出了一种名为“模板字符串”的新语法,这种语法在一些低版本的浏览器里并不兼容。下面是一段模板字符串的示例代码:

var name = "Guy Fieri";
var place = "Flavortown";
`Hello ${name}, ready for ${place}?`;

此时Babel 就可以帮我们把这段代码转换为大部分低版本浏览器也能够识别的 ES5 代码, 如下所示:

var name = "Guy Fieri";
var place = "Flavortown";
"Hello ".concat(name, ", ready for ").concat(place, "?");

类似的,Babel 也具备将 JSX 语法转换为 JavaScript 代码的能力。

(3)那么 Babel 具体会将 JSX 处理成什么样子呢?

不妨直接打开 Babel 的 playground 来看一看。此处仍然键入文章开头示例代码中的JSX 部分:

可以看到,所有的 JSX 标签都被转化成了 React.createElement 调用,这也就意味着,我们写的 JSX 其实写的就是 React.createElement,虽然它看起来有点像 HTML,但也只是“看起来像”而已。JSX 的本质是React.createElement这个 JavaScript 调用的语法糖,这也就完美地呼应上了 React 官方给出的“JSX 充分具备 JavaScript 的能力”这句话。

2)React 选用 JSX 语法的动机

换个角度想想,既然 JSX 等价于一次 React.createElement 调用,那么 React 官方为什么不直接引导我们用 React.createElement 来创建元素呢?

原因非常简单,我们来看一个相对复杂一些的组件的 JSX 代码和 React.createElement 调用之间的对比。它们各自的形态如下图所示,图中左侧是 JSX 代码,右侧是 React.createElement 调用:

你会发现,在实际功能效果一致的前提下,JSX 代码层次分明、嵌套关系清晰;而 React.createElement 代码则给人一种非常混乱的“杂糅感”,这样的代码不仅读起来不友好,写起来也费劲。

JSX 语法糖允许前端开发者使用我们最为熟悉的类 HTML 标签语法来创建虚拟 DOM,在降低学习成本的同时,也提升了研发效率与研发体验

到这里,我们也能够充分理解“JSX 是 JavaScript 的一种语法扩展,它和模板语言很接近,但是它充分具备 JavaScript 的能力。 ”这一定义背后的深意。那么文中反复提及的 React.createElement 又是何方神圣呢? 下面就深入相关源码来一窥究竟。

3)、JSX 背后的功能模块

(1)JSX 是如何映射为 DOM 的:起底 createElement 源码

(1)在分析开始之前,先尝试阅读追加进源码中的逐行代码解析,大致理解 createElement 中每一行代码的作用:
/**
 101. React的创建元素方法
 */
export function createElement(type, config, children) {
  // propName 变量用于储存后面需要用到的元素属性
  let propName; 
  // props 变量用于储存元素属性的键值对集合
  const props = {}; 
  // key、ref、self、source 均为 React 元素的属性,此处不必深究
  let key = null;
  let ref = null; 
  let self = null; 
  let source = null; 
  // config 对象中存储的是元素的属性
  if (config != null) { 
    // 进来之后做的第一件事,是依次对 ref、key、self 和 source 属性赋值
    if (hasValidRef(config)) {
      ref = config.ref;
    }
    // 此处将 key 值字符串化
    if (hasValidKey(config)) {
      key = '' + config.key; 
    }
    self = config.__self === undefined ? null : config.__self;
    source = config.__source === undefined ? null : config.__source;
    // 接着就是要把 config 里面的属性都一个一个挪到 props 这个之前声明好的对象里面
    for (propName in config) {
      if (
        // 筛选出可以提进 props 对象里的属性
        hasOwnProperty.call(config, propName) &&  /
        !RESERVED_PROPS.hasOwnProperty(propName) 
      ) {
        props[propName] = config[propName]; 
      }
    }
  }
  // childrenLength 指的是当前元素的子元素的个数,减去的 2 是 type 和 config 两个参数占用的长度
  const childrenLength = arguments.length - 2; 
  // 如果抛去type和config,就只剩下一个参数,一般意味着文本节点出现了
  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
    props.children = childArray; 
  } 
  // 处理 defaultProps
  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,
  );
}

通过对源码细节的初步展示,接下来逐步提取源码中的关键知识点和核心思想。

(2)入参解读:创造一个元素需要知道哪些信息

先来看看方法的入参:

export function createElement(type, config, children)

可以看到:createElement 有 3 个入参,这 3 个入参囊括了 React 创建一个元素所需要知道的全部信息。 type:用于标识节点的类型。它可以是类似“h1”“div”这样的标准 HTML 标签字符串,也可以是 React 组件类型或 React fragment 类型。 config:以对象形式传入,组件所有的属性都会以键值对的形式存储在 config 对象中。 children:以对象形式传入,它记录的是组件标签之间嵌套的内容,也就是所谓的“子节点”“子元素”。

如果文字描述使你觉得抽象,下面这个调用示例可以帮你增进对概念的理解:

React.createElement("ul", {
  // 传入属性键值对
  className: "list"
   // 从第三个入参开始往后,传入的参数都是 children
}, React.createElement("li", {
  key: "1"
}, "1"), React.createElement("li", {
  key: "2"
}, "2"));

这个调用对应的 DOM 结构如下:

<ul className="list">
  <li key="1">1</li>
  <li key="2">2</li>
</ul>

在对入参的形式和内容有了大致的把握之后,继续来看 createElement 的函数逻辑。

(3)createElement 函数体拆解

前面我们已经阅读过 createElement 源码细化到每一行的解读,而接下来探讨的则是 createElement在逻辑层面的任务流转。针对这个过程,可总结为以下这张流程图:

对于这个流程图,或许会打破不少人对 createElement 的幻想。在实际的面试场景下,许多候选人由于缺乏对源码的了解,谈及 createElement 时总会倾向于去夸大它的“工作量”。但其实可以发现了,createElement 中并没有十分复杂的涉及算法或真实 DOM 的逻辑,它的每一个步骤几乎都是在格式化数据

说得更直白点,createElement 就像是开发者和 ReactElement 调用之间的一个“转换器”、一个数据处理层。它可以从开发者处接受相对简单的参数,然后将这些参数按照 ReactElement 的预期做一层格式化,最终通过调用 ReactElement 来实现元素的创建。整个过程如下图所示:

现在看来,createElement 原来只是个“参数中介”。此时我们的注意力自然而然地就聚焦在了 ReactElement 上,接下来就乘胜追击,深挖 ReactElement 的源码!

(4)出参解读:初识虚拟 DOM

上面已经分析过,createElement 执行到最后会 return 一个针对 ReactElement 的调用。依旧事先看一看关于 ReactElement的源码 + 注释形式的解析:

const ReactElement = function(type, key, ref, self, source, owner, props) {
  const element = {
    // REACT_ELEMENT_TYPE是一个常量,用来标识该对象是一个ReactElement
    $$typeof: REACT_ELEMENT_TYPE,
    // 内置属性赋值
    type: type,
    key: key,
    ref: ref,
    props: props,
    // 记录创造该元素的组件
    _owner: owner,
  };
  // 
  if (__DEV__) {
    // 这里是一些针对 __DEV__ 环境下的处理,对于大家理解主要逻辑意义不大,此处我直接省略掉,以免混淆视听
  }
  return element;
};

ReactElement 的代码出乎意料的简短,从逻辑上可以看出,ReactElement 其实只做了一件事情,那就是“创建”,说得更精确一点,是“组装”:ReactElement 把传入的参数按照一定的规范,“组装”进了 element 对象里,并把它返回给了 React.createElement,最终 React.createElement 又把它交回到了开发者手中。整个过程如下图所示:

如果你想要验证这一点,可以尝试输出我们示例中 App 组件的 JSX 部分:

const AppJSX = (<div className="App">
  <h1 className="title">I am the title</h1>
  <p className="content">I am the content</p>
</div>)
console.log(AppJSX)

你会发现它确实是一个标准的 ReactElement 对象实例,如下图(生产环境下的输出结果)所示:

这个 ReactElement 对象实例,本质上是以 JavaScript 对象形式存在的对 DOM 的描述,也就是老生常谈的“虚拟 DOM”(准确地说,是虚拟 DOM 中的一个节点。关于虚拟 DOM, 后续专栏的“模块二:核心原理”中将会花大量的篇幅来研究它,此处只需要能够结合源码,形成初步认知即可)。

既然是“虚拟 DOM”,那就意味着和渲染到页面上的真实 DOM 之间还有一些距离,这个“距离”,就是由大家喜闻乐见的ReactDOM.render方法来填补的。

在每一个 React 项目的入口文件中,都少不了对 React.render 函数的调用。下面我们来看一看ReactDOM.render 方法的入参规则:

ReactDOM.render(
    // 需要渲染的元素(ReactElement)
    element, 
    // 元素挂载的目标容器(一个真实DOM)
    container,
    // 回调函数,可选参数,可以用来处理渲染结束后的逻辑
    [callback]
)

ReactDOM.render 方法可以接收 3 个参数,其中第二个参数就是一个真实的 DOM 节点,这个真实的 DOM 节点充当“容器”的角色,React 元素最终会被渲染到这个“容器”里面去。比如,示例中的 App 组件,它对应的 render 调用是这样的:

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

注意,这个真实 DOM 一定是确实存在的。比如,在 App 组件对应的 index.html 中,已经提前预置 了 id 为 root 的根节点:

<body>
    <div id="root"></div>
</body>

所以这就意味着id为root的dom节点会成为app组件的容器,app组件最终会被挂载为root的子元素,具体如下图所示

以上,对于文初所提及的三大问题,通过贯穿全文的知识探究,相信对于三个问题所存留的疑惑,我们都能够从中找到答案。

3、最后的总结:

当然,以上所涉及的知识都只是我们深入钻研react知识系统及其运作机制的一个引子。下一步则是探究一个react组件是如何走完自己的“一生”以及它是如何及时的实现“自我更新”的。

4、上/下一篇

深入浅出地学习React[学习笔记1]

深入浅出地学习React[学习笔记3]

学习出处(文章转载自):kaiwu.lagou.com/course/cour…