ここ数年、フロントエンド界隈では仮想DOMと呼ばれる技術が人気です。フロントエンドでUIを構築する際に、その状態(表示可否、いくつ表示しているかなど)をJavaScript側で管理するのは大変です。そこで仮想DOMを用いることで、表示ロジック側に判定を任せられます。開発者はJavaScript側で変数を更新すれば、後の表示はライブラリにお任せできるのです。

そんな仮想DOMですが、慣れるまで使いづらいと感じる人は多いでしょう。また、ReactやVueなど仮想DOMを使ったフレームワークでもお作法が異なって混乱してしまう問題もあります。

そうした状況下で作られた新しいJavaScriptフロントエンドフレームワークがSvelteになります。Svelteの特徴として次の3つが挙げられます。

  1. HTML/JavaScript/CSSといった既存の技術だけを利用する
  2. 仮想DOMは使わない
  3. 簡単な状態管理

この記事ではSvelteをMonacaアプリに導入するまでのステップを簡単に紹介します。

Svelte • Cybernetically enhanced web apps

ベストな方法はVue × Onsen UIプロジェクトテンプレートをベースにする

Svelteでは Webpackなどの導入が必要で、多少なりとも複雑なステップを踏む必要があります。しかし、Vue × Onsen UIプロジェクトのテンプレートをベースにすれば、とても簡単に導入できます(ReactやAngularのテンプレートでもそれほど変わらないはずです)。

ライブラリの追加、削除

Vueのテンプレートを導入した状態だとして進めます。Monaca CLIであれば Onsen UI and Vue.js > Onsen UI V2 Vue Minimum を選択してプロジェクトを作成してください。

不要なライブラリを消して、逆にSvelteを追加します。package.json でいえば、次のような差分になります。

  • 削除するライブラリ
    • onsenui
    • vue-onsenui
  • 追加するライブラリ
    • svelte
    • svelte-loader

svelte-loaderはSvelteのWebpack用ライブラリです。

webpack.config.jsを修正する

次に webpack.config.js を修正します。修正箇所は次の通りです。

以下を削除

const { VueLoaderPlugin } = require('vue-loader');

以下のように変更

alias: {
  // 'vue$': 'vue/dist/vue.esm.js', ← この行は削除
  svelte: path.resolve('node_modules', 'svelte'), // ← この行を追加
  '@': path.resolve(__dirname, 'src')                                            
}

以下のキーを追加

extensions: ['.mjs', '.js', '.svelte'],
mainFields: ['svelte', 'browser', 'module', 'main']

以下のように変更

{
  // test: /\.vue$/, ← この行を削除
  // include: path.resolve(__dirname, 'src'), ← この行を削除
  // use: 'vue-loader' ← この行を削除
  test: /\.(html|svelte)$/, // この行を追加
  exclude: /node_modules/, // この行を追加
  use: 'svelte-loader'
}

上記テストに追加

{
  test: /\.html$/,
  use: [
    {
      loader: 'html-loader',
      options: { minimize: true }
    }
  ]
}

以下を削除

plugins: [
  // new VueLoaderPlugin(), ← この行を削除
  // : 以下略
]

念のためできあがったwebpack.config.jsを貼り付けます。

const webpack = require('webpack');
const HtmlWebPackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const ProgressBarPlugin = require('progress-bar-webpack-plugin');

const path = require('path');
const argvs = require('yargs').argv;
const devMode = process.env.WEBPACK_SERVE || argvs.mode === 'development';

const DEFAULT_PORT = 8080;
const host = process.env.MONACA_SERVER_HOST || argvs.host || '0.0.0.0';
const port = argvs.port || DEFAULT_PORT;
const wss = process.env.MONACA_TERMINAL ? true : false;
const socketPort = port + 1; //it is used for webpack-hot-client

let webpackConfig = {
  mode: devMode ? 'development' : 'production',

  entry: {
    app: ['./src/main.js']
  },

  output: {
    path: path.resolve(__dirname, 'www'),
    filename: '[name].bundle.js',
  },

  optimization: {
    removeAvailableModules: true,
    splitChunks: {
      chunks: 'all'
    },
    runtimeChunk: true,
    removeEmptyChunks: true,
    mergeDuplicateChunks: true,
    providedExports: true,
  },

  resolve: {
    alias: {
      svelte: path.resolve('node_modules', 'svelte')
    },
    extensions: ['.mjs', '.js', '.svelte'],
    mainFields: ['svelte', 'browser', 'module', 'main']
  },

  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        include: path.resolve(__dirname, 'src'),
        use: [{
          loader: 'babel-loader',
          options: {
            presets: [ 'env' ]
          } 
        }]
      },
      {
        test: /\.(html|svelte)$/,
        exclude: /node_modules/,
        use: 'svelte-loader'
      },
      {
        test: /\.(png|jpe?g|gif|svg|woff|woff2|ttf|eot|ico)(\?\S*)?$/,
        loader: 'file-loader?name=assets/[name].[hash].[ext]'
      },
      {
        test: /\.css$/,
        use: [          
          devMode ? 'style-loader' : MiniCssExtractPlugin.loader,
          {
            loader: 'css-loader',
            options: { importLoaders: 1 }
          },
          {
            loader: 'postcss-loader',
            options: { sourceMap: true }
          }
        ]
      },
      {
        test: /\.json$/,
        loader: 'json'
      }
    ]
  },

  // See below for dev plugin management.
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].css',
      chunkFilename: '[name].css'
    }),
    new ProgressBarPlugin(),
  ],

  resolveLoader: {
    modules: [ 'node_modules' ]
  },

  performance: {
    hints: false
  }
};

// Development mode
if(devMode) {

  webpackConfig.devtool = 'eval';

  webpackConfig.serve = {
    port: port,
    host: host,
    devMiddleware: {
      publicPath: '/',
      stats: {
        colors: true,
        errorDetails: true,
        performance: true,
        source: true,
        warnings: true,
        builtAt: true,
      }
    },
    hotClient: {
      port: socketPort,
      https: wss
    }
  };

  let devPlugins = [
    new HtmlWebPackPlugin({
      template: 'src/public/index.html.ejs',
      chunksSortMode: 'dependency'
    })
  ];

  webpackConfig.plugins = webpackConfig.plugins.concat( devPlugins );

} else {

  // Production mode
  let prodPlugins = [
    new HtmlWebPackPlugin({
      template: 'src/public/index.html.ejs',
      chunksSortMode: 'dependency',
      externalCSS: ['components/loader.css'],
      externalJS: ['components/loader.js'],
      minify: {
        caseSensitive: true,
        collapseWhitespace: true,
        conservativeCollapse: true,
        removeAttributeQuotes: true,
        removeComments: true
      }
    })
  ];
  webpackConfig.plugins = webpackConfig.plugins.concat( prodPlugins );

}

module.exports = webpackConfig;

src/public/index.html.ejsの修正

Svelteでは特にあらかじめDOMを用意しておく必要はありませんので、 src/public/index.html.ejs を開いて修正します。

<!-- 以下を削除 -->
<div id="app"></div>

src/main.jsの修正

最初のエントリポイントになる src/main.js を修正します。ちょっとVueに似ていますが、よりシンプルです。propsは次の画面(ここではApp)に引き渡す引数です。targetでdocument.bodyとしていますので、画面全体を書き換えられます。

import App from './App.svelte';

const app = new App({
    target: document.body,
    props: {
    }
});

window.app = app;

export default app;

src/App.svelteの作成

ではmain.jsから呼ばれているApp.svelteを作成します。まずはただのHTMLを表示します。

<h1>Hello world!</h1>

これでHTMLに反映されていれば、Svelteが無事に動いているというになります。

Svelteの使い方

これで終わってしまったらSvelteの良さが伝わらないので、幾つかサンプルを実行してみましょう。

文字を出力する

文字を出力する場合、次のように書きます。VueやReactのような専用記法ではなく、よく見知ったHTMLとJavaScriptの書き方が使えているのが分かります。

<script>
    const name = 'world';
</script>

<h1>Hello {name}!</h1>

同様にHTMLの要素を書き換えることもできます。以下の例では1秒ごとにh1タグの文字色を変更しています。

<script>
  const name = 'world';
  let color = 'red';
  setInterval(() => {
    const colors = ['red', 'blue', 'green', 'yellow', 'purple', 'black', 'skyblue'];
    color = colors[Math.floor(Math.random() * colors.length)]
  }, 1000)
</script>

<h1 style="color: {color}">Hello {name}!</h1>

ここもVueのようにテキストの場合と要素の場合で書き方を変えたりしないので分かりやすいです。

処理分岐

if文を使った処理分岐の方法は次のようになります。 {#if}{/if} を使って分岐を書きます。分岐処理の際にはJavaScriptが使えるので分かりやすいです。イベントの取得は on:イベント名 で、関数などを指定します。

<script>
    let user = { loggedIn: false };

    function toggle() {
        user.loggedIn = !user.loggedIn;
    }
</script>

{#if user.loggedIn}
    <button on:click={toggle}>
        Log out
    </button>
{/if}

{#if !user.loggedIn}
    <button on:click={toggle}>
        Log in
    </button>
{/if}

入力値の取得

入力された値を取得する場合のサンプルです。 bind:value を定義することで、入力された値をJavaScript側の変数に入れられます。

<script>
    let name = '';
</script>

<input bind:value={name} placeholder="enter your name">
<p>Hello {name || 'stranger'}!</p>

外部ファイルの利用

別なSvelteファイルを定義して、呼び出すこともできます。HTMLやJavaScriptが複雑化しないためにも必要でしょう。元のファイルでは次のように定義します。 Nested.svelte が外部ファイルです。

<script>
    import Nested from './Nested.svelte';
</script>

<style>
    p {
        color: purple;
        font-family: 'Comic Sans MS', cursive;
        font-size: 2em;
    }
</style>

<p>These styles...</p>
<Nested/>

Nested.svelteの内容は次のようになります。

<p>...don't affect this element</p>

なお、表示を見て分かる通り、呼び出し元のスタイル設定は、別なコンポーネントには影響されません。

外部ファイルへの変数の受け渡し

いわゆるpropsの使い方です。これもシンプルです。まず親ファイルで次のように要素を使って変数を指定します。

<script>
    import Nested from './Nested.svelte';
</script>

<Nested answer={42}/>

受け取る側では変数をexportで受け取れます。

<script>
    export let answer;
</script>

<p>The answer is {answer}</p>

外部ファイルを使った変数の書き換え

外部ファイルが増えていくと、その中で変数をどう管理するかが問題になります。いわゆるステート管理ですが、多くのフレームワークの悩みです。Svelteでは、専用の仕組みを用意しています。

まず変数(読み込みのみ、書き込み可の二つが定義ができます)のファイルを用意します。この writable という関数で変数を定義するのがコツです。

import { writable } from 'svelte/store';

export const count = writable(0);

各コンポーネントでは、このファイルを読み込んで、updateを使って変数を更新します。

<script>
    import { count } from './stores.js';

    function increment() {
        count.update(n => n + 1);
    }
</script>

<button on:click={increment}>
    +
</button>

setメソッドでリセットもできます。

<script>
    import { count } from './stores.js';

    function reset() {
        count.set(0);
    }
</script>

<button on:click={reset}>
    reset
</button>

また、各コンポーネントで起こっている変数の書き換えを検知するためにサブスクライブ(購読)メソッドが用意されています。これはcountが書き換わったタイミングを受け取りたい場合に用いるもので、今回の場合であればcountをそのまま出力する形でも問題ありません。

<script>
    import { count } from './stores.js';
    import Incrementer from './Incrementer.svelte';
    import Decrementer from './Decrementer.svelte';
    import Resetter from './Resetter.svelte';

    let count_value;

  // または count をそのまま出力でも大丈夫です。
    const unsubscribe = count.subscribe(value => {
        count_value = value;
    });
</script>

<h1>The count is {count_value}</h1>

<Incrementer/>
<Decrementer/>
<Resetter/>

このようなしくみで簡単なカウンタ機能を、部品ごとに分解して開発できます。Vueで同じようなことをしようと実現するためにはステート管理が必須ですが、Svelteならとてもシンプルです。

UIライブラリについて

Svelteはフレームワークですので、見た目のよいUIを実現するためには別途UIフレームワークを利用するとよいでしょう。現状、Onsen UIは対応していませんが、次のようなUIフレームワークが存在します。独自ではなく、何らかの別なUIフレームワークをSvelte向けにラッピングしているようです。

まとめ

Svelteはほかのフレームワークと比べると、学習コストは低そうです。すでにHTML/JavaScript/CSSを分かっていれば、その知識のまま移行できます。VueやAngular、Reactなどに取っつきづらさを感じていた方は試してみてはいかがでしょうか。

Svelte • Cybernetically enhanced web apps