JavaScript中的递归Promise

60
我正在编写一个JavaScript的Promise,用于查找链接的最终重定向URL。
我的做法是使用XMLHttpRequestPromise中进行HEAD请求。然后,在加载时,检查HTTP状态是否在300范围内,或者如果对象附加了responseURL并且该URL与原始URL不同。
如果以上两个条件都不成立,我会使用resolve(url)。否则,我会在响应URL上递归调用getRedirectUrl()resolve()
以下是我的代码:
function getRedirectUrl(url, maxRedirects) {
    maxRedirects = maxRedirects || 0;
    if (maxRedirects > 10) {
        throw new Error("Redirected too many times.");
    }

    var xhr = new XMLHttpRequest();
    var p = new Promise(function (resolve) {
        xhr.onload = function () {
            var redirectsTo;
            if (this.status < 400 && this.status >= 300) {
                redirectsTo = this.getResponseHeader("Location");
            } else if (this.responseURL && this.responseURL != url) {
                redirectsTo = this.responseURL;
            }

            if (redirectsTo) {
                // check that redirect address doesn't redirect again
                // **problem line**
                p.then(function () { self.getRedirectUrl(redirectsTo, maxRedirects + 1); });
                resolve();
            } else {
                resolve(url);
            }
        }

        xhr.open('HEAD', url, true);
        xhr.send();
    });

    return p;
}

然后使用这个函数的方法如下:

getRedirectUrl(myUrl).then(function (url) { ... });

问题在于getRedirectUrl中的resolve();会在调用函数的then()之前调用,而在此时,URL是undefined
我尝试过使用return self.getRedirectUrl(...)代替p.then(...getRedirectUrl...),但这永远不会解决问题。
我猜测我使用的模式(基本上是临时想出来的)不太对。

2
p.then(...) 与一个不产生任何可观察副作用且不返回任何值的函数一起使用是没有意义的。 - zerkms
一般来说,尽量避免在 Promise 构造函数中做太多的工作。这很少是你想要的,而且问题会随着 then() 的常规链和多个函数变得更加明显。 - Evert
7个回答

62

问题在于您从getRedirectUrl()返回的承诺需要包括到达URL的整个逻辑链。 您只是返回了第一个请求的承诺。 您在函数中间使用的.then()没有起到任何作用。

要解决此问题:

创建一个承诺,它解析为重定向的redirectUrl或否则为null

function getRedirectsTo(xhr) {
    if (xhr.status < 400 && xhr.status >= 300) {
        return xhr.getResponseHeader("Location");
    }
    if (xhr.responseURL && xhr.responseURL != url) {
        return xhr.responseURL;
    }

    return null;
}

var p = new Promise(function (resolve) {
    var xhr = new XMLHttpRequest();

    xhr.onload = function () {
        resolve(getRedirectsTo(xhr));
    };

    xhr.open('HEAD', url, true);
    xhr.send();
});

使用.then()那个上返回递归调用,或根据需要不返回:

return p.then(function (redirectsTo) {
    return redirectsTo
        ? getRedirectUrl(redirectsTo, redirectCount+ 1)
        : url;
});

完整解决方案:

function getRedirectsTo(xhr) {
    if (xhr.status < 400 && xhr.status >= 300) {
        return xhr.getResponseHeader("Location");
    }
    if (xhr.responseURL && xhr.responseURL != url) {
        return xhr.responseURL;
    }

    return null;
}

function getRedirectUrl(url, redirectCount) {
    redirectCount = redirectCount || 0;

    if (redirectCount > 10) {
        throw new Error("Redirected too many times.");
    }

    return new Promise(function (resolve) {
        var xhr = new XMLHttpRequest();

        xhr.onload = function () {
            resolve(getRedirectsTo(xhr));
        };

        xhr.open('HEAD', url, true);
        xhr.send();
    })
    .then(function (redirectsTo) {
        return redirectsTo
            ? getRedirectUrl(redirectsTo, redirectCount + 1)
            : url;
    });
}

2
我很困惑!我不明白这里发生了什么。如果我调用getRedirectUrl(),它会返回一个Promise,而该Promise具有resolve()函数来执行一些操作。当这些操作完成后,可能会递归跟随重定向。但是!如果xhr.open(); xhr.send()唯一出现的地方是在Promise resolve()函数中,那么第一个请求怎么会发生呢?我对Promise和JavaScript还很陌生,所以可能漏掉了什么。 - happybeing
@theWebalyst xhr.open()xhr.send() 函数在 getRedirectUrl() 返回之前被调用。传递到 Promise 构造函数中的函数会立即被 Promise 构造函数调用。 - JLRishe
2
@theWebalyst 这里的微妙细节是resolve函数实际上是传递给Promise构造函数的处理程序函数的参数。导致混淆的思考错误在于promise有一个执行某些操作的resolve()函数。实际上,处理程序函数执行某些操作并在完成时调用它所传递的resolve函数。当重定向过多时,您可以添加一个名为reject的第二个参数,并调用它而不是throw。效果相同。 - Stijn de Witt
这很聪明,非常感谢您的建议。 - Jiro Matchonson
1
完全救了我,五年后。谢谢! - James Baker

41

这是简化后的解决方案:

const recursiveCall = (index) => {
    return new Promise((resolve) => {
        console.log(index);
        return resolve(index)
    }).then(idx => {
        if (idx < 3) {
            return recursiveCall(++idx)
        } else {
            return idx
        }
    })
}

recursiveCall(0).then(() => console.log('done'));

3
如果索引足够大,你将会遇到“Maximum call stack size exceeded”错误。 - balazs
2
@balazs 好的,你可以将 return resolve(recursiveCall(++index)) 更改为 return setTimeout(() => resolve(recursiveCall(++index)), 0);,然后就可以了。但这与问题本身无关,可能会在简化的示例中引起混淆。 - cuddlemeister
当然,针对这个问题的使用情况不会达到那个限制,但是对于那些想要更通用解决方案的人(我认为问题的标题听起来很通用),了解限制是很有好处的。 - balazs
@balazs 完全同意 - cuddlemeister
@Kameneth 谢谢,根据您的建议进行了编辑。 - cuddlemeister
显示剩余2条评论

8
以下有两个功能:
  • _getRedirectUrl - 模拟setTimeout对象,以查找重定向URL的单步查找(这等同于您的XMLHttpRequest HEAD请求的单个实例)
  • getRedirectUrl - 递归调用Promises以查找重定向URL
秘密酱是子Promise,其成功完成将触发来自父Promise的resolve()调用。

function _getRedirectUrl( url ) {
    return new Promise( function (resolve) {
        const redirectUrl = {
            "https://mary"   : "https://had",
            "https://had"    : "https://a",
            "https://a"      : "https://little",
            "https://little" : "https://lamb",
        }[ url ];
        setTimeout( resolve, 500, redirectUrl || url );
    } );
}

function getRedirectUrl( url ) {
    return new Promise( function (resolve) {
        console.log("* url: ", url );
        _getRedirectUrl( url ).then( function (redirectUrl) {
            // console.log( "* redirectUrl: ", redirectUrl );
            if ( url === redirectUrl ) {
                resolve( url );
                return;
            }
            getRedirectUrl( redirectUrl ).then( resolve );
        } );
    } );
}

function run() {
    let inputUrl = $( "#inputUrl" ).val();
    console.log( "inputUrl: ", inputUrl );
    $( "#inputUrl" ).prop( "disabled", true );
    $( "#runButton" ).prop( "disabled", true );
    $( "#outputLabel" ).text( "" );
    
    getRedirectUrl( inputUrl )
    .then( function ( data ) {
        console.log( "output: ", data);
        $( "#inputUrl" ).prop( "disabled", false );
        $( "#runButton" ).prop( "disabled", false );
        $( "#outputLabel").text( data );
    } );

}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>

Input:

<select id="inputUrl">
    <option value="https://mary">https://mary</option>
    <option value="https://had">https://had</option>
    <option value="https://a">https://a</option>
    <option value="https://little">https://little</option>
    <option value="https://lamb">https://lamb</option>
</select>

Output:

<label id="outputLabel"></label>

<button id="runButton" onclick="run()">Run</button>

作为递归 Promise 的另一个示例,我用它来解决迷宫问题。 Solve() 函数递归调用以在解决迷宫的过程中前进一步,否则当遇到死路时会回溯。 setTimeout 函数用于将解决方案的动画设置为每帧100ms(即10hz帧速率)。

const MazeWidth = 9
const MazeHeight = 9

let Maze = [
    "# #######",
    "#   #   #",
    "# ### # #",
    "# #   # #",
    "# # # ###",
    "#   # # #",
    "# ### # #",
    "#   #   #",
    "####### #"
].map(line => line.split(''));

const Wall = '#'
const Free = ' '
const SomeDude = '*'

const StartingPoint = [1, 0]
const EndingPoint = [7, 8]

function PrintDaMaze()
{
    //Maze.forEach(line => console.log(line.join('')))
    let txt = Maze.reduce((p, c) => p += c.join('') + '\n', '')
    let html = txt.replace(/[*]/g, c => '<font color=red>*</font>')
    $('#mazeOutput').html(html)
}

function Solve(X, Y) {

    return new Promise( function (resolve) {
    
        if ( X < 0 || X >= MazeWidth || Y < 0 || Y >= MazeHeight ) {
            resolve( false );
            return;
        }
        
        if ( Maze[Y][X] !== Free ) {
            resolve( false );
            return;
        }

        setTimeout( function () {
        
            // Make the move (if it's wrong, we will backtrack later)
            Maze[Y][X] = SomeDude;
            PrintDaMaze()

            // Check if we have reached our goal.
            if (X == EndingPoint[0] && Y == EndingPoint[1]) {
                resolve(true);
                return;
            }

            // Recursively search for our goal.
            Solve(X - 1, Y)
            .then( function (solved) {
                if (solved) return Promise.resolve(solved);
                return Solve(X + 1, Y);
            } )
            .then( function (solved) {
                if (solved) return Promise.resolve(solved);
                return Solve(X, Y - 1);
             } )
             .then( function (solved) {
                if (solved) return Promise.resolve(solved);
                return Solve(X, Y + 1);
             } )
             .then( function (solved) {
                 if (solved) {
                     resolve(true);
                     return;
                 }

                 // Backtrack
                 setTimeout( function () {
                     Maze[Y][X] = Free;
                     PrintDaMaze()
                     resolve(false);
                 }, 100);
                 
             } );

        }, 100 );
    } );
}

Solve(StartingPoint[0], StartingPoint[1])
.then( function (solved) {
    if (solved) {
        console.log("Solved!")
        PrintDaMaze()
    }
    else
    {
        console.log("Cannot solve. :-(")
    }
} );
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>

<pre id="mazeOutput">
</pre>


5
请查看下面的示例,它将返回给定数字的阶乘,就像我们在许多编程语言中所做的那样。
我使用JavaScript promises实现了下面的示例。

let code = (function(){
 let getFactorial = n =>{
  return new Promise((resolve,reject)=>{
   if(n<=1){
    resolve(1);
   }
   resolve(
    getFactorial(n-1).then(fact => {
     return fact * n;
    })
   )
  });
 }
 return {
  factorial: function(number){
   getFactorial(number).then(
    response => console.log(response)
   )
  }
 }
})();
code.factorial(5);
code.factorial(6);
code.factorial(7);


适用于我。 - Ketan Yekale

4

如果您处于支持 async/await 的环境中(现代环境几乎都支持),您可以编写一个看起来更像我们熟知和喜爱的递归函数模式的 async 函数。由于 XMLHttpRequest 只通过 load 事件检索值(而不是直接暴露出 Promise),因此无法完全避免使用 Promise,但使调用的函数具有递归性质会让它更加熟悉。

比起我最初写这个问题时拥有的四年 JavaScript 经验,我稍微整理了一下代码,但其工作方式基本相同。

// creates a simple Promise that resolves the xhr once it has finished loading
function createXHRPromise(url) {
    return new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();

        // addEventListener('load', ...) is basically the same as setting
        // xhr.onload, but is better practice
        xhr.addEventListener('load', () => resolve(xhr));

        // throw in some error handling so that the calling function 
        // won't hang
        xhr.addEventListener('error', reject);
        xhr.addEventListener('abort', reject);

        xhr.open('HEAD', url, true);
        xhr.send();
    });
}

async function getRedirectUrl(url, maxRetries = 10) {
    if (maxRetries <= 0) {
        throw new Error('Redirected too many times');
    }

    const xhr = await createXHRPromise(url);
    if (xhr.status >= 300 && xhr.status < 400) {
        return getRedirectUrl(xhr.getResponseHeader("Location"), maxRetries - 1);
    } else if (xhr.responseURL && xhr.responseURL !== url) {
        return getRedirectUrl(xhr.responseURL, maxRetries - 1);
    }

    return url;
}

async/await的简要解释

  • async functionPromise语法糖
  • awaitPromise.then()语法糖
  • async function中的returnresolve()语法糖
  • async function中的throwreject()语法糖

如果async function返回另一个async function调用或Promise,那么函数/承诺将在原始调用解决之前解决,就像在Promise模式中解决Promise一样。

因此,可以像原来的问题一样调用getRedirectUrl(someUrl).then(...).catch(...)

需要注意的是,使用XHR解析重定向URL将无法为任何未包含正确CORS标头的URL提供服务。


作为额外的奖励,async/await使得迭代方法变得轻松简单。

async function getRedirectUrl(url, maxRetries = 10) {
    for (let i = 0; i < maxRetries; i++) {
        const xhr = await createXHRPromise(url);
        if (xhr.status >= 300 && xhr.status < 400) {
            url = xhr.getResponseHeader("Location");
        } else if (xhr.responseURL && xhr.responseURL !== url) {
            url = xhr.responseURL;
        } else {
            return url;
        }
    }

    throw new Error('Redirected too many times');
}

另外需要注意的是:现代浏览器有一个fetch()函数,它基本上实现了createXHRPromise()的功能,但更加灵活。虽然它不支持在node中使用,但是有一个名为node-fetch的npm包可以解决这个问题。


0
如果您有一个带有异步调用的嵌套数组结构,则此解决方案(基于先前的答案构建)可能会有所帮助。该示例为在(可能)嵌套的数组中找到的每个值运行setTimeout(),并在完成所有值后解决:
const recursiveCall = (obj) => {
    return new Promise((resolve) => {
        if(obj instanceof Array){
            let cnt = obj.length;
            obj.forEach(el => {
                recursiveCall(el)
                .then(() => {
                    if(!--cnt)return resolve();
                })
                
            });
        } else {
            setTimeout(() => {
                console.log(obj);
                return resolve();
            }, obj);
            
        }
    })
}

recursiveCall([100,50,[10,[200, 300],30],1]).then(() => console.log('done'));

>1
>10
>30
>50
>100
>200
>300
>done

根据你的代码,我猜想你对JavaScript还比较新?如果你感兴趣的话,我写了一些注释示例,展示了使用声明式函数式编程而不是命令式过程式编程的“JavaScript方式编写此代码”。https://codepen.io/dfoverdx/pen/bGreoXK?editors=1111 - dx_over_dt

0
请查看以下关于JavaScript/TypeScript中递归Promise的示例,只有当数字增加到大于13时才会解决Promise。
下面的代码适用于TypeScript,并稍微修改即可用于JavaScript。
async iterate(number: number): Promise<any> {
        return new Promise((resolve, reject) => {
            let number = 0;
            if (number > 13) {
                // recursive terminate condition
                resolve(number);
                return;
            } else {
                number = number + 1;
                // recursive call
                this.iterate(number).then(resolve);
            }

        });
    }




this.iterate().then((resolvedData: any) => {
           // wait until number is not greater than 13
           console.log(resolvedData);
    });

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接