Discover Meteor

Building Real-Time JavaScript Web Apps

Version 1.7.2 (updated December 5, 2014)

Giới thiệu

1

Hãy thử tưởng tượng trong đầu một thí nghiệm như sau. Từ màn hình máy tính, mở 2 cửa sổ dẫn tới cùng một thư mục. Sau đó, bấm chọn một cửa sổ và tiến hành xóa một tập tin trong thư mục ở cửa sổ đó. Câu hỏi đặt ra là: Liệu tập tin bị xóa có biến mất ngay lập tức ở cửa sổ còn lại không?

Tất nhiên, ta không cần phải mở máy tính và thực hiện hết các tao tác trên mới biết được câu trả lời. Các thay đổi thực hiện với hệ thống tập tin trên máy tính (local filesystems) luôn được phản ánh đồng bộ ở mọi nơi mà không cần phải làm mới (refresh) hay tham chiếu ngược (callback).

Tuy nhiên, hãy xem kịch bản trên sẽ ra sao nếu diễn ra trên nền web. Giả sử, bạn mở cùng một địa chỉ admin của một trang web WordPress trong hai cửa sổ trình duyệt. Sau đó, tiến hành tạo một bài viết mới từ một trong hai cửa sổ đó. Khác với hệ thống tập tin trên máy tính, các cửa sổ của trình duyệt web không phản ánh sự thay đổi tức thì, trừ khi bạn làm mới chúng.

Trong những năm qua, chúng ta đã quá quen với ý tưởng rằng, hoạt động tương tác trên nền web là ngắn hạn và rời rạc.

Tuy nhiên, nền tảng công nghệ Meteor mới ra đời sẽ thách thức thực trạng trên khi cho phép xây dựng các ứng dụng web tương tác theo thời gian thực.

Meteor là gì?

Meteor là một nền tảng được xây dựng trên môi trường Node.js, cho phép tạo ra các ứng dụng web theo thời gian thực. Nó đảm bảo việc đồng bộ thông tin giữa cơ sở dữ liệu của ứng dụng và giao diện người dùng.

Chính vì được xây dựng trên nền Node.js nên Meteor sử dụng JavaScript trên cả máy khách và máy chủ. Hơn thế nữa, Meteor còn cho phép chia sẻ code giữa hai môi trường này.

Có thể nói, Meteor là một nền tảng vừa đơn giản lại mạnh mẽ khi xóa bỏ hầu hết mọi phiền phức và cạm bẫy thông thường hay gặp phải khi phát triển ứng dụng web.

Tại sao nên sử dụng Meteor?

Vậy tại sao bạn nên dành thời gian nghiên cứu Meteor thay vì các nền tảng web khác? Tạm gác lại rất nhiều các tính năng hữu dụng khác của Meteor, chúng tôi tin rằng, điểm mạnh nhất của nền tảng web này là: rất dễ nắm bắt.

Học Meteor dễ hơn nhiều so với bất kỳ nền tảng web nào khác. Bạn có thể xây dựng và cho chạy một ứng dụng web theo thời gian thực chỉ sau vài giờ. Và nếu bạn đã từng phát triển front-end và quen thuộc với JavaScript, bạn sẽ không cần mất thời gian học một ngôn ngữ mới khi tiếp cận với Meteor.

Meteor có thể là nền tảng lý tưởng cho nhu cầu phát triển web của bạn, cũng có thể không. Nhưng nếu bạn chỉ mất một khóa học vài buổi để tìm ra chân lý thì tại sao lại không thử?

Mục đích của cuốn sách?

Vài năm trở lại đây, chúng tôi có cơ hội làm việc với nhiều dự án Meteor, từ phát triển ứng dụng di động đến ứng dụng web, từ các dự án thương mại đến các dự án mã nguồn mở.

Tuy thật không dễ để tìm ra câu trả lời cho nhiều vấn đề, nhưng chúng tôi đã học hỏi được khá nhiều. Đôi khi phải chọn lọc giải pháp từ nhiều nguồn khác nhau, đôi khi phải tự tìm ra lối đi cho riêng mình. Với cuốn sách này, chúng tôi hy vọng có thể chia sẻ những kiến thức quý báu mình học được và tập hợp chúng thành một cuốn cẩm nang hướng dẫn các bước xây dựng một ứng dụng Meteor từ a đến z.

Ứng dụng Meteor chúng tôi sẽ xây dựng là một phiên bản đơn giản của các kiểu website tin tức xã hội như Hacker News hay Reddit. Chúng tôi gọi nó là Microscope (được đặt theo tên người anh em Telescope - một ứng dụng Meteor mã nguồn mở - http://telesc.pe). Trong khi xây dựng Microscope, chúng tôi sẽ đề cập đến tất cả các yếu tố cần thiết để tạo ra một ứng dụng Meteor, ví như tài khoản người dùng (user account), bộ sưu tập Meteor (Meteor collection), định tuyến (routing), và nhiều yếu tố khác nữa.

Đối tượng của cuốn sách?

Một trong những mục tiêu của chúng tôi khi viết cuốn sách là giữ cho mọi thứ gần gũi và dễ hiểu. Vì vậy, bạn có thể dễ dàng học theo cuốn sách dù chưa từng có kinh nghiệm với Meteor, Node.js, MVC frameworks, hay thậm chí là lập trình cho máy chủ nói chung.

Mặt khác, chúng tôi giả định người đọc phải có hiểu biết cơ bản với các cú pháp và khái niệm của JavaScript. Tuy nhiên, để hiểu cuốn sách này không hề khó, nếu bạn có kinh nghiệm xử lý các mã mã jQuery hay quen thuộc với giao diện điều khiển cho lập trình viên trên các trình duyệt.

Nếu bạn vẫn chưa thực sự tự tin với kiến thức JavaScript của mình, chúng tôi khuyên bạn nên đọc qua bài viết về JavaScript cho nền tảng Meteor trước khi bắt đầu cuốn sách.

Về tác giả

Nếu bạn đang tự hỏi tại sao nên tin tưởng chúng tôi, dưới đây là tập hợp thông tin về các tác giả của cuốn sách.

** Tom Coleman ** một thành viên của Percolate Studio, đơn vị phát triển web chú trọng vào chất lượng và trải nghiệm người dùng. Ông còn là một trong những nhà điều hành Atmosphere, và cũng là một trong những bộ não đằng sau nhiều dự án Meteor mã nguồn mở khác như Iron Router.

** Sacha Greif ** có kinh nghiệm làm việc với nhiều công ty startup như HipmunkRubyMotion với vai trò nhân viên phát triển sản phẩm và thiết kế web. Ông là tác giả của Telescope, Sidebar (dựa trên Telescope), và cũng là người sáng lập Folyo.

Chương và mục nâng cao

Cuốn sách này được thiết kế cho cả người dùng mới làm quen Meteor và các lập trình viên có kinh nghiệm. Nội dung được chia làm hai loại: chương cơ bản (đánh số từ 1 đến 14) và các nội dung nâng cao (các số lẻ 0,5).

Chương cơ bản hướng dẫn người đọc các bước xây dựng một ứng dụng Meteor. Nội dung tập trung vào các điểm mục trọng yếu, tránh đi sâu vào chi tiết rườm rà, tốn thời gian cho người đọc.

Mặt khác, các mục nâng cao đi sâu hơn vào những chi tiết phức tạp của Meteor, giúp người đọc có cái nhìn sâu hơn về bản chất hoạt động của Meteor.

Vì vậy, những người mới làm quen với Meteor có thể bỏ qua các mục nâng cao và quay lại đọc khi đã có một hiểu biết nhất định về Meteor.

Đẩy code và cập nhật ứng dụng

Không gì tệ hơn việc theo dõi một cuốn sách lập trình từ đầu đến cuối và rồi đột nhiên nhận ra các dòng code của bạn không hoạt động được như các ví dụ trong sách.

Để tránh gặp phải trường hợp này, chúng tôi đã tạo một kho dữ liệu cho Microscope trên GitHub, và sẽ cung cấp các link trực tiếp đến mỗi thay đổi trên git. Ngoài ra, mỗi lần code mới được đưa lên git, một liên kết sẽ được tạo ra link đến bản cập nhật của ứng dụng, giúp tiện cho việc so sánh bản ứng dụng trên web và trên máy tính. Hãy xem ví dụ dưới đây.

Commit 11-2

Display notifications in the header.

Cũng cần lưu ý rằng, việc chúng tôi cung cấp sẵn các bản cập nhật code không có nghĩa là bạn chỉ xài không mà không cần hiểu bản chất. Việc tự gõ ra các dòng code của ứng dụng sẽ giúp bạn sẽ học tốt hơn.

Các nguồn tài liệu liên quan

Nếu bạn muốn tìm hiểu thêm về một khía cạnh cụ thể của Meteor, bạn nên bắt đầu đọc từ tài liệu chính thống về Meteor.

Khi cần xử lý sự cố, giải đáp các thắc mắc hay hỗ trợ trực tuyến, bạn nên tham khảo tại Stack Overflow và #meteor IRC channel.

Git có thật cần thiết?

Để đọc hiểu cuốn sách này, bạn không nhất thiết phải quen thuộc với việc quản lý code trên Git. Tuy nhiên, chúng tôi khuyên bạn nên có hiểu biết nhất định về Git.

Nếu bạn cần một cuốn cẩm nang cấp tốc về Git, có thể đọc cuốn Git đơn giản hơn bạn nghĩ của Nick Farina.

Nếu bạn là người mới làm quen với Git, chúng tôi khuyên bạn nên cài ứng dụng GitHub cho Mac. Nó cho phép bạn sao chép và quản lý các kho code trên git mà không cần sử dụng dòng lệnh.

Liên hệ

Khởi đầu

2

Ấn tượng đầu tiên là quan trọng, và quá trình cài đặt của Meteor nên khá dễ dàng. Trong hầu hết các trường hợp, bạn sẽ sẵn sàng chạy được trong vòng chưa đầy năm phút.

Để bắt đầu, chúng ta có thể cài đặt Meteor bằng cách mở một cửa sổ terminal và gõ:

curl https://install.meteor.com | sh

Điều này sẽ cài đặt các meteor thực thi trên hệ thống của bạn và bạn đã sẵn sàng để sử dụng Meteor.

Không Cài đặt Meteor

Nếu bạn không thể (hoặc không muốn) cài đặt Meteor tại địa phương, chúng tôi khuyên bạn nên kiểm tra Nitrous.io.

Nitrous.io là một dịch vụ cho phép bạn chạy các ứng dụng và chỉnh sửa mã nguồn của họ ngay trong trình duyệt của bạn, và chúng tôi đã viết một hướng dẫn ngắn để giúp bạn có được thiết lập.

Bạn có thể chỉ cần làm theo hướng dẫn cho tới (và bao gồm) phần “Cài đặt Meteor”, và sau đó làm theo cuốn sách một lần nữa bắt đầu từ phần “Tạo một app đơn giản” của chương này.

Tạo một app đơn giản

Bây giờ chúng ta đã cài đặt Meteor, chúng ta hãy tạo ra một ứng dụng. Để làm được điều này, chúng tôi sử dụng công cụ dòng lệnh ‘meteor` của Meteor:

meteor create microscope

Lệnh này sẽ tải về Meteor, và thiết lập một dự án Meteor cơ bản, sẵn sàng để sử dụng cho bạn. Khi thực hiện xong, bạn sẽ thấy một thư mục, microscope/, có chứa những điều sau đây:

.meteor
microscope.css
microscope.html
microscope.js

App mà Meteor đã tạo ra cho bạn là một ứng dụng soạn sẵn thể hiện một vài mẫu đơn giản.

Mặc dù ứng dụng của chúng tôi không làm được gì nhiều, chúng ta vẫn có thể chạy nó. Để chạy ứng dụng này, quay về terminal và gõ:

cd microscope
meteor

Bây giờ trỏ trình duyệt của bạn đến http://localhost:3000/ (hoặc tương đương 'http://0.0.0.0:3000/`) và bạn sẽ thấy một cái gì đó như thế này:

Meteor's Hello World.
Meteor’s Hello World.

Commit 2-1

Created basic microscope project.

Xin chúc mừng! Bạn đã có ứng dụng Meteor đầu tiên đang chạy của riêng mình. Ngoài ra, để dừng ứng dụng thì tất cả việc bạn cần phải làm là tới tab của terminal đang chạy ứng dụng, và nhấn ctrl+c.

Cũng lưu ý rằng nếu bạn đang sử dụng Git, đây là một thời điểm tốt để khởi tạo repo của bạn với git init.

Tạm biệt Meteorite

Có một thời gian, khi mà Meteor dựa vào một trình quản lý gói bên ngoài gọi là meteorite. Kể từ phiên bản 0.9.0 của Meteor, Meteorite trở nên không cần thiết nữa vì tính năng của nó đã được đồng hóa vào trong chính Meteor.

Vì vậy, nếu bạn gặp bất kỳ tài liệu tham khảo nào liên hệ với tiện ích dòng lệnh mrt của Meteorite trong suốt cuốn sách này hoặc trong khi duyệt các tài liệu liên quan đến Meteor, bạn có thể an toàn thay thế chúng bằng dòng lệnh meteor bình thường.

Thêm một Package

Bây giờ chúng ta sẽ sử dụng hệ thống gói Meteor để thêm framework Bootstrap vào dự án.

Điều này không khác gì so với việc thêm Bootstrap theo cách thông thường bằng tay bao gồm cả tập tin CSS và JavaScript, ngoại trừ chúng ta dựa vào trình quản lý package để giữ cho mọi thứ được cập nhật.

Ngoài ra, chúng tôi cũng sẽ thêm gói Underscore. Underscore là một thư viện tiện ích JavaScript, và nó rất hữu ích khi thao tác với các cấu trúc dữ liệu JavaScript.

Theo văn bản này, gói underscore vẫn là một phần trong những package “chính thức” của Meteor, đó là lý do tại sao nó không có tên tác giả:

meteor add mizzao:bootstrap-3
meteor add underscore

Lưu ý rằng chúng ta đang thêm Bootstrap 3. Một số ảnh chụp màn hình trong cuốn sách này được thực hiện với một phiên bản cũ của Microscope chạy Bootstrap 2, có nghĩa là chúng có thể trông hơi khác nhau.

Commit 2-2

Added bootstrap and underscore packages.

Ngay sau khi bạn đã thêm gói Bootstrap bạn sẽ nhận thấy một sự thay đổi trong khung trần của ứng dụng:

With Bootstrap.
With Bootstrap.

Không giống như cách thêm vào tài nguyên từ bên ngoài theo cách “truyền thống”, chúng ta đã không phải liên kết tới bất kỳ tập tin CSS hoặc JavaScript, vì Meteor sẽ quản lý tất cả những việc đó cho chúng ta! Đó chỉ là một trong nhiều ưu điểm của các gói trong Meteor.

Chú ý về các gói

Khi nói về các gói trong bối cảnh của Meteor, nó đáng giá để chúng ta đặc tả. Meteor sử dụng năm loại package cơ bản:

  • Chính bản thân Meteor được chia thành các gói các gói nền tảng Meteor. Chúng bao gồm trong mọi ứng dụng Meteor, và thường thì bạn không cần phải lo lắng nhiều về .
  • Gói Meteor chính quy được gọi là “isopacks”, hoặc là các gói đẳng cấu (có nghĩa là họ có thể làm việc trên cả máy khách và máy chủ). Gói của bên thứ nhất như tài khoản-ui hoặcappcache được duy trì bởi đội ngũ nòng cốt Meteor và đi kèm với Meteor.
  • Gói của bên thứ ba là gói isopacks được phát triển bởi những người dùng khác mà đã được tải lên máy chủ quản lý gói Meteor. Bạn có thể duyệt chúng trên Atmosphere hoặc với lệnh meteor search.
  • Gói địa phương là các gói tùy chỉnh, bạn có thể tự tạo ra và đặt trong thư mục ’/packages`.
  • Gói NPM (Node.js Packaged Modules) là gói Node.js. Mặc dù chúng không làm việc trực tiếp với Meteor được, chúng có thể được sử dụng bởi các loại gói vừa được liệt kê.

Cấu trúc file của một ứng dụng Meteor

Trước khi chúng ta bắt đầu code, chúng ta phải lập dự án theo đúng cách. Để đảm bảo chúng ta có một cấu trúc thoáng, hãy mở thư mục 'microscopevà xóa' microscope.html, microscope.js, microscope.css.

Tiếp theo, tạo ra bốn thư mục gốc bên trong /microscope:/client, /server,/public, và /lib.

Tiếp theo, chúng ta cũng sẽ tạo ra file rỗng main.htmlmain.js bên trong /client. Đừng lo lắng nếu điều này phá vỡ các ứng dụng hiện tại, chúng ta sẽ bắt đầu điền vào các tập tin này trong chương tiếp theo.

Chúng ta nên đề cập đến việc một số thư mục này là đặc biệt. Khi nói đến mã chạy, Meteor có một vài quy tắc:

  • Mã trong thư mục /server chỉ chạy trên máy chủ.
  • Mã trong thư mục /client chỉ chạy trên máy khách.
  • Mọi thứ khác chạy trên cả máy khách và máy chủ.
  • Tài nguyên tĩnh của bạn (font chữ, hình ảnh, vv) nằm trong thư mục /public.

Và cũng sẽ hữu ích nếu biết làm thế nào Meteor quyết định thứ tự nạp các file:

  • Các tập tin trong /lib được nạp trước bất cứ điều gì khác.
  • Bất kỳ tập tin main.* nào cũng được nạp sau tất cả mọi thứ khác.
  • Mọi thứ khác tải trong thứ tự chữ cái dựa trên tên file.

Lưu ý rằng mặc dù Meteor có những quy tắc, nó không thực sự buộc bạn phải sử dụng bất kỳ cấu trúc tập tin được định nghĩa sẵn nào cho ứng dụng nếu bạn không muốn. Vì vậy, cấu trúc mà chúng tôi đề nghị chỉ là một cách để làm việc, không phải là một quy tắc cứng nhắc.

Chúng tôi khuyến khích bạn kiểm tra tài liệu Meteor chính thức nếu bạn muốn biết thêm chi tiết về điều này.

Meteor có phải là MVC?

Nếu bạn đến từ các framework khác như Ruby on Rails, bạn có thể tự hỏi liệu Meteor có áp dụng mô hình MVC (Model View Controller).

Câu trả lời ngắn gọn là không. Không giống như Rails, Meteor không áp đặt bất kỳ cấu trúc được xác định trước nào đối với ứng dụng của bạn. Vì vậy, trong cuốn sách này, chúng tôi sẽ đơn giản đặt ra mã theo cách hợp lý nhất, mà không lo lắng quá nhiều về cụm từ MVC.

Không có thư mục public?

OK, đó chỉ là chuyện đùa. Chúng ta không thực sự cần thư mục public vì một lý do đơn giản là Microscope không dùng dữ liệu tĩnh! Nhưng, vì hầu hết các ứng dụng Meteor khác sẽ bao gồm ít nhất là một vài hình ảnh, chúng tôi nghĩ cũng quan trọng để nói về nó.

Ngoài ra, bạn cũng có thể nhận thấy một thư mục .meteor ẩn. Đây là nơi Meteor lưu trữ code của mình, và thay đổi mọi thứ trong đó thường là một ý kiến tồi. Trong thực tế, bạn không thực sự cần phải quan tâm đến thư mục này. Các trường hợp ngoại lệ duy nhất này là các file .meteor/packages.meteor/release, dùng để liệt kê các gói thông minh của bạn và phiên bản của Meteor để sử dụng. Khi bạn thêm gói và thay đổi bản phát hành Meteor, có thể sẽ hữu ích nếu kiểm tra các thay đổi trong những tập tin này.

Underscores vs CamelCase

Điều duy nhất chúng ta sẽ nói về cuộc tranh cãi giữa biến dùng gạch dưới lâu đời (my_variable) và biến theo kiểu camelCase (myVariable) là nó không thực sự có vấn đề nếu bạn chọn một trong hai loại miễn là bạn trung thành với nó.

Trong cuốn sách này, chúng tôi đang sử dụng camelCase vì đó là cách thông thường JavaScript làm việc (dù sao đi nữa, đó là JavaScript, không phải java_script!).

Các trường hợp ngoại lệ của luật này là tên các tập tin, mà sẽ sử dụng gạch dưới (my_file.js), và các lớp CSS, mà sử dụng dấu gạch nối (.my-class). Lý do cho điều này là trong tập tin hệ thống, gạch chân là phổ biến nhất, trong khi các cú pháp CSS tự nó đã sử dụng dấu gạch nối (font-family, text-align, vv).

Chú trọng tới CSS

Cuốn sách này không phải là về CSS. Vì vậy, để tránh làm chậm tốc độ của bạn với các chi tiết về kiểu dáng, chúng tôi đã quyết định để cho toàn bộ stylesheet có sẵn từ đầu, vì vậy bạn không cần phải lo lắng về nó thêm một lần nữa.

CSS tự động được nạp và làm nhỏ bởi Meteor, vì vậy không giống như các tài nguyên tĩnh khác, nó được đặt trong /client, mà không phải là /public. Hãy bắt đầu và tạo một thư mục 'client/stylesheets/bây giờ, và đặt filestyle.css` sau đây bên trong đó:

.grid-block, .main, .post, .comments li, .comment-form {
  background: #fff;
  -webkit-border-radius: 3px;
  -moz-border-radius: 3px;
  -ms-border-radius: 3px;
  -o-border-radius: 3px;
  border-radius: 3px;
  padding: 10px;
  margin-bottom: 10px;
  -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.15);
  -moz-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.15);
  box-shadow: 0 1px 1px rgba(0, 0, 0, 0.15); }

body {
  background: #eee;
  color: #666666; }

.navbar {
  margin-bottom: 10px; }
  /* line 32, ../sass/style.scss */
  .navbar .navbar-inner {
    -webkit-border-radius: 0px 0px 3px 3px;
    -moz-border-radius: 0px 0px 3px 3px;
    -ms-border-radius: 0px 0px 3px 3px;
    -o-border-radius: 0px 0px 3px 3px;
    border-radius: 0px 0px 3px 3px; }

#spinner {
  height: 300px; }

.post {
  /* For modern browsers */
  /* For IE 6/7 (trigger hasLayout) */
  *zoom: 1;
  position: relative;
  opacity: 1; }
  .post:before, .post:after {
    content: "";
    display: table; }
  .post:after {
    clear: both; }
  .post.invisible {
    opacity: 0; }
  .post.instant {
    -webkit-transition: none;
    -moz-transition: none;
    -o-transition: none;
    transition: none; }
  .post.animate{
    -webkit-transition: all 300ms 0ms ease-in;
    -moz-transition: all 300ms 0ms ease-in;
    -o-transition: all 300ms 0ms ease-in;
    transition: all 300ms 0ms ease-in; }
  .post .upvote {
    display: block;
    margin: 7px 12px 0 0;
    float: left; }
  .post .post-content {
    float: left; }
    .post .post-content h3 {
      margin: 0;
      line-height: 1.4;
      font-size: 18px; }
      .post .post-content h3 a {
        display: inline-block;
        margin-right: 5px; }
      .post .post-content h3 span {
        font-weight: normal;
        font-size: 14px;
        display: inline-block;
        color: #aaaaaa; }
    .post .post-content p {
      margin: 0; }
  .post .discuss {
    display: block;
    float: right;
    margin-top: 7px; }

.comments {
  list-style-type: none;
  margin: 0; }
  .comments li h4 {
    font-size: 16px;
    margin: 0; }
    .comments li h4 .date {
      font-size: 12px;
      font-weight: normal; }
    .comments li h4 a {
      font-size: 12px; }
  .comments li p:last-child {
    margin-bottom: 0; }

.dropdown-menu span {
  display: block;
  padding: 3px 20px;
  clear: both;
  line-height: 20px;
  color: #bbb;
  white-space: nowrap; }

.load-more {
  display: block;
  -webkit-border-radius: 3px;
  -moz-border-radius: 3px;
  -ms-border-radius: 3px;
  -o-border-radius: 3px;
  border-radius: 3px;
  background: rgba(0, 0, 0, 0.05);
  text-align: center;
  height: 60px;
  line-height: 60px;
  margin-bottom: 10px; }
  .load-more:hover {
    text-decoration: none;
    background: rgba(0, 0, 0, 0.1); }

.posts .spinner-container{
  position: relative;
  height: 100px;
}

.jumbotron{
  text-align: center;
}
.jumbotron h2{
  font-size: 60px;
  font-weight: 100;
}

@-webkit-keyframes fadeOut {
  0% {opacity: 0;}
  10% {opacity: 1;}
  90% {opacity: 1;}
  100% {opacity: 0;}
}

@keyframes fadeOut {
  0% {opacity: 0;}
  10% {opacity: 1;}
  90% {opacity: 1;}
  100% {opacity: 0;}
}

.errors{
  position: fixed;
  z-index: 10000;
  padding: 10px;
  top: 0px;
  left: 0px;
  right: 0px;
  bottom: 0px;
  pointer-events: none;
}
.alert {
          animation: fadeOut 2700ms ease-in 0s 1 forwards;
  -webkit-animation: fadeOut 2700ms ease-in 0s 1 forwards;
     -moz-animation: fadeOut 2700ms ease-in 0s 1 forwards;
  width: 250px;
  float: right;
  clear: both;
  margin-bottom: 5px;
  pointer-events: auto;
}
client/stylesheets/style.css

Commit 2-3

Re-arranged file structure.

Chú ý về CoffeeScript

Trong cuốn sách này, chúng ta sẽ viết với JavaScript thuần. Nhưng nếu bạn thích CoffeeScript, Meteor cũng có thể đáp ứng. Đơn giản chỉ cần thêm các gói CoffeeScript và bạn sẽ sẵn sàng:

meteor add coffeescript

Triển khai

Sidebar 2.5

Một số người thích làm việc lặng lẽ trên một dự án cho đến khi nó hoàn hảo, trong khi những người khác không thể chờ đợi để cho thế giới biết càng sớm càng tốt.

Nếu bạn thuộc nhóm đầu tiên, và muốn tạm thời phát triển tại localhost, bạn có thể bỏ qua chương này. Mặt khác, nếu bạn thích dành thời gian để tìm hiểu làm thế nào để triển khai các ứng dụng trực tuyến của Meteor, chúng tôi sẽ hướng dẫn cho bạn.

Chúng ta sẽ được học làm thế nào để triển khai một ứng dụng Meteor theo vài cách khác nhau. Bạn có thể tự do lựa chọn cách triển khai trong bất kỳ giai đoạn phát triển nào, cho dù bạn đang làm việc trên Microscope hoặc bất kỳ ứng dụng Meteor khác. Hãy cùng bắt đầu!

Giới thiệu về Sidebars

Đây là một chương về sidebar. Sidebar tìm hiểu sâu sắc về một chủ đề chung của Meteor độc lập với phần còn lại của cuốn sách.

Vì vậy, nếu bạn muốn bắt đầu xây dựng Microscope, bạn có thể tạm thời bỏ qua chương này và trở lại với nó sau.

Triển khai trên Meteor

Triển khai trên một tên miền phụ Meteor (ví dụ ‘http://myapp.meteor.com`) là lựa chọn dễ dàng nhất, và là điều đầu tiên chúng ta sẽ thử làm. Điều này có thể hữu ích để giới thiệu ứng dụng của bạn với những người khác trong những ngày đầu của nó, hoặc để nhanh chóng thiết lập một server cho môi trường staging.

Triển khai trên Meteor khá là đơn giản. Bạn chỉ cần mở terminal, di chuyển vào thư mục ứng dụng Meteor của bạn, và gõ:

meteor deploy myapp.meteor.com

Tất nhiên, bạn sẽ phải thay thế “myapp” với một tên do bạn lựa chọn, tốt nhất là một tên mới mà vẫn chưa được sử dụng.

Nếu đây là lần đầu tiên bạn triển khai một ứng dụng, bạn sẽ được nhắc nhở để tạo một tài khoản Meteor. Và nếu mọi việc suôn sẻ, sau một vài giây, bạn sẽ có thể truy cập vào ứng dụng của bạn tại http://myapp.meteor.com.

Bạn có thể tham khảo tài liệu chính thức để biết thêm thông tin về những thứ như truy cập trực tiếp vào cơ sở dữ liệu lưu trữ trên máy chủ của bạn, hoặc cấu hình một tên miền tùy chỉnh cho ứng dụng của bạn.

Triển khai trên Modulus

Modulus là một lựa chọn tuyệt vời cho việc triển khai các ứng dụng Node.js. Đó là một trong số ít PaaS (nền tảng như một dịch vụ) mà cung cấp hỗ trợ Meteor chính thức, và đã có khá một số lượng người chạy sản phẩm ứng dụng Meteor trên đó.

Bạn có thể học thêm về Modulus bằng việc đọc hướng dẫn triển khai cho ứng dụng Meteor.

Hãy bắt đầu bằng việc tạo một tài khoản. Để triển khai ứng dụng trên Modulus, chúng ta cần phải cài đặt công cụ dòng lệnh Modulus

npm install -g modulus

Và sau đó xác nhận với:

modulus login

Bây giờ chúng ta sẽ tạo ra một dự án Modulus (lưu ý rằng bạn cũng có thể làm được điều này thông qua bảng điều khiển web Modulus ’):

modulus project create

Bước tiếp theo sẽ được tạo ra một cơ sở dữ liệu MongoDB cho ứng dụng của chúng ta. Chúng ta có thể tạo ra một cơ sở dữ liệu MongoDB với chính Modulus, Compose hoặc với bất kỳ nhà cung cấp dịch vụ cloud MongoDB nào khác.

Một khi đã tạo ra cơ sở dữ liệu MongoDB, chúng ta có thể nhận được MONGO_URL cho cơ sở dữ liệu từ giao diện người dùng web Modulus ’(đi vào Bảng điều khiển> Cơ sở dữ liệu> Chọn cơ sở dữ liệu của bạn> Quản trị), sau đó sử dụng nó để cấu hình ứng dụng như vậy:

modulus env set MONGO_URL "mongodb://<user>:<pass>@mongo.onmodulus.net:27017/<database_name>"

Đó là bây giờ thời gian để triển khai các ứng dụng của chúng tôi. Nó là dễ như gõ:

modulus deploy

Bây giờ chúng ta đã triển khai thành công ứng dụng của chúng tôi để Modulus. Tham khảo tài liệu Modulus để biết thêm thông tin về truy cập nhật ký, thiết lập tên miền tùy chỉnh, và SSL.

Meteor Up

Mặc dù giải pháp cloud xuất hiện hàng ngày, chúng thường xuất hiện đi kèm với vấn đề và hạn chế của mình. Vì vậy tốt hơn hết là bạn triển khai ứng dụng Meteor trên server của riêng mình. Chỉ có một vấn đề là, việc tự mình triển khai thường không đơn giản, nhất là nếu bạn mong muốn triển khai với chất lượng của một sản phẩm thực sự.

Meteor Up (hoặc nói ngắn gọn mup) là một giải pháp khác, được đi kèm với tiện ích dòng lệnh giúp cho việc thiết lập và triển khai dễ dàng hơn. Vì vậy hãy cùng xem làm thế nào để triển khai Microscope với Meteor Up.

Trước hết, chúng ta cần một server để push dữ liệu lên. Chúng tôi khuyến khích dùng Digital Ocean là dịch vụ có thể bắt đầu với chỉ $5 một tháng, hoặc AWS, dịch vụ có thể bắt đầu hoàn toàn miễn phí (bạn sẽ sớm gặp phải những vấn đề về việc mở rộng, nhưng đủ để bạn tìm hiểu sơ qua với Meteor Up).

Dù cho bạn chọn server nào, bạn sẽ đi đến ba thứ: địa chỉ IP cho server, tài khoản đăng nhập (thường là root hoặc ubuntu), và mật khẩu. Hãy giữ cho chúng được bảo mật, vì bạn sẽ cần dùng đến sớm thôi!

Khởi tạo Meteor Up

Để bắt đầu, chúng ta cần phải cài đặt Meteor Up qua npm như sau:

npm install -g mup

Chúng ta sẽ tạo một thư mục đặc biệt, tách rời mà sẽ chứa đựng thiết lập cho Meteor Up cho việc triển khai. Chúng ta dùng thư mục tách rời vì hai lý do: đầu tiên, sẽ tốt hơn nếu tránh dùng thông tin cá nhân vào một repo Git, đặc biệt trong trường hợp bạn làm việc với mã nguồn công cộng.

Tiếp theo, bằng việc dùng thư mục tách rời, chúng ta có thể đồng thời quản lý thiết lập của nhiều Meteor Up. Điều này sẽ thuận tiện cho cả việc triển khai môi trường phát triển và staging.

Hãy tạo một thư mục và dùng nó để khởi tạo dự án Meteor Up:

mkdir ~/microscope-deploy
cd ~/microscope-deploy
mup init

Chia sẻ qua Dropbox

Cách tốt nhất để chắc chắn rằng bạn và nhóm của bạn cùng dùng chung thiết lập cho việc triển khai là tạo thư mục thiết lập Meteor Up bên trong Dropbox hoặc một dịch vụ tương tự.

Thiết lập cho Meteor Up

Khi khởi tạo một dự án mới, Meteor Up sẽ tạo hai file cho bạn: mup.jsonsettings.json.

mup.json sẽ thiết lập liên quan đến việc triển khai, trong khi settings.json sẽ chứa toàn bộ thiết lập liên quan đến ứng dụng (token OAuth, token phân tích, vân vân…).

Bước tiếp theo là thiết lập file mup.json. Sau đây là file mup.json được tạo mặc định bởi mup init, và tất cả việc bạn phải làm chỉ là điền vào chỗ trống:

{
  //server authentication info
  "servers": [{
    "host": "hostname",
    "username": "root",
    "password": "password"
    //or pem file (ssh based authentication)
    //"pem": "~/.ssh/id_rsa"
  }],

  //install MongoDB in the server
  "setupMongo": true,

  //location of app (local directory)
  "app": "/path/to/the/app",

  //configure environmental
  "env": {
    "ROOT_URL": "http://supersite.com"
  }
}
mup.json

Hãy xem xét từng bước thiết lập ở trên.

Authentication Server

Bạn sẽ nhận thấy rằng Meteor Up hỗ trợ xác thực dựa trên mật khẩu và xác thực dựa trên khoá mật (PEM), vì vậy nó có thể được sử dụng với hầu hết các nhà cung cấp điện toán đám mây.

Lưu ý quan trọng: nếu bạn chọn để sử dụng xác thực dựa trên mật khẩu, trước hết hãy chắc chắn rằng bạn đã cài đặt sshpass (tham khảo hướng dẫn này).

Cấu hình MongoDB

Bước tiếp theo là cấu hình cơ sở dữ liệu MongoDB cho ứng dụng của bạn. Chúng tôi khuyên bạn nên sử dụng Compose hoặc bất kỳ nhà cung cấp dịch vụ đám mây MongoDB nào khác, vì họ cung cấp hỗ trợ chuyên nghiệp và các công cụ quản lý tốt hơn.

Nếu bạn đã quyết định sử dụng Compose, thiết lập setupMongo thành false và thêm biến môi trường MONGO_URL trong khối env của file mup.json. Nếu bạn quyết định host MongoDB với Meteor Up, chỉ cần thiết lập setupMongo thành true và Meteor Up sẽ lo phần còn lại.

Đường dẫn ứng dụng Meteor

Vì cấu hình Meteor Up của chúng ta nằm trong một thư mục khác, chúng ta sẽ cần phải trỏ Meteor Up trở lại ứng dụng của chúng ta bằng việc sử dụng thuộc tính app. Chỉ cần nhập vào đường dẫn local đầy đủ, mà bạn có thể nhận được bằng cách sử dụng lệnh pwd từ terminal khi đã nằm bên trong thư mục của ứng dụng.

Biến môi trường

Bạn có thể xác định tất cả các biến môi trường của ứng dụng (chẳng hạn như là ROOT_URL,MAIL_URL, MONGO_URL, vân vân) bên trong khối env.

Thiết lập và Triển khai

Trước khi chúng ta có thể triển khai, chúng ta sẽ cần phải thiết lập máy chủ để sẵn sàng host ứng dụng Meteor. Sự kỳ diệu của Meteor Up ở chỗ nó có thể gói gọn quá trình phức tạp này trong một lệnh duy nhất!

mup setup

Lệnh này sẽ mất một vài phút tùy thuộc vào hiệu suất của máy chủ và kết nối mạng. Sau khi quá trình cài đặt thành công, cuối cùng chúng ta sẽ có thể triển khai ứng dụng của mình với:

mup deploy

Lệnh này sẽ bó các ứng dụng Meteor, và triển khai đến máy chủ mà chúng ta vừa mới thiết lập.

Hiển thị log

Viết log là một việc khá quan trọng và Meteor Up cung cấp một cách rất dễ dàng để xử lý chúng bằng cách dùng lệnh tail -f. Chỉ cần gõ:

mup logs -f

Lệnh này kết thúc giới thiệu tổng quan cho chúng ta về những gì Meteor Up có thể làm. Để biết thêm thông tin, chúng tôi khuyên bạn nên truy Meteor Up’s GitHub repository.

Ba cách để triển khai các ứng dụng Meteor nên là đủ cho hầu hết các trường hợp sử dụng. Tất nhiên, chúng tôi biết một số độc giả sẽ thích được kiểm soát hoàn toàn và thiết lập máy chủ Meteor từ con số 0. Nhưng đó là một chủ đề cho một ngày khác… hoặc có thể là trong một cuốn sách khác!

Templates

3

Để dễ dàng vào phát triển Meteor, chúng ta sẽ áp dụng một phương pháp tiếp cận từ ngoài vào trong. Nói cách khác, chúng ta sẽ xây dựng một HTML / JavaScript “dump” bao bọc bên ngoài, và sau đó gắn nó vào bên trong ứng dụng của chúng ta.

Điều này có nghĩa là trong chương này chúng ta sẽ chỉ để quan tâm đến những gì đang xảy ra bên trong thư mục /client.

Nếu bạn vẫn chưa tạo, hãy bắt đầu bằng việc tạo ra một file tên là main.html bên trong thư mục /client và thêm vào đoạn code sau:

<head>
  <title>Microscope</title>
</head>
<body>
  <div class="container">
    <header class="navbar navbar-default" role="navigation"> 
      <div class="navbar-header">
        <a class="navbar-brand" href="/">Microscope</a>
      </div>
    </header>
    <div id="main" class="row-fluid">
      {{> postsList}}
    </div>
  </div>
</body>
client/main.html

Đây sẽ là template chính cho ứng dụng của chúng ta. Như bạn có thể nhìn thấy đoạn code hầu như là HTML ngoại trừ tag {{> postsList}} mục đích để thêm template vào, nó chèn vào template postsList tới ngay sau đây. Bây giờ, hãy cùng tạo ra một vài template nữa.

Meteor Templates

Về mặt cốt lõi, một trang tin tức mạng xã hội được kết cấu từ những bài viết sắp xếp thành danh sách, và đó cũng sẽ là điều mà chúng ta sẽ làm với các templete.

Hãy tạo một thư mục / templates bên trong / client. Đây sẽ là nơi chúng ta đặt tất cả template vào, và để giữ cho mọi thứ gọn gàng, chúng tôi cũng sẽ tạo ra thư mục / posts bên trong/ templates để lưu trữ những template liên quan đến việc post bài viết.

Tìm kiếm file

Meteor rất giỏi trong việc tìm kiếm file. Bất kể bạn đặt code ở đâu trong thư mục /client, Meteor sẽ tìm và biên dịch nó theo đúng chuẩn. Điều này có nghĩa là, bạn sẽ không cần phải viết đoạn mã để thêm vào đường dẫn cho các file JavaScript hay là CSS.

Điều này cũng có nghĩa là bạn có thể đặt tất cả file trong cùng một thư mục, hoặc thậm trí tất cả code trong cùng một file. Nhưng vì Meteor sẽ biên dịch mọi thứ thành một file đơn nhất đã được làm nhỏ (minify), chúng ta nên giữ cho mọi thứ được cấu trúc và sẽ dùng cấu trúc file sạch sẽ hơn.

Cuối cùng thì chúng ta đã sẵn sàng để tạo ra template thứ hai. Bên trong thư mục client / templates / posts, tạo ra file posts_list.html với nội dung như sau:

<template name="postsList">
  <div class="posts">
    {{#each posts}}
      {{> postItem}}
    {{/each}}
  </div>
</template>
client/templates/posts/posts_list.html

post_item.htmlvới nội dung như sau:

<template name="postItem">
  <div class="post">
    <div class="post-content">
      <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
    </div>
  </div>
</template>
client/templates/posts/post_item.html

Lưu ý đến thuộc tính name =" postsList " của phần tử template. Đây là tên sẽ được sử dụng bởi Meteor để biết được template nào đang ở đâu (chú ý răng tên thực của file không liên quan).

Bây giờ, hãy cùng xem xét tới hệ thống Template của Meteor, là Spacebars. Spacebars đơn giản chỉ là HTML, với ba thứ được thêm vào: inclusions (cũng được biết đến như là “partials”), expressionsblock helpers.

Inclusions sử dụng cú pháp {{> templateName}} , công việc của nó đơn giản chỉ là bảo Meteor thay thế đoạn mã với template tương ứng (trong trường hợp của chúng ta là postItem).

Biểu thức , ví dụ như là {{title}} hoặc là gọi đến một thuộc tính của object hiện tại, hoặc là trả về giá trị của một template helper đã được định nghĩa trong trình quản lý template (sẽ tìm hiểu sau).

Cuối cùng, block helper là những tag đặc biệt điều khiển luồng của template, ví dụ như {{#each}}…{{/each}} hoặc {{#if}}…{{/if}}.

Tìm hiểu hơn nữa

Bạn có thể tham khảo tài liệu Spacebars nếu muốn tìm hiểu thêm về Spacebars.

Gắn kết những kiến thức kể trên, chúng ta có thể bắt đầu hiểu những gì đang xảy ra ở đây.

Đầu tiên, trong template postsList, chúng ta lặp qua object post với block helper {{#each}}…{{/each}}. Sau đó, với mỗi thành phần, chúng ta thêm vào template postItem.

Vậy template postItem này tới từ đâu? Đây là một câu hỏi hay. Nó thực ra là một template helper, và bạn có thể suy nghĩ nó như là một chỗ giữ cho giá trị thay đổi.

Bản thân template postItem khá là dễ hiểu. Nó chỉ dùng đúng ba biểu thức: {{url}}{{title}} đều trả về thuộc tính của tài liệu, và {{domain}}gọi tới tempalte helper.

Template Helpers

Cho đến bây giờ, chúng ta đã làm việc với Spacebars, thứ thực ra là HTML nhưng với một vài tag thêm vào. Không giống như ngôn ngữ khác, như PHP chẳng hạn (hoặc như HTML thuần, có thể chứa JavaScript), Meteor giữ template và phần logic độc lập, và những template này bản thân nó thực tế không làm nhiều việc.

Theo trình tự, một template cần có helper. Bạn có thể nghĩ helper như là những người đầu bếp lấy nguyên liệu thô (dữ liệu của bạn) và chuẩn bị chúng, trước khi truyền ra đĩa (template) tới người phục vụ, là người sẽ đưa ra cho bạn.

Nói cách khác, trong khi mục đích của template giới hạn trong việc hiển thị hoặc tạo vòng lặp trên các biến, helper là người thực sự làm công việc phức tạp gán giá trị cho các biến.

Controllers?

Có thể bạn sẽ nghĩ theo hướng file chứa helper cho một template là một controller. Nhưng điều đó có thể không thực sự chính xác, vì controller (trong mô hình MVC) thường làm công việc khác một chút.

Vì vậy mà chúng tôi quyết định tránh việc dùng thuật ngữ đó, và đơn giản gọi là “template helper” hoặc “logic cho template” khi nói về code JavaScript cho một template.

Để mọi thứ được đơn giản, chúng ta sẽ dùng giữ luật đặt tên file chứa helper của một template giống tên của template, nhưng với đuôi là .js. Vậy hãy cùng tạo ra file posts_list.js bên trong thư mục /client/templates/posts và bắt đầu viết helper đầu tiên:

var postsData = [
  {
    title: 'Introducing Telescope',
    url: 'http://sachagreif.com/introducing-telescope/'
  }, 
  {
    title: 'Meteor',
    url: 'http://meteor.com'
  }, 
  {
    title: 'The Meteor Book',
    url: 'http://themeteorbook.com'
  }
];
Template.postsList.helpers({
  posts: postsData
});
client/templates/posts/posts_list.js

Nếu bạn thực hành đúng, bạn sẽ thấy màn hình như sau trong trình duyệt:

Our first templates with static data
Our first templates with static data

Chúng tôi đang làm hai việc ở đây. Đầu tiên, chúng tôi đang thiết lập một số dữ liệu mẫu dummy trong mảng postsData. Dữ liệu mà thông thường sẽ đến từ các cơ sở dữ liệu, nhưng kể từ khi chúng tôi đã không nhìn thấy như thế nào để làm điều đó chưa (chờ cho chương tiếp theo!), Chúng tôi đang “gian lận” bằng cách sử dụng dữ liệu tĩnh.

Thứ hai, chúng tôi đang sử dụng Template.postsList.helpers Meteor () chức năng để tạo ra một helper mẫu gọi là posts trả vềpostsData mảng chúng ta vừa định nghĩa ở trên.

Nếu bạn còn nhớ, chúng ta đang sử dụng là posts helper trong template postsList:

<template name="postsList">
  <div class="posts">
    {{#each posts}}
      {{> postItem}}
    {{/each}}
  </div>
</template>
client/templates/posts/posts_list.html

Với việc định nghĩa helper posts, điều đó có nghĩa là chúng ta có thể sử dụng cho template, do đó template của chúng ta sẽ có thể lặp qua mảng postData và đưa vào mỗi object tương ứng template postItem.

Commit 3-1

Added basic posts list template and static data.

The domain Helper

Tương tự như vậy, chúng ta sẽ tạo post_item.js để giữ logic của template postItem:

Template.postItem.helpers({
  domain: function() {
    var a = document.createElement('a');
    a.href = this.url;
    return a.hostname;
  }
});
client/templates/posts/post_item.js

Lần này giá trị domain helper của chúng tôi không phải là một mảng, nhưng một chức năng vô danh. Mô hình này là phổ biến hơn nhiều (và hữu dụng hơn) so với trước đây được đơn giản hóa ví dụ dữ liệu giả của chúng tôi.

Displaying domains for each links.
Displaying domains for each links.

Helper domain nhận một URL và trả về domain thông qua JavaScript. Nhưng ngay từ ban đầu, từ đâu nó có thể nhận url?

Để trả lời câu hỏi này, chúng ta cần phải quay lại template posts_list.html. Block helper {{#each}} không chỉ lặp lại trên mảng, mà nó còn thiết lập giá trị của this bên trong block vào object lặp tương ứng.

Điều này có nghĩa là giữa hai tag {{#each}}, mỗi bài viết được gán vàothis lần lượt, và điều đó mở rộng tất cả mọi thứ bên trong trình quản lý template (post_item.js).

Bây giờ chúng ta đã hiểu tại sao this.url trả về URL của bài viết hiện tại. Hơn thế nữa, nếu chúng ta dùng {{title}}{{url}}bên trong template post_item, Meteor biết được rằng chúng ta muốn chỉ this.titlethis.url và trả về giá trị đúng đắn.

Commit 3-2

Setup a `domain` helper on the `postItem`.

JavaScript Magic

Mặc dù điều này không phải chỉ riêng có ở Meteor, đây là một giải thích ngắn gọn về đoạn code JavaScript ở trên. Đầu tiên, chúng ta tạo ra một thành phần HTML (a) rỗng, và lưu trữ trong bộ nhớ.

Sau đó, chúng ta thiết lập href bằng với URl của bài viết hiện tại (như chúng ta đã thấy trước đó, nó là một helper this trong object đang được trỏ tới).

Cuối cùng, chúng ta sử dụng thuộc tính hostname của thành phần a để quay trở lại đường dẫn domain mà không cần toàn bộ URL

Nếu bạn đã theo dõi xuyên suốt hướng dẫn, bạn sẽ thấy được một danh sách bài viết trong trình duyệt. Danh sách đó đơn giản chỉ là dữ liệu tĩnh, vì vậy mà nó không sử dụng tính năng real-time của Meteor. Và chúng ta sẽ biết cách làm điều này trong chương kế tiếp!

Hot Code Reload

Bạn có thể thấy rằng bạn thậm chí không cần phải tự tải lại cửa sổ trình duyệt của bạn bất cứ khi nào bạn thay đổi một tập tin.

Điều này là bởi vì Meteor theo dõi tất cả các file trong thư mục dự án của bạn, và tự động làm mới trình duyệt của bạn mỗi khi nó phát hiện ra sự thay đổi nào đó.

Hot Code Reload của Meteor khá là thông minh, nó thậm trí còn cung cấp trang thái của ứng dụng giữa mỗi lần làm mới

Sử dụng Git & GitHub

Sidebar 3.5

GitHub là một kho lưu trữ dữ liệu cho các dự án mã nguồn mở dựa trên hệ thống quản lý version Git, và chức năng chính của nó là làm cho việc chia sẻ mã nguồn và cộng tác trên các dự án được dễ dàng hơn. Nhưng nó cũng là một công cụ học tập tuyệt vời. Trong chương sidebar này, chúng ta sẽ lướt qua một vài cách sử dụng GitHub giúp bạn đi suốt cuốn sách Khám phá Meteor.

Chương Sidebar này giả định bạn không quen với Git và GitHub. Nếu bạn đã quen thuộc với cả hai, bạn có thể bỏ qua chương này để đi tiếp!

Commit

Khối làm việc cơ bản của một kho git là commit. Bạn có thể tưởng tượng commit như là một bản chụp lại trạng thái của mã nguồn tại một thời điểm.

Thay vì chỉ đơn giản là cung cấp cho bạn mã thành phẩm cho Microscope, chúng tôi đã thực hiện những bản chụp tại mỗi bước cho bạn, và bạn có thể xem tất cả chúng trực tuyến trên GitHub.

Ví dụ, đây là trạng thái của commit cuối cùng trong chương trước:

A Git commit as shown on GitHub.
A Git commit as shown on GitHub.

Thứ mà bạn đang nhìn thấy là “diff” (viết tắt cho “difference” - thay đổi) của file post_item.js, hay nói cách khác là thay đổi được tạo bởi commit hiện tại. Trong trường hợp này, chúng ta đã tạo file post_item.js từ trạng thái trống, vì vậy tất cả nội dung của nó được đánh dấu với màu xanh.

Hãy so sánh với một ví dụ từ commit sau này trong cuốn sách:

Modifying code.
Modifying code.

Lần này, chỉ có các dòng đã sửa đổi được đánh dấu màu xanh.

Và tất nhiên, đôi khi bạn không thêm hoặc thay đổi dòng mã, mà xóa chúng:

Deleting code.
Deleting code.

Chúng ta đã được thấy tác dụng của GitHub lần đầu tiên: thấy được sơ lược những thay đổi về code.

Truy vấn code của một commit

Màn hình hiển thị commit của Git cho chúng ta thấy những thay đổi trong commit đó, nhưng đôi khi chúng ta cũng muốn biết được những file đã không thay đổi, để chắc chắn rằng mọi đoạn code làm đúng chức năng của nó trong bước hiện tại.

GitHub cũng có thể giúp chúng ta làm việc này. Khi bạn đang ở trên một trang commit, bấm vào button Browse code:

The Browse code button.
The Browse code button.

Bây giờ bạn sẽ có quyền truy cập vào repo với trạng thái của một commit cụ thể:

The repository at commit 3-2.
The repository at commit 3-2.

GitHub không cung cấp cho chúng ta nhiều gợi ý về commit mà chúng ta đang theo dõi, nhưng bạn có thể so sánh với hiển thị của nhánh master “bình thường” để thấy được cấu trúc file có sự thay đổi:

The repository at commit 14-2.
The repository at commit 14-2.

Truy cập local một commit

Chúng ta vừa thấy làm thế nào để truy cập toàn bộ code của một commit trực tuyến trên GitHub. Nhưng sẽ như thế nào nếu bạn cũng muốn làm điều tương tự ở local? Ví dụ, bạn muốn chạy ứng dụng local tại một commit cụ thể nào đó để thấy được hoạt động của nó tại thời điểm đó.

Để làm được điều này, chúng ta sẽ bắt đầu thực hành lần đầu (trong cuốn sách này) với tiện ích dòng lệnh git. Cho người mới bắt đầu, chắc chắn rằng bạn đã cài đặt Git. Sau đó clone (hay nói cách khác, tải một bản copy về local) repository Microscope với:

git clone git@github.com:DiscoverMeteor/Microscope.git github_microscope

github_microscope ở cuối câu lệnh đơn giản chỉ là tên của thư mục local mà bạn muốn clone ứng dụng vào. Giả định rằng bạn đã có một thư mục microscope tồn tại, hãy chọn một tên thư mục khác (không nhất thiết phải trùng tên với repo GitHub).

Hãy cd vào repository để chúng ta có thể bắt đầu sử dụng tiện ích dòng lệnh git:

cd github_microscope

Bây giờ khi mà chúng ta đã clone repository từ GitHub, chúng ta đã thực sự tải toàn bộ code của ứng dụng, nghĩa là chúng ta đang có code ở commit cuối cùng.

May mắn là, có một cách để quay ngược trở lại và “check out” (kiểm tra lại) một commit cụ thể nào đó mà không ảnh hưởng tới các phần khác. Hãy thử lệnh sau:

git checkout chapter3-1
Note: checking out 'chapter3-1'.

You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by performing another checkout.

If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -b with the checkout command again. Example:

  git checkout -b new_branch_name

HEAD is now at a004b56... Added basic posts list template and static data.

Git thông báo cho chúng ta biết rằng chúng ta đang ở trạng thái “detached HEAD”, nghĩa là theo Git, chúng ta có thể quan sát commit trong quá khứ nhưng không thể thay đổi chúng. Bạn có thể suy nghĩ nó giống như là một chiếc gậy phù thuỷ điều tra quá khứ thông qua quả cầu chiêm tinh.

(Lưu ý rằng Git cũng có các lệnh cho phép bạn thay đổi commit trong quá khứ. Điều này cũng giống như là cỗ máy vượt thời gian trở về một thời điểm trong quá khứ và chạm vào một con bướn, nhưng nó nằm ngoài phạm vi của phần giới thiệu này).

Lý do bạn có thể đơn giản gõ chapter3-1 là do chúng tôi đã tạo nhãn trước đó cho tất cả commit của Microscope với ký hiệu của từng chương. Nếu không phải vậy, bạn đã cần phải tìm ra hash của commit hoặc một định danh đơn trị nào đó.

Một lần nữa, GitHub giúp chúng ta làm việc dễ dàng hơn. Bạn có thể tìm ra hash của commit tại góc dưới bên phải của phần xanh da trời trên ô chứa phần đầu commit, giống như hình sau:

Finding a commit hash.
Finding a commit hash.

Hãy cùng thử với hash thay vì dùng nhãn như trước:

git checkout c7af59e425cd4e17c20cf99e51c8cd78f82c9932
Previous HEAD position was a004b56... Added basic posts list template and static data.
HEAD is now at c7af59e... Augmented the postsList route to take a limit

Cuối cùng, làm thế nào để chúng ta thoát khỏi việc nhìn vào quả cầu ma thuật và quay trở lại hiện tại? Chúng ta sẽ bảo Git rằng chúng ta muốn điều chỉnh về nhánh master:

git checkout master

Lưu ý rằng bạn cũng có thể chạy lệnh meteor tại bất kỳ thời điểm nào, ngay cả khi trong trạng thái “detached HEAD”. Có thể bạn sẽ phải chạy lệnh meteor update trước nếu như Meteor phàn nàn về việc thiếu package, vì package không nằm trong Git repo của Microscope.

Viễn cảnh lịch sử

Sau đây là một kịch bản phổ biến khác: bạn nhìn vào một file và thấy rằng đã có một vài thay đổi so với trước đó. Có điều là, bạn không nhớ ra khi nào file đó đã thay đổi. Bạn có thể tìm kiếm từng commit một cho tới khi bạn thấy commit đúng, tuy nhiên cũng có cách dễ dàng hơn bằng việc dùng tính năng History của GitHub.

Đầu tiên, truy cập vào file của bạn trên GitHub, sau đó định vị button “History”:

GitHub's History button.
GitHub’s History button.

Bây giờ bạn đã có được danh sách những commit mà ảnh hưởng tới file đó:

Displaying a file's history.
Displaying a file’s history.

Trò chơi đổ lỗi

Để kết thúc, hãy cùng nhìn vào button Blame:

GitHub's Blame button.
GitHub’s Blame button.

Hiển thị gọn gàng này giúp chúng ta thấy được ai đã thay đổi file, và với commit nào (hay nói cách khác, ai để đổ lỗi khi mà mọi thứ không còn hoạt động như ý):

GitHub's Blame view.
GitHub’s Blame view.

Bây giờ Git - và cả GitHub - đã trở thành một công cụ khá phức tạp, do vậy chúng tôi không hi vọng có thể giải thích tất cả mọi thứ trong một chương đơn lẻ. Thực tế, chúng ta đã chỉ lướt ngắn gọn qua bề nổi của những công cụ này. Nhưng hi vọng, một chút kiến thức này sẽ giúp bạn theo dõi suốt cuốn sách này.

Collections

4

Trong chương một, chúng ta đã nói về tính năng lõi của Meteor, đó là sự đồng bộ tự động giữa client và server.

Trong chương này, chúng ta sẽ tìm hiểu sâu hơn về cơ chế hoạt động của nó, cũng như sẽ theo dõi sự vận hành của kỹ thuật chủ đạo giúp làm được điều đó, chính là Collection của Meteor.

Collection là một dạng cấu trúc dữ liệu đặc biệt. Nó giúp chúng ta quản lý việc lưu trữ dữ liệu thường trực, chính là cơ sở dữ liệu MongoDB ở phía server, sau đó đồng bộ nó với từng kết nối từ trình duyệt web của người dùng trong thời gian thực.

Chúng ta muốn dữ liệu bài viết được thường trực và chia sẻ giữa mọi người dùng, vì vậy chúng ta sẽ bắt đầu bằng việc tạo một collection gọi là Posts để lưu trữ.

Collections đóng vai trò khá là chủ đạo trong mọi ứng dụng, do đó để chắc chắn rằng chúng được định nghĩa trước tiên, chúng ta sẽ đặt bên trong thư mục lib. Nếu bạn chưa hoàn thành việc này, hãy tạo thư mục collections/ bên trong lib, và sau đó tạo một file tên là posts.js bên trong thư mục đó. Khi hoàn thành, hãy thêm vào:

Posts = new Mongo.Collection('posts');
lib/collections/posts.js

Commit 4-1

Added a posts collection

Dùng Var hay là không?

Với Meteor, từ khoá var hạn chế phạm vi của một đối tượng trong file hiện hành. Hiện tại, chúng ta muốn Collection Posts khả dụng với toàn bộ ứng dụng, bởi vậy mà chúng ta sẽ không dùng từ khoá var.

Lưu dữ liệu

Ứng dụng web có 3 cách cơ bản để lưu dữ liệu đối lập nhau, mỗi cách hoàn thiện được một nhiệm vụ riêng biệt:

  • Bởi bộ nhớ của trình duyệt: những thứ như là biến số JavaScript được lưu tại bộ nhớ của trình duyệt, điều đó có nghĩa là chúng không lâu dài: chúng chỉ bố cục với tab hiện tại của trình duyệt, và sẽ biến mất ngay khi bạn tắt tab trình duyệt.
  • Bởi bộ lưu trữ của trình duyệt: trình duyệt cũng có thể lưu trữ lâu dài hơn nhờ vào cookie hoặc là bộ lưu trữ cục bộ. Mặc dù dữ liệu này vẫn tồn tại giữa các phiên làm việc (session), nó vẫn cục bộ đối với người dùng hiện tại (tuy nhiên khả dụng giữa các tab của trình duyệt) và không thể dễ dàng chia sẻ với người dùng khác.
  • Cơ sở dữ liệu phía server: nơi tốt nhất lưu trữ dữ liệu lâu dài, mà bạn muốn khả dụng cho nhiều hơn một người dùng đó là cơ sở dữ liệu. (MongoDB là giải pháp mặc định cho ứng dụng Meteor).

Meteor sử dụng cả ba cách trên, và đôi khi sẽ đồng bộ dữ liệu từ chỗ này sang chỗ khác (như chúng ta sẽ thấy sau đây). Như đang được nói đến, cơ sở dữ liệu giữ nguyên là “kiểu mẫu“ lưu trữ dữ liệu nguồn, bao gồm bản sao chép gốc (master copy) dữ liệu của bạn.

Client & Server

Code bên trong thư mục mà không phải là client/ hoặc server/ sẽ chạy giữa cả hai ngữ cảnh. Vì vậy Collection Posts khả dụng đối với cả phía client và server. Tuy nhiên, cách mà collection làm việc với mỗi môi trường có thể khác nhau đôi chút.

Trên phía server, collection có nhiệm vụ nói chuyện với cơ sở dữ liệu MongoDB, đọc và viết những thay đổi. Theo cách này, nó có thể được so sánh với một thư viện cơ sở dữ liệu chuẩn.

Còn ở phía client, collection là bản sao chép tập hợp con của collection kiểu mẫu thực. Collection ở phía client giữ liên lạc trong thời gian thực một cách thường xuyên và (hầu như) rõ rệt với bộ phận dữ liệu đó.

Console vs Console vs Console

Trong chương này, chúng ta sẽ bắt đầu sử dụng chức năng console trình duyệt, thứ không nên bị nhầm lẫn với terminal hoặc là Mongo shell. Sau đây là tóm tắt nhanh về mỗi thứ.

Terminal

The Terminal
The Terminal
  • Được gọi ra từ hệ điều hành máy tính của bạn.
  • console.log() từ Phía server xuất dữ liệu ra đây.
  • Bắt đầu bởi ký tự: $
  • Được biết đến như là: Shell, Bash

Console của trình duyệt

The Browser Console
The Browser Console
  • Gọi từ phía trình duyệt, chạy mã code JavaScript.
  • console.log() phía client xuất dữ liệu tại đây.
  • Bắt đầu bởi ký tự: .
  • Còn được biết đến như là: JavaScript Console, DevTools Console

Mongo Shell

The Mongo Shell
The Mongo Shell
  • Được gọi từ Terminal bởi lệnh meteor mongo.
  • Giúp bạn giao tiếp trực tiếp với cơ sở dữ liệu của ứng dụng.
  • Bắt đầu bởi ký tự: >.
  • Còn được biết đến như là: Mongo Console

Lưu ý rằng trong mỗi trường hợp, bạn không cần thiết phải gõ ký tự bắt đầu ($, , or >) như một phần của câu lệnh. Và bạn cũng có thể giả định là những dòng không bắt đầu với ký tự bắt đầu đó (prompt) là dòng xuất dữ liệu của câu lệnh được nhập trước đó.

Collections phía server

Quay trở lại với server, collection hoạt động như là API với cơ sở dữ liệu Mongo. Trong mã code phía server, nó cho phép bạn viết lệnh Mongo giống như Posts.insert() hoặc, Posts.update(), chúng sẽ tác động thay đổi tới collection posts được lưu trong Mongo.

Để xem bên trong cơ sở dữ liệu Mongo, mở một cửa sổ terminal thứ hai (trong khi meteor vẫn đang được chạy ở cửa sổ thứ nhất), sau đó đi tới đường dẫn của ứng dụng. Chạy lệnh meteor mongo để khởi tạo Mongo shell, với nó bạn có thể gõ lệnh Mongo chuẩn (và như mọi khi, bạn có thể thoát khỏi shell với phím tắt ctrl+c). Ví dụ, hãy thêm một bài viết:

meteor mongo

> db.posts.insert({title: "A new post"});

> db.posts.find();
{ "_id": ObjectId(".."), "title" : "A new post"};
The Mongo Shell

Mongo tại Meteor.com

Chú ý rằng khi mà đã đặt host ứng dụng của bạn tại *.meteor.com, bạn có thể truy cập vào ứng dụng đó bằng Mongo shell với meteor mongo myApp.

Và khi đã truy cập vào trong đó, bạn có thể lấy logs của ứng dụng bằng lệnh meteor logs myApp.

Cú pháp của Mongo thân thuộc, vì nó cũng dùng giao tiếp JavaScript. Chúng ta sẽ không thao tác thêm gì bằng Mongo shell, nhưng thi thoảng bạn cũng có thể nhìn vào nó để xem điều gì đang diễn ra.

Collections phía client

Collections trở lên thú vị hơn ở client. Khi bạn định nghĩa Posts = new Mongo.Collection('posts'); ở phía client, điều bạn đang làm là tạo ra một collection Mongo cục bộ, trong cache của trình duyệt. Khi chúng ta bảo rằng collection đã được “cache” phía client, nghĩa là nó đã chứa tập hợp con của dữ liệu, và cho phép chúng tra truy cập nhanh dữ liệu đó.

Biết được những điều này rất quan trọng vì nó là những điểm cơ bản để nắm được cách thức Meteor hoạt động. Nói một cách chung nhất, collection phía client chứa tập hợp con của tất cả dữ liệu được lưu trong Mongo collection (quả thực, chúng ta thường không muốn gửi toàn bộ cơ sở dữ liệu về client).

Thêm nữa, những dữ liệu này được lưu trong bộ nhớ trình duyệt, có nghĩa là truy cập chúng nói chung ngay lập tức. Như vậy, sẽ không mất đường dài để tới server, lấy về dữ liệu mỗi khi gọi Posts.find() phía client, do dữ liệu đã được nạp sẵn.

Giới thiệu về MiniMongo

Mongo mà được cài đặt phía client của Meteor thì được gọi là MinoMongo. Nó chưa phải là được tạo một cách hoàn hảo, nên có những trường hợp mà tính năng của Mongo không hoạt động với MiniMongo. Cho dù như thế thì tất cả những tính năng trong cuốn sách này hoạt động giống nhau cả trên Mongo lẫn MiniMongo.

Giao tiếp Client-Server

Phần chủ chốt của giao tiếp này là làm sao collection phía client đồng bộ với dữ liệu collection có cùng tên ở phía server (trong trường hợp của chúng ta là 'posts')

Thay vì việc giải thích chi tiết, hãy xem nó xảy ra như thế nào.

Bắt đầu bằng việc mở hai cửa sổ trình duyệt, và truy nhập vào console của mỗi trình duyệt. Sau đó, mở Mongo shell bên command line.

Tại thời điểm này, chúng ta có thể thấy tài liệu đã được tạo trước đó trong cả ba ngữ cảnh (chú ý rằng giao diện người dùng của ứng dụng của chúng ta vẫn hiển thị ba bài viết dummy lúc trước. Tạm thời xin hãy bỏ qua chúng).

> db.posts.find();
{title: "A new post", _id: ObjectId("..")};
The Mongo Shell
 Posts.findOne();
{title: "A new post", _id: LocalCollection._ObjectID};
First browser console

Hãy tạo một bài viết mới. Trong một cửa sổ trình duyệt, chạy đoạn mã chèn vào như sau:

 Posts.find().count();
1
 Posts.insert({title: "A second post"});
'xxx'
 Posts.find().count();
2
First browser console

Không có gì ngạc nhiên, bài viết được tạo ra trong collection cục bộ. Bây giờ, hãy kiểm tra Mongo:

❯ db.posts.find();
{title: "A new post", _id: ObjectId("..")};
{title: "A second post", _id: 'yyy'};
The Mongo Shell

Như các bạn có thể thấy, bài viết cũng được cho vào cơ sở dữ liệu Mongo, mà không cần chúng ta viết một dòng code nào bắt client gửi tới server (nói một cách chặt chẽ thì chúng ta đã viết một dòng code: new Mongo.Collection('posts')). Tuy nhiên đó không phải là tất cả.

Đưa ra cửa sổ trình duyệt thứ hai, và enter vào console trình duyệt như bên dưới:

 Posts.find().count();
2
Second browser console

Bài viết cũng ở đó! Cho dù chúng ta đã không làm mới (refresh) hoặc là tương tác với trình duyệt thứ hai, và chúng ta cũng đã không viết dòng code nào để yêu cầu việc cập nhật mới. Nó diễn ra như là có phép thuật - và cũng diễn ra ngay lập tức, mặc dù điều này sẽ trở thành hiển nhiên về sau.

Điều thực sự đã diễn ra là, collection phía server của chúng ta đã được thông báo bởi collection từ client về một bài viết mới, để tiến hành công việc truyển phát bài viết đó sang cơ sở dữ liệu Mongo và thao tác ngược cho tất cả collection đã kết nối với post.

Truy xuất bài viết từ console trình duyệt không thực sự hữu ích. Chúng ta sẽ sớm được học làm thế nào để gắn kết dữ liệu này vào template, cũng như dùng dữ liệu này để tiến hành chuyển những bản HTML nguyên bản thành một ứng dụng web đầy đủ chức năng vận hành theo thời gian thực.

Làm tăng (populate) cơ sở dữ liệu

Việc nhìn thấy dữ liệu Collection trên console trình duyệt là một việc, nhưng điều chúng ta thực sự muốn làm là hiển thị dữ liệu đó, cũng như sự thay đổi của dữ liệu, trên màn hình. Bằng việc đó, chúng ta sẽ chuyển được ứng dụng từ một trang đơn hiển thị dữ liệu tĩnh, sang một ứng dụng web thời gian thực với sự linh động, và dữ liệu thay đổi.

Việc đầu tiên chúng ta sẽ làm là đặt một vài dữ liệu vào cơ sở dữ liệu. Chúng ta sẽ làm việc đó với một file cố định, file đó sẽ nạp dữ liệu có cấu trúc vào Collection Posts khi server chạy lần đầu.

Đầu tiên, hãy chắc chắn rằng không có gì trong cơ sở dữ liệu. Chúng ta sẽ sử dụng meteor reset, để xoá cơ sở dữ liệu và khởi tạo lại dự án. Dĩ nhiên, bạn sẽ phải thật sự cẩn thận với câu lệnh này khi làm việc với một dự án thực tế.

Dừng server Meteor (bằng việc bấm ctrl-c) và sau đó, trên công cụ dòng lệnh, chạy:

meteor reset

Câu lệnh khởi tạo lại sẽ xoá toàn bộ cơ sở dữ liệu Mongo. Nó là một câu lệnh hữu ích trong quá trình phát triển dự án, những lúc mà cơ sở dữ liệu có khả năng lớn bị rơi vào trạng thái không ổn định.

Hãy khởi động lại ứng dụng Meteor của chúng ta lần nữa:

meteor

Bây giờ cơ sở dữ liệu đã trống không, chúng ta có thể thêm đoạn code sau. Nó sẽ tạo ra ba bài viết mỗi khi server khởi động, miễn sao khi đó Collection Posts đang trong trạng thái trống không.

if (Posts.find().count() === 0) {
  Posts.insert({
    title: 'Introducing Telescope',
    url: 'http://sachagreif.com/introducing-telescope/'
  });

  Posts.insert({
    title: 'Meteor',
    url: 'http://meteor.com'
  });

  Posts.insert({
    title: 'The Meteor Book',
    url: 'http://themeteorbook.com'
  });
}
server/fixtures.js

Commit 4-2

Added data to the posts collection.

Chúng ta vừa đặt file này trong thư mục server/, như vậy thì nó sẽ không bao giờ bị nạp phía trình duyệt người dùng. Đoạn code sẽ được chạy ngay khi server khởi động, và thực hiện gọi insert vào cơ sở dữ liệu để thêm ba bài việt vào Collection Posts.

Bây giờ hãy khởi động server thêm một lần nữa với câu lệnh meteor, và ba bài viết đó sẽ được nạp vào cơ sở dữ liệu.

Dữ liệu động

Nếu chúng ta mở một cửa sổ console trình duyệt, chúng ta sẽ thấy được rằng ba bài viết đã được nạp vào MiniMongo:

 Posts.find().fetch();
Browser console

Để có được những bài viết này trong file HTML được tạo, chúng ta sẽ sử dụng trợ giúp template thân thiện (template helper)

Trong chương 3 chúng ta đã thấy Meteor cho phép liên kết ngữ cảnh dữ liệu tới template Spacebar để xây dựng phần HTML view cho cấu trúc dữ liệu đơn giản.

Hãy tự do xoá postsData tại thời điểm đó. posts_list.js nên giống như sau:

Template.postsList.helpers({
  posts: function() {
    return Posts.find();
  }
});
client/templates/posts/posts_list.js

Commit 4-3

Wired collection into `postsList` template.

Tìm & Nạp

Trong Meteor, find() trả về một con trỏ, thứ là nguồn dữ liệu tác động trở lại. Khi chúng ta muốn log nội dung của nó, chúng ta có thể sử dụng fetch() trên con trỏ đó để chuyển nó thành một mảng dữ liệu.

Trong một ứng dụng, Meteor đủ thông minh để biết làm thế nào để tạo vòng lặp với con trỏ mà không bắt buộc phải chuyển đổi thành mảng trước. Chính vì vậy mà bạn sẽ không thấy fetch() được dùng nhiều trong code Meteor (và tại sao chúng ta đã không dùng ở ví dụ phía trên).

Thay vì việc lấy ra một danh sách bài viết như một mảng tĩnh từ biến, chúng ta trả về một con trỏ cho helper posts (mặc dù mọi thứ không có vẻ khác nhau mấy vì chúng ta vẫn dùng cùng dữ liệu):

Using live data
Using live data

Helper {{#each}} của chúng ta đã lặp qua tất cả Posts và hiển thị chúng trên màn hình. Collection phía server đẩy bài viết từ Mongo ra, gửi chúng tới collection phía client, và Spacebars trợ giúp gửi chúng tới template.

Bây giờ chúng ta sẽ tiến thêm một bước nữa; thêm bài viết bằng console:

 Posts.insert({
  title: 'Meteor Docs',
  author: 'Tom Coleman',
  url: 'http://docs.meteor.com'
});
Browser console

Nhìn lại trình duyệt – bạn sẽ thấy:

Adding posts via the console
Adding posts via the console

Bạn vừa mới thấy tương tác ngược diễn ra lần đầu. Khi chúng ta bảo Spacebars lặp lại con trỏ Posts.find(), nó biết làm thế nào để theo dõi sự thay đổi của con trỏ, và ráp lại HTML theo cách đơn giản nhất để sửa lại dữ liệu trên màn hình.

Giám sát sự thay đổi của DOM

Trong trường hợp này, sự thay đổi đơn giản nhất nhận thấy là một đoạn <div class="post">...</div> khác được thêm vào. Nếu bạn muốn biết thứ gì đã thực sự đã diễn ra, mở thanh giám sát DOM (DOM inspector) và chọn ra thẻ <div> tương ứng với một bài viết đang tồn tại.

Bây giờ, trong màn hình console JavaScript, thêm vào một bài viết mới. Khi bạn trở lại thanh giám sát, bạn sẽ thấy thêm một thẻ <div>, tương ứng với bài viết mới, tuy nhiên bạn vẫn thấy được là thẻ <div> trong trạng thái chọn. Điều này rất hữu ích vì nó cho biết thành phần (elements) đã được tạo lại hay là chúng đã được để không.

Kết nối Collections: Xuất bản và đặt theo dõi (Publications and Subscriptions)

Cho tới bây giờ, chúng ta đã cho phép gói (package) autopublish được kích hoạt, điều đó không phải là ý định tốt cho một ứng dụng sản phẩm. Như tên của nó nói lên, package nói rằng mỗi collection nên chia sẻ mọi phần tử của nó cho mỗi client được kết nối. Đó không phải là điều chúng ta thực sự muốn, vì vậy hãy tắt nó đi.

Mở cửa sổ terminal mới, và gõ:

meteor remove autopublish

Nó có hiệu ứng tức thời. Nếu bạn nhìn vào trình duyệt bây giờ, bạn sẽ thấy tất cả bài viết đã biến mất! Đó là vì chúng ta trước đó đã dựa vào autopublish để chắc chắn rằng collection bài viết phía client được phản chiếu tất cả bài viết trong cơ sở dữ liệu.

Rốt cuộc thì chúng ta chỉ cần chắc chắn rằng chúng ta chỉ gửi những bài viết mà người dùng thực sự cần (giống như trong trường hợp đánh số trang). Nhưng hiện tại, chúng ta sẽ thiết lập Posts để xuất bản phần tử của nó.

Để làm điều đó, chúng ta tạo một hàm publish() trả về con trỏ tham chiếu tất cả bài viết:

Meteor.publish('posts', function() {
  return Posts.find();
});
server/publications.js

Ở phía client, chúng ta phải đặt theo dõi (subscribe) tới tất cả những thứ xuất bản (publiction). Chúng ta chỉ phải thêm vào dòng sau vào main.js:

Meteor.subscribe('posts');
client/main.js

Commit 4-4

Removed `autopublish` and set up a basic publication.

Nếu chúng ta kiểm tra lại trình duyệt, lần này những bài viết đã quay trở lại. Phew!

Kết luận

Chúng ta đã đạt được điều gì? Mặc dù vẫn chưa có được giao diện người dùng, bây giờ chúng ta đã có được một ứng dụng web với chức năng hoạt động. Chúng ta có thể deploy ứng dụng này lên Internet, và (sử dụng console trình duyệt) để bắt đầu viết bài và nhìn thấy chúng xuất hiện trên trình duyệt của người dùng trên khắp thế giới.

Publications và Subscriptions

Sidebar 4.5

Publication và Subscription là một trong những khái niệm quan trọng và cơ bản của Meteor, tuy nhiên có thể khó cho việc ghi nhớ khi bạn mới chỉ bắt đầu.

Điều này có thể dẫn đến những hiểu nhầm, ví dụ như là Meteor không an toàn, hoặc là ứng dụng viết bởi Meteor không thể làm việc được với những hệ thống nhiều dữ liệu.

Một trong những lý do mà người ta thấy rằng những khái niệm này rối loạn lúc đầu chính bởi “phép thuật” mà Meteor làm cho chúng ta. Mặc dù thứ phép thuật này về cơ bản là rất hữu dụng, nó có thể không rõ ràng về thứ thực sự diễn ra sau màn hình (như những gì phép thuật thường làm). Bởi vậy, hãy cùng cởi bỏ các lớp vỏ của phép thuật này và cố gắng hiểu rõ điều gì đang diễn ra.

Những ngày trước đây

Nhưng đầu tiên, hãy cùng chúng tôi nhìn lại những ngày ban đầu của năm 2011 khi mà Meteor vẫn còn chưa ở đó. Giả sử rằng bạn đang xây dựng một ứng dụng đơn giản với Rails. Khi người dùng bước vào trang web của bạn, phía client (ví dụ như là trình duyệt của bạn) gửi yêu cầu tới ứng dụng, thứ đang chạy trên server.

Công việc đầu tiên của ứng dụng là tìm ra xem dữ liệu bạn muốn thấy. Nó có thể là trang thứ 12 của kết quả tìm kiếm, thông tin về người dùng tên là Mary, 20 đoạn tweet gần nhất của Bob, và hơn thế nữa. Bạn có thể tưởng tượng nó như là một người nhân viên cửa hàng bán sách duyệt những gian hàng để tìm ra cuốn mà bạn đã hỏi.

Một khi dữ liệu đúng đã được chọn, công việc tiếp theo của ứng dụng là dịch những dữ liệu đó thành dạng HTML đẹp, thân thiện với con người (hoặc JSON trong trường hợp là API).

Trong phép ẩn dụ về hiệu sách, nó chính là việc bọc lại cuốn sách bạn đã mua và cho vào một cái túi đẹp. Đây là phần “View” (hiển thị), một phần trong mô hình Model-View-Controller nổi tiếng.

Cuối cùng, ứng dụng sẽ lấy đoạn mã HTML đó và gửi đến cho trình duyệt. Công việc của ứng dụng vậy là xong, và mọi thứ đã nằm ngoài bàn tay mà server có thể kiểm soát được, nó giờ chỉ có thể nghỉ ngơi với một cốc bia chẳng hạn và chờ đợi yêu cầu tiếp theo.

Phương pháp của Meteor

Hãy cùng nhau nhìn lại điều gì đã khiến Meteor trở nên đặc biệt như vậy. Như chúng ta đã thấy, điều đổi mới ở Meteor chính là trong khi ứng dụng Rails chỉ chạy trên server, thì ứng dụng Meteor lại bao gồm cả thành phần ở phía client để chạy dưới client (trình duyệt web).

Pushing a subset of the database to the client.
Pushing a subset of the database to the client.

Điều đó cũng giống như là nhân viên hiệu sách đó không chỉ tìm đúng cuốn sách cho bạn, mà còn theo bạn về nhà để đọc nó vào buổi tối (điều này thực sự chúng ta sẽ thán phục mặc dù nghe có vẻ hơi kinh dị).

Cấu trúc này giúp cho Meteor có thể làm được nhiều thứ hay ho, một trong những thứ đó là cái mà Meteor gọi là cơ sở dữ liệu ở mọi nơi. Nói một cách đơn giản, Meteor lấy một tập con dữ liệu của bạn và sao chép nó tới client.

Điều này có hai hàm ý lớn: đầu tiên, thay vì gửi HTML code tới client, ứng dụng Meteor sẽ gửi dữ liệu thực, nguyên dạng và để cho client tự quyết định làm gì với nó dữ liệu mắc trên dây). Thứ hai, bạn sẽ có thể truy xuất và thay đổi dữ liệu đó tức thời mà không cần phải chờ một chuyến đi lại với server sự điều chỉnh ngầm).

Publishing (xuất bản)

Một cơ sở dữ liệu của ứng dụng có thể chứa tới hàng chục nghìn dữ liệu, một số trong chúng có thể là dữ liệu mật và nhạy cảm. Bởi vậy hiển nhiên là chúng ta không nên phản ánh toàn bộ dữ liệu cho client, vì lý do bảo mật và khả năng làm tăng quy mô ứng dụng.

Bởi vậy, chúng ta cần một thứ gì đó để cho Meteor biết là tập hợp con nào của dữ liệu có thể gửi tới client, và chúng ta hoàn thành điều này bằng cách dùng publication.

Hãy cùng quay lại với Microscope. Tại đây tất cả dữ liệu bài viết của chúng ta nằm trong cơ sở dữ liệu:

All the posts contained in our database.
All the posts contained in our database.

Mặc dù phải thú thực rằng tính năng đó không thực sự tồn tại trong Microscope, chúng ta hãy tưởng tượng rằng một vài bài viết được đánh dấu với ngôn ngữ lăng mạ. Mặc dù chúng ta muốn giữ chúng trong cơ sở dữ liệu, những bài viết này không nên được hiện hữu đối với người dùng (ví dụ như là không nên được gửi cho client).

Nhiệm vụ đầu tiên của chúng ta là nói cho Meteor biết, dữ liệu nào chúng ta thực sự muốn gửi cho client. Chúng ta sẽ bảo Meteor rằng chỉ dữ liệu không bị đánh dấu mới được xuất bản (publish):

Excluding flagged posts.
Excluding flagged posts.

Đây là đoạn code tương ứng, được đặt ở bên server:

// on the server
Meteor.publish('posts', function() {
  return Posts.find({flagged: false}); 
});

Điều này đảm bảo rằng client sẽ không có cách nào truy cập đến dữ liệu bị đánh dấu. Đây chính là cách làm cho ứng dụng Meteor bảo mật: chắc chắn rằng chỉ xuất bản dữ liệu mà bạn muốn người dùng hiện tại truy cập đến.

DDP

Một cách cơ bản, bạn có thể nghĩ publication/subscription như một hệ thống đường hầm chuyển dữ liệu từ collection phía server (nguồn) tới collection phía client (đích).

Giao thức được dùng cho đường hầm đó gọi là DDP (viết tắt của Distributed Data Protocol - giao thức dữ liệu phân tán). Để học thêm về DDP, bạn có thể theo dõi bài nói chuyện này từ hội nghị thời gian thực bởi Matt DeBergalis (một trong những người sáng lập của Meteor), hoặc screencast này bởi Chris Mather, nó sẽ dẫn suy nghĩ của bạn tới khái niệm này cụ thể hơn một chút.

Subscribing (đăng theo dõi)

Mặc dù chúng ta muốn mọi bài viết không bị đánh dấu hiện hữu với client, chúng ta không thể gửi đồng loạt cả nghìn bài viết một lúc. Chúng ta cần một cách để client đặc tả xem tập hợp con dữ liệu nào họ muốn tại một thời điểm nào đó, và đó chính là nơi mà subscriptions ra mắt.

Những dữ liệu mà bạn đăng theo dõi sẽ được phản ánh tới client bởi Minimongo, cái mà chúng ta biết đến là sự cài đặt cho MongoDB của Meteor ở phía client.

Ví dụ, giả sử chúng ta đang cần tra cứu trang thông tin cá nhân của Bob Smith, và chỉ muốn hiển thị bài viết của anh ta.

Subscribing to Bob's posts will mirror them on the client.
Subscribing to Bob’s posts will mirror them on the client.

Đầu tiên, chúng ta sẽ sửa chữa lại phần xuất bản để thêm vào tham số:

// on the server
Meteor.publish('posts', function(author) {
  return Posts.find({flagged: false, author: author});
});

Và sau đó chúng ta sẽ định nghĩa tham số đó khi chúng ta đăng theo dõi tới bản xuất bản đó trong ứng dụng từ code phía client:

// on the client
Meteor.subscribe('posts', 'bob-smith');

Đây là cách mà bạn làm cho một ứng dụng Meteor có thể mở rộng được ở phía client: thay vì đăng theo dõi tất cả dữ liệu khả dụng, chỉ cho những phần nào mà hiện tại đang cần đến. Bằng cách này, bạn sẽ tránh việc làm bộ nhớ trình duyệt bị quá tải mặc cho dữ liệu phía server của bạn có lớn tới mức nào.

Tìm kiếm

Hiện tại bài viết của Bob trải rộng ra nhiều mục (ví dụ như: “JavaScript”, ”Ruby”, and ”Python”). Mặc dù chúng ta có thể vẫn muốn nạp tất cả bài viết của Bob vào trong bộ nhớ, nhưng chúng ta chỉ muốn hiển thị những thứ trong mục “JavaScript” ngay tại thời điểm này. Đây là lúc “finding” (tìm kiếm) xuất hiện.

Selecting a subset of documents on the client.
Selecting a subset of documents on the client.

Giống như chúng ta đã làm với server, chúng ta sẽ sử dụng hàm Posts.find() để chọn ra những tập con dữ liệu:

// on the client
Template.posts.helpers({
    posts: function(){
        return Posts.find({author: 'bob-smith', category: 'JavaScript'});
    }
});

Giờ chúng ta đã hiểu rõ được nhiệm vụ của xuất bản và đăng theo dõi, chúng ta sẽ đào sâu hơn và duyệt lại một số khuôn mẫu cài đặt.

Autopublish

Nếu bạn tạo một dự án Meteor từ không có gì (ví dụ như bằng việc dùng meteor create), nó sẽ tự động dùng gói autopublish mặc định. Như một điểm bắt đầu, hãy cùng nhau xem xem nó thực sự làm gì.

Mục đích của autopublish là để giúp cho việc bắt đầu code với Meteor được dễ dàng, và nó làm điều đó bằng cách phảnh ánh tất cả dữ liệu từ server tới client, do đó thực thi công việc xuất bản và đăng theo dõi cho bạn.

Autopublish
Autopublish

Nó hoạt động như thế nào? Giả sử bạn có một collection gọi là 'posts' ở phía server. Sau đó autopublish sẽ tự động gửi mỗi bài viết nó tìm ra được trong collection posts của Mongo sang một collection gọi là 'posts' ở phía client (giả định là đã có).

Bởi vậy nếu bạn đang sử dụng autopublish, thì bạn không cần phải nghĩ về xuất bản (publication). Dữ liệu tồn tại khắp nơi, và mọi thứ rất đơn giản. Dĩ nhiên, có những vấn đề hiển nhiên với việc sao chép toàn bộ dữ liệu của database để cache trong mỗi máy người dùng.

Vì lý do này, autopublish chỉ phù hợp khi bạn mới bắt đầu, và chưa biết gì về publication.

Xuất bản toàn bộ Collections

Một khi đã xóa bỏ autopublish, bạn sẽ sớm nhận ra là dữ liệu đã bị loại bỏ khỏi client. Một cách đơn giản để mang nó trở lại là đơn giản sao chép lại những thứ mà autopublish đã làm, và xuất bản một collection trong thực thể của nó. Ví dụ:

Meteor.publish('allPosts', function(){
  return Posts.find();
});
Publishing a full collection
Publishing a full collection

Chúng ta vẫn đang xuất bản toàn bộ dữ liệu, nhưng dù sao thì chúng ta cũng quản lý được collection nào chúng ta muốn xuất bản hay là không. Trong trường hợp này, chúng ta đang xuất bản collection Posts chứ không phải là Comments.

Xuất bản một phần Collection

Cấp độ tiếp theo là xuất bản chỉ một phần của collection. Ví dụ như chúng ta chỉ muốn bài viết thuộc về một người dùng cụ thể:

Meteor.publish('somePosts', function(){
  return Posts.find({'author':'Tom'});
});
Publishing a partial collection
Publishing a partial collection

Phía sau màn hình

Nếu bạn đã đọc về (tài liệu publication Meteor), có lẽ bạn đã bị làm quá sức bằng việc nói về sử dụng added()ready() để thiết lập tính năng cho bản ghi phía client, và đã chiến đấu để tạo hình nó với ứng dụng Meteor mà bạn chưa từng nhìn thấy những method đó bao giờ.

Lý do là Meteor cung cấp một tiện ích rất quan trọng: method _publishCursor(). Bạn cũng chưa từng thấy nó bao giờ? Có lẽ không trực tiếp, nhưng nếu bạn trả về một con trỏ (ví dụ như Posts.find({'author':'Tom'})) trong hàm publish, nó chính là cái mà Meteor đang sử dụng.

Khi Meteor đã nhìn thấy publication của somePosts được trả về con trỏ, nó gọi tới _publishCursor() để – như bạn đã đoán – xuất bản con trỏ đó một cách tự động.

Đây là những gì _publishCursor() đã làm:

  • Kiểm tra collection phía server
  • Lấy toàn bộ dữ liệu phù hợp từ con trỏ và gửi tới collection có cùng tên ở phía client. (Dùng hàm .added() để làm điều đó).
  • Mỗi khi có một dữ liệu mới được thêm vào, xóa bỏ hoặc thay đổi, nó gửi những thay đổi này tới collection phía client. (dùng .observe() với con trỏ và .added(), .changed()removed() để làm điều này).

Như ví dụ ở trên, chúng ta có thể chắc chắn rằng người dùng chỉ có những bài viết mà họ quan tâm đến (bài viết được tạo bởi Tom) hiện hữu với họ trong cache của client.

Xuất bản một phần thuộc tính

Chúng ta vừa thấy làm thế nào để xuất bản một vài bài viết, nhưng chúng ta có thể cắt mỏng hơn nữa! Hãy cùng xem làm thế nào để chỉ publish một vài thuộc tính đặc biệt.

Như lúc trước, chúng ta dùng find() để trả về một con trỏ, nhưng lần này chúng ta thêm vào những trường cụ thể:

Meteor.publish('allPosts', function(){
  return Posts.find({}, {fields: {
    date: false
  }});
});
Publishing partial properties
Publishing partial properties

Dĩ nhiên, chúng ta có thể kết nối cả hai kỹ thuật. Ví dụ, nếu bạn muốn trả về tất cả bài viết bởi Tom trong khi để mặc những dữ liệu còn lại, chúng ta sẽ viết:

Meteor.publish('allPosts', function(){
  return Posts.find({'author':'Tom'}, {fields: {
    date: false
  }});
});

Kết luận

Chúng ta vừa tìm hiểu về publish từng thuộc tính trong tất cả dữ liệu của mỗi collection (với autopublish) để xuất bản chỉ một vài thuộc tính hoặc một vài dữ liệu của một vài collection.

Nó bao trùm phần cơ bản về những gì bạn có thể làm được với publication của Meteor, và những kỹ thuật đơn giản này có thể đảm bảo cho phần lớn các use case.

Đôi khi, bạn cũng cần phải đi xa hơn bằng cách kết hợp, liên kết, hoặc hợp nhất các collection. Chúng ta sẽ tìm hiểu về điều đó trong chương tiếp theo!

Định tuyến (Routing)

5

Bây giờ chúng ta đã có một danh sách các bài viết (mà cuối cùng sẽ được người dùng gửi), chúng ta cần một trang bài viết đơn lẻ mà người dùng sẽ có thể thảo luận về từng bài.

Chúng tôi muốn các trang này có thể được truy cập thông qua một permalink, một URL dạng http://myapp.com/posts/xyz (‘xyzlà một nhận dạng kiểu MongoDB' _id) duy nhất cho mỗi bài.

Điều này có nghĩa chúng tôi sẽ cần một loại định tuyến routing để nhìn vào những gì bên trong thanh URL của trình duyệt và hiển thị đúng nội dung phù hợp.

Thêm Iron Router Package

Iron Router là một gói phần mềm routing được hình thành cụ thể cho các ứng dụng Meteor.

Nó không chỉ giúp định tuyến (thiết lập đường dẫn), mà còn quản lý các bộ lọc (gán hành động cho một số đường dẫn) và thậm chí quản lý subscription (kiểm soát các đường có quyền truy cập vào dữ liệu). (Lưu ý: Iron Router được phát triển một phần bởi đồng tác giả cuốn Khám phá Meteor là Tom Coleman.)

Trước tiên, hãy cài đặt các gói package từ Atmosphere:

Đây là lệnh tải và cài đặt các gói Iron Router package vào ứng dụng của chúng tôi, sẵn sàng để sử dụng. Lưu ý: đôi khi bạn có thể phải khởi động lại ứng dụng Meteor (dùng ctrl + C để kết thúc quá trình, sau đó dùng 'meteor` để khởi động lại) trước khi một gói có thể được sử dụng.

meteor add iron:router
Terminal

////

Từ vựng Router

Chúng ta sẽ đề cập đến rất nhiều tính năng khác nhau của bộ định tuyến trong chương này. Nếu bạn đã có kinh nghiệm với một framework như Rails, bạn sẽ thấy hầu hết các khái niệm khá quen thuộc. Nhưng nếu không, đây là một danh sách các thuật ngữ giúp bạn học nhanh nhất:

  • Routes: Route là các khối cơ bản xây dựng nên định tuyến(routing). Về cơ bản, nó là một bộ các hướng dẫn cho ứng dụng biết nên đi đâu và làm gì khi nó gặp một URL.

  • Paths: Path là một URL trong ứng dụng. Nó có thể ở dạng tĩnh (/terms_of_service) hoặc động (/posts/xyz), và thậm chí bao gồm các tham số truy vấn (/search?Keyword = meteor).

  • Segments: Là các phần khác nhau của một path, được giới hạn bởi các chéo ngược (/).

  • Hooks: Hook là hành động mà bạn muốn thực hiện trước, sau, hoặc thậm chí trong quá trình định tuyến. Một ví dụ điển hình là kiểm tra quyền truy cập của người dùng trước khi hiển thị một trang nào đó cho họ.

  • Filters: Filter (Bộ lọc) đơn giản là các hook mà bạn định nghĩa trên tổng thể với một hoặc nhiều route.

  • Route Templates: Mỗi route cần trỏ đến một template. Nếu bạn không chỉ định rõ trỏ tới template nào, một route sẽ tự động trỏ tới template cùng tên với nó.

  • Layouts: Layout giống như một khung cho nội dung của bạn. Chúng chứa tất cả các mã HTML tạo nên template hiện tại, và sẽ vẫn như cũ ngay cả khi các template tự thay đổi.

  • *Controllers *: Đôi khi, bạn sẽ nhận ra rằng rất nhiều mẫu của bạn đang sử dụng cùng thông số tương tự. Thay vì sao chép lại các mã, bạn có thể tạo một routing controller, nơi chứa tất cả các logic định tuyến thông thường cho tất cả các route.

Để tìm hiểu thêm về Iron Router, hãy đọc tài liệu GitHub sau (https://github.com/EventedMind/iron-router).

Routing: Ánh xạ URL tới Template

Cho đến nay, chúng ta đã xây dựng các layout sử dụng các template tĩnh bao gồm (như {{> postsList}}). Vì vậy, mặc dù nội dung của ứng dụng có thể thay đổi, cấu trúc cơ bản của trang vẫn giữ nguyên với một tiêu đề và một danh sách các bài viết bên dưới nó.

Iron Router cho phép chúng ta thoát ra khỏi khuôn mẫu này bằng chiếm quyền quản lý những gì bên trong HTML <body> 'tag. Vì vậy, chúng ta sẽ không định nghĩa nội dung của thẻ này giống như với một trang HTML thông thường. Thay vào đó, chúng tôi sẽ trỏ các router tới một mẫu layout đặc biệt có chứa một trợ giúp mẫu{{> yield}}` helper.

Trợ giúp mẫu {{> yield}} này sẽ xác định một khu vực năng động đặc biệt mà sẽ tự động trả về bất cứ mẫu tương ứng với các tuyến đường hiện tại (như một quy ước, chúng tôi sẽ chỉ định mẫu đặc biệt này là “route template” ( tuyến đường mẫu) từ bây giờ):

Layouts and templates.
Layouts and templates.

Chúng tôi sẽ bắt đầu bằng cách tạo layout và bổ sung các {{> yield}} helper. Đầu tiên, chúng tôi sẽ loại bỏ thẻ HTML ’ từ main.html, và di chuyển nội dung của nó đến mẫu riêng của mình,layout.html(được đặt tại thưc mụcclient/templates/application`).

Iron Router sẽ giúp nhúng layout vào các mẫu 'main.html` trông như thế này:

<head>
  <title>Microscope</title>
</head>
client/main.html

Trong khi đó layout.html mới được tạo ra, giờ sẽ chứa các layout bên ngoài của ứng dụng:

<template name="layout">
  <div class="container">
    <header class="navbar navbar-default" role="navigation"> 
      <div class="navbar-header">
        <a class="navbar-brand" href="/">Microscope</a>
      </div>
    </header>
    <div id="main" class="row-fluid">
      {{> yield}}
    </div>
  </div>
</template>
client/templates/application/layout.html

Bạn sẽ nhận thấy chúng ta vừa thay thế sự bao gồm của mẫu postsList với một cuộc gọi tới yield helper.

Sau sự thay đổi này, tab trình duyệt của chúng ta sẽ chuyển sang màu trắng và một lỗi sẽ hiển thị trong giao diện điều khiển trình duyệt. Nguyên nhân là bởi chúng ta chưa bảo các router phải làm gì với các / URL, vì vậy nó chỉ hiển thị một mẫu trống.

Để bắt đầu, chúng ta có thể lấy lại hành vi cũ bằng cách lập bản đồ gốc / URL đến mẫu postsList. Chúng ta sẽ tạo ra một tập tin router.js mới bên trong thư mục / lib tại gốc của dự án:

Router.configure({
  layoutTemplate: 'layout'
});

Router.route('/', {name: 'postsList'});
lib/router.js

Chúng ta vừa thực hiện hai điều quan trọng. Đầu tiên, chúng bảo các router sử dụng mẫu layout vừa tạo làm layout mặc định cho tất cả các route.

Thứ hai, chúng ta đã định nghĩa một route mới có tên postsList và chỉ nó vào thư mục gốc/.

Thư mục /lib

Bất cứ thứ gì bạn đặt bên trong thư mục ’/ lib` được đảm bảo load trước bất cứ thứ gì khác trong ứng dụng của bạn (có thể ngoại trừ các gói thông minh). Điều này làm tạo ra một nơi tuyệt vời để đặt mã helper mà cần phải có sẵn ở mọi lúc.

Lưu ý nhỏ: vì thư mục /lib không nằm trong/ client hay / server, nên nội dung của nó sẽ có sẵn cho cả hai môi trường.

Đặt tên Routes

Hãy làm sáng tỏ một chút mơ hồ ở đây. Chúng ta đặt tên route của mình là 'postsList, nhưng chúng ta cũng có một *template* gọi làpostsList`. Vậy điều gì đang xảy ra ở đây?

Theo mặc định, Iron Router sẽ tìm một mẫu cùng tên với tên route. Trong thực tế, nó thậm chí sẽ suy ra các tên từ path mà bạn cung cấp. Mặc dù trong trường hợp cụ thể này khó thành công (bởi path của chúng ta là /), Iron Router sẽ tìm thấy mẫu chính xác nếu chúng ta sử dụng http://localhost:3000/postsList làm path.

Bạn có thể tự hỏi tại sao chúng ta cần phải đặt tên cho các route đầu tiên. Đặt tên các route cho phép chúng ta sử dụng một số tính năng của Iron Router giúp cho việc dễ dàng xây dựng các link bên trong ứng dụng. Tính năng hữu ích nhất là {{pathFor}} Spacebars helper, giúp trả về các phần URL path của bất kỳ route nào.

Chúng ta muốn liên kết tại trang chủ trỏ về danh sách các bài viết, vì vậy thay vì chỉ định một / URL tĩnh, chúng ta có thể sử dụng các helper Spacebars. Kết quả cuối cùng là như nhau, nhưng cách này linh hoạt hơn bởi các helper sẽ luôn luôn xuất URL đúng ngay cả khi chúng ta đổi path của route sau này.

<header class="navbar navbar-default" role="navigation"> 
  <div class="navbar-header">
    <a class="navbar-brand" href="{{pathFor 'postsList'}}">Microscope</a>
  </div>
</header>

//...
client/templates/application/layout.html

Commit 5-1

Very basic routing.

Waiting On Data

Nếu bạn triển khai các phiên bản hiện tại của ứng dụng (hoặc khởi động các ví dụ web bằng cách sử dụng liên kết ở trên), bạn sẽ nhận thấy danh sách xuất hiện trống trong một vài phút trước khi bài viết xuất hiện. Nguyên nhân là bởi vì khi tải trang đầu tiên, không có bài viết để hiển thị cho tới khi các thuê bao 'posts` hoàn thành việc lấy dữ liệu từ máy chủ.

Để có một trải nghiệm người dùng tốt, nên cung cấp thông tin phản hồi trực quan cho thấy dữ liệu đang được xử lý, và rằng người dùng nên đợi một chút.

May mắn thay, Iron Router cung cấp cho chúng ta một cách dễ dàng để làm điều đó: chúng ta có thể yêu cầu nó wait on các thuê bao.

Chúng ta bắt đầu bằng cách di chuyển các thuê bao posts từ main.js tới router:

Router.configure({
  layoutTemplate: 'layout',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.route('/', {name: 'postsList'});
lib/router.js

Những gì chúng ta đang nói đến ở đây là đối với mỗi route trên trang web (giờ chúng ta chỉ có một route, nhưng sẽ có nhiều hơn nữa sau đó!), chúng ta muốn đăng ký vào các thuê bao posts.

Sự khác biệt chính giữa điều này và những gì chúng ta đã có trước đó (khi các thuê bao nằm trong main.js, mà bây giờ đã trống rỗng và có thể được gỡ bỏ), là bây giờ Iron Router biết khi nào route sẵn sàng - đó là khi các route có dữ liệu nó cần xử lý.

Get A Load Of This

Biết khi nào postsList route sẵn sàng không giúp được gì nếu chúng ta chỉ cần hiển thị một mẫu trống. Rất may, Iron Router có một giải pháp đi kèm giúp trì hoãn việc hiển thị một mẫu cho đến khi các route gọi nó đã sẵn sàng, và hiển thị một mẫu loading thay vì:

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.route('/', {name: 'postsList'});
lib/router.js

Lưu ý rằng kể từ khi chúng ta đang xác định các chức năng waitOn ở mức router, trình tự này sẽ chỉ xảy ra một lần khi một người sử dụng lần đầu tiên truy cập ứng dụng của bạn. Sau đó, các dữ liệu sẽ tự động được nạp vào bộ nhớ của trình duyệt và các router sẽ không cần phải chờ đợi dữ liệu một lần nữa.

Bài toán cuối cùng phải giải là tạo một template tải thực tế. Chúng ta sẽ dùng package spin để tạo ra một spinner tải với animation đẹp. Thêm nó với meteor add sacha:spin, và sau đó tạo ra các mẫu loading như sau trong thư mục client/templates/includes

<template name="loading">
  {{>spinner}}
</template>
client/templates/includes/loading.html

Lưu ý rằng {{> spinner}} là một phần chứa trong gói spin. Mặc dù phần này xuất phát từ “bên ngoài” ứng dụng, chúng ta có thể thêm nó vào như bất kỳ mẫu nào khác.

Thường thì nên đợi các thuê bao đăng ký, vì không chỉ giúp tạo trải nghiệm người dùng tốt hơn, mà nó còn giúp bạn yên tâm rằng dữ liệu sẽ luôn luôn có sẵn từ bên trong một mẫu. Điều này giúp loại bỏ việc xử lý các mẫu được trả lại trước khi dữ liệu cơ bản của chúng có sẵn. Việc xử lý này thường đòi hỏi các cách giải quyết khá khó khăn.

Commit 5-2

Wait on the post subscription.

Khái niệm Reactivity

Phản ứng (reactivity) là một phần cốt lõi của Meteor, và mặc dù chúng ta chưa thực sự đi sâu vào nó, template tải cũng giúp cung cấp cho chúng ta một cái nhìn đầu tiên về khái niệm này.

Chuyển hướng đến một template tải khi dữ liệu chưa sẵn sàng là một giải pháp tốt, nhưng làm thế nào để các router biết khi nào cần chuyển hướng người dùng quay lại trang đúng, một khi dữ liệu đã được thông qua?

Với chương này, chúng ta hãy cứ tạm hiểu đây là nơi phản ứng đi đến. Nhưng đừng lo lắng, bạn sẽ được học kỹ về nó sớm thôi!

Định tuyến tới Một bài viết cụ thể

Bây giờ chúng ta đã biết làm thế nào để định tuyến đến postsList template. Hãy thiết lập một lộ trình để hiển thị các chi tiết của một bài viết cụ thể.

Một nhược điểm là: chúng ta không thể đi trước và xác định một lộ trình cho mỗi bài viết, vì có thể có hàng trăm trong số chúng. Vì vậy, chúng ta cần phải thiết lập một route năng động duy nhất, và làm cho route đó hiển thị bất kỳ bài viết nào chúng ta muốn.

Để bắt đầu, chúng ta sẽ tạo ra một mẫu mới để xử lý cùng một mẫu bài viết trước đó trong danh sách các bài viết.

<template name="postPage">
  {{> postItem}}
</template>
client/templates/posts/post_page.html

Chúng ta sẽ bổ sung thêm nhiều yếu tố cho mẫu này về sau (như thêm bình luận), nhưng bây giờ nhiệm vụ của nó chỉ đơn giản là một vỏ chứa {{> postItem}}.

Chúng ta sẽ tạo ra một route mới, ánh xạ đường dẫn URL của dạng /posts/<ID> tới mẫu postPage.

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.route('/', {name: 'postsList'});
Router.route('/posts/:_id', {
  name: 'postPage'
});
lib/router.js

Cú pháp :_id đặc biệt cho router biết hai điều: thứ nhất, khớp bất cứ route nào có dạng /posts/xyz/ (“xyz” có thể là bất cứ thứ gì). Thứ hai, để đặt những gì nó tìm thấy trong “xyz” vào bên trong một vùng _id trong mảng params của router.

Lưu ý rằng chúng ta chỉ sử dụng _id cho mục đích thuận tiện ở đây. Các router không có cách nào để biết nếu bạn cho đi qua nó một _id thực tế, hay chỉ một số chuỗi ngẫu nhiên các ký tự.

Chúng ta hiện đã định tuyến đến các mẫu chính xác, nhưng vẫn còn thiếu một cái gì đó: các router biết _id của bài viết chúng ta muốn hiển thị, nhưng các mẫu thì không. Vì vậy, làm thế nào để chúng ta thu hẹp khoảng cách đó?

Rất may, các router có một giải pháp tích hợp thông minh: nó cho phép bạn chỉ định ** bối cảnh dữ liệu** của một mẫu. Bạn có thể coi bối cảnh dữ liệu giống như phần bên trong một chiếc bánh ngon làm từ mẫu và layout. Đơn giản, nó là những gì bạn điền vào mẫu như sau:

The data context.
The data context.

Trong trường hợp này, chúng ta có thể có được các ngữ cảnh dữ liệu thích hợp bằng cách tìm kiếm bài viết dựa trên các _id nhận được từ URL:

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.route('/', {name: 'postsList'});
Router.route('/posts/:_id', {
  name: 'postPage',
  data: function() { return Posts.findOne(this.params._id); }
});
lib/router.js

Vì vậy, mỗi khi người dùng truy cập route này, chúng ta sẽ tìm thấy các bài viết phù hợp và đưa nó vào template. Hãy nhớ rằng 'findOnetrả về một bài duy nhất phù hợp với một truy vấn, và rằng việc cung cấp mộtidnhư một đối số là một cách viết tắt cho {_id: id} `.

Trong chức năng data của một route, this tương ứng với các route hiện đang xuất hiện, và chúng ta có thể sử dụngthis.params để truy cập vào phần tên của các route (mà chúng tôi chỉ ra bằng cách đặt tiền tố : trước chúng, bên trong path).

Tìm hiểu thêm về bối cảnh dữ liệu

Bằng cách đặt một mẫu dữ liệu ngữ cảnh, bạn có thể kiểm soát giá trị của this bên trong mẫu trợ giúp.

Điều này thường được thực hiện ngầm với các biến lặp {{#each}}, mà tự động cài đặt các dữ liệu ngữ cảnh của mỗi lần lặp đến mục hiện đang được lặp trên:

{{#each widgets}}
  {{> widgetItem}}
{{/each}}

Nhưng rõ ràng chúng ta cũng có thể làm điều đó bằng cách sử dụng ’{{}} {{#with}}, mà chỉ đơn giản nói “lấy đối tượng này, và áp dụng các mẫu sau cho nó ”. Ví dụ, chúng ta có thể viết:

{{#with myWidget}}
  {{> widgetPage}}
{{/with}}

Hóa ra bạn có thể đạt được kết quả tương tự bằng cách đi qua các bối cảnh như một tham số để gọi mẫu. Vì vậy, các khối trước của mã có thể được viết lại như sau:

{{> widgetPage myWidget}}

Để hiểu thêm về bối cảnh dữ liệu, chúng tôi đề nghị bạn đọc bài viết trên blog của chúng tôi về chủ đề này.

Sử dụng một Route Helper có tên động

Cuối cùng, chúng ta sẽ tạo ra một nút mới “Thảo luận” để liên kết đến mỗi trang bài viết. Một lần nữa, chúng ta có thể tạo một cái gì đó như <a href="/posts/{{_id}}">. Tuy nhiên, sử dụng một router helper là đáng tin cậy hơn cả.

Chúng ta đã đặt tên cho route bài viết là 'postPage, vì vậy chúng ta có thể sử dụng một {{pathFor' postPage '}}helper`:

<template name="postItem">
  <div class="post">
    <div class="post-content">
      <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
    </div>
    <a href="{{pathFor 'postPage'}}" class="discuss btn btn-default">Discuss</a>
  </div>
</template>
client/templates/posts/post_item.html

Commit 5-3

Routing to a single post page.

Nhưng khoan, chính xác thì làm thế nào các router biết nơi để lấy những phần xyz trong /posts/xyz? Chúng ta chưa hề cho bất kỳ ’_id` nào đi qua nó.

Hóa ra Iron Router đủ thông minh để tự tìm ra. Chúng ta lệnh cho các router phải sử dụng postPage route, và router tự biết rằng route này đòi hỏi một loại _id nào đó (vì đó là cách chúng ta định nghĩa path).

Vì vậy, các bộ định tuyến sẽ tìm kiếm _id này ở nơi hợp lý nhất có thể: trong bối cảnh dữ liệu của {{pathFor 'postPage'}} helper hay nói cách khác là this. Và ngạc nhiên thay, this này tương ứng với một bài viết, trong đó có một đặc tính _id.

Ngoài ra, bạn cũng có thể cho các bộ định tuyến biết nơi bạn muốn nó tìm kiếm các đặc tính ’_id, bằng cách thông qua một đối số thứ hai tới các helper (tức là .{{pathFor 'postPage’ someOtherPost}}`). Một thực tế sử dụng của mô hình này sẽ nhận được các liên kết đến các bài viết trước đó hoặc kế tiếp trong danh sách, ví dụ.

Để xem nó hoạt động chính xác hay không, duyệt đến danh sách bài viết và click vào một trong những liên kết 'Thảo luận’. Bạn sẽ thấy một cái gì đó như thế này:

A single post page.
A single post page.

HTML5 pushState

Một điều cần lưu lý rằng những thay đổi với URL diễn ra bằng cách sử dụng HTML5pushState.

Các Router thu thập các cú nhấp chuột vào URL nội bộ trong trang web, và ngăn ngừa trình duyệt đi ra ngoài ứng dụng. Thay vào đó, chúng chỉ thực hiện những thay đổi cần thiết tới trạng thái của ứng dụng.

Nếu tất cả mọi thứ hoạt động đúng, thay đổi trong trang sẽ được thể hiện ngay lập tức. Trong thực tế, đôi khi mọi thứ thay đổi quá nhanh mà một số loại trang chuyển tiếp có thể cần thiết. Điều này nằm ngoài phạm vi của chương này, nhưng dù sao cũng là một chủ đề thú vị.

Post Not Found

Chúng ta đừng quên rằng routing hoạt động cả hai cách: nó có thể thay đổi URL khi chúng ta ghé thăm một trang, nhưng nó cũng có thể hiển thị một trang mới khi chúng ta thay đổi URL. Vì vậy, chúng ta cần phải tìm ra những gì sẽ xảy ra nếu ai đó nhập sai URL.

Rất may, Iron Router giúp giải quyết vấn đề này nhờ lựa chọn notFoundTemplate.

Đầu tiên, chúng tôi sẽ thiết lập một template mới để hiển thị một thông báo lỗi 404 đơn giản:

<template name="notFound">
  <div class="not-found jumbotron">
    <h2>404</h2>
    <p>Sorry, we couldn't find a page at this address.</p>
  </div>
</template>
client/templates/application/not_found.html

Sau đó, chúng ta sẽ chỉ cần trỏ Iron Router đến mẫu này:

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

//...
lib/router.js

Để kiểm tra trang error mới của bạn, bạn có thể thử truy cập một URL ngẫu nhiên như http://localhost:3000/nothing-here.

Nhưng khoan, điều gì sẽ xảy ra nếu ai đó nhập URL dạng http://localhost:3000/posts/xyz, trong đóxyzkhông phải là một _id` hợp lệ? Đây vẫn là một route hợp lệ, chỉ có điều nó không trỏ đến bất kỳ dữ liệu nào.

Rất may, Iron Router là đủ thông minh để giải quyết bài toán này. Chúng ta chỉ cần thêm một hook đặc biệt dataNotFound vào cuối của router.js:

//...

Router.onBeforeAction('dataNotFound', {only: 'postPage'});
lib/router.js

Như vậy Iron Router sẽ biết hiển thị các trang “không tìm thấy” không chỉ cho các route không hợp lệ mà còn cho các route postPage, bất cứ khi nào các hàm data trả về một đối tượng “falsy” (ví dụ null, false,undefined , hoặc rỗng).

Commit 5-4

Added not found template.

Tại sao lại gọi là “Iron Router” (sắt)?

Bạn có thể đang thắc mắc về câu chuyện đằng sau cái tên “Iron Router”. Theo tác giả Chris Mather, nó xuất phát từ thực tế rằng thiên thạch(meteor) được cấu tạo chủ yếu từ sắt.

Về Session

Sidebar 5.5

Meteor là một bộ framework tác động ngược. Điều đó có nghĩa là khi dữ liệu thay đổi, các thứ trong ứng dụng của bạn sẽ thay đổi theo mà bạn không cần phải làm bất cứ thứ gì rõ ràng.

Chúng ta đã từng được thấy điều này thực tế khi mà template thay đổi khi dữ liệu và route thay đổi.

Chúng ta sẽ đào sâu hơn hoạt động của nó trong chương này, nhưng bây giờ, chúng tôi muốn giới thiệu một vài tính năng cơ bản của tương tác ngược mà rất hữu ích với những ứng dụng chung.

Meteor Session

Ngay bây giờ trong Microscope, trạng thái hiện tại của ứng dụng người dùng được hoàn toàn chứa trong URL mà họ đang nhìn vào (và cả cơ sở dữ liệu)

Nhưng trong nhiều trường hợp, bạn cần phải lưu giữ những trạng thái ngắn đời mà chỉ thích hợp với người dùng hiện tại của ứng dụng (ví dụ, khi một thành phần được hiển thị hoặc ẩn). Session chính là một cách tiện lợi để làm điều này.

Session là một dạng lưu trữ dữ liệu tương tác ngược toàn cục. Nó toàn cục trong khía cạnh một đối tượng đơn toàn cục: chỉ có một session, và được truy cập từ bất kỳ nơi đâu. Biến toàn cục thường được xem như là một điều gì đó xấu, nhưng trong trường hợp này session có thể dùng như một trạm giao tiếp trung tâm cho các phần khác nhau của ứng dụng.

Thay đổi Session

Session có hiệu lực mọi nơi trên client được dùng với đối tượng tên là Session. Để thiết lập giá trị session, bạn có thể gọi:

 Session.set('pageTitle', 'A different title');
Browser console

Bạn có thể gọi lại giá trị đã thiết lập với Session.get('mySessionProperty');. Đây là một nguồn dữ liệu tương tác ngược, có nghĩa là nếu bạn đặt nó trong một bộ trợ giúp (helper), bạn sẽ thấy là đầu ra của helper sẽ thay đổi một cách tương tác khi mà biến Session thay đổi.

Để thử điều này, thêm dòng sau vào bản mẫu (layout) của template:

<header class="navbar navbar-default" role="navigation"> 
  <div class="navbar-header">
    <a class="navbar-brand" href="{{pathFor 'postsList'}}">{{pageTitle}}</a>
  </div>
</header>
client/templates/application/layout.html
Template.layout.helpers({
  pageTitle: function() { return Session.get('pageTitle'); }
});
client/templates/application/layout.js

Chú ý về Sidebar Code

Chú ý rằng code được đặc tả trong chương về sidebar không phải là phần chính trong luồng của cuốn sách. Vì vậy hoặc là bạn tạo một nhánh (branch) bây giờ (nếu như dùng Git), hoặc chắc chắn rằng bạn đã khôi phục thay đổi của bạn ở cuối chương này.

Sự nạp lại tự động của Meteor ( được biết đến với tên là “hot code reload” hoặc HCR) đảm bảo duy trì biến Session, vì vậy ngay bây giờ bạn thấy “A different title” xuất hiện trên thanh nav bar. Nếu không, hãy gõ lại câu lệnh Session.set() phía trước.

Thêm nữa, nếu chúng ta thay đổi giá trị thêm một lần nữa (ở trên console trình duyệt), chúng ta sẽ thấy thêm một tựa đề nữa xuất hiện:

 Session.set('pageTitle', 'A brand new title');
Browser console

Session sẽ có hiệu lực tổng thể, vì vậy những thay đổi diễn ra mọi nơi của ứng dụng. Điều này mang đến cho chúng ta rất nhiều khả năng, nhưng cũng có thể là cạm bẫy nếu sử dụng quá nhiều.

Dù thế nào đi nữa, một điều rất quan trọng phải được chỉ ra đó là đối tượng Session không chia sẻ giữa các người dùng, hoặc giữa các tab của trình duyệt. Đó là lý do nếu bạn mở ứng dụng trên một tab mới, bạn sẽ bắt gặp tựa đề của trang trống không.

Thay đổi giống nhau

Nếu bạn thay đổi giá trị của biến Session với Session.set() nhưng với một giá trị giống nhau, Meteor đủ thông minh để bỏ qua tương tác ngược đó, và tránh gọi hàm không cần thiết.

Giới thiệu chạy tự động (autorun)

Chúng ta vừa thấy một ví dụ về nguồn dữ liệu tương tác ngược, và chứng kiến trực tiếp nó bên trong helper của một template. Nhưng trong khi một vài ngữ cảnh trong Meteor (như là helper của template) thừa hưởng tương tác ngược, phần lớn code của ứng dụng Meteor vẫn là mã code JavaScript cũ không tương tác ngược được.

Hãy giả sử rằng chúng ta có đoạn code như sau ở đâu đó trong ứng dụng:

helloWorld = function() {
  alert(Session.get('message'));
}

Mặc dù chúng ta gọi một biến Session, ngữ cảnh mà nó được gọi không tương tác ngược, nghĩa là chúng ta không nhận được alert mới mỗi khi biến số thay đổi.

Đây là nơi mà Autorun xuất hiện. Như tên gọi của nó, đoạn code trong khối autorun sẽ tự động chạy và giữ nguyên trạng thái chạy mỗi lần nguồn dữ liệu tương tác ngược thay đổi.

Hãy thử gõ đoạn mã sau vào console trình duyệt:

 Tracker.autorun( function() { console.log('Value is: ' + Session.get('pageTitle')); } );
Value is: A brand new title
Browser console

Như bạn có thể tưởng tượng, đoạn code trong khối autorun chạy một lần, xuất ra dữ liệu trên console. Bây giờ, hãy thử thay đổi lại tiêu đề:

 Session.set('pageTitle', 'Yet another value');
Value is: Yet another value
Browser console

Như có phép thuật! Khi mà giá trị session thay đổi, autorun biết rằng phải chạy lại nội dung của nó thêm lần nữa, xuất ra thêm lần nữa giá trị ra console.

Quay trở lại ví dụ trước của chúng ta, nếu chúng ta muốn kích hoạt alert mỗi lần biến session thay đổi, tất cả việc chúng ta phải làm là cho đoạn code vào khối autorun:

Tracker.autorun(function() {
  alert(Session.get('message'));
});

Như chúng ta vừa thấy, autorun có thể rất hữu ích để theo dấu nguồn dữ liệu tương tác ngược và ra lệnh tác động ngược ngay lập tức lên chúng.

Hot Code Reload

Trong suốt quá trình phát triển của Microscope, chúng ta đã sử dụng một tính năng tiết kiệm thời gian của Meteor: hot code reload (HCR). Khi chúng ta lưu một vài files mã nguồn, Meteor kiểm tra sự thay đổi và khởi động lại server Meteor một cách rõ ràng, báo cho mỗi client biết để nạp lại trang.

Điều này giống như là một thiết bị tự động nạp lại trang, nhưng với một điều khác biệt quan trọng.

Để biết được điều quan trọng đó, hãy bắt đầu bằng việc thiết lập lại giá trị biến session chúng ta đang sử dụng:

 Session.set('pageTitle', 'A brand new title');
 Session.get('pageTitle');
'A brand new title'
Browser console

Nếu chúng ta nạp lại trình duyệt bằng tay, biến session của chúng ta sẽ tự động biến mất (vì hành động này tạo ra biến session mới). Mặt khác, nếu chúng ta kích hoạt hot code reload ( ví dụ, bằng việc lưu lại files nguồn), trang web sẽ được load lại, nhưng biến session sẽ vẫn trong trạng thái được thiết lập. Hãy thử nó ngay bây giờ!

 Session.get('pageTitle');
'A brand new title'
Browser console

Vì vậy nếu bạn sử dụng biến session để theo dấu những gì người dùng đã làm, HCR nên trong suốt với người dùng, vì nó duy trì giá trị của biến session. Điều này giúp chúng ta triển khai phiên bản mới của ứng dụng sản phẩm Meteor với một sự chắc chắn rằng người dùng sẽ bị gián đoạn ít nhất.

Hãy xem xét điều này một chút. Nếu chúng ta có thể giữ được tất cả trạng thái trên URL và session, chúng ta có thể thay đổi mã nguồn đang chạy một cách trong suốt với mỗi ứng dụng client ở dưới chúng với sự gián đoạn ít nhất.

Hãy xem điều gì sẽ xảy ra nếu chúng ta làm mới trang bằng tay:

 Session.get('pageTitle');
null
Browser console

Khi chúng ta làm mới trang, chúng ta mất session. Với HCR, Meteor lưu lại session tới bộ lưu trữ cục bộ và nạp lại nó thêm lần nữa để làm mới. Tuy nhiên, cách nạp hiện còn lại hợp lý: nếu người dùng nạp lại trang, nó giống như là họ truy cập vào cùng URL một lần nữa, và họ nên thiết lập lại trạng thái ban đầu mà bất kì người dùng nào cũng sẽ thấy khi bước vào URL đó.

Bài học quan trọng rút ra là:

  1. Luôn lưu trạng thái của người dùng trong Session hoặc trên URL để người dùng có thể ít bị gián đoạn nhất khi mà một lệnh hot code reload xảy ra.

  2. Lưu bất kỳ trạng thái nào bạn muốn chia sẻ giữa các người dùng bên trong bản thân URL

Điều này kết luận cho khám phá của chúng ta về Sesion, một trong những tính năng thuận tiện nhất của Meteor. Bây giờ, đừng quên khôi phúc lại những thay đổi của bạn trước khi di chuyển sang chương tiếp theo.

Thêm người dùng

6

Cho đến bây giờ, chúng ta đã học cách tạo và hiểu thị những nội dung tĩnh một cách hợp lý và ráp chúng lại với nhau thành một bộ prototype đơn giản.

Chúng ta cũng đã thấy UI của chúng ta đáp lại như thế nào đối với dữ liệu thay đổi, và dữ liệu được chèn vào hoặc được thay đổi xuất hiện ngay lập tức. Tuy vậy, ứng dụng của chúng ta vẫn què quặt bởi sự thật là chúng ta không thể nhập dữ liệu vào. Thực tế, chúng ta còn chưa có cả người dùng!

Hãy xem chúng ta có thể sửa điều đó như thế nào.

Accounts: người dùng được tạo ra đơn giản

Trong hầu hết web framework, thêm tài khoản người dùng là một công việc quen thuộc. Bạn phải làm điều đó trong hầu hết các dự án, nhưng nó chưa bao giờ dễ dàng như vậy. Thêm vào nữa, nếu bạn phải làm việc với OAuth hoặc là thiết kế chứng thực của bên thứ ba, mọi thứ trở nên xấu xí nhanh chóng.

May mắn là Meteor đã làm việc đó cho bạn. Cảm ơn cách mà các gói package của Meteor quản lý code trên cả server (JavaScript) và client (JavaScript, HTML và CSS), chúng ta có thể có được một hệ thống tài khoản người dùng hầu như miễn phí.

Chúng ta có thể sử dụng UI được dựng sẵn của Meteor để tạo tài khoản (với lệnh meteor add accounts-ui) nhưng do chúng ta đang xây dựng ứng dụng với Bootstrap, chúng ta sẽ dùng gói ian:accounts-ui-bootstrap-3 thay thế (đừng lo lắng, thứ khác biệt duy nhất chỉ là style hiển thị). Trong màn hình command line, hãy gõ:

meteor add ian:accounts-ui-bootstrap-3
meteor add accounts-password
Terminal

Hai dòng lệnh trên tạo template tài khoản cho chúng ta, và chúng ta có thể thêm vào site bằng việc sử dụng helper {{> loginButtons}}. Mẹo nhỏ: bạn có thể điều chỉnh xem muốn dropdown log-in hiển thị về phía nào bằng việc dùng thuộc tính align (ví dụ: {{> loginButtons align="right"}}).

Chúng ta sẽ thêm button vào phần header. Và do phần header ngày càng trở nên to hơn, hãy thêm phòng cho template của nó (chúng ta sẽ đặt trong client/templates/includes/). Chúng ta sẽ thêm một số đánh dấu và class như được định nghĩa bởi Bootstrap để chắc chắn rằng mọi thứ trông đẹp mắt:

<template name="layout">
  <div class="container">
    {{> header}}
    <div id="main" class="row-fluid">
      {{> yield}}
    </div>
  </div>
</template>
client/templates/application/layout.html
<template name="header">
  <nav class="navbar navbar-default" role="navigation">
    <div class="container-fluid">
      <div class="navbar-header">
        <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navigation">
          <span class="sr-only">Toggle navigation</span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
        </button>
        <a class="navbar-brand" href="{{pathFor 'postsList'}}">Microscope</a>
      </div>
      <div class="collapse navbar-collapse" id="navigation">
        <ul class="nav navbar-nav navbar-right">
          {{> loginButtons}}
        </ul>
      </div>
    </div>
  </nav>
</template>
client/templates/includes/header.html

Bây giờ, khi truy cập vào ứng dụng, chúng ta sẽ thấy button để login tài khoản ở góc trên bên phải.

Meteor's built-in accounts UI
Meteor’s built-in accounts UI

Chúng ta có thể dùng nó để sign up, log in, tạo request để thay đổi mật khẩu, hoặc bất kỳ thứ gì khác mà một site bình thường cần có cho tài khoản có dùng mật khẩu.

Để bảo cho hệ thống tài khoản của chúng ta biết được rằng chúng ta muốn login dùng username, chúng ta chỉ cần đơn giản thêm vào khối cấu hìnhAccounts.ui trong một file mới tên là config.js vào bên trong client/helpers/:

Accounts.ui.config({
  passwordSignupFields: 'USERNAME_ONLY'
});
client/helpers/config.js

Commit 6-1

Added accounts and added template to the header

Tạo người dùng đầu tiên

Xin mời bạn tạo một tài khoản: Button “Sign in” sẽ thay đổi để hiển thị username của bạn. Điều này xác nhận rằng một tài khoản người dùng mới đã được tạo. Nhưng dữ liệu của tài khoản đó tới từ đâu?

Bằng việc thêm vào gói accounts, Meteor đã tạo ra một collection đặc biệt có thể truy cập từ Meteor.users. Để thấy nó, hãy mở console trình duyệt và nhập vào:

 Meteor.users.findOne();
Browser console

Console sẽ trả về một object thể hiện người dùng của bạn: nếu quan sát kỹ hơn, bạn có thể thấy username ở đó, cũng như một thuộc tính _id duy nhất định danh bạn. Chú ý là bạn cũng có thể lấy ra người dùng đang log-in bằng với Meteor.user().

Bây giờ hãy log out và tạo một tài khoản khác. Meteor.user() bây giờ sẽ trả về người dùng thứ hai đó. Nhưng chờ chút, hãy chạy lệnh:

 Meteor.users.find().count();
1
Browser console

Console trả về 1. Không phải là nó nên là 2 hay sao nhỉ? Hay là người dùng thứ nhất đã bị xoá đi? Nếu bạn thử login với người dùng đầu tiên, bạn sẽ điều đó không đúng.

Để chắc chắn hãy kiểm tra nguồn lưu trữ dữ liệu, là cơ sở dữ liệu Mongo. Chúng ta sẽ log in vào Mongo (với meteor mongo trên terminal) và kiểm tra:

> db.users.count()
2
Mongo console

Chắc chắn là có hai người dùng. Vậy tại sao chúng ta chỉ thấy một người dùng duy nhất ở một thời điểm trên trình duyệt?

Publication bí ẩn!

Nếu bạn suy nghĩ lại chương 4, bạn có thể đã nhớ ra rằng bằng việc tắt đi autopublish, chúng ta đã dừng việc cho collection tự động gửi dữ liệu từ server tới mỗi kết nối ở client. Chúng ta cần phải tạo cặp publication và subscription vào kênh dữ liệu.

Chúng ta vẫn chưa tạo ra publication nào đối với user. Vậy làm thế nào chúng ta có thể thấy được dữ liệu user?

Câu trả lời là gói accounts thực sự đã “auto-publish” người dùng đang log in. Nếu không, user đó đã không thể nào log in vào hệ thống được!

Tuy vậy, gói accounts chỉ publish duy nhất người dùng hiện tại. Điều này giải thích vì sao chúng ta không thấy thông tin tài khoản còn lại.

Bởi vậy, hệ thống publication chỉ publish duy nhất một user cho người dùng đang log in (và không publish gì nếu như bạn không log in).

Thêm vào nữa, dữ liệu của collection user dường như chứa đựng các trường không giống nhau giữa client và server. Trong Mongo, một user có rất nhiều dữ liệu trong nó. Để thấy điều đó, chúng ta chỉ việc quay lại terminal Mongo và gõ:

> db.users.findOne()
{
    "createdAt" : 1365649830922,
    "_id" : "kYdBd9hr3fWPGPcii",
    "services" : {
        "password" : {
            "srp" : {
                "identity" : "qyFCnw4MmRbmGyBdN",
                "salt" : "YcBjRa7ArXn5tdCdE",
                "verifier" : "df2c001edadf4e475e703fa8cd093abd4b63afccbca48fad1d2a0986ff2bcfba920d3f122d358c4af0c287f8eaf9690a2c7e376d701ab2fe1acd53a5bc3e843905d5dcaf2f1c47c25bf5dd87764d1f58c8c01e4539872a9765d2b27c700dcdedadf5ac82521467356d3f91dbeaf9848158987c6d359c5423e6b9cabf34fa0b45"
            }
        },
        "resume" : {
            "loginTokens" : [
                {
                    "token" : "BMHipQqjfLoPz7gru",
                    "when" : 1365649830922
                }
            ]
        }
    },
    "username" : "tmeasday"
}
Mongo console

Mặt khác, trên trình duyệt thì object user được gọt đi nhiều, như bạn có thể thấy bằng việc gõ dòng lệnh:

 Meteor.users.findOne();
Object {_id: "kYdBd9hr3fWPGPcii", username: "tmeasday"}
Browser console

Ví dụ này chỉ cho chúng ta thấy một collection phía local có thể giữ an toàn tập con của cơ sở dữ liệu thực. Người dùng đang log in chỉ có thể thấy đủ thông tin để thực hiện công việc cần thiết (trong trường hợp này là để sign in). Đây là một hướng hữu ích có thể học hỏi được, như bạn sẽ thấy sau đây.

Điều này không có nghĩa là bạn không thể tạo thêm dữ liệu user công khai nếu bạn muốn. Bạn có thể tham khảo tài liệu Meteor để thấy làm thế nào để publish thêm nhiều trường của collection Meteor.users.

Tương tác lại

Sidebar 6.5

Nếu như Collection là tính năng lõi của Meteor, thì tương tác lại (reactivity) là lớp vỏ làm cho tính năng đó trở nên hữu ích.

Collection biến đổi tận gốc cách ứng dụng giải quyết vấn đề dữ liệu thay đổi. Thay vì phải kiểm tra dữ liệu thay đổi bằng tay (ví dụ như là thông qua lệnh gọi AJAX) và sau đó ráp nối lại những thay đổi đó vào HTML, việc thay đổi của dữ liệu có thể diễn ra tại bất kỳ thời điểm nào và được ghép vào giao diện người dùng của bạn một cách liền mạch với Meteor.

Hãy dành một chút thời gian để suy nghĩ về nó: phía sau màn hình, Meteor có khả năng thay đổi bất kỳ phần nào của giao diện người dùng khi mà collection phía bên dưới được cập nhật.

Mệnh lệnh để làm điều đó sẽ là dùng .observe(), một hàm con trỏ khởi động callback khi mà bản ghi so khớp với con trỏ đó thay đổi. Chúng ta có thể sau đó, thay đổi trên DOM (HTML được dịch của ứng dụng web) thông qua callback. Đoạn code kết quả sẽ như sau:

Posts.find().observe({
  added: function(post) {
    // when 'added' callback fires, add HTML element
    $('ul').append('<li id="' + post._id + '">' + post.title + '</li>');
  },
  changed: function(post) {
    // when 'changed' callback fires, modify HTML element's text
    $('ul li#' + post._id).text(post.title);
  },
  removed: function(post) {
    // when 'removed' callback fires, remove HTML element
    $('ul li#' + post._id).remove();
  }
});

Bạn có thể đã nhận ra rằng đoạn code như trên sẽ nhanh chóng trở nên phức tạp. Thử tưởng tượng việc dàn xếp với thay đổi từ mỗi thuộc tính của bài post, và phải thay đổi HTML phức tạp với tag <li> của bài post. Không kể tới những trường hợp phức tạp tiềm ẩn có thể xảy ra khi chúng ta bắt đầu sử dụng thông tin trên nhiều nguồn mà chúng đều có thể thay đổi thời gian thực.

Khi nào chúng ta Nên dùng observe()?

Dùng kiểu mẫu như trên đôi khi có thể cần thiết, đặc biệt là khi giải quyết với widget từ nguồn thứ ba. Ví dụ, hãy tưởng tượng chúng ta muốn thêm hoặc xoá những điểm ghim trên bản đồ theo thời gian thực dựa trên dữ liệu Collection (để hiển thị địa điểm của người dùng đang log in).

Trong trường hợp đó, bạn sẽ cần dùng callback observe() để cho bản đồ “nói chuyện” với collection Meteor và biết làm thế nào để phản ứng lại dữ liệu thay đổi. Ví dụ, bạn sẽ dựa vào callback addedremoved để gọi method dropPin() hoặc removePin() của API bản đồ.

Cách tiếp cận khai báo

Meteor cung cấp cho chúng ta một cách tốt hơn: khả năng phản ứng, tức là cách tiếp cận khai báo trong phần lõi của nó. Khai báo để cho chúng ta khai báo mối quan hệ giữa các object, và biết được chúng sẽ được giữ đồng bộ, thay vì phải đặc tả cho từng trường hợp thay đổi.

Đây là một khái niệm có sức mạnh, bởi vì một hệ thống thời gian thực có nhiều đầu vào có thể bị thay đổi ở những thời điểm không đoán trước được. Bằng việc khai báo trạng thái làm thế nào để ráp HTML dựa trên bất kỳ nguồn dữ liệu tương tác lại nào chúng ta quan tâm đên, Meteor có thể đảm đương được công việc theo dõi những nguồn này và nhận trách nhiệm làm công việc hỗn độn là giữ cho giao diện người dùng được cập nhật.

Tất cả điều ở trên muốn nói rằng, thay vì việc nghĩ đến callback observe, Meteor để chúng ta viết:

<template name="postsList">
  <ul>
    {{#each posts}}
      <li>{{title}}</li>
    {{/each}}
  </ul>
</template>

Và sau đó lấy danh sách post với:

Template.postsList.helpers({
  posts: function() {
    return Posts.find();
  }
});

Phía sau màn hình, Meteor bọc lại observe() callback cho chúng ta, và vẽ lại những phần HTML thích đáng khi dữ liệu tương tác lại thay đổi.

Theo dấu phụ thuộc trong Meteor: tính toán số (computation)

Trong khi Meteor là một framework thời gian thực, tương tác lại, không phải tất cả code bên trong Meteor đều có tính chất tương tác lại. Nếu điều đó xảy ra, toàn bộ ứng dụng của bạn sẽ chạy lại mỗi khi có bất kỳ thay đổi nào. Thay vào đó, tương tác lại hạn chế tới những vùng đặc biệt trong mã code của bạn, chúng ta có thể gọi chúng là những vùng tính toán số

Nói cách khác, một tính toán số là một khối code mà được chạy mỗi khi mà có một nguồn dữ liệu tương tác lại mà nó phụ thuộc vào thay đổi. Nếu bạn có một nguồn dữ liệu tương tác lại (ví dụ, một biến Session) và muốn đáp lại một cách tương tác lại với nó, bạn cần phải thiết lập tính toán số cho nó.

Chú ý rằng thường thì bạn không cần phải làm việc này bởi vì Meteor đã cho mỗi template và helper tính toán số đặc biệt của nó (nghĩa là bạn có thể chắc rằng template sẽ phản xạ một cách tương tác tới nguồn dữ liệu của chúng)

Mỗi nguồn dữ liệu tương tác lại theo dõi tất cả tính toán số mà sử dụng nó để nó có thể biết được chúng mỗi khi giá trị mà nó sở hữu thay đổi. Để làm điều đó, nó gọi hàm invalidate() đối với tính toán số.

Tính toán số thường được thiết lập để dễ dàng đánh giá lại nội dung của nó trong invalidation, và đây là thứ xảy ra với tính toán số của template (mặc dù tính toán số của template cũng làm một số xảo thuật để thử và vẽ lại trang một cách hiệu quả hơn). Mặc dù bạn cũng có thể có thêm quyền điều khiển đối với tính toán số trong invalidation nếu bạn muốn, trong thực hành điều này hầu như luôn là cách xử lý mà bạn sẽ sử dụng.

Thiết lập một tính toán số (computation)

Bây giờ bạn đã hiểu được học thuyết phía sau tính toán số, việc thiết lập sẽ làm cho nó dễ hiểu hơn. Chúng ta sẽ dùng hàm Tracker.autorun để bao bọc một khối code tính toán và làm cho nó tương tác lại:

Meteor.startup(function() {
  Tracker.autorun(function() {
    console.log('There are ' + Posts.find().count() + ' posts');
  });
});

Chú ý rằng chúng ta phải cho khối Tracker bọc trong khối Meteor.startup() để chắc chắn rằng nó chỉ chạy duy nhất một lần khi Meteor đã kết thúc việc nạp collection Posts.

Phía sau màn hình, autorun sẽ tạo ra một tính toán số, và làm cho nó được tính lại mỗi khi nguồn dữ liệu mà nó phụ thuộc vào thay đổi. Chúng ta vừa thiết lập một tính toán số rất đơn giản đó là logs lại số lượng post ra console. Vì Posts.find() là một nguồn dữ liệu tương tác lại, nó sẽ đảm nhiệm vai trò báo cho tính toán số tính lại giá trị mỗi khi số lượng posts thay đổi.

> Posts.insert({title: 'New Post'});
There are 4 posts.

Kết quả của tất cả điều này là chúng ta có thể viết code mà dùng dữ liệu tương tác lại một cách rất tự nhiên, biết rằng ở phía sau màn hình, hệ thống phụ thuộc sẽ đảm nhiệm vai trò chạy lại mỗi khi đến thời điểm phù hợp.

Tạo Bài Viết

7

Chúng ta đã thấy được việc tạo bài viết thông qua console dễ dàng như thế nào bằng việc gọi tương tác cơ sở dữ liệu Posts.insert. Tuy nhiên chúng ta không mong người dùng mở console và tạo bài viết như vậy.

Dần dần, chúng ta cần xây dựng giao diện người dùng để gửi những bài viết mới cho ứng dụng.

Xây dựng trang tạo bài viết mới

Chúng ta bắt đầu bằng việc tạo route cho trang mới:

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.route('/', {name: 'postsList'});

Router.route('/posts/:_id', {
  name: 'postPage',
  data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/submit', {name: 'postSubmit'});

Router.onBeforeAction('dataNotFound', {only: 'postPage'});
lib/router.js

Thêm Đường Dẫn Cho Header

Với route đã được định nghĩa, chúng ta có thể thêm đường dẫn tới trang submit vào phần header:

<template name="header">
  <nav class="navbar navbar-default" role="navigation">
    <div class="container-fluid">
      <div class="navbar-header">
        <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navigation">
          <span class="sr-only">Toggle navigation</span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
        </button>
        <a class="navbar-brand" href="{{pathFor 'postsList'}}">Microscope</a>
      </div>
      <div class="collapse navbar-collapse" id="navigation">
        <ul class="nav navbar-nav">
          <li><a href="{{pathFor 'postSubmit'}}">Submit Post</a></li>
        </ul>
        <ul class="nav navbar-nav navbar-right">
          {{> loginButtons}}
        </ul>
      </div>
    </div>
  </nav>
</template>
client/templates/includes/header.html

Thiết lập route nghĩa là nếu người dùng truy cập vào URL /submit, Meteor sẽ hiển thị template postSubmit. Vì vậy hãy bắt đầu viết template:

<template name="postSubmit">
  <form class="main form">
    <div class="form-group">
      <label class="control-label" for="url">URL</label>
      <div class="controls">
          <input name="url" id="url" type="text" value="" placeholder="Your URL" class="form-control"/>
      </div>
    </div>
    <div class="form-group">
      <label class="control-label" for="title">Title</label>
      <div class="controls">
          <input name="title" id="title" type="text" value="" placeholder="Name your post" class="form-control"/>
      </div>
    </div>
    <input type="submit" value="Submit" class="btn btn-primary"/>
  </form>
</template>
client/templates/posts/post_submit.html

Chú ý: có rất nhiều markup ở đây, tuy nhiên nó đơn giản tới từ Twitter Bootstrap. Trong khi chỉ có thành phần form là cần thiết, tất cả markup khác giúp ứng dụng của chúng ta trông đẹp mắt hơn. Hiển thị trông sẽ giống như sau:

The post submit form
The post submit form

////

Tạo bài viết

Hãy gắn kết trình xử lý sự kiện (event handler) tới sự kiện submit form. Tốt nhất là chúng ta sử dụng sự kiện submit (hơn là nói rằng sự kiện click trên một button), vì nó sẽ bao hàm tất cả các cách để submit (ví dụ như bấm enter).

Template.postSubmit.events({
  'submit form': function(e) {
    e.preventDefault();

    var post = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val()
    };

    post._id = Posts.insert(post);
    Router.go('postPage', post);
  }
});
client/templates/posts/post_submit.js

Commit 7-1

Added a submit post page and linked to it in the header.

Hàm này sử dụng jQuery để phân tách giá trị (value) của nhiều trường trong form của chúng ta, và tạo ra một object post mới từ kết quả phân tách. Chúng ta cần chắc chắn là chúng ta preventDefault trên tham số event của handler để chắc chắn là trình duyệt không tự ý submit form.

Cuối cùng, chúng ta có thể định tuyến đến trang bài viết mới. Hàm insert() đối với một collection trả lại _id của object đã được chèn vào cơ sở dữ liệu, chính là cái mà hàm Router go() sẽ dùng để tạo ra URL cho chúng ta truy cập.

Kết quả đạt được là khi người dùng bấm submit, một bài viết mới được tạo ra, và người dùng sẽ ngay lập tức được đưa tới trang thảo luận cho bài viết mới đó.

Thêm một vài thiết lập an ninh

Tạo bài viết là một việc rất tốt, nhưng chúng ta không muốn để ất cả người thăm quan ngẫu nhiên làm điều đó: chúng ta muốn họ phải log in trước. Dĩ nhiên, chúng ta cũng có thể che giấu form tạo bài viết đối với người dùng đã log out. Tuy nhiên, người dùng vẫn có thể tạo bài viết trên console trình duyệt mà không cần log in, và chúng ta không muốn điều đó.

Rất biết ơn là an toàn dữ liệu được để sẵn bên trong collection Meteor; chỉ có điều là nó được tắt mặc định khi tạo project. Điều này giúp cho bạn bắt đầu một cách dễ dàng và bắt đầu xây dựng ứng dụng của bạn trong khi tạm để dành những phần nhàm chán đó về sau.

Ứng dụng của chúng ta không cần những bánh xe thực tập đó nữa, vì vậy hãy tháo bỏ chúng! Chúng ta sẽ xoá gói insecure:

meteor remove insecure
Terminal

Sau khi làm điều đó, bạn sẽ nhận ra là form bài viết không còn làm việc một cách hiệu quả. Bởi vì không có gói insecure, việc chèn bài viết vào collection posts từ phía client không được chấp nhận nữa.

Chúng ta phải hoặc là thiết lập một số luật báo cho Meteor biết khi nào client được phép chèn bài viết, hoặc là làm việc đó ở phía server.

Cho phép chèn bài viết

Để bắt đầu, chúng ta sẽ chỉ ra làm thế nào để cho phép client chèn bài viết, giúp cho form trở lại hoạt động bình thường. Chúng ta sẽ sử dụng một kỹ thuật khác, nhưng bây giờ, thứ sau đây sẽ giúp cho mọi thứ trở lại hoạt động:

Posts = new Mongo.Collection('posts');

Posts.allow({
  insert: function(userId, doc) {
    // only allow posting if you are logged in
    return !! userId;
  }
});
lib/collections/posts.js

Commit 7-2

Removed insecure, and allowed certain writes to posts.

Chúng ta gọi Posts.allow, báo cho Meteor biết là “đây là một hoàn cảnh mà client được phép thực hiện thao tác tới collection Posts”. Trong trường hợp này, chúng ta bảo rằng “client được phép chèn bài viết miễn là nó có userId”.

userId của user thực hiện việc thay đổi được chuyển qua gọi allowdeny (hoặc trả về null nếu không có user nào log in), là thứ mà hầu như lúc nào cũng có ích. Và do tài khoản người dùng được gắn với phần lõi của Meteor, chúng ta có thể tin tưởng rằng userId luôn đúng.

Chúng ta vừa thành công trong việc chắc chắn rằng bạn cần phải log in để tạo bài viết. Thử log out và tạo một bài viết; bạn sẽ thấy như sau trên console:

Insert failed: Access denied
Insert failed: Access denied

Tuy nhiên, chúng ta vẫn phải đổi mặt với một vài vấn đề:

  • Người dùng trong trạng thái log out vẫn có thể tiếp cận tạo form bài viết.
  • Bài viết không được gắn với user theo bất kỳ cách nào (và không có đoạn code nào trên server bắt buộc điều đó).
  • Nhiều bài viết có thể được tạo với cùng URL

Hãy cùng nhau sửa những vấn đề này.

Thiết lập an ninh cho truy cập form tạo bài viết mới

Chúng ta sẽ bắt đầu bằng việc ngăn người dùng log out từ việc thấy form submit bài viết. Chúng ta sẽ làm điều đó từ cấp router, bằng việc định nghĩa route hook.

Hook ngăn chặn xử lý định tuyến và thay đổi hành động mà router mặc định đảm đương. Bạn có thể nghĩ nó như là một người bảo vệ kiểm tra giấy chứng nhận trước khi để bạn vào (hoặc thoát ra).

Điều chúng ta cần làm là kiểm tra người dùng đã log in hay chưa, và nếu chưa thì đưa ra template accessDenied thay vì template postSubmit (chúng ta sẽ dừng router không cho làm bất kỳ điều gì khác). Vậy hãy thay đổi router.js như sau:

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.route('/', {name: 'postsList'});

Router.route('/posts/:_id', {
  name: 'postPage',
  data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/submit', {name: 'postSubmit'});

var requireLogin = function() {
  if (! Meteor.user()) {
    this.render('accessDenied');
  } else {
    this.next();
  }
}

Router.onBeforeAction('dataNotFound', {only: 'postPage'});
Router.onBeforeAction(requireLogin, {only: 'postSubmit'});
lib/router.js

Chúng ta cũng tạo template cho trang từ chối truy cập:

<template name="accessDenied">
  <div class="access-denied jumbotron">
    <h2>Access Denied</h2>
    <p>You can't get here! Please log in.</p>
  </div>
</template>
client/templates/includes/access_denied.html

Commit 7-3

Denied access to new posts page when not logged in.

Nếu bạn truy cập vào http://localhost:3000/submit/ mà không log in, bạn sẽ nhận được tin nhắn như bên dưới:

The access denied template
The access denied template

Điều hay về route hook là nó cũng tương tác ngược. Điều đó có nghĩa là chúng ta không cần nghĩ về việc thiết lập callback mỗi khi người dùng log in: khi tr thái log in của người dùng thay đổi, template của trang Router ngay lập tức chuyển từ accessDenied thành postSubmit mà chúng ta không cần phải viết bất kỳ đoạn code nào để xử lý việc đó (và tiện thể, điều này hoạt động ngay cả với nhiều tab trình duyệt).

Log in và thử làm mới trang. Bạn sẽ có thể nhận ra là template trang từ chối truy cập thỉnh thoảng loé hiện ra trong một khoảnh khắc trước khi trang submit xuất hiện. Lý do cho điều này là Meteor bắt đầu đưa ra template ngay khi có thể, trước khi nó kịp nói chuyện với server và kiểm tra nếu người dùng hiện tại (được lưu trữ trên bộ lưu trữ cục bộ trên trình duyệt) tồn tại hay không.

Để tránh điều này (là một vấn đề chung bạn sẽ gặp nhiều hơn nếu xử lý với những rắc rối xuất phát từ độ trễ giữa client và server). Chúng ta sẽ chỉ hiển thị một màn hình đang nạp cho khoảnh khắc ngắn mà chúng ta đợi để biết nếu như người dùng đã truy cập hay chưa.

Sau hết thì tại thời điển này, chúng ta không biết nếu như người dùng đúng là có chứng thực log-in đúng hay không, và chúng ta không thể hiển thị cả template accessDenied hoặc postSubmit cho đến khi chúng ta xác nhận được.

Bởi vậy chúng ta sẽ thay đổi hook để sử dụng template đang nạp khi mà Meteor.loggingIn() ở trạng thái đúng:

//...

var requireLogin = function() {
  if (! Meteor.user()) {
    if (Meteor.loggingIn()) {
      this.render(this.loadingTemplate);
    } else {
      this.render('accessDenied');
    }
  } else {
    this.next();
  }
}

Router.onBeforeAction('dataNotFound', {only: 'postPage'});
Router.onBeforeAction(requireLogin, {only: 'postSubmit'});
lib/router.js

Commit 7-4

Show a loading screen while waiting to login.

Giấu đường dẫn

Cách dễ dàng nhất để tránh người dùng do nhầm lẫn mà cố gắng truy cập tới trang này khi mà họ đã log out là giấu đường dẫn đó đi. Chúng ta có thể làm điều này khá dễ dàng:

//...

<ul class="nav navbar-nav">
  {{#if currentUser}}<li><a href="{{pathFor 'postSubmit'}}">Submit Post</a></li>{{/if}}
</ul>

//...
client/templates/includes/header.html

Commit 7-5

Only show submit post link if logged in.

helper currentUser được cung cấp cho chúng ta bằng gói accounts và là Spacebars tương ứng với Meteor.user(). Vì nó tương tác ngược, đường dẫn sẽ xuất hiện hoặc biến mất khi bạn log in và log out.

Meteor Method: Trừ tượng hoá tốt hơn và an toàn hơn

Chúng ta vừa thành công trong việc bảo mật lối vào tới bài viết mới từ người dùng log out, và từ chối những người dùng đó từ việc tạo bài viết ngay cả khi họ gian lận và dùng console. Tuy nhiên vẫn còn một vài thứ chúng ta phải để ý:

  • Đánh dấu thời gian của bài viết.
  • Chắc chắn rằng URL giống nhau không được tạo nhiều hơn một lần.
  • Thêm thông tin chi tiết về tác giả bài viết (ID, username,…).

Bạn có thể nghĩ rằng chúng ta có thể làm điều đó thông qua handler sự kiện submit. Thực tế là chúng ta sẽ nhanh chóng gặp phải những vấn đề sau.

  • Về nhãn thời gian, chúng ta phải dựa vào thời gian phía máy của người dùng, điều đó không luôn đúng trong mọi trường hợp.
  • Client sẽ không biết được tất cả URL được gửi tới trang. Chúng chỉ biết bài viết đang được hiển thị (chúng ta sẽ thấy điều này sau đây), vì vậy không có cách nào để cho URL đơn nhất phía client.
  • Cuối cùng, mặc dù chúng ta có thể thêm cụ thể người dùng từ phía client, chúng ta sẽ không bắt buộc được độ chính xác của nó, điều có thể dẫn đến ứng dụng của chúng ta bị khai thác hết từ người dùng console trình duyệt.

Cho những lý do đó, tốt hơn là giữ cho handler sự kiện đơn giản, và nếu chúng ta làm nhiều hơn là thao tác chèn hoặc sửa đơn giản tới collection, chúng ta sử dụng Method.

Một Meteor Method là một hàm phía server được gọi (call) bởi phía client. Chúng ta không phải là hoàn toàn không biết về nó – thực tế, phía sau màn hình, thao tác insert, updateremove của Collection đều là Method. Hãy xem làm thế nào để tạo ra Method của riêng chúng ta.

ãy cùng trở lại với post_submit.js. Thay vì chèn trực tiếp vào collection Posts, chúng ta sẽ gọi Method tên là postInsert:

Template.postSubmit.events({
  'submit form': function(e) {
    e.preventDefault();

    var post = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val()
    };

    Meteor.call('postInsert', post, function(error, result) {
      // display the error to the user and abort
      if (error)
        return alert(error.reason);

      Router.go('postPage', {_id: result._id});  
    });
  }
});
client/templates/posts/post_submit.js

Hàm Meteor.call gọi tên một Method ở tham số thứ nhất. Bạn có thể cung cấp tham số cho hàm call (trong trường hợp này, là object post chúng ta cấu tạo từ form), và cuối cùng gắn vào hàm callback, thứ sẽ chạy khi mà Method ở phía server đã thực hiện xong.

Callback của Meteor method luôn có hai tham số, errorresult. Nếu vì bất kỳ lý do gì mà error tồn tại, chúng ta sẽ thông báo alert cho người dùng (sử dụng return để huỷ bỏ callback). Nếu như mọi thứ hoạt đúng, chúng ta sẽ đổi hướng thành công người dùng sang trang thảo luận cho bài viết vừa được tạo.

Kiểm tra bảo mật

Chúng ta sẽ sử dụng cơ hội này để thêm vào một số thuộc tính bảo mật cho method bằng việc sử dụng gói audit-argument-checks.

Gói này giúp chúng ta kiểm tra object JavaScript theo một chuẩn định nghĩa trước. Trong trường hợp này, chúng ta sẽ dùng nó để kiểm tra xem người dùng gọi method đã log in hay chưa (bằng việc chắc chắn Meteor.userId() là một chuỗi String), và xem object postAttributes được gửi như là một tham số tới method bao gồm chuỗi titleurl hay không. Theo cách này, chúng ta không để cho những mảnh dữ liệu ngẫu nhiên vào cơ sở dữ liệu.

Hãy cùng định nghĩa method postInsert trong file collections/posts.js. Chúng ta sẽ xoá khối allow() từ posts.js bởi vì Meteor Methods dù sao bỏ qua nó.

Chúng ta cũng sẽ sau đó mở rộng (extend) object postAttributes với thêm ba thuộc tính: _idusername của người dùng, cũng như tem thời gian lúc gửi bài submitted trước khi chèn toàn bộ mọi thứ vào cơ sở dữ liệu và trả về kết quả _id tới client (nói cách khác, gọi ban đầu của method) bởi một object JavaScript.

Posts = new Mongo.Collection('posts');

Meteor.methods({
  postInsert: function(postAttributes) {
    check(Meteor.userId(), String);
    check(postAttributes, {
      title: String,
      url: String
    });

    var user = Meteor.user();
    var post = _.extend(postAttributes, {
      userId: user._id, 
      author: user.username, 
      submitted: new Date()
    });

    var postId = Posts.insert(post);

    return {
      _id: postId
    };
  }
});
lib/collections/posts.js

////

Commit 7-6

Use a method to submit the post.

Tạm biệt Allow/Deny

Chú ý rằng method _.extend() là một phần của thư viện Underscore, nó đơn giản giúp bạn “mở rộng” một object với những thuộc tính khác.

Nếu bạn muốn chạy một đoạn code trước mỗi lệnh insert, update hoặc remove ngay cả trên server, chúng tôi đề nghị kiểm tra gói collection-hooks.

Tránh bản sao (duplicate)

Chúng ta sẽ tạo thêm kiểm tra nữa trước khi đóng gọi method. Nếu như một bài viết cùng URL đã được tạo trước đó, chúng ta sẽ không thêm đường dẫn lần thứ hai mà sẽ hướng người dùng sang bài viết đã tồn tại.

Meteor.methods({
  postInsert: function(postAttributes) {
    check(this.userId, String);
    check(postAttributes, {
      title: String,
      url: String
    });

    var postWithSameLink = Posts.findOne({url: postAttributes.url});
    if (postWithSameLink) {
      return {
        postExists: true,
        _id: postWithSameLink._id
      }
    }

    var user = Meteor.user();
    var post = _.extend(postAttributes, {
      userId: user._id, 
      author: user.username, 
      submitted: new Date()
    });

    var postId = Posts.insert(post);

    return {
      _id: postId
    };
  }
});
collections/posts.js

Chúng ta đang tìm kiếm trên cơ sở dữ liệu của mình bất kỳ bài viết nào có cùng URL. Nếu như bất kỳ cái nào tìm được, chúng ta return _id của bài viết đó kèm với cờ postExists: true để cho client biết về tình huống này.

Và do chúng ta đã khởi động gọi return, method dừng lại tại thời điểm đó mà không chạy câu lệnh insert, do đó tránh được bản sao.

Điều còn lại là dùng thông tin postExists này để tạo mẩu tin cảnh báo tới helper sự kiện phía client:

Template.postSubmit.events({
  'submit form': function(e) {
    e.preventDefault();

    var post = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val()
    };

    Meteor.call('postInsert', post, function(error, result) {
      // display the error to the user and abort
      if (error)
        return alert(error.reason);

      // show this result but route anyway
      if (result.postExists)
        alert('This link has already been posted');

      Router.go('postPage', {_id: result._id});  
    });
  }
});
client/templates/posts/post_submit.js

Commit 7-7

Enforce post URL uniqueness.

Sắp xếp bài viết

Bây giờ chúng ta đã có ngày submit trên tất cả bài viết, và sẽ rất hợp lý nếu chúng được sắp xếp theo thuộc tính này. Để làm điều đó, chúng ta có thể sử dụng toán tử sort của Meteor. Toán tử này bao gồm object có chứa khoá sắp xếp và một dấu chỉ ra chúng được sắp xếp tăng hay giảm.

Template.postsList.helpers({
  posts: function() {
    return Posts.find({}, {sort: {submitted: -1}});
  }
});
client/templates/posts/posts_list.js

Commit 7-8

Sort posts by submitted timestamp.

Chúng ta đã làm việc nhiều một chút, nhưng cuối cùng có được một giao diện để người dùng nhập nội vào ứng dụng một cách an toàn!

Nhưng một ứng dụng cho phép tạo nội dung cũng đồng thời phải có cách để biên tập hoặc xoá nội dung. Đó sẽ là điều được bàn đến trong chương tiếp theo.

Đền Bù Độ Trễ

Sidebar 7.5

Trong chương trước, chúng tôi đã giới thiệu một khái niệm mới trong thế giới Meteor:Methods

Without latency compensation
Without latency compensation

Một Meteor Method là một cách thực thi chuỗi các lệnh một cách có cấu trúc trên phía server. Trong ví dụ của chúng ta, Method được sử dụng bởi vì chúng ta muốn chắc chắn rằng bài viết mới được gắn tag với tên tác giả và id cũng như thời gian hiện tại của server.

Tuy nhiên, nếu như Meteor thực hiện Methods theo cách cơ bản nhất, chúng ta đã có vấn đề. Xem xét chuỗi những sự kiện sau đây (chú ý: tem thời gian được tạo ngẫu nhiêu phục vụ cho mục đích minh hoạ):

  • +0ms: Người dùng bấm vào button submit và trình duyệt gọi Method.
  • +200ms: Server tạo thay đổi tới cơ sở dữ liệu Mongo.
  • +500ms: Client nhận những thay đổi này, cập nhật vào UI.

Nếu đây là cách mà Meteor được vận hành, sẽ có một khoảng thời gian lag giữa quá trình diễn ra hoạt động và việc nhìn thấy kết quả (độ lag đó nhiều hay ít tuỳ thuộc vào bạn gần hay xa server). Chúng ta không thể chấp nhận điều đó trong một hệ thống web hiện đại!

Đền bù độ trễ

With latency compensation
With latency compensation

Để tránh vấn đề này, Meteor giới thiệu một khái niệm gọi là đền bù độ trễ. Khi định nghĩa Method post, chúng ta đặt nó trong một file ở đường dẫn collections/. Điều này có nghĩa là nó khả dụng cho server và cho cả client – và nó sẽ chạy ở cả hai phía!

Khi tạo lệnh gọi Method, client gửi lệnh gọi đó lên server, nhưng đồng thời cũng mô phỏng hoạt động của Method ở phía collection client. Vì vậy tiến trình hoạt động sẽ như sau:

  • +0ms: Người dùng bấm vào button submit và trình duyệt gọi Method.
  • +0ms: Client mô phỏng hoạt động của Method đối với collection phía client và thay đổi UI phản ảnh thay đổi đó.
  • +200ms: Server tạo thay đổi tới cơ sở dữ liệu Mongo.
  • +500ms: Client nhận thay đổi đó và phục hồi lại những thay đổi từ mô phỏng, thay thế bằng thay đổi từ phía server (thứ mà hầu như giống nhau). UI thay đổi phản ánh lại.

Kết quả là người dùng thấy thay đổi ngay lập tức. Khi server trả lại hồi đáp một lúc sau đó, có thể có hoặc không một vài thay đổi khi mà dữ liệu phía server tải xuống. Một điều học được từ đó là chúng ta nên cố gắng để sao cho mô phỏng tài liệu thật gần nhất có thể.

Theo dõi đền bù độ trễ

Chúng ta có thể thay đổi một chút đối với việc gọi method post để thấy điều đó diễn ra. Để làm điều này, chúng ta sẽ dùng hàm Meteor._sleepForMs() để trì hoãn việc gọi method năm giây, nhưng (quan trọng) chỉ ở phía server.

Chúng ta sẽ sử dụng isServer để hỏi Meteor xem là Method đang được gọi ra từ client (vai trò “stub”) hoặc trên server. Stub tức là việc mô phỏng Method mà Meteor chạy trên client song song, trong khi Method “thực” đang được chạy trên server.

Vì vậy chúng ta sẽ hỏi Meteor xem đoạn có phải đoạn code đang được thực thi trên server hay không. Nếu vậy, chúng ta sẽ trì hoãn việc gọi năm giây và thêm vào chuỗi (server) vào cuối của tiêu đề bài viết. Nếu không phải server, chúng ta cũng có thể thêm vào chuỗi (client).

Posts = new Mongo.Collection('posts');

Meteor.methods({
  postInsert: function(postAttributes) {
    check(this.userId, String);
    check(postAttributes, {
      title: String,
      url: String
    });

    if (Meteor.isServer) {
      postAttributes.title += "(server)";
      // wait for 5 seconds
      Meteor._sleepForMs(5000);
    } else {
      postAttributes.title += "(client)";
    }

    var postWithSameLink = Posts.findOne({url: postAttributes.url});
    if (postWithSameLink) {
      return {
        postExists: true,
        _id: postWithSameLink._id
      }
    }

    var user = Meteor.user();
    var post = _.extend(postAttributes, {
      userId: user._id, 
      author: user.username, 
      submitted: new Date()
    });

    var postId = Posts.insert(post);

    return {
      _id: postId
    };
  }
});
collections/posts.js

Nếu chúng ta dừng lại ở đó, sự trình diễn sẽ không thực sự thuyết phục. Tại thời điểm này, nó chỉ giống như là bài viết được tạm dừng năm giây trước khi được đổi hướng tới danh sách bài viết, và không nhiều thứ khác xảy ra.

Để biết tại sao, hãy quay trở lại handler sự kiện submit bài viết:

Template.postSubmit.events({
  'submit form': function(e) {
    e.preventDefault();

    var post = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val()
    };

    Meteor.call('postInsert', post, function(error, result) {
      // display the error to the user and abort
      if (error)
        return alert(error.reason);

      // show this result but route anyway
      if (result.postExists)
        alert('This link has already been posted');

      Router.go('postPage', {_id: result._id});  
    });
  }
});
client/templates/posts/post_submit.js

Chúng ta đã đặt lệnh gọi Router.go() bên trong callback của method call. Điều đó có nghĩa là form sẽ đợi cho đến khi method hoàn tất rồi mới chuyển hướng.

Điều này thường sẽ là hành động đúng. Xét cho cùng thì bạn không thể chuyển hướng người dùng trước khi bạn biết là bài viết của họ đã hợp lệ hay không. Bởi vì sẽ rất là lộn xộn nếu như người dùng bị chuyển hướng một lần, rồi sau đó lại bị chuyển hướng lại về trang submit bài viết để sửa lại dữ liệu trong một vài giây.

Tuy nhiên cho mục đích của ví dụ này, chúng ta muốn thấy kết quả ngay lập tức. Vì vậy chúng ta sẽ thay đổi lệnh gọi route để chuyển hướng tới route postsList (chúng ta không thể chuyển hướng bài viết bởi vì chúng ta không biết _id của nó bên ngoài method), đưa nó ra khỏi callback, và chúng ta sẽ thấy điều gì xảy ra:

Template.postSubmit.events({
  'submit form': function(e) {
    e.preventDefault();

    var post = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val()
    };

    Meteor.call('postInsert', post, function(error, result) {
      // display the error to the user and abort
      if (error)
        return alert(error.reason);

      // show this result but route anyway
      if (result.postExists)
        alert('This link has already been posted');
    });

    Router.go('postsList');  

  }
});
client/templates/posts/post_submit.js

Commit 7-5-1

Demonstrate the order that posts appear using a sleep.

Nếu chúng ta tạo một bài viết bây giờ, chúng ta sẽ thấy đền bù độ trễ rõ ràng. Đầu tiên, một bài viết sẽ được chèn vào với (client) trong tiêu đề (bài viết đầu trong danh sách, kết nối tới GitHub):

Our post as first stored in the client collection
Our post as first stored in the client collection

Năm giây sau đó, nó sẽ được thay thế bằng tài liệu thực đã được chèn vào từ phía server:

Our post once the client receives the update from the server collection
Our post once the client receives the update from the server collection

Client Collection Methods

Có thể bạn đã nghĩ rằng Methods trở nên phức tạp sau điều này, nhưng thực tế chúng khá là đơn giản. Chúng ta thực sự đã thấy ba Methods rất đơn giản: Method biến đổi collection, insert, updateremove.

Khi bạn định nghĩa một collection phía server tên là 'posts', bạn hoàn toàn đang định nghĩa ba Methods: posts/insert, posts/updateposts/delete. Nói cách khác, khi bạn gọi Posts.insert() ở phía collection client, bạn đang gọi Method đền bù độ trễ mà nó làm hai việc:

  1. Kiểm tra xem nếu chúng ta có thể thực hiện sự biến đổi bằng cách gọi callback allowdeny (điều này tuy nhiên không cần thiết đối với mô phỏng).
  2. Thực sự tạo thay đổi đối với dữ liệu lưu bên dưới.

Methods Calling Methods

Nếu bạn đang theo kịp, có thể bạn đã nhận ra rằng Method post của chúng ta gọi một Method khác (posts/insert) khi chúng ta chèn bài biết. Điều này xảy ra như thế nào?

Khi mà mô phỏng (phiên bản phía client của Method) đang được chạy, chúng ta mô phỏng insert (chúng ta chèn vào collection phía client), nhưng chúng ta không làm việc gọi thực tế insert phía server, như chúng ta có thể suy ra, phiên bản phía server của post thực hiện việc này.

Bởi thế, khi mà Method post phía server gọi insert, không có sự lo lắng nào về mô phỏng, và công đoạn chèn dữ liệu diễn ra trơn tru.

Như đã làm trước đó, đừng quên phục hồi những thay đổi của bạn trước khi di chuyển sang chương tiếp theo.

Biên Tập Bài Viết

8

Bây giờ chúng ta đã có thể tạo bài viết, bước tiếp theo sẽ là biên tập và xoá chúng. Trong khi code UI để làm việc đó khá là dễ dàng, đây là thời điểm thích hợp để nói về việc làm thế nào Meteor quản lý quyền hạn.

Ban đầu hãy lắp ráp router trước. Chúng ta sẽ thêm vào route để truy cập trang biên tập bài viết và thiết lập ngữ cảnh dữ liệu cho nó.

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.route('/', {name: 'postsList'});

Router.route('/posts/:_id', {
  name: 'postPage',
  data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/posts/:_id/edit', {
  name: 'postEdit',
  data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/submit', {name: 'postSubmit'});

var requireLogin = function() {
  if (! Meteor.user()) {
    if (Meteor.loggingIn()) {
      this.render(this.loadingTemplate);
    } else {
      this.render('accessDenied');
    }
  } else {
    this.next();
  }
}

Router.onBeforeAction('dataNotFound', {only: 'postPage'});
Router.onBeforeAction(requireLogin, {only: 'postSubmit'});
lib/router.js

Template biên tập bài viết

Chúng ta bây giờ có thể tập trung vào template. Template postEdit của chúng ta sẽ khá là tiêu chuẩn:

<template name="postEdit">
  <form class="main form">
    <div class="form-group">
      <label class="control-label" for="url">URL</label>
      <div class="controls">
          <input name="url" id="url" type="text" value="{{url}}" placeholder="Your URL" class="form-control"/>
      </div>
    </div>
    <div class="form-group">
      <label class="control-label" for="title">Title</label>
      <div class="controls">
          <input name="title" id="title" type="text" value="{{title}}" placeholder="Name your post" class="form-control"/>
      </div>
    </div>
    <input type="submit" value="Submit" class="btn btn-primary submit"/>
    <hr/>
    <a class="btn btn-danger delete" href="#">Delete post</a>
  </form>
</template>
client/templates/posts/post_edit.html

Và sau đây là file post_edit.js đi kèm với nó:

Template.postEdit.events({
  'submit form': function(e) {
    e.preventDefault();

    var currentPostId = this._id;

    var postProperties = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val()
    }

    Posts.update(currentPostId, {$set: postProperties}, function(error) {
      if (error) {
        // display the error to the user
        alert(error.reason);
      } else {
        Router.go('postPage', {_id: currentPostId});
      }
    });
  },

  'click .delete': function(e) {
    e.preventDefault();

    if (confirm("Delete this post?")) {
      var currentPostId = this._id;
      Posts.remove(currentPostId);
      Router.go('postsList');
    }
  }
});
client/templates/posts/post_edit.js

Bây giờ thì phần lớn đoạn code có lẽ đã trở lên thân thuộc với bạn.

Chúng ta có hai hàm callback cho sự kiện của template: một cho sự kiện submit và một cho sự kiện click vào đường dẫn xoá.

Callback xoá khá là đơn giản: xoá bỏ sự kiện click mặc định, sau đó hỏi người dùng xác nhận. Nếu bạn nhận nó, thu được ID bài viết hiện tại từ ngữ cảnh dữ liệu của template, xoá nó, và cuối cùng đổi hướng người dùng về trang chủ (homepage).

Callback cập nhật thì dài hơn một chút, nhưng cũng không quá phức tạp hơn. Sau khi xoá bỏ sự kiện mặc định và lấy ra bài viết hiện tại, chúng ta lấy giá trị từ trường của form từ trang và lưu trữ chúng trong object postProperties.

Chúng ta sau đó gửi object tới Method Collection.update() sử dụng toán tử $set (thứ sẽ thay đổi một bộ các trường được đặc tả trong khi giữ nguyên những trường còn lại), và dùng callbac để hiển thị hoặc lỗi nếu việc update thất bại, hoặc đưa người dùng trở lại trang bài viết nếu như việc update thành công.

Thêm đường dẫn

Chúng ta cũng thêm vào đường dẫn vào bài viết để người dùng có thể truy cập được vào trang biên tập bài viết.

<template name="postItem">
  <div class="post">
    <div class="post-content">
      <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
      <p>
        submitted by {{author}}
        {{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
      </p>
    </div>
    <a href="{{pathFor 'postPage'}}" class="discuss btn btn-default">Discuss</a>
  </div>
</template>
client/templates/posts/post_item.html

Dĩ nhiên, chúng ta không muốn hiển thị đường dẫn biên tập tới bất kỳ ai khác. Đây là lúc mà helper ownPost xuất hiện:

Template.postItem.helpers({
  ownPost: function() {
    return this.userId === Meteor.userId();
  },
  domain: function() {
    var a = document.createElement('a');
    a.href = this.url;
    return a.hostname;
  }
});
client/templates/posts/post_item.js
Post edit form.
Post edit form.

Commit 8-1

Added edit posts form.

Trang biên tập form của chúng ta trông khá ổn, nhưng chúng ta không thực sự biên tập bất kỳ thứ gì bây giờ. Điều gì đang diễn ra?

Thiết lập quyền hạn

Vì chúng ta đã xoá bỏ gói insecure, tất cả thay đổi từ client đều bị từ chối.

Để thay đổi điều này, chúng ta sẽ thiết lập một vài luật về quyền hạn. Đầu tiên, tạo một file permissions.js bên trong lib. Điều này đảm bảo rằng logic quyền hạn được nạp đầu tiên (và hữu dụng đối với cả hai môi trường):

// check that the userId specified owns the documents
ownsDocument = function(userId, doc) {
  return doc && doc.userId === userId;
}
lib/permissions.js

Trong chương về Tạo bài viết, chúng ta đã loại bỏ Method allow() bởi vì chúng ta chỉ muốn chèn thêm bài viết mới từ Method phía server (thứ bỏ qua allow()).

Nhưng bây giờ vì chúng ta biên tập và xoá bài viết từ phía client, hãy quay trở lại posts.js và thêm khối allow() vào:

Posts = new Mongo.Collection('posts');

Posts.allow({
  update: function(userId, post) { return ownsDocument(userId, post); },
  remove: function(userId, post) { return ownsDocument(userId, post); },
});

//...
lib/collections/posts.js

Commit 8-2

Added basic permission to check the post’s owner.

Hạn chế biên tập

Chỉ vì bạn có thể biên tập bài viết của mình, điều đó cũng không có nghĩa là bạn nên biên tập mọi thuộc tính. Ví dụ, chúng ta không muốn người dùng có thể tạo bài viết và gán cho một ai đó khác.

Vì vậy chúng ta sẽ dùng callback deny() của Meteor để chắc chắn rằng người dùng chỉ có thể biên tập một số trường nhất định:

Posts = new Mongo.Collection('posts');

Posts.allow({
  update: function(userId, post) { return ownsDocument(userId, post); },
  remove: function(userId, post) { return ownsDocument(userId, post); },
});

Posts.deny({
  update: function(userId, post, fieldNames) {
    // may only edit the following two fields:
    return (_.without(fieldNames, 'url', 'title').length > 0);
  }
});

//...
lib/collections/posts.js

Commit 8-3

Only allow changing certain fields of posts.

Chúng ta đang nói về mảng fieldNames mà có chứa danh cách những trường đang được thay đổi, và sử dụng Method without của Underscore để trả về một mảng con chứa những trường mà không phải url hoặc title.

Nếu mọi việc bình thường, mảng đó sẽ trống không và độ dài của nó sẽ là 0. Nếu ai đó cố làm điều gì đó gượng ép, độ dài của mảng sẽ trở thành 1 hoặc lớn hơn, và callback sẽ trả về true (do đó từ chối việc cập nhật).

Bạn có thể đã nhận ra rằng không nơi nào trong đoạn code biên tập bài viết kiểm tra đường dẫn có bị trùng lặp hay . Điều này có nghĩa là một người dùng có thể submit đường dẫn và sau đó thay đổi đường dẫn của nó để vượt qua đoạn kiểm tra đó. Giải pháp cho vấn đề này có thể cũng là sử dụng Meteor method cho việc biên tập bài viết, nhưng chúng tôi sẽ dành công việc đó như một bài tập cho độc giả.

Method Calls vs điều khiển dữ liệu phía client

Để tạo bài viết, chúng ta đang sử dụng Meteor method postInsert, nhưng ngược lại khi biên tập và xoá chúng, chúng ta đang gọi updateremove trực tiếp ở phía client và hạn chế truy cập thông qua allowdeny.

Khi nào thì phù hợp để làm theo cách này hay cách kia?

Khi mà mọi thứ khá rõ ràng và bạn có thể diễn đạt một cách thoả đáng các luật thông qua allowdeny, thường thì sẽ đơn giản hơn khi mà làm trực tiếp trên client.

Tuy nhiên, ngay khi bạn bắt đầu cần phải làm những thứ bên ngoài tầm kiểm soát của người dùng (ví dụ như gắn tem thời gian cho bài viết hoặc chỉ định nó cho người dùng đúng), thường thì tốt hơn dùng Method.

Gọi Method cũng thường hợp lý hơn trong một vài hoàn cảnh:

  • Khi mà bạn muốn biết hoặc trả về giá trị dựa vào callback hơn là đợi cho tương tác ngược và đồng bộ truyền lại.
  • Cho hàm với cơ sở dữ liệu nặng, mà việc gửi dữ liệu lớn như vậy đắt giá.
  • Khi tổng hợp và tập hợp dữ liệu (ví dụ như đếm, tính trung bình, tính tổng).

Kiểm tra blog của chúng tôi cho việc khảo sát sâu hơn chủ đề này.

Cho phép và Từ chối

Sidebar 8.5

Hệ thống bảo mật của Meteor cho phép chúng ta quản lý việc sửa đổi cơ sở dữ liệu mà không cần phải định nghĩa Methods trong mọi lần muốn thay đổi.

Bởi vì chúng ta cần phải làm những công việc bổ trợ như là trang trí bài viết với những thuộc tính bổ sung và thực hiện hành động đặc biệt khi URL đã được tạo, việc sử dụng một Method đặc biệt post rất hợp lý trong trường hợp tạo một bài viết.

Tuy nhiên, chúng ta không thực sự cần tạo thêm Method mới cho việc sửa và xoá bài viết. Chúng ta chỉ cần kiểm tra xem người dùng có quyền để thực hiện những hành động này hay không, và điều này được làm dễ dàng với callback allowdeny.

Sử dụng những callback này giúp chúng ta mô tả rõ hơn về thay đổi cơ sở dữ liệu, và tuyên bố được kiểu cập nhật nào được phép sử dụng. Việc chúng được tích hợp kèm với hệ thống tài khoản (account system) là một điểm cộng nữa.

Tổ hợp callback

Chúng ta có thể định nghĩa hàm callback allow nhiều tuỳ theo nhu cầu. Chúng ta chỉ cần ít nhất một trong số chúng trả về true cho thay đổi đang diễn ra. Bởi vậy khi mà Posts.insert được gọi trên trình duyệt (kể cả gọi từ ứng dụng phía client hoặc là từ console), server sẽ lần lượt gọi bất kỳ đoạn kiểm tra allow-insert nào có thể cho tới khi tìm thấy một hàm trả về true. Nếu nó không tìm thấy thì việc insert sẽ không được thực hiện, và sẽ trả về lỗi 403 cho client.

Tương tự, chúng ta cũng có thể khai báo một hoặc nhiều callback deny. Nếu bất kỳ một trong số callback đó trả về true, thay đổi sẽ bị huỷ bỏ và 403 sẽ được trả về. Logic ở đây là, để cho một lệnh insert thành công, một hoặc nhiều callback allow insertmọi callback deny insert sẽ được thực thi.

Note: n/e stands for Not Executed
Note: n/e stands for Not Executed

Nói một cách khác, Meteor dời xuống danh sách callback bắt đầu với deny, rồi tới allow, và thực thi mỗi callback cho đến khi một trong số chúng trả về true.

Một trong những ví dụ thực hành cho mô hình này là có hai callback allow(), một kiểm tra xem bài viết có thuộc về người dùng hiện tại hay không, một kiểm tra xem người dùng hiện tại có quyền quản trị hay không. Nếu người dùng hiện tại là quản trị, điều này đảm bảo rằng họ có thể cập nhật bất kỳ bài viết nào, bởi vì một trong số callback sẽ trả về true.

Đền bù độ trễ

Hãy nhớ lại rằng những Method thay đổi cơ sở dữ liệu ( ví dụ như là .update()) đều có thể đền bù độ trễ, giống như bất kỳ Method nào khác. Ví dụ, nếu bạn thử xoá một bài viết không thuộc sở hữu của mình ở trong console dòng lệnh, bạn sẽ thấy bài viết biến mất vì collection ở local bị mất tài liệu đó, nhưng sau đó sẽ hiển thị lại do server thông báo lại rằng, thực tế tài liệu đã không bị xoá.

Tất nhiên động thái này không phải là một vấn đề khi được khởi động từ console (rốt cuộc, nếu người dùng định thử và làm rối tung dữ liệu trên console, nó thực sự không phải vấn đề của bạn đối với thứ hiện thị trên trình duyệt của họ). Tuy nhiên, bạn cần phải chắc chắn rằng điều này không xảy ra đối với giao diện người dùng. Ví dụ, bạn cần phải chịu bỏ sức để chắc chắn rằng bạn không đang hiển thị button xoá tài liệu mà người dùng không được phép xoá.

May mắn là, do bạn có thể chia sẻ code chứa quyền hạn giữa client và server (ví dụ, bạn có thể viết một hàm thư viện canDeletePost(user, post) và đặt nó trong đường dẫn chung /lib), nên làm như vậy thường sẽ không phải code lại nhiều.

Quyền hạn phía server

Xin nhớ lại rằng hệ thống quyền hạn chỉ áp dụng cho thay đổi cơ sở dữ liệu từ phía client. Ở trên server, Meteor mặc định là tất cả thao tác đều được cho phép.

Điều này có nghĩa là nếu bạn muốn viết một Method deletePost ở phía server mà có thể gọi từ client, bất kỳ ai cũng có thể xoá bất kỳ bài viết nào. Vì vậy chắc hẳn bạn không muốn vậy cho tới khi đã kiểm tra quyền hạn với Meteor trước.

Thông báo

9

Việc đơn thuần sử dụng dialog alert() của trình duyệt để cảnh báo người dùng khi có vấn đề với việc submit form thường không làm thoả mãn, và chắc chắn việc đó cũng không tạo ra UX tốt được. Chúng ta có thể làm tốt hơn.

Để thay thế, chúng ta sẽ xây dựng một cơ cấu thông báo lỗi linh hoạt hơn để thông báo cho người dùng điều gì đang diễn ra mà không phá hỏng luồng ứng dụng.

Chúng ta sẽ cài đặt một hệ thống đơn giản hiển thị lỗi ở góc trên bên phải cửa sổ, giống như ứng dụng Growl nổi tiếng của hệ điều hành Mac.

Giới thiệu về Local Collections

Để bắt đầu, chúng ta cần tạo một collection để lưu trữ lỗi. Để cho những thông báo lỗi này chỉ hữu hiệu với session hiện tại và không cần phải lưu dài hạn, chúng ta sẽ sử dụng một thứ mới, đó là tạo collection cục bộ (local collection).Điều này có nghĩa là collection Errors chỉ tồn tại trong trình duyệt và sẽ không đồng bộ ngược với server.

Để đạt được điều này, chúng ta tạo thông báo lỗi bên trong thư mục client (để cho collection chỉ tồn tại phía client), và với tên MongoDB cho collection là null (vì dữ liệu của collection sẽ không bao giờ được lưu vào cơ sở dữ liệu phía server):

// Local (client-only) collection
Errors = new Mongo.Collection(null);
client/helpers/errors.js

Bây giờ, sau khi đã tạo được collection, chúng ta có thể thêm hàm throwError để gọi khi muốn thêm thông báo lỗi. Chúng ta không cần phải lo lắng về allow hoặc deny hoặc bất kỳ vấn đề bảo mật nào, vì collection “cục bộ” đối với người dùng hiện tại.

throwError = function(message) {
  Errors.insert({message: message});
};
client/helpers/errors.js

Điểm thuận lợi để lưu thông báo lỗi với collection cục bộ là, cũng như mọi collection khác, nó tương tác lại – nghĩa là chúng ta có thể hiển thị lỗi một cách có tương tác giống như hiển thị bất kỳ dữ liệu collection nào khác.

Hiển thị lỗi

Chúng ta sẽ thêm thông báo lỗi ở phía trên cùng của layout chính:

<template name="layout">
  <div class="container">
    {{> header}}
    {{> errors}}
    <div id="main" class="row-fluid">
      {{> yield}}
    </div>
  </div>
</template>
client/templates/application/layout.html

Bây giờ hãy cùng tạo template errorserror trong errors.html:

<template name="errors">
  <div class="errors">
    {{#each errors}}
      {{> error}}
    {{/each}}
  </div>
</template>

<template name="error">
  <div class="alert alert-danger" role="alert">
    <button type="button" class="close" data-dismiss="alert">&times;</button>
    {{message}}
  </div>
</template>
client/templates/includes/errors.html

Templates ghép đôi

Bạn sẽ nhận ra rằng chúng ta đang đặt hai template vào trong cùng một file. Cho đến bây giờ, chúng ta đã bám sát quy ước “một file, một template”, nhưng Meteor vẫn hoạt động tốt dù tất cả template của chúng ta được đặt vào một file chung (mặc dù điều này sẽ tạo ra một file main.html rất lộn xộn!).

Trong trường hợp hiện tại, vì cả hai template đều khá là ngắn gọn, chúng ta có thể tạo ra ngoại lệ và để chúng vào trong cùng một file. Việc này làm cho kho chứa của chúng ta sạch sẽ hơn.

Chúng ta chỉ cần tạo helper cho template nữa là mọi thứ sẽ ổn!

Template.errors.helpers({
  errors: function() {
    return Errors.find();
  }
});
client/templates/includes/errors.js

Bạn có thể thử thông báo lỗi vừa tạo bằng tay. Bạn chỉ cần mở console trình duyệt và gõ:

throwError("I'm an error!");
Testing error messages.
Testing error messages.

Commit 9-1

Basic error reporting.

Hai loại Lỗi

Tại thời điểm này, việc phân biệt giữ lỗi “cấp độ ứng dụng” và lỗi “cấp độ code” là rất quan trọng.

Lỗi cấp độ ứng dụng thường tạo ra do hành động của người dùng, và người dùng có thể làm gì đó sau khi nó xảy ra. Những lỗi này bao gồm cả lỗi về kiểm tra form, lỗi về quyền truy cập, lỗi “không tìm thấy”, và nhiều lỗi khác nữa. Đây là những lỗi mà bạn muốn hiển thị cho người dùng để chỉ cho họ cách sửa bất kỳ vấn đề gì họ đang mắc phải.

Lỗi cấp độ code thì khác, là những lỗi xảy ra không mong muốn do sai sót khi code, và bạn thường không muốn trực tiếp hiển thị ra cho người dùng. Thay vào đó là theo dõi bằng công cụ từ bên thứ ba, ví dụ như là Kadira.

Trong chương này, chúng ta sẽ tập trung vào việc khắc phục loại lỗi thứ nhất, chứ không tập trung vào việc khắc phục lỗi sai sót do code.

Tạo thông báo lỗi

Chúng ta đã biết cách hiển thị thông báo lỗi, nhưng chúng ta vẫn cần phải kích hoạt trước khi có thể nhìn thấy. Chúng ta vừa thực hiện một bối cảnh lỗi khá tốt: cảnh báo về việc bị trùng lặp bài viết. Đơn giản hãy thay thế alert trong helper sự kiện postSubmit với hàm throwError vừa thiết lập:

Template.postSubmit.events({
  'submit form': function(e) {
    e.preventDefault();

    var post = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val()
    };

    Meteor.call('postInsert', post, function(error, result) {
      // display the error to the user and abort
      if (error)
        return throwError(error.reason);

      // show this result but route anyway
      if (result.postExists)
        throwError('This link has already been posted');

      Router.go('postPage', {_id: result._id});  
    });
  }
});
client/templates/posts/post_submit.js

Chúng ta sẽ làm điều tương tự cho sự kiện của helper postEdit:

Template.postEdit.events({
  'submit form': function(e) {
    e.preventDefault();

    var currentPostId = this._id;

    var postProperties = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val()
    }

    Posts.update(currentPostId, {$set: postProperties}, function(error) {
      if (error) {
        // display the error to the user
        throwError(error.reason);
      } else {
        Router.go('postPage', {_id: currentPostId});
      }
    });
  },
  //...
});
client/templates/posts/post_edit.js

Commit 9-2

Actually use the error reporting.

Hãy thử tạo một bài viết và thêm vào URL là http://meteor.com. Do URL này đã được gắn với một bài viết trước đó, bạn sẽ thấy:

Triggering an error
Triggering an error

Làm sạch thông báo lỗi

Bạn vừa nhận ra rằng thông báo lỗi biến mất sau khi đã xuất hiện một vài giây. Điều này thực sự là do một đoạn CSS chúng ta đã thêm vào stylesheet ngay từ lúc bắt đầu cuốn sách:

@keyframes fadeOut {
  0% {opacity: 0;}
  10% {opacity: 1;}
  90% {opacity: 1;}
  100% {opacity: 0;}
}

//...

.alert {
  animation: fadeOut 2700ms ease-in 0s 1 forwards;
  //...
}
client/stylesheets/style.css

Chúng ta đang định nghĩa một đoạn CSS animaton fadeOut để đặc tả 4 keyframe cho thuộc tính độ trong suốt (0%, 10%, 90%, và 100% của khoảng thời gian diễn animation) và áp dụng vào class .alert.

Đoạn animation sẽ chạy trong 2700 mili giây, sử dụng phương trình đo thời gian ease-in, chạy với độ trễ là 0 giây, chạy đúng một lần, và cuối cùng dừng lại ở keyframe cuối cùng khi mọi thứ đã xong xuôi.

Animations vs Animations

Có thể bạn đang tự hỏi tại sao mình lại dùng animation trên nền CSS (thứ được định nghĩa trước và ngoài khả năng kiểm soát của cúng ta), thay vì dùng animation được quản lý bởi chính bản thân Meteor.

Trong khi Meteor đúng là có cung cấp sự hỗ trợ cho việc chèn animation, chúng ta muốn chương này tập trung vào thông báo lỗi. Vì vậy chúng ta sử dụng animation CSS đơn giản và chúng ta sẽ dành những công việc trang hoàng trong chương về Animation.

Hiện tại mọi thứ đã hoạt động, nhưng nếu bạn kích hoạt nhiều lỗi (bằng việc submit cùng một đường dẫn ba lần chẳng hạn), bạn sẽ nhận ra rằng chúng bị chồng đống lên nhau:

Stack overflow.
Stack overflow.

Điều này là do trong khi thành phần .alert biến mất khi nhìn bằng mắt nhưng thực ra vẫn tồn tại trong DOM. Chúng ta cần sửa điều này.

Đây chính là một trong những tình huống mà Meteor toả sáng. Vì collection Errors tương tác lại, tất cả việc chúng ta cần làm là xoá thông báo lỗi cũ ra khỏi collection!

Chúng ta sẽ dùng hàm Meteor.setTimeout để đặc tả một hàm callback sẽ được chạy sau mỗi khoảng thời gian tạm ngừng (trong trường hợp này là 3000 mili giây).

Template.errors.helpers({
  errors: function() {
    return Errors.find();
  }
});

Template.error.rendered = function() {
  var error = this.data;
  Meteor.setTimeout(function () {
    Errors.remove(error._id);
  }, 3000);
};
client/templates/includes/errors.js

Commit 9-3

Clear errors after 3 seconds.

Hàm callback rendered kích hoạt mỗi khi template được đưa ra trình duyệt. Bên trong hàm callback, this tham chiếu tới template hiện tại, và this.data để chúng ta truy cập tới dữ liệu của object đang được đưa ra (render). Trong trường hợp của chúng ta, chính là một thông báo lỗi.

Lục tìm Kiểm tra

Cho đến bây giờ, chúng ta vẫn chưa thêm một kiểm tra (validation) nào cho form. Ít nhất thì chúng ta cũng mong muốn người dùng cung cấp cả URL và tựa đề cho bài viết mới. Vì vậy hãy cùng chắc chắn họ sẽ làm như vậy.

Chúng ta sẽ làm hai việc để chỉ ra những trường bị thiếu: thứ nhất, chúng ta sẽ đưa ra một class CSS đặc biệt has-error cho vào div cha của bất kỳ trường nào của form có vấn đề. Thứ hai, chúng ta sẽ hiển thị một thông báo lỗi hữu ích như bên dưới.

Để bắt đầu, hãy thêm vào template postSubmit những helper sau:

<template name="postSubmit">
  <form class="main form">
    <div class="form-group {{errorClass 'url'}}">
      <label class="control-label" for="url">URL</label>
      <div class="controls">
          <input name="url" id="url" type="text" value="" placeholder="Your URL" class="form-control"/>
          <span class="help-block">{{errorMessage 'url'}}</span>
      </div>
    </div>
    <div class="form-group {{errorClass 'title'}}">
      <label class="control-label" for="title">Title</label>
      <div class="controls">
          <input name="title" id="title" type="text" value="" placeholder="Name your post" class="form-control"/>
          <span class="help-block">{{errorMessage 'title'}}</span>
      </div>
    </div>
    <input type="submit" value="Submit" class="btn btn-primary"/>
  </form>
</template>
client/templates/posts/post_submit.html

Chú ý rằng chúng ta đã thêm vào tham số (theo trình tự là urltitle) cho mỗi helper. Điều này giúp chúng ta có thể dùng lại helper cho cả hai lần, thay đổi hoạt động dựa vào tham số.

Bây giờ sẽ là phần thú vị: làm cho những helper này thực sự hoạt động.

Chúng ta sẽ dùng Session để lưu object postSubmitErrors chứa bất kỳ lỗi tiềm tàng nào. Khi mà người dùng tương tác với form, object này sẽ thay đổi, nghĩa là sẽ tương tác để hiển thị lại nội dung và hình thức của form.

Trước tiên, chúng ta sẽ khởi tạo object mỗi khi template postSubmit được tạo. Điều này giúp chắc chắn là người dùng sẽ không thấy lỗi cũ tồn đọng từ trang truy cập trước đó.

Sau đó chúng ta định nghĩa hai template helper. Chúng cùng nhìn vào thuộc tính field của Session.get('postSubmitErrors') (khi mà fieldurl hoặc title tuỳ thuộc vào nơi chúng ta gọi helper).

Trong khi errorMessage chỉ đơn giản trả về bản thân tin thông báo, errorClass kiểm tra sự có mặt của tin thông báo và trả về has-error nếu có lỗi tồn tại.

Template.postSubmit.created = function() {
  Session.set('postSubmitErrors', {});
}

Template.postSubmit.helpers({
  errorMessage: function(field) {
    return Session.get('postSubmitErrors')[field];
  },
  errorClass: function (field) {
    return !!Session.get('postSubmitErrors')[field] ? 'has-error' : '';
  }
});
client/templates/posts/post_submit.js

Bạn có thể kiểm tra rằng helper của chúng ta đang hoạt động đúng bằng việc mở console trình duyệt và gõ vào như bên dưới:

Session.set('postSubmitErrors', {title: 'Warning! Intruder detected. Now releasing robo-dogs.'});
Browser console
Red alert! Red alert!
Red alert! Red alert!

Bước tiếp theo là thực sự lắp ráp Session object postSubmitErrors tới form.

Trước khi làm vậy, chúng ta sẽ tạo một hàm validatePost trong posts.js để xem trong object post, và trả về object errors mà chứa đựng bất kỳ lỗi xác đáng nào (tức là, khi mà trường title hoặc url bị thiếu):

//...

validatePost = function (post) {
  var errors = {};

  if (!post.title)
    errors.title = "Please fill in a headline";

  if (!post.url)
    errors.url =  "Please fill in a URL";

  return errors;
}

//...
lib/collections/posts.js

Chúng ta sẽ gọi hàm này từ helper sự kiện postSubmit:

Template.postSubmit.events({
  'submit form': function(e) {
    e.preventDefault();

    var post = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val()
    };

    var errors = validatePost(post);
    if (errors.title || errors.url)
      return Session.set('postSubmitErrors', errors);

    Meteor.call('postInsert', post, function(error, result) {
      // display the error to the user and abort
      if (error)
        return throwError(error.reason);

      // show this result but route anyway
      if (result.postExists)
        throwError('This link has already been posted');

      Router.go('postPage', {_id: result._id});  
    });
  }
});
client/templates/posts/post_submit.js

Chú ý rằng chúng ta đang dùng return chỉ để huỷ thực thi của helper nếu như có lỗi xảy ra mà không phải bởi vì chúng ta muốn trả về giá trị này ở chỗ nào đó.

Caught red-handed.
Caught red-handed.

Kiểm tra phía Server

Chúng ta vẫn chưa thực sự kết thúc. Chúng ta đang kiểm tra sự có mặt của URL và tựa đề ở phía client, nhưng còn về phía server thì sao? Sau tất cả, rất có thể sẽ có ai đó sẽ cố nhập vào bài viết trống bằng tay với việc gọi method postInsert thông qua console trình duyệt.

Mặc dù chúng ta không cần hiển thị thông báo lỗi nào ở phía server, chúng ta vẫn sẽ dùng cùng hàm validatePost. Ngoại trừ việc là lần này chúng ta sẽ gọi bên trong method postInsert, và không chỉ với sự kiện helper:

Meteor.methods({
  postInsert: function(postAttributes) {
    check(this.userId, String);
    check(postAttributes, {
      title: String,
      url: String
    });

    var errors = validatePost(postAttributes);
    if (errors.title || errors.url)
      throw new Meteor.Error('invalid-post', "You must set a title and URL for your post");

    var postWithSameLink = Posts.findOne({url: postAttributes.url});
    if (postWithSameLink) {
      return {
        postExists: true,
        _id: postWithSameLink._id
      }
    }

    var user = Meteor.user();
    var post = _.extend(postAttributes, {
      userId: user._id, 
      author: user.username, 
      submitted: new Date()
    });

    var postId = Posts.insert(post);

    return {
      _id: postId
    };
  }
});
lib/collections/posts.js

Xin được nhắc lại, người dùng thường không cần phải thấy thông báo “You must set a title and URL for your post”. Nó sẽ chỉ hiện ra nếu như ai đó muốn vượt qua giao diện người dùng chúng ta đã làm cẩn thận, bằng cách là sử dụng trực tiếp console.

Để kiểm tra điều này, mở cửa sổ console trình duyệt và thử gõ bài viết không có URL:

Meteor.call('postInsert', {url: '', title: 'No URL here!'});

Nếu bạn đã thực hiện công việc đầy đủ, bạn sẽ thấy được một đoạn code đáng sợ đi kèm với thông báo “You must set a title and URL for your post”.

Commit 9-4

Validate post contents on submission.

Kiểm tra lỗi khi Biên tập

Để mọi thứ hợp lý, chúng ta cũng sẽ dùng đoạn kiểm tra với việc biên tập bài viết. Mã code sẽ trông khá giống. Đầu tiên, với template:

<template name="postEdit">
  <form class="main form">
    <div class="form-group {{errorClass 'url'}}">
      <label class="control-label" for="url">URL</label>
      <div class="controls">
          <input name="url" id="url" type="text" value="{{url}}" placeholder="Your URL" class="form-control"/>
          <span class="help-block">{{errorMessage 'url'}}</span>
      </div>
    </div>
    <div class="form-group {{errorClass 'title'}}">
      <label class="control-label" for="title">Title</label>
      <div class="controls">
          <input name="title" id="title" type="text" value="{{title}}" placeholder="Name your post" class="form-control"/>
          <span class="help-block">{{errorMessage 'title'}}</span>
      </div>
    </div>
    <input type="submit" value="Submit" class="btn btn-primary submit"/>
    <hr/>
    <a class="btn btn-danger delete" href="#">Delete post</a>
  </form>
</template>
client/templates/posts/post_edit.html

Sau đó, với template helper:

Template.postEdit.created = function() {
  Session.set('postEditErrors', {});
}

Template.postEdit.helpers({
  errorMessage: function(field) {
    return Session.get('postEditErrors')[field];
  },
  errorClass: function (field) {
    return !!Session.get('postEditErrors')[field] ? 'has-error' : '';
  }
});

Template.postEdit.events({
  'submit form': function(e) {
    e.preventDefault();

    var currentPostId = this._id;

    var postProperties = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val()
    }

    var errors = validatePost(postProperties);
    if (errors.title || errors.url)
      return Session.set('postEditErrors', errors);

    Posts.update(currentPostId, {$set: postProperties}, function(error) {
      if (error) {
        // display the error to the user
        throwError(error.reason);
      } else {
        Router.go('postPage', {_id: currentPostId});
      }
    });
  },

  'click .delete': function(e) {
    e.preventDefault();

    if (confirm("Delete this post?")) {
      var currentPostId = this._id;
      Posts.remove(currentPostId);
      Router.go('postsList');
    }
  }
});
client/templates/posts/post_edit.js

Cũng giống như chúng ta đã làm cho form submit bài viết, chúng ta cũng muốn kiểm tra bài viết trên server. Bạn sẽ phải nhớ là chúng ta không dùng một method để biên tập bài viết, mà sẽ gọi update trực tiếp từ client.

Điều này có nghĩa là chúng ta sẽ cần có hàm callback deny thay thế:

//...

Posts.deny({
  update: function(userId, post, fieldNames, modifier) {
    var errors = validatePost(modifier.$set);
    return errors.title || errors.url;
  }
});

//...
lib/collections/posts.js

Chú ý rằng tham số post tham chiếu đến bài viết đang tồn tại. Trong trường hợp này, chúng ta muốn kiểm tra việc update, do đó chúng ta sẽ gọi validatePost với nội dung modifier của thuộc tính $set (như trong Posts.update({$set: {title: ..., url: ...}})).

Điều này hoạt động vì modifier.$set chứa cùng thuộc tính titleurl như toàn thể object url. Dĩ nhiên, nó không nghĩa là mọi cập nhật bộ phận chỉ ảnh hưởng tới title hoặc url sẽ thất bại, nhưng trong thực hành điều này không phải là một vấn đề.

Bạn có thể nhận ra rằng đây là callback deny thứ hai của chúng ta. Khi thêm vào nhiều callback deny, hành động sẽ thất bại nếu như bất kỳ một trong số chúng trả về true. Trong trường hợp này, điều đó có nghĩa là update sẽ thành công chỉ khi mà nó hướng tới trường titleurl, và không có trường nào trong hai trường bị trống.

Commit 9-5

Validate post contents when editing.

Tạo một Meteor Package

Sidebar 9.5

Chúng ta vừa mới tạo mô hình thông báo lỗi mà có thể tái sử dụng, vậy tại sao không đóng gói nó lại và chia sẻ với toàn bộ cộng đồng Meteor nhỉ?

Để bắt đầu, chắc chắn rằng bạn có một tài khoản phát triển của Meteor. Bạn có thể yêu cầu một tài khoản tại meteor.com, nhưng cũng có thể là bạn đã tạo sẵn khi bạn đăng ký cuốn sách này! Dù trong trường hợp nào, hãy kiểm tra tên tài khoản của bạn, vì nó sẽ được dùng trong suốt chương này.

Chúng ta sẽ sử dụng tài khoản tên là tmeasday cho chương này – bạn cũng có thể thay thế bằng tài khoản của riêng mình.

Trước hết chúng ta cần phải tạo cấu trúc cho package để lưu trú vào đó. Việc này có thể thực hiện bằng lệnh meteor create --package tmeasday:errors. Chú ý rằng Meteor sẽ tạo một thư mục tên là packages/tmeasday:errors/ với một số file bên trong. Chúng ta sẽ bắt đầu bằng việc biên tập lại file package.js. File này thông báo cho Meteor biết package nên được dùng như thế nào, và object hoặc hàm nào cần phải xuất ra.

Package.describe({
  name: "tmeasday:errors",
  summary: "A pattern to display application errors to the user",
  version: "1.0.0"
});

Package.onUse(function (api, where) {
  api.versionsFrom('0.9.0');

  api.use(['minimongo', 'mongo-livedata', 'templating'], 'client');

  api.addFiles(['errors.js', 'errors_list.html', 'errors_list.js'], 'client');

  if (api.export) 
    api.export('Errors');
});
packages/tmeasday:errors/package.js

Khi phát triển một gói package dùng cho thế giới thực, sẽ rất tốt nếu chúng ta nhập vào phần git của Package.describe với kho chứa Git URL (ví dụ như là https://github.com/tmeasday/meteor-errors.git). Bằng cách này người dùng có thể đọc mã nguồn của bạn, và (mặc định là bạn dùng GitHub) readme của gói package cũng sẽ xuất hiện trên Atmosphere.

Hãy cùng thêm ba file vào package. (Chúng ta có thể xoá bỏ bản mẫu mà Meteor đưa vào) Chúng ta có thể lấy những file này từ Microscope mà không cần phải thay đổi nhiều trừ một số thuộc tính namespacing và một vài tinh chỉnh nhỏ cho API:

Errors = {
  // Local (client-only) collection
  collection: new Mongo.Collection(null),

  throw: function(message) {
    Errors.collection.insert({message: message, seen: false})
  }
};
packages/tmeasday:errors/errors.js
<template name="meteorErrors">
  <div class="errors">
    {{#each errors}}
      {{> meteorError}}
    {{/each}}
  </div>
</template>

<template name="meteorError">
  <div class="alert alert-danger" role="alert">
    <button type="button" class="close" data-dismiss="alert">&times;</button>
    {{message}}
  </div>
</template>
packages/tmeasday:errors/errors_list.html
Template.meteorErrors.helpers({
  errors: function() {
    return Errors.collection.find();
  }
});

Template.meteorError.rendered = function() {
  var error = this.data;
  Meteor.setTimeout(function () {
    Errors.collection.remove(error._id);
  }, 3000);
};
packages/tmeasday:errors/errors_list.js

Kiểm thử gói package với Microscope

Bây giờ chúng ta sẽ thử kiểm tra cục bộ với Microscope để chắc chắn rằng code đã thay đổi vẫn hoạt động tốt. Để kết nối package với dự án của chúng ta, hãy chạy lệnh meteor add tmeasday:errors. Sau đó, chúng ta cần xoá những file tồn tại mà đã trở thành dư thừa do có package mới:

rm client/helpers/errors.js
rm client/templates/includes/errors.html
rm client/templates/includes/errors.js
removing old files on the bash console

Một điều nữa chúng ta cần phải làm là cập nhật một số thay đổi để API trở về hoạt động đúng:

  {{> header}}
  {{> meteorErrors}}
client/templates/application/layout.html
Meteor.call('postInsert', post, function(error, result) {
  if (error) {
    // display the error to the user
    Errors.throw(error.reason);

client/templates/posts/post_submit.js
Posts.update(currentPostId, {$set: postProperties}, function(error) {
  if (error) {
    // display the error to the user
    Errors.throw(error.reason);
client/templates/posts/post_edit.js

Commit 9-5-1

Created basic errors package and linked it in.

Sau khi những thay đổi đã được tạo, chúng ta sẽ có được tính năng như trước khi tạo package.

Viết mã kiểm thử

Việc đầu tiên khi phát triển một package là kiểm thử đối với ứng dụng, nhưng việc tiếp theo phải làm là viết mã kiểm thử để kiểm tra hoạt động của package.

Hãy cùng tạo một file kiểm thử dùng Tinytest để làm việc này:

Tinytest.add("Errors - collection", function(test) {
  test.equal(Errors.collection.find({}).count(), 0);

  Errors.throw('A new error!');
  test.equal(Errors.collection.find({}).count(), 1);

  Errors.collection.remove({});
});

Tinytest.addAsync("Errors - template", function(test, done) {  
  Errors.throw('A new error!');
  test.equal(Errors.collection.find({}).count(), 1);

  // render the template
  UI.insert(UI.render(Template.meteorErrors), document.body);

  Meteor.setTimeout(function() {
    test.equal(Errors.collection.find({}).count(), 0);
    done();
  }, 3500);
});
packages/tmeasday:errors/errors_tests.js

Trong đoạn kiểm thử này, chúng ta kiểm tra hoạt động cơ bản của hàm Meteor.Errors, và ngoài ra cũng kiểm tra thêm lần nữa code trong rendered của template vẫn hoạt động đúng.

Chúng ta sẽ không đi vào tất cả khía cạnh của việc viết kiểm thử cho Meteor package, (vì API vẫn chưa phải là bản cuối cùng và vẫn còn thay đổi rất nhiều), nhưng hi vọng rằng nó đủ để tìm hiểu cách thức hoạt động.

Để bảo cho Meteor biết làm thế nào để chạy kiểm thử trong package.js, sử dụng đoạn mã sau:

Package.onTest(function(api) {
  api.use('tmeasday:errors', 'client');
  api.use(['tinytest', 'test-helpers'], 'client');  

  api.addFiles('errors_tests.js', 'client');
});
packages/tmeasday:errors/package.js

Commit 9-5-2

Added tests to the package.

Sau đó chúng ta có thể chạy kiểm thử với:

meteor test-packages tmeasday:errors
Terminal
Passing all tests
Passing all tests

Release Package

Bây giờ, chúng ta sẽ release package và làm cho nó hiện hữu với toàn bộ thế giới. Chúng ta làm việc này bằng cách đẩy nó lên server package của Meteor, và cho nó vào trong Atmopshere.

May mắn thay việc đó khá đơn giản. Chúng ta chỉ cần cd tới thư mục của package, và chạy lệnh meteor publish --create:

cd packages/tmeasday:errors
meteor publish --create
Terminal

Bây giờ gói package đã được release, chúng ta có thể xoá nó ra khỏi dự án và thêm vào sau đó một cách trực tiếp:

rm -r packages/errors
meteor add tmeasday:errors
Terminal (run from the top level of the app)

Commit 9-5-4

Removed package from development tree.

Bạn sẽ thấy Meteor tải package lần đầu tiên. Chúng ta đã làm rất tốt!

Như với các chương sidebar khác, chắc chắn rằng bạn đã khôi phục lại những thay đổi trước khi đi tiếp (hoặc là chắc chắn là bạn sẽ chịu trách nhiệm xem xét chúng kỹ càng trong khi đi tiếp phần còn lại của cuốn sách).

Tính năng bình luận

10

Mục tiêu của trang tin tức mạng xã hội là để tạo một cộng đồng người dùng. Và nó sẽ trở nên khó khăn nếu như chúng ta không cung cấp một cách để mọi người giao tiếp với nhau. Vì vậy trong chương này, hãy cùng nhau thêm vào bình luận!

Chúng ta sẽ bắt đầu bằng việc tạo một collection mới để lưu bình luận vào, và cũng sẽ thêm vào một số dữ liệu cố định vào collection đó.

Comments = new Mongo.Collection('comments');
lib/collections/comments.js
// Fixture data
if (Posts.find().count() === 0) {
  var now = new Date().getTime();

  // create two users
  var tomId = Meteor.users.insert({
    profile: { name: 'Tom Coleman' }
  });
  var tom = Meteor.users.findOne(tomId);
  var sachaId = Meteor.users.insert({
    profile: { name: 'Sacha Greif' }
  });
  var sacha = Meteor.users.findOne(sachaId);

  var telescopeId = Posts.insert({
    title: 'Introducing Telescope',
    userId: sacha._id,
    author: sacha.profile.name,
    url: 'http://sachagreif.com/introducing-telescope/',
    submitted: new Date(now - 7 * 3600 * 1000)
  });

  Comments.insert({
    postId: telescopeId,
    userId: tom._id,
    author: tom.profile.name,
    submitted: new Date(now - 5 * 3600 * 1000),
    body: 'Interesting project Sacha, can I get involved?'
  });

  Comments.insert({
    postId: telescopeId,
    userId: sacha._id,
    author: sacha.profile.name,
    submitted: new Date(now - 3 * 3600 * 1000),
    body: 'You sure can Tom!'
  });

  Posts.insert({
    title: 'Meteor',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://meteor.com',
    submitted: new Date(now - 10 * 3600 * 1000)
  });

  Posts.insert({
    title: 'The Meteor Book',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://themeteorbook.com',
    submitted: new Date(now - 12 * 3600 * 1000)
  });
}
server/fixtures.js

Đừng quen việc publish và subscribe cho collection mới:

Meteor.publish('posts', function() {
  return Posts.find();
});

Meteor.publish('comments', function() {
  return Comments.find();
});
server/publications.js
Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() {
    return [Meteor.subscribe('posts'), Meteor.subscribe('comments')];
  }
});
lib/router.js

Commit 10-1

Added comments collection, pub/sub and fixtures.

Chú ý rằng để kích hoạt đoạn code này, bạn cần phải dùng meteor reset để làm sạch cơ sở dữ liệu. Sau khi đã thiết lập lại, cũng không được quên tạo một tài khoản mới và đăng nhập trở lại!

Đầu tiên, chúng ta đã tạo một vài người dùng (hoàn toàn là tài khoản giả), chèn vào cơ sở dữ liệu và sử dụng id để chọn chúng ra khỏi cơ sở dữ liệu sau đấy. Sau đó chúng ta thêm vào bình luận cho mỗi người dùng trong bài viết đầu tiên, liên kết bình luận với bài viết (bằng postId), và người dùng (bằng userId). Chúng ta cũng đã thêm ngày gửi và nội dung cho mỗi bình luận, cùng với author, một trường được chuẩn hoá.

Ngoài ra, chúng ta đã bổ xung thêm định tuyến để đợi cho mảng chứa cả những bình luận và subscription của các bài viết.

Hiển thị bình luận

Việc lưu bình luận vào cơ sở dữ liệu đã được xong xuôi, nhưng chúng ta cũng cần phải hiển thị chúng trên trang thảo luận. Hi vọng rằng quá trình này đã trở lên quen thuộc với bạn, và bạn có được ý tưởng những bước cần phải thực hiện:

<template name="postPage">
  {{> postItem}}

  <ul class="comments">
    {{#each comments}}
      {{> commentItem}}
    {{/each}}
  </ul>
</template>
client/templates/posts/post_page.html
Template.postPage.helpers({
  comments: function() {
    return Comments.find({postId: this._id});
  }
});
client/templates/posts/post_page.js

Chúng ta đặt khối {{#each comments}} bên trong template của bài viết, để cho đối tượng this là một bài viết bên trong helper comments. Để tìm ra bình luận tương ứng, chúng ta kiểm tra những bình luận được gắn với bài viết thông qua thuộc tính postId.

Bằng việc dùng những gì chúng ta đã được học về helper và Spacebar, việc dịch ra một mình luận là khá hiển nhiên. Chúng ta sẽ tạo ra một thư mục comments bên trong thư mục templates để lưu thông tin về bình luận, và một template commentItem bên trong nó:

<template name="commentItem">
  <li>
    <h4>
      <span class="author">{{author}}</span>
      <span class="date">on {{submittedText}}</span>
    </h4>
    <p>{{body}}</p>
  </li>
</template>
client/templates/comments/comment_item.html

Hãy cùng tạo một template helper để định dạng ngày submitted theo một chuẩn thân thiện hơn:

Template.commentItem.helpers({
  submittedText: function() {
    return this.submitted.toString();
  }
});
client/templates/comments/comment_item.js

Sau đó, chúng ta sẽ hiển thị số lượng bình luận cho mỗi bài viết:

<template name="postItem">
  <div class="post">
    <div class="post-content">
      <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
      <p>
        submitted by {{author}},
        <a href="{{pathFor 'postPage'}}">{{commentsCount}} comments</a>
        {{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
      </p>
    </div>
    <a href="{{pathFor 'postPage'}}" class="discuss btn btn-default">Discuss</a>
  </div>
</template>
client/templates/posts/post_item.html

Và thêm helper commentsCount vào post_item.js:

Template.postItem.helpers({
  ownPost: function() {
    return this.userId === Meteor.userId();
  },
  domain: function() {
    var a = document.createElement('a');
    a.href = this.url;
    return a.hostname;
  },
  commentsCount: function() {
    return Comments.find({postId: this._id}).count();
  }
});
client/templates/posts/post_item.js

Commit 10-2

Display comments on `postPage`.

Bây giờ bạn có thể hiển thị được những bình luận đã thêm và sẽ thấy được như sau:

Displaying comments
Displaying comments

Thêm bình luận

Hãy thêm vào một cách thức để người dùng của chúng ta có thể tạo bình luận. Công đoạn để làm điều đó khá là giống với những gì chúng ta đã làm khi tạo bài viết.

Chúng ta sẽ bắt đầu bằng việc tạo một form để thêm bình luận ở cuối mỗi bài viết:

<template name="postPage">
  {{> postItem}}

  <ul class="comments">
    {{#each comments}}
      {{> commentItem}}
    {{/each}}
  </ul>

  {{#if currentUser}}
    {{> commentSubmit}}
  {{else}}
    <p>Please log in to leave a comment.</p>
  {{/if}}
</template>
client/templates/posts/post_page.html

Và sau đó tạo template cho form bình luận:

<template name="commentSubmit">
  <form name="comment" class="comment-form form">
    <div class="form-group {{errorClass 'body'}}">
        <div class="controls">
            <label for="body">Comment on this post</label>
            <textarea name="body" id="body" class="form-control" rows="3"></textarea>
            <span class="help-block">{{errorMessage 'body'}}</span>
        </div>
    </div>
    <button type="submit" class="btn btn-primary">Add Comment</button>
  </form>
</template>
client/templates/comments/comment_submit.html

Để đăng bình luận, chúng ta sẽ gọi method comment trong comment_submit.js mà hoạt động của nó cũng giống như khi đăng bài viết:

Template.commentSubmit.created = function() {
  Session.set('commentSubmitErrors', {});
}

Template.commentSubmit.helpers({
  errorMessage: function(field) {
    return Session.get('commentSubmitErrors')[field];
  },
  errorClass: function (field) {
    return !!Session.get('commentSubmitErrors')[field] ? 'has-error' : '';
  }
});

Template.commentSubmit.events({
  'submit form': function(e, template) {
    e.preventDefault();

    var $body = $(e.target).find('[name=body]');
    var comment = {
      body: $body.val(),
      postId: template.data._id
    };

    var errors = {};
    if (! comment.body) {
      errors.body = "Please write some content";
      return Session.set('commentSubmitErrors', errors);
    }

    Meteor.call('commentInsert', comment, function(error, commentId) {
      if (error){
        throwError(error.reason);
      } else {
        $body.val('');
      }
    });
  }
});
client/templates/comments/comment_submit.js

Cũng giống như việc chúng ta đã làm với Meteor method post ở phía server, chúng ta tạo Meteor method commet để thêm bình luận, kiểm tra xem mọi thứ có hợp lệ hay không, và cuối cùng là thêm bình luận mới đó vào trong collection.

Comments = new Mongo.Collection('comments');

Meteor.methods({
  commentInsert: function(commentAttributes) {
    check(this.userId, String);
    check(commentAttributes, {
      postId: String,
      body: String
    });

    var user = Meteor.user();
    var post = Posts.findOne(commentAttributes.postId);

    if (!post)
      throw new Meteor.Error('invalid-comment', 'You must comment on a post');

    comment = _.extend(commentAttributes, {
      userId: user._id,
      author: user.username,
      submitted: new Date()
    });

    return Comments.insert(comment);
  }
});
lib/collections/comments.js

Commit 10-3

Created a form to submit comments.

Việc đang làm không có gì là trang hoàng, chúng ta chỉ kiểm tra xem người dùng có đang đăng nhập, bình luận có nội dung, và nó được liên kết tới một bài viết.

The comment submit form
The comment submit form

Điều khiển Subscription cho bình luận

Như mọi thứ đã đúng vị trí, chúng ta đang publish tất cả bình luận trên tất cả các bài viết cho tất cả client. Điều này có vẻ khá là lãng phí. Xét cho cùng, chúng ta cũng chỉ muốn dùng một tập con nhỏ dữ liệu tại một thời điểm. Vì vậy, hãy cùng cải tiến việc xuất bản và đăng theo dõi để quản lý cho đúng bình luận nào cần phải xuất bản.

Nếu chúng ta suy nghĩ về điều đó, thì lần duy nhất cần phải đăng theo dõi xuất bản của comments là khi một người dùng truy cập vào một trang đơn lẻ, và chúng ta chỉ muốn tải tập con những bình luận liên quan đến bài viết đó.

Bước đầu tiên sẽ thay đổi cách chúng ta subscribe tới bình luận. Cho tới bây giờ, chúng ta đã subscribe ở mức router, nghĩa là chúng ta tải tất cả dữ liệu một lần khi bộ định tuyến được khởi tạo.

Nhưng bây giờ chúng ta muốn việc subscribe phụ thuộc vào tham số của đường dẫn, và tham số đó hiển nhiên là sẽ thay đổi tại bất kỳ thời điểm nào. Bởi vậy chúng ta sẽ cần chuyển đoạn code subscribe từ mức router sang mức route.

Điều này có thêm một hệ quả nữa: thay vì tải dữ liệu ngay từ lúc khởi tạo ứng dụng, chúng ta sẽ tải khi mà bước vào một route. Điều này có nghĩa là sẽ có một khoảng thời gian đợi để trình duyệt tải, nhưng điều này là một điểm lùi không thể tránh khỏi trừ khi bạn định luôn luôn tải trước toàn bộ phần tử của dữ liệu.

Đầu tiên, chúng ta sẽ dừng việc tải trước tất cả bình luận trong khối configure bằng việc xoá bỏ Meteor.subscribe('comments') (nói cách khác, trở lại trạng thái chúng ta đã có trước đó):

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() {
    return Meteor.subscribe('posts');
  }
});
lib/router.js

Và chúng ta sẽ thêm vào một hàm waitOn ở mức route cho route postPage:

//...

Router.route('/posts/:_id', {
  name: 'postPage',
  waitOn: function() {
    return Meteor.subscribe('comments', this.params._id);
  },
  data: function() { return Posts.findOne(this.params._id); }
});

//...
lib/router.js

Chúng ta đang thêm this.params._id như một tham số cho việc subscribe. Vì vậy hãy dùng thông tin đó để chắc chắn rằng dữ liệu bình luận đã được bó hẹp lại chỉ trong bài viết hiện tại:

Meteor.publish('posts', function() {
  return Posts.find();
});

Meteor.publish('comments', function(postId) {
  check(postId, String);
  return Comments.find({postId: postId});
});
server/publications.js

Commit 10-4

Made a simple publication/subscription for comments.

Chỉ có một vấn đề: khi chúng ta quay lại trang chủ, nó sẽ hiển thị rằng mọi bài viết đều có 0 bình luận:

Our comments are gone!
Our comments are gone!

Đếm số lượng bình luận

Lý do cho vấn đề này khá là rõ ràng: chúng ta chỉ nạp bình luận tại route postPage, vì vậy khi chúng ta gọi tới Comments.find({postId: this._id}) trong helper commentsCount, Meteor không thể tìm ra được dữ liệu phía client cần thiết cho chúng ta.

Cách tốt nhất để giải quyết vấn đề này là bất chuẩn hoá số lượng bình luận trên bài viết (nếu bạn không chắc về nghĩa của từ này, cũng đừng lo lắng vì chúng ta sẽ làm rõ trong phần sidebar tiếp theo!). Mặc dù vậy như bạn thấy, đoạn code trở nên phức tạp hơn một chút, nhưng chúng ta được lợi ích là hiệu suất được tăng nên do không cần phải publish tất cả bình luận.

Chúng ta sẽ đạt được điều này bằng cách thêm vào thuộc tính commentsCount vào cấu trúc dữ liệu của post. Để bắt đầu, chúng ta cập nhật dữ liệu cố định bài viết (và dùng meteor reset để nạp lại – đừng quên tạo một tài khoản mới sau đó):

// Fixture data 
if (Posts.find().count() === 0) {
  var now = new Date().getTime();

  // create two users
  var tomId = Meteor.users.insert({
    profile: { name: 'Tom Coleman' }
  });
  var tom = Meteor.users.findOne(tomId);
  var sachaId = Meteor.users.insert({
    profile: { name: 'Sacha Greif' }
  });
  var sacha = Meteor.users.findOne(sachaId);

  var telescopeId = Posts.insert({
    title: 'Introducing Telescope',
    userId: sacha._id,
    author: sacha.profile.name,
    url: 'http://sachagreif.com/introducing-telescope/',
    submitted: new Date(now - 7 * 3600 * 1000),
    commentsCount: 2
  });

  Comments.insert({
    postId: telescopeId,
    userId: tom._id,
    author: tom.profile.name,
    submitted: new Date(now - 5 * 3600 * 1000),
    body: 'Interesting project Sacha, can I get involved?'
  });

  Comments.insert({
    postId: telescopeId,
    userId: sacha._id,
    author: sacha.profile.name,
    submitted: new Date(now - 3 * 3600 * 1000),
    body: 'You sure can Tom!'
  });

  Posts.insert({
    title: 'Meteor',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://meteor.com',
    submitted: new Date(now - 10 * 3600 * 1000),
    commentsCount: 0
  });

  Posts.insert({
    title: 'The Meteor Book',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://themeteorbook.com',
    submitted: new Date(now - 12 * 3600 * 1000),
    commentsCount: 0
  });
}
server/fixtures.js

Như thông thường khi cập nhật dữ liệu tĩnh của file, bạn cần phải meteor reset cơ sở dữ liệu để chắc chắn nó hoạt động.

Sau đó, chúng ta chắc chắn rằng tất cả bài viết đều bắt đầu với 0 bình luận:

//...

var post = _.extend(postAttributes, {
  userId: user._id,
  author: user.username,
  submitted: new Date(),
  commentsCount: 0
});

var postId = Posts.insert(post);

//...
collections/posts.js

Và sau đó, chúng ta cập nhật commentsCount tương ứng mỗi khi một bình luận mới được tạo, bằng việc sử dụng toán tử $inc của Mongo (nghĩa là tăng giá trị của trường thêm một):

//...

comment = _.extend(commentAttributes, {
  userId: user._id,
  author: user.username,
  submitted: new Date()
});

// update the post with the number of comments
Posts.update(comment.postId, {$inc: {commentsCount: 1}});

return Comments.insert(comment);

//...
collections/comments.js

Cuối cùng, chúng ta có thể đơn giản xoá bỏ helper commentsCount từ client/templates/posts/post_item.js vì trường đó giờ có thể nhập vào trực tiếp từ bài viết.

Commit 10-5

Denormalized the number of comments into the post.

Bây giờ người dùng đã có thể nói chuyện với nhau, và sẽ thật đáng tiếc nếu như họ bị bỏ lỡ mất bình luận của người khác. Và chương tiếp theo sẽ chỉ cho bạn cách cài đặt thông báo (notification) để ngăn chặn điều đáng tiếc đó!

Bất chuẩn hoá

Sidebar 10.5

Bất chuẩn hoá dữ liệu có nghĩa là không lưu trữ nó ở dạng “bình thường”. Nói cách khác, bất chuẩn hoá (denormalization) có nghĩa là có nhiều bản sao chép của cùng một bộ dữ liệu được cài vào.

Trong chương trước, chúng ta đã thực hiện công việc bất chuẩn hoá đối với số lượng bình luận của object bài viết nhằm mục đích tránh phải nạp lại tất cả bình luận mọi lần. Theo ý nghĩa của mô hình dữ liệu, đây là một sự dư thừa, do chúng ta hoàn toàn có thể đếm số lượng bản ghi một cách chính xác tại mọi thời điểm để tìm ra con số đó (bỏ qua vấn đề hiệu suất).

Bất chuẩn hoá thường có nghĩa là thêm việc cho người lập trình. Trong ví dụ của chúng ta, mỗi lần chúng ta thêm vào hoặc xoá bỏ một bình luận nào đó, chúng ta đồng thời phải thay đổi bài viết tương ứng để chắc chắn rằng trường commentsCount vẫn đúng. Đây chính là lý do vì sao những bộ cơ sở dữ liệu quan hệ sẽ cau mày vì cách tiếp cận này.

Tuy nhiên, cách tiếp cận truyền thống cũng có điểm trừ của nó: nếu như không có thuộc tính commentsCount, chúng ta đã phải gửi tất cả dữ liệu bình luận xuống trong mỗi lần chỉ để đếm tổng của chúng, đó chính là cách mà chúng ta đã làm ban đầu. Bất chuẩn hoá giúp chúng ta tránh được điều này.

Publish đặc biệt

Cũng sẽ có thể nếu như chúng ta tạo một bản publish riêng mà nhiệm vụ của nó chỉ là gửi đi số lượng bình luận mà chúng ta quan tâm (ví dụ như là số lượng bình luận của bài viết mà đang được hiển thị, thông qua câu query tổng hợp ở phía server).

Nhưng điều đó cũng phải được xem xét dựa trên độ phức tạp mà code publish mang lại có thực sự dễ chịu hơn so với việc tạo bất chuẩn hoá hay không.

Dĩ nhiên, việc xem xét đó cũng là một phần đặc thù của ứng dụng: Nếu như bạn viết code mà tính toàn vẹn của dữ liệu ở mức quan trọng tối cao, thì tốt hơn hết là tránh việc dữ liệu không đồng nhất sẽ quan trọng hơn nhiều so với việc chú trọng tới hiệu suất tăng thêm được.

Tài liệu lồng nhau hay là sử dụng nhiều collection

Nếu như bạn đã trải nghiệm với Mongo, bạn có thể đã ngạc nhiên khi thấy chúng ta tạo ra một collection mới chỉ cho bình luận: tại sao lại không nhúng bình luận thành một danh sách bên trong tài liệu bài viết?

Đó là vì nhiều công cụ trong Meteor làm việc tốt hơn ở cấp độ collection. Ví dụ:

  1. Helper {{#each}} làm việc rất hiệu quả khi mà lặp lại trên một con trỏ (là kết quả của collection.find()). Điều tương tự không đúng khi mà lặp trên một mảng object của một tài liệu lớn.
  2. allowdeny đều hoạt động ở mức tài liệu, và do đó sẽ dễ dàng hơn để đảm bảo mỗi thay đổi của bình luận đơn lẻ được sửa đúng mà sẽ phức tạp hơn nhiều khi làm cùng việc ở mức bài viết.
  3. DDP hoạt động thuộc tính cấp cao nhất trong tài liệu – điều này có nghĩa là nếu như commentslà một thuộc tính của post, mỗi lần bình luận được viết trên một bài viết, thì server sẽ gửi toàn bộ cập nhật của danh sách bình luận của bài viết đó tới mỗi kết nối client.
  4. và subscribe sẽ dễ dàng hơn nhiều ở mức tài liệu. Ví dụ, bạn sẽ thấy khó khăn nếu muốn phân trang bình luận của bài viết, trừ khi bình luận đó ở trên collection riêng của nó.

Mongo đề nghị dữ liệu lồng nhau để giảm thiểu chi phí cho câu truy vấn khi tìm nạp dữ liệu. Tuy nhiên, điều này sẽ ít khi là vấn đề khi dùng kiến trúc của Meteor: trong hầu hết thời gian, chúng ta truy vấn bình luận ở phía client, nơi mà việc truy cập tới cơ sở dữ liệu hầu như là miễn phí.

Điểm trừ của việc bất chuẩn hoá

Có một số quan điểm khá tốt nói rằng bạn không nên sử dụng bất chuẩn hoá cho dữ liệu. Cho một trường hợp mà tốt hơn là không nên dùng bất chuẩn hoá, chúng tôi đề xuất bạn xem qua Why You Should Never Use MongoDB được viết bởi Sarah Mei.

Tính năng thông báo

11

Bây giờ khi mà người dùng đã có thể viết bình luận trên các bài viết khác, sẽ tốt hơn nếu chúng ta để họ biết rằng một hội thoại đã được bắt đầu.

Để làm điều này, chúng ta sẽ báo cho chủ của bài viết rằng có một bình luận mới trên bài viết của họ, và cung cấp đường dẫn để hiển thị bình luận đó.

Đây là một trong những tính năng nơi mà Meteor có thể toả sáng: bởi vì Meteor mặc định xử lý thời gian thực, chúng ta sẽ có thể hiển thị những thông báo đó tức thời. Chúng ta không cần người dùng phải làm mới trang hoặc kiểm tra lại, chúng ta có thể đơn giản thêm thông báo mới vào mà không cần phải viết thêm đoạn code đặc biệt nào.

Tạo thông báo

Chúng ta sẽ tạo một thông báo mỗi khi ai đó viết bình luận vào bài viết của bạn. Trong tương lai, tính năng thông báo có thể mở rộng để đáp ứng nhiều tình huống, nhưng tạm thời nó sẽ chỉ đủ để giúp người dùng được cho biết điều gì đang diễn ra.

Hãy cùng tạo collection Notifications, và một hàm createCommentNotification mà nhiệm vụ của nó sẽ là chèn một thông báo khớp với mỗi bình luận trên mỗi bài viết của bạn.

Vì chúng ta sẽ cập nhật thông báo từ phía client, chúng ta cần phải chắc chắn rằng việc gọi allow có khả năng chống nhiễu bên ngoài. Do đó cần phải kiểm tra rằng:

  • Người dùng gọi lệnh update sở hữu thông báo được sửa đổi.
  • Người dùng chỉ cập nhật một trường đơn lẻ.
  • Trường đơn lẻ đó là một thuộc tính read của thông báo.
Notifications = new Mongo.Collection('notifications');

Notifications.allow({
  update: function(userId, doc, fieldNames) {
    return ownsDocument(userId, doc) && 
      fieldNames.length === 1 && fieldNames[0] === 'read';
  }
});

createCommentNotification = function(comment) {
  var post = Posts.findOne(comment.postId);
  if (comment.userId !== post.userId) {
    Notifications.insert({
      userId: post.userId,
      postId: post._id,
      commentId: comment._id,
      commenterName: comment.author,
      read: false
    });
  }
};
lib/collections/notifications.js

Cũng giống như bài viết hoặc bình luận, collection Notifications sẽ được chia sẻ giữa cả client và server. Vì chúng ta cần phải cập nhật thông báo mỗi khi người dùng đã nhìn thấy chúng, chúng ta cũng cần phải kích hoạt tính năng cập nhật, chắc chắn rằng chúng ta hạn chế quyền cập nhật chỉ cho phép đối với dữ liệu của người dùng đó.

Chúng ta cũng vừa tạo một hàm đơn giản theo dõi bài viết người dùng đang tạo bình luận, khám phá ra xem ai sẽ nhận được thông báo, và thêm bản ghi thông báo mới.

Chúng ta đã tạo bình luận với Method phía server, nên chúng ta chỉ cần bổ sung Method đó để gọi hàm cần thiết. Chúng ta sẽ thay đổi return Comments.insert(comment); bằng comment._id = Comments.insert(comment) để lưu _id của bình luận mới được tạo vào biến số , sau đó gọi hàm createCommentNotification của chúng ta:

Comments = new Mongo.Collection('comments');

Meteor.methods({
  commentInsert: function(commentAttributes) {

    //...

    comment = _.extend(commentAttributes, {
      userId: user._id,
      author: user.username,
      submitted: new Date()
    });

    // update the post with the number of comments
    Posts.update(comment.postId, {$inc: {commentsCount: 1}});

    // create the comment, save the id
    comment._id = Comments.insert(comment);

    // now create a notification, informing the user that there's been a comment
    createCommentNotification(comment);

    return comment._id;
  }
});
lib/collections/comments.js

Hãy cùng publish thông báo:

Meteor.publish('posts', function() {
  return Posts.find();
});

Meteor.publish('comments', function(postId) {
  check(postId, String);
  return Comments.find({postId: postId});
});

Meteor.publish('notifications', function() {
  return Notifications.find();
});
server/publications.js

Và subscribe phía client:

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() {
    return [Meteor.subscribe('posts'), Meteor.subscribe('notifications')]
  }
});
lib/router.js

Commit 11-1

Added basic notifications collection.

Hiển thị thông báo

Bây giờ chúng ta có thể tiếp tục và thêm một danh sách thông báo vào phần header.

<template name="header">
  <nav class="navbar navbar-default" role="navigation">
    <div class="container-fluid">
      <div class="navbar-header">
        <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navigation">
          <span class="sr-only">Toggle navigation</span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
        </button>
        <a class="navbar-brand" href="{{pathFor 'postsList'}}">Microscope</a>
      </div>
      <div class="collapse navbar-collapse" id="navigation">
        <ul class="nav navbar-nav">
          {{#if currentUser}}
            <li>
              <a href="{{pathFor 'postSubmit'}}">Submit Post</a>
            </li>
            <li class="dropdown">
              {{> notifications}}
            </li>
          {{/if}}
        </ul>
        <ul class="nav navbar-nav navbar-right">
          {{> loginButtons}}
        </ul>
      </div>
    </div>
  </nav>
</template>
client/templates/includes/header.html

Và tạo template notificationsnotificationItem (chúng dùng chung cùng một file đơn lẻ là notifications.html)

<template name="notifications">
  <a href="#" class="dropdown-toggle" data-toggle="dropdown">
    Notifications
    {{#if notificationCount}}
      <span class="badge badge-inverse">{{notificationCount}}</span>
    {{/if}}
    <b class="caret"></b>
  </a>
  <ul class="notification dropdown-menu">
    {{#if notificationCount}}
      {{#each notifications}}
        {{> notificationItem}}
      {{/each}}
    {{else}}
      <li><span>No Notifications</span></li>
    {{/if}}
  </ul>
</template>

<template name="notificationItem">
  <li>
    <a href="{{notificationPostPath}}">
      <strong>{{commenterName}}</strong> commented on your post
    </a>
  </li>
</template>
client/templates/notifications/notifications.html

Kế hoạch là để cho mỗi thông báo chứa một đường dẫn tới bài viết được viết bình luận, và tên của người dùng đã viết bình luận.

Tiếp đó, chúng ta cần chắc chắn rằng đã chọn đúng danh sách thông báo trong helper, và cập nhật những thông báo đó thành “read” (đã đọc) khi mà người dùng bấm vào đường dẫn đó.

Template.notifications.helpers({
  notifications: function() {
    return Notifications.find({userId: Meteor.userId(), read: false});
  },
  notificationCount: function(){
    return Notifications.find({userId: Meteor.userId(), read: false}).count();
  }
});

Template.notificationItem.helpers({
  notificationPostPath: function() {
    return Router.routes.postPage.path({_id: this.postId});
  }
});

Template.notificationItem.events({
  'click a': function() {
    Notifications.update(this._id, {$set: {read: true}});
  }
});
client/templates/notifications/notifications.js

Commit 11-2

Display notifications in the header.

Bạn có thể nghĩ rằng hệ thống thông báo không khác nhiều so với hệ thống hiển thị lỗi, và thực tế là cấu trúc của chúng rất giống nhau. Chỉ có một điểm khác biệt: chúng ta đã vừa tạo một collection đồng bộ thích đáng client-server. Điều này có nghĩa là hệ thống thông báo của chúng ta ổn định, và miễn là chúng ta dùng cùng một tài khoản, nó sẽ tồn tài giữa các lần làm mới trình duyệt và giữa các thiết bị khác nhau.

Hãy thử điều đó: mở một trình duyệt thứ hai (ví dụ như là từ Firefox), tạo một tài khoản người dùng, và viết bình luận vào bài viết bạn đã tạo với tài khoản chính thứ nhất (cái mà bạn đang mở với Chrome). Bạn sẽ thấy thứ gì đó như sau:

Displaying notifications.
Displaying notifications.

Quản lý truy cập tới thông báo

Chức năng thông báo đã hoạt động tốt. Tuy nhiên vẫn còn một vấn đề nhỏ: hệ thống thông báo của chúng ta đang ở chế độ công khai.

Nếu bạn vẫn đang để trình duyệt thứ hai ở trạng thái mở, cố chạy đoạn code sau ở phía console trình duyệt:

 Notifications.find().count();
1
Browser console

Tài khoản mới này (là người đã viết bình luận) không nên thấy bất kỳ thông báo nào. Thông báo mà họ nhìn thấy trong collection Notifications thực tế thuộc về người dùng đầu tiên của chúng ta.

Ngoài vấn đề quyền riêng tư, chúng ta cũng không thể nào đủ sức để có dữ liệu thông báo nạp trên tất cả trình duyệt của người dùng. Trên một trang đủ lớn, điều này có thể dẫn đến vấn đề quá tải bộ nhớ và tạo ra những vấn đề nghiêm trọng về hiệu suất.

Chúng ta sẽ giải quyết bài toán này với publish. Chúng ta có thể dùng việc publish để đặc tả chính xác phần nào của collection sẽ được chia sẻ với các trình duyệt.

Để hoàn thành điều này, chúng ta cần phải trả về một con trỏ khác trong phần publish so với là Notifications.find() như hiện tại. Là, chúng ta muốn trả về một con trỏ tương ứng với thông báo hiện tại của người dùng.

Làm như vậy khá là hiển nhiên, vì hàm publish có được _id của người dùng hiện tại với this.userId:

Meteor.publish('notifications', function() {
  return Notifications.find({userId: this.userId, read: false});
});
server/publications.js

Commit 11-3

Only sync notifications that are relevant to the user.

Bây giờ, nếu kiểm tra hai cửa sổ trình duyệt, bạn sẽ thấy hai collection thông báo khác nhau:

 Notifications.find().count();
1
Browser console (user 1)
 Notifications.find().count();
0
Browser console (user 2)

Thực tế, danh sách thông báo nên được thay đổi khi bạn đăng nhập vào ứng dụng và đăng xuất khỏi ứng dụng. Điều này là do publish tự động thay đổi và xuất bản lại mỗi khi tài khoản của người dùng thay đổi.

Ứng dụng của chúng ta ngày càng trở nên chức năng hơn, và do ngày càng nhiều người dùng tham gia và bắt đầu viết bài nên chúng ta sẽ có nguy cơ trang chủ quá dài. Chúng ta sẽ bàn về vấn đề này ở chương tiếp theo với việc thực hiện phân trang.

Tương tác lại nâng cao

Sidebar 11.5

Hiếm khi bạn phải tự viết phụ thuộc để theo dấu code của mình, nhưng sẽ rất hữu ích nếu chúng ta hiểu được để điều tra được cách thức hoạt động của luồng xử lý phụ thuộc.

Tưởng tượng rằng chúng ta muốn theo dõi xem có bao nhiêu bạn Facebook của người dùng hiện tại đã “thích” mỗi bài viết trên Microscope. Hãy giả sử rằng chúng ta đã làm việc cụ thể về cách thức xác thực tài khoản người dùng với Facebook, tạo lệnh gọi API tương thích, và đã phân tách được dữ liệu tương ứng. Bây giờ chúng ta có một hàm bất đồng bộ bên phía client trả về số lượng thích là getFacebookLikeCount(user, url, callback).

Điều cần phải lưu ý về hàm này là nó rất không tương tác lại và cũng không theo thời gian thực. Nó gọi một yêu cầu HTTP tới Facebook, nhưng hàm này sẽ không chạy lại bởi chính nó mỗi khi biến đếm thay đổi ở phía Facebook, và UI của chúng ta sẽ không thay đổi trong khi dữ liệu bên dưới thì có.

Để điều chỉnh điều này, chúng ta có thể bắt đầu dùng setInterval để gọi tới hàm của chúng ta với mỗi vài giây:

currentLikeCount = 0;
Meteor.setInterval(function() {
  var postId;
  if (Meteor.user() && postId = Session.get('currentPostId')) {
    getFacebookLikeCount(Meteor.user(), Posts.find(postId).url, 
      function(err, count) {
        if (!err)
          currentLikeCount = count;
      });
  }
}, 5 * 1000);

Mỗi lần kiểm tra biến currentLikeCount, chúng ta có thể kỳ vọng sẽ nhận được con số đúng với sai sót độ biên năm giây. Chúng ta có thể dùng biến này trong helper như sau:

Template.postItem.likeCount = function() {
  return currentLikeCount;
}

Tuy nhiên, vẫn chưa có gì bảo cho template của chúng ta vẽ lại mỗi khi currentLikeCount thay đổi. Mặc dù hiện tại biến số được giả lập thời gian thực và sẽ thay đổi bởi chính bản thân nó, nó vẫn không tương tác ngược nên không thể giao tiếp một cách chính xác với hệ thống Meteor.

Theo dấu tương tác lại: Sự tính toán

Tương tác ngược của Meteor được thực hiện trung gian thông qua phụ thuộc, là cấu trúc dữ liệu theo dấu một bộ tính toán.

Như chúng ta đã thấy trong chương sidebar trước đó về tương tác lại, một sự tính toán là một bộ phận code mà dùng dữ liệu tương tác ngược. Trong trường hợp này, có một sự tính toán được tạo ngầm cho template postItem, và mỗi helper trên trình quản lý của template cũng có sự tính toán của riêng nó.

Bạn có thể suy nghĩ một tính toán là một phần code “đảm nhiệm” về dữ liệu tương tác lại. Khi dữ liệu thay đổi, chính là thao tác tính toán này được thông báo (qua invalidate()), và cũng chính là sự tính toán quyết định xem cái gì cần phải được hoàn thành.

Chuyển một biến số sang một hàm tương tác lại

Để chuyển biến số currentLikeCount sang một nguồn dữ liệu tương tác lại, chúng ta cần phải theo dấu tất cả sự tính toán đã sử dụng nó trong một phụ thuộc. Điều này cần thay đổi nó từ một biến số sang một hàm số (là thứ sẽ trả về giá trị):

var _currentLikeCount = 0;
var _currentLikeCountListeners = new Tracker.Dependency();

currentLikeCount = function() {
  _currentLikeCountListeners.depend();
  return _currentLikeCount;
}

Meteor.setInterval(function() {
  var postId;
  if (Meteor.user() && postId = Session.get('currentPostId')) {
    getFacebookLikeCount(Meteor.user(), Posts.find(postId), 
      function(err, count) {
        if (!err && count !== _currentLikeCount) {
          _currentLikeCount = count;
          _currentLikeCountListeners.changed();
        }
      });
  }
}, 5 * 1000);

Chúng ta vừa thực hiện thiết lập một phụ thuộc là _currentLikeCountListeners, nó sẽ theo dõi tất cả sự tính toán mà currentLikeCount() đã sử dụng. Mỗi khi giá trị của currentLikeCount thay đổi, chúng ta gọi tới hàm changed() trên phụ thuộc đó, thứ sẽ làm mất hiệu lực tất cả tính toán đã được theo dõi.

Những tính toàn này sau đó có thể di chuyển tiếp và xử lý thay đổi cho từng nền tảng trường hợp.

Nếu như bạn thấy rằng đoạn code ở trên quá nhiều bản mẫu cho một nguồn dữ liệu tương tác lại đơn giản, bạn đã đúng, và Meteor có cung cấp một vài công cụ làm sẵn để cho việc này trở nên đơn giản hơn (giống như là bạn không cần phải dùng đến tính toán trực tiếp, mà bạn sẽ thường dùng autoruns). Có một gói nền tảng là reactive-var làm chính xác những gì mà hàm currentLikeCount() của chúng ta đã làm. Nếu chúng ta thêm nó vào:

meteor add reactive-var

Thì chúng ta có thể đơn giản hoá code như sau:

var currentLikeCount = new ReactiveVar();

Meteor.setInterval(function() {
  var postId;
  if (Meteor.user() && postId = Session.get('currentPostId')) {
    getFacebookLikeCount(Meteor.user(), Posts.find(postId), 
      function(err, count) {
        if (!err) {
          currentLikeCount.set(count);
        }
      });
  }
}, 5 * 1000);

Bây giờ để dùng nó, chúng ta sẽ gọi currentLikeCount.get() trong helper và mọi thứ sẽ hoạt động như trước. Chúng ta cũng có thể sử dụng một gói hệ thống nữa tên là reactive-dict, cung cấp lưu trữ tương tác ngược dạng khoá-giá trị (hầu như tác dụng giống với Session).

So sánh Tracker với Angular

Angular là một thư viện được phát triển bởi đội ngũ Google, tương tác lại chỉ ở phía client. Việc so sánh giữa cách tiếp cận của Meteor tới việc theo dõi phụ thuộc với cách của Angular chỉ mang tính minh hoạ, vì phương pháp của hai thứ khá là khác nhau.

Như chúng ta đã thấy thì mô hình Meteor dùng khối code gọi là khối tính toán. Những tính toán này được theo dõi bởi (hàm) nguồn dữ liệu tác lại đặc biệt mà có nhiệm vụ làm mất hiệu lực chúng khi cần thiết. Vì vậy nguồn dữ liệu thông báo một cách rõ ràng cho tất cả phụ thuộc của nó mỗi khi chúng gọi tới invalidate(). Chú ý rằng mặc dù điều này thường là khi dữ liệu đã thay đổi, nguồn dữ liệu cũng có thể tạm thời quyết định kích hoạt việc làm mất hiệu lực này cho những lý do khác.

Thêm vào đó, mặc dù tính toán thường được chạy lại mỗi khi việc làm mất hiệu lực đã diễn ra, bạn cũng có thể thiết lập chúng để hoạt động theo ý muốn. Tất cả điều này giúp chúng ta có sự quản lý cấp cao đối với việc tương tác lại.

Về phía Angular, việc tương tác lại được làm trung gian thông qua phạm vi (scope) của object. Một phạm vi có thể được nghĩ như là một object JavaScript với một vài hàm đặc biệt.

Khi bạn muốn tương tác lại phụ thuộc vào giá trị của một phạm vi, bạn gọi tới scope.$watch, nói lên rằng bạn quan tâm đến (ví dụ phần nào của phạm vi mà bạn cần dùng tới) và một hàm listener sẽ chạy mỗi khi biểu diễn đó thay đổi. Vì vậy bạn nói rõ ràng và chính xác điều gì bạn muốn làm mỗi khi biểu diễn đó thay đổi.

Quay trở lại ví dụ về Facebook của chúng ta, chúng ta sẽ viết:

$rootScope.$watch('currentLikeCount', function(likeCount) {
  console.log('Current like count is ' + likeCount);
});

Dĩ nhiên, cũng như bạn hiếm khi thực hiện tính toán trong Meteor, bạn cũng sẽ không thường gọi $watch trực tiếp trong Angular vì ng-model{{expressions}} tự động thiết lập theo dõi và đảm nhiệm việc tạo lại khi dữ liệu thay đổi.

Mỗi khi giá trị tương tác lại đó thay đổi, scope.$apply() sẽ phải được gọi. Điều này ước lượng lại mỗi watcher của phạm vi, nhưng chỉ gọi hàm listener của watcher mà giá trị của biển thức đó đã thay đổi.

Vì vậy scope.$apply() cũng tương tự như dependency.changed(), ngoại trừ việc nó thực hiện tại cấp bậc phạm vi, thay vì cho bạn quyền điều khiển chính xác listener nào nên được ước tính giá trị lại. Điều này có nghĩa là, việc thiếu một chút quyền điều khiển này giúp Angular có khả năng thông minh và hiệu quả trong việc quyết định chính xác listener nào cần phải được ước tính lại.

Nếu dùng Angular, code cho hàm getFacebookLikeCount() sẽ tương tự như sau:

Meteor.setInterval(function() {
  getFacebookLikeCount(Meteor.user(), Posts.find(postId), 
    function(err, count) {
      if (!err) {
        $rootScope.currentLikeCount = count;
        $rootScope.$apply();
      }
    });
}, 5 * 1000);

Phải thừa nhận rằng, Meteor đảm nhiệm hầu hết những phần nặng nề cho chúng ta và để chúng ta có được lợi ích từ tương tác lại mà không cần quá nhiều công việc phải làm. Nhưng cũng hi vọng, việc học sâu về những khuôn mẫu này sẽ giúp ích cho bạn trong việc đưa dự án tiến xa hơn.

Phân trang

12

Mọi thứ đang diễn ra tuyệt vời với Microscope, và chúng ta có thể mong chờ thu nhận khả quan khi phát hành sản phẩm ra thế giới.

Vì vậy chúng ta cũng nên suy nghĩ một chút về hệ quả hiệu suất của số lượng bài viết được nhập vào trang web khi nó cất cánh!

Chúng ta đã nói trước đó làm thế nào collection phía client nên chứa tập con của dữ liệu trên server, và chúng ta cũng đã thành công trong việc dùng nó cho collection thông báo và bình luận.

Tại thời điểm hiện tại, tuy nhiên chúng ta vẫn đang publish tất cả bài viết cùng một lúc, tới tất cả kết nối từ người dùng. Dần dần, sẽ có hàng nghìn đường dẫn được tạo, và điều đó sẽ trở thành vấn đề. Để giải quyết nó, chúng ta cần phải phân trang cho bài viết.

Thêm bài viết nữa

Đầu tiên, đối với dữ liệu tĩnh của chúng ta, hãy nạp đủ số bài viết để việc phân trang thực sự có ý nghĩa:

// Fixture data 
if (Posts.find().count() === 0) {

  //...

  Posts.insert({
    title: 'The Meteor Book',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://themeteorbook.com',
    submitted: new Date(now - 12 * 3600 * 1000),
    commentsCount: 0
  });

  for (var i = 0; i < 10; i++) {
    Posts.insert({
      title: 'Test post #' + i,
      author: sacha.profile.name,
      userId: sacha._id,
      url: 'http://google.com/?q=test-' + i,
      submitted: new Date(now - i * 3600 * 1000),
      commentsCount: 0
    });
  }
}
server/fixtures.js

Sau khi chạy meteor reset và bắt đầu ứng dụng thêm một lần nữa, bạn sẽ thấy thứ gì đó như sau:

Displaying dummy data.
Displaying dummy data.

Commit 12-1

Added enough posts that pagination is necessary.

Phân trang vô hạn

Chúng ta sẽ bắt đầu việc phân trang theo phong cách “vô hạn”. Điều chúng ta sẽ làm là ban đầu hiển thị, chẳng hạn 10 bài viết trên màn hình, với một đường dẫn “load more” (nạp thêm) được gắn ở cuối trang. Khi bấm vào đường dẫn này, 10 bài viết sẽ hiển thị thêm trên danh sách và tiếp tục mãi mãi như vậy. Điều này có nghĩa là chúng ta có thể quản lý hệ thống phân trang bằng một tham số đơn biểu diễn số lượng bài viết để hiển thị trên màn hình.

Bây giờ chúng ta cần một cách để thông báo cho server biết được về tham số này để có thể biết được bao nhiêu bài viết cần gửi tới client. Nó xảy ra vì chúng ta đã subscribe tới publish posts trên router, vì vậy chúng ta sẽ lợi dụng điều này và để router quản lý việc phân trang.

Cách dễ nhất để làm điều này là đơn giản để tham số hạn chế số lượng bài viết trên đường dẫn, URLs khi đó sẽ có dạng là http://localhost:3000/25. Một điểm cộng nữa cho việc dùng URL như vậy là nếu bạn đang hiển thị 25 bài viết và do sơ suất bị nạp lại trình duyệt thì bạn vẫn sẽ nhìn thấy 25 bài viết khi mà trang đã được nạp xong.

Để làm được điều này một cách đúng đắn, chúng ta cần thay đổi cách subscribe tới bài viết. Cũng giống như cách chúng ta đã làm đối với chương Tạo bình luận, chúng ta sẽ cần phải di chuyển code cho việc subscribe từ router sang mức route.

Điều này có thể khá là khó hiểu khi nói cùng một lúc, nhưng sẽ trở nên rõ ràng hơn với đoạn code.

Đầu tiên, chúng ta dừng việc subscribe tới publish posts ở trong khối Router.configure(). Hãy đơn giản xoá Meteor.subscribe('posts'), để lại chỉ subscribe về notifications:

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() { 
    return [Meteor.subscribe('notifications')]
  }
});
lib/router.js

Chúng ta sẽ thêm vào một tham số postsLimitcho đường dẫn của route. Thêm vào ? sau tên của tham số nghĩa là nó không bắt buộc. Điều đó có nghĩa là route của chúng ta sẽ hợp lệ không chỉ với http://localhost:3000/50, mà còn với cả http://localhost:3000.

//...

Router.route('/:postsLimit?', {
  name: 'postsList',
});

//...
lib/router.js

Rất quan trọng phải chú ý rằng một đường dẫn với dạng /:parameter? sẽ tổ hợp với tất cả đường dẫn có thể. Vì mỗi route sẽ phân tách lần lượt để xem nó có khớp với đường dẫn hiện tại hay không, chúng ta cần phải chắc chắn rằng chúng ta tổ chức route theo thứ tự đặc trưng riêng giảm dần

Nói cách khác, route mà mục đích đặc trưng hơn ví dụ như là /posts/:_id nên xuất hiện trước, và route tới postsList nên được đưa xuống vị trí cuối cùng của nhóm route vì nó thường khớp với mọi trường hợp.

Bây giờ là lúc chúng ta ứng phó với vấn đề subscribe và tìm kiếm dữ liệu thích hợp. Chúng ta cần phải giải quyết vấn đề khi mà tham số postsLimit không xuất hiện, nên chúng ta sẽ gán nó với một giá trị mặc định. Chúng ta sẽ dùng “5” để thực sự có đủ phòng hiển thị phân trang.

//...

Router.route('/:postsLimit?', {
  name: 'postsList',
  waitOn: function() {
    var limit = parseInt(this.params.postsLimit) || 5; 
    return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: limit});
  }
});

//...
lib/router.js

Bạn sẽ nhận ra rằng chúng ta đang đưa ra một object Javascript ({sort: {submitted: -1}, limit: postsLimit}) kèm với việc publish posts. Object này được dùng như là tham số options cho câu khai báo phía server Posts.find(). Hãy cùng chuyển sang code phía server để thi hành điều này:

Meteor.publish('posts', function(options) {
  check(options, {
    sort: Object,
    limit: Number
  });
  return Posts.find({}, options);
});

Meteor.publish('comments', function(postId) {
  check(postId, String);
  return Comments.find({postId: postId});
});

Meteor.publish('notifications', function() {
  return Notifications.find({userId: this.userId});
});
server/publications.js

Gửi thông báo

Đoạn code publish của chúng ta có kết quả thông báo cho server rằng nó có thể tin tưởng vào bất kỳ object JavaScript nào gửi từ phía client (trong trường hợp của chúng ta, {limit: postsLimit}) để phục vụ như là options của câu lệnh find(). Điều này làm cho người dùng có thể submit bất kỳ thông tin thêm nào họ muốn thông qua console trình duyệt.

Trong trường hợp của chúng ta, điều này là vô hại, vì tất cả người dùng có thể làm là sắp xếp lại bài viết một cách khác biệt, hoặc là thay đổi giới hạn (thứ chúng ta muốn có thể thay đổi được từ đầu). Mặc dù trong ứng dụng thực tế, có thể cũng cần phải hạn chế chính tham số hạn chế đó!

May mắn là bằng việc dùng check() chúng ta biết được người dùng không thể lén đưa thêm tuỳ chọn vào (ví dụ fields, thứ trong vài trường hợp có thể phơi bày ra dữ liệu cá nhân của tài liệu).

Dù vậy, một kiểu mẫu bảo mật hơn cũng có thể gửi từng tham số riêng lẻ thay vì cả object, để chắc chắn rằng bạn vẫn giữ quyền kiểm soát dữ liệu của mình:

Meteor.publish('posts', function(sort, limit) {
  return Posts.find({}, {sort: sort, limit: limit});
});

Bây giờ khi mà chúng ta đã subscribe ở mức route, nó sẽ hợp lý hơn nếu chúng ta thiết lập văn cảnh dữ liệu trong cùng một chỗ. Chúng ta sẽ đi chệch so với kiểu mẫu trước đó và tạo hàm data trả về object JavaScript thay vì đơn giản trả về một con trỏ. Điều này giúp chúng ta tạo ra một văn cảnh dữ liệu có tên, mà sẽ được gọi là posts.

Điều này có nghĩa là đơn giản thay vì hoàn toàn khả dụng với this ở trong template, bối cảnh dữ liệu của chúng ta sẽ khả dụng ở posts. Xa rời thành phần nhỏ này, đoạn code của chúng ta trông khá quen thuộc:

//...

Router.route('/:postsLimit?', {
  name: 'postsList',
  waitOn: function() {
    var limit = parseInt(this.params.postsLimit) || 5; 
    return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: limit});
  },
  data: function() {
    var limit = parseInt(this.params.postsLimit) || 5; 
    return {
      posts: Posts.find({}, {sort: {submitted: -1}, limit: limit})
    };
  }
});

//...
lib/router.js

Và vì chúng ta đang thiết lập bối cảnh dữ liệu ở mức route, chúng ta có thể thoát khỏi một cách an toàn từ helper của template posts bên trong fiel posts_list.js và chỉ đơn giản xoá nội dung của file.

Chúng ta đặt tên cho bối cảnh dữ liệu là posts (giống với tên helper), vì vậy chúng ta không cần phải tiếp xúc với template postList!

Hãy cùng nắp chúng lại. Sau đây là code của file router.js sau khi chúng ta đã làm mới và cải tiến:

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() { 
    return [Meteor.subscribe('notifications')]
  }
});

Router.route('/posts/:_id', {
  name: 'postPage',
  waitOn: function() {
    return Meteor.subscribe('comments', this.params._id);
  },
  data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/posts/:_id/edit', {
  name: 'postEdit',
  data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/submit', {name: 'postSubmit'});

Router.route('/:postsLimit?', {
  name: 'postsList',
  waitOn: function() {
    var limit = parseInt(this.params.postsLimit) || 5; 
    return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: limit});
  },
  data: function() {
    var limit = parseInt(this.params.postsLimit) || 5; 
    return {
      posts: Posts.find({}, {sort: {submitted: -1}, limit: limit})
    };
  }
});

var requireLogin = function() {
  if (! Meteor.user()) {
    if (Meteor.loggingIn()) {
      this.render(this.loadingTemplate);
    } else {
      this.render('accessDenied');
    }
  } else {
    this.next();
  }
}

Router.onBeforeAction('dataNotFound', {only: 'postPage'});
Router.onBeforeAction(requireLogin, {only: 'postSubmit'});
lib/router.js

Commit 12-2

Augmented the postsList route to take a limit.

Hãy cùng thử xem hệ thống phân trang của chúng ta đang hoạt động như thế nào. Chúng ta bây giờ có khả năng hiển htij một con số tuỳ ý số bài viết trên trang chủ đơn giản bằng việc thay đổi tham số của URL. Ví dụ, thử truy cập vào http://localhost:3000/3. Bạn sẽ thấy thứ gì đó như sau:

Controlling the number of posts on the homepage.
Controlling the number of posts on the homepage.

Tại sao không dùng theo trang?

Tại sao chúng ta lại dùng phương pháp tiếp cân “phân trang vô hạn” mà không phải là hiển thị các trang riêng biệt cho mỗi 10 bài viết, giống như là cách Google làm đối với kết quả tìm kiếm? Đây là hệ quả của lý thuyết thời gian thực bao quát bởi Meteor.

Hãy tưởng tượng là bạn đang phân trang collection Posts dùng mô hình phân trang kết quả Google, và hiện tại chúng ta đang ở trang thứ 2, nơi chứa bài viết từ 10 đến 20. Điều gì sẽ xảy ra nếu như người dùng nào đó xoá bất kỳ bài nào trong 10 bài viết trước đó?

Vì ứng dụng của chúng ta theo thời gian thưc, bộ dữ liệu của chúng ta sẽ thay đổi. Bài viết thứ 10 sẽ trở thành bài viết thứ 9, và biến mất khỏi màn hình hiển thị, trong khi bài viết thứ 11 sẽ nằm trong danh sách. Điều này sẽ khiến người dùng thấy là bài viết của mình đang bị thay đổi mà không có lý do!

Ngay cả khi chúng ta chấp nhận lỗ hổng UX này, phân trang theo phương pháp truyền thống cũng khó cho việc thực hiện xét về mặt kỹ thuật.

Hãy cùng quay trở lại ví dụ trước của chúng ta. Chúng ta đang publish bài viết thứ 10 tới 20 từ collection Posts, nhưnng làm thế nào để tìm những bài viết này từ phía client? Bạn không thể nhặt bài viết từ 10 đến 20, vì chỉ có 10 bài viết trên dữ liệu phía client.

Một giải pháp cho việc này là publish 10 bài viết này từ phía server, và sau đó thực hiện Posts.find() phía client để chọn ra tất cả bài viết đã được publish.

Điều này hoạt động tốt nếu như bạn chỉ có một subscribe duy nhất. Nhưng điều gì sẽ xảy ra nếu như bạn bắt đầu có nhiều hơn một subscribe bài viết, như chúng ta sẽ sớm thực hiện?

Giả dụ một subscribe hỏi bài viết từ thứ 10 đến 20, và một cái khác từ bài viết 30 đến 40. Bạn bây có tổng cộng 20 bài viết được nạp phía client, và sẽ không có cách nào để biết bài viết nào thuộc về subscribe nào.

Vì tất cả lý do đó, phân trang theo phương pháp truyền thống không thực sự hợp lý khi làm việc với Meteor.

Tạo một Controller cho Route

Có thể bạn đã nhận ra rằng chúng ta đang lặp lại var limit = parseInt(this.params.postsLimit) || 5; hai lần. Thêm nữa, việc hard-code số “5” thực ra cũng không phải là lý tưởng. Không đến mức là ngày tận thế, tuy nhiên luôn luôn tốt hơn nếu như chúng ta theo sát nguyên tắc DRY (Don’t Repeat Yourself) khi có thể, hãy cùng xem chúng ta có thể điều chỉnh một chút như thế nào.

Chúng ta sẽ giới thiệu một khái niệm mới của Iron Router, Route Controller. Một route controller là một cách đơn giản để ghép nhóm tính năng route với nhau vào một gói có thể dùng được mà bất kỳ route nào cũng có thể kế thừa từ nó. Ngay bây giờ chúng ta sẽ chỉ dùng nó cho một route đơn lẻ, nhưng bạn sẽ thấy trong chương tiếp theo tính năng này hữu hiệu như thế nào.

//...

PostsListController = RouteController.extend({
  template: 'postsList',
  increment: 5, 
  postsLimit: function() { 
    return parseInt(this.params.postsLimit) || this.increment; 
  },
  findOptions: function() {
    return {sort: {submitted: -1}, limit: this.postsLimit()};
  },
  waitOn: function() {
    return Meteor.subscribe('posts', this.findOptions());
  },
  data: function() {
    return {posts: Posts.find({}, this.findOptions())};
  }
});

//...

Router.route('/:postsLimit?', {
  name: 'postsList'
});

//...
lib/router.js

Hãy xem xét từng bước một. Đầu tiên, chúng ta tạo một controller bằng việc mở rộng RouteController. Chúng ta sau đó thiết lập thuộc tính template giống như đã làm trước đó, và thêm một thuộc tính mới là increment.

Sau đó chúng ta định nghĩa một hàm limit mới mà nó trả về giới hạn hiện tại và một hàm findOptions mà sẽ trả về object tuỳ chọn. Điều này dường như là một bước thêm vào, nhưng chúng ta sẽ làm rõ nó sau đây.

Tiếp theo, chúng ta định nghĩa hàm waitOndata như lúc trước, ngoại trừ chúng sẽ sử dụng hàm findOptions chúng ta tạo trước đó.

Bởi vì controller của chúng ta gọi tới PostsListController và route có tên là postsList, Iron Router sẽ tự động dùng controller. Vì vậy chúng ta chỉ cần bỏ đi waitOndata từ định nghĩa route (vì bây giờ controller sẽ xử lý chúng). Nếu chúng ta cần sử dụng controller với một tên khác, chúng ta có hteer đã dùng tuỳ chọn controller (chúng ta sẽ thấy một ví dụ dùng cái này trong chương tiếp theo).

Commit 12-3

Refactored postsLists route into a RouteController.

Thêm vào một đường dẫn nạp thêm

Chúng ta đã có một hệ thống phân trang hoạt động, và code của chúng ta trông cũng khá ổn. Duy chỉ có một vấn đề: không có cách nào để thực sự dùng hệ thống phân trang đó ngoại trừ việc thay đổi URL bằng tay. Điều này thực sự là không tạo nên trải nghiệm người dùng tốt, vì vậy hãy cùng thay đổi điều này.

Điều chúng ta muốn làm cũng khá đơn giản. Chúng ta sẽ thêm một button “load more” ở cuối danh sách bài viết, thứ sẽ tăng số lượng bài viết đang hiển thị thêm 5 mỗi lần được bấm vào. Vì vậy nếu đang trên URL http://localhost:3000/5, bấm vào “load more” sẽ mang tới http://localhost:3000/10. Nếu như bạn làm đến bước này trong cuốn sách, chúng tôi tin rằng bạn có thể xử lý một chút toán!

Như đã làm từ trước, chúng ta sẽ thêm vào logic phân trang trên route. Bạn có nhớ rằng chúng ta đã đặt tên bối cảnh dữ liệu một cách rõ ràng hơn là chỉ dùng một con trỏ không tên? Thực ra, không có một luật nào nói rằng hàm data chỉ có thể nhận con trỏ, vì vậy chúng ta sẽ dùng kỹ thuật tương tự để tạo ra URL cho button “load more”.

//...

PostsListController = RouteController.extend({
  template: 'postsList',
  increment: 5, 
  postsLimit: function() { 
    return parseInt(this.params.postsLimit) || this.increment; 
  },
  findOptions: function() {
    return {sort: {submitted: -1}, limit: this.postsLimit()};
  },
  waitOn: function() {
    return Meteor.subscribe('posts', this.findOptions());
  },
  posts: function() {
    return Posts.find({}, this.findOptions());
  },
  data: function() {
    var hasMore = this.posts().count() === this.postsLimit();
    var nextPath = this.route.path({postsLimit: this.postsLimit() + this.increment});
    return {
      posts: this.posts(),
      nextPath: hasMore ? nextPath : null
    };
  }
});

//...
lib/router.js

Hãy cùng xem xét sâu hơn về router. Hãy nhớ rằng route postList (thứ được thừa hưởng từ controller PostsListController chúng ta đang làm việc trên) nhận tham số là postsLimit.

Vì vậy khi mà chúng ta cung cấp {postsLimit: this.limit() + this.increment} chothis.route.path(), chúng ta đang bảo cho routepostList` tạo ra đường dẫn của riêng nó sử dụng object JavaScript như là bối cảnh dữ liệu.

Nói cách khác, đây chính xác là cách làm giống như là dùng helper Spacebar {{pathFor 'postsList'}}, ngoại trừ việc chúng ta thay this bằng bối cảnh dữ liệu chúng ta tự tạo.

Chúng ta đang lấy đường dẫn đó và thêm nó vào bối cảnh dữ liệu cho template, nhưng chỉ khi có nhiều bài viết để hiển thị. Cách chúng ta làm điều đó có một chút mẹo.

Chúng ta biết rằng this.limit() trả về số lượng bài viết hiện tại chúng ta muốn hiển thị, thứ có thể là giá trị của URL hiện tại, hoặc giá trị mặc định (5) nếu như URL không chứa tham số.

Mặt khác, this.posts tham chiếu tới con trỏ hiện tại, vì vậy this.posts.count() tham chiếu tới số lượng bài viết mà thực sự nằm trong con trỏ.

Điều mà chúng ta đang nói tới đây là nếu như chúng ta hỏi về n bài viết và chúng ta nhận được n, chúng ta sẽ hiển thị button “load more”. Nhưng nếu chúng ta hỏi cho n và nhận được ít hơn n, thì điều đó có nghĩa là chúng ta đã đạt tới giới hạn và sẽ dùng việc hiển thị button.

Điều đó nói lên rằng, hệ thống của chúng ta sẽ thất bại trong một trường hợp: khi mà số lượng khoản mục trong cơ sở dữ liệu đúng bằng n. Nếu điều đó xay ra, client sẽ hỏi n bài viết và nhận được n trở lại và vẫn hiển thị button “load more”, không nhận ra rằng không còn khoản mục nào nữa.

Đáng tiếc là không có cách đơn giản nào để giải quyết vấn đề này, vì vậy ngay bây giờ chúng ta phải chấp nhận việc cài đặt không hoàn hảo này.

Mọi việc còn tồn lại là thêm vào một đường dẫn “load more” ở cuối của danh sách bài viết, chắc chắn rằng chỉ hiển thị nếu như thực sự còn thêm bài viết để nạp:

<template name="postsList">
  <div class="posts">
    {{#each posts}}
      {{> postItem}}
    {{/each}}

    {{#if nextPath}}
      <a class="load-more" href="{{nextPath}}">Load more</a>
    {{/if}}
  </div>
</template>
client/templates/posts/posts_list.html

Đây là thứ mà danh sách bài viết của bạn sẽ hiển thị:

The “load more” button.
The “load more” button.

Commit 12-4

Added nextPath() to the controller and use it to step thr…

Trải nghiệm người dùng tốt hơn

Hệ thống phân trang của chúng ta hiện tại đã hoạt động tốt, nhưng vẫn còn vấn đề khá rắc rối: mỗi lần bấm vào “load more” thì router hỏi xem có bài viết không, tính năng waitOn của Iron Router gửi cho chúng ta template loading trong khi đợi bài viết mới tới. Kết quả là chúng ta bị chuyển tới đầu trang mỗi lần, và cần phải cuộn xuống dưới cùng để duyệt tiếp.

Vì vậy, chúng ta phải bảo Iron Router không subscribe waitOn nữa. Thay vào đó, chúng ta sẽ định nghĩa subscribe trong một subscribe hook.

Chú ý rằng chúng ta không trả về subscribe này trong phần hook. Trả về nó (là cách mà subscribe hook thường làm) sẽ kích hoạt loading hook toàn cục, và đó chính là điều chúng ta cần phải tránh. Thay vào đó chúng ta sử dụng subscribe hook như một chỗ thuận tiện để định nghĩa subscribe của chúng ta, đơn giản là dùng hook onBeforeAction.

Chúng ta cũng sẽ gửi kèm một biến ready tham chiếu tới this.postsSub.ready như một phần của bối cảnh dữ liệu. Điều này sẽ để chúng ta báo cho template khi nào việc subscribe bài viết đã hoàn thành.

//...

PostsListController = RouteController.extend({
  template: 'postsList',
  increment: 5, 
  postsLimit: function() { 
    return parseInt(this.params.postsLimit) || this.increment; 
  },
  findOptions: function() {
    return {sort: {submitted: -1}, limit: this.postsLimit()};
  },
  subscriptions: function() {
    this.postsSub = Meteor.subscribe('posts', this.findOptions());
  },
  posts: function() {
    return Posts.find({}, this.findOptions());
  },
  data: function() {
    var hasMore = this.posts().count() === this.postsLimit();
    var nextPath = this.route.path({postsLimit: this.postsLimit() + this.increment});
    return {
      posts: this.posts(),
      ready: this.postsSub.ready,
      nextPath: hasMore ? nextPath : null
    };
  }
});

//...
lib/router.js

Chúng ta sẽ sau đó kiểm tra biến ready trong template để hiển thị một spinner ở cuối của danh sách bài viết trong khi đang nạp dữ liệu mới:

<template name="postsList">
  <div class="posts">
    {{#each posts}}
      {{> postItem}}
    {{/each}}

    {{#if nextPath}}
      <a class="load-more" href="{{nextPath}}">Load more</a>
    {{else}}
      {{#unless ready}}
        {{> spinner}}
      {{/unless}}
    {{/if}}
  </div>
</template>
client/templates/posts/posts_list.html

Commit 12-5

Add a spinner to make pagination nicer.

Truy cập bất kỳ bài viết nào

Chúng ta hiện tại đang nạp vào năm bài viết mới nhất mặc định, nhưng điều xảy ra khi mà ai đó truy cập tới một trang bài viết đơn lẻ?

An empty template.
An empty template.

Nếu bạn thử nó, bạn sẽ đối mặt với lỗi “not found” (không tìm thấy). Điều này hợp lý: Chúng ta vừa bảo router subscribe tới bộ xuất bản posts khi nạp route postsList, nhưng chúng ta đã không bảo nó nên làm điều gì với route postPage.

Nhưng xa hơn nữa, chúng ta đều biết làm thế nào để subscribe tới một danh sách n bài viết mới nhất. Làm thế nào để chúng ta hỏi server cho một bài viết đặc thù? Chúng tôi sẽ tiết lộ cho bạn một bí mật ở đây: bạn có thể có nhiều hơn là một publish cho mỗi collection!

Vậy để lấy lại những bài viết đã mất, chúng ta sẽ tạo một publish mới, khác với singlePost mà chỉ publish một bài viết, định danh bởi _id.

Meteor.publish('posts', function(options) {
  return Posts.find({}, options);
});

Meteor.publish('singlePost', function(id) {
  check(id, String)
  return Posts.find(id);
});

//...
server/publications.js

Bây giờ, hãy cùng subscribe tới bài viết đúng từ phía client. Chúng ta đã subscribe tới bản xuất bản comments trên hàm waitOn của route postPage, vì vậy chúng ta có thể đơn giản thêm vào subscribe tới singlePost. Và đừng quên thêm vào subscribe tới route postEdit, vì nó cũng cần dữ liệu tương tự:

//...

Router.route('/posts/:_id', {
  name: 'postPage',
  waitOn: function() {
    return [
      Meteor.subscribe('singlePost', this.params._id),
      Meteor.subscribe('comments', this.params._id)
    ];
  },
  data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/posts/:_id/edit', {
  name: 'postEdit',
  waitOn: function() { 
    return Meteor.subscribe('singlePost', this.params._id);
  },
  data: function() { return Posts.findOne(this.params._id); }
});

//...
lib/router.js

Commit 12-6

Use a single post subscription to ensure that we can alwa…

Sau khi mọi thứ với phân trang đã hoàn tất, ứng dụng của chúng ta không còn gặp vấn đề khi mở rộng, và người dùng chắc chắn còn cung cấp nhiều đường dẫn hơn trước. Vì vậy sẽ rất tuyệt nếu chúng ta có một cách để xếp hạng những đường dẫn này! Nếu bạn vẫn chưa biết cách làm, đó chính là thứ sẽ được giới thiệu trong chương tiếp theo!

Tính năng bỏ phiếu

13

Bây giờ trang của chúng ta đã trở nên ngày một nổi tiếng hơn, và việc tìm ra đường dẫn tốt nhất nhanh trong trở nên rắc rối.

Chúng ta có thể xây dựng một hệ thống xếp hạng phức tạp với karma, điểm phân dã dựa theo thời gian, và nhiều thứ khác nữa (phần lớn được cài đặt trong Telescope, người anh lớn của Microscope). Nhưng cho ứng dụng của chúng ta, chúng ta sẽ giữ cho mọi thứ thật đơn giản và chỉ đánh giá bài viết bởi số lượng phiếu bầu nó nhận được.

Hãy bắt đầu bằng việc cho người dùng một cách để bình chọn.

Mô hình dữ liệu

Chúng ta sẽ lưu một danh sách upvoter cho mỗi bài viết để có thể biết có hiển thị button upvote tới người dùng, cũng như là để tránh người dùng bình chọn hai lần

Sự riêng tư dữ liệu & Publications

Chúng ta sẽ publish những danh sách này tới tất cả người dùng, nó cũng sẽ tự động làm cho dữ liệu được truy cập từ console trình duyệt.

Đây là một vấn đề về sự riêng tư dữ liệu phát sinh từ cách mà collection hoạt động. Ví dụ, liệu chúng ta có muốn mọi người có thể tìm ra ai đã bình chọn cho bài viết của họ? Trong trường hợp của chúng ta, làm điều đó thực ra không mang lại hậu quả gì, nhưng việc nhận thức ra vấn đề đó cũng rất quan trọng.

Chúng ta cũng sẽ dùng bất chuẩn hoá cho tổng số upvoter của mỗi bài viết để dễ dàng truy cập được con số đó. Vậy nên chúng ta sẽ thêm vào hai thuộc tính cho bài viết, upvotersvotes. Hãy bắt đầu bằng việc thêm vào dữ liệu tĩnh:

// Fixture data
if (Posts.find().count() === 0) {
  var now = new Date().getTime();

  // create two users
  var tomId = Meteor.users.insert({
    profile: { name: 'Tom Coleman' }
  });
  var tom = Meteor.users.findOne(tomId);
  var sachaId = Meteor.users.insert({
    profile: { name: 'Sacha Greif' }
  });
  var sacha = Meteor.users.findOne(sachaId);

  var telescopeId = Posts.insert({
    title: 'Introducing Telescope',
    userId: sacha._id,
    author: sacha.profile.name,
    url: 'http://sachagreif.com/introducing-telescope/',
    submitted: new Date(now - 7 * 3600 * 1000),
    commentsCount: 2,
    upvoters: [], 
    votes: 0
  });

  Comments.insert({
    postId: telescopeId,
    userId: tom._id,
    author: tom.profile.name,
    submitted: new Date(now - 5 * 3600 * 1000),
    body: 'Interesting project Sacha, can I get involved?'
  });

  Comments.insert({
    postId: telescopeId,
    userId: sacha._id,
    author: sacha.profile.name,
    submitted: new Date(now - 3 * 3600 * 1000),
    body: 'You sure can Tom!'
  });

  Posts.insert({
    title: 'Meteor',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://meteor.com',
    submitted: new Date(now - 10 * 3600 * 1000),
    commentsCount: 0,
    upvoters: [], 
    votes: 0
  });

  Posts.insert({
    title: 'The Meteor Book',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://themeteorbook.com',
    submitted: new Date(now - 12 * 3600 * 1000),
    commentsCount: 0,
    upvoters: [], 
    votes: 0
  });

  for (var i = 0; i < 10; i++) {
    Posts.insert({
      title: 'Test post #' + i,
      author: sacha.profile.name,
      userId: sacha._id,
      url: 'http://google.com/?q=test-' + i,
      submitted: new Date(now - i * 3600 * 1000 + 1),
      commentsCount: 0,
      upvoters: [], 
      votes: 0
    });
  }
}
server/fixtures.js

Như thường lệ, dừng ứng dụng đang hoạt động, chạy lệnh meteor reset, khởi động lại ứng dụng và tạo một tài khoản mới. Hãy chắc chắn rằng hai thuộc tính này được khởi tạo khi bài viết đã được tạo:

//...

var postWithSameLink = Posts.findOne({url: postAttributes.url});
if (postWithSameLink) {
  return {
    postExists: true,
    _id: postWithSameLink._id
  }
}

var user = Meteor.user();
var post = _.extend(postAttributes, {
  userId: user._id, 
  author: user.username, 
  submitted: new Date(),
  commentsCount: 0,
  upvoters: [], 
  votes: 0
});

var postId = Posts.insert(post);

return {
  _id: postId
};

//...
collections/posts.js

Template bình chọn

Đầu tiên, chúng ta thêm vào một button upvote cho bộ phận bài viết và hiển thị biến đếm upvote trong metadata:

<template name="postItem">
  <div class="post">
    <a href="#" class="upvote btn btn-default"></a>
    <div class="post-content">
      <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
      <p>
        {{votes}} Votes,
        submitted by {{author}},
        <a href="{{pathFor 'postPage'}}">{{commentsCount}} comments</a>
        {{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
      </p>
    </div>
    <a href="{{pathFor 'postPage'}}" class="discuss btn btn-default">Discuss</a>
  </div>
</template>
client/templates/posts/post_item.html
The upvote button
The upvote button

Sau đó, chúng ta gọi đến server Method upvote khi người dùng bấm vào button:

//...

Template.postItem.events({
  'click .upvote': function(e) {
    e.preventDefault();
    Meteor.call('upvote', this._id);
  }
});
client/templates/posts/post_item.js

Cuối cùng, chúng ta quay trở lại file lib/collections/posts.js và thêm vào Meteor Method phía server để upvote bài viết:

//...

Meteor.methods({
  post: function(postAttributes) {
    //...
  },

  upvote: function(postId) {
    check(this.userId, String);
    check(postId, String);

    var post = Posts.findOne(postId);
    if (!post)
      throw new Meteor.Error('invalid', 'Post not found');

    if (_.include(post.upvoters, this.userId))
      throw new Meteor.Error('invalid', 'Already upvoted this post');

    Posts.update(post._id, {
      $addToSet: {upvoters: this.userId},
      $inc: {votes: 1}
    });
  }
});

//...
lib/collections/posts.js

Commit 13-1

Added basic upvoting algorithm.

Method này khá là dễ hiểu. Chúng ta thực hiện một số đoạn kiểm tra có tính chất bảo vệ để chắc chắn rằng người dùng đã đăng nhập và bài viết thực sự tồn tại. Sau đó chúng ta kiểm tra thêm lần nữa là người đã bình chọn cho bài viết rồi hay chưa, nếu họ chưa thì tăng biến bình chọn thêm một và thêm người dùng đó vào danh sách upvoter.

Bước cuối cùng này khá là thú vị, như chúng ta đã vừa dùng một vài toán tử đặc biệt của Mongo. Còn nhiều thứ có thể học hỏi được, nhưng hai thứ sau khá là hữu ích: $addToSet thêm một mục dữ liệu vào một mảng thuộc tính miễn là nó chưa tồn tại, và $inc đơn giản tăng một trường số nguyên.

Tính điều chỉnh giao diện người dùng

Nếu như một người dùng chưa đăng nhập, hoặc đã từng upvote cho bài viết, họ sẽ không thể bình chọn thêm lần nữa. Để phản ánh điều này trong UI, chúng ta sẽ dùng một helper để thêm vào thuộc tính class CSS disabled với button upvote.

<template name="postItem">
  <div class="post">
    <a href="#" class="upvote btn btn-default {{upvotedClass}}"></a>
    <div class="post-content">
      //...
  </div>
</template>
client/templates/posts/post_item.html
Template.postItem.helpers({
  ownPost: function() {
    //...
  },
  domain: function() {
    //...
  },
  upvotedClass: function() {
    var userId = Meteor.userId();
    if (userId && !_.include(this.upvoters, userId)) {
      return 'btn-primary upvotable';
    } else {
      return 'disabled';
    }
  }
});

Template.postItem.events({
  'click .upvotable': function(e) {
    e.preventDefault();
    Meteor.call('upvote', this._id);
  }
});
client/templates/posts/post_item.js

Chúng ta đã thay đổi class từ .upvote sang .upvotable, vì vậy đừng quên thay đổi bộ điều khiển sự kiện bấm chuột.

Greying out upvote buttons.
Greying out upvote buttons.

Commit 13-2

Grey out upvote link when not logged in / already voted.

Tiếp theo, có thể bạn đã nhận ra là bài viết với một bình chọn được dán nhãn “1 votes”, vì vậy hãy dành thời gian để biến thành số nhiều nhãn này một cách thích hợp. Số nhiều có thể là một quá trình xử lý phức tạp, nhưng bây giờ chúng ta chỉ làm theo một cách đơn giản nhất. Chúng ta sẽ tạo một helper Spacebar chung mà có thể dùng ở nhiều nơi:

UI.registerHelper('pluralize', function(n, thing) {
  // fairly stupid pluralizer
  if (n === 1) {
    return '1 ' + thing;
  } else {
    return n + ' ' + thing + 's';
  }
});
client/helpers/spacebars.js

Helper chúng ta đã tạo trước đó được gắn chặt với template nó áp dụng vào. Nhưng bằng việc sử dụng UI.registerHelper, chúng ta tạo một helper toàn cục mà có thể dùng trong bất kỳ template nào:

<template name="postItem">

//...

<p>
  {{pluralize votes "Vote"}},
  submitted by {{author}},
  <a href="{{pathFor 'postPage'}}">{{pluralize commentsCount "comment"}}</a>
  {{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
</p>

//...

</template>
client/templates/posts/post_item.html
Perfecting Proper Pluralization (now say that 10 times)
Perfecting Proper Pluralization (now say that 10 times)

Commit 13-3

Added pluralize helper to format text better.

Bạn bây giờ sẽ thấy “1 vote”.

Thuật toán bình chọn thông minh hơn

Đoạn code upvote của chúng ta trông khá tốt, nhưng chúng ta cần phải làm tốt hơn nữa. Trong method upvote, chúng ta tạo ra hai lệnh gọi Mongo: một để gắn với bài viết và một để cập nhật nó.

Có hai vấn đề với việc này. Đầu tiên, nó không hiệu quả theo nghĩa chúng ta phải tới cơ sở dữ liệu hai lần. Nhưng quan trọng hơn, nó dẫn đến một cuộc đua điều kiện. Chúng ta đang đi theo thuật toán như sau:

  1. Lấy bài viết từ cơ sở dữ liệu.
  2. Kiểm tra xem người dùng đã bỏ phiếu hay chưa.
  3. Nếu chưa, bỏ phiếu cho người dùng đó.

Điều gì sẽ xảy ra nếu người dùng bỏ phiếu cho bìa viết với bước 1 và 3? Đoạn code hiện tại của chúng ta mở cửa cho người dùng có thể bỏ phiếu hai lần cho cùng một bài viết. Rất biết ơn là Mongo cho phép chúng ta gộp bước 1-3 vào trong cùng một lệnh Mongo:

//...

Meteor.methods({
  post: function(postAttributes) {
    //...
  },

  upvote: function(postId) {
    check(this.userId, String);
    check(postId, String);

    var affected = Posts.update({
      _id: postId, 
      upvoters: {$ne: this.userId}
    }, {
      $addToSet: {upvoters: this.userId},
      $inc: {votes: 1}
    });

    if (! affected)
      throw new Meteor.Error('invalid', "You weren't able to upvote that post");
  }
});

//...
collections/posts.js

Commit 13-4

Better upvoting algorithm.

Điều đang được nói đến là “tìm tất cả bài viết với id này và người dùng này chưa bỏ phiếu, sau đó thay đổi chúng theo cách này”. Nếu người dùng chưa bỏ phiếu, dĩ nhiên là nó sẽ tìm bài viết với id đó. Mặt khác, nếu như người dùng đã bỏ phiếu, câu truy vấn sẽ không khớp với bất kỳ tài liệu nào, và do đó không có gì diễn ra.

Đền bù độ trễ

Giả dụ bạn cố ăn gian và gửi một bài viết tới đầu danh sách bằng việc đổi số lượng bình chọn của bài viết:

> Posts.update(postId, {$set: {votes: 10000}});
Browser console

(postId là id của một bài viết)

Hàng rào chắn này có thể được xây dựng với callback deny() (trong collections/posts.js) và lập tức có tác dụng.

Nhưng nếu như bạn nhìn kỹ hơn, bạn có thể đã nhận ra sự đền bù độ trễ trong hành động. Nó có thể rất nhanh, nhưng bài viết sẽ nhảy lên vị trí đầu trong một khoảng thời gian trước khi trở lại đúng vị trí của nó.

Điều gì đã diễn ra? Trong collection cục bộ Posts, update được áp dụng mà không có trở ngại gì. Điều này xảy ra tức thì, nên bài viết sẽ lên đầu danh sách. Trong khi đó, trên server, update bị từ chối. Nên sau đó một chút (đo bằng mili giây nếu bạn chạy ứng dụng Meteor trên chính máy của mình), server sẽ trả về lỗi, bảo là collection cục bộ phải đổi lại giá trị.

Kết quả là: trong khi chờ đợi cho server trả lời, giao diện người dùng không thể tránh khỏi việc tin tưởng collection cục bộ. Ngay khi server trở lại và từ chối việc sửa đổi, giao diện người dùng phản ánh lại điều đó.

Xếp hạng bài viết trang ngoài

Bây giờ chúng ta đã có một điểm số cho mỗi bài viết dựa trên số lượng bình chọn, hãy cùng hiển thị danh sách bài viết tốt nhất. Để làm điều này, chúng ta sẽ thấy làm thế nào để quản lý hai subscription riêng biệt đối với collection bài viết, và tạo template postList tổng quát hơn một chút.

Để bắt đầu, chúng ta muốn có hai subscription, một cho thứ tự sắp xếp. Thủ thuật ở đây là cả hai subscription sẽ subscribe tới cùng publish posts, chỉ khác biệt về tham số.

Chúng ta cũng sẽ tạo hai route mới là newPostsbestPosts, truy cập được từ URLs /new/best (dĩ nhiên là cùng với /new/5/best/5).

Để làm điều này, chúng ta sẽ mở rộng PostsListController thành hai controller là NewPostsListControllerBestPostsListController. Điều này giúp chúng ta tạo ra cùng chọn lựa route cho cả homenewPosts, bằng việc đưa cho chúng ta NewPostsListController đơn lẻ để kế thừa. Thêm vào đó, nó cũng là minh hoạ tốt cho việc thể hiển Iron Router có thể mềm dẻo như thế nào.

Vì vậy hãy cùng thay đổi thuộc tính sắp xếp {submitted: -1} trong PostsListController bằng this.sort, là thứ sẽ được cung cấp bởi NewPostsListControllerBestPostsListController:

//...

PostsListController = RouteController.extend({
  template: 'postsList',
  increment: 5, 
  postsLimit: function() { 
    return parseInt(this.params.postsLimit) || this.increment; 
  },
  findOptions: function() {
    return {sort: this.sort, limit: this.postsLimit()};
  },
  subscriptions: function() {
    this.postsSub = Meteor.subscribe('posts', this.findOptions());
  },
  posts: function() {
    return Posts.find({}, this.findOptions());
  },
  data: function() {
    var hasMore = this.posts().count() === this.postsLimit();
    return {
      posts: this.posts(),
      ready: this.postsSub.ready,
      nextPath: hasMore ? this.nextPath() : null
    };
  }
});

NewPostsController = PostsListController.extend({
  sort: {submitted: -1, _id: -1},
  nextPath: function() {
    return Router.routes.newPosts.path({postsLimit: this.postsLimit() + this.increment})
  }
});

BestPostsController = PostsListController.extend({
  sort: {votes: -1, submitted: -1, _id: -1},
  nextPath: function() {
    return Router.routes.bestPosts.path({postsLimit: this.postsLimit() + this.increment})
  }
});

Router.route('/', {
  name: 'home',
  controller: NewPostsController
});

Router.route('/new/:postsLimit?', {name: 'newPosts'});

Router.route('/best/:postsLimit?', {name: 'bestPosts'});
lib/router.js

Chú ý rằng hiện tại chúng ta có nhiều hơn là một route, chúng ta đang lấy đi logic nextPath ra khỏi PostsListController và cho vào NewPostsController cũng như BestPostsController vì đường dẫn sẽ trở nên khác biệt trong cả hai trường hợp.

Thêm vào đó, khi chúng ta sắp xếp bởi votes, chúng ta có một sự sắp xếp theo sau bằng việc submit tem thời gian và _id để chắc chắn rằng thứ tự sắp xếp được đặc tả.

Với controller mới tạo, chúng ta có thể an toàn thoát khỏi route postsList trước đó. Chỉ cần xoá đoạn code sau:

 Router.route('/:postsLimit?', {
  name: 'postsList'
 })
lib/router.js

Chúng ta cũng sẽ thêm đường dẫn vào header:

<template name="header">
  <nav class="navbar navbar-default" role="navigation">
    <div class="container-fluid">
      <div class="navbar-header">
        <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navigation">
          <span class="sr-only">Toggle navigation</span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
        </button>
        <a class="navbar-brand" href="{{pathFor 'home'}}">Microscope</a>
      </div>
      <div class="collapse navbar-collapse" id="navigation">
        <ul class="nav navbar-nav">
          <li>
            <a href="{{pathFor 'newPosts'}}">New</a>
          </li>
          <li>
            <a href="{{pathFor 'bestPosts'}}">Best</a>
          </li>
          {{#if currentUser}}
            <li>
              <a href="{{pathFor 'postSubmit'}}">Submit Post</a>
            </li>
            <li class="dropdown">
              {{> notifications}}
            </li>
          {{/if}}
        </ul>
        <ul class="nav navbar-nav navbar-right">
          {{> loginButtons}}
        </ul>
      </div>
    </div>
  </nav>
</template>
client/templates/includes/header.html

Và cuối cùng, chúng ta cũng cần phải cập nhật bộ điều khiển sự kiện xoá bài viết:

  'click .delete': function(e) {
    e.preventDefault();

    if (confirm("Delete this post?")) {
      var currentPostId = this._id;
      Posts.remove(currentPostId);
      Router.go('home');
    }
  }
client/templates/posts_edit.js

Với tất cả mọi thứ đã xong xuôi, bây giờ chúng ta có thể đạt tới một danh sách bài viết tốt nhất:

Ranking by points
Ranking by points

Commit 13-5

Added routes for post lists, and pages to display them.

Header tốt hơn

Bây giờ khi mà chúng ta đã có hai danh sách bài viết, có thể sẽ khó để biết được danh sách nào đang được hiển thị. Bởi vậy hãy cùng nhìn lại header để làm cho điều này hiển nhiên hơn. Chúng ta sẽ tạo một file quản lý header.js và tạo helper mà sử dụng đường dẫn hiện tại trong các mục dữ liệu trên navigation:

Lý do chúng ta muốn phụ trợ route nhiều tên là vì cả route homenewPosts (đang trỏ đến //new lần lượt) mang đến cùng một template. Nghĩa là activeRouteClass của chúng ta nên đủ thông minh để tạo ra tag <li> hoạt động trên cả hai trường hợp.

<template name="header">
  <nav class="navbar navbar-default" role="navigation">
    <div class="container-fluid">
      <div class="navbar-header">
        <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navigation">
          <span class="sr-only">Toggle navigation</span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
        </button>
        <a class="navbar-brand" href="{{pathFor 'home'}}">Microscope</a>
      </div>
      <div class="collapse navbar-collapse" id="navigation">
        <ul class="nav navbar-nav">
          <li class="{{activeRouteClass 'home' 'newPosts'}}">
            <a href="{{pathFor 'newPosts'}}">New</a>
          </li>
          <li class="{{activeRouteClass  'bestPosts'}}">
            <a href="{{pathFor 'bestPosts'}}">Best</a>
          </li>
          {{#if currentUser}}
            <li class="{{activeRouteClass 'postSubmit'}}">
              <a href="{{pathFor 'postSubmit'}}">Submit Post</a>
            </li>
            <li class="dropdown">
              {{> notifications}}
            </li>
          {{/if}}
        </ul>
        <ul class="nav navbar-nav navbar-right">
          {{> loginButtons}}
        </ul>
      </div>
    </div>
  </nav>
</template>
client/templates/includes/header.html
Template.header.helpers({
  activeRouteClass: function(/* route names */) {
    var args = Array.prototype.slice.call(arguments, 0);
    args.pop();

    var active = _.any(args, function(name) {
      return Router.current() && Router.current().route.getName() === name
    });

    return active && 'active';
  }
});
client/templates/includes/header.js
Showing the active page
Showing the active page

Tham số helper

Chúng ta vẫn chưa dùng mô hình đặc biệt đó cho tới bây giờ, nhưng cũng giống như bất kỳ tag Spacebar nào khác, tag của template helper có thể nhận tham số.

Và trong khi bạn dĩ nhiên có thể gửi tên đặc biệt cho hàm của bạn, bạn cũng có thể gửi số không được đặc tả và cả tham số không tên và lấy lại chúng bằng việc gọi object arguments bên trong hàm.

Trong trường hợp cuối cùng này, có thể bạn muốn chuyển object arguments tới một mảng JavaScript ổn định và sau đó gọi pop() để tránh hash bị thêm vào cuối Spacebar.

Cho mỗi khoản mục của navigation, helper activeRouteClass lấy danh sách tên của route, và sau đó dùng helper any() của Underscore để xem nếu như có route nào vượt qua bài kiểm tra (ví dụ URl tương ứng của nó bằng với đường dẫn hiện tại).

Nếu như bất kỳ route nào khớp với đường dẫn hiện tại, any() sẽ trả về true. Cuối cùng, chúng ta sẽ lợi dụng mô hình boolean && string của JavaScript khi mà false && myString trả về false nhưng true && myString trả về myString.

Commit 13-6

Added active classes to the header.

Bây giờ người dùng đã có thể bình chọn thời gian thực, chúng ta sẽ thấy các khoản mục nhảy lên và nhảy xuống trang chủ khi thứ hạng thay đổi. Nhưng chẳng phải là sẽ tuyệt hơn nữa nếu như có một cách để làm mềm tất cả những hoạt hoạ đó hay sao?

Publish nâng cao

Sidebar 13.5

Cho đến bây giờ chắc hẳn bạn đã nắm vững được cách thức publish và subscribe tương tác với nhau. Vì vậy hãy cùng giải thoát khỏi phần huấn luyện cơ bản và bước vào vài tình huống nâng cao hơn.

Publish một collection nhiều lần

Trong chương sidebar đầu tiên của chúng ta về publish, chúng ta đã thấy một vài khuôn mẫu publish và subscribe cơ bản, và chúng ta đã học làm thế nào hàm _publishCursor có thể dễ dàng cài đặt cho ứng dụng của chúng ta.

Đầu tiên, hãy cùng gợi nhớ lại chính xác _publishCursor là cái gì: nó lấy tất cả tài liệu mà khớp với một con trỏ, và đẩy chúng tới collection có cùng tên ở phía client. Chú ý rằng tên của publication không bị bao gồm.

Điều này có nghĩa là chúng ta có thể có nhiều hơn một publication liên kết bản collection giữa client và server.

Chúng ta đã luôn gặp mô hình này trong chương về phân trang, khi mà chúng ta đã publish một tập con phân trang của tất cả bài viết thêm vào bài viết đang được hiển thị.

Một trường hợp khác được dùng là để publish mô tả khái quáthay của một lượng dữ liệu lớn, hay là toàn bộ chi tiết của một mục dữ liệu đơn lẻ:

Publishing a collection twice
Publishing a collection twice
Meteor.publish('allPosts', function() {
  return Posts.find({}, {fields: {title: true, author: true}});
});

Meteor.publish('postDetail', function(postId) {
  return Posts.find(postId);
});

Bây giờ khi mà client subscribe tới những publish này, collection 'posts' của nó nhận dữ liệu sinh ra từ hai nguồn: danh sách tiêu đề và tên tác giả từ subscription thứ nhất, và toàn bộ chi tiết của bài viết từ cái thứ hai.

Bạn có thể nhận ra rằng bài viết được publish từ postDetail cũng được publish bởi allPosts (mặc dù với chỉ một tập con thuộc tính). Tuy nhiên, Meteor đảm nhận việc phủ lên nhau này bằng cách hợp nhất các trường và chắc chắn rằng không có bài viết bị trùng lặp.

Điều này rất tuyệt, bởi vì bây giờ khi mà chúng ta dịch ra danh sách tóm tắt của bài viết, chúng ta đang dùng object mà chỉ có đủ dữ liệu để làm việc cần thiết. Tuy nhiên, khi chúng ta đưa ra trang bài viết đơn lẻ, chúng ta cần phải làm mọi thứ để hiển thị nó. Dĩ nhiên, chúng ta cần phải chú ý để client không kỳ vọng tất cả các trường hữu dụng đối với tất cả bài viết trong trường hợp này – đây là một điều cơ bản!

Cần phải ghi chú rằng bạn không bị hạn chế việc thay đổi thuộc tính của tài liệu. Bạn có thể publish cùng thuộc tính ở cả hai publication, nhưng sắp xếp các mục một cách khác đi.

Meteor.publish('newPosts', function(limit) {
  return Posts.find({}, {sort: {submitted: -1}, limit: limit});
});

Meteor.publish('bestPosts', function(limit) {
  return Posts.find({}, {sort: {votes: -1, submitted: -1}, limit: limit});
});
server/publications.js

Subscribe tới Publication nhiều lần

Chúng ta vừa thấy làm thế nào để publish một collection đơn lẻ nhiều hơn một lần. Điều đó dẫn đến bạn có thể hoàn thành một kết quả tương tự với một mô hình khác: tạo một publication đơn, nhưng subscribe tới nó nhiều lần.

Trong Microscope, chúng ta subscribe tới publication posts nhiều lần, nhưng Iron Router cài đặt và tháo dỡ mỗi subscription cho chúng ta. Do đó không có lý do gì chúng ta không thể subscribe nhiều lần một cách đồng thời.

Ví dụ, giả sử rằng chúng ta muốn nạp cả những bài viết mới nhất và tốt nhất vào bộ nhớ cùng một lúc:

Subscribing twice to one publication
Subscribing twice to one publication

Chúng ta vừa thiết lập một publication đơn lẻ:

Meteor.publish('posts', function(options) {
  return Posts.find({}, options);
});

Và sau đó chúng ta subscribe tới publication này nhiều lần. Thực tế là những gì chúng ta đang làm tương tự như với Microscope:

Meteor.subscribe('posts', {submitted: -1, limit: 10});
Meteor.subscribe('posts', {baseScore: -1, submitted: -1, limit: 10});

Điều gì đang thực sự xảy ra ở đây? Mỗi trình duyệt mở ra hai subscription khác nhau, mỗi subscription kết nối tới cùng publication ở phía server.

Mỗi subscription cung cấp tham số khác nhau cho publication, nhưng về cơ bản, mỗi lần một bộ tài liệu (khác nhau) được dùng kéo từ collection posts và gửi xuống cho collection phía client.

Bạn còn có thể subscribe tới cùng một publication hai lần với cùng bộ tham số! Khá là khó để nghĩ về một tình huống mà nó hữu ích bây giờ, nhưng sự mềm dẻo này có thể sẽ dùng một ngày nào đó!

Nhiều collection trong một subscription đơn

Không giống như hệ cơ sở dữ liệu quan hệ truyền thống như MySQL là thứ dùng joins, cơ sở dữ liệu NoSQL như Mongo có bất chuẩn hoákết cấu lồng. Hãy cùng xem những từ ngữ này trong bối cảnh của Meteor.

Hãy cùng nhìn vào một ví dụ cụ thể. Chúng ta thêm bình luận đối với bài viết, và đến giờ, chúng ta hoàn toàn hạnh phúc với việc chỉ publish bình luận trên bài viết đơn mà người dùng đang nhìn vào.

Tuy nhiên, giả sử chúng ta muốn chỉ ra bình luận trên tất cả bài viết trên một trang đầu (chú ý rằng những bài viết này sẽ thay đổi khi chúng ta phân trang chúng). Trường hợp này đưa ra một ví dụ tốt về việc nhúng bình luận vào trong bài viết, và thực tế là chúng ta đã đẩy counts số lượng bình luận bất chuẩn hoá.

Dĩ nhiên chúng ta có thể luôn luôn nhúng bình luận vào trong bài viết, bỏ qua luôn collection Comments. Nhưng như chúng ta đã thấy trước đó trong chương Bất chuẩn hoá, bằng việc làm như vậy chúng ta sẽ mất một số lợi ích có được từ làm việc với collection riêng lẻ.

Nhưng cũng có một số thủ thuật tham gia vào việc subscription làm cho việc nhúng bình luận vào khả thi, trong khi vẫn giữ được collection riêng biệt.

Hãy cùng tưởng tượng rằng ở trang chủ danh sách bài viết, chúng ta muốn subscribe tới danh sách của 2 bình luận mới nhất cho mỗi bài viết.

Sẽ trở nên khó khăn để hoàn thành điều này mà không dùng publication độc lập cho bình luận, đặc biệt là nếu danh sách bài viết bị giới hạn theo một cách nào đó (ví dụ 10 bài gần nhất). Chúng ta sẽ phải viết publication trông như sau:

Two collections in one subscription
Two collections in one subscription
Meteor.publish('topComments', function(topPostIds) {
  return Comments.find({postId: topPostIds});
});

Điều này sẽ trở thành vấn đề nếu xét về mặt hiệu năng. Vì publication cần phải chạy nhanh xuống và xuất bản lại mỗi lần danh sách topPostIds thay đổi.

Có một cách cho điều này. Chúng ta có thể lợi dụng một điều là chúng ta không những có thể có nhiều hơn một publication cho mỗi collection, mà còn có thể có nhiều collection cho một publication:

Meteor.publish('topPosts', function(limit) {
  var sub = this, commentHandles = [], postHandle = null;

  // send over the top two comments attached to a single post
  function publishPostComments(postId) {
    var commentsCursor = Comments.find({postId: postId}, {limit: 2});
    commentHandles[postId] = 
      Mongo.Collection._publishCursor(commentsCursor, sub, 'comments');
  }

  postHandle = Posts.find({}, {limit: limit}).observeChanges({
    added: function(id, post) {
      publishPostComments(id);
      sub.added('posts', id, post);
    },
    changed: function(id, fields) {
      sub.changed('posts', id, fields);
    },
    removed: function(id) {
      // stop observing changes on the post's comments
      commentHandles[id] && commentHandles[id].stop();
      // delete the post
      sub.removed('posts', id);
    }
  });

  sub.ready();

  // make sure we clean everything up (note `_publishCursor`
  //   does this for us with the comment observers)
  sub.onStop(function() { postHandle.stop(); });
});

Chú ý rằng chúng ta không trả về bất kỳ thứ gì trong publication này, vì chúng ta gửi thông tin bằng tay tới sub (thông qua .added() và những hàm tương tự). Bởi vậy chúng ta không cần phải hỏi _publishCursor để làm điều đó cho chúng ta bằng việc trả về con trỏ.

Bây giờ, mỗi khi publish một bài viết chúng ta cũng đồng thời publish hai bình luận mới nhất gắn với nó. Và tất cả ở trong cùng một lệnh gọi subscription!

Mặc dù Meteor không tạo ra điều này một cách trực tiếp, bạn có thể nhìn vào gói publish-with-relations trên Atmosphere, là thứ mục đích làmthì cho mô hình này dễ sử dụng.

Liên kết collection khác nhau

Ngoài ra thì kiến thức mới mẻ về sự mềm dẻo của việc subscription còn giúp chúng ta làm được thêm gì nữa? Thực sự thì, nếu chúng ta không dùng _publishCursor, chúng ta không cần tuân thủ sự ràng buộc là collection nguồn ở phía server cần có cùng tên như collection đích ở phía client.

One collection for two subscriptions
One collection for two subscriptions

Một trong những lý do chúng ta muốn làm điều này là do tính Thừa kế Bảng Đơn

Giả sử rằng chúng ta muốn tham khảo nhiều loại object từ bài viết của chúng ta, mỗi trong số chúng đều lưu những trường chung nhưng có một chút khác biệt về nội dung. Ví dụ, chúng ta có thể xây dựng một blog engine như kiểu Tumblr mà mỗi bài viết xử lý ID, tem thời gian, và tựa đề; nhưng thêm vào đó còn có thể có cả ảnh, video, đường dẫn hoặc chỉ văn bản.

Chúng ta có thể lưu trữ tất cả object này trong một collection 'resources', sử dụng thuộc tính type để chỉ ra đó là kiểu object nào (video, image, link, vân vân).

Và mặc dù chúng ta có một collection Resources phía server, chúng ta còn có thể biến đổi collection đơn đó thành đa collection phía client Videos, Images, vân vân… với chỉ một chút thay đổi:

  Meteor.publish('videos', function() {
    var sub = this;

    var videosCursor = Resources.find({type: 'video'});
    Mongo.Collection._publishCursor(videosCursor, sub, 'videos');

    // _publishCursor doesn't call this for us in case we do this more than once.
    sub.ready();
  });

Chúng ta đang bảo _publishCursor publish (giống như là trả về) con trỏ video sẽ làm, nhưng thay vì publish tới collection resources phía client, chúng ta đang publish từ 'resources' sang 'videos'.

Một ý tưởng khác nữa là dùng publish tới collection phía client nơi mà không có collection nào từ phía server! Ví dụ, bạn có thể túm dữ liệu từ bên dịch vụ thứ 3, và publish chúng vào collection bên phía client.

Cảm ơn nhiều tới sự mềm dẻo của API publish, khả năng đã trở thành không có giới hạn.

Animations

14

Bây giờ chúng ta đã có tính năng bỏ phiếu theo thời gian thực, ghi điểm, và xếp hạng. Tuy nhiên, điều này dẫn đến sự xung đột, không ổn định đối với trải nghiệm người dùng khi mà bài viết nhảy chỗ này qua chỗ khác trên trang chủ. Chúng ta sẽ dùng animation để làm trơn vấn đề này.

Giới thiệu _uihooks

_uihooks là một tính năng còn mới và chưa được tạo tài liệu của Blaze. Như tên gọi, nó cho phép chúng ta truy cập tới hàm hook mà có thể kích hoạt mỗi khi có thành phần mới được chèn vào, xoá bỏ hoặc khi tạo animation.

Danh sách đầy đủ hàm hook như bên dưới:

  • insertElement: được gọi mỗi khi có thành phần mới được chèn vào.
  • moveElement: được gọi mỗi khi có thành phần thay đổi vị trí.
  • removeElement: được gọi khi một thành phần bị xoá.

Một khi đã được định nghĩa, những hàm hook này sẽ thay thế hành vi mặc định của Meteor. Hay nói theo cách khác, thay vì việc chèn mới, di chuyển hoặc xoá phần tử, Meteor sẽ làm bất kỳ thứ gì mà chúng ta mô tả - và chúng ta hoàn toàn có thể điều khiển những hoạt động này như ý muốn!

Meteor & DOM

Trước khi bắt đầu vào phần thú vị (là làm cho mọi thứ di chuyển), chúng ta cần phải hiểu cách Meteor tương tác với DOM (Document Object Model – tổ hợp những thành phần HTML làm nên nội dung của trang).

Điểm cốt yếu phải chú ý là thành phần DOM thực sự không có khả năng “di chuyển”; tuy nhiên, chúng có thể được xoá và tạo lại (chú ý rằng đây là hạn chế của chính bản thân DOM, không phải của Meteor). Vì vậy để tạo cảm giác phần tử A và B thay đổi vị trí, Meteor sẽ thực sự xoá thành phần B và chèn một thành phần sao chép mới (B’) trước thành phần A.

Điều này làm cho animation cần một chút thủ thuật, do chúng ta không thể tạo animation cho B di chuyển tới vị trí mới, vì B sẽ biến mất ngay khi Meteor tạo lại trang (điều này xảy ra ngay lập tức vì khả năng phản ứng lại của Meteor). Xin đừng lo lắng, chúng ta sẽ tìm ra cách.

Vận động viên chạy Soviet

Nhưng trước hết, hãy bắt đầu với một câu chuyện.

Đó là vào năm 1980, trong giai đoạn của chiến tranh lạnh. Olympics được tổ chức tại Moscow, và người Soviet quyết định phải chiến thắng giải chạy 100 mét bằng mọi giá. Vì vậy một nhóm nhà khoa học thông minh Soviet trang bị cho vận động viên với thiết bị di chuyển tức thời, ngay khi nghe thấy tiếng súng bắt đầu, vận động viên biến mất chớp nhoáng, và ngay lập tức hiện ra lại trong không-thời gian liên tục tại điểm kết thúc.

May mắn là giám khảo cuộc đua đã sớm nhận ra sự vi phạm, và vận động viên đó không còn cách nào khác là di chuyển tức thời về lại vạch xuất phát, trước khi được phép tham gia cuộc đua như mọi vận động viên khác.

Nguồn gốc của câu chuyện lịch sử này có thể không thực sự đáng tin cậy, vì vậy bạn không nên tin nó hoàn toàn đúng. Nhưng hãy giữ lại “người vận động viên Soviet và thiết bị di chuyển tức thời” trong đầu khi đi tiếp nội dung chương này.

Chia nhỏ

Khi Meteor nhận được cập nhật và phản ánh lại vào DOM, bài viết của chúng ta sẽ được dịch chuyển tức thời tới vị trí cuối cùng của chúng, và giống như là vận động viên soviet. Nhưng dù cho trong trường hợp Olympics hay trong ứng dụng của chúng ta, việc di chuyển tức thời là không thể. Vì vậy, chúng ta di chuyển thành phần về “vị trí xuất phát” và làm cho nó “chạy” (hay nói cách khác, tạo animation cho nó) tới vị trí đích.

Bởi vậy để thay đổi vị trí bài viết A và B (đang được đặt tại vị trí p1 và p2 theo thứ tự), chúng ta sẽ đi theo các bước sau:

  1. Xoá B
  2. Tạo B’ phía trước A trong DOM
  3. Di chuyển tức thời B’ tới p2
  4. Di chuyển tức thời A tới p1
  5. Tạo animation A tới p2
  6. Tạo animation B’ tới p1

Biểu đồ sau giải thích những bước trên chi tiết hơn:

Switching two posts
Switching two posts

Một lần nữa, trong bước 3 và 4 chúng ta đã không tạo animation giữa A và B’ tới vị trí của chúng mà “di chuyển tức thời” chúng. Vì điều này xảy ra ngay lập tức, chúng sẽ có hiệu ứng là B đã không bị xoá, và cả hai thành phần đều được chuyển tới vị trí mới.

Mặc định, Meteor có thể giải quyết được bước 1 & 2, và việc thực hiện lại chúng khá là dễ dàng. Và ở bước 5 và 6, tất cả việc chúng ta phải làm là di chuyển thành phần tới vị trí mới. Do vậy, phần mà chúng ta thực sự cần phải quan tâm là bước 3 và 4, gửi các thành phần tới điểm bắt đầu animation.

Thay đổi vị trí với CSS

Để tạo animation cho bài viết cần sắp xếp, chúng ta sẽ phải dấn thân vào mảnh đất CSS. Một chút ôn lại về thay đổi vị trí với CSS có thể sẽ cần thiết.

Thành phần trên trang web mặc định dùng vị trí tĩnh. Thành phần được đặt vị trí tĩnh phù hợp với luồng của trang, và toạ độ của chúng trên màn hình không thể thay đổi hoặc tạo animation.

Vị trí relative (tương đối) thì khác, chúng là thành phần phù hợp với luồng của trang, nhưng đồng thời cũng có thể thay đổi vị trí tương đối so với vị trí ban đầu.

Vị trí absolute (tuyệt đối) đi một bước xa hơn và để cho bạn thiết toạ độ x/y tương đối so với tài liệu hoặc thành phần cha tuyệt đối hoặc tương đối đầu tiên.

Chúng ta sẽ dùng vị trí tương đối để tạo animation cho bài viết. Chúng tôi đã tạo CSS cho bạn, nhưng nếu bạn muốn tự làm thì tất cả mọi việc cần thực hiện là thêm đoạn code sau vào stylesheet:

.post{
  position:relative;
  transition:all 300ms 0ms ease-in;
}
client/stylesheets/style.css

Điều này làm cho bước 5 và 6 dễ dàng hơn: tất cả mọi việc chúng ta cần làm là thiết lập lại top thành 0px (giá trị mặc định) và bài viết của chúng ta sẽ di chuyển trở lại vị trí “bình thường”.

Vì vậy về cơ bản, thử thách duy nhất của chúng ta là tìm ra nơi để từ đó tạo ra animation (bước 3 và 4) tương đối tới vị trí mới. Nói cách khác, phải bù trừ bao nhiêu. Nhưng điều đó cũng không thực sự khó: cách đơn giản là lấy vị trí của một bài viết cũ trừ đi vị trí mới của nó.

Cài đặt _uihooks

Bây giờ chúng ta đã hiều về những nhân tố khác nhau khi thử với việc tạo animation cho một danh sách hạng mục. Chúng ta thực sự đã sẵn sàng để cài đặt animation. Chúng ta sẽ bắt bằng việc bọc lại danh sách bài viết vào một thành phần chứa đựng .wrapper:

<template name="postsList">
  <div class="posts page">
    <div class="wrapper">
      {{#each posts}}
        {{> postItem}}
      {{/each}}
    </div>

    {{#if nextPath}}
      <a class="load-more" href="{{nextPath}}">Load more</a>
    {{else}}
      {{#unless ready}}
        {{> spinner}}
      {{/unless}}
    {{/if}}
  </div>
</template>
/client/templates/posts/post_list.html

Trước khi chúng ta làm bất kỳ thứ gì khác, hãy cùng nhìn lại hoạt động của bài viết hiện tại, mà không có animation:

The non-animated post list.
The non-animated post list.

Hãy cùng mang đến _uihooks. Chúng ta sẽ chọn div .wrapper đó bên trong template callback rendered, và định nghĩa một thành phần hook moveElement.

Template.postsList.rendered = function () {
  this.find('.wrapper')._uihooks = {
    moveElement: function (node, next) {
      // do nothing for now
    }
  }
}
/client/templates/posts/post_list.js

Hàm moveElementmà chúng ta vừa định nghĩa sẽ được gọi mỗi khi vị trí của một thành phần mới thay đổi thay vì hoạt động mặc định của Blaze. Và do hàm đó đang rỗng, điều đó nghĩa là không có gì diễn ra.

Hãy thử nó: mở phần hiển thị “Best“ và upvote một vài bài viết: thứ tự sẽ không bị thay đổi cho tới khi bạn bắt buộc nó dịch lại trang (bằng việc tải lại trang hoặc thay đổi route).

An empty moveElement callback: nothing happens
An empty moveElement callback: nothing happens

Chúng ta vừa kiểm chứng rằng _uihooks hoạt động. Bây giờ hãy tạo animation cho nó!

Tạo animation cho việc sắp xếp lại bài viết

Hàm hook moveElement nhận vào hai tham số: nodenext.

  • node là thành phần đang được di chuyển tới vị trí mới trong DOM.
  • next là thành phần ngay sau vị trí mới mà node đang được di chuyển tới.

Biết được điều này, chúng ta có thể tạo ra quá trình animation như sau (hãy tham chiếu lại phần ví dụ về “vận động viên Soviet” nếu cần). Khi mà một sự thay đổi được tìm ra, chúng ta sẽ:

  1. Chèn node vào trước next (nói cách khác, hoạt động mặc định sẽ diễn ra nếu chúng ta không đặc tả hàm hook moveElement nào).
  2. Di chuyển node quay trở lại vị trí ban đầu của nó.
  3. Nhích mọi thành phần giữa nodenext để tạo không gian cho node.
  4. Tạo animation cho tất cả thành phần trở lại ví trí mặc định mới.

Chúng ta sẽ làm tất cả điều này thông qua thư viện jQuery, là thư viện xử lý DOM tốt nhất hiện nay. jQuery thực ra nằm ngoài phạm vi của cuốn sách, nhưng hãy cùng lướt qua một số method jQuery hữu ích mà chúng ta sẽ sử dụng:

  • $(): bọc bất kỳ thành phần DOM nào thành một object jQuery.
  • offset(): gọi ra vị trí hiện tại của một thành phần tương đối với tài liệu, và trả về một object chứa thuộc tính topleft.
  • outerHeight(): lấy ra độ dài “bên ngoài” (bao gồm padding và margin) của một thành phần.
  • nextUntil(selector): lấy tất cả phần tử phía sau phần tử mục tiêu cho tới (nhưng không bao gồm) phần tử match với selector.
  • insertBefore(selector): chèn một thành phần trước thành phần match với selector.
  • removeClass(class): xoá bỏ class CSS class nếu xuất hiện trên thành phần.
  • css(propertyName, propertyValue): thiết lập thuộc tính CSS propertyName thành propertyValue.
  • height(): lấy độ cao của một thành phần.
  • addClass(class): thêm class CSS class CSS vào một thành phần.
Template.postsList.rendered = function () {
  this.find('.wrapper')._uihooks = {
    moveElement: function (node, next) {
      var $node = $(node), $next = $(next);
      var oldTop = $node.offset().top;
      var height = $node.outerHeight(true);

      // find all the elements between next and node
      var $inBetween = $next.nextUntil(node);
      if ($inBetween.length === 0)
        $inBetween = $node.nextUntil(next);

      // now put node in place
      $node.insertBefore(next);

      // measure new top
      var newTop = $node.offset().top;

      // move node *back* to where it was before
      $node
        .removeClass('animate')
        .css('top', oldTop - newTop);

      // push every other element down (or up) to put them back
      $inBetween
        .removeClass('animate')
        .css('top', oldTop < newTop ? height : -1 * height)


      // force a redraw
      $node.offset();

      // reset everything to 0, animated
      $node.addClass('animate').css('top', 0);
      $inBetween.addClass('animate').css('top', 0);
    }
  }
}
/client/templates/posts/post_list.js

Một vài ghi chú:

  • Chúng ta tính toán độ dài của $node để biết xem phải bù vào thành phần $inBetween bao nhiêu. Chúng ta dùng outerHeight(true) để có margin và padding làm nhân tố trong việc tính toán.
  • Chúng ta không biết là next tới trước hay là sau node khi đi dần xuống DOM. Vì vậy chúng ta kiểm tra cả hai cấu hình khi định nghĩa $inBetween.
  • Để chuyển giữa thành phần “di chuyển tức thời” and “animation”, chúng ta đơn giản dịch chuyển CSS class animate tắt và mở (animation thực sự được định nghĩa trong code CSS ứng dụng).
  • Vì chúng ta đang sử dụng vị trí tương đối, chúng ta có thể luôn luôn điều chỉnh lại bất kỳ thành phần thuộc tính top nào trở về 0 để đưa lại vị trí mà thuộc về.

Bắt buộc vẽ lại

Bạn có thể đang thắc mắc về dòng $node.offset(). Tại sao chúng ta lại hỏi vị trí của $node nếu như chúng ta không định làm gì đó với nó?

Hãy theo cách này: nếu như bạn bảo một thiết bị người máy thông minh chạy về hướng bắc 5 km, và sau khi đã hoàn thành thì chạy lại vị trí ban đầu, nó có thể sẽ suy ra rằng việc kết thúc tại cùng vị trí có thể giảm chi phí năng lượng và không chạy đi đâu cả.

Vì vậy để chắc chắn rằng thiết bị người máy của chúng ta chạy tổng cộng 10km, chúng ta sẽ bảo nó đo lại toạ độ tại thời điểm 5km trước khi quay đầu về.

Trình duyệt cũng hoạt động theo cách tương tự: nếu chúng ta đưa ra cả lệnh css('top', oldTop - newTop)css('top', 0) một cách liên tục, toạ độ mới sẽ đơn giản thay thế toạ độ cũ và không có gì xảy ra. Nếu chúng ta thực sự muốn thấy animation, chúng ta cần phải bắt buộc trình duyệt vẽ lại thành phần sau khi vị trí đầu đã thay đổi.

Một cách đơn giản để bắt buộc vẽ lại là bảo trình duyệt kiểm tra thành phần offset – nó không thể biết đó là gì cho đến khi đã vẽ lại thành phần một lần nữa.

Hãy tạo ra sự quay vòng. Hãy quay trở lại phần hiển thị “Best” và bắt đầu upvoting: bạn sẽ thấy bài viết lướt lên và xuống với vẻ duyên dáng như đang múa ba lê!

Animated reordering
Animated reordering

Commit 14-1

Added post reordering animation.

Không thể làm mờ dần

Bây giờ khi chúng ta đã hoàn thành phần thủ thuật cho việc sắp xếp, phần tiếp theo sẽ về bài viết với animation được chèn vào và xoá đi!

Đầu tiên, chúng ta sẽ fade in bài viết mới (chú ý rằng vì mục đích đơn giản, chúng ta sử dụng JavaScript animation trong lần này):

Template.postsList.rendered = function () {
  this.find('.wrapper')._uihooks = {
    insertElement: function (node, next) {
      $(node)
        .hide()
        .insertBefore(next)
        .fadeIn();
    },
    moveElement: function (node, next) {
      //...
    }
  }
}
/client/templates/posts/post_list.js

Để có một bức tranh rõ ràng, chúng ta có thể kiểm tra animation mới bằng việc chèn bài viết thông qua dòng lệnh với:

Meteor.call('postInsert', {url: 'http://apple.com', title: 'Testing Animations'})
Fading in new posts
Fading in new posts

Và sau đó chúng ta sẽ fade out bài viết đã xoá:

Template.postsList.rendered = function () {
  this.find('.wrapper')._uihooks = {
    insertElement: function (node, next) {
      $(node)
        .hide()
        .insertBefore(next)
        .fadeIn();
    },
    moveElement: function (node, next) {
      //...
    },
    removeElement: function(node) {
      $(node).fadeOut(function() {
        $(this).remove();
      });
    }
  }
}
/client/templates/posts/post_list.js

Một lần nữa, hãy đơn giản xoá bài viết thông qua dòng lệnh (dùng Posts.remove('somePostId')) để thấy hiện ứng diễn ra.

Fading out deleted posts
Fading out deleted posts

Commit 14-2

Fade items in when they are drawn.

Chuyển tiếp trang

Cho đến giờ chúng ta đã tạo thành phần animation bên trong một trang. Nhưng điều gì nếu như chúng ta muốn thêm hiệu ứng chuyển tiếp giữa các trang?

Việc chuyển tiếp trang là công việc của Iron Router. Bạn bấm vào một đường dẫn, và nội dung của helper {{> yield}} trong layout.html được tự động thay đổi.

Nó giống như chúng ta đã thay đổi hoạt động mặc định của Blaze cho danh sách bài viết, chúng ta có thể làm điều tương tự cho {{> yield}} để thêm vào hiệu ứng fade giữa các route!

Nếu chúng ta muốn fade in và out trang web, chúng ta sẽ phải chắc chắn rằng trang này hiển thị trên chóp của trang khác. Chúng ta làm điều đó bằng việc dùng position:absolute trên div container.page mà bọc mọi template của trang.

Chúng ta không muốn trang hoàn toàn được đặt vị trí tương đối với cửa sổ window, vì nó sẽ che mất header của ứng dụng. Vì vậy chúng ta sẽ để position:relative cho div #main để position:absolute của div .page nhận nguồn từ #main.

Để tiết kiệm thời gian, chúng tôi đã tạo sẵn code CSS cần thiết cho style.css:

//...

#main{
  position: relative;
}
.page{
  position: absolute;
  top: 0px;
  width: 100%;
}

//...
/client/stylesheets/style.css

Đã đến lúc để thêm vào code fade cho trang. Nó cũng khá quen thuộc, vì nó là đoạn code mà chúng ta đã dùng cho việc chèn và xoá bài viết:

Template.layout.rendered = function() {
  this.find('#main')._uihooks = {
    insertElement: function(node, next) {
      $(node)
        .hide()
        .insertBefore(next)
        .fadeIn();
    },
    removeElement: function(node) {
      $(node).fadeOut(function() {
        $(this).remove();
      });
    }
  }
}
/client/templates/application/layout.js
Transitioning in-between pages with a fade
Transitioning in-between pages with a fade

Commit 14-3

Transition between pages by fading.

Chúng ta vừa xem xét một vài mô hình cho việc tạo animation cho thành phần trong ứng dụng Meteor. Trong khi đây không phải là một danh sách xem xét hết mọi khía cạnh, hi vọng rằng nó cung cấp phần cơ bản để xây dựng sự chuyển tiếp chi tiết hơn.

Tiến xa hơn

14.5

Hi vọng rằng những chương vừa qua đã đưa cho bạn một cái nhìn khái quát về những việc phải làm khi xây dựng một ứng dụng Meteor. Vậy chúng ta sẽ bước tiếp như thế nào?

Chương thêm vào

Trước hết, nếu bạn vẫn chưa định kết thúc, bạn có thể mua bản Full hoặc Premium để có quyền truy cập tới những chương thêm. Những chương này sẽ giúp bạn giải những tình huống trong thế giới thực như là xây dựng API cho ứng dụng, thêm vào dịch vụ của bên thứ ba, chuyển đổi ứng dụng.

Cẩm nang Meteor

Bổ sung thêm vào tài liệu chính quy, cẩm nang Meteor là một tài liệu đào sâu hơn vào những chủ đề đặc thù như là Tracker và Blaze.

Evented Mind

Nếu bạn muốn đào sâu hơn nữa vào phần phức của Meteor, chúng tôi đặc biệt khuyến khích bạn kiểm tra Evented Mind của Chris Mather, một hệ thống học bằng video với hơn 50 video riêng lẻ (và video mới được thêm vào hàng tuần).

MeteorHacks

Một trong những cách tốt nhất để giữ cập nhật với Meteor là subscribe tới thư tin tức hàng tuần MeteorHacks’ của Arunoda Susiripala. Blog MeteorHacks cũng là một nguồn tuyệt vời cho những đề tài nâng cao của Meteor.

Atmosphere

Atmosphere, kho package không chính thức của Meteor, là một nơi tuyệt vời nữa để học hỏi thêm: bạn có thể khám phá package mới và xem xét code của chúng để tìm ra mô hình người ta đang sử dụng.

(Phủ nhận trách nhiệm: Atmosphere được bảo trì một phần bởi Tom Coleman, một trong những tác giả của cuốn sách.)

Meteorpedia

Meteorpedia là trang wiki cho mọi thứ của Meteor. Và dĩ nhiên, nó được tạo ra bằng Meteor!

BulletProof Meteor

Thêm một đề xướng từ Arunoda MeteorHacks, BulletProof Meteor sẽ đưa bạn tới những bài học Meteor tập trung vào vấn đề hiệu suất, giúp bạn kiểm tra kiến thức thông qua các chủ đề dạng Q&A.

Meteor Podcast

Josh và Ry từ cửa hàng Meteor Differential ghi âm lại Meteor Podcast hàng tuần, và nó là một cách tuyệt vời để tiếp tục theo dõi điều gì đang diễn ra trong cộng đồng Meteor.

Những nguồn khác

Stephan Hochhaus đã biên soạn một danh sách tài nguyên học Meteor chi tiết.

Blog của Manuel Schoebel là một nguồn tuyệt vời nữa với những bài viết về Meteor. Và tương tự là blog của Gentlenode.

Nhận sự giúp đỡ

Nếu bạn gặp phải trở ngại khó khăn, cách tốt nhất là hỏi Stack Overflow. Hãy chắc chắn là bạn đặt câu hỏi với nhãn meteor.

Cộng đồng

Cuối cùng, cách tốt nhất để giữ cập nhật với Meteor là tích cực trong cộng đồng. Chúng tôi khuyến khích đăng ký Meteor mailing list, theo dõi Meteor CoreMeteor Talk Google Groups, và tạo một tài khoản trong forum Meteor Crater.io.

Từ vựng Meteor

99

Client

Client là browser trên các loại máy tính hay thiết bị cầm tay của người dùng

Collection

Collection là Tập hợp data đồng bộ hoá giưa client và server

Computation

Là quá trình tính toán đến khi nguồn data thay đổi trong một đoạn code

Cursor

Con trỏ là khái niệm về kết quả của một query trong Mongodb

DDP

DDP là Meteor giao thức để đồng bộ hoá thông tin ở client và server

Tracker

Tracker là một quá trình chạy liên tục trong background của hệ thống

Document

Document là khái niệm một record trong Mongodb collection

Helpers

Helpers là khái niệm các hàm hỗ trợ xử lí data

Latency Compensation

Thời gian bù trừ

Meteor Development Group (MDG)

Nhóm phát triển Meteor (MDG)

Method

Phương pháp

MiniMongo

MiniMongo

Package

Gói

Publication

Publication

Server

Server là máy chủ chạy ứng dụng

Session

Session

Subscription

Subscription

Template

Template

Template Data Context

Hoàn cảnh của dữ liệu trong template

Changelog

99

December 5, 2014 1.7.2

  • ////
  • ////
  • ////