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

View File

@ -27,45 +27,43 @@ export function createComponentNode(
name,
props: [],
child: undefined,
subComponents: [],
properties: [],
dependencyMap: {},
reactiveMap: {},
lifecycle: {},
parent,
// 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() {
return comp.properties
.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.ownAvailableProperties, ...(comp.parent ? comp.parent.availableProperties : [])];
},
};
return comp;
}
export function addProperty(
comp: ComponentNode,
name: string,
value: t.Expression | null,
isComputed: boolean,
isMethod = false
) {
comp.properties.push({ name, value, isComputed, isMethod });
export function addProperty(comp: ComponentNode, name: string, value: t.Expression | null, isComputed: boolean) {
comp.properties.push({ name, value, isComputed, isMethod: false });
}
export function addMethod(comp: ComponentNode, name: string, value: t.Expression | null) {
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(
comp: ComponentNode,
type: PropType,

View File

@ -14,12 +14,19 @@
*/
import { AnalyzeContext, Visitor } from './types';
import { addLifecycle, addMethod, addProperty } from './nodeFactory';
import { addMethod, addProperty, createComponentNode } from './nodeFactory';
import { isValidPath } from './utils';
import { type types as t, type NodePath } from '@babel/core';
import { reactivityFuncNames } from '../const';
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 {
return {
VariableDeclaration(path: NodePath<t.VariableDeclaration>, ctx) {
@ -38,10 +45,28 @@ export function propertiesAnalyze(): Visitor {
const init = declaration.get('init');
let deps: string[] | null = null;
if (isValidPath(init)) {
// the property is a method
if (init.isArrowFunctionExpression() || init.isFunctionExpression()) {
addMethod(ctx.current, id.node.name, init.node);
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);
}
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 { PropType } from '../constants';
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
@ -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;
type defaultVal = any | null;
type Bitmap = number;
interface Property {
interface BaseProperty<V> {
name: string;
value: t.Expression | null;
// indicate the value is a state or computed or watch
listeners?: string[];
bitmap?: Bitmap;
value: V;
// need a flag for computed to gen a getter
// watch is a static computed
isComputed: 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 {
name: string;
type: PropType;
@ -47,7 +52,15 @@ export interface ComponentNode {
name: string;
props: Prop[];
// 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[];
/**
* The map to find the dependencies
@ -56,7 +69,6 @@ export interface ComponentNode {
[key: string]: string[];
};
child?: InulaNode;
subComponents?: ComponentNode[];
parent?: ComponentNode;
/**
* The function body of the fn component code
@ -103,8 +115,9 @@ export interface Branch {
export interface AnalyzeContext {
level: number;
t: typeof t;
current: ComponentNode;
analyzers: Analyzer[];
htmlTags: string[];
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 babel from '@babel/core';
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) => {
_t = types;
export const getBabelApi = (): typeof babel => {
if (!babelApi) {
throw new Error('Please call register() before using the babel api');
}
return babelApi;
};
export const types = new Proxy(

View File

@ -1,7 +1,7 @@
import type babel from '@babel/core';
import { type PluginObj } from '@babel/core';
import { type DLightOption } from './types';
import { defaultAttributeMap } from './const';
import { defaultAttributeMap, defaultHTMLTags } from './const';
import { analyze } from './analyze';
import { NodePath, type types as t } from '@babel/core';
import { COMPONENT } from './constants';
@ -14,11 +14,18 @@ export default function (api: typeof babel, options: DLightOption): PluginObj {
files = '**/*.{js,ts,jsx,tsx}',
excludeFiles = '**/{dist,node_modules,lib}/*',
enableDevTools = false,
htmlTags = defaultHtmlTags => defaultHtmlTags,
customHtmlTags = defaultHtmlTags => defaultHtmlTags,
attributeMap = defaultAttributeMap,
} = options;
register(types);
const htmlTags =
typeof customHtmlTags === 'function'
? customHtmlTags(defaultHTMLTags)
: customHtmlTags.includes('*')
? [...new Set([...defaultHTMLTags, ...customHtmlTags])].filter(tag => tag !== '*')
: customHtmlTags;
register(api);
return {
visitor: {
Program: {
@ -45,7 +52,9 @@ export default function (api: typeof babel, options: DLightOption): PluginObj {
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
path.skip();
}

View File

@ -17,6 +17,7 @@ import { describe, expect, it } from 'vitest';
import { genCode, mockAnalyze } from '../mock';
import { propertiesAnalyze } from '../../src/analyze/propertiesAnalyze';
import { propsAnalyze } from '../../src/analyze/propsAnalyze';
import { ComponentNode } from '../../src/analyze/types';
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', () => {
const root = analyze(`
Component(() => {
@ -115,6 +138,6 @@ describe('analyze properties', () => {
expect(root.properties.map(p => p.name)).toEqual(['foo', 'onClick', 'onHover', 'onInput']);
expect(root.properties[1].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 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 { analyze } from '../src/analyze';
import generate from '@babel/generator';
import * as t from '@babel/types';
import { register } from '../src/babelTypes';
import { defaultHTMLTags } from '../src/const';
export function mockAnalyze(code: string, analyzers?: Analyzer[]): ComponentNode {
let root: ComponentNode | null = null;
@ -27,17 +28,17 @@ export function mockAnalyze(code: string, analyzers?: Analyzer[]): ComponentNode
plugins: [
syntaxJSX.default ?? syntaxJSX,
function (api): PluginObj {
register(api.types);
register(api);
return {
visitor: {
FunctionExpression: path => {
root = analyze(api.types, 'test', path, analyzers);
root = analyze('test', path, { customAnalyzers: analyzers, htmlTags: defaultHTMLTags });
if (root) {
path.skip();
}
},
ArrowFunctionExpression: path => {
root = analyze(api.types, 'test', path, analyzers);
root = analyze('test', path, { customAnalyzers: analyzers, htmlTags: defaultHTMLTags });
if (root) {
path.skip();
}