feat: sub component

This commit is contained in:
Hoikan 2024-04-25 11:46:11 +08:00
parent 5427f13880
commit f15b7d1a14
11 changed files with 247 additions and 108 deletions

View File

@ -5,9 +5,7 @@ import { createComponentNode } from './nodeFactory';
import { propertiesAnalyze } from './propertiesAnalyze'; import { propertiesAnalyze } from './propertiesAnalyze';
import { lifeCycleAnalyze } from './lifeCycleAnalyze'; import { lifeCycleAnalyze } from './lifeCycleAnalyze';
import { getFnBody } from '../utils'; import { getFnBody } from '../utils';
const builtinAnalyzers = [propsAnalyze, propertiesAnalyze, lifeCycleAnalyze]; const builtinAnalyzers = [propsAnalyze, propertiesAnalyze, lifeCycleAnalyze];
let analyzers: Analyzer[] = builtinAnalyzers;
export function isCondNode(node: any): node is CondNode { export function isCondNode(node: any): node is CondNode {
return node && node.type === 'cond'; return node && node.type === 'cond';
@ -36,16 +34,17 @@ function mergeVisitor(...visitors: Analyzer[]): Visitor {
// walk through the function component body // walk through the function component body
export function analyzeFnComp( export function analyzeFnComp(
types: typeof t,
fnNode: NodePath<t.FunctionExpression | t.ArrowFunctionExpression>, fnNode: NodePath<t.FunctionExpression | t.ArrowFunctionExpression>,
componentNode: ComponentNode, componentNode: ComponentNode,
{ htmlTags, analyzers }: { analyzers: Analyzer[]; htmlTags: string[] },
level = 0 level = 0
) { ) {
const visitor = mergeVisitor(...analyzers); const visitor = mergeVisitor(...analyzers);
const context: AnalyzeContext = { const context: AnalyzeContext = {
level, level,
t: types,
current: componentNode, current: componentNode,
htmlTags,
analyzers,
traverse: (path: NodePath<t.Statement>, ctx: AnalyzeContext) => { traverse: (path: NodePath<t.Statement>, ctx: AnalyzeContext) => {
path.traverse(visitor, ctx); path.traverse(visitor, ctx);
}, },
@ -94,17 +93,14 @@ export function analyzeFnComp(
* @param customAnalyzers * @param customAnalyzers
*/ */
export function analyze( export function analyze(
types: typeof t,
fnName: string, fnName: string,
path: NodePath<t.FunctionExpression | t.ArrowFunctionExpression>, path: NodePath<t.FunctionExpression | t.ArrowFunctionExpression>,
customAnalyzers?: Analyzer[] options: { customAnalyzers?: Analyzer[]; htmlTags: string[] }
) { ) {
if (customAnalyzers) { const analyzers = options?.customAnalyzers ? options.customAnalyzers : builtinAnalyzers;
analyzers = customAnalyzers;
}
const root = createComponentNode(fnName, path); const root = createComponentNode(fnName, path);
analyzeFnComp(types, path, root); analyzeFnComp(path, root, { analyzers, htmlTags: options.htmlTags });
return root; return root;
} }

View File

@ -27,45 +27,43 @@ export function createComponentNode(
name, name,
props: [], props: [],
child: undefined, child: undefined,
subComponents: [],
properties: [], properties: [],
dependencyMap: {}, dependencyMap: {},
reactiveMap: {}, reactiveMap: {},
lifecycle: {}, lifecycle: {},
parent, parent,
// fnBody, // fnBody,
get availableProps() {
return comp.props
.map(({ name, nestedProps, alias }) => {
const nested = nestedProps ? nestedProps.map(name => name) : [];
return [alias ? alias : name, ...nested];
})
.flat();
},
get ownAvailableProperties() {
return [...comp.properties.filter(p => !p.isMethod).map(({ name }) => name), ...comp.availableProps];
},
get availableProperties() { get availableProperties() {
return comp.properties return [...comp.ownAvailableProperties, ...(comp.parent ? comp.parent.availableProperties : [])];
.filter(({ isMethod }) => !isMethod)
.map(({ name }) => name)
.concat(
comp.props
.map(({ name, nestedProps, alias }) => {
const nested = nestedProps ? nestedProps.map(name => name) : [];
return [alias ? alias : name, ...nested];
})
.flat()
);
}, },
}; };
return comp; return comp;
} }
export function addProperty( export function addProperty(comp: ComponentNode, name: string, value: t.Expression | null, isComputed: boolean) {
comp: ComponentNode, comp.properties.push({ name, value, isComputed, isMethod: false });
name: string,
value: t.Expression | null,
isComputed: boolean,
isMethod = false
) {
comp.properties.push({ name, value, isComputed, isMethod });
} }
export function addMethod(comp: ComponentNode, name: string, value: t.Expression | null) { export function addMethod(comp: ComponentNode, name: string, value: t.Expression | null) {
comp.properties.push({ name, value, isComputed: false, isMethod: true }); comp.properties.push({ name, value, isComputed: false, isMethod: true });
} }
export function addSubComponent(comp: ComponentNode, subComp: ComponentNode, isComputed: boolean) {
comp.properties.push({ name: subComp.name, value: subComp, isSubComp: true, isComputed, isMethod: false });
}
export function addProp( export function addProp(
comp: ComponentNode, comp: ComponentNode,
type: PropType, type: PropType,

View File

@ -14,12 +14,19 @@
*/ */
import { AnalyzeContext, Visitor } from './types'; import { AnalyzeContext, Visitor } from './types';
import { addLifecycle, addMethod, addProperty } from './nodeFactory'; import { addMethod, addProperty, createComponentNode } from './nodeFactory';
import { isValidPath } from './utils'; import { isValidPath } from './utils';
import { type types as t, type NodePath } from '@babel/core'; import { type types as t, type NodePath } from '@babel/core';
import { reactivityFuncNames } from '../const'; import { reactivityFuncNames } from '../const';
import { types } from '../babelTypes'; import { types } from '../babelTypes';
import { COMPONENT } from '../constants';
import { analyzeFnComp } from '.';
/**
* collect all properties and methods from the node
* and analyze the dependencies of the properties
* @returns
*/
export function propertiesAnalyze(): Visitor { export function propertiesAnalyze(): Visitor {
return { return {
VariableDeclaration(path: NodePath<t.VariableDeclaration>, ctx) { VariableDeclaration(path: NodePath<t.VariableDeclaration>, ctx) {
@ -38,10 +45,28 @@ export function propertiesAnalyze(): Visitor {
const init = declaration.get('init'); const init = declaration.get('init');
let deps: string[] | null = null; let deps: string[] | null = null;
if (isValidPath(init)) { if (isValidPath(init)) {
// the property is a method
if (init.isArrowFunctionExpression() || init.isFunctionExpression()) { if (init.isArrowFunctionExpression() || init.isFunctionExpression()) {
addMethod(ctx.current, id.node.name, init.node); addMethod(ctx.current, id.node.name, init.node);
return; return;
} }
// Should like Component(() => {})
if (
init.isCallExpression() &&
init.get('callee').isIdentifier() &&
(init.get('callee').node as t.Identifier).name === COMPONENT &&
(init.get('arguments')[0].isFunctionExpression() || init.get('arguments')[0].isArrowFunctionExpression())
) {
const fnNode = init.get('arguments')[0] as
| NodePath<t.ArrowFunctionExpression>
| NodePath<t.FunctionExpression>;
const subComponent = createComponentNode(id.node.name, fnNode, ctx.current);
analyzeFnComp(fnNode, subComponent, ctx);
deps = getDependenciesFromNode(id.node.name, init, ctx);
addProperty(ctx.current, id.node.name, subComponent, !!deps?.length);
return;
}
deps = getDependenciesFromNode(id.node.name, init, ctx); deps = getDependenciesFromNode(id.node.name, init, ctx);
} }
addProperty(ctx.current, id.node.name, init.node || null, !!deps?.length); addProperty(ctx.current, id.node.name, init.node || null, !!deps?.length);

View File

@ -3,64 +3,6 @@ import { AnalyzeContext, Visitor } from './types';
import { addProp } from './nodeFactory'; import { addProp } from './nodeFactory';
import { PropType } from '../constants'; import { PropType } from '../constants';
import { types } from '../babelTypes'; import { types } from '../babelTypes';
function analyzeSingleProp(
value: t.ObjectProperty['value'],
key: string,
path: NodePath<t.ObjectProperty>,
{ t, current }: AnalyzeContext
) {
let defaultVal: t.Expression | null = null;
let alias: string | null = null;
const nestedProps: string[] | null = [];
let nestedRelationship: t.ObjectPattern | t.ArrayPattern | null = null;
if (t.isIdentifier(value)) {
// 1. handle alias without default value
// handle alias without default value
if (key !== value.name) {
alias = value.name;
}
} else if (t.isAssignmentPattern(value)) {
// 2. handle default value case
const assignedName = value.left;
defaultVal = value.right;
if (t.isIdentifier(assignedName)) {
if (assignedName.name !== key) {
// handle alias in default value case
alias = assignedName.name;
}
} else {
throw Error(`Unsupported assignment type in object destructuring: ${assignedName.type}`);
}
} else if (t.isObjectPattern(value) || t.isArrayPattern(value)) {
// 3. nested destructuring
// we should collect the identifier that can be used in the function body as the prop
// e.g. function ({prop1, prop2: [p20X, {p211, p212: p212X}]}
// we should collect prop1, p20X, p211, p212X
path.get('value').traverse({
Identifier(path) {
// judge if the identifier is a prop
// 1. is the key of the object property and doesn't have alias
// 2. is the item of the array pattern and doesn't have alias
// 3. is alias of the object property
const parentPath = path.parentPath;
if (parentPath.isObjectProperty() && path.parentKey === 'value') {
// collect alias of the object property
nestedProps.push(path.node.name);
} else if (
parentPath.isArrayPattern() ||
parentPath.isObjectPattern() ||
parentPath.isRestElement() ||
(parentPath.isAssignmentPattern() && path.key === 'left')
) {
// collect the key of the object property or the item of the array pattern
nestedProps.push(path.node.name);
}
},
});
nestedRelationship = value;
}
addProp(current, PropType.SINGLE, key, defaultVal, alias, nestedProps, nestedRelationship);
}
/** /**
* Analyze the props deconstructing in the function component * Analyze the props deconstructing in the function component
@ -104,3 +46,62 @@ export function propsAnalyze(): Visitor {
}, },
}; };
} }
function analyzeSingleProp(
value: t.ObjectProperty['value'],
key: string,
path: NodePath<t.ObjectProperty>,
{ current }: AnalyzeContext
) {
let defaultVal: t.Expression | null = null;
let alias: string | null = null;
const nestedProps: string[] | null = [];
let nestedRelationship: t.ObjectPattern | t.ArrayPattern | null = null;
if (types.isIdentifier(value)) {
// 1. handle alias without default value
// handle alias without default value
if (key !== value.name) {
alias = value.name;
}
} else if (types.isAssignmentPattern(value)) {
// 2. handle default value case
const assignedName = value.left;
defaultVal = value.right;
if (types.isIdentifier(assignedName)) {
if (assignedName.name !== key) {
// handle alias in default value case
alias = assignedName.name;
}
} else {
throw Error(`Unsupported assignment type in object destructuring: ${assignedName.type}`);
}
} else if (types.isObjectPattern(value) || types.isArrayPattern(value)) {
// 3. nested destructuring
// we should collect the identifier that can be used in the function body as the prop
// e.g. function ({prop1, prop2: [p20X, {p211, p212: p212X}]}
// we should collect prop1, p20X, p211, p212X
path.get('value').traverse({
Identifier(path) {
// judge if the identifier is a prop
// 1. is the key of the object property and doesn't have alias
// 2. is the item of the array pattern and doesn't have alias
// 3. is alias of the object property
const parentPath = path.parentPath;
if (parentPath.isObjectProperty() && path.parentKey === 'value') {
// collect alias of the object property
nestedProps.push(path.node.name);
} else if (
parentPath.isArrayPattern() ||
parentPath.isObjectPattern() ||
parentPath.isRestElement() ||
(parentPath.isAssignmentPattern() && path.key === 'left')
) {
// collect the key of the object property or the item of the array pattern
nestedProps.push(path.node.name);
}
},
});
nestedRelationship = value;
}
addProp(current, PropType.SINGLE, key, defaultVal, alias, nestedProps, nestedRelationship);
}

View File

@ -23,17 +23,22 @@ export type JSX = t.JSXElement | t.JSXFragment;
export type LifeCycle = typeof WILL_MOUNT | typeof ON_MOUNT | typeof WILL_UNMOUNT | typeof ON_UNMOUNT; export type LifeCycle = typeof WILL_MOUNT | typeof ON_MOUNT | typeof WILL_UNMOUNT | typeof ON_UNMOUNT;
type defaultVal = any | null; type defaultVal = any | null;
type Bitmap = number; type Bitmap = number;
interface Property { interface BaseProperty<V> {
name: string; name: string;
value: t.Expression | null; value: V;
// indicate the value is a state or computed or watch
listeners?: string[];
bitmap?: Bitmap;
// need a flag for computed to gen a getter // need a flag for computed to gen a getter
// watch is a static computed // watch is a static computed
isComputed: boolean; isComputed: boolean;
isMethod: boolean; isMethod: boolean;
} }
interface Property extends BaseProperty<t.Expression | null> {
// indicate the value is a state or computed or watch
listeners?: string[];
bitmap?: Bitmap;
}
interface SubCompProperty extends BaseProperty<ComponentNode> {
isSubComp: true;
}
interface Prop { interface Prop {
name: string; name: string;
type: PropType; type: PropType;
@ -47,7 +52,15 @@ export interface ComponentNode {
name: string; name: string;
props: Prop[]; props: Prop[];
// A properties could be a state or computed // A properties could be a state or computed
properties: Property[]; properties: (Property | SubCompProperty)[];
/**
* The available props for the component, including the nested props
*/
availableProps: string[];
/**
* The available properties for the component
*/
ownAvailableProperties: string[];
availableProperties: string[]; availableProperties: string[];
/** /**
* The map to find the dependencies * The map to find the dependencies
@ -56,7 +69,6 @@ export interface ComponentNode {
[key: string]: string[]; [key: string]: string[];
}; };
child?: InulaNode; child?: InulaNode;
subComponents?: ComponentNode[];
parent?: ComponentNode; parent?: ComponentNode;
/** /**
* The function body of the fn component code * The function body of the fn component code
@ -103,8 +115,9 @@ export interface Branch {
export interface AnalyzeContext { export interface AnalyzeContext {
level: number; level: number;
t: typeof t;
current: ComponentNode; current: ComponentNode;
analyzers: Analyzer[];
htmlTags: string[];
traverse: (p: NodePath<t.Statement>, ctx: AnalyzeContext) => void; traverse: (p: NodePath<t.Statement>, ctx: AnalyzeContext) => void;
} }

View File

@ -0,0 +1,45 @@
/*
* Copyright (c) 2024 Huawei Technologies Co.,Ltd.
*
* openInula is licensed under Mulan PSL v2.
* You can use this software according to the terms and conditions of the Mulan PSL v2.
* You may obtain a copy of Mulan PSL v2 at:
*
* http://license.coscl.org.cn/MulanPSL2
*
* THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
* EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
* MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
* See the Mulan PSL v2 for more details.
*/
import { Visitor } from './types';
import { type types as t, type NodePath } from '@babel/core';
import { parseView as parseJSX } from 'jsx-view-parser';
import { getBabelApi } from '../babelTypes';
import { parseReactivity } from '@openinula/reactivity-parser';
import { reactivityFuncNames } from '../const';
/**
* Analyze the watch in the function component
*/
export function viewAnalyze(): Visitor {
return {
ReturnStatement(path: NodePath<t.ReturnStatement>, { htmlTags, current }) {
const returnNode = path.get('argument');
if (returnNode.isJSXElement() || returnNode.isJSXFragment()) {
const viewUnits = parseJSX(returnNode.node, {
babelApi: getBabelApi(),
htmlTags,
parseTemplate: false,
});
const [viewParticles, usedPropertySet] = parseReactivity(viewUnits, {
babelApi: getBabelApi(),
availableProperties: current.availableProperties,
dependencyMap: current.dependencyMap,
reactivityFuncNames,
});
}
},
};
}

View File

@ -1,8 +1,17 @@
import { type types as t } from '@babel/core'; import { type types as t } from '@babel/core';
import type babel from '@babel/core';
let _t: null | typeof types = null; let _t: null | typeof types = null;
let babelApi: typeof babel | null = null;
export const register = (api: typeof babel) => {
babelApi = api;
_t = api.types;
};
export const register = (types: typeof t) => { export const getBabelApi = (): typeof babel => {
_t = types; if (!babelApi) {
throw new Error('Please call register() before using the babel api');
}
return babelApi;
}; };
export const types = new Proxy( export const types = new Proxy(

View File

@ -1,7 +1,7 @@
import type babel from '@babel/core'; import type babel from '@babel/core';
import { type PluginObj } from '@babel/core'; import { type PluginObj } from '@babel/core';
import { type DLightOption } from './types'; import { type DLightOption } from './types';
import { defaultAttributeMap } from './const'; import { defaultAttributeMap, defaultHTMLTags } from './const';
import { analyze } from './analyze'; import { analyze } from './analyze';
import { NodePath, type types as t } from '@babel/core'; import { NodePath, type types as t } from '@babel/core';
import { COMPONENT } from './constants'; import { COMPONENT } from './constants';
@ -14,11 +14,18 @@ export default function (api: typeof babel, options: DLightOption): PluginObj {
files = '**/*.{js,ts,jsx,tsx}', files = '**/*.{js,ts,jsx,tsx}',
excludeFiles = '**/{dist,node_modules,lib}/*', excludeFiles = '**/{dist,node_modules,lib}/*',
enableDevTools = false, enableDevTools = false,
htmlTags = defaultHtmlTags => defaultHtmlTags, customHtmlTags = defaultHtmlTags => defaultHtmlTags,
attributeMap = defaultAttributeMap, attributeMap = defaultAttributeMap,
} = options; } = options;
register(types); const htmlTags =
typeof customHtmlTags === 'function'
? customHtmlTags(defaultHTMLTags)
: customHtmlTags.includes('*')
? [...new Set([...defaultHTMLTags, ...customHtmlTags])].filter(tag => tag !== '*')
: customHtmlTags;
register(api);
return { return {
visitor: { visitor: {
Program: { Program: {
@ -45,7 +52,9 @@ export default function (api: typeof babel, options: DLightOption): PluginObj {
console.error('Component macro must be assigned to a variable'); console.error('Component macro must be assigned to a variable');
} }
} }
const root = analyze(types, name, componentNode); const root = analyze(name, componentNode, {
htmlTags,
});
// The sub path has been visited, so we just skip // The sub path has been visited, so we just skip
path.skip(); path.skip();
} }

View File

@ -17,6 +17,7 @@ import { describe, expect, it } from 'vitest';
import { genCode, mockAnalyze } from '../mock'; import { genCode, mockAnalyze } from '../mock';
import { propertiesAnalyze } from '../../src/analyze/propertiesAnalyze'; import { propertiesAnalyze } from '../../src/analyze/propertiesAnalyze';
import { propsAnalyze } from '../../src/analyze/propsAnalyze'; import { propsAnalyze } from '../../src/analyze/propsAnalyze';
import { ComponentNode } from '../../src/analyze/types';
const analyze = (code: string) => mockAnalyze(code, [propsAnalyze, propertiesAnalyze]); const analyze = (code: string) => mockAnalyze(code, [propsAnalyze, propertiesAnalyze]);
@ -101,6 +102,28 @@ describe('analyze properties', () => {
}); });
}); });
describe('subComponent', () => {
it('should analyze dependency from subComponent', () => {
const root = analyze(`
Component(() => {
let foo = 1;
const Sub = Component(() => {
let bar = foo;
});
})
`);
expect(root.properties.length).toBe(2);
expect(root.dependencyMap).toEqual({ Sub: ['foo'] });
expect((root.properties[1].value as ComponentNode).dependencyMap).toMatchInlineSnapshot(`
{
"bar": [
"foo",
],
}
`);
});
});
it('should collect method', () => { it('should collect method', () => {
const root = analyze(` const root = analyze(`
Component(() => { Component(() => {
@ -115,6 +138,6 @@ describe('analyze properties', () => {
expect(root.properties.map(p => p.name)).toEqual(['foo', 'onClick', 'onHover', 'onInput']); expect(root.properties.map(p => p.name)).toEqual(['foo', 'onClick', 'onHover', 'onInput']);
expect(root.properties[1].isMethod).toBe(true); expect(root.properties[1].isMethod).toBe(true);
expect(root.properties[2].isMethod).toBe(true); expect(root.properties[2].isMethod).toBe(true);
expect(root.dependencyMap).toMatchInlineSnapshot(`{}`); expect(root.dependencyMap).toMatchInlineSnapshot('{}');
}); });
}); });

View File

@ -0,0 +1,19 @@
import { propertiesAnalyze } from '../../src/analyze/propertiesAnalyze';
import { propsAnalyze } from '../../src/analyze/propsAnalyze';
import { viewAnalyze } from '../../src/analyze/viewAnalyze';
import { genCode, mockAnalyze } from '../mock';
import { describe, expect, it } from 'vitest';
const analyze = (code: string) => mockAnalyze(code, [propsAnalyze, propertiesAnalyze, viewAnalyze]);
describe('watchAnalyze', () => {
it('should analyze watch expressions', () => {
const root = analyze(/*js*/ `
Comp(({name}) => {
let count = 11
return <div className={name}>{count}</div>
})
`);
expect(true).toHaveLength(1);
});
});

View File

@ -14,12 +14,13 @@
*/ */
import { Analyzer, ComponentNode, InulaNode } from '../src/analyze/types'; import { Analyzer, ComponentNode, InulaNode } from '../src/analyze/types';
import babel, { type PluginObj, transform as transformWithBabel } from '@babel/core'; import { type PluginObj, transform as transformWithBabel } from '@babel/core';
import syntaxJSX from '@babel/plugin-syntax-jsx'; import syntaxJSX from '@babel/plugin-syntax-jsx';
import { analyze } from '../src/analyze'; import { analyze } from '../src/analyze';
import generate from '@babel/generator'; import generate from '@babel/generator';
import * as t from '@babel/types'; import * as t from '@babel/types';
import { register } from '../src/babelTypes'; import { register } from '../src/babelTypes';
import { defaultHTMLTags } from '../src/const';
export function mockAnalyze(code: string, analyzers?: Analyzer[]): ComponentNode { export function mockAnalyze(code: string, analyzers?: Analyzer[]): ComponentNode {
let root: ComponentNode | null = null; let root: ComponentNode | null = null;
@ -27,17 +28,17 @@ export function mockAnalyze(code: string, analyzers?: Analyzer[]): ComponentNode
plugins: [ plugins: [
syntaxJSX.default ?? syntaxJSX, syntaxJSX.default ?? syntaxJSX,
function (api): PluginObj { function (api): PluginObj {
register(api.types); register(api);
return { return {
visitor: { visitor: {
FunctionExpression: path => { FunctionExpression: path => {
root = analyze(api.types, 'test', path, analyzers); root = analyze('test', path, { customAnalyzers: analyzers, htmlTags: defaultHTMLTags });
if (root) { if (root) {
path.skip(); path.skip();
} }
}, },
ArrowFunctionExpression: path => { ArrowFunctionExpression: path => {
root = analyze(api.types, 'test', path, analyzers); root = analyze('test', path, { customAnalyzers: analyzers, htmlTags: defaultHTMLTags });
if (root) { if (root) {
path.skip(); path.skip();
} }