vue腳手架多頁自動化生成實踐

維李設論發表於2023-02-28

前言

在前端開發過程中,常常面對多種業務場景。到目前為止,前端對於不同場景的處理通常會採用不同的渲染方案來組合處理,常見的渲染方案包括: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領域的體驗與開發建設。技術方案的選擇不只侷限於生態的整合,更重要的是對合適場景的合理應用。

“形而上者謂之道,形而下者謂之器”,各位前端開發者不僅應該只著眼於眼前的業務實現,同時也需要展望未來,站在更高的視野上來俯視技術的走向與演進,共勉!!!

參考

相關文章