起因
最近半年一直在做 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"])]);
后续
到这里我们的 插件基本能使用了,不过还远远不够我们还需要去尝试让他支持withDirectives 也就是指令,以及自动解开ref,更或者是静态树的提升 ,这是接下来要做的。
我的文章最先发表在我的GitHub博客,欢迎关注
代码
本文插件代码 babel-plugin-transform-vue-jsx