+12

Build once, run everywhere với Docker: Cấu hình dynamic ENV cho Frontend

Hello các bạn lại là mình đây 👋👋

Nhân một ngày mà nhìn vào list các bài muốn viết mà mình đã lên ý tưởng từ lâu thấy có rất nhiều thứ muốn chia sẻ, và cũng tranh thủ tuần này rảnh vậy là lại ngứa ngáy muốn viết liền 😁

Hôm nay chúng ta sẽ cùng xem cách để có thể cấu hình dynamic env cho app frontend với Docker và bài toán thực tế nhé 😉

Lên thuyền thôi những người anh em thiện lành ơi 🚀🚀

Vài dòng về Frontend

Tổng quan

Có thể các bạn đã biết (hoặc chưa biết 😂) thì hầu hết các app frontend (FE) hiện tại (2024) đều dùng những library/framework như Vue/React/Angular,... bởi vì chúng giúp việc build frontend rất nhanh, UX tốt, không phải load lại trang, logics JS được bind trực tiếp lên UI, logics thay đổi UI tự update,...

Và còn vô vàn những thứ khác nữa, nói chung mình thấy tech phía FE phát triển với tốc độ khá khủng khiếp và nó có mặt ở mọi nơi, từ web đến native (mobile), desktop.

Và góp phần vào sự phát triển đó thì không thể thiếu được một phần rất quan trọng vào sự phát triển của FE đó là sự ra đời của vô vàn bundler (Webpack, Parcel, Vite, Rollup, Rolldown,...) cái sau ra tốc độ lại bàn thờ hơn cả cái trước 😎😎

Không như backend, FE (modern FE) hầu hết cần phải được bundle thì mới chạy production được, quá trình này về cơ bản là bundler sẽ lấy tất cả source code, assets (image, css, html,...) và tạo ra các file bundle tối ưu (HTML, CSS, JS). Quá trình thì mô tả cơ bản như sau (mình lấy trên trang chủ webpack):

Screenshot 2024-04-27 at 11.29.58 PM.png

Và có một điều rất quan trọng ta cần chú ý đó là, sau khi bundle thì ta nhận được các file là static, tức là nội dung không (nên) được thay đổi nữa.

Và 1 thứ đó là biến môi trường (ENV) thì sẽ được bundler thay thế ngay tại thời điểm build bằng giá trị thực trước khi sinh ra các file static.

Vấn đề của FE

Bởi vì như vậy nên không như backend, ở các môi trường khác nhau (dev, staging, production...) ta chỉ cần có file .env và để hết các biến thay đổi theo môit rường ở đó. Thì với frontend sau khi build là mọi thứ đã được "hardcode" static ở đó rồi.

Vậy nhưng hiện nay ở rất nhiều công ty, và cả công ty cũ của mình thì trong deployment workflow của họ follow theo style Build once, run everywhere: tức là ta chỉ được build app 1 lần và deploy ra các môi trường khác nhau. Điều này có những mặt tốt như sau:

  • Đảm bảo chắc chắn rằng tại mọi môi trường ta cùng có 1 bản build y hệt như nhau
  • Vì code FE (đôi khi) có thể bị ảnh hưởng bởi môi trường, build cho dev ok xong build staging thì lỗi,...
  • Không rebuild khi ra môi trường khác, vì ví dụ build cho staging xong gọi QA vào test ok, xong ra production build lại dùng command khác lại build với 1 source code khác 😂😂
  • ...

Cái Build once, run everywhere kia với backend thì rất dễ, vì xưa nay nó vẫn dễ như vậy rồi 😎, thay biến môi trường đi là xong. Nhưng với frontend thì lại không hề dễ như vậy. Bởi vì frontend nó cần phải build, và nó sẽ được build ra các file static, và code static đó sẽ được gửi tới user khi client truy cập trang web, vậy nên về mặt kĩ thuật với mình thì với frontend không có khái niệm "biến môi trường"

vì mỗi lần code frontend chạy trên trình duyệt của 1 user mà, vậy thì "môi trường" ở đây là môi trường nào 😅

Cần chú ý thật kĩ, khi dev ở local thì chạy trên máy của bạn nên bạn sửa ENV sẽ thấy nó ăn ngay nhưng khi deploy production thì không vậy đâu nhé 😂

Do vậy với frontend thường khi cần dùng tới biến môi trường ta sẽ cần tạo nhiều file env như sau:

Screenshot 2024-04-27 at 11.50.58 PM.png

Và có một điều lưu ý là các file .env kia được hardcode thẳng vào project và commit lên git (trừ .env.local). Nếu không thì cũng phải hardcode vào CICD pipeline để "inject" vào quá trình build. Một lần nữa mình phải note lại: đó là biến môi trường với FE nó chỉ có tác dụng tại thời điểm build

Vậy có cách nào để ta có thể làm được điều tương tự như backend hay không? Tức là build 1 lần và tới môi trường nào thì ta dùng 1 file .env thôi là được không? 🧐🧐

Giải pháp hiện tại

Ta cùng điểm qua một số giải pháp phổ biến hiện tại nhé:

Cách 1: Cách phổ biến nhất chắc vấn là đặt các các file .env (.env.production, .env.development) như mình nói bên trên trực tiếp vào source code và ở file package.json khi cần build cho môi trường nào thì ta dùng command dành cho môi trường đó, nó sẽ select file .env tương ứng, ví dụ:

"scripts": {
    "build": "yarn build",
    "build:staging": "env-cmd -f .env.staging yarn build",
    "build:production": "env-cmd -f .env.production yarn build"
}

Nhược điểm của cách này thì ta thấy rõ là cần phải rebuild cho từng môi trường.

Hơn nữa cách này ta phải để các file env trực tiếp vào Git, hoặc không thì cũng phải lưu trên CICD để lấy ra lúc build.

Và 1 cái nữa như mình đã nói ở trước, staging QA test oke rồi, nhưng khi ra production phải build lại với command khác, thế nhỡ command đó lại build với source code khác thì sao? 😅

Cách 2: Gọi API khi app vừa load lên để lấy các config cần thiết

Với cách này thì đúng là ta có thể làm dynamic env, vì env giờ đẩy qua cho backend rồi còn đâu 😂. Và với cách này thì ta sẽ cần 1 network call, nếu network chậm thì ảnh hưởng tới toàn bộ app của chúng ta, user là người trực tiếp chịu ảnh hưởng 😜

Và đương nhiên với cách này ta cần phụ thuộc vào backend

Cách này nó cũng gọi là "Remote Config" như mọi người vẫn gọi (ví dụ Firebase Remote Config)

Cách 3: không dùng env, đặt thẳng vào code

let backendURL = ''

if (location.hostname.includes('production.com')) {
  backendURL = 'api-prod.com'
} else if (location.hostname.includes('staging.com')) {
  backendURL = 'api-staging.com'
} else {
  backendURL = 'api-dev.com'
}

Ở trên ta dựa vào domain hiện tại mà frontend chạy là gì và từ đó chọn ra backend tương ứng. Với cách này thì ưu điểm là không cần ENV gì nữa.

Nhưng nhược điểm cực lớn đó là nó làm frontend phụ thuộc hoàn toàn vào domain, mà đáng ra, FE "tốt" thì không quan trọng nó được deploy ở domain nào, mọi API call đều chỉ dùng đường dẫn tương đối thôi, ví dụ /api/orders, phần domain sẽ được tự động tính toán dựa vào domain hiện tại mà FE được deploy

Bởi vì với đoạn code bên trên, giả sử ta đổi domain của FE đi thành example.com, giờ chả có dev, staging hay production gì để mà if/else nữa cả 😂

Hmmmm, vậy giờ có cách nào làm được như backend không ta....🤔🤔🤔🤔

Âu cây ta cùng xem nhé 😉

Dynamic ENV

Ý tưởng

Nếu các bạn để ý thì với frontend phần code bundle sẽ hoàn toàn do bundler chịu trách nhiệm và rất khó để ta có thể thọc vào phần code đó và thêm logics gì đó sau khi code đã được build thành static files

Nhưng mỗi FE project sẽ luôn có 1 phần đó là các files asset public thì sẽ không được bundle, và ta sẽ lưu thông tin về ENV ở những files như vậy, có 2 files ta có thể dùng:

  • tạo 1 file JS lưu ENV vào window và import nó vào file entry index.html
  • đặt thẳng ENV vào file index.html

2 cách này cũng xêm xêm nhau ta cùng xem nhé

Clone code

Như thường lệ đầu tiên ta clone source code cho bài này ở đây, nhánh master và folder là fe-dynamic-env nhé các bạn

Sau khi clone về thì ta chạy thử lên coi sao nha, ta chạy với docker luôn để bạn nào không có NodeJS (npm) cũng xem được luôn:

docker compose up -d --build

Sau đó ta mở trình duyệt ở địa chỉ http://localhost:8080 sẽ thấy như sau nhé:

Screenshot 2024-04-28 at 10.39.26 AM.png

Ở đây mình đã setup cho các bạn 1 project React + Vite, build cho production bằng Docker và chạy với nginx

Món chính

Giờ ta vô món chính của ngày hôm nay nha 😋😋

Các bạn để ý ta có dòng text "Vite + React". Mỗi khi ta muốn thay đổi nó thì ta sẽ phải sửa code src/App.tsx sau đó build lại docker compose up -d --build

Giờ ta sẽ cùng nhau đưa dòng text đó vào ENV và làm nó dynamic, mỗi khi sửa chỉ cần restart thay vì build là có thể thấy update ngay nhé 😙😙

Như đã nói ở trên ta sẽ đặt env ở public assets, ở bài này nó sẽ là những files ở trong folder public hoặc file index.html (ở root folder project)

Trước hết ta down app đi đã nhé:

docker compose down

Đầu tiên trong folder public các bạn tạo cho mình file env.template.js với nội dung như sau:

window.__HELLO_MESSAGE__ = "${HELLO_MESSAGE}"

Ở trên ta sẽ tạo thêm thuộc tính global __HELLO_MESSAGE__ trong window, giá trị thì bằng 1 cái string với placeholder là biến môi trường mà tí nữa ta sẽ truyền vào ${HELLO_MESSAGE}

biến global thì kiểu convention là thường ta tránh việc trùng lặp nên người ta hay thêm những dạng tiền/hậu tố như __ 😉

Sau đó ta tạo file env.jsvới nội dung như sau:

window.__HELLO_MESSAGE__ = "Default text"

Lí do là tí nữa ta sẽ inject ENV vào file env.template.js, sau đó xuất content ra file env.js. Trong trường hợp ta không inject được env vào env.template.js thì ở env.js ta có sẵn fallback message để vẫn có thể hiển thị ra cho user

Tiếp theo ta mở index.html và import file env.js kia vào:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React + TS</title>

    <!-- =====Ở Đây===== -->
    <script src="/env.js"></script>

  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

Tiếp theo là ở trong code ta sẽ truy cập tới biến global kia như sau:

import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'

declare global {
  interface Window { __HELLO_MESSAGE__: string; }
}

function App() {
  const [count, setCount] = useState(0)

  return (
    <>
      <div>
        <a href="https://vitejs.dev" target="_blank">
          <img src={viteLogo} className="logo" alt="Vite logo" />
        </a>
        <a href="https://react.dev" target="_blank">
          <img src={reactLogo} className="logo react" alt="React logo" />
        </a>
      </div>
      <h1>{window.__HELLO_MESSAGE__}</h1>
      <div className="card">
        <button onClick={() => setCount((count) => count + 1)}>
          count is {count}
        </button>
        <p>
          Edit <code>src/App.tsx</code> and save to test HMR
        </p>
      </div>
      <p className="read-the-docs">
        Click on the Vite and React logos to learn more
      </p>
    </>
  )
}

export default App

Ở trên ta có 2 thay đổi:

  1. ở đoạn thẻ <h1> ta đã thay đoạn text cũ bằng code lấy message từ window
  2. Ở đầu file ta có thêm 1 đoạn code Typescript nhỏ để khai báo thêm 1 thuộc tính vào global window (cái này bạn nào dùng Javascript thì không cần)

Tiếp là bước quan trọng nhất đó là inject env từ Dockfile:

FROM node:20-alpine as build-stage
WORKDIR /app
COPY package.json yarn.lock* ./
RUN yarn --frozen-lockfile
COPY . .
RUN yarn build

FROM nginx:1.25-alpine
WORKDIR /usr/share/nginx/html
COPY --from=build-stage /app/dist .
EXPOSE 80
# CMD ["nginx", "-g", "daemon off;"]

CMD /bin/sh -c "envsubst < env.template.js > env.js && nginx -g 'daemon off;'"

Ở trên ta sửa duy nhất dòng CMD cuối cùng, ta sẽ dùng envsubst, đây là 1 CLI tool đọc env từ môi trường và thực hiện thay thế env vào file text. Sau khi inject được env thì ta start nginx như thường.

Có thể các bạn thắc mắc, lúc tạo file env thì nó nằm trong folder public mà sao ở CMD thì lại làm ngay ở root, thì đó là do output của bundler, sau khi build thì mọi files public nó đưa hết lên cùng 1 level:

Screenshot 2024-04-28 at 11.02.20 AM.png

Và giờ, trước khi up thì ta truyền biến môi trường từ docker-compose.yml như ta vẫn làm với backend nha 😉:

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - 8080:80
    environment:
      - HELLO_MESSAGE=Hello World

Cuối cùng là ta start thôiiiiiiiii 🚀🚀🚀:

docker compose up -d --build

Sau đó ta mở trình duyệt ở http://localhost:8080 vàaaaaaaa pòm pòm chíu chíu 🎆🎆:

Screenshot 2024-04-28 at 11.15.59 AM.png

Message của ta đã hiện ra ở đó. Giờ mỗi khi muốn thay đổi ta chỉ cần sửa trực tiếp ở docker-compose.yml và không cần build lại nữa. Y hệt như backend vậy 😎😎😎

Vọc vạch

File env.template.js

Ta thử truy cập http://localhost:8080/env.js xem có gì nha:

window.__HELLO_MESSAGE__ = "Hello World"

Ở trên ta thấy từ file template nó đã inject được ENV vào và export ra file env.js này

Tiếp theo ta truy cập http://localhost:8080/env.template.js sẽ thấy:

window.__HELLO_MESSAGE__ = "${HELLO_MESSAGE}"

Ở đây ta thấy rằng file template này vẫn được truy cập public trong khi nó không có tác dụng gì khi chạy nữa (runtime), do vậy ta có thể xoá nó đi sau khi file env.js được export thành công.

Ta sửa lại Dockerfile như sau nhé:

FROM node:20-alpine as build-stage
WORKDIR /app
COPY package.json yarn.lock* ./
RUN yarn --frozen-lockfile
COPY . .
RUN yarn build

FROM nginx:1.25-alpine
WORKDIR /usr/share/nginx/html
COPY --from=build-stage /app/dist .
EXPOSE 80
# CMD ["nginx", "-g", "daemon off;"]

CMD /bin/sh -c "envsubst < env.template.js > env.js && rm env.template.js && nginx -g 'daemon off;'"

Ở trên mình đã thêm đoạn rm env.template.js để xoá file template đi rồi. Ta build và restart lại app nha:

docker compose down
docker compose up -d --build

Sau đó lại truy cập http://localhost:8080/env.template.js và ta sẽ thấy:

Screenshot 2024-04-28 at 11.22.14 AM.png

Và user sẽ không thể truy cập và biết được ta có gì ở file env.template.js nữa 🤪

Đưa logics vào ENTRYPOINT

Như các bạn thấy hiện tại logics inject ENV ta để ở CMD, kèm với command start nginx, trông nó khá là xấu 🤣

Ta sẽ chuyển qua dùng ENTRYPOINT đưa phần logics vào shell script trông cho đẹp nhé.

Các bạn tạo cho mình file entrypoint.sh ở root folder project với nội dung như sau:

#!/bin/sh

# Substitute environment variables in env.js
envsubst < env.template.js > env.js

# Remove template file
rm env.template.js

nginx -g 'daemon off;'

Sau đó ở Dockerfile ta sửa lại chút:

FROM node:20-alpine as build-stage
WORKDIR /app
COPY package.json yarn.lock* ./
RUN yarn --frozen-lockfile
COPY . .
RUN yarn build

FROM nginx:1.25-alpine
WORKDIR /usr/share/nginx/html
COPY --from=build-stage /app/dist .
COPY entrypoint.sh .
RUN chmod +x entrypoint.sh
EXPOSE 80

ENTRYPOINT ["./entrypoint.sh"]

Logics thì y hệt các bạn tự thẩm nha 😁

Sau đó ta rebuild lại project và test thử trên trình duyệt để đảm bảo mọi thứ vẫn oke nha:

docker compose down
docker compose up -d --build

Cache env file với HTTP caching

Nếu các bạn để ý thì hiện tại app của chúng ta sẽ luôn load file env.js đầu tiên sau đó mới load đến những thành phần khác. Ta không dùng async hay defer mà ta muốn đảm bảo là env phải ready trước thì mới load app

Screenshot 2024-04-28 at 11.56.04 AM.png

Điều này dẫn tới một vấn đề có thể xảy ra: như ở ảnh trên ta thấy rằng để load env.js thì ta cũng tốn 1 network call

Mặc dù env.js và code bundle frontend đặt chung 1 chỗ, nên nếu FE load nhanh thì env.js thường load cũng sẽ nhanh, nhưng khi chạy production user nhiều thì sẽ có thể có những trường hợp mà network bất ổn dẫn tới việc env.js load chậm hơn, dẫn tới toàn bộ app của ta load chậm đi

Để giải quyết vấn đề này ta có nhiều cách, ở bài này ta sẽ dùng HTTP caching nhé. Vì mình thấy đây là 1 kĩ thuật phổ biến, được dùng rộng rãi, nhưng có vẻ hiện tại nó không được phổ biến nhiều tới anh em dev FE nữa. Về cơ bản kĩ thuật này là từ Nginx ta sẽ trả về header "báo hiệu" cho trình duyệt biết rằng "ê cache file này lại nha nó ít thay đổi lắm" 🫠

Đầu tiên các bạn tạo mình file app.nginx.conf để cấu hình nginx cho app của chúng ta:

server {
    listen       80;
    server_name  localhost;

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
    }

    location = /env.js {
        root /usr/share/nginx/html;
        expires 5s;  # Cache for 1 hour
    }

    error_page   500 502 503 504  /50x.html;
    
    location = /50x.html {
        root   /usr/share/nginx/html;
    }

}

Thực tế file này mình copy từ file mặc định của nginx và chỉ thêm vào đúng 1 đoạn location = /env.js, ở đây ta nói với trình duyệt là cache file này lại 5 giây (để dễ demo, thực tế ta nên chọn thời gian cao hơn như 1 giờ/1 tuần,...)

Sau đó ta sửa lại docker-compose.yml chút để mount file này vào container:

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - 8080:80
    volumes:
      - ./app.nginx.conf:/etc/nginx/conf.d/default.conf
    environment:
      - HELLO_MESSAGE=Hello World

Và giờ ta restart lại lại là được rồi (không cần rebuild vì ta không sửa gì liên quan tới Dockerfile):

docker compose down
docker compose up -d --build

Sau đó ta quay lại trình duyệt và F5 nhiều lần sẽ thấy như sau:

ezgif-5-695cee45c8.gif

Những gì xảy ra bên trên như sau:

  • Lần đầu tiên: load env.js qua network
  • Trong vòng 5 giây tiếp theo nếu ta liên tục F5 sẽ thấy trình duyệt dùng luôn từ memory cache
  • Hết thời gian 5 giây thì lại tiếp tục load env.js qua network

Nếu ta inspect request header lên sẽ thấy là nginx gửi về trình duyệt header max-age như sau:

Screenshot 2024-04-28 at 12.22.03 PM.png

Khi trình duyệt thấy có Cache-Control ở header là sẽ thực hiện caching theo những strategy đã được định sẵn

Đưa thẳng vào index.html

Nếu ta thấy rằng việc dùng file env.js này kiểu gì cũng tốn ít nhất 1 network call, và ta muốn cải thiện việc này thì ta có thể đưa tất cả vào index.html cũng được luôn 😎😎

Ta sửa lại index.html xoá luôn đoạn import script cho env.js đi nhé:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React + TS</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

Giờ ta có thể xoá env.jsenv.template.js trong folder public đi rồi. Sau đó ta update lại entrypoint.sh nữa nha:

#!/bin/sh

cp index.html index.template.html

sed -i '1i <script>\n  window.__HELLO_MESSAGE__ = "${HELLO_MESSAGE}";\n</script>\n' index.template.html

envsubst < index.template.html > index.html

rm index.template.html

nginx -g 'daemon off;'

Ở trên các bạn thấy rằng ta tạo file index.template.html từ nội dung hiện tại của index.html, lí do là vì nội dung của index.html sau khi build thì bundler sẽ inject một đống thứ khác vào và ta không đoán trước được nó là gì, nom nó như sau:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React + TS</title>
    <script type="module" crossorigin src="/assets/index-fk17Nz9V.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-DiwrgTda.css">
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

Như các bạn thấy content của index.html là random không đoán trước vậy nên với cách này ta không tạo file template riêng

Quay trở lại với file entrypoint.sh mà ta vừa update, ở đó ta có:

  • copy từ file index.html ra file index.template.html
  • dùng sed để insert vào đầu file template phần code JS
  • logics còn lại thì vẫn vậy

Kết quả mà lát nữa ta sẽ nhận được file index.html với nội dung xêm xêm như sau:

<script>
  window.__HELLO_MESSAGE__ = "Hello World";
</script>

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React + TS</title>
    <script type="module" crossorigin src="/assets/index-fk17Nz9V.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-DiwrgTda.css">
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

Vì giờ ta không dùng env.js nữa nên ta cũng chả cần custom cấu hình nginx nữa, nên ở file docker-compose.yml ta có thể bỏ đi đoạn mount volume:

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - 8080:80
    # volumes:
    #   - ./app.nginx.conf:/etc/nginx/conf.d/default.conf
    environment:
      - HELLO_MESSAGE=Hello World

Và giờ ta rebuild project rồi start nha:

docker compose down
docker compose up -d --build

Sau khi chạy lên ta sẽ thấy điều tương tự:

Screenshot 2024-04-28 at 12.48.02 PM.png

Như các bạn thấy thì với cách này ta bỏ hẳn được file env.js, tiết kiệm được một network call, nhưng hơi "tricky" chút đoạn tạo file template từ file bundle index.html

Ở đây mình làm demo nên các bạn để ý trong entrypoint.sh ta đang hardcode trực tiếp biến môi trường ở đó. Khi làm thật, có nhiều biến môi trường thì ta nên tách ra làm 1 file riêng, đọc content file đó lên rồi dùng sed insert vào index.html

Kết bài

Ngày xưa mình cứ nghĩ làm Frontend là quanh quẩn Vue/React/Angular,... UI button, background,... nhưng thực tế là Frontend nó còn có rất nhiều thứ xung quanh để ta có thể "chơi" 😂 và áp dụng vào các bài toán thực tế. Ví dụ như bài này cũng xuất phát từ yêu cầu công việc của mình cần Build once, run everywhere cho frontend

Hi vọng đã đem tới cho các bạn một cách mới trong việc xử lý ENV cho frontend.

Chúc các bạn cuối tuần vui vẻ hẹn gặp lại các bạn vào các bài sau 👋


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí