普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月27日掘金 前端

别再让 AI 直接写页面了:一种更稳的中后台开发方式

2025年12月27日 00:35

本文讨论的不是 Demo 级别的 AI 编码体验,而是面向真实团队、长期维护的中后台工程实践。

AI 能写代码,但不意味着它适合直接“产出页面”。

最近一年,大模型在前端领域的讨论几乎都围绕一个问题:

“能不能让 AI 直接把页面写出来?”

在真实的中后台项目中,我的答案是:
不但不稳,而且很危险。

这篇文章想分享一种我在真实项目中实践过、可长期使用、可规模化的方式:
不是让 AI 写页面,而是把 AI 纳入中后台前端的工程体系中。 把 AI 的不确定性关进了笼子里,用工程流程保证可控性。

模板固化规范,Spec 描述变化,大模型生成 Spec,脚本生成代码,lint/test 做兜底。 它解决了 AI 上工程最致命的四件事:

  1. 可审计:变化在 spec,生成结果可 diff
  2. 可重复:同一个 spec 反复生成结果一致
  3. 可兜底:lint/test 是硬门槛
  4. 可规模化:从 prompt 工艺变成流程

在中后台场景,尤其是 CRUD 占比高 的项目里,这几乎就是“性价比最优解”。

一、中后台页面开发的真实困境

如果你做过中后台前端,一定对下面这些场景不陌生:

  • 页面 80% 是 CRUD
  • 列表页结构高度一致
  • 表单字段不断变化
  • 大量复制粘贴
  • 页面逻辑“看起来差不多,但永远不完全一样”

最终结果往往是:

  • 代码冗余
  • 风格不统一
  • 新人上手慢
  • 改一个字段要改好几个地方

这些问题不是某个框架的问题,而是中后台开发的结构性问题。

二、为什么“让 AI 直接写页面”在真实项目里行不通?

很多人第一反应是:

“既然页面这么重复,为什么不直接让 AI 写 Vue / React 页面?”

在真实项目中,这种方式往往会遇到几个致命问题。

1️⃣ 不稳定

  • 同样的 prompt,每次生成结果不同
  • 组件结构、命名风格不断漂移
  • 难以保证团队统一规范

2️⃣ 难以 review

  • AI 一次生成几百行代码
  • reviewer 很难判断“这是不是对的”
  • 出问题时难以定位责任

3️⃣ 无法规模化

  • prompt 是隐性的
  • 经验无法沉淀
  • 每个页面都是“重新生成一次”

4️⃣ 工程体系无法兜底

  • lint / test 很难提前发现语义问题
  • 一旦出错,往往是运行期问题

结论很明确:
AI 直接写页面,更像是 demo,而不是工程方案。

三、一个更稳的思路:把“变化”和“稳定”拆开

在真实项目中,我最终选择了一种更偏工程化的做法:

Template + Spec + Generator + AI

核心思想只有一句话:

模板负责稳定性,Spec 负责变化,AI 只参与变化。

这个流程长什么样?

需求描述
   ↓
页面规格(Spec)
   ↓
模板(Template)
   ↓
生成脚本(Generator)
   ↓
页面代码
   ↓
lint / test 校验

这不是为了“多一层抽象”,而是为了把 AI 的不确定性限制在可控范围内

四、什么是 Spec?

**Spec(Specification)**可以理解为:

页面的“规格说明书”

它描述的是:

  • 页面标题
  • 接口地址
  • 表格字段
  • 查询条件
  • 表单字段与校验规则

而不是:

  • 生命周期怎么写
  • API 怎么调用
  • UI 组件怎么拼

这些内容,非常适合用一份结构化数据来表达。

一个简化的 Spec 示例

{
  "title": "供应商管理",
  "api": {
    "list": "/api/supplier/list",
    "create": "/api/supplier/create",
    "update": "/api/supplier/update",
    "delete": "/api/supplier/delete"
  },
  "columns": [
    { "prop": "name", "label": "供应商名称" },
    { "prop": "contact", "label": "联系人" },
    { "prop": "status", "label": "状态" }
  ],
  "formSchema": [
    { "prop": "name", "label": "供应商名称", "required": true },
    { "prop": "contact", "label": "联系人" },
    {
      "prop": "status",
      "label": "状态",
      "type": "select",
      "options": ["启用", "停用"]
    }
  ]
}

这份 JSON 不依赖任何前端框架,但已经完整描述了一个中后台页面的“变化点”。

五、Template:把重复劳动固化成资产

Template 是固定不变的部分,例如:

  • 页面整体结构
  • 表格 / 表单 / 弹窗骨架
  • API 调用方式
  • 分页逻辑
  • 错误处理方式

它的特点是:

  • 人工维护
  • 版本化
  • 可 review
  • 很少变动

你可以用 Vue、React、Svelte,模板思想本身与框架无关

六、Generator:让生成变成确定性行为

Generator 的职责非常单一:

把 Spec 填进 Template,生成代码文件

这一点非常重要:

  • Generator 是脚本
  • 输出是确定的
  • 不涉及 AI 决策

换句话说:

Generator 不是“智能的”,但它是可靠的。

七、AI 在这里扮演什么角色?

在这套体系中,AI 的职责被严格限制在两点:

✅ 1. 从自然语言生成 Spec

AI 非常擅长:

  • 理解业务描述
  • 生成结构化 JSON
  • 补全字段信息

✅ 2. 按 lint / 报错做最小修复

  • 只修具体文件
  • 只做最小 diff
  • 不重写整体结构

❌ AI 不该做的事

  • 直接写页面代码
  • 修改模板
  • 改动基础设施
  • 引入新依赖

这样做的结果是:
AI 的能力被“工程流程”约束,而不是反过来。

✅ 正确姿势

让 Codex 做两件事:

1️⃣ 根据自然语言 生成 Spec JSON
2️⃣ 根据 lint / 报错 做最小 patch 修复

示例指令(在 Codex CLI / IDE 中):

specs/ 下生成 supplier.json,字段为供应商名称、联系人、电话、状态(启用/停用),接口路径为 /api/supplier/*,输出必须是严格 JSON。

然后:

yarn gen:page specs/supplier.json
yarn lint

如果 lint 报错,再让 Codex 修:

根据 lint 报错,只修改 src/views/supplier/List.vue,用最小改动修到通过。

八、为什么这种方式更适合真实团队?

从工程角度看,这种方式有几个明显优势:

  • 可控:模板稳定,变化集中在 Spec
  • 可 review:Spec 是结构化数据
  • 可回滚:git diff 非常清晰
  • 可规模化:不是 prompt 驱动,而是流程驱动
  • 可迁移:换框架只需换模板

这也是为什么它比“直接让 AI 写页面”更稳。

九、这套思路不只适用于中后台

这种模式可以自然扩展到:

  • 表单页 / 详情页
  • 权限路由生成
  • 页面迁移(如 Vue2 → Vue3)
  • 低代码 / 页面工厂
  • 前端工程自动化

核心不在工具,而在拆分变化与稳定的边界

十、模板化不是终点:一条更现实的“最佳进化路线”

需要说明的是,Template + Spec + Generator 并不是终极方案,而是一个非常合适的工程起点。并不是所有团队都需要走到配置驱动或 AST 修改阶段,对很多团队来说,Template + Spec 本身已经是最优解。

在真实项目中,我更推荐把它看作一条“可进化的路线”,而不是一锤定音的设计。

第一步:Template + Spec(现在的方案)

适用场景:

  • CRUD 页面占比高
  • 新页面数量多
  • 团队希望尽快统一规范

价值:

  • 快速落地
  • 风险可控
  • 非常适合引入 AI 的第一步

第二步:抽象稳定能力,弱化模板复杂度

当发现模板里开始出现大量条件分支时,一个更稳的做法是:

把模板中的稳定逻辑抽成基础组件(如 BaseCrudPage)

此时:

  • 模板变薄
  • spec 只描述“页面配置”
  • 页面本身不再频繁生成新文件

这一步,已经非常接近配置驱动页面渲染

第三步:从“生成代码”走向“配置即页面”

在 CRUD 占比极高的系统中,最终形态往往是:

页面 = 配置(Spec) + 渲染器

此时:

  • 新页面不再生成 .vue 文件
  • 只新增 spec + 路由配置
  • AI 直接生成 spec,收益最大

这本质上就是低代码/页面工厂的雏形

第四步:存量项目引入结构化修改(AST / Patch)

对于已有大量页面的系统,更稳妥的方式是:

  • 用 spec 描述“变更意图”
  • 用工具对代码做结构化修改(如只改 columns、formSchema)
  • AI 只产出 patch,而不是重写页面

这一步非常适合:

  • 老项目
  • 安全要求高的团队
  • 渐进式演进

一句话总结这条路线

模板化是把 AI 引入工程体系的第一步,
配置驱动和结构化修改,才是中后台工程的长期形态。

十一、总结

大模型的价值,不在于“替代工程师写页面”,
而在于:

把重复劳动结构化,并嵌入到工程体系中。

在中后台前端开发中,
最稳的方式永远不是“让 AI 自由发挥”,
而是让它在清晰的边界内工作。

如果只能记住一句话: 不要让 AI 直接写页面,让它写“变化”,其余交给工程。


如果你觉得这篇文章对你有启发,欢迎点赞或收藏 👍

昨天 — 2025年12月26日掘金 前端

Flutter组件封装:视频播放组件全局封装

作者 SoaringHeart
2025年12月26日 20:52

一、需求来源

最近要开发一个在线视频播放的功能,每次实时获取播放,有些卡顿和初始化慢的问题,就随手优化一下。

二、使用示例

1、数据源

class VideoDetailsProvider extends ChangeNotifier {

    /// 当前播放中的
    final _videoModelController = StreamController<VideoDetailModel?>.broadcast();

    /// 当前播放中 Stream<VideoDetailModel>
    Stream<VideoDetailModel?> get videoModelStream => _videoModelController.stream;


    /// 点击(model不为空时播放,为空时关闭)
    sinkModelForVideoPlayer({required VideoDetailModel? model}) {
      _videoModelController.add(model);
    }

}

2、显示播放器组件

StreamBuilder<VideoDetailModel?>(
  stream: provider.videoModelStream,
  builder: (context, snapshot) {
    if (snapshot.data == null) {
      return const SizedBox();
    }
    final model = snapshot.data;
    if (model?.isVideo != true) {
      return const SizedBox();
    }

    return SafeArea(
      top: true,
      bottom: false,
      left: false,
      right: false,
      child: Container(
        decoration: BoxDecoration(
          color: Colors.black,
        ),
        child: AppVideoPlayer(
          key: Key(model?.url ?? ""),
          url: model?.url ?? "",
          onFullScreen: (value) async {
            if (!value) {
              await Future.delayed(const Duration(milliseconds: 300));//等待旋转完成
              SystemChromeExt.changeDeviceOrientation(isPortrait: true);
            }
          },
          onClose: () {
            DLog.d("close");
            provider.sinkModelForVideoPlayer(model: null);
          },
        ),
      ),
    );
  },
)

三、源码

1、AppVideoPlayer.dart

//
//  AppVideoPlayer.dart
//  flutter_templet_project
//
//  Created by shang on 2025/12/12 18:11.
//  Copyright © 2025/12/12 shang. All rights reserved.
//

import 'package:chewie/chewie.dart';
import 'package:flutter/material.dart';
import 'package:flutter_templet_project/basicWidget/AppVideoPlayer/AppVideoPlayerService.dart';
import 'package:flutter_templet_project/extension/extension_local.dart';
import 'package:video_player/video_player.dart';

/// 播放器
class AppVideoPlayer extends StatefulWidget {
  const AppVideoPlayer({
    super.key,
    this.controller,
    required this.url,
    this.autoPlay = true,
    this.looping = false,
    this.aspectRatio = 16 / 9,
    this.isPortrait = true,
    this.fullScreenVN,
    this.onFullScreen,
    this.onClose,
  });

  final AppVideoPlayerController? controller;

  final String url;
  final bool autoPlay;
  final bool looping;
  final double aspectRatio;

  /// 设备方向
  final bool isPortrait;

  final ValueNotifier<bool>? fullScreenVN;

  final void Function(bool isFullScreen)? onFullScreen;

  final VoidCallback? onClose;

  @override
  State<AppVideoPlayer> createState() => _AppVideoPlayerState();
}

class _AppVideoPlayerState extends State<AppVideoPlayer> with WidgetsBindingObserver, AutomaticKeepAliveClientMixin {
  VideoPlayerController? _videoController;
  ChewieController? _chewieController;

  Duration position = Duration.zero;

  @override
  void dispose() {
    widget.controller?._detach(this);
    WidgetsBinding.instance.removeObserver(this);
    _onClose();
    // 不销毁 VideoPlayerController,让全局复用
    // _chewieController?.dispose();
    super.dispose();
  }

  @override
  void initState() {
    super.initState();
    widget.controller?._attach(this);
    WidgetsBinding.instance.addObserver(this);
    WidgetsBinding.instance.addPostFrameCallback((_) {
      initPlayer();
    });
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    switch (state) {
      case AppLifecycleState.inactive:
      case AppLifecycleState.hidden:
      case AppLifecycleState.paused:
        {
          _chewieController?.pause();
        }
        break;
      case AppLifecycleState.detached:
        break;
      case AppLifecycleState.resumed:
        {
          _chewieController?.play();
        }
        break;
    }
  }

  Future<void> initPlayer() async {
    // DLog.d(widget.url.split("/").last);
    assert(widget.url.startsWith("http"), "url 错误");

    _videoController = await AppVideoPlayerService.instance.getController(widget.url);

    _chewieController?.dispose();
    _chewieController = ChewieController(
      videoPlayerController: _videoController!,
      autoPlay: widget.autoPlay,
      looping: widget.looping,
      aspectRatio: widget.aspectRatio,
      autoInitialize: true,
      allowFullScreen: true,
      allowMuting: false,
      showControlsOnInitialize: false,
      // customControls: const AppVideoControls(),
    );

    _chewieController!.addListener(() {
      final isFullScreen = _chewieController!.isFullScreen;
      widget.fullScreenVN?.value = isFullScreen;
      widget.onFullScreen?.call(isFullScreen);
      if (isFullScreen) {
        DLog.d("进入全屏");
      } else {
        DLog.d("退出全屏");
      }
    });
    if (mounted) {
      setState(() {});
    }
  }

  Future<void> _onClose() async {
    if (_videoController?.value.isPlaying == true) {
      _videoController?.pause();
    }
  }

  @override
  void didUpdateWidget(covariant AppVideoPlayer oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.url != widget.url) {
      initPlayer();
    }
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();

    /// 🔥屏幕横竖切换时 rebuild Chewie,但不 rebuild video
    DLog.d([MediaQuery.of(context).orientation]);
    // setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    if (widget.url.startsWith("http") != true) {
      return const SizedBox();
    }

    if (_chewieController == null) {
      return const Center(child: CircularProgressIndicator());
    }
    // return Chewie(controller: _chewieController!);
    return Stack(
      children: [
        Positioned.fill(
          child: Chewie(
            controller: _chewieController!,
          ),
        ),
        Positioned(
          right: 20,
          top: 10,
          child: Container(
            // decoration: BoxDecoration(
            //   color: Colors.red,
            //   border: Border.all(color: Colors.blue),
            // ),
            child: buildCloseBtn(onTap: widget.onClose),
          ),
        ),
      ],
    );
  }

  Widget buildCloseBtn({VoidCallback? onTap}) {
    return GestureDetector(
      behavior: HitTestBehavior.opaque,
      onTap: () {
        DLog.d("buildCloseBtn");
        _onClose();
        if (onTap != null) {
          onTap();
          return;
        }
        Navigator.pop(context);
      },
      child: Container(
        // padding: EdgeInsets.all(6),
        // decoration: BoxDecoration(
        //   color: Colors.black54,
        //   shape: BoxShape.circle,
        //   border: Border.all(color: Colors.blue),
        // ),
        child: const Icon(Icons.close, color: Colors.white, size: 24),
      ),
    );
  }

  @override
  bool get wantKeepAlive => true;
}

class AppVideoPlayerController {
  _AppVideoPlayerState? _anchor;

  void _attach(_AppVideoPlayerState anchor) {
    _anchor = anchor;
  }

  void _detach(_AppVideoPlayerState anchor) {
    if (_anchor == anchor) {
      _anchor = null;
    }
  }

  VideoPlayerController? get videoController {
    assert(_anchor != null);
    return _anchor!._videoController;
  }

  ChewieController? get chewieController {
    assert(_anchor != null);
    return _anchor!._chewieController;
  }
}

2、AppVideoPlayerService.dart

//
//  AppVideoPlayerService.dart
//  flutter_templet_project
//
//  Created by shang on 2025/12/12 18:10.
//  Copyright © 2025/12/12 shang. All rights reserved.
//

import 'package:flutter_templet_project/extension/extension_local.dart';
import 'package:quiver/collection.dart';
import 'package:video_player/video_player.dart';

/// 视频播放控制器全局管理
class AppVideoPlayerService {
  AppVideoPlayerService._();
  static final AppVideoPlayerService _instance = AppVideoPlayerService._();
  factory AppVideoPlayerService() => _instance;
  static AppVideoPlayerService get instance => _instance;

  /// 播放器字典
  LruMap<String, VideoPlayerController> get controllerMap => _controllerMap;
  // final _controllerMap = <String, VideoPlayerController>{};
  final _controllerMap = LruMap<String, VideoPlayerController>(maximumSize: 10);

  /// 最新使用播放器
  VideoPlayerController? current;

  /// 有缓存控制器
  bool hasCtrl({required String url}) => _controllerMap[url] != null;

  /// 是网络视频
  static bool isVideo(String? url) {
    if (url?.isNotEmpty != true) {
      return false;
    }

    final videoUri = Uri.tryParse(url!);
    if (videoUri == null) {
      return false;
    }

    final videoExt = ['.mp4', '.mov', '.avi', '.wmv', '.flv', '.mkv', '.webm'];
    final ext = url.toLowerCase();
    final result = videoExt.any((e) => ext.endsWith(e));
    return result;
  }

  /// 获取 VideoPlayerController
  Future<VideoPlayerController?> getController(String url, {bool isLog = false}) async {
    assert(url.startsWith("http"), "必须是视频链接,请检查链接是否合法");
    final vc = _controllerMap[url];
    if (vc != null) {
      current = vc;
      if (isLog) {
        DLog.d(["缓存: ${vc.hashCode}"]);
      }
      return vc;
    }

    final videoUri = Uri.tryParse(url);
    if (videoUri == null) {
      return null;
    }

    final ctrl = VideoPlayerController.networkUrl(videoUri);
    await ctrl.initialize();
    _controllerMap[url] = ctrl;
    current = ctrl;
    if (isLog) {
      DLog.d(["新建: ${_controllerMap[url].hashCode}"]);
    }
    return ctrl;
  }

  /// 播放
  Future<void> play({required String url, bool onlyOne = true}) async {
    await getController(url);
    for (final e in _controllerMap.entries) {
      if (e.key == url) {
        if (!e.value.value.isPlaying) {
          e.value.play();
        } else {
          e.value.pause();
        }
      } else {
        if (onlyOne) {
          e.value.pause();
        }
      }
    }
  }

  /// 暂停所有视频
  void pauseAll() {
    for (final e in _controllerMap.entries) {
      e.value.pause();
    }
  }

  void dispose(String url) {
    _controllerMap[url]?.dispose();
    _controllerMap.remove(url);
  }

  void disposeAll() {
    for (final c in _controllerMap.values) {
      c.dispose();
    }
    _controllerMap.clear();
  }
}

最后、总结

1、播放视频组件和视频列表是分开的,通过监听 VideoPlayerController 保持状态(播放,暂停)一致性,类似网络视频小窗口播放。

2、通过 AppVideoPlayerService 实现全局 VideoPlayerController 缓存,提高初始化加载速度。

3、通过 quiver 的 LruMap 实现最大缓存数控制,防止内存爆炸。

github

我接入了微信小说小程序官方阅读器

作者 jack_po
2025年12月26日 18:31

概述

微信官方为小说类小程序提供了专用的阅读器插件,所有小说类目的小程序都必须使用该组件。

快速开始

1. 添加插件配置

在小程序的 app.json 文件中添加插件配置:

{
  "plugins": {
    "novel-plugin": {
      "version": "latest",
      "provider": "wx293c4b6097a8a4d0"
    }
  }
}

2. 初始化插件

app.js 中初始化插件并监听页面加载事件:

// app.js
const novelPlugin = requirePlugin('novel-plugin')

App({
  onLaunch() {
    // 监听阅读器页面加载事件
    novelPlugin.onPageLoad(onNovelPluginLoad)
  },
})

function onNovelPluginLoad(data) {
  // data.id - 阅读器实例ID
  const novelManager = novelPlugin.getNovelManager(data.id)
  
  // 设置目录状态
  novelManager.setContents({
    contents: [
      { index: 0, status: 0 }, // 第一章:免费
      { index: 1, status: 2 }, // 第二章:未解锁
      { index: 2, status: 1 }, // 第三章:已解锁
    ]
  })

  // 监听用户行为
  novelManager.onUserTriggerEvent(res => {
    console.log('用户操作:', res.event_id, res)
  })
}

3. 跳转到阅读页面

// 跳转到阅读器页面
wx.navigateTo({
  url: 'plugin-private://wx293c4b6097a8a4d0/pages/novel/index?bookId=书籍ID'
})

核心功能详解

页面跳转参数

跳转到阅读页面时可以传入以下参数:

参数 必填 说明
bookId 书籍ID
chapterIndex 跳转章节下标(从0开始)
fontSize 字体大小(0-9)
turnPageWay 翻页方式:SWIPE/MOVE/SCROLL
backgroundConfigIndex 背景色序号(1-5)
isNightMode 是否夜间模式
showListenButton 是否显示听书按钮

章节解锁功能

1. 创建解锁组件

首先创建一个自定义组件 charge-dialog

<!-- charge-dialog.wxml -->
<view class="charge-dialog">
  <text>解锁第 {{ chapterIndex + 1 }} 章</text>
  <button bindtap="unlock">立即解锁</button>
</view>
// charge-dialog.js
const novelPlugin = requirePlugin('novel-plugin')

Component({
  properties: {
    novelManagerId: Number,
    bookId: String,
    chapterIndex: Number,
    chapterId: String
  },

  methods: {
    unlock() {
      const novelManager = novelPlugin.getNovelManager(this.properties.novelManagerId)
      
      // 执行解锁逻辑...
      
      // 解锁完成后通知阅读器
      novelManager.paymentCompleted()
    }
  }
})

2. 注册组件

app.json 中注册组件:

{
  "plugins": {
    "novel-plugin": {
      "version": "latest",
      "provider": "wx293c4b6097a8a4d0",
      "genericsImplementation": {
        "novel": {
          "charge-dialog": "components/charge-dialog/charge-dialog"
        }
      }
    }
  }
}

消息推送配置

阅读器通过消息推送验证章节解锁状态,需要在服务器端配置消息接收:

// 服务器接收消息示例
{
  "ToUserName": "小程序原始ID",
  "FromUserName": "用户openid",
  "CreateTime": 时间戳,
  "MsgType": "event",
  "Event": "wxa_novel_chapter_permission",
  "BookId": "书籍ID",
  "ChapterIndex": 章节下标,
  "ChapterId": "章节ID",
  "Source": 1 // 1表示用户实际阅读
}

// 响应格式
{
  "ErrCode": 0,
  "ErrMsg": "",
  "ChapterPerms": [{
    "StartChapterIndex": 0,
    "EndChapterIndex": 2,
    "Perm": 1 // 0-免费 1-已解锁 2-未解锁
  }]
}

高级功能

1. 自定义解锁方式

// 设置不同的解锁方式
novelManager.setChargeWay({
  globalConfig: {
    mode: 2, // 1:默认 2:广告解锁 3:自定义解锁
    buttonText: "解锁"
  },
  chapterConfigs: [
    {
      chapterIndex: 10,
      mode: 3,
      buttonText: "VIP解锁"
    }
  ]
})

// 监听自定义解锁事件
novelManager.onUserClickCustomUnlock(res => {
  console.log('自定义解锁章节:', res.chapterIndex)
})

2. 插入自定义段落

// 在正文中插入自定义内容
novelManager.setParagraphBlock({
  chapterConfigs: [{
    chapterIndex: 0,
    blocks: [{
      height: 100,    // 高度
      position: 1,    // 位置(0:标题前,1:第一段前)
      ext: "自定义数据",
      key: "unique-id"
    }]
  }]
})

3. 广告插入

// 插入广告
novelManager.setAdBlock({
  chapterConfigs: [{
    chapterIndex: 0,
    blocks: [{
      type: 1,        // 1:强制观看 2:banner广告 3:书签广告
      position: 10,   // 广告位置
      duration: 6,    // 强制观看时长(秒)
      unitId: "广告单元ID"
    }]
  }]
})

实用API参考

获取阅读器信息

// 获取当前阅读器实例
const novelManager = novelPlugin.getCurrentNovelManager()

// 获取书籍ID
const bookId = novelManager.getBookId()

// 获取插件信息
const pluginInfo = novelManager.getPluginInfo()

导航控制

// 设置关闭行为
novelManager.setClosePluginInfo({
  url: '/pages/index/index',
  mode: 'redirectTo'
})

// 设置返回行为
novelManager.setLeaveReaderInfo({
  url: '/pages/bookshelf/index',
  mode: 'switchTab'
})

分享配置

novelManager.setShareParams({
  title: '书籍标题',
  imageUrl: '封面图片',
  args: {
    from: 'share',
    time: '2024'
  }
})

书架功能

// 设置书架状态
novelManager.setBookshelfStatus({
  bookshelfStatus: 1 // 0:未添加 1:已添加
})

// 监听书架点击
novelManager.onClickBookshelf(res => {
  // 处理书架逻辑
  novelManager.setBookshelfStatus({
    bookshelfStatus: res.bookshelfStatus ? 1 : 0
  })
})

事件监听

阅读器提供丰富的事件监听:

novelManager.onUserTriggerEvent(res => {
  switch(res.event_id) {
    case 'start_read':          // 开始阅读
    case 'change_chapter':      // 切换章节
    case 'change_fontsize':     // 调整字号
    case 'click_listen':        // 点击听书
    case 'audio_start':         // 音频开始
    // ... 更多事件
  }
})

让大语言模型拥有“记忆”:多轮对话与 LangChain 实践指南

作者 wwwwW
2025年12月26日 18:09

让大语言模型拥有“记忆”:多轮对话与 LangChain 实践指南

在当前人工智能应用开发中,大语言模型(LLM)因其强大的自然语言理解与生成能力,被广泛应用于聊天机器人、智能客服、个人助理等场景。然而,一个常见的问题是:LLM 本身是无状态的——每一次 API 调用都独立于前一次,就像每次 HTTP 请求一样,模型无法“记住”你之前说过什么。

那么,如何让 LLM 拥有“记忆”,实现真正的多轮对话体验?本文将从原理出发,结合 LangChain 框架的实践代码,深入浅出地讲解这一关键技术。


一、为什么 LLM 默认没有记忆?

大语言模型(如 DeepSeek、GPT、Claude 等)在设计上遵循“输入-输出”模式。当你调用其 API 时,仅传递当前的问题或指令,模型不会自动保留之前的交互内容。例如:

ts
编辑
const res1 = await model.invoke('我叫王源,喜欢喝白兰地');
const res2 = await model.invoke('我叫什么名字?');

第二次调用时,模型完全不知道“王源”是谁,因此很可能回答:“我不知道你的名字。”
这是因为两次调用之间没有任何上下文传递。


二、实现“记忆”的核心思路:维护对话历史

要让 LLM 表现出“有记忆”的行为,最直接的方法是:在每次请求中显式传入完整的对话历史。通常以 messages 数组的形式组织:

json
编辑
[  {"role": "user", "content": "我叫王源,喜欢喝白兰地"},  {"role": "assistant", "content": "很高兴认识你,王源!白兰地是很优雅的选择。"},  {"role": "user", "content": "你知道我是谁吗?"}]

模型通过分析整个对话上下文,就能准确回答:“你是王源,喜欢喝白兰地。”

但这种方法存在明显问题:随着对话轮次增加,输入 token 数量不断膨胀,不仅增加计算成本,还可能超出模型的最大上下文长度限制(如 32768 tokens)。这就像滚雪球,越滚越大。


三、LangChain 提供的解决方案:模块化记忆管理

为了解决上述问题,LangChain 等 AI 应用框架提供了专门的 Memory 模块,帮助开发者高效管理对话历史,并支持多种存储策略(内存、数据库、向量化摘要等)。

以下是一个使用 @langchain/deepseekRunnableWithMessageHistory 的完整示例:

1. 初始化模型与提示模板

ts
编辑
import { ChatDeepSeek } from '@langchain/deepseek';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { RunnableWithMessageHistory } from '@langchain/core/runnables';
import { InMemoryChatMessageHistory } from '@langchain/core/chat_history';

const model = new ChatDeepSeek({
  model: 'deepseek-chat',
  temperature: 0,
});

// 定义包含历史记录占位符的提示模板
const prompt = ChatPromptTemplate.fromMessages([
  ['system', '你是一个有记忆的助手'],
  ['placeholder', '{history}'], // 历史消息将插入此处
  ['human', '{input}']
]);

2. 构建带记忆的可运行链

ts
编辑
const runnable = prompt.pipe(model);

// 创建内存中的对话历史存储
const messageHistory = new InMemoryChatMessageHistory();

// 封装成支持会话记忆的链
const chain = new RunnableWithMessageHistory({
  runnable,
  getMessageHistory: async () => messageHistory,
  inputMessagesKey: 'input',
  historyMessagesKey: 'history',
});

3. 执行多轮对话

ts
编辑
// 第一轮:用户自我介绍
const res1 = await chain.invoke(
  { input: '我叫王源,喜欢喝白兰地' },
  { configurable: { sessionId: 'makefriend' } }
);
console.log(res1.content); // “你好,王源!白兰地确实很经典。”

// 第二轮:提问名字
const res2 = await chain.invoke(
  { input: '我叫什么名字?' },
  { configurable: { sessionId: 'makefriend' } }
);
console.log(res2.content); // “你叫王源。”

✅ 关键点:sessionId 用于区分不同用户的会话。同一个 sessionId 下的所有交互共享同一段历史。


四、进阶思考:如何优化长对话的记忆效率?

虽然内存存储简单易用,但在生产环境中,面对海量用户和长期对话,我们需要更高效的策略:

  1. 滑动窗口记忆:只保留最近 N 轮对话。
  2. 摘要压缩:定期将历史对话总结成一段摘要,替代原始记录。
  3. 向量数据库 + 语义检索:将关键信息存入向量库,按需检索相关上下文(适用于知识密集型对话)。
  4. 混合记忆:结合短期(最近几轮)+ 长期(摘要/数据库)记忆。

LangChain 已支持多种 Memory 类型,如 BufferWindowMemorySummaryMemoryVectorStoreRetrieverMemory 等,可根据场景灵活选择。


五、结语

让 LLM 拥有“记忆”,本质上是将无状态的模型调用转化为有状态的会话系统。通过维护对话历史并合理控制上下文长度,我们可以在成本与体验之间取得平衡。

LangChain 等框架极大简化了这一过程,使开发者能专注于业务逻辑,而非底层状态管理。未来,随着上下文窗口的扩大和记忆机制的智能化(如自动遗忘、重点记忆),AI 助手将越来越像一个真正“记得你”的朋友。

正如我们在代码中看到的那样——当模型说出“你叫王源”时,那一刻,它仿佛真的记住了你。

鸿蒙分布式KVStore冲突解决机制:原理、实现与工程化实践

作者 Nick不懂
2025年12月26日 18:07

鸿蒙分布式KVStore冲突解决机制:原理、实现与工程化实践

一、核心背景与问题定义

分布式KVStore是鸿蒙(HarmonyOS/OpenHarmony)实现跨设备数据协同的核心组件,适用于应用配置、用户状态等轻量级数据的跨设备同步场景。其基于最终一致性模型设计,在多设备并发写入同一Key时,必然产生数据冲突。本文聚焦 SingleKVStore(单版本模式) 的冲突解决机制,明确核心问题边界:当组网内多个设备对同一Key执行写入操作时,如何保证数据最终一致性,同时避免业务关键数据丢失。

二、冲突产生的底层逻辑

1. 冲突触发条件

  • 数据维度:同一SingleKVStore实例中,不同设备对同一Key执行写入(覆盖/更新)操作;

  • 网络维度:设备间网络中断后恢复连接,或多设备同时在线时并发写入;

  • 系统维度:同步过程中数据传输延迟、设备时钟偏差(影响时间戳判断)。

2. 默认冲突解决策略:LWW(Last Write Wins)

鸿蒙默认采用“最后写入者获胜”策略,核心判定依据为数据的写入时间戳(系统时间)或版本号:

  • 判定逻辑:同步时对比同一Key的时间戳,保留时间戳更新的写入数据;

  • 适用场景:配置类数据(如主题设置、字体大小),这类数据对“最新状态”的需求优先于“全量合并”;

  • 局限性:无法处理需要结构化合并的场景(如待办清单数组、多字段对象),且设备时钟偏差可能导致错误的优先级判定。

三、自定义冲突解决:实战实现(Stage模型+ArkTS)

1. 前置准备:权限与依赖

需在module.json5中声明分布式数据操作权限,确保跨设备数据同步能力正常启用:


{

  "module": {

    "requestPermissions": [

      {

        "name": "ohos.permission.DISTRIBUTED_DATASYNC",

        "reason": "跨设备数据同步需要",

        "usedScene": { "ability": ["com.demo.kvstore.DemoAbility"], "when": "always" }

      },

      {

        "name": "ohos.permission.GET_DISTRIBUTED_DEVICE_INFO",

        "reason": "获取组网设备信息需要",

        "usedScene": { "ability": ["com.demo.kvstore.DemoAbility"], "when": "always" }

      }

    ]

  }

}

依赖导入(API 10+,需同步升级SDK至对应版本):


import distributedData from '@ohos.data.distributedData';

import { BusinessError } from '@ohos.base';

import deviceManager from '@ohos.distributedHardware.deviceManager';

2. 核心实现步骤

步骤1:初始化分布式KVStore(指定单版本模式)

class DistributedKVManager {

  private kvStore: distributedData.SingleKVStore | null = null;

  private readonly STORE_NAME = 'demo_business_store'; // 跨设备统一存储名称

  private readonly SECURITY_LEVEL = distributedData.SecurityLevel.S1; // 基础安全等级(非加密)

  


  // 初始化KVStore,确保跨设备共享同一存储实例

  async init(): Promise<boolean> {

    try {

      const options: distributedData.Options = {

        createIfMissing: true,

        encrypt: false,

        securityLevel: this.SECURITY_LEVEL

      };

      // 获取SingleKVStore实例(单版本模式,支持跨设备同步)

      this.kvStore = await distributedData.getSingleKVStore(this.STORE_NAME, options);

      console.info('DistributedKVStore初始化成功');

      this.registerConflictListener(); // 注册冲突监听

      return true;

    } catch (error) {

      const err = error as BusinessError;

      console.error(`KVStore初始化失败:code=${err.code}, message=${err.message}`);

      return false;

    }

  }

}

步骤2:自定义冲突解决逻辑(以待办清单合并为例)

针对结构化数据(如待办清单数组),实现“数组去重合并”的自定义策略,替代默认LWW策略:


private async registerConflictListener() {

  if (!this.kvStore) return;

  


  // 监听所有数据变更(本地+远端),通过业务逻辑识别冲突

  this.kvStore.on('dataChange', distributedData.SubscribeType.SUBSCRIBE_TYPE_ALL, async (data) => {

    for (const entry of data.updateEntries) {

      const key = entry.key;

      const remoteValue = entry.value; // 远端同步过来的新数据

      const localValue = await this.kvStore!.get(key); // 本地当前数据

  


      // 1. 数据格式校验(约定value为{ ver: number, payload: any }结构)

      if (!this.validateDataFormat(localValue) || !this.validateDataFormat(remoteValue)) {

        console.warn(`数据格式非法,采用LWW策略:key=${key}`);

        return;

      }

  


      // 2. 判定冲突:本地与远端版本号不同时视为冲突

      if (localValue.ver !== remoteValue.ver) {

        console.info(`检测到冲突:key=${key},本地版本=${localValue.ver},远端版本=${remoteValue.ver}`);

        const mergedValue = this.mergeTodoList(localValue.payload, remoteValue.payload);

        const newVer = Math.max(localValue.ver, remoteValue.ver) + 1; // 生成新版本号

  


        // 3. 写入合并后的数据(避免循环同步,需原子操作)

        await this.kvStore!.put(key, { ver: newVer, payload: mergedValue });

        await this.kvStore!.flush(); // 强制刷盘,确保同步可靠性

      }

    }

  });

}

  


// 校验数据格式(业务自定义)

private validateDataFormat(data: any): boolean {

  return typeof data === 'object' && data !== null && 'ver' in data && 'payload' in data;

}

  


// 待办清单合并逻辑:去重并保留所有有效条目

private mergeTodoList(localTodo: Array<{ id: string; content: string; completed: boolean }>, 

                      remoteTodo: Array<{ id: string; content: string; completed: boolean }>): Array<any> {

  const todoMap = new Map<string, any>();

  // 先加入本地条目

  localTodo.forEach(todo => todoMap.set(todo.id, todo));

  // 加入远端条目(远端已完成状态优先,避免本地未同步的完成状态丢失)

  remoteTodo.forEach(todo => {

    const existTodo = todoMap.get(todo.id);

    if (existTodo) {

      todoMap.set(todo.id, { ...existTodo, completed: todo.completed });

    } else {

      todoMap.set(todo.id, todo);

    }

  });

  return Array.from(todoMap.values());

}

步骤3:主动同步触发(控制同步时机)

通过sync方法主动触发跨设备数据同步,支持指定同步模式和目标设备:


// 触发同步:向组网内所有设备推送本地数据并拉取远端数据

async syncData(): Promise<boolean> {

  if (!this.kvStore) return false;

  try {

    const syncOptions: distributedData.SyncOptions = {

      syncMode: distributedData.SyncMode.PUSH_PULL, // 推拉模式(双向同步)

      deviceIds: [], // 空数组表示同步至所有组网设备

      delayMs: 100 // 延迟100ms同步,避免频繁写入导致的抖动

    };

    await this.kvStore.sync(syncOptions);

    console.info('数据同步触发成功');

    return true;

  } catch (error) {

    const err = error as BusinessError;

    console.error(`同步失败:code=${err.code}, message=${err.message}`);

    return false;

  }

}

四、工程化最佳实践

1. 冲突规避:存储结构设计原则

  • 拆分Key粒度:将复杂对象拆分为多个独立Key(如todo_list_202506todo_config),减少同一Key的并发写入;

  • 设备维度隔离:非共享数据使用DeviceKVStore(按设备分片存储),天然避免跨设备冲突;

  • 版本号强制递增:约定所有写入操作必须生成新的版本号(如基于时间戳+设备ID),确保冲突判定准确性。

2. 性能优化:减少无效同步

  • 批量同步聚合同步请求:短时间内多次写入后,延迟100-300ms触发同步(通过delayMs配置),减少同步次数;

  • 过滤无效变更:在dataChange监听中,对比数据内容是否真的变化,避免因版本号误判导致的重复合并;

  • 控制数据规模:SingleKVStore建议单Key数据不超过10KB,总条目数不超过1000条,超出场景切换至分布式数据库。

3. 可靠性保障:异常处理与校验

  • 幂等性设计:合并逻辑确保多次执行结果一致,避免同步重试导致的数据重复;

  • 数据备份:关键业务数据定期备份至本地文件,避免KVStore同步异常导致的数据丢失;

  • 冲突日志:记录冲突发生时间、Key、本地/远端数据内容,便于问题排查。

五、常见问题与解决方案

1. 冲突监听不触发

  • 排查方向1:权限未授予(需动态申请DISTRIBUTED_DATASYNC权限,尤其是API 11+版本);

  • 排查方向2:订阅类型错误(需使用SUBSCRIBE_TYPE_ALL监听本地和远端变更);

  • 排查方向3:KVStore实例未正确初始化(确保getSingleKVStore调用成功后再注册监听)。

2. 合并后数据再次冲突

  • 解决方案:写入合并数据时生成全局唯一版本号(如Date.now() + 设备ID后缀),避免不同设备生成相同版本号;

  • 补充措施:同步后触发flush强制刷盘,确保数据持久化后再参与下一轮同步。

3. 设备时钟偏差导致LWW策略失效

  • 解决方案:替换时间戳为“版本号+设备优先级”的判定逻辑(如手机优先级高于手表,相同版本号时保留手机数据);

  • 实现方式:在数据结构中增加devicePriority字段,冲突时优先保留优先级高的设备数据。

六、核心总结

分布式KVStore的冲突解决核心在于“先规避、后解决”:通过合理的Key粒度设计和存储模式选择,减少冲突发生概率;针对无法规避的冲突,基于业务场景实现自定义合并逻辑(如数组合并、字段优先级合并),替代默认LWW策略。开发过程中需重点关注版本号管理、同步时机控制和异常日志记录,确保跨设备数据一致性的同时,保障业务数据可靠性。

别再让大模型“胡说八道”了!LangChain 的 JsonOutputParser 教你驯服 AI 输出

2025年12月26日 17:45
当 AI 开始“自由发挥” 你有没有遇到过这样的场景? 你辛辛苦苦写了一堆提示词(prompt),满怀期待地调用大模型,结果它回你一段: 而你真正想要的,只是一个干净、结构化的 JSON!

VueCropper加载OBS图片跨域问题

2025年12月26日 17:30

问题场景

在集成 VueCropper 图片裁剪组件时,遇到一个典型的跨域问题:加载存储在 OBS 上的图片时,浏览器会对同一张图片发起两次请求,最终第二次请求触发跨域错误。以下是问题的完整排查过程与现象梳理:

  1. 请求行为异常:同一张 OBS 图片被浏览器发起两次请求,第一次请求正常响应,第二次请求直接抛出跨域相关错误(如 CORS policy: No 'Access-Control-Allow-Origin' header);
  2. 初步排查方向:初期优先怀疑 OBS 服务器跨域配置缺失,反馈运维核查后,确认 OBS 已正确配置跨域允许规则(含允许当前前端域名、支持 GET 方法等);
  3. 关键测试突破:通过浏览器开发者工具测试发现,开启「停用缓存」功能后,跨域错误消失。据此锁定问题根源与浏览器缓存机制相关;
  4. 核心差异定位:对比两次请求的详细信息,发现跨域标识配置不一致——第一次加载图片未设置 crossOrigin 标识,第二次加载时则添加了该标识;
  5. 问题逻辑闭环:第一次请求因无 crossOrigin 标识,OBS 服务器识别为非跨域请求,未返回跨域响应头;第二次请求虽开启 crossOrigin,但因图片 URL 未变,浏览器直接复用缓存的无跨域响应头资源,导致跨域校验失败。

问题根源

为验证上述排查结论,通过原生 JavaScript 代码复现了问题场景,最终确认问题根源为 跨域策略与浏览器缓存冲突

window.addEventListener('DOMContentLoaded', () => {
    const image = new Image();
    // 第一次加载:未设置 crossOrigin 标识
    image.src = 'https://xxx.png'; 
    image.addEventListener('load', () => {
        console.log('图片加载完成');
        const image2 = new Image();
        // 第二次加载:设置跨域标识(与第一次不一致)
        image2.crossOrigin = "anonymous";
        // 加载同一张图片,触发跨域错误
        image2.src = image.src; 
        image2.addEventListener('load', () => {
            console.log('图片2加载完成');
        });
    });
});

该问题的核心矛盾是「同一张图片的两次请求采用不一致的跨域策略」,结合浏览器缓存机制与 OBS 服务器的跨域响应规则,具体错误逻辑可拆解为两步:

  1. 非跨域模式缓存:第一次加载未设置 crossOrigin,浏览器以「非跨域模式」发起请求(请求头 Sec-Fetch-Mode = no-cors);OBS 服务器识别该模式后,不返回 Access-Control-Allow-Origin 等跨域响应头,浏览器则将这份「无跨域响应头的图片资源」缓存至本地;
  2. 跨域模式缓存冲突:第二次加载设置 crossOrigin: "anonymous",浏览器需以「跨域模式」请求;但因图片 URL 未变,浏览器直接复用本地缓存的无跨域响应头资源,跨域校验无法通过,最终触发错误。

关键结论:同一张图片的所有请求,跨域标识必须完全统一(要么所有请求都设 crossOrigin,要么都不设),否则会因缓存机制导致跨域策略冲突。

解决办法

1. 核心通用方案:统一跨域标识(推荐首选)

思路:统一所有加载该图片的 Image 实例的跨域配置(均设置或均不设置 crossOrigin),确保两次请求的跨域策略一致,从根源避免缓存与跨域规则的冲突。

核心注意点:crossOrigin 必须在 src 赋值 之前 设置。若先赋值 src,浏览器会提前以默认模式发起请求,仍会触发跨域冲突。

正确代码示例:

window.addEventListener('DOMContentLoaded', () => {
  // 第一次加载:提前设置 crossOrigin,统一跨域策略
  const image = new Image();
  image.crossOrigin = "anonymous"; 
  image.src = 'https://xxx.png';

  image.addEventListener('load', () => {
      console.log('图片加载完成');
      // 第二次加载:保持 crossOrigin 配置一致
      const image2 = new Image();
      image2.crossOrigin = "anonymous"; 
      image2.src = image.src;

      image2.addEventListener('load', () => {
          console.log('图片2加载完成');
      });
  });

  // 添加错误监听,便于问题排查
  image.addEventListener('error', (e) => console.error('图片1加载失败:', e));
  image2.addEventListener('error', (e) => console.error('图片2加载失败:', e));
});

适用场景:绝大多数前端场景(包括 VueCropper 等依赖 canvas 的裁剪组件场景),且图片存储端(OBS/CDN)已配置跨域允许规则。

2. 绕过缓存方案:让第二次请求视为「新资源」

思路:若历史代码无法批量修改 crossOrigin 配置,可通过让第二次请求「避开缓存」,迫使浏览器重新向 OBS 服务器发起请求(而非复用旧缓存),从而避免跨域策略冲突。

推荐方案:给图片 URL 添加随机参数(如时间戳),使浏览器识别为「新资源」,触发全新请求:

image.addEventListener('load', () => {
  const image2 = new Image();
  image2.crossOrigin = "anonymous";
  // 追加时间戳参数,避开浏览器缓存
  image2.src = `${image.src}&t=${Date.now()}`; 
  image2.addEventListener('load', () => console.log('图片2加载完成'));
});

适用场景:历史代码架构复杂,无法批量统一 crossOrigin 配置,需快速临时修复跨域问题。

缺点:完全失去缓存优势,每次请求均需重新下载图片,会增加带宽消耗并延长页面加载时间,仅建议临时使用。

3. 后端/存储端方案:从根源消除跨域

思路:通过后端代理或域名绑定,将「跨域图片请求」转为「同源请求」,从根源上消除跨域问题,无需前端额外配置 crossOrigin

具体做法:

  1. 同源代理(推荐):前端请求自身后端的代理接口,由后端代为拉取 OBS 图片并返回给前端。此时前端接收的图片为同源资源,无需任何跨域配置。

示例(Node.js/Express 代理):

// 后端代理代码(Node.js/Express)
const express = require('express');
const axios = require('axios');
const app = express();

// 图片代理接口:接收前端传递的 OBS 图片 URL
app.get('/proxy-image', async (req, res) => {
  const imgUrl = req.query.url;
  try {
      // 后端请求 OBS 图片,以流形式返回给前端
      const response = await axios.get(imgUrl, { responseType: 'stream' });
      response.data.pipe(res);
  } catch (err) {
      res.status(404).send('图片加载失败');
  }
});

app.listen(3000, () => console.log('代理服务启动在 3000 端口'));

前端代码:

// 前端代码:请求后端代理接口,无跨域问题
const image = new Image();
image.src = `http://localhost:3000/proxy-image?url=${encodeURIComponent('https://xxx.png')}`;
image.addEventListener('load', () => console.log('图片加载完成'));

适用场景:前端需大量处理跨域图片(如批量裁剪、像素操作);追求稳定可靠的跨域解决方案,不希望依赖前端代码配置。

优点:彻底解决跨域问题,前端无需任何跨域相关配置;缓存机制可正常生效,不影响加载性能;安全性更高(避免设置 * 允许所有域名跨域)。

在 React 里优雅地 “隐藏 iframe 滚动条”

2025年12月26日 17:27

前端有一个经典问题:

你在宿主页面怎么写 CSS,都管不到 iframe 内部的滚动条。

所以正确的前端方案不是 “给外层容器加 overflow”,而是:尽量在 iframe 自己层面兜底 + 同源时向 iframe 内注入 CSS

本文只聚焦前端实现,不展开前后端传参链路。


1. 为什么你明明设置了,滚动条还是在?

因为滚动条来自 iframe 内部 document

  • 外层 divoverflow-hidden 只能裁剪 “iframe 这个盒子” 是否溢出
  • iframe 里面的页面是否出现滚动条,取决于 iframe 内部的 html/body 或某个容器的 overflow
  • 宿主页面的 CSS 不会跨 document 生效(iframe 天生隔离)

2. 我们最终用了 “两层方案”,解决现实世界的不确定性

实现集中在 src/components/Search/ViewExtensionIframe.tsx:1-88

2.1 第一层:scrolling="no" 作为低成本兜底

<iframe scrolling={hideScrollbar ? "no" : "auto"} />

它不是现代标准,但在某些 WebView/嵌入环境里仍能减少滚动条出现的概率。

它的价值在于:不依赖同源,哪怕你进不去 iframe,也能 “碰一碰运气”。

2.2 第二层(主力):同源时注入一段隐藏滚动条的 CSS

核心是这段逻辑(同文件 applyHideScrollbarToIframe):

  • src/components/Search/ViewExtensionIframe.tsx:5-39

做法很直接:

  1. 拿到 iframe.contentDocument
  2. 往里面塞一个带固定 id<style>
  3. 开关关闭时把这个 <style> 删掉

注入的 CSS 同时覆盖主流引擎:

* {
  scrollbar-width: none;      /* Firefox */
  -ms-overflow-style: none;   /* 老 IE/Edge 风格 */
}
*::-webkit-scrollbar {        /* Chrome/Safari */
  width: 0px;
  height: 0px;
}

为什么用 *

  • 扩展页面的滚动容器不一定是 body,可能是任意 div overflow-auto
  • * 能最大概率“全场隐藏”,更通用

为什么要固定 id

  • 防止重复注入(多次 onLoad / 状态变化)
  • 关闭时能精确移除,保证可逆

3. 为什么要 “onLoad + useEffect” 双触发?

这是最容易漏、也最影响体验的一点。

  • useEffect:响应 hideScrollbar 变化(开关切换时立刻生效)
  • onLoad:保证“首次加载完成后一定注入成功”

原因:iframe 的加载时序不可控。你在 React 渲染完时,iframe 可能还没 ready,contentDocument 为空;等到 onLoad 才能 100% 确认 document 存在。

对应代码:

  • src/components/Search/ViewExtensionIframe.tsx:52-55
  • src/components/Search/ViewExtensionIframe.tsx:78-84

4. 这个方案的边界与坑(提前写清楚,后面少掉头发)

4.1 “隐藏滚动条” 不等于 “不能滚”

我们只隐藏 scrollbar 的视觉表现,滚动行为仍存在(滚轮/触摸板/键盘都能滚)。
这在沉浸式页面很舒服,但在长页面里也可能让用户不知道 “还能滚”。

4.2 跨域 iframe:注入会失败,但不会炸

如果 iframe 加载跨域页面,访问 contentDocument 会触发同源限制。当前实现用 try/catch 静默吞掉异常,结果是:

  • CSS 注入失败
  • 只剩 scrolling="no" 兜底,效果不保证

这不是 bug,是浏览器安全模型决定的。

4.3 全局 * 的副作用

它会把 iframe 内所有滚动条都干掉,包括某些组件内部滚动区域、代码块滚动等。
目前我们选择“通用性优先”,但如果未来某些扩展需要保留局部滚动条,就要改为更精确的选择器策略。


5. 一句话总结

在前端想稳定控制 iframe 滚动条,最靠谱的思路是:

  • 先用 iframe 自身属性做兜底
  • 再在同源条件下对 iframe 内部 document 注入 CSS
  • 用固定 style id 保证幂等与可逆
  • 用 onLoad + effect 解决时序问题

这就是 hideScrollbar 在前端真正解决的问题:不是写没写 CSS,而是有没有把 CSS 写到“对的世界里”。

LangChain Memory 实战指南:让大模型记住你每一句话,轻松打造“有记忆”的AI助手

作者 栀秋666
2025年12月26日 17:15

引言

你有没有遇到过这样的尴尬?
向 AI 提问:“我叫张三”,下一秒再问“我叫什么名字?”——它居然说:“抱歉,我不记得了。” 😅

别怪模型笨,这是所有 LLM 的“先天缺陷”:无状态调用

但今天,我们用 LangChain Memory 给它装上“大脑”,实现真正意义上的多轮对话记忆!

本文将带你从零构建一个会“记事”的智能助手,并深入剖析底层原理、性能瓶颈与生产级优化方案。


🧠 一、为什么你的 AI 总是“金鱼脑”?

1.1 大模型的“失忆症”真相

我们知道,大语言模型(LLM)本质上是一个“黑箱函数”:

response = model(prompt)

每次调用都是独立的 HTTP 请求,没有任何上下文保留机制 —— 就像每次见面都重新认识一遍。

举个例子:

// 第一次对话
await model.invoke("我叫陈昊,喜欢喝白兰地");
// 输出:很高兴认识你,陈昊!看来你喜欢喝白兰地呢~

console.log('------ 分割线 ------');

// 第二次对话
await model.invoke("我叫什么名字?");
// 输出:呃……不太清楚,能告诉我吗?

👉 结果令人崩溃:前脚刚自我介绍,后脚就忘了!

这在实际项目中是不可接受的。无论是客服机器人、教育助手还是个性化推荐系统,都需要记住用户的历史行为和偏好


1.2 解决思路:把“记忆”塞进 Prompt

既然模型不会自己记,那就我们来帮它记

核心思想非常简单:

✅ 每次请求前,把之前的对话历史拼接到当前 prompt 中
✅ 让模型“看到”完整的聊天记录,从而做出连贯回应

这就像是给模型戴上了一副“记忆眼镜”。

而 LangChain 的 Memory 模块,正是对这一过程的高度封装与自动化。


⚙️ 二、LangChain Memory 核心原理拆解

LangChain 提供了一套优雅的 API,让我们可以用几行代码实现“有记忆”的对话系统。

先看最终效果:

User: 我叫陈昊,喜欢喝白兰地
AI: 你好,陈昊!白兰地可是很有品味的选择哦~

User: 我喜欢喝什么酒?
AI: 你说你喜欢喝白兰地呀~是不是准备开一瓶庆祝一下?😉

✅ 成功记住用户名字和饮酒偏好!

下面我们就一步步实现这个功能。


2.1 核心组件一览

组件 作用
ChatMessageHistory 存储对话历史(内存/文件/数据库)
ChatPromptTemplate 定义提示词模板,预留 {history} 占位符
RunnableWithMessageHistory 自动注入历史 + 管理会话生命周期
sessionId 区分不同用户的会话,避免串台

2.2 完整代码实战

// 文件名:memory-demo.js
import { ChatDeepSeek } from "@langchain/deepseek";
import 'dotenv/config';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { RunnableWithMessageHistory } from "@langchain/core/runnables";
import { InMemoryChatMessageHistory } from '@langchain/core/chat_history';

// 1. 初始化模型
const model = new ChatDeepSeek({
    model: 'deepseek-reasoner',
    temperature: 0.3,
});

// 2. 构建带历史的 Prompt 模板
const prompt = ChatPromptTemplate.fromMessages([
    ['system', '你是一个温暖且有记忆的助手,请根据对话历史回答问题'],
    ['placeholder', '{history}'],   // ← 历史消息自动插入这里
    ['human', '{input}']            // ← 当前输入
]);

// 3. 创建可运行链(含调试输出)
const runnable = prompt
    .pipe(input => {
        console.log('\n🔍 最终发送给模型的完整上下文:');
        console.log(JSON.stringify(input, null, 2));
        return input;
    })
    .pipe(model);

// 4. 初始化内存历史存储
const messageHistory = new InMemoryChatMessageHistory();

// 5. 创建带记忆的链
const chain = new RunnableWithMessageHistory({
    runnable,
    getMessageHistory: () => messageHistory,
    inputMessagesKey: 'input',
    historyMessagesKey: 'history'
});

// 6. 开始对话测试
async function testConversation() {
    // 第一轮:告知信息
    const res1 = await chain.invoke(
        { input: "我叫陈昊,喜欢喝白兰地" },
        { configurable: { sessionId: "user_001" } }
    );
    console.log("🤖 回应1:", res1.content);

    // 第二轮:提问历史
    const res2 = await chain.invoke(
        { input: "我叫什么名字?我喜欢喝什么酒?" },
        { configurable: { sessionId: "user_001" } }
    );
    console.log("🤖 回应2:", res2.content);
}

testConversation();

📌 运行结果示例

🤖 回应1: 你好,陈昊!听说你喜欢喝白兰地,真是个有品位的人呢~

🤖 回应2: 你叫陈昊,而且你说你喜欢喝白兰地哦~要不要来点搭配小吃?

🎉 成功!模型不仅记住了名字,还能综合多个信息进行推理回答!


2.3 关键机制图解

              +---------------------+
              |   用户新输入         |
              +----------+----------+
                         |
       +-----------------v------------------+
       |   ChatPromptTemplate              |
       |                                     |
       |   System: 你是有记忆的助手         |
       |   History: [之前的所有对话] ←───────+←─ 从 messageHistory 读取
       |   Human: 我叫什么名字?             |
       +-----------------+------------------+
                         |
                调用模型 → LLM
                         |
           返回响应 ←────+
                         |
       +-----------------v------------------+
       |  自动保存本次交互                  |
       |  user: 我叫什么名字?               |
       |  assistant: 你叫陈昊...            |
       |  写入 InMemoryChatMessageHistory   |
       +------------------------------------+

整个流程全自动闭环,开发者只需关注业务逻辑。


⚠️ 三、真实场景下的三大挑战与破解之道

虽然上面的例子很美好,但在生产环境中你会立刻面临三个“灵魂拷问”:


❌ 挑战1:Token “滚雪球”爆炸增长!

随着对话轮数增加,历史消息越积越多,导致:

  • 单次请求 Token 数飙升
  • 成本翻倍 💸
  • 响应变慢 ⏳
  • 可能超出模型最大长度限制(如 8192)
✅ 解法:选择合适的 Memory 类型
Memory 类型 特点 适用场景
BufferWindowMemory 只保留最近 N 轮 通用对话、短程记忆
ConversationSummaryMemory 自动生成一句话总结 长周期对话、节省 Token
EntityMemory 提取关键实体(人名/偏好) 推荐系统、CRM 助手
示例:使用窗口记忆(保留最近3轮)
import { BufferWindowMemory } from "langchain/memory";

const memory = new BufferWindowMemory({
    k: 3,
    memoryKey: "history"
});

📌 推荐组合:短期细节靠窗口 + 长期特征靠总结


❌ 挑战2:重启服务后历史全丢?!

InMemoryChatMessageHistory 是临时存储,服务一重启,记忆清零。

✅ 解法:持久化到数据库或文件
方案一:本地文件存储(轻量级)
import { FileChatMessageHistory } from "@langchain/core/chat_history";

const getMessageHistory = async (sessionId) => {
    return new FileChatMessageHistory({
        filePath: `./history/${sessionId}.json`
    });
};
方案二:Redis / MongoDB(高并发推荐)
npm install @langchain/redis
import { RedisChatMessageHistory } from "@langchain/redis";

const getMessageHistory = async (sessionId) => {
    return new RedisChatMessageHistory({
        sessionId,
        client: redisClient // 已连接的 Redis 客户端
    });
};

💡 生产环境强烈建议使用 Redis:高性能、支持过期策略、分布式部署无忧。


❌ 挑战3:多人同时聊天会不会串消息?

当然会!如果所有用户共用同一个 messageHistory,就会出现 A 用户看到 B 用户的对话。

✅ 解法:用 sessionId 隔离会话
await chain.invoke(
    { input: "我饿了" },
    { configurable: { sessionId: "user_123" } }  // 每个用户唯一 ID
)

await chain.invoke(
    { input: "我饿了" },
    { configurable: { sessionId: "user_456" } }  // 不同用户,不同历史
)

✅ 安全隔离,互不干扰!


🛠️ 四、Memory 的典型应用场景(附案例灵感)

场景 如何使用 Memory
客服机器人 记住订单号、投诉进度、用户情绪变化
教育辅导 追踪学习章节、错题记录、掌握程度
电商导购 记住预算、品牌偏好、尺码需求
编程助手 保持代码上下文、函数定义、项目结构
心理咨询 理解用户情绪演变、关键事件回顾

💡 创新玩法:结合 SummaryMemory + VectorDB,实现“长期人格记忆”——让 AI 记住你是内向还是外向、喜欢幽默还是严谨。


📘 五、高频面试题(LangChain 方向)

  1. LLM 为什么需要 Memory?它的本质是什么?
  2. Buffer vs Summary Memory 的区别?什么时候用哪种?
  3. 如何设计一个支持百万用户在线的记忆系统架构?
  4. 如何防止敏感信息被 Memory 记录?(安全考量)

📝 结语:让 AI 真正“懂你”,从一次对话记忆开始

“智能不是回答问题的能力,而是理解上下文的艺术。”

LangChain Memory 虽然只是整个 LLM 应用中的一个小模块,但它却是通往拟人化交互的关键一步。

从“无状态”到“有记忆”,我们不只是在写代码,更是在塑造一种新的沟通方式。

未来属于那些能让 AI 记住你、理解你、陪伴你的产品。

而现在,你已经有了打造它的钥匙。


从 iframe 到 Shadow DOM:一次关于「隔离」的前端边界思考

作者 shanLion
2025年12月26日 17:02

一、问题背景:我到底想解决什么?

在复杂前端系统中,我们经常会遇到这样的需求:

  • 页面需要嵌入第三方内容 / 子系统

  • 希望 样式、脚本互不影响

  • 同时又要保证:

    • 正常渲染
    • 合理交互
    • 可控的安全边界

常见方案是 iframe,但一旦深入使用,就会立刻遇到一系列问题:

  • Cookie 是否会被注入?
  • JS 能否互相访问?
  • 样式是否会污染?
  • sandbox 一加,页面怎么直接不显示了?
  • 不加 allow-same-origin 又为什么什么都“坏了”?

这篇文章,就是围绕这些真实问题展开。


二、iframe 的本质:浏览器级别的“硬隔离”

1️⃣ iframe 是什么?

从浏览器角度看,iframe 并不是一个普通 DOM,而是:

一个完整的、独立的浏览上下文(Browsing Context)

它拥有自己的:

  • DOM 树
  • JS 执行环境
  • CSS 作用域
  • Cookie / Storage(是否共享取决于策略)

这也是它“隔离性强”的根本原因。


2️⃣ iframe 天生适合做什么?

  • 微前端子应用
  • 第三方内容嵌入
  • 不可信页面展示
  • 强安全边界场景

它解决的是“不信任”的问题,而不是“优雅”的问题。


三、sandbox:安全从这里开始,也从这里失控

1️⃣ sandbox 到底干了什么?

当你给 iframe 加上:

<iframe sandbox></iframe>

你相当于对它说:

“你什么都不能干。”

包括但不限于:

  • ❌ JS 不执行
  • ❌ 表单提交被禁
  • ❌ 同源身份被剥夺
  • ❌ Cookie / localStorage 全部隔离

2️⃣ 为什么加了 sandbox,页面直接空白?

因为很多页面:

  • 依赖 JS 渲染
  • 依赖同源读取资源
  • 依赖 Cookie 维持状态

一旦 sandbox 默认限制生效,页面逻辑上还能加载,但功能全废


3️⃣ allow-scripts + allow-same-origin 为什么危险?

sandbox="allow-scripts allow-same-origin"

这是一个经典陷阱组合

原因是:

  • allow-scripts:允许 JS 执行
  • allow-same-origin:恢复同源身份

⚠️ 一旦两者同时存在:

iframe 内的 JS 可以认为自己是“正常页面”

从规范角度,它已经具备了逃逸 sandbox 的能力

这也是 MDN 明确标注的安全风险。


四、那我只是不想 Cookie 被注入,怎么办?

❌ 错误直觉

“去掉 allow-same-origin 就好了”

结果是:

  • JS 取不到任何资源
  • 页面渲染失败
  • iframe 内容直接消失

✅ 正确理解

Cookie 注入的本质是:

  • 同源 + 凭证传递

控制 Cookie 的正确方式是:

  • SameSite
  • HttpOnly
  • Secure
  • 服务端鉴权策略

而不是指望 iframe sandbox 去“顺便解决”。


五、Shadow DOM:另一种完全不同的“隔离”

1️⃣ Shadow DOM 隔离的是什么?

Shadow DOM 隔离的是:

  • 样式作用域
  • DOM 结构可见性

但它:

  • ❌ 不隔离 JS
  • ❌ 不隔离 Cookie
  • ❌ 不隔离安全上下文

它解决的是:

组件级的可维护性问题

而不是安全问题。


2️⃣ iframe vs Shadow DOM 对比

维度 iframe Shadow DOM
样式隔离 ✅ 强 ✅ 中
JS 隔离 ✅ 强
安全边界
通信成本
性能 较重
使用复杂度

一句话总结:

iframe 是安全隔离工具
Shadow DOM 是工程隔离工具

解锁移动端H5调试:Eruda & VConsole 实战指南

作者 CRAB
2025年12月26日 17:00

在PC端,我们有Chrome DevTools。但在微信小程序或App的WebView中开发H5时,F12瞬间失灵,调试如同“盲人摸象”。本文将为你深度剖析两大神器——VConsoleEruda,助你轻松攻克移动端H5调试难题。

前言:为何我们需要它们?

  1. 环境封闭:小程序的 web-view和 App 的 WebView 通常不开放完整的DevTools权限。
  2. 真机差异:PC模拟环境与真实手机在渲染、性能、API支持上存在鸿沟。
  3. 网络与设备:需要查看在特定网络(4G/5G)和设备下的表现。
  4. 快速定位console.log和网络请求详情是定位问题的生命线。

两大神器简介与核心对比

特性 Eruda 🛠️ VConsole 📱
定位 专业、全功能“仪表盘” 轻量、便捷“口袋工具”
出品方 Liriliri (独立开发者) 微信团队
功能丰富度 ★★★★★ (Console, Elements, Network, Resources, Snippets...) ★★★★☆ (Console, Network, Element, Storage)
易用性 ★★★☆☆ (需简单学习,模块化) ★★★★★ (开箱即用,零成本)
性能/体积 相对较重 (~180KB gzipped) 非常轻量 (~45KB gzipped)
推荐场景 复杂应用、深度调试、专业开发者 快速迭代、微信生态、新手、轻量需求

一句话总结:追求深度和全面,选 Eruda;追求简单和快捷,尤其在微信环境,选 VConsole。


实战篇:手把手教你集成

1. Eruda 集成与使用

第1步:引入脚本 (务必按需加载!)

<!-- 推荐:通过URL参数 ?eruda=true 或开发环境判断来加载 -->
<script>
  if (/localhost|127.0.0.1|dev/i.test(window.location.hostname) || /eruda=true/.test(window.location.search)) {
    var script = document.createElement('script');
    script.src = '//cdn.jsdelivr.net/npm/eruda';
    script.onload = function () { eruda.init(); };
    document.body.appendChild(script);
  }
</script>

第2步:初始化与配置 (可选)

eruda.init({
  tool: ['console', 'elements', 'network'], // 只启用需要的工具
  autoScale: true,
});

第3步:效果一览

点击右下角 Logo 即可唤出功能强大的控制台。

929F667A-F134-4B5D-9C5A-387209BADB96_1_201_a.jpeg

2. VConsole 集成与使用

第1步:引入脚本 (同样按需加载!)

<!-- 推荐:通过环境变量或域名判断来加载 -->
<script>
  if (/localhost|127.0.0.1|dev/i.test(window.location.hostname)) {
    document.write('<script src="https://unpkg.com/vconsole/dist/vconsole.min.js"></script>');
    document.write('<script>new VConsole();</script>');
  }
</script>

第2步:初始化与配置 (可选)

const vConsole = new VConsole({
  defaultPlugins: ['system', 'network', 'element'],
  onReady: function() { console.log('VConsole 已就绪!'); }
});

第3步:效果一览

点击右下角绿色 “V” 图标即可打开简洁的调试面板。

BDDE4554-869F-49A3-AE5C-E78FF43B6E1D_1_201_a.jpeg


最佳实践与避坑指南

  1. 安全第一:切勿发布到生产环境! 使用环境变量或域名/IP白名单严格控制加载,是每位前端工程师的基本素养。

  2. 动态开启:给测试和产品同学开个“后门” 使用 URL 参数(如 ?debug=true)控制,无需重新发版即可开启调试。

  3. VConsole 进阶

    • vConsole.showSwitch()可以显示一个手动切换面板的开关。
    • 可以自定义插件,扩展其功能。
  4. Eruda 进阶

    • 使用 eruda.add(erudaPerformance)动态添加性能监控插件。
    • 通过 eruda.settings.set()动态调整设置。

总结

工欲善其事,必先利其器。对于移动端H5开发者而言,VConsoleEruda 就是那把最锋利的“器”。

  • 日常快速调试、微信H5开发,VConsole 是你的不二之选。
  • 面对复杂Bug、需要进行深度性能分析,Eruda 的强大功能会让你事半功倍。

希望这篇指南能帮助你彻底摆脱调试困境,在移动端H5的世界里游刃有余!欢迎在评论区分享你的使用心得和遇到的问题。

Vue2/Vue3 迁移头秃?Renderless 架构让组件 “无缝穿梭”

2025年12月26日 16:57

本文由体验技术团队刘坤原创。

"一次编写,到处运行" —— 这不是 Java 的专利,也是 Renderless 架构的座右铭!

开篇:什么是 Renderless 架构?

🤔 传统组件的困境

想象一下,你写了一个超棒的 Vue 3 组件:

<!-- MyAwesomeComponent.vue -->
<template>
  <div>
    <button @click="handleClick">{{ count }}</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const count = ref(0)
const handleClick = () => {
  count.value++
}
</script>

问题来了:这个组件只能在 Vue 3 中使用!如果你的项目是 Vue 2,或者你需要同时支持 Vue 2 和 Vue 3,怎么办?

✨ Renderless 的解决方案

Renderless 架构将组件拆分成三个部分:

┌─────────────────────────────────────────┐
|             模板层(pc.vue)             |
|         "我只负责展示,不关心逻辑"        |
└─────────────────────────────────────────┘
              ↕️
┌─────────────────────────────────────────┐
│         逻辑层(renderless.ts)          │
│       "我是大脑,处理所有业务逻辑"         │
└─────────────────────────────────────────┘
              ↕️
┌─────────────────────────────────────────┐
│            入口层 (index.ts)           │
│         "我是门面,统一对外接口"          │
└─────────────────────────────────────────┘

核心思想:将 UI(模板)和逻辑(业务代码)完全分离,逻辑层使用 Vue 2 和 Vue 3 都兼容的 API。

📊 为什么需要 Renderless?

特性 传统组件 Renderless 组件
Vue 2 支持
Vue 3 支持
逻辑复用 困难 简单
测试友好 一般 优秀
代码组织 耦合 解耦

🎯 适用场景

  • ✅ 需要同时支持 Vue 2 和 Vue 3 的组件库
  • ✅ 逻辑复杂,需要模块化管理的组件
  • ✅ 需要多端适配的组件(PC、移动端、小程序等)
  • ✅ 需要高度可测试性的组件

第一步:理解 @opentiny/vue-common(必须先掌握)

⚠️ 重要提示:为什么必须先学习 vue-common?

在学习 Renderless 架构之前,你必须先理解 @opentiny/vue-common,因为:

  1. 它是基础工具:Renderless 架构完全依赖 vue-common 提供的兼容层
  2. 它是桥梁:没有 vue-common,就无法实现 Vue 2/3 的兼容
  3. 它是前提:不理解 vue-common,就无法理解 Renderless 的工作原理

打个比方vue-common 就像是你学开车前必须先了解的"方向盘、刹车、油门",而 Renderless 是"如何驾驶"的技巧。没有基础工具,再好的技巧也无法施展!

🤔 为什么需要 vue-common?

想象一下,Vue 2 和 Vue 3 就像两个说不同方言的人:

  • Vue 2this.$refs.inputthis.$emit('event')Vue.component()
  • Vue 3refs.inputemit('event')defineComponent()

如果你要同时支持两者,难道要写两套代码吗?当然不! 这就是 @opentiny/vue-common 存在的意义。

✨ vue-common 是什么?

@opentiny/vue-common 是一个兼容层库,它:

  1. 统一 API:提供一套统一的 API,自动适配 Vue 2 和 Vue 3
  2. 隐藏差异:让你无需关心底层是 Vue 2 还是 Vue 3
  3. 类型支持:提供完整的 TypeScript 类型定义

简单来说vue-common 是一个"翻译官",它让 Vue 2 和 Vue 3 能够"说同一种语言"。

🛠️ 核心 API 详解

1. defineComponent - 组件定义的统一入口

import { defineComponent } from '@opentiny/vue-common'

// 这个函数在 Vue 2 和 Vue 3 中都能工作
export default defineComponent({
  name: 'MyComponent',
  props: { ... },
  setup() { ... }
})

工作原理

  • Vue 2:内部使用 Vue.extend()Vue.component()
  • Vue 3:直接使用 Vue 3 的 defineComponent()
  • 你只需要写一套代码,vue-common 会自动选择正确的实现

2. setup - 连接 Renderless 的桥梁

import { setup } from '@opentiny/vue-common'

// 在 pc.vue 中
setup(props, context) {
  return setup({ props, context, renderless, api })
}

工作原理

  • 接收 renderless 函数和 api 数组
  • 自动处理 Vue 2/3 的差异(如 emitslotsrefs 等)
  • renderless 返回的 api 对象注入到模板中

关键点

// vue-common 内部会做类似这样的处理:
function setup({ props, context, renderless, api }) {
  // Vue 2: context 包含 { emit, slots, attrs, listeners }
  // Vue 3: context 包含 { emit, slots, attrs, expose }

  // 统一处理差异
  const normalizedContext = normalizeContext(context)

  // 调用 renderless
  const apiResult = renderless(props, hooks, normalizedContext)

  // 返回给模板使用
  return apiResult
}

3. $props - 通用 Props 定义

import { $props } from '@opentiny/vue-common'

export const myComponentProps = {
  ...$props, // 继承通用 props
  title: String
}

提供的基础 Props

  • tiny_mode:组件模式(pc/saas)
  • customClass:自定义类名
  • customStyle:自定义样式
  • 等等...

好处

  • 所有组件都有统一的 props 接口
  • 减少重复代码
  • 保证一致性

4. $prefix - 组件名前缀

import { $prefix } from '@opentiny/vue-common'

export default defineComponent({
  name: $prefix + 'SearchBox' // 自动变成 'TinySearchBox'
})

作用

  • 统一组件命名规范
  • 避免命名冲突
  • 便于识别组件来源

5. isVue2 / isVue3 - 版本检测

import { isVue2, isVue3 } from '@opentiny/vue-common'

if (isVue2) {
  // Vue 2 特定代码
  console.log('运行在 Vue 2 环境')
} else if (isVue3) {
  // Vue 3 特定代码
  console.log('运行在 Vue 3 环境')
}

使用场景

  • 需要针对特定版本做特殊处理时
  • 调试和日志记录
  • 兼容性检查

🔍 深入理解:vue-common 如何实现兼容?

场景 1:响应式 API 兼容

// 在 renderless.ts 中
export const renderless = (props, hooks, context) => {
  const { reactive, computed, watch } = hooks

  // 这些 hooks 来自 vue-common 的兼容层
  // Vue 2: 使用 @vue/composition-api 的 polyfill
  // Vue 3: 直接使用 Vue 3 的原生 API

  const state = reactive({ count: 0 })
  const double = computed(() => state.count * 2)

  watch(
    () => state.count,
    (newVal) => {
      console.log('count changed:', newVal)
    }
  )
}

兼容原理

  • Vue 2:vue-common 内部使用 @vue/composition-api 提供 Composition API
  • Vue 3:直接使用 Vue 3 的原生 API
  • 对开发者透明,无需关心底层实现

场景 2:Emit 兼容

export const renderless = (props, hooks, { emit }) => {
  const handleClick = () => {
    // vue-common 会自动处理 Vue 2/3 的差异
    emit('update:modelValue', newValue)
    emit('change', newValue)
  }
}

兼容原理

// vue-common 内部处理(简化版)
function normalizeEmit(emit, isVue2) {
  if (isVue2) {
    // Vue 2: emit 需要特殊处理
    return function (event, ...args) {
      // 处理 Vue 2 的事件格式
      this.$emit(event, ...args)
    }
  } else {
    // Vue 3: 直接使用
    return emit
  }
}

场景 3:Refs 访问兼容

export const renderless = (props, hooks, { vm }) => {
  const focusInput = () => {
    // vue-common 提供了统一的访问方式
    const inputRef = vm?.$refs?.inputRef || vm?.refs?.inputRef
    inputRef?.focus()
  }
}

兼容原理

  • Vue 2:vm.$refs.inputRef
  • Vue 3:vm.refs.inputRef
  • vue-common 提供统一的访问方式,自动适配

📊 vue-common 提供的常用 API 列表

API 作用 Vue 2 实现 Vue 3 实现
defineComponent 定义组件 Vue.extend() defineComponent()
setup 连接 renderless Composition API polyfill 原生 setup
$props 通用 props 对象展开 对象展开
$prefix 组件前缀 字符串常量 字符串常量
isVue2 Vue 2 检测 true false
isVue3 Vue 3 检测 false true

🎯 使用 vue-common 的最佳实践

✅ DO(推荐)

  1. 始终使用 vue-common 提供的 API
    // ✅ 好
    import { defineComponent, setup } from '@opentiny/vue-common'
    
    // ❌ 不好
    import { defineComponent } from 'vue' // 这样只能在 Vue 3 中使用
    
  2. 使用 $props 继承通用属性
    // ✅ 好
    export const props = {
      ...$props,
      customProp: String
    }
    
  3. 使用 $prefix 统一命名
    // ✅ 好
    name: $prefix + 'MyComponent'
    

❌ DON'T(不推荐)

  1. 不要直接使用 Vue 2/3 的原生 API
    // ❌ 不好
    import Vue from 'vue' // 只能在 Vue 2 中使用
    import { defineComponent } from 'vue' // 只能在 Vue 3 中使用
    
  2. 不要硬编码组件名前缀
    // ❌ 不好
    name: 'TinyMyComponent' // 硬编码前缀
    
    // ✅ 好
    name: $prefix + 'MyComponent' // 使用变量
    

🔗 总结

@opentiny/vue-common 是 Renderless 架构的基石

  • 🎯 目标:让一套代码在 Vue 2 和 Vue 3 中都能运行
  • 🛠️ 手段:提供统一的 API 和兼容层
  • 结果:开发者无需关心底层差异,专注于业务逻辑

记住:使用 Renderless 架构时,必须使用 vue-common 提供的 API,这是实现跨版本兼容的关键!

🎓 学习检查点

在继续学习之前,请确保你已经理解:

  • defineComponent 的作用和用法
  • setup 函数如何连接 renderless
  • $props$prefix 的用途
  • vue-common 如何实现 Vue 2/3 兼容

如果你对以上内容还有疑问,请重新阅读本节。理解 vue-common 是学习 Renderless 的前提!

第二步:核心概念 - 三大文件

现在你已经理解了 vue-common,我们可以开始学习 Renderless 架构的核心了!

📋 文件结构

一个标准的 Renderless 组件包含三个核心文件:

my-component/
├── index.ts          # 入口文件:定义组件和 props
├── pc.vue            # 模板文件:只负责 UI 展示
└── renderless.ts     # 逻辑文件:处理所有业务逻辑

1. 三大核心文件详解

📄 index.ts - 组件入口

import { $props, $prefix, defineComponent } from '@opentiny/vue-common'
import template from './pc.vue'

// 定义组件的 props
export const myComponentProps = {
  ...$props, // 继承通用 props
  title: {
    type: String,
    default: 'Hello'
  },
  count: {
    type: Number,
    default: 0
  }
}

// 导出组件
export default defineComponent({
  name: $prefix + 'MyComponent', // 自动添加前缀
  props: myComponentProps,
  ...template // 展开模板配置
})

关键点

  • $props:提供 Vue 2/3 兼容的基础 props
  • $prefix:统一的组件名前缀(如 Tiny
  • defineComponent:兼容 Vue 2/3 的组件定义函数

🎨 pc.vue - 模板文件

<template>
  <div class="my-component">
    <h1>{{ title }}</h1>
    <button @click="handleClick">点击了 {{ count }} 次</button>
    <p>{{ message }}</p>
  </div>
</template>

<script lang="ts">
import { defineComponent, setup, $props } from '@opentiny/vue-common'
import { renderless, api } from './renderless'

export default defineComponent({
  props: {
    ...$props,
    title: String,
    count: Number
  },
  setup(props, context) {
    // 关键:通过 setup 函数连接 renderless
    return setup({ props, context, renderless, api })
  }
})
</script>

关键点

  • 模板只负责 UI 展示
  • 所有逻辑都从 renderless 函数获取
  • setup 函数是连接模板和逻辑的桥梁

🧠 renderless.ts - 逻辑层

// 定义暴露给模板的 API
export const api = ['count', 'message', 'handleClick']

// 初始化状态
const initState = ({ reactive, props }) => {
  const state = reactive({
    count: props.count || 0,
    message: '欢迎使用 Renderless 架构!'
  })
  return state
}

// 核心:renderless 函数
export const renderless = (props, { reactive, computed, watch, onMounted }, { emit, nextTick, vm }) => {
  const api = {} as any
  const state = initState({ reactive, props })

  // 定义方法
  const handleClick = () => {
    state.count++
    emit('update:count', state.count)
  }

  // 计算属性
  const message = computed(() => {
    return `你已经点击了 ${state.count} 次!`
  })

  // 生命周期
  onMounted(() => {
    console.log('组件已挂载')
  })

  // 暴露给模板
  Object.assign(api, {
    count: state.count,
    message,
    handleClick
  })

  return api
}

关键点

  • api 数组:声明要暴露给模板的属性和方法
  • renderless 函数接收三个参数:
    1. props:组件属性
    2. hooks:Vue 的响应式 API(reactive, computed, watch 等)
    3. context:上下文(emit, nextTick, vm 等)
  • 返回的 api 对象会被注入到模板中

第三步:实战演练 - 从零开始改造组件

现在你已经掌握了:

  • vue-common 的核心 API
  • ✅ Renderless 架构的三大文件

让我们通过一个完整的例子,将理论知识转化为实践!

🎯 目标

将一个简单的计数器组件改造成 Renderless 架构,支持 Vue 2 和 Vue 3。

📝 步骤 1:创建文件结构

my-counter/
├── index.ts          # 入口文件
├── pc.vue            # 模板文件
└── renderless.ts     # 逻辑文件

📝 步骤 2:编写入口文件

// index.ts
import { $props, $prefix, defineComponent } from '@opentiny/vue-common'
import template from './pc.vue'

export const counterProps = {
  ...$props,
  initialValue: {
    type: Number,
    default: 0
  },
  step: {
    type: Number,
    default: 1
  }
}

export default defineComponent({
  name: $prefix + 'Counter',
  props: counterProps,
  ...template
})

📝 步骤 3:编写逻辑层

// renderless.ts
export const api = ['count', 'increment', 'decrement', 'reset', 'isEven']

const initState = ({ reactive, props }) => {
  return reactive({
    count: props.initialValue || 0
  })
}

export const renderless = (props, { reactive, computed, watch }, { emit, vm }) => {
  const api = {} as any
  const state = initState({ reactive, props })

  // 增加
  const increment = () => {
    state.count += props.step
    emit('change', state.count)
  }

  // 减少
  const decrement = () => {
    state.count -= props.step
    emit('change', state.count)
  }

  // 重置
  const reset = () => {
    state.count = props.initialValue || 0
    emit('change', state.count)
  }

  // 计算属性:是否为偶数
  const isEven = computed(() => {
    return state.count % 2 === 0
  })

  // 监听 count 变化
  watch(
    () => state.count,
    (newVal, oldVal) => {
      console.log(`计数从 ${oldVal} 变为 ${newVal}`)
    }
  )

  // 暴露 API
  Object.assign(api, {
    count: state.count,
    increment,
    decrement,
    reset,
    isEven
  })

  return api
}

📝 步骤 4:编写模板

<!-- pc.vue -->
<template>
  <div class="tiny-counter">
    <div class="counter-display">
      <span :class="{ 'even': isEven, 'odd': !isEven }">
        {{ count }}
      </span>
      <small v-if="isEven">(偶数)</small>
      <small v-else>(奇数)</small>
    </div>

    <div class="counter-buttons">
      <button @click="decrement">-</button>
      <button @click="reset">重置</button>
      <button @click="increment">+</button>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent, setup, $props } from '@opentiny/vue-common'
import { renderless, api } from './renderless'

export default defineComponent({
  props: {
    ...$props,
    initialValue: Number,
    step: Number
  },
  emits: ['change'],
  setup(props, context) {
    return setup({ props, context, renderless, api })
  }
})
</script>

<style scoped>
.tiny-counter {
  padding: 20px;
  border: 1px solid #ddd;
  border-radius: 8px;
  text-align: center;
}

.counter-display {
  font-size: 48px;
  margin-bottom: 20px;
}

.counter-display .even {
  color: green;
}

.counter-display .odd {
  color: blue;
}

.counter-buttons button {
  margin: 0 5px;
  padding: 10px 20px;
  font-size: 18px;
  cursor: pointer;
}
</style>

🎉 完成!

现在这个组件可以在 Vue 2 和 Vue 3 中无缝使用了!

<!-- Vue 2 或 Vue 3 都可以 -->
<template>
  <tiny-counter :initial-value="10" :step="2" @change="handleChange" />
</template>

第四步:进阶技巧

恭喜你!如果你已经完成了实战演练,说明你已经掌握了 Renderless 架构的基础。现在让我们学习一些进阶技巧,让你的组件更加优雅和强大。

1. 模块化:使用 Composables

当逻辑变得复杂时,可以将功能拆分成多个 composables:

// composables/use-counter.ts
export function useCounter({ state, props, emit }) {
  const increment = () => {
    state.count += props.step
    emit('change', state.count)
  }

  const decrement = () => {
    state.count -= props.step
    emit('change', state.count)
  }

  return { increment, decrement }
}

// composables/use-validation.ts
export function useValidation({ state }) {
  const isEven = computed(() => state.count % 2 === 0)
  const isPositive = computed(() => state.count > 0)

  return { isEven, isPositive }
}

// renderless.ts
import { useCounter } from './composables/use-counter'
import { useValidation } from './composables/use-validation'

export const renderless = (props, hooks, context) => {
  const api = {} as any
  const state = initState({ reactive, props })

  // 使用 composables
  const { increment, decrement } = useCounter({ state, props, emit })
  const { isEven, isPositive } = useValidation({ state })

  Object.assign(api, {
    count: state.count,
    increment,
    decrement,
    isEven,
    isPositive
  })

  return api
}

2. 访问组件实例(vm)

有时候需要访问组件实例,比如获取 refs:

export const renderless = (props, hooks, { vm }) => {
  const api = {} as any

  const focusInput = () => {
    // Vue 2: vm.$refs.inputRef
    // Vue 3: vm.refs.inputRef
    const inputRef = vm?.$refs?.inputRef || vm?.refs?.inputRef
    if (inputRef) {
      inputRef.focus()
    }
  }

  // 存储 vm 到 state,方便在模板中使用
  state.instance = vm

  return api
}

3. 处理 Slots

在 Vue 2 中,slots 的访问方式不同:

export const renderless = (props, hooks, { vm, slots }) => {
  const api = {} as any
  const state = initState({ reactive, props })

  // 存储 vm 和 slots
  state.instance = vm

  // Vue 2 中需要手动设置 slots
  if (vm && slots) {
    vm.slots = slots
  }

  return api
}

在模板中检查 slot:

<template>
  <div v-if="state.instance?.$slots?.default || state.instance?.slots?.default">
    <slot></slot>
  </div>
</template>

4. 生命周期处理

export const renderless = (props, hooks, context) => {
  const { onMounted, onBeforeUnmount, onUpdated } = hooks

  // 组件挂载后
  onMounted(() => {
    console.log('组件已挂载')
    // 添加事件监听
    document.addEventListener('click', handleDocumentClick)
  })

  // 组件更新后
  onUpdated(() => {
    console.log('组件已更新')
  })

  // 组件卸载前
  onBeforeUnmount(() => {
    console.log('组件即将卸载')
    // 清理事件监听
    document.removeEventListener('click', handleDocumentClick)
  })

  return api
}

5. 使用Watch监听

export const renderless = (props, hooks, context) => {
  const { watch } = hooks

  // 监听单个值
  watch(
    () => state.count,
    (newVal, oldVal) => {
      console.log(`count 从 ${oldVal} 变为 ${newVal}`)
    }
  )

  // 监听多个值
  watch([() => state.count, () => props.step], ([newCount, newStep], [oldCount, oldStep]) => {
    console.log('count 或 step 发生了变化')
  })

  // 深度监听对象
  watch(
    () => state.user,
    (newUser) => {
      console.log('user 对象发生了变化', newUser)
    },
    { deep: true }
  )

  // 立即执行
  watch(
    () => props.initialValue,
    (newVal) => {
      state.count = newVal
    },
    { immediate: true }
  )

  return api
}

常见问题与解决方案

❓ 问题 1:为什么我的响应式数据不更新?

原因:在 renderless 中,需要将响应式数据暴露到 api 对象中。

// ❌ 错误:直接返回 state
Object.assign(api, {
  state // 这样模板无法访问 state.count
})

// ✅ 正确:展开 state 或明确暴露属性
Object.assign(api, {
  count: state.count, // 明确暴露
  message: state.message
})

// 或者使用 computed
const count = computed(() => state.count)
Object.assign(api, {
  count // 使用 computed 包装
})

❓ 问题 2:如何在模板中访问组件实例?

解决方案:将 vm 存储到 state 中。

export const renderless = (props, hooks, { vm }) => {
  const state = initState({ reactive, props })
  state.instance = vm // 存储实例

  return api
}

在模板中:

<template>
  <div>
    <!-- 访问 refs -->
    <input ref="inputRef" />
    <button @click="focusInput">聚焦</button>
  </div>
</template>
const focusInput = () => {
  const inputRef = state.instance?.$refs?.inputRef || state.instance?.refs?.inputRef
  inputRef?.focus()
}

❓ 问题 3:Vue 2 和 Vue 3 的 emit 有什么区别?

解决方案:使用 @opentiny/vue-common 提供的兼容层。

export const renderless = (props, hooks, { emit: $emit }) => {
  // 兼容处理
  const emit = props.emitter ? props.emitter.emit : $emit

  const handleClick = () => {
    // 直接使用 emit,兼容层会处理差异
    emit('update:modelValue', newValue)
    emit('change', newValue)
  }

  return api
}

❓ 问题 4:如何处理异步操作?

解决方案:使用 nextTick 确保 DOM 更新。

export const renderless = (props, hooks, { nextTick }) => {
  const handleAsyncUpdate = async () => {
    // 执行异步操作
    const data = await fetchData()
    state.data = data

    // 等待 DOM 更新
    await nextTick()

    // 此时可以安全地操作 DOM
    const element = state.instance?.$el || state.instance?.el
    if (element) {
      element.scrollIntoView()
    }
  }

  return api
}

❓ 问题 5:如何调试 Renderless 组件?

技巧

  1. 使用 console.log
export const renderless = (props, hooks, context) => {
  console.log('Props:', props)
  console.log('State:', state)
  console.log('Context:', context)

  // 在关键位置添加日志
  const handleClick = () => {
    console.log('Button clicked!', state.count)
    // ...
  }

  return api
}
  1. 使用 Vue DevTools
    • 在模板中添加调试信息
    • 使用 state 存储调试数据
  2. 断点调试
    • renderless.ts 中设置断点
    • 检查 api 对象的返回值

最佳实践

✅ DO(推荐做法)

  1. 模块化组织代码
    src/
    ├── index.ts
    ├── pc.vue
    ├── renderless.ts
    ├── composables/
    │   ├── use-feature1.ts
    │   └── use-feature2.ts
    └── utils/
        └── helpers.ts
    
  2. 明确声明 API
    // 在文件顶部声明所有暴露的 API
    export const api = ['count', 'increment', 'decrement', 'isEven']
    
  3. 使用 TypeScript
    interface State {
      count: number
      message: string
    }
    
    const initState = ({ reactive, props }): State => {
      return reactive({
        count: props.initialValue || 0,
        message: 'Hello'
      })
    }
    
  4. 处理边界情况
    const handleClick = () => {
      if (props.disabled) {
        return // 提前返回
      }
    
      try {
        // 业务逻辑
      } catch (error) {
        console.error('Error:', error)
        emit('error', error)
      }
    }
    

❌ DON'T(不推荐做法)

  1. 不要在模板中写逻辑

    <!-- ❌ 不好 -->
    <template>
      <div>{{ count + 1 }}</div>
    </template>
    
    <!-- ✅ 好 -->
    <template>
      <div>{{ nextCount }}</div>
    </template>
    
    const nextCount = computed(() => state.count + 1)
    
  2. 不要直接修改 props

    // ❌ 不好
    props.count++ // 不要这样做!
    
    // ✅ 好
    state.count = props.count + 1
    emit('update:count', state.count)
    
  3. 不要忘记清理资源

    // ❌ 不好
    onMounted(() => {
      document.addEventListener('click', handler)
      // 忘记清理
    })
    
    // ✅ 好
    onMounted(() => {
      document.addEventListener('click', handler)
    })
    
    onBeforeUnmount(() => {
      document.removeEventListener('click', handler)
    })
    

🎓 总结

Renderless 架构的核心思想是关注点分离

  • 模板层:只负责 UI 展示
  • 逻辑层:处理所有业务逻辑
  • 入口层:统一对外接口

通过这种方式,我们可以:

  • ✅ 同时支持 Vue 2 和 Vue 3
  • ✅ 提高代码的可维护性
  • ✅ 增强代码的可测试性
  • ✅ 实现逻辑的模块化复用

🚀 下一步

  1. 查看 @opentiny/vue-search-box 的完整源码
  2. 尝试改造自己的组件
  3. 探索更多高级特性

📚 参考资源

Happy Coding! 🎉

记住:Renderless 不是魔法,而是一种思维方式。当你理解了它,你会发现,原来组件可以这样写!

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyVue 源码:github.com/opentiny/ti…
TinyEngine 源码: github.com/opentiny/ti…
欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor~ 如果你也想要共建,可以进入代码仓库,找到 good first issue 标签,一起参与开源贡献~

二叉搜索树:让数据在有序中生长的智慧之树

作者 鱼鱼块
2025年12月26日 16:45
二叉搜索树:让数据有序生长的智慧树 想象一下,你正在整理一个家庭相册。你会把年代久远的照片放在左边,近几年的照片放在右边,每一张照片都可以按照时间顺序快速找到——这就是二叉搜索树的思想。它是一种让数据
❌
❌