在前端頁面中使用Markdown並且優化a標簽


近期在自己的項目中加入了對 Markdown 語法 的支持,主要用到的是markedjs這個項目。該項目托管在github上,地址為:https://github.com/markedjs/marked/

項目的安裝

下載項目之后,在根目錄下執行如下 npm 命令 進行安裝

$ npm install

安裝完成之后最終項目的目錄結構如下

 

我們看一下根目錄下的 package.json 文件,部分內容如下。 json有自己的語法格式,可以參考 Json 教程

"scripts": {
    "test": "jasmine --config=jasmine.json",
    "test:all": "npm test && npm run test:lint",
    "test:unit": "npm test -- test/unit/**/*-spec.js",
    "test:specs": "npm test -- test/specs/**/*-spec.js",
    "test:lint": "eslint bin/marked .",
    "test:redos": "node test/vuln-regex.js",
    "test:update": "node test/update-specs.js",
    "rules": "node test/rules.js",
    "bench": "npm run rollup && node test/bench.js",
    "lint": "eslint --fix bin/marked .",
    "build:reset": "git checkout upstream/master lib/marked.js lib/marked.esm.js marked.min.js",
    "build": "npm run rollup && npm run minify",
    "build:docs": "node build-docs.js",
    "rollup": "npm run rollup:umd && npm run rollup:esm",
    "rollup:umd": "rollup -c rollup.config.js",
    "rollup:esm": "rollup -c rollup.config.esm.js",
    "minify": "uglifyjs lib/marked.js -cm  --comments /Copyright/ -o marked.min.js",
    "minifyMessage": "uglifyjs ext/onmpwmessage.js -cm  --comments /Copyright/ -o ext/onmpwmessage.min.js",
    "preversion": "npm run build && (git diff --quiet || git commit -am build)"
  }

 

執行如下命令

$ npm run build

 

命令執行完成會生成marked.min.js文件

 

最后我們將 marked.min.js 文件拷貝到我們的項目中,然后就可以使用了

使用markedjs 解析編譯Markdown內容

在頁面中引入 marked.min.js 文件

<script type="text/javascript" src="/js/marked.min.js"></script>

 

接下來就是對內容的解析了,首先要初始化marked對象

marked.setOptions({
    renderer: new marked.Renderer(),
    gfm: true,
    tables: true,
    breaks: false,
    pedantic: false,
    sanitize: false,
    smartLists: true,
    smartypants: false,
    highlight: function (code,lang) {
        //使用 highlight 插件解析文檔中代碼部分
        return hljs.highlightAuto(code,[lang]).value;
    }
});

 

然后調用marked函數進行解析

let originText = "[跡憶客](https://www.jiyik.com)";
let newText = marked(originText);
console.log(newText);

 

實際情況中我們可以通過ajax從后台獲取markdown的內容,然后通過marked解析成 html,將解析后的 html 內容放到頁面中相應的地方即可。

說一下我的markdown的應用

本人的項目中不是在前端對Markdown進行轉換,而是在編輯器中按照Markdown語法編輯好內容之后,通過markedjs將內容轉換成html,存入到數據庫中,在前台取出來的直接就是解析后的內容了,可以直接顯示在頁面上。

 


對markedJs的優化

下面到了本次重點內容了,markedJs相對來說比較成熟,個人感覺功能還是比較全面的。然而美中不足的是,可能受markdown默認語法的影響,對 a標簽 的解析只有是當前頁面打開,沒有新窗口打開的語法。也就是說對於下面的語法

[跡憶客](https://www.jiyik.com "這里是title")

 

最終只能轉換成

<a href="https://www.jiyik.com" title="這里是title">跡憶客</a>

 

如果我想要新窗口打開的a標簽,是沒有對應的語法可以使用的。總不能因為一個a標簽就將markedJs拋棄不用吧,面對這種情況,即然項目是開源的,那就試着看一下自己能不能加上這一屬性。

我總共用了三種方法來增加target這一屬性

直接暴力添加

最開始我是這么考慮的,在項目中一般都是在文章內容里才會用到markdown的語法。一般情況下文章內容中的跳轉都會使用新窗口打開。所以說,直接在解析后的a標簽中加上屬性target="_blank"

按照這一思路,我就直接去看源碼。此種方式有個最簡單的方式就是全項目搜索<a。找到構造 a 標簽的地方,在后面直接加上 target="_blank" 就可以了。

在項目中的 src/Renderer.js 文件中的140行左右

let out = '<a href="' + escape(href) + '"';

 

直接添加target屬性

let out = '<a href="' + escape(href) + '" target="_blank"';

 

然后在根目錄下執行命令

$ npm run build

 

將生成的 marked.min.js 應用到項目中。之后再新添加的a標簽都帶着 target="_blank" 屬性。

雖然添加上了,但是仔細想想這種方式和沒優化之前並沒有什么區別,只是一個新窗口,一個不新窗口。沒辦法進行控制是最痛苦的。要是能通過某種方式對這個屬性進行控制,那就完美了。

使用!控制屬性是否添加

要想能控制target屬性,就要在[]()中使用某種符號進行標記。img標簽對應的markdown的語法為![]()。借鑒img標簽的語法,我把嘆號放到中括號里面[!]來實現對target屬性的控制。

要實現的效果如下

[跡憶客](https://www.jiyik.com) // 解析后為 <a href="https://www.jiyik.com">跡憶客</a>

[!跡憶客](https://www.jiyik.com) // 解析后為 <a href="https://www.jiyik.com" target="_blank">跡憶客</a>

 

要實現這種效果,就不像上面一樣了,直接全項目搜索 <a 是沒什么用的。這里我使用了WebStorm打開marked項目,然后利用上面的調試工具,追蹤它的代碼。

首先要在webstorm中配置markedJs,使其能夠運行。首先新建 node.js 腳本運行

 

新建成功之后,可以在代碼中打上斷點,運用webstorm的調試功能來追蹤其代碼。

當然這里不能在項目的入口文件就打斷點,這樣在追蹤的過程中是很痛苦的,因為如果代碼層級很深的話,容易走着走着就迷路了。

先讀源碼,在認為和解析a標簽相關的地方打上斷點。在讀了源碼之后,我是在 src/Tokenizer.js文件中的 link() 方法里打上的斷點(在 474 行)

 

經過追蹤,最終跟到了src/Tokenizer.js中的outputLink()方法中,其實現如下:

function outputLink(cap, link, raw) {
  const href = link.href;
  const title = link.title ? escape(link.title) : null;
  const text = cap[1].replace(/\\([\[\]])/g, '$1');

  if (cap[0].charAt(0) !== '!') {
    return {
      type: 'link',
      raw,
      href,
      title,
      text,
    };
  } else {
    return {
      type: 'image',
      raw,
      href,
      title,
      text: escape(text)
    };
  }
}

 

代碼中的 text 保存的就是 [跡憶客] 中的文本(跡憶客)。如果我們加上嘆號,[!跡憶客],那text的值為“!跡憶客”。這樣我們就可以對text的文本做一個判斷,如果第一個字母是嘆號!,則就要將target的值設置為"_blank"。否則的話target就為空。然后在返回的對象中加上target屬性。修改后的代碼如下

function outputLink(cap, link, raw) {
  const href = link.href;
  const title = link.title ? escape(link.title) : null;
  const text = cap[1].replace(/\\([\[\]])/g, '$1');

  if (cap[0].charAt(0) !== '!') {
    let a_text = text;
    let target = "";
    if(a_text.charAt(0) === '!') {
      target = "_blank";
      a_text = a_text.substring(1); // 這里將文本中的!去掉
    }
    return {
      type: 'link',
      raw,
      href,
      title,
      text:a_text,
      target
    };
  } else {
    return {
      type: 'image',
      raw,
      href,
      title,
      text: escape(text)
    };
  }
}

 

然后繼續追蹤代碼,來到了我們第一種方法中暴力添加的地方 link() 方法。這里我們不再使用暴力了,因為我們現在有選擇了,需要給link方法增加一個參數 target

link(href, title, text, target) {
    href = cleanUrl(this.options.sanitize, this.options.baseUrl, href);
    if (href === null) {
      return text;
    }
    let out = '';
    if(target !== "") {
      out = '<a href="' + escape(href) + '" target="' + escape(target) + '"';
    }else{
      out = '<a href="' + escape(href) + '"';
    }
    if (title) {
      out += ' title="' + title + '"';
    }
    out += '>' + text + '</a>';
    return out;
  }

 

然后我們繼續找到調用link方法的地方—— src/Parser.js文件的第219行

 

在link方法調用的地方將 target參數傳過去

case 'link': {
      out += renderer.link(token.href, token.title, this.parseInline(token.tokens, renderer),token.target);
      break;
    }

 

到這里我們所有的代碼就修改完成了,接下來就是編譯項目,生成 marked.min.js 文件,在我的項目中使用了。

使用了一段時間,沒有發現什么問題。但是總感覺這種方式不夠徹底,當然不是說對於語法上不夠徹底,而是對於代碼上不夠徹底。在匹配出text之后,還要對text的首字母進行判斷,然后在截取字符串。效率上應該是有些不足(雖然實際情況沒什么影響,但是畢竟要本着精益求精的態度不是嗎,請允許我裝一下)。還是應該繼續優化代碼,接下來就來到了終極的方法

究極大招,修改規則

即然不想從文本那里動手,那就要改變其匹配的規則。同樣繼續使用webstorm斷點調試。可以發現對所有的標簽匹配的規則如下

const inline = {
  escape: /^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,
  autolink: /^<(scheme:[^\s\x00-\x1f<>]*|email)>/,
  url: noopTest,
  tag: '^comment'
    + '|^</[a-zA-Z][\\w:-]*\\s*>' // self-closing tag
    + '|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>' // open tag
    + '|^<\\?[\\s\\S]*?\\?>' // processing instruction, e.g. <?php ?>
    + '|^<![a-zA-Z]+\\s[\\s\\S]*?>' // declaration, e.g. <!DOCTYPE html>
    + '|^<!\\[CDATA\\[[\\s\\S]*?\\]\\]>', // CDATA section
  link: /^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/,
  reflink: /^!?\[(label)\]\[(?!\s*\])((?:\\[\[\]]?|[^\[\]\\])+)\]/,
  nolink: /^!?\[(?!\s*\])((?:\[[^\[\]]*\]|\\[\[\]]|[^\[\]])*)\](?:\[\])?/,
  reflinkSearch: 'reflink|nolink(?!\\()',
  emStrong: {
    lDelim: /^(?:\*+(?:([punct_])|[^\s*]))|^_+(?:([punct*])|([^\s_]))/,
    //        (1) and (2) can only be a Right Delimiter. (3) and (4) can only be Left.  (5) and (6) can be either Left or Right.
    //        () Skip other delimiter (1) #***                (2) a***#, a***                   (3) #***a, ***a                 (4) ***#              (5) #***#                 (6) a***a
    rDelimAst: /\_\_[^_]*?\*[^_]*?\_\_|[punct_](\*+)(?=[\s]|$)|[^punct*_\s](\*+)(?=[punct_\s]|$)|[punct_\s](\*+)(?=[^punct*_\s])|[\s](\*+)(?=[punct_])|[punct_](\*+)(?=[punct_])|[^punct*_\s](\*+)(?=[^punct*_\s])/,
    rDelimUnd: /\*\*[^*]*?\_[^*]*?\*\*|[punct*](\_+)(?=[\s]|$)|[^punct*_\s](\_+)(?=[punct*\s]|$)|[punct*\s](\_+)(?=[^punct*_\s])|[\s](\_+)(?=[punct*])|[punct*](\_+)(?=[punct*])/ // ^- Not allowed for _
  },
  code: /^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,
  br: /^( {2,}|\\)\n(?!\s*$)/,
  del: noopTest,
  text: /^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\<!\[`*_]|\b_|$)|[^ ](?= {2,}\n)))/,
  punctuation: /^([\spunctuation])/
};

 

這里我們只關心link規則

link: /^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/

 

原來你一開始就沒有把我們可愛的target考慮進去,target一定不是親生的。

即然你不要,那我們就自己動手將其加進去吧,修改規則如下

link: /^!?\[(target)(label)\]\(\s*(href)(?:\s+(title))?\s*\)/,

 

這還不夠,像里面的 target、label、href和title這都是一個標記,來說明此處應該是什么。用這種正則去匹配也匹配不出什么東西來啊。下面肯定還藏着有東西呢。於是繼續尋找,最終發現下面的代碼

inline._label = /(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/;
inline._href = /<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/;
inline._title = /"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/;

inline.link = edit(inline.link)
  .replace('label', inline._label)
  .replace('href', inline._href)
  .replace('title', inline._title)
  .getRegex();

 

啊哈哈,這就對上了。這是為了防止一個這么長的正則不好閱讀,所以才使用標記來進行說明,然后由程序自己來替換使用。還挺人性化的嗎,這里給點個贊。
這就好辦了,上面我們即然加上了target的標記,那這里我們也加個正則來匹配我們的嘆號!

inline._target = /!?/;

inline.link = edit(inline.link)
  .replace('target',inline._target)
  .replace('label', inline._label)
  .replace('href', inline._href)
  .replace('title', inline._title)
  .getRegex();

 

因為我要捕獲匹配的結果,所以在上面target標記外面加了小括號(target) 。 這里是屬於正則表達式的知識點了。所以說正則表達式還是很重要的,如果不了解正則那我們也就沒有大招了。到了第二種方式也就停止了。看到這是不是有種想學習正則表達式的沖動了。點擊學習正則表達式

接下來我們要對在第二種方式中修改的 outputlink() 方法再次進行修改

function outputLink(cap, link, raw) {
  const href = link.href;
  const title = link.title ? escape(link.title) : null;
  const text = cap[2].replace(/\\([\[\]])/g, '$1');
  const target = (cap[1].length == 1 && cap[1] === '!')?"_blank":"";

  if (cap[0].charAt(0) !== '!') {
    return {
      type: 'link',
      raw,
      href,
      title,
      text,
      target
    };
  } else {
    return {
      type: 'image',
      raw,
      href,
      title,
      text: escape(text)
    };
  }
}

 

看起來,是不是變簡單了呢。不過只是修改這里還是不行的,因為我們前面在正則中多加了一個捕獲組,所以對於之前的text、href和title它們的分組索引都要加 1 才對。
要在哪里修改呢,這里繼續往下尋找,又找到了一個link方法,但是這個link方法和之前加參數的 link 方法不同。該link方法是 src/Tokenizer.js 文件中定義的。

link(src) {
    const cap = this.rules.inline.link.exec(src);
    if (cap) {
      const trimmedUrl = cap[3].trim(); // 原先為 cap[2].trim()
      if (!this.options.pedantic && /^</.test(trimmedUrl)) {
        // commonmark requires matching angle brackets
        if (!(/>$/.test(trimmedUrl))) {
          return;
        }

        // ending angle bracket cannot be escaped
        const rtrimSlash = rtrim(trimmedUrl.slice(0, -1), '\\');
        if ((trimmedUrl.length - rtrimSlash.length) % 2 === 0) {
          return;
        }
      } else {
        // find closing parenthesis
        // 原先為 const lastParenIndex = findClosingBracket(cap[2], '()')
        const lastParenIndex = findClosingBracket(cap[3], '()'); 
        if (lastParenIndex > -1) {
          const start = cap[0].indexOf('!') === 0 ? 5 : 4;
          const linkLen = start + cap[1].length + lastParenIndex;

          // 原先為 cap[2] = cap[2].substring(0, lastParenIndex);
          cap[3] = cap[3].substring(0, lastParenIndex); 
          cap[0] = cap[0].substring(0, linkLen).trim();
          cap[4] = ''; // 原先為 cap[3] = '';
        }
      }
      let href = cap[3]; // 原先為 let href = cap[2];
      let title = '';
      if (this.options.pedantic) {
        // split pedantic href and title
        const link = /^([^'"]*[^\s])\s+(['"])(.*)\2/.exec(href);

        if (link) {
          href = link[1];
          title = link[3];
        }
      } else {
        // 原先為 title = cap[3] ? cap[3].slice(1, -1) : '';
        title = cap[4] ? cap[4].slice(1, -1) : '';
      }

      href = href.trim();
      if (/^</.test(href)) {
        if (this.options.pedantic && !(/>$/.test(trimmedUrl))) {
          // pedantic allows starting angle bracket without ending angle bracket
          href = href.slice(1);
        } else {
          href = href.slice(1, -1);
        }
      }
      return outputLink(cap, {
        href: href ? href.replace(this.rules.inline._escapes, '$1') : href,
        title: title ? title.replace(this.rules.inline._escapes, '$1') : title
      }, cap[0]);
    }
  }

 

第二種方式中修改的其他地方的代碼就不要再繼續動了,保持在第二種方式中的修改即可。

到此終極大招放完了。使用命令編譯生成 marked.min.js 文件就行了。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM