完全理清前端單頁面應用(SPA)的實現原理 【精讀源碼】

clipboard.png

隨着 React Vue前端框架的興起,出現了 Vue-router,react-router-dom等前端路由管理庫,利用他們構建出來的單頁面應用,也是愈來愈接近原生的體驗,不再是之前的點擊標籤跳轉頁面,刷新整個頁面了,那麼他們的原理是什麼呢?

優質gitHub開源練手項目:

先說說原始的MPA多頁面應用:

文末還有新建的QQ以及微信羣哦~ 歡迎你們加入~~

傳統的多頁面應用構建方式:

  • 純服務端渲染,先後端不分離,使用jsp,jade,'ejs','tempalte'等技術在後臺先拼接成對應的HTML結構,而後轉換成字符串,在每一個對應的路由返回對應的數據(文件)便可
Jade模版服務端渲染,代碼實現:
const express= require('express')
const app =express()
const jade = require('jade')
const result = ***
const url path = *** 
const html = jade.renderFile(url, { data: result, urlPath })//傳入數據給模板引擎
app.get('/',(req,res)=>{
    res.send(html)//直接吐渲染好的`html`文件拼接成字符串返回給客戶端
}) //RestFul接口 

app.listen(3000,err=>{
    //do something
})
  • 使用jQuery等傳統庫繪製的前端頁面

傳統先後端不分離,服務端渲染的優缺點:

優勢:

  • SEO友好,由於返回給前端的是渲染好的HTML結構,裏面的內容均可以被爬蟲抓取到。
  • 對於一些應用性能等要求不高的項目,好比某個公司的靜態網頁,內容不多的狀況下,直接一把梭就好,不用再搭建工程化的環境等
  • 對於後端程序員(全乾工程師)來講,不用去特地學習前端框架,公司也不用特地去招聘前端
  • 兼容性好,傳統服務端渲染多頁面應用吐出來的都是字符串,HTML結構

缺點:

  • 若是項目很大,不利於維護,據我所知,目前不少雲計算公司,還有很多都是使用非單頁面應用,例如一個幾十萬行的項目是用jQuery寫的,若是註釋和文檔不是很是齊全,那麼真的會無從下手
  • 性能和用戶體驗,不能跟單頁面應用相比
  • 後期迭代,升級空間不大,目前大部分寫得比較好的庫,都創建vue,react等框架基礎上,他們都有一套本身的運行機制,有本身的生命週期,而且不像傳統的應用,還加上了一層虛擬DOM以及diff算法
  • 如今相似Ant-Design-pro這樣的開箱即用的庫已經不少,單頁面應用的學習和開發成本已經很低很低,若是還在使用傳統的技術去開發新的應用,對於開發人員多心裏來講也是一種折磨。
這裏並非說多頁面應用很差,只能說各有各自的好,單頁面應用若是經過大量的極致優化手段,是能夠從很多方面跟原生一拼。

clipboard.png

目前的單頁面應用:

  • 只有一張Web頁面的應用,是一種從Web服務器加載的富客戶端,單頁面跳轉僅刷新局部資源 ,公共資源(js、css等)僅需加載一次,經常使用於PC端官網、購物等網站
  • 其實只有一個空的DIV標籤,其餘都是js動態生態的內容

單頁面應用實現步驟:

代碼實現:

  • 首先是一個靜態模板文件 index.html
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>

<body>
    <div id="root"></div>
</body>
<script>

</script>

</html>
  • vue react框架的入口文件中指定對應的渲染元素:
import React from 'react;
import ReactDOM from 'react-dom';

ReactDOM.render(
<App/>,
document.querySelector("#root")
)
  • 引入react-router或者 react-router-dom,dva等路由跳轉的庫
  • 配置路由跳轉
<HashRouter>//這裏使用HashRouter
      <ErrorBoundary>//React錯誤邊界
        <Switch>
          <Route path="/login" component={Login} />
          <Route path="/home" component={Home} />
          <Route path="/" component={NotFound} />//404路由或者重定向均可以
        </Switch>
      </ErrorBoundary>
</HashRouter>

單頁面應用所謂路由跳轉,其實最終結果就是:

  • 瀏覽器的url地址發生變化,可是其實並無發送請求,也沒有刷新整個頁面
  • 根據咱們配置的路由信息,每次點擊切換路由,會切換到不一樣的組件顯示,相似於選項卡功能的實現,可是同時url地址欄會變化
  • 分爲HashRouterBrowserRouter兩種模式

本身實現一個粗略的路由跳轉:

本身實現傳統的Hash模式跳轉:

hash 就是指 url 後的 # 號以及後面的字符。例如 www.baidu.com/#segmentfault,那麼 #segmentfault就是 hash
  • 須要用到的幾個知識點:css

    • window.location.hash = '**'; // 設置當前的hash值
    • const hash = window.location.hash 獲取當前的hash值
    • hash改變會觸發windowhashchange事件
window.onhashchange=function(e){
    let newURL = e.newURL; // 改變後的新 url地址
    let oldURL = e.oldURL; // 改變前的舊 url地址
}
這裏特別注意, hash改變並不會發送請求

開始實現Hash模式跳轉:

使用相似發佈訂閱模式的方式,使用ES6的class實現:

  • 初始訂閱,每一個不一樣的hash值,對應不一樣的函數調用處理。
class Router {
  constructor() {
    this.routes = {};
    this.currentUrl = '';
  }
  route(path, callback) {
    this.routes[path] = callback || function() {};
  }
  updateView() {
    this.currentUrl = location.hash.slice(1) || '/';
    this.routes[this.currentUrl] && this.routes[this.currentUrl]();
  }
  init() {
    window.addEventListener('load', this.updateView.bind(this), false);
    window.addEventListener('hashchange', this.updateView.bind(this), false);
  }
}
  • routes 用來存放不一樣路由對應的回調函數
  • init 用來初始化路由,在 load 事件發生後刷新頁面,而且綁定 hashchange 事件,當 hash 值改變時觸發對應回調函數

開始使用:

<div id="app">
  <ul>
    <li>
      <a href="#/">home</a>
    </li>
    <li>
      <a href="#/about">about</a>
    </li>
    <li>
      <a href="#/topics">topics</a>
    </li>
  </ul>
  <div id="content"></div>
</div>
<script src="js/router.js"></script>
<script>
  const router = new Router();
  router.init();
  router.route('/', function () {
    document.getElementById('content').innerHTML = 'Home';
  });
  router.route('/about', function () {
    document.getElementById('content').innerHTML = 'About';
  });
  router.route('/topics', function () {
    document.getElementById('content').innerHTML = 'Topics';
  });
</script>
這樣一個簡單的 hash模式路由就作好了,剩下的就是路由嵌套,以及錯誤邊界的處理

History模式實現:

  • History來自Html5的規範
  • History模式,url地址欄的改變並不會觸發任何事件
  • History模式,可使用history.pushState,history.replaceState來控制url地址,history.pushState() 和 history.replaceState() 的區別在於:html

    • history.pushState() 在保留現有歷史記錄的同時,將 url 加入到歷史記錄中。
    • history.replaceState() 會將歷史記錄中的當前頁面歷史替換爲 url。
  • History模式下,刷新頁面會404,須要後端配合匹配一個任意路由,重定向到首頁,特別是加上Nginx反向代理服務器的時候
咱們須要換個思路,咱們能夠羅列出全部可能觸發 history 改變的狀況,而且將這些方式一一進行攔截,變相地監聽 history 的改變。

對於一個應用而言,url 的改變(不包括 hash 值得改變)只能由下面三種狀況引發:

  • 點擊瀏覽器的前進或後退按鈕
  • 點擊 a 標籤
  • 在 JS 代碼中觸發 history.push(replace)State 函數
只要對上述三種狀況進行攔截,就能夠變相監聽到 history 的改變而作出調整。針對狀況 1,HTML5 規範中有相應的 onpopstate 事件,經過它能夠監聽到前進或者後退按鈕的點擊,值得注意的是,調用 history.push(replace)State 並不會觸發 onpopstate 事件。

開始實現:

class Router {
  constructor() {
    this.routes = {};
    this.currentUrl = '';
  }
  route(path, callback) {
    this.routes[path] = callback || function() {};
  }
  updateView(url) {
    this.currentUrl = url;
    this.routes[this.currentUrl] && this.routes[this.currentUrl]();
  }
  bindLink() {
    const allLink = document.querySelectorAll('a[data-href]');
    for (let i = 0, len = allLink.length; i < len; i++) {
      const current = allLink[i];
      current.addEventListener(
        'click',
        e => {
          e.preventDefault();
          const url = current.getAttribute('data-href');
          history.pushState({}, null, url);
          this.updateView(url);
        },
        false
      );
    }
  }
  init() {
    this.bindLink();
    window.addEventListener('popstate', e => {
      this.updateView(window.location.pathname);
    });
    window.addEventListener('load', () => this.updateView('/'), false);
  }
}

Router 跟以前 Hash 路由很像,不一樣的地方在於:

  • init 初始化函數,首先須要獲取全部特殊的連接標籤,而後監聽點擊事件,並阻止其默認事件,觸發 history.pushState 以及更新相應的視圖。
  • 另外綁定 popstate 事件,當用戶點擊前進或者後退的按鈕時候,可以及時更新視圖,另外當剛進去頁面時也要觸發一次視圖更新。

實際使用:

<div id="app">
  <ul>
    <li><a data-href="/" href="#">home</a></li>
    <li><a data-href="/about" href="#">about</a></li>
    <li><a data-href="/topics" href="#">topics</a></li>
  </ul>
  <div id="content"></div>
</div>
<script src="js/router.js"></script>
<script>
  const router = new Router();
  router.init();
  router.route('/', function() {
    document.getElementById('content').innerHTML = 'Home';
  });
  router.route('/about', function() {
    document.getElementById('content').innerHTML = 'About';
  });
  router.route('/topics', function() {
    document.getElementById('content').innerHTML = 'Topics';
  });
</script>
  • 跟以前的 html 基本一致,區別在於用 data-href 來表示要實現軟路由的連接標籤。
  • 固然上面還有狀況 3,就是你在 JS 直接觸發 pushState 函數,那麼這時候你必需要調用視圖更新函數,不然就是出現視圖內容和 url 不一致的狀況。
setTimeout(() => {
  history.pushState({}, null, '/about');
  router.updateView('/about');
}, 2000);

React-router-dom源碼:

Router組件:

export class Route extends Component {
  componentWillMount() {
    window.addEventListener('hashchange', this.updateView, false);
  }
  componentWillUnmount() {
    window.removeEventListener('hashchange', this.updateView, false);
  }
  updateView = () => {
    this.forceUpdate();
  }
  render() {
    const { path, exact, component } = this.props;
    const match = matchPath(window.location.hash, { exact, path });
    if (!match) {
      return null;
    }
    if (component) {
      return React.createElement(component, { match });
    }
    return null;
  }
}
  • 組件掛載監聽hash change原生事件,將要卸載時候移除事件監聽防止內存泄漏
  • 每次hash改變,就觸發全部對應hash的回掉,全部的Router都去更新視圖
  • 每一個Router組件中,都去對比當前的hash值和這個組件的path屬性,若是不同,那麼就返回null,·不然就渲染這個組件對應的視圖

History模式的實現:

clipboard.png

實現History前端

這裏想多留些時間寫其餘源碼,這篇文章寫得很是好,你們也能夠去看看,本文不少借鑑他的。

withRouter高階函數的源碼:

var withRouter = function withRouter(Component) {
  var C = function C(props) {
    var wrappedComponentRef = props.wrappedComponentRef,
        remainingProps = _objectWithoutProperties(props, ["wrappedComponentRef"]);

    return _react2.default.createElement(_Route2.default, {
      children: function children(routeComponentProps) {
        return _react2.default.createElement(Component, _extends({}, remainingProps, routeComponentProps, {
          ref: wrappedComponentRef
        }));
      }
    });
  };

  C.displayName = "withRouter(" + (Component.displayName || Component.name) + ")";
  C.WrappedComponent = Component;
  C.propTypes = {
    wrappedComponentRef: _propTypes2.default.func
  };

  return (0, _hoistNonReactStatics2.default)(C, Component);
};
  • 傳入一個組件,返回一個新的組件,而且給這個組件賦予全局屬性,擁有路由組件的三大屬性

Switch組件:

Switch.prototype.render = function render() {
    var route = this.context.router.route;
    var children = this.props.children;

    var location = this.props.location || route.location;

    var match = void 0,
        child = void 0;
    _react2.default.Children.forEach(children, function (element) {
      if (match == null && _react2.default.isValidElement(element)) {
        var _element$props = element.props,
            pathProp = _element$props.path,
            exact = _element$props.exact,
            strict = _element$props.strict,
            sensitive = _element$props.sensitive,
            from = _element$props.from;

        var path = pathProp || from;

        child = element;
        match = (0, _matchPath2.default)(location.pathname, { path: path, exact: exact, strict: strict, sensitive: sensitive }, route.match);
      }
    });

    return match ? _react2.default.cloneElement(child, { location: location, computedMatch: match }) : null;
  };
  • 遍歷因此傳入的子元素
  • 若是有符合的路由對應的元素,那麼就返回,並且只匹配這一個路由。再也不繼續往下匹配
  • 若是第二條沒有找到符合的元素,那麼拋出錯誤
若是以爲寫得好,記得點個贊哦,另外新建了微信和QQ羣,歡迎各位小哥哥小姐姐入駐~
  • 微信羣:

clipboard.png

  • QQ羣

clipboard.png

相關文章
相關標籤/搜索