如何理解 React 中的关键语法 JSX ?

253 阅读8分钟

1. 前言

在 React 的开发中,我们会经常使用一种特殊的语法来描述 React 的组件,那就是 JSX。JSX 是学习 React 框架必须要了解的核心概念之一。如果从来没有接触过这种语法,当第一次看到 React 代码时,一定会发现在 React代码中 JavaScript 和 HTML 是耦合在一起的。这和我们一开始学习“前端母语” HTML、JavaScript、CSS 时所接纳的内容结构,层叠样式,行为动作要分离,三者之间分工明确,不要耦合在一起概念完全相反。其实,当 React 首次带着 JSX发布之时,将视图定义与控制逻辑紧密耦合的想法就在社区引起了很多争议,新事物总是褒贬不一的,因为不同的人有不同的体验,当基于不同的角度来思考时,有些人觉得 JSX 美若天仙,但也有人觉得 JSX 就是丑小鸭。不过从 React 诞生到现在,历时 9 年,React 框架不断完善,JSX 也被越来越多的开发者所认可,变得越来越流行(之前在很多人眼中是丑小鸭,现在在大家眼中也变成白天鹅🦢 啦)。说了老半天了,那么接下来我们就来具体了解一下 JSX 吧~

2. JSX 是什么

我们先来看一下 JSX 的官方定义

📚 JSX 是一个 JavaScript 的语法扩展。我们建议在 React 中配合使用 JSX,JSX 可以很好地描述 UI 应该呈现出它应有交互的本质形式。JSX 可能会使人联想到模板语言,但它具有 JavaScript 的全部功能。

从这段官方定义中可抽离两个关键点:JavaScript 语法扩展具备 JavaScript 的全部功能。JavaScript 语法扩展是JSX 的核心本质,JSX 基于 JavaScript 语言,所以它具备 JavaScript 的全部能力(JS 能做的 JSX 都可以做,比如表达式计算),但又新增了某些能力(JS 不能做的 JSX 也可以做,比如自定义组件)。

如何理解 JSX 可以很好地描述 UI 应该呈现出它应有交互的本质形式这一点呢?通俗易懂的来说,就是 JSX 让我们可以用JS 语法灵活的描述视图状态,我们可以很容易的通过 JSX 结构推导出实际视图效果。JSX 最大的一个特点就是在 HTML和 JavaScript 之间创造了一种非常特殊且极其高效的结合,能够让开发者在 JavaScript 代码中直接写 HTML 的标记。原本 HTML 只能描述视图静态的内容结构,但通过 JSX 语法将 JavaScript 和 HTML 融合在一起,HTML 就像是被赋予了生命力一样能够描述视图的动态交互了。

3. 为什么 React 选择使用 JSX

对于这个问题,React 官方给出的解释是这样的:

📚 React 认为渲染逻辑本质上与其他 UI 逻辑内在耦合,比如,在 UI 中需要绑定处理事件、在某些时刻状态发生变化时需要通知到 UI,以及需要在 UI 中展示准备好的数据。React 并没有采用将标记与逻辑分离到不同文件这种人为的分离方式,而是通过将二者共同存放在称之为“组件”的松散耦合单元之中,来实现关注点分离。我们将在后面章节中深入学习组件。如果你还没有适应在 JS 中使用标记语言,这个会议讨论应该可以说服你。

React 不强制要求使用 JSX,但是大多数人发现,在 JavaScript 代码中将 JSX 和 UI 放在一起时,会在视觉上有辅助作用。它还可以使 React 显示更多有用的错误和警告消息。

React 的设计初衷是关注点分离,其关注基本单位是组件,在组件内部高内聚,组件之间低耦合。

对于原先那种仅是把 HTML、JavaScript 这两种语言技术放在不同文件位置里进行分开管理的情况,当我们试图更改一个HTML 元素的 class 类名或 id 标识时,我们必须验证没有任何 DOM 绑定被破坏,为了做这种验证我们需要在 HTML 和 JavaScript 文件中去到处寻找逻辑关联,这充分表明这两者之间是存在密切关系的。也就是说,将 HTML 和 JS 保存在各自分离文件中仍然需要作出串联改动,这不是真正的去耦合,不同的技术语言独立放置的原则只是让开发者可以一次只关注一种技术概念而已,这其实更像是技术分离而不是以视觉为单位进行真正的业务逻辑分离。

React 官方解释还提到一点,大多数人都发现,React 在 JavaScript 代码中将 JSX 和 UI 放在一起时,会在视觉上有辅助作用。想象一下,在复杂的 UI 界面开发过程中或者 code review 时你需要不断地跑去多个分散的文件中来理解以及更改代码逻辑,是不是会感觉很麻烦。而 JSX 可以避免这种麻烦,并且 JSX 不会引入太多新的概念,它是一种 JavaScript 语法的扩展,具有 JavaScript 的全部功能,就连条件表达式和循环都仍然是 JavaScript 的方式,编码更纯净更具有可读性。

4. JSX 语法是如何生效的

本小节介绍的是 React 16.x 版本及之前版本的 JSX 转换,17.x 版本及之后的 JSX 转换在第 6 小节会提及~

JSX 的定位是 JavaScript 的「语法扩展」,而不是“某个版本”,这就决定了浏览器并不会像天然支持 JavaScript 一样支持 JSX。这就引出了一个问题 :由于 JSX 并不是有效的 JavaScript 代码,浏览器不理解它就没办法对其进行解析,那么在 React 框架中,JSX 是如何生效的呢?

其实 React 官网已经给出了答案,JSX 会被编译为一个 React.createElement() 的函数调用,React.createElement() 将返回一个叫作 React Element 的 JS 对象。也就是说,在编译之后,JSX 会被转为普通 JavaScript 函数调用,并且对其取值后得到 JavaScript 对象。我们先不急于关注 React.createElement 这个 API 到底做了什么,咱们先来说说“编译”是怎么回事:“编译”这个动作,是由 Babel 来完成的。什么是 Babel 呢?Babel 官网介绍如下:

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

比如说,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 代码的能力。那么 Babel 具体会将 JSX 处理成什么样子呢?我们以 React 官网给出的示例说明:

<MyButton color="blue" shadowSize={2}>
  Click Me
</MyButton>

借助 Babel “翻译”的魔力,上述 JSX 代码会被编译为:

React.createElement(
  MyButton,
  {color: 'blue', shadowSize: 2},
  'Click Me'
)

当然,你也可以通过 Babel 提供的 REPL 在线编译器 输入其他任何 JSX 代码进行实时查看转换结果。

你会发现,JSX 代码真的如同 React 官网所说的那样都被转换成了 React.createElement 函数调用,转换前和转换后的代码是等效的,也就是说其实写 JSX 就是在写 React.createElement 函数,换句话说,JSX 就是 React.createElement 函数的语法糖。

相较于直接使用 React.createElement 函数来创建 React 元素来说,React.createElement 代码则给人一种非常混乱的杂糅感,这样的代码不仅读起来不友好,写起来也费劲,而 JSX 语法糖可以使我们的代码更简洁,代码结构层次更清晰 ,并且 JSX 语法糖允许开发人员像写 HTML 一样来写我们的 JS 代码,可以很好的提高研发效率。

5. React.createElement 源码解析

我们前面提到,React.createElement() 将返回一个叫作 React Element 的 JS 对象,你一定很好奇 createElement 函数是如何生成一个 JS 对象的,以及该 JS 对象是怎样的结构,我们首先从 React.createElement 函数声明入手来看看它的接收参数是什么:

React.createElement(component, props, ...children)

结合 createElement 函数声明和 Babel 转换后的 createElement 函数调用来看,不难得出结论,createElement 函数接收 3 个或以上参数,这 3 个参数包含了我们在创建一个 React 元素时写的全部信息:

1️⃣ type:要创建的 React 元素类型,可以是原生的 div 、span 这样的 HTML 标签,也可以是 React 组件类型,还可以是React fragment 空元素;2️⃣ config:我们写在标签上的属性集合,js 对象格式,组件所有的属性(不包含默认的一些属性)都会以键值对的形式存储在 config 对象中,若标签上未添加任何属性则为 null;3️⃣ children:泛指第二个参数后的所有参数,它记录的是当前创建的 React 元素的子节点或者叫子元素,表示的是元素之间嵌套的内容;

接下来我们就通过源码来看看 React.createElement 这个函数到底做了什么:

⚠️:我们贴出的源码都会忽略针对 __DEV__ 环境下的处理,因为它们对于大家理解主要逻辑意义不大~

// packages/react/src/ReactElement.js

export function createElement(type, config, children) {
  // propName 变量用于储存后面需要用到的元素属性
  let propName;

  // Reserved names are extracted
  const props = {};

  let key = null;
  let ref = null;
  let self = null;
  let source = null;

  // config 对象中存储的是元素的属性,config 不为 null 时,说明元素上有属性
  if (config != null) {
    // 其中,key 和 ref 为 react 提供的特殊属性,不加入到 props 中,而是用 key 和 ref 单独记录
    if (hasValidRef(config)) {
      // 有合法的 ref 时,则将其赋值给 config.ref 
      ref = config.ref;
    }
    if (hasValidKey(config)) {
      // 有合法的 key 时,则将 key 值字符串化后赋值给 config.key 
      key = '' + config.key;
    }

    // self 和 source 是开发环境下对代码在编译器中位置等信息进行记录,用于开发环境下调试
    self = config.__self === undefined ? null : config.__self;
    source = config.__source === undefined ? null : config.__source;
    
    // Remaining properties are added to a new props object
    // 将 config 中除 key、ref、__self、__source 之外的属性添加到 props 中
    for (propName in config) {
      if (
        // 筛选出可以提进 props 对象里的属性
        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 属性上
  // childrenLength 指的是当前元素的子元素的个数,减去的 2 是 type 和 config 两个参数占用的长度
  const childrenLength = arguments.length - 2;
  if (childrenLength === 1) {
    // 共 3 个参数时表示只有一个子节点,直接将子节点赋值给 props 的 children 属性    
    props.children = children;
  } else if (childrenLength > 1) {
    // 3 个以上参数时表示有多个子节点,将子节点 push 到一个数组中然后将数组赋值给 props 的 children
    
    // 声明一个子元素数组
    const childArray = Array(childrenLength);
    // 把子元素推进数组里
    for (let i = 0; i < childrenLength; i++) {
      childArray[i] = arguments[i + 2];
    }
    // 最后把这个数组赋值给 props.children
    props.children = childArray;
  }

  // Resolve default props
  // 处理 defaultProps,此处针对 class 组件类型
  // 如果有 defaultProps,对其遍历并且将用户在标签上未对其手动设置属性添加进 props 中
  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,
  );
}

由代码可知,React.createElement 做的事情主要有:

1️⃣ 解析 config 参数中是否有合法的 key、ref、__source 和 __self 属性,若存在分别赋值给 key、ref、source 和self;将剩余的属性解析挂载到 props 上;2️⃣ 除 type 和 config 外后面的参数,挂载到 props.children 上;3️⃣ 针对类组件,如果 type.defaultProps 存在,遍历 type.defaultProps 的属性,如果 props 不存在该属性,则添加到 props 上;4️⃣ 调用 ReactElement 函数,并将 type、key、ref、self、props 等信息传入其中;

其实从源码角度来看,createElement 中并没有十分复杂的涉及算法或真实 DOM 的逻辑,它的每一个步骤几乎都是在格式化数据,然后交付给 ReactElement 函数,最后返回 🔙 ReactElement 函数的调用结果,也就是说,React Element 这一 JS 对象的创建实质上是在 ReactElement 函数中进行的:

// packages/react/src/ReactElement.js

const ReactElement = function(type, key, ref, self, source, owner, props) {
  const element = {
    // This tag allows us to uniquely identify this as a React Element
    // 用于表示是否为 ReactElement
    $$typeof: REACT_ELEMENT_TYPE,

    // Built-in properties that belong on the element
    // 用于创建真实 dom 的相关信息
    type: type,
    key: key,
    ref: ref,
    props: props,

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

  return element;
};

ReactElement 函数的代码比较简短,除去 DEV 处理的逻辑代码基本就没有别的内容,从实际代码来看,ReactElement 其实只做了一件事情,那就是按照一定的规范将 React.createElement 函数传给他的数据组装成 element 对象,并把这个组装好的对象返回给 React.createElement,最终 React.createElement 又把它交回到开发者手中。

最终得到的这个对象实例在 React 中被称为 ReactElement 对象,其本质上是以 JavaScript 对象形式存在的对 DOM 的描述,它在 React 工作过程中起到了非常重要的作用,它是 React 中 “Virtual DOM” 实现的一部分。

总结:React.createElement 其实是一个工厂函数,用于创建 ReactElement 对象。

6. 关于 JSX 你还需知道……

1️⃣ 前面关于 JSX 转换的一些知识其实我们是基于 React 16.x 及之前的版本来介绍的,React 16.x 及之前版本下的 JSX 转换被称为旧的 JSX 转换:当我们使用 JSX 时,编译器会将其转换为浏览器可以理解的 React.createElement 函数调用,因此需要显式将 React 引入,才能正常调用 createElement。

而 React 17.x 及之后的版本向我们提供了一个全新版本的 JSX 转换,官方与 babel 进行了合作,直接通过将 react/jsx-runtime 对 jsx 语法进行了新的转换而不依赖 React.createElement,转换的结果便是可直接供 ReactDOM.render 使用的ReactElement 对象。因此如果在 React 17 版本后只是用 jsx 语法不使用其他的 react 提供的 api,可以不引入 React,应用程序依然能够正常运行。

虽然现在 react 17 之后我们可以不再依赖 React.createElement 这个 api 了,但是实际场景中以及很多开源包中可能会有很多通过 React.createElement 手动创建元素的场景,所以我们在前面主要介绍的是旧的 JSX 转换。

PS ⚠️:更多有关于 React jsx 新转换的内容可以去看官网了解:介绍全新的 JSX 转换

2️⃣ 使用 JSX 并不是倒退,它只是一个语法糖而已,虽然在 React 中,不强制要求使用 JSX,但是官方却推荐使用。

PS ⚠️:更多有关内容可以去看官网了解:不使用 JSX 的 React

参考资料

Pete Hunt -- 2013 JSConf 《React:重新思考最佳实践》演讲