之前我写过一篇文章 《打造一个优雅的微信文章编辑器》, 那时候是直接 fork 大神小胡子哥 https://github.com/barretlee 的线上排版编辑器 https://github.com/barretlee/online-markdown 过来揣摩了一番, 顺便改了点样式, 加了一个代码主题色 Material Dark, 就上线了. 实际上, 项目的代码和体验一直我都感觉挺别扭的, 便彻底重构了一番.
新版访问地址: https://md.ironmaxi.com
新版界面:
操作效果:
大体介绍一下, 我在原项目的基础上做了什么工作:
添加 webpack 配置, 支持本地调试;
引入 Vue, 虽然没必要, 但是有了数据双向绑定, 代码写起来简洁, 维护起来方便;
添加实时预览功能, 左侧写出来的 Markdown 文字, 右侧立即预览, 没有延迟;
左侧与右侧视图同步滚动, 进一步提升使用体验;
点击复制内容, 所有文字及排版样式统统拷贝进剪贴板, 不用再多按一次 crtl + c;
根据微信公众编辑器的样式及限制, 多做了一些兼容;
增加了 3 种不同样式的 blockqoute;
站点升级 https 协议;
重磅: Service Worker 加持, 只要访问过一次线上地址, 那么静态资源都会被缓存, 离线可用!
重磅: GitLab CI/CD 加持, 我只要往 master 主干提交代码, 项目便可以自动打包构建, 并部署到我的个人服务器中, 而且还能自动帮我 push 到 GitHub 仓库中, 省去了我人工操作的步骤, 非常优雅, 这个后面详细说!
接下来, 我详细介绍一下完成这个项目的大致步骤与思路. 具体的项目代码, 大家可以访问 GitHub 仓库 https://github.com/hadeshe93/irm-markdowner . 如果这款工具好用, 解决了你的痛点, 请给仓库一个 Star!
1. 项目结构
.
├── .babelrc
├── .gitignore
├── .GitLab-ci.YAML // 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.YAML
- service-worker-plugin.JS
- sw-register.JS
- build
- src
2. 核心功能
在这个编辑器中, 最核心的功能只有两个:
将 Markdown 转为 HTML;
给不同语言的代码设置高亮.
2.1 转 Markdown 为 HTML
我们要引入第三方库: https://github.com/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 给不同语言的代码设置高亮
依赖的核心第三方插件就是 https://github.com/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 全部复制到剪贴板里面. 这里我们借助的是第三方库 https://github.com/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 加持?
大家如果访问了我的线上版本: https://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>
- Windows.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 */
- });
- ]
- // ...
- };
另外, 我们可以同步地翻一下该仓库提供的源码文件 , 有这么一段代码:
- 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 提供的入口文件 , 其中有那么段代码:
- let con = fs.readFileSync(swRegisterFilePath, 'utf-8');
- let version = me.version;
- /* eslint-disable max-nested-callbacks */
- con = babelCompiler(con).replace(/(['"])([^\s;,()]+?\.js[^'"]*)\1/g, item => {
- let swFilePath = RegExp.$2;
- if (/\.JS/g.test(item)) {
- item = item.replace(/\?/g, '&');
- }
- // if is full url path or relative path
- if (/^(http(s)?:)?\/\//.test(swFilePath) || swFilePath[0] !== '/') {
- // 加构建时间戳
- return item.replace(/\.JS/g, ext => `${ext}?v=${version}`);
- }
- // if is absolute path
- if (swFilePath.indexOf(publicPath) !== 0) {
- let ret = item.replace(
- swFilePath,
- (publicPath + '/' + swFilePath)
- .replace(/\/{1,}/g, '/')
- // 加构建时间戳
- .replace(/\.JS/g, ext => `${ext}?v=${version}`)
- );
- return ret;
- }
- // 加构建时间戳
- return item.replace(/\.JS/g, ext => `${ext}?v=${version}`);
- });
说白了就是对 sw-register.JS 文件中的, 所有 .JS 文件路径都加上构建时间戳.
4.2 使用 sw-precache-webpack-plugin 优雅地设置缓存策略
有了注册 service worker 的脚本代码, 现在来实现最后一步, 使用 service worker 设置缓存策略.
也就是设置 service worker 在不同的生命周期阶段 (例如: install,activate 等) 如何表现, 在 fetch 事件发生时, 如何对资源做响应和缓存.
我们在这里借助 插件, 其内部帮我们对以上情况做好了一系列的通用缓存策略, 剩下来的, 我们只需要配置, 在不同的场景下, 要缓存那些静态资源或者异步请求资源.
在 webpack 配置中引入插件, 并设置如下:
- // webpack.config.JS
- // ...
- const SWPrecacheWebpackPlugin = require('sw-precache-webpack-plugin');
- module.exports = {
- // ...
- plugins: [
- new SWPrecacheWebpackPlugin({
- /* 配置项 */
- }),
- ],
- };
这样, 该插件会自动帮我们在 output.path 指定的路径下生成 service-worker.JS, 我们只需要将其注册即可, 但这我们在上一步已经做好啦!
来看看本项目的配置项:
- // webpack.config.JS
- // ...
- module.exports = {
- // ...
- plugins: [
- new SWPrecacheWebpackPlugin(
- {
- cacheId: 'app-cache',
- // 生成的文件名称
- filename: 'service-worker.js',
- // webpack 生成的静态资源全部缓存
- mergeStaticsConfig: true,
- // 忽略的文件
- staticFileGlobsIgnorePatterns: [
- /\.map$/ // map 文件不需要缓存
- ],
- // 是否压缩, 默认不压缩
- minify: true,
- // 注入的动态脚本, 可以加载自定义插件
- importScripts: [
- 'service-worker-plugin.js'
- ],
- verbose: true,
- // 缓存动态资源
- runtimeCaching: [
- {
- urlPattern: /demo\.md/,
- handler: 'networkFirst'
- },
- ]
- }
- ),
- ],
- };
看到了吗? 我们只需要对想要缓存的资源去做配置即可, 省去了一堆的缓存策略逻辑, 是不是非常便捷高效?!
5. GitLab CI/CD 加持
这篇文章到这里, 已经很长了, 而且超纲了很多. 但我还是想记录一下, 一步步优化工程的细节点, 这一步骤纯属是为了加速集成发布的, 关于 CI/CD 的文章, 我还在筹备当中, 如果读者们感兴趣的话, 可以去找些入门资料来阅读一下.
我这里用上 CI/CD 有什么好处呢? 首先要说一下构建, 发布项目代码的痛点:
不同平台安装的 NPM 包有可能是不一样的, 或许明明在 Mac 上打包构建是成功的, 去到 Windows 上居然失败了, mmp;
每次打包构建完我都要打开 Filezilla, 然后手动拖一下? 要是我一小时内, 不断地集成快速发布呢? 代码功能有回滚呢? 我就要不断地命令行打包构建, 鼠标触摸板拖动上传发布, 不是心累二字能够形容!
然而, 当我们用上了 CI/CD, 可以起码做到一些什么呢?
每次保证同样的平台进行依赖安装和构建, 解决了不同平台差异性导致的安装, 构建, 打包的隐患;
基于 Git 提交, 自动安装依赖, 打包构建, 测试检查, 发布上线, 全自动, 解放双手, 拥抱未来.
限于主题和篇幅, 我贴一下该项目使用 CI/CD 配置文件, 内容非常简单, 也是为了能让新手看懂, 入门这个东西, 并不困难:
- # 定义 stages
- stages:
- - install_build_deploy
- # 定义 job
- job_install_build_deploy:
- stage: install_build_deploy
- only:
- - master
- except:
- changes:
- - README.md
- script:
- # 打印一些相关信息
- - pwd
- - whoami
- # 安装依赖
- - echo "Starting job_install"
- - NPM install
- # 打包构建
- - echo "Starting job_build"
- - NPM run build
- # 部署
- - echo "Starting deploy"
- - sudo rm -rf /var/data/sword/md
- - sudo cp -r md /var/data/sword
总结
其实原本实现这个排版编辑器的核心功能是很简单的, 但是不断地去思考如何优化项目, 优化工程, 我真的是从中收获到很多.
如果本文对你有帮助, 不妨给我一个喜欢.
如果这个项目对你有帮助, 不妨给我一个 Star.
感谢你们的阅读.
觉得本文不错的话, 分享一下给小伙伴吧~
来源: https://juejin.im/post/5c9b437851882502fe5136c5