【もりけん塾】JS課題32 – Vanilla JSで記事一覧ページと絞り込み機能を作成 –

JavaScript

取得したJSONデータからニュース記事一覧を作成して、カテゴリの絞り込み機能を作成しました。

こんにちは。Webコーダーのはるです。

現在、所属している「もりけん塾」でJavaScriptのハンズオン課題 に取り組み、レビューをいただいています。

今日は、課題32 についてアウトプットをしていきます。
(前回までのアウトプットは、こちらです。)

実装する過程で学んだことを、学習ノートとして記録していきます。

認識の間違えている箇所がありましたら、お問い合わせからご指摘いただけるとうれしいです。

JavaScript課題32の仕様

    (もりけんさんのJavaScriptハンズオン課題より)

    JavaScript課題32の学習記録

    主にレビューいただいた箇所の復習として記録を残します。

    制作物

    JSONデータのfetch()

    記事のJSONデータをAPIで取得してデータをfetchしました。

    fetchの課題は久しぶりだったので、日頃のレビューで得たことを復習しながら取り組みました。

    APIのendpointは、通常のデータのほかに、データが空だったとき、503エラーの時、fetchに失敗した時などさまざまなものを用意してエラー時の挙動を検証しました。

    const url = "https://mocki.io/v1/6cb2582d-1e91-4777-a963-729425ae5962";
    // const url = "https://mocki.io/v1/8cc57c74-d671-48ac-b59f-d3dfb73ec8c1"; //No data
    // const url = "https://httpstat.us/503"; // 503 error
    // const url = "https://mocki.io/v1/fafafafa"; // Failed to fetch

    まず、APIからJSONデータをfetchします

    const fetchData = async(api) => {
        addLoading(newsContent);
        try {
            const response = await fetch(api);
    
            if (response.ok) {
                return await response.json(); 
            } else {
                // catchに移らないHTTPステータスエラーの時
                // 本当は、別ページに遷移するなどステータスコードによって対応を変える認識
                console.error(`${response.status}:${response.statusText}`); 
                displayErrorStatus(newsContent, response);
            }
    
        } catch (error) {
            displayInfo(newsContent, error); // それ以外のエラー対応
        } finally {
            removeLoading();
        }
    };
     

    fetch() は、HTTPエラーの時に拒否されない -> catchに移らないということを知った。

    fetch() のプロミスはネットワークエラーが発生した場合(普通は権限の問題があったときなど)のみ拒否されます。 fetch() のプロミスは HTTP エラー(404 など)では拒否されません。代わりに、 then() ハンドラーで Response.ok や Response.status プロパティをチェックする必要があります。MDN

    そのため、tryの中でresponse.okではない時 = HTTPエラー のハンドリングを追加した。

    if (response.ok) {
       return await response.json(); 
    } else {
       // catchに移らないHTTPステータスエラーの時
       // 本当は、別ページに遷移するなどステータスコードによって対応を変える認識
       console.error(`${response.status}:${response.statusText}`); 
       displayErrorStatus(newsContent, response);
    }

    課題上、エラーを表示する対応にとどめています。

    次に、リストデータを取得し、データが存在すれば引数として渡す。
    データがなければinfoを出す。

    const fetchListData = async() => {
        const data = await fetchData(url);
      
       // ステータスコードのエラーの時, console.errorのみでクラッシュしないためreturn
        if (!data) return; 
    
       // データが空の時
        if (!data.length) {
            displayInfo(newsContent, "no data"); 
        } else {
            // データが存在すれば、データを渡す
            renderCategories(data);
            renderArticleList(data);
            addEventForCategoryList(data);
        }
    };
     
    レビューで console.error() と throw new Error() について知りました。

    console.errorはおそらくユーザーには見えないまま通過し、コンソールを開いている人だけがメッセージを見ることができ、throwはすべてが停止する

    決定的な違いは、throwすると実行が停止するが、console.errorは停止しない。 たいていの場合、エラーを投げる方がよいでしょう。 これは、何かが失敗し、エラーが予想され、捕捉され、適切に処理されない限り、通常の実行が継続できないことを知らせるための組み込みの方法です。

    https://stackoverflow.com/questions/60383852/should-i-use-console-error-or-throw-new-error

    記事一覧の作成

    JSONデータを使用して記事一覧を作成しました。

    引数のデータには、先ほどfetchした記事のデータが渡ります。

    // 記事カードの作成
    const createArticleCards = data => {
     const fragment = document.createDocumentFragment();
     data.articles.forEach(article => {
      const newsItem = createElementWithClassName("li", "news__item");
      const thumbnailWrapper = createElementWithClassName("div", "news__item-thumbnail-wrap");
      const thumbnail = createElementWithClassName("img", "news__item-thumbnail");
      const infoArea = createElementWithClassName("div", "news__item-info");
      const categoryLabel = createElementWithClassName("p", "news__item-category");
      const date = createElementWithClassName("p", "news__item-date");
      const title = createElementWithClassName("h3", "news__item-title");
      const titleLink = createElementWithClassName("a", "news__item-link");
    
      thumbnail.src = article.img;
      thumbnail.alt = "";
      categoryLabel.textContent = data.category;
      date.textContent = article.date;
      titleLink.textContent = article.title;
      titleLink.href = "#";
      titleLink.classList.add("link");
    
      thumbnailWrapper.appendChild(thumbnail);
      infoArea.appendChild(categoryLabel).after(date);
      title.appendChild(titleLink);
      fragment.appendChild(newsItem).appendChild(title).after(infoArea, thumbnailWrapper);
     })
     return fragment;
    };
    レビュー
    最初につけた関数名「createNewsCards()」だと、データのカテゴリに「News」があるため混同してわかりにくいとご指摘いただきました。「createArticleCards()」に変更しました。

    下記では、データのカテゴリの数だけulを作成しました。

    これで、カテゴリに関係なく全ての記事が一覧として表示される状態です。

    // ニュース記事リストをrenderする
    const renderNewsList = data => {
     const newsList = createElementWithClassName("ul", "news__list");
     const fragment = document.createDocumentFragment();
     data.forEach(item => {
      fragment.appendChild(createNewsCards(item));
     })
     newsContent.appendChild(newsList).appendChild(fragment);
    };

    サムネイル画像がない時の対応

    取得した記事データにサムネイルの画像パスがない場合は「No Img」のサムネイルを出力するようにしました。仕様にはありませんでしたが、必要だと思い対応しました。

    下記は、サムネイル画像を作成する関数です。引数のarticleには、1つ1つの記事データが渡ってきます。

    const createThumbnail = article => {
     const thumbnailWrapper = createElementWithClassName("div", "news__item-thumbnail-wrap");
     const thumbnail = createElementWithClassName("img", "news__item-thumbnail");
     const noImgSrc = "./img/no-img.jpg";
    
     thumbnail.alt = "";
     thumbnail.src = article.img || noImgSrc;
    
     thumbnailWrapper.appendChild(thumbnail);
     return thumbnailWrapper;
    };

    データのimgキーの値があればパスをセットし、なければnoImgSrcを出力します。

    thumbnail.src = article.img || noImgSrc;
    

    レビューのご指摘

    ① リファクタリング

    最初以下のように書いていました。

    thumbnail.src = article.img ? article.img : noImgSrc;

    これをOR演算子で以下のように書き換えることができると教わりました。

    thumbnail.src = article.img || noImgSrc;
    

    論理和

    expr1 が true に変換できる場合は expr1 を返し、それ以外の場合は expr2 を返します。(MDN)

    ② noImgSrcを表示させる条件

    最初、サムネイルの画像パスがない場合に加えて、サムネイルの画像パスが誤っている時も条件に入れていました。

    後者をコードにすると以下です。

    thumbnail.addEventListener("error", () => thumbnail.src = noImgSrc);

    この制御を行わない場合は、サムネイルは空白になり、console.logに404エラーが表示されます。

    レビューをいただき、開発者が気付くためにコンソールに404エラーが出ているのであり、そちらもNo Imgにするのは違うと理解できました。

    ありがとうございます!

    カテゴリー検索プルダウンの作成

    カテゴリー検索のプルダウンは、「すべて」のみデフォルトで用意して、それ以外はデータから取得して動的に追加しました。

    HTML

    <div class="news__pulldown-wrapper">
     <select name="select-category" class="news__pulldown" id="js-select-category">
      <option value="all">すべて</option>
     </select>
    </div>

    <option>の動的な作成

    const createOptionElements = data => {
     const fragment = document.createDocumentFragment();
     data.forEach(({category}) => {
      const optionElement = document.createElement("option");
      optionElement.value = category;
      optionElement.textContent = category;
      fragment.appendChild(optionElement);
     })
     return fragment;
    };

    それをrenderする

    const renderCategories = data => {
     const selectElement = document.getElementById("js-select-category");
     selectElement.appendChild(createOptionElements(data));
    };
    レビュー
    renderの関数名をrenderOptionElements() としていましたが、この関数はほかに使用する予定はなく、具体的な命名をした方が分かりやすいとアドバイスいただきrenderCategories()に変更しました。

     

    記事の絞り込み機能を作成

    カテゴリを選択すると、changeイベントを発火。
    プルダウンを選択する時のイベントはclickではなくchangeと知りました。

    const addEventForCategoryList = data => {
     selectElement.addEventListener("change", (e) => {
      const selectedCategory = e.target.value;
    
      if (selectedCategory === "all"){
       renderArticleList(data);
      } else {
       const filteredData = data.filter(item => item.category === selectedCategory);
       renderArticleList(filteredData);
      }
     })
    };

    イベントは親の<select>に設定して、e.target.valueで選択しているカテゴリを取得しました。

    if文でカテゴリが”すべて”の場合とそれ以外で分けて、記事のデータをフィルタリングしました。

     if (selectedCategory === "all"){
       renderArticleList(data);
     } else {
       const filteredData = data.filter(item => item.category === selectedCategory);
       renderArticleList(filteredData);
     }
    

    data または filteredData を renderArticleList()関数に引数で渡します。

    const renderArticleList = data => {
     const newsList = createElementWithClassName("ul", "news__list");
    
     const fragment = document.createDocumentFragment();
     data.forEach(item => {
      fragment.appendChild(createArticleCards(item));
     })
     newsContent.replaceChildren(newsList);
     newsList.appendChild(fragment);
    };

    動的にrenderするリストを変更するので、カテゴリが選択されるごとに新しいリストを作成します。

    const newsList = createElementWithClassName("ul", "news__list");
    

    渡ってきたカテゴリの記事数だけカードを作成します。

    data.forEach(item => {
      fragment.appendChild(createArticleCards(item));
    })
    

    新しいnewsList(ul)にreplaceします。

    そこに新しく作られた選択カテゴリのリストをappendします。

    newsContent.replaceChildren(newsList);
    newsList.appendChild(fragment);
    

     

    レビュー

    元々書いていたコードはこちらです。

    const renderArticleList = (data, category = "all") => {
     const newsList = createElementWithClassName("ul", "news__list");
    
     if(category !== "all") {
      data = filterArticlesData(data, category);
     }
    
     const fragment = document.createDocumentFragment();
     data.forEach(item => {
      fragment.appendChild(createArticleCards(item));
     })
     newsContent.replaceChildren(newsList);
     newsList.appendChild(fragment);
    };
    
    const filterArticlesData = (data, category) => {
     const filteredData = data.filter(item => item.category === category);
     return filteredData;
    };

    fileterArticlesData()関数で選択したカテゴリのデータをフィルタリングして、そのデータをrenderArticleList()に渡します。

    カテゴリにより場合分けもrenderArticleList()内で行っており、単にrenderするだけの関数ではありませんでした。

    addEventForCategoryList()で選択カテゴリによって記事をソートし、renderArticleList()に渡す。

    renderArticleList()ではそれをrenderするように修正しました。

    カテゴリを実際に知っているのは、addEventForCategoryList()のみというシンプルなロジックになりました(複雑に書いていた)

    コード

    archive-news.jsが主なコードです。

    sign up -> login -> ニュース一覧から実際の挙動も確認できます。

    //

    学習に使用している本は、JavaScript本格入門・独習JavaScriptです。

      

    あとがき

    もりけんさん(@terrace_tec)、さえさん(@sae_prog)、まいさん(@mai2022web)、takakoさん(@wattwattwatt) お忙しい中レビューいただきありがとうございました!

    全8回の細かい粒度に分けてのPRに挑戦しました。

    これは、最初にロジックを考えて、それを細かく段階に分けて実装するので、ロジックを自分で組めるようになってきたということを意味すると思いました。

    少し前の自分だったらできなかったことだと思うので、少し成長を感じました。

    教えていただいたことを生かして、次の課題に進みたいと思います!

    今日は以上です。

    //

    【もりけん塾で勉強しています】

    もりけんさん(@terrace_tec)のHPはこちら