普通视图
飞荣达:现有产能可灵活应对当前订单交付需求 适时规划专用产能
荣耀家族机器人包揽冠亚季军
北京首个交警机器人亮相街头 参与指挥亦庄半程马拉松比赛
高德发布全球首个面向AGI的全栈具身技术体系“ABot”
WSBK荷兰站第二轮正赛将举行 “张雪机车”今日再战
首创证券冲击第14家“A+H”上市券商获证监会备案。
从“连接失败”到丝滑登录:我用 ethers.js 连接 MetaMask 的完整踩坑实录
背景
上个月,我接手了一个新的 DeFi 项目前端开发。第一个核心功能就是用户钱包连接。团队技术栈是 React + TypeScript,对于 Web3 交互,我们选择了老牌且功能强大的 ethers.js 库。我心想:“连接 MetaMask 嘛,官方文档例子那么多,还不是手到擒来?” 于是,我复制了一段最常见的示例代码,准备十分钟搞定这个功能。然而,现实很快给了我一个教训——从“连接”到“稳定可用”之间,隔着一整条满是坑的沟。
问题分析
我最开始的思路非常简单:检查 window.ethereum 是否存在,如果存在,就用 ethers.providers.Web3Provider 包装它,然后调用 provider.send('eth_requestAccounts', []) 来触发 MetaMask 的授权弹窗。代码跑起来,在已经安装了 MetaMask 的浏览器里,第一次点击确实弹窗了,连接成功了。
但问题接踵而至:
- 刷新页面后,连接状态丢失:用户需要重新点击连接并授权,体验极差。
- 用户切换了 MetaMask 账户:前端界面上的地址没有自动更新。
- 用户切换了网络(比如从以太坊主网切换到 Goerli 测试网):我们的 DApp 需要感知到这个变化,并可能提示用户切换回目标网络。
- 用户根本没有安装 MetaMask:页面直接报错,白屏。
最初的代码只处理了“发起连接”这个单一动作,完全没考虑 Web3 应用是“动态的”、“有状态的”。我意识到,我需要构建的不是一个“连接按钮”,而是一个完整的“钱包连接状态管理器”。它需要监听区块链提供者的各种事件,并将状态同步到 React 组件中。
核心实现
第一步:封装一个健壮的钱包连接钩子
我决定创建一个自定义 React Hook useWallet 来集中管理所有钱包状态。首先,要安全地获取 window.ethereum 对象。这里就有第一个坑:TypeScript 不知道 window.ethereum 的类型。
// types/global.d.ts
interface Window {
ethereum?: any; // 为了快速开发,可以先设为 any,更严谨的做法是导入 MetaMask 的 EIP-1193 类型
}
// hooks/useWallet.ts
import { useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';
declare global {
interface Window {
ethereum?: any;
}
}
export const useWallet = () => {
const [provider, setProvider] = useState<ethers.providers.Web3Provider | null>(null);
const [signer, setSigner] = useState<ethers.Signer | null>(null);
const [account, setAccount] = useState<string>('');
const [chainId, setChainId] = useState<number>(0);
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string>('');
// 初始化:检查是否已授权
useEffect(() => {
if (!window.ethereum) {
setError('请安装 MetaMask 钱包扩展');
return;
}
const initProvider = new ethers.providers.Web3Provider(window.ethereum, 'any'); // 'any' 允许任何网络
setProvider(initProvider);
// 尝试获取已连接的账户
initProvider.listAccounts().then((accounts) => {
if (accounts.length > 0) {
setAccount(accounts[0]);
setSigner(initProvider.getSigner());
}
});
// 获取当前网络链 ID
initProvider.getNetwork().then((network) => {
setChainId(network.chainId);
});
}, []);
}
注意:new Web3Provider(window.ethereum, 'any') 中的 'any' 参数很重要,它告诉 ethers 我们接受任何网络,这样在监听网络切换时不会抛出错误。
第二步:实现连接与断开连接
连接函数需要处理用户交互和可能的拒绝。
// 在 useWallet 钩子内
const connectWallet = useCallback(async () => {
if (!provider) {
setError('Provider 未初始化');
return;
}
setIsConnecting(true);
setError('');
try {
// 这会触发 MetaMask 弹窗
const accounts = await provider.send('eth_requestAccounts', []);
const currentAccount = accounts[0];
setAccount(currentAccount);
setSigner(provider.getSigner());
// 连接成功后,再获取一次最新的网络信息
const network = await provider.getNetwork();
setChainId(network.chainId);
} catch (err: any) {
console.error('连接钱包失败:', err);
// 用户拒绝连接是最常见的错误
setError(err.code === 4001 ? '用户拒绝了连接请求' : `连接失败: ${err.message}`);
} finally {
setIsConnecting(false);
}
}, [provider]);
const disconnectWallet = useCallback(() => {
// 注意:MetaMask 没有真正的“断开连接”API,这里只是清除本地状态
setAccount('');
setSigner(null);
setChainId(0);
// 在实际项目中,你可能还需要清除相关的应用状态(如用户余额、NFT等)
}, []);
这里有个坑:disconnectWallet 并不能让 MetaMask 忘记你的网站授权。真正的“断开”需要用户在 MetaMask 界面手动操作。我们只是在前端清除了状态。
第三步:监听账户与网络变化
这是实现“状态同步”的核心。我们需要监听 window.ethereum 发出的事件。
// 在 useWallet 钩子的 useEffect 中,初始化之后
useEffect(() => {
if (!window.ethereum) return;
// 监听账户变更
const handleAccountsChanged = (accounts: string[]) => {
console.log('accountsChanged', accounts);
if (accounts.length === 0) {
// MetaMask 被锁定或用户主动断开连接了所有账户
disconnectWallet();
} else if (accounts[0] !== account) {
// 用户切换了账户
setAccount(accounts[0]);
if (provider) {
setSigner(provider.getSigner());
}
}
};
// 监听链 ID 变更(网络切换)
const handleChainChanged = (_chainId: string) => {
// 注意:MetaMask 文档建议在链变更时刷新页面,但现代 DApp 通常不这样做
// 我们只是更新 chainId 状态,组件可以根据新 chainId 做出反应(如提示切换网络)
console.log('chainChanged', _chainId);
// chainId 是十六进制字符串,需要转换
setChainId(parseInt(_chainId, 16));
// 网络变了,provider 和 signer 实例其实还能用,但某些场景可能需要重置
if (provider) {
provider.getNetwork().then(network => setChainId(network.chainId));
}
};
window.ethereum.on('accountsChanged', handleAccountsChanged);
window.ethereum.on('chainChanged', handleChainChanged);
// 组件卸载时清除监听
return () => {
window.ethereum?.removeListener('accountsChanged', handleAccountsChanged);
window.ethereum?.removeListener('chainChanged', handleChainChanged);
};
}, [provider, account, disconnectWallet]); // 依赖项要小心,避免重复绑定
关键细节:chainChanged 事件回调的参数是十六进制字符串,而 ethers 的 chainId 是数字,需要转换。另外,监听器一定要在组件卸载时移除,防止内存泄漏。
第四步:在组件中使用并处理网络不匹配
最后,在组件中集成这个 Hook,并处理一个常见业务逻辑:如果用户不在我们支持的网络上,提示他切换。
// components/WalletConnector.tsx
import React from 'react';
import { useWallet } from '../hooks/useWallet';
import { shortenAddress } from '../utils/address'; // 一个格式化地址的辅助函数
const SUPPORTED_CHAIN_ID = 1; // 假设我们只支持以太坊主网
export const WalletConnector: React.FC = () => {
const {
account,
chainId,
isConnecting,
error,
connectWallet,
disconnectWallet,
} = useWallet();
const isOnSupportedNetwork = chainId === SUPPORTED_CHAIN_ID;
const handleSwitchNetwork = async () => {
if (!window.ethereum) return;
try {
// 尝试切换到以太坊主网
await window.ethereum.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: '0x1' }], // 主网的十六进制链ID
});
} catch (switchError: any) {
// 如果用户没有添加该网络,可以尝试添加它
if (switchError.code === 4902) {
try {
await window.ethereum.request({
method: 'wallet_addEthereumChain',
params: [{
chainId: '0x1',
chainName: 'Ethereum Mainnet',
nativeCurrency: { name: 'ETH', symbol: 'ETH', decimals: 18 },
rpcUrls: ['https://mainnet.infura.io/v3/YOUR_INFURA_KEY'],
blockExplorerUrls: ['https://etherscan.io'],
}],
});
} catch (addError) {
console.error('添加网络失败', addError);
}
}
console.error('切换网络失败', switchError);
}
};
if (error && !window.ethereum) {
return <div className="error">未检测到钱包,请安装 MetaMask。</div>;
}
return (
<div className="wallet-connector">
{!account ? (
<button onClick={connectWallet} disabled={isConnecting}>
{isConnecting ? '连接中...' : '连接钱包'}
</button>
) : (
<div className="wallet-info">
{!isOnSupportedNetwork && (
<div className="network-warning">
当前网络不受支持。
<button onClick={handleSwitchNetwork}>切换到主网</button>
</div>
)}
<span className="address">{shortenAddress(account)}</span>
<button onClick={disconnectWallet} className="disconnect-btn">
断开
</button>
</div>
)}
{error && <div className="error">{error}</div>}
</div>
);
};
完整代码
考虑到篇幅,这里提供一个整合后的 hooks/useWallet.ts 核心代码概览,以及一个简单的 utils/address.ts:
// hooks/useWallet.ts
import { useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';
declare global {
interface Window {
ethereum?: any;
}
}
export const useWallet = () => {
const [provider, setProvider] = useState<ethers.providers.Web3Provider | null>(null);
const [signer, setSigner] = useState<ethers.Signer | null>(null);
const [account, setAccount] = useState<string>('');
const [chainId, setChainId] = useState<number>(0);
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string>('');
const disconnectWallet = useCallback(() => {
setAccount('');
setSigner(null);
setChainId(0);
}, []);
const connectWallet = useCallback(async () => {
if (!provider) {
setError('Provider 未初始化');
return;
}
setIsConnecting(true);
setError('');
try {
const accounts = await provider.send('eth_requestAccounts', []);
const currentAccount = accounts[0];
setAccount(currentAccount);
setSigner(provider.getSigner());
const network = await provider.getNetwork();
setChainId(network.chainId);
} catch (err: any) {
console.error('连接钱包失败:', err);
setError(err.code === 4001 ? '用户拒绝了连接请求' : `连接失败: ${err.message}`);
} finally {
setIsConnecting(false);
}
}, [provider]);
useEffect(() => {
if (!window.ethereum) {
setError('请安装 MetaMask 钱包扩展');
return;
}
const initProvider = new ethers.providers.Web3Provider(window.ethereum, 'any');
setProvider(initProvider);
initProvider.listAccounts().then((accounts) => {
if (accounts.length > 0) {
setAccount(accounts[0]);
setSigner(initProvider.getSigner());
}
});
initProvider.getNetwork().then((network) => {
setChainId(network.chainId);
});
const handleAccountsChanged = (accounts: string[]) => {
if (accounts.length === 0) {
disconnectWallet();
} else if (accounts[0] !== account) {
setAccount(accounts[0]);
if (initProvider) {
setSigner(initProvider.getSigner());
}
}
};
const handleChainChanged = (_chainId: string) => {
setChainId(parseInt(_chainId, 16));
};
window.ethereum.on('accountsChanged', handleAccountsChanged);
window.ethereum.on('chainChanged', handleChainChanged);
return () => {
window.ethereum?.removeListener('accountsChanged', handleAccountsChanged);
window.ethereum?.removeListener('chainChanged', handleChainChanged);
};
}, [disconnectWallet]); // 注意依赖,这里只依赖了稳定的 disconnectWallet
return {
provider,
signer,
account,
chainId,
isConnecting,
error,
connectWallet,
disconnectWallet,
};
};
// utils/address.ts
export const shortenAddress = (address: string, chars = 4): string => {
if (!address) return '';
return `${address.substring(0, chars + 2)}...${address.substring(42 - chars)}`;
};
踩坑记录
-
Provider 未初始化错误:在connectWallet函数中直接使用provider,但provider的初始化在useEffect中,是异步的。在用户快速点击连接按钮时,provider可能还是null。解决:在函数开始处增加if (!provider) return;的判断。 -
重复监听事件导致内存泄漏:最初我把事件监听写在了一个没有依赖数组的
useEffect里,导致组件每次渲染都绑定新监听器,旧监听器未移除。解决:确保useEffect有正确的依赖数组,并在清理函数中removeListener。 -
网络切换后
signer失效的错觉:用户切换网络后,我最初错误地认为需要重新创建provider和signer。实际上,ethers的Web3Provider实例在传入'any'参数后,可以跨网络工作,signer仍然有效。需要更新的只是chainId状态。解决:在handleChainChanged中只更新chainId,除非有特定业务需求,否则不重置provider/signer。 -
chainId类型不一致:ethers的getNetwork()返回的chainId是number,而window.ethereum的chainChanged事件返回的是十六进制string。直接比较会导致判断失败。解决:统一转换为数字类型再比较,使用parseInt(_chainId, 16)。
小结
通过这次实践,我深刻体会到 Web3 前端连接钱包不仅仅是弹出一个授权窗口,更是一个需要持续监听和同步外部状态(账户、网络)的复杂功能。封装一个自定义 Hook 来集中管理这些状态和副作用,是让代码保持清晰、可维护的关键。下一步,可以在此基础上集成钱包余额查询、交易发送监听、以及多钱包提供商(如 WalletConnect)的支持。
国产光纤全球爆单 部分产品价格暴涨650%
36氪首发 | 前华为工程师创业,实现临时键合载板存量循环,龙头客户已验证
作者丨欧雪
编辑丨袁斯来
硬氪获悉,环晶芯科技已于近期完成数千万元天使轮融资。本轮由啟赋资本领投,盛世投资、海益资本及湖南省大学生创业投资基金跟投。资金将主要用于公司在无锡的首条量产线建设、研发投入及补充流动资金。
环晶芯科技成立于2025年5月,是国内首家提出临时键合载板无损回收复用方案的公司。公司创始人张介元拥有中科院、中科大、哈工大的研究背景及华为从业经历,师从国内打破临时键合胶垄断的第一人,对该类材料特性有深刻理解。
硬氪了解到,在先进封装中,为加工超薄晶圆或器件,需通过临时键合胶将其固定在坚硬的载板上。加工完成后,再将晶圆与载板分离。然而,用于粘合的临时键合胶具有极强的耐酸、耐碱、耐高温特性,导致载板表面的残胶极难清除。
当前在载板回收处理领域,行业内尚未形成成熟且可规模化量产的统一解决方案。仅有少数厂商会采用抛光工艺对载板表面进行处理,但会带来载板厚度不均一、刚性下降、产线管理复杂度提升等一系列问题。绝大多数封装厂通常使用一次后便只能丢弃或者囤积占据空间,造成巨大的成本浪费。
环晶芯科技的核心技术,可以做到临时键合载板的无损回收复用,降低先进封装辅料使用成本。整个加工过程不损伤载板材料,理论上可实现无限次回收复用。
经环晶芯科技处理后的玻璃载板,其表面洁净度、平整度等关键指标均与全新玻璃载板一致。目前,公司方案已通过国内封装龙头的技术验证,其回收服务可帮助客户大幅降低相关材料成本。
![]()
回收效果对比(图源/企业)
随着AI算力芯片、高性能计算(HPC)及消费电子对轻薄化需求的爆发,2.5D/3D封装、晶圆级封装(WLCSP)等先进封装技术渗透率持续提升,而这些工艺几乎无法避开临时键合技术。
张介元告诉硬氪:“无论是高算力的GPU、CPU,还是用于AI的高性能计算芯片,都离不开先进封装。未来从城市数据中心到家庭算力中心,整个市场对算力芯片的需求非常可观,将直接拉动对我们技术的需求。”
目前,公司已与多家国内外头部封测厂商完成接洽。公司一期产线位于无锡,设计月产能2.5万片,预计本月内完成建设,随后启动客户验厂导入流程。
以下为硬氪与张介元的对话节选:
硬氪:国内还没有其他公司做玻璃载板无损回收,技术壁垒在哪里?
张介元:这个技术从行业出现就一直有人在尝试。临时键合胶本身就是为了通过严苛的半导体制程而设计,耐酸碱、耐高温、耐离子冲击。而用强酸强碱浸泡或CMP研磨的回收方案,都有明显缺陷。
我们能做到无损,一方面是因为开发了独特的处理方案;另一方面,我的硕士导师是国内第一个实现该胶材量产、打破国际垄断的人,目前在国内市占率超50%。我跟随他多年,对材料特性的理解很深,这是我们最核心的底层优势。
硬氪:客户导入半导体供应链周期很长,公司如何解决前期市场开拓问题?
张介元:从技术验证到正式下单,封装厂通常需要半年以上。我们有几方面的策略:一是先通过关系较好的合作单位进行示范应用;二是主动与终端客户沟通,推动产业链协同发展;三是随着国内先进封装厂从几家增长到十几家,内卷加剧,降本成为核心诉求,这会大大降低我们的市场导入成本。
硬氪:公司未来的技术路径和商业化规划是怎样的?
张介元:短期看,我们的目标是2026年完成客户验证并导入订单。后续规划多条产线,就近服务当地封装产业,未来拓展海外业务。
长远看,我们定位是半导体行业的综合服务商。基于我们在临时键合领域的深刻理解,我们即将推出无碳化激光解键合设备,该加工效率更高且拥有提升生产良率的技术优势。我们也在联合哈工大做一些国产替代的研发工作。未来,我们会形成“材料研发+设备供应+工艺服务”的业务矩阵,计划以中国大陆临时键合载板回收市场为基本面,并逐步拓展至中国台湾、东南亚及日韩。
投资方观点:
海益投资认为:临时键合玻璃载板长期被海外厂商垄断,属于先进封装里典型的“卡脖子”耗材,单片成本高、占比大,国产替代逻辑极强。公司走的不是简单自研材料路线,而是通过清洗复用直接重构成本结构,既绕开了材料端的长期研发投入,又能快速兑现性价比,落地速度远超传统材料替代。
这种复用模式本质是服务+耗材的绑定,叠加高客户粘性,一旦抢占窗口期,会形成很强的先发壁垒,同时向上游材料延伸,相当于从“服务商”切到“材料商”,有极强的成长空间。
新突破 我国首个十万户级天然气掺氢应用项目启动
InheritedWidget 原理与性能
一、核心定位
InheritedWidget 是 Flutter 框架中用于实现跨组件、跨层级高效状态共享的核心机制,本质是一种依赖注入方案,用于解决 Widget 树中深层组件获取上层数据的问题,避免通过构造函数逐层传值的繁琐操作(即“prop drilling”),也是 Provider、Riverpod 等主流状态管理框架的底层实现基础。
常见应用场景:Theme、MediaQuery、Localizations 等 Flutter 内置功能,均通过 InheritedWidget 实现全局状态共享,例如 Theme.of(context).primaryColor 就是典型的 InheritedWidget 用法。
二、核心原理
2.1 核心特性
- 不可变性(Immutable) :InheritedWidget 本身是不可变组件,其内部存储的数据通常标记为 final,无法直接修改,需配合 StatefulWidget、StateNotifier 等组件管理数据变更,通过重建 InheritedWidget 实现状态更新。
- 依赖注册与通知机制:子组件通过特定方法获取 InheritedWidget 数据时,会自动注册为依赖者;当 InheritedWidget 数据变化且满足通知条件时,仅通知所有依赖它的子组件重建,而非整个子树重建,保证更新效率。
- 树内传递特性:数据仅在当前 Widget 树内共享,无法跨树使用,依赖 BuildContext 实现上层查找,脱离当前上下文无法获取数据。
2.2 底层实现机制(源码级简化)
2.2.1 核心类与方法
InheritedWidget 继承自 ProxyWidget,核心方法与关联类如下:
-
createElement():返回 InheritedElement 实例,作为 InheritedWidget 在 Element 树中的对应节点,负责管理依赖关系。 -
updateShouldNotify(oldWidget):抽象方法,用于判断 InheritedWidget 重建时,是否需要通知依赖它的子组件。返回 true 则通知,返回 false 则不通知,是控制性能的关键方法。 -
InheritedElement:继承自 ProxyElement,内部维护_dependents集合(Map<Element, Object>),用于存储所有依赖当前 InheritedWidget 的子 Element,是依赖关系的核心载体。
2.2.2 依赖建立与更新流程(4步)
-
数据共享初始化:将 InheritedWidget 嵌入 Widget 树上层,其对应的 InheritedElement 会在挂载(mount)和激活(active)阶段,通过
_updateInheritance()方法,将自身信息写入所有子 Element 的_inheritedWidgets映射中,实现数据向下传递的基础。 -
依赖注册:子组件通过
context.dependOnInheritedWidgetOfExactType<T>()方法(通常封装为of(context)简化调用),向上查找最近的指定类型 T 的 InheritedWidget。此时,当前子 Element 会被注册到 InheritedElement 的_dependents集合中,正式建立依赖关系。 - 数据更新触发:通过 setState 等方式修改 InheritedWidget 中的数据,触发 InheritedWidget 重建,生成新的实例。
-
依赖通知与重建:框架调用
updateShouldNotify(oldWidget)方法,若返回 true,InheritedElement 会遍历_dependents集合,调用所有依赖 Element 的markNeedsBuild()方法,触发这些子组件重建;若返回 false,则不进行任何通知,避免无效重建。
2.2.3 关键补充:两种查找方式的区别
子组件获取 InheritedWidget 数据有两种核心方式,直接影响是否建立依赖关系:
-
dependOnInheritedWidgetOfExactType:建立依赖关系,当 InheritedWidget 数据变化且满足通知条件时,当前子组件会被重建(最常用方式)。 -
getElementForInheritedWidgetOfExactType:仅获取 InheritedWidget 数据,不建立依赖关系,数据变化时不会触发当前子组件重建,适用于仅读取数据、不依赖数据更新的场景。
三、性能分析
3.1 优势:高效的状态共享
- 精准更新,避免冗余重建:仅通知依赖的子组件重建,而非整个 Widget 树,相比全局状态(如全局变量)的“一刀切”更新,大幅减少不必要的重建操作,提升渲染性能。
-
查找效率接近 O(1) :每个 Element 都维护
_inheritedWidgets映射,存储所有祖先 InheritedElement 的引用,子组件查找时无需逐层遍历 Widget 树,直接通过映射获取,查找效率极高。 -
无侵入式集成:无需修改子组件结构,仅通过
of(context)即可获取数据,代码侵入性低,易于维护和扩展,适配 Flutter 响应式架构。
3.2 潜在性能隐患
- 过度依赖导致大面积重建:若多个无关子组件均依赖同一个 InheritedWidget,当数据变化时,所有依赖组件都会重建,可能引发性能瓶颈(例如将全局状态都放入一个 InheritedWidget 中)。
-
updateShouldNotify滥用:若该方法始终返回 true,即使数据未发生实际变化,也会通知所有依赖组件重建,造成无效渲染;若返回 false 时机不当,会导致数据更新后子组件无法同步刷新。 -
不必要的依赖注册:子组件仅需读取数据、无需响应更新时,仍使用
dependOnInheritedWidgetOfExactType建立依赖,导致多余的重建触发。 - 数据粒度粗放:InheritedWidget 本身不支持细粒度数据监听,若存储的是复杂对象,即使仅其中一个字段变化,也会触发所有依赖组件重建,无法精准控制更新范围。
四、性能优化实践
4.1 精准控制通知时机
优化 updateShouldNotify 方法,仅在数据发生实际变化时返回 true,避免无效通知。示例:
class MyInherited extends InheritedWidget {
final int count;
const MyInherited({super.key, required super.child, required this.count});
// 仅当count发生变化时,通知依赖组件
@override
bool updateShouldNotify(covariant MyInherited oldWidget) {
return count != oldWidget.count; // 精准对比核心数据
}
static MyInherited of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<MyInherited>()!;
}
}
注意:避免在该方法中执行复杂计算、网络请求等耗时操作,否则会影响渲染性能。
4.2 拆分 InheritedWidget,细化数据粒度
将不同类型的状态拆分到多个独立的 InheritedWidget 中,使子组件仅依赖自身需要的状态,避免“一个 InheritedWidget 管理所有状态”导致的大面积重建。例如:将主题状态、用户信息状态、计数器状态分别封装为独立的 InheritedWidget,子组件按需依赖,数据变化时仅影响对应依赖者。
4.3 合理选择查找方式,避免多余依赖
仅读取数据、无需响应数据更新的子组件,使用 getElementForInheritedWidgetOfExactType 替代 dependOnInheritedWidgetOfExactType,避免建立不必要的依赖关系,减少无效重建。示例:
// 仅读取数据,不依赖更新
final myInherited = context.getElementForInheritedWidgetOfExactType<MyInherited>()?.widget as MyInherited;
final count = myInherited.count;
4.4 使用 InheritedModel 实现细粒度更新
InheritedModel 是 InheritedWidget 的增强版,支持按“维度(aspect)”控制更新范围,允许子组件仅监听特定字段的变化,适用于复杂状态场景。示例:
// 定义支持多维度的InheritedModel
class MyModel extends InheritedModel<String> {
final int countA;
final int countB;
const MyModel({super.key, required super.child, required this.countA, required this.countB});
@override
bool updateShouldNotify(covariant MyModel oldWidget) {
return countA != oldWidget.countA || countB != oldWidget.countB;
}
// 仅当依赖的维度发生变化时,通知子组件
@override
bool updateShouldNotifyDependent(MyModel oldWidget, Set<String> dependencies) {
if (dependencies.contains('countA')) return countA != oldWidget.countA;
if (dependencies.contains('countB')) return countB != oldWidget.countB;
return false;
}
// 子组件按需监听指定维度
static MyModel ofA(BuildContext context) {
return InheritedModel.inheritFrom<String>(context, aspect: 'countA')!;
}
static MyModel ofB(BuildContext context) {
return InheritedModel.inheritFrom<String>(context, aspect: 'countB')!;
}
}
此时,仅监听“countA”的子组件,仅在 countA 变化时重建;监听“countB”的子组件,仅在 countB 变化时重建,大幅提升性能。
4.5 拆分依赖子树,隔离静态组件
将依赖 InheritedWidget 的组件拆分为独立子树,非依赖组件使用 const 构造函数,避免因 InheritedWidget 变化导致非依赖组件重建。示例:
Widget build(BuildContext context) {
final theme = Theme.of(context); // 获取依赖数据
return Column(
children: [
// 依赖子树:仅当theme变化时重建
_ThemeDependentPart(theme: theme),
// 静态组件:使用const,不会因theme变化重建
const ExpensiveStaticWidget(),
],
);
}
// 独立依赖子组件
class _ThemeDependentPart extends StatelessWidget {
final ThemeData theme;
const _ThemeDependentPart({required this.theme});
@override
Widget build(BuildContext context) {
return Text("依赖主题的文本", style: theme.textTheme.titleLarge);
}
}
4.6 结合 DevTools 定位性能问题
通过 Flutter DevTools 的 Performance 面板,启用“Track widget rebuilds”功能,定位因 InheritedWidget 导致的过度重建:
- 运行应用:
flutter run --profile; - 在 DevTools 中查看 Widget 重建次数,识别频繁重建的依赖组件;
- 跳转至源码,检查依赖注册方式和
updateShouldNotify实现,针对性优化。
五、常见误区
- 误区1:认为 InheritedWidget 可以直接修改状态:InheritedWidget 本身是不可变的,无法直接修改内部数据,需配合 StatefulWidget 或状态管理工具(如 StateNotifier)管理数据变更,通过重建 InheritedWidget 实现状态更新。
- 误区2:滥用 InheritedWidget 管理所有状态:将全局所有状态放入一个 InheritedWidget 中,会导致数据变化时大面积组件重建,应按功能拆分多个 InheritedWidget,细化数据粒度。
-
updateShouldNotify误区3:忽略 的优化:始终返回 true 会导致无效重建,始终返回 false 会导致数据更新无法同步,需根据实际数据变化逻辑精准实现该方法
Hitch Open联合智元发起全球首个AI自主决策机器人乒乓球赛事
荣耀齐天大圣队“闪电”机器人获得北京亦庄半马冠军
河南省脑机接口康复设备技术创新中心揭牌
遗嘱、水管与抢救室:TS 切入 Go 的流程控制、接口与并发
🚀 省流助手(速通结论)
- Defer 扫尾:它是函数的“遗愿”,锁定的是函数作用域而非代码块。后进先出(LIFO)执行。
- 接口纯粹性:Go 接口严禁包含变量。它只定义行为,且实现是隐式的(不需要
implements)。 - 切片不是数组:
Slice是底层内存的窗口。扩容会触发“搬家”,不注意copy会导致数据人格分裂。 - 并发解耦:
WaitGroup负责同步,Channel负责传球。不要通过共享内存来通信。
1. Defer 扫尾机制:它是“遗嘱”而非 finally
在 TS 中,finally 紧跟在 try 代码块之后。但在 Go 中,defer 是函数级的延迟调用。
TypeScript(代码块级收尾)
async function writeInfo() {
try {
const file = await openFile();
// ... 逻辑 A
} finally {
file.close(); // 块结束立刻执行
}
// ... 逻辑 B (此时文件已关闭)
}
Go(函数级遗嘱)
func writeInfo() {
file, _ := os.Open("test.txt")
// defer 锁死的是整个函数。即使逻辑 B 还在跑,file 也不会关
defer file.Close()
// 如果逻辑多,必须包装成匿名函数并显式调用 ()
defer func() {
fmt.Println("开始清理多项资源")
// 复杂收尾逻辑...
}()
}
🪝 思维钩子:defer 像是在函数出口处“埋雷”。多个 defer 会像堆盘子一样后进先出(最后声明的先执行)。
2. 接口的行为契约:严禁携带“私货”
在 TS 中,interface 既可以定义方法也可以定义属性(变量)。但在 Go 中,接口是纯粹的行为契约。
TypeScript(混合定义)
interface ReadWriter {
readonly id: number; // ✅ 合法:可以包含属性
read(): void;
}
Go(纯粹行为)
type ReadWriter interface {
// b int // ❌ 编译报错:接口不能包含数据字段
Read()
Write()
}
🪝 思维钩子:Go 接口只关心“你能做什么”,而不关心“你长什么样”。想定义属性?请回 struct。这种纯粹性让 Go 的隐式实现(只要方法对上,就自动实现接口)变得异常强大。
3. 切片的动态魔术:小心“窗口”背后的陷阱
TS 开发者常把 Slice 当成普通 Array。实际上,它是指向底层内存的一个带容量描述的窗口。
TypeScript(切片即副本)
const original = [1, 2, 3];
const sub = original.slice(0, 2);
sub[0] = 99;
console.log(original[0]); // 1 (原数组不受影响)
Go(切片即视图)
original := []int{1, 2, 3}
sub := original[0:2] // sub 是原内存的“窗口”
sub[0] = 99
fmt.Println(original[0]) // ⚠️ 99 (原数组被同步修改了!)
// 💡 只有执行 copy() 才是真正的“深拷贝”
🪝 思维钩子:append 操作是分水岭。如果容量(Cap)够,它改原件;如果容量不够触发扩容,它会偷偷“搬家”并断开与原数组的联系。
4. 并发等待的范式:从 Promise 到通道
TS 靠 Promise.all 监听状态,Go 靠 sync.WaitGroup 计数或 Channel 传球。
TypeScript(状态监听)
// 并行运行,主线程通过 Promise 状态获知结束
await Promise.all([task1(), task2()]);
Go(计数同步)
var wg sync.WaitGroup
// 任务抽离为独立函数时,必须传递指针 *sync.WaitGroup
func doTask(i int, wg *sync.WaitGroup) {
defer wg.Done() // 任务完成,计数减一
fmt.Println(i)
}
func main() {
wg.Add(2) // 显式计数
go doTask(1, &wg) // 开启独立协程
go doTask(2, &wg)
wg.Wait() // 阻塞直到计数归零
}
🪝 思维钩子:go 关键字开启的是一个并行时空。WaitGroup 是你的“考勤表”,而 Channel 是不同时空间互通有无的“输油管”。
结语:
通过这三篇的“直觉对手戏”,你已经完成了从 TS 到 Go 的底层思维重构。Go 的魅力不在于语法糖,而在于那份极致的确定性。
祝你在 Gopher 的世界里,写出像水一样清澈的代码。
从 Demo 到生产:为什么你的 AI 功能一上线就成了不可控的“黑盒”?
做 AI 功能时,痛苦的通常不是“完全不会写”,而是另一种更微妙的状态:功能大概有了,接口也通了,但只要一出问题,整个人就开始陷入循环:是前端没发出去吗?后端没收到吗?模型调用失败了吗?还是解析挂了?
最难受的地方不是失败,而是失败以后你几乎看不见它是怎么失败的。在 AI 应用这种长链路场景下,“可观测性”不是附属品,而是核心竞争力。
💡 省流助手
- 黑盒困境:AI 链路极长(前端 -> 后端 -> 模型服务 -> 解析 -> 返回),单点失败会全线崩溃。
-
核心方案:建立“可观测性”。引入 Request ID 串联全链路,并使用结构化日志替代零散的
print。 - 实战动作:记录原始 Response、监控 Token 消耗、量化 Latency(耗时统计)。
为什么“能跑”和“能维护”之间差了一层“可见性”?
很多人初学 AI 功能的目标是“先把它跑通”。但在真实生产环境下,你需要的不仅是它能跑,而是:
- 失败时可定位:一眼看出哪层断了。
- 异常时可复现:拿到当时的上下文。
- 排错不靠猜:前后端别再互相甩锅。
在并发/分布式场景下,单点调试已经彻底失效。你必须通过日志,让系统“开口说话”。
代码范式:从“盲目打印”到“全链路追踪”
❌ 错误做法:随缘 print(日志碎了一地,根本接不起来)
当并发超过 2 个时,你根本分不清控制台里的输出属于哪个用户,哪些是成功的,哪些是重试的。
# 典型的“碎地式”打印
def call_ai(text):
print(f"开始调用模型: {text}")
res = model.invoke(text)
print(f"模型返回了: {res}")
return parse(res)
✅ 正确做法:结构化日志 + Request ID(全链路定责)
给每次请求发一张“身份证”,让所有关联日志都打上这个标记。
import uuid
import logging
# 1. 结构化日志配置(通常在全局初始化)
logger = logging.getLogger("AI-Service")
def ai_handler(text):
# 2. 为每次请求生成唯一“身份证”
request_id = str(uuid.uuid4())
extra = {"request_id": request_id}
logger.info(f"Step 1: 收到前端请求 | Input: {text[:20]}...", extra=extra)
try:
# 3. 记录耗时和原始输出
res = model.invoke(text)
logger.info("Step 2: 模型调用成功 | Response: {res[:50]}", extra=extra)
result = parse(res)
logger.info("Step 3: 结果解析完成", extra=extra)
return result
except Exception as e:
# 4. 异常捕获必须带上下文,否则你永远不知道是哪个输入触发了报错
logger.error(f"全链路崩溃 | 原因: {str(e)}", extra=extra, exc_info=True)
raise e
生产环境避坑指南
1. JSON 沉默失败陷阱
模型有时会返回包含 markdown 代码块的 JSON。如果解析器没处理好,会直接报错,导致前端拿到一个空对象。
- 对策:日志中必须记录原始 Response。当你发现解析报错时,能回看模型到底吐了什么“怪东西”。
2. Token 溢出与隐形杀手
上下文过长时,模型会直接截断输出。
-
对策:日志记录中必须包含
usage(Token 消耗)。如果输出只说了一半,看一眼日志里的 Token 限制你就全明白了。
3. Latency(耗时)是排查性能瓶颈的唯一手段
分清是后端没收到请求,还是模型在“憋大招”超时。
- 对策:在日志中量化每一段的耗时(网络时间 vs 模型生成时间)。
协作效率:日志是最低成本的沟通工具
在团队里,“看不见问题”会迅速变成摩擦点:
- 前端说:“我页面转圈,接口挂了。”
- 后端说:“我不确定请求到我这没。”
全链路追踪 (Tracing) 建立后,讨论会变样:
“看这个 request_id: a1b2...,请求 10:05 进来的,模型在 10:06 返回了 500,是 Prompt 触发了敏感词拦截。”
这就叫确定性线索。
给前端开发者的建议
下次写 AI 功能,除了逻辑实现,请强制自己问这 3 个问题:
- 如果这次请求失败了,我能在 30 秒内定位是哪层挂了吗?
- 如果用户说“刚刚那条不对”,我能通过日志找到原始输入和模型输出吗?
- 我的日志是否具备系统可观测性 (Observability)?
结语
很多 AI 功能做不下去,不是因为开发者不会写,而是因为每次出问题系统都“失声”了。结构化日志 (Structured Logging) 不仅是调试工具,更是你作为工程开发者的专业护城河。
标签:AI、前端、架构、Python、工程化
📄 第三篇:Vue 3 命令式弹窗 Provide 污染与关闭动画修复
问题背景
在使用 useCommandComponent 封装命令式弹窗时,遇到了两个典型的底层机制问题:
- Provide/Inject 数据污染:多次打开弹窗后,子组件 inject 到了上一次残留的旧数据。
- 关闭动画丢失:弹窗关闭时 DOM 被过早销毁,导致过渡动画无法完整播放。
修复点1:AppContext 上下文污染
1. 问题复现
测试场景
<!-- TestModal.vue -->
<script setup>
import { provide, inject } from 'vue'
// 每次打开都 provide 一个随机值
provide('modalConfig', Math.random())
// 尝试 inject 同一个 key
const config = inject('modalConfig')
console.log('inject 结果:', config)
</script>
// App.vue
const showModal = useCommandComponent(TestModal)
操作流程与现象
| 操作 | provide 的值 | inject 预期 | inject 实际 | 状态 |
|---|---|---|---|---|
| 第1次打开 | 0.123456 | undefined | undefined | ✅ 正常 |
| 关闭弹窗 | - | - | - | - |
| 第2次打开 | 0.789012 | undefined | 0.123456 | ❌ 污染 |
关键特征:
- 首次运行正常,第2次才出问题。
- 不报错,只是数据不对(最难排查的 bug 类型)。
- 拿到的不是本次 provide 的值,而是上一次的残留。
2. 根本原因分析
错误代码
// useCommandComponent.js - 有问题的版本
export const useCommandComponent = (Component) => {
const instance = getCurrentInstance()
// ❌ 直接修改全局 appContext.provides
const appContext = instance?.appContext
const currentProvides = instance?.provides
if (appContext && currentProvides) {
Reflect.set(appContext, 'provides', currentProvides)
}
// ...
}
污染链路详解
初始状态:
App.appContext.provides = {}
第1次注册(App.vue 中调用):
const showModal = useCommandComponent(TestModal)
// instance = App 实例
// currentProvides = App.provides = {}
// 覆盖全局(此时是空对象,暂时没问题)
Reflect.set(App.appContext, 'provides', {})
第1次打开弹窗:
showModal()
// 创建 TestModal 实例1
TestModal实例1.provides = Object.create(App.appContext.provides)
// setup 执行
provide('modalConfig', 0.123456)
// TestModal实例1.provides = { modalConfig: 0.123456 }
const config = inject('modalConfig')
// 查询链:TestModal实例1.provides → App.appContext.provides
// 结果:undefined ✅(符合预期)
// ⚠️ 如果内部嵌套调用 useCommandComponent
const showChild = useCommandComponent(ChildComponent)
// currentProvides = TestModal实例1.provides
// Reflect.set(App.appContext, 'provides', TestModal实例1.provides)
// App.appContext.provides = { modalConfig: 0.123456 } ❌ 被污染!
关闭弹窗:
// unmount(TestModal实例1)
// ❌ 但 App.appContext.provides 没有被恢复
// 仍然是:{ modalConfig: 0.123456 }
第2次打开弹窗:
showModal()
// 创建 TestModal 实例2
TestModal实例2.provides = Object.create(App.appContext.provides)
// TestModal实例2.provides.__proto__ = { modalConfig: 0.123456 } ❌ 原型链指向旧数据
// setup 执行
provide('modalConfig', 0.789012)
// TestModal实例2.provides = { modalConfig: 0.789012 }
const config = inject('modalConfig')
// 查询链:TestModal实例2.provides → App.appContext.provides
// 结果:0.123456 ❌ 拿到了第1次的残留数据!
核心问题
嵌套调用 useCommandComponent
↓
覆盖全局 appContext.provides
↓
关闭后未恢复
↓
新实例的原型链指向旧数据
↓
inject 通过原型链查到残留值
3. 修复方案
修复代码
// useCommandComponent.js - 修复版本(第30-35行)
export const useCommandComponent = (Component) => {
const instance = getCurrentInstance()
// ✅ 先复制 appContext,再独立设置 provides
const appContext = { ...instance?.appContext }
Reflect.set(appContext, 'provides', currentProvides)
// ...
}
修复原理对比
修复前(有问题):
const appContext = instance?.appContext
Reflect.set(appContext, 'provides', currentProvides)
// ❌ 所有调用共用同一个 appContext,互相覆盖
修复后(正确):
const appContext = { ...instance?.appContext }
Reflect.set(appContext, 'provides', currentProvides)
// ✅ 两步操作:
// 1. 创建完全独立的新对象
// 2. 单独设置 provides 属性
// 彻底隔离,互不影响
关键点:
-
{ ...instance?.appContext }创建全新的独立对象。 - 新对象与原始
appContext没有任何关联。
修复点2:onClosed 回调与关闭动画
1. Element Plus 的关闭事件机制
Element Plus 的 <el-dialog> 有两个关闭相关的事件:
| 事件 | 触发时机 | 说明 |
|---|---|---|
@close |
用户点击关闭按钮时 | 动画开始前立即触发 |
@closed |
关闭动画播放完成后 | 动画结束后触发 |
完整执行流程:
用户点击关闭按钮
↓
① @close 触发(此时动画还没开始)
↓
② 播放关闭动画(约 300ms)
↓
③ @closed 触发(动画已完全结束)
2. 为什么要用 onClosed 而不是 onClose?
关键问题:DOM 清理时机
命令式弹窗需要在关闭后清理 DOM:
const closed = () => {
render(null, container) // 卸载组件
container.remove() // 移除 DOM
}
如果在 @close 时清理:
用户点击关闭
↓
@close 触发
↓
立即执行 closed() → render(null, container)
↓
❌ 组件被销毁,动画无法继续播放
❌ 用户看到弹窗"瞬间消失",没有过渡效果
如果在 @closed 时清理:
用户点击关闭
↓
@close 触发(动画开始)
↓
播放关闭动画(300ms)✅ 动画完整播放
↓
@closed 触发(动画结束)
↓
执行 closed() → render(null, container) ✅ 安全清理
3. Vue 属性透传的作用
当弹窗组件作为根元素且未在 defineProps 中声明 onClosed 时,Vue 会自动将其透传给内部的 <el-dialog>。
<!-- TestModal.vue -->
<template>
<!-- el-dialog 是根元素 -->
<el-dialog v-model="visible">
弹窗内容
</el-dialog>
</template>
<script setup>
// 没有声明 onClosed,Vue 自动透传
</script>
外部调用:
showModal({
onClosed: () => console.log('用户回调')
})
结果: onClosed 被透传给 <el-dialog>,相当于:
<el-dialog v-model="visible" @closed="onClosed">
4. 实现方案
核心逻辑
// useCommandComponent.js(第42-60行)
// 清理函数
const closed = () => {
render(null, container)
container.parentNode?.removeChild(container)
}
const CommandComponent = (options = {}) => {
// ... 其他逻辑
// ✅ 统一处理 onClosed,确保动画完整 + DOM 清理
if (typeof options.onClosed !== 'function') {
// 用户没提供 onClosed,使用默认清理函数
options.onClosed = closed
} else {
// 用户提供了 onClosed,包裹一层确保能清理 DOM
const originOnClosed = options.onClosed
options.onClosed = (...args) => {
originOnClosed(...args) // 先执行用户回调
closed() // 再执行 DOM 清理
}
}
// ...
}
📄 第二篇:Vue 3 命令式弹窗 provide/inject 机制解析
1. 标准组件 vs 命令式组件
什么是标准组件?
通过模板声明,由 Vue 自动管理。
<!-- App.vue -->
<template>
<!-- ✅ 标准组件:在模板中声明 -->
<ChildComponent />
</template>
特点:
- 写在
<template>里 -
parent指向父组件实例
什么是命令式组件?
通过函数调用创建,手动挂载到 DOM。
// useCommandComponent.js
export const useCommandComponent = (Component) => {
const container = document.createElement('div')
return (options = {}) => {
const vNode = createVNode(Component, options)
render(vNode, container) // ← 手动渲染
document.body.appendChild(container)
}
}
<!-- App.vue - 使用 -->
<script setup>
const showModal = useCommandComponent(TestModal)
function open() {
showModal({ title: '弹窗' }) // ← 函数调用
}
</script>
特点:
- 不在模板中声明
- 通过函数调用(如
showModal()) -
parent = null(没有父组件)
2. 为什么 parent = null?
标准组件的渲染流程
// Vue 内部
patch(parentVNode, childVNode, container, parentComponent)
// ^^^^^^^^^^^^^^
// 传入父组件实例
结果: ChildComponent.parent = App实例
命令式组件的渲染流程
// useCommandComponent 内部
render(vNode, container)
// Vue 内部
patch(null, vNode, container, null, ...)
// ^^^^
// parent 传的是 null
结果: TestModal.parent = null
原因: 命令式组件不是通过父组件模板渲染的,而是直接 render 到 DOM,Vue 将其视为"根组件"。
3. provides 初始化逻辑
Vue 源码(简化版)
function createComponentInstance(vnode, parent, suspense) {
const instance = {
parent: parent,
appContext: vnode.appContext,
// 关键:provides 的初始化方式
provides: parent
? parent.provides // 有 parent:直接引用父组件的 provides
: Object.create(vnode.appContext.provides) // 无 parent:创建新对象
}
return instance
}
两种情况的内存结构
标准组件(ChildComponent 的父组件是 App)
App实例.provides = { config: 'app数据' }
ChildComponent实例.provides = App实例.provides // ← 同一个对象引用
特点: 父子共用同一个 provides 对象。
命令式组件(TestModal 在 App 中创建)
appContext.provides = { config: 'app数据' }
TestModal实例.provides = {} // 新空对象
TestModal实例.provides.__proto__ → appContext.provides
特点:
-
provides是独立空对象 - 原型链指向
appContext.provides
4. provide/inject 的逻辑
provide 的行为
function provide(key, value) {
const instance = getCurrentInstance()
// 如果 provides 和 parent.provides 是同一个对象
if (instance.parent && instance.provides === instance.parent.provides) {
// 写时复制:创建新对象,避免污染父组件
instance.provides = Object.create(instance.provides)
}
// 写入自己的 provides
instance.provides[key] = value
}
关键点: provide 总是写入当前实例自己的 provides。
inject 的行为(核心差异)
function inject(key) {
const instance = getCurrentInstance()
if (instance.parent == null) {
// ⚠️ 命令式组件走这里
const provides = instance.vnode.appContext.provides
// 查的是 appContext.provides,不是 instance.provides
if (key in provides) {
return provides[key]
}
} else {
// 标准组件走这里
const provides = instance.parent.provides
// 查的是父组件的 provides
if (key in provides) {
return provides[key]
}
}
}
关键差异:
-
标准组件:
inject查parent.provides -
命令式组件:
inject查appContext.provides
5. 实际示例
场景设置
// App.vue
provide('config', { theme: 'dark' })
const showModal = useCommandComponent(TestModal)
<!-- TestModal.vue -->
<script setup>
provide('modalConfig', { title: '我是弹窗' })
const config = inject('config') // ← 能拿到吗?
</script>
执行流程
1. 创建 TestModal 实例
TestModal实例.provides = {}
TestModal实例.provides.__proto__ → appContext.provides = { config: { theme: 'dark' } }
2. TestModal setup 执行
// provide
provide('modalConfig', { title: '我是弹窗' })
// TestModal实例.provides = { modalConfig: { title: '我是弹窗' } }
// inject
const config = inject('config')
// 因为 parent === null
// 查的是 appContext.provides
// appContext.provides.config → ✅ 找到 { theme: 'dark' }
结果: config = { theme: 'dark' } ✅
6. 子组件的情况
ChildTestModal(TestModal 的子组件)
<!-- TestModal.vue 模板 -->
<template>
<ChildTestModal />
</template>
// ChildTestModal 实例
ChildTestModal.parent = TestModal实例
ChildTestModal.provides = TestModal实例.provides // 初始时是同一个对象
ChildTestModal 调用 provide
<!-- ChildTestModal.vue -->
<script setup>
provide('childData', '子组件数据')
</script>
// provide 内部检测到 provides === parent.provides
ChildTestModal.provides = Object.create(TestModal实例.provides)
// 现在是一个新对象
ChildTestModal.provides.__proto__ → TestModal实例.provides
ChildTestModal.provides.childData = '子组件数据'
结果: 子组件不调用 provide 函数他的 provides 就等于父组件的 provides, 调用 provide 函数子组件的 provides 就是一个原型链指向父组件 provides 的新对象。
7. 小结
核心要点
-
命令式组件
parent = null:因为是直接render挂载,没有父组件 -
provides 初始化不同:命令式组件用
Object.create创建独立对象 -
inject 查找链不同:命令式组件查
appContext.provides,标准组件查parent.provides -
子组件有无 provide 时结果不同:避免污染父组件的
provides
所以这是命令式组件可以 inject 到 App provide 提供的数据的原理 ✅