好像能在 JSX 里面写 Vue 了诶?

1,178 阅读7分钟

前言

在 TypeScript 最近的发布版本中,有一个关于 JSX 的增强,通过这个增强与一些 TypeScript 的基础知识,我们就能做一些很魔法的事情!

通过本篇文章,你能了解到关于 JSX 编译、TypeScript 最新特性的知识。

引子

我是写 Vue 入门前端工程化的,所以说 Vue 在一段比较长的时间来支配了我的编程思维与习惯,但是我现在来写 React 了,刚开始的时候我并不是很习惯 JSX 中的各种相对来说原生许多的写法,比如什么 &&cond ? ele0 : ele1arr.map 等各种写法,然后我适应了。但是!事情没有这么简单~

正文

我们都知道 Vue 中的各种模版语法实际上是通过自己的编译器进行的实现,我是没这个闲心去搞一套基于 JSX 的模版语法编译器的,那么有没有更简单的办法来实现我们在 JSX 中也能像 Vue 一样使用模版语法来为自己提供便利呢?

全新的支持

在 TypeScript 的最近的 5.1 版本中更新了关于 JSX 属性的命名空间支持,简单来说以前你不能这么写 <Foo a:b="foo" /> ,现在可以了。那么有啥用呢?

畅想一下,我们是不是可以这么写 <Foo click:stop={noop} /> 呢?

JSX 的特殊

我们都知道,JSX 实际上只是对 UI 的一种表现形式。在这种表现形式之下,我们都会被编译器向下编译出我们时机运行的 h 函数代码(部分框架不会选择编译到 h 函数,转而是使用自己的编译器处理,这就是 preserve ,react-native 不做讨论)。

Untitled转存失败,建议直接上传图片文件

知道这个后有什么用呢?实际上 TypeScript 提供许多易于扩展 API 提供给 JSX 的使用者,有时候我们并不需要用到 React.createElement ,而是去使用其他的渲染函数。这个它也有为我们所考虑到,那就是 jsxFactoryjsxFragmentFactoryjsxImportSource 三位配置项,前俩个是在早期版本用于改变默认渲染函数,而后者则是在新版本中优化的既能声明默认的渲染函数,同时又能够同时引入类型的配置项,在其他的文章中有介绍,在这里我就不进行过多的赘述了。

结合起来

程序员在大部分时候都是通过组合来实现我们想要的效果的,那么在这里我们可不可以将俩者组合在一起从而实现我们需要的功能呢?

相信已经有想的快的朋友通过前文已经想到了思路了,没错!我们可以修改 JSXImportanceSource 的配置项,代理一个目标框架的 jsx-runtime ,通过检测传入参数的特征,将符合我们语法的处理为我们目标的…等等,可能稍微有点快,我们可以先假设一下(或者你自己思考思考🤔️)。

思路解析

指令的定义

首先我们假设我们有一个 api,可以给每个组件的 props 进行 hack 监听,那么假设他是这个样子。

// 这里我们使用 TypeScript 的 declare 简单定义一个函数的形式
declare function defineInstruction(
  name: string,
  covert: (value: unknown) => unknown
)

defineInstruction('stop', func => e => {
  if (e.stopPropagation && typeof e.stopPropagation === 'function') {
    e.stopPropagation()
  }
  return func(e)
})

代理一下

我们可以通过这个 API 将一个指令注册到一个全局的指令管理中心(人话就是一个 Object 中),这里我们称呼其为 instructions ,然后我们再通过定义一个用于代理目标框架的 jsx-runtime 来监听运行时的用户对 props 的传入数据,并按照约定的规则进行修改,并传递回我们被代理的目标。

import * as __Runtime__ from 'react/jsx-runtime'

import { instructionResolve } from './utils'

export function jsxs(type, props, key) {
  const [nProps] = instructionResolve(props)
  return __Runtime__.jsxs(type, nProps, key)
}

export function jsx(type, props, key) {
  const [nProps] = instructionResolve(props)
  return __Runtime__.jsx(type, nProps, key)
}

export const Fragment = __Runtime__.Fragment

这里我们引入了一个 instructionResolve 处理 props 传入数据,再将我们通过该函数处理过后的新 props 传递给我们的代理目标。

运行一下

在这里我们省略了我们对项目的配置,以及一些与本篇文章无关的内容,如果你有阅读的需要,可以在文末的相关找到对应的链接进行了解。

我们可以看到我们下面书写的代码,由于事件冒泡的规则我们可以知道,如果我们没在 div 上的 onClick 检测我们的目标元素,则会在子元素按钮上触发了点击事件后,再次向上触发一次父元素的事件,通常的来说我们会在下级元素的 onClick 中调用 e.stopPropagation() 方法阻止冒泡。

但是我们也知道,这实际上是一段没太大的必要的代码,在 vue 中我们可以通过 @click.stop 从而通过编译器帮我们解决这个问题,而在 JSX 中,我们并没有这种便利,而我们通过我们上面的工作就能实现我们的目标了。

export function App() {
  const [c, setC] = useState(0)
  return <div onClick={() => setC(c + 1)}>
    {c}
    <br/>
    <button onClick:stop={() => setC((count) => count + 1)}>
      trigger once add
    </button>
    <button onClick={() => setC((count) => count + 1)}>
      trigger twice add
    </button>
  </div>
}

上面这段代码在经过我们的配置,以及 TypeScript 的编译后,我们便能看到这样类似下面的一段代码(删除了部分无关的代码并简化了一下,只关注于我们所见)。

// 当我们配置 jsxImportSource 时,下面这行 import 会被 tsc 自动处理进来
import {
	Fragment, jsxDEV
// 注意下面的路径,这里我们以 Dev 为例子
} from "/@fs/project/node-modules/jsx-instruction/react/jsx[-dev](开发环境会引入 dev 版本 runtime)-runtime.ts?t=1688032372332";

export function App() {
  const [c, setC] = useState(0);
    return jsxDEV("div", {
      onClick() { setC(c + 1); },
      children: [
        c,
        jsxDEV("br"),
        jsxDEV("button", { "onClick:stop"() {
          setC((count) => count + 1);
        }, children: "trigger once add" }),
        jsxDEV("button", { "onClick"() {
          setC((count) => count + 1);
        }, children: "trigger twice add" }),
      ],
    })
}

我们可以看到 JSX 的编译结果除了多了一个能编译出来的 onclick:stop 并没有什么太大的区别,但是!我们可以看到通过 jsxImportSource 的配置 jsx-runtime 已经切换到了我们代理的 proxy 了。所以实际上传递给 react h 函数的 props 已经被我们所代理,实际上我们运行的代码是这个样子。

export function App() {
  const [c, setC] = useState(0);
  return jsxDEV("div", {
    onClick() { setC(c + 1); },
    children: [
      c,
      jsxDEV("br"),
      jsxDEV("button", { "onClick"() {
        if (e.stopPropagation && typeof e.stopPropagation === 'function') {
          e.stopPropagation()
        }
        return (() => {
          setC((count) => count + 1);
        })();
      }, children: "trigger once add" }),
      jsxDEV("button", { "onClick"() {
          setC((count) => count + 1);
      }, children: "trigger twice add" }),
    ],
  })
}

总结

至此一个简单的拓展性结构与脉络我们便从中缕清了,接下来我们便可以通过工程化的手段,去优化用户在使用该仓库中所遇到的问题。比如说:

  • 如何限制用户在键入 onClick 时提示可以 :stop ,在某个特定的类型能够提示某个指令
  • 如何让开发者能够自定义他们自己的指令,并且提供给其他的用户
  • 除了修改 Props,我们都能控制 JSX 了,是不是也能够提供控制 JSX 的行为(指控制流)

这便是另外的故事了:《TypeScript 到底怎么使用才能使我们开发的系统调用优雅,使用安全并具备拓展性?》。

上一篇关于一个 API 的使用到设计思路的进化不够具体,接下来我会立足于这个新的项目讲一讲一些更加细化的友好交互 API 设计。

相关

本项目已开源至 GitHub(类型运算警告⚠️,非体操选手慎重阅读)。如果你想试试,戳一下这里


看都看到这了,点个赞再走呗,欢迎评论区友好🧑‍🤝‍🧑交流讨论。