hacomono TECH BLOG

フィットネスクラブやスクールなどの顧客管理・予約・決済を行う、業界特化型SaaS「hacomono」を提供する会社のテックブログです!

TutorialKit と bolt.new でフレームワークを完全理解させる試み

この記事は hacomono Advent Calendar 2024 の17日目の記事です

どうも, みゅーとんです
今年は全体的にテックブログの記事執筆をサボっていたせいか, いつのまにか全体の 15% を切ってしまってました.
ブログ再起動時点はすごくエンジンかかっていたのですが, どうしたものやら..

TL;DR

  • Vue2 から 3 まで様々な書き方がある状態で, エンジニアの知識差分を埋めて, Vue3 向けのモダンな記法を広めていきたい
  • 新しい記法を覚えるには体系的に学べるほうが学習効率が良い
  • bolt.new で作ったプロジェクトを TutorialKit の template に使うことで, チュートリアルをなるべく手間かけずに作る

背景

Vue2 / Nuxt2 の脱却がとにかくしんどい

hacomono ではフロントエンドのプロジェクトを複数持っており, うちいくつかは Nuxt2 でできています.
Nuxt2 を 3 に上げるべく, 私が所属している “リアーキテクチャ & イネーブルメント 部” と, “UX改善チーム” で協力して, 互換性の無いコードや構成を少しずつ直している状態です.

インフラの構成やら, コードベースの大きさ等様々な理由で, バージョンアップまでの道のりは険しく, 数年を要する作業になっている状態です.

※ Nuxt ついては本筋から大きく逸れるので, 以降 Vue の話にスコープを絞ります

Vue2 の頃のシンタックス変化による混乱と移行コスト

Vue2 は, 実は様々な書き方が模索されていたタイミングでもあります.

例えば, Vue2 でコンポーネントの script を実装する方法だけ挙げてみると, ざっくり挙げても 以下の通り複数あります.

<!-- pure js での記法 -->
<script>
export default { 
  data() {
    return {
      counter: 0
    }
  },
  methods: {
    increment() {
      this.counter ++;
    }
  }
}
</script>
<!-- pure js ライクなオブジェクトを TypeScript で書く記法 -->
<script lang="ts">
import Vue from "vue"
type Data = { counter: number }

export default Vue.extend({
  data(): Data {
    return {
      counter: 0
    }
  },
  methods: {
    increment(): void {
      this.counter ++;
    }
  }
})
</script>
<!-- 完全に TypeScript の class に寄せた (”vue-property-decorator”) 記法 -->
<script lang="ts">
import { Component, Vue } from "vue-property-decorator"

@Component
export default class extends Vue { 
  counter = 0
  
  increment(): void {
    this.counter ++
  }
}
</script>
<!-- composition-api が生まれた際に実装された defineComponent を使って options-api で書く記法 -->
<script lang="ts">
import { defineComponent } from "vue"

type Data = { counter: number }

export default defineComponent({
  data(): Data {
    return {
      counter: 0
    }
  },
  methods: {
    increment(): void {
      this.counter ++;
    }
  }
})
</script>
<!-- composition api を script setup なしで書く記法 -->
<script lang="ts">
import { defineComponent, ref } from "vue"

export default defineComponent({
  setup() {
    const counter = ref(0)
    
    function increment() {
      counter.value ++
    }
    
    return { counter, increment }
  }
})
</script>
<!-- composition api を script setup で書く記法 -->
<script lang="ts" setup>
import { ref } from "vue"

const counter = ref(0)

function increment() {
  counter.value ++
}
</script>

ライブラリの更新情報等をしっかり追っていれば defineComponent を使う記法 (options-api, composition-api 問わず) か, script setup が最もモダンで, 推奨される記法であると理解するはずです.
その記法に書き換えられるかどうかは, また別の話.

歴史がもたらすマイグレーションの困難性

実際のプロダクト開発においては, さまざまな理由からモダンなコードに寄せるのが困難であったりします. コードをきれいなまま保てるケースはあまり多くはない印象です. 例えば, 様々な理由でフレームワークバージョンを上げられずに, 古いバージョンのコードが量産されてしまったり, プロダクト独自の書き方が生まれてしまったり.
フレームワークの推奨する記法に沿ったピュアなコードを維持するのは, 困難です.

Nuxt3 化のプロジェクトにおいては, 互換性のない古いコードが多い状態を脱却すべく, 可能な範囲でコードを最新に近い記法に書き換えている状態です.

しかし, 古いコード / 問題のあるコードがマジョリティのままでは, これから新しく実装するコードも, 古いコード/問題のコードを参考にしてしまいがちです.
昨今は Google Copilot による開発生産性の向上が見込めますが, これは既存のコードを真似するような提案をすることが大変多い印象があります.

ゆえに, 新しい記法を広めていくためには, 以下の施策が必要と感じています.

  • 愚直に新しいコードがマジョリティになるようにひたすら書き換えていく
  • 開発者自身に推奨される記法を学んでもらう
  • 問題のあるコードは “何が問題なのか” “どう書き換えるべきか” を常に提唱し, 理解してもらう


私が所属する部署は リアーキテクチャ & イネーブルメント 部 です.
Nuxt3 にバージョンアップをすることはメインミッションではありますが,
開発者全員に新しいコードを積極的に書いてもらうために理解を促すことも, そのタスクに含まれていると認識しています.

課題

開発者自身に推奨される記法を学んでもらう

問題のあるコードは “何が問題なのか” “どう書き換えるべきか” を常に提唱し, 理解してもらう

極論, “フレームワークのマニュアルに書いてある推奨の記法に従え” “それ以外はするな” と言えるなら, この2 つは概ね解消されるでしょう. しかし, 実態のコードと照らし合わせてみても, それはなかなか難しいものです.

つまり, 書き換えのためのガイドラインが必要です.
とはいっても, GitHub の markdown とかにつらつらと文章を書いて指示しても, 誰も読まないでしょう.
実際, フロントエンドのテストコードを広めるために, テストコードのガイドラインを書くよりも, モブプロを実施したほうが理解してもらいやすい傾向はありました.

学習は体系的に学べるほうが圧倒的に効率が良いです.
一方で, 体型的に学べるコンテンツは実装コストが圧倒的に高いです.

これらを解消する方法が望まれていました.

解決方法

TutorialKit の導入

TutorialKit について 3 行でまとめ.

  • StackBlitz が提供するフロントエンドのフレームワーク
  • インタラクティブなチュートリアルを SSG で作るためのもの
  • Astro, WebContainerAPI がベースになっている


ものは試しに 公式サイトの Try Demo を眺めてみてください.
左のチュートリアルに従って, コードに手を加えていくと, Preview が自動的に反映してきますね.

引用: Create interactive coding tutorials TutorialKit

TutorialKit を使えば, こういったサイトを自前で作ることができます. すごいですね.

また, preview には Web Container が使われています.
ブラウザ上で node.js が動作しているため, Nuxt2 の SSR (dev mode) を preview に使用することもできました.
SSG のサイトなのに, クライアント上で SSR します. よくわからないですね.

公式のマニュアルに則って, markdown とコードベースの template を用意することで, チュートリアルコンテンツを実装することができます. 具体的な実装方法は公式の記載を参照するほうが良いので, ここでは割愛します.

チュートリアルを説明しやすくするために, コードベースをどう作っていくかが課題になっていきそうです.
たとえば, 課題で提示する Vue2, Vue3 の知識差分を埋める要件であれば, Vue2 と Vue3 それぞれの入門用プロジェクトを用意する必要がありそうです.

bolt.new で TutorialKit の template 実装コストを削減

bolt.new について 3 行でまとめ

  • またしても StackBlitz によるプロダクト
  • AI によってフロントエンドのプロジェクト・コードベースを自動生成する
  • 生成後に Web Container によって, その場でプレビューされる


bolt.new のサイトはこちら .. bolt.new
試しに, 以下のプロンプトでコードを組んでもらいました

- Content for learning the life cycle of Vue
- Responding to lifecycle hooks such as created, mounted, etc., to make some kind of change to the screen
- Fixed in Vue 2.7 (Options API)
- Uses typescript and vite

生成時の画面


出来上がったコードに対して

  • ライフサイクルの動作確認に console.log を使わず, 画面に表す作りにしてほしい
  • 画面にライフサイクルを表示する仕組みを Vue のライフサイクルに依存すると誤作動を起こすので, Vue のライフサイクルに依存しない仕組みにしてほしい

などの要望を追加で指示などをして, 出来上がったコードベースは以下の通りです.

<!-- App.vue -->
<script lang="ts">
import Vue from 'vue';
import LifecycleDemo from './components/LifecycleDemo.vue';

export default Vue.extend({
  name: 'App',
  components: {
    LifecycleDemo
  },
  data() {
    return {
      showDemo: true
    };
  },
  methods: {
    toggleDemo() {
      this.showDemo = !this.showDemo;
    }
  }
});
</script>

<template>
  <div id="app">
    <h1>Vue 2.7 Lifecycle Hooks</h1>
    <button @click="toggleDemo">{{ showDemo ? 'Destroy' : 'Create' }} Component</button>
    <LifecycleDemo v-if="showDemo" />
  </div>
</template>

<style>
#app {
  font-family: Arial, sans-serif;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}

button {
  background-color: #42b883;
  color: white;
  border: none;
  padding: 10px 20px;
  border-radius: 4px;
  cursor: pointer;
  margin-bottom: 20px;
}

button:hover {
  background-color: #3aa876;
}
</style>
<!-- LifecycleDemo.vue -->
<script lang="ts">
import Vue from 'vue';

interface Data {
  count: number;
  message: string;
  intervalId: number | null;
  mountedTime: string;
}

export default Vue.extend({
  name: 'LifecycleDemo',
  
  data(): Data {
    return {
      count: 0,
      message: 'Component is initializing...',
      intervalId: null,
      mountedTime: ''
    };
  },

  beforeCreate() {
    this.$nextTick(() => {
      this.logEvent('beforeCreate: Component is being initialized');
    });
  },

  created() {
    this.message = 'Component created, waiting to be mounted...';
    this.logEvent('created: Component instance created');
  },

  beforeMount() {
    this.logEvent('beforeMount: Component is about to be mounted');
  },

  mounted() {
    this.message = 'Component is mounted and running!';
    this.mountedTime = new Date().toLocaleTimeString();
    this.logEvent('mounted: Component is mounted to DOM');
    
    this.intervalId = window.setInterval(() => {
      this.count++;
    }, 1000);
  },

  beforeUpdate() {
    this.logEvent('beforeUpdate: Component is about to update');
  },

  updated() {
    this.logEvent('updated: Component has been updated');
  },

  beforeDestroy() {
    this.logEvent('beforeDestroy: Component is about to be destroyed');
    if (this.intervalId !== null) {
      clearInterval(this.intervalId);
    }
  },

  destroyed() {
    this.logEvent('destroyed: Component has been destroyed');
  },

  methods: {
    logEvent(message: string) {
      const eventsContainer = document.querySelector('.events-log');
      if (eventsContainer) {
        const eventElement = document.createElement('div');
        eventElement.className = 'event-item';
        eventElement.textContent = `${new Date().toLocaleTimeString()} - ${message}`;
        eventsContainer.insertBefore(eventElement, eventsContainer.firstChild);
        
        // Keep only the last 100 events to prevent memory issues
        while (eventsContainer.children.length > 100) {
          eventsContainer.removeChild(eventsContainer.lastChild as Node);
        }
      }
    }
  }
});
</script>

<template>
  <div class="lifecycle-demo">
    <h2>Vue Lifecycle Demo</h2>
    <div class="status">
      <p>Status: {{ message }}</p>
      <p>Mounted at: {{ mountedTime }}</p>
      <p>Seconds since mounted: {{ count }}</p>
    </div>
    <div class="events">
      <h3>Lifecycle Events:</h3>
      <div class="events-log"></div>
    </div>
  </div>
</template>

<style scoped>
.lifecycle-demo {
  padding: 20px;
  border: 2px solid #42b883;
  border-radius: 8px;
  max-width: 600px;
  margin: 20px auto;
}

.status {
  background-color: #f8f8f8;
  padding: 15px;
  border-radius: 4px;
  margin: 10px 0;
}

.events {
  margin-top: 20px;
  text-align: left;
}

.events-log {
  max-height: 200px;
  overflow-y: auto;
  border: 1px solid #eee;
  padding: 10px;
  border-radius: 4px;
  background-color: #fafafa;
}

.event-item {
  padding: 5px;
  border-bottom: 1px solid #eee;
  font-family: monospace;
  font-size: 0.9em;
}

.event-item:last-child {
  border-bottom: none;
}

h2 {
  color: #42b883;
  margin-bottom: 20px;
}

h3 {
  color: #2c3e50;
  margin-bottom: 10px;
}
</style>

ほとんどの要件を満たしており, 少しの手直しだけで済むコードが出来上がりました.
要件通り, vue も 2.7 だし, バンドラーには vite が使われています.

実装の例

出来上がったページを軽く紹介します.
なお, 実用にはまだ至っていないチュートリアルなので, いずれ変更があるかもしれないです.

内容は Vue2 のライフサイクルを体系的に学べるコンテンツになっています.
Options API でのライフサイクル基礎を理解した上で, Composition API だとどう変わるかをその後に提示する予定です.

ライフサイクルを試したいコンポーネント上で logEvent を呼ぶと, そのタイミングで画面上にログが出力されるような動きをします.

今後

後続のチュートリアルとして, vue-router の navigation guard を理解したうえで Nuxt2 のライフサイクルを深く理解し, Nuxt3 ではどう書き換えが必要になるのか? などのコンテンツをまとてみようかなと考えています.

また, もしかしたらコードの要件を ChatGPT に食わせれば, チュートリアルの文章そのものも自動生成できるのでは? と勘ぐっています. 多分なんとかなるでしょう.
ChatGPT, bolt.new それぞれのプロンプトがちゃんと設計されていれば, 実装コストをかなり低く作れそうです.

ある程度実装固まってきたら, 他の開発者に見てもらって, “完全に理解した!” っていうリアクションがどれほど得られるか見てみたいですね.

まとめ

(まとめは Notion AI に書かせました)
今回は StackBlitz が提供する2つのプロダクトを活用して、フレームワークの理解を深めるためのチュートリアルを効率的に作成する方法を紹介しました。
TutorialKit を利用することで、エディタ環境の整備と学習者の進捗管理が容易になり、bolt.new によってテンプレートの実装コストを大幅に削減できることがわかりました。
これらのツールを組み合わせることで、より効果的な学習コンテンツを、少ない工数で提供できる可能性が見えてきました。今後は AI との連携も視野に入れながら、さらなる改善を進めていきたいと思います。


株式会社hacomonoでは一緒に働く仲間を募集しています。
エンジニア採用サイトや採用ウィッシュリストもぜひご覧ください!