2017-01-01 12:27:17

Vue2+SSR:快速定制邮件模板

邮件模板一直是一个头疼的问题,对用户来说,他们希望你的邮件和你的首页一样漂亮,但是邮件客户端的多端一致性非常低,导致写邮件模板特别麻烦。
本文介绍对开发与部署都友好的一种邮件模板的策略。

邮件模板简介

邮件模板有如下几个特点和难点:

  • 需要样式内联
    • 出于安全的原因, 很多邮件客户端不支持 link 或者 style 样式,只能通过内联的方式
      • 这将导致写邮件模板时,复制粘贴的内联样式非常多,到了后期它将几乎不可维护
  • 需要经过压缩
    • 防止html中的空格被客户端错误地解析
  • 在后端读取,生成并发出

一般的做法如下:

  1. 照常写模板,样式内嵌在header中
  2. 模板使用ejs或nunjucks等模板引擎的写法
    • 这里略有蛋疼,因为ejs与html冲突,nunjucks默认写法与Mustache又冲突
  3. 使用工具将内嵌的样式转为内联
    • 工具太多,学习和使用成本高居不下
  4. 压缩并输出可以被模板引擎读取的模板

这个过程非常麻烦,一旦修改,则需要完全走完这四步流程。

现在,前端有了服务端渲染SSR后,邮件模板已经可以被很好地解决,无论是开发体验还是部署体验都可以令人满意

SSR邮件的优势

  • 开发时
    • 基于webpack的热重载
      • 不仅不需要重启服务器,连刷新页面都不用,自动帮你把样式刷新好
    • 启用css module
      • 启用css模块,完美解决内联样式的繁杂问题
  • 部署时
    • 开发代码与部署代码分离
      • 将邮件项目代码打包成单个js文件,线上项目只需要添加vue-server-renderer等依赖
    • 提供SSR接口
      • 传入数据,传出返回压缩后html的promise,使用方便

初始化项目

玩过SSR的同学可以直接略过这里一段, 直接跳到下一段css module

在需要接入SSR邮件的项目中新建email文件夹,进入该文件夹后直接使用vue-cli

vue init webpack .

安装依赖

给email项目安装依赖

npm install --save vue-server-renderer vue-router vuex
npm install --save-dev cross-env

这里的依赖纯粹给开发邮件时用。

回到上一级目录,安装依赖

npm install --save vue vue-server-renderer vue-router vuex

这里的依赖就是后端在服务器上SSR邮件所需要的了。

配置gitignore

dist/*
!dist/server-bundle.js
!dist/static/styles.css

配置package.json

  "scripts": {
    "dev": "node build/dev-server.js",
    "build": "npm run build:client && npm run build:server",
    "build:client": "node build/build.js",
    "build:server": "cross-env NODE_ENV=production webpack --config buildack.server.conf.js --progress --hide-modules"
  },

添加SSR的webpack入口文件

在build文件夹中新建webpack.server.conf.js

const webpack = require('webpack')
const base = require('.ack.base.conf')

module.exports = Object.assign({}, base, {
  target: 'node',
  devtool: false,
  entry: './src/server-entry.js',
  output: Object.assign({}, base.output, {
    filename: 'server-bundle.js',
    libraryTarget: 'commonjs2'
  }),
  externals: Object.keys(require('../package.json').dependencies),
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
      'process.env.VUE_ENV': '"server"'
    }),
    // minify JS
    new webpack.optimize.UglifyJsPlugin({
      compress: {
        warnings: false
      },
      output: {
        comments: false
      },
      sourceMap: false
    })
  ]
})

拆分入口文件

src/main.js拆分。

main.js如下:

import Vue from 'vue'
import router from './route'
import store from './store'

import App from './App'
Vue.component('app', App)

const app = new Vue({
  router,
  store,
  ...App
})

export { app, router, store };

添加客户端入口文件client-entry.js

import { app, router, store } from './main.js';

app.$mount('#email');

添加服务端入口文件server-entry.js

import { app, router, store } from './main.js';

const isDev = process.env.NODE_ENV !== 'production'

export default context => {
  router.push(context.url)
  let current = router.history.current
  context.path = current.path
  context.query = current.query
  context.params = current.params
  context.url = current.fullPath

  store.replaceState({
    styles: context.styles,
    dataObj: context.dataObj
  })

  const s = isDev && Date.now()

  return Promise.all(router.getMatchedComponents().map(function (component, index) {
    if (component.preFetch) {
      return component.preFetch(store, context)
    }
  })).then((arr) => {
    isDev && console.log(`data pre-fetch: ${Date.now() - s}ms`)

    context.initialState = store.state
    return app
  })
}

注意服务端入口文件中下语句

store.replaceState({
    styles: context.styles,
    dataObj: context.dataObj
  })

因此vuex的Store应该长这样:

import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)

export default new Vuex.Store({
  state: {
    dataObj: {},  // 传入的数据源对象
    styles: ''  // 传入的内联样式字符串
  }
})

好了,ctrl+c/v的部分就到这里了

css module

开启css module很简单,文档在这里

现在,在组件data中打印一下this.$style,得到如下:

vue-loader的样式根本不是内联,而是内嵌的。它将class混合计算得到组件真实的style,再将其内嵌在header中。所以我认为这个css module并不是完全的css模块。

第一个问题:内联样式

这里得考虑客户端开发和服务端渲染两种入口

客户端开发时,直接用document.querySelectorAll('style')将所有样式解析成对象。

服务端渲染时,提前将样式用webpack的ExtractTextPlugin打包成css文件,再通过fs.readFileSync读入再解析即可。

判断是否是服务端环境,请使用vue的this.$isServer

解析样式时会用到右边的正则/\.([^\s]+)\s*\{\s*([^\}]+?)\s*\}/m,注意,匹配到的字符串可能存在换行符,要用/(\r\n|\r|\n)/g给剔除掉。

现在,可以用css module得到的hash值去拿真实的内联样式,再手动绑定到template中。

css module还没完,前端肯定会遇到另外一个问题。这种开启了css module的样式,使用@import导入的公共样式并没有开启css module,而是原始的class,怎么办?

第二个问题:开启公共样式的css module

这时候,就得vue的mixin出马了。不熟悉的请去看vue的文档。

将公共样式抽成一个空组件inline.vue,纯粹为开启css module:

<script>
  export default {
    name: 'common-style'
  }
</script>

<style module>
.base {
  text-decoration: none;
  color: #000;
  background: #fff;
}

.tableStyle {
  width: 100%;
  max-width: 100%;
  margin-bottom: 20px;
  border: 1px solid #ddd;
}
</style>

新建mixin.js, 导入inline.vue组件,导入之前内联css的逻辑函数,新建一个如下的mixin

import { parseStyle } from '../utils' // 解析内联css的函数
import inline from '../inline.vue'  // css module公共样式组件

export default {
  data () {
    let inlineStyle = {};
    if (this.$isServer === false) {
      let styles = document.querySelectorAll('style')
      styles.forEach(style => {
        parseStyle(style.innerHTML, inlineStyle)
      })
    } else {
      parseStyle(this.$store.state.styles, inlineStyle)
    }
    return Object.assign({}, inlineStyle);
  },
  computed: {
    dataObj () {
      return this.$store.state.dataObj  // 返回服务端传入的数据源对象
    },
    $style () {
      return inline.computed.$style()  // 返回公共组件中的内联样式的hash值数组
    }
  }
}

现在把这个mixin和邮件组件混合,this.$style确实是公共组件内的style,看起来混合对了,但是,又有一个问题: 邮件组件内的css module被完全覆盖了。

第三个问题:计算属性的混合策略未达到预期

这个问题可以通过调整mixin的$style计算属性解决,更名一下就ok。但是我个人更喜欢将其混合起来,全部都用this.$style获取。

这里需要使用vue暴露出来的混合策略接口。

给main.js添加如下的混合策略代码,因为这个混合策略需要在客户端和服务端同时生效

Vue.config.optionMergeStrategies.computed = function (toVal, fromVal) {

  if (!toVal) return fromVal
  if (!fromVal) return toVal
  if (toVal.$style || fromVal.$style) {
    return Object.assign({}, toVal, fromVal, {
      $style: function () {
        return Object.assign({}, toVal.$style(), fromVal.$style())
      }
    });
  } else {
    return Object.assign({}, toVal, fromVal)
  }
}

混合策略不多做介绍,仍然建议不熟悉的用户看文档,里面甚至有介绍如何用混和策略在vue2中开启vue1的vuex接口

现在,css module相关的问题就解决完了

添加服务端入口文件email.js

const fs = require('fs')
const path = require('path')
const resolve = file => path.resolve(__dirname, file)

const createBundleRenderer = require('vue-server-renderer').createBundleRenderer

const bundlePath = resolve('./dist/server-bundle.js')
let renderer = createBundleRenderer(fs.readFileSync(bundlePath, 'utf-8'))

const stylesFile = resolve('./dist/static/styles.css')
let styles = fs.readFileSync(stylesFile, 'utf-8')

const template = (() => {
  let html = fs.readFileSync(resolve('./index.html'), 'utf-8')
  return {
    head: html.split('<div id=email></div>')[0] || '',
    tail: html.split('<div id=email></div>')[1] || ''
  }
})()

module.exports = (url, dataObj) => new Promise((resolve, reject) => {
  const context = {
    url,
    dataObj,
    styles
  }
  renderer.renderToString(context, (err, html) => {
    if (err) return reject(err)
    let result = template.head + html.replace(' server-rendered="true"', '') + template.tail
    resolve(result)
  })
});

注意,上面template自执行函数中,划分条件<div id=email></div>必须在index.html存在。
另外,vue的SSR吐出来的html中,顶层div会带上server-rendered="true"的属性,记得剔除掉就好

现在,SSR邮件就搭好了。再贴一点文档。

开发

npm install
npm run dev

支持样式的热重载

接口

引入根目录中email.js

  • 传入两个参数
    • 参数1:路径对象
    • 参数2:需要传给模版的数据
      • 类型为Object,可以在模版组件中通过dataObj访问到
  • 返回promise,resolve后得到服务端渲染的html模版

范例如下:

let email = require('../email')

let data = {"h5.ele.me": {"/aaa/bbb?ccc=ddd": 2, "/eee/fff?ggg=jjj": 1}, "h.ele.me":{"/aaa/bbb?ccc=ddd": 2, "/eee/fff?ggg=jjj":2}};

email('/alarm', data).then(html => {
    console.log(html)
})

模版开发

到了这里,写邮件模板实际上就变成了开发vue组件了,想怎么拆就怎么拆

script

请确保导入了上面为解决第二个问题建立的mixin:

import ssrMixin from '../mixin'

export default {
  mixins: [ssrMixin]
}

mixins提供一些属性与方法:

  • dataObj
    • 调用email.js时传入的第二个参数,包含该模版页面所有的数据
  • $style
    • 公共组件内的css module样式的hash数组

开发时,可以直接在模版组件中设置一个名为dataObj的computed属性,覆盖mixin并用以测试,不要忘记上线前删除这个computed

style

确保开启了css module,即style标签拥有module属性,之后通过this.$style访问

工具提供一个全局的公共样式组件src/inline.vue,可以将公共的css module的类写在这里
调用了mixins的组件可以直接通过this.$style访问到公共样式组件内的类

路由

开发完一个模版组件后,不要忘记在src/route/index.js挂载对应的路由

构建

npm run build

测试

在每个模版组件开发完后,强烈建议在test文件夹中写测试
具体写法请参考接口范例

结语

  • ExtractTextPlugin会把样式最后的分号给去掉, 因此绑两个样式给同一个标签时,中间不要忘了加分号
  • 这个SSR邮件和外部项目的耦合度,实际上是高得飞起的
  • 打包出来的server-bundle.js内本来是有header标签中那些样式的,但是使用的却是exports.push(webpack的exports是数组),并且没有暴露任何接口出来。因此不得不用ExtractTextPlugin
  • SSR读css文件取出来的样式,末尾并没有分号,因此绑多个样式在一起时,中间得手动加分号
  • 提交前先构建一次
  • 保证每个模版组件都有对应的测试

2017-01-06 更新

被vue 2.1.7打脸了....
之前采用的是本地构建好直接传仓库里这种策略. 2.1.6升2.1.7时vue-template-compiler有个breaking change, 虽然每个vue版本我都会阅读更新日志, 但是这个breaking change只说了会影响组件库没提SSR, 然后线上又忘记锁vue版本,导致SSR时template组件被解析成undefined.

解决办法:

  • [x] 锁版本+jenkins线上构建
  • [x] 接入类greenkeeper的依赖更新推送服务

另外, 本文介绍的这种模式, 更加适合做成rest api形式的微服务, 保证一个系统用到的所有邮件都可以通过一个接口调用, POST路由与数据源, 返回SSR好的HTML.
否则, 一个项目就要复制一次邮件项目过来, PR动不动几十个文件实在是没法review, 因为说到底它还是一个前端项目

2017-01-28 更新

又一个breaking change,查了下日志里又没说,2.1.5以上webpack的alias必须是vue/dist/vue.common,否则线上跑的是开发模式的vue。。。

本文链接:https://smallpath.me/post/vue2-ssr-email-template

-- EOF --