动手实现一个简单的 JSX 渲染器

335 阅读4分钟

最近有哥们问我 JSX 到底有多少种用法,直接把我问到了 1715420252107-RBFdMs

所以打算写篇文章记录一下自己对 JSX 的认识,顺便也巩固一下自己的知识。

这篇文章我将使用 esbuild 这个 js 打包器来处理 JSX 语法,当然你使用 babel 或者 tsc 也可以,或者其他更多的支持 JSX 语法转换的打包器。

JSX 的概念

在现代的 Web 开发框架当中,React 算的上是 JSX 使用的开山鼻祖了,这里我直接给出 React 官网对 JSX 的表述。

1715416328688-mPrGZO

JSX 就是 javascript 的语法扩展,能够让开发者使用类似 HTML 标签的方式使用 js 编写 UI 界面。 一个最简单的 JSX 如下所示:

const element = <h1>Hello JSX</h1>

JSX 与我们使用的 Vue 模版或者其他模版不一样,它只是 JavaScript 的语法糖,也就是说在我们将 JSX 编译成 js 文件之后,原本的 JSX 语法都将被替换成纯 js。

使用 esbuild 打包 JSX

esbuild 是一个使用 go 语言写的 javascript 打包器,速度非常快,能够处理现代化 web 开发当中各种资源的打包操作,如:js/ts 语法转换、jsx/tsx 编译等等,还能够更多的其他特性,详细的学习文档可以直接阅读官网。

搭建项目

直接创建一个标准的前端项目,将 esbuild 安装到项目的依赖当中,项目大概目录结构如下所示。

1715417731794-unGdSc

esbuild 安装命令

pnpm add esbuild -D

将下列执行命令添加到 packages.json 的 scripts 当中

  "scripts": {
    "build": "esbuild src/index.jsx --jsx=transform --outfile=dist/index.js"
  },

然后在 index.jsx 当中写入

const element = <h1>hello jsx</h1>

然后执行 pnpm build 获得的打包后的 js 文件如下所示:

const element = /* @__PURE__ */ React.createElement("h1", null, "hello jsx");

可以看到,esbuild 已经帮我们把 JSX 转换成了 React.createElement 函数调用的方式了,默认情况下使用 esbuild--jsx=transformesbuild 就会把我们写的 JSX 语法都编译成 React.createElement

我们也可以在 jsx 的文件顶部加上下面的注视,也可以指定我们需要将 jsx 编译为什么函数调用。

/** @jsx 函数名 */

也可以设置 jsconfig.json 或者 tsconfig.json 当中的 compilerOptions.jsxFactory,也可以指定。

{
  "compilerOptions": {
    "jsxFactory": "h",
  }
}

我们将上面的 index.mjs 修改为如下内容:

/** @jsx h */
const element = <h1>hello jsx</h1>

得到的结果如下:

const element = /* @__PURE__ */ h("h1", null, "hello jsx");

OK,通过上面的知识点,我们可以让我们编写的 JSX 在编译之后走我们自定义的函数调用。

自定义 JSX 渲染函数

这里我们也把自定义的渲染函数名称为 h,其含义为 hyperscript("hypertext" + "javascript").

通过结果我们可以反推出来我们函数的定义以及根据我们使用 Vue 或者 React 的经验,h 函数一边都是用来返回虚拟 DOM(vnode)的,那么我们可以直接写出以下函数:

function h(nodeName, attributes, ...args) {
      let children = args.length ? [].concat(...args) : null;
      return { nodeName, attributes, children };
}

我们给 name 增加一下打印语句,然后编译一下项目,并直接使用 node 执行一下打包之后的结果。

1715419084598-yeUqqm

可以看到,成功的把我们需要的 vnode 打印出来了,可以测试一下标签嵌套,当然得到的结果也和我们预期的一样。

有了 vnode 之后,我们还需要一个 render 函数将其渲染到界面上。 可以尝试自己写一下,加深自己的理解,很简单,这里我直接贴出代码。

function render(vnode) {
    // 节点是字符串直接渲染
    if (vnode.split) return document.createTextNode(vnode);
    
    // 根据节点创建元素
    let n = document.createElement(vnode.nodeName);
    
    // 节点的属性,这里需要考虑递归处理样式,这里只简单的设置属性
    let a = vnode.attributes || {};
    Object.keys(a).forEach( k => n.setAttribute(k, a[k]) );
    
    // 递归处理子节点
    (vnode.children || []).forEach( c => n.appendChild(render(c)) );

    return n;
}

有了上面的函数,我们就已经能够成功的将我们写的jsx渲染到界面上了。

完整代码:

/** @jsx h */

function h(nodeName, attributes, ...args) {
  let children = args.length ? [].concat(...args) : null;
  return { nodeName, attributes, children };
}

function render(vnode) {
  if (vnode.split) return document.createTextNode(vnode);

  let n = document.createElement(vnode.nodeName);

  let a = vnode.attributes || {};

  Object.keys(a).forEach( k => n.setAttribute(k, a[k]) );

  (vnode.children || []).forEach( c => n.appendChild(render(c)) );

  return n;
}

const element = <h1 class="test">hello
  <span>JSX</span>
</h1>

document.body.appendChild(render(element));

1715419751925-jn754P

可以看到,界面按照我们的预期渲染到界面了。

我们在使用它来测试一下列表渲染,直接使用,完整的代码如下所示:

/** @jsx h */
function h(nodeName, attributes, ...args) {
  let children = args.length ? [].concat(...args) : null;
  return { nodeName, attributes, children };
}

function render(vnode) {
  if (vnode.split) return document.createTextNode(vnode);

  let n = document.createElement(vnode.nodeName);

  let a = vnode.attributes || {};

  Object.keys(a).forEach( k => n.setAttribute(k, a[k]) );

  (vnode.children || []).forEach( c => n.appendChild(render(c)) );

  return n;
}

let items = ['foo', 'bar', 'baz'];

function item(text) {
    return <li>{text}</li>;
}

let list = render(
  <ul>
    { items.map(item) }
  </ul>
);

document.body.appendChild(list);

允许的效果如下所示:

1715419946950-KW65E5

非常完美,如果你读到这里,那么你已经成功的掌握了自己写 jsx 渲染函数的能力,反正最终 jsx 会转换为函数调用,所以 javascript 能怎么用,jsx 就能怎么用,非常灵活多变。