20200216のJavaScriptに関する記事は29件です。

jQuery 仮サイト作成のbase ② (自分用)

ezgif.com-video-to-gif (2).gif

前回の続き:スライドショーについてのみ

  <div class="slick">
    <div class="single">
      <div><img src="image/802.jpg" alt=""></div>
      <div><img src="image/IMG_3943.jpg" alt=""></div>
      <div><img src="image/IMG_4259.jpg" alt=""></div>
      <div><img src="image/IMG_4926.jpg" alt=""></div>

    </div>
  </div>

jquery.js
var page=0;
var lastPage =parseInt($(".single img").length-1);

  $(".single img").css("display","none");
  $(".single img").eq(page).css("display","block");  //pageを取得

function changePage(){
  $(".single img").fadeOut(1000);
  $(".single img").eq(page).fadeIn(1000);          //一旦1秒で画像間の接続時間
};

var Timer;
function startTimer(){
Timer =setInterval(function(){
  if(page === lastPage){
     page = 0;
     changePage();
  }else{
     page ++;
     changePage();
  };},4000);                        //一旦4秒で次画像へ
};
}

startTimer();
styles.css
//cssは仮

.slick {
  width: 100%;
  height: 400px;
  position: relative;
  /* left: 0; */
  top: 260px;

}
.single {
  position: relative;
}
.single img {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  width: 100%;
}

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

htmlを介さずにjsonから自動でコンテンツを作って更新するスクリプト

こんにちは、wattak777 です。

最近、必要に迫られてNode.jsを嗜むようになったのですが、とある時に「jsonで記載された元データをロードしてコンテンツとして表示し、ユーザがそのコンテンツを更新するとそのjsonを更新する」みたいなことをしようとしてNode.jsでソース一本で出来ないか?と考えたサンプルです。

ファイル構成は以下。
+
├ server.js
└ db.json

server.js
var express = require('express') ;
var url = require('url') ;

var app = express() ;
var json_write = {
    param1_label: '',
    param1_value: '',
    param2_label: '',
    param2_value: ''
}

app.get('/update_param', function(req, res) {
console.log(" GET" ) ;
    var url_parse = url.parse(req.url, true) ;
console.log("  search:%s", url_parse.search) ;
    json_write.param1_label = url_parse.query.param1_label ;
    json_write.param1_value = url_parse.query.param1_value ;
    json_write.param2_label = url_parse.query.param2_label ;
    json_write.param2_value = url_parse.query.param2_value ;

    var json = JSON.stringify(json_write, null, '    ' ) ;
    var fs = require('fs') ;
    fs.writeFile('./db.json', json, err => {
        if (err) {
            res.sendStatus(500) ;
        } else {
            res.sendStatus(200) ;
        }
    }) ;
}) ;

const setJson_JS = '<script type="text/javascript">\n' +
    '  function setJson() {\n' +
    '    var sethttp = new XMLHttpRequest() ;\n' +
    '    var url_str = "http://自らのIPアドレス:50000/update_param?" ;\n' +
    '    var elem = document.getElementById("param1_label") ;\n' +
    '    var url_str = url_str + "param1_label=" + encodeURI(elem.innerText) + "&" ;\n' +
    '    var elem = document.getElementById("param1_value") ;\n' +
    '    var url_str = url_str + "param1_value=" + encodeURI(elem.value) + "&" ;\n' +
    '    var elem = document.getElementById("param2_label") ;\n' +
    '    var url_str = url_str + "param2_label=" + encodeURI(elem.innerText) + "&" ;\n' +
    '    var elem = document.getElementById("param2_value") ;\n' +
    '    var url_str = url_str + "param2_value=" + encodeURI(elem.value) ;\n' +
    '\n' +
    '    sethttp.open("GET", url_str) ;\n' +
    '    sethttp.send() ;\n' +
    '  }\n' +
    '</script>\n' ;

const templ_input = '<p><input type="button" value="close" onClick="Javasctipt:history.back()"></p>\n' +
'<p><input type="button" value="send" onClick="setJson()"></p>\n' ;

app.get('/view_param', function(req, res) {
console.log(" GET" ) ;
    var fs = require('fs') ;
    var readJson = fs.readFileSync('jsondb/db.json') ;
    const jsonObj = JSON.parse( readJson ) ;
    var html_text = '<p><span id="param1_label">' ;
    html_text += jsonObj.param1_label ;
    html_text += '</span>:<input type="text" size="100" name="param1" id="param1_value" value="' ;
    html_text += jsonObj.param1_value + '"></p>\n' ;
    html_text += '<p><span id="param2_label">' ;
    html_text += jsonObj.param2_label ;
    html_text += '</span>:<input type="text" size="100" name="param2" id="param2_value" value="' ;
    html_text += jsonObj.param2_value + '"></p>\n' ;
    html_text += templ_input ;

    res.writeHead(200, {'Content-Type' : 'text/html'}) ;
    res.write(setJson_JS + html_text) ;
    res.end() ;
}) ;

var server = app.listen(50000, function() {
    console.log("listening at port %s", server.address().port) ;
});
db.json
{
    "param1_label": "param1_label",
    "param1_value": "param1 def value.",
    "param2_label": "param2_label",
    "param2_value": "param2 def value."
}

これを使ってnodeで起動させ、ブラウザより、
http://立てたIPアドレス:50000/view_param
とすると画面が表示され、更新後にjsonが更新されることが出来ました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

まだMVCで消耗してるの?〜Django x Reactで始めるSPA開発〜

ここ最近JSフレームワークを使ったサイトが増えてきています。
とくにReactやVueなどのJSフレームワークはSPAというアプリケーション開発によく使われ、サイトを利用するユーザーだけでなく開発者にも多くのメリットをもたらします。

想定読者

  • Web開発経験者
  • APIを使ったWebアプリケーションを開発したことがある人
  • JavaScriptをそこそこ知っててPythonもそこそこ知ってる人
  • Djangoをちょっと知っている
  • MVCもしくはMTVを使った開発をしたことがある人

別記事にもっと詳細に書いた記事があるので、本記事で難しいと感じた方やもっと深いところまで学習したい方はこちらをご覧ください。
まだMVCで消耗してるの?〜React x Djangoで始める今時Web開発〜

この記事ではフロントエンドにReact、バックエンドにDjangoを使用してチュートリアルを進めていきます。
チュートリアルはToDoアプリを題材にして進めていきます。

SPAとは

SPAはSingle Page Applicationと呼ばれ、ユーザーエクスペリエンスを向上させるのに有効な手立てとなります。
また、データバインディング、仮想DOM、Componentの3つの特徴を兼ね備えています。

データバインディング

素のJavaScriptを使って値を変更する場合、DOMを指定して値を変更する処理を毎回動かさなければなりません。
ですが、JSフレームワークを使うと定義しておいた変数が更新されるたびに画面上の値も変更されます。

仮想DOM

JSフレームワークには、クライアントのブラウザで描画をするためのDOMとサーバーとDOMの間に存在する仮想DOMの2種類があります。
仮想DOMの役割は、新しくサーバーから吐き出された仮想DOMと現在存在する仮想DOMとの差分を取り、その差分をDOMに反映することです。
そのためDOMの更新は差分があった部分だけとなり、ページのレンダリングを高速にすることができます。

Component

JSフレームワークでは、ページの要素をコンポーネントと呼ばれる部品単位に分割することができます。 そうすることで、コンポーネントを再利用することができ同じコードを書かずに済みます。

このチュートリアルではページを1枚作るだけなので、ユーザーエクスペリエンスにつながるメリットを肌で感じることはできないかもしれないのですが、開発面でのメリットは感じることができると思います。

Django環境構築

まずはバックエンドから進めていきます。

以下のコマンドを順に実行してください。

mkdir todo-backend
cd todo-backend
python3 -m venv env
source env/bin/activate
pip install django djangorestframework django-cors-header
django-admin startproject project .
django-admin startapp todo
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver

環境が構築できたら127.0.0.1:8000にアクセスしてください。
初期画面が表示されるはずです。

Django環境の設定

settings.pyにプラグイン追加の設定とクロスオリジンの設定を追記していきます。
クロスオリジンの設定は、WebブラウザからAPIを実行するときにアクセス拒否されるのを防ぐために追記します。

settings.py
INSTALLED_APPS = [
   'django.contrib.admin',
   'django.contrib.auth',
   'django.contrib.contenttypes',
   'django.contrib.sessions',
   'django.contrib.messages',
   'django.contrib.staticfiles',
   'rest_framework',
   'corsheaders',
   'todo'
]

MIDDLEWARE = [
   'corsheaders.middleware.CorsMiddleware',
]

# 許可するオリジン
CORS_ORIGIN_WHITELIST = [
   'http://localhost:3000',
]

ついでにprojectディレクトリ内のurl設定ファイルに、APIのルーティングを設定します。

urls.py
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
   path('admin/', admin.site.urls),
   path('api/', include('todo.urls')),
]

バックエンドの実装

todoアプリ内を実装していきます。

models.py
from django.db import models

class Todo(models.Model):
   name = models.CharField(max_length=64, blank=False, null=False)
   checked = models.BooleanField(default=False)

   def __str__(self):
       return self.name

マイグレーションを実行します。

python manage.py makemigrations
python manage.py migrate
admin.py
from django.contrib import admin
from .models import Todo

@admin.register(Todo)
class Todo(admin.ModelAdmin):
   pass
serializer.py
from rest_framework import serializers
from .models import Todo

class TodoSerializer(serializers.ModelSerializer):
   class Meta:
       model = Todo
       fields = ('id', 'name', 'checked')
views.py
from rest_framework import filters, generics, viewsets
from .models import Todo
from .serializer import TodoSerializer

class ToDoViewSet(viewsets.ModelViewSet):
   queryset = Todo.objects.all()
   serializer_class = TodoSerializer
   filter_fields = ('name',)
urls.py
from rest_framework import routers
from .views import ToDoViewSet
from django.urls import path, include

router = routers.DefaultRouter()
router.register(r'todo', ToDoViewSet)

urlpatterns = [
   path('', include(router.urls)),
]

ここまで終えたら、http://localhost:8000/admin にアクセスしてToDoをいくつか追加しておいてください。

React環境構築

Reactの環境立ち上げにはCreate React Appを使います。

yarn create react-app todo-frontend
cd todo-frontend
yarn start

http://localhost:3000にアクセスして画面が正常に表示されたら環境構築完了です。

ルーティング

Reactはルーティング機能を持たないので、別にプラグインをインストールします。

yarn add react-router-dom

srcディレクトリ直下にRouter.jsxを作成してください。

Router.jsx
import React from 'react';
import { BrowserRouter, Route } from 'react-router-dom';
import Top from '../components/Top';

const Router = () => {
 return (
   <BrowserRouter>
   </BrowserRouter>
 );
};
export default Router;

App.jsにルーティングを読み込ませます。

App.js
import React from 'react';
import Router from './configs/Router';

function App() {
 return (
   <Router />
 );
}

export default App;

画面デザイン

画面のデザインにはMaterial UIというデザインフレームワークを使います。
Reactのプラグインとして提供されているので、yarn addでインストールしてください。

yarn add @material-ui/core

下の画像が出来上がり図です。

Screenshot from 2020-02-16 00-50-08.png

API実装

まずはAPIを実装していきます。
一つのコンポーネント内に含めると可読性が落ちるので、別ファイルに分けてAPI処理を実装します。
実装するAPI処理は、ToDoリスト取得、ToDo作成、ToDoのチェック、ToDo削除の4つです。

src/common/apiディレクトリを作り、その中にtodo.jsを作成してください。

todo.js
const originUrl = 'http://127.0.0.1:8000';

const getTodoList = (() => {
  const url = new URL('/api/todo/', originUrl);
  return new Promise( (resolve, reject) => {
    fetch(url.href)
    .then( res => res.json() )
    .then( json => resolve(json) )
    .catch( () => reject([]) );
  });
});
export default getTodoList;

export const postCreateTodo = (name) => {
  const url = new URL('/api/todo/', originUrl);
  return new Promise( resolve => {
    fetch(url.href, {
      method: 'POST',
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        name: name
      })
    })
    .then( res => res.json() )
    .then( data => resolve(data) );
  });
};

export const patchCheckTodo = ((id, check) => {
  const url = new URL(`/api/todo/${id}/`, originUrl);
  fetch(url.href, {
    method: 'PATCH',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      checked: check
    })
  });
});

export const deleteTodo = ((id) => {
  const url = new URL(`/api/todo/${id}/`, originUrl);
  fetch(url.href, { method: 'DELETE' });
});

コンポーネント実装

次にコンポーネントを実装します。

index.jsx
import React, { useEffect, useState } from 'react';
import Button from '@material-ui/core/Button';
import Box from '@material-ui/core/Box';
import FormGroup from '@material-ui/core/FormGroup';
import FormControlLabel from '@material-ui/core/FormControlLabel';
import Checkbox from '@material-ui/core/Checkbox';
import Container from '@material-ui/core/Container';
import { makeStyles } from '@material-ui/core/styles';
import TextField from '@material-ui/core/TextField';
import getToDoList, { postCreateTodo, patchCheckTodo, deleteTodo } from '../../common/api/todo';

const useStyles = makeStyles(theme => ({
  todoTextField: {
    marginRight: theme.spacing(1)
  }
}));

const Top = () => {
  const classes = useStyles();
  const [todoList, setTodoList] = useState([]);
  const [todo, setTodo] = useState('');

  useEffect(() => {
    (async () => {
      const list = await getToDoList();
      setTodoList(list);
    })();
  }, []);

  const handleCreate = async () => {
    if ( todo === '' || todoList.some( value => todo === value.name ) ) return;
    const createTodoResponse = await postCreateTodo(todo);
    setTodoList(todoList.concat(createTodoResponse));
  };

  const handleSetTodo = (e) => {
    setTodo(e.target.value);
  };

  const handleCheck = (e) => {
    const todoId = e.target.value;
    const checked = e.target.checked;
    const list = todoList.map( (value, index) => {
      if (value.id.toString() === todoId) {
        todoList[index].checked = checked;
      }
      return todoList[index];
    });
    setTodoList(list)
    patchCheckTodo(todoId, checked);
  }

  const handleDelete = (e) => {
    const todoId = e.currentTarget.dataset.id;
    const list = todoList.filter( value => value['id'].toString() !== todoId);
    setTodoList(list);
    deleteTodo(todoId);
  };

  return (
    <Container maxWidth="xs">
      <Box display="flex" justifyContent="space-between" mt={4} mb={4}>
        <TextField className={classes.todoTextField} label="やること" variant="outlined" size="small" onChange={handleSetTodo} />
        <Button variant="contained" color="primary" onClick={handleCreate}>作成</Button>
      </Box>
      <FormGroup>
        {todoList.map((todo, index) => {
          return (
            <Box key={index} display="flex" justifyContent="space-between" mb={1}>
              <FormControlLabel
                control={
                  <Checkbox
                    checked={todo.checked}
                    onChange={handleCheck}
                    value={todo.id}
                    color="primary"
                  />
                }
                label={todo.name}
              />
              <Button variant="contained" color="secondary" data-id={todo.id} onClick={handleDelete}>削除</Button>
            </Box>
          )
        })}
      </FormGroup>
    </Container>
  )
};
export default Top;

最後に

ToDoアプリを一つ作りましたが、この記事の内容だけだとまだ実用はできないので、いずれホスティングに載せるところまでを紹介しようと思います。

誤字脱字や、間違いがあればご連絡ください。
ソースコードをGitHubに上げているので、必要であれば使ってください。

フロントエンド
https://github.com/uichi/todo-frontend

バックエンド
https://github.com/uichi/todo-backend

参考

React公式
Material UI公式
Django公式
Django REST framework公式

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

うんちメーカーを支える技術 ~ 状態遷移はうんちの夢を見るか?

うんちメーカーの誕生

「うんちメーカー」というブラウザゲームを作りました。

ホーム - うんちメーカー no unchi.png

うんちの長さを競い合うゲームで、オンラインのランキング機能があります。
使用した技術はDjango, Vueなどです。
今回はこのうんちメーカーで使っている技術について書いてみたいと思います。

主役はVue

フロントエンドのフレームワークはVueを使いました。
Vueはコンポーネント志向のフレームワークで、SFC(Single File Component)という単位でモジュールを定義できます。
例えば↓のようにです。

<script>
export default {
  data () {
    return {
    }
  }
}
</script>

<template>
  <div class="">Hello, World!</div>
</template>

<style lang="scss" scoped>
</style>

SFCはJavaScript, HTML, CSSを1つのファイルにまとめたコンポーネントで、これらを1つにまとめることで効率よく開発することが出来ます。
↓のようにコンポーネントを利用すると、属性にデータを渡すことができます。

<my-component :my-data="1" />

属性に渡されたデータはSFCではpropsというオブジェクト内の変数に共有されます。
この変数はたとえば↑のmy-dataの値を変更すると、その変更がリアルタイムでSFCにも反映されます。
このpropsの機能を使うことで、状態を持つコンポーネントを簡単に定義することが出来ます。

コンポーネントの状態遷移

ゲームは状態遷移のかたまりだと、今回の開発で思いました。
状態遷移とは、状態を定義した変数をひとつ用意して、その変数の値を次々に変化させて、モジュールなどの振る舞いを変える技術を指します。

たとえば↓のような変数があるとして、

const status = 'first'

この変数は↓のように参照されます。

switch (status) {
case 'first': /* TODO */ break
case 'running': /* TODO */ break 
case 'waiting': /* TODO */ break
}

switch文のcaseに状態に応じた処理を書くことで、状態遷移を実現させることが出来ます。
状態を持つ変数自体は、各状態の処理の中で変更していきます。

先ほどのコンポーネントのpropsにこのような状態を持たせることで、コンポーネントに状態遷移を行わせることが出来ます。
例えば↓のようにです。

<my-component :status="myComponentStatus" />

コンポーネント内では、setIntervalでループを回し、この状態を監視させます。

mounted () {
  this.iid = setInterval(this.update, 16.66)
},

methods: {
  update () {
    switch (this.status) {
    case 'first': /* TODO */ break
    case 'running': /* TODO */ break
    case 'waiting': /* TODO */ break
    }
  },
},

うんちメーカーのオブジェクトは複雑な状態を持っていますが、基本的にはこのような状態遷移で動作しています。

ゲーム開発における状態遷移の有用性

私は状態遷移は文字列のパースなどで学んだのですが、今回の開発でゲームにも応用できることがわかりました。
ゲームは複雑な状態を持っていますが、それらを一度に処理しようとすると高い確率で肛門がパンクします。
しかし、状態という単位にゲーム全体の振る舞いを分割統治することで開発が容易になります。

ゲーム開発における状態遷移の有用性は確かなもので、これを知っていると知らないとでは作れるものが異なってくると思いました。
知らないとクソゲーを作ることになってしまいますが、知っていればうんちメーカーのようなクソゲーを作ることが出来ます。

デザインパターンへの応用

状態を管理するデザインパターンに有名なGoFのStateパターンがありますが、今回はこれは使いませんでした。

うんちメーカーはかなり規模の小さいアプリだったので利用する必要もなかったのですが、規模の大きさによってはこれらのデザインパターンの利用を検証する必要があるかもしれません。

おわりに

ストレスの多い世の中ですが、うんちメーカーでいっぱい出してすっきりしましょう。

以上です。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ブラウザでランダムなQRコードを生成するツールを作った話

サマリ

ブラウザで動作するランダムQRコード発行ツールを作成しました。
screenShot.png

成果物

こちらにアクセスするだけで使えます。ソースコードもOSSでどうぞ。

開発モチベーション

先日作成したQRコードのReaderのパフォーマンステストをしたいなと思い、自動でかつ、いろいろなサイズでQRコードが発行されるツールが欲しくなりました。テスト結果も上々でした↓。

使い方

  1. ツールにアクセスするだけで、OKです。すぐにQRコードが発行されると思います。
  2. 下記の設定が可能です。
    1. リフレッシュレート( 0.2 Hz - 10 Hz)
    2. Min/Max version ( Version 1 - Version 40. QRコードのサイズの事です)
    3. エラー訂正レベル (L/M/Q/H)

SWのポイント

QRコード読み取りライブラリ

こちらの記事を参考に、node-qrcode @oldairを使用することにしました。
使い方もすごく簡単です。

index.html
<script src="https://cdn.jsdelivr.net/npm/qrcode@latest/build/qrcode.min.js"></script>
<canvas id="QRCode"></canvas>
index.js
const QRCodeCanvas = document.getElementById( "QRCode" );

// generateRandomData() はランダムにデータ生成する自前関数
QRCode.toCanvas( QRCodeCanvas, generateRandomData(), {
 margin: 2,
 scale: 3,
 errorCorrectionLevel: getErrCorrectionLevel()
} );

とするだけで、canvasへQRコードを描画してくれます。

所感と考察

  • 今回の目的であるパフォーマンス測定には十分使えたので大変良かった。また、これにより読み取りツール側のバグらしき挙動も見つけた。
    • 特大サイズ(Level19以上)になってくると読めない、特定のデータサイズ(特に各レベルのデータ最大境界値付近)で生成したQRが読めないなど。
  • 図らずも、Generative artのようなものが産まれた
  • 今回のQRコード生成をきっかけにいくつかのアイディアを思いついたので、時間を見つけて検証していきたい。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

無理してReduxを使わずに、Custom Hookで楽に状態を管理する

はじめに

Reduxを使わなくても、ReactでSPAをつくることは可能です。

画面の初期化時に一度だけ叩くapiをreduxで管理したことはありませんか?
apiを一度叩いて画面に表示するだけなのに、reducer/action/actionCreatorを作ってconnectをしてませんか?
Reduxを無理して使っていませんか?

自分が参画したWebアプリケーション開発では、Reactが採用される場合、常にReduxもセットになっていました。

Reduxは要件によってはオーバーエンジニアリングかもなーと感じることが多いので、改めてどんな時にReduxが有効かのか自分なりに考えてみました。

SPAの設計で大事なこと

自分がフロントエンドの設計で大事だと思うのは、「ViewとStateの分離」です。複雑なアプリケーションでこれが出来ていないと可動性が低く変更しづらいです。

Viewはコンポーネントと呼ばれたりしますが見た目の部分です。
Stateはコンポーネントの状態です。本記事では、状態を変化させるロジックも含めStateと呼びます。

個人的には以下を実現することが重要です。

  • ViewとStateが密結合にならず、コードの可読性を保てる。

  • ユーザーが複雑な操作をしても、アプリケーションの状態が予測可能になっている。

どんな時にReduxが有効か

Reduxが実現しているFluxアーキテクチャは、状態をStoreに、ロジックをAction/ActionCreatorにすることで、アプリケーションの状態を予測可能にしています。

Reduxを使うことで「ViewとStateの分離」は達成出来ます。
ですが、Reduxを使わなくても「ViewとStateの分離」は達成出来ます。
Redux辛いみたいな話はわざわざReduxで解決しなくても良い問題をReduxの冗長な処理を書いて解決しているからだと思います。

では、Reduxを使わないと辛い時はどんな時でしょうか?
自分は以下だと考えます。

  • コンポーネントの階層が深い
  • 画面で発火するイベントが多い

componentの階層が深い

propsのバケツリレーと呼ばれる問題です。prop drillingの方が有名でしょうか。

ey25z0hvmy31xiiqqwgq.png
出典:Learn React Context in 5 Minutes - A Beginner's Tutorial

Reactではコンポーネントにデータを渡す時にpropsを使います。
コンポーネントの階層が深いと、親コンポーネントから子コンポーネント、さらに孫コンポーネントと渡したいコンポーネントまで延々とpropsをバケツリレーのように渡していく必要があります。
このバケツリレーを省略するためにReduxを使うのは有用です。
自分は3回を超えたらRedux導入を考えます。

画面で発火するイベントが多い

1つの画面でユーザーがやれることが多い画面はReduxが有効だと判断します。
例えば、SlackやGoogle Carenderは1つの画面で様々なイベントを発火でき、リアルタイムにUIを変更しつつapiリクエストを飛ばします。

様々なイベントを高速でハンドリングする場合は、アプリケーションの状態が予測しづらくなるのでReduxを使います。
Reduxはdevtoolが強力でstoreに対する変更を全て記録してくれるのでデバッグもしやすいです。

Custom Hookで「ViewとStateの分離」を実現する

一覧画面等で、画面初期化時に一度だけfetchしたデータを表示するのにReduxを使うのはオーバーエンジニアリングです。

自分はReactのCustom Hookを使います。

以下はユーザー情報を表示するだけの架空の画面の例です。

Custom HookにState(状態とロジック)を切り出します。

Custom HookはUtil的な使い方だけでなく、ドメイン固有の状態とロジックを管理するのにも有効です。

useUser.ts
export const useUser = (): [
  User | undefined,
  () => void
] => {
  const [user, setUser] = React.useState<User | undefined>(undefined);
  const [errorMessage, setErrorMessage] = React.useState<string | undefined>(undefined);

  const fetchUser = async (): Promise<void> => {
    try {
      const res = await axios.get<User>('/api/user');
      setUser(res.data);
    } catch (e) {
      setError(e.response.date.message)
    }
  };

  return [
    user,
    errorMessage,
    fetchUser,
  ];
};

stateとstateを操作するロジックをuseUserというCustom Hookに分離しています。
Reduxのreducer/action/actionCreatorをまとめたduckに近いですが、少ない記述量で「ViewとStateの分離」が出来ています。

container.ts
import { useUser } from 'useUser';

export const UserContainer: React.FC = () => {
  const [user, errorMessage, fetchUser] = useUser();
  React.useEffect(() => {
    fetchUser();
  }, [fetchUser]);

  return (
    <UserComponent
      user={user}
      errorMessage={errorMessage}
    />
  );
};

Custom Hookは任意のcontainer/component層でデータとロジックを注入できます。
自分はいつもcontainer層で注入します。

Reduxを使うならアプリケーション全体で、Stateの管理方法をReduxで統一したいと思ってた時もありましたが、最近は要件によってCustom HookとReduxを使い分けるのが良いかなと考えています。

Custom Hookの中でapiリクエストをする場合、middlewareを気にしなくて良いです。

今のところ上手くいってますが、同じ画面やドメインでCustom HookとReduxの両方を使わないようにしています。

まとめ

Reactでは「ViewとStateの分離」をいくつかの方法で実現出来ます。

とりえあずReduxにすると大きく外すことがないですが学習コストやコード量が大きいので、Reduxでオーバーエンジニアリングになりそうな時はCustom Hookがおすすめです。

参考

JavaScript: Reduxが必要なとき/不要なとき(翻訳)

Learn React Context in 5 Minutes - A Beginner's Tutorial

Fluxとはなんなのか

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue.js初心者がチャットアプリケーションの開発を通じて勉強した話

はじめに

Vue.js初心者がチャットアプリケーションを作りながら勉強した話です。
題材はBuild a Real-time Chat App with Pusher and Vue.js(*1)です。(ありがとうございます)

このアプリケーション全てを理解するのは初心者には大変だったので
細かい部分の理解は置いておき、全体の理解に努めたつもりです。

完成品はこちら:hugging:
image.png
↓ログインするとチャット画面に遷移
image.png

ちなみに私のスペックは

  • Vue.jsの知識は全くない
  • 開発経験はある(Reactとか)

という感じです。

(*1) 一部日本語訳と私のコメントですm(_ _)m

手順

では、本題です。

製作物の確認(1/9)

Slackのようなチャットアプリケーションを作ります。
要件は

  • 複数の部屋がある
  • ルームメンバーをリストし、ログイン状態を表示する
  • 他のユーザが入力開始するタイミングを検出し表示する

です。(本格的ですね:flushed:
バックエンドはChatKitというサービスを利用し、フロントエンドをVue.jsで実装します。

事前準備として、Vue CLIをグローバルにインストールしておきます。
(Nodeがマシンにインストールされている必要があります)

$ npm install -g @vue/cli

バックエンドの準備(2/9)

PusherのChatKitというサービスを利用するので、まずは右上からサインアップします。
(Githubでサインアップしてエラーが出た場合、 パスワードのリセットで解消する可能性があります)

サインインするとポップアップが出ますが、一旦スキップでも大丈夫です。
image.png

「CHATKIT」を選び、「CREATE」を押します。
image.png
INSTANCE NAMEは VueChatTut でOKです。

インスタンスができたら「Console」タブの「CREATE USER」からユーザを作成します。
image.png

"John"(User Identifier)と“John Wick"(Display Name)とします。
同じ要領で

  • salt, Evelyn Salt
  • hunt, Ethan Hunt

というユーザも作りましょう。

続いて部屋を作り、ユーザを割り当てます。
「Create and join a room」をクリックし、"John Wick"(Select a user to create the room)を選択、"General"(Room Name)と記入し「CREATE ROOM」をクリックします。

image.png

部屋ができたら「Add user to room」でsaltとhuntを追加します。
同様に

  • Weapons (john, salt)
  • Combat (john, hunt)

という部屋も作り、メンバーを追加しましょう。

次に、「Add message to room」からテストメッセージを送っておきます。例えば、"General"で"John Wick"(Select a message author)を選択、"test"(Messge)と記入し「CREATE MESSAGE」をクリックします。

プロジェクトの作成(3/9)

Vue CLIでプロジェクトを作ります。何個か質問されますが、全てEnterしました。

$ vue create vue-chatkit

不要なファイルの削除と、今回使うファイルの作成を行います。

$ mkdir src/assets/css
$ mkdir src/store
$ mkdir src/views

$ touch src/assets/css/{loading.css,loading-btn.css}
# ↑はhttps://github.com/sitepoint-editors/vue-chatkit/tree/master/src/assets/cssから内容をコピペしておきます

$ touch src/components/{ChatNavBar.vue,LoginForm.vue,MessageForm.vue,MessageList.vue,RoomList.vue,UserList.vue}
$ touch src/store/{actions.js,index.js,mutations.js}
$ touch src/views/{ChatDashboard.vue,Login.vue}
$ touch src/{chatkit.js,router.js}

$ rm src/components/HelloWorld.vue

srcディレクトリ以下はこんな感じになります。

$ tree -L 2 --matchdirs src
src
├── App.vue ←大元のビュー
├── assets ←CSSや画像
│   ├── css
│   └── logo.png
├── chatkit.js ←ChatKitと接続する
├── components ←コンポーネント。コンポーネントを集めてビューを作る
│   ├── ChatNavBar.vue
│   ├── LoginForm.vue
│   ├── MessageForm.vue
│   ├── MessageList.vue
│   ├── RoomList.vue
│   └── UserList.vue
├── main.js ←Vueアプリケーションを起動する
├── router.js ←ルーティング
├── store ←状態管理
│   ├── actions.js
│   ├── index.js
│   └── mutations.js
└── views ←ビュー
    ├── ChatDashboard.vue
    └── Login.vue

5 directories, 16 files

次に、依存関係をインストールします。

$ npm i @pusher/chatkit-client bootstrap-vue moment vue-chat-scroll vuex-persist vue-router vuex
  • @pusher/chatkit-client、ChatKitのリアルタイムクライアントインターフェイス
  • bootstrap-vue、CSSフレームワーク
  • moment、日付と時刻のフォーマットユーティリティ
  • vue-chat-scroll、新しいコンテンツが追加されると自動的に下にスクロールする
  • vuex、Vue.jsアプリケーションのための状態管理ライブラリ。Reactで言うReduxのイメージ
  • vuex-persist、ブラウザのローカルストレージにVuexの状態を保存する
  • vue-router、Vue.jsのルータ

では、Vue.jsプロジェクトの設定をしましょう。src/main.jsを開き、以下のように更新します。

src/main.js
import Vue from 'vue'
import BootstrapVue from 'bootstrap-vue'
import VueChatScroll from 'vue-chat-scroll'

import App from './App.vue'
import router from './router'
import store from './store/index'

import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap-vue/dist/bootstrap-vue.css'
import './assets/css/loading.css'
import './assets/css/loading-btn.css'

Vue.config.productionTip = false // trueにすると開発者向けメッセージがコンソールに出る
Vue.use(BootstrapVue) // ライブラリを利用する宣言
Vue.use(VueChatScroll)

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app') // Vueインスタンスを作成し、#appにマウント(idがappであるDOMを置換)
// 詳しく知りたい方はネットで調べてみてください。私のような初心者の内はおまじないという認識で良さそう

src/router.jsを以下のように更新します。

src/router.js
import Vue from 'vue'
import Router from 'vue-router'
import Login from './views/Login.vue'
import ChatDashboard from './views/ChatDashboard.vue'

Vue.use(Router)

export default new Router({
  mode: 'history', // ページのリロードなしにURL遷移を実現する(SPAのためという理解)
  base: process.env.BASE_URL,
  routes: [
// 「/」というパスのルートをLoginコンポーネントにマップする
    {
      path: '/',
      name: 'login',
      component: Login
    },
    {
      path: '/chat',
      name: 'chat',
      component: ChatDashboard,
    }
  ]
})

src/store/index.jsを更新します。

src/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import VuexPersistence from 'vuex-persist'
import mutations from './mutations'
import actions from './actions'

Vue.use(Vuex)

const debug = process.env.NODE_ENV !== 'production'

const vuexLocal = new VuexPersistence({
  storage: window.localStorage
})

export default new Vuex.Store({
  state: {
  }, // ストアの状態
  mutations, // ストアの状態を変更するメソッド群(ミューテーションをコミットすることで変更できる)
  actions, // ミューテーションをコミットするメソッド群
  getters: {
  }, // ストアの状態を加工して取得するメソッド群
  plugins: [vuexLocal.plugin], // LocalStorageを使うよ
  strict: debug // 開発時に状態変更をデバッキングツールで追跡できるようにする(ChromeかFireFoxの方は「Vue.js devtools」という拡張機能が便利)
})

UIインターフェイスの構築(4/9)

ここからちょっとハードになって行きますよ〜まずはsrc/App.vueを更新します。

src/App.vue
<template>
  <div id="app">
    <router-view/> // 先ほどのrouter.jsによってマッチしたコンポーネントが描画される
  </div>
</template>

次に、src/store/index.jsのstateとgettersセクションを更新します。
loadingで「CSSローダーを実行する必要があるか」やerrorで「エラー情報」を管理していますが、一個一個分かっていなくてOKです。

// ...
state: {
  loading: false,
  sending: false,
  error: null,
  user: [],
  reconnect: false,
  activeRoom: null,
  rooms: [],
  users: [],
  messages: [],
  userTyping: null
},
getters: {
  hasError: state => state.error ? true : false
},
// ...

ログイン画面src/view/Loing.vueを作ります。構成はこんな感じで、フォームがコンポーネントとなっています。
image.png

src/view/Loing.vue
<template> // テンプレートはtemplateタグで囲む
  <div class="login">
// b-xxはbootstrapのコンポーネント
    <b-jumbotron  header="Vue.js Chat"
                  lead="Powered by Chatkit SDK and Bootstrap-Vue"
                  bg-variant="info"
                  text-variant="white">
      <p>For more information visit website</p>
      <b-btn target="_blank" href="https://pusher.com/chatkit">More Info</b-btn>
    </b-jumbotron>
    <b-container>
      <b-row>
        <b-col lg="4" md="3"></b-col>
        <b-col lg="4" md="6">
          <LoginForm />
        </b-col>
        <b-col lg="4" md="3"></b-col>
      </b-row>
    </b-container>
  </div>
</template>

<script>
import LoginForm from '@/components/LoginForm.vue'

export default {
  name: 'login',
  components: {
// Loginのテンプレート内でLoginFormを使えるようにする
    LoginForm
  }
}
</script>

ログイン画面で利用するコンポーネントsrc/view/LoginForm.vueを更新します。

src/view/LoginForm.vue
<template>
  <div class="login-form">
    <h5 class="text-center">Chat Login</h5>
    <hr>
// ↓preventDefaultを実行し、onSubmitメソッドを呼ぶ
    <b-form @submit.prevent="onSubmit">
 // ↓showの頭にコロンをつけているので、値としてscriptタグ内のhasErrorという算出プロパティを使える。また、変数を表示する場合は{{}}で囲う
      <b-alert variant="danger" :show="hasError">{{ error }} </b-alert>

      <b-form-group id="userInputGroup"
                    label="User Name"
                    label-for="userInput">
        <b-form-input id="userInput"
                      type="text"
                      placeholder="Enter user name"
// ↓双方向データバインディングを作成する。入力に応じてuserIdが更新される
                      v-model="userId"
                      autocomplete="off"
                      :disabled="loading"
                      required>
        </b-form-input>
      </b-form-group>

      <b-button type="submit"
                variant="primary"
                class="ld-ext-right"
                v-bind:class="{ running: loading }" // v-bind:classでクラスを動的に切り替えられる。loadingがtrueのときrunningクラスをつける
                :disabled="isValid">
                Login <div class="ld ld-ring ld-spin"></div>
      </b-button>
    </b-form>
  </div>
</template>

<script>
import { mapState, mapGetters } from 'vuex'

export default {
  name: 'login-form',
  data() { // データ定義
    return {
      userId: '',
    }
  },
  computed: { // 算出プロパティ。テンプレートからロジックを切り出せる
    isValid: function() {
      const result = this.userId.length < 3;
      return result ? result : this.loading
    },
    ...mapState([
      'loading',
      'error'
    ]), // stateを返す
    ...mapGetters([
      'hasError'
    ]) // getterの評価後の値を返す
  }
}
</script>

ここまで書くと表示確認ができます:blush:
npm run serve でVue devサーバを起動し、http://localhost:8080 を開いてみましょう。

image.png

続いて、チャット画面src/view/ChatDashBoard.vueを作ります。
チャット画面は

  • ChatNavBar、ナビゲーションバー
  • RoomList、ログインしたユーザがアクセスできる部屋の一覧表示
  • UserList、選択したルームのメンバーをリスト
  • MessageList、選択したルームに投稿されたメッセージをリスト
  • MessageForm、選択したルームにメッセージを送信するためのフォーム

の5つのコンポーネントで構成されています。
image.png

src/view/ChatDashBoard.vue
<template>
  <div class="chat-dashboard">
    <ChatNavBar />
    <b-container fluid class="ld-over" v-bind:class="{ running: loading }">
      <div class="ld ld-ring ld-spin"></div>
      <b-row>
        <b-col cols="2">
          <RoomList />
        </b-col>

        <b-col cols="8">
          <b-row>
            <b-col id="chat-content">
              <MessageList />
            </b-col>
          </b-row>
          <b-row>
            <b-col>
              <MessageForm />
            </b-col>
          </b-row>
        </b-col>

        <b-col cols="2">
          <UserList />
        </b-col>
      </b-row>
    </b-container>
  </div>
</template>

<script>
import ChatNavBar from '@/components/ChatNavBar.vue'
import RoomList from '@/components/RoomList.vue'
import MessageList from '@/components/MessageList.vue'
import MessageForm from '@/components/MessageForm.vue'
import UserList from '@/components/UserList.vue'
import { mapState } from 'vuex';

export default {
  name: 'Chat',
  components: {
    ChatNavBar,
    RoomList,
    UserList,
    MessageList,
    MessageForm
  },
  computed: {
    ...mapState([
      'loading'
    ])
  }
}
</script>

全てのコンポーネントを表示するため、ボイラープレートコードを挿入します。
ここはコピペで大丈夫です。

src/view/ChatNavBar.vue
<template>
  <b-navbar id="chat-navbar" toggleable="md" type="dark" variant="info">
    <b-navbar-brand href="#">
      Vue Chat
    </b-navbar-brand>
    <b-navbar-nav class="ml-auto">
      <b-nav-text>{{ user.name }} | </b-nav-text>
      <b-nav-item href="#" active>Logout</b-nav-item>
    </b-navbar-nav>
  </b-navbar>
</template>

<script>
import { mapState } from 'vuex'

export default {
  name: 'ChatNavBar',
  computed: {
    ...mapState([
      'user',
    ])
  },
}
</script>

<style>
  #chat-navbar {
    margin-bottom: 15px;
  }
</style>
src/components/RoomList.vue
<template>
  <div class="room-list">
    <h4>Channels</h4>
    <hr>
    <b-list-group v-if="activeRoom">
      <b-list-group-item v-for="room in rooms"
                        :key="room.name"
                        :active="activeRoom.id === room.id"
                        href="#"
                        @click="onChange(room)">
        # {{ room.name }}
      </b-list-group-item>
    </b-list-group>
  </div>
</template>

<script>
import { mapState } from 'vuex'

export default {
  name: 'RoomList',
  computed: {
    ...mapState([
      'rooms',
      'activeRoom'
    ]),
  }
}
</script>
src/components/UserList.vue
<template>
  <div class="user-list">
    <h4>Members</h4>
    <hr>
    <b-list-group>
      <b-list-group-item v-for="user in users" :key="user.username">
        {{ user.name }}
        <b-badge v-if="user.presence"
        :variant="statusColor(user.presence)"
        pill>
        {{ user.presence }}</b-badge>
      </b-list-group-item>
    </b-list-group>
  </div>
</template>

<script>
import { mapState } from 'vuex'

export default {
  name: 'user-list',
  computed: {
    ...mapState([
      'loading',
      'users'
    ])
  },
  methods: {
    statusColor(status) {
      return status === 'online' ? 'success' : 'warning'
    }
  }
}
</script>
src/components/MessageList.vue
<template>
  <div class="message-list">
    <h4>Messages</h4>
    <hr>
    <div id="chat-messages" class="message-group" v-chat-scroll="{smooth: true}">
      <div class="message" v-for="(message, index) in messages" :key="index">
        <div class="clearfix">
          <h4 class="message-title">{{ message.name }}</h4>
          <small class="text-muted float-right">@{{ message.username }}</small>
        </div>
        <p class="message-text">
          {{ message.text }}
        </p>
        <div class="clearfix">
          <small class="text-muted float-right">{{ message.date }}</small>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import { mapState } from 'vuex'

export default {
  name: 'message-list',
  computed: {
    ...mapState([
      'messages',
    ])
  }
}
</script>

<style>
.message-list {
  margin-bottom: 15px;
  padding-right: 15px;
}
.message-group {
  height: 65vh !important;
  overflow-y: scroll;
}
.message {
  border: 1px solid lightblue;
  border-radius: 4px;
  padding: 10px;
  margin-bottom: 15px;
}
.message-title {
  font-size: 1rem;
  display:inline;
}
.message-text {
  color: gray;
  margin-bottom: 0;
}
.user-typing {
  height: 1rem;
}
</style>
src/components/MessageForm.vue
<template>
  <div class="message-form ld-over">
    <small class="text-muted">@{{ user.username }}</small>
    <b-form @submit.prevent="onSubmit" class="ld-over" v-bind:class="{ running: sending }">
      <div class="ld ld-ring ld-spin"></div>
      <b-alert variant="danger" :show="hasError">{{ error }} </b-alert>
      <b-form-group>
        <b-form-input id="message-input"
                      type="text"
                      v-model="message"
                      placeholder="Enter Message"
                      autocomplete="off"
                      required>
        </b-form-input>
      </b-form-group>
      <div class="clearfix">
        <b-button type="submit" variant="primary" class="float-right">
          Send
        </b-button>
      </div>
    </b-form>
  </div>
</template>

<script>
import { mapState, mapGetters } from 'vuex'

export default {
  name: 'message-form',
  data() {
    return {
      message: ''
    }
  },
  computed: {
    ...mapState([
      'user',
      'sending',
      'error',
      'activeRoom'
    ]),
    ...mapGetters([
      'hasError'
    ])
  }
}
</script>

かなりそれっぽくなりましたね。
image.png

データがない状態なので、stateにモックデータを入れてみます。

src/store/index.js
// ...
state: {
  loading: false,
  sending: false,
  error: 'Relax! This is just a drill error message',
  user: {
    username: 'Jack',
    name: 'Jack Sparrow'
  },
  reconnect: false,
  activeRoom: {
    id: '124'
  },
  rooms: [
    {
      id: '123',
      name: 'Ships'
    },
    {
      id: '124',
      name: 'Treasure'
    }
  ],
  users: [
    {
      username: 'Jack',
      name: 'Jack Sparrow',
      presence: 'online'
    },
    {
      username: 'Barbossa',
      name: 'Hector Barbossa',
      presence: 'offline'
    }
  ],
  messages: [
    {
      username: 'Jack',
      date: '11/12/1644',
      text: 'Not all treasure is silver and gold mate'
    },
    {
      username: 'Jack',
      date: '12/12/1644',
      text: 'If you were waiting for the opportune moment, that was it'
    },
    {
      username: 'Hector',
      date: '12/12/1644',
      text: 'You know Jack, I thought I had you figured out'
    }
  ],
  userTyping: null
},
// ...

いい感じです。
image.png

stateは元に戻しておきます。

/src/store/index.js
// ...
state: {
  loading: false,
  sending: false,
  error: null,
  user: null,
  reconnect: false,
  activeRoom: null,
  rooms: [],
  users: [],
  messages: [],
  userTyping: null
}
// ...

パスワードレス認証(5/9) ※公開するサービスでは適切で安全な認証システムにしましょう

プロジェクトのルートに以下のファイルを作成し、環境変数を設定します。

.env.local
VUE_APP_INSTANCE_LOCATOR=
VUE_APP_TOKEN_URL=
VUE_APP_MESSAGE_LIMIT=10

ChatKitの「Credentials」タブに移動し、「TEST TOKEN PROVIDER」の「ENABLED?」にチェックをつけます。
そして、
VUE_APP_INSTANCE_LOCATORに「Instance Locator」
VUE_APP_TOKEN_URLに「Your Test Token Provider Endpoint」
の値を書きます。

VUE_APP_MESSAGE_LIMITは取得するメッセージの数を制限しているだけです。

次に、src/chatkit.jsに行き、ChatKitへ接続する土台を作ります。

src/chatkit.js
import { ChatManager, TokenProvider } from '@pusher/chatkit-client'

const INSTANCE_LOCATOR = process.env.VUE_APP_INSTANCE_LOCATOR;
const TOKEN_URL = process.env.VUE_APP_TOKEN_URL;
const MESSAGE_LIMIT = Number(process.env.VUE_APP_MESSAGE_LIMIT) || 10;

let currentUser = null;
let activeRoom = null;

async function connectUser(userId) {
  const chatManager = new ChatManager({
    instanceLocator: INSTANCE_LOCATOR,
    tokenProvider: new TokenProvider({ url: TOKEN_URL }),
    userId
  });
  currentUser = await chatManager.connect();
  return currentUser;
}

export default {
  connectUser
}

続いて、stateにデータをセットする処理を追加します。
長いですが、ただのセッターなのでがっつり見る必要はありません。

src/store/mutations
export default {
  setError(state, error) {
    state.error = error;
  },
  setLoading(state, loading) {
    state.loading = loading;
  },
  setUser(state, user) {
    state.user = user;
  },
  setReconnect(state, reconnect) {
    state.reconnect = reconnect;
  },
  setActiveRoom(state, roomId) {
    state.activeRoom = roomId;
  },
  setRooms(state, rooms) {
    state.rooms = rooms
  },
  setUsers(state, users) {
    state.users = users
  },
 clearChatRoom(state) {
    state.users = [];
    state.messages = [];
  },
  setMessages(state, messages) {
    state.messages = messages
  },
  addMessage(state, message) {
    state.messages.push(message)
  },
  setSending(state, status) {
    state.sending = status
  },
  setUserTyping(state, userId) {
    state.userTyping = userId
  },
  reset(state) {
    state.error = null;
    state.users = [];
    state.messages = [];
    state.rooms = [];
    state.user = null
  }
}

次に、src/store/actions.jsを更新します。

src/store/actions.js
import chatkit from '../chatkit';

// Helper function for displaying error messages
function handleError(commit, error) {
  const message = error.message || error.info.error_description;
  commit('setError', message);
}

export default {
// ChatKitに接続し、stateを更新する
  async login({ commit, state }, userId) {
    try {
      commit('setError', '');
      commit('setLoading', true);
      // Connect user to ChatKit service
      const currentUser = await chatkit.connectUser(userId);
      commit('setUser', {
        username: currentUser.id,
        name: currentUser.name
      });
      commit('setReconnect', false);

      // Test state.user
      console.log(state.user);
    } catch (error) {
      handleError(commit, error)
    } finally {
      commit('setLoading', false);
    }
  }
}

今、作ったメソッドをsrc/components/LoginForm.vueから実行します。

src/components/LoginForm.vue
import { mapState, mapGetters, mapActions } from 'vuex'

//...
export default {
  //...
  methods: {
    ...mapActions([
      'login'
    ]),
    async onSubmit() {
      const result = await this.login(this.userId);
      if(result) {
// ログインできたらチャット画面に遷移する。loginメソッドがbooleanを返していないからまだ動かない
        this.$router.push('chat');
      }
    }
  }
}

.env.localをロードするために、Vueサーバを再起動します。
間違ったユーザ名を入力するとエラーになることが確認できます。

image.png

チャットに参加する(6/9)

チャット入室時にRoomListUserListが反映されるようsrc/chatkit.jsを更新します。

src/chatkit.js
//...
import moment from 'moment'
import store from './store/index'

//...
function setMembers() {
  const members = activeRoom.users.map(user => ({
    username: user.id,
    name: user.name,
    presence: user.presence.state
  }));
  store.commit('setUsers', members);
}

async function subscribeToRoom(roomId) {
  store.commit('clearChatRoom');
  activeRoom = await currentUser.subscribeToRoom({
    roomId,
    messageLimit: MESSAGE_LIMIT,
    hooks: {
      onMessage: message => {
        store.commit('addMessage', {
          name: message.sender.name,
          username: message.senderId,
          text: message.text,
          date: moment(message.createdAt).format('h:mm:ss a D-MM-YYYY')
        });
      },
      onPresenceChanged: () => {
        setMembers();
      },
      onUserStartedTyping: user => {
        store.commit('setUserTyping', user.id)
      },
      onUserStoppedTyping: () => {
        store.commit('setUserTyping', null)
      }
    }
  });
  setMembers();
  return activeRoom;
}

export default {
  connectUser,
  subscribeToRoom
}

ChatKitサービスのイベントハンドラの意味はこちらです。

  • onMessage、メッセージを受信する
  • onPresenceChanged、ユーザがログインまたはログアウトした時にイベントを受け取る
  • onUserStartedTyping、ユーザが入力しているイベントを受け取る
  • onUserStopperTyping、ユーザが入力を停止したというイベントを受け取る

ログイン後、チャット画面にリダイレクトするよう更新します。

src/store/actions.js
//...
try {
  //... (place right after the `setUser` commit statement)
  // Save list of user's rooms in store
  const rooms = currentUser.rooms.map(room => ({
    id: room.id,
    name: room.name
  }))
  commit('setRooms', rooms);

  // Subscribe user to a room
  const activeRoom = state.activeRoom || rooms[0]; // pick last used room, or the first one
  commit('setActiveRoom', {
    id: activeRoom.id,
    name: activeRoom.name
  });
  await chatkit.subscribeToRoom(activeRoom.id);

  return true;
} catch (error) {
  //...
}

これで正しいユーザ名でログインした時チャット画面に遷移するようになりました。

部屋を変更する(7/9)

RoomListのクリックで部屋を変更できるようにします。
まず、src/store/actions.jsloginメソッドの後にこちらを追加します。
stateのactiveRoomを更新するものです。

src/store/actions.js
async changeRoom({ commit }, roomId) {
  try {
    const { id, name } = await chatkit.subscribeToRoom(roomId);
    commit('setActiveRoom', { id, name });
  } catch (error) {
    handleError(commit, error)
  }
},

次に、src/componenents/RoomList.vueのscriptタグ内に以下を追加します。
すでにルーム名をクリックするとonChangeを呼び出す実装はされている@click="onChange(room)"ので、先ほど作ったchangeRoomonChangeメソッドから実行します。

src/componenents/RoomList.vue
import { mapState, mapActions } from 'vuex'
//...
export default {
  //...
  methods: {
    ...mapActions([
      'changeRoom'
    ]),
    onChange(room) {
      this.changeRoom(room.id)
    }
  }
}

ブラウザで部屋を切り替えると、MessageListUserListが更新されることが確認できます:ok_woman:

もしCannot read property 'subscribeToRoom' of nullというエラーが出たらログインし直してみてください。
次のセクションで対応します。

ページの更新後のユーザの再接続(8/9)

前セクションでご紹介したエラーはページをリロードした際に、ChatKitサーバに接続する参照がnullにリセットされるために起こるものです。
これを修正するには再接続操作を実行する必要があります。

src/components/ChatNavBar.vueのscriptタグ内を以下のように更新します。

src/components/ChatNavBar.vue
<script>
import { mapState, mapActions, mapMutations } from 'vuex'

export default {
  name: 'ChatNavBar',
  computed: {
    ...mapState([
      'user',
      'reconnect'
    ])
  },
  methods: {
    ...mapActions([
      'logout',
      'login'
    ]),
    ...mapMutations([
      'setReconnect'
    ]),
    onLogout() {
      this.$router.push({ path: '/' });
      this.logout();
    },
    unload() {
      if(this.user.username) { // User hasn't logged out
        this.setReconnect(true);
      }
    }
  },
  mounted() {
    window.addEventListener('beforeunload', this.unload);
    if(this.reconnect) {
      this.login(this.user.username);
    }
  }
}
</script>
  1. upload。ページの更新が発生すると呼び出される。user.usernameが設定されている=ログインしていると、stateのreconnectにtrueを設定する。
  2. mounted。ChatNavBarビューのレンダリングが完了するたびに呼び出される。最初に、ページがアンロードされる直前に呼び出されるイベントリスナーにハンドラーを割り当てる。また、stateのreconnectがtrueであれば、ログイン手順が実行され、ChatKitサービスに再接続される。

また、ログアウト機能も追加されていますが、これは次のセクションで。

これらの更新を行った後、ページをリロードして部屋を切り替えてもエラーにならないはずです:ok_woman:

メッセージの送信、ユーザの入力の検出、ログアウト(9/9)

ラストです。src/chatkit.jsを更新します。

src/chatkit.js
//...
async function sendMessage(text) {
  const messageId = await currentUser.sendMessage({
    text,
    roomId: activeRoom.id
  });
  return messageId;
}

export function isTyping(roomId) {
  currentUser.isTypingIn({ roomId });
}

function disconnectUser() {
  currentUser.disconnect();
}

export default {
  connectUser,
  subscribeToRoom,
  sendMessage,
  disconnectUser
}

src/atore/actions.jsに移動し、changeRoomメソッドの直後に以下を挿入します。

src/atore/actions.js
async sendMessage({ commit }, message) {
  try {
    commit('setError', '');
    commit('setSending', true);
    const messageId = await chatkit.sendMessage(message);
    return messageId;
  } catch (error) {
    handleError(commit, error)
  } finally {
    commit('setSending', false);
  }
},
async logout({ commit }) {
  commit('reset');
  chatkit.disconnectUser();
  window.localStorage.clear();
}

logoutメソッドでは、セキュリティのためストアのリセットとローカルストレージのクリアも行います。

次に、src/components/MessageForm.vueのinputディレクティブを更新します。

src/components/MessageForm.vue
<b-form-input id="message-input"
              type="text"
              v-model="message"
              @input="isTyping"
              placeholder="Enter Message"
              autocomplete="off"
              required>
</b-form-input>

src/components/MessageForm.vueのscriptタグ内を以下の通り更新します。

src/components/MessageForm.vue
<script>
import { mapActions, mapState, mapGetters } from 'vuex'
import { isTyping } from '../chatkit.js'

export default {
  name: 'message-form',
  data() {
    return {
      message: ''
    }
  },
  computed: {
    ...mapState([
      'user',
      'sending',
      'error',
      'activeRoom'
    ]),
    ...mapGetters([
      'hasError'
    ])
  },
  methods: {
    ...mapActions([
      'sendMessage',
    ]),
    async onSubmit() {
      const result = await this.sendMessage(this.message);
      if(result) {
        this.message = '';
      }
    },
     async isTyping() {
      await isTyping(this.activeRoom.id);
    }
  }
}
</script>

そして、src/MessageList.vueを更新します。

src/MessageList.vue
import { mapState } from 'vuex'

export default {
  name: 'message-list',
  computed: {
    ...mapState([
      'messages',
      'userTyping'
    ])
  }
}

メッセージ送信機能が動作するはずです。

別のユーザが入力しているという通知を表示するには、src/MessageList.vuemessage-group直後にこのスニペットを追加します。

src/MessageList.vue
<div class="user-typing">
  <small class="text-muted" v-if="userTyping">@{{ userTyping }} is typing....</small>
</div>

最後に、src/components/ChatNavBar.vueにログアウト処理をつけます。

src/components/ChatNavBar.vue
 <b-nav-item href="#" @click="onLogout" active>Logout</b-nav-item>

完成です٩( 'ω' )و

まとめ

知識ない状態でアプリケーションをイチから作るのは(記事があるとは言え)不安もありましたが、この辺が良かったのでまたやりたいと思います:v:

  • ドキュメントをただ読むより楽しい!
  • ファイル構成や、ライフサイクル、機能といったVue.jsの特徴を知ることができた
  • チャットのライブラリやUIライブラリなど実用的なツールを知ることができた
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue.jsでTodoアプリ作ってみよう!

今回はVue.jsでTodoアプリを作ってみます
早速やっていきましょう~!
なお超基礎的な知識として下記の記事に書いてることを前提でお話します!
https://qiita.com/takepon_it/items/f89e0e3023a3070dbce6

1.画面を作ろう

今回はTodoリストの機能を作りたいだけなので見た目は簡素な物でいきます!
https://unpkg.com/vue
を読み込むのを忘れないでください!

index.html
<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="utf-8">
        <title>Todoアプリ</title>
        <link rel="stylesheet" href="style.css">
        <script src="https://unpkg.com/vue"></script>

    </head>
    <body>
        <div class="container">
            <h1>Todoアプリ</h1>
        </div>
        <div id="app">
            <div>
                <input type="text" placeholder="テキスト入力してね">
                <button type="button">追加</button>
                <ul>
                </ul>
            </div>
        </div>
        <script src="main.js"></script>
    </body>
</html>

image.png

こんな感じですね!

2.コンポーネントを作ろう!

早速タスクを追加する機能を追加していきましょう!

まずコンポーネントを定義していきます
コンポーネントはVue.componentを使うことで定義することできます!

ToDo の要素を todo-item というコンポーネントとして定義してみましょう!
それと合わせて既にここでタスクを追加した際のhtmlも作っておきましょう!
テンプレートをtemplateオプションに渡せばできます!

main.js
Vue.component('todo-item', {
    props: {
        todo: {
            type: Object,
            required: true
        }
    },
    //htmlの挿入
    template: '<div>' +
        '<input type="checkbox" v-model="todo.completed">' +
        '<span>{{todo.text}}</span>' +
        '</div>'
});

取り合えずこれでtodo-itemというコンポーネントを作れました!

3.タスクを保持するデータを作ろう!

次にテキストボックスの値をinputという名前で保存しよう!
では今からdatainputを入れていくよ~

main.js
var vm = new Vue({
    el: '#app',
    data: {
        input: '',
        //タスクを管理する配列
        todos: []
    },

タスクを管理する配列として todos:[] も作っとこう!

4.テキストとデータをくっつけよう!

早速テキストボックスとinputv-modelを使ってくっつけよう!

v-modelはinput要素にv-modelという属性をつけることで、データとinput要素の入力値をくっつけることができます!

追加ボタンの上のテキストボックスの所を

index.html
<input type="text" v-model="input" placeholder="テキスト入力してね">

にしてみましょう! 早速次にいきましょう!

5.タスクを追加するインスタンスメソッドを作ろう!

本題のTodoを追加するための機能を作っていきましょう!
addTodoというメソッドを作ろう!
インスタンスメソッドを作るにはmethodsオプションを使用したらできるよ

main.js
 methods: {
        //Todo追加関数
        addTodo: function () {
            this.todos.push(
                {
                    completed: false,
                    text: this.input
                })
            this.input = ''
        }
    }

これを追加してください!
配列へのpushやそういったものは割愛させて頂きます

6.タスクを追加する処理を書いていこう!

そしたらボタンをクリックしたらタスクを追加できるようにしたいので
ボタンとメソッドを紐づける必要があります!
これをするには
v-on:click
これを使って、ボタンが押されるのを監視し、ボタンが押された時に先ほどのaddTodoメソッドを実行できるようにしよう!

index.html
<button type="button" v-on:click="addTodo">追加</button>

こうですね!

7.タスクを削除する機能を作ろう!

タスクを追加したら、それを遂行した時の為に当然削除っていう機能も欲しいですよね?
こちらを今から実装していきたいと思います!

冒頭で作成したtempleteオプションに続けて記述したいと思います

main.js
 //htmlの挿入
    template: '<div>' +
        '<input type="checkbox" v-model="todo.completed">' +
        '<span>{{todo.text}}</span>' +
        '<button type="button" v-on:click="onclickRemove">削除</button>' +
        '</div>',

最終的にはこうですね!
しかしこれだけではなv-onオプションを使っている所を見て頂きたいのですが
onclickRemoveとあります
実際に削除ボタンを押されたときの処理をまだ書いていないので今から書いていきましょう。

main.js
    methods: {
        onclickRemove: function () {
            this.$emit('remove')
        }
    }

こちらを始めのインスタンスメソッドに追記します
$emitでカスタムイベントのremoveを発動します!
そして最後に

index.html
 <li v-for="(todo, index) in todos">
 <todo-item v-bind:todo="todo" v-on:remove="todos.splice(index,1)"></todo-item>
</li>

ul要素の中に上記を追記してください
v-forで要素を繰り返し表示します、この場合はtodos配列の中に格納されているタスクを表示させる為ですね
v-bindでデータを属性の値に紐付けします。

以上をもってTodoアプリの完成です!!!!!!

8.最後に

プログラミング初心者の割には頑張れた気がします
この調子でQiitaとか使ってアウトプットしていきたいな・・・

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

アロー関数の思わずぴえんしちゃった記法

はじめに

社内SEからJavaScriptを主に使用するWeb系企業を転職をして一ヶ月。
会社で使うわけではないですが、Reactを勉強する上でJavaScript(ES6)の記法を知らないと
頭がぐちゃぐちゃになる気がしたので、ちょっとだけ記事としてまとめたいと思います。

意味がわからなかった記法

こいつです。

:cry:アロー関数:cry:

「そんなこともわからないのなら辞めてしまえ」という声が聞こえてきそう。。。
基本的なことはわかっているのですが、ちょっと複雑にされると

「???????????」

となるのです。

アロー関数

基本は簡単です。

const addFunc = function(a, b) {
    return a + b;
}

const result = addFunc(1, 2);
console.log(result);

上記が今までの記法。

ES6だとアロー関数が使用できます。

const addFunc = (a, b) => {
    return a + b;
}

const result = addFunc(1, 2);
console.log(result);

returnを書くのが面倒な人のために、

const addFunc = (a, b) => (
    a + b
)

const result = addFunc(1, 2);
console.log(result);

戻り値がオブジェクトの場合は、

const addFunc = (a, b) => ({
    result: a + b
})

const result = addFunc(1, 2);
console.log(result);

ここまでは簡単。

お次。

const addFunc = a => b => {
    return a + b
}

ぴえん:cry:
矢印2つある:cry:
ぴえん:cry:

【コメントより】
これはアロー関数で高階関数(カリー化)を定義しています。

const addFunc = a => b => {
    return a + b
}

const addTest = addFunc(1);

const result = addTest(2)
console.log(result);

これは、aという引数を持たせた状態でaddTestに関数を代入します。
う〜ん、使いどころがイマイチわからない。

恐らく、exportするファイル内で知ることのできる値と知ることのできない値を引数に使用したい場合に使うのかな?
上記の例でいうと、

「aは今わかるけど、bはstore内の値を使いたいから今は知らんなぁ。。。export先でならわかるけどそっちではaがわからんなぁ。。。」

みたいな状況で使うのかなと思っています。
詳しい人教えてください。

終わり

次は分割代入についてまとめたいです。
以上!
ご指摘等あればよろしくお願いいたします。
ぴえん:cry:

ちなみに

ぴえん:cry: の正式な絵文字をQiitaでも使いたい:cry:

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

モック生成・パターン管理ツールの am-mocktimes を Svelte で作り直した

am-mocktimes の簡単なご紹介

am-mocktimes とは、モック生成とパターン管理を行うツールです。

直感的には、以下のコードを書くと

patterns.yml
Travel Plan A: [setPlan, A]
Travel Plan B: [setPlan, B]
Travel Plan Z:
   - [setPlan, Z]
   - [setLocation, Cell Game, true]

以下のような一覧画面が表示され

image.png

以下のコードを追加して、上記リンクをクリックすると

mock-config.js
import mock from 'am-mocktimes'

mock({
    setPlan(name) {
        // 処理
    },
    setLocation(name, flag) {
        // 処理
    }
})

アプリ画面に切り替わり実行されます。(ブラウザバックで戻れます。)

Plan A 選択 Plan B 選択
image-20200216181125682.png image-20200216181156134.png

詳しいドキュメントはレポジトリをご覧ください。上記デモはこちら

Svelteに移行した理由

am-mocktimes は Riot.js を採用していました。Riot は Web Standard に合わせるような仕様を目指していると感じていて、とても好感がありました。ライブラリ特有の処理を覚えることが少なく、軽量であったことも大きいです。そのため、捨てやすいライブラリでもありました。

Svelte は js に完全にコンパイルするため、ライブラリとしての容量は0kbになります ( 参考記事 ) 。より Web Standard に近い形になると考えることも出来そうです。
また、 2019 JavaScript Rising Stars / Front-end Frameworks では突如3位に浮上し、注目度が急激に高まっています。

Does it mean that we have now a BIG-4 instead of a BIG-3?

「(Vue.js, React, Angularの) 3強時代から、4強時代になったことを意味する?」と書かれている通り、人気上位のフレームワークにも食い込んできています。

これらの注目もあって、 Riot.js に比べて開発ツールが非常に充実しています。 linter や formatter, suggest の機能が vscode で得られたのは非常に大きく、書きやすさも申し分無い。デメリットとしては TypeScript サポートが現時点では無いことですが、個人的にはそれ以上のメリットを感じています。

移行の決め手となったのは、 Svelte の機能の一つである component-scoped CSS (子コンポーネントに style が継承されない機能) です。予期せぬ style崩れが起きないようずっと気をつかっていて、この機能を持つ Web Components に何度も乗り換えたいと思ってたくらいなので、喉が潤った感じがしました。この機能を知らない方は こちらのTutorial を見ると分かり易いと思います。

実際に作ってみて

想像以上に作り易かったです。 Svelte Tutorial (公式) を8章くらいまで読んで、開発の合間に Svelte Master / tutorial を見つつ作りました。最初に公式の Tutorial を全部眺めていたら、書き直しが減ったように思います。再帰などいくつかハマった点はありますが、無事に解決し全体的に大満足です。

Riot.jsについて

自分はこのフレームワークをとても愛していて、マイペースで作り上げていくコアコミッターの方を応援し続けるつもりです。ただもしかすると、Riot.js が本当に目指していたのは Svelte のような 0kbライブラリなのではないか、と考えたりもしています。良い意味で自分の予想を裏切ってくれたら嬉しく思います。

全体を通して

Svelte は型に厳密なプログラミングでは無いので、それを求めている大規模・大人数開発には向かないと思います。ただ、型安全性を持たなくとも、開発パフォーマンスを十分に高めることが出来る一つの手法であると考えています。型安全性を持つ開発としては、自分は Rust でのフロントエンド開発に期待を寄せています。今後は Svelte と Rust の融合は可能か、どのように進化していくとより良いか等の理想を考えながら、開発していきたいと思っています。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Techpitの教材「Vue.js と Firebase ではてなブックマーク風なブックマークサービスを作ってみよう!」を終えての感想

説明

TechCommitさんのお年玉企画で、Techpitさんの教材「Vue.js と Firebase ではてなブックマーク風なブックマークサービスを作ってみよう!」が当選したので使ってみた感想を書きました。

前提

筆者のレベル

言語・ツール レベル
HTML・CSS 基礎レベル
JavaScript 業務で少し触ったことがある   
Vue.js はじめて
Firebase はじめて

扱った技術

  • Vue.js
  • Bulma
  • Firebase Authentication
  • Firestore
  • Firebase Storage
  • Firebase Cloud Function
  • Firebase Hosting

作った機能

  • ブックマークサービス
    • トップページ(全ユーザーのブックマーク一覧)
    • サインアップ
    • サインイン
    • 認証情報変更
    • ブックマークへのコメント
    • ブックマークコメントのスター登録
    • プロファイルページ

完成品

公開したアプリケーション。
https://pitmark-prod-9c6b2.firebaseapp.com/

トップページ

スクリーンショット 2020-02-16 17.27.46.png

サインアップ

スクリーンショット 2020-02-16 17.35.36.png

サインイン

スクリーンショット 2020-02-16 17.35.54.png

認証情報変更

スクリーンショット 2020-02-16 17.39.43.png

ブックマークへのコメント&スター登録

スクリーンショット 2020-02-16 18.02.23.png

プロファイルページ

スクリーンショット 2020-02-16 17.59.03.png

感想

  1. 手順通りに進めるとアプリができあがるので、Progate等で基礎を学んだあとに何か成果物を作りたい人に良いと思いました。Vue.jsとFirebaseの使い方を丁寧に説明してくれるので詰まる箇所も少なく進めていけました。ただし説明が少ない箇所もあるので自分で調べる必要もあります(Vueの公式ページがわかりやすいので助かる)。

  2. HTML・CSS、JavaScriptの基礎を学んでいないと消化不良になりそうです。自分はJavaScriptをしっかり学んでいなかったのでJavaScriptとVue.jsの両方を調べながら教材を進めていきましたが、それだとVue.jsの内容が頭に入りきらないことがあったのでちょっともったいなかったと感じました。

  3. Firebaseは初めて触りましたが、基礎的な内容を学ぶことができ勉強になりました。アプリを簡単に公開までもっていけます。学んだ内容は

    1. Firebase Authentication:認証機能
    2. Firestore:データベース機能
    3. Firebase Storage:ファイルストレージ機能
    4. Firebase Cloud Function:データが作成・更新・削除されたときに特定の処理を裏側で行わせる等で使用
    5. Firebase Hosting:アプリの公開に使用
  4. Bulmaが便利。Flexboxベースの柔軟なレイアウトを簡単に構築できる。教材に沿って使っただけなので自分で色々試してみたい。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

top-level awaitがどのようにES Modulesに影響するのか完全に理解する

先日、TypeScript 3.8 RCが公開されました。TypeScript 3.8はクラスのprivateフィールド(#nameみたいなやつ)を始めとして、ECMAScriptの新機能のサポートがいくつか追加されています。この記事で取り扱うtop-level awaitもその一つです。

この記事ではtop-level awaitに焦点を当てて、その意味や使い方について余すところなく解説します。top-level awaitは一見単純な機能に見えますが、実はモジュール (ES Modules) と深い関係があり、そこがtop-level awaitの特に難しい点です。そこで、この記事ではECMAScriptのモジュールについても詳しく解説します。この記事を読んでtop-level awaitを完全に理解して備えましょう。

※ この記事は3分の1くらい読むと「まとめ」があり、残りはおまけです。記事の長さに怖気付かずに、ぜひおまけの前までだけでも読みましょう。おまけでは、top-level awaitに限らずES Modulesについて完全に理解することを目指します。

top-level awaitとは

top-level awaitはECMAScriptに対するStage 3プロポーザルです。実際のプロポーザルは以下のURLで閲覧できます。プロポーザルというのはECMAScript (JavaScript) に対する新機能であり、まだ正式採用には至っていないものを指します。とはいえ、Stage 3というのはもう言語機能のデザインが終了して正式採用間近という段階であり、このステージに達したものはTypeScriptにも導入されます。また、v8にはすでに実装されており、フラグ付きですが利用できます。

名前の通り、top-level awaitというのはasync関数の外でもawait式が書けるという機能です。従来awaitが書けるのはasync関数の中だけでしたが、その制限が緩和されてファイルのトップレベル(関数の外の部分)にもawaitが書けるようになります。ただし、asyncではないただの関数の中にawaitを書くのは相変わらずだめです。

top-level awaitは色々な議論の種となった機能です。議論ではよくTLAと略されているのが見かけられます。

top-level awaitの嬉しい点 (1) ラッパーasync関数が不要

top-level awaitがあると、特にちょっとしたスクリプトが書きやすくなります。非同期処理を行いたい場合従来はmainみたいな名前でasync関数を定義してその中に処理を書いていましたが、top-level awaitを用いるとこれを直に書くことができます。node.jsの例だとこんな具合です(エラー処理は適当です)。変数を使うならちゃんと関数に入れたほうがいいという考え方もありますが、書き捨てのスクリプトなどでは一々関数にまとめるのは面倒なだけということも多いでしょう。

import fs from "fs";
const { readFile, writeFile } = fs.promises;

// 従来の書き方
async function main() {
  const content = await readFile("./from", "utf8");
  await writeFile("./to", content.toUpperCase());
}
main().catch(err => {
  console.error(err);
  process.exit(1);
});

// top-level awaitを用いた書き方
const content = await readFile("./from", "utf8");
await writeFile("./to", content.toUpperCase());

ちなみに、top-level awaitはモジュールでのみ使用できます。というのも、JavaScriptではソースコード(ファイル単位)はスクリプトかモジュールかのいずれかに分類され、外的要因や内的要因によってどちらなのかが決められるのです1import文やexport文もモジュールでのみ利用できる構文です。

top-level awaitの嬉しい点 (2) 非同期処理の結果をエクスポートできる

書き捨てのスクリプトだけでなく、他のファイルから依存されるモジュールの場合もよい点があります。それは、非同期処理の結果をエクスポートできる点です。例えば、設定ファイルを読み込んだ結果をエクスポートしたければ次のようにすることができるでしょう(実際は読んだあとに追加処理があるでしょうが省略します)。

export const content = await readFile("./setting", "utf8");

export文は必ずトップレベルに存在することから、top-level awaitが無いとこのような書き方は不可能です。例えば、次のようにしてはいけません。これだと、モジュールが読み込まれた瞬間はcontentundefinedとなってしまうからです。

だめな例
// だめな例
export let content;
readFile("./setting", "utf8").then(c => {
  content = c;
});

代わりのワークアラウンドとしては、関数をエクスポートするとかPromiseをエクスポートするといった方法を取ることになります。これらの方法は使う側で一手間必要になりますから、これらに比べるとtop-level awaitはだいぶすっきりと書くことができて嬉しいですね。

ワークアラウンドの例
// 関数をエクスポート
let content;
export const getContent = async () => {
  if (content) return content;
  content = await readFile("./setting", "utf8");
  return content;
};

// Promiseをエクスポート
export const contentPromise = readFile("./setting", "utf8")

top-level awaitとモジュール

一見すると極めて単純な機能拡張に見えるtop-level awaitですが、実はそうでもありません。この機能拡張は本質的にはトップレベルの実行が非同期実行になるということであり、仕様化のためにはそれに伴って現れる諸問題を解決する必要がありました。

従来、非同期実行(中でawaitが可能な実行)はasync関数の中でのみ行うことができました。async関数の返り値はPromiseであるため、async関数の中でawaitにより行われる「待つ」という操作はそのPromiseが解決するまでの時間が延びるという形で吸収されます。

しかし、トップレベルのawaitはそうではなく、awaitで待つという操作はモジュール全体の実行が止まることを意味します。このことは、主にimportされたモジュールがtop-level awaitを持っていた場合にどうするのかという問題に繋がります。直感的にはモジュール全体が暗黙のasync関数に包まれた
感じの挙動になりますが、モジュール間の依存関係という特有の事情の存在により状況がややこしくなっています。

単純な例

最も単純な例でtop-level awaitとモジュールの関係を見てみましょう。foo.mjsroot.mjsの2つのファイルがあるとします。foo.mjsはさっきの例のもので、root.mjsfoo.mjsを読み込みます。

foo.mjs
export const content = await readFile("./setting", "utf8");
root.mjs
import { content } from "./foo.mjs";
console.log(content);

ここでroot.mjsを実行するとどうなるのか見てみます。root.mjsfoo.mjsに依存している(foo.mjsからインポートしている)ため、root.mjsよりも先にfoo.mjsが実行されます。これはtop-level awaitが無い場合でも同様です。

ここで実行されるfoo.mjsはtop-level awaitを含んでいます。つまり、foo.mjsの実行が完了するまでには時間がかかるということです。特に、root.mjsfoo.mjsからcontentをインポートして使用していますが、foo.mjscontentの中身を計算し終えるまでには時間がかかります。

top-level awaitがある場合、foo.mjsの実行が完了してからroot.mjsが実行されます。これにより、root.mjsfoo.mjsがエクスポートするcontentを計算し終えてから実行を開始するため、contentの内容をちゃんと得ることができます。

このように、top-level awaitを使用する場合はそれに依存するモジュールの実行をブロックすることになります。これはtop-level awaitの重要な特徴ですから理解しておきましょう。

並列な依存関係がある場合

ここまでは、さもありなんという内容でした。次は登場人物をひとり増やしましょう。

例えば、次のように3つのモジュールがあるとします。root.mjsを実行した場合にどのような挙動をするか説明できるでしょうか。説明を簡単にするため、sleep関数はいい感じにもともと用意されているものとします(例えばsleep(5000)は5秒後に解決するPromiseを返します)。

a.mjs
await sleep(5000);
console.log("I am a.mjs");
b.mjs
await sleep(3000);
console.log("I am b.mjs");
root.mjs
import "./a.mjs";
import "./b.mjs";
console.log("I am root.mjs");

root.jsは2つの依存関係a.jsb.jsを持ち、どちらもtop-level awaitを使用します。先ほどと同様に考えれば、これらの実行が終わってからでないとroot.jsを実行することはできません。しかし、今回は2つのimportが直列的に実行されるのか並列的に実行されるのかという2つの可能性があります。それによって答えが変わってきますね。

答えは、並列的に実行されます。すなわち、importされた全てのモジュールは同時に実行されます(ただし、直列との対比で並列という言葉を用いましたが、JavaScriptはマルチスレッドではないため同時に実行されるのは一つだけです2。複数のasync関数を順番に呼び出してPromise.allでまとめるのと同じ感じです)。

この場合、先にimportされているa.jsがまず実行されますが、awaitにより待機に入った時点でb.jsの実行に移り、こちらもawaitにより待機状態になります。両方の依存モジュールが待機状態になりましたが、これらの実行が完了したわけではないのでまだroot.jsは実行されません。これら両方の待機状態が解消され、a.jsb.jsの実行が完了して初めてroot.jsが実行されます。

つまり、上記のサンプルの実行結果はこのようになります。

  • 3秒後にI am b.jsと表示される。
  • さらに2秒後(実行開始から5秒後)にI am a.jsと表示される。
  • 即座にroot.jsの実行が開始され、I am root.jsと表示される。

なお、root.mjsの中でimport文がどこに書かれていようとも、全てのimportが解決される(依存モジュールの実行が完了する)までは実行されません。例えroot.mjsをこのように書いたとしても、表示の順番はI am a.mjsI am b.mjsI am root.mjsとなります。

root.mjs
console.log("I am root.mjs");

import "./a.mjs";
import "./b.mjs";

この点は直感に反するという人がいるかもしれませんので注意が必要です。どこにimportを書こうとも必ず依存モジュールの方が先に実行されるのです。通常はimportはファイルの先頭にまとめて書かれますが、そもそもどこに書こうとも動作が変わりませんから、ファイルの先頭以外に書く意味が無いのです。ただし、import同士の順番は実行順に影響しますから注意してください。

まとめると、モジュールの循環参照が無い場合はtop-level awaitとimportの関係はそんなに難しくありません。「importされている各モジュールは並行的に実行され、それらが完了する(モジュールの最後まで実行される)と自身が実行される」と説明できます。

なお、ES2020で導入予定のdynamic importについてはまた話が違います。コード中にdynamic importが存在したとしても、それを待たずに(静的なimportはもちろん待ちますが)コードの実行が開始されます。そして、import(...)式が評価されたタイミングで初めてモジュールの読み込み・依存先の実行が行われるのです。

ということで、top-level awaitとdynamic importを組み合わせることで、コード実行とimportの順番をより細かく制御できるようになります。

root.mjs
console.log("I am root.mjs");
await import("./a.mjs");
await import("./b.mjs");

この場合、まずI am root.mjsと表示→5秒後にI am a.jsと表示→さらに3秒後にI am b.jsと表示→I am root.jsと表示、という実行になります3

この例では静的なimport文が無いため、実行するといきなりroot.mjsの実行が開始されます。これにより最初にI am root.mjsと表示されます。その後import("./a.mjs")が評価された時点でa.mjsの読み込みと実行が開始されます。import("./a.mjs")はPromiseを返しますが、これが解決されるのはa.mjsの実行が完了したあと、すなわちtop-level awaitによる中断を乗り越えて最後まで実行されたあとです。よって、await import("./a.mjs")root.mjsの実行は5秒間停止します。同様に、await import("./b.mjs")にも3秒かかります。

基本的には、dynamic importよりも静的なimport文を用いたほうがよいでしょう。その理由は主に2つあります。一つは静的なimport文ならば上述のように依存モジュールを並列的に実行できることです(dynamic importもPromise.allと組み合わせれば同じことができますが、そこまでするよりは普通にimport文を書いた方がきれいな記述になります)。もう一つは次に説明する循環参照のハンドリングをうまくやってくれることです。

循環参照がある場合 (1) そもそも同期的なモジュールの循環参照は?

モジュールの循環参照はあまり勧められたことではないのですが、ES Modulesでは循環参照があっても一応動くようになっています。top-level awaitから一旦離れて同期的なモジュールの循環参照について理解しましょう。

a.mjs
import "./b.mjs";
console.log("I am a.mjs");
b.mjs
import "./a.mjs";
console.log("I am b.mjs");
root.mjs
import "./a.mjs";
console.log("I am root.mjs");

このサンプルでは、root.mjsa.mjsb.mjsa.mjsという循環参照が発生しています。なお、実際に実行した結果はこうです。

I am b.mjs
I am a.mjs
I am root.mjs

一見すると「a.mjsを実行すると先にb.mjsを実行する必要があり、b.mjsを実行すると先にa.mjsを実行する必要がある」という無限ループに陥るように思えますが、そうはなりません。この場合は依存先よりも先に自身が実行されるという現象が発生します。

具体的な実行順は、依存関係グラフを深さ優先探索することで決められます。依存関係グラフに属する各モジュールは葉から順に(帰りがけ順で)実行されます。ただし、同じモジュールを訪れるのは1回だけです。

今回の例の場合は、root.mjsa.mjsb.mjsという順で依存関係が探索されますが、その次のb.mjsa.mjsという依存関係は実行順を決める際に無視されます。これは、a.mjsは探索の途中ですでに一度訪れているからです。よって、実行順は葉から順に辿るのでb.mjsa.mjsroot.mjsとなります。

ここで、b.mjsimport "./a.mjs"という依存を持っているにもかからわず、a.mjsよりもb.mjsが先に実行されるという現象が発生しました。このように、循環参照が発生した場合は探索順に依存して一部の依存関係を無視することで実行順を決定します。

ここで少し気になるのは、「a.mjsよりも先にb.mjsの内容が実行されるのなら、b.mjsからa.mjsがエクスポートするものを使用したらどうなるのか」ということです。これを確かめるようには例を次のように書き換えます。

a.mjs
import "./b.mjs";
console.log("I am a.mjs");

export const a = "aaaaaa";
b.mjs
import { a } from "./a.mjs";
console.log("I am b.mjs", a);
root.mjs
import "./a.mjs";
console.log("I am root.mjs");

root.mjsa.mjsb.mjsa.mjsという循環参照があるのは変わりませんが、b.mjsa.mjsがエクスポートするaを使用しています。

問題は、先ほど説明した理屈によりa.mjsよりも先にb.mjsが実行されることです。つまり、a.mjsexport const a = "aaaaaa";が実行されるよりも前にb.mjsが実行されてしまいます。

実際にこれを実行すると、b.mjsは以下のようなエラーを発生させます。

console.log("I am b.mjs", a);
                          ^
ReferenceError: Cannot access 'a' before initialization

つまり、a.mjsよりも先にb.mjsが実行される場合、export const a = "aaaaaa";が実行されるよりも前にa.mjsからエクスポートされたaを使用しようとするのはエラーとなるのです。ただし、aをインポートするだけではエラーにならず、a.mjsが実行される(aに値がセットされる)よりも前にそれを使用するのがエラーとなります。よって、a.mjsが実行されてからaを使用するようにするとエラーが消えます。

例えばb.mjsを次のように変えることで、エラーを発生させずに次のような出力を得ることができます。

b.mjs
import { a } from "./a.mjs";
console.log("I am b.mjs");
setTimeout(() => {
  console.log("a is", a);
}, 1000);
得られる出力
I am b.mjs
I am a.mjs
I am root.mjs
a is aaaaaa

余談ですが、aをインポートするだけではエラーとならず、それを使うタイミングでエラーが起きるのは少し変ですね。まるで変数aの内容が勝手に書き換わっているようにも見えます。実際には、importexportでやりとりされているのが値ではなくbindingであることからこのような挙動が実現されています。実際、webpackなどのバンドラを通すと、a__importedModule.aみたいに変換され、aは変数ではなくオブジェクト(モジュール名前空間)のプロパティとなります。これにより「import後に値が変わる」という挙動を再現しているのです。また、バンドラを用いる場合、未初期化のbindingを用いた場合はエラーが発生するのではなくundefinedとなることがあります。

また、そもそもexportされていないものをimportするのは、自分より先にa.mjsが実行されるかどうかとは無関係にエラーとなります。これは、何がそのモジュールからexportされているかはコードを実際に実行しなくてもパースするだけで分かるからです4

b.mjs
// こうするとエラーが発生
import { abcdefg } from "./a.mjs";

循環参照がある場合 (2) top-level awaitとの関係

以上で説明した循環参照のハンドリングは、top-level awaitがある場合でも基本的には同様に成り立ちます。

a.mjs
import "./b.mjs";

await sleep(3000);
console.log("I am a.mjs");
b.mjs
import "./a.mjs";

await sleep(5000);
console.log("I am b.mjs");
root.mjs
import "./a.mjs";
console.log("I am root.mjs");

この例は先ほどと似ていますが、a.mjsb.mjsにはtop-level awaitを用いたsleep呼び出しが追加されています。この場合、やはり循環参照があり、a.mjsよりも先にb.mjsが実行されます。

その結果、次のような実行経路を辿ります。

  • 最初にb.mjsが実行される。よって、実行開始から5秒後にI am b.mjsと表示。
  • 次にa.mjsが実行される。よって、さらに3秒後にI am a.mjsと表示。
  • 直後にroot.mjsが実行され、I am root.mjsと表示。

これは先ほどのtop-level awaitが無い場合の例と何も変わっていませんね。それぞれのモジュールの実行完了まで時間がかかるようになっただけです。

このように、top-level awaitのある無しによってモジュールの実行順が変わることはありません。これはプロポーザル文書にも明記されており、プログラマがこれまでに得た直感を破壊しないように気が遣われています。

ただし、この場合に保証されるモジュールの実行順とは「モジュールの実行開始順」です。モジュールがawaitに到達した時点でそのモジュールは中断し、次のモジュールの実行に移ることができます。これはasync関数の場合と同じものとして理解することができます。すなわち、async関数を呼び出したら最初のawaitまではそのまま実行されるものの、そのタイミングで呼び出し元に制御が返るのでした(多くの場合ですぐawaitされるのであまり意味はありませんが)。

循環参照がある場合 (3) 強連結成分の扱い

dynamic importでは、import("./a.mjs")のような構文でPromiseを得ることができます。そして、a.mjsが読み込まれたらこのPromiseが解決されます。a.mjsがtop-level awaitを含む場合には、それが全部解決されてa.mjsの実行が終了して初めてPromiseが解決されます。

このことはすでに見た通りですが、これに循環参照が関わると一つ特殊な挙動が発生します。それは、循環参照の強連結成分に含まれるモジュールのPromiseは全て同時に解決されるというものです。強連結成分というのは要するに互いに循環参照しているモジュールたちのことです。このことは次のような例で確かめられます。

a.mjs
import "./b.mjs";

await sleep(5000);
console.log("I am a.mjs");
b.mjs
import "./a.mjs";

await sleep(3000);
console.log("I am b.mjs");
root.mjs
import("./a.mjs").then(() => console.log("a.mjs is loaded"));
await sleep(1000);
import("./b.mjs").then(() => console.log("b.mjs is loaded"));

a.mjsb.mjsは普通に循環参照を形成しており、root.mjsはそれぞれに対してdynamic importを行います。これによって、a.mjsb.mjsがそれぞれ読み込まれた瞬間にconsole.logが実行されます。このroot.mjsを実行したらどのような挙動になるかはお分かりでしょうか。ただし、ファイルシステムからファイルを読み込んでそのJavaScriptファイルを実行するのにかかる時間は1秒より十分短いものとします。

答えは次の通りです。

  • 実行から3秒後にI am b.mjsと表示される。
  • そのさらに5秒後(実行から合計8秒後)にI am a.mjsと表示される。
  • その直後にa.mjs is laodedb.mjs is loadedの順で表示される。

この挙動になる理由を順に説明します。

  1. まず、root.mjsを実行するとdynamic importによりimport("./a.mjs")が実行されます。直後のawait sleep(1000)によりroot.mjsの実行は中断します。import("./a.mjs")の返り値であるPromiseを$P_a$とします。
  2. この間に、import("./a.mjs")によりa.mjsが読み込まれます。これはb.mjsimportしているので、さらにb.mjsが読み込まれます。これはa.mjsへの循環参照を持ちますが、先ほど説明したメカニズムにより、b.mjsが先に実行されます。b.mjsawait sleep(3000)にさしかかって待機します。
  3. 開始から1秒後にroot.mjsawaitが終了し、import("./b.mjs")が実行されます。b.mjsはすでにa.mjs経由で実行されているため、ここでは何も起こりません。import("./b.mjs")の返り値であるPromiseを$P_b$とします。
  4. 開始から3秒後に./b.mjsawaitが終了し、console.logによりI am b.mjsが出力されます。ここが一番のポイントですが、b.mjsの実行が終了しても$P_b$はまだ解決されない点に注意してください。
  5. b.mjsの実行が終了したので、それに依存していたa.mjsの実行が開始されます。これはawait sleep(5000);に差し掛かって停止します。
  6. 開始から8秒後にa.mjsawaitが終了し、console.logによりI am a.mjsが出力されます。
  7. この直後、$P_a$と$P_b$が同時に解決されます。$P_a$の方が先に作られたため、$P_a$のthenハンドラが先に呼び出されてa.mjs is loadedが先に表示されます。その後にb.mjs is loadedが表示されます。

4と7の動作がポイントです。$P_a$や$P_b$はどちらもimport()によって作成されたPromiseですが、これらはa.mjsの実行が完了した時に解決されるPromise $P$ にthenで繋がっています。そのため、$P$が解決された段階で$P_a$と$P_b$もresolveされます。$P_b$はb.mjsimportしてできたPromiseですが、これが(a.mjsの完了を意味する)$P$に繋がっているというのが特徴的です。これは、import("./b.mjs")の時点ですでにb.mjsが実行済であり、かつb.mjsを含む強連結成分の“親”5a.mjsであったことから、$P_b$は$P$に繋がることになります。

このように、循環する依存関係(強連結成分)中のモジュールの実行が終わるのを待機した場合、どのモジュールを待機した場合でも親の実行が終わる(=強連結成分全体の実行が終わる)まで待機することになります。これはtop-level awaitに関して仕様上保証する性質のひとつであり、強連結成分の外から内部のモジュールの結果を利用したい場合、内部の実行が全部終わって初めてその結果が利用できるようになります。

今回の場合、b.mjsの実行は開始から3秒で終了しているはずなのに、実際にimport("./b.mjs")の結果 ($P_b$)が解決されるのが8秒後であるという点にこの性質が現れています。実際の実行順序がどうであれ、b.mjsは構文上a.mjsに依存するものとされているのだから、a.mjsの実行が完了する(=強連結成分全体の実行が完了する)までは利用できないということになります。

この仕様は、中途半端な状態のモジュール(まだ実行されていないモジュールからエクスポートされている変数を使用するモジュールなど)を循環参照の外に露出しないことが目的です。top-level awaitが無くても循環参照の内部でそのような状態を作ることは可能でしたが、やはりそのような状態は外に漏れないようになっていました。

循環参照がある場合 (4) dynamic importでデッドロックを作る

top-level awaitがモジュールの依存関係の中に現れるとなると、気になるのがデッドロックが発生しないかどうかです。モジュールたちが互いに互いを待機し合う状況になると先に進まなくなってしまうかもしれません。

結論から言えば、静的なimportだけ使っていれば、たとえ循環参照していてもデッドロックにはなりません。これまで説明してきたように、循環参照があったとしても適当な順番でモジュールが実行されるからです。

ただし、dynamic importを用いている場合にはデッドロックの可能性が一応あります。特に、先ほど説明したdynamic importの挙動を使ってデッドロックを発生させることができます。具体例を見てみましょう。

a.mjs
import "./b.mjs";

await import("./b.mjs");
console.log("I am a.mjs");
b.mjs
import "./a.mjs";
console.log("I am b.mjs");
root.mjs
import "./a.mjs";
console.log("I am root.mjs");

この状態でroot.mjsを実行すると、I am b.mjsだけが表示された状態で停止します。プロセス自体は終了せずに停止することから、デッドロックに陥ったことが伺えます。では、なぜこれはデッドロックとなるのか考えてみましょう。

静的なimport文によるroot.mjsa.mjsb.mjsa.mjsという依存関係がありますから、これまでの例と同様にまずb.mjsが実行されます。何の問題もなくI am b.mjsが表示されます。次に実行されるのはa.mjsですが、ここで`await import("./b.mjs")に差し掛かります。

ここで重要な点は、a.mjsb.mjsは静的なimport文によって循環参照を作っており、同じ強連結成分に属するという点です。つまり、import("./b.mjs")a.mjsb.mjsの実行が完了するまで解決されません。ここで、今a.mjsを実行中なのに、a.mjsの実行が完了するまでawaitで待ち続けてしまうという状況が発生しました。これがデッドロックです6。よって、このプログラムはこのawaitで永遠に待ち続けるため終了できずI am a.mjsI am root.mjsも出力されません。

ただ、実際にこの方法でデッドロックが発生することは滅多に無いでしょう。すでに静的なimportで循環参照が生成されている内部でさらにdynamic importで同じモジュールを読み込まないといけないからです。とはいえ、込み入った依存関係の中に循環参照が形成されてしまい気づかないうちにdynamic importも併用していたということはもしかしたらあるかもしれません。変なところでプログラムが止まってしまう場合はこの現象を疑うべきかもしれません。尤も、循環参照を避けるのが一番良いことではありますが。

top-level awaitのサポート

さて、ここまででtop-level awaitがどのように動作するのか理解できたことと思います。では、top-level awaitはどのように実用すれば良いのでしょうか。

答えは、残念ながらまだです(2020年2月現在)。

top-level awaitを使う方法は主に2つあります。ES Modulesをサポートした環境で直接用いるか、バンドラを用いるかです。前者に関しては、v8エンジンにはすでに実装されています(--harmony-top-level-awaitフラグが必要ですが)。ただし、v8を利用しているnode.jsやGoogle Chromeにおけるサポートはまだです。とはいえ、近いうちにtop-level awaitが利用できるようになるでしょう。

とはいえ、ES Modulesを直に使っているという人はかなり少ないのが現状です。node.jsに関してはES Modulesが正式にサポートされたのがv13.2.0とかなり最近ですし、ブラウザでは後方互換性の問題やローディング速度の問題7からES Modulesをそのまま使うという選択肢が取られることはほとんどありません。

ということで、少なくともブラウザにおいてはtop-level awaitが最初に実用的に利用されるのはバンドラ経由のはずです。そもそも、top-level awaitはモジュールがどう読み込まれて実行されるかという点を変化させるものですから、Babelなどのトランスパイラが対応する範疇のものではありません8。代わりにtop-level awaitのサポートを実装すべきなのはwebpackなどのバンドラというわけです。

バンドラとしてはwebpackが一番早く動いており、v5系でサポートされると思われます。top-level awaitのサポートは5.0.0-alpha.15にて実験的なものが導入されました。歴史的経緯から、webpackには古いプロポーザル内容のサポートも含まれています。top-level awaitのプロポーザルをより良いものにするために、webpackなどのバンドラがある種の実験場として活躍してきたという経緯があります。

node.jsにおけるES Modulesサポートとtop-level await

上述の通り、ES2015でES Modulesが導入されてから、実際にnode.jsでES Modulesがサポートされる(2019年後半)までには随分時間がかかりました。その要因には色々ありますが、top-level awaitの存在もその一因だったようです。こちらの記事が詳しいので解説を譲ります。

まとめ

この記事では、Stage 3となりTypeScript 3.8によるサポートが追加されたtop-level awaitについて解説しました。top-level awaitが本質的にはES Modulesの意味を変えるものであることを説明し、併せてES Modulesについても必要に応じて解説しました。

TypeScript 3.8によりサポートされたといっても構文を理解できるようになったという話で、実際にtop-level awaitが使えるようになるまでには各種実行環境やバンドラによるサポートが必要となりますから、まだ先の話でしょう。

top-level awaitは非同期処理を行なった結果をエクスポートしたい場合などにたいへん便利ですから、実用化されたらぜひ使っていきたいですね。

おまけ: 仕様書を読んでES Modulesを完全理解する

ここまでこの記事を読んだ皆さんはES Modulesの挙動やtop-level awaitの挙動について詳しくなりました。しかし、やはり仕様書を読んで理解しないと完全理解とまでは言えませんよね。そこで、おまけとしてES Modulesの挙動を仕様書を見ながら追っていきます。なお、ES2020の仕様を参照したいのでこちらのドラフト (https://tc39.es/ecma262/)の2020年2月13日版を参照しながら説明します。あなたがこの記事を閲覧した日時によっては細かい節番号等が変わっているかもしれませんが、適宜調整してください。

Q. おまけが本編では?
A. はい。

モジュールという概念は仕様でどう扱われるのか

ECMAScriptでは、JavaScriptのソースコードがどのように解釈・実行されるのかが定められていますが、JavaScriptのソースコードの最も大きな単位(文法定義でいう開始記号)はScriptまたはModuleであり、これらは1つのソースコード(すなわち1つのファイルに記述されたひとかたまりのソースコード)に対応するものです。

その一方で、モジュールというのはimport文を通じて複数のソースコードが連結されて実行されるものです。それゆえに、「モジュールを実行する」というのは従来のように「ソースコードを読み込んで構文解析を行い、規則にしたがって実行する」という枠には収まらない行為であると言えます。

ECMAScriptでは、このような概念を定義するための仕様が存在します。モジュールを完全に理解する最初の一歩として、まずこの辺りを見てみましょう。

Module Record

Module Recordとは、モジュール一つ一つを表す仕様書上のオブジェクト(Record)です。この記事ではa.mjsb.mjsroot.mjsという3つのファイルが出てくる例などを示しましたが、この場合はそれぞれのファイルに対応するModule Recordが存在します。つまり、root.mjsを実行する際には裏で(少なくとも仕様書上で)3つのModule Recordが生成されていたことになります。仕様からModule Recordの説明を引用します。

A Module Record encapsulates structural information about the imports and exports of a single module. This information is used to link the imports and exports of sets of connected modules. A Module Record includes four fields that are only used when evaluating a module.

モジュールはimport文とexport文によって繋がるのですから、Module Recordはそれらの情報を含んでいなければいけません。実際、仕様書を見るとModule Recordは「GetExportedNames」といった(仕様書上の)メソッドを持つことが定義されています。また、この記事でも説明したように、実行時にも各モジュールが決められた順番で実行されます。これも仕様書的には「Module Recordを実行する」というような形で表されます。

Module Recordについては、3種類の分類が仕様書上で定義されています。それぞれAbstract Module Record, Cyclic Module Record, そしてSource Text Module Recordです。これらは分離したものではなく、ある種の継承関係にあります。実際、任意のModule RecordはAbstract Module Recordであり、Cyclic Module RecordはAbstract Module Recordの一瞬であり、Source Text Module RecordはCyclic Module Recordの一種です。

なぜこのような分類が用意されているのかと言えば、モジュールはJavaScriptソースファイルに限らず、処理系定義のモジュールがあるかもしれないからです。例えばWebpackでCSS Modulesを用いる環境では以下のようなimport文が用いられます。

import styles from "./styles.css";

これがどうバンドルされるかは別として9、ES Moduleを用いるコードとして見ればstyles.cssというファイルをモジュールとしてimportしていることになります。ECMAScript仕様上ではこのようなものが許されるのです。

そもそも、importのspecifier(インポート元を示す文字列部分)をどう解釈するのかも処理系依存です。ブラウザ上では"https://.../styles.css"のようにURLを示すことができますが、その場合の処理はECMAScript仕様書には書かれていません。また、node.jsではimport fs from "fs"のように組み込みモジュールを読み込むことができますが、当然これもECMAScript仕様書の埒外です。ECMAScriptが定めるのは、「何らかの方法で読み込まれたモジュールをどう実行するか」という部分なのです。

ということで、ECMAScript仕様書では、その実態が何とも知れない対象に対して物事を定義する必要があるのです。Abstract Module Recordは、モジュールとして最低限備えるべき性質を規定します。すなわち、何をexportしており何をimportしているのか分かる、またモジュールとして実行できるという性質です。

次にCyclic Module Recordとはもう少し条件が厳しいモジュールで、仕様書では次のように定義されています。

A Cyclic Module Record is used to represent information about a module that can participate in dependency cycles with other modules that are subclasses of the Cyclic Module Record type. Module Records that are not subclasses of the Cyclic Module Record type must not participate in dependency cycles with Source Text Module Records.

その名が示す通り、Cyclic Module Recordとは、dependency cycle(循環した依存関係)の中に現れることができるモジュールを指します。逆に言えば、Cyclic Module Recordではないモジュールは循環参照を作ることができません。Cyclic Module Recordの特徴は、依存関係の解決(後述するLink)や実際の実行順(後述するEvaluate)が仕様書で定義されるということです。逆に言えば、Cyclic Module Recordでないモジュールに関しては、どのように依存関係が解決され、どのような順番で実行されるのかということは、ECMAScriptの範疇には含まれないということになります(別の仕様書がCyclic Module RecordでないModule Recordを定義してそこで挙動が定義される可能性はあります)。

この記事では循環参照が発生した場合に挙動について説明しましたが、それもこのCyclic Module Recordに対して定義されたものになります。

最後のSource Text Module Recordは、ECMAScriptソースコードにより定義されるモジュールです。我々が普段扱うモジュールは基本的にこれになります10。実際、この記事にこれまで出てきたモジュールは全てSource Text Module Recordになります。Source Text Module Recordについては「そのモジュールが実行されたらどのような挙動をするのか」という点が仕様に含まれます。まあ、モジュールの中身がECMAScriptソースコードなのだからこれは当然ですね。

A Source Text Module Record is used to represent information about a module that was defined from ECMAScript source text (10) that was parsed using the goal symbol Module. Its fields contain digested information about the names that are imported by the module and its concrete methods use this digest to link, link, and evaluate the module.

ということで、主にプラットフォームによる拡張を可能にする目的で、どこまで仕様が関与するかを基準にモジュールが3階層に区分されていることを解説しました。次は、モジュールに関する仕様の中身をもう少し見てみましょう。

LinkとEvaluate

Abstract Module Recordは(言い換えれば全てのModule Recordは)、LinkEvaluateという2つのメソッドを持つと定義されています。それぞれについて、仕様では次のように説明されています

Link()

Prepare the module for evaluation by transitively resolving all module dependencies and creating a module Environment Record.

Evaluate()

If this module has already been evaluated successfully, return undefined; if it has already been evaluated unsuccessfully, throw the exception that was produced. Otherwise, transitively evaluate all module dependencies of this module and then evaluate this module.

Link must have completed successfully prior to invoking this method.

これを読むと、Link()は依存関係の解決を行うメソッドであることが分かります。module Environment Recordを作るとありますが、これはモジュールがエクスポートしている名前の一覧を保持する名前空間です。

Evaluate()は実際にモジュールを実行するメソッドです。説明に書いてある通り、このメソッドにはいくつかの制約が存在します。このメソッドよりも前にLink()が呼ばれている必要があること、2度目以降の呼び出しではモジュールを再度実行せずに前回と同じ結果を返す必要があること、そしてこのモジュール本体を実行する前に依存先のモジュールを先に呼び出す必要があることです11

Abstract Module Recordの場合、Link()とEvaluate()を実行すると何が起こるのかは定義されていません。もし別の仕様がAbstract Module Recordの具体例を定義した場合は、そちらでその場合のLink()とEvaluate()の実装が定義されることになるでしょう。

Cyclic Module RecordにおけるLink()の挙動

一方、Cyclic Module RecordではLinkとEvaluateの実装が定義されています。まずはCyclic Module RecordのLinkの実装
見てみます(長いので引用はしません)。

Linkの挙動を要約すると、「依存関係を深さ優先探索で順に辿り、各モジュールに対してInitializeEnvironment()メソッドを実行する。また、依存関係中にCyclic Module Recordでないモジュールがある場合はそれのLink()を実行する」となります。InitializeEnvironment()についてはCyclic Module Recordに対しては定義されておらず、次のように説明されています

Initialize the Lexical Environment of the module, including resolving all imported bindings, and create the module's execution context.

また、Source Text Module Recordに対しては具体的な挙動が仕様で定義されています。説明には「execution contextを定義する」などがあり、具体的には(Source Text Module Recordの場合は)そのモジュール用の変数スコープを初期化・生成するといった処理が含まれます。基本的には仕様書上必要な初期化処理であり、我々が直接気にするものではありません。

Cyclic Module RecordにおけるLink()の定義には、記事中で説明したような循環参照の対処も含まれています。すでに訪れたモジュールか否かはCyclic Module Recordの内部スロット[[Status]]で管理されます。[[Status]]の初期状態はunlinkedであり、深さ優先探索の行きがけにlinkingに変更されます。また、帰りがけにlinkedになります。これにより、すでに訪れられたモジュールか否かを[[Status]]を見ることで判断できるのです。

もうひとつ重要なポイントは、「依存関係を辿る」部分で用いられるHostResolveImportedModuleという処理です。

HostResolveImportedModule

HostResolveImportedModuleは、"./a.mjs"のようなspecifierを解決し、実際のModule Recordを取得・生成する処理です。名前の頭にHostとついているのは、仕様書で具体的な処理を定義しないということを示唆しています。先ほども触れたように、specifierから実際のモジュールを得るためにすべきことは環境によって異なるからです。

HostResolveImportedModule(referencingScriptOrModule, specifier)の処理については次の条件を満たさなければいけないと定義されています。

The implementation of HostResolveImportedModule must conform to the following requirements:

  • The normal return value must be an instance of a concrete subclass of Module Record.
  • If a Module Record corresponding to the pair referencingScriptOrModule, specifier does not exist or cannot be created, an exception must be thrown.
  • Each time this operation is called with a specific referencingScriptOrModule, specifier pair as arguments it must return the same Module Record instance if it completes normally.

すなわち、HostResolveImportedModuleは与えられたspecifierに対するModule Recordを返さないといけないこと、またspecifierに対するモジュールが存在しない場合はエラーを発生させなければいけないことが示されています。また、同じモジュールが複数回HostResolveImportedModuleされようとした場合には、同じModule Recordインスタンスが返されないといけないとしています。

ただし、HostResolveImportedModuleはreferencingScriptOrModuleも受け取っている点に着目してください。これは、「どのモジュール(実際にはモジュールとは限りませんが)からspecifierが参照されているか」を示すものです。この引数が必要な理由は、specifierだけからモジュールを特定できないかもしれないからです。例えば./a.mjsのような相対パスのspecifierは、どこからimportされているかによって参照先が変わります。

HostResolveImportedModuleはspecifierからModule Recordを作るので、一般にはネットワークアクセスなどを含みうる時間のかかる処理です。これはJavaScriptにおいては非同期処理として表現されるものですが、仕様書上のアルゴリズムは必ずしも(JavaScriptにおける意味で)同期的なものとは限りません。HostResolveImportedModuleを呼び出した場合は、必要なネットワークアクセスなどの処理が完了するまでそこでブロックすることになります。

また、Module Recordはそれが何をexportしているかとか、どのモジュールを依存関係に持つかという情報を含んでいますから、Module Recordを作成するためには読み込まれたモジュールをパースするところまで行う必要があります。HostResolveImportedModuleはこれらの処理を内包したものとなっています。

Cyclic Module RecordにおけるLink()の定義を読んだ方は、モジュールが依存する各モジュールに対してループしながらHostResolveImportedModule()を呼んでいるのが気にかかったかもしれません(次の部分)。

For each String required that is an element of module.[[RequestedModules]], do

a. Let requiredModule be ? HostResolveImportedModule(module, required).

(後略)

愚直に解釈すれば、複数の依存が存在するときにそれらを直列的に読み込むように思えるかもしれません。もちろんそのようにしても仕様違反ではありませんが、実際の処理系(ブラウザなど)はより最適化された機構を持ち、全ての依存関係に対して並列的に読み込みを行うことができます。これもまた、仕様違反とはなりません。なぜなら、仕様には「HostResolveImportedModuleが呼ばれてから読み込みを開始しなければいけない」などとは書いておらず、投機的に読み込みを開始していてもよいからです(後述)。

まとめると、Cyclic Module RecordのLink()の工程ではモジュールの全ての依存関係の読み込み・パースを行い、依存関係グラフ状の全てのモジュールに対するModule Recordを用意するということです。

Cyclic Module RecordにおけるEvaluate()の挙動

Link()の挙動を理解したので、次はEvaluate()の定義を読んでみましょう。こちらも要約すると、「モジュールの依存関係を深さ優先探索で読み込み、各モジュールに対してExecuteModule()を実行する。Cyclic Module Recordでないモジュールに対してはExecute()を実行する」というものです。実際にみてみると、Link()と非常に似た定義になっていることが分かります。これは、深さ優先探索の部分が同じだからです(linking, linkedという[[Status]]の代わりにevaluating, evaluatedが使われています)。ExecuteModule()についてはInitializeEnvironment()と同様にCyclic Module Recordにおいては実装が定義されていないメソッドであり、以下のように説明されています

Evaluate the module's code within its execution context.

例によって、Source Text Module Recordに対しては具体的な実装が規定されています。Source Text Module Recordに対してはExecuteModule()の動作は単純で、モジュールに書かれているコードを実行するだけです。例えばa.mjsに次のように書かれていた場合、I am a.mjsと表示されるのはこの段階です。

a.mjs
import from "./b.mjs";
console.log("I am a.mjs");

ちなみに、Evaluate()からも先ほどのHostResolveImportedModuleが使用されています。すでに説明した「複数回読んだら同じ結果を返す」という制約により、ここでは特に何も行われずにModule Recordが得られます(Link()時にすでに同じ呼び出しが行われているため)。

モジュールは誰が実行するのか: HTML仕様書に例を見る

ここまでで、モジュールの実行を司るのはLink()とEvaluate()という2つの(仕様書内で用いられる)メソッドであることが分かりました。まずLink()により依存関係が全て解決され、そのあとにEvaluate()を実行することでモジュールが(依存関係の末端から順に)実行されます。

ここでひとつの疑問が発生します。それは「最初のModule Recordは誰が作るのか」ということです。Link()やEvaluate()自体もModule Recordが持つメソッドであり、それらを実行することで、そのモジュールとそれが依存するモジュール全体を実行することができるのでした。しかし、ここまで解説した仕様には「一番の根に当たるModule Recordはどのように作られるのか」という点が含まれていません(根以外はHostResolveImportedModuleが作成してくれます)。

実は、この点はECMAScript仕様書の管轄ではありません。ECMAScript仕様書はModule Recordがどう振る舞うかについて定義する一方で、Module Recordをどう作るのかには関与しないのです。ECMAScript仕様書内でModule Recordを作る手段であるHostResolveImportedModuleについてもその具体的な挙動は示されていません。

そこで、実際にModule Recordが作られる場面を見るためにHTML仕様書を見に行きましょう。

script要素の挙動を調べる

HTMLでは、<script type="module">...</script>を用いることで、その中に書かれたソースコードをModule扱いで実行するはずです。ということで、script要素の仕様を見てみます。

script要素の中に書かれたプログラムが実行される場合に入り口となる仕様は以下の記述から始まります。

When a script element that is not "parser-inserted" experiences one of the events listed in the following list, the user agent must immediately prepare the script element:

  • The script element becomes connected.
  • The script element is connected and a node or document fragment is inserted into the script element, after any script elements inserted at that time.
  • The script element is connected and has a src attribute set where previously the element had no such attribute.

これはscript要素がprepareされる条件を示した仕様であり、HTMLのソース中に書かれているscript要素の場合は一番上の「The script element becomes connected」に該当してプログラムが実行されます。これは要するに「文書の木構造にscript要素が挿入(追加)されたとき」を意味していますが、HTMLパーサーがは文書を上から読んで適宜読んだ要素を文書に挿入していくので、この条件が満たされるのはHTMLパーサーが<script> ... </script>を読み終わった瞬間ということになります(勿論このことはHTML仕様書に明記されていますが、HTMLのパーサーの仕様をこの記事で解説するのはつらいので割愛します)。

Module Recordが作成されるまで

気を取り直して、script要素がprepareされると何が起こるのかを仕様から読み取りましょう。これは28ステップもある長いアルゴリズムですが、ステップ27に次のような記述があります。

Fetch an inline module script graph, given source text, base URL, settings object, and options. When this asynchronously completes, set the script's script to the result. At that time, the script is ready.

the script's scriptというよくわからない概念がありますが、定義を見るといくつかの要素からなるstructであり、その要素の中にrecordというものがあります。

A record

Either a Script Record, for classic scripts; a Source Text Module Record, for module scripts; or null. In the former two cases, it represents a parsed script; null represents a failure parsing.

ここに「module scriptsの場合はSource Text Module Recordである」とあります。the script's scriptにはFetch an inline module script graphの結果がセットされるとありますから、このFetch an inline module script graphアルゴリズムがSource Text Module Recordを作成していることが伺えます。

スクリプトをネットワークから読み込む部分を省くと、最終的にこれはcreate a module scriptアルゴリズムに行き着きます。このアルゴリズムのステップ7でresultが定義されており、この中身こそがSource Text Module Recordです。

Let result be ParseModule(source, settings's Realm, script).

ParseModuleはECMAScript仕様書で定義されている抽象操作であり、ソースコードとRealmを受け取ってSource Text Module Recordを作成するものです。このように、HTML仕様書が担当するのはソースコードをテキストとして取得する部分であり、実際にSource Text Module Recordを作成する部分はECMAScript仕様書からエクスポートされたものを用いています。

Link()の実行

視点をFetch an inline module script graphアルゴリズムに戻すと、2ステップ後に fetch the descendants of and link a module scriptアルゴリズムが呼ばれています。このアルゴリズムのステップ5にこのように書かれています。

If parse error is null, then:

  1. Let record be result's record.
  2. Perform record.Link().

ここでLink()の呼び出しを見つけることができました。ただ、HTML仕様書の場合はLink()の中で(HostResolveImportedModule経由で)ネットワークアクセスを行うのであ無く、同アルゴリズムのステップ1から呼び出されているfetch the descendants of a module scriptアルゴリズムの中でリクエストの発行が規定されており、どのようにHTTPリクエストが作られるのかといったことまで詳細に仕様化されています。これこそまさに、Link()よりも先に投機的に読み込みを開始する例です。

Evaluate()の実行

さて、元のprepare a scriptアルゴリズムでは、最後のステップでexecute a script blockを実行しています。これは内部でrun a module scriptアルゴリズムを呼び出しており、そのステップ7でEvalute()の呼び出しが規定されています。

Otherwise:

  1. Let record be script's record.
  2. Set evaluationStatus to record.Evaluate().

長くなりましたが、このようにしてHTML仕様書が「Source Text Module Recordの作成→Link()呼び出し→Evaluate()呼び出し」という一連の流れを規定していることが分かりました。<script type="module"></script>と書くとモジュールが実行されるのは、このようなHTML仕様書とECMAScript仕様書の協業によって成されていることなのです。

環境依存の操作の定義

ECMAScript仕様では、HostResolveImportedModuleなどの操作は具体的には定義されず、制約のみが規定されていました。HTML仕様書ではこれらの具体的な内容も定義されています。つまり、HTML文書の処理の一環としてモジュールを実行する際にはこの特定の内容を用いなければなりません。これはHTML仕様書の以下の節で定義されています。

dynamic importの挙動

dynamic importはこの記事でも何度も出てきていますが、import("./a.mjs")のように関数呼び出しに類似した構文でモジュールをインポートできるもので、大きな特徴が2つあります。一つはimport(`${name}.mjs`)のようにインポート先を動的に決められるというもので、もう一つはインポートを実行するタイミングをプログラムで制御できる点です。

この2つの特徴から、dynamic importはこれまで説明したようなフローには乗りません。Module Recordのような概念は共通ですが、独自の処理がされているところがあります。

ということで、import()構文の挙動を定義している12.3.10 Import Callsを見てみましょう。

ImportCall: import ( AssignmentExpression )

  1. Let referencingScriptOrModule be ! GetActiveScriptOrModule().
  2. Let argRef be the result of evaluating AssignmentExpression.
  3. Let specifier be ? GetValue(argRef).
  4. Let promiseCapability be ! NewPromiseCapability(%Promise%).
  5. Let specifierString be ToString(specifier).
  6. IfAbruptRejectPromise(specifierString, promiseCapability).
  7. Perform ! HostImportModuleDynamically(referencingScriptOrModule, specifierString, promiseCapability).
  8. Return promiseCapability.[[Promise]].

import()呼び出しはPromiseを返しますが、そのPromiseをステップ4で初期化し、8で返していることが分かります。実際にモジュールを読み込む処理をしているのがステップ7のHostImportModuleDynamicallyです。Hostと名前に付いていることから分かる通り、これもまた環境依存の処理です。

HostImportModuleDynamically

具体的な挙動が環境依存の処理には大抵説明と制約が書かれていますから、チェックしてみましょう。まずは説明です。

HostImportModuleDynamically is an implementation-defined abstract operation that performs any necessary setup work in order to make available the module corresponding to the ModuleSpecifier String, specifier, occurring within the context of the script or module represented by the Script Record or Module Record referencingScriptOrModule. (referencingScriptOrModule may also be null, if there is no active script or module when the import() expression occurs.) It then performs FinishDynamicImport to finish the dynamic import process.

要約すると、「与えられたspecifierを頼りにモジュールを何らかの手段で読み込み、読み込み終わったらFinishDynamicImportを呼ぶような処理である」とされています。

HostImportModuleDynamicallyが持つ制約については長いので引用しませんが、こちらも要約すると以下のようなことが書かれています。

  • HostImportModuleDynamicallyが呼ばれたなら、将来的に必ずFinishDynamicImportが呼ばれなければならない。
  • 同じモジュールに対して複数回HostImportModuleDynamicallyが呼ばれたら、必ず同じ結果にならなければいけない。
  • HostImportModuleDynamicallyが成功した場合、その後ここで読み込まれたモジュールに対してHostResolveImportedModuleを呼び出したなら、その結果はすでにEvaluate()を実行済みのModule Recordでなければならない。

特に最後のものが重要です。この制約から、HostImportModuleDynamicallyの処理に何が期待されているのか明らかになります。Evaluate()はLink()が成功してから呼び出さなければいけないことも踏まえると、HostImportModuleDynamicallyが実行されたら、何らかの方法で処理系は以下のことを行わなければいけないのです。

  • 示されているモジュールを動的に読み込む。
  • そのモジュールに対応するModule Recordを作成する。
  • そのModule Recordに対応するLink()とEvaluate()を実行する。

ここまで終えて初めてFinishDynamicImportが呼び出されます。そのことを踏まえて次はFinishDynamicImportを調べましょう。

FinishDynamicImport

FinishDynamicImportについては具体的な挙動が定義されています。説明には次のように書かれています。

FinishDynamicImport completes the process of a dynamic import originally started by an import() call, resolving or rejecting the promise returned by that call as appropriate according to completion. It is performed by host environments as part of HostImportModuleDynamically.

つまり、import()により読み込まれたモジュールの読み込みが完了し次第、import()によって作られたPromiseを解決するのがFinishDynamicImportの役目です。

なお、import()の返り値のPromiseは、モジュール読み込みが成功した場合はそのモジュールの名前空間オブジェクトです。これを取得するにはModule Recordが必要ですが、その取得のためにはすでに説明したHostResolveImportedModuleを使用します。このHostResolveImprotedModuleの結果がどうなるのかはすでに説明した通りで、HostImportModuleDynamicallyが持つ制約によりすでにEvaluate()が実行済みのModule Recordとなります。

ちなみに、HostResolveImprotedModule自体が「同じモジュールを読み込んだら同じModule Recordを返さなければならない」という制約を持ちますから、複数回同じモジュールをdynamic importしたり、すでに静的にimportされているものをdynamic importしてももう一度同じモジュールが実行されることはありません(すでに説明した通り、Module RecordのEvaluate()を複数回呼び出しても最初の1回しか実際に実行しないという制約があるため)。

dynamic importの挙動はどこまで予測可能か

dynamic importの定義を見ると、大部分がHostResolveImportedModuleの中に押し込められていて、ほとんど何も定義されていないように見えます。これは、そもそもモジュールという概念がECMAScriptの枠に完全には収まらないものであることから仕方ありません。dynamic importされたモジュールがSource Text Module Recordでは無かった場合にECMAScript仕様書でできることはほとんどありません。

最も抽象的なAbstract Module Recordの場合を考えると、ECMAScript仕様書に見えているインターフェースはLink()とEvaluate()だけです。よって、モジュールを実行するというのは、何らかの方法でModule Recordを得たのちにこの2つのメソッドを実行することを指します。

Module Recordを得る部分は完全に環境によりけりですから、その部分が環境依存となっているのは問題ありません。しかし、import()構文の意味は「モジュールを読み込んで実行する」(そして実行結果をモジュール名前空間オブジェクトとして返す)というものですから、ただ読み込むだけでなく「実行する」ことも必要です。

実は、この「実行する」という要求を表すのがHostImportModuleDynamicallyの制約にある「得られたModule RecordはすでにEvaluate()を実行された状態でなければならない」という制約と、「将来的に必ずFinishDynamicImportを呼ばなければいけない」という制約なのです。永遠にFinishDynamicImportを呼ばないわけには行きませんが、FinishDynamicImportを呼ぶ前には必ずModule Recordを作ってEvaluate()を実行しておかなければいけません。よって、HostImportModuleDynamicallyは「モジュールを読み込み、Module Recordを作り、Link()とEvaluate()を実行する」という一連の操作を暗に要求しているのです。具体的な実装を与えずとも、制約によってこれくらいの制御は可能であるという例になっていますね。

ただし、モジュールの読み込みは外的要因で失敗することもあり得るので、処理系には失敗するという選択肢も残されています。よって、実際には「dynamic importが発生したら何もせずに常に失敗を返す」というのもECMAScript処理系としては妥当なものになってしまいます。他にも、将来的に呼び出すことが求められているとはいえいつとは指定されていないので、10年や20年後でも許されるでしょう。ECMAScriptが具体的なモジュール読み込みプロセスに関わらないためこれらは仕方がありませんし、これはdynamic importに限った話でもないのですが。

top-level awaitがES Modulesに与える影響

このおまけではここまでECMAScriptのモジュールの仕様を眺めてきましたが、実はこれらはtop-level awaitが導入される以前のものです。この記事はtop-level awaitが主題なので、top-level awaitが仕様にどのように影響を及ぼすのか解説します。top-level awaitのプロポーザルには(というより全てのStage 3プロポーザルには)仕様テキストが用意されており、典型的にはECMAScript本体との差分という形で表示されます。この記事をここまで読んだ方ならば差分を読むだけで理解できるかもしれませんね。

いくつか重要なところをピックアップします。まずはEvaluate()の定義に対する変更です。従来Evaluateは「モジュールの実行に成功したらundefinedを返し、失敗したらエラーをthrowする」として定義されていましたが、「Promiseを返す」に変更されています。これは、この記事で最初の方に述べた「トップレベルの実行が非同期的になる」という点が反映されています。モジュールの実行は今やawaitで中断される可能性があり、Evaluate()が返すPromiseはそのような中断がもう無くなって完全に実行が終了した段階で成功裏に解決されます。

また、ExecuteModule()の定義にも非同期に対応するための変更が加えられています。ただし、こちらはPromiseを返すのではなく、「実行が終わったら与えられたPromiseを解決する」という形になっています。このためにPromise Capability(Promise本体としてに対するresolve・reject関数がセットになったもの)を受け取れるように変更されました。

特筆に値するのは、Link()の定義が何も変わっていないことです。Linkはあくまで依存関係グラフを構築することが主眼のフェーズであり実際に実行するフェーズではありませんから、top-level awaitの影響を受けないということですね。一方で、Evaluate()については返り値をPromiseにするために少なくない量の変更が入っています。

また、ParseModuleの定義を見ると興味深いことが書かれています。ステップ12です。

Let async be body Contains await.

そして、このasyncフラグは結果のSource Text Module Recordの[[Async]]フラグに格納されます。つまり、あるモジュールが非同期かどうかはそれがトップレベルにawaitを含むかどうかによって決められるのです。これは、top-level awaitがない時代のモジュールにおける後方互換性を守るためです。逆に言えばこれは、トップレベルにawaitを含んでいれば(実際にawaitが実行されないとしても)挙動に影響があるということです。

同期モジュールと非同期モジュールの違い

ちょっと唐突ですが、同期モジュールと非同期モジュールの違いがわかる例を用意しました。

a.mjs
console.log("I am a.mjs");
root.mjs
import("./a.mjs").then(() => console.log("a.mjs is loaded"));
Promise.resolve().then(async () => {
  await null;
  await null;
  console.log("I am root.mjs");
});

とても人工的な例ですが、このroot.mjs`を実行すると、筆者の手元のMacとv8では次の順に表示されます。

I am a.mjs
a.mjs is loaded
I am root.mjs

dynamic importでa.mjsを読み込んだ時点で即座にI am a.mjsと表示されます。その後import("./a.mjs")が解決され、とa.mjs is loadedと表示されます。

一方のI am root.mjsは、Promise.resolve()awaitを用いて、root.mjsが実行されてから3 tick後(マイクロタスク実行ループが3巡した後)にI am root.mjsと表示されます。

a.mjs is loadedI am root.mjsよりも先に表示されることから、import("./a.mjs")の解決が3 tick以内に行われていることがわかります。

ここで、a.mjsを次のように変更してみましょう。

a.mjs
console.log("I am a.mjs");

if (false) await null;

こうすると、root.mjsを実行したときの表示はこのように変化します。

I am a.mjs
I am root.mjs
a.mjs is loaded

公今回新しくawaitを追加したことで、a.mjsは非同期モジュールになりました。ただし、awaitif (false)でガードされているため実際にa.mjsを実行したときの挙動は何も変わないはずです。

しかし、a.mjsが非同期モジュールになったことで、a.mjsが読み込まれるのが遅くなりa.mjs is loadedI am root.mjsよりも後に表示されることになります。ただし、root.mjsawait nullを一つ増やすと結果が元に戻ります。

要するに、a.mjsが非同期モジュールになったこと自体を原因として、import("./a.mjs")が解決されるまでの時間が1 tick長くなったのです。この1 tickの違いがどこで生まれたのかは仕様書を追っていけば説明できますが、そろそろこの記事を書く体力が尽きてきたので省略します。暇な方はぜひ挑戦して記事を書きましょう。

モジュールのimportexportの仕組み

ここまではモジュールがどのように実行されるのかについて焦点をあてて解説しましたが、この記事はES Modules完全理解と銘打っているので、ES Modulesのもうひとつの大きな特徴についても解説します。それはモジュールのimportexportがどのような機構で行われるのかです。

モジュール名前空間オブジェクト

ES Modulesを扱っていると、モジュール名前空間オブジェクトが得られることがあります。これは、import *構文やdynamic importを使うと得られます。例えば、次の例をnode.jsで実行してみましょう。

a.mjs
export const foo = "foo";
export const bar = "bar";
export default "default";
root.mjs
import * as m from "./a.mjs";
console.log(m);

そうすると、console.logによって次のように表示されます。

[Module] { bar: 'bar', default: 'default', foo: 'foo' }

[Module]という表示が、{ bar: 'bar', default: 'default', foo: 'foo' }がモジュール名前空間オブジェクトであることを示しています。

モジュール名前空間オブジェクトは仕様書上でModule Namespace Exotic Objectと呼ばれています。Exotic Objectというのは、通常の言葉とは異なる特殊な挙動を示すオブジェクトの総称です(他にはProxyや配列といったオブジェクトがexotic objectです)。

上の例ではmがModule Namespace Exotic Objectですが、それに対してm.fooなどのアクセスをするとa.mjsからエクスポートされているfooの値である"foo"が得られます。このように、Module Namespace Exotic Objectは「モジュールからエクスポートされている値の集合」を表すようなオブジェクトと見なせます。

このようなプロパティアクセスにおいて、Module Namespace Exotic Objectの[[Get]]内部メソッドが呼ばれます。この[[Get]]内部メソッドの定義を画像で引用します。

スクリーンショット 2020-02-16 0.24.23.png

この実装は、普通のオブジェクトの挙動を模倣しつつも実態は全然別物という様相です。この実装を読むと察せられることは、Module Namespace Exotic Objectはエクスポート元モジュールのトップレベルスコープに直結しているということです。詳しい説明は後に回して、とりあえず定義を読みましょう。

ステップ1〜2はプロパティ名がシンボルの場合(後述)、ステップ3〜4はプロパティ名が存在しなかった場合にundefinedを返す処理です。存在しないプロパティを取得しようとした場合にundefinedが返るというのはJavaScriptにおいて普通のことですが、Module Namespace Exotic Objectの場合はその判断はプロパティ名がO.[[Exports]]に含まれるかどうかによって行われます。O.[[Exports]]というのはあとで解説しますが、恐らく当該モジュールからエクスポートされている名前の一覧であるという想像がつきます。

ステップ5にあるmというのはModule Recordです。ステップ6ではmのResolveExportモジュールを用いてbindingを得ています。あとで説明しますが、このbindingというのはモジュールにおいて非常に重要な概念で、モジュールがエクスポートする名前ひとつひとつに対して対応するbindingが存在します。

ステップ8ではtargetModuleを得ており、察するにこれはbindingが属するモジュールを表すModule Recordです。それはmと同じではないかと思われる方がいるでしょうが、export * from "module"構文をはじめとする「再エクスポート」の構文によって、他のモジュールがエクスポートしたbindingをそのままエクスポートしている可能性が存在します。bindingmが他のモジュールから再エクスポートしたものだった場合、targetModulemとは異なり、オリジナルのモジュールを指すことになります。ステップ10はそのbindingがES2020で追加されたexport * as ns構文でエクスポートされていた場合に対応するものです。この場合エクスポートされているのは別のModule Namespace Exotic Objectとなります。

ステップ11〜14が通常の場合です。ステップ11〜13ではモジュールの[[Environment]]内部プロパティを通してEnvironmentRecordを取得しており、ステップ14でそのGetBindingValueメソッドを用いて値を取得しています。

EnvironmentRecordとは、変数のスコープに対応する仕様書上の概念です。GetBindingValueメソッドはそこから変数の値を取得するメソッドです。これらはモジュールに特有の概念ではなく、単純に「変数の値を取得する」という処理にも使われます。つまり、ステップ11〜14をまとめると「エクスポート元モジュールの(トップレベル)変数スコープからから変数の値を取得する」ということになるのです。

このことは、このような例を通して確かめられます。

a.mjs
export let foo = "foo";

export const setFoo = value => {
  foo = value;
};
root.mjs
import * as m from "./a.mjs";

console.log(m.foo); // "foo"
m.setFoo("hello, world!");
console.log(m.foo); // "hello, world!"

a.mjsfoosetFooがエクスポートされており、setFooは呼び出されるとfooを書き換えます。root.mjsでは、m.setFooを呼び出すことでa.mjsfooの値を書き換えています。

すると、何ということでしょう。m.setFooを呼び出すとm.fooの値が書き換えられてしまいました。これが、Module Namespace Exotic Object ma.mjsのスコープに直結していることを示しています。

Module Namespace Exotic Object はいつ作られるのか

上の例ではmがModule Namespace Exotic Objectでした。では、このmはいつどのように作られるのでしょうか。明らかに、mimport文の作用によって作られます。mがいつ作られるのかをより厳密に調べるには、次の実験が有用です。すなわち、root.mjsをこのように変えてみます。

root.mjs
console.log(m.foo); // "foo"
m.setFoo("hello, world!");
console.log(m.foo); // "hello, world!"

import * as m from "./a.mjs";

このようにimport文を末尾に動かしても、この例は動作します。このことは、モジュールが実行される(Execute()が呼び出される)よりも前にmがすでに用意されていることを示唆しています。ということは、mがLink()の段階で用意されているという説が非常に有力です。ということでLink()の処理の中から当該処理を探し出したいところですが、もう少し考えてみましょう。この処理はSource Text Module Recordに特有であることを考えると、Link()の処理中で呼び出されるInitializeEnvironment()メソッド中に記述されているというあたりを付けることができますね。ということで見るべきはSource Text Module RecordのInitializeEnvironment()メソッドの定義です。これは非常に長いので引用はせずに進みます。

このメソッドのステップ6〜8を見ると、このモジュール用の新しいEnvironmentRecord(≒このモジュールのトップレベル変数スコープ)が作られていることが読み取れます。そして、ステップ9でこのモジュールが含む全てのimport文に対してループしているようです。import * as m from "..."構文の場合はステップ9-cが実行されます。

i. Let namespace be ? GetModuleNamespace(importedModule).
ii. Perform ! envRec.CreateImmutableBinding(in.[[LocalName]], true).
iii. Call envRec.InitializeBinding(in.[[LocalName]], namespace).

ステップiでGetModuleNamespaceを用いてインポート元モジュールに対するModule Namespace Exotic Objectを作成しています。ステップii, iiiではEnvironmentRecordのCreateImmutableBinding・InitializeBindingを用いてこのモジュールのトップレベルスコープにインポートされた名前の変数(この場合はm)を生やしています。

ステップ9-dでそれ以外の場合も扱っていますので、合わせて見てみましょう。

また、「Module Namespace Exotic Objectがいつ作られるのか」という問いに答えるにはGetModuleNamespaceの中身を見に行く必要があります。ざっと読むと、インポート元のモジュールからexportされている名前を全部取得してそれをModuleNamespaceCreateに渡しているように見えます。このModuleNamespaceCreateは、オブジェクトを作ってModule Namespace Exotic Object用の内部スロットを定義するだけです。先ほど[[Exports]]内部プロパティが出てきましたが、これはエクスポートされている名前の一覧をArray.prototype.sortでソートしたものであると書いてあります。

Environment Recordとbinding

ここまでの説明では、EnvironmentRecordというものが何度も出てきました。これは変数のスコープを表す概念であり、そこに属する変数はbindingと呼ばれます。

仕様書の8.1.1 Environment Recordsで定義されている通り、スコープに属するそれぞれの変数はbindingとしてEnvironment Recordに格納されています12。bindingはImmutable BindingとMutable Bindingの2種類があり、Immutable Bindingはその名の通り書き換え不可のbindingで、const宣言により作られます。また、上で見たimport * as m構文で作られるmのbindingもこのImmutable Bindingです。

例えば、次のプログラムを見てみましょう。

// Environment Record 1
const foo = "foo";
let bar = "bar";
if (true) {
  // Environment Record 2
  let foo = "123";
}

このプログラムには2つのEnvironment Recordが関わっています。トップレベルのEnvironment RecordであるEnvironment Record 1と、if文のブロックの中のスコープを表すEnvironment Record 2です。前者はfooという名前のImmutable Bindingとbarという名前のMutable Bindingをもち、後者はfooという名前のMutable Bindingを持っています。

変数の操作は仕様上ではこれらのEnvironment Recordからbindingの値を取得したり、bindingの値を書き換えたりという操作として定義されています。

また、モジュールのトップレベルスコープを表すModule Environment Recordにおいては、第3の種類のbindingであるImmutable Imported Bindingが存在します。これは他のモジュールからインポートされたbindingを表すImmutable Bindingであり、その値を取得しようとした場合は元のモジュールのEnvironment Recordから再帰的に値を取得します。Immutable Imported Bindingは通常のimport文により作られます。

少し前に出てきた例を少し変えることでこれを確かめましょう。

a.mjs
export let foo = "foo";

export const setFoo = value => {
  foo = value;
};
root.mjs
import { foo, setFoo } from "./a.mjs";
console.log(foo); // "foo"
setFoo("hello, world!");
console.log(foo); // "hello, world!"

a.mjsは元々の例と全く変わりませんが、root.mjsが変わっています。今度はimport * as m構文を用いず、import { foo, setFoo }としてfoosetFooにインポートされます。これはroot.mjsのEnvironment Record内にfoosetFooというImmutable Imported Bingingを作ります。これが意味するところは、root.mjsの中でfooの値を取得すると常にa.mjsfooの値が取得されるということです。よって、先ほどと同様にsetFooを呼び出すとfooの値が変わります。

このように、Immutable Imported Bindingである変数においては、インポート先でその値を全く操作しなくてもインポート元により値が変わることがあります。これはImported Bindingに特有の事象であり、慣れていないと困惑しますから注意が必要です。特に、「import文は値をインポートしているのではなくbindingを(間接的に)インポートしている」という理解が重要です。

モジュールからエクスポートされる名前はどう決定されるのか

モジュールが名前をエクスポートするのは非常に基本的な事項であり、Abstract Module Recordですら名前をエクスポートすることができます。これに関連して、Abstract Module Recordが持つ2つのメソッドが定義されています。すなわち、GetExportedNamesとResolveExportです。

GetExportedNames([exportStarSet])

Return a list of all names that are either directly or indirectly exported from this module.

ResolveExport(exportName [, resolveSet ])

Return the binding of a name exported by this module. Bindings are represented by a ResolvedBinding Record, of the form { [[Module]]: Module Record, [[BindingName]]: String }. If the export is a Module Namespace Object without a direct binding in any module, [[BindingName]] will be set to "namespace". Return null if the name cannot be resolved, or "ambiguous" if multiple bindings were found.

Each time this operation is called with a specific exportName, resolveSet pair as arguments it must return the same result if it completes normally.

GetExportedNamesはそのモジュールがエクスポートする名前の一覧を取得するものです。exportStarSetという引数がありますが、これはSource Text Module Recordにおいてexport *により形成される無限ループの対策として用いられるものです。Source Text Module Recordの場合はあらかじめParseModule時に自身が持つ
export文の一覧を[[LocalExportEntries]], [[IndirectExportEntries]], [[StarExportEntries]]の3つに分類して保持しており、GetExportedNamesはそれらの名前を全部列挙して返す処理を行います。[[LocalExportEntries]]はローカル変数をexport文でエクスポートした場合、[[IndirectExportEntries]]はimport文でインポートした名前をそのままexport文で再エクスポートした場合、そして[[StarExportEntries]]はexport * from "module"構文で他のモジュールがエクスポートする名前を全てい再エクスポートする場合です。[[StarExportEntries]]に対しては、そこで参照されているモジュールに再帰的にGetExportedNamesを呼び出すことでエクスポートされた名前の一覧を完成させます。

余談ですが、このことから「importされた名前をそのまま再エクスポートする」という行為は特別な扱いを受けることが分かります。

b.js
import { foo } from "./a.mjs";

export { foo };
export const foo2 = foo;

このようにした場合、b.jsfoofoo2の2つの名前をエクスポートしていることになりますが、前者は[[IndirectExportEntries]]に分類され、後者は[[LocalExportEntries]]に分類されます。また、b.mjsのEnvironment Recordにおいては前者はImmutable Imported Bindingである一方、後者はImmutable Bindingとなります。

これが意味することは、a.mjsfooの値が変更された場合、b.mjsfooはその影響を受ける一方でfoo2は影響を受けないということです。foo2に対するfoo2 = fooという代入はb.mjsが実行された瞬間に行われるため、foo2の値はその瞬間のfooで固定され、その後a.mjsfooの値が変更されてもfoo2の値には変更されません。foo2fooを参照するImported Bindingではなくただのローカル変数であることから考えれば、これは自然な振る舞いですね。

一方のResolveExportは、そのモジュールからエクスポートされている1つの名前に対するResolved Binding(これはbindingの所属モジュールとそのモジュールにおける名前のペアです)を返すメソッドです(名前が見つからなかった場合はnullを返すと言った挙動もあります)。特に名前の再エクスポートがある場合、オリジナルのモジュールまで再帰的に辿っていくことになります。

ResolvedExportは、Source Text Module Recordにおけるimport文の処理(InitializeEnvironment()時)に用いられます。例えば、b.mjsimport { foo } from "./a.mjs";というimport文を持っていた場合、このimport文の初期化時にはa.mjsを表すModule Recordに対してResolveExport("foo")という問い合わせが実行されることになります。すると、ResolveExportはfooの大元のbindingを探して返してくれるのです。

a.mjsexport const foo = ...のようにしていればfooの大元のモジュールはa.mjsですが、もしこれはexport { foo } from "./other.mjs"のような再エクスポートだった場合は大元のモジュールはother.mjs(またはさらに別のモジュール)であることになります。

大元のモジュールを知ることは、import文によるImmutable Imported Bindingの向き先を決めるために必要になります。

また、少し前にModule Namespace Exotic Objectのプロパティが取得されたときの処理を見ましたが、このときもResolveExportを用いてbindingを解決してからそのbindingの値を読むという処理を行なっています。

おまけのまとめ

おまけとして、ES Moduleに関する仕様を一通り解説しました。

基本的な事項として、モジュールというものは仕様書の中ではModule Recordとして表されるということをまず説明しました。Abstract Module Record, Cyclic Module Record, Source Text Module Recordという3階層に区分されたインターフェース定義や、HostResolveImportedModuleとかHostImportModuleDynamicallyといったインターフェースを通して、外部環境による拡張可能性が提供されています。

次に、モジュールが実行されるまでにはModule Recordを作る、Link()を作る、Evaluate()を実行するという3ステップがあることも解説しました。HTML仕様書ではscript要素の解釈に際して実際のこの3ステップを実行していることも解説しました。

ECMAScript仕様においてモジュールの周りは仕様書リーディングの難易度が高い部分ですが、この記事の解説があれば問題なく読みこなせるでしょう。もし自分でJavaScriptを実行する何らかの仕様を書きたくなっても書くことができますね。Module Recordを作成し、Link()とEvaluate()を実行すればOKです。

最後に、モジュールのインポート・エクスポートの仕組みを解説しました。インポートされたものはモジュールのトップレベルに位置するModule Environment Recordにbindingとして登録されることで、インポート先で使用できるようになるのでした。また、再エクスポートという概念がインポート・エクスポートの解決を多少ややこしくしていることが分かりました。再エクスポートの概念を理解するためには、モジュール間でやり取りされるのは値ではなくbindingであるということをしっかりと分かっている必要があります。再エクスポートでは、概念上はまさに同じbindingをエクスポートしているのです。

まとめ(再掲)

実はこの記事のまとめは途中にあったのですが、途中で記事を読むのに飽きて最後までスクロールしてきた方のためにまとめを再掲します。

この記事では、Stage 3となりTypeScript 3.8によるサポートが追加されたtop-level awaitについて解説しました。top-level awaitが本質的にはES Modulesの意味を変えるものであることを説明し、併せてES Modulesについても必要に応じて解説しました。

TypeScript 3.8によりサポートされたといっても構文を理解できるようになったという話で、実際にtop-level awaitが使えるようになるまでには各種実行環境やバンドラによるサポートが必要となりますから、まだ先の話でしょう。

top-level awaitは非同期処理を行なった結果をエクスポートしたい場合などにたいへん便利ですから、実用化されたらぜひ使っていきたいですね。


  1. HTMLから呼び出されるJavaScriptの場合は、<script type="module">によって読み込まれたものがモジュールと見なされ、それ以外はスクリプトです。TypeScriptでコンパイルする場合には、import文かexport文を含むファイルがモジュールと見なされます。 

  2. 本当に同時に実行されるのか、それとも同時に実行するのかは1つだけなのかという違いを並列 (parallel)・並行 (concurrent)という用語で区別することもあるようですが、この定義に関しては信頼できる情報源がいまいち見当たらないので個人的には使用を避けています。信頼できる(学術的な)ソースをお持ちの方はぜひご連絡ください。 

  3. なお、node.jsでは(--harmony-top-level-awaitオプションを有効にしても)dynamic importとtop-level awaitの組み合わせをまだサポートしていないようです。この例はv8 (d8) を直接動作させることで動作を確認できます。 

  4. 実際にはECMAScriptのモジュールではJavaScriptファイル以外をモジュールとして読み込むことも可能(もちろんプラットフォームのサポートが必要)な仕様になっていますが、静的importの場合はその場合も「何がexportされているか」は実行する前に判明していなければいけません。node.jsではESモジュールからCommonJSモジュールを読み込む場合は必ずdynamic importを使用しなければいけませんが、その理由はこの点にあります。 

  5. 仕様書ではroot of the cycleと呼ばれています。これはその強連結成分の入口となったモジュールであり、より厳密に言えば依存関係グラフを深さ優先探索するときに行きがけ順につけたID(0, 1, 2……と振られていく)が強連結成分の中で一番小さいモジュールとして定義されます。 

  6. デッドロックの定義は2つ以上のプロセス(など)が互いを待っている状態と定義されるらしいので、この状態をデッドロックと呼ぶのが妥当なのかは少し怪しいかもしれません。一応、a.mjsb.mjsが互いを待ち続けていると見ることもできますが。 

  7. ファイルを取得しないと次の依存先がわからないので、愚直に読み込むと依存関係グラフの深さの数だけサーバーと往復しないといけないという問題があります。この問題を解決すると思われていたHTTP/2 Server Pushもうまくいっていません。 

  8. 記事タイトルに「TypeScript 3.8でサポートされた」とありますが、TypeScriptも当然ながらtop-level awaitがあっても何かトランスパイルするわけではありません。出力にもそのままtop-level awaitが残ります。TypeScriptはあくまでtop-level awaitを従来文法エラーとしていたのを許すようにしただけです。 

  9. Webpackは今の所JavaScriptソースコードであるようなモジュール(またはwasmモジュール)を対象として扱うため、CSSファイルは典型的にはstyle-loaderによってJavaScriptに変換されます。 

  10. node.jsでモジュールからdynamic importでCommonJSモジュールを読み込む場合はCommonJSモジュールはSource Text Module RecordではなくCyclic Module RecordかAbstract Module Recordとして扱われそうです(CommonJSモジュールのソースコードはES ModulesでいうModuleに適合するものではないため)。node.jsの挙動は仕様化されていないので証左はありませんが。 

  11. 尤も、循環する依存関係がある場合にはこの制約は完全には満たされていないのですが。実際のところ、Cyclic Module Recordではその場合の処理が明示されており、Abstract Module Recordの場合は循環参照を作らないので大きな問題ではありません。 

  12. JavaScriptのスコープはネストしていますが、Environment Record自体にはその対応は含まれておらず、あくまで一つのスコープを司る比較的単純な概念として存在しています。ネストしたスコープの探索はGetIdentifierReferenceが担当しています。 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

「JavaScript の this を理解する多分一番分かりやすい説明」を読んで手を動かして理解した

JavaScript の this を理解する多分一番分かりやすい説明を読んで手を動かしてみた。

サンプルコード

function test() {
    console.log(this)
}
var obj = {}
obj.test = test

console.log(test) //1
console.log(obj)  //2
test() //3
obj.test() //4

1

関数そのものを出力。

ƒ test() {
    console.log(this)
    }

2

関数そのものが入っているオブジェクトを出力。

{test: ƒ}

3

thisはグローバルオブジェクト、ブラウザではWindowオブジェクトをさす。

Window {parent: Window, opener: null, top: Window, length: 0, frames: Window, …}

4

thisはobj(関数そのものが入っているオブジェクト)をさす。

{test: ƒ}

メソッドチェーン

var obj = {
    test: function() { return this },
    alert: function(msg) { console.log(msg) }
    }
var test = obj.test

console.log(test) // 1
console.log(obj.test()) // 2
console.log(test()) // 3
test() // 4
obj.test().alert("hello") // 5
test().alert("hello") // 6

1

オブジェクトの中身のtestそのものを出力。

ƒ () { return this }

2

thisはobjをさすことを出力。

{test: ƒ, alert: ƒ}

3

thisはグローバルオブジェクト、ブラウザではWindowオブジェクトをさすことを出力。

Window {parent: Window, opener: null, top: Window, length: 0, frames: Window, …}

4

関数名ではないため呼び出せない。

5

オブジェクトの中身のalertを発動

hello

6

Windowオブジェクトに対してalertを発動

helloというアラートを表示
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ReactNavigation v.5 とReduxでタブのバッジ数を管理する

この記事でやること

Reduxで管理する通知バッジ数をReactNativeのボトムタブに表示させる記事です。
通知バッジ数はスクリーンをまたぐ変数なので、Reduxで管理するのが良いかと思います。

こんな感じのやつです ↓↓ (この記事ではこれの簡易版を作ります)
S__31916034.jpg

ReactNavigation v.4では、ボトムタブのレンダリングのタイミングに癖があった(?)ようです。
Reduxで状態を更新してもボトムタブに即時反映はされませんでした(僕の周りでも何人か言ってましたが、間違っていたら教えてください!)。
僕は無理やりテキトーな変数を入れたNavigationActionsをボトムタブにdispatchすることで、無理やり再レンダリングさせて、即時反映させていました(本当はこの記事はそれを書く予定だった)。

しかしなんと、v.5ではそんな必要がなくなってました…!
ありがてぇ…!

やりたいこと

画面から通知バッジ数を変更してボトムタブの数字に即時反映

主な環境

  • Expo 36.0.0
  • react 16.9.0
  • @react-navigation/bottom-tabs 5.0.5
  • @react-navigation/native 5.0.5
  • @react-navigation/stack 5.0.5
  • react-redux 7.1.3
  • redux 4.0.5

ReactNavigationはモジュールの移動が激しいですね。
公式ドキュメントを読んで、必要なライブラリをインストールしていってください。

画面構成

ホームスクリーンをStackにして、それをひとつのタブに対応させる単純な画面構成です。

コード

Redux

初期状態とreducerの定義をします

src/reducers/index.js
const INITIAL_STATE = {
    badgeNumber:0,
}

const reducer = (state=INITIAL_STATE, action) => {
    switch (action.type){
        case "SET_BADGE_NUMBER":
            return {...state, badgeNumber:action.badgeNumber}
        default:
            return state;
    }
}

export default reducer

actionの定義をします

src/actions/index.js
export const setBadgeNumber = badgeNumber => ({
    type:"SET_BADGE_NUMBER",
    badgeNumber
})

storeを作ります

src/store.js
import { createStore } from 'redux'
import reducers from './reducers'

export default createStore(reducers)

スクリーン

HomeScreenを作り、Reduxと繋げます。badgeNumber+1というテキストをタッチするとバッジ数がインクリメントされる仕様に。

src/screens/Home.js
import React from 'react';
import { Text, View, TouchableOpacity } from 'react-native';
import { setBadgeNumber } from '../../src/actions'
import { connect } from 'react-redux'

const HomeScreen =({ badgeNumber,setBadgeNumber})=>{
  return(
    <View>
      <TouchableOpacity onPress={()=>setBadgeNumber(badgeNumber+1)}>
        <Text>badgeNumber + 1</Text>
      </TouchableOpacity>
    </View>
  )
}
const mapStateToProps = state => ({
  badgeNumber: state.badgeNumber
})

const mapDispatchToProps = {
  setBadgeNumber
}

 const Home = connect(
    mapStateToProps,
    mapDispatchToProps
  )(HomeScreen)

  export default Home

ナビゲーション

ReactNavigationでナビゲーションを作ります。
StackとTabでHomeScreenをラップしていきます。

App.js
import React from 'react';
import { Text } from 'react-native';
import { createStackNavigator } from '@react-navigation/stack';
import { NavigationContainer } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { Provider, connect } from 'react-redux'
import { setBadgeNumber } from './src/actions'
import store from './src/store'
import Home from './src/screens/Home'

// ここでStackをつくります()今回は1画面だけだけど
const Stack = createStackNavigator();

const HomeStack=()=>{
  return (
    <Stack.Navigator>
      <Stack.Screen name="Home" component={Home} />
    </Stack.Navigator>
  );
}


// ここでBottomTabを作ります(今回は上で作ったStack1つだけ)
const BottomTab = createBottomTabNavigator();

const MyBottomTab=()=>{
  return (
    <BottomTab.Navigator>
      <BottomTab.Screen 
        name="Home" component={HomeStack}
        options={{
          tabBarLabel: 'Home',
          tabBarIcon: () => (
            <Text>{store.getState().badgeNumber}</Text>
          ),
        }} />
    </BottomTab.Navigator>
  );
}


const Main=()=>{
  return(
    <NavigationContainer>
      <MyBottomTab>
      </MyBottomTab>
    </NavigationContainer>
  )
}

const mapStateToProps = state => ({
  badgeNumber: state.badgeNumber
})

const mapDispatchToProps = {
  setBadgeNumber
}

const ConnectedMain = connect(
  mapStateToProps,
  mapDispatchToProps
)(Main)

const App=()=>{
    return (
        <Provider store={store}>
            <ConnectedMain />
        </Provider>
    )
}
export default App

これで、HomeScreenのbadgeNumber+1をタッチすれば、ボトムタブの数字も更新されていくと思います。

まとめ

基本に忠実なReduxの使い方、構成です。
上記のコードだけでタブに即時反映してくれるようになってとてもありがたいですね。
ReactNavigation v.5はv.4に比べて見通し良くなったと思います(前はcreateなんとかnavigatorみたいなのが何をやっているかわかりづらかった)。

今回は、HomeScreenから通知バッジ数を変更する仕様でしたが、プッシュ通知が来たらバッジ数をインクリメントすることも考えられるかなと。
僕も、プロジェクトにおいてWebsocket対応の優先順位はまだ低いと感じたときには、プッシュ通知が来たのをトリガーにバッジ数をインクリメントしています。
その場合、Focusされているのがどの画面か問わず、storeの状態を書き換えなければいけませんが、上記で言うMainコンポーネントの中でNotifications.addListenerを使ってハンドリングすると上手くいきます。

参考になれば幸いです!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

javascriptタブの切り替え ザッと復習

JavaScript

カリキュラムの復習。タブの切り替えです。
これから使いそうなのでまとめてみました。
Image from Gyazo

タブ切り替え

今回メニュー三つはmenu_itemクラス、クリックされた時にactiveクラスを付与
メニュー下の内容はcontentクラスに、クリックされた時にshowクラスを付与して切り替えます。

まずノードを取得し、配列に変換する

main.js
let tabs = document.getElementsByClassName("menu_item");
  // tabsを配列に変換する
  tabsAry = Array.prototype.slice.call(tabs);
Array.prototype.slice.call()

これで引数にとったオブジェクトを配列に変換してくれます。
menu_item三つがtabsAryに入ります。

activeクラスの切り替え

関数を作ります(今回はtabSwitch)

main.js
// クラスの切り替えをtabSwitch関数で定義
  function tabSwitch() {
    // 全てのactiveクラスのうち、最初の要素を削除("[0]は、最初の要素の意味")
    document.getElementsByClassName("active")[0].classList.remove("active");

    // クリックしたタブにactiveクラスを追加
    this.classList.add("active");

ノード取得しclassList.removeでactiveクラスの削除
this.classList.addでクリックした所にactiveクラスの追加

showクラスの切り替え

menu_itemと違いclickされずに変更するためactiveクラスとは違う方法が必要

main.js
// コンテンツの全てのshowクラスのうち、最初の要素を削除
    document.getElementsByClassName("show")[0].classList.remove("show");

 // 何番目の要素がクリックされたかを、配列tabsから要素番号を取得
    const index = tabsAry.indexOf(this);

    // クリックしたcontentクラスにshowクラスを追加する
    document.getElementsByClassName("content")[index].classList.add("show");
  }
indexOf()

inexOf()は配列に対してだけ使い、DOMを引数にとって一致した要素番号を戻します。
これでクリックされて付与するactiveクラスと同じ番号を取得できます。
その番号にclassList.addでshowクラスを追加します。

tabSwitchの呼び出し

これでtabsAryに入ったmain_item(value)がクリックされた時にtabswitch関数を呼び出せます。

main.js
 // タブメニューの中でクリックイベントが発生した場所を探し、下で定義したtabSwitch関数を呼び出す
  tabsAry.forEach(function(value) {
    value.addEventListener("click", tabSwitch);
  });

以上復習になりました。
forEach文については多くの使い方があるので、わかりやすかったsamuraiblogさんの参考サイト載せておきます。
https://www.sejuku.net/blog/20257

間違えているところあったら申し訳ございません。
ありがとうございました!!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Vue.js]外側をクリックすると閉じるメニュー(複数の場合)

はじめに

  • メニューボタン押すと開く
  • メニューボタンもう一度押すと閉じる
  • メニューの外側押しても閉じる

この操作はこちら↓の記事を読んでもらえればできます。
[Vue.js]外側をクリックすると閉じるドロップダウンメニュー

ですが、複数ボタンがある場合、この処理だけだと操作性の問題が生じたため、いろいろ改善しました。それを紹介します。

注意 : 本記事は上で紹介した記事の技術がわかった上でお話をします。

[問題 1] メニューが閉じない場合がある

複数のボタンがある時、メニューを一度開き、別のボタンを押すと先に開いていたメニューが閉じない。
デモ (JSFiddle)

以下の画像をご覧ください。
Info 1 から順番にボタンを押していくと、Info 2を押した時にInfo 1のメニューが閉じない。
image.png

構造

親コンポーネント↓ : <msg-comp> を複数呼び出して並べる。

<div id="app">
  <div class="containter">
    <msg-comp
      v-for="msg in msgList"
      :msg="msg"
    ></msg-comp>
  </div>
</div>

<msg-comp>コンポーネント↓ : ボタンを押すと <info-menu> が開く。

<div class="msg">
  <button v-on:click.stop="showInfo = !showInfo">Info {{msg}}</button>
  <info-menu 
      v-if="showInfo"
      v-on:close="showInfo = false">
  </info-menu>
</div>

<info-menu>コンポーネント↓ : クリックイベントが発生した際、クリックした要素がこのコンポーネント内の要素ではない場合に閉じる。

<div class="info">
    <div>AAA</div>
    <div>BBB</div>
    <div>CCC</div>
</div>

問題1 の原因

<!--  <msg-comp> コンポーネント内  -->
<button v-on:click.stop="showInfo = !showInfo">Info {{msg}}</button>

.stop modifierでバブリングの停止を行っているのが原因。
Info 2 を押してメニューを開ける時に、自分自身のメニューを閉じるイベントが発火しないようになっているのはいいんだが、 Info 1のメニューを閉じるイベントも発火しない。

問題1 解決策

  • .stop modifierをやめて自前で実装。
  • クリックした要素が開けるボタンだった時に、閉じる処理を発火させないようにする。

問題1 の解決策 デモ (JSFiddle)

まず、<msg-comp>コンポーネント内のbuttonから.stopを削除。
ref="msgBtn"でボタンのDOMを取得。取得したDOM要素を<info-menu>コンポーネントへpropsで投げる。

<!--  <msg-comp> コンポーネント内  -->
  <div class="msg">
    <button 
      ref="msgBtn" 
      v-on:click="showInfo = !showInfo"
    >
      Info {{msg}}
    </button>
    <info-menu 
      v-if="showInfo"
      v-on:close="showInfo = false"
      :msgBtn="$refs.msgBtn"
    >
    </info-menu>
  </div>

<info-menu>コンポーネントで、ボタンのDOM(msgBtn)を受け取る。
イベントリスナーの発火条件を追加。
 これで判定できる→ !this.msgBtn.contains(e.target)
 → 意味:クリックしたDOMが、ボタンのDOM内にあればfalseになる

Vue.component('info-menu', {
  mixins:[listener],
  template: '#info',
  props: {
    msgBtn: {
      type: HTMLButtonElement
    }
  },
  created:function(){
    this.listen(window, 'click', function(e){
      if (!this.$el.contains(e.target) && !this.msgBtn.contains(e.target)){
        this.$emit('close');
      }
    }.bind(this));
  }
}

注意点:propsにtype指定する際、msgBtnのDOMの型(type)をHTMLButtonElementとしている。a要素とかを使うなら変わる。

以上です。
Info 1を押して開いた状態でInfo 2を押すとInfo 1のmenuが閉じる! 解決できました。

image.png

[問題 2] Tab移動でInfo内部から抜けた時に勝手に閉じて欲しい

これは問題というより、そういう設計にしたかったという願望です。
Infoのサイズが大きい場合にはUX的にもそうしたいですよね。

問題2 解決策

問題2 の解決策 デモ (JSFiddle)

これはめちゃ簡単。新しくイベントリスナー追加するだけ。

created:function(){
  // ~~~
  this.listen(window, 'keyup', function(e){
    if (!this.$el.contains(e.target) && e.key === 'Tab'){
      this.$emit('close');
    }
  }.bind(this));
}

注意点としてはkeydownではなくkeyupにするところ。
keydownにしてしまうと、まだbuttonにいて、Infoに移ろうとする時に発火してしまい、すぐ閉じてしまう。
keyupにしておくと、Tabのkeydown時にInfoに移るので、keyupが発火する時にはInfoにいる。よって閉じない。

[おまけ] Nuxt.jsでやりたい!

Nuxt.jsでやる際に参考になりそうな話をします。

mixinをNuxtでやる

Nuxt.jsの場合、mixinは pluginsフォルダにmixin-common-methods.js 的なものを作り、
nuxt-config.js 内に plugins: ['@/plugins/mixin-common-methods'] とすれば良い。
各コンポーネントからわざわざ呼び出しとかもなし、これだけで使える。

参考 : Nuxt.jsで異なるコンポーネントから共通で利用できる関数を定義する(mixin編)

plugins/mixin-common-methods.js
import Vue from 'vue'

Vue.mixin({
  destroyed() {
    if (this._eventRemovers) {
      this._eventRemovers.forEach(function(eventRemover) {
        eventRemover.remove()
      })
    }
  },
  methods: {
    listen(target, eventType, callback) {
      if (!this._eventRemovers) {
        this._eventRemovers = []
      }
      target.addEventListener(eventType, callback)
      this._eventRemovers.push({
        remove() {
          target.removeEventListener(eventType, callback)
        }
      })
    }
  }
})
~/components/info-menu.vue
export default {
  props: {
    msgBtn: {
      type: HTMLButtonElement
    }
  },
  //~~~
  created:function(){
    this.listen(window, 'click', function(e){
      if (!this.$el.contains(e.target) && !this.msgBtn.contains(e.target)){
        this.$emit('close');
      }
    }.bind(this));
  },
  //~~~
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

どこ住み?てかSlackやってる?生後10ヶ月のエンジニアが爆速10時間でChatbot制作!‬

[前置き]
閲覧ありがとうございます!エンジニア歴10ヶ月のひよっこwebでべろぱーです。
本投稿はエンジニア歴3年未満程度の初学者向け投稿になります。
個人で何か作りたいけど、丁度いい規模感のアイデアが思い付かない...
そんなあなたに!お手軽なChatbotの制作をオススメします!
というわけで作ってみましたー!↓↓

作ったもの -What I made-

??地域の飲食店検索Slackチャットボット「GourmetNav」 ??

Slack上で動作するチャットボット。
「駅名」+ 「空白」 + 「店名に関わるキーワード」を入力すると、その地域の飲食店名とお店のURLを飛ばしてくれる(σ・ω・)σ
gourmetnav.gif

おもしろいところ

  • 地図を見ずに、駅名なんとなくの店名だけで検索できる。Slackで!!
  • チェーン店も、もちろん対象。目指せ、地域のスタバマスター!٩(。˃ ᵕ ˂ )وイェーィ♪

なにができるの

  • 「GourmetNav」の検索結果から、ワンクリックで各飲食店のサイトに飛べる。
  • わざわざブラウザで色んなページを見に行ったりしなくていい。
  • 検索結果にたまーに混じっている別の地域のお店、とかがない(←ここ重要!)

技術的な話

TL;DR (大まかなデータの流れ)

Slack × GAS × 駅情報取得API × ぐるなびAPI を使用しています。

  1. Slackのチャンネル「GourmetNav」から、「駅名」+ 「半角(または全角)空白」 + 「店名に関わるキーワード」を入力、送信。
  2. Slackから受け取った入力情報をGAS上で参照。
  3. 駅情報取得APIを用いて、入力情報内の「駅名」から緯度・経度を取得。
  4. ぐるなびAPIを用いて、上記の緯度・経度からその地域の飲食店を検索、一覧にしてGoogle Spreadsheetに出力(2度目以降はシートを初期化して更新するのでデータは保持しない)
  5. 生成されたSpreadsheet内の飲食店一覧から、「店名に関わるキーワード」と部分一致する「飲食店名」とその「サイトURL」を取得。
  6. 取得した「飲食店名」とその「サイトURL」をSlack側に出力。
  7. Slackのデータ容量圧迫しないように投稿を毎日自動削除するようにトリガーを設定(任意)

具体的な面倒くさいところ

1. まず、ローカルでの開発環境構築(任意)

ある程度のコード量/ロジックを書くならば、GASをブラウザ上のスクリプトエディタでベタ書きするのは、間違いなくNonsenseなんじゃないかと思います。(知らんけど)
(参考): Google Apps Script をローカル環境で快適に開発するためのテンプレートを作りました
(著者): @howdy39

※型定義ないとデバックしづらいのでTypeScriptも入れました(任意)

今回デバッグしていて初めて気付いたんですけど、Javascriptって配列にtypeofかけても「object」って返されるんですね。なんて暗黒言語!!
競技プログラミング(Atcoder)でC#愛好家の私には拒絶反応が出たので、typescript導入しました。
(参考): clasp + TypeScriptで課題改善botを作った
(著者): @mochisuna
(参考): JavaScriptの型などの判定いろいろ
(著者): @amamamaou

2. GASでSlackからの入力情報を受け取り、参照し、出力する(必須)

開発環境を整備したら、まずはSlackとBotによる簡単な入出力を実装する。
下記の記事を参考にすると、以下のようなことができる。
✔️GASでPOSTリクエストを受け取る
✔️SlackでOutgoing WebHookの設定を行う
✔️GASでSlackから渡された値を参照する
(参考): Slack上のメッセージをGoogleAppsScriptで受け取ってよしなに使う
(著者): @kyo_nanba

※このとき、SlackAppというライブラリを使用することで設定が少し楽になる

世の中のSlack愛好家の皆さんに圧倒的感謝!
SlackApp作成者@soundTricker さんに三跪九叩頭しましょう。
(参考): Slack BotをGASでいい感じで書くためのライブラリを作った

3. 駅情報取得API, ぐるなびAPIを上記の中に組み込んで出力内容を工夫する

この部分に関しては、記事読んで実践するだけですんなり実装できました。
(参考): GoogleAppsScriptでぐるなびAPIから取得した駅周辺のお店をスプレッドシートに書き出す
(著者): @kouheidev

4. 上記参考記事を悪魔合体させながら論理構造を調整

今回は社内Slack上で作成したこともあり、一旦ソースコードは開示しません。
(まだまだ生後10ヶ月のエンジニア、トークンの扱いとかGASの仕様とか、セキュリティ面に関しては完璧に理解してるわけじゃないので!)
ただまあ、ベテランの皆さんなら多分苦もなく実装できると思います。(・ω・。)


苦労したところ

無限ループ??

GAS上でのテストでは問題ないのに、
Slackで実際に入出力をテストすると無限ループが頻繁に発生しました。(恐ろしい...)
暫くして気付いたのですが、下記のロジックによるものでした。
ユーザの「A」という検索ワードに対して、「GourmetNav」は該当する検索結果を見つけてきます。
そして、「Aが見つかりました!」とSlackに出力してくれるわけです。
この出力を、GAS側は「ユーザによる新しい入力」として検知し、再び検索/出力処理を繰り返すわけです。
なるほど〜〜それはそうだわ! ということで、
doPost内の記述に下記を加えることで無限ループは解消します!

  if (e.parameter.user_name == "slackbot"){
    throw new Error("this is bot."); //入力者がbotだった場合、エラーを返します
  }

まとめ

Slackからのデータ入力をSpreadsheetに出力だとか、
Spreadsheet側の入力をSlackに出力するだとか、
こうした一方通行なBotならQiita上にも数多くあります

ただ、今回作成したのはInteractiveなChatBot。
ユーザ入力に対して、GAS側で入力を検知、
各API内を検索、Spleadsheetに検索結果を出力、
そして、ユーザの求めている情報にマッチする情報のみをユーザに提供する。

こうしたInteractiveなSlackチャットボット制作記事は、決して多くはないかと思います。
GASでSlack向けに開発している方にとって、何かの一助となれば幸いです。

ついったフォローしてね。(・ω・。)
@NadjaHarold

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

どこ住み?てかSlackやってる?生後10ヶ月のエンジニアによる爆速10時間Chatbot制作総まとめ!!‬

作ったもの -What I made-

??地域の飲食店検索Slackチャットボット「GourmetNav」 ??

Slack上で動作するチャットボット。
「駅名」+ 「空白」 + 「店名に関わるキーワード」を入力するとがその地域の飲食店名とお店のURLを飛ばしてくれる(σ・ω・)σ
gourmetnav.gif

おもしろいところ

  • 地図を見ずに、駅名なんとなくの店名だけで検索できる。Slackで!!
  • チェーン店も、もちろん対象。目指せ、地域のスタバマスター!٩(。˃ ᵕ ˂ )وイェーィ♪

なにができるの

  • 「GourmetNav」の検索結果から、ワンクリックで各飲食店のサイトに飛べる。
  • わざわざブラウザで色んなページを見に行ったりしなくていい。
  • 検索結果にたまーに混じっている別の地域のお店、とかがない(←ここ重要!)

技術的な話

TL;DR (大まかなデータの流れ)

Slack × GAS × 駅情報取得API × ぐるなびAPI を使用しています。

  1. Slackのチャンネル「GourmetNav」から、「駅名」+ 「半角(または全角)空白」 + 「店名に関わるキーワード」を入力、送信。
  2. Slackから受け取った入力情報をGAS上で参照。
  3. 駅情報取得APIを用いて、入力情報内の「駅名」から緯度・経度を取得。
  4. ぐるなびAPIを用いて、上記の緯度・経度からその地域の飲食店を検索、一覧にしてGoogle Spreadsheetに出力(2度目以降はシートを初期化して更新するのでデータは保持しない)
  5. 生成されたSpreadsheet内の飲食店一覧から、「店名に関わるキーワード」と部分一致する「飲食店名」とその「サイトURL」を取得。
  6. 取得した「飲食店名」とその「サイトURL」をSlack側に出力。
  7. Slackのデータ容量圧迫しないように投稿を毎日自動削除するようにトリガーを設定(任意)

具体的な面倒くさいところ

1. まず、ローカルでの開発環境構築(任意)

ある程度のコード量/ロジックを書くならば、GASをブラウザ上のスクリプトエディタでベタ書きするのは、間違いなくNonsenseなんじゃないかと思います。(知らんけど)
(参考): Google Apps Script をローカル環境で快適に開発するためのテンプレートを作りました
(著者): @howdy39

※型定義ないとデバックしづらいのでTypeScriptも入れました(任意)

今回デバッグしていて初めて気付いたんですけど、Javascriptって配列にtypeofかけても「object」って返されるんですね。なんて暗黒言語!!
競技プログラミング(Atcoder)でC#愛好家の私には拒絶反応が出たので、typescript導入しました。
(参考): clasp + TypeScriptで課題改善botを作った
(著者): @mochisuna
(参考): JavaScriptの型などの判定いろいろ
(著者): @amamamaou

2. GASでSlackからの入力情報を受け取り、参照し、出力する(必須)

開発環境を整備したら、まずはSlackとBotによる簡単な入出力を実装する。
下記の記事を参考にすると、以下のようなことができる。
✔️GASでPOSTリクエストを受け取る
✔️SlackでOutgoing WebHookの設定を行う
✔️GASでSlackから渡された値を参照する
(参考): Slack上のメッセージをGoogleAppsScriptで受け取ってよしなに使う
(著者): @kyo_nanba

※このとき、SlackAppというライブラリを使用することで設定が少し楽になる

世の中のSlack愛好家の皆さんに圧倒的感謝!
SlackApp作成者@soundTricker さんに三跪九叩頭しましょう。
(参考): Slack BotをGASでいい感じで書くためのライブラリを作った

3. 駅情報取得API, ぐるなびAPIを上記の中に組み込んで出力内容を工夫する

この部分に関しては、記事読んで実践するだけですんなり実装できました。
(参考): GoogleAppsScriptでぐるなびAPIから取得した駅周辺のお店をスプレッドシートに書き出す
(著者): @kouheidev

4. 上記参考記事を悪魔合体させながら論理構造を調整

今回は社内Slack上で作成したこともあり、一旦ソースコードは開示しません。
(まだまだ生後10ヶ月のエンジニア、トークンの扱いとかGASの仕様とか、セキュリティ面に関しては完璧に理解してるわけじゃないので!)
ただまあ、ベテランの皆さんなら多分苦もなく実装できると思います。(・ω・。)


苦労したところ

無限ループ??

GAS上でのテストでは問題ないのに、
Slackで実際に入出力をテストすると無限ループが頻繁に発生しました。(恐ろしい...)
暫くして気付いたのですが、下記のロジックによるものでした。
ユーザの「A」という検索ワードに対して、「GourmetNav」は該当する検索結果を見つけてきます。
そして、「Aが見つかりました!」とSlackに出力してくれるわけです。
この出力を、GAS側は「ユーザによる新しい入力」として検知し、再び検索/出力処理を繰り返すわけです。
なるほど〜〜それはそうだわ! ということで、
doPost内の記述に下記を加えることで無限ループは解消します!

  if (e.parameter.user_name == "slackbot"){
    throw new Error("this is bot."); //入力者がbotだった場合、エラーを返します
  }

まとめ

Slackからのデータ入力をSpreadsheetに出力だとか、
Spreadsheet側の入力をSlackに出力するだとか、
こうした一方通行なBotならQiita上にも数多くあります

ただ、今回作成したのはInteractiveなChatBot。
ユーザ入力に対して、GAS側で入力を検知、
各API内を検索、Spleadsheetに検索結果を出力、
そして、ユーザの求めている情報にマッチする情報のみをユーザに提供する。

こうしたInteractiveなSlackチャットボット制作記事は、決して多くはないかと思います。
GASでSlack向けに開発している方にとって、何かの一助となれば幸いです。

ついったフォローしてね。(・ω・。)
@NadjaHarold

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

NW.jsを使ってWEBアプリをデスクトップアプリに移植する

概要

NW.jsはJavaScriptを使ってデスクトップアプリを作ることができるフレームワーク。 類似のフレームワークにelectronなどがある。

はじめはelectronを検証していたが、NW.jsの方がソースの隠蔽が可能とのことで、こちらも検証してみることにした。ただしNW.jsもただパッケージ化しただけだと、簡単に解析されてしまう旨の記述をよく見るため、こちらは後日調査。

ディレクトリ作成

以下の構成でディレクトリを作成 。package.jsonが2つあることに注意

nw.js-example/
├── src/
│   ├── app/
│   │  └── main.js
│   ├── assets/
│   │  └── icon.png
│   ├── styles/
│   │  └── common.css
│   ├── views/
│   │  └── main.html
│   └── package.json
└── package.json

マニュフェストファイル作成

マニュフェストファイルこと、src/package.jsonを作成する

これは作成するアプリの設定を記述する

{
  "name":"nw.js-example",
  "version":"1.0.0",
  "main":"views/main.html",
  "window":{
    "min_width":400,
    "min_height":400,
    "icon":"assets/icon.png"
  }
}

HTMLファイル作成

マニュフェストファイルのmainに記載されたhtmlファイルが最初に実行される

そのためview.html/main.htmlを作成

ビルド設定

ルートのpackage.jsonを編集するビルド時の設定をする
name,versionともに両package.jsonファイルに同じものを記載しておかないと

パッケージが作成できないので要注意。

{
  "name":"nw.js-example",
  "version":"1.0.0",
  "devDependencies":{
    "nw":"^0.18.2",
    "nw-builder":"^3.1.2"
  },
  "scripts":{
    "dev":"nw src/",
    "prod":"nwbuild --platforms win32,win64,osx64,linux32,linux64 --buildDir dist/ src/"
  }
}

必要なパッケージのインストール

以下コマンドを実行し、上述のdevDependenciesに記載されるパッケージをインストール

$ npm install

デバッグ実行

$ npm run dev  

パッケージ作成

$ npm run prod

package.jsonのscripts:prodに指定した分だけパッケージが作成される

nw1.png

win64は以下のようになっており、 64bitWindows環境でnw.js-example.exeを実行できる

nw2.png

一方linux64は以下のような感じになっており、コマンドラインからnw.js-exampleを実行できる。 GUI経由でダブルクリックでは実行できないが、これはubuntuの設定の問題の様子。

nexeファイルというものについても後日調査。

$ ./nw.js-example

nw3.png

結果

nw4.png

デスクトップアプリとしてアプリケーションを実行することができた。

所感

工数の観点からwebアプリをデスクトップアプリ、ネイティブアプリに変換・流用することを検討しており、Erectron,NW.jsと調査をしたものの、いずれも制約があったり、性能面で不都合があったりと、思ったように簡単に変換・移植ができるわけではない。数年先のマルチプラットフォームフレームワークはどうなるかはわからないものの、直近はWEBアプリ、ネイティブアプリ別々の技術を習得していく必要があるかと思われる。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

SalesforceからJavascriptボタンを追放する!アクションボタンとフロービルダーを組み合わせた(後編)

こちらはSalesforceからJavascriptボタンを追放する!アクションボタンとフロービルダーを組み合わせた(前編)の続きの内容になります。まだの方は前編からご確認ください。

行動一括作成全体像.png

1.現在表示している取引先と商談の情報を取得(前編解説済み)
2.画面にデータを入力(前編解説済み)
3.条件分岐で親取引先がない場合は最初の取引先の活動だけ作成
親取引先がある場合は親に関連している全ての取引先を取得
4.3.で取得した情報をループに渡し最新の商談を取得後、行動を作成
5.取得した行動の件名がないものは削除し、ループに戻る。
件名はあるものはループにもどり取引先の数だけ繰り返す

後編は3.の条件分岐からの解説です。

④条件分岐「決定」

アイコンでは「決定」とありますが、分岐の条件を決めて場合わけするために使うものです。決定のラベル、API名を入力し「結果」と呼ばれる場合分けを決めていきます。「+」で追加すれば多くの結果を作成することができます。

プロセスビルダーでいうと◇マークの条件ノードの部分となります。
その条件に合致した場合のアクションはこの後のレコードの取得や更新などの要素につなげていきます

今回結果は取得した取引先に親取引先が紐づいているかどうかの判定になります。
場合分けは以下のようになります。
決定.png

結果の順序の一番下が「デフォルトの結果」とありますが、こちらのラベルも変更することができますのでこちらは「あり」に変更します。

親取引先が「なし」の場合

⑤親なしの行動作成

「レコードを作成」から以下のように入力します。基本的に画面で入力してあるものをそのまま項目にいれていきます。ポイントはSubject(件名)となります。こちらは取得した取引先に紐づいている商談のフェーズにより自動的に件名をいれる数式を作成します。

フロービルダーのレコード作成.png

基本的にカスタム項目の数式で作成するものと同様に作成することができます。

フロービルダーの数式.png

ここで気を付けるべきはこの段階で数式にエラーがあってもすぐわからずフローを保存しようとしたときにエラーが発生してしまいます。

慣れないうちはカスタム項目の数式を仮に作成してエラーがないかどうか確認してからこちらにコピペすればスムーズでしょう。

⑤親なしの行動作成は作成したことをユーザに伝える画面⑬を表示させて終了となります。

親取引先が「あり」の場合

⑥親に関連する取引先取得

まずは親取引先に紐づいている子の取引先を全て取得していきます。
次の条件で取得することができます。

項目:ParentId
演算子:次の文字列と一致する
値:{!get_Account(最初の「レコードを取得」で設定したAPI名).ParentId}

(「手動割り当て変数(詳細)」にチェックが入っていない場合)

⑦各取引先処理

続いて「ループ」の設定です。詳細がわからない人は次の記事を読まれたほうがスムーズかと思います。
プロセスビルダーとFlowBuilderを組み合わせた(応用)

コレクション変数は直前の「レコードを取得」で設定したAPI名を選択します。
ループ変数は任意のループ変数を設定します。

ループのラベル:各取引先処理(任意の名前)
ループのAPI名:roop_Account(任意の名前)
コレクション変数:{!get_related_parent_acount}(任意の名前)
ループ変数:{!related_Account}(任意の名前、データ型:レコード、オブジェクト:取引先)

⑧最新の商談取得

「レコードを取得」でループ中の取引先の最新の商談を取得します。
行動は各取引先の最新の作成日の商談のフェーズをベースに行動の件名を取得していきます。

レコードの取得のラベル:最新の商談取得(任意の名前)
レコードのAPI参照名:get_new_Opp(任意の名前)
レコードを絞り込み
項目:AccountId
演算子:次の値に一致する
値:{!related_Account(ループ変数の名前).Id}

レコードを並び替え
並び替え順:降順
並び替え:CreatedDate

条件自体は複数の商談が該当する場合でも、並び替える基準の項目や順序を決めた上で「最初のレコードのみ」保存すればほしい一つだけのレコードを取得することができます。ここではフェーズも商談項目として保存します。

⑨行動作成

続いて行動の作成をしていきます。親取引先がなしの場合と同様に項目を保存していきます。
違いは件名の数式のリソースだけとなりますので新しく数式を作成します。
(あるいはif文を組み合わせて、ない場合・ある場合で数式を作成し一つにまとめるのも良いと思います。)

Caseの数式
TEXT({!get_Opp.StageName})からTEXT({!get_new_Opp.StageName})へ変更

⑩件名の取得

続いて行動の情報を取得します。
オブジェクト:行動
項目 :Id
演算子:次の文字列と一致する
値  :{!make_Event}(⑨で作成した行動のAPI参照名)

⑪件名があるか

続いて「決定」となります。
先ほど取得した行動の情報をもとに判定していきます。

結果:なし
リソース:{!get_Subject(先ほど作成した行動のAPI参照名).Subject}
演算子:null
値:{!$GlobalConstant.True}

デフォルトの結果を「あり」に変更します。
結果「あり」の場合は「ループ」につなげます。

結果「なし」の場合
件名がないということは商談がないということなので、今回の前提として行動を作成しないようにします。

⑫行動削除

「レコードを削除」で以下のように設定します。
レコードの絞り込み
項目:Id
演算子:次の文字列と一致する
値:{!make_Event}(⑨で作成した行動のAPI参照名)

その後ループにもどるようにつなげます。

ループが終わってからも処理を続けたい場合は続けることができます。今回は行動が作成したことをユーザに伝える画面につなげて処理を終えます。

フローをデバッグして動作を確認する

2,3個までの要素のフローであれば特別デバッグをしなくても実際に動かしてテストすれば十分かもしれません。しかし今回のように数多くの要素を組み合わせると、実際に有効化してテストを繰り返すのはなかなか骨の折れる作業になります。作成途中でテストして動作を確認したいときもあるので、こういうときはフロービルダー上でデバッグすることができます。

フローをデバッグ.png

入力変数として出てくるのは変数の作成時に「フロー外部での可用性」で「入力で使用可能」にチェックが入っているとデバッグの際に出てきます。フローアクションをするときは自動的にIDを取得してくれますが、デバッグのときは自分で対象のIDを入れなければいけません。今回は取引先IDを入力します。またデバッグで作成されたレコードは実際に作成されますので、不要であれば後程削除する必要があります。

フロービルダーのデバッグ実行.png

作成したフローに従って右側の「デバッグの詳細」に記録が残ります。補足すると「高速検索」とはフロービルダーの「レコードを取得」にあたります。旧クラウドフローデザイナーの名残として残っているのだと思います。

ここで動作を十分確認してから、有効化し、前編で解説したようにフローアクションを追加すれば完成です。

レコード画面にもフローを追加してみる

またLightningアプリケーションビルダーからレコードにフローを追加することもできます。

Lightningアプリケーションビルダーでレコード画面にフローを追加.png

簡単に解説すると、Lightningアプリケーションビルダーではレイアウトの編集が可能です。
フローは1列か2列かを選択できます。また動的なレコードを表示する際は「レコードIDをこの変数に渡す」にチェックを入れます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【JavaScript】誕生年の配列を作るサンプルコード(ギネス世界記録を反映)

はじめに

ギネス世界記録の最高齢を反映した、誕生年の整数配列を作るサンプルコードを残します。
セレクトボックスで使えるのではと思います。

環境

OS: macOS Catalina 10.15.3

結論

sample.js
  const year = new Date().getFullYear()

  // ギネス世界記録の最高齢
  const maxAge = 117 

  const start = year - maxAge
  const end = year

  // 必要な範囲の整数配列を作成
  const years = [...Array(end - start + 1).keys()].map( i => start + i)

  // 降順に並び替えて選びやすくする
  // ※最終的にはこれを使う
  const descYears = years.sort((a,b) => {
    return (a < b ? 1 : -1)
  })

おわりに

最後まで読んで頂きありがとうございました:bow_tone1:

どなたかの参考になれば幸いです:relaxed:

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

JavaScript 指定した文字の前後の切り出し

JavaScriptを使用して、指定した文字の前後の文字列を切り出す方法を紹介します。

var str = 'abcdefghij';
// dより前の文字が欲しい
var cut1 = str.substr(0, str.indexOf('d'));

console.log(cut1);

// 結果
abc
// dより後の文字が欲しい
var cut2 = str.substr(str.indexOf('d') + 1);

console.log(cut2);

// 結果
fghij

関数の紹介

indexOf()

文字列の中に指定した文字があるかを探してあれば、そのインデックス(先頭を0とした順番)を返却する。

今回の場合は、指定した「d」と言う文字が4番目にあるので3と言うインデックスが返却される。

substr()

substr(開始位置, 終了位置)とすることで、開始位置から終了位置までの文字を切り出すことができる。

今回の場合は、

str.substr(0, str.indexOf('d'))

とすることで、文字列の0番目から3番目までの文字列が切り出されるので、返却値が以下のようになる。

abc

また、substr(数字)とすることで、指定した数字から末尾までの文字を切り出すことができる。
今回の場合は、

str.substr(str.indexOf('d') + 1)

とすることで、文字列の4番目から末尾までの文字が切り出されるので、返却値が以下のようになる。

fghij
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ブラウザでQRコードを読めるようにした際のメモ

サマリ

先日作成したwebcamの動画ストリーミングアプリにQRコードの読み取り機能を設けました。
screenShot.png

成果物

こちらにアクセスするだけで使えます。ソースコードもOSSでどうぞ。

使い方については、前回の記事を参照してください。
カメラが起動したらQRコードが大きく映るようにカメラ前においてください。無事に読み取りができれば、→側のテキスト領域に読み込んだデータが追記されていきます。

SWのポイント

QRコード読みとりのライブラリ

こちらの記事を参考にさせていただき、モダンな記載になっていて、その中でもメジャーなjsQR(@cozmo)を選択してみました。結果的に、すごい簡単に使えて最&高です。どれくらい簡単か↓

index.html
  <!-- QR decoding -->
  <script src="https://cozmo.github.io/jsQR/jsQR.js"></script>
index.js
// imageData is data written in a canvas
const code = jsQR( imageData.data, imageData.width, imageData.height, {
  inversionAttempts: "dontInvert",
} );
console.log( code.data );

嘘だろっていうくらい簡単にデコードできました。とりあえず300msecくらい毎のデコードでいいかなと思って設定していますが、100msec毎でも問題なく動いていたので、パフォーマンス上げたい方は変更してもらえますと幸いです。
なお、カメラのピントを合わせることが出来なかったり、たくさんQRコードが存在するとどれを読んでいるのか不明だったりするので、その辺は読み取り方に工夫がいります。

お役立ち: VSCode LiveServerのHTTPS対応

今回の実装をするにあたり、超絶便利なVSCode LiveServerはデフォルト設定から変更しています。ローカルのPC環境でiPhoneからもLiveServerにアクセスしてほしいのですが、カメラを使用するには、localhostかhttpsでないといけないので、そのあたりの設定を変更しています。やるべきことは下記の2点です。

VSCode LiveServerのHTTPS化

こちらの記事にばっちり手順が書いてあります。Windowsですとopensslをインストールする必要があるので、そちらは事前にやっておきましょう。opensslがあれば、たった1行でオレオレ証明書cert.pem, key.pemが作成できます。どこか適当なフォルダに配置しパスをLiveServerのsettingに書いておきましょう。

Serverへのアクセス先をローカルホストから変更

外部のスマホからアクセスしたいので、こちらもLiveServerの設定からIPアドレスを変更します。具体的には下記の設定に、自分のPCのIPアドレスを設定します。Windowsであれば、cmdからipconfigして取得してください。
image.png

iOS側で確認

PCとiPhoneなどが同一のネットワークにつながっていれば、iPhoneのSafariから<IP Address>:5500にアクセスするとスマホ側からもLiveServer機能が使えるはずです。ちゃんとコード編集すると自動リロードしてくれます。。。が、コードの編集が反映されていない時が多いので、その場合はSafariの設定からキャッシュの削除をしてみてください。その点は面倒ですね。

以上ですが、LiveServerの便利さを伝えたかっただけの記事です。失礼しました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Alexaスキルの開発 0から公開まで

初めて Alexaスキル の開発を始めてから公開するまでの流れについて。

フローチャートを作成する

事前に Alexaにどう発話したらどう分岐するかをまとめたフローチャートを作っておくとフローが整理できて開発が捗るので以下のような感じで作成します。(黒塗り多くてすみません)

alexa-flowchart.png

alexa developer console への登録

amazon alexaにアクセスし、アカウントを作成してログインします。
「スキルの作成」ボタンを押し、好きなスキル名を入力して作成。

スキルを作成した後、ビルドタブの左メニューから「エンドポイント」を選択し、表示される スキルID を覚えておきましょう。
screenshot.png

AWS Lambdaの利用

作成

AWSにログインしてLambdaにアクセスし、関数の作成ボタンを押します。
関数名を入力し、ランタイムは今回は「Node.js」を選択し、作成。
(自分が開発していた当時は東京リージョンだと必要な機能が揃っておらず、オレゴンリージョンを利用しました)

Alexa Skill Kit の追加

次にLambdaの作成した関数の画面にて、「トリガーを追加」で「Alexa Skill Kit」を選択し、スキルIDに先程 alexa deevloper console でコピーしたスキルIDを貼り付けて追加します。

screenshot.png

ARNをメモ

画面右上の ARN 値を覚えておきます。

screenshot.png

再び alexa developer consoleに戻ってスキル設定

alexa developer console と Lambdaの紐付け

alexa developer console にて、スキルを選択したあとのビルドタブの左メニューから「エンドポイント」を選択し、
「Aws LambdaのARN」を選択して、「デフォルトの地域」に先程メモした ARN を貼り付けて「エンドポイントを保存」。

呼び出し名の設定

ビルドタブの「呼び出し名」をクリックし、なんという呼びかけでスキルが起動するかを設定します。

インテントの追加

ビルドタブのインテントの追加をクリックし、今回のスキルでAlexaが受け付ける可能性のある全発話をインテントとして作成していきます。例えば、

インテント名 発話例
RecommendIntent 「おすすめを教えて」「おすすめ見せて」「おすすめ開いて」
SelectNumberIntent 「{number}で」「{number}でお願い」「{number}がいい」
SelectCategoryIntent 「{category}にする」「{category}が見たい」「{category}を見せて」

といった感じ。編集が終わったらモデルのビルドをすることで反映されます。

screenshot.png

  • 各インテントにて、できる限り発話の揺らぎを網羅するようにします。足りていないとAmazonからのレビューで指摘されます。
  • 上記 {number}{category} のように、一部を変数化して、複数の発話を受け付けることも可能。変数のパターンは別途 スロットタイプ に登録します。{number}など一部の変数は既に用意されています。
  • 肯定/否定の返事をするインテントや「前へ」「次へ」などのインテントは既にビルトインとして用意されているものがあるので、独自に追加せずにそれをビルトインインテントとして追加します。
  • CancelIntent (取り消し)、HelpIntent (ヘルプ)、StopIntent (中止)などいくつかのインテントはルール上、必ず実装しないといけない模様です。

Cloud9と連携する

LambdaとCloud9を連携させることで、Cloud9の高度なIDE上でAlexaスキル用の関数をコーディングすることができます。Cloud9を使わずにLambda内で書くことも可能ですが、Cloud9の方が使いやすくておすすめ。

環境の作成

AWSからCloud9を選択し、「Create Environment」を押して、名前などを適当に入力して環境を作成します。
Lambdaと同じリージョンで作成していれば、少し前に作成したLambda関数が右ペインの AWS ResourcesRemote Functions に表示されているので、それをインポートすることでコーディングできます。
コーディングが終わったら Local Functionsを Deployすれば Lambdaに反映可能。

必要なパッケージのインストール

必要なパッケージをCloud9上のbashコンソールでインストールしておきます。

cd package.json が入ったディレクトリ
npm install ask-sdk --save

まずは、返事をするだけのスキルを作ってみる

Cloud9上に index.js を作成します。
どのディレクトリに置くかは、Cloud9でどの範囲をインポートしたかによるので一概に言えませんが、
もし package.json があればそれと同じ階層に作成します。

const Alexa = require('ask-sdk');

/**
 * あいさつ
 */
const GreetingHandler = {
  canHandle(handlerInput) {
    const request = handlerInput.requestEnvelope.request;
    // 初回起動時
    return request.type === 'LaunchRequest';
  },

  async handle(handlerInput) {    
    return handlerInput.responseBuilder
      .speak('今日もおはようございます。')
      .withShouldEndSession(true)
      .getResponse();
  },
};

/**
 * 全てマッチしなかった場合
 */
const FallbackHandler = {
  canHandle() {
    return true;
  },

  handle(handlerInput) {
    return handlerInput.responseBuilder
      .speak('申し訳ありません。もう一度お話しください。')
      .reprompt('もう一度お話しください。')
      .getResponse();
  },
};

const ErrorHandler = {
  canHandle() {
    return true;
  },

  handle(handlerInput, error) {
    return handlerInput.responseBuilder
      .speak('エラーが発生しました。もう一度お話しください。')
      .reprompt('もう一度お話しください。')
      .getResponse();
  },
};

exports.handler = Alexa.SkillBuilders.standard()
  .addRequestHandlers(
    GreetingHandler,
    FallbackHandler
  )
  .addErrorHandlers(ErrorHandler)
  .lambda();

とりあえずこんな感じ。GreetingHandlerが今回作ったメインのハンドラー。
開発は主に、各イベントや発話ごとのハンドラーを作成し、それをexports.handlerに登録していくことになります。
Handlerで必須なのは、canHandle()handle() で、前者はどのようなイベントや発話を受け取ったときにそのハンドラーを起動するかを設定し、handle()ではユーザーに画面や発話などを返したり裏で行う処理などをコーディングしていきます。

ハンドラーが複数あった場合、addRequestHandlers()内で指定した順に canHandle() での判定が行われ、最初にマッチしたハンドラーが起動します。
上記例の様に、どのハンドラーの条件にもマッチしなかった場合に必ず受け取るFallbackHandlerを作っておくと良さげ。(switch 文の default みたいなイメージ)

デプロイ

コーディングが終わったら、Cloud9の右ペインの AWS Resources で作成した Local Functions を選択して (Deploy)ボタンを押せば、Lambdaに反映されます。

screenshot.png

作ったスキルのテスト

エミュレーター上でテスト

alexa developer consoleの「テスト」タブに移動し、入力欄に呼び出し名 (少し前の項目で設定した名前) を入力するとスキルが起動して、「今日もおはようございます」と返ってくることを確認できました。
今はまだ作り込んでいないので、これですぐにスキルが終了してしまいますが、この後の作り込みでもうちょっとやり取りができるスキルを作っていきます。

screenshot.png

実端末でテスト

Alexa にアクセスし、アカウントを作って実端末と紐付けを行います。
ウィザードにしたがって進んでいくことで紐付けは完了します。

有効なスキル - 開発スキル に行くと、今開発中のスキルが表示されるので、ここでAlexaに開発中のスキルをインストールできます。(ここは記憶が定かでないので間違っていたらすみません)
あとは、実端末で「アレクサ、○○」と話しかければスキルが起動します。

ログを確認

スキルがうまく動作しなかった場合は、AWSのCloudWatchでログを確認できます。
CloudWatchのロググループをクリックすると作ったスキルが表示されるので、そこをクリックすることでログを確認できます。

screenshot.png

スキルの公開

さて、まだ現時点では公開できる内容のスキルではありませんが、全体の流れを追うため、一旦スキルの作り込みは後回しにし、スキルの作り込みが終わった前提でスキルの公開までの流れを書いていきます。

alexa developer consoleの「公開タブ」にアクセスし、公開設定をおこなっていきます。
質問項目が多岐に渡りますが、最低限、必須項目だけでも頑張って埋めていきます。
全て入力が終わり、何か問題があれば指摘を受けますので修正します。
問題なければ「実行」ボタンを押すことで、自動テストが実行されます。
問題なければ、Amazonスタッフに検証依頼を出すことになります。
その後数日以内にフィードバックがありますので、修正等のやり取りを行い、最終的に問題なければ無事公開されます。

screenshot.png

バージョン管理を行う

無事公開はできましたが、このままだとスキルを編集して反映すると即本番のスキルに影響が出てしまいます。
それだと問題があるので、Lambdaのエイリアス機能を使って、本番用と開発用を別々に管理していきます。

エイリアスの作成

Lambdaにアクセスし、「アクション」→「新しいバージョンを発行」で、今の最新版に対してバージョン名をつけます。名前は「1.0.0」など好きな名前をつけます。
次に「エイリアスの作成」で本番用のエイリアスを作成します。名前は「prod」など適当につけ、先程作ったバージョン番号を選択します。

screenshot.png

スキルの向き先を変える

次に今作ったエイリアスのARNをコピーし、alexa developer consoleの「ビルド」タブの「エンドポイント」の「デフォルトの地域」の値を上書きします。
これで、今開発中のスキルは本番のエイリアスを向いていることになります。

再度、本番公開

この状態で、本番公開を進めれば、スキルは本番用のエイリアスを向いた状態でリリースされます。
その後またエンドポイントを$LATESTのARNに戻すことで、開発中のスキルは最新の状態を向きますが、本番スキルはエイリアスを向いたままとなり、プログラムを更新しても本番は影響を受けずにすみます。

その後の運用

スキルを更新する場合は、再度新しいバージョンを作ってエイリアスをそのバージョンに向け直せば、本番のスキルを更新できます。インテントの修正が無ければ再度Amazonに依頼を出す必要はありません。(インテントの修正がある場合は毎回依頼が必要)

少し面倒な点として、再度Amazonに依頼を出す際、開発中のスキルを本番用エイリアスに向けた状態で依頼を出す訳にもいきませんが、かといって何らかのエイリアスに向けておかないと本番公開されたときにエイリアスを向かなくなってしまい困ってしまいます。
したがって、もう一つ本番用のエイリアス (prod2) を作成し、そこに向けた状態で依頼を出します。
そうすることで、本番公開後、prod2に向いた状態でスキルが公開されます。
今後は、prodとprod2を交互に切り替えて依頼していくことになります。(もっと良い方法があれば…)

もうちょっと作り込みしてみる

問いかける

handle()の最後で以下のようなレスポンスを返すことで、ユーザーに問いかけを発信して、応答を待つことができます。

handle(handlerInput) {
    // ~略~ 色々な処理
    // 問いかけ
    return handlerInput.responseBuilder
        .speak('あなたは男性ですか?女性ですか?')
        .reprompt('あなたは男性ですか?女性ですか?')
        .getResponse();
}

なお、文章をAlexaが正しく読んでくれないときは、

'<phoneme alphabet="x-amazon-ja-jp" ph="オオダ\'シ">大田市</phoneme>'

という感じの文をspeak()に入れることで正しく読んでくれます。「\'」は日本語アクセントが低音に移る点を指定。

問いかけ後、返事を受け取る

canHandle()で受け取りたいインテントをキャッチすれば、そのハンドラーのhandle()が起動します。
以下の例は、肯定の応答を受けたときに起動する設定。

canHandle(handlerInput) {
    const request = handlerInput.requestEnvelope.request;   
    // 「はい」と応答されたとき
    if (request.type === 'IntentRequest' && request.intent.name === 'AMAZON.YesIntent') {
        return true;
    }
    return false;
}

handle(handlerInput) {
    // 「はい」と応答されたときの処理
}

会話の進捗によって処理を変える

例えば、「はい」「いいえ」で答える質問を2回に分けて投げかける場合、1つ前の方法だとどっちの質問か区別せずに反応してしまいます。そこで、現在の会話の進捗をセッション変数に保持しておき、その進捗に応じて処理を行います。

// 最初の質問
const FirstQuestionHandler = {
    canHandle(handlerInput) {
        const request = handlerInput.requestEnvelope.request;
        // 初回起動時
        return request.type === 'LaunchRequest';
    }

    handle(handlerInput) {
        // セッション変数に現在の質問の位置を記憶
        const attributesManager = handlerInput.attributesManager;
        const attributes        = attributesManager.getSessionAttributes();
        attributes.state        = 'first';
        return handlerInput.responseBuilder.speak('あなたは男性ですか?').reprompt('あなたは男性ですか?').getResponse();
    }
}

// 2つ目の質問
const SecondQuestionHandler = {
    canHandle(handlerInput) {
        const request = handlerInput.requestEnvelope.request
        if (request.type === 'IntentRequest') {
            const attributesManager = handlerInput.attributesManager;
            const attributes        = attributesManager.getSessionAttributes();
            // 1つ目の質問の後のとき
            if (attributes.state && attributes.state === 'first') {
                // 「はい」か「いいえ」で答えたとき
                if (['AMAZON.YesIntent', 'AMAZON.NoIntent'].includes(request.intent.name)) {
                    return true;
                }
            }
        }
        return false;   
    }

    handle(handlerInput) {
        // セッション変数に現在の質問の位置を記憶
        const attributesManager = handlerInput.attributesManager;
        const attributes        = attributesManager.getSessionAttributes();
        attributes.state        = 'second';
        return handlerInput.responseBuilder.speak('あなたは20歳以上ですか?').reprompt('あなたは20歳以上ですか?').getResponse();
    }
}

// 結果
const ResultHandler = {
    canHandle(handlerInput) {
        const request = handlerInput.requestEnvelope.request
        if (request.type === 'IntentRequest') {
            const attributesManager = handlerInput.attributesManager;
            const attributes        = attributesManager.getSessionAttributes();
            // 2つ目の質問の後のとき
            if (attributes.state && attributes.state === 'second') {
                // 「はい」か「いいえ」で答えたとき
                if (['AMAZON.YesIntent', 'AMAZON.NoIntent'].includes(request.intent.name)) {
                    return true;
                }
            }
        }
        return false;
    }

    handle(handlerInput) {
        // 処理
    }
}

問いかけ時に画面に情報を表示する

handle()のレスポンスでテンプレート機能を使うことで画面に情報を表示させることができます。
テンプレートはいくつか種類があるようです。
また、画面タッチにも対応させることができます。
ただし画面表示に対応していない端末を考慮して処理を分岐させるといいでしょう。

    handle(handlerInput) {
        // 画面表示に対応している場合
        if (handlerInput.requestEnvelope.context.System.device.supportedInterfaces.Display) {
            const viewport = handlerInput.requestEnvelope.context.Viewport;
            // 画面が丸い端末の場合
            const isRound  = viewport && viewport.shape === 'ROUND';
            handlerInput.responseBuilder.addRenderTemplateDirective({
                type: 'BodyTemplate2', // 今回は背景画像とその上に文字を表示するテンプレートを選択
                token: 'token',
                backButton: 'HIDDEN',
                image: isRound ? new Alexa.ImageHelper().addImageInstance('https://画像URL').getImage() : null,
                backgroundImage: new Alexa.ImageHelper().addImageInstance('https://画像URL').getImage(),
                title: 'タイトル',
                textContent: new Alexa.RichTextContentHelper().withPrimaryText(
                    '文章文章文章<br /><action token="detail">説明を聞く</action>'
                ).getTextContent(),
            });
        }
    }

上記で設置した「説明を聞く」ボタンのタッチは以下のようにcanHandle()内で検知することができます。

if (request.type === 'Display.ElementSelected' && request.token === 'detail') {

スワイプして一覧の中から選べるようにする

スワイプして複数の商品から選択する、といったテンプレートもあります。

const template = {
    type: 'ListTemplate2',
    token: 'string',
    title: '選択してください。',
    backButton: 'HIDDEN',
    listItems: []
};

template.listItems.push({
    token: 'detail_1',
    image: new Alexa.ImageHelper().addImageInstance('https://画像URL').getImage(),
    textContent: new Alexa.RichTextContentHelper().withPrimaryText('<font size="2">商品1</font>').getTextContent()
    });
}
template.listItems.push({
    token: 'detail_2',
    image: new Alexa.ImageHelper().addImageInstance('https://画像URL').getImage(),
    textContent: new Alexa.RichTextContentHelper().withPrimaryText('<font size="2">商品2</font>').getTextContent()
    });
}
handlerInput.responseBuilder.addRenderTemplateDirective(template);

return handlerInput.responseBuilder
    .speak('商品一覧を表示しています。画面を右へスワイプすると、すべての商品を見ることができます。商品の詳細を聞くには、画面にタッチするか、番号をおっしゃってください。')
    .reprompt('商品を選択してください。')
    .getResponse();

発話時に一緒にカードでメッセージを通知する。

return handlerInput.responseBuilder
    .speak('ありがとうございました。詳しくはお送りしたカードを参照してください。')
    .withSimpleCard('購入した商品: XXX')
    .withShouldEndSession(true) // スキルを終了する
    .getResponse();

スロットタイプの値を受け取る

前述のインテント作成の項目のような形でスロットタイプ型のインテントを作成した場合、スロットタイプのどの値が発話されたか、受け取ることができます。
例えば、{number}番目にする と発話された場合、numberの値を受け取ることができますので、その値に応じて処理を分岐できます。

// スロット値が渡された場合
if ((request.intent.slots.category.resolutions && request.intent.slots.number.resolutions.resolutionsPerAuthority[0].values) {
    const number = request.intent.slots.number.resolutions.resolutionsPerAuthority[0].values[0].value.id;
}

次回起動時に前回の状態に応じた処理を行う

次回起動時に前回の続きから処理を行ったり、前回どう応答したかに応じて処理を変えたい場合は、DynamoDB を利用した、状態を永続的に記憶する機能を使います。

exports.handler = Alexa.SkillBuilders.standard().addRequestHandlers(
    // 略
    FallbackHandler
  )
  .addErrorHandlers(ErrorHandler)
  .withTableName('テーブル名')
  .withAutoCreateTable(true)
  .lambda();

こんな感じで機能を有効にしつつ、

async handle(handlerInput) {
    const attributesManager = handlerInput.attributesManager;
    const persistentAttrs = await attributesManager.getPersistentAttributes();
    persistentAttrs.hogehoge = '変数に入れる値';
    attributesManager.setPersistentAttributes(persistentAttrs);
    await attributesManager.savePersistentAttributes();
}

で状態を記録します。 await しないといけないので、メソッドに async をつける必要があります。

const persistentAttrs    = await attributesManager.getPersistentAttributes();
if (typeof persistentAttrs.hogehoge !== 'undefined' && hogehoge === 'X') {
    return true;
}

あとは、こんな感じで判定できます。前回終了直前に、今の状態を記憶しておいて、次回起動時、状態が記憶されていれば再開用のハンドラーを呼び出せばいいでしょう。
通常、起動優先度を上げるために、addRequestHandlers の上位のハンドラーで判定する必要があるでしょう。

APIと通信してその結果に応じた処理をする

普通に Axios などを利用すれば良いです。以下、一例。handle()に asyncをつけるのを忘れないようにする必要があります。

const axios = require('axios');

// 略

const response = await axios.post('https://APIのURL', parameters, {
    headers: {
        Authorization: 'Bearer ' + handlerInput.requestEnvelope.context.System.user.accessToken
    },
    validateStatus: function (status) {
        return true;
    }
});

if (response.status !== 200) {

アカウントリンク機能を利用する

今回は省略し、必要に応じて別記事で書きたいと思います。

Amazon Payでの課金機能を利用する

今回は省略し、必要に応じて別記事で書きたいと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

例外が発生したら一定時間待機してからリトライする

JavaScriptではSleepに相当する処理が無いので気軽にリトライができない。async/awaitがあれば比較的簡単に解決できるが、IEにそのような機能は無い。

Promiseを使うことで解決できる。PromiseであればpolyfillがあるためIEでも利用できる。

ただ、いざ書いてみると大変面倒だったためメモしておく。

function retry(fn, opt){
    opt = opt || {};
    opt.times = opt.times || 3;
    opt.delay = opt.delay || function() { return 1000 }; // ExpornentialBackoff したいとき
    opt.abort = opt.abort || function() { return false }; // 中断したいときは true を返す
    opt.count = 0;
    opt.error = [];

    // 初回は即実行して欲しい
    var promise = new Promise(function(resolve,reject){
        var result;
        try{
            result = fn()
            resolve(result);
        }catch(e){
            opt.count++;
            opt.error.push(e);
            reject(opt);
        }
    });
    var i = 0;
    for(; i < opt.times; i++){
        promise = promise.catch(function(opt){
            // リトライは間隔を開けて欲しい
            return new Promise(function(resolve,reject){
                setTimeout(function(){
                    var result;
                    try {
                        if (opt.abort()) { return }
                        result = fn();
                        resolve(result);
                    }catch(e){
                        opt.count++;
                        opt.error.push(e);
                        reject(opt);
                    }
                }, opt.delay());
            });
        });
    }
    return promise;
}

以下の必ず例外を発生する関数を retry に投入すると、コンソールに1秒間隔で日付を6回(1+5回)表示し、最後に "fail" を表示する。

function fail() {
    console.log(new Date());
    throw 1;
}
retry(fail, { times: 5 }).catch(function(e){ console.log("fail") })

一方で、例外を発生しない場合は日付が1回表示されて終了する。

function pass() {
    console.log(new Date());
}
retry(pass, { times: 5 }).catch(function(e){ console.log("fail") })

更に、以下は途中で正常終了するため、コンソールには 1, 2, 3, pass が表示されて終了する。

var i = 0;
function safe() {
    i++;
    console.log(i);
    if (i < 3) { throw 1 }
}
retry(safe, { times: 5 }).then(function(){ console.log("pass") }).catch(function(e){ console.log("fail") })

以上

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

スクロールすると要素が揺れるページを作る

最初はJSのみで実装しようと思いましたが、難しくて挫折。CSSアニメーションを使えばかなり簡単に作れましたー。ちなみに、ライブラリは使っていません。

何かしら良さげなライブラリがありそうですが、パッと見た感じなさそうなので作ってみました。

HTMLは必要最低限。

HTML

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>trace huuuu</title>
  <link rel="stylesheet" href="style.css">
</head>
<body style="height: 200vh; width: 100vw;">
  <div class="shakes"></div>
  <div class="shakes"></div>
  <div class="shakes"></div>
  <div class="shakes"></div>
  <div class="shakes"></div>
  <div class="shakes"></div>
  <script src="script.js"></script>
</body>
</html>

揺らす要素にshakesというクラスを付けています。
transform-origin: 50% 0;と書いておくと、揺れてる感が出て良き良き。

CSS

.shakes {
  width: 200px;
  height: 100px;
  margin: 150px;
  transform-origin: 50% 0;
  background-color: pink;
}

正直JSはコードに自信がないので晒したくはないのですが、ひとまず下記のような感じにしてみました。

各要素ごとに、振幅にばらつきがあったり、振れの速度が違った方が面白いので、それぞれランダムな値を持たせています。

JavaScript

let oldPos = 0;
let timeoutId = 0;
let scrolls = [];
let minShakeAngle = 0.3; // 最小振れ角
let attenuation = 0.8; // 振幅減衰率
let defaultAngle = 45; // 基準振れ角

window.onload = () => {
  oldPos = window.scrollY;
  let shakes = Array.from(document.getElementsByClassName('shakes'));
  shakes.forEach((shake, index) => {
    scrolls.push(new Scroll(shake));
  });

  window.addEventListener( "scroll", () => {
    clearTimeout( timeoutId );
    let timeoutId = setTimeout( function () {
      let distance = Math.abs(window.scrollY - oldPos);
      let angleRate = distance / (document.body.clientHeight - window.innerHeight);
      let angle = angleRate * defaultAngle;
      scrolls.forEach((scroll) => {
        scroll.shakeSet(angle);
      });
      oldPos = window.scrollY;
    }, 15);
  });
}

class Scroll {
  constructor(el) {
    this.el = el; // 要素
    this.currentAngle = 0; // 現在の振れ角
    this.add = 1; // 振れ方向の正負
    this.animationId;
    this.animateFlg = false; // 振れているかどうか
    this.angleRate = Math.random() + 0.5; // 要素ごとの振れ角のばらつき
    this.animationInterval = 200 * Math.random() + 500; // 要素ごとの振れ速度
    // 要素ごとに振れ速度を変えて、
    el.style.transition = `transform ${this.animationInterval}ms ease-in-out`;
    // インスタンス生成時に要素を揺らしています。
    this.shakeSet(10);
  }

  shakeSet(angle) {
    angle *= this.angleRate;
    if(this.currentAngle < angle) {
      this.currentAngle = angle;
      if(!this.animateFlg) {
        this.animateFlg = true;
        this._shake();
      }
    }
  }

  _shake() {
    this.animationId = setInterval(() => {
      this.el.style.transform = `rotate(${this.currentAngle * this.add}deg)`;
      this.add = this.add * -1;
      if(this.currentAngle < minShakeAngle) {
        this.el.style.transform = `rotate(0deg)`;
        clearInterval(this.animationId);
        this._clearVariable();
        return;
      }
      this.currentAngle *= attenuation;
    }, this.animationInterval);
  }

  _clearVariable() {
    this.add = 1;
    this.currentAngle = 0;
    this.animateFlg = false;
  }
}

動作は下記URLより

https://mi-miya.github.io/traces/huuuu/

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

風速データをパーティクルで描画する

leaflet.jsのプラグイン『leaflet-velocity』は、物凄く簡単に風をパーティクルで描画できます。
leaflet-velocity

この記事では、leafet-velocityを使って、気象庁の風速データを視覚化したいと思います。
ほとんどGitHubに書いてあるようなことなので、あくまでプログラミング初心者の方向けです。

1. 風速データを取得する。

データは、京都大学のデータサーバに置いてあるので、それを取ってきます。
1. 地球流体電脳倶楽部
2. データサーバ
3. 気象庁データ
4. 数値予報GPV
5. 任意の日時を選択

今回は2018年5月15日の午前0時(日本時間9時)の、地上10m地点の風速データを使います。 ですので、ダウンロードするファイルはこちらになります。

window date


Z_C_RJTD_20180515000000_MSM_GPV_RjpLsurf_FH00-15_grib2.bin

MSM, GPV, Lsurf 等の意味についてはここでは触れません。(私もあまり自信がないので)。

落としてきたファイルから、風速データを取り出す!

大体、気象データは "grib" という形式で記述されています。
このファイルを人間が解読するには、wgrib2というツールを使うのが便利です。
(wgrib2の導入は別の記事にも書いていますのでご参照ください。) では、さっそくwgrib2を使っていきます。


まずは風速データのみを一覧表示してみます。
gribファイルは通常、一つのファイルに色々な種類のデータが入っています。

$ wgrib2 (gribファイル)

このコマンドでもデータの一覧表示ができますが、量が多すぎて大変なので、-matchオプションを使って風速データのみを取り出します。
(ちなみに、風速はU:東西方向、V:南北方向 の二つから成り立っています。なので、風速を扱いたいときは、UGRD, VGRD両方を取り出す必要があります。)

  $ wgrib2 (gribファイル) -match UGRD

wgrib2

ここで、少し出力内容を見ていきます。

1.3:0:d=2018051500:UGRD:10 m above ground:anl

このような出力の場合、
- 1.3 => データのインデックス番号
- d=2018051500 => 2018年5月15日のデータ
- UGRD => U方向の強さ
- 10m above ground => 地上10m地点
- anl => 何時間後の数値予想か(anlの場合は0)

となります。
今回は要件が【地上10m地点で午前0時時点の風速データ】なので、一番上(index番号が1.3のデータ)で良さそうです。


次は、U, V それぞれの方向のデータを取り出します。
wgrib2 の-dオプションに、取り出したいデータのインデックス番号を与えることで、データを抽出できます。

$ wgrib2 -d 1.3 (grib2ファイル) -grib u.grib2

wgrib

出力ファイルは、U方向のデータということで、"u.grib2" という名前にしました。
そのあと、同じようにして"v.grib2"ファイルも準備します。
![キャプチャ.PNG](https://qiita-image-store.s3.amazonaws.com/0/165044/c8087230-5ab8-09bd-f7eb-f0ee179718d6.png)


次にU, V それぞれのファイルを連結する。
これは、leaflet-velocityが読み込む際に必要な手順となります。
ここはさらっと流します。

$ copy /b u.grib2 + v.grib2 wind.grib2

==出力==
u.grib2
v.grib2
1 個のファイルをコピーしました。


最後に、gribファイルをJSON形式に変換します。
leaflet-velocityで風速データを読み込ませるには、形式をJSONにする必要があります。
そこで、grib2json というツールを使います。

実際に変換するコマンドはこちらです。

  $ grib2json --names --data --o wind.json wind.grib2

HTML, JavaScriptを準備

以下のコードでは、leafletはCDNを使用します。 leaflet-velocityは予め用意しておいてください。 Leaflet-Velocity

index.html
<body>
  <link rel="stylesheet" href="https://npmcdn.com/leaflet@1.1.0/dist/leaflet.css" />
  <script src="https://npmcdn.com/leaflet@1.1.0/dist/leaflet.js">&lt;/script>
  <link rel="stylesheet" href="leaflet-velocity.css" />
  <script src="leaflet-velocity.js">&lt;/script>

  <div id="map">&lt;/div>

  <link rel="stylesheet" href="style.css" />
  <script src="script.js">&lt;/script>

</body>

css:style.css

style.css
#map {
  width: 100vw;
  height: 100vh;
}

js:script.js

function initDemoMap(){
    // 衛星写真タイルレイヤ
    var Esri_WorldImagery = L.tileLayer('http://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
        attribution: 'Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, ' +
        'AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community'
    });

    // 地形マップ
    var Stamen_TerrainBackground = L.tileLayer(
        'https://stamen-tiles-{s}.a.ssl.fastly.net/terrain-background/{z}/{x}/{y}.png', {
        attribution: 'Map tiles by <a href="http://stamen.com">Stamen Design</a>, <a href="http://creativecommons.org/licenses/by/3.0">CC BY 3.0</a> &mdash; Map data &copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>',
        }
    );

    // 黒地マップ
    var CartoDB_DarkMatterNoLabels = L.tileLayer(
        'https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_nolabels/{z}/{x}/{y}.png', {
        attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="http://cartodb.com/attributions">CartoDB</a>',
        subdomains: 'abcd',
        maxZoom: 19
        }
    );

    var baseLayers = {
        "Satellite": Esri_WorldImagery,
        "OpenElevationMap": Stamen_TerrainBackground,
        "CartoDB.DarkMatter": CartoDB_DarkMatterNoLabels
    };

    // マップ設定
    map = L.map('map', {
        layers: [CartoDB_DarkMatterNoLabels],
        minZoom: 4,
        maxZoom: 10
    });

    var layerControl = L.control.layers(baseLayers);
    layerControl.addTo(map);
    map.setView([36.5, 136], 5);

    return {
        map: map,
        layerControl: layerControl
    };
}

// demo map
var mapStuff = initDemoMap();
var map = mapStuff.map;
var layerControl = mapStuff.layerControl;

// load data (u, v grids) from somewhere (e.g. https://github.com/danwild/wind-js-server)
$.getJSON('/wind.json', function (data) {

  var velocityLayer = L.velocityLayer({
    displayValues: true,
    displayOptions: {
      velocityType: 'GBR Wind',
      displayPosition: 'bottomleft',
      displayEmptyString: 'No wind data'
    },
    data: data,
        minVelocity: 0,
        maxVelocity: 11,
        velocityScale: 0.015,
  });

  layerControl.addOverlay(velocityLayer, 'Wind - Great Barrier Reef');
});

これだけでOKで。
地図の日本周辺に小さい点で風が描画されるはずです。
データの準備さえできてしまえば、かなり簡単にお洒落なパーティクル処理が実装できますね。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

の大小比較はつねにその理由を、仕様を引用して解説する JavaScript

x < yはtrue、falseまたはundefinedを返します。undefinedが返る時は、少なくとも片方がNaN(非数)であることを表します。

x < yの判定のうち、片方(または両方)がundefinedのときのステップを中心に、以下に記載します。

xをプリミティブ型(Boolean, Null, Undefined, Number, BigInt, String, Symbolのいずれか)に変換する
yをプリミティブ型(Boolean, Null, Undefined, Number, BigInt, String, Symbolのいずれか)に変換する
変換した結果、両方ともStringであれば、
xとyを辞書順で並べ、xの方が先に来るならばtrue、そうでないならばfalseを返す(詳しくは仕様を参照してください)
それ以外であれば、
xをさらにNumberに変換する
yをさらにNumberに変換する
xがNaNであればundefinedを返す
yが
NaNであればundefinedを返す
xとyを数値として比較し、xの方が小さければtrue、そうでないならばfalseを返す(詳しくは仕様を参照してください)
undefinedの大小比較 共通手順を追う
例として、undefined < 0のときの処理を追ってみましょう。
上の手順に当てはめると、x = undefined、y = 0です。

xをプリミティブ型(Boolean, Null, Undefined, Number, BigInt, String, Symbolのいずれか)に変換する
→ undefinedをプリミティブ型に変換するとundefined(変化なし)です。
yをプリミティブ型(Boolean, Null, Undefined, Number, BigInt, String, Symbolのいずれか)に変換する
→ 0をプリミティブ型に変換すると0(変化なし)です。
変換した結果、両方ともStringであれば、
→ 条件に当てはまらないので次に進みます
それ以外であれば、
xをさらにNumberに変換する
yをさらにNumberに変換する
→ Numberへの変換表はこちらです。undefinedはNaNに変換され、0は0のままです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Node.jsでCSVファイルを読みこんでJSONデータに変換するサンプルコード

Node.jsでShift_JIS で書かれたCSVファイルを取り扱う必要があり、その際の備忘メモ。
JSON変換までできた方が便利なので、csvtojson を使ってみました。

要約

  • csvtojsonを使えば、標準的なCSVファイルを簡単にJSONデータに変更できました。
  • Node.jsはファイル読み込みは基本UTF-8を想定しているようですが、UTF-8以外の場合は iconv-liteをかましてShift_JIS→UTF-8変換してあげればOKでした。
  • csvtojson はコマンドラインからも呼び出せるので、コマンドラインでCSV→JSON変換できるのはなにげに便利です。

前提や環境

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.15.3
BuildVersion:   19D76

$ node --version
v10.16.2
$ 

ちなみにサンプルデータは 住所.jp という日本の住所情報を用いてみました。

やってみる

$ git clone --branch 0.1.0 https://github.com/masatomix/csv-sample-node.git
$ cd csv-sample-node/
$ npm install

落としてきたプロジェクトのディレクトリ構成はこんな感じです。処理対象のcsvファイル(13tokyo.csv)だけは、上記サイト から落としておきましょう。


$ tree
.
├── data
│   └── 13tokyo.csv  ←読み込むCSVファイル
├── src
│   └── index.ts
├── dist
│   ├── index.js
│   └── index.js.map
├── package.json
└── tsconfig.json

ソースコードはこんな感じ。

index.ts
import fs from 'fs'
import iconv from 'iconv-lite'
import csv from 'csvtojson'

/**
 * 指定したパスのcsvファイルをロードして、JSONオブジェクトとしてparseする。
 * 全行読み込んだら完了する Promise を返す。
 * @param path
 */
const parse = (path: string): Promise<any[]> => {
  return new Promise((resolve, reject) => {
    let datas: any[] = []
    fs.createReadStream(path)
      .pipe(iconv.decodeStream('Shift_JIS'))
      .pipe(iconv.encodeStream('utf-8'))
      .pipe(csv().on('data', data => datas.push(JSON.parse(data)))) // 各行読んだらココが呼ばれるので配列にpush
      .on('end', () => resolve(datas)) // 全部終わったらココにくるので、resolveする
  })
}

if (!module.parent) {
  // 呼んでみる
  parse('./data/13tokyo.csv').then((results: any[]) => {
    // 郵便番号が「100-000x」のものに絞ってみた
    results = results.filter(address => address['郵便番号'].startsWith('100-000'))
    console.table(results)
    // for (const address of results) {
    //   console.log(address)
    // }
  })
}

実行してみます。

$ npm run dev

> csv-sample-node@0.1.0-SNAPSHOT dev /Users/xxx/git/csv-sample-node
> ts-node src/index.ts

┌─────────┬─────────────┬────────┬─────────┬─────────────┬────────────┬────────┬───────┬───────┬──────────┬────────┬────────┬────────┬────────────┬──────────┬───────┬───────┬──────────┬────┬──────┬────────┬───────┬───────┐
│ (index) │    住所CD     │ 都道府県CD │ 市区町村CD  │    町域CD     │    郵便番号    │ 事業所フラグ │ 廃止フラグ │ 都道府県  │  都道府県カナ  │  市区町村  │ 市区町村カナ │   町域   │    町域カナ    │   町域補足   │ 京都通り名 │  字丁目  │  字丁目カナ   │ 補足 │ 事業所名 │ 事業所名カナ │ 事業所住所 │ 新住所CD │
├─────────┼─────────────┼────────┼─────────┼─────────────┼────────────┼────────┼───────┼───────┼──────────┼────────┼────────┼────────┼────────────┼──────────┼───────┼───────┼──────────┼────┼──────┼────────┼───────┼───────┤
│    0    │ '100000000' │  '13'  │ '13101' │ '131010000' │ '100-0000' │  '0'   │  '0'  │ '東京都' │ 'トウキョウト' │ '千代田区' │ 'チヨダク' │   ''   │    ' '     │ '(該当なし)' │  ''   │  ''   │    ''    │ '' │  ''  │   ''   │  ''   │  ''   │
│    1    │ '100000400' │  '13'  │ '13101' │ '131010006' │ '100-0004' │  '0'   │  '0'  │ '東京都' │ 'トウキョウト' │ '千代田区' │ 'チヨダク' │ '大手町'  │  'オオテマチ'   │    ''    │  ''   │  ''   │    ''    │ '' │  ''  │   ''   │  ''   │  ''   │
│    2    │ '100000200' │  '13'  │ '13101' │ '131010039' │ '100-0002' │  '0'   │  '0'  │ '東京都' │ 'トウキョウト' │ '千代田区' │ 'チヨダク' │ '皇居外苑' │ 'コウキョガイエン' │    ''    │  ''   │  ''   │    ''    │ '' │  ''  │   ''   │  ''   │  ''   │
│    3    │ '100000100' │  '13'  │ '13101' │ '131010045' │ '100-0001' │  '0'   │  '0'  │ '東京都' │ 'トウキョウト' │ '千代田区' │ 'チヨダク' │ '千代田'  │   'チヨダ'    │    ''    │  ''   │  ''   │    ''    │ '' │  ''  │   ''   │  ''   │  ''   │
│    4    │ '100000300' │  '13'  │ '13101' │ '131010051' │ '100-0003' │  '0'   │  '0'  │ '東京都' │ 'トウキョウト' │ '千代田区' │ 'チヨダク' │ '一ツ橋'  │  'ヒトツバシ'   │    ''    │  ''   │ '1丁目' │ '01チョウメ' │ '' │  ''  │   ''   │  ''   │  ''   │
│    5    │ '100000500' │  '13'  │ '13101' │ '131010055' │ '100-0005' │  '0'   │  '0'  │ '東京都' │ 'トウキョウト' │ '千代田区' │ 'チヨダク' │ '丸の内'  │  'マルノウチ'   │    ''    │  ''   │  ''   │    ''    │ '' │  ''  │   ''   │  ''   │  ''   │
│    6    │ '100000600' │  '13'  │ '13101' │ '131010057' │ '100-0006' │  '0'   │  '0'  │ '東京都' │ 'トウキョウト' │ '千代田区' │ 'チヨダク' │ '有楽町'  │ 'ユウラクチョウ'  │    ''    │  ''   │  ''   │    ''    │ '' │  ''  │   ''   │  ''   │  ''   │
└─────────┴─────────────┴────────┴─────────┴─────────────┴────────────┴────────┴───────┴───────┴──────────┴────────┴────────┴────────┴────────────┴──────────┴───────┴───────┴──────────┴────┴──────┴────────┴───────┴───────┘
$ 

ちゃんとShift_JISのCSVを読み込めています。
また、CSVの1行目のヘッダ行の文字列をJSONデータのプロパティ名として扱えてますね。簡単です。

おまけ: コマンドラインから使ってみる

npmに付属しているnpx コマンドを使うことで、csvtojsonをコマンドラインから呼び出せます。

$ npx csvtojson ./data/13tokyo.csv 
{"�Z��CD":"101841500","�s���{��CD":"13","�s�撬��CD":"13101","����CD":"131010019","�X�֔ԍ�":"101-8415","���Ə��t���O":"1","�
$

あー Shift_JISはダメですね。ということでnkf をかましてみます。

$ cat ./data/13tokyo.csv | nkf -S | npx csvtojson
{"住所CD":"101006101","都道府県CD":"13","市区町村CD":"13101","町域CD"
$

nkfコマンドは適宜Homebrewなどで入れておきましょう。
おつかれさまでした。

関連リンク・ソースコード

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む