基于Nuxt.js构建同构应用

基于Nuxt.js构建同构应用

本文涉及技术栈:ES6(包括Promise、async/await等)、Vue(包含vue-router、vuex)、类express Node.js Web框架

SPA应用遇到的瓶颈

前后端分离的开发模式衍生出了SPA应用(Single Page Application)并得以迅速发展。 

由于SPA的一些限制,使其主要应用于管理后台及一些重操作应用。 

  • SEO - 渲染完全由JS完成,页面无内容HTML 
  • 首屏加载速度 - 页面请求->JS资源请求->(数据请求)->执行渲染 (延迟了用户可见界面的时间) 
  • 旧版浏览器兼容 - 流行框架Vue的响应式实现(Object.defineProperty get set)从根本上不可能支持一些低版本浏览器,如IE8及以下

以上一些限制,使得偏内容类网站难以采用SPA应用模式。 

 SPA的服务端解决方案——SSR(Server Side Render 服务端渲染)

SSR即服务端渲染,页面请求后返回的是HTML内容(即传统的页面内容),使得浏览器能够立即渲染页面。

页面被浏览器解析时,同时也在加载应用JS脚本,当JS加载完成并执行,根据该应用SPA库的SSR框架实现,SPA库将接管应用后续的页面切换及用户操作,就是说应用重新变成了SPA应用,直到页面被刷新或新标签页打开。

这种首屏服务端渲染,后续SPA接管的应用模式,不仅解决的首屏加载慢和SEO的问题,甚至部分兼容问题(纯内容类网站),还保留了SPA应用优秀的用户体验。

类似基于React的SSR Next.js,Vue的SSR解决方案Nuxt.js于同日诞生。

Nuxt.js简介

自2016年10月底诞生至今,Nuxt经过两年多的发展,已成为基于Vue.js的通用应用框架。 

目前最新版本2.3.x,近期更新较频繁,刚完成框架重构(2.3版本开始Nuxt被分割成各个功能独立的依赖包,归属在@nuxt namespace下,mono-repo模式)。 

Nuxt.js集成了Vue2(核心 UI渲染)、Vue-router(路由)、Vuex(应用状态)、Vue-meta(head管理),并采用Webpack、vue-loader、babel-loader等来处理代码自动化构建。使Nuxt应用开发同一般Vue应用开发几乎一模一样。





Nuxt提供了两种应用模式: 

  • SPA:单页面应用模式,即一般SPA应用,只是为其赋予了Nuxt特性(参照Nuxt特性) 
  • universal:通用应用模式,即SSR模式。

Nuxt处理页面请求到渲染及路由导航流程图:


重要提示:Vue客户端渲染是直接通过构建dom渲染界面,而服务端是通过拼装HTML结构交给浏览器来渲染界面。因而执行环境的不同,Vue生命周期钩子的调用也不一致,在服务端渲染过程中只会调用beforeCreate和created。建议一些客户端和服务端都需要被执行的方法放在created生命周期钩子而客户端独有的则在mouted去调用。 除了生命周期钩子,还有一些两端均会被执行到的函数(如asyncData),我们需要考虑前后端执行环境的差异,如前端对DOM、Window等对象的操作,在服务端运行必然报错,而服务端对http.IncommingMessage/http.ServerResponse实例的调用,在客户端运行也会报错。 

区别服务端与客户端的执行代码,是同构应用的必须要注意的。

应用模板脚手架

要求:

npm 5.2以上 

安装: 

npx create-nuxt-app <program name> # 根据cli提示选择项目配置 
npm run dev # 以开发模式启动项目 
npm run build && npm start # 构建应用并启动服务

番外小知识:

npx是npm5.2版本以上内置的小工具,用以临时安装依赖包并执行其提供的命令,而无需全局安装该依赖包并为之后的版本更新困扰。

项目目录

|- .nuxt/  # 产出代码 <dist>
|- assets/  # 资源<也可包含未经编译的less/scss/js/ts等>
|- components/  # 普通组件
|- layouts/  # 布局组件
|- middleware/  # 中间件 <用于页面渲染之前,浏览器端与服务端均会执行>
|- pages/  # 页面组件
|- plugins/  # vue应用插件 <引入第三方插件或Vue插件, 运行于Vue实例化之前>
|- static/  # 静态服务目录 <由node static-serve中间件提供,可直接访问,服务启动时该目录文件将被映射到域名根目录下>
|- store/  # vuex 状态管理
|- nuxt.config.js  # nuxt 全局配置文件

视图

Nuxt应用明确了layout、page、普通组件的概念,存放在不用的目录中(layouts、pages、components),并为layout和page提供了一些特性。


布局组件 layout

Nuxt提供了默认的布局组件 'default':

<template>
	<nuxt/>
</template>

可以通过在layouts目录下创建Vue文件来创建以文件名为名称的布局组件。 

注意<nuxt />是布局中必须包含的组件,用以显示页面主体内容。 

布局组件中可以设置middleware和head。 

通过在页面组件中设置layout字段值即可配置页面所应用的组件,如页面不指定layout则使用默认layout。 

更改默认layout可以通过在layouts目录下增加或改写default.vue文件来实现

错误页面

错误页面是一个特殊的页面,当页面或服务端发生错误或404时Nuxt将渲染该页面。

默认的错误页面是由Nuxt提供的一个NuxtError组件,也可以通过在layouts目录下创建error.vue文件来替代默认错误页面。

页面组件 

Nuxt为页面组件扩展了许多Options,提供了丰富的功能特性:

asyncData

​主要为异步数据获取提供支持的函数,接收nuxt context对象作为参数,最终返回值应当是一个对象,与页面组件data返回的数据合并({...data,...asyncData}); 

当该函数返回Promise对象时,将等待promise被解析后才会继续实例化组以resolve结果作为返回值,也可以使用async/await语法(新版本不再支持callback作为第二个参数处理异步回调)。 

该函数在浏览器端和服务端都会执行,因此需要注意通过nuxt提供的process.server和process.client来区分服务端与客户端代码,否则可能会由于执行环境不同而会发生错误;

// 异步数据获取示例
export default {
    validate ({ params }) {
        return /^\d+$/.test(params.id)
    },
    async asyncData ({ app, params, error }) {
        try{
            let { data } = await app.$api(`https://my-api/posts/${params.id}`)
            return { title: data.title }
        } catch (err) {
            error(err);
        }
    }
}
// 区分服务端与客户端示例
export default {
    asyncData ({ req, res, store}) {
        if (process.server) {
            // 服务端 从session中获取用户信息
            return { user: req.session.user }
        }
        // 客户端 从store中获取用户信息
        return { user: store.state.user }
    }
}

fetch

与asyncData类似,支持异步处理,主要用于渲染页面之前将获取的异步数据填充到store。该方法不会设置组件数据,也不需要返回值。

head

配置当前页面的 Meta 标签, 详情参考 页面头部配置API。

layout

指定当前页面使用的布局

loading

指定是否手动调用this.$nuxt.$loading即NuxtLoading组件。默认false

transition

指定页面切换的过渡效果,transition name 或 transition配置对象

scrollToTop

在嵌套路由的页面中应用,用以在路由切换后决定是否回到顶部。默认false

validate

接收nuxt context作为参数,主要用于动态路由的参数校验。页面组件实例化之前执行。

middleware

接收nuxt context作为参数,路由中间件,页面组件实例化之前执行。

watchQuery

路由query改变时,由于复用组件并不会更新数据重新渲染,可以通过设置该为true监听所有query,或者字符串数组指定需要监听的query来决定是否需要更新数据来重新渲染界面。 

注意:以上提供的一些方法都在页面组件实例化之前(每次服务端渲染或路由更新)被调用,因此无法在函数中通过this访问到该页面组件实例(head除外),通常使用context对象提供的属性/方法。这些方法都支持异步处理,只需返回promise或声明为async函数。

context对象

context对象是Nuxt中非常重要的一个常用对象,它为客户端与服务端提供了一些方便的属性和方法。

共有属性/方法: 

  • app - Vue根配置对象,采用inject注入的插件可以通过该对象获得 
  • base - router.base 
  • route - vue-router 当前路由 
  • store - Vuex.Store实例 
  • env - nuxt.config.js中配置的环境变量 
  • params - route.params 
  • query - route.query 
  • error - 展示错误页面 eg. error({statusCode: 502, message: '错误信息'}) 
  • redirect - 重定向到其他路由 eg. redirect() 

服务端特有属性/方法: 

  • req - connect中间件 req参数 
  • res - connext中间件 res参数

路由

nuxt通过定义pages目录结构及文件命名规则来自动生成vue-router的routes配置,并提供了<nuxt-link>组件(基于<router-link>,使用完全一致)以便页面之间跳转。

基础路由

目录结构:

|- pages/
    |- index.vue
    |- user/
        |- index.vue
        |- one.vue

生成路由:

router: {
    routes: [
        {
            name: 'index',
            path: '/',
            component: 'pages/index.vue'
        },
        {
            name: 'user',
            path: '/user',
            component: 'pages/user/index.vue'
        },
        {
            name: 'user-one',
            path: '/user/one',
            component: 'pages/user/one.vue'
        }
    ]
}

动态路由

定义带参数的动态路由,需要创建对应的以下划线作为前缀的 Vue 文件 或 目录; 

路由参数可以通过页面组件中定义的validate函数进行校验; 

动态路由同目录下若无index.vue,则意味该动态路由参数是可选的,否则相反;

|- pages/
    |- index.vue
    |- _slug/
        |- comments.vue
        |- index.vue
    |- users/
        |- _id.vue

生成路由:

router: {
    routes: [
        {
            name: 'index',
            path: '/',
	    component: 'pages/index.vue'
        },
        {
            name: 'users-id',
            path: '/users/:id?',
            component: 'pages/users/_id.vue'
        },
        {
            name: 'slug',
            path: '/:slug',
            component: 'pages/_slug/index.vue'
        },
        {
            name: 'slug-comments',
            path: '/:slug/comments',
            component: 'pages/_slug/comments.vue'
        }
    ]
}

嵌套路由

创建内嵌子路由,需要添加一个 Vue 文件,同时添加一个与该文件同名的目录用来存放子视图组件,注意该Vue 文件中需要包含<nuxt-child/>组件用于显示子组件内容(类似于<router-view>); 

目录结构:

|- pages/
    |- users/
        |- _id.vue
        |- index.vue
    |- users.vue

生成路由:

router: {
    routes: [
        {
            path: '/users',
            component: 'pages/users.vue',
            children: [
                {
                    path: '',
                    component: 'pages/users/index.vue',
                    name: 'users'
                },
                {
                    path: ':id',
                    component: 'pages/users/_id.vue',
                    name: 'users-id'
                }
            ]
        }
    ]
}

页面过渡效果

通过为nuxt.config.js中transition字段全局配置或在页面组件的transition字段设置transition name值即可在页面切换时产生设置的过渡效果

中间件

middleware目录下放置的文件将被配置作为路由渲染之前执行的中间件,如权限鉴别; 

文件名即为中间件名称; 

设置中间件的三种方式: 

  • 通过nuxt.config.js中router配置middleware值对所有路由都执行指定中间件; 
  • 在布局组件中设置middleware字段值指定应用该layout的路由界面执行指定中间件; 
  • 在页面组件中设置middleware字段值指定应用该页面路由执行指定中间件; 

中间件的执行: 

中间件是一个接收nuxt的context作为参数函数,并且在浏览器端和服务端都会执行,因此需要注意通过nuxt提供的process.server和process.client来区分服务端与客户端代码,否则可能会由于执行环境不同而会发生错误;

状态管理

Nuxt状态管理基于Vuex,它根据store目录下的配置决定如何生成Vuex.Store实例,若无配置文件则不启用vuex。 

可以通过在store目录下创建index.js文件来配置vuex,也可以将state/getters/actions/mutations从index文件中单独分离出来,store目录下的子目录将被作为vuex子模块对待。当index.js文件导出为函数时,需要手动创建并返回Vuex.Store实例。 

需要注意state必须作为函数并返回对象,这是为了防止在服务端不同请求之间共用状态。 

Nuxt为服务端运行提供了nuxtServerInit action,用以将服务端数据直接传入客户端store,如用户信息。 

当store actions中设置了nuxtServerInit时,服务端会触发nuxtServerInit,并传入context对象作为payload。

// nuxtServerInit 示例
export default {
    state : () => ({
        user: null
    }),
    mutations: {
        updateUser(state, user){
            state.user = user
        }
    },
    actions: {
        nuxtServerInit ({ commit }, { req }) {
            if (req.session.user) {
                commit('updateUser', req.session.user)
            }
        }
    }
}

插件

Nuxt提供了插件机制方便扩展应用。 所有插件应放置在plugins目录下以方便管理。

编写插件

由于插件是执行在Vue实例化之前,因此可以为Vue提供扩展插件或UI库,也可以全局绑定一些如lodash的工具库。

// 直接执行插件示例
import Vue from 'vue'
import VueNotifications from 'vue-notifications'
Vue.use(VueNotifications)

插件也可以返回一个函数,并使用nuxt提供的扩展方法。 

返回函数接收两个参数:nuxt context对象和inject 扩展方法。inject方法需要传入(key,value)参数,传入的value将以$key键被添加到app(可通过context.app访问)、store和Vue.prototype。(函数式插件也支持异步处理)

// 函数式插件示例:
import lodash from 'lodash'
import api from '~/utils/api'
export default (context, inject) => {
    inject('_', lodash);
    inject('api', api);
}

// 使用
export default {
    async asyncData({ app, error }){
        try{
            let { list=[] } = await app.$api('/list');
            return { list }
        } catch (err) { error(err) }
    },
    computed: {
        shuffleList () {
            return this.$_.shuffle(this.list);
        }
    }
}

配置插件

想要在应用程序中使用插件,必须要在nuxt.config.js文件返回配置对象的plugins字段指明,plugins应当是一个数组,数组项为每一个应用的插件,数组项值为字符串时指该插件的文件位置,且默认为客户端与服务端都应用该插件,如果只想该插件运行在客户端,需要指定ssr为false。

// 配置示例如下:
export default {
    // ...
    plugins: [
        '~~plugins/lodash',
        { src: '~~plugins/vue-notifications', ssr: false }
    ]
}

配置

配置优先级:

一些配置项可以在通过nuxt.config.js来全局配置,或者在layout中配置,也可以在页面中配置,如middleware,head等。 

其优先级随着适用的范围越大而越低。即page > layout > nuxt.config.js 

提示:当使用Nuxt提供的CLI运行应用时,CLI参数的优先级会高于配置文件(nuxt.config.js)

配置值类型:

Nuxt为配置项提供了非常灵活的值,同一个配置项可以为字符串(单个值),可以为字符串数组(多个值),也可以为对象数组(更详细的配置),甚至也可以为函数(返回配置值),如head、plugins等等。具体详情可参照源码进行研究。

配置示例:

export default {
    // nuxt应用的模式:universal | spa
    mode: 'universal',
    // 服务配置:host、port、https。(host指定0.0.0.0以允许外部地址访问)
    server: {
        https: false,
        host: '0.0.0.0',
        port: 12345
    },
    // 页面head的全局配置,参考vue-meta
    head: {
        title: 'site name',
        meta: [
            {charset: 'utf-8'},
            {name: 'renderer', content: 'webkit'},
            {name: 'viewport', content: 'width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=0'},
        ],
        link: [
            {rel: 'icon', type: 'image/x-icon', href: '/favicon.ico'}
        ],
        script: []
    },
    // 配置全局样式文件
    css: [
        {src: '~assets/style.less', lang: 'less'}
    ],
    // 页面路由切换等待数据时的进度条相关配置
    loading: {
        color: 'green',
        failedColor: 'red',
        height: '4px',
        duration: 5000,
        continuous: false,
        rtl: false,
        throttle: 100,
        css: true
    },
    // 页面切换过渡transition
    transition: {
        name: 'page',
        mode: 'out-in',
        appear: true,
        appearClass: 'appear',
        appearActiveClass: 'appear-active',
        appearToClass: 'appear-to'
    },
    // 布局切换过渡transition
    layoutTransition: {
        name: 'layout',
        mode: 'out-in'
    },
    // 路由基本配置
    router: {
        base: '/',
        mode: 'history',
        linkActiveClass: 'active',
        linkExactActiveClass: 'active',
        extendRoutes: null,
        parseQuery: false,
        stringifyQuery: false,
        routes: [],
        middleware: [],
        fallback: false
    },
    // 指定vue实例化前需要执行的插件,可通过ssr的布尔值指定是否在服务端运行该插件,不设置即两端均运行
    plugins: [
        {src: '~plugins/configs'},
        {src: '~plugins/axios'},
        {src: '~plugins/jquery', ssr: false}
    ],
    // 用户定义Nuxt模块
    modules: [],
    // 代码构建相关配置,包含webpack的一些配置
    build: {
        analyze: false, // 是否展示可视化构建后的打包文件
        publicPath: '/static/',
        extractCSS: process.env.NODE_ENV === 'production',
        splitChunks: {
            layouts: false,
            pages: true // 页面将被作为单独的chunk打包并按需异步加载
        },
        // webpack build扩展
        extend(config, ctx) {
            if (ctx.isDev && ctx.isClient) {
                config.module.rules.push({
                    enforce: 'pre',
                    test: /\.(js|vue)$/,
                    loader: 'eslint-loader',
                    exclude: /(node_modules)/
                });
            }
            config.resolve.extensions = config.resolve.extensions.concat(['less', 'css']);
            config.module.rules.push({
                test: /\.html$/,
                use: [
                    {
                        loader: 'html-loader',
                        options: {
                            minimize: true
                        }
                    }
                ]
            });
        }
    },
    // Vue全局配置
    vue: {
        config: {
            silent: undefined, // = !dev
            performance: undefined // = dev
        }
    },
    // 服务端node中间件,可以在该配置指定api服务中间件
    serverMiddleware: ['~~server/index'],
    // 服务端渲染配置
    render: {
        bundleRenderer: {
            shouldPrefetch: () => false
        },
        resourceHints: true,
        http2: {
            push: false,
            shouldPush: null
        },
        static: {
            prefix: true
        },
        etag: {
            weak: false
        },
        csp: false,
        dist: {
            index: false,
            maxAge: '1y'
        }
    },
    // 增加自定义目录或文件变化监听,以便开发中重编译重启动服务
    watch: ['~~server'],
    // Nuxt使用的webpack及chokidar监听库的配置
    watchers: {
        webpack: {
            ignored: /-dll/
        },
        chokidar: {}
    },
    // nuxt应用各个阶段(模块配置、构建、服务启动、路由渲染等)的hook函数
    hooks: {
        ready(nuxt) {},
        listen(server, {port, host, path}) {
            let {env, pid, ppid, platform, arch, versions, argv} = process;
            server.setTimeout(30000);
            console.log(`> ===================================================`);
            console.log(`> Deploy Hooks服务已启动`);
            console.log(`> PORT: ${port}`);
            console.log(`> NODE_ENV: ${env.NODE_ENV}`);
            console.log(`> PID: ${pid}`);
            console.log(`> PPID: ${ppid}`);
            console.log(`> Platform: ${platform} ${arch}`);
            if(env.npm_config_user_agent){
                console.log(`> UA: V${env.npm_config_user_agent}`);
            }else{
                console.log(`> NODE: V${versions.node} ${argv[0]}`);
            }
            console.log(`> Current Time: ${new Date().toLocaleString()}`);
            console.log(`> ===================================================`);
        },
        error(err) {},
        close(nuxt) {},
        modules: {
            before() {},
            done() {}
        },
        render: {
            before(renderer, renderOpts) {},
            setupMiddleware(app) {},
            errorMiddleware(app) {},
            resourcesLoaded(resources) {},
            done(renderer) {},
            route(url, result, context) {},
            routeDone(url, result, context) {},
            routeContext(contextNuxt) {}
        },
        build: {
            before(builder, buildOpts) {},
            extendRoutes(routes, r) {},
            templates({templatesFiles, templateVars, r}) {},
            compile({name, compiler}) {},
            compiled({name, compiler, stats}) {},
            done(builder) {}
        },
        watch: {
            fileChanged(builder, filename) {}
        }
    },
    // 错误异常消息提示配置
    messages: {
        loading: '加载中...',
        error_404: '您访问的页面不存在或访问地址已变更~',
        back_to_home: '回到首页',
        server_error: 'Server Error',
        server_error_details: '哎呀,系统开小差了~~~',
        client_error: 'Error',
        client_error_details: '页面发生错误,请在控制台中查看错误信息.'
    }
};

模块

有时候项目的特别配置项比较多,显得十分臃肿,这时我们可以通过Nuxt提供的模块进行更灵活的配置。 

模块在Nuxt中被导出为Module类,我们可以通过Nuxt实例的moduleContainer访问到,也可以在配置文件modules数组配置自己的模块,如下:

export default {
    // ...
    modules: [
        '@nuxtjs/axios',
        '~~modules/hooks',
        function () { }
    ]
}

Nuxt初始化时,在ready阶段会先按顺序加载并执行所有已配置的modules,模块函数中可以通过this访问到Module实例从而访问Nuxt实例,因而可以轻松的扩展和配置Nuxt。

Module实例属性

  • nuxt - 当前Nuxt实例对象 
  • options - 已完成序列化的配置项 
  • requiredModules - 当前已加载并执行的模块

Module实例方法 

  • addTemplate - 添加应用构建模板<options.build.templates>(Nuxt内部使用lodash的template方法用以创建预编译模板) 
  • addPlugin - 添加插件<options.plugins> 
  • addLayout - 添加布局组件<options.layouts> 
  • addErrorLayout - 设置错误页面组件<options.ErrorPage> 
  • addServerMiddleware - 设置服务端中间件<options.serverMiddleware> 
  • extendBuild - 构建扩展<options.build.extend> 
  • extendRoutes - 路由扩展<options.router.extendRoutes> 
  • addModule - 添加并执行模块

服务端中间件

Nuxt的server层是基于connect 的node web服务,通过中间件以管道形式层层处理来自客户端的请求 (http.IncommingMessage 对象)并最终将处理结果返回给客户端。

Nuxt内置中间件

以下按中间件注册顺序列出主要Nuxt内置中间件:

  • 压缩中间件 - 【production】处理gzip压缩,默认使用compression,可通过配置文件render.compressor配置或自定义压缩中间件 
  • webpack热加载/开发中间件 - 【development】webpack-dev-middleware/webpack-hot-middleware 通过build.devMiddleware/build.hotMiddleware配置 
  • static静态服务中间件 - 使用serve-static中间件为static目录提供静态文件服务,默认通过根目录(/)访问,可通过render.static配置,.prefix设置路由前缀 
  • dist静态服务中间件 - 【production】为客户端构建的资源(/.nuxt/dist/client)提供静态服务,配置文件build.publicPath作为路由前缀,通过render.dist对静态服务中间件进行配置 
  • 用户中间件 - 通过配置文件serverMiddleware添加的中间件 
  • 静态服务fallback中间件 - 当配置文件render.fallback配置了dist/static时会为配置了fallback的dist/static静态服务注册fallback着陆页(即404页面)
  • Nuxt中间件 - Nuxt SSR核心,匹配并渲染当前路由页面组件,内部实现了etag/http2 push/csp等优化 
  • 错误中间件 - 捕获到程序错误或手动调用error方法时会直接next到错误中间件。Nuxt对dev时的错误提示使用了youch插件,以方便开发过程的调试。

通过Nuxt提供的用户中间件配置为入口,我们可以注册自己的中间件,如API服务,而不必另外部署API服务。 

用户中间件可以通过配置文件Nuxt.config.js中serverMiddleware进行配置,也可以通过模块方法addServerMiddleware添加。

export default {
    // ...
    serverMiddleware: [
        '~~server/api',
        { path: '/api2', handler: '~~server/api2' }
    ]
}

CLI 

# 开发模式启动Web服务(热加载)   
nuxt / nuxt dev
# 利用webpack编译压缩源代码,以供发布 
nuxt build 
# 生产模式启动Web服务器(启动服务前确保已build) 
nuxt start 
# 依据路由生成静态页面(适用于无状态应用,动态路由需另外配置)
nuxt generate

Node进程管理

 PM2是目前流行的Node进程管理工具,社区版具有以下优势: 

  • 持久化 - 启动应用后,一旦程序报错崩溃将会自动重启 
  • 多应用 - 所有node应用均可被添加到守护进程,在后台持续运行 
  • 日志 - 所有std输出会被添加到日志文件 
  • 负载均衡 - 可创建多个共享相同服务端口子进程来分摊请求 (适用于无状态应用) 

PM2也面向企业的提供了功能更强大更精细的高级收费版本。IBM、微软、PayPal等指明公司都在使用。

相关链接

Nuxt官方网站

Nuxt Github

Express官方文档

PM2官方文档

最后编辑于 2018-12-11 19:44