2024年9月

二维码如今是移动应用流量入口以及功能实现的重要工具,也是各App的流量入口,使用场景愈加丰富,广泛应用于支付、出行、餐饮、生活服务、智慧生活、零售及广告营销等主流场景。

然而,在实际生活中,扫码环境如光照强度、扫码角度、距离等,相机功能如缩放、对焦、曝光等和码图本身完整程度、弯曲程度等很大程度上会影响用户的扫码体验。

HarmonyOS SDK
统一扫码服务
(Scan Kit)作为软硬协同的系统级扫码服务,帮助开发者的应用快速构建面向各种场景的码图识别和生成能力。统一扫码服务应用了多项计算机视觉技术和AI算法技术,不仅实现了远距离自动扫码,同时还针对多种复杂扫码场景(如暗光、污损、模糊、小角度、曲面码等)做了识别优化,实现远距离码或小型码的检测和自动放大,提升扫码成功率与用户体验。

image

image

其中统一扫码服务的
默认界面扫码能力
提供系统级体验一致的扫码界面,包含相机预览流,相册扫码入口,暗光环境闪光灯开启提示,支持单码和多码识别,具备相机预授权,调用接口时,无需开发者再次申请相机权限。适用于不同扫码场景的应用开发。

能力优势

接入简单:一行代码,接入简单;系统级接口,包体0增加。

免弹窗:系统相机权限预授权,保护用户信息安全。

识别率高:应用多项CV技术,提升扫码成功率和速度。

识别距离远:应用端侧AI算法技术,实现远距离识码。

业务流程

image

开发步骤

统一扫码服务提供了默认界面扫码的能力,由扫码接口直接控制相机实现最优的相机放大控制、自适应的曝光调节、自适应对焦调节等操作,保障流畅的扫码体验,减少开发者的工作量。

以下示例为调用Scan Kit的startScanForResult接口跳转扫码页面。

1.导入默认界面扫码模块,
scanCore
提供扫码类型定义,
scanBarcode
提供拉起默认界面扫码的方法和参数,导入方法如下。

import { scanCore, scanBarcode } from '@kit.ScanKit';
// 导入默认界面需要的日志模块和错误码模块
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';

2.调用startScanForResult方法拉起默认扫码界面。

通过Promise方式得到扫码结果。

[@Entry](https://my.oschina.net/u/4127701)
[@Component](https://my.oschina.net/u/3907912)
struct ScanBarCodePage {
  build() {
    Column() {
      Row() {
        Button("Promise with options")
          .backgroundColor('#0D9FFB')
          .fontSize(20)
          .fontColor('#FFFFFF')
          .fontWeight(FontWeight.Normal)
          .align(Alignment.Center)
          .type(ButtonType.Capsule)
          .width('90%')
          .height(40)
          .margin({ top: 5, bottom: 5 })
          .onClick(() => {
            // 定义扫码参数options
            let options: scanBarcode.ScanOptions = {
              scanTypes: [scanCore.ScanType.ALL],
              enableMultiMode: true,
              enableAlbum: true
            };
            // 可调用getContext接口获取当前页面关联的UIAbilityContext
            scanBarcode.startScanForResult(getContext(this), options).then((result: scanBarcode.ScanResult) => {
              // 收到扫码结果后返回
              hilog.info(0x0001, '[Scan CPSample]', `Succeeded in getting ScanResult by promise with options, result is ${JSON.stringify(result)}`);
            }).catch((error: BusinessError) => {
              hilog.error(0x0001, '[Scan CPSample]',
                `Failed to get ScanResult by promise with options. Code:${error.code}, message: ${error.message}`);
            });
          })
      }
      .height('100%')
    }
    .width('100%')
  }
}

通过Callback回调函数得到扫码结果。

@Entry
@Component
struct ScanBarCodePage {
  build() {
    Column() {
      Row() {
        Button('Callback with options')
          .backgroundColor('#0D9FFB')
          .fontSize(20)
          .fontColor('#FFFFFF')
          .fontWeight(FontWeight.Normal)
          .align(Alignment.Center)
          .type(ButtonType.Capsule)
          .width('90%')
          .height(40)
          .margin({ top: 5, bottom: 5 })
          .onClick(() => {
            // 定义扫码参数options
            let options: scanBarcode.ScanOptions = {
              scanTypes: [scanCore.ScanType.ALL],
              enableMultiMode: true,
              enableAlbum: true
            };
            // 可调用getContext接口获取当前页面关联的UIAbilityContext
            scanBarcode.startScanForResult(getContext(this), options,
              (error: BusinessError, result: scanBarcode.ScanResult) => {
                if (error) {
                  hilog.error(0x0001, '[Scan CPSample]',
                    `Failed to get ScanResult by callback with options. Code: ${error.code}, message: ${error.message}`);
                  return;
                }
                // 收到扫码结果后返回
                hilog.info(0x0001, '[Scan CPSample]', `Succeeded in getting ScanResult by callback with options, result is ${JSON.stringify(result)}`);
              })
          })
      }
      .height('100%')
    }
    .width('100%')
  }
}

了解更多详情>>

访问
统一扫码服务联盟官网

获取
默认界面扫码服务开发指导文档

在现代桌面应用开发中,使用 Electron 加载远程服务器托管的前端资源,再与本地 API 交互,能够带来灵活的部署和强大的本地功能支持。这种方式不仅提升了开发效率,还能充分利用 PC 端的资源和性能。

本文将深入解析如何使用 Electron 实现这一架构,并探讨其背后的关键技术,包括
ipcMain

ipcRenderer
进程间通讯,以及
preload.js
安全交互等内容。你将学会如何打造既能随时更新前端,又能高效利用本地硬件资源的桌面应用。

1. 服务器资源与 Electron 的高效结合

通常,我们的前端资源(HTML、CSS、JavaScript)可以托管在远程服务器上,比如通过 Nginx、Apache 等托管工具来部署静态页面和资源。

Electron 使用
BrowserWindow
加载这些远程资源:

const { app, BrowserWindow } = require('electron');const path = require('path');

function createWindow() {
const win = newBrowserWindow({
width:
800,
height:
600,
webPreferences: {
preload: path.join(__dirname,
'preload.js'),
nodeIntegration:
false,
contextIsolation:
true,
},
});
//加载服务器托管的前端页面 win.loadURL('https://example.com');
}

app.whenReady().then(createWindow);

这样,Electron 应用可以直接从服务器加载最新的前端资源,同时主进程负责处理本地 API 的调用和交互。

2.
preload.js
:前端与本地 API 的安全桥梁

Electron 提供了
preload.js
,这是一个在 Web 页面加载之前运行的脚本,它允许安全地在前端和主进程之间创建通信通道。通过
preload.js
,我们可以将本地 API 的访问封装起来,并通过
contextBridge
暴露给前端。

const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld(
'electronAPI', {
sendMessage: (channel, data)
=>{const validChannels = ['toMain'];if(validChannels.includes(channel)) {
ipcRenderer.send(channel, data);
}
},
receiveMessage: (channel, func)
=>{const validChannels = ['fromMain'];if(validChannels.includes(channel)) {
ipcRenderer.on(channel, (
event, ...args) =>func(...args));
}
}
});

这种方式确保前端无法直接访问 Node.js API,从而提高了应用的安全性。

3. 利用
ipcMain

ipcRenderer
实现前后端通讯

前端通过
preload.js
与主进程进行消息交互,而主进程通过
ipcMain
监听来自前端的请求。以下是主进程中如何处理前端请求并与本地 API 交互的示例:

const { ipcMain } = require('electron');

ipcMain.on(
'toMain', (event, data) =>{
console.log(
'收到前端数据:', data);//调用本地 API 或进行其他操作 const response =callLocalAPI(data);//发送结果给前端 event.sender.send('fromMain', response);
});

function callLocalAPI(data) {
return`处理后的数据: ${data}`;
}

前端可以使用暴露的 API 来发送消息并接收响应:

<script>window.electronAPI.sendMessage('toMain', '这是来自前端的数据');

window.electronAPI.receiveMessage(
'fromMain', (response) =>{
console.log(
'收到主进程响应:', response);
});
</script>

4. 综合工作流

通过这套架构,Electron 可以:

  1. 从服务器加载和渲染最新的前端资源。
  2. 使用
    preload.js
    提供安全的接口,允许前端与本地 API 进行通讯。
  3. 利用
    ipcMain

    ipcRenderer
    实现前后端的双向通讯。

结语

这种 Electron 与服务器资源结合的架构,不仅让前端资源管理更加灵活,还能高效利用本地 API 和硬件资源。无论是需要频繁更新的前端界面,还是依赖本地系统功能的应用场景,这种方式都能提供强大支持。

通过本文的示例,你已经掌握了如何通过 Electron 加载服务器资源并与本地 API 交互的核心技术,为你的桌面应用注入更多可能性。

让我们一起动手,打造更加灵活与强大的桌面应用吧!

导读:大模型能力的发展和成熟,催生出新一代智能化 BI—— ChatBI,即通过自然语言处理(NLP)与大型语言模型(LLMs)的结合,极大简化数据分析过程,提高效率并降低分析门槛。火山引擎数智平台旗下智能数据洞察产品 DataWind 近期上线 ChatBI 能力,提供智能修复、多语法适用等能力,在性能上实现秒级响应、一键生成。用户只需要通过文字描述需求, 就能生成指标,快速实现数据获取、分析计算与图表搭建,大幅降低数据消费门槛。本篇文章将从技术架构、实现路径、总结展望几个方面,拆解火山引擎数智平台如何落地 ChatBI 能力。

BI 其实是一个由来已久的名词。其中 I——“intelligence”的内涵已经随着时间推移和时代发展而逐渐发生变化。

起初,人们认为在数据仪表盘和看板上能够进行筛选条件变更与维度下钻就是智能化表现。

而随着平台更新迭代,更多高阶、复杂的功能以更易操作的形式更新到平台中,让没有计算机背景或编程背景的人也能够深切体会到代码、计算机或者大数据时代所带来的智能之感。

随着 AI 时代的来临,大家对于智能化有了更多期待。例如: 它是否能够“猜到”自己的想法进行智能推荐?或是,当看到数据异常,它能否帮忙找出原因?

客观而言,从 2018 年开始开发的抖音集团内部 BI 平台起步较晚。 因此其直接跳过了 BI 平台早期发展阶段,从立项之初,它的目标便是成为能够满足公司内部几乎所有数据分析需求的数据分析平台。

在抖音集团内部,BI 平台建设分为以下几个阶段:

一是 2020 年前后的开发建设。在这个阶段投入了大量资源,对结果归因相关功能进行开发,希望能够帮助用户解决归因问题。

二是在 2021 年 4 月,发布了低代码的可视化建模工具。原因在于,团队不想让用户在数据分析的过程中发现数据尚未准备完毕时,需要去专门联系数仓开发人员重新准备一份数据。为此开发了可视化建模工具,希望用户仅需要进行简单的拖拉拽操作就可以轻松处理数据。

三是 2023 年年底。内部团队面对迅速发展的 ChatGPT,认为它会对 BI 产生具有如“掀桌子”一般颠覆性的影响,因此经过一段时间的尝试,便在今年 4 月份对内进行了产品发布。就目前而言落地效果不错,已经有几千人在高频使用这一内部产品。

当前,火山引擎数智平台旗下的智能数据洞察 DataWind 已构建起包含了数据准备与管理,数据分析,以及多端展示等功能的相对完善的产品能力矩阵,同时赋予产品系统高度可运维优势。

截至目前,抖音集团 80%内部员工成为产品月活用户,同时在工作日单日的产品最低查询量基本处于 200w 次以上。

火山引擎数智平台高性能数据分析架构方案

数据驱动决策,是在抖音集团内部深入人心的重要概念,并与公司所推行的 OKR 理念相互契合。由于 OKR 通常以指标化方式去衡量,在指标出现问题需要进行排查探寻原因时,数据分析便成了必不可少的过程。同时,在排查过程中用户脑海中会同时存在多种分析思路,如果数据分析时间过长,就会将原本的分析思路打断。因此,为了实现高速分析,企业内部员工用户对分析平台的性能有着极大要求。

尽管性能十分重要,但 BI 平台开发厂商往往认为其更多与引擎有关:引擎能力较差会导致 BI 所能处理的事情并不多。

但数据集、数据源、数据量的大小以及 Query 的复杂程度等并不是用户所关心的,他们关心的是自己的数据分析能否能快速完成。因此在提高性能方面的开发面临着很大挑战。

为了满足用户对性能的需求,开发中采取了有别于主流 BI 厂商的思路。虽然 DataWind 产品支持直连目前通用的大部分数据源、数据引擎与数据库。但在企业内部用户更多地使用“抽取”,即围绕自研分析性数据库 ByteHouse 建设了非常重的抽取链路,把公司内几乎全部需要进行数据分析的数据全部放入 ByteHouse 中。

由于数据存放方式对于查询效率有着极大影响,因此 BI 团队使用了大量的 ByteHouse 集群来满足用户对于实时连接、离线连接、不同表引擎连接的需求

同时,如何充分有效地利用 ByteHouse 高性能引擎也十分重要。比如应该向什么样的集群推荐数据集,选择什么样的表引擎,以及确定什么样的分片和排序键策略等。这些为问题对于性能而言都相当关键。

在这里,先简单介绍一下 ByteHouse。相较于原生 Clickhouse,ByteHouse 针对多个领域做了性能优化。

首先,是 HaMergeTree 方面的优化工作。HaMergeTree 对于大部分企业用户而言都不可或缺。原生 ClickHouse 在对 Apache ZooKeeper(ZK)存在较大依赖,在文件的 part 信息处理方面依赖性更甚。这就导致 ClickHouse 处理大规模数据集时,易造成 ZK 资源紧张,管理的 znode 数量暴增,影响系统性能和稳定性。

ByteHouse 对此进行了大量优化,从而降低了对 ZK 的依赖程度。目前在 ByteHouse 中,对 ZK 的一脸仅仅存在于 schema 信息,以及生成自增序列等极少数场景中,从而保证了 ByteHouse 的整体性能和可用性。

在 HAUniqueMergeTree,即原生 ClickHouse 的 Raplacing MergeTree 方面。相对而言,ClickHouse 引擎在读取方面并不高效,而 ByteHouse 在处理此方面问题时,会通过建立一定的索引实现对记录的快速更新和标记删除,从而提高性能。

此外,原生 Clickhouse 的 join 能力因为对 coordinator 节点压力较大的问题被大家诟病已久。ByteHouse 在这个方面实现了真正的分布式 join,同时也基于此做了大量优化器方面的工作。例如当大表 join 小表时,ByteHouse 会根据小表的数据情况进行自主判断,去对大表中的部分数据免读或免下封。

总体而言,把大量数据导入 ByteHouse 并不意味着表的数量很多。在抖音集团内部,大家更愿意把更全面、更明细的数据导入到 ByteHouse 集群中,从而避免在做数据分析的过程中出现某一方面的数据不存在或者明细级别不够的情况。

在使用到非常细的粒度的场景,团队认为大部分查询是基于某些高频指标维度,去找到非常明细的数据所做的查询。因此会很容易地想到解决方案,即建立一些 Cube 或物化视图,并且建立一些自动路由。

对于用户而言,这些操作都是十分透明的。而从工程上值得一提的是,团队并没有使用 ByteHouse 自带的物化视图或是 projection 方式,因为在开发实践测试中,发现这种方式对于集群与整体性能有负面影响。目前,开发中主要使用基于 Hadoop 链路与基于 Spark 的链路去进行 Cube 建设,并且由此实现自动路由。从用户角度来看,用户会面对一张宽泛且明细力度极细的大表。

但这种方法的副作用在于由于产品的各个业务线都采取了付费使用形式,为数据集建设了大量聚合表必然会导致成本的上升。这就涉及到一个新的问题:在满足了用户的速度要求情况下,如何降低成本?

针对这个问题,解决的思路是提供数据冷热分层,对于最为常用的数据,例如最近 7 天或 14 天的数据,可以放置于 ByteHouse 中进行存储。而相对距离当前时间较远的数据,则放置在存算分离的 ByteHouse 集群当中,通过更为廉价的方式来实现查询。至于时间更为久远的数据,比如过去一年的数据,就存于 Hive 表内,可以通过 Python 或者 Spark 的方式来进行查询。

而之所以保留对以 Sparck 方式进行兜底查询这一方式的支持,是由于 MPP 相关数据库在兜底能力方面普遍存在硬伤。故而采用 Spark 方式来兜底,如此一来,至少能够确保用户在极为极端的情况下仍然能查询到数据结果。

除此之外,性能优化与成本治理也是值得研究的问题。对此开发中所采用的是一种较为偏向“人民战争”的方式。鉴于仅依赖平台运维团队来监控所有性能指标及具体数据库表,难以满足集团公司庞大的业务需求,团队选择将这种监控与优化的能力内嵌于产品体系中。

从而让每个业务线的负责人,乃至每个项目管理者,都能直观地了解到哪些数据集消耗资源较多、哪些数据集的成本效益比较低——即投入大额资金但查询频率并不高的情况。该策略还可协助他们识别出哪些部分可通过构建多级聚合来提升性能,以及在确保性能不受影响的前提下,如何实施成本控制措施以实现更高效的资源配置。

通过这种方式,不仅能够分散管理和优化的压力,还能促进全员对资源效率的关注与参与,确保了整个集团在规模扩张的同时保持成本效益与服务性能的最优化。

BI + AI 实现智能数据洞察

抖音集团内部在 BI 平台建设阶段,对智能部分投入较多。

而对智能部分的分享可以大致分为三个部分。

其一为数据开发,旨在帮助进行数据准备的人员能够准备更具价值的数据;其二是数据分析,期望能够助力用户进行异常指标查询以及异常归因;其三为数据消费,通过对话式问答的方式来提升提取信息的效率。

数据开发的场景相对简单,团队的工作主要是集成多种 AI 算子至低代码可视化建模工具中,其中运用较多的是预测能力。而使用预测的场景也非常容易理解。

假设用户有一张表,其中某一列可能表示几天后的数据,若此时用户已知道其他列的信息和历史数据,便会希望通过机器学习的方式预测出该列的新值。从目前来看,此类需求较多。从算子角度来讲,产品母线已集成约 40 多种算子,其中特征工程算子与预测算子是被频繁使用的两类。

接着是数据分析场景。在数据分析场景中,开发团队希望能够帮助用户更快捷地进行异常指标查询以及异常归因,不想再为用户配置例如当数据指标低于 10%或 5%再发送警告此类傻瓜式警告方式。

团队想要开发一个更为灵活、能够反映指标季节性的预警系统,因此在开发中采用了 STO 算法,同时结合指标平滑技术,利用残差结合历史数据计算出指标的波动范围,当超出这一波动范围时,就会进行告警。

从产品形态方面分析,归因可以分为以下几类。

从产品形态上即时归因较易理解,即用户在发现异常时只需点击一下,系统便会进行归因。就维度选择而言,开发团队参照了一种基于基尼系数的维度选择方式,基尼系数常被联合国用于贫富差异比较,将其理解成维度后,可把每个维度视为一个国家,若某个维度中维值对某一指标的贡献较为平均且无明显差异性,则认为该维度可能并非主要原因。

在确定维度后会通过一系列方法计算维值的贡献率。即时归因对即时性要求较高,其可以在短时间,比如 15 秒钟左右,返回查询结果,但即时归因能分析的事情相对较少,它不会进行相关指标分析,仅会做维度分析,也不会做过多维度组合相关分析,总体功能较为简单。

而另一种归因——洞察报告的功能则相对丰富,洞察报告通过异步通知模式可以处理相对复杂的需求,能够分析不同指标和进行多种维度组合。

用户归因前进行配置,比如选择指标归因或维度归因,选择大致组合后便可生成洞察报告,洞察报告既可以在系统上查看,也可以推送给相应的 IM。

此外,还有一种在内部使用较多的归因——指标分析树。

在集团内部,大家在进行 OKR 对齐时,指标往往会形成类似指标体系的东西,即:上级重视 GMV 等指标,而下级更关注 PV 等指标,这种差异便会形成树状指标结构。如果对于指标存在疑问,就会进行固化的基于维度的分析,其总体思路是将指标分析过程进行固化,确保在查看 OKR 或指标时,能够清楚知道出现异常的板块、维度和节点。

归因功能从实现角度而言整体相对简单,难点主要在于产品设计与算法相关处理,而其在工程角度也是较为简单的问题。此外,异步的洞察报告和指标分析数调度在实现时,要尽量避免对在线查询产生影响,要尽量减少占用在线查询资源。

在积极开展指标归因相关工作时,大模型出现了。随后,团队投入了大量的时间去探索与大模型相关的能力,同时也耗费了较多的资源。从结果方面来看,目前集团内部已有大几千人成为 ABI 的 Copilot 常用用户,因此整体而言取得了不错的成果。

从探索角度而言,团队开发了多样化的场景,但落地结果有部分相对成功,也有部分相对失败。

如今回顾那些不太成功的场景,都存在一个共性,即所生成内容的质量并非很高。也就是说,相对而言或许产品交互方面它们还有很大的改进空间,但在内容质量方面的调优往往较为困难。

例如,用户期望大模型能帮助进行归因,告知数据为何不对或者接下来应朝哪个方向去查看。

关于这方面的能力,团队最初将其上线的原因在于其表现实际上超出了预期。从开发者角度来看,特别容易以较低的预期来看待大模型相关事宜,觉得它能做到与自己一样的事情就感觉它似乎表现得已经不错了,但实际上从用户解决问题的角度来看,往往生成的内容质量没有那么高。所以在这一点上特别容易让团队产生较为乐观的预期,而这往往会导致落地效果或落地姿态不够理想。

目前取得成功的案例存在一些共性特点。首先,如果功能是为了解决诸如用户在解决问题过程中本身就需要去搜索资料的问题,与 ChatGPT 相结合往往能获得较好的解决方式。

在代码开发场景方面,团队产品功能内部落地情况较好,包括此前列举的 SQL 查询,围绕 ABI 所支持的高阶 Notebook 做法。这些功能能够取代用户在网上搜索查看大量 Stack overflow 帖子,之后提炼代码编辑思路的场景,而在大模型加持的代码编辑器中,DataWind 提供了如解释 SQL、优化 SQL、生成注释以及报错修复等一系列功能。

又或者说数据准备的第一步:源数据的录入。起初在录入指标时,往往需要进行诸多翻译工作为指标命名。

若是处于多语言环境,还需配置该指标的外语名称。对于这类问题,以往解决时通常需要查阅公开资料,查询相关单词的英文写法等。而这部分的工作,大模型能够有较为出色的表现,能够极大地节省用户精力。

而在仪表盘的探索分析及解读中,大模型能发挥的最大作用在于帮助用户展开润色工作,因为用户可能需要迅速地将数据结果发送给上级,而自己书写解读内容可能会相对困难。在这一场景中,DataWind 除了对某一图表进行数据解释外,还会推荐一些 follow up 问题,而这些 follow up 问题实则完全由 GPT 所推荐。

当用户点击一个问题时,它亦会描述接下来要进行的操作,这也变相回应了另一个问题,即如何让用户知晓模型后续的行为。因为目前至关重要的一点是,以大模型在现阶段的能力,其回答是无法做到百分百准确的。

而在严肃场合,需要的是一个非常精准的数字,用户会非常想要了解其统计口径是什么,它又是如何得出这个结论的。因此在落地场景中,一个极为重要的原则是必须让用户清楚大模型到底做了什么,或者说大模型做了哪些部分,以及接下来将如何处理这个请求,这个数据究竟是如何得来的,这一点十分关键,否则,即便模型准确率能够达到 95%以上,其在数据产品中的落地也较为困难。

接着再来看一下 SQL 查询的场景,本产品能够进行解释以及优化。在 editor 中可以运用自然语言来帮助生成相应的 SQL 以及与 notebook 相关的一些代码。

从实现角度而言。第一点便是合规与内容审核问题。抖音集团内部实践最初采用了 GPT 模型,进行了多种尝试,包括 GPT 3 的 tuning 等,对比后选择了 GPT 4,同时也在努力对接公司自研大模型,因此在内容审核方面较为吃力,例如:若认为 table 的 schema 不那么敏感,而 table 的数据敏感,那么那些维值应如何处理?是否需要进行向量化匹配?这会涉及到一系列的技术和工程问题。

再如精调问题,究竟是否要采用模型精调以及何时采用?即便到目前内部也未完全放弃模型精调这一路线,开发团队对于精调一事的理解更多是以空间换取时间,因为有时团队会发现当用户将私域问题描述得极为详尽时提示语过长,过长的提示语一方面可能无法输入,另一方面也可能影响整体使用效率。此时要通过部分精调的方式来减少需要提供的 prompt 数量。

总结与展望

简单总结几个未来展望的要点:

一,企业级 BI 正逐渐成为新趋势,曾经的普遍情况是诸多业务部门各自购买 BI,而全公司或全员使用的 BI 在当时并不重要,但如今其重要性愈发凸显。

二,指标治理以及 AI 能力也是至关重要的部分。

三,团队认为数据消费能够推动数据建设,总体建设思路是将上层的数据消费打造得极为繁荣,在相对繁荣之后,会持续向下层的数据建设,如 ETL 部分、数仓部分以及数据湖部分提出新的诉求,从而带动下层基础设施的建设。

点击
火山引擎DataWind
了解更多

前言

分布式ID,在我们日常的开发中,其实使用的挺多的。

有很多业务场景在用,比如:

  1. 分布式链路系统的trace_id
  2. 单表中的主键
  3. Redis中分布式锁的key
  4. 分库分表后表的id

今天跟大家一起聊聊分布式ID的一些常见方案,希望对你会有所帮助。

1 UUID

UUID (Universally Unique IDentifier) 通用唯一识别码 ,也称为 GUID (Globally Unique IDentifier) 全球唯一标识符。

UUID是一个长度为128位的标志符,能够在时间和空间上确保其唯一性。

UUID最初应用于Apollo网络计算系统,随后在Open Software Foundation(OSF)的分布式计算环境(DCE)中得到应用。

可让分布式系统可以不借助中心节点,就可以生成唯一标识, 比如唯一的ID进行日志记录。

UUID是基于时间戳、MAC地址、随机数等多种因素生成,理论上全球范围内几乎不可能重复。

在Java中可以通过UUID的randomUUID方法获取唯一字符串:

import java.util.UUID;

/**
 * @author 苏三
 * @date 2024/9/13 上午10:38
 */
public class UuidTest {
    public static void main(String[] args) {
        String uuid = UUID.randomUUID().toString();
        System.out.println(uuid);
    }
}

运行结果:

22527933-d0a7-4c2b-a377-aeb438a31b02

优点:UUID不借助中心节点,可以保持程序的独立性,可以保证程序在不同的数据库之间,做数据迁移,都不受影响。

缺点:UUID生成的字符串太长,通过索引查询数据的效率比较低。此外,UUID生成的字符串,顺序没有保证,不是递增的,不满足工作中的有些业务场景。

在分布式日志系统或者分布式链路跟踪系统中,可以使用UUID生成唯一标识,用于串联请求的日志。

2 数据库自增ID

在很多数据库中自增的主键ID,数据库本身是能够保证唯一的。

MySQL中的auto_increment。

Oracle中sequence。

我们在业务代码中,不需要做任何处理,这个ID的值,是由数据库自动生成的,并且它会保证数据的唯一性。

优点:非常简单,数据查询效率非常高。

缺点:只能保证单表的数据唯一性,如果跨表或者跨数据库,ID可能会重复。ID是自增的,生成规则很容易被猜透,有安全风险。ID是基于数据库生成的,在高并发下,可能会有性能问题。

在一些老系统或者公司的内部管理系统中,可能会用数据库递增ID作为分布式ID的方案,这些系统的用户并发量一般比较小,数据量也不多。

3 数据库号段模式

在高并发的系统中,频繁访问数据库,会影响系统的性能。

可以对数据库自增ID方案做一个优化。

一次生成一定步长的ID,比如:步长是1000,每次数据库自增1000,ID值从100001变成了101001。


将100002~101001这个号段的1000个ID,缓存到服务器的内存从。

当有获取分布式ID的请求过来时,先从服务器的内存中获取数据,如果能够获取到,则直接返回。

如果没有获取到,则说明缓存的号段的数据已经被获取完了。

这时需要重新从数据库中获取一次新号段的ID,缓存到服务器的内存中,这样下次又能直接从内存中获取ID了。

优点:实现简单,对数据库的依赖减弱了,可以提升系统的性能。

缺点:ID是自增的,生成规则很容易被猜透,有安全风险。如果数据库是单节点的,有岩机的风险。

4 数据库的多主模式

为了解决上面单节点岩机问题,我们可以使用数据库的多主模式。

即有多个master数据库实例。

在生成ID的时候,一个请求只能写入一个master实例。

为了保证在不同的master实例下ID的唯一性,我们需要事先规定好每个master下的大的区间,比如:master1的数据是10开头的,master2的数据是11开头的,master3的数据是12开头的。

然后每个master,还是按照数据库号段模式来处理。

优点:避免了数据库号段模式的单节点岩机风险,提升了系统的稳定性,由于结合使用了号段模式,系统性能也是OK的。

缺点:跨多个master实例下生成的ID,可能不是递增的。

5 Redis生成ID

除了使用数据库之外,Redis其实也能产生自增ID。

我们可以使用Redis中的incr命令:

redis> SET ID_VALUE 1000
OK

redis> INCR ID_VALUE
(integer) 1001

redis> GET ID_VALUE 
"1001"

给ID_VALUE设置了值是1000,然后使用INCR命令,可以每次都加1。

这个方案跟我们之前讨论过的方案1(数据库自增ID)的方案类似。

优点:方案简单,性能比方案1更好,避免了跨表或者跨数据库,ID重复的问题。

缺点:ID是自增的,生成规则很容易被猜透,有安全风险。并且Redis可能也存在单节点,岩机的风险。

6 Zookeeper生成ID

Zookeeper主要通过其znode数据版本来生成序列号,可以生成32位和64位的数据版本号,客户端可以使用这个版本号来作为唯一的序列号。

由于需要高度依赖Zookeeper,并且是同步调用API,如果在竞争较大的情况下,需要考虑使用分布式锁。

因此,性能在高并发的分布式环境下,也不太理想。

很少人会使用Zookeeper来生成唯一ID。

7 雪花算法

Snowflake(雪花算法)是Twitter开源的分布式ID算法。

核心思想:使用一个 64 bit 的 long 型的数字作为全局唯一 id。

最高位是符号位,始终为0,不可用。

41位的时间序列,精确到毫秒级,41位的长度可以使用69年。时间位还有一个很重要的作用是可以根据时间进行排序。

10位的机器标识,10位的长度最多支持部署1024个节点

12位的计数序列号,序列号即一系列的自增id,可以支持同一节点同一毫秒生成多个ID序号,12位的计数序列号支持每个节点每毫秒产生4096个ID序号。

优点:算法简单,在内存中进行,效率高。高并发分布式环境下生成不重复ID,每秒可生成百万个不重复ID。
基于时间戳,以及同一时间戳下序列号自增,基本保证ID有序递增。并且不依赖第三方库或者中间件,稳定性更好。

缺点:依赖服务器时间,服务器时钟回拨时可能会生成重复ID。

8 Leaf

Leaf是美团开源的分布式ID生成系统,它提供了两种生成ID的方式:

  • Leaf-segment号段模式
  • Leaf-snowflake雪花算法

Leaf-segment号段模式,需要创建一张表:

这个模式就是我们在第3节讲过的数据库号段模式。

biz_tag用来区分业务,max_id表示该biz_tag目前所被分配的ID号段的最大值,step表示每次分配的号段长度。

原来获取ID每次都需要写数据库,现在只需要把step设置得足够大,比如1000。那么只有当1000个号被消耗完了之后才会去重新读写一次数据库。

Leaf-snowflake雪花算法,是在传统雪花算法之上,加上Zookeeper,做了一点改造:

Leaf-snowflake服务需要从Zookeeper按顺序的获取workId,会缓存到本地。

如果Zookeeper出现异常,Leaf-snowflake服务会直接获取本地的workId,它相当于对Zookeeper是弱依赖的。

因为这种方案依赖时间,如果机器的时钟发生了回拨,那么就会有可能生成重复的ID号,它内部有一套机制解决机器时钟回拨的问题:

如果你想知道美团Leaf的更多细节,可以看看Github地址:
https://github.com/Meituan-Dianping/Leaf

最近整理了一份10万字的面试宝典,可以免费送给大家,获取方式加我微信:su_san_java,备注:面试。

9 Tinyid

Tinyid是滴滴用Java开发的一款分布式id生成系统,基于数据库号段算法实现。

Tinyid是在美团的ID生成算法Leaf的基础上扩展而来,支持数据库多主节点模式,它提供了REST API和JavaClient两种获取方式,相对来说使用更方便。

但跟美团Leaf不同的是,Tinyid只支持号段一种模式,并不支持Snowflake模式。

基于数据库号段模式的简单架构方案:

ID生成系统向外提供http服务,请求经过负载均衡router,能够路由到其中一台tinyid-server,这样就能从事先加载好的号段中获取一个ID了。

如果号段还没有加载,或者已经用完了,则需要向db再申请一个新的可用号段,多台server之间因为号段生成算法的原子性,而保证每台server上的可用号段不重,从而使id生成不重。

但也带来了这些问题:

  • 当id用完时需要访问db加载新的号段,db更新也可能存在version冲突,此时id生成耗时明显增加。
  • db是一个单点,虽然db可以建设主从等高可用架构,但始终是一个单点。
  • 使用http方式获取一个id,存在网络开销,性能和可用性都不太好。

为了解决这些这些问题:增加了tinyid-client本地生成ID、使用双号段缓存、增加多 db 支持提高服务的稳定性。

最终的架构方案如下:

Tinyid方案主要做了下面这些优化:

  • 增加tinyid-client:tinyid-client向tinyid-server发送请求来获取可用号段,之后在本地构建双号段、id生成,如此id生成则变成纯本地操作,性能大大提升。
  • 使用双号段缓存:为了避免在获取新号段的情况下,程序获取唯一ID的速度比较慢。Tinyid中的号段在用到一定程度的时候,就会去异步加载下一个号段,保证内存中始终有可用号段。
  • 增加多db支持:每个DB都能生成唯一ID,提高了可用性。

如果你想知道滴滴Tinyid的更多细节,可以看看Github地址:
https://github.com/didi/tinyid

10 UidGenerator

百度 UID-Generator 使用 Java 语言,基于雪花算法实现。

UidGenerator以组件形式工作在应用项目中, 支持自定义workerId位数和初始化策略, 从而适用于docker等虚拟化环境下实例自动重启、漂移等场景。

在实现上, UidGenerator通过借用未来时间来解决sequence天然存在的并发限制。

采用RingBuffer来缓存已生成的UID, 并行化UID的生产和消费, 同时对CacheLine补齐,避免了由RingBuffer带来的硬件级「伪共享」问题. 最终单机QPS可达600万。

Snowflake算法描述:指定机器 & 同一时刻 & 某一并发序列,是唯一的。据此可生成一个64 bits的唯一ID(long)。默认采用上图字节分配方式:

  • sign(1bit):固定1bit符号标识,即生成的UID为正数。
  • delta seconds (28 bits) :当前时间,相对于时间基点"2016-05-20"的增量值,单位:秒,最多可支持约8.7年
  • worker id (22 bits):机器id,最多可支持约420w次机器启动。内置实现为在启动时由数据库分配,默认分配策略为用后即弃,后续可提供复用策略。
  • sequence (13 bits):每秒下的并发序列,13 bits可支持每秒8192个并发。

sequence决定了UidGenerator的并发能力,13 bits的 sequence 可支持 8192/s 的并发,但现实中很有可能不够用,从而诞生了 CachedUidGenerator。

CachedUidGenerator 使用 RingBuffer 缓存生成的id。RingBuffer是个环形数组,默认大小为 8192 个(可以通过boostPower参数设置大小)。

RingBuffer环形数组,数组每个元素成为一个 slot。

Tail 指针、Cursor 指针用于环形数组上读写 slot:

  • Tail指针:表示 Producer 生产的最大序号(此序号从 0 开始,持续递增)。Tail 不能超过 Cursor,即生产者不能覆盖未消费的 slot。当 Tail 已赶上 curosr,此时可通过 rejectedPutBufferHandler 指定 PutRejectPolicy。
  • Cursor指针:表示 Consumer 消费到的最小序号(序号序列与 Producer 序列相同)。Cursor 不能超过 Tail,即不能消费未生产的 slot。当 Cursor 已赶上 tail,此时可通过 rejectedTakeBufferHandler 指定 TakeRejectPolicy。

RingBuffer填充触发机制:

  • 程序启动时,将RingBuffer填充满。
  • 在调用getUID()方法获取id时,如果检测到RingBuffer中的剩余id个数小于总个数的50%,将RingBuffer填充满。
  • 定时填充(可配置是否使用以及定时任务的周期)。

如果你想知道百度uid-generator的更多细节,可以看看Github地址:
https://github.com/baidu/uid-generator

最后说一句(求关注,别白嫖我)

如果这篇文章对您有所帮助,或者有所启发的话,帮忙扫描下发二维码关注一下,您的支持是我坚持写作最大的动力。

求一键三连:点赞、转发、在看。
关注公众号:【苏三说技术】,在公众号中回复:面试、代码神器、开发手册、时间管理有超赞的粉丝福利,另外回复:加群,可以跟很多BAT大厂的前辈交流和学习。

前言

在Vue3.5版本中
响应式 Props 解构
终于正式转正了,这个功能之前一直是
试验性
的。这篇文章来带你搞清楚,一个String类型的props经过解构后明明应该是一个常量了,为什么还没丢失响应式呢?本文中使用的Vue版本为欧阳写文章时的最新版
Vue3.5.5

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

看个demo

我们先来看个解构props的例子。

父组件代码如下:

<template>
  <ChildDemo name="ouyang" />
</template>

<script setup lang="ts">
import ChildDemo from "./child.vue";
</script>

父组件代码很简单,给子组件传了一个名为
name
的prop,
name
的值为字符串“ouyang”。

子组件的代码如下:

<template>
  {{ localName }}
</template>

<script setup lang="ts">
const { name: localName } = defineProps(["name"]);
console.log(localName);
</script>

在子组件中我们将
name
给解构出来了并且赋值给了
localName
,讲道理解构出来的
localName
应该是个常量会丢失响应式的,其实不会丢失。

我们在浏览器中来看一下编译后的子组件代码,很简单,直接在network中过滤子组件的名称即可,如下图:
network

从上面可以看到原本的
console.log(localName)
经过编译后就变成了
console.log(__props.name)
,这样当然就不会丢失响应式了。

我们再来看一个另外一种方式解构的例子,这种例子解构后就会丢失响应式,子组件代码如下:

<template>
  {{ localName }}
</template>

<script setup lang="ts">
const props = defineProps(["name"]);
const { name: localName } = props;
console.log(localName);
</script>

在上面的例子中我们不是直接解构
defineProps
的返回值,而是将返回值赋值给
props
对象,然后再去解构
props
对象拿到
localName

network2

从上图中可以看到这种写法使用解构的
localName
时,就不会在编译阶段将其替换为
__props.name
,这样的话
localName
就确实是一个普通的常量了,当然会丢失响应式。

这是为什么呢?为什么这种解构写法就会丢失响应式呢?别着急,我接下来的文章会讲。

从哪里开下手?

既然这个是在编译时将
localName
处理成
__props.name
,那我们当然是在编译时debug了。

还是一样的套路,我们在vscode中启动一个
debug
终端。
debug-terminal

在之前的
通过debug搞清楚.vue文件怎么变成.js文件
文章中我们已经知道了
vue
文件中的
<script>
模块实际是由
vue/compiler-sfc
包的
compileScript
函数处理的。

compileScript
函数位置在
/node_modules/@vue/compiler-sfc/dist/compiler-sfc.cjs.js

找到
compileScript
函数就可以给他打一个断点了。

compileScript函数


debug
终端上面执行
yarn dev
后在浏览器中打开对应的页面,比如:
http://localhost:5173/
。此时断点就会走到
compileScript
函数中。

在我们这个场景中简化后的
compileScript
函数代码如下:

function compileScript(sfc, options) {
  const ctx = new ScriptCompileContext(sfc, options);
  const scriptSetupAst = ctx.scriptSetupAst;

  // 2.2 process <script setup> body
  for (const node of scriptSetupAst.body) {
    if (node.type === "VariableDeclaration" && !node.declare) {
      const total = node.declarations.length;
      for (let i = 0; i < total; i++) {
        const decl = node.declarations[i];
        const init = decl.init;
        if (init) {
          // defineProps
          const isDefineProps = processDefineProps(ctx, init, decl.id);
        }
      }
    }
  }

  // 3 props destructure transform
  if (ctx.propsDestructureDecl) {
    transformDestructuredProps(ctx);
  }

  return {
    //....
    content: ctx.s.toString(),
  };
}

在之前的
为什么defineProps宏函数不需要从vue中import导入?
文章中我们已经详细讲解过了
compileScript
函数中的入参
sfc
、如何使用
ScriptCompileContext
类new一个
ctx
上下文对象。所以这篇文章我们就只简单说一下他们的作用即可。

  • 入参
    sfc
    对象:是一个
    descriptor
    对象,
    descriptor
    对象是由vue文件编译来的。
    descriptor
    对象拥有template属性、scriptSetup属性、style属性,分别对应vue文件的
    <template>
    模块、
    <script setup>
    模块、
    <style>
    模块。

  • ctx
    上下文对象:这个
    ctx
    对象贯穿了整个script模块的处理过程,他是根据vue文件的源代码初始化出来的。在
    compileScript
    函数中处理script模块中的内容,实际就是对
    ctx
    对象进行操作。最终
    ctx.s.toString()
    就是返回script模块经过编译后返回的js代码。

搞清楚了入参
sfc
对象和
ctx
上下文对象,我们接着来看
ctx.scriptSetupAst
。从名字我想你也能猜到,他就是script模块中的代码对应的AST抽象语法树。如下图:
scriptSetupAst

从上图中可以看到
body
属性是一个数组,分别对应的是源代码中的两行代码。

数组的第一项对应的Node节点类型是
VariableDeclaration
,他是一个变量声明类型的节点。对应的就是源代码中的第一行:
const { name: localName } = defineProps(["name"])

数组中的第二项对应的Node节点类型是
ExpressionStatement
,他是一个表达式类型的节点。对应的就是源代码中的第二行:
console.log(localName)

我们接着来看
compileScript
函数中的外层for循环,也就是遍历前面讲的body数组,代码如下:

function compileScript(sfc, options) {
  // ...省略
  // 2.2 process <script setup> body
  for (const node of scriptSetupAst.body) {
    if (node.type === "VariableDeclaration" && !node.declare) {
      const total = node.declarations.length;
      for (let i = 0; i < total; i++) {
        const decl = node.declarations[i];
        const init = decl.init;
        if (init) {
          // defineProps
          const isDefineProps = processDefineProps(ctx, init, decl.id);
        }
      }
    }
  }
  // ...省略
}

我们接着来看外层for循环里面的第一个if语句:

if (node.type === "VariableDeclaration" && !node.declare)

这个if语句的意思是判断当前的节点类型是不是变量声明并且确实有初始化的值。

我们这里的源代码第一行代码如下:

const { name: localName } = defineProps(["name"]);

很明显我们这里是满足这个if条件的。

接着在if里面还有一个内层for循环,这个for循环是在遍历node节点的
declarations
属性,这个属性是一个数组。

declarations
数组属性表示当前变量声明语句中定义的所有变量,可能会定义多个变量,所以他才是一个数组。在我们这里只定义了一个变量
localName
,所以
declarations
数组中只有一项。

在内层for循环,会去遍历声明的变量,然后从变量的节点中取出
init
属性。我想聪明的你从名字应该就可以看出来
init
属性的作用是什么。

没错,
init
属性就是对应的变量的初始化值。在我们这里声明的
localName
变量的初始化值就是
defineProps(["name"])
函数的返回值。

接着就是判断
init
是否存在,也就是判断变量是否是有初始化值。如果为真,那么就执行
processDefineProps(ctx, init, decl.id)
判断初始化值是否是在调用
defineProps
。换句话说就是判断当前的变量声明是否是在调用
defineProps
宏函数。

processDefineProps函数

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

function processDefineProps(ctx, node, declId) {
  if (!isCallOf(node, DEFINE_PROPS)) {
    return processWithDefaults(ctx, node, declId);
  }
  // handle props destructure
  if (declId && declId.type === "ObjectPattern") {
    processPropsDestructure(ctx, declId);
  }
  return true;
}

processDefineProps
函数接收3个参数。

  • 第一个参数
    ctx
    ,表示当前上下文对象。

  • 第二个参数
    node
    ,这个节点对应的是变量声明语句中的初始化值的部分。也就是源代码中的
    defineProps(["name"])

  • 第三个参数
    declId
    ,这个对应的是变量声明语句中的变量名称。也就是源代码中的
    { name: localName }


为什么defineProps宏函数不需要从vue中import导入?
文章中我们已经讲过了这里的第一个if语句就是用于判断当前是否在执行
defineProps
函数,如果不是那么就直接
return false

我们接着来看第二个if语句,这个if语句就是判断当前变量声明是不是“对象解构赋值”。很明显我们这里就是解构出的
localName
变量,所以代码将会走到
processPropsDestructure
函数中。

processPropsDestructure
函数

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

function processPropsDestructure(ctx, declId) {
  const registerBinding = (
    key: string,
    local: string,
    defaultValue?: Expression
  ) => {
    ctx.propsDestructuredBindings[key] = { local, default: defaultValue };
  };

  for (const prop of declId.properties) {
    const propKey = resolveObjectKey(prop.key);
    registerBinding(propKey, prop.value.name);
  }
}

前面讲过了这里的两个入参,
ctx
表示当前上下文对象。
declId
表示变量声明语句中的变量名称。

首先定义了一个名为
registerBinding
的箭头函数。

接着就是使用for循环遍历
declId.properties
变量名称,为什么会有多个变量名称呢?

答案是解构的时候我们可以解构一个对象的多个属性,用于定义多个变量。

prop
属性如下图:
prop

从上图可以看到
prop
中有两个属性很显眼,分别是
key

value

其中
key
属性对应的是解构对象时从对象中要提取出的属性名,因为我们这里是解构的
name
属性,所以上面的值是
name

其中
value
属性对应的是解构对象时要赋给的目标变量名称。我们这里是赋值给变量
localName
,所以上面他的值是
localName

接着来看for循环中的代码。

执行
const propKey = resolveObjectKey(prop.key)
拿到要从
props
对象中解构出的属性名称。

将断点走进
resolveObjectKey
函数,代码如下:

function resolveObjectKey(node: Node) {
  switch (node.type) {
    case "Identifier":
      return node.name;
  }
  return undefined;
}

如果当前是标识符节点,也就是有name属性。那么就返回name属性。

最后就是执行
registerBinding
函数。

registerBinding(propKey, prop.value.name)

第一个参数为传入解构对象时要提取出的属性名称,也就是
name
。第二个参数为解构对象时要赋给的目标变量名称,也就是
localName

接着将断点走进
registerBinding
函数,他就在
processPropsDestructure
函数里面。

function processPropsDestructure(ctx, declId) {
  const registerBinding = (
    key: string,
    local: string,
    defaultValue?: Expression
  ) => {
    ctx.propsDestructuredBindings[key] = { local, default: defaultValue };
  };
  // ...省略
}

ctx.propsDestructuredBindings
是存在ctx上下文中的一个属性对象,这个对象里面存的是需要解构的多个props。

对象的key就是需要解构的props。

key对应的value也是一个对象,这个对象中有两个字段。其中的
local
属性是解构props后要赋给的变量名称。
default
属性是props的默认值。

在debug终端来看看此时的
ctx.propsDestructuredBindings
对象是什么样的,如下图:
propsDestructuredBindings

从上图中就有看到此时里面已经存了一个
name
属性,表示
props
中的
name
需要解构,解构出来的变量名为
localName
,并且默认值为
undefined

经过这里的处理后在ctx上下文对象中的
ctx.propsDestructuredBindings
中就已经存了有哪些props需要解构,以及解构后要赋值给哪个变量。

有了这个后,后续只需要将script模块中的所有代码遍历一次,然后找出哪些在使用的变量是props解构的变量,比如这里的
localName
变量将其替换成
__props.name
即可。

transformDestructuredProps函数

接着将断点层层返回,走到最外面的
compileScript
函数中。再来回忆一下
compileScript
函数的代码,如下:

function compileScript(sfc, options) {
  const ctx = new ScriptCompileContext(sfc, options);
  const scriptSetupAst = ctx.scriptSetupAst;

  // 2.2 process <script setup> body
  for (const node of scriptSetupAst.body) {
    if (node.type === "VariableDeclaration" && !node.declare) {
      const total = node.declarations.length;
      for (let i = 0; i < total; i++) {
        const decl = node.declarations[i];
        const init = decl.init;
        if (init) {
          // defineProps
          const isDefineProps = processDefineProps(ctx, init, decl.id);
        }
      }
    }
  }

  // 3 props destructure transform
  if (ctx.propsDestructureDecl) {
    transformDestructuredProps(ctx);
  }

  return {
    //....
    content: ctx.s.toString(),
  };
}

经过
processDefineProps
函数的处理后,
ctx.propsDestructureDecl
对象中已经存了有哪些变量是由props解构出来的。

这里的
if (ctx.propsDestructureDecl)
条件当然满足,所以代码会走到
transformDestructuredProps
函数中。

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

import { walk } from 'estree-walker'

function transformDestructuredProps(ctx) {
  const rootScope = {};
  let currentScope = rootScope;
  const propsLocalToPublicMap: Record<string, string> = Object.create(null);

  const ast = ctx.scriptSetupAst;

  for (const key in ctx.propsDestructuredBindings) {
    const { local } = ctx.propsDestructuredBindings[key];
    rootScope[local] = true;
    propsLocalToPublicMap[local] = key;
  }

  walk(ast, {
    enter(node: Node) {
      if (node.type === "Identifier") {
        if (currentScope[node.name]) {
          rewriteId(node);
        }
      }
    },
  });

  function rewriteId(id: Identifier) {
    // x --> __props.x
    ctx.s.overwrite(
      id.start! + ctx.startOffset!,
      id.end! + ctx.startOffset!,
      genPropsAccessExp(propsLocalToPublicMap[id.name])
    );
  }
}


transformDestructuredProps
函数中主要分为三块代码,分别是for循环、执行
walk
函数、定义
rewriteId
函数。

我们先来看第一个for循环,他是遍历
ctx.propsDestructuredBindings
对象。前面我们讲过了这个对象中存的属性key是解构了哪些props,比如这里就是解构了
name
这个props。

接着就是使用
const { local } = ctx.propsDestructuredBindings[key]
拿到解构的props在子组件中赋值给了哪个变量,我们这里是解构出来后赋给了
localName
变量,所以这里的
local
的值为字符串"localName"。

由于在我们这个demo中只有两行代码,分别是解构props和
console.log
。没有其他的函数,所以这里的作用域只有一个。也就是说
rootScope
始终等于
currentScope

所以这里执行
rootScope[local] = true
后,
currentScope
对象中的
localName
属性也会被赋值true。如下图:
currentScope

接着就是执行
propsLocalToPublicMap[local] = key
,这里的
local
存的是解构props后赋值给子组件中的变量名称,
key
为解构了哪个props。经过这行代码的处理后我们就形成了一个映射,后续根据这个映射就能轻松的将script模块中使用解构后的
localName
的地方替换为
__props.name

propsLocalToPublicMap
对象如下图:
propsLocalToPublicMap

经过这个for循环的处理后,我们已经知道了有哪些变量其实是经过props解构来的,以及这些解构得到的变量和props的映射关系。

接下来就是使用
walk
函数去递归遍历script模块中的所有代码,这个递归遍历就是遍历script模块对应的AST抽象语法树。

在这里是使用的
walk
函数来自于第三方库
estree-walker

在遍历语法树中的某个节点时,进入的时候会触发一次
enter
回调,出去的时候会触发一次
leave
回调。

walk
函数的执行代码如下:

walk(ast, {
  enter(node: Node) {
    if (node.type === "Identifier") {
      if (currentScope[node.name]) {
        rewriteId(node);
      }
    }
  },
});

我们这个场景中只需要
enter
进入的回调就行了。


enter
回调中使用外层if判断当前节点的类型是不是
Identifier

Identifier
类型可能是变量名、函数名等。

我们源代码中的
console.log(localName)
中的
localName
就是一个变量名,当递归遍历AST抽象语法树遍历到这里的
localName
对应的节点时就会满足外层的if条件。

在debug终端来看看此时满足外层if条件的node节点,如下图:
node

从上面的代码可以看到此时的node节点中对应的变量名为
localName
。其中
start

end
分别表示
localName
变量的开始位置和结束位置。

我们回忆一下前面讲过了
currentScope
对象中就是存的是有哪些本地的变量是通过props解构得到的,这里的
localName
变量当然是通过props解构得到的,满足里层的if条件判断。

最后代码会走进
rewriteId
函数中,将断点走进
rewriteId
函数中,简化后的代码如下:

function rewriteId(id: Identifier) {
  // x --> __props.x
  ctx.s.overwrite(
    id.start + ctx.startOffset,
    id.end + ctx.startOffset,
    genPropsAccessExp(propsLocalToPublicMap[id.name])
  );
}

这里使用了
ctx.s.overwrite
方法,这个方法接收三个参数。

第一个参数是:开始位置,对应的是变量
localName
在源码中的开始位置。

第二个参数是:结束位置,对应的是变量
localName
在源码中的结束位置。

第三个参数是想要替换成的新内容。

第三个参数是由
genPropsAccessExp
函数返回的,执行这个函数时传入的是
propsLocalToPublicMap[id.name]

前面讲过了
propsLocalToPublicMap
存的是props名称和解构到本地的变量名称的映射关系,
id.name
是解构到本地的变量名称。如下图:
overwrite

所以
propsLocalToPublicMap[id.name]
的执行结果就是
name
,也就是名为
name
的props。

接着将断点走进
genPropsAccessExp
函数,简化后的代码如下:

const identRE = /^[_$a-zA-Z\xA0-\uFFFF][_$a-zA-Z0-9\xA0-\uFFFF]*$/;
function genPropsAccessExp(name: string): string {
  return identRE.test(name)
    ? `__props.${name}`
    : `__props[${JSON.stringify(name)}]`;
}

使用正则表达式去判断如果满足条件就会返回
__props.${name}
,否则就是返回
__props[${JSON.stringify(name)}]

很明显我们这里的
name
当然满足条件,所以
genPropsAccessExp
函数会返回
__props.name

那么什么情况下不会满足条件呢?

比如这样的props:

const { "first-name": firstName } = defineProps(["first-name"]);
console.log(firstName);

这种props在这种情况下就会返回
__props["first-name"]

执行完
genPropsAccessExp
函数后回到
ctx.s.overwrite
方法的地方,此时我们已经知道了第三个参数的值为
__props.name
。这个方法的执行会将
localName
重写为
__props.name


ctx.s.overwrite
方法执行之前我们来看看此时的script模块中的js代码是什么样的,如下图:
before

从上图中可以看到此时的代码中
console.log
里面还是
localName

执行完
ctx.s.overwrite
方法后,我们来看看此时是什么样的,如下图:
after

从上图中可以看到此时的代码中
console.log
里面已经变成了
__props.name

这就是在编译阶段将使用到的解构
localName
变量变成
__props.name
的完整过程。

这会儿我们来看前面那个例子解构后丢失响应式的例子,我想你就很容易想通了。

<script setup lang="ts">
const props = defineProps(["name"]);
const { name: localName } = props;
console.log(localName);
</script>

在处理
defineProps
宏函数时,发现是直接解构了返回值才会进行处理。上面这个例子中没有直接进行解构,而是将其赋值给
props
,然后再去解构
props
。这种情况下
ctx.propsDestructuredBindings
对象中什么都没有。

后续在递归遍历script模块中的所有代码,发现
ctx.propsDestructuredBindings
对象中什么都没有。自然也不会将
localName
替换为
__props.name
,这样他当然就会丢失响应式了。

总结

在编译阶段首先会处理宏函数
defineProps
,在处理的过程中如果发现解构了
defineProps
的返回值,那么就会将解构的
name
属性,以及
name
解构到本地的
localName
变量,都全部一起存到
ctx.propsDestructuredBindings
对象中。

接下来就会去递归遍历script模块中的所有代码,如果发现使用的
localName
变量能够在
ctx.propsDestructuredBindings
对象中找的到。那么就说明这个
localName
变量是由props解构得到的,就会将其替换为
__props.name
,所以使用解构后的props依然不会丢失响应式。

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

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