JavaScript >> Javascript 文檔 >  >> JavaScript

如何使用 SortableJS 構建拖放 UI

如何構建一個簡單的拖放式購物車 UI,其中包含商品列表和可將其放入的購物車。

開始使用

在本教程中,我們將使用 CheatCode 的全棧 JavaScript 框架 Joystick。 Joystick 將前端 UI 框架與用於構建應用的 Node.js 後端結合在一起。

首先,我們要通過 NPM 安裝 Joystick。確保在安裝之前使用 Node.js 16+ 以確保兼容性(如果您需要學習如何安裝 Node.js 或在計算機上運行多個版本,請先閱讀本教程):

終端

npm i -g @joystick.js/cli

這將在您的計算機上全局安裝操縱桿。安裝好之後,接下來我們新建一個項目:

終端

joystick create app

幾秒鐘後,您將看到一條消息已註銷到 cd 進入你的新項目並運行 joystick start .在你這樣做之前,我們需要安裝一個依賴sortablejs

終端

cd app && npm i sortablejs

之後,你就可以啟動你的應用了:

終端

joystick start

在此之後,您的應用應該可以運行了,我們可以開始了。

為商店物品添加組件

為了開始,我們將向前跳一點。在我們的商店中,我們的目標是擁有一個可以拖放到購物車中的商品列表。為了保持 UI 一致,我們希望商店中的商品與購物車中的商品使用相同的設計。

為方便起見,讓我們先創建一個 StoreItem 將顯示我們的每個購物車項目的組件。

/ui/components/storeItem/index.js

import ui from '@joystick.js/ui';

const StoreItem = ui.component({
  css: `
    div {
      position: relative;
      width: 275px;
      border: 1px solid #eee;
      padding: 15px;
      align-self: flex-end;
      background: #fff;
      box-shadow: 0px 0px 2px 2px rgba(0, 0, 0, 0.02);
    }

    div img {
      max-width: 100%;
      height: auto;
      display: block;
    }

    div h2 {
      font-size: 18px;
      margin: 10px 0 0;
    }

    div p {
      font-size: 15px;
      line-height: 21px;
      margin: 5px 0 0 0;
      color: #888;
    }

    div button {
      position: absolute;
      top: 5px;
      right: 5px;
      z-index: 2;
    }
  `,
  events: {
    'click .remove-item': (event, component = {}) => {
      if (component.props.onRemove) {
        component.props.onRemove(component.props.item.id);
      }
    },
  },
  render: ({ props, when }) => {
    return `
      <div data-id="${props.item?.id}">
        ${when(props.onRemove, `<button class="remove-item">X</button>`)}
        <img src="${props.item?.image}" alt="${props.item?.name}" />
        <header>
          <h2>${props.item?.name} &mdash; $${props.item?.price}</h2>
          <p>${props.item?.description}</p>
        </header>
      </div>
    `;
  },
});

export default StoreItem;

因為這個組件相當簡單,所以我們輸出了上面的全部內容。

我們的目標是為每個項目呈現卡片式設計。首先,在 render() 在上面組件的函數中,我們返回一個 HTML 字符串,當卡片呈現在屏幕上時,它代表卡片。

首先,關於<div></div> 標籤開始我們的 HTML,我們添加一個 data-id 屬性設置為值 props.item.id .如果我們看一下我們的 render() 函數定義我們可以看到我們期望傳遞一個值——一個代表組件實例的對象——我們可以用 JavaScript 解構它。

在那個對像上,我們期望一個 props 包含 props 或 properties 的值 作為對像傳遞給我們的組件。關於那個 對象,我們期望一個道具 item 它將包含我們正在嘗試渲染的當前項目(在商店或購物車中)。

這裡,data-id 我們設置為 props.item.id 的屬性 將用於識別在我們的 UI 中拖放時將哪個項目添加到購物車中。

接下來,我們利用 Joystick 的 when() 函數(稱為渲染函數),它幫助我們根據值有條件地返回一些 HTML。在這裡,我們傳遞 props.onRemove 作為第一個參數(我們想要測試的“真實性”),如果它存在,我們想要渲染一個 <button></button> 用於刪除項目。因為我們要為我們的購物車重用這個組件 我們的商店商品,我們想讓移除按鈕的呈現有條件,因為它只適用於我們購物車中的商品。

我們的 HTML 的其餘部分非常簡單。使用相同的 props.item 值,我們渲染 image , name , price , 和 description 從那個對象。

在此之上,在 events 對象——我們為組件定義 JavaScript 事件監聽器——我們定義了一個事件監聽器,它監聽 click 我們的 <button></button> 上的事件 的類.remove-item .如果檢測到點擊,Joystick 將調用我們傳遞給 click .remove-item 的函數 .

在該函數內部,我們檢查組件是否具有 component.props.onRemove 價值。如果它確實 我們想調用那個函數,傳入 component.props.item.id ,或者,我們嘗試從購物車中移除的商品的 ID。

最後,在我們組件的頂部,為了讓事情看起來更漂亮,我們添加了必要的 CSS 以使我們的組件具有卡片樣式的外觀。

繼續,接下來,我們要開始獲取主要的 Store 頁面連線。在此之前,我們需要快速修改服務器上的路由以呈現我們接下來要創建的商店頁面。

修改索引路由

當我們運行 joystick create app 時,我們需要對作為項目模板的一部分自動添加的路由進行一些小的更改 以上。打開/index.server.js 項目根目錄下的文件,我們想要更改我們傳遞給 res.render() 的頁面名稱 對於索引 / 路線:

/index.server.js

import node from "@joystick.js/node";
import api from "./api";

node.app({
  api,
  routes: {
    "/": (req, res) => {
      res.render("ui/pages/store/index.js", {
        layout: "ui/layouts/app/index.js",
      });
    },
    "*": (req, res) => {
      res.render("ui/pages/error/index.js", {
        layout: "ui/layouts/app/index.js",
        props: {
          statusCode: 404,
        },
      });
    },
  },
});

這裡,我們要修改對res.render()的調用 在處理函數內部傳遞給 "/" 路線,交換 ui/pages/index/index.js ui/pages/store/index.js 的路徑 .

注意 :這種變化是任意的,僅用於為我們的工作添加上下文。如果您願意,您可以保留原始路線並在 /ui/pages/index/index.js 處修改頁面 使用我們將在下面查看的代碼。

接下來,讓我們將頁面與我們的商店和購物車連接起來,我們將在該路徑上實現拖放 UI。

為我們的商店添加一個組件

現在是重要的東西。讓我們從創建我們假設存在於 /ui/pages/store/index.js 的組件開始 :

/ui/pages/store/index.js

import ui from '@joystick.js/ui';
import StoreItem from '../../components/storeItem';

const items = [
  { id: 'apple', image: '/apple.png', name: 'Apple', price: 0.75, description: 'The original forbidden fruit.' },
  { id: 'banana', image: '/banana.png', name: 'Banana', price: 1.15, description: 'It\'s long. It\'s yellow.' },
  { id: 'orange', image: '/orange.png', name: 'Orange', price: 0.95, description: 'The only fruit with a color named after it.' },
];

const Store = ui.component({
  state: {
    cart: [],
  },
  css: `
    .store-items {
      display: grid;
      grid-template-columns: 1fr 1fr 1fr;
      grid-column-gap: 20px;
      list-style: none;
      width: 50%;
      padding: 40px;
      margin: 0;
    }

    .cart {
      display: flex;
      background: #fff;
      border-top: 1px solid #eee;
      position: fixed;
      bottom: 0;
      left: 0;
      right: 0;
      padding: 25px;
      min-height: 150px;
      text-align: center;
      color: #888;
    }

    .cart footer {
      position: absolute;
      bottom: 100%;
      right: 20px;
      padding: 10px;
      border: 1px solid #eee;
      background: #fff;
    }

    .cart footer h2 {
      margin: 0;
    }

    .cart-items {
      width: 100%;
      display: flex;
      position: relative;
      overflow-x: scroll;
    }

    .cart-items > div:not(.placeholder):not(:last-child) {
      margin-right: 20px;
    }

    .cart-items .placeholder {
      position: absolute;
      inset: 0;
      display: flex;
      align-items: center;
      justify-content: center;
    }
  `,
  render: ({ component, each, when, state, methods }) => {
    return `
      <div class="store">
        <div class="store-items">
          ${each(items, (item) => {
            return component(StoreItem, { item });
          })}
        </div>
        <div class="cart">
          <div class="cart-items">
            ${when(state.cart.length === 0, `
              <div class="placeholder">
                <p>You don't have any items in your cart. Drag and drop items from above to add them to your cart.</p>
              </div>
            `)}
            ${each(state.cart, (item) => {
              return component(StoreItem, {
                item,
                onRemove: (itemId) => {
                  // We'll handle removing the item here.
                },
              });
            })}
          </div>
          <footer>
            <h2>Total: $${/*  We'll handle removing the item here. */}</h2>
          </footer>
        </div>
      </div>
    `;
  },
});

export default Store;

從頂部開始,首先,我們導入 StoreItem 我們在上面創建的組件。在此之下,我們創建了一個 items 的靜態列表 作為一個對像數組,每個對象代表我們商店中可用的一個項目。對於每個項目,我們都有一個 id , 一個 image , 一個 name , 一個 price , 和一個 description .

在此之下,我們使用 ui.component() 定義我們的組件 導入的ui提供的函數 @joystick.js/ui 中的對象 在頁面頂部。向它傳遞一個描述我們組件的選項對象。最重要的是,我們通過定義一個默認的 state 來開始。 我們組件的值,為 cart 添加一個空數組 (這就是我們從商店中“丟棄”的物品所在的地方)。

這將允許我們開始使用 state.cart 在我們的 render() 沒有任何項目的函數(如果我們不這樣做,我們會在渲染時得到一個錯誤 state.cart 未定義)。

在此下方,我們添加了一些 css 對於我們的商店物品和我們的購物車。這樣做的結果是我們的商店物品和我們的購物車的水平列表,一個固定在屏幕底部的“bin”,我們可以在其中拖動物品。

這裡的關鍵部分是 render() 功能。在這裡,我們看到了我們在構建 StoreItem 時學到的一些模式的重複 零件。同樣,在我們的 render() ,我們返回我們想要為我們的組件呈現的 HTML。著眼於細節,除了 when() 之外,我們還利用了一個額外的渲染功能 我們之前了解的函數:each() .顧名思義,對於每個 x 項目,我們要渲染一些 HTML。

<div class="store-items"></div>裡面 ,我們正在調用 each() 傳遞靜態 items 我們在文件頂部創建的列表作為第一個參數,第二個是 each() 的函數 調用我們數組中的每個項目。這個函數應該返回一個 HTML 字符串。在這裡,為了得到它,我們返回對另一個渲染函數 component() 的調用 這有助於我們在 HTML 中渲染另一個 Joystick 組件。

在這裡,我們期望 component() 拿我們的StoreItem 組件(在我們文件的頂部導入)並將其呈現為 HTML,將我們作為第二個參數傳遞的對像作為其 props 傳遞 價值。回想一下,之前,我們期望 props.itemStoreItem 中定義 ——這就是我們定義它的方式。

在此下方,我們使用 when() 渲染我們的購物車 UI 再次說“如果我們的購物車中沒有任何商品,則呈現一個佔位符消息以引導用戶。”

在此之後,我們使用 each() 再一次,這次循環我們的 state.cart value 並再次返回對 component() 的調用 並傳遞我們的 StoreItem 它的組成部分。同樣,我們通過 item 作為道具,除此之外,我們傳遞 onRemove() 我們在 StoreItem 中預期的函數 這將在我們的項目上呈現我們的“刪除”按鈕。

接下來,我們有兩個佔位符註釋要替換: onRemove() 時要做什麼 被調用,然後在我們的 render() 底部 ,提供我們購物車中所有商品的總和。

/ui/pages/store/index.js

import ui from '@joystick.js/ui';
import StoreItem from '../../components/storeItem';

const items = [
  { id: 'apple', image: '/apple.png', name: 'Apple', price: 0.75, description: 'The original forbidden fruit.' },
  { id: 'banana', image: '/banana.png', name: 'Banana', price: 1.15, description: 'It\'s long. It\'s yellow.' },
  { id: 'orange', image: '/orange.png', name: 'Orange', price: 0.95, description: 'The only fruit with a color named after it.' },
];

const Store = ui.component({
  state: {
    cart: [],
  },
  methods: {
    getCartTotal: (component = {}) => {
      const total = component?.state?.cart?.reduce((total = 0, item = {}) => {
        return total += item.price;
      }, 0);

      return total?.toFixed(2);
    },
    handleRemoveItem: (itemId = '', component = {}) => {
      component.setState({
        cart: component?.state?.cart?.filter((cartItem) => {
          return cartItem.id !== itemId;
        }),
      });
    },
  },
  css: `...`,
  render: ({ component, each, when, state, methods }) => {
    return `
      <div class="store">
        <div class="store-items">
          ${each(items, (item) => {
            return component(StoreItem, { item });
          })}
        </div>
        <div class="cart">
          <div class="cart-items">
            ${when(state.cart.length === 0, `
              <div class="placeholder">
                <p>You don't have any items in your cart. Drag and drop items from above to add them to your cart.</p>
              </div>
            `)}
            ${each(state.cart, (item) => {
              return component(StoreItem, {
                item,
                onRemove: (itemId) => {
                  methods.handleRemoveItem(itemId);
                },
              });
            })}
          </div>
          <footer>
            <h2>Total: $${methods.getCartTotal()}</h2>
          </footer>
        </div>
      </div>
    `;
  },
});

export default Store;

在這裡稍作改動,現在我們調用 methods.handleRemoveItem() 傳入 itemId 我們希望從 StoreItem 回來 當它調用 onRemove 一個項目的功能。在底部,我們還添加了對 methods.getCartTotal() 的調用 .

在操縱桿組件中,methods 是我們可以在我們的組件上調用的雜項函數。在 methods 中 我們添加的對象,我們正在定義這兩個函數。

對於 getCartTotal() 我們的目標是遍歷 state.cart 中的所有項目 並為他們提供總數。在這裡,為了做到這一點,我們使用 JavaScript reduce 函數來表示“從 0 開始 , 對於 state.cart 中的每個項目 ,返回total的當前值 當前item的值 的price 屬性。

對於 .reduce() 的每次迭代 返回值成為 total 的新值 然後將其傳遞給數組中的下一項。完成後,reduce() 將返回最終值。

handleRemoveItem() 下 ,我們的目標是過濾掉用戶想要從 state.cart 中刪除的任何項目 .為此,我們調用 component.setState() (搖桿自動通過component 實例作為我們傳遞給方法函數的任何參數之後的最後一個參數),覆蓋 cart 調用 component.state.filter() 的結果 .對於 .filter() 我們只想保留帶有 id 的項目 沒有 匹配傳遞的 itemId (即,將其從購物車中過濾掉)。

有了這個,我們就可以進行拖放了。讓我們看看它是如何連接起來的,然後試一試我們的 UI:

/ui/pages/store/index.js

import ui from '@joystick.js/ui';
import Sortable from 'sortablejs';
import StoreItem from '../../components/storeItem';

const items = [...];

const Store = ui.component({
  state: {
    cart: [],
  },
  lifecycle: {
    onMount: (component = {}) => {
      const storeItems = component.DOMNode.querySelector('.store-items');
      const storeCart = component.DOMNode.querySelector('.cart-items');

      component.itemsSortable = Sortable.create(storeItems, {
        group: {
          name: 'store',
          pull: 'clone',
          put: false,
        },
        sort: false,
      });

      component.cartSortable = Sortable.create(storeCart, {
        group: {
          name: 'store',
          pull: true,
          put: true,
        },
        sort: false,
        onAdd: (event) => {
          const target = event?.item?.querySelector('[data-id]');
          const item = items?.find(({ id }) => id === target?.getAttribute('data-id'));

          // NOTE: Remove the DOM node that SortableJS added for us before calling setState() to update
          // our list. This prevents the render from breaking.
          event?.item?.parentNode.removeChild(event.item);

          component.setState({
            cart: [...component.state.cart, {
              ...item,
              id: `${item.id}-${component.state?.cart?.length + 1}`,
            }],
          });
        },
      });
    },
  },
  methods: {...},
  css: `...`,
  render: ({ component, each, when, state, methods }) => {
    return `
      <div class="store">
        <div class="store-items">
          ${each(items, (item) => {
            return component(StoreItem, { item });
          })}
        </div>
        <div class="cart">
          <div class="cart-items">
            ${when(state.cart.length === 0, `
              <div class="placeholder">
                <p>You don't have any items in your cart. Drag and drop items from above to add them to your cart.</p>
              </div>
            `)}
            ${each(state.cart, (item) => {
              return component(StoreItem, {
                item,
                onRemove: (itemId) => {
                  methods.handleRemoveItem(itemId);
                },
              });
            })}
          </div>
          <footer>
            <h2>Total: $${methods.getCartTotal()}</h2>
          </footer>
        </div>
      </div>
    `;
  },
});

export default Store;

上面,我們在組件選項 lifecycle 中添加了一個附加屬性 ,在此之上,我們添加了一個函數 onMount .顧名思義,這個函數在我們的組件最初渲染或掛載時被 Joystick 調用 在瀏覽器中。

對於我們的拖放,我們想要使用它,因為我們需要確保我們想要變成拖放列表的元素實際上是在瀏覽器中呈現的——如果它們不是,我們的 Sortable 將沒有任何內容將其功能“附加”到。

onMount 內部 , 我們取 component 實例(由操縱桿自動傳遞給我們)併兩次調用 component.DOMNode.querySelector() , 一個用於我們的 store-items 列表和一個用於我們的 cart-items 列表。

這裡,component.DOMNode 由 Joystick 提供,包含在瀏覽器中呈現的表示該組件的實際 DOM 元素。這允許我們直接與原始 DOM(與 Joystick 實例或虛擬 DOM 不同)進行交互。

在這裡,我們調用 .querySelector() 在那個值上說“在這個組件內部,找到類名為 store-items 的元素 以及類名 cart-items 的元素 .一旦我們有了這些,接下來,我們通過調用 Sortable.create() 為每個列表創建我們的 Sortable 實例(這些將添加必要的拖放功能) 並將我們從 DOM 檢索到的元素作為 storeItems 傳遞 或 storeCart .

對於第一個 Sortable 實例——對於 storeItems ——我們的定義要簡單一些。在這裡,我們指定 group 允許我們使用通用名稱創建“鏈接”拖放目標的屬性(這裡我們使用 store )。它還允許我們為此列表配置拖放行為。

在這種情況下,當我們拖動它們(而不是完全移動它們)時,我們希望從我們的商店列表中“克隆”元素,而我們這樣做 希望允許項目為 put 回到列表中。此外,我們 希望我們的列表是可排序的(意味著可以通過拖放來更改順序)。

在此之下,對於我們的第二個可排序實例,我們遵循類似的模式,但是在 group 下 設置,對於 pull 我們通過 true 對於 put 我們通過 true (意味著可以通過拖放將項目拉出並放入此列表中)。與我們的商店商品列表類似,我們也禁用了 sort .

這裡重要的部分是 onAdd() 功能。每當將新項目添加或刪除到列表中時,Sortable 都會調用此方法。我們的目標是確認 drop 事件,然後將被丟棄的商品添加到我們的購物車中。

因為Sortable在拖拽的時候直接修改了DOM,所以我們需要做一點工作。我們的目標是只讓 Joystick 將購物車中的商品列表渲染到 DOM 中。為此,我們必須動態刪除 Sortable before 添加的 DOM 項 我們更新我們的狀態,這樣我們就不會破壞渲染。

為了到達那裡,我們接受了 DOM event 通過 sortable 傳遞給我們並在 DOM 中找到我們嘗試添加到購物車的列表項。為此,我們調用 .querySelector()event.item — 表示在 Sortable 中放置的項目的 DOM 元素 — 並在其中查找具有 data-id 的元素 屬性(商店商品)。

一旦我們有了這個,我們在我們的靜態 items 上執行一個 JavaScript Array.find() 我們之前定義的列表,看看我們是否可以找到任何具有 id 的對象 匹配 data-id 的值 在放置的元素上。

如果我們這樣做了,接下來,就像我們在上面暗示的那樣,我們使用 event?.item?.parentNode.removeChild(event.item) 刪除由 Sortable 在列表中創建的 DOM 元素 .完成後,我們調用以使用 component.setState() 更新我們的組件狀態 將購物車設置為一個數組,該數組傳播(複製)component.state.cart 的當前內容 並添加一個由找到的 item 組成的新對象 (我們使用 JavaScript 傳播 ... 運算符“將其內容解壓縮到一個新對像上)和一個 id 這是 id 被丟棄的項目的後跟 -${component.state?.cart?.length + 1} .

我們這樣做是因為 id 如果我們將多個相同的商品拖入購物車時,我們購物車中的商品需要具有一些唯一性(這裡我們只是在末尾添加一個數字後綴以使其足夠獨特)。

而已!現在,當我們將商品從商店列表拖到購物車時,我們將看到該商品自動添加。我們還將看到我們通過 methods.getCartTotal() 渲染的總數 用新值更新。

總結

在本教程中,我們學習瞭如何使用 SortableJS 連接拖放 UI。我們學習瞭如何創建一個包含兩個單獨列表的頁面,將它們作為一個組連接在一起,並學習如何管理它們之間的拖放交互。我們還學習瞭如何利用 state 在 Joystick 組件內部根據用戶交互動態呈現項目。


Tutorial JavaScript 教程
  1. Node.js 在 Google Sheet 中寫入數據

  2. 在 React 中構建符號匹配遊戲

  3. PDFJS 不能使用本地工作文件

  4. 懶惰的 Web 組件:The Book

  5. JavaScript 承諾和異步等待

  6. WebGL 和著色器簡介

  7. 使用 SVG 圖標路徑繪圖 [關閉]

  1. 從上下文 API 開始

  2. 文檔化教程

  3. 解鎖前端 - 調用標準化組件 API pt.1

  4. Netflix 克隆 Web 應用程序模板 (PWA)

  5. 使用 QUnit 進行測試:第 1 部分

  6. 我可以將此作為參數傳遞給javascript中的另一個函數嗎

  7. 如何使用渲染道具模式開發你的 React 超能力

  1. 跟踪您的生產力 - API 優先

  2. 入門 React Native 測試庫

  3. Scully 教程:Angular 網站的靜態站點生成器

  4. 所有關於 Web 開發