express 使用 vue-router 的 history 踩坑

history 是什麼?

官方說法

vue-router 默認 hash 模式 —— 使用 URL 的 hash 來模擬一個完整的 URL,因而當 URL 改變時,頁面不會從新加載。javascript

若是不想要很醜的 hash,咱們能夠用路由的 history 模式,這種模式充分利用 history.pushState API 來完成 URL 跳轉而無須從新加載頁面。css

當你使用 history 模式時,URL 就像正常的 url,例如 yoursite.com/user/id,也好看…html

我的理解

上面是官方的解釋,文檔的一向風格,只給懂的人看。兩年前我比如今還菜的時候,看了這段話表示他在說個錘子,直接跳過了。前端

我不講🔨,直接舉🌰vue

通常的咱們把項目放到服務器上,路由都是在服務器中設置的。java

好比網站 https://www.text.com/ 中 admin目錄下有一個 login.html 的頁面。當用戶輸入 https://www.text.com/admin/login,先解析 www.text.com 域名部分獲得服務器 ip 和 端口號,根據 ip 和 端口號找到對應的服務器中的對應的程序,而後在程序解析 /admin/login 路徑知道了你要找的是 admin 目錄下的 login.html 頁面,而後就返回給你這個頁面。git

這是正常的方式,服務器控制一個路由指向一個頁面的文件(不考慮重定向的狀況),這樣咱們的項通常有多少個頁面就有多少個 html 文件。github

而 vue 中,咱們打包好的文件實際上是隻有一個 index.html,全部的行爲都是在這一個頁面上完成。用戶的全部的路由其實都是在請求 index.html 頁面。web

假設承載 vue 項目 index.html 也是在 admin 目錄下,vue 項目中也有一個 login 頁面,那對應的url就是https://www.text.com/admin/#/loginvue-router

這個 url 由三部分組成,是 www.text.com 是域名,/admin 是項目所在目錄,和上面同樣這個解析工做是由服務器完成的,服務器解析出 /admin 的路由,就返回給你 index.html/#/login 是 vue-router 模擬的路由,由於頁面全部的跳轉 vue 都是在index.html中完成的,因此加上 # 表示頁內切換。假設切換到 home 頁面,對應的 html 文件仍是index.html,url 變成 https://www.text.com/admin/#/home,vue-router 判斷到 /#/home 的改變而改變了頁面 dom 元素,從而給用戶的感受是頁面跳轉了。這就是 hash 模式。

那咱們就知道了,正常的 url 和 hash 模式的區別,頁面的 js 代碼沒辦法獲取到服務器判斷路由的行爲,因此只能用這種方式實現路由的功能。

而 history 模式就是讓 vue 的路由和正常的 url 同樣,至於怎麼作下文會說到。

爲何須要實現

說怎麼作以前,先說說爲何須要 history 模式。官方文檔說了,這樣比較好看。emmmmmm,對於直接面向消費者的網站好看這個確實是個問題,有個 /# 顯得不夠大氣。對於企業管理的 spa 這其實也沒什麼。

因此除了好看以外,history 模式還有其餘優點。

咱們知道,若是頁面使用錨點,就是一個 <a> 標籤,<a href='#mark1'></a>,點擊以後若是頁面中有 id 爲 mark1 的標籤會自動滾動到對應的標籤,而 url 後面會加上 #mark.

問題就出在這裏,使用 hash 模式,#mark會替換掉 vue-router 模擬的路由。好比這個 <a> 標籤是在上面說的 login 頁面,點擊以後 url 會從 https://www.text.com/admin/#/login 變成 https://www.text.com/admin/#/mark。wtf???正常看來問題不大,錨點滾動嘛,實在不行能夠 js 模擬,可是由於我要實現 markdown 的標題導航功能,這個功能是插件作好的,究竟該插件仍是用 history 。 權衡利弊下仍是使用 history 模式工做量小,並且更美。

怎麼作

既然知道是什麼,爲何,下面就該研究怎麼作了。

官方文檔裏有「詳盡」的說明,其實這事兒原本不難,原理也很簡單。經過上文咱們知道 vue-router 採用 hash 模式最大的緣由在於全部的路由跳轉都是 js 模擬的,而 js 沒法獲取服務器判斷路由的行爲,那麼就須要服務器的配合。原理就是不管用戶輸入的路由是什麼全都指向 index.html 文件,而後 js 根據路由再進行渲染。

按照官方的作法,前端 router 配置裏面加一個屬性,以下

const router = new VueRouter({
  mode: 'history',
  routes: [...]
})
複製代碼

後端的我不一一贅述,我用的是express,因此直接用了 connect-history-api-fallback 中間件。(中間件地址 github.com/bripkens/co…

const history = require('connect-history-api-fallback')
app.use(history({
    rewrites: [
        {
            from: /^\/.*$/,
            to: function (context) {
                return "/";
            }
        },
    ]
}));

app.get('/', function (req, res) {
    res.sendFile(path.join(process.cwd(), "client/index.html"));
});

app.use(
    express.static(
        path.join(process.cwd(), "static"),
        {
            maxAge: 0,//暫時關掉cdn
        }
    )
);
複製代碼

坑1

按道理來講這樣就沒問題了,然鵝放到服務器裏面以後,開始出幺蛾子了。靜態文件加載的時候接口返回都是

We're sorry but client doesn't work properly without JavaScript enabled. Please enable it to continue.

看着字面意思,說個人項目(項目名client)沒有啓用 JavaScript ,莫名其妙徹底不能理解。因而乎仔細比對控制檯 responses headers 和request headers ,發現了一些貓膩,請求頭的 accept 和響應頭的 content-type 對不上,請求 css 文件請求頭的 accept 是text/css,響應頭的 content-type 是 text/html。這個不該該請求什麼響應什麼嗎,我想要崔鶯鶯同樣女子作老婆,給我個杜十娘也認了,結果你給我整個潘金蓮讓我咋整。

徹底不知道到底哪裏出了問題,google上面也沒有找到方法。開始瞎琢磨,既然對不上,那就想我手動給對上行不行。在express.static 的 setHeaders 裏面檢查讀取文件類型,而後根據文件類型手動設置mime type,我開始佩服個人機智。

app.use(
    express.static(
        path.join(process.cwd(), "static"),
        {
            maxAge: 0,
            setHeaders(res,path){
                // 經過 path 獲取文件類型,設置對應文件的 mime type。
            }
        }
    )
);
複製代碼

緩存時間設置爲0,關掉CDN... 一頓操做, 發現不執行 setHeaders 裏面的方法。這個時候已經晚上 11 點了,我已經絕望了,最後一次看了一遍 connect-history-api-fallback 的文檔,以爲 htmlAcceptHeaders 這個配置項這麼違和,其餘的都能明白啥意思,就這個怎麼都不能理解,死馬當活馬醫扔進代碼試試,竟然成了。

const history = require('connect-history-api-fallback')
app.use(history({
    htmlAcceptHeaders: ['text/html', 'application/xhtml+xml']
    rewrites: [
        {
            from: /^\/.*$/,
            to: function (context) {
                return "/";
            }
        },
    ]
}));
複製代碼

到底誰寫的文檔,靜態文件的 headers 的 accepts 和 htmlAcceptHeaders 有什麼關係。咱也不知道,咱也沒地方問。這事兒耽誤了我大半天的時間,不研究透了內心不舒服。老規矩,看 connect-history-api-fallback 源碼。

'use strict';

var url = require('url');

exports = module.exports = function historyApiFallback(options) {
  options = options || {};
  var logger = getLogger(options);

  return function(req, res, next) {
    var headers = req.headers;
    if (req.method !== 'GET') {
      logger(
        'Not rewriting',
        req.method,
        req.url,
        'because the method is not GET.'
      );
      return next();
    } else if (!headers || typeof headers.accept !== 'string') {
      logger(
        'Not rewriting',
        req.method,
        req.url,
        'because the client did not send an HTTP accept header.'
      );
      return next();
    } else if (headers.accept.indexOf('application/json') === 0) {
      logger(
        'Not rewriting',
        req.method,
        req.url,
        'because the client prefers JSON.'
      );
      return next();
    } else if (!acceptsHtml(headers.accept, options)) {
      logger(
        'Not rewriting',
        req.method,
        req.url,
        'because the client does not accept HTML.'
      );
      return next();
    }

    var parsedUrl = url.parse(req.url);
    var rewriteTarget;
    options.rewrites = options.rewrites || [];
    for (var i = 0; i < options.rewrites.length; i++) {
      var rewrite = options.rewrites[i];
      var match = parsedUrl.pathname.match(rewrite.from);
      if (match !== null) {
        rewriteTarget = evaluateRewriteRule(parsedUrl, match, rewrite.to, req);

        if(rewriteTarget.charAt(0) !== '/') {
          logger(
            'We recommend using an absolute path for the rewrite target.',
            'Received a non-absolute rewrite target',
            rewriteTarget,
            'for URL',
            req.url
          );
        }

        logger('Rewriting', req.method, req.url, 'to', rewriteTarget);
        req.url = rewriteTarget;
        return next();
      }
    }

    var pathname = parsedUrl.pathname;
    if (pathname.lastIndexOf('.') > pathname.lastIndexOf('/') &&
        options.disableDotRule !== true) {
      logger(
        'Not rewriting',
        req.method,
        req.url,
        'because the path includes a dot (.) character.'
      );
      return next();
    }

    rewriteTarget = options.index || '/index.html';
    logger('Rewriting', req.method, req.url, 'to', rewriteTarget);
    req.url = rewriteTarget;
    next();
  };
};

function evaluateRewriteRule(parsedUrl, match, rule, req) {
  if (typeof rule === 'string') {
    return rule;
  } else if (typeof rule !== 'function') {
    throw new Error('Rewrite rule can only be of type string or function.');
  }

  return rule({
    parsedUrl: parsedUrl,
    match: match,
    request: req
  });
}

function acceptsHtml(header, options) {
  options.htmlAcceptHeaders = options.htmlAcceptHeaders || ['text/html', '*/*'];
  for (var i = 0; i < options.htmlAcceptHeaders.length; i++) {
    if (header.indexOf(options.htmlAcceptHeaders[i]) !== -1) {
      return true;
    }
  }
  return false;
}

function getLogger(options) {
  if (options && options.logger) {
    return options.logger;
  } else if (options && options.verbose) {
    return console.log.bind(console);
  }
  return function(){};
}
複製代碼

這個代碼還真是通俗易懂,就不去一行行分析了(實際上是我懶)。直接截取關鍵代碼:

else if (!acceptsHtml(headers.accept, options)) {
      logger(
        'Not rewriting',
        req.method,
        req.url,
        'because the client does not accept HTML.'
      );
      return next();
    }
複製代碼
function acceptsHtml(header, options) {
  //在這裏
  options.htmlAcceptHeaders = options.htmlAcceptHeaders || ['text/html', '*/*'];
  for (var i = 0; i < options.htmlAcceptHeaders.length; i++) {
    if (header.indexOf(options.htmlAcceptHeaders[i]) !== -1) {
      return true;
    }
  }
  return false;
}
複製代碼

前一段代碼,若是 acceptsHtml 函數返回 false,說明瀏覽器不接受 html 文件,跳過執行 next(),不然繼續執行。

後一段代碼, acceptsHtml 函數內部設置 htmlAcceptHeaders 的默認值是 'text/html', '*/*' 。判斷請求頭的accept,若是匹配上說明返回true,不然返回false。直接用默認值接口不能正常返回 css 和 js, 改爲'text/html', 'application/xhtml+xml' 就能運行了。這就奇了怪了,htmlAcceptHeaders 爲何會影響 css 和 js。太晚了,不太想糾結了,簡單粗暴把源碼摳出來直接放到項目裏面跑一下,看看到底發生了什麼。

function acceptsHtml(header, options) {
    options.htmlAcceptHeaders = options.htmlAcceptHeaders || ['text/html', '*/*'];
    console.log("header", header);
    console.log("htmlAcceptHeaders", options.htmlAcceptHeaders);
    for (var i = 0; i < options.htmlAcceptHeaders.length; i++) {
        console.log("indexOf", header.indexOf(options.htmlAcceptHeaders[i]));
        if (header.indexOf(options.htmlAcceptHeaders[i]) !== -1) {
            return true;
        }
    }
    return false;
}
複製代碼

設置 htmlAcceptHeaders 值爲'text/html', 'application/xhtml+xml'

header text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3
htmlAcceptHeaders [ 'text/html', 'application/xhtml+xml' ]
indexOf 0
header text/css,*/*;q=0.1
htmlAcceptHeaders [ 'text/html', 'application/xhtml+xml' ]
indexOf -1
indexOf -1
複製代碼

不設置 htmlAcceptHeaders

header text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3
htmlAcceptHeaders [ 'text/html', '*/*' ]
indexOf 0
header application/signed-exchange;v=b3;q=0.9,*/*;q=0.8
htmlAcceptHeaders [ 'text/html', '*/*' ]
indexOf -1
indexOf 39
複製代碼

這時候我忽然茅塞頓開,htmlAcceptHeaders 這個屬性過濾 css 和 js 文件,若是用默認的 'text/html', '*/*' 屬性,css 和 js 文件都會被匹配成 html 文件,而後一陣處理致使響應頭的 mime 文件類型變成 text/html 致使瀏覽器沒法解析。

原來不是寫文檔的人邏輯有問題,而是他是個懶人,不想解釋太多,我是個蠢人不能一會兒理解他的「深意」。

坑2

還有一點要注意,就是路由名稱的設定。仍是這個URL https://www.text.com/admin/login,服務器把全部/admin的路由都指向了 vue 的 index.html 文件,hash模式下咱們的路由這麼配置的路由

const router = new VueRouter({
  routes: [{
        path: "/login",
        name: "login",
        component: login
    }]
})
複製代碼

這時咱們改爲history模式

const router = new VueRouter({
  mode: 'history',
  routes: [{
        path: "/login",
        name: "login",
        component: login
    }]
})
複製代碼

打開 urlhttps://www.text.com/admin/login會發現自動跳轉到https://www.text.com/login,緣由就是/admin的路由都指向了 vue 的 index.html 文件以後,js 根據咱們的代碼把url改爲了 https://www.text.com/login,若是咱們不刷新頁面沒有任何問題,由於頁面內全部的跳轉仍是 vue-router 控制, index.html 這個文件沒變。可是若是刷新頁面那就會出問題,服務器從新判斷 /login 路由對應的文件。所以使用 history 模式時前端配置 vue-router 時也須要考慮後臺的項目所在目錄。

好比上面的例子應該改成,這樣能夠避免這種狀況的問題

const router = new VueRouter({
  mode: 'history',
  routes: [{
        path: "/admin/login",
        name: "login",
        component: login
    }]
})
複製代碼

參考連接

router.vuejs.org/zh/guide/es…

相關文章
相關標籤/搜索