Typescript client side: Ứng dụng sử dụng API camera của trình duyệt
Mình đã viết nhiều bài viết sử dụng Typescript để xây dựng ứng dụng phía server như Typescript: Tự viết router cho ứng dụng express sử dụng decorators hoặc Serverless Typescript với AWS Lambda, API Gateway và DynamoDB trên môi trường offline – Phần 01 …Về phía client, mình có viết một bài Xây dựng ứng dụng bằng React sử dụng Typescript , nhưng ở bài viết này mình sẽ không sử dụng thư viện hay framework js nào, chúng ta thường nói với nhau là “viết thuần”.
Giới thiệu
Trong bài viết này, chúng ta sẽ cùng đi xây dựng một ứng dụng đơn giản có sử dụng camera, ứng dụng sẽ sử dụng HTML5, CSS3 và Typescript.
Typescript có những tính năng mạnh mẽ và phong phú, giúp chúng ta có thể xây dựng những ứng dụng từ đầu với khả năng tùy chỉnh cao. Trên blog đã có nhiều bài viết sử dụng Typescript trên môi trường Node, còn trong bài viết này, chúng ta sẽ sử dụng trên môi trường Browser, sử dụng các API mà Browser cung cấp để xây dựng một ứng dụng.
Các browser hiện đại đều dùng chung các tiêu chuẩn cung cấp các API để làm việc với trình duyệt hoặc truy cập tới các thiết bị phần cứng như Camera.
Cấu trúc dự án
Mình mặc định các bạn đã có hiểu biết cơ bản về Typescript, html, css và các công cụ kiểu như npm.
Tại thư mục của dự án chúng ta sẽ khởi tạo các file đầu tiên, bao gồm, package.json – file được sinh ra bằng lệnh npm init -y
, tsconfig.json – được sinh ra bằng lệnh tsc --init
Chúng ta sẽ định nghĩa các tùy chọn compiler của Typescript sao khi chuyển sang mã js, mã js đó có thể chạy tốt trên môi trường browser.
Dự này không sử dụng các package bên ngoài, ngoài môi trường Node và trình biên dịch Typescript. File tsconfig.json quy định cho trình biên dịch Typescript biên dịch mã TS thành mã JS tương ứng với cú pháp ES6, thư mục output chứa file .js là ./build
Cấu trúc file HTML
Chúng sẽ xây dựng một web app đơn giản, nên bắt buộc phải có một file html là nơi “vẽ” giao diện của ứng dụng.
Ứng dụng của chúng ta sẽ có các chức năng: Hiện thị hình ảnh thu được từ camera của thiết bị, chụp lại ảnh và tải xuống, nếu thiết bị có 2 camera, có thể chuyển đổi các camera.
Giao diện sẽ cơ bản như thế này:
Nội dung file index.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>TypeScript Photo Application</title> <link rel="stylesheet" type="text/css" media="screen" href="./css/index.css" /> <script type="module" src="./build/app.js"></script> </head> <body> <main> <video></video> </main> <footer> <button name="take_photo"> <svg viewBox="-1 -1 2 2"> <circle cx="0" cy="0" r=".6"></circle> </svg> </button> <button name="switch_cam"> <svg viewBox="-1 -1 2 2"> <polyline points="-.2,-.6 -.7,0 -.2,.6"></polyline> <polyline points=".2,-.6 .7,0 .2,.6"></polyline> </svg> </button> </footer> </body> </html>
Nội dung khá đơn giản và dễ hiểu, liên kết các file css/index.css và /build/app.js . Cấu trúc bao gồm: Phần chính – thẻ main, chứa một thẻ video để hiện thị preview hình ảnh của camera. Phần footer gồm 2 nút, một để chụp và tải ảnh xuống, một để chuyển đổi camera.
Mình sử dụng SVG để tạo icon cho các button, các bạn có thể tìm hiểu thêm. Trong bài viết này mình cũng không đi sâu vào phần tạo giao diện của ứng dụng, mình chỉ muốn giới thiệu việc sử dụng Typescript để viết mã chạy trên môi trường Browser. Nên mình cũng cung cấp luôn file css của ứng dụng – index.css
:root { --root-metric: 16px; --footer-height: calc(var(--root-metric) * 6); --button-height: calc(var(--root-metric) * 4); } html, body, button>svg { width: 100%; height: 100%; margin: 0; padding: 0; } body { background: black; } video { height: calc(100vh - var(--footer-height)); width: 100vw; } footer { position: absolute; bottom: 0; width: 100vw; height: var(--footer-height); display: flex; flex-flow: row nowrap; align-content: stretch; align-items: center; justify-content: space-evenly; background: linear-gradient(2.7rad, #1a1a1a, #424242); border-top: 1px solid #6a6a6a; } button { flex: .24; padding: 0; height: var(--button-height); background: #02030440; border: 2px solid #7070a0; border-radius: 8px; font-size: 24px; color: #efefef; cursor: pointer; } button:hover { background: #20304040; border: 2px solid #d0e0f0; } button:active { color: #606060; } button>svg>* { stroke: #a0b0c0; fill: #0a0a1a2a; stroke-width: 0.06; }
Xây dựng ứng dụng hoàn chỉnh
Phần chính của bài viết, cũng là phần chính của ứng dụng: Sử dụng Typescript để triển khai các tính năng chính của ứng dụng, chúng bao gồm:
- Preview live hình ảnh từ camera
- Chuyển đổi giữa camera trước và camera sau
- Chụp ảnh và tải xuống ảnh vừa chụp
Trước tiên chúng ta sẽ có file ./src/types.ts
, file này sẽ chứa những kiểu biến được dùng nhiều lần, các kiểu này sẽ được dùng lại trong toàn ứng dụng, thuận tiện cho việc phát triển và đặc biệt đảm bảo tính “type safety” của ứng dụng.
// src/types.ts export enum CameraMode { User = 'user', Environment = 'environment', } export interface IButtons { takePhoto: HTMLButtonElement, switchCam: HTMLButtonElement, } export abstract class Defaults { static width: number = 640; }
Chúng ta có enum CameraMode để lựa chọn chế độ cam trước(user) hay cam sau(environment) (Doc) , một interface IButtons định nghĩa các nút sẽ sử dụng trong ứng dụng và một class Defaults định nghĩa những giá trị mặc định sẽ dùng trong ứng dụng, ở đây chúng ta có độ rộng mặc định của khung preview là 640.
Tiếp theo là file chính của ứng dụng – src/app.ts.
Chúng ta sẽ bắt đầu xây dựng từng phần của ứng dụng, mình sẽ cố gắng giải thích từng bước.
Class App chứa những trường static để lưu giữ những giá trị được sử dụng trong suốt ứng dụng, các giá trị lần lượt là:
- mode: Giá trị chế độ cam đang được chọn, mặc định là CameraMode.Environment
- buttons: 2 nút chức năng của ứng dụng
- canvas: Một đối tượng dùng để trích xuất ảnh từ thẻ video
- video: Một thẻ HTMLVideoElement để hiển thị hình ảnh preview của cam
Chúng ta cũng có hàm init() tại đây định nghĩa các giá trị như các nút chức năng, gán việc xử lý các sự kiện của các nút, sử dụng API của trình duyệt để truy cập vào camera devices (enumerateDevices) để khởi tạo việc sử dụng các camera.
Chúng ta sẽ lần lượt xử lý từng tag TODO.
Đầu tiền sẽ là // TODO: Handle on enumerate devices
Phương thức onEnumerateDevices được gọi khi lấy được số lượng cam của thiết bị, từ số lượng cam của thiết bị, chúng ta sẽ quyết định những nút chức năng nào được hoạt động. Sau cùng gọi phương thức initCamera để lấy dữ liệu từ camera.
Tiếp theo chúng ta xử lý để hiển thị hình ảnh preview của camera: // TODO: Handler on get media
Phương thức onGetMedia xử lý khi chúng ta đã lấy được dữ liệu từ camera: Khởi tạo giá trị cho biến canvas, video, và kết nối dữ liệu từ camera với thẻ video để hiển thị lên giao diện ứng dụng. Khi video đã sẵn sàng, chúng ta sẽ tính toán lại kích thước của video với phương thức onVideoReady.
Chạy ứng dụng
Tới đây bạn đã có thể chạy thử ứng dụng, bằng cách gọi phương thức App.init();
Trước khi sử dụng trình duyệt để truy cập tới file index.html, chúng ta cần build mã TS sang mã JS với câu lệnh tsc
. Tốt nhất các bạn nên chạy ứng dụng với một server static file. Khi trình duyệt truy cập tới file index.html của bạn, sẽ có một popup yêu cầu quyền truy cập camera của bạn, khi bạn nhấn cho phép, hình ảnh preview của camera sẽ hiển thị trên ứng dụng của bạn.
Xử lý sự kiện của các nút chức năng
Đầu tiên sẽ là sự kiện khi bạn click vào nút để chụp ảnh // TODO: Take photo action
Khi nút takePhoto được click, sẽ gọi tới phương thức takePhoto, tại đây chúng ta vẽ đối tượng ảnh lên canvas, nội dung là ảnh đang hiểu thị ở thẻ video và cuối cùng là tải ảnh đó xuống.
Còn một nút chức năng nữa: // TODO: Switch cam action
Khá đơn giản, chúng ta đổi giá trị của mode và gọi lại phương thức initCamera()
Cuối cùng là phương thức để xử lý khi có lỗi: // TODO: Handle on error
Hoàn thành toàn bộ ứng dụng.
Nội dung toàn bộ file app.ts
// src/app.ts import { CameraMode, IButtons, Defaults } from "./types.js"; abstract class App { static mode: CameraMode = CameraMode.Environment; static buttons: IButtons = { takePhoto: null, switchCam: null, } static canvas: HTMLCanvasElement; static video: HTMLVideoElement; static init() { App.buttons.takePhoto = document.querySelector("button[name='take_photo']"); App.buttons.switchCam = document.querySelector("button[name='switch_cam']"); App.buttons.takePhoto.onclick = () => { App.takePhoto(); } App.buttons.switchCam.onclick = () => { App.switchCam(); } navigator.mediaDevices.enumerateDevices() .then((mediaDeviceInfos) => { App.onEnumerateDevices(mediaDeviceInfos); }) .catch((reason) => { App.onError(reason); }); } static onEnumerateDevices(devices: MediaDeviceInfo[]): Promise<void> { if (devices.length < 1) { App.buttons.takePhoto.disabled = true; App.buttons.switchCam.disabled = true; } if (devices.length < 2) { App.buttons.switchCam.disabled = true; } return App.initCamera(); } static initCamera(): Promise<void> { const constraints = { audio: false, video: { facingMode: App.mode } }; return navigator.mediaDevices.getUserMedia(constraints) .then((stream) => { App.onGetMedia(stream); }) .catch((reason) => { App.onError(reason); }); } static onGetMedia(stream: MediaStream) { App.canvas = document.createElement('canvas'); App.video = document.querySelector('video') App.video.onloadedmetadata = () => { App.video.play(); } App.video.oncanplay = () => { App.onVideoReady(); } App.video.srcObject = stream; } static onVideoReady() { App.canvas.width = Defaults.width; App.canvas.height = App.video.videoHeight / (App.video.videoWidth / Defaults.width); App.video.setAttribute('height', App.canvas.height.toString()); App.video.setAttribute('width', App.canvas.width.toString()); } static takePhoto() { const context = App.canvas.getContext('2d'); context.drawImage(App.video, 0, 0, App.canvas.width, App.canvas.height); const url = App.canvas.toDataURL('image/jpeg'); const a = document.createElement('a'); a.href = url; a.target = '_blank'; a.download = 'photo.jpeg'; a.click(); } static switchCam() { App.mode = (App.mode == CameraMode.User) ? CameraMode.Environment : CameraMode.User; App.initCamera(); } static onError(reason: any) { console.log(reason); } } App.init();
Các bạn để ý sẽ thấy có dòng from "./types.js";
, để giúp trình duyệt tìm đúng file theo cú pháp import của es6, một số trình duyệt sẽ không hỗ trợ cú pháp này, bài viết sử dụng chrome phiên bản 79.
Nâng cao
Khi phát triển ứng dụng với Typescript chúng ta phải liên tục lặp lại thao tác gọi lệnh tsc để chuyển mã TS sang mã JS. Đặc biệt trong dự án này (hay các dự án front-end) chúng ta phải liên tục reload lại trình duyệt mỗi khi có thay đổi. Gọi tsc, reload trình duyệt…cứ lặp lại, mất thời gian và hơi nhàm chán.
Tôi muốn giới thiệu tới các bạn 1 công cụ là BrowserSync : Công cụ giúp tự động reload lại trình duyệt khi có thay đổi ở các file. Công cụ này các bạn có thể cài đặt với lệnh
npm install -g browser-sync
và dùng được lại với nhiều dự án mà không cần cài đặt lại.
Quy lại dự án, chúng ta sẽ cập nhật nội dung của file package.json
{ "name": "typescript_photo_app", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "build": "tsc", "dev": "browser-sync start --server --files \"*.html, css/*.css, build/*.js\"" }, "keywords": [], "author": "codetheworld.io", "license": "ISC" }
Chúng ta có script dev, trong quá trình phát triển chúng ta chạy lệnh npm run dev
, một static server được khởi tạo, khi bạn truy cập tới địa chỉ output, bạn sẽ truy cập tới file index.html. Ở một cửa sổ terminal khác, chúng ta chạy lệnh tsc -w
(build lại file ts khi có thay đổi). Giờ mỗi khi nội dung file có thay đổi, trình duyệt sẽ tự khởi động lại.
Kết luận
Việc phát triển ứng dụng phía client với Typescript cũng gần tương tự với việc phát triền phía server. Typescript cùng với các công cụ như VSCode sẽ giúp cho việc phát triển ứng dụng javascript dễ dàng hơn rất nhiều.