Skip to content

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

@jaskang

Description

@jaskang

起因

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

image

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"])]);

image

ok。

后续

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

代码

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

相关阅读

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    FEjavascript html cssVue3.0Vue3.0

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions