2018年11月17日

[React] 搭配 React Router 打造一個動態麵包屑(dynamic breadcrumb)

這篇文章主要說明如何整合 react-router 來製作一個具有導覽功能的麵包屑(breadcrumb),也就是這個麵包屑可以根據當前使用者瀏覽的路由動態顯示出對應的名稱。由於會說明之所以這麼做的思路,因此篇幅較長;如果想要直接看如何使用這段程式碼,可以到 Github 上檢視 ReadMe,當中的說明較為精簡。
img
這篇文章不會從頭開始說明 React 和 React Router 的使用,因此建議閱讀前應該具備基本的 React 和 React Router 知識,不然可能會看得相當吃力。
來看看怎麼做吧!

建立 React 專案

在這裡我們直接使用 create-react-app 來建立一個簡單的 React 專案,就稱作 react-router-breadcrumb
$ create-react-app react-router-breadcrumb   # 透過 create-react-app 建立專案
$ cd react-router-breadcrumb                 # 進入建立好的專案資料夾
另外,需要使用到 react-router-dom 來幫我們建立路由:
$ npm install react-router-dom        # 安裝 react-router-dom
接著就可以啟動專案,然後到 localhost:3000 即可看到預設的畫面:
$ npm run start                # 啟動專案
img
在這篇文章中不會說明 create-react-app 的使用,若有需要可自參閱到 create-react-app 的官方文件。

前置清理與載入樣式

為了讓我們的畫面比較乾淨一些,就先直接套 Bootstrap 4 進來用,如果不想套的話也是可以,就是畫面會比較呆板一些。
/public/index.html 中把 Bootstrap 4 的 CDN 連結套用進來:
<!-- /public/index.html -->

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1, shrink-to-fit=no"
    />
    <meta name="theme-color" content="#000000" />
    <!-- import bootstrap here -->
    <link
      rel="stylesheet"
      href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css"
    />
    <title>React App</title>
  </head>
  <body>
    <noscript> You need to enable JavaScript to run this app. </noscript>
    <div id="root"></div>

    <!-- libs below are for bootstrap -->
    <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js"></script>
  </body>
</html>
接著把所有在 App.js 中預設的畫面都清掉,寫個「Hello React」確認沒問題就好:
// /src/App.js

import React, { Component } from 'react';

class App extends Component {
  render() {
    return <h1 className="text-primary">Hello React</h1>;
  }
}

export default App;
畫面長這樣空空的,而且因為套了 Bootstrap 中 text-primary 的樣式,文字有變色,就代表有成功載入 Bootstrap 了:
img
如果到這一步有問題的話,可以對照看看這個 commit

建立需要的頁面 pages 和 components

清理完後就可以來建立所需要的頁面。

建立頁面(Pages)

先把在這個專案中會使用的頁面建立起來,可以想像一個商城的結構大概是這樣,我們有三個外層的路由,分別是「首頁」、「書籍館」和「3C 商品館」,而 「3C 商品館」中又會細分出「手機館」、「桌機館」和「筆電館」,從這樣的結構可以看出,將會使用到嵌套式路由(Nested Routing)

- Home       # 首頁
- Books      # 書籍館
- Electronics    # 3C 商品館
--- Mobile       # 手機館
--- Desktop      # 桌機館
--- Laptop       # 筆電館
因為頁面(Page)的內容不是我們的重點,所以在本文中把所有的頁面組件(Page component)都寫在一支 pages.js 的檔案中。
// /src/pages.js

import React from 'react';

/**
 * These are root pages
 */
const Home = () => {
  return <h1 className="py-3">Home</h1>;
};

const Books = () => {
  return <h1 className="py-3">Books</h1>;
};

const Electronics = () => {
  return <h1 className="py-3">Electronics</h1>;
};

/**
 * These are pages nested in Electronics
 */
const Mobile = () => {
  return <h3>Mobile Phone</h3>;
};

const Desktop = () => {
  return <h3>Desktop PC</h3>;
};

const Laptop = () => {
  return <h3>Laptop</h3>;
};

export { Home, Books, Electronics, Mobile, Desktop, Laptop };
對照目前的 commit

建立基本的路由

再來我們先建立基本的路由,以便透過輸入網址連到這些頁面。
index.js 中,先載入 BrowserRouterSwitch
// /src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter, Switch } from 'react-router-dom';
import './index.css';
import App from './App';

ReactDOM.render(
  <BrowserRouter>
    <Switch>
      <App />
    </Switch>
  </BrowserRouter>,
  document.getElementById('root')
);
App.js 中,把當初 create-react-app 建立但用不到的內容都砍掉,只需要指定不同的路由應該要對應到哪些頁面就好,對於 <Route /> 這個組件的概念不用想得太複雜,簡單理解成就是當瀏覽器網址列的 URL 和這個 path 相匹配到時,就會在「這個位置」顯示該 component
// /src/App.js
import React, { Component } from 'react';
import { Route } from 'react-router-dom';
import { Index, Books, Electronics } from './pages';

class App extends Component {
  render() {
    return (
      <div className="container">
        {/* The corresponding component will show here if the current URL matches the path */}
        <Route path="/" exact component={Index} />
        <Route path="/books" component={Books} />
        <Route path="/electronics" component={Electronics} />
      </div>
    );
  }
}

export default App;
這時候當你在網址列輸入 /, /books, /electronics,應該就能順利看到那些頁面。
img
對於 <Route /> 這個組件的概念不用想得太複雜,簡單來說就是當瀏覽器的 URL 和這個 path 相匹配到時,就會在「這個位置」載入該 component
這時候因為還沒配置嵌套式路由的緣故,因此輸入 /electronics/mobile 時還不會找到相對應的頁面,依照同樣的概念,我們可以在 Electronics 這個 Page 中加入 <Route /> 組件,一旦當前瀏覽器網址列上的 URL 和 <Route /> 中的 path 相配對時,就會在「這個位置」顯示出所指定的頁面
因此,在 pages.js 中的 Electronics 組件中,加上路由:
// /src/page.js
import { Switch, Route } from 'react-router-dom';

// ...

const Electronics = () => {
  return (
    <div>
      <h1>Electronics</h1>
      <Switch>
        {/* The component will show here if the current URL matches the path */}
        <Route path="/electronics/mobile" component={Mobile} />
        <Route path="/electronics/desktop" component={Desktop} />
        <Route path="/electronics/laptop" component={Laptop} />
      </Switch>
    </div>
  );
};

// ...
這時候當我們在輸入網址列 /electronics/mobile 時,也會出現相對應的畫面:
img
  • 關於路由的配置可進一步參考 React Router 官方文件。
  • 如果撰寫過程中有問題,可以和此 commit 對照。

建立導覽列組件

每一次都要從網址列輸入網址實在有點麻煩,既然路由都配置好了,先來做個導覽列方便使用吧。
為了方便示範,而且這個專佔中不會有太多的 React 組件,我們把在頁面中會套用到的組件都放在一隻叫做 components.js 的檔案中。
Navbar 基本上就是直接套用 Bootstrap 4 Navbar 的結構和樣式,並且搭配 react-router-dom<Link> 來建立連結:
// /src/components.js

import React from 'react';
import { Link } from 'react-router-dom';
import logo from './logo.svg';

const Navbar = () => {
  return (
    <nav className="navbar navbar-expand-sm navbar-light bg-light">
      <Link className="navbar-brand" to="/">
        <img src={logo} alt="react-router-breadcrumb" width="30" height="30" />
      </Link>

      <button
        className="navbar-toggler"
        type="button"
        data-toggle="collapse"
        data-target="#navbarContent"
        aria-controls="navbarContent"
        aria-expanded="false"
        aria-label="Toggle navigation"
      >
        <span className="navbar-toggler-icon" />
      </button>

      <div className="collapse navbar-collapse" id="navbarContent">
        <ul className="navbar-nav">
          <li className="nav-item">
            <Link className="nav-link" to="/">
              Home
            </Link>
          </li>
          <li className="nav-item">
            <Link className="nav-link" to="/books">
              Books
            </Link>
          </li>
          <li className="nav-item dropdown">
            <Link
              className="nav-link dropdown-toggle"
              to="/electronics"
              id="navbarDropdownMenuLink"
              role="button"
              data-toggle="dropdown"
              aria-haspopup="true"
              aria-expanded="false"
            >
              Electronics
            </Link>
            <div
              className="dropdown-menu"
              aria-labelledby="navbarDropdownMenuLink"
            >
              <Link className="dropdown-item" to="/electronics/mobile">
                Mobile Phone
              </Link>
              <Link className="dropdown-item" to="/electronics/desktop">
                Desktop PC
              </Link>
              <Link className="dropdown-item" to="/electronics/laptop">
                Laptop
              </Link>
            </div>
          </li>
        </ul>
      </div>
    </nav>
  );
};

export { Navbar };
接著在 <App /> 組件中載入 <Navbar /> 即可:
// /src/App.js

import React, { Component } from 'react';
import { Route } from 'react-router-dom';
import { Home, Books, Electronics } from './pages';
import { Navbar } from './components';

class App extends Component {
  render() {
    return (
      <div className="container">
        {/* Put Navbar Here */}
        <Navbar />

        <Route path="/" exact component={Home} />
        <Route path="/books" component={Books} />
        <Route path="/electronics" component={Electronics} />
      </div>
    );
  }
}

export default App;
到目前為止完成畫面差不多完成了:
img
如果撰寫過程中有問題,可以和此 commit 對照。

把麵包屑名稱帶入路由當中

碰到的困難

到目前為止,已經可以根據 react-router 顯示出相對應的頁面。一般來說,這樣的路由配置是沒有問題的,但這樣做在製作麵包屑時會碰到一個問題,我們將無法知道每一個對應到的 path 它的麵包屑名稱是什麼,什麼意思呢?
例如,當 path 是 /electronics/desktop 時,希望麵包屑名稱會顯示「Desktop PC」;當 path 為 /electronics 時,麵包屑名稱則要顯示「Electronics」,這些麵包屑的名稱是無法直接從路由的 path 看出來的。
直覺上,透過 React Router 提供的 render 方法,我們可以把麵包屑的名稱當做 props 傳到對應的 Page 當中,像下面這樣:
/**
 * Although we can pass breadcrumb name into Page component
 * through `render` method provided by React Router.
 *
 * However, we can only get the current Page breadcrumb name
 * but not the breadcrumb name of it's parent in nested routing.
 **/
<Route
  path="/books"
  render={(props) => <Books breadcrumbName="books" {...props} />}
/>
但這樣做會有個問題,以 /electronics/desktop 為例,當透過 propsbreadcrumbName 傳到該組件中時,雖然到路由 /electronics/desktop 時我們可以取得這個 Page 的麵包屑名稱為 "Desktop PC",但是我們沒辦法知道 /electronics 的麵包屑名稱是什麼,然而,麵包屑需要顯示的樣子應該要會像這樣:
Home > Electronics > Desktop PC
因此直接透過 props 把 breadcrumbName 傳入頁面中似乎不能達到想要的功能。

解決方法一:定義路由表(堪用)

第一種解決方式是定義一個路由表(在這裡不使用),在 Ant Design 麵包屑組件Other Router Integration 中,說明了一種解法,就是先定義好路由表,接著再去把網址列當前的 URL 去跟這個定義好的路由表匹配,就可以知道每一個路由應該要顯示的麵包屑名稱為何。
定義好的路由表會長像這樣:
const breadcrumbNameMap = new Map([
  //  [path, breadcrumbName]
  ['/', 'Home'],
  ['/books', 'Book'],
  ['/electronics', 'Electronics'],
  ['/electronics/mobile', 'Mobile'],
  ['/electronics/desktop', 'Desktop'],
  ['/electronics/laptop', 'Laptop']
]);
接著就可以去把當前網址列的 URL 和這個路由表匹配,以產生麵包屑,詳細的做法可以參考 Ant Design 麵包屑組件Other Router Integration
但這麼做的麻煩之處在於,每當我們要添加路由時,除了先透過 <Route path="/" component={Home} /> 撰寫好路由後,還需要把這個新的路由添加到路由表中,如果忘了加,麵包屑就出不來。
沒辦法寫一次就直接套用覺得有些麻煩,因此後來我們決定不這麼用。

解決方法二:集中式路由設定管理(建議)

為了不要額外建立一個路由表,勢必要把路由所對應到的麵包屑名稱,集中設定在一個地方,而這種集中式路由管理的方式可以方便我們在一個地方把路由和麵包屑名稱都寫好。
關於集中式路由設定的寫法可以參考 React Router 的官網範例 Route Config
於是我們要來重新組織一下路由,把它變成集中式的路由設定,並且可以把每一路由對應到的麵包屑名稱直接填入。
先建立一個名為 routes.js 的檔案,統一將路由定義在這裡:
// /src/routes.js

import { Home, Books, Electronics, Mobile, Desktop, Laptop } from './pages';

const routes = [
  {
    path: '/',
    component: Home,
    exact: true,
    breadcrumbName: 'Home'
  },
  {
    path: '/books',
    component: Books,
    breadcrumbName: 'Book'
  },
  {
    path: '/electronics',
    component: Electronics,
    breadcrumbName: 'Electronics',
    routes: [
      {
        path: '/electronics/mobile',
        component: Mobile,
        breadcrumbName: 'Mobile Phone'
      },
      {
        path: '/electronics/desktop',
        component: Desktop,
        breadcrumbName: 'Desktop PC'
      },
      {
        path: '/electronics/laptop',
        component: Laptop,
        breadcrumbName: 'Laptop'
      }
    ]
  }
];

export default routes;
接著在有使用 <Route /> 組件的地方,原本是寫死在裡面的,現在改成用這個路由設定來產生,例如原本的 App.js 中路由 <Route />的部分是這樣寫:
// /src/App.js

// ...
class App extends Component {
  render() {
    return (
      <div className="container">
        <Navbar />

        <Route path="/" exact component={Home} />
        <Route path="/books" component={Books} />
        <Route path="/electronics" component={Electronics} />
      </div>
    );
  }
}
// ...
export default App;
可以改成:
// /src/App.js

import routes from './routes';

class App extends Component {
  render() {
    return (
      <div className="container">
        <Navbar />

        {/* Refactor for using routes config */}
        {routes.map((route, i) => {
          const { path, exact, routes } = route;
          return (
            <Route
              key={i}
              path={path}
              exact={exact}
              render={(routeProps) => (
                <route.component routes={routes} {...routeProps} />
              )}
            />
          );
        })}
      </div>
    );
  }
}

export default App;
  • 我們先把寫好的路由設定(route config)透過 import 載入進來。
  • 接著把在 routes 設定檔中寫好的 path, exact 透過 props 傳進去 <Route path={path} exact={exact} />
  • 對於有使用到嵌套式路由的頁面,為了要把嵌套在內的 routes 傳到該頁面內,我們不能直接寫 <Route component={PageComponent} /> ,因為這種寫法無法把資料透過 props 傳到頁面內。因此需要使用 React-Router 中另外提供的 render 屬性。
  • render 屬性中需要代入一個函式,並回傳要渲染的頁面,例如, render={() => <PageComponent />} ,這種寫法可以把資料透過 props 傳到某一 Page 當中。
  • 如果 routes 裡面還有 routes 表示它是嵌套式路由(nesting routes),一層路由裡還有其他路由,這時候要把它當成該頁面的組件傳進去,所以會有 render={() => <PageComponent routes={routes} />} 的寫法。
  • render 屬性後面接的這個函式中,可以接收一個參數,我們把這個參數取名為 routePropsrouteProps 會傳回原本在 <Route /> 中可以拿到的 match, location, history 等屬性。接著透過 {...routeProps} 可以在把這些屬性注回到 Page 當中。寫起來會是這樣, render={(routeProps) => <PageComponent routes={routes} {...routeProps}/>}
  • 最後, <route.component /> 可以動態指定要渲染的 Page 為何。
如果你覺得上面這樣的寫法太複雜了,你還無法理解,可以先跳過繼續往後閱讀沒關係。
同樣的,因為在 Electronics 頁面中也有使用到 <Route> 組件,因此也可以改成這樣的寫法:
// /src/pages.js

// ...
// Get routes props from Electronics Page
const Electronics = ({ routes }) => {
  return (
    <div>
      <h1 className="py-3">Electronics</h1>

      <Switch>
        {/* Refactor for using routes config */}
        {routes.map((route, i) => {
          const { path, exact, routes } = route;
          return (
            <Route
              key={i}
              path={path}
              exact={exact}
              render={(routeProps) => (
                <route.component routes={routes} {...routeProps} />
              )}
            />
          );
        })}
      </Switch>
    </div>
  );
};
// ...
  • 首先把在 App.js 時透過 React Router render={() => <PageComponent routes={routes} />} 傳進來的 routes 拿出來。
  • 和上面使用一樣的方法,透過 {routes.map()} 去把所有相關的 <Route /> 組件組出來。
改成這樣之後,路由還是可以正常切換。
  • 如果不能切換,可能是有哪裡的程式碼打錯了,可以對照參考一下這個 commit
  • 關於集中式路由設定的寫法可以參考 React Router 的官網範例 Route Config

乾淨清爽:使用 react-router-config

或許你會覺得在每個頁面中,都要透過 {routes.map()} 這一大塊程式碼,才能渲染原本的路由很麻煩,好在當我們定義集中式路由之後,React Router 提供了我們 react-router-config 這個套件,這裡面提供了 renderRoutes 這個方法可以幫我們省去寫一大段程式碼的麻煩,而它的原理和我們剛剛實作的方法是很類似的。
$ npm install react-router-config
安裝好之後就可以來整理一下上面的程式碼,首先是 App.js
// /src/App.js
import React, { Component } from 'react';
import { renderRoutes } from 'react-router-config';
import { Navbar } from './components';
import routes from './routes';

class App extends Component {
  render() {
    return (
      <div className="container">
        <Navbar />

        {/* use renderRoutes method here*/}
        {renderRoutes(routes)}
      </div>
    );
  }
}

export default App;
  • 記得先 import renderRoutes,再把 routes 帶進去,{renderRoutes(routes)},它就會幫你產出相對應的 <Router /> 組件。
Electronics 頁面中的寫法和剛剛類似,只有些微不同,不是直接拿 routes ,因為它把 routes 又包在 route 內,因此是拿 route.routes
import React from 'react';
import { renderRoutes } from 'react-router-config';
import { Nav, ElectronicsNav } from './components';

// ...
const Electronics = ({ route }) => {
  return (
    <div>
      <h1 className="py-3">Electronics</h1>

      {renderRoutes(route.routes)}
    </div>
  );
};
// ...
一整個乾淨清爽的感覺,是不是覺得精簡非常多啊!你可能會想為什麼不早點把這好東西拿出來!?哎呀,了解一下背後的原理也是不錯的嘛。
如果畫面以及路由切換都和剛剛一樣正常運作的話,就表示程式碼應該沒什麼問題。

根據路由取得麵包屑名稱

為了要取得每一個路由所對應到的麵包屑名稱,我們把路由的寫法改成路由設定檔(route config)的方式,接下來就要在特定的路由下取得麵包屑的名稱。

取得當前瀏覽器網址列的路由:location

每個透過 <Route /> 組件產生的頁面,都會帶有透過 React Router 添加的屬性,可以透過該頁面的 props 屬性取得,其中包含 history, location, matchroute

嵌套式路由:以 Mobile Page 為例

舉例來說,在 Mobile 這個頁面中,可以把 props 透過 console.log 顯示出來看一下:
// /src/pages.js

// ...
const Mobile = (props) => {
  console.log('props in Mobile', props);
  return <h3>Mobile Phone</h3>;
};

// ...
當在瀏覽器的導覽列輸入 localhost:3000/electronics/mobile 時,可以在 console 中看到 React Router 添加的屬性,其中 location.pathname 屬性可以讓我們知道當前瀏覽器網址列所在的路徑為何:
img
route 屬性則是來自當初設定好的路由配置,因此在這裡可以看到添加進去的 breadcrumbName 屬性。也就是說從該頁面的 props 就可以知道它的 breadcrumbName 為何
img

取得當前路由的外層路由名稱:matchRoutes()

從上面的例子可以看到,雖然直接根據頁面內的 route.breadcrumbName 屬性就知道該頁面的麵包屑名稱是什麼。但是「嵌套式路由」中除了需要知道當前路由的麵包屑名稱外,還需要知道它上一層的名稱。
例如,當網址當前的路由是 /electronics/mobile 時,雖然可以知道這個 Page 的名稱是 Mobile Phone,但同時還需要知道 /electronics 的麵包屑名稱是 Electronics,因為麵包屑組起來是這樣的:
Home > Electronics > Mobile Phone
這時候我們需要使用到 react-router-config 提供的另一個方法,稱作 matchRoutes
matchRoutes 基本的使用方式像這樣,前面放當初定義好的路由設定檔,後面則放當前網址列的路由:
matchedRoutes = matchRoutes(routes, pathname);
把它寫到最到 Electronics 頁面中 console.log() 出來看看:
// /src/pages.js
import { renderRoutes, matchRoutes } from 'react-router-config';
import routes from './routes';

// ...
const Electronics = ({ route, location }) => {
  const matchedRoutes = matchRoutes(routes, location.pathname);
  console.log('matchedRoutes in Electronics', matchedRoutes);

  return (
    <div>
      <h1 className="py-3">Electronics</h1>
      {renderRoutes(route.routes)}
    </div>
  );
};
// ...
當在瀏覽器導覽列輸入 localhost:3000/electronics/mobile 時,從 console 的結果可以看到,透過 matchRoutes 這個方法,除了可以拿到當前路由的 breadcrumbName 外,還可以把上一層路由的麵包屑名稱也拿到,也就是說,透過 matchRoutes 取得的資訊,就可以幫助我們組出麵包屑了:
img
簡單的把 Electronics 頁面改一下,就可以製作出我們想要的麵包屑了:
// /src/pages.js
import { renderRoutes, matchRoutes } from 'react-router-config';
import { Link } from 'react-router-dom';
import routes from './routes';

// ...

const Electronics = ({ route, location }) => {
  const matchedRoutes = matchRoutes(routes, location.pathname);

  return (
    <div>
      <h1 className="py-3">Electronics</h1>

      {/* Breadcrumb */}
      <nav>
        <ol className="breadcrumb">
          {matchedRoutes.map((matchRoute, i) => {
            const { path, breadcrumbName } = matchRoute.route;

            return (
              <li key={i} className="breadcrumb-item">
                <Link to={path}>{breadcrumbName} </Link>
              </li>
            );
          })}
        </ol>
      </nav>

      {renderRoutes(route.routes)}
    </div>
  );
};

// ...
  • 透過 matchRoutes() 可以取得所有從該網址 URL 開始,向上層推算的所有路由的麵包屑名稱
  • 將配對出的 matchedRoutes 透過 map 來跑迴圈,疊代出每一個麵包名稱(breadcrumbName)和路由的路徑( path)。
現在,當你在 localhost:3000/electronics/ 路由內的頁面就都可以看到麵包屑了:
img
因為我們在 HomeBooks 頁面都還沒放麵包屑進去,所以在那兩個頁面自然還不會看到麵包屑,再把麵包屑放入 HomeBooks 頁面前,先再把這個麵包屑優化一下。

優化麵包屑

最後一個麵包屑不要是連結

在剛剛的畫面中,你會發現即使已經在 Mobile 這一頁,Mobile Phone 的麵包屑仍然是可以點擊的連結,但一般來說當使用者已經在這個頁面時,該麵包屑就不該還可以被點擊:
img
這裡我們可以簡單判斷,先定一個名為 isActive 的變數,如果當前網址列的 URL 和 matchedRoutesroutepath 一樣時(location.pathname === route.path),表示這個配對到的路由就是使用者目前所在的頁面,isActive 會是 true,這時候就不要使用 <Link /> 產生連結。大概是這樣:
// /src/pages.js
// ...

const Electronics = ({ route, location }) => {
  //...
  <nav>
    <ol className="breadcrumb">
      {matchedRoutes.map((matchRoute, i) => {
        const { path, breadcrumbName } = matchRoute.route;

        // check whether the the path is the Page path user currently at
        const isActive = path === location.pathname;

        // if the Page path is user currently at, then do not show <Link />
        return isActive ? (
          <li key={i} className="breadcrumb-item active">
            {breadcrumbName}
          </li>
        ) : (
          <li key={i} className="breadcrumb-item">
            <Link to={path}>{breadcrumbName} </Link>
          </li>
        );
      })}
    </ol>
  </nav>;
  // ...
};

// ...
這時候使用者當前所在頁面的麵包屑就不會亮起,也不可點擊:
img
如果到這一步有問題的話,可以比對看看這個 commit

客製化麵包屑內容

到目前為止麵包屑的功能已經差不多了,但還有一個可以優化的地方,像是這頁如果我們想在 Electronics 前面多一個 Home 的麵包屑,像是這樣 Home / Electronics / Mobile Phone 該怎麼辦呢?
方法不難,既然我們可以是透過 matchedRoutes 透過迴圈去跑出所有的麵包屑,只要我們改一下 matchedRoutes 這個陣列的內容,自然可以客製化出想要的麵包屑
// /src/pages.js

const Electronics = ({ route, location }) => {
  let matchedRoutes = matchRoutes(routes, location.pathname);

  // Customize breadcrumb through modifying matchRoutes array
  matchedRoutes = [
    {
      route: {
        path: '/',
        breadcrumbName: 'Home'
      }
    },
    ...matchedRoutes
  ];

  return (
    // ...
  );
};
在原本的 matchedRoues 陣列中,多添加了一個 route 物件,如此在稍後產生麵包屑的時候,自然就會多 Home 的麵包屑:
img
如果到這一步有問題的話,可以比對這個 commit

建立麵包屑組件

寫到這裡已經完成了麵包屑,最後因為在 Home, Books 頁面中也都會使用到麵包屑,因此可以把剛剛寫在 Electronics 頁面中的麵包屑拆成一個組件,直接套用到其他有需要使用的頁面即可:
// /src/pages.js

// ...
const Electronics = ({ route, location }) => {
  return (
    <div>
      <h1 className="py-3">Electronics</h1>

      {/* move to component */}
      <Breadcrumb locationPath={location.pathname} />

      {renderRoutes(route.routes)}
    </div>
  );
};
//...
把原本的內容放到 components.js 中:
// /src/components.js
import { matchRoutes } from 'react-router-config';
import routes from './routes';
// ...

const Breadcrumb = ({ locationPath }) => {
  let matchedRoutes = matchRoutes(routes, locationPath);

  return (
    <nav>
      <ol className="breadcrumb">
        {matchedRoutes.map((matchRoute, i) => {
          const { path, breadcrumbName } = matchRoute.route;
          const isActive = path === locationPath;

          return isActive ? (
            <li key={i} className="breadcrumb-item active">
              {breadcrumbName}
            </li>
          ) : (
            <li key={i} className="breadcrumb-item">
              <Link to={path}>{breadcrumbName} </Link>
            </li>
          );
        })}
      </ol>
    </nav>
  );
};

export { Navbar, Breadcrumb };
這時候因為已經把 breadcrumb 抽成一個 component,所以剛剛透過修改 matchedRoutes 來客制化麵包屑的方式不能寫死在 <Breadcrumb /> 中,而是應該要可以在不同的頁面客製化出不同的麵包屑內容。
因此,我們在 <Breadcrumb /> 組件中新增一個名為 onMatchedRoutes 的 callback function:
// /src/components.js

// ...
// User can use the onMatchedRoutes callback to modify breadcrumb
const Breadcrumb = ({ locationPath, onMatchedRoutes }) => {
  let matchedRoutes = matchRoutes(routes, locationPath);

  if (typeof onMatchedRoutes === 'function') {
    matchedRoutes = onMatchedRoutes(matchedRoutes);
  }

  return (
    // ...
  );
};

// ...
讓使用者在使用頁面中使用這個組件時,還有機會去修改要顯示的麵包屑為何:
// /src/pages.js

// ...
const Electronics = ({ route, location }) => {
  // Provide a function as props into <Breadcrumb /> to modify breadcrumb
  const onMatchedRoutes = (matchedRoutes) => {
    return [
      {
        route: {
          path: '/',
          breadcrumbName: 'Home'
        }
      },
      ...matchedRoutes
    ];
  };

  return (
    <div>
      <h1 className="py-3">Electronics</h1>

      {/* Pass onMatchedRoutes function as props here */}
      <Breadcrumb
        locationPath={location.pathname}
        onMatchedRoutes={onMatchedRoutes}
      />

      {renderRoutes(route.routes)}
    </div>
  );
};
如果到這一步有問題的話,可以對照這個 commit

套用麵包屑組件

寫到這裡就大功告成拉!最後我們把 <Breadcrumb /> 也放到 HomeBooks 中:
// /src/pages.js

// ...
const Home = ({ location }) => {
  return (
    <div>
      <h1 className="py-3">Home</h1>
      <Breadcrumb locationPath={location.pathname} />
    </div>
  );
};

const Books = ({ location }) => {
  const onMatchedRoutes = (matchedRoutes) => {
    return [
      {
        route: {
          path: '/',
          breadcrumbName: 'Home'
        }
      },
      ...matchedRoutes
    ];
  };

  return (
    <div>
      <h1 className="py-3">Books</h1>
      <Breadcrumb
        locationPath={location.pathname}
        onMatchedRoutes={onMatchedRoutes}
      />
    </div>
  );
};

// ...
完成後的畫面就像這樣子,麵包屑可以根據路由自動變換,如果有需要客製化添加麵包屑的地方,也可以透過 onMatchedRoutes 這個 callback function 來修改:
img
如果到這一步有問題的話,可以對照參考這個 commit

參考

2018年8月31日

[JS] 使用 JavaScript 解析網址與處理網址中的參數(URL Parameters)

keywords: parse URL, parameter, URL, query string
由於前後端分離的趨勢,很多的前端網頁都是透過 API 的方式向後端要資料,而最普遍的就是使用 GET 方法在網址後面加上「參數(parameters)」或稱「查詢字串(query string)」來向後端索取資料。
Imgur
由於在瀏覽器內建用來發送 AJAX 請求的 Fetch API 似乎除了把整個 URL 代進去之外,好像沒有看到其他比較漂亮用來設定網址參數的方法,但是網址參數的部分有時候真的是又臭又長,把它整個放進去實在不太好閱讀
找了一下,發現其實不需要額外裝什麼套件就可以處理網址的解析和網址參數的設定,瀏覽本身提供了 URLURLSearchParams 的 WebAPIs 可以使用。
// 直接把網址參數代入
fetch('https://github.com/search?q=react&type=Code')
  .then(function(response) {
    // Do something ...
  })
如果對於網址各部分的名稱不太清楚,可以參考這篇:網址 URL 英文大小寫是否有差別?
讓我們來看一下可以怎麼使用這些 API 來解析網址和設定網址中的參數

解析網址(Parsing URL)

要解析網址的話,可以使用瀏覽器內建的 URL 這個 Web API,使用的方式很簡單,把網址代進去 URL 建構式就可以了:
let githubURL = new URL('https://github.com/search?q=react&type=Code');
建立好了之後就可以使用幾個不同的屬性來取得網址的內容:
// 取得完整網址(URL)
githubURL.href;      // "https://github.com/search?q=react&type=Code"

// 取得網址中的主機名稱
githubURL.hostname;     // "github.com"

// 取得網頁路徑部分
githubURL.pathname;   // "/search"

// 取得網址中的通訊協定部分
githubURL.protocol;    // "https:"

// 取得網頁參數部分
githubURL.search;     // "?q=react&type=Code"
githubURL.searchParams;  // URLSearchParams {}
透過把網址輸入 URL 建構式之後,瀏覽器幾乎把所有網址的部分都解析完了,透過 githubURL.search 這個方法雖然可以取得完整的網址參數部分,但是還沒有把內容完全解析出來,這時候可以使用 githubURL.searchParams 這個物件。

URLSearchParams

githubURL.searchParams 這個物件是透過另一個名為 URLSearchParams 的 Web API 所建立的,透過這個 API 可以很方便的幫去設定、刪除和讀取網址字串的部分。
想要檢視這個 URLSearchParams 物件最簡單的方是直接使用 toString() 方法:
// 檢視 URLSearchParams 物件
githubURL.searchParams.toString();      // "q=react&type=Code"
假設我們要取得解析後的網址參數,只需要使用 .entries() 方法搭配 for ... of 就可以:
let params = githubURL.searchParams;
for (let pair of params.entries()) {
  console.log(`key: ${pair[0]}, value: ${pair[1]}`)
}
如此就可以把整個網址參數的部分解析出來:
key: q, value: react
key: type, value: Code
除了可以把網址參數的部分解析出來外,它還提供了像是 .has(<key>).get(<key>) 的方法,可以檢驗特定的參數是否存在,並取得其值:
let params = githubURL.searchParams;
params.has('q');    // true
params.get('q');    // "react"
其實如果你對於 ES6 當中的 Map 物件有印象的話,你會發現 URLSearchParams 的用法就和 Map 的用法大同小異。

設定網址參數(URL parameters)

除了前面提過的, URLSearchParams 物件可以解析網址參數之外,它還可以用來設定網址參數,有幾種不同的設定方式,這幾種寫法會得到一樣的結果:
let githubURL = new URL('https://github.com/search');

// 方法一:直接寫入
var searchParams = new URLSearchParams('q=react&type=Code');

// 方法二:代入陣列
var searchParams = new URLSearchParams([['q', 'react'], ['type', 'Code']]);

// 方法三:代入物件
var searchParams = new URLSearchParams({q: 'react', type: 'Code'});

// 都會得到一樣的結果
searchParams.toString() // "q=react&type=Code"
設定好網址參數後最後我們只需要把這個 URLSearchParams 物件代入原本的 URL 物件中就可以了:
githubURL.search = searchParams;
githubURL.href; // "https://github.com/search?q=react&type=Code"
另外,在 URLSearchParams 中,還提供了 .set(), .append(), .sort(), .delete() 的方法可以讓你才操作這些網址參數,有興去的話可以到 MDN 上看一下。

實作

解析網址中的網址參數

假設我們想要解析這段網址:
https://www.google.com.tw/search?hl=zh-TW&as_q=react&&lr=lang_zh-TW&cr=countryTW&as_qdr=all&as_occt=any
const googleSearchURL = new URL('https://www.google.com.tw/search?hl=zh-TW&as_q=react&&lr=lang_zh-TW&cr=countryTW&as_qdr=all&as_occt=any');

// 透過物件的解構賦值,取出 URL 物件的屬性值
const { href, protocol, hostname, pathname, search, searchParams } = googleSearchURL;

// 透過陣列的解構賦值,取得網址參數部分
for(let [key, value] of searchParams.entries()) {
  console.log(`key: ${key}, value: ${value}`)
}

// 取得所有 key-value,回傳陣列
[...searchParams];

設定網址參數代入 fetch API 中

原本要直接把整個網址代入 fetch API 內:
// 原本的寫法
const googleURL = 'https://www.google.com.tw/search';
const searchParams = 'as_occt=any&as_q=react&as_qdr=all&cr=countryTW&hl=zh-TW&lr=lang_zh-TW';

// fetch API
fetch(`${googleURL}?${searchParams}`).then();
使用 URLSearchParams 後可以把參數整理的比較乾淨再放入:
const googleURL = new URL('https://www.google.com.tw/search');
let searchParams = new URLSearchParams({
  as_occt: "any",
  as_q: "react",
  as_qdr: "all",
  cr: "countryTW",
  hl: "zh-TW",
  lr: "lang_zh-TW"
});

googleURL.search = searchParams;

// fetch API
fetch(googleURL.href).then();

參考資料

  • URL @ MDN > Web technology for developers > Web APIs
  • URL SearchParams @ MDN > Web technology for developers > Web APIs

2018年6月30日

[Guide] 瞭解網頁中看不懂的編碼:Unicode 在 JavaScript 中的使用

keywords: unicode, utf-8, JavaScript
如果只是想要簡單知道 Unicode 是什麼,沒有要瞭解使用方式的話,可以觀看計算機科學速成課(第四集):二進制 ,在此教學影片的後半段有提到文字符號的編碼概念。
在學習網頁開發的過程中,一定會慢慢的碰到所謂的 Unicode, UTF-8 還有其他幾種不同的編碼方式,這麼說你可能不會太有感覺,讓我們以 Facebook 為例,你可以看到在資料傳遞的過程中,常常有這種看不太懂的東西...
Imgur
我們把它拿出來編排一下大概長這樣:
"profiles": {
  "768320183253420": {
    "name": "PJCHENder\u7db2\u9801\u524d\u7aef\u8cc7\u6e90\u7ad9",
    "firstName": "PJCHENder\u7db2\u9801\u524d\u7aef\u8cc7\u6e90\u7ad9",
    "businessID": 0,
    "businessName": null,
    "allBusinessData": [
    {
      "businessID": 0,
      "businessName": null
    }]
  },
  // ...
}
你可以看到裡面有 \u7db2\u9801\u524d\u7aef\u8cc7\u6e90\u7ad9 這種看不太懂的內容。
再舉一個例子來看,有些時候我們可能看到某些網路文章想要分享給別人,明明網址是:https://today.line.me/tw/pc/article/冰火之國+冰島-ownNlj,可是一分享出去卻變成一堆看似亂碼的東西 https://today.line.me/tw/pc/article/%E5%86%B0%E7%81%AB%E4%B9%8B%E5%9C%8B+%E5%86%B0%E5%B3%B6-ownNlj
上面的這些例子都是文字轉成編碼後的情況,你應該可以瞭解到這些編碼在瞭解網頁開發時的重要性的,如果不懂的話,就無法把這些內容解碼回去,那就會看不懂別人的內容阿...
Imgur
看到編碼卻不知道怎麼解碼,那該怎麼上車呢?
在這篇文章中會說明在網頁中常用的一些編碼方式,讓對網頁編碼沒有概念的捧油們可以對它有些概念,重點是對它不再畏懼,然後有興趣的話可以在透過延伸閱讀中的文章進一步瞭解更多細節。

什麼是字串編碼(String Encode)?

先來談談什麼是字串編碼,字串編碼簡單來說,就是把我們熟悉的文字用許多數字來代表它,你可以想像就像在當兵或坐牢的時候,大家不會直接叫你的名字,而是叫你的編號
例如,「阿童」在坐牢時的編號是「9453」,這時候只要廣播「9453」請到司令台集合,阿童就會知道這是在叫他,就會跑到司令台來了。
這個編號是不會重複的,也就是「9453」就只會是「阿童」的編號,不會同時有其他淡水阿婆、基隆阿公的編號也一樣是 9453。
能夠代表你的身份證字號也是一種編碼的概念,這個編號就可以用來代表它指稱的是你。

電腦只認得數字

接著你可能會好奇,在監獄裡面因為方便管理、去個人化等因素,所以把人名改成編碼似乎合理,但為什麼需要把我們常用的文字也都變成編碼呢?
這是因為電腦在運算的過程中,底層都還是數字來表示的,電腦只看得懂數字,但並不認得文字符號。也就是它只看的懂「9453」這個數字,但並不認得「阿童」這些字。
更精確的說,電腦只認得「二進制」這種用 0 和 1 組成的數字。
由於電腦只認得數字,但是我們人類看得懂、使用的卻是文字,那麼該怎麼辦呢?於是,美國最早定義了一套文字符號的編碼,能夠將每個英文的文字符號(Symbol)都對應到一個數字,這個最早統一的規定就稱為「ASCII 編碼」
文字符號(Symbol)指的是可以把文字拆成的最小單位,像是英文的「字母」 a, b, c 都是文字符號;而中文的「國字」,像是 , , 這些也都是文字符號。

ASCII 編碼(ASCII Code)

ASCII Code 是由美國制訂,最早用來統一英文文字符號和數字間的對應關係,除了 52 個大小寫英文字母外,其中也包含了常用的符號(如 !, <, = )和特殊字元,總共定義了 127 個關係。
舉例來說,從下面的 ASCII Table 中可以看到,最上面的地方寫了 Dec 和 Char,其中 Char 就是文字符號的部分,Dec 則是用來表示該文字符號的十進位數字,在這張表中可以看到大寫英文字母的 A 對應到的編號竟是十進位的 65 號,小寫英文字母 a 則是對應到十進位的 97 號:
Imgur
那麼 ASCII 的編碼方式就是我們最前面提到的, Facebook 中回傳的 \u9673\u67cf\u878d 內容嗎?答案是:「有關但不同」。

Unicode(萬國碼)

在最前面所提到 Facebook 中回傳的 \u9673\u67cf\u878d 其實用的是 Unicode 編碼?Unicode 編碼又是什麼呢?
你可以發現在 ASCII 中定義了 127 個文字符號和數字之間的關係,但是那是因為英文只需要有 26 個英文字母就可以組成各是各樣的單字,但是中文或者說許多國家的語言並不是這樣阿,很明顯的 ASCII Code 不適用於所有的語言
此外,如果不同國家使用不同的編碼來表徵自己的文字符號,又會變得非常不方便,例如,在台灣「9453」這個數字代表「阿童」,同樣的數字「9453」在美國代表「阿普」,在韓國「9453」則代表的是「金秘書」,這樣真的是非常的不方便阿!有沒有辦法讓全世界有一個共用的罪犯編號不會重複,讓 9453 就只能代表阿童呢?
於是,讓各語言的文字符號都能用一個數字代表它,而且這個數字是世界通用且不會重複的,就出現了所謂的 Unicode(萬國碼)這個編碼。Unicode 就像一個世界通用的大字典,收納了好幾百萬個全世界的文字符號,且每一個文字符號都有一個屬於自己的編號。不會同一個編號在台灣、美國、日本卻表示不同的文字符號。
Unicode 就像一個世界通用的大字典,收納了好幾百萬個全世界的文字符號,且每一個文字符號都有一個屬於自己的編號,在 Unicode 的官網 http://www.unicode.org/ 中列出了所有的 Unicode。

用來表示數值的不同方式:談談進位制

這裡我們要先稍微跳開一下文字編碼的部分,來談一下進位制。
因為電腦只認得數字(01),因此不論是 ASCII 或 Unicode,都是把數字和文字符號間做一對一的轉換,因此都可以把文字轉換成對應到的數字,但是用來表示數字的方式則有很多種,我們把它稱為進位制,像是二進制、八進制、十進制和十六進制等等。
由於在 Unicode 中多數使用十六進制的方式來表示一個數值,因此在這裡先簡單說明進位制的概念和轉換。

不同進位制的表示

在這裡我們不會說明進位制之間轉換的計算方式,而是說明如何用 JavaScript 或工具進行數字間不同進位制的轉換,先來簡單瞭解不同進位制的表示方式:
二進制(Binary;bin)只用 01 這兩個數字表示一個數值的方式就稱為二進制,因此最後要給電腦看的數值最終都將轉為二進制(Binary)。在 JavaScript 中,如果你要告訴程式這是一個二進制的數字,需在數字的最前面加上 0b
八進位(Octal;oct)只用 0, 1, ... 7 這八個數字來表示一個數值的方式稱為八進制。在 JavaScript 中,如果你要告訴程式這是一個八進制的數字,需在數字的最前面加上 00o
十進位(Decimal;dec):這是平常使用的數字表示法,只用 0, 1, ... 9 這十個數字來表示一個數值的方式稱為十進制。在 JavaScript 中,如果你要告訴程式這是一個十進制的數字,你什麼都不必做,直接輸入的數字就是表示十進制
十六進位(Hexadecimal;hex)只用 0, 1, ..., 9, A, B, C, D, E, F 這十六個是數字來表示一個數值的方式稱為十六進制,其中 A 表示十進制中的 10, B 表示十進制中的 11,F 表示十進制中的 15,以此類推。在色碼的表示上,也常使用十六進制的數字(如,#FF00AA )來表示一個顏色。在 JavaScript 中,如果你要告訴程式這是一個十六進制的數字,需在數字的最前面加上 0x
使用十六進制的好處是,可以使用較少的位數就能表示更多的數值,例如十六進制的兩位數的 FF 就表示在十進制的需要用三位數表示的 255
進位制(縮寫) 前綴法(JavaScript) 下標表示法
二進位(Binary;bin) 0b11111110 (11111110)2
八進位(Octal;oct) 0376, 0o376 (376)8
十進位(Decimal;dec) 254 (254)10
十六進位(Hexadecimal;hex) 0xfe (fe)16

使用 JavaScript 進行不同進位制間的轉換

將不同進位制的數值轉為十進位:Number()

在 JavaScript 中要把不同進位制的數值轉換成十進制非常容易,只需要在該數值前讓 JavaScript 知道該數值是什麼進位制(例如,0x, 0b)接著使用 Number() 函式即可
在下面的例子中,9453, 10010011101101, 22355, 24ed 這些不同進位制的數字在轉換後其實都是表示十進制的 9453
/* 使用 Number() 將不同進位制的數值轉為 10 進位 */

Number('9453')        // 回傳 9453,直接帶入就是 10 進制
Number('0b10010011101101')  // 回傳 9453,以二進制的的前綴法 0b 表示
Number('022355')     // 回傳 9453,以舊八進制的前綴法 0 表示
Number('0o22355')     // 回傳 9453,以新的八進制的前綴法 0o 表示
Number('0x24ed')      // 回傳 9453,以十六進制的的前綴法 0x 表示
你會發現,十六進位制(24ed)比起二進位制(0b10010011101101),明顯只需要較少的位數就能表示同一個數值。
除了使用 Number() 函式外,使用 Number.parseInt(string, radix) 也能達到轉換的效果:
// 指定該內容要使用的進位制

parseInt('fe', 16)    // 254, 0xfe = 254
parseInt('376', 8)    // 254, 0o376 = 254
parseInt('11111110', 2)   // 254, 0b11111110 = 254
若想進一步瞭解 Number.parseInt() 的用法,可參考 parseInt() 在 MDN 上的說明

將十進位轉換為不同進位制:toString()

如果想要將十進位的數值轉換成其他進位制的話,可以使用 Number.prototype.toString() 這個函式:
/* 使用 toString() 將十進位轉換成不同進位制 */

(9453).toString(2)     // 回傳 '10010011101101',將 9453 轉換成 二 進制
(9453).toString(8)     // 回傳 '22355',將 9453 轉換成 八 進制
(9453).toString(16)      // 回傳 '24ed',將 9453 轉換成 十六 進制

進位制轉換工具

如果你想要看看自己轉換的結果正不正確,可以使用進位換算計算機這套小工具,我們平常用的是 10 進位的數字,所以只需要在十進位的地方輸入「9453」按下計算後,就會出現二進位的結果是「10010011101101」。
Imgur

Unicode 的表示方式(U+<十六進制數值>)

在瞭解不同數值間的進位制轉換後,來看看一般會怎麼樣來表示 Unicode 的數值。
前面提到過Unicode 就像一個世界通用的大字典,收納了好幾百萬個全世界的文字符號,且每一個文字符號都有一個屬於自己的編號
而一般在 Unicode 中我們會用十六進制來表示某一個文字符號的編號,並且使用 U+<十六進制數值> 的方式來表示,例如 U+0061 就表示英文字母的 a,其中的 0061 是十六進位制的數值,轉換成 10 進位的話是 97,你可以發現這和當初的 ASCII 表示相對應的:
Number('0x0061')    // 回傳 97,將十六進制的 0061 轉成 10 進制
又例如,阿童的「阿」用 Unicode 來表示是 U+963f,「童」則是可用 Unicode U+7ae5 表示。

碼點(Code Point)

概念上,我們會把這些文字符號所對應到的編號,稱作是「碼點(Code Point)」,又稱作「編碼位置」。在 Unicode 中指的是 U+ 後面的十六進制數值,每個碼點都是唯一的
。例如 a 的碼點是 0061,「阿」的碼點是 963f,「灣」的碼點是 7ae5
根據碼點的編號範圍,又可以分成「基本平面」和「輔助平面」。

基本名面和輔助平面

Unicode 編碼中碼點的可能範圍從 U+0000 一直到 U+10FFFF 超過 110 萬個文字符號,因此又可分為基本平面(BMP, Basic Multilingual Plane)和輔助平面(SMP, Supplementary planes or Astral planes)。
  • 基本平面(BMP):碼點位置範圍從 U+0000U+FFFF,這個平面放了最常見的文字符號。
  • 輔助平面(SMP):碼點位置範圍從 U+010000 一直到 U+10FFFF,又稱為補充平面。
此外,原本 ASCII 中數字和文字符號的對應關係,可以直接沿用到 Unicode 中,也就是 ASCII 中 a 的編號會和 Unicode 中 a 的編號相同。

JavaScript 中提供的相關函式

keywords: String.fromCodePoint(), String.prototype.codePointAt()
若想要查看某一個字的 Unicode 碼點,或者根據碼點反查是某一個字,在 JavaScript ES6 提供了 String.fromCodePoint(), String.prototype.codePointAt() 這兩個函式,讓你可以在 Unicode 碼點和文字符號間相互轉換
str.codePointAt()String.fromCodePoint() 是 ES6 提供的函式,若要對應回 ES5 的用法,則分別是對應回 str.charCodeAt()String.fromCharCode()
大部分的情況下 ES5 和 ES6 會得到一樣的結果,但若有使用到輔助平面文字符號(U+010000 以上)時 ES5 則可能會產生錯誤,因此建議在支援 ES6 的情況下,使用 ES6 的函式以避免錯誤發生

文字符號 --> Unicode 碼點(10 進位)

透過 String.prototype.codePointAt() 可以將一個文字符號轉換成 Unicode 碼點,但回傳的是十進位,因此一般會需要轉成 16 進位來表示:
// 文字符號 --> Unicode 碼點(10 進位)
String.prototype.codePointAt(<index>)  // ES6 提供的方法
'A'.codePointAt()            // 回傳 '65'(這是十進制)
(65).toString(16)              // 回傳 41,將 65 轉成十六進制後,表示 A 為 U+0041

// 也可以一次把它轉成 16 進制
'A'.codePointAt().toString(16)      // '41',表示 A = U+0041
'童'.codePointAt().toString(16)      // '7ae5',表示童 = U+7ae5
'💩'.codePointAt().toString(16)      // '1f4a9',表示 💩 = U+1f4a9

Unicode 碼點 --> 文字符號

使用 JavaScript ES6 的方法 String.formCodePoint() 則可以把碼點轉換為文字符號。如同前面文章「]不同進位制的表示」所述,數字在表示時若為 16 進位,則開頭需加上 0x 來表示:
// Unicode 碼點 -> 文字符號
String.fromCodePoint(<num1> [, ...[, numN]])    // ES6 提供的方法

String.fromCodePoint(65)     // A,使用 10 進位
String.fromCodePoint(0x0041) // A,使用 16 進位
String.fromCodePoint(0o101)    // A,使用 8 進位

在 JavaScript 字串中顯示 Unicode 字元

除了透過 String.formCodePoint(<num>) 這種方式來將 Unicode 碼點轉換為文字符號外。在 JavaScript 中,可以直接使用 console.log() 函式就能將 Unicode 轉換成文字符號顯示出來
其中根據不同的適用時機或場合有幾種不同的表示方式,包括:
  1. 只使用到 Unicode 基本平面時(\u<碼點>
  2. 有使用到 Unicode 輔助平面時(\u{ <碼點> }
  3. 只使用到 ASCII 字符時(\x<碼點>

只使用到 Unicode 基本平面時(\u<碼點>)

  • 使用:\u<碼點>
  • 適用範圍:Unicode 基本平面字符,也就是 U+0000U+FFFF
console.log('\u0041\u0042\u0043')    // 'ABC'

'臺' === '\u81fa'             // true
console.log('\u81fa')          // 臺
console.log('\u81fa\u7063');      // 臺灣
console.log('\u2661 \u81fa\u7063');   // '♡ 臺灣'
❗️ 若在 \u 使用了輔助平面文字符號的碼點時會產生錯誤的結果

有使用到 Unicode 輔助平面時(\u{ 碼點 })

  • 使用:\u{碼點}
  • 適用範圍:所有 Unicode 字元,特別是有使用超過 U+FFFF 的輔助平面文字符號
為了支援輔助平面的字元,在 JavaScript ES6 中引進了新的 Unicode 碼點跳脫序列(Unicode code point escapes),透過將碼點放在大括號 {} 內,也就是 \u{碼點} 就能正確識別,許多常見的 emoji 符號都是屬於輔助平面內的文字符號:
// 使用 2 位數的十六進制(一個位元組)
console.log('\u{41}\u{42}\u{43}');      // 'ABC'

// 使用 4 位數的十六進制(兩個位元組)
console.log('\u{0041}\u{0042}\u{0043}')    // ABC

// 使用超過 4 位數以上的十六進制
console.log('\u{1F4A9}');         // '💩' U+1F4A9
console.log('\u{1F923}');         // '🤣' U+1F923
console.log('\u{1F436}')         // '🐶' U+1F436

只使用到 ASCII 字符時(\x)

  • 使用:\x<碼點>
  • 適用範圍: U+0000U+00FF,只用到 Unicode 兩位數的十六進制時,也就是一個位元組時
  • 舉例來說, U+0041 = \x41
console.log('\x41\x42\x43');          // 'ABC'

統整一下

A = U+0041 = \x41 = \u0041 = \u{41} = \u{0041}
也就是下面會得到一樣的結果
console.log('\x41')            // 'A'
console.log('\u0041')          // 'A'
console.log('\u{41}')          // 'A'
console.log('\u{0041}')          // 'A'

在 CSS 中使用 Unicode

如果想要在 CSS 中使用 Unicode,可以使用前輟 \<碼點> 來進行跳脫
.content{
    display: inline-block;
    background-color: steelblue;
    padding: 100px;
    color: white;
}
.content::before{
    content: '\0041';  // A, 輸入 16 進位制的 Unicode
    // content: '\570B'; // 國
    // content: '\00A9'; // ©
}

結語

在瞭解了 Unicode 的使用和轉換後相信你已經可以解碼本篇文章中最一開始的那串內容是什麼了吧?
{
  "name": "PJCHENder\u7db2\u9801\u524d\u7aef\u8cc7\u6e90\u7ad9"
}
試著用用看上面幾種不同的方法,像是 console.log()String.fromCodePoint() 的方法來解碼 Unicode 吧!
或者你也可以利用 str.codePointAt() 編碼打造一段屬於自己圈子內的低調碼,請有興趣的捧油們一起來解碼:
// 自製低調碼

[...'準備上車啦'].map(i => i.codePointAt().toString(16))
// [ '6e96', '5099', '4e0a', '8eca', '5566' ]

等等,那麼 UTF-8, UTF-16, UTF-32 是什麼?

透過 Unicode(萬國碼)讓所有國家的文字符號都有個唯一的碼點可以在電腦內被辨認,但是這個碼點(從 U+0000U+10FFFF)要如何儲存在電腦中則不是 Unicode 要解決的,也就是說,要用什麼樣的方式才能有效的把所需的碼點存在電腦中,但又不佔據太多的容量則沒有在 Unicode 中被規範。
UTF-8, UTF-16 或 UTF-32 則都可以視為是 Unicode 實做的一種方式,它們用不同的方式來規範要如何將 Unicode 中的碼點儲存在電腦中以被使用,如果直接把整個 Unicode 代碼搬進電腦裡儲存,可能會佔用太多不必要的空間(例如,UTF-32)。
在網際網路上最常被使用的則是 UTF-8 的編碼方式,而在 JavaScript 引擎中主要支援的則是 UTF-16

其他網際網路上常用編碼方式

除了用 Unicode 來為每個文字符號來進行編碼外,在網際網路上,也經常會使用 HTML Encode 或者是 URI Encode 來將內容或網址進行編碼。

HTML Encode

HTML Encode 的特點在於,它會用 & 開始 ; 結束,例如 &plus; 字串透過 HTML 轉換後會變成 +,它也有可以用十六進位或十進位的方式表示:
"+" = &plus; = &#x0002B; = &#43;
HTML Encode Character Reference :HTML 編碼對照表。

URI Encode

在網際網路上最常被使用的則是 UTF-8 的編碼方式,它的特點是會用 % 開頭,許多的網址或文字內容為了確保正確性,常常會先使用 URI Encode 後再顯示。
文章最前面所提到的例子 https://today.line.me/tw/pc/article/%E5%86%B0%E7%81%AB%E4%B9%8B%E5%9C%8B+%E5%86%B0%E5%B3%B6-ownNlj 就是因為把這些中文字轉換成 UTF-8 1編碼後的結果。
在 JavaScript 中提供 encodeURI()encodeURIComponent() 的方法可以將字串轉換成 UTF-8,這兩個函式的差別在於,encodeURI() 不會對 URI 具有特殊意義的字符編碼(例如,ASCII 碼、數字、- _ . ! ~ * ' ( ) ;/?:@&=+$,#),但 encodeURIComponent() 則是全部都會進行編碼,編碼完的內容會是 UTF-8
encodeURI('/')            //   '/' 是 URI 中有意義的字符,不會進行編碼
encodeURIComponent('/')       //   '%2F'

encodeURI('&')            //   '&' 是 URI 中有意義的字符,不會進行編碼
encodeURIComponent('&')       //   '%26'

encodeURIComponent('國')       // '%E5%9C%8B'
encodeURIComponent('💩')        // '%F0%9F%92%A9'
相對應的解碼方式則是 decodeURI()decodeURIComponent()
decodeURI('https://today.line.me/tw/pc/article/%E5%86%B0%E7%81%AB%E4%B9%8B%E5%9C%8B+%E5%86%B0%E5%B3%B6-ownNlj')   //"https://today.line.me/tw/pc/article/冰火之國+冰島-ownNlj"

延伸閱讀

參考工具

2018年4月24日

[APP] 如何撰寫 Typora 中 markdown 的客製化樣式


此文件翻譯自 Write Custom Theme for Typora @ Typora 官網。翻譯時間為 2018-04-24。

總結

如果你想要為 Typora 撰寫客製化的樣式,你需要:
  1. 建立一個新的 css 檔案,這個檔案的名稱不能包含大寫字母或空格,例如,my-typora-theme 就是一個有效的檔案名稱。
  2. 寫 CSS 檔案。
  • 我們準備了一個 toolkit 讓你可以快速開始,並做一些簡單的測試。
  • 如果你是要從開始寫,可以從裡面的 template.less 開始。
  • 如果你想要套用 Wordpress 或 Jekyll 等現有主題的 css 檔案,只要把內容複製下來,加上那些在 css 檔案中沒有涵括到的部分樣式,像是 "toc" 的樣式,或是其他的 UI 元素。
  1. 檢測/除錯你的 CSS 檔案:
  • 將你所建立的 CSS 檔案放入 toolkit/theme/test.css 中,並記得放入你引用到的圖片或字體檔,接著打開在 toolkit/coretoolkit/eletron 內的 HTML 檔案來預覽你的 CSS。若你的作業系統是 Mac 則使用 Safari 來開啟該檔案,若是在 Linux/Windows 下,則使用 Chrome 來開啟。
  • 接著跟著 how to install custom theme 的說明來將你的樣式安裝到 Typora 上作為測試。
  1. 如果你想要分享你的主題,只要 fork 一份,並發送 PR 到 Typora Theme Gallery 上即可。

基本

  1. css 命名的規則:不要使用大寫字母,將空白以 - 替換掉,typora 會將它們轉換成在選單中可讀的名稱。舉例來說,my-first-typora-theme.css 會在 typora 的 "Themes" 選單項目中變成 "My First Typora Theme"
  2. 將預設的字體大小放到 html 中,接著像是 h1p 則使用 rem 作為單位,否則的話,在偏好設定中所設定的客製化字體大小將不會生效。
  3. Typora 是在 macOS 上是透過 Webkit,在 Windows/Linux 上是透過 Chromium,因此需使用 Chrome 或 Safari 所支援的 css 樣式。
  4. 有些 CSS 的調整可能會使得 Typora 產生未預期的情況,例如在 #write 將上 white-space: pre-wrap;,將使得編輯時無法透過 Tab 鍵來插入 \t,因此請盡可能不要複寫預設的 CSS 樣式,並且試試看會不會產生錯誤。

我該用哪個 CSS 選擇器?

一般來說

html:Typora 的視窗內容是一個網頁,因此請把 background, font-family 或起它通用的屬性添加到 html 標籤上。在 Mac 上如果,如果你使用了 seamless window style ,那麼工具列的背景顏色將會套用在 html 上的 background-color 樣式。
#write:寫作的區域是套個 #write,改變它的 width, height, padding 將會調整寫作區域的尺寸。你所設定的屬性,像是添加在 html 上的 color 樣式,會套用在整個 window 內容; UI 也是,像是插入表格時視窗的文字顏色。因此,**如果你只想要改變寫作區域的樣式而不影響到 UI 的部分,則可以把樣式放在 #write 上。
/** example **/
html, body {
  background-color: #fefefe; /*background color of the window and titlebar*/
  font-family: helvetica, sans-serif; /*custom font*/
  ...
}

html {
  font-size: 14px; /*default font size*/
}

#write {
  max-width: 90%; /*adjust size of the wriring area*/
  font-size: 1rem; /*basic font size*/
  color: #555; /*basic font color*/
  ...
}
Typora 會試圖渲染所有 markdown 中的元素,所以段落會被 <p> 標籤包住,清單會被 <ul><ol> 包住,就如同其他 Markdown 處理器一樣,因此你可以透過改變這些 HTML 標籤的樣式來改變它們的外觀。也因此,Wordpress 或其他靜態頁面所使用的 CSS 檔案也會影響 Typora 中大部分的樣式,你可以直接「移植」這些 CSS 規則過來,並添加缺少的樣式或做些調整就好。

Block Elements

如果前面所述,Typora 會是的渲染所有 Markdown 的元素,例如段落用 <p>、表格用 <table>、第一街的標題用 <h1>,等等,你可以改變它們的樣式:
p {...}
h1 {...}
table {...}
table th td {...}
table tr:nth-child(2n) td {...}
...
你可以在這些選擇器前面加上 #write 讓它們只套用於寫作區域而不會影響到其他的控制元件,例如,某些對話方框中的標題是套用 h4 的標籤:
/*this will only aplly to h4 in dialogs popped up by typora (just an example)*/
.dialog h4 {...} 

/*this will only apply to h4 inside writing area, which is generated after user input "#### " */
#write h4 {...} 
此外,所有的區塊元素都有 mdtype 這個屬性,舉例來說,你可以透過 [mdtype="heading"] 來選到標題,其他的類型像是 paragraph, heading, blockquote, fences, hr, def_link, def_footnote, table, meta_block, math_block, list, toc, list_item, table_row, table_cell, line但大部分的情況下,使用 HTML 標籤就已經非常足夠
mdtype Output Css Selector Explanation
paragraph p
line .md-line A paragraph can contain one or more .md-line
heading h1~h6
blockquote blockquote
list (unordered list) ul li
list (ordered list) ol li
list (task) ul.task-list li. task-list-item
toc .md-toc Also refer to [this doc][toc]
fences (before codemirror is initialized) pre.md-fences.mock-cm
fences pre.md-fences please refer to “Code Fences” section
diagrams pre[lang=’sequence’], pre[lang=’flow’], pre[lang=’mermaid’] They are special code fences with certain code language.
hr hr
def_link .md-def-link with children .md-def-name, .md-def-content, .md-def-title
def_footnote .md-def-footnote with children .md-def-name, .md-def-content
meta_block pre.md-meta-block content for YAML front matters
math_block [mdtype=”math_block”] preview part is .mathjax-block, html content is generated via MathJax. TeX editor is powered by CodeMirror, please refer to “Code Fences” section
table table thead tbody th tr td

Lines

Typroa 會渲染如實的渲染斷行,因此,一個段落中會透過 \n 來包含許多行,而 .md-line 就是用來選擇 <p> 中的每一行。

Code Fences(程式碼區塊)

程式碼高亮的效果是透過 CodeMirror 的功能來達到,因此可以參 這份文件 來檢視更多細節。

Mermaid(流程圖)

Markdown 中的流程圖式透過 Mermaid 完成。

Inline Elements

行內元素也會如同多數的 markdown 解析器一樣的被渲染,因此你可以使用:
strong {
  font-weight: bold;
}
em {..}
code {..}
a {..}
img {..}
mark {..} /*highlight*/
行內元素通常會被 span 、meta syntax 或最後輸出的行內元素所包住,例如,**strong** 會被渲染成:
<!--wrapper for strong element-->
<span md-inline="strong" class=""> 
  
  <!--meta syntax for strong element-->
  <span class="md-meta md-before">**</span> 
  
    <!--output for strong element-->
    <strong>
      <!--inner output-->
      <span md-inline="plain">strong</span> 
    </strong>
  
   <!--meta syntax for strong element-->
  <span class="md-meta md-after">**</span>
</span>
如你所見,整個行內元素被帶有 md-inline 屬性的 span 所包住,用來指稱解析後的結果,其他可能的屬性包含(有些行內元素需要在偏好設定中開啟):
md-inline syntax Output Tag
plain plain span
strong **strong** strong
em *em* em
code code code
underline <u>underline</u> u
escape \( span
tag <button>
del ~~del~~ del
footnote ^1 sup
emoji :smile: span
inline_math $x^2$ span
subscript ~sub~ sub
superscript ^sup^ sup
linebreak (two whitespace at end of a line)
highlight ==highlight== mark
url http://typora.io a
autolink <http://typora.io> a
link [link](href) a
reflink [link][ref] a
image ![img](src) img
refimg ![img][ref] img
下面會說明 Typora 如何為行內的 markdown 語法添加樣式,像是 *_ ,這些通常會在 Typroa 中被隱藏起來,而你通常也不需要個別為它們設定 CSS 規則。
大部分的語法像是 **== 會在你將 markdown 轉換成 HTML 後消失,因此他們被 md-meta 的 class 所包住,並且預設會帶有 display:none ,一些像是 markdown 中圖片的語法預設會被隱藏,並且被以 md-content 的 class 所包住。當你的游標在這個行內元素時,被關注(focus)的那個將會被 md-expand class 所包住,接著 .md-meta.md-content 會變成可見,所以如果你想改變它們的外觀,將樣式套用在 .md-meta.md-content

原始碼模式(Source Code Mode)

原始碼模式(SourceCode Mode)是透過 CodeMirror 的加持,所以它們所使用的語法高亮和程式碼區塊的樣式是一樣的(檢視更多細節)。需要留意的是,**程式碼區塊使用 codemirror 主題的 .cm-s-inner ,但在程式碼模式下,codemirror 的主題是使用 .cm-s-typora-default ,所以 CSS 像這樣:
.cm-s-typora-default .cm-header {
  /*styles for h1~h6 in source code mode*/
}

專注模式(Focus Mode)

關於這個主題,可以參考這份文件

客製化字體(Custom Font)

關於這個主題,可以參考這份文件(目前暫無連結)。

背景(Background)

關於這個主題,可以參考 這份文件

控制 UI(Controller UI)

大部分的 UI 元件包括提示項目(tooltip)、對話框(dialog)和按鈕都是透過 HTML 所繪製。當你完成上面調整樣式的步驟後,如果發現這些 UI 和你的主題不搭時,你可以改變這些部分。在 toolkit 中的 HTML 檔案包含了大部分常用的 UI 元件,方便你可以除錯。

適用於 Windows/Linux 的其他 UI

相較於 macOS 的版本,Windows/Linux 版本的 Typora 使用了更多 HTML 的元素,包含清單(context menu)、偏好設定(preference panel)、甚至是視窗的外框(如果你在 Windows 上使用的是 "unibody" 的 window style)。
toolkit 中的 HTML 檔案包含了大部分常用的 UI 元件,方便你可以除錯。

列印(Print)

在下面的區塊中所撰寫的 CSS 將只會套用到列印或匯出成 PDF 時套用:
@media print {
    /* for example: */
    .typora-export * {
        -webkit-print-color-adjust: exact;
    }
    /* add styles here */
}

除錯和測試(Debug and Test)

在瀏覽器中測試

我們在 toolkithtml-preview 資料夾內提供的 HTML 檔案讓你可以透過 Safari 或 Chrome 來預覽你的主題。使用它們時,重新命名你的檔案並將 CSS 檔案放在 html-preview/theme/test.css 內。

在 Typora 中測試

跟著這份文件可以學習如何將主題安裝到 Typora 上。
對於 Mac 的使用者,在工具列「說明」的選單中可以勾選 enable debug mode,接著在內容上可以點選「檢閱元件(Inspect Element)」來跳出開發者工具。
對於 Windows/Linux 的使用者,你可以使用從「檢視」中切換 Toggle DevTools 來開關開發者工具。

客製化樣式的技巧和參考文件

相關的文件列在這裡
如果你有好的點子或用法想要分享,可以到 typora-wiki-site 上發送 PR。