支付宝 KJS Compose 动态化方案与架构设计

编者按:在 KMP + Compose 成为主流原生 UI 技术栈的背景下,业务对"动态化"的诉求正从依赖 WebView 或独立渲染体系,转向在不破坏现有渲染链路、不新增 DSL、且不影响核心页面性能的前提下,实现更细粒度、可控的动态交付能力。

本文由支付宝终端技术团队潘云逸(法慧)编写,结合工程实践,提出了一种基于 Kotlin/JS + Compose Runtime + Native Skia 的局部动态化方案:由 JS 侧负责 UI 计算,Native 侧复用既有 Skia 渲染栈完成最终上屏,在原生 Compose 页面中实现区块级、脚本驱动的动态 UI 嵌入。

背景与目标

1.1 业务背景:为什么又要谈"动态化"

大家对"动态化"的直觉基本等同于:"JS + WebView/Native 渲染引擎 + 一套新 DSL/框架"。

端内也有 Cube、H5/小程序、Bundle2.5 等等,但在 MYKMP 的技术体系下,有一些可见的痛点:

  • 我们已经有一套成熟、统一的 Compose 布局体系,业务习惯用 Compose DSL 写 UI,对于非复用资产,写一套卡片成本较高,同层嵌入下会有很多兼容性问题需要处理。

  • 首页、Tab 等核心场景对性能非常敏感,不希望在这些关键页面内耦合 webview;

同时,业务侧的诉求又很明确:希望在已有 KMP 原生页面中,局部"插一块"动态区域,这块区域可以通过下发脚本快速更新,而不需要上架发版。

所以,我们需要的是这样一种能力:

  • UI 代码依然是 Compose DSL,甚至可以通过注解决定当前组件是否为 Remote 组件

  • 动态化的载体是 JS 脚本(易分发、易管理);

  • 可以在原生 Compose 页面里"局部嵌入"动态 UI;

  • 不依赖 WebView,也不破坏现有 compose Native 侧渲染栈。

1.2 我们的目标:Compose 逻辑动线 JS 闭环,Native 只负责上屏渲染

在不破坏现有 Native 渲染链路的前提下,我们给自己设定了三层目标:

  1. 体验目标:业务侧"像写普通 Compose 一样写动态 UI"

    • 业务同学继续用 Compose DSL 写 UI,不需要学习新的 DSL 或框架;

    • 动态与静态页面在代码结构上尽量一致,只是"跑的地方"不同:

      • 静态 UI:即原生 KMP Compose 代码,直接在 Native Compose Runtime 中执行;

      • 动态 UI:即编译生成的 JS 代码,在 JS Runtime 里执行 Compose 逻辑。

  2. 架构目标:拆分"计算 UI"与"渲染 UI"

我们把整个 UI 渲染链路拆成两段:

换句话说:

JS 世界只负责"算 UI、出图纸",Native 世界负责"照图纸画"。

  • JS 侧:

    • 搭起一套 JS 版本的 Kotlin Runtime + Compose Runtime;

    • 负责状态管理、重组、布局、测量;

    • 把最终需要绘制的内容,归约为一组"Canvas 绘制指令"(落在 CanvasDrawScope 上);

    • 不做真正的像素绘制,也不依赖 Web Canvas 或 Skiko Wasm。

  • Native 侧:

    • 继续使用现有的 Kotlin/Native + Skia 渲染栈;

    • 负责接收 JS 侧通过 JS Binding 传来的绘制指令;

    • Skia PictureRecorder 做"录制",形成一份可重放的 Picture

    • 在需要上屏时,把这份 Picture 绘制到对应的 Compose 容器区域。

  1. 能力目标:先支持"局部动态化嵌入",后续再拓展到整个页面。 我们并不追求"一上来就把整页交给脚本",而是聚焦在更具体、可控的能力:

这样做的好处是:

  • 在一个原生 Compose 页面中,某个区域由动态脚本驱动;

  • 这个区域可以独立更新、独立灰度;

  • 其余部分仍然是正常的原生 Compose 代码;

  • 支持后续扩展为多块动态区域、甚至组合嵌入。

  • 对现有业务页面改造成本小(添加一个动态容器 Composable 即可);

  • 动态能力可以从简单场景逐步扩展到复杂场景;

  • 一旦出现问题,可以快速降级为静态实现或关闭某个动态区域。

1.3 最终希望达到的状态

从业务视角来看,我们希望最后呈现出来的是这样一种体验:

  • 写 UI 的方式不变:依旧是熟悉的 @ComposableColumnRowText 等;

  • 多了一种"部署方式":

    • 可以把这段 Compose 编译到 Native,随 App 发版;

    • 也可以把这段 Compose 编译到 JS,通过脚本下发,在 JS Runtime 中运行;

  • 在页面里用起来,大致是这样的:

    @Composable fun HomePage() { Column { Header() // 原生静态区域

    less 复制代码
        RemoteView(
            // scriptId 只是个唯一标识符,可以是业务自定义的 bizId,也可以是模块 artifactId
            scriptId = "home_feed_dynamic_v1", // 对应某个下发脚本
            modifier = Modifier.fillMaxWidth().height(200.dp)
        )
    
        Footer() // 原生静态区域
    }

    }

  • 而对于这块 RemoteView

    • 首次进入时:在后台创建一个 JSContext,加载业务下发的 JS(里面是 Compose + Kotlin Runtime),执行初始绘制,生成一份 Skia Picture

    • 业务状态变化时:JS 侧触发重组,重新生成绘制指令,Native 录制新的 Picture,局部刷新该区域;

    • 业务无感知整个渲染细节,只关注"这块区域的 UI 和状态"。

从架构视角来看,这套方案在动态化能力与现有原生栈之间搭了一座桥:

  • 上层统一使用 Compose DSL;

  • 中间用 Kotlin/JS Runtime + Compose Runtime 实现"跨语言的 UI 计算";

  • 底层统一落在 Skia 渲染,沿用现有性能优化和渲染流水线。

这就是我们这次方案设计背后的背景与目标:
在不引入 WebView、不重复引入一整套 Wasm Skia 渲染栈的前提下,将 Compose 的动态化能力"搬到 JS + Native Binding"之上,让业务在最小心智负担下获得扎实可控的局部动态化能力。

现有方案分析

2.1 Kuikly 动态化

Kuikly 是腾讯开源的一套跨平台 UI 渲染解决方案,它的技术路线是保留 Kotlin 侧的 UI 树/组件体系,复用 ComposeDSL,在 Kotlin 内部内化 Build -> Measure -> Layout,Native 侧仅保留了原子化的 View 能力和通用的视图树增删改接口的映射实现(创建控件、挂载子控件、设置属性、设置布局结果等),Kotlin 侧最终通过渲染接口,直接把布局和属性同步变更到 Native View 上,再由 Native View 上屏渲染。

在这个技术架构体系里,提供动态化能力就相对比较简单了,只需要适配 ComposeDSL,完成以下路径即可:

  1. 下发的 Compose DSL 解析-> Kuikly 组件树🌲

  2. Native 侧 Kotlin 完成测量布局

  3. 通过渲染接口更新原生控件

2.2 KJS For Web

Kotlin/JS(简称 KJS)其实已经提供了一套比较完整的动态化能力:

把 Kotlin 代码编译成 JS,并带上一整套 Kotlin Runtime,在浏览器环境里跑。

它典型的产物结构是三件套:

  • composeApp.js:业务代码 + Kotlin/JS Runtime + Compose Runtime;

  • skiko.js:JS 与 Skia(Wasm)的胶水层;

  • skiko.wasm:真正的 Skia 图形引擎(Wasm 版)。

在 Web 场景下,这套方案是合理的:浏览器提供了 JS 引擎和 Canvas,KJS 提供了 Kotlin/JS Runtime 和 Compose 逻辑,Skiko/Skia 则负责最终的像素级绘制。

但如果我们把这套设计直接搬到客户端原生环境,会遇到几个核心问题:

  1. 强依赖 Web 环境
  • KJS for Web 默认存在 document、Canvas、事件模型等浏览器能力;

  • 这意味着要在我们的场景中落地,通常需要引入 WebView,这是首页 / Tab 等关键场景无法接受的。

  1. 重复图形栈:多一套 Wasm Skia 没意义
  • 客户端 Native 侧已经有一整套围绕 Skia 的渲染 pipeline;

  • 再加载一个 skiko.wasm,等于在 App 里用 Wasm 再跑一份 Skia ------ 包体、内存、初始化成本都非常高,而且和现有渲染栈重复。

  1. iOS 等平台的环境限制
  • iOS 不允许应用自行 JIT,WebAssembly 在非 Web 环境要靠解释或 AOT 运行;

  • 在这种约束下,再塞进一套重型 Wasm 渲染引擎,性价比很低。

总体来看,KJS for Web 是为"在浏览器里跑 Compose"设计的,而我们现在要解决的是"在原生环境里跑动态 Compose",两者的前提条件完全不同。

2.3 RemoteCompose

其本质是用"可序列化的绘制文档"+"通用 Player",把 Compose UI 从 App 本地编译时,解耦成运行时可远程下发和播放的形态。

1.声明式文档序列化

  • 把 Compose UI 的绘制过程"捕获"为一份二进制文档(类似"绘制指令的录像带");

  • 文档里不是 JSON 组件树,而是很底层的绘图操作、布局信息、状态、交互定义。

2.平台无关的文档回放(渲染)

  • 客户端拿到这份文档,不需要原来的 @Composable 代码,也不需要业务 ViewModel;

  • 只用一个 RemoteDocumentPlayer 组件按指令在 Canvas 上画出来;

  • 同一文档可以在不同 Android 设备(手机 / 平板 / 折叠屏 / Wear)以原生方式渲染。

RemoteCompose的潜在问题是

  1. 官方能力仅支持 Android,目前为 Snapshot 版本

  2. 需要维护一个服务端应用,用来做状态同步,驱动服务端 compose 重组和渲染指令重录

  3. 交互场景可能会因为网络抖动发生延迟

KJS 动态化方案和架构设计

为了满足业务对于 Compose 动态化的诉求,我们也评估了一些目前现有的技术路线,例如 KJS for Web、RemoteCompose 等方案:

  • KJS for Web 强依赖 Web 环境 + Wasm Skia,不适用于纯 Native 页面;

  • RemoteCompose 更偏"后端生成文档 + Android 客户端播放",且目前主要定位在 Android 生态。

最终,我们基于 Kotlin/JS(KJS)和现有 Native Skia 渲染栈,设计了一套 "KJS Native"的局部动态化方案。

方案核心思路:JS 做重组布局,Native 做渲染重放。

用 KJS 在 JS Runtime 中跑一套 Compose Runtime,只负责重组 / 布局 / 测量,并生成"绘制指令";

再通过 JS Binding 把这些指令下发到 Native,用 Skia 的 Picture 录制与重放完成真实渲染。

这其中有三个关键点:

  1. KJS 只承载逻辑侧的 Compose Runtime

    • JS 侧不引入 skiko.wasm,也不跑 Wasm 版 Skia;

    • 只保留 Kotlin/JS Runtime + Compose Runtime(重组、布局、测量);

    • CanvasDrawScope 这一层统一收口所有绘制动作。

  2. 通过 JS Binding 把 Canvas 绘制指令映射到 Native

    • 在 JS 侧改造 compose-ui / ui-graphics / ui-text 的 JS target;

    • 剥离对 Skiko 的依赖,把最终的 drawRect/drawPath/drawText/... 变成一组"跨语言绘制指令";

    • 通过 JSI/NativeBinding 将这些指令传递到 Native。

  3. Native 使用 Skia PictureRecorder 做录制与重放

    • Native 收到绘制指令后,用 Skia PictureRecorder 开始一次录制:

      • beginRecording():接收并执行指令,真实调用 Skia API;

      • endRecording():得到一份 Picture

    • 页面实际渲染时,只需要在对应区域 Canvas.drawPicture(picture) 即可;

    • 动态化组件设置成 graphicLayer 做层级提升,降低重绘的开销

下面以一个动态容器 RemoteView 为例,串一下实际执行链路。

3.1 整体流程介绍

Step 1:业务声明动态容器

在原生 Compose 页面中,业务只需要用一个容器承接动态区域:

ini 复制代码
@Composable
fun HomePage() {
    Column {
        Header() // 原生静态区域
        // scriptId 只是个唯一标识符,可以是业务自定义的 bizId,也可以是模块 artifactId
        RemoteView(pageId =pageId, 
                   viewId = viewId, 
                   scriptUrl = "https://kmp_demo/composeApp.js", 
                   modifier =  Modifier.height(
                   height = 150.dp).fillMaxWidth())

        Footer() // 原生静态区域
    }
}

RemoteView 内部做的事情:

  • 根据 scriptUrl,异步从本地 / 网络获取对应的 JS 产物;

  • 通过 KJSEngine 创建一个 JSContext

  • 在这个 JSContext 里加载并执行业务的 Compose JS 代码(KJS 编译产物)

Step 2:JS Runtime 中首次 Composition 与绘制指令生成

在 JSContext 中:

  1. 启动 Kotlin/JS Runtime + Compose Runtime;

  2. 运行动态区域对应的 @Composable 函数;

  3. 完成第一次重组 / 布局 / 测量;

  4. 进入改造后的 CanvasDrawScope,生成一组绘制指令(逻辑上类似:drawRectdrawTextdrawImage 等)。

此时,所有绘制动作都不直接画,而是以指令的形式,通过 JS Binding 送到 Native。

Step 3:Native 录制 Skia Picture

Native 侧:

  1. 收到"开始录制"的信号,创建一个 Skia PictureRecorder

  2. 遍历 JS 传来的绘制指令,逐条调用 Skia 对应 API,比如:

    • canvas.drawRect(...)

    • canvas.drawPath(...)

    • paragraph.paint(canvas)(文字链路我们通过封装进行适配,最终产出的也是);

  3. 结束录制,得到一份 Picture 对象;

  4. 通过 callback 或状态更新,将这份 Picture 提交给业务层。

Step 4:业务 Compose 把 Picture 上屏

RemoteView 内部维护一个 State<Picture?>

  • 当 Native 录制完成,更新 Picture;

  • Compose 检测到状态变化,重新执行 RemoteViewdraw 逻辑;

  • 在对应的 DrawScope 中调用 drawPicture(picture),将 Picture 绘制到该容器区域。

到此为止,动态区域的首次展示完成。

Step 5:后续状态变化与局部重绘

当 JS 侧状态发生变化(例如:数据返回、用户点击、内部动画触发):

  1. JS 中的 Compose Runtime 触发重组 → 布局 → 再次进入绘制阶段;

  2. 新一轮绘制指令通过 Binding 下发到 Native;

  3. Native 重新录制一份新的 Picture

  4. 业务层再次更新 Picture 状态,触发该区域的局部重绘。

整个过程对业务来说是透明的,状态在 KJS 内部流转,最终呈现的是像素变化。这种解决方案的一个可见的好处是:动态化区块的渲染结果是通过原生画布承接的,天然的不存在任何同层问题,在 KJS 实现中,通过剥离实体 Surface,使得驱动页面渲染绘制成为可能。同样的,在适配多平台时,因为最终的 js 产物多端一致,只需要实现一些原子化的 JS Binding 指令即可,迁移到其他平台的成本相对较低。

3.2 状态与交互

动态 UI 不可能只有"画一张静态图",交互是必须的。我们在两端都做了设计:

JS 侧:保留 Compose 的状态模型

在 KJS 侧,我们依然使用 Compose 的状态模型,例如 remembermutableStateOf 等,用来管理组件内部状态:

kotlin 复制代码
@Composable
fun DynamicButton() {
    var clicked by remember { mutableStateOf(false) }
    Button(onClick = { clicked = !clicked }) {
        Text(if (clicked) "Clicked" else "Click Me")
    }
}

这些状态变化会自然驱动 KJS 侧重组和重新绘制,从而生成新的绘制指令。

Native 侧:事件分发与同步

为了让用户的点击 / 滑动事件能反馈到 JS 侧,我们提供了一条事件通路:

  1. Native Compose 页面捕获点击 / 滑动等原生事件;

  2. 将相关信息(坐标、类型等)通过 Native Binding 传给 JS 侧的 composeScene;

  3. 在 JS 侧,映射到 Compose 的输入事件系统(如点击、滚动事件),触发对应的处理逻辑;

  4. 状态变化后,重新生成绘制指令 → 新 Picture → 局部重绘。

大部分图形绘制指令可以比较直接映射为 Skia 的 API(直线、矩形、圆形、路径、图片等)。

文字渲染则相对复杂,需要一些额外的工作:

3.3 文字渲染链路

在 Kotlin Native 侧,Compose 的主要工作是:

  1. 把声明式样式翻译成 Skia 能识别的字体资源和段落样式(读取、注册、解析、fallback);

  2. 拿到 Skia 算好的尺寸并把它画到 Skia Canvas 上

    真正的「断行、字形选择、光栅化」全部由 Skia 完成。

我们先来看看一次正常的文字渲染流程是啥样的:

阶段

输入

动作(关键源码)

输出

1 入口

TextLayoutInput(text, style, constraints, maxLines, overflow...)

layout(textLayoutInput)被调用

开始布局流程

2 预排版

同上

MultiParagraphIntrinsics(...) 构造

得到段落骨架 nonNullIntrinsics,含每段 intrinsics、字体、占位符等,但还没真布局

3 决定排版宽度

constraints + 骨架.maxIntrinsicWidth

val width = if (min==max) max else maxIntrinsicWidth.coerceIn(min, max)

最终用于布局的 width(像素值)

4 修正 maxLines

softWrap、overflow

val overwrite = !softWrap && overflow.isEllipsis val finalMaxLines = if(overwrite) 1 else maxLines

修正后的行数限制

5 创建 MultiParagraph

骨架、修正后的 width、maxLines、overflow

MultiParagraph(...)``init{} 内: ① 逐段创建 Paragraph ② 累加 height/lineCount ③ 提前 break 若已超限

得到真正的排版实体: - 总宽 = constraints.maxWidth.toFloat() - 总高 = currentHeight - lineCount - didExceedMaxLines - paragraphInfoList(含每段 y/行号/字符区间) - placeholderRects

6 包装成结果

原始 TextLayoutInput + MultiParagraph

TextLayoutResult(layoutInput = ..., multiParagraph = ..., size = ...)

返回的 TextLayoutResult 实例,所有查询/绘制都依赖它

总结下来其实是:

  • Kotlin/Native 侧的文字渲染依赖 Skia 的排版能力,向上层 kotlin 侧透出 SKParagraph,通过拿到 TextLayoutResult 来融合进 Compose 整体的布局测量流程,真正绘制时会调用 paragraph.paint(canvas)

  • 原生 KMP Compose 中,Skiko 把这部分封装好直接用,现在我们只能在 KJS 里封装了一套完整对应能力:

    • JS 侧描述文本内容、样式、约束(宽度、高度等);

    • Native 根据描述构建 Skia Paragraph,对文本布局与绘制进行处理,返回 TextLayoutResult,融合进已有的 Compose 布局体系。

    • 在录制阶段,把文字绘制透过 RecordingCanvas 纳入 Picture。

方案对比

4.1 与 KJS For Web 的对比

  • KJS For Web:

    • 强依赖 Web 环境(DOM、Canvas、WebAssembly);

    • 引入 skiko.wasm,在 JS + Wasm 中跑 Skia;

    • 通常需要 WebView,在首页/Tab 等原生核心场景难以接受。

  • 我们:

    • 不依赖 WebView,不使用 Wasm;

    • 完整渲染权留在 Native Skia;

    • KJS 只做逻辑层的 Compose Runtime,不启动第二套图形栈。

4.2 与 RemoteCompose 的对比

  • RemoteCompose:

    • 服务端生成"绘制文档";客户端通过 Remote Player 解释执行;

    • 更偏"服务端驱动 UI",客户端仅负责播放和行为回调;

    • 文档协议、操作模型、状态系统都是单独的一套。

  • 我们:

    • 动态产物是 KJS 编译后的 JS + Kotlin/JS Runtime;

    • 没有引入额外通用文档协议,也没有 Remote Player 黑盒;

    • 直接对接现有 Compose 框架与 Skia 渲染栈,掌控力更强,贴合 KMP 架构。

从业务视角看,我们的方案更接近于:

在原生 Compose 页面中,插入一块由 JS 驱动的 Compose 子树,这棵子树负责自己重组和"画什么",

而"怎么画"仍然交给原生 Skia。

性能基线

5.1 产物包大小

目前线下产物采用单产物方便快速验证,单产物 DCE + 混淆后,大小为 1.52 MiB(包含 kotlin runtime + compose foundation/ui/ui-graphics/ui-text 等必要的基础库)。

5.2 线下效果对比

此处为对比视频,见公众号文章

左:KJS Native

右:原生 KMP

5.3 线下数据对比

以上述简单的图文视图为例:

  1. KJS Native 最终的渲染指令为 113 条,从端侧创建 composeWindow 为起点,首帧耗时为 256 ms。

  2. 在 KMP 原生中,同样的视图首帧耗时为 180 ms。

总结与展望

从当前在鸿蒙端完成的端到端 POC 结果来看,KJS x Native 方案能够以相对低的改造成本,满足 KMP 业务对"动态化交付与快速迭代"的核心诉求:在不切换现有技术栈、不引入新的渲染体系、且不显著增加工程复杂度的前提下,把动态能力收敛在 KMP 技术栈内部,形成"产物生成---下发---加载---执行/渲染---监控回收"的闭环。这意味着业务仍可以沿用既有的 KMP 工程组织方式、研发协作方式与质量保障体系。

当然,要将 POC 推进到可规模化落地的工程能力,后续仍有不少工作亟需完善与产品化沉淀,包括但不限于:

  • 组件体系适配与体验对齐:推动 AntUI/Tecla 等组件库的接入与一致性适配,确保动态化场景下的样式、交互、无障碍、主题(深色/浅色)与多端一致性体验,尽可能做到业务侧"用起来像原生 KMP 开发"。

  • 工程化与链路完善:沉淀标准化的产物构建、版本管理、依赖治理与灰度发布策略,降低发布与运维成本。

  • 能力边界拓展与约束:在安全与性能可控的前提下,持续扩大可动态化的 UI/交互/数据能力,同时明确"哪些可以动态、哪些必须静态"的红线与最佳实践,形成可复用的业务接入规范。

业务侧引入该方案后,能直接获得更细粒度的"区块级动态化"能力:页面可以按区块拆分为可独立更新的单元,通过下发 JS 产物完成局部替换、样式/布局调整、交互逻辑调整等,避免传统整包发布的高成本与长周期。在此基础上,还可以进一步实现:

  • 区块级动态修复:线上出现问题时,可针对特定区块进行快速修复与回滚,把影响面控制在最小范围内,缩短故障处理链路。

  • 更灵活的逻辑/视图更新:在不触发完整发版的情况下,进行轻量的交互优化、文案与布局调整、策略实验等。

  • 快速 AB 与灰度验证:在性能与稳定性可接受的范围内,支持更高频的实验迭代,通过精细化人群与版本控制,加速验证效率与业务决策闭环。

从更前沿的业务形态看,方案也为 LLM 生成式 UI 打开了落地通路:可以由 KMP Agent 基于上下文与策略生成 UI/逻辑对应的 JS 产物,并结合风控与审核机制进行下发,再通过 KJS x Native 在端上渲染展示,实现"按需生成、按需更新"的交互体验。这不仅能扩展动态化的应用场景(如个性化内容编排、运营位智能生成、任务引导动态拼装等),也为后续探索"端侧智能 + 可控动态渲染"的组合提供了工程基础。

随着方案逐步完善并达到可规模化上线标准,我们将进一步补齐文档、示例、接入规范与基建能力(调试工具、性能监控、异常治理与安全策略等),在内部稳定运行一段时间后推动对外开源,促进社区共建与生态扩展。

相关推荐
代码匠心36 分钟前
AI 自动编程:一句话设计高颜值博客
前端·ai·ai编程·claude
_AaronWong2 小时前
Electron 实现仿豆包划词取词功能:从 AI 生成到落地踩坑记
前端·javascript·vue.js
cxxcode2 小时前
I/O 多路复用:从浏览器到 Linux 内核
前端
用户5433081441942 小时前
AI 时代,前端逆向的门槛已经低到离谱 — 以 Upwork 为例
前端
JarvanMo2 小时前
Flutter 版本的 material_ui 已经上架 pub.dev 啦!快来抢先体验吧。
前端
恋猫de小郭2 小时前
AI 可以让 WIFI 实现监控室内人体位置和姿态,无需摄像头?
前端·人工智能·ai编程
哀木3 小时前
给自己整一个 claude code,解锁编程新姿势
前端
程序员鱼皮3 小时前
GitHub 关注突破 2w,我总结了 10 个涨星涨粉技巧!
前端·后端·github
UrbanJazzerati3 小时前
Vue3 父子组件通信完全指南
前端·面试
是一碗螺丝粉3 小时前
5分钟上手LangChain.js:用DeepSeek给你的App加上AI能力
前端·人工智能·langchain