2024年1月

仅需半小时,即可实现纯血鸿蒙版本的ChatGPT!

废话少说,先看效果图:

如上图所示,这个小Demo实现了AI智能问答。靠右加粗的文本是用户点击底部提交按钮后出现的;后面靠左对齐的普通文本是来自AI的回答内容。当然,整个内容是可滑动浏览的,当内容被滑动时,屏幕右侧将出现滚动条。最后,为什么UI是英文呢?因为鸿蒙的模拟器目前没有内置中文输入法,恰好这个AI服务也可以用英文来回答。

值得注意的是:这个小Demo之所以我称其为Demo,是因为它的功能实在是太简单了。只有一个基础的AI对话功能,如果要做成一个产品,我觉得起码得有个数据持久化的过程,而且还能支持文本的编辑、复制、删除,还要提供收藏功能。更重要的,UI也需要好好美化一下……

所以,这篇文章就权当抛砖引玉,让大家体会一下开发原生纯血鸿蒙版本的App是有多么轻松。

前置条件

  1. DevEco 3.1.1 Release;
  2. 在百度智能云控制台上创建好应用,保存好API Key和Secret Key。

创建项目(5分钟)

使用DevEco创建项目仅需两步,第一步选择类型,第二步填写项目信息。

对于第一步,我们选择Application(应用程序)->Empty Ability(空白Ability);

对于第二步,我们选择迄今为止最新的Compile SDK,即3.1.0(API 9),Model选择Stage,不开启“Enable Super Visual”。其余的内容大家根据自身环境配置进行填写就好。

编码实现(20分钟)

整个编码过程分为三个步骤,首先添加权限,然后实现UI,最后实现网络操作。

添加权限(2分钟)

在整个App的项目结构中,找到默认创建的entry模块,依次定位到src->main->module.json5,权限在该文件中进行配置。

这个Demo功能非常简单,但仍需对其添加必要的网络访问权限,以确保可以打开网络套接字,完成HTTP请求和响应。

代码片段如下:

"requestPermissions": [
  {
    "name": "ohos.permission.INTERNET",
  }
],

UI实现(10分钟)

回过头来看本文最上方的截图,经过布局分析后,可以得到如下结论:整个界面是纵向布局,由两个部分构成。一是可滚动的对话历史;二是下方的输入框和提交按钮。

因此,整个UI布局最外层应该是一个Column,表示纵向布局。其中,占据90%高度的对话历史区域,占据10%高度的输入框和按钮区域。

先来看对话历史区域,它其实本质上也是一个Column,每个item就是一段文字。根据文字的类型来判断是居左还是居右。在这个Column之外,为了让整个对话历史区域可以上下滚动查看,因此还需要Scroll组件将整个Column组件包裹起来。

代码片段如下:

@State messagesList: Object[] = [{ 'role': 'user', 'content': 'What can I help you with?' }]
// 历史问答
Scroll() {
  Column() {
    ForEach(this.messagesList, (item: Object) => {
      if (item['role'] == 'user') {
        Text(item['content']).fontSize(20).fontWeight(FontWeight.Bold).width('100%').textAlign(TextAlign.End)
      } else {
        Text(item['content']).fontSize(20).width('100%').textAlign(TextAlign.Start)
      }
    })
  }.width('100%').alignItems(HorizontalAlign.Center)
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Auto)
.scrollBarColor(Color.Gray)
.scrollBarWidth(10)
.edgeEffect(EdgeEffect.Fade)
.height('90%')
.width('100%')

请注意这段代码中的messagesList,它是一个对象数组。role表示角色,即该条消息是用户发送的,还是服务器返回的;content表示文字内容。

在由Scroll包裹的Column组件之中,使用了ArkTS提供的ForEach渲染方式进行逐条消息的渲染,并使用if...else...条件判断语句对角色来源进行区分。

再来看底部的输入框和操作按钮,由于它们是横向排列的,所以使用Row组件进行布局。在此,我将文本输入框设定了80%的宽度,提交按钮设定了20%的宽度。

代码片段如下:

@State questionStr: string = ''
// 文本输入和提交
Row() {
  TextInput({ placeholder: 'Please input your question', text: this.questionStr })
    .type(InputType.Normal)
    .onChange((value: string) => {
      this.questionStr = value
    })
    .width('80%')
  Button('提交').type(ButtonType.Capsule).onClick(() => {
    this.messagesList.push({ 'role': 'user', 'content': this.questionStr })
    getAnswer(this.questionStr, this.messagesList)
    this.questionStr = ''
  }).width('20%')
}.height('10%').width('100%')

在这段代码中,questionStr表示输入框中的文字字符串。getAnswer()函数发起并接收HTTP请求,向服务器提交用户问题字符串,并等待接收响应内容,将问题的回答放入messagesList对象数组之中,完成整个问答流程。

最后,将上述Scroll组件和Row组件一并放入Column内,完成整个UI绘制。

网络访问(8分钟)

根据百度官方的开发文档,完成整个AI问答过程至少需要两个步骤:获取access_token和获取问题答案。

获取access_token的过程在程序一开始就可以进行了,因为在后续的操作中都要用到access_token。因此,我声明了access_token的全局变量,并将获取该值的方法封装为getToken()函数,具体代码如下:

var access_token: string = ''
function getToken() {
  let httpRequest = http.createHttp();
  httpRequest.on('headersReceive', (header) => {
    console.info('header: ' + JSON.stringify(header));
  });
  httpRequest.request(
    "https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=[你的应用的API Key]&client_secret=[你的应用的Secret Key]",
    {
      method: http.RequestMethod.POST,
      header: {
        'Content-Type': 'application/json',
      },
      expectDataType: http.HttpDataType.OBJECT,
      usingCache: true,
      priority: 1,
      connectTimeout: 60000,
      readTimeout: 60000,
      usingProtocol: http.HttpProtocol.HTTP1_1,
    }, (err, data) => {
    if (!err) {
      access_token = data.result['access_token']
    } else {
      httpRequest.off('headersReceive')
      httpRequest.destroy()
    }
  });
}

getToken()函数我在回调的onPageShow()函数中使用,即程序启动后,就获取access_token。

最后,我们来实现getAnswer()函数,它是向服务器提交问题和接收响应的函数,具体代码如下:

function getAnswer(questionStr: string, messageList: Object[]) {
  let httpRequest = http.createHttp();
  httpRequest.on('headersReceive', (header) => {
    console.info('header: ' + JSON.stringify(header));
  });
  httpRequest.request(
    "https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/yi_34b_chat?access_token=" + access_token,
    {
      method: http.RequestMethod.POST,
      header: {
        'Content-Type': 'application/json'
      },
      extraData: { "messages": [{
        "role": "user",
        "content": questionStr
      }] },
      expectDataType: http.HttpDataType.OBJECT,
      usingCache: true,
      priority: 1,
      connectTimeout: 60000,
      readTimeout: 60000,
      usingProtocol: http.HttpProtocol.HTTP1_1,
    }, (err, data) => {
    if (!err) {
      messageList.push({ 'role': 'assist', 'content': data.result['result'] })
    } else {
      httpRequest.off('headersReceive')
      httpRequest.destroy()
    }
  }
  );
}

还需要做些别的吗?

答案是:没有了,真的不用了。

运行项目(5分钟)

无论是本地模拟器,还是真机,抑或是远程模拟器,只需要启动其中一个,然后让这个程序跑起来吧!如无意外,你将会得到一个超级简易的AI问答机器人,纯血鸿蒙版。

当然,这里我写了需要5分钟,是包括了下载模拟器镜像的时间,如果你有真机或是使用远程模拟器的话,那就会更快了。

最后,我们来对比一下。我完成上述功能,Index.ets一共117行,你的呢?

本篇的性能优化不是八股文类的优化方案,而是针对具体场景,具体分析,从排查卡顿根因到一步步寻找解决方案,甚至是规避等方案来最终解决性能问题的经历实操

所以,解决方案可能不通用,不适用于你的场景,但这个解决过程是如何一步步去处理的,解决思路是怎么样的,应该还是可以提供一些参考、借鉴意义的

当然,也许你还有更好的解决方案,也欢迎评论教一下,万分感谢

问题现象

我基于
twaver.js
库实现了一个园区内网络设备的拓扑呈现,连线表示设备间的拓扑关系,线路上支持流动动画、告警动画、链路信息等呈现,如:

但当呈现的节点数量超过 1000 后,动画开始有点丢帧,操作有点点滞后感

超过 5000 个节点后,页面就非常的卡顿,难以操作

所以,就开始了性能优化之路

猜测&验证

猜测 1:Vue 框架的响应式处理导致的性能瓶颈

之所以有这个猜测是因为,我在官方给的 demo 上体验时,上万个节点时都不卡顿,更何况是一千个节点而已

而我的项目跟官方 demo 的差异有两块:

  • 我用 vue 框架开发,官方 demo 用的纯 html + js
  • 我功能已经开发完,所以实际上还参杂了其他各种实现的代码,官方 demo 很简单的纯节点和链路

为了验证这个猜想,我另外搞了个空项目,纯粹就只是把官方 demo 的代码迁移到 vue 上运行起来而已,如:

【10000 个节点,20000 条连线,twaver 官方 demo 耗时 250ms,不卡顿】

【10000 个节点,20000 条连线,vue 实现的 demo 耗时 11500ms,操作上有 0.5s 的滞后感】

同样的代码,同样的数据量,区别仅仅是一个用纯 js 实现,一个用 vue 实现,但两边的耗时差异将近 45 倍

所以就开始思考了,Vue 框架能影响到性能问题的是什么?

无非不就是响应式处理,内部会自动对复杂对象深度遍历去配置 setter, getter 来拦截对象属性的读写

而 twaver 的对象结构又非常复杂,就导致了一堆无效的响应式处理耗时资源:

看到没有,twaver 的两个变量 box 和 network,内部结构非常复杂,N 多的内嵌对象,全部都被响应式处理,这占用的资源是非常恐怖的

(注:Vue2.x 版本可以直接在开发者工具面板上查看对象内部是否有 setter 和 getter 就知道这个对象是否有被响应式处理)

但我们其实又不需要它能够响应式,我们只是想使用 twaver 对象的一些 api 而已

那么该怎么来避免 Vue 对这些数据进行的响应式处理呢?

下一章节里再具体介绍解法,至少到这里已经明确了卡顿的根因之一是 Vue 对 twaver 的数据对象进行了响应式处理而引发的性能瓶颈

猜测 2:动画太多导致的性能瓶颈

这个应该是显而易见的根因之一了,每条链路上都会有各种动画,而实现上又是每条链路内部自己维护自己的动画管理器(twaver.Animate)

简单去捞了下 twaver 内部源码实现,动画管理器用了
requestAnimationFrame
来实现动画帧,用了
setTimeout
来实现动画的延迟执行

那么当节点成千上万时,肯定会卡顿,毕竟这么多异步任务

而之所以会这么实现,原因之一是官方给的链路动画 demo 就是这么做的,当初做的时候直接用 demo 方案来实现了

而 demo 显然只是介绍链路动画怎么实现而已,不会给你考虑到极端场景的性能瓶颈问题

那么怎么解决呢?不难,无非就是抽离复用 + 按需刷新思路而已,具体也是下面讲解

猜测 3:一次性呈现的节点链路太多导致的性能瓶颈

这也是显而易见的根因之一,就像长列表问题一样,一次性呈现的节点链路太多了,必然会导致性能瓶颈问题

也不需要去验证了,思考解决方案就行

但这跟长列表实现上有点不太一样,因为 twaver 内部是用 canvas 来绘制节点和链路的,并不是用 dom 绘制,所以虚拟列表那种思路在这里行不通

但本质上的解决都一个样,无非就是一次性没必要呈现这么多节点,因为一屏内又显示不了,没有意义

所以,按照这种思路去寻找解决方案,具体也下面讲讲

猜测 4:dom 节点太多导致的性能瓶颈

虽然 twaver 内部是用 canvas 绘制的节点和链路,但当节点毕竟复杂时,比如:

这种时候用 canvas 画不出来,只能用 div 绘制,twaver 也支持 HTMLNode 类型节点,这就意味着也会存在 dom 过多的场景

而 dom 导致的性能问题包括 dom 元素过多,频繁操作 dom

因此解决方案上就是尽量避免创建过多的 dom 元素以及避免频繁操作 dom 即可,具体也下面讲

解决方案

绕过 Vue 的自动对数据模型进行的响应式处理

Vue2.x 框架内部会自动将声明在 data 里的变量进行响应式处理,第一个想到的是尝试用 Object.freeze 来冻结对象,例如:

this.box = Object.freeze(new twaver.ElementBox());

但有两个问题:

  • Object.freeze 是浅冻结,不是深度冻结,内嵌的对象好像还是会被响应式处理
  • 可能会引发功能异常,因为没法确认三方库内部是否有用到对象的枚举、遍历、扩展等能力

那么还有其他什么方案吗?

如果是 Vue3.x 的话,因为响应式处理是显示调用,就没有这些烦恼了。

至于 Vue2.x,内部自动进行了响应式处理,因此我们需要去源码里看看有没有什么办法可以绕过响应式处理。

注:下面是 Vue 2.7.16 版本的源码

源码里给 data 数据进行响应式处理是在
core/instance/state.ts#initData()

// core/instance/state.ts
function initData(vm: Component) {
  let data: any = vm.$options.data;
  data = vm._data = isFunction(data) ? getData(data, vm) : data || {};
  // 省略判断 data 为对象的代码
  //  ...

  const keys = Object.keys(data);
  const props = vm.$options.props;
  const methods = vm.$options.methods;
  let i = keys.length;
  while (i--) {
    const key = keys[i];
    // 省略判断 data 的字段与 props 或 methods 是否有同名的场景
    // ...

    // 判断变量命名是否是 _ 或 $ 为前缀
    if (!isReserved(key)) {
      proxy(vm, `_data`, key); // 这里是关键之一,把 data 里的对象挂载到外部 vue 组件上
    }
  }
  const ob = observe(data); // 响应式处理 data 数据
  ob && ob.vmCount++;
}

上面的源码里我省略了一些无关的代码,然后有两个关键点,一个是通过
isReserved(key)
判断变量命名是否是以
_

$
开头的代理处理,另一个是
observe(data)
处理响应式的 data 数据

第一点等会再讲,先来看看是怎么对 data 数据进行响应式处理的:

// core/observer/index.ts
export function observe(
  value: any,
  shallow?: boolean,
  ssrMockReactivity?: boolean
): Observer | void {
  // 如果该对象已经响应式处理过了,就跳过,没必要再次处理
  if (value && hasOwn(value, "__ob__") && value.__ob__ instanceof Observer) {
    return value.__ob__;
  }
  // 当满足下面条件时,对对象进行响应式处理
  if (
    shouldObserve && // 总开关
    (ssrMockReactivity || !isServerRendering()) && // 非服务端渲染场景
    (isArray(value) || isPlainObject(value)) && // 数组或对象
    Object.isExtensible(value) && // 支持扩展(即动态增删字段)
    !value.__v_skip /* ReactiveFlags.SKIP */ && // 是否跳过响应式处理
    !isRef(value) && // // 是否是响应式对象
    !(value instanceof VNode) // 是否是 VNode 对象
  ) {
    // 内部遍历对象的属性,调用 defineReactive() 来对属性进行 setter, getter 拦截
    // 而 setter 里又重新调用 observe() 处理属性值,从而达到深度递归处理内嵌对象属性的响应式效果
    return new Observer(value, shallow, ssrMockReactivity);
  }
}

所以,我们其实是有办法来绕过响应式处理的,比如给对象增加一个要跳过响应式处理的标志
__v_skip
,如:

const box = new twaver.ElementBox();
box.__v_skip = true; // 这个是关键
this.box = box;

const network = new twaver.vector.Network(this.box);
network.__v_skip = true; // 这个是关键
this.network = network;

注意: __v_skip 是 Vue2.7.x 版本后加入的逻辑,在 Vue2.6 及之前版本里,并没有该逻辑,相反只有一个 _isVue 标志位判断

有人说,不用这么麻烦,把变量命名改成
_
为前缀,也能绕过响应式处理,这是真的吗?毕竟源码里好像没有看到相关的代码

别急,还记得我上面介绍
initData()
源码里的两个关键点之一的 ``

// core/instance/state.ts
function initData(vm: Component) {
  // 省略其他无关代码
  //  ...
  while (i--) {
    // 省略其他无关代码
    //  ...

    // 判断变量命名是否是 _ 或 $ 为前缀
    if (!isReserved(key)) {
      // 把 data 里的对象挂载到外部 vue 组件上
      proxy(vm, `_data`, key);
    }
  }
  // 省略其他无关代码
  //  ...
}

这里会遍历 data 里的各个属性字段,然后把里面非
_

$
为前缀的变量都挂到外部 Vue 组件实例上,这样我们代码里才可以直接用
this.xxx
来操作这些变量

由于我们命名了
_box

_network
变量,这些以
_
开头的变量就没有被挂到 Vue 组件实例上,而后续我们代码里使用
this._box = xxx
这样来赋值变量,其实本质上是动态的往 Vue 组件实例上增加了一个
_box
变量,由于 Vue2.x 不支持对动态添加的属性进行响应式处理,因此这才能达到绕过响应式处理的效果

所以把变量命名改成
_
为前缀,其实是误打误撞的刚好绕过了响应式处理

Vue 官方文档里其实也有解释说了:

Properties that start with _ or $ will not be proxied on the Vue instance because they may conflict with Vue’s internal properties and API methods. You will have to access them as vm.$data._property

大意就是,Vue 内部变量命名就是以
_

$
为前缀命名,因此不会把 data 里以
_

$
开头的变量挂到外部上来,防止变量命名冲突覆盖掉内部变量而引起异常。因此当 data 里有这些变量时,使用时应该要
this.$data._xxx
的方式来操作这些变量

虽然是误打误撞的绕过了响应式处理,但这种方案不会让代码更繁琐,使用上还算方便,就是需要放开 eslint 的
vue/no-reserved-keys
校验规则

【举一反三】

当用到其他一些三方库,三方库变量又不是全局而是当前组件内的局部变量 data 内部时,都会存在被 Vue 响应式处理的问题。

如果你也有遇到这种场景,不防往这方面去考虑看看如果绕过响应式处理

共同复用全局的动画管理器 + 按需刷新

【原实现方案】

每条链路的动画由各自内部实现:

export default function FlowLink() {
  FlowLink.superClass.constructor.apply(this, arguments);
  this._animate = this.getAnimate();
}

twaver.Util.ext(FlowLink, twaver.Link, {
  play: function (options) {
    this._animate.play();
    return this._animate;
  },
  getAnimate: function (options) {
    // 内部自己的动画管理器
    this._animate = new twaver.Animate(
      Object.assign(
        {
          from: 0,
          to: 1,
          repeat: Number.POSITIVE_INFINITY,
          reverse: false,
          delay: 200, // 动画延迟
          dur: 5000, // 动画时才
          easing: "linear", // 线性动画
          onUpdate: (value) => {
            // 更新动画进度
            this.setClient("anim.percent", value);
          },
        },
        options
      )
    );
    return this._animate;
  },
});

而每条链路都是独立的 FlowLink 实例对象,当达到成千上万条链路时,资源就被撑爆了,很卡

【复用全局动画管理器思想】

其实,每条链路内部的动画管理器是一模一样的,那我们其实可以实现一个全局的统一动画管理器,这样不管链路有多少条,我们的动画管理器都只有 1 个

但动画管理器就要有种途径来找到各个链路,这样才能触发链路的刷新,以便它们内部根据最新动画进度来进行渲染

【按需刷新思想】

既然动画管理器内部需要捞取到链路来刷新,那干脆,只捞取屏幕可视范围内的链路进行刷新,屏幕外部的链路就不通知刷新

这样不就更节省性能损耗了

export default function FlowLink() {
  FlowLink.superClass.constructor.apply(this, arguments);
}

twaver.Util.ext(FlowLink, twaver.Link, {
  play: function () {
    // 链路内部不维护动画管理器了,只需要加个动画开关即可
    this.setClient("anim.enable", true);
  },
});
export default class GLobalAnimation {
  constructor(network) {
    this._network = network; // 与动画关联的拓扑画布
    this._linkAnimation = null; // 链路动画实例
    this._linkAnimPercent = 0; // 链路动画进度
  }

  playLinkAnimation() {
    if (!this._linkAnimation) {
      this._linkAnimation = this._initLinkAnimation();
      this._linkAnimation.play();
    }
  }

  _initLinkAnimation() {
    return new twaver.Animate({
      from: 0,
      to: 1,
      repeat: Number.POSITIVE_INFINITY,
      reverse: false,
      delay: 200, // 动画延迟
      dur: 5000, // 动画时才
      easing: "linear", // 线性动画
      onUpdate: (value) => {
        // 只重绘可视范围内的链路
        try {
          const state = this._network.state || {};
          // 滑动、缩放、布局过程中,都没必要更新UI
          const isReady = !state.zooming && !state.panning && !state.layouting;
          if (isReady) {
            // 获取经过缩放后的可视范围
            const viewRect = this._getZoomRect(this._network.getViewRect());
            // 根据可视范围,获取范围内的链路对象
            const nodes = this._network.getElementsAtRect(viewRect, true);
            nodes.forEach((node) => {
              // 刷新指定链路节点
              this._network.invalidateElementUI(node, false);
            });
          }
        } catch (error) {
          console.error("[GlobalAnimation]", error);
        }
      },
    });
  }

  _getZoomRect(rect) {
    const zoom = this._network.getZoom() || 1;
    const offset = 200;
    return {
      x: (rect.x - offset) / zoom,
      y: (rect.y - offset) / zoom,
      width: (rect.width + offset * 2) / zoom,
      height: (rect.height + offset * 2) / zoom,
    };
  }
}

这种思路有点像一开始只站在局部角度来思考代码实现,优化后则是站在全局角度上来进行的思考

而解决思路则是万能的复用,万能的懒加载,按需使用思想

交互上进行规避,如增加默认折叠、展开处理

由于节点是直接借助 twaver 内部的 canvas 实现,因此节点数量太多导致的性能瓶颈问题是 twaver 库本身就存在的问题,虽然 twaver 已经做到 1W 级别的节点的丝滑呈现,但当数量继续加上去,达到 5W,10W 级别时,也会开始出现操作滞后感,卡顿等性能瓶颈

也许你会说,简单,跟上个问题一样,按需加载不就行了,只绘制屏幕可视范围内的节点,其余节点不绘制

理论上可行,但实现上难度很大

因为上一个问题是节点链路已经绘制完毕的基础上,来进行刷新范围的过滤,所以只需要根据坐标点信息的计算就能达到诉求

但现在场景是还没绘制,你没法获知任何信息

你不知道经过缩放、拖拽后的当前视图里,到底应该呈现哪些节点

而且,twaver 是付费框架,源码是混淆的,你不知道内部它是怎么实现的,无法参与节点的排版过程,也导致你很难下手去实现所谓的按需绘制问题

再者,我们还有搜索定位的交互需求,就算你上面问题都解决了,那当搜索的节点是没绘制的节点,你如何去定位到该节点真实的位置

基于以上种种原因,考虑到投入成本的性价比,我们最终决定采用从非技术角度去优化:
从交互上进行规避

  • 增加节点的默认折叠处理方案,当超过一定数量时,默认把子孙节点折叠起来,这样能够避免一次性渲染太多节点
  • 同时增加展开/折叠全部节点的快捷操作
  • 由于孤点没有树形结构,因此当超过一定数量孤点时,需要另外处理折叠逻辑
  • 搜索节点时,发现节点处于折叠状态的话,要自动进行展开处理

简单来说就是会设定一个阈值,当节点超过这个数量时,都折叠起来,等用户手动去展开再呈现,相当于
分页呈现的思想

dom 节点的懒创建 + 缓存和复用

有些复杂节点的场景无法用 twaver 的默认节点样式呈现,也就用不了 canvas 实现,只能自己用 html 方式来实现

但也不可能用纯 html + js 实现,还是依赖于 vue 框架,这就涉及到 vue 组件的手动创建、挂载、销毁

这种复杂节点过多时,就会涉及到 dom 元素的反复创建、销毁以及渲染过多的性能瓶颈问题

那么解决方案上,一样也是懒加载,但为了组件可以复用,增加了缓存和复用处理,避免相同组件要重复创建

具体做法则是:

  • 重写了 twaver 绘制 dom 元素的方法逻辑,改造成懒加载方式
    • 即当节点不在页面可视范围内的话,不挂载 dom 到界面上,避免一次性渲染太多 dom
  • 收集缓存所有的 dom 组件
    • 当反复使用时,直接复用缓存
    • 当销毁时,手动触发 vue 的 destroy,及时销毁资源

小结

其实,大多数的性能问题本质上都是大同小异的原因:

  • 无意义的内存占用过高
    ,如 Vue 对 twaver 数据对象的响应式处理
  • 一次性处理的东西过多
    ,如渲染上万个节点
  • 短时间内频繁执行某些其实没意义的操作
    ,如实时刷新即使在屏幕外的动画
  • 反复创建、销毁行为
    ,如 dom 节点的反复创建

所以性能优化的难点之一在于排查根因,找到问题所在后,才能去着手思考对应的解决方案

而解决思路无外乎也是大同小异:

  • 按需使用、懒加载、分页
  • 缓存和复用
  • 规避方法

前言

在日常开发中
vue
的模版语法在大多数情况都能够满足我们的需求,但是在一些复杂的业务场景中使用模版语法就有些麻烦了。这个时候灵活的
JSX/TSX
渲染函数就能派上用场了,大多数同学的做法都是将
*.vue
文件改为
*.tsx
或者
*.jsx
文件。其实我们可以直接在
*.vue
文件中直接使用
JSX/TSX
渲染函数。

什么场景需要使用JSX/TSX渲染函数

假设我们现在有这样的业务场景,在我们的页面中有个
list
数组。我们需要去遍历这个数组,根据每一项的item去渲染不同的组件。如果tem的数据满足条件A,那么就渲染组件A。如果item的数据满足条件B,那么就渲染组件B。如果item的数据满足条件C,那么就渲染组件C。

如果我们使用vue模版语法去实现这个需求,我们的
Page.vue
文件的代码就需要是这样的:

<template>
  <template v-for="item in list">
    <ComponentA v-if="isComponentA(item)" />
    <ComponentB v-else-if="isComponentB(item)" />
    <ComponentC v-else-if="isComponentC(item)" />
  </template>
</template>

<script setup lang="ts">
import ComponentA from "./component-a.vue";
import ComponentB from "./component-b.vue";
import ComponentC from "./component-c.vue";

const list: Array<number> = [1, 5, 3, 2, 1];

const isComponentA = (item): boolean => {
  return item % 3 === 0;
};

const isComponentB = (item): boolean => {
  return item % 3 === 1;
};

const isComponentC = (item): boolean => {
  return item % 3 === 2;
};
</script>

这样虽然可以实现功能,但是明显不够优雅,领导code review时看了直呼摇头。

在*.jsx/tsx文件中使用JSX/TSX渲染函数

此时机智的小伙伴会说,我们可以使用
vue

setup
方法使用
JSX/TSX
渲染函数实现。确实可以,我们来看看具体实现的代码:

import { defineComponent } from "vue";
import ComponentA from "./component-a.vue";
import ComponentB from "./component-b.vue";
import ComponentC from "./component-c.vue";

export default defineComponent({
  setup() {
    const list = [1, 5, 3, 2, 1, 0];

    function renderDataList(data: Array<number>) {
      return data?.map((val) => {
        if (val % 3 === 0) {
          return <ComponentA />;
        } else if (val % 3 === 1) {
          return <ComponentB />;
        } else {
          return <ComponentC />;
        }
      });
    }

    return () => {
      return <div>{renderDataList(list)}</div>;
    };
  },
});

首先我们需要将原来的
Page.vue
文件改为
Page.tsx
文件,然后我们需要将原来写在
template
中的代码摞到
setup
中。这种写法有如下几个痛点:
由于没有使用
vue
的模版语法,所以
vue
内置的
v-model
等指令和项目中自己封装的指令等都不能使用了,只能使用js去自己实现。

按照常规的思维,
setup
直接返回一个值就行了,但是如果你这样写就会收到这样的报错:

[Vue warn]: setup() should not return VNodes directly - return a render function instead.

原因是
setup()
函数在每个组件中只会被调用一次,而返回的渲染函数将会被调用多次。这样就导致我们的代码只能在外面包裹一层匿名函数:

return () => {
  return <div>{renderDataList(list)}</div>;
};

在*.vue文件中使用JSX/TSX渲染函数

那么有没有方法可以让我们在使用
JSX/TSX
渲染函数的同时,也可以在
vue
文件中使用模版语法呢?答案是:当然可以!

首先我们需要导入
@vitejs/plugin-vue-jsx

// vite.config.js
import vue from '@vitejs/plugin-vue'

export default {
  plugins: [vue()],
}

然后我们需要将
vue
文件的
script
标签的
lang
设置为
tsx或者jsx
。具体的
Page.vue
代码如下:

<template>
  <RenderDataList :data="list" />
</template>

<script setup lang="tsx">
import ComponentA from "./component-a.vue";
import ComponentB from "./component-b.vue";
import ComponentC from "./component-c.vue";

const list = [1, 5, 3, 2, 1];

const RenderDataList = (props: { data: Array<number> }) => {
  return props.data?.map((val) => {
    if (val % 3 === 0) {
      return <ComponentA />;
    } else if (val % 3 === 1) {
      return <ComponentB />;
    } else {
      return <ComponentC />;
    }
  });
};
</script>

在上面这个例子中我们定义了一个
RenderDataList
,然后在
template
中可以直接将
RenderDataList
当作一个组件使用。vscode也会给出智能提示。


react
中,这种场景我们可以将
RenderDataList
当作一个函数去使用,然后在模版中直接调用这个函数就行了。但是在
vue
中,
RenderDataList
只能当做一个组件使用,不能当做函数调用。

还有一点需要避坑的是,假如我们的props中定义了一个驼峰命名法的变量,例如:
pageNum
。在
template
中传入
pageNum
的时候必须写成
:pageNum="xxx"
,不能写成
:page-num="xxx"

总结

这篇文件介绍了如何在
*.vue
文件中直接使用
JSX/TSX
渲染函数,只需要导入
@vitejs/plugin-vue-jsx
,然后将
script
标签的
lang
设置为
tsx或者jsx
。就可以在
script
中直接定义组件,然后在
template
中直接使用组件就可以了。这样我们既可以使用
JSX/TSX
渲染函数的灵活性,也可以使用
vue
模版语法中内置的指令等功能。

如果我的文章对你有点帮助,欢迎关注公众号:【欧阳码农】,文章在公众号首发。你的支持就是我创作的最大动力,感谢感谢!

转载:
原文链接

从SDK9开始,Java支持多模块编译。那么,怎么用javac实现多模块编译呢?

项目介绍

图片

先来看看我们的项目。

首先lib文件夹下是依赖模块,有一个hello模块。hello模块包含hello包,并且被导出。

图片

图片

然后是test,是我们的主模块,包含一个test包,里面有个叫Main的主类。
图片

图片

有源码的编译

首先,我们模拟,我们具有这两个类的源码时的编译。

编译命令:

javac -d .\target\build1 --module-source-path ".;.\lib" .\test\module-info.java .\test\test\Main.java

首先,-d是输出路径。--module-source-path是模块源码的保存路径。在这些路径下,直接保存这些模块的源码。文件名就是模块名(即使模块名包含".",文件名也是包含"."的模块名,而不是多级目录),这些文件名下直接就有module-info.java文件。
.
路径指当前路径,也就是test包的位置,
.\lib
则是hello包的路径。

通过class文件编译

有时候,我们没有Hello包的源码,那么怎么办呢?

先模拟这个环境,编译hello包:

javac -d .\target\build2 --module-source-path ".;.\lib" .\lib\hello\module-info.java .\lib\hello\hello\Hello.java

此时,hello包将编译在.\target\build2\hello位置,那么.\target\build2就是包存放的位置了。

javac -d .\target\build2 --module-source-path "." -p ".\target\build2" .\test\module-info.java .\test\test\Main.java

然后编译test,此时使用-p来指示模块的位置。

通过jar文件编译

jar文件编译和class文件编译其实很类似,我们来试一下。

首先把hello2编译成jar。

mkdir target/build3jar -cvf .\target\build3\hello.jar -C .\target\build2\hello .

然后编译

javac -d .\target\build3 --module-source-path "." -p ".\target\build3" .\test\module-info.java .\test\test\Main.java

结果非常符合预期。

写在最后

通过上面的操作,我们就能自己编译我们的java多模块工程了。当然,借助maven等工具能实现更高效的开发。

消息队列面试题:为什么要使用消息队列?

开源项目:浪海博客  需要星星 谢谢 ~

gitee地址:https://gitee.com/langhai666/langhai-blog

github地址:https://github.com/Allenkuzma/langhaiblogs

为什么要使用消息队列?

首先需要了解 MQ 消息队列的三个最重要的优点或者说特征:解耦、异步、削峰。

这里由购物场景来说明:用户下单,必然会生成订单,生成物流信息,以及减少库存。

解耦:

在没有引入消息队列之前,下单系统会和库存系统以及物流系统等其他系统存在严重的耦合关系。

在引入消息队列之后,各个系统只需要从自己感兴趣的队列当中消费消息,也就是一个发布订阅模型,在代码层面各个系统的耦合性大大降低了。

异步:

在没有引入消息队列之前,下单系统需要同步调用各个其他系统来进行减少库存和生成物流信息等操作。这些操作的时间加起来,有可能超过1秒,可能会给用户的体验带来影响。

在引入消息队列之后,下单系统只需要将消息发送到消息队列中即可,这样大大节省了用户等待的时间,提升了用户的体验感。

削峰:

餐饮系统当中,中饭和晚饭的时候是一个高峰期,在某一个时间段会有大量的请求发过来,这样有可能导致把mysql打死,从而导致系统崩溃。

在引入消息队列之后,可以将大量的请求先存储在消息队列当中,按照自己系统的消费能力慢慢消费,最终起到一个流量平缓的效果。

在引入消息队列之后,带来了哪一些弊端?

系统可用性降低

系统引入了外部的组件越多,遇到的问题就会越多。如果消息队列挂了怎么办呢?这个时候将要考虑消息队列的高可用模型。

系统的复杂度提高

引入消息队列之后,需要考虑消息的丢失问题,以及消息有可能被重复消费的问题。还有一致性问题(有的系统成功消费,有的系统消费失败,造成数据不一致的情况。)等等。

如何从activeMQ、RabbitMQ、RocketMQ、Kafka中选择合适的消息队列?

activeMQ的起源比较早,现在社区的活跃度比较低,使用的人也比较少,一般不推荐使用。

rabbitMQ采用的erlang语言开发,如果要进行二次定制开发的话,就必须掌握erlang语言。rabbitMQ的时效性最低,可以到达微秒级别。

rocketMQ是阿里巴巴出品,经历过大规模吞吐量场景的验证(双十一购物),国内有许多公司在使用。

kafka在大数据方面应用的非常多,比如说用来进行日志采集和实时计算等场景。

本文章由 浪海博客 2024-01-12 编写发布。