nodejs实战——在线邮件模板编辑器SRM

      SRM是笔者在学习nodejs过程中的产物,主要实现了将style标签与link标签的css样式,动态解析到dom上。最终生成用style属性写样式的html文本,以符合大多数邮件系统对邮件模板的要求。

      另外SRM没有用户体系,用户在进入首页时后,第一次保存时,会同步刷新页面,生成一个唯一链接,用户需要保存该链接。在关闭浏览器后,若想继续编辑之前的内容,访问之前的链接即可。

SRM线上DEMO截图:

SRM功能非常简单,没有复杂的业务逻辑,只有需求明确的功能开发,就是样式的解析,后续我们将一步一步讲解整个编辑器的开发过程。

第一步,选择nodejs建站框架,笔者最近常用express,想当然就是他了。

通过express初始化目录。命令:express projectName

开始构建路由,这里路由我没有从根目录开始构建,考虑到后期可能做首页,所以直接将路由命名为workspace


数据存储设计:

我们的数据读取是根据URL匹配的,而URL只有一个命叫id的参数。每一个id对应moogodb中一份code代码。是滴的。就是这么简单!简单粗爆的设计方式,设计初衷本就是内部工具,没有用户体系这个概念,后期即使需要做用户体系,也将直接接入公司的用户体系,保证公司用户体系的完整性。

页面数据结构如下:

1
2
3
4
{
urlId: id //页面id
code: str //未解析的html


后端开发部分:

一、编辑器页面渲染路由:
构建思路:

  1. 判断url是否带id参数
  2. 若无id,直接渲染编辑器首页。
  3. 若带有id,进入数据查询是否存在,若id不存在,重定向到首页,若id存在,取出对应的code
  4. 将code返回前端用作源码编辑器显示,将code时行解析返回前端用作邮件html显示。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/* 编辑器页面渲染 */
router.get('', function(req, res, next) {
var urlId=req.query.id;//获取地址上的id参数
if(!urlId){
//不带ID地址请求
res.render('workspace', {
pageConfig: pageConfig,
code: '',
urlId: tools.createGUID(),//前端生成唯一识别码,也可以利用mongodb的id
pageName: 'workspace'
});
}else{
//请求带id,进行数据库查找
//这里的db是作者自己封装的db对象,提供mongodb便捷操作,后期会有博文介绍作者自己的pana系列模块
db.inDB({
collectionName: "workspace",
queryParam: {
urlId: urlId
},
result: function(boolean,result){
if(boolean){
//数据匹配成功,
var codeStr=result.code;
//进行样式解析,后面会有代码介绍
jMailParse(codeStr,function(parsedCodeStr){
//渲染页面
res.render('workspace', {
pageConfig: pageConfig,
urlId: urlId,
code: codeStr,
parsedCodeStr: parsedCodeStr,
pageName: 'workspace'
});
})
}else{
//数据库没有这个id,重定向到工作平台首页
res.redirect('/workspace')
}
}
})
}
});

二、核心功能jMailParse介绍

jMailParse模块是作者从同事JMail响应式邮件的工具包中,拆分出来的一个小模块。进行了简单修改,使其适用于SRM项目。该模块主要基于juice处理。非常easy,感兴趣的朋友可以移步 juice 查看具体信息。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//清理空的style标签
var cleanEmptyStyle = function($){
var $styles = $("style");
$styles.each(function(index, style){
if($(style).is(":empty")){
$(style).remove();
}
});
};

//进行样式解析,此处是个异步操作,提供callback处理后续业务
var parse = function(htmlStr,callback){
juice.juiceResources(htmlStr, {
preserveMediaQueries: true,
removeStyleTags: true,
webResources: {
images: false, // 忽略图片
scripts: false // 忽略 js
}
},function(err, code){
var cheerio = Cheerio.load(code);
cleanEmptyStyle(cheerio);
code = tools.trim(cheerio.html());//由pana-tools模块提供trim处理
if(typeof callback ==="function") callback(code);
});
};

module.exports = parse;

三、运行接口
构建思路:

  1. 获取postData。
  2. 执行db update操作, 将urlId做为查询条件 ,有对应数据更新,无对应数据新增。
  3. 结合前端代码实时刷新邮件html,预览界面。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* 运行接口 */
router.post("/run",function(req,res,next){
var postData=req.body,
createResponse=new response(res);
db.update({
collectionName: 'workspace',
selector: { "urlId": postData.urlId},
data: postData,
success: function(data){
if(data && data.urlId==postData.urlId){
jMailParse(data.code,function(parsedCodeStr){
data.parsedCodeStr=parsedCodeStr;
createResponse.success(data);
})
return;
}
createResponse.error({result: data});
}
})
});

三、预览路由
构建思路:

  1. 使用iframe嵌入页面。
  2. 获取页面参数id,若id不存在,渲染预览界面html为空
  3. 若id存在将查询数据,若id在数据库中不存在,渲染预览界面html为空
  4. 若id在数据库中存在,获取对应数据code值。
  5. 将获取的code利用jMailParse进行解析,用作预览html显示。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/* 预览界面 */
router.get("/preview",function(req,res,next){
var urlId=req.query.id;
if(urlId){
db.inDB({
collectionName: "workspace",
queryParam: {
urlId: urlId
},
result: function(boolean,result){
if(boolean){
//数据匹配成功,渲染预览页面
var codeStr=result.code;
jMailParse(codeStr,function(htmlStr){
res.render('preview', {
htmlStr: htmlStr
});
});
}else{
//数据库中不存在该urlid,渲染空页面
res.render("preview",{
htmlStr: ""
})
}
}
})
return;
}

//url不带id,渲染空页面
res.render("preview",{
htmlStr: ""
})
})

后台代码基本结束,下面开始前端部分。


前端开发部分:

前端代码较为简单,注释比较全,大家直接看代码吧,有问题留言即可。
前端主要用到:

  1. materialize框架
  2. jQuery库
  3. codeMirror编辑器

模板部分:
workspace.jade

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
doctype html
html
include header
body
nav.nospace
.nav-wrapper.blue.darken-2.nospace
h1.logo
a(href='/') #{pageConfig.projectName}
span.desc 邮件在线编辑器
include menu
span.tips.green Save & Run : Mac (command+s) | Win (ctrl+s)
.content
.row.nospace
#left.col.l7.nospace
block htmlEditor
#right.col.l5.nospace.fullHeight
block htmlPreview

header.jade

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
head(lang="en")
title #{pageConfig.title}
meta(http-equiv="content-type", content="text/html; charset=UTF-8")
meta(name="description", content="")
meta(name="keywords", content="")
meta(name="viewport", content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0;")
- if (pageConfig.devMode == true)
link(rel="stylesheet", href="/stylesheets/base/base.css", type="text/css")
link(rel="stylesheet", href="/stylesheets/#{pageName}.css", type="text/css")
script(type="application/javascript", src="/javascripts/lib/jquery.js")
script(type="application/javascript", src="/javascripts/lib/materialize.min.js")
script(type="application/javascript", src="/javascripts/lib/codemirror.js")
script(type="application/javascript", src="/javascripts/common/api.js")
block beforeJS
script(type="application/javascript", src="/javascripts/#{pageName}.js")
- else
link(rel="stylesheet", href="/stylesheets/dist/#{pageName}.css", type="text/css")
script(type="application/javascript", src="/javascripts/dist/#{pageName}.js")

页面workspace.jade

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
extends template/workspace
block beforeJS
script(src="/javascripts/common/codeEditor.js" type="application/javascript")

block htmlEditor
div#htmlEditor
textarea(id="htmlEditorTextarea" placeholder="Code goes here..." name="htmlEditorTextarea") #{code}
div.clearfix.sub-title 邮件HTML:
div#parseResult
textarea(id="htmlParseResult" disabled="disabled" placeholder="result is here..." name="htmlEditorTextarea") #{parsedCodeStr}

block htmlPreview
.htmlPreview.fullHeight
iframe(id="previewIframe" src="/workspace/preview?id=#{urlId}", border="0" width="100%" height="100%")
input(type="hidden",value="#{urlId}",id="urlId")

交互部分:
javascript代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
//获取地址栏参数
function getQueryString(name){
var reg = new RegExp("(^|&)"+ name +"=([^&]*)(&|$)");
var r = window.location.search.substr(1).match(reg);
if(r!=null)return unescape(r[2]); return null;
}

//消息通知,仅用于refresh通知
function message(str){
this.create=function(){
$("body").append('<div id="message" class="message blue">'+str+'</div>');
};
this.destory=function(){
$("#message").remove();
};
}

//计算codeMirror高度
function getHeight($dom,isSetHeight){
var offset=$dom.offset(),
top=offset.top;
if(isSetHeight){
var setHeight=($(window).height()-top)/1.5;
}else{
var setHeight=($(window).height()-top);
}
return setHeight;
}

$(function(){
//实例化源码编辑器,codeEditor基于codeMirror二次封装 ,集成高度自适应功能。
var htmlEditor=new codeEditor({id: "htmlEditorTextarea",isSetHeight: true});
//实例化邮件模板html编辑器,仅用于显示
var htmlParseResultEditor=new codeEditor({id: "htmlParseResult"});
//设置预览页面高度
$(".htmlPreview").height($("#left").height());
var urlId=$("#urlId").val();

//调用后端run接口
var run=function(){
var htmlContent=htmlEditor.getValue();
if(!$.trim(htmlContent)){
return;
}
var msg=new message("refresh...");
msg.create();
//api是笔者封装的api方式,集成统一的消息处理机制,统一路径管理等功能。
api.post({
url: api.url.run,
abort: true,
data: {
code: htmlContent,
urlId: urlId
},
success: function(data){
var queryId=getQueryString("id")
if(!queryId){
//在新页面,执行run时,刷新页面带上urlId;
window.location.href="/workspace?id="+urlId;
}else{
//window.location.reload();
//在已有id的页面,执行run时,仅异步刷新预览界面与邮件模板html编辑器,
$("#previewIframe").attr("src","/workspace/preview?id="+urlId);
htmlParseResultEditor.setValue(data.result.parsedCodeStr);
}
msg.destory();
},
error: function(data){
//console.log(data)
msg.destory();
}
})
}


//ctrl+s保存
$("#htmlEditor").on("keydown.workspace",function(e){
if((e.metaKey && e.keyCode===83) || (e.ctrlKey && e.keyCode===83)){
e.preventDefault()
e.stopPropagation()
run();
}
});


//运行按钮邦定运行方式
$("#runBtn").on("click.workspace",function(){
run();
});

//窗口缩放,自适应高度
$(window).resize(function(){
$htmlEditor=$("#htmlEditor")
$htmlEditor.height(getHeight($htmlEditor,true));
htmlEditor.refresh();
$htmlParseResultEditor=$("#parseResult")
$htmlParseResultEditor.height(getHeight($htmlParseResultEditor));
htmlParseResultEditor.refresh();
$(".htmlPreview").height($("#left").height());
})

})