通过 Livewire 在 Laravel 项目中实现基于 PHP 的全栈开发(二):组件创建和渲染的底层实现

前言

昨天在发布完 Livewire 入门教程后,分享到 v2ex 和朋友圈引来各种评论,大多数人持有的态度都是「这玩意没啥用」,我想这部分人大多是已经非常熟悉Laravel + Vue/React 进行前后端开发了,而目前无论就性能和先天基因来说,Livewire 确实都是不如原生 Vue/React 组件的,就像 Weex、React Native 一直致力于通过 JavaScript 开发原生 App 统一大前端也存在各种问题一样,我们不妨放下这些争议,来看一下 Livewire 存在的一些正面意义,比如某些小的公司,没有独立的前端资源,后端也只是是个入门级的 PHPer,对前端 JavaScript 技术栈还不甚了解,或者干脆,我们只是通过 Laravel 框架来做一些演示项目而已,这个时候我们是否可以拿 Livewire 来做个短期的过渡和快速实现?我想这样的需求和市场还是存在的,就像 Weex、React Native 一样,它们的市场也无非是客户端开发资源不充分的情况下的一个短期替代品,目前还无法做到和原生 App 开发一样的兼容性和性能。

所以鉴于此,我还是把 Livewire 系列继续讲下去,算是对 Laravel 生态的一个补充。如果你已经掌握 Vue/React 开发,可以跳过,但如果你目前的情况满足我上面描述的 Livewire 适用场景,希望这个系列教程对你有所帮助。

今天学院君给大家介绍的是 Livewire 组件的创建、渲染和数据绑定实现。

创建组件

关于 Livewire 组件的创建我们在上篇教程中已经演示过,可以通过 php artisan make:livewire 命令来实现,后面跟上组件名称作为参数即可:

php artisan make:livewire counter

此外,如果我们要创建的组件名称包含多个单词,可以通过 - 将它们连接起来:

php artisan make:livewire demo-component  // 服务端组件类名是 DemoComponent

另外,如果想要将组件模板放到指定子目录下,可以在创建组件时通过 . 将目录分隔开:

php artisan make:livewire demo.counter  // 服务端路径是 Demo/Counter.php,组件模板路径是 demo/counter.blade.php

和 Vue 组件不同,Livewire 组件会将组件的 HTML 模板和脚本代码拆分为两部分,HTML 模板位于 resources/views/livewire 目录下,因此我们在视图目录中可以通过 Blade 语法来编写组件模板代码;脚本代码(基于 PHP 实现)位于 app/Http/Livewire 目录下,因此数据绑定和页面操作触发的回调函数都可以通过服务端的 PHP 代码来实现。

注:php artisan make:livewire 命令的底层实现源码定义在 vendor/calebporzio/livewire/src/Commands/LivewireMakeCommand.php 中,感兴趣的同学可以去看看。

组件渲染

当我们要在某个视图模板中引入 Livewire 组件时,需要通过 Blade 指令 @livewire

@livewire('counter')

这个 Blade 指令定义在 Livewire\LivewireBladeDirectives 类中:

<?php

namespace Livewire;

use Illuminate\Support\Str;

class LivewireBladeDirectives
{
    public static function livewire($expression)
    {
        $lastArg = trim(last(explode(',', $expression)));

        if (Str::startsWith($lastArg, 'key(') && Str::endsWith($lastArg, ')')) {
            $cachedKey = Str::replaceFirst('key(', '', Str::replaceLast(')', '', $lastArg));
            $args = explode(',', $expression);
            array_pop($args);
            $expression = implode(',', $args);
        } else {
            $cachedKey = "'".str_random(7)."'";
        }

        return <<<EOT
<?php
if (! isset(\$_instance)) {
    \$dom = \Livewire\Livewire::mount({$expression})->toHtml();
} elseif (\$_instance->childHasBeenRendered($cachedKey)) {
    \$componentId = \$_instance->getRenderedChildComponentId($cachedKey);
    \$componentTag = \$_instance->getRenderedChildComponentTagName($cachedKey);
    \$dom = \Livewire\Livewire::dummyMount(\$componentId, \$componentTag);
    \$_instance->preserveRenderedChild($cachedKey);
} else {
    \$response = \Livewire\Livewire::mount({$expression});
    \$dom = \$response->toHtml();
    \$_instance->logRenderedChild($cachedKey, \$response->id, \$response->getRootElementTagName());
}
echo \$dom;
?>
EOT;
    }
}

当第一次渲染 Livewire 组件时 $_instance 为空,所以会调用第一个 if 语句中的代码:

$dom = \Livewire\Livewire::mount({$expression})->toHtml();

对应的 mount 方法定义在 vendor/calebporzio/livewire/src/LivewireManager.php 中:

public function mount($name, ...$options)
{
    $instance = $this->activate($name);
    $instance->mount(...$options);
    $dom = $instance->output();
    $id = str_random(20);
    $properties = ComponentHydrator::dehydrate($instance);
    $events = $instance->getEventsBeingListenedFor();
    $children = $instance->getRenderedChildren();
    $checksum = md5($name.$id);

    $middlewareStack = $this->currentMiddlewareStack();
    if ($this->middlewaresFilter) {
        $middlewareStack = array_filter($middlewareStack, $this->middlewaresFilter);
    }
    $middleware = encrypt($middlewareStack, $serialize = true);

    return new InitialResponsePayload([
        'id' => $id,
        'dom' => $dom,
        'data' => $properties,
        'name' => $name,
        'checksum' => $checksum,
        'children' => $children,
        'events' => $events,
        'middleware' => $middleware,
    ]);
}

这段代码首先调用 LivewireManageractive 方法通过组件名 counter 获取并初始化组件类 Counter 实例,然后调用该实例上的 mount 方法(相应逻辑代码位于其父类 Livewire\Component 中),现在是一个空实现,你可以在 Counter 类中实现该方法以便在组件 mount 阶段做一些初始化操作(类似 Vue 组件的 created 生命周期钩子,此外 Livewire 还支持 updatedupdating 钩子函数),接下来调用 output 方法获取组件的 HTML 模板,该方法同样定义在父类 Livewire\Component 中:

public function output($errors = null)
{
    $view = $this->render();

    throw_unless($view instanceof View,
        new \Exception('"render" method on ['.get_class($this).'] must return instance of ['.View::class.']'));

    $dom = $view
        ->with([
            'errors' => (new ViewErrorBag)->put('default', $errors ?: new MessageBag),
            '_instance' => $this,
        ])
        // Automatically inject all public properties into the blade view.
        ->with($this->getPublicPropertiesDefinedBySubClass())
        ->render();

    // Basic minification: strip newlines and return carraiges.
    return str_replace(["\n", "\r"], '', $dom);
}

可以看到,该方法调用了 Counter 类的 render 方法获取视图模板实例 $view,如果组件类没有定义 render 方法中抛出异常,然后对 $view 进行初始化设置,比如添加 $errors$_instance 属性,并注入 Counter 类的所有 public 属性值到视图数据 data 数组中,这就是为什么我们可以在组件视图模板中直接使用 PHP 组件类的公共属性,最后调用 $viewrender 方法将视图模板以 HTML 字符串的形式返回给 $dom

回到 LivewireManagermount 方法,到目前为止我们已经知道 php artisan make:livewire 默认生成的前后端组件代码是如何组合成最终的动态 Livewire 组件模板了,接下来, Livewire 还会为该组件生成一个 ID 标识、提取组件类属性数据、监听事件列表、包含的子组件、并为该组件生成一个校验和,如果定义了中间件过滤函数,还会通过该函数对当前路由上有效的中间件进行过滤处理。最后,mount 方法会把以上这些获取到的变量值以响应载荷实例的方式返回。

接下来回到调用 mount 方法的 @livewire 指令类 LivewireBladeDirectives,调用完 mount 方法返回的是 InitialResponsePayload 类的实例,接着会调用这个实例上的 toHtml 方法将该实例的属性值注入到最终渲染的组件模板 counter 对应的字符串 $dom 中并返回:

public function toHtml()
{
    return $this->injectComponentDataAsHtmlAttributesInRootElement(
        $this->dom,
        $this->toArray()
    );
}

最后 LivewireBladeDirectiveslivewire 方法会打印这个最终返回的组件模板 $dom,将其作为整个视图模板的一部分在客户端渲染出来。以上就是 Livewire 组件首次渲染的完整流程。

如果 $_instance 已经存在,或者视图模板已经缓存,则执行 livewire 方法中另外两个判断分支中的代码以提高性能,避免重复初始化。

这里仅仅分析了 php artisan make:livewire 默认生成的组件类和组件模板的渲染逻辑,更多复杂的交互和功能实现,我们后面会逐一介绍,这里先了解下总体实现思路即可。

另外,Livewire 组件默认的 JavaScript 和 CSS 资源需要在使用 Livewire 组件的地方通过 @livewireAssets 指令引入,对应的实现源码位于 LivewireManager 类的 assets 方法中:

public function assets($options = null)
{
    $options = $options ? json_encode($options) : '';
    $manifest = json_decode(file_get_contents(__DIR__.'/../dist/mix-manifest.json'), true);
    $versionedFileName = $manifest['/livewire.js'];

    $csrf = csrf_token();

    return <<<EOT
<!-- Livewire Assets-->
<style>[wire\:loading] { display: none; }</style>
<script src="/livewire{$versionedFileName}"></script>
<script>
    document.addEventListener("DOMContentLoaded", function() {
        window.livewire = new Livewire({$options});
        window.livewire_token = "{$csrf}";
    });
</script>
EOT;
}

主要引入的是带版本号的 livewire.js 以及初始化的 JavaScript 全局变量 livewirelivewire_token,关于 livewire.js 及这两个全局变量的用法我们后面会介绍到,在纯静态 Livewire 组件中基本用不上。

注意事项

编写 Livewire 组件的时候需要注意,和 Vue 组件一样,Livewire 组件模板中只能有一个 HTML 根标签:

<div style="text-align: center">
    <button wire:click="increment">+</button>
    <h1>{{ $count }}</h1>
    <button wire:click="decrement">-</button>
</div>

如果有另外一个与 div 标签并列的其他标签则会报错。

此外,我们前面提到,Livewire 组件通过后端 PHP 组件类的公共属性实现类似 Vue 组件中的 data 功能,如果要让这些类属性在组件模板中可见,必须是 public 类型,且必须是字符串、数字、数组这些基本类型,否则无法在组件模板中渲染。除了通过 public 属性之外,还可以通过在 render 方法中返回视图模板时显式传递数据到组件视图:

public function render()
{
    return view('livewire.counter', [
        'name' => '学院君'
    ]);
}

上一篇: 通过 Livewire 在 Laravel 项目中实现基于 PHP 的全栈开发(一):快速上手篇

下一篇: 通过 Livewire 在 Laravel 项目中实现基于 PHP 的全栈开发(三):数据绑定及底层实现