使用 React Hooks 定製一個多級下拉的 TextArea 組件(巨詳細)

前言

最近在業務中遇到了一個關於 多級下拉 需求,須要將後端樹狀數據顯示在 textarea 上,同時 textArea 中也能對數據進行處理,轉化爲能進行多級選擇樹狀數據。javascript

拿問卷星的多級下拉舉個例子,以下圖所示,用戶能夠在 textArea 框進行多級下拉的數據的編寫,第一行表明標題,餘下的每一行表明一個多級下拉框中各級的數據,各級數據之間使用 / 來進行分隔。css

數據編輯完成保存以後,咱們將樹狀數據用在移動端或者小程序端,這樣就完成了一個多級下拉的組件。html

今天這篇文章就簡單介紹一下這個工做流程,主要包括:前端

  • 怎樣將 樹狀數據 轉化爲 textarea 上展現的 value 值 ?
  • 怎樣將 textarea 中的數據 轉化爲 樹狀數據
  • 怎樣判斷 哪些數據已存在的,哪些數據是新增的,哪些數據是刪除的 ?
  • 怎麼測試一個將要發佈到 npm 的組件?

關於多級下拉的 數據展現 在這篇文章中不會作介紹,那麼接下來咱們就開始發車。java

 

項目說明

項目預覽圖

 

技術棧

這個組件是使用 React Hooks + TypeScript 來實現,衆所周知,HooksReact 將來的趨勢,同時 TypeScript 也是 JavaScript 將來的趨勢,小弟恰好拿這個組件練練手。node

打包工具用的是 Rollup,由於打包組件庫的 RollupWebpack 更受歡迎,Webpack 更適合打包複雜的大型項目。關於 Webpack 的學習,你們能夠參考筆者整理的 Webpack 學習文檔react

 

項目結構

項目結構以下所示:webpack

.
├── node_modules // 第三方的依賴
├── example    // 開發時預覽代碼
    ├── public // 放置靜態資源文件夾
    ├── src    // 示例代碼目錄
      ├── app.js     // 測試項目 入口 js 文件
      └── index.html // 測試項目 入口 html 文件
    ├── yarn.lock    // 測試項目 yarn lock 文件
    └── package.json // 測試項目 依賴
├── src        // 組件源代碼目錄
    ├── components     // 輪子的目錄
      ├──textarea // 項目內部使用的一個 textarea 組件
      ├── index.less     // 組件核心代碼樣式文件
      └── textarea.tsx // 組件核心代碼
    ├── types  // typescripe 的接口定義
    ├── utils  // 工具函數目錄
    ├── assets  // 靜態資源目錄
    ├── index.tsx  // 項目入口文件
    └── index.less   // 項目入口樣式文件
├── lib  // 組件打包結果目錄
├── test // 測試文件夾
├── typings  // 放置項目全局 ts 申明文件目錄
├── .babelrc // babel 配置文件
├── .eslintignore // eslintignore 配置文件
├── .eslintrc.js // eslint 配置文件
├── .gitignore // git上傳時忽略的文件
├── api-extractor.json // 用於將多個 ts 聲明文件合成一個
├── jest.config.js // 測試配置文件
├── .npmignore // npm 上傳忽略文件
├── README.md
├── tsconfig.eslint.json // ts 的 eslint 文件
├── tsconfig.json // ts 的配置文件
├── rollup.config.js // rollup 打包配置文件
├── yarn.lock    // yarn lock 文件
└── package.json // 當前整一個項目的依賴
對於項目中除了源碼之外的一些知識點,筆者就不細說了,好比 Rollup 如何配置; api-extractor 如何將多個聲明文件生成一個等等,你們能夠自行查閱一波。

 

倉庫地址

倉庫地址在此:多級下拉 textarea 組件git

 

Hooks 骨架代碼

咱們使用的 React Hooks 來編寫這個組件,通常編寫組件以前咱們須要明確這個組件該支持哪些功能,即支持哪些 props,在這個組件中暫時支持下面這些參數:github

<TreeTextArea
  treeTitle={title} // 多級下拉 標題數據
  treeData={tree_value} // 樹狀數據
  row={21} // textarea 的行數
  showNumber // 是否展現左側 textarea 數字
  shouleGetTreeData // 是否開啓 處理樹數據的功能
  delimiter='/'     // 以什麼符號切割
  maxLevel={4}      // 支持的最大級數
  onChangeTreeData={  // 與 shouleGetTreeData 進行搭配使用,返回處理後的標題和樹狀數據
    (treeTitle, treeData) => {
      console.log('---treeTitle---', treeTitle);
      console.log('---treeData---', treeData);
    }
  }
  defaultData={DEFAULT_TEXT} // 樹狀數據默認值
  placeholder='請輸入標題,例:省份/城市/區縣/學校&#10;浙江省/寧波市/江北區/學校1'
/>

咱們在 src/components/textarea.tsx 中進行相應代碼的編寫,其中包括接收相應的傳入相應的 props 值、剛進入頁面的時候去 初始化數據監聽數據變化獲取樹狀值 等操做:

const TreeTextArea = (props: Props): JSX.Element => {
  // 一系列 props 數據的接受
  // ...
  const [__textAreaData, setTextAreaData] = useState('');
  const [__flattenData, setFlattenData] = useState([]);
  
  // 數據初始化
  useEffect(()=>{
    if (isArray(__treeData) && isArray(__treeTitle)) {
      // ...

      const flattenData = flattenChainedData(__treeData);
      const textAreaData = getTextAreaData(flattenData, titles);

      setFlattenData(flattenData);
      setTextAreaData(textAreaData.join('\n'));
    }

    return ()=>{
      // willUnMount
    }
  }, [])
  
  // 監聽數據變化
  const onChange = (data: any): void => {}
  
  // 設置默認值
  const getDefaultData = (): void => {}
  
  // 獲取樹狀值
  const getTreeData = (e: any): void => {
    const { onChangeTreeData } = props;
    // ...
    if (onChangeTreeData) {
      onChangeTreeData(levelTitles, valueData);
    }
  }

  return (
    <div className={styles.wrapper}>
      <NumberTextArea
        row={__row}
        value={__textAreaData}
        onChange={onChange}
        showNumber={__showNumber}
        placeholder={__placeholder}
        errCode={__errCode}
        errText={__errText}
      />
      {
        // ...
        // 填充默認值、獲取樹狀值 代碼
      }
    </div>
  )
}

咱們內部還封裝了一個 NumberTextArea,在這個組件中我增長了 左側序號顯示錯誤顯示 等邏輯,具體的代碼就不貼上來了,你們有興趣能夠參考源碼。

關於相關的 React Hooks 知識你們能夠自行查閱相關資料學習,筆者在這裏也不作介紹了。

接下來咱們來看一下組件中最核心的 多級下拉邏輯處理

 

多級下拉邏輯核心代碼

在總體骨架代碼搭建好以後,咱們就只需關注 textarea 處理數據的邏輯就好了。

首先咱們在 utils 目錄下新建 testData.ts,模擬後端的 json 數據,以下圖所示:

 

數據渲染

咱們先從編輯開始提及,假如後端給了咱們要渲染的標題以及多級下拉的樹狀數據:

接着咱們但願經過一些處理將後端給的數據修改爲 textarea 中能夠展現的 value 值,相似於下面的字符串做爲 value 值:

這裏咱們須要作的是將 樹狀數據 進行 扁平化處理,給每一級的數據增長一個 title 屬性,這即是咱們須要在 textarea 每一行中所要展現的數據,相似以下的數據:

咱們須要將每一級的數據的都扁平化出來。

但這裏有一個問題好比 浙江省/寧波市浙江省/寧波市/海曙區 這是兩個不一樣的數據,可是在 textarea 中其實不須要展現 浙江省/寧波市 這一個數據的,因此我在這裏作了一個判斷,若是這一個數據有孩子的話,就給他增長一個屬性 hasChildren,讓咱們在獲取 textarea 的數據的時候作一下過濾就好了,不展現有屬性 hasChildren 的數據。

那麼咱們如何來扁平化數據呢?其實只要對樹狀數據作一下遞歸處理就好了。

/**
 * 將後端的 樹狀結構 數據 扁平化
 * @param {Array} data : 後端 tree_node 數據
 */
export const flattenChainedData = (data: any) => {
  let arr = [];

  forEach(data, (item) => {
    const childrens = item.children;
    const rootObj = createNewObj(item, item.value);
    if (childrens) {
      rootObj.hasChildren = true;
    }

    arr.push(rootObj);

    if (childrens) {
      // 遞歸得到全部扁平的數據
      const dataNew = getChildFlattenData(childrens, item.value, 1);
      arr = concat(arr, dataNew);
    }
  });

  return arr;
};

/**
 * 遞歸得到 扁平數組
 * @param {*} data : 要處理的數組
 * @param {*} title : 前幾級 拼的 title
 * @param {*} level : 當前級數
 */
const getChildFlattenData = (data, title, level) => {
  // 超過最大級數
  if (level > MAX_LEVEL) return false;
  if (!data) return false;

  let arr = [];

  forEach(data, (item) => {
    const { children } = item;
    const rootObj = createNewObj(item, `${title}/${item.value}`);

    if (children) {
      rootObj.hasChildren = true;
      const childrenData = getChildFlattenData(children, `${title}/${item.value}`, level + 1);
      arr.push(rootObj);
      arr = concat(arr, childrenData);
    } else {
      arr.push(rootObj);
    }
  });

  return arr;
};

其中上面的 createNewObj 是爲扁平數據新增 value/title 屬性,返回新的對象,具體就不上代碼了。

轉化爲扁平數據以後,咱們就能夠將數據中的 title 屬性拿出來,組成 textarea 所需的數據便可:

/**
 * 將 扁平數據 轉化爲 textarea 中 value
 * @param {Array} flattenData : 扁平化數據
 * @param {String} titles : textarea 第一行的 title
 */
export const getTextAreaData = (flattenData, titles) => {
  const newData = filter(flattenData, (item) => {
    return !item.hasChildren && item.status !== 2;
  });

  const arr = [];

  arr.push(titles);

  forEach(newData, (item) => {
    arr.push(item.title);
  });

  return arr;
};

其中咱們過濾了 hasChildrentrue,同時這裏以 status = 2 表示刪除的數據,也進行過濾, 這樣咱們即可以獲得以下圖所示的一個 textarea 數組:

接着咱們將這些數組經過 \n 換行符 join 起來就是咱們所需的 textareavalue 值了。

 

樹狀數據處理

這裏是整個多級下拉邏輯中最核心的部分,咱們須要將用戶修改過的數據,與原來的數據進行關聯,生成新的樹狀數據。咱們分爲四個步驟來說解這一個步驟。

咱們會建立一個 數據處理類 treeTextAreaDataHandle,並在 constructor 構造函數中傳入 扁平化數據textarea 文本框的值 來初始化一個實例對象。以後咱們會在此類中完善咱們處理數據的一些屬性和方法。

class treeTextAreaDataHandle {
  // 扁平化數組
  private flattenData: FlattenDataObj[];

  // textarea 框 文本值
  private textAreaTexts: string;

  constructor(options: treeTextAreaData) {
    const { flattenData, textAreaTexts } = options;

    this.flattenData = flattenData;
    this.textAreaTexts = textAreaTexts;
  }
}

生成修改的初始映射

第一步咱們會生成用戶修改後的 textarea 初始映射數據,咱們會根據級數分別放在不一樣的數組中,舉個🌰:

咱們會根據用戶最後輸入完成的 textarea 值,來生成一組根據級數排布的對象數據,以下圖:

如上面這張圖會轉化爲以下面圖中的數據,這個數據會是咱們進行接下去三步操做的關鍵:

這裏須要注意的一個問題,有可能在某一級是有同名的值存在,這個時候咱們不能單純的就把這兩個值認爲是同一個值,而要去比較他們的爸爸是不是同樣的,以此類推,遞歸比較直到第一級,若是都是同樣的話,那麼他們纔是同一個值,不然就是不一樣的值。

舉個簡單例子,好比有兩個數據: 浙江省/寧波市/海曙區江蘇省/無錫市/海曙區 這兩個值,雖然第三級中的 海曙區 名字是相同的,可是他們是兩個不一樣的值,他們應該被分配到兩個不一樣的 id,而且各自的 parent_id 也不同。

接下來上代碼,咱們新建一個實例方法 transDataFromText,在這個方法中進行樹狀數據的轉化,並獲得最後的數據:

/**
 * 將 textarea 數據 轉化爲 後端所需的樹狀結構數據
 * @param {Array} flattenData : 扁平數據
 * @param {String} texts : textarea 的文本
 */
public transDataFromText() {
  const texts = this.textAreaTexts;

  const arr = texts.split('\n');

  // 去除標題
  if (arr.length > 1) {
    arr.shift();
  }

  // 賦值每一行文字爲數組
  this.textAreaArr = arr;

  // 解析 TextArea 數據 爲 指定 層級映射數據
  this.parserRootData();

  // ...
}

咱們在 parserRootData 這個方法中去生成修改後的初始映射

/**
 * 將 textarea 數據 轉化爲 相應級數 的 數據
 * @param {Array} textArr : textarea 的文本 轉化的數組
 * @param {Number} handleLevel : 要處理的級數
 */
private parserRootData() {
  // 每一行的 textArea 值
  const textArr  = this.textAreaArr;
  // 最大級數
  const handleLevel = this.MAX_LEVEL;
  // 以什麼分隔符切割
  const delimiter = this.delimiter;

  // 去重 每一級 textArea 值
  const uniqueTextArr = uniq(textArr);

  // 映射數據存放對象
  const namesArrObj: namesArrObj = {};

  // 根據最大級數爲每一級建立一個數組
  for (let i = 1; i <= handleLevel; i++) {
    namesArrObj[`${ROOT_ARR_PREFIX}_${i}`] = [];
  }

  // 遍歷 每一行的 textArea 值
  forEach(uniqueTextArr, (item: string) => {
    // 切割 每一行 字符串,生成字符串
    const itemArr = item.split(delimiter);
    
    // 根據最大級數往 namesArrObj 塞數據
    for (let i = 1; i <= handleLevel; i++) {
      if (
        !treeTextAreaDataHandle.sameParentNew(namesArrObj, itemArr, i)
        && itemArr[i - 1]
      ) {
        // 建立一個對應級數的對象,塞入對應的數組
        const obj: parserItemObj = {};
        obj.id = _id();
        obj.value = itemArr[i - 1];
        obj.level = i;

        // 獲取當前的級數的值,爸爸的 id
        const parentId = treeTextAreaDataHandle.getParentIdNew(
          namesArrObj, itemArr, i
        );
        
        obj.parent_id = parentId;
        
        namesArrObj[`${ROOT_ARR_PREFIX}_${i}`].push(obj);
      }
    }
  });

  // 保存到對象的 rootArrObj 屬性值中
  this.rootArrObj = namesArrObj;
}

上面最爲關鍵的一個方法就是靜態方法 sameParentNew,做用是幫咱們遞歸判斷 兩個相同名稱的值是否真的相同。其實原理也很簡單,也是 遞歸 判斷他們各自的爸爸是否相同。具體代碼你們能夠參考源碼。

其次這裏還有用到相似:

  • 建立 id的方法:_id()
  • parent_id 的靜態方法:getParentIdNew

到這裏咱們第一步生成 初始映射 數據就完成了,接下來咱們就須要結合後端提供給咱們的扁平數據 flattenData 來填充已存在的數據,同時篩選出新增的數據。

 

填充存在數據並篩選新增數據

這一步咱們須要將後端給咱們的數據 flattenData 與咱們的初始映射數據進行比對。填充存在數據的屬性同時篩選出新增的數據,並給新增數據加上屬性 new = true,最後塞到對應的對象對應級數組中去 existNamesArrObjaddNamesArrObj

舉個🌰

咱們新增了 浙江省/寧波市/高新區,咱們能夠在新增數據中的第三級中找到 高新區,由於 浙江省寧波市 已經存在,他們不會被添加到新增的數組中去,只會在已存在的對象中被找到,而且會用後端給的 id 替換掉咱們以前生成映射數據是生成的 id,以下圖:

存在數據:existNamesArrObj

新增數據:addNamesArrObj

這裏咱們還要注意的一個點是,咱們在進行數據篩選以前,須要將後端給的數據 flattenData 數據中加上一個屬性 root_id,它的做用是幫咱們將 修改後數據 和以前 後端給的數據 進行關聯,好比上面咱們新增 高新區 這個例子,他的爸爸是已經存在的,他的 id 是已經存在的 36178,可是新增的高新區的 parent_id 是咱們在映射數據時生成的,這兩個確定不相等,咱們須要藉助 root_id 來將這兩個數據聯繫起來。

接下來上代碼,咱們將這一波處理放到 handleExistData 方法中,

/**
 * 填充已有的數據,並篩選出新增的數據
 * @param {*} TextAreaData : parserRootData() 處理的數據
 * @param {*} newFlattenData : 扁平化數據
 * @param {Number} handleLevel : 要處理的級數
 */
private handleExistData() {
  const namesArrObj = this.rootArrObj;
  const newFlattenData = this.flattenData;
  const handleLevel = this.MAX_LEVEL;

  // 存在的數據
  const existNamesArrObj = {};
  // 新增的數據
  const addNamesArrObj = {};

  for (let i = 1; i <= handleLevel; i++) {
    addNamesArrObj[`${ADD_ARR_PREFIX}_${i}`] = [];
    existNamesArrObj[`${EXIST_ARR_PREFIX}_${i}`] = [];
  }

  // flatten 加上 parser 的 映射 id
  this.setMapIdForFlattenDataToRootData();

  for (let i = 1; i <= handleLevel; i++) {
    // 獲取出事映射相應級數的數據
    const curNamesArr = namesArrObj[`${ROOT_ARR_PREFIX}_${i}`];

    forEach(curNamesArr, (item) => {
      // 設立一個標誌位
      // 標誌這一級的數據是否存在
      let flag = false;
            
      // 映射數據的屬性 
      const { value, parent_id, id } = item;

      // 新增數據 obj
      const addNewObj: addNewObj = {
        level: i,
        value,
        id,
        new: true,
        root_id: id,
      };

         // 遍歷比較後端數據 與 映射數據 的 `value` 和 `level`
      // 來肯定他們映射數據是否存在
      // 存在就
      forEach(newFlattenData, (val) => {
        if (value === val.value) {
          if (val.level === i) {
            // level 等於 1
            if (val.level === 1 && val.parent_id === 0) {
              const obj = { ...val };
              existNamesArrObj[`${EXIST_ARR_PREFIX}_${i}`].push(obj);
              flag = true;
            }
            // level 大於 1
            if (val.level !== 1 || val.parent_id !== 0) {
              if (this.isExistitem(val, parent_id, i)) {
                const obj = { ...val };
                existNamesArrObj[`${EXIST_ARR_PREFIX}_${i}`].push(obj);
                flag = true;
              }
            }
          }
        }
      });

      // 若是是新增數據
      if (!flag) {
        // 塞入 addNamesArrObj
        addNamesArrObj[`${ADD_ARR_PREFIX}_${i}`].push(addNewObj);
        // 塞入 最新 扁平化數據
        newFlattenData.push(addNewObj);
      }
    });
  }

  // 將 existNamesArrObj 掛到類屬性 existNamesArrObj 上
  this.existNamesArrObj = existNamesArrObj;
  this.addNamesArrObj = addNamesArrObj;
}

上面的方法中還用到了一個比較重要的方法 isExistitem 方法,判斷當前級數據是否存在,其原理跟parserRootData 中用到的 sameParentNew 相似,也是去遞歸比較在他們的爸爸是不是相同的,直到找到不一樣的爸爸或者到第一級爲止,只不過這裏面比較的是 初始映射數據後端扁平數據

還有一個方法就是咱們上面講到的給 後端扁平數據 添加 root_id 的方法 setMapIdForFlattenDataToRootData,具體代碼筆者不貼了,你們有興趣能夠自行查看。

處理完已存在數據,和新增數據,咱們還須要處理刪除的數據。

這裏若是需求中要出對數據進行排序的話,其實能夠吧 存在數據新增數據 放在一個對象中,這樣每次有新增或者存在數據的時候都會從上都下依次塞入,如今筆者是吧 存在數據新增數據 分開來了, 新增數據 默認都是在最後的。

 

處理刪除數據

通常來講,若是數據刪除了,前端還須要將數據傳給後端,告訴後端這條數據刪除了。因此咱們須要給刪除的數據中加上相應的 狀態值,這裏咱們加了 status = 2,表明此條數據在前端已經被刪除了。

實現起來很簡單,由於咱們經過第二步已經獲得了 已經存在的數據,只須要拿它與最初的 後端提供的扁平數據 進行比較一波就能得出哪些數據被刪除了,篩選出來以後將他們將上相應的屬性便可。

好比咱們刪除了 江蘇省/無錫市/惠山區 這一行,實際上是刪除了 無錫市惠山區 兩個數據,咱們能夠獲得以下結果:

接下來上代碼,咱們將篩選刪除數據的方法寫在 handleTagForDeleleByLevel 方法中,

/**
 * 根據標題 幾級 來獲取刪除的數據,並給刪除數據打上標籤,並返回刪除數據
 * @param {*} handleDataArr : fillExistData() 處理的數據
 * @param {*} newFlattenData : 扁平化數據
 * @param {Number} handleLevel : 要處理的級數
 */
private handleTagForDeleleByLevel = () => {
  const existNamesArrObj = this.existNamesArrObj;
  const handleLevel = this.MAX_LEVEL;

  // 存放 存在扁平數據 的數組
  let existData = [];

  // 遍歷 存在數據對象 扁平化存在數據
  for (let i = 1; i <= handleLevel; i++) {
    const curArray = existNamesArrObj[`${EXIST_ARR_PREFIX}_${i}`];
    existData = concat(existData, curArray);
  }

  // 給刪除數據添加屬性 status = 2
  const deleteData = this.addTagForDeleleData(existData);

  // 將 deleteData 掛到屬性 deleteData 上
  this.deleteData = deleteData;
};

咱們經過 addTagForDeleleData 方法來比較不一樣的值,並加上屬性 status=2,在這裏咱們也可使用lodashdifference 方法來獲得兩個數組不一樣的值。

處理完上面三步以後,基本上就大公告成了,接下來就生成最終樹狀數據。

 

生成樹狀數據

最後咱們就須要將 存在數據新增數據刪除數據 生成一個新的扁平化數組,由這個新扁平化數據生成咱們想要的樹狀數據。

好比咱們新增 浙江省/寧波市/高新區,刪除 江蘇省/無錫市/惠山區,最終會獲得新的扁平數據以下,咱們能夠看到 高新區 是新增的,惠山區 也加上了相應的 status=2 的屬性:

接着咱們就能夠根據這個扁平化數據,遞歸生成樹狀數據,以下圖:

接下來上代碼,首先 getLastFlattenData 方法,經過這個方法咱們能夠獲取到最新的扁平化數據:

/**
 * 生成最新的數據
 * @param {*} existNamesArrObj : existNamesArrObj 已存在數據
 * @param {*} addNamesArrObj : addNamesArrObj 新增數據
 * @param {*} deleteData : addTagForDeleleByLevel() 獲得的刪除數據
 * @param {Number} handleLevel : 要處理的級數
 */
private getLastFlattenData() {
  const existNamesArrObj = this.existNamesArrObj;
  const newAddNamesArrObj = this.newAddNamesArrObj;
  const deleteData = this.deleteData;
  const handleLevel = this.MAX_LEVEL;

  let lastData = [];

  let AddLast = [];
  let ExistLast = [];

  // 遍歷 扁平化 存在和新增數據
  for (let i = 1; i <= handleLevel; i++) {
    const curArrayExist = existNamesArrObj[`${EXIST_ARR_PREFIX}_${i}`];
    const curArrayAdd = newAddNamesArrObj[`${HANDLE_ADD_ARR_PREFIX}_${i}`];

    ExistLast = concat(ExistLast, curArrayExist);
    AddLast = concat(AddLast, curArrayAdd);
  }

  // 合併三種類型的數據
  lastData = concat(lastData, ExistLast, AddLast, deleteData);

  // 將 lastData 掛到 類屬性 newDataLists 上
  this.newDataLists = lastData;
};

最後就是生成最終樹狀數據,原理就是從 parent_id0 開始進行 遞歸遍歷,直到遍歷完全部節點爲止,與此同時咱們須要在生成樹以前,刪除一些本來不須要的屬性,好比新增屬性 new,映射關聯的 root_id 等。

/**
 * 刪除 以前 組裝 樹狀結構時 使用的 一些自定義屬性
 * 後端不須要
 * @param {Object} item : 每一項的 item
 */
static clearParamsInTreeData = (item) => {
  delete item.title;
  delete item.hasChildren;
  delete item.root_id;

  if (item.new) {
    delete item.new;
    delete item.id;
    delete item.parent_id;
  }
};

/**
 * 遞歸 將扁平數據轉化爲 樹狀 結構數據
 * 用於 transDataFromText
 * @param {Array} lists : 扁平數據
 * @param {Number} parent_id : 爸爸的 id
 */
private getTreeDataBylists = (parent_id: number | string): any => {

  const lists = this.newDataLists;

  //遞歸,菜單
  const tree = [];

  forEach(lists, (item) => {
    const newItemId = item.parent_id;

    if (parent_id === newItemId) {
      const childrenTree = this.getTreeDataBylists(item.id);
      if (isArray(childrenTree) && childrenTree.length > 0) {
        item.children = childrenTree;
      } else {
        item.children = null;
      }

      // 刪除沒必要要屬性
      treeTextAreaDataHandle.clearParamsInTreeData(item);
      
      tree.push(item);
    }
  });

  return tree;
};

到這裏咱們變完成了對 textarea 的處理,最終的 transDataFromText 方法以下:

/**
 * 將 textarea 數據 轉化爲 後端所需的樹狀結構數據
 * @param {Array} flattenData : 扁平數據
 * @param {String} texts : textarea 的文本
 */
public transDataFromText() {
  const texts = this.textAreaTexts;

  const arr = texts.split('\n');

  if (arr.length > 1) {
    arr.shift();
  }

  this.textAreaArr = arr;

  // 解析 TextArea 數據 爲 指定 層級映射數據
  this.parserRootData();

  // 填充已有數據 並 篩選新增數據
  this.handleExistData();

  // 處理新增數據
  this.handleParamsInAddData();

  // 獲取刪除數據
  this.handleTagForDeleleByLevel();

  // 獲取最新扁平數據
  this.getLastFlattenData();

  // 獲取最新樹狀數據
  this.lastTreeData = this.getTreeDataBylists(0);

  return this.lastTreeData;
}

 

錯誤處理

咱們須要對一些錯誤進行處理,好比 用戶可能不會輸入標題、又或者 用戶輸入的標題大於了最大支持級數(固然在咱們項目中,這個最大支持級數用戶能夠本身來控制)、又或者 標題的級數與下面內容的級數不對應,這些都應該被歸爲錯誤列表中。

舉個例子,當用戶沒有輸入標題的時候,咱們應該提示其輸入標題,以下圖:

咱們新建一個方法 isEquelLevel 方法,來檢測用戶輸入的值是否符合規範,代碼其實也很簡單,咱們能夠取到最終的數據,遍歷數據中是否存在錯誤,存在錯誤就拋出相應的 錯誤碼 errorCode錯誤信息 ERROR_INFO,錯誤類型以下:

/**
 * 校驗信息
 */
export const ERROR_INFO = {
  1: '第一行標題不可爲空',
  2: `第一行標題不可超過 ${MAX_LEVEL} 列`,
  3: '標題和選擇項的層級數請保持一致',
  4: `選擇項不可超過 ${MAX_LEVEL} 行`,
  5: '請至少填寫一行選擇項',
};

 

測試

功能寫完以後,咱們須要測試一下組件的功能,能夠藉助使用 create-react-appreact-scripts 幫咱們快速啓動一個應用:

package.json 配置:

如下是測試項目 package.json 文件:

{
  "name": "example",
  "version": "0.0.0",
  "description": "",
  "license": "MIT",
  "private": true,
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject",
  },
  "author": "Darrell",
  "dependencies": {
    "lodash": "^4.17.15",
    "react": "link:../node_modules/react",
    "react-dom": "link:../node_modules/react-dom",
    "react-scripts": "^3.4.1"
  },
  "browserslist": [
    ">0.2%",
    "not dead",
    "not ie <= 11",
    "not op_mini all"
  ]
}

這裏面須要注意的一個問題就是,這裏面的 reactreact-dom 兩個依賴須要使用上一級根目錄 node_modules 下的依賴。

由於使用 Hooks 寫的插件會由於有多個 React 應用而報錯,以下圖:

致使這個問題的緣由主要是第一個 React 版本沒到 16.8,或者第三個,在項目中有多個 React 引用。

至於第二個問題,Hooks 不符合規範基本上在咱們安裝了 eslint-plugin-react-hooks 插件以後就基本上能夠規避掉了。關於這個問題的更多信息你們能夠參考 這條 issure

而後咱們進入 exmaple 安裝相應的依賴,直接運行 yarn start 就能夠將咱們的項目跑起來了。

 

主要代碼

咱們在 examplepublic 目錄下新建

  • index.html:項目的模版文件,即負責項目顯示的 html 文件
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta name="theme-color" content="#000000">

    <link rel="manifest" href="%PUBLIC_URL%/manifest.json">

    <title>測試頁面</title>
  </head>

  <body>
    <noscript>
      You need to enable JavaScript to run this app.
    </noscript>

    <div id="root"></div>
  </body>
</html>
  • manifest.json:若是寫手機端的 h5app,圖標、主題顏色等是在這個文件裏是設置的,在這裏咱們能夠隨意配置一波。

同時在 src 目錄下新建:

  • index.js:項目入口文件
  • index.css:入口文件的樣式
import React from 'react'
import ReactDOM from 'react-dom'

import './index.css'
import App from './App'

ReactDOM.render(<App />, document.getElementById('root'))
  • App.js:測試組件的文件
import React, { Component } from 'react'

// 測試數據
import { title, tree_value, DEFAULT_TEXT } from './testData'

import TreeTextArea from 'darrell-tree-textarea'

export default class App extends Component {
  render () {

    return (
      <div className='App'>
        <TreeTextArea
          treeTitle={title}
          treeData={tree_value}
          row={21}
          showNumber
          shouleGetTreeData
          delimiter='/'
          maxLevel={4}
          onChangeTreeData={
            (treeTitle, treeData) => {
              console.log('---treeTitle---', treeTitle);
              console.log('---treeData---', treeData);
            }
          }
          defaultData={DEFAULT_TEXT}
          placeholder='請輸入標題,例:省份/城市/區縣/學校&#10;浙江省/寧波市/江北區/學校1'
        />
      </div>
    )
  }
};

 

npm 以前的測試

上面的測試文件是寫在咱們的組件項目中的。

可是通常在發包以前,咱們須要在其餘的項目裏面測試使用一下,這個時候咱們能夠藉助 npm link

  • 首先在組件下根目錄下 執行 npm link,這句命令意思就是將組件引入到全局的 node_modules

  • 在你要使用組件的目錄下,經過下面命令引用組件:
npm link <package 名>

看個🌰:假設咱們使用 create-react-app 新建了一個項目 my-app,咱們就能夠在此項目的根目錄下面,運行:

npm link @darrell/darrell-tree-textarea

這個時候咱們能夠在項目中有了咱們項目的依賴:

可是由於是項目的引用,因此這個依賴包含了咱們插件項目中的全部內容,包括 node_modules,這裏會出現咱們上面提到的 Hooks 開發組件 Invalid hook call 這個錯誤,由於在咱們的依賴下有 @darrell/darrell-tree-textarea 下有 node_modules 文件下,在它下面有 React 依賴,同時在 my-app 下面的 node_modules 下也有 React 依賴,因此就會出現 多個 React 引用 這個問題。

這個問題在咱們發到 npm 上以後不會出現,由於在上傳到 npm 上的時候是不會把 node_modules 目錄傳上去的。

解決辦法有兩個:

  • 刪除 @darrell/darrell-tree-textarea 下的 node_modules,可是每次都須要從新安裝
  • 推薦使用,在 my-app 項目下,改一下配置文件,將全部的 react 引用指向同一個引用
alias: {
  // ...
  'react': path.resolve(__dirname, '../node_modules/react'),
  'react-dom': path.resolve(__dirname, '../node_modules/react-dom'),
},

關於如何發包你們能夠參考這篇文章:從零開始實現類 antd 分頁器(三):發佈npm,這篇文章中有詳細的介紹組件的測試和 npm 的發佈,在本篇文章中就不涉及了。

 

小結

本文主要講了如何製做一個能處理 多級下拉樹狀數據textarea 組件的編寫,總體來看仍是比較簡單,整個組件的難點應該是如何有效的 遞歸處理 數據:

  • 好比如何扁平化樹狀數據:遞歸處理他們的 children
  • 如何判斷兩個名字相同的數據是否相同:遞歸判斷他們的爸爸是否相同 等等問題

還有在組件測試那裏也折騰了蠻久的,由於碰到了 React Hooks 組件不能運行的問題,我曾一度覺得是 Hooks 的寫法有問題,後來沒想到是多個 React 引用出現的錯誤。

不過如今回過頭來思考這個問題,發現 React 的錯誤提醒其實作的很清楚,本身只要跟着這個錯誤提示一步一步就能把問題解決掉。

實不相瞞,想要個贊!

 

參考內容

相關文章
相關標籤/搜索