mya-jinja
基于 mya 的针对 jinja2 模板的前端工程解决方案
背景
- 利用后端模板的能力实现后端模块化(组件化)和静态资源动态加载,并以此为基础实现诸如 bigrender 等一系列优化方案
- 前后端分离和本地化开发
- 替换 django 默认模板引擎为 jinja2,提升模板渲染速度
方案构成
- jinja2扩展,负责解析自定义标签,并根据静态资源映射表渲染最终页面
mya-jinja
(mya插件),输出静态资源映射表,编译打包相关配置,路径修正等mya-server-jinja
(mya插件),本地开发时提供静态服务器、模板渲染、mock数据、路由等功能mya-optimizer-jinja-xss
(mya插件),jinja模板 xss 防范- 其他:脚手架
预备工作
在阅读以下内容之前,建议可以先安装一下mya,然后利用脚手架初始化一个项目,对着项目看,会更容易理解以下内容。
npm install -g myamkdir projectcd projectmya init jinja2mya server start --type jinjanpm run mock
自定义标签
html
{% html framework="static/script/lib/mod.js" %}{% endhtml %}
用于替换原生的 html
标签,输出 <html>
和 </html>
属性:
- framework: 指定项目依赖的模块加载器,支持本地文件路径和线上地址
注意:
- 如果在页面中手动引用了模块加载器(eg. mya.js/mod.js)或者包含模块加载器的文件(eg. core.js),则无需添加 framework 属性
- 可以通过命名空间引用其他模块的文件,比如
framework="common:static/lib/mya.js"
- framework指定的路径可以是CDN地址,比如
{% html framework="https://s3b.bytecdn.cn/ies/static/script/lib/mya.js" %}
comp
{% 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
{% comp name="common:component/button/index.html" %}{% comp name="component/home/comp1/index.html" %}
在页面中通过 comp
引用组件,组件模板同级目录下的同名的css(scss/less)和js文件无需再手动引用(包括它们依赖的文件),它们最终会分别被插入到 </head>
和 </body>
之前。
如果想将css和js分别插入指定的位置,可以在页面中想插入的位置分别放置以下占位符: <!-- STYLE_PLACEHOLDER -->
<!-- SCRIPT_PLACEHOLDER -->
。
script
{% 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>
当中,每个语句块会被自执行函数包裹起来。
比如上面例子实际渲染的结果是:
... <!-- 打包合并后的js -->
这里要重点说明下 script
标签的使用场景。首先要知道这里的 require
是 mod.js(默认使用的模块化框架) 中定义的require,它是去 查找调用 已经加载了的js模块,而不是去 加载 js文件。也就是说 require
语句正常执行的前提是 require
中指定的js文件已经加载到页面中。
前面提到过,模板文件所在目录下同名的css和js会自动被加载,无需手动引用,这样的好处是当移除一个不再使用的组件或者复用一个组件时,可以很方便得删除或者copy代码。以删除为例,我们删掉页面中引用组件的 comp
标签,然后直接删除组件目录,就完成组件的清理,而不需要担心哪里还引用了组件的css或者js。
为了更好地实现组件的自我管理,把组件 调用 的语句放在组件模板中,就不需要在页面里面单独 require
了。所以通常我们会这么使用:
A/component/home/comp1/index.html
...{% script %}require('component/home/comp1/index').init({{ user|jsonify }}){% endscript %}
A/component/home/comp1/index.js
var util = ;exports { //...}
通过以上说明,script
标签主要的用途是包裹 组件调用 的语句。如果有些内嵌脚本,如统计脚本,不想被收集到最后面,直接使用原生标签即可。
注意:
- 被
script
包裹的js语句不能使用es6,因为这些语句可能包含模板语法,在babel编译时会报错。 - require可以通过命名空间引用其他模块的文件,比如
require('common:component/util/index')
style
{% style %}.home-comp1 { color: red}{% endstyle %}
style
标签包裹的css语句会被收集起来,和页面依赖的css文件(组件css、页面入口css等)一同插入到head最后或者指定位置,其中页面依赖的css文件在前,style
标签包裹的css语句在后。多个 style
标签包含的语句会被合并到一对 <style></style>
当中。
style
标签使用的场景不多,其意图主要也是实现组件的自我管理。比如一个组件只有少量样式,不希望外链接,可以放在 style
标签中。
注意: style
标签中的内容不会参与编译,所以不能使用 sass
或者 less
语法。
require
{% 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
<!-- 国际化场景 -->
uri
标签用于获取文件的线上路径(会输出实际 cdn 的路径),支持动态路径,用于国际化场景
项目开发
前端项目
https://code.byted.org/ies/jinja2
后端项目
在后端项目里引用 mya_jinja_plugin。以django为例:
settings.py
TEMPLATE_DIRS = ( ,) # MYA 静态资源映射表存放目录 MYA_CONF_DIR =
在配置文件中指定模板根目录以及存放静态资源映射表(见 原理说明)的目录。
views.py
from django.http import HttpResponsefrom mya_jinja.render_util import view return
引用 mya_jinja_plugin 中的 view
方法,传入模板id和模板所需数据。
view
方法参数:
- request: 请求对象
- template_id: 形式为
${namespace}:${relativePath}
,自定义标签 中介绍comp
标签时有提到。 - data: 模板数据
公共模块管理
对于多个项目公用的模块,可以作为项目的 submodule 存在,也可以作为一个符合 mya-jinja 解决方案规范的项目发布,然后在其他项目中通过命名空间引用。
submodule
cd projectgit submodule add submodule_repogit submodule update
跨模块调用
将 common 模块作为一个符合 mya-jinja 目录规范的项目单独维护和发布。以本地开发为例:
cd project/commonnpm run devcd project/Anpm run dev
首先把公共模块和当前项目都发布到本地server,然后在当前项目A中使用以下方式调用:
aaa/index.html
{% comp name="common:component/button/index.html" %}{% require("common:/static/srcipt/util/index.js") %} <!-- 加载js文件 相当于script标签引入 待支持 -->
aaa/index.js
var util = ;
文件合并
脚手架默认生成了以下打包合并规则:
/** * 说明:以下是默认配置,可以根据自己业务进行调整 * 规则: * 1. 页面组件和页面入口文件打包到一个文件中,比如 /component/home/comp1/index.js /component/home/comp2/index.js /page/home/index.js -> /page/home.js * 2. 公共组件打包到 common 文件中 */ /** * 页面文件配置 */// 页面组件和页面入口 allInOnefis;fis; fis;fis; /** * 公共文件配置 */ // 项目公共组件fis; fis;
需要说明的是,按照目录规范,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。可以看到,页面组件目录和页面入口目录的名字是相同的,这样做方面配置打包策略,让页面组件和页面入口合并到一个文件中。
比如:
fis;fis;
这样 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
{% comp name="component/home/header/index.html" title="this is a title" %} {% comp name="component/home/footer/index.html" username=user.username %}
component/home/header/index.html
this is header {{ title }} {% comp name="component/common/button/index.html" %}
component/home/footer/index.html
this is footer {{ username }} {% comp name="component/common/button/index.html" %}{% script %} {# 自定义script标签,其中包裹的js会被插入到页面最后或者指定位置 #}require('component/home/footer/index').init({{ data|jsonify }}); // 组件初始化逻辑,可以接受后端传递的模板变量{% endscript %}
静态资源映射表
这张表是 fis
在编译过程中帮我们生成的,我们整个方案的核心也是围绕这张表来的。
"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)。
另外,对于存在分支逻辑的情况,后端模板的优势也体现出来了:
{% if expr %} {% comp name="component/common/xxx.html" %}{% endif %}
比如不同等级用户看到的内容不一样,或者达成某个条件才能看到特定功能,有了以上方案,我们就能实现动态加载某个组件的依赖资源。
合并打包
由于是在模板渲染阶段动态分析并加载依赖的,所以不能像使用 fis3-postpackager-loader 方案一样提前在编译阶段合并打包。但是我们仍可以通过 packTo
手动指定需要合并的文件,比如对于专属于某个页面的组件,我们可以打包到一个入口文件里,而对于多个页面公用的组件则打包到 common 文件里,或者不合并。
通过手动控制打包策略,能够提升组件的缓存命中率,降低修改组件后需要重新下载的文件大小,做更精细的优化。
比如按如下配置,最终可以得到如下的静态资源映射表:
fis-conf.js
fis; fis;
map.json
"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 关联起来了,这样我们就能利用这个关系来输出合并后的静态资源了。