阅读视图

发现新文章,点击刷新页面。

拒绝重写!Flutter Add-to-App 全攻略:让原生应用“渐进式”拥抱跨平台

为什么我们需要 Add-to-App?

unwatermarked_Gemini_Generated_Image_51ku3f51ku3f51ku.png 在移动开发领域,Flutter 的跨平台优势(Write once, run anywhere)毋庸置疑。但在现实世界中,我们往往面临着沉重的“历史包袱”。

痛点场景:

“我们公司有一个维护了 5 年的电商 App,原生代码几十万行。最近老板嫌 UI 迭代慢,想用 Flutter,但完全重写是不可能的——业务线太长,风险太大。我们要的是渐进式的改变。”

这就是 Add-to-App 存在的意义。它允许我们将 Flutter 视为一个“库”或“模块”,嵌入到现有的 Android 或 iOS 应用中。

它的核心价值在于:

  1. 成本控制:无需抛弃现有的原生资产(支付模块、复杂的底层算法等)。
  2. 渐进迁移:可以从一个非核心页面(如“关于我们”或“活动页”)开始,逐步扩大 Flutter 的版图。
  3. 复用能力:新开发的 Flutter 模块可以直接在 Android 和 iOS 甚至 Web 上复用,从一开始就享受跨平台红利。

Add-to-App 的基本概念与原理

什么是 Add-to-App?

简单来说,Add-to-App 就是把 Flutter 环境(Dart VM + Flutter Engine)打包成一个原生组件(View 或 ViewController/Activity),塞进现有的原生 App 里。

  • 对于 Android:Flutter 只是一个 View,或者一个 Activity/Fragment。
  • 对于 iOS:Flutter 只是一个 UIView,或者 FlutterViewController。

运行模式:多引擎 vs 多视图

在混合开发中,理解 Flutter 的“寄生”方式至关重要:

策略 描述 优点 缺点
单引擎复用 (Single Engine) 全局维护一个 Engine,在不同原生页面间跳转时,通过 attach/detach 挂载到当前界面。 内存占用最低;状态不仅共享且保持。 导航栈管理极其复杂(原生页面 A -> Flutter B -> 原生 C -> 返回 B 时需恢复现场)。
多引擎 (Multi-Engine) 每次打开 Flutter 页面都创建一个新 Engine。 逻辑隔离,互不干扰;导航栈管理简单。 内存爆炸(每个 Engine 默认消耗较大),启动延迟明显。
FlutterEngineGroup (推荐) 官方提供的轻量级多引擎方案(Flutter 2.0+)。 多个 Engine 共享 GPU 上下文、字体和代码段,新增一个 Engine 仅需 ~180KB 内存 Dart Isolate 彼此隔离,状态不共享(需通过数据层同步)。

误区提示:桌面端/Web 支持的“多视图(Multi-view)”模式(即一个 Engine 渲染多个窗口)目前尚未在移动端 Add-to-App 场景中稳定支持。在移动端,请优先考虑 FlutterEngineGroup

最佳实践场景

  • 高频迭代的业务模块:如电商的活动页、个人中心。
  • 复杂的 UI 交互:如需要高性能动画的图表页。
  • 统一逻辑:双端逻辑完全一致的表单提交或业务计算。

实战 I:在 Android 原生 App 中嵌入 Flutter

创建 Flutter Module

注意,我们不能 flutter create my_app,因为我们不需要一个完整的 App 壳子,我们需要的是一个模块

# 在原生项目同级目录下执行
flutter create -t module my_flutter_module

执行后,你会发现生成的目录结构中,androidios 文件夹是隐藏的(.android, .ios),因为它们是自动生成的包装器。

将 Flutter Module 导入 Android 项目

自 Flutter 3.x 起,官方推荐通过 Gradle 脚本自动管理依赖,避免手动编写 implementation 导致的版本冲突。

步骤 1:修改 settings.gradle

在 include ':app' 之后加入:

// 绑定 Flutter 模块构建脚本
setBinding(new Binding([gradle: this]))
evaluate(new File(
  settingsDir.parentFile, // 假设 flutter_module 与当前项目同级
  'my_flutter_module/.android/include_flutter.groovy'
))

步骤 2:修改 app/build.gradle

依赖会自动注入,通常无需手动添加 implementation project(':flutter')。但需确保 compileSdkVersion 与 Flutter 模块要求一致(通常需 API 33+)。

在 Android 上渲染 Flutter (Activity 与 Fragment)

方式 A:使用 FlutterActivity(全屏场景)

适合独立的业务流程,如“个人中心”或“设置页”。

// 使用缓存 Engine 启动(推荐)
startActivity(
    FlutterActivity
        .withCachedEngine("my_engine_id")
        .build(this)
);

方式 B:使用 FlutterFragment(局部嵌入)

适合将 Flutter 作为一个 View 块嵌入原生页面,例如在一个原生 Tab 页中展示 Flutter 列表。

// 在原生 Activity 或 Fragment 中
FragmentManager fragmentManager = getSupportFragmentManager();
fragmentManager
    .beginTransaction()
    .replace(R.id.fragment_container, 
             FlutterFragment.withCachedEngine("my_engine_id").build())
    .commit();

性能优化 Tip:使用缓存 Engine

withNewEngine() 会导致每次打开页面都有明显的“白屏”或加载延迟。推荐使用 FlutterEngineCache 进行预热:

// 1. 在 Application 启动时预热
public class MyApplication extends Application {
    @Override
    public void onCreate() {
        super.onCreate();
        // 实例化 Engine
        FlutterEngine flutterEngine = new FlutterEngine(this);
        // 开始执行 Dart 代码(预加载)
        flutterEngine.getDartExecutor().executeDartEntrypoint(
            DartExecutor.DartEntrypoint.createDefault()
        );
        // 存入缓存
        FlutterEngineCache
            .getInstance()
            .put("my_engine_id", flutterEngine);
    }
}

// 2. 启动时使用缓存的 Engine
startActivity(
    FlutterActivity
        .withCachedEngine("my_engine_id")
        .build(this)
);

实战 II:在 iOS 原生 App 集成 Flutter

创建 Flutter Module

(同上,使用同一个 my_flutter_module 即可)

CocoaPods 集成

这是 iOS 最标准的集成方式。

修改 Podfile

在 iOS 工程的 Podfile 中添加脚本钩子:

# Podfile
platform :ios, '14.0'

# 定义 Flutter 模块路径
flutter_application_path = '../my_flutter_module'

# 加载 Flutter 的 Pod 助手脚本
load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')

target 'MyApp' do
  use_frameworks!
  
  # 安装 Flutter 依赖
  install_all_flutter_pods(flutter_application_path)
end

执行 pod install,你会发现 Flutter 相关的 Framework 已经被链接进来了。

在 iOS 中打开 Flutter View

使用 FlutterViewController

import Flutter

// 在某个按钮点击事件中
@objc func showFlutter() {
    // 获取 Flutter Engine(同样建议使用 Cache,这里演示简单模式)
    let flutterEngine = (UIApplication.shared.delegate as! AppDelegate).flutterEngine
    
    let flutterViewController = FlutterViewController(
        engine: flutterEngine, 
        nibName: nil, 
        bundle: nil
    )
    
    present(flutterViewController, animated: true, completion: nil)
}

在 iOS 中使用缓存 Engine

为了避免点击按钮时卡顿,强烈建议在 App 启动时预热 Engine。

步骤 1:在 AppDelegate 中初始化并缓存

import UIKit
import Flutter
import FlutterPluginRegistrant // 用于注册插件

@main
class AppDelegate: FlutterAppDelegate { // 继承 FlutterAppDelegate
  
  lazy var flutterEngine = FlutterEngine(name: "my_engine_id")

  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    // 1. 运行 Engine (预热)
    flutterEngine.run();
    // 2. 注册插件(关键!否则 Flutter 里的插件无法使用)
    GeneratedPluginRegistrant.register(with: flutterEngine);
    
    return super.application(application, didFinishLaunchingWithOptions: launchOptions);
  }
}

步骤 2:使用缓存 Engine 弹出页面

@objc func showFlutter() {
    let appDelegate = UIApplication.shared.delegate as! AppDelegate
    let flutterEngine = appDelegate.flutterEngine
    
    let flutterVC = FlutterViewController(engine: flutterEngine, nibName: nil, bundle: nil)
    present(flutterVC, animated: true, completion: nil)
}

进阶:原生与 Flutter 的双向通信 (MethodChannel)

当混合开发时,不可避免地需要数据交互:Flutter 读取原生的 Token,或者原生调用 Flutter 的刷新方法。MethodChannel 是最常用的桥梁。

5.1 Flutter 端 (Dart)

import 'package:flutter/services.dart';

class NativeBridge {
  static const platform = MethodChannel('com.example.app/data');

  // 调用原生方法
  Future<String> getUserToken() async {
    try {
      final String token = await platform.invokeMethod('getToken');
      return token;
    } on PlatformException catch (e) {
      return "Failed: '${e.message}'.";
    }
  }
}

5.2 Android 端

// 需在 Engine 初始化后注册 Channel
new MethodChannel(flutterEngine.getDartExecutor().getBinaryMessenger(), "com.example.app/data")
    .setMethodCallHandler(
        (call, result) -> {
            if (call.method.equals("getToken")) {
                // 执行原生逻辑获取 Token
                String token = MyAuthManager.getToken();
                result.success(token);
            } else {
                result.notImplemented();
            }
        }
    );

注意事项:

MethodChannel 并非能传递任意对象,它的底层依赖 BinaryMessenger 进行二进制流传输。

  • StandardMethodCodec(标准编解码器) : Flutter 默认使用此 Codec,它只支持高效序列化以下基础类型

    • null, bool, int, double, String
    • List, Map (仅限上述基础类型的集合)
    • 二进制数据 (Uint8List / byte[])

注意:如果你尝试直接传递一个自定义类 User,通道会报错。 解决方案:将对象转为 JSON String 或 Map 进行传递,或者自定义 Codec。


进阶:混合栈管理与多 Engine 挑战

在 Add-to-App 中,最头疼的问题往往是 导航栈(Navigation Stack)。

比如:原生 A -> Flutter B -> 原生 C -> Flutter D

挑战

  1. 内存爆炸:如果每次 > Flutter 都创建一个新 Engine,内存会迅速耗尽。
  2. 状态丢失:如果复用同一个 Engine,从 C 返回 B 时,Flutter 的状态怎么恢复?

解决方案策略

当原生应用需要在 Feed 流中嵌入多个 Flutter 卡片,或者同时存在多个 Flutter 页面栈时,单纯的“单引擎”或“多引擎”都不够完美。

终极方案:FlutterEngineGroup 这是官方为了解决“多实例内存占用”推出的 API。

原理: 它允许你创建多个 Engine 实例,这些实例共享内存重的资源(如 Skia Shader、字体、Dart VM 快照),但保持 Dart Isolate 隔离

代码示例 (Android)

// 创建 EngineGroup
FlutterEngineGroup engineGroup = new FlutterEngineGroup(context);

// 创建第一个轻量级 Engine
FlutterEngine engine1 = engineGroup.createAndRunDefaultEngine(context);

// 创建第二个轻量级 Engine(复用资源,内存开销极低)
FlutterEngine engine2 = engineGroup.createAndRunDefaultEngine(context);

状态管理挑战: 由于 EngineGroup 中的 Isolate 是隔离的,engine1 中的全局变量无法被 engine2 直接读取。

  • 解决:相比于通过原生层(Host)作为中转站,或者使用持久化存储(Database/SharedPrefs)来同步不同 Flutter 页面间的数据。使用平台通道并且搭配上pigeon,相信会给你复杂原生交互提供不少的便利。

7. 常见问题与“避坑”指南

场景 现象/原因 解决方案
冷启动 点击按钮后,等待 1-2 秒才出现 Flutter 画面,且有白屏。 必须预热 Engine!在 App 启动时初始化 Engine 并存入 Cache。
调试 运行原生 App 后,无法使用 Flutter 的热重载 (Hot Reload)。 在终端运行 flutter attach,连接到正在运行的设备。
图片加载 Flutter 无法加载原生 Assets 中的图片。 原生图片需在 Flutter pubspec.yaml 中声明,或通过 Platform Channel 传递图片数据(字节流)。
生命周期 Flutter 页面退后台后,代码被挂起。 原生层需正确转发生命周期事件(lifecycle_channel),确保 Flutter 知道自己处于前台还是后台。

8. 总结

Add-to-App 方案打破了“非黑即白”的技术选型困境,是目前大型 App 引入 Flutter 的主流路径。

核心路径回顾:

  1. flutter create -t module 创建模块。
  2. 利用 FlutterEngineCache 解决性能问题。
  3. 利用 MethodChannel 打通数据经脉。

混合开发没有银弹,只有不断的权衡。希望本文能帮助你在现有的原生堡垒中,成功开辟出第一块 Flutter 的疆土!

延伸阅读

希望这篇分享对你有帮助!如果想了解更深层的 Engine 源码分析,欢迎留言讨论。

Wiki 开发日记:学做 Markdown 大纲视图

大家好,我是AY。

笔者折腾了Wiki 的 Markdown 大纲视图,整个过程基本就是从“一头雾水”到“强行破局”,接下来记录下我充满艰辛的开发路程。

一、寻找 Markdown 的“真身”

刚开始做的时候,我一直在想 Markdown 大纲的核心本质到底是什么?

说白了就是提取、转化、渲染,这么三步走的一个数据流!

  1. 提取(Extraction) :从非结构化的 Markdown 或 Block 积木中识别标题。
  2. 转化(Transformation) :将积木转换为包含 idleveltext 的结构化 JSON 数组。
  3. 渲染(Rendering) :将数组映射为 UI 组件,通过 level 决定缩进。

但我首先得看懂现在的项目是怎么把 Markdown 长出来的——我看 Wiki 页面用了 Ant Design X 的 AI 组件,里面嵌套了 Markdown 解析器,我想从解析器入手看它是怎么渲染大纲的,结果发现我还没看明白,也许逻辑藏得太深...那么,我就简单粗暴地去找 F12 控制台,直接去 Source 里面溯源。

结果发现根源在 node_modules 一个灰色块里——这说明它是个三方组件库。

我顺着 DevTools 往上摸,盯着组件树一层层找,看 render 里的配置。

我心想,配置里一定有一个状态(State)储存着我们的 Wiki 文本内容,不然这个编辑区它到底是怎么长出来的?

二、深入 BlockNote:静态看不懂,就看动态

摸到最后发现,这玩意儿不是简单的纯文本渲染,而是用了 BlockNote —— 跟 Notion 类似,是块级编辑器。

我想搞清楚数据到底在哪,就去翻代码里的 page detail(从服务器数据库拿出来的整篇数据),然后去读 BlockNote 的 Editor API。

我发现我其实看不太明白,尤其是 editor.document 这个属性返回的数据结构,去看那个 document entity 的字段定义时,感觉自己有点迷失。

我看静态类型的定义搞不清楚,那我就要去学会看动态运行的数据。

我直接在配置里加了一段调试代码:

useEffect(() => {
  if (editor) {
    // 强制把 editor 挂在 window 对象上,管理员直接在控制台随时调遣
    (window as any).myEditor = editor;
    console.log("✅ 管理员已就位,请前往控制台输入 myEditor 检查");
  }
}, [editor]);

这一按回车,在控制台输入 myEditor.document,数组全展开了。我盯着看它的 typeprops 还有 level

发现 typeheading 的就是标题,而第三个非标题的 typeparagraph,也就是普通的文段,它确实没有 level 这个属性。

最关键的发现是 content:标题的文字内容不是一个简单的字符串,而是一个数组——所以我后面不能直接拿来用。

根据 BlockNote 官方文档定义,文档的核心是 Block 对象。我在控制台展开 myEditor.document 后,验证了其标准的 Block Schema

  • Block 类型:每个块都有一个 type 字段。大纲只关注 type: "heading"

  • Props 属性:标题的级别(H1/H2/H3)存储在块的 props 对象中,定义为 props: { level: number }

  • InlineContent 数组:这是最核心的发现。官方文档指出,BlockNote 的内容并非 string,而是 InlineContent[]

    定义: InlineContent 可以是 StyledText(带样式的文本)或 Link(链接)。这意味着提取标题时,必须遍历这个数组并拼接所有 text 节点。

三、架构方案:为什么必须实时?

关于这个大纲的数据源,我一开始没有弄清楚,以为要从后端获取。

但仔细一想逻辑不对:

大纲必须得是用户输入、内存状态更新,然后立刻触发 UI 渲染,大纲和编辑器的文字要同步变。

这种同步逻辑必须从当前编辑器的实时状态中提取,因为它是最快的,也是最真实的。如果非要走后端,那你在 Wiki 里打一个字,都要等数据传到服务器、存进数据库、再推回给大纲,中间那几百毫秒甚至几秒的延迟,会让大纲的操作感非常卡顿。

****即,大纲必须实现强实时性 (Real-time Synchronization) 。 若将逻辑放在后端,会产生明显的网络延迟 (Latency) 。因此,我选择监听编辑器的 Editor Snapshot(编辑器快照) 。这是最真实的内存数据源,能保证用户每输入一个字符,大纲都能毫秒级地响应。

四、逻辑拆解:监听、过滤、结构化

我想清楚了大纲实现的三个核心动作:

  • 监听:怎么感知用户敲了键盘?我去翻 BlockNote 里面有没有内容改变的 Hook(钩子)——那就是调用 editor.onChange 钩子。

  • 过滤:编辑器里可能有图片、段落,大纲却只要文字。对策是:我写了个 filter,专门根据 type === 'heading' 进行过滤,把正文部分全部扔掉。

  • 结构化

    • 处理层级:H1、H2、H3 怎么体现?我发现几级标题就藏在 props.level 里面,这就是我区分标题级别、显示缩进的依据。
    • 文字合并:因为 content 是数组,我得用 MDN 里的 Array.prototype.map 映射出文字,再用 join('') 把它们连成一串。
    • UI 渲染:缩进我打算直接在 div 里写个公式,把 level 数值乘上一定的像素(比如 12px 或 20px),CSS 的魔法计算就不打算写成函数了,直接内联。

五、性能优化:useMemo 的“洗豆子”理论

做的时候我发现一个很严重的问题:

如果不做优化,我每输入一个字符,控制台就显示整个页面被重新渲染一遍。

我又去补充学习了一下 memouseMemo。``

简单来说,memo 是跳过组件渲染,而 useMemo 是缓存计算结果。

  • useMemo (计算缓存) :大纲提取就像从万颗豆子里挑出黄豆。useMemo 确保只有在 editor.document 这个依赖项真正改变时,才重新执行复杂的过滤和拼接逻辑。
  • memo (组件记忆) :将大纲 UI 封装在 React.memo 中,防止父组件其他状态更新引发大纲的无效刷新。

我的“洗豆子”理论: 如果你有 1 万个 Block,那 filtermap 就像是从 1 万个豆子里挑出黄豆再洗干净。如果没有 useMemo,你每次打一个空格,React 就要重新挑一次、重新洗一次。但 useMemo 能让这一万次循环只在 editor.document 真正改变时才发生,减少了巨大的性能开销。

六、交互与布局的“临门一脚”

最后是大纲作为侧边栏的弹出与布局。

我参考了 项目已有的 Chat 页面的三栏布局,但它是完美的 Splitter 组件库。我们 Wiki 的边栏需要自己布局,我看了下父子组件都是 Flex。

我决定在父组件里加一个 useState 的 Hook 来控制大纲“这扇门”的开关,并把整个提取逻辑写进去。

可是,还有一个交互大坑:BlockNote 默认只会移光标,不会滚动页面。

文档长了,光标跑到底部去了,浏览器视口却不动。点击大纲跳转的逻辑我决定写在主页面(大管家)里,因为它同时拿着 Editor 实例和大纲组件。 我最后用了 DOM 修正。

为了防止滚动到顶时挡住标题,我还在内联 CSS 里加了高度限制(偏移量),并配合一个 setTimeout 定时器。

由于 Wiki 的 Header 是 Sticky (粘性布局) 的,直接跳转会导致标题被遮挡。

CSS 的 Scroll Margin 属性硬核修正:

[data-id] { scroll-margin-top: 80px; } /* 预留出 Header 的高度 */

虽然不知道是不是最好的方案,但它能规避原生滚动“弹一下”的抖动感,让视觉效果更丝滑。

七、学习防抖 (Debounce) 与 异步更新

原本我没有看懂配合的一个防抖,但确实稳了很多。统统通过这个小项目补上,坚强的菜鸟学习中!

防抖 (Debounce):高频状态更新的“节流阀”

在 BlockNote 这种高度响应式的编辑器中,用户的每一次击键都会触发 onChange 事件。如果文档包含数千个节点,不加干预的实时提取会导致 CPU 在用户输入时陷入持续的“遍历-计算-重绘”死循环。

防抖机制本质上是一个基于计时器的事件合并方案

它通过 useMemo 维持一个单例的计时器,当连续的 onChange 触发时,旧的计时器被不断销毁,只有当用户停止输入并超过 300ms 的阈值后,才会真正触发那一版“最真实”的大纲提取逻辑。这不仅大幅降低了计算开销,还规避了 UI 频繁闪烁的问题。

// 伪代码实现逻辑
const debouncedUpdate = useMemo(() => {
  return debounce(() => {
    // 只有在用户“停手”后的间隙,才去执行昂贵的过滤与拼接逻辑
    const snapshot = editor.document;
    const headings = filterHeadings(snapshot);
    setOutline(headings);
  }, 300);
}, [editor]);

异步调度 (Async Scheduling):跳转逻辑的“缓冲带”

在处理大纲跳转时,存在一个关键的交互冲突:编辑器的 setTextCursorPosition 会触发内部的焦点切换与视图定位,而我们自定义的 scrollIntoView 也在尝试改变滚动位置。如果这两者同步发生,浏览器会因为竞争控制权而产生一种“弹一下”的抖动感,甚至定位失败。

这个利用了任务队列(Task Queue) 的异步特性。

通过微任务或延迟调度,先让编辑器完成光标定位和状态更新,待 DOM 稳定后,再执行平滑滚动逻辑。这种“先定光标,后滚视图”的先后顺序,配合 CSS 的 scroll-margin-top 偏移量,完美消除了原生滚动冲突导致的视觉抖动。

// 伪代码实现逻辑
onItemClick = (id) => {
  // 1. 同步执行:先告诉编辑器光标在哪,它会处理自己的内部逻辑
  editor.focus();
  editor.setTextCursorPosition(id, 'start');

  // 2. 异步调度:将滚动任务推入下一轮任务循环,确保 DOM 已响应光标状态
  setTimeout(() => {
    const el = document.querySelector(`[data-id="${id}"]`);
    el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
  }, 0); 
};

啥意思呢?其实就是说:防抖负责“省 CPU”,异步调度负责“稳 UI”。

⭐小结

虽然只是这么一个小功能,但我在逐步尝试脱离AI喂代码,自己去思考代码这样写是否稳定,数据从哪里来,又应该到哪里去。

我不敢说这样一个小功能有多少难点,但我可以非常确定这样的学习方式对我有很大的成长:我学着去看一个完全陌生的BlockNote的API,也接触到当前AI开发场景下的组件库antd X;对于一个好的前端开发者来说,AI不是来杀死前端的,而是在提供更加新的开发场景。(不过当然,过去的我太依赖ai开发,没有自己的反思,绝对是无法在ai强大的编程能力下存活的,哈哈哈哈...)

前端开发所需要的技术素养实在是太多,单独拎出来一个浏览器视口为什么不随光标移动,也就可以深入去学浏览器的默认行为,底层原理;不单单是demo能用,还要模拟如果上线真实投入使用用户会如何操作?高频更新的顾虑,考虑怎么防抖,等等。保持我现有的耐心和热情,一次又一次的bug和debug都是学习机会,即使我只是做了一个这么小的功能。

限于个人经验,文中若有疏漏,还请不吝赐教。

参考文献:

介绍 - Ant Design X

总览 - Ant Design X

Using Pro - Marked Documentation

BlockNote - Manipulating Blocks

BlockNote - Introduction

BlockNote - Events

BlockNote - Real-time Collaboration

Array.prototype.map() - JavaScript | MDN

Array.prototype.join() - JavaScript | MDN

将 Props 传递给组件 – React 中文文档

BlockNote - Overview

BlockNote - Cursor & Selections

useMemo – React 中文文档

memo – React 中文文档

Element:scrollIntoView() 方法 - Web API | MDN

从零实现富文本编辑器#11-Immutable状态维护与增量渲染

在先前我们讨论了视图层的适配器设计,主要是全量的视图初始化渲染,包括生命周期同步、状态管理、渲染模式、DOM映射状态等。在这里我们需要处理变更的增量更新,这属于性能方面的考量,需要考虑如何实现不可变的状态对象,以此来实现Op操作以及最小化DOM变更。

从零实现富文本编辑器系列文章

行级不可变状态

在这里我们先不引入视图层的渲染问题,而是仅在Model层面上实现精细化的处理,具体来说就是实现不可变的状态对象,仅更新的节点才会被重新创建,其他节点则直接复用。由此想来此模块的实现颇为复杂,也并未引入immer等框架,而是直接处理的状态对象,因此先从简单的更新模式开始考虑。

回到最开始实现的State模块更新文档内容,我们是直接重建了所有的LineState以及LeafState对象,然后在React视图层的BlockModel中监听了OnContentChange事件,以此来将BlockState的更新应用到视图层。

delta.eachLine((line, attributes, index) => {
  const lineState = new LineState(line, attributes, this);
  lineState.index = index;
  lineState.start = offset;
  lineState.key = Key.getId(lineState);
  offset = offset + lineState.length;
  this.lines[index] = lineState;
});

这种方式简单直接,全量更新状态能够保证在React的状态更新,然而这种方式的问题在于性能。当文档内容非常大的时候,全量计算将会导致大量的状态重建,并且其本身的改变也会导致Reactdiff差异进而全量更新文档视图,这样的性能开销通常是不可接受的。

那么通常来说我们就需要基于变更来确定状态的更新,首先我们需要确定更新的粒度,例如以行为基准则未变更的时候就直接取原有的LineState。相当于尽可能复用Origin List然后生成Target List,这样的方式自然可以避免部分状态的重建,尽可能复用原本的对象。

整体思路大概是先执行变成生成最新的列表,然后分别设置旧列表和新列表的rowcol两个指针值,然后更新时记录起始row,删除和新增自然是正常处理,对于更新则认为是先删后增。对于内容的处理则需要分别讨论单行和跨行的问题,中间部分的内容就作为重建的操作。

最后可以将这部分增删LineState数据放置于Changes中,就可以得到实际增删的Ops了,这样我们就可以优化部分的性能,因为仅原列表和目标列表的中间部分才会重建,其他部分的行状态直接复用。此外这部分数据在applydelta中是不存在的,同样可以认为是数据的补充。

  Origin List (Old)                          Target List (New)
+-------------------+                      +-------------------+
| [0] LineState A   | <---- Retain ------> | [0] LineState A   | (Reused)
+-------------------+                      +-------------------+
| [1] LineState B   |          |           | [1] LineState B2  | (Update)
+-------------------+       Changes        |     (Modified)    | (Del C)
| [2] LineState C   |          |           +-------------------+
+-------------------+          V           | [2] NewState X    | (Inserted)
| [3] LineState D   | ---------------\     +-------------------+
+-------------------+                 --> | [3] LineState D   | (Reused)
| [4] LineState E   | <---- Retain ------> | [4] LineState E   | (Reused)
+-------------------+                      +-------------------+

那么这里实际上是存在非常需要关注的点,我们现在维护的是状态模型,也就是说所有的更新就不再是直接的compose,而是操作我们实现的状态对象。本质上我们是需要实现行级别的compose方法,这里的实现非常重要,假如我们对于数据的处理存在偏差的话,那么就会导致状态出现问题。

此外在这种方式中,我们判断LineState是否需要新建则是根据整个行内的所有LeafState来重建的。也就是说这种时候我们是需要再次将所有的op遍历一遍,当然实际上由于最后还需要将compose后的Delta切割为行级别的内容,所以其实即使在应用变更后也最少需要再遍历两次。

那么此时我们需要思考优化方向,首先是首个retain,在这里我们应该直接完整复用原本的LineState,包括处理后的剩余节点也是如此。而对于中间的节点,我们就需要为其独立设计更新策略,这部分理论上来说是需要完全独立处理为新的状态对象的,这样可以减少部分Leaf Op的遍历。

new Delta().retain(5).insert("xx")
insert("123"), insert("\n") // skip 
insert("456"), insert("\n") // new line state

其中,如果是新建的节点,我们直接构建新的LineState即可,删除的节点则不从原本的LineState中放置于新的列表。而对于更新的节点,我们需要更新原本的LineState对象,因为实际上行是存在更新的,而重点是我们需要将原本的LineStatekey值复用。

这里我们先简单实现实现描述一下复用的问题,比较方便的实现则是直接以\n的标识为目标的State,这就意味着我们要独立\n为独立的状态。即如果在123|456\n|位置插入\n的话,那么我们就是123是新的LineState456是原本的LineState,以此来实现key的复用。

[
  insert("123"), insert("\n"), 
  insert("456"), insert("\n")
]
// ===>
[ 
  LineState(LeafState("123"), LeafState("\n")), 
  LineState(LeafState("456"), LeafState("\n"))
]

其实这里有个非常值得关注的点是,LineStateDelta中是没有具体对应的Op的,而相对应的LeafState则是有具体的Op的。这就意味着我们在处理LineState的更新时,是不能直接根据变更控制的,因此必须要找到能够映射的状态,因此最简单的方案即根据\n节点映射。

LeafState("\n", key="1") <=> LineState(key="L1")

实际上我们可以总结一下,最开始我们考虑先更新再diff,后来考虑的是边更新边记录。边更新边记录的优点在于,可以避免再次遍历一边所有Leaf节点的消耗,同时也可以避免diff的复杂性。但是这里也存在个问题,如果内部进行了多次retain操作,则无法直接复用LineState

不过通常来说,最高频的操作是输入内容,这种情况下首操作一般都是retain,尾操作为空会收集剩余文档内容,因此这部分优化是会被高频触发的。而如果是多次的内容部分变更操作,这部分虽然可以通过判断行内的叶子结点是否变更,来判断是否复用行对象,但是也存在一定复杂性。

关于这部分的具体实现,在编辑器的状态模块里存在独立的Mutate模块,这部分实现在后边实现各个模块时会独立介绍。到这里我们就可以实现一个简单的Immutable状态维护,如果Leaf节点发生变化之后,其父节点Line会触发更新,而其他节点则可以直接复用。

Key 值维护

至此我们实现了一套简单的Immutable Delta+Iterator来处理更新,这种时候我们就可以借助不可变的方式来实现React视图的更新,那么在React的渲染模式中,key值的管理也是个值的探讨的问题。

在这里我们就可以根据状态不可变来生成key值,借助WeakMap映射关系获取对应的字符串id值,此时就可以借助key的管理以及React.memo来实现视图的复用。其实在这里初步看起来key值应该是需要主动控制强制刷新的时候,以及完全是新节点才会用得到的。

但是这种方式也是有问题的,因为此时我们即使输入简单的内容,也会导致整个行的key发生改变,而此时我们是不必要更新此时的key的。因此key值是需要单独维护的,不能直接使用不可变的对象来索引key值,那么如果是直接使用index作为key值的话,就会存在潜在的原地复用问题。

key值原地复用会导致组件的状态被错误保留,例如此时有个非受控管理的input组件列表,在某个输入框内已经输入了内容,当其发生顺序变化时,原始输入内容会跟随着原地复用的策略留在原始的位置,而不是跟随到新的位置,因为其整体列表顺序key未发生变化导致React直接复用节点。

LineState节点的key值维护中,如果是初始值则是根据state引用自增的值,在变更的时候则是尽可能地复用原始行的key,这样可以避免过多的行节点重建并且可以控制整行的强制刷新。

而对于LeafState节点的key值最开始是直接使用index值,这样实际上会存在隐性的问题,而如果直接根据Immutable来生成key值的话,任何文本内容的更改都会导致key值改变进而导致DOM节点的频繁重建。

export const NODE_TO_KEY = new WeakMap<Object.Any, Key>();
export class Key {
  /** 当前节点 id */
  public id: string;
  /** 自动递增标识符 */
  public static n = 0;

  constructor() {
    this.id = `${Key.n++}`;
  }

  /**
   * 根据节点获取 id
   * @param node
   */
  public static getId(node: Object.Any): string {
    let key = NODE_TO_KEY.get(node);
    if (!key) {
      key = new Key();
      NODE_TO_KEY.set(node, key);
    }
    return key.id;
  }
}

通常使用index作为key是可行的,然而在一些非受控场景下则会由于原地复用造成渲染问题,diff算法导致的性能问题我们暂时先不考虑。在下面的例子中我们可以看出,每次我们都是从数组顶部删除元素,而实际的input值效果表现出来则是删除了尾部的元素,这就是原地复用的问题。在非受控场景下比较明显,而我们的ContentEditable组件就是一个非受控场景,因此这里的key值需要再考虑一下。

const { useState, Fragment, useRef, useEffect } = React;
function App() {
  const ref = useRef<HTMLParagraphElement>(null);
  const [nodes, setNodes] = useState(() => Array.from({ length: 10 }, (_, i) => i));

  const onClick = () => {
    const [_, ...rest] = nodes;
    console.log(rest);
    setNodes(rest);
  };

  useEffect(() => {
    const el = ref.current;
    el && Array.from(el.children).forEach((it, i) => ((it as HTMLInputElement).value = i + ""));
  }, []);

  return (
    <Fragment>
      <p ref={ref}>
        {nodes.map((_, i) => (<input key={i}></input>))}
      </p>
      <button onClick={onClick}>slice</button>
    </Fragment>
  );
}

考虑到先前提到的我们不希望任何文本内容的更改都导致key值改变引发重建,因此就不能直接使用计算的immutable对象引用来处理key值,而描述单个op的方法除了insert就只剩下attributes了。

但是如果基于attributes来获得就需要精准控制合并insert的时候取需要取旧的对象引用,且没有属性的op就不好处理了,因此这里可能只能将其转为字符串处理,但是这样同样不能保持key的完全稳定,因此前值的索引改变就会导致后续的值出现变更。

const prefix = new WeakMap<LineState, Record<string, number>>();
const suffix = new WeakMap<LineState, Record<string, number>>();
const mapToString = (map: Record<string, string>): string => {
  return Object.keys(map)
    .map(key => `${key}:${map[key]}`)
    .join(",");
};
const toKey = (state: LineState, op: Op): string => {
  const key = op.attributes ? mapToString(op.attributes) : "";
  const prefixMap = prefix.get(state) || {};
  prefix.set(state, prefixMap);
  const suffixMap = suffix.get(state) || {};
  suffix.set(state, suffixMap);
  const prefixKey = prefixMap[key] ? prefixMap[key] + 1 : 0;
  const suffixKey = suffixMap[key] ? suffixMap[key] + 1 : 0;
  prefixMap[key] = prefixKey;
  suffixMap[key] = suffixKey;
  return `${prefixKey}-${suffixKey}`;
};

slate中我先前认为生成的key跟节点是完全一一对应的关系,例如当A节点变化时,其代表的层级key必然会发生变化。然而在关注这个问题之后,我发现其在更新生成新的Node之后,会同步更新Path以及PathRef对应的Node节点所对应的key值。

for (const [pathRef, key] of pathRefMatches) {
  if (pathRef.current) {
    const [node] = Editor.node(e, pathRef.current)
    NODE_TO_KEY.set(node, key)
  }
  pathRef.unref()
}

在后续观察Lexical实现的选区模型时,发现其是用key值唯一地标识每个叶子结点的,选区也是基于key值来描述的。整体表达上比较类似于Slate的选区结构,或者说是DOM树的结构。这里仅仅是值得Range选区,Lexical实际上还有其他三种选区类型。

{
  anchor: { key: "51", offset: 2, type: "text" },
  focus: { key: "51", offset: 3, type: "text" }
}

在这里比较重要的是key值变更时的状态保持,因为编辑器的内容实际上是需要编辑的。然而如果做到immutable话,很明显直接根据状态对象的引用来映射key会导致整个编辑器DOM无效的重建。例如调整标题的等级,就由于整个行key的变化导致整行重建。

那么如何尽可能地复用key值就成了需要研究的问题,我们的编辑器行级别的key是被特殊维护的,即实现了immutable以及key值复用。而目前叶子状态的key依赖了index值,因此如果调研Lexical的实现,同样可以将其应用到我们的key值维护中。

通过在playground中调试可以发现,即使我们不能得知其是否为immutable的实现,依然可以发现Lexicalkey是以一种偏左的方式维护。因此在我们的编辑器实现中,也可以借助同样的方式,合并直接以左值为准复用,拆分时若以0起始直接复用,起始非0则创建新key

  1. [123456(key1)][789(bold-key2)]文本,将789的加粗取消,整段文本的key值保持为key1
  2. [123456789(key1)]]文本,将789这段文本加粗,左侧123456文本的key值保持为key1789则是新的key
  3. [123456789(key1)]]文本,将123这段文本加粗,左侧123文本的key值保持为key1456789则是新的key
  4. [123456789(key1)]]文本,将456这段文本加粗,左侧123文本的key值保持为key1456789分别是新的key

因此,此时在编辑器中我们也是用类似偏左的方式维护key,由于我们需要保持immutable,所以这里的表达实际上是尽可能复用先前的key状态。这里与LineStatekey值维护方式类似,都是先创建状态然后更新其key值,当然还有很多细节的地方需要处理。

// 起始与裁剪位置等同 NextOp => Immutable 原地复用 State
if (offset === 0 && op.insert.length <= length) {
  return nextLeaf;
}
const newLeaf = new LeafState(retOp, nextLeaf.parent);
// 若 offset 是 0, 则直接复用原始的 key 值
offset === 0 && newLeaf.updateKey(nextLeaf.key);

这里还存在另一个小问题,我们创建LeafState就立即去获得对应的key值,然后再考虑去复用原始的key值。这样其实就会导致很多不再使用的key值被创建,导致每次更新的时候看起来key的数字差值比较大。当然这并不影响整体的功能与性能,只是调试的时候看起来比较怪。

因此我们在这里还可以优化这部分表现,也就是说我们在创建的时候不会去立即创建key值,而是在初始化以及更新的时候再从外部设置其key值。这个实现其实跟indexoffset的处理方式比较类似,我们整体在update时处理所有的相关值,且开发模式渲染时进行了严格检查。

// BlockState
let offset = 0;
this.lines.forEach((line, index) => {
  line.index = index;
  line.start = offset;
  line.key = line.key || Key.getId(line);
  const size = line.isDirty ? line.updateLeaves() : line.length;
  offset = offset + size;
});
this.length = offset;
this.size = this.lines.length;
// LineState
let offset = 0;
const ops: Op[] = [];
this.leaves.forEach((leaf, index) => {
  ops.push(leaf.op);
  leaf.offset = offset;
  leaf.parent = this;
  leaf.index = index;
  offset = offset + leaf.length;
  leaf.key = leaf.key || Key.getId(leaf);
});
this._ops = ops;
this.length = offset;
this.isDirty = false;
this.size = this.leaves.length;

此外,在实现单元测试时还发现,在leaf上独立维护了key值,那么\n这个特殊的节点自然也会有独立的key值。这种情况下在line级别上维护的key值倒是也可以直接复用\n这个leafkey值。当然这只是理论上的实现,可能会导致一些意想不到的刷新问题。

视图增量渲染

在视图模块最开始的设计上,我们的状态管理形式是直接全量更新Delta,然后使用EachLine遍历重建所有的状态。并且实际上我们维护了DeltaState两个数据模型,建立其关系映射关系本身也是一种损耗,渲染的时候的目标状态是Delta而非State

这样的模型必然是耗费性能的,每次Apply的时候都需要全量更新文档并且再次遍历分割行状态。当然实际上只是计算迭代的话,实际上是不会太过于耗费性能,但是由于我们每次都是新的对象,那么在更新视图的时候,更容易造成性能的损耗,计算的性能通常可接受,而视图更新操作DOM成本更高。

实际上,我们上边复用其key值,解决的问题是避免整个行状态视图re-mount。而即使复用了key值,因为重建了整个State实例,React也会继续后边的re-render流程。因此我们在这里需要解决的问题是,如何在无变更的情况下尽可能避免其视图re-render

由于我们实现了行级不可变状态维护,那么在视图中就可以直接对比状态对象的引用是否变化来决定是否需要重渲染。因此只需要对于ViewModel的节点补充了React.memo,在这个场景下甚至于不需要重写对比函数,只需要依赖我们的immutable状态复用能够正常起到效果。

const LeafView: FC<{ editor: Editor; leafState: LeafState; }> = props => {
  return (
    <span {...{ [LEAF_KEY]: true }} >
      {runtime.children}
    </span>
  );
}
export const LeafModel = React.memo(LeafView);

同样的,针对LineView也需要补充memo,而且由于组件内本身可能存在状态变化,例如Composing组合输入的控制,所以针对于内部节点的计算也会采用useMemo来缓存结果,避免重复计算。

const LineView: FC<{ editor: Editor; lineState: LineState; }> = props => {
  const elements = useMemo(() => {
     // ...
    return nodes;
  }, [editor, lineState]);
  return (
    <div {...{ [NODE_KEY]: true }} >
      {elements}
    </div>
  );
}
export const LineModel = React.memo(LineView);

而视图刷新仍然还是直接控制lines这个状态的引用即可,相当于核心层的内容变化与视图层的重渲染,是直接依赖于事件模块通信就可以实现的。由于每次取lines状态时都是新的引用,所以React会认为状态发生了变化,从而触发重渲染。

const onContentChange = useMemoFn(() => {
  if (flushing.current) return void 0;
  flushing.current = true;
  Promise.resolve().then(() => {
    flushing.current = false;
    setLines(state.getLines());
  });
});

而虽然触发了渲染,但是由于key以及memo的存在,会以line的状态为基准进行对比。只有LineState对象的引用发生了变化,LineModel视图才会触发更新逻辑,否则会复用原有的视图,这部分我们可以直接依赖Reactdevtools录制或Highlight就可以观察到。

视图增量更新这部分其实比较简单,主要是实现不可变对象以及key值维护的逻辑都在核心层实现,视图层主要是依赖其做计算,对比是否需要重渲染。其实类似的实现在低代码的场景中也可以应用,毕竟实际上富文本也就是相当于一个零代码的编辑器,只不过组装的不是组件而是文本。

总结

在先前我们主要讨论了视图层的适配器设计,主要是全量的视图初始化渲染,以及状态模型到DOM结构性的规则设定。在这里则主要考虑更新处理时性能的优化,主要是在增量更新时,如何最小化DOM以及Op操作、key值的维护、以及在React中实现增量渲染的方式。

其实接下来需要考虑输入内容时,如何避免规定的DOM的结构被破坏,主要涉及脏DOM检查、选区更新、渲染Hook等,这部分内容在#8#9的输入法处理中已经有了详细的讨论,因此这里就不再次展开了。

那么接下来我们需要讨论的是编辑节点的组件预设,例如零宽字符、Embed节点、Void节点等。主要是为编辑器的插件扩展提供预设的组件,在这些组件内存在一些默认的行为,并且同样预设了部分DOM结构,以此来实现在规定范围内的编辑器操作。

每日一题

参考

【React-6/Lesson89(2025-12-27)】React Context 详解:跨层级组件通信的最佳实践📚

🎯 React 组件通信方式概览

在 React 开发中,组件之间的数据传递是核心问题。目前主要有四种通信方式:

1️⃣ 父子组件通信

这是最基础的通信方式,父组件通过 props 将数据传递给子组件。这种方式简单直接,适用于层级较浅的组件关系。

function Child({ message }) {
  return <div>{message}</div>
}

function Parent() {
  return <Child message="Hello from Parent" />
}

2️⃣ 子父组件通信

子组件通过回调函数将数据传递给父组件。父组件定义一个函数,通过 props 传递给子组件,子组件调用这个函数来传递数据。

function Child({ onMessage }) {
  return <button onClick={() => onMessage('Hello from Child')}>发送消息</button>
}

function Parent() {
  const handleMessage = (msg) => {
    console.log(msg)
  }
  return <Child onMessage={handleMessage} />
}

3️⃣ 兄弟组件通信

兄弟组件之间的通信通常需要通过共同的父组件作为中转。父组件维护共享状态,兄弟组件通过 props 接收和修改这个状态。

function SiblingA({ value, onChange }) {
  return <input value={value} onChange={(e) => onChange(e.target.value)} />
}

function SiblingB({ value }) {
  return <div>输入的值:{value}</div>
}

function Parent() {
  const [value, setValue] = useState('')
  return (
    <>
      <SiblingA value={value} onChange={setValue} />
      <SiblingB value={value} />
    </>
  )
}

4️⃣ 跨层级通信

当组件层级较深时,使用 props 一层层传递数据会变得非常繁琐。这就是 Context 要解决的问题。

🚀 Context 的诞生背景

痛点分析

在传统的父子组件通信中,如果需要将数据从顶层组件传递到深层嵌套的子组件,就必须经过每一层中间组件。这种方式存在以下问题:

传递路径过长:数据需要经过多个中间组件,每个组件都需要接收并传递 props,即使它们并不使用这些数据。

维护成本高:每次修改数据结构或添加新的 props,都需要修改整条传递链路上的所有组件。

代码冗余:中间组件充满了不相关的 props 传递,代码可读性降低。

现实类比

这就像古代的驿站传递制度:皇帝要给边疆的将军送一封密信,必须经过沿途的每个驿站,每个驿站都要接收并转发这封信,即使驿站官员并不关心信的内容。这种传递方式效率低下,而且容易在传递过程中出现问题。

就像《长安的荔枝》中描述的那样,为了将新鲜荔枝从岭南送到长安,需要经过无数驿站,每个驿站都要接力传递,成本极高,风险很大。

💡 Context 的核心思想

Context 提供了一种在组件树中共享数据的方式,无需通过 props 一层层传递。它的核心思想是:

数据在查找的上下文里:在最外层组件提供数据,任何层级的组件都可以直接访问这些数据。

主动查找能力:需要消费数据的组件拥有主动查找数据的能力,而不是被动接收。

规矩不变:父组件(外层组件)仍然负责持有和改变数据,只是传递方式从"一路传"变成了"全局提供"。

📖 Context 的三步使用法

第一步:创建 Context 容器

使用 createContext 创建一个 Context 对象,这个对象就是数据容器。可以传入一个默认值作为参数。

import { createContext } from 'react'

export const UserContext = createContext(null)

createContext 接受一个默认值参数,当组件在匹配的 Provider 之外使用 Context 时,会使用这个默认值。

第二步:提供数据

使用 Context.Provider 组件包裹需要共享数据的组件树,通过 value 属性提供数据。

export default function App() {
  const user = {
    name: "Andrew"
  }
  
  return (
    <UserContext.Provider value={user}>
      <Page />
    </UserContext.Provider>
  )
}

Provider 组件接受一个 value 属性,这个值会被所有消费该 Context 的组件访问到。Provider 可以嵌套使用,内层的 Provider 会覆盖外层的值。

第三步:消费数据

使用 useContext Hook 在组件中消费 Context 数据。

import { useContext } from 'react'
import { UserContext } from '../App'

export default function UserInfo() {
  const user = useContext(UserContext)
  return (
    <div>{user.name}</div>
  )
}

useContext 接受一个 Context 对象作为参数,返回该 Context 的当前值。当 Context 的值发生变化时,使用该 Context 的组件会重新渲染。

🎨 实战案例:主题切换应用

需求分析

创建一个支持白天/夜间主题切换的应用,主题状态需要在整个应用中共享。这是一个典型的跨层级通信场景。

项目结构

theme-demo/
├── src/
│   ├── App.jsx
│   ├── contexts/
│   │   └── ThemeContext.jsx
│   ├── components/
│   │   ├── Header.jsx
│   │   └── Content.jsx
│   ├── pages/
│   │   └── Page.jsx
│   └── theme.css

创建 ThemeContext

首先创建主题 Context,包含主题状态和切换主题的方法。

import { createContext, useState, useEffect } from 'react'

export const ThemeContext = createContext(null)

export default function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light')
  
  const toggleTheme = () => {
    setTheme((prevTheme) => (prevTheme === 'light' ? 'dark' : 'light'))
  }
  
  useEffect(() => {
    document.documentElement.setAttribute('data-theme', theme)
    document.body.setAttribute('data-theme', theme)
  }, [theme])
  
  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  )
}

关键点解析

  • useState 管理主题状态,初始值为 'light'
  • toggleTheme 函数使用函数式更新,确保基于前一个状态进行切换
  • useEffect 监听主题变化,同步更新 htmlbody 元素的 data-theme 属性
  • ThemeProvider 作为高阶组件,包裹子组件并提供主题上下文

在应用中使用 ThemeProvider

在应用的根组件中使用 ThemeProvider 包裹整个组件树。

import ThemeProvider from './contexts/ThemeContext'
import Page from './pages/Page'

export default function App() {
  return (
    <>
      <ThemeProvider>
        <Page />
      </ThemeProvider>
    </>
  )
}

在组件中消费主题数据

在 Header 组件中消费主题数据,显示当前主题并提供切换按钮。

import { useContext } from 'react'
import { ThemeContext } from '../contexts/ThemeContext'

export default function Header() {
  const { theme, toggleTheme } = useContext(ThemeContext)
  
  return (
    <div style={{ padding: 24 }}>
      <h2>当前主题:{theme}</h2>
      <button className="button" onClick={toggleTheme}>
        切换主题
      </button>
    </div>
{

在 Page 组件中也可以消费主题数据,实现不同组件共享同一主题状态。

import { useContext } from 'react'
import { ThemeContext } from '../contexts/ThemeContext'
import Header from '../components/Header'
import Content from '../components/Content'

export default function Page() {
  const theme = useContext(ThemeContext)
  
  return (
    <div style={{ padding: 24 }}>
      <Header />
      <Content />
    </div>
  )
}

🎯 CSS 变量实现主题切换

使用 CSS 变量(Custom Properties)是实现主题切换的优雅方式。CSS 变量允许我们在不同的主题下动态改变样式。

定义 CSS 变量

theme.css 中定义主题相关的 CSS 变量。

:root {
  --bg-color: pink
  --text-color: #222
  --primary-color: #1677ff
}

[data-theme='dark'] {
  --bg-color: #141414
  --text-color: #f5f5f5
  --primary-color: #4e8cff
}

语法解析

  • :root 选择器匹配文档的根元素,在这里定义全局 CSS 变量
  • --bg-color--text-color 等是自定义属性名,必须以 -- 开头
  • [data-theme='dark'] 是属性选择器,匹配所有 data-theme 属性值为 'dark' 的元素
  • 在不同的选择器中重新定义变量值,实现主题切换

使用 CSS 变量

在样式中使用 var() 函数引用 CSS 变量。

body {
  margin: 0
  background-color: var(--bg-color)
  color: var(--text-color)
  transition: all 0.3s
}

.button {
  padding: 8px 16px
  background: var(--primary-color)
  color: #fff
  border: none
  cursor: pointer
}

关键特性

  • var(--bg-color) 引用之前定义的 CSS 变量
  • data-theme 属性改变时,CSS 变量的值会自动更新
  • transition: all 0.3s 实现主题切换的平滑过渡效果

JavaScript 控制 CSS 变量

通过 JavaScript 动态修改元素的 data-theme 属性,触发 CSS 变量的变化。

useEffect(() => {
  document.documentElement.setAttribute('data-theme', theme)
  document.body.setAttribute('data-theme', theme)
}, [theme])

当 theme 状态变化时,useEffect 会执行,更新 DOM 元素的属性,从而触发 CSS 变量的重新计算。

🔍 Context 的高级特性

默认值的作用

Context 的默认值在以下情况下使用:

  1. 组件在匹配的 Provider 之外使用 Context
  2. Provider 的 value 属性为 undefined
const MyContext = createContext('默认值')

function Component() {
  const value = useContext(MyContext)
  return <div>{value}</div>
}

export default function App() {
  return <Component />
}

在这个例子中,Component 会显示"默认值",因为它没有被任何 Provider 包裹。

Provider 嵌套

多个 Provider 可以嵌套使用,内层的 Provider 会覆盖外层的值。

const ThemeContext = createContext('light')
const ColorContext = createContext('blue')

function Child() {
  const theme = useContext(ThemeContext)
  const color = useContext(ColorContext)
  return <div>主题:{theme},颜色:{color}</div>
}

export default function App() {
  return (
    <ThemeContext.Provider value="dark">
      <ColorContext.Provider value="red">
        <Child />
      </ColorContext.Provider>
    </ThemeContext.Provider>
  )
}

Child 组件会显示"主题:dark,颜色:red"。

Context 性能优化

当 Context 的值变化时,所有消费该 Context 的组件都会重新渲染。为了优化性能,可以:

  1. 拆分 Context:将频繁变化和不常变化的数据拆分到不同的 Context 中
const UserContext = createContext(null)
const ThemeContext = createContext(null)

function App() {
  const [user, setUser] = useState(null)
  const [theme, setTheme] = useState('light')
  
  return (
    <UserContext.Provider value={user}>
      <ThemeContext.Provider value={theme}>
        <Child />
      </ThemeContext.Provider>
    </UserContext.Provider>
  )
}
  1. 使用 useMemo:避免不必要的对象重新创建
function App() {
  const [theme, setTheme] = useState('light')
  
  const contextValue = useMemo(() => ({
    theme,
    toggleTheme: () => setTheme(prev => prev === 'light' ? 'dark' : 'light')
  }), [theme])
  
  return (
    <ThemeContext.Provider value={contextValue}>
      <Child />
    </ThemeContext.Provider>
  )
}
  1. 使用 React.memo:避免不必要的组件重新渲染
const ExpensiveComponent = React.memo(function ExpensiveComponent() {
  const { theme } = useContext(ThemeContext)
  return <div>主题:{theme}</div>
})

📝 Context 最佳实践

1. 合理拆分 Context

不要将所有数据都放在一个 Context 中,应该根据功能模块合理拆分。

const UserContext = createContext(null)
const ThemeContext = createContext(null)
const LocaleContext = createContext(null)

2. 使用自定义 Hook

封装 Context 的消费逻辑,提供更友好的 API。

export function useTheme() {
  const context = useContext(ThemeContext)
  if (!context) {
    throw new Error('useTheme 必须在 ThemeProvider 内部使用')
  }
  return context
}

function Component() {
  const { theme, toggleTheme } = useTheme()
  return <button onClick={toggleTheme}>{theme}</button>
}

3. 提供默认值或错误处理

确保 Context 的使用是安全的,提供合理的默认值或错误处理。

export function useUser() {
  const user = useContext(UserContext)
  if (!user) {
    throw new Error('useUser 必须在 UserProvider 内部使用')
  }
  return user
}

4. 避免过度使用

Context 适合用于全局状态,如用户信息、主题、语言设置等。对于局部状态,仍然应该使用 props 或状态管理库。

5. 文档化 Context

为 Context 添加清晰的文档说明,包括提供的数据类型和使用方法。

/**
 * 用户上下文
 * @type {React.Context<{
 *   name: string
 *   email: string
 *   role: string
 * }>}
 */
export const UserContext = createContext(null)

🌟 总结

React Context 是解决跨层级组件通信的强大工具,它提供了优雅的数据共享方式,避免了 props 传递的繁琐。通过合理使用 Context,可以:

  • 简化组件间的数据传递
  • 提高代码的可维护性
  • 实现全局状态管理
  • 构建更灵活的应用架构

结合 CSS 变量,Context 可以轻松实现主题切换、国际化等全局功能。在实际开发中,应该根据具体需求选择合适的通信方式,Context 是工具箱中的重要一员,但不是唯一的解决方案。

速通-微信小程序 2Day

速通-微信小程序 2Day

速通-微信小程序-CSDN博客 紧接前文,搭配有助于快速,巩固/学习!话不多说开始!

这一部分挺简单的,最起码对于做过前端Vue 开发,前后端的 me,so so easy!

WXML 模板语法

WXML(WeiXin Markup Language)是小程序的视图层模板语言

作用类似 HTML,但扩展了「数据绑定、逻辑渲染、事件绑定」等核心能力,

是小程序 数据驱动视图 核心能力:数据绑定、逻辑渲染(列表/条件)、事件绑定;

数据绑定:

Mustache 语法 ,把data中的数据绑定到页面中渲染:

WXML 通过双大括号 {{}}将 JS 逻辑层数据渲染到视图层,支持 内容绑定、属性绑定、表达式运算

  • {{}}内支持简单表达式(算术、三元、逻辑运算),但不支持复杂语句(如if/for循环)

  • 文本绑定中,{{}}会自动解析 \n 为换行符(对应之前text组件的换行场景)

  • ⚠️绑定的数据必须在页面 data 中定义,否则会显示为空;

内容绑定: <标签> {{ 绑定页面data中的变量,文本渲染 }} </标签>

属性绑定: 标签,属性值也用 {{}} 绑定,注意,属性名无需加引号(区别于 VUE 的v-bind

xxx.js

Page({
/** 页面的初始数据 */
    data: {
img: "https://i1.hdslb.com/bfs/face/6ac26f27a6d25da7865cab8e0806676c8f4cd62c.webp",
        username: "wsm",
        age: 25
    },
    /** 省略其他配置.... */
})

xxx.wxml

<view>
  <!-- 绑定页面data中的变量 -->
  <text>用户名:{{username +'\n'}}</text>
  <text>年龄:{{age + 1 +'\n'}}</text> <!-- 支持表达式运算 -->
  <text>成年:{{age >= 18 ? '是' : '否'}} \n</text> <!-- 三元运算 -->

  <!-- 动态绑定标签属性-data中的变量 -->
  <image src="{{img}}"  mode="widthFix"></image>
</view>

在这里插入图片描述

事件绑定:

小程序的事件绑定是视图层(WXML)与逻辑层(JS)通信的核心方式 也是实现用户交互的唯一入口;

由于小程序是双线程模型(渲染层 + 逻辑层),事件需通过微信客户端(Native)中转,

因此绑定规则、事件对象与普通网页的 DOM 事件有本质差异;

在这里插入图片描述

事件 中转通信

小程序的事件并非直接的 DOM 事件,而是通过 微信客户端作为中转桥接层

将视图层用户交互 点击、输入 转发到逻辑层 JS 函数处理,这种设计避免了直接 DOM 操作;

  • 核心限制 :禁止直接操作 DOM,所有交互必须通过 事件绑定 + 数据驱动 实现

  • 触发流程 :用户在视图层触发事件 → 微信客户端捕获事件并封装成事件对象

    → 转发到逻辑层对应的 JS 函数 → 逻辑层处理后通过 setData 更新视图;

事件绑定的两种核心方式:

小程序提供 bindcatch 两种绑定前缀,核心区别是是否允许事件冒泡

绑定方式 行为 适用场景
bind 绑定事件并允许冒泡(事件会向上传递给父元素) 需要父元素响应子元素事件的场景
如:事件委托)
catch 绑定事件并阻止冒泡(事件不会向上传递) 仅需当前元素响应事件的场景
如:按钮点击、表单提交)
<!-- 父元素绑定bindtap,子元素绑定bindtap -->
<view bindtap="parentTap" style="padding: 20rpx; background: #f0f0f0;">
    <button bindtap="childTap">点击子元素(允许冒泡)</button>
</view>

<!-- 父元素绑定bindtap,子元素绑定catchtap -->
<view bindtap="parentTap" 
style="padding: 20rpx; background: #f0f0f0; margin-top: 20rpx;">
    <button catchtap="childTap">点击子元素(阻止冒泡)</button>
</view>
/** 页面定义函数 */
parentTap() {
    console.log("父元素事件触发");
},
childTap() {
console.log("子元素事件触发");
}

在这里插入图片描述

  • 点击第一个按钮(bindtap):会先触发childTap,再触发parentTap(事件冒泡到父元素)

  • 点击第二个按钮(catchtap):仅触发childTap,不会触发parentTap(阻止冒泡)

事件对象(e):交互数据的 “载体”

事件触发时,逻辑层的 JS 函数会收到一个 事件对象e,包含事件的所有信息,核心属性如下:

属性 作用
e.type 事件类型(如 tapinput
区分不同事件类型,如同时绑定多种事件时判断触发源)
e.target 触发事件的源元素(实际点击的元素)
事件委托场景中,获取触发事件的具体子元素
e.currentTarget 绑定事件的当前元素(事件绑定的元素)
获取绑定事件的元素的自定义属性或统一处理父元素逻辑
e.detail 事件携带的额外信息(如表单输入值、滑动距离)
获取表单组件的输入内容(如 input 实时值)、滑动组件的位移量
e.dataset 元素的自定义属性(通过 data-* 定义)
事件传参的核心方式(如传递商品 ID、列表项索引)
<view bindtap="handleTap" data-id="parent">
  <button data-id="child">点击按钮</button>
</view>
handleTap(e) {
  console.log(e.target.dataset.id); // 输出:child(实际触发的元素是按钮)
  console.log(e.currentTarget.dataset.id); // 输出:parent(绑定事件的元素是view)
}

target 和 currentTarget 的区别

target:指向实际触发事件的元素(子元素)

currentTarget:指向绑定事件的元素(父元素)

在这里插入图片描述

小程序 常用事件

冒泡事件(支持bind/catch

事件名 触发场景 实战用途
tap 点击元素 (手指触摸后马上离开) 按钮点击、列表项跳转、提交操作
longpress 长按元素(触摸时间≥350ms) 弹出操作菜单、删除确认
touchstart 手指触摸开始 滑动交互、手势识别
touchend 手指触摸结束 滑动结束、手势确认
touchmove 手指触摸后移动 滑动拖拽、进度条控制

非冒泡事件(仅支持bindcatch无效)

事件名 触发场景 实战用途
submit 表单提交 提交表单数据到后端
scroll 页面 / 滚动容器滚动 监听滚动位置、实现上拉加载更多
change 表单组件值变化(如 switch、picker) 监听开关状态、选择器变化
input 输入框内容变化 实时获取用户输入(如搜索框、表单)

事件传参:data-* 自定义属性

小程序不支持直接在事件绑定中传参 如: bindtap="handleClick(123)"会报错

必须通过data-*自定义属性传递参数,再从事件对象的 e.currentTarget.dataset 中获取;

<!-- 事件传参: -->
<view>
<!-- 可以为组件提供data-*自定义属性传参,其中*代表的是参数的名字,示例代码如下: -->
    <button bindtap="handleTapparam" data-param1="123" data-param2="{{123}}" >事件传参 param1</button>
</view>
handleTapparam(e){
    //通过 event.target.dataset.参数名 即可获取到具体参数的值
    //通过dataset可以访问到具体参数的值
    console.log(e.target.dataset);            
    console.log(e.target.dataset.param1);     
    console.log(e.target.dataset.param2);
}

在这里插入图片描述表单事件的detail属性

实现文本框和 data 之间的数据同步

小程序文本框(input组件)与页面data数据双向同步

核心依托小程序 数据绑定 +input 组件 输入事件监听 🖥️🖥️🖥️

通过setData完成视图层(文本框)与逻辑层(data)的双向数据更新;

  • 文本框通过value="{{data中的变量}}"实现 data 到文本框的单向渲染(初始值回显);
  • 绑定input组件的输入事件(bindinput/bindblur),实时 / 按需获取文本框输入值;
  • 在事件处理函数中,通过this.setData({ 变量: 输入值 })完成 文本框到 data 的反向同步
  • 全程遵循小程序「数据驱动视图」原则, 禁止直接操作 DOM ,所有数据更新均通过setData实现;
  <input value="{{inpmas}}" bindinput="inpvalue"
  style="border: 1px solid black; margin: 5px; padding: 5px;" />
/** 页面的初始数据 */
data: {
    img: ".................",
inpmas: '兴趣爱好',
    username: "wsm",
age: 25,
},
//data 之间的数据同步
inpvalue(e){
//通过 e.detail.value 获取到文本框最新的值
this.setData({ inpmas: e.detail.value });
console.log(this.data.inpmas);
},

在这里插入图片描述

条件渲染:

WXML 的条件渲染是小程序根据逻辑层(JS)数据动态控制视图层元素显示 / 隐藏的核心能力,

完全遵循「数据驱动视图」原则,禁止直接操作 DOM 修改显示状态

wx:if

wx:if / wx:elif / wx:else(多分支条件,逻辑销毁 / 重建)

wx:if 是 WXML 原生支持的 多分支条件渲染语法 ,支持单条件、多条件分支、嵌套判断

底层通过 销毁 / 重建组件节点 实现显示 / 隐藏(条件不满足时节点从渲染树移除,满足时重新创建)

<view>
  <!-- 当isShow为true时显示,否则销毁该节点 -->
  <view wx:if="{{isShow}}">仅满足条件时显示</view>

  <!-- 多分支按顺序匹配,仅执行第一个满足条件的分支 -->
  <view wx:if="{{status === 0}}">待支付</view>
  <view wx:elif="{{status === 1}}">已支付/待发货</view>
  <view wx:elif="{{status === 2}}">已发货/待收货</view>
  <view wx:else>已完成/已取消</view>

  <!-- block包裹多节点,一次控制所有元素的显示/隐藏 -->
  <block wx:if="{{hasData}}">
    <view>商品名称:{{goods.name}}</view>
    <view>商品价格:{{goods.price}}元</view>
    <button>立即购买</button>
  </block>
  <!-- 空状态提示 -->
  <view wx:else>暂无商品数据</view>
</view>
Page({
  /** 页面的初始数据 */
  data: {
    status: 3,
    isShow: false,
    hasData: false,
    goods: { name:"AAA", price:12.00 },
  },
})

在这里插入图片描述

<block> 包裹多节点(不生成冗余 DOM)

若需要 同时控制多个元素的条件显示,直接给每个元素加wx:if会冗余,

可使用<block>标签包裹 ——<block>是 WXML 的无渲染容器

仅用于包裹节点,不生成实际 DOM 元素,不影响页面布局;

wx:hidden

hidden 是 WXML 元素的通用属性,仅支持单分支条件判断(隐藏 / 显示)

底层通过CSS 的 display: none 实现隐藏 —— 元素节点始终存在于渲染树中

仅通过样式控制不可见,适合 单条件、高频切换 的场景(如弹窗、下拉菜单、开关控制的内容

<view>
  <!-- 方式1:直接写布尔值(静态,无动态切换需求) -->
  <view hidden="{{true}}">静态隐藏的内容</view>
  <!-- 方式2:绑定data中的布尔变量(推荐,支持动态切换) -->
  <view hidden="{{isHidden}}">高频切换的内容(如弹窗)</view>
  <!-- 方式3:结合简单表达式(无需额外定义data变量) -->
  <view hidden="{{list.length === 0}}">列表有数据时显示</view>
</view>
Page({
  /** 页面的初始数据 */
  data: {
    // 控制hidden的核心变量
    isHidden: false, 
    list: [1,2]
  },
})

在这里插入图片描述

if 🆚 hidden

两者的核心差异在于 底层实现方式,直接决定了 切换性能开销适用场景

运行方式不同:

  • wx:if 以动态创建和移除元素的方式,控制元素的展示与隐藏
  • hidden 以切换样式的方式(display: none/block;),控制元素的显示与隐藏

使用建议: 频繁切换时,建议使用 hidden,控制条件复杂时,建议使用 wx:if、wx:elif、wx:else

wx:for 列表渲染

wx:for 是 WXML 原生核心的列表渲染指令,用于将数组 / 对象中的数据循环渲染为页面节点;

将逻辑层(JS)data中的数组 / 对象,在视图层(WXML)中循环生成相同结构的节点,

实现数据与视图的联动渲染,无需手动操作 DOM;

直接给元素绑定wx:for="{{数组名}}",即可循环渲染该元素;

小程序自动提供 2 个默认变量,无需手动定义:

  • item:当前循环的 数组项 每一个元素
  • index:当前循环的 索引 从 0 开始

wx:key是必填项 无合法wx:key会触发控制台性能警告,且列表重排时易出现渲染错误

<view>
  <!-- wx:for绑定数组,wx:key绑定唯一标识 -->
  <view wx:for="{{goodsList}}" wx:key="id" class="goods-item">
    <text>第{{index+1}}个商品:</text>
    <text>名称:{{item.name}},价格:{{item.price}}元</text>
  </view>
</view>
Page({
  /** 页面的初始数据 */
  data: {
    // 模拟后端返回的商品列表,id为唯一标识
    goodsList: [
      { id: 1, name: "小程序开发实战", price: 59 },
      { id: 2, name: "Java后端进阶", price: 79 },
      { id: 3, name: "全栈开发指南", price: 99 }
    ]
  },
})

在这里插入图片描述

WXSS 模板样式

WXSS(WeiXin Style Sheet)是小程序的专属样式语言,

类似于CSS样式,并,在 CSS 基础上扩展了 rpx 响应式尺寸单位 解决移动端多设备适配复用问题;

rpx 尺寸单位

什么是 rpx 尺寸单位

rpx(responsive pixel)是小程序独有的响应式尺寸单位

用于统一不同屏幕宽度设备的尺寸显示,替代 CSS 中的固定单位 px,无需手动计算适配比例;

实现原理: 小程序将所有设备的屏幕宽度统一映射为 750rpx 750rpx = 设备实际屏幕宽度

框架会根据设备屏幕宽度自动换算 rpx 对应的物理像素:

  • 在 iPhone X(屏幕宽度 375px)上:换算比例与 iPhone 6 一致
  • 在 iPhone 6(屏幕宽度 375px)上:750rpx = 375px1rpx = 0.5px
  • 在安卓设备(如屏幕宽度 414px)上:750rpx = 414px1rpx ≈ 0.552px

设计稿适配:主流设计稿宽度为 750px,设计稿上的 1px 可直接对应小程序的 1rpx

官方建议:开发微信小程序时,设计师可以用 iPhone6 作为视觉稿的标准

@import 样式导入

用于导入外部 WXSS 样式文件,实现 公共样式复用

如:全局主题、组件样式、工具类),避免重复编写样式,提升代码可维护性;

支持 相对路径 和 绝对路径 ,必须用双引号包裹路径,结尾加分号,可导入多个文件,按顺序合并样式;

在这里插入图片描述

app.json 全局配置

小程序根目录下的 app.json 文件是小程序的 全局配置文件 常用的配置项如下:

  • pages: 记录当前小程序所有页面的存放路径,必选,小程序启动的基础
  • tabBar: 设置小程序底部的 tabBar 效果
  • window: 全局设置小程序窗口的外观
  • style: 是否启用新版的组件样式

配置 window

属性名 默认值 说明
navigationBarTitleText 字符串 导航栏标题文字内容
navigationBarBackgroundColor #000000 导航栏背景颜色,如 #000000
navigationBarTextStyle white 导航栏标题颜色,仅支持 black/white
backgroundColor #ffffff 窗口的背景色
backgroundTextStyle dark 下拉 loading 的样式,仅支持 dark/light
enablePullDownRefresh false 是否全局开启下拉刷新
onReachBottomDistance 50 页面上拉触底事件触发,距页面底部距离,单位为px
navigationStyle default custom 为自定义导航栏,适合沉浸式页面

配置 tabBar

tabBar 是小程序的 全局底部导航组件,用于在多个核心页面(如首页、我的、订单)之间快速切换;

是多页面小程序的标配。它完全在 app.json 中配置,无需编写额外代码,以下完整配置指南🧭:

小程序中通常将其分为: 底部 tabBar顶部 tabBar

  • tabBar中只能:配置最少 2 个、最多 5 个 tab 页签

  • 渲染 顶部tabBar 时,不显示 icon,只显示文本

tabBarapp.json 的顶级配置项,分为必填项和可选项

配置项 必填 / 取值 / 说明
color 是,tab 未选中时的文字颜色(十六进制色值,如 #666666
selectedColor 是,tab 选中时的文字颜色(需与 color 区分,如品牌色 #ff4444
backgroundColor 是,tabBar 的背景色(十六进制色值,如 #ffffff
borderStyle 否,tabBar 上边框的样式,仅支持 black/white,默认 black
position 否,tabBar 的位置,默认 bottom(底部),可选 top(顶部)
⚠️ 注意:position: "top" 时,不显示 icon,仅显示文字
custom 否,是否使用自定义 tabBar(默认 false
开启后需在根目录创建 custom-tab-bar 自定义组件,适合复杂交互;
list 是,tab 项的数组,最少 2 个,最多 5 个,每个元素为一个 tab 配置对象;

每个 tab 项的配置(list 数组元素)

配置项 必填 / 取值 / 说明
pagePath 是,点击 tab 跳转的页面路径,
必须是 pages 数组中已注册的页面pages/index/index
text 是,tab 上显示的文字,如 首页 我的
iconPath 否,tab 未选中时的图标路径,建议用 81px:81px 的 2x 图,避免模糊
selectedIconPath 否,tab 选中时的图标路径,需与 iconPath 尺寸一致

以下是 <常见小程序> 的典型 tabBar 配置: 首页 / 消息 / 联系我们

{
  "pages": [
    "pages/home/home",
    "pages/contact/contact",
    "pages/message/message"
  ],
  "window": {
    "navigationBarTextStyle": "black",
    "navigationBarTitleText": "我的小程序",
    "navigationBarBackgroundColor": "#FFFFFF"
  },
  "style": "v2",
  "lazyCodeLoading": "requiredComponents",
  "componentFramework": "glass-easel",
  "sitemapLocation": "sitemap.json",
  
  "tabBar": {
    "color": "#666666",
    "selectedColor": "#ff4444",
    "backgroundColor": "#ffffff",
    "borderStyle": "black",
    "list": [
      {
        "text": "首页",
        "pagePath": "pages/home/home",
        "iconPath": "images/tab/home.png",
        "selectedIconPath": "images/tab/home-active.png"
      },
      {
        "text": "消息",
        "pagePath": "pages/message/message",
        "iconPath": "images/tab/message.png",
        "selectedIconPath": "images/tab/message-active.png"
      },
      {
        "text": "联系我们",
        "pagePath": "pages/contact/contact",
        "iconPath": "images/tab/contact.png",
        "selectedIconPath": "images/tab/contact-active.png"
      }
    ]
  }
}
  • ⚠️ tabBar —— list 最少两个最多5个,且必须在 Page中存在!!!

在这里插入图片描述

xxx.json 局部配置

小程序中,每个页面都有自己的 .json 配置文件,用来对当前页面的窗口外观、页面效果等进行配置

小程序中,app.json 中的 window 节点,可以全局配置小程序中每个页面的窗口表现;

  • 如果,某些小程序页面想要拥有特殊的窗口表现,

  • 此时,页面级别的 .json 就可以实现这种需求

根据就近原则,最终的效果以页面配置为准

网络数据请求

小程序中网络数据请求的限制:

为保障用户数据安全,小程序的网络请求有以下强制规则:

域名白名单

  • 上线请求的域名必须在,微信公众平台 开发管理→开发设置→服务器域名

    中配置白名单,仅支持 HTTPS 协议,域名必须经过 ICP 备案,可在小程序:项目配置中查看!

    在这里插入图片描述

  • 开发阶段可在开发者工具中开启 不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书

    豁免;上线前必须完成域名备案并配置白名单; 详情——本地配置——不校验合法域名

    在这里插入图片描述

    仅在开发阶段使用,上线必须合法域名HTTPS!!

协议要求

  • 上线: 仅支持 HTTPS/WSS 协议,禁止使用 HTTP
  • 开发阶段: 可暂时豁免,上线前必须切换为 HTTPS 域名;

请求频率

  • 单个小程序的并发请求数限制为 10 个,高频请求需做节流/合并
  • 避免短时间内发起大量请求,可通过防抖 / 合并请求优化

跨域限制

  • 仅需配置微信域名白名单,无需后端设置 CORS

  • 无需配置 CORS(由微信客户端中转请求,无浏览器同源限制)

相关测试接口:

GET: https://applet-base-api-t.itheima.net/slides 获取轮播图信息;

POST: https://applet-base-api-t.itheima.net/api/post 上传用户信息;

在这里插入图片描述

发生 GET 请求

home.wxml

<!-- 轮播图容器:适配小程序轮播组件 -->
<swiper  indicator-dots  autoplay interval="3000"  
circular  indicator-active-color="#ff4444" class="banner-swiper">
  <!-- 循环渲染轮播图项 -->
  <swiper-item wx:for="{{slides}}" wx:key="id">
    <image  src="{{item.image}}"  style="height: 100%; width: 100%;"  />
  </swiper-item>
</swiper>

home.js

Page({
  /*** 页面的初始数据 */
  data: {
    slides: [] // 轮播图数据列表,初始为空
  },
  /** 生命周期函数--监听页面加载 */
  onLoad(options) {
    // 调用GET请求渲染页面轮播图~
    this.getSlides();
  },
  //GET请求获取页面轮播图信息
  getSlides() {
    // 显示加载中提示,提升用户体验
    wx.showLoading({title: '加载中...', mask: true});
    wx.request({
      url: 'https://applet-base-api-t.itheima.net/slides',
      method: 'GET',
      success: (res) => {
        // 接口请求成功:判断状态码,更新数据
        if (res.statusCode === 200 && res.data) {
          this.setData({slides: res.data });
        }else{
           // 接口返回异常,提示用户
           wx.showToast({ title: '轮播图数据加载失败', icon: 'none',duration: 2000 })
        }
      },
      fail: (err) => {
        console.error('轮播图请求失败:', err);
        wx.showToast({ title: '轮播图数据加载失败', icon: 'none',duration: 2000 })
      },
      // 无论成功/失败,都关闭加载提示
      complete: () => {
        wx.hideLoading();
      }
    })
  }
})

在这里插入图片描述

发送 POST 请求

<!-- 分割线 -->
<view class="divider">\n</view>

<!-- 用户信息输入区域(新增) -->
<view>
  <view>
    <text>姓名:</text>
    <input type="text" placeholder="请输入姓名" 
value="{{name}}" bindinput="syncInput" data-key="name"/>
  </view>
  <view class="form-item">
    <text class="label">性别:</text>
    <input type="text" placeholder="请输入性别 男/女" 
value="{{gender}}" bindinput="syncInput" data-key="gender" />
  </view>
  <!-- 提交按钮 -->
  <button bindtap="postUserInfo" class="submit-btn">提交用户信息</button>
</view>

<!-- POST请求结果展示区域(新增) -->
<view wx:if="{{postResult}}">
  <text>提交结果:</text>
  <text>{{postResult}}</text>
</view>
Page({
  /*** 页面的初始数据 */
  data: {
    slides: [], // 轮播图数据列表,初始为空
    name: '',
    gender: '',
    postResult: ''
  },
  /** 生命周期函数--监听页面加载 */
  onLoad(options) {   },
  //GET请求页面轮播图信息
  getSlides() {  },

  // 新增:同步输入框数据到data(实时绑定)
  // 通过data-key区分是name还是gender输入框
  syncInput(e) {
    const key = e.currentTarget.dataset.key;
    this.setData({[key]: e.detail.value });
  },
  // 新增:POST请求提交用户信息
  postUserInfo() {
    // 非空校验
    const { name, gender } = this.data;
    if (!name || !gender) {
      wx.showToast({ title: '请完善姓名和性别', icon: 'none' });
      return;
    }
    // 显示加载提示
    wx.showLoading({ title: '提交中...', mask: true });
    // 发起POST请求
    wx.request({
      url: 'https://applet-base-api-t.itheima.net/api/post',
      method: 'POST',
      data: { name, gender },
      header: { 'content-type': 'application/json' },
      success: (res) => {
        // 将返回的JSON转为字符串,方便页面展示
        if (res.statusCode === 200 && res.data) {
          this.setData({postResult: JSON.stringify(res.data, null, 2)})
          wx.showToast({ title: '提交成功', icon: 'success' });
        }else {
          wx.showToast({ title: '提交失败', icon: 'none' });
        }
      },
      fail: (err) => {
        console.error('POST请求失败:', err);
        wx.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
      },
      complete: () => { wx.hideLoading(); },
    })
  }
})

在这里插入图片描述

小程序网络请求进阶

小程序的网络请求能力远不止 GET/POST

完全支持 RESTful 风格的 PUT/DELETE 等方法,同时提供了专门的 API 处理表单和文件传输;

相关文档:

黑马—小程序简介_哔哩哔哩_bilibili 对应:Day2 天内容!

  • blog 中涉及接口案例,可能会失效,可以到官方评论区,会定期更新最新域名!

Veaury:让Vue和React组件在同一应用中共存的神器

前端开发者常常面临这样的困境:Vue项目需要使用React生态的优秀组件,或者React项目想引入Vue的优雅解决方案。过去,这几乎意味着需要完全重写或寻找笨重的替代方案。

今天介绍的Veaury将彻底改变这一局面。这是一个专门设计用于在Vue和React之间实现无缝互操作的工具库。

核心问题与挑战

在实际开发中,跨框架组件复用面临诸多挑战:

  1. 上下文隔离:Vue和React有各自独立的上下文系统,数据传递困难
  2. 生命周期不匹配:两个框架的生命周期模型完全不同
  3. 事件系统差异:Vue使用自定义事件,React使用合成事件
  4. 渲染机制不同:Vue基于模板,React基于JSX

Veaury的技术实现原理

Veaury通过高阶组件(HOC)的方式,在两种框架之间搭建桥梁。其核心思路是:

// 简化版实现原理示意
function createCrossFrameworkWrapper(OriginalComponent, targetFramework) {
  return function Wrapper(props, context) {
    // 处理props转换
    const convertedProps = convertProps(props, targetFramework);
    
    // 处理上下文传递
    const frameworkContext = adaptContext(context, targetFramework);
    
    // 根据目标框架选择渲染方式
    if (targetFramework === 'vue') {
      return renderAsVue(OriginalComponent, convertedProps, frameworkContext);
    } else {
      return renderAsReact(OriginalComponent, convertedProps, frameworkContext);
    }
  };
}

主要特性

1. 完整的Vue 3支持

  • 支持Composition API和Options API
  • 支持Teleport、Suspense等Vue 3特性
  • 完整的响应式系统集成

2. 双向上下文共享

// React组件可以访问Vue的provide/inject
// Vue组件可以访问React的Context
const SharedComponent = ({ theme }) => {
  // theme可以来自Vue的provide或React的Context
  return <div className={`theme-${theme}`}>共享主题</div>;
};

3. 纯模式(Pure Mode)

消除包装器带来的额外DOM元素,保持组件树的整洁:

// 使用纯模式包装
const PureReactComponent = applyPureReactInVue(ReactComponent);
// 渲染结果没有额外的div包裹

4. 生命周期映射

Veaury智能地映射两个框架的生命周期:

Vue 生命周期 React 等效
onMounted useEffect(() => {}, [])
onUpdated useEffect(() => {})
onUnmounted useEffect(() => () => {})

实际应用示例

场景一:在Vue项目中使用React组件

<template>
  <div>
    <h2>Vue组件主体</h2>
    <!-- 直接使用React组件 -->
    <ReactDataTable :data="tableData" @row-click="handleRowClick" />
  </div>
</template>

<script setup>
import { ref } from 'vue';
import { applyPureReactInVue } from 'veaury';
import ReactDataTable from './ReactDataTable.jsx';

// 将React组件转换为Vue可用的组件
const ReactDataTable = applyPureReactInVue(ReactDataTable);

const tableData = ref([
  { id: 1, name: '项目A', value: 100 },
  { id: 2, name: '项目B', value: 200 }
]);

const handleRowClick = (rowData) => {
  console.log('行点击事件:', rowData);
  // 处理来自React组件的事件
};
</script>

场景二:在React项目中使用Vue组件

import React, { useState } from 'react';
import { applyVueInReact } from 'veaury';
import VueRichEditor from './VueRichEditor.vue';

const RichEditor = applyVueInReact(VueRichEditor);

function App() {
  const [content, setContent] = useState('');
  const [isDarkMode, setIsDarkMode] = useState(false);

  const handleContentChange = (newContent) => {
    setContent(newContent);
    // 处理来自Vue组件的事件
  };

  return (
    <div className={isDarkMode ? 'dark-theme' : 'light-theme'}>
      <h1>React应用中的Vue富文本编辑器</h1>
      <RichEditor
        modelValue={content}
        onUpdate:modelValue={handleContentChange}
        darkMode={isDarkMode}
        v-slots={{
          toolbar: () => <div>自定义工具栏</div>
        }}
      />
      <button onClick={() => setIsDarkMode(!isDarkMode)}>
        切换主题
      </button>
    </div>
  );
}

性能考虑

Veaury在性能方面做了大量优化:

  1. 最小化重渲染:通过精细的响应式侦听,避免不必要的重新渲染
  2. 内存效率:合理管理组件实例,避免内存泄漏
  3. 构建优化:支持Tree-shaking,只引入需要的功能

性能对比示例:

// 传统iframe方案 vs Veaury方案
// iframe:独立的DOM、样式和上下文,开销大
// Veaury:共享同一DOM,轻量级包装,性能接近原生

企业级应用实践

案例:低代码平台集成

某低代码平台使用Veaury实现插件系统:

  • 核心框架:Vue 3 + TypeScript
  • 插件生态:支持React和Vue两种插件
  • 实现效果:开发者可使用任意框架开发插件

案例:微前端架构

在微前端场景中,Veaury帮助不同技术栈的子应用共享组件:

// 主应用(Vue)使用子应用(React)的组件库
import { applyPureReactInVue } from 'veaury';
import ReactDesignSystem from 'team-react-ds';

// 在Vue主应用中直接使用React设计系统
const VueWrappedButton = applyPureReactInVue(ReactDesignSystem.Button);
const VueWrappedModal = applyPureReactInVue(ReactDesignSystem.Modal);

配置与构建

Vite配置示例

// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import veauryVitePlugins from 'veaury/vite';

export default defineConfig({
  plugins: [
    veauryVitePlugins({
      type: 'vue', // 或 'react',根据主框架选择
      vueOptions: {
        reactivityTransform: true // 启用响应式语法糖
      }
    })
  ],
  optimizeDeps: {
    include: ['veaury']
  }
});

Webpack配置要点

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.vue$/,
        use: 'vue-loader'
      },
      {
        test: /\.jsx$/,
        use: 'babel-loader',
        options: {
          presets: ['@babel/preset-react']
        }
      }
    ]
  }
};

局限性说明

尽管Veaury功能强大,但仍有一些限制:

  1. 部分高级特性:某些框架特定的高级特性可能不完全支持
  2. 开发体验:调试时需要了解两种框架
  3. 学习成本:团队需要同时熟悉Vue和React

总结

对于需要在Vue和React之间搭建桥梁的项目,Veaury提供了一个成熟、稳定的解决方案。无论是新项目技术选型,还是老项目现代化改造,都值得考虑这一工具。

技术栈不应成为创新的约束,而应是实现目标的工具。 Veaury正是这一理念的实践,让开发者能够专注于创造价值,而不是被框架之争所困扰。

❌