ผลต่างระหว่างรุ่นของ "01204223/react-flask"

จาก Theory Wiki
ไปยังการนำทาง ไปยังการค้นหา
 
(ไม่แสดง 63 รุ่นระหว่างกลางโดยผู้ใช้คนเดียวกัน)
แถว 1: แถว 1:
 
: ''หน้านี้เป็นส่วนหนึ่งของวิชา [[01204223]]''
 
: ''หน้านี้เป็นส่วนหนึ่งของวิชา [[01204223]]''
  
== เริ่มต้นและการติดตังซอฟต์แวร์ต่าง ๆ ==
+
== เริ่มต้นและการติดตั้งซอฟต์แวร์ต่าง ๆ ==
  
 
=== node.js และ npm ===
 
=== node.js และ npm ===
แถว 40: แถว 40:
 
ให้ทำตามรายการคำสั่งดังกล่าว
 
ให้ทำตามรายการคำสั่งดังกล่าว
  
ึึึคำสั่ง npm install จะติดตั้งไลบรารีที่เกี่ยวข้องลงในไดเร็กทอรี node_modules (ซึ่งจะใหญ่ขึ้นเรื่อยๆ อาจจะทำดิสก์เต็มได้) เมื่อติดตั้งเสร็จจะมีข้อความประมาณนี้
+
คำสั่ง npm install จะติดตั้งไลบรารีที่เกี่ยวข้องลงในไดเร็กทอรี node_modules (ซึ่งจะใหญ่ขึ้นเรื่อยๆ อาจจะทำดิสก์เต็มได้) เมื่อติดตั้งเสร็จจะมีข้อความประมาณนี้
  
 
  added 156 packages, and audited 157 packages in 33s
 
  added 156 packages, and audited 157 packages in 33s
แถว 114: แถว 114:
 
       <button>-1</button>
 
       <button>-1</button>
 
       &nbsp;&nbsp;&nbsp;
 
       &nbsp;&nbsp;&nbsp;
       { count }  
+
       {count}  
 
       &nbsp;&nbsp;&nbsp;
 
       &nbsp;&nbsp;&nbsp;
 
       <button>+1</button>
 
       <button>+1</button>
 
       <br />
 
       <br />
       Bar: { count > 0 ? "*".repeat(count) : "(empty)" }
+
       Bar: {count > 0 ? "*".repeat(count) : "(empty)"}
 
       <br />
 
       <br />
       { count > 10 ? "high" : "low" }
+
       {count > 10 ? (
 +
        <>
 +
          high
 +
          <button>reset</button>
 +
        </>
 +
      ) : (
 +
        <>low</>
 +
      )}
 
     </>
 
     </>
 
   )
 
   )
แถว 130: แถว 137:
 
[[Image:223-react-count-bar.png|300px]]
 
[[Image:223-react-count-bar.png|300px]]
  
ให้ลองแก้ค่าเริ่มต้นของ count เป็นค่าต่าง ๆ แล้วลองดูผลลัพธ์ เช่น เป็น 0, 10, 20 เป็นต้น
+
ให้ลองแก้ค่าเริ่มต้นของ count เป็นค่าต่าง ๆ แล้วลองดูผลลัพธ์ เช่น เป็น 0, 10, 20 เป็นต้น ให้ลองเปลี่ยนค่าจนเห็นปุ่ม reset
  
สังเกตการใช้ฟังก์ชัน <code>repeat</code> และการใช้ conditional expression (เงื่อนไข ? ค่าเมื่อจริง : ค่าเมื่อเท็จ) ในการเขียน
+
ข้อสังเกตจากโค้ดด้านบน
 +
* การใช้ <tt>&lt;> &lt;/></tt> (fragment) ในการประกาศส่วนที่เป็น html
 +
* ในการเขียน tag br ถ้าเป็น html ธรรมดา เราสามารถเขียน <tt>&lt;br></tt> ได้เลย แต่ใน jsx เราต้องมีการเว้นช่องว่างและระบุ / เพื่อปิด tag ด้วย (ต้องเขียนเป็น <tt>&lt;br /></tt>)
 +
* การใช้ฟังก์ชัน <code>repeat</code>  
 +
* การใช้ conditional expression (เงื่อนไข ? ค่าเมื่อจริง : ค่าเมื่อเท็จ) ในการเขียน
  
ถ้าสังเกต เราจะเห็นว่าใน ui ของเรามีปุ่ม -1 และ +1 อยู่ น่าจะเดาได้ว่าปุ่มทั้งสองจะทำอะไร แต่ตอนนี้กดไปก็ยังไม่มีอะไรเกิดขึ้น เพราะว่าเรายังไมไ่ด้เขียนโค้ดอะไรไปติดกับมัน
+
เราสามารถใส่ JavaScript expression ได้โดยใส่ไว้ในเครื่องหมายปีกกา { } และในนั้นถ้าเราต้องการใส่ html เราต้องเปิดปิดด้วย <> </> ก่อน (สังเกตในส่วนเงื่อนไข low, high)
 +
 
 +
ถ้าสังเกต เราจะเห็นว่าใน ui ของเรามีปุ่ม -1 และ +1 อยู่ (รวมถึง reset) น่าจะเดาได้ว่าปุ่มทั้งสองจะทำอะไร แต่ตอนนี้กดไปก็ยังไม่มีอะไรเกิดขึ้น เพราะว่าเรายังไมไ่ด้เขียนโค้ดอะไรไปติดกับมัน
  
 
=== ทิศทางการ render และ state ===
 
=== ทิศทางการ render และ state ===
แถว 141: แถว 154:
  
 
เมื่อมีการกดปุ่ม เราน่าจะต้องดำเนินการดังนี้
 
เมื่อมีการกดปุ่ม เราน่าจะต้องดำเนินการดังนี้
- แก้ค่า count
 
- ปรับการแสดงผล count
 
- คำนวณสตริงแสดงดาวให้เป็นไปตาม count
 
- ตรวจสอบเงื่อนไขในการแสดงว่า high หรือ low
 
  
ตัวอย่างโค้ดที่เราเขียนนี้ เป็นตัวอย่างของระบบ ui ที่มีความซับซ้อนไม่มากนัก แต่เราก็อาจจะพอนึกได้ว่า ถ้ามีการเปลี่ยนสถานะบางอย่าง จะส่งผลกระทบต่อหน้าจอหลายส่วน ซึ่งผลกระทบเหล่านี้
+
* แก้ค่า count
 +
* ปรับการแสดงผล count
 +
* คำนวณสตริงแสดงดาวให้เป็นไปตาม count
 +
* ตรวจสอบเงื่อนไขในการแสดงว่า high หรือ low  พร้อมกับแสดงหรือซ่อนปุ่ม reset
 +
 
 +
ตัวอย่างโค้ดที่เราเขียนนี้ เป็นตัวอย่างของระบบ ui ที่มีความซับซ้อนไม่มากนัก แต่เราก็อาจจะพอนึกได้ว่า ถ้ามีการเปลี่ยนสถานะบางอย่าง จะส่งผลกระทบต่อหน้าจอหลายส่วน ซึ่งผลกระทบเหล่านี้ถ้าต้องจัดการหลาย ๆ สถานะพร้อมกันจะซับซ้อนมาก
 +
 
 +
แนวคิดหลักที่ทำให้ React นั้นเป็นที่นิยมคือการเปลี่ยนมุมมองการเขียนโค้ดจากการคิดว่าถ้ามีสถานะอะไรเปลี่ยน เราจะต้องปรับอะไรบ้าง และอะไรจะกระทบกันบ้างให้เป็น
 +
 
 +
: '''เราไม่ต้องสนใจว่าจะต้องแก้ไขอะไรบ้าง แต่ให้พิจารณาการ render หน้าจอใหม่ทั้งหมดเลย'''
 +
 
 +
แต่ถ้าทำตามวิธีดังกล่าวตรง ๆ หน้าเว็บเราคงจะกระพริบไปกระพริบมา ไม่ต่างจากการกด refresh ทุกครั้ง &nbsp;&nbsp;&nbsp; สิ่งที่ทำให้แนวคิดดังกล่าวนำมาใช้ในการพัฒนา ui ได้จริงคือการ render ลงไปที่ virtual DOM ก่อนที่จะปรับเปลี่ยนหน้าจอจริง ๆ เฉพาะในส่วนที่มีความแตกต่าง ทำให้การแก้ไขไม่ได้กระทบหน้าจอทั้งหมด
 +
 
 +
รูปด้านล่างเปรียบเทียบวิธีการจัดการกับ interaction สองแบบ
 +
 
 +
[[Image:223-react-states-render.jpeg|500px]]
 +
 
 +
ด้านล่างแสดงตัวอย่างของการ update หน้าจอเป็นส่วนๆ
 +
 
 +
[[Image:223-react-virtual-dom.jpeg|500px]]
 +
 
 +
=== ไอเดียที่อยากทำ ===
 +
 
 +
เราอยากจะให้ปรับค่าตัวแปร count ถ้ามีการกดปุ่ม เราอาจจะแก้ส่วน button -1 โดยเพิ่มตัวจัดการ event onClick ลงไป ให้เป็นดังนี้
 +
 
 +
<button onClick={() => {count--;}}>-1</button>
 +
 
 +
หมายเหตุ: สังเกตการเขียน function ด้วย arrow
 +
 
 +
ให้ทดลองแก้แล้วทดลองดูผล
 +
 
 +
สังเกตว่าเมื่อกดปุ่ม เราก็ยังไม่เห็นอะไร
 +
 
 +
เป็นไปได้ว่าไม่มีการทำงาน หรือไม่มีการปรับค่า เราจะแก้โค้ดใหม่ ให้มีการ alert ค่า count มาดูด้วย ดังด้านล่าง
 +
 
 +
<button onClick={() => {count--; alert(count)}}>-1</button>
 +
 
 +
สังเกตว่าค่าตัวแปร count นั้นลดลงจริง แต่ไม่มีผลอะไรกับการแสดงบนหน้าจอเลย  ก่อนจะอ่านต่อไป ให้คิดว่าเป็นเพราะอะไร
 +
 
 +
คำตอบก็คือ การที่เราแก้ค่าตัวแปรนั้น ไม่มีผลต่อการกลับไป render App ใหม่ เพราะว่า React ไม่ทราบว่ามีการเปลี่ยนสถานะแล้ว
 +
 
 +
=== React hook useState ===
 +
 
 +
ใน React เวอร์ชันใหม่ (ไม่มากนัก) มีการเพิ่ม [https://react.dev/reference/react/hooks hook] ที่เป็นฟังก์ชันพิเศษที่ทำให้เราสามารถ "จิ้ม" เข้าไปในการทำงานของ React เพื่อเพิ่มการจัดการเกี่ยวกับ state และ side effect ได้
 +
 
 +
เราจะเพิ่ม state ให้กับ component โดยการเรียก react hook ที่ชื่อว่า <tt>useState</tt> โดยเราจะแก้ส่วนหัวของ function <tt>App</tt> ให้เป็นดังนี้
 +
 
 +
  // ของเดิม: let count = 5;
 +
  const [count, setCount] = useState(0);
 +
 
 +
บรรทัดดังกล่าวประกาศว่าใน component App จะมี state <tt>count</tt> ที่มีค่าเริ่มต้นเป็น 0 และมีฟังก์ชัน <tt>setCount</tt> ที่เราสามารถเรียกเพื่อเปลี่ยนค่าได้
 +
 
 +
สังเกตว่าทั้ง count และ setCount จะเป็น constant
 +
 
 +
จากนั้นเราจะแก้ onClick ของปุ่ม -1 ให้เป็นดังนี้
 +
 
 +
<button onClick={() => {setCount(count - 1)}}>-1</button>
 +
 
 +
กล่าวคือ ถ้ามีการกดปุ่ม -1 ให้ปรับค่า state count (ผ่านทางฟังก์ชัน setCount) ให้เป็น count -1
 +
 
 +
ให้ลองแก้, จัดเก็บ, และทดลองกดปุ่ม
 +
 
 +
{{กล่องฟ้า|
 +
'''งานของคุณ''': ให้แก้ปุ่ม +1 และ reset ให้ทำงานตามที่เราต้องการ โดยเพิ่ม onClick และเรียก setCount ให้เหมาะสม
 +
}}
  
 
== Todo list แบบไม่มี server ==
 
== Todo list แบบไม่มี server ==
  
 +
หลังจากการทดลองเบื้องต้นในส่วนที่ผ่านมา เราจะใช้โครงของ React app เดิมมาทำแอพ todo list แบบง่าย ๆ
 +
 +
=== แสดงรายการเริ่มต้น (ยังไม่มี state) ===
 +
 +
ให้แก้ component App ให้เป็นดังด้านล่าง
 +
 +
<syntaxhighlight>
 +
function App() {
 +
  let todoList = [
 +
    { id: 1,
 +
      title: 'Learn React',
 +
      done: true },
 +
    { id: 2,
 +
      title: 'Build a React App',
 +
      done: false },
 +
  ];
 +
 +
  return (
 +
    <>
 +
      <h1>Todo List</h1>
 +
      <ul>
 +
        {todoList.map(todo => (
 +
          <li key={todo.id}>
 +
            <span className={todo.done ? "done" : ""}>{todo.title}</span>
 +
          </li>
 +
        ))}
 +
      </ul>
 +
    </>
 +
  )
 +
}
 +
</syntaxhighlight>
 +
 +
'''สังเกต''':
 +
* ให้สังเกตการใช้ <tt>map</tt> กับ <tt>todoList</tt>  เราส่ง arrow function ที่รับ todo เข้าไปใน map เพื่อเรียกใช้ฟังก์ชันดังกล่าวกับทุก ๆ todo ในรายการ
 +
* เราจะมี prop <tt>key</tt> ที่ระบุให้กับ element <tt>li</tt> สำหรับให้ React ตรวจสอบว่ามีการเพิ่มหรือลบขอในรายการหรือไม่ (โดยใช้ key เป็นเหมือนชื่อเฉพาะที่ใช้เรียกข้อมูลนี้
 +
 +
นอกจากนี้ให้เพิ่มกฎ .done ลงใน <tt>App.css</tt> ดังด้านล่างด้วย เพื่อแสดงรายการที่มีขีด
 +
 +
.done {
 +
  text-decoration: line-through;
 +
}
 +
 +
ลองเซฟและดูผล ควรจะเห็นหน้าจอเป็นดังนี้
 +
 +
[[Image:223-react-todo-init.png|400px]]
 +
 +
=== ใช้ useState และเพิ่มปุ่ม toggle ===
 +
 +
เราจะเก็บ todoList ใน state ดังนั้นให้ปรับส่วนประกาศให้เป็นดังด้านล่างนี้
 +
 +
<pre>
 +
  const initialTodoList = [      // เปลี่ยนชื่อ
 +
    { id: 1,
 +
      title: 'Learn React',
 +
      done: true },
 +
    { id: 2,
 +
      title: 'Build a React App',
 +
      done: false },
 +
  ];
 +
 +
  const [todoList, setTodoList] = useState(initialTodoList);
 +
</pre>
 +
 +
เราจะเพิ่มปุ่ม toggle เอาไว้เพื่อปรับสถานะการทำเสร็จ  ให้แก้ในส่วน jsx template ที่แสดงรายการให้เป็นดังนี้
 +
 +
<pre>
 +
        {todoList.map(todo => (
 +
          <li key={todo.id}>
 +
            <span className={todo.done ? "done" : ""}>{todo.title}</span>
 +
            <button>toggle</button>
 +
          </li>
 +
        ))}
 +
</pre>
 +
 +
เราจะเขียนตัวจัดการ event onClick ของปุ่ม toggle โดยเราจะสร้างฟังก์ชัน toggleDone และเรียกใช้  ให้เพิ่ม onClick เข้าไปดังนี้
 +
 +
<pre>
 +
            <button onClick={() => {toggleDone(todo.id)}}>toggle</button>
 +
</pre>
 +
 +
เราจะเขียนฟังก์ชัน toggleDone ไว้ในฟังก์ชัน App ก่อนถึงคำสั่ง return
 +
 +
<syntaxhighlight>
 +
function App() {
 +
  const initialTodoList = [
 +
    // ละไว้
 +
  ];
 +
 +
  const [todoList, setTodoList] = useState(initialTodoList);
 +
 +
  function toggleDone(id) {
 +
    // ** เราจะเขียนตรงนี้ **
 +
  }
 +
 +
  return (
 +
    // ละไว้
 +
  )
 +
}
 +
</syntaxhighlight>
 +
 +
ฟังก์ชันดังกล่าว ต้องรับผิดชอบในการปรับ state ของ <tt>App</tt> โดยการเรียก <tt>setTodoList</tt>  สังเกตว่าในการทำงานดังกล่าว เราจะต้องสร้างรายการใหม่ '''ทั้งรายการ''' ไม่ใช่แค่ปรับค่า todo แต่ข้อมูลเดียว เพราะว่าฟังก์ชัน setTodoList ทำงานกับทั้งรายการ
 +
 +
ดังนั้นโครงของ toggleDone จะเป็นดังนี้
 +
 +
<syntaxhighlight>
 +
  function toggleDone(id) {
 +
    let newTodoList =  ...
 +
    // ...
 +
    // ...
 +
    setTodoList(newTodoList);
 +
  }
 +
</syntaxhighlight>
 +
 +
{{กล่องฟ้า|
 +
'''งานของคุณ''' ให้ทดลองเขียนเองก่อน แล้วค่อยดูเฉลยที่ใช้ map ด้านล่าง
 +
}}
 +
 +
'''เฉลย''' เราสามารถใช้ map ในการไล่ปรับค่าได้ สังเกตว่าใน arrow function ที่ส่งให้ map นั้น เราจะตรวจสอบ object todo ว่า id ตรงหรือไม่ ถ้าไม่ตรงก็คืนค่าเดิม แต่ถ้าตรง เราจะคือ object ใหม่ที่คัดลอกของจาก todo เดิม ก่อนจะปรับแค่ done ให้มีค่าตรงกันข้าม
 +
 +
<syntaxhighlight>
 +
  function toggleDone(id) {
 +
    let newTodoList = todoList.map(todo => {
 +
      if (todo.id === id) {
 +
        return { ...todo, done: !todo.done };
 +
      }
 +
      return todo;
 +
    });
 +
    setTodoList(newTodoList);
 +
  }
 +
</syntaxhighlight>
 +
 +
ให้สังเกตการใช้ rest property <tt>...</tt> (ตรง ...todo) ในการคัดลอกคุณสมบัติที่เหลือทั้งหมดของ todo ที่เราไม่ได้กำหนด
 +
 +
{{กล่องฟ้า|
 +
'''งานของคุณ''' ให้แก้ปุ่ม toggle ให้แสดงเป็นข้อความว่า Done ถ้าการกดทำให้ done และแสดงว่า Reset ถ้ากดแล้วกลับมา done เป็น false แทนที่จะแสดงว่า toggle อย่างเดียว
 +
}}
 +
 +
'''ทดลอง refresh''' เนื่องจากเราไม่มี server สำหรับเก็บข้อมูล ถ้าเรา refresh หน้าจอเว็บ ข้อมูลทุกอย่างจะกลับไปเหมือนตอนเริ่มต้น
 +
 +
== Todo list: add new item ==
 +
 +
ความสะดวกของการใช้ useState ก็คือเราสามารถสร้าง state เพื่อมาติดตามการเปลี่ยนแปลงค่าของ input ในหน้าจอได้โดยสะดวก
 +
 +
ให้เพิ่ม state <tt>newTitle</tt> ใน <tt>App</tt>
 +
 +
  const [newTitle, setNewTitle] = useState("");
 +
 +
จากนั้นให้เพิ่ม input สำหรับการเพิ่ม todo ในรายการ
 +
 +
<syntaxhighlight>
 +
  return (
 +
    <>
 +
      <h1>Todo List</h1>
 +
      <ul>
 +
        ... ละไว้ ...
 +
      </ul>
 +
      New: <input type="text" value={newTitle} onChange={(e) => {setNewTitle(e.target.value)}} />
 +
      <button>Add</button>
 +
    </>
 +
  )
 +
</syntaxhighlight>
 +
 +
สังเกตว่าเรากำหนด event onChange ให้ไปกำหนดค่าใหม่ให้กับ state <tt>newTitle</tt>  สังเกตว่า function จัดการ onChange มีการอ้างถึง e ที่เป็นข้อมูลที่มากับ event และอ่านค่า value ของ element ดังกล่าว (ซึ่งก็คือ input ที่เราติดตามอยู่นี่เอง)
 +
 +
เราสามารถทดลองพิมพ์ค่า state ดังกล่าวออกมาได้ โดยเพิ่ม onClick ที่ button ดังนี้
 +
 +
<syntaxhighlight>
 +
      <button onClick={() => {alert(newTitle)}}>Add</button>
 +
</syntaxhighlight>
 +
 +
ให้ทดลองพิมพ์ข้อความและกดปุ่ม จากนั้นแก้ข้อความแล้วกดปุ่มอีกครั้ง
 +
 +
เราจะเขียนฟังก์ชัน <tt>addNewTodo</tt> เพื่อเพิ่ม todo ลงในรายการ
 +
 +
ก่อนอื่นให้ปรับให้ event onClick ที่ปุ่ม Add ไปเรียกฟังก์ชันนี้ก่อน
 +
 +
<syntaxhighlight>
 +
      <button onClick={() => {addNewTodo()}}>Add</button>
 +
</syntaxhighlight>
 +
 +
และเพิ่มฟังก์ชันทั้งสองลงด้านในของ function App
 +
 +
<syntaxhighlight lang="javascript">
 +
  function newId() {
 +
    if (todoList.length == 0) {
 +
      return 1;
 +
    }
 +
    let maxId = todoList[0].id;
 +
    todoList.forEach(todo => {
 +
      if (todo.id > maxId) {
 +
        maxId = todo.id;
 +
      }
 +
    });
 +
    return maxId + 1;
 +
  }
 +
 +
  function addNewTodo() {
 +
    let title = newTitle;
 +
    let newTodo = {
 +
      id: newId(),
 +
      title: title,
 +
      done: false,
 +
    };
 +
    setTodoList([...todoList, newTodo]);
 +
    setNewTitle("");
 +
  }
 +
</syntaxhighlight>
 +
 +
'''หมายเหตุ''' ฟังก์ชัน <tt>newId</tt> หาค่า id มากสุด แล้วคืนค่าดังกล่าว +1  เราสามารถใช้ฟังก์ชัน Math.max (ที่รับรายการมาใน argument หลายตัว) ร่วมกับ spread operator ทำให้เขียนสั้นลงได้เป็นดังนี้
 +
 +
<syntaxhighlight lang="javascript">
 +
  function newId() {
 +
    if (todoList.length == 0) {
 +
      return 1;
 +
    }
 +
    return 1 + Math.max(...(todoList.map(todo => todo.id)));
 +
  }
 +
</syntaxhighlight>
  
 
== Todo list: delete todo item ==
 
== Todo list: delete todo item ==
 +
 +
เราจะเพิ่มปุ่ม delete เพื่อลบของออกจากรายการ ให้เพิ่มปุ่มสำหรับทุก ๆ todo
 +
 +
<pre>
 +
            <button onClick={() => {deleteTodo(todo.id)}}>❌</button>
 +
</pre>
 +
 +
{{กล่องฟ้า|
 +
'''งานของคุณ''' เขียนฟังก์ชัน deleteTodo
 +
 +
'''คำแนะนำ''': ให้ลองใช้ [https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter filter] ในการเลือกข้อมูลในรายการ todoList ที่ต้องการเก็บไว้ (ใช้คล้ายๆ  map)
 +
}}
  
 
== การใช้ Flask เป็น backend: load รายการ todo list ==
 
== การใช้ Flask เป็น backend: load รายการ todo list ==
 +
 +
เราจะสร้าง project Flask เพื่อทำเป็น backend ให้หาไดเร็กทอรีใหม่ และสร้าง virtual environment รวมทั้งติดตั้งไลบรารีที่ต้องใช้ในการพัฒนา Flask [[01204223/flask|อ่านทบทวนได้ที่เอกสารตอนที่ทำ Flask]]
 +
 +
สิ่งที่แตกต่างไปก็คือ ตอนนี้หน้าเว็บที่เราทำจะเป็น API ที่ React จะมาเรียกใช้อีกที  ดังนั้นสิ่งที่ function ที่จัดการกับแต่ละ URL คืนกลับไปจะเป็นข้อมูลประเภท json
 +
 +
ด้านล่างเป็นโค้ด <tt>main.py</tt> ที่รับ request ที่ url /api/todos/ และจะคืนรายการเป็น json  สังเกตว่าเราจะให้รายการใน todo list แตกต่างจากใน React เพราะว่าถ้าโหลดรายการได้ จะได้เห็นความแตกต่าง
 +
 +
<syntaxhighlight lang="python">
 +
from flask import Flask, request, jsonify
 +
 +
app = Flask(__name__)
 +
 +
todo_list = [
 +
    { "id": 1,
 +
      "title": 'Learn Flask',
 +
      "done": True },
 +
    { "id": 2,
 +
      "title": 'Build a Flask App',
 +
      "done": False },
 +
]
 +
 +
@app.route('/api/todos/')
 +
def get_todos():
 +
    return jsonify(todo_list)
 +
</syntaxhighlight>
 +
 +
'''หมายเหตุ''': สังเกตการใช้งานฟังก์ชัน <tt>jsonify</tt>
 +
 +
ให้เรียกให้ web development server ทำงาน อย่าลืม export FLASK_APP=main.py แล้วเรียก
 +
 +
flask run --debug
 +
 +
จากนั้นให้ลองเข้าเว็บที่ [http://localhost:5000/api/todos/ http://localhost:5000/api/todos/]  จะเห็นข้อมูลแบบ json ที่คืนกลับมา
 +
 +
ตอนนี้เราจะมี terminal ที่เรียก development server ทำงานอยู่สองโปรแกรม อันหนึ่งของ React (frontend) อีกอันเป็นของ Flask (backend) อย่าเพิ่งสับสน
 +
 +
[[Image:223-vite-flask-server-console.png|500px]]
 +
 +
=== ใช้ useEffect ในการเรียก api เพื่อโหลดค่าเริ่มต้นของ todoList ===
 +
 +
ให้แก้ส่วน import ตอนต้น <tt>App.jsx</tt> ให้ import useEffect มาด้วย
 +
 +
import { useState, useEffect } from 'react'
 +
 +
จากนั้นเพิ่มค่าคงที่แทน URL ของ api endpoint และเขียนโค้ดให้โหลดข้อมูล จากนั้นนำมากำหนดค่าให้กับ todoList
 +
 +
<syntaxhighlight lang="javascript">
 +
function App() {
 +
  const TODOLIST_API_URL = 'http://localhost:5000/api/todos/';
 +
 +
  // .. ละไว้
 +
 +
  // ประกาศหลังประกาศ setTodoList ใน useState
 +
  useEffect(() => {
 +
    fetchTodoList();
 +
  }, []);
 +
 +
  async function fetchTodoList() {
 +
    try {
 +
      const response = await fetch(TODOLIST_API_URL);
 +
      if (!response.ok) {
 +
        throw new Error('Network error');
 +
      }
 +
      const data = await response.json();
 +
      setTodoList(data);
 +
    } catch (err) {
 +
      alert("Failed to fetch todo list from backend. Make sure the backend is running.");
 +
    }
 +
  }
 +
 +
  // ..
 +
}
 +
</syntaxhighlight>
 +
 +
เมื่อเรียกใช้งาน ในหน้าจอ server ของ Flask จะเห็นว่ามีการเรียกมายัง api แต่หน้าเว็บของ React จะยังไม่มีการเปลี่ยนแปลง
 +
 +
ถ้าไปกด Inspection และดูที่หน้า console จะพบว่าการเรียกดังกล่าวถูก block ด้วย [https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS CORS policy] เพราะว่า frontend เราทำงานที่ http://localhost:5173/ ส่วน backend เราอยู่ที่ http://localhost:5000/api/todo/ ซึ่งถือว่าเป็นคนละที่กัน
 +
 +
[[Image:223-react-cors-error.png|800px]]
 +
 +
ถ้าจะทำให้สามารถเรียกได้ ใน Flask เราจะต้องเพิ่มโค้ดเพื่อเปิดการอนุญาตนี้ด้วย
  
 
=== CORS ===
 
=== CORS ===
 +
 +
เราต้องติดตั้งไลบรารี [https://pypi.org/project/flask-cors/ Flask-CORS] เสียก่อน  ให้เบรคออกมาจาก flask run แล้วเรียก
 +
 +
pip install flask-cors
 +
 +
เพื่อติดตั้งไลบรารี
 +
 +
จากนั้นให้เพิ่มบรรทัดที่เรียก CORS เพิ่มไปในไฟล์ main.py (บรรทัดที่เพิ่มจะมี remark ด้านหลัง)
 +
 +
<syntaxhighlight lang="python">
 +
from flask import Flask, request, jsonify
 +
from flask_cors import CORS                  # เพิ่ม import
 +
 +
app = Flask(__name__)
 +
CORS(app)                                    # เพิ่มการอนุญาต
 +
 +
# ... ละไว้
 +
</syntaxhighlight>
 +
 +
ถ้าแก้แล้ว ให้ start flask run ใหม่ แล้วลอง refresh เว็บ React front end น่าจะเห็นว่ามีรายการใหม่ เป็น Learn Flask แทนที่ Learn React
 +
 +
=== ทำความเข้าใจกับการเรียก fetch (async / await) ===
 +
เราจะกลับมาดูการเรียก api จาก React ผ่านทางฟังก์ชัน fetch ([https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch Fetch API]) ตามโค้ดด้านล่าง
 +
 +
<syntaxhighlight lang="javascript">
 +
  async function fetchTodoList() {
 +
    try {
 +
      const response = await fetch(TODOLIST_API_URL);
 +
      const data = await response.json();
 +
      setTodoList(data);
 +
    } catch (err) {
 +
      alert("Failed to fetch todo list from backend. Make sure the backend is running.");
 +
    }
 +
  }
 +
</syntaxhighlight>
 +
 +
ในโค้ดด้านล่างมีการใช้ keyword <tt>await</tt> และ <tt>async</tt> ซึ่งเป็น keyword ที่เกี่ยวข้องกับการ "รอ" การทำงานแบบ asynchronous
 +
 +
เพื่อความเรียบง่ายและป้องกันปัญหาหลายๆ อย่าง ภาษา JavaScript จะมีตัว run-time ที่ทำงานแบบ single thread นั่นคือ จะไม่มีการทำงานหลาย ๆ งานพร้อมกัน  ดังนั้น ถ้ามีกระบวนการใดทำงานค้างอยู่ กระบวนการอื่น ๆ ก็จะไม่สามารถทำอะไรได้
 +
 +
ยกตัวอย่างเช่น ถ้าเราเรียก request ผ่านไปทางเน็ตเวิร์ค (เช่นผ่านทางฟังก์ชัน <tt>fetch</tt> หรือการแกะ json ผ่านทาง <tt>.json()</tt>) ซึ่งจะต้องใช้เวลาในการรอคอยคำตอบ ถ้าทุกอย่างต้องหยุดรอผลลัพธ์ การอัพเดทหน้าจอต่าง ๆ รวมถึง animation หรือการประมวลผลอื่น ๆ ก็จะหยุดค้างไปด้วย ซึ่งส่งผลเสียอย่างร้ายแรงต่อประสบการณ์ของผู้ใช้  &nbsp;&nbsp;&nbsp; ดังนั้นในภาษา JavaScript ถ้ามีกิจกรรมที่ต้องมีการหยุดรอ โดยเฉพาะงานที่เป็นงานด้าน I/O คำสั่งโดยมากจึงเป็นคำสั่งแบบ asynchronous นั่นคือ จะเป็นคำสั่งที่ทำแล้ว "ปล่อยให้เกิดการทำงานต่อทันที" โดยไม่ต้องรอผล  แต่จะมีวิธีบางอย่างมาแจ้งกับเราว่าผลลัพธ์เสร็จแล้ว
 +
 +
เมื่อก่อนการเขียนแบบนี้จะใช้การเขียนแบบ callback ต่อมาพัฒนาเป็น promise และล่าสุด ด้วย syntax ของ JavaScript สมัยใหม่ เราสามารถใช้ keyword <tt>await</tt> ประกอบการเรียกฟังก์ชันเหล่านี้ เพื่อให้การทำงานเสมือนมีการหยุดรอผลลัพธ์โดยอัตโนมัติ
 +
 +
วิธีการใช้คร่าว ๆ เป็นดังนี้
 +
 +
* สมมติว่าเรามีฟังก์ชันที่คืนค่าเป็น promise จากตัวอย่างด้านบน เช่น function <tt>fetch</tt> หรือ ฟังก์ชัน <tt>json</tt> ของ response ที่คืนค่าเป็น promise ดังรายละเอียดตาม document ที่คัดมาตามด้านล่าง
 +
** The <tt>[https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API fetch]()</tt> method takes one mandatory argument, the path to the resource you want to fetch. It returns a '''Promise''' that resolves to the Response to that request — as soon as the server responds with headers — even if the server response is an HTTP error status. (จาก [https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API])
 +
** The <tt>json()</tt> method of the [https://developer.mozilla.org/en-US/docs/Web/API/Response Response] interface takes a Response stream and reads it to completion. It returns a '''promise''' which resolves with the result of parsing the body text as JSON. (จาก [https://developer.mozilla.org/en-US/docs/Web/API/Response/json])
 +
* ในการเรียก ให้เราเพิ่ม keyword '''<tt>await</tt>''' ไว้ด้านหน้า จะทำให้การทำงานของฟังก์ชันหยุดรอจนกว่า promise จะ resolve (ทำงานเสร็จสิ้น)
 +
* ฟังก์ชันที่มีการใช้ <tt>await</tt> จะต้องประกาศ keyword '''<tt>async</tt>''' ด้านหน้าด้วย เพื่อระบุว่าเป็น asynchronous function
 +
 +
ถ้าสนใจทำความเข้าใจอย่างละเอียด [[js-async-await-gemini|สามารถอ่านสรุปจาก Gemini และมีตัวอย่างโปรแกรมให้ทดลองเพื่อเพิ่มความเข้าใจได้]]
  
 
== เพิ่ม todo item ==
 
== เพิ่ม todo item ==
  
== กิจกรรมอื่น ๆ delete และ done ==
+
ในการเพิ่ม todo item นั้น เนื่องจากเรามีทั้งส่วน backend และ frontend เราจะต้องทำงานสองส่วน
 +
 
 +
=== backend ===
 +
 
 +
เราต้องออกแบบ api endpoint สำหรับการเพิ่ม item &nbsp;&nbsp;&nbsp; จากที่เราได้เคยเขียนมา การแก้ไขและเพิ่มข้อมูลจะใช้ http request แบบ POST และมักจะใช้ url เดียวกับการอ่านข้อมูลในรายการ (ซึ่งจะเป็น request แบบ GET)
 +
 
 +
ดังนั้น เราจะแก้ฟังก์ชัน <tt>get_todos</tt> เดิมให้รับแต่ method GET และเพิ่มฟังก์ชัน <tt>new_todo</tt> เพื่อเพิ่มของในรายการ ดังด้านล่าง
 +
 
 +
<syntaxhighlight lang="python">
 +
@app.route('/api/todos/', methods=['GET'])      # ระบุให้รับแค่ GET
 +
def get_todos():
 +
    return jsonify(todo_list)
 +
 
 +
@app.route('/api/todos/', methods=['POST'])
 +
def add_todo():
 +
    # .... เราจะเขียนที่นี่
 +
</syntaxhighlight>
 +
 
 +
เมื่อเราเลือก url endpoint แล้ว เราต้องพิจารณาต่อว่าจะส่งข้อมูลอะไรจาก React frontend บ้าง แล้ว api จะคืนอะไรกลับไป  เราจะทำดังนี้
 +
 
 +
* frontend จะส่งข้อมูลมาเป็น json โดยระบุแค่ title (เช่น <tt>{ title: 'New task' }</tt>)
 +
* api จะสร้าง todo item ใหม่ (สร้าง id ใหม่ด้วย) แล้วคืนทั้ง todo item กลับไปแบบ json (เช่น <tt>{ id: 123, title: 'New task', done: false }</tt>)
 +
 
 +
ด้านล่างเป็นฟังก์ชันสำหรับสร้าง todo item (ยังไม่ใช่ฟังก์ชันสำหรับรับ request)  สังเกตว่าเรามีการตรวจสอบความถูกต้องของข้อมูลที่ได้รับมาด้วย (เช่น มี title หรือไม่)
 +
 
 +
<syntaxhighlight lang="python">
 +
def new_todo(data):
 +
    if len(todo_list) == 0:
 +
        id = 1
 +
    else:
 +
        id = 1 + max([todo['id'] for todo in todo_list])
 +
 
 +
    if 'title' not in data:
 +
        return None
 +
   
 +
    return {
 +
        "id": id,
 +
        "title": data['title'],
 +
        "done": getattr(data, 'done', False),
 +
    }
 +
</syntaxhighlight>
 +
 
 +
ด้านล่างเป็นฟังก์ชัน <tt>add_todo</tt> ที่รับ POST request แล้วแกะข้อมูล json ด้วย <tt>get_json</tt> ก่อนจะนำไปประมวลผล  ถ้าไม่สามารถเพิ่มข้อมูลได้ จะ return json ที่ระบุ error และใช้ http response code 400
 +
 
 +
<syntaxhighlight lang="python">
 +
@app.route('/api/todos/', methods=['POST'])
 +
def add_todo():
 +
    data = request.get_json()
 +
    todo = new_todo(data)
 +
    if todo:
 +
        todo_list.append(todo)
 +
        return jsonify(todo)
 +
    else:
 +
        # return http response code 400 for bad requests
 +
        return (jsonify({'error': 'Invalid todo data'}), 400) 
 +
</syntaxhighlight>
 +
 
 +
=== การทดสอบ api ===
 +
 
 +
ในการพัฒนาทั่วไป เมื่อเรามีส่วน backend แล้ว เราอาจจะสามารถ test โปรแกรมในส่วนดังกล่าวได้เลย โดยไม่ต้องรอการพัฒนาในส่วนของ frontend เครื่องมือที่นิยมใช้กันมีมากมาย เช่น postman, talend api tester (chrome extension) หรือ bruno เป็นต้น &nbsp;&nbsp; ในที่นี้เราจะละส่วนนี้ไปก่อน
 +
 
 +
=== frontend ===
 +
 
 +
ด้านล่างเป็นโค้ด <tt>addNewTodo</tt> ใหม่ ที่เพิ่มการเรียก api แล้ว  สังเกตว่าเราเปลี่ยนฟังก์ชันเป็น async function และใช้ await ในการรอผลลัพธ์จาก fetch api
 +
 
 +
ในการเรียก api เราจะระบุ method POST และระบุประเภท contest เป็น applicant/json ที่ request headers
 +
 
 +
<syntaxhighlight lang="javascript">
 +
  async function addNewTodo() {
 +
    try {
 +
      const response = await fetch(TODOLIST_API_URL, {
 +
        method: 'POST',
 +
        headers: {
 +
          'Content-Type': 'application/json',
 +
        },
 +
        body: JSON.stringify({ 'title': newTitle }),
 +
      });
 +
      if (response.ok) {
 +
        const newTodo = await response.json();
 +
        setTodoList([...todoList, newTodo]);
 +
        setNewTitle("");
 +
      }
 +
    } catch (error) {
 +
      console.error("Error adding new todo:", error);
 +
    }
 +
  }
 +
</syntaxhighlight>
 +
 
 +
เราสามารถทดลองเพิ่ม todo item ได้เลย ถ้าทุกอย่างเรียบร้อยจะสามารถเพิ่มข้อมูลในรายการได้
 +
 
 +
เนื่องจากเรามี server เก็บรายการแล้ว ถ้าเรา refresh หน้า web ui รายการทั้งหมดจะยังอยู่เหมือนเดิม
 +
 
 +
=== ใช้ Devtool ในการดูการส่งข้อมูลระหว่าง frontend กับ backend ===
 +
 
 +
บราวเซอร์สมัยใหม่จะมาพร้อมกับเครื่องมือสำหรับนักพัฒนา เราสามารถเรียกได้โดยการกด inspect ที่หน้าจอหรือกดจากเมนู
 +
 
 +
เมื่อเรากดเพิ่ม todo item ไป เราจะสามารถดูในส่วน network เพื่อศึกษาว่าเกิดอะไรขึ้นได้
 +
 
 +
ด้านล่างเป็นตัวอย่าง request ที่ทำงานถูกต้อง
 +
[[Image:223-react-devtool-1.png|600px]]
 +
 
 +
ถ้าเรากดไปดูรายละเอียด เราสามารถดูส่วน request headers, payload (ข้อมูลที่ส่งไป), และ response ได้ ดังรูปด้านล่าง
 +
 
 +
[[Image:223-react-devtool-inspect.png|600px]]
 +
 
 +
== กิจกรรมอื่น ๆ done และ delete ==
 +
 
 +
=== การ toggle done ===
 +
 
 +
เราจะใช้ url <tt>/api/todos/[id]/toggle/<tt> สำหรับการ toggle โดยจะใช้ method PATCH (เราจะใช้ PATCH เมื่อมีการแก้บางส่วนของข้อมูล ใช้ PUT ถ้าแก้ทั้งหมด)
 +
 
 +
ด้านล่างเป็น function ฝั่ง backend
 +
 
 +
<syntaxhighlight lang="python">
 +
@app.route('/api/todos/<int:id>/toggle/', methods=['PATCH'])
 +
def toggle_todo(id):
 +
    todos = [todo for todo in todo_list if todo['id'] == id]
 +
    if not todos:
 +
        return (jsonify({'error': 'Todo not found'}), 404)
 +
    todo = todos[0]
 +
    todo['done'] = not todo['done']
 +
    return jsonify(todo)
 +
</syntaxhighlight>
 +
 
 +
ฟังก์ชันนี้หลังจากการอัพเดทแล้ว จะคืน json ของ todo item ที่อัพเดทแล้วทั้งก้อนกลับมาเลย
 +
 
 +
สำหรับส่วน frontend เราจะเปลี่ยนฟังก์ชัน toggleDone เป็น async และใช้ string template literal ในการสร้าง URL
 +
 
 +
'''หมายเหตุ''' สังเกตว่าในการสร้าง URL ตรงท้าย TODOLIST_API_URL ของเรามี / ต่อท้ายอยู่แล้ว ในการต่อ id เข้าไปเราจึงต่อไปเลย ไม่มีการเพิ่ม / อีกที
 +
 
 +
<syntaxhighlight lang="javascript">
 +
  async function toggleDone(id) {
 +
    const toggle_api_url = `${TODOLIST_API_URL}${id}/toggle/`
 +
    // งานของคุณ: เขียนส่วนที่เหลือเอง
 +
    // ให้เรียก fetch ไปที่ url ดังกล่าว แบบ patch, ถ้าทำงานสำเร็จให้สร้าง todo item ใหม่ไปแทนของเดิม
 +
  }
 +
</syntaxhighlight>
 +
 
 +
=== การ delete ===
 +
 
 +
เราจะสร้าง api ที่รับ http request แบบ DELETE ไปที่ url ของ todo items ด้านล่างเป็นฟังก์ชันใน frontend ที่แก้ไขแล้ว
 +
 
 +
<syntaxhighlight lang="javascript">
 +
  async function deleteTodo(id) {
 +
    const delete_api_url = `${TODOLIST_API_URL}${id}/`
 +
    try {
 +
      const response = await fetch(delete_api_url, {
 +
        method: 'DELETE',
 +
      });
 +
      if (response.ok) {
 +
        setTodoList(todoList.filter(todo => todo.id !== id));
 +
      }
 +
    } catch (error) {
 +
      console.error("Error deleting todo:", error);
 +
    }
 +
  }
 +
</syntaxhighlight>
 +
 
 +
ด้านล่างเป็นการประกาศในส่วน backend ตอนต้น เราจะสั่ง global todo_list เพราะว่าเราอาจจะต้องมีการ assign list ใหม่ทั้งชิ้นให้กับตัวแปร todo_list ที่เป็น global &nbsp;&nbsp; อย่างไรก็ตาม ถ้าใช้คำสั่ง del ในการลบข้อมูลในรายการเอง ก็อาจจะไม่จำเป็นต้องระบุ global ก็ได้
 +
 
 +
<syntaxhighlight lang="python">
 +
@app.route('/api/todos/<int:id>/', methods=['DELETE'])
 +
def delete_todo(id):
 +
    global todo_list
 +
    # งานของคุณ: ลบ todo item ที่มี id ตรงกับ id
 +
    # ถ้าทำงานถูกต้อง ให้คืน response ที่เป็น json ว่าง ๆ หรือ response message บางอย่างกลับไปก็ได้
 +
</syntaxhighlight>
  
 
== เรื่องอื่น ๆ ที่ต้องทำ ==
 
== เรื่องอื่น ๆ ที่ต้องทำ ==
 +
 +
ในการพัฒนาเว็บแอพให้สามารถทำงานได้จริง ยังมีอีกหลายเรื่องที่ต้องทำ เรื่องหลัก ๆ มีดังนี้
 +
 +
'''ระบบรวม'''
 +
* Authentication
 +
 +
'''หลังบ้าน'''
 +
* ใช้ database จริง
 +
 +
'''หน้าบ้าน'''
 +
* แยก component ของหน้าเว็บเป็นส่วน ๆ แทนที่จะมี component App เพียงอย่างเดียว

รุ่นแก้ไขปัจจุบันเมื่อ 01:09, 9 มกราคม 2569

หน้านี้เป็นส่วนหนึ่งของวิชา 01204223

เริ่มต้นและการติดตั้งซอฟต์แวร์ต่าง ๆ

node.js และ npm

เนื่องจาก react เป็น JavaScript framework เราจำเป็นต้องติดตั้งซอฟต์แวร์ที่เกี่ยวข้องเสียก่อน การใช้ JavaScript บนเครื่องเราเองนั้น เราต้องมีตัว runtime ที่ใช้ทำงานกับโปรแกรมภาษา JavaScript &nbps;  ในทางฝั่ง server Node.js น่าจะเป็นระบบที่มีคนใช้มากที่สุด ที่มาพร้อมกับเครื่องมือประกอบต่าง ๆ มากมาย (มากมายจริง ๆ)

ในการจะใช้เครื่องมือบน node.js ได้นั้น เราจะต้องติดตั้งโปรแกรม npm (Node Package Manager) ด้วย ดังนั้น ในขั้นแรกเราจะติดตั้งทั้งสองอย่างไปด้วยกัน

การติดตั้งบน MacOS และ Ubuntu:

การติดตั้งบน Windows

เมื่อติดตั้งแล้ว ให้ลองเรียกทั้งสองคำสั่งด้านล่างใน terminal

node -v
npm -v

ถ้ามีเลขเวอร์ชันขึ้นมาก็น่าจะเรียบร้อย

การสร้างโครง React project

ในการพัฒนาด้วย React เราจะเขียนโค้ดที่ไม่ได้ถูกนำไปรันบน browser โดยตรง เพราะว่าจะต้องมีการ preprocess อะไรก่อนมากมาย ซึ่งขั้นตอนเหล่านี้ถ้าเราต้อง setup เองทั้งหมดจะยุ่งยากมาก แต่เนื่องจาก environment ในการพัฒนาด้วย JavaScript นั้นมีเครื่องมือมากมาย เราจึงสามารถพึ่งพาระบบเหล่านั้นได้เลย

เราจะสร้างโครงโปรเจ็คด้วย vite โดยสั่ง (ให้เปิด terminal มาเรียกใช้งานเลย เพราะว่าจะมีคำสั่งที่ต้องเรียกค้างไว้ระหว่างพัฒนา)

npm create vite@latest first-react-app -- --template react

จะมีคำถามให้ตอบหลายคำถาม ถ้ามีการถามว่า

Install with npm and start now?

ให้เลือก No ไว้ก่อน เพราะว่าเราจะได้กดอะไรต่าง ๆ เอง เมื่อ npm ทำงานเสร็จ จะมีคำสั่งบอกให้เราเรียกเพื่อเริ่มติดตั้งไลบรารีและเริ่มเรียก dev server ดังด้านล่าง

 cd first-react-app
 npm install
 npm run dev

ให้ทำตามรายการคำสั่งดังกล่าว

คำสั่ง npm install จะติดตั้งไลบรารีที่เกี่ยวข้องลงในไดเร็กทอรี node_modules (ซึ่งจะใหญ่ขึ้นเรื่อยๆ อาจจะทำดิสก์เต็มได้) เมื่อติดตั้งเสร็จจะมีข้อความประมาณนี้

added 156 packages, and audited 157 packages in 33s

ส่วนคำสั่ง

npm run dev

จะเป็นการเรียก development web server ให้ทำงาน ให้เรียกแล้วเปิด terminal ทิ้งไว้ เมื่อเรียกเสร็จโปรแกรมจะแสดง URL ให้เราเข้าดูหน้าเว็บ (น่าจะเป็น http://localhost:5173/ ) นอกจากนี้ ถ้าเราแก้ไขอะไรในโปรเจ็ค vite จะจัดการ build โค้ดทั้งหมดให้รวมทั้ง reload หน้าเว็บของเราใน browser ให้โดยอัตโนมัติ

ถ้าไม่มีข้อผิดพลาดอะไร ใน browser จะแสดงโลโก้ Vite และ React

อ่านเพิ่มเติมเกี่ยวกับการทำงานของ vite

ทดลอง React เบื้องต้น

ก่อนที่เราจะทดลองพัฒนาแอพลิเคชัน เราจะศึกษาพื้นฐานการทำงานของ React ผ่านทางเว็บง่าย ๆ ก่อน ในไดเร็กทอรี first-react-app ที่เราสร้าง จะมีไฟล์

index.html

ซึ่งจะเป็นหน้าเว็บหลักของเรา ถ้ากดเขาไปดูจะพบหน้าเว็บที่เกือบ ๆ จะว่าง แต่มีส่วนโหลด /src/main.jsx อยู่

<!doctype html>
<html lang="en">
  <!-- ## ละไว้บางส่วน ## -->  
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

ส่วนที่เราจะเขียนโปรแกรมกันจริงๆ จะอยู่ในไดเร็กทอรี src อยู่ ในนั้นจะมีไฟล์เหล่านี้

- App.jsx      // โค้ดของ component App (เราจะเขียนกันที่ไฟล์นี้ก่อน)
- main.jsx     // สร้าง component App และนำไปใส่ใน element id root
- App.css      // จัดการกับ style สำหรับ component App
- index.css    // style ของแอพโดยรวม

main.jsx

ให้ลองกดไปดูโค้ดใน main.jsx สักเล็กน้อย (เราจะไม่ได้แก้อะไร)

// ----- ไฟล์ main.jsx ------  (เพื่อกันความสับสนบางทีจะมีแจ้งชื่อไฟล์ไว้ด้านบน)
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

โค้ดดังกล่าวเป็นไฟล์ jsx ซึ่งเป็นไฟล์ที่มีการผสมกันระหว่าง JavaScript กับ template สังเกตว่าเราจะเขียน html tag ผสมกับโค้ดได้เลย ในส่วนนั้นจะมีการทำงานไม่ต่างกับ Jinja teamplate ที่เราเขียนใน Flask มาแล้ว

App.jsx

เราจะทำงานกับไฟล์นี้เป็นส่วนใหญ่ โค้ดปัจจุบันมีการทำงานหลายอย่างอยู่แล้ว แต่เราจะเริ่มทำใหม่กันตั้งแต่แรก

เราจะสร้าง component App ที่แทนแอพลิเคชันของเรา โดยในการเขียน เราจะประกาศเป็น function App ให้แก้ function ดังกล่าวในโค้ดให้เป็นดังด้านล่างก่อน

function App() {
  let count = 5;

  return (
    <>
      <button>-1</button>
      &nbsp;&nbsp;&nbsp;
      {count} 
      &nbsp;&nbsp;&nbsp;
      <button>+1</button>
      <br />
      Bar: {count > 0 ? "*".repeat(count) : "(empty)"}
      <br />
      {count > 10 ? (
        <>
          high
          <button>reset</button>
        </>
      ) : (
        <>low</>
      )}
    </>
  )
}

เมื่อ save แล้วจะเห็นผลลัพธ์เป็นดังนี้

223-react-count-bar.png

ให้ลองแก้ค่าเริ่มต้นของ count เป็นค่าต่าง ๆ แล้วลองดูผลลัพธ์ เช่น เป็น 0, 10, 20 เป็นต้น ให้ลองเปลี่ยนค่าจนเห็นปุ่ม reset

ข้อสังเกตจากโค้ดด้านบน

  • การใช้ <> </> (fragment) ในการประกาศส่วนที่เป็น html
  • ในการเขียน tag br ถ้าเป็น html ธรรมดา เราสามารถเขียน <br> ได้เลย แต่ใน jsx เราต้องมีการเว้นช่องว่างและระบุ / เพื่อปิด tag ด้วย (ต้องเขียนเป็น <br />)
  • การใช้ฟังก์ชัน repeat
  • การใช้ conditional expression (เงื่อนไข ? ค่าเมื่อจริง : ค่าเมื่อเท็จ) ในการเขียน

เราสามารถใส่ JavaScript expression ได้โดยใส่ไว้ในเครื่องหมายปีกกา { } และในนั้นถ้าเราต้องการใส่ html เราต้องเปิดปิดด้วย <> </> ก่อน (สังเกตในส่วนเงื่อนไข low, high)

ถ้าสังเกต เราจะเห็นว่าใน ui ของเรามีปุ่ม -1 และ +1 อยู่ (รวมถึง reset) น่าจะเดาได้ว่าปุ่มทั้งสองจะทำอะไร แต่ตอนนี้กดไปก็ยังไม่มีอะไรเกิดขึ้น เพราะว่าเรายังไมไ่ด้เขียนโค้ดอะไรไปติดกับมัน

ทิศทางการ render และ state

ก่อนจะเขียน React ต่อไป ให้ลองคิดว่าถ้าเราต้องเขียนโปรแกรมเพื่อจัดการกับค่า count หลังการกดปุ่มทั้งสอง เราต้องทำอะไรบ้าง

เมื่อมีการกดปุ่ม เราน่าจะต้องดำเนินการดังนี้

  • แก้ค่า count
  • ปรับการแสดงผล count
  • คำนวณสตริงแสดงดาวให้เป็นไปตาม count
  • ตรวจสอบเงื่อนไขในการแสดงว่า high หรือ low พร้อมกับแสดงหรือซ่อนปุ่ม reset

ตัวอย่างโค้ดที่เราเขียนนี้ เป็นตัวอย่างของระบบ ui ที่มีความซับซ้อนไม่มากนัก แต่เราก็อาจจะพอนึกได้ว่า ถ้ามีการเปลี่ยนสถานะบางอย่าง จะส่งผลกระทบต่อหน้าจอหลายส่วน ซึ่งผลกระทบเหล่านี้ถ้าต้องจัดการหลาย ๆ สถานะพร้อมกันจะซับซ้อนมาก

แนวคิดหลักที่ทำให้ React นั้นเป็นที่นิยมคือการเปลี่ยนมุมมองการเขียนโค้ดจากการคิดว่าถ้ามีสถานะอะไรเปลี่ยน เราจะต้องปรับอะไรบ้าง และอะไรจะกระทบกันบ้างให้เป็น

เราไม่ต้องสนใจว่าจะต้องแก้ไขอะไรบ้าง แต่ให้พิจารณาการ render หน้าจอใหม่ทั้งหมดเลย

แต่ถ้าทำตามวิธีดังกล่าวตรง ๆ หน้าเว็บเราคงจะกระพริบไปกระพริบมา ไม่ต่างจากการกด refresh ทุกครั้ง     สิ่งที่ทำให้แนวคิดดังกล่าวนำมาใช้ในการพัฒนา ui ได้จริงคือการ render ลงไปที่ virtual DOM ก่อนที่จะปรับเปลี่ยนหน้าจอจริง ๆ เฉพาะในส่วนที่มีความแตกต่าง ทำให้การแก้ไขไม่ได้กระทบหน้าจอทั้งหมด

รูปด้านล่างเปรียบเทียบวิธีการจัดการกับ interaction สองแบบ

223-react-states-render.jpeg

ด้านล่างแสดงตัวอย่างของการ update หน้าจอเป็นส่วนๆ

223-react-virtual-dom.jpeg

ไอเดียที่อยากทำ

เราอยากจะให้ปรับค่าตัวแปร count ถ้ามีการกดปุ่ม เราอาจจะแก้ส่วน button -1 โดยเพิ่มตัวจัดการ event onClick ลงไป ให้เป็นดังนี้

<button onClick={() => {count--;}}>-1</button>

หมายเหตุ: สังเกตการเขียน function ด้วย arrow

ให้ทดลองแก้แล้วทดลองดูผล

สังเกตว่าเมื่อกดปุ่ม เราก็ยังไม่เห็นอะไร

เป็นไปได้ว่าไม่มีการทำงาน หรือไม่มีการปรับค่า เราจะแก้โค้ดใหม่ ให้มีการ alert ค่า count มาดูด้วย ดังด้านล่าง

<button onClick={() => {count--; alert(count)}}>-1</button>

สังเกตว่าค่าตัวแปร count นั้นลดลงจริง แต่ไม่มีผลอะไรกับการแสดงบนหน้าจอเลย ก่อนจะอ่านต่อไป ให้คิดว่าเป็นเพราะอะไร

คำตอบก็คือ การที่เราแก้ค่าตัวแปรนั้น ไม่มีผลต่อการกลับไป render App ใหม่ เพราะว่า React ไม่ทราบว่ามีการเปลี่ยนสถานะแล้ว

React hook useState

ใน React เวอร์ชันใหม่ (ไม่มากนัก) มีการเพิ่ม hook ที่เป็นฟังก์ชันพิเศษที่ทำให้เราสามารถ "จิ้ม" เข้าไปในการทำงานของ React เพื่อเพิ่มการจัดการเกี่ยวกับ state และ side effect ได้

เราจะเพิ่ม state ให้กับ component โดยการเรียก react hook ที่ชื่อว่า useState โดยเราจะแก้ส่วนหัวของ function App ให้เป็นดังนี้

  // ของเดิม: let count = 5;
  const [count, setCount] = useState(0);

บรรทัดดังกล่าวประกาศว่าใน component App จะมี state count ที่มีค่าเริ่มต้นเป็น 0 และมีฟังก์ชัน setCount ที่เราสามารถเรียกเพื่อเปลี่ยนค่าได้

สังเกตว่าทั้ง count และ setCount จะเป็น constant

จากนั้นเราจะแก้ onClick ของปุ่ม -1 ให้เป็นดังนี้

<button onClick={() => {setCount(count - 1)}}>-1</button>

กล่าวคือ ถ้ามีการกดปุ่ม -1 ให้ปรับค่า state count (ผ่านทางฟังก์ชัน setCount) ให้เป็น count -1

ให้ลองแก้, จัดเก็บ, และทดลองกดปุ่ม

งานของคุณ: ให้แก้ปุ่ม +1 และ reset ให้ทำงานตามที่เราต้องการ โดยเพิ่ม onClick และเรียก setCount ให้เหมาะสม

Todo list แบบไม่มี server

หลังจากการทดลองเบื้องต้นในส่วนที่ผ่านมา เราจะใช้โครงของ React app เดิมมาทำแอพ todo list แบบง่าย ๆ

แสดงรายการเริ่มต้น (ยังไม่มี state)

ให้แก้ component App ให้เป็นดังด้านล่าง

function App() {
  let todoList = [
    { id: 1,
      title: 'Learn React',
      done: true },
    { id: 2,
      title: 'Build a React App',
      done: false },
  ];

  return (
    <>
      <h1>Todo List</h1>
      <ul>
        {todoList.map(todo => (
          <li key={todo.id}>
            <span className={todo.done ? "done" : ""}>{todo.title}</span>
          </li>
        ))}
      </ul>
    </>
  )
}

สังเกต:

  • ให้สังเกตการใช้ map กับ todoList เราส่ง arrow function ที่รับ todo เข้าไปใน map เพื่อเรียกใช้ฟังก์ชันดังกล่าวกับทุก ๆ todo ในรายการ
  • เราจะมี prop key ที่ระบุให้กับ element li สำหรับให้ React ตรวจสอบว่ามีการเพิ่มหรือลบขอในรายการหรือไม่ (โดยใช้ key เป็นเหมือนชื่อเฉพาะที่ใช้เรียกข้อมูลนี้

นอกจากนี้ให้เพิ่มกฎ .done ลงใน App.css ดังด้านล่างด้วย เพื่อแสดงรายการที่มีขีด

.done {
  text-decoration: line-through;
}

ลองเซฟและดูผล ควรจะเห็นหน้าจอเป็นดังนี้

223-react-todo-init.png

ใช้ useState และเพิ่มปุ่ม toggle

เราจะเก็บ todoList ใน state ดังนั้นให้ปรับส่วนประกาศให้เป็นดังด้านล่างนี้

  const initialTodoList = [      // เปลี่ยนชื่อ
    { id: 1,
      title: 'Learn React',
      done: true },
    { id: 2,
      title: 'Build a React App',
      done: false },
  ];

  const [todoList, setTodoList] = useState(initialTodoList);

เราจะเพิ่มปุ่ม toggle เอาไว้เพื่อปรับสถานะการทำเสร็จ ให้แก้ในส่วน jsx template ที่แสดงรายการให้เป็นดังนี้

        {todoList.map(todo => (
          <li key={todo.id}>
            <span className={todo.done ? "done" : ""}>{todo.title}</span>
            <button>toggle</button>
          </li>
        ))}

เราจะเขียนตัวจัดการ event onClick ของปุ่ม toggle โดยเราจะสร้างฟังก์ชัน toggleDone และเรียกใช้ ให้เพิ่ม onClick เข้าไปดังนี้

            <button onClick={() => {toggleDone(todo.id)}}>toggle</button>

เราจะเขียนฟังก์ชัน toggleDone ไว้ในฟังก์ชัน App ก่อนถึงคำสั่ง return

function App() {
  const initialTodoList = [
    // ละไว้
  ];

  const [todoList, setTodoList] = useState(initialTodoList);

  function toggleDone(id) {
    // ** เราจะเขียนตรงนี้ **
  }

  return (
    // ละไว้
  )
}

ฟังก์ชันดังกล่าว ต้องรับผิดชอบในการปรับ state ของ App โดยการเรียก setTodoList สังเกตว่าในการทำงานดังกล่าว เราจะต้องสร้างรายการใหม่ ทั้งรายการ ไม่ใช่แค่ปรับค่า todo แต่ข้อมูลเดียว เพราะว่าฟังก์ชัน setTodoList ทำงานกับทั้งรายการ

ดังนั้นโครงของ toggleDone จะเป็นดังนี้

  function toggleDone(id) {
    let newTodoList =  ...
    // ...
    // ...
    setTodoList(newTodoList);
  }

งานของคุณ ให้ทดลองเขียนเองก่อน แล้วค่อยดูเฉลยที่ใช้ map ด้านล่าง

เฉลย เราสามารถใช้ map ในการไล่ปรับค่าได้ สังเกตว่าใน arrow function ที่ส่งให้ map นั้น เราจะตรวจสอบ object todo ว่า id ตรงหรือไม่ ถ้าไม่ตรงก็คืนค่าเดิม แต่ถ้าตรง เราจะคือ object ใหม่ที่คัดลอกของจาก todo เดิม ก่อนจะปรับแค่ done ให้มีค่าตรงกันข้าม

  function toggleDone(id) {
    let newTodoList = todoList.map(todo => {
      if (todo.id === id) {
        return { ...todo, done: !todo.done };
      }
      return todo;
    });
    setTodoList(newTodoList);
  }

ให้สังเกตการใช้ rest property ... (ตรง ...todo) ในการคัดลอกคุณสมบัติที่เหลือทั้งหมดของ todo ที่เราไม่ได้กำหนด

งานของคุณ ให้แก้ปุ่ม toggle ให้แสดงเป็นข้อความว่า Done ถ้าการกดทำให้ done และแสดงว่า Reset ถ้ากดแล้วกลับมา done เป็น false แทนที่จะแสดงว่า toggle อย่างเดียว

ทดลอง refresh เนื่องจากเราไม่มี server สำหรับเก็บข้อมูล ถ้าเรา refresh หน้าจอเว็บ ข้อมูลทุกอย่างจะกลับไปเหมือนตอนเริ่มต้น

Todo list: add new item

ความสะดวกของการใช้ useState ก็คือเราสามารถสร้าง state เพื่อมาติดตามการเปลี่ยนแปลงค่าของ input ในหน้าจอได้โดยสะดวก

ให้เพิ่ม state newTitle ใน App

 const [newTitle, setNewTitle] = useState("");

จากนั้นให้เพิ่ม input สำหรับการเพิ่ม todo ในรายการ

  return (
    <>
      <h1>Todo List</h1>
      <ul>
        ... ละไว้ ...
      </ul>
      New: <input type="text" value={newTitle} onChange={(e) => {setNewTitle(e.target.value)}} />
      <button>Add</button>
    </>
  )

สังเกตว่าเรากำหนด event onChange ให้ไปกำหนดค่าใหม่ให้กับ state newTitle สังเกตว่า function จัดการ onChange มีการอ้างถึง e ที่เป็นข้อมูลที่มากับ event และอ่านค่า value ของ element ดังกล่าว (ซึ่งก็คือ input ที่เราติดตามอยู่นี่เอง)

เราสามารถทดลองพิมพ์ค่า state ดังกล่าวออกมาได้ โดยเพิ่ม onClick ที่ button ดังนี้

      <button onClick={() => {alert(newTitle)}}>Add</button>

ให้ทดลองพิมพ์ข้อความและกดปุ่ม จากนั้นแก้ข้อความแล้วกดปุ่มอีกครั้ง

เราจะเขียนฟังก์ชัน addNewTodo เพื่อเพิ่ม todo ลงในรายการ

ก่อนอื่นให้ปรับให้ event onClick ที่ปุ่ม Add ไปเรียกฟังก์ชันนี้ก่อน

      <button onClick={() => {addNewTodo()}}>Add</button>

และเพิ่มฟังก์ชันทั้งสองลงด้านในของ function App

  function newId() {
    if (todoList.length == 0) {
      return 1;
    }
    let maxId = todoList[0].id;
    todoList.forEach(todo => {
      if (todo.id > maxId) {
        maxId = todo.id;
      }
    });
    return maxId + 1;
  }

  function addNewTodo() {
    let title = newTitle;
    let newTodo = {
      id: newId(),
      title: title,
      done: false,
    };
    setTodoList([...todoList, newTodo]);
    setNewTitle("");
  }

หมายเหตุ ฟังก์ชัน newId หาค่า id มากสุด แล้วคืนค่าดังกล่าว +1 เราสามารถใช้ฟังก์ชัน Math.max (ที่รับรายการมาใน argument หลายตัว) ร่วมกับ spread operator ทำให้เขียนสั้นลงได้เป็นดังนี้

  function newId() {
    if (todoList.length == 0) {
      return 1;
    }
    return 1 + Math.max(...(todoList.map(todo => todo.id)));
  }

Todo list: delete todo item

เราจะเพิ่มปุ่ม delete เพื่อลบของออกจากรายการ ให้เพิ่มปุ่มสำหรับทุก ๆ todo

            <button onClick={() => {deleteTodo(todo.id)}}>❌</button>

งานของคุณ เขียนฟังก์ชัน deleteTodo

คำแนะนำ: ให้ลองใช้ filter ในการเลือกข้อมูลในรายการ todoList ที่ต้องการเก็บไว้ (ใช้คล้ายๆ map)

การใช้ Flask เป็น backend: load รายการ todo list

เราจะสร้าง project Flask เพื่อทำเป็น backend ให้หาไดเร็กทอรีใหม่ และสร้าง virtual environment รวมทั้งติดตั้งไลบรารีที่ต้องใช้ในการพัฒนา Flask อ่านทบทวนได้ที่เอกสารตอนที่ทำ Flask

สิ่งที่แตกต่างไปก็คือ ตอนนี้หน้าเว็บที่เราทำจะเป็น API ที่ React จะมาเรียกใช้อีกที ดังนั้นสิ่งที่ function ที่จัดการกับแต่ละ URL คืนกลับไปจะเป็นข้อมูลประเภท json

ด้านล่างเป็นโค้ด main.py ที่รับ request ที่ url /api/todos/ และจะคืนรายการเป็น json สังเกตว่าเราจะให้รายการใน todo list แตกต่างจากใน React เพราะว่าถ้าโหลดรายการได้ จะได้เห็นความแตกต่าง

from flask import Flask, request, jsonify

app = Flask(__name__)

todo_list = [
    { "id": 1,
      "title": 'Learn Flask',
      "done": True },
    { "id": 2,
      "title": 'Build a Flask App',
      "done": False },
]

@app.route('/api/todos/')
def get_todos():
    return jsonify(todo_list)

หมายเหตุ: สังเกตการใช้งานฟังก์ชัน jsonify

ให้เรียกให้ web development server ทำงาน อย่าลืม export FLASK_APP=main.py แล้วเรียก

flask run --debug

จากนั้นให้ลองเข้าเว็บที่ http://localhost:5000/api/todos/ จะเห็นข้อมูลแบบ json ที่คืนกลับมา

ตอนนี้เราจะมี terminal ที่เรียก development server ทำงานอยู่สองโปรแกรม อันหนึ่งของ React (frontend) อีกอันเป็นของ Flask (backend) อย่าเพิ่งสับสน

223-vite-flask-server-console.png

ใช้ useEffect ในการเรียก api เพื่อโหลดค่าเริ่มต้นของ todoList

ให้แก้ส่วน import ตอนต้น App.jsx ให้ import useEffect มาด้วย

import { useState, useEffect } from 'react'

จากนั้นเพิ่มค่าคงที่แทน URL ของ api endpoint และเขียนโค้ดให้โหลดข้อมูล จากนั้นนำมากำหนดค่าให้กับ todoList

function App() {
  const TODOLIST_API_URL = 'http://localhost:5000/api/todos/';

  // .. ละไว้

  // ประกาศหลังประกาศ setTodoList ใน useState 
  useEffect(() => {
    fetchTodoList();
  }, []);

  async function fetchTodoList() {
    try {
      const response = await fetch(TODOLIST_API_URL);
      if (!response.ok) { 
        throw new Error('Network error');
      }
      const data = await response.json();
      setTodoList(data);
    } catch (err) {
      alert("Failed to fetch todo list from backend. Make sure the backend is running.");
    }
  }

  // ..
}

เมื่อเรียกใช้งาน ในหน้าจอ server ของ Flask จะเห็นว่ามีการเรียกมายัง api แต่หน้าเว็บของ React จะยังไม่มีการเปลี่ยนแปลง

ถ้าไปกด Inspection และดูที่หน้า console จะพบว่าการเรียกดังกล่าวถูก block ด้วย CORS policy เพราะว่า frontend เราทำงานที่ http://localhost:5173/ ส่วน backend เราอยู่ที่ http://localhost:5000/api/todo/ ซึ่งถือว่าเป็นคนละที่กัน

223-react-cors-error.png

ถ้าจะทำให้สามารถเรียกได้ ใน Flask เราจะต้องเพิ่มโค้ดเพื่อเปิดการอนุญาตนี้ด้วย

CORS

เราต้องติดตั้งไลบรารี Flask-CORS เสียก่อน ให้เบรคออกมาจาก flask run แล้วเรียก

pip install flask-cors

เพื่อติดตั้งไลบรารี

จากนั้นให้เพิ่มบรรทัดที่เรียก CORS เพิ่มไปในไฟล์ main.py (บรรทัดที่เพิ่มจะมี remark ด้านหลัง)

from flask import Flask, request, jsonify
from flask_cors import CORS                   # เพิ่ม import

app = Flask(__name__)
CORS(app)                                     # เพิ่มการอนุญาต

# ... ละไว้

ถ้าแก้แล้ว ให้ start flask run ใหม่ แล้วลอง refresh เว็บ React front end น่าจะเห็นว่ามีรายการใหม่ เป็น Learn Flask แทนที่ Learn React

ทำความเข้าใจกับการเรียก fetch (async / await)

เราจะกลับมาดูการเรียก api จาก React ผ่านทางฟังก์ชัน fetch (Fetch API) ตามโค้ดด้านล่าง

  async function fetchTodoList() {
    try {
      const response = await fetch(TODOLIST_API_URL);
      const data = await response.json();
      setTodoList(data);
    } catch (err) {
      alert("Failed to fetch todo list from backend. Make sure the backend is running.");
    }
  }

ในโค้ดด้านล่างมีการใช้ keyword await และ async ซึ่งเป็น keyword ที่เกี่ยวข้องกับการ "รอ" การทำงานแบบ asynchronous

เพื่อความเรียบง่ายและป้องกันปัญหาหลายๆ อย่าง ภาษา JavaScript จะมีตัว run-time ที่ทำงานแบบ single thread นั่นคือ จะไม่มีการทำงานหลาย ๆ งานพร้อมกัน ดังนั้น ถ้ามีกระบวนการใดทำงานค้างอยู่ กระบวนการอื่น ๆ ก็จะไม่สามารถทำอะไรได้

ยกตัวอย่างเช่น ถ้าเราเรียก request ผ่านไปทางเน็ตเวิร์ค (เช่นผ่านทางฟังก์ชัน fetch หรือการแกะ json ผ่านทาง .json()) ซึ่งจะต้องใช้เวลาในการรอคอยคำตอบ ถ้าทุกอย่างต้องหยุดรอผลลัพธ์ การอัพเดทหน้าจอต่าง ๆ รวมถึง animation หรือการประมวลผลอื่น ๆ ก็จะหยุดค้างไปด้วย ซึ่งส่งผลเสียอย่างร้ายแรงต่อประสบการณ์ของผู้ใช้     ดังนั้นในภาษา JavaScript ถ้ามีกิจกรรมที่ต้องมีการหยุดรอ โดยเฉพาะงานที่เป็นงานด้าน I/O คำสั่งโดยมากจึงเป็นคำสั่งแบบ asynchronous นั่นคือ จะเป็นคำสั่งที่ทำแล้ว "ปล่อยให้เกิดการทำงานต่อทันที" โดยไม่ต้องรอผล แต่จะมีวิธีบางอย่างมาแจ้งกับเราว่าผลลัพธ์เสร็จแล้ว

เมื่อก่อนการเขียนแบบนี้จะใช้การเขียนแบบ callback ต่อมาพัฒนาเป็น promise และล่าสุด ด้วย syntax ของ JavaScript สมัยใหม่ เราสามารถใช้ keyword await ประกอบการเรียกฟังก์ชันเหล่านี้ เพื่อให้การทำงานเสมือนมีการหยุดรอผลลัพธ์โดยอัตโนมัติ

วิธีการใช้คร่าว ๆ เป็นดังนี้

  • สมมติว่าเรามีฟังก์ชันที่คืนค่าเป็น promise จากตัวอย่างด้านบน เช่น function fetch หรือ ฟังก์ชัน json ของ response ที่คืนค่าเป็น promise ดังรายละเอียดตาม document ที่คัดมาตามด้านล่าง
    • The fetch() method takes one mandatory argument, the path to the resource you want to fetch. It returns a Promise that resolves to the Response to that request — as soon as the server responds with headers — even if the server response is an HTTP error status. (จาก [1])
    • The json() method of the Response interface takes a Response stream and reads it to completion. It returns a promise which resolves with the result of parsing the body text as JSON. (จาก [2])
  • ในการเรียก ให้เราเพิ่ม keyword await ไว้ด้านหน้า จะทำให้การทำงานของฟังก์ชันหยุดรอจนกว่า promise จะ resolve (ทำงานเสร็จสิ้น)
  • ฟังก์ชันที่มีการใช้ await จะต้องประกาศ keyword async ด้านหน้าด้วย เพื่อระบุว่าเป็น asynchronous function

ถ้าสนใจทำความเข้าใจอย่างละเอียด สามารถอ่านสรุปจาก Gemini และมีตัวอย่างโปรแกรมให้ทดลองเพื่อเพิ่มความเข้าใจได้

เพิ่ม todo item

ในการเพิ่ม todo item นั้น เนื่องจากเรามีทั้งส่วน backend และ frontend เราจะต้องทำงานสองส่วน

backend

เราต้องออกแบบ api endpoint สำหรับการเพิ่ม item     จากที่เราได้เคยเขียนมา การแก้ไขและเพิ่มข้อมูลจะใช้ http request แบบ POST และมักจะใช้ url เดียวกับการอ่านข้อมูลในรายการ (ซึ่งจะเป็น request แบบ GET)

ดังนั้น เราจะแก้ฟังก์ชัน get_todos เดิมให้รับแต่ method GET และเพิ่มฟังก์ชัน new_todo เพื่อเพิ่มของในรายการ ดังด้านล่าง

@app.route('/api/todos/', methods=['GET'])      # ระบุให้รับแค่ GET
def get_todos():
    return jsonify(todo_list)

@app.route('/api/todos/', methods=['POST'])
def add_todo():
    # .... เราจะเขียนที่นี่

เมื่อเราเลือก url endpoint แล้ว เราต้องพิจารณาต่อว่าจะส่งข้อมูลอะไรจาก React frontend บ้าง แล้ว api จะคืนอะไรกลับไป เราจะทำดังนี้

  • frontend จะส่งข้อมูลมาเป็น json โดยระบุแค่ title (เช่น { title: 'New task' })
  • api จะสร้าง todo item ใหม่ (สร้าง id ใหม่ด้วย) แล้วคืนทั้ง todo item กลับไปแบบ json (เช่น { id: 123, title: 'New task', done: false })

ด้านล่างเป็นฟังก์ชันสำหรับสร้าง todo item (ยังไม่ใช่ฟังก์ชันสำหรับรับ request) สังเกตว่าเรามีการตรวจสอบความถูกต้องของข้อมูลที่ได้รับมาด้วย (เช่น มี title หรือไม่)

def new_todo(data):
    if len(todo_list) == 0:
        id = 1
    else:
        id = 1 + max([todo['id'] for todo in todo_list])

    if 'title' not in data:
        return None
    
    return {
        "id": id,
        "title": data['title'],
        "done": getattr(data, 'done', False),
    }

ด้านล่างเป็นฟังก์ชัน add_todo ที่รับ POST request แล้วแกะข้อมูล json ด้วย get_json ก่อนจะนำไปประมวลผล ถ้าไม่สามารถเพิ่มข้อมูลได้ จะ return json ที่ระบุ error และใช้ http response code 400

@app.route('/api/todos/', methods=['POST'])
def add_todo():
    data = request.get_json()
    todo = new_todo(data)
    if todo:
        todo_list.append(todo)
        return jsonify(todo)
    else:
        # return http response code 400 for bad requests
        return (jsonify({'error': 'Invalid todo data'}), 400)

การทดสอบ api

ในการพัฒนาทั่วไป เมื่อเรามีส่วน backend แล้ว เราอาจจะสามารถ test โปรแกรมในส่วนดังกล่าวได้เลย โดยไม่ต้องรอการพัฒนาในส่วนของ frontend เครื่องมือที่นิยมใช้กันมีมากมาย เช่น postman, talend api tester (chrome extension) หรือ bruno เป็นต้น    ในที่นี้เราจะละส่วนนี้ไปก่อน

frontend

ด้านล่างเป็นโค้ด addNewTodo ใหม่ ที่เพิ่มการเรียก api แล้ว สังเกตว่าเราเปลี่ยนฟังก์ชันเป็น async function และใช้ await ในการรอผลลัพธ์จาก fetch api

ในการเรียก api เราจะระบุ method POST และระบุประเภท contest เป็น applicant/json ที่ request headers

  async function addNewTodo() {
    try {
      const response = await fetch(TODOLIST_API_URL, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ 'title': newTitle }),
      });
      if (response.ok) {
        const newTodo = await response.json();
        setTodoList([...todoList, newTodo]);
        setNewTitle("");
      }
    } catch (error) {
      console.error("Error adding new todo:", error);
    }
  }

เราสามารถทดลองเพิ่ม todo item ได้เลย ถ้าทุกอย่างเรียบร้อยจะสามารถเพิ่มข้อมูลในรายการได้

เนื่องจากเรามี server เก็บรายการแล้ว ถ้าเรา refresh หน้า web ui รายการทั้งหมดจะยังอยู่เหมือนเดิม

ใช้ Devtool ในการดูการส่งข้อมูลระหว่าง frontend กับ backend

บราวเซอร์สมัยใหม่จะมาพร้อมกับเครื่องมือสำหรับนักพัฒนา เราสามารถเรียกได้โดยการกด inspect ที่หน้าจอหรือกดจากเมนู

เมื่อเรากดเพิ่ม todo item ไป เราจะสามารถดูในส่วน network เพื่อศึกษาว่าเกิดอะไรขึ้นได้

ด้านล่างเป็นตัวอย่าง request ที่ทำงานถูกต้อง 223-react-devtool-1.png

ถ้าเรากดไปดูรายละเอียด เราสามารถดูส่วน request headers, payload (ข้อมูลที่ส่งไป), และ response ได้ ดังรูปด้านล่าง

223-react-devtool-inspect.png

กิจกรรมอื่น ๆ done และ delete

การ toggle done

เราจะใช้ url /api/todos/[id]/toggle/ สำหรับการ toggle โดยจะใช้ method PATCH (เราจะใช้ PATCH เมื่อมีการแก้บางส่วนของข้อมูล ใช้ PUT ถ้าแก้ทั้งหมด)

ด้านล่างเป็น function ฝั่ง backend

@app.route('/api/todos/<int:id>/toggle/', methods=['PATCH'])
def toggle_todo(id):
    todos = [todo for todo in todo_list if todo['id'] == id]
    if not todos:
        return (jsonify({'error': 'Todo not found'}), 404)
    todo = todos[0]
    todo['done'] = not todo['done']
    return jsonify(todo)

ฟังก์ชันนี้หลังจากการอัพเดทแล้ว จะคืน json ของ todo item ที่อัพเดทแล้วทั้งก้อนกลับมาเลย

สำหรับส่วน frontend เราจะเปลี่ยนฟังก์ชัน toggleDone เป็น async และใช้ string template literal ในการสร้าง URL

หมายเหตุ สังเกตว่าในการสร้าง URL ตรงท้าย TODOLIST_API_URL ของเรามี / ต่อท้ายอยู่แล้ว ในการต่อ id เข้าไปเราจึงต่อไปเลย ไม่มีการเพิ่ม / อีกที

  async function toggleDone(id) {
    const toggle_api_url = `${TODOLIST_API_URL}${id}/toggle/`
    // งานของคุณ: เขียนส่วนที่เหลือเอง
    // ให้เรียก fetch ไปที่ url ดังกล่าว แบบ patch, ถ้าทำงานสำเร็จให้สร้าง todo item ใหม่ไปแทนของเดิม
  }

การ delete

เราจะสร้าง api ที่รับ http request แบบ DELETE ไปที่ url ของ todo items ด้านล่างเป็นฟังก์ชันใน frontend ที่แก้ไขแล้ว

  async function deleteTodo(id) {
    const delete_api_url = `${TODOLIST_API_URL}${id}/`
    try {
      const response = await fetch(delete_api_url, {
        method: 'DELETE',
      });
      if (response.ok) {
        setTodoList(todoList.filter(todo => todo.id !== id));
      }
    } catch (error) {
      console.error("Error deleting todo:", error);
    }
  }

ด้านล่างเป็นการประกาศในส่วน backend ตอนต้น เราจะสั่ง global todo_list เพราะว่าเราอาจจะต้องมีการ assign list ใหม่ทั้งชิ้นให้กับตัวแปร todo_list ที่เป็น global    อย่างไรก็ตาม ถ้าใช้คำสั่ง del ในการลบข้อมูลในรายการเอง ก็อาจจะไม่จำเป็นต้องระบุ global ก็ได้

@app.route('/api/todos/<int:id>/', methods=['DELETE'])
def delete_todo(id):
    global todo_list
    # งานของคุณ: ลบ todo item ที่มี id ตรงกับ id
    # ถ้าทำงานถูกต้อง ให้คืน response ที่เป็น json ว่าง ๆ หรือ response message บางอย่างกลับไปก็ได้

เรื่องอื่น ๆ ที่ต้องทำ

ในการพัฒนาเว็บแอพให้สามารถทำงานได้จริง ยังมีอีกหลายเรื่องที่ต้องทำ เรื่องหลัก ๆ มีดังนี้

ระบบรวม

  • Authentication

หลังบ้าน

  • ใช้ database จริง

หน้าบ้าน

  • แยก component ของหน้าเว็บเป็นส่วน ๆ แทนที่จะมี component App เพียงอย่างเดียว