1. 程式人生 > >樹狀選單的前世今生

樹狀選單的前世今生

作者: code lighter 原創

日期: 2017/5/29

關鍵詞: 樹狀選單分級選單遞迴堆疊字串解析格式轉換

很多時候,網站需要動態載入多級分類選單,後臺程式通過查詢資料庫生成分類選單的巢狀陣列,然後將巢狀陣列轉換成JSON格式輸出給前端程式展示。類似下面這樣:

$data = [0=>["text"=>"生活電器",
            "id"=>1,
            "parent_id"=>0,
            "children"=>[0=>["text"=>"空調",
                             "id"
=>2, "parent_id"=>1], 1=>["text"=>"冰箱", "id"=>3, "parent_id"=>1] ] ], 1=>["text"=>"男裝", "id"=>4, "parent_id"
=>0 ];

我們發現上面這種巢狀資料的新增會非常麻煩,需要一個一個手動新增,很難一次性批量匯入資料庫。因為子類選單的parent_id需要指向父類的id,如果在平時測試的時候,需要錄入這種層級選單資料,將會非常頭疼。那麼有沒有一種快速批量錄入這種分類選單的方法呢?當然有,比如我靈機一動,想到了下面這種方法.

開啟txt檔案,設定編碼格式為utf-8,錄入下面這種格式的資料,一行代表一個選單,一個 + 號表示一級子選單,二個 + 號表示二級子選單,依此類推。頂級選單不需要 號,錄入的時候記得前後不要留空格,這樣看上去更直觀。仔細觀察這種資料結構,你會發現它暗含了選單的層級關係,而且錄入十分方便。

生活電器

+空調

++智慧空調
++變頻空調

+冰箱

++三門冰箱
++對開門冰箱

男裝

+清涼夏裝

+溫情冬裝

程式讀入上面的txt資料,然後自動生成下面這種JSON巢狀資料結構,非常方便程式進一步處理。比如可以批量匯入資料庫,作為快取檔案直接返回給網站前端程式等.

[
  {
    "id": 1,
    "text": "生活電器",
    "level": 0,
    "parent_id": 0,
    "children": [
      {
        "id": 2,
        "text": "空調",
        "level": 1,
        "parent_id": 1,
        "children": [
          {
            "id": 3,
            "text": "智慧空調",
            "level": 2,
            "parent_id": 2
          },
          {
            "id": 4,
            "text": "變頻空調",
            "level": 2,
            "parent_id": 2
          }
        ]
      },
      {
        "id": 5,
        "text": "冰箱",
        "level": 1,
        "parent_id": 1,
        "children": [
          {
            "id": 6,
            "text": "三門冰箱",
            "level": 2,
            "parent_id": 5
          },
          {
            "id": 7,
            "text": "對開門冰箱",
            "level": 2,
            "parent_id": 5
          }
        ]
      }
    ]
  },
  {
    "id": 8,
    "text": "男裝",
    "level": 0,
    "parent_id": 0,
    "children": [
      {
        "id": 9,
        "text": "清涼夏裝",
        "level": 1,
        "parent_id": 8
      },
      {
        "id": 10,
        "text": "溫情冬裝",
        "level": 1,
        "parent_id": 8
      }
    ]
  }
]

那麼問題來了,程式如何讀取txt檔案裡的分級選單資料,並識別每級選單的層級關係呢?如果我們一邊讀取一邊解析,會發現程式需要不斷回溯檢視,處理起來會非常麻煩。比如當處理到 冰箱 這一行的時候,程式需要回溯檢視父級選單 生活電器 並將 冰箱parent_id 指向 生活電器id,而處理到 男裝 這一行的時候 parent_id = 0,程式的狀態遷移並不好控制。
在處理類似這種比較棘手的程式設計問題的時候, 我們要轉變思維。借鑑數學領域裡經常用到的化歸思想,不斷將問題進行轉化,轉化到到我們熟悉的問題上來。

  1. 在PHP專案中,生成分類層級選單,我們一般採用的方法是從資料庫中讀取出分類選單資料[一個二維陣列],然後根據每行資料的id, parent_id 遞迴呼叫選單生成函式,將二維陣列轉化為巢狀陣列,如下所示
   public function buildTree(&$array,$callback=null,$parent_id=0,$child_node="children"){
        $tree = [];
        foreach($array as $k=>$v){
            if($v['parent_id'] == $parent_id){
                unset($array[$k]);
                $tmp =is_callable($callback)?call_user_func($callback,$v):$v;
                $children = $this->buildTree($array,$callback,$v['id'],$child_node);
                if($children){
                    $tmp[$child_node] = $children;
                }
                $tree[] = $tmp;
            }
        }
        return $tree;
    }

上面這個函式的 callback 引數是一個回撥函式,是用來過濾每行資料的。回撥函式裡,是簡單的賦值語句,取出想要的欄位,比如 menu_name, is_visible, id, parent_id 。當然 id,parent_id 是必須取出來的,因為他們標識了選單的層級關係。注意程式裡面的 unset($array[$k]); 這一句程式碼主要是為了提高遞迴程式的效能,每次遞迴的時候,陣列會變小。這樣一來,已經遍歷過的資料,就不會重複遍歷了。

有了上面的函式做鋪墊,我們發現只要找到每行資料的id,parent_id的關係,就能呼叫上面這個選單函式生成想要的多維巢狀陣列。
怎麼確定這種關係是關鍵,也是難點。我們分步來做

步驟 1. 遍歷每行選單資料,根據每行資料前面的+號,標識每行的層級level

步驟 2.步驟 1 基礎上我們再次遍歷每行選單資料, 根據每行資料的 level 大小,我們來設定每個選單的 parent_id 從而確定選單之間的關係,遍歷的過程我們發現需要檢視之前選單的 id, 而程式不能確定當前行的選單的父類到底是遍歷過的選單行的哪一個。這個時候我們需要採用堆疊資料結構,將每次遍歷選單的id 壓棧, 當層級深的選單遍歷到層級淺的選單,我們再將資料出棧。比如遍歷到 冰箱 這一行的時候,我們需要彈出到 生活電器 這一層。具體程式碼如下.

    public function markTree(&$data){
        $level = $parent_id = 0;
        foreach($data as $k=>$v){
            if($v['level']>$level){
                $this->_stack->push(["level"=>$v['level'],"id"=>$v['id']]);
                $data[$k]['parent_id'] =$data[$k-1]['id'];
                $level = $v['level'];
                $parent_id = $data[$k-1]['id'];
            }else if($v['level']<$level){
                $parent_id = $this->findParent($v['level']-1);
                $data[$k]['parent_id'] = $parent_id;
                $level = $v['level'];
            }else {
                $this->_stack->push(["level"=>$v['level'],"id"=>$v['id']]);
                $data[$k]['parent_id'] = $parent_id;
            }
        }
    }

總結, 在處理複雜問題的時候,我們發現分步解決是一個很好的手段,也是常見的工程思想。比如火箭製造,程式編譯,TCP/IP協議 莫不如是。甚至CPU晶片這麼高階的東西,都借鑑了流水線生產的思想,設計了多級指令快取,提高程式執行效率。

完整程式原始碼:

<?php
/**
 * Author: code lighter
 * Date: 2017/5/28
 * Time: 21:14
 * Function: translate the plain menu data to a nested tree array in PHP
 * The input plain menu data format as follows:
 *    top_menu
 *    +sub_menu
 *    ++sub_menu
 *    top_menu
 * The program will translate above data to php nested array
 * $output = [
 *  0=>["text"=>"top_menu",
 *      "id"=>1,
 *      "parent_id=>0,
 *      "children"=>[
 *          "text"=>"sub_menu",
 *          "id"=>2,
 *          "parent_id"=>1,
 *          "children"=>[
 *               "text"=>"sub_menu",
 *               "id"=>3,
 *               "parent_id"=>2
 *           ]
 *       ]
 * ],
 * 1=>["text="top_menu",
 *     "id"=>4,
 *     "parent_id"=>0
 *    ]
 * ];
 */

namespace company\code_lighter;
/*
 * The stack class to stock the depth of the menu
 */
class Stack
{
    public $_stack_size;
    public $_ptr;
    public $_container;
    public function __construct()
    {
        $this->_container = [];
        $this->_stack_size = 20;
        $this->_ptr = -1;
    }
    public function isEmpty()
    {
        return $this->_ptr==-1;
    }
    public function isFull()
    {
        return $this->_ptr == $this->_stack_size-1;
    }
    public function push($data)
    {
        if($this->isFull())
            return false;
        $this->_container[] = $data;
        $this->_ptr++;
    }
    public function pop(){
        if($this->isEmpty())
            return false;
        $this->_ptr--;
        return  array_pop($this->_container);
    }
}
class MenuTree
{
    private $_filePath; //file path
    private $_loaded; //mark file loaded or not
    private $_errors; 
    private $_data; // the input menu data
    private $_parsed_data; // each line include a level property to mark the menu depth.
    private $_cur_pos; // the current parsed char
    private $_current_line_words; // the length of each line
    private $_stack; // stack for parse hierarchy retionship between parent menus and its sub menus 
    public function __construct($filePath)
    {
        $this->_filePath = $filePath;
        $this->_loaded= false;
        $this->_errors = [];
        $this->_data = [];
        $this->_parsed_data = [];
        $this->_cur_pos = 0;
        $this->_current_line_words = 0;
        $this->_stack= new Stack;
    }
    public function log($error_code,$error_msg){
        $_errors[] = ['error_code'=>$error_code,'error_msg'=>$error_msg];
    }
    public function hasError(){
        return count($this->_errors);
    }
    public function readFile()
    {
        if($this->_loaded) return;
        $file = fopen(dirname(__FILE__).'/'.$this->_filePath,'r');
        if(!$file){
            $this->log(601,"can't open file ".$this->_filePath);
        }
        while(!feof($file)){
            $this->_data[] = fgets($file);
        }
        fclose($file);
        $this->_loaded = true;
    }
    public function getData(){
        if(!$this->hasError()){
            if(!$this->_loaded){
                $this->readFile();
            }
            return $this->_data;
        }
        return false;
    }
    public function getNextChar(&$line)
    {
        if($this->_cur_pos<$this->_current_line_words){
            $char = mb_substr($line,$this->_cur_pos,1,'utf-8');
            $this->_cur_pos ++;
            return $char;
        }
        $this->_cur_pos++;
        return false;
    }
    // parse the + sign before each line
    public function parse(){
        $this->readFile();
        if(!$this->hasError()){
            $data = [];
            foreach($this->_data as $k=>$line){
                $this->_current_line_words = mb_strlen($line,'utf-8');
                $level =0;
                while($this->getNextChar($line)=='+'){
                    $level++;
                }
                do{
                    $char = $this->getNextChar($line);
                }while($char != "\r" && $char != false);

                $_line = [
                    'id'=>$k+1,
                    'text'=>mb_substr($line,$level,$this->_cur_pos-1 -$level,'utf-8'),
                    'level'=>$level
                 ];
                $data[] = $_line;
                $this->_cur_pos = 0;
            }
            return $data;
        }
        return [];
    }
    // mark the hierarchy relationship of each line
    public function markTree(&$data){
        $level = $parent_id = 0;
        foreach($data as $k=>$v){
            if($v['level']>$level){
                $this->_stack->push(["level"=>$v['level'],"id"=>$v['id']]);
                $data[$k]['parent_id'] =$data[$k-1]['id'];
                $level = $v['level'];
                $parent_id = $data[$k-1]['id'];
            }else if($v['level']<$level){
                $parent_id = $this->findParent($v['level']-1);
                $data[$k]['parent_id'] = $parent_id;
                $level = $v['level'];
            }else {
                $this->_stack->push(["level"=>$v['level'],"id"=>$v['id']]);
                $data[$k]['parent_id'] = $parent_id;
            }
        }
    }
    // look for the parent id of the current menu in the stack
    public function findParent($level){
        while(!$this->_stack->isEmpty()){
            $data = $this->_stack->pop();
            if($data['level'] == $level){
                return $data['id'];
            }
        }
        return 0;
    }
    // convert two dimension array into nested array
    public function buildTree(&$array,$callback=null,$parent_id=0,$child_node="children"){
        $tree = [];
        foreach($array as $k=>$v){
            if($v['parent_id'] == $parent_id){
                unset($array[$k]);
                $tmp =is_callable($callback)?call_user_func($callback,$v):$v;
                $children = $this->buildTree($array,$callback,$v['id'],$child_node);
                if($children){
                    $tmp[$child_node] = $children;
                }
                $tree[] = $tmp;
            }
        }
        return $tree;
    }
    // output the nested array to json object
    public function toJson()
    {
        $data = $this->parse();
        $this->markTree($data);
        $tree = $this->buildTree($data);
        return $tree;
    }
}

測試例程

namespace company\controllers;
use Yii;
use yii\web\Controller;
use yii\web\Response;
use company\code_lighter\MenuTree;

class TestController extends Controller
{
    public $enableCsrfValidation = false;
    public function actionMenuTree()
    {
        Yii::$app->response->format = Response::FORMAT_JSON;
        // following lines are the test code
        $menuTree = new MenuTree("menu.txt");
        return $menuTree->toJson();
    }
}