1. 程式人生 > >分享vue專案的服務端渲染學習過程

分享vue專案的服務端渲染學習過程

      最近抽出了點時間,弄了下vue ssr專案,至於ssr的優點就不多提了。學習路線參照了官方例項,有興趣的同學可以去看下。

     我的專案地址,主要使用了ssr+typescript+vuex+vue-cli 2.0,有興趣的同學,歡迎start。

那麼就先講下前期的打包配置吧,本地開發,也就是所謂的dev,需要熱更新等一系列便於除錯的外掛,所以需要區分webpack的配置。程式碼就不多提了,可以看下官方配置,也可以看下我的配置。

      如果是使用js+vue-cli 2.0的同學,那麼官方例項可以完美支援,一點都不需要動。我用的是ts+vue-cli 2.0寫的,webpack 4.0以上才支援ts,所以需要升級webpack版本,但是4.0以後,有很多外掛都棄用了,坑的一批。比如壓縮css的外掛ExtractTextPlugin,需要替換成MiniCssExtractPlugin,但是坑比的是服務端渲染還不能用(document is no defined),這裡也是需要注意的點,千萬不要在服務端和客戶端都能觸發的鉤子中操作dom,比如created,asyncData。所以不能像之前那樣寫在base.config裡面了,也就是服務端不能使用,如果你也碰到了這個問題,可以看下我的這篇

從webpack 3.0升級到4.0的經歷

server.js

專案的起始點就是server.js檔案,看下package.json,script命令就可以看出來,其實啟動專案就是執行node server.js。官方例項用的那些快取外掛就不多提了,其實有些快取配置可以配在nginx裡面的。細心的同學一看app.all()就知道了,其實這就是開了個node伺服器而已,我們前端跳轉的路由,就相當於一個get請求,伺服器接到這個請求,會根據vue提供的ssr外掛,把頁面渲染好之後再發送到客戶端。在渲染的同時,會有個上下文物件context,記錄這你想要往客戶都傳輸的資訊,什麼都可以傳,比如頁面渲染時間,語言版本等等。

function render (req, res) {
  const s = Date.now()

  res.setHeader("Content-Type", "text/html");
  res.setHeader("Server", serverInfo); // 往響應頭裡新增一些服務端資訊

  const handleError = err => {
    if (err.url) {
      res.redirect(err.url)
    } else if(err.code === 404) {
      res.status(404).send('404 | Page Not Found')
    } else {
      res.status(500).send('500 | Internal Server Error')
      console.error(`error during render : ${req.url}`)
      console.error(err.stack)
    }
  }

  const context = {
    title: 'Confession-Wall',
    url: req.url
  }

  renderer.renderToString(context, (err, html) => {
    if (err) {
      return handleError(err)
    }
    res.send(html);
    if (!isProd) {
      console.log(`頁面渲染耗時: ${Date.now() - s}ms`);
    }
  })
}

app.all(`${config.BasePath}*`, isProd ? render : (req, res) => {
  if (req.method !== 'GET') return next();
  readyPromise.then(() => render(req, res))
})

const port = process.env.PORT || 3000
app.listen(port, () => {
  console.log(`server started at localhost:${port}`);
})

 

main.ts

下圖為main.ts的程式碼,由於為了每個使用者從服務端拿到的是新的沒有汙染的程式碼,所以store,router,vue,每次都要new一個新的例項。

// main.ts
import Vue from 'vue';
import App from './App.vue';
import LocalStore from './store/index'
import LocalRouter from './router/index'
 // 在伺服器端渲染時把當前的路由資訊,同步進store中,也就相當於vuex store中多了個route module
import { sync } from 'vuex-router-sync';

Vue.directive('focus', {
    inserted: function (el) {
        el.focus();
    }
});

export function createApp () {
    const store = new LocalStore();
    const router = new LocalRouter();
    sync(store, router);

    const app = new Vue({
        router,
        store,
        render: h => h(App)
    })
    return { app, router, store };
}

vuex-router-sync

至於使用了vuex-router-sync外掛的效果如何,我們可以在瀏覽器的console裡可以打印出來,因為服務端向客戶端同步資料,是通過向window全域性中注入一個物件,用來記錄服務端的store資訊。如下圖所示,你會看到store裡面會多了個route的module,不是必須的,你也可以不是使用,或者通過你自己的方式實現。

當然從服務端能帶過來的不僅是store,也不僅僅往store裡新增route資訊,只要你需要的任何騷操作資訊都可以,下面會講到。

entry-server.ts

接下來應該到了entry-server.ts檔案了,註釋裡寫了我在學習時對其的理解。

// entry-server
import { createApp } from './main';

export interface Context {
    title: string;
    url: string;
    state: any
}

// context由server.js中注入
export default (context: Context) => {
    return new Promise((resolve, reject) => {
        const { app, router, store } = createApp()

        const { url } = context
        const { fullPath } = router.resolve(url).route

        // 判斷req裡的請求地址是否等於當前路由
        if (fullPath !== url) {
            return reject({ url: fullPath })
        }

        // 如果等於,則把當前url,push進router中,便於客戶端接管
        router.push(url)

        router.onReady(() => {
            const matchedComponents = router.getMatchedComponents()
            if (!matchedComponents.length) {
                return reject({ code: 404 })
            }
            // 如果路由匹配,則觸發伺服器端asyncData鉤子,此鉤子便是你元件定義的鉤子函式,
            // 預設寫在與methods同級,所以取的是其options,其實可以自行定義其位置,和實現方法
            // 可以在這裡對鉤子重寫,使之擁有更多功能
            Promise.all(matchedComponents.map((Component:any) => {
                if (Component.options.asyncData) {
                    return Component.options.asyncData({
                        store,
                        route: router.currentRoute
                    })
                }
            })).then(() => {
                // 把服務端請求到的資料,注入windows中的__INITIAL_STATE__中,便於客戶端接管vuex store
                context.state = store.state;
                resolve(app);
            }).catch(reject);
        }, reject)
    })
}

需要注意的地方是這裡暴露出來的方法,返回的是一個promise,之前寫的時候不注意,踩了個大坑。Component.options.asyncData,這裡其實可以自由發揮的,按官方那個例項來看,一般asyncData鉤子是與methods同級,所以這裡你去拿元件上的asyncData就可以了。至於叫不叫asyncData,你可以自行發揮,你也可以放在methods裡面,怎麼樣的行。只要在這裡能取到相應地方的相應方法就可以了。傳入的引數你也可以自行發揮,比如傳如isServer: true,用以區分是服務端渲染還是客戶端渲染觸發了這個鉤子,以及重定向方法之類的。context.state就是向客戶端注入的內容,可以自行新增東西。比如:

context.state =  { 
      store: store.state,
      text: '我是服務端注入的內容'
}

此時客戶端接受的window.__INITIAL_STATE__就如圖所示,這是你客戶端同步狀態的時候就要取對store了。

entry-client.ts

接下來應該就是entry-client.ts檔案了。

import Vue from 'vue';
import 'es6-promise/auto';
import { createApp }  from './main';
import { Route } from 'vue-router';
/**
 * 當元件複用時,觸發asyncData鉤子,重新請求資料
 */
Vue.mixin({
    beforeRouteUpdate (to: any, from: any, next: any) {
        const { asyncData } = (this as any).$options
        if (asyncData) {
            asyncData({
            store: (this as any).$store,
            route: to
            }).then(next).catch(next)
        } else {
            next()
        }
    }
})

const { app, router, store } = createApp()

// 獲取服務端渲染時,注入的__INITIAL_STATE__資訊,並同步到客戶端的vuex store中
if (window.__INITIAL_STATE__) {
    store.replaceState(window.__INITIAL_STATE__)
}

router.onReady(() => {
    router.beforeResolve( async (to: Route, from: Route, next: any) => {
        const matched = router.getMatchedComponents(to)
        const prevMatched = router.getMatchedComponents(from)
        let diffed = false
        // 校驗to的路由地址和from的路由地址是否相等,如果不相等則在客戶端觸發asyncData鉤子
        const activated = matched.filter((c: any, i: any) => {
            return diffed || (diffed = (prevMatched[i] !== c))
        })
        const asyncDataHooks = activated.map((c:any) => c.options.asyncData).filter((_: any) => _)
        if (!asyncDataHooks.length) {
            return next()
        }
        await Promise.all(asyncDataHooks.map( async (hook: any) => await hook({ store, route: to })))
        .then(() => {
            next()
        })
        .catch(next)
    })
    app.$mount('#app'); // 掛在到app上
})

// 如果瀏覽器支援serviceWorker則註冊
if (navigator.serviceWorker) {
    navigator.serviceWorker.register('/service-worker.js').then((registration) => {
        console.log('serviceWorker註冊成功')
    }).catch(() => {
        console.log('serviceWorker註冊失敗')
    })
}
// 向window type中插入__INITIAL_STATE__以至於ts不報錯
declare global {
    interface Window {
      __INITIAL_STATE__: any
    }
  }

index.template.html

跟官方例項一樣,也是可以改造的,你可以在server.js的context中注入你任何想注入的類容,像下面{{ title }}一樣注入到你渲染後的模板中,比如頁面構建時間,當前語言版本,等一系列操作。body中沒東西,就會預設在window中注入名為__INITIAL_STATE__的物件,當然你也可以自定義,比如使用名字為__INIT_STATE__的物件,可以在body中加入這段程式碼

{{{ renderState({ windowKey: '__INIT_STATE__', contextKey: 'state', }) }}} {{{ renderScripts() }}}

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>{{ title }}</title>
    <meta charset="utf-8">
    <meta name="mobile-web-app-capable" content="yes">
    <meta name="apple-mobile-web-app-capable" content="yes">
    <meta name="apple-mobile-web-app-status-bar-style" content="default">
    <link rel="apple-touch-icon" sizes="120x120" href="./public/logo-120.png">
    <meta name="viewport" content="width=device-width, initial-scale=1, minimal-ui">
    <link rel="shortcut icon" sizes="48x48" href="./public/logo-48.png">
    <meta name="theme-color" content="#f60">
    <link rel="manifest" href="./manifest.json">
  </head>
  <body>
  <!--vue-ssr-outlet-->
  </body>
</html>

.vue檔案

如果你前面拿的是與methods同級的屬性,那麼就寫在同級就行了,鉤子函式的名字和引數與你前面entry-client.ts裡保持一致,跟客戶端渲染的created鉤子差不多,裡面放一些請求,和改變vuex的東西進行資料預取。至於vuex的形式,以及用不用vuex做狀態管理都無所謂。

async asyncData({store, route}:any) {
    const id = route.query.id; 
    let params: Detail.ArticDetail.RequestParams = {
        id: id
    };
    store.commit('detail/articDetail/$assignParams', params);
    await store.dispatch('detail/articDetail/getArticDetail');
}

拿之前的老專案重構的,由於vue-cli 2.0用起來不太好,以及當初自己摸索的vuex寫法比較噁心,就只搭個例項了,專案地址,有興趣的,覺得寫了這麼多廢話有點用的同學歡迎start。

有興趣的同學,可以一起討論,無時不在。