feat: should support passing object slots via JSX children (#204)

* feat: should support passing object slots via JSX children

* feat: add unit test

* feat: remove `cloneDeep` of `isSlot`

* chore(deps): add `@babel/template`
This commit is contained in:
Amour1688 2020-12-07 23:05:41 +08:00 committed by GitHub
parent b443f847ca
commit 5b6323f22a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 296 additions and 50 deletions

View File

@ -11,11 +11,10 @@
"url": "git+https://github.com/vuejs/jsx-next.git" "url": "git+https://github.com/vuejs/jsx-next.git"
}, },
"scripts": { "scripts": {
"dev": "npm run build && webpack-dev-server",
"build": "tsc", "build": "tsc",
"lint": "eslint 'src/*.ts'", "lint": "eslint 'src/*.ts'",
"test": "npm run build && jest --coverage", "test": "yarn build && jest --coverage",
"prepublishOnly": "npm run build" "prepublishOnly": "yarn build"
}, },
"bugs": { "bugs": {
"url": "https://github.com/vuejs/jsx-next/issues" "url": "https://github.com/vuejs/jsx-next/issues"
@ -26,6 +25,7 @@
"dependencies": { "dependencies": {
"@babel/helper-module-imports": "^7.0.0", "@babel/helper-module-imports": "^7.0.0",
"@babel/plugin-syntax-jsx": "^7.0.0", "@babel/plugin-syntax-jsx": "^7.0.0",
"@babel/template": "^7.0.0",
"@babel/traverse": "^7.0.0", "@babel/traverse": "^7.0.0",
"@babel/types": "^7.0.0", "@babel/types": "^7.0.0",
"@vue/babel-helper-vue-transform-on": "^1.0.0-rc.2", "@vue/babel-helper-vue-transform-on": "^1.0.0-rc.2",
@ -48,4 +48,4 @@
"typescript": "^4.0.2", "typescript": "^4.0.2",
"vue": "3.0.0" "vue": "3.0.0"
} }
} }

View File

@ -1,5 +1,6 @@
import * as t from '@babel/types'; import * as t from '@babel/types';
import * as BabelCore from '@babel/core'; import * as BabelCore from '@babel/core';
import template from '@babel/template';
import syntaxJsx from '@babel/plugin-syntax-jsx'; import syntaxJsx from '@babel/plugin-syntax-jsx';
import { addNamed, isModule, addNamespace } from '@babel/helper-module-imports'; import { addNamed, isModule, addNamespace } from '@babel/helper-module-imports';
import { NodePath } from '@babel/traverse'; import { NodePath } from '@babel/traverse';
@ -23,7 +24,6 @@ export type ExcludesBoolean = <T>(x: T | false | true) => x is T;
const hasJSX = (parentPath: NodePath) => { const hasJSX = (parentPath: NodePath) => {
let fileHasJSX = false; let fileHasJSX = false;
parentPath.traverse({ parentPath.traverse({
JSXElement(path) { // skip ts error JSXElement(path) { // skip ts error
fileHasJSX = true; fileHasJSX = true;
@ -62,6 +62,7 @@ export default ({ types }: typeof BabelCore) => ({
'resolveDirective', 'resolveDirective',
'mergeProps', 'mergeProps',
'createTextVNode', 'createTextVNode',
'isVNode',
]; ];
if (isModule(path)) { if (isModule(path)) {
// import { createVNode } from "vue"; // import { createVNode } from "vue";
@ -83,6 +84,24 @@ export default ({ types }: typeof BabelCore) => ({
return identifier; return identifier;
}); });
}); });
state.set('@vue/babel-plugin-jsx/runtimeIsSlot', () => {
if (importMap.runtimeIsSlot) {
return importMap.runtimeIsSlot;
}
const { name: isVNodeName } = state.get('isVNode')();
const isSlot = path.scope.generateUidIdentifier('isSlot');
const ast = template.ast`
function ${isSlot.name}(s) {
return typeof s === 'function' || (Object.prototype.toString.call(s) === '[object Object]' && !${isVNodeName}(s));
}
`;
const lastImport = (path.get('body') as NodePath[]).filter((p) => p.isImportDeclaration()).pop();
if (lastImport) {
lastImport.insertAfter(ast);
}
importMap.runtimeIsSlot = isSlot;
return isSlot;
});
} else { } else {
// var _vue = require('vue'); // var _vue = require('vue');
let sourceName = ''; let sourceName = '';

View File

@ -83,28 +83,99 @@ const transformJSXElement = (
const slotFlag = path.getData('slotFlag') || SlotFlags.STABLE; const slotFlag = path.getData('slotFlag') || SlotFlags.STABLE;
const createVNode = t.callExpression(createIdentifier(state, 'createVNode'), [ let VNodeChild;
tag,
props, if (children.length > 1 || slots) {
(children.length || slots) ? ( VNodeChild = isComponent ? t.objectExpression([
isComponent !!children.length && t.objectProperty(
? t.objectExpression([ t.identifier('default'),
!!children.length && t.objectProperty( t.arrowFunctionExpression([], t.arrayExpression(buildIIFE(path, children))),
),
...(slots ? (
t.isObjectExpression(slots)
? (slots! as t.ObjectExpression).properties
: [t.spreadElement(slots!)]
) : []),
optimize && t.objectProperty(
t.identifier('_'),
t.numericLiteral(slotFlag),
),
].filter(Boolean as any)) : t.arrayExpression(children);
} else if (children.length === 1) {
const child = children[0];
if (t.isIdentifier(child)) {
VNodeChild = t.conditionalExpression(
t.callExpression(state.get('@vue/babel-plugin-jsx/runtimeIsSlot')(), [child]),
child,
t.objectExpression([
t.objectProperty(
t.identifier('default'), t.identifier('default'),
t.arrowFunctionExpression([], t.arrayExpression(buildIIFE(path, children))), t.arrowFunctionExpression([], t.arrayExpression(buildIIFE(path, [child]))),
), ),
...(slots ? (
t.isObjectExpression(slots)
? (slots! as t.ObjectExpression).properties
: [t.spreadElement(slots!)]
) : []),
optimize && t.objectProperty( optimize && t.objectProperty(
t.identifier('_'), t.identifier('_'),
t.numericLiteral(slotFlag), t.numericLiteral(slotFlag),
) as any,
].filter(Boolean)),
);
} else if (
t.isCallExpression(child) && child.loc && isComponent
) { // the element was generated and doesn't have location information
const slotId = path.scope.generateUidIdentifier('slot');
const scope = path.hub.getScope();
if (scope) {
scope.push({
id: slotId,
kind: 'let',
});
}
VNodeChild = t.conditionalExpression(
t.callExpression(
state.get('@vue/babel-plugin-jsx/runtimeIsSlot')(),
[t.assignmentExpression('=', slotId, child)],
),
slotId,
t.objectExpression([
t.objectProperty(
t.identifier('default'),
t.arrowFunctionExpression([], t.arrayExpression(buildIIFE(path, [slotId]))),
), ),
].filter(Boolean as any)) optimize && t.objectProperty(
: t.arrayExpression(children) t.identifier('_'),
) : t.nullLiteral(), t.numericLiteral(slotFlag),
) as any,
].filter(Boolean)),
);
} else if (t.isFunctionExpression(child) || t.isArrowFunctionExpression(child)) {
VNodeChild = t.objectExpression([
t.objectProperty(
t.identifier('default'),
child,
),
]);
} else if (t.isObjectExpression(child)) {
VNodeChild = t.objectExpression([
...child.properties,
optimize && t.objectProperty(
t.identifier('_'),
t.numericLiteral(slotFlag),
),
].filter(Boolean as any));
} else {
VNodeChild = isComponent ? t.objectExpression([
t.objectProperty(
t.identifier('default'),
t.arrowFunctionExpression([], t.arrayExpression([child])),
),
]) : t.arrayExpression([child]);
}
}
const createVNode = t.callExpression(createIdentifier(state, 'createVNode'), [
tag,
props,
VNodeChild || t.nullLiteral(),
!!patchFlag && optimize && t.numericLiteral(patchFlag), !!patchFlag && optimize && t.numericLiteral(patchFlag),
!!dynamicPropNames.size && optimize !!dynamicPropNames.size && optimize
&& t.arrayExpression( && t.arrayExpression(

View File

@ -127,9 +127,53 @@ exports[`override props single: single 1`] = `
_createVNode(\\"div\\", a, null);" _createVNode(\\"div\\", a, null);"
`; `;
exports[`reassign variable as component: reassign variable as component 1`] = ` exports[`passing object slots via JSX children multiple expressions: multiple expressions 1`] = `
"import { createVNode as _createVNode } from \\"vue\\"; "import { createVNode as _createVNode } from \\"vue\\";
import { resolveComponent as _resolveComponent } from \\"vue\\";
_createVNode(_resolveComponent(\\"A\\"), null, {
default: () => [foo, bar],
_: 1
});"
`;
exports[`passing object slots via JSX children single expression, function expression: single expression, function expression 1`] = `
"import { createVNode as _createVNode } from \\"vue\\";
import { resolveComponent as _resolveComponent } from \\"vue\\";
_createVNode(_resolveComponent(\\"A\\"), null, {
default: () => \\"foo\\"
});"
`;
exports[`passing object slots via JSX children single expression, non-literal value: runtime check: single expression, non-literal value: runtime check 1`] = `
"import { createVNode as _createVNode } from \\"vue\\";
import { isVNode as _isVNode } from \\"vue\\";
import { resolveComponent as _resolveComponent } from \\"vue\\";
let _slot;
function _isSlot(s) {
return typeof s === 'function' || Object.prototype.toString.call(s) === '[object Object]' && !_isVNode(s);
}
const foo = () => 1;
_createVNode(_resolveComponent(\\"A\\"), null, _isSlot(_slot = foo()) ? _slot : {
default: () => [_slot],
_: 1
});"
`;
exports[`reassign variable as component: reassign variable as component 1`] = `
"import { isVNode as _isVNode } from \\"vue\\";
import { createVNode as _createVNode } from \\"vue\\";
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
function _isSlot(s) {
return typeof s === 'function' || Object.prototype.toString.call(s) === '[object Object]' && !_isVNode(s);
}
let a = 1; let a = 1;
const A = defineComponent({ const A = defineComponent({
setup(_, { setup(_, {
@ -146,7 +190,7 @@ const _a = function () {
return a; return a;
}(); }();
a = _createVNode(A, null, { a = _createVNode(A, null, _isSlot(a) ? a : {
default: () => [_a], default: () => [_a],
_: 2 _: 2
});" });"

View File

@ -1,5 +1,9 @@
import { import {
reactive, ref, defineComponent, CSSProperties, ComponentPublicInstance, reactive,
ref,
defineComponent,
CSSProperties,
ComponentPublicInstance,
} from 'vue'; } from 'vue';
import { shallowMount, mount, VueWrapper } from '@vue/test-utils'; import { shallowMount, mount, VueWrapper } from '@vue/test-utils';
@ -66,9 +70,7 @@ describe('Transform JSX', () => {
const wrapper = mount({ const wrapper = mount({
render() { render() {
return ( return <Child class="parent" foo={1} />;
<Child class="parent" foo={1} />
);
}, },
}); });
expect(wrapper.classes()).toStrictEqual([]); expect(wrapper.classes()).toStrictEqual([]);
@ -123,7 +125,7 @@ describe('Transform JSX', () => {
const wrapper = shallowMount({ const wrapper = shallowMount({
setup() { setup() {
// @ts-ignore // @ts-ignore
return () => <div class="a" {...{ class: 'b' } } />; return () => <div class="a" {...{ class: 'b' }} />;
}, },
}); });
expect(wrapper.classes().sort()).toEqual(['a', 'b'].sort()); expect(wrapper.classes().sort()).toEqual(['a', 'b'].sort());
@ -145,10 +147,12 @@ describe('Transform JSX', () => {
const wrapper = shallowMount({ const wrapper = shallowMount({
setup() { setup() {
// @ts-ignore // @ts-ignore
return () => <div { ...propsA } { ...propsB } />; return () => <div {...propsA} {...propsB} />;
}, },
}); });
expect(wrapper.html()).toBe('<div style="color: blue; width: 300px; height: 300px;"></div>'); expect(wrapper.html()).toBe(
'<div style="color: blue; width: 300px; height: 300px;"></div>',
);
}); });
test('JSXSpreadChild', () => { test('JSXSpreadChild', () => {
@ -259,7 +263,7 @@ describe('directive', () => {
calls.push(1); calls.push(1);
}, },
}; };
const wrapper = shallowMount(({ const wrapper = shallowMount({
directives: { custom: customDirective }, directives: { custom: customDirective },
setup() { setup() {
return () => ( return () => (
@ -272,28 +276,28 @@ describe('directive', () => {
/> />
); );
}, },
})); });
const node = wrapper.vm.$.subTree; const node = wrapper.vm.$.subTree;
expect(calls).toEqual(expect.arrayContaining([1])); expect(calls).toEqual(expect.arrayContaining([1]));
expect(node.dirs).toHaveLength(1); expect(node.dirs).toHaveLength(1);
}); });
test('vHtml', () => { test('vHtml', () => {
const wrapper = shallowMount(({ const wrapper = shallowMount({
setup() { setup() {
return () => <h1 v-html="<div>foo</div>"></h1>; return () => <h1 v-html="<div>foo</div>"></h1>;
}, },
})); });
expect(wrapper.html()).toBe('<h1><div>foo</div></h1>'); expect(wrapper.html()).toBe('<h1><div>foo</div></h1>');
}); });
test('vText', () => { test('vText', () => {
const text = 'foo'; const text = 'foo';
const wrapper = shallowMount(({ const wrapper = shallowMount({
setup() { setup() {
return () => <div v-text={text}></div>; return () => <div v-text={text}></div>;
}, },
})); });
expect(wrapper.html()).toBe('<div>foo</div>'); expect(wrapper.html()).toBe('<div>foo</div>');
}); });
}); });
@ -315,7 +319,11 @@ describe('slots', () => {
const wrapper = mount({ const wrapper = mount({
setup() { setup() {
return () => <A v-slots={{ foo: (val: string) => val }}><span>default</span></A>; return () => (
<A v-slots={{ foo: (val: string) => val }}>
<span>default</span>
</A>
);
}, },
}); });
@ -325,11 +333,7 @@ describe('slots', () => {
test('without default', () => { test('without default', () => {
const A = defineComponent({ const A = defineComponent({
setup(_, { slots }) { setup(_, { slots }) {
return () => ( return () => <div>{slots.foo?.('foo')}</div>;
<div>
{slots.foo?.('foo')}
</div>
);
}, },
}); });
@ -362,7 +366,11 @@ describe('PatchFlags', () => {
const onClick = () => { const onClick = () => {
visible.value = false; visible.value = false;
}; };
return () => <div v-show={visible.value} onClick={onClick}>NEED_PATCH</div>; return () => (
<div v-show={visible.value} onClick={onClick}>
NEED_PATCH
</div>
);
}, },
}); });
@ -380,7 +388,9 @@ describe('PatchFlags', () => {
}; };
return () => ( return () => (
<div {...bindProps} class="static" onClick={onClick}>full props</div> <div {...bindProps} class="static" onClick={onClick}>
full props
</div>
); );
}, },
}); });
@ -455,9 +465,7 @@ describe('variables outside slots', () => {
const textarea = <textarea id="textarea" {...attrs} />; const textarea = <textarea id="textarea" {...attrs} />;
return ( return (
<A inc={this.inc}> <A inc={this.inc}>
<div> <div>{textarea}</div>
{textarea}
</div>
<button id="button" onClick={this.inc}>+1</button> <button id="button" onClick={this.inc}>+1</button>
</A> </A>
); );
@ -480,7 +488,6 @@ test('reassign variable as component should work', () => {
}); });
/* eslint-disable */ /* eslint-disable */
// @ts-ignore
const _a2 = 2; const _a2 = 2;
a = _a2; a = _a2;
/* eslint-enable */ /* eslint-enable */
@ -495,3 +502,72 @@ test('reassign variable as component should work', () => {
expect(wrapper.html()).toBe('<span>2</span>'); expect(wrapper.html()).toBe('<span>2</span>');
}); });
describe('should support passing object slots via JSX children', () => {
const A = defineComponent({
setup(_, { slots }) {
return () => (
<span>
{slots.default?.()}
{slots.foo?.()}
</span>
);
},
});
test('single expression, variable', () => {
const slots = { default: () => 1, foo: () => 2 };
const wrapper = mount({
render() {
return <A>{slots}</A>;
},
});
expect(wrapper.html()).toBe('<span>12</span>');
});
test('single expression, object literal', () => {
const wrapper = mount({
render() {
return <A>{{ default: () => 1, foo: () => 2 }}</A>;
},
});
expect(wrapper.html()).toBe('<span>12</span>');
});
test('single expression, object literal', () => {
const wrapper = mount({
render() {
return <A>{{ default: () => 1, foo: () => 2 }}</A>;
},
});
expect(wrapper.html()).toBe('<span>12</span>');
});
test('single expression, non-literal value', () => {
const foo = () => 1;
const wrapper = mount({
render() {
return <A>{foo()}</A>;
},
});
expect(wrapper.html()).toBe('<span>1<!----></span>');
});
test('single expression, function expression', () => {
const wrapper = mount({
render() {
return (
<A>{() => 'foo'}</A>
);
},
});
expect(wrapper.html()).toBe('<span>foo<!----></span>');
});
});

View File

@ -1,6 +1,11 @@
import { transform } from '@babel/core'; import { transform } from '@babel/core';
import JSX, { Opts } from '../src'; import JSX, { Opts } from '../src';
interface Test {
name: string;
from: string;
}
const transpile = ( const transpile = (
source: string, options: Opts = {}, source: string, options: Opts = {},
) => new Promise((resolve, reject) => transform( ) => new Promise((resolve, reject) => transform(
@ -18,7 +23,7 @@ const transpile = (
}, },
)); ));
const tests = [ const tests: Test[] = [
{ {
name: 'input[type="checkbox"]', name: 'input[type="checkbox"]',
from: '<input type="checkbox" v-model={test} />', from: '<input type="checkbox" v-model={test} />',
@ -155,7 +160,7 @@ tests.forEach((
); );
}); });
const overridePropsTests = [{ const overridePropsTests: Test[] = [{
name: 'single', name: 'single',
from: '<div {...a} />', from: '<div {...a} />',
}, { }, {
@ -173,3 +178,34 @@ overridePropsTests.forEach((
}, },
); );
}); });
const slotsTests: Test[] = [
{
name: 'multiple expressions',
from: '<A>{foo}{bar}</A>',
},
{
name: 'single expression, function expression',
from: `
<A>{() => "foo"}</A>
`,
},
{
name: 'single expression, non-literal value: runtime check',
from: `
const foo = () => 1;
<A>{foo()}</A>;
`,
},
];
slotsTests.forEach(({
name, from,
}) => {
test(
`passing object slots via JSX children ${name}`,
async () => {
expect(await transpile(from, { optimize: true })).toMatchSnapshot(name);
},
);
});