エントリー
こんにちは、ウラジミール・ミレンコです。Lightspeedのフロントエンド開発者です。今日は、特定のフレームワークにコンポーネントがないという問題と、それらを自動的に変換することについてお話します。
背景
これまで、管理パネル用のeコマース製品と小売製品の両方で、React.JSをメインフレームワークとして使用していましたが、レストランプラットフォームではAngularを使用しているため、コンポーネントライブラリを使用できません。 私の休暇前に、UI / UXを1つの形式にする必要があるため、この問題はより深刻になりました。 コンポーネントの移行のトピックについて少し調査し、概念実証を行い、自分の気持ちを共有することにしました。 これが現在の投稿になります。
理論のビット
さらに理解するには、次の表記法を知っておく必要があります。
ASTは抽象構文ツリーであり、コードをツリー形式で表現したものであり、括弧などはありません。 ASTの頻繁な使用の例はbabelであり、パーサーを使用してASTを構築し、次にes6で導入されたタイプでリーフをes5でサポートされるタイプに変換します。
https://ru.m.wikipedia.org/wiki/Abstract_syntactic_tree
問題解決アプローチ
最初に思いついたのは、もちろんReactからAngularに直接変換し、よく考えて(そしてこれは休暇中に常に機能するとは限りません)、このアイデアは中間ツリーなしで直接変換されないため完全に拒否されました。
いいアイデアは、私がそう言うかもしれないならば、より大きな抽象化、より高いレベルを持つ普遍的な木です。 主なアイデアは、少し後の例について、複雑な構造をより抽象的な構造に変換することでした。
プロセスは次のようになります。
- ASTでのjsの解析
- 解析パーサー
- 取得された最上位の構造に基づくUST生成(汎用抽象ツリー)
- USTからTypeScript AST + Angular Template htmlを生成する
世界が構築される(クロスアウトする)基本原則はパーサーです。 私は、一致と述語に基づいてこの全体を構築することが最良の方法であるという結論に達しました。
パーサーを理論的に機能させるには、match関数とparser関数を記述する必要がありますが、現在のノードをチェックするプレーヤーのリストを制限するために、目的のタイプの入力ノードも追加します(最適化のためではなく、利便性のため)。
マッチャーは、渡されたノードに応じてtrue/false
返しtrue/false
。 必要なのはパーサーであるかどうかにかかわらず、ノードのチェックを行います。
プレーヤーがtrue
を返したtrue
、解析関数をすでに呼び出します。 パーサー関数は、USTノードを返します。これは、解析されたASTノードで発生したことの抽象的な説明です。
解析の基本概念は、各パーサーが結果のツリーに影響を与えることなく解析ノードを呼び出すことができるということです。 これは、子を生成し、時には子上に構築するため、マッチングと解析の両方で役立ちます。
コンポーネント入力パラメーターの解析
おそらく、これは最も興味深いタスクの1つです。 ご存じのとおり、Reactコンポーネントはstate, props, context, outer scope
多くの場所からパラメーターを取得できます。
PoCの段階では、 props
のみを検討し、特定の設計においてのみ検討しますが、これについては後で詳しく説明します。
そのため、ASTには追跡変数はありません。 特定のノードを見ると、 Identifier
が表示されますが、この変数が正確にどこから来たのか、少しでもわかりません。
ASTトラバーサルが助けになり、特定のノードの現在のスコープがわかります。
コンポーネントの入力パラメーターとして次の構成を考慮します。
const {a} = this.props;
言い換えると、 id
がObjectPattern
でinit
がMemberExpression
で、 property
必ず'props'
であるVariableDeclaration
を探します。
理論から実践へ
使用ツール:
- ASTの解析-
babylon
- ASTノードタイプの定義
babel-types
- 木製の
babel-traverse
-babel-traverse
- 角度擬似テンプレート生成
parse5
パーサー述語インターフェース:
export interface ParserPredicate { matchingType:string; isMatching: (token:any) => boolean; parse: (token:any) => any; }
さて、すぐに実装の例:
export class JSXExpressionMap implements ParserPredicate { matchingType = 'JSXExpressionContainer'; parse(token: JSXExpressionContainer): any { const expression = token.expression as CallExpression; const callee = expression.callee as MemberExpression; const baseObject = (callee.object as Identifier).name; const arrowExpression = expression.arguments[0] as ArrowFunctionExpression; const renderOutput = resolverRegistry.resolve(arrowExpression.body); let baseItem = this.getBaseObjectName(callee); let newBaseItem = resolveVariable(token, baseItem); return { type: 'ForLoop', baseItem: { type: 'Identifier', name: newBaseItem }, arguments: arrowExpression.params, children: renderOutput, mutations: this.getMutations(callee) } } getBaseObjectName(callee: MemberExpression) { let temp = callee; while (!isIdentifier(temp.object)) { temp = temp.object.callee; } return (temp.object as Identifier).name; } getMutations(callee: MemberExpression) { if (!isCallExpression(callee.object)) return []; return [callee]; } isMatching(token: JSXExpressionContainer): boolean { if (!isCallExpression(token.expression)) return false; const expression = token.expression as CallExpression; if (!isMemberExpression(expression.callee)) return false; const callee = expression.callee as MemberExpression; if (!isIdentifier(callee.property)) return false; const fnToBeCalled = (callee.property as Identifier).name; if (fnToBeCalled === 'map') { return true; } return false; } }
上記のコードに基づいて、この述部がJSXExpressionContainerの入力を待機していることが明らかになり、入力ノードにこのパーサーが本当に必要かどうかを判断するさまざまなチェックがあります。
この述部は、次のJSXコンストラクトで機能します。
{ items.map(x=>(<li>{x}</li>) }
解析
構文解析関数は、構成を断片に解析します。また、元のパラメーターの変異を見つけることもできます。
{ items.filter(x=>x>5).filter(x=>x>10).map//etc }
次は変数を決定するプロセスです。resolveVariable関数がこれを担当します。 スコープを決定し、この変数の定義を検索します。
export const resolveVariable = (token:any, identifier:string) => { let newIdentifier = identifier; traverse(resolverRegistry.ast, { enter: (path) => { if (path.node !== token) return; if (path.scope.bindings[identifier]) { const binding = path.scope.bindings[identifier]; const declaratorNode = binding.path.node as VariableDeclarator; if (isObjectPattern(declaratorNode.id) && isMemberExpression(declaratorNode.init)) { const init = declaratorNode.init as MemberExpression; if (isThisExpression(init.object) && isIdentifier(init.property)) { newIdentifier = resolverRegistry.registerVariable(identifier, init.property.name === 'props' ? 'Input' : 'Local'); } } } } }); return newIdentifier; };
このコードでは、 const {varName} = this.props
固定的に探していconst {varName} = this.props
。 これはPoCなので、それで十分です。 このuuid関数は、コンポーネントのテンプレートとASTクラスを構築するために変数識別子を返します。
パーサーの出口で、タイプForLoop
USTノードを取得します。
新しいコンポーネントのテンプレートとクラスを生成する
この場合、 parse5
およびbabel-types
を使用し始めています。 ジェネレーターはマッチャーの原則に基づいて動作しますが、述部はありません。この場合、ジェネレーターは特定のタイプの完全な生成を行います。
export class ForLoopGenerator implements Generator { matchingType = 'ForLoop'; generate(node: any):any { const children = node.children; let key; const attrs:Array<any> = getAttributes(children.attributes.filter((x:any)=>x.name !== 'key')); const originalName = resolverRegistry.vars.get(node.baseItem.name); attrs.push({ name:'*ngFor', value: `let ${node.arguments[0].name} of ${originalName && originalName.name}` }); const htmlNode = { tagName:children.identifier.value, nodeName:children.identifier.value, attrs: attrs, childNodes: new Array<any>(), }; let keyAttribute = children.attributes.find((x:any) => x.name === 'key'); if (keyAttribute) { if (isMemberExpression(keyAttribute.value)) { const {value} = keyAttribute; key = `${value.object.name}.${value.property.name}`; } } for (let child of children.children) { htmlNode.childNodes.push(angularGenerator.generate(child)); } return htmlNode; } }
次に、結果のhtmlノードはparse5
を使用してhtmlに変換されます。
コンポーネントクラスの生成
現時点では、クラスジェネレーターは非常に簡単に動作し、変数から入力パラメーターを収集してクラスに追加します。
export class AngularComponentGenerator { generateInputProps():Array<any> { const declarations:Array<any> = []; resolverRegistry.vars.forEach((value, key, map1) => { switch (value.type) { case 'Input': declarations.push( b.classProperty( b.identifier(value.name), undefined, undefined, [ b.decorator(b.identifier('Input')) ] ) ); } }); return declarations; } generate() { const src = b.file( b.program( [ b.exportNamedDeclaration( b.classDeclaration(b.identifier('MyComponent'), undefined, b.classBody( [ ...this.generateInputProps(), ] ), [ b.decorator(b.callExpression( b.identifier('Component'), [ b.objectExpression( [ b.objectProperty(b.identifier('selector'),b.stringLiteral('my-component'),false,false,[]), b.objectProperty(b.identifier('templateUrl'),b.stringLiteral('./my-component.component.html'),false,false,[]), ] ) ] )) ]), [], undefined, ) ] )); return generator(src).code; } }
結果と結論
入力コンポーネントの場合:
import React from 'react'; class MyComponent extends React.Component { render() { const {a} = this.props; const {b} = this.props; return (<div className="asd"> <h1>Title</h1> { a } { b } <ul> { a.map(asd => (<li key={asd.key}>{asd}<text>asd</text></li>)) } </ul> { children } <h3>And here we go</h3> </div>) } }
ここにテンプレートがあります:
<div class="asd"> <h1>Title</h1> {{a}} {{b}} <ul> <li *ngFor="let asd of a">{{asd}}<text>asd</text></li> </ul> <ng-content></ng-content> <h3>And here we go</h3> <div>
そしてそのようなクラス:
@Component({ selector: "my-component", templateUrl: "./my-component.component.html" }) export class MyComponent { @Input() a; @Input() b; }
一般的な結論
- 現時点では、コンバーターが可能だという感覚があります
- 交差を防ぐために静的述語アナライザーが必要
- たくさんの仕事があります
ご清聴ありがとうございました。
作業が行われているリポジトリへのリンク。 松葉杖はまだたくさんありますが、PoCなので、できます。