Edited at

Vueでキャンペーンサイトを作った時の知見

More than 1 year has passed since last update.

今回は、manabiyaと言うサイトをVue+Typescriptで作った際の知見を紹介します。


ディレクトリ構成

./src

├── scripts
│   ├── common
│   │   ├── components
│   │   │   ├── Footer
│   │   │   ├── MailInputForm
│   │   │   ├── NavibarWidget
│   │   │   ├── OwnerModal
│   │   │   └── SessionOwner
│   │   │   └── OwnerList
│   │   └── data
│   ├── contacts
│   ├── contents
│   ├── speaker
│   │   └── components
│   │   ├── KeynoteSpeech
│   │   │   └── OwnerList
│   │   ├── Presenter
│   │   │   └── OwnerList
│   │   └── SpeakerContent
│   └── top
│   └── components
│   ├── ConceptWidget
│   ├── ContentWidget
│   ├── FirstViewWidget
│   ├── MapComponent
│   ├── SessionOwnerWidget
│   ├── SnsWidget
│   └── SponsorWidget
├── scss
├── test
│   ├── e2e
│   │   ├── custom-assertions
│   │   └── specs
│   └── unit
│   └── specs
└── types

基本的に、scriptsの命名規則は、URLと1:1対応させています。これにより、そのページでどのコンポーネントやファイルが読み込まれているのかわかりやすくなるためです。


vue-routerをSingle-Fileフォーマットで設定する。

vue-routerはよくVueコンポーネントの発火のタイミングで設定するサンプルが多いですが、SIngle-FIleでコンポーネント化してラップして使うこともできます。

これによって、複数のVueアプリケーションの共存などをやりやすくしたり、起点となるDOMのテンプレートやVue以外の初期化スクリプトに対して疎結合のまま、Routingを設定することができます。

<template lang="pug">

#app-manabiya
router-view
</template>

<script lang="ts">
import VueRouter from "vue-router"
import Vue, {ComponentOptions} from "vue"
import Speaker from "speaker/index.vue"
import Top from "top/index.vue"

Vue.use(VueRouter);

const routes = [
{ path: '/speaker', component: Speaker as ComponentOptions<Vue>},
{ path: '/', component: Top as ComponentOptions<Vue>}
];
const router = new VueRouter({
routes,
mode: 'history',
scrollBehavior (to, from, savedPosition) {
return { x: 0, y: 0 }
}
});
export default {
router
} as ComponentOptions<Vue>;

</script>


それぞれのページにおけるコンポーネントの設定

urlに対応するdirectoryに起点となるファイルを起きます"/"はtopディレクトリに対応させています。このプロジェクトではtop/index.tsを起点とします。

<template lang="pug">

#manabiya
#top.p-firstView
first-view-widget
nav.p-navibar
navibar-widget(:naviData="naviData")
section#concept.c-sectionBlock.c-sectionBlock--gray
concept-widget
section#contents.c-sectionBlock.c-sectionBlock--black.p-contents
content-widget
section#session_owner.c-sectionBlock.c-sectionBlock--black.p-speaker
session-owner-widget
//section#sns.c-sectionBlock.c-sectionBlock--gray
// sns-widget
section#access.c-sectionBlock.p-access
#map
map-back-ground
section#sponsor.c-sectionBlock.c-sectionBlock--gray.p-sponsor
sponsor-widget
footer-widget
</template>

<script lang="ts">
import MapBackGround from './components/MapComponent/index.vue';
import SponsorWidget from './components/SponsorWidget/index.vue';
import FirstViewWidget from './components/FirstViewWidget/index.vue';
import NavibarWidget from 'common/components/NavibarWidget/index.vue';
import ConceptWidget from './components/ConceptWidget/index.vue';
import ContentWidget from './components/ContentWidget/index.vue';
import SessionOwnerWidget from './components/SessionOwnerWidget/index.vue';
import SnsWidget from './components/SnsWidget/index.vue';
import FooterWidget from 'common/components/Footer/index.vue';
import Vue , {ComponentOptions} from 'vue';
import {naviData} from "./NaviData";

export default {
data(){
return {
naviData
};
},
components:{
MapBackGround,
SponsorWidget,
FirstViewWidget,
NavibarWidget,
ConceptWidget,
ContentWidget,
SessionOwnerWidget,
SnsWidget,
FooterWidget,
}
} as ComponentOptions<Vue>;
</script>
<style></style>

それぞれのsectionタグごとにコンポーネントを呼び出すことで、編集すべきファイルの混乱を防ぎます。Componentは相対パスあるいは、commonのcomponentsで共通化された階層で呼び出すため、共通化できるコンポーネントはcommonに移し、./パスをcommonに変更するだけで呼び込めるように済むようにwebpackを設定してあるため、共通化や分離が容易になります。あとはそれぞれのcomponentを単一ファイルで呼び出したり、ディレクトリ化してネストさせるなど好きなように設定ができます。


Webpackの設定

今回はVue+Typescriptの構成とsassやクロスブラウザ対応をやるため、sass+postcssローダーの設定を行なって居ます。

globalなsassを使いまわしたいのでbuildを二つに分けています。

goのバックエンドアプリケーションとデプロイするため、deployディレクトリにbuildファイルを配置し、deployディレクトリをデプロイするように設定してあります。静的ファイルなどはfile-loaderで配置を行わず最初からdeploy/publicに配置して置くことで、buildの時間を減らすようにして居ます(という言い訳をして居ますが、スピードは対した差にならない+公開するアセットの設定はできた方が便利なのでただの怠慢だったりします)。

var webpack = require("webpack");

var minimist = require('minimist');
var args = minimist(process.argv),
host = args.host || "127.0.0.1",
devServerPort = args.p || 4000;
var environment = process.env.NODE_ENV || 'development';

var CleanWebpackPlugin = require('clean-webpack-plugin');
var ExtractTextPlugin = require("extract-text-webpack-plugin");

var glob = require('glob');

var extractSass = new ExtractTextPlugin({
filename: "[name].css?[contenthash]"
});

var js = {
entry: './src/scripts/main.ts',
output: {
path: __dirname + "/deploy/public/scripts",
filename: 'bundle.js'
},
module: {
rules:[
{
test:/\.scss$/,
exclude: /node_modules/,
use: [
{
loader: "style-loader" // creates style nodes from JS strings
},
{
loader: "css-loader", // translates CSS into CommonJS
options: { minimize: process.env.NODE_ENV === "production" }
},
{
loader: 'postcss-loader',
options: {
plugins: (loader) => [
require('postcss-smart-import'),
require('autoprefixer')({
"browsers": [
"ie >= 11",
"last 2 Edge versions",
"last 2 Firefox versions",
"last 2 Chrome versions",
"last 2 Safari versions",
"last 2 Opera versions",
"last 2 iOS versions",
"last 2 ChromeAndroid versions"
]
}),
]
}
},
{
loader: "resolve-url-loader" // compiles Sass to CSS
},
{
loader: "sass-loader" // compiles Sass to CSS
}
]
},
{
test: /\.(html)$/,
exclude: /node_modules/,
use: {
loader: 'html-loader',
options: {
attrs: [':data-src']
}
}
},
{
loader: 'vue-loader',
exclude: /node_modules/,
test: /\.vue$/,
options:{
loaders: {
js: 'babel-loader!eslint-loader',
scss: 'vue-style-loader!css-loader!sass-loader',
sass: 'vue-style-loader!css-loader!sass-loader?indentedSyntax',
},
postcss:{
plugins: (loader) => [
require('postcss-smart-import'),
require('autoprefixer'),
]
}
}
},
{
loader: 'babel-loader',
exclude: /node_modules/,
test: /\.jsx?$/,
options: {
cacheDirectory: true,
presets: ['es2015']
}
},
{ test: /\.ts$/,
exclude: /node_modules/,
use: { loader: 'ts-loader', options: {
appendTsSuffixTo: [/\.vue$/],
}
}
}
],
},
resolve: {
modules:["node_modules","src/scripts"],
alias: {
'vue$': 'vue/dist/vue.esm.js',
'jquery': 'jquery/dist/jquery.js'
},
extensions: ['.js','.sass','.scss','.ts', '.tsx', '.vue', '.vuex']
},
plugins: [
new webpack.ProvidePlugin({
$: "jquery",
jQuery: "jquery",
"window.jQuery": "jquery",
}),
new CleanWebpackPlugin([
'public/scripts'
])
],
externals:{
google:true
}
};

var style = {
entry: {index:'./src/scss/index.scss'},
output: {
path: __dirname + "/deploy/public/css",
filename: '[name].css'
},
module: {
rules:[{
test: /\.scss$/,
use: extractSass.extract({
use: [
{
loader: "css-loader", // translates CSS into CommonJS
options: { minimize: process.env.NODE_ENV === "production" }
},
{
loader: 'postcss-loader',
options: {
ident: 'postcss',
plugins: () => [ require('postcss-smart-import'),require('autoprefixer')({
"browsers": [
"ie >= 11",
"last 2 Edge versions",
"last 2 Firefox versions",
"last 2 Chrome versions",
"last 2 Safari versions",
"last 2 Opera versions",
"last 2 iOS versions",
"last 2 ChromeAndroid versions"
]
})]
}
},
"resolve-url-loader",
"sass-loader"
],
// use style-loader in development
fallback: "style-loader"
})
}]
},
resolve: {
extensions: ['.scss','.css','.js']
},
plugins: [extractSass,new CleanWebpackPlugin([
'public/css',
])]
};

if(environment !== 'production') {
js.devServer = {
historyApiFallback: true,
host: host,
contentBase:'deploy/public',
port: devServerPort,
hot: true,
headers: { 'Access-Control-Allow-Origin': '*' }
};
console.log(environment);
js.devtool = 'cheap-eval-source-map';
js.output.sourceMapFilename = "[file].map";
js.plugins.push(
new webpack.HotModuleReplacementPlugin()
);
} else {
js.plugins.push(
new webpack.optimize.UglifyJsPlugin(),
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: '"production"'
}
})
);
}

module.exports = [
js,
style
];

vueでtypescriptを扱う方法は色々試したのですが、ts-loaderで以下の設定をするのが

一番楽だという結論に落ち着きました。

{ 

test: /\.ts$/,
exclude: /node_modules/,
use: {
loader: 'ts-loader',
options: {
appendTsSuffixTo: [/\.vue$/],
}
}
}

今のところこの構成で困っていることはありません。Typescript+Vueの構成は一度作ってしまうと本当に快適にコーディングできるのでおすすめです。

皆さんもぜひ挑戦してみてください。