iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 23
0

https://ithelp.ithome.com.tw/upload/images/20200929/20113602IedcFi9fBp.jpg

在上篇中已經做完了喜歡文章的功能,但還存在一些問題,本篇就來看這些問題。

預加載 likers()->count() 問題

首先看到的是重複的 Query,原因是 Post model 撈 likers (喜歡此文章的用戶) 數量撈 2 次:

https://ithelp.ithome.com.tw/upload/images/20200925/20113602DWNWpmzfh1.jpg

禍源在這兒:

app/Presenters/PostPresenter.php

public function values(): array
{
    return [
        ...
        'likes' => $this->likers_count_readable,
        ...
    ];
}

我們都是成熟(誤)的開發者了,遇到問題要學會自己 Debug。爬了源碼後發現問題在這:

vendor/multicaret/laravel-acquaintances/src/Traits/CanBeLiked.php

trait CanBeLiked
{
    public function likersCount()
    {
        return $this->likers()->count();
    }

    public function getLikersCountAttribute()
    {
        return $this->likersCount();
    }
}

當然第一個就會想到用 withCount()/loadCount() 來解,但即使預加載了數量,也暫時回來用 likers_count,這兩個 Query 還是頑固地在原地不動。

這個套件加了 getLikersCountAttribute() 覆蓋掉了 likers_count,所以即使用了預加載還是讀不到值。而 likers_count_readable 的數值來源是呼叫 likers()->count(),當然也不能用預加載讀。

山不轉路轉,我們還可以自己新增自己的 trait 去繼承 (正確來說是 use) 原本的 trait,然後覆寫 likersCount(),讓他強制讀取 likers_count 的值:

不去發 PR 而是自己覆寫的原因則是,等下還要客製化一些東西。

app/Acquaintances/CanBeLiked.php

<?php

namespace App\Acquaintances;

use Multicaret\Acquaintances\Traits\CanBeLiked as BaseCanBeLiked;

trait CanBeLiked
{
    use BaseCanBeLiked;

    public function likersCount()
    {
        return $this->attributes['likers_count'] ?? 0;
    }
}

接下來就可以替換掉 Post model 裡引用的 CanBeLiked,改成 use App\Acquaintances\CanBeLiked。既然要強制讀取,那也要讓 likers_count 隨時都可以讀取到值。這裡新增一個 Global Scope 來讓每個 Post 的 Query 都會自帶 likers_count

app/Post.php

use App\Acquaintances\CanBeLiked;
use Illuminate\Database\Eloquent\Builder;

class Post extends Model
{
    use CanBeLiked;

    protected static function booted()
    {
        static::addGlobalScope('likers', fn (Builder $builder) => $builder->withCount('likers'));
    }
}

本地化可讀數字

事情還沒完,回來看前端,如果我們先暫時把 likersCount() 改成一個稍大一點的數字會看到什麼結果?:

app/Acquaintances/CanBeLiked.php

public function likersCount()
{
    return 12877;
}

https://ithelp.ithome.com.tw/upload/images/20200925/20113602Y7dw25CLiK.jpg

居然寫 12.8K?這個格式和語言都不合我們的使用習慣啊!必須改!

追下去發現要改的是在 Multicaret\Acquaintances\Interaction 這個 class,先覆蓋引用 Interaction 的來源:

app/Acquaintances/CanBeLiked.php

public function likersCountFormatted($precision = 1, $divisors = null)
{
    return Interaction::numberToReadable($this->likersCount, $precision, $divisors);
}

再新增我們的 Interaction,這裡除了改成中文外,還多做了一點調整,讓數字在千位(含)以下時不要有小數點:

app/Acquaintances/Interaction.php

<?php

namespace App\Acquaintances;

use Multicaret\Acquaintances\Interaction as BaseInteraction;

class Interaction extends BaseInteraction
{
    static public function numberToReadable($number, $precision = 1, $divisors = null, $base = 10000)
    {
        $shorthand = '';
        $divisor = pow($base, 0);

        if (! isset($divisors)) {
            $divisors = [
                $divisor => $shorthand,
                pow($base, 0) => '',
                pow($base, 1) => '萬',
                pow($base, 2) => '億',
                pow($base, 3) => '兆',
                pow($base, 4) => '京',
            ];
        }

        foreach ($divisors as $divisor => $shorthand) {
            if (abs($number) < ($divisor * $base)) {
                break;
            }
        }

        if ($divisor === 1) {
            $precision = 0;
        }

        return number_format($number / $divisor, $precision).$shorthand;
    }
}

然後文章喜歡的可讀數字正常了,從剛才的 12.8K 變成了 1.3萬

https://ithelp.ithome.com.tw/upload/images/20200925/201136023McrxE0URl.jpg

還有一個要改的是文章的瀏覽次數,也一樣變成易於閱讀的數字:

app/Presenters/PostPresenter.php

use App\Acquaintances\Interaction;

public function values(): array
{
    return [
        ...
        'visits' => Interaction::numberToReadable($this->visits),
        'likes' => $this->likers_count_readable,
        ...
    ];
}

https://ithelp.ithome.com.tw/upload/images/20200925/20113602nal9U4jsRM.jpg

終於告一段落了。記得把假的數字改回來 XD!

預加載用戶關聯數量

UserPresenter 還有沒有預加載的關聯數量,也一樣讀取預加載的數量:

app/Presenters/UserPresenter.php

public function presetWithCount()
{
    return $this->with(fn (User $user) => [
        'postsCount' => $user->published_posts_count,
        'likesCount' => $user->liked_posts_count,
    ]);
}

然後在文章的 Controller 處進行預加載:

app/Http/Controllers/Post/ShowPost.php

public function __invoke(Post $post)
{
    ...
    $this->incrementVisit($post);

    $post->load(['author' => fn ($query) => $query->withCount('publishedPosts', 'likedPosts')]);
    ...
}

回頭再去看 Debugbar, 會發現少了 2 個 Query,目的達成!

修復我的文章/草稿列表 N+1 問題

既然要修就一起修,這個算是我的失誤 (欸嘿~)。在我的文章/草稿列表裡本來就不需要載入文章作者,因為這全部都是當前登入用戶的文章。因此原本會引入作者的 preset('list') 就可以刪掉,同時也消除了 N+1 問題的根源:

app/Http/Controllers/Post/PostController.php

public function index()
{
    ...
    return Inertia::render('Post/List', [
        ...
        'posts' => PostPresenter::collection($posts)->get(),
    ]);
}

public function drafts()
{
    ...
    return Inertia::render('Post/List', [
        ...
        'posts' => PostPresenter::collection($posts)->get(),
    ]);
}

部分重載 (Partial Reload) 和懶加載 (Lazy Evaluation)

再來就是本篇的重點,部分重載 (Partial Reload)懶加載 (Lazy Evaluation),這兩個部分在 Inertia 裡算是比較進階一點的東西,但也不是很難。

先說部分重載,為什麼要部分重載?先看看下圖:

https://ithelp.ithome.com.tw/upload/images/20200925/20113602GXv7wawX0F.jpg

在點完喜歡後更新頁面時,卻還是重新載入所有 Props,但絕大部分的 Props 都是不需要重新加載。這時候部分重載就可以用 only 指定只要載入哪些 Props,減輕回傳的資料量。

既然需要使用到部分重載,就拿點擊喜歡文章來示範。首先先指定更新頁面只會加載 postOnlyLikeserrorspostOnlyLikes 只會裝更新頁面會更新的相關資料,等下會在後端設定。然後要把 likesis_liked 變成 postOnlyLikes 的屬性。還有在點完喜歡後頁面都會被拉回頂端,這個只要加上 preserve-scroll,就可以保留滾動位置了:

resources/js/Pages/Post/Show.vue

<template>
  <inertia-link
    ...
    preserve-scroll
    :only="['postOnlyLikes', 'errors']"
    ...
  >
    <icon class="mr-1 text-purple-500" :icon="!postOnlyLikes.is_liked
      ? 'heroicons-outline:heart'
      : 'heroicons-solid:heart'"
    />喜歡 | {{ postOnlyLikes.likes }}
  </inertia-link>
</template>

<script>
export default {
  props: {
    ...
    postOnlyLikes: Object
  }
}

新增 postOnlyLikes,裡面只裝點了喜歡後要更新的資料:

app/Http/Controllers/Post/ShowPost.php

public function __invoke(Post $post)
{
    ...
    return Inertia::render('Post/Show', [
        ...
        'postOnlyLikes' => PostPresenter::make($post)
            ->only('likes')
            ->with(fn (Post $post) => [
                'is_liked' => $post->is_liked,
            ])
            ->get(),
    ]);
}

新增 Post 裡的 is_liked Accessor,並快取 is_liked 的值:

app/Post.php

use Illuminate\Support\Facades\Auth;

protected ?bool $isLikedCache = null;

public function getIsLikedAttribute()
{
    if (is_null($this->isLikedCache)) {
        $this->isLikedCache = Auth::user() ? $this->isLikedBy(Auth::user()) : false;
    }

    return $this->isLikedCache;
}

當然其實 $post->is_liked 是從 PostPresenter 裡抽取出來的,也要更新一下:

app/Presenters/PostPresenter.php

public function presetShow()
{
    return $this->with(fn (Post $post) => [
        ...
        'is_liked' => $post->is_liked,
    ]);
}

看看結果,點了喜歡的資料載入就減少許多了:

https://ithelp.ithome.com.tw/upload/images/20200925/20113602TPaZsOHqwL.jpg

原理

剛才實作完了,現在回來看原理,首先是熟悉的 HTTP 請求內容。這次不是照搬官網的,而是調整成剛才部分重載的 HTTP 請求:

請求:

GET: https://lightning.test/posts/1
Accept: text/html, application/xhtml+xml
X-Requested-With: XMLHttpRequest
X-Inertia: true
X-Inertia-Partial-Data: postOnlyLikes,errors
X-Inertia-Partial-Component: Post/Show
X-Inertia-Version: 88ddfee9f5eae5bd7f534fa187e69da8

響應:

HTTP/1.1 200 OK
Content-Type: application/json

{
  "component": "Post/Show",
  "props": {
    "post": {...},       // 不包含
    "postOnlyLikes": {   // 包含
      "likes": "6",
      "is_liked": false
    },
    "errors": {...}      // 包含
  },
  "url": "/posts/39",
  "version": "88ddfee9f5eae5bd7f534fa187e69da8"
}

這次終於要來講新的 Header 了,X-Inertia-Partial-Data 是剛才傳進去 only 的欄位,但要用 , 隔開,X-Inertia-Partial-Component 是要部分重載的組件名稱,因為 Inertia 有個規定,只能對同一頁面發出部分重載的請求,否則不會起作用。

說完部分重載再來說 懶加載 (Lazy Evaluation),懶加載一定要在用部分重載時才有作用。就拿剛才的範例,假設現在有個 test_users 欄位,裡面有個 User::all() 會跟資料庫發 select * from `users`; 這串 Query,即使用了部分重載 (即 only 沒有包含 test_users),那串 SQL Query 仍會執行:

app/Http/Controllers/Post/ShowPost.php

public function __invoke(Post $post)
{
    ...
    return Inertia::render('Post/Show', [
        'test_users' => \App\User::all(),
    ]);
}

其實這也沒有很奇怪,因為程式執行到這裡就一定會先發送 SQL Query 才會傳值給 Inertia 視圖。要推遲執行時間就要使用懶加載,作法很簡單,只要套上 Closure 就行了。只有確定要使用到 test_users 時才會真正被加載:

app/Http/Controllers/Post/ShowPost.php

public function __invoke(Post $post)
{
    ...
    return Inertia::render('Post/Show', [
        'test_users' => fn () => \App\User::all(),
    ]);
}

然後再回去看 Debugbar,只有在頁面剛載入時 (需要 test_users) 才會執行 select * from `users`;,之後再點喜歡文章按鈕時 (不需要 test_users) 都不會看到這行 Query 了。這就是 懶加載

總結

文章喜歡的功能終於處裡完成了,同時也了解了 Inertia 的 部分重載懶加載功能。下篇開始要進入本系列 Lightning 的最後一個功能 文章留言 的部分,快要結束囉!

Lightning 範例程式碼:https://github.com/ycs77/lightning

參考資料


上一篇
Day 22 Lightning 喜歡文章功能
下一篇
Day 24 Lightning 留言功能
系列文
關於我用 Laravel 寫 SPA 卻不寫 API 的那檔事30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言