本文是系列文章,其他文章见:
鸿蒙@fw/router框架源码解析(一)-router页面管理
鸿蒙@fw/router框架源码解析(二)-Navigation页面管理
鸿蒙@fw/router框架源码解析(三)-Navigation页面容器封装
鸿蒙@fw/router框架源码解析(五)-无代码依赖如何实现拦截器逻辑
鸿蒙@fw/router框架源码解析(六)-模块化开发如何实现代码解耦
鸿蒙@fw/router框架源码解析
介绍
@fw/router是在HarmonyOS鸿蒙系统中开发应用所使用的开源模块化路由框架。 该路由框架基于模块化开发思想设计,支持页面路由和服务路由,支持自定义装饰器自动注册,与系统路由相比使用更便捷,功能更丰富。
具体功能介绍见@fw/router:鸿蒙模块化路由框架,助力开发者实现高效模块化开发!
基于模块化的开发需求,本框架支持以下功能:
- 支持页面路由和服务路由;
- 页面路由支持多种模式(router模式,Navigation模式,混合模式);
- router模式支持打开非命名路由页面;
- 页面打开支持多种方式(push/replace),参数传递;关闭页面,返回指定页面,获取返回值,跨页面获取返回值;
- 支持服务路由,可使用路由url调用公共方法,达到跨技术栈调用以及代码解耦的目的;
- 支持页面路由/服务路由通过装饰器自动注册;
- 支持动态导入(在打开路由时才import对应har包),支持自定义动态导入逻辑;
- 支持添加拦截器(打开路由,关闭路由,获取返回值);
- Navigation模式下支持自定义Dialog对话框;
详见gitee传送门
代码解析
FWRouterHvigorPlugin
FWRouterHvigorPlugins是@fw/router的一部分,如果使用Navigation页面管理,借助FWRouterHvigorPlugin可以增加开发效率。(当然,如果不用也不是不可以)
Hvigor插件介绍
讲Hvigor插件之前先看一下Hvigor是什么。
hvigor构建工具是一款基于TS实现的构建任务编排工具,主要提供任务管理机制,包括任务注册编排、工程模型管理、配置管理等关键能力,提供专用于构建和测试应用的流程和可配置设置。
DevEco Studio使用构建工具hvigor来自动执行和管理构建流程,实现应用/服务构建任务流的执行,完成HAP/APP的构建打包。
hvigor可独立于DevEco Studio运行,这意味着,你可以在DevEco Studio内、命令行工具或是集成服务器上构建应用。无论您从命令行工具或是DevEco Studio上构建项目,构建过程的输出都将相同。
然后看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官方的typescript
npm包进行代码解析。
typescript
npm包主要是将我们的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
类获取到的数据有三个:
struct
的名称TestDestination
;NavigationRoute
参数routeName
的值testPage
;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
;
这一部分代码有待改进,因为paramsDefine
和paramsUse
其实是代码模板的逻辑,而不是代码解析器的;从代码内聚原则考虑,不应该放在这个类里面。(以后再优化吧捂脸)
装饰器代码解析完毕后更新了状态位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语法解析;
第二,你在模板替换的时候可能会遇到一些意想不到的问题;
第三,如果你本地无法进行断点调试,那么只能通过一行行的日志输出来调整代码……