Đâ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 Elm
và GitHub 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-id
là origin
.
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ệpLayout.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ằngElm
vàJavaScript
để triển khai các bố cụcLayout
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.html
và 404.html
. Các tệp thực thi này là kết quả sau khi biên dịch code Elm
và copy/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ầuRoute
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ếnRoute
để tạo ra codeHTML
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ácTopic
.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ácSeries
bài viết thuộcTopic
đ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ộtTopic
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ủaSeries
hiện tại nếu người dùng đang chọn xem trang đại diện của mộtSeries
.
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ắnNavigator.Collapse
trong mộtappMsg
và gửi đệ quy lại vào chính hàmApp.update
. - Sau đó gửi lệnh để trình duyệt thay đổi
Url
hiện hành bằng cách đẩyUrl
mới vào vị trí đầu tiên của danh sáchBrowser.History
.
- Đả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à
-- 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 Functional
và Object-Oriented
, mặc dù đã điểm danh sơ lược qua một số yếu tố liên quan khi học Ada
và Elm
; 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] - ...