# vue脚手架多页自动化生成实践

# 前言

在前端开发过程中,常常面对多种业务场景。到目前为止,前端对于不同场景的处理通常会采用不同的渲染方案来组合处理,常见的渲染方案包括:CSR(Client Side Rendering)、SSR(Server Side Rendering)、SSG(Static Site Generation)、ISR(Incremental Site Rendering)、DPR(Distributed Persistent Rendering)、NSR(Native Side Rendering)以及ESR(Edge Side Rendering)等。在目前项目开发过程中,遇到了需要构建门户类应用的需求,而团队主要技术栈以Vue为主,整个技术方案以Vue全家桶进行构建。因此,本文旨在针对门户类应用的场景下的Vue脚手架构建方案的一些总结和分析,通过自动化的配置脚本来生成模板化的多页应用实践,以期能够给读者提供一个基于Vue全家桶的门户类工程构建方案。

# 架构

图片

对于门户类型的应用,由于其大部分内容变动内容较少,而对于部分关键页面却会有动态更新的要求,因而在通常会采用多页形式的处理配合部分单页应用中的优势进行处理。因而,在技术选型方面,团队采用了预渲染配合多页的方式实现门户类SEO及首屏加载快的需求。同时,结合单页应用的优势,在多页中的部分关键页面中采用单页中的优点,如:路由切换快、用户体验好等。综上,架构风格采用ISR的增量渲染方案,由于项目背景的特殊性,无法配合常规CDN等部署方案特点,但可以使用云原生相关的中间件实现类似效果,整体部署仍以“云+端”的形式为主。

# 目录

selfService├─portal
├─ build                                // vue cli打包所需的options中内容一些抽离,对其中做了环境区分
|   ├─ demo
|   |    ├─config.json
|   |    ├─configureWebpack.js
|   ├─ dev
|   |    ├─ config.json
|   |    ├─ configureWebpack.js
|   ├─ production
|   |    ├─ config.json
|   |    ├─ configureWebpack.js
|   ├─ chainWebpack.js
|   ├─ configureWebpack.js
|   ├─ devServer.js
|   ├─ pages.js
|   ├─ routes.js
|   ├─ index.js
|   ├─ utils.js
├─ deploy                                 // 不同环境的部署
|   ├─ demo
|   |    ├─ default.conf
|   |    ├─ Dockerfile
|   |    ├─ env.sh
|   ├─ dev
|   |    ├─ default.conf
|   |    ├─ Dockerfile
|   |    ├─ env.sh
|   ├─ production
|   |    ├─ default.conf
|   |    ├─ Dockerfile
|   |    ├─ env.sh
|   ├─ build.sh
├─ public
|   ├─ pageA                              // pageA的html,这里可以存放一些静态资源,非构建状态下的js、css等
|   |    ├─ index.html
|   ├─ pageB                              // pageB的html,这里可以存放一些静态资源,非构建状态下的js、css等
|   |    ├─ index.html
|   ├─ favicon.ico
├─ src
|   ├─ assets                             // 存放小资源,通常为必须,如:logo等,其他静态资源请放入cdn或者public下
|   |    ├─ logo.png
|   ├─ components                         // 公共组件,可抽离多个静态页面的公共组件
|   |    ├─ Header.vue
|   ├─ router
|   |    ├─ pageA                         // pageA的router,使用了history模式
|   |        ├─ index.js
|   |    ├─ pageB                         // pageB的router,使用了history模式
|   |        ├─ index.js
|   ├─ store
|   |    ├─ pageA                         // pageA的Vuex
|   |        ├─ index.js
|   |    ├─ pageB                         // pageB的Vuex
|   |        ├─ index.js
|   ├─ views
|   |    ├─ pageA                         // pageA的页面,写法和之前一个的单页应用一致
|   |        ├─ main.js                   // 注入了mode,挂载到了vue的原型上,使用this可以获取环境变量
|   |        ├─ pageA.vue
|   |    ├─ pageB                         // pageB的页面,写法和之前一个的单页应用一致
|   |        ├─ main.js                   // 注入了mode,挂载到了vue的原型上,使用this可以获取环境变量
|   |        ├─ pageB.vue
├─ scripts                 
├─ babel.config.js                        // 配置es转化语法
├─ vue.config.js                          // vue cli打包相关配置
├─ app.json                               // 存放各个多页应用的public、router、vuex、views入口地址

# 实践

# 配置

图片

Vue脚手架中配置多页主要是使用Webpack中的pages入口配置,这里主要是修改vue.config.js中的pages的设置,代码如下:

const PrerenderSPAPlugin = require('prerender-spa-plugin');
const Renderer = PrerenderSPAPlugin.PuppeteerRenderer;
module.exports = {
    // ...
    pages: {
        page3:{
            entry: "src/views/page3/main.js",
            template: "public/page3/index.html",
            filename: "page3.html",
            title: "page3"
        }
    },
    configureWebpack: config => {
         config.plugins.push(
            new PrerenderSPAPlugin({
                staticDir: path.resolve(__dirname,'../../dist'),
                routes: [
                    '/page3'
                ],
                renderer: new Renderer({
                    less: false,
                    //renderAfterDocumentEvent: 'render-event',
                    //renderAfterTime: 5000,
                    //renderAfterElementExists: 'my-app-element'
                }),
            })
        )
    } 
}

其中,如果配置了pages,@vue/cli-service会先清除原有的entry,如果没有index,则devServer默认入口的根路径'/'仍为index.html;如果有index的key值,则会进行相应的覆盖。在这里,对pages下的key值为对应多页的路径,如:上述代码下的page3,则对应的路径为'/page3.html';pages下的value可以为字符串,也可以为对象,其中:entry为多页的入口(必选项)、template为模板来源、filename为打包后的输出名称以及title会通过html-webpack-plugin的插件对template中的<title><%= htmlWebpackPlugin.options.title %></title>进行替换。

而对于预渲染的应用,这里使用了prerender-spa-pluginvue-meta-info来进行SEO及首屏加载优化,代码如下:

// ...
import MetaInfo from 'vue-meta-info'

Vue.use(MetaInfo)

new Vue({
    router,
    store,
    render: h => h(index),
    mounted () {
        document.dispatchEvent(new Event('custom-render-trigger'))
    }
}).$mount('#page3')

# 脚本

图片

通过上述的配置,基本就可以实现一个 预渲染+多页 的vue脚手架搭建。但是,除了开发环境的配置,对于生产环境、部署等也需要进行一定的设置,这样频繁的操作就会带来一定的功效降低。因而,在前端工程化领域中,通常会进行一定的脚本化或者说脚手架方案的构建。这里,在目前项目中,团队对多页应用的配置进行了自动化的脚本实现。

生成多页的脚本主要通过page.js进行实现,代码如下:

const inquirer  = require('inquirer');
const chalk = require('chalk');
const fs = require('fs');
const path = require('path');
const ora = require('ora');
const { transform, compose } = require('./utils');

const spinner = ora();

const PAGE_REG = /[a-zA-Z_0-9]/ig;
const rootDir = path.resolve(process.cwd(), '.');

// 判断dir目录下是否存在name的文件夹
const isExt = (dir, name)  => fs.existsSync(path.join(dir, name));

const APP_JSON_EJS = `{
    "pages": <%= page_name %> 
}`;

const INDEX_HTML_EJS = `<!DOCTYPE html>
<html lang="">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <link rel="icon" href="../favicon.ico">
    <title><%= htmlWebpackPlugin.options.title %></title>
  </head>
  <body>
    <noscript>
      <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
    </noscript>
    <%= page_name %>
  </body>
</html>
`

const INDEX_VUE_EJS = `<%= page_name %>

<script>
export default {
components: {
},
data() {
  return {};
},
};
</script>

<style lang="less">
</style>`

const MAIN_JS_EJS = `<%= page_name %>`

const INDEX_ROUTER_EJS = `import Vue from 'vue'
import VueRouter from 'vue-router'

Vue.use(VueRouter)

const routes = [
    <%= page_name %>
]

const router = new VueRouter({
  mode: 'history',
  routes
})

export default router`

const INDEX_STORE_EJS = `import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export default new Vuex.Store({
  state: {
  },
  mutations: {
  },
  actions: {
  },
  modules: {
  }
})
`

// inquirer list
const promptList = [
    {
        type: 'input',
        name: 'page_name',
        message: '请输入你想要创建的多页应用名称',
        filter: function (v) {
            return v.match(PAGE_REG).join('')
        }
    }
];

// nginx的default.conf所需添加内容
const addDefaultConf = page_name => {
    return `location /${page_name} {
    root   /usr/share/nginx/html;
    index  ${page_name}.html;
    try_files $uri $uri/ /${page_name}.html;
    gzip_static on;
}`
};

// page_name下的index.html
const addIndexHtml = page_name => {
    return `<div id="${page_name}" data-server-rendered="true"></div>`
};

// page_name下的router
const addRouterIndex = page_name => {
    return `{
    path: '/',
    component: () => import('../../views/${page_name}/index.vue')
},`
};

// page_name下的views index.vue
const addViewsIndex = page_name => {
    return `<template>
    <div>
        ${page_name}
    </div>
</template>`
};

// page_name下的views main.js
const addViewsMain = page_name => {
    return `import Vue from 'vue'
import index from './index.vue'
import router from '../../router/${page_name}/index.js'
import store from '../../store/${page_name}/index.js'
import MetaInfo from 'vue-meta-info'

Vue.use(MetaInfo)

import axios from 'axios'

Vue.prototype.$mode = process.env.VUE_APP_MODE;

Vue.prototype.axios = axios;

Vue.config.productionTip = false

new Vue({
    router,
    store,
    render: h => h(index),
    mounted () {
        document.dispatchEvent(new Event('custom-render-trigger'))
    }
}).$mount('#${page_name}')`
};

// page_name下的pages.js
const addPages = page_name => {
    return JSON.stringify({
        entry: `src/views/${page_name}/main.js`,
        template: `public/${page_name}/index.html`,
        filename: `${page_name}.html`,
        title: `${page_name}`,
    })
}

const updateApp = page_name => {
    // 获取pages的数组
    const pages = require('../app.json')['pages'];
    if(pages.includes(page_name)) return true;
    pages.push(page_name);
    spinner.start()
    fs.writeFile(`${rootDir}/app.json`, transform(/<%= page_name %>/g, JSON.stringify(pages), APP_JSON_EJS), err => {
        spinner.color = 'red';
        spinner.text = 'Loading Update app.json'
        if(err) {
            spinner.fail(chalk.red(`更新app.json失败`))
            return false;
        } else {
            spinner.succeed(chalk.green(`更新app.json成功`))
            return true;
        }
    });
}

// 处理 public 文件夹下的核心逻辑
const processPublic = args => {
    const { page_name } = args;
    if(isExt(`${rootDir}/public`, page_name)) {
        return args;
    } else {
        fs.mkdirSync(`${rootDir}/public/${page_name}`)
    }
    fs.writeFileSync(
        `${rootDir}/public/${page_name}/index.html`, 
        transform(/<%= page_name %>/g, addIndexHtml(page_name), INDEX_HTML_EJS)
    );
    // 处理默认页面的跳转
    const content = require('../app.json')['pages'].map(page => {
        return `<li>
    <a href="/${page}.html">${page}</a>
</li>`
    }).join(`
`);
    const ejs_arr = fs.readFileSync(`${rootDir}/public/index.html`, 'utf-8').split(`<body>`);
    fs.writeFileSync(
        `${rootDir}/public/index.html`, 
        ejs_arr[0] + `<body>
`+`<h1>自服务门户</h1>
<ul>
    ${content}
</ul>` + `
</body>
</html>`
    );
    return args;
};

// 处理 src/views 文件夹下的核心逻辑
const processViews = args => {
    const { page_name } = args;
    if(isExt(`${rootDir}/src/views`, page_name)) {
        return args;
    } else {
        fs.mkdirSync(`${rootDir}/src/views/${page_name}`)
    }
    fs.writeFileSync(
        `${rootDir}/src/views/${page_name}/index.vue`, 
        transform(/<%= page_name %>/g, addViewsIndex(page_name), INDEX_VUE_EJS)
    );
    fs.writeFileSync(
        `${rootDir}/src/views/${page_name}/main.js`, 
        transform(/<%= page_name %>/g, addViewsMain(page_name), MAIN_JS_EJS)
    );
    return args;
};

// 处理 src/router 文件夹下的核心逻辑
const processRouter = args => {
    const { page_name } = args;
    if(isExt(`${rootDir}/src/router`, page_name)) {
        return args;
    } else {
        fs.mkdirSync(`${rootDir}/src/router/${page_name}`)
    }
    fs.writeFileSync(
        `${rootDir}/src/router/${page_name}/index.js`, 
        transform(/<%= page_name %>/g, addRouterIndex(page_name), INDEX_ROUTER_EJS)
    );
    return args;
};

// 处理 src/store 文件夹下的核心逻辑
const processStore = args => {
    const { page_name } = args;
    if(isExt(`${rootDir}/src/store`, page_name)) {
        return args;
    } else {
        fs.mkdirSync(`${rootDir}/src/store/${page_name}`)
    }
    fs.writeFileSync(
        `${rootDir}/src/store/${page_name}/index.js`, 
        INDEX_STORE_EJS
    );
    return args;
};

// 处理 build 文件夹下的核心逻辑
const processBuild = args => {
    const { page_name } = args;
    // 处理 build/page.js
    const pages  = require('../build/pages.js');
    if(Object.keys(pages).includes(page_name)) return args;
    pages[`${page_name}`] = JSON.parse(addPages(page_name));
    const PAGES_JS_EJS =`const pages = ${JSON.stringify(pages)}
module.exports = pages;
    `;
    fs.writeFileSync(
        `${rootDir}/build/pages.js`, 
        PAGES_JS_EJS
    );

    // 处理 build/routes.js
    const routes = require('../build/routes.js');
    if(routes.includes(`/${page_name}`)) return args;
    routes.push(`/${page_name}`);
    const ROUTES_JS_EJS =`const pages = ${JSON.stringify(routes)}
module.exports = pages;
    `;
    fs.writeFileSync(
        `${rootDir}/build/routes.js`, 
        ROUTES_JS_EJS
    );
    return args;
}

// 处理 deploy 文件夹下的核心逻辑
const processDeploy = args => {
    const { page_name } = args;
    const reg = new RegExp(`location /${page_name}`);
     ['demo', 'dev', 'production'].forEach(item => {
        const content = fs.readFileSync(`${rootDir}/deploy/${item}/default.conf`, 'utf-8');
        if(reg.test(content)) return args;
        const ejs_arr = content.split(`location  /api/`)
        fs.writeFileSync(
            `${rootDir}/deploy/${item}/default.conf`, 
            transform(/<%= page_name %>/g, addDefaultConf(page_name), ejs_arr[0] + `<%= page_name %>
location  /api/`+ ejs_arr[1])
        );
    });
    return args;
};

inquirer
    .prompt(promptList)
    .then(answers => {
        const page_name = answers.page_name;
        return updateApp(page_name)
    })
    .then(() => {
        const pages = require('../app.json')['pages'];
        pages.forEach(page => {
            console.log('page', page)
            compose(
                processDeploy,
                processBuild, 
                processStore, 
                processRouter, 
                processViews, 
                processPublic
            )({
                page_name: page
            });
        })
    })
    .catch(err => {
        if(err) {
            console.log(chalk.red(err))
        }
    })

为了更好的实现代码的优雅性,对代码工具进行了抽离,放入到utils.js中,代码如下:

// 将内容替换进ejs占位符
const transform = ($, content, ejs) => ejs.replace($,content);

// 将流程串联
const compose = (...args) => args.reduce((prev,current) => (...values) => prev(current(...values)));

module.exports = {
    transform,
    compose
}

# 总结

仅管到目前为止,单页应用仍是前端开发中的主流方案。但是,随着各大应用的复杂度提升,多种方案的建设也都有了来自业界不同的声音,诸如:多种渲染方案、Island架构等都是为了能更好的提升Web领域的体验与开发建设。技术方案的选择不只局限于生态的整合,更重要的是对合适场景的合理应用。

“形而上者谓之道,形而下者谓之器”,各位前端开发者不仅应该只着眼于眼前的业务实现,同时也需要展望未来,站在更高的视野上来俯视技术的走向与演进,共勉!!!

# 参考