鸽到现在终于想起来了Babel的第三篇,也就是最终章了。这篇主要介绍一下如何开发一个Babel的插件,从头实现一个React的jsx语法转换的插件。

 

实现JSX转换的Babel插件

我们知道 React 的 jsx 实际上是 JavaScript 的扩展,jsx 的格式最终会被转换成 React 提供的 createElement 方法(在v17版本之前,v17之后可以使用babel提供的_jsx,不需要import React),举个例子:

<div>
  <h1>Hello World</h1>
</div>复制代码

会被转换成如下形式:

var a = React.createElement(
  'div', 
  null, 
  React.createElement("h1", null, "Hello World")
);复制代码

所以我们JSX转换的插件的目标就是识别出 jsx这种 <> 格式的语法,然后将其转换。现在就让我们开始写起来~

准备工作

首先先来创建一个webpack项目,方便检验我们的插件的效果。不想动手的同学,可以直接查看这个我已经写好的项目

mkdir babel_jsx_transform_demo
cd ./babel_jsx_transform_demo
npm init
npm install -D webpack webpack-cli @babel/core babel-loader复制代码

创建好项目之后创建一个webpack.config.js添加如下配置信息:

const path = require('path')

module.exports = {
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js'
  },
  mode: 'none',
  module: {
    rules: [{
      test: /\.js$/,
      use: {
        loader: 'babel-loader',
        options: {
          plugins: []
        }
      }
    }]
  }
}复制代码

非常简单的配置,mode 设置为 none 的原因是方便我们观察产物。

创建 ./src/index.js 作为我们的入口文件,把我们上面用到的JSX内容写进去

const App = () => {

  return (<div><h1>Hello World</h1></div>)
}

console.log(App())复制代码

这里我们就不写 import React from 'react' 了(否则打包产物会带有React 内部的代码,不方便我们查看)

准备工作到这里就结束了~

编写插件

我们可以通过 AST Explorer 这个工具先看一下 上面的那段JSX对应的AST结构是什么:

{
  type: 'JSXElement',
  openingElement: {
    type: 'JSXOpeningElement',
    name: {
      type: 'JSXIdentifier',
      name: 'div'
    },
    attributes: [],
    selfClosing: false
  },
  closingElement: {
    type: 'JSXClosingElement',
    name: {
      type: 'JSXIdentifier',
      name: 'div'
    }
  },
  children: [{
    type: 'JSXElement',
    openingElement: {
      type: 'JSXOpeningElement',
      name: {
        type: 'JSXIdentifier',
        name: 'h1'
      },
      attributes: [],
      selfClosing: false
    },
    closingElement: {
      type: 'JSXClosingElement',
      name: {
        type: 'JSXIdentifier',
        name: 'h1'
      }
    },
    children: [{
      type: 'JSXText',
      value: 'Hello World'
    }]
  }]
}复制代码

上面的结构是我删除了大部分JSX AST的属性之后得到的一个结构,基本属性有:

  • type:说明这个节点是一个什么类型的节点,比如 JSXText 就是 JSX内部的文案
  • openingElement:JSX的起始标签的节点结构,如 <div>
  • closingElement:JSX的结束标签的节点结构,如 </div>
  • name: 节点名

我们的Babel插件实际上就是要遍历AST识别到JSX类型的节点,然后对其进行处理转换成新的节点。 现在让我们来开始写我们的插件:

Babel插件实际上就是导出一个函数,在项目根目录创建一个文件 ./plugin/jsx_transform.js,导出一个函数。Babel插件基于访问者模式,我们的插件就是给访问者提供一个接口:

module.exports = function({types: t}) {

}复制代码

这里我导出了一个函数,函数的参数是一个对象,我们通过解构的方式拿到其中的types,也就是 @babel/types,关于 babel/types 的强大之处这里就不多介绍了,可以去查看 babel系列的第二篇。函数返回一个具有visitor属性的对象,该对象内部是对各种类型的标签(比如 JSXElement)的处理逻辑,是一个个的函数。我们这个插件要处理的是 jsx,那么当然是要写关于 JSXElement 的处理逻辑了,具体逻辑的含义在代码的注释中:

module.exports = function ({ types: t }) {
  return {
    visitor: {
      // 处理 JSXElement
      JSXElement(path) {
        // 得到当前 JSX的节点结构
        const node = path.node

        // JSXOpeningElement
        const { openingElement } = node
        // 获取这个JSX标签的名字
        const tagName = openingElement.name.name
        // 不考虑 JSX上的props,直接传递null
        const attributes = t.nullLiteral()

        // React
        const reactIdentifier = t.identifier("React")
        // createElement
        const createElementIdentifier = t.identifier("createElement")

        // React.createElement
        const callee = t.memberExpression(
          reactIdentifier,
          createElementIdentifier
        )
        // 调用React.createElement需要传递的参数
        const args = [t.stringLiteral(tagName), attributes]
        
        // 生成React.createElement('xxx', null, children)
        const callRCExpression = t.callExpression(callee, args)
        callRCExpression.arguments = callRCExpression.arguments.concat(
          path.node.children
        )

        // 用生成的createElement结构替换之前的jsx结构
        path.replaceWith(callRCExpression, path.node)
      },
      // 处理 JSXText 节点
      JSXText(path) {
        const nodeText = path.node.value
        // 直接用 string 替换 原来的节点
        path.replaceWith(t.stringLiteral(nodeText), path.node)
      },
    },
  }
}复制代码

这里我偷懒没有处理 JSX props相关的逻辑,所以最终生成的会是 React.createElement(TAGNAME, null, children),这里的第二个参数 null 在真实情况下会是一个 props 的数组结构。

运行验证

写好了插件之后,我们就要在bable-loader中引入我们的插件:

rules: [{
  test: /\.js$/,
  use: {
    loader: 'babel-loader',
    options: {
      plugins: ['./plugin/jsx_transform.js']
    }
  }
}]复制代码

在package.json中添加一条 scripts:

"scripts": {
  "start": "webpack"
},复制代码

执行 npm run start,发现并不是我们想象中的直接运行,而是报错了,报错内容大致是:

ERROR in ./src/index.js
Module build failed (from ./node_modules/babel-loader/lib/index.js):
SyntaxError: xxx/babel_jsx_plugin_demo/src/index.js: Support for the experimental syntax 'jsx' isn't currently enabled (6:11):

  4 | const App = () => {
  5 | 
> 6 |   return (<div><h1>Hello World</h1></div>)
    |           ^
  7 | }
  8 | 
  9 | console.log(App())

Add @babel/preset-react (https://git.io/JfeDR) to the 'presets' section of your Babel config to enable transformation.
If you want to leave it as-is, add @babel/plugin-syntax-jsx (https://git.io/vb4yA) to the 'plugins' section to enable parsing.复制代码

上面的内容大致就是不能解析jsx语法,所以我们还需要安装一个 plugin 让webpack能解析 jsx 语法,运行npm i -D @babel/plugin-syntax-jsx,安装这个插件,然后更改我们的 webpack.config.js:

rules: [{
  test: /\.js$/,
  use: {
    loader: 'babel-loader',
    options: {
      plugins: ['@babel/plugin-syntax-jsx' ,'./plugin/jsx_transform.js']
    }
  }
}]复制代码

再次运行 npm run start,可以看到 build 成功,内容已经输出到 dist/bundle.js中,文件内容如下:

/******/ (() => { // webpackBootstrap
const App = () => {
  return React.createElement("div", null, React.createElement("h1", null, "Hello World"));
};

console.log(App());
/******/ })()
;复制代码

可以看到我们的jsx已经转换成功了~

总结

本文简单的从0到1实现了一个 jsx 的转换插件,虽然功能不是很完善(不支持 props处理、不支持自定义Components的处理),真实的jsx转换插件要比这个复杂的多。