diff --git a/packages/babel-plugin-jsx/src/index.js b/packages/babel-plugin-jsx/src/index.js index 1ae8053..6215f19 100644 --- a/packages/babel-plugin-jsx/src/index.js +++ b/packages/babel-plugin-jsx/src/index.js @@ -1,13 +1,11 @@ import syntaxJsx from '@babel/plugin-syntax-jsx'; import tranformVueJSX from './transform-vue-jsx'; -import sugarVModel from './sugar-v-model'; import sugarFragment from './sugar-fragment'; export default ({ types: t }) => ({ name: 'babel-plugin-jsx', inherits: syntaxJsx, visitor: { - ...sugarVModel(t), ...tranformVueJSX(t), ...sugarFragment(t), }, diff --git a/packages/babel-plugin-jsx/src/sugar-v-model.js b/packages/babel-plugin-jsx/src/sugar-v-model.js deleted file mode 100644 index 782ee59..0000000 --- a/packages/babel-plugin-jsx/src/sugar-v-model.js +++ /dev/null @@ -1,174 +0,0 @@ -import camelCase from 'camelcase'; -import { addNamespace } from '@babel/helper-module-imports'; -import { createIdentifier, checkIsComponent } from './utils'; - -const cachedCamelCase = (() => { - const cache = Object.create(null); - return (string) => { - if (!cache[string]) { - cache[string] = camelCase(string); - } - - return cache[string]; - }; -})(); - -const startsWithCamel = (string, match) => string.startsWith(match) - || string.startsWith(cachedCamelCase(match)); - -/** - * Add property to a JSX element - * - * @param t - * @param path JSXOpeningElement - * @param value string - */ -const addProp = (path, value) => { - path.node.attributes.push(value); -}; - -/** - * Get JSX element tag name - * - * @param path Path - */ -const getTagName = (path) => path.get('name.name').node; - -/** - * Get JSX element type - * - * @param t - * @param path Path - */ -const getType = (t, path) => { - const typePath = path - .get('attributes') - .find( - (attributePath) => t.isJSXAttribute(attributePath) - && t.isJSXIdentifier(attributePath.get('name')) - && attributePath.get('name.name').node === 'type' - && t.isStringLiteral(attributePath.get('value')), - ); - - return typePath ? typePath.get('value.value').node : ''; -}; - -/** - * @param t - * Transform vModel -*/ -const getModelDirective = (t, path, state, value) => { - const tag = getTagName(path); - const type = getType(t, path); - - addProp(path, t.jsxSpreadAttribute( - t.objectExpression([ - t.objectProperty( - t.stringLiteral('onUpdate:modelValue'), - t.arrowFunctionExpression( - [t.identifier('$event')], - t.assignmentExpression('=', value, t.identifier('$event')), - ), - ), - ]), - )); - - if (checkIsComponent(t, path)) { - addProp(path, t.jsxAttribute(t.jsxIdentifier('modelValue'), t.jsxExpressionContainer(value))); - return null; - } - - let modelToUse; - switch (tag) { - case 'select': - modelToUse = createIdentifier(t, state, 'vModelSelect'); - break; - case 'textarea': - modelToUse = createIdentifier(t, state, 'vModelText'); - break; - default: - switch (type) { - case 'checkbox': - modelToUse = createIdentifier(t, state, 'vModelCheckbox'); - break; - case 'radio': - modelToUse = createIdentifier(t, state, 'vModelRadio'); - break; - default: - modelToUse = createIdentifier(t, state, 'vModelText'); - } - } - - return modelToUse; -}; - - -/** - * Parse vModel metadata - * - * @param t - * @param path JSXAttribute - * @returns null | Object<{ modifiers: Set, valuePath: Path}> - */ -const parseVModel = (t, path) => { - if (t.isJSXNamespacedName(path.get('name')) || !startsWithCamel(path.get('name.name').node, 'v-model')) { - return null; - } - - if (!t.isJSXExpressionContainer(path.get('value'))) { - throw new Error('You have to use JSX Expression inside your v-model'); - } - - const modifiers = path.get('name.name').node.split('_'); - modifiers.shift(); - - return { - modifiers: new Set(modifiers), - value: path.get('value.expression').node, - }; -}; - -export default (t) => ({ - JSXAttribute: { - exit(path, state) { - const parsed = parseVModel(t, path); - if (!parsed) { - return; - } - - if (!state.get('vue')) { - state.set('vue', addNamespace(path, 'vue')); - } - - const { modifiers, value } = parsed; - - const parent = path.parentPath; - // v-model={xx} --> v-_model={[directive, xx, void 0, { a: true, b: true }]} - const directive = getModelDirective(t, parent, state, value); - if (directive) { - path.replaceWith( - t.jsxAttribute( - t.jsxIdentifier('_model'), // TODO - t.jsxExpressionContainer( - t.arrayExpression([ - directive, - value, - modifiers.size && t.unaryExpression('void', t.numericLiteral(0), true), - modifiers.size && t.objectExpression( - [...modifiers].map( - (modifier) => t.objectProperty( - t.identifier(modifier), - t.booleanLiteral(true), - ), - ), - ), - ].filter(Boolean)), - ), - ), - ); - } else { - path.remove(); - } - }, - }, -}); diff --git a/packages/babel-plugin-jsx/src/transform-vue-jsx.js b/packages/babel-plugin-jsx/src/transform-vue-jsx.js index d2028d6..b8b4c60 100644 --- a/packages/babel-plugin-jsx/src/transform-vue-jsx.js +++ b/packages/babel-plugin-jsx/src/transform-vue-jsx.js @@ -10,11 +10,12 @@ import { transformJSXText, transformJSXExpressionContainer, transformJSXSpreadChild, + parseDirectives, + isFragment, } from './utils'; const xlinkRE = /^xlink([A-Z])/; -const onRE = /^on[A-Z][a-z]+$/; -const rootAttributes = ['class', 'style']; +const onRE = /^on[^a-z]/; const isOn = (key) => onRE.test(key); @@ -22,28 +23,13 @@ const transformJSXSpreadAttribute = (t, path, mergeArgs) => { const argument = path.get('argument').node; const { properties } = argument; if (!properties) { - return t.spreadElement(argument); + // argument is an Identifier + mergeArgs.push(argument); + } else { + mergeArgs.push(t.objectExpression(properties)); } - return t.spreadElement(t.objectExpression(properties.filter((property) => { - const { key, value } = property; - const name = key.value; - if (rootAttributes.includes(name)) { - mergeArgs.push( - t.objectExpression([ - t.objectProperty( - t.stringLiteral(name), - value, - ), - ]), - ); - return false; - } - return true; - }))); }; -const needToMerge = (name) => rootAttributes.includes(name) || isOn(name); - const getJSXAttributeValue = (t, path) => { const valuePath = path.get('value'); if (valuePath.isJSXElement()) { @@ -81,28 +67,68 @@ const isConstant = (t, path) => { return false; }; +const mergeAsArray = (t, existing, incoming) => { + if (t.isArrayExpression(existing.value)) { + existing.value.elements.push(incoming.value); + } else { + existing.value = t.arrayExpression([ + existing.value, + incoming.value, + ]); + } +}; + +const dedupeProperties = (t, properties = []) => { + const knownProps = new Map(); + const deduped = []; + properties.forEach((prop) => { + const { key: { value: name } = {} } = prop; + const existing = knownProps.get(name); + if (existing) { + if (name === 'style' || name === 'class' || name.startsWith('on')) { + mergeAsArray(t, existing, prop); + } + } else { + knownProps.set(name, prop); + deduped.push(prop); + } + }); + + return deduped; +}; + const buildProps = (t, path, state) => { + const tag = getTag(t, path); const isComponent = checkIsComponent(t, path.get('openingElement')); const props = path.get('openingElement').get('attributes'); const directives = []; + const dynamicPropNames = new Set(); + + let patchFlag = 0; + + if (isFragment(t, path.get('openingElement.name'))) { + patchFlag |= PatchFlags.STABLE_FRAGMENT; + } + if (props.length === 0) { return { + tag, props: t.nullLiteral(), directives, + patchFlag, + dynamicPropNames, }; } - const propsExpression = []; + const properties = []; // patchFlag analysis - let patchFlag = 0; let hasRef = false; let hasClassBinding = false; let hasStyleBinding = false; let hasHydrationEventBinding = false; let hasDynamicKeys = false; - const dynamicPropNames = []; const mergeArgs = []; props @@ -110,10 +136,6 @@ const buildProps = (t, path, state) => { if (prop.isJSXAttribute()) { let name = getJSXAttributeName(t, prop); - if (name === '_model') { - name = 'onUpdate:modelValue'; - } - const attributeValue = getJSXAttributeValue(t, prop); if (!isConstant(t, attributeValue) || name === 'ref') { @@ -139,69 +161,85 @@ const buildProps = (t, path, state) => { name !== 'key' && !isDirective(name) && name !== 'on' - && !dynamicPropNames.includes(name) ) { - dynamicPropNames.push(name); + dynamicPropNames.add(name); } } if (state.opts.transformOn && (name === 'on' || name === 'nativeOn')) { - const transformOn = addDefault( - path, - '@ant-design-vue/babel-helper-vue-transform-on', - { nameHint: '_transformOn' }, - ); + if (!state.get('transformOn')) { + state.set('transformOn', addDefault( + path, + '@ant-design-vue/babel-helper-vue-transform-on', + { nameHint: '_transformOn' }, + )); + } mergeArgs.push(t.callExpression( - transformOn, + state.get('transformOn'), [attributeValue || t.booleanLiteral(true)], )); return; } - if (isDirective(name) || name === 'onUpdate:modelValue') { - if (name === 'onUpdate:modelValue') { - directives.push(attributeValue); + if (isDirective(name)) { + const { directive, modifiers, directiveName } = parseDirectives( + t, { + tag, + isComponent, + name, + path: prop, + state, + value: attributeValue, + }, + ); + + if (directive) { + directives.push(t.arrayExpression(directive)); } else { - const directiveName = name.startsWith('v-') - ? name.replace('v-', '') - : name.replace(`v${name[1]}`, name[1].toLowerCase()); - if (directiveName === 'show') { - directives.push(t.arrayExpression([ - createIdentifier(t, state, 'vShow'), - attributeValue, - ])); - } else { - directives.push(t.arrayExpression([ - t.callExpression(createIdentifier(t, state, 'resolveDirective'), [ - t.stringLiteral(directiveName), - ]), - attributeValue, - ])); + // must be v-model and is a component + properties.push(t.objectProperty( + t.stringLiteral('modelValue'), + attributeValue, + )); + + dynamicPropNames.add('modelValue'); + + if (modifiers.size) { + properties.push(t.objectProperty( + t.stringLiteral('modelModifiers'), + t.objectExpression( + [...modifiers].map((modifier) => ( + t.objectProperty( + t.stringLiteral(modifier), + t.booleanLiteral(true), + ) + )), + ), + )); } } - return; - } - if (needToMerge(name)) { - mergeArgs.push( - t.objectExpression([ - t.objectProperty( - t.stringLiteral( - name, - ), - attributeValue, + + if (directiveName === 'model') { + properties.push(t.objectProperty( + t.stringLiteral('onUpdate:modelValue'), + t.arrowFunctionExpression( + [t.identifier('$event')], + t.assignmentExpression('=', attributeValue, t.identifier('$event')), ), - ]), - ); + )); + + dynamicPropNames.add('onUpdate:modelValue'); + } return; } if (name.match(xlinkRE)) { name = name.replace(xlinkRE, (_, firstCharacter) => `xlink:${firstCharacter.toLowerCase()}`); } - propsExpression.push(t.objectProperty( + properties.push(t.objectProperty( t.stringLiteral(name), attributeValue || t.booleanLiteral(true), )); } else { hasDynamicKeys = true; - propsExpression.push(transformJSXSpreadAttribute(t, prop, mergeArgs)); + transformJSXSpreadAttribute(t, prop, mergeArgs); } }); @@ -215,7 +253,7 @@ const buildProps = (t, path, state) => { if (hasStyleBinding) { patchFlag |= PatchFlags.STYLE; } - if (dynamicPropNames.length) { + if (dynamicPropNames.size) { patchFlag |= PatchFlags.PROPS; } if (hasHydrationEventBinding) { @@ -230,14 +268,42 @@ const buildProps = (t, path, state) => { patchFlag |= PatchFlags.NEED_PATCH; } + let propsExpression; + + if (mergeArgs.length) { + if (properties.length) { + mergeArgs.push(...dedupeProperties(t, properties)); + } + if (mergeArgs.length > 1) { + const exps = []; + const objectProperties = []; + mergeArgs.forEach((arg) => { + if (t.isIdentifier(arg) || t.isExpression(arg)) { + exps.push(arg); + } else { + objectProperties.push(arg); + } + }); + propsExpression = t.callExpression( + createIdentifier(t, state, 'mergeProps'), + [ + ...exps, + objectProperties.length + && t.objectExpression(objectProperties), + ].filter(Boolean), + ); + } else { + // single no need for a mergeProps call + // eslint-disable-next-line prefer-destructuring + propsExpression = mergeArgs[0]; + } + } else if (properties.length) { + propsExpression = t.objectExpression(dedupeProperties(t, properties)); + } + return { - props: mergeArgs.length ? t.callExpression( - createIdentifier(t, state, 'mergeProps'), - [ - ...mergeArgs, - propsExpression.length && t.objectExpression(propsExpression), - ].filter(Boolean), - ) : t.objectExpression(propsExpression), + tag, + props: propsExpression, directives, patchFlag, dynamicPropNames, @@ -270,19 +336,19 @@ const getChildren = (t, paths) => paths throw new Error(`getChildren: ${path.type} is not supported`); }).filter((value) => ( value !== undefined - && value !== null - && !t.isJSXEmptyExpression(value) + && value !== null + && !t.isJSXEmptyExpression(value) )); const transformJSXElement = (t, path, state) => { - const tag = getTag(t, path); const children = t.arrayExpression(getChildren(t, path.get('children'))); const { + tag, props, directives, patchFlag, - dynamicPropNames = [], + dynamicPropNames, } = buildProps(t, path, state); const flagNames = Object.keys(PatchFlagNames) @@ -292,12 +358,17 @@ const transformJSXElement = (t, path, state) => { .join(', '); const isComponent = checkIsComponent(t, path.get('openingElement')); + const child = children.elements.length === 1 ? children.elements[0] : children; + if (state.opts.compatibleProps && !state.get('compatibleProps')) { + state.set('compatibleProps', addDefault( + path, '@ant-design-vue/babel-helper-vue-compatible-props', { nameHint: '_compatibleProps' }, + )); + } + const createVNode = t.callExpression(createIdentifier(t, state, 'createVNode'), [ tag, - state.opts.compatibleProps ? t.callExpression(addDefault( - path, '@ant-design-vue/babel-helper-vue-compatible-props', { nameHint: '_compatibleProps' }, - ), [props]) : props, - children.elements.length + state.opts.compatibleProps ? t.callExpression(state.get('compatibleProps'), [props]) : props, + children.elements[0] ? ( isComponent ? t.objectExpression([ @@ -306,16 +377,25 @@ const transformJSXElement = (t, path, state) => { t.callExpression(createIdentifier(t, state, 'withCtx'), [ t.arrowFunctionExpression( [], - children, + t.isStringLiteral(child) + ? t.callExpression( + createIdentifier(t, state, 'createTextVNode'), + [child], + ) + : child, ), ]), ), + t.objectProperty( + t.identifier('_'), + t.numericLiteral(1), + ), ]) - : children + : child ) : t.nullLiteral(), - patchFlag && t.addComment(t.numericLiteral(patchFlag), 'leading', ` ${flagNames} `), - dynamicPropNames.length - && t.arrayExpression(dynamicPropNames.map((name) => t.stringLiteral(name))), + patchFlag && t.addComment(t.numericLiteral(patchFlag), 'trailing', ` ${flagNames} `, false), + dynamicPropNames.size + && t.arrayExpression([...dynamicPropNames.keys()].map((name) => t.stringLiteral(name))), ].filter(Boolean)); if (!directives.length) { diff --git a/packages/babel-plugin-jsx/src/utils.js b/packages/babel-plugin-jsx/src/utils.js index 2bb615c..74a85f3 100644 --- a/packages/babel-plugin-jsx/src/utils.js +++ b/packages/babel-plugin-jsx/src/utils.js @@ -43,6 +43,15 @@ const createIdentifier = (t, state, id) => t.memberExpression(state.get('vue'), const isDirective = (src) => src.startsWith('v-') || (src.startsWith('v') && src.length >= 2 && src[1] >= 'A' && src[1] <= 'Z'); +/** + * Check if a JSXOpeningElement is fragment + * @param {*} t + * @param {*} path + * @returns boolean + */ +const isFragment = (t, path) => t.isJSXMemberExpression(path) + && path.node.property.name; + /** * Check if a JSXOpeningElement is a component * @@ -54,7 +63,7 @@ const checkIsComponent = (t, path) => { const namePath = path.get('name'); if (t.isJSXMemberExpression(namePath)) { - return namePath.node.property.name !== 'Fragment'; // For withCtx + return !isFragment(t, namePath); // For withCtx } const tag = namePath.get('name').node; @@ -180,6 +189,102 @@ const transformJSXExpressionContainer = (path) => path.get('expression').node; */ const transformJSXSpreadChild = (t, path) => t.spreadElement(path.get('expression').node); +/** + * Get JSX element type + * + * @param t + * @param path Path + */ +const getType = (t, path) => { + const typePath = path + .get('attributes') + .find( + (attributePath) => t.isJSXAttribute(attributePath) + && t.isJSXIdentifier(attributePath.get('name')) + && attributePath.get('name.name').node === 'type' + && t.isStringLiteral(attributePath.get('value')), + ); + + return typePath ? typePath.get('value.value').node : ''; +}; + +const resolveDirective = (t, path, state, tag, directiveName) => { + if (directiveName === 'show') { + return createIdentifier(t, state, 'vShow'); + } if (directiveName === 'model') { + let modelToUse; + const type = getType(t, path.parentPath); + switch (tag.value) { + case 'select': + modelToUse = createIdentifier(t, state, 'vModelSelect'); + break; + case 'textarea': + modelToUse = createIdentifier(t, state, 'vModelText'); + break; + default: + switch (type) { + case 'checkbox': + modelToUse = createIdentifier(t, state, 'vModelCheckbox'); + break; + case 'radio': + modelToUse = createIdentifier(t, state, 'vModelRadio'); + break; + default: + modelToUse = createIdentifier(t, state, 'vModelText'); + } + } + return modelToUse; + } + return t.callExpression( + createIdentifier(t, state, 'resolveDirective'), [ + t.stringLiteral(directiveName), + ], + ); +}; + +/** + * Parse directives metadata + * + * @param t + * @param path JSXAttribute + * @returns null | Object<{ modifiers: Set, valuePath: Path}> + */ +const parseDirectives = (t, { + name, path, value, state, tag, isComponent, +}) => { + const modifiers = name.split('_'); + const directiveName = modifiers.shift() + .replace(/^v/, '') + .replace(/^-/, '') + .replace(/^\S/, (s) => s.toLowerCase()); + + if (directiveName === 'model' && !t.isJSXExpressionContainer(path.get('value'))) { + throw new Error('You have to use JSX Expression inside your v-model'); + } + + const modifiersSet = new Set(modifiers); + + const hasDirective = directiveName !== 'model' || (directiveName === 'model' && !isComponent); + + return { + directiveName, + modifiers: new Set(modifiers), + directive: hasDirective ? [ + resolveDirective(t, path, state, tag, directiveName), + value, + modifiersSet.size && t.unaryExpression('void', t.numericLiteral(0), true), + modifiersSet.size && t.objectExpression( + [...modifiersSet].map( + (modifier) => t.objectProperty( + t.identifier(modifier), + t.booleanLiteral(true), + ), + ), + ), + ].filter(Boolean) : undefined, + }; +}; + export { createIdentifier, @@ -193,4 +298,6 @@ export { transformJSXExpressionContainer, PatchFlags, PatchFlagNames, + parseDirectives, + isFragment, };