控件 (Widgets)

Web开发的一个挑战是我们要整合三种不同的客户端技术:HTML、CSS和Javascript。更糟 的是,我们必须把它们放在页面的不同位置:CSS要放在头部的style标签内,Javascript 要放在头部的script标签里,HTML要放在正文里。如果你想把CSS和Javascript放在外部 文件中,也完全没有问题!

在实践中,这种做法在构建单个网页时能很好工作,因为我们可以将结构(HTML)、样式 (CSS)和逻辑(Javascript)相互分离。但如果我们想构建容易组合的代码模块,要协调这 三个分离的部分就会有点头疼。控件是Yesod对这一问题的解决方法。控件也能帮助避免 重复引用类库,如jQuery。

我们的四门模板语言——Hamlet、Cassius、Lucius和Julius——提供了构建输出的原始工具 。控件是让它们完美结合在一起的黏合剂。

概要

  1. {-# LANGUAGE OverloadedStrings #-}
  2. {-# LANGUAGE QuasiQuotes #-}
  3. {-# LANGUAGE TemplateHaskell #-}
  4. {-# LANGUAGE TypeFamilies #-}
  5. import Yesod
  6. data App = App
  7. mkYesod "App" [parseRoutes|
  8. / HomeR GET
  9. |]
  10. instance Yesod App
  11. getHomeR = defaultLayout $ do
  12. setTitle "My Page Title"
  13. toWidget [lucius| h1 { color: green; } |]
  14. addScriptRemote "https://ajax.googleapis.com/ajax/libs/jquery/1.6.2/jquery.min.js"
  15. toWidget
  16. [julius|
  17. $(function() {
  18. $("h1").click(function(){
  19. alert("You clicked on the heading!");
  20. });
  21. });
  22. |]
  23. toWidgetHead
  24. [hamlet|
  25. <meta name=keywords content="some sample keywords">
  26. |]
  27. toWidget
  28. [hamlet|
  29. <h1>Here's one way of including content
  30. |]
  31. [whamlet|<h2>Here's another |]
  32. toWidgetBody
  33. [julius|
  34. alert("This is included in the body itself");
  35. |]
  36. main = warp 3000 App

这会生成如下的HTML代码(缩进是我增加的):

  1. <!DOCTYPE html>
  2. <html>
  3. <head>
  4. <title>My Page Title</title>
  5. <meta name="keywords" content="some sample keywords">
  6. <style>h1{color:green}</style>
  7. </head>
  8. <body>
  9. <h1>Here's one way of including content</h1>
  10. <h2>Here's another</h2>
  11. <script>
  12. alert("This is included in the body itself");
  13. </script>
  14. <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.6.2/jquery.min.js">
  15. </script><script>
  16. $(function() {
  17. $('h1').click(function() {
  18. alert("You clicked on the heading!");
  19. });
  20. });
  21. </script>
  22. </body>
  23. </html>

控件由哪些元素构成?

从非常浅的层面来说,一个HTML文档不过是一丢嵌套的标签。大部分HTML生成工具都是这 样做的:你定义好标签的层级就可以了。但假设我需要给一个页面写一个组件用来显示导 航栏。我希望它可以“即插即用”:我在适当的时候调用这个函数,导航栏就会被插入到正 确的位置(标签层级)。

这是我们简陋的HTML生成工具失效的地方。我们的导航栏除了HTML,可能还包含了一些 CSS和JavaScript。当我们调用导航栏函数时,<head>标签已经生成过了,因此要将包 含(导航栏)CSS的<style>标签加进去已经太迟了。通常的办法是,我们需要将导航栏函 数分为三部分:HTML、CSS和JavaScript,并确保我们总是同时调用这三部分。

控件采取的是另一种做法。不把HTML文档视为一个单一的标签树,而是视为许多不同的页 面组件。特别是:

  • 标题

  • 外部CSS

  • 外部Javascript

  • CSS声明

  • Javascript代码

  • 任意的内容

  • 任意的内容

不同的组件有不同的语义。比如,只能有一个标题,但可以有多个外部脚本和样式表。然 而,每个外部脚本或样式表应该只被引用一次。另一方面,任意的head和body内容是没有 限制的(有些人可能只想放五段lorem ipsum而已)。

控件的任务是保持各个组件的相互独立,同时用适当的逻辑将它们组合在一起。这包括: 取第一个标题而忽略其它标题,过滤重复的外部脚本和样式表引用,拼接head和body的内 容,等等。

构造控件

为了使用控件,你当然需要能够构造他们。最常用的方法是通过ToWidget型类,以及它 的toWidget方法。它能把你的莎氏模板直接转换成Widget:Hamlet代码会出现在正文 ,Julius脚本会在头部的<script>标签里,Cassius和Lucius会在(头部的)<style>标 签里。

注意 你实际上可以覆盖默认行为,让脚本和样式代码出现在各自的外部文件里。脚手架 项目自动为你完成这一点。

但如果你想增加一些<meta>标签呢?它们也需要放在头部。或者如果你想让Javascript 出现在正文而不是头部呢?为此,Yesod提供了另外两个型类:ToWidgetHead和 ToWidgetBody。这两个类(的作用)就像它们名字说的一样。

另外,还有一些其它函数用来创建特殊的控件:

  • setTitle
    将一些HTML代码转换成页面标题。

  • toWidgetMedia
    与toWidget一样,但需要一个额外的参数来表示样 式所应用的媒介。这对于创建比如说打印样式会有用。

  • addStylesheet
    通过标签,增加一个外部样式表的引用。输入参数是类型安全 的URL。

  • addStylesheetRemote
    与addStylesheet一样,但输入参数是普通URL。对于引用托管 在CDN上的文件有用,比如Google CDN上的jQuery UI CSS文件。

  • addScript
    通过<script>标签,增加一个外部脚本的引用。输入参数是类型安全的 URL。

  • addScriptRemote
    与addScript一样,但输入参数是普通URL。对于引用托管在CDN上 的文件有用,比如Google CDN上的jQuery文件。

组合控件 (Combining Widgets)

控件的目的是增强可组合性。你可以将单独的HTML、CSS和Javascript组合成更复杂的结 构,然后再进一步组合成完整的页面。这些都能通过Widget的Monad实例很自然地实 现,也就是说你可以用do语句来组合控件。

  1. myWidget1 = do
  2. toWidget [hamlet|<h1>My Title|]
  3. toWidget [lucius|h1 { color: green } |]
  4. myWidget2 = do
  5. setTitle "My Page Title"
  6. addScriptRemote "http://www.example.com/script.js"
  7. myWidget = do
  8. myWidget1
  9. myWidget2
  10. -- or, if you want
  11. myWidget' = myWidget1 >> myWidget2
注意 如果你需要的话,Widget也是Monoid的实例。也就是说你可以使用mconcat 或Writer monad来组合控件。以我的经验来说,用do语句最简单也最自然。

生成ID

如果我们要进行真正的代码复用,我们总是会遇到命名冲突。假设我们有两个辅助库都用 了‘`foo’'这个类名来控制样式。我们想要避免这种情况。因此,我们有newIdent函数 。它会为当前的处理函数自动生成一个唯一的名字。

  1. getRootR = defaultLayout $ do
  2. headerClass <- newIdent
  3. toWidget [hamlet|<h1 .#{headerClass}>My Header|]
  4. toWidget [lucius| .#{headerClass} { color: green; } |]

whamlet

假设我们有一个标准的Hamlet模板,它嵌套了另一个Hamlet模板来表示页脚:

  1. page =
  2. [hamlet|
  3. <p>This is my page. I hope you enjoyed it.
  4. ^{footer}
  5. |]
  6. footer =
  7. [hamlet|
  8. <footer>
  9. <p>That's all folks!
  10. |]

如果页脚是普通的HTML,它能正常工作,但如果我们想要增加一些样式呢?好吧,我们可 以很容易的将页脚转换成一个控件:

  1. footer = do
  2. toWidget
  3. [lucius|
  4. footer {
  5. font-weight: bold;
  6. text-align: center
  7. }
  8. |]
  9. toWidget
  10. [hamlet|
  11. <footer>
  12. <p>That's all folks!
  13. |]

但我们有个问题:一个Hamlet模板只能嵌套另一个Hamlet模板;它不知道什么是控件。这 就是whamlet的用处了。它的语法与普通的Hamlet完全一致,并且变量插值(#{…})和 URL插值(@{…})也是一样的。但嵌套插值(^{…})的输入参数是一个控件,输出结果 也是一个控件。要使用它,只需要:

  1. page =
  2. [whamlet|
  3. <p>This is my page. I hope you enjoyed it.
  4. ^{footer}
  5. |]

如果你更喜欢把模板放在外部文件里的话,还可以用whamletFile函数。

注意 脚手架项目有一个更方便的函数,widgetFile,它会自动引用你的Lucius、 Cassius和Julius文件。我们会在“脚手架”一章中详述。

类型

你可能注意到了我一直在回避控件的类型标识。简单的答案是每个控件的类型都是 Widget。但如果你去Yesod类库里找,却找不到Widget的定义。怎么回事?

Yesod定义了一个非常相似的类型:data WidgetT site m a。这个数据类型是一个 monad transformer。最后两个参数是底层monad类型和monad值。site是你的应用的基 础数据类型。因为基础数据类型随每个站点而不同,不可能在类库里定义一个适用于所有 应用的Widget数据类型。

取而代之,mkYesod这个Haskell模板函数会为你生成类型别名。假设你的基础数据类型 是MyApp,那你的Widget的定义是这样的:

  1. type Widget = WidgetT MyApp IO ()

我们将monad的值设为(),因为一个控件的值最终是被丢弃的。IO是标准的基础monad ,几乎在所有情况下都会用到。唯一的例外是写子站(subsite)的时候。子站是一个更高 级的话题,会在它自己的章节中讲解。

一旦我们知道了Widget的类型,就很容易给前面的例子加上类型标识:

  1. footer :: Widget
  2. footer = do
  3. toWidget
  4. [lucius|
  5. footer {
  6. font-weight: bold;
  7. text-align: center
  8. }
  9. |]
  10. toWidget
  11. [hamlet|
  12. <footer>
  13. <p>That's all folks!
  14. |]
  15. page :: Widget
  16. page =
  17. [whamlet|
  18. <p>This is my page. I hope you enjoyed it.
  19. ^{footer}
  20. |]

等我们开始讲解处理函数时,我们会在HandlerT和Handler类型身上看到相似的情况 。

使用控件

我们有这么漂亮的控件数据类型已经很好了,但到底怎么把它们转换成用户可以与之交互 的东西?最常用的做法是defaultLayout函数,它的类型标识是Widget → Handler Html。

defaultLayout实际上是个型类的方法,它可以在每个应用中重新定义。这也是Yesod应 用定义主题的方法。所以我们剩下的问题是:在defaultLayout函数内,怎么拆开一个 Widget?答案是用widgetToPageContent函数。让我们看一下(简化了的)类型:

  1. widgetToPageContent :: Widget -> Handler (PageContent url)
  2. data PageContent url = PageContent
  3. { pageTitle :: Html
  4. , pageHead :: HtmlUrl url
  5. , pageBody :: HtmlUrl url
  6. }

距离我们的目标已经很近了。我们现在可以直接访问HTML的头部和正文,以及标题。至此 ,我们可以用Hamlet把它们与页面布局组合成一个文件,然后用giveUrlRenderer函数 将Hamlet的结果转换为实际呈现给用户的HTML。下面的代码说明了这个过程。

  1. {-# LANGUAGE OverloadedStrings #-}
  2. {-# LANGUAGE QuasiQuotes #-}
  3. {-# LANGUAGE TemplateHaskell #-}
  4. {-# LANGUAGE TypeFamilies #-}
  5. import Yesod
  6. data App = App
  7. mkYesod "App" [parseRoutes|
  8. / HomeR GET
  9. |]
  10. myLayout :: Widget -> Handler Html
  11. myLayout widget = do
  12. pc <- widgetToPageContent widget
  13. giveUrlRenderer
  14. [hamlet|
  15. $doctype 5
  16. <html>
  17. <head>
  18. <title>#{pageTitle pc}
  19. <meta charset=utf-8>
  20. <style>body { font-family: verdana }
  21. ^{pageHead pc}
  22. <body>
  23. <article>
  24. ^{pageBody pc}
  25. |]
  26. instance Yesod App where
  27. defaultLayout = myLayout
  28. getHomeR :: Handler Html
  29. getHomeR = defaultLayout
  30. [whamlet|
  31. <p>Hello World!
  32. |]
  33. main :: IO ()
  34. main = warp 3000 App

这都很好,但还有一件事困扰我:就是style标签。它有一些问题:

  • 不像Lucius和Cassius,它不能在编译时做正确性检查。

  • 虽然这个例子很简单,但在复杂的情况下,我们会遇到字符转义的问题。

  • 我们会有两个style标签而不是一个:一个是myLayout生成的,另一个是pageHead 基于控件内设置的样式生成的。

我们还有一个锦囊可以用:我们在调用widgetToPageContent前对控件做一些最后的调 整。其实非常简单:我们只是再次用了do语句。

  1. {-# LANGUAGE OverloadedStrings #-}
  2. {-# LANGUAGE QuasiQuotes #-}
  3. {-# LANGUAGE TemplateHaskell #-}
  4. {-# LANGUAGE TypeFamilies #-}
  5. import Yesod
  6. data App = App
  7. mkYesod "App" [parseRoutes|
  8. / HomeR GET
  9. |]
  10. myLayout :: Widget -> Handler Html
  11. myLayout widget = do
  12. pc <- widgetToPageContent $ do
  13. widget
  14. toWidget [lucius| body { font-family: verdana } |]
  15. giveUrlRenderer
  16. [hamlet|
  17. $doctype 5
  18. <html>
  19. <head>
  20. <title>#{pageTitle pc}
  21. <meta charset=utf-8>
  22. ^{pageHead pc}
  23. <body>
  24. <article>
  25. ^{pageBody pc}
  26. |]
  27. instance Yesod App where
  28. defaultLayout = myLayout
  29. getHomeR :: Handler Html
  30. getHomeR = defaultLayout
  31. [whamlet|
  32. <p>Hello World!
  33. |]
  34. main :: IO ()
  35. main = warp 3000 App

使用处理函数

我们至今还没怎么讲处理函数,但一旦开始讲,问题就来了:我们怎么在控件中使用这 些函数?比如,如果一个控件需要使用lookupGetParam来查询请求参数?

第一种答案是用handlerToWidget函数,它将一个Handler动作转换为一个Widget。 然而,在很多情况下并不需要这么做。来看看lookupGetParam函数的类型标识:

  1. lookupGetParam :: MonadHandler m => Text -> m (Maybe Text)

这个函数可以在任何MonadHandler的实例中使用。而且方便的是,Widget就是 MonadHandler的实例。这意味着大部分代码既可以在Handler中运行,也可以在 Widget中运行。而且如果你需要显式的将Handler转换为Widget,你还是可以用 handlerToWidget函数。

注意 这与Yesod 1.1及更早的版本有显著的区别。之前是没有MonadHandler这个型类 的,所有函数都需要显式的使用lift转换,而不是handlerToWidget。新版本不仅更 容易使用,而且也避免了旧版中使用的奇怪的monad transformer技巧。

小结

构筑每个页面的砖块是控件。独立的HTML、CSS和Javascript代码段可以通过多态的 toWidget函数转换成控件。使用do语句,可以将这些独立的控件组合成更大的控件,最 后构成页面的全部内容。

通常在defaultLayout函数中拆开这些控件,defaulLayout能将统一的外观风格应用到所 有页面。