普通视图
别再让 AI 直接写页面了:一种更稳的中后台开发方式
本文讨论的不是 Demo 级别的 AI 编码体验,而是面向真实团队、长期维护的中后台工程实践。
AI 能写代码,但不意味着它适合直接“产出页面”。
最近一年,大模型在前端领域的讨论几乎都围绕一个问题:
“能不能让 AI 直接把页面写出来?”
在真实的中后台项目中,我的答案是:
不但不稳,而且很危险。
这篇文章想分享一种我在真实项目中实践过、可长期使用、可规模化的方式:
不是让 AI 写页面,而是把 AI 纳入中后台前端的工程体系中。
把 AI 的不确定性关进了笼子里,用工程流程保证可控性。
模板固化规范,Spec 描述变化,大模型生成 Spec,脚本生成代码,lint/test 做兜底。 它解决了 AI 上工程最致命的四件事:
- 可审计:变化在 spec,生成结果可 diff
- 可重复:同一个 spec 反复生成结果一致
- 可兜底:lint/test 是硬门槛
- 可规模化:从 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 直接写页面,让它写“变化”,其余交给工程。
如果你觉得这篇文章对你有启发,欢迎点赞或收藏 👍
Flutter组件封装:视频播放组件全局封装
一、需求来源
最近要开发一个在线视频播放的功能,每次实时获取播放,有些卡顿和初始化慢的问题,就随手优化一下。
二、使用示例
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 实现最大缓存数控制,防止内存爆炸。
我接入了微信小说小程序官方阅读器
概述
微信官方为小说类小程序提供了专用的阅读器插件,所有小说类目的小程序都必须使用该组件。
快速开始
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 实践指南
让大语言模型拥有“记忆”:多轮对话与 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/deepseek 和 RunnableWithMessageHistory 的完整示例:
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下的所有交互共享同一段历史。
四、进阶思考:如何优化长对话的记忆效率?
虽然内存存储简单易用,但在生产环境中,面对海量用户和长期对话,我们需要更高效的策略:
- 滑动窗口记忆:只保留最近 N 轮对话。
- 摘要压缩:定期将历史对话总结成一段摘要,替代原始记录。
- 向量数据库 + 语义检索:将关键信息存入向量库,按需检索相关上下文(适用于知识密集型对话)。
- 混合记忆:结合短期(最近几轮)+ 长期(摘要/数据库)记忆。
LangChain 已支持多种 Memory 类型,如 BufferWindowMemory、SummaryMemory、VectorStoreRetrieverMemory 等,可根据场景灵活选择。
五、结语
让 LLM 拥有“记忆”,本质上是将无状态的模型调用转化为有状态的会话系统。通过维护对话历史并合理控制上下文长度,我们可以在成本与体验之间取得平衡。
LangChain 等框架极大简化了这一过程,使开发者能专注于业务逻辑,而非底层状态管理。未来,随着上下文窗口的扩大和记忆机制的智能化(如自动遗忘、重点记忆),AI 助手将越来越像一个真正“记得你”的朋友。
正如我们在代码中看到的那样——当模型说出“你叫王源”时,那一刻,它仿佛真的记住了你。
鸿蒙分布式KVStore冲突解决机制:原理、实现与工程化实践
鸿蒙分布式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_202506、todo_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策略。开发过程中需重点关注版本号管理、同步时机控制和异常日志记录,确保跨设备数据一致性的同时,保障业务数据可靠性。
Intersection Observer 的实战方案
事件委托(Event Delegation)的原理
JS复杂去重一定要先排序吗?深度解析与性能对比
别再让大模型“胡说八道”了!LangChain 的 JsonOutputParser 教你驯服 AI 输出
VueCropper加载OBS图片跨域问题
问题场景
在集成 VueCropper 图片裁剪组件时,遇到一个典型的跨域问题:加载存储在 OBS 上的图片时,浏览器会对同一张图片发起两次请求,最终第二次请求触发跨域错误。以下是问题的完整排查过程与现象梳理:
- 请求行为异常:同一张 OBS 图片被浏览器发起两次请求,第一次请求正常响应,第二次请求直接抛出跨域相关错误(如
CORS policy: No 'Access-Control-Allow-Origin' header); - 初步排查方向:初期优先怀疑 OBS 服务器跨域配置缺失,反馈运维核查后,确认 OBS 已正确配置跨域允许规则(含允许当前前端域名、支持 GET 方法等);
- 关键测试突破:通过浏览器开发者工具测试发现,开启「停用缓存」功能后,跨域错误消失。据此锁定问题根源与浏览器缓存机制相关;
- 核心差异定位:对比两次请求的详细信息,发现跨域标识配置不一致——第一次加载图片未设置
crossOrigin标识,第二次加载时则添加了该标识; - 问题逻辑闭环:第一次请求因无
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 服务器的跨域响应规则,具体错误逻辑可拆解为两步:
- 非跨域模式缓存:第一次加载未设置
crossOrigin,浏览器以「非跨域模式」发起请求(请求头Sec-Fetch-Mode = no-cors);OBS 服务器识别该模式后,不返回Access-Control-Allow-Origin等跨域响应头,浏览器则将这份「无跨域响应头的图片资源」缓存至本地; - 跨域模式缓存冲突:第二次加载设置
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。
具体做法:
- 同源代理(推荐):前端请求自身后端的代理接口,由后端代为拉取 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 滚动条”
前端有一个经典问题:
你在宿主页面怎么写 CSS,都管不到 iframe 内部的滚动条。
所以正确的前端方案不是 “给外层容器加 overflow”,而是:尽量在 iframe 自己层面兜底 + 同源时向 iframe 内注入 CSS。
本文只聚焦前端实现,不展开前后端传参链路。
1. 为什么你明明设置了,滚动条还是在?
因为滚动条来自 iframe 内部 document:
- 外层
div的overflow-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
做法很直接:
- 拿到
iframe.contentDocument - 往里面塞一个带固定
id的<style> - 开关关闭时把这个
<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-55src/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助手
引言
你有没有遇到过这样的尴尬?
向 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 方向):
- LLM 为什么需要 Memory?它的本质是什么?
- Buffer vs Summary Memory 的区别?什么时候用哪种?
- 如何设计一个支持百万用户在线的记忆系统架构?
- 如何防止敏感信息被 Memory 记录?(安全考量)
📝 结语:让 AI 真正“懂你”,从一次对话记忆开始
“智能不是回答问题的能力,而是理解上下文的艺术。”
LangChain Memory 虽然只是整个 LLM 应用中的一个小模块,但它却是通往拟人化交互的关键一步。
从“无状态”到“有记忆”,我们不只是在写代码,更是在塑造一种新的沟通方式。
未来属于那些能让 AI 记住你、理解你、陪伴你的产品。
而现在,你已经有了打造它的钥匙。
从 iframe 到 Shadow DOM:一次关于「隔离」的前端边界思考
一、问题背景:我到底想解决什么?
在复杂前端系统中,我们经常会遇到这样的需求:
-
页面需要嵌入第三方内容 / 子系统
-
希望 样式、脚本互不影响
-
同时又要保证:
- 正常渲染
- 合理交互
- 可控的安全边界
常见方案是 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 的正确方式是:
SameSiteHttpOnlySecure- 服务端鉴权策略
而不是指望 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 实战指南
在PC端,我们有Chrome DevTools。但在微信小程序或App的WebView中开发H5时,F12瞬间失灵,调试如同“盲人摸象”。本文将为你深度剖析两大神器——VConsole 和 Eruda,助你轻松攻克移动端H5调试难题。
前言:为何我们需要它们?
-
环境封闭:小程序的
web-view和 App 的 WebView 通常不开放完整的DevTools权限。 - 真机差异:PC模拟环境与真实手机在渲染、性能、API支持上存在鸿沟。
- 网络与设备:需要查看在特定网络(4G/5G)和设备下的表现。
-
快速定位:
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 即可唤出功能强大的控制台。
![]()
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” 图标即可打开简洁的调试面板。
![]()
最佳实践与避坑指南
-
安全第一:切勿发布到生产环境! 使用环境变量或域名/IP白名单严格控制加载,是每位前端工程师的基本素养。
-
动态开启:给测试和产品同学开个“后门” 使用 URL 参数(如
?debug=true)控制,无需重新发版即可开启调试。 -
VConsole 进阶
-
vConsole.showSwitch()可以显示一个手动切换面板的开关。 - 可以自定义插件,扩展其功能。
-
-
Eruda 进阶
- 使用
eruda.add(erudaPerformance)动态添加性能监控插件。 - 通过
eruda.settings.set()动态调整设置。
- 使用
总结
工欲善其事,必先利其器。对于移动端H5开发者而言,VConsole 和 Eruda 就是那把最锋利的“器”。
- 日常快速调试、微信H5开发,VConsole 是你的不二之选。
- 面对复杂Bug、需要进行深度性能分析,Eruda 的强大功能会让你事半功倍。
希望这篇指南能帮助你彻底摆脱调试困境,在移动端H5的世界里游刃有余!欢迎在评论区分享你的使用心得和遇到的问题。
刨根问底栏目组 - 学习 Zustand 的广播哲学
Vue2/Vue3 迁移头秃?Renderless 架构让组件 “无缝穿梭”
本文由体验技术团队刘坤原创。
"一次编写,到处运行" —— 这不是 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,因为:
-
它是基础工具:Renderless 架构完全依赖
vue-common提供的兼容层 -
它是桥梁:没有
vue-common,就无法实现 Vue 2/3 的兼容 -
它是前提:不理解
vue-common,就无法理解 Renderless 的工作原理
打个比方:vue-common 就像是你学开车前必须先了解的"方向盘、刹车、油门",而 Renderless 是"如何驾驶"的技巧。没有基础工具,再好的技巧也无法施展!
🤔 为什么需要 vue-common?
想象一下,Vue 2 和 Vue 3 就像两个说不同方言的人:
-
Vue 2:
this.$refs.input、this.$emit('event')、Vue.component() -
Vue 3:
refs.input、emit('event')、defineComponent()
如果你要同时支持两者,难道要写两套代码吗?当然不! 这就是 @opentiny/vue-common 存在的意义。
✨ vue-common 是什么?
@opentiny/vue-common 是一个兼容层库,它:
- 统一 API:提供一套统一的 API,自动适配 Vue 2 和 Vue 3
- 隐藏差异:让你无需关心底层是 Vue 2 还是 Vue 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 的差异(如
emit、slots、refs等) - 将
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(推荐)
-
始终使用 vue-common 提供的 API
// ✅ 好 import { defineComponent, setup } from '@opentiny/vue-common' // ❌ 不好 import { defineComponent } from 'vue' // 这样只能在 Vue 3 中使用 -
使用 $props 继承通用属性
// ✅ 好 export const props = { ...$props, customProp: String } -
使用 $prefix 统一命名
// ✅ 好 name: $prefix + 'MyComponent'
❌ DON'T(不推荐)
-
不要直接使用 Vue 2/3 的原生 API
// ❌ 不好 import Vue from 'vue' // 只能在 Vue 2 中使用 import { defineComponent } from 'vue' // 只能在 Vue 3 中使用 -
不要硬编码组件名前缀
// ❌ 不好 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函数接收三个参数:-
props:组件属性 -
hooks:Vue 的响应式 API(reactive, computed, watch 等) -
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 组件?
技巧:
- 使用 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
}
-
使用 Vue DevTools:
- 在模板中添加调试信息
- 使用
state存储调试数据
-
断点调试:
- 在
renderless.ts中设置断点 - 检查
api对象的返回值
- 在
最佳实践
✅ DO(推荐做法)
-
模块化组织代码
src/ ├── index.ts ├── pc.vue ├── renderless.ts ├── composables/ │ ├── use-feature1.ts │ └── use-feature2.ts └── utils/ └── helpers.ts -
明确声明 API
// 在文件顶部声明所有暴露的 API export const api = ['count', 'increment', 'decrement', 'isEven'] -
使用 TypeScript
interface State { count: number message: string } const initState = ({ reactive, props }): State => { return reactive({ count: props.initialValue || 0, message: 'Hello' }) } -
处理边界情况
const handleClick = () => { if (props.disabled) { return // 提前返回 } try { // 业务逻辑 } catch (error) { console.error('Error:', error) emit('error', error) } }
❌ DON'T(不推荐做法)
-
不要在模板中写逻辑
<!-- ❌ 不好 --> <template> <div>{{ count + 1 }}</div> </template> <!-- ✅ 好 --> <template> <div>{{ nextCount }}</div> </template>const nextCount = computed(() => state.count + 1) -
不要直接修改 props
// ❌ 不好 props.count++ // 不要这样做! // ✅ 好 state.count = props.count + 1 emit('update:count', state.count) -
不要忘记清理资源
// ❌ 不好 onMounted(() => { document.addEventListener('click', handler) // 忘记清理 }) // ✅ 好 onMounted(() => { document.addEventListener('click', handler) }) onBeforeUnmount(() => { document.removeEventListener('click', handler) })
🎓 总结
Renderless 架构的核心思想是关注点分离:
- 模板层:只负责 UI 展示
- 逻辑层:处理所有业务逻辑
- 入口层:统一对外接口
通过这种方式,我们可以:
- ✅ 同时支持 Vue 2 和 Vue 3
- ✅ 提高代码的可维护性
- ✅ 增强代码的可测试性
- ✅ 实现逻辑的模块化复用
🚀 下一步
- 查看
@opentiny/vue-search-box的完整源码 - 尝试改造自己的组件
- 探索更多高级特性
📚 参考资源
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 标签,一起参与开源贡献~