2024年11月

1.简介

从这篇文章开始,就开始要介绍UI自动化核心的内容,也是最困难的部分了,就是:定位元素,并去对定位到的元素进行一系列相关的操作。想要对元素进行操作,第一步,也是最重要的一步,就是要找到这个元素,如果连元素都定位不到,后续什么操作都是无用功,都是扯淡,因此宏哥建议小伙伴或者同学们从这里开始就要跟紧宏哥的脚步,一步一个脚印的将基础打结实,不要到后期了要定位操作元素了,到处找人问到处碰壁。说到元素定位,小伙伴或者童鞋们肯定会首先想到 selenium 的八大元素定位大法。同理Playwright也有自己的元素定位的方法。今天就给小伙伴或者童鞋们讲解和分享一下Playwright的元素定位方法。其实在Python+Playwright自动化测试系列文章中也介绍过元素定位,宏哥看一下Java和Python的几乎是大同小异,只不过是Java和Python语法格式的区别,其他的大差不差。但是为了这一系列文字的完整和连贯,宏哥还是将其讲解和分享一下。

2.什么是定位?

宏哥这里说的定位和我们平时说的不一样,Playwright能够模拟用户去浏览器页面上执行对应(输入,点击,清除,提交)等操作,它是凭什么方式去寻找到页面的元素?Playwright没有视觉、听觉等。Selenium通过在页面上寻找元素位置,找到元素后,然后对元素进行相应的操作,Playwright寻找元素位置的方法,称之为定位。

3.定位器(Locator)

官网是这样对定位器进行定义的:定位器(Locator)是 Playwright 的自动等待和重试能力的核心部分。简而言之,定位器是一种随时在网页上查找元素的方法。

4.内置定位器

这些是 Playwright 推荐的内置定位器。

5.元素基础定位方式

Playwright 带有多个内置定位器。为了使测试具有弹性,我们建议优先考虑面向用户的属性和显式契约,例如 Page.getByRole()。例如:以下 DOM 结构。

按名称为“Sign in”的角色找到元素:
button 。

page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Sign in")).click();
敲黑板!!!注意
使用代码生成器生成定位器,然后根据需要进行编辑。

每次将定位器用于操作时,页面中都会找到最新的 DOM 元素。在下面的代码片段中,底层 DOM 元素将定位两次,一次在每次操作之前。这意味着,如果 DOM 在两次调用之间由于重新渲染而发生变化,则将使用与定位器对应的新元素。

Locator locator =page.getByRole(AriaRole.BUTTON,new Page.GetByRoleOptions().setName("Sign in"))

locator.hover();
locator.click();

请注意,创建定位器的所有方法(如 Page.getByLabel())也可用于 Locator 和 FrameLocator 类,因此您可以链接它们并迭代缩小定位器的范围。

Locator locator =page
.frameLocator(
"#my-frame")
.getByRole(AriaRole.BUTTON,
new Page.GetByRoleOptions().setName("Sign in"));

locator.click();

5.1按角色定位

Page.getByRole()定位器反映了用户和辅助技术如何感知页面,例如,某个元素是按钮还是复选框。按角色定位时,通常还应传递可访问的名称,以便定位器精确定位确切的元素。例如:以下 DOM 结构。

您可以根据其隐式角色来定位每个元素:

assertThat(page
.getByRole(AriaRole.HEADING,
new Page.GetByRoleOptions().setName("Sign up")))
.isVisible();

page.getByRole(AriaRole.CHECKBOX,
new Page.GetByRoleOptions().setName("Subscribe"))
.check();

page.getByRole(AriaRole.BUTTON,
newPage.GetByRoleOptions().setName(
Pattern.compile(
"submit", Pattern.CASE_INSENSITIVE)))
.click();

角色定位器包括按钮、复选框、标题、链接、列表、表格等,并遵循 W3C 对 ARIA 角色、ARIA 属性和可访问名称的规范。请注意,许多 html 元素(例如)都有一个隐式定义的角色,该角色定位器可以识别该角色:
<button>

请注意,角色定位器
不会取代可访问性审核和一致性测试,而是提供有关 ARIA 指南的早期反馈。

敲黑板!!!注意:何时使用角色定位器:官网
建议优先使用角色定位器来定位元素,因为这是最接近用户和辅助技术感知页面的方式。

5.2按标签定位

大多数表单控件通常都有专用标签,可以方便地使用这些标签与表单进行交互。在这种情况下,您可以使用 Page.getByLabel() 通过其关联标签来定位控件。例如:以下 DOM 结构。

您可以通过标签文本找到输入后填充输入:

page.getByLabel("Password").fill("secret");

敲黑板!!!注意:何时使用标签定位器:官网建议在查找表单字段时,请使用此定位器。

5.3按占位符定位

输入可能具有占位符属性,以提示用户应输入什么值。您可以使用 Page.getByPlaceholder()找到此类输入。例如:以下 DOM 结构。

您可以通过占位符文本找到输入后填充输入:

page.getByPlaceholder("name@example.com").fill("playwright@microsoft.com");

敲黑板!!!注意:何时使用占位符定位器:官网建议在查找没有标签但具有占位符文本的表单元素时,请使用此定位器。

5.4按文本定位

通过它包含的文本找到一个元素。使用 Page.getByText()时,您可以通过子字符串、精确字符串或正则表达式进行匹配。例如:以下 DOM 结构。

您可以通过它包含的文本找到该元素:

assertThat(page.getByText("Welcome, John")).isVisible();

设置完全匹配:

assertThat(page
.getByText(
"Welcome, John", new Page.GetByTextOptions().setExact(true)))
.isVisible();

与正则表达式匹配:

assertThat(page
.getByText(Pattern.compile(
"welcome, john$", Pattern.CASE_INSENSITIVE)))
.isVisible();

敲黑板!!!注意:

(1)按文本匹配始终会使空格归一化,即使完全匹配也是如此。例如,它将多个空格转换为一个空格,将换行符转换为空格,并忽略前导和尾随空格。

(2)何时使用文本定位器:官网建议建议使用文本定位器来查找非交互式元素,如div、span、p 等。对于交互式元素(如button、a、input 等),请使用角色定位器。

您还可以按文本进行筛选,这在尝试查找列表中的特定项目时非常有用。

5.5通过替代文本定位

所有图像都应具有描述图像的属性alt。您可以使用 Page.getByAltText() 根据替代文本定位图像。例如:以下 DOM 结构。

您可以通过文本替代找到图像后单击图像:

page.getByAltText("playwright logo").click();

敲黑板!!!注意,何时使用 ALT 定位器:官网建议当您的元素支持 alt 文本(如 img 和 area)时,请使用此定位器。

5.6按标题定位

使用 Page.getByTitle()找到具有匹配 title 属性的元素。例如:以下 DOM 结构。

您可以通过标题文本找到问题后检查问题计数:

assertThat(page.getByTitle("Issues count")).hasText("25 issues");

敲黑板!!!注意,何时使用标题定位器:官网建议当元素具有title属性时,请使用此定位器。

5.7按测试ID定位

通过测试 ID 进行测试是最具弹性的测试方式,因为即使您的文本或属性角色发生变化,测试仍将通过。 QA 和开发人员应定义显式测试 ID 并使用 Page.getByTestId() 查询它们。但是,通过测试 ID 进行测试不是面向用户的。如果角色或文本值对您很重要,请考虑使用面向用户的定位器,例如角色和文本定位器。例如:以下 DOM 结构。

您可以通过其测试 ID 找到该元素:

page.getByTestId("directions").click();

敲黑板!!!注意,何时使用 TESTID 定位器:官网建议当您选择使用测试 ID 方法时,或者当您无法按角色或文本进行定位时,也可以使用测试 ID。

5.7.1设置自定义的测试ID

默认情况下,Page.getByTestId()将根据属性data-testid定位元素,但您可以在测试配置中或通过调用 Selectors.setTestIdAttribute()来配置它。

将测试 ID 设置为对测试使用自定义数据属性。

playwright.selectors().setTestIdAttribute("data-pw");

在您的 html 中,您现在可以用data-pw作您的测试 ID,而不是默认的data-testid。然后像往常一样找到该元素:

page.getByTestId("directions").click();

5.8通过CSS或者Xpath定位

如果您绝对必须使用 CSS 或 XPath 定位器,则可以使用 Page.locator()创建一个定位器,该定位器采用描述如何在页面中查找元素的选择器。Playwright 支持 CSS 和 XPath 选择器,如果您省略或添加前缀css=或xpath=,则会自动检测它们。

page.locator("css=button").click();
page.locator(
"xpath=//button").click();

page.locator(
"button").click();
page.locator(
"//button").click();

XPath 和 CSS 选择器可以绑定到 DOM 结构或实现。当 DOM 结构发生变化时,这些选择器可能会中断。下面的长 CSS 或 XPath 链是导致测试不稳定的
不良做法的一个示例:

page.locator("#tsf > div:nth-child(2) > div.A8SBwf > div.RNNXgb > div > div.a4bIc > input").click();

page.locator(
"//*[@id='tsf']/div[2]/div[1]/div[1]/div/div[2]/input").click();

敲黑板!!!注意,何时使用此工具:官网不建议使用 CSS 和 XPath,因为 DOM 经常会更改,从而导致无法恢复的测试。取而代之的是,尝试提出一个接近用户感知页面方式的定位器,例如角色定位器,或者使用测试 ID 定义明确的测试合同。

6.阴影定位-Shadow DOM

在做web自动化的时候,一些元素在shadow-root的节点下,使得playwright中无法通过xpath来定位

上面所看到的shadow-root标签其实就是一个shadowDOM,那么什么是shadowDOM呢?

他是前端的一种页面封装技术,可以将shadowDOM视为“DOM中的DOM”(可以看成一个隐藏的DOM)

他是一个独立的DOM树,具有自己的元素和样式,与原始文档DOM完全隔离。

shadowDOM必须附在一个HTML元素中,存放shadowDOM的元素,我们可以把它称为宿主元素。在HTML5中有很多的标签样式都是通过shadowDOM来实现的。

比如:日期选择框,音频播放标签,视频播放标签都自带了样式;(这种封装对于前端开发来说虽好,但是我们测试人员在做web自动给的时候就会遇到一些问题,shadowDOM中的标签无法定位。)

默认情况下,Playwright 中的所有定位器都使用 Shadow DOM 中的元素。例外情况是:

  • 通过 XPath 定位不会刺穿阴影根部。
  • 不支持闭合模式影子根。

例如:以下自定义 Web 组件示例:

<x-detailsrole=buttonaria-expanded=truearia-controls=inner-details>
  <div>Title</div>#shadow-root<divid=inner-details>Details</div>
</x-details>

您可以采用与影子根根本不存在相同的方式进行定位。

要单击 :
<div>Details</div>

page.getByText("Details").click();
<x-detailsrole=buttonaria-expanded=truearia-controls=inner-details>
  <div>Title</div>#shadow-root<divid=inner-details>Details</div>
</x-details>

点击 :
<x-details>

page.locator("x-details", new Page.LocatorOptions().setHasText("Details"))
.click();
<x-detailsrole=buttonaria-expanded=truearia-controls=inner-details>
  <div>Title</div>#shadow-root<divid=inner-details>Details</div>
</x-details>

要确保包含文本“详细信息”,请执行以下操作:
<x-details>

assertThat(page.locator("x-details")).containsText("Details");

7.小结

今天这一篇主要是讲解我们日常工作中在使用Playwright进行元素定位的一些比较常用的基础定位方法的理论基础知识以及在什么情况下推荐使用,当然了这不是一成不变的,希望大家在使用中可以灵活多变的应用,一种不行就换另一种说不定就可以了,不要太较真死活就要用它,一棵树上吊死。

好了,今天时间也不早了,宏哥就讲解和分享到这里,感谢您耐心的阅读,希望对您有所帮助。

一、SpringAI 简介

随着人工智能技术的飞速发展,越来越多的开发者开始探索如何将
AI
能力集成到现有的应用中来提升产品的智能化水平。
Spring AI
正是为
Java
开发者提供的一款强大的
AI
框架,使得这一集成过程变得前所未有的简单和高效。

本文将深入探讨
Spring AI
的核心概念以及如何快速上手使用这款智能新利器。

二、什么是Spring AI?

目前
AI
应用程序开发框架主要是
Python
生态;而
Spring AI
是由
Spring
团队推出的一个扩展框架,专为将
AI
能力集成到
Java
应用中而设计。它利用
Spring
的生态系统优势,提供了一系列简单易用的
API
和工具,使开发者可以轻松地加载、训练和推理
AI模型
。这不仅降低了开发门槛,还极大地提高了开发效率。

Spring AI
的核心是解决
AI
集成的根本挑战:
将您的企业数据和 API 与 AI 模型连接起来。

三、Spring AI的核心概念

3.1. Models

模型
(Models)是指在处理和生成信息的算法,通常模仿人类认知功能。通过从大型数据集中学习模式和见解,这些模型可以做出预测、文本、图像或其他输出,增强跨行业的各种应用。

Spring AI
支持多种
AI模型
包括神经网络、决策树等。模型可以通过训练数据进行训练,之后用于推理。

3.2. Prompts

提示
(Prompts)是基于语言输入的基础,指导
AI
模型生成特定输出。对于熟悉
ChatGPT
的人来说,提示可能看起来只是输入对话框中的文本,传送到
API
然而,它的内涵远不止于此,在许多
AI
模型中,提示文本并不只是一个简单的字符串。


ChatGPT

API
中,一个提示包含多个文本输入,每个输入都会被赋予不同的角色。例如,有一个
系统角色
它告诉模型如何行为并设定互动的上下文。此外,还有一个
用户角色
通常就是用户的输入。

设计有效的提示既是一门艺术,也是一门科学。
ChatGPT
被设计用于人类对话,这与使用
SQL
等语言
提问
的方式有很大不同。与
AI
模型交流更像是与另一个人对话。

3.3. Embeddings

嵌入
(Embeddings)是文本、图像或视频的数值表示,用于捕捉输入之间的关系。

嵌入通过将文本、图像和视频转换为浮点数数组(称为向量)来工作。这些向量旨在捕捉文本、图像和视频的含义。嵌入数组的长度被称为向量的维度。

通过计算两个文本的向量表示之间的数值距离,应用程序可以判断生成这些嵌入向量的对象之间的相似性。

3.4. Tokens

令牌
(Tokens)是
AI
模型工作的基础构件。输入时,模型会将单词转换为令牌;输出时,模型会将令牌重新转换为单词。

3.5. Structured Output

AI
模型的输出通常以
java.lang.String
的形式返回,即使你要求回复为
JSON
格式,但它并不是一个
JSON
数据结构,而只是一个字符串。

这一复杂性催生了一个专门的领域,涉及创建提示以获得预期输出,然后将生成的简单字符串转换为可用于应用集成的数据结构。

3.6. Bringing Your Data & APIs to the AI Model

如何让
AI
模型掌握它未被训练过的信息?

目前有三种方法可以定制
AI
模型以整合您的数据:

  • 微调
    (Fine Tuning):这种传统的机器学习技术涉及调整模型,并改变其内部权重。然而非常耗费资源。此外,有些模型可能不支持这一选项。
  • 提示嵌入
    (Prompt Stuffing):一种更实际的替代方案是将您的数据嵌入提供给模型的提示中。
  • 函数调用
    (Function Calling):这种技术允许注册自定义的用户函数,将大型语言模型与外部系统的 API 连接起来。

3.7. Retrieval Augmented Generation

RAG
是一种称为
检索增强生成
的技术,用以解决如何将相关数据整合到提示中以确保
AI
模型能够给出准确的回答。

这种方法涉及一种批处理风格的编程模型,其中任务从文档中读取非结构化数据,进行转换,然后将其写入向量数据库。从宏观角度看,这类似于一个
ETL
(提取、转换和加载)管道。向量数据库用于
RAG
技术中的检索部分。

四、快速上手指南

注意 Spring AI 支持 Spring Boot 3.2. x 和 3.3.x

Maven 仓库配置

pom.xml
中添加以下内容:

<repositories>
  <repository>
    <id>spring-milestones</id>
    <name>Spring Milestones</name>
    <url>https://repo.spring.io/milestone</url>
    <snapshots>
      <enabled>false</enabled>
    </snapshots>
  </repository>
  <repository>
    <id>spring-snapshots</id>
    <name>Spring Snapshots</name>
    <url>https://repo.spring.io/snapshot</url>
    <releases>
      <enabled>false</enabled>
    </releases>
  </repository>
</repositories>

导入 Spring AI BOM

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>1.0.0-SNAPSHOT</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

添加 OpenAI 聊天

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>

使用 OpenAI 创建 API 来访问 ChatGPT 模型。在OpenAI 注册页面创建账户并在API 密钥页面生成令牌。

代码样例

@RestController
public class EmbeddingApiController {
    @Resource
    private EmbeddingClient client;

    @GetMapping("/api/v1/embedding")
    public Map<String, Object> getEmbedding(@RequestParam(name = "message", defaultValue = "Share a funny story") String input) {
        EmbeddingResponse response = client.embedForResponse(Collections.singletonList(input));
        return Collections.singletonMap("embedding", response);
    }
}

总结

SpringAI
无疑是
Java
开发领域在人工智能方向上的重要创新。它将
Spring Framework
的优势与先进的
AI
技术完美融合,通过其关键特性在多个方面为开发者提供了强大的助力。

前言

watch这个API大家都很熟悉,今天这篇文章欧阳来带你搞清楚Vue3的watch是如何实现对响应式数据进行监听的。注:本文使用的Vue版本为
3.5.13

关注公众号:【前端欧阳】,给自己一个进阶vue的机会

看个demo

我们来看个简单的demo,代码如下:

<template>
  <button @click="count++">count++</button>
</template>

<script setup lang="ts">
import { ref, watch } from "vue";
const count = ref(0);
watch(count, (preVal, curVal) => {
  console.log("count is changed", preVal, curVal);
});
</script>

这个demo很简单,使用watch监听了响应式变量
count
,在watch回调中进行了console打印。如何有个button按钮,点击后会count++。

开始打断点

现在我们第一个断点应该打在哪里呢?

我们要看watch的实现,那么当然是给我们demo中的watch函数打个断点。

首先执行
yarn dev
将我们的demo跑起来,然后在浏览器的network面板中找到对应的vue文件,右键点击
Open in Sources panel
就可以在source面板中打开我们的代码啦。如下图
source

然后给watch函数打个断点,如下图:
debug

接着刷新页面,此时代码将会停留在断点出。将断点走进watch函数,代码如下:

function watch(source, cb, options) {
  return doWatch(source, cb, options);
}

从上面的代码可以看到在watch函数中直接返回了
doWatch
函数。

将断点走进
doWatch
函数,在我们这个场景中简化后的代码如下(
为了方便大家理解,本文中会将scheduler任务调度相关的代码移除掉,因为这个不影响watch的主流程
):

function doWatch(source, cb, options = EMPTY_OBJ) {
  const baseWatchOptions = extend({}, options);
  const watchHandle = baseWatch(source, cb, baseWatchOptions);
  return watchHandle;
}

从上面的代码可以看到底层实际是在执行
baseWatch
函数,而这个
baseWatch
就是由
@vue/reactivity
包中导出的watch函数。关于这个
baseWatch
函数的由来可以看看欧阳之前的文章:
Vue3.5新增的baseWatch让watch函数和Vue组件彻底分手

baseWatch
函数

将断点走进
baseWatch
函数,在我们这个场景中简化后的代码如下:

const INITIAL_WATCHER_VALUE = {}

function watch(
  source: WatchSource | WatchSource[] | WatchEffect | object,
  cb?: WatchCallback | null,
  options: WatchOptions = EMPTY_OBJ
): WatchHandle {
  let effect: ReactiveEffect;
  let getter: () => any;

  if (isRef(source)) {
    getter = () => source.value;
  }

  let oldValue: any = INITIAL_WATCHER_VALUE;

  const job = () => {
    if (cb) {
      const newValue = effect.run();
      if (hasChanged(newValue, oldValue)) {
        const args = [
          newValue,
          oldValue === INITIAL_WATCHER_VALUE ? undefined : oldValue,
          boundCleanup,
        ];

        cb(...args);
        oldValue = newValue;
      }
    }
  };
  effect = new ReactiveEffect(getter);
  effect.scheduler = job;

  oldValue = effect.run();
}

首先定义了两个变量
effect

getter

effect

ReactiveEffect
类的实例。

接着就是使用
isRef(source)
判断watch监听的是不是一个ref变量,如果是就将
getter
函数赋值为
getter = () => source.value
。这么做的原因是为了保持一致(watch也可以直接监听一个getter函数),并且后面会对这个getter函数进行读操作触发依赖收集。

我们知道watch的回调中有
oldValue

newValue
这两个字段,在
watch
函数内部有个字段也名为
oldValue
用于存旧的值。

接着就是定义了一个
job
函数,我们先不看里面的代码,执行这个
job
函数就会执行watch的回调。

然后执行
effect = new ReactiveEffect(getter)
,这个
ReactiveEffect
类是一个底层的类。
在Vue的设计中,所有的订阅者都是继承的这个
ReactiveEffect

。比如
watchEffect

computed()
、render函数等。

在我们这个场景中
new ReactiveEffect
时传入的
getter
函数就是
getter = () => source.value
,这里的
source
就是watch监听的响应式变量
count

接着将
job
函数赋值给
effect.scheduler
属性,在
ReactiveEffect
类中依赖触发时就会执行
effect.scheduler
方法(接下来会讲)。

最后就是执行
effect.run()
拿到初始化时watch监听变量的值,这个
run
方法也是在
ReactiveEffect
类中。接下来也会讲。

ReactiveEffect

前面我们讲过了
ReactiveEffect
是Vue的一个底层类,所有的订阅者都是继承的这个类。将断点走进
ReactiveEffect
类,在我们这个场景中简化后的代码如下:

class ReactiveEffect<T = any> implements Subscriber, ReactiveEffectOptions {
  constructor(fn) {
    this.fn = fn;
  }

  run(): T {
    const prevEffect = activeSub;
    activeSub = this;
    try {
      return this.fn();
    } finally {
      activeSub = prevEffect;
    }
  }

  trigger(): void {
    this.scheduler();
  }
}

在new一个
ReactiveEffect
实例时传入的getter函数会赋值给实例的
fn
方法。(实际的
ReactiveEffect
代码比这个要复杂很多,感兴趣的同学可以去看源代码)

我们回到前面讲过的
baseWatch
函数中的最后一块:
oldValue = effect.run()
。这里执行了
effect
实例的
run
方法拿到watch监听变量的值,并且赋值给
oldValue
变量。

因为我们如果不使用
immediate: true
,那么Vue会等watch监听的变量改变后才会触发watch回调,回调中有个字段叫
oldValue
,这个
oldValue
就是初始化时执行
run
方法拿到的。

比如我们这里
count
初始化的值是0,初始化执行
oldValue = effect.run()
后就会给
oldValue
赋值为0。当点击
count++
按钮后,
count
的值就变成了1,所以在watch回调第一次触发的时候他就知道
oldValue
的值是0啦。

除此之外,在
run
方法中还有收集依赖的作用。Vue维护了一个全局变量
activeSub
表示当前active的订阅者是谁,在同一时间只可能有一个active的订阅者,不然触发get拦截进行依赖收集时就不知道该把哪个订阅者给收集了。


run
方法中将当前的
activeSub
给存起来,等下面的代码执行完了后将全局变量
activeSub
改回去。

接着就是执行
activeSub = this;
将当前的watch设置为全局变量
activeSub

接下来就是执行
return this.fn()
,前面我们讲过了这个
this.fn()
方法就是watch监听的getter函数。由于我们watch监听的是一个响应式变量
count
,在前面处理后他的getter函数就是
getter = () => source.value;
。这里的source就是watch监听的变量,这个getter函数实际就是
getter = () => count.value;

那么这里执行
return this.fn()
就是执行
() => count.value
,将会触发响应式变量
count
的get拦截。在get拦截中会进行依赖收集,由于此时的全局变量
activeSub
已经变成了订阅者watch,所以响应式变量
count
在依赖收集的过程中收集的订阅者就是watch。这样响应式变量
count
就和订阅者watch建立了依赖收集的关系。关于Vue3.5依赖收集和依赖触发可以看看欧阳之前的文章:
看不懂来打我!让性能提升56%的Vue3.5响应式重构

当我们点击
count++
后会修改响应式变量
count
的值,就会进行依赖触发,经过一堆操作后最后就会执行到这里的
trigger
方法中。在
trigger
方法中直接执行
this.scheduler()
,在前面已经对
scheduler
方法进行了赋值,回忆一下
baseWatch
函数的代码。如下:

const INITIAL_WATCHER_VALUE = {}

function watch(
  source: WatchSource | WatchSource[] | WatchEffect | object,
  cb?: WatchCallback | null,
  options: WatchOptions = EMPTY_OBJ
): WatchHandle {
  let effect: ReactiveEffect;
  let getter: () => any;

  if (isRef(source)) {
    getter = () => source.value;
  }

  let oldValue: any = INITIAL_WATCHER_VALUE;

  const job = () => {
    if (cb) {
      const newValue = effect.run();
      if (hasChanged(newValue, oldValue)) {
        const args = [
          newValue,
          oldValue === INITIAL_WATCHER_VALUE ? undefined : oldValue,
          boundCleanup,
        ];

        cb(...args);
        oldValue = newValue;
      }
    }
  };
  effect = new ReactiveEffect(getter);
  effect.scheduler = job;

  oldValue = effect.run();
}

这里将
job
函数赋值给
effect.scheduler
方法,所以当响应式变量
count
的值改变后实际就是在执行这里的
job
函数。


job
函数中首先判断是否有传入watch的callback函数,然后执行
const newValue = effect.run()

执行这行代码有两个作用:

第一个作用是重新执行getter函数,也就是
getter = () => count.value;
,拿到最新
count
的值,将其赋值给
newValue

第二个作用是watch除了监听响应式变量之外还可以监听一个getter函数,那么在
getter
函数中就可以类似computed一样在某些条件下监听变量A,某些条件下监听变量B。这里的第二个作用是重新收集依赖,因为此时watch可能从监听变量A变成了监听变量B。

接着就是执行
if (hasChanged(newValue, oldValue))
判断watch监听的变量新的值和旧的值是否相等,如果不相等才去执行
cb(...args)
触发watch的回调。最后就是将当前的
newValue
赋值给
oldValue
,下次触发watch回调时作为
oldValue
字段。

总结

这篇文章讲了watch如何对响应式变量进行监听,其实底层依赖的是
@vue/reactivity
包的
baseWatch
函数。在
baseWatch
函数中会使用
ReactiveEffect
类new一个
effect
实例,这个
ReactiveEffect
类是一个底层的类,Vue的订阅者都是基于这个类去实现的。

如果没有使用
immediate: true
,初始化时会去执行一次
effect.run()
对watch监听的响应式变量进行读操作并且将其赋值给
oldValue
。读操作会触发get拦截进行响应式变量的依赖收集,会将当前watch作为订阅者进行收集。

当响应式变量的值改变后会触发set拦截,进而依赖触发。前一步将watch也作为订阅者进行了收集,依赖触发时也会通知到watch,所以此时会执行watch中的
job
函数。在
job
函数中会再次执行
effect.run()
拿到响应式变量最新的值赋值给
newValue
,同时再次进行依赖收集。如果
oldValue

newValue
不相等,那么就触发watch的回调,并且将
oldValue

newValue
作为参数传过去。

关注公众号:【前端欧阳】,给自己一个进阶vue的机会

另外欧阳写了一本开源电子书
vue3编译原理揭秘
,看完这本书可以让你对vue编译的认知有质的提升。这本书初、中级前端能看懂,完全免费,只求一个star。

hello,大家好,我是程序员海军,公众号已经快一年多没更新了,没更新的这段时间,我去哪了呢。这两年经历了很多事情,主要情感上占据大部分时间, 从失恋 - 谈对象 - 再失恋。

言归正传,近期我负责的公司人力资源系统中,薪酬统计模块的开发进度正稳步推进。在此过程中,我发现需要展示多种图表(如 ECharts)来直观反映数据。然而,ECharts 的配置过程相对复杂,频繁查阅官方文档不仅耗时,而且效率不高。为了提升开发效率,我萌生了这样一个想法:将 ECharts 中的一些通用属性进行提炼和整理,同时汇总常用的配置项以及在实际应用中需要注意的要点。这样一来,不仅能够简化配置过程,还能为团队提供一个便捷的参考指南。

EChart 资源

Vue-EChart

不想封装 Echart, 可以选用这种方案。

Vue-ECharts 是一个 Vue 组件,旨在简化在 Vue 应用中集成 ECharts 的过程。它封装了 ECharts 的初始化和使用逻辑,用户只需要在 Vue 模板中添加组件并传递相应的 props,即可轻松创建图表。

支持Vue2 & Vue3 & Nuxt3

DOC:
https://github.com/ecomfe/vue-echarts#readme

image

优点:

  1. Vue-ECharts 组件会自动处理 ECharts 实例的生命周期,能够根据 Vue 组件的状态变化自动更新图表。这使得代码更加简洁,易于维护。
  2. 通过 Vue 的数据绑定机制,可以直接将数据绑定到组件的 props 上,Vue-ECharts 会自动将数据变化应用到图表上。
  3. 作为 Vue 组件,可以很容易地与其他 Vue 组件组合,并且可以利用 Vue 的指令和事件系统。

EChart 配置生成 option

目前只有三种图表状态,可生成图片和JSON.

https://github.com/BruceHenry/chart-creator

image

EChart 速查手册 [官网]

https://echarts.apache.org/zh/cheat-sheet.html

image

EChart 主题配置 [官网]

https://echarts.apache.org/zh/theme-builder.html

image

EChart 社区示例 [社区,提供了大量的示例基本可以满足任何需求]

  1. MCChart

image

  1. isqqw

image

  1. MakeAPie

image

  1. PPChart

image

阅读导图

image

常用属性配置

title 标题配置

image

  1. text - 标题文本,例如 "柱状图"
  2. subtext
    - 副标题文本***
  3. *
    left 标题的水平位置,可以是像'left' 'center' 'right' 或者像'20%'
    这样的百分比
  4. top
    *** **
    - 标题的垂直位置,可以是像
    ** **
    'top'
    ,
    ****
    __
    'middle'
    __
    ,
    __**
    __
    'bottom'

    __**
    __
    或者像**__**
    __
    '20%'
    __
    __
    这样的百分比***
  5. textStyle
    *** **
    - 控制标题文本样式的对象,可以包括
    ** ****
    color
    ****,
    fontStyle

    fontWeight

    fontFamily
    ,*
    fontSize****** ****
  6. subtextStyle
    *** **
    - 控制副标题文本样式的对象,属性同
    ** ****
    textStyle
    *
  7. textAlign
    *** **
    - 标题文本对齐,例如
    ** **
    'left'
    ,
    ****
    __
    'right'
    __
    ,**__**
    __
    'center'***
  8. padding
    *** **
    - 标题内边距,可以是数字或数组
    ** ****
    [上, 右, 下, 左]
    *
  9. itemGap - 主副标题之间的间距
option = {
    title: {
        // 主标题文本设置
        text: '主标题文本',
        // 副标题文本设置
        subtext: '副标题文本',
        // 标题水平位置设置,'center' 表示居中
        left: 'center',
        // 标题垂直位置设置,'top' 表示顶部
        top: 'top',
        // 主标题样式设置,包括文字颜色、字体风格、字体粗细、字体族、字体大小
        textStyle: {
            color: 'black',      // 文字颜色
            fontStyle: 'normal', // 字体风格,'normal'表示普通样式
            fontWeight: 'bold',  // 字体粗细,'bold'表示加粗
            fontFamily: 'Arial', // 字体族,这里设置为Arial
            fontSize: 18,        // 字体大小,单位像素
        },
        // 副标题样式设置,属性同主标题textStyle
        subtextStyle: {
            color: '#aaa',       // 文字颜色
            fontStyle: 'normal', // 字体风格
            fontWeight: 'normal',// 字体粗细
            fontFamily: 'Arial', // 字体族
            fontSize: 12,        // 字体大小
        },
        // 标题文本对齐方式,'center' 表示居中对齐
        textAlign: 'center',
        // 标题内边距,第一个值表示上边距,第二个值表示右边距
        padding: [5, 10],
        // 主副标题之间的间距
        itemGap: 10
    },
    // 此处省略其他的图表配置选项…
};

tooltip 提示框

  1. formatter

    - 提示框浮层的内容格式器,支持字符串模板和回调函数两种形式
  2. axisPointer

    - 坐标轴指示器配置,指定其类型如

    'line'

    'shadow'

  3. show

    - 是否显示提示框组件,包括提示框浮层和 axisPointer,默认为
    ***

    ***
    true
  4. backgroundColor

    - 提示框浮层的背景颜色
  5. borderColor

    - 提示框浮层的边框颜色
  6. borderWidth

    - 提示框浮层的边框宽
  7. padding - 提示框浮层内边距,可以是数字或数组 __
    [上, 右, 下, 左]
    **
// 工具提示配置
    tooltip: {
        // 触发类型,'item' 表示数据项图形触发,用于散点图等无类目轴的图表,'axis' 表示坐标轴触发,用于柱状图等有类目轴的图表
        trigger: 'axis',
        // 自定义提示框内容
        formatter: function (params) {
            // `params` 是一个数组,包含了当前鼠标所在点的所有数据信息
            let res = params[0].name + '<br>';
            params.forEach(function (item) {
                // 添加信息,这里只是个简单示例,具体格式可以自由配置
                res += item.seriesName + ': ' + item.value + '<br>';
            });
            return res;
        },
        // 坐标轴指示器配置
        axisPointer: {
            type: 'shadow' // 'line' 表示直线指示器,'shadow' 表示阴影指示器
        },
        // 控制浮层显示
        show: true,
        // 浮层背景颜色
        backgroundColor: 'rgba(50,50,50,0.7)',
        // 浮层边框颜色
        borderColor: '#333',
        // 浮层边框宽度
        borderWidth: 0,
        // 浮层内边距
        padding: 10 // 或者使用数组形式,例如 [5, 10, 5, 10]
    },

  1. formatter

    - 提示框浮层的内容格式器,支持字符串模板和回调函数两种形式
  2. axisPointer

    - 坐标轴指示器配置,指定其类型如

    'line'

    'shadow'

  3. show

    - 是否显示提示框组件,包括提示框浮层和 axisPointer,默认为
    ***

    ***
    true
  4. backgroundColor

    - 提示框浮层的背景颜色
  5. borderColor

    - 提示框浮层的边框颜色
  6. borderWidth

    - 提示框浮层的边框宽
  7. padding - 提示框浮层内边距,可以是数字或数组 __
    [上, 右, 下, 左]
    **
// 工具提示配置
    tooltip: {
        // 触发类型,'item' 表示数据项图形触发,用于散点图等无类目轴的图表,'axis' 表示坐标轴触发,用于柱状图等有类目轴的图表
        trigger: 'axis',
        // 自定义提示框内容
        formatter: function (params) {
            // `params` 是一个数组,包含了当前鼠标所在点的所有数据信息
            let res = params[0].name + '<br>';
            params.forEach(function (item) {
                // 添加信息,这里只是个简单示例,具体格式可以自由配置
                res += item.seriesName + ': ' + item.value + '<br>';
            });
            return res;
        },
        // 坐标轴指示器配置
        axisPointer: {
            type: 'shadow' // 'line' 表示直线指示器,'shadow' 表示阴影指示器
        },
        // 控制浮层显示
        show: true,
        // 浮层背景颜色
        backgroundColor: 'rgba(50,50,50,0.7)',
        // 浮层边框颜色
        borderColor: '#333',
        // 浮层边框宽度
        borderWidth: 0,
        // 浮层内边距
        padding: 10 // 或者使用数组形式,例如 [5, 10, 5, 10]
    },

legend(图例组件)

image

图表的图例,表示不同系列的标识。

legend: {
    top: '5%',
    left: 'center',
    data: ['直接访问', '联盟广告', '搜索引擎']
}

series(系列列表)

image

每个
series
代表一组数据项的集合,对于饼图配置的主要部分。

series 属性及其可能的值包括:

  • type: 'pie' // 必须设置为 'pie' 表示这是一个饼图。
  • radius: 半径,可以是百分比或固定像素值,也可以是数组形式表示内外半径。
  • center: Pie图的中心位置。
  • data: 数据项数组,每个数据项包括 value (数值)和 name (名称)。
  • stillShowZeroSum: 如果所有数据值都是0,是否显示图形。
  • label: 用于设置数据标签,如是否显示、位置、格式等。

series: [
    {
        name: '访问来源',
        type: 'pie',
        radius: '50%', // 半径大小,支持百分比
        center: ['50%', '50%'], // 饼图的中心位置
        data: [
            {value: 335, name: '直接访问'},
            {value: 234, name: '联盟广告'},
            {value: 1548, name: '搜索引擎'}
        ],
        emphasis: {
            itemStyle: {
                shadowBlur: 10,
                shadowOffsetX: 0,
                shadowColor: 'rgba(0, 0, 0, 0.5)'
            }
        },
        stillShowZeroSum: true,
        label: {
            normal: {
                show: true,
                position: 'outside' // 标签的位置
            },
            emphasis: {
                show: true,
                textStyle: {
                    fontSize: '30',
                    fontWeight: 'bold'
                }
            }
        }
    }
]

toolbox
(工具栏配置)

image

color
(配色方案)

image

定义颜色数组,用于系列中每个扇形的默认颜色:

  • 示例:
    <font style="color:rgb(46, 50, 56);">['#5470C6', '#91CC75', '#FAC858', '#EE6666']</font>

animation
(动画配置)

image

完整配置示例代码

option = {
    title: {
        // 饼图的标题
        text: 'Pie Chart Example'
    },
    tooltip: {
        // 鼠标悬浮时的提示框设置
        trigger: 'item' // item: 数据项图形触发
    },
    legend: {
        // 图例组件,展示不同系列的标识颜色和名称
        top: '5%',    // 距离容器上方的距离
        left: 'center', // 水平居中
        // 对应系列名称的数据
        data: ['Direct Visit', 'Union Ad', 'Search Engine']
    },
    toolbox: {
    show: true, // 显示工具栏
    feature: {
      saveAsImage: {}, // 保存为图片功能
      restore: {} // 还原功能
    }
  },
    series: [
        {
            // 系列名称,用于tooltip的显示
            name: 'Access From',
            type: 'pie', // 类型必须为饼图
            // 饼图的半径大小
            radius: '55%',
            // 饼图的中心(圆心)位置
            center: ['50%', '60%'],
            // 数据数组,包含每个扇区的大小和名称
            data: [
                { value: 335, name: 'Direct Visit' },
                { value: 234, name: 'Union Ad' },
                { value: 1548, name: 'Search Engine' }
            ],
            // 强调样式,当鼠标悬浮时显示阴影等效果
            emphasis: {
                itemStyle: {
                    shadowBlur: 10, // 阴影的模糊大小
                    shadowOffsetX: 0, // 阴影水平方向上的偏移距离
                    shadowColor: 'rgba(0, 0, 0, 0.5)' // 阴影颜色
                }
            },
            // 标签的显示方式设定
            label: {
                normal: {
                    // 常规状态下的标签显示设置
                    show: true, // 是否展示标签
                    position: 'outside', // 标签的位置
                    // 标签的格式化器
                    formatter: '{b}: {c} ({d}%)'
                },
                emphasis: {
                    // 高亮状态下的文本样式定义
                    show: true,
                    textStyle: {
                        fontSize: '30', // 字体大小
                        fontWeight: 'bold' // 字体粗细
                    }
                }
            }
        }
    ]
};

EChart 在 Vue3 中实战

// 图表实例的引用
  const chart = ref(null);

  // 初始化图表
  const initChart = () => {
    if (chart.value) {
      const myChart = echarts.init(chart.value); // 初始化 ECharts 实例
      myChart.setOption(props.option); // 设置配置项
    }
  };

  // 监听 props 的变化,动态更新图表
  watch(
    () => props.option,
    (newOption) => {
      if (chart.value) {
        const myChart = echarts.getInstanceByDom(chart.value); // 获取已初始化的图表实例
        myChart.setOption(newOption); // 更新配置项
      }
    }
  );

响应式处理

图表在窗口大小变化时能够自动调整。可以使用
resize()
方法手动调整图表大小,通常是在
updated
生命周期钩子中调用。

// 响应式调整图表大小, 监听窗口大小变化,确保图表自适应容器的大小。
const resizeChart = () => {
  if (chart.value) {
    const myChart = echarts.getInstanceByDom(chart.value);
    myChart.resize(); // 调整图表尺寸
  }
};

// 生命周期钩子
onMounted(() => {
  initChart(); // 组件挂载后初始化图表
  window.addEventListener('resize', resizeChart); // 监听窗口大小变化
});

动态数据更新

使用 Vue 的响应式数据(如
ref

reactive
)配合
watch
,在数据变化时调用
chart.setOption()
重新渲染图表。

const chartOption = ref({
      title: { text: '实时数据' },
      tooltip: { trigger: 'axis' },
      xAxis: { data: ['1', '2', '3', '4', '5'] },
      yAxis: {},
      series: [{
        name: '销量',
        type: 'line',
        data: [120, 132, 101, 134, 90]
      }]
    });



watch(
  () => props.option,
  (newOption) => {
    if (chartInstance.value) {
      chartInstance.value.setOption(newOption); // 动态更新图表配置
    }
  },
  { deep: true } // 深度监听
);



chartOption.value.series[0].data.push(Math.random() * 100);
chartOption.value.xAxis.data.push(String(chartOption.value.xAxis.data.length + 1));

图表容器大小自适应

父容器尺寸发生变化时,图表可能不会自动调整大小。 我们可以通过 监听
resize
事件或使用 Vue 的响应式布局方案,调用
chart.resize()
更新图表尺寸。

const observer = new ResizeObserver(() => {
  chartInstance.value?.resize(); // 动态调整大小
});
observer.observe(containerElement); // 监听容器

国际化与多语言支持

图表中包含的文案(如标题、提示)需要支持多语言。 我们可以使用 Vue I18n 或其他国际化工具动态替换文本。

npm install vue-i18n
import { useI18n } from 'vue-i18n';

const { t } = useI18n();
const option = reactive({
  title: { text: t('chart.title') },
});

ECharts 图表导出

toolbox: {
  feature: {
    saveAsImage: { show: true },
  },
};

性能优化

销毁 ECharts 实例

在组件销毁时,确保销毁 ECharts 实例,防止内存泄漏。

 onBeforeUnmount(() => {
      if (chart.value) {
        const myChart = echarts.getInstanceByDom(chart.value);
        myChart.dispose(); // 销毁 ECharts 实例
      }
      window.removeEventListener('resize', resizeChart); // 移除事件监听
    });

大数据渲染性能优化

数据量大时,ECharts 的渲染可能会导致性能瓶颈。 我们可以通过以下三种方式来达到优化作用

  • 使用
    **dataZoom**
    :

    允许用户缩放数据区域,减少可视数据点。
  • 启用分片渲染:

series: [
  {
    type: 'line',
    large: true, // 启用大数据优化
    largeThreshold: 4000, // 数据量门槛
  },
];
  • 降级动画:
    动态数据场景中禁用或简化动画。
animation: false,
progressive: 4000, // 分步渲染
progressiveThreshold: 10000, // 数据点门槛

图表配置项太长,分离配置项

配置项代码过长,组件可读性降低。我们可以将配置项提取到单独的模块,便于复用和维护。

image

EChart 知识点常考

如何初始化和销毁 ECharts 实例?

  • echarts.init(dom)
    初始化。
  • chart.dispose()
    销毁实例,避免内存泄漏。

ECharts 常见配置项有哪些?

  • title
    ,
    legend
    ,
    tooltip
    ,
    grid
    ,
    xAxis
    ,
    yAxis
    ,
    series
    ,
    toolbox
    ,
    dataZoom

动态数据更新

- 如何实现图表数据的动态更新?
- 如何监听 `props` 变化并更新图表配置?

性能优化

- 大数据渲染场景如何优化?
- 什么是 `progressive` 渲染模式?

图表事件

- 如何捕获图表点击、悬停事件并执行对应的业务逻辑?

如何处理数据较多导致渲染卡顿的情况?

  • 使用数据分片渲染(
    progressive
    )。
  • 禁用动画。
  • 使用
    dataZoom
    限制显示范围。

如何实现图表导出功能?

使用
toolbox.feature.saveAsImage
或通过
chart.getDataURL()
获取图表数据并导出。

最后

通过对 ECharts 通用属性的深入提炼与系统整理,以及常用配置项的归纳总结,我们能够迅速实现图表的搭建与展示,极大地提升了开发效率。此外,借助丰富的 ECharts 资源和示例,我们基本上能够满足大部分业务场景的需求。这样一来,我们不仅能够更加快捷地实现功能需求,还能腾出更多宝贵的时间,专注于其他重要任务。

欢迎关注我的微信公众号
【前端自学社区】
,在那里我将分享更多关于前端技术的心得与实用技巧。



Flink Forward Asia 2024 即将盛大开幕!

作为 Apache Flink 社区备受期待的年度盛会之一,本届大会将于
11 月 29 至 30 日

上海
隆重举行。Flink Forward Asia(简称 FFA)是由 Apache 官方授权的社区技术大会,旨在汇聚领先的行业实践与技术动态。在众多合作伙伴和技术开发者的支持下,FFA 已成功举办六届。


适逢 Apache Flink 诞生 10 周年,今年的 FFA 将与广大开发者分享 Flink 过去十年在技术和生态方面的演进历程及关键成果。同时,基于当前 AI 时代的新场景,展望未来十年 Flink 的发展方向和规划。大会还将为您提供全面了解 Flink 2.0 版本的机会,并与业界领袖共同探讨 Apache Flink 的过去、现在及未来,以及其在实际应用中的潜力。快来预约主论坛直播,享受大数据技术盛宴~


主会场看点




The Past, Present, and Future of Apache Flink

中,王峰老师将带领我们回顾过去十年间Apache Flink的发展历程,从其诞生到成为流计算领域的事实标准。不仅会分享Flink在技术革新与生态建设方面取得的关键成就,还会探讨AI新时代背景下,Flink如何适应不断变化的需求,并预测未来十年内它可能采取的发展路径及目标设定。



Apache Flink 2.0: Streaming into the Future

议题中,宋辛童、梅源和李麟三位老师将共同聚焦于即将发布的Flink 2.0版本上。自2016年Flink 1.0发布以来,这将是该框架经历的最大规模更新之一。他们将深入解析这一里程碑式版本所带来的技术创新点,以及这些变化对当前大数据处理乃至人工智能领域可能产生的长远影响。



Paimon 1.0: Unified Lake Format for Data + AI

环节中,李劲松老师将介绍今年刚毕业为Apache顶级项目的Paimon。通过结合使用Flink,Paimon 1.0 致力于构建一个高效的数据湖仓系统,在阿里巴巴集团及其他多个行业已成功实施应用。此外,来自淘天、抖音及VIVO等企业的嘉宾还将分享各自基于Paimon+Flink架构的实际案例,展示如何加速实现数据湖内的实时流动与分析能力。


在《
Fluss: Next-Gen Streaming Storage for Real-Time Analytics

这一主题中,伍翀老师将介绍一种专为流分析设计的新一代存储解决方案——Fluss。重点讨论 Fluss 如何有效解决当前流存储方案中存在的挑战,并详细介绍其核心功能特点、适用场景及其带来的优势。同时也会探讨 Fluss 与现有湖存储技术之间的整合可能性及其后续发展蓝图。


在圆桌讨论环节中,来自不同行业的大数据专家将就“AI 时代下大数据技术未来路在何方?”展开深入讨论。嘉宾将结合各自的研究背景和实践经验,就如何更好地融合AI与大数据处理技术、应对行业新趋势等问题进行探讨。嘉宾将分享各自在推动大数据与AI技术融合方面的见解和经验,为未来的发展方向提供新的思路。

主持人:李劲松|阿里云智能开源湖存储负责人

嘉宾:

  • 王峰|阿里云智能开源大数据平台负责人
  • 陈叶超|喜马拉雅数据平台部负责人
  • 云襄|哈啰集团大数据总监
  • 黄博远|阿里云智能集团资深产品专家、阿里云人工智能平台PAI产品负责人



主论坛直播预约


PC 端主会场直播观看

我们诚挚地邀请您观看 Flink Forward Asia 2024|上海站主论坛!这不仅是一次深入了解 Apache Flink 最新技术动态与应用场景的机会,更是一个让您亲手实践、探索无限可能的平台!在观看直播的同时,我们特别设置了互动环节——完成指定的Flink实验任务,就有机会赢取一系列精美好礼:
Apache Flink 黑色款双肩包、Flink Forward 长袖卫衣、Flink 咖啡杯、阿里云定制帆布袋、花朵坐垫。

报名预约,你将在视频开始前收到短信通知,
立即报名

移动端主会场直播观看

可扫码下方二维码或点击
链接
预约直播。

▼ 「预约直播」扫码立即预约主论坛直播 ▼