[Web]自製公車資訊實作細節-前端

前端的部分使用native javascript開發,並且用了一些功能讓網頁更方便操作,當然UI還有待改善,畢竟我不是學美術的XD

還沒看過該作品的同學,可以從這邊前往,一鍵公車,眼尖的朋友可能會發現畫面和當初的版本似乎不同了,畢竟也花了不少時間持續優化,所以跟初版相比算是華麗了不少吧,以下是前端的一些特點

  1. 資料取得與展示
  2. 加到桌面,如APP一般
  3. SVG Sprite
  4. 可單手操作的選單
  5. 簡潔的Toast訊息
  6. 分享功能
  7. 避免外部資源被快取而不更新

洋洋灑灑也有好幾項,雖然有些只是使用到而已,但是適當的使用,也可以讓你的應用加分,而不是把所有功能都放進來就好,有些是使用別人的成果,雖然有經過一些調整,還是會將出處附在參考,以示尊重。另外因為是設定給手機使用,就不考慮相容老爺機或者桌機的部分,盡量使用新的API

資料取得與展示

使用fetch來取得資料,為了避免使用者習慣性重新整理,使用了快取機制,因為資料端更新頻率約1分鐘,太頻繁沒有幫助反而造成後端壓力,所以1分鐘內直接使用快取。

另外自動1分鐘重新整理一次,省去使用者手動操作,考量到使用者會關螢幕,一段時間再打開來看,接收visibilitychange事件,讓使用者開螢幕時,馬上重新整理,並在讀取時,右下角顯示圖示讓使用者知道

const DATA_CACHE_TIME = 'data_cache_time';
const DATA_CACHE = 'data_cache';
const REFRESH_PERIOD = 60*1000;
const DATA_URL = 'https://script.google.com/macros/s/AKfycbxVH6wsfiRs_u-GSLVKVoENVX_SfJ7wUSFEqTB_hDFleA4NzEY/exec?key=7dKw1ubRxz4EtL2EuN5ZWKZPT9GBeL6T';

let loadTimeout;
//檢測頁面不可見時,停止重整,在頁面恢復時,馬上重新整理
document.addEventListener('visibilitychange', function(){
  clearTimeout(loadTimeout);
  if(document['hidden']){
  }else{
    loadData()
  }
}, false);

function loadData(){
  //檢查和上次快取的時間間隔,低於間隔則直接使用快取,減少要求次數
  var now = new Date();
  //if less than 60s use cache
  if(localStorage[DATA_CACHE] && 
    localStorage[DATA_CACHE_TIME] && 
    (now.getTime() - parseInt(localStorage[DATA_CACHE_TIME], 10) < 60000)){
    parseData(JSON.parse(localStorage[DATA_CACHE]));
    return;
  }
  document.querySelector('.loading').classList.toggle('hidden');
  fetch(DATA_URL,{
    method:'GET',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8'
    }
  })
  .then(res => {
      return res.json();
  }).then(result => {
      parseData(result);
      localStorage[DATA_CACHE] = JSON.stringify(result);
    localStorage[DATA_CACHE_TIME] = now.getTime();
      document.querySelector('.loading').classList.toggle('hidden');
      loadTimeout = setTimeout(function(){loadData()}, REFRESH_PERIOD);
  })
  .catch(function(err) {
      alert(err);
      document.querySelector('.loading').classList.toggle('hidden');
      loadTimeout = setTimeout(function(){loadData()}, REFRESH_PERIOD);
  });
}

function parseData(result){
  let estimate = [];
  for(let i=0;i<result.length;i++){
    let text = '';
    if(result[i]['PlateNumb'] == '-1' || result[i]['PlateNumb'] == ''){
      text = getStatus(result[i]['StopStatus'], result[i]['NextBusTime']);
    }else{
      if(result[i]['EstimateTime'] > 60){
        text = Math.ceil(result[i]['EstimateTime']/60)+'分後';
      }else{
        text = '1分鐘內';
      }
    }
    estimate.push({
      route: result[i]['SubRouteName']['Zh_tw'],
      no: result[i]['PlateNumb'],
      text: text,
      stop: result[i]['StopName']['Zh_tw']
    });
  }
  showResult(estimate);
}

function showResult(estimate){
  let content = '';
  for(let i=0;i<estimate.length;i++){
    content += '<div class="info_row">'+
      '<div class="info_route">'+estimate[i]['route'] + '</div>'+
      '<div class="info_no">'+estimate[i]['no']+'</div>'+
      '<div class="info_text">' + estimate[i]['text']+'</div>'+
      '<div class="info_stop">' + estimate[i]['stop']+'</div>'+
    '</div>';
  }
  document.getElementById('estimate_info').innerHTML = content;
}

function getStatus(status, next_time){
  if(status === 1){
    if(next_time == undefined){ return '尚未發車';}
    var time = new Date(next_time);
    var hour = time.getHours() < 10 ? '0'+time.getHours() : time.getHours();
    var minute = time.getMinutes() < 10 ? '0'+time.getMinutes() : time.getMinutes();
    return hour + ':' + minute + '發車';
  }else if(status === 2){
    return '交管不停靠';
  }else if(status === 3){
    return '末班車已過';
  }else if(status === 4){
    return '今日未營運';
  }
}

CSS部分,簡單定義了每一欄的寬度,讀取圖示等

.info_row{
  padding: .5em 0;
}
.info_route{
  display: inline-block;
  width: 15%;
}
.info_no{
  display: inline-block;
  width: 25%;
}
.info_text{
  display: inline-block;
  width: 30%;
}
.info_stop{
  display: inline-block;
  width: 30%;
}
.svg-icon {
    fill: #fff;
    width: .8em;
  height: .8em;
  margin: .2em;
}
.loading{
  width: 1.5em;
  height: 1.5em;
  position: absolute;
  bottom: .3em;
  right: .3em;
  fill: black;
  animation: spin 1.4s infinite linear;
}
.hidden { display: none; }

@keyframes spin{
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(-360deg);
  }
}

加到桌面,如APP一般

Add to home screen,這個功能可以將捷徑建立在手機桌面,執行時可隱藏網址列,獲得類似原生APP的操作體驗,需要建立manifest.json,定義捷徑的外觀和作用,還有記得製作圖示檔案

進入時的啟動畫面
{
  "short_name": "一鍵公車",
  "name": "一鍵公車",
  "icons": [
    {
      "src": "icon-192.png",
      "type": "image/png",
      "sizes": "192x192"
    },
    {
      "src": "icon-512.png",
      "type": "image/png",
      "sizes": "512x512"
    }
  ],
  "start_url": "./index.html?source=app",
  "scope": "./",
  "background_color": "#3367D6",
  "display": "standalone",
  "theme_color": "#3367D6"
}

SVG Sprite

考量到SVG的支援度已相當普及,用起來也比CSS Sprite簡單,因此把圖示的部分全部集中到一個SVG檔案中,使用方法如下,只要指定symbol的id就可以囉,是不是很方便~

<svg class="svg-icon loading hidden" xmlns="http://www.w3.org/2000/svg">
  <use xlink:href="icon.svg#loading"></use>
</svg>

SVG的格式如下,svg節點中可放置多個symbol,此格式的圖可以由CSS來定義大小和顏色,非常靈活

<svg xmlns="http://www.w3.org/2000/svg">
  <symbol id="loading" viewBox="0 0 24 24"><path d="M23 12c0 1.042-.154 2.045-.425 3h-2.101c.335-.94.526-1.947.526-3 0-4.962-4.037-9-9-9-1.706 0-3.296.484-4.655 1.314l1.858 2.686h-6.994l2.152-7 1.849 2.673c1.684-1.049 3.659-1.673 5.79-1.673 6.074 0 11 4.925 11 11zm-6.354 7.692c-1.357.826-2.944 1.308-4.646 1.308-4.962 0-9-4.038-9-9 0-1.053.191-2.06.525-3h-2.1c-.271.955-.425 1.958-.425 3 0 6.075 4.925 11 11 11 2.127 0 4.099-.621 5.78-1.667l1.853 2.667 2.152-6.989h-6.994l1.855 2.681z"/></symbol>
</svg>

可單手操作的選單

多數手機的APP和網頁選單都是從上面操作,不是左上就是右上,問題是這樣很不利於單手使用,考慮到這點,我花了一些時間找是否有單手可操作的選單,終於讓我找到了,居然連文章都沒有,只有一個範例,根據我的需求調整了一些屬性,單位也都改成em

不得不佩服作者,因為他把大部分的參數都使用CSS Variables,很輕易地可以調整,HTML的部分也不會太複雜,還保留了不少彈性,篇幅太大,詳細的代碼還是要參考github原始碼~

:root{
  --colorWhite: #fff;
  --colorMain: #4557bb;
  --rLinkTextColor: var(--colorWhite);
  --rlinkActiveColor: #d0e6ff;
  --menuCircleSize: 6em;
  --menuCircleBgColor: var(--colorMain);
  --menuHamburgerWidth: 1.68em;
  --menuHamburgerHeight: 1.2em;
  --menuHamburgerBgColor: var(--colorWhite);
}
<div class="menu">
  <nav class="menu__nav">
    <ul class="r-list menu__list">
      <li class="menu__group">
        <a href="https://ptx.transportdata.tw/PTX" class="r-link menu__link">
          <svg class="svg-icon" xmlns="http://www.w3.org/2000/svg" data-svg-id="bus" onload="assignSvgUrl(this);">
        <use xlink:href=""></use>
      </svg>交通部PTX
        </a>
      </li>
      <li class="menu__group">
        <a href="https://woodloch.blog" class="r-link menu__link">
          <svg class="svg-icon" xmlns="http://www.w3.org/2000/svg" data-svg-id="user" onload="assignSvgUrl(this);">
        <use xlink:href=""></use>
      </svg>木澤
        </a>
      </li>
      <li class="menu__group">
        <a href="https://woodlochhome.wordpress.com/2020/05/30/oneclickbus/" class="r-link menu__link">
          <svg class="svg-icon" xmlns="http://www.w3.org/2000/svg" data-svg-id="link" onload="assignSvgUrl(this);">
        <use xlink:href=""></use>
      </svg>部落格
        </a>
      </li>
      <li class="menu__group">
        <a href="mailto:admin@woodloch.blog" class="r-link menu__link">
          <svg class="svg-icon" xmlns="http://www.w3.org/2000/svg" data-svg-id="mail" onload="assignSvgUrl(this);">
        <use xlink:href=""></use>
      </svg>聯繫我
        </a>
      </li>
      <li class="menu__group">
        <a href="javascript:void(0);" onclick="shareUrl();" class="r-link menu__link">
          <svg class="svg-icon" xmlns="http://www.w3.org/2000/svg" data-svg-id="share" onload="assignSvgUrl(this);">
        <use xlink:href=""></use>
      </svg>分享給朋友
        </a>
      </li>
    </ul>
  </nav>
  <div class="menu__toggle">
    <button class="r-button menu__hamburger">
        <span class="m-hamburger">
        <span class="m-hamburger__label">Open menu</span>
        </span>
    </button>
  </div>  
</div>

簡潔的Toast訊息

考量訊息提示時,不希望使用者一直去點確定,那就學原生APP的Toast方式好了,許多套件都很花俏,但容量不小,後來找到一個簡易輕量的範例,套用了以後發現了一個bug,因為它使用js的setTimeout來隱藏,要配合CSS動畫的時間,有時候兩者時間因為延遲不一致,會看到不正常的閃爍。這部份好解決,不要使用animation,只用transition就可以了

#toast {
  min-width: 250px; 	  
  background-color: #333; 
  color: #fff; 
  text-align: center; 
  border-radius: 2px; 
  padding: 16px; 
  position: fixed; 
  z-index: 1000; /* need to higher than .menu in nav.css */
  left: 50%; /* Center the toast */
  transform: translatex(-50%);
  opacity: 0;
  bottom: calc(var(--toastBottom) * -1 );
  transition: bottom .5s, opacity .5s;/* move with animation */
}

#toast.show {
  opacity: 1;
  bottom: var(--toastBottom);
  transition: bottom .5s, opacity .5s;/* move with animation */
}
:root{
	--toastBottom: 3.2em;
}
<div id="toast">Some text some message..</div>
function showToast(text) {
  var x = document.getElementById("toast");
  x.innerText = text;
  x.classList.toggle('show');
  setTimeout(function(){ x.classList.toggle('show'); }, 3000);
}

分享功能

Web應用畢竟還是靠網址傳遞,因此方便分享,所以使用了Share API來呼叫原生的分享功能,假設不支援,也可以把網址複製到剪貼簿中,再到其他的社群貼上

//for share API
const shareData = {
  url: window.location.href
};

function shareUrl(){
  //確認支援度
  if (navigator.share) {
      try {
        navigator.share(shareData);
    } catch (err) {
        const { name, message } = err;
        if (name === 'AbortError') {
          console.log('您已取消分享此訊息');
        } else {
          console.log('發生錯誤', err);
        }
    }
  } else {
    //不支援則將網址複製到剪貼簿
    copy(window.location.href);
    showToast('網址複製完成');
  }
  //隱藏選單
  document.querySelector('.menu__hamburger').dispatchEvent(new Event('click'));
}

function copy(text){
  const input = document.createElement('input');
  input.value = text;
  document.body.appendChild(input);
  input.select();
  if (document.execCommand('Copy')) {
       document.execCommand('Copy');
  }
  document.body.removeChild(input);
}

避免外部資源被快取而不更新

在初版開發完畢後,後續改版一直有遇到一個困擾,就是外部引用的資源會因為瀏覽器快取,顯示舊版本的內容,像是SVG明明已經新增圖了,可是快取的圖沒有,就空一塊,又或者是選單的nav.css已經修改樣式,但同樣因為快取問題,顯示舊的樣式。

通常處理這個問題,就是從引用的url下手,尾部加上版號之類的,只要url不同,瀏覽器就會重新取得最新版,問題是CSS和SVG都是hardcode上去的,這是前端,沒有後端幫我加字串,因此不得已的情況下,只好採用動態載入,採用半自動的方式,定義好一個值,每次有更新就跟著改變,配合動態載入外部資源url變更達到更新的效果

const LAST_MODIFIED_DATE = '2020060121';
//prevent css cache not update
document.getElementsByTagName("head")[0].insertAdjacentHTML(
	'afterBegin',
	'<link rel="stylesheet" href="nav.css?v='+LAST_MODIFIED_DATE+'" />');

SVG則是定義在onload事件,動態改變href值

function assignSvgUrl(svg){
  var use = svg.getElementsByTagName("use")[0];
  use.href.baseVal = 'icon.svg?v='+LAST_MODIFIED_DATE+'#' + svg.dataset.svgId;
}
<svg class="svg-icon" xmlns="http://www.w3.org/2000/svg" data-svg-id="user" onload="assignSvgUrl(this);">
  <use xlink:href=""></use>						</svg>

感覺篇幅有點長,可以看到這裡,耐心令人佩服,Web前端博大精深,UI、UX更是一門學問,如果你有更好的想法,歡迎多多指教~

一拖拉庫的參考

對「[Web]自製公車資訊實作細節-前端」的一則回應

Add yours

發表迴響

在下方填入你的資料或按右方圖示以社群網站登入:

WordPress.com 標誌

您的留言將使用 WordPress.com 帳號。 登出 /  變更 )

Facebook照片

您的留言將使用 Facebook 帳號。 登出 /  變更 )

連結到 %s

在 WordPress.com 建立網站或網誌

向上 ↑

%d 位部落客按了讚: