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

521 阅读11分钟

本文是系列文章,其他文章见:
鸿蒙@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官方的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语法解析;
第二,你在模板替换的时候可能会遇到一些意想不到的问题;
第三,如果你本地无法进行断点调试,那么只能通过一行行的日志输出来调整代码……