## mya-jinja

基于 mya 的针对 jinja2 模板的前端工程解决方案


### 背景

* 利用后端模板的能力实现后端模块化（组件化）和静态资源动态加载，并以此为基础实现诸如 bigrender 等一系列优化方案
* 前后端分离和本地化开发
* 替换 django 默认模板引擎为 jinja2，提升模板渲染速度


### 方案构成

* jinja2扩展，负责解析自定义标签，并根据静态资源映射表渲染最终页面
* `mya-jinja`（mya插件），输出静态资源映射表，编译打包相关配置，路径修正等
* `mya-server-jinja`（mya插件），本地开发时提供静态服务器、模板渲染、mock数据、路由等功能
* `mya-optimizer-jinja-xss`（mya插件），jinja模板 xss 防范
* 其他：脚手架


### 预备工作

在阅读以下内容之前，建议可以先安装一下mya，然后利用脚手架初始化一个项目，对着项目看，会更容易理解以下内容。

```bash
npm install -g mya
mkdir project
cd project
mya init jinja2
mya server start --type jinja
npm run mock
```

然后访问 http://127.0.0.1:8080


### 自定义标签

#### html

```html
<!DOCTYPE html>
{% html framework="static/script/lib/mod.js" %}
<head></head>
<body></body>
{% endhtml %}
```

用于替换原生的 `html` 标签，输出 `<html>` 和 `</html>`

属性：

* **framework**: 指定项目依赖的模块加载器，支持本地文件路径和线上地址

**注意**:

1. 如果在页面中手动引用了模块加载器（eg. mya.js/mod.js）或者包含模块加载器的文件（eg. core.js），则无需添加 framework 属性
2. 可以通过命名空间引用其他模块的文件，比如 `framework="common:static/lib/mya.js"`
3. framework指定的路径可以是CDN地址，比如 `{% html framework="https://s3b.bytecdn.cn/ies/static/script/lib/mya.js" %}`

#### comp

```html
{% comp name="component/home/header/index.html" title="this is a title" username=user.username %}
{% comp name="common:component/common/button/index.html" %}
```

组件标签，加载组件对应的后端模板，并收集模板依赖的静态资源（开启了同名依赖，会自动加载和模板名同名的css和js）。类似于 `{% include "xxx" %}`。需要 **显式** 将组件所需的数据作为属性传递。

属性：

* **name**：模板文件的ID，形式为 `${namespace}:${relativePath}`，namespace 即项目的命名空间（参考 https://code.byted.org/ies/jinja2 中的 **命名空间**），relativePath 即模板文件相对所属项目的根目录的相对路径。在开发时，如果引用的组件模板在当前项目中，可以省略 `namespace`，mya会在构建过程中帮你添加；如果引用的组件在其他项目中（跨项目调用），则需要加上组件所在项目的 `namespace`。

* 其他：比如上面例子中的 `title`、`username`，都是组件所需的数据，通过显式传递，在组件模板内部就能够引用到这些变量。

举例：

```
|- common
    |- component
        |- button
            |- index.html
            |- index.scss
            |- index.js
|- A
    |- component
        |- home
            |- comp1
                |- index.html
                |- index.scss
                |- index.js
    |- page
        |- home
            |- index.html
            |- index.js
            |- index.scss
```

**A/page/home/index.html**

```html
<div>
{% comp name="common:component/button/index.html" %}
{% comp name="component/home/comp1/index.html" %}
</div>
```

在页面中通过 `comp` 引用组件，组件模板同级目录下的同名的css(scss/less)和js文件无需再手动引用（包括它们依赖的文件），它们最终会分别被插入到 `</head>` 和 `</body>` 之前。

如果想将css和js分别插入指定的位置，可以在页面中想插入的位置分别放置以下占位符： `<!-- STYLE_PLACEHOLDER -->`  `<!-- SCRIPT_PLACEHOLDER -->`。

#### script

```html
{% script %}
require('page/home/index').init({{ user|jsonify }});
require('common:component/button/index').init();
{% endscript %}
```

`script` 标签包裹的js语句会被收集起来，和页面依赖的js文件（组件js、页面入口js等）一同插入到body最后或者指定位置，其中页面依赖的js文件在前，`script` 标签包裹的js语句在后。多个 `script` 标签包含的语句会被合并到一对 `<script></script>` 当中，每个语句块会被自执行函数包裹起来。

比如上面例子实际渲染的结果是：

```html
<body>
    ...
    <script src="pkg.js"></script> <!-- 打包合并后的js -->
    <script>
    (function() {
        require('page/home/index').init({{ user|jsonify }});
        require('common:component/button/index').init();
    })();
    </script>
</body>
```

这里要重点说明下 `script` 标签的使用场景。首先要知道这里的 `require` 是 mod.js（默认使用的模块化框架） 中定义的require，它是去 **查找调用** 已经加载了的js模块，而不是去 **加载** js文件。也就是说 `require` 语句正常执行的前提是 `require` 中指定的js文件已经加载到页面中。

前面提到过，模板文件所在目录下同名的css和js会自动被加载，无需手动引用，这样的好处是当移除一个不再使用的组件或者复用一个组件时，可以很方便得删除或者copy代码。以删除为例，我们删掉页面中引用组件的 `comp` 标签，然后直接删除组件目录，就完成组件的清理，而不需要担心哪里还引用了组件的css或者js。

为了更好地实现组件的自我管理，把组件 **调用** 的语句放在组件模板中，就不需要在页面里面单独 `require` 了。所以通常我们会这么使用：

**A/component/home/comp1/index.html**

```html
<div class="home-comp1">
    ...
</div>
{% script %}
require('component/home/comp1/index').init({{ user|jsonify }})
{% endscript %}
```

**A/component/home/comp1/index.js**

```javascript
var util = require('component/util/index');
exports.init = function(user) {
    //...
}
```

通过以上说明，`script` 标签主要的用途是包裹 **组件调用** 的语句。如果有些内嵌脚本，如统计脚本，不想被收集到最后面，直接使用原生标签即可。

**注意：**

* 被 `script` 包裹的js语句不能使用es6，因为这些语句可能包含模板语法，在babel编译时会报错。
* require可以通过命名空间引用其他模块的文件，比如 `require('common:component/util/index')`

#### style

```html
{% style %}
.home-comp1 {
    color: red
}
{% endstyle %}
```

`style` 标签包裹的css语句会被收集起来，和页面依赖的css文件（组件css、页面入口css等）一同插入到head最后或者指定位置，其中页面依赖的css文件在前，`style` 标签包裹的css语句在后。多个 `style` 标签包含的语句会被合并到一对 `<style></style>` 当中。

`style` 标签使用的场景不多，其意图主要也是实现组件的自我管理。比如一个组件只有少量样式，不希望外链接，可以放在 `style` 标签中。

**注意：** `style` 标签中的内容不会参与编译，所以不能使用 `sass` 或者 `less` 语法。

#### require

```html
{% require src="static/script/lib/base.js" %}
{% require src="common:static/script/lib/mya.js" %}
{% require src="common:static/style/lib/core.css" %}
```

`require` 标签用于引用js或者css文件，支持跨模块引用。通过 require 引用的文件会按顺序收集起来，最终和组件js一起插入到body最后或者指定位置（在 {% script %} 标签收集的js之前）

#### uri

```html
    <img src="{% uri src='static/image/cn.png' %}" />
    <img src="{% uri src='static/image/{{_mya_locale}}.png' %}" /> <!-- 国际化场景 -->
```

`uri` 标签用于获取文件的线上路径（会输出实际 cdn 的路径），支持动态路径，用于国际化场景

### 项目开发

#### 前端项目

https://code.byted.org/ies/jinja2

#### 后端项目

在后端项目里引用 [mya_jinja_plugin](https://code.byted.org/ies/mya_jinja_plugin)。以django为例：

**settings.py**

```python
TEMPLATE_DIRS = (
    os.path.join(os.path.dirname(__file__), 'templates'),
)

# MYA 静态资源映射表存放目录
MYA_CONF_DIR = os.path.join(os.path.dirname(__file__), 'templates', 'template', 'mya_conf')
```

在配置文件中指定模板根目录以及存放静态资源映射表（见 **原理说明**）的目录。

**views.py**

```python
from django.http import HttpResponse
from mya_jinja.render_util import view

def index(request):
    return HttpResponse( view(request, 'A:page/home/index.html', data) )
```

引用 mya_jinja_plugin 中的 `view` 方法，传入模板id和模板所需数据。

`view` 方法参数：

* request: 请求对象
* template_id: 形式为 `${namespace}:${relativePath}`，**自定义标签** 中介绍 `comp` 标签时有提到。
* data: 模板数据


### 公共模块管理

对于多个项目公用的模块，可以作为项目的 submodule 存在，也可以作为一个符合 mya-jinja 解决方案规范的项目发布，然后在其他项目中通过命名空间引用。

#### submodule

```bash
cd project
git submodule add submodule_repo
git submodule update
```

#### 跨模块调用

将 common 模块作为一个符合 mya-jinja 目录规范的项目单独维护和发布。以本地开发为例：

```shell
cd project/common
npm run dev
cd project/A
npm run dev
```

首先把公共模块和当前项目都发布到本地server，然后在当前项目A中使用以下方式调用：

**aaa/index.html**

```html
{% comp name="common:component/button/index.html" %}
{% require("common:/static/srcipt/util/index.js") %} <!-- 加载js文件 相当于script标签引入 待支持 -->
```

**aaa/index.js**

```javascript
var util = require('common:/static/srcipt/util/index.js');
```

### 文件合并

脚手架默认生成了以下打包合并规则：

```javascript
/**
 * 说明：以下是默认配置，可以根据自己业务进行调整
 * 规则：
 * 1. 页面组件和页面入口文件打包到一个文件中，比如
    /component/home/comp1/index.js
    /component/home/comp2/index.js
    /page/home/index.js
    ->
    /page/home.js
 * 2. 公共组件打包到 common 文件中
 */

/**
 * 页面文件配置
 */
// 页面组件和页面入口 allInOne
fis.match('/component/(*)/**.js', {
    packTo: '/pkg/page/$1.js'
});
fis.match('/page/(*)/*.js', {
    packTo: '/pkg/page/$1.js'
});

fis.match('/component/(*)/**.{scss,less}', {
    packTo: '/pkg/page/$1.css'
});
fis.match('/page/(*)/*.{scss,less}', {
    packTo: '/pkg/page/$1.css'
});

/**
 * 公共文件配置
 */

// 项目公共组件
fis.match('/component/{api,common,const,util}/**.js', {
    packTo: '/pkg/common.js'
});

fis.match('/component/{api,common,const,util}/**.{scss,less}', {
    packTo: '/pkg/common.css'
});
```

需要说明的是，按照目录规范，component 下的目录（除了common、util、const、api等公共目录）都应该对应一个页面，比如 component/reflow_video、component/reflow_person，尽量不要出现 component/reflow/video component/reflow/person 的情况。page 下面可以出现分组，比如 page/reflow/reflow_video、page/reflow/reflow_person。可以看到，页面组件目录和页面入口目录的名字是相同的，这样做方面配置打包策略，让页面组件和页面入口合并到一个文件中。

比如：
```javascript
fis.match('/component/(*)/**.js', {
    packTo: '/pkg/page/$1.js'
});
fis.match('/page/reflow/(*)/*.js', {
    packTo: '/pkg/page/$1.js'
});
```

这样 component/reflow_video 下的js 和 page/reflow/reflow_video 下的js 都会打到 /pkg/page/reflow_video.js 中。

我们的初衷是，如果你完全按照目录规范来组织项目（component和page下面的一级目录都是页面，没有分组），那么使用默认打包配置就能满足最小文件数的需求，如果你的目录结构下存在分组，可以通过自定义规则来实现精细化的打包。


### 原理说明

简单的说，就是在本地构建时生成一份 **静态资源映射表**，记录每个文件（包括模板和静态资源）所依赖的其他文件的ID以及每个文件自身的实际地址或路径（CDN地址或模板路径），包括打包配置，然后把这份静态资源映射表发布到后端机器上。结合jinja2扩展（自定义标签），在后端渲染模板时会根据`namespace`读取对应的静态资源映射表，然后分析依赖并去重，最终按照依赖顺序以及打包结果将静态资源输出到指定位置或者默认位置。

为了简化原理，这里没有引入 `namespace`（参考 https://code.byted.org/ies/jinja2 中的 **命名空间**） 的概念。

#### 目录结构

```
component
    |- common    // 公共组件
        |- button
            |- index.html
            |- index.scss
    |- home      // 页面组件
        |- header
            |- index.html
            |- index.scss
            |- index.js
        |- footer
            |- index.html
            |- index.scss
            |- index.js
page
    |- home
        |- index.html
```

#### 代码文件

`page/home/index.html`
```html
<div class="page-home">
    {% comp name="component/home/header/index.html" title="this is a title" %}
    {% comp name="component/home/footer/index.html" username=user.username %}
</div>
```

`component/home/header/index.html`
```html
<div class="home-header">
    <p>this is header</p>
    <p>{{ title }}</p>
    {% comp name="component/common/button/index.html" %}
</div>
```

`component/home/footer/index.html`
```html
<div class="home-footer">
    <p>this is footer</p>
    <p>{{ username }}</p>
    {% comp name="component/common/button/index.html" %}
</div>
{% script %} {# 自定义script标签，其中包裹的js会被插入到页面最后或者指定位置 #}
require('component/home/footer/index').init({{ data|jsonify }}); // 组件初始化逻辑，可以接受后端传递的模板变量
{% endscript %}
```

#### 静态资源映射表

这张表是 `fis` 在编译过程中帮我们生成的，我们整个方案的核心也是围绕这张表来的。

```js
{
    "res": {
        "pages/home/index.html": {
            "uri": "/pages/home/index.html",
            "type": "html",
            "extras": {
                "isPage": true
            }
        },
        "component/home/header/index.html": {
            "uri": "component/home/header/index.html",
            "type": "html",
            "deps": [
                "component/common/header/index.js",
                "component/common/header/index.scss"
            ]
        },
        "component/common/header/index.scss": {
            "uri": "/static/component/header/index_01815f8.css",
            "type": "css"
        },
        "component/common/header/index.js": {
            "uri": "/static/component/header/index_5921cb7.js",
            "type": "js",
            "extras": {
                "moduleId": "component/common/header"
            },
            "deps": []
        },
        // ...
    },
    "pkg": {}
}
```

默认配置可以是开启同名依赖，这样只需要在页面中通过自定义标签 `comp` 引入组件模板即可，而不用再手动在页面里添加js或css文件。从这里也可以看出，后端组件化的思路和vue单文件组件、react的jsx+css in js是相通的。在实际渲染时，会通过本地生成的静态资源映射表来分析组件依赖的css和js，然后按照依赖顺序插入页面指定位置（占位符）或默认位置（head、body）。


另外，对于存在分支逻辑的情况，后端模板的优势也体现出来了：

```html
{% if expr %}
    {% comp name="component/common/xxx.html" %}
{% endif %}
```

比如不同等级用户看到的内容不一样，或者达成某个条件才能看到特定功能，有了以上方案，我们就能实现动态加载某个组件的依赖资源。


#### 合并打包

由于是在模板渲染阶段动态分析并加载依赖的，所以不能像使用 [fis3-postpackager-loader](https://github.com/fex-team/fis3-postpackager-loader) 方案一样提前在编译阶段合并打包。但是我们仍可以通过 `packTo` 手动指定需要合并的文件，比如对于专属于某个页面的组件，我们可以打包到一个入口文件里，而对于多个页面公用的组件则打包到 common 文件里，或者不合并。  

通过手动控制打包策略，能够提升组件的缓存命中率，降低修改组件后需要重新下载的文件大小，做更精细的优化。

比如按如下配置，最终可以得到如下的静态资源映射表：

`fis-conf.js`

```javascript
fis.match('/component/home/**.js', {
    packTo: '/component/home.js'
});

fis.match('/component/home/**.scss', {
    packTo: '/component/home.css'
});
```

`map.json`

```javascript
{
    "res": {
        // ...
        "component/home/comp1/index.js": {
            "uri": "/static/resource/component/home/comp1/index_72e8398.js",
            "type": "js",
            "extras": {
                "moduleId": "component/home/comp1/index"
            },
            "deps": [
                "component/util/index.js",
                "component/api/index.js"
            ],
            "pkg": "p0"
        },
        "component/home/comp1/index.scss": {
            "uri": "/static/resource/component/home/comp1/index_d038aea.css",
            "type": "css",
            "pkg": "p1"
        },
        "component/home/comp2/index.js": {
            "uri": "/static/resource/component/home/comp2/index_850ff2a.js",
            "type": "js",
            "extras": {
                "moduleId": "component/home/comp2/index"
            },
            "deps": [
                "component/util/index.js"
            ],
            "pkg": "p0"
        },
        "component/home/comp2/index.scss": {
            "uri": "/static/resource/component/home/comp2/index_cc9c549.css",
            "type": "css",
            "pkg": "p1"
        },
        //...
    },
    "pkg": {
        "p0": {
            "uri": "/static/resource/component/home_190fd56.js",
            "type": "js",
            "has": [
                "component/home/comp1/index.js",
                "component/home/comp2/index.js"
            ],
            "deps": [
                "component/util/index.js",
                "component/api/index.js"
            ]
        },
        "p1": {
            "uri": "/static/resource/component/home_95d80cc.css",
            "type": "css",
            "has": [
                "component/home/comp1/index.scss",
                "component/home/comp2/index.scss"
            ]
        }
    }
}
```

可以看到，`map.res` 中的 `pkg` 字段和 `map.pkg` 中的 key 关联起来了，这样我们就能利用这个关系来输出合并后的静态资源了。