- vừa được xem lúc

[Declarative Programming + Elm] Bài 18 - Simplicity SPA Elm

0 0 10

Người đăng: Semi Dev

Theo Viblo Asia

Đây là mini project thứ ba trên hành trình tự học code mà mình chia sẻ tại nền tảng blog Viblo ở đây và mình hy vọng rằng vẫn còn nhiều người quan tâm tới Sub-Series này. Lý do là mình đã phải bỏ quãng khá lâu không thể đăng bài viết theo kiểu vừa code vừa chia sẻ kiến thức, bởi các công cụ được Elm cung cấp mặc định vẫn có phần rất mới mẻ đối với mình. Việc vừa code một mini project và vừa đăng tải các bài viết chia sẻ song song với tiến trình cập nhật vốn kiến thức cá nhân khiến mình cảm thấy có phần hơi cập dập. Bởi rất có thể mình sẽ phải liên tiếp quay trở lại chỉnh sửa các bài viết đã qua khi chạm tới một kĩ thuật xử lý code mới mà mình mới Google được.

Tuy nhiên thì sau khi code xong một trang blog đơn giản bằng Elm và đăng tải lên GitHub Page thì mình nhận ra là để xây dựng một ứng dụng trang đơn kiểu này cũng không hẳn yêu cầu một kiến trúc code phức tạp. Thực tế thì tất cả những gì mình đã cần sử dụng không hề nằm ngoài so với các kiến thức căn bản về ngôn ngữ và framework Elm đã được nhắc đến trong các bài viết trước đó.

Nếu như bạn đã đồng hành cùng Series Tự Học Lập Trình đầu tiên cho tới hết bài viết gần nhất về Elm trước khi mình bỏ quãng thì rất có thể hiện tại bạn đã viết xong một vài SPA xịn bằng Elm rồi. Tuy nhiên, mình vẫn muốn đăng một vài bài viết về tiến trình xây dựng trang blog cá nhân đơn giản mà mình đã thực hiện với ElmGitHub Page để dành cho việc tự đọc tham khảo lại sau này nếu bản thân cần tới; Lý do là bởi sau Sub-Series này thì hành trình tự học của mình vẫn sẽ tiếp tục với các ngôn ngữ khác nữa và sẽ phải tạm biệt Elm một thời gian không rõ bao lâu.

Blog: https://thinhtranhnvn.github.io

Source Code: GitHub.com -> thinhtranhnvn.github.io

Data Service

Ở đây mình trông chờ hoàn toàn vào cơ chế đáp ứng yêu cầu dữ liệu đơn giản của GitHub khi chúng ta nhấn vào nút xem nội dung tệp gốc raw trên giao diện của GitHub. Ví dụ:

https://raw.githubusercontent.com/thinhtranhnvn/thinhtranhnvn.github.io/main/data/topic-list.json

Trong đường dẫn này thì bắt đầu từ sau tên nhánh chính branch: main dữ liệu trong một thư viện repository bất kỳ trên GitHub sẽ có thể được truy xuất theo đường dẫn có cấu trúc thư mục tương tự như lưu trữ trên máy tính. Từ thư mục gốc / chúng ta đang trỏ tới thư mục data rồi sau đó chọn tệp topic-list.json. Và như vậy chúng ta sẽ có thể cứ thế tạo ra các thư mục lưu trữ dữ liệu theo cấu trúc phù hợp với nhu cầu sắp xếp các bài viết blog.

Trang blog mà mình xây dựng như bạn thấy trong liên kết ở trên có các bài viết được nhóm theo hai cấp độ: đầu tiên là chủ đề Topic, và sau đó là các Series thuộc mỗi chủ dề. Như vậy để gửi yêu cầu truy vấn tới một tệp .txt chứa nội dung của một bài viết thì dạng đường dẫn mà mình sử dụng là:

Post: /data/topic-id/series-id/post-slug.txt

Và liên kết trỏ tới bài viết có dạng là:

Url: https://thinhtranhnvn.github.io/topic-id/series-id/post-slug

Danh sách các bài viết và metadata của tất cả đều được nhóm trong một tệp khai báo post-list.json ở cùng cấp thư mục với các tệp nội dung bài, tức là một thư mục tượng trưng cho một Series sẽ có một tệp post-list.json và các tệp nội dung bài viết .txt.

Post-List: /data/topic-id/series-id/post-list.json

Và liên kết trỏ tới trang xem đề mục của Series có dạng là:

Url: https://thinhtranhnvn.github.io/topic-id/series-id

Tương tự thì mỗi thư mục tương trưng cho một Topic sẽ có một tệp series-list.json chứa metadata của các Series và các thư mục tượng trưng cho các Series thuộc Topic đó. Tuy nhiên thì thiết kế blog này luôn luôn có một thanh điều hướng phụ để hiện danh sách các Series thuộc Topic đang xem, vì vậy nên khi người dùng nhấn vào tên của một Topic thì mình chọn hiển thị một bài viết tổng quan Overview để giới thiệu khái quát nội dung. Tệp lưu bài viết Overview này được đặt riêng biệt ngay trong cấp đầu tiên của thư mục Topic đó và bên cạnh tệp series-list.json.

Series-List: /data/topic-id/series-list.json

Topic-Overview: /data/topic-id/overview.txt

Và liên kết trỏ tới trang giới thiệu tổng quan của mỗi Topic có dạng là:

Url: https://thinhtranhnvn.github.io/topic-id

Riêng liên kết trỏ tới trang chủ, được ngầm định là trỏ tới một Topic mặc định có tên là Origin, và tương đương với liên kết trỏ tới trang Topic ở trên với topic-idorigin.

Url: https://thinhtranhnvn.github.io/origin

Routing

Với định hướng thiết kế đường dẫn và hiển thị nội dung như trên thì chúng ta có các tuyến yêu cầu được định nghĩa là:

module Route exposing (..) import Url exposing (Url)
import Url.Parser as UrlParser exposing (Parser, parse, oneOf, string, top, (</>)) -- Route - - - - - - - - - - - - - - - - - - - - - - - - - - - type Route = HomePage | TopicPage (TopicId) | SeriesPage (TopicId) (SeriesId) | PostPage (TopicId) (SeriesId) (PostSlug) type alias TopicId = String
type alias SeriesId = String
type alias PostSlug = String -- parser - - - - - - - - - - - - - - - - - - - - - - - - - - - parser : Parser (Route -> a) a
parser = oneOf [ UrlParser.map (HomePage) (top) , UrlParser.map (TopicPage) (string) , UrlParser.map (SeriesPage) (string </> string) , UrlParser.map (PostPage) (string </> string </> string) ] -- Url Example:
-- https://thinhtranhnvn.github.io/origin/heart-sutra/00-the-first-preface
-- topicId = origin
-- seriesId = heart-sutra
-- postSlug = 00-the-first-preface

Code Management

Việc tổ chức sắp xếp code ở đây được thực hiện thuận theo tiến trình code của mình và được chia làm hai giai đoạn là:

  • mockup - tạo ra các tệp Layout.html để mô phỏng bố cục các trang tĩnh và triển khai code CSS.
  • src - viết code xử lý logic bằng ElmJavaScript để triển khai các bố cục Layout vào logic xử lý dựa trên các tuyến yêu cầu ở trên.

Và vì vậy nên tại thư mục gốc của project bạn có thể thấy hai thư mục với tên như trên được đặt bên cạnh các tệp thực thi index.html404.html. Các tệp thực thi này là kết quả sau khi biên dịch code Elmcopy/paste một số nội dung ở thẻ head của tệp blank.html để gắn kèm code CSS trong thư mục mockup. Khi người dùng trỏ tới tên miền của blog thì GitHub Page gửi tới trình duyệt một trong hai tệp thực thi trên có chứa toàn bộ code xử lý logic của ứng dụng SPA đã xây dựng.

Đây là cấu trúc thư mục /mockup:

/mockup
├── Element
│ ├── Base
│ │ └── style.css
│ ├── Indexer
│ │ ├── ...
│ │ └── style.css
│ ├── Navigator
│ │ ├── ..
│ │ └── style.css
│ ├── Overview
│ │ ├── ..
│ │ └── style.css
│ └── Reader
│ ├── ..
│ └── style.css
├── Layout
│ ├── Home.html
│ └── Series.html
|
├── master.css
├── jquery.min.js
└── main.js

Ở đây tệp code CSS được viết tản ra các tệp thành phần và @import tổ hợp lại tại các tệp /mockup/Element/.../style.css. Sau đó các tệp này tiếp tục được @import tổ hợp lại tại một tệp /mockup/master.css duy nhất để nhúng vào các tệp Layout.html và các tệp thực thi sau khi biên dịch code Elm. Mình có sử dụng thêm một chút jQuery trong code ở phần /src, còn tệp /mockup/main.js ở đây thì chỉ là nháp tạm trong giai đoạn làm mockup.

Và đây là cấu trúc thư mục /src:

/src
├── App.elm
├── Context.elm
├── Data
│ ├── Post.elm
│ ├── Series.elm
│ └── Topic.elm
├── Element
│ ├── Indexer.elm
│ ├── Metadata.elm
│ ├── metadata.js
│ ├── Navigator.elm
│ ├── navigator.js
│ ├── Overview.elm
│ └── Reader.elm
├── Extension
│ └── Http
│ └── Error.elm
└── Route.elm

Tệp đại diện với hàm main để bắt đầu chương trình là App.elm mô phỏng ứng dụng ở cấp độ tổng quan và điều hành việc hiển thị các Element phù hợp tùy vào Route mà người dùng yêu cầu. Và đây là các đoạn code ngắn về App.Model, App.Msg, App.init, và App.view:

type alias Model = { url : Url , key : Key -- , route : Route , context : Context.Model -- , metadata : Metadata.Model , navigator : Navigator.Model , overview : Overview.Model , reader : Reader.Model , indexer : Indexer.Model }

Ở đây chúng ta có các Element với các chức năng cụ thể là:

  • Context - Gửi yêu cầu truy vấn dữ liệu để mô phỏng bối cảnh hoạt động hiện thời của toàn bộ App dựa trên tuyến yêu cầu Route mà người dùng chọn.
  • Các thành phần sử dụng dữ liệu từ Context.Model và định tuyến Route để tạo ra code HTML hiển thị nội dung:
    • Metadata - cập nhật các thẻ <meta /> trong <head>.
    • Navigator - hiển thị thanh điều hướng chính với danh sách liên kết trỏ tới các Topic.
    • Overview - hiển thị thanh điều hướng phụ với danh sách các liên kết trỏ tới các Series bài viết thuộc Topic đang xem.
    • Reader - hiển thị nội dung bài viết nếu người dùng đang xem trang đại diện của một Topic hoặc một trang bài viết cụ thể.
    • Indexer - hiển thị khối nội dung liệt kê danh sách liên kết trỏ tới các bài viết của Series hiện tại nếu người dùng đang chọn xem trang đại diện của một Series.
type Msg = GotUrlRequest (UrlRequest) | UrlChanged (Url) -- | CtxMsg (Context.Msg) -- | MtdMsg (Metadata.Msg) | NavMsg (Navigator.Msg) | OvrMsg (Overview.Msg) | RdrMsg (Reader.Msg) | IdxMsg (Indexer.Msg)

Khi sử dụng view của các module Element để tổ hợp tại App.view thì chúng ta sẽ cần thực hiện thao tác Html.map để chuyển đổi các node thuộc các kiểu Html Metadata.Msg, Html Navigator.Msg, v.v.. .về kiểu Html App.Msg. Vì vậy nên trong định nghĩa của App.Msg sẽ cần có các kiểu vỏ bọc Wrapper để bọc lấy các tin nhắn Msg tạo ra từ các module Element.

init : () -> Url -> Key -> ( Model, Cmd Msg )
init _ url key = let route = Route.fromUrl (url) ( ctxModel, ctxCmd ) = Context.init (Url.toString url) -- ( mtdModel, _ ) = Metadata.init (ctxModel) ( navModel, _ ) = Navigator.init (ctxModel) ( ovrModel, _ ) = Overview.init (ctxModel) ( rdrModel, _ ) = Reader.init (ctxModel) ( idxModel, _ ) = Indexer.init (ctxModel) -- model = { url = url , key = key -- , route = route , context = ctxModel -- , metadata = mtdModel , navigator = navModel , overview = ovrModel , reader = rdrModel , indexer = idxModel } cmd = Cmd.map (CtxMsg) (ctxCmd) -- in ( model, cmd )

Ở trình App.view thì dựa vào Route cụ thể, chúng ta sẽ chỉ hiển thị các Element phù hợp. Và với thiết kế như đã nói trên thì Metadata, và hai thanh điều hướng Navigator + Overview sẽ luôn có mặt. Reader thì sẽ xuất hiện ở các trang đại diện Topic bao gồm cả trang chủ, và các trang bài viết cụ thể. Và như vậy chỉ có duy nhất trang đại diện cho Series sẽ có phần Reader được thay bằng Indexer để hiện danh sách liên kết trỏ tới các bài viết thuộc Series đó.

view : Model -> Document Msg
view model = let mtdHtml = Metadata.view (model.metadata) navHtml = Navigator.view (model.navigator) ovrHtml = Overview.view (model.overview) rdrHtml = Reader.view (model.reader) idxHtml = Indexer.view (model.indexer) -- pageHtml = case (model.route) of -- SeriesPage _ _ -> [ Html.map (MtdMsg) (mtdHtml) , Html.map (NavMsg) (navHtml) , Html.map (OvrMsg) (ovrHtml) , Html.map (IdxMsg) (idxHtml) ] -- _ -> [ Html.map (MtdMsg) (mtdHtml) , Html.map (NavMsg) (navHtml) , Html.map (OvrMsg) (ovrHtml) , Html.map (RdrMsg) (rdrHtml) ] -- in { title = "Semi Dev_ 's Blog" , body = pageHtml }

Trình cập nhật App.update cũng sẽ ủy thác việc xử lý các Element.Msg tới cho các trình cập nhật Element.update khi người dùng chọn xem một liên kết mới và bối cảnh Context có sự thay đổi. Vì vậy nên các Element sẽ phải cung cấp ra các kiểu Msg để App điều hành nếu cần thiết.

Ở đây ngoài các tin nhắn Context.Msg thì chúng ta chỉ có duy nhất thao tác đóng/mở và thu gọn thanh điều hướng chính Navigator cần xử lý; Và vì vậy nên hầu hết các tin nhắn thuộc các kiểu Element.Msg đều không có ý nghĩa đối với trình điều khiển chính.

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model = case (msg) of -- GotUrlRequest req -> ... -- UrlChanged newUrl -> ... -- CtxMsg ctxMsg -> ... -- NavMsg navMsg -> ... -- _ -> ( model, Cmd.none )

Khi người dùng click vào một liên kết thì trình điều khiển sẽ nhận được một UrlRequest và các trường hợp xử lý căn bản vẫn là:

  • Browser.External - liên kết trỏ tới trang web khác tên miền ở bên ngoài. Lúc này chúng ta chỉ cần gửi lệnh để trình duyệt tải trang web đó.
  • Browser.Internal - liên kết trỏ tới một trang đơn khác cùng tên miền. Lúc này chúng ta cần thực hiện hai thao tác đó là:
    • Đảm bảo rằng thanh điều hướng chính sẽ được thu hẹp lại nếu như đang ở trạng thái mở rộng. Đây chính là vị trí mà chúng ta sẽ sử dụng tin nhắn mà Navigator thiết kế để mở cho code bên ngoài điều hành. Thao tác xử lý cần thực hiện là bọc tin nhắn Navigator.Collapse trong một appMsg và gửi đệ quy lại vào chính hàm App.update.
    • Sau đó gửi lệnh để trình duyệt thay đổi Url hiện hành bằng cách đẩy Url mới vào vị trí đầu tiên của danh sách Browser.History.
 -- GotUrlRequest req -> case (req) of -- Browser.External href -> let cmd = Browser.load (href) in ( model, cmd ) -- Browser.Internal newUrl -> let appMsg = NavMsg (Navigator.CollapseFolder) ( updatedModel, _ ) = update (appMsg) (model) -- urlStr = Url.toString (newUrl) cmd = Browser.pushUrl (model.key) (urlStr) in ( updatedModel, cmd )

Sau đó trình App.update sẽ tiếp tục nhận được hai tin nhắn là NavMsg (Navigator.CollapseFolder) được gửi đệ quy trực tiếp từ chính App.update, và UrlChanged được gửi từ trình điều khiển Program sau khi Browser.History đã được cập nhật xong. Lúc này chúng ta có tin nhắn yêu cầu thu hẹp thanh điều hướng chính Navigator sẽ được ủy thác lại cho Navigator.update xử lý:

 -- NavMsg navMsg -> let ( navModel, _ ) = Navigator.update (navMsg) (model.navigator) -- updatedAppModel = { model | navigator = navModel } in ( updatedAppModel, Cmd.none )

Còn tin nhắn UrlChanged sẽ được xử lý ở cấp độ điều hành của App là cập nhật Context, sau đó sử dụng Context mới để cập nhật các Element giao diện. Tuy nhiên, thao tác cập nhật Context thường sẽ phải gửi yêu cầu truy vấn dữ liệu và chúng ta không biết rằng khi nào thì trình duyệt web mới thực sự nhận được dữ liệu mới và bản ghi Context.Model mới được tạo ra. Vì vậy nên ở đây chúng ta chỉ gọi Context.update với Url mới và chờ đợi để bắt lại các tin nhắn Context.Msg tại cấp độ điều hành của App.update để xử lý tiếp.

 -- UrlChanged newUrl -> let newRoute = Route.fromUrl (newUrl) -- newUrlStr = Url.toString (newUrl) ( ctxModel, ctxCmd ) = Context.update (Context.UrlChanged newUrlStr) (model.context) -- updatedModel = { model | url = newUrl -- , route = newRoute , context = ctxModel } cmd = Cmd.map (CtxMsg) (ctxCmd) -- in ( updatedModel, cmd )

Trình update của Context có thể gửi đi nhiều yêu cầu truy vấn dữ liệu bởi vì chúng ta đang có các thành phần sử dụng tới danh sách các chủ đề Topic, danh sách các Series, và một bản nội dung bài viết mã markdown hoặc danh sách các bài viết Post. Các yêu cầu này sẽ được phản hồi vào những thời điểm rời rạc vì vậy nên chúng ta không cần phải quan tâm tới việc gom dữ liệu phản hồi mà thay vào đó thì cứ bất kỳ khi nào App.update thấy có CtxMsg được gửi tới là sẽ sử dụng ngay bản ghi Context.Model mới để cập nhật các Element đồ họa.

 -- CtxMsg ctxMsg -> let ( ctxModel, ctxCmd ) = Context.update (ctxMsg) (model.context) -- ( mtdModel, _ ) = Metadata.update (Metadata.ContextChanged ctxModel) (model.metadata) ( navModel, _ ) = Navigator.update (Navigator.ContextChanged ctxModel) (model.navigator) ( ovrModel, _ ) = Overview.update (Overview.ContextChanged ctxModel) (model.overview) ( rdrModel, _ ) = Reader.update (Reader.ContextChanged ctxModel) (model.reader) ( idxModel, _ ) = Indexer.update (Indexer.ContextChanged ctxModel) (model.indexer) -- updatedAppModel = { model | context = ctxModel -- , metadata = mtdModel , navigator = navModel , overview = ovrModel , reader = rdrModel , indexer = idxModel } cmd = Cmd.map (CtxMsg) (ctxCmd) in ( updatedAppModel, cmd )

Và đó là logic điều hành một SPA đơn giản được hỗ trợ bởi kiến trúc Elm Architecture. Những chi tiết khác về việc viết code triển khai thực sự không có gì đáng kể và đều tương đương với các ví dụ đơn giản mà chúng ta đã thực hiện trong những bài viết học lý thuyết về ngôn ngữ và kiến trúc của Elm.

Functional Programming + Haskell

Trong khuôn khổ giới hạn của một mini project nghiệm thu kiến thức của Sub-Series giới thiệu mô hình lập trình Declarative Programming, có lẽ điều đáng kể nhất mà mình học được đó là kiến trúc phần mềm mà Elm cung cấp sẵn. Và bởi vì đây đã là mini project thứ ba trong hành trình tự học code mà mình chia sẻ ở đây, nên việc viết lại code triển khai chi tiết trong các bài viết diễn giải lại source code trong liên kết ở đầu bài viết có lẽ không hẳn cần thiết nếu như bạn đã đồng hành cùng từ Series Tự Học Lập Trình Web đầu tiên.

Tại thời điểm này thì chúng ta vẫn chưa có các Sub-Series dành cho hai mô hình lập trình FunctionalObject-Oriented, mặc dù đã điểm danh sơ lược qua một số yếu tố liên quan khi học AdaElm; Vì vậy nên ở đây mình sẽ tiếp tục đặt một điểm nối tiếp tới Sub-Series mới Functional Programming + Haskell, và chúng ta sẽ để dành điểm chuyển tiếp này cho đến khi thực hiện xong Sub-Series Object-Oriented + Java đã dự định từ khi kết thúc Sub-Series học Ada trước đó.

(chưa đăng tải) [Functional Programming + Haskell] - ...

Bình luận

Bài viết tương tự

- vừa được xem lúc

Closure trong Javascript - Phần 2: Định nghĩa và cách dùng

Các bạn có thể đọc qua phần 1 ở đây. Để mọi người không quên, mình xin tóm tắt gọn lại khái niệm lexical environment:.

0 0 51

- vừa được xem lúc

Var vs let vs const? Các cách khai báo biến và hằng trong Javascript

Dạo này mình tập tành học Javascript, thấy có 2 cách khai báo biến khác nhau nên đã tìm tòi sự khác biệt. Nay xin đăng lên đây để mọi người đọc xong hy vọng phân biệt được giữa let và var, và sau đó là khai báo hằng bằng const.

0 0 31

- vừa được xem lúc

VueJS: Tính năng Mixins

Chào mọi người, hôm nay mình sẽ viết về Mixins và 1 số vấn đề trong sử dụng Mixins hay ho mà mình gặp trong dự án thực. Trích dẫn từ trang chủ của VueJS:.

0 0 27

- vừa được xem lúc

Asset Pipeline là cái chi chi?

Asset Pipeline. Asset pipeline là cái chi chi. . Giải thích:.

0 0 47

- vừa được xem lúc

Tạo data table web app lấy dữ liệu từ Google Sheets sử dụng Apps Script

Google Sheets là công cụ tuyệt vời để lưu trữ bảng tính trực tuyến, bạn có thể truy cập bảng tính bất kỳ lúc nào ở bất kỳ đâu và luôn sẵn sàng để chia sẻ với người khác. Bài này gồm 2 phần.

0 0 266

- vừa được xem lúc

Học Deep Learning trên Coursera miễn phí

Bạn muốn bắt đầu với Deep Learning nhưng không biết bắt đầu từ đâu? Bạn muốn có một công việc ở mức fresher về Deep Learning? Bạn muốn khoe bạn bè về kiến thức Deep Learning của mình. Bắt đầu từ đâu.

0 0 35