Markmap Docker 部署与优化完整笔记

一、项目概述

基于 Node.js 和 markmap 库构建的思维导图渲染服务,支持将 Markdown 文件渲染为交互式思维导图,部署在 Podman 容器中,使用 Caddy 作为反向代理。

二、项目结构

/data/markmap/
├── Dockerfile          # 容器构建文件
├── server.js           # 主服务程序
├── public/
│   └── js/            # 前端 JavaScript 库
│       ├── d3.min.js
│       └── markmap-view.js
├── docs/              # Markdown 文件存放目录
│   ├── dy.mm.md
│   └── dn.mm.md
└── Caddyfile          # Caddy 反向代理配置

三、Dockerfile 配置

FROM docker.1ms.run/node:26-alpine

WORKDIR /app

# 安装 markmap 核心依赖
RUN npm install express markmap-lib markmap-view d3

# 复制前端库到可访问目录
RUN mkdir -p public/js && \
    cp node_modules/d3/dist/d3.min.js public/js/ && \
    cp node_modules/markmap-view/dist/browser/index.js public/js/markmap-view.js

COPY server.js .
COPY public /app/public

EXPOSE 3000

CMD ["node", "server.js"]

四、Caddy 反向代理配置

zmdmap.zhaopeng.site {
    reverse_proxy mdmap:3000
}

五、Server.js 完整代码

const express = require('express');
const path = require('path');
const fs = require('fs');
const markmapLib = require('markmap-lib');
const { Transformer } = markmapLib;

const app = express();
const PORT = process.env.PORT || 3000;

// 关闭所有日志 - 减少 IO
console.log = function() {};
console.info = function() {};
console.warn = function() {};

app.disable('x-powered-by');

// 静态文件服务
app.use('/js', express.static(path.join(__dirname, 'public/js')));

// 处理 .md 和 .mm.md 文件
app.get(/\/[^/]+\.(mm\.)?md$/, async (req, res) => {
  try {
    const filePath = path.join(__dirname, 'docs', req.path);
    
    if (!fs.existsSync(filePath)) {
      return res.status(404).send('File not found');
    }
    
    const content = fs.readFileSync(filePath, 'utf-8');
    
    const transformer = new Transformer();
    const result = transformer.transform(content);
    const root = result.root;
    
    const html = generateHTML(root, req.path);
    res.send(html);
  } catch (err) {
    res.status(500).send(`
      <h2>处理文件时出错</h2>
      <p>${err.message}</p>
    `);
  }
});

// 首页 - 显示空白
app.get('/', (req, res) => {
  res.send(`
    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Markmap</title>
        <style>
            * { margin: 0; padding: 0; box-sizing: border-box; }
            html, body { 
                width: 100%; 
                height: 100%; 
                background: #f5f5f5;
                display: flex;
                justify-content: center;
                align-items: center;
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            }
            .message {
                color: #ccc;
                font-size: 14px;
                letter-spacing: 2px;
                user-select: none;
            }
        </style>
    </head>
    <body>
        <div class="message">Markmap</div>
    </body>
    </html>
  `);
});

// 404 处理
app.use((req, res) => {
  res.status(404).send(`
    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>404</title>
        <style>
            * { margin: 0; padding: 0; box-sizing: border-box; }
            html, body { 
                width: 100%; 
                height: 100%; 
                background: #f5f5f5;
                display: flex;
                justify-content: center;
                align-items: center;
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            }
            .message {
                color: #ddd;
                font-size: 13px;
                letter-spacing: 1px;
                user-select: none;
            }
        </style>
    </head>
    <body>
        <div class="message">404</div>
    </body>
    </html>
  `);
});

function generateHTML(root, filePath) {
  return `<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Markmap - ${path.basename(filePath)}</title>
    <style>
        * { 
            margin: 0; 
            padding: 0; 
            box-sizing: border-box; 
        }
        html, body {
            width: 100%;
            height: 100%;
            overflow: hidden;
            background: #f5f5f5;
        }
        body { 
            display: flex; 
            justify-content: center; 
            align-items: center; 
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            padding: 20px;
        }
        #mindmap {
            width: 100%;
            height: 100%;
            max-width: 1400px;
            max-height: 90vh;
            background: white;
            border-radius: 16px;
            box-shadow: 0 4px 24px rgba(0,0,0,0.1);
            padding: 20px;
            position: relative;
            min-height: 80vh;
        }
        #mindmap svg {
            width: 100% !important;
            height: 100% !important;
            display: block !important;
            background: #fafafa !important;
            border-radius: 8px;
        }
        .loading {
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100%;
            min-height: 500px;
            color: #999;
            font-size: 18px;
        }
        .error {
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100%;
            color: #e74c3c;
            font-size: 16px;
            padding: 20px;
            text-align: center;
            flex-direction: column;
            gap: 10px;
        }
        
        /* 节点圆点样式微调 */
        .markmap-node circle {
            fill: #3498db !important;
            stroke: #2980b9 !important;
            stroke-width: 2px !important;
            cursor: pointer !important;
        }
        
        .markmap-node text {
            font-size: 14px !important;
            font-weight: 500 !important;
            fill: #2c3e50 !important;
        }

        .markmap-node {
            cursor: pointer !important;
        }
    </style>
</head>
<body>
    <div id="mindmap">
        <div class="loading">加载思维导图中...</div>
    </div>
    
    <script src="/js/d3.min.js"></script>
    <script src="/js/markmap-view.js"></script>
    
    <script>
        (function() {
            // 禁用浏览器控制台日志
            console.log = function() {};
            console.info = function() {};
            console.warn = function() {};
            console.debug = function() {};
            
            const container = document.getElementById('mindmap');
            const data = ${JSON.stringify(root)};
            
            if (!data || !data.children || data.children.length === 0) {
                container.innerHTML = '<div class="error">思维导图数据为空</div>';
                return;
            }
            
            if (typeof d3 === 'undefined') {
                container.innerHTML = '<div class="error">d3.js 未加载</div>';
                return;
            }
            if (typeof markmap === 'undefined') {
                container.innerHTML = '<div class="error">markmap-view 未加载</div>';
                return;
            }
            
            try {
                const { Markmap } = window.markmap;
                
                container.innerHTML = '';
                
                const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
                svg.setAttribute('width', '100%');
                svg.setAttribute('height', '100%');
                svg.style.width = '100%';
                svg.style.height = '100%';
                svg.style.display = 'block';
                svg.style.background = '#fafafa';
                container.appendChild(svg);
                
                const mm = Markmap.create(svg, {
                    autoFit: false,              // 关闭自动适应全图(关键:防止点击节点触发缩放复位)
                    fitRatio: 0.92,
                    duration: 150,               // 保留轻微平滑过度,防止图形闪烁
                    maxWidth: 500,
                    colorFreezeLevel: 2,
                    zoom: true,
                    pan: true,
                    nodeMinHeight: 28,
                    spacingVertical: 15,
                    spacingHorizontal: 20,
                    color: (node, depth) => {
                        const colors = ['#3498db', '#2ecc71', '#f39c12', '#e74c3c', '#9b59b6', '#1abc9c', '#e67e22', '#1dd1a1'];
                        return colors[depth % colors.length] || '#3498db';
                    },
                    toggleRecursively: false
                }, data);
                
                // 仅在首次渲染时全图居中一次
                requestAnimationFrame(() => {
                    mm.fit();
                    const loading = container.querySelector('.loading');
                    if (loading) loading.style.display = 'none';
                });
                
            } catch (err) {
                container.innerHTML = \`
                    <div class="error">
                        <strong>渲染失败</strong>
                        <br>
                        \${err.message}
                    </div>
                \`;
            }
        })();
    </script>
</body>
</html>`;
}

app.listen(PORT, '0.0.0.0', () => {});

全屏版

const express = require('express');
const path = require('path');
const fs = require('fs');
const markmapLib = require('markmap-lib');
const { Transformer } = markmapLib;

const app = express();
const PORT = process.env.PORT || 3000;

// 关闭所有日志 - 减少 IO
console.log = function() {};
console.info = function() {};
console.warn = function() {};

app.disable('x-powered-by');

// 静态文件服务
app.use('/js', express.static(path.join(__dirname, 'public/js')));

// 处理 .md 和 .mm.md 文件
app.get(/\/[^/]+\.(mm\.)?md$/, async (req, res) => {
  try {
    const filePath = path.join(__dirname, 'docs', req.path);
    
    if (!fs.existsSync(filePath)) {
      return res.status(404).send('File not found');
    }
    
    const content = fs.readFileSync(filePath, 'utf-8');
    
    const transformer = new Transformer();
    const result = transformer.transform(content);
    const root = result.root;
    
    const html = generateHTML(root, req.path);
    res.send(html);
  } catch (err) {
    res.status(500).send(`
      <h2>处理文件时出错</h2>
      <p>${err.message}</p>
    `);
  }
});

// 首页 - 显示空白
app.get('/', (req, res) => {
  res.send(`
    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Markmap</title>
        <style>
            * { margin: 0; padding: 0; box-sizing: border-box; }
            html, body { 
                width: 100%; 
                height: 100%; 
                background: #ffffff;
                display: flex;
                justify-content: center;
                align-items: center;
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            }
            .message {
                color: #ccc;
                font-size: 14px;
                letter-spacing: 2px;
                user-select: none;
            }
        </style>
    </head>
    <body>
        <div class="message">Markmap</div>
    </body>
    </html>
  `);
});

// 404 处理
app.use((req, res) => {
  res.status(404).send(`
    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>404</title>
        <style>
            * { margin: 0; padding: 0; box-sizing: border-box; }
            html, body { 
                width: 100%; 
                height: 100%; 
                background: #ffffff;
                display: flex;
                justify-content: center;
                align-items: center;
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            }
            .message {
                color: #ddd;
                font-size: 13px;
                letter-spacing: 1px;
                user-select: none;
            }
        </style>
    </head>
    <body>
        <div class="message">404</div>
    </body>
    </html>
  `);
});

function generateHTML(root, filePath) {
  return `<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Markmap - ${path.basename(filePath)}</title>
    <style>
        * { 
            margin: 0; 
            padding: 0; 
            box-sizing: border-box; 
        }
        html, body {
            width: 100vw;
            height: 100vh;
            overflow: hidden;
            background: #ffffff;
        }
        #mindmap {
            width: 100vw;
            height: 100vh;
            background: #ffffff;
            position: relative;
        }
        #mindmap svg {
            width: 100% !important;
            height: 100% !important;
            display: block !important;
            background: #ffffff !important;
        }
        .loading {
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100%;
            color: #999;
            font-size: 18px;
        }
        .error {
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100%;
            color: #e74c3c;
            font-size: 16px;
            padding: 20px;
            text-align: center;
            flex-direction: column;
            gap: 10px;
        }
        
        /* 节点圆点样式微调 */
        .markmap-node circle {
            fill: #3498db !important;
            stroke: #2980b9 !important;
            stroke-width: 2px !important;
            cursor: pointer !important;
        }
        
        .markmap-node text {
            font-size: 14px !important;
            font-weight: 500 !important;
            fill: #2c3e50 !important;
        }

        .markmap-node {
            cursor: pointer !important;
        }
    </style>
</head>
<body>
    <div id="mindmap">
        <div class="loading">加载思维导图中...</div>
    </div>
    
    <script src="/js/d3.min.js"></script>
    <script src="/js/markmap-view.js"></script>
    
    <script>
        (function() {
            // 禁用浏览器控制台日志
            console.log = function() {};
            console.info = function() {};
            console.warn = function() {};
            console.debug = function() {};
            
            const container = document.getElementById('mindmap');
            const data = ${JSON.stringify(root)};
            
            if (!data || !data.children || data.children.length === 0) {
                container.innerHTML = '<div class="error">思维导图数据为空</div>';
                return;
            }
            
            if (typeof d3 === 'undefined') {
                container.innerHTML = '<div class="error">d3.js 未加载</div>';
                return;
            }
            if (typeof markmap === 'undefined') {
                container.innerHTML = '<div class="error">markmap-view 未加载</div>';
                return;
            }
            
            try {
                const { Markmap } = window.markmap;
                
                container.innerHTML = '';
                
                const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
                svg.setAttribute('width', '100%');
                svg.setAttribute('height', '100%');
                svg.style.width = '100%';
                svg.style.height = '100%';
                svg.style.display = 'block';
                svg.style.background = '#ffffff';
                container.appendChild(svg);
                
                const mm = Markmap.create(svg, {
                    autoFit: false,
                    fitRatio: 0.95,             // 调大缩放占比,使全图初始化时利用更多可视空间
                    duration: 150,
                    maxWidth: 600,              // 调大节点最大宽度,适应大屏显示
                    colorFreezeLevel: 2,
                    zoom: true,
                    pan: true,
                    nodeMinHeight: 28,
                    spacingVertical: 15,
                    spacingHorizontal: 20,
                    color: (node, depth) => {
                        const colors = ['#3498db', '#2ecc71', '#f39c12', '#e74c3c', '#9b59b6', '#1abc9c', '#e67e22', '#1dd1a1'];
                        return colors[depth % colors.length] || '#3498db';
                    },
                    toggleRecursively: false
                }, data);
                
                // 仅在首次渲染时全图居中一次
                requestAnimationFrame(() => {
                    mm.fit();
                    const loading = container.querySelector('.loading');
                    if (loading) loading.style.display = 'none';
                });
                
            } catch (err) {
                container.innerHTML = \`
                    <div class="error">
                        <strong>渲染失败</strong>
                        <br>
                        \${err.message}
                    </div>
                \`;
            }
        })();
    </script>
</body>
</html>`;
}

app.listen(PORT, '0.0.0.0', () => {});

六、部署命令

构建镜像

podman build -t markmap .

运行容器

podman run -d --name mdmap -p 3000:3000 -v /data/markmap/docs:/app/docs markmap

查看容器状态

podman ps
podman logs -f mdmap

停止和删除容器

podman stop mdmap
podman rm mdmap

七、问题排查与解决方案

问题1: Transformer is not defined

错误信息:

ReferenceError: Transformer is not defined

原因: markmap-lib 的导出方式问题。

解决方案:

const markmapLib = require('markmap-lib');
const { Transformer } = markmapLib;

问题2: SVG 内容为空

错误信息:

📐 SVG content length: 55
📐 SVG children count: 2

原因: markmap-view API 使用方式不正确。

解决方案: 使用 Markmap.create() 方法,传入数据和配置。

问题3: color is not a function

错误信息:

TypeError: color is not a function

原因: color 配置项需要是函数,不是数组。

解决方案:

color: (node, depth) => {
    const colors = ['#3498db', '#2ecc71', '#f39c12', '#e74c3c', '#9b59b6', '#1abc9c'];
    return colors[depth % colors.length] || '#3498db';
}

问题4: 节点点击抖动

原因:

  • 过渡动画导致元素位置变化
  • 点击区域过小
  • hover 效果触发重排

解决方案:

  1. 禁用所有 transition
  2. 增大 circle 半径
  3. 禁用 hover 效果
  4. 设置 duration: 0

问题5: CORS 错误

错误信息:

Access to fetch at 'https://rumtc.myalicdn.com/v1/common/manage' from origin has been blocked by CORS policy

原因: 外部监控脚本加载失败,不影响核心功能。

解决方案: 忽略此错误,或移除相关脚本。

八、配置参数说明

Markmap 核心配置

参数说明
autoFittrue自动适配视图
fitRatio0.92适配比例
duration0动画持续时间(0=禁用)
maxWidth500节点最大宽度
zoomtrue启用缩放
pantrue启用拖拽
nodeMinHeight28节点最小高度
spacingVertical15垂直间距
spacingHorizontal20水平间距
toggleRecursivelyfalse仅切换当前节点

九、访问方式

十、性能优化

  1. 禁用所有日志输出,减少 IO 操作
  2. 禁用动画效果,提升响应速度
  3. 增大节点间距,提升交互体验
  4. 使用静态文件服务,减少重复加载
  5. 容器化部署,资源隔离

十一、注意事项

  1. 确保 docs 目录存在并有正确的文件权限
  2. 文件必须使用 .md 或 .mm.md 扩展名
  3. Caddy 需要正确配置反向代理
  4. 容器重启后需重新挂载数据卷
  5. 生产环境建议使用更严格的安全配置

标签: none

添加新评论