嫌微信公众号排版太丑?这里让你一步到位

4,222 阅读9分钟

之前我写过一篇文章 《打造一个优雅的微信文章编辑器》,那时候是直接 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. 核心功能

在这个编辑器中,最核心的功能只有两个:

  1. 将 markdown 转为 html;
  2. 给不同语言的代码设置高亮。

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,我给大家总结下官方推荐用法:

  1. 引入该插件:
    <script src="https://cdn.jsdelivr.net/gh/google/code-prettify@master/loader/run_prettify.js"></script>
  2. 查看入门文档,配置你所需要的引入 url;
  3. 查看皮肤库并选择你所喜欢的一款;
  4. 将代码写进带 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 去高亮代码,必须给 precode 元素加上 prettyprint 样式名,如果还需要行号的话,还得加上 linenums 样式名。我们就借助 showdown 的插件,实现给所有转换出来的 html 中的 precode 加样式名。在 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,其中有那么段代码:

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 事件发生时,如何对资源做响应和缓存。

我们在这里借助 goldhand/sw-precache-webpack-plugin 插件,其内部帮我们对以上情况做好了一系列的通用缓存策略,剩下来的,我们只需要配置,在不同的场景下,要缓存那些静态资源或者异步请求资源。

在 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 有什么好处呢?首先要说一下构建、发布项目代码的痛点:

  1. 不同平台安装的 npm 包有可能是不一样的,或许明明在 mac 上打包构建是成功的,去到 windows 上居然失败了,mmp;
  2. 每次打包构建完我都要打开 Filezilla,然后手动拖一下?要是我一小时内,不断地集成快速发布呢?代码功能有回滚呢?我就要不断地命令行打包构建,鼠标触摸板拖动上传发布,不是心累二字能够形容!

然而,当我们用上了 CI/CD,可以起码做到一些什么呢?

  1. 每次保证同样的平台进行依赖安装和构建,解决了不同平台差异性导致的安装、构建、打包的隐患;
  2. 基于 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⭐️。

感谢你们的阅读。


微信公众号
觉得本文不错的话,分享一下给小伙伴吧~