Livewire クラスメソッドから他のコンポーネントを指定してイベントを送るには?emitFind

ショコラ
ショコラ

Livewire クラスメソッドから他のコンポーネントを指定してイベントを送るには?emitFind

実は、コンポーネントID や nameプロパティ(コンポーネント識別名) を指定してイベントを送る手段がありません。

・emit だと全体にイベントを送る。
・emitSelf だと自分自身。
・emitUp だと親~先祖。
・emitTo だとコンポーネントクラス。

それを実現するのが「emitFind」。自作です。
emitFind({コンポーネントID}) や emitFind({コンポーネント識別名}) でイベントを送ることができます。

もっさん先輩
もっさん先輩

Form間で値をコピーするサンプルプログラムを作成してみます。

手順

Laravel と Livewire をインストールして、クラスメソッドから他のコンポーネントにイベントを送る手順。

  1. プロジェクト名(emitFind)を決めて以下のコマンドを実行します。
curl -s https://laravel.build/emitFind | bash

インストール時にプロジェクト名のディレクトリが作成されます。

  1. インストールの最後に sudo でパスワードの入力を求められます。

↓下のメッセージが表示されてインストールは終わります。

Use 'docker scan' to run Snyk tests against images to find vulnerabilities and learn how to fix them

Get started with: cd emitFind && ./vendor/bin/sail up
  1. sail のエイリアスを定義します。
echo "alias sail='[ -f sail ] && sh sail || sh vendor/bin/sail'" >> ~/.bashrc
source ~/.bashrc

Laravel のインストールはここまで。

  1. 「sail up」でコンテナを起動します。
cd emitFind && sail up -d
  1. ララベルのトップディレクトリで、Livewireパッケージ をインストールします。
sail composer require livewire/livewire
  1. 次のコマンドを実行して、formコンポーネント を生成します。
sail artisan make:livewire form
$ sail artisan make:livewire form
 COMPONENT CREATED  ?

CLASS: app/Http/Livewire/Form.php
VIEW:  resources/views/livewire/form.blade.php

次の 2つ のファイルが生成されます。

<?php

namespace App\Http\Livewire;

use Livewire\Component;

class Form extends Component
{
    public function render()
    {
        return view('livewire.form');
    }
}
<div>
    {{-- Because she competes with no one, no one can compete with her. --}}
</div>

※因みに、renderメソッド を定義しなくても livewire.formは呼び出されます。

<?php

namespace App\Http\Livewire;

use Livewire\Component;

class Form extends Component
{
}
  1. 生成された formコンポーネント のクラスとビューを次のように置き換えます。

app/Http/Livewire/Form.php

<?php
namespace App\Http\Livewire;
class Form extends \Livewire\Component
{
  use LiveRelation;
  public $to;
  public $pet;
  public $feedback;
  public $contact_me = true;

  public function copy(LiveData $data) {
    \Log::debug(__METHOD__);
    \Log::debug('送信 '.$this->id);

    $data[$this->to]['pet']        = $this->pet;
  //$data[$this->to]['feedback']   = $this->feedback;
    $data[$this->to]['contact_me'] = $this->contact_me;
    $this->emitDirtySet($data);
    $this->emitFind($this->to,'copy2',$this->feedback);
  }

  protected $listeners = ['copy2'];
  public function copy2($feedback) {
    \Log::debug(__METHOD__);
    \Log::debug('受信 '.$this->id);

    $this->feedback = $feedback;
  }
}

↑上のポイントは、copyメソッド 内の emitFindメソッド で、もう1つのコンポーネントに copy2イベント を送っています。
また、emitFind よりも前で emitDirtySet を実行しています。これは emitFind の後に動く、LiveData のデストラクタによる値の上書きを防ぐ為です。
(ここで emitDirtySet が生まれた!)

resources/views/livewire/form.blade.php

<div>
  <div class="ui top attached tabular menu">
    <div class="active item">{{ $name }}</div>
  </div>
  <div class="ui bottom attached active tab segment">
    <div class="ui form">
      <div class="field">
        <label>Pet</label>
        <select class="ui selection dropdown" wire:model="pet">
          <option value="">Pet</option>
          <option value="0">Cat</option>
          <option value="1">Dog</option>
          <option value="2">Bird</option>
          <option value="3">Rabbit</option>
          <option value="4">Squirrel</option>
          <option value="5">Horse</option>
          <option value="6">Turtle</option>
          <option value="7">Parrot</option>
        </select>
      </div>
      <div class="field">
        <label>Feedback</label>
        <textarea wire:model="feedback"></textarea>
      </div>
      <div class="field">
        <div class="ui checkbox">
          <input type="checkbox" wire:model="contact_me">
          <label>It's okay to contact me.</label>
        </div>
      </div>
      <div class="actions">
        <div class="ui positive button" wire:click="copy()">Copy </div>
      </div>
    </div>
  </div>
</div>
  1. LiveRelationトレイト を作成します。

配置先:app/Http/Livewire/LiveRelation.php

<?php
namespace App\Http\Livewire;
use Livewire\LivewireManager;
use Livewire\LifecycleManager;
use Livewire\Component;
trait LiveRelation
{
  public string $bind = ''; // 『親のプロパティ:自分のプロパティ』で関連付ける。
  public string $name = ''; // コンポーネント識別名
  public bool $livedata = true; // メソッド呼び出し時のパラメーターに LiveData を自動で付ける。

  public function boot() {
    LiveData::$current_component = $this;
  }

  public function isChild($childId) {
    return collect($this->previouslyRenderedChildren)->pluck('id')->search($childId);
  }

  public function dehydrate() {
    if ('' != $this->bind) {
      $vars = [];
      foreach (explode(',',$this->bind) as $bind) {
        [$property,$self_property] = explode(':',str_repeat($bind.':',2));
        $method = 'get'.\Str::studly($self_property);
        if (method_exists($this,$method))
          $value = $this->{$method}();
        else if (property_exists($this,$self_property))
          $value = $this->{$self_property};
        else
          continue;
        $vars[$property] = $value;
      }
      if ([] !== $vars)
        $this->emitParent('updateBindings',$vars);
    }
  }

  protected function getListeners() {
    return array_merge(parent::getListeners(),['updateBindings','syncBindings','setProperties'],
      ['set'.\Str::studly($this->id) => 'setProperties'],
      ('' != $this->name) ? ['set'.\Str::studly($this->name) => 'setProperties']:[]);
  }

  public function updateBindings($vars) {
    foreach ($vars as $property => $value) {
      $method = 'update'.\Str::studly($property);
      if (method_exists($this,$method))
        $this->{$method}($value);
      else if (property_exists($this,$property))
        $this->{$property} = $value;
    }
  }

  public function syncBindings(LiveData $data) {
    if ('' != $this->bind) {
      foreach (explode(',',$this->bind) as $bind) {
        [$property,$self_property] = explode(':',str_repeat($bind.':',2));
        $this->$self_property = $data->getParent()[$property];
      }
    }
  }

  public function setProperties(LiveData $data) {
    foreach ($data() as $property => $value)
      $this->{$property} = $value;
    $data()->dirty = false;
  }

  public function emitSet(string $key, LiveData $data) {
    if (!in_array($key,[$this->id,$this->name]))
      $this->emit('set'.\Str::studly($key),$data);
  }

  public function emitDirtySet(LiveData $data) {
    collect($data)
      ->filter(fn($component) => $component->dirty)
      ->each(fn($component) => $this->emitSet($component['id'],$data));
  }

  public function emitFind($key, $event, ...$params) {
    $this->emit('emitFind',$key,$event,...$params);
  }

  public function emitParent($event, ...$params) {
    $this->emit('emitParent',$this->id,$event,...$params);
  }

  public function mountLivewireTag(string $livewire_tag,$target='body') {
    $this->emit('createComponent',render_blade($livewire_tag),$target);
  }

  public function mountComponent(string $name,array $params=[],$target='body') {
    $this->emit('createComponent',\Livewire\Livewire::mount($name,$params)->html(),$target);
  }

  public function removeComponent(string $key = null) {
    $key ??= $this->id;
    $this->emit('removeComponent',$key);
  }

  public function createComponent(string $name,array $params) {
    $id = str()->random(20);
    return LifecycleManager::fromInitialRequest($name,$id)
      ->boot()
      ->initialHydrate()
      ->mount($params)
      ->instance;
  }

  public function renderComponent(Component $component,string $target='body') {
    $response = LifecycleManager::fromInitialInstance($component)
      ->renderToView()
      ->initialDehydrate()
      ->toInitialResponse();
    $this->emit('createComponent',$response->html(),$target);
  }
}

LiveRelation のポイント
①LiveRelation の bootメソッド で、LiveData の カレントコンポーネント を設定するようにしました。
②emitFindメソッドを追加しました。JavaScript の emitFind を使います。
③emitParentメソッド を追加しました。JavaScript の emitParent を使います。
④mountLivewireTag、mountComponent、removeComponentメソッド を追加しました。
⑤createComponent、renderComponentメソッド を追加しました。

配置先:app/Http/Livewire/LiveData.php

<?php
namespace App\Http\Livewire;
use Illuminate\Database\Eloquent\Model;
use \Livewire\Wireable;
use \Livewire\Component;
use Illuminate\Support\Collection;

class ComponentData extends \ArrayObject {
  public bool $dirty = false;
  public function __toString() {
    return var_export($this->getArrayCopy(),true);
  }
  public function offsetSet(mixed $key, mixed $value) {
    parent::offsetSet($key,$value);
    $this->dirty = true;
  }
}

class LiveData extends Model implements Wireable,\IteratorAggregate
{
  protected Collection $components;

  // カレントコンポーネントは LiveRelation の boot で設定される。
  public static Component $current_component;

  // Model のメソッドをオーバーライド
  public function resolveRouteBinding($value, $field = null) {
    $this->components = collect($value)->map(fn($component) => new ComponentData($component));
    return $this;
  }

  // Model のメソッドをオーバーライド
  public function toArray() {
    return $this->components->toArray();
  }

  public function getIterator() {
    return $this->components->keyBy('name');
  }

  public function __construct(array $attributes = []) {
    parent::__construct($attributes);
    $this->components = collect();
  }

  public function __destruct() {
    $this->components
      ->filter(fn($component) => $component->dirty)
      ->each(fn($component) => self::$current_component->emitSet($component['name'],$this));
  }

  public function __invoke(Component $component = null): mixed {
    $component ??= self::$current_component;
    return $this->getComponentData($component->id);
  }

  public function getComponentData(string $needle = null, string $column_key = 'id') {
    $needle ??= self::$current_component->id;
    foreach ($this->components->keyBy($column_key) as $var => $component)
      eval("\$_{$var} = \$component;");
    return ${'_'.$needle} ?? null;
  }

  public function getComponentDataByName(string $name) {
    return $this->getComponentData($name,'name');
  }

  public function __get(mixed $key):mixed {
    return $this->getComponentDataByName($key);
  }

  public function offsetGet(mixed $key):mixed {
    return $this->getComponentDataByName($key) ?? $this->getComponentData($key);
  }

  public function getParent(string $componentId = null) {
    $component = $this->getComponentData($componentId);
    return isset($component['parentId']) ? $this->getComponentData($component['parentId']) : null;
  }

  public function getChildren(string $componentId = null) {
    $component = $this->getComponentData($componentId);
    return collect($component['childIds'])->map(fn($childId) => $this->getComponentData($childId));
  }

  // Wireable 対応
  public function toLivewire() {
    return $this->components;
  }

  // Wireable 対応
  public static function fromLivewire($value) {
    $this->resolveRouteBinding($value);
  }
}

LiveData のポイント
①Modelクラスを継承し、resolveRouteBinding、toArrayメソッド をオーバーライドしてデータベースに関係なく使えるようにしました。
②getComponetメソッド だと「Component が取得できる」と勘違いしてしまうので、getComponentData に変更しました。getParent と getChildren も取得できるものは Component ではありません。
③Wireable に対応し(toLivewire、fromLivewireメソッド追加)、LiveData を emit で送れるようにしました。
④ArrayObject は \Log::dump すると「could not be converted to string」のエラーになってしまうので、ComponentDataクラス を用意しました。
⑤ComponentDataクラス にダーティフラグを持たせて、LiveDataオブジェクト削除時に、自動で他のコンポーネントのプロパティを更新するようにしました。

  1. public/liveext.js を作成します。

配置先:public/liveext.js

// window.Livewire は定義済みです。

const data = () => {
  return window.livewire.all().map(el => {
    const defer = new Map
    Object.keys(el.__instance.data).forEach(key => defer[key] = el.__instance.getPropertyValueIncludingDefers(key))
    return Object.assign(defer,{
      id: el.__instance.id,
      childIds: el.__instance.childIds,
      parentId: parentId(el.__instance.id)
    })
  })
}
const parentId = key => {
  let id = componentId(key)
  return window.livewire.all().find(el => -1 != el.__instance.childIds.indexOf(id))?.__instance.id
}
const componentId = key => window.livewire.all().find(el => -1 != [el,el.__instance,el.__instance.id,el.__instance.data.name].indexOf(key))?.__instance.id
const parent = key => window.livewire.find(parentId(key))
const syncBindings = () => window.livewire.emit('syncBindings',data())
export default {data,parentId,componentId,parent,syncBindings}

window.livewire.parentId = parentId
window.livewire.data = data
window.livewire.componentId = componentId
window.livewire.find = new Proxy(window.livewire.find,{
  apply(target,thisArg,argumentsList) {
    let key = argumentsList[0]
    let id = componentId(key)
    return target.apply(thisArg,[id])
  }
})
window.livewire.parent = parent
window.livewire.syncBindings = syncBindings
window.livewire.on('emitFind',(key,event,...params) => window.livewire.find(key).emitSelf(event,...params))
window.livewire.on('emitParent',(key,event,...params) => parent(key).emitSelf(event,...params))
window.livewire.on('createComponent',(html,target='body') => {
  const el = document.createElement('div')
  el.innerHTML = html
  document.querySelector(target).appendChild(el.firstElementChild)
  window.livewire.rescan()
})
window.livewire.on('removeComponent',key => {
  let id = componentId(key)
  document.querySelector(`[wire\\:id="${id}"]`).remove()
  window.livewire.rescan()
})
window.livewire.hook('component.initialized',component => {
  if (component.data.livedata) {
    component.addAction = new Proxy(component.addAction,{
      apply(target,thisArg,argumentsList) {
        let action = argumentsList[0]
        if (('method' in action.payload) && ('$' != action.payload.method.charAt(0)))
          action.payload.params.push(data())
        return target.apply(thisArg,[action])
      }
    })
  }
})

liveext.js のポイント
①Livewire.parentId関数で 親コンポーネントのID を取得できます。
②getPropertyValueIncludingDefers で defer に設定された値も取得できるように対応しました。
③Livewire.componentId関数で name から id を引けるようにしました。
④Livewire.find関数で name から find できるように変更しました。
⑤Livewire.syncProperties関数で 子供のプロパティが親のプロパティに同期できるようにしました。
⑥emitFindイベントで 特定のコンポーネントにイベントを送信できるようにしました。
⑦JavaScript のメソッド呼び出し時に、Livewire.data() を自動でパラメーターに追加するようにしました。
⑧createComponent、removeComponent を追加しました。コンポーネントを動的に作成、削除することができます。

  1. resources/views/index.blade.php ファイル を作成します。
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <script src="https://cdn.jsdelivr.net/npm/jquery@3.6.1/dist/jquery.min.js"></script>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/fomantic-ui@2.9.0/dist/semantic.min.css">
    <script src="https://cdn.jsdelivr.net/npm/fomantic-ui@2.9.0/dist/semantic.min.js"></script>
    <script type="module" src="/liveext.js"></script>
    <livewire:styles />
  </head>
  <body>
    <div class="ui basic segment">
      <livewire:form name="form1" to="form2" />
      <livewire:form name="form2" to="form3" />
      <livewire:form name="form3" to="form1" />
    </div>
    <livewire:scripts />
  </body>
</html>
  1. routes/web.php にルートを追加します。
Route::get('/', fn() => view('index'));
  1. ブラウザで確認します。

コピーボタンを押すと、まず自分の copyメソッド が呼ばれ、emitFindを実行し、相手の copy2メソッド が呼び出されます。

[2022-11-28 16:25:15] local.DEBUG: App\Http\Livewire\Form::copy
[2022-11-28 16:25:15] local.DEBUG: 送信 R6PzkBKJx2VdiIcYAS9U
[2022-11-28 16:25:15] local.DEBUG: App\Http\Livewire\Form::copy2
[2022-11-28 16:25:15] local.DEBUG: 受信 ZPsoF71VVgCY8vI42bng

以上

Scroll to Top