AJAX loading of related products in Magento 2

AJAX loading of related products in Magento 2

Introduction

In some projects the amount of related products can be significant. In my case there were more than 300 related products and upsell products on some product detail pages. The consequence of this was a high time to first byte on product detail pages. In the mentioned project it was about 15 seconds for some of the products. So we searched for a possible optimization. We finally decided to implement asynchronous loading of product recommendations via AJAX. At the end we had a significant performance improvement and better user experience.

In the following I will describe how to implement the solution on the example of related products on product detail pages. With some modifications this can also be used for upsell products on PDP, or even for crosssells in the Checkout Cart.

In the following we use a sample extension called N98_AjaxProductRecommendations.

The principle of the implementation

First what needs to be done is remove the rendering of the related product block on product detail page. After that we replace the removed block with a custom placeholder block with own placeholder template. The template contains a placeholder <div> element and an initialization of a custom JS file. On document ready event an AJAX request is triggered to get the content of related products list and to put the content into the mentioned placeholder <div>. For the AJAX request we need a custom controller class and an abstract layout file. In the placeholder template we also build a check whether the product has related products and only if so, initialize the JS. This avoids unneeded requests for pages where there are no related products linked.

Initialize module N98_AjaxProductRecommendations

etc/module.xml :

<?xml version="1.0"?>
<!--
/**
 * @copyright Copyright (c) netz98 GmbH (https://www.netz98.de)
 */
-->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
    <module name="N98_AjaxProductRecommendations">
        <sequence>
            <module name="Magento_Catalog" />
            <module name="Magento_TargetRule" />
        </sequence>
    </module>
</config>

composer.json :

{
    "name": "n98/ext.magento2.n98.ajax-product-recommendations",
    "description": "Switch to AJAX loading of product recommendations.",
    "type": "magento2-module",
    "license": [
        "proprietary"
    ],
    "authors": [
        {
            "name": "netz98 GmbH",
            "email": "magento@netz98.de"
        }
    ],
    "autoload": {
        "files": [
            "registration.php"
        ],
        "psr-4": {
            "N98\\AjaxProductRecommendations\\": ""
        }
    }
}

registration.php :

<?php
/**
 * @copyright Copyright (c) netz98 GmbH (https://www.netz98.de)
 */

\Magento\Framework\Component\ComponentRegistrar::register(
    \Magento\Framework\Component\ComponentRegistrar::MODULE,
    'N98_AjaxProductRecommendations',
    __DIR__
);

Route definition and controller

etc/frontend/routes.xml :

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">
    <router id="standard">
        <route id="n98_ajaxproductrecommendations" frontName="n98_ajaxproductrecommendations">
            <module name="N98_AjaxProductRecommendations" />
        </route>
    </router>
</config>

Controller/Ajax/RenderRelated.php :

<?php
/**
 * @copyright Copyright (c) netz98 GmbH (https://www.netz98.de)
 *
 * @see PROJECT_LICENSE.txt
 */

namespace N98\AjaxProductRecommendations\Controller\Ajax;

use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Framework\App\Action\Action;
use Magento\Framework\App\Action\Context;
use Magento\Framework\App\ResponseInterface;
use Magento\Framework\Controller\Result\Json;
use Magento\Framework\Controller\Result\JsonFactory;
use Magento\Framework\Registry;

/**
 * Class RenderRelated
 *
 * @package N98\AjaxProductRecommendations\Controller\Ajax
 */
class RenderRelated extends Action
{
    const BLOCK_NAME_PRODUCT_RELATED_PRODUCTS = 'catalog.product.related';
    /**
     * @var JsonFactory
     */
    private $jsonFactory;
    /**
     * @var ProductRepositoryInterface
     */
    private $productRepository;
    /**
     * @var Registry
     */
    private $coreRegistry;

    /**
     * Render constructor.
     * @param Context $context
     * @param JsonFactory $jsonFactory
     * @param ProductRepositoryInterface $productRepository
     * @param Registry $coreRegistry
     */
    public function __construct(
        Context $context,
        JsonFactory $jsonFactory,
        ProductRepositoryInterface $productRepository,
        Registry $coreRegistry
    ) {
        parent::__construct($context);
        $this->jsonFactory = $jsonFactory;
        $this->productRepository = $productRepository;
        $this->coreRegistry = $coreRegistry;
    }

    /**
     * Execute action based on request and return result
     *
     * Note: Request will be added as operation argument in future
     *
     * @return \Magento\Framework\Controller\ResultInterface|ResponseInterface
     * @throws \Magento\Framework\Exception\NotFoundException
     */
    public function execute()
    {
        if (!$this->getRequest()->isAjax()) {
            $this->_forward('noroute');
            return;
        }

        $result = $this->jsonFactory->create();

        $productId = $this->getRequest()->getParam('product_id');

        if (!$productId) {
            return $this->setErrorResult(
                $result,
                __('Product recommendations could not be loaded.')
            );
        }

        try {
            $product = $this->productRepository->getById($productId);
            /*
             * set current product in registry for this
             * 2 keys in order it to be used in rendered blocks
             */
            $this->coreRegistry->register('product', $product);
            $this->coreRegistry->register('current_product', $product);

            $this->_view->loadLayout(
                ['default', 'n98_ajaxproductrecommendations_content_abstract'],
                true,
                true,
                false
            );
            $layout = $this->_view->getLayout();
            $block = $layout->getBlock(self::BLOCK_NAME_PRODUCT_RELATED_PRODUCTS);
            if (!$block) {
                return $this->setErrorResult(
                    $result,
                    __('Product recommendations could not be loaded.')
                );
            }
            $output = $block->toHtml();
            $result->setData(
                [
                    'output' => $output,
                    'success' => true
                ]
            );
            return $result;
        } catch (\Exception $e) {
            return $this->setErrorResult(
                $result,
                __('Product recommendations could not be loaded.')
            );
        }
    }

    /**
     * Set error result
     *
     * @param Json $result
     * @param string $errorMessage
     * @return Json
     */
    private function setErrorResult(Json $result, $errorMessage)
    {
        $result->setData(
            [
                'output' => $errorMessage,
                'success' => false
            ]
        );

        return $result;
    }
}

Frontend Block, Layouts, Template, ViewModel and JS file

Block/Placeholder/Related.php :

<?php
/**
 * @copyright Copyright (c) netz98 GmbH (https://www.netz98.de)
 *
 * @see PROJECT_LICENSE.txt
 */

namespace N98\AjaxProductRecommendations\Block\Placeholder;

use Magento\Catalog\Block\Product\View as CatalogProductView;

/**
 * Class Related
 *
 * @package N98\AjaxProductRecommendations\Block\Placeholder
 */
class Related extends CatalogProductView
{
    /**
     * @return string
     */
    public function getAjaxUrl()
    {
        // return relative url, base urls will be prefixed in js
        return 'n98_ajaxproductrecommendations/ajax/renderRelated';
    }
}

view/frontend/layout/catalog_product_view.xml :

<?xml version="1.0"?>
<!--
/**
 * @copyright Copyright (c) netz98 GmbH (https://www.netz98.de)
 *
 * @see PROJECT_LICENSE.txt
 */
-->
<page layout="1column" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <body>
        <referenceContainer name="content.aside">
            <block class="N98\AjaxProductRecommendations\Block\Placeholder\Related"
                   name="catalog.product.related.placeholder" as="related-products-placeholder"
                   template="N98_AjaxProductRecommendations::placeholder/related.phtml"
                   before="product.info.upsell">
                <arguments>
                    <argument name="view_model" xsi:type="object">N98\AjaxProductRecommendations\ViewModel\RelatedViewModel</argument>
                </arguments>
            </block>
        </referenceContainer>
        <!-- Remove related blocks as they are now loaded via AJAX -->
        <referenceBlock name="related_products_impression" remove="true"/>
        <referenceBlock name="catalog.product.related" remove="true"/>
    </body>
</page>

view/frontend/layout/n98_ajaxproductrecommendations_content_abstract.xml :

<?xml version="1.0"?>
<!--
/**
 * @copyright Copyright (c) netz98 GmbH (https://www.netz98.de)
 *
 * @see PROJECT_LICENSE.txt
 */
-->
<layout xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/layout_generic.xsd">
    <block class="Magento\TargetRule\Block\Catalog\Product\ProductList\Related" name="catalog.product.related" template="Magento_Catalog::product/list/items.phtml">
        <arguments>
            <argument name="type" xsi:type="string">related-rule</argument>
            <argument name="view_model" xsi:type="object">Magento\Catalog\ViewModel\Product\Listing\PreparePostData</argument>
        </arguments>
        <block class="Magento\Catalog\Block\Product\ProductList\Item\Container" name="related.product.addto" as="addto">
            <block class="Magento\Wishlist\Block\Catalog\Product\ProductList\Item\AddTo\Wishlist"
                   name="related.product.addto.wishlist" as="wishlist" before="compare"
                   template="Magento_Wishlist::catalog/product/list/addto/wishlist.phtml"/>
            <block class="Magento\Catalog\Block\Product\ProductList\Item\AddTo\Compare"
                   name="related.product.addto.compare" as="compare"
                   template="Magento_Catalog::product/list/addto/compare.phtml"/>
        </block>
    </block>
</layout>

view/frontend/templates/placeholder/related.phtml :

<?php
/**
 * @copyright Copyright (c) netz98 GmbH (https://www.netz98.de)
 *
 * @see       PROJECT_LICENSE.txt
 */
/** @var $block \N98\AjaxProductRecommendations\Block\Placeholder\Related */
/** @var \N98\AjaxProductRecommendations\ViewModel\RelatedViewModel $viewModel */
$viewModel = $block->getData('view_model');
$product = $block->getProduct();
?>
<?php if ($viewModel->productHasRelatedProducts($product)): ?>
    <?php
    $htmlPlaceholderId = 'product-recommendation-detailpage-related-placeholder';
    ?>
    <div id="<?php echo $htmlPlaceholderId; ?>"></div>
    <?php
    $ajaxUrl = $block->getAjaxUrl();
    $loaderImage = $block->escapeUrl($block->getViewFileUrl('images/loader-1.gif'));
    ?>
    <script type="text/x-magento-init">
    {
        "*": {
            "N98_AjaxProductRecommendations/js/related-products-loader": {
                "ajaxUrl": "<?php echo $ajaxUrl; ?>",
                "productId": "<?php echo $product->getId(); ?>",
                "htmlPlaceholderId": "<?php echo $htmlPlaceholderId; ?>",
                "loaderImage": "<?php echo $loaderImage; ?>"
            }
        }
    }

    </script>
<?php endif; ?>

view/frontend/web/js/related-products-loader.js :

define([
    "jquery",
    "loader"
], function ($) {
    "use strict";

    function renderRelatedProducts(config) {
        var ajaxUrl = BASE_URL + config.ajaxUrl;
        var productId = config.productId;
        var elementId = config.htmlPlaceholderId;
        var loaderImageUrl = config.loaderImage;

        $(document).ready(function () {
            $.ajax({
                context: '#' + elementId,
                url: ajaxUrl,
                type: 'POST',
                data: {product_id: productId},
                dataType: 'json',
                beforeSend: function() {
                    $('#' + elementId).loader({icon: loaderImageUrl});
                    $('#' + elementId).trigger('processStart');
                },
                success: function(data) {
                    var element = $('#' + elementId);
                    if (data.success === true) {
                        element.html(data.output).trigger('contentUpdated');
                        $('form[data-role="tocart-form"]').catalogAddToCart();
                    } else {
                        element.html('<p><strong>' + data.output + '</strong></p>'); // display error message
                    }
                    return data.success;
                },
                complete: function() {
                    $('#' + elementId).trigger('processStop');
                },
                error: function() {
                    $('#' + elementId).trigger('processStop');
                }
            });
        });
    }

    return renderRelatedProducts;
});

ViewModel/RelatedViewModel.php :

<?php
/**
 * @copyright Copyright (c) netz98 GmbH (https://www.netz98.de)
 *
 * @see       PROJECT_LICENSE.txt
 */

namespace N98\AjaxProductRecommendations\ViewModel;

use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Model\Product;
use Magento\Framework\View\Element\Block\ArgumentInterface;

/**
 * Class RelatedViewModel
 *
 * @package N98\AjaxProductRecommendations\ViewModel
 */
class RelatedViewModel implements ArgumentInterface
{
    /**
     * Product has related products check
     *
     * @param ProductInterface $product
     *
     * @return bool
     */
    public function productHasRelatedProducts(ProductInterface $product)
    {
        /** @var Product $product */
        $relatedCollection = $product->getRelatedLinkCollection();

        // use getSize() methods in order not to load collection but just trigger count sql
        if ($relatedCollection->getSize() > 0) {
            return true;
        }

        return false;
    }
}

Summing up

The presented code was tested with Magento Commerce 2.3.5 and simple products only. The examples in this article do not claim to be complete. Feel free to leave a comment if you have a question or suggestion for improvements.

Leave a Reply

Your email address will not be published. Required fields are marked *