乐趣区

关于vue.js: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-plugin 和 vue-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 畛域的体验与开发建设。技术计划的抉择不只局限于生态的整合,更重要的是对适合场景的正当利用。

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

参考

  • vue-cli 搭建自动化多页面我的项目(vue 高阶)
  • Vue-cli 配置多页面
  • vue 预渲染之 prerender-spa-plugin 插件利用
  • 预渲染插件 prerender-spa-plugin 生成多页面
  • CSR、SSR、NSR、ESR 傻傻分不清楚,一文帮你理清前端渲染计划!
  • vue 我的项目革新 SSR(服务端渲染)
  • 什么是 SSR/SSG/ISR?如何在 AWS 上托管它们?
  • 卷起来,前端建站 SSG,SSR,ISR,Hydration, Island…一网打尽
  • 你晓得吗?SSR、SSG、ISR、DPR 有什么区别?
  • SSR、ISR、CSR、SSG 有什么区别
  • 一文看懂 Next.js 渲染办法:CSR、SSR、SSG 和 ISR
退出移动版