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…