風柳メモ

ソフトウェア・プログラミング関連の覚書が中心

chrome-extension-kickstart-typescript が Node.js v12 だとうまく動かない件とその対策

■ 追記
2020年02月11日にid:SWIMATH2さんが対応して下さりgenerator-chrome-extension-kickstart-typescript v4.4.1 以降では、Node.js 12 でも元の手順のままで正常動作するようになった模様です。
以下は古い情報となります。


勉強を兼ねてブラウザ拡張機能をTypeScriptで書いてみようと、swimath2.hatenablog.comgenerator-chrome-extension-kickstart-typescript を試してみたところ、Node.js v12 ではそのままでは動作しませんでした。
試行錯誤の末にとりあえず動くようになったので、覚え書きとして手順等を書いておきます。




対策(お急ぎの方はこちらだけお読みください)

試行環境
# Node.js v12.14.1 LTS および Yarn 1.21.1 は、それぞれインストーラをダウンロードしてインストール済
$ node -v && npm -v
v12.14.1
6.13.4

$ yarn -v
1.21.1

# yo および generator-chrome-extension-kickstart-typescript を npm もしくは yarn にて global インストール
#$ npm install -g yo generator-chrome-extension-kickstart-typescript
$ yarn global add yo generator-chrome-extension-kickstart-typescript

$ yo --version
3.1.1
手順

1. 拡張機能用のプロジェクト作成
拡張機能用のフォルダを作り(例では sample)chrome-extension-kickstart-typescript を --skip-install 付きで実行

$ mkdir -p sample
$ cd sample
# --skip-install を付けて、npm による自動インストールを抑制
$ yo chrome-extension-kickstart-typescript --skip-install

2. package.json 編集

$ vi ./package.json

※差分例

--- ./package.json.orig 2020-01-11 14:33:14.473509100 +0900
+++ ./package.json      2020-01-11 14:44:36.316563000 +0900
@@ -32,7 +32,7 @@
     "chromereload": "0.x.x",
     "debounce": "1.x.x",
     "del": "3.x.x",
-    "gulp": "3.x.x",
+    "gulp": "3.9.0",
     "gulp-bump": "2.x.x",
     "gulp-cache": "0.x.x",
     "gulp-clean-css": "^3.x.x",
@@ -57,6 +57,10 @@
     "vinyl-named": "1.x.x",
     "webpack": "3.x.x",
     "webpack-stream": "3.x.x",
-    "yargs": "^8.x.x"
+    "yargs": "^8.x.x",
+    "typescript": "^3.x.x"
+  },
+  "resolutions": {
+    "gulp/**/graceful-fs": "^4.x.x"
   }
 }

3. パッケージのインストール
yarn install で必要な全パッケージをインストール

$ yarn install

4. 動作確認
動作するかどうかを yarn run で確認

$ yarn run dev:chrome

※ 正常動作したときの例

yarn run v1.21.1
$ gulp --watch --vendor=chrome
[14:56:00] Requiring external module babel-core/register
[14:56:07] Using gulpfile D:\webext\sample\gulpfile.babel.js
[14:56:07] Starting 'build'...
[14:56:07] Starting 'clean'...
[14:56:07] Finished 'clean' after 3.37 ms
[14:56:07] Starting 'manifest'...
[14:56:07] Starting 'scripts'...
[14:56:07] Starting 'styles:css'...
[14:56:07] Starting 'styles:less'...
[14:56:07] Starting 'styles:sass'...
[14:56:07] Starting 'pages'...
[14:56:07] Starting 'locales'...
[14:56:07] Starting 'images'...
[14:56:07] Starting 'fonts'...
[14:56:07] Starting 'chromereload'...
[14:56:07] Starting 'livereload-server'
[14:56:07] Finished 'styles:css' after 32 ms
[14:56:07] Finished 'styles:less' after 26 ms
[14:56:07] Finished 'styles:sass' after 26 ms
[14:56:07] Starting 'styles'...
[14:56:07] Finished 'styles' after 20 μs
[14:56:07] Finished 'pages' after 27 ms
[14:56:07] Finished 'fonts' after 26 ms
[14:56:07] Finished 'manifest' after 264 ms
[14:56:07] Finished 'locales' after 237 ms
[14:56:07] Finished 'images' after 237 ms
ts-loader: Using typescript@3.7.4 and D:\webext\sample\tsconfig.json
[14:56:10] Finished 'scripts' Hash: 941f7e8c10f53a4b8956
Version: webpack 3.12.0
Time: 2076ms
        Asset     Size  Chunks             Chunk Names
background.js  6.83 kB       0  [emitted]  background
   [0] multi ./app/scripts/background.ts 28 bytes {0} [built]
   [1] ./app/scripts/background.ts 254 bytes {0} [built]
[14:56:10] webpack is watching for changes

経緯

発生した不具合

最初に、qiita.com を参考にして、

$ mkdir -p sample
$ cd sample
$ yo chrome-extension-kickstart-typescript
# 試行のためとりあえず問にはすべて[Enter]
$ npm install --save-dev typescript
$ npm install --save-dev gulp@3.9.0
$ npm run dev:chrome

のようにしたところ、

$ npm run dev:chrome

> sample@0.0.0 dev:chrome D:\webext\sample
> gulp --watch --vendor=chrome

[08:33:12] Requiring external module babel-core/register
fs.js:27
const { Math, Object } = primordials;
                         ^

ReferenceError: primordials is not defined
    at fs.js:27:26
    at req_ (D:\webext\sample\node_modules\natives\index.js:143:24)
    at Object.req [as require] (D:\webext\sample\node_modules\natives\index.js:55:10)
    at Object.<anonymous> (D:\webext\sample\node_modules\vinyl-fs\node_modules\graceful-fs\fs.js:1:37)
    at Module._compile (internal/modules/cjs/loader.js:955:30)
    at Module._extensions..js (internal/modules/cjs/loader.js:991:10)
    at Object.require.extensions.<computed> [as .js] (D:\webext\sample\node_modules\babel-register\lib\node.js:152:7)
    at Module.load (internal/modules/cjs/loader.js:811:32)
    at Function.Module._load (internal/modules/cjs/loader.js:723:14)
    at Module.require (internal/modules/cjs/loader.js:848:19)
npm ERR! code ELIFECYCLE
npm ERR! errno 1
npm ERR! sample@0.0.0 dev:chrome: `gulp --watch --vendor=chrome`
npm ERR! Exit status 1
npm ERR!
npm ERR! Failed at the sample@0.0.0 dev:chrome script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output above.

npm ERR! A complete log of this run can be found in:
npm ERR!     C:\Users\testuser\AppData\Roaming\npm-cache\_logs\2020-01-11T06_33_12_337Z-debug.log
調査

Node.js v12 と gulp.js 3 は相性が悪いらしい?!
とりあえず「ReferenceError: primordials is not defined」で検索してみると
stackoverflow.com
に行き当たる。
こちらの回答によると、

  1. gulp.js を version 4 にする
  2. Node.js のバージョンを v12 未満へ下げる

のいずれかで対応できるらしい。

1. の gulp.js のバージョンを変える方は、3→4 の変更に対応してソースコードも変更が必要になると思われるので手間がかかりそうだったため、2. の方法を試してみる。
nvm-windows で Node.js のバージョンを 10.18.1 LTS にしてみると、確かに動作するようになった。

Node.js v12 & gulp.js 3 のまま使いたいな……
できれば Node.js や gulp.js のバージョンを変えずに使う方法はないか、と調べていると、
https://timonweb.com/posts/how-to-fix-referenceerror-primordials-is-not-defined-error/timonweb.com
に行き当たる。

要は、gulp.js が利用している graceful-fs のバージョンが古いのが悪いらしい。

$ npm list graceful-fs

で調べてみると、他はほとんどが "graceful-fs@4.2.3" を使っているのに対し、gulp(及び
gulp が依存しているパッケージ)については

+-- gulp@3.9.0
| `-- vinyl-fs@0.3.14
|   +-- glob-watcher@0.0.6
|   | `-- gaze@0.5.2
|   |   `-- globule@0.1.0
|   |     `-- glob@3.1.21
|   |       `-- graceful-fs@1.2.3
|   `-- graceful-fs@3.0.12

のように、確かに古いバージョン("graceful-fs@1.2.3"、"graceful-fs@3.0.12")を使っている。

そういえば、yo chrome-extension-kickstart-typescript 実行時にも

npm WARN deprecated graceful-fs@1.2.3: please upgrade to graceful-fs 4 for compatibility with current and future versions of Node.js

のような警告が出ていた。これによれば、graceful-fs 4 以降を使うようにすれば良さげ。

上記の記事を参考に、npm-shrinkwrap.json を

{
  "dependencies": {
    "graceful-fs": {
        "version": "^4.x.x"
     }
  }
}

の内容で作成した後、改めて npm install 後に npm run dev:chrome をすると、

# package-lock.json は npm-shrinkwrap.json と排他なため、消しておく
$ rm package-lock.json
# パッケージのインストール
$ npm install
# ※このとき、npm-shrinkwrap.json も更新されることに注意

# 開発環境を起動
$ npm run dev:chrome

すると、今度はエラーになると起動することができた。

ただし、この方法だと、

  • npm install --save-dev でパッケージを入れたりすると、最初に指定した "dependencies" が維持されないため、再度 npm-shrinkwrap.json を上記内容に書き換えた上で npm install を実行しなおす必要がある

ということで、やや保守性に欠ける。

特定のパッケージが依存関係にあるパッケージだけバージョンを指定したい
要望としては

  • gulp が依存している graceful-fs のみ、バージョンを指定したい
  • メンテナンスは最小限(意識するのは初回のみ)としたい(パッケージの追加時等に影響無いようにしたい)

となる。

調べてみると npm の代わりに Yarn を使用し、『選択的な依存関係の解決(Selective dependency resolutions)』機能を使えばできそう(package.json に "resolutions" フィールドを追加)。

  "resolutions": {
    "gulp/**/graceful-fs": "^4.x.x"
  }

この方針で調整したのが、こちらの手順になる

参考:npm で Yarn の "resolutions" っぽいことはできないか?
一応、npm 用にそういうパッケージも存在する模様。
github.com

ただ、試してみた限りでは

  • 階層指定("gulp/**/graceful-fs" 等といった書き方)はできない
  • "scripts"セクションに"preinstall"を追加するのは、一度 npm install を実行後にする必要あり
    いったん npm install してから(package-lock.json が作成されてから)でないと、npm-force-resolutions がエラーになる
  • パッケージの追加時等でpackage-lock.jsonが更新された際には、npm install をやり直す必要あり

のような感じで、今ひとつ使い勝手が良くないように感じた。
ざっくりとか見ていないため、誤解しているかもしれない

その他

generator-chrome-extension-kickstart 及び主な fork の状況

【覚書】他社で取得した独自ドメインのメールアカウントをさくらのレンタルサーバーで使う場合のDNS設定方法

さくらのレンタルサーバーのメールサーバー機能を利用して、(さくら外で取得した)独自ドメインのアカウントでメールを送信したら、Gmail で「未認証メール」扱いとなった(「?」マークがついた)。
調べてみると、独自ドメインの DNS 設定(SPFレコード)が誤っていた。
再発防止のため、メモ書きとして恥をさらしておく。





メール用の DNS 設定例

さくらのレンタルサーバーの設定が以下の様になっている場合、

項目 値(例) 備考
初期ドメイン myaccount.sakura.ne.jp さくらのアカウント名が頭に付くドメイン名
ホスト名 wwwNNN.sakura.ne.jp さくらのサーバーのホスト名(IPv4アドレス=YYY.YYY.YYY.YYY)
※サーバ情報の表示>サーバに関する情報の「ホスト名」
SPFチェック対象となる(メールヘッダのReceived: from ~に記載される)のはこちら
IPアドレス XXX.XXX.XXX.XXX 初期ドメインに紐づいたIPv4アドレス
※サーバ情報の表示>サーバに関する情報の「IPアドレス」
※共有アドレスか専用アドレスかはプランによって異なる
サーバーのホスト名に紐づいたもの(YYY.YYY.YYY.YYY)とは異なる場合があることに注意

以下の様に独自ドメインを取得し、

項目 値(例) 備考
独自ドメイン mydomain.com (さくら外で取得した)独自ドメイン名

"user@mydomain.com" のようなメールアドレス(アカウント)を、さくらのレンタルサーバーで使用することを想定する。
さくらのコントロールパネルから、ドメイン/SSL設定>[新しいドメインの追加] にて、「5. 他社で取得したドメインを移管せずに使う」により、mydomain.com を追加してあるものとする。

このとき、メール用として、(他社の)DNSサーバーには以下の様に MX レコードと TXT レコード(SPFレコード)を登録する。

mydomain.com.          3600    IN      MX      10 myaccount.sakura.ne.jp.
mydomain.com.          3600    IN      TXT     "v=spf1 a:wwwNNN.sakura.ne.jp mx ~all"

SPFレコードの記述は、さくらのドメイン詳細設定を使用した際に「□SPFレコードを利用する」にチェックを入れた場合に設定されるものに準拠している(SPFディレクトリ中、'mx' は無くても良い。また、'a:wwwNNN.sakura.ne.jp' の代わりに 'ip4:YYY.YYY.YYY.YYY' と直接 IP アドレスを記載しても動作はするが、IPv6アドレスが使用されることもあるため、ドメイン名にしておいた方が無難)。

失敗例

SPF レコードを

mydomain.com.          3600    IN      TXT     "v=spf1 ip4:XXX.XXX.XXX.XXX ~all"
mydomain.com.          3600    IN      TXT     "v=spf1 a:myaccount.sakura.ne.jp ~all"
mydomain.com.          3600    IN      TXT     "v=spf1 mx ~all"

といった様に、許容するIPアドレス/ドメインとして初期ドメインを設定してしまうと、認証に失敗してしまう(場合がある)。
ビジネスプラン等では、初期ドメイン(myaccount.sakura.ne.jp)とサーバーのホスト(wwwNNN.sakura.ne.jp)が示すIPアドレスが異なっているため、失敗(softfail)する。スタンダードプラン等では同じIPアドレスを示すので、結果的に通る(pass)場合もある。

例えば Gmail アドレス宛にメールを送信したとき、認証に失敗していると、メールヘッダで

ARC-Authentication-Results: i=1; mx.google.com;
       spf=softfail (google.com: domain of transitioning user@mydomain.com does not designate YYY.YYY.YYY.YYY as permitted sender) smtp.mailfrom=user@mydomain.com

のように SPF チェックの結果が "softfail" であったことが記載され、Gmail で当該メールを見たときに「未認証メール」として、「?」マークが付く。
メールを開き、「?」マークにマウスオーバ(スマートフォン等の場合にはタップ)すると、「Gmail では、このメールが(スパム発信者からではなく)本当に mydomain.com から送信されたメールであることを確認できませんでした。」といった文言が表示される。

SPF レコードの妥当性チェック方法

設定した SPF レコードが有効かどうかは、port25が提供しているサービスを使うのが手っ取り早い。

check-auth@verifier.port25.com

宛にメールを送信すると、「Authentication Report」という Subject がついて、

==========================================================
Summary of Results
==========================================================
SPF check:          pass
"iprev" check:      pass
DKIM check:         none
SpamAssassin check: ham

のように SPF チェックの結果などを含むメールが返信されてくる。

Firefox のアドオン(content_scripts)でXMLHttpRequestやfetchを使う場合の注意

先日、Firefox で Twitter メディアダウンローダが正常に動作しないという報告を受け(その後、近傍ツイート検索も異常な動作をすることが判明)、原因を突き止めるのに時間がかかったので、覚え書き。

結論としては、

ということ。





現象

Firefox 上で、Twitter 用のアドオン(Twitter メディアダウンローダ(0.1.1.2801以前)/近傍ツイート検索(0.2.6.1200以前))を動かすと、jQuery.getJSON() や jQuery.ajax() で、本来 JSON が返されるべきところが HTML が返ってきてしまい、正常に動作しない。
基本的には同一のスクリプトであるにも関わらず、Google Chrome 拡張機能版や、Firefox であっても Tampermonkey 上のユーザースクリプトとしてであれば問題なく動作する。

原因

当該関数による GET リクエストの HTTP Request Header に

  • Origin: null がセットされる
  • Referer はセットされない

という動作になっていた。

なお、この現象が発生した際には、当該通信は開発者ツールのネットワークモニタ上に表示されない、という困った不都合もある(こちらは、未だに表示させる方法が不明のまま)。

Twitter 側で(リクエストの正当性検証のため?おそらく 最近になって)Referer等をチェックするようになったことが原因で、正常な応答が返されなくなっていたものと思われる。

追記(2018/07/19)

SWSが本脅威を対策するためには、Refererなどを確認してリクエストの正当性を検証したり、CookieにSameSite属性を付与する手法などが考えられます。

ソーシャルウェブサービスにおける新たなプライバシー脅威「Silhouette」を発見:NTT持株会社ニュースリリース:NTT HOME

実は、このプライバシー脅威(Silhouette)への対策の影響だった、とか?

対策

This is accomplished by exposing more privileged XHR and fetch instances in the content script, which has the side-effect of not setting the Origin and Referer headers like a request from the page itself would, this is often preferable to prevent the request from revealing its cross-orign nature. From version 58 onwards extensions that need to perform requests that behave as if they were sent by the content itself can use content.XMLHttpRequest and content.fetch() instead. For cross-browser extensions their presence must be feature-detected.

Content scripts - Mozilla | MDN

バージョン58以上の Firefox においては、アドオンで content_scripts 内で XMLHttpRequest や fetch (およびそれらを使用している jQuery 等のライブラリ)を使用するときには、window.XMLHttpRequest や window.fetch の代わりに、content.XMLHttpRequest や content.fetch を用いればよいらしい。
これにより、普通にページ(コンテンツ)上から発行されたのと同様に、Origin や Referer がセットされるようになる。

注意事項

content_scripts 内では、window.XMLHttpRequest や window.fetch は read-only になっているため、content.* で上書きしようとしてもできない。

自作スクリプト内ならば、使用するスコープの最初で、

const XMLHttpRequest = ( typeof content != 'undefined' && typeof content.XMLHttpRequest == 'function' ) ? content.XMLHttpRequest  : window.XMLHttpRequest;
const fetch = ( typeof content != 'undefined' && typeof content.fetch == 'function' ) ? content.fetch  : window.fetch;

のようにしておけばよいかも。

問題なのは、jQuery のようなライブラリを使用する場合で、作りによってはソースコードを直接書き換えるしか無いかもしれない。
なお、Firefox のアドオンを AMO にアップする際には、既知のライブラリを使う場合、改変されたものは同梱出来ない、という制約がある。レビューでチェックされ、改変が見つかった場合は当該バージョンが無効化されてしまう(既に経験済み(汗))。

幸い、jQuery の場合には、読み込んだ後にjQuery.ajaxSettings.xhrを次のような感じにオーバーライドしてやればよさげ。
追記と訂正:直接書き換えるよりも、jQuery.ajaxSetup() を介するべきだった。

if ( ( typeof content != 'undefined' ) && ( typeof content.XMLHttpRequest == 'function' ) ) {
    /*
    //jQuery.ajaxSettings.xhr = function () {
    //    try {
    //        return new content.XMLHttpRequest();
    //    } catch ( e ) {}
    //};
    */
    jQuery.ajaxSetup( {
        xhr : function () {
            try {
                return new content.XMLHttpRequest();
            } catch ( e ) {}
        }
    } );
}

WordPressをhttps化したら、Contact Form 7 の設定が消えてしまった⁉

WordPress を設置してあるサイトを SSL/TLS(http → https)化し、記事なども含めて http://~ となっていたサイトの URL を https://~ に置換したところ、Contact Form 7 の一部の設定が消えてしまった。
今後も似たような失敗をしそうなので、覚え書きとして記事にしておく。




現象

f:id:furyu-tei:20180607212208p:plain

のような、コンタクトフォーム(Contact Form 7)のメールテンプレート設定がなされていたところに、Search Regexを使用して、Post meta value 中のサイトの URL を http://~ から https://~ に置換した。

f:id:furyu-tei:20180607212720p:plain

すると、なぜか、コンタクトフォームのメールテンプレート設定が無くなってしまっていた。

f:id:furyu-tei:20180607212804p:plain

原因

バックアップして置いたデータベースのファイルを調べてみたところ、コンタクトフォームのメールテンプレートは、

(9, 5, '_mail', 'a:9:{s:6:\"active\";b:1;s:7:\"subject\";s:35:\"WordPressてすと \"[your-subject]\"\";s:6:\"sender\";s:42:\"[your-name] \";s:9:\"recipient\";s:18:\"xxxxxxxx@xxxxxxxxx\";s:4:\"body\";s:238:\"差出人: [your-name] <[your-email]>\n題名: [your-subject]\n\nメッセージ本文:\n[your-message]\n\n-- \nこのメールは WordPressてすと (http://xxxxxxxxxxxxxxxxxxxxxxxxx) のお問い合わせフォームから送信されました\";s:18:\"additional_headers\";s:22:\"Reply-To: [your-email]\";s:11:\"attachments\";s:0:\"\";s:8:\"use_html\";b:0;s:13:\"exclude_blank\";b:0;}'),

のような形で格納されていた。

勘のいい方はお気づきだろう……そう、つまり、PHPでシリアル化(serialize)された形式になっているため、http → https へと単純に置換してしまうと、文字数が1文字増えて、シリアル化データとしては壊れてしまい、コンタクトフォームでは不正な値として設定が無効化されてしまったのである。

対策

今回たまたま当方が気付いたのがコンタクトフォームだっただけで、似たような現象は他のプラグインでも発生しうると思われる。
Search Regex による Post meta value の機械的な置換は実施せず、各プラグインの設定にサイトの URL が含まれる場合には、逐次確認して手動で変更するのが無難だろう。
コンタクトフォームに限れば、データベースファイル中の '_mail' や '_mail_2' といった行の "body" 中にサイトの URL が含まれる場合、 "body" の長さをひとつ増加させるようなスクリプトでも組んでやれば対応可能ではあろうが。

より効率の良い方法やプラグインなどをご存知の方は、ご教示願いたい。

追記

はけた氏(@)にもツイートでご指摘いただいたが、こういう場合は
interconnectit.com
を使うのが定石の模様。

Search Replace DB を使うと、
f:id:furyu-tei:20180608090145p:plain
このように、Post meta value 等のシリアル化されたデータについても、それに配慮した変換を行ってくれる。

なお、実際に不具合で発生したサイトでも最初はこれを試そうとしたのだが、WordPress のバージョンの問題でうまく実行できなかったのであった……環境はなるべく最新のものを使おうね、というお話(自分が制作・管理していない場合、いかんともしがたいが・苦笑)。

【覚書】viewport指定について

モバイルデバイス用のviewport設定についてまじめに調べようとしたら、深みにはまりそうだったので、とりあえず整理を兼ねて覚え書きを残しておく。
ついでに、viewportの簡易的な表示テストができるページも作ってみた




viewport の指定方法

viewportは、HTMLのhead内のmeta要素として指定する

<meta name="viewport" content="(viewportのパラメータ)" />

viewportのパラメータはカンマ(",")区切りで指定。

なお、これは主としてモバイルデバイス(スマートフォン・タブレットなど)用の設定であり、基本的にはデスクトップ用ブラウザの表示には影響を与えない。
ブラウザが常に全画面で表示されるために、基本、ウィンドウの幅=デバイスの表示幅となるモバイルデバイスにおいて(余白が出来たり見切れたりすることなく)適切なページを表示を行うための指定という認識。

viewportの主要なパラメータ

<meta name="viewport"> の content の値
設定可能な値 説明
width 正の整数または文字列 device-width ウェブサイトを描画したいビューポートの幅をピクセル数で定義します。
height 正の整数またはテキスト device-height ビューポートの高さを定義します。どのブラウザーでも使用されていません。
initial-scale 0.0 から 10.0 までの、正の数値 デバイスの幅 (ポートレートモードでの device-width またはランドスケープモードでの device-height) とビューポートの寸法との比率を定義します。
maximum-scale 0.0 から 10.0 までの、正の数値 ズームの最大値を定義します。この値は minimum-scale と同じまたはより大きくしなければなりません。そうではないときの動作は未定義です。ブラウザーの設定でこの規則を無視できます。また、iOS 10 以降は既定で無視します。
minimum-scale 0.0 から 10.0 までの、正の数値 ズームの最小値を定義します。この値は maximum-scale と同じまたはより小さくしなければなりません。そうではないときの動作は未定義です。ブラウザーの設定でこの規則を無視できます。また、iOS 10 以降は既定で無視します。
user-scalable yes または no no を設定すると、ユーザーはページのズームができなくなります。既定値は yes です。ブラウザーの設定でこの規則を無視できます。また、iOS 10 以降は既定で無視します。

<meta>: 文書レベルメタデータ要素 - HTML: HyperText Markup Language | MDN
viewportを指定しないとどうなる?

viewport指定を省略した場合、モバイルデバイスでは、ブラウザが暗黙的にデフォルトのwidth値(ブラウザ毎に異なる、例えば980といった値)を用いて、これをウィンドウ(≒スクリーン)内に収めるように表示される。
例えば、デスクトップ向けのページなどで、横幅がこれより大きい値を前提として設計されていたら見切れてしまう。

device-width とは?

device-widthとは、モバイルデバイスが持つ画面(スクリーン)の論理解像度(ポイント実質解像度CSSピクセル)の横幅のことで、これはカタログに載っている解像度(画素数)とは異なる。
device-heightは同じく縦幅のことだが、ほとんど使うことはないと思われる。
例えば、iPhone X のディスプレイは5.8インチ・1125x2436ピクセルの解像度を持つが、device-widthは375(device-heightは812)となる。
すなわち、ブラウザのウィンドウで375px×812pxの画像を等倍で表示した場合、画面にピッタリと収まるサイズで表示されるということになる。

initial-scale とは?

基本的には、ページを表示した際の、最初の表示倍率(拡大率)のこと。
例えば、initial-scale=1 の時は等倍で、initial-scale=0.5の時は幅が半分に縮小(矩形としては4分の1)、initial-scale=2の時には幅が倍に拡大(矩形としては4倍)となる。

これだけならばよいのだが、ややこしいのが『条件によっては viewport の大きさに影響してしまう』ということ(後述)。

viewport が影響する主な要素

JavaScript

viewportの指定によりブラウザのウィンドウの大きさが決まることになるため、window.innerWidth・innerHeightといったパラメータに影響する。
なお、window.screen.widthやwindow.screen.availWidthは、viewportの指定によらず一定で、デバイス固有の値(≒device-width)を持つ。

viewport 設定テスト用ページについて

簡易的な viewport 設定テスト用ページを作成した。

viewport setting tester

  • radioボタンに応じて、meta[name="viewport"]を書き換えるようになっている。
  • 条件によっては、値を変更しても表示が変わらない場合がある。この場合、[Reload]を押すことで反映される。
    Mobile 版 Firefox では、[Reload]を押しても変わらない場合あり。この場合、ページをいったん閉じて、開きなおす必要がある。
  • Google Chrome の開発者ツールのモバイルデバイスエミュレータ(device toolbar)で、画面の拡大縮小(ピンチアウト/イン)の操作は、[Shift]+ドラッグ(スワイプ)で行う([Ctrl]+[+]/[-]では変化しないことに注意)。リセットは[Ctrl]+[0]。

具体的な viewport 指定の例

"width=device-width, initial-scale=1" で、万全!?

viewportについて調べていると、典型的な設定例として出てくるのは、

<meta name="viewport" content="width=device-width, initial-scale=1" />

のようなもの。

  • viewport(≒ブラウザのウィンドウ)幅を device-width に
  • 最初の表示倍率を 1 に

という指定。
f:id:furyu-tei:20180520095805p:plain

適切なレスポンシブ対応(メディアクエリのブレイクポイントの設定)が行われている場合には、この設定で問題は無い。

レスポンシブ対応がなされていない場合

レスポンシブ対応がなされておらず、『とりあえず、デスクトップ用の画面を、モバイルデバイスでも横幅が綺麗に収まる形で表示したい』という場合の設定。
例は、横幅1000pxで設計されているページの例

<meta name="viewport" content="width=1000, user-scalable=no" />

f:id:furyu-tei:20180520095818p:plain
本来ならば、width さえ指定しておけば、ブラウザが拡大率を適切に設定して、スクリーン内に収まるように調整してくれそうなもの。
ではなぜ user-scalable=no を設定しているのかというと、状況によってはこの自動調整がうまくいかないケース(等倍で表示されるケース)があったため
うまく自動調整されない場合の原因は不明。
いずれにしても、user-scalable=no にするとユーザーが拡大縮小できなくなってしまい不便なので、早期にきちんとレスポンシブ対応すべきではある。

レスポンシブ対応が不完全な場合

スマートフォンには対応したのだけれど、ある範囲(例えば横幅768px~1199px)のデバイス(タブレット)だけ表示が見切れる(デスクトップ用に1200px前提でページ設計)という場合の設定。

<meta name="viewport" content="width=device-width, initial-scale=1">

<script type="text/javascript">
(function () {
'use strict';

var minimum_screen_width_for_tablet = 768, // タブレットとみなすデバイス画面の最小論理解像度
    // ※ CSSメディアクエリの指定(例: @media (min-width: 768px) { ... })に合わせること
    viewport_width_for_tablet = 1200, // viewportとして指定する横幅(例えばデスクトップ用として想定されている最小の横幅)
    use_initial_scale = false,
    
    vieport_element = document.querySelector('meta[name="viewport"]'),
    adjust_viewport = function () {
        // TODO: 一部のWindows系モバイルデバイス(IEや旧Edgeを使用している場合等)では meta[name="viewport"] による指定が効かないので別途対応要
        try {
            var device_width = window.screen.width;
            
            if (minimum_screen_width_for_tablet <= device_width) {
                if (use_initial_scale) {
                    vieport_element.setAttribute('content', 'width=' + viewport_width_for_tablet + ', initial-scale=' + (device_width / viewport_width_for_tablet));
                    // 問題点: "width" と "initial-scale" で指定した場合うまく表示されないケースあり(デバイスを回転させた場合等)
                }
                else {
                    vieport_element.setAttribute('content', 'width=' + viewport_width_for_tablet + ', user-scalable=no');
                    // 問題点: "user-scalable=no" によりユーザーによるズーム不可となってしまう
                }
            }
            else {
                vieport_element.setAttribute('content', 'width=device-width, initial-scale=1');
            }
        }
        catch (error) {
            console.error(error);
        }
    };

window.addEventListener('orientationchange', function (event) {
    adjust_viewport();
});

window.addEventListener('resize', function (event) {
    adjust_viewport();
});

adjust_viewport();

})();
</script>

デフォルトとして、meta[name="viewport"]には"width=device-width, initial-scale=1"を指定しておき、JavaScript で window.screen.width によりデバイスのスクリーン幅(≒device-width)を調べ、条件に当てはまる場合だけ書き換えを行う。

上記は簡易的なものだが、
github.com
を使えばもっと細かく調整できる、かも(未確認)。

注意点・問題点など

initial-scale について

上記のとおり、initial-scaleは初期の表示倍率を示すほかに、条件によっては viewport の大きさに影響してしまう。
具体的には、

X = device-width / initial-scale
とするとき、
width < X となる場合には、viewport の幅として X が適用される(widthの指定は無視される)。

模様。
例えばdevice-widthが360のモバイルデバイスの場合に、
"width=640, initial-scale=0.5"
のような指定をすると、実際の viewport の幅としては 720(=360/0.5)が適用されてしまう(width=640の方は無視される)

また、initial-scale に 1 未満の値を指定しても、状況によってはうまく縮小表示されないケースもある。
具体的な条件は不明。ただし、この場合でも、user-scalable=no と併用すれば正しく縮小表示される模様。

ブラウザによってウィンドウサイズ(innerWidth/Height)の決定方法が異なっている?

f:id:furyu-tei:20180520095827p:plain

Google Chromeの場合
innerWidth/innerHeightは、ユーザーがscale(ピンチインで縮小表示)して表示可能な最大領域のピクセル数を示す模様。

この最大領域の横幅は、document.body の大きさと viewport の minimum-scale により、以下のように決まっている模様。

  • document.body の横幅が viewport の以下の場合には、viewport の幅
  • document.body の横幅が viewport より大きい場合、device-width / minimum-scale と document.body の横幅のうち、小さい方

あくまで最大領域に応じて決まるため、実際のユーザーの拡大縮小操作には影響されない。

Firefoxの場合
innerWidth/innerHeightは、ドキュメントのうち、ブラウザの表示領域に実際に表示されているピクセル数を示す模様。
ユーザーの拡大縮小操作に伴って変動する。

【未確認】Safariの場合
実機が無いため、確認できていない。

【未確認】Windows Tablet / Phone について

Windows 系のモバイルデバイスについては、meta[name="viewport"] による指定は効かないため、別途、CSS で @-ms-viewport による指定が必要
実機が無いこともあり、確認が取れていない。