路由Hvigor插件实现原理-鸿蒙@fw/router框架源码解析(四)

222 篇文章 0 订阅
222 篇文章 0 订阅

介绍

@fw/router是在HarmonyOS鸿蒙系统中开发应用所使用的开源模块化路由框架。 该路由框架基于模块化开发思想设计,支持页面路由和服务路由,支持自定义装饰器自动注册,与系统路由相比使用更便捷,功能更丰富。

具体功能介绍见@fw/router:鸿蒙模块化路由框架,助力开发者实现高效模块化开发!

基于模块化的开发需求,本框架支持以下功能:

  • 支持页面路由和服务路由;
  • 页面路由支持多种模式(router模式,Navigation模式,混合模式);
  • router模式支持打开非命名路由页面;
  • 页面打开支持多种方式(push/replace),参数传递;关闭页面,返回指定页面,获取返回值,跨页面获取返回值;
  • 支持服务路由,可使用路由url调用公共方法,达到跨技术栈调用以及代码解耦的目的;
  • 支持页面路由/服务路由通过装饰器自动注册;
  • 支持动态导入(在打开路由时才import对应har包),支持自定义动态导入逻辑;
  • 支持添加拦截器(打开路由,关闭路由,获取返回值);
  • Navigation模式下支持自定义Dialog对话框;

代码解析

FWRouterHvigorPlugin

FWRouterHvigorPlugins是@fw/router的一部分,如果使用Navigation页面管理,借助FWRouterHvigorPlugin可以增加开发效率。(当然,如果不用也不是不可以)

Hvigor插件介绍

讲Hvigor插件之前先看一下Hvigor是什么。

hvigor构建工具是一款基于TS实现的构建任务编排工具,主要提供任务管理机制,包括任务注册编排、工程模型管理、配置管理等关键能力,提供专用于构建和测试应用的流程和可配置设置。

DevEco Studio使用构建工具hvigor来自动执行和管理构建流程,实现应用/服务构建任务流的执行,完成HAP/APP的构建打包。

hvigor可独立于DevEco Studio运行,这意味着,你可以在DevEco Studio内、命令行工具或是集成服务器上构建应用。无论您从命令行工具或是DevEco Studio上构建项目,构建过程的输出都将相同。` </pre>

然后看Hvigor插件。

hvigor允许开发者实现自己的插件,开发者可以定义自己的构建逻辑,并与他人共享。

hvigor主要提供了两种方式来实现插件:基于hvigorfile脚本开发插件、基于typescript项目开发。

@fw/router中使用的FWRouterHvigorPlugin是基于typescript项目进行开发的。

FWRouterHvigorPlugin的作用

FWRouterHvigorPlugin是在官方dynamicRouter所使用的Hvigor插件基础上进行修改而来。
具体实现了哪些功能呢?

  • 可手动配置需要处理的.ets文件列表,也支持自动扫描模块源码中的.ets文件;
  • 扫描.ets文件,解析源码,获取NavigationRoute装饰器参数以及其装饰的struct名称;
  • 处理NavigationRoute装饰器的参数,支持路由名routeName和是否包含参数hasParams
  • 根据装饰器参数以及类名,通过预设的代码模板文件,生成出对应的Navigation页面自动注册代码,并写入到统一文件中./src/main/ets/generated/RouterBuilder.ets
  • 处理har包的自动导入逻辑,在index.ets文件中自动导出生成的代码文件export * from './src/main/ets/generated/RouterBuilder';
  • 处理entry包的自动导入逻辑,在EntryAbility.ets文件中自动导入生成的代码文件import('../generated/RouterBuilder');
FWRouterHvigorPlugin源码

FWRouterHvigorPlugin目录就是一个标准的ts项目工程,插件逻辑源码位于.src/router-hvigor-plugin.ts中。

routerHvigorPlugin
export function routerHvigorPlugin(pluginConfig: PluginConfig): HvigorPlugin {
 if (!pluginConfig) {
 pluginConfig = {}
 }
 pluginConfig.annotation = ROUTER_ANNOTATION_NAME;
 pluginConfig.builderTpl = ROUTER_BUILDER_TEMPLATE;
 pluginConfig.builderDir = ROUTER_BUILDER_PATH;
 pluginConfig.builderFileName = ROUTER_BUILDER_NAME;
 return {
 pluginId: PLUGIN_ID,
 apply(node: HvigorNode) {
 // 获取模块名
 pluginConfig.moduleName = node.getNodeName();
 // 获取模块路径
 pluginConfig.modulePath = node.getNodePath();
 if (!pluginConfig.scanFiles) {
 pluginConfig.scanFiles = getFilesWithExtension(node.getNodePath() + '/src/main/ets', '.ets')
 }
 pluginExec(pluginConfig);
 }
 }
}

routerHvigorPlugin方法是Hvigor插件的主入口,返回值就是HvigorPlugin对象。
pluginConfig是插件代码需要用到的各种配置信息。具体信息后面用到了再讲。

if (!pluginConfig.scanFiles) {
pluginConfig.scanFiles = getFilesWithExtension(node.getNodePath() + '/src/main/ets', '.ets')
}

这部分代码主要是处理也支持自动扫描模块源码中的.ets文件逻辑。

pluginExec

该方法实现插件的核心逻辑。


function pluginExec(config: PluginConfig) {
 const templateModel: TemplateModel = {
 viewList: []
 };

 let isEntryModule = false

 // 遍历需要扫描的文件列表
 config.scanFiles?.forEach((file) => {
 // 筛选Entry.ets文件
 if (isEntryEtsFile(file)) {
 isEntryModule = true;
 modifyEntryForImport(file)
 }

 // 文件绝对路径
 let sourcePath = path.isAbsolute(file) ? file : path.join(config.modulePath??'', file);
 if (!sourcePath.endsWith('.ets')) {
 sourcePath = sourcePath + '.ets';
 }
 // 获取文件相对路径
 const importPath = path.relative(`${config.modulePath}/${config.builderDir}`, sourcePath).replaceAll("\", "/").replaceAll(".ets", "");
 const analyzer = new EtsAnalyzer(config, sourcePath);
 // 开始解析文件
 analyzer.start();
 // 如果解析的文件中存在装饰器,则将结果保存到列表中
 if (analyzer.routerAnnotationExisted) {
 templateModel.viewList.push({
 name: analyzer.analyzeResult.name,
 viewName: analyzer.analyzeResult.viewName,
 importPath: importPath,
 functionName: analyzer.analyzeResult.functionName,
 paramsDefine: analyzer.analyzeResult.paramsDefine === undefined ? "" : analyzer.analyzeResult.paramsDefine,
 paramsUse: analyzer.analyzeResult.paramsUse === undefined ? "" : analyzer.analyzeResult.paramsUse
 });
 }

 })
 // 生成路由方法文件
 generateBuilderEtsFileWithTemplate(templateModel, config);
 // 非feature类型模块生成Index.ets文件
 if (!isEntryModule) {
 modifyIndexForExport(config);
 }
}

该方法的主要逻辑是遍历入参中的scanFiles,然后通过EtsAnalyzer解析ets文件,获取解析结果并保存在列表中;然后生成路由自动注册代码。
同时处理好har包和entry包的自动导入逻辑。(两者二选一,所以用isEntryModule进行判断。)
注意,遍历文件时,一个文件调用一次analyzer.start();获取一次analyzer.analyzeResult,因此一个.ets文件中仅能定义一个Navigation页面。

EtsAnalyzer

EtsAnalyzer类是ets代码解析器,因为ets是ts超集,因此这里是用ts官方的typescriptnpm包进行代码解析。
typescriptnpm包主要是将我们的ets源码解析成ts语法树,然后我们遍历语法树找到我们想要的东西并保存下来。

如果想要快速看懂这部分逻辑,最好提前看一下typescript语法解析相关的文章,例如TypeScript 源码详细解读

我们的源码是:

@NavigationRoute({ routeName: "testPage", hasParams: true })
@Component
export struct TestDestination {
 @Prop params?: Record<string, ESObject>

 build() {
 Column() {
 NavDestination() {
 TestPageContent({ pageName: 'TestDestination', params: this.params })
 }
 }
 }
}

我们希望通过EtsAnalyzer类获取到的数据有三个:

  1. struct的名称TestDestination
  2. NavigationRoute参数routeName的值testPage
  3. NavigationRoute参数hasParams的值true
 start() {
 // 读取文件
 const sourceCode = readFileSync(this.sourcePath, "utf-8");
 // 解析文件,生成节点树信息
 const sourceFile = ts.createSourceFile(this.sourcePath, sourceCode, ts.ScriptTarget.ES2021, false);
 // 遍历节点信息
 ts.forEachChild(sourceFile, (node: ts.Node) => {
 // 解析节点
 this.resolveNode(node);
 });
 }

start方法读取代码文件,然后通过ts.createSourceFile就获得了一个语法树,可以以它为根节点进行遍历。

 resolveNode(node: ts.Node): NodeInfo | undefined {
 switch (node.kind) {
 // 未知的声明节点
 case ts.SyntaxKind.MissingDeclaration:
 this.resolveMissDeclaration(node);
 break;
 // 装饰器节点
 case ts.SyntaxKind.Decorator:
 this.resolveDecoration(node);
 break;
 // 表达式节点
 case ts.SyntaxKind.ExpressionStatement:
 this.resolveExpression(node);
 break;
 // 标识符节点
 case ts.SyntaxKind.Identifier:
 return this.resolveIdentifier(node);
 break;
 }
 }

在语法树所有类型节点中,我们只关注装饰器、表达式和标识符三种节点。

 resolveDecoration(node: ts.Node) {
 // 标识符是否是自定义的装饰器
 if (identifier.text === this.pluginConfig.annotation) {
 console.log('ETS文件解析器:' + `该装饰器${identifier.text}是自定义装饰器${this.pluginConfig.annotation}`);
 this.routerAnnotationExisted = true;
 this.decoratorParseState = DecoratorParseState.foundDecorator;
 const arg = callExpression.arguments[0];
 // 调用方法的第一个参数是否是对象{}表达式
 if (arg.kind === ts.SyntaxKind.ObjectLiteralExpression) {
 const properties = (arg as ts.ObjectLiteralExpression).properties;
 // 遍历装饰器中的所有参数
 console.log('ETS文件解析器:' + '遍历装饰器中的所有参数' + properties.toString());
 properties.forEach((propertie) => {
 if (propertie.kind === ts.SyntaxKind.PropertyAssignment) {
 // 参数是否是自定义装饰器中的变量名
 console.log('ETS文件解析器:' + `参数${(propertie.name as ts.Identifier).escapedText}=${(propertie.initializer as ts.StringLiteral).text}`);
 if ((propertie.name as ts.Identifier).escapedText === "routeName") {
 // 将装饰器中的变量的值赋值给解析结果中的变量
 this.analyzeResult.name = (propertie.initializer as ts.StringLiteral).text;
 }
 if ((propertie.name as ts.Identifier).escapedText === "hasParams") {
 // 将装饰器中的变量的值赋值给解析结果中的变量
 this.analyzeResult.paramsDefine = propertie.initializer.kind === ts.SyntaxKind.TrueKeyword ? "params: ESObject" : "";
 this.analyzeResult.paramsUse = propertie.initializer.kind === ts.SyntaxKind.TrueKeyword ? "{ params: params }" : "";
 }
 }
 })
 this.decoratorParseState = DecoratorParseState.parsedParams;
 } else {
 console.log('ETS文件解析器:' + '调用方法的第一个参数不是对象表达式');
 }
 }
 }

resolveDecoration装饰器解析方法我们只看核心逻辑。
首先identifier.text === this.pluginConfig.annotation判断解析的装饰器是不是我们要的装饰器@NavigationRoute
然后是通过const properties = (arg as ts.ObjectLiteralExpression).properties;获取装饰器的所有参数{ routeName: "testPage", hasParams: true }
之后遍历所有参数:


if ((propertie.name as ts.Identifier).escapedText === "routeName") {
 // 将装饰器中的变量的值赋值给解析结果中的变量
 this.analyzeResult.name = (propertie.initializer as ts.StringLiteral).text;
}

获取到routeName的值testPage


if ((propertie.name as ts.Identifier).escapedText === "hasParams") {
 // 将装饰器中的变量的值赋值给解析结果中的变量
 this.analyzeResult.paramsDefine = propertie.initializer.kind === ts.SyntaxKind.TrueKeyword ? "params: ESObject" : "";
 this.analyzeResult.paramsUse = propertie.initializer.kind === ts.SyntaxKind.TrueKeyword ? "{ params: params }" : "";
}

这里是判断hasParams参数,但是该参数不是用来直接生成源码的,而是决定生成的代码是下面两种的哪一种。

// hastPrams为true时
function testDestinationBuilder(params: ESObject) {
 TestDestination({ params: params });
}
// hastPrams为false或者不存在时
function testDestinationBuilder() {
 TestDestination();
}

因此这里将扫描结果区分成了两部分:builder方法的参数定义paramsDefine和builder方法中的参数使用paramsUse
这一部分代码有待改进,因为paramsDefineparamsUse其实是代码模板的逻辑,而不是代码解析器的;从代码内聚原则考虑,不应该放在这个类里面。(以后再优化吧捂脸)

装饰器代码解析完毕后更新了状态位this.decoratorParseState = DecoratorParseState.parsedParams;

下面是解析组件名称:

 resolveExpression(node: ts.Node) {
 let args = node as ts.ExpressionStatement;
 let identifier = this.resolveNode(args.expression);
 if (this.decoratorParseState == DecoratorParseState.parsedParams && identifier?.value === 'struct') {
 this.decoratorParseState = DecoratorParseState.foundStruct
 return
 }
 // 找到`struct`关键字后,后面一个ExpressionStatement就是组件名称
 if (this.decoratorParseState == DecoratorParseState.foundStruct) {
 this.analyzeResult.viewName = identifier?.value;
 let viewName: string = identifier?.value.toString();
 viewName = `${viewName.charAt(0).toLowerCase()}${viewName.slice(1, viewName.length)}`;
 this.analyzeResult.functionName = viewName;

 this.decoratorParseState = DecoratorParseState.idle
 }
 }

因为在源代码中,struct TestDestination组件名称之前是struct,所以这里的逻辑是先找到struct标识符,在找到这个标识符之后的一个表达式就是组件名称表达式,获取其identifier?.value即可得到TestDestination这个取值;
注意看this.decoratorParseState == DecoratorParseState.parsedParams && identifier?.value === 'struct'这个逻辑判断,就是说必须是成功解析到装饰器参数后,其之后的struct才会被解析。

generateBuilderEtsFileWithTemplate
function generateBuilderEtsFileWithTemplate(templateModel: TemplateModel, config: PluginConfig) {
 const builderPath = path.resolve(__dirname, `../${config.builderTpl}`);
 const tpl = readFileSync(builderPath, { encoding: "utf8" });
 const template = Handlebars.compile(tpl);
 const output = template({
 viewList: templateModel.viewList
 });

 const routerBuilderDir = `${config.modulePath}/${config.builderDir}`;
 if (!existsSync(routerBuilderDir)) {
 mkdirSync(routerBuilderDir, { recursive: true });
 }
 writeFileSync(`${routerBuilderDir}/${config.builderFileName}`, output, { encoding: "utf8" });
}

该方法的核心逻辑就是通过Handlebars库实现模板替换。
模板就是根目录的viewBuilder.tpl文件:


// auto-generated
import { RouterManagerForNavigation,RouterClassProvider } from '@fw/router/Index';
{{#each viewList}}
import { {{viewName}} } from '{{importPath}}'
{{/each}}

{{#each viewList}}
@Builder
function {{functionName}}Builder({{paramsDefine}}) {
 {{viewName}}({{paramsUse}});
}

@RouterClassProvider({ routeName: '{{name}}', builder: wrapBuilder({{functionName}}Builder) })
export class {{viewName}}Provider {
}

{{/each}}

const output = template({
 viewList: templateModel.viewList
});

该代码就是用templateModel.viewList中的取值去替换上面tpl中的预定义参数。
{{viewName}}组件名称
{{importPath}}组件的相对路径
{{functionName}}组件名称首字母小写后的值
{{paramsDefine}}参数定义
{{paramsUse}}参数使用
{{name}}页面路由名

具体逻辑细节以及.tpl文件的配置请查看Handlebars库的使用文档。

modifyIndexForExport
function modifyIndexForExport(config: PluginConfig) {
 const indexPath = `${config.modulePath}/Index.ets`;
 if (!existsSync(indexPath)) {
 writeFileSync(indexPath, '', 'utf-8');
 }
 let indexContent: string = readFileSync(indexPath, { encoding: "utf8" });
 if (!indexContent.includes(" * Copyright (c) 2024 Huawei Device Co., Ltd.")) {
 const licensesPath = path.resolve(__dirname, `../license.tpl`);
 const licenses: string = readFileSync(licensesPath, { encoding: "utf-8" });
 indexContent = licenses + "\n" + indexContent;
 }
 const indexArr: string[] = indexContent.split("\n");
 const indexArray: string[] = [];
 indexArr.forEach((value: string) => {
 if (!value.includes(config?.builderDir?.toString() ?? '')) {
 indexArray.push(value);
 }
 });
 indexArray.push(`export * from './${config.builderDir}/${config.builderFileName?.replace(".ets", "")}';`);
 writeFileSync(indexPath, indexArray.join("\n"), { encoding: "utf8" });
}

该方法主要是往har包根目录的Index.ets文件添加export * from './src/main/ets/generated/RouterBuilder';一行代码。

  • 文件不存在则创建;
  • 若没有协议头则增加../license.tpl里的协议头;
  • 若已添加了导出代码则不重复添加;
modifyEntryForImport

function modifyEntryForImport(indexPath: string) {
 console.log('处理EntryAbility文件:' + indexPath);
 const importStatement = "import('../generated/RouterBuilder');";

 // 判断是否已import生成的文件
 let indexContent: string = readFileSync(indexPath, { encoding: "utf8" });
 if (indexContent.includes(importStatement)) {
 return
 }
 const indexArr: string[] = indexContent.split("\n");
 let index = indexArr.findIndex((value, index, obj) => {
 return value.includes('export ')
 })
 indexArr.splice(index, 0, importStatement + '\n');

 writeFileSync(indexPath, indexArr.join("\n"), { encoding: "utf8" });
}

该方法的主要逻辑是添加导入方法。

// ...
import { RouterManager, RouterStrategy } from '@fw/router/Index';

import('../generated/RouterBuilder');

export default class EntryAbility extends UIAbility {
 // ...
}

import('../generated/RouterBuilder');添加到头部的一堆import方法和下面的EntryAbility定义之间。
该定位逻辑通过判断export 来实现。

Hvigor插件的调试运行

DevEco并不提供基于typescript的Hvigor插件的调试运行,因此调试运行需要依靠WebStorm或者VSCode来进行。

但是Hvigor插件虽然是ts项目,但它的运行却依赖两个东西,1是Hvigor,2是具体的项目目录。

那么,在外部IDE中调试运行,如何进行配置呢?

其实,当我们在DevEco中构建运行时,就可以在底部构建-构建输出标签页中看到命令行调用:


/Applications/DevEco-Studio.app/Contents/tools/node/bin/node /Applications/DevEco-Studio.app/Contents/tools/hvigor/bin/hvigorw.js --mode module -p product=default -p module=entry@default,router@default,libraryHarDemo@default,entryForOnlyNavigation@default,entryForOnlyRouter@default,libraryHar@default assembleHap assembleHar --analyze=normal --parallel --incremental --daemon

因此我们可以根据这个来进行配置。

以VSCode为例:

{
 "version": "0.2.0",
 "configurations": [
 {
 "name": "Launch and Attach to hvigor",
 "type": "node",
 "request": "launch",
 "program": "/Applications/DevEco-Studio.app/Contents/tools/hvigor/bin/hvigorw.js",
 "args": [
 "--mode", "module",
 "-p", "product=default",
 "-p", "module=entry@default",
 "assembleHap",
 "--analyze=normal",
 "--parallel",
 "--incremental",
 "--daemon",
 "--debug",
 "--stacktrace"
 ],
 "skipFiles": ["<node_internals>"],
 "env": {
 "NODE_ENV": "development"
 },
 "runtimeExecutable": "/Applications/DevEco-Studio.app/Contents/tools/node/bin/node",
 "runtimeArgs": ["--inspect-brk=9229"],
 "restart": true,
 "sourceMaps": true,
 "cwd": "/path/to/project"
 }
 ]
}

runtimeExecutable是node环境地址,如果不配则使用默认的node;
program是Hvigor的命令行入口;
args是Hvigor的命令行参数;
cwd是你的项目目录;

好消息是这配置看着比较简单,坏消息是我使用这个配置运行不起来[捂脸]。
但是配置本身应该没有问题,因为我咨询了huawei的技术支持,他的回复是他本地用这套配置可以调试运行进断点…………
或许是IDE、node环境、电脑环境之类的有问题吧…… 大家可以使用这个配置来进行尝试,有问题搞不定直接向huawei技术支持提工单问问看。

总结

FWRouterHvigorPlugin插件的代码逻辑其实并不复杂,因为主要的功能都被两个第三方插件干了。
不过当你需要修改FWRouterHvigorPlugin功能,或者在里面新增特性时,修改起来可能会比较痛苦。
第一,你需要先温习下typescript语法解析;
第二,你在模板替换的时候可能会遇到一些意想不到的问题;
第三,如果你本地无法进行断点调试,那么只能通过一行行的日志输出来调整代码……

写在最后

有很多小伙伴不知道学习哪些鸿蒙开发技术?不知道需要重点掌握哪些鸿蒙应用开发知识点?而且学习时频繁踩坑,最终浪费大量时间。所以有一份实用的鸿蒙(HarmonyOS NEXT)文档用来跟着学习是非常有必要的。

希望这一份鸿蒙学习文档能够给大家带来帮助,有需要的小伙伴自行领取,限时开源,先到先得~无套路领取!!

如果你是一名有经验的资深Android移动开发、Java开发、前端开发、对鸿蒙感兴趣以及转行人员,可以直接领取这份资料

请点击→纯血版全套鸿蒙HarmonyOS学习文档

鸿蒙(HarmonyOS NEXT)5.0最新学习路线

在这里插入图片描述

路线图适合人群:

IT开发人员:想要拓展职业边界
零基础小白:鸿蒙爱好者,希望从0到1学习,增加一项技能。
技术提升/进阶跳槽:发展瓶颈期,提升职场竞争力,快速掌握鸿蒙技术

获取以上完整版高清学习路线,请点击→纯血版全套鸿蒙HarmonyOS学习文档

《鸿蒙 (HarmonyOS)开发入门教学视频》

在这里插入图片描述

《鸿蒙生态应用开发V3.0白皮书》

在这里插入图片描述

《鸿蒙 (OpenHarmony)开发基础到实战手册》

OpenHarmony北向、南向开发环境搭建

在这里插入图片描述

《鸿蒙开发基础》

●ArkTS语言
●安装DevEco Studio
●运用你的第一个ArkTS应用
●ArkUI声明式UI开发
.……
在这里插入图片描述

《鸿蒙开发进阶》

●Stage模型入门
●网络管理
●数据管理
●电话服务
●分布式应用开发
●通知与窗口管理
●多媒体技术
●安全技能
●任务管理
●WebGL
●国际化开发
●应用测试
●DFX面向未来设计
●鸿蒙系统移植和裁剪定制
……
在这里插入图片描述

《鸿蒙进阶实战》

●ArkTS实践
●UIAbility应用
●网络案例
……
在这里插入图片描述

获取以上完整鸿蒙HarmonyOS学习文档,请点击→纯血版全套鸿蒙HarmonyOS学习文档

  • 6
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值