[Vue3 等不及了系列] 剑指JSX 一

1,253 阅读4分钟

起因

最近半年一直在做 react 开发,得益于hooks ,我可以全面使用函数式的开发方式,而antdv4 的发布也是改善了 v3 版 form 的使用体验。
虽然如此,可hooks 带来的心智负担也让人觉的不够爽。而吹了一年多的Vue3 在今年也是终于有了 alpha 版了,可是按Roadmap 来看估计要到Q3 才能使用上正式版。而且在整个Vue的生态下需要做的周边工作太多了,包括 cli eslint babel vetur devtool 等等以及一些成熟的组件库的跟进之类的。
不过作为爱捣腾的猿,是无法接受等待的,那么就拿起我们的键盘自给自足吧。

vue3 中的 jsx

先看看在Vue3中用 TSX写一个组件是什么什么样子的。

import { defineComponent } from 'vue'

interface IElHeaderProps {
  height?: string
}

export default defineComponent((props: IElHeaderProps, { slots, attrs }) => {
  const { height } = props
  return () => (
    <header {...attrs} class="el-header" style={{ height }}>
      {slots.default && slots.default()}
    </header>
  )
})

可以看到写法已经和react 很相似了,这是defineComponent 的一个重载,以前的option形式也是可以用的,而defineComponent只是用来提供类型支持的,实际上还是返回的一个 options。vue3中源码对应地址。 而和react不同的地方在于 在setup 中返回了RenderFunction,也就是说setup函数只会执行一次,不会像react 函数式组件一样rerender 重复定义。

不过受限于vue3的进度问题,上面的代码还没法跑起来,因为vue3 对比vue2 在VNode 上有了改动 ( 详情看这个rfc)

// before
{
  class: ['foo', 'bar'],
  style: { color: 'red' },
  attrs: { id: 'foo' },
  domProps: { innerHTML: '' },
  on: { click: foo },
  key: 'foo'
}

// after
{
  class: ['foo', 'bar'],
  style: { color: 'red' },
  id: 'foo',
  innerHTML: '',
  onClick: foo,
  key: 'foo'
}

现有 vue2 的jsx 插件没法支持, 我们可以对原有 babel-plugin-transform-vue-jsx 做一些修改让支持vue3

babel plugin

// babel-plugin-transform-vue-jsx 源码
return {
    inherits: require('@babel/plugin-syntax-jsx').default,
    visitor: {
      JSXNamespacedName (path) {
        throw path.buildCodeFrameError(
          'Namespaced tags/attributes are not supported. JSX is not XML.\n' +
          'For attributes like xlink:href, use xlinkHref instead.'
        )
      },
      JSXElement: {
        exit (path, file) {
          // turn tag into createElement call
          var callExpr = buildElementCall(path.get('openingElement'), file)
          if (path.node.children.length) {
            // add children array as 3rd arg
            callExpr.arguments.push(t.arrayExpression(path.node.children))
            if (callExpr.arguments.length >= 3) {
              callExpr._prettyCall = true
            }
          }
          path.replaceWith(t.inherits(callExpr, path.node))
        }
      },
      'Program' (path) {
        path.traverse({
          'ObjectMethod|ClassMethod' (path) {
            const params = path.get('params')
            // do nothing if there is (h) param
            if (params.length && params[0].node.name === 'h') {
              return
            }
            // do nothing if there is no JSX inside
            const jsxChecker = {
              hasJsx: false
            }
            path.traverse({
              JSXElement () {
                this.hasJsx = true
              }
            }, jsxChecker)
            if (!jsxChecker.hasJsx) {
              return
            }
            // do nothing if this method is a part of JSX expression
            if (isInsideJsxExpression(t, path)) {
              return
            }
            const isRender = path.node.key.name === 'render'
            // inject h otherwise
            path.get('body').unshiftContainer('body', t.variableDeclaration('const', [
              t.variableDeclarator(
                t.identifier('h'),
                (
                  isRender
                    ? t.memberExpression(
                      t.identifier('arguments'),
                      t.numericLiteral(0),
                      true
                    )
                    : t.memberExpression(
                      t.thisExpression(),
                      t.identifier('$createElement')
                    )
                )
              )
            ]))
          },
          JSXOpeningElement (path) {
            const tag = path.get('name').node.name
            const attributes = path.get('attributes')
            const typeAttribute = attributes.find(attributePath => attributePath.node.name && attributePath.node.name.name === 'type')
            const type = typeAttribute && t.isStringLiteral(typeAttribute.node.value) ? typeAttribute.node.value.value : null

            attributes.forEach(attributePath => {
              const attribute = attributePath.get('name')

              if (!attribute.node) {
                return
              }

              const attr = attribute.node.name

              if (mustUseProp(tag, type, attr) && t.isJSXExpressionContainer(attributePath.node.value)) {
                attribute.replaceWith(t.JSXIdentifier(`domProps-${attr}`))
              }
            })
          }
        })
      }
    }
  }

可以看到 JSXElement 中判断类型并为每个 JSXElement 包装一个 h() 函数,然后在 Program 中定义h是render 的参数还是 createElement。 得出了这个结论我们就可以开始行动,将它改造成vue3的jsx插件了。

import babel, { PluginItem } from '@babel/core'
import * as BabelTypes from '@babel/types'
import PluginSyntaxJsx from '@babel/plugin-syntax-jsx'
import {
  JSXAttribute,
  JSXSpreadAttribute,
  JSXElement,
  JSXIdentifier,
  Expression,
  ImportDeclaration,
  ImportSpecifier
} from '@babel/types'

type BaseBabel = typeof babel

type BaseTypes = typeof BabelTypes

interface ITypes extends BaseTypes {
  react: any
}
interface IBabel extends BaseBabel {
  types: ITypes
}

const getAttrs = (attrs: Array<JSXAttribute | JSXSpreadAttribute>, t: ITypes) => {
  const props: any[] = []
  attrs.forEach(attr => {
    if (attr.type === 'JSXAttribute') {
      const name = attr.name.name as string
      const value = attr.value
      if (t.isJSXExpressionContainer(value)) {
        props.push(t.objectProperty(t.stringLiteral(name), value.expression as Expression))
      } else {
        props.push(t.objectProperty(t.stringLiteral(name), value!))
      }
    } else if (attr.type === 'JSXSpreadAttribute') {
      // 处理 spread
      props.push(t.spreadElement(attr.argument))
    }
  })
  return t.objectExpression(props)
}

const plugin = ({ types: t }: IBabel) => {
  return {
    name: 'babel-plugin-vue-next-jsx',
    inherits: PluginSyntaxJsx,
    visitor: {
      JSXNamespacedName(path) {
        throw path.buildCodeFrameError(
          'Namespaced tags/attributes are not supported. JSX is not XML.\n' +
            'For attributes like xlink:href, use xlinkHref instead.'
        )
      },
      JSXElement: {
        exit(path) {
          // 获取 jsx
          const openingPath = path.get('openingElement')
          const parent = openingPath.parent as JSXElement
          // children:Array
          const children = t.react.buildChildren(parent)

          const name = openingPath.node.name as JSXIdentifier
          // 判断是不是组件
          const tagNode = t.react.isCompatTag(name.name) ? t.stringLiteral(name.name) : t.identifier(name.name)

          // // 创建 Vue h
          const createElement = t.identifier('h')
          const attrs = getAttrs(openingPath.node.attributes, t)
          const callExpr = t.callExpression(createElement, [tagNode, attrs, t.arrayExpression(children)])
          path.replaceWith(t.inherits(callExpr, path.node))
        }
      },
      JSXAttribute(path) {
        if (t.isJSXElement(path.node.value)) {
          path.node.value = t.jsxExpressionContainer(path.node.value)
        }
      },
      Program: {
        exit(path) {
          // 先判断有没有引入vue
          const hasImportedVue = path.node.body
            .filter(p => p.type === 'ImportDeclaration')
            .some(p => (p as ImportDeclaration).source.value == 'vue')

          if (path.node.start === 0) {
            if (!hasImportedVue) {
              // 没有引入vue 直接 import { h } from 'vue'
              path.node.body.unshift(
                t.importDeclaration([t.importSpecifier(t.identifier('h'), t.identifier('h'))], t.stringLiteral('vue'))
              )
            } else {
              // 已经有vue了 拿到这个节点
              const vueSource = path.node.body
                .filter(p => p.type === 'ImportDeclaration')
                .find(p => (p as ImportDeclaration).source.value == 'vue') as ImportDeclaration
              // 拿到vue 中导入了哪些内容
              const key = vueSource.specifiers
                .filter(s => s.type === 'ImportSpecifier')
                .map(s => (s as ImportSpecifier).imported.name)
              // 没有导入 h 函数,加进去。
              if (!key.includes('h')) {
                vueSource.specifiers.unshift(t.importSpecifier(t.identifier('h'), t.identifier('h')))
              }
            }
          }
        }
      }
    }
  } as PluginItem
}
export default plugin

这样一个初级的JSX 插件就基本完成了,

测试

然后我们需要加上一些单元测试,看是否符合预期

比如这段代码

const A = () => {} 
const a = { a:'1', b:'2' }
const Comp = () => (
<A style={{ height: '3rem', lineHeight: 4 }} {...a}>
  <div>test</div>
  <div style={{height:'4px'}}>test</div>
</A>)

期望的输出应该是这样的

import { h } from "vue";

const A = () => {};

const a = {
  a: '1',
  b: '2'
};

const Comp = () => h(A, {
  "style": {
    height: '3rem',
    lineHeight: 4
  },
  ...a
}, [h("div", {}, ["test"]), h("div", {
  "style": {
    height: '4px'
  }
}, ["test"])]);

ok。

后续

到这里我们的 插件基本能使用了,不过还远远不够我们还需要去尝试让他支持withDirectives 也就是指令,以及自动解开ref,更或者是静态树的提升 ,这是接下来要做的。

我的文章最先发表在我的GitHub博客,欢迎关注

代码

本文插件代码 babel-plugin-transform-vue-jsx

相关阅读

使用Vue 3.0做JSX(TSX)风格的组件开发