Cairo 中的组件(Components)其行为类似于 Solidity 中的抽象合约(abstract contracts)。它们可以定义和处理存储(storage)、事件(events)和函数(functions),但不能独立部署。组件的预期用途是以类似于 Solidity 抽象合约的方式分离逻辑(例如:可重用性)。
请看以下 Solidity 代码:
abstract contract C {
uint256 balance;
function increase_balance(uint256 amount) public {
require(amount != 0, "amount cannot be zero");
balance = balance + amount;
}
function get_balance() public view returns (uint256) {
return x;
}
}
contract D is C {
}
合约 C 无法被部署,因为它是抽象的。然而,如果部署了 D,那么 D 将拥有 C 的所有功能和状态。具体而言,D 将拥有公共函数 increase_balance() 和 get(),其行为与在 C 中定义的完全一致。
D 继承了 C 的所有函数、事件和存储。
我们今天要构建的合约,等同于上面展示的 Solidity 代码在 Cairo 中的实现。
最小化组件示例
创建一个空目录并在其中运行 scarb init。
将下面的代码粘贴到 src/lib.cairo 中。使用 scarb test 运行生成的测试;它们应该都会通过。
以下是这段代码的作用:
- 它声明了一个接口(interface),其中包含两个函数,用于增加并返回合约中存储的余额。
- 它创建了一个组件,该组件定义了自己的存储
x,并使用读/写操作实现了increase和get_balance函数。 - 合约导入了该组件,注册了它的存储和事件,并通过其 ABI 暴露了组件的实现。
我们将在代码之后解释它们是如何协同工作的:
// SAME TRAIT SCARB CREATES BY DEFAULT
#[starknet::interface]
pub trait IHelloStarknet<TContractState> {
/// Increase contract balance.
fn increase_balance(ref self: TContractState, amount: felt252);
/// Retrieve contract balance.
fn get_balance(self: @TContractState) -> felt252;
}
// COMPONENT IS NEW
#[starknet::component]
pub mod CounterComponent {
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
#[storage]
pub struct Storage {
x: felt252,
}
#[event]
#[derive(Drop, Debug, PartialEq, starknet::Event)]
pub enum Event {}
#[embeddable_as(CounterImplMixin)]
impl CounterImpl<TContractState, +HasComponent<TContractState>> of super::IHelloStarknet<ComponentState<TContractState>> {
fn get_balance(self: @ComponentState<TContractState>) -> felt252 {
self.x.read()
}
fn increase_balance(ref self: ComponentState<TContractState>, amount: felt252) {
assert(amount != 0, 'Amount cannot be 0');
self.x.write(self.x.read() + amount);
}
}
}
// THIS CONTRACT HAS NO FUNCTIONALITY, IT ONLY USES THE COMPONENT
#[starknet::contract]
mod HelloStarknet {
use super::CounterComponent;
component!(path: CounterComponent, storage: counter, event: CounterEvent);
#[abi(embed_v0)]
impl CounterImpl = CounterComponent::CounterImplMixin<ContractState>;
#[storage]
struct Storage {
#[substorage(v0)]
counter: CounterComponent::Storage,
}
#[event]
#[derive(Drop, Debug, PartialEq, starknet::Event)]
pub enum Event {
#[flat]
CounterEvent: CounterComponent::Event,
}
}
上述合约解析
IHelloStarknet 接口
文件顶部的 trait 与 Scarb 默认生成的保持一致,没有修改。
我们没有修改它,因为测试文件专门导入了这个接口。使用不同的名称会导致测试无法编译:
#[starknet::interface]
pub trait IHelloStarknet<TContractState> {
/// Increase contract balance.
fn increase_balance(ref self: TContractState, amount: felt252);
/// Retrieve contract balance.
fn get_balance(self: @TContractState) -> felt252;
}
Counter 组件
CounterComponent(类似于我们前面看到的 Solidity 中的“抽象合约”)几乎与 Scarb 默认生成的合约完全相同。它们之间的差异将在代码块之后进行解释。
CounterComponent:
#[starknet::component]
pub mod CounterComponent {
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
#[storage]
pub struct Storage {
x: felt252,
}
#[event]
#[derive(Drop, Debug, PartialEq, starknet::Event)]
pub enum Event {}
#[embeddable_as(CounterImplMixin)]
impl CounterImpl<TContractState, +HasComponent<TContractState>> of super::IHelloStarknet<ComponentState<TContractState>> {
fn get_balance(self: @ComponentState<TContractState>) -> felt252 {
self.x.read()
}
fn increase_balance(ref self: ComponentState<TContractState>, amount: felt252) {
assert(amount != 0, 'Amount cannot be 0');
self.x.write(self.x.read() + amount);
}
}
}
Scarb 创建的默认合约:
#[starknet::contract]
mod HelloStarknet {
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
#[storage]
struct Storage {
balance: felt252,
}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn increase_balance(ref self: ContractState, amount: felt252) {
assert(amount != 0, 'Amount cannot be 0');
self.balance.write(self.balance.read() + amount);
}
fn get_balance(self: @ContractState) -> felt252 {
self.balance.read()
}
}
}
以下是 CounterComponent 与 Scarb 生成的默认合约之间的区别:
- 组件使用了
#[starknet::component]属性进行注解- 合约使用了
#[starknet::contract]属性进行注解
- 合约使用了
- 组件中的
impl具有#[embeddable_as(CounterImplMixin)]属性- 合约具有
#[abi(embed_v0)]属性
- 合约具有
- 在组件中,
CounterImpl具有特征impl CounterImpl<TContractState, +HasComponent<TContractState>> of super::IHelloStarknet<ComponentState<TContractState>>- 合约具有特征
impl HelloStarknetImpl of super::IHelloStarknet<ContractState>
- 合约具有特征
- 组件声明了一个空的事件(event)块,尽管它并没有使用事件
- 合约可以省略事件块,但组件不能。在实践中,大多数真实世界的组件都会有事件。为了尽可能保持简单,我们目前将其保留为空。
接下来是对上面列出的差异的详细解释。
#[starknet::component] vs #[starknet::contract]
如果我们打算构建一个组件而不是合约,编译器需要知道模块的类型。使用 #[starknet::component] 注解 mod 块会告诉编译器我们正在构建一个组件,而 [starknet::contract] 则告诉编译器我们正在构建一个合约。
#[embeddable_as(CounterImplMixin)]
这个属性允许合约从组件中“引入”(bring in)一个 impl。
// Counter Mixin
#[abi(embed_v0)]
impl CounterImpl = CounterComponent::CounterImplMixin<ContractState>;
在合约中:CounterComponent 指代 CounterComponent 模块,而 CounterImplMixin 指代它正在引入(“混入”或“mixing in”)的 Impl。
CounterImplMixin 这个名称是任意的。
我们本可以在组件中写成 #[embeddable_as(FooBar)],然后在合约中放入以下代码:
#[abi(embed_v0)]
impl CounterImpl = CounterComponent::FooBar<ContractState>;
如果我们想为了不同的目的暴露不同的 impl 块,我们可以在一个组件内定义多个
embeddable_as实现(我们将在后续文章中展示相关的示例)。
“Mixin(混入)”不是一个语言结构,也不是编译器识别的术语。它是 Cairo 中的习惯用语,用来指代从组件包含到合约中的 impl,并且该 impl 会在合约中暴露新的“公共”函数。合约也可以包含不暴露任何外部函数的 impl,但这就不会被认为是“mixin”。
合约中的 #[abi(embed_v0)] 暴露了 counter impl 中的函数。如果我们像下面这样不包含 #[abi(embed_v0)]:
// #[abi(embed_v0)] commented out
impl CounterImpl = CounterComponent::Counter<ContractState>;
我们的代码仍然能够编译,但不会有任何公共函数,因此测试将无法通过。
理解组件中的 impl 定义
上述组件中的 impl 定义如下所示:
impl CounterImpl<TContractState, +HasComponent<TContractState>> of super::IHelloStarknet<ComponentState<TContractState>>
乍一看这很吓人,特别是如果你没有 Rust 背景的话。好消息是,这主要都是样板代码(boilerplate),你将在所有组件中重复使用这种模式,不需要重新编写它。但我们应该了解它的含义。
在组件中,每个 impl 都遵循这种结构:
impl {ImplName}<TContractState, +HasComponent<TContractState>> of {PathToTrait}::{TraitName}<ComponentState<TContractState>>
让我们来拆解一下:
{ImplName}是你给该实现块指定的名称。它可以是你选择的任何名字。TContractState代表合约的状态类型。+HasComponent<TContractState>告诉编译器,使用该组件的合约包含了它的状态。of {PathToTrait}::{TraitName}将该实现与定义组件接口的 trait 连接起来。ComponentState<TContractState>表示该 trait 操作的是合约状态中属于组件的那部分。
在我们的例子中:
{PathToTrait}是super,因为 trait 是在同一个文件中声明的。{TraitName}是IHelloStarknet,因为测试期望的是这个特定的 trait 名称。
一旦你理解了这种模式,无论何时为组件声明实现,都可以重复使用它。
合约如何使用组件
再次查看合约代码:
#[starknet::contract]
mod HelloStarknet {
use super::CounterComponent;
component!(path: CounterComponent, storage: counter, event: CounterEvent);
#[abi(embed_v0)]
impl CounterImpl = CounterComponent::CounterImplMixin<ContractState>;
#[storage]
struct Storage {
#[substorage(v0)]
counter: CounterComponent::Storage,
}
#[event]
#[derive(Drop, Debug, PartialEq, starknet::Event)]
pub enum Event {
#[flat]
CounterEvent: CounterComponent::Event,
}
}
在 Solidity 中,当合约继承自抽象合约时,函数、存储变量和事件会被自动“拉入”(pulled in)。但在 Cairo 中情况并非如此。
要“引入”一个组件,我们需要遵循以下检查清单:
- 使用
use导入组件。在本例中,即use super::CounterComponent。这仅仅使代码变得可用,并没有将其整合进组件中 - 我们必须声明
component!宏。这将在稍后详细解释 - 必须混入(mixed in)公共函数
- 存储必须作为
#[substorage(v0)]嵌入 - 事件必须使用
#[flat]嵌入
这些步骤缺一不可。以下是清单中每个项目的详细解释。
导入组件
将我们的 CounterComponent 命名为“CounterComponent”是可选的。它可以叫“SparklingWaterIsTasty”,也是完全可以的。但是,我们导入的组件名称必须与以下地方使用的名称一致:
component!中的path- 如下方高亮所示,它必须是 Mixin、Storage 和 Event 的来源

导入 impl
要在合约中包含组件的外部函数,我们必须执行以下操作:
- 声明一个
impl,并使用#[abi(embed_v0)]属性使其成为外部可见(如下方橙色部分所示) - 从组件中引入
CounterImplMixin。CounterImplMixin的名称必须与组件的#[embeddable_as(CounterImplMixin)]中声明的名称相匹配。仅匹配组件中的 impl 名称并不能保证导入成功,必须使用embeddable_as宏中声明的名称。 - 最后,通过“传递(passing)”
ContractState,我们赋予了CounterImplMixin混入“访问(access)”合约存储的权限(如下方白框所示)。

导入存储
与自动导入存储的合约继承不同,在 Cairo 中必须手动完成此操作。
合约的所有存储都存在于合约中标记为 #[storage] 的结构体中。尽管组件中也有一个 #[storage] 结构体,但这并“不算数”,因为那是在组件里面。
幸运的是,我们不必分别导入每一个存储变量。我们可以使用 #[substorage(v0)] 属性“一次性”导入存储。
现在让我们展示如何导入存储:
- 从组件导入的所有存储在合约的存储结构体中必须具有一个键(key)。该键的名称必须与
component!宏中声明的名称相匹配(如下方绿框和箭头所示)。它可以被命名为任何名称,但在component!中声明的值和结构体中的键之间必须保持一致。名称counter本身是任意的。这种关联也是编译器知道counter内部的存储是在其他地方定义的方式。 - 要引入组件(而非合约)的存储结构体,我们将其作为值放入结构体中,即
CounterComponent::Storage(如下方黄色和紫色框所示)。请注意,这里的Storage是组件中结构体的名称。

导入事件
导入事件与导入存储遵循相同的模式:
- 在
component!宏中声明的CounterEvent必须与合约的Event枚举中对应的项相匹配。这种一对一的匹配让编译器知道该事件是在合约外部定义的。名称CounterEvent是任意的,但无论我们选择什么名称,它都必须在component!宏和枚举变体(enum variant)中完全一致地出现。 - 条目上方的
#[flat](如下方橙色框所示)属性是必须的样板代码,它告诉编译器将组件的事件拍平(flatten)到合约的事件结构中,而不是嵌套它们。 - 我们通过
CounterComponent来引入Event。洋红色(magenta)的Event是在组件中声明的Event枚举。

总结
组件创建了它自己的函数、存储和事件,但不能作为合约进行部署。
可以使用导入,并使用 component! 声明引用,将组件导入到合约中。
函数、存储和事件必须分别单独导入。
要导入函数,请创建一个使用 #[abi(embed_v0)] 声明的新 impl,并将实现设置为在 #[embeddable_as(mixin_name)] 中指定的 mixin 名称。
要导入存储,请使用 #[substorage(v0)] 在合约的存储中创建一个新键。将存储的键设置为与 component! 宏中 storage: 声明的名称相同。然后将值设置为组件中存储结构体的路径。
要导入事件,请在事件枚举中创建一个新条目,并对其应用 #[flat] 属性。将条目设置为与 component! 宏中 event: 声明的名称相同。然后将类型设置为组件中枚举的路径。
本文是 Starknet 上的 Cairo 编程 教程系列的一部分