前端开发转战鸿蒙:5个必不可少的思维转变

299 阅读17分钟

0. 引言

最近,本人从前端开发转向了鸿蒙开发。在这个过程中,我意识到鸿蒙系统与传统前端开发之间存在一些显著的不同之处,这些不同催生出了一些必要的思维转变。文中我将逐一探讨这些转变,希望能为各位开发者提供有价值的参考,助力各位前端开发同学平滑过渡到鸿蒙开发,帮助大家在鸿蒙的开发之旅中走得更远。

1. 同样是Typescript,有何不同

从语言方面来看,鸿蒙同样采用Typescript进行开发,这一点对于前端开发同学尤其友好,没有语言的编写和阅读障碍,前端可以快速介入鸿蒙开发。(在鸿蒙中,开发语言被称为ArkTS)

但是,在ArkTS中,部分TS能力被限制:

  • TS 中过于灵活而影响开发正确性 或者 给运行时带来不必要额外开销 的特性,在ArkTS中或多或少都被做了限制。

    • 过于灵活而影响开发正确性:主要指变量声明时必须要提供明确的类型定义。

    相信大家日常开发时,类型定义都是能给就给,但是在ArkTS中,这是一条强制约束,否则无法通过编译。

    // ❌ 禁止 不能凭空创造一个可能包含任何属性的对象
    // Object literal must correspond to some explicitly declared class or interface
    // (arkts-no-untyped-obj-literals) <ArkTSCheck>
    const obj = {
      a: 1,
      b: '2'
    }
    
    interface Obj {
      a: number;
      b: string;
    }
    
    // ✅ 正确 声明的对象必须要有明确的类型
    const obj: Obj = {
      a: 1,
      b: '2'
    }
    
    • 给运行时带来不必要额外开销:主要指JS在运行时过于动态的特性。
    interface Obj {
      a: number;
      b: string;
    }
    
    const obj: Obj = {
      a: 1,
      b: '2'
    }
    // ❌ 不允许动态添加属性
    obj.c = true
    
    // ❌ 不允许解构运算
    const { a, b } = obj
    
    const obj1 = {
      // ❌ 扩展运算符不支持展开对象
      ...obj
    }
    

    (之前总有其他技术栈的同学吐槽JS的扩展运算符太灵活了,这回终于被制裁了)

  • 任何企图绕过TS检查的能力(比如ts-ignore、any)都被禁止(想只用JS不加类型蒙混过关,门都没有)

// ❌ 禁止,任何TS错误都逃不掉
// @ts-ignore 
const value = getValue()
// ❌ 禁止,不能用any,要有明确的类型
const obj: any = { a: 1 }

更多TS与ArkTS的不同,可参考:从TypeScript到ArkTS的适配规则

鸿蒙究竟为什么要这么做,选用Typescript作为自己的开发语言,又要费这么大功夫把其中一些特性禁用掉?

我相信鸿蒙最初选择TS可能是迫不得已。如果重新创建一门语言(比如鸿蒙仓颉语言),生态的发展和研发的上手成本会很高;最好选一个已经成熟的语言,并且是开源的(防止一些版权问题),而且还要上手成本低,使用范围广,降低鸿蒙开发者学习成本。综上来看,TS确实是个不错的选择。

其次,鸿蒙作为一项NA的技术栈,其稳定性和性能尤为重要,TS作为一门动态语言,对比其他NA技术栈所使用的静态语言,天生在稳定性和性能上存在一些劣势。

  • 稳定性:静态语言拥有严格的类型定义,在编译阶段会进行类型检查,相关错误会在开发阶段立即发现和修复。而动态语言通常更加灵活,在运行时才进行类型检查,如果出现错误可能导致线上问题。

    虽然TS本身已经在JS的基础上增加了类型,但是这些类型信息在运行时是丢失的,并且可手动绕过类型检查,其稳定性依赖开发者的使用方式。

  • 性能:由于静态语言在编译阶段的类型及代码结构更加确定,编译器可利用其类型信息进行更合理的内存分配,以及对特定代码结构进行针对性优化,从而提升运行时性能。但动态语言在编译期缺少对应的类型信息,只能通过一些运行时的信息进行优化,性能一般较差。

    各大浏览器其实都针对JS运行时做了特殊场景下的优化,例如为了提升对象属性访问速度,设置了隐藏类(Hidden Class)和快慢属性;为了提升JS运行时反复执行代码的效率,设置了热点代码(Hot Code)机制;等等。但是这些优化本身都是在保留JS动态性的基础上做的。

ArkTS为了解决以上两方面问题,先是 TS 的类型检查作为强要求,且去除所有可能绕过类型检查或回退至弱类型语言的能力,在编译阶段提升代码稳定性。和普通TS不同的是,TS在编译为JS后,类型信息被丢弃掉,无法带到运行时;但ArkTS可以保留类型信息,利用PGO优化为高性能机器码,提高运行时执行效率。同时削弱TS的运行时动态性,从源头将动态性禁止掉,减少执行时开销。

因此,前端开发同学在使用ArkTS时,一定要有个思想上的转换,虽然从语法上看,ArkTS和TS/JS差别不大,但在编译和运行的背后,ArkTS有着不同的使命和发展方向。之前使用JS极强灵活性的时代一去不复返了。

2. 从单页到多页,视角转变,Scope变大

作为前端开发者,尤其是移动端前端开发,通常只需聚焦于页面功能的实现,专注于自身页面的生命周期和能力使用,对于整个App级别的事情无需过多考虑。但在鸿蒙开发(包括其他移动端原生开发)时,需要从整个App应用视角思考问题及设计方案,既要利用更大的能力范围优化用户体验,也要谨慎应对资源和状态管理的挑战。

  1. 可控范围扩大

前端开发中,我们可控的范围只有整个页面,因此只需关于页面级和组件级生命周期要做的事情,例如请求初始化、加载页面缓存数据等。对于整个html及js文件加载之前的功能,都是浏览器/容器的职责,我们几乎无法干预。

但当视角扩展到整个App级别时,会发现我们可控的事情更多了。从应用冷启动到被销毁、应用前后台切换、以及进入我们负责页面的前一级页面,我们都可以做控制逻辑。比如

  • 在应用冷启动X时间后,初始化业务基础功能;
  • 在进入前一级页面时,预请求本页面数据,预加载资源;
  • 在应用前后台切换时,持久化存储数据等;

原生应用赋予了我们更多能力,让我们通过更细致且合理的设计来优化页面功能及用户体验。

  1. 谨慎使用单例

前端开发经常使用却几乎无感的事情,就是使用单例:请求器是单例,发送埋点是单例,状态管理也是单例。由于前端页面仅运行在浏览器(或端容器)提供的独立运行时内,我们可以随意的创造单例而不用担心和其他页面相互影响。甚至相同的前端页面被打开多次时,也是多个独立的环境,之间的状态/变量不会混乱。

但在鸿蒙这种App级别的开发中,单例要慎用。除非是整个App运行时全局唯一的能力(比如路由管理等),才会创建单例让各个页面都能使用。对于页面级别的状态,都需要和页面的实例相绑定。即使打开多个相同的页面,也是不同的实例,对应着不同的状态数据。防止出现页面共享同一个状态时发生竞争或冲突,保证状态的独立性。

  1. 做好资源管理

由于前端运行时,各页面之间环境是独立的,且页面退出后环境就会销毁,浏览器/容器会替我们回收内存及清理环境,下次打开时一些状态都会重新初始化,有时前端开发者不会额外注重无效资源的清理,以及移除事件监听器等(反正页面没了啥都没了)。这些习惯带到App开发中,可能会导致事件的重复监听,大量占用未释放的内存,导致App长时间运行后性能下降。因此养成显示清理资源的好习惯尤为重要。

总之,当前端开发者转向鸿蒙时,需要把视角放到整个App级别,可以在整个应用生命周期内做优化,但同时也要注意页面间状态数据相互影响,以及主动管理资源清理

3. 架构分层,项目复杂度变高

上一小节提到,从前端转到鸿蒙,其实是从单页应用转向整个App维度开发,除了在运行时的逻辑控制(如上一小节所讲)需要注意,在整个应用设计时的架构分层也需额外关注。

在前端开发单页应用时,通常目录及架构相对简单,采用MVVM或MVC模型,外加对请求、日志等基础库的业务侧封装,即可实现单页应用的绝大多数功能。(典型的单页应用项目目录如下)

典型前端项目目录如下

src/
├── assets/         # 静态资源
│   ├── images/    # 图片
│   ├── styles/    # 全局样式文件
│   └── fonts/     # 字体文件
├── components/     # 通用组件库
│   ├── Button/    # 按钮组件
│   └── ...        # 更多通用组件
├── pages/          # 页面组件
│   ├── Home/      # 首页
│   ├── About/     # 关于页面
│   └── ...        # 更多页面
├── services/       # 服务层
│   ├── api/       # API 接口封装
│   ├── utils/      # 工具类
│   └── config.ts   # 全局配置
├── store/          # 状态管理
│   ├── modules/    # 按模块划分的状态管理
│   └── index.ts    # 状态管理入口
├── routes/         # 路由配置
│   └── index.ts   # 路由表
├── hooks/         # 自定义 Hook
├── App.tsx        # 应用入口
└── index.html     # HTML 模板

image.png

在切换到鸿蒙开发后,一个大型App一般会包含多个业务,每个业务下会有多个页面,必须要对整个App进行架构分层,才利于长久维护。

  • 基建层:封装所有与业务无关的基础能力,如网络库、日志库、工具类
  • 业务层:每个业务封装为独立模块,确保模块内高内聚与模块间低耦合
  • 页面层:业务层继续拆分为页面,每个页面内实现具体UI与用户交互逻辑,在页面内可使用MVVM等模型
src/
├── infra/              # 基建层
│   ├── network/       # 网络库
│   ├── logger/        # 日志模块
│   ├── storage/       # 数据持久化
│   ├── utils/         # 工具类
│   └── config/        # 配置文件
├── business/           # 核心业务层
│   ├── payment/       # 支付页面
│   ├── order/         # 订单页面
│   │    ├── order_detail/       # 订单详情 每个页面下的结构,和前端MVVM架构目录差不多
│   │    │    ├── assets/       
│   │    │    ├── components/     
│   │    │    ├── store/ 
│   │    │    ├── service/        
│   │    │    ├── entry/ 
│   │    │         └── app.ets          
│   │    ├── order_list/         # 订单列表
│   │    └── order_base/         # 订单通用业务逻辑
│   └── shared/        # 公共业务逻辑
├── entry/             # 应用入口
│    └── app.ets       # 应用入口
└── # 其他工程化配置

同时,应用模块间依赖需要遵循以下原则

  • 依赖自上而下:上层模块可以依赖下层模块,不能反向依赖,基建层在最底层。
  • 避免交叉依赖:不允许同层级组件相互依赖,如果必须要有依赖,通过在公共业务层抽api层,通过api层实现依赖
  • 公共逻辑集中管理:若多个页面共用的业务逻辑,抽到公共业务层,避免重复逻辑

image.png

从前端单页开发转向鸿蒙应用开发后,同项目合作者数量会急剧增加,必须有合理的架构分层设计以及必须遵守的规范和共识,否则随着时间推移,项目可维护性会逐渐降低。

4. 异步&多线程,更强的能力,更高的要求

在传统的前端开发中,JavaScript本身是不支持多线程的,前端中的异步主要依赖于浏览器或Node环境提供的单线程事件循环模型,通过区分微任务Promise和宏任务setTimeout,JS主线程不断的从任务队列中取出任务并执行。即便JS代码使用了async/await这种看起来像是并发执行的逻辑,其本质仍是单线程的。

鸿蒙原生除了提供和JS一样的Promise以及async/await外(当前,其本质也是JS一样,是单线程的),还提供了真正跨线程执行任务的能力,即Worker

类似于Web Worker,鸿蒙Worker也需要开发者自行创建并且管理其生命周期,并且需要处理多个线程之间的参数传递及解析。(以下是两者的使用差异,是不是看起来很像)

Web Worker使用

// 创建worker
const worker = new Worker('worker.js');
// 发送消息
worker.postMessage(data);
// 接收消息
worker.onmessage = function(e) {
    console.log(e.data);
};
// 关闭worker
worker.terminate();

鸿蒙Worker使用

// 创建worker
const work = new worker.ThreadWorker('work.ets')
// 发送消息
worker.postMessage(data);
// 接收消息
worker.onmessage = function(e) {
    console.log(e.data);
};
// 关闭worker
worker.terminate();

一般来说,数量少但处理时间较长,或者在后台长时间执行的任务,都适合使用Worker来执行。(这一点前端和鸿蒙是一致的)。

与Web Worker不同的是,鸿蒙Worker是有能力利用多核CPU进行硬件级别并发的。

除此之外,如果遇到大量运行,且调用场景分散,且相互独立的任务,鸿蒙额外提供了TaskPool(任务池)的方式来轻量级的使用多线程能力。

TaskPool会预先创建一系列工作线程,当收到任务时,系统会自动选择合适的线程进行执行,并将执行结果返回给主线程。 省去频繁创建和销毁线程的开销,并且简化了使用成本。

@Concurrent
function addAsync(a: number, b: number) {
    return a + b
}

const task = new taskpool.Task(addAsync, 1, 2)
const result = await taskpool.execute(task)
console.log(result) // 3

是不是用起来很简单,虽然这里也使用了await,但这里await的内容是真正在其他线程中执行的,不会阻塞主线程的运行。另外,TaskPool同时支持设置任务优先级,以及让开发者根据实际情况频繁创建或取消并发任务,当系统资源不足时,可以动态调整任务执行顺便,以保证高优任务顺利执行。

由上述对比可以发现,前端开发中更多使用单线程异步能力,在鸿蒙中为开发者提供了更多多线程任务运行能力,开发者可以直接接触到线程管理,需要合理分配以及调用线程资源,在优化性能的同时,也需要注意多线程间的并发安全。 这对开发者提出了更高的要求。毫不夸张的说,鸿蒙中的多线程,更接近操作系统的开发理念。

本小节图片来源TaskPool和Worker的对比实践

  1. 热重载没了,效率变低了?

在前端的世界里,有一项让开发者受益已久但却习以为常的技术——热重载(又称Hot Reload、Hot module replacement HMR)。

热重载允许我们在开发期间,当代码发生变更时,仅需重新编译受影响的模块(当前代码及其依赖),而不需要重新编译整个项目。同时,在浏览器中运行的前端项目,仅需将新编译模块进行替换,无需重启整个应用。这项技术极大改善了前端开发者的开发体验,通常仅需数秒就可看到自己的改动内容。

前端项目之所以能够支持HMR,主要由几项原因

  • 浏览器本身在运行时就支持动态加载资源(JS、HTML、CSS)

  • JS本身是解释型语言,只有运行到的代码才会进行字节码编译,其余代码可随时替换不受影响

  • 前端项目工具链(如Webpack、Vite等)本身将前端项目拆分为不同模块,热重载时只需替换对应模块

image.png

前端与客户端的核心不同在于:客户端的原生代码最终会被编译为二进制文件(对应apk或pia文件),在运行时通常无法直接修改或替换其中某些代码。同时还要面临签名校验以及安全验证等官方检查。难以做到热重载。

因此当切换到鸿蒙开发(包括安卓或iOS开发)后,一项巨大的体感变化就是:开发效率变低了。小小一个UI改动,需要整个App重新编译,数分钟后才能看到改动效果。一天下来可能也改不了多少代码。

在鸿蒙的最初版本亦是如此。不过在最近的DevEco Studio(鸿蒙开发工具)版本中,已经支持了部分场景下的Hot Reload(例如更改UI、更改函数等) 。手动点赞👍。实测下来数十秒就能完成一次小改动的更新。虽然目前使用仍有一定局限性(例如新import的文件是无法热重载的),但对开发效率确实是很大的提升。

鸿蒙Hot Reload说明 -> ide-hot-reload-V5

对比安卓和iOS,其实它们也有支持动态更新的技术(比如iOS的Inject,安卓的Tinker),不过基本都是用于热修方案,在开发中用于热重载的场景不多。(并且这些热修方案也不被官方所提倡)。

相反,鸿蒙官方提供了一种增量调试的手段,允许开发者打出增量代码包(.hqf文件),并将增量代码与原代码包在设备上结合,实现热更新能力。(甚至这种能力可以用于应用商城中热修复)

基于前面的对比,大家在从前端转向鸿蒙开发时,对开发效率一定要有预期(排期稍微多排几天hhh)。并且需要改变一下开发习惯,从之前的写一行看一下结果,变成多写一些模块再统一检查结果。

∞. 小结

从前端开发到鸿蒙开发,虽然在技术栈上存在一定的延续性,但两者在思维模式、开发理念和技术实现上却有着显著的差异。

  • 语言适配: 从TypeScript到ArkTS,虽然语法相似,但鸿蒙对类型的严格约束和动态特性的削弱,体现了对稳定性和性能的追求。
  • 视角扩展: 从单页应用到整个App维度的开发,开发者需要关注更大的Scope,不仅仅是页面的逻辑实现,更要考虑应用生命周期、资源管理和跨页面的状态隔离。
  • 架构设计: 单页应用的简单结构已经无法满足复杂App的需求。在鸿蒙开发中,需要引入清晰的分层架构,合理规划基建层、业务层和页面层,同时注意模块的解耦和依赖管理。
  • 多线程能力: 前端开发主要依赖单线程异步模型,而鸿蒙开发提供了真正的多线程能力(如Worker和TaskPool),在提升性能的同时也对开发者提出了更高的要求,如合理的线程管理和并发安全。
  • 开发效率: 前端开发的热重载提升了开发体验,但鸿蒙开发由于编译和调试流程的不同,效率相对较低。不过,鸿蒙已经通过Hot Reload等功能逐步优化了开发效率,未来可期。

以上这些转变不仅仅是挑战,也是大家提升自身技术水平的机会。希望本文为前端开发者转向鸿蒙开发提供了一些实用的指引和参考,让大家能够快速适应新的开发环境,在鸿蒙的开发旅程中越走越远!


如果你在从前端转到鸿蒙开发之后,还遇到了哪些习惯或思维上的改变,欢迎留言一起讨论~