ejs 淺析

geograous發表於2018-06-23

ejs是一種歷史悠久的模版,具有簡單、效能好、使用廣泛的特點。雖然沒有vuereact這些專案流行,但還是有使用的場合和學習的價值。這裡會介紹ejs專案的原始碼。使用方法詳見專案的readme,或者這裡

哲學
ejs是字串模版引擎,生成的是字串,其實可以被用到非常多的地方,只要是動態生成字串,就都可以用到。它的思想是模版 + 資料 => 最終的字串模版是字串的格式,包含可變部分的和固定的部分,可變的部分通過資料來控制。通過使用include方法引用其他模版。這個模型就比較符合前端開發的需要了。

一些概念

  • template:即模版,比如這個栗子?。
<% if (user) { %>
  <h2><%= user.name %></h2>
<% } %>
  • data: 模版對應的資料,具有模版中使用的所有變數,比如上面這個栗子中必須具有user.name這個資料。
  • option:模版配置項。有這些?:

    • cache Compiled functions are cached, requires filename
    • filename The name of the file being rendered. Not required if you
      are using renderFile(). Used by cache to key caches, and for includes.
    • root Set project root for includes with an absolute path (/file.ejs).
    • context Function execution context
    • compileDebug When false no debug instrumentation is compiled
    • client When true, compiles a function that can be rendered
      in the browser without needing to load the EJS Runtime
      (ejs.min.js).
    • delimiter Character to use with angle brackets for open/close
    • debug Output generated function body
    • strict When set to true, generated function is in strict mode
    • _with Whether or not to use with() {} constructs. If false
      then the locals will be stored in the locals object. Set to false in strict mode.
    • localsName Name to use for the object storing local variables when not using
      with Defaults to locals
    • rmWhitespace Remove all safe-to-remove whitespace, including leading
      and trailing whitespace. It also enables a safer version of -%> line
      slurping for all scriptlet tags (it does not strip new lines of tags in
      the middle of a line).
    • escape The escaping function used with <%= construct. It is
      used in rendering and is .toString()ed in the generation of client functions.
      (By default escapes XML).
    • outputFunctionName Set to a string (e.g., `echo` or `print`) for a function to print
      output inside scriptlet tags.
    • async When true, EJS will use an async function for rendering. (Depends
      on async/await support in the JS runtime.
  • compile:編譯函式,把template和option轉化為一個函式,往這個函式中注入資料,生成最終的字串,不一定是html哦,還可以是各種形式的字串。
  • render:渲染函式,直接把template、data和option轉化為最終的字串。

主流程
ejs引擎的實現思路是把配置的模版轉化為渲染的函式,再通過的資料生成字串。把模版轉化為渲染函式的這個過程就是compile。它的主要工作就是通生成函式輸入和函式體的字串,再通過Function這個類來生成函式。執行流程分別為:

  1. 根據正規表示式切割模版,比如{ key1 = <%= key1 %>, 2key1 = <%= key1+key1 %> }會被切割成[ `{ key1 = `, `<%=`, ` key1 `, `%>`, `, 2key1 = `, `<%=`, ` key1+key1 `, `%>`, ` }` ]
  2. 根據切割後的資料,生成渲染函式中的執行步驟。上面這個栗子中,執行步驟為
`    ; __append("{ key1 = ")
    ; __append(escapeFn( key1 ))
    ; __append(", 2key1 = ")
    ; __append(escapeFn( key1+key1 ))
    ; __append(" }")
`
  1. 組裝函式,通過prepend+執行步驟+append的模式生成函式體的字串,最後生成這樣的函式頭、函式體,以及渲染函式。
opts.localsName + `, escapeFn, include, rethrow`
var __output = [], __append = __output.push.bind(__output);
  with (locals || {}) {
     ; __append("{ key1 = ")
    ; __append(escapeFn( key1 ))
    ; __append(", 2key1 = ")
    ; __append(escapeFn( key1+key1 ))
    ; __append(" }")
   }
  return __output.join("");
function (data) {
  var include = function (path, includeData) {
    var d = utils.shallowCopy({}, data);
    if (includeData) {
      d = utils.shallowCopy(d, includeData);
    }
    return includeFile(path, opts)(d);
  };
  return fn.apply(opts.context, [data || {}, escapeFn, include, rethrow]);
}

通過渲染函式和資料生成結果的方式和react比較像哈。但ejs在不直接支援巢狀,而是通過include方法呼叫子模版的渲染函式。

一些細節
渲染函式的4個引數:dataescapeFnincluderethrow

  • data: 傳入的資料。
  • escapeFn: 轉義函式。
  • include: 引入子模版函式。主要的邏輯是根據路徑獲取模版,並且編譯生成渲染函式進行快取,最後進行渲染。
var include = function (path, includeData) {
    var d = utils.shallowCopy({}, data);
    if (includeData) {
      d = utils.shallowCopy(d, includeData);
    }
    return includeFile(path, opts)(d);
  };
  • rethrow: 丟擲異常函式。

生成渲染函式的執行步驟。
這一步是在模版被切割之後進行的,首先模版遇到ejs的標籤時就會被切割,切割後的字串中標籤是成對出現的,引用一下上面的栗子。

[ `{ key1 = `, `<%=`, ` key1 `, `%>`, `, 2key1 = `, `<%=`, ` key1+key1 `, `%>`, ` }` ]

ejs會根據不同的標籤生成不同的執行步驟。執行過程中會遍歷整個陣列。由於標籤不能巢狀,而且成對出現,正好可以利用全域性的的變數,儲存當前標籤的型別,執行到夾在一對標籤中的內容時,可以獲取到外層標籤資訊。當執行到閉合標籤時,重置標籤資訊。

關於路徑
先了解一下include方法,ejs的語法不支援巢狀,只能通過這個方法來複用模版。下面是一個使用的栗子。

<ul>
  <% users.forEach(function(user){ %>
    <%- include(`user/show`, {user: user}) %>
  <% }); %>
</ul>

在使用include方法時,需要傳入複用template的路徑和data。路徑的邏輯先會看是否是絕對路徑,然後會拼接傳入的路徑引數和options.filename,如果不存在這個檔案最後看views的目錄下是否存在這個檔案,程式碼請看?

function getIncludePath(path, options) {
  var includePath;
  var filePath;
  var views = options.views;

  // Abs path
  if (path.charAt(0) == `/`) {
    includePath = exports.resolveInclude(path.replace(/^/*/,``), options.root || `/`, true);
  }
  // Relative paths
  else {
    // Look relative to a passed filename first
    if (options.filename) {
      filePath = exports.resolveInclude(path, options.filename);
      if (fs.existsSync(filePath)) {
        includePath = filePath;
      }
    }
    // Then look in any views directories
    if (!includePath) {
      if (Array.isArray(views) && views.some(function (v) {
        filePath = exports.resolveInclude(path, v, true);
        return fs.existsSync(filePath);
      })) {
        includePath = filePath;
      }
    }
    if (!includePath) {
      throw new Error(`Could not find the include file "` +
          options.escapeFunction(path) + `"`);
    }
  }
  return includePath;
}

這就意味著在使用include的時候,子template檔案只能在views目錄下,字尾為ejs的檔案。或者設定options.filename變數,檔案分佈在不同的目錄下。這個就比較坑了,使用起來很不方便。當巢狀層次比較高時,怎麼複用模版?貌似只能通過絕對路徑的方式了。