從0到1設計一個react-spa後臺應用

琰玉發表於2019-03-04

下面圍繞下面這張圖,談談如何構建一個基本的react-spa應用框架。

image.png

按需載入

webpack3 + react-router4 + react-loadable

使用SPA必然要說到按需載入,目前最簡潔優雅的方案是使用webpack3 + react-router4 + react-loadable, 原理就是 webpack 的 Dynamic Imports。

通俗的講,dynamic import,就是把JS程式碼分成N個頁面份數的檔案,不在使用者剛進來就全部引入,而是等使用者跳轉路由的時候,再載入對應的JS檔案。這樣做的好處就是加速首屏顯示速度,同時也減少了資源的浪費。

webpack 的 Dynamic Imports 實現主要是利用 ECMAScript的 import() 動態載入特性,用於完成動態載入即執行時載入,而 import() 目前只是一個草案,如果需要用此方法,需要引入對應的轉換器,如 babel-plugin-syntax-dynamic-import。

react-loadable是一個高階元件,參照官方文件
Code Splitting,單頁面的按需載入方案變得非常簡潔:

  1. 安裝 babel-plugin-syntax-dynamic-import,為babel配置”syntax-dynamic-import”外掛;
  2. 使用react-loadable
import Loadable from `react-loadable`;

import LoadingIndicator from `components/LoadingIndicator`;

const DataSandBox = Loadable({
  //自從webpack2.4開始,可以在動態匯入中使用魔術註釋來指定模組的chunk名字
  loader: () => import(/* webpackChunkName: "chunckName" */`../routers/module/index`),
  loading: LoadingIndicator
});

複製程式碼

LoadingIndicator是封裝好的一個在非同步載入階段的loading展示,除此之外,react-loadable還提供了delay和timeout等配置項讓按需載入的過程更加友好。

Magic Comment

上文demo程式碼中說到的魔術註釋值得說一下,這個是在webpack3新加上的。Webpack 2+ 開始,引入了Code Splitting-Async的新方法import(),用於動態引入ES Module。webpack將傳入import方法的模組打包到一個單獨的程式碼塊(chunk),但是卻不能像require.ensure一樣,為生成的chunk指定chunkName,因此在webpack3中提出了Magic Comment用於解決該問題。

publicPath

非同步載入chunck檔案需要利用publicPath來補全生產模式的cdn資源地址。參考城危同學在這篇文章中的觀點

實踐下來關於JSONP地址:”本地開發、日常開發、預發、線上”等環節有一個共同的特點,無論環境
怎麼改變,chunk檔案與主檔案的相對路徑是不會改變的,那獲取runtime的JS地址即可確定JSONP地
址,脫離環境、version和專案倉庫名。

通過在頁面入口檔案中增加如下程式碼,可以相容開發環境和生產環境對chunk檔案的引用

/**
 * 設定 __webpack_public_path__, 相容日常、預發、線上環境
 */
const js = document.scripts;
const url = js[js.length - 1].src.split("?")[0];
const urlSplit = url.split("/");
urlSplit.pop();
urlSplit.pop();
__webpack_public_path__ = urlSplit.join("/") + "/"; 
複製程式碼

Antd和React的版本

Antd 3.0其實是在看了SEECONF上它山前輩的分享而被種草的。我們希望使用Antd 3.0的視覺風格,讓後臺整體看起來更加明亮,因此,將元件庫升級為Antd 3.0,同時使用react v16。這裡需要注意的是,因為antd2.x 預設是用12px, 而3.0 使用的是14px,如果升級的話,對2.x系列業務元件尺寸挑戰,對於舊元件可能會有一些相容成本,比如需要元件內部對預設字型做一下設定。

樣式方案

CSS modules

CSS Evolution: From CSS, SASS, BEM, CSS Modules to Styled Components 比較全面的介紹了css技術的進化過程。

image.png

我們需要尋求一個搭配當前的技術選型(React)的最優方案,解決兩個問題:避免樣式覆蓋和便於實現樣式的複用。

css不是程式語言,但如果說要給它加一個作用域的概念的話,那就是:只有全域性作用域。

無論分拆為多少個css檔案,無論用怎樣的方式引入,所有的樣式規則都位於同一作用域,只要選擇符近似,就有發生覆蓋的可能。

CSS Modules是一種技術流的組織css程式碼的策略,通過工具解決了BEM依靠開發人員選擇唯一class名的工作,無法改變css全域性作用域的本性,而是依靠動態生成class名這一手段(利用webpack的css-loader),來實現區域性作用域。顯然,這樣的class名就可以是唯一的,不管原本的css程式碼寫得有多隨便,都可以這樣轉換得到不衝突的css程式碼。

要使用CSS Modules,必須想辦法把變數風格的class名注入到html中,這時,虛擬DOM風格的React,搭配CSS Modules會很容易:有了CSS “本地作用域”,所有的 React 元件都可以在邏輯和呈現狀態上進行完全的隔離。

使用CSS Modules只需要在webpack中給css-loader加上如下兩個引數:

名稱 型別 預設值 描述
modules {Boolean} false 啟用/禁用 CSS modules
localIdentName {String} [hash:base64] 配置生成的識別符號(ident),推薦設定[local]___[hash:base64:5]

js 檔案的改變就是在設定 className 時,用一個物件屬性取代了原來的字串。

import classNames from `classnames`;
import styles from `./dialog.css`;

export default class Dialog extends React.Component {
  render() {
    const cx = classNames({
      [styles.confirm]: !this.state.disabled,
      [styles.disabledConfirm]: this.state.disabled
    });

    return <div className={styles.root}>
      <a className={cx}>Confirm</a>
      ...
    </div>
  }
}
複製程式碼

如何與全域性樣式共存

在實際工程中,需要諸如reset/normalize,Settings等一些通用的全域性設定。開啟css modules設定後,所有的樣式預設都是local模式,這時,可以使用:global 標籤在主應用程式中匯入公共的樣式檔案。

image.png

覆蓋元件樣式

  • CSS Modules 不會覆蓋屬性選擇器,所以可以利用屬性選擇器來解決這個問題;
  • 引入的 antd 元件類名沒有被 CSS Modules 轉化,所以被覆蓋的類名,如 .ant-select-selection 必須放到 :global 中,為了防止對其他同類元件造成影響,可以給元件新增 className,只對這類元件進行覆蓋,也可以利用外層類名實現這種限制。

路由與佈局

資料驅動的路由配置

我們需要兩個對應關係,選單和路由的關係以及路由和元件的關係,即通過url找到menu再載入元件這樣一個過程。

url到元件的轉換包括兩個入口,一個是通過menu點選,一個是通過Link跳轉。

Route可以幫我們解決url到component的轉換,即根據path來載入對應的component。那剩下的工作就是定義一個物件來儲存關係,並實現一個通過url找到對應選單項的方法。

參考Antd pro剛對內釋出時候的原始碼,可以設計一個公共的nav.js用來管理url、選單和路由(模組元件)三者的關係。結合前文提到的按需載入策略,基本結構如下:

import BasicLayout from "components/Layouts/BasicLayout.js";
// 按路由拆分程式碼
import Loadable from "react-loadable";
import LoadingIndicator from "components/LoadingIndicator/LoadingIndicator";

//概覽頁
const DashBoard = Loadable({
  loader: () => import(/* webpackChunkName: "DashBoard" */"../routers/DashBoard/index"),
  loading: LoadingIndicator
});

/*將需要的路由元件包裝成動態載入的形式,然後配置到navData資料結構裡面*/
......

const navData = [
  {
    component: BasicLayout,
    layout: "BasicLayout",
    name: "首頁", // for breadcrumb
    path: "",
    children: [
      {
        name: "概覽",
        icon: "dashboard",
        path: "dashboard",
        component: DashBoard
      },
      {
        name: "特徵管理",
        icon: "bars",
        path: "feature",
        children: [
          {
            name: "明星人臉庫",
            icon: "star",
            path: "face",
            component: StarFaceManage,
          }
        ]
      },
      {
        name: "資料沙盤",
        icon: "play-circle",
        path: "sandbox",
        component: DataSandBox,
        isLink:true
      }
    ]
  }
];

export function getNavData() {
  return navData;
}

export { navData };
複製程式碼

name,icon是選單的展示屬性,path代表其對應的url片段,children提供選單無限向下擴充套件的能力,只有葉子節點才有對應的component。通過這種結構,可以遞迴地渲染出對應的選單結果。針對Link形式的跳轉,將isLink設定為true,不在選單的結構中顯示,但可以通過path讓route識別到。這樣,形成了一個通過資料驅動的路由配置。

url到選單的對映

url到選單的對映就是:不同的url對應的openKeys和selectedKeys屬性是啥。
下面是一個基本(只提供一種佈局)的主頁面的程式碼結構:

import BasicLayout from "components/Layouts/BasicLayout";
import { Router, Switch, Route } from "react-router-dom";
import { createBrowserHistory } from "history";
const history = createBrowserHistory();

/**
 * 設定 __webpack_public_path__, 相容日常、預發、線上環境
 */
const js = document.scripts;
const url = js[js.length - 1].src.split("?")[0];
const urlSplit = url.split("/");
urlSplit.pop();
urlSplit.pop();
__webpack_public_path__ = urlSplit.join("/") + "/";

/**
 * 基礎資訊配置 window.GV通過diamond配置
 */
const Globol_Values = window.GV || {};
//登陸使用者
const user = (Globol_Values.user && JSON.parse(Globol_Values.user)) || {};
const baseConfigs = {
  //平臺logo
  siteLogo: Globol_Values.siteLogo || "",
  .......
};

const App = () => (
  <Router history={history}>
    <Switch>
      <Route
        path="/"
        render={props =>
           (
            <BasicLayout {...props} currentUser={user} {...baseConfigs} />
           )
        }
      />
    </Switch>
  </Router>
);

ReactDOM.render(<App />, document.getElementById("app"));
複製程式碼

Router會建立一個history物件並用其保持追蹤當前location,在location有變化時對網頁進行重新渲染。通過渲染的元素會被傳入一些引數。分別是match物件,當前location物件以及history物件(由router建立)。locations 是一個含有描述URL不同部分屬性的物件,結構如下:

// 一個基本的location物件
{ pathname: `/`, search: ``, hash: ``, key: `abc123` state: {} }
複製程式碼

利用這個特性,BasicLayout在每次url變化時,可以接收父元件傳入的props中的location物件,並通過pathname屬性來進行menu的匹配。

基於 React Router 4 的可複用 Layout 元件

結合前文的設計,我們希望能夠設計一個可複用 Layout 元件。

動態標題設定

React-document-title提供了一種宣告式的方法來設定單頁應用的的文件標題

基本佈局

antd的Layout提供了基本的佈局能力。仿照pro,我們選擇”側邊兩列式佈局。頁面橫向空間有限時,側邊導航可收起”的形式,同時自定義收起觸發器。

undefined
const layout = (
   <Layout>
	 <Sider></Sider>
	 <Layout>
	    <Header></Header>
		<Content></Content>
	 </Layout>
    </Layout>
)
複製程式碼

Sider

Sider是側邊欄,功能就是展示選單,同時可以根據橫向空間展開收起。自定義觸發器首先需要把trigger屬性設定為null。breakpoint這個屬性很有意思,是觸發響應式佈局的斷點,

//antd中對breakpoint 的規範定義 也是響應式柵格的邊界
{
  xs: `480px`,
  sm: `576px`,
  md: `768px`,
  lg: `992px`,
  xl: `1200px`,
  xxl: `1600px`,
}
複製程式碼
<Sider
   trigger={null}
   collapsible
   collapsed={this.state.collapsed}
   breakpoint="md"
   onCollapse={this.onCollapse}
   width={256}
   className={styles.sider}
>
</Sider>
複製程式碼

breakpoint=”md”即body的寬度大於768時,sider就會收起。樣式上,sider的min-height需要設定為100vh,即預設高度佔滿整個瀏覽器的視窗。

參考pro的原始碼,我們可以得到啟發,sider可以通過breakpointer來動態的改變佈局,那麼根據antd的柵格規範,使用 react-container-query 動態給 layout 根據不同的寬度加 classname,那麼裡面包含的所有dom都可以根據這個來調整樣式。

import DocumentTitle from "react-document-title";
import { ContainerQuery } from "react-container-query";
//定義ContainerQuery的引數
const query = {
  "screen-xs": {
    maxWidth: 575
  },
  "screen-sm": {
    minWidth: 576,
    maxWidth: 767
  },
  "screen-md": {
    minWidth: 768,
    maxWidth: 991
  },
  "screen-lg": {
    minWidth: 992,
    maxWidth: 1199
  },
  "screen-xl": {
    minWidth: 1200
  }
};
複製程式碼

一個有動態標題和自適應能力的基本佈局結構

<DocumentTitle title={this.getPageTitle()}>
   <ContainerQuery query={query}>
       {params => <div className={classNames(params)}>{layout}</div>}
    </ContainerQuery>
</DocumentTitle>

複製程式碼

Content

Content內展示路由元件的內容,我們使用<Switch>元件來包裹一組<Route><Switch>會遍歷自身的子元素(即路由)並對第一個匹配當前路徑的元素進行渲染。將nav.js中定義的關係資料傳入,生成這組Route結構。

      <Content style={{ margin: "24px 24px 0", height: "100%" }}>
            <Switch>
              {getRouteData("BasicLayout").map(item => (
                <Route
                  exact={item.exact}
                  key={item.path}
                  path={item.path}
                  component={item.component}
                />
              ))}
              <Route
                path={"/forbidden/:routerName"}
                component={ForbiddenPage}
              />
              <Redirect exact from="/" to={defaultRoute} />
              <Route component={PageNotFound} />
            </Switch>
        </Content>
複製程式碼

總結

本文總結了一個react-SPA後臺基本框架的設計過程,省略了很多設計細節,也不涉及狀態管理方面的框架選型,只是對自己思考過程的一個回顧,希望對感興趣的同學有幫助。

相關文章