在本三部曲系列的第一部中,我们介绍了TypeScript编译的两种方案(tsc编译、babel编译)以及二者的重要差异,同时分析了IDE是如何对TypeScript代码进行类型检查的。该部分基本涵盖了TypeScript代码编译的细节,但主要是关于TS代码本身的编译与类型检查。而本文,我们将着重讨论含有JSX的TypeScript代码(又称TSX)如何进行类型检查与代码编译的。

前言:JSX编译

在介绍如何对JSX代码进行类型检查前,让我们花一点时间认识一下JSX,以及如何对其进行编译。

注意:这块内容很多,如果读者已经熟悉这块的内容,可以直接从JSX(TSX)的类型检查开始阅读。

实际上,JSX并不是合法有效的JS代码或HTML代码。目前为止也没有任何一家浏览器的引擎实现了对JSX的读取和解析。此外,JSX本身没有完全统一的规范,除了一些基本的规则以外,各种利用了JSX的JS库可以根据自身需求来设计JSX额外的特性。譬如,React中的元素会有className属性,而SolidJS中的元素会有classList属性。在FaceBook官方博文中也明确提到了:

JSX是一种类似XML的语法扩展。它不打算由引擎或浏览器实现。它也不会作为某种提案被合并到ECMAScript规范中。它旨在被各种预处理器(转译器)用于将这些标记转换为标准的ECMAScript。—— JSX (facebook.github.io)

当然,只要提到JSX我们就不得不提React,尽管React与JSX是相互独立的东西,但是React将JSX发扬光大,让更多的开发者接触到了JSX。所以我们先从React入手,分析JSX是如何编译为JS代码的。对于JSX的编译方案,已知的有两种:

  1. babel编译方案
  2. tsc编译方案

就像TypeScript编译一样,只要涉及到了编译环节,我们总是离不开编译三要素模型:源代码、编译器以及编译配置:

接下来将分别详细介绍这两种编译体系的编译过程。

babel编译体系

通过babel可以将结构化的JSX组件,转换为同样结构化的JS代码调用形式。在React中,转换JSX为原生JS代码分为两种形式:

  1. React17以前React.createElment形式;
  2. React17以后'react/jsx-runtime'形式。

先讲第一种:直接转换为React.createElement。假设源代码如下:

import React from 'react';

function App() {
return <h1>Hello World</h1>;
}

转换过程,会将上述JSX转换为如下的createElement代码:

import React from 'react';

function App() {
return React.createElement('h1', null, 'Hello world');
}

但官方提到了关于这种转换方式的两个问题:

  • 如果使用 JSX,则需在 React 的环境下,因为 JSX 将被编译成 React.createElement,也就是说强绑定React
  • 有一些 React.createElement 无法做到的性能优化和简化

基于上述的问题,在React17以后,提供了另一种转换方式:引入jsx-runtime层。假设源码如下:

function App() {
return <h1>Hello World</h1>;
}

下方是新 JSX 被转换编译后的结果:

// 由编译器引入(禁止自己引入!)
import {jsx as _jsx} from 'react/jsx-runtime'; function App() {
return _jsx('h1', { children: 'Hello world' });
}

第二种模式的核心在于:JSX编译出来的代码与React库本身进行了解耦,只将JSX转换为了与React无关的JS形式的调用描述,没有直接使用React.createElement引入了jsx-runtime这一层,屏蔽具体的调用细节,只专注JSX到JS代码最基础的映射。至于这个_jsx的具体实现,就是内部调用的是React.createElement还是另一种createElement,则可以由库内部来进行实现。

PS:可能有小伙伴会说,_jsx不还是从react/jsx-runtime这个React相关库导出的吗?实际上,这个包仅仅是由react团队在维护的原因。

上图描述了一个前端React工程里JSX代码基本的转换思路。当然,Babel在这个转换过程中承担了重要角色。在Babel中,与上述两种转换相关的核心部分是:@babel/preset-react里面引用的插件@babel/plugin-transform-react-jsx

Babelv7.9.0版本之前的该插件,只能将JSX代码转换为React.createElement调用形式。而在v7.9.0版本以后,支持我们配置转换行为。默认选项为 {"runtime": "classic"},也就是说默认还是React.createElement

如需启用新的转换,你可以使用 {"runtime": "automatic"} 作为 @babel/plugin-transform-react-jsx@babel/preset-react 的选项:

// 如果你使用的是 @babel/preset-react(内部引用了@babel/plugin-transform-react-jsx)
{
"presets": [
["@babel/preset-react", {
"runtime": "automatic"
}]
]
}
// 如果你使用的是 @babel/plugin-transform-react-jsx
{
"plugins": [
["@babel/plugin-transform-react-jsx", {
"runtime": "automatic"
}]
]
}

让我们创建一个样例jsx-babel-example,来实践上述过程。对应编译模型三要素,我们定义好如下的内容:

(1)源代码:src/index.jsx

const MyButton = (props) => <button>{props.children}</button>

function App() {
return <h1><MyButton>Hello World</MyButton></h1>;
}

(2)babel编译器以及相关插件:

yarn add -D @babel/core @babel/cli
yarn add -D @babel/plugin-transform-react-jsx

(3)编译配置.babelrc:

{
"plugins": [
[
"@babel/plugin-transform-react-jsx"
]
]
}

补充一个脚本:

{
...
+ "scripts": {
+ "build": "babel src -x .jsx --config-file ./.babelrc -d dist"
+ },
}

搭建完成以后,整体如下:

项目结构并不复杂,编译的过程我们也不再赘述。当我们运行yarn build的时候可以看到在dist目录下能够生成对应js代码:

从上图可以看到,我们的代码直接转换为了React.createElement。但是注意的是,编译结果中,babel是没有替我们插入import React from 'react'这一句代码的!如果你的代码本身没有添加import React from 'react',那么最终编译到了js代码(无论是commonjs还是esmodule),也不会引入React,然而代码却调用的是:React.createElement。正是因为如此,所以才会有我们日常小伙伴会发现,项目能够编译通过,但是运行起来的时候,会提示:

ReferenceError: React is not defined

对于上面问题的解决办法,有两种方式解决:

方式一:在你的代码中手动写上:import React from 'react'。编译后,自然而然就有了import React from 'react'

方式二:使用编译参数:"runtime": "automatic"

{
"plugins": [
[
"@babel/plugin-transform-react-jsx",
+ { "runtime": "automatic" }
]
]
}

新增上述配置以后,重新编译代码,能够看到生产的js代码:

对于重新编译好的代码,此时可以看到React.createElement调用变为了来源于"react/jsx-runtime"中的jsx方法。同时,由于这一段引入是由编译器自动加入的,因此代码进行后续的babel编译的时候,由于有react/jsx-runtime的引入,所以就不再会有所谓的React is not defined的问题。

tsc编译体系

介绍完babel编译jsx体系以后,我们再讲一下关于tsc编译jsx代码的方式。当然,基于编译要素模型,我们依然准备一个项目来解释这个过程。

(1)源代码同上src/index.jsx。

(2)typescript包:

yarn add -D typescript

(3)编译配置tsconfig.json:

{
"compilerOptions": {
"jsx": "react",
"outDir": "dist",
"rootDir": "src",
"allowJs": true
}
}

补充一个脚本:

{
...
"scripts": {
+ "build": "tsc -p tsconfig.json"
},
...
}

搭建完成后,整体如下:

当我们运行yarn build的时候可以看到在dist目录下能够生成对应js代码:

读者应该能够看到由tsc编译出来的js代码,JSX相关的代码编译为了React.createElement形式的调用。对于这个编译结果,tsconfig.json里面的配置起到了决定性作用。现在,我们着重讲一下两个配置项:jsxallowJs

"allowJs"

由于本example中我们没有编写tsx代码,还是用的jsx代码,如果不配置"allowJs": true,那么tsc编译器默认将不会处理js以及jsx文件,又因为example中src目录下只有jsx文件,于是会出现报错:

error TS18003: No inputs were found in config file '/Users/w4ngzhen/projects/web-projects/jsx-tsc-example/tsconfig.json'. Specified 'include' paths were '["**/*"]' and 'exclude' paths were '["dist"]'.

后续如果是TSX的文件,将不会出现这个问题,也不用显式配置该选项。

"jsx"

对于"jsx"这个配置,主要有以下几个值:

  • react: 将 JSX 改为等价的对 React.createElement 的调用并生成 .js 文件。
  • react-jsx: 改为 __jsx 调用并生成 .js 文件。
  • preserve: 不对 JSX 进行改变并生成 .jsx 文件。
  • react-jsxdev: 改为 __jsx 调用并生成 .js 文件。
  • react-native: 不对 JSX 进行改变并生成 .js 文件。

下图展示了当"jsx"的配置分别为:"react""react-jsx"的结果:

不难发现,"react""react-jsx"配置的编译结果,与前面babel编译中插件@babel/plugin-transform-react-jsx"runtime"的配置分别为"classic"(或默认不配置)、"automatic"的编译结果能够相对应。

tsconfig默认使用commonjs作为模块化方案,所以,"jsx": "react-jsx"配置的编译结果中引用react/jsx-runtime时,使用commonjs规范的require。如果给tsconfig.json添加配置"module": "ES6",则会看到import {jsx as __jsx} from 'react/jsx-runtime'的引用方式。

正文:JSX(TSX)的类型检查

在《2023-04-08-TypeScript必知三部曲(一)TypeScript编译方案以及IDE对TS的类型检查》中,我们已经了解了,babel不会参与TS代码的类型检查,TS代码本身的类型检查、IDE上的类型检查提示,都是经过tsc配合tsconfig配置完成。所以,接下来我们所谈的关于JSX(TSX)的类型检查,将会围绕tsc+tsconfig来进行讨论。

准备工作

在进行讨论之前,我们依然准备一个样例,这个样例与前面关于tsc编译体系的样例差别不大,重点在于index.jsx改为了index.tsx

(1)源代码src/index.tsx:

const MyButton = (props) => <button>{props.children}</button>

export function App() {
return <h1><MyButton>Hello World</MyButton></h1>;
}

(2)tsconfig.json:

{
"compilerOptions": {
"module": "ES6",
"jsx": "react",
"outDir": "dist",
"rootDir": "src",
"allowJs": true
}
}

注意"jsx"的配置我们使用"react"

(3)安装typescript并添加编译脚本:

{
"name": "jsx-tsc-example",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"scripts": {
"build": "tsc -p tsconfig.json"
},
"devDependencies": {
"typescript": "^5.0.4"
}
}

准备后整体如下:

类型问题

在上述的项目搭建完成后,我们会发现一个问题:在index.tsx代码中,我们用到了两个jsx基础标签:<button><h1>以及我们自己编写的React组件<MyButton>,但IDE替我们显示了红色,鼠标悬浮以后,会看到报错提示:

  • Cannot find name 'React'.

为什么会这样呢?针对该问题,需要两个知识点来解释。

第一,tsconfig.json的"jsx": "react"配置的编译结果是将 JSX 改为等价的对 React.createElement 的调用并生成 .js 文件;

第二,IDE进行TS的类型检查流程如下:

基于上述两点,我们可以解释这个出错的过程为:IDE识别到了tsconfig.json中的"jsx": "react"配置,调用了形如tsc --noEmit的指令,又因为我们的项目没有添加对react的依赖(类型文件也没有),因此出现了这个地方的IDE报错提示。不难想到,我们实际运行脚本进行编译的时候,会出现同样的错误:

细心的小伙伴会看到dist目录下依然生成了index.js代码,因为类型检查结果实际上不妨碍实际js代码的生成。

上面的配置,我们使用了"jsx": "react",当我们修改为"jsx": "react-jsx"又会有什么效果呢?

修改配置以后,报错如下:

有两方面:

  • Cannot find module 'react/jsx-runtime'。

  • Did you mean to set the 'moduleResolution' option...。

当我们将"moduleResolution": "node"添加以后可以解决问题2,剩下的问题就是:

  • Cannot find module 'react/jsx-runtime' or its corresponding type declarations.

无法找到模块react/jsx-rutnime或它对应的类型声明。

对照前面的"jsx": "react",当我们的配置改为了"jsx": "react-jsx"以后,JSX标签都将编译为_jsx("div", ..., ...)的调用形式,而这个_jsx来源于:import {jsx as _jsx} from 'react/jsx--runtime',而我们的项目并没有安装这个包。所以,IDE根据react-jsx"配置的结果,识别到了问题,并帮助我们提示了对应的问题。

无论是Cannot find name 'React'.还是Cannot find module 'react/jsx-runtime' or its corresponding type declarations.问题,要处理起来也很简单,安装react以及其类型定义:

yarn add react
yarn add -D @types/react

@types/react中包含了Reactreact/jsx-runtime类型定义。

终于,我们不再有类型问题了。此时,我们看看这些h1、button标签到底是什么类型以及ts是如何对这些JSX标签进行类型定义的。

在安装了@types/react后,IDEA里面,通过CTRL+鼠标左键点击相关的标签就能进入到对应的定义里面,比如我们查看<a>标签的具体定义:

通过查看类型定义dts文件,可以很容易的看到该类型为:JSX.IntrinsicElements.a。这个JSX.IntrinsicElements接口是什么呢?让我们探索。

类型检查的源头:JSX.IntrinsicElements

Intrinsic:固有的,本质的,根本的。

内在元素(IntrinsicElements)在特殊接口(既JSX.IntrinsicElements接口)上查找。 默认情况下,如果未指定此接口,则在TypeScript进行类型检查的时候,会直接忽略这些类型JSX标签具体的类型定义,任何JSX都不会对内部元素进行类型检查。 但是,如果存在此接口定义,则内部元素的名称将作为接口上的属性进行查找。

举一个简单的例子,我们可以尝试修改上图中react的dts代码,添加一个新的接口字段abc,该字段还有一个必填的name属性:

        interface IntrinsicElements {
+ abc: { name: string; children?: Element };
}

于是,在代码中,我们就能使用这个<abc>标签,同时,如果不填写name字段的值,TS还会有类型检查异常,只有正确填写name属性才能通过类型检查:

同时,当我们检查编译后的代码,会发现无论是"jsx": "react"还是"jsx": "react-jsx",关于我们使用的<abc>标签的部分,都变成了字符串"abc"的处理(这里只用tsc编译演示,babel是同样的结果,不再赘述):

当然,我们还能编写自己的自定义组件,譬如上面示例里面的<MyButton>MyButton是一个函数组件,满足React DTS文件里面的类型定义关于使用函数组件类型进行createElement的类型定义:

总结来讲,JSX(TSX)中关于内置标签的类型检查流程如下:

在前面,我们在react的官方dts中的JSX.IntrinsicElements添加了abc字段,所以我们才能编写<abc>标签并通过类型检查。但这种方式目前来讲,有个问题:非常不优雅,居然去修改react类型定义代码。那么,还有什么方式扩展JSX的内置标签元素呢?编写声明文件扩充即可:

上图中,我们主动声明了JSX.IntrinsicElements接口,并且向里面添加了a-custom-tag,于是,后面的tsx代码中我们就能使用<a-custom-tag>这个标签了。

但要注意的是,我们声明的种种类型,只针对类型检查。它仅仅保证了tsc在进行类型检查的正确性。而实际编译后的代码,因为会生成诸如:React.createElement("a-custom-tag", ...)_jsx('a-cutoms-tag', ...)等调用的js代码。不难想到在实际运行过程中,React内部是无法处理这个所谓的a-custom-tag的“内置标签”的,它就不明白这个"a-custom-tag"是什么,所以在运行时一定会有错误。

在前言中,我们已经解释了如何将JSX编译为react、react/runtime的相关调用。那么,我们可以自定义处理JSX代码吗?当然可以,如果使用的是babel编译体系,则需要自己编写babel插件;如果是tsc编译体系,则需要自定义jsxFactory,像是solidjs,就有自己的babel插件(babel-preset-solid - npm (npmjs.com))。本文不再赘述关于自定义JSX的编译过程了,网上有很多优秀的文章可以阅读。

写在最后

本文的内容其实还是有点杂乱,后续可能会重构这篇文章。

TypeScript必知三部曲(二)JSX的编译与类型检查的更多相关文章

  1. TypeScript必知三部曲(一)TypeScript编译方案以及IDE对TS的类型检查

    TypeScript代码的编译过程一直以来会给很多小伙伴造成困扰,typescript官方提供tsc对ts代码进行编译,babel也表示能够编译ts代码,它们二者的区别是什么?我们应该选择哪种方案?为 ...

  2. 编译期类型检查 in ClojureScript

    前言  话说"动态类型一时爽,代码重构火葬场",虽然有很多不同的意见(请参考),但我们看到势头强劲的TypeScript和Flow.js,也能感知到静态类型在某程度上能帮助我们写出 ...

  3. Java 泛型优点之编译时类型检查

    Java 泛型优点之编译时类型检查 使用泛型代码要比非泛型代码更有优势,下面是 Java 官方教程对泛型其中一个优点的介绍: "Stronger type checks at compile ...

  4. Android VLC播放器二次开发2——CPU类型检查+界面初始化

    上一篇讲了VLC整个程序的模块划分和界面主要使用的技术,今天分析一下VLC程序初始化过程,主要是初始化界面.加载解码库的操作.今天主要分析一下org.videolan.vlc.gui.MainActi ...

  5. javaScript 工作必知(二) null 和undefined

    null null 表示个“空” , 使用typeof (null) ;//Object ; 说明他是一个特殊的对象. null 类型只自己唯一个成员.他是不包含属性和方法的. undefined u ...

  6. 《SQL必知必会》学习笔记二)

    <SQL必知必会>学习笔记(二) 咱们接着上一篇的内容继续.这一篇主要回顾子查询,联合查询,复制表这三类内容. 上一部分基本上都是简单的Select查询,即从单个数据库表中检索数据的单条语 ...

  7. 程序员必知的8大排序(二)-------简单选择排序,堆排序(java实现)

    程序员必知的8大排序(一)-------直接插入排序,希尔排序(java实现) 程序员必知的8大排序(二)-------简单选择排序,堆排序(java实现) 程序员必知的8大排序(三)-------冒 ...

  8. MySql必知必会实战练习(二)数据检索

    在上篇博客MySql必知必会实战练习(一)表创建和数据添加中完成了各表的创建和数据添加,下面进行数据检索和过滤操作. 1. Select子句使用顺序 select--->DISTINCT---& ...

  9. Elasticsearch必知必会的干货知识二:ES索引操作技巧

    该系列上一篇文章<Elasticsearch必知必会的干货知识一:ES索引文档的CRUD> 讲了如何进行index的增删改查,本篇则侧重讲解说明如何对index进行创建.更改.迁移.查询配 ...

  10. Visual Studio (VS IDE) 你必须知道的功能和技巧 - 【.Net必知系列】

    前言 本文主要阐述一些Visual Studio开发下需要知道的少部分且比较实用的功能,也是很多人忽略的部分.一些不常用而且冷门的功能不在本文范围,当然本文的尾巴[.Net必知系列]纯属意淫,如有雷同 ...

随机推荐

  1. flash8.ocx或其附件之一不能正确注册

    运行书中自带光盘中的程序,在该程序的readme说明中,提到这类错误,解决方式是: 因为是免安装程序,需要运行"setup"文件夹下的setup.exe文件,安装控件.在安装完成后 ...

  2. Spring Boot 跨域的问题

  3. C# 实现刘谦春晚魔术

    internal class Program { static List<string> list=new List<string>() { "A",&qu ...

  4. JS实现一个布隆过滤器

    之前专门聊过令牌桶算法,而类似的方案还有布隆过滤器.它一般用于高效地查找一个元素是否在一个集合中. 用js实现如下所示: class BloomFilter { constructor(size, h ...

  5. mysql插入表中的中文字符显示为乱码或问号的解决方法

    mysql中文显示乱码或者问号是因为选用的编码不对或者编码不一致造成的,最简单的方法就是修改mysql的配置文件my.cnf.在[mydqld]和[client]段加入 default-charact ...

  6. win32 - Rendering a Stream示例

    仅供参考 文档: Rendering a Stream 代码示例: #include <cstdio> #include <Windows.h> // Windows mult ...

  7. win32 - PeekNamedPipe的用法

    PeekNamedPipe: 将数据从命名管道或匿名管道复制到缓冲区中,而不将其从管道中删除.它还返回有关管道中数据的信息. 示例: #include <iostream> #includ ...

  8. golang微服务实践:服务注册与服务发现 - Etcd的使用

    为什么? 为什么会有服务注册和服务发现?在它以前我们是怎么做的? 举个例子: 比如我们做MySQL读写分离,就在本地配置一个文件,然后程序读取这个配置文件里的数据进行数据库读写分离的设置. 但是随着业 ...

  9. RabbitMQ和RPC

    消息队列 消息队列中间件 (Message Queue Middleware,简称 MQ) 是指利用高效可靠的消息传递机制进行与平台无关的数据交流,它可以在分布式环境下扩展进程间的数据通信,并基于数据 ...

  10. 记录一个错误:Unable to find a match: python-dev

    今天尝试在Linux下运行一个Python项目,在安装requirements.txt时报错 执行命令如下: [root@VM-16-8-centos cve-search]# pip3 instal ...