之前我写过一篇文章 《打造一个优雅的微信文章编辑器》 ,那时候是直接 fork 大神 小胡子哥 的 线上排版编辑器 过来揣摩了一番,顺便改了点样式,加了一个代码主题色 Material Dark,就上线了。实际上,项目的代码和体验一直我都感觉挺别扭的,便彻底重构了一番。
新版访问地址: md.ironmaxi.com
新版界面:
操作效果:
大体介绍一下,我在原项目的基础上做了什么工作:
- 添加 webpack 配置,支持本地调试;
- 引入 Vue,虽然没必要,但是有了数据双向绑定,代码写起来简洁,维护起来方便;
- 添加实时预览功能,左侧写出来的 Markdown 文字,右侧立即预览,没有延迟;
- 左侧与右侧视图同步滚动,进一步提升使用体验;
-
点击复制内容,所有文字及排版样式统统拷贝进剪贴板,不用再多按一次
crtl + c
; - 根据微信公众编辑器的样式及限制,多做了一些兼容;
- 增加了 3 种不同样式的 blockqoute;
- 站点升级 https 协议;
- 重磅:Service Worker 加持,只要访问过一次线上地址,那么静态资源都会被缓存,离线可用!
- 重磅:Gitlab CI/CD 加持,我只要往 master 主干提交代码,项目便可以自动打包构建,并部署到我的个人服务器中,而且还能自动帮我 push 到 github 仓库中,省去了我人工操作的步骤,非常优雅,这个后面详细说!
接下来,我详细介绍一下完成这个项目的大致步骤与思路。具体的项目代码,大家可以访问 github 仓库 。如果这款工具好用、解决了你的痛点,请给仓库一个 Star⭐️!
1. 项目结构
.
├── .babelrc
├── .gitignore
├── .gitlab-ci.yml // CI/CD 配置文件
├── LICENSE
├── README.md
├── build // 存放 webpack 配置文件
├── md // 构建输出目标文件夹
├── node_modules
├── package-lock.json
├── package.json
├── service-worker-plugin.js // service worker 应用插件
├── src // 源文件夹
└── sw-register.js // 注册 service worker 的脚本文件
5 directories
复制代码
只要重点关注一下如下文件或文件夹即可:
-
.gitlab-ci.yml
-
service-worker-plugin.js
-
sw-register.js
-
build
-
src
2. 核心功能
在这个编辑器中,最核心的功能只有两个:
- 将 markdown 转为 html;
- 给不同语言的代码设置高亮。
2.1 转 markdown 为 html
我们要引入第三方库: showdownjs/showdown
使用很简单,看看官方 demo:
// converter.js
var showdown = require('showdown'),
converter = new showdown.Converter(),
text = '# hello, markdown!',
html = converter.makeHtml(text);
// output
// <h1 id="hellomarkdown">hello, markdown!</h1>
复制代码
还支持自己写插件,插件格式有两种:
- 解析自定义 markdown 语法
- 自定义修改 markdown 转为 html 的结果
举个例子:
// showdown-myExtension.js
import showdown from 'showdown';
showdown.extension('myExtension', function () {
return [
// 格式 1:解析自定义 markdown 语法
{
type: 'language',
filter (source) {
source = source.replace(/```!([\s\S]*?)```/, function (match, content) {
return '<blockquote class="danger">' + content + '</blockquote>'
});
// 继续解析其他自定义的 markdown 语法
return source;
}
},
// 格式 2:自定义修改 markdown 转为 html 的结果
{
type: 'output',
filter: function (source) {
source = source.replace(/<pre([^>]*)>([\s\S]*?)<\/pre>/gi, function (match, preClass, content) {
console.log(arguments);
return '<pre '+ preClass +'><section class="pre-content">'+ content +'</section></pre>';
});
// 继续自定义修改 markdown 转为 html 的结果
return source;
}
}
];
});
// converter.js
import showdown from 'showdown';
import './showdown-plugins/output-prettify';
import './showdown-plugins/language-blockquote';
const converter = new showdown.Converter({
// 扩展
extensions: ['myExtension'],
});
export default converter;
复制代码
我们创建
src/plugins/converter.js
,并引入
showdown.js
:
// 引入 showdown.js
import showdown from 'showdown';
// 引入自定义 showdown 的插件
import './showdown-prettify';
// 实例化 showdown.Converter
const converter = new showdown.Converter({
// 扩展
extensions: [
'prettify', 'widget-blockquote-warn'
],
parseImgDimensions: true,
strikethrough: true,
tables: true,
tasklists: true,
emoji: true,
});
converter.setFlavor('github');
export default converter;
复制代码
我们在
src/views/App.vue
中:
<template>
<div class="view-app">
<!-- ... -->
<div class="markdowner-wrapper">
<!-- 编辑框 START -->
<div class="input-wrapper">
<textarea id="input" ref="input" spellcheck="false" v-model="editorContent"
placeholder="即刻,在这里写下你的 markdown 格式文章 ..."></textarea>
</div>
<!-- 预览框 START -->
<div class="output-wrapper">
<div id="output" ref="output" v-html="previewContent"></div>
</div>
</div>
</div>
</template>
<script>
// ...
import converter from '@SRC/plugins/showdown-converter';
export default {
// ...
watch: {
// 监听 textarea 的内容改动
editorContent (newVal, oldVal) {
this.editorContentChangedHandler(newVal);
},
},
methods: {
// 编辑器内容变化回调
editorContentChangedHandler (editorContent) {
this.updatePreview(editorContent);
},
// 更新预览视图
updatePreview (editorContent) {
// 核心代码
this.previewContent = converter.makeHtml(editorContent);
// 等待 DOM 更新完毕
Vue.nextTick(() => {
this.scrollHandler(this.editorElm);
});
},
},
}
</script>
复制代码
上面代码中,我将最核心的代码抽取了出来,其中,最重要的一句代码就是:
// 将 markdown 转换为 html
this.previewContent = converter.makeHtml(editorContent);
复制代码
是不是超简单?!
2.2 给不同语言的代码设置高亮
依赖的核心第三方插件就是 google/code-prettify ,我给大家总结下官方推荐用法:
-
引入该插件:
<script src="https://cdn.jsdelivr.net/gh/google/code-prettify@master/loader/run_prettify.js"></script>
- 查看 入门文档 ,配置你所需要的引入 url;
- 查看 皮肤库 并选择你所喜欢的一款;
-
将代码写进带
prettyprint
样式名的pre
或者code
元素中,插件就会自动高亮代码了。
然后,在我的项目里面,是这样做的,还是在
src/views/App.vue
中:
<script>
// ...
import '@ASSETS/scripts/google-code-prettify/run_prettify';
export default {
// ...
methods: {
// ...
// 更新预览视图
updatePreview (editorContent) {
this.previewContent = converter.makeHtml(editorContent);
// 等待 DOM 更新完毕
Vue.nextTick(() => {
// 重新高亮渲染
PR.prettyPrint();
this.scrollHandler(this.editorElm);
});
},
},
};
</script>
复制代码
注意到,要想让
run_prettify.js
去高亮代码,必须给
pre
和
code
元素加上
prettyprint
样式名,如果还需要行号的话,还得加上
linenums
样式名。我们就借助 showdown 的插件,实现给所有转换出来的 html 中的
pre
和
code
加样式名。在
src/plugins/showdown-plugins/output-prettify.js
中:
import showdown from 'showdown';
showdown.extension('output-prettify', function () {
return [{
type: 'output',
filter: function (source) {
source = source.replace(/(<pre[^>]*>)?[\n\s]?<code([^>]*)>/gi, function (match, pre, codeClass) {
if (pre) {
return '<pre class="prettyprint linenums" style="font-size:12px;"><code' + codeClass + ' style="font-size:12px;">';
} else {
return ' <code class="prettyprint code-in-text" style="font-size:12px;">';
}
});
},
}];
});
复制代码
3. 如何复制渲染后的 html
当我们点击「复制全部内容」按钮时,会将渲染后的 html 全部复制到剪贴板里面。这里我们借助的是第三方库 zenorocha/clipboard.js 。
先来看看官方文档的用法:
var clipboard = new ClipboardJS('.btn');
clipboard.on('success', function(e) {
console.info('Action:', e.action);
console.info('Text:', e.text);
console.info('Trigger:', e.trigger);
e.clearSelection();
});
clipboard.on('error', function(e) {
console.error('Action:', e.action);
console.error('Trigger:', e.trigger);
});
复制代码
就是那么简单。
然后我们在
src/views/App.vue
中这么干:
<template>
<!-- ... -->
<div class="btn-group">
<button class="btn copy-button" ref="clipboarddBtn"
data-clipboard-action="copy" data-clipboard-target="#output">复制全部内容</button>
</div>
<!-- ... -->
</template>
<script>
// 剪贴板
import Clipboard from 'clipboard';
// 剪贴板实例容器
let clipboard = null;
// ...
export default {
// ...
mounted () {
clipboard = new Clipboard(this.$refs['clipboarddBtn']);
clipboard.on('success', (e) => {
this.$weui.toast('复制成功', 1000);
// console.info('Action:', e.action);
// console.info('Text:', e.text);
// console.info('Trigger:', e.trigger);
});
clipboard.on('error', (e) => {
this.$weui.alert('复制失败,原因请查看控制台');
console.error('Action:', e.action);
console.error('Trigger:', e.trigger);
});
},
destroyed () {
clipboard.destroy();
}
};
</script>
复制代码
4. 如何使用 Service Worker 加持?
大家如果访问了我的线上版本: md.ironmaxi.com ,那么你现在可以尝试一下,关闭网络,关闭所有浏览器;然后重新打开一个刚才访问过这个网站的浏览器,访问该域名,你会发现,照常显示,功能正常。
大家可以打开开发者工具,切换到 Network,可以看到静态资源的 Size,都是
(from ServiceWorker)
,这样我们就在断网的环境都能够使用。当然了,断网的环境我们也不能到微信公众平台发文,所以,最主要的目的还是让这款排版编辑器在网络差或者平常情况下,能够实现瞬间加载。
由于我们使用了 webpack 来搭建工程项目,我们就可以很方便地引入第三方的 webpack 插件:
这两个插件有点相辅相成的味道。玩过 Service Worker 的朋友们都知道,想要使用 Service Worker 一般都有两个步骤:
步骤 1,注册 service worker 的一段 js:
navigator.serviceWorker && navigator.serviceWorker.register('/service-worker.js').then(() => {
// ...
});
复制代码
步骤2,实现 service worker 缓存策略的逻辑代码:
self.addEventListener('install', function () {
// ...
});
self.addEventListener('activate', function () {
// ...
});
复制代码
同时,service worker 能够给我们带来优秀缓存策略的同时,也给我们出了一个难题, 如何优雅地实现更新策略 ?
当浏览器检测到实现缓存策略文件的 service-worker.js 有更新时,第一次会进入
install
阶段,用户刷新浏览器或者关闭所有相关会话,再重新打开时,新的 service-worker.js 才会进入
activate
阶段。而且,这还是理想情况,如果浏览器对 service-worker.js 进行了缓存呢?那用户浏览器就会陷入无法获取最新应用的噩梦之中!
即使通过在服务器上显式声明对 service-worker.js 不设置缓存,也就是每次都能够获取最新的,那么还是要在第二次才能进入
activate
阶段,从而起作用。对用户来说是黑盒,如果用户一直不刷新页面呢?
这些情况太可怕了。那么到底 如何优雅地实现更新策略 ?
4.1 使用 sw-register-webpack-plugin 插件优雅地注册 service-worker
我们可以将注册 service worker 的 js 代码单独抽取出来,作为一个单独的文件 sw-register.js,我们就每次多花一个请求去请求最新的 sw-register.js,如何能够绕过 service worker 和浏览器的缓存策略,每次都拿到最新的呢?答案就是 加时间戳 ,如下:
<script>
window.onload = function () {
var script = document.createElement('script');
var firstScript = document.getElementsByTagName('script')[0];
script.type = 'text/javascript';
script.async = true;
script.src = '${publicPath}/sw-register.js?_t=' + Date.now();
firstScript.parentNode.insertBefore(script, firstScript);
};
</script>
复制代码
当然了,以上这段代码,以及 sw-register.js 文件,sw-register-webpack-plugin 插件都帮我们做好了。我们只需要在 webpack 配置文件中直接使用:
// webpack.config.js
import SwRegisterWebpackPlugin from 'sw-register-webpack-plugin';
// ...
module.exports = {
plugins: [
new SwRegisterWebpackPlugin({
/* options */
});
]
// ...
};
复制代码
另外,我们可以同步地翻一下该仓库提供的源码文件 sw-register.js ,有这么一段代码:
navigator.serviceWorker.addEventListener('message', e => {
// service-worker.js 如果更新成功会 postMessage 给页面,内容为 'sw.update'
if (e.data === 'sw.update') {
// ...
}
});
复制代码
可以看到注释,「service-worker.js 如果更新成功会 postMessage 给页面,内容为 'sw.update'」,我们在条件判断语句中,就能做一些主动刷新页面或者提示用户应用更新的操作,通过 service-worker.js 去加载最新的资源。
接下来,如何在 sw-register.js 文件中加载最新的 service-worker.js 呢?其实我们要想,什么时候才需要加载最新的 service-worker.js?那就是在每一次构建之后!每一次构建都会有一个构建完成时间,我们故技重施,这样去请求
'service-worker.js?_buildTime=' + webpackBuildTime
。
来看如何去加载最新的 service-worker.js,查阅下 sw-register-webpack-plugin 提供的入口文件 index.js ,其中有那么段代码: