Liquid Snippets by ALSEL
🔌 API連携/JSON出力/JS統合上級

サードパーティ製アプリの遅延読み込み最適化

Shopify に統合されたサードパーティ製アプリスクリプトの読み込み方式を管理画面から制御し、ページ表示速度を改善するセクション。スクロール、ユーザーインタラクション、または即座読み込みのいずれかに切り替えられる。

用途
ストアのページ表示速度が遅い場合に、非重要なアプリスクリプト(チャット、レビュー、分析ツール等)の読み込みタイミングを遅延させてLCP・FIDを改善したいとき。
設置場所
theme.liquid の <head> 直前、または sections/app-optimization.liquid として配置し、Theme Customizer でセクションを追加。管理画面でアプリの URL と読み込みモード(即座・スクロール・インタラクション)を指定する。
注意点
content_for_header でアプリが既に inject されていることが前提となるため、管理画面で当該アプリが未インストール状態では動作しない。インタラクション読み込みで指定したセレクタ(CSS クラス・ID)が実際に DOM に存在することを確認しておく。複数ページで異なる読み込みモードを設定する場合、request.page_type で正確にページタイプを判定して block_key / scroll_key / interaction_key を作り分ける必要がある。
タグ:performanceapp-loadinglazy-loadscript-optimizationjavascript

コード

769 行 / liquid
{%- if section.settings.enable -%}

{%- liquid
  assign page = request.page_type | split: '/' | first

  assign block_key = 'block_' | append: page 
  assign scroll_key = 'scroll_' | append: page 
  assign interaction_key = 'interaction_' | append: page

  assign arr_block = '' | split: ''
  assign arr_scroll = '' | split: ''
  assign arr_interaction = '' | split: '' 

  for block in section.blocks
    assign url = block.settings.url | replace: '/', '\/'

    if block.settings[block_key] == true and content_for_header contains url
      assign arr_block = block | concat: arr_block
    endif

    if block.settings[scroll_key] == true and content_for_header contains url
      assign arr_scroll = block | concat: arr_scroll
    endif

    if block.settings[interaction_key] == true and content_for_header contains url
      assign arr_interaction = block | concat: arr_interaction
    endif
  endfor

  assign settings_data = arr_block | concat: arr_scroll | concat: arr_interaction | uniq | map: 'settings'
-%}

{%- if settings_data.size != 0 -%}
  <!-- section/app-optimization.liquid -->
  <script>
    (function AppOptimization() {
      'use strict';

      var supportsPassive = getPassiveSupport();
      var page = {{ page | json }};
      var debug = {{ section.settings.debug_enable | json }};

      modifyCreateElement({{ settings_data | json }}.map(loadTypeFromSettings).map(eventsFromSettings).map(outputLogic));

      function loadTypeFromSettings(settings) {
        if (settings['settings_' + page]) settings.loadType = 'settings';
        else if (settings['scroll_' + page]) settings.loadType = 'scroll';
        else settings.loadType = 'interaction';
        return settings;
      }

      function eventsFromSettings(settings) {
        if (settings.loadType !== 'interaction') return settings;

        settings.interactionEvents = [];
        var selectorList = [settings.interaction_selectors_1, settings.interaction_selectors_2];
        var eventList = [settings.interaction_event_1, settings.interaction_event_2];

        selectorList.forEach(function(selector, index) {
          if (selector.length) {
            settings.interactionEvents.push({
              nodes: nodeList(selector),
              event: eventList[index]
            });
          }
        });
        return settings;
      }

      function outputLogic(settings) {
        var data = {
          url: settings.url,
          loadType: settings.loadType,
          interactionEvents: settings.interactionEvents
        }
        if (debug) console.log('App Optimiztion Data:', data);
        return data;
      }

      function modifyCreateElement(logic) {
        var createElementBackup = document.createElement;
        
        document.createElement = function() {
          var args = Array.prototype.slice.call(arguments);
          var node = createElementBackup.bind(document).apply(undefined, args);

          // Skip if this is not a script tag
          if (first(args).toLowerCase() !== 'script') return node;

          var originalSetAttribute = node.setAttribute.bind(node);

          Object.defineProperties(node, {
            'src': {
              get() { 
                return node.getAttribute('src') || ''
              },
              set(src) {
                var loadRule = getLoadRule(logic, src);
                if (loadRule) {
                  // Remove app from logic once matched
                  logic.forEach(function(item, index){
                    if (item.url === loadRule.url) logic.splice(index, 1);
                  });

                  // Change script MIME type if script should not load
                  originalSetAttribute('type', 'javascript/blocked');
                  loadRule.originalUrl = src;
                  if (loadRule.loadType === 'interaction') window.addEventListener('load', interactionLoad(loadRule), false);
                  if (loadRule.loadType === 'scroll') window.addEventListener('load', scrollLoad(loadRule), false);
                }
                originalSetAttribute('src', src);
                return true;
              }
            }
          });
          return node;
        }
      }

      function interactionLoad(loadRule) {
        loadRule.interactionEvents.forEach(function(interactionEvent) {
          interactionEvent.nodes.forEach(function(node) {
            var eventFn = partial(loadScript, loadRule.originalUrl);
            var eventOptions = supportsPassive ? {once: true} : false;
            node.addEventListener(interactionEvent.event, eventFn, eventOptions);
          });
        });
      }

      function scrollLoad(loadRule) {
        var eventFn = partial(loadOnScroll, loadRule.originalUrl);
        var eventOptions = supportsPassive ? {passive: true} : false;
        window.addEventListener('scroll', eventFn, eventOptions);

        function loadOnScroll(src) {
          loadScript(src);
          window.removeEventListener('scroll', eventFn, eventOptions);
        }
      }

      function getLoadRule(logic, src) {
        return first(logic.filter(function(item) {
          return stringIncludes(src, item.url);
        }));
      }

      function getPassiveSupport() {
        var supportsPassive = false;
        try {
          var opts = Object.defineProperty({}, 'passive', {
            get: function() { supportsPassive = true; }
          });
          window.addEventListener('testPassive', null, opts);
          window.removeEventListener('testPassive', null, opts);
        } catch (e) {}
        return supportsPassive;
      }

      function loadScript(src) {
        var script = document.querySelector('script[type="javascript/blocked"][src="' + src + '"]');
        if (!script) return;

        var newScript = document.createElement('script');
        newScript.src = script.src;
        newScript.async = true;
        newScript.setAttribute('data-app-optimization', true);
        document.body.appendChild(newScript);

        if (debug) console.log('App Optimization Script Load:', newScript);
      }

      function nodeList(str) {
        var nodes = []
        try { nodes = Array.prototype.slice.call(document.querySelectorAll(str));
        } catch {};
        return nodes;
      }

      function stringIncludes(str1, str2) {
        return String(str1).indexOf(String(str2)) != -1;
      }

      function first(arr) {
        return arr[0];
      }

      function partial(fn) {
        var slice = Array.prototype.slice;
        var args = slice.call(arguments, 1);
        return function() {
          return fn.apply(this, args.concat(slice.call(arguments, 0)));
        };
      }
    })();
  </script>
  {%- endif -%}
{%- endif -%}

{%- if request.design_mode -%}
{%- comment -%}
  The code below will be loaded only in the Theme Editor
{%- endcomment-%}
{%- assign urls = content_for_header | split: 'var urls =' | last | split: 'for (var i = 0' | first | remove: ';' | split: ',' -%}

<template id="sd-app-list-template">
  <style>  
    :host {
      display: block;
      contain: content;
      padding: 3.2rem 1rem;
      background: #f6f6f7;
      width: 100%;
      box-shadow: 0 0 0 1px rgb(63 63 68 / 5%), 0 1px 3px 0 rgb(63 63 68 / 15%);
    }

    .w-full { width: 100% }

    .grid-container {
      max-width: 1100px;
      margin: 0 auto;
      padding: 16px 2px;
    }

    * {
      font-family: -apple-system, BlinkMacSystemFont, San Francisco, Segoe UI, Roboto, Helvetica Neue,sans-serif;
      font-size: 14px;
      color: #202223;
      text-align: left
    }

    p {
      padding: 0;
      margin: 0;
    }
    
    a {
      text-decoration: none;
    }
    
    table {
      border-collapse: collapse;
    }
    
    td, th {
      padding: 16px
    }
    
    th p { 
      text-transform: uppercase;
      font-size: 12px;
      font-weight: 600;
    }
    
    tr {
      border-bottom: 1px solid #e1e3e5;
    }
    
    tr:hover,
    tr:hover td:first-child {
      background: #fafbfb;
    }
    
    a:hover {
      text-decoration: underline;
    }
    
    td:first-child,
    th:first-child {
      position: sticky;
      left: 0;
      background: #fff;
    }
    
    th, th:first-child { background: #fafbfb; }
    
    [data-badge] {
      background-color: #e4e5e7;
      display: inline-block;
      border-radius: 2rem;
      font-size: 13px;
      line-height: 16px;
      padding: 3px 8px;
    }
    
    [data-badge="Interaction"],
    [data-badge="Scroll"] {
      background-color: #aee9d1;
    }
    
    [data-badge="Block"] {
      background-color: #fed3d1;
    }
    
    .card {
      background-color: #ffffff;
      box-shadow: rgb(23 24 24 / 5%) 0px 0px 5px 0px, rgb(0 0 0 / 15%) 0px 1px 2px 0px;
      outline: .1rem solid transparent;
      border-radius: 8px;
      overflow: auto;
      -webkit-overflow-scrolling: touch;
    }
    
    button {
      background: transparent;
      border: 0;
      cursor: pointer
    }
    
    button svg {
      display: block;
      fill: #5C5F62;
    }
    
    button:hover svg {
      fill: #008060;
    }
    
    h2 {
      font-size: 16px;
      font-weight: 600;
      line-height: 24px;
    }
    
    .text-right { text-align: right }
    .text-center { text-align: center }
    .pr-8 { padding-right: 8px }
    .mb-4 { margin-bottom: 4px }
    .mb-32 { margin-bottom: 32px }
    .flex { display: flex }
    .whitespace-no-wrap { white-space: nowrap }
    
    .btn-outline {
      padding: 9px 16px;
      border: 1px solid rgb(140, 145, 150);
      border-radius: 4px;
      display: inline-block;
      margin-top: 16px;
      margin-left: 8px;
      font-size: 14px;
      line-height: 16px;
    }
    
    .btn-primary {
      background: #008060;
      color: #fff;
      border-color: #008060;
    }
    
    .header-grid {
      display: grid;
      grid-template-columns: 1.7fr 1fr;
    }
  </style>

  <div class="grid-container">
    <div class="header-grid mb-32">
      <div>
        <h2 class="mb-4">App Optimization by Sections.design</h2>
        <p>Improve performance by optimizing how, when, and where your apps will load.</p> 
      </div>
      <div class="text-right">
        <a class="btn-outline text-center" target="_blank" href="https://sections.design/blogs/shopify/app-optimization">Read blog post</a> <a class="btn-outline btn-primary text-center" target="_blank" href="https://youtu.be/UFdTKDPCc_Q">View setup video</a>
      </div>
    </div>
      
	  <div class="card w-full">
      <table class="w-full">
        <thead>
          <tr>
            <th><p>Application</p></th>
            <th><p>Script URL</p></th>
            <th><p class="text-right pr-8">{{ page }} page load</p></th>
          </tr>
        </thead>
        <tbody>
        {%- for url in urls -%}
          {%- liquid
            assign name = 'App name'
            assign load = 'Default'
            assign block_key = 'block_' | append: page
            assign scroll_key = 'scroll_' | append: page
            assign interaction_key = 'interaction_' | append: page

            assign url_clean = url | remove: '"' | remove: '[' | remove: ']' | replace: '\/', '/' | replace: '\u0026', '&' | remove: 'https://' | remove: '//' | strip 
            assign url_display = url_clean | split: '?' | first

            for block in section.blocks
              if block.settings.url != blank and url_clean contains block.settings.url
                assign name = block.settings.title
                if block.settings[block_key] == true
                  assign load = 'Block'
                elsif block.settings[scroll_key] == true
                  assign load = 'Scroll'
                elsif block.settings[interaction_key] == true
                  assign load = 'Interaction'
                endif
              endif
            endfor
          -%}

          <tr>
            <td><p>{{ name }}</p></td>
            <td>
              <p class="flex whitespace-no-wrap">
              	<a href="https://{{ url_clean }}" target="_blank" rel="noreferrer">{{ url_display }}</a>
                <button type="button" data-clipboard="{{ url_display }}" >
                  <svg aria-hidden="true" width="14" height="14" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M7.5 2A1.5 1.5 0 006 3.5V13a1 1 0 001 1h9.5a1.5 1.5 0 001.5-1.5v-9A1.5 1.5 0 0016.5 2h-9zm-4 4H4v10h10v.5a1.5 1.5 0 01-1.5 1.5h-9A1.5 1.5 0 012 16.5v-9A1.5 1.5 0 013.5 6z"/></svg>
                </button>
              </p>
            </td>
            <td class="text-right"><p data-badge="{{ load }}">{{ load }}</p></td>
          </tr>
        {%- endfor -%}
        </tbody>
      </table>
    </div>
  </div>
</template>  

<sd-app-list></sd-app-list>

<script>
  (function ThemeEditor() {
    'use strict';       
    
    document.addEventListener('shopify:section:select', select);
    document.addEventListener('shopify:section:load', load);
    document.addEventListener('shopify:section:deselect', deselect);
    
    function select(evt) {
      if (evt.detail.sectionId !== 'app-optimization') return;
      
      let node = document.querySelector('sd-app-list');
      if (node) node.removeAttribute('style');
      if (customElements.get('sd-app-list')) return;
      defineElement();
      initCopyEvents(node.shadowRoot);
    }
   
    function load(evt) {
      if (evt.detail.sectionId !== 'app-optimization') return;
      
      let shadowRoot = document.querySelector('sd-app-list').shadowRoot;
      let nodes = Array.prototype.slice.call(shadowRoot.children);
      nodes.forEach(node => node.parentNode.removeChild(node));
      shadowRoot.appendChild(templateContent());
      initCopyEvents(shadowRoot);
    }
  
    function deselect(evt) {
      if (evt.detail.sectionId !== 'app-optimization') return;
      
      let node = document.querySelector('sd-app-list');
      if (node) node.style.display = 'none';
    }
    
    function defineElement() {
      customElements.define('sd-app-list',
        class extends HTMLElement {
          constructor() {
            super();
            const shadowRoot = this.attachShadow({mode: 'open'}).appendChild(templateContent());
          }
        }
      );
    }
   
    function templateContent() {
      let template = document.getElementById('sd-app-list-template');
      return template.content.cloneNode(true);
    }
   
    function initCopyEvents(shadowRoot) {
      let nodes = Array.prototype.slice.call(shadowRoot.querySelectorAll('[data-clipboard]'));
      nodes.forEach(node => node.addEventListener('click', copyText));
    }
  
    function copyText() {
      navigator.clipboard.writeText(this.getAttribute('data-clipboard'));
    }  
  })();
</script>
{%- endif -%}

{% schema %}
  {
    "name": "App Optimiztion",
    "settings": [
      {
        "type": "checkbox",
         "id": "enable",
         "label": "Enable App Optimization?",
         "default": true
      },
      {
        "type": "checkbox",
         "id": "debug_enable",
         "label": "Enable debug?",
         "default": false,
         "info": "Output script loading information in console.log"
      }
    ],
    "blocks": [
      {
        "type": "app",
        "name": "Application",
        "settings": [
          {
            "type": "text",
            "id": "title",
            "label": "App title"
          },
          {
            "type": "text",
            "id": "url",
            "label": "App ScriptTag URL",
            "placeholder": "e.g. shopifycdn.com/assets/v4/spr.js",
            "info": "Use partial URL, e.g: shopifycdn.com/assets/v4/spr.js"
          },
          {
            "type": "header",
            "content": "Block script from loading"
          },
          {
            "type": "paragraph",
            "content": "On selected pages, the app will not be loaded"
          },
          {
            "type": "checkbox",
            "id": "block_index",
            "label": "Index"
          },
          {
            "type": "checkbox",
            "id": "block_product",
            "label": "Product"
          },
          {
            "type": "checkbox",
            "id": "block_collection",
            "label": "Collection"
          },
           {
            "type": "checkbox",
            "id": "block_page",
            "label": "Page"
          },
          {
            "type": "checkbox",
            "id": "block_blog",
            "label": "Blog"
          },
          {
            "type": "checkbox",
            "id": "block_article",
            "label": "Article"
          },
          {
            "type": "checkbox",
            "id": "block_cart",
            "label": "Cart"
          },  
          {
            "type": "checkbox",
            "id": "block_search",
            "label": "Search"
          },        
          {
            "type": "checkbox",
            "id": "block_customers",
            "label": "Customers"
          }, 
          {
            "type": "checkbox",
            "id": "block_gift_card",
            "label": "Gift card"
          }, 
          {
            "type": "header",
            "content": "Load script on user page scroll"
          },
          {
            "type": "paragraph",
            "content": "On selected pages, the app will start to load when the user scrolls the page."
          },
          {
            "type": "checkbox",
            "id": "scroll_index",
            "label": "Index"
          },
          {
            "type": "checkbox",
            "id": "scroll_product",
            "label": "Product"
          },
          {
            "type": "checkbox",
            "id": "scroll_collection",
            "label": "Collection"
          },
           {
            "type": "checkbox",
            "id": "scroll_page",
            "label": "Page"
          },
          {
            "type": "checkbox",
            "id": "scroll_blog",
            "label": "Blog"
          },
          {
            "type": "checkbox",
            "id": "scroll_article",
            "label": "Article"
          },
          {
            "type": "checkbox",
            "id": "scroll_cart",
            "label": "Cart"
          },  
          {
            "type": "checkbox",
            "id": "scroll_search",
            "label": "Search"
          },        
          {
            "type": "checkbox",
            "id": "scroll_customers",
            "label": "Customers"
          }, 
          {
            "type": "checkbox",
            "id": "scroll_gift_card",
            "label": "Gift card"
          },
          {
            "type": "header",
            "content": "Load script on user interaction"
          },
          {
            "type": "paragraph",
            "content": "On selected pages, the app will start to load when the user will trigger the assigned events."
          },
          {
            "type": "checkbox",
            "id": "interaction_index",
            "label": "Index"
          },
          {
            "type": "checkbox",
            "id": "interaction_product",
            "label": "Product"
          },
          {
            "type": "checkbox",
            "id": "interaction_collection",
            "label": "Collection"
          },
           {
            "type": "checkbox",
            "id": "interaction_page",
            "label": "Page"
          },
          {
            "type": "checkbox",
            "id": "interaction_blog",
            "label": "Blog"
          },
          {
            "type": "checkbox",
            "id": "interaction_article",
            "label": "Article"
          },
          {
            "type": "checkbox",
            "id": "interaction_cart",
            "label": "Cart"
          },  
          {
            "type": "checkbox",
            "id": "interaction_search",
            "label": "Search"
          },        
          {
            "type": "checkbox",
            "id": "interaction_customers",
            "label": "Customers"
          }, 
          {
            "type": "checkbox",
            "id": "interaction_gift_card",
            "label": "Gift card"
          },
          {
            "type": "header",
            "content": "User interaction event 1"
          },
          {
            "type": "text",
            "id": "interaction_selectors_1",
            "label": "CSS selectors list",
            "info": "Comma separated CSS selectors",
            "placeholder": ".btn, .menu"
          },
          {
            "type": "select",
            "id": "interaction_event_1",
            "label": "Interaction event name",
            "options": [
              {
                "value": "click",
                "label": "click"
              },
              {
                "value": "mousedown",
                "label": "mousedown"
              },
              {
                "value": "mouseup",
                "label": "mouseup"
              },
              {
                "value": "focus",
                "label": "focus"
              }
            ],
            "default": "click"
          },
          {
            "type": "header",
            "content": "User interaction event 2"
          },
          {
            "type": "text",
            "id": "interaction_selectors_2",
            "label": "CSS selectors list",
            "info": "Comma separated CSS selectors",
            "placeholder": ".btn, .menu"
          },
          {
            "type": "select",
            "id": "interaction_event_2",
            "label": "Interaction event name",
            "options": [
              {
                "value": "click",
                "label": "click"
              },
              {
                "value": "mousedown",
                "label": "mousedown"
              },
              {
                "value": "mouseup",
                "label": "mouseup"
              },
              {
                "value": "focus",
                "label": "focus"
              }
            ],
            "default": "click"
          }
        ]
      }
    ]
  }
{% endschema %}

出典・ライセンス

License:
MIT

このコードは mirceapiturca 著作の MIT ライセンスソースです。 原本の著作権は mirceapiturca が保有します。日本語訳は ALSEL によるものです。

関連項目

🔌 API連携/JSON出力/JS統合中級

定期販売プランのデータ属性生成

商品バリアントと定期販売プランの ID を HTML data 属性として動的に生成するスニペット。ループで複数プランの ID を data-sellingId-1、data-sellingId-2 のように番号付けして出力する。

📁 theme-tools·MIT·9
🔌 API連携/JSON出力/JS統合中級

カスタム要素の親コンポーネント

Web Components の親要素(`<parent-element>`)でラップし、子スニペットを描画するコンポーネント。カスタム要素に依存した構造を実装する際のテンプレート。

📁 theme-tools·MIT·10
🔌 API連携/JSON出力/JS統合上級

javascript タグ内の JS コード整形

Liquid の `{% javascript %}` タグ内に記述した JavaScript コードを Prettier で自動整形する。純粋な JS であれば完全整形、Liquid 変数を含む場合はインデント調整のみで対応する。

📁 theme-tools·MIT·30
🔌 API連携/JSON出力/JS統合上級

JavaScriptタグ内のコード整形テスト

Liquid の {% javascript %} タグ内に埋め込まれた JavaScript コードを Prettier で整形するテストケース。純粋な JS、Liquid 混在、タブ設定、シングルクォート対応など複数のシナリオをカバーしている。

📁 theme-tools·MIT·38
🔌 API連携/JSON出力/JS統合上級

クイズ結果用の商品データとフィルタ条件を JSON 出力

選択したコレクションの全商品を JSON 形式で抽出し、クイズの回答ごとに設定されたフィルタ条件(価格・タグ・タイプ・ベンダー・オプション)と紐づけることで、JavaScript 側でリアルタイムに商品をマッチングできるようにする。

📁 Sections·MIT·93
🔌 API連携/JSON出力/JS統合上級

Vue コンポーネントのデモ

Vue コンポーネント、スロット、プロップ、Vuex ストア、グローバルミックス、カスタムディレクティブを一堂に展示するセクション。Shopify Theme Lab フレームワークの主要機能をインタラクティブに試せる。

📁 shopify-theme-lab·MIT·162