第3回から気がつけば半年以上たってます。今回からは JavaScript 編です。
この半年で機能追加したこともあり、当初の予定より回数が増えそうです。
JavaScript の構成
再利用できるプレゼン用WEBページを作ろう 第2回 で書いたときと比べて、テーブル操作用のファイルと、環境依存部分を切り出した分ファイルが増えています。
common - js ──┬ Env.js.example ドキュメントルートなど環境依存部分を外出しするサンプル
├ functions.js 大文字で始まる JS から呼ばれるちょっとした関数群
├ Breadcrumb.js 各ページに共通するパンくずリストを生成
├ Definitions.js ページの定義やレイアウトの定義などをまとめたもの
├ Global.js definisions.js の記述をもとに Global 変数を生成
├ GotoTop.js ページスクロールしたときに右下に出現する Goto Top ボタン
├ Headline.js Global 変数をもとにページタイトルや H1 の内容を生成
├ Layout.js URL クエリなどをもとにページごとに必要な CSS を読み込む
├ Menu.js Global 変数をもとにグローバルメニュー、サブメニューを生成
├ Modal.js モーダルダイアログを生成
├ Swithcer.js レイアウト・トンマナの変更
├ TableCheck.js テーブル各行にあるチェックボックスの操作(全チェックなど)
├ TableCopy.js テーブル全体をコピー(Google Spreadsheet などへのコピペ用)
├ TableNarrow.js テーブルの列単位で表示・非表示切り替え
└ TableSort.js テーブルのソート
develop - js ─┬ Env.js ドキュメントルートやサイト名などモジュール固有の値を定義
├ Membership.js たいていのサイトで右上にある、ログイン状態の表示
├ SiteIdentifire.js たいていのサイトで左上にある、サイトロゴ
└ (ActionName).js 各ページ固有の動作を記述したもの
functions.js を除く、大文字から始まるファイル名のものは大まかに下記のような構造をしています。
なぜ functions.js だけ大文字でないかというと、クロージャになっていないから。
クロージャについての解説は
MDN Web Docs Clousure
等を参照してください。
/**
* クロージャの定義
*/
const ClosureName = (function ($) {
// クロージャとして必要な定数や関数を記述
let
aaa = 'aaaaa';
bbb = 'bbbbb';
const getBbb = function () {
return bbb;
};
ccc = function () {
...
};
// 外部からアクセスしたい機能や定数などはリターン内に記述
return {
run: function() {
// 読み込み時に実行したい処理
},
aaa: aaa, // 直接渡しているので書き換え可能
bbb: getBbb(), // 関数の戻り値なので書き換え不可能
ccc: ccc(),
};
})(jQuery);
/**
* オンロード時に自身を起動する
*/
$(function () {
ClosureName.run();
console.log(`ClosureName loaded @version XXX
`.replace('\n', ''));
});
こんな感じで private 相当の値と public 相当の値を切り分けています。
ぱっと見、オンロード時に実行される部分
console.log(`ClosureName loaded @version XXX
`.replace('\n', ''));
が気になると思いますが、動作に関係するものではないので説明は最後にやります。
ページ構成の基本を作る 4 ファイル
(module)/Env.js -- モジュール固有の定義
今回のように、develop モジュール下のみ見せたい場合には develop/js 下に Env.js を作ります。
ここで定義した DocumentRoot などが Definitions.loadEnv() で全体の定義に反映されます。
/**
* Develop Env closure
*
* @package jQueryMock
* @version d.4
* @author shindo@lvn.co.jp
*/
const Env = (function ($) {
/**
* 環境により CSS の読込先を変えたい場合はこのファイルをコピーして
* /(module_name)/js/Env.js を作り下記のように定義する
* その際、「(module_name)」部分2か所を実際のモジュール名に変えておくとなおよい
*/
const
DocumentRoot = 'C:/www/jQueryMock/',
MenuItems = {
siteName: 'プレゼン用 MOCK', // 当該モジュールで使いたいサイト名
},
PathToCss = 'develop/css/'; // 最後にスラッシュ必要
return {
DocumentRoot: DocumentRoot,
MenuItems: MenuItems,
PathToCss: PathToCss,
}
})(jQuery);
/**
* loader ----------------------------------------------------------------------
*/
$(function () {
console.log(`Develop Env loaded @version d.4
`.replace('\n', ''));
});
Definitions.js -- ページ構成の定義
/**
* Definitions closure
*
* @package jQueryMock
* @type {{
* run: Definitions.run,
* loadEnv: function (),
* PageLayouts: {
* layoutLeftRight: {
* subMenu: string,
* openCloseBar: boolean,
* description: string,
* category: string
* },
* layoutUpDownHorizontal: {
* subMenu: string,
* openCloseBar: boolean,
* description: string,
* category: string
* },
* layoutUpDownVertical: {
* subMenu: string,
* openCloseBar: boolean,
* description: string,
* category: string
* },
* layoutUpLeftRight: {
* subMenu: string,
* openCloseBar: boolean,
* description: string,
* category: string
* },
* toneDefault: {
* subMenu: string,
* openCloseBar: boolean,
* description: string,
* category: string
* },
* toneOrange: {
* subMenu: string,
* openCloseBar: boolean,
* description: string,
* category: string
* }
* },
* MenuItems: {
* siteName: string,
* module: {item: string[][]},
* global: {item: string[][], targetBlockId: string},
* sub: {item: string[][], targetBlockId: string}
* },
* DefaultLayout: string,
* DefaultDevice: string,
* DefaultTone: string,
* DocumentRoot: string,
* PathToCss: string,
* }}
* @version d.4
* @author shindo@lvn.co.jp
*/
const Definitions = (function ($) {
const
/**
* menu definition
*/
MenuItems = {
siteName: 'プレゼン用 MOCK',
module: {
item: [
// module module-name
['develop', '管理画面'],
]
},
global: {
targetBlockId: 'globalMenu',
item: [
// module controller action controller-name
['develop', 'index', 'index', 'テスト画面'],
['develop', 'controller1', 'index', 'メニュー1'],
['develop', 'controller2', 'index', 'メニュー2'],
]
},
sub: {
targetBlockId: 'subMenu',
item: [
// module controller action action-name
['develop', 'index', 'index', 'トップ'],
['develop', 'index', 'action1', '機能1'],
['develop', 'controller1', 'index', 'メニュー1'],
['develop', 'controller1', 'action1-1', '機能1'],
['develop', 'controller2', 'index', 'メニュー2'],
['develop', 'controller2', 'action2-1', '機能1'],
]
}
},
/**
* page layout type definition
*/
PageLayouts = {
layoutUpLeftRight: {
category: 'layout',
description: '3ペイン ヘッダ左右全域、その下に左メニューと右コンテンツ',
subMenu: 'open',
openCloseBar: true
},
layoutUpDownHorizontal: {
category: 'layout',
description: '2ペイン 上メニューと下コンテンツ submenu は常に globalMenu の下に展開',
subMenu: 'open',
openCloseBar: false
},
layoutUpDownVertical: {
category: 'layout',
description: '2ペイン 上メニューと下コンテンツ onmouse で submenu が下に展開',
subMenu: 'close',
openCloseBar: false
},
layoutLeftRight: {
category: 'layout',
description: '2ペイン 左メニューと右コンテンツ',
subMenu: 'close',
openCloseBar: false
},
toneDefault: {
category: 'tone',
description: 'グリーントーン',
subMenu: '',
openCloseBar: true
},
toneOrange: {
category: 'tone',
description: 'オレンジトーン',
subMenu: '',
openCloseBar: true
}
},
DefaultLayout = 'layoutUpDownHorizontal',
DefaultTone = 'toneDefault',
DefaultDevice = 'pc';
let PathToCss = '/common/css/',
DocumentRoot = '../../';
/**
* モジュールごとの定義を読み込み定義値に反映
*/
const loadEnv = function() {
if (typeof Env.DocumentRoot !== 'undefined') {
// FireFox は CSS の href が絶対パスの場合に file:/// がないと CSS を読み込めないので、
const protocol = location.protocol,
slash = protocol === 'file:' ? '///' : '//',
scheme = protocol + slash;
Definitions.DocumentRoot = scheme + Env.DocumentRoot;
}
if (typeof Env.PathToCss !== 'undefined') {
Definitions.PathToCss = Env.PathToCss;
}
if (typeof Env.MenuItems !== 'undefined') {
Object.keys(Env.MenuItems).map((propName) => {
Definitions.MenuItems[propName] = Env.MenuItems[propName];
});
}
};
/**
* getters
*/
const getMenuItems = function () {
return MenuItems;
}
const getPageLayouts = function () {
return PageLayouts;
}
const getDefaultLayout = function () {
return DefaultLayout;
}
const getDefaultTone = function () {
return DefaultTone;
}
const getDefaultDevice = function () {
return DefaultDevice;
}
const getPathToCss = function () {
return PathToCss;
}
return {
run: function() {
loadEnv();
},
MenuItems: getMenuItems(),
PageLayouts: getPageLayouts(),
DefaultLayout: getDefaultLayout(),
DefaultTone: getDefaultTone(),
DefaultDevice: getDefaultDevice(),
DocumentRoot: DocumentRoot,
PathToCss: getPathToCss()
};
})(jQuery);
/**
* loader ----------------------------------------------------------------------
*/
$(function () {
Definitions.run();
console.log(`Definitions loaded @version d.4
`.replace('\n', ''));
});
もう少しかっこいい定義の仕方があると思いますが、いまのところ実用上問題ないのでこんな感じです。
Global.js -- 定義をもとにメニューなどで使いやすい形に変形
/**
* Global closure
*
* @package jQueryMock
* @type {{
* run: Global.run,
* tool: Global.tool,
* changeSubMenuWidth: Global.changeSubMenuWidth,
*
* query: (function(): {}),
* route: (function(): {}),
* routeName: (function(): {}),
*
* module: (function(): string),
* controller: (function(): string),
* action: (function(): string),
*
* layout: (function(): string),
* tone: (function(): string),
* isSubMenuShow: (function(): boolean),
* useOpenCloseBar: (function(): boolean),
* }}
* @version d.4
* @author shindo@lvn.co.jp
*/
const Global = (function ($) {
/**
* グローバル変数 変更は Global.js からのみ
*/
let
/**
* URL を分解して {{}} グローバル変数として保持しておく
* @type {{}}
*/
query = {},
route = {},
routeName = {},
/**
* URL を分解して {string} グローバル変数として保持しておく
* @type {string}
*/
module = '',
controller = '',
action = '',
/**
* recent layout type, Tone manner, device
*
* @type {string}
*/
layout = '',
tone = '',
device = '',
/**
* submenu show ?
*
* @type {boolean}
*/
isSubMenuShow = false,
/**
* submenu use open-close bar ?
*
* @type {boolean}
*/
useOpenCloseBar = false;
/**
* Definitions.js で定義されている定数の値を元に各変数を作る
* @type query {{ layout: string, tone: string, device: string }}
*/
function initialize() {
// route を規定、それに応じた .css, .js を読み込む
route = loadRoute();
routeName = loadRouteName(route);
module = route.module;
controller = route.controller;
action = route.action;
// layout, tone, device 指定
query = loadQuery();
if (query.layout === '') {
query.layout = Definitions.DefaultLayout;
}
layout = query.layout;
if (query.tone === '') {
query.tone = Definitions.DefaultTone;
}
tone = query.tone;
if (query.device === '') {
query.device = Definitions.DefaultDevice;
}
device = query.device;
// layout に紐づく変数
isSubMenuShow = (Definitions.PageLayouts[layout].subMenu === 'open');
useOpenCloseBar = (Definitions.PageLayouts[layout].openCloseBar);
// layout 名を body.class に持たせる
$('body').addClass(layout);
}
/**
* URL パスから module, controller, action を取得
*
* @returns {*|{controller: string, module: string, action: string}}
*/
function loadRoute() {
let paths = location.pathname.split('/'), // 最後の3個だけが対象
length = paths.length,
route = {
module: paths[length - 3],
controller: paths[length - 2],
action: paths[length - 1].replace('.html', '')
};
return route;
}
/**
*
* @param {*} route
* @returns
*/
function loadRouteName(route) {
let index = 0,
name = {
siteName: Definitions.MenuItems.siteName,
module: '',
controller: '',
action: ''
};
for (index in Definitions.MenuItems.module.item) {
if (Definitions.MenuItems.module.item[index][0] === route.module) {
name.module = Definitions.MenuItems.module.item[index][1];
break;
}
}
for (index in Definitions.MenuItems.global.item) {
if (Definitions.MenuItems.global.item[index][0] !== route.module) {
continue;
}
if (Definitions.MenuItems.global.item[index][1] === route.controller) {
name.controller = Definitions.MenuItems.global.item[index][3];
break;
}
}
for (index in Definitions.MenuItems.sub.item) {
if (Definitions.MenuItems.sub.item[index][0] !== route.module) {
continue;
}
if (Definitions.MenuItems.sub.item[index][1] !== route.controller) {
continue;
}
if (Definitions.MenuItems.sub.item[index][2] === route.action) {
name.action = Definitions.MenuItems.sub.item[index][3];
break;
}
}
return name;
}
return {
run: function () {
initialize();
},
tool: function (newController, newAction) {
doJavaScript(newController, newAction);
},
changeSubMenuWidth: function () {
$('body').toggleClass('narrowSubMenu');
},
/**
* getters
*/
query: function () {
return query;
},
route: function () {
return route;
},
routeName: function () {
return routeName;
},
module: function () {
return module;
},
controller: function () {
return controller;
},
action: function () {
return action;
},
layout: function () {
return layout;
},
tone: function () {
return tone;
},
isSubMenuShow: function () {
return isSubMenuShow;
},
useOpenCloseBar: function () {
return useOpenCloseBar;
},
};
})($);
/**
* loader ----------------------------------------------------------------------
*/
$(function () {
Global.run();
console.log(`Global loaded @version d.4
`.replace('\n', ''));
});
最初に示した例では getter を run の外に書いていますが、ここでは(面倒なので) run 内に書いてます。
functions.js -- 複数クロージャから使われる関数群
/**
* functions
*
* @package jQueryMock
* @version d.4
* @author shindo@lvn.co.jp
*/
const
/**
* URL クエリをオブジェクトとして取得
*
* @returns {{}}
*/
loadQuery = function () {
const queryString = location.search.replace('?', '')
.replace('=undefined', '=')
.replace('&', '&'),
keyValues = queryString.split('&'),
query = {
layout: '',
tone: '',
device: ''
};
let index = 0,
temp = [];
if (keyValues.length === 0) {
return query;
}
keyValues.map(function(keyValue) {
const temp = keyValue.split('=');
if (temp[0] === '' || temp[1] === '') {
return;
}
query[temp[0]] = temp[1];
});
return query;
},
/**
* クエリを書き換えてページ遷移する
*
* @param route {module: {string}, controller: {string}, action: {string}}
* @param query {layout: {string}, tone: {string}}
* @param newQuery {layout: {string}, tone: {string}}
*/
changeLayout = function (route, query, newQuery) {
let queryString = '',
page = '';
query.layout = newQuery.layout;
query.tone = newQuery.tone;
queryString = makeQueryString(query);
page = '../../' + route.module + '/' + route.controller
+ '/' + route.action + '.html' + queryString;
location.href = page;
},
/**
* CSS 追加
*
* @param cssPath
*/
loadCss = function (cssPath) {
const $css = $('<link>').attr({
type: 'text/css',
rel: 'stylesheet',
class: 'layoutCss',
href: cssPath
});
$('head').append($css);
},
/**
* メニュー生成のためテンプレートに記述されたタグを取得
*
* @param targetBlockId
* @returns {{parent: *, child: *}}
*/
loadMenuTag = function (targetBlockId) {
const $targetBlock = $('#' + targetBlockId),
$parent = $('.' + targetBlockId + 'Parent').clone(),
$child = $('.' + targetBlockId + 'Child').clone(),
tag = {
parent: $parent.html(''),
child: $child.html('')
};
return tag;
},
/**
* メニュー 生成
*
* @param {string} targetBlockId
* @param {array} menuItems
* @param {object} $parentTag
* @param {object} $childTag
* @param {object} options
*/
makeTagFromTemplate = function (targetBlockId, menuItems, $parentTag, $childTag, options) {
const $menu = $parentTag.clone();
let $child = null,
index = 0,
item = [],
id = '';
for (index in menuItems) {
item = menuItems[index];
$child = $childTag.clone();
id = targetBlockId + ucFirst(item[0]) + ucFirst(item[1]) + ucFirst(item[2]);
$child.attr({id: id})
.attr({'data-module': item[0]})
.attr({'data-controller': item[1]})
.attr({'data-action': item[2]})
.addClass('clickable')
.text(item[3]);
if (typeof options.mark !== 'undefined' && options.mark === 'goto') {
$child.append(
'<img src="../../common/img/transparent1x1.gif" class="goto" alt="goto mark">'
);
}
$menu.append($child);
}
return $menu;
},
/**
* オブジェクトを URL クエリ文字列に変換
*
* @param {object} query
* @returns {string}
*/
makeQueryString = function (query) {
let keyValueStrings = [],
queryString = '',
keyName;
for (keyName in query) {
if (keyName === '') {
continue;
}
keyValueStrings.push(keyName + '=' + query[keyName]);
}
if (keyValueStrings.length === 0) {
return queryString;
}
queryString = '?' + keyValueStrings.join('&');
return queryString;
},
/**
* 単語の最初の文字を大文字にする
*
* @param {string} text
* @returns {string}
*/
ucFirst = function (text) {
if (typeof text === 'undefined' || text === '') {
return '';
}
return text[0].toUpperCase() + text.slice(1);
},
/**
* 単語の最初の文字を小文字にする
*
* @param {string} text
* @returns {string}
*/
lcFirst = function (text) {
return text[0].toLowerCase() + text.slice(1);
},
/**
* HTML タグ文字列から tagName 相当の文字列を取得
*
* @param {object|string} tag
* @returns {*|string}
*/
loadTagName = function (tag) {
const text = (typeof tag !== 'object') ? tag : tag.html('').prop('outerHTML');
return loadTagNameFromHtml(text);
},
/**
* HTML タグ文字列から tagName 相当の文字列を取得
*
* @param {string} text
* @returns {*|string}
*/
loadTagNameFromHtml = function (text) {
const match = text.match(/<(\S+)\s*.*>/i);
return (typeof match[1] !== 'undefined') ? match[1].toLowerCase() : '';
},
/**
* キャメルケースを単語に切り分ける
*
* @param text
* @returns {string[]}
*/
divideUpperCase = function (text) {
const splitter = ' ',
response = text.replace(/[A-Z][a-z]/g, function (match) {
return splitter + match;
}).replace(/[A-Z]+$/g, function (match) {
return splitter + match;
}).trim()
.toLowerCase()
.split(splitter);
return response;
},
/**
* 数値を3桁区切りにする
*/
numberFormat = function (numberString) {
return parseInt(numberString).toLocaleString();
},
/**
* 数値を3桁区切りにして円マークをつける
*/
toYen = function (numberString) {
return '¥' + parseInt(numberString).toLocaleString();
},
/**
* テーブルに付与されたソートハンドル(▲▼)を削除
*/
deleteSortHandle = function (string) {
return string.replace('▲▼', '');
},
/**
* 指定されたエリア内のフォーム要素の値を取得
*/
loadValue = function($area) {
let $formElem = $area.find('input'),
formElemType = $formElem.attr('type'),
formElemValue = $formElem.val();
if ($formElem.length === 0) {
$formElem = $area.find('textarea');
formElemType = 'text';
formElemValue = $formElem.val();
}
if ($formElem.length === 0) {
$formElem = $area.find('select');
formElemType = 'select';
formElemValue = $formElem.val();
}
switch (formElemType) {
case 'checkbox' :
return $formElem.prop('checked') ? formElemValue : '';
default :
return formElemValue;
}
},
/**
* JS ファイルのパスを戻す
*/
selfpath = function () {
if (document.currentScript) {
return document.currentScript.src;
}
const scripts = document.getElementsByTagName('script'),
script = scripts[scripts.length - 1];
if (script.src) {
return script.src;
}
return '';
};
/**
* logging at loaded
*/
console.log(`functions loaded @version d.4
`.replace('\n', ''));
300 行近くあって長すぎるので後半の文字列処理的なものは別ファイル化したほうがいいかもしれません。
機能の概略
そもそもなぜこのモックを作り始めたかといえば、再利用できるプレゼン用WEBページを作ろう 第1回 で書いたように、「全ページ共通の部分に修正指示が入っても簡単に対応できる」ことを目的としています。
ページ共通といえば、
- パンくずも含めたメニューやヘッダ・フッタの記述内容
-
- も含めた全体のレイアウト
です。この実現のために考えたのが下記のような手順です。
- 各ページのパスやリンクアンカーとなる文言のパーツを一か所に定義しておく = Definitions.js
- パーツをもとに実際に呼び出しやすい形に変形しておく = Global.js
- 上記 2 をもとに、実際にメニューの UL, LI タグなどを組み上げる = Menu.js, Breadcrumb.js 等
- 上記 2 をもとに、レイアウトやトンマナなどの CSS を読み込む = Layout.js, Switcher.js 等
今回紹介している Definitions.js と Global.js はいずれも
- 定数名・変数名を定義(値の定義も同時に行う場合あり)
- 各定数・変数に値をセットする
- 外部からアクセスできるようにする
をやっているだけなので、特に解説は不要かと思います。
functions.js に含まれるものも解説不要なものが多いと思いますが、ちょっと長めの関数については、Menu.js, Layout.js などから実際に呼び出される場面で必要に応じて解説します。
最後に、本体の機能に関係ない部分の解説
console.log(`functions loaded @version d.4
`.replace('\n', ''));
何をしているかというと、ページが想定動作をしないときに生じる「想定した JS が読み込まれていないのでは?」という疑問を払拭(または確定)するために、各 JS に自身が読み込まれていることを version つきでコンソール出力させるための記述です。
このバージョン番号( @version )部を一括置換するために /bin/version.sh というスクリプトを用意してあります。内容は下記の通り。
if [ $# = 0 ]; then
echo "usage: version.sh [version no] [target directory]"
exit 1
fi
if [ $# = 1 ]; then
find . -type f -name '*.js' -exec sed -i -e 's/@version.*/@version '$1'/g' {} +;
find . -type f -name '*.css' -exec sed -i -e 's/@version.*/@version '$1'/g' {} +;
find . -type f -name '*.html' -exec sed -i -e 's/@version.*/@version '$1'/g' {} +;
exit 0
fi
if [ $# = 2 ]; then
find $2 -type f -name '*.js' -exec sed -i -e 's/@version.*/@version '$1'/g' {} +;
find $2 -type f -name '*.css' -exec sed -i -e 's/@version.*/@version '$1'/g' {} +;
find $2 -type f -name '*.html' -exec sed -i -e 's/@version.*/@version '$1'/g' {} +;
exit 0
fi
exit 1
これを使って
$ bash bin/version.sh d.4 develop
みたいな感じで対象となるファイル群の「@version」の値を一括で指定しているのですが、ご覧のとおり行末までのすべての文字を置換してしまうので、普通に
console.log('Definitions loaded @version d.4');
と書くと最後の「');」がなくなり、スクリプトとして動かない状態になってしまいます。それでは本末転倒。
なので
- 「@version XXX」の直後で改行したいが
- JavaScript は文字リテラルの途中に改行を含めることができない
- ECMAScript 2015 (ES 6) からバッククォート(`) で文字リテラル(テンプレートリテラル)が表現できる
- テンプレートリテラルは文字列中に改行を含めることができる
- これで解決
というわけです。
関連記事
再利用できるプレゼン用WEBページを作ろう 第1回
再利用できるプレゼン用WEBページを作ろう 第2回
再利用できるプレゼン用WEBページを作ろう 第3回