挑戰:構建房屋資料UI

在此挑戰中,我們將讓你為一個房地產網站上的房屋搜尋/篩選頁面編寫一些JavaScript。這將包括獲取JSON資料,根據提供的表單控制元件中輸入的值篩選資料,並將資料渲染到UI。在此過程中,我們還將測試你對條件語句、迴圈、陣列和陣列方法等知識的掌握程度。

起始點

首先,點選下方程式碼面板中的播放按鈕,在MDN Playground中開啟提供的示例。然後,你將按照專案簡介部分中的說明完成JavaScript功能。

html
<h1>House search</h1>
<p>
  Search for houses for sale. You can filter your search by street, number of
  bedrooms, and number of bathrooms, or just submit the search with no filters
  to display all available properties.
</p>
<form>
  <div>
    <label for="choose-street">Street:</label>
    <select id="choose-street" name="choose-street">
      <option value="">No street selected</option>
    </select>
  </div>
  <div>
    <label for="choose-bedrooms">Number of bedrooms:</label>
    <select id="choose-bedrooms" name="choose-bedrooms">
      <option value="">Any number of bedrooms</option>
    </select>
  </div>
  <div>
    <label for="choose-bathrooms">Number of bathrooms:</label>
    <select id="choose-bathrooms" name="choose-bathrooms">
      <option value="">Any number of bathrooms</option>
    </select>
  </div>
  <div>
    <button>Search for houses</button>
  </div>
</form>
<p id="result-count">Results returned: 0</p>
<section id="output"></section>
js
const streetSelect = document.getElementById("choose-street");
const bedroomSelect = document.getElementById("choose-bedrooms");
const bathroomSelect = document.getElementById("choose-bathrooms");
const form = document.querySelector("form");

const resultCount = document.getElementById("result-count");
const output = document.getElementById("output");

let houses;

function initializeForm() {

}

function renderHouses(e) {
  // Stop the form submitting
  e.preventDefault();

  // Add rest of code here
}

// Add a submit listener to the <form> element
form.addEventListener("submit", renderHouses);

// Call fetchHouseData() to initialize the app
fetchHouseData();

專案簡介

我們為你提供了一個HTML索引頁面,其中包含一個表單,允許使用者按街道、臥室數量和浴室數量搜尋房屋,以及幾個用於顯示搜尋結果的元素。我們還為你提供了一個JavaScript檔案,其中包含一些常量和變數定義,以及幾個骨架函式定義。你的任務是填寫缺失的JavaScript,使房屋搜尋介面正常工作。

提供的常量和變數定義包含以下引用:

  • streetSelect:“choose-street” <select> 元素。
  • bedroomSelect:“choose-bedrooms” <select> 元素。
  • bathroomSelect:“choose-bathrooms” <select> 元素。
  • form:包含 <select> 元素的整個 <form> 元素。
  • resultCount:“result-count” <p> 元素,每次搜尋後都會更新以顯示返回的結果數量。
  • output:“output” <section> 元素,用於顯示搜尋結果。
  • houses:最初為空,但它將包含透過解析獲取的JSON資料建立的房屋資料物件。

骨架函式是:

  • initializeForm():這將查詢資料並用可能用於搜尋的值填充 <select> 元素。
  • renderHouses():這將根據 <select> 元素值篩選資料,並渲染結果。

獲取資料

你需要做的第一件事是建立一個新函式來獲取房屋資料並將其儲存在 houses 變數中。

為此:

  1. 在變數和常量定義下方建立一個名為 fetchHouseData() 的新函式。
  2. 在函式體內部,使用 fetch() 方法獲取位於 https://mdn.github.io/shared-assets/misc/houses.json 的JSON。你應該研究此資料的結構,以準備後續步驟。
  3. 當生成的Promise解決時,檢查響應的 ok 屬性。如果為 false,則丟擲一個自定義錯誤,報告響應的 status
  4. 如果響應正常,使用 json() 方法將響應作為JSON返回。
  5. 當生成的Promise解決時,將 houses 變數設定為 json() 方法的結果(這應該是一個包含房屋資料物件的陣列),並呼叫 initializeForm() 函式。

完成 initializeForm() 函式

現在你需要編寫 initializeForm() 函式的內容。這將查詢儲存在 houses 中的資料,並使用它填充 <select> 元素,其中包含代表所有可篩選的不同值的 <option> 元素。目前,<select> 元素只包含一個 <option> 元素,其值為 ""(空字串),表示所有值。如果使用者不想按該欄位篩選結果,他們可以選擇此選項。

在函式體內部,編寫執行以下操作的程式碼:

  1. 為“choose-street” <select> 中所有不同的街道名稱建立 <option> 元素。有幾種方法可以做到這一點,但我們建議建立一個臨時陣列,然後遍歷 houses 中的所有物件。在迴圈內部,檢查你的臨時陣列是否包含當前房屋的 street 屬性。如果不包含,則將其新增到臨時陣列,並向“choose-street” <select> 新增一個 <option>,其中包含 street 屬性作為其值。
  2. 為“choose-bedrooms” <select> 中所有可能的臥室數量值建立選項。為此,你可以遍歷 houses 陣列並確定最大的 bedrooms 值,然後編寫第二個迴圈,為從 1 到最大值的每個數字向“choose-bedrooms” <select> 新增一個 <option>
  3. 為“choose-bathrooms” <select> 中所有可能的浴室數量值建立選項。這可以透過與上一步相同的技術來解決。

注意:可以直接在HTML中硬編碼 <option> 元素,但這僅適用於此精確資料集。我們希望你編寫的JavaScript能夠正確填充表單,而無論提供的資料值如何(每個房屋物件都必須具有相同的結構)。

注意: 你可以使用 innerHTML 屬性在HTML元素中新增子內容,但我們建議不要這樣做。你不能總是信任新增到頁面中的資料:如果它在伺服器上沒有正確清理,惡意攻擊者可能會利用 innerHTML 作為途徑,在你的頁面上執行跨站指令碼(XSS)攻擊。更安全的方法是使用DOM指令碼功能,例如 createElement()appendChild()textContent。使用 innerHTML 刪除子內容則不是問題。

完成 renderHouses() 函式

接下來,你需要完成 renderHouses() 函式體。這將根據 <select> 元素值篩選資料,並將結果渲染到UI。

  1. 首先,你需要篩選資料。這可能最好透過使用陣列 filter() 方法來實現,該方法返回一個新陣列,其中只包含符合篩選條件的陣列元素。
    1. 這是一個相當複雜的 filter() 函式。你需要測試房屋的 street 屬性是否等於“choose-street” <select> 的選定值,以及房屋的 bedrooms 屬性是否等於“choose-bedrooms” <select> 的選定值,以及房屋的 bathrooms 屬性是否等於“choose-bathrooms” <select> 的選定值。
    2. 如果關聯的 <select> 值為 ""(空字串,表示所有值),則測試的每個元件都始終需要返回 true。你可以透過“短路”每個檢查來實現這一點。
    3. 你還需要確保每次檢查中的資料型別匹配。表單元素的值始終是字串。這對於你的物件屬性值不一定如此。你如何使資料型別在測試目的上匹配?
  2. 使用字串結構“Results returned: number”將篩選後的搜尋結果數量輸出到“result-count” <p> 元素中。
  3. 清空“output” <section> 元素,使其不包含任何子HTML元素。如果你不這樣做,每次執行搜尋時,結果都會新增到之前結果的末尾,而不是替換它們。
  4. renderHouses() 內部建立一個名為 renderHouse() 的新函式。此函式需要將房屋物件作為引數,並執行兩件事:
    1. 計算房屋 room_sizes 物件中包含的房間總面積。這不像遍歷數字陣列並求和那樣簡單,但也不是太棘手。
    2. 在“output” <section> 元素內部新增一個 <article> 元素,其中包含房屋的編號、街道名稱、臥室和浴室數量、房間總面積和價格。如果你喜歡,可以改變結構,但我們希望它類似於以下HTML程式碼片段:
    html
    <article>
      <h2>number street name</h2>
      <ul>
        <li>🛏️ Bedrooms: number</li>
        <li>🛀 Bathrooms: number</li>
        <li>Room area: number m²</li>
        <li>Price: £price</li>
      </ul>
    </article>
    
  5. 遍歷篩選陣列中的所有房屋,並將每個房屋傳遞給 renderHouse() 呼叫。

提示和技巧

  • 你無需以任何方式更改HTML或CSS。
  • 對於查詢值陣列中的最大值等操作,reduce() 陣列函式非常方便。我們沒有在本課程中教授它,因為它相當複雜,但當你掌握它時,它非常強大。作為一個延伸目標,嘗試研究它並在你的答案中使用它。

示例

你完成的應用程式應該像以下即時示例一樣工作:

點選此處顯示解決方案

完成的JavaScript應該如下所示:

js
const streetSelect = document.getElementById("choose-street");
const bedroomSelect = document.getElementById("choose-bedrooms");
const bathroomSelect = document.getElementById("choose-bathrooms");
const form = document.querySelector("form");
const resultCount = document.getElementById("result-count");
const output = document.getElementById("output");

let houses;

// Solution: Fetching the data

function fetchHouseData() {
  fetch("https://mdn.github.io/shared-assets/misc/houses.json")
    .then((response) => {
      if (!response.ok) {
        throw new Error(`HTTP error: ${response.status}`);
      }

      return response.json();
    })
    .then((json) => {
      houses = json;
      initializeForm();
    });
}

// Solution: Completing the initializeForm() function

function initializeForm() {
  // Create options for all the different street names
  const streetArray = [];
  for (let house of houses) {
    if (!streetArray.includes(house.street)) {
      streetArray.push(house.street);
      streetSelect.appendChild(document.createElement("option")).textContent =
        house.street;
    }
  }

  // Create options for all the possible bedroom values
  const largestBedrooms = houses.reduce(
    (largest, house) => (house.bedrooms > largest ? house.bedrooms : largest),
    houses[0].bedrooms,
  );
  let i = 1;
  while (i <= largestBedrooms) {
    bedroomSelect.appendChild(document.createElement("option")).textContent = i;
    i++;
  }

  // Create options for all the possible bathroom values
  const largestBathrooms = houses.reduce(
    (largest, house) => (house.bathrooms > largest ? house.bathrooms : largest),
    houses[0].bathrooms,
  );
  let j = 1;
  while (j <= largestBathrooms) {
    bathroomSelect.appendChild(document.createElement("option")).textContent =
      j;
    j++;
  }
}

// Solution: Completing the renderHouses() function

function renderHouses(e) {
  // Stop the form submitting
  e.preventDefault();

  // Filter the data
  const filteredHouses = houses.filter((house) => {
    // prettier-ignore
    const test = (streetSelect.value === "" ||
                  house.street === streetSelect.value) &&
                 (bedroomSelect.value === "" ||
                  String(house.bedrooms) === bedroomSelect.value) &&
                 (bathroomSelect.value === "" ||
                  String(house.bathrooms) === bathroomSelect.value);
    return test;
  });

  // Output the result count to the "result-count" paragraph
  resultCount.textContent = `Results returned: ${filteredHouses.length}`;

  // Empty the output element
  output.innerHTML = "";

  // Create renderHouse() function
  function renderHouse(house) {
    // Calculate total room size
    let totalArea = 0;
    const keys = Object.keys(house.room_sizes);
    for (let key of keys) {
      totalArea += house.room_sizes[key];
    }

    // Output house to UI
    const articleElem = document.createElement("article");
    articleElem.appendChild(document.createElement("h2")).textContent =
      `${house.house_number} ${house.street}`;
    const listElem = document.createElement("ul");
    listElem.appendChild(document.createElement("li")).textContent =
      `🛏️ Bedrooms: ${house.bedrooms}`;
    listElem.appendChild(document.createElement("li")).textContent =
      `🛀 Bathrooms: ${house.bathrooms}`;
    listElem.appendChild(document.createElement("li")).textContent =
      `Room area: ${totalArea}m²`;
    listElem.appendChild(document.createElement("li")).textContent =
      `Price: £${house.price}`;
    articleElem.appendChild(listElem);
    output.appendChild(articleElem);
  }

  // Pass each house in the filtered array into renderHouse()
  for (let house of filteredHouses) {
    renderHouse(house);
  }
}

// Add a submit listener to the <form> element
form.addEventListener("submit", renderHouses);

// Call fetchHouseData() to initialize the app
fetchHouseData();