Natcha Luang - Aroonchai

Trust me I'm Petdo

Bangkok, Thailand

[Blog] บอกเล่าความเป็นมาของ theme ชุดปัจจุบัน

ไม่ได้เขียนบล็อกมาหลายเดือน ยังอยู่ครับไม่ได้เลิกเขียนแต่อย่างใด ที่หายไปเพราะกำลังยุ่ง ๆ กับการเลี้ยงลูก + กับปรับปรุง theme ของบล็อกให้เข้าที่เข้าทางอย่างที่ตั้งใจ ในตอนนี้ก็ไม่ถือว่าสมบูรณ์เท่าไรหนักยังคงขาดส่วนสำคัญอยู่หลายส่วน

แต่ตั้งใจว่าจะมาเขียนบล็อกเล่าเรื่องราวปัญหาที่พบเจอในระหว่างการทำ theme เพื่อที่จะได้เก็บเป็น know-how สำหรับการพัฒนาครั้งต่อ ๆ ไป

ก่อนที่จะเป็น theme อย่างที่เห็นในตอนนี้ ก่อนหน้าผมเคยใช้งาน Minimalist ซึ่งเป็น theme ที่อยู่ในหน้า showcase ของ Hugo เลยจับมาลองใช้งานพร้อม ๆ กับย้ายบ้านจาก Ghost มาเป็น Hugo

Minimalist

ซึ่งก็สวยงามใช้ได้เป็นอย่างดี แต่ความรู้สึกคือมันเหมือนเว็บเราซ้ำกับคนอื่นอยู่ตลอดในหัว ด้วยความที่ไม่อยากให้ซ้ำกับใครเลยตัดสินใจออกแบบและสร้าง theme ใหม่เป็นของตัวเอง

เริ่มต้นออกแบบ

เริ่มต้นการออกแบบด้วยโปรแกรม Inkscape ที่เป็น free SVG editor ก็ใช้งานได้ แต่มันมีติดขัดบ้างทั้ง UI ที่เทอะทะและต้องทำงานผ่าน X Window แถมยังมีเรื่องของภาพแตกเพราะจอ MacBook Pro มันเป็น retina อีกต่างหาก

ออกแบบด้วย Inkscape

เลยตัดสินใจเปลี่ยนมาใช้ Sketch 3 แต่ไม่ได้ซื้อน่ะ ใช้แบบทดลองใช้งาน 30 วันเอา ดังนั้นข้อจำกัดของผมคือเวลาในการทำงาน อันนี้คือตัวอย่างของหน้าเว็บที่ออกแบบด้วย Sketch 3 ซึ่ง ณ ตอนที่เขียนบทความนี้เวลาทดลองใช้งานก็หมดไปแล้ว

ผลงานจาก Sketch 3

โดยคอนเซ็ปต์วิธีการออกแบบผมก็หาแนวทางมาจากหลาย ๆ เว็บ ตัวอย่างบทความที่ผมอ่านแล้วนำมาปรับใช้กับการออกแบบ

ลงมือ coding

หลังจากเสร็จสิ้นขั้นตอนการออกแบบเรียบร้อย ก็มาสู่ขั้นตอนการ coding โดยเครื่องมือที่เลือกใช้งานก็จะยังคล้ายกับของเดิม คือใช้ Sass แต่มีที่เปลี่ยนแปลงไปคือพยายามลดบทบาทของ jQuery ลงและเปลี่ยนมาเป็น React แทน

ซึ่งผมพยายามแยกส่วนของเว็บออกเป็น component แล้วเอา React เข้าไปจับเพื่อช่วยทำ event handling แต่ก็ยังมีบางงานที่ต้องใช้ jQuery อยู่ดีเช่นพวก selector หรือ AJAX

ในส่วนของ React ตอนนี้มีอยู่ 2 component หลักที่ใช้งานคือ nav กับ post thumbnail โดยตรง nav เอาช่วยทำหน้าที่สร้าง menu list และคอย listen click event ที่ปุ่ม menu และสั่งให้ toggle menu list ตรงนี้เฉพาะกับหน้าเว็บบน mobile เท่านั้น

Nav.jsx

import $           from 'jquery';
import MenuItem    from './MenuItem';
import classNames  from 'classnames';
import {Component} from 'react';

export default class Nav extends Component {

  constructor(props) {
    super(props);

    // Parse component's props data and set it to state.
    this.state = this._parseData(this.props.data);
  }

  _parseData(val) {
    try {
      return JSON.parse(val.replace(/'/g, '"'));
    } catch (err) {
    }

    return null;
  }

  onClickBtnNavToggle() {
    let isExpanded = ! this.state.isExpanded;

    if (isExpanded) {
      $('.menu-list').css({ left: 0 });
      $('body').bind('touchmove', (evnt) => { evnt.preventDefault(); });
    } else {
      $('.menu-list').css({ left: '-100%' });
      $('body').unbind('touchmove');
    }

    this.setState({ isExpanded: isExpanded });
  }

  render() {
    let wrapperClass = {
      'wrapper': true,
      'collasped': (this.state.isExpanded ? false : true)
    };

    let menuItems = this.state.links.map((elem) => {
      if (elem.href.length) {
        return (
          <MenuItem
            href={elem.href}
            title={elem.title} />
        );
      }
    });

    return (
      <div className={classNames(wrapperClass)}>
        <ul className="menu-list">
          {menuItems}
        </ul>
        <button className="btn-toggle-nav" onClick={this.onClickBtnNavToggle.bind(this)}>
        Menu
        </button>
      </div>
    );
  }

}

และส่วนของ post thumbnail อันนี้จะซับซ้อนหน่อยตรงที่ต้องใช้ AJAX เรียกไฟล์​ thumbnail image และทำ cache ด้วย HTML5 local storage ส่วนนี้ผมเลยหยิบเอาหลักการของ Flux เข้ามาช่วยจัดการเรื่อง event flow

Thumbnail.jsx

import $                from 'jquery';
import Loading          from './Loading';
import classNames       from 'classnames';
import {Component}      from 'react';
import thumbnailStore   from '../stores/ThumbnailStore';
import ThumbnailActions from '../actions/ThumbnailActions';

export default class Thubmnail extends Component {

  constructor(props) {
    super(props);

    // Parse component's props data and set it to state.
    this.state = this._parseData(this.props.data);

    // Get thumbnail data by first tag name.
    if (this.state.tags.length) {
      this.name = this.state.tags[0].toLowerCase();
    }

    ThumbnailActions.getData(this.name);
  }

  componentDidMount() {
    thumbnailStore.addChangeListener(this._onChange.bind(this));
  }

  componentWillUnmount() {
    thumbnailStore.removeChangeListener(this._onChange.bind(this));
  }

  _onChange() {
    let data = thumbnailStore.getData(this.name);

    if (data) {
      this.setState({
        iconUrl: data.iconUrl,
        backgroundColor: data.backgroundColor
      });
    }
  }

  _parseData(val) {
    try {
      return JSON.parse(val.replace(/'/g, '"'));
    } catch (err) {
    }

    return null;
  }

  onClickWrapper() {
    window.location = this.state.link;
  }

  render() {
    let coverImage;
    let wrapperClass = {
      'wrapper': true,
      'completed': false
    };

    let $thumbnail = $('.thumbnail').eq(this.props.index);
    let $wrapper = $thumbnail.find('.wrapper');

    if (this.state.iconUrl && this.state.backgroundColor) {
      coverImage = <img src={this.state.iconUrl} className="cover-icon" alt={decodeURI(this.state.title)} />;

      $thumbnail.css({ backgroundColor: this.state.backgroundColor });
      $wrapper.css({ marginTop: -128 });

      wrapperClass.completed = true;
    }

    return (
      <div className={classNames(wrapperClass)} onClick={this.onClickWrapper.bind(this)}>
        <Loading type="arc-rotate" />
        {coverImage}
      </div>
    );
  }

}

ThumbnailStore.js

import _                  from 'lodash';
import Store              from './Store';
import AppDispatcher      from '../dispatcher/AppDispatcher';
import ThumbnailConstants from '../constants/ThumbnailConstants';

const THUMBNAIL_DATA = 'thumbnailData';

let ActionTypes = ThumbnailConstants.ActionTypes;
let _data = [];

class ThumbnailStore extends Store {

  constructor() {
    super();

    let thumbnailData = localStorage.getItem(THUMBNAIL_DATA);

    if (Array.isArray(thumbnailData)) {
      _data = thumbnailData;
    }
  }

  getData(name) {
    return _.find(_data, (elem) => {
      return elem.name == name;
    });
  }

  setData(data) {
    _data.push(data);
    localStorage.setItem(THUMBNAIL_DATA, _data);
  }

}

let thumbnailStore = new ThumbnailStore();

thumbnailStore.dispatchToken = AppDispatcher.register((action) => {
  switch (action.type) {
    case ActionTypes.COMPLETE: {
      thumbnailStore.setData(action.data);

      break;
    }

    default: break;
  }

  thumbnailStore.emitChange();
});

export default thumbnailStore;

นอกจากนี้ผมยังตั้งใจที่จะปรับให้ทั้งเว็บเป็น SPA และเอา Redux เข้ามาใช้งานแทน Flux ที่มีความซับซ้อนเกินไป และช่วยในเรื่องของการเขียนโปรแกรมให้เป็น modular ที่ดีกว่า

และถ้าสังเกต JavaScript ทั้งหมดที่เขียนจะเป็น ES6 หรือชื่ออย่างเป็นทางการว่า ES2015 และใช้ babelify ในการช่วยแปลงให้เป็น ES5 เพื่อให้ compatible กับบราวเซอร์รุ่นเก่า ๆ ได้

ใช้งานจริง

ทั้งหมดผมใช้เวลาในการ coding อยู่ประมาณ 4 ~ 5 วันก็ออกมาเป็นรูปเป็นร่างได้ เลยจัดการ deploy ด้วย CLI ขึ้น Google AppEngine ด้วยมือทั้งหมดไม่ผ่าน Wercker ซึ่งก็ไม่ได้เอ๊ะใจอะไรถึงปัญหา จนกระทั่งมาเจอใน log ของ Wercker

เจอปัญหาขนาดของ script ใหญ่มหาศาล

ต้องบอกตามตรงว่าตอนที่ใช้งานแรก ๆ ไม่ได้รู้สึกถึงปัญหานี้เลยอาจจะเป็นเพราะว่าใช้งานผ่าน WiFi อยู่ตลอดเวลาก็เป็นได้ จนมาเริ่มสังเกต log ที่อยู่ใน Wercker ว่ามันใช้เวลาในการ compile scripts นานมากในแต่ละครั้ง เลยมา watch ดูที่ฝั่งบราวเซอร์เลยเจอปัญหาเข้าจัง ๆ คือขนาดของ scripts.min.js ที่มีขนาดใหญ่ถึง 7MB

จริง ๆ แล้วปัญหานี้คือเกิดจาก การที่ผมสั่งให้ Gulp ทำ source map กับไฟล์ JavaScript และลืมไปว่าในไฟล์ scripts.js มันไม่ได้มีแค่ React, Flux อย่างที่คิด มันมี bundle nodejs module เข้ามาด้วยอีกเพียบเลย ผลก็คือ source map เลยใหญ่โตมหาศาล

วิธีแก้ปัญหาที่ง่ายที่สุดคือปิดฟังก์ชัน source map ทิ้งไปซะ!

เจอปัญหา sidebar ตกเมื่อเลื่อนหน้าจอเร็ว ๆ

จากนั้นก็มาเจออีกหนึ่งปัญหาแต่ว่าไม่ใช่เรื่องใหญ่มากคือบน Chrome Android เนี่ยเวลาเราเลื่อนหน้าเว็บเร็ว ๆ แล้ว element ที่มี position fixed ทั้งหลายมันจะอิงกับขนาดของ document แต่พอเลื่อนปั๊บขอบ address bar ก็จะเลื่อนหายไปแต่บราวเซอร์ยังไม่ได้คำนวณขนาดของ document ใหม่ มันก็เลยทำให้ element ที่ fixed กับ top ตกทันที

ปัญหานี้ยังคงไม่มีทางแก้ไข สิ่งที่ทำได้คือดัก event เมื่อมีการ toggle menu ออกมาก็กันไม่ให้เลื่อนหน้าจอได้ ซึ่งก็จะเป็นปัญหากับเว็บที่มี menu เยอะ ๆ หรือกับเครื่องที่มีหน้าจอไม่ยาวพอ ส่งผลให้ menu แสดงไม่ครบได้

สรุป

แนวทางต่อไปของเว็บไซต์คือการเปลี่ยนให้ทั้งเว็บเป็น JavaScript ด้วย React + Redux เพราะต้องการไปในทาง SPA อยู่ ต่อจากนั้นก็คือการสนับสนุน AMP อย่างเต็มรูปแบบต่อไป :)

Comments