HLS Streaming với Node.js. Tạo server phát video như Youtube
🎉 Nếu anh em thấy Front-End cạnh tranh quá thì chúng ta học thêm Back-End thôi.
Mình đang có khóa học🏆 Node.js Super, với các kiến thức như: Express.js, TypeScript, MongoDB, Socket.io, Docker, AWS, Swagger, ...
Vậy nên anh em có thể mua ngay từ bây giờ để tăng khả năng pass phỏng vấn nhé 😉
Đã bao giờ bạn muốn tạo một website phát video như Youtube, Netflix chưa?
Bạn tự hỏi họ dùng công nghệ gì mà có thể phát video mượt mà, có nhiều option độ phân giải cho người dùng lựa chọn.
Bí quyết nằm ở giao thức Adaptive Streaming. Công nghệ này cho phép server phát video với nhiều độ phân giải khác nhau, bitrate khác nhau. Khi người dùng xem video, server sẽ tự động chọn độ phân giải, bitrate phù hợp với tốc độ mạng của người dùng.
Có 2 giao thức Adaptive Streaming phổ biến nhất hiện nay là HLS và DASH. HLS thì phổ biến trong hệ sinh thái Apple.
Bài này chúng ta sẽ tìm hiểu về HLS nhen.
Nhưng trước hết thì chúng ta tìm hiểu về cách thức stream video truyền thống trước nhé.
Stream video là phát video từ server cho người dùng xem. Live stream là phát video trực tiếp. Ở đây chúng ta sẽ bàn về cách phát lại video đã được upload lên server.
🥇Stream video truyền thống
Cách stream video đơn giản nhất là upload video như thế nào thì phát lại cho người dùng y như thế.
Ví dụ: Upload 1 video phim nặng 10 GB, độ phân giải 2k, file .mp4
, bitrate 10 Mbps lên server. Server sẽ phát lại video này cho người dùng với đầy đủ thông số kể trên.
Ưu điểm của cách này là đơn giản, đem lại chất lượng video nguyên bản cho người xem. Nếu bạn dùng Express.js bên Node.js thì chỉ cần khai báo 1 route như thế này là có thể stream video được rôi:
app.use('/static/video', express.static(UPLOAD_VIDEO_DIR))
Nhưng cách này tồn tại rất nhiều nhược điểm:
- Trình duyệt có thể không play được video vì không hỗ trợ định dạng video đó, ví dụ định dạng
.mkv
chẳng hạng. - Vì video nguyên bản, nên dung lượng sẽ rất là lớn, nếu người dùng có mạng yếu thì sẽ bị lag, giật.
- Lúc nào cũng stream video với độ phân giải cao thì sẽ gây tốn băng thông cho người xem lẫn server
Bây giờ hãy để HLS tỏa sáng nào
🥇HLS Streaming
HLS là viết tắt của HTTP Live Streaming. Đây là một giao thức Adaptive Streaming do Apple phát triển.
Flow hoạt động cũng không có gì phức tạp:
- Upload 1 video lên server thì server sẽ tiến hành convert video thành file
.m3u8
và nhiều file.ts
nhỏ. Mỗi file.ts
sẽ chứa từng phân đoạn của video. Công cụ thường dùng để convert là Ffmpeg. - Khi người dùng xem video, server sẽ phát video bằng cách gửi các file
.ts
này cho trình duyệt. Ở phía trình duyệt sẽ dùng các thư viện hỗ trợ hls nhưhls.js
để phát video.
Ưu điểm của HLS là:
- Hỗ trợ nhiều định dạng video, audio khác nhau.
- Hỗ trợ nhiều chất lượng video khác nhau.
- Tự động chọn chất lượng video phù hợp với tốc độ mạng của người dùng.
- Tốn ít băng thông hơn so với stream video truyền thống.
Nhưng không phải là không có nhược điểm:
- Tích hợp phức tạp hơn so với stream video truyền thống.
- Cần phải convert video => tốn nhiều thời gian
- Tốn nhiều bộ nhớ hơn so với stream video truyền thống. Vì bây giờ chúng ta phải lưu nhiều chất lượng video khác nhau 🥲. Mà cái này cũng tùy nha, bác nào chỉ lưu 1 định dạng thôi thì có thể nó sẽ tiết kiệm bộ nhớ hơn đó.
Xong HLS rồi, giờ thì tìm hiểu về Ffmpeg nhé.
🥇Ffmpeg
FFmpeg là một công cụ mã nguồn mở, hỗ trợ convert video, audio. Công cụ này có thể chạy trên nhiều nền tảng khác nhau như Windows, Linux, MacOS.
Chúng ta sẽ dùng terminal để chạy Ffmpeg, ví dụ:
ffmpeg -i input.mp4 output.avi
Lệnh trên sẽ convert file input.mp4
thành file output.avi
.
🥈Cài đặt Ffmpeg
Thường thì server chúng ta sẽ chạy trên Linux, nhưng để test và dev trên máy local thì chúng ta phải tìm cách cài đặt trên hệ điều hành của chúng ta.
Đây là trang download chính chủ của Ffmpeg: https://ffmpeg.org/download.html
Nhưng mình khuyến khích mọi người search youtube để xem cách cài đặt cho nhanh nhé.
Từ khóa: install ffmpeg ubuntu
, Install FFMPEG on Mac Apple Silicon
, install ffmpeg windows
Ví dụ đây là cách cài đặt trên Mac Apple Silicon:
Sau khi cài đặt xong rồi mọi người gõ thử câu lệnh phía dưới xem, nếu nó hiển thị version thì là cài đặt thành công rồi nhé.
ffmpeg -version
🥈Tích hợp FFmpeg vào Node.js
Lời khuyên của mình dành cho các bạn là không nên dùng những thư viện bên thứ 3 để tích hợp Ffmpeg vào Node.js hay bất kỳ ngôn ngữ server như Java, Go, Rust,...
Vì FFmpeg nó được build cho hệ điều hành chứ không phải là dành cho một ngôn ngữ lập trình nào cả. Nên việc bạn tìm kiếm những lib ngoài để tích hợp vào server sẽ có một số rủi ro như: Ít option để lựa chọn, dễ gặp lỗi, lib không được cập nhật thường xuyên,...
Ví dụ Node.js có thư viện node-fluent-ffmpeg, đã 6 năm không cập nhật gì mới, và còn dùng ffmpeg version 2.
Ơ thế thì dùng như thế nào? Làm sao để tương tác với terminal nhỉ?
Điều này không hề khó, các ngôn ngữ backend hiện nay đều có thể tương tác với terminal một cách rất dễ dàng. Với nodejs thì chúng ta sẽ dùng exec
trong module child_process
để tương tác với terminal.
Ví dụ đoạn code dưới đây mình sẽ dùng ffmpeg để lấy bitrate của video
import { exec } from 'child_process'export const getBitrate = (filePath: string) => {return (new Promise() <number >((resolve, reject) => {exec(`ffprobe -v error -select_streams v:0 -show_entries stream=bit_rate -of default=nw=1:nk=1 ${filePath}`,(err, stdout, stderr) => {if (err) {return reject(err)}resolve(Number(stdout.trim()))})}))}
À, dạo này có một cái lib là ffmpeg.wasm, đọc sơ qua cũng khá uy tín, nó dùng wasm để chạy FFmpeg trên môi trường trình duyệt thôi, các phiên bản trước đây thì chạy trên node.js được nhưng giờ thì không nữa. Về performance thì chậm hơn 10 lần so với dùng cách thuần bằng ffmpeg của mình (mình đọc doc của họ là thế).
Oke, bây giờ thì làm sao để biết mấy câu lệnh ffmpeg để convert video, audio nhỉ?
Nếu bạn muốn nghiên cứu sâu vào FFmpeg thì đọc document của họ 😂
Còn mình thì lười hơn, và mình muốn nhanh nên dùng ChatGPT để tìm và giải thích script, cùng với đó là research trên mấy cái blog như hlsbook và H.264 Video Encoding Guide.
Sau quãng thời gian vọc vạch thì mình đúc kết được một số script ffmpeg phổ biến, hay dùng, các bạn có thể tham khảo nhé.
🥈Các câu lệnh FFmpeg phổ biến
Để giải thích các câu lệnh FFmpeg dưới đây, các bạn cứ paste nó vào ChatGPT là được nhé.
🥉Convert video sang HLS giữ nguyên chất lượng
Câu lệnh dưới đây sẽ giữ nguyên chất lượng video lẫn audio của video đó. Chỉ đơn thuần là convert sang định dạng HLS:
input.mov
là đường dẫn video input vàinput.m3u8
là đường dẫn output-hls_time 10
là thời gian mỗi segment video là 10s
ffmpeg -i input.mov -codec: copy -start_number 0 -hls_time 10 -hls_list_size 0 -f hls input.m3u8
Câu lệnh trên sẽ tạo ra các file dạng input0.ts
, input1.ts
, input2.ts
,... và một file input.m3u8
để chứa thông tin về các file ts
đó.
🥉Convert video sang HLS theo độ phân giải
Convert sang HLS và scale down video về một kích thước nào đó, ví dụ 320x180, thì bạn có thể dùng câu lệnh sau:
ffmpeg -i input.mov -vf scale=320:180 -start_number 0 -hls_time 10 -hls_list_size 0 -f hls input.m3u8
Câu lệnh trên mình không chọn codec nên nó sẽ được FFmpeg tự động chọn codec phù hợp nhất cho video đó.
Câu lệnh dưới đây mình sẽ chọn codec là H.264 và audio là AAC, cùng với đó là scale video về 1280x720, đổi tên file segment thành segment_001.ts
, segment_002.ts
,... và đổi tên file playlist thành output.m3u8
ffmpeg -i input.mov \-c:v libx264 -s 1280x720 \-c:a aac \-hls_time 10 -hls_list_size 0 -hls_segment_filename "segment_%03d.ts" \-f hls output.m3u8
Đôi lúc chúng ta muốn chuyển về độ phân giải thấp hơn, nhưng vẫn giữ nguyên tỉ lệ khung hình, thì chúng ta có thể dùng câu lệnh sau:
ffmpeg -i input.mov \-c:v libx264 -vf "scale=-1:720" \-c:a aac \-hls_time 10 -hls_list_size 0 -hls_segment_filename "segment_%03d.ts" \-f hls output.m3u8
Câu lệnh trên mình dùng scale=-1:720
để nó tự động tính toán ra chiều rộng của video, sao cho tỉ lệ khung hình vẫn giữ nguyên, và chiều cao là 720px.
🥉Convert video sang HLS theo nhiều độ phân giải
Câu lệnh dưới mình convert 2 độ phân giải là 640x360 và 960x540.
Các bạn lưu ý là khi convert video sang nhiều độ phân giải thì nên có file master.m3u8
để chứa thông tin về các file playlist của các độ phân giải đó.
ffmpeg -y -i input.mp4 \-preset slow -g 48 -sc_threshold 0 \-map 0:0 -map 0:1 -map 0:0 -map 0:1 \-s:v:0 640x360 -c:v:0 libx264 -b:v:0 365k \-s:v:1 960x540 -c:v:1 libx264 -b:v:1 2000k \-c:a copy \-var_stream_map "v:0,a:0 v:1,a:1" \-master_pl_name master.m3u8 \-f hls -hls_time 6 -hls_list_size 0 \-hls_segment_filename "v%v/fileSequence%d.ts" \v%v/prog_index.m3u8
Câu lệnh này sẽ tạo ra file playlist master.m3u8
cùng với đó là v0/prog_index.m3u8
, v0/fileSequence0.ts
, v0/fileSequence1.ts
,... và v1/prog_index.m3u8
, v1/fileSequence0.ts
, v1/fileSequence1.ts
,...
Khuyết điểm của câu lệnh trên là không thể chọn tỉ lệ khung hình cho video, mình đang cố định độ phân giải, vậy nên nếu video đầu vào không đúng tỉ lệ khi convert sang nó sẽ bị lệch.
Vậy có cách fix không?
Có, Chúng ta sẽ kết hợp chạy các câu lệnh ffmpeg trong nodejs để tự sinh ra command thích hợp. Chi tiết các bạn kéo xuống phần code demo nodejs phía dưới sẽ thấy!
🥇Lưu ý về chất lượng video
Chất lượng một video sau khi convert sẽ bị ảnh hưởng bởi vài yếu tố như:
🥈Thuật toán nén video (Video codec)
Có một vài thuật toán nén video phổ biến như: H.264, H.265, VP9, AV1,...
H.265 sẽ đem lại hiệu suất nén tốt hơn H.264, nghĩa là cùng 1 size video thì H.265 sẽ đem lại chất lượng video cao hơn. Nhưng điểm yếu của H.265 là nó sẽ tốn nhiều CPU hơn để encode video, cũng như là nó không được hỗ trợ trên một số thiết bị cũ.
Phổ biến nhất hiện nay vẫn là H.264, nên mình sẽ dùng H.264.
Khi chọn thuật toán nén thì cũng nên lưu ý định dạng video đầu ra, ví dụ bạn muốn output là video mp4 thì nên dùng H.264, còn nếu là webm thì nên dùng VP9 (H.264 không hỗ trợ Webm).
🥈Bitrate
Bitrate (tỷ lệ bit) là một đơn vị đo lường tốc độ truyền dữ liệu, thường được sử dụng để mô tả tốc độ truyền dữ liệu của âm thanh, video hoặc dữ liệu mạng.
Bitrate được đo bằng bit trên giây (bps) hoặc các đơn vị phổ biến khác như kilobit trên giây (Kbps), megabit trên giây (Mbps) hoặc gigabit trên giây (Gbps).
Kiểu như trong 1s thì video đó chứa bao nhiêu bit vậy đó, càng nhiều thì càng nét. Nhưng bù lại thì càng nhiều thì càng tốn dung lượng.
Lưu ý là không phải lúc nào bitrate càng cao thì video càng nét nhé, nó có giới hạn của nó.
Ví dụ: Video gốc của mình có bitrate là 10,44 Mbit/s và độ phân giải là 2880 x 1800. Bây giờ mình tăng bitrate lên 20 Mbit/s thì video sẽ không nét hơn được 😂
🥈Độ phân giải
Độ phân giải là một đơn vị đo lường kích thước của một hình ảnh, video hoặc màn hình. Nó được đo bằng số lượng pixel trên mỗi chiều của hình ảnh hoặc màn hình.
Video có độ phân giải càng cao thì càng nét, nhưng cũng càng tốn dung lượng.
🥈Các yếu tố khác
Ngoài các yếu tố kể trên thì chất lượng video đầu ra còn phụ thuộc vào một số thứ như: Chất lượng audio, FPS,...
Nhưng mà đa số các trường hợp thì chúng ta chỉ cần quan tâm các yếu tố kể trên là đủ rồi.
🥇Demo code Node.js convert video sang HLS
Dưới đây là đoạn code typescript sẽ convert video sang HLS với chất lượng tùy vào chất lượng video đầu vào
- Mặc định sẽ convert sang 720p
- Từ 720p -> 1080p sẽ convert sang 720p và 1080p
- Từ 1080p -> 1440p sẽ convert sang 720p, 1080p và 1440p
- Từ 1440p trở lên sẽ convert sang 720p, 1080p, maximum
🚨Trước khi đi vào đoạn code thì mình sẽ nói một số lưu ý về đoạn code sau
- Đoạn code này được viết bằng TypeScript, bạn nào dùng JavaScript chịu khó convert sang nhé.
- Mình dùng thư viện
zx
để chạy command cho dễ và dùngslash
để chuyển các path về dạng unix như ubuntu (mục đích là giúp window chạy không có lỗi 😂), vậy nên trước khi chạy đoạn code này, bạn phải chạynpm i zx slash
để cài đặt nó. zx
sẽ chạy các câu lệnh ffmpeg, vậy nên yêu cầu máy của bạn phải cài đặt ffmpeg từ trước.- Vì thư viện
zx
vàslash
này được đóng gói theo ES Module, nên mình mới dùng cú phápconst { $ } = await import('zx')
vàconst slash = (await import('slash')).default
để sử dụng trên dự án chạyCommonJS
. Nếu dự án bạn dùng ES Module thì cứ import bình thường. - Mình đã test trên Mac OS, Linux để chạy được nhé. Riêng Windows thì các bạn dùng Bash terminal (cái terminal khi cài git nó có á) để chạy Node.js, còn PowerShell hay CMD thì có thể gặp lỗi với thư viện
zx
này.
Cách dùng thì cứ gọi encodeHLSWithMultipleVideoStreams('Đường dẫn file của bạn tại đây là được')
import path from 'path'const MAXIMUM_BITRATE_720P = 5 * 10 ** 6 // 5Mbpsconst MAXIMUM_BITRATE_1080P = 8 * 10 ** 6 // 8Mbpsconst MAXIMUM_BITRATE_1440P = 16 * 10 ** 6 // 16Mbpsexport const checkVideoHasAudio = async (filePath: string) => {const { $ } = await import('zx')const slash = (await import('slash')).defaultconst { stdout } = await $`ffprobe ${['-v','error','-select_streams','a:0','-show_entries','stream=codec_type','-of','default=nw=1:nk=1',slash(filePath)]}`return stdout.trim() === 'audio'}const getBitrate = async (filePath: string) => {const { $ } = await import('zx')const slash = (await import('slash')).defaultconst { stdout } = await $`ffprobe ${['-v','error','-select_streams','v:0','-show_entries','stream=bit_rate','-of','default=nw=1:nk=1',slash(filePath)]}`return Number(stdout.trim())}const getResolution = async (filePath: string) => {const { $ } = await import('zx')const slash = (await import('slash')).defaultconst { stdout } = await $`ffprobe ${['-v','error','-select_streams','v:0','-show_entries','stream=width,height','-of','csv=s=x:p=0',slash(filePath)]}`const resolution = stdout.trim().split('x')const [width, height] = resolutionreturn {width: Number(width),height: Number(height)}}const getWidth = (height: number, resolution: { width: number; height: number }) => {const width = Math.round((height * resolution.width) / resolution.height)// Vì ffmpeg yêu cầu width và height phải là số chẵnreturn width % 2 === 0 ? width : width + 1}type EncodeByResolution = {inputPath: stringisHasAudio: booleanresolution: {width: numberheight: number}outputSegmentPath: stringoutputPath: stringbitrate: {720: number1080: number1440: numberoriginal: number}}const encodeMax720 = async ({bitrate,inputPath,isHasAudio,outputPath,outputSegmentPath,resolution}: EncodeByResolution) => {const { $ } = await import('zx')const slash = (await import('slash')).defaultconst args = ['-y','-i',slash(inputPath),'-preset','veryslow','-g','48','-crf','17','-sc_threshold','0','-map','0:0']if (isHasAudio) {args.push('-map', '0:1')}args.push('-s:v:0',`${getWidth(720, resolution)}x720`,'-c:v:0','libx264','-b:v:0',`${bitrate[720]}`,'-c:a','copy','-var_stream_map')if (isHasAudio) {args.push('v:0,a:0')} else {args.push('v:0')}args.push('-master_pl_name','master.m3u8','-f','hls','-hls_time','6','-hls_list_size','0','-hls_segment_filename',slash(outputSegmentPath),slash(outputPath))await $`ffmpeg ${args}`return true}const encodeMax1080 = async ({bitrate,inputPath,isHasAudio,outputPath,outputSegmentPath,resolution}: EncodeByResolution) => {const { $ } = await import('zx')const slash = (await import('slash')).defaultconst args = ['-y', '-i', slash(inputPath), '-preset', 'veryslow', '-g', '48', '-crf', '17', '-sc_threshold', '0']if (isHasAudio) {args.push('-map', '0:0', '-map', '0:1', '-map', '0:0', '-map', '0:1')} else {args.push('-map', '0:0', '-map', '0:0')}args.push('-s:v:0',`${getWidth(720, resolution)}x720`,'-c:v:0','libx264','-b:v:0',`${bitrate[720]}`,'-s:v:1',`${getWidth(1080, resolution)}x1080`,'-c:v:1','libx264','-b:v:1',`${bitrate[1080]}`,'-c:a','copy','-var_stream_map')if (isHasAudio) {args.push('v:0,a:0 v:1,a:1')} else {args.push('v:0 v:1')}args.push('-master_pl_name','master.m3u8','-f','hls','-hls_time','6','-hls_list_size','0','-hls_segment_filename',slash(outputSegmentPath),slash(outputPath))await $`ffmpeg ${args}`return true}const encodeMax1440 = async ({bitrate,inputPath,isHasAudio,outputPath,outputSegmentPath,resolution}: EncodeByResolution) => {const { $ } = await import('zx')const slash = (await import('slash')).defaultconst args = ['-y', '-i', slash(inputPath), '-preset', 'veryslow', '-g', '48', '-crf', '17', '-sc_threshold', '0']if (isHasAudio) {args.push('-map', '0:0', '-map', '0:1', '-map', '0:0', '-map', '0:1', '-map', '0:0', '-map', '0:1')} else {args.push('-map', '0:0', '-map', '0:0', '-map', '0:0')}args.push('-s:v:0',`${getWidth(720, resolution)}x720`,'-c:v:0','libx264','-b:v:0',`${bitrate[720]}`,'-s:v:1',`${getWidth(1080, resolution)}x1080`,'-c:v:1','libx264','-b:v:1',`${bitrate[1080]}`,'-s:v:2',`${getWidth(1440, resolution)}x1440`,'-c:v:2','libx264','-b:v:2',`${bitrate[1440]}`,'-c:a','copy','-var_stream_map')if (isHasAudio) {args.push('v:0,a:0 v:1,a:1 v:2,a:2')} else {args.push('v:0 v:1 v2')}args.push('-master_pl_name','master.m3u8','-f','hls','-hls_time','6','-hls_list_size','0','-hls_segment_filename',slash(outputSegmentPath),slash(outputPath))await $`ffmpeg ${args}`return true}const encodeMaxOriginal = async ({bitrate,inputPath,isHasAudio,outputPath,outputSegmentPath,resolution}: EncodeByResolution) => {const { $ } = await import('zx')const slash = (await import('slash')).defaultconst args = ['-y', '-i', slash(inputPath), '-preset', 'veryslow', '-g', '48', '-crf', '17', '-sc_threshold', '0']if (isHasAudio) {args.push('-map', '0:0', '-map', '0:1', '-map', '0:0', '-map', '0:1', '-map', '0:0', '-map', '0:1')} else {args.push('-map', '0:0', '-map', '0:0', '-map', '0:0')}args.push('-s:v:0',`${getWidth(720, resolution)}x720`,'-c:v:0','libx264','-b:v:0',`${bitrate[720]}`,'-s:v:1',`${getWidth(1080, resolution)}x1080`,'-c:v:1','libx264','-b:v:1',`${bitrate[1080]}`,'-s:v:2',`${resolution.width}x${resolution.height}`,'-c:v:2','libx264','-b:v:2',`${bitrate.original}`,'-c:a','copy','-var_stream_map')if (isHasAudio) {args.push('v:0,a:0 v:1,a:1 v:2,a:2')} else {args.push('v:0 v:1 v2')}args.push('-master_pl_name','master.m3u8','-f','hls','-hls_time','6','-hls_list_size','0','-hls_segment_filename',slash(outputSegmentPath),slash(outputPath))await $`ffmpeg ${args}`return true}export const encodeHLSWithMultipleVideoStreams = async (inputPath: string) => {const [bitrate, resolution] = await Promise.all([getBitrate(inputPath), getResolution(inputPath)])const parent_folder = path.join(inputPath, '..')const outputSegmentPath = path.join(parent_folder, 'v%v/fileSequence%d.ts')const outputPath = path.join(parent_folder, 'v%v/prog_index.m3u8')const bitrate720 = bitrate > MAXIMUM_BITRATE_720P ? MAXIMUM_BITRATE_720P : bitrateconst bitrate1080 = bitrate > MAXIMUM_BITRATE_1080P ? MAXIMUM_BITRATE_1080P : bitrateconst bitrate1440 = bitrate > MAXIMUM_BITRATE_1440P ? MAXIMUM_BITRATE_1440P : bitrateconst isHasAudio = await checkVideoHasAudio(inputPath)let encodeFunc = encodeMax720if (resolution.height > 720) {encodeFunc = encodeMax1080}if (resolution.height > 1080) {encodeFunc = encodeMax1440}if (resolution.height > 1440) {encodeFunc = encodeMaxOriginal}await encodeFunc({bitrate: {720: bitrate720,1080: bitrate1080,1440: bitrate1440,original: bitrate},inputPath,isHasAudio,outputPath,outputSegmentPath,resolution})return true}
Đoạn code trên mới convert sang HLS, còn việc stream thì đơn giản chỉ là serving static file .m3u8
và .ts
thôi chứ không có gì đặc biệt đâu, y như các bạn serving image vậy.
Còn dưới client thì dùng mấy trình play video hỗ trợ HLS như
🥇Tổng kết
Nói chung thì HLS stream có rất nhiều cái lợi, chỉ có khuyết điểm là nó convert hơi lâu và tốn khá nhiều CPU nên anh em cần phải xử lý thêm chỗ này nữa, không là treo server đó nhé.
Chúc anh em thành công 🤪
Khóa học ReactJs giúp bạn chinh phục mức lương 25 - 30 triệu/tháng
Phew! Cuối cùng bạn cũng đã đọc xong. Bài viết này có hơi dài một tí vì mình muốn nó đầy đủ nhất có thể 😅
Chúng ta đều hiểu rằng Javascript và React không hề dễ, chúng có quá nhiều concept cần phải học. Mình cũng cảm thấy nó khó! Nay lại có thể Typescript nữa 🥲, thật sự khó nuốt.
Nhưng đừng lo: Bạn có thể nắm vững các kiến thức trên chỉ trong một khóa học ReactJs Super - Shopee Clone Typescript
Mình đã bắt đầu code React vào năm 2019, và nó đã trở thành thư viện ưa thích của mình để xây dựng UI và web app. Mình cũng đã làm việc với nhiều framework khác như Angular, Vue nhưng thực sự chỉ có React là đem lại cho mình cảm xúc và sự hiệu quả. 💓
Nếu bạn đang gặp khó khăn với React, mình ở đây để giúp bạn!
Mình đã dành hơn 6 tháng để phát triển khóa học ReactJs Super - Shopee Clone Typescript. Trong khóa này bạn sẽ được học mọi thứ về thư viện ReactJs, các kiến thức từ cơ bản cho đến nâng cao nhất, mục đích của mình là giúp bạn chinh phục mức lương 25 - 30 triêu/tháng
Nếu bạn cảm thấy bài viết này của mình hữu ích, mình nghĩ bạn sẽ thích hợp với phong cách dạy của mình. Không như bài viết này, khóa học là sự kết hợp giữa các bài viết, video, quizz, bài tập nhỏ và dự án lớn có thể xin việc được ngay. Học xong mình đảm bảo bạn sẽ lên tay ngay. 💪🏻