archtechx / airwire
一个轻量级的全栈组件层,不指定你的前端框架
Requires
- illuminate/console: ^8.42
- illuminate/support: ^8.42
Requires (Dev)
- illuminate/testing: ^8.42
- orchestra/testbench: ^6.17
- pestphp/pest: ^1.2
- pestphp/pest-plugin-laravel: ^1.0.0
README
注意: 当前开发已暂停。在发布 Lean Admin 后将恢复。
Airwire
一个轻量级的全栈组件层,不指定你的前端框架
介绍
Airwire 是您 Laravel 代码和 JavaScript 之间的一个薄层。
它允许您编写类似 Livewire 风格的 OOP 组件,如下所示
class CreateUser extends Component { #[Wired] public string $name = ''; #[Wired] public string $email = ''; #[Wired] public string $password = ''; #[Wired] public string $password_confirmation = ''; public function rules() { return [ 'name' => ['required', 'min:5', 'max:25', 'unique:users'], 'email' => ['required', 'unique:users'], 'password' => ['required', 'min:8', 'confirmed'], ]; } #[Wired] public function submit(): User { $user = User::create($this->validated()); $this->meta('notification', __('users.created', ['id' => $user->id, 'name' => $user->name])); $this->reset(); return $user; } }
然后,它将生成一个 TypeScript 定义,如下所示
interface CreateUser { name: string; email: string; password: string; password_confirmation: string; submit(): AirwirePromise<User>; errors: { ... } // ... }
Airwire 将将这两部分连接起来。您可以使用任何前端框架(如果有的话),Airwire 将简单地转发调用并同步前端和后端的状态。
Airwire 的最基本用法如下所示
let component = Airwire.component('create-user') console.log(component.name); // your IDE knows that this is a string component.name = 'foo'; component.errors; // { name: ['The name must be at least 10 characters.'] } // No point in making three requests here, so let's defer the changes component.deferred.name = 'foobar'; component.deferred.password = 'secret123'; component.deferred.password_confirmation = 'secret123'; // Watch all received responses component.watch(response => { if (response.metadata.notification) { alert(response.metadata.notification) } }) component.submit().then(user => { // TS knows the exact data structure of 'user' console.log(user.created_at); })
安装
需要 Laravel 8 和 PHP 8。
首先通过 composer 安装包
composer require archtechx/airwire
然后进入您的 webpack.mix.js
并注册监视器插件。它会在您更改 PHP 代码时刷新 TypeScript 定义
mix.webpackConfig({ plugins: [ new (require('./vendor/archtechx/airwire/resources/js/AirwireWatcher'))(require('chokidar')), ], })
接下来,生成初始 TS 文件
php artisan airwire:generate
这将创建 airwire.ts
和 airwired.d.ts
。打开您的 app.ts
并导入前者
import Airwire from './airwire'
如果您有一个 app.js
文件而不是 app.ts
文件,更改文件后缀并更新您的 webpack.mix.js
文件
- mix.js('resources/js/app.js', 'public/js') + mix.ts('resources/js/app.ts', 'public/js')
如果您是第一次使用 TypeScript,您还需要在项目根目录中创建一个 tsconfig.json
文件。您可以使用这个来开始
{ "compilerOptions": { "target": "es2017", "strict": true, "module": "es2015", "moduleResolution": "node", "experimentalDecorators": true, "sourceMap": true, "skipLibCheck": true }, "include": ["resources/js/**/*"] }
到此为止!Airwire 已完全安装。
PHP 组件
创建组件
要创建组件,请运行 php artisan airwire:component
命令。
php artisan airwire:component CreateUser
示例中的命令将在 app/Airwire/CreateUser.php
中创建一个文件。
接下来,在您的 AppServiceProvider 中注册它
// boot() Airwire::component('create-user', CreateUser::class);
连接属性和方法
如果组件属性和方法使用 #[Wired]
属性(与 Livewire 不同,Livewire 使用 public
可见性用于此),则将与前端共享。
这意味着您的组件可以使用属性(甚至公共属性)而不会与前端共享,除非您显式添加此属性。
class CreateTeam extends Component { #[Wired] public string $name; // Shared public string $owner; // Not shared public function hydrate() { $this->owner = auth()->id(); } }
生命周期钩子
如上例所示,Airwire 有有用的生命周期钩子
public function hydrate() { // Executed on each request, before any changes & calls are made } public function dehydrate() { // Executed when serving a response, before things like validation errors are serialized into array metadata } public function updating(string $property, mixed $value): bool { return false; // disallow this state change } public function updatingFoo(mixed $value): bool { return true; // allow this state change } public function updated(string $property, mixed $value): void { // execute side effects as a result of a state change } public function updatedFoo(mixed $value): void { // execute side effects as a result of a state change } public function changed(array $changes): void { // execute side effects $changes has a list of properties that were changed // i.e. passed validation and updating() hooks }
验证
Airwire 组件默认使用 严格验证。这意味着如果提供的数据无效,则不能进行调用。
要禁用严格验证,将该属性设置为 false
public bool $strictValidation = false;
请注意,禁用严格验证意味着您必须完全负责在执行任何可能危险的操作之前(如数据库查询)验证所有传入的输入。
public array $rules = [ 'name' => ['required', 'string', 'max:100'], ]; // or ... public function rules() { return [ ... ]; } public function messages() { return [ ... ]; } public function attributes() { return [ ... ]; }
自定义类型
Airwire 支持自定义 DTO。只需告诉它如何解码(传入请求)和编码(传出响应)数据即可
Airwire::typeTransformer( type: MyDTO::class, decode: fn (array $data) => new MyDTO($data['foo'], $data['abc']), encode: fn (MyDTO $dto) => ['foo' => $dto->foo, 'abc' => $dto->abc], );
这不需要修改 DTO 类,并且它适用于任何扩展该类的类。
模型
默认包含模型类型转换器。它使用 toArray()
方法生成模型的 JSON 友好表示(这意味着像 $hidden
这样的内容将被尊重)。
它支持将接收到的 ID 转换为模型实例
// received: '3' public User $user;
将数组/对象转换为未保存的实例
// received: ['name' => 'Try Airwire on a new project', 'priority' => 'highest'] public function addTask(Task $task) { $task->save(); }
将属性/返回值转换为数组
public User $user; // response: {"name": "John Doe", "email": "john@example.com", ... } public find(string $id): Response { return User::find($id); } // same response as the property
如果您希望对数据编码有更多控制,可以按属性逐个添加 Decoded
属性。这对于返回模型 id 很有用,即使属性持有其实例也是如此
#[Wired] #[Encode(method: 'getKey')] public User $user; // returns '3' #[Wired] #[Encode(property: 'slug')] public Post $post; // returns 'introducing-airwire' #[Wired] #[Encode(function: 'generateHashid')] public Post $post; // returns the value of generateHashid($post)
默认值
您可以为无法在类中直接指定的属性指定默认值
#[Wired(default: [])] public Collection $results;
这些值将包含在生成的JS文件中,这意味着组件即使仅在前端初始化,在没有向服务器发送任何请求的情况下,也将具有正确的初始状态。
只读值
属性也可以是只读的。这告诉前端不要在请求数据中发送它们。
只读属性的典型用例是仅由服务器写入的数据,例如查询结果
// Search/Filter component #[Wired(readonly: true, default: [])] public Collection $results;
组件挂载
组件可以有一个mount()
方法,它返回初始状态。这种状态在组件在前端实例化时不可访问(与属性的默认值不同),因此组件会从服务器请求数据。
mount()
的一个良好用例是
public function mount() { return [ 'users' => User::all()->toArray(), ] }
挂载数据通常是只读的,因此该方法支持返回将被添加到前端组件只读数据中的值
public function mount() { return [ 'readonly' => [ 'users' => User::all()->toArray(), ], ]; }
元数据
您还可以向Airwire响应添加元数据
public function save(User $user): User { $this->validate($user->getAttributes()); if ($user->save()) { $this->metadata('The user was saved with an id of ' . $user->id); } else { throw Exception("The user couldn't be created."); } }
这些元数据将供下一节中描述的响应观察者访问。
前端
Airwire在前端提供了一些辅助工具。
全局观察者
所有响应都可以在前端进行观察。这有助于显示通知和渲染异常。
// Component-specific component.watch(response => { // ... }); // Global Airwire.watch(response => { // response.data if (response.metadata.notification) { notify(response.metadata.notification) } if (response.metadata.errors) { notify('You entered invalid data.', { color: 'red' }) } }, exception => { alert(exception) })
响应式助手
Airwire允许您指定用于创建组件单例代理的助手。它们用于与前端框架集成。
例如,与Vue集成的过程就像这样
import { reactive } from 'vue' Airwire.reactive = reactive
集成Vue.js
如上所述,您可以使用一行代码将Airwire与Vue集成。
如果您还希望有一个this.$airwire
助手(以避免使用window.Airwire
),您可以使用我们的Vue插件。以下是一个示例app.ts
可能的样子
import Airwire from './airwire'; import { createApp, reactive } from 'vue'; createApp(require('./components/Main.vue').default) .use(Airwire.plugin('vue')(reactive)) .mount('#app') declare module 'vue' { export interface ComponentCustomProperties { $airwire: typeof window.Airwire } }
data() { return { component: this.$airwire.component('create-user', { name: 'John Doe', }), } },
集成Alpine.js
注意:Alpine集成尚未经过测试,但我们预计它将正常工作。我们很快将重新实现Vue示例。
Alpine没有像Vue那样的reactive()
助手,所以我们创建了一个。
有一个需要注意的地方:它不是全局的,而是组件特定的。它与在数据突变时更新的组件列表一起工作。
因此,您需要在组件内部传递响应式助手
<div x-data="{ component: Airwire.component('create-user', { name: 'John Doe', }, $reactive) }"></div>
为了简化这一点,您可以使用我们的Airwire插件,该插件提供了一个$airwire
助手
<div x-data="{ component: $airwire('create-user', { name: 'John Doe', }) }"></div>
要使用此插件,请使用此调用 在导入Alpine之前
Airwire.plugin('alpine')()
测试
Airwire组件可以使用流畅语法进行全面测试
// Assertions against responses use send() test('properties are shared only if they have the Wired attribute', function () { expect(TestComponent::test() ->state(['foo' => 'abc', 'bar' => 'xyz']) ->send() ->data )->toBe(['bar' => 'xyz']); // foo is not Wired }); // Assertions against component state use hydrate() test('properties are shared only if they have the Wired attribute', function () { expect(TestComponent::test() ->state(['foo' => 'abc', 'bar' => 'xyz']) ->hydrate()->bar )->toBe('xyz'); // foo is not Wired });
您可以通过查看包的测试来查看实际示例。
协议规范
Airwire组件以任何方式签名或指纹。它们与REST API一样完全无状态,这允许从前端进行实例化。这与Livewire不同,Livewire不允许任何直接状态更改——所有这些更改都必须由后端“批准”并签名。
将Airwire视为REST API的OOP包装器可能是最好的思考方式。您不是编写低级控制器和路由,而是编写表达式的面向对象组件。
请求
{ "state": { "foo": "abcdef" }, "changes": { "foo": "bar" }, "calls": { "save": [ { "name": "Example task", "priority": "highest" } ] } }
响应
{ "data": { "foo": "abcdef" }, "metadata": { "errors": { "foo": [ "The name must be at least 10 characters." ] }, "exceptions": { "save": "Insufficient permissions." } } }
状态
状态指的是在做出任何更改之前的老状态。由于Airwire不会盲目信任状态,因此差异不大,但它与请求的更改分离。
这种用法包括updating
、updated
和changed
生命周期钩子。
更改
如果更改不允许,Airwire将静默失败,并简单地从请求中排除更改。
调用
调用是一组方法和它们参数的键值对。
如果执行不被允许,Airwire将静默失败并简单地从请求中排除该调用。
如果执行导致异常,Airwire还会将methodName: { exception object }
添加到元数据的exceptions
部分。
异常在TypeScript中有完整的类型定义。
验证
验证是在当前状态和新更改的组合上执行的。
验证失败的属性将在元数据的errors
对象中有一个错误字符串数组。
与其他解决方案相比
由于Airwire只是JavaScript代码和PHP文件之间的REST API层,因此不需要用它来代替其他库。你可以与其他任何东西一起使用它。
尽管如此,让我们将它与其他库进行比较,以便了解何时每个解决方案工作得最好。
Livewire
Livewire专门用于返回使用Blade生成的HTML响应。
我们的大部分API都受到Livewire的启发,有一些小的改进(例如使用PHP属性),这些改进是在使用Livewire的过程中发现的。
关于Livewire和Airwire的最佳思考方式是:Livewire支持Blade(纯服务器端渲染),而Airwire支持JavaScript(纯前端渲染)。
两者都没有能力支持对方的方法,因此主要决定因素是您使用什么进行模板化。
(此比较将所有生态系统差异排除在外;它只关注技术。)
Inertia.js
Inertia最好将其视为Vue/React等应用程序的替代路由器。在几个用例中,Airwire和Inertia的使用方式有些相似,但就大部分而言,它们非常不同,因为Inertia依赖于访问,而Airwire没有访问或路由的概念。
Inertia和Airwire很好地配对用于特定的UI组件——比如说,你使用Inertia处理前端的大部分事情,但你可能想要构建一个非常动态的组件,它需要向后台发送大量请求(例如,由于实时输入验证)。你可以简单地安装Airwire并使用它来处理这个组件,而使用Inertia处理其他所有事情。