perf: replace h with createVNode for PatchFlags (#6)

* chore: disable `no-bitwise` in eslint

* feat: replace h with createVNode for PatchFlags

* fix: vModel modifiers shift

* chore: rename v-_model to _model in sugar-v-model

* fix: hasRef will not always be false

* feat: Check if an attribute value is constant

* chore: pass null when children is empty

* chore: describe Transform JSX in test

* chore: add describe Patch Flags

* perf: import compatibleProps when opts.compatibleProps is true

* test: add coverage report (#7)

* refactor: cjs to esModule

Co-authored-by: Haoqun Jiang <haoqunjiang@gmail.com>
This commit is contained in:
Amour1688 2020-06-07 17:22:42 +08:00 committed by GitHub
parent 88bf7cca93
commit 4c34cf1d5d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 902 additions and 547 deletions

1
.eslintignore Normal file
View File

@ -0,0 +1 @@
dist

View File

@ -19,5 +19,7 @@ module.exports = {
'no-use-before-define': [0],
'no-plusplus': [0],
'import/no-extraneous-dependencies': [0],
'consistent-return': [0],
'no-bitwise': [0]
},
};

2
.gitignore vendored
View File

@ -102,3 +102,5 @@ dist
# TernJS port file
.tern-port
dist

View File

@ -3,11 +3,12 @@ module.exports = {
[
'@babel/env',
{
// "modules": "cjs"
// modules: 'cjs',
},
],
],
plugins: [
['./src/index.js', { transformOn: true }],
/* eslint-disable-next-line global-require */
[require('./dist/index.js'), { transformOn: true }],
],
};

View File

@ -11,9 +11,10 @@
"url": "git+https://github.com/vueComponent/jsx.git"
},
"scripts": {
"dev": "webpack-dev-server",
"dev": "npm run build && webpack-dev-server",
"build": "rollup -c",
"lint": "eslint --ext .js src",
"test": "jest"
"test": "jest --coverage"
},
"bugs": {
"url": "https://github.com/vueComponent/jsx/issues"
@ -30,12 +31,14 @@
"devDependencies": {
"@babel/core": "^7.9.6",
"@babel/preset-env": "^7.9.6",
"@rollup/plugin-babel": "^5.0.3",
"@vue/compiler-dom": "^3.0.0-beta.14",
"@vue/test-utils": "^2.0.0-alpha.6",
"babel-jest": "^26.0.1",
"babel-loader": "^8.1.0",
"jest": "^26.0.1",
"regenerator-runtime": "^0.13.5",
"rollup": "^2.13.1",
"vue": "^3.0.0-beta.14",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11",

View File

@ -0,0 +1,26 @@
import babel from '@rollup/plugin-babel';
export default {
input: 'src/index.js',
plugins: [
babel({
presets: [
[
'@babel/preset-env',
{
targets: {
node: 8,
},
modules: false,
},
],
],
}),
],
output: [
{
file: 'dist/index.js',
format: 'cjs',
},
],
};

View File

@ -1,9 +1,9 @@
const syntaxJsx = require('@babel/plugin-syntax-jsx').default;
const tranformVueJSX = require('./transform-vue-jsx');
const sugarVModel = require('./sugar-v-model');
const sugarFragment = require('./sugar-fragment');
import syntaxJsx from '@babel/plugin-syntax-jsx';
import tranformVueJSX from './transform-vue-jsx';
import sugarVModel from './sugar-v-model';
import sugarFragment from './sugar-fragment';
module.exports = ({ types: t }) => ({
export default ({ types: t }) => ({
name: 'babel-plugin-jsx',
inherits: syntaxJsx,
visitor: {

View File

@ -1,22 +1,30 @@
const helperModuleImports = require('@babel/helper-module-imports');
import { addNamespace } from '@babel/helper-module-imports';
const transformFragment = (t, path, { name }) => {
const transformFragment = (t, path, Fragment) => {
const children = path.get('children') || [];
return t.jsxElement(
t.jsxOpeningElement(t.jsxIdentifier(name), []),
t.jsxClosingElement(t.jsxIdentifier(name)),
t.jsxOpeningElement(Fragment, []),
t.jsxClosingElement(Fragment),
children.map(({ node }) => node),
false,
);
};
module.exports = (t) => ({
export default (t) => ({
JSXFragment: {
enter(path) {
if (!path.vueFragment) {
path.vueFragment = helperModuleImports.addNamed(path, 'Fragment', 'vue');
enter(path, state) {
if (!state.get('vue')) {
state.set('vue', addNamespace(path, 'vue'));
}
path.replaceWith(transformFragment(t, path, path.vueFragment));
path.replaceWith(
transformFragment(
t, path,
t.jsxMemberExpression(
t.jsxIdentifier(state.get('vue').name),
t.jsxIdentifier('Fragment'),
),
),
);
},
},
});

View File

@ -1,8 +1,6 @@
const htmlTags = require('html-tags');
const svgTags = require('svg-tags');
const camelCase = require('camelcase');
const { addNamed } = require('@babel/helper-module-imports');
import camelCase from 'camelcase';
import { addNamespace } from '@babel/helper-module-imports';
import { createIdentifier, checkIsComponent } from './utils';
const cachedCamelCase = (() => {
const cache = Object.create(null);
@ -55,30 +53,11 @@ const getType = (t, path) => {
return typePath ? typePath.get('value.value').node : '';
};
/**
* Check if a JSXOpeningElement is a component
*
* @param t
* @param path JSXOpeningElement
* @returns boolean
*/
const isComponent = (t, path) => {
const name = path.get('name');
if (t.isJSXMemberExpression(name)) {
return true;
}
const tag = name.get('name').node;
return !htmlTags.includes(tag) && !svgTags.includes(tag);
};
/**
* @param t
* Transform vModel
*/
const getModelDirective = (t, path, value) => {
const getModelDirective = (t, path, state, value) => {
const tag = getTagName(path);
const type = getType(t, path);
@ -94,7 +73,7 @@ const getModelDirective = (t, path, value) => {
]),
));
if (isComponent(t, path)) {
if (checkIsComponent(t, path)) {
addProp(path, t.jsxAttribute(t.jsxIdentifier('modelValue'), t.jsxExpressionContainer(value)));
return null;
}
@ -102,35 +81,21 @@ const getModelDirective = (t, path, value) => {
let modelToUse;
switch (tag) {
case 'select':
if (!path.vueVModelSelect) {
path.vueVModelSelect = addNamed(path, 'vModelSelect', 'vue');
}
modelToUse = path.vueVModelSelect;
modelToUse = createIdentifier(t, state, 'vModelSelect');
break;
case 'textarea':
if (!path.vueVModelText) {
path.vueVModelText = addNamed(path, 'vModelText', 'vue');
}
modelToUse = createIdentifier(t, state, 'vModelText');
break;
default:
switch (type) {
case 'checkbox':
if (!path.vueVModelCheckbox) {
path.vueVModelCheckbox = addNamed(path, 'vModelCheckbox', 'vue');
}
modelToUse = path.vueVModelCheckbox;
modelToUse = createIdentifier(t, state, 'vModelCheckbox');
break;
case 'radio':
if (!path.vueVModelRadio) {
path.vueVModelRadio = addNamed(path, 'vModelRadio', 'vue');
}
modelToUse = path.vueVModelRadio;
modelToUse = createIdentifier(t, state, 'vModelRadio');
break;
default:
if (!path.vueVModelText) {
path.vueVModelText = addNamed(path, 'vModelText', 'vue');
}
modelToUse = path.vueVModelText;
modelToUse = createIdentifier(t, state, 'vModelText');
}
}
@ -155,6 +120,7 @@ const parseVModel = (t, path) => {
}
const modifiers = path.get('name.name').node.split('_');
modifiers.shift();
return {
modifiers: new Set(modifiers),
@ -162,23 +128,27 @@ const parseVModel = (t, path) => {
};
};
module.exports = (t) => ({
export default (t) => ({
JSXAttribute: {
exit(path) {
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, value);
const directive = getModelDirective(t, parent, state, value);
if (directive) {
path.replaceWith(
t.jsxAttribute(
t.jsxIdentifier('v-_model'), // TODO
t.jsxIdentifier('_model'), // TODO
t.jsxExpressionContainer(
t.arrayExpression([
directive,

View File

@ -1,69 +1,48 @@
const htmlTags = require('html-tags');
const svgTags = require('svg-tags');
const { addNamed, addDefault } = require('@babel/helper-module-imports');
import { addDefault, addNamespace } from '@babel/helper-module-imports';
import {
createIdentifier,
PatchFlags,
PatchFlagNames,
isDirective,
checkIsComponent,
getTag,
getJSXAttributeName,
transformJSXText,
transformJSXExpressionContainer,
transformJSXSpreadChild,
} from './utils';
const xlinkRE = /^xlink([A-Z])/;
const eventRE = /^on[A-Z][a-z]+$/;
const onRE = /^on[A-Z][a-z]+$/;
const rootAttributes = ['class', 'style'];
const isOn = (key) => onRE.test(key);
/**
* Checks if string is describing a directive
* @param src string
*/
const isDirective = (src) => src.startsWith('v-')
|| (src.startsWith('v') && src.length >= 2 && src[1] >= 'A' && src[1] <= 'Z');
/**
* Transform JSXMemberExpression to MemberExpression
* @param t
* @param path JSXMemberExpression
* @returns MemberExpression
*/
const transformJSXMemberExpression = (t, path) => {
const objectPath = path.get('object');
const propertyPath = path.get('property');
const transformedObject = objectPath.isJSXMemberExpression()
? transformJSXMemberExpression(t, objectPath)
: objectPath.isJSXIdentifier()
? t.identifier(objectPath.node.name)
: t.nullLiteral();
const transformedProperty = t.identifier(propertyPath.get('name').node);
return t.memberExpression(transformedObject, transformedProperty);
};
/**
* Get tag (first attribute for h) from JSXOpeningElement
* @param t
* @param path JSXOpeningElement
* @returns Identifier | StringLiteral | MemberExpression
*/
const getTag = (t, path) => {
const namePath = path.get('openingElement').get('name');
if (namePath.isJSXIdentifier()) {
const { name } = namePath.node;
if (path.scope.hasBinding(name) && !htmlTags.includes(name) && !svgTags.includes(name)) {
return t.identifier(name);
const transformJSXSpreadAttribute = (t, path, mergeArgs) => {
const argument = path.get('argument').node;
const { properties } = argument;
if (!properties) {
return t.spreadElement(argument);
}
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 t.stringLiteral(name);
}
if (namePath.isJSXMemberExpression()) {
return transformJSXMemberExpression(t, namePath);
}
throw new Error(`getTag: ${namePath.type} is not supported`);
return true;
})));
};
const getJSXAttributeName = (t, path) => {
const nameNode = path.node.name;
if (t.isJSXIdentifier(nameNode)) {
return nameNode.name;
}
return `${nameNode.namespace.name}:${nameNode.name.name}`;
};
const needToMerge = (name) => rootAttributes.includes(name) || isOn(name);
const getJSXAttributeValue = (t, path) => {
const valuePath = path.get('value');
@ -80,183 +59,191 @@ const getJSXAttributeValue = (t, path) => {
return null;
};
const transformJSXAttribute = (t, path, state, attributesToMerge, directives) => {
let name = getJSXAttributeName(t, path);
const attributeValue = getJSXAttributeValue(t, path);
if (state.opts.transformOn && (name === 'on' || name === 'nativeOn')) {
const transformOn = addDefault(path, '@ant-design-vue/babel-helper-vue-transform-on', { nameHint: '_transformOn' });
attributesToMerge.push(t.callExpression(
transformOn,
[attributeValue || t.booleanLiteral(true)],
));
return null;
/**
* Check if an attribute value is constant
* @param t
* @param path
* @returns boolean
*/
const isConstant = (t, path) => {
if (t.isIdentifier(path)) {
return path.name === 'undefined';
}
if (isDirective(name)) {
const directiveName = name.startsWith('v-')
? name.replace('v-', '')
: name.replace(`v${name[1]}`, name[1].toLowerCase());
if (directiveName === '_model') {
directives.push(attributeValue);
} else if (directiveName === 'show') {
directives.push(t.arrayExpression([
state.vShow,
attributeValue,
]));
} else {
directives.push(t.arrayExpression([
t.callExpression(state.resolveDirective, [
t.stringLiteral(directiveName),
]),
attributeValue,
]));
}
return null;
if (t.isArrayExpression(path)) {
return path.elements.every((element) => isConstant(t, element));
}
if (rootAttributes.includes(name) || eventRE.test(name)) {
attributesToMerge.push(
t.objectExpression([
t.objectProperty(
t.stringLiteral(
name,
),
attributeValue,
),
]),
);
return null;
if (t.isObjectExpression(path)) {
return path.properties.every((property) => isConstant(t, property.value));
}
if (name.match(xlinkRE)) {
name = name.replace(xlinkRE, (_, firstCharacter) => `xlink:${firstCharacter.toLowerCase()}`);
}
return t.objectProperty(
t.stringLiteral(
name,
),
attributeValue || t.booleanLiteral(true),
);
};
const transformJSXSpreadAttribute = (t, path, attributesToMerge) => {
const argument = path.get('argument').node;
const { properties } = argument;
if (!properties) {
return t.spreadElement(argument);
}
return t.spreadElement(t.objectExpression(properties.filter((property) => {
const { key, value } = property;
const name = key.value;
if (rootAttributes.includes(name)) {
attributesToMerge.push(
t.objectExpression([
t.objectProperty(
t.stringLiteral(name),
value,
),
]),
);
return false;
}
if (t.isLiteral(path)) {
return true;
})));
}
return false;
};
const transformAttribute = (t, path, state, attributesToMerge, directives) => (
path.isJSXAttribute()
? transformJSXAttribute(t, path, state, attributesToMerge, directives)
: transformJSXSpreadAttribute(t, path, attributesToMerge));
const getAttributes = (t, path, state, directives) => {
const attributes = path.get('openingElement').get('attributes');
if (attributes.length === 0) {
return t.nullLiteral();
const buildProps = (t, path, state) => {
const isComponent = checkIsComponent(t, path.get('openingElement'));
const props = path.get('openingElement').get('attributes');
const directives = [];
if (props.length === 0) {
return {
props: t.nullLiteral(),
directives,
};
}
const attributesToMerge = [];
const attributeArray = [];
attributes
.forEach((attribute) => {
const attr = transformAttribute(t, attribute, state, attributesToMerge, directives);
if (attr) {
attributeArray.push(attr);
const propsExpression = [];
// 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
.forEach((prop) => {
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') {
if (
!isComponent
&& isOn(name)
// omit the flag for click handlers becaues hydration gives click
// dedicated fast path.
&& name.toLowerCase() !== 'onclick'
// omit v-model handlers
&& name !== 'onUpdate:modelValue'
) {
hasHydrationEventBinding = true;
}
if (name === 'ref') {
hasRef = true;
} else if (name === 'class' && !isComponent) {
hasClassBinding = true;
} else if (name === 'style' && !isComponent) {
hasStyleBinding = true;
} else if (
name !== 'key'
&& !isDirective(name)
&& name !== 'on'
&& !dynamicPropNames.includes(name)
) {
dynamicPropNames.push(name);
}
}
if (state.opts.transformOn && (name === 'on' || name === 'nativeOn')) {
const transformOn = addDefault(
path,
'@ant-design-vue/babel-helper-vue-transform-on',
{ nameHint: '_transformOn' },
);
mergeArgs.push(t.callExpression(
transformOn,
[attributeValue || t.booleanLiteral(true)],
));
return;
}
if (isDirective(name) || name === 'onUpdate:modelValue') {
if (name === 'onUpdate:modelValue') {
directives.push(attributeValue);
} 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,
]));
}
}
return;
}
if (needToMerge(name)) {
mergeArgs.push(
t.objectExpression([
t.objectProperty(
t.stringLiteral(
name,
),
attributeValue,
),
]),
);
return;
}
if (name.match(xlinkRE)) {
name = name.replace(xlinkRE, (_, firstCharacter) => `xlink:${firstCharacter.toLowerCase()}`);
}
propsExpression.push(t.objectProperty(
t.stringLiteral(name),
attributeValue || t.booleanLiteral(true),
));
} else {
hasDynamicKeys = true;
propsExpression.push(transformJSXSpreadAttribute(t, prop, mergeArgs));
}
});
return t.callExpression(
state.mergeProps,
[
...attributesToMerge,
t.objectExpression(attributeArray),
],
);
};
/**
* Transform JSXText to StringLiteral
* @param t
* @param path JSXText
* @returns StringLiteral
*/
const transformJSXText = (t, path) => {
const { node } = path;
const lines = node.value.split(/\r\n|\n|\r/);
let lastNonEmptyLine = 0;
for (let i = 0; i < lines.length; i++) {
if (lines[i].match(/[^ \t]/)) {
lastNonEmptyLine = i;
// patchFlag analysis
if (hasDynamicKeys) {
patchFlag |= PatchFlags.FULL_PROPS;
} else {
if (hasClassBinding) {
patchFlag |= PatchFlags.CLASS;
}
if (hasStyleBinding) {
patchFlag |= PatchFlags.STYLE;
}
if (dynamicPropNames.length) {
patchFlag |= PatchFlags.PROPS;
}
if (hasHydrationEventBinding) {
patchFlag |= PatchFlags.HYDRATE_EVENTS;
}
}
let str = '';
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const isFirstLine = i === 0;
const isLastLine = i === lines.length - 1;
const isLastNonEmptyLine = i === lastNonEmptyLine;
// replace rendered whitespace tabs with spaces
let trimmedLine = line.replace(/\t/g, ' ');
// trim whitespace touching a newline
if (!isFirstLine) {
trimmedLine = trimmedLine.replace(/^[ ]+/, '');
}
// trim whitespace touching an endline
if (!isLastLine) {
trimmedLine = trimmedLine.replace(/[ ]+$/, '');
}
if (trimmedLine) {
if (!isLastNonEmptyLine) {
trimmedLine += ' ';
}
str += trimmedLine;
}
if (
(patchFlag === 0 || patchFlag === PatchFlags.HYDRATE_EVENTS)
&& hasRef
) {
patchFlag |= PatchFlags.NEED_PATCH;
}
return str !== '' ? t.stringLiteral(str) : null;
return {
props: mergeArgs.length ? t.callExpression(
createIdentifier(t, state, 'mergeProps'),
[
...mergeArgs,
propsExpression.length && t.objectExpression(propsExpression),
].filter(Boolean),
) : t.objectExpression(propsExpression),
directives,
patchFlag,
dynamicPropNames,
};
};
/**
* Transform JSXExpressionContainer to Expression
* @param path JSXExpressionContainer
* @returns Expression
*/
const transformJSXExpressionContainer = (path) => path.get('expression').node;
/**
* Transform JSXSpreadChild
* @param t
* @param path JSXSpreadChild
* @returns SpreadElement
*/
const transformJSXSpreadChild = (t, path) => t.spreadElement(path.get('expression').node);
/**
* Get children from Array of JSX children
* @param t
@ -289,50 +276,64 @@ const getChildren = (t, paths) => paths
const transformJSXElement = (t, path, state) => {
const directives = [];
const tag = getTag(t, path);
const children = t.arrayExpression(getChildren(t, path.get('children')));
const attributes = getAttributes(t, path, state, directives);
const compatibleProps = addDefault(
path, '@ant-design-vue/babel-helper-vue-compatible-props', { nameHint: '_compatibleProps' },
);
const h = t.callExpression(state.h, [
const {
props,
directives,
patchFlag,
dynamicPropNames = [],
} = buildProps(t, path, state);
const flagNames = Object.keys(PatchFlagNames)
.map(Number)
.filter((n) => n > 0 && patchFlag & n)
.map((n) => PatchFlagNames[n])
.join(', ');
const isComponent = checkIsComponent(t, path.get('openingElement'));
const createVNode = t.callExpression(createIdentifier(t, state, 'createVNode'), [
tag,
state.opts.compatibleProps ? t.callExpression(compatibleProps, [attributes]) : attributes,
!t.isStringLiteral(tag) && !tag.name.includes('Fragment')
? t.objectExpression([
t.objectProperty(
t.identifier('default'),
t.callExpression(state.withCtx, [
t.arrowFunctionExpression(
[],
children,
state.opts.compatibleProps ? t.callExpression(addDefault(
path, '@ant-design-vue/babel-helper-vue-compatible-props', { nameHint: '_compatibleProps' },
), [props]) : props,
children.elements.length
? (
isComponent
? t.objectExpression([
t.objectProperty(
t.identifier('default'),
t.callExpression(createIdentifier(t, state, 'withCtx'), [
t.arrowFunctionExpression(
[],
children,
),
]),
),
]),
),
])
: children,
]);
])
: children
) : t.nullLiteral(),
patchFlag && t.addComment(t.numericLiteral(patchFlag), 'leading', ` ${flagNames} `),
dynamicPropNames.length
&& t.arrayExpression(dynamicPropNames.map((name) => t.stringLiteral(name))),
].filter(Boolean));
if (!directives.length) {
return h;
return createVNode;
}
return t.callExpression(state.withDirectives, [
h,
return t.callExpression(createIdentifier(t, state, 'withDirectives'), [
createVNode,
t.arrayExpression(directives),
]);
};
const imports = [
'h', 'mergeProps', 'withDirectives',
'resolveDirective', 'vShow', 'withCtx',
];
module.exports = (t) => ({
export default (t) => ({
JSXElement: {
exit(path, state) {
imports.forEach((m) => {
state[m] = addNamed(path, m, 'vue');
});
if (!state.get('vue')) {
state.set('vue', addNamespace(path, 'vue'));
}
path.replaceWith(
transformJSXElement(t, path, state),
);

View File

@ -0,0 +1,196 @@
import htmlTags from 'html-tags';
import svgTags from 'svg-tags';
const PatchFlags = {
TEXT: 1,
CLASS: 1 << 1,
STYLE: 1 << 2,
PROPS: 1 << 3,
FULL_PROPS: 1 << 4,
HYDRATE_EVENTS: 1 << 5,
STABLE_FRAGMENT: 1 << 6,
KEYED_FRAGMENT: 1 << 7,
UNKEYED_FRAGMENT: 1 << 8,
NEED_PATCH: 1 << 9,
DYNAMIC_SLOTS: 1 << 10,
HOISTED: -1,
BAIL: -2,
};
// dev only flag -> name mapping
const PatchFlagNames = {
[PatchFlags.TEXT]: 'TEXT',
[PatchFlags.CLASS]: 'CLASS',
[PatchFlags.STYLE]: 'STYLE',
[PatchFlags.PROPS]: 'PROPS',
[PatchFlags.FULL_PROPS]: 'FULL_PROPS',
[PatchFlags.HYDRATE_EVENTS]: 'HYDRATE_EVENTS',
[PatchFlags.STABLE_FRAGMENT]: 'STABLE_FRAGMENT',
[PatchFlags.KEYED_FRAGMENT]: 'KEYED_FRAGMENT',
[PatchFlags.UNKEYED_FRAGMENT]: 'UNKEYED_FRAGMENT',
[PatchFlags.NEED_PATCH]: 'NEED_PATCH',
[PatchFlags.DYNAMIC_SLOTS]: 'DYNAMIC_SLOTS',
[PatchFlags.HOISTED]: 'HOISTED',
[PatchFlags.BAIL]: 'BAIL',
};
const createIdentifier = (t, state, id) => t.memberExpression(state.get('vue'), t.identifier(id));
/**
* Checks if string is describing a directive
* @param src string
*/
const isDirective = (src) => src.startsWith('v-')
|| (src.startsWith('v') && src.length >= 2 && src[1] >= 'A' && src[1] <= 'Z');
/**
* Check if a JSXOpeningElement is a component
*
* @param t
* @param path JSXOpeningElement
* @returns boolean
*/
const checkIsComponent = (t, path) => {
const namePath = path.get('name');
if (t.isJSXMemberExpression(namePath)) {
return namePath.node.property.name !== 'Fragment'; // For withCtx
}
const tag = namePath.get('name').node;
return !htmlTags.includes(tag) && !svgTags.includes(tag);
};
/**
* Transform JSXMemberExpression to MemberExpression
* @param t
* @param path JSXMemberExpression
* @returns MemberExpression
*/
const transformJSXMemberExpression = (t, path) => {
const objectPath = path.get('object');
const propertyPath = path.get('property');
const transformedObject = objectPath.isJSXMemberExpression()
? transformJSXMemberExpression(t, objectPath)
: objectPath.isJSXIdentifier()
? t.identifier(objectPath.node.name)
: t.nullLiteral();
const transformedProperty = t.identifier(propertyPath.get('name').node);
return t.memberExpression(transformedObject, transformedProperty);
};
/**
* Get tag (first attribute for h) from JSXOpeningElement
* @param t
* @param path JSXOpeningElement
* @returns Identifier | StringLiteral | MemberExpression
*/
const getTag = (t, path) => {
const namePath = path.get('openingElement').get('name');
if (namePath.isJSXIdentifier()) {
const { name } = namePath.node;
if (path.scope.hasBinding(name) && !htmlTags.includes(name) && !svgTags.includes(name)) {
return t.identifier(name);
}
return t.stringLiteral(name);
}
if (namePath.isJSXMemberExpression()) {
return transformJSXMemberExpression(t, namePath);
}
throw new Error(`getTag: ${namePath.type} is not supported`);
};
const getJSXAttributeName = (t, path) => {
const nameNode = path.node.name;
if (t.isJSXIdentifier(nameNode)) {
return nameNode.name;
}
return `${nameNode.namespace.name}:${nameNode.name.name}`;
};
/**
* Transform JSXText to StringLiteral
* @param t
* @param path JSXText
* @returns StringLiteral
*/
const transformJSXText = (t, path) => {
const { node } = path;
const lines = node.value.split(/\r\n|\n|\r/);
let lastNonEmptyLine = 0;
for (let i = 0; i < lines.length; i++) {
if (lines[i].match(/[^ \t]/)) {
lastNonEmptyLine = i;
}
}
let str = '';
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const isFirstLine = i === 0;
const isLastLine = i === lines.length - 1;
const isLastNonEmptyLine = i === lastNonEmptyLine;
// replace rendered whitespace tabs with spaces
let trimmedLine = line.replace(/\t/g, ' ');
// trim whitespace touching a newline
if (!isFirstLine) {
trimmedLine = trimmedLine.replace(/^[ ]+/, '');
}
// trim whitespace touching an endline
if (!isLastLine) {
trimmedLine = trimmedLine.replace(/[ ]+$/, '');
}
if (trimmedLine) {
if (!isLastNonEmptyLine) {
trimmedLine += ' ';
}
str += trimmedLine;
}
}
return str !== '' ? t.stringLiteral(str) : null;
};
/**
* Transform JSXExpressionContainer to Expression
* @param path JSXExpressionContainer
* @returns Expression
*/
const transformJSXExpressionContainer = (path) => path.get('expression').node;
/**
* Transform JSXSpreadChild
* @param t
* @param path JSXSpreadChild
* @returns SpreadElement
*/
const transformJSXSpreadChild = (t, path) => t.spreadElement(path.get('expression').node);
export {
createIdentifier,
isDirective,
checkIsComponent,
transformJSXMemberExpression,
getTag,
getJSXAttributeName,
transformJSXText,
transformJSXSpreadChild,
transformJSXExpressionContainer,
PatchFlags,
PatchFlagNames,
};

View File

@ -0,0 +1,13 @@
import * as fs from 'fs';
import * as path from 'path';
import { transformSync } from '@babel/core';
import preset from '../babel.config';
test('coverage', () => {
const mainTest = fs.readFileSync(path.resolve(__dirname, './index.test.js'));
transformSync(mainTest, {
babelrc: false,
presets: [preset],
filename: 'index.test.js',
});
});

View File

@ -1,244 +1,322 @@
import { shallowMount } from '@vue/test-utils';
import { ref } from 'vue';
import { shallowMount, mount } from '@vue/test-utils';
test('should render with render function', () => {
const wrapper = shallowMount({
render() {
return <div>123</div>;
},
describe('Transform JSX', () => {
test('should render with render function', () => {
const wrapper = shallowMount({
render() {
return <div>123</div>;
},
});
expect(wrapper.text()).toBe('123');
});
test('should render with setup', () => {
const wrapper = shallowMount({
setup() {
return () => <div>123</div>;
},
});
expect(wrapper.text()).toBe('123');
});
test('Extracts attrs', () => {
const wrapper = shallowMount({
setup() {
return () => <div id="hi" dir="ltr" />;
},
});
expect(wrapper.element.id).toBe('hi');
expect(wrapper.element.dir).toBe('ltr');
});
test('Binds attrs', () => {
const id = 'foo';
const wrapper = shallowMount({
setup() {
return () => <div>{id}</div>;
},
});
expect(wrapper.text()).toBe('foo');
});
test('should not fallthrough with inheritAttrs: false', () => {
const Child = (props) => <div>{props.foo}</div>;
Child.inheritAttrs = false;
const wrapper = mount({
setup() {
return () => (
<Child class="parent" foo={1} />
);
},
});
expect(wrapper.text()).toBe('1');
});
test('Fragment', () => {
const Child = () => <div>123</div>;
Child.inheritAttrs = false;
const wrapper = mount({
setup() {
return () => (
<>
<Child />
<div>456</div>
</>
);
},
});
expect(wrapper.html()).toBe('<div>123</div><div>456</div>');
});
test('xlink:href', () => {
const wrapper = shallowMount({
setup() {
return () => <use xlinkHref={'#name'}></use>;
},
});
expect(wrapper.attributes()['xlink:href']).toBe('#name');
});
test('Merge class', () => {
const wrapper = shallowMount({
setup() {
return () => <div class="a" {...{ class: 'b' } } />;
},
});
expect(wrapper.html()).toBe('<div class="a b"></div>');
});
test('Merge style', () => {
const propsA = {
style: {
color: 'red',
},
};
const propsB = {
style: [
{
color: 'blue',
width: '200px',
},
{
width: '300px',
height: '300px',
},
],
};
const wrapper = shallowMount({
setup() {
return () => <div { ...propsA } { ...propsB } />;
},
});
expect(wrapper.html()).toBe('<div style="color: blue; width: 300px; height: 300px;"></div>');
});
test('JSXSpreadChild', () => {
const a = ['1', '2'];
const wrapper = shallowMount({
setup() {
return () => <div>{[...a]}</div>;
},
});
expect(wrapper.text()).toBe('12');
});
test('domProps input[value]', () => {
const val = 'foo';
const wrapper = shallowMount({
setup() {
return () => <input type="text" value={val} />;
},
});
expect(wrapper.html()).toBe('<input type="text">');
});
test('domProps input[checked]', () => {
const val = 'foo';
const wrapper = shallowMount({
setup() {
return () => <input checked={val} />;
},
});
expect(wrapper.vm.$.subTree.props.checked).toBe(val);
});
test('domProps option[selected]', () => {
const val = 'foo';
const wrapper = shallowMount({
render() {
return <option selected={val} />;
},
});
expect(wrapper.vm.$.subTree.props.selected).toBe(val);
});
test('domProps video[muted]', () => {
const val = 'foo';
const wrapper = shallowMount({
render() {
return <video muted={val} />;
},
});
expect(wrapper.vm.$.subTree.props.muted).toBe(val);
});
test('Spread (single object expression)', () => {
const props = {
innerHTML: 123,
other: '1',
};
const wrapper = shallowMount({
render() {
return <div {...props}></div>;
},
});
expect(wrapper.html()).toBe('<div other="1">123</div>');
});
test('Spread (mixed)', async () => {
const calls = [];
const data = {
id: 'hehe',
onClick() {
calls.push(3);
},
innerHTML: 2,
class: ['a', 'b'],
};
const wrapper = shallowMount({
setup() {
return () => (
<div
href="huhu"
{...data}
class={{ c: true }}
onClick={() => calls.push(4)}
hook-insert={() => calls.push(2)}
/>
);
},
});
expect(wrapper.attributes('id')).toBe('hehe');
expect(wrapper.attributes('href')).toBe('huhu');
expect(wrapper.text()).toBe('2');
expect(wrapper.classes()).toEqual(expect.arrayContaining(['a', 'b', 'c']));
await wrapper.trigger('click');
expect(calls).toEqual(expect.arrayContaining([3, 4]));
});
test('directive', () => {
const calls = [];
const customDirective = {
mounted() {
calls.push(1);
},
};
const wrapper = shallowMount(({
directives: { custom: customDirective },
setup() {
return () => (
<a
v-custom={{
value: 123,
modifiers: { modifier: true },
arg: 'arg',
}}
/>
);
},
}));
const node = wrapper.vm.$.subTree;
expect(calls).toEqual(expect.arrayContaining([1]));
expect(node.dirs).toHaveLength(1);
});
expect(wrapper.text()).toBe('123');
});
test('should render with setup', () => {
const wrapper = shallowMount({
setup() {
return () => <div>123</div>;
describe('Patch Flags', () => {
let renders = 0;
const Child = {
setup(props) {
return () => {
renders++;
return <div>{props.text}</div>;
};
},
});
expect(wrapper.text()).toBe('123');
});
test('Extracts attrs', () => {
const wrapper = shallowMount({
setup() {
return () => <div id="hi" dir="ltr" />;
},
});
expect(wrapper.element.id).toBe('hi');
expect(wrapper.element.dir).toBe('ltr');
});
test('Binds attrs', () => {
const id = 'foo';
const wrapper = shallowMount({
setup() {
return () => <div>{id}</div>;
},
});
expect(wrapper.text()).toBe('foo');
});
test('should not fallthrough with inheritAttrs: false', () => {
const Child = (props) => <div>{props.foo}</div>;
};
Child.inheritAttrs = false;
const wrapper = shallowMount({
setup() {
return () => (
<Child class="parent" foo={1} />
);
},
});
expect(wrapper.text()).toBe('1');
});
test('Fragment', () => {
const Child = () => <div>123</div>;
Child.inheritAttrs = false;
const wrapper = shallowMount({
setup() {
return () => (
<>
<Child />
<div>456</div>
</>
);
},
});
expect(wrapper.html()).toBe('<div>123</div><div>456</div>');
});
test('xlink:href', () => {
const wrapper = shallowMount({
setup() {
return () => <use xlinkHref={'#name'}></use>;
},
});
expect(wrapper.attributes()['xlink:href']).toBe('#name');
});
test('Merge class', () => {
const wrapper = shallowMount({
setup() {
return () => <div class="a" {...{ class: 'b' } } />;
},
});
expect(wrapper.html()).toBe('<div class="a b"></div>');
});
test('Merge style', () => {
const propsA = {
style: {
color: 'red',
},
};
const propsB = {
style: [
{
color: 'blue',
width: '200px',
it('should render when props change', async () => {
const wrapper = mount({
setup() {
const count = ref(0);
const inc = () => {
count.value++;
};
return () => (
<div onClick={inc}>
<Child text={count.value} />
</div>
);
},
{
width: '300px',
height: '300px',
});
expect(renders).toBe(1);
await wrapper.trigger('click');
expect(renders).toBe(2);
});
it('should not render with static props', async () => {
renders = 0;
const wrapper = mount({
setup() {
const count = ref(0);
const inc = () => {
count.value++;
};
return () => (
<div onClick={inc}>
<Child text={1} />
</div>
);
},
],
};
const wrapper = shallowMount({
setup() {
return () => <div { ...propsA } { ...propsB } />;
},
});
expect(wrapper.html()).toBe('<div style="color: blue; width: 300px; height: 300px;"></div>');
});
});
test('JSXSpreadChild', () => {
const a = ['1', '2'];
const wrapper = shallowMount({
setup() {
return () => <div>{[...a]}</div>;
},
});
expect(wrapper.text()).toBe('12');
});
test('domProps input[value]', () => {
const val = 'foo';
const wrapper = shallowMount({
setup() {
return () => <input type="text" value={val} />;
},
});
expect(wrapper.html()).toBe('<input type="text">');
});
test('domProps input[checked]', () => {
const val = 'foo';
const wrapper = shallowMount({
setup() {
return () => <input checked={val} />;
},
expect(renders).toBe(1);
await wrapper.trigger('click');
expect(renders).toBe(1);
});
expect(wrapper.vm.$.subTree.props.checked).toBe(val);
});
it('should not render when props does not change', async () => {
renders = 0;
const wrapper = mount({
setup() {
const count = ref(0);
const s = ref('a');
const inc = () => {
count.value++;
};
return () => (
<div onClick={inc}>
<Child text={s.value} />
{count.value}
</div>
);
},
});
test('domProps option[selected]', () => {
const val = 'foo';
const wrapper = shallowMount({
render() {
return <option selected={val} />;
},
await wrapper.trigger('click');
expect(renders).toBe(1);
});
expect(wrapper.vm.$.subTree.props.selected).toBe(val);
});
test('domProps video[muted]', () => {
const val = 'foo';
const wrapper = shallowMount({
render() {
return <video muted={val} />;
},
});
expect(wrapper.vm.$.subTree.props.muted).toBe(val);
});
test('Spread (single object expression)', () => {
const props = {
innerHTML: 123,
other: '1',
};
const wrapper = shallowMount({
render() {
return <div {...props}></div>;
},
});
expect(wrapper.html()).toBe('<div other="1">123</div>');
});
test('Spread (mixed)', async () => {
const calls = [];
const data = {
id: 'hehe',
onClick() {
calls.push(3);
},
innerHTML: 2,
class: ['a', 'b'],
};
const wrapper = shallowMount({
setup() {
return () => (
<div
href="huhu"
{...data}
class={{ c: true }}
onClick={() => calls.push(4)}
hook-insert={() => calls.push(2)}
/>
);
},
});
expect(wrapper.attributes('id')).toBe('hehe');
expect(wrapper.attributes('href')).toBe('huhu');
expect(wrapper.text()).toBe('2');
expect(wrapper.classes()).toEqual(expect.arrayContaining(['a', 'b', 'c']));
await wrapper.trigger('click');
expect(calls).toEqual(expect.arrayContaining([3, 4]));
});
test('directive', () => {
const calls = [];
const customDirective = {
mounted() {
calls.push(1);
},
};
const wrapper = shallowMount(({
directives: { custom: customDirective },
setup() {
return () => (
<a
v-custom={{
value: 123,
modifiers: { modifier: true },
arg: 'arg',
}}
/>
);
},
}));
const node = wrapper.vm.$.subTree;
expect(calls).toEqual(expect.arrayContaining([1]));
expect(node.dirs).toHaveLength(1);
});

View File

@ -144,7 +144,7 @@
dependencies:
"@babel/types" "^7.10.1"
"@babel/helper-module-imports@^7.0.0", "@babel/helper-module-imports@^7.10.1":
"@babel/helper-module-imports@^7.0.0", "@babel/helper-module-imports@^7.10.1", "@babel/helper-module-imports@^7.7.4":
version "7.10.1"
resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.10.1.tgz#dd331bd45bccc566ce77004e9d05fe17add13876"
integrity sha512-SFxgwYmZ3HZPyZwJRiVNLRHWuW2OgE5k2nrVs6D9Iv4PPnXVffuEHy83Sfx/l4SqF+5kyJXjAyUmrG7tNm+qVg==
@ -1886,6 +1886,23 @@
dependencies:
"@types/node" ">= 8"
"@rollup/plugin-babel@^5.0.3":
version "5.0.3"
resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.0.3.tgz#8d416865b0da79faf14e07c8d233abe0eac0753d"
integrity sha512-NlaPf4E6YFxeOCbqc+A2PTkB1BSy3rfKu6EJuQ1MGhMHpTVvMqKi6Rf0DlwtnEsTNK9LueUgsGEgp5Occ4KDVA==
dependencies:
"@babel/helper-module-imports" "^7.7.4"
"@rollup/pluginutils" "^3.0.8"
"@rollup/pluginutils@^3.0.8":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b"
integrity sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==
dependencies:
"@types/estree" "0.0.39"
estree-walker "^1.0.1"
picomatch "^2.2.2"
"@sinonjs/commons@^1.7.0":
version "1.8.0"
resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.0.tgz#c8d68821a854c555bba172f3b06959a0039b236d"
@ -1938,6 +1955,11 @@
resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0"
integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==
"@types/estree@0.0.39":
version "0.0.39"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f"
integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==
"@types/events@*":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7"
@ -4330,11 +4352,21 @@ estraverse@^5.1.0:
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.1.0.tgz#374309d39fd935ae500e7b92e8a6b4c720e59642"
integrity sha512-FyohXK+R0vE+y1nHLoBM7ZTyqRpqAlhdZHCWIWEviFLiGB8b04H6bQs8G+XTthacvT8VuwvteiP7RJSxMs8UEw==
estree-walker@^0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-0.6.1.tgz#53049143f40c6eb918b23671d1fe3219f3a1b362"
integrity sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==
estree-walker@^0.8.1:
version "0.8.1"
resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-0.8.1.tgz#6230ce2ec9a5cb03888afcaf295f97d90aa52b79"
integrity sha512-H6cJORkqvrNziu0KX2hqOMAlA2CiuAxHeGJXSIoKA/KLv229Dw806J3II6mKTm5xiDX1At1EXCfsOQPB+tMB+g==
estree-walker@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700"
integrity sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==
esutils@^2.0.2:
version "2.0.3"
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
@ -7853,7 +7885,7 @@ performance-now@^2.1.0:
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=
picomatch@^2.0.4, picomatch@^2.0.5, picomatch@^2.2.1:
picomatch@^2.0.4, picomatch@^2.0.5, picomatch@^2.2.1, picomatch@^2.2.2:
version "2.2.2"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad"
integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==
@ -8594,6 +8626,28 @@ ripemd160@^2.0.0, ripemd160@^2.0.1:
hash-base "^3.0.0"
inherits "^2.0.1"
rollup-plugin-babel@^4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/rollup-plugin-babel/-/rollup-plugin-babel-4.4.0.tgz#d15bd259466a9d1accbdb2fe2fff17c52d030acb"
integrity sha512-Lek/TYp1+7g7I+uMfJnnSJ7YWoD58ajo6Oarhlex7lvUce+RCKRuGRSgztDO3/MF/PuGKmUL5iTHKf208UNszw==
dependencies:
"@babel/helper-module-imports" "^7.0.0"
rollup-pluginutils "^2.8.1"
rollup-pluginutils@^2.8.1:
version "2.8.2"
resolved "https://registry.yarnpkg.com/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz#72f2af0748b592364dbd3389e600e5a9444a351e"
integrity sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==
dependencies:
estree-walker "^0.6.1"
rollup@^2.13.1:
version "2.13.1"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.13.1.tgz#06ac5be4f85df0b79f23cdfa90de63e78095a984"
integrity sha512-EiICynxIO1DTFmFn+/98gfaqCToK2nbjPjHJLuNvpcwc+P035VrXmJxi3JsOhqkdty+0cOEhJ26ceGTY3UPMPQ==
optionalDependencies:
fsevents "~2.1.2"
rsvp@^4.8.4:
version "4.8.5"
resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734"