サイト改善

効率的なWeb開発へ!Vue.jsを使ったチャットと天気ウィジェットのコンポーネント化

こんにちは、寿研究所のすぎです!

このサイトでは、私がWeb技術に挑戦する様子を発信しています。

今回は、前回の続編として、Vue.jsでできた箇所を、
ほぼコーディングなしでChatGPTへお願いしてチャット機能と天気ウィジェットのコンポーネント化に成功しました。

このアプローチにより、再利用しやすいコードを実現し、Web開発をさらに効率化させました。それでは、早速このプロセスを一緒に学んでいきましょう!

YouTubeでも詳しく解説してますのでぜひご覧ください!


チャットのUIを部品化!前回の振り返りと今回の目標

前回は、チャット機能の見た目をコンポーネント化し、独立した部品として管理できるようにしました。しかし、今回はさらに踏み込んで、中身の機能も分割し、コードの管理や拡張をしやすくしていきます。具体的には、チャットの会話部分や天気ウィジェットをそれぞれ部品化することで、他のページでも簡単に再利用できるようにするのが今回のゴールです。


チャット機能のコンポーネント化プロセス

まず最初に手を加えたのは、チャット機能の中でも「Chat-container」部分です。従来は、チャットのフレームやメッセージが1つの大きなファイルにまとまっていましたが、これを「ChatContainer.js」として分割しました。これにより、チャット全体をより効率的に管理でき、機能の追加や変更が容易になりました。

ChatContainer.js というファイルにチャットのフレーム部分を独立させ、再利用可能な部品として実装しました。

下記が元々TOPページに書かれていたコードですが

import { chatMessages } from 'https://kotobukilab.com/media/wp-content/themes/jin-child/components/chatMessages.js';
    const app = Vue.createApp({});
    app.component('chat-component', {
        template: `
            <div class="chat-container">
            <div class="chat-messages" ref="chatMessages">
                <div v-for="(messageGroup, index) in chatMessages" :key="index">
                    <div class="date-label">{{ messageGroup.date }}</div>
                    <div class="message-wrapper" v-for="(message, idx) in messageGroup.messages" :key="idx">
                        <div :class="['message', message.position]">
                            <div class="chat_image_wrapper">
                                <img :src="message.userIcon" alt="ユーザーアイコン" class="user-icon">
                            </div>
                            <div class="message-bubble">
                                <div class="user-info">
                                    <span class="user-name">{{ message.userName }}</span>
                                    <span class="message-time">{{ message.time }}</span>
                                </div>
                                <div class="message-content">{{ message.content }}</div>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
        `,
        data() {
            return {
                chatMessages: chatMessages
            };
        },

 

以下のようにコードがスッキリしました。

import ChatContainer from 'https://kotobukilab.com/media/wp-content/themes/jin-child/components/ChatContainer.js';
	const app = Vue.createApp({});
	app.component('chat-component', {
		components: {
			ChatContainer
		},
		template: `
           <ChatContainer :chatMessages="chatMessages"></ChatContainer>
        `,
		data() {
			return {
				chatMessages: chatMessages
			};
		},
	});

新たにcomponentsフォルダに「ChatContainer.js」というファイルを作成します。この中に、上記で省略したコードを書きます。

以下、Javascriptでの記述です。

export default {
    template: `
        <div class="chat-container">
            <div class="chat-messages" ref="chatMessages">
                <div v-for="(messageGroup, index) in chatMessages" :key="index">
                    <div class="date-label">{{ messageGroup.date }}</div>
                    <div class="message-wrapper" v-for="(message, idx) in messageGroup.messages" :key="idx">
                        <div :class="['message', message.position]">
                            <div class="chat_image_wrapper">
                                <img :src="message.userIcon" alt="ユーザーアイコン" class="user-icon">
                            </div>
                            <div class="message-bubble">
                                <div class="user-info">
                                    <span class="user-name">{{ message.userName }}</span>
                                    <span class="message-time">{{ message.time }}</span>
                                </div>
                                <div class="message-content">{{ message.content }}</div>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    `,
    props: ['chatMessages'],
    mounted() {
        this.$nextTick(() => {
            this.scrollToBottom();
        });
    },
    updated() {
        this.$nextTick(() => {
            this.scrollToBottom();
        });
    },
    methods: {
        scrollToBottom() {
            const chatMessages = this.$refs.chatMessages;
            if (chatMessages) {
                chatMessages.scrollTop = chatMessages.scrollHeight;
            }
        }
    }
};

WordPressサイト上での動作確認を行い、問題なく表示されることを確認しました。

みやすくなっただけでなく、今後はこのチャットをサイト上のどこからでも出すことができるようになったため、汎用性が上がります。

 


天気ウィジェットも同様に分割!

次に取り組んだのが、チャット機能の中に表示される「天気ウィジェット」です。こちらもチャットコンテナと同様に「WeatherWidget.js」として独立させ、シンプルで効率的なコードに変換しました。

まず新規ファイルを作成し、既存の天気情報部分をWeatherWidget.jsに移行。必要なデータや機能をすべてまとめ、テンプレートも簡潔に書き換えました。

まずはコンポーネントの部品を作ります。

weather-widget.js

export default {
    template: `
    <div class="weather-widget" :class="{ 'rainning': isRaining, 'cloudy': isCloudy, 'sunny': isSunny }">
        <button class="button-change-location" @click="getLocationAndWeather">現在地の天気を表示</button>
        <div v-if="isRaining" class="rainning"> 
            <!-- 雨のエフェクト -->
        </div>
        <div v-if="isCloudy" class="cloudy"></div>
        <div v-if="isSunny" class="sunny"></div>
        <p>{{ currentDate }}({{ currentDay }})</p>
        <h2>{{ locationName }}の天気</h2>
        <p>{{ today }}</p>
        <img v-if="icon" :src="'http://openweathermap.org/img/wn/' + icon + '@2x.png'" alt="天気アイコン">
        <p v-if="translatedWeather">{{ translatedWeather }}</p>
        <p v-if="temperature" class="temperature">{{ roundedTemperature }}°C</p>
        <p v-if="humidity" class="humidity">湿度: {{ humidity }}%</p>
        <p v-if="error">{{ error }}</p>
        <small>提供: OpenWeatherMap</small>
    </div>
    `,
    data() {
        return {
            weather: null,
            temperature: null,
            humidity: null,
            icon: null,
            error: null,
            currentDate: '',
            currentDay: '',
            locationName: '世田谷区' // デフォルトの場所名
        };
    },
    computed: {
        translatedWeather() {
            return this.translateWeather(this.weather);
        },
        roundedTemperature() {
            return this.temperature ? Math.round(this.temperature) : null;
        },
        isRaining() {
            return this.weather && (
                this.weather.toLowerCase().includes('rain') ||
                this.weather.toLowerCase().includes('thunderstorm')
            );
        },
        isCloudy() {
            return this.weather && (
                this.weather.toLowerCase().includes('cloud') ||
                this.weather.toLowerCase().includes('overcast')
            );
        },
        isSunny() {
            return this.weather && (
                this.weather.toLowerCase().includes('clear') ||
                this.weather.toLowerCase().includes('sunny')
            );
        }
    },
    mounted() {
        this.fetchWeather(); // デフォルトの場所の天気を取得
        const today = new Date();
        this.currentDate = today.toLocaleDateString('ja-JP');
        this.currentDay = today.toLocaleString('ja-JP', {
            weekday: 'long'
        });
    },
    methods: {
        getLocationAndWeather() {
            if (navigator.geolocation) {
                navigator.geolocation.getCurrentPosition(this.showPosition, this.showError);
            } else {
                console.error("Geolocationはこのブラウザではサポートされていません。");
            }
        },
        showPosition(position) {
            const latitude = position.coords.latitude;
            const longitude = position.coords.longitude;
            this.fetchWeather(latitude, longitude); // 現在地の天気を取得
        },
        showError(error) {
            switch (error.code) {
                case error.PERMISSION_DENIED:
                    console.error("ユーザーが現在地の取得を拒否しました。");
                    break;
                case error.POSITION_UNAVAILABLE:
                    console.error("位置情報が利用できません。");
                    break;
                case error.TIMEOUT:
                    console.error("位置情報の取得にタイムアウトしました。");
                    break;
                case error.UNKNOWN_ERROR:
                    console.error("不明なエラーが発生しました。");
                    break;
            }
        },
        fetchWeather(lat, lon) {
            const apiKey = 'f647804598318695889c3b96d8e39712';
            let url;
            if (lat && lon) {
                url = `https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&appid=${apiKey}&units=metric`;
            } else {
                const city = 'Setagaya';
                url = `https://api.openweathermap.org/data/2.5/weather?q=${city},JP&appid=${apiKey}&units=metric`;
            }
            fetch(url)
                .then(response => {
                    if (!response.ok) {
                        throw new Error('Network response was not ok');
                    }
                    return response.json();
                })
                .then(data => {
                    this.weather = data.weather[0].description;
                    this.temperature = data.main.temp;
                    this.humidity = data.main.humidity;
                    this.icon = data.weather[0].icon;
                    if (lat && lon) {
                        this.locationName = data.name; // 場所名を更新
                    } else {
                        this.locationName = '世田谷区';
                    }
                })
                .catch(error => {
                    this.error = '天気情報を取得できませんでした。';
                    console.error('Error:', error);
                });
        },
        translateWeather(description) {
            const translations = {
                "clear sky": "快晴",
                "few clouds": "晴れ時々曇り",
                "scattered clouds": "ところどころ曇り",
                "broken clouds": "曇り",
                "overcast clouds": "曇り",
                "shower rain": "にわか雨",
                "light intensity shower rain": "小雨",
                "rain": "雨",
                "moderate rain": "ほどよい雨(適度な雨)",
                "heavy intensity rain": "豪雨",
                "very heavy rain": "非常に激しい雨",
                "extreme rain": "極端な雨",
                "freezing rain": "凍える雨(氷雨)",
                "light rain": "弱い雨",
                "thunderstorm": "雷雨",
                "light thunderstorm": "弱い雷雨",
                "heavy thunderstorm": "強い雷雨",
                "ragged thunderstorm": "乱れた雷雨",
                "snow": "雪",
                "light snow": "弱い雪",
                "heavy snow": "大雪",
                "sleet": "みぞれ",
                "light shower sleet": "小みぞれ",
                "shower sleet": "みぞれのにわか雨",
                "light rain and snow": "弱い雨と雪",
                "rain and snow": "雨と雪",
                "light shower snow": "弱いにわか雪",
                "shower snow": "にわか雪",
                "heavy shower snow": "強いにわか雪",
                "mist": "霧",
                "smoke": "煙",
                "haze": "もや",
                "sand/ dust whirls": "砂塵旋風",
                "fog": "濃霧",
                "sand": "砂",
                "dust": "ほこり",
                "volcanic ash": "火山灰",
                "squalls": "突風",
                "tornado": "竜巻"
            };
            return translations[description] || description;
        }
    }
};

 

これをimportするため、home.php(元のページ)にimportを書きます。

import WeatherWidget from 'https://kotobukilab.com/media/wp-content/themes/jin-child/components/weather-widget.js';

なんと、TOPページの記述は以下だけになりました。

app.component('weather-widget', WeatherWidget);

Chat部品と同じように、再利用可能なコンポーネントとして機能することが確認できました。


次回予告:さらに効率化を目指して!

今回の作業では、チャットコンテナと天気ウィジェットの2つを成功裏にコンポーネント化することができました。これにより、他のページやプロジェクトでも簡単にこの部品を再利用することが可能になりました。

この実装ができれば他の実装をした際、自由自在にコンポーネントを出しわけすることができるようになります。

次回は、さらに「運勢表示機能」のコンポーネント化に取り組み、ページ全体の効率化を図っていきます。また、コンポーネント化した部品を別ページに表示する実装にもチャレンジする予定です。

ここまでお読みいただきありがとうございました!