專案地址 https://github.com/liangxh0523/md_web_display
先上成果圖
網頁的佈局
網頁佈局分為三部分,分別是
- 頭部header,固定定位
- 側邊欄aside,固定定位,margin-top的值是header的高度
- 內容contain,靜態定位, margin-top值為header的高度,margin-left的值為aside的寬度,是router-view的出口。分為兩部分:
- 主內容,顯示md轉換後的html頁面,margin-right值為md目錄的寬度值
- 提取markdown的h1和h2目錄,用於標題導航,固定定位
功能
為了md能在網頁上良好的展示,應具備以下功能:
- 點選左側的選單,可以獲取到相應的md內容(字串格式),將md內容轉成html,為一級、二級標題加錨點id
- 為html增加md的格式,引入一個css即可,參考網址
- 提取md中的一級二級標題,在右側顯示文章目錄
- 點選右側文章目錄,左側內容可定位到相應的位置,還要做平滑滾動處理,增強使用者體驗,ps:主要講這個功能的三種實現方法,分別是
- 原生JS的方法,優點是相容性好,幾乎所有瀏覽器都支援,缺點是程式碼不優雅
- css的scroll-behavior方法,優點是程式碼簡單、優美,缺點是不支援safari(可能更多,只測試了opera、chrome、firefox)
- js的scrollIntoView方法,優點是不需要錨點定位,程式碼簡單。缺點也是瀏覽器相容性問題,不支援safari
- 左側內容滾動時,右側目錄的啟用項隨之動態變化
md轉成html
我使用了marked.js將md轉成html
// 先安裝marked.js到本地
npm install marked --save
// 在元件內引入marked
import marked from 'marked';
// marked的基本設定
marked.setOptions({
renderer: rendererMD,
gfm: true,
tables: true,
breaks: false,
pedantic: false,
sanitize: false,
smartLists: true,
smartypants: false
});
// 例項化
let rendererMD = new marked.Renderer();
watch: {
'$route.path': {
immediate:true,
handler: function(newValue) {
this.mode = newValue.slice(1);
let index = 0;
// 在rendererMD.heading裡有自己的作用域,用this訪問不到
let mode = this.mode
// 這裡的處理只是為了同時展示多個demo,三種方法需要不同的處理方法
rendererMD.heading = function(text, level) {
if (level < 3) {
return `<h${level} id="${index++}" class="jump" >${text}</h${level}>`;
} else {
return `<h${level}>${text}</h${level}>`;
}
};
document.getElementsByTagName("html")[0].style['scroll-behavior'] = this.mode === 'css_demo' ? 'smooth' : '';
this.compiledMarkdown = marked(str);
}
}
}
複製程式碼
在html中,用v-html繫結此計算屬性即可
<div class="markdown-body" ref="content" id="content" v-html="compiledMarkdown">
複製程式碼
提取標題
getTitle(content) {
let nav = [];
let navLevel = [1, 2];
let tempArr = [];
content
.replace(/```/g, function(match) {
return '\f';
})
.replace(/\f[^\f]*?\f/g, function(match) {
return '';
})
.replace(/\r|\n+/g, function(match) {
return '\n';
})
// 以至少一個#開始,緊接非換行符外任意個字元進行惰性匹配,然後是一個換行符
.replace(/(#+)[^#][^\n]*?(?:\n)/g, function(match, m1) {
let title = match.replace('\n', '');
let level = m1.length;
tempArr.push({
title: title.replace(/^#+/, '').replace(/\([^)]*?\)/, ''),
level: level,
children: []
});
});
// tempArr得到的是全部1-6級標題,將一級和二級過濾出來
nav = tempArr.filter(_ => _.level <= 2);
let index = 0;
// 在此處加index值,這裡和標籤裡繫結的id是對應的
nav = nav.map(_ => {
_.index = index++;
return _;
});
let retNavs = [];
let toAppendNavList;
navLevel.forEach(level => {
// 遍歷一級和二級標題,將同一級的元素組成一個新陣列
toAppendNavList = this.find(nav, {
level: level
});
if (retNavs.length === 0) {
// 處理一級標題
retNavs = retNavs.concat(toAppendNavList);
} else {
// 處理二級標題,把二級標題加到相應的父節點的children中
toAppendNavList.forEach(_ => {
_ = Object.assign(_);
let parentNavIndex = this.getParentIndex(nav, _.index);
return this.appendToParentNav(retNavs, parentNavIndex, _);
});
}
});
// 此處的retNavs就是處理後的樹
return retNavs;
},
// 處理屬於同一級的標題,組成陣列
find(arr, condition) {
return arr.filter(_ => {
for (let key in condition) {
if (condition.hasOwnProperty(key) && condition[key] !== _[key]) {
return false;
}
}
return true;
});
},
// 獲取此節點的父節點
getParentIndex(nav, endIndex) {
// 從當前的index開始找 1.距離自己最近的(遞減體現) 2.level比本身小的(越小越高)
for (var i = endIndex - 1; i >= 0; i--) {
if (nav[endIndex].level > nav[i].level) {
return nav[i].index;
}
}
},
// 找到同一個父節點的所有子節點
appendToParentNav(nav, parentIndex, newNav) {
// 找到每一個二級標題的傅標題的index值
let index = this.findIndex(nav, {
index: parentIndex
});
if (index === -1) {
// 這裡處理的是三級及以下標題
// 如果在一級標題裡沒找到父節點,就去每一個一級標題裡的children裡找
let subNav;
for (var i = 0; i < nav.length; i++) {
// 處理沒有父節點的情況
subNav = nav[i];
subNav.children.length && this.appendToParentNav(subNav.children, parentIndex, newNav);
}
} else {
nav[index].children = nav[index].children.concat(newNav);
}
},
// 找符合條件的陣列中的成員
findIndex(arr, condition) {
let ret = -1;
arr.forEach((item, index) => {
for (var key in condition) {
if (condition.hasOwnProperty(key) && condition[key] !== item[key]) { // 不進行深比較
return false;
}
}
ret = index;
});
return ret;
},
複製程式碼
md目錄的展示和錨點定位
<div id="menu">
<ul class="nav-list">
<li v-for="(nav, index) in contentMenu" :key="index">
<a :href="'#' + nav.index" :class="{'active': highlightIndex === nav.index}" @click="handleHighlight(nav.index)" :key="nav.index">{{nav.title}}
</a>
<template v-if="nav.children.length > 0">
<ul class="nav-list">
<li v-for="(item, index) in nav.children" :key="index">
<a :href="'#' + item.index" :class="{active: highlightIndex === item.index}" :key="item.index" @click="handleHighlight(item.index)">{{item.title}}
</a>
</li>
</ul>
</template>
</li>
</ul>
</div>
複製程式碼
平滑滾動
這裡有三種方法實現平滑滾動
1. js原生的方法
當點選右側的滾動條時,處理點選事件,根據點選元素的offsetTop和scrollTop值的差,確定需要滾動的距離
handleHighlight(item) {
this.highlightIndex = item;
if (this.mode === 'raw_js_demo') {
this.rawJsScroll(item);
} else if (this.mode === 'scroll_into_view') {
let jump = document.querySelectorAll('.jump');
jump[item].scrollIntoView({
block: 'start',
behavior: "smooth"
});
}
},
rawJsScroll(item) {
let jump = document.querySelectorAll('.jump');
// 這裡的60是header的高度值
let total = jump[item].offsetTop - 60;
let distance = document.documentElement.scrollTop || window.pageYOffset || document.body.scrollTop;
// 平滑滾動,時長500ms,每10ms一跳,共50跳
let step = total / 50;
if (total > distance) {
smoothDown();
} else {
let newTotal = distance - total;
step = newTotal / 50;
smoothUp();
}
function smoothDown() {
if (distance < total) {
distance += step;
window.scrollTo(0, distance);
setTimeout(smoothDown, 10);
} else {
window.scrollTo(0, total);
}
}
function smoothUp() {
if (distance > total) {
distance -= step;
window.scrollTo(0, distance);
setTimeout(smoothUp, 10);
} else {
window.scrollTo(0, total);
}
}
}
複製程式碼
2. css的方式
如果是css的方式,滾動條的容器處的scroll-behavior屬性設定為smooth值
document.getElementsByTagName("html")[0].style['scroll-behavior'] = this.mode === 'css_demo' ? 'smooth' : '';
複製程式碼
由於這裡有header,所以需要錨點定位時進行偏移處理,所以對於需要定位到的元素h1、h2前面插入一個元素,這個元素的標籤內繫結錨點值,它的padding-top和margin-top設定為相反數,值為頭部的高度,這樣就完成了定位元素的偏移處理。
rendererMD.heading = function(text, level) {
if (level < 3) {
return <div class="jump offset" id="${index++}"></div><h${level}>${text}</h${level}>`;
} else {
return `<h${level}>${text}</h${level}>`;
}
};
div .offset {
position: relative;
padding-top: 40px;
margin-top: -40px;
visibility: hidden;
}
複製程式碼
3. JS的scrollIntoView()方法
如果使用scrollIntoView()的方法,就不需要加錨點值進行定位了,在目錄項的點選事件中,使用當前選中元素,設定scrollIntoView()方法的block和behavior值,直接平滑滾動到指定的位置
rendererMD.heading = function(text, level) {
if (level < 3) {
return <div class="jump offset" id="${index++}"></div><h${level}>${text}</h${level}>`;
} else {
return `<h${level}>${text}</h${level}>`;
}
};
handleHighlight(item) {
let jump = document.querySelectorAll('.jump');
jump[item].scrollIntoView({
block: 'start',
behavior: "smooth"
});
}
複製程式碼
主內容滾動,目錄高亮
在閱讀md內容時,隨著滾動條的變化,目錄的高亮項也隨著變化,這裡有注意:
- 在mounted的鉤子函式中增加scroll的監聽事件
- 由於scroll是全域性性的,所以,離開當前元件之前,應該移除此監聽事件
mounted() {
this.$nextTick(function() {
window.addEventListener('scroll', this.onScroll);
});
},
beforeDestroy() {
window.removeEventListener('scroll', this.onScroll);
},
methods: {
onScroll() {
let top = document.documentElement ? document.documentElement.scrollTop : document.body.scrollTop;
let items = document.getElementById('content').getElementsByClassName('jump');
let currentId = '';
for (let i = 0; i < items.length; i++) {
let _item = items[i];
let _itemTop = _item.offsetTop;
// 處理三種平滑滾動處理方式時的偏移量不同
let height = this.mode === 'raw_js_demo' ? 75 : (this.mode === 'css_demo' ? 10 : 0);
if (top > _itemTop - height) {
currentId = i;
} else {
break;
}
}
if (currentId) {
// 這裡的currentOId是字串,必須轉換成數字,否則高亮項的全等無法匹配
this.highlightIndex = parseInt(currentId);
}
}
}
複製程式碼
Summary
以上就是如何讓md在網頁上良好展示的全部功能,谷歌了多次,感謝分享經驗的艾瑞巴dei,開源萬歲~!
待改進的點有:
- 如何避免非標題的#的正則匹配,比如
// #這不是標題
格式的內容 - md中h1和h2標籤的處理,md中是允許html的,現在不能滿足匹配h1、h2的標籤
覺得有用的話請點贊~你的贊就是我更新最大的動力~!