皆さんこんにちは。とらのあなラボのY.Fです。
本記事は2023 夏のブログ連載企画の15日目の記事になります。
昨日14日目は古賀さんによる「簡単に作れる!VS CodeでAIとチャットする拡張機能を作ってみた」が投稿されています。 明日16日目はおっくんさんによる「レンガ 🧱の積み方をBabylon.js と 物理エンジンで シミュレーション!」が投稿される予定です。
今回の記事では、ChatGPTを利用してVue→Reactへの移行を試みたことがあり、そのまとめをしてみたいと思います。
概要
移行元は以下のような構成になります。
- Vue3
- Composition API利用
- Scoped CSS(scss)利用
以下に移行するのを目標にします。
- React.js
- Hooks利用
- cssはCSS Modules or css-in-js
なお、共通でTypeScript、Viteを利用します。
対象するアプリについて
実際には本運用している社内アプリケーションで行いましたが、今回の記事では、サンプルとして用意されている単純なTODOアプリを利用したいと思います。
<template> <section class="todoapp"> <header class="header"> <h1>todos</h1> <input class="new-todo" autofocus placeholder="What needs to be done?" @keyup.enter="addTodo" > </header> <section class="main" v-show="todos.length"> <input id="toggle-all" class="toggle-all" type="checkbox" :checked="remaining === 0" @change="toggleAll" > <label for="toggle-all">Mark all as complete</label> <ul class="todo-list"> <li v-for="todo in filteredTodos" class="todo" :key="todo.id" :class="{ completed: todo.completed, editing: todo === editedTodo }" > <div class="view"> <input class="toggle" type="checkbox" v-model="todo.completed"> <label @dblclick="editTodo(todo)">{{ todo.title }}</label> <button class="destroy" @click="removeTodo(todo)"></button> </div> <input v-if="todo === editedTodo" class="edit" type="text" v-model="todo.title" @blur="doneEdit(todo)" @keyup.enter="doneEdit(todo)" @keyup.escape="cancelEdit(todo)" > </li> </ul> </section> <footer class="footer" v-show="todos.length"> <span class="todo-count"> <strong>{{ remaining }}</strong> <span>{{ remaining === 1 ? ' item' : ' items' }} left</span> </span> <ul class="filters"> <li> <a href="#/all" :class="{ selected: visibility === 'all' }">All</a> </li> <li> <a href="#/active" :class="{ selected: visibility === 'active' }">Active</a> </li> <li> <a href="#/completed" :class="{ selected: visibility === 'completed' }">Completed</a> </li> </ul> <button class="clear-completed" @click="removeCompleted" v-show="todos.length > remaining"> Clear completed </button> </footer> </section> </template> <script lang="ts" setup> import { ref, computed, watchEffect } from 'vue' const STORAGE_KEY = 'vue-todomvc' type Visibility = "all" | "active" | "completed"; interface Todo { id: number; title: string, completed: boolean } const filters: {[idx: string]: (todos: Todo[]) => Todo[]} = { all: (todos: Todo[]) => todos, active: (todos: Todo[]) => todos.filter((todo) => !todo.completed), completed: (todos: Todo[]) => todos.filter((todo) => todo.completed) } // state const todos = ref<Todo[]>(JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]')) const visibility = ref<Visibility>('all') const editedTodo = ref<Todo | null>() // derived state const filteredTodos = computed(() => filters[visibility.value](todos.value)) const remaining = computed(() => filters.active(todos.value).length) // handle routing window.addEventListener('hashchange', onHashChange) onHashChange() // persist state watchEffect(() => { localStorage.setItem(STORAGE_KEY, JSON.stringify(todos.value)) }) function toggleAll(e: Event) { todos.value.forEach((todo: Todo) => (todo.completed = (e.target as HTMLInputElement).checked)) } function addTodo(e: Event) { const value = (e.target as HTMLInputElement).value.trim() if (value) { todos.value.push({ id: Date.now(), title: value, completed: false }); (e.target as HTMLInputElement).value = '' } } function removeTodo(todo: Todo) { todos.value.splice(todos.value.indexOf(todo), 1) } let beforeEditCache = '' function editTodo(todo: Todo) { beforeEditCache = todo.title editedTodo.value = todo } function cancelEdit(todo: Todo) { editedTodo.value = null todo.title = beforeEditCache } function doneEdit(todo: Todo) { if (editedTodo.value) { editedTodo.value = null todo.title = todo.title.trim() if (!todo.title) removeTodo(todo) } } function removeCompleted() { todos.value = filters.active(todos.value) } function onHashChange() { const route = window.location.hash.replace(/#\/?/, '') if (filters[route]) { visibility.value = route as Visibility } else { window.location.hash = '' visibility.value = 'all' } } </script> <style scoped lang="scss"> html, body { margin: 0; padding: 0; } button { margin: 0; padding: 0; border: 0; background: none; font-size: 100%; vertical-align: baseline; font-family: inherit; font-weight: inherit; color: inherit; -webkit-appearance: none; appearance: none; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } body { font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; line-height: 1.4em; background: #f5f5f5; color: #111111; min-width: 230px; max-width: 550px; margin: 0 auto; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; font-weight: 300; } .hidden { display: none; } .todoapp { background: #fff; margin: 130px 0 40px 0; position: relative; box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1); input { &::-webkit-input-placeholder, &::-moz-placeholder, &::input-placeholder { font-style: italic; font-weight: 400; color: rgba(0, 0, 0, 0.4); } } h1 { position: absolute; top: -140px; width: 100%; font-size: 80px; font-weight: 200; text-align: center; color: #b83f45; -webkit-text-rendering: optimizeLegibility; -moz-text-rendering: optimizeLegibility; text-rendering: optimizeLegibility; } } .new-todo, .edit { position: relative; margin: 0; width: 100%; font-size: 24px; font-family: inherit; font-weight: inherit; line-height: 1.4em; color: inherit; padding: 6px; border: 1px solid #999; box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); box-sizing: border-box; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } .new-todo { padding: 16px 16px 16px 60px; height: 65px; border: none; background: rgba(0, 0, 0, 0.003); box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03); } .main { position: relative; z-index: 2; border-top: 1px solid #e6e6e6; } .toggle-all { width: 1px; height: 1px; border: none; /* Mobile Safari */ opacity: 0; position: absolute; right: 100%; bottom: 100%; + label { display: flex; align-items: center; justify-content: center; width: 45px; height: 65px; font-size: 0; position: absolute; top: -65px; left: -0; &:before { content: '❯'; display: inline-block; font-size: 22px; color: #949494; padding: 10px 27px 10px 27px; -webkit-transform: rotate(90deg); transform: rotate(90deg); } } &:checked + label:before { color: #484848; } } .todo-list { margin: 0; padding: 0; list-style: none; li { position: relative; font-size: 24px; border-bottom: 1px solid #ededed; &:last-child { border-bottom: none; } &.editing { border-bottom: none; padding: 0; .edit { display: block; width: calc(100% - 43px); padding: 12px 16px; margin: 0 0 0 43px; } .view { display: none; } } .toggle { text-align: center; width: 40px; /* auto, since non-WebKit browsers doesn't support input styling */ height: auto; position: absolute; top: 0; bottom: 0; margin: auto 0; border: none; /* Mobile Safari */ -webkit-appearance: none; appearance: none; opacity: 0; + label { /* Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433 IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/ */ background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23949494%22%20stroke-width%3D%223%22/%3E%3C/svg%3E'); background-repeat: no-repeat; background-position: center left; } &:checked + label { background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%2359A193%22%20stroke-width%3D%223%22%2F%3E%3Cpath%20fill%3D%22%233EA390%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22%2F%3E%3C%2Fsvg%3E'); } } label { word-break: break-all; padding: 15px 15px 15px 60px; display: block; line-height: 1.2; transition: color 0.4s; font-weight: 400; color: #484848; } &.completed label { color: #949494; text-decoration: line-through; } .destroy { display: none; position: absolute; top: 0; right: 10px; bottom: 0; width: 40px; height: 40px; margin: auto 0; font-size: 30px; color: #949494; transition: color 0.2s ease-out; &:hover, &:focus { color: #C18585; } &:after { content: '×'; display: block; height: 100%; line-height: 1.1; } } &:hover .destroy { display: block; } .edit { display: none; } &.editing:last-child { margin-bottom: -1px; } } } .footer { padding: 10px 15px; height: 20px; text-align: center; font-size: 15px; border-top: 1px solid #e6e6e6; &:before { content: ''; position: absolute; right: 0; bottom: 0; left: 0; height: 50px; overflow: hidden; box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), 0 8px 0 -3px #f6f6f6, 0 9px 1px -3px rgba(0, 0, 0, 0.2), 0 16px 0 -6px #f6f6f6, 0 17px 2px -6px rgba(0, 0, 0, 0.2); } } .todo-count { float: left; text-align: left; strong { font-weight: 300; } } .filters { margin: 0; padding: 0; list-style: none; position: absolute; right: 0; left: 0; li { display: inline; a { color: inherit; margin: 3px; padding: 3px 7px; text-decoration: none; border: 1px solid transparent; border-radius: 3px; &:hover { border-color: #DB7676; } &.selected { border-color: #CE4646; } } } } .clear-completed, html .clear-completed:active { float: right; position: relative; line-height: 19px; text-decoration: none; cursor: pointer; } .clear-completed:hover { text-decoration: underline; } .info { margin: 65px auto 0; color: #4d4d4d; font-size: 11px; text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); text-align: center; p { line-height: 1; } a { color: inherit; text-decoration: none; font-weight: 400; &:hover { text-decoration: underline; } } } /* Hack to remove background from Mobile Safari. Can't use it globally since it destroys checkboxes in Firefox */ @media screen and (-webkit-min-device-pixel-ratio: 0) { .toggle-all { background: none; } .todo-list li .toggle { background: none; height: 40px; } } @media (max-width: 430px) { .footer { height: 50px; } .filters { bottom: 10px; } } :focus, .toggle:focus + label, .toggle-all:focus + label { box-shadow: 0 0 2px 2px #CF7D7D; outline: 0; } </style>
実行すると以下のような画面が表示されます。(サンプルをViteのプロジェクトに移植しているため若干デザインのズレがあります。)
このソースをChatGPTの力を借りてReactに置き換えてみます。
単純な置き換え
システムプロンプトは以下とします。
あなたはフロントエンドコード生成機です。 特にVue、React、TypeScriptに詳しいです。回答はマークダウンとコードブロックを利用してください。
プロンプトは以下です。
以下Vue3で書かれたプログラムです。 React HooksとTypeScriptを利用してReactに書き換えてください。 コンポーネント名はTodoList、ファイル名はTodoList.tsxとしてください。 {上記のコード}
上記で得られたのが以下のReactのコードになります。
import React, { useEffect, useState } from "react"; const STORAGE_KEY = "react-todomvc"; type Visibility = "all" | "active" | "completed"; interface Todo { id: number; title: string; completed: boolean; } const filters: { [idx: string]: (todos: Todo[]) => Todo[] } = { all: (todos: Todo[]) => todos, active: (todos: Todo[]) => todos.filter((todo) => !todo.completed), completed: (todos: Todo[]) => todos.filter((todo) => todo.completed), }; const TodoList = () => { const [todos, setTodos] = useState<Todo[]>( JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]") as Todo[] /* ここだけ追加 */, ); const [visibility, setVisibility] = useState<Visibility>("all"); const [editedTodo, setEditedTodo] = useState<Todo | null>(null); useEffect(() => { localStorage.setItem(STORAGE_KEY, JSON.stringify(todos)); }, [todos]); const filteredTodos = filters[visibility](todos); const remaining = filters.active(todos).length; const toggleAll = (e: React.ChangeEvent<HTMLInputElement>) => { const checked = e.target.checked; setTodos((prevTodos) => prevTodos.map((todo) => ({ ...todo, completed: checked })) ); }; const addTodo = (e: React.KeyboardEvent<HTMLInputElement>) => { const value = e.currentTarget.value.trim(); if (value) { setTodos((prevTodos) => [ ...prevTodos, { id: Date.now(), title: value, completed: false, }, ]); e.currentTarget.value = ""; } }; const removeTodo = (todo: Todo) => { setTodos((prevTodos) => prevTodos.filter((item) => item !== todo)); }; let beforeEditCache = ""; const editTodo = (todo: Todo) => { beforeEditCache = todo.title; setEditedTodo(todo); }; const cancelEdit = (todo: Todo) => { setEditedTodo(null); todo.title = beforeEditCache; }; const doneEdit = (todo: Todo) => { if (editedTodo) { setEditedTodo(null); todo.title = todo.title.trim(); if (!todo.title) { removeTodo(todo); } } }; const removeCompleted = () => { setTodos((prevTodos) => filters.active(prevTodos)); }; const onHashChange = () => { const route = window.location.hash.replace(/#\/?/, ""); if (filters[route]) { setVisibility(route as Visibility); } else { window.location.hash = ""; setVisibility("all"); } }; useEffect(() => { window.addEventListener("hashchange", onHashChange); return () => { window.removeEventListener("hashchange", onHashChange); }; }, []); return ( <section className="todoapp"> <header className="header"> <h1>todos</h1> <input className="new-todo" autoFocus placeholder="What needs to be done?" onKeyUp={(e) => { if (e.key === "Enter") { addTodo(e); } }} /> </header> <section className="main" style={{ display: todos.length ? "block" : "none" }} > <input id="toggle-all" className="toggle-all" type="checkbox" checked={remaining === 0} onChange={toggleAll} /> <label htmlFor="toggle-all">Mark all as complete</label> <ul className="todo-list"> {filteredTodos.map((todo) => ( <li key={todo.id} className={`todo ${todo.completed ? "completed" : ""} ${ todo === editedTodo ? "editing" : "" }`} > <div className="view"> <input className="toggle" type="checkbox" checked={todo.completed} onChange={(e) => { setTodos((prevTodos) => prevTodos.map((item) => item === todo ? { ...item, completed: e.target.checked } : item ) ); }} /> <label onDoubleClick={() => editTodo(todo)}> {todo.title} </label> <button className="destroy" onClick={() => removeTodo(todo)} > </button> </div> <input className="edit" type="text" value={todo.title} onBlur={() => doneEdit(todo)} onKeyUp={(e) => { if (e.key === "Enter") { doneEdit(todo); } else if (e.key === "Escape") { cancelEdit(todo); } }} /> </li> ))} </ul> </section> <footer className="footer" style={{ display: todos.length ? "block" : "none" }} > <span className="todo-count"> <strong>{remaining}</strong> <span>{remaining === 1 ? " item" : " items"} left</span> </span> <ul className="filters"> <li> <a href="#/all" className={visibility === "all" ? "selected" : ""} onClick={() => setVisibility("all")} > All </a> </li> <li> <a href="#/active" className={visibility === "active" ? "selected" : ""} onClick={() => setVisibility("active")} > Active </a> </li> <li> <a href="#/completed" className={visibility === "completed" ? "selected" : ""} onClick={() => setVisibility("completed")} > Completed </a> </li> </ul> <button className="clear-completed" onClick={removeCompleted} style={{ display: todos.length > remaining ? "block" : "none" }} > Clear completed </button> </footer> </section> ); }; export default TodoList;
これを実行してみると以下のような画面になります。
機能的には動きますが、デザイン等がほぼありません。
Vue.jsではscoped cssでスタイル適用していましたが、Reactにはそういった機能は無いので、代替手段を取る必要があります。今回は、scoped cssをCSS Modulesに移植してみたいと思います。
CSSの移植
SCSSのファイル自体は、Vueのscoped cssをそのままTodoList.module.scssなどとして保存すれば良いので、React側で、CSS Modulesとしてスタイル適用したコードを生成してもらうことにします。 先述のChatGPTとの対話に続いて、以下プロンプトを入力します。
以下のTodoList.module.scssを、TodoList.tsxにCSS Modulesとして適用したコードを生成してください。 {scoped css(scss)の内容}
出来上がったのが以下のファイルです。(TodoList.tsx)
import React, { useEffect, useState } from "react"; import styles from "./TodoList.module.scss"; const STORAGE_KEY = "react-todomvc"; type Visibility = "all" | "active" | "completed"; interface Todo { id: number; title: string; completed: boolean; } const filters: { [idx: string]: (todos: Todo[]) => Todo[] } = { all: (todos: Todo[]) => todos, active: (todos: Todo[]) => todos.filter((todo) => !todo.completed), completed: (todos: Todo[]) => todos.filter((todo) => todo.completed), }; const TodoList: React.FC = () => { const [todos, setTodos] = useState<Todo[]>( JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]") as Todo[], ); const [visibility, setVisibility] = useState<Visibility>("all"); const [editedTodo, setEditedTodo] = useState<Todo | null>(null); useEffect(() => { localStorage.setItem(STORAGE_KEY, JSON.stringify(todos)); }, [todos]); const filteredTodos = filters[visibility](todos); const remaining = filters.active(todos).length; const toggleAll = (e: React.ChangeEvent<HTMLInputElement>) => { const checked = e.target.checked; setTodos((prevTodos) => prevTodos.map((todo) => ({ ...todo, completed: checked, })) ); }; const addTodo = (e: React.KeyboardEvent<HTMLInputElement>) => { const value = e.currentTarget.value.trim(); if (e.key === "Enter" && value) { setTodos((prevTodos) => [ ...prevTodos, { id: Date.now(), title: value, completed: false, }, ]); e.currentTarget.value = ""; } }; const removeTodo = (todo: Todo) => { setTodos((prevTodos) => prevTodos.filter((t) => t.id !== todo.id)); }; let beforeEditCache = ""; const editTodo = (todo: Todo) => { beforeEditCache = todo.title; setEditedTodo(todo); }; const cancelEdit = (todo: Todo) => { setEditedTodo(null); todo.title = beforeEditCache; }; const doneEdit = (todo: Todo) => { if (editedTodo !== null) { setEditedTodo(null); todo.title = todo.title.trim(); if (!todo.title) removeTodo(todo); } }; const removeCompleted = () => { setTodos((prevTodos) => filters.active(prevTodos)); }; const onHashChange = () => { const route = window.location.hash.replace(/#\/?/, ""); if (filters[route]) { setVisibility(route as Visibility); } else { window.location.hash = ""; setVisibility("all"); } }; useEffect(() => { window.addEventListener("hashchange", onHashChange); return () => { window.removeEventListener("hashchange", onHashChange); }; }, []); return ( <section className={styles.todoapp}> <header className={styles.header}> <h1>todos</h1> <input className={styles["new-todo"]} autoFocus placeholder="What needs to be done?" onKeyDown={addTodo} /> </header> <section className={styles.main} style={{ display: todos.length ? "block" : "none" }} > <input id="toggle-all" className={styles["toggle-all"]} type="checkbox" checked={remaining === 0} onChange={toggleAll} /> <label htmlFor="toggle-all">Mark all as complete</label> <ul className={styles["todo-list"]}> {filteredTodos.map((todo) => ( <li key={todo.id} className={`${styles.todo} ${ todo.completed ? styles.completed : "" } ${todo === editedTodo ? styles.editing : ""}`} > <div className={styles.view}> <input className={styles.toggle} type="checkbox" checked={todo.completed} onChange={() => { setTodos((prevTodos) => prevTodos.map((t) => t.id === todo.id ? { ...t, completed: !t.completed } : t ) ); }} /> <label onDoubleClick={() => editTodo(todo)}>{todo.title}</label> <button className={styles.destroy} onClick={() => removeTodo(todo)} > </button> </div> <input className={styles.edit} type="text" value={todo.title} onBlur={() => doneEdit(todo)} onKeyDown={(e) => { if (e.key === "Enter") doneEdit(todo); else if (e.key === "Escape") cancelEdit(todo); }} onChange={(e) => { todo.title = e.target.value; setTodos([...todos]); }} /> </li> ))} </ul> </section>{" "} <footer className={styles.footer} style={{ display: todos.length ? "block" : "none" }} > <span className={styles["todo-count"]}> <strong>{remaining}</strong>{" "} <span>{remaining === 1 ? " item" : " items"} left</span> </span>{" "} <ul className={styles.filters}> <li> <a href="#/all" className={visibility === "all" ? styles.selected : ""} > All </a> </li>{" "} <li> <a href="#/active" className={visibility === "active" ? styles.selected : ""} > Active </a> </li>{" "} <li> <a href="#/completed" className={visibility === "completed" ? styles.selected : ""} > Completed </a> </li> </ul>{" "} <button className={styles["clear-completed"]} onClick={removeCompleted} style={{ display: todos.length > remaining ? "block" : "none" }} > Clear completed </button> </footer> </section> ); }; export default TodoList;
実行してみると以下のような画面が表示されます。
bodyなどに対するCSSが若干ずれているため少し崩れていますが、概ね問題なさそうです。
一回のプロンプトで生成してみる
上記では、tsx化とCSS Modules化を分けて行いましたが、以下のようなプロンプトでまとめて出力できました。
以下Vue3で書かれたプログラムです。React HooksとTypeScriptを利用してReactに書き換えてください。条件は以下です。 ・コンポーネント名はTodoList ・ファイル名はTodoList.tsx ・scoped cssはTodoList.module.scssとして別ファイルにし、それをTodoList.tsxに適用すること {Vueのソース}
このようにすれば、将来的にfunction callingなどを利用して、コードの変換を自動化するツールに落とし込みやすいかなと思います。
まとめ
今回の記事では、ChatGPTを利用したソースコード変換について紹介してみました。新しいものを1から作ってもらうにはプロンプトなどに結構試行錯誤が必要な印象でしたが、元となるものがある場合はかなりの精度のものが出力されるなーと感じました。
今回紹介したものと逆方向の、ReactからVueへの変換もそんなに難しくはないと思います。また、フレームワークのバージョンアップについても期待できそうです。
今回の例では、VueからReactへの変換でしたが、どのような変換を行うにしても、移行元および移行先についての知識は必要かなと思いました。
生成されたコードが曖昧だったり、多少間違っていたりなどはあるため、適宜直したり、再質問したりする場合、何がおかしいのか判断できる必要があると思います。
ただ、多少間違っていてもかなり手間は省けるので、この手の移行を考えている場合は一考の余地はあるかと思います。
採用情報
虎の穴ラボでは一緒に働く仲間を募集中です!
この記事を読んで、興味を持っていただけた方はぜひ弊社の採用情報をご覧ください。
yumenosora.co.jp